From ce2eaab75a77866301d88aeb1cd46774cf6287fd Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 19:13:17 -0400 Subject: [PATCH] feat(adr-118/p6.8): pipeline gate-state observability (269/269 GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/pipeline_gate_observability.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 v2/crates/wifi-densepose-bfld/tests/pipeline_gate_observability.rs diff --git a/v2/crates/wifi-densepose-bfld/tests/pipeline_gate_observability.rs b/v2/crates/wifi-densepose-bfld/tests/pipeline_gate_observability.rs new file mode 100644 index 00000000..759a1796 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/pipeline_gate_observability.rs @@ -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"); +}