From 8b79d951c1485f43a6d897090bf03d288302151b Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 15:07:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-118/p3.3):=20CoherenceGate=20hysteresi?= =?UTF-8?q?s=20+=205s=20debounce=20=E2=80=94=2085/85=20GREEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 11. Wraps the stateless GateAction classifier from iter 10 with two stabilizing mechanisms per ADR-121 §2.5: * ±0.05 HYSTERESIS — a score must clear the current band's edge by HYSTERESIS before the gate considers the next band. * 5-second DEBOUNCE_NS — a different action must persist that long before it becomes current; returning to the current band cancels it. Added (no_std-compatible): - src/coherence_gate.rs: * HYSTERESIS const (0.05) + DEBOUNCE_NS const (5_000_000_000) * CoherenceGate { current, pending: Option<(GateAction, u64)> } * new() / Default / current() / pending() (diagnostic accessors) * evaluate(score, timestamp_ns) -> GateAction Algorithm: compute effective_target via per-direction hysteresis check, promote pending after DEBOUNCE_NS elapsed, cancel pending on return to current band, reset debounce clock if pending target changes * Private helpers effective_target / action_idx / upper_edge_of / lower_edge_of - pub use CoherenceGate from lib.rs tests/coherence_gate.rs (13 named tests, all green): fresh_gate_starts_in_accept_with_no_pending low_score_stays_in_accept_with_no_pending score_just_past_boundary_but_within_hysteresis_does_not_pend (0.52: above 0.5 but inside hysteresis envelope — no pending) score_clearly_past_hysteresis_starts_pending (0.6: past 0.55 hysteresis edge — pending PredictOnly registered) pending_action_promotes_after_full_debounce pending_action_does_not_promote_before_debounce (verified at DEBOUNCE_NS - 1) returning_to_current_band_cancels_pending changing_pending_target_resets_the_debounce_clock (PredictOnly pending at t=0, then Recalibrate at t=1s — clock resets, must wait until t=1s+DEBOUNCE_NS before Recalibrate is current) downward_transitions_also_require_hysteresis (from PredictOnly, 0.48 stays put; 0.44 pends Accept) spike_to_one_then_back_to_zero_never_promotes_to_recalibrate (transient spike + return to baseline produces no transition) boundary_value_with_hysteresis_does_not_promote (0.5+0.05-epsilon) boundary_value_at_hysteresis_exact_does_pend (0.5+0.05) nan_score_stays_in_current_action_with_no_pending ACs progressed: - ADR-121 AC4 — Recalibrate fires when score >= 0.9 for >= DEBOUNCE_NS (5s). The debounce test above directly exercises this. - ADR-121 AC5 — hysteresis test confirms action does not oscillate across ± 0.05 of a threshold within a 5-second window. Test config: - cargo test --no-default-features → 56 passed (43 + 13) - cargo test → 85 passed (72 + 13) Out of scope (next iter target): - SoulMatchOracle stub trait (ADR-121 §2.6) + Recalibrate exemption — when --features soul-signature is enabled and the oracle reports a known enrolled person_id match, the gate downgrades Recalibrate → PredictOnly. - BfldEvent struct (ADR-121 §2.1 output event) — first downstream consumer of the gate action. Co-Authored-By: claude-flow --- .../wifi-densepose-bfld/src/coherence_gate.rs | 138 ++++++++++++++++++ v2/crates/wifi-densepose-bfld/src/lib.rs | 2 + .../tests/coherence_gate.rs | 134 +++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 v2/crates/wifi-densepose-bfld/src/coherence_gate.rs create mode 100644 v2/crates/wifi-densepose-bfld/tests/coherence_gate.rs diff --git a/v2/crates/wifi-densepose-bfld/src/coherence_gate.rs b/v2/crates/wifi-densepose-bfld/src/coherence_gate.rs new file mode 100644 index 00000000..c3f2bf9d --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/coherence_gate.rs @@ -0,0 +1,138 @@ +//! Stateful coherence gate with hysteresis + debounce. ADR-121 §2.4 + §2.5. +//! +//! Wraps the stateless [`crate::identity_risk::GateAction::from_score`] band +//! classifier with two stabilizing mechanisms: +//! +//! - **Hysteresis (±0.05)** — a score must clear the current band's edge by +//! `HYSTERESIS` before the gate considers the next band. +//! - **Debounce (5 seconds)** — once a different action is "pending", it must +//! persist for `DEBOUNCE_NS` of wall time before it becomes the current +//! action. Returning to the current band cancels the pending action. +//! +//! Together these prevent the gate from flapping when the risk score +//! oscillates near a boundary or spikes briefly on a single bad frame. + +use crate::identity_risk::{ + GateAction, PREDICT_ONLY_THRESHOLD, RECALIBRATE_THRESHOLD, REJECT_THRESHOLD, +}; + +/// Symmetric hysteresis band applied to every action boundary. +pub const HYSTERESIS: f32 = 0.05; + +/// Pending action must persist this long (in nanoseconds) before promotion. +pub const DEBOUNCE_NS: u64 = 5_000_000_000; + +/// Stateful gate. Construct with `CoherenceGate::new()` and call +/// `evaluate(score, timestamp_ns)` per frame to obtain the active action. +pub struct CoherenceGate { + current: GateAction, + pending: Option<(GateAction, u64)>, +} + +impl CoherenceGate { + /// Build a fresh gate, starting in [`GateAction::Accept`] with no pending + /// transition. + #[must_use] + pub const fn new() -> Self { + Self { + current: GateAction::Accept, + pending: None, + } + } + + /// Current published action — does **not** advance any state. + #[must_use] + pub const fn current(&self) -> GateAction { + self.current + } + + /// Pending action (if any) — useful for diagnostics / dashboards. + #[must_use] + pub const fn pending(&self) -> Option { + match self.pending { + Some((a, _)) => Some(a), + None => None, + } + } + + /// Drive the gate with a fresh score reading and a monotonic timestamp. + /// 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); + 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)); + } + } + self.current + } +} + +impl Default for CoherenceGate { + fn default() -> Self { + Self::new() + } +} + +fn effective_target(score: f32, current: GateAction) -> GateAction { + let raw = GateAction::from_score(score); + if raw == current { + return current; + } + if action_idx(raw) > action_idx(current) { + // Crossing upward — score must clear current's upper edge + HYSTERESIS. + if score >= upper_edge_of(current) + HYSTERESIS { + raw + } else { + current + } + } else { + // Crossing downward — score must fall below current's lower edge - HYSTERESIS. + if score < lower_edge_of(current) - HYSTERESIS { + raw + } else { + current + } + } +} + +const fn action_idx(a: GateAction) -> u8 { + match a { + GateAction::Accept => 0, + GateAction::PredictOnly => 1, + GateAction::Reject => 2, + GateAction::Recalibrate => 3, + } +} + +fn upper_edge_of(a: GateAction) -> f32 { + match a { + GateAction::Accept => PREDICT_ONLY_THRESHOLD, + GateAction::PredictOnly => REJECT_THRESHOLD, + GateAction::Reject => RECALIBRATE_THRESHOLD, + GateAction::Recalibrate => f32::INFINITY, + } +} + +fn lower_edge_of(a: GateAction) -> f32 { + match a { + GateAction::Accept => f32::NEG_INFINITY, + GateAction::PredictOnly => PREDICT_ONLY_THRESHOLD, + GateAction::Reject => REJECT_THRESHOLD, + GateAction::Recalibrate => RECALIBRATE_THRESHOLD, + } +} diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index c19226f6..e3bd6baf 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -13,6 +13,7 @@ #![cfg_attr(not(feature = "std"), no_std)] +pub mod coherence_gate; pub mod embedding; pub mod embedding_ring; pub mod frame; @@ -23,6 +24,7 @@ pub mod payload; pub mod privacy_gate; pub mod sink; +pub use coherence_gate::CoherenceGate; 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/coherence_gate.rs b/v2/crates/wifi-densepose-bfld/tests/coherence_gate.rs new file mode 100644 index 00000000..009b910a --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/coherence_gate.rs @@ -0,0 +1,134 @@ +//! Acceptance tests for ADR-121 §2.5 — `CoherenceGate` hysteresis + debounce. + +use wifi_densepose_bfld::coherence_gate::{DEBOUNCE_NS, HYSTERESIS}; +use wifi_densepose_bfld::{CoherenceGate, GateAction}; + +#[test] +fn fresh_gate_starts_in_accept_with_no_pending() { + let g = CoherenceGate::new(); + assert_eq!(g.current(), GateAction::Accept); + assert_eq!(g.pending(), None); +} + +#[test] +fn low_score_stays_in_accept_with_no_pending() { + let mut g = CoherenceGate::new(); + let out = g.evaluate(0.3, 0); + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), None); +} + +#[test] +fn score_just_past_boundary_but_within_hysteresis_does_not_pend() { + // current = Accept, upper edge = 0.5, hysteresis = 0.05 → need >= 0.55 to start pending. + let mut g = CoherenceGate::new(); + let out = g.evaluate(0.52, 0); + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), None, "0.52 must not start a pending transition"); +} + +#[test] +fn score_clearly_past_hysteresis_starts_pending() { + let mut g = CoherenceGate::new(); + let out = g.evaluate(0.6, 0); + assert_eq!(out, GateAction::Accept, "still Accept until debounce elapses"); + assert_eq!(g.pending(), Some(GateAction::PredictOnly)); +} + +#[test] +fn pending_action_promotes_after_full_debounce() { + let mut g = CoherenceGate::new(); + g.evaluate(0.6, 0); + assert_eq!(g.current(), GateAction::Accept); + let out = g.evaluate(0.6, DEBOUNCE_NS); + assert_eq!(out, GateAction::PredictOnly); + assert_eq!(g.pending(), None); +} + +#[test] +fn pending_action_does_not_promote_before_debounce() { + let mut g = CoherenceGate::new(); + g.evaluate(0.6, 0); + let out = g.evaluate(0.6, DEBOUNCE_NS - 1); + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), Some(GateAction::PredictOnly)); +} + +#[test] +fn returning_to_current_band_cancels_pending() { + let mut g = CoherenceGate::new(); + g.evaluate(0.6, 0); // pending PredictOnly + let out = g.evaluate(0.4, 1_000_000_000); // 1s later, back in Accept band + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), None, "returning to current band cancels pending"); +} + +#[test] +fn changing_pending_target_resets_the_debounce_clock() { + let mut g = CoherenceGate::new(); + g.evaluate(0.6, 0); // pending PredictOnly at t=0 + g.evaluate(0.95, 1_000_000_000); // pending Recalibrate at t=1s (clock reset) + // At t=1s + DEBOUNCE_NS - 1, still not promoted (Recalibrate pending since 1s) + let out = g.evaluate(0.95, 1_000_000_000 + DEBOUNCE_NS - 1); + assert_eq!(out, GateAction::Accept); + // At t=1s + DEBOUNCE_NS, promoted to Recalibrate + let out = g.evaluate(0.95, 1_000_000_000 + DEBOUNCE_NS); + assert_eq!(out, GateAction::Recalibrate); +} + +#[test] +fn downward_transitions_also_require_hysteresis() { + let mut g = CoherenceGate::new(); + // Force gate into PredictOnly state. + g.evaluate(0.6, 0); + g.evaluate(0.6, DEBOUNCE_NS); + assert_eq!(g.current(), GateAction::PredictOnly); + + // 0.48 is below 0.5 but only by 0.02 — within hysteresis envelope. + let out = g.evaluate(0.48, 2 * DEBOUNCE_NS); + assert_eq!(out, GateAction::PredictOnly); + assert_eq!(g.pending(), None, "0.48 is within downward hysteresis"); + + // 0.44 is below 0.5 - 0.05 = 0.45 → starts pending Accept. + g.evaluate(0.44, 3 * DEBOUNCE_NS); + assert_eq!(g.pending(), Some(GateAction::Accept)); +} + +#[test] +fn spike_to_one_then_back_to_zero_never_promotes_to_recalibrate() { + let mut g = CoherenceGate::new(); + g.evaluate(1.0, 0); // pending Recalibrate at t=0 + // 1 second later score is back to 0 — cancel pending. + let out = g.evaluate(0.0, 1_000_000_000); + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), None); + // Even waiting longer, the gate stays in Accept. + let out = g.evaluate(0.0, 100 * DEBOUNCE_NS); + assert_eq!(out, GateAction::Accept); +} + +#[test] +fn boundary_value_with_hysteresis_does_not_promote() { + // Edge: current=Accept, score = upper_edge + HYSTERESIS - epsilon (just below). + let mut g = CoherenceGate::new(); + let out = g.evaluate(0.5 + HYSTERESIS - 0.0001, 0); + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), None); +} + +#[test] +fn boundary_value_at_hysteresis_exact_does_pend() { + let mut g = CoherenceGate::new(); + let out = g.evaluate(0.5 + HYSTERESIS, 0); + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), Some(GateAction::PredictOnly)); +} + +#[test] +fn nan_score_stays_in_current_action_with_no_pending() { + let mut g = CoherenceGate::new(); + let out = g.evaluate(f32::NAN, 0); + // NaN maps to Accept via from_score; gate stays in Accept and clears pending. + assert_eq!(out, GateAction::Accept); + assert_eq!(g.pending(), None); +}