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