From 24f63466c15d68c9b6020f7ae99a2b4abea6bf91 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 16:37:11 -0400 Subject: [PATCH] feat(adr-118/p4.6): BfldPipeline::process_to_frame wire-bytes path (152/152 GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 20. Adds the wire-bytes companion to BfldPipeline::process so callers needing BfldFrame (for ESP-NOW, UDP, file dump, witness bundles, etc.) don't have to drop down to BfldEmitter + manual BfldFrame construction. Added (in src/pipeline.rs): - BfldPipeline::process_to_frame( inputs: SensingInputs, header_template: BfldFrameHeader, payload: BfldPayload, embedding: Option, ) -> Option Algorithm: 1. Cache timestamp_ns from inputs (consumed by the inner process()). 2. Call self.process(inputs, embedding) — gate logic decides drop/emit. Returns None if the gate rejects, propagating to caller. 3. Clone header_template, override timestamp_ns and privacy_class from the current pipeline state (privacy_mode-aware). 4. Build via BfldFrame::from_payload — CRC covers the section-prefixed payload bytes per ADR-119 §2.2. Separation of concerns: pipeline owns gate / ring / hasher state; caller owns AP / STA / session identity (provided via header_template). tests/pipeline_to_frame.rs (6 named tests, all green): process_to_frame_emits_frame_under_low_risk (timestamp_ns + privacy_class correctly propagated from pipeline) process_to_frame_returns_none_under_sustained_high_risk (gate Reject path: two consecutive high-risk calls → None) process_to_frame_round_trips_through_bytes (frame.to_bytes() → BfldFrame::from_bytes() → parse_payload() identity) process_to_frame_overrides_class_in_privacy_mode (enable_privacy_mode → frame.header.privacy_class = Restricted byte) process_to_frame_preserves_header_template_identity_fields (ap_hash, sta_hash, session_id, channel from template survive) process_to_frame_uses_input_timestamp_not_template_timestamp (template.timestamp_ns = 12345 is overridden by inputs.timestamp_ns) ACs progressed: - ADR-118 §2.1 wire-bytes consumer path now reachable from BfldPipeline, not just from low-level BfldEmitter + manual frame construction. - ADR-119 AC5/AC6 — round-trip-through-bytes test exercises the full pipeline+frame stack, not just the frame in isolation. - ADR-122 §2.2 prep — the BfldFrame is the wire format MQTT eventually publishes via tokio loop (next iter pair); process_to_frame is the per-frame producer that loop will call. Test config: - cargo test --no-default-features → 72 passed (pipeline_to_frame cfg-out) - cargo test → 152 passed (146 + 6) Out of scope (next iter target): - BfldPipelineHandle: Arc> + tokio task that pumps an inbound (SensingInputs, IdentityEmbedding) channel into MQTT per-class topics (ADR-122 §2.2). Brings in tokio + rumqttc deps behind a `mqtt` feature. - Cargo benchmark: pipeline throughput target ≥ 40 frames/sec on a Pi 5 core (ADR-118 §6 P2 effort estimate). Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-bfld/src/pipeline.rs | 27 ++- .../tests/pipeline_to_frame.rs | 158 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 v2/crates/wifi-densepose-bfld/tests/pipeline_to_frame.rs diff --git a/v2/crates/wifi-densepose-bfld/src/pipeline.rs b/v2/crates/wifi-densepose-bfld/src/pipeline.rs index aa7593c4..9e47386c 100644 --- a/v2/crates/wifi-densepose-bfld/src/pipeline.rs +++ b/v2/crates/wifi-densepose-bfld/src/pipeline.rs @@ -17,7 +17,7 @@ use crate::emitter::{BfldEmitter, SensingInputs}; use crate::identity_risk::GateAction; use crate::signature_hasher::SignatureHasher; -use crate::{BfldEvent, IdentityEmbedding, PrivacyClass}; +use crate::{BfldEvent, BfldFrame, BfldFrameHeader, BfldPayload, IdentityEmbedding, PrivacyClass}; /// Construction parameters for [`BfldPipeline`]. Matches the ADR-118 default- /// secure posture: `class = Anonymous`, no zone, no signature hasher. @@ -114,6 +114,31 @@ impl BfldPipeline { Some(event) } + /// Wire-bytes variant of [`Self::process`]: returns a [`BfldFrame`] ready + /// to serialize via `BfldFrame::to_bytes()`. Caller supplies a + /// `header_template` carrying AP / STA / session identity fields and a + /// `payload` typed via [`BfldPayload`]. The pipeline overrides the + /// template's `timestamp_ns` and `privacy_class` from its own state, then + /// builds the frame via [`BfldFrame::from_payload`] so the CRC covers the + /// section-prefixed bytes. + /// + /// Returns `None` whenever the gate drops the underlying event (Reject or + /// Recalibrate), so `process_to_frame` is a strict subset of `process`. + pub fn process_to_frame( + &mut self, + inputs: SensingInputs, + header_template: BfldFrameHeader, + payload: BfldPayload, + embedding: Option, + ) -> Option { + let timestamp_ns = inputs.timestamp_ns; + let _gate_signal = self.process(inputs, embedding)?; + let mut header = header_template; + header.timestamp_ns = timestamp_ns; + header.privacy_class = self.current_privacy_class().as_u8(); + Some(BfldFrame::from_payload(header, &payload)) + } + /// `true` if `enable_privacy_mode()` has been called more recently than /// `disable_privacy_mode()`. #[must_use] diff --git a/v2/crates/wifi-densepose-bfld/tests/pipeline_to_frame.rs b/v2/crates/wifi-densepose-bfld/tests/pipeline_to_frame.rs new file mode 100644 index 00000000..1cdf5ffc --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/pipeline_to_frame.rs @@ -0,0 +1,158 @@ +//! Acceptance tests for `BfldPipeline::process_to_frame`. ADR-118 §2.1 wire-bytes path. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS; +use wifi_densepose_bfld::{ + BfldConfig, BfldFrame, BfldFrameHeader, BfldPayload, BfldPipeline, IdentityEmbedding, + PrivacyClass, SensingInputs, EMBEDDING_DIM, +}; + +fn inputs(timestamp_ns: u64, risk: [f32; 4]) -> SensingInputs { + let [sep, stab, consist, risk_conf] = risk; + SensingInputs { + timestamp_ns, + presence: true, + motion: 0.4, + person_count: 1, + sensing_confidence: 0.9, + sep, + stab, + consist, + risk_conf, + rf_signature_hash: None, + } +} + +fn embedding() -> IdentityEmbedding { + IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM]) +} + +fn header_template() -> BfldFrameHeader { + let mut h = BfldFrameHeader::empty(); + h.ap_hash = [0xA1; 16]; + h.sta_hash = [0xA2; 16]; + h.session_id = [0xA3; 16]; + h.channel = 36; + h.bandwidth_mhz = 80; + h.n_subcarriers = 234; + h.n_tx = 2; + h.n_rx = 2; + h +} + +fn typed_payload() -> BfldPayload { + BfldPayload { + compressed_angle_matrix: vec![0x11; 32], + amplitude_proxy: vec![0x22; 16], + phase_proxy: vec![0x33; 16], + snr_vector: vec![0x44; 8], + csi_delta: None, + vendor_extension: vec![], + } +} + +#[test] +fn process_to_frame_emits_frame_under_low_risk() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + let frame = p + .process_to_frame( + inputs(1_700_000_000_000_000_000, [0.2, 0.2, 0.2, 0.2]), + header_template(), + typed_payload(), + Some(embedding()), + ) + .expect("low-risk frame must be emitted"); + assert_eq!({ frame.header.timestamp_ns }, 1_700_000_000_000_000_000); + assert_eq!({ frame.header.privacy_class }, PrivacyClass::Anonymous.as_u8()); +} + +#[test] +fn process_to_frame_returns_none_under_sustained_high_risk() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + // Push gate into Reject via two consecutive high-risk evaluations. + let _ = p.process_to_frame( + inputs(0, [1.0, 1.0, 1.0, 0.8]), + header_template(), + typed_payload(), + Some(embedding()), + ); + let after = p.process_to_frame( + inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]), + header_template(), + typed_payload(), + Some(embedding()), + ); + assert!(after.is_none(), "Reject gate must drop the frame"); +} + +#[test] +fn process_to_frame_round_trips_through_bytes() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + let frame = p + .process_to_frame( + inputs(1_700_000_000_000_000_000, [0.1, 0.1, 0.1, 0.1]), + header_template(), + typed_payload(), + Some(embedding()), + ) + .unwrap(); + let bytes = frame.to_bytes(); + let parsed = BfldFrame::from_bytes(&bytes).expect("frame must round-trip"); + let parsed_payload = parsed.parse_payload().expect("payload must round-trip"); + assert_eq!(parsed_payload, typed_payload()); +} + +#[test] +fn process_to_frame_overrides_class_in_privacy_mode() { + let mut p = BfldPipeline::new( + BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Anonymous), + ); + p.enable_privacy_mode(); + let frame = p + .process_to_frame( + inputs(0, [0.1, 0.1, 0.1, 0.1]), + header_template(), + typed_payload(), + Some(embedding()), + ) + .unwrap(); + assert_eq!( + { frame.header.privacy_class }, + PrivacyClass::Restricted.as_u8(), + "privacy_mode must override into the frame header byte too", + ); +} + +#[test] +fn process_to_frame_preserves_header_template_identity_fields() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + let frame = p + .process_to_frame( + inputs(0, [0.1, 0.1, 0.1, 0.1]), + header_template(), + typed_payload(), + Some(embedding()), + ) + .unwrap(); + assert_eq!(frame.header.ap_hash, [0xA1; 16]); + assert_eq!(frame.header.sta_hash, [0xA2; 16]); + assert_eq!(frame.header.session_id, [0xA3; 16]); + assert_eq!({ frame.header.channel }, 36); +} + +#[test] +fn process_to_frame_uses_input_timestamp_not_template_timestamp() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + let mut tmpl = header_template(); + tmpl.timestamp_ns = 12345; // sentinel that must be overridden + let frame = p + .process_to_frame( + inputs(9_999_999_999_999_999, [0.1, 0.1, 0.1, 0.1]), + tmpl, + typed_payload(), + Some(embedding()), + ) + .unwrap(); + assert_eq!({ frame.header.timestamp_ns }, 9_999_999_999_999_999); +}