Compare commits

...
13 Commits
26 changed files with 5094 additions and 220 deletions

No files matched your search

@@ -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.
@@ -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.
@@ -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.
+2
View File
@@ -96,6 +96,8 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [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
@@ -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.
+8 -1
View File
@@ -257,10 +257,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)
```
+14 -3
View File
@@ -263,8 +263,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;
}
@@ -330,13 +338,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
@@ -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)
+25 -5
View File
@@ -39,16 +39,35 @@ 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
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal
test_thermal test_csi_sanitize
# --- 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 +112,8 @@ 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
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -130,5 +149,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
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
@@ -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) {
@@ -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;
@@ -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;
}
@@ -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
}
}
@@ -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]
@@ -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
@@ -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
@@ -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);
}
}
@@ -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),
@@ -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);
}
}
@@ -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,
}),
@@ -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,51 +200,119 @@ 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
}
}
}
@@ -174,6 +355,33 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
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");
@@ -215,10 +423,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 +436,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 +452,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 +465,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);
}
}
@@ -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
@@ -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,
)
@@ -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