diff --git a/v2/crates/wifi-densepose-bfld/src/coherence_gate.rs b/v2/crates/wifi-densepose-bfld/src/coherence_gate.rs index c3f2bf9d..fb079c1c 100644 --- a/v2/crates/wifi-densepose-bfld/src/coherence_gate.rs +++ b/v2/crates/wifi-densepose-bfld/src/coherence_gate.rs @@ -59,22 +59,44 @@ impl CoherenceGate { /// Returns the currently-active action after the update. pub fn evaluate(&mut self, score: f32, timestamp_ns: u64) -> GateAction { let target = effective_target(score, self.current); + self.advance_state(target, timestamp_ns) + } + + /// Variant of [`Self::evaluate`] that consults a [`SoulMatchOracle`]. + /// When the gate would transition to [`GateAction::Recalibrate`] and the + /// oracle reports a [`MatchOutcome::Match`], the target is downgraded to + /// [`GateAction::PredictOnly`] — the high score is the *intended* outcome + /// of a successful Soul Signature match and should not rotate `site_salt`. + /// See ADR-121 §2.6. + pub fn evaluate_with_oracle( + &mut self, + score: f32, + timestamp_ns: u64, + oracle: &O, + ) -> GateAction { + let mut target = effective_target(score, self.current); + if target == GateAction::Recalibrate { + if let MatchOutcome::Match { .. } = oracle.matches_enrolled() { + target = GateAction::PredictOnly; + } + } + self.advance_state(target, timestamp_ns) + } + + /// Shared hysteresis-debounce state-machine driver. + fn advance_state(&mut self, target: GateAction, timestamp_ns: u64) -> GateAction { if target == self.current { - // Score is back inside (or never left) the current band's hysteresis - // envelope. Cancel any pending transition. self.pending = None; return self.current; } match self.pending { Some((pending, since)) if pending == target => { - // Same target as before — check whether debounce has elapsed. if timestamp_ns.saturating_sub(since) >= DEBOUNCE_NS { self.current = target; self.pending = None; } } _ => { - // Either no pending, or pending differs from current target. self.pending = Some((target, timestamp_ns)); } } @@ -82,6 +104,50 @@ impl CoherenceGate { } } +// --- SoulMatchOracle ------------------------------------------------------- +// +// The trait + MatchOutcome enum live here so the Recalibrate exemption is +// addressable without pulling in any Soul Signature implementation crate. +// Downstream crates compiled with `--features soul-signature` provide their +// own oracle impl; otherwise `NullOracle` is the sensible default. + +/// Result of an oracle lookup. ADR-121 §2.6. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchOutcome { + /// The current high-separability cluster matches an enrolled subject — + /// the gate must NOT recalibrate, because the match is the intended outcome. + Match { + /// Opaque per-deployment person identifier. + person_id: u64, + }, + /// No enrolled subject matches the cluster — proceed with normal gating. + NotEnrolled, + /// Soul Signature is disabled in this deployment (e.g., `privacy_class = 3`). + /// Treated identically to `NotEnrolled` by the gate. + Suppressed, +} + +/// Oracle hook consulted before the gate fires `Recalibrate`. Implementations +/// live in the Soul Signature integration crate; this crate ships only the +/// trait and a no-op fallback ([`NullOracle`]). +pub trait SoulMatchOracle { + /// Return the current match outcome. May be called once per evaluation + /// when the gate is about to fire `Recalibrate`; implementations should + /// be cheap (the iter-10 budget is < 1 ms via RaBitQ; see ADR-121 §2.7). + fn matches_enrolled(&self) -> MatchOutcome; +} + +/// No-op oracle — always reports `NotEnrolled`. Used when Soul Signature is +/// not enabled, so the gate behaves identically to [`CoherenceGate::evaluate`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct NullOracle; + +impl SoulMatchOracle for NullOracle { + fn matches_enrolled(&self) -> MatchOutcome { + MatchOutcome::NotEnrolled + } +} + impl Default for CoherenceGate { fn default() -> Self { Self::new() diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index e3bd6baf..933aca6f 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -24,7 +24,7 @@ pub mod payload; pub mod privacy_gate; pub mod sink; -pub use coherence_gate::CoherenceGate; +pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle}; pub use embedding::{IdentityEmbedding, EMBEDDING_DIM}; pub use embedding_ring::{EmbeddingRing, RING_CAPACITY}; pub use identity_risk::{score as identity_risk_score, GateAction}; diff --git a/v2/crates/wifi-densepose-bfld/tests/soul_match_oracle.rs b/v2/crates/wifi-densepose-bfld/tests/soul_match_oracle.rs new file mode 100644 index 00000000..c5b8a98e --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/soul_match_oracle.rs @@ -0,0 +1,98 @@ +//! Acceptance tests for ADR-121 §2.6 — `SoulMatchOracle` Recalibrate exemption. + +use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS; +use wifi_densepose_bfld::{ + CoherenceGate, GateAction, MatchOutcome, NullOracle, SoulMatchOracle, +}; + +/// Oracle that always claims an enrolled match. +struct AlwaysMatch; +impl SoulMatchOracle for AlwaysMatch { + fn matches_enrolled(&self) -> MatchOutcome { + MatchOutcome::Match { person_id: 0x4242_4242 } + } +} + +/// Oracle that reports suppressed (class-3 deployment). +struct AlwaysSuppressed; +impl SoulMatchOracle for AlwaysSuppressed { + fn matches_enrolled(&self) -> MatchOutcome { + MatchOutcome::Suppressed + } +} + +#[test] +fn null_oracle_matches_default_evaluate_behavior() { + let mut a = CoherenceGate::new(); + let mut b = CoherenceGate::new(); + let oracle = NullOracle; + for (i, score) in [0.1, 0.4, 0.6, 0.8, 0.95].iter().enumerate() { + let ts = (i as u64) * 2 * DEBOUNCE_NS; + assert_eq!(a.evaluate(*score, ts), b.evaluate_with_oracle(*score, ts, &oracle)); + } +} + +#[test] +fn match_outcome_downgrades_recalibrate_to_predict_only() { + let mut g = CoherenceGate::new(); + let oracle = AlwaysMatch; + // Score = 0.95 would normally pend Recalibrate. With AlwaysMatch oracle, + // it pends PredictOnly instead. + g.evaluate_with_oracle(0.95, 0, &oracle); + assert_eq!(g.pending(), Some(GateAction::PredictOnly)); +} + +#[test] +fn match_exemption_promotes_predict_only_after_debounce_not_recalibrate() { + let mut g = CoherenceGate::new(); + let oracle = AlwaysMatch; + g.evaluate_with_oracle(0.95, 0, &oracle); + let out = g.evaluate_with_oracle(0.95, DEBOUNCE_NS, &oracle); + assert_eq!(out, GateAction::PredictOnly); + assert_ne!(out, GateAction::Recalibrate, "Match must prevent Recalibrate"); +} + +#[test] +fn match_outcome_does_not_affect_lower_actions() { + let mut g = CoherenceGate::new(); + let oracle = AlwaysMatch; + // Score in the Reject band — oracle exemption does NOT apply (only to Recalibrate). + g.evaluate_with_oracle(0.8, 0, &oracle); + assert_eq!(g.pending(), Some(GateAction::Reject)); + + // Run to debounce — current must become Reject, not PredictOnly. + let out = g.evaluate_with_oracle(0.8, DEBOUNCE_NS, &oracle); + assert_eq!(out, GateAction::Reject); +} + +#[test] +fn suppressed_outcome_does_not_exempt_recalibrate() { + let mut g = CoherenceGate::new(); + let oracle = AlwaysSuppressed; + g.evaluate_with_oracle(0.95, 0, &oracle); + // Suppressed is functionally equivalent to NotEnrolled — Recalibrate stays pending. + assert_eq!(g.pending(), Some(GateAction::Recalibrate)); +} + +#[test] +fn not_enrolled_outcome_does_not_exempt_recalibrate() { + let mut g = CoherenceGate::new(); + let oracle = NullOracle; // always NotEnrolled + g.evaluate_with_oracle(0.95, 0, &oracle); + assert_eq!(g.pending(), Some(GateAction::Recalibrate)); +} + +#[test] +fn match_outcome_carries_person_id() { + let outcome = AlwaysMatch.matches_enrolled(); + match outcome { + MatchOutcome::Match { person_id } => assert_eq!(person_id, 0x4242_4242), + other => panic!("expected Match, got {other:?}"), + } +} + +#[test] +fn null_oracle_default_constructor_works() { + let oracle = NullOracle; + assert_eq!(oracle.matches_enrolled(), MatchOutcome::NotEnrolled); +}