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