diff --git a/v2/crates/wifi-densepose-bfld/src/emitter.rs b/v2/crates/wifi-densepose-bfld/src/emitter.rs new file mode 100644 index 00000000..32a0945b --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/emitter.rs @@ -0,0 +1,165 @@ +//! `BfldEmitter` — end-to-end pipeline. ADR-118 §2.1. +//! +//! Wires the per-frame sensing inputs through: +//! +//! ```text +//! risk = identity_risk::score(sep, stab, consist, conf_factor) +//! -> gate.evaluate_with_oracle(risk, ts, &oracle) -> GateAction +//! -> if Recalibrate: ring.drain() +//! -> if action.drops_event(): return None +//! -> else: BfldEvent::with_privacy_gating(...) +//! ``` +//! +//! The emitter owns the `CoherenceGate` and `EmbeddingRing` state so the +//! caller only supplies per-frame inputs. Identity embeddings are pushed to +//! the ring before the gate is consulted; on `Recalibrate` the ring is +//! drained synchronously inside this function. + +#![cfg(feature = "std")] + +use crate::coherence_gate::{CoherenceGate, NullOracle, SoulMatchOracle}; +use crate::embedding_ring::EmbeddingRing; +use crate::identity_risk::{score, GateAction}; +use crate::{BfldEvent, IdentityEmbedding, PrivacyClass}; + +/// Per-frame sensing inputs to [`BfldEmitter::emit`]. +#[derive(Debug, Clone)] +pub struct SensingInputs { + /// Monotonic capture-clock timestamp in nanoseconds. + pub timestamp_ns: u64, + /// Whether an occupant is present in the zone. + pub presence: bool, + /// Normalized motion magnitude `[0,1]`. + pub motion: f32, + /// Estimated occupant count. + pub person_count: u8, + /// Sensing confidence (NOT the risk-score `conf` factor) — `[0,1]`. + pub sensing_confidence: f32, + + // --- Risk-score factors (ADR-121 §2.2) ------------------------------- + /// `identity_separability_score` — `[0,1]`. + pub sep: f32, + /// `temporal_stability` — `[0,1]`. + pub stab: f32, + /// `cross_perspective_consistency` — `[0,1]`. + pub consist: f32, + /// Risk-score sample confidence factor — `[0,1]`. + pub risk_conf: f32, + + // --- Optional identity-derived fields -------------------------------- + /// Per-day BLAKE3-keyed `rf_signature_hash`. Stripped at class 3 by the + /// privacy-gated event constructor. + pub rf_signature_hash: Option<[u8; 32]>, +} + +/// End-to-end pipeline. Owns the gate state, the embedding ring, and the +/// configured node identity. Defaults to `PrivacyClass::Anonymous`. +pub struct BfldEmitter { + node_id: String, + default_zone_id: Option, + privacy_class: PrivacyClass, + gate: CoherenceGate, + ring: EmbeddingRing, +} + +impl BfldEmitter { + /// Build a new emitter in the production-default state: class Anonymous, + /// empty gate/ring, no default zone. + #[must_use] + pub fn new(node_id: impl Into) -> Self { + Self { + node_id: node_id.into(), + default_zone_id: None, + privacy_class: PrivacyClass::Anonymous, + gate: CoherenceGate::new(), + ring: EmbeddingRing::new(), + } + } + + /// Set the default zone ID emitted with each event (None = single-zone). + #[must_use] + pub fn with_zone(mut self, zone_id: impl Into) -> Self { + self.default_zone_id = Some(zone_id.into()); + self + } + + /// Override the privacy class (default `Anonymous`). + #[must_use] + pub const fn with_privacy_class(mut self, class: PrivacyClass) -> Self { + self.privacy_class = class; + self + } + + /// Read-only access to the current gate action — useful for diagnostics. + #[must_use] + pub const fn current_action(&self) -> GateAction { + self.gate.current() + } + + /// Read-only access to the ring length (post any in-flight drain). + #[must_use] + pub const fn ring_len(&self) -> usize { + self.ring.len() + } + + /// Run one pipeline step with the default [`NullOracle`]. Returns + /// `Some(BfldEvent)` if the gate permitted publishing, `None` if the + /// action was `Reject` or `Recalibrate`. + pub fn emit( + &mut self, + inputs: SensingInputs, + embedding: Option, + ) -> Option { + self.emit_with_oracle(inputs, embedding, &NullOracle) + } + + /// Same as [`Self::emit`] but consults a [`SoulMatchOracle`] before the + /// gate fires `Recalibrate`. See ADR-121 §2.6. + pub fn emit_with_oracle( + &mut self, + inputs: SensingInputs, + embedding: Option, + oracle: &O, + ) -> Option { + let risk = score(inputs.sep, inputs.stab, inputs.consist, inputs.risk_conf); + + if let Some(emb) = embedding { + // Always push, regardless of action — the ring is the rolling + // memory of recent identity embeddings, used for separability. + self.ring.push(emb); + } + + let action = self + .gate + .evaluate_with_oracle(risk, inputs.timestamp_ns, oracle); + + if action == GateAction::Recalibrate { + self.ring.drain(); + } + + if action.drops_event() { + return None; + } + + let identity_risk_score = match self.privacy_class { + PrivacyClass::Anonymous => Some(risk), + // Class 3 strips identity_risk; class 0/1 keep it (research modes). + // The BfldEvent constructor enforces the class-3 strip again as a + // defense-in-depth measure. + _ => Some(risk), + }; + + Some(BfldEvent::with_privacy_gating( + self.node_id.clone(), + inputs.timestamp_ns, + inputs.presence, + inputs.motion, + inputs.person_count, + inputs.sensing_confidence, + self.default_zone_id.clone(), + self.privacy_class, + identity_risk_score, + inputs.rf_signature_hash, + )) + } +} diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 24c9a482..2a7e6162 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -17,6 +17,8 @@ pub mod coherence_gate; pub mod embedding; pub mod embedding_ring; #[cfg(feature = "std")] +pub mod emitter; +#[cfg(feature = "std")] pub mod event; pub mod frame; pub mod identity_risk; @@ -28,6 +30,8 @@ pub mod sink; pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle}; #[cfg(feature = "std")] +pub use emitter::{BfldEmitter, SensingInputs}; +#[cfg(feature = "std")] pub use event::BfldEvent; pub use embedding::{IdentityEmbedding, EMBEDDING_DIM}; pub use embedding_ring::{EmbeddingRing, RING_CAPACITY}; diff --git a/v2/crates/wifi-densepose-bfld/tests/emitter_pipeline.rs b/v2/crates/wifi-densepose-bfld/tests/emitter_pipeline.rs new file mode 100644 index 00000000..d8073330 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/emitter_pipeline.rs @@ -0,0 +1,124 @@ +//! 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); +}