feat(core,signal): ADR-136 streaming-engine frame contracts (#840)
- ComplexSample LE wrapper (16-byte canonical encoding, serde tuple, as_complex32) - CsiMetadata gains calibration_id/model_id/model_version + append-only setters - CanonicalFrame trait + impl for CsiFrame (BLAKE3 witness, deterministic bytes) - Stage<I,O>/Versioned/QualityScored traits + FrameMeta alias in ruvsense - 9 ADR-136 acceptance tests (AC1-AC8); workspace builds, 0 errors Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
24d68dfa72
commit
11f89727f1
|
|
@ -10611,6 +10611,7 @@ name = "wifi-densepose-core"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"blake3",
|
||||||
"chrono",
|
"chrono",
|
||||||
"ndarray 0.17.2",
|
"ndarray 0.17.2",
|
||||||
"num-complex",
|
"num-complex",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||||
# UUID for unique identifiers
|
# UUID for unique identifiers
|
||||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# BLAKE3 witness hashing (ADR-136 CanonicalFrame; no_std-safe like wifi-densepose-bfld)
|
||||||
|
blake3 = { version = "1.5", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
proptest.workspace = true
|
proptest.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,13 @@ pub mod utils;
|
||||||
|
|
||||||
// Re-export commonly used types at the crate root
|
// Re-export commonly used types at the crate root
|
||||||
pub use error::{CoreError, CoreResult, InferenceError, SignalError, StorageError};
|
pub use error::{CoreError, CoreResult, InferenceError, SignalError, StorageError};
|
||||||
pub use traits::{DataStore, NeuralInference, SignalProcessor};
|
pub use traits::{CanonicalFrame, DataStore, NeuralInference, SignalProcessor};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AntennaConfig,
|
AntennaConfig,
|
||||||
// Bounding box
|
// Bounding box
|
||||||
BoundingBox,
|
BoundingBox,
|
||||||
|
// ADR-136 canonical complex-sample contract
|
||||||
|
ComplexSample,
|
||||||
// Common types
|
// Common types
|
||||||
Confidence,
|
Confidence,
|
||||||
// CSI types
|
// CSI types
|
||||||
|
|
@ -99,10 +101,10 @@ pub const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.5;
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
|
|
||||||
pub use crate::error::{CoreError, CoreResult};
|
pub use crate::error::{CoreError, CoreResult};
|
||||||
pub use crate::traits::{DataStore, NeuralInference, SignalProcessor};
|
pub use crate::traits::{CanonicalFrame, DataStore, NeuralInference, SignalProcessor};
|
||||||
pub use crate::types::{
|
pub use crate::types::{
|
||||||
AntennaConfig, BoundingBox, Confidence, CsiFrame, CsiMetadata, DeviceId, FrameId,
|
AntennaConfig, BoundingBox, ComplexSample, Confidence, CsiFrame, CsiMetadata, DeviceId,
|
||||||
FrequencyBand, Keypoint, KeypointType, PersonPose, PoseEstimate, ProcessedSignal,
|
FrameId, FrequencyBand, Keypoint, KeypointType, PersonPose, PoseEstimate, ProcessedSignal,
|
||||||
SignalFeatures, Timestamp,
|
SignalFeatures, Timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,46 @@
|
||||||
use crate::error::{CoreResult, InferenceError, SignalError, StorageError};
|
use crate::error::{CoreResult, InferenceError, SignalError, StorageError};
|
||||||
use crate::types::{CsiFrame, FrameId, PoseEstimate, ProcessedSignal, Timestamp};
|
use crate::types::{CsiFrame, FrameId, PoseEstimate, ProcessedSignal, Timestamp};
|
||||||
|
|
||||||
|
/// ADR-136 §2.5 — deterministic, architecture-independent frame serialisation.
|
||||||
|
///
|
||||||
|
/// Every frame type that crosses a [`Stage`](https://example.invalid) boundary
|
||||||
|
/// or is recorded/replayed (`homecore-recorder`) implements `CanonicalFrame`.
|
||||||
|
/// The encoding is stable across architectures (little-endian per ADR-136 §2.3,
|
||||||
|
/// via [`ComplexSample::to_le_bytes`](crate::types::ComplexSample::to_le_bytes))
|
||||||
|
/// and across runs (fixed field order), so a BLAKE3 of the bytes is a witness
|
||||||
|
/// hash compatible with the ADR-028 proof chain and the ADR-119
|
||||||
|
/// `signature_hasher` precedent.
|
||||||
|
///
|
||||||
|
/// # Determinism contract
|
||||||
|
///
|
||||||
|
/// Feeding a recorded `Vec<CsiFrame>` through the stage chain twice MUST yield
|
||||||
|
/// byte-identical output streams, verified by equal [`Self::witness_hash`].
|
||||||
|
pub trait CanonicalFrame {
|
||||||
|
/// Deterministic, architecture-independent encoding of this frame.
|
||||||
|
///
|
||||||
|
/// Rules (ADR-136 §2.5): fixed-width little-endian fields in declared order;
|
||||||
|
/// complex payload as `ComplexSample::to_le_bytes()` in stream-major order;
|
||||||
|
/// raw IEEE-754 LE only (no text formatting of floats).
|
||||||
|
fn to_canonical_bytes(&self) -> alloc_vec::Vec<u8>;
|
||||||
|
|
||||||
|
/// BLAKE3-256 of [`Self::to_canonical_bytes`] — the witness hash (ADR-028).
|
||||||
|
fn witness_hash(&self) -> [u8; 32] {
|
||||||
|
blake3::hash(&self.to_canonical_bytes()).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Vec` alias that works under both `std` and `no_std + alloc` (core is
|
||||||
|
// `#![cfg_attr(not(feature = "std"), no_std)]`). Keeps `CanonicalFrame` usable
|
||||||
|
// on the Xtensa/ESP32 target referenced by ADR-136 §2.3.
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
mod alloc_vec {
|
||||||
|
pub use std::vec::Vec;
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
mod alloc_vec {
|
||||||
|
pub use alloc::vec::Vec;
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for signal processing.
|
/// Configuration for signal processing.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,105 @@ use serde::{Deserialize, Serialize};
|
||||||
use crate::error::{CoreError, CoreResult};
|
use crate::error::{CoreError, CoreResult};
|
||||||
use crate::{DEFAULT_CONFIDENCE_THRESHOLD, MAX_KEYPOINTS};
|
use crate::{DEFAULT_CONFIDENCE_THRESHOLD, MAX_KEYPOINTS};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ADR-136 — Canonical complex sample contract
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Canonical complex sample for all RuView frame contracts (CSI, CIR, Doppler).
|
||||||
|
///
|
||||||
|
/// Wraps [`num_complex::Complex64`]. The `serde` impl and [`Self::to_le_bytes`]
|
||||||
|
/// write `(re, im)` as two little-endian `f64`, matching the ADR-119 endianness
|
||||||
|
/// guarantee so x86_64 (ruvultra), aarch64 (cognitum-v0), and Xtensa (ESP32-S3)
|
||||||
|
/// produce bit-identical bytes. Downstream `f32` paths (CIR taps, ADR-134;
|
||||||
|
/// NN inference, ADR-146) narrow on demand via [`Self::as_complex32`].
|
||||||
|
///
|
||||||
|
/// This is the *contract* representation used at stage boundaries and by the
|
||||||
|
/// deterministic [`CanonicalFrame`](crate::traits::CanonicalFrame) serialiser.
|
||||||
|
/// `CsiFrame.data` remains `Array2<Complex64>` for ndarray-native math.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct ComplexSample(pub Complex64);
|
||||||
|
|
||||||
|
impl ComplexSample {
|
||||||
|
/// Construct from real/imaginary `f64` parts.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(re: f64, im: f64) -> Self {
|
||||||
|
Self(Complex64::new(re, im))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Magnitude `|z|`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn norm(&self) -> f64 {
|
||||||
|
self.0.norm()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase angle `arg(z)` in radians.
|
||||||
|
#[must_use]
|
||||||
|
pub fn arg(&self) -> f64 {
|
||||||
|
self.0.arg()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Narrow to `f32` complex for CIR (ADR-134) / NN (ADR-146) paths.
|
||||||
|
///
|
||||||
|
/// This is a lossy *view*, never re-serialised as the witness form
|
||||||
|
/// (ADR-136 §3.3 risk mitigation — one encoder only).
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_complex32(&self) -> num_complex::Complex32 {
|
||||||
|
num_complex::Complex32::new(self.0.re as f32, self.0.im as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical 16-byte little-endian encoding: `re || im`, each `f64` LE.
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_le_bytes(&self) -> [u8; 16] {
|
||||||
|
let mut b = [0u8; 16];
|
||||||
|
b[0..8].copy_from_slice(&self.0.re.to_le_bytes());
|
||||||
|
b[8..16].copy_from_slice(&self.0.im.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode from the canonical 16-byte little-endian encoding.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_le_bytes(b: [u8; 16]) -> Self {
|
||||||
|
let mut re = [0u8; 8];
|
||||||
|
let mut im = [0u8; 8];
|
||||||
|
re.copy_from_slice(&b[0..8]);
|
||||||
|
im.copy_from_slice(&b[8..16]);
|
||||||
|
Self(Complex64::new(f64::from_le_bytes(re), f64::from_le_bytes(im)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Complex64> for ComplexSample {
|
||||||
|
fn from(z: Complex64) -> Self {
|
||||||
|
Self(z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ComplexSample> for Complex64 {
|
||||||
|
fn from(s: ComplexSample) -> Self {
|
||||||
|
s.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
impl Serialize for ComplexSample {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
// Two LE f64 — deterministic across architectures (ADR-136 §2.3).
|
||||||
|
use serde::ser::SerializeTuple;
|
||||||
|
let mut t = s.serialize_tuple(2)?;
|
||||||
|
t.serialize_element(&self.0.re)?;
|
||||||
|
t.serialize_element(&self.0.im)?;
|
||||||
|
t.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
impl<'de> Deserialize<'de> for ComplexSample {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let (re, im) = <(f64, f64)>::deserialize(d)?;
|
||||||
|
Ok(Self(Complex64::new(re, im)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Common Types
|
// Common Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -327,6 +426,23 @@ pub struct CsiMetadata {
|
||||||
pub noise_floor_dbm: i8,
|
pub noise_floor_dbm: i8,
|
||||||
/// Frame sequence number
|
/// Frame sequence number
|
||||||
pub sequence_number: u32,
|
pub sequence_number: u32,
|
||||||
|
|
||||||
|
/// UUID of the ADR-135 empty-room baseline subtracted from this frame
|
||||||
|
/// (ADR-136 §2.2). `None` ⇒ uncalibrated (no `BaselineCalibration::subtract()`
|
||||||
|
/// applied). Set only by the calibration stage; append-only thereafter.
|
||||||
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
pub calibration_id: Option<Uuid>,
|
||||||
|
|
||||||
|
/// Identifier of the RF encoder / model family consuming this frame
|
||||||
|
/// (ADR-136 §2.2, ADR-146). Stable across a deployment; `0` ⇒ unassigned.
|
||||||
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
pub model_id: u16,
|
||||||
|
|
||||||
|
/// Monotonic model version (ADR-119 §2.1 reserved-flag pattern: low byte
|
||||||
|
/// minor, high byte major). `0` ⇒ unassigned. Set only by the model-binding
|
||||||
|
/// stage; append-only thereafter.
|
||||||
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
pub model_version: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CsiMetadata {
|
impl CsiMetadata {
|
||||||
|
|
@ -343,9 +459,26 @@ impl CsiMetadata {
|
||||||
rssi_dbm: -50,
|
rssi_dbm: -50,
|
||||||
noise_floor_dbm: -90,
|
noise_floor_dbm: -90,
|
||||||
sequence_number: 0,
|
sequence_number: 0,
|
||||||
|
// ADR-136 provenance: unassigned until calibration / model-binding stages.
|
||||||
|
calibration_id: None,
|
||||||
|
model_id: 0,
|
||||||
|
model_version: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Binds the ADR-135 empty-room baseline that was subtracted from this
|
||||||
|
/// frame (ADR-136 §2.4 boundary rule — only the calibration stage calls this).
|
||||||
|
pub fn set_calibration(&mut self, calibration_id: Uuid) {
|
||||||
|
self.calibration_id = Some(calibration_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds the RF model family/version that will consume this frame
|
||||||
|
/// (ADR-136 §2.4 — only the model-binding stage calls this).
|
||||||
|
pub fn set_model(&mut self, model_id: u16, model_version: u16) {
|
||||||
|
self.model_id = model_id;
|
||||||
|
self.model_version = model_version;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the Signal-to-Noise Ratio in dB.
|
/// Returns the Signal-to-Noise Ratio in dB.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn snr_db(&self) -> f64 {
|
pub fn snr_db(&self) -> f64 {
|
||||||
|
|
@ -414,6 +547,73 @@ impl CsiFrame {
|
||||||
pub fn amplitude_variance(&self) -> f64 {
|
pub fn amplitude_variance(&self) -> f64 {
|
||||||
self.amplitude.var(0.0)
|
self.amplitude.var(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zero-allocation view of the complex payload as [`ComplexSample`]s in
|
||||||
|
/// stream-major (`[stream][subcarrier]`) order — the canonical contract
|
||||||
|
/// representation (ADR-136 §2.3) without copying the `ndarray` buffer.
|
||||||
|
pub fn data_complex_samples(&self) -> impl Iterator<Item = ComplexSample> + '_ {
|
||||||
|
self.data.iter().map(|z| ComplexSample(*z))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::traits::CanonicalFrame for CsiFrame {
|
||||||
|
/// Deterministic, architecture-independent encoding (ADR-136 §2.5).
|
||||||
|
///
|
||||||
|
/// Layout: frame id (16 UUID bytes) ‖ metadata fields in declared order
|
||||||
|
/// (each fixed-width LE; `device_id` length-prefixed; `calibration_id` as
|
||||||
|
/// 16 UUID bytes or 16 zero bytes for `None`) ‖ `(nrows, ncols)` as u32 LE
|
||||||
|
/// ‖ complex payload as `ComplexSample::to_le_bytes()` in stream-major order.
|
||||||
|
fn to_canonical_bytes(&self) -> Vec<u8> {
|
||||||
|
let m = &self.metadata;
|
||||||
|
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
|
||||||
|
let mut b = Vec::with_capacity(88 + 16 * self.data.len());
|
||||||
|
|
||||||
|
// Frame id.
|
||||||
|
b.extend_from_slice(self.id.as_uuid().as_bytes());
|
||||||
|
|
||||||
|
// Metadata, declared order.
|
||||||
|
b.extend_from_slice(&m.timestamp.seconds.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.timestamp.nanos.to_le_bytes());
|
||||||
|
let dev = m.device_id.as_str().as_bytes();
|
||||||
|
b.extend_from_slice(&(dev.len() as u32).to_le_bytes());
|
||||||
|
b.extend_from_slice(dev);
|
||||||
|
b.push(match m.frequency_band {
|
||||||
|
FrequencyBand::Band2_4GHz => 0,
|
||||||
|
FrequencyBand::Band5GHz => 1,
|
||||||
|
FrequencyBand::Band6GHz => 2,
|
||||||
|
});
|
||||||
|
b.push(m.channel);
|
||||||
|
b.extend_from_slice(&m.bandwidth_mhz.to_le_bytes());
|
||||||
|
b.push(m.antenna_config.tx_antennas);
|
||||||
|
b.push(m.antenna_config.rx_antennas);
|
||||||
|
match m.antenna_config.spacing_mm {
|
||||||
|
Some(s) => {
|
||||||
|
b.push(1);
|
||||||
|
b.extend_from_slice(&s.to_le_bytes());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
b.push(0);
|
||||||
|
b.extend_from_slice(&[0u8; 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.extend_from_slice(&m.rssi_dbm.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.sequence_number.to_le_bytes());
|
||||||
|
match m.calibration_id {
|
||||||
|
Some(id) => b.extend_from_slice(id.as_bytes()),
|
||||||
|
None => b.extend_from_slice(&[0u8; 16]),
|
||||||
|
}
|
||||||
|
b.extend_from_slice(&m.model_id.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.model_version.to_le_bytes());
|
||||||
|
|
||||||
|
// Shape, then complex payload stream-major.
|
||||||
|
b.extend_from_slice(&(self.data.nrows() as u32).to_le_bytes());
|
||||||
|
b.extend_from_slice(&(self.data.ncols() as u32).to_le_bytes());
|
||||||
|
for sample in self.data_complex_samples() {
|
||||||
|
b.extend_from_slice(&sample.to_le_bytes());
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -1040,6 +1240,106 @@ mod tests {
|
||||||
assert!((distance - 5.0).abs() < 0.001);
|
assert!((distance - 5.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== ADR-136 acceptance tests =====
|
||||||
|
use crate::traits::CanonicalFrame;
|
||||||
|
|
||||||
|
/// Deterministic LCG so the test needs no external RNG dependency.
|
||||||
|
fn lcg(state: &mut u64) -> f64 {
|
||||||
|
*state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||||
|
// Map high bits into [-1e6, 1e6) for a wide exponent spread.
|
||||||
|
((*state >> 11) as f64 / (1u64 << 53) as f64) * 2.0e6 - 1.0e6
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC1 — `ComplexSample` little-endian round-trip + endianness pin.
|
||||||
|
#[test]
|
||||||
|
fn ac1_complex_sample_le_roundtrip() {
|
||||||
|
let mut st = 42u64;
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
let (re, im) = (lcg(&mut st), lcg(&mut st));
|
||||||
|
let s = ComplexSample::new(re, im);
|
||||||
|
let bytes = s.to_le_bytes();
|
||||||
|
assert_eq!(ComplexSample::from_le_bytes(bytes), s, "LE round-trip");
|
||||||
|
// Byte 0 is the LSB of `re` encoded little-endian.
|
||||||
|
assert_eq!(bytes[0], re.to_le_bytes()[0], "endianness pin on re LSB");
|
||||||
|
assert_eq!(bytes[8], im.to_le_bytes()[0], "endianness pin on im LSB");
|
||||||
|
}
|
||||||
|
// NaN/inf survive the byte round-trip (bit-exact).
|
||||||
|
let edge = ComplexSample::new(f64::NAN, f64::INFINITY);
|
||||||
|
let rt = ComplexSample::from_le_bytes(edge.to_le_bytes());
|
||||||
|
assert!(rt.0.re.is_nan() && rt.0.im.is_infinite());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC2 — `FrameMeta` provenance defaults + append-only setters.
|
||||||
|
#[test]
|
||||||
|
fn ac2_frame_meta_provenance_defaults() {
|
||||||
|
let mut m = CsiMetadata::new(DeviceId::new("esp32-s3-com9"), FrequencyBand::Band2_4GHz, 6);
|
||||||
|
assert_eq!(m.calibration_id, None);
|
||||||
|
assert_eq!(m.model_id, 0);
|
||||||
|
assert_eq!(m.model_version, 0);
|
||||||
|
|
||||||
|
let cal = uuid::Uuid::new_v4();
|
||||||
|
m.set_calibration(cal);
|
||||||
|
m.set_model(7, 0x0102);
|
||||||
|
assert_eq!(m.calibration_id, Some(cal));
|
||||||
|
assert_eq!(m.model_id, 7);
|
||||||
|
assert_eq!(m.model_version, 0x0102);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC6 (frame-level) — `CanonicalFrame` is deterministic across runs and
|
||||||
|
/// sensitive to provenance changes.
|
||||||
|
#[test]
|
||||||
|
fn ac6_canonical_frame_witness_deterministic() {
|
||||||
|
use ndarray::Array2;
|
||||||
|
let meta = CsiMetadata::new(DeviceId::new("node-1"), FrequencyBand::Band5GHz, 36);
|
||||||
|
let data = Array2::from_shape_fn((3, 56), |(r, c)| {
|
||||||
|
Complex64::new((r * 56 + c) as f64 * 0.5, (c as f64).sin())
|
||||||
|
});
|
||||||
|
let frame = CsiFrame::new(meta, data);
|
||||||
|
|
||||||
|
// Same frame hashes identically twice (replay determinism, AC6).
|
||||||
|
assert_eq!(frame.witness_hash(), frame.witness_hash());
|
||||||
|
let bytes = frame.to_canonical_bytes();
|
||||||
|
assert_eq!(bytes.len(), frame.to_canonical_bytes().len());
|
||||||
|
|
||||||
|
// Changing provenance changes the witness (no silent collisions).
|
||||||
|
let mut frame2 = frame.clone();
|
||||||
|
frame2.metadata.set_model(1, 1);
|
||||||
|
assert_ne!(frame.witness_hash(), frame2.witness_hash());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
#[test]
|
||||||
|
fn ac3_serde_forward_read_legacy_metadata() {
|
||||||
|
// A pre-ADR-136 CsiMetadata payload without the three new fields.
|
||||||
|
let legacy = r#"{
|
||||||
|
"timestamp": {"seconds": 1700000000, "nanos": 0},
|
||||||
|
"device_id": "legacy-node",
|
||||||
|
"frequency_band": "Band2_4GHz",
|
||||||
|
"channel": 1,
|
||||||
|
"bandwidth_mhz": 20,
|
||||||
|
"antenna_config": {"tx_antennas": 1, "rx_antennas": 3, "spacing_mm": null},
|
||||||
|
"rssi_dbm": -50,
|
||||||
|
"noise_floor_dbm": -90,
|
||||||
|
"sequence_number": 0
|
||||||
|
}"#;
|
||||||
|
let m: CsiMetadata = serde_json::from_str(legacy).expect("legacy metadata must load");
|
||||||
|
assert_eq!(m.calibration_id, None);
|
||||||
|
assert_eq!(m.model_id, 0);
|
||||||
|
assert_eq!(m.model_version, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC1b — `ComplexSample` serde tuple form is the two LE f64 contract.
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
#[test]
|
||||||
|
fn ac1b_complex_sample_serde_tuple() {
|
||||||
|
let s = ComplexSample::new(1.5, -2.25);
|
||||||
|
let j = serde_json::to_string(&s).unwrap();
|
||||||
|
assert_eq!(j, "[1.5,-2.25]");
|
||||||
|
let back: ComplexSample = serde_json::from_str(&j).unwrap();
|
||||||
|
assert_eq!(back, s);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bounding_box_iou() {
|
fn test_bounding_box_iou() {
|
||||||
let box1 = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
|
let box1 = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,73 @@ pub enum RuvSenseError {
|
||||||
/// Common result type for RuvSense operations.
|
/// Common result type for RuvSense operations.
|
||||||
pub type Result<T> = std::result::Result<T, RuvSenseError>;
|
pub type Result<T> = std::result::Result<T, RuvSenseError>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ADR-136 — Streaming-engine contract surface (Stage / Versioned / QualityScored)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// `FrameMeta` is the streaming-engine vocabulary alias for the core
|
||||||
|
/// `CsiMetadata` (ADR-136 §2.2). It *is* the same struct — re-exported, not
|
||||||
|
/// copied — so cross-stage hops carry provenance (`calibration_id`, `model_id`,
|
||||||
|
/// `model_version`) without conversion cost.
|
||||||
|
pub use wifi_densepose_core::types::CsiMetadata as FrameMeta;
|
||||||
|
|
||||||
|
/// Result type returned by a [`Stage`] transform.
|
||||||
|
pub type StageResult<O> = std::result::Result<O, RuvSenseError>;
|
||||||
|
|
||||||
|
/// A pipeline stage that transforms one typed frame into another (ADR-136 §2.4).
|
||||||
|
///
|
||||||
|
/// Stages are `Send + Sync`. Determinism rule: given the same input bytes and
|
||||||
|
/// the same `&self` configuration, [`Stage::process`] MUST produce the same
|
||||||
|
/// output bytes (ADR-136 §2.5 replay contract). Mutable runtime state (rolling
|
||||||
|
/// windows, Welford accumulators) lives behind `&self` interior types whose
|
||||||
|
/// effect on output is captured by the deterministic-replay fixture.
|
||||||
|
///
|
||||||
|
/// **Boundary rule:** a stage never mutates its input's `FrameMeta.calibration_id`
|
||||||
|
/// or `model_id`/`model_version` except the calibration stage (sets
|
||||||
|
/// `calibration_id`) and the model-binding stage (sets the model fields). This
|
||||||
|
/// keeps provenance append-only along the chain.
|
||||||
|
pub trait Stage<I, O>: Send + Sync {
|
||||||
|
/// Human/stage identifier, e.g. `"phase_align"`, `"calibration"`.
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Transform one input frame into one output frame.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`RuvSenseError`] if the stage cannot process the input.
|
||||||
|
fn process(&self, input: I) -> StageResult<O>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward-compatible version stamp (ADR-136 §2.4, mirrors ADR-119 §2.1).
|
||||||
|
///
|
||||||
|
/// A `(major, minor)` pair plus a reserved-flags word so future revisions extend
|
||||||
|
/// without breaking the deterministic byte layout.
|
||||||
|
pub trait Versioned {
|
||||||
|
/// `(major, minor)` version of this stage's output contract.
|
||||||
|
fn version(&self) -> (u8, u8);
|
||||||
|
|
||||||
|
/// Reserved forward-compat flags (ADR-119 reserved bits 2..15). Default `0`.
|
||||||
|
fn reserved_flags(&self) -> u16 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if a consumer at `other` can consume output produced at
|
||||||
|
/// [`Self::version`] — equal major and `self.minor >= other.minor`.
|
||||||
|
fn is_compatible_with(&self, other: (u8, u8)) -> bool {
|
||||||
|
let (maj, min) = self.version();
|
||||||
|
maj == other.0 && min >= other.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stage output carrying a scalar quality score and a confidence interval
|
||||||
|
/// (ADR-136 §2.4). Consumed by ADR-137 (fusion quality) and ADR-145 (ablation).
|
||||||
|
pub trait QualityScored {
|
||||||
|
/// Scalar quality in `[0.0, 1.0]`; higher is better.
|
||||||
|
fn quality_score(&self) -> f32;
|
||||||
|
|
||||||
|
/// `(lower, upper)` confidence bounds with `0.0 <= lower <= upper <= 1.0`.
|
||||||
|
fn confidence_bounds(&self) -> (f32, f32);
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for the RuvSense pipeline.
|
/// Configuration for the RuvSense pipeline.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RuvSenseConfig {
|
pub struct RuvSenseConfig {
|
||||||
|
|
@ -298,6 +365,97 @@ mod tests {
|
||||||
assert_eq!(NUM_KEYPOINTS, 17);
|
assert_eq!(NUM_KEYPOINTS, 17);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== ADR-136 trait-surface acceptance tests =====
|
||||||
|
|
||||||
|
// Tiny stages forming a Stage<u32,u32> -> Stage<u32,String> chain (AC4).
|
||||||
|
struct Doubler;
|
||||||
|
impl Stage<u32, u32> for Doubler {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"doubler"
|
||||||
|
}
|
||||||
|
fn process(&self, input: u32) -> StageResult<u32> {
|
||||||
|
Ok(input * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Stringify;
|
||||||
|
impl Stage<u32, String> for Stringify {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"stringify"
|
||||||
|
}
|
||||||
|
fn process(&self, input: u32) -> StageResult<String> {
|
||||||
|
Ok(format!("v{input}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC4 — heterogeneous `Stage` chain composes and visits stages in order.
|
||||||
|
#[test]
|
||||||
|
fn ac4_stage_chain_composition() {
|
||||||
|
let s1 = Doubler;
|
||||||
|
let s2 = Stringify;
|
||||||
|
let mut visited = Vec::new();
|
||||||
|
visited.push(s1.name());
|
||||||
|
let mid = s1.process(21).unwrap();
|
||||||
|
visited.push(s2.name());
|
||||||
|
let out = s2.process(mid).unwrap();
|
||||||
|
assert_eq!(out, "v42");
|
||||||
|
assert_eq!(visited, vec!["doubler", "stringify"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct V(u8, u8);
|
||||||
|
impl Versioned for V {
|
||||||
|
fn version(&self) -> (u8, u8) {
|
||||||
|
(self.0, self.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC5 — `Versioned` compatibility: equal major, minor >= consumer's.
|
||||||
|
#[test]
|
||||||
|
fn ac5_versioned_compatibility() {
|
||||||
|
let v = V(1, 3);
|
||||||
|
assert!(v.is_compatible_with((1, 3)), "equal");
|
||||||
|
assert!(v.is_compatible_with((1, 0)), "newer minor accepts older consumer");
|
||||||
|
assert!(!v.is_compatible_with((1, 4)), "older producer rejects newer consumer");
|
||||||
|
assert!(!v.is_compatible_with((2, 0)), "major mismatch rejected");
|
||||||
|
assert_eq!(v.reserved_flags(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Q(f32, f32, f32);
|
||||||
|
impl QualityScored for Q {
|
||||||
|
fn quality_score(&self) -> f32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn confidence_bounds(&self) -> (f32, f32) {
|
||||||
|
(self.1, self.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC8 — `QualityScored` bounds invariant: 0 <= lower <= upper <= 1.
|
||||||
|
#[test]
|
||||||
|
fn ac8_quality_scored_bounds() {
|
||||||
|
let q = Q(0.9, 0.7, 0.95);
|
||||||
|
let s = q.quality_score();
|
||||||
|
let (lo, hi) = q.confidence_bounds();
|
||||||
|
assert!((0.0..=1.0).contains(&s));
|
||||||
|
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FrameMeta` is the same type as core `CsiMetadata` (ADR-136 §2.2).
|
||||||
|
#[test]
|
||||||
|
fn frame_meta_is_csi_metadata() {
|
||||||
|
fn assert_same<T>(_: &T, _: &T) {}
|
||||||
|
let a = FrameMeta::new(
|
||||||
|
wifi_densepose_core::types::DeviceId::new("n"),
|
||||||
|
wifi_densepose_core::types::FrequencyBand::Band2_4GHz,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let b = wifi_densepose_core::types::CsiMetadata::new(
|
||||||
|
wifi_densepose_core::types::DeviceId::new("n"),
|
||||||
|
wifi_densepose_core::types::FrequencyBand::Band2_4GHz,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_same(&a, &b); // compiles only if FrameMeta == CsiMetadata
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_config_pipeline() {
|
fn custom_config_pipeline() {
|
||||||
let cfg = RuvSenseConfig {
|
let cfg = RuvSenseConfig {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue