mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-20 13:50:07 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d613a576ea | ||
|
|
97578ee401 | ||
|
|
e54b4171ca | ||
|
|
338c523217 | ||
|
|
224c9eb671 | ||
|
|
2deb6d2d64 | ||
|
|
82e05d0664 | ||
|
|
a2c662f9f7 | ||
|
|
48c6244d37 | ||
|
|
30404dd9cf | ||
|
|
c4021f9884 | ||
|
|
2227b42841 | ||
|
|
2e9e87c65c |
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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, &litudes), 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, &litudes), 1.25);
|
||||
assert_eq!(compatible_dominant_frequency(2, f64::NAN, &litudes), 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(&, &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
Reference in new issue
Block a user