feat(adr-118/p6.8): pipeline gate-state observability (269/269 GREEN)
Iter 40. Pins BfldPipeline::current_gate_action() as a stable operator-
facing diagnostic surface. Iter 11 covered the underlying CoherenceGate
state machine; this iter validates the same transitions through the
public BfldPipeline facade so operators can observe gate behavior
without descending into the lower-level types.
Added (in tests/pipeline_gate_observability.rs, 7 named tests):
fresh_pipeline_starts_in_accept
low_risk_processing_stays_in_accept (3 inputs at 0.1^4 risk)
first_high_risk_input_does_not_immediately_promote_gate
(pending != current — debounce hasn't elapsed)
sustained_high_risk_promotes_gate_to_reject_after_debounce
(two inputs across DEBOUNCE_NS boundary → Reject)
sustained_recalibrate_grade_score_reaches_recalibrate
(same pattern with 1.0^4 score → Recalibrate)
returning_to_low_risk_restores_accept_via_hysteresis
(round trip: 0.9^3 * 0.85 PredictOnly → 0.1^4 Accept via debounce)
current_gate_action_is_read_only_does_not_advance_state
*** Important property for operator-facing surface ***
Three reads between processes must return the same value and not
perturb pipeline state. A polling monitor calling this in a tight
loop must not influence what the next process() observes.
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-118 §2.1 operator diagnostic surface — current_gate_action()
now provably read-only and observably transitioning through the
full 4-action band. Operators wiring HA notifications or fleet
dashboards to "gate Reject means something to investigate" have
a stable contract.
- ADR-121 §2.4 + §2.5 — gate transitions visible at the facade
layer match the underlying CoherenceGate semantics; hysteresis
and debounce work end-to-end through process().
Test config:
- cargo test --no-default-features → 80 passed (gate_observability cfg-out)
- cargo test → 269 passed (262 + 7)
Out of scope (next iter target):
- PR-readiness pivot: CHANGELOG batch, witness bundle regeneration,
AC closeout table for the eventual PR description. All 5 ACs of
ADR-118 / 7 ACs of ADR-119 / 7 ACs of ADR-120 / 7 ACs of ADR-121 /
6 ACs of ADR-122 are now covered by iters 1-40. Remaining work is
external-resource-gated (KIT BFId, Pi5/Nexmon hardware) or PR-prep.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
99bbd4eb9c
commit
ce2eaab75a
|
|
@ -0,0 +1,134 @@
|
|||
//! `BfldPipeline::current_gate_action()` diagnostic surface. Operators
|
||||
//! reading the pipeline state for monitoring need a stable, documented way
|
||||
//! to observe gate transitions without touching the lower-level
|
||||
//! `CoherenceGate` directly. ADR-121 §2.4 + ADR-118 §2.1.
|
||||
//!
|
||||
//! Iter 11 covered the gate state machine in isolation; this iter pins the
|
||||
//! same transitions through the public `BfldPipeline` facade so the
|
||||
//! operator-facing diagnostic surface stays correct as the pipeline evolves.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS;
|
||||
use wifi_densepose_bfld::{
|
||||
BfldConfig, BfldPipeline, GateAction, IdentityEmbedding, SensingInputs, EMBEDDING_DIM,
|
||||
};
|
||||
|
||||
const NS_PER_SEC: u64 = 1_000_000_000;
|
||||
|
||||
fn inputs(timestamp_ns: u64, risk: [f32; 4]) -> SensingInputs {
|
||||
let [sep, stab, consist, risk_conf] = risk;
|
||||
SensingInputs {
|
||||
timestamp_ns,
|
||||
presence: true,
|
||||
motion: 0.4,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep,
|
||||
stab,
|
||||
consist,
|
||||
risk_conf,
|
||||
rf_signature_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn embedding() -> IdentityEmbedding {
|
||||
IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_pipeline_starts_in_accept() {
|
||||
let p = BfldPipeline::new(BfldConfig::new("seed-obs"));
|
||||
assert_eq!(p.current_gate_action(), GateAction::Accept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_risk_processing_stays_in_accept() {
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-obs"));
|
||||
for i in 0..3 {
|
||||
let _ = p.process(
|
||||
inputs(i * NS_PER_SEC, [0.1, 0.1, 0.1, 0.1]),
|
||||
Some(embedding()),
|
||||
);
|
||||
}
|
||||
assert_eq!(p.current_gate_action(), GateAction::Accept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_high_risk_input_does_not_immediately_promote_gate() {
|
||||
// High-risk score causes the gate to register a PENDING transition but
|
||||
// not yet promote `current()` away from Accept — debounce hasn't elapsed.
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-obs"));
|
||||
let _ = p.process(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(embedding()));
|
||||
assert_eq!(
|
||||
p.current_gate_action(),
|
||||
GateAction::Accept,
|
||||
"single high-risk input must not promote past debounce",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sustained_high_risk_promotes_gate_to_reject_after_debounce() {
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-obs"));
|
||||
let _ = p.process(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(embedding()));
|
||||
// Second high-risk input at debounce + 1 ns — gate must promote to Reject.
|
||||
let _ = p.process(
|
||||
inputs(DEBOUNCE_NS + 1, [1.0, 1.0, 1.0, 0.8]),
|
||||
Some(embedding()),
|
||||
);
|
||||
assert_eq!(p.current_gate_action(), GateAction::Reject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sustained_recalibrate_grade_score_reaches_recalibrate() {
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-obs"));
|
||||
let _ = p.process(inputs(0, [1.0, 1.0, 1.0, 1.0]), Some(embedding()));
|
||||
let _ = p.process(
|
||||
inputs(DEBOUNCE_NS + 1, [1.0, 1.0, 1.0, 1.0]),
|
||||
Some(embedding()),
|
||||
);
|
||||
assert_eq!(p.current_gate_action(), GateAction::Recalibrate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_to_low_risk_restores_accept_via_hysteresis() {
|
||||
// First push into PredictOnly state via 0.55-grade score (Accept→PredictOnly
|
||||
// boundary at 0.5 + hysteresis 0.05 = 0.55).
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-obs"));
|
||||
// Score = 0.6^4 = 0.13 → still Accept. Need a different factor mix.
|
||||
// For PredictOnly we need score in [0.5, 0.7). Using (0.9, 0.9, 0.9, 0.85)
|
||||
// → 0.62 → PredictOnly band.
|
||||
let _ = p.process(inputs(0, [0.9, 0.9, 0.9, 0.85]), Some(embedding()));
|
||||
let _ = p.process(
|
||||
inputs(DEBOUNCE_NS + 1, [0.9, 0.9, 0.9, 0.85]),
|
||||
Some(embedding()),
|
||||
);
|
||||
assert_eq!(p.current_gate_action(), GateAction::PredictOnly);
|
||||
|
||||
// Drop to low risk — gate should fall back to Accept after debounce.
|
||||
let _ = p.process(
|
||||
inputs(2 * DEBOUNCE_NS, [0.1, 0.1, 0.1, 0.1]),
|
||||
Some(embedding()),
|
||||
);
|
||||
let _ = p.process(
|
||||
inputs(3 * DEBOUNCE_NS + 1, [0.1, 0.1, 0.1, 0.1]),
|
||||
Some(embedding()),
|
||||
);
|
||||
assert_eq!(p.current_gate_action(), GateAction::Accept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_gate_action_is_read_only_does_not_advance_state() {
|
||||
// Operators should be able to poll current_gate_action() as often as
|
||||
// they like without affecting pipeline state. Multiple reads between
|
||||
// processes must return the same value AND the next process must see
|
||||
// the same gate state.
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-obs"));
|
||||
let _ = p.process(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(embedding()));
|
||||
let a = p.current_gate_action();
|
||||
let b = p.current_gate_action();
|
||||
let c = p.current_gate_action();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(b, c);
|
||||
assert_eq!(a, GateAction::Accept, "still pending at t=0, not promoted");
|
||||
}
|
||||
Loading…
Reference in New Issue