feat(adr-118/p3.3): CoherenceGate hysteresis + 5s debounce — 85/85 GREEN

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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 15:07:40 -04:00
parent 2e7f67c933
commit 8b79d951c1
3 changed files with 274 additions and 0 deletions

View File

@ -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<GateAction> {
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,
}
}

View File

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

View File

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