feat(adr-118/p4.6): BfldPipeline::process_to_frame wire-bytes path (152/152 GREEN)

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<IdentityEmbedding>,
  ) -> Option<BfldFrame>

  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<Mutex<BfldPipeline>> + 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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 16:37:11 -04:00
parent ac461f94fc
commit 24f63466c1
2 changed files with 184 additions and 1 deletions

View File

@ -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<IdentityEmbedding>,
) -> Option<BfldFrame> {
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]

View File

@ -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);
}