From 11f89727f10631baca721d43994e4c33ae2925b0 Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 28 May 2026 22:54:48 -0400 Subject: [PATCH] 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/Versioned/QualityScored traits + FrameMeta alias in ruvsense - 9 ADR-136 acceptance tests (AC1-AC8); workspace builds, 0 errors Co-Authored-By: claude-flow --- v2/Cargo.lock | 1 + v2/crates/wifi-densepose-core/Cargo.toml | 3 + v2/crates/wifi-densepose-core/src/lib.rs | 10 +- v2/crates/wifi-densepose-core/src/traits.rs | 40 +++ v2/crates/wifi-densepose-core/src/types.rs | 300 ++++++++++++++++++ .../wifi-densepose-signal/src/ruvsense/mod.rs | 158 +++++++++ 6 files changed, 508 insertions(+), 4 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 62b6c3b0..a436476d 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -10611,6 +10611,7 @@ name = "wifi-densepose-core" version = "0.3.0" dependencies = [ "async-trait", + "blake3", "chrono", "ndarray 0.17.2", "num-complex", diff --git a/v2/crates/wifi-densepose-core/Cargo.toml b/v2/crates/wifi-densepose-core/Cargo.toml index 79cd9821..2d00a38d 100644 --- a/v2/crates/wifi-densepose-core/Cargo.toml +++ b/v2/crates/wifi-densepose-core/Cargo.toml @@ -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 diff --git a/v2/crates/wifi-densepose-core/src/lib.rs b/v2/crates/wifi-densepose-core/src/lib.rs index 001cb2e8..514d2ce6 100644 --- a/v2/crates/wifi-densepose-core/src/lib.rs +++ b/v2/crates/wifi-densepose-core/src/lib.rs @@ -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, }; } diff --git a/v2/crates/wifi-densepose-core/src/traits.rs b/v2/crates/wifi-densepose-core/src/traits.rs index d3bfb89e..1712333b 100644 --- a/v2/crates/wifi-densepose-core/src/traits.rs +++ b/v2/crates/wifi-densepose-core/src/traits.rs @@ -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` 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; + + /// 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))] diff --git a/v2/crates/wifi-densepose-core/src/types.rs b/v2/crates/wifi-densepose-core/src/types.rs index 8437a972..cbdf6376 100644 --- a/v2/crates/wifi-densepose-core/src/types.rs +++ b/v2/crates/wifi-densepose-core/src/types.rs @@ -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` 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 for ComplexSample { + fn from(z: Complex64) -> Self { + Self(z) + } +} + +impl From for Complex64 { + fn from(s: ComplexSample) -> Self { + s.0 + } +} + +#[cfg(feature = "serde")] +impl Serialize for ComplexSample { + fn serialize(&self, s: S) -> Result { + // 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: D) -> Result { + 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, + + /// 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 + '_ { + 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 { + 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); diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs index 17249c7a..0f2930c7 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs @@ -143,6 +143,73 @@ pub enum RuvSenseError { /// Common result type for RuvSense operations. pub type Result = std::result::Result; +// ============================================================================= +// 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 = std::result::Result; + +/// 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: 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; +} + +/// 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 -> Stage chain (AC4). + struct Doubler; + impl Stage for Doubler { + fn name(&self) -> &'static str { + "doubler" + } + fn process(&self, input: u32) -> StageResult { + Ok(input * 2) + } + } + struct Stringify; + impl Stage for Stringify { + fn name(&self) -> &'static str { + "stringify" + } + fn process(&self, input: u32) -> StageResult { + 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) {} + 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 {