diff --git a/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift b/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift index 6cd69dbe..a021bd0d 100644 --- a/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift +++ b/packages/kits/GeniusLocusKit/Sources/GeniusLocusKit/Brain/ContradictionHunt.swift @@ -25,10 +25,9 @@ // 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 uses raw vector candidates and hydrated bodies, +// so it applies the default recall sensitivity ceiling itself. Drawers +// above `.elevated` are skipped before cue screening or snippet creation. // // Cost: O(probeLimit · K) index lookups + one batched body hydration // for the surviving candidate pairs — never O(N²) over content. @@ -168,6 +167,7 @@ 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 } + guard Self.isHuntRecallEligible(a), Self.isHuntRecallEligible(b) else { continue } // Incremental watermark: at least one side must be new enough. if let watermark = filedAfter, a.filedAt <= watermark, b.filedAt <= watermark { continue } @@ -229,6 +229,12 @@ public extension GeniusLocusKit { deduplicated: deduplicated) } + /// Default MCP hunt disclosure ceiling, matching recall's default + /// `.sensitivityAtMost(.elevated)` posture. + internal static func isHuntRecallEligible(_ drawer: Drawer) -> Bool { + drawer.adjectiveSensitivity.rawValue <= AdjectiveSensitivity.elevated.rawValue + } + /// Canonical unordered pair key — lexicographically smaller ID first. internal static func pairKey(_ a: String, _ b: String) -> String { a < b ? "\(a)||\(b)" : "\(b)||\(a)" diff --git a/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift b/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift index a264ee5b..0a013ee8 100644 --- a/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift +++ b/packages/kits/GeniusLocusKit/Tests/GeniusLocusKitTests/ContradictionHuntTests.swift @@ -36,14 +36,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 ) } @@ -55,9 +60,12 @@ struct ContradictionHuntTests { engram: Fingerprint256, kit: GeniusLocusKit, handle: EstateHandle, - vectorStore: VectorStore + vectorStore: VectorStore, + sensitivity: AdjectiveSensitivity = .normal ) async throws -> Drawer { - let drawer = try await kit.capture(handle, captureFrame(content: content, room: "study")) + let drawer = try await kit.capture( + handle, + captureFrame(content: content, room: "study", sensitivity: sensitivity)) try await vectorStore.addVector( itemID: drawer.id, engram: engram, modelID: Self.modelID, modelVersion: "1.0", filedAt: Self.t0) @@ -155,6 +163,25 @@ struct ContradictionHuntTests { #expect(contradicts.isEmpty) } + @Test("restricted and secret drawers are excluded before snippet disclosure") + func restrictedAndSecretDrawersAreExcluded() async throws { + let (kit, handle, vectorStore) = try await makeKit() + try await plant( + "Bob lives in Paris", + engram: near, kit: kit, handle: handle, vectorStore: vectorStore, + sensitivity: .restricted) + try await plant( + "Bob does not live in Paris", + engram: near, kit: kit, handle: handle, vectorStore: vectorStore, + sensitivity: .secret) + + 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 8938cb54..66562555 100644 --- a/packages/kits/GeniusLocusKit/rust/src/coordinator.rs +++ b/packages/kits/GeniusLocusKit/rust/src/coordinator.rs @@ -2759,6 +2759,13 @@ impl EstateCoordinator { if a.tombstoned_at.is_some() || b.tombstoned_at.is_some() { continue; } + let max_hunt_sensitivity = + locus_kit::adjectives::AdjectiveSensitivity::Elevated.raw_value(); + if a.adjective_sensitivity().raw_value() > max_hunt_sensitivity + || b.adjective_sensitivity().raw_value() > max_hunt_sensitivity + { + 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 { @@ -8910,7 +8917,27 @@ mod tests { content: &str, engram: &engram_lib::Engram, ) -> locus_kit::drawer::Drawer { - let drawer = coord.capture(h, cap_frame(content), NOW).expect("capture"); + hunt_plant_with_sensitivity( + coord, + h, + vs, + content, + engram, + locus_kit::adjectives::AdjectiveSensitivity::Normal, + ) + } + + fn hunt_plant_with_sensitivity( + coord: &EstateCoordinator, + h: &EstateHandle, + vs: &VectorStore, + content: &str, + engram: &engram_lib::Engram, + sensitivity: locus_kit::adjectives::AdjectiveSensitivity, + ) -> locus_kit::drawer::Drawer { + let mut frame = cap_frame(content); + frame.sensitivity = sensitivity; + let drawer = coord.capture(h, frame, NOW).expect("capture"); vs.add_vector(&drawer.id, engram, "minilm-v6", "1.0", NOW) .expect("add_vector"); drawer @@ -9007,6 +9034,35 @@ mod tests { assert_eq!(contradicts, 0); } + #[test] + fn hunt_restricted_and_secret_drawers_are_excluded() { + let (coord, h, vs) = open_one_with_vectors(); + let near = hunt_near(); + hunt_plant_with_sensitivity( + &coord, + &h, + &vs, + "Bob lives in Paris", + &near, + locus_kit::adjectives::AdjectiveSensitivity::Restricted, + ); + hunt_plant_with_sensitivity( + &coord, + &h, + &vs, + "Bob does not live in Paris", + &near, + locus_kit::adjectives::AdjectiveSensitivity::Secret, + ); + + 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();