feat(adr-118/p3.4): CoherenceGate clock-skew resilience (303/303 GREEN)

Iter 44. Pins the gate's saturating_sub-based debounce as safe under
clock perturbation. NTP rollback, system-clock adjustment, monotonic-
source switch — all can produce a backward `timestamp_ns` between
calls. The gate must NOT promote spuriously on backward jumps and
MUST NOT panic on identical / zero / u64::MAX-ish timestamps.

Added (in tests/gate_clock_skew.rs, no_std-compatible):
- 7 named tests, all green:

  backward_jump_after_pending_does_not_promote_prematurely
    Pending at t = DEBOUNCE_NS + 100; backward jump to t = 0.
    saturating_sub(0, DEBOUNCE_NS+100) = 0 < DEBOUNCE_NS → no promotion.

  forward_recovery_after_backward_jump_still_promotes_correctly
    Backward jump doesn't corrupt the pending `since` stamp; once wall
    time advances past since + DEBOUNCE_NS, promotion fires normally.

  identical_timestamps_across_repeated_polls_do_not_progress_state
    Five identical timestamps in a row — gate never promotes; both
    current and pending remain stable. Important for HA dashboards
    polling at >1Hz: the polling itself must not cause transitions.

  backward_jump_with_no_pending_is_a_noop
    Edge: no pending in flight, backward jump — gate stays clean.

  very_large_forward_jump_promotes_but_does_not_panic
    Stress: t = u64::MAX/2 jump. No overflow, no panic, promotes.

  backward_then_forward_into_different_action_band_resets_pending_correctly
    More subtle: pending PredictOnly → backward jump WITH a different
    score (recalibrate-grade) — pending target changes, debounce
    clock resets to the new (smaller) timestamp; forward by DEBOUNCE_NS
    promotes to Recalibrate.

  no_panic_on_zero_timestamp_with_predict_only_pending
    Regression guard: a poorly-initialized monotonic clock could
    deliver t=0 as the first sample. Gate must not panic.

ADR-124 status (iter step 0 sibling check):
- docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md unchanged
  at 431 lines. SENSE-BRIDGE scope remains orthogonal.

ACs progressed:
- ADR-121 §2.5 debounce property — saturating_sub usage now has a
  regression test. A future PR that swaps to plain `-` (panic on
  underflow) fires `no_panic_on_zero_timestamp_with_predict_only_pending`.
- ADR-118 §2.1 operator-facing diagnostic safety — current_gate_action
  polled at the same timestamp from a Prometheus exporter or HA
  dashboard cannot cause unintended state transitions.

Test config:
- cargo test --no-default-features → 97 passed (90 + 7 no_std-compat)
- cargo test                       → 303 passed (296 + 7)

Out of scope (next iter target):
- PR-readiness pivot still pending: CHANGELOG, witness bundle,
  AC closeout table. External-resource-gated work (KIT BFId,
  Pi5/Nexmon) still skipped.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 19:32:23 -04:00
parent 08d5cce6ad
commit 6aa5eb17e1
1 changed files with 120 additions and 0 deletions

View File

