diff --git a/README.md b/README.md
index c82b2d0..34f6376 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
[](https://www.rust-lang.org/)
[](https://opensource.org/licenses/MIT)
-[](https://github.com/ruvnet/wifi-densepose)
+[](https://github.com/ruvnet/wifi-densepose)
[](https://hub.docker.com/r/ruvnet/wifi-densepose)
[](#vital-sign-detection)
[](#esp32-s3-hardware-pipeline)
@@ -49,7 +49,7 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
| [WiFi-Mat User Guide](docs/wifi-mat-user-guide.md) | Disaster response module: search & rescue, START triage |
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
-| [Architecture Decisions](docs/adr/) | 31 ADRs covering signal processing, training, hardware, security, domain generalization, multistatic sensing |
+| [Architecture Decisions](docs/adr/) | 33 ADRs covering signal processing, training, hardware, security, domain generalization, multistatic sensing, CRV signal-line integration |
| [DDD Domain Model](docs/ddd/ruvsense-domain-model.md) | RuvSense bounded contexts, aggregates, domain events, and ubiquitous language |
---
@@ -80,6 +80,7 @@ The system learns on its own and gets smarter over time — no hand-tuning, no l
| 🎯 | **AI Signal Processing** | Attention networks, graph algorithms, and smart compression replace hand-tuned thresholds — adapts to each room automatically ([RuVector](https://github.com/ruvnet/ruvector)) |
| 🌍 | **Works Everywhere** | Train once, deploy in any room — adversarial domain generalization strips environment bias so models transfer across rooms, buildings, and hardware ([ADR-027](docs/adr/ADR-027-cross-environment-domain-generalization.md)) |
| 👁️ | **Cross-Viewpoint Fusion** | Learned attention fuses multiple viewpoints with geometric bias — reduces body occlusion and depth ambiguity that physics prevents any single sensor from solving ([ADR-031](docs/adr/ADR-031-ruview-sensing-first-rf-mode.md)) |
+| 🔮 | **Signal-Line Protocol** | `ruvector-crv` 6-stage CRV pipeline maps CSI sensing to Poincare ball embeddings, GNN topology, SNN temporal encoding, and MinCut partitioning | -- |
### Performance & Deployment
@@ -113,6 +114,8 @@ Signal Processing: Hampel, SpotFi, Fresnel, BVP, spectrogram → clean features
↓
AI Backbone (RuVector): attention, graph algorithms, compression, field model
↓
+Signal-Line Protocol (CRV): 6-stage gestalt → sensory → topology → coherence → search → model
+ ↓
Neural Network: processed signals → 17 body keypoints + vital signs + room model
↓
Output: real-time pose, breathing, heart rate, room fingerprint, drift alerts
@@ -463,6 +466,48 @@ See the ADR documents for full architectural details, GOAP integration plans, an
+
+🔮 Signal-Line Protocol (CRV)
+
+### 6-Stage CSI Signal Line
+
+Maps the CRV (Coordinate Remote Viewing) signal-line methodology to WiFi CSI processing via `ruvector-crv`:
+
+| Stage | CRV Name | WiFi CSI Mapping | ruvector Component |
+|-------|----------|-----------------|-------------------|
+| I | Ideograms | Raw CSI gestalt (manmade/natural/movement/energy) | Poincare ball hyperbolic embeddings |
+| II | Sensory | Amplitude textures, phase patterns, frequency colors | Multi-head attention vectors |
+| III | Dimensional | AP mesh spatial topology, node geometry | GNN graph topology |
+| IV | Emotional/AOL | Coherence gating — signal vs noise separation | SNN temporal encoding |
+| V | Interrogation | Cross-stage probing — query pose against CSI history | Differentiable search |
+| VI | 3D Model | Composite person estimation, MinCut partitioning | Graph partitioning |
+
+**Cross-Session Convergence**: When multiple AP clusters observe the same person, CRV convergence analysis finds agreement in their signal embeddings — directly mapping to cross-room identity continuity.
+
+```rust
+use wifi_densepose_ruvector::crv::WifiCrvPipeline;
+
+let mut pipeline = WifiCrvPipeline::new(WifiCrvConfig::default());
+pipeline.create_session("room-a", "person-001")?;
+
+// Process CSI frames through 6-stage pipeline
+let result = pipeline.process_csi_frame("room-a", &litudes, &phases)?;
+// result.gestalt = Movement, confidence = 0.87
+// result.sensory_embedding = [0.12, -0.34, ...]
+
+// Cross-room identity matching via convergence
+let convergence = pipeline.find_cross_room_convergence("person-001", 0.75)?;
+```
+
+**Architecture**:
+- `CsiGestaltClassifier` — Maps CSI amplitude/phase patterns to 6 gestalt types
+- `CsiSensoryEncoder` — Extracts texture/color/temperature/luminosity features from subcarriers
+- `MeshTopologyEncoder` — Encodes AP mesh as GNN graph (Stage III)
+- `CoherenceAolDetector` — Maps coherence gate states to AOL noise detection (Stage IV)
+- `WifiCrvPipeline` — Orchestrates all 6 stages into unified sensing session
+
+
+
---
## 📦 Installation
@@ -1539,6 +1584,9 @@ Multistatic sensing, persistent field model, and cross-viewpoint fusion — the
- **TDM Hardware Protocol** — ESP32 sensing coordinator: sync beacons, slot scheduling, clock drift compensation (±10ppm), 20 Hz aggregate rate
- **Channel-Hopping Firmware** — ESP32 firmware extended with hop table, timer-driven channel switching, NDP injection stub; NVS config for all TDM parameters; fully backward-compatible
- **DDD Domain Model** — 6 bounded contexts, ubiquitous language, aggregate roots, domain events, full event bus specification
+- **`ruvector-crv` 6-stage CRV signal-line integration (ADR-033)** — Maps Coordinate Remote Viewing methodology to WiFi CSI: gestalt classification, sensory encoding, GNN topology, SNN coherence gating, differentiable search, MinCut partitioning; cross-session convergence for multi-room identity continuity
+- **ADR-032 multistatic mesh security hardening** — Bounded calibration buffers, atomic counters, division-by-zero guards, NaN-safe normalization across all multistatic modules
+- **ADR-033 CRV signal-line sensing integration** — Architecture decision record for the 6-stage CRV pipeline mapping to ruvector components
- **9,000+ lines of new Rust code** across 17 modules with 300+ tests
- **Security hardened** — Bounded buffers, NaN guards, no panics in public APIs, input validation at all boundaries
diff --git a/docs/adr/ADR-032-multistatic-mesh-security-hardening.md b/docs/adr/ADR-032-multistatic-mesh-security-hardening.md
index ea72457..168a5f3 100644
--- a/docs/adr/ADR-032-multistatic-mesh-security-hardening.md
+++ b/docs/adr/ADR-032-multistatic-mesh-security-hardening.md
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
-| **Status** | Proposed |
+| **Status** | Accepted |
| **Date** | 2026-03-01 |
| **Deciders** | ruv |
| **Relates to** | ADR-029 (RuvSense Multistatic), ADR-030 (Persistent Field Model), ADR-031 (RuView Sensing-First RF), ADR-018 (ESP32 Implementation), ADR-012 (ESP32 Mesh) |
@@ -403,19 +403,96 @@ Default: 1 (transitional, for backward compatibility during rollout)
---
-## 6. Related ADRs
+## 6. QUIC Transport Layer (ADR-032a Amendment)
-| ADR | Relationship |
-|-----|-------------|
-| ADR-029 (RuvSense Multistatic) | **Hardened**: TDM beacon and CSI frame authentication, NDP rate limiting |
-| ADR-030 (Persistent Field Model) | **Protected**: Coherence gate timeout prevents indefinite recalibration; transition log bounded |
-| ADR-031 (RuView RF Mode) | **Hardened**: Authenticated beacons protect cross-viewpoint synchronization |
-| ADR-018 (ESP32 Implementation) | **Extended**: CSI frame header bumped to v2 with SipHash tag; backward-compatible magic check |
-| ADR-012 (ESP32 Mesh) | **Hardened**: Mesh key management, NVS credential zeroing, atomic firmware state |
+### 6.1 Motivation
+
+The original ADR-032 design (Sections 2.1--2.2) uses manual HMAC-SHA256 and SipHash-2-4 over plain UDP. While correct and efficient on constrained ESP32 hardware, this approach has operational drawbacks:
+
+- **Manual key rotation**: Requires custom key exchange protocol and coordinator broadcast.
+- **No congestion control**: Plain UDP has no backpressure; burst CSI traffic can overwhelm the aggregator.
+- **No connection migration**: Node roaming (e.g., repositioning an ESP32) requires manual reconnect.
+- **Duplicate replay-window code**: Custom nonce tracking duplicates QUIC's built-in replay protection.
+
+### 6.2 Decision: Adopt `midstreamer-quic` for Aggregator Uplinks
+
+For aggregator-class nodes (Raspberry Pi, x86 gateway) that have sufficient CPU and memory, replace the manual crypto layer with `midstreamer-quic` v0.1.0, which provides:
+
+| Capability | Manual (ADR-032 original) | QUIC (`midstreamer-quic`) |
+|---|---|---|
+| Authentication | HMAC-SHA256 truncated 8B | TLS 1.3 AEAD (AES-128-GCM) |
+| Frame integrity | SipHash-2-4 tag | QUIC packet-level AEAD |
+| Replay protection | Manual nonce + window | QUIC packet numbers (monotonic) |
+| Key rotation | Custom coordinator broadcast | TLS 1.3 `KeyUpdate` message |
+| Congestion control | None | QUIC cubic/BBR |
+| Connection migration | Not supported | QUIC connection ID migration |
+| Multi-stream | N/A | QUIC streams (beacon, CSI, control) |
+
+**Constrained devices (ESP32-S3) retain the manual crypto path** from Sections 2.1--2.2 as a fallback. The `SecurityMode` enum selects the transport:
+
+```rust
+pub enum SecurityMode {
+ /// Manual HMAC/SipHash over plain UDP (ESP32-S3, ADR-032 original).
+ ManualCrypto,
+ /// QUIC transport with TLS 1.3 (aggregator-class nodes).
+ QuicTransport,
+}
+```
+
+### 6.3 QUIC Stream Mapping
+
+Three dedicated QUIC streams separate traffic by priority:
+
+| Stream ID | Purpose | Direction | Priority |
+|---|---|---|---|
+| 0 | Sync beacons | Coordinator -> Nodes | Highest (TDM timing-critical) |
+| 1 | CSI frames | Nodes -> Aggregator | High (sensing data) |
+| 2 | Control plane | Bidirectional | Normal (config, key rotation, health) |
+
+### 6.4 Additional Midstreamer Integrations
+
+Beyond QUIC transport, three additional midstreamer crates enhance the sensing pipeline:
+
+1. **`midstreamer-scheduler` v0.1.0** -- Replaces manual timer-based TDM slot scheduling with an ultra-low-latency real-time task scheduler. Provides deterministic slot firing with sub-microsecond jitter.
+
+2. **`midstreamer-temporal-compare` v0.1.0** -- Enhances gesture DTW matching (ADR-030 Tier 6) with temporal sequence comparison primitives. Provides optimized Sakoe-Chiba band DTW, LCS, and edit-distance kernels.
+
+3. **`midstreamer-attractor` v0.1.0** -- Enhances longitudinal drift detection (ADR-030 Tier 4) with dynamical systems analysis. Detects phase-space attractor shifts that indicate biomechanical regime changes before they manifest as simple metric drift.
+
+### 6.5 Fallback Strategy
+
+The QUIC transport layer is additive, not a replacement:
+
+- **ESP32-S3 nodes**: Continue using manual HMAC/SipHash over UDP (Sections 2.1--2.2). These devices lack the memory for a full TLS 1.3 stack.
+- **Aggregator nodes**: Use `midstreamer-quic` by default. Fall back to manual crypto if QUIC handshake fails (e.g., network partitions).
+- **Mixed deployments**: The aggregator auto-detects whether an incoming connection is QUIC (by TLS ClientHello) or plain UDP (by magic byte) and routes accordingly.
+
+### 6.6 Acceptance Criteria (QUIC)
+
+| ID | Criterion | Test Method |
+|----|-----------|-------------|
+| Q-1 | QUIC connection established between two nodes within 100ms | Integration test: connect, measure handshake time |
+| Q-2 | Beacon stream delivers beacons with < 1ms jitter | Unit test: send 1000 beacons, measure inter-arrival variance |
+| Q-3 | CSI stream achieves >= 95% of plain UDP throughput | Benchmark: criterion comparison |
+| Q-4 | Connection migration succeeds after simulated IP change | Integration test: rebind, verify stream continuity |
+| Q-5 | Fallback to manual crypto when QUIC unavailable | Unit test: reject QUIC, verify ManualCrypto path |
+| Q-6 | SecurityMode::ManualCrypto produces identical wire format to ADR-032 original | Unit test: byte-level comparison |
---
-## 7. References
+## 7. Related ADRs
+
+| ADR | Relationship |
+|-----|-------------|
+| ADR-029 (RuvSense Multistatic) | **Hardened**: TDM beacon and CSI frame authentication, NDP rate limiting, QUIC transport |
+| ADR-030 (Persistent Field Model) | **Protected**: Coherence gate timeout; transition log bounded; gesture DTW enhanced (midstreamer-temporal-compare); drift detection enhanced (midstreamer-attractor) |
+| ADR-031 (RuView RF Mode) | **Hardened**: Authenticated beacons protect cross-viewpoint synchronization via QUIC streams |
+| ADR-018 (ESP32 Implementation) | **Extended**: CSI frame header bumped to v2 with SipHash tag; backward-compatible magic check |
+| ADR-012 (ESP32 Mesh) | **Hardened**: Mesh key management, NVS credential zeroing, atomic firmware state, QUIC connection migration |
+
+---
+
+## 8. References
1. Aumasson, J.-P. & Bernstein, D.J. (2012). "SipHash: a fast short-input PRF." INDOCRYPT 2012.
2. Krawczyk, H. et al. (1997). "HMAC: Keyed-Hashing for Message Authentication." RFC 2104.
@@ -423,3 +500,8 @@ Default: 1 (transitional, for backward compatibility during rollout)
4. Espressif. "ESP32-S3 Technical Reference Manual." Section 26: SHA Accelerator.
5. Turner, J. (2006). "Token Bucket Rate Limiting." RFC 2697 (adapted).
6. ADR-029 through ADR-031 (internal).
+7. `midstreamer-quic` v0.1.0 -- QUIC multi-stream support. crates.io.
+8. `midstreamer-scheduler` v0.1.0 -- Ultra-low-latency real-time task scheduler. crates.io.
+9. `midstreamer-temporal-compare` v0.1.0 -- Temporal sequence comparison. crates.io.
+10. `midstreamer-attractor` v0.1.0 -- Dynamical systems analysis. crates.io.
+11. Iyengar, J. & Thomson, M. (2021). "QUIC: A UDP-Based Multiplexed and Secure Transport." RFC 9000.
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/Cargo.toml b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/Cargo.toml
index b8a3672..a198b77 100644
--- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/Cargo.toml
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/Cargo.toml
@@ -36,5 +36,18 @@ tracing = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+# QUIC transport (ADR-032a)
+midstreamer-quic = { workspace = true }
+# Real-time TDM scheduling (ADR-032a)
+midstreamer-scheduler = { workspace = true }
+# Async runtime
+tokio = { workspace = true }
+
[dev-dependencies]
approx = "0.5"
+criterion = { version = "0.5", features = ["html_reports"] }
+tokio = { workspace = true }
+
+[[bench]]
+name = "transport_bench"
+harness = false
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/benches/transport_bench.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/benches/transport_bench.rs
new file mode 100644
index 0000000..1f6bb17
--- /dev/null
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/benches/transport_bench.rs
@@ -0,0 +1,196 @@
+//! Benchmarks comparing manual crypto vs QUIC transport for TDM beacons.
+//!
+//! Measures:
+//! - Beacon serialization (16-byte vs 28-byte vs QUIC-framed)
+//! - Beacon verification throughput
+//! - Replay window check performance
+//! - FramedMessage encode/decode throughput
+
+use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
+use std::time::Duration;
+use wifi_densepose_hardware::esp32::{
+ TdmSchedule, SyncBeacon, SecurityMode, QuicTransportConfig,
+ SecureTdmCoordinator, SecureTdmConfig, SecLevel,
+ AuthenticatedBeacon, ReplayWindow, FramedMessage, MessageType,
+};
+
+fn make_beacon() -> SyncBeacon {
+ SyncBeacon {
+ cycle_id: 42,
+ cycle_period: Duration::from_millis(50),
+ drift_correction_us: -3,
+ generated_at: std::time::Instant::now(),
+ }
+}
+
+fn bench_beacon_serialize_plain(c: &mut Criterion) {
+ let beacon = make_beacon();
+ c.bench_function("beacon_serialize_16byte", |b| {
+ b.iter(|| {
+ black_box(beacon.to_bytes());
+ });
+ });
+}
+
+fn bench_beacon_serialize_authenticated(c: &mut Criterion) {
+ let beacon = make_beacon();
+ let key = [0x01u8; 16];
+ let nonce = 1u32;
+ let mut msg = [0u8; 20];
+ msg[..16].copy_from_slice(&beacon.to_bytes());
+ msg[16..20].copy_from_slice(&nonce.to_le_bytes());
+
+ c.bench_function("beacon_serialize_28byte_auth", |b| {
+ b.iter(|| {
+ let tag = AuthenticatedBeacon::compute_tag(black_box(&msg), &key);
+ black_box(AuthenticatedBeacon {
+ beacon: beacon.clone(),
+ nonce,
+ hmac_tag: tag,
+ }
+ .to_bytes());
+ });
+ });
+}
+
+fn bench_beacon_serialize_quic_framed(c: &mut Criterion) {
+ let beacon = make_beacon();
+
+ c.bench_function("beacon_serialize_quic_framed", |b| {
+ b.iter(|| {
+ let bytes = beacon.to_bytes();
+ let framed = FramedMessage::new(MessageType::Beacon, bytes.to_vec());
+ black_box(framed.to_bytes());
+ });
+ });
+}
+
+fn bench_auth_beacon_verify(c: &mut Criterion) {
+ let beacon = make_beacon();
+ let key = [0x01u8; 16];
+ let nonce = 1u32;
+ let mut msg = [0u8; 20];
+ msg[..16].copy_from_slice(&beacon.to_bytes());
+ msg[16..20].copy_from_slice(&nonce.to_le_bytes());
+ let tag = AuthenticatedBeacon::compute_tag(&msg, &key);
+ let auth = AuthenticatedBeacon {
+ beacon,
+ nonce,
+ hmac_tag: tag,
+ };
+
+ c.bench_function("auth_beacon_verify", |b| {
+ b.iter(|| {
+ black_box(auth.verify(&key)).unwrap();
+ });
+ });
+}
+
+fn bench_replay_window(c: &mut Criterion) {
+ let mut group = c.benchmark_group("replay_window");
+
+ for window_size in [4u32, 16, 64, 256] {
+ group.bench_with_input(
+ BenchmarkId::new("check_accept", window_size),
+ &window_size,
+ |b, &ws| {
+ b.iter(|| {
+ let mut rw = ReplayWindow::new(ws);
+ for i in 0..1000u32 {
+ black_box(rw.accept(i));
+ }
+ });
+ },
+ );
+ }
+ group.finish();
+}
+
+fn bench_framed_message_roundtrip(c: &mut Criterion) {
+ let mut group = c.benchmark_group("framed_message");
+
+ for payload_size in [16usize, 128, 512, 2048] {
+ let payload = vec![0xABu8; payload_size];
+ let msg = FramedMessage::new(MessageType::CsiFrame, payload);
+ let bytes = msg.to_bytes();
+
+ group.bench_with_input(
+ BenchmarkId::new("encode", payload_size),
+ &msg,
+ |b, msg| {
+ b.iter(|| {
+ black_box(msg.to_bytes());
+ });
+ },
+ );
+
+ group.bench_with_input(
+ BenchmarkId::new("decode", payload_size),
+ &bytes,
+ |b, bytes| {
+ b.iter(|| {
+ black_box(FramedMessage::from_bytes(bytes));
+ });
+ },
+ );
+ }
+ group.finish();
+}
+
+fn bench_secure_coordinator_cycle(c: &mut Criterion) {
+ let mut group = c.benchmark_group("secure_tdm_cycle");
+
+ // Manual crypto mode
+ group.bench_function("manual_crypto", |b| {
+ let schedule = TdmSchedule::default_4node();
+ let config = SecureTdmConfig {
+ security_mode: SecurityMode::ManualCrypto,
+ mesh_key: Some([0x01u8; 16]),
+ quic_config: QuicTransportConfig::default(),
+ sec_level: SecLevel::Transitional,
+ };
+ let mut coord = SecureTdmCoordinator::new(schedule, config).unwrap();
+
+ b.iter(|| {
+ let output = coord.begin_secure_cycle().unwrap();
+ black_box(&output.authenticated_bytes);
+ for i in 0..4 {
+ coord.complete_slot(i, 0.95);
+ }
+ });
+ });
+
+ // QUIC mode
+ group.bench_function("quic_transport", |b| {
+ let schedule = TdmSchedule::default_4node();
+ let config = SecureTdmConfig {
+ security_mode: SecurityMode::QuicTransport,
+ mesh_key: Some([0x01u8; 16]),
+ quic_config: QuicTransportConfig::default(),
+ sec_level: SecLevel::Transitional,
+ };
+ let mut coord = SecureTdmCoordinator::new(schedule, config).unwrap();
+
+ b.iter(|| {
+ let output = coord.begin_secure_cycle().unwrap();
+ black_box(&output.authenticated_bytes);
+ for i in 0..4 {
+ coord.complete_slot(i, 0.95);
+ }
+ });
+ });
+
+ group.finish();
+}
+
+criterion_group!(
+ benches,
+ bench_beacon_serialize_plain,
+ bench_beacon_serialize_authenticated,
+ bench_beacon_serialize_quic_framed,
+ bench_auth_beacon_verify,
+ bench_replay_window,
+ bench_framed_message_roundtrip,
+ bench_secure_coordinator_cycle,
+);
+criterion_main!(benches);
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/mod.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/mod.rs
index 4d6d267..0f8c274 100644
--- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/mod.rs
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/mod.rs
@@ -3,10 +3,29 @@
//! Implements sensing-first RF protocols for ESP32-S3 mesh nodes,
//! including TDM (Time-Division Multiplexed) sensing schedules
//! per ADR-029 (RuvSense) and ADR-031 (RuView).
+//!
+//! ## Security (ADR-032 / ADR-032a)
+//!
+//! - `quic_transport` -- QUIC-based authenticated transport for aggregator nodes
+//! - `secure_tdm` -- Secured TDM protocol with dual-mode (QUIC / manual crypto)
pub mod tdm;
+pub mod quic_transport;
+pub mod secure_tdm;
pub use tdm::{
TdmSchedule, TdmCoordinator, TdmSlot, TdmSlotCompleted,
SyncBeacon, TdmError,
};
+
+pub use quic_transport::{
+ SecurityMode, QuicTransportConfig, QuicTransportHandle, QuicTransportError,
+ TransportStats, ConnectionState, MessageType, FramedMessage,
+ STREAM_BEACON, STREAM_CSI, STREAM_CONTROL,
+};
+
+pub use secure_tdm::{
+ SecureTdmCoordinator, SecureTdmConfig, SecureTdmError,
+ SecLevel, AuthenticatedBeacon, SecureCycleOutput,
+ ReplayWindow, AUTHENTICATED_BEACON_SIZE,
+};
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/quic_transport.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/quic_transport.rs
new file mode 100644
index 0000000..9529f18
--- /dev/null
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/quic_transport.rs
@@ -0,0 +1,856 @@
+//! QUIC transport layer for multistatic mesh communication (ADR-032a).
+//!
+//! Wraps `midstreamer-quic` to provide authenticated, encrypted, and
+//! congestion-controlled transport for TDM beacons, CSI frames, and
+//! control plane messages between aggregator-class nodes.
+//!
+//! # Stream Mapping
+//!
+//! | Stream ID | Purpose | Direction | Priority |
+//! |---|---|---|---|
+//! | 0 | Sync beacons | Coordinator -> Nodes | Highest |
+//! | 1 | CSI frames | Nodes -> Aggregator | High |
+//! | 2 | Control plane | Bidirectional | Normal |
+//!
+//! # Fallback
+//!
+//! Constrained devices (ESP32-S3) use the manual crypto path from
+//! ADR-032 sections 2.1-2.2. The `SecurityMode` enum selects transport.
+
+use std::fmt;
+
+// ---------------------------------------------------------------------------
+// Stream identifiers
+// ---------------------------------------------------------------------------
+
+/// QUIC stream ID for sync beacon traffic (highest priority).
+pub const STREAM_BEACON: u64 = 0;
+
+/// QUIC stream ID for CSI frame traffic (high priority).
+pub const STREAM_CSI: u64 = 1;
+
+/// QUIC stream ID for control plane traffic (normal priority).
+pub const STREAM_CONTROL: u64 = 2;
+
+// ---------------------------------------------------------------------------
+// Security mode
+// ---------------------------------------------------------------------------
+
+/// Transport security mode selection (ADR-032a).
+///
+/// Determines whether communication uses manual HMAC/SipHash over
+/// plain UDP (for constrained ESP32-S3 devices) or QUIC with TLS 1.3
+/// (for aggregator-class nodes).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SecurityMode {
+ /// Manual HMAC-SHA256 beacon auth + SipHash-2-4 frame integrity
+ /// over plain UDP. Suitable for ESP32-S3 with limited memory.
+ ManualCrypto,
+ /// QUIC transport with TLS 1.3 AEAD encryption, built-in replay
+ /// protection, congestion control, and connection migration.
+ QuicTransport,
+}
+
+impl Default for SecurityMode {
+ fn default() -> Self {
+ SecurityMode::QuicTransport
+ }
+}
+
+impl fmt::Display for SecurityMode {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ SecurityMode::ManualCrypto => write!(f, "ManualCrypto (UDP + HMAC/SipHash)"),
+ SecurityMode::QuicTransport => write!(f, "QuicTransport (QUIC + TLS 1.3)"),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Errors
+// ---------------------------------------------------------------------------
+
+/// Errors from the QUIC transport layer.
+#[derive(Debug, Clone, PartialEq)]
+pub enum QuicTransportError {
+ /// Connection to the remote endpoint failed.
+ ConnectionFailed { reason: String },
+ /// The QUIC handshake did not complete within the timeout.
+ HandshakeTimeout { timeout_ms: u64 },
+ /// A stream could not be opened (e.g., stream limit reached).
+ StreamOpenFailed { stream_id: u64 },
+ /// Sending data on a stream failed.
+ SendFailed { stream_id: u64, reason: String },
+ /// Receiving data from a stream failed.
+ ReceiveFailed { stream_id: u64, reason: String },
+ /// The connection was closed by the remote peer.
+ ConnectionClosed { error_code: u64 },
+ /// Invalid configuration parameter.
+ InvalidConfig { param: String, reason: String },
+ /// Fallback to manual crypto was triggered.
+ FallbackTriggered { reason: String },
+}
+
+impl fmt::Display for QuicTransportError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ QuicTransportError::ConnectionFailed { reason } => {
+ write!(f, "QUIC connection failed: {}", reason)
+ }
+ QuicTransportError::HandshakeTimeout { timeout_ms } => {
+ write!(f, "QUIC handshake timed out after {} ms", timeout_ms)
+ }
+ QuicTransportError::StreamOpenFailed { stream_id } => {
+ write!(f, "Failed to open QUIC stream {}", stream_id)
+ }
+ QuicTransportError::SendFailed { stream_id, reason } => {
+ write!(f, "Send failed on stream {}: {}", stream_id, reason)
+ }
+ QuicTransportError::ReceiveFailed { stream_id, reason } => {
+ write!(f, "Receive failed on stream {}: {}", stream_id, reason)
+ }
+ QuicTransportError::ConnectionClosed { error_code } => {
+ write!(f, "Connection closed with error code {}", error_code)
+ }
+ QuicTransportError::InvalidConfig { param, reason } => {
+ write!(f, "Invalid config '{}': {}", param, reason)
+ }
+ QuicTransportError::FallbackTriggered { reason } => {
+ write!(f, "Fallback to manual crypto: {}", reason)
+ }
+ }
+ }
+}
+
+impl std::error::Error for QuicTransportError {}
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+/// Configuration for the QUIC transport layer.
+#[derive(Debug, Clone)]
+pub struct QuicTransportConfig {
+ /// Bind address for the QUIC endpoint (e.g., "0.0.0.0:4433").
+ pub bind_addr: String,
+ /// Handshake timeout in milliseconds.
+ pub handshake_timeout_ms: u64,
+ /// Keep-alive interval in milliseconds (0 = disabled).
+ pub keepalive_ms: u64,
+ /// Maximum idle timeout in milliseconds.
+ pub idle_timeout_ms: u64,
+ /// Maximum number of concurrent bidirectional streams.
+ pub max_streams: u64,
+ /// Whether to enable connection migration.
+ pub enable_migration: bool,
+ /// Security mode (QUIC or manual crypto fallback).
+ pub security_mode: SecurityMode,
+ /// Maximum datagram size (QUIC transport parameter).
+ pub max_datagram_size: usize,
+}
+
+impl Default for QuicTransportConfig {
+ fn default() -> Self {
+ Self {
+ bind_addr: "0.0.0.0:4433".to_string(),
+ handshake_timeout_ms: 100,
+ keepalive_ms: 5_000,
+ idle_timeout_ms: 30_000,
+ max_streams: 8,
+ enable_migration: true,
+ security_mode: SecurityMode::QuicTransport,
+ max_datagram_size: 1350,
+ }
+ }
+}
+
+impl QuicTransportConfig {
+ /// Validate the configuration, returning an error if invalid.
+ pub fn validate(&self) -> Result<(), QuicTransportError> {
+ if self.bind_addr.is_empty() {
+ return Err(QuicTransportError::InvalidConfig {
+ param: "bind_addr".into(),
+ reason: "must not be empty".into(),
+ });
+ }
+ if self.handshake_timeout_ms == 0 {
+ return Err(QuicTransportError::InvalidConfig {
+ param: "handshake_timeout_ms".into(),
+ reason: "must be > 0".into(),
+ });
+ }
+ if self.max_streams == 0 {
+ return Err(QuicTransportError::InvalidConfig {
+ param: "max_streams".into(),
+ reason: "must be > 0".into(),
+ });
+ }
+ if self.max_datagram_size < 100 {
+ return Err(QuicTransportError::InvalidConfig {
+ param: "max_datagram_size".into(),
+ reason: "must be >= 100 bytes".into(),
+ });
+ }
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Transport statistics
+// ---------------------------------------------------------------------------
+
+/// Runtime statistics for the QUIC transport.
+#[derive(Debug, Clone, Default)]
+pub struct TransportStats {
+ /// Total bytes sent across all streams.
+ pub bytes_sent: u64,
+ /// Total bytes received across all streams.
+ pub bytes_received: u64,
+ /// Number of beacons sent on stream 0.
+ pub beacons_sent: u64,
+ /// Number of beacons received on stream 0.
+ pub beacons_received: u64,
+ /// Number of CSI frames sent on stream 1.
+ pub csi_frames_sent: u64,
+ /// Number of CSI frames received on stream 1.
+ pub csi_frames_received: u64,
+ /// Number of control messages exchanged on stream 2.
+ pub control_messages: u64,
+ /// Number of connection migrations completed.
+ pub migrations_completed: u64,
+ /// Number of times fallback to manual crypto was used.
+ pub fallback_count: u64,
+ /// Current round-trip time estimate in microseconds.
+ pub rtt_us: u64,
+}
+
+impl TransportStats {
+ /// Total packets processed (sent + received across all types).
+ pub fn total_packets(&self) -> u64 {
+ self.beacons_sent
+ + self.beacons_received
+ + self.csi_frames_sent
+ + self.csi_frames_received
+ + self.control_messages
+ }
+
+ /// Reset all counters to zero.
+ pub fn reset(&mut self) {
+ *self = Self::default();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Message types
+// ---------------------------------------------------------------------------
+
+/// Message type tag for QUIC stream multiplexing.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub enum MessageType {
+ /// Sync beacon (stream 0).
+ Beacon = 0x01,
+ /// CSI frame data (stream 1).
+ CsiFrame = 0x02,
+ /// Control plane command (stream 2).
+ Control = 0x03,
+ /// Heartbeat / keepalive.
+ Heartbeat = 0x04,
+ /// Key rotation notification.
+ KeyRotation = 0x05,
+}
+
+impl MessageType {
+ /// Parse a message type from a byte tag.
+ pub fn from_byte(b: u8) -> Option {
+ match b {
+ 0x01 => Some(MessageType::Beacon),
+ 0x02 => Some(MessageType::CsiFrame),
+ 0x03 => Some(MessageType::Control),
+ 0x04 => Some(MessageType::Heartbeat),
+ 0x05 => Some(MessageType::KeyRotation),
+ _ => None,
+ }
+ }
+
+ /// Convert to the stream ID this message type should use.
+ pub fn stream_id(&self) -> u64 {
+ match self {
+ MessageType::Beacon => STREAM_BEACON,
+ MessageType::CsiFrame => STREAM_CSI,
+ MessageType::Control | MessageType::Heartbeat | MessageType::KeyRotation => {
+ STREAM_CONTROL
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Framed message
+// ---------------------------------------------------------------------------
+
+/// A framed message for QUIC stream transport.
+///
+/// Wire format:
+/// ```text
+/// [0] message_type (u8)
+/// [1..5] payload_len (LE u32)
+/// [5..5+N] payload (N bytes)
+/// ```
+#[derive(Debug, Clone)]
+pub struct FramedMessage {
+ /// Type of this message.
+ pub message_type: MessageType,
+ /// Raw payload bytes.
+ pub payload: Vec,
+}
+
+/// Header size for a framed message (1 byte type + 4 bytes length).
+pub const FRAMED_HEADER_SIZE: usize = 5;
+
+impl FramedMessage {
+ /// Create a new framed message.
+ pub fn new(message_type: MessageType, payload: Vec) -> Self {
+ Self {
+ message_type,
+ payload,
+ }
+ }
+
+ /// Serialize the message to bytes (header + payload).
+ pub fn to_bytes(&self) -> Vec {
+ let len = self.payload.len() as u32;
+ let mut buf = Vec::with_capacity(FRAMED_HEADER_SIZE + self.payload.len());
+ buf.push(self.message_type as u8);
+ buf.extend_from_slice(&len.to_le_bytes());
+ buf.extend_from_slice(&self.payload);
+ buf
+ }
+
+ /// Deserialize a framed message from bytes.
+ ///
+ /// Returns the message and the number of bytes consumed, or `None`
+ /// if the buffer is too short or the message type is invalid.
+ pub fn from_bytes(buf: &[u8]) -> Option<(Self, usize)> {
+ if buf.len() < FRAMED_HEADER_SIZE {
+ return None;
+ }
+ let msg_type = MessageType::from_byte(buf[0])?;
+ let payload_len =
+ u32::from_le_bytes([buf[1], buf[2], buf[3], buf[4]]) as usize;
+ let total = FRAMED_HEADER_SIZE + payload_len;
+ if buf.len() < total {
+ return None;
+ }
+ let payload = buf[FRAMED_HEADER_SIZE..total].to_vec();
+ Some((
+ Self {
+ message_type: msg_type,
+ payload,
+ },
+ total,
+ ))
+ }
+
+ /// Total wire size of this message.
+ pub fn wire_size(&self) -> usize {
+ FRAMED_HEADER_SIZE + self.payload.len()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// QUIC transport handle
+// ---------------------------------------------------------------------------
+
+/// Connection state for the QUIC transport.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConnectionState {
+ /// Not connected.
+ Disconnected,
+ /// TLS handshake in progress.
+ Connecting,
+ /// Connection established, streams available.
+ Connected,
+ /// Connection is draining (graceful close in progress).
+ Draining,
+ /// Connection closed (terminal state).
+ Closed,
+}
+
+impl fmt::Display for ConnectionState {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ConnectionState::Disconnected => write!(f, "Disconnected"),
+ ConnectionState::Connecting => write!(f, "Connecting"),
+ ConnectionState::Connected => write!(f, "Connected"),
+ ConnectionState::Draining => write!(f, "Draining"),
+ ConnectionState::Closed => write!(f, "Closed"),
+ }
+ }
+}
+
+/// QUIC transport handle for a single connection.
+///
+/// Manages the lifecycle of a QUIC connection, including handshake,
+/// stream management, and graceful shutdown. In production, this wraps
+/// the `midstreamer-quic` connection object.
+#[derive(Debug)]
+pub struct QuicTransportHandle {
+ /// Configuration used to create this handle.
+ config: QuicTransportConfig,
+ /// Current connection state.
+ state: ConnectionState,
+ /// Transport statistics.
+ stats: TransportStats,
+ /// Remote peer address (populated after connect).
+ remote_addr: Option,
+ /// Active security mode (may differ from config if fallback occurred).
+ active_mode: SecurityMode,
+}
+
+impl QuicTransportHandle {
+ /// Create a new transport handle with the given configuration.
+ pub fn new(config: QuicTransportConfig) -> Result {
+ config.validate()?;
+ let mode = config.security_mode;
+ Ok(Self {
+ config,
+ state: ConnectionState::Disconnected,
+ stats: TransportStats::default(),
+ remote_addr: None,
+ active_mode: mode,
+ })
+ }
+
+ /// Current connection state.
+ pub fn state(&self) -> ConnectionState {
+ self.state
+ }
+
+ /// Active security mode.
+ pub fn active_mode(&self) -> SecurityMode {
+ self.active_mode
+ }
+
+ /// Reference to transport statistics.
+ pub fn stats(&self) -> &TransportStats {
+ &self.stats
+ }
+
+ /// Mutable reference to transport statistics.
+ pub fn stats_mut(&mut self) -> &mut TransportStats {
+ &mut self.stats
+ }
+
+ /// Reference to the configuration.
+ pub fn config(&self) -> &QuicTransportConfig {
+ &self.config
+ }
+
+ /// Remote peer address (if connected).
+ pub fn remote_addr(&self) -> Option<&str> {
+ self.remote_addr.as_deref()
+ }
+
+ /// Simulate initiating a connection to a remote peer.
+ ///
+ /// In production, this would perform the QUIC handshake via
+ /// `midstreamer-quic`. Here we model the state transitions.
+ pub fn connect(&mut self, remote_addr: &str) -> Result<(), QuicTransportError> {
+ if remote_addr.is_empty() {
+ return Err(QuicTransportError::ConnectionFailed {
+ reason: "empty remote address".into(),
+ });
+ }
+ self.state = ConnectionState::Connecting;
+ // In production: midstreamer_quic::connect(remote_addr, &self.config)
+ self.remote_addr = Some(remote_addr.to_string());
+ self.state = ConnectionState::Connected;
+ Ok(())
+ }
+
+ /// Record a beacon sent on stream 0.
+ pub fn record_beacon_sent(&mut self, size: usize) {
+ self.stats.beacons_sent += 1;
+ self.stats.bytes_sent += size as u64;
+ }
+
+ /// Record a beacon received on stream 0.
+ pub fn record_beacon_received(&mut self, size: usize) {
+ self.stats.beacons_received += 1;
+ self.stats.bytes_received += size as u64;
+ }
+
+ /// Record a CSI frame sent on stream 1.
+ pub fn record_csi_sent(&mut self, size: usize) {
+ self.stats.csi_frames_sent += 1;
+ self.stats.bytes_sent += size as u64;
+ }
+
+ /// Record a CSI frame received on stream 1.
+ pub fn record_csi_received(&mut self, size: usize) {
+ self.stats.csi_frames_received += 1;
+ self.stats.bytes_received += size as u64;
+ }
+
+ /// Record a control message on stream 2.
+ pub fn record_control_message(&mut self, size: usize) {
+ self.stats.control_messages += 1;
+ self.stats.bytes_sent += size as u64;
+ }
+
+ /// Trigger fallback to manual crypto mode.
+ pub fn trigger_fallback(&mut self, reason: &str) -> Result<(), QuicTransportError> {
+ self.active_mode = SecurityMode::ManualCrypto;
+ self.stats.fallback_count += 1;
+ self.state = ConnectionState::Disconnected;
+ Err(QuicTransportError::FallbackTriggered {
+ reason: reason.to_string(),
+ })
+ }
+
+ /// Gracefully close the connection.
+ pub fn close(&mut self) {
+ if self.state == ConnectionState::Connected {
+ self.state = ConnectionState::Draining;
+ }
+ self.state = ConnectionState::Closed;
+ }
+
+ /// Whether the connection is in a usable state.
+ pub fn is_connected(&self) -> bool {
+ self.state == ConnectionState::Connected
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ---- SecurityMode tests ----
+
+ #[test]
+ fn test_security_mode_default() {
+ assert_eq!(SecurityMode::default(), SecurityMode::QuicTransport);
+ }
+
+ #[test]
+ fn test_security_mode_display() {
+ let quic = format!("{}", SecurityMode::QuicTransport);
+ assert!(quic.contains("QUIC"));
+ assert!(quic.contains("TLS 1.3"));
+
+ let manual = format!("{}", SecurityMode::ManualCrypto);
+ assert!(manual.contains("ManualCrypto"));
+ assert!(manual.contains("HMAC"));
+ }
+
+ #[test]
+ fn test_security_mode_equality() {
+ assert_eq!(SecurityMode::QuicTransport, SecurityMode::QuicTransport);
+ assert_ne!(SecurityMode::QuicTransport, SecurityMode::ManualCrypto);
+ }
+
+ // ---- QuicTransportConfig tests ----
+
+ #[test]
+ fn test_config_default() {
+ let cfg = QuicTransportConfig::default();
+ assert_eq!(cfg.bind_addr, "0.0.0.0:4433");
+ assert_eq!(cfg.handshake_timeout_ms, 100);
+ assert_eq!(cfg.max_streams, 8);
+ assert!(cfg.enable_migration);
+ assert_eq!(cfg.security_mode, SecurityMode::QuicTransport);
+ assert_eq!(cfg.max_datagram_size, 1350);
+ }
+
+ #[test]
+ fn test_config_validate_ok() {
+ let cfg = QuicTransportConfig::default();
+ assert!(cfg.validate().is_ok());
+ }
+
+ #[test]
+ fn test_config_validate_empty_bind_addr() {
+ let cfg = QuicTransportConfig {
+ bind_addr: String::new(),
+ ..Default::default()
+ };
+ let err = cfg.validate().unwrap_err();
+ assert!(matches!(err, QuicTransportError::InvalidConfig { .. }));
+ }
+
+ #[test]
+ fn test_config_validate_zero_handshake_timeout() {
+ let cfg = QuicTransportConfig {
+ handshake_timeout_ms: 0,
+ ..Default::default()
+ };
+ let err = cfg.validate().unwrap_err();
+ assert!(matches!(err, QuicTransportError::InvalidConfig { .. }));
+ }
+
+ #[test]
+ fn test_config_validate_zero_max_streams() {
+ let cfg = QuicTransportConfig {
+ max_streams: 0,
+ ..Default::default()
+ };
+ let err = cfg.validate().unwrap_err();
+ assert!(matches!(err, QuicTransportError::InvalidConfig { .. }));
+ }
+
+ #[test]
+ fn test_config_validate_small_datagram() {
+ let cfg = QuicTransportConfig {
+ max_datagram_size: 50,
+ ..Default::default()
+ };
+ let err = cfg.validate().unwrap_err();
+ assert!(matches!(err, QuicTransportError::InvalidConfig { .. }));
+ }
+
+ // ---- MessageType tests ----
+
+ #[test]
+ fn test_message_type_from_byte() {
+ assert_eq!(MessageType::from_byte(0x01), Some(MessageType::Beacon));
+ assert_eq!(MessageType::from_byte(0x02), Some(MessageType::CsiFrame));
+ assert_eq!(MessageType::from_byte(0x03), Some(MessageType::Control));
+ assert_eq!(MessageType::from_byte(0x04), Some(MessageType::Heartbeat));
+ assert_eq!(MessageType::from_byte(0x05), Some(MessageType::KeyRotation));
+ assert_eq!(MessageType::from_byte(0x00), None);
+ assert_eq!(MessageType::from_byte(0xFF), None);
+ }
+
+ #[test]
+ fn test_message_type_stream_id() {
+ assert_eq!(MessageType::Beacon.stream_id(), STREAM_BEACON);
+ assert_eq!(MessageType::CsiFrame.stream_id(), STREAM_CSI);
+ assert_eq!(MessageType::Control.stream_id(), STREAM_CONTROL);
+ assert_eq!(MessageType::Heartbeat.stream_id(), STREAM_CONTROL);
+ assert_eq!(MessageType::KeyRotation.stream_id(), STREAM_CONTROL);
+ }
+
+ // ---- FramedMessage tests ----
+
+ #[test]
+ fn test_framed_message_roundtrip() {
+ let payload = vec![0xDE, 0xAD, 0xBE, 0xEF];
+ let msg = FramedMessage::new(MessageType::Beacon, payload.clone());
+
+ let bytes = msg.to_bytes();
+ assert_eq!(bytes.len(), FRAMED_HEADER_SIZE + 4);
+
+ let (decoded, consumed) = FramedMessage::from_bytes(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded.message_type, MessageType::Beacon);
+ assert_eq!(decoded.payload, payload);
+ }
+
+ #[test]
+ fn test_framed_message_empty_payload() {
+ let msg = FramedMessage::new(MessageType::Heartbeat, vec![]);
+ let bytes = msg.to_bytes();
+ assert_eq!(bytes.len(), FRAMED_HEADER_SIZE);
+
+ let (decoded, consumed) = FramedMessage::from_bytes(&bytes).unwrap();
+ assert_eq!(consumed, FRAMED_HEADER_SIZE);
+ assert!(decoded.payload.is_empty());
+ }
+
+ #[test]
+ fn test_framed_message_too_short() {
+ assert!(FramedMessage::from_bytes(&[0x01, 0x00]).is_none());
+ }
+
+ #[test]
+ fn test_framed_message_invalid_type() {
+ let bytes = [0xFF, 0x00, 0x00, 0x00, 0x00];
+ assert!(FramedMessage::from_bytes(&bytes).is_none());
+ }
+
+ #[test]
+ fn test_framed_message_truncated_payload() {
+ // Header says 10 bytes payload but only 5 available
+ let mut bytes = vec![0x01];
+ bytes.extend_from_slice(&10u32.to_le_bytes());
+ bytes.extend_from_slice(&[0u8; 5]);
+ assert!(FramedMessage::from_bytes(&bytes).is_none());
+ }
+
+ #[test]
+ fn test_framed_message_wire_size() {
+ let msg = FramedMessage::new(MessageType::CsiFrame, vec![0; 100]);
+ assert_eq!(msg.wire_size(), FRAMED_HEADER_SIZE + 100);
+ }
+
+ #[test]
+ fn test_framed_message_large_payload() {
+ let payload = vec![0xAB; 4096];
+ let msg = FramedMessage::new(MessageType::CsiFrame, payload.clone());
+ let bytes = msg.to_bytes();
+ let (decoded, _) = FramedMessage::from_bytes(&bytes).unwrap();
+ assert_eq!(decoded.payload.len(), 4096);
+ assert_eq!(decoded.payload, payload);
+ }
+
+ // ---- ConnectionState tests ----
+
+ #[test]
+ fn test_connection_state_display() {
+ assert_eq!(format!("{}", ConnectionState::Disconnected), "Disconnected");
+ assert_eq!(format!("{}", ConnectionState::Connected), "Connected");
+ assert_eq!(format!("{}", ConnectionState::Draining), "Draining");
+ }
+
+ // ---- TransportStats tests ----
+
+ #[test]
+ fn test_transport_stats_default() {
+ let stats = TransportStats::default();
+ assert_eq!(stats.total_packets(), 0);
+ assert_eq!(stats.bytes_sent, 0);
+ assert_eq!(stats.bytes_received, 0);
+ }
+
+ #[test]
+ fn test_transport_stats_total_packets() {
+ let stats = TransportStats {
+ beacons_sent: 10,
+ beacons_received: 8,
+ csi_frames_sent: 100,
+ csi_frames_received: 95,
+ control_messages: 5,
+ ..Default::default()
+ };
+ assert_eq!(stats.total_packets(), 218);
+ }
+
+ #[test]
+ fn test_transport_stats_reset() {
+ let mut stats = TransportStats {
+ beacons_sent: 10,
+ bytes_sent: 1000,
+ ..Default::default()
+ };
+ stats.reset();
+ assert_eq!(stats.beacons_sent, 0);
+ assert_eq!(stats.bytes_sent, 0);
+ }
+
+ // ---- QuicTransportHandle tests ----
+
+ #[test]
+ fn test_handle_creation() {
+ let handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ assert_eq!(handle.state(), ConnectionState::Disconnected);
+ assert_eq!(handle.active_mode(), SecurityMode::QuicTransport);
+ assert!(!handle.is_connected());
+ assert!(handle.remote_addr().is_none());
+ }
+
+ #[test]
+ fn test_handle_creation_invalid_config() {
+ let cfg = QuicTransportConfig {
+ bind_addr: String::new(),
+ ..Default::default()
+ };
+ assert!(QuicTransportHandle::new(cfg).is_err());
+ }
+
+ #[test]
+ fn test_handle_connect() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ handle.connect("192.168.1.100:4433").unwrap();
+ assert!(handle.is_connected());
+ assert_eq!(handle.remote_addr(), Some("192.168.1.100:4433"));
+ }
+
+ #[test]
+ fn test_handle_connect_empty_addr() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ let err = handle.connect("").unwrap_err();
+ assert!(matches!(err, QuicTransportError::ConnectionFailed { .. }));
+ }
+
+ #[test]
+ fn test_handle_record_beacon() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ handle.record_beacon_sent(28);
+ handle.record_beacon_sent(28);
+ handle.record_beacon_received(28);
+ assert_eq!(handle.stats().beacons_sent, 2);
+ assert_eq!(handle.stats().beacons_received, 1);
+ assert_eq!(handle.stats().bytes_sent, 56);
+ assert_eq!(handle.stats().bytes_received, 28);
+ }
+
+ #[test]
+ fn test_handle_record_csi() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ handle.record_csi_sent(512);
+ handle.record_csi_received(512);
+ assert_eq!(handle.stats().csi_frames_sent, 1);
+ assert_eq!(handle.stats().csi_frames_received, 1);
+ }
+
+ #[test]
+ fn test_handle_record_control() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ handle.record_control_message(64);
+ assert_eq!(handle.stats().control_messages, 1);
+ }
+
+ #[test]
+ fn test_handle_fallback() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ handle.connect("192.168.1.1:4433").unwrap();
+ let err = handle.trigger_fallback("handshake timeout").unwrap_err();
+ assert!(matches!(err, QuicTransportError::FallbackTriggered { .. }));
+ assert_eq!(handle.active_mode(), SecurityMode::ManualCrypto);
+ assert_eq!(handle.state(), ConnectionState::Disconnected);
+ assert_eq!(handle.stats().fallback_count, 1);
+ }
+
+ #[test]
+ fn test_handle_close() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ handle.connect("192.168.1.1:4433").unwrap();
+ assert!(handle.is_connected());
+ handle.close();
+ assert_eq!(handle.state(), ConnectionState::Closed);
+ assert!(!handle.is_connected());
+ }
+
+ #[test]
+ fn test_handle_close_when_disconnected() {
+ let mut handle = QuicTransportHandle::new(QuicTransportConfig::default()).unwrap();
+ handle.close();
+ assert_eq!(handle.state(), ConnectionState::Closed);
+ }
+
+ // ---- Error display tests ----
+
+ #[test]
+ fn test_error_display() {
+ let err = QuicTransportError::HandshakeTimeout { timeout_ms: 100 };
+ assert!(format!("{}", err).contains("100 ms"));
+
+ let err = QuicTransportError::StreamOpenFailed { stream_id: 1 };
+ assert!(format!("{}", err).contains("stream 1"));
+ }
+
+ // ---- Stream constants ----
+
+ #[test]
+ fn test_stream_constants() {
+ assert_eq!(STREAM_BEACON, 0);
+ assert_eq!(STREAM_CSI, 1);
+ assert_eq!(STREAM_CONTROL, 2);
+ }
+}
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/secure_tdm.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/secure_tdm.rs
new file mode 100644
index 0000000..afbedc0
--- /dev/null
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/secure_tdm.rs
@@ -0,0 +1,994 @@
+//! Secured TDM protocol over QUIC transport (ADR-032a).
+//!
+//! Wraps the existing `TdmCoordinator` and `SyncBeacon` types with
+//! QUIC-based authenticated transport. Supports dual-mode operation:
+//! QUIC for aggregator-class nodes and manual crypto for ESP32-S3.
+//!
+//! # Architecture
+//!
+//! ```text
+//! SecureTdmCoordinator
+//! |-- TdmCoordinator (schedule, cycle state)
+//! |-- QuicTransportHandle (optional, for QUIC mode)
+//! |-- SecurityMode (selects QUIC vs manual)
+//! |-- ReplayWindow (nonce-based replay protection for manual mode)
+//! ```
+//!
+//! # Beacon Authentication Flow
+//!
+//! ## QUIC mode
+//! 1. Coordinator calls `begin_secure_cycle()`
+//! 2. Beacon serialized to 16-byte wire format (original)
+//! 3. Wrapped in `FramedMessage` with type `Beacon`
+//! 4. Sent over QUIC stream 0 (encrypted + authenticated by TLS 1.3)
+//!
+//! ## Manual crypto mode
+//! 1. Coordinator calls `begin_secure_cycle()`
+//! 2. Beacon serialized to 28-byte authenticated format (ADR-032 Section 2.1)
+//! 3. HMAC-SHA256 tag computed over payload + nonce
+//! 4. Sent over plain UDP
+
+use super::quic_transport::{
+ FramedMessage, MessageType, QuicTransportConfig,
+ QuicTransportHandle, QuicTransportError, SecurityMode,
+};
+use super::tdm::{SyncBeacon, TdmCoordinator, TdmSchedule, TdmSlotCompleted};
+use std::collections::VecDeque;
+use std::fmt;
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/// Size of the HMAC-SHA256 truncated tag (manual crypto mode).
+const HMAC_TAG_SIZE: usize = 8;
+
+/// Size of the nonce field (manual crypto mode).
+const NONCE_SIZE: usize = 4;
+
+/// Replay window size (number of past nonces to track).
+const REPLAY_WINDOW: u32 = 16;
+
+/// Size of the authenticated beacon (manual crypto mode): 16 + 4 + 8 = 28.
+pub const AUTHENTICATED_BEACON_SIZE: usize = 16 + NONCE_SIZE + HMAC_TAG_SIZE;
+
+/// Default pre-shared key for testing (16 bytes). In production, this
+/// would be loaded from NVS or a secure key store.
+const DEFAULT_TEST_KEY: [u8; 16] = [
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
+ 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
+];
+
+// ---------------------------------------------------------------------------
+// Errors
+// ---------------------------------------------------------------------------
+
+/// Errors from the secure TDM layer.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SecureTdmError {
+ /// The beacon HMAC tag verification failed.
+ BeaconAuthFailed,
+ /// The beacon nonce was replayed (outside the replay window).
+ BeaconReplay { nonce: u32, last_accepted: u32 },
+ /// The beacon buffer is too short.
+ BeaconTooShort { expected: usize, got: usize },
+ /// QUIC transport error.
+ Transport(QuicTransportError),
+ /// The security mode does not match the incoming packet format.
+ ModeMismatch { expected: SecurityMode, got: SecurityMode },
+ /// The mesh key has not been provisioned.
+ NoMeshKey,
+}
+
+impl fmt::Display for SecureTdmError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ SecureTdmError::BeaconAuthFailed => write!(f, "Beacon HMAC verification failed"),
+ SecureTdmError::BeaconReplay { nonce, last_accepted } => {
+ write!(
+ f,
+ "Beacon replay: nonce {} <= last_accepted {} - REPLAY_WINDOW",
+ nonce, last_accepted
+ )
+ }
+ SecureTdmError::BeaconTooShort { expected, got } => {
+ write!(f, "Beacon too short: expected {} bytes, got {}", expected, got)
+ }
+ SecureTdmError::Transport(e) => write!(f, "Transport error: {}", e),
+ SecureTdmError::ModeMismatch { expected, got } => {
+ write!(f, "Security mode mismatch: expected {}, got {}", expected, got)
+ }
+ SecureTdmError::NoMeshKey => write!(f, "Mesh key not provisioned"),
+ }
+ }
+}
+
+impl std::error::Error for SecureTdmError {}
+
+impl From for SecureTdmError {
+ fn from(e: QuicTransportError) -> Self {
+ SecureTdmError::Transport(e)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Replay window
+// ---------------------------------------------------------------------------
+
+/// Replay protection window for manual crypto mode.
+///
+/// Tracks the highest accepted nonce and a window of recently seen
+/// nonces to handle UDP reordering.
+#[derive(Debug, Clone)]
+pub struct ReplayWindow {
+ /// Highest nonce value accepted so far.
+ last_accepted: u32,
+ /// Window size.
+ window_size: u32,
+ /// Recently seen nonces within the window (for dedup).
+ seen: VecDeque,
+}
+
+impl ReplayWindow {
+ /// Create a new replay window with the given size.
+ pub fn new(window_size: u32) -> Self {
+ Self {
+ last_accepted: 0,
+ window_size,
+ seen: VecDeque::with_capacity(window_size as usize),
+ }
+ }
+
+ /// Check if a nonce is acceptable (not replayed).
+ ///
+ /// Returns `true` if the nonce should be accepted.
+ pub fn check(&self, nonce: u32) -> bool {
+ if nonce == 0 && self.last_accepted == 0 && self.seen.is_empty() {
+ // First nonce ever
+ return true;
+ }
+ if self.last_accepted >= self.window_size
+ && nonce < self.last_accepted.saturating_sub(self.window_size)
+ {
+ // Too old
+ return false;
+ }
+ // Check for exact duplicate within window
+ !self.seen.contains(&nonce)
+ }
+
+ /// Accept a nonce, updating the window state.
+ ///
+ /// Returns `true` if the nonce was accepted, `false` if it was
+ /// rejected as a replay.
+ pub fn accept(&mut self, nonce: u32) -> bool {
+ if !self.check(nonce) {
+ return false;
+ }
+
+ self.seen.push_back(nonce);
+ if self.seen.len() > self.window_size as usize {
+ self.seen.pop_front();
+ }
+
+ if nonce > self.last_accepted {
+ self.last_accepted = nonce;
+ }
+
+ true
+ }
+
+ /// Current highest accepted nonce.
+ pub fn last_accepted(&self) -> u32 {
+ self.last_accepted
+ }
+
+ /// Number of nonces currently tracked in the window.
+ pub fn window_count(&self) -> usize {
+ self.seen.len()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Authenticated beacon (manual crypto mode)
+// ---------------------------------------------------------------------------
+
+/// An authenticated beacon in the manual crypto wire format (28 bytes).
+///
+/// ```text
+/// [0..16] SyncBeacon payload (cycle_id, period, drift, reserved)
+/// [16..20] nonce (LE u32, monotonically increasing)
+/// [20..28] hmac_tag (HMAC-SHA256 truncated to 8 bytes)
+/// ```
+#[derive(Debug, Clone)]
+pub struct AuthenticatedBeacon {
+ /// The underlying sync beacon.
+ pub beacon: SyncBeacon,
+ /// Monotonic nonce for replay protection.
+ pub nonce: u32,
+ /// HMAC-SHA256 truncated tag (8 bytes).
+ pub hmac_tag: [u8; HMAC_TAG_SIZE],
+}
+
+impl AuthenticatedBeacon {
+ /// Serialize to the 28-byte authenticated wire format.
+ pub fn to_bytes(&self) -> [u8; AUTHENTICATED_BEACON_SIZE] {
+ let mut buf = [0u8; AUTHENTICATED_BEACON_SIZE];
+ let beacon_bytes = self.beacon.to_bytes();
+ buf[..16].copy_from_slice(&beacon_bytes);
+ buf[16..20].copy_from_slice(&self.nonce.to_le_bytes());
+ buf[20..28].copy_from_slice(&self.hmac_tag);
+ buf
+ }
+
+ /// Deserialize from the 28-byte authenticated wire format.
+ ///
+ /// Does NOT verify the HMAC tag -- call `verify()` separately.
+ pub fn from_bytes(buf: &[u8]) -> Result {
+ if buf.len() < AUTHENTICATED_BEACON_SIZE {
+ return Err(SecureTdmError::BeaconTooShort {
+ expected: AUTHENTICATED_BEACON_SIZE,
+ got: buf.len(),
+ });
+ }
+ let beacon = SyncBeacon::from_bytes(&buf[..16]).ok_or(SecureTdmError::BeaconTooShort {
+ expected: 16,
+ got: buf.len(),
+ })?;
+ let nonce = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
+ let mut hmac_tag = [0u8; HMAC_TAG_SIZE];
+ hmac_tag.copy_from_slice(&buf[20..28]);
+ Ok(Self {
+ beacon,
+ nonce,
+ hmac_tag,
+ })
+ }
+
+ /// Compute the expected HMAC tag for this beacon using the given key.
+ ///
+ /// Uses a simplified HMAC approximation for testing. In production,
+ /// this calls mbedtls HMAC-SHA256 via the ESP-IDF hardware accelerator
+ /// or the `sha2` crate on aggregator nodes.
+ pub fn compute_tag(payload_and_nonce: &[u8], key: &[u8; 16]) -> [u8; HMAC_TAG_SIZE] {
+ // Simplified HMAC: XOR key into payload hash. In production, use
+ // real HMAC-SHA256 from sha2 crate. This is sufficient for
+ // testing the protocol structure.
+ let mut tag = [0u8; HMAC_TAG_SIZE];
+ for (i, byte) in payload_and_nonce.iter().enumerate() {
+ tag[i % HMAC_TAG_SIZE] ^= byte ^ key[i % 16];
+ }
+ tag
+ }
+
+ /// Verify the HMAC tag using the given key.
+ pub fn verify(&self, key: &[u8; 16]) -> Result<(), SecureTdmError> {
+ let mut msg = [0u8; 20];
+ msg[..16].copy_from_slice(&self.beacon.to_bytes());
+ msg[16..20].copy_from_slice(&self.nonce.to_le_bytes());
+ let expected = Self::compute_tag(&msg, key);
+ if self.hmac_tag == expected {
+ Ok(())
+ } else {
+ Err(SecureTdmError::BeaconAuthFailed)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Secure TDM coordinator
+// ---------------------------------------------------------------------------
+
+/// Security configuration for the secure TDM coordinator.
+#[derive(Debug, Clone)]
+pub struct SecureTdmConfig {
+ /// Security mode (QUIC or manual crypto).
+ pub security_mode: SecurityMode,
+ /// Pre-shared mesh key (16 bytes) for manual crypto mode.
+ pub mesh_key: Option<[u8; 16]>,
+ /// QUIC transport configuration (used if mode is QuicTransport).
+ pub quic_config: QuicTransportConfig,
+ /// Security enforcement level.
+ pub sec_level: SecLevel,
+}
+
+/// Security enforcement level (ADR-032 Section 2.8).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SecLevel {
+ /// Accept unauthenticated frames, log warning.
+ Permissive = 0,
+ /// Accept both authenticated and unauthenticated.
+ Transitional = 1,
+ /// Reject unauthenticated frames.
+ Enforcing = 2,
+}
+
+impl Default for SecureTdmConfig {
+ fn default() -> Self {
+ Self {
+ security_mode: SecurityMode::QuicTransport,
+ mesh_key: Some(DEFAULT_TEST_KEY),
+ quic_config: QuicTransportConfig::default(),
+ sec_level: SecLevel::Transitional,
+ }
+ }
+}
+
+/// Secure TDM coordinator that wraps `TdmCoordinator` with authenticated
+/// transport.
+///
+/// Supports dual-mode operation:
+/// - **QUIC mode**: Beacons are wrapped in `FramedMessage` and sent over
+/// encrypted QUIC streams.
+/// - **Manual crypto mode**: Beacons are extended to 28 bytes with HMAC-SHA256
+/// tags and sent over plain UDP.
+#[derive(Debug)]
+pub struct SecureTdmCoordinator {
+ /// Underlying TDM coordinator (schedule, cycle state).
+ inner: TdmCoordinator,
+ /// Security configuration.
+ config: SecureTdmConfig,
+ /// Monotonic nonce counter (manual crypto mode).
+ nonce_counter: u32,
+ /// QUIC transport handle (if QUIC mode is active).
+ transport: Option,
+ /// Replay window for received beacons (manual crypto mode).
+ replay_window: ReplayWindow,
+ /// Total beacons produced.
+ beacons_produced: u64,
+ /// Total beacons verified.
+ beacons_verified: u64,
+ /// Total verification failures.
+ verification_failures: u64,
+}
+
+impl SecureTdmCoordinator {
+ /// Create a new secure TDM coordinator.
+ pub fn new(
+ schedule: TdmSchedule,
+ config: SecureTdmConfig,
+ ) -> Result {
+ let transport = if config.security_mode == SecurityMode::QuicTransport {
+ Some(QuicTransportHandle::new(config.quic_config.clone())?)
+ } else {
+ None
+ };
+
+ Ok(Self {
+ inner: TdmCoordinator::new(schedule),
+ config,
+ nonce_counter: 0,
+ transport,
+ replay_window: ReplayWindow::new(REPLAY_WINDOW),
+ beacons_produced: 0,
+ beacons_verified: 0,
+ verification_failures: 0,
+ })
+ }
+
+ /// Begin a new secure sensing cycle.
+ ///
+ /// Returns the authenticated beacon (in either QUIC or manual format)
+ /// and the raw beacon for local processing.
+ pub fn begin_secure_cycle(&mut self) -> Result {
+ let beacon = self.inner.begin_cycle();
+ self.beacons_produced += 1;
+
+ match self.config.security_mode {
+ SecurityMode::ManualCrypto => {
+ let key = self.config.mesh_key.ok_or(SecureTdmError::NoMeshKey)?;
+ self.nonce_counter = self.nonce_counter.wrapping_add(1);
+
+ let mut msg = [0u8; 20];
+ msg[..16].copy_from_slice(&beacon.to_bytes());
+ msg[16..20].copy_from_slice(&self.nonce_counter.to_le_bytes());
+ let tag = AuthenticatedBeacon::compute_tag(&msg, &key);
+
+ let auth_beacon = AuthenticatedBeacon {
+ beacon: beacon.clone(),
+ nonce: self.nonce_counter,
+ hmac_tag: tag,
+ };
+
+ Ok(SecureCycleOutput {
+ beacon,
+ authenticated_bytes: auth_beacon.to_bytes().to_vec(),
+ mode: SecurityMode::ManualCrypto,
+ })
+ }
+ SecurityMode::QuicTransport => {
+ let beacon_bytes = beacon.to_bytes();
+ let framed = FramedMessage::new(
+ MessageType::Beacon,
+ beacon_bytes.to_vec(),
+ );
+ let wire = framed.to_bytes();
+
+ if let Some(ref mut transport) = self.transport {
+ transport.record_beacon_sent(wire.len());
+ }
+
+ Ok(SecureCycleOutput {
+ beacon,
+ authenticated_bytes: wire,
+ mode: SecurityMode::QuicTransport,
+ })
+ }
+ }
+ }
+
+ /// Verify a received beacon.
+ ///
+ /// In manual crypto mode, verifies the HMAC tag and replay window.
+ /// In QUIC mode, the transport layer already provides authentication.
+ pub fn verify_beacon(&mut self, buf: &[u8]) -> Result {
+ match self.config.security_mode {
+ SecurityMode::ManualCrypto => {
+ // Try authenticated format first
+ if buf.len() >= AUTHENTICATED_BEACON_SIZE {
+ let auth = AuthenticatedBeacon::from_bytes(buf)?;
+ let key = self.config.mesh_key.ok_or(SecureTdmError::NoMeshKey)?;
+ match auth.verify(&key) {
+ Ok(()) => {
+ if !self.replay_window.accept(auth.nonce) {
+ self.verification_failures += 1;
+ return Err(SecureTdmError::BeaconReplay {
+ nonce: auth.nonce,
+ last_accepted: self.replay_window.last_accepted(),
+ });
+ }
+ self.beacons_verified += 1;
+ Ok(auth.beacon)
+ }
+ Err(e) => {
+ self.verification_failures += 1;
+ Err(e)
+ }
+ }
+ } else if buf.len() >= 16 && self.config.sec_level != SecLevel::Enforcing {
+ // Accept unauthenticated 16-byte beacon in permissive/transitional
+ let beacon = SyncBeacon::from_bytes(buf).ok_or(
+ SecureTdmError::BeaconTooShort {
+ expected: 16,
+ got: buf.len(),
+ },
+ )?;
+ self.beacons_verified += 1;
+ Ok(beacon)
+ } else {
+ Err(SecureTdmError::BeaconTooShort {
+ expected: AUTHENTICATED_BEACON_SIZE,
+ got: buf.len(),
+ })
+ }
+ }
+ SecurityMode::QuicTransport => {
+ // In QUIC mode, extract beacon from framed message
+ let (framed, _) = FramedMessage::from_bytes(buf).ok_or(
+ SecureTdmError::BeaconTooShort {
+ expected: 5 + 16,
+ got: buf.len(),
+ },
+ )?;
+ if framed.message_type != MessageType::Beacon {
+ return Err(SecureTdmError::ModeMismatch {
+ expected: SecurityMode::QuicTransport,
+ got: SecurityMode::ManualCrypto,
+ });
+ }
+ let beacon = SyncBeacon::from_bytes(&framed.payload).ok_or(
+ SecureTdmError::BeaconTooShort {
+ expected: 16,
+ got: framed.payload.len(),
+ },
+ )?;
+ self.beacons_verified += 1;
+
+ if let Some(ref mut transport) = self.transport {
+ transport.record_beacon_received(buf.len());
+ }
+
+ Ok(beacon)
+ }
+ }
+ }
+
+ /// Complete a slot in the current cycle (delegates to inner coordinator).
+ pub fn complete_slot(
+ &mut self,
+ slot_index: usize,
+ capture_quality: f32,
+ ) -> TdmSlotCompleted {
+ self.inner.complete_slot(slot_index, capture_quality)
+ }
+
+ /// Whether the current cycle is complete.
+ pub fn is_cycle_complete(&self) -> bool {
+ self.inner.is_cycle_complete()
+ }
+
+ /// Current cycle ID.
+ pub fn cycle_id(&self) -> u64 {
+ self.inner.cycle_id()
+ }
+
+ /// Active security mode.
+ pub fn security_mode(&self) -> SecurityMode {
+ self.config.security_mode
+ }
+
+ /// Reference to the underlying TDM coordinator.
+ pub fn inner(&self) -> &TdmCoordinator {
+ &self.inner
+ }
+
+ /// Total beacons produced.
+ pub fn beacons_produced(&self) -> u64 {
+ self.beacons_produced
+ }
+
+ /// Total beacons successfully verified.
+ pub fn beacons_verified(&self) -> u64 {
+ self.beacons_verified
+ }
+
+ /// Total verification failures.
+ pub fn verification_failures(&self) -> u64 {
+ self.verification_failures
+ }
+
+ /// Reference to the QUIC transport handle (if available).
+ pub fn transport(&self) -> Option<&QuicTransportHandle> {
+ self.transport.as_ref()
+ }
+
+ /// Mutable reference to the QUIC transport handle (if available).
+ pub fn transport_mut(&mut self) -> Option<&mut QuicTransportHandle> {
+ self.transport.as_mut()
+ }
+
+ /// Current nonce counter value (manual crypto mode).
+ pub fn nonce_counter(&self) -> u32 {
+ self.nonce_counter
+ }
+
+ /// Reference to the replay window.
+ pub fn replay_window(&self) -> &ReplayWindow {
+ &self.replay_window
+ }
+
+ /// Security enforcement level.
+ pub fn sec_level(&self) -> SecLevel {
+ self.config.sec_level
+ }
+}
+
+/// Output from `begin_secure_cycle()`.
+#[derive(Debug, Clone)]
+pub struct SecureCycleOutput {
+ /// The underlying sync beacon (for local processing).
+ pub beacon: SyncBeacon,
+ /// Authenticated wire bytes (format depends on mode).
+ pub authenticated_bytes: Vec,
+ /// Security mode used for this beacon.
+ pub mode: SecurityMode,
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::esp32::tdm::TdmSchedule;
+ use std::time::Duration;
+
+ fn test_schedule() -> TdmSchedule {
+ TdmSchedule::default_4node()
+ }
+
+ fn manual_config() -> SecureTdmConfig {
+ SecureTdmConfig {
+ security_mode: SecurityMode::ManualCrypto,
+ mesh_key: Some(DEFAULT_TEST_KEY),
+ quic_config: QuicTransportConfig::default(),
+ sec_level: SecLevel::Transitional,
+ }
+ }
+
+ fn quic_config() -> SecureTdmConfig {
+ SecureTdmConfig {
+ security_mode: SecurityMode::QuicTransport,
+ mesh_key: Some(DEFAULT_TEST_KEY),
+ quic_config: QuicTransportConfig::default(),
+ sec_level: SecLevel::Transitional,
+ }
+ }
+
+ // ---- ReplayWindow tests ----
+
+ #[test]
+ fn test_replay_window_new() {
+ let rw = ReplayWindow::new(16);
+ assert_eq!(rw.last_accepted(), 0);
+ assert_eq!(rw.window_count(), 0);
+ }
+
+ #[test]
+ fn test_replay_window_accept_first() {
+ let mut rw = ReplayWindow::new(16);
+ assert!(rw.accept(0)); // First nonce accepted
+ assert_eq!(rw.window_count(), 1);
+ }
+
+ #[test]
+ fn test_replay_window_monotonic() {
+ let mut rw = ReplayWindow::new(16);
+ assert!(rw.accept(1));
+ assert!(rw.accept(2));
+ assert!(rw.accept(3));
+ assert_eq!(rw.last_accepted(), 3);
+ }
+
+ #[test]
+ fn test_replay_window_reject_duplicate() {
+ let mut rw = ReplayWindow::new(16);
+ assert!(rw.accept(1));
+ assert!(!rw.accept(1)); // Duplicate rejected
+ }
+
+ #[test]
+ fn test_replay_window_accept_within_window() {
+ let mut rw = ReplayWindow::new(16);
+ assert!(rw.accept(5));
+ assert!(rw.accept(3)); // Out of order but within window
+ assert_eq!(rw.last_accepted(), 5);
+ }
+
+ #[test]
+ fn test_replay_window_reject_too_old() {
+ let mut rw = ReplayWindow::new(4);
+ for i in 0..20 {
+ rw.accept(i);
+ }
+ // Nonce 0 is way outside the window
+ assert!(!rw.accept(0));
+ }
+
+ #[test]
+ fn test_replay_window_evicts_old() {
+ let mut rw = ReplayWindow::new(4);
+ for i in 0..10 {
+ rw.accept(i);
+ }
+ assert!(rw.window_count() <= 4);
+ }
+
+ // ---- AuthenticatedBeacon tests ----
+
+ #[test]
+ fn test_auth_beacon_roundtrip() {
+ let beacon = SyncBeacon {
+ cycle_id: 42,
+ cycle_period: Duration::from_millis(50),
+ drift_correction_us: -3,
+ generated_at: std::time::Instant::now(),
+ };
+ let key = DEFAULT_TEST_KEY;
+ let nonce = 7u32;
+
+ let mut msg = [0u8; 20];
+ msg[..16].copy_from_slice(&beacon.to_bytes());
+ msg[16..20].copy_from_slice(&nonce.to_le_bytes());
+ let tag = AuthenticatedBeacon::compute_tag(&msg, &key);
+
+ let auth = AuthenticatedBeacon {
+ beacon,
+ nonce,
+ hmac_tag: tag,
+ };
+
+ let bytes = auth.to_bytes();
+ assert_eq!(bytes.len(), AUTHENTICATED_BEACON_SIZE);
+
+ let decoded = AuthenticatedBeacon::from_bytes(&bytes).unwrap();
+ assert_eq!(decoded.beacon.cycle_id, 42);
+ assert_eq!(decoded.nonce, 7);
+ assert_eq!(decoded.hmac_tag, tag);
+ }
+
+ #[test]
+ fn test_auth_beacon_verify_ok() {
+ let beacon = SyncBeacon {
+ cycle_id: 100,
+ cycle_period: Duration::from_millis(50),
+ drift_correction_us: 0,
+ generated_at: std::time::Instant::now(),
+ };
+ let key = DEFAULT_TEST_KEY;
+ let nonce = 1u32;
+
+ let mut msg = [0u8; 20];
+ msg[..16].copy_from_slice(&beacon.to_bytes());
+ msg[16..20].copy_from_slice(&nonce.to_le_bytes());
+ let tag = AuthenticatedBeacon::compute_tag(&msg, &key);
+
+ let auth = AuthenticatedBeacon {
+ beacon,
+ nonce,
+ hmac_tag: tag,
+ };
+ assert!(auth.verify(&key).is_ok());
+ }
+
+ #[test]
+ fn test_auth_beacon_verify_tampered() {
+ let beacon = SyncBeacon {
+ cycle_id: 100,
+ cycle_period: Duration::from_millis(50),
+ drift_correction_us: 0,
+ generated_at: std::time::Instant::now(),
+ };
+ let key = DEFAULT_TEST_KEY;
+ let nonce = 1u32;
+
+ let mut msg = [0u8; 20];
+ msg[..16].copy_from_slice(&beacon.to_bytes());
+ msg[16..20].copy_from_slice(&nonce.to_le_bytes());
+ let mut tag = AuthenticatedBeacon::compute_tag(&msg, &key);
+ tag[0] ^= 0xFF; // Tamper with tag
+
+ let auth = AuthenticatedBeacon {
+ beacon,
+ nonce,
+ hmac_tag: tag,
+ };
+ assert!(matches!(
+ auth.verify(&key),
+ Err(SecureTdmError::BeaconAuthFailed)
+ ));
+ }
+
+ #[test]
+ fn test_auth_beacon_too_short() {
+ let result = AuthenticatedBeacon::from_bytes(&[0u8; 10]);
+ assert!(matches!(
+ result,
+ Err(SecureTdmError::BeaconTooShort { .. })
+ ));
+ }
+
+ #[test]
+ fn test_auth_beacon_size_constant() {
+ assert_eq!(AUTHENTICATED_BEACON_SIZE, 28);
+ }
+
+ // ---- SecureTdmCoordinator tests (manual crypto) ----
+
+ #[test]
+ fn test_secure_coordinator_manual_create() {
+ let coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ assert_eq!(coord.security_mode(), SecurityMode::ManualCrypto);
+ assert_eq!(coord.beacons_produced(), 0);
+ assert!(coord.transport().is_none());
+ }
+
+ #[test]
+ fn test_secure_coordinator_manual_begin_cycle() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ let output = coord.begin_secure_cycle().unwrap();
+
+ assert_eq!(output.mode, SecurityMode::ManualCrypto);
+ assert_eq!(output.authenticated_bytes.len(), AUTHENTICATED_BEACON_SIZE);
+ assert_eq!(output.beacon.cycle_id, 0);
+ assert_eq!(coord.beacons_produced(), 1);
+ assert_eq!(coord.nonce_counter(), 1);
+ }
+
+ #[test]
+ fn test_secure_coordinator_manual_nonce_increments() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+
+ for expected_nonce in 1..=5u32 {
+ let _output = coord.begin_secure_cycle().unwrap();
+ // Complete all slots
+ for i in 0..4 {
+ coord.complete_slot(i, 1.0);
+ }
+ assert_eq!(coord.nonce_counter(), expected_nonce);
+ }
+ }
+
+ #[test]
+ fn test_secure_coordinator_manual_verify_own_beacon() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ let output = coord.begin_secure_cycle().unwrap();
+
+ // Create a second coordinator to verify
+ let mut verifier =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ let beacon = verifier
+ .verify_beacon(&output.authenticated_bytes)
+ .unwrap();
+ assert_eq!(beacon.cycle_id, 0);
+ }
+
+ #[test]
+ fn test_secure_coordinator_manual_reject_tampered() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ let output = coord.begin_secure_cycle().unwrap();
+
+ let mut tampered = output.authenticated_bytes.clone();
+ tampered[25] ^= 0xFF; // Tamper with HMAC tag
+
+ let mut verifier =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ assert!(verifier.verify_beacon(&tampered).is_err());
+ assert_eq!(verifier.verification_failures(), 1);
+ }
+
+ #[test]
+ fn test_secure_coordinator_manual_reject_replay() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ let output = coord.begin_secure_cycle().unwrap();
+
+ let mut verifier =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+
+ // First acceptance succeeds
+ verifier
+ .verify_beacon(&output.authenticated_bytes)
+ .unwrap();
+
+ // Replay of same beacon fails
+ let result = verifier.verify_beacon(&output.authenticated_bytes);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_secure_coordinator_manual_backward_compat_permissive() {
+ let mut cfg = manual_config();
+ cfg.sec_level = SecLevel::Permissive;
+ let mut coord = SecureTdmCoordinator::new(test_schedule(), cfg).unwrap();
+
+ // Send an unauthenticated 16-byte beacon
+ let beacon = SyncBeacon {
+ cycle_id: 99,
+ cycle_period: Duration::from_millis(50),
+ drift_correction_us: 0,
+ generated_at: std::time::Instant::now(),
+ };
+ let bytes = beacon.to_bytes();
+
+ let verified = coord.verify_beacon(&bytes).unwrap();
+ assert_eq!(verified.cycle_id, 99);
+ }
+
+ #[test]
+ fn test_secure_coordinator_manual_reject_unauthenticated_enforcing() {
+ let mut cfg = manual_config();
+ cfg.sec_level = SecLevel::Enforcing;
+ let mut coord = SecureTdmCoordinator::new(test_schedule(), cfg).unwrap();
+
+ let beacon = SyncBeacon {
+ cycle_id: 99,
+ cycle_period: Duration::from_millis(50),
+ drift_correction_us: 0,
+ generated_at: std::time::Instant::now(),
+ };
+ let bytes = beacon.to_bytes();
+
+ // 16-byte unauthenticated beacon rejected in enforcing mode
+ let result = coord.verify_beacon(&bytes);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_secure_coordinator_no_mesh_key() {
+ let cfg = SecureTdmConfig {
+ security_mode: SecurityMode::ManualCrypto,
+ mesh_key: None,
+ ..Default::default()
+ };
+ let mut coord = SecureTdmCoordinator::new(test_schedule(), cfg).unwrap();
+ let result = coord.begin_secure_cycle();
+ assert!(matches!(result, Err(SecureTdmError::NoMeshKey)));
+ }
+
+ // ---- SecureTdmCoordinator tests (QUIC mode) ----
+
+ #[test]
+ fn test_secure_coordinator_quic_create() {
+ let coord =
+ SecureTdmCoordinator::new(test_schedule(), quic_config()).unwrap();
+ assert_eq!(coord.security_mode(), SecurityMode::QuicTransport);
+ assert!(coord.transport().is_some());
+ }
+
+ #[test]
+ fn test_secure_coordinator_quic_begin_cycle() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), quic_config()).unwrap();
+ let output = coord.begin_secure_cycle().unwrap();
+
+ assert_eq!(output.mode, SecurityMode::QuicTransport);
+ // QUIC framed: 5-byte header + 16-byte beacon = 21 bytes
+ assert_eq!(output.authenticated_bytes.len(), 5 + 16);
+ assert_eq!(coord.beacons_produced(), 1);
+ }
+
+ #[test]
+ fn test_secure_coordinator_quic_verify_own_beacon() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), quic_config()).unwrap();
+ let output = coord.begin_secure_cycle().unwrap();
+
+ let mut verifier =
+ SecureTdmCoordinator::new(test_schedule(), quic_config()).unwrap();
+ let beacon = verifier
+ .verify_beacon(&output.authenticated_bytes)
+ .unwrap();
+ assert_eq!(beacon.cycle_id, 0);
+ }
+
+ #[test]
+ fn test_secure_coordinator_complete_cycle() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+ coord.begin_secure_cycle().unwrap();
+
+ for i in 0..4 {
+ let event = coord.complete_slot(i, 0.95);
+ assert_eq!(event.slot_index, i);
+ }
+ assert!(coord.is_cycle_complete());
+ }
+
+ #[test]
+ fn test_secure_coordinator_cycle_id_increments() {
+ let mut coord =
+ SecureTdmCoordinator::new(test_schedule(), manual_config()).unwrap();
+
+ let out0 = coord.begin_secure_cycle().unwrap();
+ assert_eq!(out0.beacon.cycle_id, 0);
+ for i in 0..4 {
+ coord.complete_slot(i, 1.0);
+ }
+
+ let out1 = coord.begin_secure_cycle().unwrap();
+ assert_eq!(out1.beacon.cycle_id, 1);
+ }
+
+ // ---- SecLevel tests ----
+
+ #[test]
+ fn test_sec_level_values() {
+ assert_eq!(SecLevel::Permissive as u8, 0);
+ assert_eq!(SecLevel::Transitional as u8, 1);
+ assert_eq!(SecLevel::Enforcing as u8, 2);
+ }
+
+ // ---- Error display tests ----
+
+ #[test]
+ fn test_secure_tdm_error_display() {
+ let err = SecureTdmError::BeaconAuthFailed;
+ assert!(format!("{}", err).contains("HMAC"));
+
+ let err = SecureTdmError::BeaconReplay {
+ nonce: 5,
+ last_accepted: 10,
+ };
+ assert!(format!("{}", err).contains("replay"));
+
+ let err = SecureTdmError::NoMeshKey;
+ assert!(format!("{}", err).contains("Mesh key"));
+ }
+}
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/Cargo.toml b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/Cargo.toml
index 6aa9534..f128607 100644
--- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/Cargo.toml
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/Cargo.toml
@@ -32,6 +32,10 @@ ruvector-attn-mincut = { workspace = true }
ruvector-attention = { workspace = true }
ruvector-solver = { workspace = true }
+# Midstreamer integrations (ADR-032a)
+midstreamer-temporal-compare = { workspace = true }
+midstreamer-attractor = { workspace = true }
+
# Internal
wifi-densepose-core = { version = "0.2.0", path = "../wifi-densepose-core" }
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/attractor_drift.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/attractor_drift.rs
new file mode 100644
index 0000000..cd7e31c
--- /dev/null
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/attractor_drift.rs
@@ -0,0 +1,573 @@
+//! Enhanced longitudinal drift detection using `midstreamer-attractor`.
+//!
+//! Extends the Welford-statistics drift detection from `longitudinal.rs`
+//! with phase-space attractor analysis provided by the
+//! `midstreamer-attractor` crate (ADR-032a Section 6.4).
+//!
+//! # Improvements over base drift detection
+//!
+//! - **Phase-space embedding**: Detects regime changes invisible to simple
+//! z-score analysis (e.g., gait transitioning from limit cycle to
+//! strange attractor = developing instability)
+//! - **Lyapunov exponent**: Quantifies sensitivity to initial conditions,
+//! catching chaotic transitions in breathing patterns
+//! - **Attractor classification**: Automatically classifies biophysical
+//! time series as point attractor (stable), limit cycle (periodic),
+//! or strange attractor (chaotic)
+//!
+//! # References
+//! - ADR-030 Tier 4: Longitudinal Biomechanics Drift
+//! - ADR-032a Section 6.4: midstreamer-attractor integration
+//! - Takens, F. (1981). "Detecting strange attractors in turbulence."
+
+use midstreamer_attractor::{
+ AttractorAnalyzer, AttractorType, PhasePoint,
+};
+
+use super::longitudinal::DriftMetric;
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+/// Configuration for attractor-based drift analysis.
+#[derive(Debug, Clone)]
+pub struct AttractorDriftConfig {
+ /// Embedding dimension for phase-space reconstruction (Takens' theorem).
+ /// Default: 3 (sufficient for most biophysical signals).
+ pub embedding_dim: usize,
+ /// Time delay for phase-space embedding (in observation steps).
+ /// Default: 1 (consecutive observations).
+ pub time_delay: usize,
+ /// Minimum observations needed before analysis is meaningful.
+ /// Default: 30 (about 1 month of daily observations).
+ pub min_observations: usize,
+ /// Lyapunov exponent threshold for chaos detection.
+ /// Default: 0.01.
+ pub lyapunov_threshold: f64,
+ /// Maximum trajectory length for the analyzer.
+ /// Default: 10000.
+ pub max_trajectory_length: usize,
+}
+
+impl Default for AttractorDriftConfig {
+ fn default() -> Self {
+ Self {
+ embedding_dim: 3,
+ time_delay: 1,
+ min_observations: 30,
+ lyapunov_threshold: 0.01,
+ max_trajectory_length: 10000,
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Error types
+// ---------------------------------------------------------------------------
+
+/// Errors from attractor-based drift analysis.
+#[derive(Debug, thiserror::Error)]
+pub enum AttractorDriftError {
+ /// Not enough observations for phase-space embedding.
+ #[error("Insufficient observations: need >= {needed}, have {have}")]
+ InsufficientData { needed: usize, have: usize },
+
+ /// The metric has no observations recorded.
+ #[error("No observations for metric: {0}")]
+ NoObservations(String),
+
+ /// Phase-space embedding dimension is invalid.
+ #[error("Invalid embedding dimension: {dim} (must be >= 2)")]
+ InvalidEmbeddingDim { dim: usize },
+
+ /// Attractor analysis library error.
+ #[error("Attractor analysis failed: {0}")]
+ AnalysisFailed(String),
+}
+
+// ---------------------------------------------------------------------------
+// Attractor classification result
+// ---------------------------------------------------------------------------
+
+/// Classification of a biophysical time series attractor.
+#[derive(Debug, Clone, PartialEq)]
+pub enum BiophysicalAttractor {
+ /// Point attractor: metric has converged to a stable value.
+ Stable { center: f64 },
+ /// Limit cycle: metric oscillates periodically.
+ Periodic { lyapunov_max: f64 },
+ /// Strange attractor: metric exhibits chaotic dynamics.
+ Chaotic { lyapunov_exponent: f64 },
+ /// Transitioning between attractor types.
+ Transitioning {
+ from: Box,
+ to: Box,
+ },
+ /// Insufficient data to classify.
+ Unknown,
+}
+
+impl BiophysicalAttractor {
+ /// Whether this attractor type warrants monitoring attention.
+ pub fn is_concerning(&self) -> bool {
+ matches!(
+ self,
+ BiophysicalAttractor::Chaotic { .. } | BiophysicalAttractor::Transitioning { .. }
+ )
+ }
+
+ /// Human-readable label for reporting.
+ pub fn label(&self) -> &'static str {
+ match self {
+ BiophysicalAttractor::Stable { .. } => "stable",
+ BiophysicalAttractor::Periodic { .. } => "periodic",
+ BiophysicalAttractor::Chaotic { .. } => "chaotic",
+ BiophysicalAttractor::Transitioning { .. } => "transitioning",
+ BiophysicalAttractor::Unknown => "unknown",
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Attractor drift report
+// ---------------------------------------------------------------------------
+
+/// Report from attractor-based drift analysis.
+#[derive(Debug, Clone)]
+pub struct AttractorDriftReport {
+ /// Person this report pertains to.
+ pub person_id: u64,
+ /// Which biophysical metric was analyzed.
+ pub metric: DriftMetric,
+ /// Classified attractor type.
+ pub attractor: BiophysicalAttractor,
+ /// Whether the attractor type has changed from the previous analysis.
+ pub regime_changed: bool,
+ /// Number of observations used in this analysis.
+ pub observation_count: usize,
+ /// Timestamp of the analysis (microseconds).
+ pub timestamp_us: u64,
+}
+
+// ---------------------------------------------------------------------------
+// Per-metric observation buffer
+// ---------------------------------------------------------------------------
+
+/// Time series buffer for a single biophysical metric.
+#[derive(Debug, Clone)]
+struct MetricBuffer {
+ /// Metric type.
+ metric: DriftMetric,
+ /// Observed values (most recent at the end).
+ values: Vec,
+ /// Maximum buffer size.
+ max_size: usize,
+ /// Last classified attractor label.
+ last_label: String,
+}
+
+impl MetricBuffer {
+ /// Create a new buffer.
+ fn new(metric: DriftMetric, max_size: usize) -> Self {
+ Self {
+ metric,
+ values: Vec::new(),
+ max_size,
+ last_label: "unknown".to_string(),
+ }
+ }
+
+ /// Add an observation.
+ fn push(&mut self, value: f64) {
+ if self.values.len() >= self.max_size {
+ self.values.remove(0);
+ }
+ self.values.push(value);
+ }
+
+ /// Number of observations.
+ fn count(&self) -> usize {
+ self.values.len()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Attractor drift analyzer
+// ---------------------------------------------------------------------------
+
+/// Attractor-based drift analyzer for longitudinal biophysical monitoring.
+///
+/// Uses phase-space reconstruction (Takens' embedding theorem) and
+/// `midstreamer-attractor` to classify the dynamical regime of each
+/// biophysical metric. Detects regime changes that precede simple
+/// metric drift.
+pub struct AttractorDriftAnalyzer {
+ /// Configuration.
+ config: AttractorDriftConfig,
+ /// Person ID being monitored.
+ person_id: u64,
+ /// Per-metric observation buffers.
+ buffers: Vec,
+ /// Total analyses performed.
+ analysis_count: u64,
+}
+
+// Manual Debug since AttractorAnalyzer does not derive Debug
+impl std::fmt::Debug for AttractorDriftAnalyzer {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("AttractorDriftAnalyzer")
+ .field("person_id", &self.person_id)
+ .field("analysis_count", &self.analysis_count)
+ .finish()
+ }
+}
+
+impl AttractorDriftAnalyzer {
+ /// Create a new attractor drift analyzer for a person.
+ pub fn new(
+ person_id: u64,
+ config: AttractorDriftConfig,
+ ) -> Result {
+ if config.embedding_dim < 2 {
+ return Err(AttractorDriftError::InvalidEmbeddingDim {
+ dim: config.embedding_dim,
+ });
+ }
+
+ let buffers = DriftMetric::all()
+ .iter()
+ .map(|&m| MetricBuffer::new(m, 365)) // 1 year of daily observations
+ .collect();
+
+ Ok(Self {
+ config,
+ person_id,
+ buffers,
+ analysis_count: 0,
+ })
+ }
+
+ /// Add an observation for a specific metric.
+ pub fn add_observation(&mut self, metric: DriftMetric, value: f64) {
+ if let Some(buf) = self.buffers.iter_mut().find(|b| b.metric == metric) {
+ buf.push(value);
+ }
+ }
+
+ /// Perform attractor analysis on a specific metric.
+ ///
+ /// Reconstructs the phase space using Takens' embedding and
+ /// classifies the attractor type using `midstreamer-attractor`.
+ pub fn analyze(
+ &mut self,
+ metric: DriftMetric,
+ timestamp_us: u64,
+ ) -> Result {
+ let buf_idx = self
+ .buffers
+ .iter()
+ .position(|b| b.metric == metric)
+ .ok_or_else(|| AttractorDriftError::NoObservations(metric.name().into()))?;
+
+ let count = self.buffers[buf_idx].count();
+ let min_needed = self.config.min_observations;
+ if count < min_needed {
+ return Err(AttractorDriftError::InsufficientData {
+ needed: min_needed,
+ have: count,
+ });
+ }
+
+ // Build phase-space trajectory using Takens' embedding
+ // and feed into a fresh AttractorAnalyzer
+ let dim = self.config.embedding_dim;
+ let delay = self.config.time_delay;
+ let values = &self.buffers[buf_idx].values;
+ let n_points = values.len().saturating_sub((dim - 1) * delay);
+
+ let mut analyzer = AttractorAnalyzer::new(dim, self.config.max_trajectory_length);
+
+ for i in 0..n_points {
+ let coords: Vec = (0..dim).map(|d| values[i + d * delay]).collect();
+ let point = PhasePoint::new(coords, i as u64);
+ let _ = analyzer.add_point(point);
+ }
+
+ // Analyze the trajectory
+ let attractor = match analyzer.analyze() {
+ Ok(info) => {
+ let max_lyap = info
+ .max_lyapunov_exponent()
+ .unwrap_or(0.0);
+
+ match info.attractor_type {
+ AttractorType::PointAttractor => {
+ // Compute center as mean of last few values
+ let recent = &values[values.len().saturating_sub(10)..];
+ let center = recent.iter().sum::() / recent.len() as f64;
+ BiophysicalAttractor::Stable { center }
+ }
+ AttractorType::LimitCycle => BiophysicalAttractor::Periodic {
+ lyapunov_max: max_lyap,
+ },
+ AttractorType::StrangeAttractor => BiophysicalAttractor::Chaotic {
+ lyapunov_exponent: max_lyap,
+ },
+ _ => BiophysicalAttractor::Unknown,
+ }
+ }
+ Err(_) => BiophysicalAttractor::Unknown,
+ };
+
+ // Check for regime change
+ let label = attractor.label().to_string();
+ let regime_changed = label != self.buffers[buf_idx].last_label;
+ self.buffers[buf_idx].last_label = label;
+
+ self.analysis_count += 1;
+
+ Ok(AttractorDriftReport {
+ person_id: self.person_id,
+ metric,
+ attractor,
+ regime_changed,
+ observation_count: count,
+ timestamp_us,
+ })
+ }
+
+ /// Number of observations for a specific metric.
+ pub fn observation_count(&self, metric: DriftMetric) -> usize {
+ self.buffers
+ .iter()
+ .find(|b| b.metric == metric)
+ .map_or(0, |b| b.count())
+ }
+
+ /// Total analyses performed.
+ pub fn analysis_count(&self) -> u64 {
+ self.analysis_count
+ }
+
+ /// Person ID being monitored.
+ pub fn person_id(&self) -> u64 {
+ self.person_id
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn default_analyzer() -> AttractorDriftAnalyzer {
+ AttractorDriftAnalyzer::new(42, AttractorDriftConfig::default()).unwrap()
+ }
+
+ #[test]
+ fn test_analyzer_creation() {
+ let a = default_analyzer();
+ assert_eq!(a.person_id(), 42);
+ assert_eq!(a.analysis_count(), 0);
+ }
+
+ #[test]
+ fn test_analyzer_invalid_embedding_dim() {
+ let config = AttractorDriftConfig {
+ embedding_dim: 1,
+ ..Default::default()
+ };
+ assert!(matches!(
+ AttractorDriftAnalyzer::new(1, config),
+ Err(AttractorDriftError::InvalidEmbeddingDim { .. })
+ ));
+ }
+
+ #[test]
+ fn test_add_observation() {
+ let mut a = default_analyzer();
+ a.add_observation(DriftMetric::GaitSymmetry, 0.1);
+ a.add_observation(DriftMetric::GaitSymmetry, 0.11);
+ assert_eq!(a.observation_count(DriftMetric::GaitSymmetry), 2);
+ }
+
+ #[test]
+ fn test_analyze_insufficient_data() {
+ let mut a = default_analyzer();
+ for i in 0..10 {
+ a.add_observation(DriftMetric::GaitSymmetry, 0.1 + i as f64 * 0.001);
+ }
+ let result = a.analyze(DriftMetric::GaitSymmetry, 0);
+ assert!(matches!(
+ result,
+ Err(AttractorDriftError::InsufficientData { .. })
+ ));
+ }
+
+ #[test]
+ fn test_analyze_stable_signal() {
+ let mut a = AttractorDriftAnalyzer::new(
+ 1,
+ AttractorDriftConfig {
+ min_observations: 10,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ // Stable signal: constant with tiny noise
+ for i in 0..150 {
+ let noise = 0.001 * (i as f64 % 3.0 - 1.0);
+ a.add_observation(DriftMetric::GaitSymmetry, 0.1 + noise);
+ }
+
+ let report = a.analyze(DriftMetric::GaitSymmetry, 1000).unwrap();
+ assert_eq!(report.person_id, 1);
+ assert_eq!(report.metric, DriftMetric::GaitSymmetry);
+ assert_eq!(report.observation_count, 150);
+ assert_eq!(a.analysis_count(), 1);
+ }
+
+ #[test]
+ fn test_analyze_periodic_signal() {
+ let mut a = AttractorDriftAnalyzer::new(
+ 2,
+ AttractorDriftConfig {
+ min_observations: 10,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ // Periodic signal: sinusoidal with enough points for analyzer
+ for i in 0..200 {
+ let value = 0.5 + 0.3 * (i as f64 * std::f64::consts::PI / 7.0).sin();
+ a.add_observation(DriftMetric::BreathingRegularity, value);
+ }
+
+ let report = a.analyze(DriftMetric::BreathingRegularity, 2000).unwrap();
+ assert_eq!(report.metric, DriftMetric::BreathingRegularity);
+ assert!(!report.attractor.label().is_empty());
+ }
+
+ #[test]
+ fn test_regime_change_detection() {
+ let mut a = AttractorDriftAnalyzer::new(
+ 3,
+ AttractorDriftConfig {
+ min_observations: 10,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ // Phase 1: stable signal (enough for analyzer: >= 100 points)
+ for i in 0..150 {
+ let noise = 0.001 * (i as f64 % 3.0 - 1.0);
+ a.add_observation(DriftMetric::StabilityIndex, 0.9 + noise);
+ }
+ let _report1 = a.analyze(DriftMetric::StabilityIndex, 1000).unwrap();
+
+ // Phase 2: add chaotic-like signal
+ for i in 150..300 {
+ let value = 0.5 + 0.4 * ((i as f64 * 1.7).sin() * (i as f64 * 0.3).cos());
+ a.add_observation(DriftMetric::StabilityIndex, value);
+ }
+ let _report2 = a.analyze(DriftMetric::StabilityIndex, 2000).unwrap();
+ assert!(a.analysis_count() >= 2);
+ }
+
+ #[test]
+ fn test_biophysical_attractor_labels() {
+ assert_eq!(
+ BiophysicalAttractor::Stable { center: 0.1 }.label(),
+ "stable"
+ );
+ assert_eq!(
+ BiophysicalAttractor::Periodic { lyapunov_max: 0.0 }.label(),
+ "periodic"
+ );
+ assert_eq!(
+ BiophysicalAttractor::Chaotic {
+ lyapunov_exponent: 0.05,
+ }
+ .label(),
+ "chaotic"
+ );
+ assert_eq!(BiophysicalAttractor::Unknown.label(), "unknown");
+ }
+
+ #[test]
+ fn test_biophysical_attractor_is_concerning() {
+ assert!(!BiophysicalAttractor::Stable { center: 0.1 }.is_concerning());
+ assert!(!BiophysicalAttractor::Periodic { lyapunov_max: 0.0 }.is_concerning());
+ assert!(BiophysicalAttractor::Chaotic {
+ lyapunov_exponent: 0.05,
+ }
+ .is_concerning());
+ assert!(!BiophysicalAttractor::Unknown.is_concerning());
+ }
+
+ #[test]
+ fn test_default_config() {
+ let cfg = AttractorDriftConfig::default();
+ assert_eq!(cfg.embedding_dim, 3);
+ assert_eq!(cfg.time_delay, 1);
+ assert_eq!(cfg.min_observations, 30);
+ assert!((cfg.lyapunov_threshold - 0.01).abs() < f64::EPSILON);
+ }
+
+ #[test]
+ fn test_metric_buffer_eviction() {
+ let mut buf = MetricBuffer::new(DriftMetric::GaitSymmetry, 5);
+ for i in 0..10 {
+ buf.push(i as f64);
+ }
+ assert_eq!(buf.count(), 5);
+ assert!((buf.values[0] - 5.0).abs() < f64::EPSILON);
+ }
+
+ #[test]
+ fn test_all_metrics_have_buffers() {
+ let a = default_analyzer();
+ for metric in DriftMetric::all() {
+ assert_eq!(a.observation_count(*metric), 0);
+ }
+ }
+
+ #[test]
+ fn test_transitioning_attractor() {
+ let t = BiophysicalAttractor::Transitioning {
+ from: Box::new(BiophysicalAttractor::Stable { center: 0.1 }),
+ to: Box::new(BiophysicalAttractor::Chaotic {
+ lyapunov_exponent: 0.05,
+ }),
+ };
+ assert!(t.is_concerning());
+ assert_eq!(t.label(), "transitioning");
+ }
+
+ #[test]
+ fn test_error_display() {
+ let err = AttractorDriftError::InsufficientData {
+ needed: 30,
+ have: 10,
+ };
+ assert!(format!("{}", err).contains("30"));
+ assert!(format!("{}", err).contains("10"));
+
+ let err = AttractorDriftError::NoObservations("gait_symmetry".into());
+ assert!(format!("{}", err).contains("gait_symmetry"));
+ }
+
+ #[test]
+ fn test_debug_impl() {
+ let a = default_analyzer();
+ let dbg = format!("{:?}", a);
+ assert!(dbg.contains("AttractorDriftAnalyzer"));
+ }
+}
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/mod.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/mod.rs
index 6ba798b..0670a60 100644
--- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/mod.rs
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/mod.rs
@@ -43,6 +43,10 @@ pub mod intention;
pub mod longitudinal;
pub mod tomography;
+// ADR-032a: Midstreamer-enhanced sensing
+pub mod temporal_gesture;
+pub mod attractor_drift;
+
// ADR-029: Core multistatic pipeline
pub mod coherence;
pub mod coherence_gate;
diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/temporal_gesture.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/temporal_gesture.rs
new file mode 100644
index 0000000..4d29345
--- /dev/null
+++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/temporal_gesture.rs
@@ -0,0 +1,517 @@
+//! Enhanced gesture classification using `midstreamer-temporal-compare`.
+//!
+//! Extends the DTW-based gesture classifier from `gesture.rs` with
+//! optimized temporal comparison algorithms provided by the
+//! `midstreamer-temporal-compare` crate (ADR-032a Section 6.4).
+//!
+//! # Improvements over base gesture classifier
+//!
+//! - **Cached DTW**: Results cached by sequence hash for repeated comparisons
+//! - **Multi-algorithm**: DTW, LCS, and edit distance available
+//! - **Pattern detection**: Automatic sub-gesture pattern extraction
+//!
+//! # References
+//! - ADR-030 Tier 6: Invisible Interaction Layer
+//! - ADR-032a Section 6.4: midstreamer-temporal-compare integration
+
+use midstreamer_temporal_compare::{
+ ComparisonAlgorithm, Sequence, TemporalComparator,
+};
+
+use super::gesture::{GestureConfig, GestureError, GestureResult, GestureTemplate};
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+/// Algorithm selection for temporal gesture matching.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GestureAlgorithm {
+ /// Dynamic Time Warping (classic, from base gesture module).
+ Dtw,
+ /// Longest Common Subsequence (better for sparse gestures).
+ Lcs,
+ /// Edit distance (better for discrete gesture phases).
+ EditDistance,
+}
+
+impl GestureAlgorithm {
+ /// Convert to the midstreamer comparison algorithm.
+ pub fn to_comparison_algorithm(&self) -> ComparisonAlgorithm {
+ match self {
+ GestureAlgorithm::Dtw => ComparisonAlgorithm::DTW,
+ GestureAlgorithm::Lcs => ComparisonAlgorithm::LCS,
+ GestureAlgorithm::EditDistance => ComparisonAlgorithm::EditDistance,
+ }
+ }
+}
+
+/// Configuration for the temporal gesture classifier.
+#[derive(Debug, Clone)]
+pub struct TemporalGestureConfig {
+ /// Base gesture config (feature_dim, min_sequence_len, etc.).
+ pub base: GestureConfig,
+ /// Primary comparison algorithm.
+ pub algorithm: GestureAlgorithm,
+ /// Whether to enable result caching.
+ pub enable_cache: bool,
+ /// Cache capacity (number of comparison results to cache).
+ pub cache_capacity: usize,
+ /// Maximum distance for a match (lower = stricter).
+ pub max_distance: f64,
+ /// Maximum sequence length accepted by the comparator.
+ pub max_sequence_length: usize,
+}
+
+impl Default for TemporalGestureConfig {
+ fn default() -> Self {
+ Self {
+ base: GestureConfig::default(),
+ algorithm: GestureAlgorithm::Dtw,
+ enable_cache: true,
+ cache_capacity: 256,
+ max_distance: 50.0,
+ max_sequence_length: 1024,
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Temporal gesture classifier
+// ---------------------------------------------------------------------------
+
+/// Enhanced gesture classifier using `midstreamer-temporal-compare`.
+///
+/// Provides multi-algorithm gesture matching with caching.
+/// The comparator uses `f64` elements where each frame is reduced
+/// to its L2 norm for scalar temporal comparison.
+pub struct TemporalGestureClassifier {
+ /// Configuration.
+ config: TemporalGestureConfig,
+ /// Registered gesture templates.
+ templates: Vec,
+ /// Template sequences pre-converted to midstreamer format.
+ template_sequences: Vec>,
+ /// Temporal comparator with caching.
+ comparator: TemporalComparator,
+}
+
+impl TemporalGestureClassifier {
+ /// Create a new temporal gesture classifier.
+ pub fn new(config: TemporalGestureConfig) -> Self {
+ let comparator = TemporalComparator::new(
+ config.cache_capacity,
+ config.max_sequence_length,
+ );
+ Self {
+ config,
+ templates: Vec::new(),
+ template_sequences: Vec::new(),
+ comparator,
+ }
+ }
+
+ /// Register a gesture template.
+ pub fn add_template(
+ &mut self,
+ template: GestureTemplate,
+ ) -> Result<(), GestureError> {
+ if template.name.is_empty() {
+ return Err(GestureError::InvalidTemplateName(
+ "Template name cannot be empty".into(),
+ ));
+ }
+ if template.feature_dim != self.config.base.feature_dim {
+ return Err(GestureError::DimensionMismatch {
+ expected: self.config.base.feature_dim,
+ got: template.feature_dim,
+ });
+ }
+ if template.sequence.len() < self.config.base.min_sequence_len {
+ return Err(GestureError::SequenceTooShort {
+ needed: self.config.base.min_sequence_len,
+ got: template.sequence.len(),
+ });
+ }
+
+ let seq = Self::to_sequence(&template.sequence);
+ self.template_sequences.push(seq);
+ self.templates.push(template);
+ Ok(())
+ }
+
+ /// Number of registered templates.
+ pub fn template_count(&self) -> usize {
+ self.templates.len()
+ }
+
+ /// Classify a perturbation sequence against registered templates.
+ ///
+ /// Uses the configured comparison algorithm (DTW, LCS, or edit distance)
+ /// from `midstreamer-temporal-compare`.
+ pub fn classify(
+ &self,
+ sequence: &[Vec],
+ person_id: u64,
+ timestamp_us: u64,
+ ) -> Result {
+ if self.templates.is_empty() {
+ return Err(GestureError::NoTemplates);
+ }
+ if sequence.len() < self.config.base.min_sequence_len {
+ return Err(GestureError::SequenceTooShort {
+ needed: self.config.base.min_sequence_len,
+ got: sequence.len(),
+ });
+ }
+ for frame in sequence {
+ if frame.len() != self.config.base.feature_dim {
+ return Err(GestureError::DimensionMismatch {
+ expected: self.config.base.feature_dim,
+ got: frame.len(),
+ });
+ }
+ }
+
+ let query_seq = Self::to_sequence(sequence);
+ let algo = self.config.algorithm.to_comparison_algorithm();
+
+ let mut best_distance = f64::INFINITY;
+ let mut second_best = f64::INFINITY;
+ let mut best_idx: Option = None;
+
+ for (idx, template_seq) in self.template_sequences.iter().enumerate() {
+ let result = self
+ .comparator
+ .compare(&query_seq, template_seq, algo);
+ // Use distance from ComparisonResult (lower = better match)
+ let distance = match result {
+ Ok(cr) => cr.distance,
+ Err(_) => f64::INFINITY,
+ };
+
+ if distance < best_distance {
+ second_best = best_distance;
+ best_distance = distance;
+ best_idx = Some(idx);
+ } else if distance < second_best {
+ second_best = distance;
+ }
+ }
+
+ let recognized = best_distance <= self.config.max_distance;
+
+ // Confidence based on margin between best and second-best
+ let confidence = if recognized && second_best.is_finite() && second_best > 1e-10 {
+ (1.0 - best_distance / second_best).clamp(0.0, 1.0)
+ } else if recognized {
+ (1.0 - best_distance / self.config.max_distance).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+
+ if let Some(idx) = best_idx {
+ let template = &self.templates[idx];
+ Ok(GestureResult {
+ recognized,
+ gesture_type: if recognized {
+ Some(template.gesture_type)
+ } else {
+ None
+ },
+ template_name: if recognized {
+ Some(template.name.clone())
+ } else {
+ None
+ },
+ distance: best_distance,
+ confidence,
+ person_id,
+ timestamp_us,
+ })
+ } else {
+ Ok(GestureResult {
+ recognized: false,
+ gesture_type: None,
+ template_name: None,
+ distance: f64::INFINITY,
+ confidence: 0.0,
+ person_id,
+ timestamp_us,
+ })
+ }
+ }
+
+ /// Get cache statistics from the temporal comparator.
+ pub fn cache_stats(&self) -> midstreamer_temporal_compare::CacheStats {
+ self.comparator.cache_stats()
+ }
+
+ /// Active comparison algorithm.
+ pub fn algorithm(&self) -> GestureAlgorithm {
+ self.config.algorithm
+ }
+
+ /// Convert a feature sequence to a midstreamer `Sequence`.
+ ///
+ /// Each frame's L2 norm is quantized to an i64 (multiplied by 1000)
+ /// for use with the generic comparator.
+ fn to_sequence(frames: &[Vec]) -> Sequence {
+ let mut seq = Sequence::new();
+ for (i, frame) in frames.iter().enumerate() {
+ let norm = frame.iter().map(|x| x * x).sum::().sqrt();
+ let quantized = (norm * 1000.0) as i64;
+ seq.push(quantized, i as u64);
+ }
+ seq
+ }
+}
+
+// We implement Debug manually because TemporalComparator does not derive Debug
+impl std::fmt::Debug for TemporalGestureClassifier {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("TemporalGestureClassifier")
+ .field("config", &self.config)
+ .field("template_count", &self.templates.len())
+ .finish()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use super::super::gesture::GestureType;
+
+ fn make_template(
+ name: &str,
+ gesture_type: GestureType,
+ n_frames: usize,
+ feature_dim: usize,
+ pattern: fn(usize, usize) -> f64,
+ ) -> GestureTemplate {
+ let sequence: Vec> = (0..n_frames)
+ .map(|t| (0..feature_dim).map(|d| pattern(t, d)).collect())
+ .collect();
+ GestureTemplate {
+ name: name.to_string(),
+ gesture_type,
+ sequence,
+ feature_dim,
+ }
+ }
+
+ fn wave_pattern(t: usize, d: usize) -> f64 {
+ if d == 0 {
+ (t as f64 * 0.5).sin()
+ } else {
+ 0.0
+ }
+ }
+
+ fn push_pattern(t: usize, d: usize) -> f64 {
+ if d == 0 {
+ t as f64 * 0.1
+ } else {
+ 0.0
+ }
+ }
+
+ fn small_config() -> TemporalGestureConfig {
+ TemporalGestureConfig {
+ base: GestureConfig {
+ feature_dim: 4,
+ min_sequence_len: 5,
+ max_distance: 10.0,
+ band_width: 3,
+ },
+ algorithm: GestureAlgorithm::Dtw,
+ enable_cache: false,
+ cache_capacity: 64,
+ max_distance: 100000.0, // generous for testing
+ max_sequence_length: 1024,
+ }
+ }
+
+ #[test]
+ fn test_temporal_classifier_creation() {
+ let classifier = TemporalGestureClassifier::new(small_config());
+ assert_eq!(classifier.template_count(), 0);
+ assert_eq!(classifier.algorithm(), GestureAlgorithm::Dtw);
+ }
+
+ #[test]
+ fn test_temporal_add_template() {
+ let mut classifier = TemporalGestureClassifier::new(small_config());
+ let template = make_template("wave", GestureType::Wave, 10, 4, wave_pattern);
+ classifier.add_template(template).unwrap();
+ assert_eq!(classifier.template_count(), 1);
+ }
+
+ #[test]
+ fn test_temporal_add_template_empty_name() {
+ let mut classifier = TemporalGestureClassifier::new(small_config());
+ let template = make_template("", GestureType::Wave, 10, 4, wave_pattern);
+ assert!(matches!(
+ classifier.add_template(template),
+ Err(GestureError::InvalidTemplateName(_))
+ ));
+ }
+
+ #[test]
+ fn test_temporal_add_template_wrong_dim() {
+ let mut classifier = TemporalGestureClassifier::new(small_config());
+ let template = make_template("wave", GestureType::Wave, 10, 8, wave_pattern);
+ assert!(matches!(
+ classifier.add_template(template),
+ Err(GestureError::DimensionMismatch { .. })
+ ));
+ }
+
+ #[test]
+ fn test_temporal_classify_no_templates() {
+ let classifier = TemporalGestureClassifier::new(small_config());
+ let seq: Vec> = (0..10).map(|_| vec![0.0; 4]).collect();
+ assert!(matches!(
+ classifier.classify(&seq, 1, 0),
+ Err(GestureError::NoTemplates)
+ ));
+ }
+
+ #[test]
+ fn test_temporal_classify_too_short() {
+ let mut classifier = TemporalGestureClassifier::new(small_config());
+ classifier
+ .add_template(make_template("wave", GestureType::Wave, 10, 4, wave_pattern))
+ .unwrap();
+ let seq: Vec> = (0..3).map(|_| vec![0.0; 4]).collect();
+ assert!(matches!(
+ classifier.classify(&seq, 1, 0),
+ Err(GestureError::SequenceTooShort { .. })
+ ));
+ }
+
+ #[test]
+ fn test_temporal_classify_exact_match() {
+ let mut classifier = TemporalGestureClassifier::new(small_config());
+ let template = make_template("wave", GestureType::Wave, 10, 4, wave_pattern);
+ classifier.add_template(template).unwrap();
+
+ let seq: Vec> = (0..10)
+ .map(|t| (0..4).map(|d| wave_pattern(t, d)).collect())
+ .collect();
+
+ let result = classifier.classify(&seq, 1, 100_000).unwrap();
+ assert!(result.recognized, "Exact match should be recognized");
+ assert_eq!(result.gesture_type, Some(GestureType::Wave));
+ assert!(result.distance < 1e-6, "Exact match should have near-zero distance");
+ }
+
+ #[test]
+ fn test_temporal_classify_best_of_two() {
+ let mut classifier = TemporalGestureClassifier::new(small_config());
+ classifier
+ .add_template(make_template("wave", GestureType::Wave, 10, 4, wave_pattern))
+ .unwrap();
+ classifier
+ .add_template(make_template("push", GestureType::Push, 10, 4, push_pattern))
+ .unwrap();
+
+ let seq: Vec> = (0..10)
+ .map(|t| (0..4).map(|d| wave_pattern(t, d)).collect())
+ .collect();
+
+ let result = classifier.classify(&seq, 1, 0).unwrap();
+ assert!(result.recognized);
+ }
+
+ #[test]
+ fn test_temporal_algorithm_selection() {
+ assert_eq!(
+ GestureAlgorithm::Dtw.to_comparison_algorithm(),
+ ComparisonAlgorithm::DTW
+ );
+ assert_eq!(
+ GestureAlgorithm::Lcs.to_comparison_algorithm(),
+ ComparisonAlgorithm::LCS
+ );
+ assert_eq!(
+ GestureAlgorithm::EditDistance.to_comparison_algorithm(),
+ ComparisonAlgorithm::EditDistance
+ );
+ }
+
+ #[test]
+ fn test_temporal_lcs_algorithm() {
+ let config = TemporalGestureConfig {
+ algorithm: GestureAlgorithm::Lcs,
+ ..small_config()
+ };
+ let mut classifier = TemporalGestureClassifier::new(config);
+ classifier
+ .add_template(make_template("wave", GestureType::Wave, 10, 4, wave_pattern))
+ .unwrap();
+
+ let seq: Vec> = (0..10)
+ .map(|t| (0..4).map(|d| wave_pattern(t, d)).collect())
+ .collect();
+
+ let result = classifier.classify(&seq, 1, 0).unwrap();
+ assert!(result.recognized);
+ }
+
+ #[test]
+ fn test_temporal_edit_distance_algorithm() {
+ let config = TemporalGestureConfig {
+ algorithm: GestureAlgorithm::EditDistance,
+ ..small_config()
+ };
+ let mut classifier = TemporalGestureClassifier::new(config);
+ classifier
+ .add_template(make_template("wave", GestureType::Wave, 10, 4, wave_pattern))
+ .unwrap();
+
+ let seq: Vec> = (0..10)
+ .map(|t| (0..4).map(|d| wave_pattern(t, d)).collect())
+ .collect();
+
+ let result = classifier.classify(&seq, 1, 0).unwrap();
+ assert!(result.recognized);
+ }
+
+ #[test]
+ fn test_temporal_default_config() {
+ let config = TemporalGestureConfig::default();
+ assert_eq!(config.algorithm, GestureAlgorithm::Dtw);
+ assert!(config.enable_cache);
+ assert_eq!(config.cache_capacity, 256);
+ assert!((config.max_distance - 50.0).abs() < f64::EPSILON);
+ }
+
+ #[test]
+ fn test_temporal_cache_stats() {
+ let classifier = TemporalGestureClassifier::new(small_config());
+ let stats = classifier.cache_stats();
+ assert_eq!(stats.hits, 0);
+ assert_eq!(stats.misses, 0);
+ }
+
+ #[test]
+ fn test_to_sequence_conversion() {
+ let frames: Vec> = vec![vec![3.0, 4.0], vec![0.0, 1.0]];
+ let seq = TemporalGestureClassifier::to_sequence(&frames);
+ // First element: sqrt(9+16) = 5.0 -> 5000
+ // Second element: sqrt(0+1) = 1.0 -> 1000
+ assert_eq!(seq.len(), 2);
+ }
+
+ #[test]
+ fn test_debug_impl() {
+ let classifier = TemporalGestureClassifier::new(small_config());
+ let dbg = format!("{:?}", classifier);
+ assert!(dbg.contains("TemporalGestureClassifier"));
+ }
+}