Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/tests/emitter_pipeline.rs
T
ruv 9c518f6e36 feat(adr-118/p4.2): BfldEmitter end-to-end pipeline (109/109 GREEN)
Iter 14. Wires every iter-1..13 primitive into a single ADR-118 §2.1
pipeline: per-frame sensing inputs go in, a privacy-gated BfldEvent
(or None) comes out. First time every constituent is exercised together.

Added (gated on `feature = "std"`):
- src/emitter.rs:
  * SensingInputs struct — 11 fields: timestamp_ns, presence, motion,
    person_count, sensing_confidence, sep, stab, consist, risk_conf,
    rf_signature_hash (Option)
  * BfldEmitter struct owning: node_id, default_zone_id, privacy_class,
    CoherenceGate, EmbeddingRing
  * Builder API: new(node_id) → with_zone(...) → with_privacy_class(...)
  * current_action() / ring_len() diagnostic accessors
  * emit(inputs, embedding) → Option<BfldEvent>
      1. score = identity_risk::score(sep, stab, consist, risk_conf)
      2. ring.push(embedding) if Some
      3. action = gate.evaluate_with_oracle(score, ts, &NullOracle)
      4. if action == Recalibrate { ring.drain() }
      5. if action.drops_event() { return None }
      6. else BfldEvent::with_privacy_gating(...) honoring privacy_class
  * emit_with_oracle(...) variant for `--features soul-signature` callers
- pub use BfldEmitter, SensingInputs from lib.rs

tests/emitter_pipeline.rs (7 named tests, all green):
  emitter_emits_event_under_low_risk
  emitter_drops_event_under_sustained_high_risk (debounce honored)
  emitter_drains_ring_on_recalibrate
    (fills ring to 5, then Recalibrate-grade score → ring_len() == 0)
  restricted_class_strips_identity_fields_in_emitted_event
    (class 3: identity_risk_score AND rf_signature_hash both None)
  with_zone_sets_default_zone_id_on_event
  embedding_is_pushed_to_ring_even_when_event_dropped
    (privacy gating drops the event but the ring still observes the
     embedding so subsequent separability calculations remain valid)
  ring_unchanged_when_no_embedding_supplied

ACs progressed:
- ADR-118 AC1 (BFLD core pipeline integration) — every component from
  iter 1 (frame format) through iter 13 (event) is now traversed by a
  single emit() call. This is the first end-to-end smoke proof.
- ADR-121 AC4 — Recalibrate-grade sustained score triggers ring drain
  (verified by ring_len() going from 5 to 0).
- ADR-122 AC1 — privacy_class threaded through the pipeline so the
  output event is correctly gated for HA/Matter consumption.

Test config:
- cargo test --no-default-features → 64 passed (emitter cfg-out)
- cargo test                       → 109 passed (102 + 7)

Out of scope (next iter target):
- Wiring rf_signature_hash computation from BLAKE3-keyed(site_salt,
  features) per ADR-120 §2.3 — the SensingInputs.rf_signature_hash
  is supplied by caller for now; needs a SignatureHasher with site_salt
  initialization in a follow-up iter.
- Embedding ring → identity_separability_score derivation (currently
  `sep` is caller-supplied; should be computed from ring contents).
- MQTT topic publisher wrapping BfldEmitter (ADR-122 §2.2) — depends
  on a runtime (tokio).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 15:37:23 -04:00

125 lines
4.0 KiB
Rust

