feat(adr-118/p3.4): SoulMatchOracle + Recalibrate exemption (93/93 GREEN)

Iter 12. Wires the ADR-121 §2.6 Recalibrate exemption: when an enrolled
person_id matches the current high-separability cluster, the gate
downgrades the would-be Recalibrate to PredictOnly. The high score is
the *intended* outcome of a Soul Signature match, not an attacker-grade
sniffer arrival — so site_salt rotation is suppressed.

Added (no_std-compatible):
- src/coherence_gate.rs additions:
  * MatchOutcome enum: Match { person_id: u64 } | NotEnrolled | Suppressed
  * SoulMatchOracle trait with matches_enrolled() -> MatchOutcome
  * NullOracle (default-constructible, always reports NotEnrolled)
  * CoherenceGate::evaluate_with_oracle(score, ts, &O: SoulMatchOracle)
    — same hysteresis/debounce as evaluate(), but downgrades Recalibrate
    to PredictOnly when oracle returns Match { .. }
  * Refactored evaluate(): extracted advance_state(target, ts) shared with
    evaluate_with_oracle. evaluate is now a 4-line wrapper.
- pub use MatchOutcome, NullOracle, SoulMatchOracle from lib.rs

tests/soul_match_oracle.rs (8 named tests, all green):
  null_oracle_matches_default_evaluate_behavior
    (parameterized over 5 score points; oracle-aware and oracle-free
     gates produce identical trajectories)
  match_outcome_downgrades_recalibrate_to_predict_only
    (score=0.95 pends PredictOnly instead of Recalibrate)
  match_exemption_promotes_predict_only_after_debounce_not_recalibrate
    (after DEBOUNCE_NS, current is PredictOnly — never Recalibrate)
  match_outcome_does_not_affect_lower_actions
    (Reject pending stays Reject; oracle only intercepts Recalibrate)
  suppressed_outcome_does_not_exempt_recalibrate
    (Suppressed is functionally equivalent to NotEnrolled at the gate)
  not_enrolled_outcome_does_not_exempt_recalibrate
  match_outcome_carries_person_id
  null_oracle_default_constructor_works

ACs progressed:
- ADR-121 §2.6 fully covered as a stateless integration point — the
  hook is in place for the `--features soul-signature` Soul Signature
  crate (TBD) to plug in a real RaBitQ-backed oracle.
- ADR-118 §1.4 Soul Signature companion contract is now structurally
  enforced at the gate boundary: enrolled subjects do not trigger
  site_salt rotation; everyone else does.

Test config:
- cargo test --no-default-features → 64 passed (56 + 8)
- cargo test                       → 93 passed (85 + 8)

Out of scope (next iter target):
- BfldEvent struct (ADR-121 §2.1 output event JSON) — the downstream
  consumer of GateAction. Pairs the gate decision with presence/motion/
  person_count sensing fields.
- Optional: connect SoulMatchOracle into the actual `--features
  soul-signature` build (compile-time gate around a re-export).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 15:17:24 -04:00
parent 8b79d951c1
commit ae6fd75095
3 changed files with 169 additions and 5 deletions

View File

@ -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<O: SoulMatchOracle>(
&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()

View File

@ -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};

View File

@ -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);
}