diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index b17e3ddc..56d8eaf3 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -27,6 +27,8 @@ pub mod identity_risk; #[cfg(feature = "std")] pub mod payload; #[cfg(feature = "std")] +pub mod pipeline; +#[cfg(feature = "std")] pub mod privacy_gate; pub mod signature_hasher; pub mod sink; @@ -47,6 +49,8 @@ pub use frame::BfldFrame; #[cfg(feature = "std")] pub use payload::BfldPayload; #[cfg(feature = "std")] +pub use pipeline::{BfldConfig, BfldPipeline}; +#[cfg(feature = "std")] pub use privacy_gate::PrivacyGate; pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN}; pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink}; diff --git a/v2/crates/wifi-densepose-bfld/src/pipeline.rs b/v2/crates/wifi-densepose-bfld/src/pipeline.rs new file mode 100644 index 00000000..aa7593c4 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/pipeline.rs @@ -0,0 +1,156 @@ +//! `BfldPipeline` — public entry point. ADR-118 §2.1. +//! +//! Thin facade over [`crate::BfldEmitter`] that adds: +//! +//! - A configuration struct ([`BfldConfig`]) for ergonomic construction. +//! - A `privacy_mode` toggle that flips the active class to +//! [`PrivacyClass::Restricted`] (and back to the configured baseline) +//! without rebuilding the underlying emitter state. +//! - A single named consumer call ([`Self::process`]) so callers don't have +//! to navigate the lower-level emitter API. +//! +//! Future iters add `process_to_frame()` (BfldFrame production) and a `tokio` +//! MQTT loop wrapper on top of this same facade. + +#![cfg(feature = "std")] + +use crate::emitter::{BfldEmitter, SensingInputs}; +use crate::identity_risk::GateAction; +use crate::signature_hasher::SignatureHasher; +use crate::{BfldEvent, IdentityEmbedding, PrivacyClass}; + +/// Construction parameters for [`BfldPipeline`]. Matches the ADR-118 default- +/// secure posture: `class = Anonymous`, no zone, no signature hasher. +#[derive(Debug, Clone)] +pub struct BfldConfig { + /// Node identifier published in every `BfldEvent.node_id`. + pub node_id: String, + /// Optional default zone; passed through to every event. + pub default_zone_id: Option, + /// Baseline privacy class. `privacy_mode = true` overrides to Restricted. + pub privacy_class: PrivacyClass, + /// Optional signature hasher; when present, the pipeline derives + /// `rf_signature_hash` via [`crate::IdentityFeatures`]. + pub signature_hasher: Option, +} + +impl BfldConfig { + /// Build a minimal config: node_id only, class defaulted to Anonymous. + #[must_use] + pub fn new(node_id: impl Into) -> Self { + Self { + node_id: node_id.into(), + default_zone_id: None, + privacy_class: PrivacyClass::Anonymous, + signature_hasher: None, + } + } + + /// Set the default 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 baseline privacy class. + #[must_use] + pub const fn with_privacy_class(mut self, class: PrivacyClass) -> Self { + self.privacy_class = class; + self + } + + /// Install a signature hasher. + #[must_use] + pub fn with_signature_hasher(mut self, hasher: SignatureHasher) -> Self { + self.signature_hasher = Some(hasher); + self + } +} + +/// Public BFLD entry point. Owns the configured emitter and the +/// `privacy_mode` toggle. +pub struct BfldPipeline { + /// Baseline class — the class to which `disable_privacy_mode()` returns. + baseline_class: PrivacyClass, + privacy_mode: bool, + emitter: BfldEmitter, +} + +impl BfldPipeline { + /// Build a pipeline from `config`. The underlying emitter is initialized + /// with the configured class; `privacy_mode` is initially `false`. + #[must_use] + pub fn new(config: BfldConfig) -> Self { + let mut emitter = BfldEmitter::new(config.node_id); + if let Some(zone) = config.default_zone_id { + emitter = emitter.with_zone(zone); + } + emitter = emitter.with_privacy_class(config.privacy_class); + if let Some(hasher) = config.signature_hasher { + emitter = emitter.with_signature_hasher(hasher); + } + Self { + baseline_class: config.privacy_class, + privacy_mode: false, + emitter, + } + } + + /// Process a single sensing frame. Delegates to the underlying emitter, + /// then post-processes the resulting event to honor `privacy_mode`. When + /// privacy mode is engaged the published event is demoted to Restricted + /// (identity-derived fields stripped) regardless of the configured baseline. + pub fn process( + &mut self, + inputs: SensingInputs, + embedding: Option, + ) -> Option { + let mut event = self.emitter.emit(inputs, embedding)?; + if self.privacy_mode { + event.privacy_class = PrivacyClass::Restricted; + event.apply_privacy_gating(); + } + Some(event) + } + + /// `true` if `enable_privacy_mode()` has been called more recently than + /// `disable_privacy_mode()`. + #[must_use] + pub const fn is_privacy_mode_enabled(&self) -> bool { + self.privacy_mode + } + + /// Read the currently active class. Returns Restricted if privacy mode is + /// engaged, otherwise the baseline. + #[must_use] + pub const fn current_privacy_class(&self) -> PrivacyClass { + if self.privacy_mode { + PrivacyClass::Restricted + } else { + self.baseline_class + } + } + + /// Read-only access to the current gate action — for diagnostics. + #[must_use] + pub const fn current_gate_action(&self) -> GateAction { + self.emitter.current_action() + } + + /// Engage privacy mode: future `process()` calls return events demoted + /// to Restricted (identity_risk_score + rf_signature_hash stripped) + /// regardless of the configured baseline. + /// + /// The override is applied post-emission so the underlying gate / ring / + /// hasher state remains unchanged and recoverable when privacy mode is + /// later disabled. + pub fn enable_privacy_mode(&mut self) { + self.privacy_mode = true; + } + + /// Disengage privacy mode: future events return to the configured baseline. + pub fn disable_privacy_mode(&mut self) { + self.privacy_mode = false; + } +} diff --git a/v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs b/v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs new file mode 100644 index 00000000..0a477765 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs @@ -0,0 +1,127 @@ +//! Acceptance tests for the `BfldPipeline` facade. ADR-118 §2.1. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{ + BfldConfig, BfldPipeline, IdentityEmbedding, PrivacyClass, SensingInputs, SignatureHasher, + EMBEDDING_DIM, SITE_SALT_LEN, +}; + +fn inputs() -> SensingInputs { + SensingInputs { + timestamp_ns: 1_700_000_000_000_000_000, + presence: true, + motion: 0.4, + person_count: 1, + sensing_confidence: 0.9, + sep: 0.2, + stab: 0.2, + consist: 0.2, + risk_conf: 0.2, + rf_signature_hash: None, + } +} + +fn embedding() -> IdentityEmbedding { + IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM]) +} + +// --- BfldConfig builder -------------------------------------------------- + +#[test] +fn config_defaults_to_anonymous_no_zone_no_hasher() { + let c = BfldConfig::new("seed-01"); + assert_eq!(c.node_id, "seed-01"); + assert_eq!(c.privacy_class, PrivacyClass::Anonymous); + assert!(c.default_zone_id.is_none()); + assert!(c.signature_hasher.is_none()); +} + +#[test] +fn config_builder_methods_chain() { + let hasher = SignatureHasher::new([0u8; SITE_SALT_LEN]); + let c = BfldConfig::new("seed-01") + .with_zone("kitchen") + .with_privacy_class(PrivacyClass::Derived) + .with_signature_hasher(hasher); + assert_eq!(c.default_zone_id.as_deref(), Some("kitchen")); + assert_eq!(c.privacy_class, PrivacyClass::Derived); + assert!(c.signature_hasher.is_some()); +} + +// --- BfldPipeline core --------------------------------------------------- + +#[test] +fn fresh_pipeline_is_not_in_privacy_mode() { + let p = BfldPipeline::new(BfldConfig::new("seed-01")); + assert!(!p.is_privacy_mode_enabled()); + assert_eq!(p.current_privacy_class(), PrivacyClass::Anonymous); +} + +#[test] +fn pipeline_process_returns_anonymous_event_under_low_risk() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + let evt = p.process(inputs(), Some(embedding())).expect("low risk"); + assert_eq!(evt.privacy_class, PrivacyClass::Anonymous); + assert!(evt.identity_risk_score.is_some()); +} + +// --- privacy_mode toggle ------------------------------------------------- + +#[test] +fn enable_privacy_mode_demotes_published_events_to_restricted() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + p.enable_privacy_mode(); + assert!(p.is_privacy_mode_enabled()); + assert_eq!(p.current_privacy_class(), PrivacyClass::Restricted); + let evt = p.process(inputs(), Some(embedding())).expect("low risk"); + assert_eq!(evt.privacy_class, PrivacyClass::Restricted); + assert!(evt.identity_risk_score.is_none(), "score must be stripped"); + assert!(evt.rf_signature_hash.is_none(), "hash must be stripped"); +} + +#[test] +fn disable_privacy_mode_restores_baseline_class() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + p.enable_privacy_mode(); + let demoted = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(demoted.privacy_class, PrivacyClass::Restricted); + + p.disable_privacy_mode(); + assert!(!p.is_privacy_mode_enabled()); + assert_eq!(p.current_privacy_class(), PrivacyClass::Anonymous); + let restored = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(restored.privacy_class, PrivacyClass::Anonymous); + assert!(restored.identity_risk_score.is_some()); +} + +#[test] +fn privacy_mode_overrides_derived_baseline_too() { + // Operator running at Derived (class 1, research mode) can still flip the + // emergency switch to Restricted without restarting the pipeline. + let mut p = BfldPipeline::new( + BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Derived), + ); + p.enable_privacy_mode(); + let evt = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(evt.privacy_class, PrivacyClass::Restricted); + assert!(evt.identity_risk_score.is_none()); +} + +// --- hasher wiring through the facade ----------------------------------- + +#[test] +fn pipeline_with_hasher_emits_derived_rf_signature_hash() { + let hasher = SignatureHasher::new([7u8; SITE_SALT_LEN]); + let mut p = BfldPipeline::new(BfldConfig::new("seed-01").with_signature_hasher(hasher)); + let evt = p.process(inputs(), Some(embedding())).unwrap(); + let hash = evt.rf_signature_hash.expect("hasher path must produce a hash"); + assert_ne!(hash, [0u8; 32], "derived hash must be non-trivial"); +} + +#[test] +fn zone_is_threaded_from_config_to_event() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01").with_zone("kitchen")); + let evt = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(evt.zone_id.as_deref(), Some("kitchen")); +}