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:
parent
8b79d951c1
commit
ae6fd75095
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue