Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs
T
ruv ac461f94fc feat(adr-118/p4.5): BfldPipeline facade + BfldConfig (146/146 GREEN)
Iter 19. Public lib.rs entry point per ADR-118 §2.1. Thin facade over
BfldEmitter that adds a config-driven builder and a privacy_mode
toggle for emergency demote-to-Restricted without rebuilding the
gate/ring/hasher state.

Added (gated on `feature = "std"`):
- src/pipeline.rs:
  * BfldConfig { node_id, default_zone_id, privacy_class, signature_hasher }
    with new/with_zone/with_privacy_class/with_signature_hasher builder
  * BfldPipeline { baseline_class, privacy_mode, emitter }
  * BfldPipeline::new(config) — initializes the underlying emitter
  * process(inputs, embedding) -> Option<BfldEvent>
    Delegates to emitter.emit() then post-processes: if privacy_mode is
    engaged, demotes the resulting event to Restricted and calls
    apply_privacy_gating to strip identity fields
  * enable_privacy_mode() / disable_privacy_mode() / is_privacy_mode_enabled()
  * current_privacy_class() — returns Restricted when privacy_mode else baseline
  * current_gate_action() — delegate diagnostic
- pub use BfldConfig, BfldPipeline from lib.rs

Design note: the privacy_mode override is applied post-emission, NOT by
rebuilding the emitter. This preserves gate state (current action,
pending transitions), ring contents, and hasher salt across the toggle —
critical for incident response where the operator needs to keep
detecting anomalies while temporarily redacting the public surface.

tests/pipeline_facade.rs (9 named tests, all green):
  config_defaults_to_anonymous_no_zone_no_hasher
  config_builder_methods_chain
  fresh_pipeline_is_not_in_privacy_mode
  pipeline_process_returns_anonymous_event_under_low_risk
  enable_privacy_mode_demotes_published_events_to_restricted
    (verifies BOTH identity_risk_score AND rf_signature_hash become None)
  disable_privacy_mode_restores_baseline_class
    (round-trip: enable → demoted → disable → restored to Anonymous)
  privacy_mode_overrides_derived_baseline_too
    (research-mode operator can still flip the emergency switch)
  pipeline_with_hasher_emits_derived_rf_signature_hash
  zone_is_threaded_from_config_to_event

ACs progressed:
- ADR-118 §2.1 — public entry point now matches the implementation
  plan §1.2 sketch: BfldPipeline::new(config) → process() → BfldEvent.
  Future iters add process_to_frame() and the tokio MQTT loop.
- ADR-118 §1.5 enable_privacy_mode requirement — operator can engage
  Restricted-class redaction without restarting the pipeline or
  losing in-flight detection state. First runtime witness of this.

Test config:
- cargo test --no-default-features → 72 passed (pipeline cfg-out)
- cargo test                       → 146 passed (137 + 9)

Out of scope (next iter target):
- process_to_frame(inputs, payload, embedding) -> Option<BfldFrame>
  for callers that need wire-format bytes rather than JSON events.
- BfldPipelineHandle wrapping the pipeline in Arc<Mutex<...>> + a
  tokio task that pumps an MQTT loop (ADR-122 §2.2 emitter half).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 16:28:42 -04:00

128 lines
4.5 KiB
Rust

//! 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"));
}