diff --git a/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift b/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift index 286ea53b..c3066783 100644 --- a/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift +++ b/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift @@ -25,10 +25,10 @@ // any lifecycle, including `.withdrawn` (a rejected review) and // tombstoned rows — is never proposed again. Rejection is durable. // -// Sensitivity: `DrawerStore.addTunnel` stamps the tunnel with the MAX -// of its endpoints' sensitivities (#57, both legs), so a proposed edge -// touching a restricted drawer is gated by the tunnel sensitivity -// ceiling automatically; the hunter adds nothing to disclose. +// Sensitivity: the hunt applies the same default ceiling as recall +// (`.sensitivityAtMost(.elevated)`) before content screening or +// snippet generation. Proposed tunnels are then additionally stamped +// by `DrawerStore.addTunnel` with the MAX endpoint sensitivity (#57). // // Cost: O(probeLimit · K) index lookups + one batched body hydration // for the surviving candidate pairs — never O(N²) over content. @@ -221,6 +221,13 @@ public extension GeniusLocusKit { for pair in candidatePairs { guard let a = drawersByID[pair.a], let b = drawersByID[pair.b], a.tombstonedAt == nil, b.tombstonedAt == nil else { continue } + // Match BitmapEvaluator's default recall posture: callers + // without an explicit sensitivity grant may only mine the Normal + // tier (normal + elevated). Restricted/secret rows must not be + // screened, proposed, or echoed as borderline snippets. + guard a.adjectiveSensitivity.rawValue <= AdjectiveSensitivity.elevated.rawValue, + b.adjectiveSensitivity.rawValue <= AdjectiveSensitivity.elevated.rawValue + else { continue } // Incremental watermark: at least one side must be new enough. if let watermark = filedAfter, a.filedAt <= watermark, b.filedAt <= watermark { continue } diff --git a/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift b/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift index 871a2a64..c2e252ff 100644 --- a/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift +++ b/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift @@ -37,14 +37,19 @@ struct ContradictionHuntTests { return (kit, handle, vectorStore) } - private func captureFrame(content: String, room: String) -> CaptureFrame { + private func captureFrame( + content: String, + room: String, + sensitivity: AdjectiveSensitivity = .normal + ) -> CaptureFrame { CaptureFrame( content: content, channel: .typed, room: room, latticeAnchor: LatticeAnchor(udcCode: "004"), addedBy: "hunt-tests", - embeddingModelID: Self.modelID + embeddingModelID: Self.modelID, + sensitivity: sensitivity ) } @@ -156,6 +161,32 @@ struct ContradictionHuntTests { #expect(contradicts.isEmpty) } + @Test("restricted and secret candidates are excluded by the default hunt sensitivity ceiling") + func protectedCandidatesAreExcludedByDefaultCeiling() async throws { + let (kit, handle, vectorStore) = try await makeKit() + let normal = try await kit.capture( + handle, + captureFrame(content: "Bob lives in Paris", room: "study")) + let secret = try await kit.capture( + handle, + captureFrame( + content: "Bob does not live in Paris SECRET-DO-NOT-ECHO", + room: "study", + sensitivity: .secret)) + + for drawer in [normal, secret] { + try await vectorStore.addVector( + itemID: drawer.id, engram: near, modelID: Self.modelID, + modelVersion: "1.0", filedAt: Self.t0) + } + + let report = try await kit.huntContradictions(in: handle, now: Self.t0) + + #expect(report.proposed.isEmpty) + #expect(report.borderline.isEmpty) + #expect(report.pairsScreened == 0) + } + @Test("unrelated content proposes nothing; missing vector store is reported") func guards() async throws { let (kit, handle, vectorStore) = try await makeKit() diff --git a/packages/kits/GeniusLocusKit/rust/src/coordinator.rs b/packages/kits/GeniusLocusKit/rust/src/coordinator.rs index 0ad5d66c..d3253e95 100644 --- a/packages/kits/GeniusLocusKit/rust/src/coordinator.rs +++ b/packages/kits/GeniusLocusKit/rust/src/coordinator.rs @@ -2839,6 +2839,17 @@ impl EstateCoordinator { if a.tombstoned_at.is_some() || b.tombstoned_at.is_some() { continue; } + // Match BitmapEvaluator's default recall posture: callers + // without an explicit sensitivity grant may only mine the Normal + // tier (normal + elevated). Restricted/secret rows must not be + // screened, proposed, or echoed as borderline snippets. + if a.adjective_sensitivity().raw_value() + > locus_kit::adjectives::AdjectiveSensitivity::Elevated.raw_value() + || b.adjective_sensitivity().raw_value() + > locus_kit::adjectives::AdjectiveSensitivity::Elevated.raw_value() + { + continue; + } // Incremental watermark: at least one side must be new enough. if let Some(watermark) = filed_after { if a.filed_at <= watermark && b.filed_at <= watermark { @@ -9087,6 +9098,31 @@ mod tests { assert_eq!(contradicts, 0); } + #[test] + fn hunt_protected_candidates_are_excluded_by_default_ceiling() { + let (coord, h, vs) = open_one_with_vectors(); + let near = hunt_near(); + let normal = coord + .capture(&h, cap_frame("Bob lives in Paris"), NOW) + .expect("capture normal"); + let mut secret_frame = cap_frame("Bob does not live in Paris SECRET-DO-NOT-ECHO"); + secret_frame.sensitivity = locus_kit::adjectives::AdjectiveSensitivity::Secret; + let secret = coord + .capture(&h, secret_frame, NOW) + .expect("capture secret"); + for drawer in [&normal, &secret] { + vs.add_vector(&drawer.id, &near, "minilm-v6", "1.0", NOW) + .expect("add vector"); + } + + let report = coord + .hunt_contradictions(&h, "minilm-v6", 50, None, 64, NOW) + .expect("hunt"); + assert!(report.proposed.is_empty()); + assert!(report.borderline.is_empty()); + assert_eq!(report.pairs_screened, 0); + } + #[test] fn hunt_guards_and_missing_vector_store() { let (coord, h, vs) = open_one_with_vectors();