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:
parent
ac461f94fc
commit
24f63466c1
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue