Merge upstream/main, resolve README.md table-row conflict

This commit is contained in:
ruv 2026-09-09 08:40:14 -04:00
commit 26e8ca3f39
43 changed files with 6782 additions and 351 deletions

View file

@ -98,6 +98,8 @@ jobs:
run: |
make test_adr110
./test_adr110
make test_c6_antenna
./test_c6_antenna
- name: Verify binary size budget
working-directory: firmware/esp32-csi-node
@ -184,3 +186,22 @@ jobs:
name: esp32-csi-node-firmware-${{ matrix.variant }}
path: firmware/esp32-csi-node/release-staging/
retention-days: 90
build-c6-idf6:
name: Build XIAO C6 external antenna (ESP-IDF 6.0.2)
runs-on: ubuntu-latest
container:
image: espressif/idf:v6.0.2
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Build the IDF 6 canary image
working-directory: firmware/esp32-csi-node
run: |
. $IDF_PATH/export.sh
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" set-target esp32c6
idf.py build
test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024))
strings build/esp32-csi-node.bin | grep -F "external U.FL"

15
.gitignore vendored
View file

@ -17,11 +17,16 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak
firmware/esp32-hello-world/sdkconfig.old
# Host-built firmware test binaries (compiled from test/*.c, not source)
firmware/esp32-csi-node/test/test_adr110
firmware/esp32-csi-node/test/test_vitals
firmware/esp32-csi-node/test/fuzz_serialize
firmware/esp32-csi-node/test/fuzz_edge
firmware/esp32-csi-node/test/fuzz_nvs
# Compiled host-test and fuzz binaries. The Makefile writes them beside their
# sources, so they land in a tracked directory. Ignore them by SHAPE rather
# than by name: every source here carries an extension and no binary does.
#
# This replaced a hand-maintained list of five targets, which had gone stale --
# test_csi_sanitize, test_mmwave_detect and test_thermal were all built by
# `make host_tests`, ignored by nothing, and got committed by accident.
firmware/esp32-csi-node/test/test_*
firmware/esp32-csi-node/test/fuzz_*
!firmware/esp32-csi-node/test/*.*
firmware/esp32-csi-node/test/*.exe
firmware/esp32-csi-node/test/*.obj

View file

@ -720,6 +720,7 @@ Start with the user, build, and calibration guides; expand for the full referenc
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [CSI Frame Selection — Measured](docs/csi-frame-selection-measured.md) | Which rate gate actually buys cross-node pairing, measured on a nine-node ESP32-C6 fleet as a 2x2 factorial in three Latin-square blocks. Selecting frames by 802.11 `rx_seq` roughly doubled pairing (+31.7 pp) while mesh-time alignment bought +0.5 pp, inside noise. Includes the trade it makes, a prediction that turned out wrong, the limits of the measurement, and `run_arms.py` so the comparison can be re-run rather than taken on trust. |
| [ESP32-C6 Firmware Runbook](firmware/esp32-csi-node/RUNBOOK.md) | What to run, in what order, and what to check: the three-file build that keeps OTA rollback compiled in, a five-key pre-flash gate, which flash regions OTA can and cannot reach, a single-board diagnostic flash with a revert path, and the tunables whose right value depends on your house rather than on the chip. |
| [Architecture Decisions](docs/adr/README.md) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |

View file

@ -0,0 +1,193 @@
# ADR-345: Per-link CSI attribution and node-to-node ranging
- **Status**: Proposed — measurement layer implemented and validated on hardware; localization not built
- **Date**: 2026-08-28
- **Deciders**: Joe
- **Tags**: csi, wire-format, esp32-c6, localization, adr-018, adr-060, adr-110
## Context
Every attempt at device-free position estimation in this repository has failed
against the same wall, and the wall has now been measured twice from different
directions.
**Phase is unusable.** The bistatic tier needed a signed per-link radial
velocity, which requires the common-mode component of CSI phase. Measured
2026-08-28 across all three ESP32-C6 nodes over ~40,000 frames, the
frame-to-frame change in common-mode phase is statistically indistinguishable
from uniform random — resultant length `R` = 0.011–0.032 against a 0.000
reference, standard deviation 1.78–1.81 against `pi/sqrt(3)` = 1.814, and no
lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is
packet-detection timing quantization: common-mode phase is `-2*pi*f_c*tau`, and
one ADC sample of jitter at 20 MHz is `2*pi * 2.412e9 * 50e-9` ~= 758 rad, about
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
is default-off as a result (`bistatic_tier_enabled`).
**Amplitude works but had no parallax.** The three AP -> node links leave a
single distant transmitter within a 36.5 degree fan and converge on the node
cluster. Bearings computed from `room_config.json`: 198.5, 221.2, 235.0 degrees.
Scalar per-node energy weighting was closed with a clean negative result on
2026-08-27 — magnitude responds to proximity, direction does not.
**The receivers were hearing far more than we recorded.** A node in promiscuous
MGMT+DATA mode with no `filter_mac` produces CSI for every transmitter on the
channel. Measured on node 2: the AP accounted for only **34%** of frames; the
two peer nodes supplied 39% and 27%. Those peer frames are valid measurements
of *node-to-node* channels — but ADR-018's wire format carries no transmitter
address, so the sink could not attribute them. They interleaved into one
`frame_history` with mixed geometry, and the only available remedy
(`filter_mac`, ADR-060) fixes the mixing by discarding ~75% of the data.
The node-to-node links between the same three boards cross the room from three
sides at **108.7 degrees** of angular spread (bearings 0.0, 73.2, 108.7) versus
36.5 for the AP links — roughly 3x the parallax, with both endpoints known,
fixed, and under our control rather than wherever the router happens to live.
Link count also grows as `N*(N-1)/2`: 3 nodes give 3 links, 6 give 15, 13 give
78.
## Decision
Carry the transmitter MAC on the CSI wire and model CSI per **link**
(receiver, transmitter) rather than per **node**, as a layer alongside the
existing per-node model.
### 1. Wire format v2
`CSI_MAGIC_V2 = 0xC5110008`, header 26 bytes: the v1 header unchanged through
byte 19, then the transmitter address (`info->mac`, 802.11 addr2) at bytes
20..25, with I/Q from byte 26.
`0xC5110002`..`0xC5110007` were already claimed by the vitals, feature and other
edge packets, so v2 takes the next free value rather than an adjacent one — see
`issue_928_magic_collision_tests` for why that matters. The parser accepts both
magics, so a mixed fleet keeps working and v1 nodes simply report no
transmitter.
### 2. Per-link state (`links.rs`)
`LinkTable` keyed by `LinkId { rx_node, tx_mac }`, each link holding its own
amplitude history, RSSI EMA, learned resting baseline and motion metric.
The metric is the mean over subcarriers of the temporal standard deviation of
**AGC-normalized** amplitude. Removing each frame's own mean across subcarriers
is essential rather than cosmetic: the ESP32 applies automatic gain control per
packet, so absolute amplitude jumps between packets for reasons unrelated to the
channel, and any statistic built on the raw level measures the radio's gain
control instead of the room.
Amplitude only, deliberately — nothing here can depend on phase coherence.
### 3. Beacon rate
`BEACON_PERIOD_MS` 100 -> 20. At 10 Hz a peer samples a node-to-node link with a
5 Hz Nyquist while motion Doppler lives at 1–20 Hz; it aliases before it starts.
20 ms matches both the receiver's own `CSI_MIN_PROCESS_INTERVAL_US` ceiling and
the gateway self-ping already used for the AP link.
### 4. `GET /api/v1/links` and a live UI panel
The link table was initially reachable only through a 30-second log line, which
is the wrong medium for a quantity whose value is watching it respond while you
walk across the room. `GET /api/v1/links` returns one row per link — `motion`,
`raw_motion`, `rssi_dbm`, `frames`, `kind` — and `LinkMeshPanel` renders them in
the Sensing tab at 1 Hz. `raw_motion` is shown beside `motion` deliberately: a
link whose baseline has not settled reports a healthy raw value and a near-zero
motion, and with only the one number that is indistinguishable from a dead link.
Node identity is **inferred, not measured**. Nothing in the system records a
node's own MAC — a node reports the addresses it hears, never its own. But a
radio does not receive its own transmissions, so given the set of receivers
currently reporting, a transmitter heard by all of them except exactly one is
that one receiver's own board; a transmitter heard by every receiver is external
infrastructure. `links::infer_transmitting_node` declines (returns `None`) with
fewer than three receivers, where "heard by all but one" and "heard by exactly
one" are the same observation, and whenever more than one receiver is missing,
which a peer out of range produces indistinguishably. The field is named
`tx_node_inferred` and the UI draws the peer id with a trailing `?`.
Checked against the known MAC map on 2026-08-28 the inference labelled all four
transmitters correctly — nodes 0, 1 and 2 by address, and the AP as external —
with no access to that map. A wrong answer here mislabels a display row; it
never reaches a metric.
## Consequences
### Validated on hardware, 2026-08-28
All three nodes on wire v2 at 20 ms produced the full mesh: **9 links** (3 AP +
6 directional node-to-node).
Reciprocity — each physical channel measured independently from both ends:
| channel | direction A | direction B | ratio |
|---|---|---|---|
| 0 <-> 1 | 2.229 | 1.930 | 1.15 |
| 0 <-> 2 | 1.353 | 1.000 | 1.35 |
| 1 <-> 2 | 1.398 | 1.486 | 1.06 |
Nothing in the implementation enforces this. Three independent radio pairs
agreeing within 6–35% is the strongest available evidence that the per-link
separation is real rather than an artifact of frame bucketing.
Link quality is also far more uniform than the AP links: node-to-node raw motion
spans 1.0–2.2 across all six, versus 3.8–17.8 for the three AP links (which
track RSSI: -67, -72, -80). Uniform link quality is desirable for tomographic
localization — no single link dominates the solution.
### Known limitations
- **Peer links are ~10x weaker.** A 16-byte ESP-NOW beacon offers far less
energy to estimate a channel response from than a full data frame. Whether
1.0–2.2 perturbation is sufficient to localize with is **unknown and untested**.
- **Three links is below threshold.** Tomographic localization needs many
crossing links; 3 will not localize regardless of quality. This ADR delivers
the measurement layer, not localization.
- **Localization is not implemented.** No position tier consumes `LinkTable`.
The live position estimate remains `doppler_centroid`.
- **Per-node path regression.** Removing `filter_mac` to admit peer frames
returned the *per-node* `frame_history` to a mixed-transmitter stream, since
it still consumes every frame regardless of source. Node 2's classification
signal is noisier as a result. The fix is to select a link to feed the
per-node path — software filtering that keeps the other links — and it is not
yet done.
### Scaling limits to settle before more nodes
- **Airtime.** Every node broadcasts, so N nodes put N/period frames per second
on the channel being sensed. 3 nodes at 20 ms is 150 pps; 13 would be 650 pps.
`BEACON_PERIOD_MS` should become an NVS setting scaled to fleet size.
- **`MAX_LINKS` is 64.** 13 nodes give 78 receiver-transmitter pairs plus the AP
plus neighbours, which overflows. Raise deliberately, with the memory implied.
- **Placement.** Links must *cross* the target area. Nodes spread through a
house give roughly one link per room, each attenuated by walls and perturbed
by anyone anywhere along it — whole-house coarse presence, not position.
Perimeter placement around a single room is the geometry that works.
## Related bugs found and fixed while implementing
- **ADR-060 MAC filtering cost ~75% of capture rate.** The 50 Hz rate gate ran
*before* the MAC filter and stamped `s_last_process_us` before the check, so a
frame from any other transmitter consumed the slot and was then discarded.
Enabling `filter_mac` dropped yield from ~42 pps to 6–13. Filtering first
restores it (measured 31–37 pps). A 6-byte `memcmp` is negligible ISR work next
to the CSI processing the gate guards.
- **The entire edge pipeline was dead on ESP32-C6.** `EDGE_MAX_SUBCARRIERS` was
128, an S3-era assumption; a C6 on an HE-capable AP delivers HE20 frames with
256 bins, and `process_frame` rejected every one. ADR-110 onboarded the C6 to
the capture path but not this one. Vitals, presence, fall detection and
per-slot counting had never run on these boards. Now target-conditional on
`CONFIG_SOC_WIFI_HE_SUPPORT`, with the four subcarrier-sized arrays hoisted to
static so peak stack is *lower* than it was at 128.
- **A target-conditional constant that failed silently.** `edge_processing.h`
had no `#include` at all, so `CONFIG_SOC_WIFI_HE_SUPPORT` was undefined and
`#if` evaluated it as 0 — selecting the wrong size with no warning. An
undefined identifier in `#if` is 0 in C. This is the same failure mode as the
bug it was fixing, one layer up.
## References
- `v2/crates/wifi-densepose-sensing-server/src/links.rs` — per-link state, 8 tests
- `v2/crates/wifi-densepose-sensing-server/src/phase_diag.rs` — the phase measurement
- `firmware/esp32-csi-node/main/csi_collector.c` — wire v2 serialization, filter ordering
- `firmware/esp32-csi-node/main/edge_processing.h` — `EDGE_MAX_SUBCARRIERS`
- ADR-018 (wire format), ADR-060 (MAC filtering), ADR-110 (C6 onboarding)

View file

@ -0,0 +1,134 @@
# ADR 355: Local empty room bootstrap prior
Status: Accepted
Date: 2026-09-04
## Context
Most installations are occupied when RuView first starts. An uncalibrated
signal heuristic can interpret HVAC, clock error, neighboring motion, or a
short covariance window as a person. It can also surface plausible numeric
vitals before a room model establishes occupant attribution.
One empty home is not representative training data for all homes and contains
no positive human examples. It is useful only as a recent negative prior for
the same installation. It cannot define a universal human signal model.
The field model was memory only. The server also used a relative `data/`
directory, which is unreliable for embedded application hosts.
## Decision
RuView may persist a completed empty room field model as a local bootstrap
prior only after two server controlled stages:
1. Normal calibration reaches both 1,000 accepted frames and 600 seconds.
The frame target represents twenty complete fifty frame runtime windows.
A caller may select one currently live ESP32 source. The server checks its
freshness before replacing an active bootstrap prior, binds collection to
that source, and rejects frames from other radios. When no source is
selected, the first accepted ESP32 source preserves legacy behavior.
2. Promotion observes 12 new samples spaced by at least one second. Each
sample has a four second bounded acquisition timeout. At least 10 must
resolve empty, all ticks must be fresh, and none may publish numeric vital
signs.
The stored image contains aggregate means, environmental modes, mode energies,
noise statistics, sorted scalar residual references, configuration, one source
node ID, and a server generated model ID. It excludes raw CSI, waveforms,
device addresses, room names, pose, identity, credentials, and positive human
labels. Runtime matching compares a fifty frame mean with the learned residual
distribution. Its score is empirical background conformance, not an absence
probability.
The image is bound to a SHA 256 digest of the stable installation ID, carries
an integrity digest over the exact stored payload bytes, is capped at 1 MiB,
and inherits the field model expiry.
Files with an invalid schema, dimensions, values, node set, installation
binding, digest, size, or lifetime fail closed.
Restoring an image creates a field model in `bootstrap_only` mode. It may
reduce an uncalibrated false person count, but it cannot authorize calibrated
evidence or numeric heart and breathing rates. Explicit calibration replaces
it with fresh statistics. Cancelling an unfinished capture restores a valid
prior. Administrator scoped reset removes it.
A restored image may receive one operator confirmed held out empty refinement.
The server measures 12 fresh, source bound runtime residuals at one second
spacing and requires zero stale samples and zero numeric vital samples. It
persists only the scalar residuals. The new boundary is the larger of the
existing threshold and 110 percent of the held out 95th percentile, capped at
125 percent of the prior threshold. The one use marker persists in the model,
so repeated requests cannot ratchet the threshold upward.
The status endpoint reports the learned runtime window, residual threshold,
reference count, current background conformance, and the negative only
authority boundary. A restored model never resumes collection and never
persists raw calibration frames.
The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application
host to provide a protected state directory.
## Consequences
Startup can use recent measured background structure instead of only generic
heuristics. No accuracy lift is claimed until occupied and empty held out data
from multiple installations is recorded.
The largest failure mode is overfitting to a temporarily quiet home and
suppressing a weak occupant. Controls are low authority, 24 hour expiry,
installation binding, vital abstention, explicit recalibration, and reset.
A human signal model still requires positive, consented, leakage separated
occupied sequences. Empty data is never relabeled as human training.
## Implementation
1. `wifi-densepose-signal::ruvsense::field_model` exports and validates
`FieldModelSnapshotV1`.
2. `wifi-densepose-sensing-server::bootstrap_baseline` owns schema, integrity,
storage, lifetime, and the held out gate.
3. `POST /api/v1/calibration/bootstrap/promote` measures the validation window
and stores the image only on pass.
4. `GET /api/v1/calibration/status` reports bootstrap authority and explicitly
denies calibrated evidence and numeric vital authority.
5. Numeric vitals fail closed unless an explicit fresh calibration resolves
exactly one occupant at sufficient signal quality and confidence.
6. The legacy `edge_vitals` WebSocket message applies the same publication
gate as `sensing_update`. When the bootstrap background matches, it emits
absent state, zero people, null numeric rates, and an explicit abstention
reason instead of forwarding raw edge candidates.
7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for
deterministic single link capture. A missing or stale selection fails
without disabling the active bootstrap prior.
8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N`
performs the bounded one time held out refinement. It requires administrator
authority when transport authentication is enabled.
## Measured installation evidence
One physical empty home run on source Node 5 collected 1,762 accepted frames
over 655 seconds and produced 35 privacy reduced reference windows. Promotion
observed 12 of 12 fresh empty samples, zero stale samples, and zero numeric
vital samples. After process restart, 12 of 12 fresh samples matched the empty
background and contained no numeric vital signs. Background conformance scores
ranged from 50.0 to 64.3 percent with a median of 54.3 percent.
This is measured negative evidence for one installation. No occupied positive
holdout was recorded, so it does not prove person recall, person counting, or
cross home accuracy improvement.
## Acceptance test
With a physically empty room, complete calibration on stable nodes, promote
the single source model, and restart the server with the same installation ID
and data directory. Status must report `binding_mode=bootstrap_only`, zero
collection frames, and false authority for calibrated evidence and numeric
vitals. Twelve fresh empty samples must contain no numeric vital signs.
Starting a new room calibration must replace the prior and begin at frame zero.
When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of
the source set for the entire capture.
After restart, a confirmed empty holdout may refine the restored boundary once.
The stored snapshot must reject a second refinement and must continue to deny
calibrated evidence and numeric vital authority.

View file

@ -0,0 +1,151 @@
# ADR 356: Rate truthful CSI and reliability gated room adaptation
Status: Accepted
Date: 2026-09-05
## Context
RuView mixed three different notions of time. ESP32 firmware requested a packet
rate, the server observed a delivered rate, and several server paths still used
fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named
`dominant_freq_hz` was not a temporal frequency at all. It was the index of the
strongest subcarrier multiplied by 0.05. A second defect compared a newly
inserted frame with itself, forcing that motion delta to zero.
These errors can move a spectral peak into the breathing or heartbeat band and
produce plausible but false values. They also make models sensitive to WiFi
traffic and processor scheduling rather than physical motion.
ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four
CSI bytes invalid because of a hardware limitation. It also documents that the
CSI receive callback runs in the WiFi task and should hand work to a lower
priority task rather than perform lengthy processing. RuView serialized the
invalid bytes unchanged and fed the original buffer to edge processing.
Recent work suggests four useful directions with different maturity:
1. OpenCSI represents each link as a dimensionless residual against its own
quiet period temporal standard deviation and exposes baseline maturity. Its
reported transfer results are limited to binary presence.
2. MORIC derives motion centered delay and Doppler views to reduce dependence
on static environment specific CSI structure.
3. Parameter efficient CSI crowd counting combines self supervised pretraining,
small room adapters, and a stateful counting machine.
4. WiFi JEPA masks complete radio links and predicts their latent embeddings,
using real and ray traced CSI for pose representation learning.
The papers report promising external results. None is measured RuView evidence,
and none authorizes deployment without leakage free occupied and empty held out
evaluation.
## Decision
### Firmware ingestion
1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized
CSI bytes while retaining the fixed packet shape.
2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred.
3. Feed edge DSP from the sanitized serialized payload, never the original
invalid driver buffer.
4. Keep callback work bounded to sanitation, serialization, and the existing
queue handoff. Do not add transforms to the WiFi task.
### Server time model
1. Estimate delivered CSI rate from accepted monotonic frame timestamps after
five samples. Use 20 Hz only during startup.
2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or
timestamp artifact cannot retune downstream filters above their supported
range.
3. Reconfigure the vital detector only after a 20 percent rate change. Clear
temporal and smoothing state whenever its clock changes. Samples calculated
with different clocks must never be blended.
4. Compute temporal dominant frequency from the recent mean amplitude series
with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to
the smaller of 4 Hz or 45 percent of sample rate.
5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows.
6. Compare the current frame with the prior frame, not itself, for motion delta.
7. Export zero sample rate until the estimate is mature. Never present a startup
fallback as a measured node property.
### Model compatibility and room reliability
1. Preserve the historical fifth input feature for version 1 adaptive models at
the model boundary. Version 2 and later models consume the true temporal
frequency. This prevents an existing model from silently receiving a
different input distribution after a server update.
2. A background model is unreliable until it contains at least ten finite,
nonnegative residual references. An unreliable model cannot suppress a
runtime change and exposes no conformance score.
3. Expose background maturity, reliability, and a robust residual Z score based
on the median and median absolute deviation.
4. Keep the persisted empty home prior negative only. It cannot authorize
calibrated presence, person count, pose, or numeric vitals.
### Model roadmap
The following work remains proposal only:
1. Evaluate an OpenCSI style per link quiet residual normalization for binary
presence without replacing absolute magnitude features used for motion.
2. Evaluate delay and Doppler views after phase sanitation for activity.
3. Pretrain a frozen multilink encoder and adapt small room specific modules for
counting.
4. Evaluate link masked self supervision for pose only when at least three
spatially distinct links and synchronized positive pose labels exist.
Every candidate uses immutable manifests and splits by room, subject,
recording, and time. It is compared with the frozen baseline under identical
seeds. Counting includes confusion matrices and mean absolute error. Pose
includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled
label control must fail.
Promotion requires at least 10 percent held out lift, less than 10 milliseconds
added p95 inference latency, no more than 20 percent peak memory overhead, zero
leakage or secret findings, frozen anchor retention, and no regression in empty
rejection or occupied recall. Promotion remains an explicit maintainer action.
## Consequences
The firmware no longer preserves a known invalid prefix as sensor evidence. The
server uses an observed physical clock for spectral features and vital filters.
Existing version 1 adaptive models remain compatible. Sparse background samples
fail open to observation and fail closed for suppression.
The direct Fourier estimator is intentionally small and bounded to 128 temporal
samples. It is portable and deterministic, but it is not a replacement for a
phase sanitized delay and Doppler pipeline.
The largest remaining uncertainty is human recall. Empty home evidence can
measure false presence but cannot show whether weak occupied signals are being
suppressed. The fix path is a consented, leakage separated occupied holdout with
multiple spatial links and synchronized reference labels.
## Research basis
1. Espressif ESP-IDF WiFi CSI programming guide:
https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html
2. Espressif ESP CSI reference implementation:
https://github.com/espressif/esp-csi
3. OpenCSI:
https://arxiv.org/abs/2607.26665
4. MORIC:
https://arxiv.org/abs/2506.12997
5. Parameter efficient CSI crowd counting:
https://arxiv.org/abs/2601.02203
6. WiFi JEPA:
https://arxiv.org/abs/2607.11064
## Acceptance test
Run the firmware serializer host tests and the focused hardware, signal, and
sensing server Rust suites. The deterministic serializer test must prove the
first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must
round trip through the Rust parser. The temporal estimator must recover a
1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and
remain below 10 milliseconds p95 on the target Mac. Existing version 1 model
input must retain the legacy proxy. A background model with nine references
must never report a match or suppress a runtime change. Physical firmware is
not qualified until an S3 and C6 boot and stream the sanitized flag without
watchdog, memory, or transport regressions during a five minute burn in.

View file

@ -0,0 +1,143 @@
# ADR 357: Raw CSI calibration sample integrity
Status: Accepted
Date: 2026-09-06
## Context
The empty room field model requires 1,000 accepted observations over at least
600 seconds. The sensing server previously invoked calibration from both the
raw CSI path and the one second edge vital path. Edge vital packets did not add
a CSI observation, so that path repeatedly fed the existing history tail. A
counter could therefore satisfy the frame gate without representing 1,000
independent CSI packets.
Node liveness also combined edge vital and CSI traffic. A source emitting only
edge vital summaries could appear live enough to start calibration even though
it had no fresh raw CSI. Finally, cloning the complete per node history before
each feed copied as much as 204,800 bytes for a 100 frame, 256 subcarrier
window even though calibration consumes only the current observation.
The earlier single installation result recorded in ADR 355 predates this
admission boundary. Its frame count is not accepted as evidence of independent
CSI coverage. It remains useful only as diagnosis of the failure mode and must
not be promoted as a production bootstrap prior.
## Decision
1. Only a packet on the frozen, model specific CSI grid may advance field
calibration. Edge vital packets update their own health and presentation
state but never feed the field model. The independent live inference grid
remains unchanged.
2. Each calibration session stores the last admitted wrapping sequence for
every contributing node. Duplicate sequences are ignored. Normal forward
progress and the `u32::MAX` to zero wrap are accepted.
3. A packet one through sixteen sequence values behind the current high water mark
is bounded UDP reordering. It is recorded and dropped without feeding the
model or moving the high water mark. A backward depth above sixteen, or an
ambiguous half range sequence, latches that node as faulted for the remainder
of the session. Status exposes the policy, reorder window, per node reorder
count, maximum observed depth, and `sequence_fault_node_ids`. Finalization
returns `calibration_sequence_discontinuity` after a fault. The operator must
cancel and restart the empty room capture.
4. A requested source may start calibration only after a header only, bounded
observation window identifies one stable grid. The selected grid needs at
least ten seconds of evidence, a measured rate of at least 2 Hz, a latest
sample newer than five seconds, and no observed gap of five seconds or more.
Calibration status reports the immutable grid, selection evidence, current
grid age, and `last_sequence_by_node` without exposing CSI values.
5. Start, cancel, reset, and process initialization clear sequence admission
state. A failed feed does not advance the sequence cursor, so the same
packet may be retried after a transient model input failure.
6. The field bridge receives the current amplitude slice rather than a cloned
history. A separate bounded runtime history retains only that same frozen
grid, so the model is never trained on one symbol layout and evaluated on a
different layout. Canonical grid normalization remains unchanged.
7. The exact source grid is stored inside the digest protected bootstrap v2
payload. A restored model rebuilds only its matching runtime history. An
unbound legacy image cannot become an active startup prior.
8. Successful finalization clears the selected grid runtime history. Bootstrap
promotion waits up to 35 seconds for a complete fresh runtime window before
scoring twelve additional samples. This covers a 50 frame window at the 2 Hz
admission floor plus ten seconds of bounded jitter. Calibration observations
therefore cannot enter the held out decision window.
9. Stop and promotion require the frozen grid to remain fresh and reject any
latched sequence fault immediately before persistence. Bootstrap v2 accepts
exactly one source node because the current runtime supports one grid bound
field model.
10. The legacy `--calibrate` startup switch fails closed before opening ports.
Grid evidence does not exist at process start, so operators start the server,
wait ten seconds, then call the explicit single source calibration API.
## Consequences
Calibration frame count now means unique, forward raw CSI observations within
one uninterrupted sequence epoch. A vitals only node cannot produce a
bootstrap model. Late packets never become calibration evidence.
One physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563,
4,119,564, and 4,119,565 in one receive batch. A second independent 90 second
capture observed bounded late packets at depths 9, 10, and 11. The window of
sixteen covers the measured maximum with five positions of margin. At the
firmware acceptance ceiling of 50 Hz, the window represents no more than 320
milliseconds. Late packets are always dropped. Larger regressions remain
failed closed.
The same 90 second capture measured three interleaved HT grids on Node 5. The
64 subcarrier stream delivered 436 frames at 4.84 Hz with a 1.56 second maximum
gap. The 128 stream delivered 100 frames at 1.11 Hz with a 5.05 second maximum
gap. The 192 stream delivered 49 frames at 0.54 Hz with an 11.53 second maximum
gap. Only the 64 grid exceeded the 2 Hz admission floor and stayed inside the
five second freshness boundary, so the final field model binds to that grid.
The current wire format still has no authenticated boot epoch or stable device
identity. A restart very early in a session or a same endpoint adversary cannot
be distinguished cryptographically. A future ADR 018 revision must carry an
authenticated device identifier and a random per boot identifier before the
server claims exact restart or identity collision detection.
No new raw CSI is persisted. Sequence cursors and fault markers remain bounded
in memory to active calibration sources. The bootstrap schema advances to v2
to protect the grid identity while its negative only authority remains
unchanged.
## Implementation
1. `NodeState` records header only recent grid evidence and a separate bounded
field model history.
2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence
ordering, fault latching, model feed, and cursor commit.
3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude
slice and rejects an empty observation.
4. Calibration start, status, stop, cancel, reset, promotion, refinement, and
restart enforce the frozen source and grid contract.
5. `bootstrap_baseline` persists the source grid inside the verified v2 image.
6. Finalization clears training history, and promotion blocks until the model
has a complete fresh scoring window plus twelve separately spaced samples.
7. Startup calibration without measured grid evidence exits with a typed
operator instruction instead of creating a model that can never receive a
frame.
## Acceptance test
Unit tests must prove stable grid selection, rate and gap rejection, first
packet acceptance, forward progress, duplicate rejection, source and grid
isolation, maximum counter wrap, empty input retry, measured depth eleven
reordering without a model feed, depth sixteen acceptance, depth seventeen
latching, half range rejection, session clear, receipt visibility, protected
grid persistence, and typed stop refusal.
Tests must also prove startup flag refusal, missing and stale grid refusal,
complete holdout isolation, and sequence fault rejection during holdout.
On physical hardware, begin a Node 5 empty room capture and observe a fresh
sixty second interval. Calibration frame growth must match unique 64 subcarrier
Node 5 sequences while the independent global inference grid remains unchanged.
Edge vital packets and other grids must produce zero growth. The bound grid
must remain active, sequence faults must remain empty, and numeric vital output
must remain absent. Any bounded late packet must appear in the reorder receipts
while leaving the model count unchanged. Complete both the 1,000 frame and 600
second gates, then run the twelve sample server controlled bootstrap holdout.
Restart and prove that only the persisted grid rebuilds the runtime history.
Do not claim human detection improvement until a separate consented occupied
holdout demonstrates retained recall.

View file

@ -92,10 +92,13 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
| [ADR-345](ADR-345-per-link-csi-and-node-to-node-ranging.md) | Per-link CSI attribution and node-to-node ranging — why phase-based position fails on this hardware (measured), and the coordination-free `(tx_mac, rx_seq)` join key that does not need it | Proposed — measurement layer implemented and validated on hardware; localization not built |
| [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed |
| [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed |
| [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed |
| [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted |
| [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted |
| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted |
### Platform and UI

View file

@ -0,0 +1,63 @@
# WiFlow Reproduction Protocol
Status: Proposed research protocol
Tracks issue #1786.
## Objective
Determine whether a CSI optical flow style representation improves reusable camera free motion intelligence in RuView rather than only improving a proxy flow metric.
## Required capture metadata
Every run records hardware family, firmware, antenna count and geometry, channel width, center frequency, packet rate, timestamp source, synchronization method, room identity, participant identity class, label source, calibration, packet loss, missing subcarriers, preprocessing version, model commit, accelerator, software versions, power mode, and random seeds.
## Baselines
1. current RuView CSI preprocessing plus strongest task specific sensing head
2. WiFlow style motion field representation
3. simpler temporal derivative or phase and amplitude motion baseline
All conditions use identical train, validation, and held out environment splits.
## Metrics
Primary intermediate metrics:
* endpoint error or equivalent flow error
* directional consistency
* temporal stability
* uncertainty calibration
Required downstream metrics:
* tracking quality
* occupancy or presence quality
* pose or activity metric where available
* spatial state consistency in RuField or WorldGraph
Systems metrics:
* p50 and p95 inference latency
* peak memory
* CPU, GPU, or accelerator utilization
* energy per inference where measurable
* bytes per observation and sustained bandwidth
## Stress tests
Test unseen rooms, unseen participants, changed antenna geometry, a second RF hardware family, packet loss, burst loss, missing subcarriers, lower packet rate, clock jitter, and partial calibration failure.
## Promotion gate
The representation graduates only when the same learned representation improves at least two downstream RuView tasks on held out environments, or reaches equivalent downstream quality with materially lower edge cost.
A better optical flow proxy score alone is insufficient.
## Security and privacy
Visual or LiDAR labels may be used as privileged training evidence but must not become a runtime dependency for camera free deployment claims. Dataset provenance and consent requirements remain explicit.
## Rollback
The experiment is isolated behind a representation interface. Existing RuView sensing heads remain the production fallback.

View file

@ -115,6 +115,30 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
#### Seeed XIAO ESP32-C6 external antenna
The XIAO ESP32-C6 has a hardware RF switch between its internal ceramic
antenna and U.FL connector. Generic C6 boards do not share this wiring, so the
firmware leaves GPIO3 and GPIO14 untouched by default. For an XIAO with an
external antenna already attached while powered off, build with one of the
opt-in overlays:
```bash
# Controlled A condition: internal antenna, RF path stated in the boot log.
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-internal" \
set-target esp32c6
idf.py build
# Controlled B condition: external U.FL antenna.
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" \
set-target esp32c6
idf.py build
```
The boot log must report the intended `internal ceramic` or `external U.FL`
path before that measurement is labelled. The standard C6 build keeps the
board default and emits no antenna-path claim.
### 3. Provision WiFi credentials (no reflash needed)
```bash
@ -257,10 +281,17 @@ Offset Size Field
12 4 Sequence number (LE u32)
16 1 RSSI (i8)
17 1 Noise floor (i8)
18 2 Reserved
18 1 PPDU type (ADR-110; zero when tagging is disabled)
19 1 Flags: bit0=40 MHz, bit2=STBC, bit4=sync valid,
bit5=invalid first CSI word zeroed by firmware
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
```
When ESP-IDF marks `first_word_invalid`, the firmware preserves packet geometry,
zeros the first four CSI bytes, and sets byte 19 bit 5. The same sanitized bytes
feed the edge DSP path. This prevents a documented hardware artifact from being
learned as motion while keeping older readers wire compatible.
### Vitals Packet (32 bytes)
```

View file

@ -0,0 +1,334 @@
# Firmware runbook — ESP32-C6 CSI node
Operational procedure. `README.md` explains the project; this says exactly what
to run, in what order, and what to check. Follow it top to bottom.
If you are an agent: **read this file before touching firmware.** Everything
below was learned by getting it wrong at least once.
---
## 1. Build
**One command. Copy it.** From the **repository root**, not this directory:
```bash
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.4 bash -c \
"cat sdkconfig.defaults sdkconfig.defaults.16mb sdkconfig.defaults.esp32c6 \
> sdkconfig.defaults.build && \
SDKCONFIG_DEFAULTS='sdkconfig.defaults.build' idf.py set-target esp32c6 && \
idf.py build"
```
Takes ~3 minutes cold, well under a minute incremental.
### The three defaults files are NOT all automatic
IDF picks up `sdkconfig.defaults` and `sdkconfig.defaults.<target>` on its own.
It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
Build without it and you get an image that is correct in every visible respect
and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in
the app expecting the capability. Silent, and only discoverable by pushing a
deliberately bad image.
That is why the command above concatenates all three explicitly rather than
relying on discovery.
### Never `rm -rf sdkconfig` without a backup
The README's headline example opens with `rm -rf build sdkconfig`. `sdkconfig`
is **untracked and gitignored**. `partitions_16mb.csv` was lost to exactly this,
and `CLAUDE.md` warns about it *even when a README documents it*.
```bash
cp sdkconfig "sdkconfig.backup-$(date +%Y%m%d-%H%M%S)" # then regenerate
```
A stale `sdkconfig` is a real hazard, not a hypothetical: one found on
2026-09-03 said `FLASHSIZE=4MB`, `partitions_4mb.csv`,
`DYNAMIC_TX_BUFFER_NUM=64` — against a fleet running 16MB and 128. Anything
built from it would silently not be the configuration under test.
### Verify before you flash anything
```bash
grep -E "^CONFIG_(IDF_TARGET|ESPTOOLPY_FLASHSIZE|PARTITION_TABLE_CUSTOM_FILENAME|\
ESP_WIFI_DYNAMIC_TX_BUFFER_NUM|BOOTLOADER_APP_ROLLBACK_ENABLE)=" sdkconfig
```
All five must read:
| key | expected |
|---|---|
| `CONFIG_IDF_TARGET` | `"esp32c6"` |
| `CONFIG_ESPTOOLPY_FLASHSIZE` | `"16MB"` |
| `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` | `"partitions_16mb.csv"` |
| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `128` |
| `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` | `y` |
**Do not use the native `C:\Espressif\...esp-idf-v5.5.5`.** Wrong version, its
venv does not match the default python, and a container-built `build/` refuses
a native build outright. Docker only.
---
## 2. What OTA can and cannot reach
This is the distinction that decides whether a change needs a cable.
| region | offset | reachable by OTA? |
|---|---|---|
| bootloader | `0x0` | **NO — USB only** |
| partition table | `0x8000` | **NO — USB only** |
| otadata | `0xF000` | rewritten by OTA |
| ota_0 | `0x20000` | yes (4 MB) |
| ota_1 | `0x420000` | yes (4 MB) |
| coredump | `0x820000` | no |
| storage (fat) | `0x830000` | no |
| nvs | `0x9000` | never written by a flash — provisioning survives |
**OTA replaces the app partition only.** Anything in the bootloader —
`CONFIG_BOOTLOADER_*`, notably `APP_ROLLBACK_ENABLE` — or any partition-table
change **requires USB on every board**.
There is **no bootloader version on the wire**. A node's sync packet reports app
health, not bootloader capability, so you cannot determine remotely which boards
have a rollback-capable bootloader. Track it per board, or do a USB pass across
all of them.
Corollary: "every node reports health" proves the **app** is current and proves
nothing about the bootloader. Do not answer a bootloader question with app
evidence.
---
## 3. Deploying
### Over the air — app-only changes
Works end-to-end and is the default for anything that is not a bootloader or
partition change. Roll to **one node**, soak it, then the rest.
### Over USB — bootloader, partition table, or recovery
```bash
python -m esptool --chip esp32c6 -b 460800 \
--before default_reset --after hard_reset write_flash \
--flash_mode dio --flash_size 16MB --flash_freq 80m \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0xf000 build/ota_data_initial.bin \
0x20000 build/esp32-csi-node.bin
```
`0x9000` is not in that list, so SSID, password and `node_id` survive.
### Safe single-board diagnostic flash, with a revert path
Nodes run from whichever slot `otadata` selects. **Parse it before flashing** —
two 32-byte entries at offsets 0 and 0x1000; active slot is
`(max ota_seq - 1) % 2`.
1. Back up `otadata` (8 KB) **and** the live app slot with `esptool read-flash`.
2. Flash the diagnostic build — it lands in `ota_0` and rewrites `otadata`.
3. **Revert = write the 8 KB `otadata` backup back.** Production in `ota_1` was
never touched, so no rebuild is needed.
Validate any backup image before trusting it: byte 0 must be `0xE9`, and the
`esp_app_desc_t` magic at offset `0x20` must be `0xABCD5432`.
**Never flash the whole fleet from a workstation, and never use the server's OTA
endpoint for a diagnostic.** USB, one board, revert path secured first.
---
## 4. Serial
CH343 USB-serial at 115200.
To read a running board **without resetting it**, open with
`DtrEnable=$false; RtsEnable=$false`. esptool asserts RTS and *will* reset the
board, which loses the state you were trying to observe.
---
## 5. Changing config without a cable
Most parameters do not need a flash at all:
```bash
export RUVIEW_OTA_PSK_FILE=<path to the psk file> # never inline the key
python config_push.py --node <node-ip> --get
python config_push.py --node <node-ip> --set led_brightness=10
```
Keys marked `assoc: true` in `config_api.c` (`wifi_ssid`, `wifi_password`,
`channel_hop_count`, `dwell_ms`, `csi_channel`) are applied as a **trial**: the
node banks its current values, reboots, and keeps the change only if it
re-associates. Everything else applies without that risk.
`config_push.py` has **no unset**. To restore a default you must set it
explicitly. Current defaults:
| key | default | source |
|---|---|---|
| `vital_interval_ms` | 1000 | `Kconfig.projbuild` |
| `swarm_heartbeat_sec` | 30 | `nvs_config.c` |
| `swarm_ingest_sec` | 5 | `nvs_config.c` |
| `beacon_period_ms` | 0 = derive from fleet size | `nvs_config.c` |
---
## 6. Reading a node remotely
`GET /api/v1/mesh` returns per-node `health`: `min_heap_kib`, `die_c`,
`thermal_state`, `tx_dbm`, `reset_reason`, and from sync proto v3 the TX-path
counters `send_fail` / `rate_skip` / `early_drop`.
`min_heap_kib` is a **minimum-since-boot watermark**, not an instantaneous
reading. It resets on reboot and only ever falls. Sampling it within a minute of
a restart tells you nothing — that mistake produced a wrong "no effect" verdict
on 2026-09-03; the real answer, once it had settled, was −56 KiB.
`/api/v1/nodes` `rssi_dbm` is **not** the AP link — it is whatever link landed
most recently and swings 20+ dB with no physical change. Filter `/api/v1/links`
by the AP's BSSIDs instead, and read `csi_fps_ema` alongside, because link count
alone hides a starved node.
---
## 7. Tunables, and which ones depend on your house
`idf.py menuconfig` exposes these. The defaults are what a nine-node ESP32-C6
fleet in one house settled on; several are **not** universal, and the point of
listing them here is that you can pick and measure rather than inherit someone
else's building.
| option | default | depends on your house? |
|---|---|---|
| `CSI_GATE_MESH_ALIGNED` | y | **yes** |
| `CSI_SEQ_DIAG` | n | diagnostic only |
| `THERMAL_MONITOR` | y | no |
| `THERMAL_THROTTLE` | n | **yes** |
| `THERMAL_THROTTLE_C` | 72 | **yes** |
| `THERMAL_HYSTERESIS_C` | 8 | no |
| `UPLINK_WATCHDOG` | y | no |
| `UPLINK_WATCHDOG_TIMEOUT_S` | 1200 | **yes** |
### The ones worth measuring yourself
**`CSI_GATE_MESH_ALIGNED`** decides whether nodes keep the *same* frames or
merely the same *number* of frames, which is what makes cross-node pairing
possible at all. Whether alignment helps depends on how much your nodes
actually overhear in common, and that is a property of your walls and your
transmitters. In one measured nine-node fleet, aligning windows bought
+0.5 pp of pairing while selecting frames by `rx_seq` bought +31.7 pp -- so do
not assume the default is doing the heavy lifting in your building.
Turn on `CSI_SEQ_DIAG` to measure it. It logs `(source MAC, rx_seq)` per CSI
callback, which is what lets you compute the fraction of frames two nodes hold
in common instead of guessing.
**`THERMAL_THROTTLE` / `THERMAL_THROTTLE_C`** depend on enclosure and placement,
not on the chip. A bare bench board never reaches 72 C; the same board sealed in
a printed case on a sunny wall can. Read `die_c` from `/api/v1/mesh` for a few
days before deciding, because throttling transmit power costs range.
**`UPLINK_WATCHDOG_TIMEOUT_S`** is a bet about your AP. 1200 s suits a network
where a wedge is rare and a reboot is cheap. If your uplink drops briefly and
often, a short timeout turns a recoverable gap into a reboot loop; if it wedges
hard, a long one leaves a node dark for twenty minutes.
### A worked example: the same two options, in one house
MEASURED on a nine-node ESP32-C6 fleet in a single detached house. **These are
our numbers, not yours.** They are here to show the shape of the decision, and
how much a building can move it.
Four arms as a 2x2 factorial, 20-minute runs, three Latin-square blocks so slow
drift landed on every arm equally. Metric is the fraction of transmissions held
in common by two or more nodes.
| arm | mean |
|---|---|
| `elapsed` (per-node gate) | 0.2434 |
| `mesh` (aligned windows) | 0.2480 |
| `seq` (keep `rx_seq` divisible by 8) | **0.5605** |
| `mesh` + `seq` | 0.5452 |
`seq` roughly doubled pairing, +31.7 pp, about 70x the measured drift floor and
reproduced in all three blocks. Mesh alignment bought **+0.5 pp** -- inside
noise. Stacking them was very slightly *worse* than `seq` alone.
**Why that happened here, and why yours may differ.** These nodes already
overheard a great deal in common -- two boards side by side heard the same
transmissions 72% of the time -- so there was a large pool of shared
transmissions for `seq` to select from, and little left for window alignment to
recover. A house with fewer shared transmitters, thicker interior walls, or
nodes spread so that few pairs hear the same radios has less for `seq` to work
with, and phase alignment may matter more rather than less. We did not measure
such a house and do not claim to know.
**It is a trade, not a free win.** At period 8 the `seq` arms carried ~75-95k
transmissions per 20 minutes against ~120-160k ungated: pairing is bought with
raw frame rate. Absolute paired transmissions per minute still rose (2629 vs
2524), because the discarded frames are disproportionately ones only one node
would have kept. Whether that trade is right depends on what consumes your
stream. On-device edge processing was **unaffected** here -- frames processed
stayed ~1900 per node per 4 minutes in both modes, because that pipeline is
separately rate-limited -- so the cost fell on the uplink, not on local sensing.
If your uplink is the scarce resource, that changes the answer.
**The two options differ in kind, not just in effect.** Mesh alignment is an
agreement between receivers: it needs a shared clock, so it inherits whatever
the sync topology does. Selecting by `rx_seq` needs no agreement at all. The
802.11 sequence control field is assigned by the TRANSMITTER and is identical
at every receiver, so it is a coordination-free name for one transmission --
two nodes that have never heard of each other, sit in different sync domains,
or run with no mesh whatsoever still keep the same subset of frames.
In the code the seq test runs before any sync check and reads only the frame;
only the mesh branch calls `c6_sync_espnow_is_valid()`. That asymmetry is the
reason `seq` had nothing to degrade in our runs, and it is worth weighing
directly: if your fleet's sync is fragile, `seq` is not merely the better
performer, it is the one with no dependency that can quietly fail.
**A limitation of our own measurement, which cuts against the mesh arm.** The
mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`; a node
with no valid sync falls back to the elapsed gate rather than gating on a
meaningless epoch. That is the right behaviour, but it means our `mesh` arms
were partly `elapsed` arms: three of nine nodes never synchronised, so for those
three the setting under test was silently not in effect. Read the +0.5 pp as
"mesh where sync held, elapsed where it did not" rather than as a clean
fleet-wide measurement of alignment.
This is itself a house-dependent property. Time sync here is leader-elected and
hub-and-spoke -- one node broadcasts and the rest follow. Whether every node can
hear that leader is a question about your building, not about the firmware. In a
larger or more divided house the leader may not reach everyone, the fleet
fragments into sync domains, and any option that depends on shared time degrades
quietly for the nodes that fell out. Check `is_leader` and sync validity in
`/api/v1/mesh` before concluding that an alignment setting did or did not help.
**A prediction we got wrong, kept because it is the useful part.** Our mesh had
split into two leader domains with three nodes never synchronising at all. We
expected `seq` to win most on pairs spanning that split. Per-pair yield said
otherwise: same-domain 5.82x, cross-domain 6.24x, involving a never-synced node
**5.16x** -- the lowest, the opposite of the prediction -- and within-group
spread (3.6x-8.1x) dwarfed every between-group difference.
So `seq` is **not** a workaround for broken time sync; it wins roughly
uniformly, including where sync is healthy. The consequence is negative and
worth having: repairing the mesh split would probably not have closed the gap,
and that effort would have been spent for nothing.
### Measuring rather than guessing
A gate comparison needs a fixed environment, repeated blocks, and every arm run
on every node -- otherwise node placement is confounded with the setting. The
harness used for the published comparison is in the repository; use it rather
than eyeballing a frame rate, which moves for reasons unrelated to the setting.

View file

@ -14,6 +14,7 @@ set(SRCS
"c6_twt.c"
"c6_timesync.c"
"c6_lp_core.c"
"c6_antenna_select.c"
# ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6)
"c6_sync_espnow.c"
# ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled)
@ -48,6 +49,12 @@ if(IDF_TARGET STREQUAL "esp32c6")
list(APPEND REQUIRES ieee802154 ulp esp_hw_support)
endif()
# ESP-IDF 6 split the temperature sensor driver out of the legacy `driver`
# component. Keep the 5.4 lane working while declaring the v6 dependency.
if(IDF_VERSION_MAJOR GREATER_EQUAL 6)
list(APPEND REQUIRES esp_driver_tsens)
endif()
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
if(CONFIG_CSI_MOCK_ENABLED)
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")

View file

@ -1,5 +1,27 @@
menu "CSI Node Configuration"
config C6_XIAO_ANTENNA_SELECT
bool "Control the Seeed XIAO ESP32-C6 RF switch"
depends on IDF_TARGET_ESP32C6
default n
help
Enable only for Seeed XIAO ESP32-C6 boards. The XIAO uses GPIO3
to enable RF-switch control and GPIO14 to choose its internal or
external antenna. Leave this disabled for generic ESP32-C6 boards
because GPIO3 and GPIO14 may have unrelated functions there.
choice C6_XIAO_ANTENNA_PATH
prompt "Seeed XIAO ESP32-C6 antenna path"
depends on C6_XIAO_ANTENNA_SELECT
default C6_XIAO_ANTENNA_INTERNAL
config C6_XIAO_ANTENNA_INTERNAL
bool "Internal ceramic antenna"
config C6_XIAO_ANTENNA_EXTERNAL
bool "External U.FL antenna"
endchoice
config CSI_NODE_ID
int "Node ID (0-255)"
default 1
@ -651,3 +673,20 @@ config UPLINK_WATCHDOG_TIMEOUT_S
packet's health field before assuming the watchdog is responsible.
endmenu
config CSI_GATE_MESH_ALIGNED
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
default y
help
The per-node elapsed gate ("20 ms since MY last accept") gives every node
an independent phase, so two healthy nodes at 50 Hz accept disjoint
frames and nothing can be paired across them. This aligns the 20 ms
window to mesh time so nodes choose the same windows.
config CSI_SEQ_DIAG
bool "Log (source MAC, rx_seq) for every CSI callback (ADR-345 phase 2 probe)"
default n
help
Diagnostic. Emits the transmitter address and 802.11 sequence number for
each CSI callback so the effect of the gate above can be measured rather
than assumed. Verbose; leave off in normal operation.

View file

@ -0,0 +1,56 @@
#include "c6_antenna_select.h"
#include "sdkconfig.h"
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
#include "driver/gpio.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define XIAO_RF_SWITCH_POWER_GPIO GPIO_NUM_3
#define XIAO_RF_SWITCH_SELECT_GPIO GPIO_NUM_14
static const char *TAG = "c6_antenna";
#endif
esp_err_t c6_xiao_antenna_apply(void)
{
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
gpio_config_t config = {
.pin_bit_mask = (1ULL << XIAO_RF_SWITCH_POWER_GPIO) |
(1ULL << XIAO_RF_SWITCH_SELECT_GPIO),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&config);
if (err != ESP_OK) {
return err;
}
err = gpio_set_level(XIAO_RF_SWITCH_POWER_GPIO, 0);
if (err != ESP_OK) {
return err;
}
vTaskDelay(pdMS_TO_TICKS(100));
#if defined(CONFIG_C6_XIAO_ANTENNA_EXTERNAL)
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_EXTERNAL;
const char *name = "external U.FL";
#else
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_INTERNAL;
const char *name = "internal ceramic";
#endif
err = gpio_set_level(XIAO_RF_SWITCH_SELECT_GPIO,
c6_xiao_antenna_gpio14_level(antenna));
if (err == ESP_OK) {
ESP_LOGI(TAG, "XIAO ESP32-C6 antenna path: %s", name);
}
return err;
#else
return ESP_OK;
#endif
}

View file

@ -0,0 +1,17 @@
#pragma once
typedef enum {
C6_XIAO_ANTENNA_INTERNAL = 0,
C6_XIAO_ANTENNA_EXTERNAL = 1,
} c6_xiao_antenna_t;
static inline int c6_xiao_antenna_gpio14_level(c6_xiao_antenna_t antenna)
{
return antenna == C6_XIAO_ANTENNA_EXTERNAL ? 1 : 0;
}
#ifdef ESP_PLATFORM
#include "esp_err.h"
esp_err_t c6_xiao_antenna_apply(void);
#endif

View file

@ -13,7 +13,7 @@
* `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c.
*
* 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up
* (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no
* (ESP-IDF deep-sleep GPIO wake API). No debounce, no
* sub-10 µA floor, but no LP toolchain dependency either.
* This is the path the v0.6.6 firmware shipped with.
*
@ -26,6 +26,7 @@
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE)
#include "c6_lp_core.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "driver/rtc_io.h"
@ -134,10 +135,17 @@ esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high)
#else
/* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */
uint64_t mask = 1ULL << wake_gpio;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
esp_sleep_gpio_wake_up_mode_t mode = active_high
? ESP_GPIO_WAKEUP_GPIO_HIGH
: ESP_GPIO_WAKEUP_GPIO_LOW;
esp_err_t err = esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(mask, mode);
#else
esp_deepsleep_gpio_wake_up_mode_t mode = active_high
? ESP_GPIO_WAKEUP_GPIO_HIGH
: ESP_GPIO_WAKEUP_GPIO_LOW;
esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode);
#endif
if (err != ESP_OK) {
ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err));
return err;