@ -0,0 +1,120 @@
//! `CoherenceGate` clock-skew resilience. The gate's debounce uses
//! `timestamp_ns.saturating_sub(since)` so a backward time jump (NTP
//! rollback, system-clock adjustment, monotonic-source switch) yields a
//! zero-elapsed delta — the pending action stays pending, the current
//! action stays current. No spurious transitions either direction.
//!
//! This iter pins the property at the public CoherenceGate surface so a
//! future refactor that swaps `saturating_sub` for a plain `-` (which
//! would panic on underflow) fires loud.
use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS;
use wifi_densepose_bfld::{CoherenceGate, GateAction};
// Score that puts the gate into PredictOnly band after debounce.
fn predict_only_grade() -> f32 {
0.6
}
// Score that puts the gate into Recalibrate band after debounce.
fn recalibrate_grade() -> f32 {
0.95
}
fn low_risk() -> f32 {
0.1
}
#[test]
fn backward_jump_after_pending_does_not_promote_prematurely() {
let mut g = CoherenceGate::new();
// Pending PredictOnly at t = DEBOUNCE_NS + 100 (so a forward DEBOUNCE_NS
// elapsed time would have promoted, but we'll jump backward instead).
g.evaluate(predict_only_grade(), DEBOUNCE_NS + 100);
assert_eq!(g.current(), GateAction::Accept);
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
// Backward jump to t = 0. saturating_sub(0, DEBOUNCE_NS+100) = 0 < DEBOUNCE_NS.
// The pending stays in place; current stays Accept.
let after_rollback = g.evaluate(predict_only_grade(), 0);
assert_eq!(after_rollback, GateAction::Accept);
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
}
#[test]
fn forward_recovery_after_backward_jump_still_promotes_correctly() {
let mut g = CoherenceGate::new();
g.evaluate(predict_only_grade(), DEBOUNCE_NS + 100); // pending at t_old
g.evaluate(predict_only_grade(), 0); // backward jump
// Wall time advances past the ORIGINAL pending timestamp by DEBOUNCE_NS.
// Since the "since" stamp wasn't reset on the backward jump (target
// didn't change), the second evaluate at 0 didn't reset; the third at
// 2*DEBOUNCE_NS + 100 should now satisfy (2*DEBOUNCE_NS + 100) -
// (DEBOUNCE_NS + 100) >= DEBOUNCE_NS → promote.
let after_recovery = g.evaluate(predict_only_grade(), 2 * DEBOUNCE_NS + 100);
assert_eq!(after_recovery, GateAction::PredictOnly);
}
#[test]
fn identical_timestamps_across_repeated_polls_do_not_progress_state() {
let mut g = CoherenceGate::new();
let t = 1_000_000_000;
// Three identical evaluations — saturating_sub(t, t) = 0 < DEBOUNCE_NS.
// Gate never promotes regardless of how many times we poll.
for _ in 0..5 {
g.evaluate(predict_only_grade(), t);
}
assert_eq!(g.current(), GateAction::Accept);
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
}
#[test]
fn backward_jump_with_no_pending_is_a_noop() {
let mut g = CoherenceGate::new();
// No previous evaluation — pending is None. Backward jump from 1e9 to
// 0 with a low-risk score must keep gate at Accept with no pending.
g.evaluate(low_risk(), 1_000_000_000);
assert_eq!(g.pending(), None);
let after = g.evaluate(low_risk(), 0);
assert_eq!(after, GateAction::Accept);
assert_eq!(g.pending(), None);
}
#[test]
fn very_large_forward_jump_promotes_but_does_not_panic() {
let mut g = CoherenceGate::new();
g.evaluate(predict_only_grade(), 0);
// Jump u64::MAX / 2 ns into the future — debounce trivially satisfied.
let huge = u64::MAX / 2;
let after = g.evaluate(predict_only_grade(), huge);
assert_eq!(after, GateAction::PredictOnly);
}
#[test]
fn backward_then_forward_into_different_action_band_resets_pending_correctly() {
let mut g = CoherenceGate::new();
// Pending PredictOnly at t = 10 * DEBOUNCE_NS.
g.evaluate(predict_only_grade(), 10 * DEBOUNCE_NS);
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
// Backward jump but with a Recalibrate-grade score — gate should re-pend
// Recalibrate at the NEW timestamp.
g.evaluate(recalibrate_grade(), 5 * DEBOUNCE_NS);
assert_eq!(g.pending(), Some(GateAction::Recalibrate));
// The new pending is set at t=5*DEBOUNCE_NS. Advance another
// DEBOUNCE_NS forward → promote to Recalibrate.
let after = g.evaluate(recalibrate_grade(), 6 * DEBOUNCE_NS);
assert_eq!(after, GateAction::Recalibrate);
}
#[test]
fn no_panic_on_zero_timestamp_with_predict_only_pending() {
// Regression guard: a poorly-initialized monotonic clock could deliver
// t=0 as the first sample. Gate must not panic even if `since` is 0
// and `timestamp_ns` is 0.
let mut g = CoherenceGate::new();
g.evaluate(predict_only_grade(), 0);
let after = g.evaluate(predict_only_grade(), 0);
assert_eq!(after, GateAction::Accept);
}