//! End-to-end pipeline tests for `BfldEmitter`. ADR-118 §2.1.
#![cfg(feature = "std")]
use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS;
use wifi_densepose_bfld::{
BfldEmitter, GateAction, IdentityEmbedding, PrivacyClass, SensingInputs, EMBEDDING_DIM,
};
fn inputs(ts_ns: u64, risk_factors: [f32; 4]) -> SensingInputs {
let [sep, stab, consist, risk_conf] = risk_factors;
SensingInputs {
timestamp_ns: ts_ns,
presence: true,
motion: 0.5,
person_count: 1,
sensing_confidence: 0.9,
sep,
stab,
consist,
risk_conf,
rf_signature_hash: Some([0xCD; 32]),
}
}
fn dummy_embedding() -> IdentityEmbedding {
IdentityEmbedding::from_raw([0.1; EMBEDDING_DIM])
}
#[test]
fn emitter_emits_event_under_low_risk() {
let mut e = BfldEmitter::new("seed-01");
let out = e
.emit(inputs(0, [0.2, 0.2, 0.2, 0.2]), Some(dummy_embedding()))
.expect("low risk should produce an event");
assert_eq!(out.node_id, "seed-01");
assert!(out.presence);
assert!(out.identity_risk_score.is_some());
assert_eq!(e.current_action(), GateAction::Accept);
}
#[test]
fn emitter_drops_event_under_sustained_high_risk() {
let mut e = BfldEmitter::new("seed-01");
// First call: score ~ 0.7 pending Reject. Event still emits this turn
// because the gate hasn't promoted yet (current is still Accept).
let first = e.emit(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(dummy_embedding()));
assert!(first.is_some(), "first high-risk call still emits");
// After debounce: current becomes Reject -> event dropped.
let after = e.emit(
inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]),
Some(dummy_embedding()),
);
assert!(after.is_none(), "post-debounce Reject drops the event");
assert_eq!(e.current_action(), GateAction::Reject);
}
#[test]
fn emitter_drains_ring_on_recalibrate() {
let mut e = BfldEmitter::new("seed-01");
// Pump 5 embeddings under a slow rising score so the ring fills.
for i in 0..5 {
let _ = e.emit(
inputs(i * 1_000_000, [0.3, 0.3, 0.3, 0.3]),
Some(dummy_embedding()),
);
}
assert_eq!(e.ring_len(), 5);
// Now push a Recalibrate-grade score and run past debounce.
e.emit(inputs(10_000_000, [1.0, 1.0, 1.0, 1.0]), Some(dummy_embedding()));
let _ = e.emit(
inputs(10_000_000 + DEBOUNCE_NS, [1.0, 1.0, 1.0, 1.0]),
Some(dummy_embedding()),
);
assert_eq!(e.current_action(), GateAction::Recalibrate);
assert_eq!(e.ring_len(), 0, "Recalibrate must drain the embedding ring");
}
#[test]
fn restricted_class_strips_identity_fields_in_emitted_event() {
let mut e = BfldEmitter::new("seed-01").with_privacy_class(PrivacyClass::Restricted);
let out = e
.emit(inputs(0, [0.2, 0.2, 0.2, 0.2]), Some(dummy_embedding()))
.expect("Accept should emit");
assert!(
out.identity_risk_score.is_none(),
"class 3 must strip identity_risk_score",
);
assert!(
out.rf_signature_hash.is_none(),
"class 3 must strip rf_signature_hash",
);
}
#[test]
fn with_zone_sets_default_zone_id_on_event() {
let mut e = BfldEmitter::new("seed-01").with_zone("kitchen");
let out = e
.emit(inputs(0, [0.1, 0.1, 0.1, 0.1]), Some(dummy_embedding()))
.unwrap();
assert_eq!(out.zone_id.as_deref(), Some("kitchen"));
}
#[test]
fn embedding_is_pushed_to_ring_even_when_event_dropped() {
let mut e = BfldEmitter::new("seed-01");
// Drive into Reject state.
e.emit(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(dummy_embedding()));
e.emit(
inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]),
Some(dummy_embedding()),
);
assert_eq!(e.current_action(), GateAction::Reject);
// Even though the gate dropped events, the embeddings landed in the ring.
assert_eq!(e.ring_len(), 2);
}
#[test]
fn ring_unchanged_when_no_embedding_supplied() {
let mut e = BfldEmitter::new("seed-01");
let _ = e.emit(inputs(0, [0.1, 0.1, 0.1, 0.1]), None);
assert_eq!(e.ring_len(), 0);
}