View file

@ -114,6 +114,9 @@ static int64_t s_last_send_us = 0;
*/
#define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */
static int64_t s_last_process_us = 0;
/* Mesh-epoch window index of the last accepted frame. UINT64_MAX means "none
* yet", which no real bucket can collide with. */
static uint64_t s_last_gate_bucket = UINT64_MAX;
static uint32_t s_early_drop = 0;
/* ---- ADR-029: Channel-hop state ---- */
@ -263,8 +266,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
buf[19] = 0;
#endif
/* I/Q data */
/* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid
* when first_word_invalid is set. Never let those hardware artifacts feed
* either the host or edge DSP path. Preserve the fixed ADR-018 geometry by
* zeroing rather than removing bytes, and mark the sanitation in bit 5. */
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
if (info->first_word_invalid && iq_len > 0) {
size_t invalid_len = iq_len < 4 ? iq_len : 4;
memset(&buf[CSI_HEADER_SIZE], 0, invalid_len);
buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED;
}
return frame_size;
}
@ -277,9 +288,53 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
(void)ctx;
/* Early rate gate: drop excess callbacks to ~50 Hz to prevent
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq).
*
* HOW the rate is limited decides whether cross-node fusion is possible.
*
* The original gate is "at least 20 ms since MY last accept". Each node
* therefore has an independent phase: node A can accept at t=0,20,40 ms
* while node B accepts at 7,27,47. Both are perfectly healthy and both hit
* 50 Hz, yet they accept disjoint frames and nothing can be paired.
*
* MEASURED 2026-08-30, two boards side by side: they HEARD the same
* transmissions 72% of the time but only 25% of accepted frames were
* common. The frames were there; the gate was throwing away the pairing.
*
* So gate on a bucket of the MESH-ALIGNED epoch instead: every node takes
* the first frame it hears in the same absolute 20 ms window. Two nodes
* that heard the same frame now both accept it. Mesh sync is good to
* roughly 490 us against a 20 ms bucket -- about 2.5% of a window -- so
* boundary disagreements are rare.
*
* Falls back to the original elapsed-time gate whenever mesh sync is not
* valid (no leader heard yet, or a node that just booted), because an
* unsynced epoch would otherwise gate on a meaningless number. A node
* running unsynced simply gets the old behaviour rather than no gate.
*
* The half-interval floor below keeps the crash protection honest: bucket
* gating alone could accept at the end of one window and the start of the
* next, back to back. The floor bounds that to ~100 Hz for a single pair
* while the average stays at 50 Hz. Do not remove it; the gate exists for
* a crash, not for tidiness. */
int64_t now_us = esp_timer_get_time();
if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) {
bool take;
#ifdef CONFIG_CSI_GATE_MESH_ALIGNED
if (c6_sync_espnow_is_valid()) {
uint64_t bucket = c6_sync_espnow_get_epoch_us()
/ (uint64_t)CSI_MIN_PROCESS_INTERVAL_US;
take = (bucket != s_last_gate_bucket)
&& ((now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US / 2);
if (take) {
s_last_gate_bucket = bucket;
}
} else {
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
}
#else
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
#endif
if (!take) {
s_early_drop++;
return;
}
@ -330,13 +385,16 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
* while the on-device Tier 1/2 pipeline receives a uniform, sustainable
* stream. Enqueuing every burst frame overloaded the unicore C6 DSP and
* turned 30-40 callback pps into an irregular approximately 8 Hz subset. */
if (info->buf && info->len > 0) {
if (frame_len > CSI_HEADER_SIZE) {
if (s_next_edge_enqueue_us == 0) {
s_next_edge_enqueue_us = now_us;
}
if (now_us >= s_next_edge_enqueue_us) {
(void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
/* Reuse the sanitized ADR-018 payload. Feeding info->buf here
* would reintroduce first_word_invalid artifacts on device. */
(void)edge_enqueue_csi(&frame_buf[CSI_HEADER_SIZE],
(uint16_t)(frame_len - CSI_HEADER_SIZE),
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
/* Preserve the configured sample clock instead of resetting it to

View file

@ -17,6 +17,9 @@
/** ADR-018 header size in bytes. */
#define CSI_HEADER_SIZE 20
/** ADR-018 byte 19: ESP-IDF reported and firmware sanitized invalid CSI prefix. */
#define CSI_FLAG_FIRST_WORD_SANITIZED (1U << 5)
/** Maximum frame buffer size (header + 4 antennas * 256 subcarriers * 2 bytes). */
#define CSI_MAX_FRAME_SIZE (CSI_HEADER_SIZE + 4 * 256 * 2)

View file

@ -37,6 +37,7 @@
#include "c6_twt.h" /* ADR-110: TWT (no-op stub on S3) */
#include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */
#include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */
#include "c6_antenna_select.h" /* XIAO C6 RF switch (opt-in; generic C6 no-op) */
#include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */
#include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */
#ifdef CONFIG_CSI_MOCK_ENABLED
@ -331,6 +332,11 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep
* ownership of GPIO3 and GPIO14 because the default implementation is a
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
ESP_ERROR_CHECK(c6_xiao_antenna_apply());
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the
* common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target.

View file

@ -0,0 +1,4 @@
# Seeed XIAO ESP32-C6 external U.FL antenna test overlay.
# Use only with the XIAO board; generic C6 boards may use GPIO3/GPIO14.
CONFIG_C6_XIAO_ANTENNA_SELECT=y
CONFIG_C6_XIAO_ANTENNA_EXTERNAL=y

View file

@ -0,0 +1,4 @@
# Seeed XIAO ESP32-C6 internal ceramic antenna test overlay.
# Keeps RF-switch control enabled so the boot log proves the selected path.
CONFIG_C6_XIAO_ANTENNA_SELECT=y
CONFIG_C6_XIAO_ANTENNA_INTERNAL=y

View file

@ -39,16 +39,45 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
STUBS_SRC = stubs/esp_stubs.c
MAIN_DIR = ../main
# GNU ld and Apple's ld spell dead section elimination differently.
# Keep the host sanitation test portable across the Linux CI runner and macOS.
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
DEAD_STRIP_LDFLAGS := -Wl,-dead_strip
else
DEAD_STRIP_LDFLAGS := -Wl,--gc-sections
endif
# Default fuzz duration (seconds) and jobs
FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
test_thermal run_thermal
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
test_c6_antenna run_c6_antenna
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal
test_thermal test_csi_sanitize test_c6_antenna
# --- XIAO ESP32-C6 RF switch selection ---
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
# low by the device implementation before this level is applied.
test_c6_antenna: test_c6_antenna_select.c $(MAIN_DIR)/c6_antenna_select.h
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
run_c6_antenna: test_c6_antenna
./test_c6_antenna
# --- ESP-IDF invalid first-word sanitation ---
# Links the production serializer. Function sections and dead stripping keep
# unrelated device-only callback code out of this host executable.
test_csi_sanitize: test_csi_sanitize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC)
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections \
$^ $(DEAD_STRIP_LDFLAGS) -o $@ -lm
run_csi_sanitize: test_csi_sanitize
./test_csi_sanitize
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@ -93,8 +122,9 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
run_thermal: test_thermal
./test_thermal
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135 + thermal)"
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
run_c6_antenna
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@ -130,5 +160,6 @@ run_nvs: fuzz_nvs
run_all: run_serialize run_edge run_nvs
clean:
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize test_c6_antenna
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/

View file

@ -90,6 +90,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
info.rx_ctrl.sig_mode = legacy_inputs & 0x03;
info.rx_ctrl.cwb = (legacy_inputs >> 2) & 0x01;
info.rx_ctrl.stbc = (legacy_inputs >> 3) & 0x01;
info.first_word_invalid = (test_case & 0x80) != 0;
/* Use remaining fuzz data as I/Q buffer content. */
uint16_t iq_len;
@ -136,6 +137,17 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
if (result > out_len) {
__builtin_trap(); /* Buffer overflow detected. */
}
if (result >= CSI_HEADER_SIZE + iq_len && info.first_word_invalid && iq_len > 0) {
size_t invalid_len = iq_len < 4 ? iq_len : 4;
for (size_t i = 0; i < invalid_len; i++) {
if (out_buf[CSI_HEADER_SIZE + i] != 0) {
__builtin_trap(); /* Invalid hardware prefix escaped sanitation. */
}
}
if ((out_buf[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0) {
__builtin_trap(); /* Sanitized payload must carry provenance. */
}
}
/* --- Test case 1: NULL info pointer --- */
if (test_case & 0x01) {

View file

@ -90,6 +90,7 @@ typedef struct {
typedef struct {
wifi_pkt_rx_ctrl_t rx_ctrl;
uint8_t mac[6];
bool first_word_invalid;
int16_t len; /**< Length of the I/Q buffer in bytes. */
int8_t *buf; /**< Pointer to I/Q data. */
} wifi_csi_info_t;

View file

@ -0,0 +1,13 @@
#include <assert.h>
#include <stdio.h>
#include "c6_antenna_select.h"
int main(void)
{
assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_INTERNAL) == 0);
assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_EXTERNAL) == 1);
puts("PASS: XIAO ESP32-C6 antenna selection truth table");
return 0;
}

View file

@ -0,0 +1,56 @@
/** Deterministic host test for ESP-IDF first_word_invalid sanitation. */
#include "esp_stubs.h"
#include "nvs_config.h"
#include "csi_collector.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
nvs_config_t g_nvs_config;
static void make_info(wifi_csi_info_t *info, int8_t *iq, uint16_t len, bool invalid)
{
memset(info, 0, sizeof(*info));
info->rx_ctrl.channel = 6;
info->rx_ctrl.rssi = -42;
info->rx_ctrl.noise_floor = -91;
info->len = (int16_t)len;
info->buf = iq;
info->first_word_invalid = invalid;
}
int main(void)
{
int8_t iq[] = {1, 2, 3, 4, 5, 6, 7, 8};
uint8_t frame[CSI_MAX_FRAME_SIZE];
wifi_csi_info_t info;
make_info(&info, iq, sizeof(iq), true);
size_t len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(iq));
assert(frame[CSI_HEADER_SIZE] == 0);
assert(frame[CSI_HEADER_SIZE + 1] == 0);
assert(frame[CSI_HEADER_SIZE + 2] == 0);
assert(frame[CSI_HEADER_SIZE + 3] == 0);
assert(frame[CSI_HEADER_SIZE + 4] == 5);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
make_info(&info, iq, sizeof(iq), false);
len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(iq));
assert(memcmp(&frame[CSI_HEADER_SIZE], iq, sizeof(iq)) == 0);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0);
int8_t short_iq[] = {9, 10};
make_info(&info, short_iq, sizeof(short_iq), true);
len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(short_iq));
assert(frame[CSI_HEADER_SIZE] == 0);
assert(frame[CSI_HEADER_SIZE + 1] == 0);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
puts("CSI invalid-prefix sanitation: PASS");
return 0;
}

View file

@ -1 +1 @@
0.8.8
0.8.10

View file

@ -145,13 +145,16 @@ impl PpduType {
/// bit 2 : STBC
/// bit 3 : LDPC (reserved — not yet populated by firmware)
/// bit 4 : 802.15.4 time-sync valid (C6 only)
/// bit 5-7 : reserved
/// bit 5 : ESP-IDF invalid first CSI word was zeroed by firmware
/// bit 6-7 : reserved
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adr018Flags {
pub bw40: bool,
pub stbc: bool,
pub ldpc: bool,
pub ieee802154_sync_valid: bool,
#[serde(default)]
pub first_word_sanitized: bool,
}
impl Adr018Flags {
@ -161,6 +164,7 @@ impl Adr018Flags {
stbc: (b & 0x04) != 0,
ldpc: (b & 0x08) != 0,
ieee802154_sync_valid: (b & 0x10) != 0,
first_word_sanitized: (b & 0x20) != 0,
}
}
pub fn to_byte(self) -> u8 {
@ -169,6 +173,7 @@ impl Adr018Flags {
if self.stbc { b |= 0x04; }
if self.ldpc { b |= 0x08; }
if self.ieee802154_sync_valid { b |= 0x10; }
if self.first_word_sanitized { b |= 0x20; }
b
}
}

View file

@ -413,15 +413,16 @@ mod tests {
#[test]
fn adr110_flags_round_trip_all_bits() {
// All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D);
// All known flag bits set: bw40 + STBC + LDPC + sync + sanitized prefix = 0x3D.
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x3D);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert!(frame.metadata.adr018_flags.bw40);
assert!(frame.metadata.adr018_flags.stbc);
assert!(frame.metadata.adr018_flags.ldpc);
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
assert!(frame.metadata.adr018_flags.first_word_sanitized);
// Round-trip the encoder
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D);
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x3D);
}
#[test]

