mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
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>
This commit is contained in:
@@ -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<String>,
|
||||
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<String>) -> 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<String>) -> 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<IdentityEmbedding>,
|
||||
) -> Option<BfldEvent> {
|
||||
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<O: SoulMatchOracle>(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
oracle: &O,
|
||||
) -> Option<BfldEvent> {
|
||||
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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user