511 lines
17 KiB
Rust
511 lines
17 KiB
Rust
//! RuvSense -- Sensing-First RF Mode for Multistatic WiFi DensePose (ADR-029)
|
|
//!
|
|
//! This bounded context implements the multistatic sensing pipeline that fuses
|
|
//! CSI from multiple ESP32 nodes across multiple WiFi channels into a single
|
|
//! coherent sensing frame per 50 ms TDMA cycle (20 Hz output).
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! The pipeline flows through six stages:
|
|
//!
|
|
//! 1. **Multi-Band Fusion** (`multiband`) -- Aggregate per-channel CSI frames
|
|
//! from channel-hopping into a wideband virtual snapshot per node.
|
|
//! 2. **Phase Alignment** (`phase_align`) -- Correct LO-induced phase rotation
|
|
//! between channels using `ruvector-solver::NeumannSolver`.
|
|
//! 3. **Multistatic Fusion** (`multistatic`) -- Fuse N node observations into
|
|
//! a single `FusedSensingFrame` with attention-based cross-node weighting
|
|
//! via `ruvector-attn-mincut`.
|
|
//! 4. **Coherence Scoring** (`coherence`) -- Compute per-subcarrier z-score
|
|
//! coherence against a rolling reference template.
|
|
//! 5. **Coherence Gating** (`coherence_gate`) -- Apply threshold-based gate
|
|
//! decision: Accept / PredictOnly / Reject / Recalibrate.
|
|
//! 6. **Pose Tracking** (`pose_tracker`) -- 17-keypoint Kalman tracker with
|
|
//! lifecycle state machine and AETHER re-ID embedding support.
|
|
//!
|
|
//! # RuVector Crate Usage
|
|
//!
|
|
//! - `ruvector-solver` -- Phase alignment, coherence decomposition
|
|
//! - `ruvector-attn-mincut` -- Cross-node spectrogram fusion
|
|
//! - `ruvector-mincut` -- Person separation and track assignment
|
|
//! - `ruvector-attention` -- Cross-channel feature weighting
|
|
//!
|
|
//! # References
|
|
//!
|
|
//! - ADR-029: Project RuvSense
|
|
//! - IEEE 802.11bf-2024 WLAN Sensing
|
|
|
|
// ADR-030: Exotic sensing tiers
|
|
pub mod adversarial;
|
|
pub mod cross_room;
|
|
pub mod field_model;
|
|
pub mod gesture;
|
|
pub mod intention;
|
|
pub mod longitudinal;
|
|
pub mod tomography;
|
|
|
|
// ADR-032a: Midstreamer-enhanced sensing
|
|
pub mod attractor_drift;
|
|
pub mod temporal_gesture;
|
|
|
|
// ADR-029: Core multistatic pipeline
|
|
pub mod coherence;
|
|
pub mod coherence_gate;
|
|
pub mod multiband;
|
|
pub mod multistatic;
|
|
pub mod phase_align;
|
|
pub mod pose_tracker;
|
|
|
|
// ADR-134: CIR estimation (ISTA + NeumannSolver warm-start)
|
|
pub mod cir;
|
|
|
|
// ADR-137: Fusion-engine quality scoring (evidence + contradiction flags)
|
|
pub mod fusion_quality;
|
|
|
|
// ADR-138: Array coordinator — clock-quality gating + directional evidence
|
|
pub mod array_coordinator;
|
|
|
|
// ADR-142: Evolution tracker + temporal VoxelMap (Bayesian, privacy-gated)
|
|
pub mod evolution;
|
|
|
|
// ADR-143: RF-SLAM persistent reflector discovery + static-anchor learning
|
|
pub mod rf_slam;
|
|
|
|
// ADR-135: Empty-room baseline calibration (Welford online, circular phase)
|
|
pub mod calibration;
|
|
|
|
// Re-export core types for ergonomic access
|
|
pub use coherence::CoherenceState;
|
|
pub use coherence_gate::{GateDecision, GatePolicy};
|
|
pub use array_coordinator::{
|
|
ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, DirectionalEvidence,
|
|
};
|
|
pub use evolution::{
|
|
ChangePoint, EvolutionTracker, TemporalVoxel, TemporalVoxelMap, VoxelGate, VoxelPrivacy,
|
|
};
|
|
pub use rf_slam::{PersistentReflector, ReflectorClass, ReflectorObservation, RfSlam};
|
|
pub use fusion_quality::{
|
|
CalibrationId, ContradictionFlag, EvidenceRef, FamilyId, QualityScore,
|
|
};
|
|
pub use multiband::MultiBandCsiFrame;
|
|
pub use multistatic::FusedSensingFrame;
|
|
pub use phase_align::{PhaseAlignError, PhaseAligner};
|
|
pub use pose_tracker::{
|
|
CompressedPoseHistory, KeypointState, PoseTrack, SkeletonConstraints,
|
|
TemporalKeypointAttention, TrackLifecycleState, TrackerConfig,
|
|
};
|
|
|
|
/// Number of keypoints in a full-body pose skeleton (COCO-17).
|
|
pub const NUM_KEYPOINTS: usize = 17;
|
|
|
|
/// Keypoint indices following the COCO-17 convention.
|
|
pub mod keypoint {
|
|
pub const NOSE: usize = 0;
|
|
pub const LEFT_EYE: usize = 1;
|
|
pub const RIGHT_EYE: usize = 2;
|
|
pub const LEFT_EAR: usize = 3;
|
|
pub const RIGHT_EAR: usize = 4;
|
|
pub const LEFT_SHOULDER: usize = 5;
|
|
pub const RIGHT_SHOULDER: usize = 6;
|
|
pub const LEFT_ELBOW: usize = 7;
|
|
pub const RIGHT_ELBOW: usize = 8;
|
|
pub const LEFT_WRIST: usize = 9;
|
|
pub const RIGHT_WRIST: usize = 10;
|
|
pub const LEFT_HIP: usize = 11;
|
|
pub const RIGHT_HIP: usize = 12;
|
|
pub const LEFT_KNEE: usize = 13;
|
|
pub const RIGHT_KNEE: usize = 14;
|
|
pub const LEFT_ANKLE: usize = 15;
|
|
pub const RIGHT_ANKLE: usize = 16;
|
|
|
|
/// Torso keypoint indices (shoulders, hips, spine midpoint proxy).
|
|
pub const TORSO_INDICES: &[usize] = &[LEFT_SHOULDER, RIGHT_SHOULDER, LEFT_HIP, RIGHT_HIP];
|
|
}
|
|
|
|
/// Unique identifier for a pose track.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct TrackId(pub u64);
|
|
|
|
impl TrackId {
|
|
/// Create a new track identifier.
|
|
pub fn new(id: u64) -> Self {
|
|
Self(id)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for TrackId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "Track({})", self.0)
|
|
}
|
|
}
|
|
|
|
/// Error type shared across the RuvSense pipeline.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum RuvSenseError {
|
|
/// Phase alignment failed.
|
|
#[error("Phase alignment error: {0}")]
|
|
PhaseAlign(#[from] phase_align::PhaseAlignError),
|
|
|
|
/// Multi-band fusion error.
|
|
#[error("Multi-band fusion error: {0}")]
|
|
MultiBand(#[from] multiband::MultiBandError),
|
|
|
|
/// Multistatic fusion error.
|
|
#[error("Multistatic fusion error: {0}")]
|
|
Multistatic(#[from] multistatic::MultistaticError),
|
|
|
|
/// Coherence computation error.
|
|
#[error("Coherence error: {0}")]
|
|
Coherence(#[from] coherence::CoherenceError),
|
|
|
|
/// Pose tracker error.
|
|
#[error("Pose tracker error: {0}")]
|
|
PoseTracker(#[from] pose_tracker::PoseTrackerError),
|
|
}
|
|
|
|
/// 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 {
|
|
/// Maximum number of nodes in the multistatic mesh.
|
|
pub max_nodes: usize,
|
|
/// Target output rate in Hz.
|
|
pub target_hz: f64,
|
|
/// Number of channels in the hop sequence.
|
|
pub num_channels: usize,
|
|
/// Coherence accept threshold (default 0.85).
|
|
pub coherence_accept: f32,
|
|
/// Coherence drift threshold (default 0.5).
|
|
pub coherence_drift: f32,
|
|
/// Maximum stale frames before recalibration (default 200 = 10s at 20Hz).
|
|
pub max_stale_frames: u64,
|
|
/// Embedding dimension for AETHER re-ID (default 128).
|
|
pub embedding_dim: usize,
|
|
}
|
|
|
|
impl Default for RuvSenseConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_nodes: 4,
|
|
target_hz: 20.0,
|
|
num_channels: 3,
|
|
coherence_accept: 0.85,
|
|
coherence_drift: 0.5,
|
|
max_stale_frames: 200,
|
|
embedding_dim: 128,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top-level pipeline orchestrator for RuvSense multistatic sensing.
|
|
///
|
|
/// Coordinates the flow from raw per-node CSI frames through multi-band
|
|
/// fusion, phase alignment, multistatic fusion, coherence gating, and
|
|
/// finally into the pose tracker.
|
|
pub struct RuvSensePipeline {
|
|
config: RuvSenseConfig,
|
|
#[allow(dead_code)]
|
|
phase_aligner: PhaseAligner,
|
|
coherence_state: CoherenceState,
|
|
#[allow(dead_code)]
|
|
gate_policy: GatePolicy,
|
|
frame_counter: u64,
|
|
}
|
|
|
|
impl RuvSensePipeline {
|
|
/// Create a new pipeline with default configuration.
|
|
pub fn new() -> Self {
|
|
Self::with_config(RuvSenseConfig::default())
|
|
}
|
|
|
|
/// Create a new pipeline with the given configuration.
|
|
pub fn with_config(config: RuvSenseConfig) -> Self {
|
|
let n_sub = 56; // canonical subcarrier count
|
|
Self {
|
|
phase_aligner: PhaseAligner::new(config.num_channels),
|
|
coherence_state: CoherenceState::new(n_sub, config.coherence_accept),
|
|
gate_policy: GatePolicy::new(
|
|
config.coherence_accept,
|
|
config.coherence_drift,
|
|
config.max_stale_frames,
|
|
),
|
|
config,
|
|
frame_counter: 0,
|
|
}
|
|
}
|
|
|
|
/// Return a reference to the current pipeline configuration.
|
|
pub fn config(&self) -> &RuvSenseConfig {
|
|
&self.config
|
|
}
|
|
|
|
/// Return the total number of frames processed.
|
|
pub fn frame_count(&self) -> u64 {
|
|
self.frame_counter
|
|
}
|
|
|
|
/// Return a reference to the current coherence state.
|
|
pub fn coherence_state(&self) -> &CoherenceState {
|
|
&self.coherence_state
|
|
}
|
|
|
|
/// Advance the frame counter (called once per sensing cycle).
|
|
pub fn tick(&mut self) {
|
|
self.frame_counter += 1;
|
|
}
|
|
}
|
|
|
|
impl Default for RuvSensePipeline {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_config_values() {
|
|
let cfg = RuvSenseConfig::default();
|
|
assert_eq!(cfg.max_nodes, 4);
|
|
assert!((cfg.target_hz - 20.0).abs() < f64::EPSILON);
|
|
assert_eq!(cfg.num_channels, 3);
|
|
assert!((cfg.coherence_accept - 0.85).abs() < f32::EPSILON);
|
|
assert!((cfg.coherence_drift - 0.5).abs() < f32::EPSILON);
|
|
assert_eq!(cfg.max_stale_frames, 200);
|
|
assert_eq!(cfg.embedding_dim, 128);
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_creation_defaults() {
|
|
let pipe = RuvSensePipeline::new();
|
|
assert_eq!(pipe.frame_count(), 0);
|
|
assert_eq!(pipe.config().max_nodes, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_tick_increments() {
|
|
let mut pipe = RuvSensePipeline::new();
|
|
pipe.tick();
|
|
pipe.tick();
|
|
pipe.tick();
|
|
assert_eq!(pipe.frame_count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn track_id_display() {
|
|
let tid = TrackId::new(42);
|
|
assert_eq!(format!("{}", tid), "Track(42)");
|
|
assert_eq!(tid.0, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn track_id_equality() {
|
|
assert_eq!(TrackId(1), TrackId(1));
|
|
assert_ne!(TrackId(1), TrackId(2));
|
|
}
|
|
|
|
#[test]
|
|
fn keypoint_constants() {
|
|
assert_eq!(keypoint::NOSE, 0);
|
|
assert_eq!(keypoint::LEFT_ANKLE, 15);
|
|
assert_eq!(keypoint::RIGHT_ANKLE, 16);
|
|
assert_eq!(keypoint::TORSO_INDICES.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn num_keypoints_is_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]
|
|
fn custom_config_pipeline() {
|
|
let cfg = RuvSenseConfig {
|
|
max_nodes: 6,
|
|
target_hz: 10.0,
|
|
num_channels: 6,
|
|
coherence_accept: 0.9,
|
|
coherence_drift: 0.4,
|
|
max_stale_frames: 100,
|
|
embedding_dim: 64,
|
|
};
|
|
let pipe = RuvSensePipeline::with_config(cfg);
|
|
assert_eq!(pipe.config().max_nodes, 6);
|
|
assert!((pipe.config().target_hz - 10.0).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn error_display() {
|
|
let err = RuvSenseError::Coherence(coherence::CoherenceError::EmptyInput);
|
|
let msg = format!("{}", err);
|
|
assert!(msg.contains("Coherence"));
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_coherence_state_accessible() {
|
|
let pipe = RuvSensePipeline::new();
|
|
let cs = pipe.coherence_state();
|
|
assert!(cs.score() >= 0.0);
|
|
}
|
|
}
|