View file

@ -30,7 +30,7 @@ mdns-sd = "0.11"
# Serialization
serde = { workspace = true }
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
# Logging
tracing.workspace = true

View file

@ -43,6 +43,8 @@ per ADR-022 Phase 3.
- **WebSocket broadcast** -- Real-time sensing updates pushed to all connected clients at
`ws://localhost:8765/ws/sensing`.
- **Static file serving** -- Hosts the sensing UI on port 8080 with CORS headers.
- **Private startup baseline** -- Restores a recent, installation-bound empty-room field model
without storing raw CSI or authorizing numeric vital signs (ADR-355).
## Modules
@ -74,6 +76,23 @@ cargo run -p wifi-densepose-sensing-server -- \
--static-dir ./ui
```
### Empty-room startup baseline
Provide a stable installation ID and an application-owned private state directory:
```bash
cargo run -p wifi-densepose-sensing-server -- \
--installation-id installation-01 \
--data-dir /path/to/private/application-state
```
Complete the normal empty-room calibration, then call
`POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout
before storing an aggregate field-model snapshot. On a later start with the same installation ID,
the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false
positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use
`POST /api/v1/calibration/reset` with administrator scope to remove it.
### Using as a library
```rust

View file

@ -139,6 +139,39 @@ pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_
]
}
/// Preserve the fifth feature's historical meaning for models trained before
/// the server began measuring temporal frequency from a frame window.
///
/// Version 1 models learned a proxy equal to the strongest subcarrier index
/// multiplied by 0.05. Version 2 and later models consume the measured
/// temporal frequency in hertz. Keeping this translation at the model boundary
/// prevents a server upgrade from silently changing an existing model's input
/// distribution.
pub fn compatible_dominant_frequency(
model_version: u32,
temporal_frequency_hz: f64,
amplitudes: &[f64],
) -> f64 {
if model_version >= 2 {
return if temporal_frequency_hz.is_finite() {
temporal_frequency_hz.max(0.0)
} else {
0.0
};
}
amplitudes
.iter()
.enumerate()
.filter(|(_, amplitude)| amplitude.is_finite())
.max_by(|(_, left), (_, right)| {
left.partial_cmp(right)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(index, _)| index as f64 * 0.05)
.unwrap_or(0.0)
}
/// Compute statistical features from raw subcarrier amplitudes.
fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
if amps.is_empty() {
@ -642,3 +675,21 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
pub fn model_path() -> PathBuf {
PathBuf::from("data/adaptive_model.json")
}
#[cfg(test)]
mod compatibility_tests {
use super::compatible_dominant_frequency;
#[test]
fn version_one_keeps_legacy_subcarrier_proxy() {
let amplitudes = [1.0, 2.0, 8.0, 3.0];
assert_eq!(compatible_dominant_frequency(1, 1.25, &amplitudes), 0.10);
}
#[test]
fn version_two_uses_measured_temporal_frequency() {
let amplitudes = [1.0, 20.0, 2.0];
assert_eq!(compatible_dominant_frequency(2, 1.25, &amplitudes), 1.25);
assert_eq!(compatible_dominant_frequency(2, f64::NAN, &amplitudes), 0.0);
}
}

View file

@ -491,6 +491,10 @@ const READ_SAFE_MUTATIONS: &[&str] = &[
// Capture and calibration: create data, never destroy it.
"/api/v1/calibration/start",
"/api/v1/calibration/stop",
// Promotion consumes only server measured held out evidence and stores a
// bounded privacy reduced prior. Cancel discards only an unfinished run.
"/api/v1/calibration/bootstrap/promote",
"/api/v1/calibration/cancel",
"/api/v1/pose/calibrate",
"/api/v1/recording/start",
"/api/v1/recording/stop",
@ -1970,6 +1974,7 @@ mod scope_gate_polarity_tests {
(Method::DELETE, "/api/v1/models/m1"),
(Method::DELETE, "/api/v1/recording/r1"),
(Method::POST, "/api/v1/config/ground-truth"),
(Method::POST, "/api/v1/calibration/reset"),
] {
assert_eq!(
required_scope_for(&m, p),

View file

@ -0,0 +1,532 @@
//! Local, privacy reduced empty room bootstrap model persistence.
//!
//! A bootstrap image is deliberately lower authority than a completed room
//! calibration. It can suppress background perturbations while the server
//! starts, but restoring it never authorizes calibrated evidence or vitals.
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use wifi_densepose_signal::ruvsense::field_model::{
FieldModel, FieldModelError, FieldModelSnapshotV1,
};
pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v2";
pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only";
pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12;
pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10;
pub const BOOTSTRAP_VALIDATION_MIN_SPACING_MS: u64 = 1_000;
pub const BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS: u64 = 4_000;
const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576;
/// Exact CSI layout used to train the persisted empty room model.
///
/// Restoring code must require incoming frames to match this binding before
/// the bootstrap image can influence inference.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BootstrapCsiGrid {
pub n_subcarriers: u16,
pub ppdu_type: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct BootstrapPayloadV2 {
schema: String,
authority: String,
installation_binding_sha256: String,
source_node_ids: Vec<u8>,
source_grid: BootstrapCsiGrid,
source_model_id: String,
created_at_unix_ms: u64,
expires_at_unix_ms: u64,
field_model: FieldModelSnapshotV1,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct BootstrapImageV2 {
payload: BootstrapPayloadV2,
content_sha256: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BootstrapImageRawV2 {
payload: Box<RawValue>,
content_sha256: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BootstrapBaselineMetadata {
pub authority: &'static str,
pub source_node_ids: Vec<u8>,
pub source_grid: BootstrapCsiGrid,
pub source_model_id: String,
pub created_at_unix_ms: u64,
pub expires_at_unix_ms: u64,
pub content_sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BootstrapValidationSample {
pub fresh_tick: bool,
pub calibrated_empty: bool,
pub vital_signs_absent: bool,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
pub struct BootstrapValidationResult {
pub sample_count: usize,
pub empty_sample_count: usize,
pub stale_sample_count: usize,
pub vital_sign_sample_count: usize,
pub passed: bool,
}
#[derive(Debug, Error)]
pub enum BootstrapBaselineError {
#[error("a stable installation_id is required for a local bootstrap baseline")]
MissingInstallationId,
#[error("bootstrap baseline path is invalid")]
InvalidPath,
#[error("bootstrap baseline file is too large")]
FileTooLarge,
#[error("bootstrap baseline image is malformed: {0}")]
Malformed(String),
#[error("bootstrap baseline belongs to another installation")]
InstallationMismatch,
#[error("bootstrap baseline has expired")]
Expired,
#[error("bootstrap baseline digest does not verify")]
DigestMismatch,
#[error("field model snapshot is invalid: {0}")]
FieldModel(#[from] FieldModelError),
#[error("bootstrap baseline storage failed: {0}")]
Io(#[from] std::io::Error),
#[error("bootstrap baseline serialization failed: {0}")]
Json(#[from] serde_json::Error),
}
pub fn path_in(data_dir: &Path) -> PathBuf {
data_dir.join("calibration").join("bootstrap-empty-v1.json")
}
pub fn installation_binding(installation_id: &str) -> Result<String, BootstrapBaselineError> {
let installation_id = installation_id.trim();
if installation_id.is_empty() || installation_id.len() > 128 {
return Err(BootstrapBaselineError::MissingInstallationId);
}
Ok(hex_sha256(installation_id.as_bytes()))
}
pub fn evaluate_validation(samples: &[BootstrapValidationSample]) -> BootstrapValidationResult {
let empty_sample_count = samples
.iter()
.filter(|sample| sample.fresh_tick && sample.calibrated_empty)
.count();
let stale_sample_count = samples.iter().filter(|sample| !sample.fresh_tick).count();
let vital_sign_sample_count = samples
.iter()
.filter(|sample| !sample.vital_signs_absent)
.count();
BootstrapValidationResult {
sample_count: samples.len(),
empty_sample_count,
stale_sample_count,
vital_sign_sample_count,
passed: samples.len() == BOOTSTRAP_VALIDATION_SAMPLES
&& empty_sample_count >= BOOTSTRAP_VALIDATION_MIN_EMPTY
&& stale_sample_count == 0
&& vital_sign_sample_count == 0,
}
}
pub fn store(
path: &Path,
installation_id: &str,
source_node_ids: &[u8],
source_grid: BootstrapCsiGrid,
source_model_id: &str,
created_at_unix_ms: u64,
field_model: &FieldModel,
) -> Result<BootstrapBaselineMetadata, BootstrapBaselineError> {
let snapshot = field_model.export_snapshot()?;
let calibrated_at_ms = snapshot.modes.calibrated_at_us / 1_000;
let expiry_ms = (snapshot.config.baseline_expiry_s * 1_000.0) as u64;
let payload = BootstrapPayloadV2 {
schema: BOOTSTRAP_BASELINE_SCHEMA.to_string(),
authority: BOOTSTRAP_BASELINE_AUTHORITY.to_string(),
installation_binding_sha256: installation_binding(installation_id)?,
source_node_ids: source_node_ids.to_vec(),
source_grid,
source_model_id: source_model_id.to_string(),
created_at_unix_ms,
expires_at_unix_ms: calibrated_at_ms.saturating_add(expiry_ms),
field_model: snapshot,
};
validate_payload(&payload, installation_id, created_at_unix_ms)?;
let content_sha256 = payload_digest(&payload)?;
let image = BootstrapImageV2 {
payload,
content_sha256,
};
let bytes = serde_json::to_vec(&image)?;
if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
atomic_write_private(path, &bytes)?;
Ok(metadata(&image))
}
pub fn load(
path: &Path,
installation_id: &str,
current_unix_ms: u64,
) -> Result<(FieldModel, BootstrapBaselineMetadata), BootstrapBaselineError> {
let file_metadata = fs::symlink_metadata(path)?;
if file_metadata.file_type().is_symlink() || !file_metadata.is_file() {
return Err(BootstrapBaselineError::InvalidPath);
}
if file_metadata.len() > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
let mut bytes = Vec::with_capacity(file_metadata.len() as usize);
OpenOptions::new()
.read(true)
.open(path)?
.take(BOOTSTRAP_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
// Verify the exact payload bytes written by storage before decoding the
// floating point model. Equivalent JSON number spellings may serialize
// differently, so decode and reserialize is not an integrity contract.
let raw_image: BootstrapImageRawV2 = serde_json::from_slice(&bytes)?;
if hex_sha256(raw_image.payload.get().as_bytes()) != raw_image.content_sha256 {
return Err(BootstrapBaselineError::DigestMismatch);
}
let payload: BootstrapPayloadV2 = serde_json::from_str(raw_image.payload.get())?;
validate_payload(&payload, installation_id, current_unix_ms)?;
let image = BootstrapImageV2 {
payload,
content_sha256: raw_image.content_sha256,
};
let model = FieldModel::from_snapshot(
image.payload.field_model.clone(),
current_unix_ms.saturating_mul(1_000),
)?;
Ok((model, metadata(&image)))
}
pub fn remove(path: &Path) -> Result<bool, BootstrapBaselineError> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(BootstrapBaselineError::InvalidPath);
}
fs::remove_file(path)?;
Ok(true)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn validate_payload(
payload: &BootstrapPayloadV2,
installation_id: &str,
current_unix_ms: u64,
) -> Result<(), BootstrapBaselineError> {
if payload.schema != BOOTSTRAP_BASELINE_SCHEMA
|| payload.authority != BOOTSTRAP_BASELINE_AUTHORITY
|| payload.source_model_id.is_empty()
|| payload.source_model_id.len() > 128
|| payload.source_node_ids.len() != 1
|| !(1..=2_048).contains(&payload.source_grid.n_subcarriers)
|| payload.source_grid.ppdu_type > 3
|| payload.created_at_unix_ms == 0
|| payload.expires_at_unix_ms <= payload.created_at_unix_ms
{
return Err(BootstrapBaselineError::Malformed(
"invalid schema, authority, identity, node set, source grid, or lifetime".into(),
));
}
if payload.installation_binding_sha256 != installation_binding(installation_id)? {
return Err(BootstrapBaselineError::InstallationMismatch);
}
if current_unix_ms > payload.expires_at_unix_ms {
return Err(BootstrapBaselineError::Expired);
}
Ok(())
}
fn payload_digest(payload: &BootstrapPayloadV2) -> Result<String, serde_json::Error> {
Ok(hex_sha256(&serde_json::to_vec(payload)?))
}
fn metadata(image: &BootstrapImageV2) -> BootstrapBaselineMetadata {
BootstrapBaselineMetadata {
authority: BOOTSTRAP_BASELINE_AUTHORITY,
source_node_ids: image.payload.source_node_ids.clone(),
source_grid: image.payload.source_grid,
source_model_id: image.payload.source_model_id.clone(),
created_at_unix_ms: image.payload.created_at_unix_ms,
expires_at_unix_ms: image.payload.expires_at_unix_ms,
content_sha256: image.content_sha256.clone(),
}
}
fn hex_sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), BootstrapBaselineError> {
let parent = path.parent().ok_or(BootstrapBaselineError::InvalidPath)?;
fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or(BootstrapBaselineError::InvalidPath)?;
let mut nonce = [0_u8; 8];
OsRng.fill_bytes(&mut nonce);
let temporary = parent.join(format!(
".{file_name}.{:016x}.tmp",
u64::from_le_bytes(nonce)
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let write_result = (|| -> Result<(), std::io::Error> {
let mut file = options.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
fs::rename(&temporary, path)?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig;
const TEST_GRID: BootstrapCsiGrid = BootstrapCsiGrid {
n_subcarriers: 192,
ppdu_type: 2,
};
fn completed_model(now_us: u64) -> FieldModel {
let mut model = FieldModel::new(FieldModelConfig {
n_links: 1,
n_subcarriers: 4,
n_modes: 2,
min_calibration_frames: 3,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 3_600.0,
})
.unwrap();
for index in 0..3 {
model
.feed_calibration(&[vec![1.0 + index as f64, 2.0, 3.0, 4.0]])
.unwrap();
}
model.finalize_calibration(now_us, 7).unwrap();
model
}
#[test]
fn round_trip_is_installation_bound_and_contains_no_raw_frames() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
let stored = store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let text = String::from_utf8(fs::read(&path).unwrap()).unwrap();
assert!(!text.contains("home-a"));
assert!(!text.contains("raw_csi"));
assert_eq!(stored.source_grid, TEST_GRID);
let (restored, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
assert_eq!(stored, loaded);
assert_eq!(
restored.export_snapshot().unwrap(),
model.export_snapshot().unwrap()
);
assert!(matches!(
load(&path, "home-b", now_ms + 1),
Err(BootstrapBaselineError::InstallationMismatch)
));
}
#[test]
fn tamper_and_expiry_fail_closed() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let mut image: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
image["payload"]["source_grid"]["n_subcarriers"] = serde_json::json!(64);
fs::write(&path, serde_json::to_vec(&image).unwrap()).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 1),
Err(BootstrapBaselineError::DigestMismatch)
));
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 3_600_001),
Err(BootstrapBaselineError::Expired)
| Err(BootstrapBaselineError::FieldModel(
FieldModelError::BaselineExpired { .. }
))
));
}
#[test]
fn store_rejects_invalid_source_grid() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
for source_grid in [
BootstrapCsiGrid {
n_subcarriers: 0,
ppdu_type: 0,
},
BootstrapCsiGrid {
n_subcarriers: 2_049,
ppdu_type: 0,
},
BootstrapCsiGrid {
n_subcarriers: 192,
ppdu_type: 4,
},
] {
assert!(matches!(
store(&path, "home-a", &[5], source_grid, "model", now_ms, &model,),
Err(BootstrapBaselineError::Malformed(_))
));
}
}
#[test]
fn store_requires_exactly_one_source_node() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
for source_node_ids in [&[][..], &[5, 7][..]] {
assert!(matches!(
store(
&path,
"home-a",
source_node_ids,
TEST_GRID,
"model",
now_ms,
&model,
),
Err(BootstrapBaselineError::Malformed(_))
));
}
}
#[test]
fn source_grid_is_required_when_loading_a_v2_image() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let mut image: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
image["payload"]
.as_object_mut()
.unwrap()
.remove("source_grid");
let payload = serde_json::to_vec(&image["payload"]).unwrap();
let rewritten = format!(
"{{\"payload\":{},\"content_sha256\":\"{}\"}}",
String::from_utf8(payload.clone()).unwrap(),
hex_sha256(&payload)
);
fs::write(&path, rewritten).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 1),
Err(BootstrapBaselineError::Json(_))
));
}
#[test]
fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() {
assert_eq!(BOOTSTRAP_VALIDATION_MIN_SPACING_MS, 1_000);
assert_eq!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, 4_000);
assert!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS);
let passing = vec![
BootstrapValidationSample {
fresh_tick: true,
calibrated_empty: true,
vital_signs_absent: true,
};
BOOTSTRAP_VALIDATION_SAMPLES
];
assert!(evaluate_validation(&passing).passed);
let mut stale = passing.clone();
stale[0].fresh_tick = false;
assert!(!evaluate_validation(&stale).passed);
let mut vital = passing;
vital[0].vital_signs_absent = false;
assert!(!evaluate_validation(&vital).passed);
}
#[test]
fn restart_verifies_the_exact_stored_payload_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let original = fs::read_to_string(&path).unwrap();
let image: BootstrapImageRawV2 = serde_json::from_str(&original).unwrap();
let rewritten_payload = image.payload.get().replacen(
"\"min_calibration_duration_s\":0.0",
"\"min_calibration_duration_s\":0.00",
1,
);
assert_ne!(rewritten_payload, image.payload.get());
let digest = hex_sha256(rewritten_payload.as_bytes());
let rewritten =
format!("{{\"payload\":{rewritten_payload},\"content_sha256\":\"{digest}\"}}");
fs::write(&path, rewritten).unwrap();
let (_, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
assert_eq!(loaded.content_sha256, digest);
}
}

View file

@ -178,7 +178,12 @@ pub struct Args {
#[arg(long, value_name = "TYPE")]
pub build_index: Option<String>,
/// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...")
/// Node positions for multistatic fusion.
///
/// Two forms, mixable: "x,y,z;..." positional, where each entry's identity
/// is its index, and "node_id:x,y,z;..." explicit. Prefer the explicit form
/// unless the fleet is numbered 0,1,2,... — otherwise "the third entry" and
/// "node 3" are different nodes and nothing in the string says which is meant.
#[arg(long, env = "SENSING_NODE_POSITIONS")]
pub node_positions: Option<String>,

View file

@ -11,6 +11,141 @@ use crate::vital_signs::VitalSigns;
const EDGE_MAX_PERSONS: u8 = 4;
/// Estimate a real temporal motion frequency from recent CSI amplitude
/// history. Each frame is reduced to its mean amplitude, detrended, Hann
/// windowed, and evaluated on exact DFT bins.
pub fn estimate_temporal_dominant_frequency_hz(
frame_history: &VecDeque<Vec<f64>>,
sample_rate_hz: f64,
) -> f64 {
if !sample_rate_hz.is_finite() || sample_rate_hz <= 0.0 {
return 0.0;
}
let recent_frames: Vec<&Vec<f64>> = frame_history
.iter()
.rev()
.take(128)
.collect();
if recent_frames
.iter()
.any(|frame| frame.is_empty() || frame.iter().any(|value| !value.is_finite()))
{
return 0.0;
}
let samples: Vec<f64> = recent_frames
.into_iter()
.rev()
.map(|frame| frame.iter().sum::<f64>() / frame.len() as f64)
.collect();
let n = samples.len();
if n < 16 {
return 0.0;
}
let mean = samples.iter().sum::<f64>() / n as f64;
let upper_hz = 4.0_f64.min(sample_rate_hz * 0.45);
if upper_hz < 0.1 {
return 0.0;
}
let mut powers = Vec::new();
for bin in 1..=(n / 2) {
let frequency_hz = bin as f64 * sample_rate_hz / n as f64;
if !(0.1..=upper_hz).contains(&frequency_hz) {
continue;
}
let mut real = 0.0;
let mut imag = 0.0;
let angle_step = 2.0 * std::f64::consts::PI * bin as f64 / n as f64;
let (step_sin, step_cos) = angle_step.sin_cos();
let mut angle_cos = 1.0;
let mut angle_sin = 0.0;
for (index, sample) in samples.iter().enumerate() {
let window =
0.5 * (1.0 - (2.0 * std::f64::consts::PI * index as f64 / (n - 1) as f64).cos());
let value = (sample - mean) * window;
real += value * angle_cos;
imag -= value * angle_sin;
let next_cos = angle_cos * step_cos - angle_sin * step_sin;
angle_sin = angle_sin * step_cos + angle_cos * step_sin;
angle_cos = next_cos;
}
powers.push((frequency_hz, real * real + imag * imag));
}
if powers.is_empty() {
return 0.0;
}
let mean_power = powers.iter().map(|(_, power)| power).sum::<f64>() / powers.len() as f64;
let (peak_hz, peak_power) = powers
.into_iter()
.max_by(|left, right| {
left.1
.partial_cmp(&right.1)
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or((0.0, 0.0));
if mean_power <= f64::EPSILON || peak_power / mean_power < 2.0 {
0.0
} else {
peak_hz
}
}
#[cfg(test)]
mod temporal_frequency_tests {
use super::*;
#[test]
fn recovers_temporal_frequency_in_hertz() {
let sample_rate_hz = 20.0;
let target_hz = 1.2;
let mut history = VecDeque::new();
for sample in 0..100 {
let t = sample as f64 / sample_rate_hz;
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * target_hz * t).sin();
history.push_back(vec![value; 56]);
}
let measured = estimate_temporal_dominant_frequency_hz(&history, sample_rate_hz);
assert!((measured - target_hz).abs() <= 0.21, "measured {measured} Hz");
}
#[test]
fn abstains_on_short_or_flat_history() {
let short = VecDeque::from(vec![vec![20.0; 56]; 15]);
assert_eq!(estimate_temporal_dominant_frequency_hz(&short, 20.0), 0.0);
let flat = VecDeque::from(vec![vec![20.0; 56]; 100]);
assert_eq!(estimate_temporal_dominant_frequency_hz(&flat, 20.0), 0.0);
let mut corrupt = flat;
corrupt[50][4] = f64::NAN;
assert_eq!(estimate_temporal_dominant_frequency_hz(&corrupt, 20.0), 0.0);
}
#[test]
fn temporal_frequency_p95_stays_below_server_budget() {
let sample_rate_hz = 50.0;
let mut history = VecDeque::new();
for sample in 0..128 {
let t = sample as f64 / sample_rate_hz;
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * 1.2 * t).sin();
history.push_back(vec![value; 256]);
}
let mut timings = Vec::with_capacity(1_000);
for _ in 0..1_000 {
let start = std::time::Instant::now();
std::hint::black_box(estimate_temporal_dominant_frequency_hz(
std::hint::black_box(&history),
sample_rate_hz,
));
timings.push(start.elapsed());
}
timings.sort_unstable();
let p95 = timings[949];
eprintln!("temporal dominant frequency p95: {p95:?}");
assert!(p95 < std::time::Duration::from_millis(10), "p95 {p95:?}");
}
}
/// Person count is supporting evidence, never an independent occupancy claim.
/// Return zero and mark invalid for contradictory or out-of-range firmware.
fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) {
@ -469,14 +604,7 @@ pub fn extract_features_from_frame(
0.0
};
let peak_idx = frame
.amplitudes
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
let dominant_freq_hz = peak_idx as f64 * 0.05;
let dominant_freq_hz = estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz);
let threshold = mean_amp * 1.2;
let change_points = frame
@ -485,7 +613,9 @@ pub fn extract_features_from_frame(
.filter(|w| (w[0] < threshold) != (w[1] < threshold))
.count();
let temporal_motion_score = if let Some(prev_frame) = frame_history.back() {
// The caller appends the current frame before extraction, so the true
// predecessor is the second item from the back.
let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) {
let n_cmp = n_sub.min(prev_frame.len());
if n_cmp > 0 {
let diff_energy: f64 = (0..n_cmp)
@ -636,13 +766,18 @@ pub fn adaptive_override(
.back()
.map(|v| v.as_slice())
.unwrap_or(&[]);
let dominant_frequency = adaptive_classifier::compatible_dominant_frequency(
model.version,
features.dominant_freq_hz,
amps,
);
let feat_arr = adaptive_classifier::features_from_runtime(
&serde_json::json!({
"variance": features.variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
"dominant_freq_hz": features.dominant_freq_hz,
"dominant_freq_hz": dominant_frequency,
"change_points": features.change_points,
"mean_rssi": features.mean_rssi,
}),

View file

@ -40,6 +40,106 @@ const ENERGY_THRESH_3: f64 = 25.0;
/// reliable — genuine higher counts come from the multistatic fusion path.
const MAX_SINGLE_LINK_OCCUPANCY: usize = 3;
/// Low-authority comparison of the current source window with a restored
/// empty-room bootstrap image. This is background conformance only and cannot
/// authorize presence, person count, pose, identity, or vital signs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BootstrapBackgroundMatch {
pub matches_empty: bool,
pub score: Option<f64>,
pub normalized_residual_z: Option<f64>,
pub maturity: f64,
pub reliable: bool,
pub residual_energy: f64,
pub residual_energy_threshold: f64,
pub window_size: usize,
pub reference_window_count: usize,
}
fn mean_frame(frames: &[Vec<f64>]) -> Option<Vec<f64>> {
let width = frames.first()?.len();
if width == 0 {
return None;
}
let mut mean = vec![0.0_f64; width];
let mut count = 0_usize;
for frame in frames {
if frame.len() != width {
continue;
}
for (sum, value) in mean.iter_mut().zip(frame.iter()) {
*sum += value;
}
count += 1;
}
if count == 0 {
return None;
}
for value in &mut mean {
*value /= count as f64;
}
Some(mean)
}
fn perturbation_occupancy(field: &FieldModel, frames: &[Vec<f64>]) -> Option<usize> {
let adaptive_threshold = field.empty_room_residual_energy_threshold();
let frame = match adaptive_threshold {
Some(_) => mean_frame(frames)?,
None => frames.first()?.clone(),
};
let perturbation = field.extract_perturbation(&[frame]).ok()?;
let count = match adaptive_threshold {
Some(threshold) => {
let empty_threshold = threshold.max(1.0);
if perturbation.total_energy > empty_threshold * 25.0 {
3
} else if perturbation.total_energy > empty_threshold * 12.0 {
2
} else if perturbation.total_energy > empty_threshold {
1
} else {
0
}
}
None => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
};
Some(count)
}
/// Provenance for a count produced by the calibrated field model. Callers
/// that attach calibration evidence must never substitute the score heuristic
/// when the calibrated path cannot evaluate the observation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalibratedOccupancyMethod {
Eigenvalue,
PerturbationEnergy,
}
impl CalibratedOccupancyMethod {
pub const fn wire_name(self) -> &'static str {
match self {
Self::Eigenvalue => "field_model_eigenvalue_v1",
Self::PerturbationEnergy => "field_model_perturbation_energy_v1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CalibratedOccupancy {
pub person_count: usize,
pub method: CalibratedOccupancyMethod,
}
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
/// This avoids the DimensionMismatch error when feeding single-frame observations.
pub fn single_link_config() -> FieldModelConfig {
@ -49,6 +149,18 @@ pub fn single_link_config() -> FieldModelConfig {
}
}
/// Resolve the model status at the observation wall clock. `FieldModel::status`
/// records the state at collection or restore time and does not advance as a
/// long-running server crosses the stale and expiry boundaries.
pub fn calibration_status_at(field: &FieldModel, observed_at_us: u64) -> CalibrationStatus {
match field.status() {
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting => field.status(),
CalibrationStatus::Fresh | CalibrationStatus::Stale | CalibrationStatus::Expired => {
field.check_freshness(observed_at_us)
}
}
}
/// Estimate occupancy using the FieldModel when calibrated, falling back
/// to the score-based heuristic otherwise.
///
@ -58,16 +170,17 @@ pub fn single_link_config() -> FieldModelConfig {
pub fn occupancy_or_fallback(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
smoothed_score: f64,
prev_count: usize,
) -> usize {
match field.status() {
match calibration_status_at(field, observed_at_us) {
CalibrationStatus::Fresh | CalibrationStatus::Stale => {
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.cloned()
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
@ -87,82 +200,197 @@ pub fn occupancy_or_fallback(
// Fallback: perturbation energy thresholds.
// FieldModel expects [n_links][n_subcarriers] — we use n_links=1.
let observation = vec![frames[0].clone()];
match field.extract_perturbation(&observation) {
Ok(perturbation) => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
Err(_) => score_to_person_count(smoothed_score, prev_count),
}
perturbation_occupancy(field, &frames)
.unwrap_or_else(|| score_to_person_count(smoothed_score, prev_count))
}
_ => score_to_person_count(smoothed_score, prev_count),
}
}
/// Feed the latest frame to the FieldModel during calibration collection.
/// Return true only when a restored low-authority bootstrap model has a full
/// runtime window and that model resolves the window as empty. This helper is
/// deliberately one-way: a bootstrap prior may suppress an obvious background
/// false positive, but cannot authorize presence or vital signs.
pub fn bootstrap_empty_prior_applies(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> bool {
bootstrap_background_match(field, frame_history, observed_at_us)
.is_some_and(|result| result.matches_empty)
}
/// Score the current canonical runtime window against the learned empty-room
/// residual references. Legacy models without scalar references preserve the
/// binary match but intentionally return no score.
pub fn bootstrap_background_match(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<BootstrapBackgroundMatch> {
if frame_history.len() < OCCUPANCY_WINDOW
|| field.check_freshness(observed_at_us) != CalibrationStatus::Fresh
{
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
let result = field.empty_room_match(&frames)?;
Some(BootstrapBackgroundMatch {
matches_empty: result.matches_empty,
score: result.score,
normalized_residual_z: result.normalized_residual_z,
maturity: result.maturity,
reliable: result.reliable,
residual_energy: result.residual_energy,
residual_energy_threshold: result.residual_energy_threshold,
window_size: result.window_size,
reference_window_count: result.reference_window_count,
})
}
/// Produce a fail-closed occupancy result from a fresh calibrated model.
pub fn calibrated_occupancy(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<CalibratedOccupancy> {
if field.check_freshness(observed_at_us) != CalibrationStatus::Fresh {
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
return None;
}
if let Ok(person_count) = field.estimate_occupancy(&frames) {
return Some(CalibratedOccupancy {
person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY),
method: CalibratedOccupancyMethod::Eigenvalue,
});
}
Some(CalibratedOccupancy {
person_count: perturbation_occupancy(field, &frames)?,
method: CalibratedOccupancyMethod::PerturbationEnergy,
})
}
/// Feed the current CSI frame to the FieldModel during calibration.
///
/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame
/// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside
/// `feed_calibration`; without accepting the `Uncalibrated` state here the two
/// gates deadlock and the frame count never leaves 0 (calibration/start yields
/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest
/// frame as a single-link observation (n_links=1) and feeds it.
pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
/// an `Uncalibrated` model that nothing would ever advance). Edge-vitals
/// packets have no CSI observation and must never call this helper. The caller
/// owns the stateful sequence admission gate. Wraps the current frame as a
/// single-link observation (n_links=1) and feeds it.
pub fn maybe_feed_calibration(field: &mut FieldModel, amplitudes: &[f64]) -> bool {
if !matches!(
field.status(),
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
) {
return;
) || amplitudes.is_empty()
{
return false;
}
if let Some(latest) = frame_history.back() {
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(latest);
let observations = vec![canonical];
if let Err(e) = field.feed_calibration(&observations) {
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(amplitudes);
let observations = vec![canonical];
match field.feed_calibration(&observations) {
Ok(()) => true,
Err(e) => {
tracing::debug!("FieldModel calibration feed: {e}");
false
}
}
}
/// Parse node positions from a semicolon-delimited string.
/// One parsed `--node-positions` entry: a position, and the node id if the
/// operator named one explicitly.
///
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
/// Malformed entries are skipped with a warning log.
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
/// `node_id: None` means the entry was given positionally, so its identity is
/// its index in the list — which is only the same thing as a node id on a fleet
/// numbered `0, 1, 2, ...`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NodePositionEntry {
pub node_id: Option<u8>,
pub position: [f32; 3],
}
/// Parse node positions, keeping any explicit node ids.
///
/// Two accepted forms, per entry:
///
/// * `x,y,z` — positional; identity is the entry's index.
/// * `node_id:x,y,z` — explicit; identity is the stated node id.
///
/// The explicit form exists because the positional one is only unambiguous on a
/// fleet whose ids happen to run `0, 1, 2, ...`. Anywhere else, "the third
/// entry" and "node 3" are different nodes, and nothing in the string says
/// which was meant. Naming the id removes the guess rather than relocating it.
///
/// Both forms may be mixed; a positional entry keeps its index as its identity.
/// Malformed entries are skipped with a warning rather than failing the boot —
/// a typo in one triplet should not take the server down.
pub fn parse_node_position_entries(input: &str) -> Vec<NodePositionEntry> {
if input.is_empty() {
return Vec::new();
}
input
.split(';')
.enumerate()
.filter_map(|(idx, triplet)| {
.filter_map(|(idx, raw)| {
let entry = raw.trim();
if entry.is_empty() {
return None;
}
// An explicit id is everything before the first ':'. Coordinates
// never contain one, so this cannot be ambiguous.
let (node_id, triplet) = match entry.split_once(':') {
Some((id_str, rest)) => match id_str.trim().parse::<u8>() {
Ok(id) => (Some(id), rest),
Err(_) => {
tracing::warn!(
"Skipping node position entry {idx}: '{entry}' has \
an unparseable node id before ':' (expected \
node_id:x,y,z with node_id in 0..=255)"
);
return None;
}
},
None => (None, entry),
};
let parts: Vec<&str> = triplet.split(',').collect();
if parts.len() != 3 {
tracing::warn!(
"Skipping malformed node position entry {idx}: '{triplet}' (expected x,y,z)"
"Skipping malformed node position entry {idx}: '{entry}' (expected x,y,z)"
);
return None;
}
match (
parts[0].parse::<f32>(),
parts[1].parse::<f32>(),
parts[2].parse::<f32>(),
parts[0].trim().parse::<f32>(),
parts[1].trim().parse::<f32>(),
parts[2].trim().parse::<f32>(),
) {
(Ok(x), Ok(y), Ok(z)) => Some([x, y, z]),
(Ok(x), Ok(y), Ok(z)) => Some(NodePositionEntry {
node_id,
position: [x, y, z],
}),
_ => {
tracing::warn!("Skipping unparseable node position entry {idx}: '{triplet}'");
tracing::warn!("Skipping unparseable node position entry {idx}: '{entry}'");
None
}
}
@ -170,10 +398,81 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
.collect()
}
/// Build the `node_id -> position` map the live `NodeInfo` output reads.
///
/// Identity is the explicit `node_id:` prefix when the operator gave one, and
/// the entry's list index otherwise.
///
/// This exists as a function rather than a loop inside `main()` because the bug
/// it fixes was invisible without a test: positions were inserted keyed by list
/// index and read back by `node_id`, so on any fleet not numbered `0, 1, 2, ...`
/// every lookup missed and silently fell through to the hardcoded
/// `[2.0, 0.0, 1.5]` that `--node-positions` exists to replace. The config
/// looked applied and did nothing.
pub fn node_positions_by_id(
entries: &[NodePositionEntry],
) -> std::collections::HashMap<u8, [f32; 3]> {
let mut map = std::collections::HashMap::new();
for (idx, e) in entries.iter().enumerate() {
let key = e.node_id.unwrap_or(idx as u8);
if let Some(prev) = map.insert(key, e.position) {
tracing::warn!(
"node position for node {key} given twice ({prev:?} then {:?}); the later entry wins",
e.position
);
}
}
map
}
/// Parse node positions from a semicolon-delimited string.
///
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
/// Malformed entries are skipped with a warning log.
///
/// Positional view, for consumers that index the list rather than key it by
/// node id. Since issue #1866 the fusion path is no longer one of them: it
/// takes the keyed map from [`node_positions_by_id`] via
/// `MultistaticFuser::set_node_positions_by_id()`. Use
/// [`parse_node_position_entries`] where identity is needed.
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
parse_node_position_entries(input)
.into_iter()
.map(|e| e.position)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_test_model() -> FieldModel {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 10,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 2.0,
};
let mut field = FieldModel::new(config).expect("field model");
for index in 0..20 {
let phase = index as f64 * 0.1;
let observation = vec![(0..56)
.map(|subcarrier| {
1.0 + subcarrier as f64 * 0.01 + (phase + subcarrier as f64 * 0.03).sin() * 0.01
})
.collect()];
field
.feed_calibration(&observation)
.expect("calibration frame");
}
field
.finalize_calibration(1_000_000, 7)
.expect("finalize calibration");
field
}
#[test]
fn test_parse_node_positions() {
let positions = parse_node_positions("0,0,1.5;3,0,1.5;1.5,3,1.5");
@ -203,6 +502,158 @@ mod tests {
assert_eq!(positions[0], [3.0, 4.0, 5.0]);
}
// ---- Explicit node ids (the indexing-mismatch regression) ----
/// Positional entries carry no id, so identity falls back to the index.
/// This is the ONLY case the old behaviour handled correctly.
#[test]
fn positional_entries_report_no_explicit_id() {
let e = parse_node_position_entries("0,0,1.5;3,0,1.5");
assert_eq!(e.len(), 2);
assert!(e.iter().all(|x| x.node_id.is_none()));
assert_eq!(e[1].position, [3.0, 0.0, 1.5]);
}
/// THE REGRESSION. Positions were stored keyed by list index and read back
/// by node_id, so on any fleet not numbered 0,1,2,... every lookup missed
/// and fell through to the hardcoded default the option exists to replace.
/// Non-sequential ids are what catch that.
#[test]
fn non_sequential_node_ids_are_preserved() {
let e = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9");
assert_eq!(e.len(), 3);
assert_eq!(e[0].node_id, Some(11));
assert_eq!(e[1].node_id, Some(12));
assert_eq!(e[2].node_id, Some(13));
assert_eq!(e[2].position, [7.0, 8.0, 9.0]);
// The index would have been 0,1,2 -- none of which is a real node here.
for (idx, entry) in e.iter().enumerate() {
assert_ne!(
entry.node_id,
Some(idx as u8),
"index and node id must not be conflated"
);
}
}
/// Two-digit ids, and a fleet re-homed to even numbers -- the shape of the
/// planned hardware check (nine boards moved from 0..8 to 0,2,4,...,16).
#[test]
fn two_digit_and_even_numbered_fleet_ids_parse() {
let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1";
let e = parse_node_position_entries(spec);
assert_eq!(e.len(), 9);
let ids: Vec<u8> = e.iter().map(|x| x.node_id.unwrap()).collect();
assert_eq!(ids, vec![0, 2, 4, 6, 8, 10, 12, 14, 16]);
assert_eq!(e[8].position, [8.0, 0.0, 1.0]);
}
/// A non-zero starting index must work too -- nothing requires node 0.
#[test]
fn a_fleet_that_does_not_start_at_zero_parses() {
let e = parse_node_position_entries("18:1.5,2.5,3.5");
assert_eq!(e[0].node_id, Some(18));
assert_eq!(e[0].position, [1.5, 2.5, 3.5]);
}
/// Mixing is allowed: an unkeyed entry keeps its index.
#[test]
fn mixed_forms_keep_their_own_identity() {
let e = parse_node_position_entries("0,0,1;7:9,9,9");
assert_eq!(e[0].node_id, None);
assert_eq!(e[1].node_id, Some(7));
}
/// An unparseable id is skipped rather than silently treated as positional
/// -- guessing there would reintroduce the exact ambiguity being removed.
#[test]
fn an_unparseable_node_id_is_skipped_not_guessed() {
let e = parse_node_position_entries("abc:1,2,3;5:4,5,6");
assert_eq!(e.len(), 1, "only the well-formed entry survives");
assert_eq!(e[0].node_id, Some(5));
}
/// 256 is out of range for a u8 node id and must not wrap to 0.
#[test]
fn an_out_of_range_node_id_is_rejected() {
let e = parse_node_position_entries("256:1,2,3");
assert!(e.is_empty(), "256 must not wrap to node 0");
}
/// The positional view stays byte-compatible for the fuser, which indexes
/// the list rather than keying it.
#[test]
fn the_positional_view_is_unchanged_by_explicit_ids() {
let p = parse_node_positions("11:1,2,3;12:4,5,6");
assert_eq!(p, vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
}
/// The regression itself, at the map that `NodeInfo` actually reads.
///
/// The old code inserted keyed by list index and looked up by node_id. On a
/// fleet numbered 11,12,13 that map holds keys 0,1,2, so every lookup misses
/// and the node reports the hardcoded default. This asserts both halves:
/// the real ids resolve, and the indices are absent.
#[test]
fn the_map_is_keyed_by_node_id_not_list_index() {
let entries = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9");
let map = node_positions_by_id(&entries);
assert_eq!(map.get(&11), Some(&[1.0, 2.0, 3.0]));
assert_eq!(map.get(&12), Some(&[4.0, 5.0, 6.0]));
assert_eq!(map.get(&13), Some(&[7.0, 8.0, 9.0]));
for idx in 0u8..3 {
assert!(
map.get(&idx).is_none(),
"list index {idx} must not be a key -- that is the bug: a lookup by node_id would miss and fall back to the hardcoded position"
);
}
}
/// Positional input keeps the old behaviour exactly: index becomes the key.
/// This is what made the bug invisible on a 0..8 fleet.
#[test]
fn positional_input_still_keys_by_index() {
let entries = parse_node_position_entries("0,0,1;3,0,1;6,0,1");
let map = node_positions_by_id(&entries);
assert_eq!(map.get(&0), Some(&[0.0, 0.0, 1.0]));
assert_eq!(map.get(&1), Some(&[3.0, 0.0, 1.0]));
assert_eq!(map.get(&2), Some(&[6.0, 0.0, 1.0]));
}
/// A re-homed fleet at 0,2,4,...,16 resolves on the real ids, and the odd
/// indices in between are absent.
#[test]
fn an_even_numbered_fleet_resolves_by_id() {
let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1";
let map = node_positions_by_id(&parse_node_position_entries(spec));
assert_eq!(map.len(), 9);
assert_eq!(map.get(&16), Some(&[8.0, 0.0, 1.0]));
assert_eq!(map.get(&10), Some(&[5.0, 0.0, 1.0]));
// Index-keyed would have produced 0..8; 1,3,5,7 are not real nodes here.
for odd in [1u8, 3, 5, 7] {
assert!(map.get(&odd).is_none(), "node {odd} does not exist in this fleet");
}
}
/// A duplicate id keeps the later entry rather than silently holding two.
#[test]
fn a_duplicate_node_id_keeps_the_later_entry() {
let map = node_positions_by_id(&parse_node_position_entries("4:1,1,1;4:2,2,2"));
assert_eq!(map.len(), 1);
assert_eq!(map.get(&4), Some(&[2.0, 2.0, 2.0]));
}
/// Whitespace around entries and coordinates is tolerated.
#[test]
fn whitespace_is_tolerated() {
let e = parse_node_position_entries(" 3 : 1.0 , 2.0 , 3.0 ");
assert_eq!(e.len(), 1);
assert_eq!(e[0].node_id, Some(3));
assert_eq!(e[0].position, [1.0, 2.0, 3.0]);
}
/// Regression: a freshly-started (`Uncalibrated`) field model must begin
/// collecting once frames arrive. Before the fix, `maybe_feed_calibration`
/// only fed while already `Collecting`, but only `feed_calibration` sets
@ -215,10 +666,7 @@ mod tests {
// n_subcarriers defaults to 56; one single-link frame of that width.
let frame = vec![0.5_f64; 56];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(frame);
maybe_feed_calibration(&mut field, &history);
assert!(maybe_feed_calibration(&mut field, &frame));
assert_eq!(
field.status(),
@ -231,8 +679,8 @@ mod tests {
"frame count must advance past 0"
);
// Subsequent frames keep accumulating while Collecting.
maybe_feed_calibration(&mut field, &history);
// A subsequent unique frame keeps accumulating while Collecting.
assert!(maybe_feed_calibration(&mut field, &frame));
assert_eq!(field.calibration_frame_count(), 2);
}
@ -247,10 +695,7 @@ mod tests {
// 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw.
let wide = vec![0.5_f64; 128];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(wide);
maybe_feed_calibration(&mut field, &history);
assert!(maybe_feed_calibration(&mut field, &wide));
assert_eq!(
field.status(),
@ -263,4 +708,129 @@ mod tests {
"wide frame must accumulate, not be silently dropped"
);
}
#[test]
fn maybe_feed_calibration_rejects_an_empty_observation() {
let mut field = FieldModel::new(single_link_config()).expect("field model");
assert!(!maybe_feed_calibration(&mut field, &[]));
assert_eq!(field.calibration_frame_count(), 0);
}
#[test]
fn calibrated_occupancy_is_fresh_model_only_and_normalizes_runtime_frames() {
let field = fresh_test_model();
let mut history = VecDeque::new();
history.push_back(vec![1.02; 128]);
let result =
calibrated_occupancy(&field, &history, 1_500_000).expect("fresh calibrated inference");
assert!(result.person_count <= MAX_SINGLE_LINK_OCCUPANCY);
assert!(matches!(
result.method,
CalibratedOccupancyMethod::Eigenvalue | CalibratedOccupancyMethod::PerturbationEnergy
));
assert_eq!(calibrated_occupancy(&field, &history, 4_000_000), None);
assert_eq!(
calibrated_occupancy(&field, &VecDeque::new(), 1_500_000),
None
);
}
#[test]
fn occupancy_falls_back_after_expiry_without_a_process_restart() {
let field = fresh_test_model();
let baseline = field.modes().unwrap().baseline[0].clone();
let history: VecDeque<Vec<f64>> =
(0..OCCUPANCY_WINDOW).map(|_| baseline.clone()).collect();
assert_eq!(field.status(), CalibrationStatus::Fresh);
assert_eq!(
calibration_status_at(&field, 1_500_000),
CalibrationStatus::Fresh
);
assert_eq!(
occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0),
0,
"a fresh field model may resolve its learned baseline as empty"
);
assert_eq!(field.status(), CalibrationStatus::Fresh);
assert_eq!(
calibration_status_at(&field, 4_000_000),
CalibrationStatus::Expired
);
assert_eq!(
occupancy_or_fallback(&field, &history, 4_000_000, 0.0, 0),
score_to_person_count(0.0, 0),
"an expired in-memory model must fall back even though its cached status is fresh"
);
}
#[test]
fn adaptive_runtime_reference_scores_empty_and_rejects_shift() {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 500,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 60.0,
};
let mut field = FieldModel::new(config).expect("field model");
let mut calibration = Vec::new();
for frame_index in 0..600 {
let time = frame_index as f64 * 0.071;
let frame: Vec<f64> = (0..56)
.map(|subcarrier| {
let carrier = subcarrier as f64;
18.0 + carrier * 0.08
+ (time + carrier * 0.13).sin() * 0.8
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
})
.collect();
field.feed_calibration(&[frame.clone()]).unwrap();
calibration.push(frame);
}
field.finalize_calibration(1_000_000, 7).unwrap();
let empty_history: VecDeque<Vec<f64>> = calibration[550..600].iter().cloned().collect();
let empty = calibrated_occupancy(&field, &empty_history, 1_500_000)
.expect("adaptive perturbation result");
assert_eq!(empty.person_count, 0);
let background = bootstrap_background_match(&field, &empty_history, 1_500_000)
.expect("background score");
assert!(background.matches_empty);
assert_eq!(background.reference_window_count, 12);
assert!(background.reliable);
assert_eq!(background.maturity, 0.6);
assert!(background.normalized_residual_z.is_some());
assert!(background
.score
.is_some_and(|score| (0.5..=1.0).contains(&score)));
let short_history: VecDeque<Vec<f64>> = empty_history.iter().take(49).cloned().collect();
assert!(!bootstrap_empty_prior_applies(
&field,
&short_history,
1_500_000
));
let shifted_history: VecDeque<Vec<f64>> = calibration[550..600]
.iter()
.map(|frame| {
frame
.iter()
.enumerate()
.map(|(index, value)| value + if index % 3 == 0 { 8.0 } else { -5.0 })
.collect()
})
.collect();
let shifted = calibrated_occupancy(&field, &shifted_history, 1_500_000)
.expect("shifted perturbation result");
assert!(shifted.person_count >= 1);
let shifted_background = bootstrap_background_match(&field, &shifted_history, 1_500_000)
.expect("shifted background result");
assert!(!shifted_background.matches_empty);
assert_eq!(shifted_background.score, Some(0.0));
assert!(shifted_background.reliable);
}
}

View file

@ -9,6 +9,7 @@
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
pub mod bearer_auth;
pub mod bootstrap_baseline;
pub mod browser_session;
pub mod ws_ticket;
pub mod cli;

File diff suppressed because it is too large Load diff

View file

@ -544,6 +544,10 @@ impl AppStateInner {
pub fn person_count(&self) -> usize {
use crate::csi::score_to_person_count;
use crate::field_bridge;
let observed_at_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_micros() as u64)
.unwrap_or(0);
match self.field_model.as_ref() {
Some(fm) => {
let history = if !self.frame_history.is_empty() {
@ -559,6 +563,7 @@ impl AppStateInner {
field_bridge::occupancy_or_fallback(
fm,
history,
observed_at_us,
self.smoothed_person_score,
self.prev_person_count,
)

View file

@ -346,6 +346,31 @@ impl VitalSignDetector {
self.frame_count = 0;
}
/// Retune temporal windows to an observed CSI clock. Changes smaller than
/// twenty percent are ignored to avoid resetting on normal arrival jitter.
/// A real clock change clears history so samples from different clocks are
/// never interpreted in one spectrum.
pub fn reconfigure_sample_rate(&mut self, sample_rate: f64) -> bool {
if !sample_rate.is_finite() || sample_rate < 1.0 {
return false;
}
let relative_change = (sample_rate - self.sample_rate).abs() / self.sample_rate.max(1.0);
if relative_change < 0.20 {
return false;
}
self.sample_rate = sample_rate;
self.breathing_capacity =
((sample_rate * self.breathing_window_secs) as usize).max(1);
self.heartbeat_capacity =
((sample_rate * self.heartbeat_window_secs) as usize).max(1);
self.reset();
true
}
pub fn sample_rate_hz(&self) -> f64 {
self.sample_rate
}
/// Current buffer fill levels for diagnostics.
/// Returns (breathing_len, breathing_capacity, heartbeat_len, heartbeat_capacity).
pub fn buffer_status(&self) -> (usize, usize, usize, usize) {
@ -842,6 +867,22 @@ mod tests {
assert_eq!(hb_len, 0);
}
#[test]
fn measured_clock_reconfigures_once_and_clears_mixed_history() {
let mut detector = VitalSignDetector::new(20.0);
let amp = vec![10.0; 56];
let phase = vec![0.0; 56];
for _ in 0..32 {
detector.process_frame(&amp, &phase);
}
assert!(!detector.reconfigure_sample_rate(18.0));
assert_eq!(detector.sample_rate_hz(), 20.0);
assert!(detector.reconfigure_sample_rate(12.0));
assert_eq!(detector.sample_rate_hz(), 12.0);
assert_eq!(detector.buffer_status(), (0, 360, 0, 180));
assert!(!detector.reconfigure_sample_rate(f64::NAN));
}
#[test]
fn test_vital_signs_default() {
let vs = VitalSigns::default();

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,7 @@
//! - `ruvector-attn-mincut` for cross-node spectrogram attention gating
//! - `ruvector-mincut` for person separation (DynamicMinCut)
use std::collections::HashMap;
use std::sync::Arc;
use super::cir::{CirConfig, CirEstimator};
@ -72,6 +73,12 @@ pub struct FusedSensingFrame {
/// Per-node multi-band frames (preserved for geometry computations).
pub node_frames: Vec<MultiBandCsiFrame>,
/// Node positions (x, y, z) in meters from deployment configuration.
///
/// Parallel to [`Self::node_frames`]: `node_positions[i]` is the
/// configured position of `node_frames[i].node_id`, resolved by identity
/// rather than by rank (issue #1866). Nodes with no configured position
/// appear at the origin. Index `i` is this cycle's cohort rank, which is
/// *not* a node id -- read the id from the paired frame.
pub node_positions: Vec<[f32; 3]>,
/// Number of active nodes contributing to this frame.
pub active_nodes: usize,
@ -147,8 +154,31 @@ impl MultistaticConfig {
/// maximum legitimate spread between two paired node frames in one cycle is
/// the full cycle length `tdm_total_slots × slot_duration_us` (last slot vs
/// first slot). The hard guard is set to that cycle length plus 20% jitter
/// headroom; the soft guard to ~⅓ of the cycle (a normal adjacent-slot pair
/// fuses cleanly, a near-full-cycle spread is flagged as loose alignment).
/// headroom; the soft guard to the schedule's own **designed stagger**,
/// `(slots - 1) * slot_duration_us` -- the offset between the first and
/// last slot, which is the spread a complete, healthy cycle produces.
///
/// # Why not ~⅓ of the cycle (issue #1866-adjacent, corrected)
///
/// The soft guard was previously `cycle_us / 3`, reasoned about as "a
/// normal *adjacent-slot pair* fuses cleanly". But [`MultistaticFuser::fuse_scored`]
/// does not compare pairs -- it takes `max(timestamp) - min(timestamp)`
/// across the **whole cohort**, so the spread it measures is always the
/// first-to-last offset, `(N - 1)` slots. Against that:
///
/// ```text
/// soft < stagger <=> N*slot/3 < (N-1)*slot <=> N/3 < N-1 <=> N > 1.5
/// ```
///
/// which holds for **every** `N >= 2`. The derived soft guard therefore
/// never covered the schedule it was derived from, at any fleet size, and
/// a complete healthy cycle was flagged as a `TimestampMismatch`
/// contradiction -- which demotes the BFLD privacy class one step
/// (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs.
///
/// The doc example below is the smallest demonstration: the 2-slot mesh
/// whose **measured** 18,194 µs spread motivated this constructor derived
/// a 12,000 µs soft guard, below its own motivating measurement.
///
/// `tdm_total_slots` is clamped to ≥ 1. All other fields take their
/// [`Default`] values.
@ -160,6 +190,9 @@ impl MultistaticConfig {
/// // reported 18,194 µs 2-slot spread.
/// let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000);
/// assert!(cfg.guard_interval_us >= 18_194);
/// // ...and the soft guard now covers it too, so a healthy 2-slot cycle
/// // is not recorded as a contradiction.
/// assert!(cfg.soft_guard_us >= 18_194);
/// ```
#[must_use]
pub fn for_tdm_schedule(tdm_total_slots: usize, slot_duration_us: u64) -> Self {
@ -167,8 +200,19 @@ impl MultistaticConfig {
let cycle_us = slots.saturating_mul(slot_duration_us);
// +20% jitter headroom on the full cycle.
let guard_interval_us = cycle_us.saturating_add(cycle_us / 5).max(1);
// Soft band at ~⅓ cycle, kept strictly below the hard guard.
let soft_guard_us = (cycle_us / 3).clamp(1, guard_interval_us.saturating_sub(1).max(1));
// Soft band at the schedule's designed stagger -- the first-to-last
// slot offset, which is what `fuse_scored` measures. Kept strictly
// below the hard guard, so a spread approaching a full cycle is still
// flagged as loose alignment.
// +20% jitter headroom, mirroring the hard guard: the *nominal* stagger
// is not the observed one. The 2-slot mesh below has an 18,000 us
// nominal stagger but a MEASURED 18,194 us spread, so a bare nominal
// bound would flag the very cycle it was derived from. Always strictly
// below the hard guard, since stagger < cycle.
let stagger_us = slots.saturating_sub(1).saturating_mul(slot_duration_us);
let soft_guard_us = stagger_us
.saturating_add(stagger_us / 5)
.clamp(1, guard_interval_us.saturating_sub(1).max(1));
Self {
guard_interval_us,
soft_guard_us,
@ -191,8 +235,16 @@ impl MultistaticConfig {
/// disable the CIR path and keep the legacy frequency-domain coherence only.
pub struct MultistaticFuser {
config: MultistaticConfig,
/// Node positions in 3D space (meters).
node_positions: Vec<[f32; 3]>,
/// Node positions in 3D space (meters), keyed by node id (issue #1866).
///
/// Keyed rather than indexed because [`Self::fuse`] receives a *compacted*
/// cohort: `node_frames_from_states_with_guard` drops nodes that are
/// stale, outside the timestamp guard, or have an empty history. Under
/// index addressing, one node missing a single beacon re-ranks every node
/// above it and silently hands them their neighbour's coordinates. Each
/// `MultiBandCsiFrame` carries its own `node_id`, so the position is
/// looked up by identity and a silent node moves nobody.
node_positions: HashMap<u8, [f32; 3]>,
/// Optional shared CIR estimator (ADR-134). `None` = legacy path only.
cir_estimator: Option<Arc<CirEstimator>>,
}
@ -212,7 +264,7 @@ impl MultistaticFuser {
pub fn new() -> Self {
Self {
config: MultistaticConfig::default(),
node_positions: Vec::new(),
node_positions: HashMap::new(),
cir_estimator: None,
}
}
@ -221,7 +273,7 @@ impl MultistaticFuser {
pub fn with_config(config: MultistaticConfig) -> Self {
Self {
config,
node_positions: Vec::new(),
node_positions: HashMap::new(),
cir_estimator: None,
}
}
@ -267,11 +319,39 @@ impl MultistaticFuser {
fuser
}
/// Set node positions for geometric diversity computations.
/// Set node positions from a positional list (**legacy compatibility**).
///
/// The list index is the node id: `positions[i]` belongs to node `i`. That
/// is the meaning the `--node-positions` flag has always carried when its
/// entries are given without an explicit `node_id:` prefix, so this
/// mapping is preserved exactly rather than reinterpreted.
///
/// This is only correct for fleets whose ids are dense and zero-based.
/// Prefer [`Self::set_node_positions_by_id`], which states the identity
/// instead of inferring it from ordering.
pub fn set_node_positions(&mut self, positions: Vec<[f32; 3]>) {
self.node_positions = positions
.into_iter()
.enumerate()
.map(|(idx, position)| (idx as u8, position))
.collect();
}
/// Set node positions keyed by node id (issue #1866, preferred).
///
/// The position travels with the board it was measured for. Nodes absent
/// from the map fuse at the origin, exactly as before; nodes present but
/// not currently reporting cost nothing and shift nobody.
pub fn set_node_positions_by_id(&mut self, positions: HashMap<u8, [f32; 3]>) {
self.node_positions = positions;
}
/// Return the configured position for one node id, if any.
#[must_use]
pub fn node_position(&self, node_id: u8) -> Option<[f32; 3]> {
self.node_positions.get(&node_id).copied()
}
/// Return the configured hard timestamp guard in microseconds.
///
/// Callers that assemble frames before invoking [`Self::fuse`] use this
@ -283,11 +363,33 @@ impl MultistaticFuser {
self.config.guard_interval_us
}
/// Return the current node positions.
pub fn node_positions(&self) -> &[[f32; 3]] {
/// Return the configured positions keyed by node id.
#[must_use]
pub fn node_positions_by_id(&self) -> &HashMap<u8, [f32; 3]> {
&self.node_positions
}
/// Return the configured positions as a dense id-indexed list
/// (**legacy compatibility view**).
///
/// Entry `i` is node `i`'s position, with unconfigured ids filled at the
/// origin, so this round-trips [`Self::set_node_positions`]. It is a view
/// of the configuration, *not* of what any particular fusion cycle used —
/// a cycle's cohort is compacted, so read
/// [`FusedSensingFrame::node_positions`] alongside its `node_frames` for
/// that.
#[must_use]
pub fn node_positions(&self) -> Vec<[f32; 3]> {
let Some(&max_id) = self.node_positions.keys().max() else {
return Vec::new();
};
let mut dense = vec![[0.0_f32, 0.0, 0.0]; max_id as usize + 1];
for (&id, &position) in &self.node_positions {
dense[id as usize] = position;
}
dense
}
/// Fuse multiple node frames into a single `FusedSensingFrame`.
///
/// When only one node is provided, falls back to single-node mode
@ -360,11 +462,19 @@ impl MultistaticFuser {
timestamps.sort_unstable();
let timestamp_us = timestamps[timestamps.len() / 2];
// Build node positions list, filling with origin for unknown nodes
let positions: Vec<[f32; 3]> = (0..n_nodes)
.map(|i| {
// Build the node positions list parallel to `node_frames`, resolving
// each entry by that frame's own `node_id` (issue #1866). The cohort
// handed to `fuse` is compacted -- stale, out-of-guard and
// empty-history nodes are already dropped -- so a frame's index here
// is its rank in this cycle, not its identity. Addressing by index
// gave every node above a gap its neighbour's coordinates whenever a
// single board missed a beacon. Unknown nodes still fill with the
// origin, unchanged.
let positions: Vec<[f32; 3]> = node_frames
.iter()
.map(|frame| {
self.node_positions
.get(i)
.get(&frame.node_id)
.copied()
.unwrap_or([0.0, 0.0, 0.0])
})
@ -964,6 +1074,88 @@ mod tests {
);
}
/// A derived schedule must not flag its own healthy cycle as a
/// contradiction.
///
/// Every prior assertion on `soft_guard_us` was *relational* (`soft <
/// hard`, `soft >= 1`), so nothing compared it to a spread the schedule
/// actually produces -- which is how `cycle/3` survived. `fuse_scored`
/// measures `max - min` across the whole cohort, i.e. `(N-1)` slots, so
/// that is what the soft guard must cover.
#[test]
fn derived_soft_guard_covers_the_schedules_own_stagger() {
for (slots, slot_us) in [
(2_usize, 18_000_u64), // the measured 2-slot mesh in the doc comment
(3, 27_000),
(4, 12_500),
(6, 33_300),
(9, 35_600), // a nine-node fleet: 320 ms cycle
(13, 36_900),
] {
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
// Nominal stagger; the derived guard must clear it WITH headroom,
// because observed spread runs slightly over nominal (18,194 us
// measured against an 18,000 us nominal 2-slot stagger).
let stagger = (slots as u64 - 1) * slot_us;
assert!(
cfg.soft_guard_us > stagger,
"{slots} slots x {slot_us} us: soft guard {} us is below the \
schedule's own first-to-last stagger of {stagger} us, so a \
complete healthy cycle is recorded as a TimestampMismatch \
contradiction and demotes the privacy class",
cfg.soft_guard_us
);
assert!(cfg.soft_guard_us < cfg.guard_interval_us);
}
}
/// End to end: a full, evenly-staggered cycle fuses with **no**
/// contradiction under its own derived config.
#[test]
fn a_healthy_full_cycle_raises_no_contradiction() {
let (slots, slot_us) = (9_usize, 35_600_u64);
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
let fuser = MultistaticFuser::with_config(cfg.clone());
// Node k transmits in slot k -- the schedule working exactly as designed.
let frames: Vec<MultiBandCsiFrame> = (0..slots)
.map(|k| make_node_frame(k as u8, k as u64 * slot_us, 56, 1.0))
.collect();
let (_fused, score) = fuser
.fuse_scored(&frames, 0.0)
.expect("a healthy cycle must fuse");
assert!(
!score.forces_privacy_demotion(),
"a complete {slots}-slot cycle is the schedule operating normally \
and must not demote the privacy class; flags: {:?}",
score.contradiction_flags
);
}
/// The soft band still does its job: a spread beyond the designed stagger
/// is flagged.
#[test]
fn a_spread_beyond_the_stagger_is_still_flagged() {
let (slots, slot_us) = (9_usize, 35_600_u64);
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
let hard = cfg.guard_interval_us;
let fuser = MultistaticFuser::with_config(cfg);
// Two nodes, inside the hard guard but wider than a full cycle's stagger.
let late = hard - 1;
let frames = vec![
make_node_frame(0, 0, 56, 1.0),
make_node_frame(1, late, 56, 1.0),
];
let (_f, score) = fuser.fuse_scored(&frames, 0.0).expect("within hard guard");
assert!(
score.forces_privacy_demotion(),
"a spread of {late} us exceeds the designed stagger and must still \
be recorded as loose alignment"
);
}
/// The derived soft guard stays strictly below the hard guard, and a
/// degenerate (0-slot) schedule clamps to a usable config.
#[test]
@ -1064,10 +1256,16 @@ mod tests {
#[test]
fn node_positions_set_and_retrieved() {
// The legacy positional setter round-trips through the dense view:
// index i in, index i out. Storage is keyed by id underneath, and
// `positions[i]` is documented to mean node i.
let mut fuser = MultistaticFuser::new();
let positions = vec![[0.0, 0.0, 1.0], [3.0, 0.0, 1.0]];
fuser.set_node_positions(positions.clone());
assert_eq!(fuser.node_positions(), &positions[..]);
assert_eq!(fuser.node_positions(), positions);
assert_eq!(fuser.node_position(0), Some([0.0, 0.0, 1.0]));
assert_eq!(fuser.node_position(1), Some([3.0, 0.0, 1.0]));
assert_eq!(fuser.node_position(2), None);
}
#[test]
@ -1083,6 +1281,255 @@ mod tests {
assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]); // default
}
/// Issue #1866 regressions: a fused contribution must carry the position
/// belonging to its own `node_id`, under every cohort shape the live
/// collector can produce.
///
/// `node_frames_from_states_with_guard` hands `fuse` a *compacted* cohort:
/// stale, out-of-guard and empty-history nodes are already gone. So the
/// index of a frame is its rank in this cycle, never its identity.
mod positions_keyed_by_node_id {
use super::*;
fn positions(entries: &[(u8, [f32; 3])]) -> HashMap<u8, [f32; 3]> {
entries.iter().copied().collect()
}
/// Every frame gets its own node's coordinates -- the core assertion.
fn assert_each_frame_owns_its_position(
configured: &HashMap<u8, [f32; 3]>,
frames: &[MultiBandCsiFrame],
) {
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured.clone());
let fused = fuser.fuse(frames).expect("cohort fuses");
assert_eq!(fused.node_positions.len(), frames.len());
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
let expected = configured
.get(&frame.node_id)
.copied()
.unwrap_or([0.0, 0.0, 0.0]);
assert_eq!(
*position, expected,
"node {} received the wrong position",
frame.node_id
);
}
}
#[test]
fn sparse_node_ids_each_get_their_own_position() {
// Real fleets use logical ids like 11/12/13. Under rank addressing
// every one of these resolved to the origin, because indices
// 11..13 do not exist in a 3-entry list.
let configured = positions(&[
(11, [1.0, 0.0, 0.0]),
(12, [2.0, 0.0, 0.0]),
(13, [3.0, 0.0, 0.0]),
]);
let frames = vec![
make_node_frame(11, 100, 56, 1.0),
make_node_frame(12, 101, 56, 1.0),
make_node_frame(13, 102, 56, 1.0),
];
assert_each_frame_owns_its_position(&configured, &frames);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let fused = fuser.fuse(&frames).expect("cohort fuses");
assert_eq!(fused.node_positions[0], [1.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[2], [3.0, 0.0, 0.0]);
}
#[test]
fn configuration_order_does_not_change_any_position() {
// Insertion order must be irrelevant: identity is stated, not
// inferred from where an entry sat in the config.
let ascending = positions(&[
(3, [3.0, 0.0, 0.0]),
(5, [5.0, 0.0, 0.0]),
(9, [9.0, 0.0, 0.0]),
]);
let reversed = positions(&[
(9, [9.0, 0.0, 0.0]),
(5, [5.0, 0.0, 0.0]),
(3, [3.0, 0.0, 0.0]),
]);
let frames = vec![
make_node_frame(3, 100, 56, 1.0),
make_node_frame(5, 101, 56, 1.0),
make_node_frame(9, 102, 56, 1.0),
];
let mut a = MultistaticFuser::new();
a.set_node_positions_by_id(ascending);
let mut b = MultistaticFuser::new();
b.set_node_positions_by_id(reversed);
assert_eq!(
a.fuse(&frames).expect("fuses").node_positions,
b.fuse(&frames).expect("fuses").node_positions
);
}
#[test]
fn a_silent_node_does_not_move_the_others() {
// THE REASON THIS IS KEYED BY ID. Node 2 misses a beacon and drops
// out of the cohort. Under rank addressing nodes 3 and 4 shift
// down one slot and silently inherit their neighbours' coordinates
// -- a fleet-wide position error caused by one dropped frame.
let configured = positions(&[
(1, [1.0, 0.0, 0.0]),
(2, [2.0, 0.0, 0.0]),
(3, [3.0, 0.0, 0.0]),
(4, [4.0, 0.0, 0.0]),
]);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let all_up = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(2, 101, 56, 1.0),
make_node_frame(3, 102, 56, 1.0),
make_node_frame(4, 103, 56, 1.0),
];
let two_silent = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(3, 102, 56, 1.0),
make_node_frame(4, 103, 56, 1.0),
];
let full = fuser.fuse(&all_up).expect("fuses");
let gapped = fuser.fuse(&two_silent).expect("fuses");
assert_eq!(full.node_positions[2], [3.0, 0.0, 0.0]);
assert_eq!(full.node_positions[3], [4.0, 0.0, 0.0]);
// Node 3 is now at rank 1 and node 4 at rank 2, but both keep
// their own coordinates.
assert_eq!(gapped.node_positions[0], [1.0, 0.0, 0.0]);
assert_eq!(gapped.node_positions[1], [3.0, 0.0, 0.0]);
assert_eq!(gapped.node_positions[2], [4.0, 0.0, 0.0]);
}
#[test]
fn join_and_leave_churn_never_reassigns_a_position() {
// Walk a node through leave -> rejoin. Every cohort shape must
// still pair each frame with its own configured coordinates.
let configured = positions(&[
(0, [0.0, 0.0, 0.0]),
(1, [1.0, 1.0, 0.0]),
(2, [2.0, 2.0, 0.0]),
(3, [3.0, 3.0, 0.0]),
]);
let cohorts: Vec<Vec<u8>> = vec![
vec![0, 1, 2, 3],
vec![0, 2, 3],
vec![2, 3],
vec![3],
vec![0, 3],
vec![0, 1, 2, 3],
];
for ids in cohorts {
let frames: Vec<MultiBandCsiFrame> = ids
.iter()
.enumerate()
.map(|(i, &id)| make_node_frame(id, 100 + i as u64, 56, 1.0))
.collect();
assert_each_frame_owns_its_position(&configured, &frames);
}
}
#[test]
fn a_configured_but_absent_node_costs_nothing() {
// Positions may be configured for boards that are not reporting.
// They must not consume a slot or displace anyone.
let configured = positions(&[
(1, [1.0, 0.0, 0.0]),
(7, [7.0, 0.0, 0.0]),
(42, [42.0, 0.0, 0.0]),
]);
let frames = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(7, 101, 56, 1.0),
];
assert_each_frame_owns_its_position(&configured, &frames);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let fused = fuser.fuse(&frames).expect("fuses");
assert_eq!(fused.node_positions.len(), 2);
assert_eq!(fused.node_positions[1], [7.0, 0.0, 0.0]);
}
#[test]
fn an_unconfigured_node_still_fuses_at_the_origin() {
// Unchanged fallback behaviour: unknown node -> origin, and it
// must not disturb its neighbours.
let configured = positions(&[(1, [1.0, 0.0, 0.0]), (3, [3.0, 0.0, 0.0])]);
let frames = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(2, 101, 56, 1.0),
make_node_frame(3, 102, 56, 1.0),
];
assert_each_frame_owns_its_position(&configured, &frames);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let fused = fuser.fuse(&frames).expect("fuses");
assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]);
}
#[test]
fn legacy_positional_setter_still_means_index_equals_node_id() {
// Documented compatibility mapping: a bare `--node-positions`
// list has always meant "the i-th entry is node i". Preserved
// exactly, so existing deployments are unaffected.
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
]);
let frames = vec![
make_node_frame(0, 100, 56, 1.0),
make_node_frame(1, 101, 56, 1.0),
make_node_frame(2, 102, 56, 1.0),
];
let fused = fuser.fuse(&frames).expect("fuses");
assert_eq!(fused.node_positions[0], [0.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[1], [1.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[2], [2.0, 0.0, 0.0]);
}
/// Negative control: the pre-fix behaviour must actually be gone.
///
/// Rank addressing over a dense zero-based config only diverges from
/// id addressing once the cohort is compacted, so this pins the exact
/// case the old code got wrong rather than restating the happy path.
#[test]
fn rank_addressing_would_have_failed_this_cohort() {
let configured = positions(&[
(0, [0.0, 0.0, 0.0]),
(1, [1.0, 0.0, 0.0]),
(2, [2.0, 0.0, 0.0]),
]);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
// Node 1 is silent; the cohort is [0, 2].
let frames = vec![
make_node_frame(0, 100, 56, 1.0),
make_node_frame(2, 101, 56, 1.0),
];
let fused = fuser.fuse(&frames).expect("fuses");
// Rank addressing would hand rank 1 the position of node 1.
assert_ne!(fused.node_positions[1], [1.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[1], [2.0, 0.0, 0.0]);
}
}
#[test]
fn geometric_diversity_single_node() {
assert_eq!(geometric_diversity(&[[0.0, 0.0, 0.0]]), 0.0);