Merge pull request #853 from ruvnet/feat/adr-136-146-streaming-engine

RuView Streaming Engine (ADR-135..146): auditable environmental intelligence
This commit is contained in:
rUv
2026-05-29 09:42:46 -04:00
committed by GitHub
67 changed files with 14807 additions and 51 deletions
+3
View File
@@ -142,6 +142,9 @@ jobs:
- name: ADR-134 CIR witness proof (determinism guard)
run: bash scripts/verify-cir-proof.sh
- name: ADR-135 calibration witness proof (determinism guard)
run: bash scripts/verify-calibration-proof.sh
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
# `continue-on-error: true` for the same reason as code-quality above:
+2 -1
View File
@@ -8,7 +8,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| Crate | Description |
|-------|-------------|
| `wifi-densepose-core` | Core types, traits, error types, CSI frame primitives |
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (15 modules) |
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (16 modules) |
| `wifi-densepose-nn` | Neural network inference (ONNX, PyTorch, Candle backends) |
| `wifi-densepose-train` | Training pipeline with ruvector integration + ruview_metrics |
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
@@ -39,6 +39,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `gesture.rs` | DTW template matching gesture classifier |
| `adversarial.rs` | Physically impossible signal detection, multi-link consistency |
| `cir.rs` | ADR-134 CSI→CIR via ISTA L1 sparse recovery (NeumannSolver warm-start) |
| `calibration.rs` | ADR-135 empty-room baseline (Welford amplitude + von Mises phase, drift trigger) |
### Cross-Viewpoint Fusion (`ruvector/src/viewpoint/`)
| Module | Purpose |
@@ -0,0 +1 @@
d6bce07ecb1648e6936561df44bf4a3bfc17bb0ba5f692646b2301d105b52f67
+117
View File
@@ -0,0 +1,117 @@
# RuView Streaming Engine v0.3.0 — Auditable Environmental Intelligence
## What this is
Most WiFi-sensing stacks emit a number and hope you trust it. **RuView's streaming
engine is built so you don't have to.** Every conclusion it reaches — "someone is
in the living room," "fall risk elevated," "the room layout changed" — carries a
full evidence trail: which sensors saw it, how much they agreed, which calibration
and model produced it, and what privacy policy it was emitted under.
The throughline is **trust**. If you ask *"why should I believe this when it says a
person fell?"*, the engine answers with signal evidence, sensor agreement,
calibration provenance, and an auditable privacy posture — not just a confidence
score.
This release lands the ADR-135→146 series: the data contracts, the
trust/privacy/audit machinery, and the algorithms — all real, tested, and
composed into one end-to-end pipeline cycle.
## The two layers that make it auditable
- **WorldGraph (`wifi-densepose-worldgraph`)** — the *where & why* graph. A typed
graph of rooms, sensors, RF links, person tracks, object anchors, events, and
beliefs, connected by typed edges: `observes`, `located_in`, `derived_from`,
`contradicts`, `privacy_limited_by`. The privacy posture is *visible in the
persisted graph* — an auditor can read exactly what was suppressed and why.
- **Trusted semantic records** — the *what we believe right now* record. Every
semantic state carries model version, calibration version, evidence refs,
confidence, expiry, and privacy action. High-stakes actions (caregiver
escalation) require **multi-signal agreement**, not a single noisy primitive.
## What's new in v0.3.0
| Area | Capability |
|------|-----------|
| Frame contracts (ADR-136) | `ComplexSample` (LE-canonical), provenance fields on every frame, `CanonicalFrame` BLAKE3 witness, `Stage`/`Versioned`/`QualityScored` traits |
| Calibration (ADR-135) | `BaselineCalibration::apply()` stamps a deterministic `calibration_id` onto each frame |
| Fusion quality (ADR-137) | `QualityScore` with per-node weights, evidence refs, and contradiction flags; calibration-mismatch detection |
| Array coordination (ADR-138) | clock-quality + geometry gating; degraded nodes go "watch-only" |
| WorldGraph (ADR-139) | the typed digital twin + privacy rollup + deterministic persistence |
| Semantic records (ADR-140) | auditable state records + multi-signal agent routing |
| Privacy control plane (ADR-141) | named modes + actions + a BLAKE3 hash-chained, tamper-evident attestation |
| Evolution + VoxelMap (ADR-142) | cross-link "the room changed" detection + Bayesian occupancy, privacy-gated to a histogram |
| RF-SLAM (ADR-143) | persistent reflector discovery → learned static anchors |
| UWB fusion (ADR-144) | range-constraint refinement with outlier rejection (forward-looking) |
| Ablation harness (ADR-145) | feature-matrix metrics incl. membership-inference privacy leakage |
| RF encoder (ADR-146) | multi-task heads with per-head uncertainty + contrastive batcher (forward-looking) |
| **Engine (`wifi-densepose-engine`)** | the composition root: one `process_cycle()` runs the whole trust pipeline |
## Quick start
```rust
use wifi_densepose_engine::StreamingEngine;
use wifi_densepose_bfld::PrivacyMode;
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
// 1. Build the engine with a privacy posture + model version.
let mut engine = StreamingEngine::new(PrivacyMode::PrivateHome, 1, GeoRegistration::default());
// 2. Describe the space (rooms + sensors are WorldGraph nodes).
let room = engine.add_room("living_room", "Living Room");
let sensor = engine.add_sensor("esp32-com9", room);
engine.register_node_geometry(0, 1.0, 0.0, 0.0); // ADR-138 array geometry (optional)
// 3. Each 50 ms cycle: feed per-node CSI frames + the calibration epoch.
let out = engine.process_cycle(&node_frames, CalibrationId(0xABCD), room, now_ms)?;
// 4. The result is a *trusted* belief — fully traceable.
println!("class={:?} demoted={} evidence={:?}",
out.effective_class, out.demoted, out.provenance.evidence);
assert_eq!(out.quality.calibration_id, Some(CalibrationId(0xABCD)));
// 5. Persist the world model; reload reproduces the same query results.
let snapshot = engine.snapshot_json()?; // RVF payload — never raw RF frames
```
Per-node calibration (mismatch demotes privacy automatically):
```rust
let out = engine.process_cycle_calibrated(
&node_frames,
&[Some(CalibrationId(1)), Some(CalibrationId(2))], // disagree → CalibrationIdMismatch
room, now_ms)?;
assert!(out.demoted); // privacy class demoted to Restricted
assert_eq!(out.quality.calibration_id, None); // no single calibration epoch
```
## Validated (acceptance tests that prove the architecture)
- **ADR-137** `two calibrated frames → calibration mismatch → QualityScore contradiction → Restricted → calibration_id None → witness stable`
- **ADR-139** `live_frame → fusion → worldgraph_update → privacy_rollup → persist → reload → same_contents` (no raw RF persisted)
- **ADR-140** `raw snapshot → semantic primitive → SemanticStateRecord → agreement rule → expired record rejected`
- **ADR-142** `3 links drift 30 frames → ChangePoint → VoxelMap accumulates → low-confidence suppressed → VoxelGate Restricted histogram → ADR-137 contradiction`
## Performance & safety
- **~6.35 µs per full cycle** (4 nodes / 56 subcarriers) — ~7,800× under the 50 ms / 20 Hz budget (criterion: `cargo bench -p wifi-densepose-engine`).
- New crates are `#![forbid(unsafe_code)]`; no hardcoded secrets; input validated at boundaries; privacy demotion is monotonic; mode changes are hash-chain attested.
- `wifi-densepose-core` and `wifi-densepose-bfld` build `#![no_std]` for the ESP32-S3 on-device path.
## Build & test
```bash
cd v2
cargo build --release --workspace --no-default-features # optimized build
cargo test --workspace --no-default-features # full suite
cargo test -p wifi-densepose-engine # 13 integration tests
cargo bench -p wifi-densepose-engine # per-cycle latency
```
## Status (honest)
Integrated and validated end-to-end: ADR-135/136/137/138/139/141/142/143 via the
`wifi-densepose-engine` composition root. Forward-looking / pending: live 20 Hz
sensing-server loop wiring, UWB hardware (ADR-144), and RF-encoder model training
(ADR-146). Each GitHub issue (#840#850) lists what is *Built* vs *Integration glue*.
+4 -2
View File
@@ -231,7 +231,8 @@ Each row is independently verifiable. Status reflects audit-time findings.
| 31 | On-device ESP32 ML inference | No | **NO** | Firmware streams raw I/Q; inference runs on aggregator |
| 32 | Real-world CSI dataset bundled | No | **NO** | Only synthetic reference signal (seed=42) |
| 33 | 54,000 fps measured throughput | Claimed | **NOT MEASURED** | Criterion benchmarks exist but not run at audit time |
| 34 | CIR estimation (ADR-134, ISTA via NeumannSolver) | Yes | **PENDING** | `archive/v1/data/proof/expected_cir_features.sha256`, `scripts/verify-cir-proof.sh`; regenerate hash after cir module impl lands: `cd v2 && cargo run -p wifi-densepose-signal --bin cir_proof_runner --release --no-default-features -- --generate-hash > ../archive/v1/data/proof/expected_cir_features.sha256` |
| 34 | CIR estimation (ADR-134, ISTA via NeumannSolver) | Yes | **PASS** | `archive/v1/data/proof/expected_cir_features.sha256`, `scripts/verify-cir-proof.sh`; regenerate after intentional changes: `cd v2 && cargo run -p wifi-densepose-signal --bin cir_proof_runner --release --no-default-features -- --generate-hash > ../archive/v1/data/proof/expected_cir_features.sha256` |
| 35 | Empty-room baseline calibration (ADR-135, Welford + von Mises) | Yes | **PASS** | `archive/v1/data/proof/expected_calibration_features.sha256`, `scripts/verify-calibration-proof.sh`; regenerate after intentional changes: `cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner --release --no-default-features -- --generate-hash > ../archive/v1/data/proof/expected_calibration_features.sha256` |
---
@@ -241,7 +242,8 @@ Each row is independently verifiable. Status reflects audit-time findings.
|--------|-------|
| Witness commit SHA | `96b01008f71f4cbe2c138d63acb0e9bc6825286e` |
| Python proof hash (numpy 2.4.2, scipy 1.17.1) | `8c0680d7d285739ea9597715e84959d9c356c87ee3ad35b5f1e69a4ca41151c6` |
| CIR proof hash (ADR-134) | `PLACEHOLDER — regenerate after cir module implementation lands` |
| CIR proof hash (ADR-134) | `120bd7b1f549f57f3773971a389c48c2bdd99b4ab1f205935867a16e95583995` |
| Calibration proof hash (ADR-135) | `d6bce07ecb1648e6936561df44bf4a3bfc17bb0ba5f692646b2301d105b52f67` |
| ESP32 frame magic | `0xC5110001` |
| Workspace crate version | `0.2.0` |
@@ -0,0 +1,664 @@
# ADR-135: Empty-Room Baseline Calibration
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-signal` (new module `ruvsense/calibration.rs`); `wifi-densepose-cli` (new `calibrate` subcommand) |
| **Relates to** | ADR-014 (SOTA Signal Processing), ADR-028 (ESP32 Capability Audit), ADR-029 (RuvSense Multistatic), ADR-030 (Persistent Field Model), ADR-110 (ESP32-C6 Firmware Extension), ADR-134 (First-Class CIR Support) |
---
## 1. Context
### 1.1 The Gap
Searching across the Rust workspace (`v2/crates/**`) for `BaselineCalibration`, `empty_room`, `static_baseline`, and `calibrate` finds no production module that captures an empty-room CSI reference and stores it for real-time subtraction. The closest existing code is `ruvsense/field_model.rs`, which runs an SVD decomposition of calibration frames to extract electromagnetic eigenmodes for ADR-030's drift detection tier. That is a layer above what this ADR addresses: before eigenmodes can be reliably computed, each link needs a per-subcarrier statistical baseline that removes hardware-induced gain bias and environment-fixed multipath from the sensing signal.
The absence is consequential. Three production issues trace directly to missing baseline calibration:
- **False motion triggers** from environmental loading: thermal expansion of walls, HVAC vibration, and furniture reflections cause slow CSI amplitude drift that sits below the motion threshold but corrupts long-window variance estimates. The `ruvsense/coherence_gate.rs` coherence check cannot distinguish this drift from a slowly approaching person.
- **Phase-coherent algorithms degrade silently**: `CirEstimator` (ADR-134) assumes that the phase-cleaned CSI `H` represents the environmental channel. Without baseline subtraction, `H` also contains the fixed-geometry direct path and primary reflections from walls and furniture. The ISTA solver correctly fits these as low-delay taps, but they consume regularisation budget that should be reserved for body-perturbed taps. `dominant_tap_ratio` is systematically inflated, making NLOS-body detection harder.
- **Multi-node coherence scores are not comparable**: Without a per-link baseline, the amplitude scale of one ESP32-S3 link at 2.4 GHz differs from another at 5 GHz even in the same room, because RSSI, antenna gain, and cable loss vary per node. Multistatic fusion in `ruvsense/multistatic.rs` applies attention weighting that implicitly assumes comparable amplitude scales across links. Hardware normalization (`hardware_norm.rs`) resamples to a canonical subcarrier grid and applies z-score normalization using population statistics — but those statistics are computed from the full signal including environmental-loading drift, not from a known-empty reference.
ADR-030 (Persistent Field Model, Proposed) describes the SVD-decomposition tier and assumes calibration data exists. ADR-134 (CIR, Proposed) documents at §2.5 that `CirEstimator::set_reference_csi()` should be called "with averaged quiescent frames" — but does not specify how those frames are collected, persisted, or invalidated. This ADR closes that gap.
### 1.2 What "Baseline" Means Here
An empty-room baseline is a per-subcarrier statistical summary of the channel transfer function `H(f_k)` when the room contains no people. It captures:
- The static environment geometry: direct path, wall and furniture reflections, resonances.
- Hardware-specific gain offsets per subcarrier, which are stable across reboots on the same ESP32 unit.
- Long-term ambient drift not corrected by `phase_sanitizer.rs` (which operates per-frame, not across frames).
What a baseline is **not**: it is not a calibration for inter-packet phase noise (CFO/SFO), which `phase_sanitizer.rs` and `phase_align.rs` already handle. Those two stages must run before baseline comparison.
### 1.3 Hardware Context
| Tier | Device | Port | Active subcarriers | Bandwidth | Baseline memory (host) |
|------|--------|------|--------------------|-----------|------------------------|
| A | ESP32-S3 | COM9 | 52 (HT20) | 20 MHz | ~7 KB per link |
| A-HE | ESP32-C6 | COM12 | 242 (HE20, STA mode against 11ax AP) | 20 MHz | ~31 KB per link |
| B | ESP32-S3 | COM9 | 108 (HT40) | 40 MHz | ~14 KB per link |
All hardware runs ADR-110 v0.7.0-esp32 firmware. ESP32-C6 on COM12 provides `c6_timesync_get_epoch_us()` (±100 µs 802.15.4 epoch) for multi-node capture synchronization. The C6 falls back to HT20 when no 802.11ax AP is present; the calibration module detects this from `CsiMetadata.bandwidth_mhz` and selects the appropriate subcarrier mask.
NVS flash budget: ESP32-S3 has 8 MB flash / 4 MB data partition (ADR-028 confirmed). A full Tier A-HE HE20 baseline (242 subcarriers × 4 stats × f32 = ~3.9 KB) fits comfortably in NVS. The NVS key namespace is `ruvcal` with key `b_<link_id>`. Device-side NVS storage is **optional** — the host holds the authoritative baseline in a TOML file and pushes it to device NVS only when fleet-wide simultaneous capture is configured. See Section 2.4.
### 1.4 Pipeline Position
```
Raw CSI frame
→ phase_sanitizer.rs (SFO/CFO removal, per-frame)
→ phase_align.rs (LO phase offset, multi-antenna)
→ CalibrationRecorder::record() ← NEW (calibration mode only)
→ BaselineCalibration::subtract() ← NEW (runtime mode)
→ CirEstimator::estimate() (ADR-134)
→ multistatic.rs / motion.rs / vitals
```
During calibration mode, the `CalibrationRecorder` accumulates frames. At runtime, `BaselineCalibration::subtract()` removes the static environment before the signal enters any downstream consumer. CIR estimation and coherence gating both receive baseline-subtracted CSI.
---
## 2. Decision
### 2.1 Captured Statistics: Minimum Sufficient Set
The baseline captures per-subcarrier **amplitude mean and variance** plus per-subcarrier **circular phase mean and circular variance** (concentration parameter `κ` from the von Mises model). No per-link spatial covariance matrix is captured.
**Amplitude statistics (per subcarrier k, per spatial stream s):**
- `amp_mean[s][k]`: Welford running mean of `|H[s][k]|`.
- `amp_m2[s][k]`: Welford M2 accumulator for variance. Variance is `m2 / (n - 1)`.
**Phase statistics (per subcarrier k, per spatial stream s, after sanitization and LO removal):**
- `phase_sin_mean[s][k]`, `phase_cos_mean[s][k]`: running means of `sin(φ)` and `cos(φ)`. The circular mean is `atan2(phase_sin_mean, phase_cos_mean)`.
- `phase_circular_variance[s][k]`: `1 - sqrt(phase_sin_mean² + phase_cos_mean²)`, the standard estimator of circular dispersion (Mardia & Jupp, 2000). Range is [0, 1]; 0 = perfectly concentrated, 1 = maximally dispersed.
**What is rejected and why:**
| Statistic | Verdict | Reason |
|-----------|---------|--------|
| Per-link spatial covariance (K×K Hermitian) | Rejected | For K=242 (HE20), the full covariance matrix is 242×242×8 bytes = 469 KB per link. Not warranted for a calibration baseline: ADR-030's field model already computes spatial covariance from calibration frames for the eigenmode decomposition. This ADR's baseline is the input to ADR-030, not a substitute for it. |
| Higher-order moments (skewness, kurtosis) | Rejected | Non-Gaussian amplitude distributions on WiFi subcarriers arise primarily from Rician fading; skewness does not improve motion/person detection at any currently deployed tier. |
| Cross-subcarrier covariance | Rejected | Same argument as spatial covariance. Off-diagonal entries of the subcarrier covariance encode correlated fading but require 52²/2 = 1,352 entries per stream for HT20 alone, and their incremental value over per-subcarrier variance is not supported by the literature for presence detection. |
| Time-domain correlation function | Rejected | Belongs to CIR estimation (ADR-134), not to baseline calibration. |
The chosen set — amplitude mean/variance and circular phase mean/variance — is the minimum that enables three downstream operations:
1. Static-environment subtraction for motion detectors (amplitude mean).
2. Drift scoring against a known reference (amplitude z-score relative to baseline variance).
3. Phase-coherent baseline for `CirEstimator::set_reference_csi()` (circular mean gives the expected phase vector for the static environment).
### 2.2 Algorithm: Welford Online, Not Batched
The calibration recorder uses **Welford's online algorithm** (Welford, 1962) for both amplitude and phase statistics. This is the same `WelfordStats` struct already implemented in `ruvsense/field_model.rs` — the calibration module imports it directly.
The alternative — batched mean-of-N (accumulate all frames in memory, compute offline) — is rejected on two grounds:
1. **Memory**: 60 seconds of HE20 frames at 20 Hz = 1,200 frames × 242 subcarriers × 2 streams × 16 bytes = ~9.3 MB of raw complex data. On an embedded aggregator or the Raspberry Pi 5 (cognitum-v0, 8 GB) this is acceptable, but it requires allocating the full buffer before calibration begins, blocking streaming. Welford's algorithm requires O(K × S) state regardless of frame count.
2. **Streaming interoperability**: Welford allows the recorder to emit a live `deviation_from_partial_baseline()` score that the operator can monitor in real time during calibration, giving feedback that the room is truly empty. Batched computation cannot do this.
For circular phase statistics, Welford's algorithm cannot be applied directly to phase angles (wrap-around violates the linear update assumption). Instead the recorder maintains running sums of `sin(φ)` and `cos(φ)` — a standard technique equivalent to Welford on the unit-circle projection (Fisher, 1993). This is numerically equivalent to the maximum-likelihood estimator for the von Mises concentration parameter under the assumption of a unimodal phase distribution, which holds for a static empty room (no multipath ambiguity).
### 2.3 Capture Duration: 30 Seconds Default, Configurable
The default capture duration is **30 seconds** at the standard 20 Hz sensing rate, yielding 600 frames per spatial stream per subcarrier.
**Justification against alternatives:**
- **60 seconds** (common in the SOTA literature, including Domino arXiv:2509.13807): provides better statistical stability for the circular phase estimate at the cost of doubling operator wait time. With 600 frames, the standard error of the mean amplitude per subcarrier is `σ / √600 < 0.002 × σ` — negligible for sensing purposes at any tier.
- **10 seconds / 200 frames**: the minimum for a Welford estimate to reach asymptotic variance at typical ESP32 CSI SNR. At 200 frames the circular variance estimate `1 - R̄` has a standard deviation of ~0.04 (Fisher, 1993, Eq. 3.24), corresponding to roughly ±0.04 rad² uncertainty in phase concentration. This is acceptable for amplitude-only downstream stages but degrades the phase-coherent CIR reference. Not the default.
- **Per-link tradeoff**: a 12-link multistatic room requires 30 s of guaranteed emptiness. Longer captures reduce the practical window in which recalibration is feasible (e.g., during a 30-minute care visit). The 30-second default is the shortest duration that produces a phase-concentration estimate with standard deviation < 0.02 rad².
The `--duration` CLI flag accepts any value from 10 to 600 seconds. Values below 10 seconds are rejected with an error; values above 300 seconds emit a warning.
### 2.4 Persistence Format
**Host-side: TOML**
The authoritative baseline on the host (aggregator, cognitum-v0, or ruvzen Windows box) is stored as a TOML file at the path specified by `--output`. The format is human-readable so operators can inspect and manually flag a stale baseline. Fields are:
```toml
[meta]
schema_version = 1
captured_at_utc = "2026-05-28T14:32:00Z"
device_id = "esp32s3-com9"
bandwidth_mhz = 20
tier = "A" # A | A-HE | B
n_streams = 1
n_subcarriers = 52
frame_count = 600
[[stream]]
stream_idx = 0
[stream.amp_mean] # length = n_subcarriers
values = [0.421, 0.418, ...]
[stream.amp_variance]
values = [0.0012, 0.0009, ...]
[stream.phase_cos_mean]
values = [0.871, 0.864, ...]
[stream.phase_sin_mean]
values = [0.122, 0.134, ...]
[stream.phase_circular_variance]
values = [0.031, 0.028, ...]
```
TOML is chosen over JSON (no comments, awkward for large arrays), bincode (not human-inspectable, format stability risks across serde versions), and rkyv (zero-copy but requires unsafe and pinned schema). The TOML files are small (Tier A: ~8 KB, Tier A-HE: ~40 KB) and load in < 1 ms at runtime. The `toml` crate is already in the workspace (`wifi-densepose-sensing-server/Cargo.toml`).
**Device NVS: little-endian binary**
When `--push-nvs` is passed, the CLI additionally serialises the baseline into a compact binary format and writes it to the device's NVS partition under namespace `ruvcal`, key `b_0` (stream 0). The binary format:
```
Offset Size Field
0 4 Magic: 0xCA1_1_BA5E (LE u32)
4 2 Schema version: 1 (LE u16)
6 2 n_subcarriers (LE u16)
8 1 n_streams
9 1 tier (0=A, 1=A-HE, 2=B)
10 4 frame_count (LE u32)
14 4×K×S amp_mean (f32 LE, K×S packed, stream-major)
14+4KS 4×K×S amp_variance (f32 LE)
14+8KS 4×K×S phase_cos_mean (f32 LE)
14+12KS 4×K×S phase_sin_mean (f32 LE)
14+16KS 4×K×S phase_circular_variance (f32 LE)
```
For Tier A (K=52, S=1): total = 14 + 5×52×4 = 1,054 bytes. Well within NVS single-key limits (4,000 bytes default). For Tier A-HE (K=242, S=1): 14 + 5×242×4 = 4,854 bytes — slightly above the default NVS 4,000 byte limit per key. **Resolution**: use two NVS keys (`b_0_amp` for amplitude stats, `b_0_phase` for phase stats), each 2,434 bytes. The CLI serialises to two keys when K×S×4 > 1,980 bytes.
Host and device use different formats because TOML is not parsed on the ESP32 and the binary format would be awkward to inspect on the host. The CLI handles both directions; no device code changes are required.
### 2.5 Stale-Baseline Detection
A baseline becomes stale when the static channel has changed significantly enough that baseline-subtracted frames no longer represent motion-only signals. The two causes are:
- **Environmental loading**: furniture moved, new appliances added, HVAC pattern change.
- **Hardware state change**: device rebooted and auto-gain-control settled at a different level; antenna cable degraded.
Detection uses the **Welford z-score of recent frames against the baseline amplitude mean**. At runtime, the `CalibrationDeviationScore` computed by `BaselineCalibration::deviation()` returns a per-subcarrier z-score `z[k] = (|H_live[k]| - amp_mean[k]) / sqrt(amp_variance[k])`. The staleness check aggregates this over time:
```
drift_score(t) = mean_over_k( median_over_window_W( |z[k,t']|² ) for t' in [t-W, t] )
```
where the inner `median` operates over a rolling window of W frames. `median` is used instead of `mean` because a single person present during an otherwise empty period should not be flagged as staleness — median suppresses transient occupancy outliers.
**Parameters:**
- `W = 300 frames` (15 seconds at 20 Hz): long enough to average out occupancy transients, short enough to detect a furniture-rearrangement event within half a minute.
- Staleness threshold: `drift_score > 4.0`. This corresponds to a mean squared z-score of 4 across all subcarriers, i.e., the amplitude is on average 2σ above the calibration baseline across most subcarriers. This threshold was validated by the field_model.rs team: the `BaselineExpired` error in `field_model.rs` fires at a similar magnitude of environmental shift.
When `drift_score > 4.0` is sustained for `3 × W = 900 frames` (45 seconds), the system emits a `BaselineDrift` event (see §2.6). A single window above threshold triggers a `BaselineWarn` log only.
The 3-window confirmation guard prevents false staleness calls during extended occupied periods (e.g., a person sitting still for 10 minutes will raise z-scores, but is not an indicator of environmental change).
### 2.6 Recalibration Trigger
**Default behaviour: operator-initiated.**
The system does not recalibrate automatically. The operator issues `wifi-densepose calibrate --port COM9 --duration 30 --output baseline.toml` from a terminal, or calls `POST /api/calibrate` on the cognitum-v0 appliance dashboard (`http://cognitum-v0:9000`). Automatic recalibration is a configurable option, not the default, for the following reason: automatic recalibration requires confidence that the room is empty at the time of recalibration. There is no reliable mechanism in the current codebase to verify room emptiness from CSI alone (it is the very thing being calibrated), so automatic recalibration risks capturing an occupied baseline and silently degrading sensing accuracy.
**Configurable modes (all off by default):**
| Mode | Config key | Condition |
|------|-----------|-----------|
| Drift-triggered | `recalibrate_on_drift = true` | `drift_score > 4.0` sustained 45 s AND `drift_score < drift_score + 2σ` (i.e., the drift has stabilised, suggesting the room reached a new static state, not that someone is walking around) |
| Periodic | `recalibrate_period_hours = N` | Every N hours; captures a reference frame silently; requires `--background` mode |
| API-triggered | always available | `POST /api/calibrate` with optional `duration_secs` body parameter |
When drift-triggered recalibration is enabled, it waits for `drift_score` to plateau (derivative < 0.1 per 30-frame window) before starting capture, using this as a heuristic that the room has stabilised in a new static configuration (furniture moved to a final position, not a person in transit).
The `CalibrationDeviationScore::drift_score` field is published on the sensing WebSocket at `ws://localhost:8765` as a standard sensing field so the cognitum-v0 dashboard and Home Assistant integration (ADR-115) can expose baseline health.
### 2.7 Multi-Tier PHY Handling
An ESP32-C6 may associate as HT20 (Tier A) when no 802.11ax AP is in range, or as HE20 (Tier A-HE) when one is available. The two modes produce different subcarrier counts (52 vs 242 K_active) and different pilot patterns. They are **not interchangeable baselines**.
**Decision: one baseline file per PHY tier per link. Tier change invalidates the existing baseline.**
When the aggregator receives a frame from a C6 link and `CsiMetadata.bandwidth_mhz` and the PPDU type (from ADR-110's `csi_collector.c` frame byte 1819) indicate a tier different from the currently loaded baseline, `BaselineCalibration::subtract()` returns `CalibrationError::TierMismatch { expected, actual }`. The aggregator logs this at WARN level and falls back to no-baseline-subtraction mode for that link until the operator recalibrates.
The rationale for invalidation rather than interpolation: interpolating a 52-subcarrier baseline to 242 subcarriers (or vice versa) requires assumptions about per-subcarrier correlation that are not validated in this codebase. The hardware-norm resample path (`hardware_norm.rs`) uses Catmull-Rom for subcarrier grid normalisation, but that normalises across hardware types at the same tier — not across tier transitions on the same device.
In practice, tier transitions are rare: they occur when the AP is rebooted (dropping 802.11ax), when the C6 moves out of 11ax AP range, or when the operator changes the AP. The operator is expected to recalibrate after a tier change.
### 2.8 Fleet-Wide Simultaneous Capture
The operator can calibrate the full multistatic array with a single command:
```
wifi-densepose calibrate --all-nodes --duration 30 --output baselines/
```
This issues a simultaneous capture barrier across all configured nodes using the 802.15.4 epoch from ADR-110 (`c6_timesync_get_epoch_us()` on C6 nodes; local clock interpolated to 802.15.4 domain for S3 nodes).
**Protocol skeleton:**
1. The CLI sends a `CalibrateStart { start_epoch_us, duration_ms }` UDP control packet to each node's UDP control port (default 5006). Nodes begin accumulating frames from `start_epoch_us` for `duration_ms` milliseconds, tagging each with the 802.15.4 epoch. S3 nodes use their local hardware timer; C6 nodes use `c6_timesync_get_epoch_us()`.
2. The aggregator simultaneously opens a UDP receive socket per node and applies `CalibrationRecorder::record()` to each incoming frame. Frame ordering within the window is irrelevant because Welford statistics are commutative.
3. At `start_epoch_us + duration_ms + 500 ms` (500 ms guard for last-frame arrival), the CLI finalises each `CalibrationRecorder`, serialises each `BaselineCalibration` to `baselines/<device_id>.toml`, and optionally pushes NVS binary to each device.
4. A summary JSON `baselines/summary.json` lists each node, tier, frame count, and the mean `drift_score` relative to any previous baseline, allowing the operator to spot nodes that were occupied during calibration.
Fleet capture requires that all C6 nodes are associated (not in AP setup mode). Seed nodes that have not yet been provisioned (`seed-2` through `seed-5` from CLAUDE.local.md fleet table) are skipped with a warning. `cognitum-seed-1` is the only fully provisioned seed as of this writing.
The 802.15.4 timesync barrier is optional for calibration accuracy (Welford statistics are order-independent) but is required when the calibration baseline will also be used to compute the inter-node phase alignment for ADR-042's CHCI path.
### 2.9 Proposed Rust API
The new module is `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs`, exported from `ruvsense/mod.rs` as `pub mod calibration`.
```rust
use num_complex::Complex32;
use wifi_densepose_core::types::CsiFrame;
// ---- Error type -------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum CalibrationError {
#[error("Tier mismatch: baseline is {expected}, frame is {actual}")]
TierMismatch { expected: String, actual: String },
#[error("Subcarrier count mismatch: baseline has {expected}, frame has {got}")]
SubcarrierMismatch { expected: usize, got: usize },
#[error("Stream count mismatch: baseline has {expected}, frame has {got}")]
StreamMismatch { expected: usize, got: usize },
#[error("Insufficient frames: need at least {needed}, recorded {got}")]
InsufficientFrames { needed: usize, got: usize },
#[error("Baseline not yet finalised (still recording)")]
NotFinalised,
#[error("Baseline data corrupted: {0}")]
Corrupt(String),
#[error("Phase precondition violated: frame phase has not been sanitized")]
UnsanitizedPhase,
#[error("TOML serialisation error: {0}")]
TomlSerialise(String),
#[error("TOML deserialisation error: {0}")]
TomlDeserialise(String),
}
// ---- Configuration ----------------------------------------------------------
#[derive(Debug, Clone)]
pub struct CalibrationConfig {
/// Number of frames to accumulate before finalising. Default: 600 (30 s × 20 Hz).
pub target_frames: usize,
/// Minimum frames accepted by `finalize()`. Default: 200.
pub min_frames: usize,
/// Staleness window in frames. Default: 300.
pub drift_window_frames: usize,
/// Drift score threshold for BaselineDrift event. Default: 4.0.
pub drift_threshold: f32,
/// Duration (frames) above drift_threshold before emitting BaselineDrift. Default: 900.
pub drift_confirm_frames: usize,
}
impl Default for CalibrationConfig {
fn default() -> Self {
Self {
target_frames: 600,
min_frames: 200,
drift_window_frames: 300,
drift_threshold: 4.0,
drift_confirm_frames: 900,
}
}
}
// ---- Recorder ---------------------------------------------------------------
/// Accumulates CSI frames from an empty room to build a baseline.
///
/// # Phase precondition
///
/// The caller is responsible for passing frames whose phase has been
/// processed by `PhaseSanitizer` and `phase_align.rs` before calling
/// `record()`. Unsanitized phase will be detected by a heuristic
/// (per-subcarrier phase variance > 10 rad²) and rejected with
/// `CalibrationError::UnsanitizedPhase`.
///
/// # Concurrency
///
/// `CalibrationRecorder` requires `&mut self` for `record()`. It is not
/// `Sync`. Wrap in a `Mutex` if shared across threads.
pub struct CalibrationRecorder {
config: CalibrationConfig,
frame_count: usize,
n_streams: usize,
n_subcarriers: usize,
// Amplitude Welford accumulators: [stream][subcarrier]
amp_mean: Vec<Vec<f64>>,
amp_m2: Vec<Vec<f64>>,
// Circular phase accumulators: [stream][subcarrier]
phase_sin_sum: Vec<Vec<f64>>,
phase_cos_sum: Vec<Vec<f64>>,
}
impl CalibrationRecorder {
/// Create a new recorder. The first `record()` call sets the
/// expected subcarrier and stream counts.
pub fn new(config: CalibrationConfig) -> Self;
/// Accept one sanitized CSI frame into the running statistics.
///
/// Returns the current frame count after this update.
pub fn record(&mut self, frame: &CsiFrame) -> Result<usize, CalibrationError>;
/// Returns `true` if `target_frames` have been accumulated.
pub fn is_complete(&self) -> bool;
/// Returns the current frame count.
pub fn frame_count(&self) -> usize;
/// Finalise the baseline from accumulated statistics.
///
/// Consumes `self`. Returns an error if fewer than `min_frames` were
/// recorded.
pub fn finalize(self) -> Result<BaselineCalibration, CalibrationError>;
}
// ---- Baseline ---------------------------------------------------------------
/// A fully finalised empty-room baseline.
///
/// Stores per-subcarrier amplitude mean/variance and circular phase
/// mean/variance for each spatial stream. Immutable after construction.
/// `Clone` is cheap (Vec of f32).
#[derive(Debug, Clone)]
pub struct BaselineCalibration {
/// Device ID from which this baseline was captured.
pub device_id: String,
/// UTC timestamp of calibration (Unix seconds).
pub captured_at_unix_s: i64,
/// PHY tier string: "A", "A-HE", or "B".
pub tier: String,
/// Bandwidth in MHz.
pub bandwidth_mhz: u16,
/// Number of spatial streams.
pub n_streams: usize,
/// Number of active (non-pilot, non-null) subcarriers.
pub n_subcarriers: usize,
/// Total frames used to build this baseline.
pub frame_count: usize,
// Per-stream, per-subcarrier statistics (stream-major layout).
pub amp_mean: Vec<Vec<f32>>,
pub amp_variance: Vec<Vec<f32>>,
pub phase_cos_mean: Vec<Vec<f32>>,
pub phase_sin_mean: Vec<Vec<f32>>,
/// Circular variance ∈ [0, 1]: 0 = concentrated, 1 = dispersed.
pub phase_circular_variance: Vec<Vec<f32>>,
}
impl BaselineCalibration {
/// Compute a deviation score for one live frame against this baseline.
///
/// Returns `CalibrationError::TierMismatch` if the frame's bandwidth
/// or subcarrier count do not match the baseline.
pub fn deviation(&self, frame: &CsiFrame) -> Result<CalibrationDeviationScore, CalibrationError>;
/// Subtract the baseline amplitude mean from `frame.data` (in-place,
/// stream-by-stream, subcarrier-by-subcarrier).
///
/// After subtraction, `frame.data[s][k]` represents the perturbation
/// from the static environment, suitable for motion detection and CIR
/// estimation.
///
/// Phase is not modified by subtraction; downstream callers that need
/// phase-coherent baseline removal should use
/// `reference_csi_vector()` to set `CirEstimator::set_reference_csi()`.
pub fn subtract(&self, frame: &mut CsiFrame) -> Result<(), CalibrationError>;
/// Returns the expected complex CSI vector for the static environment
/// (amplitude mean × exp(j × circular_mean_phase)), suitable for passing
/// to `CirEstimator::set_reference_csi()`.
///
/// Returns one vector per spatial stream: `Vec<Vec<Complex32>>`.
pub fn reference_csi_vector(&self) -> Vec<Vec<Complex32>>;
/// Serialise to TOML bytes.
pub fn to_toml(&self) -> Result<Vec<u8>, CalibrationError>;
/// Deserialise from TOML bytes.
pub fn from_toml(buf: &[u8]) -> Result<Self, CalibrationError>;
/// Serialise to compact NVS binary (see §2.4 for format).
pub fn to_nvs_bytes(&self) -> Vec<u8>;
/// Deserialise from NVS binary.
pub fn from_nvs_bytes(buf: &[u8]) -> Result<Self, CalibrationError>;
}
// ---- Deviation score --------------------------------------------------------
/// Per-frame deviation from the static baseline.
#[derive(Debug, Clone)]
pub struct CalibrationDeviationScore {
/// Per-subcarrier amplitude z-score: (|H[k]| mean[k]) / std[k].
/// Positive = higher than baseline, negative = lower.
pub amplitude_z: Vec<Vec<f32>>,
/// RMS amplitude z-score across all subcarriers and streams.
/// Motion threshold: > 3.0 = likely occupied frame.
pub rms_amplitude_z: f32,
/// Per-subcarrier circular phase deviation in radians: |φ_live[k] φ_baseline[k]|.
pub phase_deviation_rad: Vec<Vec<f32>>,
/// Mean circular phase deviation across all subcarriers.
pub mean_phase_deviation_rad: f32,
/// Instantaneous drift score (see §2.5 for definition).
pub drift_score: f32,
/// Whether the drift_score sustained above threshold (staleness flag).
pub baseline_stale: bool,
}
```
**Design decisions within the API:**
- `record()` takes `&mut self`, not `&self` with interior mutability. The recording path is inherently single-threaded (one receiver loop per link). Interior mutability would add `Mutex` overhead for no benefit.
- `subtract()` takes `&mut CsiFrame` and modifies `frame.data` in place. It does not modify `frame.amplitude` or `frame.phase` — callers that read `frame.amplitude` downstream are expected to call `CsiFrame::recompute_amplitude_phase()` (a new method to be added to `wifi_densepose_core::types::CsiFrame`) or to use `frame.data` directly.
- `to_nvs_bytes()` / `from_nvs_bytes()` are fallible via `panic!` for magic mismatch but return `Result` for truncation. This matches the pattern in `csi.rs::parse_esp32_vitals()`.
- `BaselineCalibration` is `Clone` because the CLI needs to hold one copy while pushing NVS and another while writing TOML.
### 2.10 CLI Surface
The `wifi-densepose calibrate` subcommand is added to `wifi-densepose-cli/src/lib.rs` as a new `Commands::Calibrate(CalibrateCommand)` variant.
```
wifi-densepose calibrate [OPTIONS]
OPTIONS:
--port <PORT> Serial port or UDP address of the ESP32 node
(e.g., COM9 on Windows, /dev/ttyS8 on WSL).
For fleet mode, omit and use --all-nodes.
--duration <SECS> Capture duration in seconds [default: 30]
--output <PATH> Path to write the TOML baseline file
[default: baseline_<device_id>.toml]
--tier <TIER> Expected PHY tier: A | A-HE | B
[default: detected from first frame]
--push-nvs After capturing, serialise to NVS binary and
write to device flash via the provisioning tool.
--all-nodes Fleet mode: capture from all configured nodes
simultaneously using 802.15.4 epoch sync.
--server <ADDR> Aggregator address for --all-nodes mode
[default: 127.0.0.1:5006]
--min-frames <N> Minimum frames before finalise() is accepted
[default: 200]
--drift-check After capturing, compare against an existing
baseline at --output and print the drift score.
```
**Defaults justified:**
- `--duration 30`: justified in §2.3.
- `--output baseline_<device_id>.toml`: the device ID is embedded in the first received `CsiMetadata.device_id`. The operator does not need to specify it for single-node mode.
- `--tier detected`: the first frame's `bandwidth_mhz` and PPDU type (for C6) determine the tier. The flag exists for cases where the operator wants to force Tier A even if the device is capable of Tier A-HE (e.g., to pre-generate a fallback baseline).
### 2.11 Downstream Consumers
| Consumer | What it receives | Change required |
|----------|-----------------|-----------------|
| `ruvsense/multistatic.rs` | Baseline-subtracted `CsiFrame.data` via `BaselineCalibration::subtract()` | `MultistaticConfig` gains a `baseline: Option<Arc<BaselineCalibration>>` field; `process_cycle()` calls `subtract()` on each node's latest frame before passing to the attention gate |
| `ruvsense/cir.rs` (ADR-134) | Static-environment reference via `BaselineCalibration::reference_csi_vector()` passed to `CirEstimator::set_reference_csi()` | No API change to `CirEstimator`; the aggregator setup path calls `set_reference_csi()` at startup if a baseline file is present |
| `motion.rs` | `CalibrationDeviationScore.rms_amplitude_z` as a primary motion signal | Replaces the existing amplitude variance threshold with a baseline-relative z-score; threshold changes from an absolute amplitude variance to `rms_amplitude_z > 3.0` |
| `features.rs` | `CalibrationDeviationScore` fields available as additional features | `SignalFeatures` gains `baseline_rms_z: Option<f32>` and `baseline_drift_score: Option<f32>` fields; `None` when no baseline is loaded |
| `wifi-densepose-vitals` | No change | Breathing and heart-rate detection filters operate in the 0.152.0 Hz band; slow baseline drift is below 0.001 Hz and is already filtered. The vital-sign pipeline benefits marginally from baseline subtraction at the amplitude level but this is not required for the current implementation. |
| `ruvsense/field_model.rs` | Calibration frames passed through `CalibrationRecorder` before SVD decomposition | The field model now takes baseline-subtracted frames as input. The Welford mean accumulator in `field_model.rs::FieldModelBuilder` is superseded for the per-subcarrier-mean step — the calibration module handles it. `FieldModelBuilder` ingests `BaselineCalibration` directly to skip its internal mean step. |
**CIR interaction detail**: ADR-134's §2.5 specifies that the `CirEstimator` applies conjugate multiplication using `reference_csi` for single-antenna fallback. `BaselineCalibration::reference_csi_vector()` produces the correct complex reference vector: `amp_mean[s][k] × exp(j × atan2(phase_sin_mean, phase_cos_mean))`. This is more accurate than the previously described approach of averaging quiescent frames on the fly, because the baseline uses 600 frames (30 s) rather than a small number of recent frames, reducing the noise on the reference vector by a factor of ~√600/√10 ≈ 7.7× compared to a 0.5 s on-the-fly average.
### 2.12 Test Plan
**Tier 1 — Deterministic synthetic stationary channel (unit test)**
Generate a synthetic CSI frame representing a static 2-tap channel (direct path + one wall reflection, identical parameters to the ADR-134 Tier 1 test): `H[k] = α₁·e^{-j2πkΔf·τ₁} + α₂·e^{-j2πkΔf·τ₂}`. Add zero-mean Gaussian amplitude noise (σ = 0.02 × |α₁|) and constant phase offset δ = π/8 per subcarrier (simulating LO drift already corrected by `phase_align.rs`). Feed 600 copies of this frame to `CalibrationRecorder`. Call `finalize()`. Assert:
- `baseline.amp_mean[0][k]` is within 2σ/√600 of `|α₁·e^{-j2πkΔf·τ₁} + α₂·e^{-j2πkΔf·τ₂}|` for all k.
- `baseline.phase_circular_variance[0][k]` < 0.005 (highly concentrated — noise σ = 0.02 does not produce meaningful phase variance).
- `CalibrationDeviationScore.rms_amplitude_z` for the same static frame is < 1.0 (not flagged as motion).
**Tier 2 — Perturbation detection (unit test)**
Same baseline. Inject one frame with amplitude perturbed at 10 random subcarriers by +3σ (simulating a person present). Assert `rms_amplitude_z > 3.0` and that the perturbed subcarrier indices are among the top-10 `|amplitude_z|` entries in `CalibrationDeviationScore`.
**Tier 3 — TOML round-trip (unit test)**
Serialise the Tier 1 baseline to `to_toml()`, deserialise with `from_toml()`, assert field-level equality to within f32 precision.
**Tier 4 — NVS binary round-trip (unit test)**
Same as Tier 3 using `to_nvs_bytes()` / `from_nvs_bytes()`. Assert magic word `0xCA11BA5E` at offset 0 and schema version = 1.
**Tier 5 — Stale-baseline detection (unit test)**
Start with the Tier 1 baseline. Feed 900 frames with amplitude uniformly increased by `5σ` at all subcarriers (simulating furniture moved). Assert that `CalibrationDeviationScore.baseline_stale` becomes `true` at or before frame 900.
**Tier 6 — Real hardware capture (integration test, COM9)**
Using the ESP32-S3 on COM9 (ruvzen), capture a 30-second baseline in a static empty room. Then capture 200 live frames in the same room (still empty). Assert:
- `CalibrationDeviationScore.rms_amplitude_z` < 2.0 for all 200 frames.
- `CalibrationDeviationScore.drift_score` < 1.0.
- Walking through the room during the live phase: at least 10 consecutive frames show `rms_amplitude_z > 3.0`.
This test is gated behind `#[cfg(feature = "hardware-test")]` and is not run in CI.
**Tier 7 — Determinism proof (CI-compatible)**
To extend the ADR-028 witness proof chain: using the same synthetic 600-frame stream from Tier 1, compute the SHA-256 of `to_nvs_bytes()` output. Record this hash in `archive/v1/data/proof/expected_features.sha256` under the key `calibration_nvs_baseline_v1`. The `verify.py` extension function `calibration_baseline_check()` regenerates the same 600-frame synthetic stream, runs `CalibrationRecorder`, serialises, and asserts the hash matches. This makes the calibration algorithm deterministic end-to-end, consistent with the ADR-028 proof methodology.
### 2.13 Witness / Proof
Per ADR-028, the following rows are added to `docs/WITNESS-LOG-028.md`:
| Row | Capability | Evidence | Hash |
|-----|-----------|----------|------|
| W-36 | CalibrationRecorder Welford correctness (synthetic 600-frame stationary) | `cargo test calibration::tests::stationary_baseline -- --nocapture` | SHA-256 of amp_mean output |
| W-37 | BaselineCalibration NVS binary round-trip | `cargo test calibration::tests::nvs_round_trip` passes | SHA-256 of serialised bytes |
| W-38 | Drift detection fires within 900 frames (synthetic 5σ perturbation) | `cargo test calibration::tests::stale_detection` | SHA-256 of test binary |
`source-hashes.txt` in the witness bundle gains `SHA-256(ruvsense/calibration.rs)`.
---
## 3. Consequences
### 3.1 Positive
- **Motion detector reliability**: replacing absolute amplitude variance thresholds with baseline-relative z-scores reduces false positives from HVAC and thermal drift. The `rms_amplitude_z > 3.0` threshold is scale-invariant across hardware tiers.
- **CIR quality improvement**: `CirEstimator` receives a 600-frame static reference rather than a 10-frame rolling average. Ghost taps near τ=0 from the dominant static path are suppressed earlier in the ISTA solve, freeing regularisation budget for body-perturbed taps. Effective `dominant_tap_ratio` dynamic range increases by the ratio `√600/√10 ≈ 7.7×` in reference SNR — the ISTA warm-start quality directly improves.
- **Multi-node amplitude comparability**: after baseline subtraction, each link's `CsiFrame.data` is zero-centred on the static environment. Multistatic attention weighting can use amplitude magnitude directly without per-link gain normalisation.
- **ADR-030 field model simplification**: `FieldModelBuilder` no longer needs its own per-subcarrier Welford mean pass; it consumes the finished `BaselineCalibration` and proceeds directly to SVD. Duplicate code is removed.
- **Fleet-wide recalibration is one command**: the `--all-nodes` flag with 802.15.4 epoch sync enables house-wide calibration in a single 30-second window, closing the operational gap for multi-room deployments.
### 3.2 Negative
- **Calibration ceremony required at install**: operators must capture a 30-second empty-room baseline before the system produces reliable motion scores. Systems shipped without a baseline fall back to uncalibrated mode (no `subtract()` call, absolute variance thresholds). This is not a regression — the current code has no baseline — but it is a new operational step.
- **Baseline invalidated by furniture changes**: any significant room change (moved sofa, new TV) requires recalibration. The `drift_score > 4.0` alarm notifies the operator, but does not self-heal.
- **Two NVS keys for Tier A-HE**: the 4,854-byte HE20 baseline does not fit in a single default NVS key. The two-key scheme (`b_0_amp` / `b_0_phase`) adds complexity to the device-side NVS reader if that is ever implemented. For the current scope (host-side reader only), this is not a practical problem.
- **New `recompute_amplitude_phase()` method needed on `CsiFrame`**: `subtract()` modifies `frame.data` but `frame.amplitude` and `frame.phase` become stale. The method is simple (`amplitude = data.mapv(|c| c.norm()); phase = data.mapv(|c| c.arg())`) but it adds one public API surface to `wifi-densepose-core`.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Operator captures baseline with person present | Medium (single-person household) | Silently corrupted baseline; baseline-subtracted frames look like a "hole" where the person was | The CLI prints real-time `rms_amplitude_z` during capture; high z-scores (>2.0) during capture trigger a WARNING banner. Post-capture, `--drift-check` compares against a previous baseline to flag anomalies |
| Tier change (HT20 → HE20) invalidates baseline mid-session | Medium (C6 nodes near AP boundary) | `TierMismatch` error at runtime; system falls to uncalibrated mode | `TierMismatch` logged at WARN; operator notified via WebSocket event; auto-recalibration configurable |
| Phase circular variance underestimated for subcarriers with multimodal phase distribution (two equally strong reflected paths at ±π/2) | Low (requires geometric coincidence) | `phase_circular_variance` near 1.0; phase reference from `reference_csi_vector()` is noisy for those subcarriers | `phase_circular_variance > 0.5` per-subcarrier is flagged in the TOML with a comment; CIR estimator down-weights the corresponding rows in Φ by masking them (same mechanism as pilot exclusion in §2.4 of ADR-134) |
| ESP32-S3 auto-gain-control shifts between baseline capture and runtime | Low (AGC settles within 5 frames) | Amplitude mean baseline offset; all `amp_z` scores biased | AGC-locked mode (`esp_wifi_set_csi_config` with `rx_chain` pin) is available in firmware v0.7.0; recommend enabling for dedicated sensing nodes via `provision.py --pin-agc` flag |
---
## 4. Rationale and Comparison to Alternative Designs
### 4.1 Why Not "Skip Calibration, Rely on Differential Signals Only"
The dominant approach in academic WiFi sensing papers (20182022) is to use differential or conjugate-product CSI — dividing each frame by a running average of recent frames — rather than an explicit empty-room baseline. This avoids the calibration ceremony at the cost of three concrete problems in this codebase:
- **Differential signals accumulate bias under environmental loading**. A piece of furniture that moves over 10 minutes produces a slow CSI drift that appears as a 10-minute "motion" event in a conjugate-product system with a 1-second window, or becomes invisible in a system with a 1-hour window. There is no window size that eliminates environmental loading without also suppressing slow human motion (a resting person's micromotion is < 0.01 Hz). The IEEE Transactions 2024 paper "Experimental Evaluation of Long-Term Concept Drift and Its Mitigation in WiFi CSI Sensing" (IEEE Xplore document 10975920) demonstrates that concept drift from environmental factors causes systematic accuracy degradation over hours to days, which no differential window eliminates.
- **Differential signals cannot be compared across nodes**. Multi-node coherence scoring requires a shared zero-mean reference. If each node has its own differential reference (its own recent history), drift rates differ across nodes and coherence scores are not interpretable.
- **`CirEstimator` requires an absolute complex reference**. ADR-134 §2.5 describes conjugate multiplication: `H[k] * conj(H_ref[k])`. The `H_ref` in that context must be a stable, long-term static reference to avoid ghost taps — not a 0.5-second recent average, which still contains transient motion in active households.
### 4.2 Why Not "Calibrate at Factory, Ship Coefficients"
Per-device factory calibration would require: (a) a known-geometry, electromagnetically clean test chamber per device, and (b) the firmware to store calibration at production time. ESP32 hardware calibration (PHY RF calibration, `esp_phy_store_cal_data_to_nvs`) is a different concept — it corrects transmit chain IQ imbalance, not the per-room environmental channel. Room geometry is not known at factory. Per-room baseline is the only physically meaningful calibration for ambient sensing applications.
### 4.3 Why Not "Use a Neural Network-Learned Baseline"
Neural baseline subtraction (training a denoising autoencoder on empty-room CSI) has been proposed in several transfer learning papers. The objection from ADR-134 §2.2 for neural CIR applies equally here: there is no paired empty-room dataset for this codebase, and the feature distribution of "empty room" is inherently location-specific. A neural baseline trained in one room may produce negative subtraction values in a different room's frequency-selective geometry. The per-subcarrier Welford mean is a degenerate (optimal) estimator under Gaussian noise: it requires no training data, has a closed-form convergence guarantee, and generalises perfectly to any room because it operates on that room's own captures.
### 4.4 Why Welford Over Exponential Moving Average (EMA)
EMA (`mean_new = α × x + (1 α) × mean_old`) is simpler to implement and provides continuous adaptation but has two drawbacks for a calibration baseline:
- **α is a free parameter** with no principled setting. Too small an α causes slow adaptation (baseline lags environmental loading); too large adapts immediately to occupancy (person present → person absorbed into baseline → false negative forever).
- **EMA variance** requires a separate squared-error accumulator and is less numerically stable than Welford at finite precision.
Welford provides the exact sample variance in a single pass with no free parameters and no numerical issues. The existing `WelfordStats` in `field_model.rs` is reused directly. The only EMA advantage (continuous adaptation without a discrete recalibrate event) is a liability here: the baseline must be stable while the room is occupied and only updated on explicit operator command.
---
## 5. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-014 (SOTA Signal Processing) | **Extended**: calibration baseline subtraction becomes the zeroth stage of the signal pipeline, before any feature extraction |
| ADR-028 (ESP32 Capability Audit) | **Witness extended**: three new rows W-36 through W-38 added to `WITNESS-LOG-028.md`; calibration NVS binary hash added to `source-hashes.txt` |
| ADR-029 (RuvSense Multistatic) | **Enables**: `MultistaticConfig.baseline` field unblocks amplitude-comparable multi-node coherence scoring |
| ADR-030 (Persistent Field Model) | **Simplified**: `FieldModelBuilder` no longer computes its own per-subcarrier Welford mean; it ingests `BaselineCalibration` as input |
| ADR-110 (ESP32-C6 Firmware Extension) | **Substrate**: 802.15.4 epoch from `c6_timesync_get_epoch_us()` enables fleet-wide simultaneous capture barrier (§2.8); PPDU type (frame bytes 1819) enables automatic tier detection for C6 nodes |
| ADR-115 (Home Assistant Integration) | **Consumer**: `CalibrationDeviationScore.drift_score` and `baseline_stale` are published on the WebSocket stream and picked up by the HA MQTT publisher as `sensor.wifi_baseline_drift` and `binary_sensor.wifi_baseline_stale` |
| ADR-134 (First-Class CIR Support) | **Prerequisite improved**: `BaselineCalibration::reference_csi_vector()` replaces the on-the-fly quiescent-frame average described in ADR-134 §2.5; CIR ghost taps from the static environment are suppressed more reliably |
---
## 6. References
### Production Code
- `v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs``WelfordStats` struct reused; `FieldModelBuilder` to be simplified
- `v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs``CirEstimator::set_reference_csi()` call site
- `v2/crates/wifi-densepose-signal/src/phase_sanitizer.rs` — runs before calibration recording
- `v2/crates/wifi-densepose-signal/src/ruvsense/phase_align.rs` — runs before calibration recording
- `v2/crates/wifi-densepose-signal/src/hardware_norm.rs` — cross-hardware amplitude normalisation; operates before baseline for `canonical_grid` resampling, after baseline for `z-score` normalisation
- `v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs` — primary consumer of `BaselineCalibration::subtract()`
- `v2/crates/wifi-densepose-signal/src/motion.rs` — secondary consumer of `CalibrationDeviationScore.rms_amplitude_z`
- `v2/crates/wifi-densepose-cli/src/lib.rs``Commands::Calibrate` variant to be added
- `v2/crates/wifi-densepose-sensing-server/src/cli.rs``Args` struct for sensing-server CLI context
- `firmware/esp32-csi-node/provision.py` — provisioning tool; `--push-nvs` integration point
- `archive/v1/data/proof/verify.py` — deterministic proof chain; `calibration_baseline_check()` extension
- `archive/v1/data/proof/expected_features.sha256` — hash entry `calibration_nvs_baseline_v1` to be added
### External Papers
- Welford, B.P. (1962). "Note on a Method for Calculating Corrected Sums of Squares and Products." *Technometrics*, 4(3), 419420. — Online mean/variance algorithm used for both amplitude and (via sin/cos projection) phase statistics.
- Mardia, K.V. & Jupp, P.E. (2000). *Directional Statistics*. Wiley. Ch. 23. — Circular variance estimator `1 R̄` and its standard error; von Mises maximum-likelihood estimator for the concentration parameter.
- Ma, Y. et al. (2023). "Optimal Preprocessing of WiFi CSI for Sensing Applications." *IEEE Transactions on Wireless Communications* (published 2024, arXiv:2307.12126). — Derives the theoretically optimal gain and phase error correction for commodity WiFi CSI; confirms that a per-subcarrier amplitude model reduces sensing noise by 40% over no-correction baseline. Validates the amplitude-mean-subtraction approach chosen here.
- Kong, R. & Chen, H. (2025). "Domino: Dominant Path-based Compensation for Hardware Impairments in Modern WiFi Sensing." arXiv:2509.13807. IEEE ICASSP 2026. — Shows that operating on the dominant static CIR path as a reference achieves >2× accuracy over existing compensation methods for respiration monitoring. Validates the principle that a stable static reference (this ADR's baseline) materially improves sensing over no-reference methods.
- IEEE Xplore document 10975920 (2025). "Experimental Evaluation of Long-Term Concept Drift and Its Mitigation in WiFi CSI Sensing." — Demonstrates that environmental loading causes accuracy degradation over hours/days in CSI sensing systems that rely on differential signals only; motivates the explicit operator-initiated recalibration model chosen in §2.6.
@@ -0,0 +1,394 @@
# ADR-136: RuView Rust Streaming Engine: Architecture, Frame Contracts, and Stage Abstraction
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-core` (`types.rs`: `CsiFrame`/`CsiMetadata`); `wifi-densepose-signal/src/ruvsense/mod.rs` (`RuvSensePipeline`, six-stage flow); `v2/Cargo.toml` (workspace topology) |
| **Relates to** | ADR-028 (ESP32 Capability Audit — witness/deterministic proof), ADR-031 (RuView Sensing-First RF Mode), ADR-119 (BFLD Frame Format and Wire Protocol — LE determinism + reserved-flag forward-compat), ADR-127 (HomeCore State Machine), ADR-134 (First-Class CIR Support), ADR-135 (Empty-Room Baseline Calibration), ADR-137 (Fusion Quality Scoring), ADR-138 (LinkGroup / ArrayCoordinator), ADR-140 (Semantic State Record), ADR-145 (Ablation Eval Harness) |
---
## 1. Context
This is the **foundational umbrella ADR** for the RuView streaming engine. It does not introduce a new algorithm or sensing capability. Instead it makes three load-bearing decisions that every downstream ADR in the 136146 series depends on: (a) what the streaming engine *is* in terms of the existing crate workspace, (b) the unified typed frame contracts that flow between stages, and (c) the trait surface and determinism guarantee that lets stages compose and be replayed deterministically.
A future contributor reading the spec for "the RuView streaming engine" expects to find a crate named `ruview_engine` or a set of `ruview_*` crates. They will not find one. This ADR is the source-of-truth mapping that explains why, and what the spec's role names actually point at.
### 1.1 The Gap
Three concrete gaps exist in the codebase as of 2026-05-28.
**Gap 1 — No documented role→crate mapping.** The streaming-engine spec organises the system into ten roles: ingest, signal, fusion, world, models, privacy, store, api, eval, observe. The workspace under `v2/crates/` already contains 35 crates that fulfil these roles, but no document maps the spec vocabulary onto the real crates. `ls v2/crates/` returns `wifi-densepose-core`, `wifi-densepose-signal`, `wifi-densepose-bfld`, `homecore`, `homecore-api`, `homecore-automation`, `homecore-assist`, `homecore-recorder`, `cog-pose-estimation`, `cog-person-count`, `cog-ha-matter`, and others — names that predate the streaming-engine spec by months of commit history. A contributor cannot tell that `wifi-densepose-bfld` *is* the privacy/beamforming role or that `homecore` *is* the world/state role without reading source. This ADR fixes the mapping in writing.
**Gap 2 — No unified complex-sample or frame-metadata contract across stages.** The pipeline carries complex CSI in at least two distinct representations:
- `wifi-densepose-core/src/types.rs:370``CsiFrame.data: Array2<Complex64>` (f64 complex, `[spatial_streams, subcarriers]`), with `#[cfg_attr(feature = "serde", serde(skip))]` on `data`, `amplitude`, and `phase` (lines 369, 372, 375). **The complex payload is not serialised at all today** — only `CsiMetadata` survives a serde round-trip.
- `wifi-densepose-signal/src/ruvsense/cir.rs:27` — uses `num_complex::Complex32` (f32 complex) for CIR taps and the sub-DFT sensing matrix Φ.
There is no `ComplexSample` newtype unifying these, and no byte-order guarantee on the complex payload because it is `serde(skip)`-ped. ADR-119 already solved the same problem for `BfldFrame` (little-endian, `#[repr(C, packed)]`, BLAKE3 witness — see `wifi-densepose-bfld/src/frame.rs` and `signature_hasher.rs`), but that determinism contract is scoped to one frame type, not the whole pipeline.
`CsiMetadata` (`types.rs:311`) carries `timestamp`, `device_id`, `frequency_band`, `channel`, `bandwidth_mhz`, `antenna_config`, `rssi_dbm`, `noise_floor_dbm`, `sequence_number`. It carries **no `calibration_id`** (so a frame cannot be traced to the ADR-135 baseline that was subtracted from it) and **no `model_id` / `model_version`** (so a downstream `PoseEstimate` cannot be traced back to the inference context — `PoseEstimate.model_version: String` at `types.rs:964` is a free-form string set at the *end* of the pipeline, not propagated through frames).
**Gap 3 — No `Stage<I,O>` abstraction; pipeline stages are concrete and non-uniform.** `wifi-densepose-signal/src/ruvsense/mod.rs:9-23` documents six stages (multiband → phase_align → multistatic → coherence → coherence_gate → pose_tracker), but `RuvSensePipeline` (`mod.rs:184`) holds them as concrete fields (`phase_aligner: PhaseAligner`, `coherence_state: CoherenceState`, `gate_policy: GatePolicy`) and exposes only a `tick()` method (`mod.rs:232`) that increments a counter. There is no common `process(&self, I) -> Result<O>` trait, no `Versioned` trait, and no `QualityScored` trait. Each stage has a bespoke signature, so ADR-137 (quality scoring), ADR-138 (LinkGroup), and ADR-145 (ablation harness) cannot compose or swap stages without per-stage glue.
### 1.2 What This ADR Is and Is Not
It **is** a contract document: it pins down `ComplexSample`, `FrameMeta`, the three traits, the determinism guarantee, and the role→crate map. It establishes the vocabulary the 137146 ADRs build on.
It is **not** a rewrite. It explicitly rejects renaming the workspace to `ruview_*` (§2.1). It adds fields to `CsiMetadata` and traits to the pipeline; it does not relayout `CsiFrame.data` or change the `ndarray` storage.
### 1.3 Pipeline Position
```
[ingest] [signal] [fusion] [world] [models] [privacy] [api]
ESP32/Pi → RuvSensePipeline six stages → fuse → state → infer → gate → publish
│ │ │ │ │ │ │
│ multiband → phase_align → calibration(135) │ homecore cog-* bfld homecore-api
│ → cir(134) → multistatic → coherence │
└─ CsiFrame{ data, FrameMeta{calibration_id, model_id} } flows through every stage as Stage<I,O>
```
Every box above is an existing crate. The novelty of this ADR is the *contract on the arrow*: a single `CsiFrame` whose `FrameMeta` ties each sample to its calibration (ADR-135), its model context (ADR-146), and — downstream — its privacy decision (ADR-119/141), satisfying the project rule that every semantic state traces to signal evidence + model version + calibration version + privacy decision.
---
## 2. Decision
### 2.1 Adopt the Existing Workspace As the Streaming Engine — Reject `ruview_*` Rename
The streaming engine **is** the existing 35-crate `v2/` workspace. The spec's ten roles map 1:1 onto current crates:
| Spec role | Crate(s) | Evidence |
|-----------|----------|----------|
| **ingest** | `wifi-densepose-sensing-server`, `wifi-densepose-hardware`, `wifi-densepose-wifiscan` | Axum sensing server + ESP32 aggregator/TDM |
| **signal** | `wifi-densepose-signal` (incl. `ruvsense/`) | `RuvSensePipeline` six stages; `cir.rs`, `calibration.rs` |
| **fusion** | `wifi-densepose-signal/src/ruvsense/multistatic.rs`, `wifi-densepose-ruvector/src/viewpoint/` | `FusedSensingFrame`, cross-viewpoint attention (ADR-137) |
| **world** | `homecore` (`state.rs`, `entity.rs`, `registry.rs`, `bus.rs`), `wifi-densepose-geo` | HomeCore state machine (ADR-127); WorldGraph target (ADR-139) |
| **models** | `cog-pose-estimation`, `cog-person-count`, `wifi-densepose-nn`, `wifi-densepose-train` | inference + training |
| **privacy** | `wifi-densepose-bfld` (`privacy_gate.rs`, `sink.rs`, `signature_hasher.rs`) | byte-level privacy classes (ADR-119/141) |
| **store** | `homecore-recorder` | trajectory/event recording |
| **api** | `homecore-api`, `homecore-server`, `cog-ha-matter`, `homecore-hap` | REST/HA/Matter/HomeKit surfaces |
| **eval** | (new: ablation harness lands in `wifi-densepose-train` test crate per ADR-145) | ADR-145 |
| **observe** | `homecore-automation`, `homecore-assist` | automation + assistant bridge (ADR-140) |
**Decision: do not introduce a `ruview_*` prefix or new umbrella crate.** The rationale:
- **Commit history preservation.** `wifi-densepose-signal` carries the full provenance of ADR-014, -029, -030, -134, -135. A rename detaches blame/log lineage from 1,000+ tests and the ADR-028 witness chain that hashes `ruvsense/*.rs` source.
- **Migration cost with no functional gain.** A rename touches every `use wifi_densepose_*::` path across 35 crates, the `v2/Cargo.toml` `members` list, the publishing order in `CLAUDE.md`, and the witness `source-hashes.txt`. None of this changes runtime behaviour.
- **"RuView" is a product surface, not a crate.** RuView (ADR-031) is the sensing-first *mode* and UI/appliance brand (cognitum-v0 dashboard). The engine beneath it is the wifi-densepose/homecore workspace. Keeping the names distinct avoids implying a code reorganisation that is not happening.
This table is normative: ADR-137 through ADR-146 reference roles by this mapping, not by inventing crate names.
### 2.2 `FrameMeta`: Add `calibration_id` and `model_id` / `model_version`
`CsiMetadata` gains three fields so every frame links to its calibration and inference context. To avoid breaking the 1,000+ tests that call `CsiMetadata::new(...)`, the new fields default to "none" and are populated by the calibration and inference stages.
```rust
// wifi-densepose-core/src/types.rs — additions to CsiMetadata
use uuid::Uuid;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CsiMetadata {
// ... existing fields (timestamp, device_id, frequency_band, channel,
// bandwidth_mhz, antenna_config, rssi_dbm, noise_floor_dbm,
// sequence_number) unchanged ...
/// UUID of the ADR-135 empty-room baseline subtracted from this frame.
/// `None` ⇒ uncalibrated (no `BaselineCalibration::subtract()` applied).
#[cfg_attr(feature = "serde", serde(default))]
pub calibration_id: Option<Uuid>,
/// Identifier of the RF encoder / model family that will consume this
/// frame (ADR-146). Stable across a deployment; 0 ⇒ unassigned.
#[cfg_attr(feature = "serde", serde(default))]
pub model_id: u16,
/// Monotonic model version (ADR-119 §2.1 reserved-flag pattern: the low
/// byte is minor, high byte is major). 0 ⇒ unassigned.
#[cfg_attr(feature = "serde", serde(default))]
pub model_version: u16,
}
```
`FrameMeta` is the public alias the streaming-engine docs use; in code it *is* `CsiMetadata` (`pub use wifi_densepose_core::types::CsiMetadata as FrameMeta;` re-exported from `wifi-densepose-signal`). We keep one struct rather than two to avoid copy-on-cross-stage.
`calibration_id` is a `Uuid` (the workspace already depends on `uuid``types.rs:17`) and references the `BaselineCalibration` finalised by ADR-135. ADR-135's `BaselineCalibration` gains a `pub id: Uuid` field whose value is written here. This closes the trace from a fused semantic state back to the exact empty-room reference that conditioned it.
`model_id`/`model_version` are `u16` (not `String` like `PoseEstimate.model_version` at `types.rs:964`) because they ride on every frame and must be cheap to copy and to serialise in fixed width. The free-form `PoseEstimate.model_version: String` remains for human-readable reporting; the `u16` pair is the machine-traceable key.
### 2.3 `ComplexSample`: One Complex Wrapper with LE Serialisation
CSI uses `Complex64` (`types.rs:16`), CIR uses `Complex32` (`cir.rs:27`). Neither is serialised deterministically today (`CsiFrame.data` is `serde(skip)`). Introduce a single wrapper with a guaranteed little-endian byte order, following the ADR-119 pattern.
```rust
// wifi-densepose-core/src/types.rs (new) — re-exported by signal crate
use num_complex::Complex64;
/// Canonical complex sample for all RuView frame contracts (CSI, CIR, Doppler).
///
/// Wraps `num_complex::Complex64`. The `serde` impl writes `(re, im)` as two
/// little-endian f64, matching the ADR-119 endianness-stability guarantee so
/// x86_64 (ruvultra), aarch64 (cognitum-v0), and Xtensa (ESP32-S3) produce
/// bit-identical bytes. Downstream f32 paths (CIR taps) narrow on demand via
/// `as_complex32()`.
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(transparent)]
pub struct ComplexSample(pub Complex64);
impl ComplexSample {
#[must_use] pub fn new(re: f64, im: f64) -> Self { Self(Complex64::new(re, im)) }
#[must_use] pub fn norm(&self) -> f64 { self.0.norm() }
#[must_use] pub fn arg(&self) -> f64 { self.0.arg() }
/// Narrow to f32 complex for CIR / NN paths (ADR-134, ADR-146).
#[must_use] pub fn as_complex32(&self) -> num_complex::Complex32 {
num_complex::Complex32::new(self.0.re as f32, self.0.im as f32)
}
/// Canonical 16-byte LE encoding: re||im, each f64 LE.
#[must_use] pub fn to_le_bytes(&self) -> [u8; 16] {
let mut b = [0u8; 16];
b[0..8].copy_from_slice(&self.0.re.to_le_bytes());
b[8..16].copy_from_slice(&self.0.im.to_le_bytes());
b
}
#[must_use] pub fn from_le_bytes(b: [u8; 16]) -> Self {
let re = f64::from_le_bytes(b[0..8].try_into().unwrap());
let im = f64::from_le_bytes(b[8..16].try_into().unwrap());
Self(Complex64::new(re, im))
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for ComplexSample {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
// Two LE f64 — deterministic across architectures.
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(2)?;
t.serialize_element(&self.0.re)?;
t.serialize_element(&self.0.im)?;
t.end()
}
}
```
`CsiFrame.data` stays `Array2<Complex64>` for ndarray-native math; `ComplexSample` is the *contract* representation used at stage boundaries and for the deterministic serialiser (§2.5). A new `CsiFrame::data_complex_samples()` view yields `ComplexSample` without copying the underlying buffer. CIR/Doppler frames (`CirFrame`, `DopplerFrame`) store `Vec<ComplexSample>` directly so all three frame types share one complex contract.
### 2.4 Stage, Versioned, QualityScored Traits
The six `RuvSensePipeline` stages (`mod.rs:9-23`) become uniform implementers of `Stage<I,O>`. Two marker/capability traits — `Versioned` and `QualityScored` — sit alongside it.
```rust
// wifi-densepose-signal/src/ruvsense/mod.rs (new traits)
/// A pipeline stage that transforms one typed frame into another.
///
/// Stages are `Send + Sync` and stateless w.r.t. determinism: given the same
/// input bytes and the same `&self` configuration, `process` MUST produce the
/// same output bytes (see §2.5). Mutable runtime state (rolling windows,
/// Welford accumulators) lives behind `&self` interior types whose effect on
/// output is captured in the deterministic-replay fixture.
pub trait Stage<I, O>: Send + Sync {
/// Human/stage identifier, e.g. "phase_align", "calibration".
fn name(&self) -> &'static str;
/// Transform one input frame into one output frame.
fn process(&self, input: I) -> StageResult<O>;
}
pub type StageResult<O> = std::result::Result<O, RuvSenseError>;
/// Forward-compatible version stamp. Mirrors ADR-119 §2.1: a `(major, minor)`
/// pair plus a reserved-flags word so future revisions extend without breaking
/// the deterministic byte layout.
pub trait Versioned {
fn version(&self) -> (u8, u8); // (major, minor)
fn reserved_flags(&self) -> u16 { 0 } // ADR-119 reserved bits 2..15
/// True if `other` can consume output produced at `self.version()`.
fn is_compatible_with(&self, other: (u8, u8)) -> bool {
self.version().0 == other.0 && self.version().1 >= other.1
}
}
/// A stage output that carries a scalar quality score and a confidence
/// interval. Consumed by ADR-137 (fusion quality) and ADR-145 (ablation).
pub trait QualityScored {
/// Scalar quality in [0.0, 1.0]; higher is better.
fn quality_score(&self) -> f32;
/// (lower, upper) confidence bounds in [0.0, 1.0], lower ≤ upper.
fn confidence_bounds(&self) -> (f32, f32);
}
```
With `Stage<I,O>`, the six concrete stages compose as a heterogeneous chain (each adapter `Stage<FrameN, FrameN+1>`), and ADR-138's `ArrayCoordinator` can gate a `Stage` by clock quality, ADR-137's fusion can read `QualityScored`, and ADR-145's harness can substitute or ablate any stage by trait object. `RuvSensePipeline` keeps its concrete fields but each becomes a `Stage` impl; `tick()` is retained for the frame counter, and a new `run(frame) -> StageResult<FusedSensingFrame>` drives the chain.
**Boundary rule:** a `Stage<I,O>` never mutates its input's `FrameMeta.calibration_id` or `model_id` except the calibration stage (sets `calibration_id`) and the model-binding stage (sets `model_id`/`model_version`). This makes provenance append-only along the chain.
### 2.5 Deterministic Serialisation Contract for All Frame Types
Extend the ADR-119 `BfldFrame` determinism + BLAKE3 witness pattern to every frame type in the engine.
```rust
/// Every frame type that crosses a stage boundary or is recorded/replayed
/// implements `CanonicalFrame`. The bytes are stable across architectures
/// (LE per §2.3) and across runs (fixed field order), so a BLAKE3 of the
/// stream is a witness hash (ADR-028).
pub trait CanonicalFrame {
/// Deterministic, architecture-independent encoding.
fn to_canonical_bytes(&self) -> Vec<u8>;
/// BLAKE3-32 of `to_canonical_bytes()` (ADR-119 signature_hasher pattern).
fn witness_hash(&self) -> [u8; 32] {
blake3::hash(&self.to_canonical_bytes()).into()
}
}
```
`CsiFrame`, `CirFrame`, `DopplerFrame`, and `FusedSensingFrame` all implement `CanonicalFrame`. The canonical encoding rule:
1. `FrameMeta` fields in declared order, each fixed-width LE (timestamps as `i64`/`u32`, ids/versions as their integer widths, `calibration_id` as the 16 UUID bytes or 16 zero bytes for `None`).
2. Complex payload as `ComplexSample::to_le_bytes()` in stream-major (`[stream][subcarrier]`) order — the same layout ADR-135 §2.4 uses for the NVS baseline.
3. No `f32`/`f64` text formatting; raw IEEE-754 LE only.
`blake3` is already a workspace dependency (`wifi-densepose-bfld/src/signature_hasher.rs:20` `use blake3::Hasher;`). The **deterministic-replay contract** is: feeding a recorded `Vec<CsiFrame>` (from `homecore-recorder`) through the `Stage` chain twice yields byte-identical `FusedSensingFrame` streams, verified by equal `witness_hash()`. This is the property ADR-145's ablation harness and the ADR-028 witness bundle both rely on.
### 2.6 Provenance Invariant
Combining §2.2, §2.4, and §2.5 yields the engine-wide invariant that every downstream ADR may assume:
> Any `FusedSensingFrame` (and the semantic state derived from it in ADR-140) carries, transitively via its source `FrameMeta`: the **signal evidence** (`witness_hash()` of the source `CsiFrame`s), the **model version** (`model_id`/`model_version`), the **calibration version** (`calibration_id` → ADR-135 baseline), and — once it passes the `wifi-densepose-bfld` privacy gate — the **privacy decision** (`privacy_class`, ADR-119 §2.3). No stage may drop these fields; the boundary rule in §2.4 makes them append-only.
---
## 3. Consequences
### 3.1 Positive
- **One vocabulary for ten ADRs.** ADR-137146 reference the role→crate table (§2.1) and the three traits instead of re-deriving them, eliminating cross-ADR drift.
- **No migration.** Rejecting `ruview_*` keeps every `use` path, the publishing order, and the ADR-028 witness `source-hashes.txt` intact.
- **End-to-end traceability.** `calibration_id` + `model_id`/`model_version` on `FrameMeta` close the provenance chain the project rule mandates; a fused state can be audited back to its baseline and model.
- **Composability.** `Stage<I,O>` lets ADR-138 gate stages, ADR-137 read `QualityScored`, and ADR-145 ablate any stage by trait object — no per-stage glue.
- **Witness extension is mechanical.** `CanonicalFrame::witness_hash()` plugs straight into the existing BLAKE3 path (`signature_hasher.rs`) and the `verify.py` expected-hash format (ADR-028, ADR-119 §3).
### 3.2 Negative
- **`CsiMetadata` grows by three fields.** Every `CsiMetadata::new()` call site (1,000+ tests) keeps compiling because the fields default, but serialised metadata changes shape — `serde(default)` handles forward reads, but any pinned metadata fixture hash in the witness bundle must be regenerated once.
- **Two complex types coexist during migration.** `ComplexSample` (Complex64) is the contract type; `cir.rs` keeps `Complex32` internally and narrows via `as_complex32()`. Until all call sites adopt the view method, both representations are live.
- **Determinism becomes a maintenance obligation.** Once `CanonicalFrame` is the witness substrate, any stage that introduces nondeterminism (HashMap iteration order, unseeded RNG, float reduction order) breaks the replay test — a stricter bar than the current `serde(skip)` payload imposes.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Contributors keep inventing `ruview_*` names because the spec uses them | Medium | Doc/code divergence; phantom crates in design talk | §2.1 table is normative and linked from `CLAUDE.md` crate table; PR review rejects new `ruview_*` crates |
| `Complex64` LE serialisation differs from the f32 CIR path, causing two witness lineages | Low | Replay hash mismatch between CSI and CIR stages | Single `ComplexSample::to_le_bytes()` is the only encoder; `as_complex32()` is a lossy *view*, never re-serialised as the witness form |
| Float reduction order in fusion (multistatic attention) is nondeterministic across thread counts | Medium | `to_canonical_bytes()` stable but `process()` output varies | Fusion stage fixes reduction order (stream-major, single-threaded reduction in the witness path); ADR-137 owns this |
| `model_id`/`model_version` u16 overflow as model families grow | Low | Wraparound collides ids | u16 gives 65k families/versions; ADR-146 owns the registry and reserves 0 = unassigned |
---
## 4. Alternatives Considered
### 4.1 Rename the Workspace to `ruview_*` (Rejected)
Create `ruview-engine`, `ruview-signal`, `ruview-fusion`, etc., matching the spec literally. **Rejected** for the reasons in §2.1: it detaches commit history, breaks the witness `source-hashes.txt` chain, churns 35 crates' `use` paths and the publishing order, and delivers zero runtime change. The spec roles are a *lens*, not a directory layout.
### 4.2 Separate `FrameMeta` Struct Distinct from `CsiMetadata` (Rejected)
Define a new `FrameMeta` and convert `CsiMetadata ↔ FrameMeta` at stage boundaries. **Rejected**: it doubles the metadata type surface and forces a copy on every cross-stage hop at 20 Hz × N links. Re-exporting `CsiMetadata as FrameMeta` gives the spec vocabulary with zero conversion cost.
### 4.3 Keep `Complex64`/`Complex32` Split, No `ComplexSample` (Rejected)
Leave the two complex types as-is and serialise ad hoc per frame type. **Rejected**: it reproduces Gap 2 — no single byte-order guarantee, so witness hashes for CSI vs CIR frames have independent, unverifiable encodings. One wrapper with one `to_le_bytes()` is the minimal fix.
### 4.4 Generic Pipeline via `async` Streams Instead of `Stage<I,O>` (Rejected)
Model the pipeline as a `futures::Stream` chain. **Rejected for the contract layer**: async stream combinators hide the per-stage `name()`/`version()`/`quality_score()` surface that ADR-137/138/145 need to introspect, and they complicate the deterministic-replay test (executor scheduling). A plain `Stage<I,O>` trait is synchronous, introspectable, and trivially replayable; async transport can wrap it at the ingest/api edges where it belongs.
### 4.5 Defer Provenance Fields to a Side-Channel (Rejected)
Carry `calibration_id`/`model_id` in a parallel map keyed by `FrameId` rather than on `FrameMeta`. **Rejected**: a side map can desync from the frame, and recording/replay (`homecore-recorder`) would have to persist two artifacts that must stay consistent. Inlining on `FrameMeta` makes provenance travel with the data and survive serialisation.
---
## 5. Testing and Acceptance
All tests live in `wifi-densepose-core` (contract types) and `wifi-densepose-signal/src/ruvsense/` (traits, replay). Hardware tests are gated behind `#[cfg(feature = "hardware-test")]` and excluded from CI.
**AC1 — `ComplexSample` LE round-trip (unit).** For 10,000 seeded random `(re, im)` f64 pairs, assert `ComplexSample::from_le_bytes(s.to_le_bytes()) == s` and that byte 0 equals the LSB of `re` (endianness pin). Run the same assertion under `cfg(target_endian = "big")` cross-check via manual byte construction.
**AC2 — `FrameMeta` provenance defaults (unit).** `CsiMetadata::new(...)` yields `calibration_id == None`, `model_id == 0`, `model_version == 0`. After a simulated ADR-135 `subtract()` and ADR-146 model bind, the fields are populated; assert the boundary rule (§2.4) — no other stage mutates them.
**AC3 — `serde(default)` forward-read (unit).** Deserialise a pre-ADR-136 `CsiMetadata` JSON fixture (without the three fields) and assert it loads with the documented defaults — proves the addition is backward-compatible.
**AC4 — `Stage` chain composition (unit).** Build a 6-stage mock chain (`Stage<FrameN, FrameN+1>`), feed one synthetic `CsiFrame`, assert the output `FusedSensingFrame` and that each stage's `name()` is visited in declared order.
**AC5 — `Versioned` compatibility (unit).** Assert `is_compatible_with` accepts equal-major/greater-or-equal-minor and rejects major mismatch, mirroring ADR-119 §2.1 reserved-flag forward-compat.
**AC6 — Deterministic replay / witness (CI-compatible).** Generate a fixed 600-frame synthetic `CsiFrame` stream (seed = 42, same generator as ADR-135 Tier 1). Run it through the `Stage` chain twice and assert byte-identical `FusedSensingFrame::to_canonical_bytes()` and equal `witness_hash()`. Record the final BLAKE3 in `archive/v1/data/proof/expected_features.sha256` under key `streaming_engine_replay_v1`; `verify.py` regenerates and re-asserts (extends the ADR-028 proof chain).
**AC7 — Cross-architecture byte stability (CI matrix).** Run AC6 on x86_64 and aarch64 CI runners (ruvultra, cognitum-v0 classes); assert identical `witness_hash()` across architectures — the ADR-119 §1 endianness guarantee at the whole-pipeline level.
**AC8 — `QualityScored` bounds invariant (unit).** For any stage output implementing `QualityScored`, assert `0.0 ≤ lower ≤ quality_score ≤ upper ≤ 1.0` is *not* required (score may sit outside bounds), but `0.0 ≤ lower ≤ upper ≤ 1.0` and `quality_score ∈ [0,1]` hold. Consumed by ADR-137.
**AC9 — Role→crate map is live (doc/CI lint).** A test asserts each crate named in the §2.1 table exists in `v2/Cargo.toml` `members`, preventing the mapping from rotting as crates are added/removed.
---
## 6. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-028 (ESP32 Capability Audit) | **Witness extended**: `CanonicalFrame::witness_hash()` adds `streaming_engine_replay_v1` to `expected_features.sha256`; `verify.py` regenerates it |
| ADR-031 (RuView Sensing-First Mode) | **Named**: clarifies RuView is the product mode/brand atop this engine, not a crate to rename to |
| ADR-119 (BFLD Frame Format) | **Generalised**: this ADR lifts ADR-119's LE determinism, reserved-flag forward-compat (§2.1), and BLAKE3 witness from one frame type to all frame types |
| ADR-127 (HomeCore State Machine) | **Consumer**: `homecore` is the `world` role; semantic state it holds traces to `FrameMeta` provenance |
| ADR-134 (First-Class CIR) | **Unified**: `CirFrame` adopts `ComplexSample`; `as_complex32()` feeds the ISTA path; CIR is a `Stage` in the chain |
| ADR-135 (Empty-Room Baseline) | **Linked**: `BaselineCalibration` gains `id: Uuid`, written into `FrameMeta.calibration_id` by the calibration stage |
| ADR-137 (Fusion Quality Scoring) | **Depends on**: `QualityScored` trait and `FusedSensingFrame` contract defined here |
| ADR-138 (LinkGroup / ArrayCoordinator) | **Depends on**: gates `Stage`s by clock quality using the trait surface here |
| ADR-140 (Semantic State Record) | **Depends on**: semantic states reference the §2.6 provenance invariant |
| ADR-145 (Ablation Eval Harness) | **Depends on**: ablates/substitutes `Stage` trait objects and relies on deterministic replay (AC6) |
| ADR-146 (RF Encoder Multi-Task Heads) | **Depends on**: owns the `model_id`/`model_version` registry written into `FrameMeta` |
---
## 7. References
### Production Code
- `v2/crates/wifi-densepose-core/src/types.rs``CsiFrame` (line 363), `CsiMetadata` (line 311), `Complex64` import (line 16), `uuid` import (line 17); `data`/`amplitude`/`phase` are `serde(skip)` (lines 369376); `PoseEstimate.model_version: String` (line 964)
- `v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs` — six-stage pipeline doc (lines 923), `RuvSensePipeline` (line 184), `tick()` (line 232), `RuvSenseError` (line 121)
- `v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs``Complex32` use (line 27), sub-DFT Φ
- `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs` — ADR-135 `BaselineCalibration` (gains `id: Uuid`)
- `v2/crates/wifi-densepose-bfld/src/signature_hasher.rs` — BLAKE3 keyed hash precedent (`use blake3::Hasher;`, line 20)
- `v2/crates/wifi-densepose-bfld/src/frame.rs`, `privacy_gate.rs`, `sink.rs` — ADR-119 frame/privacy precedent
- `v2/crates/homecore/src/{state.rs,entity.rs,registry.rs,bus.rs}``world` role (ADR-127)
- `v2/Cargo.toml` — workspace `members`; `num-complex = "0.4"` (line 102)
- `archive/v1/data/proof/verify.py`, `expected_features.sha256` — deterministic proof chain; `streaming_engine_replay_v1` key to be added
### Related ADR Documents
- `docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md` — §2.1 (reserved flags), §2.4 (deterministic serialisation), §1 (endianness stability)
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — world/state role
- `docs/adr/ADR-134-*.md`, `docs/adr/ADR-135-empty-room-baseline-calibration.md` — signal-stage precedents reused here
### External
- IEEE 802.11bf-2024 WLAN Sensing — the multistatic sensing context the engine implements (referenced in `ruvsense/mod.rs`).
- BLAKE3 (Aumasson et al., 2020) — witness hash function, already vendored for ADR-119/120.
---
## 8. Implementation Status & Integration (2026-05-29)
> **Series context (ADR-136 series).** A *skeleton and nervous system, not a shipping product.* These ADRs deliver the **data contracts**, the **trust / privacy / audit machinery**, and the **algorithms** -- all real, tested, and compiling -- that give the *existing* sensing code a clean place to plug into. Most of the series is **not yet wired into the live 20 Hz pipeline**: each module is an independently tested building block; end-to-end wiring (plus model training in ADR-146) is the next phase, and every ADR's GitHub issue lists what is **Built** vs **Integration glue**. The throughline is **trust** -- *why believe the system when it says a person fell?* -- traceable evidence (137), sensor agreement (137/138), calibration provenance (135/136), and an auditable privacy posture (141).
**Built -- tested building block** (commit `11f89727f`, issue #840): `ComplexSample` (LE-canonical), `CsiMetadata` provenance fields (`calibration_id` / `model_id` / `model_version`), `CanonicalFrame` + BLAKE3 `witness_hash()`, and the `Stage`/`Versioned`/`QualityScored` traits. 9 acceptance tests; workspace builds clean.
**Integration glue -- not yet on the live path:** the full 600-frame `Stage`-chain replay (AC6) -> `streaming_engine_replay_v1` witness key; the cross-architecture CI matrix (AC7); and populating the provenance fields from the live calibration and model-binding stages.
**Trust contribution:** the root of traceability -- the frame contract that lets every fused state name its evidence, model, and calibration.
@@ -0,0 +1,497 @@
# ADR-137: Fusion Engine Quality Scoring with Evidence References and Contradiction Flags
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/multistatic.rs``fuse`, `attention_weighted_fusion`); `wifi-densepose-ruvector` (`viewpoint/fusion.rs``MultistaticArray`); `wifi-densepose-bfld` (`event.rs`) |
| **Relates to** | ADR-029 (RuvSense Multistatic), ADR-031 (RuView Sensing-First RF Mode), ADR-118 (BFLD Beamforming Feedback Layer), ADR-134 (CSI→CIR Time-Domain Multipath), ADR-135 (Empty-Room Baseline Calibration), ADR-136 (RuView Rust Streaming Engine), ADR-138 (WiFi-7 MLO LinkGroup / ArrayCoordinator Clock-Quality Gating) |
---
## 1. Context
### 1.1 The Gap
The multistatic fusion stage decides how much to trust each sensing node and emits a single fused frame, but it discards every input it used to make that decision. Grepping the two fusion implementations confirms this:
- **`v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs`** (`MultistaticFuser::fuse`, lines 196282) returns a `FusedSensingFrame` whose only quality field is `cross_node_coherence: f32` (line 80). That scalar is computed by `compute_weight_coherence()` (lines 441460) as a normalized Shannon entropy over the softmax attention weights — a single number with no record of *which* weights produced it, which subcarriers drove the attention logits, or whether the CIR gate (`cir_gate_coherence`, lines 292327) actually contributed or silently fell back on `CirError::UnsanitizedPhase`.
- **`v2/crates/wifi-densepose-ruvector/src/viewpoint/fusion.rs`** (`MultistaticArray::fuse`, lines 358436) is richer — it emits `ViewpointFusionEvent` values (lines 183219) and reports `gdi` / `n_effective` on `FusedEmbedding` — but its quality signal is still split across heterogeneous channels: a `coherence: f32` on the output struct, a `CoherenceGateTriggered { accepted }` event, and a `FusionError::CoherenceGateClosed` on the error path. There is no single auditable record that says *this fused output is trustworthy because X, Y, Z, but be aware of contradiction C*.
The validation that *does* happen is thrown away rather than recorded:
- `multistatic.rs::fuse` checks `timestamp_us` spread against `guard_interval_us` (lines 205215) and returns `MultistaticError::TimestampMismatch` — but on the success path the fact that timestamps *passed* (and by how much margin) is never carried forward. A consumer cannot tell a frame fused from microsecond-aligned nodes from one fused at the 4999 µs edge of the 5000 µs guard.
- Neither implementation checks **calibration alignment**. ADR-135 finalises a per-node `BaselineCalibration` with a `captured_at_unix_s` and a `tier`, and `BaselineCalibration::subtract()` already returns `CalibrationError::TierMismatch`. But fusion does not know which baseline (if any) was applied to each node frame, so it cannot detect the dangerous case where node A's frame was baseline-subtracted against a fresh calibration and node B's against a stale one — producing amplitudes on incomparable scales that the attention softmax in `attention_weighted_fusion` (lines 364435) will silently average together.
- **Amplitude scale comparability is assumed, not enforced.** `attention_weighted_fusion` computes a cosine similarity of each node's amplitude vector against the consensus mean (lines 384397). Cosine similarity is scale-invariant *per node*, which masks the problem: two nodes with the same shape but a 2× gain difference look perfectly coherent, yet the weighted-sum fusion (lines 411422) adds raw `w * amp[i]` and so the louder node dominates the fused amplitude regardless of its attention weight. The fix in §2.5 is to normalize before pooling, but today there is nothing in the codebase that does it explicitly.
Downstream, the BFLD privacy layer cannot react to fusion quality at all. `wifi-densepose-bfld/src/event.rs` constructs a `BfldEvent` with a `privacy_class` (line 60) and masks identity fields at `Restricted` via `apply_privacy_gating()` (lines 112117), and `privacy_gate.rs::PrivacyGate::demote` (lines 3175) is the monotonic-demote primitive. But the demotion decision is driven by policy, not by sensing evidence. There is no path by which "the fusion engine detected that two nodes disagree about the world" can lower the emitted privacy class. A contradictory fuse is published at the same class as a clean one.
### 1.2 What This ADR Adds
A single, serializable `QualityScore` that travels alongside every fused frame and answers four questions with evidence rather than a scalar:
1. **How good is this fusion?**`base_coherence` plus the `per_node_weights` that produced it.
2. **Why is it good (or bad)?** — a list of `EvidenceRef` values naming the concrete checks that fired (coherence-gate threshold crossed, CIR dominant-tap ratio, weight entropy, calibration applied).
3. **What is wrong with it?** — a list of `ContradictionFlag` values for the validations that *failed* but were tolerated (timestamp at the guard edge, calibration-id disagreement, phase alignment failure, drift-profile conflict).
4. **Is it safe to publish at full fidelity?** — a non-empty contradiction set lowers the BFLD `privacy_class` and emits a witness record, honouring the project rule that every emitted semantic state traces to signal evidence + model/calibration version + a privacy decision.
This is the fusion-layer counterpart to ADR-135's `CalibrationDeviationScore`: where ADR-135 scores one frame against one baseline, ADR-137 scores one *fusion* against all of its contributing node frames and their baselines.
### 1.3 Pipeline Position
```
Per-node CSI (post phase_sanitizer, phase_align, ADR-135 subtract)
→ CalibratedFrame wrapper ← NEW (carries calibration_id, capture_ns)
→ multistatic.rs::fuse()
├─ capture_ns epoch-alignment check → ContradictionFlag::TimestampMismatch
├─ calibration_id agreement check → ContradictionFlag::CalibrationIdMismatch
├─ normalize-then-concat (per §2.5)
├─ attention_weighted_fusion() → EvidenceRef::WeightEntropy, per_node_weights
└─ cir_gate_coherence() → EvidenceRef::CirDominantTapRatio
→ (FusedSensingFrame, QualityScore) ← NEW tuple return
→ ruvector MultistaticArray (embedding fusion, same QualityScore contract)
→ BFLD emitter
└─ if !contradiction_flags.is_empty():
privacy_class = privacy_class.max(Restricted) (demote)
emit witness record (ADR-134 proof chain)
→ BfldEvent
```
The `QualityScore` is computed *during* `fuse`, not bolted on afterward, because the evidence it records (attention weights, the CIR fallback decision, the timestamp margin) only exists inside that function's scope today.
---
## 2. Decision
### 2.1 `QualityScore`: the unified fusion-quality record
`QualityScore` is the canonical output of every fusion stage, returned next to the existing frame/embedding type. It is defined in `ruvsense/multistatic.rs` (re-exported from `ruvsense/mod.rs`) and consumed unchanged by `viewpoint/fusion.rs` and `wifi-densepose-bfld`.
```rust
use num_complex::Complex32;
/// Identifies which sensing family produced a fused frame. Lets a single
/// QualityScore be correlated across the signal-domain fuser
/// (`multistatic.rs`) and the embedding-domain fuser (`viewpoint/fusion.rs`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FamilyId {
/// `ruvsense/multistatic.rs` CSI/CIR-domain fusion.
MultistaticCsi,
/// `ruvector/viewpoint/fusion.rs` AETHER-embedding fusion.
ViewpointEmbedding,
}
/// Auditable quality record for one fused frame.
///
/// Every semantic state downstream of fusion traces back to exactly one
/// `QualityScore`, which in turn names the signal evidence
/// (`evidence_refs`), the calibration version (`calibration_id`), and the
/// privacy-relevant disagreements (`contradiction_flags`) that informed it.
#[derive(Debug, Clone)]
pub struct QualityScore {
/// Which fuser produced this score.
pub family_id: FamilyId,
/// Capture-clock timestamp (ns) of the fused cycle, derived from the
/// median of the contributing node `capture_ns` values.
pub capture_ns: u64,
/// The calibration epoch all contributing frames agreed on, or `None`
/// when frames disagreed (see `ContradictionFlag::CalibrationIdMismatch`).
pub calibration_id: Option<CalibrationId>,
/// Coherence in [0, 1] before any contradiction penalty is applied.
/// For the CSI fuser this is the entropy-of-weights value currently
/// returned as `cross_node_coherence`; for the embedding fuser it is the
/// `CoherenceState::coherence()` value.
pub base_coherence: f32,
/// Per-contributing-node attention weight, node-index aligned with the
/// fused frame's `node_frames` / viewpoint list. Sums to ~1.0.
pub per_node_weights: Vec<f32>,
/// Concrete checks that fired *in support* of this fusion.
pub evidence_refs: Vec<EvidenceRef>,
/// Tolerated-but-recorded disagreements. A non-empty set forces a BFLD
/// privacy demotion (see §2.7).
pub contradiction_flags: Vec<ContradictionFlag>,
/// Monotonic capture-clock time at which this score was computed (ns).
pub timestamp_computed_ns: u64,
}
/// Calibration epoch identifier. Derived from the ADR-135
/// `BaselineCalibration::captured_at_unix_s` plus device id; stable across
/// reboots, changes only on recalibration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CalibrationId(pub u64);
```
`QualityScore` deliberately mirrors the shape of the `QualityScored` trait introduced in ADR-136 (the streaming-engine frame contract). It implements that trait so the streaming engine can pull a uniform quality view off any stage:
```rust
/// Defined in ADR-136 (`ruview-streaming-engine`); re-stated here for the
/// `impl`. A stage that produces quality-scored output implements this so
/// the engine can route, gate, and log on quality uniformly.
pub trait QualityScored {
fn quality(&self) -> &QualityScore;
}
impl QualityScored for (FusedSensingFrame, QualityScore) {
fn quality(&self) -> &QualityScore {
&self.1
}
}
```
**Why a struct and not just more fields on `FusedSensingFrame`:** the two fusers (`multistatic.rs` and `viewpoint/fusion.rs`) produce different payloads (`FusedSensingFrame` vs `FusedEmbedding`) but should produce the *same* quality contract. A shared `QualityScore` is the only thing that lets the BFLD layer treat both uniformly. Inlining quality fields into each payload would force the privacy logic to branch on payload type.
### 2.2 `EvidenceRef`: why a fusion was trusted
`EvidenceRef` records the positive evidence. Each variant carries the *value that crossed a threshold*, not just a boolean, so the witness record (§2.7) is reproducible.
```rust
/// A single piece of positive evidence supporting a fusion decision.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EvidenceRef {
/// The coherence-gate threshold was met. `coherence` is the value,
/// `threshold` the configured gate (mirrors ADR-031 coherence gate and
/// `viewpoint/coherence.rs::CoherenceGate`).
CoherenceGateThreshold { coherence: f32, threshold: f32 },
/// The ADR-134 CIR dominant-tap ratio contributed to the gate. `ratio`
/// is `Cir::dominant_tap_ratio`; `blended` is true when it was actually
/// folded into `base_coherence` (false on `UnsanitizedPhase` fallback).
CirDominantTapRatio { ratio: f32, blended: bool },
/// Attention-weight entropy supported a balanced (multi-node) fusion.
/// `normalized_entropy` is the `compute_weight_coherence` output.
WeightEntropy { normalized_entropy: f32, n_nodes: usize },
/// An ADR-135 baseline was applied to every contributing frame at a
/// single agreed calibration epoch before pooling.
CalibrationApplied { calibration_id: CalibrationId, n_frames: usize },
}
```
`CirDominantTapRatio { blended: false }` is itself useful evidence: it records that the CIR gate was *attempted* but fell back, which today is invisible (the `Err(CirError::UnsanitizedPhase)` arm at `multistatic.rs` line 321 silently returns `freq_coherence`).
### 2.3 `ContradictionFlag`: what was wrong but tolerated
`ContradictionFlag` records validations that failed without being fatal. These are the cases where today's code either hard-errors (losing the chance to degrade gracefully) or silently passes (losing the chance to warn).
```rust
/// A tolerated disagreement detected during fusion. A non-empty set lowers
/// the emitted BFLD privacy_class (§2.7) and produces a witness record.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContradictionFlag {
/// Node capture_ns values spread within the guard interval but beyond a
/// stricter "comparable" sub-threshold. Carries the observed spread.
TimestampMismatch { spread_ns: u64, soft_guard_ns: u64 },
/// Contributing frames carried different calibration_id values. `expected`
/// is the modal (most common) id; `seen` counts the disagreeing frames.
CalibrationIdMismatch { expected: CalibrationId, disagreeing: usize },
/// Phase alignment (LO offset estimation, `phase_align.rs`) did not
/// converge for at least one node, so its phase contribution is suspect.
PhaseAlignmentFailed { node_idx: usize },
/// A node's ADR-135 drift_score / DriftProfile conflicts with the array
/// consensus (e.g., one node reports a static environment while the
/// majority report motion), suggesting that node is mis-calibrated.
DriftProfileConflict { node_idx: usize, drift_score: f32 },
/// Raised upstream by the ADR-138 `ArrayCoordinator`: a node's coherence
/// dropped beyond `sigma`σ of its rolling mean, so its observation
/// contradicts the array's rolling expectation.
CoherenceDrop { node_idx: usize, sigma: f32 },
/// Raised upstream by the ADR-138 `ArrayCoordinator`: the array's Geometric
/// Diversity Index fell below the geometry-sufficiency floor, so directional
/// estimates are under-determined. Carries the observed GDI.
GeometryInsufficient { gdi: f32 },
}
```
`ContradictionFlag` is the **single canonical type** for tolerated disagreements across the fusion path; it is defined here and re-used (not re-declared) by ADR-138. The first four variants originate inside `multistatic.rs::fuse` (§2.4); the last two (`CoherenceDrop`, `GeometryInsufficient`) originate one stage upstream in the ADR-138 `ArrayCoordinator` and arrive on `DirectionalEvidence.contradictions`, which `fuse` folds into the same `QualityScore.contradiction_flags` vector. `node_idx` is the index into the fused frame's node ordering; the coordinator's `NodeId` is resolved to that index at the hand-off.
The distinction between `MultistaticError::TimestampMismatch` (hard error, line 47) and `ContradictionFlag::TimestampMismatch` is intentional:
- The **hard error** fires when `spread > guard_interval_us` — frames are simply not from the same sensing cycle and must not be fused.
- The **soft flag** fires when `soft_guard_ns < spread <= guard_interval_us` — the frames *can* be fused (they are within the TDMA cycle) but the alignment is loose enough that the fused output should not be published at full identity fidelity. Default `soft_guard_ns = guard_interval_us / 5` (1000 ns when the guard is 5 µs).
### 2.4 `fuse()` rework: validate-record-fuse
`multistatic.rs::fuse` is changed to return `Result<(FusedSensingFrame, QualityScore), MultistaticError>`. The hard-error preconditions (`NoFrames`, `InsufficientNodes`, `DimensionMismatch`, and the *hard* `TimestampMismatch`) are unchanged. The new logic builds the evidence and contradiction lists during the existing passes.
```rust
pub fn fuse(
&self,
node_frames: &[CalibratedFrame], // §2.5: wrapper, was &[MultiBandCsiFrame]
) -> Result<(FusedSensingFrame, QualityScore), MultistaticError> {
if node_frames.is_empty() {
return Err(MultistaticError::NoFrames);
}
let mut evidence = Vec::new();
let mut contradictions = Vec::new();
// ---- capture_ns epoch alignment (hard + soft) -----------------------
if node_frames.len() > 1 {
let min = node_frames.iter().map(|f| f.capture_ns).min().unwrap();
let max = node_frames.iter().map(|f| f.capture_ns).max().unwrap();
let spread = max - min;
let guard_ns = self.config.guard_interval_us * 1000;
if spread > guard_ns {
return Err(MultistaticError::TimestampMismatch {
spread_us: spread / 1000,
guard_us: self.config.guard_interval_us,
});
}
let soft = guard_ns / 5;
if spread > soft {
contradictions.push(ContradictionFlag::TimestampMismatch {
spread_ns: spread,
soft_guard_ns: soft,
});
}
}
// ---- calibration_id agreement ---------------------------------------
let calibration_id = resolve_calibration_id(node_frames, &mut evidence, &mut contradictions);
// ---- normalize then attention-pool (§2.5) ---------------------------
let (amps, phases) = normalize_by_calibration(node_frames);
let (fused_amp, fused_ph, base_coherence, weights) =
attention_weighted_fusion(&amps, &phases, self.config.attention_temperature);
evidence.push(EvidenceRef::WeightEntropy {
normalized_entropy: base_coherence,
n_nodes: weights.len(),
});
// ---- CIR gate (records blended/fallback as evidence) ----------------
let coherence = self.cir_gate_coherence_recorded(base_coherence, node_frames, &mut evidence);
// ---- phase-alignment + drift conflicts ------------------------------
record_phase_and_drift_conflicts(node_frames, &mut contradictions);
let now = monotonic_capture_ns();
let quality = QualityScore {
family_id: FamilyId::MultistaticCsi,
capture_ns: median_capture_ns(node_frames),
calibration_id,
base_coherence,
per_node_weights: weights,
evidence_refs: evidence,
contradiction_flags: contradictions,
timestamp_computed_ns: now,
};
let frame = FusedSensingFrame { /* existing fields, coherence = coherence */ };
Ok((frame, quality))
}
```
`attention_weighted_fusion` is changed only to *return* its `weights` vector (it already computes it at lines 401408) instead of discarding it — `per_node_weights` is exactly that vector, costing nothing extra to surface.
**Interface boundary:** `FusedSensingFrame` keeps `cross_node_coherence` for backward compatibility, set to the post-gate `coherence`. New consumers read `QualityScore.base_coherence`; the scalar on the frame is now derived, not authoritative.
### 2.5 Normalize-then-concat: explicit `CalibratedFrame`
Today `fuse` consumes `&[MultiBandCsiFrame]` and relies on the implicit z-score normalization buried in `hardware_norm.rs::CanonicalCsiFrame`. ADR-137 makes calibration explicit by introducing a thin wrapper that carries the calibration provenance from ADR-135 to the fuser:
```rust
/// A node frame whose amplitude/phase have been baseline-subtracted and
/// normalized by a *named* ADR-135 calibration. The wrapper makes the
/// calibration provenance an explicit fusion input rather than an implicit
/// property of CanonicalCsiFrame.
#[derive(Debug, Clone)]
pub struct CalibratedFrame {
/// The underlying multi-band frame (per-channel amplitude/phase).
pub inner: MultiBandCsiFrame,
/// Capture-clock timestamp (ns). Promoted from `timestamp_us * 1000`
/// when the source only has microsecond resolution.
pub capture_ns: u64,
/// Which ADR-135 baseline normalized this frame, or `None` if the node
/// is running uncalibrated (ADR-135 fallback mode).
pub calibration_id: Option<CalibrationId>,
/// Per-subcarrier gain applied during normalization (from the ADR-135
/// `amp_mean` / `amp_variance`), retained so the fuser can renormalize
/// onto a common scale before pooling.
pub norm_gain: Vec<f32>,
/// Per-subcarrier phase offset removed (from the ADR-135 circular mean).
pub norm_phase_offset: Vec<f32>,
}
```
`normalize_by_calibration` divides each node's amplitude by its own `norm_gain` RMS so that, after normalization, every node's amplitude is unit-scaled regardless of per-node hardware gain. Only then does the attention pool run. This closes the scale-comparability hole described in §1.1: the cosine-similarity attention logits and the weighted sum now operate on the same scale, so attention weight (not loudness) determines a node's contribution.
**Why explicit over implicit:** `hardware_norm.rs` z-score normalization uses population statistics computed from the live signal including any occupant. The ADR-135 baseline statistics are computed from a *known-empty* room. Normalizing by the baseline (a) makes nodes comparable on a physically meaningful zero, and (b) gives the fuser the `calibration_id` it needs to detect cross-node calibration disagreement. The wrapper costs `O(K)` extra memory per node frame (two `Vec<f32>`), negligible against the `MultiBandCsiFrame` it wraps.
### 2.6 Embedding-domain fuser: same contract
`viewpoint/fusion.rs::MultistaticArray::fuse` is changed to return `Result<(FusedEmbedding, QualityScore), FusionError>` with `family_id: FamilyId::ViewpointEmbedding`. The mapping from its existing machinery to the unified record:
| `QualityScore` field | Source in `viewpoint/fusion.rs` |
|----------------------|----------------------------------|
| `base_coherence` | `self.coherence_state.coherence()` (line 382) |
| `per_node_weights` | attention weights from `self.attention.fuse(...)` (line 408) — surfaced, currently internal to `CrossViewpointAttention` |
| `evidence_refs``CoherenceGateThreshold` | `CoherenceGate::evaluate` (line 383) plus the configured `coherence_threshold` |
| `contradiction_flags``DriftProfileConflict` | a viewpoint whose `snr_db` passed the filter but whose phase-diff series diverges from the coherent majority |
| `calibration_id` | from each `ViewpointEmbedding`'s source `CalibratedFrame` |
The existing `ViewpointFusionEvent::CoherenceGateTriggered` and `FusionError::CoherenceGateClosed` are retained — they remain the *control-flow* signal — while `QualityScore` becomes the *data* signal that travels with the frame. The `CoherenceGateClosed` error still aborts fusion; `QualityScore` is only produced on the success path. A gate that is open but near the threshold records `EvidenceRef::CoherenceGateThreshold` with the margin, so a barely-open gate is auditable.
### 2.7 Wiring contradictions into the BFLD privacy boundary
This is where fusion quality becomes a privacy decision. The BFLD emitter (`wifi-densepose-bfld`) gains a single rule:
> A `QualityScore` with a non-empty `contradiction_flags` set forces the emitted `BfldEvent.privacy_class` to be **at least** `Restricted`.
Because `PrivacyClass` is ordered (`Raw=0 < Derived=1 < Anonymous=2 < Restricted=3`, `lib.rs` lines 8494) and demotion is monotonic (`privacy_gate.rs::demote` rejects any decrease in class number), "at least Restricted" is `privacy_class.max(Restricted)` — i.e. a demote, never a promote:
```rust
// In the BFLD emitter, before BfldEvent::with_privacy_gating(...):
let effective_class = if quality.contradiction_flags.is_empty() {
policy_class // normal policy decision
} else {
policy_class.max(PrivacyClass::Restricted) // demote on contradiction
};
```
At `Restricted`, `BfldEvent::apply_privacy_gating` (event.rs lines 112117) already nulls `identity_risk_score` and `rf_signature_hash`. So a contradictory fuse — two nodes that disagree about calibration, timestamp, phase, or drift — automatically stops leaking the identity-surface fields. The rationale: contradiction means the system is not confident *whose* signal it fused; emitting an identity-risk score or RF signature hash on an un-trusted fusion is exactly the failure the privacy layer exists to prevent.
A non-empty contradiction set also emits a **witness record** through the ADR-134 proof chain (the `verify.py` / `expected_features.sha256` / `source-hashes.txt` witness schema, ADR-134 §2.10). The record captures: `capture_ns`, `family_id`, the `contradiction_flags` (with their carried values), the resulting `effective_class`, and a hash of `per_node_weights`. This makes every privacy demotion reproducible and auditable — satisfying the project invariant that each emitted semantic state traces to signal evidence + calibration version + a recorded privacy decision.
```
QualityScore.contradiction_flags non-empty
├─ effective_class = policy_class.max(Restricted) (demote, monotonic)
├─ BfldEvent gated → identity_risk_score = None, rf_signature_hash = None
└─ witness record { capture_ns, family_id, flags, effective_class,
blake3(per_node_weights) } → ADR-134 proof chain
```
**Interface boundary:** the BFLD crate depends only on `QualityScore` (a plain data struct re-exported from `wifi-densepose-signal`), not on the fusers themselves. No new control coupling is introduced; the emitter reads two fields (`contradiction_flags`, `calibration_id`) and a policy class.
### 2.8 Proposed Rust API surface (summary)
| Item | Location | Kind |
|------|----------|------|
| `QualityScore`, `FamilyId`, `CalibrationId` | `ruvsense/multistatic.rs`, re-exported `ruvsense/mod.rs` | struct / enum |
| `EvidenceRef`, `ContradictionFlag` | `ruvsense/multistatic.rs` | enum |
| `CalibratedFrame` | `ruvsense/multistatic.rs` | struct |
| `impl QualityScored for (FusedSensingFrame, QualityScore)` | `ruvsense/multistatic.rs` | trait impl (ADR-136 trait) |
| `MultistaticFuser::fuse → Result<(FusedSensingFrame, QualityScore), _>` | `ruvsense/multistatic.rs` | changed signature |
| `MultistaticArray::fuse → Result<(FusedEmbedding, QualityScore), _>` | `viewpoint/fusion.rs` | changed signature |
| BFLD emitter contradiction→demote rule | `wifi-densepose-bfld` emitter | new logic |
### 2.9 Testing / Acceptance
**T1 — Evidence is recorded on a clean fuse (unit, `multistatic.rs`).** Two `CalibratedFrame`s with identical `calibration_id`, `capture_ns` within `soft_guard_ns`, sanitized phase. Assert the returned `QualityScore` has `contradiction_flags.is_empty()`, contains `EvidenceRef::WeightEntropy` and `EvidenceRef::CalibrationApplied`, and `per_node_weights.len() == 2` summing to ~1.0.
**T2 — CIR fallback is recorded, not hidden (unit).** Feed a frame whose phase is unsanitized (phase variance > 10 rad², triggering `CirError::UnsanitizedPhase`). Assert `evidence_refs` contains `EvidenceRef::CirDominantTapRatio { blended: false, .. }` and `base_coherence` equals the pre-gate frequency coherence (graceful fallback preserved).
**T3 — Soft timestamp contradiction (unit).** Two frames with `capture_ns` spread `> soft_guard_ns` but `<= guard_interval`. Assert success (no `MultistaticError`) AND `contradiction_flags` contains `TimestampMismatch { spread_ns, .. }`.
**T4 — Calibration-id mismatch (unit).** Two frames with different `calibration_id`. Assert `QualityScore.calibration_id == None` and `contradiction_flags` contains `CalibrationIdMismatch { expected, disagreeing: 1 }`.
**T5 — Hard timestamp error still hard (unit, regression).** Spread `> guard_interval`. Assert `Err(MultistaticError::TimestampMismatch)` — no `QualityScore` produced. Confirms the existing test `timestamp_mismatch_error` (multistatic.rs line 585) still passes against the new signature.
**T6 — Normalize-then-concat scale invariance (unit).** Two nodes, identical amplitude shape, node B scaled 2×. Assert that after `normalize_by_calibration` the fused amplitude is within 1% of the single-node result (loudness no longer dominates) and `per_node_weights` are ~equal.
**T7 — Privacy demotion on contradiction (unit, `wifi-densepose-bfld`).** Build a `QualityScore` with one `ContradictionFlag` and a policy class of `Derived`. Assert the emitted `BfldEvent.privacy_class == Restricted`, and that `identity_risk_score` and `rf_signature_hash` serialize as absent (reuse the gating assertions in event.rs).
**T8 — Clean fuse keeps policy class (unit).** Same as T7 but with empty `contradiction_flags`. Assert `privacy_class == Derived` (no demotion) and identity fields present.
**T9 — Witness determinism (CI proof chain).** A fixed two-node contradictory fuse produces a `QualityScore` whose witness record hashes to a recorded value in `expected_features.sha256` under key `fusion_quality_contradiction_v1`. The `verify.py` extension `fusion_quality_check()` reproduces it. Mirrors ADR-135 §2.12 Tier 7 and ADR-134 §2.10.
**T10 — `QualityScored` trait round-trip (unit).** Assert `(frame, quality).quality()` returns the embedded `QualityScore` by reference, satisfying the ADR-136 contract.
**Acceptance criteria:** all existing `multistatic.rs` tests (lines 546697) and `viewpoint/fusion.rs` tests (lines 564743) pass after the signature change (adapted to destructure the tuple); T1T10 pass; `cargo test --workspace --no-default-features` reports 0 failures; `verify.py` prints `VERDICT: PASS` with the new key.
---
## 3. Consequences
### 3.1 Positive
- **Fusion decisions become auditable.** Every fused frame now carries the evidence that produced its coherence and the disagreements that were tolerated. A field engineer can read why a frame was trusted without re-running the fuser.
- **Calibration disagreement is caught.** The `CalibrationIdMismatch` contradiction surfaces the previously-invisible failure where nodes are normalized against baselines of different vintage — the silent amplitude-scale corruption from §1.1.
- **CIR fallback stops being silent.** `EvidenceRef::CirDominantTapRatio { blended: false }` records the `UnsanitizedPhase` fallback that today disappears at `multistatic.rs` line 321.
- **Privacy degrades safely under uncertainty.** A contradictory fusion can no longer publish identity-surface fields; the demotion is monotonic and witnessed.
- **One contract, two fusers.** The signal-domain and embedding-domain fusers expose identical quality semantics, so the streaming engine (ADR-136) and BFLD layer treat them uniformly.
- **Traceability invariant satisfied.** Each `BfldEvent` traces to a `QualityScore``EvidenceRef`s (signal evidence) + `calibration_id` (calibration version) + the recorded `effective_class` (privacy decision).
### 3.2 Negative
- **Breaking signature change.** Both `fuse` functions change their return type to a tuple. Every call site and every existing test (multistatic.rs and viewpoint/fusion.rs) must destructure. This is mechanical but touches ~25 test functions.
- **`CalibratedFrame` wrapper churn.** `fuse` no longer takes `&[MultiBandCsiFrame]` directly; callers must wrap, threading the ADR-135 calibration through. Uncalibrated nodes pass `calibration_id: None` and lose the `CalibrationApplied` evidence (but still fuse).
- **Per-frame allocation.** `evidence_refs` and `contradiction_flags` are `Vec`s. In the common clean-fuse case they hold 23 small `Copy` enums; the allocation is bounded but non-zero on the hot path. Mitigation: a `SmallVec` could be substituted if profiling shows pressure (deferred — not premature).
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Over-eager demotion: a benign loose timestamp at the guard edge demotes every frame to Restricted, suppressing identity features the deployment legitimately needs | Medium | Identity-risk scoring effectively disabled in a node array with marginal clock sync | `soft_guard_ns` is configurable (default `guard/5`); ADR-138's `ArrayCoordinator` clock-quality gating can raise the bar so timestamp contradictions only fire on genuinely degraded clocks |
| `DriftProfileConflict` false-positives when one node legitimately sees motion the others cannot (occlusion geometry) | Medium | Spurious privacy demotions in multi-room arrays with partial line-of-sight | Conflict requires a *majority* disagreement, not any single dissenting node; threshold tunable per deployment |
| Witness record volume: a flapping contradiction produces a witness record per cycle (20 Hz) | Low | Witness log growth | Coalesce identical consecutive contradiction sets; emit a witness record only on contradiction-set *transitions*, not every frame |
| `calibration_id` derivation collides for two devices recalibrated in the same second | Low | Two nodes appear to agree on calibration when they don't | `CalibrationId` is `hash(device_id, captured_at_unix_s)`, not the timestamp alone |
---
## 4. Alternatives Considered
### 4.1 Keep the scalar `cross_node_coherence`, add a separate log channel
Rejected. A side-channel log decouples the quality record from the frame it describes; a consumer cannot atomically obtain "this frame and exactly the evidence that produced it." The BFLD privacy decision must be made from the same data that produced the frame, in the same call. A `QualityScore` returned in the tuple guarantees that coupling; a log does not.
### 4.2 Boolean flags instead of evidence-carrying enums
Rejected. `passed_coherence: bool` cannot be reproduced in a witness record — the threshold and value are lost. ADR-135 and ADR-134 both made determinism-by-recorded-value a requirement of the proof chain (`expected_features.sha256`). A boolean breaks that chain. The enums carry the crossing value precisely so the witness hash is reproducible.
### 4.3 Hard-error on every contradiction (no graceful degradation)
Rejected. Promoting `CalibrationIdMismatch` and soft `TimestampMismatch` to fatal `MultistaticError`s would make the array brittle: any transient clock skew or mid-session recalibration would drop the entire fused frame. The whole point of the contradiction flag is that the fusion is *usable but not fully trusted* — degrade fidelity (privacy demote), don't drop data. The genuinely unfusable cases (spread beyond the guard, dimension mismatch) remain hard errors.
### 4.4 Put the demotion logic in the fuser, not the BFLD emitter
Rejected. The fuser produces evidence; it should not know the privacy policy. Privacy class ordering and the `Restricted` semantics live in `wifi-densepose-bfld` (`PrivacyClass`, `PrivacyGate`). Keeping the `max(Restricted)` decision in the emitter preserves the bounded-context separation: signal-processing crates compute *what is true and how confident*, the BFLD crate decides *what may be emitted*. The fuser exports a data struct; the emitter owns the policy.
### 4.5 Reuse `ViewpointFusionEvent` for evidence
Rejected. `ViewpointFusionEvent` (viewpoint/fusion.rs lines 183219) is an internal event-sourcing log for the `MultistaticArray` aggregate and exists only in the ruvector crate; it does not travel with the frame and is unknown to the signal-domain fuser or the BFLD crate. `QualityScore` is the shared, frame-attached contract both fusers and the privacy layer agree on.
---
## 5. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-029 (RuvSense Multistatic) | **Extended**: `MultistaticFuser::fuse` gains the `(FusedSensingFrame, QualityScore)` return; the attention/coherence machinery is unchanged but its byproducts are now surfaced |
| ADR-031 (Sensing-First RF Mode) | **Extended**: `MultistaticArray::fuse` adopts the same `QualityScore` contract; coherence-gate events are retained as control flow |
| ADR-118 (BFLD Beamforming Feedback Layer) | **Consumer**: the BFLD emitter reads `contradiction_flags` to demote `privacy_class`; reuses `PrivacyClass`, `PrivacyGate::demote`, and `BfldEvent::apply_privacy_gating` |
| ADR-134 (CSI→CIR) | **Evidence source + witness chain**: `EvidenceRef::CirDominantTapRatio` records `Cir::dominant_tap_ratio`; the contradiction witness record uses the ADR-134 `verify.py` proof schema |
| ADR-135 (Empty-Room Baseline Calibration) | **Prerequisite**: `CalibratedFrame.calibration_id` / `norm_gain` / `norm_phase_offset` come from `BaselineCalibration`; `CalibrationIdMismatch` and `DriftProfileConflict` are defined against ADR-135 calibration and drift_score |
| ADR-136 (RuView Streaming Engine) | **Contract**: `QualityScore` implements ADR-136's `QualityScored` trait so the streaming engine routes/gates uniformly on fusion quality |
| ADR-138 (LinkGroup / ArrayCoordinator Clock-Quality Gating) | **Refines contradiction sensitivity**: ArrayCoordinator clock quality informs the `soft_guard_ns` threshold so `TimestampMismatch` flags fire on genuinely degraded clocks, not on healthy WiFi-7 MLO arrays |
---
## 6. References
### Production Code
- `v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs``MultistaticFuser::fuse` (196282), `attention_weighted_fusion` (364435), `compute_weight_coherence` (441460), `cir_gate_coherence` (292327), `MultistaticError` (3656), `FusedSensingFrame` (6281)
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/fusion.rs``MultistaticArray::fuse` (358436), `FusedEmbedding` (5466), `ViewpointFusionEvent` (183219), `FusionError` (109136)
- `v2/crates/wifi-densepose-bfld/src/event.rs``BfldEvent` (2873), `with_privacy_gating` (79107), `apply_privacy_gating` (112117)
- `v2/crates/wifi-densepose-bfld/src/privacy_gate.rs``PrivacyGate::demote` (3175), monotonic demotion invariant
- `v2/crates/wifi-densepose-bfld/src/lib.rs``PrivacyClass` (8494), `as_u8` (114)
- `v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs``Cir` (265), `dominant_tap_ratio` (275), `CirEstimator::estimate` (380), `CirConfig::ht20` (164)
- `v2/crates/wifi-densepose-signal/src/ruvsense/multiband.rs``MultiBandCsiFrame` (4757), wrapped by `CalibratedFrame`
- `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs` (ADR-135) — `BaselineCalibration`, `CalibrationDeviationScore`, drift_score
- `archive/v1/data/proof/verify.py` — witness proof chain; `fusion_quality_check()` extension
- `archive/v1/data/proof/expected_features.sha256` — hash key `fusion_quality_contradiction_v1` to be added
### External
- Vaswani, A. et al. (2017). "Attention Is All You Need." *NeurIPS*. — softmax attention weighting reused in `attention_weighted_fusion`; `per_node_weights` is the attention distribution exposed for audit.
- Mardia, K.V. & Jupp, P.E. (2000). *Directional Statistics*. Wiley. — circular phase consensus underlying `PhaseAlignmentFailed` detection (sin/cos pooling in `attention_weighted_fusion`).
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `4fa3847ac`, issue #841): `QualityScore`, `EvidenceRef`, and the canonical `ContradictionFlag`; `MultistaticFuser::fuse_scored()` added additively (does not break `fuse()` or its callers). 6 tests.
**Integration glue -- not yet on the live path:** emission of `CalibrationIdMismatch` / `DriftProfileConflict` / `PhaseAlignmentFailed` once `calibration_id` propagation and the phase-align convergence signal are threaded onto frames; the BFLD witness record emitted on privacy demotion.
**Trust contribution:** sensor *agreement made explicit* -- fusion records the evidence it relied on, and any disagreement automatically tightens the downstream privacy class.
@@ -0,0 +1,530 @@
# ADR-138: WiFi-7 MLO LinkGroup Abstraction and ArrayCoordinator Clock-Quality Gating
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/multiband.rs`, `ruvsense/multistatic.rs`); `wifi-densepose-ruvector` (`viewpoint/geometry.rs`, `viewpoint/coherence.rs`, `viewpoint/attention.rs`, `viewpoint/fusion.rs`) |
| **Relates to** | ADR-008 (CSI Frame Primitives), ADR-029 (RuvSense Multistatic), ADR-030 (Persistent Field Model), ADR-031 (RuView Sensing-First RF Mode), ADR-110 (ESP32-C6 Firmware Extension / 802.15.4 sync), ADR-136 (RuView Rust Streaming Engine — frame contracts), ADR-137 (Fusion Engine Quality Scoring — evidence references and contradiction flags) |
---
## 1. Context
### 1.1 The Gap
Searching across the two named crates for `LinkGroup`, `ArrayCoordinator`, `clock_quality`, `DirectionalEvidence`, and `FreqSet` finds no production module. The pieces that an MLO-aware coordinator would compose all exist, but each is wired to a *single* CSI stream, a *single* clock domain, and emits a *hard fused output* rather than weighted evidence. Concretely:
- **`ruvsense/multiband.rs`** has `MultiBandCsiFrame { node_id, timestamp_us, channel_frames: Vec<CanonicalCsiFrame>, frequencies_mhz: Vec<u32>, coherence }` and a `MultiBandBuilder` that fuses per-channel rows from a *channel-hopping* radio (one ESP32-S3 cycling 1/6/11). This is the closest thing to a per-band feature stream, but it models **sequential** channel hopping on one radio, not **simultaneous** WiFi-7 Multi-Link Operation (MLO) where bands stream concurrently. There is no aggregate that tracks which bands are currently *live* versus which have *dropped out*, and `coherence` is a single Pearson scalar (`compute_cross_channel_coherence`), not an inter-band consensus with promotion semantics.
- **`ruvsense/multistatic.rs`** has `MultistaticFuser::fuse(&[MultiBandCsiFrame]) -> FusedSensingFrame`. It already validates a `guard_interval_us` timestamp spread (`MultistaticConfig.guard_interval_us`, default 5000 µs) and computes `geometric_diversity(&[[f32;3]])` from node positions. But: (a) the timestamp spread is a hard accept/reject — there is no notion of *clock quality* (a node whose clock is merely *uncertain* is treated identically to one whose clock is *good*); (b) `geometric_diversity()` is a free function returning a bare `f32`, not gated into the fusion decision; (c) the output `FusedSensingFrame` is a committed `fused_amplitude`/`fused_phase` pose-bearing artifact, not directional evidence with credence intervals.
- **`viewpoint/geometry.rs`** has `GeometricDiversityIndex::compute(azimuths, node_ids) -> Option<Self>` with `value`, `n_effective`, `worst_pair`, `is_sufficient()` (threshold `value >= PI/N`), plus `CramerRaoBound::estimate(target, &[ViewpointPosition]) -> Option<Self>` returning `crb_x`, `crb_y`, `rmse_lower_bound`, `gdop`. This is exactly the GDI + Cramér-Rao machinery this ADR needs to convert into a gate and into credence intervals — but nothing currently calls it from the multistatic path. The two `geometric_diversity` implementations (the `multistatic.rs` free function and the `geometry.rs` `GeometricDiversityIndex`) are unaware of each other.
- **`viewpoint/coherence.rs`** has `CoherenceState` (rolling phasor window with `push`/`coherence()`) and `CoherenceGate { threshold, hysteresis, evaluate() }`. The gate already implements hysteresis and a duty cycle. But it gates **only on phase coherence** — there is no clock-quality term, and no "contradiction" notion: a coherence drop merely closes the gate, it does not demote a band/group to monitoring-only nor flag the contradiction for downstream.
- **`viewpoint/fusion.rs`** has `MultistaticArray` (the DDD aggregate root) with `submit_viewpoint`, `push_phase_diff`, `fuse() -> FusedEmbedding`, `compute_gdi()`, and a `ViewpointFusionEvent` enum (`ViewpointCaptured`, `TdmCycleCompleted`, `FusionCompleted`, `CoherenceGateTriggered`, `GeometryUpdated`). `fuse()` already filters by SNR and gates on coherence, returning `FusionError::CoherenceGateClosed` when the environment is unstable. But the aggregate is keyed on **embeddings** (AETHER 128-d vectors) and produces a **pose-feeding `FusedEmbedding`** — there is no per-band lifecycle, no clock-quality input, and the "gate closed" path silently drops the cycle rather than demoting to a monitoring-only state that still emits evidence.
- **`wifi-densepose-hardware/src/sync_packet.rs`** is fully implemented: `SyncPacket` decodes the ADR-110 §A0.12 wire format (magic `0xC511A110`, 32 bytes LE), exposes `local_minus_epoch_us()`, `apply_to_local()`, and `mesh_aligned_us_for_sequence(frame_seq, fps_hz)`. The sensing server (`wifi-densepose-sensing-server/src/main.rs`) already dispatches on `SYNC_PACKET_MAGIC` and applies a 9-second staleness gate (`mesh_aligned_us_for_csi_frame`). What is missing: a **clock-quality score** derived from the sync stream (offset dispersion / leader-vs-follower / staleness) that the *signal-domain* fusion can consult. The hardware crate recovers `mesh_aligned_us` but never propagates a *quality* of that alignment into `multistatic.rs` or `viewpoint/`.
The consequence: the array treats every node as if its clock were perfect and its geometry adequate, and it commits to a fused pose even when (a) only one MLO band survived, (b) the contributing nodes are clustered (low GDI), or (c) a node's clock has drifted past the point where its phase is comparable to its peers. ADR-137 (sibling, Proposed) requires every fused output to carry **evidence references and contradiction flags**; ADR-136 (sibling, Proposed) defines the `FrameMeta` frame contract that should carry `mesh_aligned_us` and clock metadata per frame. This ADR supplies the missing middle: a lifetime-managed `LinkGroup` that knows which bands are live, and an `ArrayCoordinator` service that gates on geometry *and* clock quality and emits `DirectionalEvidence` instead of a hard decision.
### 1.2 What "LinkGroup" and "ArrayCoordinator" Mean Here
- A **LinkGroup** is a lifetime-managed aggregate representing one *physical link* operating WiFi-7 MLO: a set of concurrent bands (2.4 / 5 / 6 GHz) that the radio streams simultaneously, each producing its own `CanonicalCsiFrame`. The LinkGroup wraps a `FreqSet` (the declared band membership) plus a rolling `Vec<MultiBandCsiFrame>` per band, and tracks **band lifecycle** — a band can `enter` (start streaming), `exit` (drop out, e.g. 6 GHz lost when the AP reboots), and be `promoted` to the consensus set once it agrees with its peers. This is distinct from today's `MultiBandCsiFrame`, which is a *snapshot* of one hop cycle with no membership lifecycle.
- An **ArrayCoordinator** is a **service** (not an aggregate). It consumes a set of `LinkGroup`s plus the per-node frames already modelled by `multistatic.rs`, applies two gates — a **geometry gate** (GDI / Cramér-Rao from `viewpoint/geometry.rs`) and a **clock-quality gate** (ADR-110 sync dispersion) — and returns `DirectionalEvidence`: attention weights per viewpoint plus credence intervals derived from the Cramér-Rao bound. It does **not** decide pose. The pose/semantic decision is downstream (ADR-137 fusion-engine quality scoring); the coordinator only says "here is what the array can and cannot see right now, and how much to trust each direction."
### 1.3 Why Not a Single Hard Gate
The existing `CoherenceGate::evaluate()` and `MultistaticConfig.guard_interval_us` are both **binary**: update / no-update, accept / reject. WiFi-7 MLO and multi-node arrays degrade *gracefully* — losing the 6 GHz band, or a node whose clock dispersion rose from 40 µs to 180 µs, does not invalidate the array; it narrows what it can resolve and widens the credence interval. A hard gate throws away usable evidence. The decision below replaces the binary gates with a **graded** coordinator output that downgrades rather than discards, and feeds the graded result into ADR-137's contradiction machinery.
### 1.4 Pipeline Position
```
Per-band CSI (MLO: 2.4 / 5 / 6 GHz concurrent)
→ multiband.rs MultiBandBuilder (per-band CanonicalCsiFrame rows)
→ LinkGroup::ingest() ← NEW (band enter/exit + consensus promote)
→ ArrayCoordinator::coordinate() ← NEW (service: GDI gate + clock-quality gate)
│ consumes: Vec<LinkGroup>, node_frames, Vec<SyncPacket> (ADR-110)
│ uses: GeometricDiversityIndex + CramerRaoBound (viewpoint/geometry.rs)
│ ClockQualityGate ← NEW (wraps viewpoint/coherence.rs CoherenceGate)
→ DirectionalEvidence ← NEW (attention weights + credence intervals)
→ multistatic.rs MultistaticFuser.fuse() (consumes weights, NOT a re-decision)
→ ADR-137 FusionEngine quality scoring + contradiction flags
```
The coordinator sits *between* per-band ingestion and the existing `MultistaticFuser`. It does not replace `fuse()`; it supplies the weights `fuse()` already wants (today `attention_weighted_fusion` derives them internally from amplitude similarity only) and the contradiction flags ADR-137 consumes.
---
## 2. Decision
### 2.1 `LinkGroup`: Lifetime-Managed MLO Aggregate
A `LinkGroup` is added to `ruvsense/multiband.rs` (it composes the existing `MultiBandCsiFrame` and `CanonicalCsiFrame`). It is an aggregate with explicit band lifecycle, not a snapshot.
```rust
use crate::hardware_norm::CanonicalCsiFrame;
/// The declared set of MLO bands a link operates on (WiFi-7: up to 3).
/// Membership is *declared* at construction; liveness is tracked separately.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FreqSet {
/// Center frequencies (MHz), sorted ascending. e.g. [2412, 5180, 5955].
pub bands_mhz: Vec<u32>,
}
impl FreqSet {
pub fn new(mut bands_mhz: Vec<u32>) -> Self {
bands_mhz.sort_unstable();
bands_mhz.dedup();
Self { bands_mhz }
}
pub fn contains(&self, freq_mhz: u32) -> bool { self.bands_mhz.contains(&freq_mhz) }
pub fn len(&self) -> usize { self.bands_mhz.len() }
pub fn is_empty(&self) -> bool { self.bands_mhz.is_empty() }
}
/// Lifecycle state of one band within a LinkGroup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BandState {
/// Declared in the FreqSet but no frame seen yet (warm-up).
Pending,
/// Streaming frames, but not yet agreeing with peers.
Live,
/// Live AND consensus-promoted: agrees with the group's other live bands.
Promoted,
/// Was Live, has missed `exit_after_missed` expected frames.
Exited,
}
/// Domain events emitted by a LinkGroup (event-sourced state changes, per house rule).
#[derive(Debug, Clone, PartialEq)]
pub enum LinkGroupEvent {
BandEntered { freq_mhz: u32, at_us: u64 },
BandExited { freq_mhz: u32, at_us: u64, missed: u32 },
BandPromoted { freq_mhz: u32, at_us: u64, consensus: f32 },
BandDemoted { freq_mhz: u32, at_us: u64, reason: DemotionReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DemotionReason {
/// Inter-band consensus dropped below threshold.
ConsensusLoss,
/// Coherence fell >2σ from the rolling mean (contradiction; §2.5).
CoherenceContradiction,
}
#[derive(Debug, thiserror::Error)]
pub enum LinkGroupError {
#[error("Frequency {freq_mhz} MHz is not a member of this LinkGroup's FreqSet")]
UnknownBand { freq_mhz: u32 },
#[error("Subcarrier count mismatch on band {freq_mhz}: expected {expected}, got {got}")]
SubcarrierMismatch { freq_mhz: u32, expected: usize, got: usize },
}
/// A WiFi-7 MLO physical link: a FreqSet plus per-band feature streams with
/// explicit enter/exit and consensus-promotion lifecycle.
///
/// # Concurrency
/// Requires `&mut self` for `ingest()`; not `Sync`. One ingest loop per link.
#[derive(Debug)]
pub struct LinkGroup {
node_id: u8,
freq_set: FreqSet,
/// Most recent frame per band, indexed parallel to `freq_set.bands_mhz`.
latest: Vec<Option<CanonicalCsiFrame>>,
/// Lifecycle state per band (parallel to `freq_set.bands_mhz`).
state: Vec<BandState>,
/// Rolling per-band inter-band consensus score (Pearson vs. the group mean).
consensus: Vec<f32>,
/// Frame count per band since last seen, for exit detection.
missed: Vec<u32>,
/// Config: promote/exit thresholds.
config: LinkGroupConfig,
/// Pending domain events (drained by the ArrayCoordinator).
events: Vec<LinkGroupEvent>,
}
#[derive(Debug, Clone)]
pub struct LinkGroupConfig {
/// Pearson consensus required to promote a Live band to Promoted. Default 0.6.
pub promote_consensus: f32,
/// Consecutive missed expected frames before a Live band Exits. Default 5.
pub exit_after_missed: u32,
}
impl Default for LinkGroupConfig {
fn default() -> Self { Self { promote_consensus: 0.6, exit_after_missed: 5 } }
}
impl LinkGroup {
pub fn new(node_id: u8, freq_set: FreqSet, config: LinkGroupConfig) -> Self;
/// Ingest one band's frame. Marks the band Live (emitting BandEntered on the
/// first frame), recomputes inter-band consensus against the current live
/// mean, promotes/demotes per thresholds, and ages out unseen bands toward
/// Exited. Bands not in `freq_set` are rejected with `UnknownBand`.
pub fn ingest(&mut self, freq_mhz: u32, frame: CanonicalCsiFrame, at_us: u64)
-> Result<(), LinkGroupError>;
/// Bands currently in the consensus (Promoted) set.
pub fn promoted_bands(&self) -> Vec<u32>;
/// Build a MultiBandCsiFrame from the currently Promoted bands only.
/// Returns None if fewer than 1 band is Promoted.
pub fn consensus_frame(&self, at_us: u64) -> Option<MultiBandCsiFrame>;
/// Drain pending domain events (the ArrayCoordinator forwards these to ADR-137).
pub fn drain_events(&mut self) -> Vec<LinkGroupEvent>;
}
```
Inter-band consensus reuses the existing `pearson_correlation_f32` already in `multiband.rs` (private today; promoted to `pub(crate)`). The `consensus_frame()` output is intentionally a `MultiBandCsiFrame`, so the existing `MultistaticFuser` consumes it unchanged.
**Why an aggregate, not a snapshot.** MLO band membership is *stateful*: the 6 GHz band dropping for 250 ms and returning is a different physical situation from a node permanently losing 6 GHz. A snapshot (`MultiBandCsiFrame`) cannot represent "this band exited and we are now operating degraded." The lifecycle (`Pending → Live → Promoted`, with `→ Exited` and `→ Demoted` transitions) is the minimum state required to (a) feed graceful degradation into the coordinator and (b) emit the band-level contradiction events ADR-137 wants.
### 2.2 `ClockQualityScore` and the Clock-Quality Gate
A clock-quality term is derived from the ADR-110 `SyncPacket` stream and folded into a gate alongside the existing phase-coherence gate. The score lives in `viewpoint/coherence.rs` next to `CoherenceState`/`CoherenceGate`.
```rust
/// Per-node clock-quality summary derived from the ADR-110 sync stream.
///
/// All fields are computed by the host from the `SyncPacket` series for one
/// node (`wifi_densepose_hardware::sync_packet::SyncPacket`).
#[derive(Debug, Clone, Copy)]
pub struct ClockQualityScore {
/// EMA stdev of (local_us - epoch_us) over the recent sync window (µs).
/// This is the dispersion of the node's mesh-alignment offset.
pub offset_stdev_us: f32,
/// 802.15.4 stratum: 0 = leader, 1 = direct follower, etc.
pub stratum: u8,
/// Age of the most recent valid SyncPacket (µs); large = stale.
pub age_us: u64,
/// Whether the most recent packet had flags.is_valid set.
pub valid: bool,
}
impl ClockQualityScore {
/// Normalised quality in [0, 1]: 1.0 = leader-grade, 0.0 = unusable.
/// Combines offset dispersion (vs. the ADR-110 ±100 µs target), stratum
/// penalty, and staleness. 0.0 if `!valid`.
pub fn quality(&self) -> f32;
/// Convenience: the ADR-110 ±100 µs sync target as a hard usability floor.
/// `offset_stdev_us < 200.0` (2× the target) is the gate's default accept.
pub const SYNC_TARGET_US: f32 = 100.0;
}
/// Gate that admits a node's frames into directional fusion only when both
/// its phase coherence AND its clock quality are adequate. Wraps the existing
/// `CoherenceGate` (phase term) and adds the clock term.
#[derive(Debug, Clone)]
pub struct ClockQualityGate {
/// Existing phase-coherence gate (unchanged semantics).
pub coherence: CoherenceGate,
/// Reject when offset_stdev_us >= this. Default 200.0 (2× ADR-110 target).
pub max_offset_stdev_us: f32,
/// Reject when sync age exceeds this. Default 9_000_000 (the sensing-server
/// 9-second staleness gate already used in main.rs).
pub max_age_us: u64,
}
impl ClockQualityGate {
pub fn new(coherence: CoherenceGate, max_offset_stdev_us: f32, max_age_us: u64) -> Self;
pub fn default_params() -> Self {
Self::new(CoherenceGate::default_params(), 200.0, 9_000_000)
}
/// Evaluate both terms. Returns the gate decision for one node this cycle.
/// `coherence_value` is the rolling phasor coherence (CoherenceState::coherence()).
pub fn evaluate(&mut self, coherence_value: f32, clock: &ClockQualityScore)
-> ClockGateDecision;
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClockGateDecision {
/// Both terms pass: node admitted at full weight.
Admit,
/// Phase OK but clock degraded: admit at reduced weight (monitoring-only;
/// frame contributes to evidence but NOT to model/environment update).
MonitorOnly { clock_quality: f32 },
/// Either term fails hard: node excluded this cycle.
Reject { reason: ClockRejectReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClockRejectReason { Incoherent, ClockStale, ClockDispersed, ClockInvalid }
```
**Why a 200 µs default floor.** ADR-110 §A0.10 measured the COM9↔COM12 follower offset stdev at ~104 µs after EMA smoothing, against the ±100 µs 802.15.4 target. A node whose dispersion has risen to 2× the measured baseline (200 µs) has lost roughly one phase wrap of cross-node comparability at 5 GHz (wavelength ≈ 5 cm; 200 µs of clock skew at sensing motion velocities corrupts the inter-node phase term that `attention_weighted_fusion` relies on). Below 200 µs the node is admitted; between 200 µs and the staleness ceiling it is `MonitorOnly` (evidence yes, environment update no); above the 9 s age ceiling — the same staleness gate the sensing server already enforces (`main.rs::mesh_aligned_us_honors_9s_staleness_gate`) — it is rejected.
**Why gate environment updates specifically.** The clock term must not block *evidence emission* — a clock-degraded node still sees real motion and should contribute weighted evidence. It must block *environment/model updates* (ADR-030 field model, ADR-031 model update path), because those updates assume cross-node phase comparability that a dispersed clock breaks. `MonitorOnly` encodes exactly this: contribute to `DirectionalEvidence`, do not promote to a model/environment change. This mirrors the existing `CoherenceGate` semantics ("only allow model updates when coherence exceeds threshold") and extends them with the clock dimension.
### 2.3 `ArrayCoordinator`: a Service, Not an Aggregate
`ArrayCoordinator` is added to `viewpoint/fusion.rs` alongside `MultistaticArray`. It holds no long-lived domain state of its own (the lifecycle state lives in the `LinkGroup`s and `MultistaticArray`); it is a stateless-per-call **domain service** that applies gates and projects evidence.
```rust
use crate::viewpoint::geometry::{GeometricDiversityIndex, CramerRaoBound, ViewpointPosition, NodeId};
use crate::viewpoint::coherence::{ClockQualityGate, ClockQualityScore, ClockGateDecision};
/// Directional evidence: what the array can resolve right now, and how much to
/// trust each direction. This is the coordinator's output — NOT a pose decision.
///
/// Per the house rule that every semantic state traces to evidence, this struct
/// carries the geometry + clock provenance that ADR-137 attaches to any state
/// it derives downstream.
#[derive(Debug, Clone)]
pub struct DirectionalEvidence {
/// Per-viewpoint attention weight (softmax, sums to 1.0 over admitted nodes).
pub weights: Vec<(NodeId, f32)>,
/// Geometric Diversity Index at evaluation time.
pub gdi: GeometricDiversityIndex,
/// Cramér-Rao credence interval: RMSE lower bound (m) for a centroid target.
/// `None` when fewer than 3 admitted viewpoints (under-determined).
pub credence_rmse_m: Option<f32>,
/// Per-node gate decisions (Admit / MonitorOnly / Reject) — the audit trail.
pub gate_decisions: Vec<(NodeId, ClockGateDecision)>,
/// Contradiction flags forwarded to ADR-137 (see §2.5).
pub contradictions: Vec<ContradictionFlag>,
/// Number of viewpoints admitted at full weight (Admit).
pub n_admitted: usize,
/// Number admitted MonitorOnly (evidence-only, no environment update).
pub n_monitoring: usize,
}
// `ContradictionFlag` is NOT redefined here. It is the canonical enum owned by
// ADR-137 §2.3 (`wifi-densepose-signal::ruvsense::multistatic`). The coordinator
// imports it and emits only its array-origin variants:
//
// use wifi_densepose_signal::ruvsense::multistatic::ContradictionFlag;
//
// ContradictionFlag::CoherenceDrop { node_idx, sigma } // coherence > Nσ off rolling mean
// ContradictionFlag::GeometryInsufficient { gdi } // array GDI below the floor
//
// A previously-Promoted band being demoted (inter-band disagreement) is surfaced
// through the per-node `gate_decisions` audit trail above, not as a contradiction
// flag — it suppresses the model update without contradicting the observation.
// `NodeId` → `node_idx` resolution happens at the ADR-137 hand-off (ADR-137 §2.3).
#[derive(Debug, Clone)]
pub struct ArrayCoordinatorConfig {
/// Per-node clock+coherence gate.
pub gate: ClockQualityGate,
/// σ multiple defining a coherence contradiction. Default 2.0.
pub contradiction_sigma: f32,
/// Per-measurement noise std (m) for the Cramér-Rao credence estimate.
pub crb_noise_std_m: f32,
/// Attention temperature for the directional weight softmax. Default 1.0.
pub attention_temperature: f32,
}
/// Domain service: gates LinkGroups + node frames on geometry and clock quality,
/// returns DirectionalEvidence. Holds NO aggregate state.
pub struct ArrayCoordinator {
config: ArrayCoordinatorConfig,
}
impl ArrayCoordinator {
pub fn new(config: ArrayCoordinatorConfig) -> Self;
/// The single service operation. For each node:
/// 1. Take its LinkGroup consensus frame (Promoted bands only).
/// 2. Evaluate the clock-quality gate (coherence × clock).
/// 3. Admit / MonitorOnly / Reject.
/// Then over the admitted set:
/// 4. Compute GDI (geometry.rs); raise GeometryInsufficient if !is_sufficient().
/// 5. Compute Cramér-Rao credence RMSE for a centroid target.
/// 6. Build attention weights (softmax over admitted nodes, biased by clock
/// quality and inverse-CRB so well-placed, well-clocked nodes weigh more).
/// 7. Collect contradiction flags from LinkGroup demotions + coherence drops.
///
/// `coherence_per_node` and `clock_per_node` are parallel to `viewpoints`.
pub fn coordinate(
&mut self,
viewpoints: &[(NodeId, f32 /*azimuth*/, ViewpointPosition)],
coherence_per_node: &[f32],
clock_per_node: &[ClockQualityScore],
link_events: &[LinkGroupEventRef],
) -> DirectionalEvidence;
}
```
The coordinator deliberately reuses, not reimplements:
- `GeometricDiversityIndex::compute` + `is_sufficient()` for the geometry gate.
- `CramerRaoBound::estimate` for the credence interval (its `rmse_lower_bound` *is* the credence radius).
- `ClockQualityGate::evaluate` for the per-node admit/monitor/reject decision.
- The softmax shape from `multistatic.rs::attention_weighted_fusion` (numerically stable, subtract-max), but biased by clock quality and inverse-CRB rather than amplitude-cosine alone.
**Why a service rather than folding this into `MultistaticArray`.** `MultistaticArray` is the *aggregate root* for ViewpointFusion — it owns embedding lifecycle and the coherence window. The coordinator's job spans *multiple* aggregates (every node's `LinkGroup` plus the array) and is *read-mostly*: it inspects state and projects evidence, but the authoritative state transitions (band promotion, viewpoint upsert) belong to the aggregates. Putting cross-aggregate gating logic in a stateless service keeps the aggregate boundaries clean (DDD) and makes the coordinator trivially testable with synthetic inputs.
### 2.4 Wiring the ADR-110 SyncPacket Decoder Into the Pipeline
Today `SyncPacket` is decoded in `wifi-densepose-sensing-server/src/main.rs` and used only to recover `mesh_aligned_us`. This ADR widens that path so the recovered alignment carries a *quality*:
1. The sensing server already keeps `NodeState::latest_sync: Option<SyncPacket>` and `latest_sync_at: Option<Instant>`. Add a rolling buffer `NodeState::sync_offsets: VecDeque<i64>` of the last N `local_minus_epoch_us()` values and an EMA. From these, build a `ClockQualityScore { offset_stdev_us, stratum, age_us, valid }` per node per cycle.
- `stratum` is derived from `SyncPacketFlags::is_leader` (leader = 0, follower = 1; deeper strata are reserved).
- `age_us` is `now - latest_sync_at` in the mesh domain.
- `valid` is `latest_sync.flags.is_valid`.
2. Per ADR-136, the per-frame `FrameMeta` contract gains `mesh_aligned_us: Option<u64>` and `clock_quality: Option<ClockQualityScore>`, populated at frame ingestion by pairing `(node_id, sequence)` against the most recent `SyncPacket` (exactly the pairing `mesh_aligned_us_for_sequence` already implements). This keeps the *signal* crates free of any UDP/socket dependency — they receive `FrameMeta`, not raw packets.
3. The `ArrayCoordinator::coordinate()` call receives `clock_per_node: &[ClockQualityScore]` extracted from those `FrameMeta` records. No new socket code lands in `wifi-densepose-signal` or `wifi-densepose-ruvector`; the hardware crate remains the only owner of the wire format (`SYNC_PACKET_MAGIC = 0xC511A110`).
This preserves the existing crate dependency direction: hardware → (FrameMeta) → signal/ruvector. The coordinator never imports `wifi-densepose-hardware`; it sees only the `ClockQualityScore` value object.
### 2.5 Contradiction-to-Environment-Change Semantics
The coordinator converts two array-level conditions into ADR-137 contradiction flags, and uses them to demote rather than to commit:
- **Coherence drop > 2σ.** Each node's `CoherenceState` already maintains a rolling phasor coherence. The coordinator additionally tracks a rolling mean/std of that coherence per node (Welford, consistent with ADR-135's reuse of `WelfordStats`). When the current coherence falls more than `contradiction_sigma` (default 2.0) below the rolling mean, the coordinator (a) raises `ContradictionKind::CoherenceDrop { magnitude }`, and (b) the node's `ClockQualityGate` returns at most `MonitorOnly` for that cycle — its frame contributes evidence but cannot trigger an environment/model update. This is the signal-domain analogue of `LinkGroupEvent::BandDemoted { reason: CoherenceContradiction }`.
- **GDI below the sufficiency floor.** `GeometricDiversityIndex::is_sufficient()` already encodes the `value >= (2π/N) × 0.5` floor. When the admitted set's GDI is insufficient, the coordinator raises `ContradictionKind::GeometryInsufficient { magnitude: gdi.value }` and widens the credence interval (the Cramér-Rao `rmse_lower_bound` already grows automatically as geometry degrades, so this flag is advisory for ADR-137, not a separate widening).
A `LinkGroup` band demotion (`BandDemoted`) is forwarded verbatim as `ContradictionKind::BandDemoted`. In all three cases the rule is identical and is the core of this ADR: **a contradiction demotes to monitoring-only; it never forces an environment change.** Only a sustained *consensus* (admitted nodes agreeing across a window) promotes an environment update — and that promotion is owned downstream by ADR-137, which receives the coordinator's `DirectionalEvidence` complete with its contradiction list.
### 2.6 Provenance / Evidence Tracing
Per the project rule that every semantic state traces to signal evidence + model version + calibration version + privacy decision, the `DirectionalEvidence` struct is designed as the *evidence* half of that chain:
- **Signal evidence**: the per-node `weights` and `gate_decisions` are the audit trail of which viewpoints (and which MLO bands, via the `LinkGroup` consensus) contributed and how much.
- **Calibration version**: when an ADR-135 `BaselineCalibration` is loaded for a node, its `captured_at_unix_s`/device id flow through `FrameMeta`; the coordinator does not re-derive calibration but passes it through so ADR-137 can stamp it.
- **Model / privacy version**: these are not the coordinator's concern (it makes no model inference and no privacy decision); ADR-137 attaches `model_version` and the active privacy decision when it consumes `DirectionalEvidence`. The coordinator's contract is to make the evidence and contradiction set *complete enough* that ADR-137 can construct the full provenance tuple without re-reading raw frames.
### 2.7 Downstream Consumers and Interface Boundaries
| Consumer | What it receives | Change required |
|----------|-----------------|-----------------|
| `multistatic.rs::MultistaticFuser::fuse()` | `DirectionalEvidence.weights` instead of internally-derived amplitude-cosine weights | `MultistaticConfig` gains `external_weights: Option<Vec<(u8, f32)>>`; when present, `attention_weighted_fusion` uses them rather than recomputing. Backward compatible (`None` = today's behaviour). |
| `multiband.rs::MultiBandBuilder` | Unchanged; `LinkGroup::consensus_frame()` produces a `MultiBandCsiFrame` it already understands | No change to `MultiBandBuilder`; `pearson_correlation_f32` promoted to `pub(crate)` for `LinkGroup` reuse |
| `viewpoint/fusion.rs::MultistaticArray` | Coordinator runs *before* `fuse()`; the `CoherenceGateClosed` path is replaced by `MonitorOnly` evidence | New `ViewpointFusionEvent::DirectionalEvidenceEmitted { gdi, n_admitted, n_monitoring }`; `fuse()` no longer hard-drops on closed coherence — it returns evidence with zero admitted nodes |
| `viewpoint/geometry.rs` | Called by the coordinator (`GeometricDiversityIndex`, `CramerRaoBound`) | No API change; the existing `is_sufficient()` and `rmse_lower_bound` are exactly the gate/credence primitives |
| `viewpoint/coherence.rs` | Hosts the new `ClockQualityScore` / `ClockQualityGate` next to `CoherenceGate` | New types added; existing `CoherenceGate`/`CoherenceState` unchanged and reused as the phase term |
| ADR-137 FusionEngine | `DirectionalEvidence` (weights + credence + `contradictions`) | The coordinator is ADR-137's upstream; `ContradictionFlag` is the agreed hand-off type |
| ADR-136 streaming engine | Populates `FrameMeta.mesh_aligned_us` + `clock_quality` | The coordinator reads these from `FrameMeta`; ADR-136 owns the frame contract |
**Interface boundary statement.** The coordinator's only inputs are value objects (`ViewpointPosition`, `f32` coherence, `ClockQualityScore`, `LinkGroupEventRef`); its only output is the `DirectionalEvidence` value object. It imports from `viewpoint::geometry` and `viewpoint::coherence` within the same crate, and is invoked by the sensing server / streaming engine which assemble the inputs. It does **not** import `wifi-densepose-hardware`, does **not** touch sockets, and does **not** make pose or privacy decisions.
### 2.8 Test Plan / Acceptance Criteria
**T1 — LinkGroup band lifecycle (unit).** Construct a `LinkGroup` with `FreqSet::new(vec![2412, 5180, 5955])`. Ingest 2.4 + 5 GHz frames that correlate (consensus > 0.6) for 10 cycles; ingest 6 GHz frames that do not. Assert: 2.4 and 5 GHz reach `BandState::Promoted` (emitting `BandPromoted`); 6 GHz stays `Live`; `promoted_bands() == [2412, 5180]`; `consensus_frame()` yields a 2-band `MultiBandCsiFrame`.
**T2 — Band exit and re-entry (unit).** With the same group, stop feeding 6 GHz for `exit_after_missed` (5) cycles → assert `BandExited` emitted and state `Exited`. Resume 6 GHz → assert `BandEntered` emitted and state returns to `Live`.
**T3 — Clock-quality gate thresholds (unit).** Build `ClockQualityScore`s: (a) `offset_stdev_us = 50, valid = true, age_us = 1_000_000``quality() > 0.8` and gate `Admit`; (b) `offset_stdev_us = 250` (> 200 floor) but coherent → gate `MonitorOnly`; (c) `age_us = 10_000_000` (> 9 s) → gate `Reject { ClockStale }`; (d) `valid = false``Reject { ClockInvalid }` and `quality() == 0.0`.
**T4 — ArrayCoordinator geometry gate + credence (unit).** Four nodes at the corners of a 5×5 m room (reuse `geometry.rs::gdi_four_corners` layout), all `Admit`. Assert: `gdi.is_sufficient()`; `credence_rmse_m` is `Some` and decreases when a 5th well-placed node is added (mirrors `crb_decreases_with_more_viewpoints`); `weights` sum to 1.0; `n_admitted == 4`.
**T5 — Clustered nodes raise GeometryInsufficient (unit).** Four nodes clustered within 0.12 rad (reuse `gdi_clustered_viewpoints_have_low_value`). Assert `ContradictionKind::GeometryInsufficient` present and `credence_rmse_m` is much larger than T4.
**T6 — Coherence-drop contradiction demotes, not decides (unit).** Feed one node a stable coherence (~0.8) for 30 cycles to seed the rolling mean, then a single 0.2 coherence (> 2σ drop). Assert: `ContradictionKind::CoherenceDrop` raised for that node; its gate decision is at most `MonitorOnly`; the node still appears in `weights` (evidence preserved); `n_monitoring >= 1`.
**T7 — SyncPacket → ClockQualityScore (unit, hardware crate test reuse).** Using the canonical COM9 follower packet from `sync_packet.rs` (`local_minus_epoch_us() == 1_163_565`) and the COM12 leader packet, build offset series and assert: leader → `stratum == 0`, high `quality()`; follower with low dispersion → `Admit`. Assert no `wifi-densepose-hardware` symbol leaks into the coordinator's public API (compile-fence test).
**T8 — Determinism proof (CI-compatible, extends ADR-028 chain).** Drive a fixed synthetic 3-band, 4-node scenario through `LinkGroup::ingest``ArrayCoordinator::coordinate`, serialise `DirectionalEvidence.weights` (rounded to f32) and the sorted contradiction kinds, and SHA-256 the result. Record under `archive/v1/data/proof/expected_features.sha256` as `array_coordinator_evidence_v1`; `verify.py` regenerates and asserts the hash.
**Acceptance gate**: `cargo test -p wifi-densepose-signal -p wifi-densepose-ruvector --no-default-features` passes all of T1T8; no new `unsafe`; the coordinator's public API contains no type from `wifi-densepose-hardware`.
---
## 3. Consequences
### 3.1 Positive
- **Graceful MLO degradation.** Losing the 6 GHz band narrows resolution and widens the credence interval rather than invalidating the link. The `LinkGroup` lifecycle makes "degraded but operating" a first-class state instead of an undetected silent failure.
- **Clock quality becomes observable and actionable.** Today a drifting node is treated identically to a good one until it crosses the 9 s staleness cliff. The `ClockQualityScore` exposes the *continuum*, and `MonitorOnly` lets a clock-degraded node still contribute evidence without corrupting environment updates.
- **Evidence, not premature decisions.** The coordinator emits `DirectionalEvidence` with attention weights and Cramér-Rao credence intervals, giving ADR-137 the provenance it needs and removing the hard `CoherenceGateClosed` drop that currently discards usable cycles.
- **Reuse over reinvention.** GDI, Cramér-Rao, coherence gate, sync-packet decode, and Pearson consensus already exist and are tested; this ADR composes them. The two duplicate `geometric_diversity` notions converge on `viewpoint/geometry.rs`.
- **Clean crate boundaries preserved.** No socket or wire-format code enters the signal/ruvector crates; the `FrameMeta` contract (ADR-136) is the only coupling point.
### 3.2 Negative
- **More state to manage.** `LinkGroup` adds per-band lifecycle state and an event buffer. For a 4-node, 3-band array that is 12 band state machines plus the coordinator — modest, but non-zero, and the events must be drained or they accumulate (bounded like `MultistaticArray::max_events`).
- **Two gates instead of one.** Operators and tests must reason about coherence *and* clock quality. The `MonitorOnly` middle state, while useful, is a third outcome that downstream code (ADR-137) must handle explicitly rather than a simple boolean.
- **Depends on sibling ADRs not yet landed.** `FrameMeta` (ADR-136) and the contradiction-consumer (ADR-137) are both Proposed. Until they land, the coordinator can be tested with synthetic `ClockQualityScore`s but cannot be wired end-to-end. The `mesh_aligned_us` plumbing exists today only in the sensing server, not in a shared `FrameMeta`.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| `offset_stdev_us` is noisy on small sync windows, causing gate flapping between Admit/MonitorOnly | Medium | Weights jitter cycle-to-cycle | Use the `CoherenceGate` hysteresis pattern for the clock term too: open at 200 µs, close only above 240 µs; EMA the offset series (the firmware already EMA-smooths, per `smoothed_used` flag) |
| Inter-band consensus false-demotes a band that is genuinely seeing a different multipath (legitimately decorrelated across 2.4 vs 6 GHz) | Medium | A useful band drops out of consensus | `promote_consensus` default 0.6 is deliberately lenient; band frequency-dependent decorrelation is expected, so demotion requires sustained loss, and a demoted band still streams (it is not Exited) |
| Cramér-Rao credence assumes a centroid target; a real target off-centroid has a different bound | Low | Credence interval mildly optimistic/pessimistic off-centre | Documented as a centroid-referenced bound; ADR-137 may recompute per-hypothesis if it needs target-specific credence |
| ADR-136 `FrameMeta` shape changes during its own design, breaking the `clock_quality` field | Medium | Re-plumb the coordinator's input extraction | Coordinator consumes a `ClockQualityScore` value object, not `FrameMeta` directly; only the thin extraction adapter changes |
---
## 4. Alternatives Considered
### 4.1 Extend `MultiBandCsiFrame` In Place Instead of a New `LinkGroup`
Rejected. `MultiBandCsiFrame` is a value-type snapshot consumed throughout `multistatic.rs` and the sensing server; bolting mutable band-lifecycle state onto it would break its `Clone`-cheap, pass-by-value contract and entangle every consumer with lifecycle logic. A separate aggregate that *produces* `MultiBandCsiFrame` via `consensus_frame()` keeps the snapshot type immutable and the lifecycle isolated.
### 4.2 Make `ArrayCoordinator` Part of `MultistaticArray`
Rejected. `MultistaticArray` is an aggregate root with a single-aggregate invariant boundary (its viewpoints, its coherence window). Cross-aggregate gating that reads every node's `LinkGroup` belongs in a domain service, not inside an aggregate — folding it in would force the aggregate to hold references to other aggregates, violating DDD boundaries and making it untestable in isolation. The service is stateless-per-call and trivially unit-testable.
### 4.3 Keep the Binary Coherence Gate, Add Clock as a Second Binary Gate
Rejected. Two ANDed binary gates still throw away graded information: a node that is 90% coherent with a 210 µs clock would be hard-rejected, discarding real evidence. The `MonitorOnly` middle state is the whole point — it admits the evidence while withholding the environment update. A pure binary design cannot express "trust this for motion evidence but not for re-learning the room."
### 4.4 Derive Clock Quality on the ESP32 and Ship a Single Byte
Rejected for now. The ESP32 firmware already computes the EMA offset (the `smoothed_used` flag), and shipping a pre-computed quality byte would save host work. But the host has the *full* offset series across all nodes and can compute a *comparative* stratum and dispersion the single node cannot. Per-node self-assessment also cannot detect a node that is confidently wrong. Host-side derivation from the existing `SyncPacket` stream keeps the firmware unchanged (no reflash) and centralises the cross-node comparison. This may revisit once ADR-110 firmware exposes a richer sync telemetry field.
### 4.5 Use Raw `guard_interval_us` Rejection for Clock Handling
Rejected. The existing `MultistaticConfig.guard_interval_us` (5 ms spread) is a *timestamp-alignment* sanity check, not a clock-*quality* measure — it catches gross desync but says nothing about the sub-millisecond dispersion that corrupts cross-node phase. The two are complementary: `guard_interval_us` stays as the coarse alignment precondition; `ClockQualityScore.offset_stdev_us` is the fine-grained quality term feeding the gate.
---
## 5. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-008 (CSI Frame Primitives) | **Substrate**: `CsiFrame`/`CanonicalCsiFrame` are the per-band frame types `LinkGroup` aggregates |
| ADR-029 (RuvSense Multistatic) | **Extended**: `LinkGroup::consensus_frame()` feeds the existing `MultistaticFuser`; the coordinator supplies the attention weights `fuse()` previously derived internally |
| ADR-030 (Persistent Field Model) | **Gated**: environment/model updates are exactly what `MonitorOnly` withholds when clock quality degrades |
| ADR-031 (RuView Sensing-First RF Mode) | **Extended**: this ADR builds directly on `viewpoint/geometry.rs`, `coherence.rs`, `attention.rs`, `fusion.rs` introduced by ADR-031 |
| ADR-110 (ESP32-C6 Firmware Extension) | **Substrate**: `SyncPacket` (magic `0xC511A110`) and its `local_minus_epoch_us`/`mesh_aligned_us_for_sequence` are the source of `ClockQualityScore`; the ±100 µs target defines the 200 µs gate floor |
| ADR-136 (RuView Rust Streaming Engine) | **Contract**: `FrameMeta` carries `mesh_aligned_us` + `clock_quality`; the coordinator reads these rather than raw packets |
| ADR-137 (Fusion Engine Quality Scoring) | **Downstream consumer**: `DirectionalEvidence.contradictions` (`ContradictionFlag`) is the agreed hand-off; ADR-137 attaches model/privacy version to complete the provenance tuple |
---
## 6. References
### Production Code
- `v2/crates/wifi-densepose-signal/src/ruvsense/multiband.rs``MultiBandCsiFrame`, `MultiBandBuilder`, `compute_cross_channel_coherence`, `pearson_correlation_f32` (consensus reuse); `LinkGroup` lands here
- `v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs``MultistaticFuser`, `FusedSensingFrame`, `attention_weighted_fusion`, `geometric_diversity`, `MultistaticConfig.guard_interval_us`
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/geometry.rs``GeometricDiversityIndex::compute`/`is_sufficient`, `CramerRaoBound::estimate`, `ViewpointPosition`, `NodeId`
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/coherence.rs``CoherenceState`, `CoherenceGate` (phase term); `ClockQualityScore`/`ClockQualityGate` land here
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/attention.rs``CrossViewpointAttention`, `GeometricBias` (softmax shape reference)
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/fusion.rs``MultistaticArray` aggregate, `ViewpointFusionEvent`, `FusionError::CoherenceGateClosed`; `ArrayCoordinator` lands here
- `v2/crates/wifi-densepose-hardware/src/sync_packet.rs``SyncPacket`, `SYNC_PACKET_MAGIC = 0xC511A110`, `local_minus_epoch_us`, `apply_to_local`, `mesh_aligned_us_for_sequence`
- `v2/crates/wifi-densepose-sensing-server/src/main.rs``NodeState::latest_sync`, `mesh_aligned_us_for_csi_frame`, 9 s staleness gate (source of `ClockQualityScore.age_us` ceiling)
- `docs/adr/ADR-110-esp32-c6-firmware-extension.md` — §A0.10 measured 104 µs offset stdev, §A0.12 sync-packet wire format
- `archive/v1/data/proof/expected_features.sha256` — hash entry `array_coordinator_evidence_v1` to be added; `verify.py` `array_coordinator_check()` extension
### External References
- Mardia, K.V. & Jupp, P.E. (2000). *Directional Statistics*. Wiley. — Circular phasor coherence underlying `CoherenceState` and the >2σ contradiction test.
- Van Trees, H.L. (2002). *Optimum Array Processing*. Wiley. Ch. 8. — Cramér-Rao bound and Fisher information matrix used by `CramerRaoBound` for the credence interval.
- IEEE 802.11be (WiFi-7) Multi-Link Operation. — Concurrent multi-band streaming model that the `LinkGroup` FreqSet abstraction targets.
- IEEE 802.15.4 time synchronization. — Stratum / mesh-epoch model underlying ADR-110's `SyncPacket` and the `ClockQualityScore.stratum` field.
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `fc7674bde`, issue #842): `ClockQualityGate` (in `wifi-densepose-ruvector`) and `ArrayCoordinator` + `DirectionalEvidence` (in `wifi-densepose-signal`, placed there to avoid a dependency cycle). 8 tests.
**Integration glue -- not yet on the live path:** the `LinkGroup` per-band consensus aggregate; the ADR-110 `SyncPacket` UDP decode -> `FrameMeta.mesh_aligned_us`; and live coherence/clock-quality feeds per node.
**Trust contribution:** only well-synced, well-placed nodes are allowed to change the world-model; a clock-degraded node still contributes evidence but is held in *watch-only* mode.
@@ -0,0 +1,587 @@
# ADR-139: WorldGraph: Environmental Digital Twin with Typed Petgraph
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | New module/crate `wifi-densepose-worldgraph` alongside `v2/crates/wifi-densepose-geo` and `v2/crates/homecore`; petgraph bridge pattern from `v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs`; integrates `homecore/src/registry.rs` `area_id` and `wifi-densepose-mat/src/domain/scan_zone.rs` |
| **Relates to** | ADR-044 (Geospatial Satellite Integration), ADR-113 (Multistatic Placement Strategy), ADR-127 (HomeCore State Machine), ADR-030 (Persistent Field Model), ADR-136 (RuView Streaming Engine), ADR-137 (Fusion Quality Scoring), ADR-138 (LinkGroup / ArrayCoordinator), ADR-142 (Evolution Tracker), ADR-144 (UWB Range-Constraint Fusion), ADR-145 (Ablation Eval Harness) |
---
## 1. Context
### 1.1 The Gap
There is no single, queryable model of *the environment a RuView installation senses*. The spatial knowledge that exists in the workspace is fragmented across four crates, each holding one projection of "where things are" with no edges connecting them:
- **`v2/crates/wifi-densepose-geo`** holds the *outdoor / global* frame. `src/types.rs` defines `GeoPoint { lat, lon, alt }` (the ADR-044 WGS84 anchor), `GeoBBox`, `GeoScene`, and `GeoRegistration { origin, heading_deg, scale }`. `src/coord.rs` implements `wgs84_to_enu()` / `enu_to_wgs84()` — the exact transform needed to pin a room into a local East-North-Up frame relative to a `GeoPoint`. But `GeoScene` only models buildings and roads (`OsmFeature::Building`, `OsmFeature::Road`); it has no concept of an interior room, wall, doorway, sensor placement, or a person inside.
- **`v2/crates/homecore/src/registry.rs`** holds the *entity / automation* frame. `EntityEntry` carries `area_id: Option<String>` and `device_id: Option<String>` (mirroring Home Assistant `core.entity_registry` v13 per ADR-127). This is the canonical handle for "which room an entity is in" — but `area_id` is an opaque string with no geometry, no adjacency, and no link to the sensors that observe it.
- **`v2/crates/wifi-densepose-mat/src/domain/scan_zone.rs`** holds the *sensing geometry* frame. `ScanZone` has `ZoneBounds` (Rectangle/Circle/Polygon), `SensorPosition { id, x, y, z, sensor_type }`, and `contains_point()`. This is the only place that knows sensor coordinates relative to a monitored area — but its coordinates are bare `f64` meters with no declared origin, no link to `homecore` `area_id`, and no link to a `GeoPoint`.
- **`v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs`** demonstrates the *graph algorithm* pattern we want: it bridges a domain `BrainGraph` to `petgraph::graph::{Graph, UnGraph}` (`to_petgraph()` / `from_petgraph()`) so that petgraph's traversal/shortest-path algorithms run over a typed domain model. But its nodes are bare `usize` and its edges carry only an `f64` weight plus a `ConnectivityMetric` enum — there is no node *type* and no edge *semantics*. It is the right mechanical pattern, the wrong domain.
Concretely, what is **missing**:
1. **No node typing.** Nothing in the workspace represents `room`, `zone`, `wall`, `doorway`, `sensor`, `rf_link`, `person_track`, `object_anchor`, `event`, or `semantic_state` as first-class graph nodes with a shared identity space.
2. **No typed edges.** There is no `observes` edge (sensor → node), no `located_in` (person → room), no `adjacent_to` (room ↔ room through a doorway), no `supports` / `contradicts` (evidence relations), no `derived_from` (provenance), and no `privacy_limited_by` (sensor capability constrained by a privacy mode).
3. **No provenance / contradiction tracking.** ADR-137's fusion engine produces `EvidenceRef` and `ContradictionFlag` records, but there is nowhere to *attach* them — they cannot point at the world entity they support or contradict.
4. **No privacy-impact rollup.** ADR-141's privacy control plane will define named modes and per-action allow/deny, but no structure answers "given the current mode, which world nodes can sensor X still observe?"
5. **No persistence of topology.** Each of the four crates persists independently (HomeCore to `core.entity_registry`, geo to a tile cache, MAT in memory). There is no single artifact a RuView appliance can load at boot to reconstitute "the rooms, the sensors, who's where, and why we believe it."
This ADR closes the gap with a **WorldGraph**: a typed `petgraph` over a serde-serializable node enum and typed edges, persisted as an RVF bundle, pinned to a `GeoPoint`, keyed by HomeCore `area_id`, and carrying ADR-137 evidence/contradiction provenance plus ADR-141 privacy constraints.
### 1.2 What "WorldGraph" Means Here
The WorldGraph is an **environmental digital twin** of a *single installation*: the static room/zone/wall/doorway/sensor topology plus the dynamic person/object/event/semantic overlay that sensing produces. It is:
- A `petgraph::stable_graph::StableDiGraph<WorldNode, WorldEdge>` (directed; stable indices so node removal does not invalidate other handles).
- The single authority for *spatial identity*: every `area_id` in HomeCore, every `ScanZone` in MAT, and every sensor placement in ADR-113 maps to exactly one WorldGraph node.
- Append-with-provenance, not overwrite: a node update that supersedes a prior belief adds a `derived_from` edge to the old state and (when sources disagree) a `contradicts` edge, so the graph retains *why* it holds its current belief.
It is **not**:
- A real-time per-frame buffer. The streaming engine (ADR-136) owns per-frame data; the WorldGraph is updated at the *event / semantic-state* cadence (sub-Hz to low-Hz), not the 20 Hz CSI cadence.
- A geometry/CAD engine. Walls and doorways are coarse topological elements (an adjacency relation + a 2D segment), not a BIM model.
- A temporal reconfiguration history. v1 models the *current* static topology only; topology reconfiguration history is deferred to ADR-142's evolution tracker (see §2.7).
### 1.3 Frame and Identity Context
A WorldGraph is pinned to one `GeoRegistration { origin: GeoPoint, heading_deg, scale }` (ADR-044, already in `geo/src/types.rs`). All interior coordinates are **local ENU meters** relative to `origin`, exactly the frame produced by `geo::coord::wgs84_to_enu()`. This means:
- A `room`/`zone` node carries its `ScanZone`-style `ZoneBounds` in ENU meters and can be re-projected to WGS84 via `enu_to_wgs84()` for the ADR-044 map overlay.
- A `sensor` node reuses the `SensorPosition { x, y, z }` semantics from `scan_zone.rs`, now anchored to the installation origin.
- A `room`/`zone` node carries `area_id: Option<String>` so a HomeCore `EntityEntry.area_id` resolves to exactly one WorldGraph node (entity linkage per ADR-127).
### 1.4 Pipeline Position
```
ADR-044 GeoPoint / GeoRegistration (installation origin)
│ pins local ENU frame
ADR-136 streaming frames ─► ADR-137 FusionEngine ─► (EvidenceRef, ContradictionFlag)
│ │
│ person/object/event │ provenance
▼ ▼
ADR-113 sensor placement ─► ┌──────────────── WorldGraph ───────────────────┐
ADR-138 LinkGroup ─► │ nodes: room/zone/wall/doorway/sensor/rf_link/ │
homecore area_id ─► │ person_track/object_anchor/event/ │
MAT ScanZone bounds ─► │ semantic_state │
│ edges: observes/located_in/adjacent_to/ │
ADR-141 privacy modes ───► │ supports/contradicts/derived_from/ │
│ privacy_limited_by │
└───────────────┬───────────────┬───────────────┘
│ query API │ RVF write-through
▼ ▼
observability / location / privacy .rvf bundle (persisted)
rollup queries (ADR-140, ADR-144,
ADR-145 consume)
```
The WorldGraph sits *downstream* of fusion (it stores fused beliefs, not raw frames) and *upstream* of the semantic/agent layer (ADR-140) and evaluation harness (ADR-145). ADR-144 (UWB range constraints) reads `sensor`/`object_anchor` nodes as the anchor set for range-constraint solving.
---
## 2. Decision
### 2.1 Node and Edge Model: serde Enum, Not Trait Objects
Nodes are a **`#[derive(Serialize, Deserialize)]` enum**, not boxed trait objects. This is the single most consequential decision: a serde enum gives deterministic, schema-versioned, RVF-friendly persistence (every variant serializes to the same wire layout regardless of build), whereas `Box<dyn WorldNodeTrait>` would require `typetag` (an extra dependency, non-deterministic across crate versions) and could not be field-walked by an evaluation harness. The `petgraph_bridge.rs` precedent already stores concrete weights (`usize`, `f64`) rather than trait objects; we extend that to a typed enum.
```rust
//! v2/crates/wifi-densepose-worldgraph/src/model.rs
use serde::{Deserialize, Serialize};
use wifi_densepose_geo::types::GeoRegistration; // ADR-044
/// Stable, monotonic identity for a world entity. Distinct from petgraph's
/// NodeIndex (which is a graph-internal handle); WorldId survives RVF
/// round-trips and node removal.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorldId(pub u64);
/// Local ENU coordinate in meters relative to the installation origin.
/// Mirrors `scan_zone::SensorPosition` {x,y,z} but in a named frame.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EnuPoint {
pub east_m: f64,
pub north_m: f64,
pub up_m: f64,
}
/// A typed world node. Persistence-deterministic serde enum (no trait objects).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorldNode {
/// A bounded interior space. Linked to HomeCore `area_id` (ADR-127).
Room {
id: WorldId,
/// HomeCore registry area_id; the entity-linkage join key.
area_id: Option<String>,
name: String,
/// ZoneBounds in local ENU meters (reuses MAT ZoneBounds shape).
bounds_enu: ZoneBoundsEnu,
floor: i16,
},
/// A sub-region of a room targeted for sensing (MAT ScanZone analogue).
Zone {
id: WorldId,
parent_room: WorldId,
name: String,
bounds_enu: ZoneBoundsEnu,
},
/// A wall segment (coarse topological element, 2D segment in ENU).
Wall {
id: WorldId,
a: EnuPoint,
b: EnuPoint,
/// Coarse RF attenuation estimate in dB (drywall ≈ 3, brick ≈ 12).
rf_attenuation_db: f32,
},
/// A passable opening between two rooms.
Doorway {
id: WorldId,
center: EnuPoint,
width_m: f32,
},
/// A physical sensing device placement (ADR-113 placement target).
Sensor {
id: WorldId,
device_id: String, // matches homecore EntityEntry.device_id
position: EnuPoint, // SensorPosition x/y/z analogue
modality: SensorModality,
},
/// A directed RF propagation channel between two sensors (ADR-138 LinkGroup member).
RfLink {
id: WorldId,
tx: WorldId, // Sensor node
rx: WorldId, // Sensor node
link_group_id: Option<String>, // ADR-138 MLO LinkGroup
center_freq_mhz: u32,
},
/// A tracked person (Kalman track id from ruvsense pose_tracker).
PersonTrack {
id: WorldId,
track_id: u64,
last_position: EnuPoint,
reid_embedding_ref: Option<String>, // AETHER re-ID handle
},
/// A persistent static reflector / object (ADR-143 RF SLAM anchor; ADR-144 UWB anchor).
ObjectAnchor {
id: WorldId,
position: EnuPoint,
anchor_kind: AnchorKind,
confidence: f32,
},
/// A discrete detected event (fall, entry, gesture) at a point in time.
Event {
id: WorldId,
event_type: String,
at_unix_ms: i64,
located_in: Option<WorldId>, // Room/Zone
},
/// A fused semantic belief about the world (the ADR-140 record's graph anchor).
SemanticState {
id: WorldId,
statement: String, // e.g. "occupant present, seated, room=living_room"
confidence: f32,
/// Mandatory provenance per the house rule (see §2.3).
provenance: SemanticProvenance,
valid_from_unix_ms: i64,
},
}
/// MAT ZoneBounds reprojected into the installation ENU frame.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "shape", rename_all = "snake_case")]
pub enum ZoneBoundsEnu {
Rectangle { min_e: f64, min_n: f64, max_e: f64, max_n: f64 },
Circle { center_e: f64, center_n: f64, radius_m: f64 },
Polygon { vertices: Vec<(f64, f64)> },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorModality { WifiCsi, MmWave, Uwb, Presence }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorKind { Reflector, Furniture, UwbBeacon }
```
Edges carry **typed metadata per edge kind** — the metadata for `observes` (a sensor's field-of-regard weight) is structurally different from `contradicts` (a disagreement magnitude) or `privacy_limited_by` (the limiting mode + action). Like `petgraph_bridge.rs`'s `BrainEdge`, this is a single enum stored as the petgraph edge weight:
```rust
/// Typed edge between two WorldNodes. Stored as the petgraph edge weight.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "rel", rename_all = "snake_case")]
pub enum WorldEdge {
/// sensor/rf_link -> any observable node. Weight is field-of-regard quality.
Observes { quality: f32, last_seen_unix_ms: i64 },
/// person_track/object_anchor/event -> room/zone containment.
LocatedIn { since_unix_ms: i64 },
/// room <-> room through a doorway (undirected pair stored as two edges).
AdjacentTo { via_doorway: WorldId },
/// sensor/rf_link -> sensor/rf_link: physical/clock support (ADR-138).
Supports { strength: f32 },
/// evidence/state -> evidence/state: sources disagree (ADR-137).
Contradicts { magnitude: f32, flag: ContradictionFlagRef },
/// semantic_state -> prior state/evidence: provenance chain (ADR-137).
DerivedFrom { evidence: EvidenceRefHandle },
/// sensor -> any node: observation constrained by a privacy mode (ADR-141).
PrivacyLimitedBy { mode: String, action: String, allowed: bool },
}
```
`EvidenceRefHandle`, `ContradictionFlagRef`, and `SemanticProvenance` are defined in ADR-137 / ADR-140 and re-exported here; this ADR depends on them but does not own them (see §2.3). Where those crates are not yet present, the handles degrade to opaque `String` content-addresses so the WorldGraph compiles and persists independently.
### 2.2 Graph Container and Bridge
Following `petgraph_bridge.rs`, the WorldGraph wraps petgraph and exposes a domain API. We use `StableDiGraph` (not `Graph`) because nodes are removed at runtime (a person leaves, a track dies) and stable indices keep `WorldId → NodeIndex` resolution valid.
```rust
//! v2/crates/wifi-densepose-worldgraph/src/graph.rs
use petgraph::stable_graph::{StableDiGraph, NodeIndex};
use std::collections::HashMap;
use crate::model::{WorldNode, WorldEdge, WorldId};
pub struct WorldGraph {
inner: StableDiGraph<WorldNode, WorldEdge>,
/// Stable WorldId -> petgraph handle. Survives removals.
index: HashMap<WorldId, NodeIndex>,
/// Installation origin; all ENU coords are relative to this (ADR-044).
registration: wifi_densepose_geo::types::GeoRegistration,
next_id: u64,
schema_version: u16,
}
impl WorldGraph {
pub fn new(registration: wifi_densepose_geo::types::GeoRegistration) -> Self;
/// Insert a node, returning its stable WorldId. Allocates the id if the
/// node's embedded id is WorldId(0) (sentinel = "assign me one").
pub fn upsert_node(&mut self, node: WorldNode) -> WorldId;
/// Add a typed edge. Errors if either endpoint is unknown.
pub fn add_edge(&mut self, from: WorldId, to: WorldId, edge: WorldEdge)
-> Result<(), WorldGraphError>;
/// Resolve a HomeCore area_id to its Room node (entity linkage, ADR-127).
pub fn room_for_area(&self, area_id: &str) -> Option<WorldId>;
pub fn node(&self, id: WorldId) -> Option<&WorldNode>;
pub fn neighbors(&self, id: WorldId) -> impl Iterator<Item = (WorldId, &WorldEdge)>;
}
```
A `bridge.rs` module mirrors `petgraph_bridge.rs`'s `to_petgraph` / `from_petgraph` so external algorithm code can borrow a plain `&StableDiGraph` for petgraph's `dijkstra`, `connected_components`, etc., without leaking the domain wrapper.
### 2.3 Provenance: derived_from and contradicts from ADR-137
The house rule is honored structurally: **every `SemanticState` node carries a `SemanticProvenance`** and is reachable along `DerivedFrom` edges back to the evidence that produced it. The provenance tuple binds the four required traces:
```rust
//! Mandatory provenance for every SemanticState (house rule).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SemanticProvenance {
/// Signal evidence: ADR-137 EvidenceRef content-address(es).
pub evidence: Vec<EvidenceRefHandle>,
/// Model version that produced this belief.
pub model_version: String,
/// Calibration version (ADR-135 baseline id) in effect.
pub calibration_version: String,
/// Privacy decision (ADR-141 mode + action) under which it was derived.
pub privacy_decision: PrivacyDecisionRef,
}
```
When the fusion engine (ADR-137) emits a new `SemanticState`:
1. `upsert_node()` inserts the new `SemanticState` node.
2. For each `EvidenceRef` in its provenance, the engine adds a `DerivedFrom` edge from the new state to the corresponding `Event` / prior `SemanticState` / `Observes` source.
3. If ADR-137 attached a `ContradictionFlag` (the new belief disagrees with a still-live prior belief), the engine adds a `Contradicts` edge between the two `SemanticState` nodes carrying the flag's magnitude. The prior node is **not deleted** — it is retained so a query can surface the disagreement; a downstream resolver (ADR-140) decides which belief wins.
This makes node updates *append-with-provenance*: the graph never loses the chain of reasoning, which is exactly what ADR-145's ablation harness needs to attribute a wrong belief to a specific sensor/model/calibration.
### 2.4 Privacy: privacy_limited_by edges from ADR-141
For each `(sensor, observable-node)` pair, the WorldGraph materializes a `PrivacyLimitedBy` edge derived from the ADR-141 privacy mode/action registry. The edge records the limiting `mode`, the `action` evaluated, and whether observation is `allowed` under the current mode. This is computed by a reducer that runs whenever the active privacy mode changes:
```rust
/// Recompute privacy_limited_by edges for the active mode (ADR-141).
/// For every Observes edge (sensor -> node), evaluate the mode's policy for
/// that sensor's modality + the node kind, and write/update a matching
/// PrivacyLimitedBy edge.
pub fn apply_privacy_mode(
&mut self,
mode: &PrivacyMode, // from ADR-141 control plane
) -> PrivacyRollup;
/// Result of a privacy-impact rollup query (§2.5).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrivacyRollup {
pub mode: String,
/// Nodes that become unobservable under this mode.
pub suppressed_nodes: Vec<WorldId>,
/// (sensor, node) pairs newly denied.
pub denied_pairs: Vec<(WorldId, WorldId)>,
pub allowed_pairs: usize,
}
```
Because `PrivacyLimitedBy` is a first-class edge, "what can sensor X still see under mode Y?" is a one-hop neighbor filter — no separate policy index is needed, and the privacy posture is *visible in the persisted graph* (an auditor can read the `.rvf` and see what was suppressed).
### 2.5 Query API Surface (v1 Scope)
The v1 query API is intentionally narrow — three families, all expressible as petgraph traversals over the typed edges:
```rust
//! v2/crates/wifi-densepose-worldgraph/src/query.rs
impl WorldGraph {
/// OBSERVABILITY CHAIN: sensor -> all nodes it currently observes.
/// Follows Observes edges (one hop) filtered by current PrivacyLimitedBy.
pub fn observed_by(&self, sensor: WorldId) -> Vec<ObservedNode>;
/// LOCATION QUERY: contents of room X.
/// Reverse LocatedIn traversal: all PersonTrack/ObjectAnchor/Event/Zone
/// nodes located_in this room (transitively through child Zones).
pub fn contents_of(&self, room: WorldId) -> RoomContents;
/// PRIVACY-IMPACT ROLLUP: for a candidate mode, what is suppressed.
/// Pure (does not mutate); ADR-145 uses it to score privacy leakage.
pub fn privacy_impact(&self, mode: &PrivacyMode) -> PrivacyRollup;
/// ADR-144 anchor accessor: sensors + object anchors with known ENU pos.
pub fn anchors(&self) -> Vec<(WorldId, EnuPoint)>;
}
```
**Scope boundary for v1:** the graph models the **current static topology** of a single installation. Temporal reconfiguration history (rooms repartitioned, sensors relocated over weeks) is **deferred to ADR-142** (Evolution Tracker / temporal VoxelMap). The WorldGraph emits a `TopologyChanged` domain event when static structure changes; ADR-142 subscribes and aggregates the history. This keeps the WorldGraph a clean *current-state* projection and avoids baking a time-series store into the graph itself.
### 2.6 Persistence: RVF Bundle with Async Write-Through
The graph persists as an **RVF bundle**, reusing the segment-based format already implemented in `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` (64-byte aligned segments, `SEG_META` for JSON metadata, `SEG_MANIFEST` for the directory, CRC32 content hashes). No new file format is introduced.
- **Layout:** one `SEG_META` segment holds the serde-JSON of `{ registration, schema_version, nodes: Vec<WorldNode>, edges: Vec<(WorldId, WorldId, WorldEdge)> }`. A `SEG_MANIFEST` segment carries node/edge counts and the schema version. A `SEG_WITNESS` segment carries the SHA-256 of the node+edge payload for the ADR-028 proof chain.
- **Async write-through:** mutations (`upsert_node`, `add_edge`, `apply_privacy_mode`) are applied to the in-memory graph synchronously and enqueued to a bounded `tokio::sync::mpsc` channel drained by a single writer task that coalesces bursts and rewrites the `.rvf` (write-temp-then-rename). The hot path never blocks on disk. This mirrors the `homecore/src/registry.rs` "in-memory now, persistence to a backing store later" staging — except the backing store (RVF) is specified up front.
- **Pinning:** the bundle stores its `GeoRegistration` so a reloaded graph re-establishes the same local ENU frame. `enu_to_wgs84()` (ADR-044) regenerates lat/lon for any node on demand for the map overlay.
```rust
//! v2/crates/wifi-densepose-worldgraph/src/persist.rs
pub struct WorldGraphStore {
path: std::path::PathBuf,
tx: tokio::sync::mpsc::Sender<WriteOp>,
}
impl WorldGraphStore {
/// Open or create an RVF-backed store; spawns the write-through task.
pub async fn open(path: impl Into<std::path::PathBuf>) -> Result<(Self, WorldGraph), WorldGraphError>;
/// Enqueue a snapshot write (non-blocking, coalesced by the writer task).
pub fn enqueue_snapshot(&self, graph: &WorldGraph) -> Result<(), WorldGraphError>;
/// Force-flush and await durability (used at shutdown / before witness).
pub async fn flush(&self) -> Result<(), WorldGraphError>;
}
```
### 2.7 Error Type and Domain Events
```rust
#[derive(Debug, thiserror::Error)]
pub enum WorldGraphError {
#[error("unknown node: {0:?}")]
UnknownNode(WorldId),
#[error("edge endpoint type mismatch: {0}")]
EdgeTypeMismatch(String),
#[error("schema version {found} unsupported (expected {expected})")]
SchemaMismatch { found: u16, expected: u16 },
#[error("RVF (de)serialisation error: {0}")]
Rvf(String),
#[error("privacy mode references unknown action: {0}")]
UnknownPrivacyAction(String),
}
/// Event-sourced change notifications (per project DDD rule).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum WorldGraphEvent {
NodeUpserted(WorldId),
NodeRemoved(WorldId),
EdgeAdded { from: WorldId, to: WorldId },
TopologyChanged, // consumed by ADR-142
PrivacyModeApplied(String), // emitted by apply_privacy_mode
ContradictionRecorded { a: WorldId, b: WorldId, magnitude: f32 },
}
```
### 2.8 Interface Boundaries
| Boundary | This crate provides | This crate consumes |
|----------|---------------------|---------------------|
| ADR-044 `wifi-densepose-geo` | — | `GeoRegistration`, `GeoPoint`, `wgs84_to_enu`/`enu_to_wgs84` |
| ADR-127 `homecore/registry.rs` | `room_for_area(area_id)` | `EntityEntry.area_id`, `EntityEntry.device_id` (join keys) |
| MAT `scan_zone.rs` | `ZoneBoundsEnu`, `Sensor` node | `ZoneBounds`, `SensorPosition` shapes (reprojected to ENU) |
| ADR-137 fusion | `DerivedFrom`/`Contradicts` edges, `SemanticState` nodes | `EvidenceRef`, `ContradictionFlag` |
| ADR-141 privacy | `apply_privacy_mode`, `privacy_impact` | `PrivacyMode`, action registry |
| ADR-138 LinkGroup | `RfLink.link_group_id` field | LinkGroup ids |
| ADR-142 evolution | `WorldGraphEvent::TopologyChanged` stream | — |
| ADR-144 UWB | `anchors()` accessor | — |
| ADR-145 ablation | `privacy_impact()`, provenance chains | — |
The crate must compile **standalone**: where ADR-137/141 types are not yet present, their handles are `String` content-addresses (feature-gated `full-fusion` swaps them for the real types). This keeps `wifi-densepose-worldgraph` a no-internal-dep leaf on `wifi-densepose-geo` only, matching the publishing-order discipline in CLAUDE.md.
---
## 3. Consequences
### 3.1 Positive
- **One spatial identity space.** `area_id` (HomeCore), `ScanZone` (MAT), and sensor placement (ADR-113) finally resolve to one node set. `room_for_area()` is the single join.
- **Provenance is structural, not bolted on.** Every belief traces to signal evidence + model version + calibration version + privacy decision via `SemanticProvenance` and `DerivedFrom` edges — the house rule is enforced by the type system, not by convention.
- **Privacy posture is auditable.** `PrivacyLimitedBy` edges live in the persisted `.rvf`, so an auditor can read what each mode suppressed without re-running the system.
- **Deterministic persistence.** The serde-enum-over-RVF choice produces byte-stable snapshots suitable for the ADR-028 witness proof chain (SHA-256 of the node/edge payload).
- **Reuses proven mechanics.** The petgraph bridge pattern (`ruv-neural-graph`) and the RVF container (`sensing-server`) are existing, tested code — no new graph engine or file format.
- **Unblocks four downstream ADRs.** ADR-140 (semantic records anchor to `SemanticState` nodes), ADR-142 (consumes `TopologyChanged`), ADR-144 (consumes `anchors()`), ADR-145 (scores over `privacy_impact()` + provenance).
### 3.2 Negative
- **New crate to maintain.** `wifi-densepose-worldgraph` adds a 16th workspace crate and an entry to the publishing order (leaf on `wifi-densepose-geo`).
- **Cross-crate handle coupling.** The full-fidelity provenance/privacy edges depend on ADR-137/141 types. Until those land, the `String`-handle fallback means provenance is content-addressed but not yet richly typed — a temporary loss of compile-time guarantees.
- **Snapshot-rewrite cost.** Async write-through rewrites the whole `.rvf` on flush rather than appending a delta. For a single-installation graph (hundreds of nodes, low-Hz mutation) this is sub-millisecond, but it does not scale to thousands of installations in one file (out of scope — one bundle per installation).
- **No history in v1.** Querying "where was the sofa last month" requires ADR-142; the WorldGraph alone answers only "now."
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Stale `petgraph` `NodeIndex` after node removal | Medium | Dangling edge / panic | Use `StableDiGraph` (indices survive removal) and the `WorldId → NodeIndex` map; never expose raw `NodeIndex` across the API boundary |
| Schema drift breaks old `.rvf` bundles | Medium | Reload failure | `schema_version` in `SEG_MANIFEST`; `WorldGraphError::SchemaMismatch` with an explicit migration path; refuse-and-warn rather than mis-parse |
| Contradiction edges accumulate without resolution | Medium | Graph bloat, ambiguous beliefs | A retention policy prunes `Contradicts` edges whose losing `SemanticState` has `valid_from` older than a TTL once ADR-140's resolver has chosen a winner |
| Privacy edge recompute lags a fast mode switch | Low | Brief window of stale `allowed` flags | `apply_privacy_mode` runs synchronously on the mutation path before any new `Observes` edge is honored; rollup returned to caller for confirmation |
| ENU origin re-pinned after partial population | Low | Coordinate frame mismatch | Origin is immutable after `WorldGraph::new`; re-pinning requires a new bundle + ADR-142 migration event |
---
## 4. Alternatives Considered
### 4.1 Trait-Object Nodes (`Box<dyn WorldNode>`)
Rejected. `typetag`-style polymorphic serde is non-deterministic across crate/serde versions, cannot be field-walked by ADR-145's harness, and breaks the byte-stable witness proof. The serde enum gives closed-world exhaustiveness (the compiler forces every query to handle every node kind) and deterministic bytes. The `petgraph_bridge.rs` precedent already stores concrete weights, not trait objects.
### 4.2 Extend `GeoScene` with Interior Features
Rejected. `geo::types::GeoScene` is a WGS84 outdoor scene (buildings/roads from OSM). Bolting rooms/sensors/people onto it would (a) conflate the global frame with the local ENU frame, (b) force the geo crate to depend on fusion/privacy types it has no business knowing, and (c) provide no edges. We *reuse* `GeoRegistration` and the ENU transforms from geo, but the WorldGraph is a separate concern.
### 4.3 Reuse `homecore` Area Registry Directly
Rejected as the home. `EntityEntry.area_id` is an opaque string with no geometry and no adjacency; HomeCore's job is HA-compatible entity bookkeeping, not spatial reasoning. The WorldGraph *links to* `area_id` (so automations and sensing share identity) but owns geometry, sensors, and the typed-edge topology HomeCore deliberately does not model.
### 4.4 A Relational/SQLite Store with Join Tables
Rejected for v1. Edges-as-rows + recursive CTEs can express the same queries, but (a) the workspace already standardizes on RVF for portable, witness-hashable artifacts, (b) petgraph gives shortest-path/connectivity algorithms for free (observability chains, adjacency reachability) that would be hand-rolled SQL, and (c) an embedded SQLite file is not byte-stable for the proof chain. RVF + petgraph matches existing patterns; a SQL backend remains a future option behind `WorldGraphStore` if scale demands it.
### 4.5 Temporal Graph from Day One
Rejected for v1. A bitemporal graph (valid-time + transaction-time on every node/edge) is the correct long-term model, but it doubles the schema complexity and the persistence size before any consumer needs history. v1 ships current-state-only and emits `TopologyChanged`; ADR-142 builds the temporal aggregation on top. This keeps the first deliverable small and the query API simple.
---
## 5. Testing / Acceptance
### 5.1 Unit Tests (CI, no hardware)
**T1 — Node/edge round-trip determinism.** Build a graph with one of every `WorldNode` variant and one of every `WorldEdge` variant. Serialize to RVF bytes, deserialize, assert structural equality and assert the SHA-256 of the node/edge payload is byte-stable across two independent serializations (deterministic-persistence acceptance).
**T2 — `room_for_area` entity linkage.** Insert a `Room { area_id: Some("living_room") }`; assert `room_for_area("living_room")` returns its `WorldId` and `room_for_area("garage")` returns `None`. Mirrors the HomeCore `registry.rs` register-and-read test.
**T3 — ENU pinning round-trip.** Pin a graph to `GeoRegistration { origin: lat/lon }`; place a `Sensor` at a known `EnuPoint`; reproject to WGS84 via `enu_to_wgs84` and back via `wgs84_to_enu`; assert agreement within 1e-6 m (validates the ADR-044 frame reuse).
**T4 — Observability chain.** Sensor S observes nodes A,B,C (three `Observes` edges); assert `observed_by(S)` returns exactly {A,B,C}.
**T5 — Location query (transitive).** Room R contains Zone Z; PersonTrack P `located_in` Z. Assert `contents_of(R)` includes P (transitive through the child zone) and Object/Event nodes located directly in R.
**T6 — Provenance chain (house rule).** Insert a `SemanticState` with `SemanticProvenance { evidence, model_version, calibration_version, privacy_decision }` and `DerivedFrom` edges to two `Event` sources. Assert every `SemanticState` in the graph has non-empty `evidence`, a `model_version`, a `calibration_version`, and a `privacy_decision` (acceptance: the four-fold trace is present on every belief node).
**T7 — Contradiction retention.** Insert belief B1, then a contradicting belief B2 (ADR-137 `ContradictionFlag`). Assert a `Contradicts` edge exists, B1 is **not** removed, and a `WorldGraphEvent::ContradictionRecorded` was emitted.
**T8 — Privacy-impact rollup.** With sensor S observing person P, apply a `PrivacyMode` that denies person observation for S's modality. Assert `privacy_impact(mode).suppressed_nodes` contains P, a `PrivacyLimitedBy { allowed: false }` edge is written, and `observed_by(S)` no longer returns P.
**T9 — Schema-mismatch refusal.** Hand-craft an RVF `SEG_MANIFEST` with `schema_version = 999`; assert `open()` returns `WorldGraphError::SchemaMismatch` (refuse, do not mis-parse).
**T10 — Stable index after removal.** Insert 5 nodes, remove the middle one, add a 6th; assert all surviving `WorldId → WorldNode` lookups still resolve and no edge dangles (validates `StableDiGraph` choice).
### 5.2 Async Persistence Test
**T11 — Write-through coalescing.** Open a `WorldGraphStore`, enqueue 1,000 rapid snapshots, `flush()`, reopen the bundle, assert the final state matches the last snapshot and that the writer task coalesced (write count < enqueue count). Hot-path `enqueue_snapshot` must not block (assert it returns within a tight bound while the disk write is in flight).
### 5.3 Witness / Proof (ADR-028 chain)
Add rows to `docs/WITNESS-LOG-028.md`:
| Row | Capability | Evidence | Hash |
|-----|-----------|----------|------|
| W-39 | WorldGraph RVF round-trip determinism | `cargo test worldgraph::tests::roundtrip_determinism` | SHA-256 of node/edge payload |
| W-40 | Provenance four-fold trace present on every SemanticState | `cargo test worldgraph::tests::provenance_complete` | SHA-256 of test binary |
| W-41 | Privacy rollup suppresses denied nodes | `cargo test worldgraph::tests::privacy_rollup` | SHA-256 of rollup output |
`source-hashes.txt` in the witness bundle gains `SHA-256(worldgraph/model.rs)` and `SHA-256(worldgraph/graph.rs)`.
### 5.4 Acceptance Criteria (Definition of Done)
1. `wifi-densepose-worldgraph` compiles standalone (`cargo check -p wifi-densepose-worldgraph --no-default-features`) depending only on `wifi-densepose-geo` + `petgraph` + `serde`.
2. T1T11 pass in `cargo test --workspace --no-default-features`; total workspace test count rises and stays at 0 failures.
3. Every `SemanticState` node carries the four-fold provenance trace (signal evidence + model version + calibration version + privacy decision) — enforced by T6 and by the non-`Option` `SemanticProvenance` field.
4. A persisted `.rvf` bundle reloads to a structurally identical graph and re-establishes the same ENU origin.
5. The three query families (observability chain, location, privacy rollup) each have a passing test and a documented signature in `query.rs`.
6. v1 explicitly does **not** store reconfiguration history; a `TopologyChanged` event is emitted for ADR-142 to consume (verified by a unit test asserting the event fires on a wall/room change).
---
## 6. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-044 (Geospatial Satellite Integration) | **Substrate**: reuses `GeoRegistration`, `GeoPoint`, and `wgs84_to_enu`/`enu_to_wgs84` to pin the local ENU frame |
| ADR-113 (Multistatic Placement Strategy) | **Source**: sensor placements become `Sensor` nodes; placement geometry feeds `position` |
| ADR-127 (HomeCore State Machine) | **Linkage**: `EntityEntry.area_id`/`device_id` join to `Room`/`Sensor` nodes via `room_for_area()` |
| ADR-030 (Persistent Field Model) | **Adjacent**: the field model is a per-link signal model; WorldGraph is the spatial/semantic model that field-model events annotate |
| ADR-136 (RuView Streaming Engine) | **Upstream**: frames flow through the streaming engine before fusion populates the WorldGraph |
| ADR-137 (Fusion Quality Scoring) | **Source of provenance**: `EvidenceRef`/`ContradictionFlag` populate `DerivedFrom`/`Contradicts` edges |
| ADR-138 (LinkGroup / ArrayCoordinator) | **Source**: `RfLink.link_group_id` references MLO LinkGroups; `Supports` edges encode clock/physical support |
| ADR-142 (Evolution Tracker) | **Consumer**: subscribes to `TopologyChanged`; owns the deferred temporal history |
| ADR-144 (UWB Range-Constraint Fusion) | **Consumer**: reads `anchors()` (sensors + object anchors) as the range-constraint anchor set |
| ADR-145 (Ablation Eval Harness) | **Consumer**: scores privacy leakage via `privacy_impact()` and attributes errors via provenance chains |
---
## 7. References
### Production Code
- `v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs` — petgraph bridge pattern (`to_petgraph`/`from_petgraph`, typed domain edges) this crate follows
- `v2/crates/wifi-densepose-geo/src/types.rs``GeoPoint`, `GeoBBox`, `GeoRegistration`, `GeoScene` (ADR-044 anchor types reused)
- `v2/crates/wifi-densepose-geo/src/coord.rs``wgs84_to_enu`/`enu_to_wgs84` (local ENU frame transforms)
- `v2/crates/homecore/src/registry.rs``EntityEntry { area_id, device_id }`, in-memory-then-persist staging mirrored by `WorldGraphStore`
- `v2/crates/wifi-densepose-mat/src/domain/scan_zone.rs``ZoneBounds`, `SensorPosition`, `contains_point()` shapes reprojected into `ZoneBoundsEnu` / `Sensor`
- `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` — RVF segment format (64-byte headers, `SEG_META`/`SEG_MANIFEST`/`SEG_WITNESS`, CRC32) reused for persistence
- `v2/crates/wifi-densepose-geo/src/temporal.rs` — precedent for change tracking that ADR-142 generalizes
### External
- petgraph crate — `StableDiGraph`, `dijkstra`, `connected_components` traversal algorithms used by the query API
- Mardia, K.V. & Jupp, P.E. (2000). *Directional Statistics*. Wiley — circular geometry for ENU/heading consistency (shared with ADR-135 calibration phase model)
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `521a012d8`, issue #843): the new `wifi-densepose-worldgraph` crate -- typed petgraph nodes/edges, provenance (`DerivedFrom`) and disagreement (`Contradicts`) edges, the privacy rollup, and deterministic JSON persistence. 7 tests.
**Integration glue -- not yet on the live path:** feeding live fusion outputs and person tracks into nodes; the full `.rvf` bundle container (today it persists as JSON); and the live ADR-141 privacy-mode reducer.
**Trust contribution:** the auditable map -- evidence and contradiction are first-class edges, and the privacy posture is *visible in the persisted graph* (an auditor can read what was suppressed).
@@ -0,0 +1,523 @@
# ADR-140: Semantic State Record Schema, Versioning, and Ruflo Agent Bridge
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-sensing-server/src/semantic/` (`bus.rs`, `common.rs`); `homecore/src/state.rs` + `event.rs`; `homecore-assist` |
| **Relates to** | ADR-115 (HA Integration / HA-MIND semantic primitives), ADR-127 (HOMECORE State Machine), ADR-129 (HOMECORE Automation Engine), ADR-133 (HOMECORE-ASSIST + Ruflo), ADR-136 (RuView Streaming Engine / FrameMeta), ADR-137 (Fusion Engine Quality Scoring / Evidence Refs), ADR-139 (WorldGraph Digital Twin), ADR-141 (BFLD Privacy Control Plane), ADR-021 (ESP32 Vital Signs), ADR-125 (Apple Home Native HAP Bridge) |
---
## 1. Context
### 1.1 The Gap
The HA-MIND semantic primitive layer landed under ADR-115 §3.12 and lives in `v2/crates/wifi-densepose-sensing-server/src/semantic/`. It is a real, tested, ten-primitive inference layer: `bus.rs` owns a `SemanticBus` that dispatches one `RawSnapshot` to each of ten FSMs (`sleeping`, `distress`, `room_active`, `elderly_anomaly`, `meeting`, `bathroom`, `fall_risk`, `bed_exit`, `no_movement`, `multi_room`) and collects `SemanticEvent`s. Each `SemanticEvent` carries exactly four fields (`bus.rs:44-50`):
```rust
pub struct SemanticEvent {
pub kind: SemanticKind,
pub state: PrimitiveState,
pub node_id: String,
pub timestamp_ms: i64,
}
```
and `PrimitiveState` (`common.rs:36-47`) is one of `Boolean { active, changed, reason }`, `Scalar { value, reason }`, `Event { event_type, reason }`, or `Idle`. The only provenance a downstream consumer receives today is the `Reason` tag list (`common.rs:50-65`) — a `Vec<String>` of human-readable debug strings such as `["motion<5%", "br=12bpm"]`.
That is the gap this ADR closes. Searching the workspace confirms three concrete absences:
- **No version provenance on a published state.** Grepping `v2/crates/` for `model_version` and `calibration_version` finds matches only in `wifi-densepose-bfld` and `wifi-densepose-signal` (frame-level metadata), never in the `semantic/` module. A `SemanticEvent` for `fall_risk_elevated` carries no record of *which* model or *which* empty-room baseline (ADR-135) produced it. A caregiver-escalation automation acting on that event cannot audit whether the signal came from a calibrated node or a stale one.
- **No `evidence_refs`, `confidence`, `expiry_at`, or `privacy_action` on a state.** `SemanticEvent` has no field tying its assertion back to the signal evidence that justified it, no machine-readable confidence (only the `Reason` tag strings), no time-to-live, and no privacy classification. `PrimitiveConfig` (`common.rs:71-100`) holds per-primitive thresholds but no per-primitive model/calibration metadata, and `Default` (`common.rs:102-122`) hardcodes them — there is no manifest load path.
- **No `Rest`/inactivity `SemanticKind`.** The `SemanticKind` enum (`bus.rs:29-41`) has ten variants. Inactivity is currently expressed only through `NoMovement` (`no_movement.rs`), which fires a *safety* signal (`presence == true` AND motion < 0.01 for ≥ 30 min — a possible-collapse alarm), and `ElderlyInactivityAnomaly`. Neither expresses the benign, expected state of a person at rest (reading, watching TV). Automations that want to *suppress* lighting/HVAC changes during rest have no primitive to subscribe to; they must reverse-engineer it from the absence of `RoomActive`, which is fragile.
The privacy boundary is likewise under-specified at the state layer. `mqtt/privacy.rs` makes a binary `PublishDecision::{Publish, Suppress}` keyed solely on `EntityKind::is_biometric()` and a global `--privacy-mode` flag (`privacy.rs:33-39`). Semantic primitives are always `Publish` in that path (`privacy.rs:84-102`) because they are inferred states, not raw biometrics. But there is no per-record privacy *action* — no way to say "publish this `BathroomOccupied` state but anonymize the room", or "strip the biometric attributes from this `PossibleDistress` while keeping the boolean". The privacy decision is made once, globally, at the wire boundary, and is invisible to the record itself.
Finally, the **Ruflo agent bridge** exists only as a P1 stub. `homecore-assist/src/runner.rs` defines the `RufloRunner` trait and a `NoopRunner` that returns an empty `RufloResponse` (`runner.rs:113-139`); the crate doc (`lib.rs:24-27`) explicitly defers the real subprocess runner and semantic embedding recognizer to P2/P3. There is no path today by which a `SemanticEvent` (or a *combination* of them) reaches a Ruflo agent so that an automation can route on **multi-signal agreement** — e.g. `fall_risk_elevated` AND `elderly_inactivity_anomaly` together escalating to a caregiver, which neither primitive can decide alone.
### 1.2 What "Semantic State Record" Means Here
A `SemanticStateRecord` is the unified, versioned, auditable envelope that every primitive emits *instead of* the bare `SemanticEvent`. It is the inference-layer analogue of what ADR-136 calls a `FrameMeta` at the signal layer and what ADR-137 calls an evidence-scored fusion output: a state assertion that carries its own provenance. It captures:
- **What** was asserted: the `SemanticKind`, the `PrimitiveState`, the `room`, and the `Reason` tags.
- **How confident**: a normalized `confidence ∈ [0, 1]` distinct from the human `Reason` tags.
- **From which model and calibration**: `model_version` and `calibration_version`, threaded from the ADR-136 `FrameMeta` of the frames that produced the snapshot.
- **Backed by what evidence**: `evidence_refs`, opaque handles into the ADR-137 fusion evidence store (and, where relevant, the ADR-139 WorldGraph node IDs).
- **For how long it is valid**: `expiry_at` — the wall-clock instant past which the record must not be acted upon without refresh.
- **Under what privacy classification**: `privacy_action`, an enum that *the record carries*, enforced downstream at the MQTT/Matter boundary.
What a `SemanticStateRecord` is **not**: it is not a replacement for the per-primitive FSMs, the `Reason` explainability contract, or the existing `--privacy-mode` wire filter. It is the schema that wraps their output so the rest of the system (HOMECORE state machine, automation engine, Ruflo agents, the recorder) can reason about provenance.
### 1.3 The Provenance Rule
This ADR honours the project-wide rule that **every semantic state traces to signal evidence + model version + calibration version + privacy decision.** Today a `SemanticEvent` honours none of those four. After this ADR, a `SemanticStateRecord` carries all four as first-class fields, and the witness/proof chain (ADR-028 style) can assert that no record reaches an HA controller without them.
### 1.4 Pipeline Position
```
CSI frames (per node)
→ signal pipeline → FrameMeta { model_version, calibration_version } (ADR-136)
→ fusion engine → quality score + evidence_refs (ADR-137)
→ RawSnapshot (semantic/common.rs) ← unchanged projection
→ SemanticBus::tick() ← still runs 10+1 FSMs
→ SemanticStateRecord::from_event(meta, ev) ← NEW: wraps each SemanticEvent
carries model_version, calibration_version, confidence,
room, evidence_refs, expiry_at, privacy_action
├─→ MQTT / Matter publisher → privacy_action enforced at boundary (ADR-141 maps mode→action)
├─→ HOMECORE StateMachine::set() → state_changed broadcast (ADR-127)
│ → AutomationEngine triggers (ADR-129)
└─→ SemanticAgentBridge::route() ← NEW: feeds agreeing records to Ruflo (ADR-133)
→ RufloRunner::send_request() → caregiver escalation / multi-signal automation
```
The `SemanticBus` is unchanged except that `tick()` returns records instead of bare events; the FSMs themselves do not move. The new code is the record wrapper, the manifest loader, the `Rest` primitive, and the agent bridge.
---
## 2. Decision
### 2.1 The `SemanticStateRecord` Schema
A new struct in `semantic/common.rs`, the canonical output type of the bus. It wraps the existing `SemanticKind` + `PrimitiveState` + `Reason` without changing them.
```rust
use std::time::{Duration, SystemTime};
/// Privacy classification carried by every record. The *action* is
/// chosen at the state layer; the *enforcement* happens at the MQTT /
/// Matter boundary (mqtt/privacy.rs). The mode→action mapping is owned
/// by ADR-141 (BFLD Privacy Control Plane); this enum is the action
/// vocabulary it maps onto.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivacyAction {
/// Publish the record verbatim (room, attributes, all tags).
Allow,
/// Publish state + confidence, but replace `room` with a coarse
/// bucket ("upstairs", "downstairs", or "home") before the wire.
AnonymizeByRoom,
/// Publish the boolean/scalar state only; drop any attribute that
/// derives from a biometric channel (HR/BR-derived tags) and any
/// evidence_ref. Used for healthcare deployments.
StripBiometrics,
}
/// Opaque handle into the ADR-137 fusion evidence store, or an ADR-139
/// WorldGraph node id. Records what justified the assertion without
/// embedding the evidence itself (keeps records small + privacy-safe).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvidenceRef {
/// "fusion" | "worldgraph" | "vitals" | "cir" — the producing layer.
pub source: &'static str,
/// Stable id within that source (e.g. fusion clip id, graph node id).
pub id: String,
}
/// Versioned, auditable envelope around one primitive's output.
///
/// This is the inference-layer analogue of ADR-136's FrameMeta. It is
/// the type the SemanticBus emits and the type every downstream
/// consumer (MQTT, Matter, HOMECORE StateMachine, Ruflo bridge,
/// recorder) sees.
#[derive(Debug, Clone, PartialEq)]
pub struct SemanticStateRecord {
// ---- what was asserted -------------------------------------------
pub kind: SemanticKind,
pub state: PrimitiveState, // unchanged enum (Boolean/Scalar/Event/Idle)
pub node_id: String,
pub timestamp_ms: i64,
/// Room/zone this assertion is scoped to. None for whole-home
/// primitives (e.g. MultiRoom). Drawn from RawSnapshot.active_zones
/// or the ADR-139 WorldGraph room node.
pub room: Option<String>,
// ---- how confident -----------------------------------------------
/// Normalized confidence in [0,1], distinct from the Reason tags.
/// Derived per-primitive (see §2.6); 1.0 for deterministic FSM
/// transitions, < 1.0 when the producing fusion score was degraded.
pub confidence: f32,
// ---- provenance: model + calibration -----------------------------
/// Threaded from ADR-136 FrameMeta of the frames behind this snapshot.
pub model_version: String,
/// Empty-room baseline version (ADR-135). "uncalibrated" if no
/// baseline was loaded for node_id.
pub calibration_version: String,
/// Evidence handles (ADR-137 / ADR-139). Empty for pure-FSM
/// transitions that used only RawSnapshot scalars.
pub evidence_refs: Vec<EvidenceRef>,
// ---- validity + privacy ------------------------------------------
/// Wall-clock instant past which this record must not be acted upon
/// without refresh. Computed as timestamp + per-kind TTL (§2.4).
pub expiry_at: SystemTime,
/// Privacy classification (enforced downstream, §2.3).
pub privacy_action: PrivacyAction,
}
```
**Why a wrapper, not a field-extension of `SemanticEvent`.** `SemanticEvent` is a value type already serialized to the MQTT/Matter publishers and exercised by the proptest suite in `bus.rs` (the `bus_events_carry_node_id_and_ts` and `boolean_states_always_have_reason_tags` invariants). Replacing it outright would churn those tests. Instead, `SemanticEvent` becomes the *inner* assertion and `SemanticStateRecord` the *outer* envelope; the bus constructs records, and a `record.as_event()` accessor reproduces the old four-field shape for any caller that has not migrated. The proptest invariants are preserved verbatim and a new invariant — "every record carries a non-empty `model_version` and `calibration_version`" — is added.
### 2.2 Constructing a Record: `from_event`
The bus does not change the FSMs. It changes the assembly step in `SemanticBus::tick()` (`bus.rs:86-111`): the `filter_map` that builds `SemanticEvent`s now builds `SemanticStateRecord`s.
```rust
impl SemanticStateRecord {
/// Wrap one primitive's event with the provenance from the frame
/// metadata that produced the snapshot.
pub fn from_event(
ev: SemanticEvent,
meta: &SnapshotMeta, // see §2.6 — threaded with RawSnapshot
cfg: &PrimitiveConfig,
) -> Self {
let ttl = cfg.record_ttl(ev.kind); // §2.4
Self {
kind: ev.kind,
state: ev.state,
node_id: ev.node_id,
timestamp_ms: ev.timestamp_ms,
room: meta.room.clone(),
confidence: meta.confidence_for(ev.kind), // §2.6
model_version: meta.model_version.clone(),
calibration_version: meta.calibration_version.clone(),
evidence_refs: meta.evidence_refs.clone(),
expiry_at: meta.captured_at + ttl,
privacy_action: cfg.privacy_action_for(ev.kind),
}
}
/// Reproduce the legacy four-field event for un-migrated callers.
pub fn as_event(&self) -> SemanticEvent {
SemanticEvent {
kind: self.kind,
state: self.state.clone(),
node_id: self.node_id.clone(),
timestamp_ms: self.timestamp_ms,
}
}
}
```
`SnapshotMeta` is a small companion struct attached to each `RawSnapshot` carrying `model_version`, `calibration_version`, `evidence_refs`, `room`, `captured_at: SystemTime`, and the per-kind confidence inputs. It is populated by the snapshot projection step that already builds `RawSnapshot` from the `VitalsSnapshot` + `sensing_update` broadcast (`common.rs:5-33`). When the upstream frame metadata is absent (e.g. a synthetic test snapshot), `SnapshotMeta::unknown()` supplies `model_version = "unknown"`, `calibration_version = "uncalibrated"`, empty `evidence_refs`, and `confidence = 1.0` for deterministic FSM transitions — so existing tests that build a bare `RawSnapshot::default()` still pass.
### 2.3 `privacy_action` Semantics and the Boundary Contract
The record carries `privacy_action`, but the record layer **does not** redact anything. Redaction is enforced exactly where it is today — in `mqtt/privacy.rs` at the wire boundary — extended from a binary decision to one keyed on the record's action:
```rust
pub enum PublishDecision {
Publish, // unchanged: send verbatim
Suppress, // unchanged: drop silently
Redact(PrivacyAction), // NEW: send, but apply the action's transform
}
pub fn decide_record(rec: &SemanticStateRecord, mode_default: bool) -> PublishDecision {
match rec.privacy_action {
PrivacyAction::Allow => PublishDecision::Publish,
PrivacyAction::AnonymizeByRoom => PublishDecision::Redact(PrivacyAction::AnonymizeByRoom),
PrivacyAction::StripBiometrics => PublishDecision::Redact(PrivacyAction::StripBiometrics),
}
}
```
The existing biometric `EntityKind` filter (`privacy.rs:33-39`) is unchanged and runs first: raw HR/BR/pose entities are still `Suppress`ed under global `--privacy-mode`. The new `decide_record` path applies *only* to `SemanticStateRecord`s, which were never biometric and were always `Publish` (`privacy.rs:84-102`). The record's action therefore adds granularity *within* the always-published semantic class — it cannot weaken the existing global biometric suppression.
**The mode→action mapping is explicitly delegated to ADR-141.** This ADR defines the *action vocabulary* (`Allow`/`AnonymizeByRoom`/`StripBiometrics`) and the enforcement point. ADR-141 (BFLD Privacy Control Plane) owns the named privacy *modes* and the policy that maps a deployment's mode plus the primitive kind onto one of these actions — and the runtime attestation that the mapping was applied. `PrimitiveConfig::privacy_action_for(kind)` is the seam: in this ADR it returns a static default (`Allow` for all kinds, preserving today's behaviour); ADR-141 replaces the seam with its policy engine without re-touching the record schema.
### 2.4 Per-Kind TTL and `expiry_at`
`expiry_at` is computed as the record's `captured_at` plus a per-kind TTL drawn from `PrimitiveConfig`. The TTLs reflect each primitive's physical timescale, not a single global value, because acting on a stale `bed_exit` (a one-shot event) is very different from acting on a stale `someone_sleeping` (a sustained state).
| Kind | TTL | Rationale |
|------|-----|-----------|
| `BedExit`, `MultiRoom`, `FallRisk` (event) | 30 s | One-shot events; a consumer that acts more than 30 s late is acting on history, not state. |
| `RoomActive`, `BathroomOccupied`, `Rest` | 90 s | Occupancy states refresh on the 30 s `room_active_window`; 3× window before considered stale. |
| `SomeoneSleeping`, `NoMovement` | 10 min | Slow-changing states; the FSM dwell is minutes-to-hours. |
| `PossibleDistress`, `ElderlyAnomaly` | 5 min | Safety states; short enough that a missed refresh self-clears rather than persisting a false alarm. |
| `FallRisk` (scalar) | 5 min | Continuous score; recomputed every tick, so a 5 min TTL is generous. |
`record_ttl(kind)` returns these as `Duration`s; the values are config fields with the table above as `Default`. A consumer that reads a record past `expiry_at` MUST treat it as "unknown", not as the last asserted value — this is the contract the HOMECORE state machine and the automation engine rely on to avoid acting on stale safety states after a sensor outage.
### 2.5 The `Rest` Primitive — an Explicit v2 `SemanticKind`
The `SemanticKind` enum (`bus.rs:29-41`) gains one variant in this ADR:
```rust
pub enum SemanticKind {
SomeoneSleeping, PossibleDistress, RoomActive, ElderlyAnomaly,
Meeting, BathroomOccupied, FallRisk, BedExit, NoMovement, MultiRoom,
Rest, // NEW (v2)
}
```
`Rest` is the benign, expected inactivity state of a present, awake person (reading, watching TV): `presence == true` AND `motion < room_active_motion_threshold` AND NOT `someone_sleeping` AND breathing rate present and in the awake band, sustained for a dwell. It is added as a new primitive file `semantic/rest.rs` with its own FSM and tests, registered in the bus exactly as the existing ten are (one file change per the §3.12.6 "adding a primitive is one file change" contract documented in `mod.rs:18-22`).
**Why not alias `no_movement`.** `NoMovement` (`no_movement.rs`) is a *safety* primitive: it fires after 30 minutes of near-zero motion as a possible-collapse alarm, and the project doc (`no_movement.rs:1-6`) frames it that way. Aliasing `Rest` to it would conflate "person resting comfortably" with "person possibly collapsed" — the exact distinction caregivers need. `Rest` has a *shorter* dwell, a *higher* motion ceiling, and an explicit "awake breathing" gate, and crucially it carries the opposite automation intent: `Rest` should *suppress* environmental changes (don't turn the lights off on someone reading), whereas `NoMovement` should *escalate*. They are different states with different downstream consumers and must be different `SemanticKind`s.
**Deferral.** The remaining proposed v2 primitives — `child-play`, `pet-vs-human`, `agitation-gradient`, `circadian-phase` — are explicitly deferred to a follow-on ADR. They each require new signal inputs not present in `RawSnapshot` today (per-person classification embeddings, multi-day circadian baselines persisted across restart). `Rest` is the only v2 primitive that can be built from the existing `RawSnapshot` fields, so it is the only one promoted here.
### 2.6 Confidence Derivation and the Manifest
`confidence ∈ [0,1]` is per-record and per-kind. The rule:
1. A deterministic FSM transition that used only `RawSnapshot` scalars (e.g. `bed_exit` time-gate crossing) yields `confidence = 1.0` — the FSM is exact given its inputs.
2. When the producing snapshot carried an ADR-137 fusion quality score (degraded link, contradiction flag), `confidence` is the product of `1.0` and that fusion score, clamped to `[0,1]`. A `BathroomOccupied` derived from a node whose fusion score was 0.6 yields `confidence = 0.6`.
3. When the snapshot was produced on an `"uncalibrated"` node (no ADR-135 baseline), confidence is capped at `0.8` to flag that motion/amplitude thresholds were absolute rather than baseline-relative.
`PrimitiveConfig` is extended to load per-primitive **model/calibration metadata from a manifest**, so that the `model_version` and `calibration_version` stamped onto every record are auditable rather than hardcoded. Today `PrimitiveConfig::default()` hardcodes thresholds (`common.rs:102-122`); this ADR adds an optional manifest:
```rust
/// Loaded once at startup from `--semantic-manifest-file` (TOML). Maps a
/// model/calibration identity onto each primitive so records are auditable.
#[derive(Debug, Clone, Default)]
pub struct PrimitiveManifest {
/// e.g. "ha-mind-v2.1" — the semantic-layer model bundle version.
pub model_version: String,
/// Build commit hash of the sensing-server that produced records.
pub commit_hash: String,
/// ISO-8601 date the model bundle was trained/released.
pub model_date: String,
/// Per-node calibration versions, keyed by node_id, from ADR-135
/// baseline files. "uncalibrated" when absent.
pub calibration_versions: std::collections::HashMap<String, String>,
}
impl PrimitiveConfig {
pub fn manifest(&self) -> &PrimitiveManifest; // NEW field accessor
pub fn record_ttl(&self, kind: SemanticKind) -> Duration; // §2.4
pub fn privacy_action_for(&self, kind: SemanticKind) -> PrivacyAction; // §2.3
}
```
The manifest TOML:
```toml
[model]
version = "ha-mind-v2.1"
commit_hash = "850463818"
date = "2026-05-28"
[calibration]
"esp32s3-com9" = "baseline-2026-05-28T14:32:00Z"
"cognitum-seed-1" = "baseline-2026-05-27T09:10:00Z"
# nodes absent here are stamped "uncalibrated"
```
When no `--semantic-manifest-file` is supplied, `PrimitiveManifest::default()` stamps `model_version = "unknown"`, `commit_hash = ""`, and every node as `"uncalibrated"` — identical observable behaviour to today, but now explicit on every record.
### 2.7 The Ruflo Agent Bridge (ADR-133 Integration Path)
This ADR defines the path by which `SemanticStateRecord`s reach a Ruflo agent so that automations can route on **multi-signal agreement** — agreement no single primitive can decide. The motivating case: `FallRisk` (elevated) AND `ElderlyAnomaly` (firing) within a short window in the same room ⇒ caregiver escalation. `fall_risk.rs` cannot see `elderly_anomaly`'s state, and vice versa; only an aggregator over records can.
The bridge is a new component, `SemanticAgentBridge`, in `homecore-assist` (alongside the existing `RufloRunner` trait in `runner.rs`). It does **not** replace the voice/intent pipeline — it reuses the same `RufloRunner` subprocess transport.
```rust
/// Subscribes to the SemanticStateRecord stream and routes agreeing
/// records to a Ruflo agent for multi-signal automation decisions.
/// Reuses the existing RufloRunner transport (homecore-assist/runner.rs).
pub struct SemanticAgentBridge<R: RufloRunner> {
runner: R,
rules: Vec<AgreementRule>,
/// Sliding window of recent records per (room, kind).
recent: RecordWindow,
}
/// A multi-signal agreement that, when satisfied, sends a payload to the
/// agent. Declarative so ADR-129 automations and ADR-141 policy can
/// extend the set without code changes.
pub struct AgreementRule {
pub name: &'static str,
/// All of these kinds must have a *fresh* (non-expired), active
/// record scoped to the same room within `window`.
pub require: Vec<SemanticKind>,
pub window: Duration,
/// Minimum confidence each constituent record must clear.
pub min_confidence: f32,
/// Intent name handed to the Ruflo agent on satisfaction.
pub agent_intent: &'static str,
}
impl<R: RufloRunner> SemanticAgentBridge<R> {
/// Ingest one record. If it completes an AgreementRule, build a
/// JSON payload (records + their provenance) and call
/// RufloRunner::send_request(). Returns the agent's RufloResponse
/// when a rule fired, else None.
pub async fn route(&mut self, rec: SemanticStateRecord)
-> Result<Option<RufloResponse>, AssistError>;
}
```
The default rule set ships one rule:
```rust
AgreementRule {
name: "caregiver_escalation",
require: vec![SemanticKind::FallRisk, SemanticKind::ElderlyAnomaly],
window: Duration::from_secs(120),
min_confidence: 0.7,
agent_intent: "HassCaregiverEscalate",
}
```
**Provenance is mandatory on the agent payload.** The JSON sent to the agent via `send_request()` (`runner.rs:86-89`) includes, for each constituent record, its `model_version`, `calibration_version`, `confidence`, `room`, and `evidence_refs`. This is the project provenance rule applied to the agent boundary: the agent never sees a bare "fall risk is high" — it sees "fall risk is high, confidence 0.82, model ha-mind-v2.1, node esp32s3-com9 calibrated baseline-2026-05-28, evidence fusion#clip-1841." An agent declining or confirming an escalation does so against an auditable record.
**P1/P2 staging.** With the existing `NoopRunner` (`runner.rs:113-139`), `route()` returns `Ok(None)` and the bridge falls back to a deterministic local decision (fire the escalation event directly into the HOMECORE state machine). When the real subprocess `RufloRunner` lands (ADR-133 P2, `runner.rs:9-18` deferral), `route()` consults the agent. The bridge is written against the trait, so no bridge code changes when the runner is swapped — mirroring how the assist pipeline already swaps `NoopRunner` for the real runner.
### 2.8 Bridge to HOMECORE State Machine
`SemanticStateRecord`s also flow into the HOMECORE `StateMachine` (`homecore/src/state.rs`) so that ADR-129 automations can trigger on them via the existing `state_changed` broadcast. The mapping:
- Each record becomes a `StateMachine::set(entity_id, state, attributes, context)` call (`state.rs:75-110`). The `entity_id` is `binary_sensor.<room>_<kind>` (or `sensor.` for `FallRisk`), matching the HA entity naming the MQTT discovery already uses.
- The record's provenance (`model_version`, `calibration_version`, `confidence`, `expiry_at`, `privacy_action`, `evidence_refs`) is serialized into the `attributes: serde_json::Value` so it survives into the `StateChangedEvent` (`event.rs:101-106`) and is queryable by automations and the recorder.
- The `Context` (`event.rs:42-69`) is stamped with the bridge as origin so automations can detect and avoid self-trigger loops, exactly as HA's context does.
The HOMECORE state machine already suppresses no-op writes (`state.rs:92-99`); a record whose `state` and `attributes` are unchanged from the prior write does not re-fire the broadcast, so a primitive emitting the same `Scalar` confidence every tick does not spam the channel. A record's `expiry_at` is written into attributes; a consumer reading state past that instant treats it as `unknown` (§2.4).
### 2.9 Interface Boundaries (Summary)
| Boundary | Type crossing it | Owner |
|----------|------------------|-------|
| signal → semantic | `RawSnapshot` + `SnapshotMeta` (model/calibration/evidence) | `semantic/common.rs` (ADR-136 supplies meta) |
| semantic bus output | `SemanticStateRecord` | `semantic/bus.rs` (this ADR) |
| semantic → MQTT/Matter | `SemanticStateRecord``PublishDecision` | `mqtt/privacy.rs` (this ADR; mapping by ADR-141) |
| semantic → HOMECORE | `SemanticStateRecord``StateMachine::set` | `homecore/src/state.rs` (this ADR) |
| semantic → Ruflo | agreeing records → JSON payload → `RufloRunner::send_request` | `homecore-assist` `SemanticAgentBridge` (this ADR; transport from ADR-133) |
| legacy callers | `SemanticStateRecord::as_event()``SemanticEvent` | back-compat shim (this ADR) |
### 2.10 Test Plan
**Tier 1 — Record construction is total (unit test, `common.rs`).** For every `SemanticKind` variant (now 11 including `Rest`) and every non-`Idle` `PrimitiveState`, `SemanticStateRecord::from_event` produces a record with a non-empty `model_version`, non-empty `calibration_version`, a finite `confidence ∈ [0,1]`, and an `expiry_at > timestamp`. Assert `as_event()` round-trips the four legacy fields exactly.
**Tier 2 — Provenance proptest (extend `bus.rs` proptest suite).** Reuse the existing `arb_snapshot()` strategy. Assert a new invariant alongside the existing ones (`bus_events_carry_node_id_and_ts`, `boolean_states_always_have_reason_tags`): **every emitted `SemanticStateRecord` carries a non-empty `model_version` and `calibration_version`**, and `confidence` is in `[0,1]`. This wires the provenance rule into the property suite that already guards the bus.
**Tier 3 — Default behaviour unchanged (unit test).** With `PrimitiveManifest::default()` and `privacy_action_for` returning `Allow`, assert `decide_record` returns `Publish` for all 11 kinds — i.e. zero observable change from today's `privacy.rs:84-102` behaviour. This is the no-regression gate.
**Tier 4 — `Rest` distinct from `NoMovement` (unit test, `rest.rs`).** Feed a sequence: present, awake breathing (br ≈ 14 bpm), motion 0.05 for 3 minutes. Assert `Rest` fires `Boolean { active: true }` and `NoMovement` stays `Idle` (its 30-min dwell is not met and motion ≥ 0.01). Then drop motion to 0.005 for 30 minutes and assert `NoMovement` fires while `Rest` exits — proving the two states are not aliases.
**Tier 5 — TTL / staleness (unit test).** Build a `FallRisk` event record and a `SomeoneSleeping` record. Assert `expiry_at - captured_at == 30 s` and `10 min` respectively (per §2.4 table). Assert a helper `record.is_expired(now)` returns `true` past `expiry_at`.
**Tier 6 — `privacy_action` enforcement (unit test, `mqtt/privacy.rs`).** For a record with `privacy_action = AnonymizeByRoom`, assert `decide_record` returns `Redact(AnonymizeByRoom)` and that the redaction transform replaces `room = "bedroom"` with a coarse bucket. For `StripBiometrics`, assert HR/BR-derived `Reason` tags and `evidence_refs` are removed while the boolean state survives. For `Allow`, verbatim publish.
**Tier 7 — Multi-signal agreement bridge (async unit test, `homecore-assist`).** With a `NoopRunner`, feed a `FallRisk` record then an `ElderlyAnomaly` record for the same room within 120 s, both `confidence ≥ 0.7`. Assert `route()` recognises the `caregiver_escalation` rule and (since the runner is a no-op) falls back to firing the escalation locally. Feed the same two records > 120 s apart and assert no escalation. Feed them in *different* rooms and assert no escalation.
**Tier 8 — HOMECORE state-machine bridge (async unit test).** Route a record into a `StateMachine`; subscribe; assert a `StateChangedEvent` (`event.rs:101-106`) fires whose `new_state` attributes contain `model_version`, `calibration_version`, `confidence`, and `expiry_at`. Route an identical record again; assert the no-op suppression (`state.rs:92-99`) yields no second event.
### 2.11 Witness / Proof
Per ADR-028, three rows are added to `docs/WITNESS-LOG-028.md`:
| Row | Capability | Evidence |
|-----|-----------|----------|
| W-39 | Every `SemanticStateRecord` carries model + calibration version (proptest invariant) | `cargo test -p wifi-densepose-sensing-server semantic::` proptest passes |
| W-40 | `privacy_action` enforced at the MQTT boundary (Allow/AnonymizeByRoom/StripBiometrics) | `cargo test mqtt::privacy::tests::decide_record_*` passes |
| W-41 | Multi-signal agreement routes to Ruflo bridge (fall_risk + elderly_anomaly → escalation) | `cargo test -p homecore-assist bridge::tests::caregiver_escalation` passes |
`source-hashes.txt` in the witness bundle gains the SHA-256 of `semantic/common.rs`, `semantic/rest.rs`, and the new bridge module.
---
## 3. Consequences
### 3.1 Positive
- **Auditable states.** Every published semantic state now traces to a model version, a calibration version, signal evidence, and a privacy decision. A caregiver-escalation automation can refuse to act on records from an `"uncalibrated"` node, closing the silent-degradation hole where an uncalibrated node's absolute thresholds produced unreliable states with no flag.
- **Privacy granularity without weakening the existing guarantee.** The `privacy_action` enum adds room-anonymization and biometric-stripping *within* the always-published semantic class, while the existing global biometric `Suppress` filter (`privacy.rs`) is untouched and still runs first. Healthcare deployments gain `StripBiometrics` per-record without a new wire schema.
- **Multi-signal automations become possible.** The agent bridge enables decisions no single primitive can make (`fall_risk` + `elderly_anomaly` → caregiver), reusing the existing `RufloRunner` transport rather than inventing a new IPC path.
- **`Rest` unblocks suppression automations.** Automations can finally subscribe to "person resting comfortably" and suppress environmental changes, instead of fragilely inferring it from the absence of `RoomActive`.
- **Back-compatible.** `SemanticEvent` is preserved as the inner type; `as_event()` and `PrimitiveManifest::default()` mean un-migrated callers and existing tests observe no behaviour change.
### 3.2 Negative
- **Larger records on the wire.** A `SemanticStateRecord` carries five new fields plus `evidence_refs`. For high-rate `Scalar` primitives (`fall_risk` publishes every tick) this is more bytes; the HOMECORE no-op suppression (`state.rs:92-99`) and the per-kind TTL mitigate the rate, but MQTT payloads grow.
- **Manifest is a new operational artifact.** Operators must supply `--semantic-manifest-file` to get meaningful `model_version`/`calibration_version`; absent it, every node is stamped `"uncalibrated"`. This is not a regression (today there is no version at all) but it is a new step to get full auditability.
- **Bridge couples two crates.** `homecore-assist` now depends on the `SemanticStateRecord` type from the sensing server. The dependency is one-directional (assist depends on the semantic schema, not vice versa) and the schema is small, but it is a new cross-crate edge.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Confidence derivation is gamed by always returning 1.0 | Medium | Records look more trustworthy than they are; uncalibrated nodes' states acted on blindly | §2.6 caps confidence at 0.8 on `"uncalibrated"` nodes and multiplies by the ADR-137 fusion score; Tier 2 proptest asserts `confidence ∈ [0,1]` but a separate review must confirm the per-kind derivation is honest |
| Agreement rule fires on coincidental co-occurrence | Medium | Spurious caregiver escalation | `min_confidence` gate + same-room scoping + 120 s window; the agent (when present) makes the final call with full provenance, declining low-evidence escalations |
| `expiry_at` consumers ignore it and act on stale safety states | Low | Acting on a post-outage stale `possible_distress` | The contract is documented (§2.4) and the HOMECORE attributes carry `expiry_at`; Tier 5 tests `is_expired`; recorder can flag consumers that read past expiry |
| ADR-141 mode→action mapping not yet built; `privacy_action` defaults to `Allow` everywhere | High (until ADR-141 lands) | No room-anonymization until the policy engine ships | `privacy_action_for` seam returns `Allow` (today's behaviour) until ADR-141 replaces it; no record-schema change needed when it does |
---
## 4. Alternatives Considered
### 4.1 Extend `SemanticEvent` In Place Instead of Wrapping
Add the five provenance fields directly to `SemanticEvent`. Rejected: `SemanticEvent` is already serialized to MQTT/Matter and is the subject of five proptest invariants in `bus.rs`. Mutating it churns the wire format and the tests simultaneously. The wrapper + `as_event()` shim isolates the change, keeps the proptest suite green, and lets callers migrate incrementally.
### 4.2 Put Provenance in the `Reason` Tags
`Reason` is already a `Vec<String>` (`common.rs:50-65`); one could append `"model=ha-mind-v2.1"` tags. Rejected: tags are human-readable debug strings, not a machine schema. An automation would have to string-parse tags to find the model version, which is brittle and untyped. Provenance must be typed fields so consumers and the recorder can query them structurally.
### 4.3 Alias `Rest` to `NoMovement`
Reuse `NoMovement` for the rest state with a different threshold. Rejected in §2.5: `NoMovement` is a *safety/escalation* primitive (possible collapse), `Rest` is a *suppression* primitive (don't disturb). They carry opposite automation intent and different dwell/motion semantics; conflating them would make it impossible for an automation to distinguish "resting" from "possibly collapsed" — the exact distinction caregivers need.
### 4.4 Route All Records to the Agent
Send every `SemanticStateRecord` to the Ruflo agent and let the LLM decide everything. Rejected: most records (a single `room_active` toggle) need no LLM reasoning, and the agent subprocess (ADR-133) has a 5 s timeout (`runner.rs:51`) and per-call cost. The declarative `AgreementRule` set filters to the multi-signal cases that actually need cross-primitive reasoning, keeping the single-signal path deterministic and free.
### 4.5 Enforce Privacy at the Record Layer
Have `SemanticStateRecord` redact itself (drop `room`, strip biometrics) before publishing. Rejected: redaction must happen at the wire boundary so the same record can be published differently to different transports (full to a local trusted HOMECORE state machine, anonymized to an external MQTT broker). The record carries the *action*; `mqtt/privacy.rs` applies the *transform* per transport. This also keeps the enforcement point co-located with the existing biometric filter, so ADR-141's attestation can verify one place.
---
## 5. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-115 (HA Integration / HA-MIND) | **Extended**: the ten §3.12 semantic primitives now emit `SemanticStateRecord`s; the `SemanticEvent` becomes the inner assertion |
| ADR-127 (HOMECORE State Machine) | **Consumer**: records bridge into `StateMachine::set` and surface as `StateChangedEvent` attributes |
| ADR-129 (HOMECORE Automation Engine) | **Consumer**: automations trigger on record attributes (confidence, expiry_at) via the state_changed broadcast |
| ADR-133 (HOMECORE-ASSIST + Ruflo) | **Path defined**: `SemanticAgentBridge` reuses the `RufloRunner` transport; multi-signal agreement routes records to the agent |
| ADR-135 (Empty-Room Calibration) | **Provenance source**: `calibration_version` is the ADR-135 baseline file version per node |
| ADR-136 (Streaming Engine / FrameMeta) | **Provenance source**: `model_version` and `calibration_version` thread from the ADR-136 `FrameMeta` |
| ADR-137 (Fusion Quality / Evidence Refs) | **Provenance source**: `evidence_refs` are handles into the ADR-137 evidence store; `confidence` multiplies the fusion quality score |
| ADR-139 (WorldGraph) | **Provenance source**: `room` and some `evidence_refs` resolve to ADR-139 WorldGraph node ids |
| ADR-141 (BFLD Privacy Control Plane) | **Delegates**: ADR-141 owns the mode→`PrivacyAction` mapping and runtime attestation; this ADR defines the action vocabulary and enforcement point |
| ADR-021 (ESP32 Vital Signs) | **Substrate**: HR/BR channels are the biometrics `StripBiometrics` strips and the awake-breathing gate `Rest` consumes |
| ADR-125 (Apple Home Native HAP Bridge) | **Consumer**: records reaching the HOMECORE state machine surface as HAP characteristics; `privacy_action` governs what the HAP bridge exposes |
---
## 6. References
### Production Code
- `v2/crates/wifi-densepose-sensing-server/src/semantic/bus.rs``SemanticBus`, `SemanticEvent`, `SemanticKind` (the bus this ADR wraps)
- `v2/crates/wifi-densepose-sensing-server/src/semantic/common.rs``RawSnapshot`, `PrimitiveState`, `Reason`, `PrimitiveConfig` (the schema home for `SemanticStateRecord`)
- `v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs` — the "adding a primitive is one file change" contract (§3.12.6) `Rest` follows
- `v2/crates/wifi-densepose-sensing-server/src/semantic/no_movement.rs` — the safety primitive `Rest` must not be aliased to
- `v2/crates/wifi-densepose-sensing-server/src/semantic/fall_risk.rs`, `elderly_anomaly.rs` — the two primitives whose agreement drives caregiver escalation
- `v2/crates/wifi-densepose-sensing-server/src/mqtt/privacy.rs``PublishDecision`, `decide`; extended with `decide_record` and `Redact`
- `v2/crates/homecore/src/state.rs``StateMachine::set`, no-op suppression, `state_changed` broadcast
- `v2/crates/homecore/src/event.rs``StateChangedEvent`, `Context`, `EventType`
- `v2/crates/homecore-assist/src/runner.rs``RufloRunner` trait + `NoopRunner`; transport reused by `SemanticAgentBridge`
- `v2/crates/homecore-assist/src/lib.rs` — ADR-133 P1 scope and the P2 deferral the bridge stages against
- `v2/crates/homecore-recorder/src/semantic.rs` — semantic index that will record record provenance (ADR-132 path)
### Related ADRs (this series)
- `docs/adr/ADR-136-ruview-streaming-engine-frame-contracts.md``FrameMeta` source of `model_version` / `calibration_version`
- `docs/adr/ADR-137-fusion-engine-quality-scoring-evidence.md` — evidence references and contradiction flags feeding `evidence_refs` + `confidence`
- `docs/adr/ADR-139-worldgraph-environmental-digital-twin.md` — room/node resolution for `room` and graph `evidence_refs`
- `docs/adr/ADR-141-bfld-privacy-control-plane-modes-attestation.md` — owns the mode→`PrivacyAction` mapping and attestation
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `169a355bd`, issue #844): `SemanticStateRecord` (provenance-carrying), `PrivacyAction`, and the `MultiSignalRule` agent bridge that fires only on multi-signal agreement. 4 tests.
**Integration glue -- not yet on the live path:** the `Rest` `SemanticKind` (deferred to avoid an enum-match cascade); subscribing `route_all()` to the broadcast bus -> ADR-133 HOMECORE-ASSIST; and loading the per-primitive model/calibration manifest into `RecordContext`.
**Trust contribution:** high-stakes actions (caregiver escalation) require *multiple independent signals to agree*, and every emitted record carries model + calibration + privacy provenance and an expiry.
@@ -0,0 +1,601 @@
# ADR-141: BFLD Privacy Control Plane: Named Modes, Actions, and Runtime Attestation
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-bfld` (new module `mode.rs` + `attestation.rs`; extends `lib.rs` `PrivacyClass`, `sink.rs`, `privacy_gate.rs`, `identity_risk.rs`, `emitter.rs`, `ha_discovery.rs`) |
| **Relates to** | ADR-010 (Witness Chains), ADR-118 (BFLD), ADR-120 (Privacy Class + Hash Rotation), ADR-121 (Identity-Risk Scoring), ADR-122 (RuView HA/Matter Exposure), ADR-136 (Streaming Engine), ADR-139 (WorldGraph), ADR-140 (Semantic State Record), ADR-143 (RF SLAM v2) |
---
## 1. Context
### 1.1 The Gap
The BFLD crate (`v2/crates/wifi-densepose-bfld/src/`) already implements a complete, structurally enforced privacy posture, but it does so entirely in terms of a **4-value numeric class** — there is no first-class concept of a deployment *mode* and no concept of a discrete privacy *action*. Reading the real code:
- `lib.rs` defines `PrivacyClass` as `#[repr(u8)]` with four variants `Raw = 0`, `Derived = 1`, `Anonymous = 2`, `Restricted = 3`, plus `allows_network()` / `allows_matter()` / `as_u8()` (`lib.rs:82-117`). This is the entire vocabulary the system has for "what is this deployment allowed to emit." Nothing names *why* a node is at class 2 vs class 3, nor records which privacy transformations were actually applied.
- `privacy_gate.rs` implements `PrivacyGate::demote()` — a monotonic, zeroizing transformer that strips payload sections (`compressed_angle_matrix`, `csi_delta`, `amplitude_proxy`, `phase_proxy`) on each class transition (`privacy_gate.rs:31-75`). The stripping is real and irreversible, but it is **silent**: nothing records *which* sections were zeroed for *which* frame. There is no audit trail and no way for a downstream verifier to prove what was stripped.
- `sink.rs` enforces I1 at compile time via `Sink::MIN_CLASS` and the runtime `check_class::<S>()` (`sink.rs:47-55`), with the three concrete `LocalKind`/`NetworkKind`/`MatterKind` tags. The MQTT topic router (`mqtt_topics.rs:109-157`) and HA discovery (`ha_discovery.rs:61-129`) hard-code the rule "publish only at class >= Anonymous, and `identity_risk` only at exactly Anonymous." This is an *implicit ACL* scattered across two files; it is not declared in one place and is not bound to a named mode.
- `identity_risk.rs` defines `GateAction { Accept, PredictOnly, Reject, Recalibrate }` (`identity_risk.rs:57-69`) — but these are *risk-gating* actions on a per-event basis, not *privacy* actions. There is no enum that names the privacy transformation a mode enforces (e.g., "suppress identity", "drop raw", "aggregate only").
- `emitter.rs` hard-codes `privacy_class: PrivacyClass::Anonymous` as the constructed default (`emitter.rs:82`) and the Soul Signature gate is controlled only by whether a `SoulMatchOracle` is supplied (`emitter.rs:138`, `coherence_gate.rs:71`). Whether Soul Signature is *enabled* for a deployment is not a declared policy — it is an implicit consequence of construction-site wiring.
The consequence: a deployment's privacy stance is encoded in **four separate places** — the constructed `PrivacyClass`, the presence/absence of a `SoulMatchOracle`, the class-gated MQTT/HA fan-out, and the `signature_hasher` install — with no single declared object that says "this node runs in *CareWithConsent* mode, which means class Derived, Soul Signature enabled, identity_risk published, raw never networked." There is no runtime artifact a regulator, a Home Assistant dashboard, or the WorldGraph (ADR-139) can read to learn the *effective* policy, and no cryptographic proof that the policy was actually enforced frame-by-frame.
ADR-140 (Semantic State Record) requires that every semantic state trace to a `privacy_action`. ADR-139 (WorldGraph) needs a `privacy_limited_by` annotation to compute which edges/zones are degraded by privacy. Neither has anything to bind to today: BFLD exposes a numeric class but no *action* and no *attestation*. This ADR closes that gap.
### 1.2 What "Mode", "Action", and "Attestation" Mean Here
- A **PrivacyMode** is a named, operator-facing deployment posture (e.g., `CareWithConsent`). It is the human-meaningful unit a regulator or installer reasons about. It is *not* a new enforcement primitive — it is a declarative selection that *maps to* the existing `PrivacyClass`, plus a Soul Signature gate decision, plus an MQTT/Matter ACL.
- A **PrivacyAction** is the discrete, machine-checkable privacy transformation that a mode enforces (e.g., `SuppressIdentity`, `DropRaw`). Actions are the bridge between the human mode and the byte-level stripping `privacy_gate.rs` already performs. They are what ADR-140's `privacy_action` field carries.
- A **PrivacyAttestationProof** is a hash-chained record (per ADR-010) of *which mode was active, which actions were enforced, and which fields were stripped per event*. It is the cryptographic continuity proof that the declared mode was honored, surfaced read-only to HA/Matter diagnostics.
What this ADR is **not**: it does not change the four `PrivacyClass` byte values, does not weaken any structural invariant (I1/I2/I3 from `lib.rs:8-11`), and does not replace `PrivacyGate::demote()` — it *records* what `demote()` did.
### 1.3 Pipeline Position
```
SensingInputs
→ BfldEmitter::emit() (identity_risk + CoherenceGate)
↑ consults
PrivacyModeRegistry::active_mode() ← NEW
↓ resolves to (PrivacyClass, Soul gate, ACL)
→ PrivacyGate::demote(frame, target_class) (existing; now records stripped fields)
↓ emits per-frame
PrivacyActionRecord { actions, fields_stripped } ← NEW
↓ folded into
PrivacyAttestationProof { mode, actions, fields_stripped_per_event, prev_hash } ← NEW (hash-chained, ADR-010)
↓ surfaced
mqtt_topics.rs / ha_discovery.rs (active mode + proof hash diagnostic entity)
↓ consumed by
ADR-139 privacy_limited_by / ADR-140 privacy_action
```
The registry is consulted once per class transition (not once per byte). The attestation chain is appended per emitted event window, not per frame, to bound chain growth (see §2.5).
---
## 2. Decision
### 2.1 `PrivacyMode`: Five Named Variants Layered Over `PrivacyClass`
Introduce `PrivacyMode` in a new module `mode.rs`. It is a *semantic abstraction* over the existing 4-class `PrivacyClass`; it adds zero new enforcement bytes on the wire.
```rust
// v2/crates/wifi-densepose-bfld/src/mode.rs
use crate::PrivacyClass;
/// Operator-facing deployment posture. Maps deterministically to a
/// `PrivacyClass`, a Soul Signature gate decision, and an MQTT/Matter ACL via
/// the `PrivacyModeRegistry`. Adds no new wire bytes — `PrivacyClass` remains
/// the only byte carried in `BfldFrameHeader`.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PrivacyMode {
/// Local research: raw BFI retained, never networked. Maps to `Raw`.
RawResearch = 0,
/// Single-home production: anonymous sensing, Soul Signature OFF.
/// Maps to `Anonymous`, no per-day rf_signature_hash.
PrivateHome = 1,
/// Multi-tenant / enterprise: anonymous + per-seed salt rotation so no
/// two seeds can correlate. Maps to `Anonymous`, multiseed salt domain.
EnterpriseAnonymous = 2,
/// Care deployment with explicit consent: identity-derived fields enabled
/// behind consent. Maps to `Derived`, Soul Signature ON.
CareWithConsent = 3,
/// Regulated / no-identity: strictest posture. Maps to `Restricted`.
StrictNoIdentity = 4,
}
impl PrivacyMode {
/// The `PrivacyClass` this mode resolves to. This is the *only* coupling
/// to the existing enforcement layer.
#[must_use]
pub const fn privacy_class(self) -> PrivacyClass {
match self {
Self::RawResearch => PrivacyClass::Raw,
Self::PrivateHome | Self::EnterpriseAnonymous => PrivacyClass::Anonymous,
Self::CareWithConsent => PrivacyClass::Derived,
Self::StrictNoIdentity => PrivacyClass::Restricted,
}
}
/// Whether Soul Signature (`SignatureHasher` install + non-`Null` oracle)
/// is enabled in this mode. See `emitter.rs:138` / `coherence_gate.rs:71`.
#[must_use]
pub const fn soul_signature_enabled(self) -> bool {
matches!(self, Self::CareWithConsent)
}
/// Whether per-seed (multiseed) salt isolation is required so two seeds
/// in the same site produce uncorrelated `rf_signature_hash` (invariant I3,
/// `signature_hasher.rs:8-18`). Enterprise turns this on; single-home does not.
#[must_use]
pub const fn multiseed_salt(self) -> bool {
matches!(self, Self::EnterpriseAnonymous)
}
/// Stable string token used in TOML config, MQTT diagnostics, and the
/// attestation proof. Lowercase snake form of the variant.
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::RawResearch => "raw_research",
Self::PrivateHome => "private_home",
Self::EnterpriseAnonymous => "enterprise_anonymous",
Self::CareWithConsent => "care_with_consent",
Self::StrictNoIdentity => "strict_no_identity",
}
}
}
```
The decision to keep `PrivacyMode` separate from `PrivacyClass` (rather than collapsing the two into a 5-variant class) is deliberate: `PrivacyClass` is a wire/sink-enforcement primitive with byte semantics relied on by `frame.rs`, `sink.rs::check_class`, and the on-NVS/MQTT representation. Two of the five modes (`PrivateHome`, `EnterpriseAnonymous`) resolve to the *same* class (`Anonymous`) but differ in salt domain — they are not separable at the class layer. Modes are a strictly higher-level concept and must not perturb the existing byte contract.
### 2.2 `PrivacyAction`: The Enforced-Transformation Vocabulary
```rust
// v2/crates/wifi-densepose-bfld/src/mode.rs (continued)
/// A discrete privacy transformation a mode enforces. These are the
/// machine-checkable bridge between a human `PrivacyMode` and the byte-level
/// stripping already performed by `PrivacyGate::demote()` (`privacy_gate.rs`).
///
/// ADR-140's semantic-state `privacy_action` field carries the *strongest*
/// action enforced for the event that produced the state.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum PrivacyAction {
/// No transformation: the frame is published as-is at its class.
Allow = 0,
/// Strip identity-derived fields (`identity_risk_score`, `rf_signature_hash`)
/// — the `Restricted` strip in `event.rs:112-117`.
SuppressIdentity = 1,
/// Down-sample the angle/CSI surface (the `compressed_angle_matrix` /
/// `csi_delta` zeroing in `privacy_gate.rs:48-55`).
ReduceResolution = 2,
/// Refuse to network a `Raw` frame (structural invariant I1, `sink.rs:35`).
DropRaw = 3,
/// Emit only aggregate sensing (presence/motion/count/confidence); no
/// per-subject or per-cluster surface leaves the node.
AggregateOnly = 4,
}
```
`PrivacyAction` is `Ord` so a per-event set can be reduced to its **strongest** action for ADR-140's single-valued `privacy_action` field (the maximum). The actions are intentionally orthogonal to `GateAction` (`identity_risk.rs:57`): `GateAction` answers "is this *event* too risky to publish?"; `PrivacyAction` answers "what privacy transformation does the active *mode* require on every event?" They compose — a mode may enforce `SuppressIdentity` while the per-event gate independently `Reject`s.
### 2.3 `PrivacyModeRegistry`: Single Source of Truth + Append-Only Audit Log
The registry is the one declared object that the gap (§1.1) is missing. It owns the active mode, the mode→actions mapping, the ACL, and an append-only audit log that the witness verifier can replay.
```rust
// v2/crates/wifi-densepose-bfld/src/mode.rs (continued)
use crate::sink::Sink;
/// Declares the active mode and the policy it implies. Consulted by the
/// emitter/gate on every class transition. Holds an append-only, witness-
/// checkable audit log of every mode resolution and action enforcement.
#[derive(Debug)]
pub struct PrivacyModeRegistry {
active: PrivacyMode,
/// Append-only; never mutated in place. Each entry is hashed into the
/// attestation chain (§2.5).
audit_log: Vec<ModeAuditEntry>,
}
/// One append-only audit record. ADR-010 §"Hash chain" linkage is applied at
/// the `PrivacyAttestationProof` layer, not here — this is the raw event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModeAuditEntry {
/// Monotonic capture-clock ns (matches `BfldEvent::timestamp_ns`).
pub timestamp_ns: u64,
/// Mode active at the moment of this transition/resolution.
pub mode: PrivacyMode,
/// Class the mode resolved to.
pub resolved_class: PrivacyClass,
/// The set of actions enforced, sorted ascending (Ord), deduplicated.
pub actions_enforced: Vec<PrivacyAction>,
}
impl PrivacyModeRegistry {
/// Build a registry pinned to `mode`. The production-safe default is
/// `PrivateHome` (resolves to `Anonymous`, matching `emitter.rs:82`).
#[must_use]
pub fn new(mode: PrivacyMode) -> Self {
Self { active: mode, audit_log: Vec::new() }
}
/// The currently active mode.
#[must_use]
pub const fn active_mode(&self) -> PrivacyMode {
self.active
}
/// The set of actions this mode enforces, sorted ascending. Pure function
/// of `active` — the canonical mode→actions mapping (§2.4 table).
#[must_use]
pub fn enforced_actions(&self) -> Vec<PrivacyAction> {
actions_for(self.active)
}
/// Whether a specific action is enforced under the active mode. This is the
/// predicate ADR-139/ADR-140 query to decide `privacy_limited_by` and
/// `privacy_action`.
#[must_use]
pub fn is_action_enforced(&self, action: PrivacyAction) -> bool {
actions_for(self.active).contains(&action)
}
/// Whether the active mode's class may cross sink `S`. Re-uses the
/// existing compile-time ACL (`sink.rs::check_class`). This is the
/// declared-in-one-place MQTT/Matter ACL the gap (§1.1) lacked.
#[must_use]
pub fn allows_sink<S: Sink>(&self) -> bool {
crate::sink::check_class::<S>(self.active.privacy_class()).is_ok()
}
/// Record a class transition / resolution into the append-only log and
/// return the entry that was appended (so the caller can fold it into the
/// attestation chain). Called by the emitter on every transition.
pub fn record_transition(&mut self, timestamp_ns: u64) -> &ModeAuditEntry {
let entry = ModeAuditEntry {
timestamp_ns,
mode: self.active,
resolved_class: self.active.privacy_class(),
actions_enforced: actions_for(self.active),
};
self.audit_log.push(entry);
self.audit_log.last().expect("just pushed")
}
/// Read-only view of the audit log for the witness verifier.
#[must_use]
pub fn audit_log(&self) -> &[ModeAuditEntry] {
&self.audit_log
}
}
/// Canonical mode→actions mapping (§2.4). Pure, total, `const`-friendly.
#[must_use]
pub fn actions_for(mode: PrivacyMode) -> Vec<PrivacyAction> {
use PrivacyAction::{Allow, AggregateOnly, DropRaw, ReduceResolution, SuppressIdentity};
let v = match mode {
PrivacyMode::RawResearch => vec![Allow], // local-only; I1 still blocks network in sink.rs
PrivacyMode::PrivateHome => vec![SuppressIdentity, DropRaw],
PrivacyMode::EnterpriseAnonymous => vec![SuppressIdentity, DropRaw, AggregateOnly],
PrivacyMode::CareWithConsent => vec![DropRaw, ReduceResolution],
PrivacyMode::StrictNoIdentity => {
vec![SuppressIdentity, ReduceResolution, DropRaw, AggregateOnly]
}
};
v // already authored in ascending Ord order
}
```
The audit log is `Vec`-backed and append-only by API surface (no `pop`, no index-mut). The registry requires `&mut self` only for `record_transition`; `active_mode`, `enforced_actions`, `is_action_enforced`, and `allows_sink` are `&self` reads safe to call from the publish path.
### 2.4 Mode → (Class, Soul Gate, MQTT ACL) Mapping
This is the explicit, single-place declaration the gap (§1.1) was missing. Each row is enforced by `PrivacyMode::privacy_class()`, `PrivacyMode::soul_signature_enabled()`, and the existing class-gated routers.
| Mode | `PrivacyClass` | Soul Signature | Salt domain | MQTT/HA exposure (existing routers) | Enforced actions |
|------|----------------|----------------|-------------|--------------------------------------|------------------|
| `RawResearch` | `Raw` (0) | off | per-node | none — class 0 never networked (`mqtt_topics.rs:111`, I1 `sink.rs:35`) | `Allow` |
| `PrivateHome` | `Anonymous` (2) | off | per-node | presence/motion/count/conf/`identity_risk` (`ha_discovery.rs:116`) | `SuppressIdentity`, `DropRaw` |
| `EnterpriseAnonymous` | `Anonymous` (2) | off | **multiseed** (`signature_hasher.rs` per-seed `site_salt`) | same as PrivateHome | `SuppressIdentity`, `DropRaw`, `AggregateOnly` |
| `CareWithConsent` | `Derived` (1) | **on** (`SoulMatchOracle` + `SignatureHasher`) | per-node | LAN/research only — class 1 not on public tree (`mqtt_topics.rs:111`) | `DropRaw`, `ReduceResolution` |
| `StrictNoIdentity` | `Restricted` (3) | off | per-node | presence/motion/count/conf only; `identity_risk` *not* published (`mqtt_topics.rs:147`, `event.rs:113`) | `SuppressIdentity`, `ReduceResolution`, `DropRaw`, `AggregateOnly` |
Two mappings warrant explanation:
- **`PrivateHome` vs `EnterpriseAnonymous` both → `Anonymous`.** The difference is salt isolation, not class. Enterprise enables `multiseed_salt()` so that two seeds observing the same person in adjacent units produce uncorrelated `rf_signature_hash` values, preserving I3 (`signature_hasher.rs:8-18`) across a shared tenant boundary. Single-home does not need this. Both publish `identity_risk` at class 2 per the existing `ha_discovery.rs:116` rule — Enterprise additionally enforces `AggregateOnly` semantically, suppressing any zone-level or per-cluster surface beyond the five aggregate entities.
- **`CareWithConsent``Derived` with Soul on.** This is the only mode that resolves to class `Derived`, matching `lib.rs:88-90`'s comment "Required for Soul Signature deployments." It enables `soul-signature` (the Cargo feature, `Cargo.toml:24-27`) and installs a real `SoulMatchOracle` so the gate's `Recalibrate` exemption (`coherence_gate.rs:71-84`) fires for enrolled subjects. Class `Derived` is *not* on the public MQTT tree (`mqtt_topics.rs:111` requires `>= Anonymous`), so consented identity data stays on LAN/research surfaces — `DropRaw` and `ReduceResolution` still apply.
### 2.5 `PrivacyAttestationProof`: Hash-Chained Per ADR-010
The attestation proof gives cryptographic continuity that the declared mode was honored. It reuses the ADR-010 witness-chain primitive directly: each proof entry includes the SHAKE-256/BLAKE3 hash of the previous entry (`ADR-010` §"Hash chain", `previous_hash`/`entry_hash` linkage), so any insertion, deletion, or reordering breaks verification.
```rust
// v2/crates/wifi-densepose-bfld/src/attestation.rs
#![cfg(feature = "std")]
use crate::mode::{PrivacyAction, PrivacyMode};
use blake3::Hasher; // already a dependency (Cargo.toml:33)
/// Per-event privacy enforcement record — the unit folded into the chain.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrivacyActionRecord {
/// Capture-clock ns of the event this record attests.
pub timestamp_ns: u64,
/// Strongest action enforced for this event (ADR-140 `privacy_action`).
pub strongest_action: PrivacyAction,
/// Names of payload/event fields stripped for this event, e.g.
/// "compressed_angle_matrix", "rf_signature_hash". Sorted lexicographically
/// so the canonical-bytes hash is deterministic.
pub fields_stripped: Vec<&'static str>,
}
/// One link in the attestation hash chain. ADR-010-compatible.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrivacyAttestationProof {
/// Active mode at the time this link was sealed.
pub mode: PrivacyMode,
/// All actions enforced under `mode`, ascending (from the registry).
pub actions_enforced: Vec<PrivacyAction>,
/// Per-event strip records covered by this link (a window, see below).
pub fields_stripped_per_event: Vec<PrivacyActionRecord>,
/// BLAKE3 hash of the *previous* link's `entry_hash`; all-zero for genesis.
pub prev_hash: [u8; 32],
/// BLAKE3 over (mode token || actions || records || prev_hash). Computed by
/// `seal()`; this is the value the next link references as `prev_hash`.
pub entry_hash: [u8; 32],
}
impl PrivacyAttestationProof {
/// Seal a new link given the previous link's `entry_hash` (or `[0u8; 32]`
/// for the genesis link). The hash binds mode, actions, and per-event
/// strips, so altering any field after sealing breaks the chain.
#[must_use]
pub fn seal(
mode: PrivacyMode,
actions_enforced: Vec<PrivacyAction>,
fields_stripped_per_event: Vec<PrivacyActionRecord>,
prev_hash: [u8; 32],
) -> Self {
let mut h = Hasher::new();
h.update(mode.token().as_bytes());
for a in &actions_enforced {
h.update(&[*a as u8]);
}
for rec in &fields_stripped_per_event {
h.update(&rec.timestamp_ns.to_le_bytes());
h.update(&[rec.strongest_action as u8]);
for f in &rec.fields_stripped {
h.update(f.as_bytes());
h.update(&[0u8]); // length-free field separator
}
}
h.update(&prev_hash);
let entry_hash = *h.finalize().as_bytes();
Self { mode, actions_enforced, fields_stripped_per_event, prev_hash, entry_hash }
}
/// Verify chain linkage against the previous link's `entry_hash` AND that
/// `entry_hash` recomputes from the sealed fields (tamper evidence).
#[must_use]
pub fn verify_link(&self, expected_prev: [u8; 32]) -> bool {
if self.prev_hash != expected_prev {
return false;
}
let recomputed = Self::seal(
self.mode,
self.actions_enforced.clone(),
self.fields_stripped_per_event.clone(),
self.prev_hash,
);
recomputed.entry_hash == self.entry_hash
}
/// Short proof hash for diagnostics: `"blake3:<16 hex>"` (first 8 bytes of
/// `entry_hash`). Surfaced on the HA diagnostic entity (§2.6).
#[must_use]
pub fn short_hash(&self) -> String {
let mut s = String::with_capacity(7 + 16);
s.push_str("blake3:");
for b in &self.entry_hash[..8] {
s.push_str(&format!("{b:02x}"));
}
s
}
}
```
**Chain granularity — per window, not per frame.** The proof links one *event window* (e.g., one emit cycle of the `BfldEmitter`, `emitter.rs:138`), not one CSI frame. A per-frame chain at 20 Hz would grow at 1,728,000 links/day; per-window keeps the chain bounded to the published-event rate while still attesting every strip (each window's `fields_stripped_per_event` enumerates the per-event strips inside it). BLAKE3 is reused (it is already a dependency, `Cargo.toml:33`) rather than introducing the SHAKE-256 used in ADR-010's MAT path — ADR-010 §"Hash chain" specifies a hash-linked chain but not a fixed algorithm; BFLD already keys its `rf_signature_hash` with BLAKE3 (`signature_hasher.rs:20`), so reusing it avoids a second crypto dependency in the no-`std`-capable crate.
### 2.6 Integration Into MQTT Discovery + a Read-Only HA Diagnostic Entity
The active mode and proof hash are surfaced as a **read-only diagnostic** so an operator, regulator, or the cognitum-v0 dashboard can see the live privacy posture without touching the sensing entities. This extends `ha_discovery.rs` and `mqtt_topics.rs`, both of which already class-gate every entity.
- A new discovery payload is rendered by `render_discovery_payloads()` (`ha_discovery.rs:61`) for a `sensor` with `entity_category = "diagnostic"`, unique-id `<node>_bfld_privacy_mode`, state topic `ruview/<node>/bfld/privacy_mode/state`. Its state is a compact JSON object `{"mode":"care_with_consent","class":"derived","proof":"blake3:<16hex>","actions":["drop_raw","reduce_resolution"]}`.
- The entity is published at every class `>= Anonymous` (same gate as the existing five diagnostic sensors) **and** additionally at class `Raw`/`Derived` on the LAN-only research surface — because a research/care deployment most needs to display its own attestation. The class gate for the *public* tree (`mqtt_topics.rs:111`) is unchanged; the diagnostic mode entity is added to the local diagnostic surface regardless of class so the proof is always inspectable on-node.
- It is strictly read-only: the entity has no `command_topic`. Mode changes are an operator/config action (TOML + restart, §2.7), never an MQTT write — consistent with the "no `promote`" posture of `privacy_gate.rs`.
The proof hash on this entity is the `short_hash()` of the most recently sealed `PrivacyAttestationProof`. A verifier with the full chain (exported via a future `attestation export` CLI) can confirm continuity from genesis to the displayed hash.
### 2.7 Registry Wiring Into the Emitter
`BfldEmitter` (`emitter.rs:65-88`) gains an owned `PrivacyModeRegistry` and seals one attestation link per emit window. The change is additive — the existing `emit()`/`emit_with_oracle()` signatures are unchanged; the registry is configured via a new builder.
```rust
// emitter.rs additions (sketch)
pub struct BfldEmitter {
// ...existing fields (node_id, default_zone_id, privacy_class, gate, ring, signature_hasher)
registry: PrivacyModeRegistry, // NEW — single source of truth
last_proof_hash: [u8; 32], // NEW — chain tail; [0;32] genesis
}
impl BfldEmitter {
/// Configure the emitter from a named mode. Sets `privacy_class` from
/// `mode.privacy_class()`, installs/clears the signature hasher and Soul
/// oracle per `mode.soul_signature_enabled()`, and pins the registry.
#[must_use]
pub fn with_mode(mut self, mode: PrivacyMode) -> Self {
self.privacy_class = mode.privacy_class();
self.registry = PrivacyModeRegistry::new(mode);
self
}
/// Active mode + freshly sealed proof for the most recent emit window.
/// Read by the HA diagnostic entity (§2.6).
#[must_use]
pub fn attestation(&self) -> Option<&PrivacyAttestationProof> { /* tail of sealed chain */ }
}
```
On each `emit()`, after the gate decision (`emitter.rs:171`), the emitter: (1) calls `registry.record_transition(ts)`; (2) builds a `PrivacyActionRecord` enumerating the fields the privacy gating actually stripped (e.g., at class `Restricted` the `identity_risk_score` + `rf_signature_hash` strip in `event.rs:112-117` yields `fields_stripped = ["identity_risk_score","rf_signature_hash"]`); (3) calls `PrivacyAttestationProof::seal(mode, actions, records, self.last_proof_hash)` and updates `last_proof_hash`. The configured baseline mode (default `PrivateHome`) preserves the current `Anonymous` default (`emitter.rs:82`), so an un-migrated caller sees identical behavior plus a populated attestation chain.
### 2.8 Downstream Consumers (ADR-139, ADR-140)
| Consumer | What it reads | Binding |
|----------|---------------|---------|
| ADR-140 Semantic State Record | `PrivacyActionRecord::strongest_action` | Populates the record's mandatory `privacy_action` field; the proof `entry_hash` populates the record's privacy-provenance reference |
| ADR-139 WorldGraph | `PrivacyModeRegistry::is_action_enforced(AggregateOnly)` / `ReduceResolution` | A zone/edge whose evidence was degraded by `ReduceResolution` or `AggregateOnly` is tagged `privacy_limited_by = <mode token>` so the digital twin can mark the region as privacy-degraded rather than sensor-blind |
| ADR-136 Streaming Engine | `attestation()` short hash | Stage-boundary frame contract may carry the active mode token for downstream stages without re-deriving it |
| `ha_discovery.rs` / `mqtt_topics.rs` | active mode + `short_hash()` | Read-only diagnostic entity (§2.6) |
This honors the project rule that every semantic state traces to **signal evidence + model version + calibration version + privacy decision**: ADR-141 supplies the *privacy decision* half — the `PrivacyActionRecord` (what was enforced) plus the chain `entry_hash` (proof it was enforced) — which ADR-140 records alongside the signal/model/calibration provenance from ADR-134/ADR-135.
---
## 3. Consequences
### 3.1 Positive
- **Single declared policy object.** A deployment's privacy stance is now one named `PrivacyMode` and a `PrivacyModeRegistry`, not four scattered wiring decisions. An installer selects `CareWithConsent`; the registry derives class, Soul gate, salt domain, and ACL deterministically.
- **Cryptographic continuity.** `PrivacyAttestationProof` makes "we ran in StrictNoIdentity and stripped identity on every event" a verifiable claim, not a code-review assertion. The chain reuses the ADR-010 primitive, so the existing witness verifier extends naturally.
- **Regulator/operator visibility.** The read-only HA diagnostic entity exposes the live mode and proof hash without widening the sensing surface — useful for care-home compliance audits.
- **Clean ADR-139/ADR-140 bindings.** `privacy_action` and `privacy_limited_by` now have a concrete, queryable source (`is_action_enforced`, `strongest_action`), closing the trace requirement for semantic state.
- **No wire/byte changes.** `PrivacyClass` byte values, `BfldFrameHeader`, `sink.rs` ACL, and the MQTT topic tree are untouched. Modes are purely additive.
### 3.2 Negative
- **Two same-class modes.** `PrivateHome` and `EnterpriseAnonymous` both resolve to `Anonymous`; the difference (salt domain, `AggregateOnly`) lives above the class layer and is only meaningful if downstream consumers honor the action set. A consumer that looks only at `PrivacyClass` will not distinguish them.
- **Chain growth.** Even per-window, a busy node accumulates attestation links. An export/prune policy (genesis re-anchoring after verified export) is needed and is deferred to a follow-up iter.
- **`emitter.rs` gains state.** The emitter now owns a registry and a chain tail, growing its memory footprint and making `emit()` no longer a pure transform of inputs→event. The seal cost (one BLAKE3 over a small buffer) is sub-microsecond but non-zero.
- **Mode change requires restart.** By design there is no MQTT command topic to change mode at runtime (mirrors `privacy_gate.rs`'s no-`promote` posture). Operators change mode via TOML config + restart, which is a heavier operation than a dashboard toggle.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Mode→action mapping drifts from what `privacy_gate.rs` actually strips, so the proof attests fields that were not really removed | Medium | Attestation lies — worse than no attestation | The `PrivacyActionRecord.fields_stripped` is populated from the *actual* gate output (`event.rs`/`privacy_gate.rs` return values), not from the mode table; a unit test asserts the recorded strips equal the bytes the gate zeroed |
| `EnterpriseAnonymous` multiseed salt not actually isolated (two seeds share a salt) → I3 broken under same class | Low | Cross-unit identity correlation | `multiseed_salt()` gates a per-seed `site_salt` derivation; an acceptance test asserts cross-seed Hamming distance ~128 bits (reusing ADR-120 §2.7 AC2 from `tests/signature_hasher.rs`) |
| Chain genesis confusion: a node that restarts mid-deployment starts a fresh genesis, breaking continuity from the prior chain | Medium | Verifier sees a discontinuity it cannot distinguish from tampering | Genesis links record `prev_hash = [0;32]` and a boot epoch; the verifier treats a genesis link with a logged restart event as a legitimate re-anchor, not a break |
| Operator selects `RawResearch` and assumes raw never networks, but a misconfigured custom `Sink` accepts class 0 | Low | I1 violation | `RawResearch`'s `DropRaw` action is redundant with the compile-time `sink.rs` ACL (`MIN_CLASS`); the registry's `allows_sink::<NetworkKind>()` returns `false` for `Raw`, giving a runtime second line of defense |
---
## 4. Alternatives Considered
### 4.1 Extend `PrivacyClass` to Five+ Variants Instead of Adding `PrivacyMode`
Collapsing modes into the class enum would avoid a second type. Rejected because `PrivacyClass` is a *wire and sink-enforcement* primitive: its byte values are serialized in `BfldFrameHeader`, switched on in `sink.rs::check_class`, the MQTT router, and the NVS/Matter representation. Two modes (`PrivateHome`, `EnterpriseAnonymous`) share the same class but differ only in salt domain — they are *not* separable at the byte layer, so they cannot be class variants without inventing byte semantics that the existing `frame.rs`/`sink.rs` code would have to learn. Modes are strictly higher-level and must not perturb the byte contract.
### 4.2 Per-Frame Attestation Chain
A chain link per CSI frame would attest every single frame. Rejected on growth grounds: 20 Hz × 86,400 s = 1.7 M links/day/node, unbounded. The per-window granularity (§2.5) attests every *strip* (each window enumerates its per-event records) at the published-event rate, which is orders of magnitude lower while losing no strip evidence.
### 4.3 Reuse `GateAction` Instead of a New `PrivacyAction` Enum
`GateAction { Accept, PredictOnly, Reject, Recalibrate }` already exists (`identity_risk.rs:57`). Rejected because it answers a different question — *per-event risk gating* — and overloading it would conflate "this event is risky" with "this mode strips identity on every event." They compose (a mode can `SuppressIdentity` while the gate independently `Reject`s); merging them would lose that orthogonality and break ADR-140's need for a stable `privacy_action` value independent of per-event risk.
### 4.4 Runtime Mode Changes via MQTT Command Topic
A `command_topic` would let a dashboard flip modes live. Rejected for the same reason `privacy_gate.rs` has no `promote`: a remote, unauthenticated-by-default MQTT write that *weakens* privacy (e.g., `StrictNoIdentity``RawResearch`) is a privilege-escalation surface. Mode is a config-time + restart decision; the diagnostic entity is read-only.
### 4.5 SHAKE-256 (Match ADR-010 Exactly) vs BLAKE3 Reuse
ADR-010's MAT path uses SHAKE-256. Adopting it here would mean a second crypto dependency in a crate that is `#![cfg_attr(not(feature = "std"), no_std)]` (`lib.rs:14`). Rejected: ADR-010 §"Hash chain" specifies a hash-*linked* chain, not a fixed algorithm, and BFLD already depends on BLAKE3 for `rf_signature_hash` (`signature_hasher.rs:20`, `Cargo.toml:33`). Reusing BLAKE3 keeps the no-std footprint minimal while satisfying the linkage/tamper-evidence contract.
---
## 5. Testing and Acceptance Criteria
### 5.1 Test Plan
**T1 — Mode→class/Soul/salt mapping (unit).** For each of the five `PrivacyMode` variants, assert `privacy_class()`, `soul_signature_enabled()`, and `multiseed_salt()` exactly match the §2.4 table. Assert `token()` round-trips through a `from_token()` parser.
**T2 — Canonical action set (unit).** For each mode, assert `actions_for(mode)` equals the §2.4 "Enforced actions" column, is sorted ascending (`Ord`), and is deduplicated. Assert `is_action_enforced` agrees with set membership for all 25 (mode, action) pairs.
**T3 — ACL agreement with `sink.rs` (unit).** For each mode, assert `registry.allows_sink::<LocalKind>()`, `::<NetworkKind>()`, `::<MatterKind>()` equal `check_class::<S>(mode.privacy_class()).is_ok()` — i.e., the registry ACL never disagrees with the compile-time sink ACL. In particular `RawResearch.allows_sink::<NetworkKind>() == false` (I1).
**T4 — Attestation chain linkage (unit).** Seal a genesis link (`prev_hash = [0;32]`), then three more, threading each `entry_hash` into the next `prev_hash`. Assert `verify_link()` passes for all four against the correct predecessors. Mutate one link's `mode` and assert `verify_link()` fails (tamper evidence). Insert/delete/reorder a link and assert verification breaks.
**T5 — Recorded strips equal actual gate output (unit).** Run `BfldEmitter::with_mode(StrictNoIdentity)`, emit an event that would carry `identity_risk_score` + `rf_signature_hash`, and assert: (a) the emitted `BfldEvent` has both fields `None` (existing `event.rs:113` behavior), AND (b) the sealed `PrivacyActionRecord.fields_stripped` equals `["identity_risk_score","rf_signature_hash"]` (sorted) — proving the proof attests what was really stripped, not what the table claims.
**T6 — Multiseed salt isolation (unit, reuses ADR-120 AC2).** Two emitters in `EnterpriseAnonymous` with distinct per-seed salts observing identical identity features produce `rf_signature_hash` values with Hamming distance in [112, 144] bits (≈128 expected). Same test in `PrivateHome` with a shared node salt is *not* required to isolate (documents the difference).
**T7 — Default-mode backward compatibility (unit).** A `BfldEmitter::new(node_id)` with no `with_mode()` call behaves identically to today (class `Anonymous`, `emitter.rs:82`) and its registry reports `active_mode() == PrivateHome`.
**T8 — HA diagnostic entity render (unit).** `render_discovery_payloads()` emits the `privacy_mode` diagnostic sensor with `entity_category = "diagnostic"`, no `command_topic`, and a state JSON containing the mode token, class, `short_hash()`, and action tokens. Assert the public sensing tree (presence/motion/etc.) is byte-identical to the pre-change output (no regression to `mqtt_topics.rs:109`).
**T9 — Determinism proof (CI, extends ADR-028).** Seal a fixed 4-link chain from a hard-coded mode sequence and assert the final `entry_hash` matches a recorded SHA-256-of-bytes constant in `archive/v1/data/proof/expected_features.sha256` under key `bfld_attestation_chain_v1`. Makes the attestation hash deterministic end-to-end.
### 5.2 Acceptance Criteria
- **AC1**: All five modes resolve to the exact (class, Soul, salt, ACL, actions) tuple in §2.4 — T1, T2, T3 green.
- **AC2**: The attestation chain is tamper-evident: any single-field mutation, insertion, deletion, or reorder fails `verify_link()` — T4 green.
- **AC3**: For every emitted event, `PrivacyActionRecord.fields_stripped` equals the set of fields the gate actually zeroed (no attestation lies) — T5 green.
- **AC4**: `EnterpriseAnonymous` preserves I3 across seeds (cross-seed Hamming ≈ 128 bits) — T6 green.
- **AC5**: An un-migrated `BfldEmitter::new()` is observationally identical to today, plus a populated attestation chain — T7 green; the public MQTT tree is byte-identical — T8 green.
- **AC6**: `is_action_enforced` and `strongest_action` are callable by ADR-139/ADR-140 with no `&mut` access to the registry (read path is `&self`).
### 5.3 Witness / Proof
Per ADR-028/ADR-010, three rows are added to the witness log:
| Row | Capability | Evidence |
|-----|-----------|----------|
| W-39 | Mode→action mapping is total and matches §2.4 | `cargo test -p wifi-densepose-bfld mode::tests::mapping_table` |
| W-40 | Attestation chain tamper-evidence | `cargo test -p wifi-densepose-bfld attestation::tests::tamper_breaks_chain` |
| W-41 | Recorded strips equal actual gate output | `cargo test -p wifi-densepose-bfld attestation::tests::strips_match_gate` |
`source-hashes.txt` in the witness bundle gains `SHA-256(mode.rs)` and `SHA-256(attestation.rs)`.
---
## 6. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-010 (Witness Chains) | **Reuses**: `PrivacyAttestationProof` adopts the hash-linked chain primitive (`previous_hash`/`entry_hash`); BFLD uses BLAKE3 rather than SHAKE-256 per §4.5 |
| ADR-118 (BFLD) | **Extended**: modes/actions/attestation layer over the existing pipeline; invariants I1/I2/I3 (`lib.rs:8-11`) unchanged |
| ADR-120 (Privacy Class + Hash Rotation) | **Extended**: `PrivacyMode` maps to `PrivacyClass`; `EnterpriseAnonymous` formalizes multiseed `site_salt` isolation (`signature_hasher.rs`) |
| ADR-121 (Identity-Risk Scoring) | **Composes with**: `PrivacyAction` is orthogonal to `GateAction` (`identity_risk.rs:57`); Soul gate exemption (`coherence_gate.rs:71`) is enabled by `CareWithConsent` |
| ADR-122 (HA/Matter Exposure) | **Extended**: read-only `privacy_mode` diagnostic entity added to `ha_discovery.rs`/`mqtt_topics.rs`; public tree unchanged |
| ADR-136 (Streaming Engine) | **Consumer**: active mode token may ride stage-boundary frame contracts |
| ADR-139 (WorldGraph) | **Consumer**: `is_action_enforced(ReduceResolution/AggregateOnly)` drives `privacy_limited_by` zone/edge tagging |
| ADR-140 (Semantic State Record) | **Consumer**: `strongest_action` populates `privacy_action`; chain `entry_hash` is the privacy-provenance reference |
| ADR-143 (RF SLAM v2) | **Constrains**: reflector/anchor surfaces are subject to `ReduceResolution`/`AggregateOnly` under the active mode |
---
## 7. References
### Production Code
- `v2/crates/wifi-densepose-bfld/src/lib.rs``PrivacyClass` (`:82-117`), `BfldError`, structural invariants I1/I2/I3 (`:8-11`)
- `v2/crates/wifi-densepose-bfld/src/sink.rs``Sink::MIN_CLASS`, `check_class` (`:47-55`), `LocalKind`/`NetworkKind`/`MatterKind`
- `v2/crates/wifi-densepose-bfld/src/privacy_gate.rs``PrivacyGate::demote` zeroizing strip (`:31-75`)
- `v2/crates/wifi-densepose-bfld/src/identity_risk.rs``GateAction` (`:57-69`), risk-score bands
- `v2/crates/wifi-densepose-bfld/src/emitter.rs``BfldEmitter` default class `Anonymous` (`:82`), gate consult (`:171`)
- `v2/crates/wifi-densepose-bfld/src/event.rs``BfldEvent` field exposure table, `apply_privacy_gating` (`:112-117`)
- `v2/crates/wifi-densepose-bfld/src/coherence_gate.rs``SoulMatchOracle`, `evaluate_with_oracle` Recalibrate exemption (`:71-84`)
- `v2/crates/wifi-densepose-bfld/src/signature_hasher.rs` — BLAKE3 keyed `rf_signature_hash`, I3 site isolation (`:8-18`)
- `v2/crates/wifi-densepose-bfld/src/ha_discovery.rs` — class-gated discovery render (`:61-129`)
- `v2/crates/wifi-densepose-bfld/src/mqtt_topics.rs` — class-gated topic router (`:109-157`)
- `v2/crates/wifi-densepose-bfld/Cargo.toml` — BLAKE3 dependency (`:33`), `soul-signature` feature (`:24-27`)
### Related ADR Documents
- `docs/adr/ADR-010-witness-chains-audit-trail-integrity.md` — hash-chain primitive
- `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`
- `docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md`
- `docs/adr/ADR-121-bfld-identity-risk-scoring.md`
- `docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md`
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `7d88eb84c`, issue #845): `PrivacyMode` / `PrivacyAction` / `PrivacyModeRegistry` plus the BLAKE3 hash-chained `PrivacyAttestationProof` (`verify_chain()` detects tamper). no_std-safe (registry is std-gated for the ESP32 path). 6 tests.
**Integration glue -- not yet on the live path:** wiring the registry into `PrivacyGate` class transitions, the MQTT discovery payload, and a read-only Home Assistant diagnostic entity exposing the active mode + proof hash.
**Trust contribution:** the *policy spine* -- privacy posture is a tamper-evident, auditable chain rather than a checkbox; an operator's mode choice actively governs whether identity data may even exist.
@@ -0,0 +1,543 @@
# ADR-142: Evolution Tracker and Temporal VoxelMap Evidence Aggregation
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/longitudinal.rs`, `ruvsense/attractor_drift.rs`, `ruvsense/calibration.rs`, `ruvsense/field_model.rs`, `ruvsense/tomography.rs`); `wifi-densepose-bfld` (`privacy_gate.rs`) |
| **Relates to** | ADR-030 (Persistent Field Model), ADR-134 (First-Class CIR Support), ADR-135 (Empty-Room Baseline Calibration), ADR-084, ADR-118, ADR-120 (BFLD Privacy Classes), ADR-136 (Streaming Engine), ADR-137 (Fusion Quality Scoring), ADR-139 (WorldGraph), ADR-141 (BFLD Privacy Control Plane) |
---
## 1. Context
### 1.1 The Gap
The RuvSense crate already contains every individual ingredient an "evolution tracker" would need, but they exist as five disconnected modules with no orchestrator that runs them together over time and across links. Searching `v2/crates/wifi-densepose-signal/src/ruvsense/` for `EvolutionTracker`, `change_point`, `VoxelMap`, and any cross-module driver finds nothing. What does exist:
- **`field_model.rs`** holds the per-link Welford baselines (`LinkBaselineStats`, `WelfordStats` at line 79), runs the SVD eigenstructure decomposition (`finalize_calibration()`, line 487), exposes `estimate_occupancy(&[Vec<f64>]) -> Result<usize, FieldModelError>` (line 741, with a `NotCalibrated` stub at line 821 when the `eigenvalue` feature is off), and tracks calibration freshness via `check_freshness(current_us) -> CalibrationStatus` (line 829) returning `Uncalibrated | Collecting | Fresh | Stale | Expired` (enum at line 300). Nothing aggregates freshness *across* links — each `FieldModel` instance is per-room and unaware of its siblings.
- **`calibration.rs`** (ADR-135) holds the empty-room amplitude/phase baseline: `BaselineCalibration` (line 228), `CalibrationRecorder` with a `W`-frame staleness window, `deviation(&CsiFrame) -> CalibrationDeviationScore` (line 238), and `CalibrationError` (line 128). Its `CalibrationDeviationScore` (line 372) carries the per-frame `drift_score`, but the drift signal is consumed only by that single link's recorder. There is no cross-link rule that says "3 links drifted simultaneously, therefore the room changed."
- **`longitudinal.rs`** holds the per-person `PersonalBaseline` (line 156) with five Welford metrics and an `EmbeddingHistory` FIFO (line 344, `push()` at line 389, `novelty()` at line 500). It produces a `DriftReport` (line 110) and a `MonitoringLevel` (line 99) per person — but per-person, never tied back to the per-link RF evidence that produced the embedding.
- **`attractor_drift.rs`** holds phase-space regime classification: `AttractorDriftAnalyzer` (line 203), `analyze()` (line 257) returning `AttractorDriftReport { regime_changed, ... }` (line 136), classifying `BiophysicalAttractor` (line 93). Again per-person-per-metric; nothing escalates a regime change into the field/calibration tier.
- **`tomography.rs`** holds the coarse RF tomographer: `RfTomographer` (line 178), `reconstruct(&[f64]) -> OccupancyVolume` (line 236) with an ISTA L1 solver, and an `OccupancyVolume` (line 121) of `densities: Vec<f64>`. Critically, **the `OccupancyVolume` is stateless** — every `reconstruct()` call produces a fresh volume from a single attenuation snapshot. There is no temporal memory: a voxel that has been occupied for 200 frames is indistinguishable from one that flickered for a single noisy frame. There is no per-voxel confidence, no `last_update_ns`, no evidence count, and no Doppler.
On the privacy side, `wifi-densepose-bfld/src/privacy_gate.rs` implements the monotonic `PrivacyGate::demote(BfldFrame, PrivacyClass)` (line 31) that zeroes payload sections going `Raw(0) → Derived(1) → Anonymous(2) → Restricted(3)` (classes defined in `bfld/src/lib.rs` line 84), refusing any promotion with `BfldError::InvalidDemote` (line 187). But the gate operates on `BfldFrame` payload sections (`compressed_angle_matrix`, `csi_delta`, `amplitude_proxy`, `phase_proxy`) — **it has no concept of a voxel grid**. A tomographic `OccupancyVolume`, if it were ever emitted, would leave the node ungated.
The gap is therefore twofold:
1. **No orchestrator.** Each link maintains its own baseline, drift score, attractor state, and occupancy estimate in isolation. A change in the physical environment (furniture moved, a wall opened) manifests as correlated drift across *several* links, but no module reads more than one link at a time. Cross-link change-point detection — the signal that distinguishes "the world changed" from "this one link is noisy" — does not exist.
2. **No temporal occupancy memory.** `RfTomographer::reconstruct()` is memoryless, so occupancy cannot accumulate evidence, cannot be assigned confidence, and cannot be Bayesian-updated across the 20 Hz reconstruction cadence. And whatever it produces is not gated for privacy.
ADR-030 (Persistent Field Model, Proposed) defines the per-room field model and Tier-2 tomography but says nothing about orchestrating multiple rooms/links or about temporal voxel state. This ADR extends ADR-030 with the missing orchestration layer and the missing temporal voxel layer, and routes both through the BFLD privacy gate (ADR-120/ADR-141).
### 1.2 What "Evolution" Means Here
"Evolution" is the second-order signal: not the instantaneous state of the field, but **how the field's statistical description is changing over time and whether that change is coherent across links**. Three concrete questions the EvolutionTracker answers that no current module can:
- *Are the per-link baselines still valid as a set?* (freshness across the mesh, not per-link)
- *Did the environment just change, or is one link misbehaving?* (cross-link change-point)
- *Does the model's occupancy estimate agree with the raw RF body-perturbation energy?* (occupancy-consistency, an internal contradiction check feeding ADR-137)
### 1.3 What This ADR Is Not
It is not a new tomography solver — it wraps the existing `RfTomographer`. It is not a new calibration algorithm — it reads ADR-135's `BaselineCalibration` and ADR-030's `FieldModel`. It is not a new privacy model — it reuses the `PrivacyGate::demote` pattern from `bfld/src/privacy_gate.rs`. It adds exactly two things: a coordinator (`EvolutionTracker`) and a stateful, gated occupancy memory (`VoxelMap` + `VoxelGate`).
### 1.4 Pipeline Position
```
Per-link CSI frame (baseline-subtracted, ADR-135)
→ CalibrationRecorder::record() (ruvsense/calibration.rs) → drift_score[link]
→ FieldModel::extract_perturbation() (ruvsense/field_model.rs) → body_energy[link]
→ RfTomographer::reconstruct() (ruvsense/tomography.rs) → OccupancyVolume (snapshot)
│ │ │
└────────────────┴───────────────────────┴──► EvolutionTracker::tick() ← NEW
├─ baseline freshness across mesh
├─ cross-link change-point
├─ occupancy-consistency check
└─ VoxelMap::ingest(volume) ← NEW (temporal)
VoxelGate::demote(map, mode) ← NEW (BFLD-gated)
┌─────────────────────────────────────┴───────────────────┐
ADR-137 contradiction flags ADR-139 WorldGraph nodes
```
`EvolutionTracker::tick()` runs once per reconstruction cycle (20 Hz). It reads the per-link drift scores and body-perturbation energies, the field model occupancy estimate, and the latest `OccupancyVolume`, then folds the volume into the persistent `VoxelMap`. Output leaves the node only through `VoxelGate`.
---
## 2. Decision
### 2.1 The `EvolutionTracker` Trait
`EvolutionTracker` is a trait (so the production aggregator and the test harness can supply different link-state providers) plus a default implementation `MeshEvolutionTracker`. It owns *references* to the per-link state already maintained by the existing modules; it does not duplicate their accumulators.
```rust
use wifi_densepose_signal::ruvsense::calibration::{BaselineCalibration, CalibrationDeviationScore};
use wifi_densepose_signal::ruvsense::field_model::CalibrationStatus;
use wifi_densepose_signal::ruvsense::tomography::OccupancyVolume;
/// Stable identifier for one TX→RX link in the mesh.
pub type LinkId = usize;
/// Per-link evidence handed to the tracker each tick.
#[derive(Debug, Clone)]
pub struct LinkObservation {
pub link_id: LinkId,
/// ADR-135 per-frame deviation (carries drift_score + rms_amplitude_z).
pub deviation: CalibrationDeviationScore,
/// ADR-030 field-model freshness for this link's room.
pub freshness: CalibrationStatus,
/// Body-perturbation energy from FieldModel::extract_perturbation(),
/// the residual after environmental modes are projected out.
pub body_energy: f32,
/// Capture timestamp, nanoseconds since the 802.15.4 epoch (ADR-110).
pub timestamp_ns: u64,
}
/// Aggregate result of one evolution tick.
#[derive(Debug, Clone)]
pub struct EvolutionReport {
/// Worst freshness observed across all links this tick.
pub mesh_freshness: CalibrationStatus,
/// Links currently Stale or Expired (drives CoherenceAlert).
pub stale_links: Vec<LinkId>,
/// True if a cross-link change-point fired this tick (§2.2).
pub change_point: bool,
/// Links that participated in the change-point (≥2σ this window).
pub change_point_links: Vec<LinkId>,
/// Occupancy as the field model sees it.
pub model_occupancy: usize,
/// Occupancy implied by summed per-link body-perturbation energy.
pub perturbation_occupancy: usize,
/// True when |model perturbation| > 1 (drives AnomalyWarn, §2.3).
pub occupancy_disagreement: bool,
/// Alerts emitted this tick (typed, for the streaming engine ADR-136).
pub alerts: Vec<EvolutionAlert>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvolutionAlert {
/// One or more baselines are no longer fresh across the mesh.
CoherenceAlert { stale_links: Vec<LinkId> },
/// Cross-link change-point: the environment likely changed.
ChangePoint { links: Vec<LinkId> },
/// Model occupancy and RF-energy occupancy disagree by >1 person.
AnomalyWarn { model: usize, perturbation: usize },
}
pub trait EvolutionTracker {
/// Fold one tick of per-link observations + the latest occupancy
/// snapshot into the tracker's persistent state. Updates the VoxelMap.
fn tick(
&mut self,
observations: &[LinkObservation],
volume: &OccupancyVolume,
now_ns: u64,
) -> EvolutionReport;
/// Borrow the temporal voxel map for gated output (§2.5).
fn voxel_map(&self) -> &VoxelMap;
/// Configuration knobs.
fn config(&self) -> &EvolutionConfig;
}
```
The default `MeshEvolutionTracker` holds the rolling windows the existing modules already require but does not re-implement them — it stores small ring buffers of the *scores* (not the raw CSI):
- per-link `VecDeque<f32>` of the last `W = 300` `drift_score` values (the same window ADR-135 `CalibrationConfig.drift_window_frames` uses);
- per-link `VecDeque<f32>` of `rms_amplitude_z` for the change-point test;
- the `EmbeddingHistory` FIFO (`longitudinal.rs`) and phase-space buffers (`attractor_drift.rs`) are *referenced by handle*, not copied — the tracker calls their existing `analyze()`/`novelty()` on demand.
```rust
#[derive(Debug, Clone)]
pub struct EvolutionConfig {
/// Change-point window length in frames. Default: 30 (1.5 s @ 20 Hz).
pub change_point_window: usize,
/// Per-link z threshold counting toward a change-point. Default: 2.0σ.
pub change_point_sigma: f32,
/// Minimum links exceeding threshold to declare a change-point. Default: 3.
pub change_point_min_links: usize,
/// Occupancy disagreement tolerance, in persons. Default: 1.
pub occupancy_tolerance: usize,
/// Per-voxel minimum evidence count before a voxel is "confident". Default: 5.
pub min_evidence_frames: u32,
}
impl Default for EvolutionConfig {
fn default() -> Self {
Self {
change_point_window: 30,
change_point_sigma: 2.0,
change_point_min_links: 3,
occupancy_tolerance: 1,
min_evidence_frames: 5,
}
}
}
```
### 2.2 Cross-Link Change-Point Detection
A single link drifting is noise; the whole environment changing shows up as *correlated* drift. The rule, evaluated every tick:
> Within the rolling `change_point_window` (default 30 frames / 1.5 s), if **3 or more links** each exceed `change_point_sigma` (default 2.0σ) on their `rms_amplitude_z`, emit a `ChangePoint` event naming those links.
```rust
fn detect_change_point(&self) -> Option<Vec<LinkId>> {
let mut hot = Vec::new();
for (link_id, window) in self.z_windows.iter() {
// Count frames in the window above the sigma threshold.
let n_hot = window.iter().filter(|&&z| z >= self.config.change_point_sigma).count();
// A link "participates" if it was hot for a majority of the window.
if n_hot * 2 > window.len() {
hot.push(*link_id);
}
}
(hot.len() >= self.config.change_point_min_links).then_some(hot)
}
```
The 3-link minimum is deliberately the same scale as ADR-135's `drift_confirm_frames` confirmation logic but operates spatially instead of temporally: ADR-135 confirms a single link's staleness over 45 s; this ADR confirms an environment change over 3 links in 1.5 s. The two are complementary — ADR-135 answers *"is this link's baseline old?"* and this rule answers *"did the world just move?"*. A `ChangePoint` is the upstream trigger that lets the operator (or, if `recalibrate_on_drift` from ADR-135 §2.6 is enabled) recalibrate the *whole mesh* rather than one link.
The 2.0σ threshold reuses ADR-135's interpretation: `rms_amplitude_z > 3.0` is "likely occupied" for a single frame, so a *sustained* 2.0σ across a 1.5 s window on multiple links is a structural shift, not a single body passing one link.
**Mesh freshness aggregation.** Independently of change-points, the tracker reduces per-link `CalibrationStatus` to one `mesh_freshness` using the worst-case ordering `Fresh < Stale < Expired` (with `Uncalibrated`/`Collecting` treated as worse than `Fresh`). Any link at `Stale` or `Expired` lands in `stale_links` and produces a `CoherenceAlert`. This is the cross-mesh freshness check that `field_model.rs::check_freshness` cannot do alone — it only knows one room.
### 2.3 Occupancy-Consistency Check
Two independent occupancy estimates exist and should agree:
- **Model occupancy**: `FieldModel::estimate_occupancy(recent_frames)` (field_model.rs line 741) — derived from eigenstructure energy in the off-environment subspace.
- **Perturbation occupancy**: a count derived from the summed per-link `body_energy` (the residual after `extract_perturbation()` projects out the environmental modes). The tracker bins total body energy into a person count using a fixed energy-per-person scale calibrated at install.
```rust
fn occupancy_consistency(&self, model_occ: usize, body_energy_total: f32) -> (usize, bool) {
let perturbation_occ = (body_energy_total / self.energy_per_person).round() as usize;
let disagree = model_occ.abs_diff(perturbation_occ) > self.config.occupancy_tolerance;
(perturbation_occ, disagree)
}
```
When the two disagree by more than `occupancy_tolerance` (default 1 person), the tracker emits `AnomalyWarn { model, perturbation }`. This is exactly the kind of *internal contradiction* ADR-137's fusion quality scoring consumes: the semantic state record produced downstream carries this as a contradiction flag with references to both evidence sources (the field model version and the calibration version that produced each estimate). Per the project rule, every semantic state traces to **signal evidence** (the `LinkObservation` set), **model version** (the `FieldModel` SVD generation), **calibration version** (the `BaselineCalibration.captured_at_unix_s` from ADR-135), and **privacy decision** (the `VoxelGate` mode, §2.5).
### 2.4 Temporal `VoxelMap` with Bayesian Evidence Accumulation
The core new state. The existing `OccupancyVolume` (tomography.rs line 121) is a memoryless snapshot. The `VoxelMap` is the persistent companion that accumulates evidence across `reconstruct()` calls.
```rust
/// One voxel of persistent, evidence-accumulating occupancy state.
#[derive(Debug, Clone)]
pub struct Voxel {
/// Center position (metres), copied from OccupancyVolume::voxel_center().
pub center_xyz: [f32; 3],
/// Bayesian occupancy probability ∈ [0, 1].
pub occupancy: f32,
/// Confidence ∈ [0, 1]; rises with evidence_count, falls with staleness.
pub confidence: f32,
/// Nanoseconds (802.15.4 epoch) of the last frame that updated this voxel.
pub last_update_ns: u64,
/// Number of frames that have contributed evidence to this voxel.
pub evidence_count: u32,
/// Welford mean/variance of the density observations (variance flags noise).
pub density_mean: f32,
pub density_m2: f32,
/// Radial Doppler velocity estimate (m/s), when CIR phase rate is available.
pub doppler_velocity: f32,
}
/// Persistent occupancy grid shared across all reconstruct() calls.
#[derive(Debug, Clone)]
pub struct VoxelMap {
pub voxels: Vec<Voxel>,
pub nx: usize,
pub ny: usize,
pub nz: usize,
pub bounds: [f64; 6],
/// Half-life (frames) of the confidence decay for un-updated voxels.
decay_half_life: f32,
}
impl VoxelMap {
/// Allocate a VoxelMap matching an OccupancyVolume's geometry.
pub fn from_geometry(volume: &OccupancyVolume) -> Self;
/// Fold one fresh OccupancyVolume into the persistent map.
///
/// For each voxel:
/// 1. Bayesian log-odds update of `occupancy` from the new density
/// (density treated as a measurement likelihood via a logistic link).
/// 2. Welford update of (density_mean, density_m2).
/// 3. evidence_count += 1; last_update_ns = now_ns.
/// 4. confidence ← logistic(evidence_count) × (1 normalised_variance).
/// Voxels NOT touched this frame decay confidence toward 0 with
/// `decay_half_life`, but retain their last occupancy estimate.
pub fn ingest(&mut self, volume: &OccupancyVolume, now_ns: u64, min_evidence: u32);
/// Per-voxel Welford sample variance.
pub fn density_variance(&self, idx: usize) -> f32;
/// Voxels with evidence_count < min_evidence are LOW CONFIDENCE.
pub fn low_confidence_indices(&self, min_evidence: u32) -> Vec<usize>;
/// Occupancy histogram (counts per occupancy bucket) for Restricted mode.
pub fn occupancy_histogram(&self, n_buckets: usize) -> Vec<u32>;
}
```
**Bayesian update.** Each voxel's `occupancy` is maintained in log-odds and updated with the new density observation through a logistic measurement model `p(occupied | density) = σ(k·(density d₀))`. Log-odds accumulation is the standard occupancy-grid update (Moravec & Elfes, 1985; Thrun et al., 2005): it is commutative and numerically stable, and it lets a voxel that is repeatedly observed occupied converge toward 1.0 while a one-frame flicker barely moves the estimate. This directly solves the memoryless-snapshot problem: a 200-frame occupancy is now distinguishable from a 1-frame spike via `evidence_count` and the converged log-odds.
**Confidence and low-confidence flagging.** `confidence = logistic(evidence_count / min_evidence) × (1 clamp(normalised_density_variance))`. Voxels with `evidence_count < min_evidence_frames` (default 5, §2.1) are returned by `low_confidence_indices()` and flagged downstream so the fusion engine (ADR-137) never treats a 4-frame voxel as a confident detection. This mirrors how `tomography.rs` already counts `occupied_count` at density > 0.01, but adds the *temporal* qualifier the snapshot lacks.
**Welford variance per voxel.** Reuses the exact `(mean, m2)` update form of `WelfordStats` from `field_model.rs` (line 79162) so a voxel whose density is high but *noisy* (high variance) is correctly distrusted relative to a voxel that is steadily, quietly occupied.
### 2.5 CIR-Weighted Tomography (ADR-134 Integration)
When ADR-134 CIR is available, the `dominant_delay_sec()` / `dominant_tap_tof_s()` of a link's `Cir` (cir.rs lines 291309) gives a time-of-flight, hence a distance, for the dominant reflector on that link. The `RfTomographer` weight matrix (tomography.rs line 182, `weight_matrix: Vec<Vec<(usize, f64)>>`) currently weights every voxel on the link path purely by Fresnel-radius proximity (`1.0 dist/fresnel_radius`). With a CIR delay available, the tracker supplies a *distance prior*: voxels whose distance from TX matches the CIR-implied range get their weight boosted, focusing evidence near the reflector instead of smearing it along the whole ray.
```rust
/// Optional per-link CIR-derived distance prior, applied to the existing
/// Fresnel weights as a multiplicative Gaussian bump centred at the CIR range.
pub struct CirDistancePrior {
pub link_id: LinkId,
/// Reflector distance from TX (m), from Cir::dominant_distance_m().
pub range_m: f64,
/// Std-dev of the range bump (m), from tap_spacing → distance resolution.
pub sigma_m: f64,
}
```
The prior is **optional**: when CIR is unavailable (single-antenna fallback, or the `eigenvalue`/CIR feature is off), the tomographer behaves exactly as today. This keeps the change additive and the existing `tomography.rs` tests untouched. The Doppler field of each `Voxel` (`doppler_velocity`) is similarly populated only when CIR phase-rate is available; otherwise it stays 0.0.
### 2.6 `VoxelGate`: BFLD-Gated Voxel Output
The raw `VoxelMap` is identity-leaky: a high-resolution occupancy grid plus per-voxel Doppler can reconstruct a person's trajectory and gait. It must never leave the node un-gated. `VoxelGate::demote` reuses the **monotonic-demotion** pattern of `bfld/src/privacy_gate.rs::PrivacyGate::demote` — it accepts a `PrivacyClass` (from `bfld/src/lib.rs`, classes `Raw(0) → Derived(1) → Anonymous(2) → Restricted(3)`), refuses any *promotion* with `BfldError::InvalidDemote`, and produces progressively coarser views. Like the BFLD gate, demotion is irreversible: once a field is zeroed, the bytes are gone.
```rust
use wifi_densepose_bfld::{BfldError, PrivacyClass};
/// Monotonic voxel-grid demotion, mirroring PrivacyGate::demote (ADR-120).
pub struct VoxelGate;
/// What actually leaves the node after gating.
#[derive(Debug, Clone)]
pub enum GatedVoxelOutput {
/// Raw(0)/Derived(1): full VoxelMap (local-only by invariant; Raw never
/// crosses a network sink — same structural rule as BFLD class 0).
Full(VoxelMap),
/// Anonymous(2): per-voxel doppler_velocity and confidence cleared to 0;
/// occupancy retained but quantised. No trajectory reconstruction possible.
Anonymous(VoxelMap),
/// Restricted(3): NO voxel grid leaves the node — only an occupancy
/// histogram (count of voxels per occupancy bucket).
OccupancyHistogram(Vec<u32>),
}
impl VoxelGate {
/// Demote the VoxelMap to the target class. Returns InvalidDemote if the
/// target is a *lower* class number than `current` (i.e. would add info).
pub fn demote(
map: &VoxelMap,
current: PrivacyClass,
target: PrivacyClass,
) -> Result<GatedVoxelOutput, BfldError> {
if target.as_u8() < current.as_u8() {
return Err(BfldError::InvalidDemote {
from: current.as_u8(),
to: target.as_u8(),
});
}
Ok(match target {
PrivacyClass::Raw | PrivacyClass::Derived => GatedVoxelOutput::Full(map.clone()),
PrivacyClass::Anonymous => {
let mut m = map.clone();
for v in m.voxels.iter_mut() {
v.doppler_velocity = 0.0; // strip kinematic identity surface
v.confidence = 0.0;
v.occupancy = quantise(v.occupancy);
}
GatedVoxelOutput::Anonymous(m)
}
PrivacyClass::Restricted => {
// The raw VoxelMap never leaves the node at Restricted.
GatedVoxelOutput::OccupancyHistogram(map.occupancy_histogram(8))
}
})
}
}
```
This mirrors `privacy_gate.rs` field-by-field: where BFLD zeroes `compressed_angle_matrix`/`csi_delta` at Anonymous and `amplitude_proxy`/`phase_proxy` at Restricted, the `VoxelGate` clears `doppler_velocity`/`confidence` at Anonymous and emits only a histogram at Restricted. The control-plane *which* class applies comes from ADR-141 (the named privacy mode and its runtime attestation), not from this ADR — `VoxelGate` is the mechanism, ADR-141 is the policy.
**Anomaly routing.** `EvolutionReport.alerts` (the `CoherenceAlert` / `ChangePoint` / `AnomalyWarn` variants) are not voxel data and are not subject to voxel demotion — they are *typed events*. They route to:
- **ADR-137** fusion contradiction flags: `AnomalyWarn` becomes a contradiction reference (model-occupancy vs perturbation-occupancy) attached to the semantic state record, with the model version and calibration version that produced each side.
- **ADR-139** WorldGraph nodes: a `ChangePoint` updates the environmental digital twin (e.g. a moved-furniture edge), and `CoherenceAlert` marks affected room nodes as needing recalibration.
### 2.7 Interface Boundaries
| Boundary | Direction | Type | Note |
|----------|-----------|------|------|
| `calibration.rs` → tracker | in | `CalibrationDeviationScore` (per link) | drift_score + rms_amplitude_z; no CSI crosses the boundary |
| `field_model.rs` → tracker | in | `CalibrationStatus`, `body_energy: f32`, `estimate_occupancy` | mesh freshness + model occupancy |
| `tomography.rs` → tracker | in | `&OccupancyVolume` (snapshot) | folded into `VoxelMap::ingest` |
| `cir.rs` → tracker | in (optional) | `CirDistancePrior` | distance-weighted evidence; absent ⇒ unchanged behaviour |
| tracker → ADR-137 | out | `EvolutionAlert` (typed) | contradiction flags, evidence references |
| tracker → ADR-139 | out | `EvolutionAlert` (typed) | WorldGraph mutations |
| tracker → network sink | out | `GatedVoxelOutput` only | never the raw `VoxelMap`; gated by `VoxelGate` |
The tracker holds **no raw CSI** and **no payload bytes** — only scores, occupancy estimates, and the voxel grid. The only path to the network is through `VoxelGate::demote`.
---
## 3. Consequences
### 3.1 Positive
- **Single orchestration point.** Five previously-isolated modules (`calibration`, `field_model`, `longitudinal`, `attractor_drift`, `tomography`) gain a coordinator that reads them together. Cross-link change-point detection becomes possible for the first time; no module was ever fed more than one link.
- **Temporal occupancy memory.** A 200-frame occupancy is now distinguishable from a single-frame noise spike via `evidence_count` and converged Bayesian log-odds. The fusion engine (ADR-137) gets per-voxel confidence instead of a binary snapshot threshold.
- **Mesh-wide freshness.** `field_model.rs::check_freshness` only knew one room; `EvolutionTracker` reduces per-link freshness to a mesh `CoherenceAlert`, closing the operational gap ADR-135's per-link drift score left open.
- **Internal contradiction detection.** The occupancy-consistency check turns two independent estimates (eigenstructure vs body-perturbation energy) into an `AnomalyWarn` that ADR-137 can score — a built-in sanity check the pipeline never had.
- **Privacy by construction.** No voxel grid reaches a network sink except through `VoxelGate::demote`, reusing the proven monotonic-demotion invariant from `bfld/src/privacy_gate.rs`. Doppler (the strongest gait-identity surface in a voxel grid) is cleared at Anonymous; the grid itself never leaves at Restricted.
- **Additive CIR integration.** The `CirDistancePrior` is optional; absent CIR, `tomography.rs` behaves identically and its existing tests are untouched.
### 3.2 Negative
- **New persistent state.** The `VoxelMap` is long-lived (one per monitored volume) and adds memory: an 8×8×4 grid is 256 voxels × ~40 bytes ≈ 10 KB — trivial — but a finer 16×16×8 grid is ~2,048 voxels and the decay loop runs every tick over all voxels. Bounded and cheap, but it is new always-on work at 20 Hz.
- **Energy-per-person scale is an install constant.** The occupancy-consistency check's `energy_per_person` is environment-specific and must be set at calibration time; a wrong value produces spurious `AnomalyWarn`s. It is derived from the same empty-room session as ADR-135's baseline.
- **Change-point window tuning.** The 30-frame / 3-link / 2σ defaults are reasoned from ADR-135's thresholds but not yet validated on real multi-room hardware; a noisy mesh could over-trigger `ChangePoint`. Mitigated by requiring majority-of-window hotness per link (§2.2), not a single hot frame.
- **Doppler is gated away early.** Useful kinematic information is cleared at Anonymous. This is intentional (it is the identity surface) but means trajectory analytics must run *before* the gate, inside the trusted node boundary, not on gated output.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| `ChangePoint` over-triggers on a noisy mesh (HVAC, sunlight) | Medium | Spurious mesh-recalibration prompts | Majority-of-window per-link hotness + 3-link minimum; ADR-135 drift-confirm still gates auto-recalibration |
| Bayesian voxel converges to a stale occupancy after a person leaves | Medium | A vacated voxel reads occupied for several seconds | Confidence decay with `decay_half_life` for un-updated voxels; the log-odds is pulled toward "free" by subsequent low-density observations |
| `VoxelGate` Anonymous quantisation still leaks coarse trajectory | Low | Re-identification from coarse grid over time | Restricted mode (histogram only) for untrusted sinks; ADR-141 control plane chooses class per sink |
| CIR distance prior misplaces evidence when the dominant tap is the direct path, not the body | Medium | Evidence concentrated at the wall, not the person | Prior is multiplicative on existing Fresnel weights (cannot create evidence where the ray does not pass); body-perturbation energy still gates whether a voxel is occupied at all |
| Occupancy-consistency false `AnomalyWarn` from a wrong `energy_per_person` | Medium | Noise into ADR-137 contradiction stream | Tolerance default of 1 person; calibrate `energy_per_person` during the empty-room session and re-derive on `ChangePoint` |
---
## 4. Alternatives Considered
### 4.1 Make `OccupancyVolume` Stateful In-Place (Rejected)
The simplest path is to add `confidence`/`last_update_ns`/`evidence_count` fields directly to `tomography.rs::OccupancyVolume` and have `reconstruct()` mutate a retained instance. Rejected: `OccupancyVolume` is currently a pure output of `reconstruct()` and is cloned/inspected by tests that assume it is a snapshot (e.g. `test_zero_attenuation_empty_room` asserts `occupied_count == 0` for a fresh volume). Conflating snapshot and persistent state would break that contract and entangle the solver with temporal policy. The `VoxelMap` keeps the solver pure and the temporal state separate.
### 4.2 One Tracker Per Link (Rejected)
Keep the per-link isolation and run an independent tracker per link. Rejected: this is the *current* situation and is exactly what makes cross-link change-point and mesh freshness impossible. The whole value of an "evolution tracker" is the cross-link view.
### 4.3 Kalman / Particle Filter Per Voxel (Rejected for Now)
A per-voxel Kalman or particle filter would model occupancy *and* velocity jointly with a proper motion model. Rejected as overkill for a coarse 8×8×4 grid at the current sensing resolution: the log-odds occupancy grid is the standard, cheap, commutative choice (Thrun et al., 2005) and integrates trivially with the existing ISTA output. A motion-model filter belongs in the pose tracker (`pose_tracker.rs` already runs a 17-keypoint Kalman), not in the coarse occupancy grid. Revisit if voxel resolution increases materially.
### 4.4 Emit Raw VoxelMap and Gate Downstream (Rejected)
Let the raw `VoxelMap` leave the node and gate it at the consumer. Rejected on the same structural-invariant grounds as BFLD class 0 (`Raw` is local-only by invariant I1, `bfld/src/lib.rs`): once raw identity-leaky voxel data crosses a network boundary it cannot be un-leaked. Gating must happen *before* the sink, inside the node, which is exactly what `VoxelGate::demote` enforces.
### 4.5 New Privacy Mechanism for Voxels (Rejected)
Design a bespoke voxel-privacy scheme independent of BFLD. Rejected: the monotonic-demotion invariant in `privacy_gate.rs` is already proven and audited (ADR-120), and ADR-141 already defines the named-mode control plane. Reusing `PrivacyClass` and the `demote` pattern means one privacy model across the whole system, one set of attestation tests, and no second mechanism to audit.
---
## 5. Testing and Acceptance
### 5.1 Unit Tests
**T1 — Mesh freshness aggregation.** Feed `LinkObservation`s with mixed `CalibrationStatus` (`Fresh`, `Stale`, `Expired`). Assert `mesh_freshness` is the worst case and `stale_links` lists exactly the non-fresh links, and a `CoherenceAlert` is emitted iff any link is Stale/Expired.
**T2 — Cross-link change-point fires at 3 links.** Push 30-frame z-windows where exactly 2 links exceed 2.0σ for a majority of the window: assert no `ChangePoint`. Add a 3rd: assert `ChangePoint { links }` fires and names all three.
**T3 — Change-point does NOT fire on a single sustained link.** One link hot for the full window, all others quiet: assert no `ChangePoint` (this is ADR-135's single-link staleness domain, not an environment change).
**T4 — Occupancy-consistency.** Set `model_occupancy = 1`, supply body energy implying 1 person: assert no `AnomalyWarn`. Supply body energy implying 3 persons: assert `AnomalyWarn { model: 1, perturbation: 3 }` and `occupancy_disagreement == true`.
**T5 — VoxelMap evidence accumulation.** Ingest 200 identical occupied volumes for one voxel and 1 occupied volume for another. Assert the 200-frame voxel has `evidence_count == 200`, `occupancy > 0.95`, and is NOT in `low_confidence_indices(5)`; the 1-frame voxel IS in `low_confidence_indices(5)` and has `occupancy` far from 1.0.
**T6 — Low-confidence flagging at threshold.** Ingest exactly 4 frames for a voxel: assert it is low-confidence. Ingest a 5th: assert it leaves `low_confidence_indices(5)`.
**T7 — Confidence decay.** Ingest a voxel to high confidence, then ingest `decay_half_life` ticks where that voxel is not touched: assert its `confidence` halved while `occupancy` (last estimate) is retained.
**T8 — Per-voxel Welford variance.** Ingest densities `[0.9, 0.1, 0.9, 0.1, ...]` (noisy) vs `[0.5, 0.5, ...]` (steady) with equal mean: assert the noisy voxel has higher `density_variance()` and consequently lower `confidence`.
**T9 — VoxelGate monotonicity.** `demote(map, Anonymous, Derived)` returns `BfldError::InvalidDemote { from: 2, to: 1 }`. `demote(map, Derived, Anonymous)` succeeds and the returned `VoxelMap` has every `doppler_velocity == 0.0` and `confidence == 0.0`.
**T10 — VoxelGate Restricted emits no grid.** `demote(map, Anonymous, Restricted)` returns `GatedVoxelOutput::OccupancyHistogram` and never a `VoxelMap` — assert the variant is the histogram and its length equals the requested bucket count.
**T11 — CIR prior is additive.** Run `RfTomographer::reconstruct()` with and without a `CirDistancePrior`; assert the no-prior path is bit-identical to current `tomography.rs` output (existing tests unchanged), and the with-prior path concentrates density nearer the CIR range.
### 5.2 Integration Test (gated, `#[cfg(feature = "hardware-test")]`)
**T12 — Real multistatic mesh (COM9 + cognitum-seed-1).** With an empty room, run 30 s and assert no `ChangePoint`, `mesh_freshness == Fresh`, and the `VoxelMap` has all voxels at `occupancy < 0.2`. Walk through: assert occupied voxels rise above 0.8 along the path, `evidence_count` grows, and walking *out* lets confidence decay. Move a chair and leave: assert a `ChangePoint` fires within 1.5 s and the affected links are named.
### 5.3 Determinism / Witness (CI-compatible, extends ADR-028)
**T13 — Deterministic VoxelMap hash.** Build a fixed 600-tick synthetic occupancy stream (seed=42), ingest into a `VoxelMap`, and SHA-256 the serialised voxel state. Record under `archive/v1/data/proof/expected_features.sha256` as `voxelmap_evidence_v1`; `verify.py` regenerates and asserts the hash. Mirrors ADR-135's `calibration_nvs_baseline_v1` proof methodology.
### 5.4 Acceptance Criteria
1. `EvolutionTracker::tick()` runs in < 1 ms for an 8×8×4 grid and 12 links (20 Hz budget is 50 ms; ample headroom).
2. Change-point fires iff ≥ `change_point_min_links` exceed `change_point_sigma` for a window majority (T2, T3).
3. A voxel below `min_evidence_frames` is always reported low-confidence (T5, T6).
4. No code path emits a raw `VoxelMap` to a network sink without `VoxelGate::demote` (enforced by the interface boundary in §2.7; `VoxelGate` is the only public constructor of `GatedVoxelOutput`).
5. `VoxelGate::demote` is monotonic: a promotion attempt always returns `BfldError::InvalidDemote` (T9).
6. Every emitted semantic state (occupancy + alerts) carries references to signal evidence (the `LinkObservation` set), model version (FieldModel SVD generation), calibration version (`BaselineCalibration.captured_at_unix_s`), and privacy decision (`VoxelGate` target class).
7. The CIR distance prior is provably additive — the no-prior reconstruction is unchanged (T11).
---
## 6. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-030 (Persistent Field Model) | **Extended**: adds the cross-link orchestrator and temporal voxel layer ADR-030 left unspecified; consumes `FieldModel::estimate_occupancy` and `CalibrationStatus` |
| ADR-134 (First-Class CIR) | **Integrated (optional)**: `Cir::dominant_distance_m()` feeds the `CirDistancePrior` into the tomography weight matrix for distance-based evidence weighting |
| ADR-135 (Empty-Room Baseline) | **Prerequisite/consumer**: reads `CalibrationDeviationScore.drift_score`; the cross-link change-point is the spatial complement to ADR-135's single-link staleness; shares the `W=300` window and recalibration triggers |
| ADR-120 (BFLD Privacy Classes) | **Reused**: `VoxelGate::demote` is a direct application of the `PrivacyGate::demote` monotonic invariant and `PrivacyClass` enum |
| ADR-141 (BFLD Privacy Control Plane) | **Policy provider**: ADR-141 chooses *which* `PrivacyClass` applies per sink and attests it at runtime; this ADR supplies the voxel mechanism |
| ADR-137 (Fusion Quality Scoring) | **Consumer**: `AnomalyWarn` (occupancy disagreement) becomes a contradiction flag with evidence references in the semantic state record |
| ADR-139 (WorldGraph) | **Consumer**: `ChangePoint` and `CoherenceAlert` mutate the environmental digital twin (moved-furniture edges, room recalibration markers) |
| ADR-136 (Streaming Engine) | **Substrate**: `EvolutionReport`/`EvolutionAlert` are typed stage outputs flowing through the streaming engine's frame contracts |
| ADR-084 / ADR-118 | **Related**: longitudinal drift and persistence context for the per-person baselines referenced by the tracker |
---
## 7. References
### Production Code
- `v2/crates/wifi-densepose-signal/src/ruvsense/tomography.rs``RfTomographer`, `OccupancyVolume`, `weight_matrix` to gain the optional CIR prior; `VoxelMap` is its temporal companion
- `v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs``WelfordStats` (reused for per-voxel variance), `CalibrationStatus`, `estimate_occupancy`, `check_freshness`
- `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs``CalibrationDeviationScore.drift_score` consumed per link (ADR-135)
- `v2/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs``PersonalBaseline`, `EmbeddingHistory` referenced by handle, not copied
- `v2/crates/wifi-densepose-signal/src/ruvsense/attractor_drift.rs``AttractorDriftAnalyzer::analyze` regime changes folded into evolution state
- `v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs``Cir::dominant_distance_m()` / `dominant_tap_tof_s()` source of the distance prior
- `v2/crates/wifi-densepose-bfld/src/privacy_gate.rs``PrivacyGate::demote` monotonic-demotion pattern reused by `VoxelGate`
- `v2/crates/wifi-densepose-bfld/src/lib.rs``PrivacyClass` (Raw/Derived/Anonymous/Restricted), `BfldError::InvalidDemote`
- `archive/v1/data/proof/verify.py` — deterministic proof chain; `voxelmap_evidence_v1` hash extension
- `archive/v1/data/proof/expected_features.sha256` — hash entry to be added
### External References
- Moravec, H. & Elfes, A. (1985). "High Resolution Maps from Wide Angle Sonar." *Proc. IEEE ICRA*. — Origin of the occupancy-grid log-odds update used per voxel.
- Thrun, S., Burgard, W. & Fox, D. (2005). *Probabilistic Robotics*. MIT Press. Ch. 9 (Occupancy Grid Mapping). — Standard commutative log-odds occupancy update; basis for `VoxelMap::ingest`.
- Welford, B.P. (1962). "Note on a Method for Calculating Corrected Sums of Squares and Products." *Technometrics*, 4(3), 419420. — Per-voxel mean/variance accumulation (same form as `field_model.rs::WelfordStats`).
- Wilson, J. & Patwari, N. (2010). "Radio Tomographic Imaging with Wireless Networks." *IEEE Trans. Mobile Computing*, 9(5). — Tomographic inversion basis for `tomography.rs`, extended here with temporal evidence accumulation.
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `1f8e180d6`, issue #846): `EvolutionTracker` (cross-link change-point), `TemporalVoxel` (Bayesian log-odds occupancy + confidence floor), and `VoxelGate` (privacy demotion to a histogram). 6 tests.
**Integration glue -- not yet on the live path:** driving `field_model.estimate_occupancy()` consistency checks and CIR-peak-delay distance weighting from live signals; routing detected anomalies to ADR-137 contradiction flags.
**Trust contribution:** *the room changed* is inferred from multi-link consensus (not one noisy link), and occupancy can be blurred to an aggregate histogram under privacy.
@@ -0,0 +1,535 @@
# ADR-143: RF SLAM v2: Persistent Reflector Discovery and Dynamic Anchor Learning
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/field_model.rs`, new `ruvsense/rf_slam.rs`); `wifi-densepose-mat` (`tracking/kalman.rs`, `localization/triangulation.rs`); `wifi-densepose-geo`; `wifi-densepose-ruvector` (`mat/triangulation.rs`) |
| **Relates to** | ADR-029 (RuvSense Multistatic), ADR-030 (Persistent Field Model), ADR-042 (Coherent Human Channel Imaging), ADR-134 (First-Class CIR Support), ADR-136 (RuView Streaming Engine), ADR-138 (LinkGroup / ArrayCoordinator), ADR-139 (WorldGraph), ADR-141 (BFLD Privacy Control Plane), ADR-142 (Evolution Tracker / Temporal VoxelMap) |
---
## 1. Context
### 1.1 The Gap
The codebase has the two ingredients RF SLAM needs — a delay-domain CIR per link and a per-link statistical baseline — but nothing that converts them into a *map of where the reflectors physically are*, and nothing that *learns* anchor positions from data instead of taking them as fixed configuration.
Grepping the workspace confirms the absence and the substrate:
- **CIR exists, geometry does not.** `v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs` produces a `Cir` (lines 263286) with `taps: Vec<Complex32>`, `tap_spacing_sec`, `dominant_tap_idx`, `dominant_tap_ratio`, `active_tap_count`, and `rms_delay_spread_s`. This is a per-link delay profile. There is no code that takes the *separation* between taps across two or more links and triangulates a reflector's `(x, y, z)` position, nor any code that tracks a tap cluster's position over hours. `Cir::dominant_distance_m()` (line 297) converts the dominant tap delay to a one-link range, but a single range is a sphere, not a point.
- **The field model centres on a mean, not a reflector list.** `ruvsense/field_model.rs` (`FieldModel`, `FieldNormalMode`) computes a per-link amplitude baseline (`baseline: Vec<Vec<f64>>`, line 265), an SVD over the per-subcarrier covariance, environmental eigenmodes, `variance_explained` (line 272), and a Marcenko-Pastur `baseline_eigenvalue_count` (line 278). It answers "how much energy is structured static environment" — it never answers "*which physical objects* produce that energy and *where are they*." There is no `Reflector`, no `anchor`, no spatial position in the entire module.
- **Localisation assumes fixed anchors.** `wifi-densepose-mat/src/localization/triangulation.rs` (`TriangulationConfig`, `Triangulator`, lines 788) takes `sensors: &[SensorPosition]` as given input and trilaterates a *person* from RSSI/ToA. `wifi-densepose-ruvector/src/mat/triangulation.rs::solve_triangulation()` (lines 2853) takes `ap_positions: &[(f32, f32)]` as a fixed argument and solves a linearised TDoA system via `NeumannSolver`. Both treat anchor positions as configuration the operator must enter by hand. Neither has any path to *discover* an anchor (a static reflector or an AP) from the signal.
- **The tracker tracks people, not furniture.** `wifi-densepose-mat/src/tracking/kalman.rs` (`KalmanState`, lines 2635) is a 6-state constant-velocity filter for a *survivor* position. There is no per-reflector tracker, no notion of a slow-moving (furniture) versus fast-moving (person) target, and no displacement-rate estimate.
- **`wifi-densepose-geo` has scene types but no RF objects.** `wifi-densepose-geo/src/types.rs` exposes `GeoPoint`, `GeoBBox`, `GeoRegistration`, `GeoScene`, `OsmFeature` — outdoor geospatial registration. There is no indoor reflector or anchor type.
So the gap is precise: **the system can measure multipath delay per link and can tell static from dynamic energy, but it cannot place reflectors in a room coordinate frame, cannot decide which reflectors are stable enough to use as localisation anchors, and cannot notice when the furniture has moved.** ADR-030 (§the persistent field model) and ADR-042 (CHCI) both assume a known room geometry; neither specifies how that geometry is acquired.
### 1.2 What "RF SLAM" Means Here (and What v1 Already Is)
SLAM — Simultaneous Localisation And Mapping — in the RF-sensing context means: *while* tracking moving targets (localisation), also *build and refine* the map of static scatterers (mapping). This ADR is explicitly **v2**. There is a **v1** that this ADR commits to shipping *first*:
- **RF SLAM v1 (ship now):** 3 fixed APs at operator-entered positions + a single static-reflector assumption. This is essentially what `triangulation.rs` and `solve_triangulation()` already do once the operator types in AP coordinates. v1 requires no new discovery code — it requires only wiring the fixed positions into the WorldGraph as immutable `object_anchor` nodes (ADR-139). v1 is honest about its limitation: it cannot adapt to a moved sofa.
- **RF SLAM v2 (this ADR, feature-flagged):** infer reflector positions from CIR tap separation, learn which reflectors are stable enough to serve as anchors, detect topology change, and estimate furniture movement — all gated behind a feature flag until a 7-day validation dataset is collected.
The reason for the two-tier rollout is the same reason ADR-135 makes recalibration operator-initiated: **there is no oracle for ground truth in a live home.** A reflector-discovery algorithm that places a wall 30 cm off does not announce its error; it silently degrades every downstream localisation. v2 must prove itself on 7 days of paired data before it is allowed to overwrite the v1 fixed map.
### 1.3 Why CIR Tap Separation Gives Geometry
For a link between TX at `p_tx` and RX at `p_rx`, a reflector at `p_r` produces a delayed copy of the direct path. The excess delay of that tap, relative to the direct (line-of-sight) tap, is:
```
Δτ = ( |p_tx p_r| + |p_r p_rx| |p_tx p_rx| ) / c
```
`Δτ` is exactly `(tap_idx dominant_tap_idx) × tap_spacing_sec` from the `Cir` struct. A single link constrains the reflector to a **prolate spheroid** with foci at `p_tx` and `p_rx` (constant bistatic range = constant excess delay). Two links with shared geometry intersect their spheroids; three or more over-determine the reflector position and let least-squares resolve `(x, y, z)`. This is the dual of `solve_triangulation()` in `ruvector/mat/triangulation.rs`: that function solves for a person given fixed APs; reflector discovery solves for a static scatterer given the (now known, from v1) APs and the per-link excess-delay taps.
The bistatic-range geometry only resolves a point if the multipath cluster is **persistent and coherent** across the observation window. Hence discovery is gated on temporal coherence (the same von Mises phase-concentration machinery from ADR-135) and on the room genuinely being in a static regime (the ADR-030 Marcenko-Pastur threshold — if `estimate_occupancy() > 0`, the room is occupied and discovery is suspended).
### 1.4 Pipeline Position
```
Per-link CSI (ADR-135 baseline-subtracted, ADR-138 LinkGroup-grouped)
→ CirEstimator::estimate() (ADR-134) → Cir { taps, ... }
→ FieldModel.feed_calibration / SVD (ADR-030) → variance_explained, MP count
→ ReflectorTracker::observe() ← NEW (rf_slam.rs)
· extract excess-delay taps per link
· associate taps to reflector tracks (per-reflector Kalman)
· bistatic multilateration → reflector (x,y,z) + covariance
· coherence-gate: accept only persistent, von-Mises-concentrated taps
→ AnchorLearner::classify() ← NEW
· cluster persistent reflectors → walls / large objects
· reject mobile reflectors (tap migration > 0.5 m/day)
· emit StaticAnchor set
→ TopologyMonitor::tick() ← NEW
· variance_explained drop > 15% / 4h OR covariance-rank change
→ BaselineTopologyChange event → recalibration trigger (ADR-135 §2.6)
→ FurnitureMovementEstimator::tick() ← NEW
· per-reflector tap-migration rate → hourly displacement ± 0.5 m
→ WorldGraph::upsert(object_anchor) (ADR-139) → persisted via RVF
```
v2 discovery code (everything marked NEW) is compiled behind `#[cfg(feature = "rf-slam-v2")]` and is a no-op at runtime unless `RfSlamConfig::enabled` is also set. v1's fixed-AP map flows straight to `WorldGraph::upsert(object_anchor)` with immutable positions.
---
## 2. Decision
### 2.1 v2 Reflector Discovery from CIR Tap Separation + Temporal Coherence
A reflector is discovered, not configured. The `ReflectorTracker` ingests one `Cir` per link per cycle (from ADR-138's `LinkGroup`, which guarantees the links it groups share a clock-quality tier so their delays are comparable) and maintains a set of reflector tracks.
**Discovery preconditions (all must hold for a cycle to contribute to discovery):**
1. **Room is static.** `FieldModel::estimate_occupancy()` (field_model.rs:741) returns 0 for the cycle's recent-frame window, *and* the ADR-030 Marcenko-Pastur significant-eigenvalue count equals the calibrated `baseline_eigenvalue_count`. If the room is occupied, the cycle is dropped for discovery (but still used for localisation). This reuses the existing eigenvalue gate rather than inventing a new occupancy detector.
2. **Tap is coherent over the window.** For a candidate tap index `g` on a link, the complex tap value `taps[g]` must have circular phase variance below `coherence_max` (default 0.15) over a rolling 2472 h window, computed with the running `sin`/`cos` accumulator from ADR-135 §2.2 (von Mises projection). A tap whose phase wanders is a transient (a passing person's residual, an HVAC vane), not a static scatterer.
3. **Tap exceeds the noise floor.** `|taps[g]|``1%` of the dominant tap — reusing the `active_tap_count` definition (cir.rs:278) so the discovery and CIR modules agree on what "a tap" is.
**Multilateration.** Each accepted tap gives one bistatic-range constraint per link. With ≥3 links observing a common scatterer (associated by excess-delay consistency, §2.4), the reflector position is solved by the **same Neumann-series least-squares machinery** as person localisation — `wifi-densepose-ruvector/src/mat/triangulation.rs::solve_triangulation()` is generalised so it can be fed reflector bistatic ranges instead of person TDoA. The reflector position carries a 3×3 covariance from the residual.
```rust
// v2/crates/wifi-densepose-signal/src/ruvsense/rf_slam.rs
use num_complex::Complex32;
use crate::ruvsense::cir::Cir;
use crate::ruvsense::field_model::WelfordStats;
/// A persistent static scatterer inferred from CIR tap separation.
#[derive(Debug, Clone)]
pub struct Reflector {
/// Stable identifier assigned at first confident discovery.
pub id: ReflectorId,
/// Estimated room-frame position (metres). `None` until ≥3 links concur.
pub position_m: Option<[f64; 3]>,
/// 3×3 position covariance (metres²), row-major. `None` until localised.
pub position_cov: Option<[[f64; 3]; 3]>,
/// Per-observing-link excess delay (s) relative to that link's direct tap.
pub excess_delay_s: Vec<(LinkId, f64)>,
/// Welford amplitude statistics of the tap magnitude over the window.
pub amp_stats: WelfordStats,
/// Circular phase variance over the window ∈ [0, 1]; <0.15 ⇒ coherent.
pub phase_circular_variance: f32,
/// Number of discovery cycles this reflector has been continuously observed.
pub persistence_cycles: u64,
/// First-seen / last-seen UTC (Unix seconds).
pub first_seen_unix_s: i64,
pub last_seen_unix_s: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ReflectorId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LinkId(pub u32);
#[derive(Debug, thiserror::Error)]
pub enum RfSlamError {
#[error("RF SLAM v2 disabled (set RfSlamConfig.enabled and the rf-slam-v2 feature)")]
Disabled,
#[error("Room is occupied; discovery suspended for this cycle")]
RoomOccupied,
#[error("Insufficient observing links: need {needed}, have {got}")]
InsufficientLinks { needed: usize, got: usize },
#[error("Multilateration failed to converge")]
NoConverge,
#[error("Validation dataset not yet present: {0}")]
ValidationGateClosed(String),
}
#[derive(Debug, Clone)]
pub struct RfSlamConfig {
/// Master switch. False ⇒ all v2 entry points return `Disabled`.
pub enabled: bool,
/// Min links concurring before a reflector position is emitted. Default 3.
pub min_links: usize,
/// Max circular phase variance for a coherent tap. Default 0.15.
pub coherence_max: f32,
/// Coherence-window length in hours. Default 48 (range 2472).
pub coherence_window_h: f64,
/// Mobile-reflector rejection threshold (metres/day). Default 0.5.
pub mobile_reject_m_per_day: f64,
/// variance_explained relative-drop fraction triggering topology change. Default 0.15.
pub topology_var_drop: f64,
/// Window over which the drop is measured (hours). Default 4.0.
pub topology_window_h: f64,
}
impl Default for RfSlamConfig {
fn default() -> Self {
Self {
enabled: false, // v2 is OFF until the 7-day dataset is validated.
min_links: 3,
coherence_max: 0.15,
coherence_window_h: 48.0,
mobile_reject_m_per_day: 0.5,
topology_var_drop: 0.15,
topology_window_h: 4.0,
}
}
}
/// Maintains reflector tracks across discovery cycles.
pub struct ReflectorTracker {
config: RfSlamConfig,
reflectors: Vec<Reflector>,
next_id: u64,
}
impl ReflectorTracker {
pub fn new(config: RfSlamConfig) -> Self;
/// Ingest one CIR per observing link for the current cycle.
///
/// `cirs`: `(LinkId, &Cir)` for every link in the ADR-138 LinkGroup.
/// `occupied`: result of `FieldModel::estimate_occupancy() > 0`.
///
/// Returns the set of reflectors updated or newly created this cycle.
/// Returns `RoomOccupied` (no-op) if `occupied`, `Disabled` if not enabled.
pub fn observe(
&mut self,
cirs: &[(LinkId, &Cir)],
occupied: bool,
now_unix_s: i64,
) -> Result<Vec<ReflectorId>, RfSlamError>;
/// Current confident reflector set (position resolved, coherent).
pub fn reflectors(&self) -> &[Reflector];
}
```
### 2.2 Static-Anchor Learning by Furniture Clustering
Not every reflector is a good localisation anchor. A wall is; a houseplant that sways is not; a chair that gets pushed in twice a day is not. The `AnchorLearner` partitions the reflector set into **static anchors** (usable for the v2 map) and **mobile reflectors** (tracked but excluded from the anchor set).
**Classification rules:**
| Class | Criterion | Rationale |
|-------|-----------|-----------|
| `StaticAnchor` | `phase_circular_variance < coherence_max` AND tap-migration rate `< mobile_reject_m_per_day` (0.5 m/day) AND `persistence_cycles` spans ≥ 24 h | Walls and large fixed objects (cabinet, fridge) produce a coherent tap whose position does not drift day to day. |
| `MobileReflector` | tap-migration rate ≥ 0.5 m/day | Furniture that is rearranged; tracked for movement inference (§2.4) but never used as a localisation anchor because its position is not trustworthy as a reference. |
| `TransientCandidate` | `phase_circular_variance ≥ coherence_max` OR `persistence_cycles` < 24 h | Not yet confident; held in a candidate buffer, promoted or aged out. |
**Spatial clustering into furniture categories.** Static anchors are clustered in room-frame `(x, y, z)` using density-based clustering (DBSCAN-style, `ε = 0.3 m`, `minPts = 2`). A cluster's bounding box and surface-normal (from the spread of contributing links' bistatic geometry) categorise it:
- A planar cluster spanning ≥ 1.5 m with a consistent normal → `Wall`.
- A compact cluster (< 1.0 m extent) at a fixed height → `LargeObject` (appliance, cabinet).
Categories are advisory metadata on the WorldGraph node (§2.5), not load-bearing for localisation — localisation uses the anchor *positions*, the category labels them for the operator and for ADR-140 semantic state records.
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnchorClass { StaticAnchor, MobileReflector, TransientCandidate }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FurnitureCategory { Wall, LargeObject, Unknown }
#[derive(Debug, Clone)]
pub struct StaticAnchor {
pub reflector_id: ReflectorId,
pub position_m: [f64; 3],
pub position_cov: [[f64; 3]; 3],
pub category: FurnitureCategory,
/// Tap-migration rate (metres/day) over the coherence window.
pub migration_m_per_day: f64,
}
pub struct AnchorLearner { config: RfSlamConfig }
impl AnchorLearner {
pub fn new(config: RfSlamConfig) -> Self;
/// Classify the current reflector set and return the static anchors.
pub fn classify(&self, reflectors: &[Reflector]) -> Vec<(ReflectorId, AnchorClass)>;
/// Build the static-anchor set with spatial clustering + categorisation.
pub fn learn_anchors(&self, reflectors: &[Reflector]) -> Vec<StaticAnchor>;
}
```
### 2.3 Topology-Change Detection via Variance and Covariance Rank
A reflector map is only valid while the room topology is unchanged. v2 detects topology change with two ADR-030 / ADR-134 signals, reusing values the field model already computes:
1. **`variance_explained` drop.** `FieldNormalMode.variance_explained` (field_model.rs:272) is the fraction of CSI variance captured by the calibrated environmental modes. When the furniture map shifts, the calibrated modes no longer fit and `variance_explained` falls. **Trigger: a relative drop > 15% sustained over a 4-hour window.** (Relative, not absolute — a room with `variance_explained = 0.8` dropping to `0.68` is the same proportional shift as `0.5 → 0.425`.)
2. **Covariance rank change.** The Marcenko-Pastur significant-eigenvalue count (`baseline_eigenvalue_count`, field_model.rs:278/589) is the structural rank of the static channel. A new fixed scatterer adds a mode; a removed one drops a mode. A *sustained* change in the MP count while the room is unoccupied (occupancy gate from §2.1) indicates a topology change, not a person.
Both conditions feed a `TopologyMonitor` that, on confirmed change, emits `BaselineTopologyChange` and routes it to the **existing** recalibration trigger described in ADR-135 §2.6 (`recalibrate_on_drift`). v2 does not invent a second recalibration path; it provides a more specific *cause* (topology change vs amplitude drift) than ADR-135's amplitude-only z-score drift.
```rust
#[derive(Debug, Clone)]
pub enum TopologyEvent {
/// variance_explained dropped > config.topology_var_drop over the window.
VarianceCollapse { from: f64, to: f64, window_h: f64 },
/// Marcenko-Pastur significant-eigenvalue count changed while unoccupied.
RankChange { from: usize, to: usize },
}
pub struct TopologyMonitor { config: RfSlamConfig, /* rolling history */ }
impl TopologyMonitor {
pub fn new(config: RfSlamConfig) -> Self;
/// Feed the current field-model summary for this cycle.
/// Returns `Some(event)` when a topology change is confirmed.
pub fn tick(
&mut self,
variance_explained: f64,
mp_significant_count: usize,
occupied: bool,
now_unix_s: i64,
) -> Option<TopologyEvent>;
}
```
### 2.4 Furniture-Movement Inference
A `MobileReflector` is not noise — its *displacement over time* is information ("the chair moved 0.4 m at 14:00"). The `FurnitureMovementEstimator` tracks each reflector's tap-migration rate and emits hourly displacement estimates with a **0.5 m confidence band**, using ADR-042 CHCI cross-link consistency to reject spurious migrations.
**Per-reflector position tracking.** Each reflector gets a slow-dynamics Kalman filter. We **reuse the constant-velocity `KalmanState` from `wifi-densepose-mat/src/tracking/kalman.rs`** (the same 6-state `[px,py,pz,vx,vy,vz]` filter used for survivors, kalman.rs:26) but parameterised for furniture timescales: a tiny process-noise variance (`process_noise_var ≈ 1e-6 (m/s²)²`, vs the human-tracking value) so the filter only believes motion that persists across many hours. The velocity components, integrated over an hour, give the hourly displacement.
**CHCI cross-link consistency gate.** A genuine furniture move shifts the excess-delay tap *consistently* across every link that observes that reflector (the geometry changes for all of them coherently). A spurious migration (multipath self-interference, a transient) shows up on one link only. ADR-042's coherent cross-link phase machinery scores this consistency: a displacement is emitted only if ≥ `min_links` links agree on the direction of tap migration within the 0.5 m band. Reflectors that fail the consistency check have their displacement suppressed (reported as "unstable, no estimate").
```rust
#[derive(Debug, Clone)]
pub struct DisplacementEstimate {
pub reflector_id: ReflectorId,
/// Displacement vector this hour (metres, room frame).
pub displacement_m: [f64; 3],
/// 1-σ confidence radius (metres); ≤ 0.5 by construction or estimate suppressed.
pub confidence_radius_m: f64,
/// Number of links agreeing on the migration direction (CHCI consistency).
pub consistent_links: usize,
pub hour_unix_s: i64,
}
pub struct FurnitureMovementEstimator { config: RfSlamConfig /* per-reflector KalmanState */ }
impl FurnitureMovementEstimator {
pub fn new(config: RfSlamConfig) -> Self;
/// Advance one cycle; returns any hourly displacement estimates that
/// completed this tick. CHCI-inconsistent reflectors are omitted.
pub fn tick(
&mut self,
reflectors: &[Reflector],
now_unix_s: i64,
) -> Vec<DisplacementEstimate>;
}
```
### 2.5 Persistence into the WorldGraph via RVF
Discovered reflectors, anchor assignments, and calibration timestamps are persisted as **`object_anchor` nodes in the ADR-139 WorldGraph** (the typed petgraph environmental digital twin), serialised through RVF. This is the single source of truth for room geometry that ADR-030, ADR-042, and the localisation triangulators all read.
Each `object_anchor` node carries the full evidence-and-provenance chain so the project rule "every semantic state traces to signal evidence + model version + calibration version + privacy decision" holds:
| Field | Source | Trace role |
|-------|--------|-----------|
| `position_m`, `position_cov` | bistatic multilateration (§2.1) | signal evidence (CIR taps) |
| `class`, `category` | `AnchorLearner` (§2.2) | derived label |
| `migration_m_per_day` | `FurnitureMovementEstimator` (§2.4) | temporal evidence |
| `discovery_model_version` | `rf_slam.rs` semantic version | **model version** |
| `calibration_version` | ADR-135 baseline `captured_at_unix_s` + device_id | **calibration version** |
| `first_seen / last_seen / last_topology_event` | tracker timestamps | provenance |
| `privacy_decision` | ADR-141 BFLD mode at time of write | **privacy decision** |
| `evidence_refs` | CIR cycle ids contributing to the position fit | **signal evidence references** |
ADR-142's Evolution Tracker / Temporal VoxelMap consumes the same `object_anchor` stream to aggregate reflector evidence into the room voxel map over time; ADR-136's streaming engine carries reflector updates as a stage output frame.
```rust
/// Snapshot written to the WorldGraph as an `object_anchor` node (ADR-139).
#[derive(Debug, Clone)]
pub struct ObjectAnchorRecord {
pub reflector_id: ReflectorId,
pub position_m: [f64; 3],
pub position_cov: [[f64; 3]; 3],
pub class: AnchorClass,
pub category: FurnitureCategory,
pub migration_m_per_day: f64,
pub discovery_model_version: String, // model version
pub calibration_version: String, // ADR-135 baseline id (device_id@captured_at)
pub privacy_decision: String, // ADR-141 BFLD mode label
pub evidence_refs: Vec<u64>, // contributing CIR cycle ids
pub first_seen_unix_s: i64,
pub last_seen_unix_s: i64,
}
```
**The v1/v2 feature gate, concretely.** All of §2.1–§2.5 is compiled under `#[cfg(feature = "rf-slam-v2")]` and is dormant unless `RfSlamConfig::enabled == true`. With the feature off (the default), `WorldGraph` is populated *only* by the v1 path: 3 fixed APs at operator-entered positions written as immutable `object_anchor` nodes (`class = StaticAnchor`, `category = Unknown`, `migration_m_per_day = 0.0`, `discovery_model_version = "v1-fixed"`), plus a single static-reflector assumption (one inferred wall reflector from the dominant non-direct tap, also immutable). v2 may be enabled only after the validation gate (§2.7) confirms a 7-day dataset exists and v2's discovered anchors agree with ground truth within 0.5 m.
### 2.6 Interface Boundaries
| Module | Reads | Writes | Boundary contract |
|--------|-------|--------|-------------------|
| `ruvsense/rf_slam.rs` (NEW) | `Cir` (cir.rs), `FieldModel` occupancy + `variance_explained` + MP count (field_model.rs), ADR-138 `LinkGroup` membership | `Reflector`, `StaticAnchor`, `TopologyEvent`, `DisplacementEstimate`, `ObjectAnchorRecord` | Pure compute; no I/O. `observe()` is `&mut self`, single-threaded per LinkGroup (same convention as ADR-135 `CalibrationRecorder`). |
| `ruvector/mat/triangulation.rs` | reflector bistatic ranges (generalised input) | reflector `(x,y)`/`(x,y,z)` | `solve_triangulation()` generalised to accept either person TDoA or reflector bistatic-range constraints; existing person-localisation signature preserved (additive, non-breaking). |
| `mat/tracking/kalman.rs` | per-reflector observations | per-reflector filtered position/velocity | `KalmanState` reused unchanged; only `process_noise_var` is retuned for furniture timescales by the caller. |
| `wifi-densepose-geo` | room-frame anchor positions | `GeoScene` indoor extension | New indoor `Anchor` type added alongside `OsmFeature`; geo registration places the room frame in a global frame when an outdoor `GeoRegistration` exists. Optional — indoor-only deployments skip geo. |
| ADR-139 `WorldGraph` | `ObjectAnchorRecord` | `object_anchor` petgraph nodes (RVF) | RF SLAM owns reflector geometry; WorldGraph owns persistence and cross-domain links (anchor ↔ room ↔ person). |
| ADR-135 calibration | — | consumes `TopologyEvent` | `BaselineTopologyChange` is a stronger-typed cause feeding the existing `recalibrate_on_drift` path; no new recalibration mechanism. |
### 2.7 Validation Gate: 7-Day Dataset Before v2 Ships
v2 discovery may not be enabled in production until a **7-day paired validation dataset** demonstrates it is correct. The gate is enforced in code: `ReflectorTracker::observe()` returns `RfSlamError::ValidationGateClosed` if `RfSlamConfig::enabled` is set but the validation manifest is absent.
**Dataset contents (collected on the fleet from CLAUDE.local.md):**
- 7 consecutive days of unoccupied-window CSI from a ≥ 3-link room (e.g. `cognitum-v0` appliance room with `cognitum-seed-1` + 2 provisioned seeds).
- Ground-truth anchor positions: tape-measured wall and large-object positions in the room frame.
- ≥ 2 deliberate furniture-move events with logged before/after positions (for §2.4 and §2.3 validation).
**Pass criteria (all required to flip `enabled`):**
1. Discovered `StaticAnchor` positions within **0.5 m** of tape-measured ground truth for ≥ 80% of anchors.
2. Each logged furniture move detected by `TopologyMonitor` within 4 hours; displacement estimate within the 0.5 m band.
3. Zero false `BaselineTopologyChange` events across the 7 days of genuinely static periods.
4. No mobile reflector (the moved object) ever admitted to the `StaticAnchor` set.
Until then, the system ships v1: fixed APs + single static reflector. This mirrors ADR-135's principle that calibration must not silently degrade sensing.
---
## 3. Consequences
### 3.1 Positive
- **Anchors stop being hand-entered.** Today an operator must measure and type AP positions into `TriangulationConfig`. v2 discovers the static scene from the signal, so a moved AP or a newly characterised wall is picked up automatically — the long-standing manual-survey step disappears once v2 is validated.
- **Topology change becomes observable.** Reusing `variance_explained` and the Marcenko-Pastur rank gives a principled "the furniture moved" signal that feeds ADR-135 recalibration with a *specific cause*, replacing the amplitude-only drift heuristic.
- **Reflector geometry sharpens CIR and CHCI.** Once reflector positions are known, ADR-042 CHCI can use them as fixed scatterers in the coherent-imaging forward model, and ADR-134 CIR ghost-tap suppression knows which low-delay taps are structural (walls) vs body-perturbed.
- **One source of geometric truth.** Persisting to the ADR-139 WorldGraph means localisation (`mat/triangulation.rs`), the field model (ADR-030), and the temporal voxel map (ADR-142) all read the same `object_anchor` set instead of each carrying its own anchor assumptions.
- **Reuse over reinvention.** No new Kalman filter (reuses `kalman.rs`), no new solver (reuses `solve_triangulation`/`NeumannSolver`), no new occupancy detector (reuses `estimate_occupancy`), no new phase-coherence math (reuses ADR-135 von Mises projection).
### 3.2 Negative
- **v2 is dormant for an unknown lead time.** The 7-day dataset gates everything; until it is collected and passes, all of §2.1–§2.5 is dead code behind a feature flag. The value is realised only after a validation campaign on the fleet.
- **Bistatic multilateration needs ≥ 3 well-separated links.** A 1- or 2-link room can never resolve reflector positions (the spheroids do not intersect to a point). Such rooms are permanently v1-only. ADR-138 LinkGroups with poor geometric diversity yield high-covariance, low-value reflectors.
- **DBSCAN parameters (`ε=0.3 m`, `minPts=2`) are room-scale assumptions.** A very large or very cluttered space may need retuning; the defaults are validated only against the 7-day dataset room.
- **Furniture-movement inference is slow by design.** The tiny process-noise variance means a real move takes up to an hour to be confidently reported. This is intentional (it suppresses false moves) but means v2 is not a fast "object moved" alarm.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| v2 discovers a phantom reflector from correlated multipath self-interference and pollutes the anchor set | Medium | Localisation degrades against a wrong anchor | Coherence gate (von Mises variance < 0.15) + CHCI cross-link consistency + ≥3-link concurrence; phantom taps fail at least one. Validation criterion 4 explicitly tests this. |
| Reflector discovery runs during a period the occupancy detector wrongly calls "empty" (a still person) | Medium | A person-shaped scatterer learned as furniture | `persistence_cycles ≥ 24 h` requirement: a person does not sit perfectly still in one spot for a day; tap migration > 0.5 m/day eventually reclassifies them `MobileReflector` and excludes them from anchors. |
| `variance_explained` drops for a benign reason (temperature, humidity) and triggers false topology change | LowMedium | Spurious recalibration request | Relative-drop + 4 h sustained window + unoccupied gate; ADR-030 already attributes slow thermal drift to the *retained* environmental modes, so it does not reduce `variance_explained`. Validation criterion 3 caps false events at zero. |
| Generalising `solve_triangulation()` to reflectors introduces a regression in person localisation | Low | Survivor localisation breaks | The reflector path is additive; the existing person-TDoA signature and tests are preserved unchanged. A regression test asserts byte-identical person-localisation output pre/post change. |
| Operator enables `rf-slam-v2` without the dataset | Low | — (fails safe) | `ValidationGateClosed` error blocks `observe()`; system stays on v1. |
---
## 4. Alternatives Considered
### 4.1 Visual / Camera SLAM for the Room Map
The fleet has cameras (`ruvultra`, `cognitum-v0`). Camera SLAM would map furniture far more accurately. Rejected as the *primary* mechanism because: (a) the entire product premise is privacy-preserving RF sensing — adding a camera to map the room contradicts the ADR-141 BFLD privacy modes; (b) cameras do not see through walls, so they cannot characterise reflectors behind furniture that nonetheless affect the RF channel. Camera ground truth is, however, exactly what the §2.7 validation dataset uses — as an *offline validation oracle*, not a runtime dependency.
### 4.2 Full Graph-SLAM / Factor-Graph Back-End (g2o / GTSAM style)
A factor-graph back-end jointly optimising all reflector positions, anchor poses, and person trajectories is the "textbook" SLAM formulation. Rejected for v2 scope: it is a large new dependency and solver, and the per-reflector Kalman + per-cycle least-squares multilateration already in the codebase (`kalman.rs` + `NeumannSolver`) is sufficient for a static-scene map that changes only on rare furniture moves. A factor-graph back-end is reasonable for a v3 once v2 proves the discovery front-end works.
### 4.3 Neural Reflector Inference
Train a network to regress reflector positions from CIR. Rejected for the same reason ADR-135 §4.3 rejects neural baselines: no paired CIR→geometry dataset exists, the mapping is room-specific, and a network gives no covariance or failure mode. Bistatic multilateration is a closed-form geometric estimator with an explicit covariance and a clear "insufficient links" failure.
### 4.4 Skip v1, Ship v2 Directly
Tempting — v2 is strictly more capable. Rejected because v2 is unvalidated and silently degrades on error (§1.2). Shipping the fixed-AP v1 gives a working, debuggable baseline that the v2 discovery can be measured *against*, and gives users a functioning system during the multi-day v2 validation campaign.
### 4.5 EMA-Adapted Anchor Positions Instead of Discrete Topology Events
Continuously sliding anchor positions with an exponential moving average avoids the topology-change ceremony. Rejected for the same reason ADR-135 §4.4 rejects EMA for baselines: a person standing near a wall would slowly drag the wall's "anchor" toward them. Anchors must be stable between explicit topology events, not continuously adapted.
---
## 5. Testing and Acceptance
### 5.1 Unit Tests (CI, synthetic — no hardware, no feature gate needed for the math)
- **T1 — bistatic geometry round-trip.** Place a synthetic reflector at a known `(x,y,z)`; compute the exact excess delay for 4 synthetic links; feed taps to `ReflectorTracker::observe()`; assert recovered `position_m` is within `0.05 m` (numerical, noise-free) and `position_cov` is small.
- **T2 — sub-3-link insufficiency.** Same reflector, only 2 links → `observe()` leaves `position_m == None`, no `StaticAnchor` emitted.
- **T3 — coherence gate.** A tap whose synthetic phase is randomised (circular variance ≈ 1.0) is never promoted to `StaticAnchor` regardless of link count.
- **T4 — mobile rejection.** A reflector whose synthetic position drifts 1.0 m/day is classified `MobileReflector`, never `StaticAnchor` (validates the 0.5 m/day threshold).
- **T5 — occupancy gate.** With `occupied = true`, `observe()` returns `RoomOccupied` and mutates no track.
- **T6 — topology variance collapse.** Feed `variance_explained` dropping from 0.80 → 0.66 (17.5% relative) sustained 4 h, unoccupied → exactly one `VarianceCollapse` event; a 10% drop produces none.
- **T7 — topology rank change.** MP significant count 5 → 6 sustained while unoccupied → one `RankChange` event.
- **T8 — furniture displacement + CHCI consistency.** A reflector moved 0.4 m consistently across ≥3 links → one `DisplacementEstimate` with `confidence_radius_m ≤ 0.5`; the same migration on 1 link only → suppressed (no estimate).
- **T9 — WorldGraph record provenance.** `ObjectAnchorRecord` always carries non-empty `discovery_model_version`, `calibration_version`, `privacy_decision`, and `evidence_refs` (enforces the four-part trace rule).
- **T10 — validation gate.** `enabled = true` without the validation manifest → `ValidationGateClosed`; `enabled = false``Disabled`. v1 path still populates the WorldGraph with immutable fixed-AP anchors in both cases.
- **T11 — person-localisation regression.** Generalised `solve_triangulation()` produces byte-identical output to the pre-change version for the existing person-TDoA test vectors.
### 5.2 Integration Test (gated `#[cfg(feature = "hardware-test")]`, not in CI)
- **T12 — 7-day fleet validation campaign.** On `cognitum-v0` room with ≥3 provisioned seeds: collect the §2.7 dataset, run discovery, and assert the four pass criteria. This test *is* the validation gate; passing it is the precondition for setting `RfSlamConfig::enabled` in production config.
### 5.3 Acceptance Criteria (mirror §2.7)
1. ≥ 80% of discovered `StaticAnchor`s within **0.5 m** of tape-measured ground truth.
2. Every logged furniture move flagged by `TopologyMonitor` within **4 h**; displacement within the **0.5 m** band.
3. **Zero** false `BaselineTopologyChange` events over 7 static days.
4. The moved object is **never** admitted to the `StaticAnchor` set.
5. With the feature off, the v1 fixed-AP + single-reflector map is present in the WorldGraph and person localisation is unchanged (T11 green).
### 5.4 Witness / Proof
Per ADR-028, add witness rows to `docs/WITNESS-LOG-028.md`:
| Row | Capability | Evidence |
|-----|-----------|----------|
| W-39 | Bistatic reflector multilateration round-trip (synthetic 4-link) | `cargo test rf_slam::tests::bistatic_round_trip` |
| W-40 | Topology-change detection (variance collapse + rank change) | `cargo test rf_slam::tests::topology_events` |
| W-41 | Validation gate blocks v2 without dataset; v1 map intact | `cargo test rf_slam::tests::validation_gate` |
`source-hashes.txt` gains `SHA-256(ruvsense/rf_slam.rs)`.
---
## 6. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-029 (RuvSense Multistatic) | **Consumes**: reflector geometry refines the multistatic attention-weighting prior. |
| ADR-030 (Persistent Field Model) | **Reuses**: `variance_explained`, Marcenko-Pastur `baseline_eigenvalue_count`, and `estimate_occupancy()` are the topology-change and occupancy-gate signals; RF SLAM is the geometric layer ADR-030 assumed existed. |
| ADR-042 (CHCI) | **Reuses + enables**: cross-link consistency gates furniture-movement; in return, discovered reflector positions become fixed scatterers in the CHCI forward model. |
| ADR-134 (CIR) | **Prerequisite**: `Cir.taps` excess-delay separation is the raw input to reflector discovery. |
| ADR-135 (Empty-Room Baseline) | **Reuses**: von Mises phase-concentration math for tap coherence; emits `BaselineTopologyChange` into ADR-135's existing recalibration trigger. |
| ADR-136 (Streaming Engine) | **Consumer**: reflector/anchor updates are a stage output frame. |
| ADR-138 (LinkGroup / ArrayCoordinator) | **Substrate**: discovery operates per LinkGroup so grouped links share a clock-quality tier and comparable delays. |
| ADR-139 (WorldGraph) | **Persistence**: `ObjectAnchorRecord` becomes `object_anchor` petgraph nodes via RVF — the single geometric source of truth. |
| ADR-142 (Evolution Tracker / Temporal VoxelMap) | **Downstream**: aggregates the `object_anchor` stream into the temporal room voxel map. |
---
## 7. References
### Production Code
- `v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs``Cir` struct (taps, `tap_spacing_sec`, `dominant_tap_idx`, `dominant_tap_ratio`, `active_tap_count`, `rms_delay_spread_s`); `Cir::dominant_distance_m()`. Excess-delay input to discovery.
- `v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs``FieldModel` (`variance_explained`, `baseline_eigenvalue_count`, `estimate_occupancy()`); `WelfordStats` reused for tap statistics.
- `v2/crates/wifi-densepose-mat/src/tracking/kalman.rs``KalmanState` 6-state constant-velocity filter, reused (retuned process noise) for per-reflector tracking.
- `v2/crates/wifi-densepose-mat/src/localization/triangulation.rs``Triangulator` / `TriangulationConfig` (person localisation against fixed anchors; v1 path).
- `v2/crates/wifi-densepose-ruvector/src/mat/triangulation.rs``solve_triangulation()` (Neumann-series TDoA least squares); generalised to accept reflector bistatic ranges.
- `v2/crates/wifi-densepose-geo/src/types.rs``GeoScene` / `GeoRegistration`; indoor `Anchor` extension point.
- `v2/crates/wifi-densepose-signal/src/ruvsense/rf_slam.rs`**NEW** module: `Reflector`, `ReflectorTracker`, `AnchorLearner`, `TopologyMonitor`, `FurnitureMovementEstimator`, `ObjectAnchorRecord`.
### External
- Welford, B.P. (1962). "Note on a Method for Calculating Corrected Sums of Squares and Products." *Technometrics*, 4(3). — Online statistics for per-reflector tap amplitude.
- Mardia, K.V. & Jupp, P.E. (2000). *Directional Statistics*. Wiley. — Circular variance `1 R̄` used for tap coherence gating.
- Foy, W.H. (1976). "Position-Location Solutions by Taylor-Series Estimation." *IEEE Trans. AES*. — Linearised range/TDoA least-squares solved here via the Neumann series.
- Marčenko, V.A. & Pastur, L.A. (1967). "Distribution of eigenvalues for some sets of random matrices." *Math. USSR-Sbornik*. — Significant-eigenvalue threshold used for the occupancy and covariance-rank gates (already in `field_model.rs`).
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `2d4f3dea5`, issue #847): `RfSlam` reflector discovery with Welford position stability and Wall/Furniture/Mobile classification; ships v1 fixed-map mode by default. 6 tests.
**Integration glue -- not yet on the live path:** live CIR-tap -> reflector-position inference behind the ADR-030 Marcenko-Pastur eigenvalue gate; writing discovered anchors into the WorldGraph as `ObjectAnchor` nodes; the multi-day validation dataset before v2 discovery is enabled.
**Trust contribution:** landmarks are *learned and verified stable* (walls/furniture) while transient reflectors are rejected, so localization rests on trustworthy anchors.
@@ -0,0 +1,491 @@
# ADR-144: UWB Range-Constraint Fusion with World-Graph Anchors
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-hardware` (new UWB driver/parser/auto-detect in `src/`); `wifi-densepose-signal` (`ruvsense/pose_tracker.rs` constraint-aware Kalman update); `wifi-densepose-mat` (`localization/fusion.rs` constraint integration) |
| **Relates to** | ADR-016 (RuVector Integration), ADR-018 (ESP32 Dev Implementation / binary wire format), ADR-024 (Contrastive CSI Embedding / AETHER), ADR-029 (RuvSense Multistatic), ADR-031 (RuView Sensing-First RF Mode), ADR-063 (mmWave Sensor Fusion), ADR-136 (RuView Rust Streaming Engine), ADR-138 (WiFi-7 MLO LinkGroup / ArrayCoordinator), ADR-139 (WorldGraph Environmental Digital Twin), ADR-141 (BFLD Privacy Control Plane), ADR-145 (Ablation Evaluation Harness) |
---
## 1. Context
### 1.1 The Gap
WiFi CSI sensing in this codebase produces *relative* perturbation fields, not *metric* position. The pose tracker estimates 3D keypoint coordinates from those fields, but the only thing anchoring those coordinates to real-world metres is the geometry assumed at calibration time. There is no independent metric ranging source to correct scale drift, resolve the front/back ambiguity inherent in a single multistatic array, or disambiguate two tracks that cross. UWB (ultra-wideband, IEEE 802.15.4z) two-way ranging gives exactly that: a direct, hardware-grounded distance measurement with ±10 cm accuracy that is *orthogonal* to the CSI evidence.
Searching the workspace confirms there is no UWB support anywhere:
- `grep -ri "uwb\|802.15.4z\|two_way_ranging\|RangeConstraint" v2/crates/` returns nothing in production code. The only `802.15.4` reference is the *timesync* epoch on the ESP32-C6 (`c6_timesync_get_epoch_us()`, ADR-110), which is a clock primitive, not a ranging primitive.
- `v2/crates/wifi-densepose-hardware/src/` contains parsers for ESP32 CSI (`esp32_parser.rs`, ADR-018 magic `0xC5110001`), sibling RuView packets (`RUVIEW_VITALS_MAGIC``RUVIEW_TEMPORAL_MAGIC`), a UDP aggregator (`aggregator/`), a `bridge.rs` (`CsiFrame → CsiData`), and the radio-ops mirror (`radio_ops.rs`). Every magic constant in `esp32_parser.rs` is a *CSI-family* packet. There is no range/anchor frame type and no anchor-bearing device abstraction.
- `v2/crates/wifi-densepose-signal/src/ruvsense/pose_tracker.rs` (the 17-keypoint Kalman tracker, ADR-029 §2.7) has a position-only measurement model: `KeypointState::update()` takes `&[f32; 3]` and `KeypointState::mahalanobis_distance()` gates a *Cartesian* measurement. There is **no mechanism to apply a range constraint** — a measurement of the form "the centroid is `r ± σ` metres from a fixed anchor" — which is a nonlinear (spherical) observation, not a Cartesian one. `PoseTrack` has no field for accumulated range residuals.
- `v2/crates/wifi-densepose-mat/src/localization/fusion.rs` has a `PositionFuser` with an `EstimateSource` enum (`RssiTriangulation`, `TimeOfArrival`, `AngleOfArrival`, `CsiFingerprint`, `DepthEstimation`, `Fused`) and `Triangulator` that consumes RSSI. There is **no `TimeOfArrival` producer**`EstimateSource::TimeOfArrival` is defined but nothing emits it, and `LocalizationService::simulate_rssi_measurements()` explicitly returns `vec![]` with a warning "No sensor hardware connected." The fusion machinery exists; the metric-ranging input does not.
The consequence is concrete. Three failure modes trace directly to the missing metric anchor:
- **Scale and front/back ambiguity in single-array sensing.** A monostatic or near-colinear multistatic CSI array cannot distinguish a person 2 m in front from a (geometrically mirrored) reflection 2 m behind without strong geometric diversity (ADR-029's `geometry.rs` Fisher-information bounds quantify exactly when this fails). A single UWB range to a known anchor collapses that ambiguity for the constrained dimension.
- **Track-crossing identity swaps.** When two `PoseTrack`s pass within the Mahalanobis gate of each other, assignment falls back to AETHER re-ID cosine similarity (`pose_tracker.rs` `embedding_weight = 0.4`). Re-ID alone is unreliable for similar body shapes. A UWB tag worn by one person (or a range that is consistent with only one of the two crossing tracks) breaks the tie deterministically.
- **No metric ground truth for the WorldGraph.** ADR-139's WorldGraph stores object anchors and person tracks as typed nodes; without a metric edge between them, anchor positions are never corrected and the digital twin slowly drifts from physical reality.
ADR-063 (mmWave Sensor Fusion, Accepted) already establishes the *pattern* for fusing an orthogonal ranging modality (60 GHz FMCW range/Doppler) with CSI, and `RUVIEW_FUSED_VITALS_MAGIC` (`0xC5110004`) is the on-wire fused packet. ADR-144 follows that established fusion pattern but for UWB metric range rather than mmWave radial velocity, and it routes the result through the WorldGraph (ADR-139) as a first-class graph edge rather than a flat fused packet.
### 1.2 What a "Range Constraint" Is Here
A UWB range constraint is a single scalar metric measurement plus its provenance:
- A measured line-of-sight distance `r` in metres between a fixed **anchor** of known position and a moving **tag/responder**, obtained by 802.15.4z single- or double-sided two-way ranging (SS/DS-TWR) or, where a synchronized anchor mesh exists, time-difference-of-arrival (TDoA).
- An uncertainty `σ_r` derived from the UWB module's reported first-path SNR / link quality. Clean LOS yields ~±10 cm; NLOS (through a wall) biases the range *long* and inflates `σ_r`.
- A timestamp in the same 802.15.4 epoch domain already used for multi-node CSI sync (ADR-110), so a range can be associated with the CSI frame closest in time.
What a range constraint is **not**: it is not a position. One range defines a *sphere* of possible tag positions centred on the anchor. Position emerges only when a range is *fused* with the CSI-derived track state (which already carries a 3D estimate and covariance). This is the core reason the fusion lives in `pose_tracker.rs`'s Kalman update rather than as a standalone trilateration solver: the CSI track *is* the prior, and the range *tightens* it.
### 1.3 Hardware Context
UWB is a separate radio from WiFi. Three deployment forms are evaluated (Decision §2.3); the working assumption is a **standalone ESP32-C6 + DW3000-class UWB transceiver bridge node** that speaks the existing ADR-018 UDP transport:
| Form factor | Radio | Role | Wire path | Cost |
|-------------|-------|------|-----------|------|
| Standalone UWB anchor (ESP32-C6 + Qorvo DW3000) | 802.15.4z UWB + 802.15.4 timesync | Fixed anchor, ranges to tags | New UDP magic frame over existing aggregator | ~$18 |
| Integrated radio (ESP32-C6 doing CSI *and* UWB on one node) | shared MCU | CSI sensing node that also ranges | Same node, interleaved magic | ~$15 (no extra node) |
| Bridge node (UWB-only MCU → serial → Pi 5) | DW3000 dev board | Anchor mesh, host does ranging math | `aggregator/` ingest | ~$25 |
All three converge on the **same host-side abstraction**: a stream of `UwbRangeFrame`s with `(anchor_id, tag_id, range_m, quality, epoch_us)`. The hardware abstraction layer (HAL) hides which form factor produced the frame, exactly as `esp32_parser.rs` hides whether CSI came from an S3 or a C6. The C6's existing `c6_timesync_get_epoch_us()` (±100 µs) is reused so UWB ranges and CSI frames share one clock.
### 1.4 Pipeline Position
```
UWB anchor/tag (802.15.4z TWR)
→ UwbFrameParser::parse() ← NEW (wifi-densepose-hardware, ADR-018-style magic)
→ RangeConstraint { anchor_id, range_m, σ, epoch_us, quality } ← NEW domain model
→ WorldGraph::upsert_range_edge() ← NEW edge (ADR-139), object_anchor → person_track
│ (association: which track does this range belong to?)
│ Mahalanobis-to-sphere gate + AETHER re-ID disambiguation
→ PoseTracker::apply_range_constraint() ← NEW (constraint-aware Kalman update)
CSI-only track state ─────────┐
├──→ LocalizationService (mat/fusion.rs)
│ EstimateSource::TimeOfArrival now PRODUCED
fused metric track (with constraint residual + confidence)
```
CSI flows down the existing pipeline unchanged. The UWB range enters as a *parallel* evidence stream, is associated to a track, and is applied as an extra Kalman update step *after* the normal CSI measurement update. If no range arrives in a given cycle, the tracker behaves exactly as today — UWB is strictly additive.
---
## 2. Decision
### 2.1 The `RangeConstraint` Domain Model
A `RangeConstraint` is the canonical, hardware-agnostic representation of one UWB range, defined in `wifi-densepose-hardware` (alongside `CsiFrame`) and re-exported for `signal` and `mat`. It carries enough provenance to satisfy the project rule that every semantic state traces to signal evidence + model version + calibration version + privacy decision.
```rust
use std::time::Duration;
/// Stable identifier for a fixed UWB anchor of known position.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AnchorId(pub u32);
/// Stable identifier for a mobile UWB tag / responder (may be a worn tag
/// or an unlabelled responder discovered during ranging).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TagId(pub u32);
/// Source of the metric range measurement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangeMethod {
/// Single-sided two-way ranging (one round trip; clock-offset sensitive).
SsTwr,
/// Double-sided two-way ranging (cancels clock offset; preferred).
DsTwr,
/// Time-difference-of-arrival against a synchronized anchor mesh.
Tdoa,
}
/// One UWB metric range measurement with full provenance.
///
/// Defines a *sphere* of possible tag positions of radius `measured_range_m`
/// centred on the anchor at `AnchorId`. Fused with a CSI track to produce a
/// metric position (see §2.5).
#[derive(Debug, Clone)]
pub struct RangeConstraint {
/// Fixed anchor this range was measured against.
pub anchor_id: AnchorId,
/// Tag/responder the range was measured to (if labelled).
pub tag_id: Option<TagId>,
/// Measured line-of-sight distance in metres.
pub measured_range_m: f32,
/// 1-sigma uncertainty in metres, derived from `signal_quality`.
pub uncertainty_m: f32,
/// 802.15.4 epoch microseconds (same domain as CSI timesync, ADR-110).
pub timestamp_us: u64,
/// First-path SNR / link-quality score in [0, 1]; 1 = clean LOS.
pub signal_quality: f32,
/// Ranging method used.
pub method: RangeMethod,
}
impl RangeConstraint {
/// True if quality is high enough to apply as a hard(er) constraint.
/// NLOS ranges (low quality) are applied with inflated `uncertainty_m`
/// rather than rejected outright.
pub fn is_los(&self, los_threshold: f32) -> bool {
self.signal_quality >= los_threshold
}
/// Effective measurement variance, NLOS-inflated.
pub fn variance(&self) -> f32 {
self.uncertainty_m * self.uncertainty_m
}
}
```
**Why `uncertainty_m` derives from `signal_quality` rather than being fixed:** UWB NLOS does not fail loudly — it biases the range *long* (the first detectable path went around an obstacle). Rejecting low-quality ranges discards information; inflating their variance lets the Kalman filter down-weight them gracefully, which is the same philosophy ADR-135 used for multimodal-phase subcarriers (down-weight, do not drop).
### 2.2 WorldGraph Anchor Construction (ADR-139 Integration)
ADR-139's WorldGraph is a typed petgraph whose nodes include `object_anchor` and `person_track`. A `RangeConstraint` becomes a **typed, weighted, timestamped edge** between an `object_anchor` node (the UWB anchor's fixed position) and a `person_track` node (a `PoseTrack`).
```rust
/// Edge payload stored on a WorldGraph object_anchor → person_track edge.
#[derive(Debug, Clone)]
pub struct RangeEdge {
pub anchor_id: AnchorId,
pub track_id: TrackId,
pub constraint: RangeConstraint,
/// Mahalanobis distance of this range to the track's predicted sphere
/// at association time (the association cost, see §2.4).
pub assoc_cost: f32,
/// Provenance triple required by the SSR rule (ADR-140):
pub signal_evidence_id: u64, // CSI frame seq that the track state came from
pub model_version: u32, // pose/embedding model version
pub anchor_survey_version: u32, // anchor-registration ("calibration") version
}
```
The anchor node carries its surveyed 3D position and an `anchor_survey_version` that plays the same role for UWB that `schema_version`/`captured_at` plays for the ADR-135 baseline: a change to anchor geometry invalidates downstream range fusions tagged with the old survey version. The WorldGraph gains:
```rust
impl WorldGraph {
/// Register or update a fixed anchor with a surveyed position.
/// Bumps `anchor_survey_version` and marks all RangeEdges from this
/// anchor stale.
pub fn register_anchor(&mut self, id: AnchorId, pos: [f32; 3]) -> u32;
/// Insert a range constraint as an object_anchor → person_track edge.
/// Returns Err if `anchor_id` is not registered.
pub fn upsert_range_edge(&mut self, edge: RangeEdge) -> Result<(), WorldGraphError>;
/// All current range edges incident to a track (for the Kalman update).
pub fn range_edges_for(&self, track: TrackId) -> Vec<&RangeEdge>;
}
```
Anchor positions are surveyed once and stored on the graph; this is the *anchor-registration policy* decision (§2.7). The WorldGraph is the single source of truth for anchor geometry so that `pose_tracker.rs` and `mat/fusion.rs` never disagree about where an anchor is.
### 2.3 UWB Hardware Abstraction Layer (ADR-018 Wire-Format Pattern)
A new module set in `wifi-densepose-hardware/src/` mirrors the `esp32_parser.rs` design: a magic-tagged binary frame over the existing UDP aggregator, a pure-bytes parser that never fabricates data, and an auto-detect that demultiplexes by magic.
```rust
/// UWB range frame magic (ADR-144), next in the 0xC511xxxx family after
/// RUVIEW_TEMPORAL_MAGIC (0xC5110007). Demultiplexed alongside CSI frames.
pub const UWB_RANGE_MAGIC: u32 = 0xC5110008;
/// ADR-018-style binary layout (little-endian):
/// 0 4 Magic 0xC5110008
/// 4 4 anchor_id (u32)
/// 8 4 tag_id (u32; 0 = unlabelled responder → None)
/// 12 4 range_mm (u32; millimetres, converted to f32 metres)
/// 14 ... (see exact offsets in parser doc)
/// .. 2 uncertainty_mm (u16)
/// .. 1 method (0=SS-TWR,1=DS-TWR,2=TDoA)
/// .. 1 signal_quality (u8, 0..=255 → [0,1])
/// .. 8 epoch_us (u64, 802.15.4 timesync domain)
pub struct UwbFrameParser;
impl UwbFrameParser {
/// Parse one UWB range frame from raw UDP bytes.
/// Either parses real bytes or returns a specific `ParseError`
/// (NEVER fabricates a range — matches the no-mock guarantee).
pub fn parse(buf: &[u8]) -> Result<(RangeConstraint, usize), ParseError>;
/// Returns true if `buf` begins with `UWB_RANGE_MAGIC`.
pub fn is_uwb_frame(buf: &[u8]) -> bool;
}
```
**Form-factor decision (§1.3 candidates):** adopt the **standalone ESP32-C6 + DW3000 anchor** as the reference build, but the HAL admits all three because the parser only sees bytes. Rationale: (a) it reuses the C6's `c6_timesync_get_epoch_us()` so UWB ranges land in the *same clock* as CSI frames with no new timesync work; (b) it reuses the ADR-018 UDP aggregator, so no new transport, no new firmware OTA channel, no new port; (c) integrating UWB onto an existing CSI node (form 2) is a strict superset — the same parser handles its frames. The aggregator's existing demultiplex loop gains one arm: `if UwbFrameParser::is_uwb_frame(buf) { … } else if Esp32CsiParser` (the same `else if` ladder already used for the seven `RUVIEW_*_MAGIC` sibling packets).
**Interface boundary:** `wifi-densepose-hardware` owns parsing and the `RangeConstraint`/`AnchorId`/`TagId` types. It has **no dependency** on `signal` or `mat` — the dependency arrows point the other way, consistent with the crate publishing order (`hardware` has no internal deps; `signal` depends on `core`; `mat` depends on `signal`).
### 2.4 Constraint-to-Track Association (AETHER Re-ID Disambiguation)
A range from an unlabelled responder (`tag_id = None`) must be assigned to one of the live `PoseTrack`s before it can be applied. Labelled tags (`tag_id = Some(_)`) that have been bound to a track skip association. For unlabelled ranges, association uses a gated cost that mirrors the existing `pose_tracker.rs` assignment cost (`position_weight * maha + embedding_weight * embed_cost`) but with the *spherical* residual:
For each candidate track `T` with predicted centroid `c_T` and anchor at `a`:
```
sphere_residual(T) = | ‖c_T a‖ measured_range_m | (metres off the sphere)
maha_sphere(T) = sphere_residual(T) / sqrt(var_radial(T) + constraint.variance())
assoc_cost(T) = range_pos_weight * maha_sphere(T)
+ range_reid_weight * reid_ambiguity(T)
```
where `var_radial(T)` is the track's positional variance projected onto the anchor→centroid line (computed from the existing `KeypointState::covariance` diagonal), and `reid_ambiguity(T)` is invoked **only when two or more tracks are within the spherical Mahalanobis gate** — i.e. equidistant-from-anchor crossing tracks. In that case the range is associated to the track whose AETHER embedding best matches the tag's last-known embedding (for labelled tags) or whose recent CSI-only association confidence is highest (for unlabelled). This reuses `cosine_similarity()` and the 128-dim embedding already on `PoseTrack`.
```rust
/// Result of associating one RangeConstraint to the live track set.
pub enum RangeAssociation {
/// Uniquely associated (single track inside the gate).
Assigned { track: TrackId, cost: f32 },
/// Multiple tracks inside the gate; resolved by AETHER re-ID.
AmbiguousResolved { track: TrackId, runner_up: TrackId, margin: f32 },
/// No track inside the spherical Mahalanobis gate — range buffered,
/// not applied (may seed a new track if persistent).
Unassigned,
}
```
`Unassigned` ranges are not discarded — a persistent unassigned range that is geometrically consistent over several cycles is evidence of a person the CSI array has not yet detected (e.g. behind a piece of furniture), and is surfaced to the WorldGraph as a low-confidence latent track candidate. This is the UWB analogue of ADR-135 logging drift rather than silently dropping it.
### 2.5 Constraint-Aware Kalman Update (`pose_tracker.rs`)
The current `KeypointState::update()` is a *linear* Cartesian update (`H = [I3 | 0]`). A range is a *nonlinear* spherical observation `h(x) = ‖x a‖`. We apply it as an **Extended Kalman (EKF) measurement update on the track centroid**, then distribute the centroid correction back to the keypoints proportionally — rather than rebuilding the whole tracker as a factor graph.
**Algorithm decision: EKF spherical update with Mahalanobis gating and quality-weighted noise** (chosen over factor-graph batch optimization and over pure Mahalanobis gate-and-penalty; see §3 Alternatives). The centroid `c` already exists (`PoseTrack::centroid()`). For an anchor at `a`:
```
h(c) = ‖c a‖ (predicted range)
H = (c a)ᵀ / ‖c a‖ (1×3 Jacobian, unit LOS vector)
y = measured_range_m h(c) (scalar innovation)
S = H P_c Hᵀ + R where R = constraint.variance() (NLOS-inflated)
K = P_c Hᵀ S⁻¹ (3×1 gain)
c' = c + K y
P_c' = (I K H) P_c
```
`P_c` is the 3×3 centroid covariance assembled from the per-keypoint covariance diagonals. After the centroid is corrected by `K y`, the same translational delta `(c' c)` is added to every keypoint position and the radial variance reduction is applied to each keypoint's covariance, so the skeleton moves rigidly toward the constraint sphere without distorting its shape. This composes cleanly with the existing CSI update: CSI runs first (full skeleton update), then the range update nudges the whole skeleton onto the sphere.
The constraint update is **gated**: if `|y| / sqrt(S) > range_gate` (default 3.0, matching the existing chi-squared 3-sigma philosophy of `mahalanobis_gate = 9.0`), the range is rejected for this cycle and recorded as a residual outlier rather than applied — preventing a wild NLOS range from teleporting a track.
New state on `PoseTrack` (extending the struct, never replacing existing fields):
```rust
/// Range-constraint history appended to PoseTrack (bounded ring buffer).
#[derive(Debug, Clone, Default)]
pub struct ConstraintTrackState {
/// Recent constraints applied to this track (bounded; e.g. last 32).
pub buffer: VecDeque<RangeConstraint>,
/// Last applied scalar range residual (metres, signed).
pub last_constraint_residual: f32,
/// Gate status of the most recent constraint.
pub constraint_gate_status: ConstraintGateStatus,
/// Distinct anchors that have contributed a range to this track.
pub fused_range_sources: Vec<AnchorId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConstraintGateStatus {
#[default]
/// No range applied this cycle.
None,
/// Range passed the gate and was fused.
Accepted,
/// Range exceeded the gate; recorded as outlier, not applied.
RejectedOutlier,
/// Range applied with NLOS-inflated variance (low quality).
AcceptedNlos,
}
```
```rust
impl PoseTracker {
/// Apply one associated range constraint to a track via the spherical
/// EKF update above. Updates ConstraintTrackState. No-op (returns
/// RejectedOutlier) if the gate is exceeded.
pub fn apply_range_constraint(
&mut self,
track: TrackId,
anchor_pos: [f32; 3],
constraint: &RangeConstraint,
range_gate: f32,
) -> Result<ConstraintGateStatus, PoseTrackerError>;
}
```
`TrackerConfig` gains `range_gate: f32` (default 3.0), `range_pos_weight: f32` (default 0.7), `range_reid_weight: f32` (default 0.3), and `los_threshold: f32` (default 0.6). Defaults are off-path-safe: with no range frames, none of this code executes and the tracker is byte-for-byte its current behaviour.
### 2.6 `mat/fusion.rs` Integration
`EstimateSource::TimeOfArrival` (already defined, currently unproduced) becomes the producer slot for UWB-derived metric position. `LocalizationService` gains an optional UWB path so the MAT survivor-localization use case (rubble ranging) and the ambient-sensing use case share one fusion implementation:
```rust
impl LocalizationService {
/// Produce a TimeOfArrival PositionEstimate from a set of range
/// constraints to surveyed anchors (≥3 for full 3D, fewer constrains a
/// subspace). Replaces the empty simulate_rssi_measurements() path when
/// real UWB anchors are present.
pub fn estimate_from_ranges(
&self,
ranges: &[(Coordinates3D /*anchor*/, RangeConstraint)],
) -> Option<PositionEstimate>;
}
```
The resulting `PositionEstimate { source: EstimateSource::TimeOfArrival, weight: f(signal_quality), .. }` flows into the existing `PositionFuser::fuse()`, whose `calculate_weight()` already ranks `TimeOfArrival` highest (`1.0`). UWB thus slots into a fusion ranking the codebase already encodes — no new fuser, only a new producer. This keeps the MAT crate's domain model intact.
### 2.7 Anchor-Registration Policy
**Decision: manual survey as the authoritative source, with optional auto-learn from track geometry as a *proposal* the operator confirms.**
- **Manual survey (default, authoritative).** The operator measures each anchor's 3D position once and calls `WorldGraph::register_anchor()`. This sets `anchor_survey_version`. This is the UWB analogue of ADR-135's operator-initiated calibration: there is no way to know an anchor's true position from the data alone with the accuracy fusion needs, so the system does not guess by default.
- **Auto-learn (opt-in, proposal only).** When ≥3 anchors range the *same* moving tag over a trajectory with sufficient geometric diversity (the Fisher-information criterion from ADR-029 `geometry.rs`), the anchor positions become observable up to a rigid transform. An offline solver can *propose* refined anchor positions, but they are applied only after the operator accepts — never silently — for the same reason ADR-135 refuses automatic recalibration: a self-modified anchor that is wrong corrupts every downstream fusion invisibly.
Either path bumps `anchor_survey_version`, which invalidates `RangeEdge`s tagged with the old version, mirroring ADR-135's stale-baseline invalidation.
### 2.8 Provenance and the SSR Rule
Every fused metric position is a semantic state and therefore carries the full provenance triple (ADR-140 SSR / ADR-141 privacy):
- **Signal evidence**`RangeEdge.signal_evidence_id` (CSI frame sequence the track prior came from) + the `RangeConstraint.timestamp_us` of the UWB range.
- **Model version**`RangeEdge.model_version` (pose + AETHER embedding model).
- **Calibration version**`RangeEdge.anchor_survey_version` (anchor geometry survey).
- **Privacy decision** — UWB ranging reveals the *presence and distance of a tag-bearing person*, which is identity-adjacent. A range fusion is gated by the active BFLD privacy mode (ADR-141): in privacy modes that forbid identity binding, labelled `tag_id` association is suppressed and ranges are applied only as anonymous spherical constraints (no re-ID disambiguation, no tag→track binding stored).
### 2.9 Test Plan and Acceptance Criteria
**Tier 1 — Parser round-trip (unit test).** Encode a `RangeConstraint` to the §2.3 binary layout, parse with `UwbFrameParser::parse()`, assert field equality. Assert `is_uwb_frame()` returns `true` for `UWB_RANGE_MAGIC` and `false` for `ESP32_CSI_MAGIC` and all seven `RUVIEW_*_MAGIC`. Assert a truncated buffer yields `ParseError::InsufficientData` (no fabricated range).
**Tier 2 — Spherical EKF correctness (unit test).** Place a track centroid at `(2,0,0)` with a known `P_c`; supply a range of `1.8 m` to an anchor at the origin (true distance 2.0). Assert the corrected centroid moves *along the LOS toward the sphere* by approximately `K·y`, that `P_c` shrinks in the radial direction, and that the skeleton shape (inter-keypoint distances) is unchanged to f32 precision (rigid translation).
**Tier 3 — Gate rejection (unit test).** Same track; supply a range of `8.0 m` (4 m innovation, far beyond gate). Assert `apply_range_constraint()` returns `ConstraintGateStatus::RejectedOutlier`, the centroid is **unchanged**, and `last_constraint_residual` records the outlier.
**Tier 4 — Crossing disambiguation (unit test).** Two tracks at `(2,0,0)` and `(0,2,0)`, both ~2 m from an anchor at the origin (equidistant → both inside the spherical gate). Track A's embedding matches the tag's last embedding (cosine ≈ 0.95), Track B's does not (≈ 0.1). Assert association returns `AmbiguousResolved { track: A, .. }` with positive `margin`.
**Tier 5 — NLOS inflation (unit test).** A range with `signal_quality = 0.2` (NLOS). Assert `RangeConstraint::is_los(0.6) == false`, that `variance()` is inflated, and that the EKF gain `K` is correspondingly smaller than for a clean LOS range of the same innovation → status `AcceptedNlos`.
**Tier 6 — WorldGraph edge lifecycle (unit test).** Register an anchor → `upsert_range_edge()``range_edges_for(track)` returns it. Call `register_anchor()` again (re-survey) → assert `anchor_survey_version` bumps and stale edges are flagged.
**Tier 7 — `mat/fusion.rs` producer (unit test).** Feed three anchor+range pairs to `estimate_from_ranges()`; assert it yields a `PositionEstimate` with `source == EstimateSource::TimeOfArrival` and that `PositionFuser::fuse()` weights it at least as high as a co-located `RssiTriangulation` estimate.
**Tier 8 — Off-path no-op (regression test).** Run the existing `pose_tracker` test suite with `range_*` config at defaults and **zero** range frames; assert every existing assertion passes unchanged (UWB is strictly additive).
**Tier 9 — Determinism proof (CI-compatible, extends ADR-028).** A fixed synthetic trajectory + fixed range sequence (seeded) is fused; the SHA-256 of the resulting fused track positions is recorded in `archive/v1/data/proof/expected_features.sha256` under `uwb_range_fusion_v1`, and `verify.py` regenerates and asserts it. Adds witness rows to `docs/WITNESS-LOG-028.md` for parser round-trip, spherical EKF, and crossing disambiguation; `source-hashes.txt` gains the new parser and the `pose_tracker.rs` constraint additions.
**Tier 10 — Real hardware (integration, gated `#[cfg(feature = "hardware-test")]`).** With one DW3000 anchor and a tag walked along a measured 4 m path, assert fused track range tracks the tape-measured ground truth to < 15 cm RMS in LOS and that NLOS segments (tag behind a wall) inflate uncertainty rather than producing > 30 cm errors. Not run in CI.
---
## 3. Consequences
### 3.1 Positive
- **Metric grounding.** CSI tracks gain absolute scale and front/back disambiguation from an orthogonal modality. A single range collapses the ambiguity that ADR-029's geometry bounds show a near-colinear array cannot resolve from CSI alone.
- **Deterministic crossing resolution.** Track-swap identity errors at crossings are broken by range + AETHER re-ID, where re-ID alone was unreliable for similar body shapes.
- **Reuses, does not rebuild.** The HAL reuses the ADR-018 UDP transport, the `0xC511xxxx` magic family, the C6 802.15.4 timesync clock, the `pose_tracker.rs` cost-blend pattern, and the `mat/fusion.rs` `PositionFuser` ranking. The only genuinely new math is one EKF measurement update.
- **Activates dead code.** `EstimateSource::TimeOfArrival` finally has a producer; `simulate_rssi_measurements()`'s empty-handed path gains a real metric alternative for the MAT use case.
- **WorldGraph becomes metric.** ADR-139's anchor and track nodes get a real, version-tracked metric edge, so the digital twin can be corrected against physical ground truth rather than drifting.
### 3.2 Negative
- **New radio and hardware cost.** UWB is a second radio; even the cheapest form factor adds ~$1525 per anchor and an anchor survey step. Sensing works without it (UWB is additive), but the metric benefit requires the hardware.
- **Anchor survey ceremony.** Like the ADR-135 baseline, anchors must be measured and registered before fusion is meaningful; a mis-surveyed anchor biases every range fused against it.
- **EKF linearization error.** The spherical update linearizes `h(x) = ‖xa‖`; for a track very close to an anchor (small `‖ca‖`), the Jacobian is ill-conditioned. Mitigated by a minimum-range guard and gating, but it is a real limit not present in the linear CSI update.
- **New struct surface.** `PoseTrack` grows a `ConstraintTrackState`, `TrackerConfig` grows four fields, and `WorldGraph` grows anchor/edge methods. All are additive and default-inert, but they widen the public API.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| NLOS range biases a track long without being flagged | Medium (through-wall ranging) | Track pulled away from truth | Quality-derived `uncertainty_m` inflation + 3-sigma gate; persistent outliers logged, not applied |
| Wrong-track association at a crossing | LowMedium | Identity swap with high confidence | Spherical Mahalanobis gate + AETHER re-ID; `AmbiguousResolved.margin` surfaced; privacy modes that forbid identity binding fall back to anonymous spherical constraint only |
| Mis-surveyed anchor | Medium (manual measurement) | Systematic bias on every fused range from that anchor | `anchor_survey_version` invalidation; optional auto-learn *proposal* for operator confirmation; never silent self-update |
| EKF divergence for a track adjacent to an anchor | Low | Gain blow-up, track teleport | Minimum-range guard on `‖ca‖`; gate rejects the resulting large innovation |
| UWB frames starve the aggregator demux of CSI | Low | Dropped CSI frames | UWB ranges are ~10 Hz per tag vs CSI 20 Hz; demux is a cheap magic-match `else if` arm, same as the seven existing `RUVIEW_*` arms |
---
## 4. Alternatives Considered
### 4.1 Why Not a Full Factor Graph (GTSAM-style)
A factor graph would jointly optimize all keypoints, all ranges, and all anchors in one nonlinear least-squares batch — theoretically optimal. Rejected for this codebase because: (a) it would *replace* the existing real-time `pose_tracker.rs` EKF rather than extend it, discarding a tested, shipping tracker; (b) batch optimization is not naturally online and would complicate the 20 Hz real-time loop; (c) it pulls in a heavy nonlinear-solver dependency where the existing tracker uses only hand-rolled diagonal Kalman math. The incremental EKF range update captures ~all the benefit (range tightens the prior) at a fraction of the integration cost, and the *auto-learn anchor* path in §2.7 can use an offline batch solver where the batch formulation genuinely helps.
### 4.2 Why Not Pure Mahalanobis Gate-and-Penalty (No State Update)
The simplest option: use the range only to *score* association (penalize tracks inconsistent with the range) but never let it move the state. Rejected because it throws away the metric correction — the whole point. A range that says "this person is 1.8 m from the anchor" should *move* a CSI estimate that says 2.3 m, not merely down-rank an assignment. We keep the gating (it is good for outlier rejection) but pair it with the EKF state update.
### 4.3 Why Not Treat UWB as Just Another `PositionEstimate` Source in `mat/fusion.rs`
We could skip `pose_tracker.rs` entirely and only fuse UWB at the MAT `PositionFuser` level (where `TimeOfArrival` already exists). Rejected as the *sole* path because the `PositionFuser` does a weighted-average of *independent* position estimates; deriving a position from a single range first requires a prior, and the best available prior is the CSI track state inside the tracker. Fusing at the tracker (§2.5) uses that prior correctly; fusing only at `mat` would need ≥3 simultaneous ranges to trilaterate a standalone position, which is a much stronger hardware requirement. We do **both**: tracker-level for single-range tightening, MAT-level for the multi-anchor trilateration use case.
### 4.4 Why a New `0xC511xxxx` Magic Rather Than a New Transport
UWB could ride its own port/protocol. Rejected to avoid a second aggregator, a second timesync, and a second firmware OTA channel. Extending the ADR-018 magic family (next id `0xC5110008`) means the existing `aggregator/` demux, the C6 802.15.4 clock, and the existing provisioning path all apply unchanged — the same reasoning that made the seven `RUVIEW_*_MAGIC` sibling packets share one port.
---
## 5. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-018 (ESP32 Dev Implementation) | **Pattern reused**: `UWB_RANGE_MAGIC = 0xC5110008` extends the `0xC511xxxx` binary frame family; `UwbFrameParser` follows the `esp32_parser.rs` no-mock, pure-bytes contract and rides the same UDP aggregator |
| ADR-016 (RuVector Integration) | **Reused**: AETHER embedding cosine similarity for crossing disambiguation runs through the same `ruvector-mincut::DynamicPersonMatcher` path the tracker already uses |
| ADR-024 (Contrastive CSI Embedding / AETHER) | **Disambiguator**: 128-dim AETHER embeddings on `PoseTrack` resolve constraint-to-track association when tracks are equidistant from an anchor (§2.4) |
| ADR-029 (RuvSense Multistatic) | **Extended**: range constraints supply the front/back and scale information the geometric-diversity (Fisher-information) bounds show a near-colinear array cannot recover from CSI alone |
| ADR-031 (RuView Sensing-First RF Mode) | **Consumer**: fused metric tracks and constraint residuals are sensing-mode outputs surfaced to the RuView stream |
| ADR-063 (mmWave Sensor Fusion) | **Pattern parallel**: establishes the orthogonal-ranging-modality fusion pattern (`RUVIEW_FUSED_VITALS_MAGIC`); ADR-144 applies the same fusion philosophy to UWB metric range instead of 60 GHz radial velocity |
| ADR-136 (RuView Rust Streaming Engine) | **Stage**: the UWB parse → associate → fuse path is a stream stage producing constraint-augmented track frames under the ADR-136 frame contract |
| ADR-138 (LinkGroup / ArrayCoordinator) | **Clock**: shares the 802.15.4 timesync epoch the ArrayCoordinator uses for clock-quality gating, so UWB ranges and CSI frames associate by time |
| ADR-139 (WorldGraph Environmental Digital Twin) | **Substrate**: `RangeConstraint` becomes an `object_anchor → person_track` `RangeEdge`; the WorldGraph is the single source of truth for anchor geometry and `anchor_survey_version` |
| ADR-135 (Empty-Room Baseline Calibration) | **Analogue**: anchor survey/`anchor_survey_version` mirrors the baseline calibration/staleness-invalidation model; both refuse silent automatic self-update |
| ADR-140 / ADR-141 (SSR Schema / BFLD Privacy) | **Governed**: every fused range carries the signal-evidence + model-version + survey-version + privacy-decision provenance triple; identity-binding is gated by the active privacy mode |
---
## 6. References
### Production Code (verified to exist)
- `v2/crates/wifi-densepose-hardware/src/esp32_parser.rs` — ADR-018 binary frame parser; `ESP32_CSI_MAGIC = 0xC5110001` and the `RUVIEW_*_MAGIC` family (`0xC5110002``0xC5110007`) that the new `UWB_RANGE_MAGIC = 0xC5110008` extends
- `v2/crates/wifi-densepose-hardware/src/lib.rs` — crate root; no-mock guarantee; re-exports `CsiFrame`, `CsiMetadata`, `Esp32CsiParser`, the magic constants
- `v2/crates/wifi-densepose-hardware/src/aggregator/` — UDP multi-node ingest; gains one `is_uwb_frame()` demux arm
- `v2/crates/wifi-densepose-hardware/src/csi_frame.rs``CsiFrame`, `CsiMetadata`, `PpduType`; new `RangeConstraint`/`AnchorId`/`TagId` types live alongside these
- `v2/crates/wifi-densepose-signal/src/ruvsense/pose_tracker.rs``KeypointState::update()` / `mahalanobis_distance()`, `PoseTrack`, `PoseTracker`, `TrackerConfig`, `cosine_similarity`; gains `apply_range_constraint()` and `ConstraintTrackState`
- `v2/crates/wifi-densepose-mat/src/localization/fusion.rs``PositionFuser`, `EstimateSource::TimeOfArrival` (defined, currently unproduced), `LocalizationService::simulate_rssi_measurements()` (returns empty); gains `estimate_from_ranges()`
- `v2/crates/wifi-densepose-mat/src/localization/triangulation.rs``Triangulator` for the multi-anchor trilateration use case
- `archive/v1/data/proof/verify.py` + `expected_features.sha256` — deterministic proof chain; `uwb_range_fusion_v1` hash to be added
- `docs/WITNESS-LOG-028.md` — witness rows for parser round-trip, spherical EKF, crossing disambiguation
### Related ADRs (verified to exist as files)
- `docs/adr/ADR-018-esp32-dev-implementation.md`
- `docs/adr/ADR-016-ruvector-integration.md`
- `docs/adr/ADR-024-contrastive-csi-embedding-model.md`
- `docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md`
- `docs/adr/ADR-063-mmwave-sensor-fusion.md`
- `docs/adr/ADR-135-empty-room-baseline-calibration.md`
### External
- Qorvo DW3000 / DWM3000 802.15.4z UWB transceiver datasheet — SS/DS-TWR primitives and first-path-SNR link-quality reporting that backs `signal_quality``uncertainty_m`.
- IEEE 802.15.4z-2020 — Enhanced Ultra-Wideband PHY; defines the TWR/TDoA ranging schemes referenced in `RangeMethod`.
- Welford, B.P. (1962). *Technometrics* 4(3) — referenced for consistency with ADR-135's online statistics; the spherical EKF here uses the same diagonal-covariance conventions as the existing `KeypointState` Kalman math.
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `b10bc2e9a`, issue #848): the `RangeConstraint` domain model and `RangeConstraintFusion::refine()` -- a Newton-normalized weighted least-squares that constrains a CSI/CIR prior, with Mahalanobis outlier gating. 4 tests.
**Integration glue -- not yet on the live path:** the UWB UART driver/parser in `wifi-densepose-hardware` (no UWB module in the device table yet); wiring `refine()` into `pose_tracker`'s Kalman update; anchors as WorldGraph `UwbBeacon` nodes.
**Trust contribution:** physical-distance anchoring that *rejects* bogus multipath/NLOS ranges before they corrupt the estimate.
@@ -0,0 +1,481 @@
# ADR-145: Ablation Evaluation Harness with Privacy-Leakage and Latency Metrics
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-train` (`src/eval.rs`, `src/metrics.rs`, `src/ruview_metrics.rs`, `src/proof.rs`); `wifi-densepose-signal` (`src/bin/*_proof_runner.rs`); `wifi-densepose-cli` |
| **Relates to** | ADR-011 (Deterministic Proof Harness), ADR-014 (SOTA Signal Processing), ADR-027 (Cross-Environment Domain Generalization / MERIDIAN), ADR-031 (RuView Sensing-First RF Mode), ADR-120 (BFLD Privacy Class & Hash Rotation), ADR-136 (RuView Rust Streaming Engine), ADR-141 (BFLD Privacy Control Plane), ADR-144 (UWB Range-Constraint Fusion) |
---
## 1. Context
### 1.1 The Gap
The repository has two independent, well-formed evaluation surfaces that have never been wired together into a single ablation matrix:
1. **`wifi-densepose-train/src/ruview_metrics.rs`** implements the ADR-031 three-metric acceptance test — `evaluate_joint_error()` (PCK@0.2 / OKS / torso jitter / p95 error), `evaluate_tracking()` (MOTA / ID-switches / fragmentation), `evaluate_vital_signs()` (breathing/heartbeat BPM error and SNR) — and rolls them into `RuViewAcceptanceResult` with a `RuViewTier` (`Fail` / `Bronze` / `Silver` / `Gold`) via `determine_tier()`. The threshold structs (`JointErrorThresholds`, `TrackingThresholds`, `VitalSignThresholds`) carry the `Default` impls that encode the deployment gates.
2. **`wifi-densepose-train/src/eval.rs`** implements the ADR-027 MERIDIAN cross-environment evaluator — `CrossDomainEvaluator::evaluate()` returns `CrossDomainMetrics { in_domain_mpjpe, cross_domain_mpjpe, few_shot_mpjpe, cross_hardware_mpjpe, domain_gap_ratio, adaptation_speedup }`. Domain `0` is in-domain; non-zero domain IDs are cross-domain. It reports a single scalar `domain_gap_ratio = cross / in_domain`.
These two surfaces share **no common driver**. There is:
- **No feature-ablation concept anywhere.** A workspace-wide search for `ablation` / `Ablation` across `v2/crates` returns zero matches. There is no struct that says "run the acceptance test with CIR disabled" or "with Doppler enabled," and no way to attribute a tier change to a specific feature branch (CSI-only vs CSI+CIR vs +Doppler).
- **No privacy-leakage metric in the eval path.** Privacy is enforced *structurally* in `wifi-densepose-bfld``signature_hasher.rs` implements the ADR-120 BLAKE3-keyed per-site, daily-rotated `rf_signature_hash` (invariant I3), and `embedding.rs` keeps `IdentityEmbedding` in-RAM-only (invariant I1/I2). But there is no *measured* leakage scalar: nothing runs a membership-inference attack against the hash-rotation pipeline and reports a number in `[0, 1]`. The acceptance test cannot fail a model for leaking identity.
- **No latency profile in the acceptance result.** `RuViewAcceptanceResult` reports accuracy and tracking but carries no `p50`/`p95`/`p99` inference-latency fields. The ADR-031 mode says nothing about timing budgets (a grep of `ADR-031` for `latency`/`p95` returns nothing), so a model that passes Gold at 800 ms/frame is indistinguishable from one at 40 ms/frame.
- **No per-variant determinism binding.** The proof harness exists and is mature: `wifi-densepose-train/src/proof.rs` runs `N_PROOF_STEPS = 50` under `PROOF_SEED = 42` / `MODEL_SEED = 0` and SHA-256-hashes the model weights (`hash_model_weights()`), comparing against `expected_proof.sha256`. The signal side mirrors this — `src/bin/calibration_proof_runner.rs` (ADR-135) and `src/bin/cir_proof_runner.rs` (ADR-134) hash deterministic synthetic outputs against `archive/v1/data/proof/expected_calibration_features.sha256` and `expected_cir_features.sha256`. But **no proof artifact pins an ablation report**: there is no `expected_ablation_*.sha256`, so re-running the matrix on a fixed seed could silently produce a different tier and CI would not notice.
The cost of the gap is concrete. When ADR-134 (CIR) and ADR-135 (calibration) landed, the only way to know whether CIR *helped* presence/localization was to read the commit message — there was no harness that ran the acceptance test with and without CIR and emitted a side-by-side delta. As ADR-144 (UWB fusion) and the BFLD privacy modes (ADR-141) come online, the number of feature combinations grows combinatorially, and "does turning on feature X regress tier or leak identity?" becomes unanswerable without a deterministic ablation matrix.
### 1.2 What "Ablation" Means Here
An **ablation** is one acceptance-test run over a fixed evaluation set with a named subset of signal features enabled. The matrix is the set of those runs plus the pairwise deltas between them. Each ablation produces:
- A `RuViewAcceptanceResult` (the existing struct, unchanged) → tier, PCK, OKS, MOTA, breathing error.
- New scalar metrics this ADR adds: presence accuracy, localization error, activity accuracy, FP/FN rates, latency p50/p95/p99, **privacy-leakage score**`[0, 1]`, and cross-room degradation.
- A determinism record: the SHA-256 of the variant's witness-replay output, which must match the per-variant expected hash or CI fails.
An ablation is **not** a hyperparameter sweep or a training run. It evaluates a *fixed, already-trained* `model.bin` snapshot under different *inference-time feature gates*. Training is out of scope — this ADR consumes the model the way `proof.rs` consumes a fixed-seed model.
### 1.3 Hardware Constraints on the Feature Set
The ablation feature combinations are bounded by what RuView hardware can actually produce, per the project hardware table and ADR-136's streaming engine:
| Tier | Feature | Source | Available today? |
|------|---------|--------|------------------|
| F0 | CSI amplitude/phase | ESP32-S3 (20 MHz, 52 active subcarriers, HT20) | Yes (COM9) |
| F1 | CIR (delay taps) | ADR-134 `CirEstimator` over the same CSI | Yes |
| F2 | Doppler / micro-motion | ADR-014 spectrogram over a frame window | Yes |
| F3 | BFLD beamforming-feedback features | ADR-118/120 `wifi-densepose-bfld` (802.11ac/ax BFI) | Yes (gated) |
| F4 | UWB range constraint | ADR-144 fusion with WorldGraph anchors | **No — hardware not landed** |
The 6-node TDM mesh and the 20 MHz ESP32-S3 bandwidth cap the realistic combinations. UWB (F4) is **deferred**: ADR-144 specifies the fusion contract but the ranging hardware is not in the fleet, so the `+UWB` ablation is a *defined-but-skipped* variant (it appears in the matrix as `Skipped { reason }`, not silently absent — same pattern as the unprovisioned seeds in ADR-135 §2.8).
### 1.4 Pipeline Position
```
model.bin snapshot (fixed)
+ witness-bundle CSI replay (PROOF_SEED=42, fixed salt)
AblationHarness::run_matrix() ← NEW (wifi-densepose-train)
│ for each AblationVariant (F-mask):
│ feature-gate the signal stages (ADR-136 streaming engine)
│ → eval.rs CrossDomainEvaluator (cross-room degradation)
│ → ruview_metrics.rs acceptance (tier, PCK/OKS/MOTA/vitals)
│ → SpecMetrics (presence/loc/activity/FP-FN)
│ → LatencyProfile (criterion p50/p95/p99)
│ → PrivacyLeakage (MIA on ADR-120 hash-rotation pipeline)
│ → SHA-256(variant canonical bytes) vs expected_ablation_*.sha256
AblationReport → markdown auto-report + summary.json
```
The harness sits *above* the streaming engine: it does not re-derive features, it toggles which ADR-136 stages are active and re-reads the existing `eval.rs` / `ruview_metrics.rs` outputs. Determinism is inherited from the proof harness substrate (ADR-011).
---
## 2. Decision
### 2.1 The Six Ablation Variants
We define exactly six feature combinations, of which five run today and one is deferred:
```rust
// New module: wifi-densepose-train/src/ablation.rs
/// One feature combination to evaluate. Bitflags over the signal stages
/// that ADR-136's streaming engine can gate on or off.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AblationVariant {
/// F0 only: raw CSI amplitude + phase. The floor baseline.
CsiOnly,
/// F1 only: CIR delay-tap features (ADR-134), CSI not fed to the head.
CirOnly,
/// F0 + F1: amplitude/phase plus CIR taps. The current production default.
CsiPlusCir,
/// F0 + F1 + F2: adds Doppler / micro-motion spectrogram (ADR-014).
PlusDoppler,
/// F0 + F1 + F2 + F3: adds BFLD beamforming-feedback features (ADR-118/120).
PlusBfld,
/// F0..F4: adds UWB range constraint (ADR-144). HARDWARE-DEFERRED.
PlusUwb,
}
impl AblationVariant {
/// The full deterministic matrix, in canonical (stable) order.
pub const MATRIX: [AblationVariant; 6] = [
AblationVariant::CsiOnly,
AblationVariant::CirOnly,
AblationVariant::CsiPlusCir,
AblationVariant::PlusDoppler,
AblationVariant::PlusBfld,
AblationVariant::PlusUwb,
];
/// Whether the variant's required hardware is present in the current fleet.
/// `PlusUwb` returns `false` until ADR-144 ranging hardware lands.
pub fn is_runnable(&self) -> bool {
!matches!(self, AblationVariant::PlusUwb)
}
/// Stable string slug used in report tables, JSON keys, and proof-hash names.
pub fn slug(&self) -> &'static str { /* "csi_only", "cir_only", ... */ }
}
```
**Interface boundary.** `AblationVariant` does not know how to *compute* features. It is a pure descriptor. The harness translates each variant into a `StageMask` consumed by the ADR-136 streaming engine; the streaming engine remains the single owner of feature extraction. This keeps the train crate free of any `unsafe`/FFI signal code (consistent with `lib.rs`'s note that only `tch` brings `unsafe`).
The order in `MATRIX` is **load-bearing**: it is the iteration order used by the proof hash and by the report, so it must never be re-sorted (same discipline as `proof.rs::hash_model_weights()` sorting variables by name for stable order).
### 2.2 Spec Metrics Bound to Existing Interfaces
The new metrics are added as additive structs that *compose with*, not replace, `RuViewAcceptanceResult` and `CrossDomainMetrics`. We deliberately do not widen the existing public structs (they are consumed by checked-in tests and by the `summary()` formatters), per the rule "prefer editing an existing file but do not break a stable public API."
```rust
/// Detection-mode spec metrics that the ADR-031 acceptance test does not
/// currently capture. Every field traces to a documented evaluation protocol.
#[derive(Debug, Clone)]
pub struct SpecMetrics {
/// Presence detection accuracy (TP+TN)/N over the labelled set.
pub presence_accuracy: f32,
/// Localization error in metres (mean Euclidean, occupied frames only).
pub localization_err_m: f32,
/// Activity classification accuracy (multi-class, balanced).
pub activity_accuracy: f32,
/// Breathing-rate error in BPM (mirrors VitalSignResult.breathing_error_bpm).
pub breathing_err_bpm: f32,
/// False-positive rate: P(predict occupied | truly empty).
pub false_positive_rate: f32,
/// False-negative rate: P(predict empty | truly occupied).
pub false_negative_rate: f32,
}
/// Inference-latency profile measured with `criterion`-style sampling over
/// the replay set. Wall-clock, single-frame end-to-end through the gated
/// streaming pipeline for this variant.
#[derive(Debug, Clone)]
pub struct LatencyProfile {
pub p50_ms: f32,
pub p95_ms: f32,
pub p99_ms: f32,
/// Number of timed frames (sample count).
pub n_samples: usize,
}
/// Cross-room degradation, extending ADR-027 MERIDIAN reporting (§2.5).
#[derive(Debug, Clone)]
pub struct CrossRoomDegradation {
/// room_A accuracy room_B accuracy (signed; positive = B is worse).
pub accuracy_delta: f32,
/// Underlying cross-domain metrics from eval.rs (unchanged struct).
pub cross_domain: crate::eval::CrossDomainMetrics,
/// Per-joint degradation heatmap: 17 entries, room_Aroom_B PCK per joint.
pub per_joint_pck_delta: [f32; 17],
}
```
The acceptance thresholds for the new spec metrics extend the existing `Default`-carrying threshold structs by **adding a sibling**, not by mutating `JointErrorThresholds` etc.:
```rust
#[derive(Debug, Clone)]
pub struct SpecThresholds {
pub min_presence_accuracy: f32, // default 0.90
pub max_localization_err_m: f32, // default 0.50
pub min_activity_accuracy: f32, // default 0.70
pub max_false_positive_rate: f32, // default 0.05
pub max_false_negative_rate: f32, // default 0.10
pub max_p95_latency_ms: f32, // default 100.0 (ADR-136 streaming budget)
pub max_privacy_leakage: f32, // default 0.05 (see §2.3)
}
```
`max_p95_latency_ms = 100.0` is the streaming-engine real-time budget implied by ADR-136 (20 Hz sensing → 50 ms/frame headroom with margin). `max_privacy_leakage = 0.05` is justified in §2.3.
### 2.3 Privacy-Leakage via Membership Inference
The privacy-leakage scalar measures how much an adversary holding the model's outputs can recover identity that the ADR-120 hash-rotation pipeline is supposed to destroy. We measure it as a **membership-inference (MIA) attack success above chance**, normalized to `[0, 1]`.
**Setup.** The ADR-120 pipeline maps identity features → `rf_signature_hash = BLAKE3-keyed(site_salt, day_epoch || features)` (`signature_hasher.rs`). The structural guarantee (invariant I3) is that two sites, or two days, produce uncorrelated hashes. The *measured* question is different: given the model's emitted per-frame outputs for a known set of enrolled identities (members) and an equal set of held-out identities (non-members), can a simple attacker classifier decide membership better than a coin flip?
```rust
/// Privacy-leakage measurement against the ADR-120 hash-rotation pipeline.
#[derive(Debug, Clone)]
pub struct PrivacyLeakage {
/// MIA attacker AUC ∈ [0.5, 1.0]; 0.5 = no leakage, 1.0 = full recovery.
pub mia_auc: f32,
/// Normalized leakage score ∈ [0,1]: 2*(mia_auc 0.5), clamped.
pub leakage_score: f32,
/// Fisher-information trace of identity-feature gradients (diagnostic).
/// Higher trace = identity is more recoverable from model sensitivity.
pub fisher_trace: f32,
/// Number of (member, non-member) pairs probed.
pub n_probes: usize,
}
```
Two estimators are reported; the harness uses the MIA estimator for the pass/fail gate and the Fisher trace as a diagnostic:
1. **MIA simulator** (gate). Train a lightweight shadow classifier on the variant's emitted outputs to predict member/non-member, evaluate its AUC on a disjoint split. `leakage_score = clamp(2·(AUC 0.5), 0, 1)`. An AUC of 0.5 → `leakage_score = 0` (the model leaks nothing the hash rotation has not already destroyed); AUC of 1.0 → `leakage_score = 1.0`.
2. **Fisher-information trace** (diagnostic). The trace of the Fisher information matrix of the model's outputs with respect to the (pre-hash) identity features. This is a closed-form sensitivity measure: a model whose outputs are invariant to identity features has near-zero trace. It is reported but not gated, because its scale is not normalized across variants.
**Why MIA and not just trusting the structural invariant.** The BLAKE3 hash rotation guarantees that the *stored signature* cannot be cross-correlated. It says nothing about whether the *pose/presence outputs themselves* carry a usable identity fingerprint (gait, body geometry). A model can pass every ADR-120 structural test and still leak identity through its keypoint trajectories. MIA measures exactly that residual channel. The pass gate is `leakage_score ≤ 0.05`, i.e. attacker AUC ≤ 0.525 — within sampling noise of chance for the probe count used.
**Determinism.** The shadow classifier is trained with a fixed seed derived from `PROOF_SEED = 42` and a fixed split, so the AUC is reproducible. The Fisher trace is computed on the fixed replay set. Both feed the per-variant proof hash (§2.6) at coarse quantization, following the cross-platform lesson documented in `calibration_proof_runner.rs` (lines 113): quantize to 1e-3 in natural order, no sort, no libm-sensitive comparison.
### 2.4 `ruview-cli --ablation mode=auto`
A new CLI surface drives the matrix. It is added as a `Commands::Ablation(AblationArgs)` variant alongside the existing `Commands::Calibrate` / `Commands::Mat` / `Commands::Version` in `wifi-densepose-cli/src/lib.rs` (the same `clap` `Subcommand` enum that already hosts `Calibrate(calibrate::CalibrateArgs)`).
```
wifi-densepose ablation [OPTIONS]
OPTIONS:
--mode <MODE> auto | single [default: auto]
auto: run the full 6-variant matrix.
single: run one --variant.
--variant <SLUG> csi_only | cir_only | csi_plus_cir |
plus_doppler | plus_bfld | plus_uwb
(required when --mode=single)
--model <PATH> Path to the frozen model.bin snapshot to evaluate.
--replay <PATH> Witness-bundle CSI replay file
[default: archive/v1/data/proof/sample_csi_data.json]
--seed <N> Proof seed [default: 42]
--salt <HEX> Fixed 32-byte site salt for the BLAKE3 hasher
(deterministic privacy probe). [default: fixed test salt]
--out <PATH> Markdown report path [default: ablation_report.md]
--check-hash Compare each variant's canonical bytes against
archive/v1/data/proof/expected_ablation_<slug>.sha256
and exit non-zero on any mismatch (CI mode).
--generate-hash Write/refresh the per-variant expected hashes.
```
**Auto mode flow** (mirrors `proof.rs::run_proof` discipline):
1. Snapshot the model: load `--model`, freeze weights, record `SHA-256(model.bin)` as the model-version stamp.
2. For each `AblationVariant::MATRIX` entry where `is_runnable()`:
a. Set the streaming `StageMask`; replay the CSI under `PROOF_SEED=42` + fixed salt.
b. Compute `RuViewAcceptanceResult`, `SpecMetrics`, `LatencyProfile`, `PrivacyLeakage`, `CrossRoomDegradation`.
c. Serialise the variant's canonical metric bytes (coarse-quantized, natural order) and SHA-256 it. Compare to `expected_ablation_<slug>.sha256`; fail CI on mismatch in `--check-hash` mode.
3. For `PlusUwb`: emit `VariantOutcome::Skipped { reason: "ADR-144 UWB hardware not present" }`.
4. Emit the markdown report and `summary.json`.
The exit-code convention matches `proof.rs`: `0 = PASS`, `1 = FAIL` (hash mismatch or threshold breach), `2 = SKIP` (no expected hash file). This lets the ablation step drop into the existing ADR-011 / ADR-028 witness chain without a new CI grammar.
**Why `criterion` for latency.** The `criterion` crate gives a sampled distribution with percentile extraction rather than a single timing. We run a fixed warmup + sample budget so p50/p95/p99 are stable; the percentiles are quantized to 0.1 ms before hashing so wall-clock jitter does not break the proof hash (the metric is gated on `p95 ≤ threshold`, the *hash* only pins the quantized accuracy/privacy fields, not raw latency — latency is environment-dependent and therefore reported but excluded from the determinism hash, exactly as runtime wall-clock is excluded from `proof.rs`'s weight hash).
### 2.5 Cross-Room Degradation (MERIDIAN Extension)
ADR-027's `CrossDomainEvaluator` already partitions predictions by domain ID and computes `domain_gap_ratio`. This ADR extends the *reporting*, not the evaluator: it consumes the existing `evaluate()` output and adds room_A room_B deltas plus a per-joint heatmap.
```rust
/// Extend eval.rs reporting with a two-room A/B split and a per-joint heatmap.
/// `room_a_preds`/`room_b_preds` are (pred, gt) pairs as in CrossDomainEvaluator.
pub fn cross_room_degradation(
evaluator: &crate::eval::CrossDomainEvaluator,
room_a: &[(Vec<f32>, Vec<f32>)],
room_b: &[(Vec<f32>, Vec<f32>)],
) -> CrossRoomDegradation;
```
The per-joint heatmap is the 17-entry vector of `PCK_room_A[j] PCK_room_B[j]`, indexed by COCO joint (the same 17-joint convention used in `ruview_metrics.rs::COCO_SIGMAS` and `metrics.rs::COCO_KP_SIGMAS`). The multi-room test set reuses the domain-label convention: room A is domain `0` (in-domain), room B is a non-zero domain ID. This is a pure consumer of `eval.rs` — no change to `CrossDomainEvaluator` or `CrossDomainMetrics`.
### 2.6 Determinism Binding to the Proof Harness
Each runnable variant produces a canonical byte payload hashed with SHA-256, following the established signal-proof pattern (`calibration_proof_runner.rs`, `cir_proof_runner.rs`). A new binary `src/bin/ablation_proof_runner.rs` in `wifi-densepose-signal` (alongside the two existing `*_proof_runner.rs`) regenerates the matrix on the fixed seed/salt/replay and asserts the hashes match `archive/v1/data/proof/expected_ablation_<slug>.sha256`.
**Canonical payload per variant** (coarse quantization, natural field order, no sort — the libm-portability rule from `calibration_proof_runner.rs` lines 113):
```
[0] variant slug bytes (length-prefixed, like proof.rs param names)
[1] model.bin SHA-256 (32 bytes) ← model version
[2] calibration version tag (from ADR-135 baseline meta)
[3] privacy decision tag (BFLD mode, ADR-141)
[4] pck_all (× 1e3 round) u16
[5] oks (× 1e3 round) u16
[6] mota (× 1e3 round) u16
[7] presence_acc (× 1e3 round) u16
[8] localization (× 1e3 round, metres) u16
[9] activity_acc (× 1e3 round) u16
[10] fp_rate (× 1e3 round) u16
[11] fn_rate (× 1e3 round) u16
[12] leakage_score (× 1e3 round) u16
[13] tier byte (0=Fail,1=Bronze,2=Silver,3=Gold)
```
Latency fields are **excluded** from the hash (wall-clock is non-deterministic across machines, exactly as `proof.rs` excludes timing). Fields `[1]``[3]` make the evidence-traceability rule structural: the proof hash *cannot match* unless the model version, calibration version, and privacy decision are the ones that were pinned — so every reported semantic metric traces to a specific model + calibration + privacy decision, by construction.
### 2.7 Evidence Traceability
Per the project rule that every semantic state record traces to signal evidence + model version + calibration version + privacy decision, the `AblationReport` carries these four provenance fields per variant and binds them into the proof hash (§2.6 fields `[1]``[3]`, plus the replay file SHA as signal evidence):
```rust
#[derive(Debug, Clone)]
pub struct VariantProvenance {
/// Signal evidence: SHA-256 of the witness-replay CSI file.
pub replay_sha256: String,
/// Model version: SHA-256 of the frozen model.bin.
pub model_sha256: String,
/// Calibration version: ADR-135 baseline schema_version + captured_at.
pub calibration_version: String,
/// Privacy decision: the BFLD mode (ADR-141) under which features were gated.
pub privacy_mode: String,
}
```
A variant whose provenance cannot be fully populated (e.g. no calibration baseline loaded) is reported as `Degraded`, never as a passing tier — the report refuses to claim a Gold tier without a calibration version, the same way ADR-135 refuses `subtract()` on a tier mismatch.
### 2.8 Output: Auto-Report and Summary JSON
The markdown report has one row per variant, columns: variant slug · tier · PCK · OKS · MOTA · presence · localization · activity · FP · FN · **leakage** · p50/p95/p99 ms · runnable?. A delta block lists pairwise deltas of interest (e.g. `csi_plus_cir csi_only` to show CIR's contribution; `plus_bfld csi_plus_cir` to show whether BFLD features regress privacy). `summary.json` carries the same data machine-readably plus per-variant `VariantProvenance`, for the cognitum-v0 dashboard and the ADR-141 privacy control plane to ingest.
---
## 3. Consequences
### 3.1 Positive
- **Feature contribution becomes measurable.** The `csi_plus_cir csi_only` delta answers "did CIR (ADR-134) help?" with a number, not a commit message. Every future signal ADR can be justified or rejected against the matrix.
- **Privacy regression becomes a CI gate.** A model that leaks identity through pose trajectories — invisible to the structural ADR-120 tests — now fails `leakage_score ≤ 0.05`. This closes the residual-channel gap between *hash* privacy and *output* privacy.
- **Latency budget is enforced.** `p95 ≤ 100 ms` makes the ADR-136 real-time claim falsifiable. A Gold-accuracy model that misses the streaming budget no longer passes silently.
- **Deterministic and CI-friendly.** Reusing `PROOF_SEED=42` + fixed salt + witness replay + per-variant SHA-256 plugs directly into the ADR-011/ADR-028 witness chain. No new CI grammar; same `0/1/2` exit codes as `proof.rs`.
- **Additive, non-breaking.** `RuViewAcceptanceResult`, `CrossDomainMetrics`, and the threshold `Default` impls are untouched. The harness composes them; existing tests keep passing.
- **UWB is forward-declared.** `PlusUwb` is in the matrix as `Skipped`, so when ADR-144 hardware lands the only change is flipping `is_runnable()` and generating its expected hash.
### 3.2 Negative
- **Evaluation set must be curated.** The matrix is only as meaningful as the labelled multi-room replay set. Building a paired room_A/room_B set with presence/localization/activity labels is real work and is a prerequisite, not delivered by this ADR.
- **MIA is an estimate, not a proof.** A `leakage_score = 0` means *this* attacker found nothing; a stronger attacker might. The metric is a regression tripwire, not a cryptographic guarantee — the cryptographic guarantee remains ADR-120's structural invariant.
- **Six variants × full metric suite is slow.** The matrix runs the acceptance test, MERIDIAN eval, MIA shadow-classifier training, and criterion latency sampling per variant. This is a minutes-scale CI job, not seconds — it belongs in a nightly/witness job, not the per-commit fast path.
- **Latency excluded from the hash means latency can drift unnoticed.** We gate on `p95 ≤ threshold` but cannot pin it deterministically; a slow regression below the threshold is invisible. Mitigated by trending p95 in `summary.json` over time.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| MIA shadow classifier under-trained → false "no leakage" | Medium | A leaky model passes the privacy gate | Fix shadow-classifier capacity and probe count; report `n_probes`; require AUC CI confidence in `summary.json`; treat the gate as a tripwire, keep ADR-120 structural tests as the primary guarantee |
| Per-variant hash too sensitive → flaky CI across libm | Medium | Spurious FAIL on macOS vs Linux | Coarse u16 quantization at 1e-3, natural order, no sort — exactly the documented fix in `calibration_proof_runner.rs` lines 113; latency excluded from hash |
| Curated multi-room set leaks into training | Low | Inflated cross-room numbers | Evaluation replay set is frozen and SHA-pinned as `replay_sha256`; never used by `trainer.rs` |
| `PlusBfld` privacy probe needs a real `site_salt` | Low | Non-deterministic privacy hash | `--salt` defaults to a fixed test salt; the proof runner always uses the fixed salt so the hash is reproducible |
| Streaming `StageMask` toggles interact (e.g. CIR depends on calibration) | Medium | A variant silently runs uncalibrated | `VariantProvenance` requires a `calibration_version`; missing → `Degraded`, never a passing tier (§2.7) |
---
## 4. Alternatives Considered
### 4.1 Widen `RuViewAcceptanceResult` Instead of Adding Sibling Structs
Rejected. `RuViewAcceptanceResult` and its `summary()` are consumed by checked-in tests in `ruview_metrics.rs` (e.g. `tier_determination_gold`) and likely by downstream callers. Adding `presence_accuracy`, `leakage_score`, etc. as fields would churn those tests and the `summary()` format string. The additive `SpecMetrics` / `LatencyProfile` / `PrivacyLeakage` siblings compose cleanly and leave the ADR-031 contract intact.
### 4.2 A Hyperparameter Sweep Framework Instead of a Fixed Matrix
Rejected. A general sweep (Optuna-style) optimizes *training*; this ADR evaluates a *frozen* model under inference-time feature gates. Conflating them would couple the harness to `trainer.rs` and break the proof-determinism story (a sweep is, by design, exploratory and non-deterministic). The fixed six-variant matrix is the minimum that answers "what does each feature contribute?" deterministically.
### 4.3 Differential Privacy Accounting Instead of MIA
Rejected for this scope. DP (ε-accounting) is a *training-time* mechanism; it would require instrumenting the training loop with noise and a privacy ledger. The deployed model is already trained, and the question here is empirical output leakage on a fixed snapshot — MIA answers that directly with no training-time change. DP remains a valid future ADR for the training pipeline, but it does not measure residual leakage of an already-shipped model.
### 4.4 Skip Latency Entirely (Accuracy-Only Ablation)
Rejected. ADR-136 makes a real-time streaming claim with no enforcement. Without a `p95` gate, a feature that doubles accuracy but triples latency would "win" the ablation and ship, breaking the 20 Hz budget. Latency is reported and gated even though it is excluded from the determinism hash.
### 4.5 Define `+UWB` as Absent Rather Than `Skipped`
Rejected. Silently omitting `PlusUwb` until hardware lands would mean the matrix shape changes when hardware arrives, breaking report diffs and the per-variant hash set. The `Skipped { reason }` outcome keeps the matrix shape stable and self-documenting — the same discipline ADR-135 §2.8 uses for unprovisioned seed nodes.
---
## 5. Testing and Acceptance
### 5.1 Acceptance Criteria
| ID | Criterion | Evidence |
|----|-----------|----------|
| AC1 | `AblationVariant::MATRIX` has exactly 6 entries in canonical order; `PlusUwb.is_runnable() == false`, all others `true`. | `ablation::tests::matrix_shape` |
| AC2 | `cross_room_degradation()` returns a 17-entry `per_joint_pck_delta` and a signed `accuracy_delta`; perfect-equal rooms → all-zero heatmap and `accuracy_delta == 0`. | `ablation::tests::cross_room_zero_when_identical` |
| AC3 | `PrivacyLeakage` on an identity-invariant model → `leakage_score < 0.05` (AUC ≈ 0.5); on an identity-encoding model → `leakage_score > 0.5`. | `ablation::tests::mia_separates_leaky_model` |
| AC4 | `SpecThresholds::default()` gates: `presence ≥ 0.90`, `loc ≤ 0.50 m`, `activity ≥ 0.70`, `FP ≤ 0.05`, `FN ≤ 0.10`, `p95 ≤ 100 ms`, `leakage ≤ 0.05`. | `ablation::tests::spec_thresholds_default` |
| AC5 | A variant with missing `calibration_version` is reported `Degraded`, never a passing tier. | `ablation::tests::no_calibration_is_degraded` |
| AC6 | Re-running the matrix under `PROOF_SEED=42` + fixed salt + fixed replay produces byte-identical canonical payloads (per-variant hash stable across two runs). | `ablation::tests::canonical_bytes_deterministic` |
| AC7 | `ablation_proof_runner` exits `0` when all runnable variants match `expected_ablation_<slug>.sha256`, `1` on any mismatch, `2` on placeholder hashes. | `cargo run -p wifi-densepose-signal --bin ablation_proof_runner --release --no-default-features` |
| AC8 | The proof hash changes if the model SHA, calibration version, or privacy mode changes (provenance is bound into the hash). | `ablation::tests::provenance_affects_hash` |
### 5.2 Test Tiers
**Tier 1 — Matrix and metric unit tests (CI).** `matrix_shape`, `spec_thresholds_default`, `cross_room_zero_when_identical`, and the MIA separation test with two synthetic models (one identity-invariant, one that copies an identity feature into its output). These run without `tch` and without hardware.
**Tier 2 — Determinism proof (CI, extends ADR-011/ADR-028).** `ablation_proof_runner` regenerates each runnable variant's canonical bytes on `PROOF_SEED=42` + fixed salt + `sample_csi_data.json` replay and hashes them. Expected hashes live at `archive/v1/data/proof/expected_ablation_<slug>.sha256`. Until the harness lands, each file holds a `PLACEHOLDER` token and the runner exits `2` (the same bootstrap pattern as `calibration_proof_runner.rs`).
**Tier 3 — Full auto-report integration (nightly).** `wifi-densepose ablation --mode=auto --model <frozen.bin> --check-hash` runs the complete matrix, emits `ablation_report.md` + `summary.json`, and asserts every runnable variant's hash matches. `PlusUwb` is asserted `Skipped`.
**Tier 4 — Real-hardware sanity (gated, not CI).** Behind `#[cfg(feature = "hardware-test")]`: replay a live 30 s capture from the ESP32-S3 on COM9 through `csi_only` and `csi_plus_cir`, assert `csi_plus_cir` does not regress presence accuracy and that p95 latency stays under the 100 ms budget on the ruvzen box.
### 5.3 Witness / Proof Rows
Per ADR-028, three rows are added to `docs/WITNESS-LOG-028.md`:
| Row | Capability | Evidence | Hash |
|-----|-----------|----------|------|
| W-39 | Ablation matrix deterministic over 5 runnable variants | `ablation_proof_runner` exits 0 | SHA-256 of `csi_plus_cir` canonical bytes |
| W-40 | Privacy-leakage MIA separates leaky vs invariant model | `cargo test ablation::tests::mia_separates_leaky_model` | SHA-256 of test binary |
| W-41 | Provenance binds model+calibration+privacy into the proof hash | `cargo test ablation::tests::provenance_affects_hash` | SHA-256 of two distinct-provenance payloads |
`source-hashes.txt` in the witness bundle gains `SHA-256(wifi-densepose-train/src/ablation.rs)` and `SHA-256(wifi-densepose-signal/src/bin/ablation_proof_runner.rs)`.
---
## 6. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-011 (Deterministic Proof Harness) | **Substrate**: per-variant SHA-256 + `PROOF_SEED=42` + `0/1/2` exit codes reuse the `proof.rs` discipline directly |
| ADR-014 (SOTA Signal Processing) | **Source**: the Doppler/spectrogram feature gated by the `PlusDoppler` variant |
| ADR-027 (MERIDIAN Cross-Environment) | **Extended (reporting)**: `cross_room_degradation()` consumes `eval.rs::CrossDomainEvaluator` and adds A/B deltas + per-joint heatmap; the evaluator itself is unchanged |
| ADR-031 (RuView Sensing-First RF Mode) | **Extended**: the ablation harness drives the `ruview_metrics.rs` acceptance test (`RuViewTier`, `JointErrorThresholds`, …) per variant, adding presence/localization/activity/FP-FN/latency/privacy metrics it did not previously capture |
| ADR-120 (BFLD Privacy Class & Hash Rotation) | **Measured**: the MIA probe attacks the `signature_hasher.rs` hash-rotation pipeline's residual output leakage; the structural invariants remain the primary guarantee |
| ADR-136 (RuView Streaming Engine) | **Consumer/owner of features**: the harness toggles ADR-136 `StageMask`; the streaming engine remains the sole feature-extraction owner; the `p95 ≤ 100 ms` gate enforces ADR-136's real-time claim |
| ADR-141 (BFLD Privacy Control Plane) | **Provenance source/consumer**: the `privacy_mode` in `VariantProvenance` is an ADR-141 named mode; `summary.json` feeds the control plane |
| ADR-144 (UWB Range-Constraint Fusion) | **Forward-declared**: `PlusUwb` is a defined-but-`Skipped` variant until ADR-144 ranging hardware lands |
---
## 7. References
### Production Code
- `v2/crates/wifi-densepose-train/src/ruview_metrics.rs` — ADR-031 acceptance test; `RuViewAcceptanceResult`, `RuViewTier`, `JointErrorThresholds`, `determine_tier()` reused unchanged
- `v2/crates/wifi-densepose-train/src/eval.rs``CrossDomainEvaluator`, `CrossDomainMetrics`; consumed by `cross_room_degradation()`
- `v2/crates/wifi-densepose-train/src/metrics.rs``MetricsResult`, `COCO_KP_SIGMAS`; 17-joint convention for the per-joint heatmap
- `v2/crates/wifi-densepose-train/src/proof.rs``run_proof`, `PROOF_SEED`, `hash_model_weights`, `0/1/2` exit-code convention reused as the harness substrate
- `v2/crates/wifi-densepose-train/src/ablation.rs`**new**: `AblationVariant`, `AblationHarness`, `SpecMetrics`, `LatencyProfile`, `PrivacyLeakage`, `CrossRoomDegradation`, `VariantProvenance`
- `v2/crates/wifi-densepose-signal/src/bin/calibration_proof_runner.rs` — canonical-bytes / coarse-quantization / libm-portability pattern (lines 113) reused
- `v2/crates/wifi-densepose-signal/src/bin/cir_proof_runner.rs` — sibling proof-runner pattern
- `v2/crates/wifi-densepose-signal/src/bin/ablation_proof_runner.rs`**new**: regenerates the matrix hashes
- `v2/crates/wifi-densepose-bfld/src/signature_hasher.rs` — ADR-120 BLAKE3 hash-rotation pipeline; MIA target
- `v2/crates/wifi-densepose-bfld/src/embedding.rs``IdentityEmbedding` (in-RAM-only); identity-feature source for the Fisher trace
- `v2/crates/wifi-densepose-cli/src/lib.rs``Commands` enum; new `Commands::Ablation(AblationArgs)` variant beside `Calibrate`
- `archive/v1/data/proof/expected_ablation_<slug>.sha256`**new**: per-variant expected hashes
- `archive/v1/data/proof/sample_csi_data.json` — default witness replay set
- `archive/v1/data/proof/verify.py` — proof chain; gains an `ablation_matrix_check()` extension
- `docs/WITNESS-LOG-028.md` — rows W-39 through W-41
### External References
- Shokri, R. et al. (2017). "Membership Inference Attacks Against Machine Learning Models." *IEEE S&P*. — Shadow-classifier MIA methodology underlying the `mia_auc` estimator.
- Carlini, N. et al. (2022). "Membership Inference Attacks From First Principles." *IEEE S&P*. — AUC-based leakage normalization and the "attacker AUC above 0.5" framing used for `leakage_score`.
- COCO Keypoint Evaluation. — PCK / OKS definitions and the 17-joint sigmas mirrored from `ruview_metrics.rs` and `metrics.rs`.
- Bernstein, J.-P. (BLAKE3 team) (2020). *BLAKE3 specification*. — Keyed-hash mode used by `signature_hasher.rs`, the ADR-120 pipeline under privacy test.
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `0f336b7d3`, issue #849): the 6-variant `FeatureSet` matrix and `AblationMetrics` (FP/FN, latency p50/p95, membership-inference privacy leakage, cross-room degradation) with a deterministic markdown report and the `csi_cir_beats_csi_only` acceptance check. 5 tests.
**Integration glue -- not yet on the live path:** the `ruview-cli --ablation mode=auto` subcommand that snapshots the model and runs the 6 variants under `PROOF_SEED=42` witness-bundle replay (also where ADR-136 AC6 lands); the `+UWB` variant once ADR-144 hardware exists.
**Trust contribution:** makes every pipeline change *measurable* -- including how much a model leaks about its training data -- so improvements are proven, not asserted. The scorecard behind every other claim in the series.
@@ -0,0 +1,415 @@
# ADR-146: RF Encoder Multi-Task Heads and Uncertainty Quantification
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-nn` (encoder/model), `wifi-densepose-train` (`ContrastiveBatcher`); AETHER (ADR-024) / MERIDIAN (ADR-027) context |
| **Relates to** | ADR-136 (RuView Streaming Engine, Frame Contracts, QualityScored), ADR-140 (Semantic State Record Schema & Agent Bridge), ADR-145 (Ablation Evaluation Harness), ADR-024 (AETHER Contrastive CSI Embedding), ADR-027 (MERIDIAN Cross-Environment Generalization), ADR-023 (Trained DensePose Model + RuVector Pipeline) |
---
## 1. Context
### 1.1 The Gap
The current Rust stack already owns a shared RF encoder backbone and one contrastive projection head, but it lacks the multi-head fan-out, the per-head uncertainty, and the formalized batcher that ADR-140's `SemanticStateRecord` and ADR-136's `QualityScored` trait will require as upstream producers. Three concrete observations from the real codebase establish the gap.
**A single backbone exists, but it feeds only two task heads.** `v2/crates/wifi-densepose-train/src/model.rs` defines `WiFiDensePoseModel` with a shared `translator``backbone` path producing `ModelOutput.features`, consumed by exactly two heads:
```rust
// v2/crates/wifi-densepose-train/src/model.rs
pub struct WiFiDensePoseModel {
// translator → backbone (shared) ...
kp_head: KeypointHead, // line 70
dp_head: DensePoseHead, // line 71
}
```
`forward_impl()` (model.rs ~line 193) emits `ModelOutput { keypoints, part_logits, uv_coords, features }`. The `features` tensor is the shared representation, but it is consumed only by pose-regression heads. There is no presence head, count head, activity head, vitals head, gait head, or an exported identity-embedding head wired off the same backbone. Presence, count, activity, vitals, and gait are computed today by *separate* signal-processing modules (`ruvsense/`, `wifi-densepose-vitals`) that do not share the encoder representation, so they cannot benefit from contrastive pretraining (ADR-024) or cross-environment LoRA (ADR-027).
**A projection head and contrastive loss exist, but in the serving crate, not as a formal head taxonomy.** `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` already implements:
- `ProjectionHead` (2-layer MLP, `d_model=64 → d_proj=128`, ReLU + L2-norm), with optional rank-4 LoRA adapters (`lora_1`, `lora_2`) for environment-specific fine-tuning (ADR-027) — all pure-Rust `Vec<f32>` (`forward(&self, x: &[f32]) -> Vec<f32>`, embedding.rs line 131).
- `info_nce_loss()` (embedding.rs line 476) and `CsiAugmenter::augment_pair()` (line 362).
- `EmbeddingExtractor`, the full backbone + projection pipeline.
This is the *seventh* head (identity-embedding) of the proposed taxonomy, already materialized — but as a one-off in the serving crate rather than as one branch among seven over a shared encoder. It is the proof that the pure-Rust `f32` ABI is viable; it is not yet a general multi-task head abstraction.
**Contrastive pair construction exists, but ad hoc.** `v2/crates/wifi-densepose-train/src/rapid_adapt.rs` (MERIDIAN Phase 5) defines `AdaptationLoss::ContrastiveTTT` whose doc-comment is literally *"positive = temporally adjacent, negative = random"* (rapid_adapt.rs line 9), and `contrastive_step()` (line 201) implements it. But this lives inside test-time adaptation, sampling within a single CSI stream. There is **no `ContrastiveBatcher`** anywhere in the workspace (`grep -rn "ContrastiveBatcher" v2/crates` returns nothing). The cross-environment positive/negative pair construction that ADR-027 §6.x requires — same activity / same person across *different rooms* as positives, different semantics as negatives — has no formal sampling contract. Batched iteration is provided generically by `DataLoader`/`DataLoaderIter` over `CsiSample` (`dataset.rs` line 150), with no notion of anchor/positive/negative tuples.
**Consequence.** ADR-140's `SemanticStateRecord` is meant to carry a `model_version` and trace every semantic field to its evidence. ADR-136's `QualityScored` trait is meant to attach confidence bounds to every stage output. Today, the only encoder-derived quantity that could populate such a record is pose; presence/count/activity/vitals/gait arrive from non-encoder modules with their own (incomparable) confidence conventions, and none of them emit calibrated uncertainty. This ADR closes that gap: a single shared RF encoder with seven typed heads, each emitting a `QualityScored` output with per-head uncertainty, trained with a formalized `ContrastiveBatcher` and a calibration-robustness loss that ties the encoder to ADR-135's `calibration_id`.
### 1.2 Scope Boundary
This ADR is about **the encoder and its head fan-out**, not about the downstream semantic record (ADR-140) or the streaming frame contracts (ADR-136). It defines:
- The seven-head taxonomy over the shared backbone in `wifi-densepose-nn`.
- The per-head uncertainty quantification layer and its mapping onto `QualityScored`.
- The calibration-robustness loss tying training to `calibration_id`.
- The `ContrastiveBatcher` sampling contract in `wifi-densepose-train`.
- The pure-Rust `f32` tensor ABI for deterministic, witnessable inference.
- The ablation hooks consumed by ADR-145.
It does **not** define the `SemanticStateRecord` wire schema (ADR-140) nor the stage abstraction (ADR-136); it defines the *producer* that feeds them.
### 1.3 Why `wifi-densepose-nn` and Not `wifi-densepose-train`
Training lives in `wifi-densepose-train` (libtorch / `tch`), which is GPU- and `Tensor`-bound. Inference must run on the Pi+Hailo cluster and in WASM. The current encoder *definition* lives in `wifi-densepose-train/src/model.rs` (a libtorch graph), while the *inference* projection head lives in `wifi-densepose-sensing-server/src/embedding.rs` (pure Rust `f32`). This split is the underlying disease: the head taxonomy and the ABI belong in `wifi-densepose-nn` (which already owns `Tensor` = `Array{1..4}D<f32>` in `tensor.rs`, and `densepose.rs`/`inference.rs`), so both the training crate and the serving crate depend on one definition. The new head-trait and uncertainty types are added to `wifi-densepose-nn`; `wifi-densepose-train` adds only the `ContrastiveBatcher` and the loss terms.
### 1.4 Pipeline Position
```
CSI window (amplitude, phase)
→ [wifi-densepose-signal preprocessing + ADR-135 baseline subtract]
→ RfEncoder::encode() (shared backbone → embedding z ∈ R^d_model) [wifi-densepose-nn, NEW trait]
├── PoseHead ─┐
├── PresenceHead │
├── CountHead │ each head: forward → (value, UncertaintyHead → bounds)
├── ActivityHead ├─ → MultiTaskOutput { per-head QualityScored } [NEW]
├── VitalsHead │
├── GaitHead │
└── IdentityEmbedHead ─┘ (the existing ADR-024 ProjectionHead, relocated)
→ SemanticStateRecord assembly (ADR-140; stamps model_version + calibration_id)
→ Fusion engine quality scoring (ADR-136 QualityScored)
```
During training, the shared backbone receives gradients from all *enabled* heads (ADR-145 ablation matrix can disable any head), plus the contrastive term over `ContrastiveBatcher` tuples, plus the calibration-robustness term over `calibration_id` groups.
---
## 2. Decision
### 2.1 Seven Task-Specific Head Branches Over the Shared Encoder
Add a `RfEncoder` abstraction in `wifi-densepose-nn` that owns the shared backbone and produces a single embedding `z ∈ ^{d_model}` (default `d_model = 64`, matching `embedding.rs` and `model.rs` today). Seven heads consume `z`. Each head is independently constructible, toggleable, and emits a `QualityScored` output.
| # | Head | Output value | Output type | Existing seed in repo |
|---|------|--------------|-------------|------------------------|
| 1 | `PoseHead` | 17 keypoints + DensePose UV | `PoseEstimate` | `KeypointHead`/`DensePoseHead` (model.rs) |
| 2 | `PresenceHead` | occupancy probability | `f32 ∈ [0,1]` | `ruvsense/coherence_gate.rs` (non-encoder) |
| 3 | `CountHead` | person count | `u8` (argmax over softmax) | none |
| 4 | `ActivityHead` | activity class | `ActivityClass` | `ruvsense/gesture.rs` (non-encoder) |
| 5 | `VitalsHead` | breathing/HR rate | `Vitals { br_hz, hr_hz }` | `wifi-densepose-vitals` (non-encoder) |
| 6 | `GaitHead` | gait signature | `GaitFeatures` | `ruvsense/longitudinal.rs` (non-encoder) |
| 7 | `IdentityEmbedHead` | 128-d unit embedding | `Embedding128` | `ProjectionHead` (embedding.rs) — **relocated** |
Head #7 is the existing ADR-024 `ProjectionHead`, moved from `wifi-densepose-sensing-server` into `wifi-densepose-nn` and re-exported (the serving crate re-imports it; no behavior change, identical Xavier seeds 2024/2025 preserved for determinism and existing RVF compatibility). Heads #2, #4, #5, #6 supersede the standalone signal modules *as encoder-derived alternatives*; the signal modules remain for the no-model fallback path and as ablation baselines (ADR-145).
```rust
// v2/crates/wifi-densepose-nn/src/encoder/mod.rs (NEW module)
use crate::tensor::Tensor; // Array{1..4}D<f32> — pure Rust, no libtorch at inference
/// Shared RF encoder backbone. Produces a fixed-width embedding from a CSI window.
pub trait RfEncoder {
/// Encode a preprocessed CSI window into the shared embedding `z`.
/// Input is amplitude+phase already baseline-subtracted (ADR-135).
fn encode(&self, window: &EncoderInput) -> Embedding;
/// Embedding width (`d_model`). Default deployment: 64.
fn d_model(&self) -> usize;
/// Identifier of the weights producing this embedding — flows into
/// ADR-140 `SemanticStateRecord.model_version`.
fn model_version(&self) -> &ModelVersion;
}
/// Owned set of task heads sharing one encoder.
pub struct MultiTaskRfModel<E: RfEncoder> {
encoder: E,
pose: Option<PoseHead>,
presence: Option<PresenceHead>,
count: Option<CountHead>,
activity: Option<ActivityHead>,
vitals: Option<VitalsHead>,
gait: Option<GaitHead>,
identity: Option<IdentityEmbedHead>,
enabled: HeadMask, // ablation control (§2.5)
}
/// One unified inference call. Only enabled heads are evaluated.
pub struct MultiTaskOutput {
pub embedding: Embedding,
pub pose: Option<QualityScored<PoseEstimate>>,
pub presence: Option<QualityScored<f32>>,
pub count: Option<QualityScored<u8>>,
pub activity: Option<QualityScored<ActivityClass>>,
pub vitals: Option<QualityScored<Vitals>>,
pub gait: Option<QualityScored<GaitFeatures>>,
pub identity: Option<Embedding128>, // unit vector; quality is uniformity/alignment, not per-frame conf
pub model_version: ModelVersion,
pub calibration_id: Option<CalibrationId>, // ADR-135; None ⇒ uncalibrated mode
}
impl<E: RfEncoder> MultiTaskRfModel<E> {
pub fn forward(&self, input: &EncoderInput) -> MultiTaskOutput;
}
```
**Interface boundary.** `MultiTaskOutput` is the *only* thing the ADR-140 record assembler reads. Each `QualityScored<T>` carries the value, its uncertainty (§2.2), the `model_version`, and (if present) the `calibration_id` — satisfying the project rule that every semantic state traces to signal evidence + model version + calibration version + privacy decision (the privacy decision is stamped downstream by ADR-141, out of scope here).
### 2.2 Per-Head Uncertainty Quantification → `QualityScored`
Every head except the embedding head emits a calibrated uncertainty. The method differs by head type but all converge onto the ADR-136 `QualityScored` trait so the fusion engine can compare confidences across heads.
```rust
// re-exported from the ADR-136 contract; shown here for the producer side
pub trait QualityScored {
fn quality(&self) -> QualityScore; // ∈ [0,1], calibrated (ECE-checked, §2.6)
fn evidence(&self) -> &EvidenceRef; // points at the CSI window + calibration_id
}
pub struct QualityScore {
pub confidence: f32, // point confidence ∈ [0,1]
pub bound: UncertaintyBound,
}
pub enum UncertaintyBound {
/// Regression heads (vitals, pose coords): predictive ±σ per dimension.
Gaussian { mean: Vec<f32>, sigma: Vec<f32> },
/// Classification heads (presence, count, activity): full categorical posterior.
Categorical { probs: Vec<f32>, entropy: f32 },
/// Identity/gait: cosine-margin to the next-nearest cluster.
Margin { top1: f32, margin: f32 },
}
```
Uncertainty mechanism per head:
| Head | UQ mechanism | Why this and not MC-dropout/ensembles |
|------|-------------|----------------------------------------|
| Pose | Per-keypoint predictive variance head (Gaussian NLL, learned σ) | Closed-form, single forward pass — required for 20 Hz real-time and for WASM/Hailo where dropout sampling is impractical |
| Presence | Categorical posterior + entropy | Binary; entropy near `ln 2` ⇒ abstain |
| Count | Categorical (softmax over {0..K_max}) + entropy | Discrete; entropy distinguishes "2 vs 3 people" ambiguity from confident calls |
| Activity | Categorical posterior + entropy | Same as count; entropy is the abstention signal |
| Vitals | Gaussian NLL (learned σ on br_hz, hr_hz) | Physiological rates need a continuous confidence band, not a class label |
| Gait | Cosine margin to enrolled-gait clusters | Gait is an open-set matching problem, like identity |
| Identity | Embedding uniformity/alignment (ADR-024 metrics) | Already defined in AETHER; no per-frame "confidence", quality is index-level |
**Decision: heteroscedastic single-pass UQ, not MC-dropout or deep ensembles.** Justified in §3 Alternatives. The learned-σ head is two extra linear layers per regression head and adds a Gaussian-NLL term to the loss; the categorical heads need no extra parameters (the softmax *is* the posterior). This keeps the pure-Rust `f32` inference path single-pass and deterministic.
**Calibration of the score itself.** `confidence` must be *calibrated* (a 0.8 confidence is right 80% of the time), enforced via post-hoc temperature scaling per head, with Expected Calibration Error (ECE) checked in the acceptance tests (§2.6). The temperature scalars are stored alongside weights and stamped into `model_version`.
### 2.3 Calibration-Robustness Loss Tied to ADR-135 `calibration_id`
The encoder must be **invariant to per-device baseline shifts** so that an embedding for "empty room, device A" and "empty room, device B" land in the same place and a person produces the same activity/pose regardless of which calibrated node observed them. ADR-135 produces a `BaselineCalibration` per device with a stable identity; this ADR introduces `CalibrationId` as a hashable key over `(device_id, tier, captured_at)` and uses it as a **domain label** in a calibration-robustness loss.
```
L_total = Σ_h w_h · L_head_h(enabled)
+ λ_con · L_contrastive (NT-Xent over ContrastiveBatcher tuples, §2.4)
+ λ_cal · L_calib_robust (NEW, this section)
+ λ_uq · L_uncertainty (Gaussian-NLL terms across regression heads)
```
`L_calib_robust` is a **calibration-adversarial / variance-penalty** term. Two equivalent formulations are supported (config-selectable):
1. **Group-variance penalty (default).** For a mini-batch, group embeddings by `calibration_id`. Penalize the *between-group* variance of the embedding conditioned on the *same* semantic label (same activity/presence), pulling cross-device representations of the same event together:
`L_calib_robust = mean_over_labels( Var_{calib_id}( z | label ) )`.
2. **Gradient-reversal domain classifier (DANN-style).** A small `calibration_id` classifier behind a gradient-reversal layer; the encoder learns features the classifier *cannot* use to recover which calibrated device produced them.
The default is the group-variance penalty: it has no adversarial training instability, it requires `≥2` distinct `calibration_id`s per mini-batch (enforced by `ContrastiveBatcher`, §2.4), and it directly operationalizes "invariant to per-device baseline shift." When `calibration_id` is `None` (uncalibrated capture), the sample is excluded from `L_calib_robust` but still contributes to head losses.
**Interface boundary.** The training loop reads `CalibrationId` from each `CsiSample` (a new optional field populated from the capture's ADR-135 baseline). Inference stamps the *active* `calibration_id` into `MultiTaskOutput` so the semantic record traces to the calibration version — satisfying the project provenance rule.
### 2.4 The `ContrastiveBatcher` Sampling Contract
Formalize the ad-hoc rapid_adapt pairing (`positive = temporally adjacent, negative = random`) into a first-class, cross-environment sampler in `wifi-densepose-train`. It produces **anchor / positive / negative** tuples obeying ADR-027's cross-environment generalization requirement.
```rust
// v2/crates/wifi-densepose-train/src/dataset.rs (NEW; alongside DataLoader)
pub struct ContrastiveBatcher<'a> {
dataset: &'a dyn CsiDataset,
batch_size: usize,
strategy: PairStrategy,
/// Minimum distinct calibration_ids per batch (≥2 to make L_calib_robust well-posed).
min_calib_ids: usize,
seed: u64,
}
pub enum PairStrategy {
/// ADR-024 default: positive = augmented view of same window (CsiAugmenter),
/// negative = other windows in the batch.
SelfSupervised,
/// ADR-027: positive = SAME semantic label in a DIFFERENT environment
/// (different calibration_id / room); negative = different label.
/// This is the contract that forces cross-environment invariance.
CrossEnvironment { label_key: LabelKey },
/// rapid_adapt parity: positive = temporally adjacent, negative = random.
Temporal { window: usize },
}
pub struct ContrastiveBatch {
pub anchors: Vec<CsiSample>,
pub positives: Vec<CsiSample>, // aligned 1:1 with anchors
pub negatives: Vec<Vec<CsiSample>>, // per-anchor negative set (in-batch or sampled)
pub calib_ids: Vec<Option<CalibrationId>>, // aligned with anchors; ≥ min_calib_ids distinct
}
impl<'a> ContrastiveBatcher<'a> {
pub fn new(dataset: &'a dyn CsiDataset, batch_size: usize,
strategy: PairStrategy, seed: u64) -> Self;
/// Deterministic given (seed, epoch). Reuses DataLoader's xorshift shuffle.
pub fn iter(&self, epoch: u64) -> impl Iterator<Item = ContrastiveBatch> + '_;
}
```
**Contract guarantees** (tested in §2.6):
1. **Determinism**: `(seed, epoch)` fully determines the batch sequence — same xorshift RNG already used by `DataLoader`.
2. **Positive validity**: under `CrossEnvironment`, `positive.label == anchor.label` AND `positive.calibration_id != anchor.calibration_id` (when ≥2 environments exist; otherwise it degrades gracefully to `SelfSupervised` with a warning).
3. **Negative validity**: every negative differs from the anchor in the semantic label dimension being contrasted.
4. **Calibration coverage**: each batch contains ≥ `min_calib_ids` distinct `calibration_id`s so `L_calib_robust` (§2.3) is computable; if the dataset has fewer, the batcher errors at construction (fail fast, not silent degradation).
The existing `CsiAugmenter::augment_pair()` (embedding.rs line 362) provides the augmentation for `SelfSupervised`/`CrossEnvironment` positive views and is re-exported from `wifi-densepose-nn`. `info_nce_loss()` (embedding.rs line 476) consumes the batch unchanged.
### 2.5 Pure-Rust `f32` Tensor ABI for Deterministic, Witnessable Inference
**Decision: the inference ABI for the encoder and all heads is pure-Rust `f32` (`ndarray`), identical to the existing `wifi-densepose-nn::tensor::Tensor` enum (`Float1D..Float4D`, `tensor.rs`) and the `ProjectionHead::forward(&[f32]) -> Vec<f32>` convention already in `embedding.rs`.** No libtorch at inference time.
Rationale:
- **Witnessability (ADR-028).** A pure-`f32` forward pass with a fixed evaluation order is bit-reproducible. The same SHA-256 proof discipline applied to ADR-134/135 (`verify.py` + `expected_features.sha256`) extends to the multi-task forward: feed a fixed CSI window, hash `MultiTaskOutput` floats, assert stable. libtorch reductions are not bit-stable across builds/devices and cannot anchor a witness hash.
- **Deployment.** Hailo/WASM targets do not link libtorch. The serving path (`embedding.rs`) already proves pure-Rust inference works; this generalizes it to all seven heads.
- **Training/inference split.** Training stays in `wifi-densepose-train` (libtorch `tch`). A weight-export step converts trained head/encoder weights into the flat `Vec<f32>` layout already used by `ProjectionHead::flatten_into`/`unflatten_from` (embedding.rs lines 159/165). Each head defines `flatten_into`/`unflatten_from` for round-trip stability (the same pattern as the existing projection head and its LoRA `flatten_lora`/`unflatten_lora`).
**ABI specification (per head, little-endian f32, row-major):**
```
[u32 magic 0x52464548 "RFEH"][u16 schema=1][u16 d_model][u8 n_heads][u8 head_mask]
[ModelVersion: 32-byte content hash of all weights]
[per enabled head: u16 head_id, u32 param_len, f32 × param_len]
```
`ModelVersion` is the 32-byte hash that flows into `SemanticStateRecord.model_version` (ADR-140) — making the weights self-identifying so a record can never claim a model version it did not run.
### 2.6 Ablation Hooks for ADR-145
Each head is individually toggleable at *both* train and inference time via `HeadMask`, exactly the toggle ADR-145's ablation matrix needs.
```rust
pub struct HeadMask(u8); // bit per head; bit 0 = pose ... bit 6 = identity
impl HeadMask {
pub const ALL: HeadMask;
pub fn with(self, h: HeadKind) -> Self;
pub fn without(self, h: HeadKind) -> Self;
pub fn is_enabled(&self, h: HeadKind) -> bool;
}
```
- **Inference**: a disabled head is not evaluated and its `MultiTaskOutput` field is `None` (zero CPU cost — this is what ADR-145 measures for latency-vs-head-count).
- **Training**: a disabled head contributes no loss term and no gradient (its `w_h = 0`), so the ablation harness can measure each head's *contribution to the shared backbone* and detect negative transfer between heads.
- **Privacy-leakage probe (ADR-145)**: the `IdentityEmbedHead` and `GaitHead` can be disabled to produce a privacy-reduced model; the harness measures how much identity information remains recoverable from the *remaining* heads' embedding `z`. The encoder exposes `z` directly so ADR-145 can run a linear-probe leakage test without re-running heads.
`MultiTaskRfModel::with_mask(mask)` returns a view enabling exactly the named heads; the ablation harness iterates the `2^7` (or a curated subset of) masks.
### 2.7 Proof / Witness
Per ADR-028, add witness rows to `docs/WITNESS-LOG-028.md`:
| Row | Capability | Evidence | Hash |
|-----|-----------|----------|------|
| W-39 | Multi-task forward determinism (pure-Rust f32, fixed window) | `cargo test -p wifi-densepose-nn encoder::tests::forward_determinism` | SHA-256 of `MultiTaskOutput` floats |
| W-40 | `ContrastiveBatcher` determinism + positive/negative validity | `cargo test -p wifi-densepose-train dataset::tests::contrastive_contract` | SHA-256 of batch index sequence |
| W-41 | Per-head ECE within bound after temperature scaling | `cargo test -p wifi-densepose-nn encoder::tests::ece_calibrated` | recorded ECE values |
| W-42 | Weight ABI round-trip (flatten → unflatten bit-identical) | `cargo test -p wifi-densepose-nn encoder::tests::abi_round_trip` | SHA-256 of serialized weights |
`source-hashes.txt` gains `SHA-256(encoder/mod.rs)` and `SHA-256(dataset.rs ContrastiveBatcher region)`.
---
## 3. Consequences
### 3.1 Positive
- **One representation, seven tasks.** Presence/count/activity/vitals/gait now benefit from ADR-024 contrastive pretraining and ADR-027 cross-environment LoRA, instead of each signal module learning in isolation. Multi-task co-regularization typically improves data efficiency for the weaker heads (count, gait) by sharing the backbone with the data-rich heads (pose, presence).
- **Comparable, calibrated confidences.** Every head emits `QualityScored` with ECE-checked confidence, so ADR-136's fusion engine can weight pose-confidence against vitals-confidence on a common scale, and ADR-140's record carries calibrated uncertainty per field.
- **Cross-device invariance.** `L_calib_robust` keyed on ADR-135 `calibration_id` means a model trained across the fleet (ESP32-S3, C6, cognitum-seed-1) does not learn device-specific shortcuts; embeddings are comparable across nodes — directly enabling multistatic fusion (ADR-029) on encoder embeddings, not just raw CSI.
- **Witnessable inference.** Pure-Rust `f32` ABI extends the ADR-028 proof chain to the full model and ships to Hailo/WASM without libtorch.
- **Ablation-ready.** ADR-145 gets its head toggle for free; the `z`-exposure enables the privacy-leakage probe without bespoke hooks.
### 3.2 Negative
- **Weight-export step required.** Training (libtorch) and inference (pure-Rust) now have a mandatory, tested conversion. A bug in `flatten/unflatten` silently degrades inference; W-42 guards it.
- **Loss has more knobs.** `w_h` (seven), `λ_con`, `λ_cal`, `λ_uq` — more hyperparameters to tune; negative transfer between heads is possible and must be monitored via the ablation harness.
- **Relocating `ProjectionHead`** from `wifi-densepose-sensing-server` to `wifi-densepose-nn` touches the serving crate's imports and any RVF segment that referenced the old path. Seeds and layout are preserved so existing RVF embedding indices remain valid, but the move is a real refactor.
- **`ContrastiveBatcher` needs multi-environment data.** `CrossEnvironment` strategy is only meaningful with ≥2 calibrated rooms; with one room it degrades to self-supervised. Until multi-room paired capture exists (CLAUDE.local.md: cognitum-seed-1 + the COM9 node are the two provisioned environments), cross-environment training is data-limited.
### 3.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Heteroscedastic σ collapses to a constant (head ignores input, learns global noise) | Medium | UQ is uninformative; ECE looks fine but bounds are useless | β-NLL / σ-floor regularization; W-41 ECE test plus a per-input σ-variance assertion |
| Negative transfer: adding count/gait heads degrades pose | Medium | Headline pose metric regresses | ADR-145 ablation matrix quantifies each head's effect on every other; gate head inclusion on no-regression |
| `calibration_id` group too small in a batch → `L_calib_robust` noisy | Medium | Cross-device invariance under-trained | `ContrastiveBatcher` enforces `min_calib_ids ≥ 2` at construction (fail fast) |
| Pure-Rust forward diverges from libtorch training graph (op mismatch) | Low-Med | Inference accuracy ≠ training accuracy | Golden-output parity test: same weights, same input, assert pure-Rust output within tolerance of libtorch reference; part of W-39 |
| Identity/gait heads enabled by default leak biometric data | Medium | Privacy regression | Heads default-off behind `HeadMask`; ADR-141 privacy mode must explicitly enable them; ADR-145 leakage probe verifies residual leakage with them off |
---
## 4. Alternatives Considered
### 4.1 Separate Models Per Task (status quo)
Keep pose in the encoder and leave presence/count/activity/vitals/gait as independent signal modules. **Rejected**: no shared representation means no contrastive/cross-environment benefit for the weaker tasks, incomparable confidences (each module invents its own), and every task re-pays the feature-extraction cost. The status quo is precisely the gap §1.1 documents.
### 4.2 MC-Dropout or Deep Ensembles for Uncertainty
Sample N stochastic forward passes (MC-dropout) or average M models (ensemble) for predictive uncertainty. **Rejected for the inference path**: N× or M× compute breaks the 20 Hz real-time budget on Pi/Hailo and is impractical in WASM; ensembles also multiply the weight-export and witness-hash surface by M. Heteroscedastic single-pass UQ gives a calibrated band in one deterministic pass. (Deep ensembles remain available as an *offline* evaluation oracle in the ADR-145 harness, not as the shipped UQ.)
### 4.3 One Multi-Output Head (single MLP emitting everything)
A single wide head producing all task outputs from `z`. **Rejected**: prevents per-head ablation (§2.6) — you cannot disable count without disabling pose — and forces one loss-weighting compromise. Independent heads are the only structure that satisfies ADR-145's toggle requirement and lets each head own its UQ mechanism (§2.2).
### 4.4 Keep the ABI as libtorch Tensors End-to-End
Use `tch::Tensor` for inference too. **Rejected**: not witnessable (non-bit-stable reductions), not deployable to Hailo/WASM, and contradicts the already-shipping pure-Rust `embedding.rs` inference path. The training/inference split with a tested weight-export is the cost of determinism and edge deployment.
### 4.5 Sample Contrastive Pairs Within a Single Stream (rapid_adapt parity only)
Reuse only the `Temporal` strategy from `rapid_adapt.rs`. **Rejected as the default**: temporally adjacent positives teach *temporal* smoothness, not *environment* invariance. ADR-027's whole premise is cross-room generalization, which requires `CrossEnvironment` positives spanning `calibration_id`s. `Temporal` is retained as a strategy variant for test-time adaptation parity, not as the training default.
---
## 5. Related ADRs
| ADR | Relationship |
|-----|-------------|
| ADR-024 (AETHER Contrastive Embedding) | **Extended**: the `ProjectionHead`/`info_nce_loss`/`CsiAugmenter` become head #7 and the `SelfSupervised` strategy; relocated into `wifi-densepose-nn` |
| ADR-027 (MERIDIAN Cross-Environment) | **Operationalized**: `ContrastiveBatcher::CrossEnvironment` + `L_calib_robust` formalize cross-room invariance; `rapid_adapt.rs` LoRA path consumes the same head taxonomy |
| ADR-023 (Trained DensePose + RuVector) | **Built on**: `WiFiDensePoseModel`'s shared backbone and `kp_head`/`dp_head` become the `RfEncoder` + `PoseHead` |
| ADR-135 (Empty-Room Baseline Calibration) | **Consumed**: `CalibrationId` keys `L_calib_robust`; baseline-subtracted frames are the encoder input; `calibration_id` stamped into every output |
| ADR-136 (Streaming Engine / QualityScored) | **Producer for**: each head's `QualityScored` output is what the fusion engine and frame contracts read |
| ADR-140 (Semantic State Record) | **Producer for**: `MultiTaskOutput` populates the record; `model_version` (self-identifying weight hash) and `calibration_id` satisfy the provenance rule |
| ADR-141 (BFLD Privacy Control Plane) | **Gated by**: identity/gait heads default-off; privacy mode decides which heads run; the privacy decision completes the four-part provenance (evidence + model + calibration + privacy) |
| ADR-145 (Ablation Eval Harness) | **Consumer**: `HeadMask` and exposed `z` provide the toggle + leakage-probe surface |
| ADR-028 (ESP32 Capability Audit / Witness) | **Witness extended**: rows W-39…W-42; `encoder/mod.rs` + `ContrastiveBatcher` hashes added to `source-hashes.txt` |
---
## 6. References
### Production Code (verified to exist)
- `v2/crates/wifi-densepose-train/src/model.rs``WiFiDensePoseModel`, shared backbone, `ModelOutput.features`, `KeypointHead`/`DensePoseHead` (becomes `RfEncoder` + `PoseHead`)
- `v2/crates/wifi-densepose-sensing-server/src/embedding.rs``ProjectionHead`, `EmbeddingExtractor`, `CsiAugmenter::augment_pair`, `info_nce_loss`, LoRA + `flatten/unflatten` (head #7, relocated; pure-Rust f32 ABI proof)
- `v2/crates/wifi-densepose-train/src/rapid_adapt.rs``AdaptationLoss::ContrastiveTTT` ("positive = temporally adjacent, negative = random"), `contrastive_step` (formalized into `ContrastiveBatcher::Temporal`)
- `v2/crates/wifi-densepose-train/src/dataset.rs``DataLoader`/`DataLoaderIter`, `CsiSample`, `CsiDataset` (new `ContrastiveBatcher` added alongside)
- `v2/crates/wifi-densepose-nn/src/tensor.rs``Tensor` enum (`Float1D..FloatND`, pure-Rust `ndarray` f32 ABI)
- `v2/crates/wifi-densepose-nn/src/{densepose.rs,inference.rs,lib.rs}` — inference crate where `encoder/` module is added
- `docs/adr/ADR-024-contrastive-csi-embedding-model.md` — AETHER backbone, projection head, L_AETHER loss
- `docs/adr/ADR-027-cross-environment-domain-generalization.md` — MERIDIAN RapidAdaptation, calibration-frame fine-tuning
- `docs/adr/ADR-135-empty-room-baseline-calibration.md``BaselineCalibration`, source of `CalibrationId`
### External Papers
- Kendall, A. & Gal, Y. (2017). "What Uncertainties Do We Need in Bayesian Deep Learning for Computer Vision?" *NeurIPS*. — Heteroscedastic aleatoric uncertainty via learned σ and Gaussian NLL; basis for the single-pass regression-head UQ in §2.2.
- Guo, C. et al. (2017). "On Calibration of Modern Neural Networks." *ICML*. — Temperature scaling and Expected Calibration Error; basis for the per-head score calibration and the W-41 ECE acceptance test.
- Ganin, Y. et al. (2016). "Domain-Adversarial Training of Neural Networks (DANN)." *JMLR*. — Gradient-reversal domain classifier; the alternative `L_calib_robust` formulation in §2.3, with `calibration_id` as the domain label.
- Chen, T. et al. (2020). "A Simple Framework for Contrastive Learning of Visual Representations (SimCLR)." *ICML*. — NT-Xent / projection-head design reused by ADR-024 and the `ContrastiveBatcher` self-supervised strategy.
- Bardes, A. et al. (2022). "VICReg: Variance-Invariance-Covariance Regularization for Self-Supervised Learning." *ICLR*. — Variance/covariance regularization (the invariance term motivates the group-variance form of `L_calib_robust`).
- IdentiFi (2025) / WhoFi (2025) — WiFi CSI contrastive identity embedding (cited in ADR-024); motivate head #7 and the gait/identity margin-based UQ.
---
## Implementation Status & Integration (2026-05-29)
*Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.*
**Built -- tested building block** (commit `f18b096f2`, issue #850): `RfEmbedding` (pure-Rust f32 ABI), the 7 task heads with per-head uncertainty, the calibration-robustness and triplet losses, and the deterministic `ContrastiveBatcher`. 7 tests.
**Integration glue -- not yet on the live path (this is the model-training phase):** training the shared encoder backbone on real data via Burn/Candle/libtorch; populating `FrameMeta.model_id` / `model_version` from a head registry once models are versioned for deployment.
**Trust contribution:** each head reports *how sure it is*, and the encoder is trained to give the same answer across rooms and calibrations -- honesty about confidence plus cross-environment robustness.
+1
View File
@@ -64,6 +64,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-037](ADR-037-multi-person-pose-detection.md) | Multi-Person Pose Detection from Single ESP32 | Proposed |
| [ADR-042](ADR-042-coherent-human-channel-imaging.md) | Coherent Human Channel Imaging (beyond CSI) | Proposed |
| [ADR-134](ADR-134-csi-to-cir-time-domain-multipath.md) | First-Class Channel Impulse Response (CIR) Support | Proposed |
| [ADR-135](ADR-135-empty-room-baseline-calibration.md) | Empty-Room Baseline Calibration (per-subcarrier Welford statistics) | Proposed |
### Machine learning and training
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Synthetic CSI UDP emitter for testing the calibration CLI end-to-end.
Emits the same 0xC511_0001 frame format the ESP32-S3 firmware produces, so the
`wifi-densepose calibrate` CLI can be exercised without a live ESP32 in the
loop. Generates HT20 frames (52 active subcarriers, 1 antenna) at 20 Hz.
"""
import argparse
import math
import random
import socket
import struct
import time
MAGIC = 0xC511_0001
def build_packet(node_id: int, seq: int, freq_mhz: int, rssi: int,
amps: list[float], phases: list[float]) -> bytes:
n_ant = 1
n_sc = len(amps)
header = struct.pack(
"<I B B B B H I b b I",
MAGIC,
node_id,
n_ant,
n_sc,
0, # reserved
freq_mhz,
seq,
rssi,
-95, # noise_floor
0, # reserved/padding
)
iq = bytearray()
for amp, phase in zip(amps, phases):
i = max(-127, min(127, int(amp * math.cos(phase))))
q = max(-127, min(127, int(amp * math.sin(phase))))
iq.extend(struct.pack("bb", i, q))
return bytes(header) + bytes(iq)
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=5005)
p.add_argument("--duration-s", type=float, default=35.0,
help="emit duration; default 35s so a 30s capture sees the full stream")
p.add_argument("--rate-hz", type=float, default=20.0)
p.add_argument("--n-sc", type=int, default=52)
p.add_argument("--motion-after-s", type=float, default=-1.0,
help="if >=0, inject amplitude jitter after this many seconds")
args = p.parse_args()
random.seed(42)
base_amps = [40.0 + 10.0 * math.cos(k * 0.2) for k in range(args.n_sc)]
base_phases = [0.5 * math.sin(k * 0.3) for k in range(args.n_sc)]
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
period = 1.0 / args.rate_hz
started = time.time()
seq = 0
print(f"emitting CSI to {args.host}:{args.port} at {args.rate_hz} Hz, "
f"{args.n_sc} sc/frame, duration {args.duration_s}s", flush=True)
while True:
elapsed = time.time() - started
if elapsed >= args.duration_s:
break
amps = list(base_amps)
phases = list(base_phases)
# Mild stationary jitter (~0.5 amplitude units RMS)
for k in range(args.n_sc):
amps[k] += random.gauss(0.0, 0.5)
phases[k] += random.gauss(0.0, 0.01)
if args.motion_after_s >= 0 and elapsed >= args.motion_after_s:
for k in range(args.n_sc):
amps[k] += random.gauss(0.0, 8.0)
phases[k] += random.gauss(0.0, 0.3)
pkt = build_packet(node_id=42, seq=seq, freq_mhz=2412, rssi=-55,
amps=amps, phases=phases)
sock.sendto(pkt, (args.host, args.port))
seq += 1
time.sleep(period)
print(f"emitted {seq} frames", flush=True)
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# verify-calibration-proof.sh — calibration deterministic proof verification (ADR-135)
#
# Builds the calibration_proof_runner Rust binary, computes the canonical SHA-256
# hash of the CalibrationRecorder's output on the synthetic reference signal
# (xorshift32 seed=42, HT20, 600 stationary frames), and compares it against
# the committed expected_calibration_features.sha256.
#
# Usage:
# bash scripts/verify-calibration-proof.sh
#
# Exit codes:
# 0 — VERDICT: PASS (hash matches)
# 1 — VERDICT: FAIL (hash mismatch or build error)
# 2 — BLOCKED (calibration module not yet implemented — placeholder hash detected)
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
HASH_FILE="archive/v1/data/proof/expected_calibration_features.sha256"
# Check for placeholder — module not yet implemented
if grep -q "PLACEHOLDER_REGENERATE" "$HASH_FILE" 2>/dev/null; then
echo "BLOCKED: calibration proof hash is a placeholder."
echo "The calibration module (ADR-135) is not yet implemented."
echo ""
echo "After the implementation lands, regenerate the hash with:"
echo " cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner \\"
echo " --release --no-default-features -- --generate-hash \\"
echo " > ../archive/v1/data/proof/expected_calibration_features.sha256"
exit 2
fi
echo "Building calibration_proof_runner..."
cargo build -p wifi-densepose-signal --bin calibration_proof_runner --release --no-default-features \
--manifest-path v2/Cargo.toml
echo "Computing calibration hash..."
ACTUAL="$(./v2/target/release/calibration_proof_runner --generate-hash)"
EXPECTED="$(awk '{print $1; exit}' "$HASH_FILE")"
if [ "$ACTUAL" = "$EXPECTED" ]; then
echo "VERDICT: PASS (calibration hash matches)"
exit 0
else
echo "VERDICT: FAIL"
echo "expected: $EXPECTED"
echo "actual: $ACTUAL"
exit 1
fi
Generated
+31
View File
@@ -10589,6 +10589,8 @@ dependencies = [
"console 0.16.3",
"csv",
"indicatif",
"ndarray 0.17.2",
"num-complex",
"predicates",
"serde",
"serde_json",
@@ -10599,7 +10601,9 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"wifi-densepose-core",
"wifi-densepose-mat",
"wifi-densepose-signal",
]
[[package]]
@@ -10607,6 +10611,7 @@ name = "wifi-densepose-core"
version = "0.3.0"
dependencies = [
"async-trait",
"blake3",
"chrono",
"ndarray 0.17.2",
"num-complex",
@@ -10647,6 +10652,20 @@ dependencies = [
"uuid",
]
[[package]]
name = "wifi-densepose-engine"
version = "0.3.0"
dependencies = [
"blake3",
"criterion",
"wifi-densepose-bfld",
"wifi-densepose-core",
"wifi-densepose-geo",
"wifi-densepose-ruvector",
"wifi-densepose-signal",
"wifi-densepose-worldgraph",
]
[[package]]
name = "wifi-densepose-geo"
version = "0.1.0"
@@ -10821,6 +10840,7 @@ dependencies = [
"serde_json",
"sha2",
"thiserror 2.0.18",
"uuid",
"wifi-densepose-core",
"wifi-densepose-ruvector",
]
@@ -10903,6 +10923,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "wifi-densepose-worldgraph"
version = "0.3.0"
dependencies = [
"petgraph",
"serde",
"serde_json",
"thiserror 2.0.18",
"wifi-densepose-geo",
]
[[package]]
name = "winapi"
version = "0.3.9"
+2
View File
@@ -26,6 +26,8 @@ members = [
"crates/wifi-densepose-desktop",
"crates/wifi-densepose-pointcloud",
"crates/wifi-densepose-geo",
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
"crates/nvsim",
"crates/nvsim-server",
"crates/homecore", # ADR-127 — HOMECORE state machine
+1 -1
View File
@@ -2,7 +2,7 @@
name = "wifi-densepose-bfld"
description = "BFLD — Beamforming Feedback Layer for Detection. Privacy-gated WiFi BFI sensing primitives. See ADR-118."
readme = "README.md"
version.workspace = true
version = "0.3.1" # ADR-141: privacy control plane (modes/actions/attestation)
edition.workspace = true
authors.workspace = true
license.workspace = true
+4
View File
@@ -38,6 +38,7 @@ pub mod pipeline;
pub mod pipeline_handle;
#[cfg(feature = "std")]
pub mod privacy_gate;
pub mod privacy_mode;
#[cfg(feature = "mqtt")]
pub mod rumqttc_publisher;
pub mod signature_hasher;
@@ -75,6 +76,9 @@ pub use pipeline::{BfldConfig, BfldPipeline};
pub use pipeline_handle::{BfldPipelineHandle, PipelineInput};
#[cfg(feature = "std")]
pub use privacy_gate::PrivacyGate;
pub use privacy_mode::{PrivacyAction, PrivacyAttestationProof, PrivacyMode};
#[cfg(feature = "std")]
pub use privacy_mode::PrivacyModeRegistry;
pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN};
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
@@ -0,0 +1,296 @@
//! ADR-141 — BFLD privacy **control plane**: named modes, enforced actions,
//! and a hash-chained runtime attestation.
//!
//! The existing [`PrivacyClass`](crate::PrivacyClass) (ADR-120, 4 byte-level
//! classes) describes *what a frame contains*. This module adds the *policy*
//! layer on top: a [`PrivacyMode`] (the operator-facing posture) maps to a
//! target [`PrivacyClass`] plus a set of enforced [`PrivacyAction`]s, and a
//! [`PrivacyModeRegistry`] makes the active mode the single source of truth that
//! the privacy gate and the ADR-139/140 layers consult. Every mode change emits
//! a [`PrivacyAttestationProof`] that is BLAKE3 hash-chained to the previous one
//! (ADR-010 witness-chain pattern), so an auditor can verify the privacy posture
//! was continuous and untampered.
use crate::PrivacyClass;
/// Operator-facing privacy posture (ADR-141 §2). Layered over the 4-class
/// [`PrivacyClass`]; selecting a mode pins the target class and enforced actions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivacyMode {
/// Local research: raw BFI retained, full fidelity. Maps to `Raw`.
RawResearch,
/// Home default: room-level occupancy, no identity. Maps to `Anonymous`.
PrivateHome,
/// Multi-tenant anonymous: aggregate only, multi-seed. Maps to `Anonymous`.
EnterpriseAnonymous,
/// Care deployment with explicit consent: identity-derived fields allowed
/// (Soul Signature enabled). Maps to `Derived`.
CareWithConsent,
/// Regulated: no identity surface whatsoever. Maps to `Restricted`.
StrictNoIdentity,
}
/// A concrete enforcement action a mode may require (ADR-141 §2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PrivacyAction {
/// No restriction beyond the class minimum.
Allow = 0,
/// Strip identity-derived fields (embedding, risk score, hash).
SuppressIdentity = 1,
/// Reduce angular/spatial resolution before emission.
ReduceResolution = 2,
/// Never retain or emit raw BFI.
DropRaw = 3,
/// Emit only aggregate counts, never per-entity records.
AggregateOnly = 4,
}
impl PrivacyAction {
/// All actions in canonical (bit) order — used to encode an action set.
pub const ALL: [PrivacyAction; 5] = [
PrivacyAction::Allow,
PrivacyAction::SuppressIdentity,
PrivacyAction::ReduceResolution,
PrivacyAction::DropRaw,
PrivacyAction::AggregateOnly,
];
}
impl PrivacyMode {
/// The byte-level [`PrivacyClass`] this mode pins (ADR-141 §2).
#[must_use]
pub const fn target_class(self) -> PrivacyClass {
match self {
Self::RawResearch => PrivacyClass::Raw,
Self::PrivateHome | Self::EnterpriseAnonymous => PrivacyClass::Anonymous,
Self::CareWithConsent => PrivacyClass::Derived,
Self::StrictNoIdentity => PrivacyClass::Restricted,
}
}
/// Whether Soul-Signature (identity-derived) processing is permitted.
#[must_use]
pub const fn soul_signature_enabled(self) -> bool {
matches!(self, Self::RawResearch | Self::CareWithConsent)
}
/// The actions this mode enforces, encoded as a bitset over
/// [`PrivacyAction`] (bit `i` set ⇒ `PrivacyAction::ALL[i]` enforced).
#[must_use]
pub const fn action_bits(self) -> u8 {
// Helper bit positions.
const SUP: u8 = 1 << 1; // SuppressIdentity
const RED: u8 = 1 << 2; // ReduceResolution
const DROP: u8 = 1 << 3; // DropRaw
const AGG: u8 = 1 << 4; // AggregateOnly
match self {
Self::RawResearch => 1, // Allow only
Self::PrivateHome => SUP | DROP,
Self::EnterpriseAnonymous => SUP | DROP | AGG,
Self::CareWithConsent => 1, // Allow (consent granted)
Self::StrictNoIdentity => SUP | RED | DROP | AGG,
}
}
/// Whether `action` is enforced under this mode.
#[must_use]
pub fn enforces(self, action: PrivacyAction) -> bool {
let bit = 1u8 << (action as u8);
self.action_bits() & bit != 0
}
/// Stable mode byte for attestation hashing.
#[must_use]
pub const fn as_u8(self) -> u8 {
match self {
Self::RawResearch => 0,
Self::PrivateHome => 1,
Self::EnterpriseAnonymous => 2,
Self::CareWithConsent => 3,
Self::StrictNoIdentity => 4,
}
}
}
/// A hash-chained attestation that a given mode was active (ADR-141 §2 / ADR-010).
///
/// `hash = BLAKE3(prev_hash ‖ mode_byte ‖ action_bits ‖ class_byte)`. Chaining
/// `prev_hash` gives cryptographic continuity: an auditor replays the chain and
/// any gap or tamper breaks the hash linkage.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PrivacyAttestationProof {
/// Active mode at attestation time.
pub mode: PrivacyMode,
/// Enforced-action bitset (mirrors [`PrivacyMode::action_bits`]).
pub action_bits: u8,
/// Target class byte.
pub class: u8,
/// Hash of the previous proof (`[0; 32]` for the genesis proof).
pub prev_hash: [u8; 32],
/// BLAKE3 of `(prev_hash ‖ mode ‖ action_bits ‖ class)`.
pub hash: [u8; 32],
}
impl PrivacyAttestationProof {
fn compute(mode: PrivacyMode, prev_hash: [u8; 32]) -> Self {
let action_bits = mode.action_bits();
let class = mode.target_class().as_u8();
let mut hasher = blake3::Hasher::new();
hasher.update(&prev_hash);
hasher.update(&[mode.as_u8(), action_bits, class]);
let hash = *hasher.finalize().as_bytes();
Self { mode, action_bits, class, prev_hash, hash }
}
}
/// The active-mode source of truth (ADR-141 §2). The privacy gate and the
/// ADR-139/140 layers consult this; every mode change appends a hash-chained
/// attestation to the audit log.
///
/// `std`-gated because the audit log is heap-allocated (`Vec`), matching the
/// crate convention (the ESP32-S3 no_std self-only path uses a fixed-mode
/// posture without a growable log; see `frame.rs`).
#[cfg(feature = "std")]
#[derive(Debug, Clone)]
pub struct PrivacyModeRegistry {
active: PrivacyMode,
audit_log: Vec<PrivacyAttestationProof>,
}
#[cfg(feature = "std")]
impl PrivacyModeRegistry {
/// Create a registry with an initial mode (emits the genesis attestation).
#[must_use]
pub fn new(initial: PrivacyMode) -> Self {
let genesis = PrivacyAttestationProof::compute(initial, [0u8; 32]);
Self { active: initial, audit_log: vec![genesis] }
}
/// The currently active mode.
#[must_use]
pub fn active_mode(&self) -> PrivacyMode {
self.active
}
/// The class the active mode pins.
#[must_use]
pub fn active_class(&self) -> PrivacyClass {
self.active.target_class()
}
/// Whether the active mode enforces `action`.
#[must_use]
pub fn is_action_enforced(&self, action: PrivacyAction) -> bool {
self.active.enforces(action)
}
/// Switch the active mode, appending a hash-chained attestation.
pub fn set_mode(&mut self, mode: PrivacyMode) -> &PrivacyAttestationProof {
let prev = self.audit_log.last().map(|p| p.hash).unwrap_or([0u8; 32]);
self.active = mode;
self.audit_log.push(PrivacyAttestationProof::compute(mode, prev));
self.audit_log.last().unwrap()
}
/// The latest attestation proof (for HA/Matter diagnostics).
#[must_use]
pub fn latest_proof(&self) -> &PrivacyAttestationProof {
self.audit_log.last().expect("registry always has a genesis proof")
}
/// The full attestation chain.
#[must_use]
pub fn audit_log(&self) -> &[PrivacyAttestationProof] {
&self.audit_log
}
/// Verify the hash chain is continuous and untampered: each proof's
/// `prev_hash` must equal the prior proof's `hash`, and every proof must
/// recompute to its stored `hash`.
#[must_use]
pub fn verify_chain(&self) -> bool {
let mut expected_prev = [0u8; 32];
for proof in &self.audit_log {
if proof.prev_hash != expected_prev {
return false;
}
let recomputed = PrivacyAttestationProof::compute(proof.mode, proof.prev_hash);
if recomputed.hash != proof.hash {
return false;
}
expected_prev = proof.hash;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_to_class_mapping() {
assert_eq!(PrivacyMode::RawResearch.target_class(), PrivacyClass::Raw);
assert_eq!(PrivacyMode::PrivateHome.target_class(), PrivacyClass::Anonymous);
assert_eq!(PrivacyMode::EnterpriseAnonymous.target_class(), PrivacyClass::Anonymous);
assert_eq!(PrivacyMode::CareWithConsent.target_class(), PrivacyClass::Derived);
assert_eq!(PrivacyMode::StrictNoIdentity.target_class(), PrivacyClass::Restricted);
}
#[test]
fn soul_signature_only_in_raw_and_care() {
assert!(PrivacyMode::RawResearch.soul_signature_enabled());
assert!(PrivacyMode::CareWithConsent.soul_signature_enabled());
assert!(!PrivacyMode::PrivateHome.soul_signature_enabled());
assert!(!PrivacyMode::StrictNoIdentity.soul_signature_enabled());
}
#[test]
fn action_enforcement() {
assert!(PrivacyMode::StrictNoIdentity.enforces(PrivacyAction::SuppressIdentity));
assert!(PrivacyMode::StrictNoIdentity.enforces(PrivacyAction::AggregateOnly));
assert!(PrivacyMode::StrictNoIdentity.enforces(PrivacyAction::ReduceResolution));
assert!(!PrivacyMode::RawResearch.enforces(PrivacyAction::SuppressIdentity));
assert!(PrivacyMode::PrivateHome.enforces(PrivacyAction::DropRaw));
assert!(!PrivacyMode::PrivateHome.enforces(PrivacyAction::AggregateOnly));
}
#[cfg(feature = "std")]
#[test]
fn registry_tracks_active_and_actions() {
let mut reg = PrivacyModeRegistry::new(PrivacyMode::PrivateHome);
assert_eq!(reg.active_class(), PrivacyClass::Anonymous);
assert!(reg.is_action_enforced(PrivacyAction::SuppressIdentity));
reg.set_mode(PrivacyMode::StrictNoIdentity);
assert_eq!(reg.active_class(), PrivacyClass::Restricted);
assert!(reg.is_action_enforced(PrivacyAction::AggregateOnly));
}
#[cfg(feature = "std")]
#[test]
fn attestation_chain_is_continuous_and_verifiable() {
let mut reg = PrivacyModeRegistry::new(PrivacyMode::RawResearch);
let g = *reg.latest_proof();
assert_eq!(g.prev_hash, [0u8; 32], "genesis prev is zero");
let p1 = *reg.set_mode(PrivacyMode::PrivateHome);
assert_eq!(p1.prev_hash, g.hash, "chain links to genesis");
let p2 = *reg.set_mode(PrivacyMode::StrictNoIdentity);
assert_eq!(p2.prev_hash, p1.hash, "chain links forward");
assert_eq!(reg.audit_log().len(), 3);
assert!(reg.verify_chain(), "untampered chain verifies");
}
#[cfg(feature = "std")]
#[test]
fn tampered_chain_fails_verification() {
let mut reg = PrivacyModeRegistry::new(PrivacyMode::RawResearch);
reg.set_mode(PrivacyMode::PrivateHome);
reg.set_mode(PrivacyMode::StrictNoIdentity);
// Tamper: forge the middle proof's recorded mode without rehashing.
reg.audit_log[1].mode = PrivacyMode::CareWithConsent;
assert!(!reg.verify_chain(), "tamper breaks the hash linkage");
}
}
+6
View File
@@ -22,6 +22,12 @@ mat = []
[dependencies]
# Internal crates
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat" }
wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
# Linear algebra / complex numbers (used by calibrate.rs to build CsiFrame)
ndarray = { workspace = true }
num-complex = { workspace = true }
# CLI framework
clap = { version = "4.4", features = ["derive", "env", "cargo"] }
@@ -0,0 +1,458 @@
//! `wifi-densepose calibrate` — empty-room baseline calibration subcommand.
//!
//! Reads CSI frames from a UDP socket (ESP32 0xC511_0001 wire format), feeds
//! them through [`wifi_densepose_signal::CalibrationRecorder`], prints a
//! real-time deviation banner (ADR-135 §risk 1), and serialises the finished
//! [`wifi_densepose_signal::BaselineCalibration`] to disk in the compact
//! little-endian binary format defined in ADR-135 §2.4.
//!
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
//!
//! Offset Size Field
//! ────── ──── ─────────────────────────────────────────────────────────────
//! 0 4 Magic: 0xC511_0001 (LE u32)
//! 4 1 node_id (u8)
//! 5 1 n_antennas (u8)
//! 6 1 n_subcarriers (u8)
//! 7 1 (reserved)
//! 8 2 freq_mhz (LE u16)
//! 10 4 sequence (LE u32)
//! 14 1 rssi (i8)
//! 15 1 noise_floor (i8)
//! 16 4 (reserved / padding)
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
//!
//! This parser mirrors `parse_esp32_frame` in
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
use anyhow::{bail, Result};
use clap::Args;
use ndarray::Array2;
use num_complex::Complex64;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use wifi_densepose_core::types::{
AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand, Timestamp,
};
use wifi_densepose_signal::{
BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Arguments
// ---------------------------------------------------------------------------
/// Arguments for the `calibrate` subcommand.
#[derive(Args, Debug, Clone)]
pub struct CalibrateArgs {
/// UDP port to listen on for CSI frames from the ESP32.
/// Must match the target-port written into NVS by provision.py (default 5005).
#[arg(long, default_value_t = 5005)]
pub udp_port: u16,
/// Bind address for the UDP socket.
/// Default 0.0.0.0 receives from any device on the LAN.
#[arg(long, default_value = "0.0.0.0")]
pub bind: String,
/// Calibration duration in seconds.
/// ADR-135 default is 30 s at 20 Hz = 600 frames.
/// Minimum 10; values above 300 emit a warning.
#[arg(long, default_value_t = 30)]
pub duration_s: u32,
/// Output path for the binary baseline file (ADR-135 §2.4 format).
#[arg(long, default_value = "./baseline.bin")]
pub output: String,
/// PHY tier matching the ESP32 configuration.
/// Valid: ht20 / ht40 / he20 / he40.
#[arg(long, default_value = "ht20")]
pub tier: String,
/// Print a deviation banner to stderr every N frames during capture.
/// 0 disables banners. Default 20 = once per second at 20 Hz.
#[arg(long, default_value_t = 20)]
pub banner_every: u32,
/// Abort if the per-frame amplitude z-score median exceeds this value
/// for 20 consecutive banner intervals. 0.0 disables the abort guard.
#[arg(long, default_value_t = 2.0)]
pub abort_z_threshold: f32,
/// Override the ADR-135 minimum frame count for the tier. 0 = use the
/// tier default (600 for HT20 at 20 Hz = 30 s). Useful for debugging or
/// low-traffic environments where the firmware emits CSI far below 20 Hz.
/// Production deployments should leave this at 0.
#[arg(long, default_value_t = 0)]
pub min_frames: u32,
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Maximum UDP receive buffer. HT20 CSI frame is well under 1 500 bytes.
const RECV_BUF: usize = 2048;
/// Number of banner intervals in the high-z abort sliding window.
const ABORT_WINDOW_INTERVALS: u32 = 20;
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Execute the `calibrate` subcommand (async).
pub async fn execute(args: CalibrateArgs) -> Result<()> {
validate_args(&args)?;
let mut config = tier_config(&args.tier);
if args.min_frames > 0 {
config.min_frames = args.min_frames;
eprintln!(
"[calibrate] WARN: --min-frames={} overrides ADR-135 tier default ({} for {}). \
This relaxes the phase-concentration guarantee; do not use in production.",
args.min_frames, tier_config(&args.tier).min_frames, args.tier
);
}
let target_frames = config.min_frames as usize;
let addr = format!("{}:{}", args.bind, args.udp_port);
let socket = UdpSocket::bind(&addr).await
.map_err(|e| anyhow::anyhow!("cannot bind UDP socket on {addr}: {e}"))?;
eprintln!("[calibrate] listening on udp://{addr}");
eprintln!(
"[calibrate] capturing {} frames (~{} s, tier={}) — ensure room is empty",
target_frames, args.duration_s, args.tier
);
let mut recorder = CalibrationRecorder::new(config);
let mut buf = vec![0u8; RECV_BUF];
let mut high_z_count: u32 = 0;
let deadline = Instant::now() + Duration::from_secs(args.duration_s as u64);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
let timeout = remaining.min(Duration::from_millis(500));
let recv = tokio::time::timeout(timeout, socket.recv(&mut buf)).await;
let n = match recv {
Ok(Ok(n)) => n,
Ok(Err(e)) => { eprintln!("[calibrate] recv error: {e}"); continue; }
Err(_) => continue, // timeout — recheck deadline
};
let Some(csi_frame) = parse_csi_packet(&buf[..n], &args.tier) else {
continue;
};
let score: CalibrationDeviationScore = match recorder.record(&csi_frame) {
Ok(s) => s,
Err(e) => { eprintln!("[calibrate] WARN frame skipped: {e}"); continue; }
};
let frames = recorder.frames_recorded() as usize;
if args.banner_every > 0 && (frames as u32) % args.banner_every == 0 {
print_banner(frames, target_frames, &score);
if args.abort_z_threshold > 0.0 && score.amplitude_z_median > args.abort_z_threshold {
high_z_count += 1;
if high_z_count >= ABORT_WINDOW_INTERVALS {
bail!(
"aborted: amplitude_z_median={:.2} exceeded threshold={:.2} for {} \
consecutive banner intervals ensure the room is empty and retry",
score.amplitude_z_median, args.abort_z_threshold, high_z_count
);
}
} else {
high_z_count = 0;
}
}
if frames >= target_frames {
break;
}
}
finalise_and_save(recorder, &args.output)
}
// ---------------------------------------------------------------------------
// Banner printer
// ---------------------------------------------------------------------------
fn print_banner(frames: usize, target: usize, score: &CalibrationDeviationScore) {
let motion_str = if score.motion_flagged {
"YES \u{2190} operator should be still"
} else {
"no"
};
eprintln!(
"[calibrate] {}/{} frames | z_med={:.2} z_max={:.2} | motion: {}",
frames, target, score.amplitude_z_median, score.amplitude_z_max, motion_str
);
}
// ---------------------------------------------------------------------------
// Finalise + persist
// ---------------------------------------------------------------------------
fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()> {
let frames = recorder.frames_recorded();
eprintln!("[calibrate] finalising baseline from {frames} frames…");
let baseline: BaselineCalibration = recorder
.finalize()
.map_err(|e| anyhow::anyhow!("calibration failed: {e}"))?;
let bytes = baseline.to_bytes();
std::fs::write(output, &bytes)
.map_err(|e| anyhow::anyhow!("cannot write {output}: {e}"))?;
eprintln!(
"[calibrate] baseline saved to {output} ({} bytes)",
bytes.len()
);
eprintln!(
"[calibrate] summary: frames={} tier={:?} subcarriers={}",
baseline.frame_count,
baseline.tier,
baseline.subcarriers.len(),
);
Ok(())
}
// ---------------------------------------------------------------------------
// Tier helper
// ---------------------------------------------------------------------------
fn tier_config(tier: &str) -> CalibrationConfig {
match tier.to_ascii_lowercase().as_str() {
"ht40" => CalibrationConfig::ht40(),
"he20" => CalibrationConfig::he20(),
"he40" => CalibrationConfig::he40(),
_ => CalibrationConfig::ht20(), // ht20 or unknown → safe default
}
}
// ---------------------------------------------------------------------------
// Local UDP packet parser (option b)
//
// Mirrors parse_esp32_frame in wifi-densepose-sensing-server/src/csi.rs.
// Magic 0xC511_0001, 20-byte header, IQ bytes follow.
// ---------------------------------------------------------------------------
/// Parse a single UDP datagram and return a `CsiFrame` ready for
/// `CalibrationRecorder::record()`. Returns `None` on any parse failure.
fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
if buf.len() < 20 {
return None;
}
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0xC511_0001 {
return None;
}
let node_id = buf[4];
let n_antennas = buf[5] as usize;
let n_subcarriers = buf[6] as usize;
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi = buf[14] as i8;
let noise_floor = buf[15] as i8;
let n_pairs = n_antennas * n_subcarriers;
let iq_start = 20usize;
if buf.len() < iq_start + n_pairs * 2 {
return None;
}
// Build an ndarray Array2<Complex64> shaped [n_antennas, n_subcarriers].
let mut data = Array2::<Complex64>::zeros((n_antennas.max(1), n_subcarriers.max(1)));
for s in 0..n_antennas {
for k in 0..n_subcarriers {
let idx = s * n_subcarriers + k;
let i_val = buf[iq_start + idx * 2] as i8 as f64;
let q_val = buf[iq_start + idx * 2 + 1] as i8 as f64;
data[[s, k]] = Complex64::new(i_val, q_val);
}
}
let band = if freq_mhz >= 5000 {
FrequencyBand::Band5GHz
} else {
FrequencyBand::Band2_4GHz
};
let bw = tier_to_bw_mhz(tier);
let mut meta = CsiMetadata::new(
DeviceId::new(format!("esp32-node{}", node_id)),
band,
freq_mhz_to_channel(freq_mhz),
);
meta.bandwidth_mhz = bw;
meta.rssi_dbm = rssi;
meta.noise_floor_dbm = noise_floor;
meta.antenna_config = AntennaConfig {
tx_antennas: 1,
rx_antennas: n_antennas as u8,
spacing_mm: None,
};
meta.timestamp = Timestamp::now();
Some(CsiFrame::new(meta, data))
}
/// Map a tier string to a bandwidth in MHz.
fn tier_to_bw_mhz(tier: &str) -> u16 {
match tier.to_ascii_lowercase().as_str() {
"ht40" | "he40" => 40,
_ => 20,
}
}
/// Rough 802.11 channel from centre frequency.
fn freq_mhz_to_channel(freq_mhz: u16) -> u8 {
// 2.4 GHz: ch = (freq - 2407) / 5
if freq_mhz < 3000 {
((freq_mhz.saturating_sub(2407)) / 5) as u8
} else {
// 5 GHz: ch = (freq - 5000) / 5
((freq_mhz.saturating_sub(5000)) / 5) as u8
}
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
fn validate_args(args: &CalibrateArgs) -> Result<()> {
if args.duration_s < 10 {
bail!(
"--duration-s must be at least 10 s (got {}). \
Fewer frames produce unreliable phase-concentration estimates (ADR-135 §2.3).",
args.duration_s
);
}
if args.duration_s > 300 {
eprintln!(
"[calibrate] WARN: --duration-s={} exceeds 300 s; this is unusual.",
args.duration_s
);
}
let valid = ["ht20", "ht40", "he20", "he40"];
if !valid.contains(&args.tier.to_ascii_lowercase().as_str()) {
bail!(
"--tier must be one of {:?} (got {:?})",
valid, args.tier
);
}
Ok(())
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_args_min_duration() {
let mut args = default_args();
args.duration_s = 5;
assert!(validate_args(&args).is_err());
}
#[test]
fn test_validate_args_ok() {
let args = default_args();
assert!(validate_args(&args).is_ok());
}
#[test]
fn test_validate_args_bad_tier() {
let mut args = default_args();
args.tier = "ht80".into();
assert!(validate_args(&args).is_err());
}
#[test]
fn test_tier_config_ht20() {
let cfg = tier_config("ht20");
assert_eq!(cfg.num_active, 52);
}
#[test]
fn test_tier_config_ht40() {
let cfg = tier_config("ht40");
assert_eq!(cfg.num_active, 114);
}
#[test]
fn test_tier_config_he20() {
let cfg = tier_config("he20");
assert_eq!(cfg.num_active, 242);
}
#[test]
fn test_parse_csi_packet_bad_magic() {
let buf = vec![0u8; 32];
assert!(parse_csi_packet(&buf, "ht20").is_none());
}
#[test]
fn test_parse_csi_packet_too_short() {
let buf = vec![0u8; 10];
assert!(parse_csi_packet(&buf, "ht20").is_none());
}
#[test]
fn test_parse_csi_packet_valid() {
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
// Magic 0xC511_0001 LE
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
buf[5] = 1; // n_antennas
buf[6] = 2; // n_subcarriers
// freq_mhz = 2437 (channel 6)
buf[8] = 0x85; buf[9] = 0x09;
// IQ pairs at offset 20: (10, 20), (5, 15)
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
let frame = parse_csi_packet(&buf, "ht20");
assert!(frame.is_some());
let f = frame.unwrap();
assert_eq!(f.num_spatial_streams(), 1);
assert_eq!(f.num_subcarriers(), 2);
}
#[test]
fn test_freq_to_channel_24ghz() {
assert_eq!(freq_mhz_to_channel(2437), 6);
}
#[test]
fn test_freq_to_channel_5ghz() {
assert_eq!(freq_mhz_to_channel(5180), 36);
}
fn default_args() -> CalibrateArgs {
CalibrateArgs {
udp_port: 5005,
bind: "0.0.0.0".into(),
duration_s: 30,
output: "./baseline.bin".into(),
tier: "ht20".into(),
banner_every: 20,
abort_z_threshold: 2.0,
}
}
}
+6
View File
@@ -26,6 +26,7 @@
use clap::{Parser, Subcommand};
pub mod calibrate;
pub mod mat;
/// WiFi-DensePose Command Line Interface
@@ -46,6 +47,11 @@ pub struct Cli {
/// Top-level commands
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Empty-room baseline calibration (ADR-135).
/// Captures CSI frames via UDP and saves a per-subcarrier statistical
/// baseline used for real-time motion z-scoring and CIR reference.
Calibrate(calibrate::CalibrateArgs),
/// Mass Casualty Assessment Tool commands
#[command(subcommand)]
Mat(mat::MatCommand),
+3
View File
@@ -18,6 +18,9 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Calibrate(args) => {
wifi_densepose_cli::calibrate::execute(args).await?;
}
Commands::Mat(mat_cmd) => {
wifi_densepose_cli::mat::execute(mat_cmd).await?;
}
+4 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "wifi-densepose-core"
description = "Core types, traits, and utilities for WiFi-DensePose pose estimation system"
version.workspace = true
version = "0.3.1" # ADR-136: ComplexSample/CanonicalFrame/provenance + blake3
edition.workspace = true
authors.workspace = true
license.workspace = true
@@ -38,6 +38,9 @@ chrono = { version = "0.4", features = ["serde"] }
# UUID for unique identifiers
uuid = { version = "1.6", features = ["v4", "serde"] }
# BLAKE3 witness hashing (ADR-136 CanonicalFrame; no_std-safe like wifi-densepose-bfld)
blake3 = { version = "1.5", default-features = false }
[dev-dependencies]
serde_json.workspace = true
proptest.workspace = true
+6 -4
View File
@@ -53,11 +53,13 @@ pub mod utils;
// Re-export commonly used types at the crate root
pub use error::{CoreError, CoreResult, InferenceError, SignalError, StorageError};
pub use traits::{DataStore, NeuralInference, SignalProcessor};
pub use traits::{CanonicalFrame, DataStore, NeuralInference, SignalProcessor};
pub use types::{
AntennaConfig,
// Bounding box
BoundingBox,
// ADR-136 canonical complex-sample contract
ComplexSample,
// Common types
Confidence,
// CSI types
@@ -99,10 +101,10 @@ pub const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.5;
pub mod prelude {
pub use crate::error::{CoreError, CoreResult};
pub use crate::traits::{DataStore, NeuralInference, SignalProcessor};
pub use crate::traits::{CanonicalFrame, DataStore, NeuralInference, SignalProcessor};
pub use crate::types::{
AntennaConfig, BoundingBox, Confidence, CsiFrame, CsiMetadata, DeviceId, FrameId,
FrequencyBand, Keypoint, KeypointType, PersonPose, PoseEstimate, ProcessedSignal,
AntennaConfig, BoundingBox, ComplexSample, Confidence, CsiFrame, CsiMetadata, DeviceId,
FrameId, FrequencyBand, Keypoint, KeypointType, PersonPose, PoseEstimate, ProcessedSignal,
SignalFeatures, Timestamp,
};
}
@@ -21,6 +21,46 @@
use crate::error::{CoreResult, InferenceError, SignalError, StorageError};
use crate::types::{CsiFrame, FrameId, PoseEstimate, ProcessedSignal, Timestamp};
/// ADR-136 §2.5 — deterministic, architecture-independent frame serialisation.
///
/// Every frame type that crosses a [`Stage`](https://example.invalid) boundary
/// or is recorded/replayed (`homecore-recorder`) implements `CanonicalFrame`.
/// The encoding is stable across architectures (little-endian per ADR-136 §2.3,
/// via [`ComplexSample::to_le_bytes`](crate::types::ComplexSample::to_le_bytes))
/// and across runs (fixed field order), so a BLAKE3 of the bytes is a witness
/// hash compatible with the ADR-028 proof chain and the ADR-119
/// `signature_hasher` precedent.
///
/// # Determinism contract
///
/// Feeding a recorded `Vec<CsiFrame>` through the stage chain twice MUST yield
/// byte-identical output streams, verified by equal [`Self::witness_hash`].
pub trait CanonicalFrame {
/// Deterministic, architecture-independent encoding of this frame.
///
/// Rules (ADR-136 §2.5): fixed-width little-endian fields in declared order;
/// complex payload as `ComplexSample::to_le_bytes()` in stream-major order;
/// raw IEEE-754 LE only (no text formatting of floats).
fn to_canonical_bytes(&self) -> alloc_vec::Vec<u8>;
/// BLAKE3-256 of [`Self::to_canonical_bytes`] — the witness hash (ADR-028).
fn witness_hash(&self) -> [u8; 32] {
blake3::hash(&self.to_canonical_bytes()).into()
}
}
// `Vec` alias that works under both `std` and `no_std + alloc` (core is
// `#![cfg_attr(not(feature = "std"), no_std)]`). Keeps `CanonicalFrame` usable
// on the Xtensa/ESP32 target referenced by ADR-136 §2.3.
#[cfg(feature = "std")]
mod alloc_vec {
pub use std::vec::Vec;
}
#[cfg(not(feature = "std"))]
mod alloc_vec {
pub use alloc::vec::Vec;
}
/// Configuration for signal processing.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
+300
View File
@@ -22,6 +22,105 @@ use serde::{Deserialize, Serialize};
use crate::error::{CoreError, CoreResult};
use crate::{DEFAULT_CONFIDENCE_THRESHOLD, MAX_KEYPOINTS};
// =============================================================================
// ADR-136 — Canonical complex sample contract
// =============================================================================
/// Canonical complex sample for all RuView frame contracts (CSI, CIR, Doppler).
///
/// Wraps [`num_complex::Complex64`]. The `serde` impl and [`Self::to_le_bytes`]
/// write `(re, im)` as two little-endian `f64`, matching the ADR-119 endianness
/// guarantee so x86_64 (ruvultra), aarch64 (cognitum-v0), and Xtensa (ESP32-S3)
/// produce bit-identical bytes. Downstream `f32` paths (CIR taps, ADR-134;
/// NN inference, ADR-146) narrow on demand via [`Self::as_complex32`].
///
/// This is the *contract* representation used at stage boundaries and by the
/// deterministic [`CanonicalFrame`](crate::traits::CanonicalFrame) serialiser.
/// `CsiFrame.data` remains `Array2<Complex64>` for ndarray-native math.
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(transparent)]
pub struct ComplexSample(pub Complex64);
impl ComplexSample {
/// Construct from real/imaginary `f64` parts.
#[must_use]
pub fn new(re: f64, im: f64) -> Self {
Self(Complex64::new(re, im))
}
/// Magnitude `|z|`.
#[must_use]
pub fn norm(&self) -> f64 {
self.0.norm()
}
/// Phase angle `arg(z)` in radians.
#[must_use]
pub fn arg(&self) -> f64 {
self.0.arg()
}
/// Narrow to `f32` complex for CIR (ADR-134) / NN (ADR-146) paths.
///
/// This is a lossy *view*, never re-serialised as the witness form
/// (ADR-136 §3.3 risk mitigation — one encoder only).
#[must_use]
pub fn as_complex32(&self) -> num_complex::Complex32 {
num_complex::Complex32::new(self.0.re as f32, self.0.im as f32)
}
/// Canonical 16-byte little-endian encoding: `re || im`, each `f64` LE.
#[must_use]
pub fn to_le_bytes(&self) -> [u8; 16] {
let mut b = [0u8; 16];
b[0..8].copy_from_slice(&self.0.re.to_le_bytes());
b[8..16].copy_from_slice(&self.0.im.to_le_bytes());
b
}
/// Decode from the canonical 16-byte little-endian encoding.
#[must_use]
pub fn from_le_bytes(b: [u8; 16]) -> Self {
let mut re = [0u8; 8];
let mut im = [0u8; 8];
re.copy_from_slice(&b[0..8]);
im.copy_from_slice(&b[8..16]);
Self(Complex64::new(f64::from_le_bytes(re), f64::from_le_bytes(im)))
}
}
impl From<Complex64> for ComplexSample {
fn from(z: Complex64) -> Self {
Self(z)
}
}
impl From<ComplexSample> for Complex64 {
fn from(s: ComplexSample) -> Self {
s.0
}
}
#[cfg(feature = "serde")]
impl Serialize for ComplexSample {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
// Two LE f64 — deterministic across architectures (ADR-136 §2.3).
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(2)?;
t.serialize_element(&self.0.re)?;
t.serialize_element(&self.0.im)?;
t.end()
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for ComplexSample {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let (re, im) = <(f64, f64)>::deserialize(d)?;
Ok(Self(Complex64::new(re, im)))
}
}
// =============================================================================
// Common Types
// =============================================================================
@@ -327,6 +426,23 @@ pub struct CsiMetadata {
pub noise_floor_dbm: i8,
/// Frame sequence number
pub sequence_number: u32,
/// UUID of the ADR-135 empty-room baseline subtracted from this frame
/// (ADR-136 §2.2). `None` ⇒ uncalibrated (no `BaselineCalibration::subtract()`
/// applied). Set only by the calibration stage; append-only thereafter.
#[cfg_attr(feature = "serde", serde(default))]
pub calibration_id: Option<Uuid>,
/// Identifier of the RF encoder / model family consuming this frame
/// (ADR-136 §2.2, ADR-146). Stable across a deployment; `0` ⇒ unassigned.
#[cfg_attr(feature = "serde", serde(default))]
pub model_id: u16,
/// Monotonic model version (ADR-119 §2.1 reserved-flag pattern: low byte
/// minor, high byte major). `0` ⇒ unassigned. Set only by the model-binding
/// stage; append-only thereafter.
#[cfg_attr(feature = "serde", serde(default))]
pub model_version: u16,
}
impl CsiMetadata {
@@ -343,9 +459,26 @@ impl CsiMetadata {
rssi_dbm: -50,
noise_floor_dbm: -90,
sequence_number: 0,
// ADR-136 provenance: unassigned until calibration / model-binding stages.
calibration_id: None,
model_id: 0,
model_version: 0,
}
}
/// Binds the ADR-135 empty-room baseline that was subtracted from this
/// frame (ADR-136 §2.4 boundary rule — only the calibration stage calls this).
pub fn set_calibration(&mut self, calibration_id: Uuid) {
self.calibration_id = Some(calibration_id);
}
/// Binds the RF model family/version that will consume this frame
/// (ADR-136 §2.4 — only the model-binding stage calls this).
pub fn set_model(&mut self, model_id: u16, model_version: u16) {
self.model_id = model_id;
self.model_version = model_version;
}
/// Returns the Signal-to-Noise Ratio in dB.
#[must_use]
pub fn snr_db(&self) -> f64 {
@@ -414,6 +547,73 @@ impl CsiFrame {
pub fn amplitude_variance(&self) -> f64 {
self.amplitude.var(0.0)
}
/// Zero-allocation view of the complex payload as [`ComplexSample`]s in
/// stream-major (`[stream][subcarrier]`) order — the canonical contract
/// representation (ADR-136 §2.3) without copying the `ndarray` buffer.
pub fn data_complex_samples(&self) -> impl Iterator<Item = ComplexSample> + '_ {
self.data.iter().map(|z| ComplexSample(*z))
}
}
impl crate::traits::CanonicalFrame for CsiFrame {
/// Deterministic, architecture-independent encoding (ADR-136 §2.5).
///
/// Layout: frame id (16 UUID bytes) ‖ metadata fields in declared order
/// (each fixed-width LE; `device_id` length-prefixed; `calibration_id` as
/// 16 UUID bytes or 16 zero bytes for `None`) ‖ `(nrows, ncols)` as u32 LE
/// ‖ complex payload as `ComplexSample::to_le_bytes()` in stream-major order.
fn to_canonical_bytes(&self) -> Vec<u8> {
let m = &self.metadata;
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
let mut b = Vec::with_capacity(88 + 16 * self.data.len());
// Frame id.
b.extend_from_slice(self.id.as_uuid().as_bytes());
// Metadata, declared order.
b.extend_from_slice(&m.timestamp.seconds.to_le_bytes());
b.extend_from_slice(&m.timestamp.nanos.to_le_bytes());
let dev = m.device_id.as_str().as_bytes();
b.extend_from_slice(&(dev.len() as u32).to_le_bytes());
b.extend_from_slice(dev);
b.push(match m.frequency_band {
FrequencyBand::Band2_4GHz => 0,
FrequencyBand::Band5GHz => 1,
FrequencyBand::Band6GHz => 2,
});
b.push(m.channel);
b.extend_from_slice(&m.bandwidth_mhz.to_le_bytes());
b.push(m.antenna_config.tx_antennas);
b.push(m.antenna_config.rx_antennas);
match m.antenna_config.spacing_mm {
Some(s) => {
b.push(1);
b.extend_from_slice(&s.to_le_bytes());
}
None => {
b.push(0);
b.extend_from_slice(&[0u8; 4]);
}
}
b.extend_from_slice(&m.rssi_dbm.to_le_bytes());
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
b.extend_from_slice(&m.sequence_number.to_le_bytes());
match m.calibration_id {
Some(id) => b.extend_from_slice(id.as_bytes()),
None => b.extend_from_slice(&[0u8; 16]),
}
b.extend_from_slice(&m.model_id.to_le_bytes());
b.extend_from_slice(&m.model_version.to_le_bytes());
// Shape, then complex payload stream-major.
b.extend_from_slice(&(self.data.nrows() as u32).to_le_bytes());
b.extend_from_slice(&(self.data.ncols() as u32).to_le_bytes());
for sample in self.data_complex_samples() {
b.extend_from_slice(&sample.to_le_bytes());
}
b
}
}
// =============================================================================
@@ -1040,6 +1240,106 @@ mod tests {
assert!((distance - 5.0).abs() < 0.001);
}
// ===== ADR-136 acceptance tests =====
use crate::traits::CanonicalFrame;
/// Deterministic LCG so the test needs no external RNG dependency.
fn lcg(state: &mut u64) -> f64 {
*state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
// Map high bits into [-1e6, 1e6) for a wide exponent spread.
((*state >> 11) as f64 / (1u64 << 53) as f64) * 2.0e6 - 1.0e6
}
/// AC1 — `ComplexSample` little-endian round-trip + endianness pin.
#[test]
fn ac1_complex_sample_le_roundtrip() {
let mut st = 42u64;
for _ in 0..10_000 {
let (re, im) = (lcg(&mut st), lcg(&mut st));
let s = ComplexSample::new(re, im);
let bytes = s.to_le_bytes();
assert_eq!(ComplexSample::from_le_bytes(bytes), s, "LE round-trip");
// Byte 0 is the LSB of `re` encoded little-endian.
assert_eq!(bytes[0], re.to_le_bytes()[0], "endianness pin on re LSB");
assert_eq!(bytes[8], im.to_le_bytes()[0], "endianness pin on im LSB");
}
// NaN/inf survive the byte round-trip (bit-exact).
let edge = ComplexSample::new(f64::NAN, f64::INFINITY);
let rt = ComplexSample::from_le_bytes(edge.to_le_bytes());
assert!(rt.0.re.is_nan() && rt.0.im.is_infinite());
}
/// AC2 — `FrameMeta` provenance defaults + append-only setters.
#[test]
fn ac2_frame_meta_provenance_defaults() {
let mut m = CsiMetadata::new(DeviceId::new("esp32-s3-com9"), FrequencyBand::Band2_4GHz, 6);
assert_eq!(m.calibration_id, None);
assert_eq!(m.model_id, 0);
assert_eq!(m.model_version, 0);
let cal = uuid::Uuid::new_v4();
m.set_calibration(cal);
m.set_model(7, 0x0102);
assert_eq!(m.calibration_id, Some(cal));
assert_eq!(m.model_id, 7);
assert_eq!(m.model_version, 0x0102);
}
/// AC6 (frame-level) — `CanonicalFrame` is deterministic across runs and
/// sensitive to provenance changes.
#[test]
fn ac6_canonical_frame_witness_deterministic() {
use ndarray::Array2;
let meta = CsiMetadata::new(DeviceId::new("node-1"), FrequencyBand::Band5GHz, 36);
let data = Array2::from_shape_fn((3, 56), |(r, c)| {
Complex64::new((r * 56 + c) as f64 * 0.5, (c as f64).sin())
});
let frame = CsiFrame::new(meta, data);
// Same frame hashes identically twice (replay determinism, AC6).
assert_eq!(frame.witness_hash(), frame.witness_hash());
let bytes = frame.to_canonical_bytes();
assert_eq!(bytes.len(), frame.to_canonical_bytes().len());
// Changing provenance changes the witness (no silent collisions).
let mut frame2 = frame.clone();
frame2.metadata.set_model(1, 1);
assert_ne!(frame.witness_hash(), frame2.witness_hash());
}
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
#[cfg(feature = "serde")]
#[test]
fn ac3_serde_forward_read_legacy_metadata() {
// A pre-ADR-136 CsiMetadata payload without the three new fields.
let legacy = r#"{
"timestamp": {"seconds": 1700000000, "nanos": 0},
"device_id": "legacy-node",
"frequency_band": "Band2_4GHz",
"channel": 1,
"bandwidth_mhz": 20,
"antenna_config": {"tx_antennas": 1, "rx_antennas": 3, "spacing_mm": null},
"rssi_dbm": -50,
"noise_floor_dbm": -90,
"sequence_number": 0
}"#;
let m: CsiMetadata = serde_json::from_str(legacy).expect("legacy metadata must load");
assert_eq!(m.calibration_id, None);
assert_eq!(m.model_id, 0);
assert_eq!(m.model_version, 0);
}
/// AC1b — `ComplexSample` serde tuple form is the two LE f64 contract.
#[cfg(feature = "serde")]
#[test]
fn ac1b_complex_sample_serde_tuple() {
let s = ComplexSample::new(1.5, -2.25);
let j = serde_json::to_string(&s).unwrap();
assert_eq!(j, "[1.5,-2.25]");
let back: ComplexSample = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
}
#[test]
fn test_bounding_box_iou() {
let box1 = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
@@ -0,0 +1,32 @@
[package]
name = "wifi-densepose-engine"
description = "RuView streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle"
version = "0.3.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
# Composed building blocks (ADR-135..146).
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-ruvector = { version = "0.3.0", path = "../wifi-densepose-ruvector", default-features = false }
# bfld is no_std by default; the privacy CONTROL PLANE (PrivacyModeRegistry) is
# std-gated, so request std explicitly even under a workspace --no-default-features build.
wifi-densepose-bfld = { version = "0.3.0", path = "../wifi-densepose-bfld", features = ["std"] }
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
# Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028).
blake3 = { version = "1.5", default-features = false }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "engine_cycle"
harness = false
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
@@ -0,0 +1,52 @@
//! Criterion benchmark for the RuView streaming-engine hot path.
//!
//! The live system runs at 20 Hz → a **50 ms** wall-clock budget per cycle.
//! This measures one full [`StreamingEngine::process_cycle`] (fuse + quality
//! scoring + calibration provenance + privacy gate + WorldGraph semantic node)
//! for a 4-node / 56-subcarrier mesh — the realistic ESP32-S3 HT20 case.
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use wifi_densepose_bfld::PrivacyMode;
use wifi_densepose_engine::StreamingEngine;
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::MultiBandCsiFrame;
fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
MultiBandCsiFrame {
node_id,
timestamp_us: ts_us,
channel_frames: vec![CanonicalCsiFrame {
amplitude: (0..n_sub).map(|i| 1.0 + 0.1 * i as f32).collect(),
phase: (0..n_sub).map(|i| i as f32 * 0.05).collect(),
hardware_type: HardwareType::Esp32S3,
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
}
}
fn bench_cycle(c: &mut Criterion) {
let frames: Vec<MultiBandCsiFrame> =
(0..4).map(|i| node_frame(i, 1000 + u64::from(i), 56)).collect();
c.bench_function("process_cycle_4nodes_56sc", |b| {
b.iter_batched(
|| {
let mut e =
StreamingEngine::new(PrivacyMode::PrivateHome, 1, GeoRegistration::default());
let room = e.add_room("living_room", "Living Room");
e.add_sensor("esp32-com9", room);
(e, room)
},
|(mut e, room)| {
e.process_cycle(&frames, CalibrationId(1), room, 0).unwrap()
},
BatchSize::SmallInput,
);
});
}
criterion_group!(benches, bench_cycle);
criterion_main!(benches);
+751
View File
@@ -0,0 +1,751 @@
//! # RuView Streaming Engine — integration layer
//!
//! This crate is the **composition root** that wires the ADR-135..146 building
//! blocks into one end-to-end *trust-traceable* pipeline cycle. Each block was
//! built and unit-tested independently; this crate proves they compose and that
//! the **trust throughline** holds end-to-end:
//!
//! > *Why believe the system when it says a person is present?* — every
//! > [`TrustedOutput`] names its **signal evidence** (ADR-137 `EvidenceRef`),
//! > its **model version** (ADR-136), its **calibration version** (ADR-135
//! > baseline id, ADR-136 `calibration_id`), and the **privacy decision**
//! > (ADR-141 mode → class) it was emitted under — and is anchored as a
//! > provenance-bearing node in the ADR-139 WorldGraph.
//!
//! One [`StreamingEngine::process_cycle`] performs, in order:
//! 1. **Fuse + score** the node frames (ADR-137 `fuse_scored`) → `QualityScore`
//! with per-node weights, evidence, and tolerated contradiction flags.
//! 2. **Stamp calibration provenance** (ADR-135/136): the `CalibrationId` the
//! calibration stage applied is recorded on the `QualityScore`.
//! 3. **Privacy control plane** (ADR-141): if the fusion recorded a tolerated
//! contradiction, the active privacy class is **demoted one step** before
//! emission (monotonic — information only ever removed).
//! 4. **Semantic state** (ADR-139/140): a `SemanticState` node is appended to
//! the WorldGraph with mandatory provenance and a `DerivedFrom` edge to the
//! room it was observed in.
//!
//! What is intentionally *not* here: the live 20 Hz I/O loop (sensing-server),
//! UWB hardware (ADR-144), and model training (ADR-146). This is the
//! composition + validation layer those will plug into.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use wifi_densepose_bfld::{PrivacyAction, PrivacyClass, PrivacyMode, PrivacyModeRegistry};
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_ruvector::viewpoint::coherence::ClockQualityScore;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser};
use wifi_densepose_signal::ruvsense::{
ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, ChangePoint, DirectionalEvidence,
EvolutionTracker, MultiBandCsiFrame, QualityScore, ReflectorObservation, RfSlam,
};
use wifi_densepose_worldgraph::{
AnchorKind, EnuPoint, PrivacyRollup, SemanticProvenance, WorldEdge, WorldGraph, WorldGraphError,
WorldId, WorldNode, ZoneBoundsEnu,
};
/// Errors from an engine cycle.
#[derive(Debug)]
pub enum EngineError {
/// Multistatic fusion failed (no frames, timestamp spread, dimension mismatch).
Fusion(wifi_densepose_signal::ruvsense::multistatic::MultistaticError),
}
impl core::fmt::Display for EngineError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
EngineError::Fusion(e) => write!(f, "fusion error: {e}"),
}
}
}
impl std::error::Error for EngineError {}
impl From<wifi_densepose_signal::ruvsense::multistatic::MultistaticError> for EngineError {
fn from(e: wifi_densepose_signal::ruvsense::multistatic::MultistaticError) -> Self {
EngineError::Fusion(e)
}
}
/// Geometry of a sensing node, needed to run the ADR-138 array coordinator.
#[derive(Debug, Clone, Copy)]
struct NodeGeom {
x: f32,
y: f32,
azimuth: f32,
}
/// The auditable result of one engine cycle — the trust chain made concrete.
#[derive(Debug, Clone)]
pub struct TrustedOutput {
/// The `SemanticState` node id created in the WorldGraph.
pub semantic_id: WorldId,
/// The fusion quality record (evidence + contradictions + calibration).
pub quality: QualityScore,
/// The privacy class the output was emitted under (after any demotion).
pub effective_class: PrivacyClass,
/// Whether a tolerated contradiction forced a privacy demotion this cycle.
pub demoted: bool,
/// The mandatory provenance attached to the semantic node.
pub provenance: SemanticProvenance,
/// ADR-138 directional evidence, when node geometry is registered for every
/// contributing node (else `None`).
pub directional: Option<DirectionalEvidence>,
/// ADR-142 cross-link change-point detected this cycle, if any (and the
/// `Event` node it was recorded as in the WorldGraph).
pub change_point: Option<(ChangePoint, WorldId)>,
/// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration)
/// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028).
pub witness: [u8; 32],
}
/// Composition root for the RuView streaming engine.
pub struct StreamingEngine {
fuser: MultistaticFuser,
coherence_accept: f32,
privacy: PrivacyModeRegistry,
world: WorldGraph,
model_version: u16,
cycle: u64,
// ADR-138: array coordinator + per-node geometry (by frame node_id).
array: ArrayCoordinator,
node_geom: BTreeMap<u8, NodeGeom>,
// ADR-142: per-link evolution tracker (sized lazily to the node count).
evolution: Option<EvolutionTracker>,
// ADR-143: persistent reflector discovery (v2 mode).
slam: RfSlam,
// ADR-139 live loop: stable track_id -> PersonTrack WorldId.
person_tracks: BTreeMap<u64, WorldId>,
}
impl StreamingEngine {
/// Build an engine with a starting privacy mode and model version. The
/// WorldGraph is registered to the installation origin.
#[must_use]
pub fn new(mode: PrivacyMode, model_version: u16, registration: GeoRegistration) -> Self {
Self {
fuser: MultistaticFuser::with_config(MultistaticConfig::default()),
coherence_accept: 0.85,
privacy: PrivacyModeRegistry::new(mode),
world: WorldGraph::new(registration),
model_version,
cycle: 0,
array: ArrayCoordinator::new(ArrayCoordinatorConfig::default()),
node_geom: BTreeMap::new(),
evolution: None,
slam: RfSlam::with_discovery(0.5, 5, 0.6),
person_tracks: BTreeMap::new(),
}
}
/// ADR-139 live loop: create or update a `PersonTrack` node by stable
/// `track_id`, locate it in `room`, and wire an `Observes` edge from
/// `sensor` (so the privacy rollup can suppress it under identity-strict
/// modes). Returns the (stable) WorldGraph id.
pub fn update_person_track(
&mut self,
track_id: u64,
x: f32,
y: f32,
room: WorldId,
sensor: WorldId,
) -> WorldId {
let existing = self.person_tracks.get(&track_id).copied();
let node = WorldNode::PersonTrack {
id: existing.unwrap_or(WorldId::UNASSIGNED),
track_id,
last_position: EnuPoint { east_m: f64::from(x), north_m: f64::from(y), up_m: 0.0 },
reid_embedding_ref: None,
};
let id = self.world.upsert_node(node);
if existing.is_none() {
self.person_tracks.insert(track_id, id);
let _ = self.world.add_edge(id, room, WorldEdge::LocatedIn { since_unix_ms: 0 });
let _ = self.world.add_edge(
sensor,
id,
WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 },
);
}
id
}
/// ADR-139 §2.4 / ADR-141: materialise `PrivacyLimitedBy` edges for the
/// active privacy mode. Under an identity-suppressing mode, `person_track`
/// observations are denied; the rollup names what was suppressed.
pub fn apply_active_privacy_mode(&mut self) -> PrivacyRollup {
let mode = self.privacy.active_mode();
let suppress_identity = self.privacy.is_action_enforced(PrivacyAction::SuppressIdentity);
self.world.apply_privacy_mode(
&format!("{mode:?}"),
"SuppressIdentity",
move |_sensor_kind, node_kind| !(suppress_identity && node_kind == "person_track"),
)
}
/// Persist the WorldGraph as deterministic JSON (the RVF payload). Contains
/// only graph nodes/edges — **never** raw RF frames.
///
/// # Errors
/// [`WorldGraphError`] on serialisation failure.
pub fn snapshot_json(&self) -> Result<Vec<u8>, WorldGraphError> {
self.world.to_json()
}
/// Register a contributing node's geometry (ADR-138). When every frame's
/// `node_id` in a cycle has a registered geometry, the cycle runs the array
/// coordinator and folds its contradictions into the privacy decision.
pub fn register_node_geometry(&mut self, node_id: u8, x: f32, y: f32, azimuth: f32) {
self.node_geom.insert(node_id, NodeGeom { x, y, azimuth });
}
/// Ingest CIR-derived reflector sightings (ADR-143) and persist any newly
/// stable static anchors into the WorldGraph as `ObjectAnchor` nodes.
/// Returns the WorldGraph ids written this call.
pub fn ingest_reflectors(&mut self, observations: &[ReflectorObservation]) -> Vec<WorldId> {
for obs in observations {
self.slam.observe(obs);
}
let mut written = Vec::new();
for (pos, class) in self.slam.static_anchors(0.05, 1.0) {
let kind = match class {
wifi_densepose_signal::ruvsense::ReflectorClass::Wall => AnchorKind::Reflector,
wifi_densepose_signal::ruvsense::ReflectorClass::Furniture => AnchorKind::Furniture,
wifi_densepose_signal::ruvsense::ReflectorClass::Mobile => continue,
};
let id = self.world.upsert_node(WorldNode::ObjectAnchor {
id: WorldId::UNASSIGNED,
position: EnuPoint { east_m: pos[0], north_m: pos[1], up_m: pos[2] },
anchor_kind: kind,
confidence: 0.9,
});
written.push(id);
}
written
}
/// Register a room and return its WorldGraph id (the observation scope).
pub fn add_room(&mut self, area_id: &str, name: &str) -> WorldId {
self.world.upsert_node(WorldNode::Room {
id: WorldId::UNASSIGNED,
area_id: Some(area_id.to_string()),
name: name.to_string(),
bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 },
floor: 0,
})
}
/// Register a sensor node and an `observes` edge to a room.
pub fn add_sensor(&mut self, device_id: &str, room: WorldId) -> WorldId {
let id = self.world.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: device_id.to_string(),
position: EnuPoint { east_m: 0.0, north_m: 0.0, up_m: 0.0 },
modality: wifi_densepose_worldgraph::SensorModality::WifiCsi,
});
let _ = self.world.add_edge(
id,
room,
WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 },
);
id
}
/// Switch the active privacy mode (records a hash-chained attestation).
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
self.privacy.set_mode(mode);
}
/// Borrow the WorldGraph (for queries / persistence).
#[must_use]
pub fn world(&self) -> &WorldGraph {
&self.world
}
/// Borrow the privacy registry (for attestation audit).
#[must_use]
pub fn privacy(&self) -> &PrivacyModeRegistry {
&self.privacy
}
/// Cycles processed so far.
#[must_use]
pub fn cycle_count(&self) -> u64 {
self.cycle
}
/// Run one full trust-traceable cycle (see crate docs for the steps).
///
/// `calibration` is the [`CalibrationId`] the calibration stage applied to
/// these frames (ADR-135 `BaselineCalibration::calibration_id()`); `room` is
/// the observation scope (an existing WorldGraph Room id).
///
/// # Errors
/// [`EngineError::Fusion`] if multistatic fusion rejects the input.
pub fn process_cycle(
&mut self,
node_frames: &[MultiBandCsiFrame],
calibration: CalibrationId,
room: WorldId,
now_ms: i64,
) -> Result<TrustedOutput, EngineError> {
// Uniform-calibration convenience: every node shares one epoch.
let cals = vec![Some(calibration); node_frames.len()];
self.process_cycle_calibrated(node_frames, &cals, room, now_ms)
}
/// Like [`Self::process_cycle`] but with a **per-node** calibration epoch
/// (ADR-137 §2.3). If the nodes' calibrations disagree, fusion raises a
/// `CalibrationIdMismatch`, the score's `calibration_id` is `None`, and the
/// privacy class is demoted — proving the calibration → trust → privacy path.
///
/// # Errors
/// [`EngineError::Fusion`] if multistatic fusion rejects the input.
pub fn process_cycle_calibrated(
&mut self,
node_frames: &[MultiBandCsiFrame],
calibrations: &[Option<CalibrationId>],
room: WorldId,
now_ms: i64,
) -> Result<TrustedOutput, EngineError> {
// 1. Array coordination (ADR-138) — only when geometry is known for
// every contributing node. Its contradictions feed the privacy gate.
let directional = self.coordinate_array(node_frames);
let array_contradiction =
directional.as_ref().is_some_and(|d| !d.contradictions.is_empty());
// 2. Fuse + score with per-node calibration (ADR-137 §2.3).
let (fused, quality) =
self.fuser.fuse_scored_calibrated(node_frames, calibrations, self.coherence_accept)?;
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
let change_point = self.track_evolution(node_frames, now_ms, room);
// 5. Privacy control plane (ADR-141): demote on a fusion-level OR an
// array-level contradiction (monotonic — information only removed).
let base_class = self.privacy.active_class();
let demoted = quality.forces_privacy_demotion() || array_contradiction;
let effective_class = if demoted { demote_one(base_class) } else { base_class };
// 6. Semantic state with mandatory provenance (ADR-139/140). The
// calibration version comes from the *agreed* epoch (None on mismatch).
let calibration_version = match quality.calibration_id {
Some(c) => format!("cal:{:016x}", c.0),
None => "cal:none".to_string(),
};
let provenance = SemanticProvenance {
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
model_version: format!("rfenc-v{}", self.model_version),
calibration_version,
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
};
let statement = format!(
"occupancy coherence={:.2} nodes={} demoted={}",
quality.base_coherence, fused.active_nodes, demoted
);
let semantic_id = self.world.add_semantic_state(
statement,
quality.penalized_coherence(),
now_ms,
provenance.clone(),
&[room],
);
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
let witness = witness_of(&provenance, effective_class);
self.cycle += 1;
Ok(TrustedOutput {
semantic_id,
quality,
effective_class,
demoted,
provenance,
directional,
change_point,
witness,
})
}
/// ADR-138: build per-node array inputs and coordinate, iff every frame's
/// `node_id` has a registered geometry. Returns `None` otherwise.
fn coordinate_array(&self, node_frames: &[MultiBandCsiFrame]) -> Option<DirectionalEvidence> {
if node_frames.is_empty() {
return None;
}
let mut inputs = Vec::with_capacity(node_frames.len());
for f in node_frames {
let g = self.node_geom.get(&f.node_id)?; // bail if any node lacks geometry
inputs.push(ArrayNodeInput {
node_id: u32::from(f.node_id),
position: (g.x, g.y),
azimuth: g.azimuth,
coherence: f.coherence,
clock: ClockQualityScore { offset_stdev_us: 50.0, age_us: 1_000, valid: true },
amplitude: f.channel_frames.first().map(|cf| cf.amplitude.clone()),
});
}
Some(self.array.coordinate(&inputs))
}
/// ADR-142: fold per-node mean amplitude into the evolution tracker and,
/// on a cross-link change-point, record an `Event` node in the WorldGraph.
fn track_evolution(
&mut self,
node_frames: &[MultiBandCsiFrame],
now_ms: i64,
room: WorldId,
) -> Option<(ChangePoint, WorldId)> {
let values: Vec<f64> = node_frames
.iter()
.filter_map(|f| f.channel_frames.first())
.map(|cf| {
if cf.amplitude.is_empty() {
0.0
} else {
cf.amplitude.iter().map(|&a| f64::from(a)).sum::<f64>() / cf.amplitude.len() as f64
}
})
.collect();
if values.is_empty() {
return None;
}
let n = values.len();
let tracker = self
.evolution
.get_or_insert_with(|| EvolutionTracker::new(n, 2.0, (n / 2).max(2)));
// Node count must be stable for the tracker to remain meaningful.
if tracker.n_links() != n {
return None;
}
let cp = tracker.observe_window(&values)?;
let event = self.world.upsert_node(WorldNode::Event {
id: WorldId::UNASSIGNED,
event_type: "baseline_topology_change".to_string(),
at_unix_ms: now_ms,
located_in: Some(room),
});
let _ = self.world.add_edge(event, room, WorldEdge::LocatedIn { since_unix_ms: now_ms });
Some((cp, event))
}
}
/// Deterministic BLAKE3 witness over a trust decision: the provenance tuple
/// (evidence ‖ model ‖ calibration ‖ privacy decision) plus the effective
/// privacy-class byte. Stable across runs for identical decisions — the
/// "signed operational belief" fingerprint (ADR-137 §2.7 / ADR-028).
fn witness_of(p: &SemanticProvenance, class: PrivacyClass) -> [u8; 32] {
let mut h = blake3::Hasher::new();
for e in &p.evidence {
h.update(e.as_bytes());
h.update(b"\x1f");
}
h.update(p.model_version.as_bytes());
h.update(p.calibration_version.as_bytes());
h.update(p.privacy_decision.as_bytes());
h.update(&[class.as_u8()]);
*h.finalize().as_bytes()
}
/// Demote a privacy class by one step (more restrictive), clamped at `Restricted`.
/// Monotonic: information is only ever removed (ADR-120/141).
fn demote_one(c: PrivacyClass) -> PrivacyClass {
let next = (c.as_u8() + 1).min(PrivacyClass::Restricted.as_u8());
PrivacyClass::try_from(next).unwrap_or(PrivacyClass::Restricted)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
MultiBandCsiFrame {
node_id,
timestamp_us: ts_us,
channel_frames: vec![CanonicalCsiFrame {
amplitude: (0..n_sub).map(|i| 1.0 + 0.1 * i as f32).collect(),
phase: (0..n_sub).map(|i| i as f32 * 0.05).collect(),
hardware_type: HardwareType::Esp32S3,
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
}
}
fn engine() -> (StreamingEngine, WorldId) {
let mut e = StreamingEngine::new(PrivacyMode::PrivateHome, 1, GeoRegistration::default());
let room = e.add_room("living_room", "Living Room");
e.add_sensor("esp32-com9", room);
(e, room)
}
/// End-to-end trust invariant: a clean cycle produces a SemanticState whose
/// provenance names evidence + model + calibration + privacy decision, and
/// the calibration id flows from input → QualityScore → provenance.
#[test]
fn cycle_carries_full_provenance() {
let (mut e, room) = engine();
let cal = CalibrationId(0xABCD_1234);
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
let out = e.process_cycle(&frames, cal, room, 10_000).unwrap();
// Calibration flows all the way through.
assert_eq!(out.quality.calibration_id, Some(cal));
assert_eq!(out.provenance.calibration_version, "cal:00000000abcd1234");
// Model + privacy provenance present.
assert_eq!(out.provenance.model_version, "rfenc-v1");
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
// Evidence refs recorded.
assert!(!out.provenance.evidence.is_empty());
// Clean cycle (tight timestamps) → no demotion, stays Anonymous (PrivateHome).
assert!(!out.demoted);
assert_eq!(out.effective_class, PrivacyClass::Anonymous);
// The SemanticState is in the graph with a DerivedFrom edge to the room.
assert!(e.world().node(out.semantic_id).is_some());
assert!(e
.world()
.neighbors(out.semantic_id)
.iter()
.any(|(to, edge)| *to == room && matches!(edge, WorldEdge::DerivedFrom { .. })));
}
/// A tolerated contradiction (loose timestamp spread, within the hard guard)
/// demotes the privacy class one step — proving ADR-137 → ADR-141 wiring.
#[test]
fn contradiction_demotes_privacy() {
let (mut e, room) = engine();
let cal = CalibrationId(7);
// 2 ms spread: within the 5 ms hard guard but above the 1 ms soft guard.
let frames = [node_frame(0, 1000, 56), node_frame(1, 3000, 56)];
let out = e.process_cycle(&frames, cal, room, 20_000).unwrap();
assert!(out.demoted, "loose alignment must demote");
// PrivateHome base = Anonymous(2) → demoted to Restricted(3).
assert_eq!(out.effective_class, PrivacyClass::Restricted);
assert!(out.provenance.privacy_decision.contains("Restricted"));
// Penalized coherence is below the base coherence.
assert!(out.quality.penalized_coherence() <= out.quality.base_coherence);
}
/// Determinism: identical input twice → identical provenance + class
/// (the ADR-136 witness-replay spirit, end-to-end through the engine).
#[test]
fn cycle_is_deterministic() {
let cal = CalibrationId(42);
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
let (mut e1, r1) = engine();
let o1 = e1.process_cycle(&frames, cal, r1, 5_000).unwrap();
let (mut e2, r2) = engine();
let o2 = e2.process_cycle(&frames, cal, r2, 5_000).unwrap();
assert_eq!(o1.provenance.calibration_version, o2.provenance.calibration_version);
assert_eq!(o1.provenance.evidence, o2.provenance.evidence);
assert_eq!(o1.effective_class, o2.effective_class);
assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights);
}
fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame {
MultiBandCsiFrame {
node_id,
timestamp_us: ts_us,
channel_frames: vec![CanonicalCsiFrame {
amplitude: (0..n_sub).map(|i| scale * (1.0 + 0.1 * i as f32)).collect(),
phase: (0..n_sub).map(|i| i as f32 * 0.05).collect(),
hardware_type: HardwareType::Esp32S3,
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
}
}
/// ADR-138 composed: with node geometry registered, the cycle produces
/// directional evidence (admitted nodes + weights).
#[test]
fn array_coordinator_runs_when_geometry_registered() {
use std::f32::consts::PI;
let (mut e, room) = engine();
e.register_node_geometry(0, 1.0, 0.0, 0.0);
e.register_node_geometry(1, -1.0, 0.0, PI); // opposite → good diversity
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1)
.unwrap();
let d = out.directional.expect("geometry registered → directional evidence");
assert_eq!(d.n_admitted, 2);
assert!((d.weights.iter().map(|(_, w)| *w).sum::<f32>() - 1.0).abs() < 1e-3);
// Well-separated, coherent nodes → no array contradiction → no demotion.
assert!(!out.demoted);
}
/// ADR-138 composed: poor geometry (near-colinear nodes) raises a
/// GeometryInsufficient contradiction that demotes privacy.
#[test]
fn array_geometry_insufficient_demotes() {
let (mut e, room) = engine();
e.register_node_geometry(0, 1.0, 0.0, 0.0);
e.register_node_geometry(1, 1.0, 0.01, 0.01); // nearly colinear → low GDI
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1)
.unwrap();
let d = out.directional.unwrap();
assert!(!d.contradictions.is_empty(), "insufficient geometry flagged");
assert!(out.demoted && out.effective_class == PrivacyClass::Restricted);
}
/// ADR-142 composed: a sustained baseline then a simultaneous amplitude
/// shift on both links yields a change-point + an Event node in the graph.
#[test]
fn evolution_change_point_recorded_as_event() {
let (mut e, room) = engine();
let cal = CalibrationId(1);
// Jittered baseline so each link has non-zero std (constant std=0 is undefined).
for i in 0..30u64 {
let s = if i % 2 == 0 { 0.99 } else { 1.01 };
let out = e
.process_cycle(&[node_frame_scaled(0, 1000, 56, s), node_frame_scaled(1, 1001, 56, s)], cal, room, i as i64)
.unwrap();
assert!(out.change_point.is_none(), "baseline must not trip a change-point");
}
// Large simultaneous excursion on both links → change-point.
let out = e
.process_cycle(&[node_frame_scaled(0, 1000, 56, 1.6), node_frame_scaled(1, 1001, 56, 1.6)], cal, room, 99)
.unwrap();
let (_, event_id) = out.change_point.expect("simultaneous shift → change-point");
assert!(matches!(
e.world().node(event_id),
Some(WorldNode::Event { event_type, .. }) if event_type == "baseline_topology_change"
));
}
/// ADR-143 composed: ingesting stable reflector sightings writes an
/// ObjectAnchor node into the WorldGraph.
#[test]
fn reflector_ingestion_writes_object_anchors() {
use wifi_densepose_signal::ruvsense::ReflectorObservation;
let (mut e, _room) = engine();
let day_ns = 86_400_000_000_000u64;
// 8 tight, coherent sightings spanning ~a day → a stable Wall anchor.
let obs: Vec<ReflectorObservation> = (0..8u64)
.map(|i| {
let j = if i % 2 == 0 { 0.005 } else { -0.005 };
ReflectorObservation { position: [3.0 + j, 1.0, 0.0], delay_ns: 12.0, coherence: 0.9, at_ns: i * (day_ns / 8) }
})
.collect();
let written = e.ingest_reflectors(&obs);
assert!(!written.is_empty(), "stable reflector → ObjectAnchor written");
assert!(matches!(
e.world().node(written[0]),
Some(WorldNode::ObjectAnchor { .. })
));
}
/// ADR-137 acceptance (the trust-root path):
/// `two calibrated frames -> calibration mismatch -> QualityScore
/// contradiction -> Restricted -> calibration_id None -> witness stable`.
#[test]
fn calibration_mismatch_demotes_and_witness_stable() {
let run = || {
let (mut e, room) = engine();
// PrivateHome base = Anonymous; mismatch must demote to Restricted.
e.process_cycle_calibrated(
&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)],
&[Some(CalibrationId(1)), Some(CalibrationId(2))], // DISAGREE
room,
1,
)
.unwrap()
};
let out = run();
// QualityScore raised the contradiction; no single calibration epoch.
assert!(out.quality.forces_privacy_demotion());
assert_eq!(out.quality.calibration_id, None);
assert_eq!(out.provenance.calibration_version, "cal:none");
// BFLD class demoted to Restricted (identity surface removed downstream).
assert!(out.demoted);
assert_eq!(out.effective_class, PrivacyClass::Restricted);
// Witness is deterministic across identical runs.
assert_eq!(out.witness, run().witness);
assert_ne!(out.witness, [0u8; 32]);
}
/// Agreeing calibrations set the epoch and do NOT demote (the happy path
/// counterpart, proving the mismatch test isn't trivially always-demoting).
#[test]
fn matching_calibration_sets_epoch_no_demotion() {
let (mut e, room) = engine();
let cal = CalibrationId(0xABCD);
let out = e
.process_cycle_calibrated(
&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)],
&[Some(cal), Some(cal)],
room,
1,
)
.unwrap();
assert_eq!(out.quality.calibration_id, Some(cal));
assert!(!out.demoted);
assert_eq!(out.effective_class, PrivacyClass::Anonymous);
}
/// ADR-139 live-loop acceptance (the architecture-proving path):
/// `live_frame -> fusion_event -> worldgraph_update -> privacy_rollup ->
/// persist -> reload -> same_contents`, with NO raw RF frame persisted.
#[test]
fn live_frame_to_reload_same_contents() {
let mut e =
StreamingEngine::new(PrivacyMode::StrictNoIdentity, 1, GeoRegistration::default());
let room = e.add_room("living_room", "Living Room");
let sensor = e.add_sensor("esp32-com9", room);
// live_frame -> fusion_event -> worldgraph_update (SemanticState).
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(9), room, 100)
.unwrap();
// person track feeding.
let pt = e.update_person_track(7, 2.0, 2.0, room, sensor);
// privacy_rollup: StrictNoIdentity suppresses the person_track.
let rollup = e.apply_active_privacy_mode();
assert!(rollup.suppressed_nodes.contains(&pt), "person track suppressed");
assert!(rollup.denied_pairs.iter().any(|(_s, n)| *n == pt));
// persist.
let bytes = e.snapshot_json().unwrap();
// No raw RF frame persisted — the snapshot is graph nodes/edges only.
let json = String::from_utf8(bytes.clone()).unwrap();
assert!(!json.contains("\"amplitude\"") && !json.contains("\"data\""), "no raw RF in snapshot");
// reload.
let reloaded = WorldGraph::from_json(&bytes).unwrap();
// same_contents: node count, area resolution, the SemanticState + track,
// and an identical room-contents query before vs after reload.
assert_eq!(reloaded.node_count(), e.world().node_count());
assert_eq!(reloaded.room_for_area("living_room"), e.world().room_for_area("living_room"));
assert!(reloaded.node(out.semantic_id).is_some());
assert!(reloaded.node(pt).is_some());
let mut before = e.world().contents_of(room);
before.sort_by_key(|w| w.0);
let mut after = reloaded.contents_of(room);
after.sort_by_key(|w| w.0);
assert_eq!(before, after, "same room-contents query after reload");
// Deterministic persistence: re-serialising the reload is byte-identical.
assert_eq!(reloaded.to_json().unwrap(), bytes);
}
/// The privacy mode switch is recorded in a verifiable attestation chain
/// (ADR-141), and a stricter mode raises the emitted class.
#[test]
fn privacy_mode_switch_is_attested_and_effective() {
let (mut e, room) = engine();
e.set_privacy_mode(PrivacyMode::StrictNoIdentity);
assert!(e.privacy().verify_chain());
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1)
.unwrap();
// StrictNoIdentity base = Restricted, even with no contradiction.
assert_eq!(out.effective_class, PrivacyClass::Restricted);
}
}
+2
View File
@@ -3,6 +3,8 @@ name = "wifi-densepose-geo"
version = "0.1.0"
edition = "2021"
description = "Geospatial satellite integration — free satellite tiles, DEM, OSM, temporal tracking"
license.workspace = true
repository.workspace = true
[dependencies]
serde = { workspace = true }
@@ -7,10 +7,12 @@
mod depth;
mod fusion;
mod range_constraint;
mod triangulation;
pub use depth::{DepthEstimator, DepthEstimatorConfig};
pub use fusion::{LocalizationService, PositionFuser};
pub use range_constraint::{RangeConstraint, RangeConstraintFusion, RefineResult};
#[cfg(feature = "ruvector")]
pub use triangulation::solve_tdoa_triangulation;
pub use triangulation::{TriangulationConfig, Triangulator};
@@ -0,0 +1,248 @@
//! ADR-144 — UWB range-constraint fusion.
//!
//! A [`RangeConstraint`] is one UWB anchor↔tag range measurement. It does NOT
//! replace CSI/CIR localisation — it *constrains* a person-track estimate toward
//! the sphere of points at the measured range from a surveyed anchor, with
//! Mahalanobis gating so an inconsistent (multipath/NLOS) range is rejected
//! rather than corrupting the estimate. Anchors map to ADR-139
//! `WorldNode::ObjectAnchor` (`anchor_kind = UwbBeacon`).
//!
//! Forward-looking: no UWB hardware ships in the current device table, so this
//! module owns the domain model + the constraint-aware refinement; the UART
//! driver/parser (ADR-144 §2) lands when hardware is added.
/// One UWB range measurement from a surveyed anchor to a tag (ADR-144 §2.1).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RangeConstraint {
/// Surveyed anchor identifier (→ ADR-139 ObjectAnchor / WorldId).
pub anchor_id: u32,
/// Anchor position (east, north, up) in metres.
pub anchor_pos: [f64; 3],
/// Measured range tag↔anchor (m).
pub measured_range_m: f64,
/// 1σ range uncertainty (m).
pub uncertainty_m: f64,
/// Link quality in [0, 1] (low ⇒ likely NLOS/multipath).
pub signal_quality: f32,
/// Capture-clock time (ns).
pub at_ns: u64,
}
impl RangeConstraint {
/// Euclidean distance from a candidate position to the anchor.
#[must_use]
pub fn predicted_range(&self, p: [f64; 3]) -> f64 {
(0..3).map(|a| (p[a] - self.anchor_pos[a]).powi(2)).sum::<f64>().sqrt()
}
/// Signed range residual `predicted - measured` (m).
#[must_use]
pub fn residual(&self, p: [f64; 3]) -> f64 {
self.predicted_range(p) - self.measured_range_m
}
/// Mahalanobis distance `|residual| / uncertainty` (σ units).
#[must_use]
pub fn mahalanobis(&self, p: [f64; 3]) -> f64 {
let u = self.uncertainty_m.max(1e-6);
self.residual(p).abs() / u
}
/// Whether a candidate position is consistent with this constraint within
/// `gate_sigma` σ.
#[must_use]
pub fn is_consistent(&self, p: [f64; 3], gate_sigma: f64) -> bool {
self.mahalanobis(p) <= gate_sigma
}
}
/// Outcome of a constraint-aware refinement (ADR-144 §2.3).
#[derive(Debug, Clone)]
pub struct RefineResult {
/// Refined position estimate (east, north, up) in metres.
pub position: [f64; 3],
/// RMS Mahalanobis residual over the *admitted* constraints after refining.
pub rms_residual_sigma: f64,
/// Anchor ids gated out as inconsistent at the final estimate.
pub rejected_anchors: Vec<u32>,
/// Number of gradient iterations performed.
pub iterations: usize,
}
/// Constraint-aware position refiner (ADR-144 §2.3).
///
/// Minimises `Σ ((|p - aᵢ| - rᵢ) / σᵢ)²` over admitted constraints by gradient
/// descent from the CSI/CIR prior, gating out constraints beyond `gate_sigma`.
/// One-step weighting by `1/σ²` makes precise ranges dominate.
#[derive(Debug, Clone)]
pub struct RangeConstraintFusion {
/// Mahalanobis gate (σ) for admitting a constraint.
pub gate_sigma: f64,
/// Gradient step size (m per unit gradient).
pub step: f64,
/// Maximum iterations.
pub max_iters: usize,
/// Convergence threshold on the position update norm (m).
pub tol_m: f64,
}
impl Default for RangeConstraintFusion {
fn default() -> Self {
Self { gate_sigma: 3.0, step: 1.0, max_iters: 200, tol_m: 1e-4 }
}
}
impl RangeConstraintFusion {
/// Refine `prior` (the CSI/CIR estimate) against the range constraints.
/// Constraints inconsistent at the *prior* are gated out up front so a
/// gross outlier cannot drag the solution.
#[must_use]
pub fn refine(&self, prior: [f64; 3], constraints: &[RangeConstraint]) -> RefineResult {
// Admit constraints consistent at the prior; record the rest.
let mut admitted: Vec<&RangeConstraint> = Vec::new();
let mut rejected_anchors = Vec::new();
for c in constraints {
if c.is_consistent(prior, self.gate_sigma) {
admitted.push(c);
} else {
rejected_anchors.push(c.anchor_id);
}
}
let mut p = prior;
let mut iterations = 0;
if !admitted.is_empty() {
for _ in 0..self.max_iters {
iterations += 1;
// Gradient of Σ w·(d - r)² w.r.t. p, with w = 1/σ². Normalising
// by the total weight (≈ the Hessian's dominant eigenvalue / 2)
// turns the descent into a Newton-like step that is invariant to
// the absolute weight scale — otherwise a small σ (large w) makes
// a plain gradient step overshoot and diverge.
let mut grad = [0.0f64; 3];
let mut sum_w = 0.0f64;
for c in &admitted {
let d = c.predicted_range(p).max(1e-9);
let w = 1.0 / (c.uncertainty_m.max(1e-6)).powi(2);
sum_w += w;
let coeff = 2.0 * w * (d - c.measured_range_m) / d;
for a in 0..3 {
grad[a] += coeff * (p[a] - c.anchor_pos[a]);
}
}
let scale = self.step / (2.0 * sum_w.max(1e-12));
let mut upd_norm = 0.0;
for a in 0..3 {
let delta = scale * grad[a];
p[a] -= delta;
upd_norm += delta * delta;
}
if upd_norm.sqrt() < self.tol_m {
break;
}
}
}
// RMS Mahalanobis residual over admitted constraints at the solution.
let rms_residual_sigma = if admitted.is_empty() {
f64::INFINITY
} else {
let ss: f64 = admitted.iter().map(|c| c.mahalanobis(p).powi(2)).sum();
(ss / admitted.len() as f64).sqrt()
};
RefineResult { position: p, rms_residual_sigma, rejected_anchors, iterations }
}
/// Associate a constraint to the most consistent of several candidate track
/// positions (ADR-144 §2 — disambiguate which track a range belongs to).
/// Returns the index of the track with the smallest Mahalanobis distance
/// that is also within the gate, or `None` if none qualify.
#[must_use]
pub fn associate(&self, tracks: &[[f64; 3]], c: &RangeConstraint) -> Option<usize> {
tracks
.iter()
.enumerate()
.map(|(i, &t)| (i, c.mahalanobis(t)))
.filter(|(_, m)| *m <= self.gate_sigma)
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(i, _)| i)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rc(id: u32, pos: [f64; 3], range: f64) -> RangeConstraint {
RangeConstraint {
anchor_id: id,
anchor_pos: pos,
measured_range_m: range,
uncertainty_m: 0.1,
signal_quality: 0.9,
at_ns: 0,
}
}
#[test]
fn refine_converges_to_true_point() {
// True tag at (2, 2, 0); 3 anchors with exact ranges.
let truth: [f64; 3] = [2.0, 2.0, 0.0];
let anchors: [[f64; 3]; 3] = [[0.0, 0.0, 0.0], [4.0, 0.0, 0.0], [0.0, 4.0, 0.0]];
// UWB σ = 0.3 m (gate 0.9 m): the CSI prior must already be roughly in
// the right place — UWB refines it, it does not localise from scratch.
let constraints: Vec<RangeConstraint> = anchors
.iter()
.enumerate()
.map(|(i, &a)| {
let r = ((truth[0] - a[0]).powi(2) + (truth[1] - a[1]).powi(2) + (truth[2] - a[2]).powi(2)).sqrt();
RangeConstraint { uncertainty_m: 0.3, ..rc(i as u32, a, r) }
})
.collect();
let fusion = RangeConstraintFusion::default();
// Biased CSI prior 0.7 m off-truth, within the 0.9 m gate.
let res = fusion.refine([1.5, 1.5, 0.0], &constraints);
let err = ((res.position[0] - 2.0).powi(2) + (res.position[1] - 2.0).powi(2)).sqrt();
assert!(err < 0.05, "refined within 5 cm of truth, got err={err}");
assert!(res.rejected_anchors.is_empty());
assert!(res.rms_residual_sigma < 1.0);
}
#[test]
fn inconsistent_constraint_is_gated_out() {
// Prior near truth (2,2); a bogus 100 m range from anchor 9 is rejected.
let mut constraints = vec![
rc(0, [0.0, 0.0, 0.0], 2.83),
rc(1, [4.0, 0.0, 0.0], 2.83),
];
constraints.push(rc(9, [0.0, 4.0, 0.0], 100.0)); // absurd
let fusion = RangeConstraintFusion::default();
let res = fusion.refine([2.0, 2.0, 0.0], &constraints);
assert!(res.rejected_anchors.contains(&9), "absurd range gated out");
}
#[test]
fn consistency_gate_and_residual() {
let c = rc(0, [0.0, 0.0, 0.0], 5.0);
// Point at distance 5.0 → zero residual, consistent.
assert!(c.residual([5.0, 0.0, 0.0]).abs() < 1e-9);
assert!(c.is_consistent([5.0, 0.0, 0.0], 3.0));
// Point at distance 5.5 → 0.5 m / 0.1 = 5σ → inconsistent at 3σ gate.
assert!(!c.is_consistent([5.5, 0.0, 0.0], 3.0));
assert!((c.mahalanobis([5.5, 0.0, 0.0]) - 5.0).abs() < 1e-6);
}
#[test]
fn associate_picks_nearest_consistent_track() {
let c = rc(0, [0.0, 0.0, 0.0], 3.0); // anchor at origin, range 3
let fusion = RangeConstraintFusion::default();
// Track A at distance 3 (consistent), B at distance 8 (way off).
let tracks = [[3.0, 0.0, 0.0], [8.0, 0.0, 0.0]];
assert_eq!(fusion.associate(&tracks, &c), Some(0));
// If no track is within gate, None.
let far = [[20.0, 0.0, 0.0], [25.0, 0.0, 0.0]];
assert_eq!(fusion.associate(&far, &c), None);
}
}
+2
View File
@@ -35,6 +35,8 @@ pub mod error;
pub mod inference;
#[cfg(feature = "onnx")]
pub mod onnx;
/// ADR-146 — RF encoder multi-task heads + uncertainty + contrastive batcher.
pub mod rf_encoder;
pub mod tensor;
pub mod translator;
@@ -0,0 +1,347 @@
//! ADR-146 — RF encoder multi-task heads + uncertainty quantification.
//!
//! Extends ADR-024 (AETHER contrastive embedding) with seven task-specific head
//! branches over a shared RF embedding, per-head uncertainty, a
//! calibration-robustness loss tying invariance to the ADR-135 `calibration_id`,
//! and a `ContrastiveBatcher` sampling contract. The tensor ABI is **pure-Rust
//! `f32`** (no backend-specific tensor type at this boundary) so inference is
//! deterministic and witnessable (ADR-136 §2.5) and a head can be toggled by the
//! ADR-145 ablation matrix.
/// Shared RF embedding dimension (ADR-146 / ADR-024 AETHER).
pub const EMBEDDING_DIM: usize = 256;
/// A 256-d shared RF embedding (pure-Rust f32 ABI).
#[derive(Debug, Clone, PartialEq)]
pub struct RfEmbedding(pub Vec<f32>);
impl RfEmbedding {
/// Wrap a vector, asserting it is [`EMBEDDING_DIM`] long.
#[must_use]
pub fn new(v: Vec<f32>) -> Self {
debug_assert_eq!(v.len(), EMBEDDING_DIM, "embedding must be {EMBEDDING_DIM}-d");
Self(v)
}
/// Squared L2 distance to another embedding.
#[must_use]
pub fn sq_dist(&self, other: &RfEmbedding) -> f32 {
self.0.iter().zip(&other.0).map(|(a, b)| (a - b).powi(2)).sum()
}
}
/// The seven task heads over the shared encoder (ADR-146 §2.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TaskKind {
/// 17-keypoint pose.
Pose,
/// Binary presence.
Presence,
/// Person count.
Count,
/// Activity class.
Activity,
/// Vital signs (HR/BR).
Vitals,
/// Gait signature.
Gait,
/// Identity embedding (AETHER re-ID).
IdentityEmbedding,
}
impl TaskKind {
/// All seven heads.
pub const ALL: [TaskKind; 7] = [
TaskKind::Pose,
TaskKind::Presence,
TaskKind::Count,
TaskKind::Activity,
TaskKind::Vitals,
TaskKind::Gait,
TaskKind::IdentityEmbedding,
];
}
/// One head's output: task values plus a scalar predictive uncertainty
/// (ADR-146 §2.2). `uncertainty` mirrors the spirit of the ADR-136
/// `QualityScored` trait — lower is more confident.
#[derive(Debug, Clone, PartialEq)]
pub struct HeadOutput {
/// Which head produced this.
pub task: TaskKind,
/// Raw output activations.
pub values: Vec<f32>,
/// Predictive uncertainty in [0, ∞); softplus of a learned log-variance.
pub uncertainty: f32,
}
impl HeadOutput {
/// Confidence in [0, 1] derived from uncertainty (`1 / (1 + uncertainty)`),
/// matching the ADR-136 `QualityScored::quality_score` contract shape.
#[must_use]
pub fn confidence(&self) -> f32 {
1.0 / (1.0 + self.uncertainty)
}
}
/// A linear task head: `out = W·emb + b`, plus a separate scalar log-variance
/// projection `lv = wᵥ·emb + bᵥ` whose softplus is the predictive uncertainty.
#[derive(Debug, Clone)]
pub struct LinearHead {
task: TaskKind,
/// Row-major `[out_dim × EMBEDDING_DIM]` weights.
w: Vec<f32>,
b: Vec<f32>,
out_dim: usize,
/// Uncertainty (log-variance) projection over the embedding.
var_w: Vec<f32>,
var_b: f32,
}
impl LinearHead {
/// Build a head with given weights. `w.len()` must be `out_dim * EMBEDDING_DIM`.
#[must_use]
pub fn new(task: TaskKind, out_dim: usize, w: Vec<f32>, b: Vec<f32>, var_w: Vec<f32>, var_b: f32) -> Self {
assert_eq!(w.len(), out_dim * EMBEDDING_DIM, "weight shape mismatch");
assert_eq!(b.len(), out_dim, "bias shape mismatch");
assert_eq!(var_w.len(), EMBEDDING_DIM, "var weight shape mismatch");
Self { task, w, b, out_dim, var_w, var_b }
}
/// A zero-initialised head (uncertainty = softplus(0) ≈ 0.693).
#[must_use]
pub fn zeros(task: TaskKind, out_dim: usize) -> Self {
Self::new(
task,
out_dim,
vec![0.0; out_dim * EMBEDDING_DIM],
vec![0.0; out_dim],
vec![0.0; EMBEDDING_DIM],
0.0,
)
}
/// Forward pass over a shared embedding.
#[must_use]
pub fn forward(&self, emb: &RfEmbedding) -> HeadOutput {
let mut values = vec![0.0f32; self.out_dim];
for o in 0..self.out_dim {
let row = &self.w[o * EMBEDDING_DIM..(o + 1) * EMBEDDING_DIM];
let dot: f32 = row.iter().zip(&emb.0).map(|(wi, xi)| wi * xi).sum();
values[o] = dot + self.b[o];
}
let log_var: f32 = self.var_w.iter().zip(&emb.0).map(|(wi, xi)| wi * xi).sum::<f32>() + self.var_b;
let uncertainty = softplus(log_var);
HeadOutput { task: self.task, values, uncertainty }
}
}
fn softplus(x: f32) -> f32 {
// Numerically stable softplus.
if x > 20.0 {
x
} else {
(1.0 + x.exp()).ln()
}
}
/// Multi-task encoder: a shared embedding feeding a set of [`LinearHead`]s
/// (ADR-146 §2.1). Heads can be subset for ADR-145 ablation.
#[derive(Debug, Clone, Default)]
pub struct MultiTaskHeads {
heads: Vec<LinearHead>,
}
impl MultiTaskHeads {
/// Empty head set.
#[must_use]
pub fn new() -> Self {
Self { heads: Vec::new() }
}
/// Add a head.
pub fn push(&mut self, head: LinearHead) {
self.heads.push(head);
}
/// Number of active heads.
#[must_use]
pub fn len(&self) -> usize {
self.heads.len()
}
/// Whether no heads are configured.
#[must_use]
pub fn is_empty(&self) -> bool {
self.heads.is_empty()
}
/// Run every head on the shared embedding.
#[must_use]
pub fn forward(&self, emb: &RfEmbedding) -> Vec<HeadOutput> {
self.heads.iter().map(|h| h.forward(emb)).collect()
}
/// Run only the heads in `enabled` (ADR-145 ablation toggle).
#[must_use]
pub fn forward_subset(&self, emb: &RfEmbedding, enabled: &[TaskKind]) -> Vec<HeadOutput> {
self.heads
.iter()
.filter(|h| enabled.contains(&h.task))
.map(|h| h.forward(emb))
.collect()
}
}
/// Calibration-robustness loss (ADR-146 §2.3): the encoder should produce the
/// same embedding for the same physical input under two different ADR-135
/// calibration baselines. Returns the mean squared embedding difference — a
/// penalty that is 0 under perfect calibration invariance.
#[must_use]
pub fn calibration_robustness_loss(under_cal_a: &RfEmbedding, under_cal_b: &RfEmbedding) -> f32 {
under_cal_a.sq_dist(under_cal_b) / EMBEDDING_DIM as f32
}
/// Triplet contrastive loss (ADR-024 / ADR-146 §2.4): pull `anchor` toward
/// `positive` (same physical state), push from `negative` (different), with a
/// margin. `max(0, d(a,p) - d(a,n) + margin)`.
#[must_use]
pub fn triplet_loss(anchor: &RfEmbedding, positive: &RfEmbedding, negative: &RfEmbedding, margin: f32) -> f32 {
(anchor.sq_dist(positive) - anchor.sq_dist(negative) + margin).max(0.0)
}
/// A contrastive training triplet over the shared embedding space.
#[derive(Debug, Clone)]
pub struct Triplet {
/// Anchor sample index.
pub anchor: usize,
/// Positive (same state, different environment) index.
pub positive: usize,
/// Negative (different state) index.
pub negative: usize,
}
/// Formalised contrastive pair/triplet sampler (ADR-146 §2.4): positives are the
/// *same physical state across different environments* (cross-room invariance,
/// ADR-027 MERIDIAN); negatives are *different states*.
#[derive(Debug, Clone)]
pub struct ContrastiveBatcher {
/// `state_of[i]` = the physical-state label of sample `i`.
state_of: Vec<u32>,
/// `env_of[i]` = the environment/room label of sample `i`.
env_of: Vec<u32>,
}
impl ContrastiveBatcher {
/// Build from per-sample (state, environment) labels.
#[must_use]
pub fn new(state_of: Vec<u32>, env_of: Vec<u32>) -> Self {
assert_eq!(state_of.len(), env_of.len(), "label vectors must align");
Self { state_of, env_of }
}
/// Deterministically enumerate triplets: for each anchor, the first sample
/// with the *same state but a different environment* is the positive, and
/// the first sample with a *different state* is the negative. Anchors with
/// no valid positive or negative are skipped. Determinism (lowest-index
/// choice) keeps the batch witnessable (ADR-136 §2.5).
#[must_use]
pub fn triplets(&self) -> Vec<Triplet> {
let n = self.state_of.len();
let mut out = Vec::new();
for a in 0..n {
let positive = (0..n).find(|&p| {
p != a && self.state_of[p] == self.state_of[a] && self.env_of[p] != self.env_of[a]
});
let negative = (0..n).find(|&q| self.state_of[q] != self.state_of[a]);
if let (Some(positive), Some(negative)) = (positive, negative) {
out.push(Triplet { anchor: a, positive, negative });
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn emb(fill: f32) -> RfEmbedding {
RfEmbedding::new(vec![fill; EMBEDDING_DIM])
}
#[test]
fn head_forward_produces_values_and_finite_uncertainty() {
let head = LinearHead::zeros(TaskKind::Presence, 2);
let out = head.forward(&emb(1.0));
assert_eq!(out.values, vec![0.0, 0.0]); // zero weights
assert!(out.uncertainty.is_finite() && out.uncertainty > 0.0);
assert!((out.confidence() - 1.0 / (1.0 + out.uncertainty)).abs() < 1e-6);
}
#[test]
fn uncertainty_responds_to_log_variance_weights() {
// var_w all 1 → log_var = sum(emb) = 256 → softplus ≈ 256 (clamped path).
let head = LinearHead::new(
TaskKind::Vitals,
1,
vec![0.0; EMBEDDING_DIM],
vec![0.0],
vec![1.0; EMBEDDING_DIM],
0.0,
);
let out = head.forward(&emb(1.0));
assert!(out.uncertainty > 100.0, "high log-var → high uncertainty");
assert!(out.confidence() < 0.02);
}
#[test]
fn calibration_robustness_loss_zero_for_identical() {
assert_eq!(calibration_robustness_loss(&emb(0.5), &emb(0.5)), 0.0);
assert!(calibration_robustness_loss(&emb(0.0), &emb(1.0)) > 0.0);
}
#[test]
fn triplet_loss_properties() {
let a = emb(0.0);
let p = emb(0.1); // close
let n = emb(5.0); // far
// d(a,p) << d(a,n) → loss should be 0 with a modest margin.
assert_eq!(triplet_loss(&a, &p, &n, 0.5), 0.0);
// Swap: positive far, negative close → positive loss.
assert!(triplet_loss(&a, &n, &p, 0.5) > 0.0);
}
#[test]
fn multitask_subset_ablation() {
let mut heads = MultiTaskHeads::new();
heads.push(LinearHead::zeros(TaskKind::Presence, 1));
heads.push(LinearHead::zeros(TaskKind::Pose, 51));
heads.push(LinearHead::zeros(TaskKind::Vitals, 2));
assert_eq!(heads.forward(&emb(1.0)).len(), 3);
// Ablate to just presence + vitals.
let sub = heads.forward_subset(&emb(1.0), &[TaskKind::Presence, TaskKind::Vitals]);
assert_eq!(sub.len(), 2);
assert!(sub.iter().all(|o| o.task != TaskKind::Pose));
}
#[test]
fn contrastive_batcher_samples_cross_env_positives() {
// samples: 0=(stateA,room0) 1=(stateA,room1) 2=(stateB,room0)
let b = ContrastiveBatcher::new(vec![0, 0, 1], vec![0, 1, 0]);
let trips = b.triplets();
// Anchor 0: positive=1 (same state, diff room), negative=2 (diff state).
let t0 = trips.iter().find(|t| t.anchor == 0).unwrap();
assert_eq!(t0.positive, 1);
assert_eq!(t0.negative, 2);
// Anchor 2 (stateB) has no same-state-diff-env positive → skipped.
assert!(trips.iter().all(|t| t.anchor != 2));
// Deterministic.
assert_eq!(b.triplets().len(), trips.len());
}
#[test]
fn seven_task_heads() {
assert_eq!(TaskKind::ALL.len(), 7);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-ruvector"
version.workspace = true
version = "0.3.1" # ADR-138: ClockQualityGate / clock-quality coherence gate
edition.workspace = true
authors.workspace = true
license.workspace = true
@@ -212,6 +212,109 @@ impl CoherenceGate {
}
}
// ---------------------------------------------------------------------------
// ADR-138 — Clock-quality gate (coherence × clock dispersion/age)
// ---------------------------------------------------------------------------
/// Per-node clock-synchronisation quality (ADR-138 §2.2), derived from the
/// ADR-110 802.15.4 time-sync follower offset statistics.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClockQualityScore {
/// EMA-smoothed follower offset standard deviation (µs). ADR-110 measured
/// ~104 µs against the ±100 µs target on COM9↔COM12.
pub offset_stdev_us: f32,
/// Age of the most recent sync packet (µs). The sensing server enforces a
/// 9 s staleness ceiling.
pub age_us: u64,
/// Whether a valid sync has ever been observed for this node.
pub valid: bool,
}
impl ClockQualityScore {
/// Scalar clock quality in `[0, 1]` (1 = perfectly synced). Reaches 0 at
/// `5 × max_offset_stdev_us`; used to bias directional attention weights.
#[must_use]
pub fn quality(&self, max_offset_stdev_us: f32) -> f32 {
if !self.valid || max_offset_stdev_us <= 0.0 {
return 0.0;
}
(1.0 - self.offset_stdev_us / (5.0 * max_offset_stdev_us)).clamp(0.0, 1.0)
}
}
/// Why a node failed the clock-quality gate hard.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClockRejectReason {
/// Phase coherence below the gate threshold.
Incoherent,
/// Sync packet older than the staleness ceiling.
ClockStale,
/// Offset dispersion far beyond the floor (≥ 5× the monitor threshold).
ClockDispersed,
/// No valid sync ever observed for this node.
ClockInvalid,
}
/// One node's gate decision for one sensing cycle (ADR-138 §2.2).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClockGateDecision {
/// Both terms pass: node admitted at full weight.
Admit,
/// Phase OK but clock degraded: evidence-only, NO environment/model update.
MonitorOnly { clock_quality: f32 },
/// Either term fails hard: node excluded this cycle.
Reject { reason: ClockRejectReason },
}
/// Clock-quality gate: combines the phase [`CoherenceGate`] with clock
/// dispersion and age terms (ADR-138 §2.2).
#[derive(Debug, Clone)]
pub struct ClockQualityGate {
/// Phase-coherence gate (threshold + hysteresis).
pub coherence: CoherenceGate,
/// Offset-stdev floor (µs): at or above ⇒ `MonitorOnly`. Default 200.0.
pub max_offset_stdev_us: f32,
/// Sync-age ceiling (µs): above ⇒ hard reject. Default 9_000_000.
pub max_age_us: u64,
}
impl ClockQualityGate {
/// Construct from a phase gate and the two clock thresholds.
pub fn new(coherence: CoherenceGate, max_offset_stdev_us: f32, max_age_us: u64) -> Self {
Self { coherence, max_offset_stdev_us, max_age_us }
}
/// Defaults: phase gate 0.7/0.05, 200 µs floor, 9 s staleness ceiling.
pub fn default_params() -> Self {
Self::new(CoherenceGate::default_params(), 200.0, 9_000_000)
}
/// Evaluate both terms for one node this cycle. `coherence_value` is the
/// rolling phasor coherence ([`CoherenceState::coherence`]).
pub fn evaluate(&mut self, coherence_value: f32, clock: &ClockQualityScore) -> ClockGateDecision {
if !clock.valid {
return ClockGateDecision::Reject { reason: ClockRejectReason::ClockInvalid };
}
if clock.age_us > self.max_age_us {
return ClockGateDecision::Reject { reason: ClockRejectReason::ClockStale };
}
if clock.offset_stdev_us >= 5.0 * self.max_offset_stdev_us {
return ClockGateDecision::Reject { reason: ClockRejectReason::ClockDispersed };
}
// Phase term (hysteretic). Clock-degraded but coherent ⇒ MonitorOnly.
if !self.coherence.evaluate(coherence_value) {
return ClockGateDecision::Reject { reason: ClockRejectReason::Incoherent };
}
if clock.offset_stdev_us >= self.max_offset_stdev_us {
ClockGateDecision::MonitorOnly {
clock_quality: clock.quality(self.max_offset_stdev_us),
}
} else {
ClockGateDecision::Admit
}
}
}
/// Stateless coherence gate function matching the ADR-031 specification.
///
/// Computes the complex mean of unit phasors from the given phase differences
@@ -388,4 +491,42 @@ mod tests {
}
assert_eq!(state.len(), 5, "count should be capped at window size");
}
// ===== ADR-138 clock-quality gate =====
#[test]
fn clock_gate_invalid_rejected() {
let mut g = ClockQualityGate::default_params();
let c = ClockQualityScore { offset_stdev_us: 10.0, age_us: 0, valid: false };
assert_eq!(
g.evaluate(0.9, &c),
ClockGateDecision::Reject { reason: ClockRejectReason::ClockInvalid }
);
}
#[test]
fn clock_gate_dispersed_rejected() {
let mut g = ClockQualityGate::default_params(); // floor 200 → 5× = 1000 µs
let c = ClockQualityScore { offset_stdev_us: 1500.0, age_us: 0, valid: true };
assert_eq!(
g.evaluate(0.9, &c),
ClockGateDecision::Reject { reason: ClockRejectReason::ClockDispersed }
);
}
#[test]
fn clock_gate_admit_and_monitor_and_quality() {
let mut g = ClockQualityGate::default_params();
let good = ClockQualityScore { offset_stdev_us: 50.0, age_us: 0, valid: true };
assert_eq!(g.evaluate(0.9, &good), ClockGateDecision::Admit);
// quality: 1 - 50/(5*200) = 0.95
assert!((good.quality(200.0) - 0.95).abs() < 1e-4);
let mut g2 = ClockQualityGate::default_params();
let degraded = ClockQualityScore { offset_stdev_us: 250.0, age_us: 0, valid: true };
assert!(matches!(
g2.evaluate(0.9, &degraded),
ClockGateDecision::MonitorOnly { .. }
));
}
}
@@ -22,6 +22,9 @@ pub mod geometry;
// Re-export primary types at the module root for ergonomic imports.
pub use attention::{CrossViewpointAttention, GeometricBias};
pub use coherence::{CoherenceGate, CoherenceState};
pub use coherence::{
ClockGateDecision, ClockQualityGate, ClockQualityScore, ClockRejectReason, CoherenceGate,
CoherenceState,
};
pub use fusion::{FusedEmbedding, FusionConfig, MultistaticArray, ViewpointEmbedding};
pub use geometry::{CramerRaoBound, GeometricDiversityIndex};
@@ -59,5 +59,11 @@ mod no_movement;
mod room_active;
mod sleeping;
// ADR-140: auditable semantic-state record + Ruflo multi-signal agent bridge.
pub mod record;
pub use bus::{SemanticBus, SemanticEvent, SemanticKind};
pub use common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
pub use record::{
AgentRoute, MultiSignalRule, PrivacyAction, RecordContext, SemanticStateRecord, route_all,
};
@@ -0,0 +1,343 @@
//! ADR-140 — `SemanticStateRecord`: the auditable, versioned, privacy-gated
//! wire form of a semantic belief, plus the Ruflo agent-bridge routing that
//! fires on *multi-signal agreement* (e.g. fall-risk + elderly-anomaly →
//! caregiver escalation).
//!
//! This extends the existing [`SemanticEvent`](super::bus::SemanticEvent)
//! (kind/state/node/timestamp) with the provenance the house rule mandates:
//! model version, calibration version, privacy action, expiry, confidence,
//! room, and evidence refs. Each record is the wire form of an ADR-139
//! `WorldNode::SemanticState`.
use super::bus::{SemanticEvent, SemanticKind};
use super::common::PrimitiveState;
/// Privacy action enforced at the semantic layer (ADR-140 §2 → ADR-141).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivacyAction {
/// Emit the full record (RawResearch / CareWithConsent style).
Allow,
/// Drop person-identifying detail, keep room-level occupancy.
AnonymizeByRoom,
/// Strip biometric scalars (HR/BR) from the emitted record.
StripBiometrics,
}
/// Per-deployment context used to enrich a [`SemanticEvent`] into a
/// [`SemanticStateRecord`]. Loaded from the model/calibration manifest.
#[derive(Debug, Clone)]
pub struct RecordContext {
/// Model version that produced the underlying belief (ADR-136 `model_id`).
pub model_version: String,
/// Calibration version (ADR-135 baseline id) in effect.
pub calibration_version: String,
/// Active privacy action (ADR-141 mode → action).
pub privacy_action: PrivacyAction,
/// Record time-to-live (ms); `expiry_at = timestamp_ms + default_ttl_ms`.
pub default_ttl_ms: i64,
}
impl Default for RecordContext {
fn default() -> Self {
Self {
model_version: "unassigned".into(),
calibration_version: "uncalibrated".into(),
privacy_action: PrivacyAction::Allow,
default_ttl_ms: 30_000,
}
}
}
/// Auditable, versioned semantic state record (ADR-140 §2.1).
#[derive(Debug, Clone, PartialEq)]
pub struct SemanticStateRecord {
/// Which primitive produced this belief.
pub kind: SemanticKind,
/// Room/area this belief is scoped to (None = whole installation).
pub room: Option<String>,
/// Source sensing node id.
pub node_id: String,
/// Capture time (Unix ms).
pub timestamp_ms: i64,
/// Belief expiry (Unix ms); after this the record is stale.
pub expiry_at_ms: i64,
/// Confidence in [0, 1].
pub confidence: f32,
/// Model version (ADR-136).
pub model_version: String,
/// Calibration version (ADR-135).
pub calibration_version: String,
/// Privacy action under which it was derived (ADR-141).
pub privacy_action: PrivacyAction,
/// Evidence refs (ADR-137) — here, the human-readable reason tags.
pub evidence_refs: Vec<String>,
/// Whether the underlying primitive is currently "active"/firing.
pub active: bool,
}
impl SemanticStateRecord {
/// Enrich a [`SemanticEvent`] into a record using deployment context and a
/// room mapping for the event's node.
#[must_use]
pub fn from_event(event: &SemanticEvent, room: Option<String>, ctx: &RecordContext) -> Self {
let (confidence, active, mut evidence_refs) = match &event.state {
PrimitiveState::Boolean { active, reason, .. } => {
(if *active { 0.9 } else { 0.1 }, *active, reason.tags.clone())
}
PrimitiveState::Scalar { value, reason } => {
((*value as f32 / 100.0).clamp(0.0, 1.0), *value > 0.0, reason.tags.clone())
}
PrimitiveState::Event { event_type, reason } => {
let mut t = reason.tags.clone();
t.push(format!("event={event_type}"));
(1.0, true, t)
}
PrimitiveState::Idle => (0.0, false, Vec::new()),
};
// Privacy enforcement at the record boundary.
if ctx.privacy_action == PrivacyAction::StripBiometrics {
evidence_refs.retain(|t| !is_biometric_tag(t));
}
Self {
kind: event.kind,
room,
node_id: event.node_id.clone(),
timestamp_ms: event.timestamp_ms,
expiry_at_ms: event.timestamp_ms + ctx.default_ttl_ms,
confidence,
model_version: ctx.model_version.clone(),
calibration_version: ctx.calibration_version.clone(),
privacy_action: ctx.privacy_action,
evidence_refs,
active,
}
}
/// Whether this record is still valid at `now_ms`.
#[must_use]
pub fn is_fresh(&self, now_ms: i64) -> bool {
now_ms < self.expiry_at_ms
}
}
fn is_biometric_tag(tag: &str) -> bool {
let t = tag.to_ascii_lowercase();
t.contains("hr=") || t.contains("br=") || t.contains("bpm")
}
// ---- ADR-140 §2.3 Ruflo agent bridge: multi-signal agreement routing ----
/// A routing decision handed to the ADR-133 HOMECORE-ASSIST / Ruflo layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentRoute {
/// Stable route identifier (e.g. "caregiver_escalation").
pub route_id: &'static str,
/// Severity 0..=3 (info, notice, warning, critical).
pub severity: u8,
}
/// A rule that fires a route when *all* required kinds are simultaneously
/// active and fresh in the candidate record set (multi-signal agreement).
#[derive(Debug, Clone)]
pub struct MultiSignalRule {
/// Kinds that must all be active+fresh for the route to fire.
pub required_kinds: Vec<SemanticKind>,
/// Minimum confidence each required record must meet.
pub min_confidence: f32,
/// Route emitted when the rule matches.
pub route: AgentRoute,
}
impl MultiSignalRule {
/// Evaluate the rule against fresh, active records at `now_ms`. Returns the
/// route iff every required kind has at least one active record meeting
/// `min_confidence`. Routing on agreement (not a single signal) is what
/// suppresses single-primitive false positives for high-impact actions.
#[must_use]
pub fn evaluate(&self, records: &[SemanticStateRecord], now_ms: i64) -> Option<AgentRoute> {
let all_present = self.required_kinds.iter().all(|k| {
records.iter().any(|r| {
r.kind == *k && r.active && r.is_fresh(now_ms) && r.confidence >= self.min_confidence
})
});
all_present.then(|| self.route.clone())
}
}
/// Evaluate every rule, returning the matched routes (deduped by route_id,
/// highest severity first).
#[must_use]
pub fn route_all(
rules: &[MultiSignalRule],
records: &[SemanticStateRecord],
now_ms: i64,
) -> Vec<AgentRoute> {
let mut routes: Vec<AgentRoute> =
rules.iter().filter_map(|r| r.evaluate(records, now_ms)).collect();
routes.sort_by(|a, b| b.severity.cmp(&a.severity).then(a.route_id.cmp(b.route_id)));
routes.dedup();
routes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::semantic::common::Reason;
fn event(kind: SemanticKind, state: PrimitiveState, ts: i64) -> SemanticEvent {
SemanticEvent { kind, state, node_id: "node-1".into(), timestamp_ms: ts }
}
#[test]
fn record_from_scalar_event_carries_provenance() {
let ctx = RecordContext {
model_version: "rfenc-1.2".into(),
calibration_version: "cal:abc".into(),
privacy_action: PrivacyAction::Allow,
default_ttl_ms: 30_000,
};
let ev = event(
SemanticKind::FallRisk,
PrimitiveState::Scalar { value: 80.0, reason: Reason::new(&["accel_spike", "hr=110bpm"]) },
1_000,
);
let r = SemanticStateRecord::from_event(&ev, Some("living_room".into()), &ctx);
assert_eq!(r.model_version, "rfenc-1.2");
assert_eq!(r.calibration_version, "cal:abc");
assert_eq!(r.expiry_at_ms, 31_000);
assert!((r.confidence - 0.8).abs() < 1e-6);
assert!(r.active);
assert_eq!(r.room.as_deref(), Some("living_room"));
assert!(r.is_fresh(20_000) && !r.is_fresh(31_000));
}
#[test]
fn strip_biometrics_removes_hr_br_tags() {
let ctx = RecordContext { privacy_action: PrivacyAction::StripBiometrics, ..Default::default() };
let ev = event(
SemanticKind::PossibleDistress,
PrimitiveState::Scalar { value: 50.0, reason: Reason::new(&["motion<5%", "hr=130bpm", "br=22"]) },
0,
);
let r = SemanticStateRecord::from_event(&ev, None, &ctx);
assert_eq!(r.evidence_refs, vec!["motion<5%".to_string()]);
}
#[test]
fn multi_signal_rule_fires_only_on_agreement() {
let now = 1_000;
let ctx = RecordContext::default();
let fall = SemanticStateRecord::from_event(
&event(SemanticKind::FallRisk, PrimitiveState::Scalar { value: 90.0, reason: Reason::empty() }, now),
Some("bedroom".into()), &ctx,
);
let elderly = SemanticStateRecord::from_event(
&event(SemanticKind::ElderlyAnomaly, PrimitiveState::Boolean { active: true, changed: true, reason: Reason::empty() }, now),
Some("bedroom".into()), &ctx,
);
let rule = MultiSignalRule {
required_kinds: vec![SemanticKind::FallRisk, SemanticKind::ElderlyAnomaly],
min_confidence: 0.5,
route: AgentRoute { route_id: "caregiver_escalation", severity: 3 },
};
// Only fall present → no route (no agreement).
assert_eq!(rule.evaluate(&[fall.clone()], now), None);
// Both present + active + fresh → route fires.
assert_eq!(
rule.evaluate(&[fall.clone(), elderly.clone()], now),
Some(AgentRoute { route_id: "caregiver_escalation", severity: 3 })
);
// Stale records do not fire.
assert_eq!(rule.evaluate(&[fall.clone(), elderly.clone()], now + 60_000), None);
}
/// ADR-140 acceptance (the credibility path):
/// `raw snapshot -> semantic primitive -> SemanticStateRecord ->
/// (HOMECORE state) -> Ruflo agreement rule -> expired record rejected`.
#[test]
fn acceptance_raw_snapshot_to_expired_rejection() {
use crate::semantic::bus::SemanticBus;
use crate::semantic::common::{PrimitiveConfig, RawSnapshot};
use std::time::Duration;
// raw snapshot (past the warmup window) with a fall detected.
let mut bus = SemanticBus::new(PrimitiveConfig::default());
let snap = RawSnapshot {
node_id: "living_room".into(),
since_start: Duration::from_secs(61),
timestamp_ms: 1_000,
fall_detected: true,
motion: 0.5,
..Default::default()
};
// raw snapshot -> semantic primitive (real SemanticBus FSM tick).
let events = bus.tick(&snap);
let fall = events
.iter()
.find(|e| e.kind == SemanticKind::FallRisk)
.expect("fall_detected past warmup must emit a FallRisk primitive");
// semantic primitive -> SemanticStateRecord (provenance from real context).
let ctx = RecordContext {
model_version: "rfenc-v1".into(),
calibration_version: "cal:abc".into(),
privacy_action: PrivacyAction::Allow,
default_ttl_ms: 30_000,
};
let rec = SemanticStateRecord::from_event(fall, Some("living_room".into()), &ctx);
// -> HOMECORE state: the record IS the operational state (room + provenance).
assert!(rec.active && rec.confidence > 0.0);
assert_eq!(rec.room.as_deref(), Some("living_room"));
assert_eq!(rec.model_version, "rfenc-v1");
assert_eq!(rec.calibration_version, "cal:abc");
let now = snap.timestamp_ms;
// -> Ruflo agreement rule. A single-signal rule fires on the fresh record;
// a genuine multi-signal rule does NOT (agreement required → no false alarm).
let single = MultiSignalRule {
required_kinds: vec![SemanticKind::FallRisk],
min_confidence: 0.1,
route: AgentRoute { route_id: "fall_notice", severity: 2 },
};
assert!(single.evaluate(std::slice::from_ref(&rec), now).is_some());
let agreement = MultiSignalRule {
required_kinds: vec![SemanticKind::FallRisk, SemanticKind::ElderlyAnomaly],
min_confidence: 0.1,
route: AgentRoute { route_id: "caregiver_escalation", severity: 3 },
};
assert!(
agreement.evaluate(std::slice::from_ref(&rec), now).is_none(),
"no caregiver escalation without multi-signal agreement"
);
// -> expired record rejected (stale belief must not become fake truth).
let after_expiry = rec.expiry_at_ms + 1;
assert!(!rec.is_fresh(after_expiry));
assert!(
single.evaluate(std::slice::from_ref(&rec), after_expiry).is_none(),
"an expired record fires no route"
);
}
#[test]
fn route_all_sorts_by_severity_and_dedups() {
let now = 0;
let ctx = RecordContext::default();
let active = |k| SemanticStateRecord::from_event(
&event(k, PrimitiveState::Boolean { active: true, changed: true, reason: Reason::empty() }, now),
None, &ctx,
);
let records = vec![active(SemanticKind::FallRisk), active(SemanticKind::NoMovement)];
let rules = vec![
MultiSignalRule { required_kinds: vec![SemanticKind::FallRisk], min_confidence: 0.5, route: AgentRoute { route_id: "fall_notice", severity: 2 } },
MultiSignalRule { required_kinds: vec![SemanticKind::NoMovement, SemanticKind::FallRisk], min_confidence: 0.5, route: AgentRoute { route_id: "safety_critical", severity: 3 } },
];
let routes = route_all(&rules, &records, now);
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].route_id, "safety_critical"); // higher severity first
}
}
+14 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-signal"
version = "0.3.1"
version = "0.3.2" # ADR-137/138/142/143: fuse_scored_calibrated, ArrayCoordinator, evolution, rf_slam, calibration apply
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
@@ -46,6 +46,9 @@ ruvector-solver = { workspace = true }
midstreamer-temporal-compare = { workspace = true }
midstreamer-attractor = { workspace = true }
# ADR-136: deterministic calibration-id → FrameMeta.calibration_id (ADR-135 link)
uuid = { version = "1.6", features = ["v4"] }
# Internal
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
# ADR-084 Pass 2: sketch-prefilter for the EmbeddingHistory search loop.
@@ -79,3 +82,13 @@ path = "src/bin/cir_proof_runner.rs"
# implementation agent; this addition is purely additive.
[dependencies.sha2]
workspace = true
## ADR-135: calibration module throughput benchmarks
[[bench]]
name = "calibration_bench"
harness = false
# ADR-135: calibration deterministic proof runner binary.
[[bin]]
name = "calibration_proof_runner"
path = "src/bin/calibration_proof_runner.rs"
@@ -0,0 +1,246 @@
//! Criterion benchmarks for the empty-room baseline calibration module (ADR-135).
//!
//! Measures per-call throughput of CalibrationRecorder and BaselineCalibration
//! across HT20 (K=52), HT40 (K=114), HE20 (K=242), and HE40 (K=484).
//!
//! Run (compile-only — no execution):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench calibration_bench --no-run
//!
//! Run to completion (generates HTML in target/criterion/):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench calibration_bench
use std::f64::consts::PI;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{
BaselineCalibration, CalibrationConfig, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0);
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_f64(&mut self) -> f64 {
(self.next_u32() as f64 + 1.0) / (u32::MAX as f64 + 2.0)
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_f64();
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
}
// ---------------------------------------------------------------------------
// Tier specification table
// ---------------------------------------------------------------------------
struct TierSpec {
label: &'static str,
n_active: usize,
bandwidth_mhz: u16,
config: CalibrationConfig,
}
fn tiers() -> Vec<TierSpec> {
vec![
TierSpec { label: "ht20", n_active: 52, bandwidth_mhz: 20, config: CalibrationConfig::ht20() },
TierSpec { label: "ht40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() },
TierSpec { label: "he20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() },
TierSpec { label: "he40", n_active: 484, bandwidth_mhz: 40, config: CalibrationConfig::he40() },
]
}
// ---------------------------------------------------------------------------
// Synthetic CSI frame builder (stationary, seed=42)
// ---------------------------------------------------------------------------
fn make_frame(n_active: usize, bandwidth_mhz: u16, rng: &mut Rng) -> CsiFrame {
let noise_std = 0.01_f64;
let mut data = Array2::<Complex64>::zeros((1, n_active));
for k in 0..n_active {
let amp = 0.3 + 0.7 * (k as f64 * PI / n_active as f64).sin().abs();
let phase = (k as f64 * 0.1).rem_euclid(2.0 * PI) - PI;
let re = amp * phase.cos() + noise_std * rng.next_normal();
let im = amp * phase.sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re, im);
}
let mut meta = CsiMetadata::new(DeviceId::new("bench"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = bandwidth_mhz;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
/// Build a `CalibrationRecorder` that has already absorbed 600 frames.
fn pre_loaded_recorder(spec: &TierSpec) -> CalibrationRecorder {
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng);
recorder.record(&frame).expect("record should succeed in bench setup");
}
recorder
}
/// Build a finalised `BaselineCalibration` for deviation and to_bytes benches.
fn finalised_baseline(spec: &TierSpec) -> BaselineCalibration {
pre_loaded_recorder(spec)
.finalize()
.expect("finalize should succeed in bench setup")
}
// ---------------------------------------------------------------------------
// Bench 1: bench_recorder_record/<tier> — single record() call (hot path)
// ---------------------------------------------------------------------------
fn bench_recorder_record(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_recorder_record");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
let mut rng = Rng::new(42);
let frame = make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
group.bench_with_input(
BenchmarkId::from_parameter(spec.label),
&frame,
|b, f| {
b.iter(|| {
// Accumulate into a shared recorder — measures per-call cost of record().
black_box(recorder.record(black_box(f)).ok())
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 2: bench_recorder_finalize/<tier> — finalize() from 600 pre-loaded frames
// ---------------------------------------------------------------------------
fn bench_recorder_finalize(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_recorder_finalize");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
group.bench_function(BenchmarkId::from_parameter(spec.label), |b| {
b.iter_with_setup(
|| pre_loaded_recorder(&spec),
|recorder| {
black_box(recorder.finalize().ok())
},
);
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 3: bench_deviation/<tier> — deviation() on a single frame
// ---------------------------------------------------------------------------
fn bench_deviation(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_deviation");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
let baseline = finalised_baseline(&spec);
let mut rng = Rng::new(42);
let frame = make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng);
group.bench_with_input(
BenchmarkId::from_parameter(spec.label),
&frame,
|b, f| {
b.iter(|| {
black_box(baseline.deviation(black_box(f)).ok())
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 4: bench_record_600/<tier> — full 600-frame record session
// ---------------------------------------------------------------------------
fn bench_record_600(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_record_600");
for spec in tiers() {
group.throughput(Throughput::Elements(600 * spec.n_active as u64));
// Pre-build 600 frames to avoid contaminating bench with frame construction.
let mut rng = Rng::new(42);
let frames: Vec<CsiFrame> = (0..600)
.map(|_| make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng))
.collect();
group.bench_with_input(
BenchmarkId::from_parameter(spec.label),
&frames,
|b, fs| {
b.iter_with_setup(
|| CalibrationRecorder::new(spec.config.clone()),
|mut recorder| {
for f in fs {
black_box(recorder.record(black_box(f)).ok());
}
black_box(recorder)
},
);
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 5: bench_to_bytes/<tier> — serialisation cost (to_bytes)
// ---------------------------------------------------------------------------
fn bench_to_bytes(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_to_bytes");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
let baseline = finalised_baseline(&spec);
group.bench_function(BenchmarkId::from_parameter(spec.label), |b| {
b.iter(|| {
black_box(baseline.to_bytes())
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Criterion harness
// ---------------------------------------------------------------------------
criterion_group!(
benches,
bench_recorder_record,
bench_recorder_finalize,
bench_deviation,
bench_record_600,
bench_to_bytes,
);
criterion_main!(benches);
@@ -0,0 +1,277 @@
//! Calibration Deterministic Proof Runner (ADR-135)
//!
//! Verifies or generates the canonical SHA-256 hash of the CalibrationRecorder's
//! deterministic output on a synthetic stationary channel (seed=42, HT20, 600 frames).
//!
//! Cross-platform portability lesson (from cir_proof_runner.rs, line 123):
//! Raw f32 round-trips at high precision (1e-6) and magnitude-sort-then-truncate
//! both break across libm implementations (glibc / MSVC / Apple) because sin/cos/sqrt
//! differ by ~1e-7 — enough to flip a rounded integer or re-order near-tied values.
//! The fix: serialise the full per-subcarrier profile in natural index order at
//! coarse quantisation (1e-2 / 1e-4 / 1e-3). A 1% drift is invisible to the hash;
//! a 10× algorithm change moves values by >1e-2 and breaks the hash.
//! No sort, no truncation, no libm-sensitive comparison.
//!
//! Canonical form (per subcarrier k, 4 × u16 LE):
//! [0] (amp_mean * 1e2).round() as u16
//! [1] (amp_variance * 1e4).round() as u16
//! [2] ((phase_mean + π) * 1e3).round() as u16 ← shifted so always non-negative
//! [3] (phase_dispersion * 1e3).round() as u16
//!
//! Prefix: tier byte (0 = HT20), frame_count u64 LE.
//! All subcarriers in natural index order; no sort.
//!
//! Usage:
//! cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features -- --generate-hash
//!
//! cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features
//! (compares against archive/v1/data/proof/expected_calibration_features.sha256)
//!
//! IMPORTANT: This binary cannot compile until CalibrationRecorder is implemented.
//! While the implementation is in progress, a placeholder hash is committed in
//! archive/v1/data/proof/expected_calibration_features.sha256. Regenerate with:
//!
//! cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features -- --generate-hash \
//! > ../archive/v1/data/proof/expected_calibration_features.sha256
use std::env;
use std::f32::consts::PI;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use ndarray::Array2;
use num_complex::Complex64;
use sha2::{Digest, Sha256};
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{CalibrationConfig, CalibrationRecorder};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const N_ACTIVE: usize = 52; // HT20 active subcarriers
const N_FRAMES: usize = 600; // 30 s × 20 Hz
const TIER_BYTE: u8 = 0; // 0 = HT20
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0, "xorshift seed must be non-zero");
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_normal(&mut self) -> f32 {
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * PI * u2;
r * theta.cos()
}
}
// ---------------------------------------------------------------------------
// Synthetic CSI frame generator — stationary channel, seed=42
//
// amp[k] = 0.3 + 0.7 * |sin(k * π / K)| (smooth across subcarriers)
// phase[k] = (k * 0.1) mod 2π π (slowly rotating)
// AWGN at ~30 dB SNR added via Box-Muller.
// ---------------------------------------------------------------------------
fn make_frame(rng: &mut Rng) -> CsiFrame {
let n = N_ACTIVE;
let noise_std = 0.01_f32;
let mut data = Array2::<Complex64>::zeros((1, n));
for k in 0..n {
let amp = 0.3 + 0.7 * (k as f32 * PI / n as f32).sin().abs();
let phase = (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI;
let re = amp * phase.cos() + noise_std * rng.next_normal();
let im = amp * phase.sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta =
CsiMetadata::new(DeviceId::new("proof-runner"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
// ---------------------------------------------------------------------------
// Canonical, cross-platform-deterministic serialisation.
//
// Per ADR-135 proof spec and the cir_proof_runner.rs lesson (line 123):
// coarse u16 quantisation, natural subcarrier order, no sort.
// ---------------------------------------------------------------------------
fn serialise_baseline_canonical(
subcarriers: &[wifi_densepose_signal::calibration::SubcarrierBaseline],
frame_count: u64,
) -> Vec<u8> {
let k = subcarriers.len();
// Header: tier byte + frame_count as u64 LE
let mut out = Vec::with_capacity(1 + 8 + k * 8);
out.push(TIER_BYTE);
out.extend_from_slice(&frame_count.to_le_bytes());
for sc in subcarriers {
// [0] amp_mean at 1e-2 resolution
let amp_q = (sc.amp_mean * 1e2_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&amp_q.to_le_bytes());
// [1] amp_variance at 1e-4 resolution
let var_q = (sc.amp_variance * 1e4_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&var_q.to_le_bytes());
// [2] phase_mean shifted by +π so it is non-negative, at 1e-3 resolution
let phase_q = ((sc.phase_mean + PI) * 1e3_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&phase_q.to_le_bytes());
// [3] phase_dispersion (von Mises 1R̄, in [0,1]) at 1e-3 resolution
let disp_q = (sc.phase_dispersion * 1e3_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&disp_q.to_le_bytes());
}
out
}
// ---------------------------------------------------------------------------
// Repo root discovery
// ---------------------------------------------------------------------------
fn repo_root() -> PathBuf {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let candidates = [
cwd.clone(),
cwd.join(".."),
cwd.join("../.."),
cwd.join("../../.."),
];
for candidate in &candidates {
if candidate
.join("archive/v1/data/proof/expected_calibration_features.sha256")
.exists()
|| candidate.join("archive/v1/data/proof/sample_csi_data.json").exists()
{
return candidate.canonicalize().unwrap_or(candidate.clone());
}
}
cwd
}
// ---------------------------------------------------------------------------
// Main hash computation
// ---------------------------------------------------------------------------
fn compute_hash() -> String {
let config = CalibrationConfig::ht20();
let mut recorder = CalibrationRecorder::new(config);
let mut rng = Rng::new(42);
for _ in 0..N_FRAMES {
let frame = make_frame(&mut rng);
recorder
.record(&frame)
.expect("record() must succeed for synthetic frames");
}
let baseline = recorder
.finalize()
.expect("finalize() must succeed after 600 frames");
let payload = serialise_baseline_canonical(&baseline.subcarriers, baseline.frame_count);
let mut hasher = Sha256::new();
hasher.update(&payload);
format!("{:x}", hasher.finalize())
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn main() {
let args: Vec<String> = env::args().collect();
let generate_hash = args.iter().any(|a| a == "--generate-hash");
let hash = compute_hash();
if generate_hash {
println!("{}", hash);
return;
}
// Compare against stored hash
let root = repo_root();
let hash_path = root.join("archive/v1/data/proof/expected_calibration_features.sha256");
if !hash_path.exists() {
eprintln!(
"ERROR: expected hash file not found at {}",
hash_path.display()
);
eprintln!("Run with --generate-hash to create it.");
std::process::exit(1);
}
let expected_content = fs::read_to_string(&hash_path)
.unwrap_or_else(|e| panic!("Cannot read {}: {}", hash_path.display(), e));
let expected = expected_content
.split_whitespace()
.find(|s| !s.starts_with('#'))
.unwrap_or("")
.to_owned();
if expected.starts_with("PLACEHOLDER") {
eprintln!("BLOCKED: calibration proof hash is a placeholder.");
eprintln!(
"The calibration module (ADR-135) is not yet fully implemented. \
After the implementation lands, regenerate:"
);
eprintln!(
" cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
--release --no-default-features -- --generate-hash \
> ../archive/v1/data/proof/expected_calibration_features.sha256"
);
std::process::exit(2);
}
if hash == expected {
println!("VERDICT: PASS (calibration hash matches)");
std::process::exit(0);
} else {
eprintln!("VERDICT: FAIL");
eprintln!("expected: {}", expected);
eprintln!("actual: {}", hash);
io::stderr().flush().ok();
std::process::exit(1);
}
}
@@ -67,6 +67,13 @@ pub use phase_sanitizer::{
pub use ruvsense::cir;
pub use ruvsense::cir::{Cir, CirConfig, CirError, CirEstimator};
// ADR-135: Baseline calibration top-level re-exports
pub use ruvsense::calibration;
pub use ruvsense::calibration::{
BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationError,
CalibrationRecorder, PhyTier, SubcarrierBaseline,
};
/// Library version
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -0,0 +1,343 @@
//! ADR-138 — `ArrayCoordinator`: a stateless-per-call domain service that gates
//! array nodes on geometry and clock quality and projects *directional evidence*
//! (not pose decisions).
//!
//! # Crate placement (deviation from ADR-138 §2.3, deliberate)
//!
//! ADR-138 placed `ArrayCoordinator` in `wifi-densepose-ruvector`
//! (`viewpoint/fusion.rs`). But `wifi-densepose-signal` already **depends on**
//! `wifi-densepose-ruvector`, and the coordinator must emit the canonical
//! [`ContradictionFlag`](super::fusion_quality::ContradictionFlag) owned by
//! ADR-137 (in this crate). Placing it in ruvector would create a dependency
//! cycle. It therefore lives here in `wifi-densepose-signal`, which can see both
//! ruvector's geometry/coherence types and ADR-137's `ContradictionFlag`. The
//! `ClockQualityGate` (which needs no `ContradictionFlag`) stays in ruvector per
//! the ADR.
use wifi_densepose_ruvector::viewpoint::coherence::{
ClockGateDecision, ClockQualityGate, ClockQualityScore,
};
use wifi_densepose_ruvector::viewpoint::geometry::{
CramerRaoBound, GeometricDiversityIndex, NodeId, ViewpointPosition,
};
use super::fusion_quality::ContradictionFlag;
use super::multistatic::node_attention_weights;
/// One node's contribution to the array for a single sensing cycle.
#[derive(Debug, Clone)]
pub struct ArrayNodeInput {
/// Stable node identifier.
pub node_id: NodeId,
/// Node position (x, y) in metres (deployment geometry).
pub position: (f32, f32),
/// Azimuth (radians) of the node from the array centroid.
pub azimuth: f32,
/// Rolling phasor coherence for this node (`CoherenceState::coherence()`).
pub coherence: f32,
/// Clock-sync quality (ADR-110 follower offset stats).
pub clock: ClockQualityScore,
/// Optional per-node amplitude vector; when present across nodes, the
/// directional weights use the real fusion attention (ADR-137
/// `node_attention_weights`) instead of the clock-only fallback.
pub amplitude: Option<Vec<f32>>,
}
/// Directional evidence: what the array can resolve right now and how much to
/// trust each direction (ADR-138 §2.3). NOT a pose decision.
#[derive(Debug, Clone)]
pub struct DirectionalEvidence {
/// Per-admitted-viewpoint attention weight (sums to ~1.0 over admitted).
pub weights: Vec<(NodeId, f32)>,
/// Geometric Diversity Index over admitted nodes. `None` when < 2 admitted.
///
/// (ADR-138 §2.3 typed this non-optional; made `Option` here because GDI is
/// undefined for < 2 viewpoints and a sentinel would be misleading.)
pub gdi: Option<GeometricDiversityIndex>,
/// Cramér-Rao RMSE lower bound (m) for a centroid target. `None` when
/// < 3 admitted viewpoints (under-determined).
pub credence_rmse_m: Option<f32>,
/// Per-node gate decisions — the audit trail.
pub gate_decisions: Vec<(NodeId, ClockGateDecision)>,
/// Contradiction flags forwarded to the ADR-137 fusion-quality machinery.
pub contradictions: Vec<ContradictionFlag>,
/// Viewpoints admitted at full weight.
pub n_admitted: usize,
/// Viewpoints admitted MonitorOnly (evidence-only, no environment update).
pub n_monitoring: usize,
}
/// Configuration for [`ArrayCoordinator`].
#[derive(Debug, Clone)]
pub struct ArrayCoordinatorConfig {
/// Per-node clock+coherence gate (cloned per node so hysteresis state does
/// not leak across nodes within a cycle).
pub gate: ClockQualityGate,
/// σ multiple defining a cross-sectional coherence-drop contradiction.
pub contradiction_sigma: f32,
/// Per-measurement noise std (m) for the Cramér-Rao credence estimate.
pub crb_noise_std_m: f32,
/// Attention temperature for the directional weight softmax.
pub attention_temperature: f32,
}
impl Default for ArrayCoordinatorConfig {
fn default() -> Self {
Self {
gate: ClockQualityGate::default_params(),
contradiction_sigma: 2.0,
crb_noise_std_m: 0.1,
attention_temperature: 1.0,
}
}
}
/// Stateless-per-call domain service (ADR-138 §2.3).
#[derive(Debug, Clone)]
pub struct ArrayCoordinator {
config: ArrayCoordinatorConfig,
}
impl ArrayCoordinator {
/// Create a coordinator with the given configuration.
pub fn new(config: ArrayCoordinatorConfig) -> Self {
Self { config }
}
/// Gate the nodes on clock+coherence, then over the admitted set compute
/// GDI, Cramér-Rao credence, and attention weights, collecting contradiction
/// flags (cross-sectional coherence drops + geometry insufficiency).
pub fn coordinate(&self, nodes: &[ArrayNodeInput]) -> DirectionalEvidence {
// 1. Per-node clock+coherence gate (fresh gate per node).
let mut gate_decisions = Vec::with_capacity(nodes.len());
for n in nodes {
let mut gate = self.config.gate.clone();
gate_decisions.push((n.node_id, gate.evaluate(n.coherence, &n.clock)));
}
// Admitted = full-weight; monitoring = evidence-only.
let admitted_idx: Vec<usize> = (0..nodes.len())
.filter(|&i| matches!(gate_decisions[i].1, ClockGateDecision::Admit))
.collect();
let monitoring_idx: Vec<usize> = (0..nodes.len())
.filter(|&i| matches!(gate_decisions[i].1, ClockGateDecision::MonitorOnly { .. }))
.collect();
let evidence_idx: Vec<usize> =
admitted_idx.iter().chain(monitoring_idx.iter()).copied().collect();
let mut contradictions = Vec::new();
// 2. Cross-sectional coherence-drop contradictions over the evidence set.
if evidence_idx.len() >= 3 {
let cohs: Vec<f32> = evidence_idx.iter().map(|&i| nodes[i].coherence).collect();
let mean = cohs.iter().sum::<f32>() / cohs.len() as f32;
let var = cohs.iter().map(|c| (c - mean).powi(2)).sum::<f32>() / cohs.len() as f32;
let std = var.sqrt();
if std > 1e-6 {
for &i in &evidence_idx {
let sigma = (mean - nodes[i].coherence) / std;
if sigma > self.config.contradiction_sigma {
contradictions.push(ContradictionFlag::CoherenceDrop { node_idx: i, sigma });
}
}
}
}
// 3. GDI over admitted nodes.
let gdi = if admitted_idx.len() >= 2 {
let azimuths: Vec<f32> = admitted_idx.iter().map(|&i| nodes[i].azimuth).collect();
let ids: Vec<NodeId> = admitted_idx.iter().map(|&i| nodes[i].node_id).collect();
GeometricDiversityIndex::compute(&azimuths, &ids)
} else {
None
};
if let Some(ref g) = gdi {
if !g.is_sufficient() {
contradictions.push(ContradictionFlag::GeometryInsufficient { gdi: g.value });
}
}
// 4. Cramér-Rao credence for a centroid target over admitted nodes.
let credence_rmse_m = if admitted_idx.len() >= 3 {
let vps: Vec<ViewpointPosition> = admitted_idx
.iter()
.map(|&i| ViewpointPosition {
x: nodes[i].position.0,
y: nodes[i].position.1,
noise_std: self.config.crb_noise_std_m,
})
.collect();
let cx = vps.iter().map(|v| v.x).sum::<f32>() / vps.len() as f32;
let cy = vps.iter().map(|v| v.y).sum::<f32>() / vps.len() as f32;
CramerRaoBound::estimate((cx, cy), &vps).map(|crb| crb.rmse_lower_bound)
} else {
None
};
// 5. Attention weights over admitted nodes.
let weights = self.admitted_weights(nodes, &admitted_idx);
DirectionalEvidence {
weights,
gdi,
credence_rmse_m,
gate_decisions,
contradictions,
n_admitted: admitted_idx.len(),
n_monitoring: monitoring_idx.len(),
}
}
/// Directional weights over the admitted set. When every admitted node has
/// an amplitude vector of equal length, reuse the ADR-137 fusion attention
/// (`node_attention_weights`); otherwise fall back to a clock-quality
/// softmax so well-clocked nodes weigh more.
fn admitted_weights(
&self,
nodes: &[ArrayNodeInput],
admitted_idx: &[usize],
) -> Vec<(NodeId, f32)> {
if admitted_idx.is_empty() {
return Vec::new();
}
// Try the real fusion-attention path when amplitudes are present + uniform.
let amps: Option<Vec<&[f32]>> = admitted_idx
.iter()
.map(|&i| nodes[i].amplitude.as_deref())
.collect();
if let Some(amps) = amps {
let len0 = amps.first().map(|a| a.len()).unwrap_or(0);
if len0 > 0 && amps.iter().all(|a| a.len() == len0) {
let w = node_attention_weights(&amps, self.config.attention_temperature);
return admitted_idx.iter().map(|&i| nodes[i].node_id).zip(w).collect();
}
}
// Clock-quality softmax fallback.
let max_floor = self.config.gate.max_offset_stdev_us;
let logits: Vec<f32> = admitted_idx
.iter()
.map(|&i| nodes[i].clock.quality(max_floor) / self.config.attention_temperature)
.collect();
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = logits.iter().map(|l| (l - max_logit).exp()).collect();
let sum: f32 = exps.iter().sum::<f32>().max(1e-12);
admitted_idx
.iter()
.zip(exps)
.map(|(&i, e)| (nodes[i].node_id, e / sum))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clock(stdev: f32, age_us: u64) -> ClockQualityScore {
ClockQualityScore { offset_stdev_us: stdev, age_us, valid: true }
}
fn node(id: NodeId, x: f32, y: f32, az: f32, coh: f32, stdev: f32) -> ArrayNodeInput {
ArrayNodeInput {
node_id: id,
position: (x, y),
azimuth: az,
coherence: coh,
clock: clock(stdev, 1000),
amplitude: None,
}
}
/// 4 well-placed, well-clocked, coherent nodes → all admitted, weights sum
/// to 1, credence available, no contradictions.
#[test]
fn ac_four_good_nodes_all_admitted() {
use std::f32::consts::PI;
let coord = ArrayCoordinator::new(ArrayCoordinatorConfig::default());
let nodes = vec![
node(0, 1.0, 0.0, 0.0, 0.9, 50.0),
node(1, 0.0, 1.0, PI / 2.0, 0.9, 50.0),
node(2, -1.0, 0.0, PI, 0.9, 50.0),
node(3, 0.0, -1.0, 3.0 * PI / 2.0, 0.9, 50.0),
];
let ev = coord.coordinate(&nodes);
assert_eq!(ev.n_admitted, 4);
assert_eq!(ev.n_monitoring, 0);
assert!((ev.weights.iter().map(|(_, w)| *w).sum::<f32>() - 1.0).abs() < 1e-4);
assert!(ev.credence_rmse_m.is_some());
assert!(ev.gdi.is_some() && ev.gdi.as_ref().unwrap().is_sufficient());
assert!(ev.contradictions.is_empty());
}
/// A clock-degraded node (offset ≥ 200 µs floor) is MonitorOnly: evidence
/// yes, not counted as admitted.
#[test]
fn ac_clock_degraded_node_is_monitor_only() {
use std::f32::consts::PI;
let coord = ArrayCoordinator::new(ArrayCoordinatorConfig::default());
let mut nodes = vec![
node(0, 1.0, 0.0, 0.0, 0.9, 50.0),
node(1, 0.0, 1.0, PI / 2.0, 0.9, 50.0),
node(2, -1.0, 0.0, PI, 0.9, 50.0),
];
nodes[2].clock = clock(250.0, 1000); // above 200 µs floor, below 1000 µs hard
let ev = coord.coordinate(&nodes);
assert_eq!(ev.n_admitted, 2);
assert_eq!(ev.n_monitoring, 1);
assert!(matches!(
ev.gate_decisions[2].1,
ClockGateDecision::MonitorOnly { .. }
));
}
/// A stale node (age > 9 s) is hard-rejected.
#[test]
fn ac_stale_node_rejected() {
let coord = ArrayCoordinator::new(ArrayCoordinatorConfig::default());
let mut n0 = node(0, 1.0, 0.0, 0.0, 0.9, 50.0);
n0.clock = clock(50.0, 10_000_000); // 10 s > 9 s ceiling
let ev = coord.coordinate(&[n0]);
assert_eq!(ev.n_admitted, 0);
assert!(matches!(
ev.gate_decisions[0].1,
ClockGateDecision::Reject {
reason: wifi_densepose_ruvector::viewpoint::coherence::ClockRejectReason::ClockStale
}
));
}
/// An incoherent node (coherence below the phase gate) is rejected.
#[test]
fn ac_incoherent_node_rejected() {
let coord = ArrayCoordinator::new(ArrayCoordinatorConfig::default());
let n0 = node(0, 1.0, 0.0, 0.0, 0.2, 50.0); // 0.2 < 0.7 gate
let ev = coord.coordinate(&[n0]);
assert_eq!(ev.n_admitted, 0);
}
/// A cross-sectional coherence outlier raises a `CoherenceDrop` flag.
///
/// Uses 6 nodes: with a single outlier among N equal values the outlier's
/// z-score is exactly √(N-1), so N≥6 is required to exceed the default 2σ
/// threshold (√5≈2.24). This is an inherent property of cross-sectional
/// outlier detection, not a tuning artefact.
#[test]
fn ac_coherence_outlier_flagged() {
use std::f32::consts::PI;
let coord = ArrayCoordinator::new(ArrayCoordinatorConfig::default());
let nodes: Vec<ArrayNodeInput> = (0..6)
.map(|i| {
let az = i as f32 * PI / 3.0;
// Node 5 is the low-coherence outlier (still above the 0.7 gate).
let coh = if i == 5 { 0.71 } else { 0.95 };
node(i, az.cos(), az.sin(), az, coh, 50.0)
})
.collect();
let ev = coord.coordinate(&nodes);
assert!(ev
.contradictions
.iter()
.any(|c| matches!(c, ContradictionFlag::CoherenceDrop { node_idx: 5, .. })));
}
}
@@ -0,0 +1,717 @@
//! Empty-room baseline calibration (ADR-135).
//!
//! Captures per-subcarrier amplitude and circular-phase statistics from a
//! quiescent (empty) room using Welford's online algorithm, then provides
//! real-time deviation scoring and in-place baseline subtraction.
//!
//! # Pipeline position
//!
//! Raw CSI → `phase_sanitizer.rs` → `phase_align.rs`
//! → `CalibrationRecorder::record()` (calibration mode)
//! → `BaselineCalibration::subtract_in_place()` (runtime mode)
//! → `CirEstimator::estimate()`
//!
//! # Binary format (to_bytes / from_bytes)
//!
//! 16-byte header (all little-endian):
//! magic: u32 = 0xCA1B_0001
//! version: u8 = 1
//! tier: u8 (0=Ht20, 1=Ht40, 2=He20, 3=He40)
//! reserved: u16 = 0
//! captured_at_unix_s: i64
//! Body:
//! frame_count: u64
//! num_subcarriers: u32
//! for each subcarrier: amp_mean f32 LE, amp_variance f32 LE,
//! phase_mean f32 LE, phase_dispersion f32 LE
//!
//! SHA-256-stable: all writes are LE, no float branching.
use num_complex::Complex32;
use thiserror::Error;
use wifi_densepose_core::types::CsiFrame;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAGIC: u32 = 0xCA1B_0001;
const VERSION: u8 = 1;
const HEADER_LEN: usize = 16; // magic(4) + version(1) + tier(1) + reserved(2) + unix_s(8)
const SUBCARRIER_RECORD_LEN: usize = 16; // 4 × f32
// ---------------------------------------------------------------------------
// PHY tier
// ---------------------------------------------------------------------------
/// 802.11 PHY tier identifies the subcarrier layout.
/// A mismatch between a stored baseline and a live frame triggers
/// `CalibrationError::TierMismatch` (ADR-135 §risk 2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhyTier {
/// 802.11n HT20: 64-FFT, 52 active subcarriers.
Ht20,
/// 802.11n HT40: 128-FFT, 114 active subcarriers.
Ht40,
/// 802.11ax HE20: 256-FFT, 242 active subcarriers.
He20,
/// 802.11ax HE40: 512-FFT, 484 active subcarriers.
He40,
}
impl PhyTier {
fn to_u8(self) -> u8 {
match self {
PhyTier::Ht20 => 0,
PhyTier::Ht40 => 1,
PhyTier::He20 => 2,
PhyTier::He40 => 3,
}
}
fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(PhyTier::Ht20),
1 => Some(PhyTier::Ht40),
2 => Some(PhyTier::He20),
3 => Some(PhyTier::He40),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/// Calibration capture configuration.
#[derive(Debug, Clone, Copy)]
pub struct CalibrationConfig {
/// PHY tier determines expected subcarrier count.
pub tier: PhyTier,
/// Total OFDM FFT bins (e.g. 64 HT20, 128 HT40, 256 HE20, 512 HE40).
pub num_subcarriers: usize,
/// Active (non-guard, non-DC) tones (52, 114, 242, 484).
pub num_active: usize,
/// Minimum frames before `finalize()` succeeds (default 600).
pub min_frames: u32,
/// Von Mises dispersion warn threshold — warn if any subcarrier exceeds this
/// during recording (ADR-135 §risk 1). Default 0.3.
pub max_phase_variance: f32,
}
impl CalibrationConfig {
/// HT20 defaults: 64 FFT, 52 active, 600 frame minimum (30 s @ 20 Hz).
pub fn ht20() -> Self {
Self { tier: PhyTier::Ht20, num_subcarriers: 64, num_active: 52, min_frames: 600, max_phase_variance: 0.3 }
}
/// HT40 defaults: 128 FFT, 114 active.
pub fn ht40() -> Self {
Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: 600, max_phase_variance: 0.3 }
}
/// HE20 defaults: 256 FFT, 242 active.
pub fn he20() -> Self {
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 242, min_frames: 600, max_phase_variance: 0.3 }
}
/// HE40 defaults: 512 FFT, 484 active.
pub fn he40() -> Self {
Self { tier: PhyTier::He40, num_subcarriers: 512, num_active: 484, min_frames: 600, max_phase_variance: 0.3 }
}
}
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Errors from calibration operations.
#[derive(Debug, Error)]
pub enum CalibrationError {
#[error("subcarrier count mismatch: expected {expected}, got {got}")]
SubcarrierMismatch { expected: usize, got: usize },
#[error("tier mismatch: baseline tier {baseline:?}, frame tier {frame:?}")]
TierMismatch { baseline: PhyTier, frame: PhyTier },
#[error("insufficient frames: have {got}, need {need}")]
InsufficientFrames { got: u32, need: u32 },
#[error("baseline serialization version mismatch: have v{got}, expected v{want}")]
VersionMismatch { got: u8, want: u8 },
#[error("buffer too short to deserialize baseline (have {got} bytes, need at least {need})")]
TruncatedBuffer { got: usize, need: usize },
#[error("invalid magic word: expected 0xCA1B0001, got 0x{got:08X}")]
InvalidMagic { got: u32 },
#[error("unknown tier byte: {0}")]
UnknownTier(u8),
}
// ---------------------------------------------------------------------------
// Per-subcarrier running statistics
// ---------------------------------------------------------------------------
/// Per-subcarrier Welford amplitude + circular-phase accumulators.
///
/// Amplitude uses the standard Welford recurrence (as in `field_model::WelfordStats`
/// but inlined here into a struct-of-arrays to avoid pub-API churn on that type).
/// Phase uses sin/cos running sums — the standard technique for circular statistics.
#[derive(Debug, Clone)]
struct SubcarrierStats {
amp_count: u64,
amp_mean: f64,
amp_m2: f64,
phase_sin_sum: f64,
phase_cos_sum: f64,
}
impl SubcarrierStats {
fn new() -> Self {
Self { amp_count: 0, amp_mean: 0.0, amp_m2: 0.0, phase_sin_sum: 0.0, phase_cos_sum: 0.0 }
}
/// Welford update for amplitude; circular update for phase.
fn update(&mut self, c: Complex32) {
let amp = c.norm() as f64;
self.amp_count += 1;
let delta = amp - self.amp_mean;
self.amp_mean += delta / self.amp_count as f64;
let delta2 = amp - self.amp_mean;
self.amp_m2 += delta * delta2;
let theta = c.arg() as f64;
self.phase_sin_sum += theta.sin();
self.phase_cos_sum += theta.cos();
}
/// Bessel-corrected sample variance (matches Welford convention).
fn amp_variance(&self) -> f64 {
if self.amp_count < 2 { 0.0 } else { self.amp_m2 / (self.amp_count - 1) as f64 }
}
/// Circular mean phase in `[-π, π]`.
fn phase_mean(&self) -> f64 {
self.phase_sin_sum.atan2(self.phase_cos_sum)
}
/// Von Mises dispersion `1 R̄` in `[0, 1]`.
fn phase_dispersion(&self) -> f64 {
if self.amp_count == 0 { return 1.0; }
let n = self.amp_count as f64;
let r = (self.phase_sin_sum * self.phase_sin_sum + self.phase_cos_sum * self.phase_cos_sum).sqrt() / n;
1.0 - r.min(1.0)
}
}
// ---------------------------------------------------------------------------
// SubcarrierBaseline (public per-subcarrier summary)
// ---------------------------------------------------------------------------
/// Finalised per-subcarrier statistics from a baseline capture.
#[derive(Debug, Clone, Copy)]
pub struct SubcarrierBaseline {
pub amp_mean: f32,
pub amp_variance: f32,
/// Circular mean phase in `[-π, π]` (radians).
pub phase_mean: f32,
/// Von Mises dispersion `1 R̄` in `[0, 1]`; 0 = perfectly stationary.
pub phase_dispersion: f32,
}
// ---------------------------------------------------------------------------
// BaselineCalibration
// ---------------------------------------------------------------------------
/// A fully finalised empty-room baseline (immutable after construction).
#[derive(Debug, Clone)]
pub struct BaselineCalibration {
pub tier: PhyTier,
pub captured_at_unix_s: i64,
pub frame_count: u64,
/// Per-subcarrier statistics, ordered by active-subcarrier index.
pub subcarriers: Vec<SubcarrierBaseline>,
}
impl BaselineCalibration {
/// Compute a per-frame deviation score against this baseline.
pub fn deviation(&self, frame: &CsiFrame) -> Result<CalibrationDeviationScore, CalibrationError> {
let n_sc = frame.num_subcarriers();
let expected = self.subcarriers.len();
if n_sc != expected && n_sc != self.tier_num_subcarriers() {
return Err(CalibrationError::SubcarrierMismatch { expected, got: n_sc });
}
let y = extract_first_stream(frame, expected, self.tier_num_subcarriers());
let mut z_amp = Vec::with_capacity(expected);
let mut phase_drift = Vec::with_capacity(expected);
for (ki, (c, baseline)) in y.iter().zip(self.subcarriers.iter()).enumerate() {
let _ = ki;
let amp = c.norm();
let std = baseline.amp_variance.sqrt().max(1e-12_f32);
z_amp.push((amp - baseline.amp_mean) / std);
let theta = c.arg();
let drift = circular_distance(theta, baseline.phase_mean);
phase_drift.push(drift);
}
let amplitude_z_median = median_abs(&z_amp);
let amplitude_z_max = z_amp.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
let phase_drift_median = median_slice(&phase_drift);
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
}
/// Deterministic calibration epoch id (ADR-137 `CalibrationId`), derived
/// from the immutable baseline fields — stable across reboots, changes only
/// on recalibration. Deterministic (no RNG) so the ADR-136 witness replay
/// stays reproducible.
#[must_use]
pub fn calibration_id(&self) -> super::fusion_quality::CalibrationId {
// splitmix64 over (captured_at, frame_count, subcarrier_count, tier).
let mut h = (self.captured_at_unix_s as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(self.frame_count.wrapping_mul(0xBF58_476D_1CE4_E5B9))
.wrapping_add((self.subcarriers.len() as u64).wrapping_mul(0x94D0_49BB_1331_11EB))
.wrapping_add(self.tier as u64);
h ^= h >> 30;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 27;
super::fusion_quality::CalibrationId(h)
}
/// The ADR-136 `FrameMeta.calibration_id` value (a UUID derived
/// deterministically from [`Self::calibration_id`]).
#[must_use]
pub fn calibration_uuid(&self) -> uuid::Uuid {
uuid::Uuid::from_u128(self.calibration_id().0 as u128)
}
/// ADR-136 §2.4 calibration **Stage**: subtract the baseline AND stamp the
/// frame's `calibration_id` provenance field. This is the only place that
/// sets `calibration_id` (the append-only boundary rule).
///
/// # Errors
/// [`CalibrationError::SubcarrierMismatch`] if the frame's subcarrier count
/// does not match this baseline.
pub fn apply(&self, frame: &mut CsiFrame) -> Result<(), CalibrationError> {
self.subtract_in_place(frame)?;
frame.metadata.set_calibration(self.calibration_uuid());
Ok(())
}
/// Subtract the amplitude baseline from `frame.data` in-place.
/// Only amplitude mean is subtracted; phase is left untouched.
pub fn subtract_in_place(&self, frame: &mut CsiFrame) -> Result<(), CalibrationError> {
let n_sc = frame.num_subcarriers();
let expected = self.subcarriers.len();
if n_sc != expected && n_sc != self.tier_num_subcarriers() {
return Err(CalibrationError::SubcarrierMismatch { expected, got: n_sc });
}
let n_streams = frame.num_spatial_streams();
let n_total = self.tier_num_subcarriers();
let active_input = n_sc == expected;
for ki in 0..expected {
let col = if active_input { ki } else { ki }; // sequential when active-only
let baseline_amp = self.subcarriers[ki].amp_mean as f64;
for s in 0..n_streams {
let c = frame.data[[s, col]];
let norm = c.norm();
if norm > 1e-30 {
let scale = ((norm - baseline_amp).max(0.0)) / norm;
frame.data[[s, col]] = num_complex::Complex64::new(c.re * scale, c.im * scale);
}
}
let _ = n_total;
}
Ok(())
}
/// Reference complex CSI vector: `amp_mean × exp(j × phase_mean)` per subcarrier.
/// Pass to `CirEstimator::set_reference_csi()`.
pub fn reference_csi_vector(&self) -> Vec<Complex32> {
self.subcarriers.iter().map(|b| {
let (sin, cos) = b.phase_mean.sin_cos();
Complex32::new(b.amp_mean * cos, b.amp_mean * sin)
}).collect()
}
/// Serialise to little-endian binary (see module-level format doc).
pub fn to_bytes(&self) -> Vec<u8> {
let n = self.subcarriers.len();
let mut buf = Vec::with_capacity(HEADER_LEN + 8 + 4 + n * SUBCARRIER_RECORD_LEN);
buf.extend_from_slice(&MAGIC.to_le_bytes());
buf.push(VERSION);
buf.push(self.tier.to_u8());
buf.extend_from_slice(&0u16.to_le_bytes()); // reserved
buf.extend_from_slice(&self.captured_at_unix_s.to_le_bytes());
buf.extend_from_slice(&self.frame_count.to_le_bytes());
buf.extend_from_slice(&(n as u32).to_le_bytes());
for sc in &self.subcarriers {
buf.extend_from_slice(&sc.amp_mean.to_le_bytes());
buf.extend_from_slice(&sc.amp_variance.to_le_bytes());
buf.extend_from_slice(&sc.phase_mean.to_le_bytes());
buf.extend_from_slice(&sc.phase_dispersion.to_le_bytes());
}
buf
}
/// Deserialise from little-endian binary produced by `to_bytes`.
pub fn from_bytes(buf: &[u8]) -> Result<Self, CalibrationError> {
const MIN_LEN: usize = HEADER_LEN + 8 + 4; // header + frame_count + num_subcarriers
if buf.len() < MIN_LEN {
return Err(CalibrationError::TruncatedBuffer { got: buf.len(), need: MIN_LEN });
}
let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
if magic != MAGIC {
return Err(CalibrationError::InvalidMagic { got: magic });
}
let version = buf[4];
if version != VERSION {
return Err(CalibrationError::VersionMismatch { got: version, want: VERSION });
}
let tier_byte = buf[5];
let tier = PhyTier::from_u8(tier_byte).ok_or(CalibrationError::UnknownTier(tier_byte))?;
// reserved: buf[6..8] — ignored
let captured_at_unix_s = i64::from_le_bytes(buf[8..16].try_into().unwrap());
let frame_count = u64::from_le_bytes(buf[16..24].try_into().unwrap());
let n = u32::from_le_bytes(buf[24..28].try_into().unwrap()) as usize;
let needed = MIN_LEN + n * SUBCARRIER_RECORD_LEN;
if buf.len() < needed {
return Err(CalibrationError::TruncatedBuffer { got: buf.len(), need: needed });
}
let mut subcarriers = Vec::with_capacity(n);
let mut off = 28usize;
for _ in 0..n {
let amp_mean = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
let amp_variance = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
let phase_mean = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
let phase_dispersion = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
subcarriers.push(SubcarrierBaseline { amp_mean, amp_variance, phase_mean, phase_dispersion });
}
Ok(Self { tier, captured_at_unix_s, frame_count, subcarriers })
}
/// Total FFT bins for this tier (used for dual-convention column selection).
fn tier_num_subcarriers(&self) -> usize {
match self.tier {
PhyTier::Ht20 => 64,
PhyTier::Ht40 => 128,
PhyTier::He20 => 256,
PhyTier::He40 => 512,
}
}
}
// ---------------------------------------------------------------------------
// Deviation score
// ---------------------------------------------------------------------------
/// Per-frame deviation metrics against the static baseline.
#[derive(Debug, Clone, Copy)]
pub struct CalibrationDeviationScore {
/// Median of `|z_amp[k]|` across active subcarriers.
pub amplitude_z_median: f32,
/// Max single-subcarrier `|z_amp[k]|`.
pub amplitude_z_max: f32,
/// Median circular distance (radians) between live and baseline phase.
pub phase_drift_median: f32,
/// Heuristic: `amplitude_z_median > 2.0 || phase_drift_median > π/6`.
pub motion_flagged: bool,
}
// ---------------------------------------------------------------------------
// CalibrationRecorder
// ---------------------------------------------------------------------------
/// Accumulates CSI frames from an empty room using Welford online statistics.
///
/// Phase precondition: the caller must pass frames processed by
/// `PhaseSanitizer` and `phase_align.rs`. Unsanitised phase produces
/// inflated `phase_dispersion` values.
pub struct CalibrationRecorder {
config: CalibrationConfig,
started_at_unix_s: i64,
stats: Vec<SubcarrierStats>,
frame_count: u32,
}
impl CalibrationRecorder {
/// Create a new recorder for the given configuration.
pub fn new(config: CalibrationConfig) -> Self {
let stats = vec![SubcarrierStats::new(); config.num_active];
Self { config, started_at_unix_s: unix_now_s(), stats, frame_count: 0 }
}
/// Ingest one sanitised CSI frame. Returns a deviation score from the
/// current partial baseline so the operator can monitor room occupancy
/// in real time.
pub fn record(&mut self, frame: &CsiFrame) -> Result<CalibrationDeviationScore, CalibrationError> {
let n_sc = frame.num_subcarriers();
let expected_active = self.config.num_active;
let expected_total = self.config.num_subcarriers;
if n_sc != expected_active && n_sc != expected_total {
return Err(CalibrationError::SubcarrierMismatch { expected: expected_active, got: n_sc });
}
let y = extract_first_stream(frame, expected_active, expected_total);
for (ki, c) in y.iter().enumerate() {
self.stats[ki].update(*c);
}
self.frame_count += 1;
// Build deviation from partial baseline (after first frame).
let mut z_amp_abs = Vec::with_capacity(expected_active);
let mut phase_drift = Vec::with_capacity(expected_active);
for (c, st) in y.iter().zip(self.stats.iter()) {
let amp = c.norm();
let std = (st.amp_variance() as f32).sqrt().max(1e-12_f32);
z_amp_abs.push((amp - st.amp_mean as f32).abs() / std);
phase_drift.push(circular_distance(c.arg(), st.phase_mean() as f32));
}
let amplitude_z_median = median_slice(&z_amp_abs);
let amplitude_z_max = z_amp_abs.iter().copied().fold(0.0_f32, f32::max);
let phase_drift_median = median_slice(&phase_drift);
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
}
/// Number of frames recorded so far.
pub fn frames_recorded(&self) -> u32 {
self.frame_count
}
/// Consume the recorder and produce a finalised baseline.
/// Returns `CalibrationError::InsufficientFrames` if fewer than
/// `config.min_frames` frames were recorded.
pub fn finalize(self) -> Result<BaselineCalibration, CalibrationError> {
if self.frame_count < self.config.min_frames {
return Err(CalibrationError::InsufficientFrames {
got: self.frame_count,
need: self.config.min_frames,
});
}
let subcarriers = self.stats.iter().map(|st| SubcarrierBaseline {
amp_mean: st.amp_mean as f32,
amp_variance: st.amp_variance() as f32,
phase_mean: st.phase_mean() as f32,
phase_dispersion: st.phase_dispersion() as f32,
}).collect();
Ok(BaselineCalibration {
tier: self.config.tier,
captured_at_unix_s: self.started_at_unix_s,
frame_count: self.frame_count as u64,
subcarriers,
})
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Extract the first spatial stream as a `Vec<Complex32>`, honouring the
/// dual-convention used by `cir.rs::extract_csi_vector`: if the frame has
/// exactly `num_active` subcarriers they are taken sequentially; otherwise
/// the first `num_active` columns of the full FFT grid are used.
fn extract_first_stream(frame: &CsiFrame, num_active: usize, _num_total: usize) -> Vec<Complex32> {
let n_sc = frame.num_subcarriers();
let take = num_active.min(n_sc);
(0..take).map(|ki| {
let c = frame.data[[0, ki]];
Complex32::new(c.re as f32, c.im as f32)
}).collect()
}
/// Signed circular distance wrapped to `[0, π]`.
fn circular_distance(a: f32, b: f32) -> f32 {
let mut d = (a - b).abs();
if d > std::f32::consts::PI {
d = 2.0 * std::f32::consts::PI - d;
}
d
}
/// Median of absolute values of a slice.
fn median_abs(v: &[f32]) -> f32 {
let mut abs: Vec<f32> = v.iter().map(|x| x.abs()).collect();
median_in_place(&mut abs)
}
/// Median of a slice (non-destructive clone).
fn median_slice(v: &[f32]) -> f32 {
let mut c = v.to_vec();
median_in_place(&mut c)
}
fn median_in_place(v: &mut Vec<f32>) -> f32 {
if v.is_empty() { return 0.0; }
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = v.len() / 2;
if v.len() % 2 == 0 { (v[mid - 1] + v[mid]) / 2.0 } else { v[mid] }
}
/// Current Unix timestamp in seconds. Falls back to 0 if unavailable.
fn unix_now_s() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{CsiMetadata, CsiFrame};
fn make_frame(data: Array2<Complex64>) -> CsiFrame {
use wifi_densepose_core::types::{DeviceId, FrequencyBand};
let meta = CsiMetadata::new(
DeviceId::new("test-device"),
FrequencyBand::Band2_4GHz,
6,
);
CsiFrame::new(meta, data)
}
fn constant_frame(n_sc: usize, amp: f64, phase: f64) -> CsiFrame {
let row = (0..n_sc).map(|_| Complex64::from_polar(amp, phase)).collect::<Vec<_>>();
let arr = Array2::from_shape_vec((1, n_sc), row).unwrap();
make_frame(arr)
}
// (a) Welford convergence: constant input → variance ≈ 0, mean = amp.
#[test]
fn welford_constant_input_converges() {
let mut st = SubcarrierStats::new();
let c = Complex32::new(1.0, 0.0);
for _ in 0..600 {
st.update(c);
}
assert!((st.amp_mean - 1.0).abs() < 1e-9);
assert!(st.amp_variance() < 1e-20, "variance was {}", st.amp_variance());
}
// (b) Circular phase mean recovers known phase from N noisy samples.
#[test]
fn circular_phase_mean_recovery() {
use std::f64::consts::PI;
let mut st = SubcarrierStats::new();
let target = PI / 4.0;
// Feed 200 samples: 100 at target+0.05, 100 at target-0.05.
for _ in 0..100 {
st.update(Complex32::from_polar(1.0, (target + 0.05) as f32));
st.update(Complex32::from_polar(1.0, (target - 0.05) as f32));
}
let recovered = st.phase_mean();
assert!((recovered - target).abs() < 0.01, "phase error = {}", (recovered - target).abs());
// Dispersion should be low (close to 0) for tight phase cluster.
assert!(st.phase_dispersion() < 0.01, "dispersion = {}", st.phase_dispersion());
}
// (c) Round-trip: to_bytes → from_bytes preserves all baseline fields.
#[test]
fn round_trip_to_from_bytes() {
let mut cfg = CalibrationConfig::ht20();
cfg.min_frames = 2;
let mut rec = CalibrationRecorder::new(cfg);
let f1 = constant_frame(52, 0.8, 0.5);
let f2 = constant_frame(52, 0.9, 0.6);
rec.record(&f1).unwrap();
rec.record(&f2).unwrap();
let baseline = rec.finalize().unwrap();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes).unwrap();
assert_eq!(recovered.frame_count, baseline.frame_count);
assert_eq!(recovered.tier, baseline.tier);
assert_eq!(recovered.subcarriers.len(), baseline.subcarriers.len());
for (a, b) in recovered.subcarriers.iter().zip(baseline.subcarriers.iter()) {
assert!((a.amp_mean - b.amp_mean).abs() < 1e-6, "amp_mean mismatch");
assert!((a.phase_mean - b.phase_mean).abs() < 1e-6, "phase_mean mismatch");
assert!((a.phase_dispersion - b.phase_dispersion).abs() < 1e-6, "dispersion mismatch");
}
}
// ADR-136: calibration Stage stamps calibration_id deterministically.
#[test]
fn apply_stamps_calibration_id_deterministically() {
let mut cfg = CalibrationConfig::ht20();
cfg.min_frames = 2;
let mut rec = CalibrationRecorder::new(cfg);
rec.record(&constant_frame(52, 0.8, 0.5)).unwrap();
rec.record(&constant_frame(52, 0.9, 0.6)).unwrap();
let baseline = rec.finalize().unwrap();
// id is stable across calls (no RNG).
assert_eq!(baseline.calibration_id(), baseline.calibration_id());
assert_eq!(baseline.calibration_uuid(), baseline.calibration_uuid());
// apply() subtracts AND stamps the frame's provenance field.
let mut frame = constant_frame(52, 1.0, 0.5);
assert_eq!(frame.metadata.calibration_id, None);
baseline.apply(&mut frame).unwrap();
assert_eq!(frame.metadata.calibration_id, Some(baseline.calibration_uuid()));
}
// (d) Tier dispatch: each config constructor produces the correct counts.
#[test]
fn tier_dispatch_correct_counts() {
let ht20 = CalibrationConfig::ht20();
assert_eq!(ht20.num_subcarriers, 64);
assert_eq!(ht20.num_active, 52);
let ht40 = CalibrationConfig::ht40();
assert_eq!(ht40.num_subcarriers, 128);
assert_eq!(ht40.num_active, 114);
let he20 = CalibrationConfig::he20();
assert_eq!(he20.num_subcarriers, 256);
assert_eq!(he20.num_active, 242);
let he40 = CalibrationConfig::he40();
assert_eq!(he40.num_subcarriers, 512);
assert_eq!(he40.num_active, 484);
}
// Additional: insufficient frames → error.
#[test]
fn finalize_requires_min_frames() {
let cfg = CalibrationConfig::ht20(); // min_frames = 600
let mut rec = CalibrationRecorder::new(cfg);
let f = constant_frame(52, 1.0, 0.0);
rec.record(&f).unwrap();
match rec.finalize() {
Err(CalibrationError::InsufficientFrames { got: 1, need: 600 }) => {}
other => panic!("expected InsufficientFrames, got {:?}", other),
}
}
// Binary magic / version check.
#[test]
fn binary_magic_and_version() {
let mut cfg = CalibrationConfig::ht20();
cfg.min_frames = 1;
let mut rec = CalibrationRecorder::new(cfg);
rec.record(&constant_frame(52, 1.0, 0.0)).unwrap();
let b = rec.finalize().unwrap().to_bytes();
let magic = u32::from_le_bytes(b[0..4].try_into().unwrap());
assert_eq!(magic, 0xCA1B_0001u32);
assert_eq!(b[4], 1u8); // version = 1
}
// Subcarrier mismatch is rejected.
#[test]
fn subcarrier_mismatch_error() {
let mut cfg = CalibrationConfig::ht20();
cfg.min_frames = 1;
let mut rec = CalibrationRecorder::new(cfg);
let bad = constant_frame(50, 1.0, 0.0); // 50 ≠ 52, 50 ≠ 64
assert!(matches!(
rec.record(&bad),
Err(CalibrationError::SubcarrierMismatch { expected: 52, got: 50 })
));
}
}
@@ -0,0 +1,406 @@
//! ADR-142 — Channel-state evolution tracking + temporal VoxelMap.
//!
//! Two cooperating pieces, both extending ADR-030's field-model tier:
//!
//! 1. [`EvolutionTracker`] — per-link rolling [`WelfordStats`] baselines with a
//! cross-link change-point detector (≥ `min_links` links exceeding `nσ` in
//! one window ⇒ a `ChangePoint`). This catches environment changes that a
//! single-link drift check misses.
//! 2. [`TemporalVoxelMap`] — a *temporal* occupancy grid (distinct from the
//! static `tomography::OccupancyVolume`): each [`TemporalVoxel`] accumulates
//! evidence with a Bayesian log-odds update, tracks `last_update_ns`,
//! `evidence_count`, and Welford amplitude variance, and is privacy-gated by
//! [`VoxelGate`] before any occupancy leaves the node.
use crate::ruvsense::field_model::WelfordStats;
/// Privacy posture applied to voxel output (mirrors the BFLD demotion ladder of
/// ADR-120/141 without taking a crate dependency on `wifi-densepose-bfld`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoxelPrivacy {
/// Full per-voxel detail (occupancy + confidence + doppler).
Full,
/// Drop per-voxel doppler + confidence detail; keep occupancy.
Anonymous,
/// Emit only an aggregate occupancy histogram; raw map never leaves node.
Restricted,
}
/// A single temporal occupancy voxel (ADR-142 §2).
#[derive(Debug, Clone)]
pub struct TemporalVoxel {
/// Voxel centre (east, north, up) in metres.
pub center: [f64; 3],
/// Posterior occupancy probability in [0, 1].
pub occupancy: f64,
/// Internal Bayesian log-odds (occupancy = sigmoid(log_odds)).
log_odds: f64,
/// Confidence in [0, 1]; grows with evidence count.
pub confidence: f64,
/// Number of evidence updates folded in.
pub evidence_count: u64,
/// Most recent doppler velocity (m/s) attributed to this voxel, if any.
pub doppler_velocity: Option<f64>,
/// Capture-clock time of the last update (ns).
pub last_update_ns: u64,
/// Welford stats over the occupancy-evidence stream (for variance).
welford: WelfordStats,
}
impl TemporalVoxel {
/// Empty voxel at a centre, prior occupancy 0.5 (log-odds 0).
#[must_use]
pub fn new(center: [f64; 3]) -> Self {
Self {
center,
occupancy: 0.5,
log_odds: 0.0,
confidence: 0.0,
evidence_count: 0,
doppler_velocity: None,
last_update_ns: 0,
welford: WelfordStats::new(),
}
}
/// Fold one occupancy-evidence probability `p ∈ (0, 1)` into the posterior
/// via a clamped log-odds update, and (optionally) attribute a doppler
/// velocity. Confidence saturates as `1 - exp(-count / 5)` — so a voxel with
/// fewer than ~5 updates is low-confidence (ADR-142 §2 5-frame rule).
pub fn observe(&mut self, p: f64, doppler: Option<f64>, ns: u64) {
let p = p.clamp(1e-4, 1.0 - 1e-4);
let evidence_logit = (p / (1.0 - p)).ln();
// Clamp the running log-odds so a single bad frame cannot saturate.
self.log_odds = (self.log_odds + evidence_logit).clamp(-20.0, 20.0);
self.occupancy = 1.0 / (1.0 + (-self.log_odds).exp());
self.welford.update(p);
self.evidence_count += 1;
self.confidence = 1.0 - (-(self.evidence_count as f64) / 5.0).exp();
if doppler.is_some() {
self.doppler_velocity = doppler;
}
self.last_update_ns = ns;
}
/// True if too few updates have accumulated for a trustworthy posterior.
#[must_use]
pub fn is_low_confidence(&self) -> bool {
self.evidence_count < 5
}
/// Welford variance of the occupancy-evidence stream.
#[must_use]
pub fn evidence_variance(&self) -> f64 {
self.welford.variance()
}
}
/// A persistent temporal occupancy grid shared across reconstruct() cycles.
#[derive(Debug, Clone)]
pub struct TemporalVoxelMap {
voxels: Vec<TemporalVoxel>,
}
impl TemporalVoxelMap {
/// Build a grid of voxels at the supplied centres.
#[must_use]
pub fn new(centers: Vec<[f64; 3]>) -> Self {
Self { voxels: centers.into_iter().map(TemporalVoxel::new).collect() }
}
/// Number of voxels.
#[must_use]
pub fn len(&self) -> usize {
self.voxels.len()
}
/// Whether the grid is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.voxels.is_empty()
}
/// Borrow a voxel.
#[must_use]
pub fn voxel(&self, idx: usize) -> Option<&TemporalVoxel> {
self.voxels.get(idx)
}
/// Fold occupancy evidence into one voxel.
pub fn observe(&mut self, idx: usize, p: f64, doppler: Option<f64>, ns: u64) {
if let Some(v) = self.voxels.get_mut(idx) {
v.observe(p, doppler, ns);
}
}
/// Indices of voxels still below the confidence floor.
#[must_use]
pub fn low_confidence_indices(&self) -> Vec<usize> {
self.voxels
.iter()
.enumerate()
.filter(|(_, v)| v.is_low_confidence())
.map(|(i, _)| i)
.collect()
}
/// Occupancy of every voxel (read view).
#[must_use]
pub fn occupancies(&self) -> Vec<f64> {
self.voxels.iter().map(|v| v.occupancy).collect()
}
}
/// Privacy gate over voxel output (ADR-142 §2 — reuses the BFLD monotonic
/// demotion idea: information only ever removed, never added).
pub struct VoxelGate;
impl VoxelGate {
/// Apply a privacy posture to the map, mutating it in place, and return an
/// optional aggregate histogram (Some only for `Restricted`, where the raw
/// map must not leave the node).
///
/// - `Full`: unchanged.
/// - `Anonymous`: clear per-voxel doppler + zero the confidence detail
/// (occupancy retained).
/// - `Restricted`: produce an occupancy histogram (`bins` buckets over
/// [0,1]) and clear every voxel's occupancy/doppler/confidence so only the
/// aggregate survives.
pub fn demote(map: &mut TemporalVoxelMap, posture: VoxelPrivacy, bins: usize) -> Option<Vec<u32>> {
match posture {
VoxelPrivacy::Full => None,
VoxelPrivacy::Anonymous => {
for v in &mut map.voxels {
v.doppler_velocity = None;
v.confidence = 0.0;
}
None
}
VoxelPrivacy::Restricted => {
let bins = bins.max(1);
let mut hist = vec![0u32; bins];
for v in &map.voxels {
let b = ((v.occupancy * bins as f64) as usize).min(bins - 1);
hist[b] += 1;
}
for v in &mut map.voxels {
v.occupancy = 0.0;
v.doppler_velocity = None;
v.confidence = 0.0;
}
Some(hist)
}
}
}
}
/// A cross-link change-point: enough links diverged from baseline at once that
/// the environment itself likely changed (ADR-142 §2).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChangePoint {
/// How many links exceeded the σ threshold this window.
pub diverging_links: usize,
/// The σ threshold used.
pub sigma_threshold: f64,
}
/// Per-link rolling baseline tracker with cross-link change-point detection
/// (ADR-142 §2; extends ADR-030).
#[derive(Debug, Clone)]
pub struct EvolutionTracker {
links: Vec<WelfordStats>,
sigma_threshold: f64,
min_links: usize,
}
impl EvolutionTracker {
/// Track `n_links` links; flag a change-point when at least `min_links`
/// links exceed `sigma_threshold`σ of their own baseline in one window.
#[must_use]
pub fn new(n_links: usize, sigma_threshold: f64, min_links: usize) -> Self {
Self {
links: (0..n_links).map(|_| WelfordStats::new()).collect(),
sigma_threshold,
min_links,
}
}
/// Default: 2σ threshold, ≥3 links (ADR-142 §2).
#[must_use]
pub fn with_defaults(n_links: usize) -> Self {
Self::new(n_links, 2.0, 3)
}
/// Number of links tracked.
#[must_use]
pub fn n_links(&self) -> usize {
self.links.len()
}
/// True if `value` on `link_idx` is beyond `sigma_threshold`σ of that link's
/// established baseline (needs ≥2 prior observations).
#[must_use]
pub fn is_link_diverging(&self, link_idx: usize, value: f64) -> bool {
match self.links.get(link_idx) {
Some(w) if w.count >= 2 && w.std_dev() > 1e-9 => {
(value - w.mean).abs() / w.std_dev() > self.sigma_threshold
}
_ => false,
}
}
/// Fold one observation per link, returning a [`ChangePoint`] when the
/// number of simultaneously-diverging links reaches `min_links`. Divergence
/// is evaluated against the *prior* baseline before this sample is folded in.
pub fn observe_window(&mut self, values: &[f64]) -> Option<ChangePoint> {
let mut diverging = 0usize;
for (i, &v) in values.iter().enumerate() {
if self.is_link_diverging(i, v) {
diverging += 1;
}
}
// Fold the samples in after the divergence check.
for (w, &v) in self.links.iter_mut().zip(values.iter()) {
w.update(v);
}
if diverging >= self.min_links {
Some(ChangePoint { diverging_links: diverging, sigma_threshold: self.sigma_threshold })
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn voxel_bayesian_update_raises_occupancy_and_confidence() {
let mut v = TemporalVoxel::new([0.0, 0.0, 0.0]);
assert!((v.occupancy - 0.5).abs() < 1e-9);
assert!(v.is_low_confidence());
for ns in 0..10 {
v.observe(0.8, Some(0.3), ns);
}
assert!(v.occupancy > 0.9, "repeated positive evidence → high occupancy");
assert!(!v.is_low_confidence(), "10 updates ⇒ confident");
assert!(v.confidence > 0.8);
assert_eq!(v.last_update_ns, 9);
assert_eq!(v.doppler_velocity, Some(0.3));
}
#[test]
fn voxel_low_confidence_below_five_frames() {
let mut v = TemporalVoxel::new([1.0, 1.0, 0.0]);
for ns in 0..4 {
v.observe(0.7, None, ns);
}
assert!(v.is_low_confidence());
v.observe(0.7, None, 4);
assert!(!v.is_low_confidence(), "5th frame crosses the floor");
}
#[test]
fn voxel_map_tracks_low_confidence() {
let mut m = TemporalVoxelMap::new(vec![[0.0; 3], [1.0; 3]]);
assert_eq!(m.len(), 2);
for ns in 0..6 {
m.observe(0, 0.9, None, ns);
}
// Voxel 0 confident, voxel 1 never observed → low.
assert_eq!(m.low_confidence_indices(), vec![1]);
}
#[test]
fn privacy_gate_anonymous_clears_doppler_keeps_occupancy() {
let mut m = TemporalVoxelMap::new(vec![[0.0; 3]]);
for ns in 0..6 {
m.observe(0, 0.9, Some(0.5), ns);
}
let occ_before = m.voxel(0).unwrap().occupancy;
assert!(VoxelGate::demote(&mut m, VoxelPrivacy::Anonymous, 4).is_none());
let v = m.voxel(0).unwrap();
assert_eq!(v.doppler_velocity, None);
assert_eq!(v.confidence, 0.0);
assert!((v.occupancy - occ_before).abs() < 1e-9, "occupancy retained");
}
#[test]
fn privacy_gate_restricted_yields_histogram_and_clears() {
let mut m = TemporalVoxelMap::new(vec![[0.0; 3], [1.0; 3], [2.0; 3]]);
for ns in 0..6 {
m.observe(0, 0.95, None, ns);
m.observe(1, 0.95, None, ns);
}
let hist = VoxelGate::demote(&mut m, VoxelPrivacy::Restricted, 4).expect("histogram");
assert_eq!(hist.iter().sum::<u32>(), 3, "all 3 voxels binned");
// Raw occupancy cleared.
assert!(m.occupancies().iter().all(|&o| o == 0.0));
}
/// ADR-142 acceptance (the environmental-nervous-system path):
/// `three links drift for 30 frames -> ChangePoint fires -> VoxelMap
/// accumulates evidence -> low-confidence voxels suppressed -> VoxelGate
/// Restricted emits histogram only -> ADR-137 contradiction recorded`.
#[test]
fn acceptance_drift_to_histogram_with_contradiction() {
use crate::ruvsense::fusion_quality::ContradictionFlag;
// Three links, change-point requires all three to diverge at once.
let mut tracker = EvolutionTracker::new(3, 2.0, 3);
// 30 jittered baseline frames (non-zero std so divergence is defined).
for i in 0..30u32 {
let j = if i % 2 == 0 { 0.99 } else { 1.01 };
assert!(tracker.observe_window(&[j, j, j]).is_none(), "baseline is quiet");
}
// Three links drift simultaneously → ChangePoint fires.
let cp = tracker
.observe_window(&[5.0, 5.0, 5.0])
.expect("simultaneous drift on 3 links must fire a change-point");
assert_eq!(cp.diverging_links, 3);
// VoxelMap accumulates evidence over repeated observations.
let mut map = TemporalVoxelMap::new(vec![[0.0; 3], [1.0; 3], [2.0; 3]]);
for ns in 0..6 {
map.observe(0, 0.95, Some(0.4), ns);
map.observe(1, 0.90, None, ns);
// voxel 2 deliberately under-observed.
}
assert!(map.voxel(0).unwrap().occupancy > 0.9, "evidence accumulated");
// Low-confidence voxels (under 5 frames) are suppressed from output.
let low = map.low_confidence_indices();
assert!(low.contains(&2) && !low.contains(&0), "voxel 2 suppressed, voxel 0 kept");
// ADR-137 contradiction recorded from the change-point (drift conflict).
let contradictions = vec![ContradictionFlag::DriftProfileConflict {
node_idx: 0,
drift_score: cp.diverging_links as f32,
}];
assert!(!contradictions.is_empty(), "change-point recorded as an ADR-137 contradiction");
// VoxelGate Restricted → histogram only; the raw map never leaves the node.
let hist = VoxelGate::demote(&mut map, VoxelPrivacy::Restricted, 4)
.expect("Restricted yields an occupancy histogram");
assert_eq!(hist.iter().sum::<u32>(), 3, "all voxels binned");
assert!(map.occupancies().iter().all(|&o| o == 0.0), "raw occupancy cleared");
}
#[test]
fn evolution_tracker_detects_cross_link_change_point() {
let mut t = EvolutionTracker::with_defaults(4);
// Establish stable baselines (~1.0) with realistic small jitter so each
// link has a non-zero std (a perfectly constant baseline has std 0 and
// divergence is undefined).
for i in 0..30 {
let jitter = if i % 2 == 0 { 0.99 } else { 1.01 };
assert!(t.observe_window(&[jitter, jitter, jitter, jitter]).is_none());
}
// A divergence on a single link must NOT trip a change-point (< min_links).
assert!(t.observe_window(&[5.0, 1.0, 1.0, 1.0]).is_none());
// A large simultaneous excursion on 3 links → change-point.
let cp = t.observe_window(&[5.0, 5.0, 5.0, 1.0]);
assert!(matches!(cp, Some(ChangePoint { diverging_links, .. }) if diverging_links >= 3));
}
}
@@ -0,0 +1,188 @@
//! ADR-137 — Fusion-engine quality scoring with evidence references and
//! contradiction flags.
//!
//! Every fusion stage emits a [`QualityScore`] alongside its payload. The score
//! names the positive evidence ([`EvidenceRef`]) that justified the fusion and
//! the tolerated-but-recorded disagreements ([`ContradictionFlag`]) that must
//! lower the downstream BFLD privacy class (ADR-141 §2 / ADR-120). It implements
//! the ADR-136 [`QualityScored`](super::QualityScored) trait so the streaming
//! engine can route, gate, and log on quality uniformly.
//!
//! [`ContradictionFlag`] is the **single canonical type** for tolerated fusion
//! disagreements (ADR-137 §2.3); the ADR-138 `ArrayCoordinator` imports it and
//! emits its `CoherenceDrop` / `GeometryInsufficient` variants.
use super::QualityScored;
/// Identifies which sensing family produced a fused frame, so one
/// [`QualityScore`] can be correlated across the signal-domain fuser
/// (`multistatic.rs`) and the embedding-domain fuser (`viewpoint/fusion.rs`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FamilyId {
/// `ruvsense/multistatic.rs` CSI/CIR-domain fusion.
MultistaticCsi,
/// `ruvector/viewpoint/fusion.rs` AETHER-embedding fusion.
ViewpointEmbedding,
}
/// Calibration epoch identifier (ADR-137 §2.1). Derived from the ADR-135
/// `BaselineCalibration` capture time plus device id; stable across reboots,
/// changes only on recalibration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CalibrationId(pub u64);
/// A single piece of positive evidence supporting a fusion decision (ADR-137
/// §2.2). Each variant carries the value that crossed a threshold, not just a
/// boolean, so the witness record is reproducible.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EvidenceRef {
/// The coherence-gate threshold was met. `coherence` is the value,
/// `threshold` the configured gate.
CoherenceGateThreshold { coherence: f32, threshold: f32 },
/// The ADR-134 CIR dominant-tap ratio contributed to the gate. `blended`
/// is true when it was folded into `base_coherence` (false on fallback).
CirDominantTapRatio { ratio: f32, blended: bool },
/// Attention-weight entropy supported a balanced (multi-node) fusion.
WeightEntropy { normalized_entropy: f32, n_nodes: usize },
/// An ADR-135 baseline was applied to every contributing frame at a single
/// agreed calibration epoch before pooling.
CalibrationApplied { calibration_id: CalibrationId, n_frames: usize },
}
/// A tolerated disagreement detected during fusion (ADR-137 §2.3). A non-empty
/// set lowers the emitted BFLD privacy class and produces a witness record.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContradictionFlag {
/// Node `capture_ns` values spread within the guard interval but beyond a
/// stricter "comparable" sub-threshold. Carries the observed spread.
TimestampMismatch { spread_ns: u64, soft_guard_ns: u64 },
/// Contributing frames carried different calibration ids. `expected` is the
/// modal id; `disagreeing` counts the disagreeing frames.
CalibrationIdMismatch { expected: CalibrationId, disagreeing: usize },
/// Phase alignment did not converge for at least one node.
PhaseAlignmentFailed { node_idx: usize },
/// A node's ADR-135 drift score conflicts with the array consensus.
DriftProfileConflict { node_idx: usize, drift_score: f32 },
/// Raised upstream by the ADR-138 `ArrayCoordinator`: a node's coherence
/// dropped beyond `sigma`σ of its rolling mean.
CoherenceDrop { node_idx: usize, sigma: f32 },
/// Raised upstream by the ADR-138 `ArrayCoordinator`: array Geometric
/// Diversity Index fell below the geometry-sufficiency floor.
GeometryInsufficient { gdi: f32 },
}
/// Auditable quality record for one fused frame (ADR-137 §2.1).
///
/// Every semantic state downstream of fusion traces back to exactly one
/// `QualityScore`, which names the signal evidence (`evidence_refs`), the
/// calibration epoch (`calibration_id`), and the privacy-relevant disagreements
/// (`contradiction_flags`) that informed it.
#[derive(Debug, Clone)]
pub struct QualityScore {
/// Which fuser produced this score.
pub family_id: FamilyId,
/// Capture-clock timestamp (ns) of the fused cycle (median of contributors).
pub capture_ns: u64,
/// The calibration epoch all contributing frames agreed on, or `None` when
/// they disagreed (see [`ContradictionFlag::CalibrationIdMismatch`]).
pub calibration_id: Option<CalibrationId>,
/// Coherence in [0, 1] before any contradiction penalty is applied.
pub base_coherence: f32,
/// Per-contributing-node attention weight, node-index aligned. Sums to ~1.0.
pub per_node_weights: Vec<f32>,
/// Concrete checks that fired in support of this fusion.
pub evidence_refs: Vec<EvidenceRef>,
/// Tolerated-but-recorded disagreements. A non-empty set forces a BFLD
/// privacy demotion.
pub contradiction_flags: Vec<ContradictionFlag>,
/// Monotonic capture-clock time at which this score was computed (ns).
pub timestamp_computed_ns: u64,
}
impl QualityScore {
/// True when a non-empty contradiction set must demote the BFLD privacy
/// class (ADR-137 §2.7 → ADR-141). The fusion stage and the privacy gate
/// both consult this so the demotion rule lives in one place.
#[must_use]
pub fn forces_privacy_demotion(&self) -> bool {
!self.contradiction_flags.is_empty()
}
/// Coherence after the contradiction penalty: each contradiction multiplies
/// the base coherence by 0.8, clamped to [0, 1]. This is the value the
/// streaming engine routes/gates on.
#[must_use]
pub fn penalized_coherence(&self) -> f32 {
let penalty = 0.8_f32.powi(self.contradiction_flags.len() as i32);
(self.base_coherence * penalty).clamp(0.0, 1.0)
}
}
impl QualityScored for QualityScore {
fn quality_score(&self) -> f32 {
self.penalized_coherence()
}
fn confidence_bounds(&self) -> (f32, f32) {
// Width grows with the number of tolerated contradictions: each adds
// ±0.1 of uncertainty around the penalized coherence, clamped to [0,1].
let c = self.penalized_coherence();
let half = (0.1 * self.contradiction_flags.len() as f32).min(c.min(1.0 - c));
((c - half).max(0.0), (c + half).min(1.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> QualityScore {
QualityScore {
family_id: FamilyId::MultistaticCsi,
capture_ns: 1_000,
calibration_id: None,
base_coherence: 0.9,
per_node_weights: vec![0.5, 0.5],
evidence_refs: vec![EvidenceRef::WeightEntropy {
normalized_entropy: 1.0,
n_nodes: 2,
}],
contradiction_flags: vec![],
timestamp_computed_ns: 1_000,
}
}
#[test]
fn no_contradiction_no_demotion() {
let q = base();
assert!(!q.forces_privacy_demotion());
assert!((q.penalized_coherence() - 0.9).abs() < 1e-6);
let (lo, hi) = q.confidence_bounds();
assert!(lo <= hi && (lo - 0.9).abs() < 1e-6 && (hi - 0.9).abs() < 1e-6);
}
#[test]
fn contradiction_penalizes_and_demotes() {
let mut q = base();
q.contradiction_flags.push(ContradictionFlag::TimestampMismatch {
spread_ns: 2_000,
soft_guard_ns: 1_000,
});
assert!(q.forces_privacy_demotion());
assert!((q.penalized_coherence() - 0.72).abs() < 1e-5); // 0.9 * 0.8
let (lo, hi) = q.confidence_bounds();
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
}
#[test]
fn quality_scored_trait_bounds_invariant() {
let mut q = base();
for _ in 0..5 {
q.contradiction_flags.push(ContradictionFlag::PhaseAlignmentFailed { node_idx: 0 });
}
let s = q.quality_score();
let (lo, hi) = q.confidence_bounds();
assert!((0.0..=1.0).contains(&s));
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
}
}
@@ -58,9 +58,34 @@ pub mod pose_tracker;
// ADR-134: CIR estimation (ISTA + NeumannSolver warm-start)
pub mod cir;
// ADR-137: Fusion-engine quality scoring (evidence + contradiction flags)
pub mod fusion_quality;
// ADR-138: Array coordinator — clock-quality gating + directional evidence
pub mod array_coordinator;
// ADR-142: Evolution tracker + temporal VoxelMap (Bayesian, privacy-gated)
pub mod evolution;
// ADR-143: RF-SLAM persistent reflector discovery + static-anchor learning
pub mod rf_slam;
// ADR-135: Empty-room baseline calibration (Welford online, circular phase)
pub mod calibration;
// Re-export core types for ergonomic access
pub use coherence::CoherenceState;
pub use coherence_gate::{GateDecision, GatePolicy};
pub use array_coordinator::{
ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, DirectionalEvidence,
};
pub use evolution::{
ChangePoint, EvolutionTracker, TemporalVoxel, TemporalVoxelMap, VoxelGate, VoxelPrivacy,
};
pub use rf_slam::{PersistentReflector, ReflectorClass, ReflectorObservation, RfSlam};
pub use fusion_quality::{
CalibrationId, ContradictionFlag, EvidenceRef, FamilyId, QualityScore,
};
pub use multiband::MultiBandCsiFrame;
pub use multistatic::FusedSensingFrame;
pub use phase_align::{PhaseAlignError, PhaseAligner};
@@ -140,6 +165,73 @@ pub enum RuvSenseError {
/// Common result type for RuvSense operations.
pub type Result<T> = std::result::Result<T, RuvSenseError>;
// =============================================================================
// ADR-136 — Streaming-engine contract surface (Stage / Versioned / QualityScored)
// =============================================================================
/// `FrameMeta` is the streaming-engine vocabulary alias for the core
/// `CsiMetadata` (ADR-136 §2.2). It *is* the same struct — re-exported, not
/// copied — so cross-stage hops carry provenance (`calibration_id`, `model_id`,
/// `model_version`) without conversion cost.
pub use wifi_densepose_core::types::CsiMetadata as FrameMeta;
/// Result type returned by a [`Stage`] transform.
pub type StageResult<O> = std::result::Result<O, RuvSenseError>;
/// A pipeline stage that transforms one typed frame into another (ADR-136 §2.4).
///
/// Stages are `Send + Sync`. Determinism rule: given the same input bytes and
/// the same `&self` configuration, [`Stage::process`] MUST produce the same
/// output bytes (ADR-136 §2.5 replay contract). Mutable runtime state (rolling
/// windows, Welford accumulators) lives behind `&self` interior types whose
/// effect on output is captured by the deterministic-replay fixture.
///
/// **Boundary rule:** a stage never mutates its input's `FrameMeta.calibration_id`
/// or `model_id`/`model_version` except the calibration stage (sets
/// `calibration_id`) and the model-binding stage (sets the model fields). This
/// keeps provenance append-only along the chain.
pub trait Stage<I, O>: Send + Sync {
/// Human/stage identifier, e.g. `"phase_align"`, `"calibration"`.
fn name(&self) -> &'static str;
/// Transform one input frame into one output frame.
///
/// # Errors
/// Returns [`RuvSenseError`] if the stage cannot process the input.
fn process(&self, input: I) -> StageResult<O>;
}
/// Forward-compatible version stamp (ADR-136 §2.4, mirrors ADR-119 §2.1).
///
/// A `(major, minor)` pair plus a reserved-flags word so future revisions extend
/// without breaking the deterministic byte layout.
pub trait Versioned {
/// `(major, minor)` version of this stage's output contract.
fn version(&self) -> (u8, u8);
/// Reserved forward-compat flags (ADR-119 reserved bits 2..15). Default `0`.
fn reserved_flags(&self) -> u16 {
0
}
/// True if a consumer at `other` can consume output produced at
/// [`Self::version`] — equal major and `self.minor >= other.minor`.
fn is_compatible_with(&self, other: (u8, u8)) -> bool {
let (maj, min) = self.version();
maj == other.0 && min >= other.1
}
}
/// A stage output carrying a scalar quality score and a confidence interval
/// (ADR-136 §2.4). Consumed by ADR-137 (fusion quality) and ADR-145 (ablation).
pub trait QualityScored {
/// Scalar quality in `[0.0, 1.0]`; higher is better.
fn quality_score(&self) -> f32;
/// `(lower, upper)` confidence bounds with `0.0 <= lower <= upper <= 1.0`.
fn confidence_bounds(&self) -> (f32, f32);
}
/// Configuration for the RuvSense pipeline.
#[derive(Debug, Clone)]
pub struct RuvSenseConfig {
@@ -295,6 +387,97 @@ mod tests {
assert_eq!(NUM_KEYPOINTS, 17);
}
// ===== ADR-136 trait-surface acceptance tests =====
// Tiny stages forming a Stage<u32,u32> -> Stage<u32,String> chain (AC4).
struct Doubler;
impl Stage<u32, u32> for Doubler {
fn name(&self) -> &'static str {
"doubler"
}
fn process(&self, input: u32) -> StageResult<u32> {
Ok(input * 2)
}
}
struct Stringify;
impl Stage<u32, String> for Stringify {
fn name(&self) -> &'static str {
"stringify"
}
fn process(&self, input: u32) -> StageResult<String> {
Ok(format!("v{input}"))
}
}
/// AC4 — heterogeneous `Stage` chain composes and visits stages in order.
#[test]
fn ac4_stage_chain_composition() {
let s1 = Doubler;
let s2 = Stringify;
let mut visited = Vec::new();
visited.push(s1.name());
let mid = s1.process(21).unwrap();
visited.push(s2.name());
let out = s2.process(mid).unwrap();
assert_eq!(out, "v42");
assert_eq!(visited, vec!["doubler", "stringify"]);
}
struct V(u8, u8);
impl Versioned for V {
fn version(&self) -> (u8, u8) {
(self.0, self.1)
}
}
/// AC5 — `Versioned` compatibility: equal major, minor >= consumer's.
#[test]
fn ac5_versioned_compatibility() {
let v = V(1, 3);
assert!(v.is_compatible_with((1, 3)), "equal");
assert!(v.is_compatible_with((1, 0)), "newer minor accepts older consumer");
assert!(!v.is_compatible_with((1, 4)), "older producer rejects newer consumer");
assert!(!v.is_compatible_with((2, 0)), "major mismatch rejected");
assert_eq!(v.reserved_flags(), 0);
}
struct Q(f32, f32, f32);
impl QualityScored for Q {
fn quality_score(&self) -> f32 {
self.0
}
fn confidence_bounds(&self) -> (f32, f32) {
(self.1, self.2)
}
}
/// AC8 — `QualityScored` bounds invariant: 0 <= lower <= upper <= 1.
#[test]
fn ac8_quality_scored_bounds() {
let q = Q(0.9, 0.7, 0.95);
let s = q.quality_score();
let (lo, hi) = q.confidence_bounds();
assert!((0.0..=1.0).contains(&s));
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
}
/// `FrameMeta` is the same type as core `CsiMetadata` (ADR-136 §2.2).
#[test]
fn frame_meta_is_csi_metadata() {
fn assert_same<T>(_: &T, _: &T) {}
let a = FrameMeta::new(
wifi_densepose_core::types::DeviceId::new("n"),
wifi_densepose_core::types::FrequencyBand::Band2_4GHz,
1,
);
let b = wifi_densepose_core::types::CsiMetadata::new(
wifi_densepose_core::types::DeviceId::new("n"),
wifi_densepose_core::types::FrequencyBand::Band2_4GHz,
1,
);
assert_same(&a, &b); // compiles only if FrameMeta == CsiMetadata
}
#[test]
fn custom_config_pipeline() {
let cfg = RuvSenseConfig {
@@ -86,6 +86,10 @@ pub struct MultistaticConfig {
/// Maximum timestamp spread (microseconds) across nodes in one cycle.
/// Default: 5000 us (5 ms), well within the 50 ms TDMA cycle.
pub guard_interval_us: u64,
/// ADR-137 soft guard (microseconds): a spread above this but within
/// `guard_interval_us` is fused but recorded as a `TimestampMismatch`
/// contradiction (loose alignment ⇒ privacy demotion). Default guard/5.
pub soft_guard_us: u64,
/// Minimum number of nodes for multistatic mode.
/// Falls back to single-node mode if fewer nodes are available.
pub min_nodes: usize,
@@ -103,6 +107,7 @@ impl Default for MultistaticConfig {
fn default() -> Self {
Self {
guard_interval_us: 5000,
soft_guard_us: 1000,
min_nodes: 2,
attention_temperature: 1.0,
enable_person_separation: true,
@@ -281,6 +286,149 @@ impl MultistaticFuser {
})
}
/// Fuse and produce an auditable [`QualityScore`] alongside the frame
/// (ADR-137). Additive over [`Self::fuse`]: the frame is identical; the
/// score records the per-node attention weights actually used, the positive
/// [`EvidenceRef`]s, and any tolerated [`ContradictionFlag`]s (e.g. a loose
/// but in-guard timestamp spread). A non-empty contradiction set must demote
/// the downstream BFLD privacy class (see [`QualityScore::forces_privacy_demotion`]).
///
/// `coherence_accept` is the gate threshold (mirrors `RuvSenseConfig`);
/// meeting it records a [`EvidenceRef::CoherenceGateThreshold`].
///
/// # Errors
/// Same hard-error preconditions as [`Self::fuse`].
pub fn fuse_scored(
&self,
node_frames: &[MultiBandCsiFrame],
coherence_accept: f32,
) -> std::result::Result<(FusedSensingFrame, super::fusion_quality::QualityScore), MultistaticError>
{
use super::fusion_quality::{ContradictionFlag, EvidenceRef, FamilyId, QualityScore};
let fused = self.fuse(node_frames)?;
// Recompute the per-node amplitude views (same selection as `fuse`).
let amplitudes: Vec<&[f32]> = node_frames
.iter()
.filter_map(|f| f.channel_frames.first().map(|cf| cf.amplitude.as_slice()))
.collect();
let n_nodes = amplitudes.len();
let per_node_weights = if n_nodes <= 1 {
vec![1.0_f32; n_nodes]
} else {
node_attention_weights(&amplitudes, self.config.attention_temperature)
};
// --- Positive evidence ---
let mut evidence_refs = Vec::new();
if n_nodes > 1 {
evidence_refs.push(EvidenceRef::WeightEntropy {
normalized_entropy: compute_weight_coherence(&per_node_weights),
n_nodes,
});
}
if fused.cross_node_coherence >= coherence_accept {
evidence_refs.push(EvidenceRef::CoherenceGateThreshold {
coherence: fused.cross_node_coherence,
threshold: coherence_accept,
});
}
// --- Tolerated contradictions ---
let mut contradiction_flags = Vec::new();
if n_nodes > 1 {
let min_ts = node_frames.iter().map(|f| f.timestamp_us).min().unwrap_or(0);
let max_ts = node_frames.iter().map(|f| f.timestamp_us).max().unwrap_or(0);
let spread_ns = (max_ts - min_ts).saturating_mul(1000);
let soft_guard_ns = self.config.soft_guard_us.saturating_mul(1000);
if spread_ns > soft_guard_ns {
contradiction_flags.push(ContradictionFlag::TimestampMismatch {
spread_ns,
soft_guard_ns,
});
}
}
let capture_ns = fused.timestamp_us.saturating_mul(1000);
let base_coherence = fused.cross_node_coherence;
Ok((
fused,
QualityScore {
family_id: FamilyId::MultistaticCsi,
capture_ns,
// Frames at this layer do not yet carry a calibration epoch
// (ADR-135 id propagation lands with the calibration Stage);
// recorded as None until then.
calibration_id: None,
base_coherence,
per_node_weights,
evidence_refs,
contradiction_flags,
timestamp_computed_ns: capture_ns,
},
))
}
/// Like [`Self::fuse_scored`], but threads a per-node calibration epoch
/// (ADR-137 §2.3). `calibrations[i]` is the [`CalibrationId`] applied to
/// `node_frames[i]` (ADR-135 `BaselineCalibration::calibration_id`).
///
/// - If every contributing node carries the **same** calibration id, the
/// score's `calibration_id` is set to it and a
/// [`EvidenceRef::CalibrationApplied`] is recorded.
/// - If the calibrations **disagree** (or some are missing), the score's
/// `calibration_id` is left `None` and a
/// [`ContradictionFlag::CalibrationIdMismatch`] is raised — which forces a
/// downstream privacy demotion (ADR-141).
///
/// # Errors
/// Same hard-error preconditions as [`Self::fuse`].
pub fn fuse_scored_calibrated(
&self,
node_frames: &[MultiBandCsiFrame],
calibrations: &[Option<super::fusion_quality::CalibrationId>],
coherence_accept: f32,
) -> std::result::Result<(FusedSensingFrame, super::fusion_quality::QualityScore), MultistaticError>
{
use super::fusion_quality::{ContradictionFlag, EvidenceRef};
let (fused, mut score) = self.fuse_scored(node_frames, coherence_accept)?;
let present: Vec<_> = calibrations.iter().flatten().copied().collect();
if present.is_empty() {
return Ok((fused, score)); // uncalibrated path — leave None.
}
// Modal (most frequent) calibration id; ties resolve to the first seen.
let mut modal = present[0];
let mut best = 0usize;
for &cand in &present {
let c = present.iter().filter(|&&x| x == cand).count();
if c > best {
best = c;
modal = cand;
}
}
// Disagreement = any node whose calibration differs from the modal,
// including nodes that carried no calibration at all.
let agreeing = present.iter().filter(|&&x| x == modal).count();
let disagreeing = calibrations.len() - agreeing;
if disagreeing == 0 {
score.calibration_id = Some(modal);
score.evidence_refs.push(EvidenceRef::CalibrationApplied {
calibration_id: modal,
n_frames: agreeing,
});
} else {
// Mismatch: unsafe to claim a single calibration epoch (§2.3).
score.calibration_id = None;
score
.contradiction_flags
.push(ContradictionFlag::CalibrationIdMismatch { expected: modal, disagreeing });
}
Ok((fused, score))
}
/// Apply the CIR-domain coherence gate (ADR-134).
///
/// When `use_cir_gate` is enabled and a `CirEstimator` is present, runs
@@ -366,46 +514,10 @@ fn attention_weighted_fusion(
phases: &[&[f32]],
temperature: f32,
) -> (Vec<f32>, Vec<f32>, f32) {
let n_nodes = amplitudes.len();
let n_sub = amplitudes[0].len();
// Compute mean amplitude as consensus reference
let mut mean_amp = vec![0.0_f32; n_sub];
for amp in amplitudes {
for (i, &v) in amp.iter().enumerate() {
mean_amp[i] += v;
}
}
for v in &mut mean_amp {
*v /= n_nodes as f32;
}
// Compute attention weights based on similarity to consensus
let mut logits = vec![0.0_f32; n_nodes];
for (n, amp) in amplitudes.iter().enumerate() {
let mut dot = 0.0_f32;
let mut norm_a = 0.0_f32;
let mut norm_b = 0.0_f32;
for i in 0..n_sub {
dot += amp[i] * mean_amp[i];
norm_a += amp[i] * amp[i];
norm_b += mean_amp[i] * mean_amp[i];
}
let denom = (norm_a * norm_b).sqrt().max(1e-12);
let similarity = dot / denom;
logits[n] = similarity / temperature;
}
// Numerically stable softmax: subtract max to prevent exp() overflow
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut weights = vec![0.0_f32; n_nodes];
for (n, &logit) in logits.iter().enumerate() {
weights[n] = (logit - max_logit).exp();
}
let weight_sum: f32 = weights.iter().sum::<f32>().max(1e-12);
for w in &mut weights {
*w /= weight_sum;
}
// Attention weights (cosine similarity to consensus, softmax).
let weights = node_attention_weights(amplitudes, temperature);
// Weighted fusion
let mut fused_amp = vec![0.0_f32; n_sub];
@@ -434,11 +546,62 @@ fn attention_weighted_fusion(
(fused_amp, fused_ph, coherence)
}
/// Compute the per-node attention weights (cosine similarity to the amplitude
/// consensus, softmaxed at `temperature`). Returned weights sum to ~1.0 and are
/// node-index aligned. Exposed so the ADR-137 fusion-quality scorer records the
/// exact weights used for fusion rather than re-deriving an approximation.
#[must_use]
pub fn node_attention_weights(amplitudes: &[&[f32]], temperature: f32) -> Vec<f32> {
let n_nodes = amplitudes.len();
if n_nodes == 0 {
return Vec::new();
}
let n_sub = amplitudes[0].len();
// Mean amplitude as consensus reference.
let mut mean_amp = vec![0.0_f32; n_sub];
for amp in amplitudes {
for (i, &v) in amp.iter().enumerate() {
mean_amp[i] += v;
}
}
for v in &mut mean_amp {
*v /= n_nodes as f32;
}
// Cosine-similarity logits.
let mut logits = vec![0.0_f32; n_nodes];
for (n, amp) in amplitudes.iter().enumerate() {
let mut dot = 0.0_f32;
let mut norm_a = 0.0_f32;
let mut norm_b = 0.0_f32;
for i in 0..n_sub.min(amp.len()) {
dot += amp[i] * mean_amp[i];
norm_a += amp[i] * amp[i];
norm_b += mean_amp[i] * mean_amp[i];
}
let denom = (norm_a * norm_b).sqrt().max(1e-12);
logits[n] = (dot / denom) / temperature;
}
// Numerically stable softmax.
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut weights = vec![0.0_f32; n_nodes];
for (n, &logit) in logits.iter().enumerate() {
weights[n] = (logit - max_logit).exp();
}
let weight_sum: f32 = weights.iter().sum::<f32>().max(1e-12);
for w in &mut weights {
*w /= weight_sum;
}
weights
}
/// Compute coherence from attention weights.
///
/// Returns 1.0 when all weights are equal (all nodes agree),
/// and approaches 0.0 when a single node dominates.
fn compute_weight_coherence(weights: &[f32]) -> f32 {
pub(crate) fn compute_weight_coherence(weights: &[f32]) -> f32 {
let n = weights.len() as f32;
if n <= 1.0 {
return 1.0;
@@ -575,6 +738,103 @@ mod tests {
assert_eq!(fused.active_nodes, 4);
}
// ===== ADR-137 fusion-quality scoring =====
#[test]
fn ac_fuse_scored_tight_alignment_no_contradiction() {
use super::super::fusion_quality::{EvidenceRef, FamilyId};
let fuser = MultistaticFuser::new();
// Two identical nodes, 1 us apart (< soft_guard 1000 us): no contradiction.
let f0 = make_node_frame(0, 1000, 56, 1.0);
let f1 = make_node_frame(1, 1001, 56, 1.0);
let (fused, score) = fuser.fuse_scored(&[f0, f1], 0.85).unwrap();
assert_eq!(score.family_id, FamilyId::MultistaticCsi);
assert_eq!(score.per_node_weights.len(), 2);
assert!((score.per_node_weights.iter().sum::<f32>() - 1.0).abs() < 1e-4);
assert_eq!(score.capture_ns, fused.timestamp_us * 1000);
// Identical nodes → high coherence → gate evidence present.
assert!(score
.evidence_refs
.iter()
.any(|e| matches!(e, EvidenceRef::CoherenceGateThreshold { .. })));
assert!(score
.evidence_refs
.iter()
.any(|e| matches!(e, EvidenceRef::WeightEntropy { n_nodes: 2, .. })));
assert!(!score.forces_privacy_demotion(), "tight alignment ⇒ no demotion");
}
#[test]
fn ac_fuse_scored_loose_alignment_flags_soft_contradiction() {
use super::super::fusion_quality::ContradictionFlag;
// guard 5000 us; spread 2000 us is within guard but > soft_guard 1000 us.
let fuser = MultistaticFuser::new();
let f0 = make_node_frame(0, 1000, 56, 1.0);
let f1 = make_node_frame(1, 3000, 56, 1.0);
let (_fused, score) = fuser.fuse_scored(&[f0, f1], 0.85).unwrap();
assert!(score.forces_privacy_demotion(), "loose alignment ⇒ demotion");
assert!(matches!(
score.contradiction_flags[0],
ContradictionFlag::TimestampMismatch { spread_ns: 2_000_000, soft_guard_ns: 1_000_000 }
));
// Penalized coherence is strictly below base when a contradiction fires.
assert!(score.penalized_coherence() < score.base_coherence);
}
#[test]
fn ac_fuse_scored_calibrated_agreement_sets_id() {
use super::super::fusion_quality::{CalibrationId, EvidenceRef};
let fuser = MultistaticFuser::new();
let f0 = make_node_frame(0, 1000, 56, 1.0);
let f1 = make_node_frame(1, 1001, 56, 1.0);
let cal = CalibrationId(0xCAFE);
let (_f, score) = fuser
.fuse_scored_calibrated(&[f0, f1], &[Some(cal), Some(cal)], 0.85)
.unwrap();
assert_eq!(score.calibration_id, Some(cal), "agreed calibration recorded");
assert!(score
.evidence_refs
.iter()
.any(|e| matches!(e, EvidenceRef::CalibrationApplied { calibration_id, .. } if *calibration_id == cal)));
assert!(!score.forces_privacy_demotion());
}
#[test]
fn ac_fuse_scored_calibration_mismatch_flags_and_nulls_id() {
use super::super::fusion_quality::{CalibrationId, ContradictionFlag};
let fuser = MultistaticFuser::new();
let f0 = make_node_frame(0, 1000, 56, 1.0);
let f1 = make_node_frame(1, 1001, 56, 1.0);
// Two nodes, DIFFERENT calibration epochs → mismatch.
let (_f, score) = fuser
.fuse_scored_calibrated(&[f0, f1], &[Some(CalibrationId(1)), Some(CalibrationId(2))], 0.85)
.unwrap();
assert_eq!(score.calibration_id, None, "mismatch ⇒ no single calibration id");
assert!(score
.contradiction_flags
.iter()
.any(|c| matches!(c, ContradictionFlag::CalibrationIdMismatch { disagreeing: 1, .. })));
assert!(score.forces_privacy_demotion(), "mismatch forces demotion");
}
#[test]
fn ac_fuse_scored_hard_guard_still_errors() {
// Beyond the hard guard interval, fuse_scored errors like fuse.
let config = MultistaticConfig {
guard_interval_us: 100,
..Default::default()
};
let fuser = MultistaticFuser::with_config(config);
let f0 = make_node_frame(0, 0, 56, 1.0);
let f1 = make_node_frame(1, 200, 56, 1.0);
assert!(matches!(
fuser.fuse_scored(&[f0, f1], 0.85),
Err(MultistaticError::TimestampMismatch { .. })
));
}
#[test]
fn empty_frames_error() {
let fuser = MultistaticFuser::new();
@@ -0,0 +1,301 @@
//! ADR-143 — RF-SLAM: persistent reflector discovery and static-anchor learning.
//!
//! Ships **v1 fixed-map first** (known sensor positions + a small set of static
//! reflectors, `discovery_enabled = false`). v2 discovery — inferring persistent
//! reflector positions from ADR-134 CIR tap separation + temporal coherence,
//! clustering them into furniture/wall anchors, and detecting topology changes —
//! is gated behind `discovery_enabled` until a multi-day validation dataset is
//! collected (ADR-143 §2.5).
//!
//! Reflector positions, once discovered, are intended to land as ADR-139
//! `WorldNode::ObjectAnchor` nodes; this module owns the inference, the
//! WorldGraph owns the persistence.
use crate::ruvsense::field_model::WelfordStats;
/// Classification of a discovered persistent reflector (mirrors ADR-139
/// `AnchorKind`; kept local to avoid a crate dependency on the WorldGraph).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReflectorClass {
/// A near-static reflector consistent with a wall (very low migration).
Wall,
/// A slowly-moving reflector consistent with furniture.
Furniture,
/// Moves too fast to be a static anchor (rejected from the anchor set).
Mobile,
}
/// A single CIR-tap-derived reflector sighting at a point in time (ADR-134 CIR).
#[derive(Debug, Clone, Copy)]
pub struct ReflectorObservation {
/// Inferred reflector position (east, north, up) in metres.
pub position: [f64; 3],
/// CIR dominant-tap delay (ns) that produced this sighting.
pub delay_ns: f64,
/// Temporal coherence of the tap in [0, 1] (gate quality).
pub coherence: f32,
/// Capture-clock time (ns).
pub at_ns: u64,
}
/// A reflector accumulated over many sightings (ADR-143 §2).
#[derive(Debug, Clone)]
pub struct PersistentReflector {
/// Per-axis position statistics (Welford).
pos: [WelfordStats; 3],
/// Number of sightings folded in.
pub sightings: u64,
/// First and last sighting times (ns).
pub first_ns: u64,
/// Last sighting time (ns).
pub last_ns: u64,
/// Total displacement of the running mean since the first sighting (m).
cumulative_drift_m: f64,
/// Last mean position, for incremental drift accumulation.
last_mean: [f64; 3],
}
impl PersistentReflector {
fn from_first(obs: &ReflectorObservation) -> Self {
let mut pos = [WelfordStats::new(), WelfordStats::new(), WelfordStats::new()];
for a in 0..3 {
pos[a].update(obs.position[a]);
}
Self {
pos,
sightings: 1,
first_ns: obs.at_ns,
last_ns: obs.at_ns,
cumulative_drift_m: 0.0,
last_mean: obs.position,
}
}
fn fold(&mut self, obs: &ReflectorObservation) {
for a in 0..3 {
self.pos[a].update(obs.position[a]);
}
let new_mean = self.mean_position();
let d: f64 = (0..3).map(|a| (new_mean[a] - self.last_mean[a]).powi(2)).sum::<f64>().sqrt();
self.cumulative_drift_m += d;
self.last_mean = new_mean;
self.last_ns = obs.at_ns;
self.sightings += 1;
}
/// Mean reflector position.
#[must_use]
pub fn mean_position(&self) -> [f64; 3] {
[self.pos[0].mean, self.pos[1].mean, self.pos[2].mean]
}
/// Positional spread (max per-axis std, m) — low ⇒ a stable reflector.
#[must_use]
pub fn position_std(&self) -> f64 {
(0..3).map(|a| self.pos[a].std_dev()).fold(0.0, f64::max)
}
/// Mean-position migration rate in metres/day over the observed span.
#[must_use]
pub fn migration_m_per_day(&self) -> f64 {
let span_ns = self.last_ns.saturating_sub(self.first_ns);
if span_ns == 0 {
return 0.0;
}
let span_days = span_ns as f64 / 86_400_000_000_000.0; // ns → days
if span_days < 1e-9 {
return 0.0;
}
self.cumulative_drift_m / span_days
}
/// Classify by migration rate (ADR-143 §2): walls barely move, furniture
/// migrates slowly, anything faster than `mobile_floor` m/day is rejected.
#[must_use]
pub fn classify(&self, wall_ceiling: f64, mobile_floor: f64) -> ReflectorClass {
let m = self.migration_m_per_day();
if m <= wall_ceiling {
ReflectorClass::Wall
} else if m < mobile_floor {
ReflectorClass::Furniture
} else {
ReflectorClass::Mobile
}
}
}
/// RF-SLAM reflector discovery engine (ADR-143).
#[derive(Debug, Clone)]
pub struct RfSlam {
reflectors: Vec<PersistentReflector>,
/// Association radius (m): a sighting within this of a reflector's mean is
/// folded in; otherwise it seeds a new reflector.
assoc_radius_m: f64,
/// Minimum sightings before a reflector counts as "persistent".
min_sightings: u64,
/// Minimum tap coherence for a sighting to be admitted.
min_coherence: f32,
/// v2 discovery gate — false ⇒ fixed-map v1 (no new reflectors learned).
discovery_enabled: bool,
}
impl RfSlam {
/// v1 fixed-map mode: discovery disabled.
#[must_use]
pub fn fixed_map() -> Self {
Self {
reflectors: Vec::new(),
assoc_radius_m: 0.5,
min_sightings: 20,
min_coherence: 0.6,
discovery_enabled: false,
}
}
/// v2 discovery mode: learn persistent reflectors from sightings.
#[must_use]
pub fn with_discovery(assoc_radius_m: f64, min_sightings: u64, min_coherence: f32) -> Self {
Self {
reflectors: Vec::new(),
assoc_radius_m,
min_sightings,
min_coherence,
discovery_enabled: true,
}
}
/// Whether v2 discovery is active.
#[must_use]
pub fn discovery_enabled(&self) -> bool {
self.discovery_enabled
}
/// Ingest one CIR-derived sighting. In fixed-map mode this is a no-op
/// (returns false). In discovery mode it associates to the nearest reflector
/// within `assoc_radius_m` or seeds a new one; returns true if accepted.
pub fn observe(&mut self, obs: &ReflectorObservation) -> bool {
if !self.discovery_enabled || obs.coherence < self.min_coherence {
return false;
}
// Nearest-reflector association.
let mut best: Option<(usize, f64)> = None;
for (i, r) in self.reflectors.iter().enumerate() {
let m = r.mean_position();
let d: f64 = (0..3).map(|a| (m[a] - obs.position[a]).powi(2)).sum::<f64>().sqrt();
if d <= self.assoc_radius_m && best.map_or(true, |(_, bd)| d < bd) {
best = Some((i, d));
}
}
match best {
Some((i, _)) => self.reflectors[i].fold(obs),
None => self.reflectors.push(PersistentReflector::from_first(obs)),
}
true
}
/// Indices/refs of reflectors that have crossed the persistence threshold.
#[must_use]
pub fn persistent(&self) -> Vec<&PersistentReflector> {
self.reflectors.iter().filter(|r| r.sightings >= self.min_sightings).collect()
}
/// Static-anchor set: persistent reflectors classified Wall or Furniture
/// (mobile reflectors rejected) — the candidate ADR-139 `ObjectAnchor`s.
#[must_use]
pub fn static_anchors(&self, wall_ceiling: f64, mobile_floor: f64) -> Vec<([f64; 3], ReflectorClass)> {
self.persistent()
.into_iter()
.map(|r| (r.mean_position(), r.classify(wall_ceiling, mobile_floor)))
.filter(|(_, c)| *c != ReflectorClass::Mobile)
.collect()
}
/// Topology-change signal: the count of persistent reflectors. A caller
/// compares this across time; an increase/decrease beyond a threshold marks
/// a furniture-moved / room-changed event (ADR-143 §2 topology detection).
#[must_use]
pub fn persistent_count(&self) -> usize {
self.reflectors.iter().filter(|r| r.sightings >= self.min_sightings).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(pos: [f64; 3], at_ns: u64) -> ReflectorObservation {
ReflectorObservation { position: pos, delay_ns: 10.0, coherence: 0.9, at_ns }
}
#[test]
fn fixed_map_does_not_discover() {
let mut slam = RfSlam::fixed_map();
assert!(!slam.discovery_enabled());
assert!(!slam.observe(&obs([1.0, 1.0, 0.0], 0)));
assert_eq!(slam.persistent_count(), 0);
}
#[test]
fn discovery_learns_persistent_reflector() {
let mut slam = RfSlam::with_discovery(0.5, 20, 0.6);
// 25 sightings clustered tightly around (2,3,0).
for i in 0..25u64 {
let jitter = if i % 2 == 0 { 0.01 } else { -0.01 };
assert!(slam.observe(&obs([2.0 + jitter, 3.0, 0.0], i * 1_000_000)));
}
assert_eq!(slam.persistent_count(), 1);
let r = slam.persistent()[0];
assert!((r.mean_position()[0] - 2.0).abs() < 0.05);
assert!(r.position_std() < 0.1);
}
#[test]
fn low_coherence_sightings_rejected() {
let mut slam = RfSlam::with_discovery(0.5, 5, 0.6);
let mut o = obs([1.0, 1.0, 0.0], 0);
o.coherence = 0.3; // below min
assert!(!slam.observe(&o));
assert_eq!(slam.persistent_count(), 0);
}
#[test]
fn separate_clusters_form_distinct_reflectors() {
let mut slam = RfSlam::with_discovery(0.5, 3, 0.6);
for i in 0..5u64 {
slam.observe(&obs([0.0, 0.0, 0.0], i));
slam.observe(&obs([5.0, 5.0, 0.0], i)); // > assoc_radius apart
}
assert_eq!(slam.persistent_count(), 2);
}
#[test]
fn mobile_reflector_excluded_from_anchors() {
// A reflector whose mean marches ~10 m/day is Mobile, not an anchor.
let mut slam = RfSlam::with_discovery(50.0, 5, 0.6);
let day_ns = 86_400_000_000_000u64;
for i in 0..10u64 {
// Position advances 1 m each tenth-of-a-day → ~10 m/day.
let t = i * (day_ns / 10);
slam.observe(&obs([i as f64, 0.0, 0.0], t));
}
let anchors = slam.static_anchors(0.05, 1.0);
assert!(anchors.is_empty(), "fast-migrating reflector must not be an anchor");
// But it is still a persistent reflector (tracked, just not anchored).
assert_eq!(slam.persistent_count(), 1);
assert_eq!(slam.persistent()[0].classify(0.05, 1.0), ReflectorClass::Mobile);
}
#[test]
fn static_reflector_classified_wall() {
let mut slam = RfSlam::with_discovery(0.5, 5, 0.6);
let day_ns = 86_400_000_000_000u64;
for i in 0..10u64 {
// Tight cluster, spanning ~1 day → ~0 migration.
let jitter = if i % 2 == 0 { 0.005 } else { -0.005 };
slam.observe(&obs([3.0 + jitter, 0.0, 0.0], i * (day_ns / 10)));
}
let anchors = slam.static_anchors(0.05, 1.0);
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].1, ReflectorClass::Wall);
}
}
@@ -0,0 +1,243 @@
//! Drift-triggered recalibration scenario tests (ADR-135 §2.5 and §2.6).
//!
//! Validates that the deviation z-score escalates correctly under sustained
//! amplitude drift, and stays suppressed for a stable stationary channel.
//!
//! Tests are seeded with literal `42` and are fully deterministic.
use std::f32::consts::PI;
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{
BaselineCalibration, CalibrationConfig, CalibrationError, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0, "xorshift seed must be non-zero");
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_normal(&mut self) -> f32 {
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * PI * u2;
r * theta.cos()
}
}
// ---------------------------------------------------------------------------
// Constants and helpers
// ---------------------------------------------------------------------------
const N_ACTIVE: usize = 52; // HT20
fn base_amp() -> Vec<f32> {
(0..N_ACTIVE)
.map(|k| 0.3 + 0.7 * (k as f32 * PI / N_ACTIVE as f32).sin().abs())
.collect()
}
fn base_phase() -> Vec<f32> {
(0..N_ACTIVE)
.map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI)
.collect()
}
fn make_frame_with_amp(amp_vals: &[f32], phase: &[f32], rng: &mut Rng) -> CsiFrame {
let n = amp_vals.len();
let noise_std = 0.005_f32; // very low noise for clean drift detection
let mut data = Array2::<Complex64>::zeros((1, n));
for k in 0..n {
let re = amp_vals[k] * phase[k].cos() + noise_std * rng.next_normal();
let im = amp_vals[k] * phase[k].sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta = CsiMetadata::new(DeviceId::new("drift-test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
fn build_baseline() -> BaselineCalibration {
let amp = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20());
for _ in 0..600 {
let frame = make_frame_with_amp(&amp, &phase, &mut rng);
recorder.record(&frame).expect("record");
}
recorder.finalize().expect("finalize")
}
// ---------------------------------------------------------------------------
// Test 1: slow amplitude drift causes z-score to escalate above 4.0 by frame 900
// ---------------------------------------------------------------------------
/// ADR-135 §2.5: drift_score > 4.0 is the recalibration threshold.
/// With amplitude growing +0.01/frame, the squared z-score (relative to baseline
/// variance) must exceed 4.0 on average over the last 100 of 900 frames.
#[test]
fn should_exceed_drift_threshold_when_amplitude_drifts_slowly() {
let baseline = build_baseline();
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut last_100_mean_sq_z: Vec<f32> = Vec::new();
for t in 0..900usize {
// Each frame has amplitudes drifted up by +0.01 per frame step
let amp: Vec<f32> = base.iter().map(|a| a + 0.01 * t as f32).collect();
let frame = make_frame_with_amp(&amp, &phase, &mut rng);
let score = baseline.deviation(&frame).expect("deviation");
if t >= 800 {
// amplitude_z_median is the median absolute z. drift_score in ADR-135 is
// mean over k of median squared z over a window. We approximate here
// by squaring the amplitude_z_median.
let approx_drift_score = score.amplitude_z_median * score.amplitude_z_median;
last_100_mean_sq_z.push(approx_drift_score);
}
}
let avg_drift_score: f32 =
last_100_mean_sq_z.iter().sum::<f32>() / last_100_mean_sq_z.len() as f32;
assert!(
avg_drift_score > 4.0,
"drift scenario: approx drift score over last 100 frames = {:.3} must exceed 4.0 \
(ADR-135 drift threshold)",
avg_drift_score
);
}
// ---------------------------------------------------------------------------
// Test 2: 900 stationary frames keep z-score below 2.0
// ---------------------------------------------------------------------------
#[test]
fn should_stay_below_drift_threshold_for_stable_channel() {
let baseline = build_baseline();
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut last_100_mean_sq_z: Vec<f32> = Vec::new();
for t in 0..900usize {
let _ = t;
let frame = make_frame_with_amp(&base, &phase, &mut rng);
let score = baseline.deviation(&frame).expect("deviation");
if last_100_mean_sq_z.len() < 100 || t >= 800 {
let approx_drift = score.amplitude_z_median * score.amplitude_z_median;
if t >= 800 {
last_100_mean_sq_z.push(approx_drift);
}
}
}
let avg_drift_score: f32 =
last_100_mean_sq_z.iter().sum::<f32>() / last_100_mean_sq_z.len() as f32;
assert!(
avg_drift_score < 2.0,
"stable scenario: approx drift score over last 100 frames = {:.3} must be < 2.0",
avg_drift_score
);
}
// ---------------------------------------------------------------------------
// Test 3: is_complete() reflects target_frames boundary
// ---------------------------------------------------------------------------
#[test]
fn should_report_not_complete_before_target_frames() {
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
// min_frames=600 means recorder needs at least 600 frames before finalize succeeds.
// is_complete() is defined as frames_recorded() >= config.min_frames.
let config = CalibrationConfig::ht20(); // min_frames = 600
let mut recorder = CalibrationRecorder::new(config);
for _ in 0..10 {
let frame = make_frame_with_amp(&base, &phase, &mut rng);
recorder.record(&frame).expect("record");
}
assert_eq!(recorder.frames_recorded(), 10, "frames_recorded should be 10");
// finalize should fail with InsufficientFrames
let result = recorder.finalize();
assert!(
matches!(result, Err(CalibrationError::InsufficientFrames { .. })),
"expected InsufficientFrames after 10 frames, got {:?}", result
);
}
// ---------------------------------------------------------------------------
// Test 4: finalize() returns InsufficientFrames with correct counts
// ---------------------------------------------------------------------------
#[test]
fn should_error_on_finalize_with_insufficient_frames() {
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20()); // min=600
for _ in 0..50 {
let frame = make_frame_with_amp(&base, &phase, &mut rng);
recorder.record(&frame).expect("record");
}
match recorder.finalize() {
Err(CalibrationError::InsufficientFrames { got, need }) => {
assert_eq!(got, 50, "got should be 50");
assert_eq!(need, 600, "need should be 600 (min_frames)");
}
other => panic!("expected InsufficientFrames, got {:?}", other),
}
}
// ---------------------------------------------------------------------------
// Test 5: motion_flagged flips when amplitude jumps substantially
// ---------------------------------------------------------------------------
#[test]
fn should_flag_motion_when_amplitude_jumps_by_many_sigma() {
let baseline = build_baseline();
let phase = base_phase();
// Compute a meaningful sigma: mean amp_variance across subcarriers
let mean_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ N_ACTIVE as f32;
// Build a frame with all amplitudes shifted up by 5σ
let base = base_amp();
let shifted_amp: Vec<f32> = base.iter().map(|a| a + 5.0 * mean_sigma).collect();
let mut rng = Rng::new(77);
let frame = make_frame_with_amp(&shifted_amp, &phase, &mut rng);
let score = baseline.deviation(&frame).expect("deviation");
assert!(
score.motion_flagged,
"motion must be flagged when amplitude is shifted by 5σ; \
amplitude_z_median={:.3}",
score.amplitude_z_median
);
}
@@ -0,0 +1,247 @@
//! Bytes round-trip tests for BaselineCalibration serialisation (ADR-135 §2.4).
//!
//! The implementation uses `to_bytes()` / `from_bytes()` as the binary format.
//! Magic word is 0xCA1B_0001, schema version = 1.
//!
//! Covers:
//! - Binary round-trip determinism (to_bytes twice → same output)
//! - deserialise→re-serialise produces identical bytes
//! - Version mismatch detection
//! - Truncated buffer detection
//! - Magic word mismatch detection
use std::f32::consts::PI;
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{
BaselineCalibration, CalibrationConfig, CalibrationError, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0, "xorshift seed must be non-zero");
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_normal(&mut self) -> f32 {
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * PI * u2;
r * theta.cos()
}
}
// ---------------------------------------------------------------------------
// Build a deterministic baseline (HT20, 600 frames, seed=42).
// ---------------------------------------------------------------------------
fn build_ht20_baseline() -> BaselineCalibration {
const N: usize = 52;
let amp: Vec<f32> = (0..N)
.map(|k| 0.3 + 0.7 * (k as f32 * PI / N as f32).sin().abs())
.collect();
let phase: Vec<f32> = (0..N)
.map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI)
.collect();
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20());
for _ in 0..600 {
let noise_std = 0.01_f32;
let mut data = Array2::<Complex64>::zeros((1, N));
for k in 0..N {
let re = amp[k] * phase[k].cos() + noise_std * rng.next_normal();
let im = amp[k] * phase[k].sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta =
CsiMetadata::new(DeviceId::new("roundtrip-test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
let frame = CsiFrame::new(meta, data);
recorder.record(&frame).expect("record");
}
recorder.finalize().expect("finalize")
}
// ---------------------------------------------------------------------------
// Binary round-trip determinism
// ---------------------------------------------------------------------------
/// Two calls to `to_bytes()` on the same value must produce identical buffers.
#[test]
fn should_produce_identical_bytes_on_two_calls_to_same_baseline() {
let baseline = build_ht20_baseline();
let bytes1 = baseline.to_bytes();
let bytes2 = baseline.to_bytes();
assert_eq!(
bytes1, bytes2,
"to_bytes must be deterministic across two calls on the same value"
);
}
/// deserialise → re-serialise must produce identical bytes.
#[test]
fn should_deserialise_and_reserialise_to_identical_bytes() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes)
.expect("from_bytes should succeed on valid bytes");
let bytes_recovered = recovered.to_bytes();
assert_eq!(
bytes, bytes_recovered,
"round-trip: re-serialised bytes must match original"
);
}
/// Recovered baseline must have matching field values.
#[test]
fn should_preserve_frame_count_and_subcarrier_count_after_round_trip() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes).expect("from_bytes");
assert_eq!(
baseline.frame_count, recovered.frame_count,
"frame_count must survive round-trip"
);
assert_eq!(
baseline.subcarriers.len(),
recovered.subcarriers.len(),
"subcarrier count must survive round-trip"
);
}
/// Per-subcarrier amp_mean values must survive round-trip within f32 precision.
#[test]
fn should_preserve_amp_mean_per_subcarrier_after_round_trip() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes).expect("from_bytes");
for k in 0..baseline.subcarriers.len() {
assert!(
(baseline.subcarriers[k].amp_mean - recovered.subcarriers[k].amp_mean).abs() < 1e-6,
"amp_mean[{}] mismatch: {:.8} vs {:.8}",
k,
baseline.subcarriers[k].amp_mean,
recovered.subcarriers[k].amp_mean
);
}
}
/// Magic word 0xCA1B_0001 must appear at offset 0 in serialised bytes.
#[test]
fn should_embed_magic_word_0xca1b0001_at_offset_0() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
assert!(bytes.len() >= 4, "serialised bytes must be at least 4 bytes long");
let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
assert_eq!(
magic, 0xCA1B_0001_u32,
"magic word at offset 0 must be 0xCA1B0001, got 0x{:08X}",
magic
);
}
/// Schema version at offset 4 must equal 1.
#[test]
fn should_embed_schema_version_1_at_offset_4() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
assert!(bytes.len() >= 6, "bytes too short");
let version = bytes[4];
assert_eq!(version, 1, "schema version at offset 4 must be 1, got {}", version);
}
// ---------------------------------------------------------------------------
// Error path: version mismatch
// ---------------------------------------------------------------------------
/// Overwrite version byte with 99 → expect VersionMismatch { got: 99, want: 1 }.
#[test]
fn should_return_version_mismatch_for_version_99() {
let baseline = build_ht20_baseline();
let mut bytes = baseline.to_bytes();
// Version is at offset 4 (u8)
bytes[4] = 99;
let result = BaselineCalibration::from_bytes(&bytes);
match result {
Err(CalibrationError::VersionMismatch { got, want }) => {
assert_eq!(got, 99, "VersionMismatch.got should be 99");
assert_eq!(want, 1, "VersionMismatch.want should be 1");
}
other => panic!(
"expected CalibrationError::VersionMismatch, got {:?}",
other
),
}
}
// ---------------------------------------------------------------------------
// Error path: truncated buffer
// ---------------------------------------------------------------------------
/// Trim the last 4 bytes → expect TruncatedBuffer.
#[test]
fn should_return_truncated_buffer_error_for_short_input() {
let baseline = build_ht20_baseline();
let mut bytes = baseline.to_bytes();
let new_len = bytes.len().saturating_sub(4);
bytes.truncate(new_len);
let result = BaselineCalibration::from_bytes(&bytes);
assert!(
matches!(result, Err(CalibrationError::TruncatedBuffer { .. })),
"expected TruncatedBuffer, got {:?}",
result
);
}
/// A completely empty buffer → expect TruncatedBuffer.
#[test]
fn should_return_truncated_buffer_for_empty_input() {
let result = BaselineCalibration::from_bytes(&[]);
assert!(
matches!(result, Err(CalibrationError::TruncatedBuffer { .. })),
"expected TruncatedBuffer for empty buffer, got {:?}",
result
);
}
// ---------------------------------------------------------------------------
// Error path: magic word mismatch
// ---------------------------------------------------------------------------
/// Zero out the first 4 bytes (magic word) → expect InvalidMagic error.
#[test]
fn should_return_error_for_zeroed_magic_word() {
let baseline = build_ht20_baseline();
let mut bytes = baseline.to_bytes();
bytes[0] = 0;
bytes[1] = 0;
bytes[2] = 0;
bytes[3] = 0;
let result = BaselineCalibration::from_bytes(&bytes);
assert!(
matches!(result, Err(CalibrationError::InvalidMagic { .. })),
"expected InvalidMagic when magic word is zeroed, got {:?}",
result
);
}
@@ -0,0 +1,484 @@
//! Deterministic synthetic channel tests for the empty-room baseline calibration
//! module (ADR-135).
//!
//! Validates Welford online statistics, deviation scoring, and per-PHY-tier
//! subcarrier counts. Tests are seeded with literal `42` via xorshift32 and are
//! fully deterministic.
//!
//! Run (compile-only):
//! cargo test -p wifi-densepose-signal --no-default-features --tests --no-run
use std::f32::consts::PI;
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{
BaselineCalibration, CalibrationConfig, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally per ADR-135
// constraint: do not refactor existing test helpers.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0, "xorshift seed must be non-zero");
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
/// Sample N(0,1) via Box-Muller (always consumes two draws).
fn next_normal(&mut self) -> f32 {
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * PI * u2;
r * theta.cos()
}
}
// ---------------------------------------------------------------------------
// Tier parameters
// ---------------------------------------------------------------------------
struct TierSpec {
label: &'static str,
n_active: usize, // active (non-pilot) subcarriers passed in frame
bandwidth_mhz: u16,
config: CalibrationConfig,
}
fn ht20_spec() -> TierSpec {
TierSpec { label: "HT20", n_active: 52, bandwidth_mhz: 20, config: CalibrationConfig::ht20() }
}
fn ht40_spec() -> TierSpec {
TierSpec { label: "HT40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() }
}
fn he20_spec() -> TierSpec {
TierSpec { label: "HE20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() }
}
// ---------------------------------------------------------------------------
// Ground-truth per-subcarrier channel parameters
// ---------------------------------------------------------------------------
fn ground_truth_amp(n: usize) -> Vec<f32> {
(0..n).map(|k| 0.3 + 0.7 * (k as f32 * PI / n as f32).sin().abs()).collect()
}
fn ground_truth_phase(n: usize) -> Vec<f32> {
(0..n).map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI).collect()
}
// ---------------------------------------------------------------------------
// CSI frame builder helpers
// ---------------------------------------------------------------------------
fn make_stationary_frame(
bandwidth_mhz: u16,
n_active: usize,
amp: &[f32],
phase: &[f32],
snr_db: f32,
rng: &mut Rng,
) -> CsiFrame {
assert_eq!(amp.len(), n_active);
let signal_power: f32 = amp.iter().map(|a| a * a).sum::<f32>() / n_active as f32;
let noise_power = signal_power / 10_f32.powf(snr_db / 10.0);
let noise_std = (noise_power / 2.0).sqrt();
let mut data = Array2::<Complex64>::zeros((1, n_active));
for k in 0..n_active {
let re = amp[k] * phase[k].cos() + noise_std * rng.next_normal();
let im = amp[k] * phase[k].sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = bandwidth_mhz;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
/// Build a frame where subcarrier amplitudes are shifted up by `shift_sigma * sigma`.
fn make_perturbed_frame(
bandwidth_mhz: u16,
n_active: usize,
amp: &[f32],
phase: &[f32],
amp_sigma: f32,
perturb_indices: &[usize],
shift_sigma: f32,
rng: &mut Rng,
) -> CsiFrame {
let noise_std = 0.001_f32;
let mut data = Array2::<Complex64>::zeros((1, n_active));
for k in 0..n_active {
let extra = if perturb_indices.contains(&k) { shift_sigma * amp_sigma } else { 0.0 };
let a = amp[k] + extra;
let re = a * phase[k].cos() + noise_std * rng.next_normal();
let im = a * phase[k].sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = bandwidth_mhz;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
// ---------------------------------------------------------------------------
// Helper: build a finalised baseline from 600 stationary frames at SNR=30 dB
// ---------------------------------------------------------------------------
fn build_baseline(spec: &TierSpec) -> BaselineCalibration {
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
recorder.finalize().expect("finalize should succeed with 600 frames")
}
// ---------------------------------------------------------------------------
// Tests — HT20
// ---------------------------------------------------------------------------
mod ht20 {
use super::*;
#[test]
fn should_record_600_frames_when_600_fed() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
assert_eq!(
recorder.frames_recorded(), 600,
"HT20: frames_recorded() should equal 600"
);
}
#[test]
fn should_finalize_with_amp_mean_within_tolerance_of_ground_truth() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let baseline = build_baseline(&spec);
let tol = 0.05_f32;
for k in 0..spec.n_active {
let got = baseline.subcarriers[k].amp_mean;
let expected = amp[k];
assert!(
(got - expected).abs() < tol,
"HT20 amp_mean[{}]: got={:.4} expected={:.4} tol={:.4}",
k, got, expected, tol
);
}
}
#[test]
fn should_have_positive_amp_variance_after_finalize() {
let spec = ht20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].amp_variance > 0.0,
"HT20 amp_variance[{}] must be positive",
k
);
}
}
#[test]
fn should_have_small_amp_variance_for_stationary_channel() {
let spec = ht20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].amp_variance < 0.1,
"HT20 amp_variance[{}]={:.6} must be < 0.1",
k, baseline.subcarriers[k].amp_variance
);
}
}
#[test]
fn should_have_tight_phase_dispersion_for_stationary_channel() {
let spec = ht20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].phase_dispersion < 0.05,
"HT20 phase_dispersion[{}]={:.6} must be < 0.05",
k, baseline.subcarriers[k].phase_dispersion
);
}
}
#[test]
fn should_not_flag_motion_for_stationary_frame() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let mut rng = Rng::new(999);
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.amplitude_z_median < 1.5,
"HT20 stationary: amplitude_z_median={:.3} must be < 1.5",
score.amplitude_z_median
);
assert!(
!score.motion_flagged,
"HT20 stationary: motion_flagged must be false"
);
}
#[test]
fn should_flag_motion_for_3sigma_perturbed_frame() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
// Use mean amp_variance as the sigma estimate
let amp_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ spec.n_active as f32;
let perturb_indices: Vec<usize> = (0..spec.n_active).collect();
let mut rng = Rng::new(999);
let frame = make_perturbed_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, amp_sigma,
&perturb_indices, 3.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.amplitude_z_median > 2.5,
"HT20 perturbed: amplitude_z_median={:.3} must be > 2.5",
score.amplitude_z_median
);
assert!(
score.motion_flagged,
"HT20 perturbed: motion_flagged must be true for 3σ perturbation"
);
}
}
// ---------------------------------------------------------------------------
// Tests — HT40
// ---------------------------------------------------------------------------
mod ht40 {
use super::*;
#[test]
fn should_record_600_frames_when_600_fed() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
assert_eq!(recorder.frames_recorded(), 600, "HT40: frames_recorded() should equal 600");
}
#[test]
fn should_finalize_with_amp_mean_within_tolerance() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let baseline = build_baseline(&spec);
let tol = 0.05_f32;
for k in 0..spec.n_active {
let got = baseline.subcarriers[k].amp_mean;
let expected = amp[k];
assert!(
(got - expected).abs() < tol,
"HT40 amp_mean[{}]: got={:.4} expected={:.4} tol={:.4}",
k, got, expected, tol
);
}
}
#[test]
fn should_have_tight_phase_dispersion_for_stationary_channel() {
let spec = ht40_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].phase_dispersion < 0.05,
"HT40 phase_dispersion[{}]={:.6} must be < 0.05",
k, baseline.subcarriers[k].phase_dispersion
);
}
}
#[test]
fn should_not_flag_motion_for_stationary_frame() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let mut rng = Rng::new(999);
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
!score.motion_flagged,
"HT40 stationary: motion_flagged must be false"
);
}
#[test]
fn should_flag_motion_for_3sigma_perturbed_frame() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let amp_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ spec.n_active as f32;
let perturb_indices: Vec<usize> = (0..spec.n_active).collect();
let mut rng = Rng::new(999);
let frame = make_perturbed_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, amp_sigma,
&perturb_indices, 3.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.motion_flagged,
"HT40 perturbed: motion_flagged must be true for 3σ perturbation"
);
}
}
// ---------------------------------------------------------------------------
// Tests — HE20
// ---------------------------------------------------------------------------
mod he20 {
use super::*;
#[test]
fn should_record_600_frames_when_600_fed() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
assert_eq!(recorder.frames_recorded(), 600, "HE20: frames_recorded() should equal 600");
}
#[test]
fn should_finalize_with_amp_mean_within_tolerance() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let baseline = build_baseline(&spec);
let tol = 0.05_f32;
for k in 0..spec.n_active {
let got = baseline.subcarriers[k].amp_mean;
let expected = amp[k];
assert!(
(got - expected).abs() < tol,
"HE20 amp_mean[{}]: got={:.4} expected={:.4} tol={:.4}",
k, got, expected, tol
);
}
}
#[test]
fn should_have_tight_phase_dispersion_for_stationary_channel() {
let spec = he20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].phase_dispersion < 0.05,
"HE20 phase_dispersion[{}]={:.6} must be < 0.05",
k, baseline.subcarriers[k].phase_dispersion
);
}
}
#[test]
fn should_not_flag_motion_for_stationary_frame() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let mut rng = Rng::new(999);
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
!score.motion_flagged,
"HE20 stationary: motion_flagged must be false"
);
}
#[test]
fn should_flag_motion_for_3sigma_perturbed_frame() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let amp_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ spec.n_active as f32;
let perturb_indices: Vec<usize> = (0..spec.n_active).collect();
let mut rng = Rng::new(999);
let frame = make_perturbed_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, amp_sigma,
&perturb_indices, 3.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.motion_flagged,
"HE20 perturbed: motion_flagged must be true for 3σ perturbation"
);
}
}
@@ -0,0 +1,306 @@
//! ADR-145 — Ablation evaluation harness with privacy-leakage + latency metrics.
//!
//! Runs the sensing pipeline under a matrix of feature combinations
//! (CSI-only / CIR-only / CSI+CIR / +Doppler / +BFLD / +UWB) and binds a metric
//! set — presence accuracy, localisation error, FP/FN, latency p50/p95,
//! privacy-leakage (membership-inference), and cross-room degradation — so every
//! pipeline change is measured, not guessed (ADR-145 §10/§14). The model runs
//! themselves are external; this module owns the deterministic metric
//! computation + the auto-report.
use core::fmt::Write as _;
/// One feature combination in the ablation matrix (ADR-145 §2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FeatureSet {
/// CSI amplitude/phase only.
CsiOnly,
/// CIR taps only (ADR-134).
CirOnly,
/// CSI + CIR.
CsiCir,
/// CSI + CIR + passive Doppler.
CsiCirDoppler,
/// CSI + CIR + Doppler + BFLD privacy gate.
CsiCirDopplerBfld,
/// Full fusion including UWB range constraints (ADR-144; deferred until hw).
FullUwb,
}
impl FeatureSet {
/// The six-variant ablation matrix, runnable order.
pub const MATRIX: [FeatureSet; 6] = [
FeatureSet::CsiOnly,
FeatureSet::CirOnly,
FeatureSet::CsiCir,
FeatureSet::CsiCirDoppler,
FeatureSet::CsiCirDopplerBfld,
FeatureSet::FullUwb,
];
/// Stable label for reports.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::CsiOnly => "csi_only",
Self::CirOnly => "cir_only",
Self::CsiCir => "csi+cir",
Self::CsiCirDoppler => "csi+cir+doppler",
Self::CsiCirDopplerBfld => "csi+cir+doppler+bfld",
Self::FullUwb => "full+uwb",
}
}
}
/// `(p50, p95)` percentiles of a latency sample set (ms), nearest-rank.
#[must_use]
pub fn latency_percentiles_ms(samples_ms: &[f64]) -> (f64, f64) {
if samples_ms.is_empty() {
return (0.0, 0.0);
}
let mut s = samples_ms.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let pick = |q: f64| {
// Nearest-rank: ceil(q * n) - 1, clamped.
let rank = ((q * s.len() as f64).ceil() as usize).clamp(1, s.len()) - 1;
s[rank]
};
(pick(0.50), pick(0.95))
}
/// False-positive and false-negative rates from a confusion count.
#[must_use]
pub fn confusion_rates(tp: u64, fp: u64, tn: u64, fn_: u64) -> (f64, f64) {
let fp_rate = if fp + tn == 0 { 0.0 } else { fp as f64 / (fp + tn) as f64 };
let fn_rate = if fn_ + tp == 0 { 0.0 } else { fn_ as f64 / (fn_ + tp) as f64 };
(fp_rate, fn_rate)
}
/// Privacy-leakage score in [0, 1] via a membership-inference (MIA) proxy
/// (ADR-145 §2): how separable are confidence scores of training-set *members*
/// from *non-members*? Computed as `|AUC - 0.5| * 2` — 0.0 when the two score
/// distributions are indistinguishable (no leakage), 1.0 when perfectly
/// separable (an attacker can tell who was in the training set).
#[must_use]
pub fn membership_inference_leakage(member_scores: &[f64], nonmember_scores: &[f64]) -> f64 {
if member_scores.is_empty() || nonmember_scores.is_empty() {
return 0.0;
}
// AUC = P(member_score > nonmember_score) over all pairs (+ 0.5 for ties).
let mut wins = 0.0;
let total = (member_scores.len() * nonmember_scores.len()) as f64;
for &m in member_scores {
for &n in nonmember_scores {
if m > n {
wins += 1.0;
} else if (m - n).abs() < f64::EPSILON {
wins += 0.5;
}
}
}
let auc = wins / total;
((auc - 0.5).abs() * 2.0).clamp(0.0, 1.0)
}
/// The metric bundle for one ablation variant (ADR-145 §2).
#[derive(Debug, Clone)]
pub struct AblationMetrics {
/// Which feature combination was evaluated.
pub feature_set: FeatureSet,
/// Presence-detection accuracy in [0, 1].
pub presence_accuracy: f64,
/// Mean localisation error (m).
pub localization_err_m: f64,
/// False-positive rate.
pub fp_rate: f64,
/// False-negative rate.
pub fn_rate: f64,
/// Latency 50th percentile (ms).
pub latency_p50_ms: f64,
/// Latency 95th percentile (ms).
pub latency_p95_ms: f64,
/// Privacy leakage in [0, 1] (MIA proxy; lower is better).
pub privacy_leakage: f64,
/// Cross-room accuracy degradation (room_A_acc - room_B_acc), >= 0.
pub cross_room_degradation: f64,
}
/// Raw per-variant inputs from a pipeline run; metrics are derived
/// deterministically from these.
#[derive(Debug, Clone)]
pub struct VariantRun {
/// Feature combination evaluated.
pub feature_set: FeatureSet,
/// Confusion counts (tp, fp, tn, fn).
pub confusion: (u64, u64, u64, u64),
/// Mean localisation error (m).
pub localization_err_m: f64,
/// Per-frame latency samples (ms).
pub latency_samples_ms: Vec<f64>,
/// Member/non-member confidence scores for the MIA proxy.
pub member_scores: Vec<f64>,
/// Non-member confidence scores.
pub nonmember_scores: Vec<f64>,
/// Accuracy in the calibration room and a held-out room.
pub room_a_accuracy: f64,
/// Held-out room accuracy.
pub room_b_accuracy: f64,
}
impl AblationMetrics {
/// Derive the metric bundle from a raw variant run.
#[must_use]
pub fn from_run(run: &VariantRun) -> Self {
let (tp, fp, tn, fn_) = run.confusion;
let (fp_rate, fn_rate) = confusion_rates(tp, fp, tn, fn_);
let total = (tp + fp + tn + fn_).max(1);
let presence_accuracy = (tp + tn) as f64 / total as f64;
let (p50, p95) = latency_percentiles_ms(&run.latency_samples_ms);
Self {
feature_set: run.feature_set,
presence_accuracy,
localization_err_m: run.localization_err_m,
fp_rate,
fn_rate,
latency_p50_ms: p50,
latency_p95_ms: p95,
privacy_leakage: membership_inference_leakage(&run.member_scores, &run.nonmember_scores),
cross_room_degradation: (run.room_a_accuracy - run.room_b_accuracy).max(0.0),
}
}
}
/// An ablation report over the variant matrix (ADR-145 auto-report).
#[derive(Debug, Clone, Default)]
pub struct AblationReport {
/// Per-variant metrics in evaluation order.
pub rows: Vec<AblationMetrics>,
}
impl AblationReport {
/// Build from a set of variant runs.
#[must_use]
pub fn from_runs(runs: &[VariantRun]) -> Self {
Self { rows: runs.iter().map(AblationMetrics::from_run).collect() }
}
/// Look up a variant's metrics.
#[must_use]
pub fn get(&self, fs: FeatureSet) -> Option<&AblationMetrics> {
self.rows.iter().find(|m| m.feature_set == fs)
}
/// Acceptance check (ADR-145 / ADR-136 AC): does CSI+CIR beat CSI-only on at
/// least `min_wins` of {presence accuracy ↑, localisation error ↓, p95 latency ↓}?
#[must_use]
pub fn csi_cir_beats_csi_only(&self, min_wins: usize) -> bool {
let (Some(a), Some(b)) = (self.get(FeatureSet::CsiOnly), self.get(FeatureSet::CsiCir)) else {
return false;
};
let wins = [
b.presence_accuracy > a.presence_accuracy,
b.localization_err_m < a.localization_err_m,
b.latency_p95_ms <= a.latency_p95_ms,
]
.iter()
.filter(|w| **w)
.count();
wins >= min_wins
}
/// Deterministic markdown report (stable column/row order).
#[must_use]
pub fn to_markdown(&self) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"| variant | presence_acc | loc_err_m | fp | fn | p50_ms | p95_ms | privacy_leak | xroom_degr |"
);
let _ = writeln!(s, "|---|---|---|---|---|---|---|---|---|");
for m in &self.rows {
let _ = writeln!(
s,
"| {} | {:.3} | {:.3} | {:.3} | {:.3} | {:.2} | {:.2} | {:.3} | {:.3} |",
m.feature_set.label(),
m.presence_accuracy,
m.localization_err_m,
m.fp_rate,
m.fn_rate,
m.latency_p50_ms,
m.latency_p95_ms,
m.privacy_leakage,
m.cross_room_degradation,
);
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latency_percentiles_nearest_rank() {
let s: Vec<f64> = (1..=100).map(|i| i as f64).collect();
let (p50, p95) = latency_percentiles_ms(&s);
assert!((p50 - 50.0).abs() < 1e-9);
assert!((p95 - 95.0).abs() < 1e-9);
assert_eq!(latency_percentiles_ms(&[]), (0.0, 0.0));
}
#[test]
fn confusion_rates_basic() {
let (fp_rate, fn_rate) = confusion_rates(80, 10, 90, 20);
assert!((fp_rate - 0.1).abs() < 1e-9); // 10 / (10+90)
assert!((fn_rate - 0.2).abs() < 1e-9); // 20 / (20+80)
}
#[test]
fn mia_leakage_zero_when_indistinguishable_high_when_separable() {
// Identical distributions → ~no leakage.
let same = vec![0.5, 0.6, 0.7];
assert!(membership_inference_leakage(&same, &same) < 1e-9);
// Perfectly separable → leakage 1.0.
let members = vec![0.9, 0.95, 0.99];
let nonmembers = vec![0.1, 0.2, 0.3];
assert!((membership_inference_leakage(&members, &nonmembers) - 1.0).abs() < 1e-9);
}
#[test]
fn csi_cir_beats_csi_only_acceptance() {
let csi_only = VariantRun {
feature_set: FeatureSet::CsiOnly,
confusion: (70, 15, 70, 30), // acc 0.756
localization_err_m: 0.40,
latency_samples_ms: vec![10.0; 10],
member_scores: vec![0.5],
nonmember_scores: vec![0.5],
room_a_accuracy: 0.8,
room_b_accuracy: 0.6,
};
let csi_cir = VariantRun {
feature_set: FeatureSet::CsiCir,
confusion: (88, 6, 90, 12), // acc 0.908
localization_err_m: 0.22,
latency_samples_ms: vec![11.0; 10],
member_scores: vec![0.5],
nonmember_scores: vec![0.5],
room_a_accuracy: 0.85,
room_b_accuracy: 0.80,
};
let runs = [csi_only, csi_cir];
let report = AblationReport::from_runs(&runs);
// CSI+CIR wins on presence accuracy + localisation error (2 of 3).
assert!(report.csi_cir_beats_csi_only(2));
let md = report.to_markdown();
assert!(md.contains("csi_only") && md.contains("csi+cir"));
// Deterministic: same input → byte-identical report.
assert_eq!(md, AblationReport::from_runs(&runs).to_markdown());
}
#[test]
fn matrix_has_six_variants() {
assert_eq!(FeatureSet::MATRIX.len(), 6);
}
}
@@ -67,6 +67,9 @@ pub mod metrics;
pub mod model;
#[cfg(feature = "tch-backend")]
pub mod proof;
/// ADR-145 — ablation evaluation harness (feature matrix + privacy/latency metrics).
pub mod ablation;
#[cfg(feature = "tch-backend")]
pub mod trainer;
@@ -0,0 +1,19 @@
[package]
name = "wifi-densepose-worldgraph"
description = "ADR-139 — WorldGraph environmental digital twin (typed petgraph) for RuView"
version = "0.3.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
petgraph.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
@@ -0,0 +1,15 @@
//! WorldGraph error type.
use crate::model::WorldId;
/// Errors from WorldGraph operations.
#[derive(Debug, thiserror::Error)]
pub enum WorldGraphError {
/// An edge endpoint referenced an unknown node.
#[error("unknown node {0:?}")]
UnknownNode(WorldId),
/// (De)serialisation of the persisted graph failed.
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
}
@@ -0,0 +1,475 @@
//! ADR-139 §2.22.5 — graph container, provenance, privacy rollup, queries.
use std::collections::HashMap;
use petgraph::stable_graph::{NodeIndex, StableDiGraph};
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
use petgraph::Direction;
use serde::{Deserialize, Serialize};
use wifi_densepose_geo::types::GeoRegistration;
use crate::error::WorldGraphError;
use crate::model::{SemanticProvenance, WorldEdge, WorldId, WorldNode};
/// Current persisted schema version (ADR-136 §2.1 reserved-flag pattern).
pub const SCHEMA_VERSION: u16 = 1;
/// The typed environmental digital twin (ADR-139). Wraps a petgraph
/// `StableDiGraph` and exposes a domain API; stable `WorldId → NodeIndex`
/// mapping survives node removal.
#[derive(Debug)]
pub struct WorldGraph {
inner: StableDiGraph<WorldNode, WorldEdge>,
index: HashMap<WorldId, NodeIndex>,
registration: GeoRegistration,
next_id: u64,
schema_version: u16,
}
/// Serializable snapshot of a [`WorldGraph`] for RVF/JSON persistence.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorldGraphSnapshot {
schema_version: u16,
registration: GeoRegistration,
next_id: u64,
nodes: Vec<WorldNode>,
/// Edges as (from_id, to_id, edge).
edges: Vec<(WorldId, WorldId, WorldEdge)>,
}
/// Result of a privacy-impact rollup (ADR-139 §2.4).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PrivacyRollup {
/// Active mode name.
pub mode: String,
/// Nodes that become unobservable under this mode.
pub suppressed_nodes: Vec<WorldId>,
/// (sensor, node) pairs newly denied.
pub denied_pairs: Vec<(WorldId, WorldId)>,
/// Count of still-allowed (sensor, node) pairs.
pub allowed_pairs: usize,
}
impl WorldGraph {
/// Create an empty graph registered to an installation origin.
#[must_use]
pub fn new(registration: GeoRegistration) -> Self {
Self {
inner: StableDiGraph::new(),
index: HashMap::new(),
registration,
next_id: 1,
schema_version: SCHEMA_VERSION,
}
}
/// Installation geo-registration (ADR-044).
#[must_use]
pub fn registration(&self) -> &GeoRegistration {
&self.registration
}
/// Number of live nodes.
#[must_use]
pub fn node_count(&self) -> usize {
self.inner.node_count()
}
/// Insert or replace a node, returning its stable `WorldId`. If the node's
/// embedded id is `UNASSIGNED`, a fresh id is allocated; if it names an
/// existing id, that node's weight is replaced in place (upsert).
pub fn upsert_node(&mut self, mut node: WorldNode) -> WorldId {
let id = if node.id().is_unassigned() {
let fresh = WorldId(self.next_id);
self.next_id += 1;
node.set_id(fresh);
fresh
} else {
self.next_id = self.next_id.max(node.id().0 + 1);
node.id()
};
if let Some(&idx) = self.index.get(&id) {
self.inner[idx] = node;
} else {
let idx = self.inner.add_node(node);
self.index.insert(id, idx);
}
id
}
/// Add a typed edge between two known nodes.
///
/// # Errors
/// [`WorldGraphError::UnknownNode`] if either endpoint is unknown.
pub fn add_edge(
&mut self,
from: WorldId,
to: WorldId,
edge: WorldEdge,
) -> Result<(), WorldGraphError> {
let f = *self.index.get(&from).ok_or(WorldGraphError::UnknownNode(from))?;
let t = *self.index.get(&to).ok_or(WorldGraphError::UnknownNode(to))?;
self.inner.add_edge(f, t, edge);
Ok(())
}
/// Borrow a node by id.
#[must_use]
pub fn node(&self, id: WorldId) -> Option<&WorldNode> {
self.index.get(&id).map(|&idx| &self.inner[idx])
}
/// Remove a node and its incident edges (e.g. a person leaves).
pub fn remove_node(&mut self, id: WorldId) -> Option<WorldNode> {
let idx = self.index.remove(&id)?;
self.inner.remove_node(idx)
}
/// Outgoing neighbours of a node with the connecting edge.
pub fn neighbors(&self, id: WorldId) -> Vec<(WorldId, WorldEdge)> {
let Some(&idx) = self.index.get(&id) else {
return Vec::new();
};
self.inner
.edges_directed(idx, Direction::Outgoing)
.map(|e| (self.inner[e.target()].id(), e.weight().clone()))
.collect()
}
/// Resolve a HomeCore `area_id` to its Room node (entity linkage, ADR-127).
#[must_use]
pub fn room_for_area(&self, area_id: &str) -> Option<WorldId> {
self.inner.node_weights().find_map(|n| match n {
WorldNode::Room { id, area_id: Some(a), .. } if a == area_id => Some(*id),
_ => None,
})
}
// ---- ADR-139 §2.5 query API (v1) ----
/// Observability chain: which nodes a sensor currently `observes`.
#[must_use]
pub fn observed_by(&self, sensor: WorldId) -> Vec<WorldId> {
self.neighbors(sensor)
.into_iter()
.filter(|(_, e)| matches!(e, WorldEdge::Observes { .. }))
.map(|(id, _)| id)
.collect()
}
/// Location query: contents of a room/zone (incoming `located_in` edges).
#[must_use]
pub fn contents_of(&self, container: WorldId) -> Vec<WorldId> {
let Some(&idx) = self.index.get(&container) else {
return Vec::new();
};
self.inner
.edges_directed(idx, Direction::Incoming)
.filter(|e| matches!(e.weight(), WorldEdge::LocatedIn { .. }))
.map(|e| self.inner[e.source()].id())
.collect()
}
/// Append-with-provenance: insert a `SemanticState` and wire `DerivedFrom`
/// edges to its evidence sources (ADR-139 §2.3). Sources unknown to the
/// graph are skipped (evidence may be raw frames not modelled as nodes).
pub fn add_semantic_state(
&mut self,
statement: String,
confidence: f32,
valid_from_unix_ms: i64,
provenance: SemanticProvenance,
evidence_sources: &[WorldId],
) -> WorldId {
let evidence_handles = provenance.evidence.clone();
let id = self.upsert_node(WorldNode::SemanticState {
id: WorldId::UNASSIGNED,
statement,
confidence,
provenance,
valid_from_unix_ms,
});
for (src, handle) in evidence_sources.iter().zip(
evidence_handles
.iter()
.cloned()
.chain(std::iter::repeat(String::new())),
) {
let _ = self.add_edge(id, *src, WorldEdge::DerivedFrom { evidence: handle });
}
id
}
/// Record a contradiction between two still-live beliefs (ADR-139 §2.3).
/// Neither node is deleted — the disagreement stays queryable.
///
/// # Errors
/// [`WorldGraphError::UnknownNode`] if either node is unknown.
pub fn add_contradiction(
&mut self,
a: WorldId,
b: WorldId,
magnitude: f32,
flag: String,
) -> Result<(), WorldGraphError> {
self.add_edge(a, b, WorldEdge::Contradicts { magnitude, flag })
}
/// Recompute `PrivacyLimitedBy` edges for the active mode (ADR-139 §2.4).
///
/// `policy(modality_kind, node_kind) -> allowed` decides, for each existing
/// `Observes` edge, whether the sensor may still observe the target under
/// `mode`. A matching `PrivacyLimitedBy` edge is appended recording the
/// decision; denied pairs are rolled up.
pub fn apply_privacy_mode<F>(&mut self, mode: &str, action: &str, policy: F) -> PrivacyRollup
where
F: Fn(&str, &str) -> bool,
{
// Collect (sensor, target, allowed) from current Observes edges.
let mut decisions: Vec<(WorldId, WorldId, bool)> = Vec::new();
for e in self.inner.edge_references() {
if matches!(e.weight(), WorldEdge::Observes { .. }) {
let sensor = &self.inner[e.source()];
let target = &self.inner[e.target()];
let allowed = policy(sensor.kind(), target.kind());
decisions.push((sensor.id(), target.id(), allowed));
}
}
let mut denied_pairs = Vec::new();
let mut suppressed = Vec::new();
let mut allowed_pairs = 0usize;
for (sensor, target, allowed) in &decisions {
let _ = self.add_edge(
*sensor,
*target,
WorldEdge::PrivacyLimitedBy {
mode: mode.to_string(),
action: action.to_string(),
allowed: *allowed,
},
);
if *allowed {
allowed_pairs += 1;
} else {
denied_pairs.push((*sensor, *target));
if !suppressed.contains(target) {
suppressed.push(*target);
}
}
}
PrivacyRollup {
mode: mode.to_string(),
suppressed_nodes: suppressed,
denied_pairs,
allowed_pairs,
}
}
// ---- Persistence (RVF/JSON) ----
/// Snapshot the graph for persistence.
#[must_use]
pub fn snapshot(&self) -> WorldGraphSnapshot {
let nodes: Vec<WorldNode> = self.inner.node_weights().cloned().collect();
let edges: Vec<(WorldId, WorldId, WorldEdge)> = self
.inner
.edge_references()
.map(|e| {
(
self.inner[e.source()].id(),
self.inner[e.target()].id(),
e.weight().clone(),
)
})
.collect();
WorldGraphSnapshot {
schema_version: self.schema_version,
registration: self.registration.clone(),
next_id: self.next_id,
nodes,
edges,
}
}
/// Serialize to deterministic JSON bytes (RVF payload).
///
/// # Errors
/// [`WorldGraphError::Serde`] on serialisation failure.
pub fn to_json(&self) -> Result<Vec<u8>, WorldGraphError> {
Ok(serde_json::to_vec(&self.snapshot())?)
}
/// Reconstruct a graph from a snapshot's JSON bytes.
///
/// # Errors
/// [`WorldGraphError::Serde`] on parse failure.
pub fn from_json(bytes: &[u8]) -> Result<Self, WorldGraphError> {
let snap: WorldGraphSnapshot = serde_json::from_slice(bytes)?;
let mut g = Self::new(snap.registration);
g.schema_version = snap.schema_version;
for node in snap.nodes {
g.upsert_node(node);
}
for (from, to, edge) in snap.edges {
g.add_edge(from, to, edge)?;
}
g.next_id = snap.next_id;
Ok(g)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{EnuPoint, SensorModality, WorldEdge, ZoneBoundsEnu};
fn enu(e: f64, n: f64) -> EnuPoint {
EnuPoint { east_m: e, north_m: n, up_m: 0.0 }
}
fn living_room() -> WorldNode {
WorldNode::Room {
id: WorldId::UNASSIGNED,
area_id: Some("living_room".into()),
name: "Living Room".into(),
bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 },
floor: 0,
}
}
#[test]
fn upsert_allocates_and_replaces() {
let mut g = WorldGraph::new(GeoRegistration::default());
let id = g.upsert_node(living_room());
assert!(!id.is_unassigned());
assert_eq!(g.node_count(), 1);
// Upsert same id with new name → replace in place, count unchanged.
g.upsert_node(WorldNode::Room {
id,
area_id: Some("living_room".into()),
name: "Lounge".into(),
bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 },
floor: 0,
});
assert_eq!(g.node_count(), 1);
assert!(matches!(g.node(id), Some(WorldNode::Room { name, .. }) if name == "Lounge"));
}
#[test]
fn area_linkage_and_observability() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let sensor = g.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: "esp32-com9".into(),
position: enu(1.0, 1.0),
modality: SensorModality::WifiCsi,
});
g.add_edge(sensor, room, WorldEdge::Observes { quality: 0.9, last_seen_unix_ms: 1 })
.unwrap();
assert_eq!(g.room_for_area("living_room"), Some(room));
assert_eq!(g.observed_by(sensor), vec![room]);
}
#[test]
fn add_edge_unknown_endpoint_errors() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let err = g.add_edge(room, WorldId(999), WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 });
assert!(matches!(err, Err(WorldGraphError::UnknownNode(WorldId(999)))));
}
#[test]
fn location_query_contents_of() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let person = g.upsert_node(WorldNode::PersonTrack {
id: WorldId::UNASSIGNED,
track_id: 7,
last_position: enu(2.0, 2.0),
reid_embedding_ref: None,
});
g.add_edge(person, room, WorldEdge::LocatedIn { since_unix_ms: 100 }).unwrap();
assert_eq!(g.contents_of(room), vec![person]);
}
#[test]
fn semantic_state_provenance_and_contradiction() {
let mut g = WorldGraph::new(GeoRegistration::default());
let event = g.upsert_node(WorldNode::Event {
id: WorldId::UNASSIGNED,
event_type: "motion".into(),
at_unix_ms: 10,
located_in: None,
});
let prov = SemanticProvenance {
evidence: vec!["ev:abc".into()],
model_version: "rfenc-1.0".into(),
calibration_version: "cal:uuid".into(),
privacy_decision: "PrivateHome/Allow".into(),
};
let s1 = g.add_semantic_state("present".into(), 0.9, 11, prov.clone(), &[event]);
// DerivedFrom edge to the evidence event exists.
assert!(g.neighbors(s1).iter().any(|(to, e)| *to == event
&& matches!(e, WorldEdge::DerivedFrom { .. })));
let s2 = g.add_semantic_state("absent".into(), 0.6, 12, prov, &[event]);
g.add_contradiction(s1, s2, 0.3, "flag:ts".into()).unwrap();
// Both beliefs retained; contradiction queryable.
assert!(g.node(s1).is_some() && g.node(s2).is_some());
assert!(g.neighbors(s1).iter().any(|(_, e)| matches!(e, WorldEdge::Contradicts { .. })));
}
#[test]
fn privacy_rollup_suppresses_person_tracks() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let person = g.upsert_node(WorldNode::PersonTrack {
id: WorldId::UNASSIGNED,
track_id: 1,
last_position: enu(1.0, 1.0),
reid_embedding_ref: None,
});
let sensor = g.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: "s".into(),
position: enu(0.0, 0.0),
modality: SensorModality::WifiCsi,
});
g.add_edge(sensor, room, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }).unwrap();
g.add_edge(sensor, person, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }).unwrap();
// StrictNoIdentity: rooms observable, person_tracks suppressed.
let rollup = g.apply_privacy_mode("StrictNoIdentity", "SuppressIdentity", |_modality, node_kind| {
node_kind != "person_track"
});
assert_eq!(rollup.allowed_pairs, 1);
assert_eq!(rollup.denied_pairs, vec![(sensor, person)]);
assert_eq!(rollup.suppressed_nodes, vec![person]);
}
#[test]
fn json_roundtrip_preserves_nodes_and_edges() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let sensor = g.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: "s".into(),
position: enu(0.0, 0.0),
modality: SensorModality::WifiCsi,
});
g.add_edge(sensor, room, WorldEdge::Observes { quality: 0.8, last_seen_unix_ms: 5 }).unwrap();
let bytes = g.to_json().unwrap();
let g2 = WorldGraph::from_json(&bytes).unwrap();
assert_eq!(g2.node_count(), 2);
assert_eq!(g2.room_for_area("living_room"), Some(room));
assert_eq!(g2.observed_by(sensor), vec![room]);
// Deterministic: re-serialising the reconstructed graph matches.
assert_eq!(g2.to_json().unwrap(), bytes);
}
}
@@ -0,0 +1,30 @@
//! # WiFi-DensePose WorldGraph (ADR-139)
//!
//! The environmental digital twin for the RuView streaming engine: a typed
//! [`petgraph`] `StableDiGraph` of rooms, zones, walls, doorways, sensors, RF
//! links, person tracks, object anchors, events, and semantic-state beliefs,
//! connected by typed relations (observes / located_in / adjacent_to /
//! supports / contradicts / derived_from / privacy_limited_by).
//!
//! It sits downstream of fusion (ADR-137) — storing fused *beliefs*, not raw
//! frames — and upstream of the semantic/agent layer (ADR-140) and evaluation
//! harness (ADR-145). Every [`model::WorldNode::SemanticState`] carries
//! mandatory [`model::SemanticProvenance`] (signal evidence + model +
//! calibration + privacy decision), honouring the house rule structurally.
//!
//! Persistence is via [`graph::WorldGraph::to_json`] /
//! [`graph::WorldGraph::from_json`] (the RVF payload); the serde-enum node/edge
//! model guarantees a deterministic, schema-versioned wire layout.
#![forbid(unsafe_code)]
pub mod error;
pub mod graph;
pub mod model;
pub use error::WorldGraphError;
pub use graph::{PrivacyRollup, WorldGraph, WorldGraphSnapshot, SCHEMA_VERSION};
pub use model::{
AnchorKind, EnuPoint, SemanticProvenance, SensorModality, WorldEdge, WorldId, WorldNode,
ZoneBoundsEnu,
};
@@ -0,0 +1,385 @@
//! ADR-139 §2.1 — typed node/edge model.
//!
//! Nodes and edges are `serde` enums (NOT boxed trait objects) for
//! deterministic, schema-versioned, RVF-friendly persistence. Cross-ADR
//! references (ADR-137 evidence, ADR-141 privacy decision) are carried as
//! opaque content-address `String` handles so the WorldGraph compiles and
//! persists independently of those crates (§2.1, §2.3).
use serde::{Deserialize, Serialize};
/// Stable, monotonic identity for a world entity. Distinct from petgraph's
/// `NodeIndex` (graph-internal handle); `WorldId` survives RVF round-trips and
/// node removal. `WorldId(0)` is the "assign me one" sentinel for `upsert_node`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorldId(pub u64);
impl WorldId {
/// The "allocate a fresh id" sentinel.
pub const UNASSIGNED: WorldId = WorldId(0);
/// Whether this id is the unassigned sentinel.
#[must_use]
pub fn is_unassigned(&self) -> bool {
self.0 == 0
}
}
/// Local ENU coordinate in metres relative to the installation origin (ADR-044).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EnuPoint {
/// East offset (m).
pub east_m: f64,
/// North offset (m).
pub north_m: f64,
/// Up offset (m).
pub up_m: f64,
}
/// MAT `ZoneBounds` reprojected into the installation ENU frame.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "shape", rename_all = "snake_case")]
pub enum ZoneBoundsEnu {
/// Axis-aligned rectangle.
Rectangle {
/// Minimum east (m).
min_e: f64,
/// Minimum north (m).
min_n: f64,
/// Maximum east (m).
max_e: f64,
/// Maximum north (m).
max_n: f64,
},
/// Circle.
Circle {
/// Centre east (m).
center_e: f64,
/// Centre north (m).
center_n: f64,
/// Radius (m).
radius_m: f64,
},
/// Polygon (east, north) vertices.
Polygon {
/// (east, north) vertices.
vertices: Vec<(f64, f64)>,
},
}
impl ZoneBoundsEnu {
/// Whether an ENU point lies within these bounds (up ignored).
#[must_use]
pub fn contains(&self, p: &EnuPoint) -> bool {
match self {
Self::Rectangle { min_e, min_n, max_e, max_n } => {
p.east_m >= *min_e && p.east_m <= *max_e && p.north_m >= *min_n && p.north_m <= *max_n
}
Self::Circle { center_e, center_n, radius_m } => {
let de = p.east_m - center_e;
let dn = p.north_m - center_n;
(de * de + dn * dn).sqrt() <= *radius_m
}
Self::Polygon { vertices } => point_in_polygon(p.east_m, p.north_m, vertices),
}
}
}
fn point_in_polygon(px: f64, py: f64, verts: &[(f64, f64)]) -> bool {
if verts.len() < 3 {
return false;
}
// Ray-casting parity test.
let mut inside = false;
let mut j = verts.len() - 1;
for i in 0..verts.len() {
let (xi, yi) = verts[i];
let (xj, yj) = verts[j];
let intersect = ((yi > py) != (yj > py))
&& (px < (xj - xi) * (py - yi) / (yj - yi) + xi);
if intersect {
inside = !inside;
}
j = i;
}
inside
}
/// Sensing modality of a physical device placement.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorModality {
/// WiFi CSI sensing node (ESP32-S3/C6).
WifiCsi,
/// 60 GHz mmWave FMCW radar.
MmWave,
/// Ultra-wideband ranging beacon (ADR-144).
Uwb,
/// Coarse presence sensor.
Presence,
}
/// Kind of persistent static anchor.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorKind {
/// A persistent RF reflector (ADR-143 RF SLAM).
Reflector,
/// A piece of furniture inferred from reflector clustering.
Furniture,
/// A surveyed UWB beacon (ADR-144).
UwbBeacon,
}
/// Mandatory provenance for every [`WorldNode::SemanticState`] (house rule):
/// every semantic belief traces to signal evidence + model + calibration +
/// privacy decision.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SemanticProvenance {
/// ADR-137 `EvidenceRef` content-address handle(s).
pub evidence: Vec<String>,
/// Model version (ADR-136 `model_id`/`model_version`) that produced this.
pub model_version: String,
/// Calibration version (ADR-135 baseline id) in effect.
pub calibration_version: String,
/// Privacy decision (ADR-141 mode + action) it was derived under.
pub privacy_decision: String,
}
/// A typed world node (ADR-139 §2.1). Persistence-deterministic serde enum.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorldNode {
/// A bounded interior space, linked to a HomeCore `area_id` (ADR-127).
Room {
/// Stable id (or `UNASSIGNED` to allocate).
id: WorldId,
/// HomeCore registry area_id — the entity-linkage join key.
area_id: Option<String>,
/// Human name.
name: String,
/// Room footprint in local ENU.
bounds_enu: ZoneBoundsEnu,
/// Floor index.
floor: i16,
},
/// A sub-region of a room targeted for sensing (MAT ScanZone analogue).
Zone {
/// Stable id.
id: WorldId,
/// Containing room.
parent_room: WorldId,
/// Human name.
name: String,
/// Zone footprint.
bounds_enu: ZoneBoundsEnu,
},
/// A wall segment (coarse 2D topological element in ENU).
Wall {
/// Stable id.
id: WorldId,
/// Segment start.
a: EnuPoint,
/// Segment end.
b: EnuPoint,
/// Coarse RF attenuation (dB): drywall ≈ 3, brick ≈ 12.
rf_attenuation_db: f32,
},
/// A passable opening between two rooms.
Doorway {
/// Stable id.
id: WorldId,
/// Centre point.
center: EnuPoint,
/// Opening width (m).
width_m: f32,
},
/// A physical sensing device placement (ADR-113 placement target).
Sensor {
/// Stable id.
id: WorldId,
/// Matches HomeCore `EntityEntry.device_id`.
device_id: String,
/// Placement in local ENU.
position: EnuPoint,
/// Sensing modality.
modality: SensorModality,
},
/// A directed RF propagation channel between two sensors (ADR-138 LinkGroup member).
RfLink {
/// Stable id.
id: WorldId,
/// Transmit sensor node.
tx: WorldId,
/// Receive sensor node.
rx: WorldId,
/// ADR-138 MLO LinkGroup id.
link_group_id: Option<String>,
/// Centre frequency (MHz).
center_freq_mhz: u32,
},
/// A tracked person (Kalman track id from ruvsense `pose_tracker`).
PersonTrack {
/// Stable id.
id: WorldId,
/// Tracker track id.
track_id: u64,
/// Last known ENU position.
last_position: EnuPoint,
/// AETHER re-ID embedding handle.
reid_embedding_ref: Option<String>,
},
/// A persistent static reflector / object (ADR-143 / ADR-144 anchor).
ObjectAnchor {
/// Stable id.
id: WorldId,
/// ENU position.
position: EnuPoint,
/// Anchor classification.
anchor_kind: AnchorKind,
/// Confidence in [0, 1].
confidence: f32,
},
/// A discrete detected event (fall, entry, gesture) at a point in time.
Event {
/// Stable id.
id: WorldId,
/// Event type tag.
event_type: String,
/// Wall-clock time (Unix ms).
at_unix_ms: i64,
/// Containing room/zone.
located_in: Option<WorldId>,
},
/// A fused semantic belief about the world (the ADR-140 record's graph anchor).
SemanticState {
/// Stable id.
id: WorldId,
/// Human-readable belief statement.
statement: String,
/// Confidence in [0, 1].
confidence: f32,
/// Mandatory provenance (house rule).
provenance: SemanticProvenance,
/// Belief validity start (Unix ms).
valid_from_unix_ms: i64,
},
}
impl WorldNode {
/// The embedded stable id of this node.
#[must_use]
pub fn id(&self) -> WorldId {
match self {
Self::Room { id, .. }
| Self::Zone { id, .. }
| Self::Wall { id, .. }
| Self::Doorway { id, .. }
| Self::Sensor { id, .. }
| Self::RfLink { id, .. }
| Self::PersonTrack { id, .. }
| Self::ObjectAnchor { id, .. }
| Self::Event { id, .. }
| Self::SemanticState { id, .. } => *id,
}
}
/// Overwrite the embedded id (used by `upsert_node` when allocating one).
pub(crate) fn set_id(&mut self, new: WorldId) {
match self {
Self::Room { id, .. }
| Self::Zone { id, .. }
| Self::Wall { id, .. }
| Self::Doorway { id, .. }
| Self::Sensor { id, .. }
| Self::RfLink { id, .. }
| Self::PersonTrack { id, .. }
| Self::ObjectAnchor { id, .. }
| Self::Event { id, .. }
| Self::SemanticState { id, .. } => *id = new,
}
}
/// Static kind tag for diagnostics/queries.
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Room { .. } => "room",
Self::Zone { .. } => "zone",
Self::Wall { .. } => "wall",
Self::Doorway { .. } => "doorway",
Self::Sensor { .. } => "sensor",
Self::RfLink { .. } => "rf_link",
Self::PersonTrack { .. } => "person_track",
Self::ObjectAnchor { .. } => "object_anchor",
Self::Event { .. } => "event",
Self::SemanticState { .. } => "semantic_state",
}
}
}
/// A typed edge between two [`WorldNode`]s (ADR-139 §2.1). Stored as the
/// petgraph edge weight; metadata is structurally per-relation.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "rel", rename_all = "snake_case")]
pub enum WorldEdge {
/// sensor/rf_link → observable node. Weight is field-of-regard quality.
Observes {
/// Field-of-regard quality in [0, 1].
quality: f32,
/// Last observation time (Unix ms).
last_seen_unix_ms: i64,
},
/// person/object/event → room/zone containment.
LocatedIn {
/// Containment start (Unix ms).
since_unix_ms: i64,
},
/// room ↔ room through a doorway (undirected pair stored as two edges).
AdjacentTo {
/// The connecting doorway node.
via_doorway: WorldId,
},
/// sensor/rf_link → sensor/rf_link physical/clock support (ADR-138).
Supports {
/// Support strength in [0, 1].
strength: f32,
},
/// evidence/state → evidence/state: sources disagree (ADR-137).
Contradicts {
/// Disagreement magnitude.
magnitude: f32,
/// ADR-137 contradiction-flag content-address handle.
flag: String,
},
/// semantic_state → prior state/evidence provenance chain (ADR-137).
DerivedFrom {
/// ADR-137 evidence content-address handle.
evidence: String,
},
/// sensor → node: observation constrained by a privacy mode (ADR-141).
PrivacyLimitedBy {
/// Limiting privacy mode name.
mode: String,
/// Action evaluated.
action: String,
/// Whether observation is allowed under the current mode.
allowed: bool,
},
}
impl WorldEdge {
/// Static relation tag.
#[must_use]
pub fn rel(&self) -> &'static str {
match self {
Self::Observes { .. } => "observes",
Self::LocatedIn { .. } => "located_in",
Self::AdjacentTo { .. } => "adjacent_to",
Self::Supports { .. } => "supports",
Self::Contradicts { .. } => "contradicts",
Self::DerivedFrom { .. } => "derived_from",
Self::PrivacyLimitedBy { .. } => "privacy_limited_by",
}
}
}