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::emitter::{BfldEmitter, SensingInputs};
|
||||||
use crate::identity_risk::GateAction;
|
use crate::identity_risk::GateAction;
|
||||||
use crate::signature_hasher::SignatureHasher;
|
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-
|
/// Construction parameters for [`BfldPipeline`]. Matches the ADR-118 default-
|
||||||
/// secure posture: `class = Anonymous`, no zone, no signature hasher.
|
/// secure posture: `class = Anonymous`, no zone, no signature hasher.
|
||||||
|
|
@ -114,6 +114,31 @@ impl BfldPipeline {
|
||||||
Some(event)
|
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
|
/// `true` if `enable_privacy_mode()` has been called more recently than
|
||||||
/// `disable_privacy_mode()`.
|
/// `disable_privacy_mode()`.
|
||||||
#[must_use]
|
#[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