//! ADR-122 AC3 — motion-state topic publishes at ≥ 1 Hz during sustained //! occupancy through the [`BfldPipelineHandle`] worker thread. //! //! Drives the handle with N inputs spaced over a known wall-clock window, //! then counts motion topic messages in the capture log. Avoids broker //! dependencies — entirely in-process via `CapturePublisher` + `Arc>`. #![cfg(feature = "std")] use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use wifi_densepose_bfld::{ BfldConfig, BfldPipeline, BfldPipelineHandle, CapturePublisher, IdentityEmbedding, PipelineInput, SensingInputs, TopicMessage, EMBEDDING_DIM, }; const NS_PER_SEC: u64 = 1_000_000_000; fn input_at(ts_secs: f64, motion: f32) -> PipelineInput { let ts_ns = (ts_secs * NS_PER_SEC as f64) as u64; PipelineInput { inputs: SensingInputs { timestamp_ns: ts_ns, presence: true, motion, person_count: 1, sensing_confidence: 0.9, sep: 0.2, stab: 0.2, consist: 0.2, risk_conf: 0.2, rf_signature_hash: None, }, embedding: Some(IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM])), } } fn motion_messages(log: &[TopicMessage]) -> Vec<&TopicMessage> { log.iter() .filter(|m| m.topic.contains("/bfld/motion/state")) .collect() } #[test] fn motion_publish_rate_meets_one_hz_under_sustained_input() { let pipeline = BfldPipeline::new(BfldConfig::new("seed-rate")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); // Drive 10 inputs spaced 100ms apart in wall time — that's a 10 Hz // input rate, well above the 1 Hz AC3 floor. Timestamps advance in // lockstep so the gate/hasher see realistic monotonic time. let n = 10usize; let interval = Duration::from_millis(100); let start = Instant::now(); for i in 0..n { let ts_secs = i as f64 * 0.1; handle.send(input_at(ts_secs, 0.5)).expect("send"); thread::sleep(interval); } let elapsed = start.elapsed(); // Worker has a small enqueue → process latency; give it a brief drain // before shutting down. thread::sleep(Duration::from_millis(150)); handle.shutdown(); let log = pub_arc.lock().unwrap(); let motions = motion_messages(&log.published); let secs = elapsed.as_secs_f64(); let rate = motions.len() as f64 / secs; eprintln!( "motion_publish_rate: {} messages in {:.3}s → {:.2} Hz (ADR-122 AC3 floor: 1.00 Hz)", motions.len(), secs, rate, ); assert!( motions.len() >= n, "expected ≥ {n} motion topic messages (one per input), got {}", motions.len(), ); assert!( rate >= 1.0, "motion publish rate {rate:.2} Hz below ADR-122 AC3 floor (1.00 Hz)", ); } #[test] fn motion_values_track_input_motion_values() { // Pin the payload-encoding contract from iter 21: motion value flows // through verbatim (formatted as "{:.6}") — no quantization drift. let pipeline = BfldPipeline::new(BfldConfig::new("seed-track")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); let values: [f32; 5] = [0.10, 0.25, 0.50, 0.75, 0.95]; for (i, &v) in values.iter().enumerate() { handle.send(input_at(i as f64 * 0.05, v)).expect("send"); } thread::sleep(Duration::from_millis(200)); handle.shutdown(); let log = pub_arc.lock().unwrap(); let motions = motion_messages(&log.published); assert_eq!(motions.len(), values.len()); for (i, &expected) in values.iter().enumerate() { let formatted = format!("{:.6}", expected); assert_eq!( motions[i].payload, formatted, "motion[{i}] payload {} != expected {}", motions[i].payload, formatted, ); } } #[test] fn motion_topic_never_appears_for_class_below_anonymous_publishing() { // Defense in depth: the iter-21 router returns empty for class < Anonymous // events. Confirm at the handle level too by configuring the pipeline // baseline to a research-only class. The handle's process() goes through // privacy_mode-aware logic; we don't have a class-1 baseline path from // BfldConfig, so this test exercises the class-3 strip-but-not-suppress // path: motion still publishes (it's sensing data), but identity_risk // does NOT (proven in iter 25). use wifi_densepose_bfld::PrivacyClass; let pipeline = BfldPipeline::new( BfldConfig::new("seed-cls3").with_privacy_class(PrivacyClass::Restricted), ); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); handle.send(input_at(0.0, 0.4)).expect("send"); thread::sleep(Duration::from_millis(100)); handle.shutdown(); let log = pub_arc.lock().unwrap(); let motions = motion_messages(&log.published); assert_eq!(motions.len(), 1, "Restricted still publishes motion (sensing)"); assert!( !log.published .iter() .any(|m| m.topic.contains("identity_risk")), "Restricted must NOT publish identity_risk topic", ); }