Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/tests/coherence_gate.rs
T
ruv 8b79d951c1 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>
2026-05-24 15:07:40 -04:00

135 lines
4.7 KiB
Rust

//! 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);
}