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"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||
# UUID for unique identifiers
|
||||
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]
|
||||
serde_json.workspace = true
|
||||
proptest.workspace = true
|
||||
|
|
|
|||
|
|
@ -53,11 +53,13 @@ pub mod utils;
|
|||
|
||||
// Re-export commonly used types at the crate root
|
||||
pub use error::{CoreError, CoreResult, InferenceError, SignalError, StorageError};
|
||||
pub use traits::{DataStore, NeuralInference, SignalProcessor};
|
||||
pub use traits::{CanonicalFrame, DataStore, NeuralInference, SignalProcessor};
|
||||
pub use types::{
|
||||
AntennaConfig,
|
||||
// Bounding box
|
||||
BoundingBox,
|
||||
// ADR-136 canonical complex-sample contract
|
||||
ComplexSample,
|
||||
// Common types
|
||||
Confidence,
|
||||
// CSI types
|
||||
|
|
@ -99,10 +101,10 @@ pub const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.5;
|
|||
pub mod prelude {
|
||||
|
||||
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::{
|
||||
AntennaConfig, BoundingBox, Confidence, CsiFrame, CsiMetadata, DeviceId, FrameId,
|
||||
FrequencyBand, Keypoint, KeypointType, PersonPose, PoseEstimate, ProcessedSignal,
|
||||
AntennaConfig, BoundingBox, ComplexSample, Confidence, CsiFrame, CsiMetadata, DeviceId,
|
||||
FrameId, FrequencyBand, Keypoint, KeypointType, PersonPose, PoseEstimate, ProcessedSignal,
|
||||
SignalFeatures, Timestamp,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,46 @@
|
|||
use crate::error::{CoreResult, InferenceError, SignalError, StorageError};
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,105 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::error::{CoreError, CoreResult};
|
||||
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
|
||||
// =============================================================================
|
||||
|
|
@ -327,6 +426,23 @@ pub struct CsiMetadata {
|
|||
pub noise_floor_dbm: i8,
|
||||
/// Frame sequence number
|
||||
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 {
|
||||
|
|
@ -343,9 +459,26 @@ impl CsiMetadata {
|
|||
rssi_dbm: -50,
|
||||
noise_floor_dbm: -90,
|
||||
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.
|
||||
#[must_use]
|
||||
pub fn snr_db(&self) -> f64 {
|
||||
|
|
@ -414,6 +547,73 @@ impl CsiFrame {
|
|||
pub fn amplitude_variance(&self) -> f64 {
|
||||
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);
|
||||
}
|
||||
|
||||
// ===== 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]
|
||||
fn test_bounding_box_iou() {
|
||||
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.
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuvSenseConfig {
|
||||
|
|
@ -298,6 +365,97 @@ mod tests {
|
|||
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]
|
||||
fn custom_config_pipeline() {
|
||||
let cfg = RuvSenseConfig {
|
||||
|
|
|
|||
Loading…
Reference in New Issue