Compare commits

...
138 Commits
Author SHA1 Message Date
Dragan Spiridonov 5ef001b4fe Merge pull request #1904 from ruvnet/codex/firmware-delivery-20260911
fix(firmware): restore native C6 onboarding delivery
2026-09-27 11:43:55 +02:00
rUv 6524712900 Merge pull request #1890: storage partition for 8MB, compile node_log out on 4MB
Verified on a physical ESP32-C6 (4 MB, esp32c6 defaults, ESP-IDF v5.4):
- App image: 1,085,424 B on main, 1,042,368 B with this change (43,056 B smaller).
- The 'Failed to find FATFS partition storage' error seen on every 4 MB C6 boot is gone.
- Wi-Fi join, CSI init and CSI UDP delivery to the host were confirmed.
CI: 35/35 green.
2026-09-26 12:36:57 -05:00
rUv dd02efe2fe Merge pull request #1903 from ruvnet/codex/calibration-process-identity
fix(calibration): bind server receipts to room identity
2026-09-16 16:30:28 +00:00
ruv 7d15e9fb4a fix(calibration): register calibrated-presence-evidence schema via field_bridge
The new schema constant was defined and asserted as a raw string literal
directly in main.rs, which the semconv registry test scans and requires
every "ruview.*" literal there to be a registered attribute/event. This
isn't a tracing event, though -- it's a wire-schema tag structurally
identical to CALIBRATION_MODEL_RECEIPT_SCHEMA, which already lives in
field_bridge.rs specifically to stay out of that scan. Move the new
constant there and reference it by identifier from main.rs, matching the
existing precedent instead of hand-editing the generated semconv registry.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-16 11:53:26 -04:00
ruv 69106970dd Merge branch 'main-ref-tmp' into codex-calib-work 2026-09-16 11:37:53 -04:00
ruv 686e4bad00 Merge branch 'fix-1936-work' into codex-calib-work 2026-09-16 11:37:42 -04:00
ruv b713a23cac fix(calibration): preserve expired status instead of collapsing to none
calibration_status unconditionally overwrote status with "none" whenever
active was false, which also erased the legitimate "expired" status for
a calibration that ran long ago and has since expired -- losing the
distinction between "never calibrated" and "was calibrated, now expired".

Fixes the pre-existing failure in
calibration_expiry_tests::long_running_expiry_is_not_reported_active_for_runtime_or_bootstrap,
called out as a known, unrelated gap in PR #1936.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-16 11:37:31 -04:00
Dragan Spiridonov 0c1efda425 chore(firmware): pin exact IDF 5.4 C6 artifact 2026-09-16 09:58:16 +02:00
Dragan Spiridonov b1be91478c fix(firmware): restore native C6 onboarding delivery 2026-09-16 09:58:16 +02:00
Joe 6eb79cad26 chore: empty commit to re-trigger stale CI (row 35 sweep, main@45fc41ac) 2026-09-15 22:28:35 -04:00
rUv 45fc41ac4c Merge pull request #1944 from ruvnet/codex/realtek-csi-upstream-main
Add bounded RTL8721Dx RAC1 CSI ingest to current main
2026-09-15 19:14:52 +00:00
rUv 901cf16eb3 Merge pull request #1945 from ruvnet/codex/adr-348-collision-fix
Repair forecast ADR collision and RustSec lockfile gate
2026-09-15 18:56:09 +00:00
ruv af2d6981a3 ci: bound S3 8 MB image to 1200 KiB 2026-09-15 14:41:08 -04:00
ruv a04fbc4694 Resolve ADR 348 collision in upstream forecast CI 2026-09-15 14:41:08 -04:00
ruv dbaa3c7f52 ci: bound S3 8 MB image to 1200 KiB 2026-09-15 14:16:05 -04:00
ruv 6cf0356554 Update rustls lockfile for RUSTSEC-2026-0285 2026-09-15 14:07:11 -04:00
ruv b2ad3a65dc Resolve ADR 348 collision in upstream forecast CI 2026-09-15 14:06:31 -04:00
ruv 815384dcc4 Update rustls lockfile for RUSTSEC-2026-0285 2026-09-15 14:02:38 -04:00
ruv 8cd8742d2b Add bounded RTL8721Dx RAC1 CSI ingest to current RuView main 2026-09-15 13:57:01 -04:00
Dragan SpiridonovandRuflo & AQE dbf4788628 fix(calibration): bind the grid the radio keeps emitting, not the sparse minority
`accept_grid` locks each node onto the densest grid it has seen and rejects
sparser frames from the feature path -- on an ESP32-C6 the ~16% HT 64-bin
minority that arrives alongside HE-SU 256-bin. `select_calibration_grid`
ordered candidates by `max_gap_s` first, so it preferred whichever grid
looked temporally smoothest. A sparse trickle at a metronomic cadence has a
smaller worst-case gap than a busy stream with one scheduling hiccup, so
selection kept choosing the grid admission is designed to discard.

Measured on four ESP32-C6 nodes: a capture bound 64sc on node 11, every node
then locked onto 256sc, the bound grid went stale with no frame for five
minutes, frame_count froze at 10,997 and `calibration_grid_is_fresh` failed.
The capture could never finalize -- `collecting` for 858 s with both minimums
long since met, no error surfaced, and a flickering UI as the client retried.
The same rig bound 256sc on a different capture and finalized with five
baseline eigenvalues.

Order by subcarrier count first so selection agrees with admission, then fall
back to the previous gap/rate/ppdu ordering to choose among equally dense
candidates.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-15 14:48:08 +02:00
Dragan SpiridonovandRuflo & AQE cdad032296 fix(calibration): only the grid-bound radio may write the single-link baseline
A room binding may name several radios, and every one of them was admitted
into the field model. That model is single-link: one baseline, one set of
amplitude offsets. Averaging two radios' offsets into it flattens the
eigenstructure and leaves only a scalar energy threshold behind.

Measured on three ESP32-C6 nodes:

  4-node binding  baseline_eigenvalue_count 0, residual threshold 18.90,
                  empty room reported an occupant in 1162/1162 frames
  1-node binding  baseline_eigenvalue_count 5, residual threshold 3.65,
                  empty room absent 604/604, occupied present 616/616

`person_count_at` already refused to score a non-bound radio, calling it
"deterministic false occupancy from hardware-specific amplitude offsets" --
but only when exactly one node was bound. With more it fell through to the
shared mixed history, so the guard never fired in the case it described.

Restrict the baseline write, not the admission. Every bound radio is still
admitted and recorded as a contributor: `calibration_source_nodes_missing`
refuses to finalize until each one proves it is live on the frozen grid, so
gating them out of the feed path entirely deadlocks the capture -- measured
on hardware as 43,120 frames over 51 minutes stuck in `collecting` with
missing_source_node_ids=[12,13,14]. Only `maybe_feed_calibration` is
restricted to the grid-bound radio, and scoring follows that same radio
whatever the size of the room's node set.

This is the narrowing `bootstrap_baseline::store` already applies when it
persists `vec![binding.source_node_id]` as the frozen model source.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-15 14:09:33 +02:00
Dragan SpiridonovandRuflo & AQE 0decc5c3fa test(calibration): cover calibrated presence evidence identity and refusals
Assert the exact wire schema and every identity field the Mac app matches
against its model receipt, plus that evidence agrees with the server's own
person count. Pair the refusals (no receipt, bootstrap-only authority,
expired model) with the positive case so an absent value can never be read as
proof when the path is simply broken.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-14 18:54:15 +02:00
Dragan SpiridonovandRuflo & AQE d4a3c7f17f feat(calibration): emit calibrated presence evidence v2 on the sensing stream
The Mac app gates its held-out empty-room check on
`calibrated_presence_evidence` carrying
`ruview.calibration.calibrated-presence-evidence.v2`. No shipped server has
ever emitted that key, so the check fails on its first iteration with "No
fresh calibrated presence result matched this room's current model receipt",
`bootstrap/promote` is never called, and `bootstrap_baseline::store` -- the
only path that persists a calibration -- never runs. A completed 62-minute
field model is therefore lost on the next restart.

Emit the evidence bound to the active model receipt. `None` unless an
explicit calibration is fresh and the field model scores the observation
without a heuristic fallback, so a present value is always calibrated.

Extract the field-model scoring history into `scoring_history()` and reuse it
for both `person_count_at` and the evidence, so the published evidence cannot
disagree with the count derived from the same model.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-14 18:49:50 +02:00
Dragan Spiridonov 2c0abcc802 fix(calibration): separate receipt schema from telemetry literals 2026-09-11 20:29:42 +02:00
Dragan Spiridonov f1dbaac452 Merge remote-tracking branch 'origin/main' into codex/calibration-process-identity 2026-09-11 20:27:02 +02:00
rUv 33a9e90896 Merge pull request #1902 from ruvnet/feat/firmware-usb-onboarding-c6
firmware: USB onboarding on main + C6 console RX fix (0.8.12)
2026-09-11 13:32:02 +00:00
Dragan Spiridonov bbbb14169d fix(calibration): bind server receipts to room identity 2026-09-11 15:21:39 +02:00
ruv 4fbe58f736 fix(firmware): keep onboarding digest compatible with ESP-IDF 6 2026-09-11 09:19:33 -04:00
ruvandClaude Fable 5.1 a64e9e98fb fix(firmware): keep the C6 console RX alive and ship USB onboarding (0.8.12)
- mmwave_sensor: the default probe pins (UART1 TX=17/RX=18) put UART1 TX on
  GPIO17, which is U0RXD on the ESP32-C6. Console input was dead on every C6
  node even when no sensor was found, because uart_driver_delete() leaves the
  GPIO matrix routing in place. C6 now defaults to GPIO4/5 and any pin pair
  overlapping the console UART is refused.
- serial_onboarding_protocol: _POSIX_C_SOURCE for strtok_r under -std=c11
  -pedantic (host tests on glibc).
- main/CMakeLists: explicit led_strip REQUIRES so builds work with the
  component manager disabled (vendored components, offline builds).
- release_bins/c6-onboarding: 0.8.12 C6 images built with ESP-IDF v5.4.

Hardware-verified on an ESP32-C6 over cu.usbserial-3120 from the RuView Mac
app: flash -> RUVIEW_HELLO_V1 receipt -> RUVIEW_CONFIG_V1 -> node 3 live at
-34 dBm on the sensing server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqz356q3Qz6sscTrpy5Jrv
2026-09-11 08:58:46 -04:00
ruv 130afabae8 feat(firmware): add secure macOS USB onboarding
(cherry picked from commit 3373e79f38afef3a1430feeb78ae034e78777c66)
2026-09-10 19:25:13 -04:00
Joeandclaude-flow 36e4df95b7 feat(firmware): give node_log an 8MB home, compile it out on 4MB
node_log currently only has a FAT volume on the 16MB table
(partitions_16mb.csv), so it silently does nothing on the two chips
most of the fleet's OTA/CI surface actually exercises.

partitions_display.csv (8MB) ends at 0x600000 with 2.00 MB of flash
never allocated. Add a storage/fat partition filling exactly that
tail (0x600000 + 0x200000 = 0x800000, the full 8 MB with nothing left
over), so node_log has somewhere to mount on the s3/8mb profile.

partitions_4mb.csv has no equivalent room -- two 1.81 MB app slots
leave only 256 KB free, not enough for a FAT volume of any useful
size. Guard node_log.c's whole implementation on
CONFIG_ESPTOOLPY_FLASHSIZE_4MB and provide no-op stubs for every
entry point, so main.c's call sites need no #ifdef of their own.
Deliberately did not touch main/CMakeLists.txt: fatfs/wear_levelling
stay in REQUIRES, and with nothing left calling into them on 4MB,
--gc-sections drops both archives the same way it already drops
libespcoredump.a when coredump-to-flash is unused (measured at 0
bytes contribution).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
2026-09-10 13:21:59 -04:00
Faizanandgyrwb6vjbj-ctrl b370d4fcd4 fix: enable macOS RSSI sensing via CoreWLAN (#1845)
* fix: enable macOS RSSI sensing via CoreWLAN

* fix: abstain when macOS WiFi identity is unavailable

---------

Co-authored-by: gyrwb6vjbj-ctrl <253160963+gyrwb6vjbj-ctrl@users.noreply.github.com>
2026-09-10 00:15:02 +00:00
500cfe4787 feat(firmware): persistent on-node log and coredump-to-flash (#1878)
* feat(firmware): add the 16MB flash profile the C6 fleet runs on

The 4MB and display layouts have 1.875MB and 2MB OTA slots, sized for 4MB
and 8MB parts. A 16MB C6 (DevKitC-1 N16) has no layout in the tree at all,
so building for one meant hand-editing sdkconfig -- and a smaller table
written over a deployed 16MB node relocates NVS and destroys its
provisioning, which is why this belongs in the repo rather than in a
workstation's untracked sdkconfig.

Two 4MB OTA slots, plus a 64K coredump partition and an 8000K FAT volume
for on-node storage. Rollback is enabled so an OTA'd image that cannot
confirm itself is reverted by the bootloader.

Layered, not inherited: sdkconfig.defaults.esp32c6 keeps its 4MB layout so
the common 4MB devkit is never silently repartitioned.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

* feat(firmware): persistent on-node log and coredump-to-flash

A remote fault currently leaves no post-mortem. main.c decodes the reset
reason at boot and logs it to the CONSOLE -- which is exactly what a power
cycle destroys, and a power cycle is how a wedged node gets recovered.

The flash was already provisioned for this and the features were simply off:
partitions_16mb.csv has reserved a 64K `coredump` partition and an 8000K
`storage` FAT volume since the 16MB layout was written, neither had ever been
mounted or written, and the build defaulted to CONFIG_ESP_COREDUMP_ENABLE_TO_NONE.

- node_log.c/h: a 128 B-record ring on the storage volume. Boot records
  (reset reason + the previous session's final uptime + a health snapshot),
  periodic health samples, and event records. Ordering is boot_id + uptime_s
  because these boards have no RTC and nothing can be timestamped absolutely.
  Counters are stored RAW and differenced at read time, so a cadence change
  never invalidates old records. Retention, not wear, binds: 128 B every
  300 s is ~208 days in 8 MB, and the write rate is bounded explicitly.
- ap_rssi_dbm, the association link from esp_wifi_sta_get_ap_info. The
  adaptive controller's rssi_median_dbm is a CSI statistic, not the uplink,
  so a node with a bad uplink currently shows no signal for it anywhere.
- CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH, complementary rather than a
  substitute: a coredump catches a CRASH, the boot record catches a HANG
  followed by a watchdog reset.

node_log_init failing is non-fatal by design -- the module disables itself
and every entry point becomes a no-op. A logging volume that will not mount
must never take the fleet down with it.

csi_collector gains two gate accessors. This build selects its frame gate at
compile time, so both return CSI_GATE_NOT_CONFIGURABLE rather than a
plausible number: the log keeps a field of fixed width and meaning either
way, and an explicit "not applicable" is stored instead of something a
reader would later reason from as a real setting.

Built with ESP-IDF v5.4 for esp32c6 using
defaults + .esp32c6 + .16mb. Verified from the resulting artifacts: 16MB
table with coredump and storage present, COREDUMP_ENABLE_TO_FLASH set,
rollback enabled, image 0x10abd0 bytes in a 4M slot. NOT flashed to hardware
from this branch -- the equivalent code was exercised on a node-3 pilot, but
that is not evidence for this build.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

* docs(adr-362): reading the on-node log is on-demand, never streamed

The log engine lands with no consumer, so the access pattern was open. Records
the decision to pull a node's log only when a human asks -- after a hang or a
crash, or while troubleshooting that node -- and to keep nothing server-side.

The reasoning that decided it: the ring already survives a power cycle on the
node (~208 days of retention), so a server copy duplicates a durability
property that is already met; and airtime is this fleet's binding constraint,
measured at 91% consumed by two slow nodes, so a periodic drain across nine
boards would spend exactly the resource whose exhaustion you would open the
log to investigate. An instrument must not perturb what it measures.

Leaves the decode-where question open on purpose, with the argument for a raw
proxy stated: decoding server-side defines the record layout in both C and
Rust, and the pilot already paid once for a host/firmware disagreement about a
field's meaning.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

---------

Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-09-09 23:50:34 +00:00
f7c2c6587e feat(firmware): add the 16MB flash profile the C6 fleet runs on (#1877)
The 4MB and display layouts have 1.875MB and 2MB OTA slots, sized for 4MB
and 8MB parts. A 16MB C6 (DevKitC-1 N16) has no layout in the tree at all,
so building for one meant hand-editing sdkconfig -- and a smaller table
written over a deployed 16MB node relocates NVS and destroys its
provisioning, which is why this belongs in the repo rather than in a
workstation's untracked sdkconfig.

Two 4MB OTA slots, plus a 64K coredump partition and an 8000K FAT volume
for on-node storage. Rollback is enabled so an OTA'd image that cannot
confirm itself is reverted by the bootloader.

Layered, not inherited: sdkconfig.defaults.esp32c6 keeps its 4MB layout so
the common 4MB devkit is never silently repartitioned.


Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-09-09 23:31:03 +00:00
30391bfbd2 docs(adr-361): single-transmitter pairing -- hypothesis confirmed, fix not adopted (#1876)
A-B-A capture from the overnight run, ten 30 s windows per arm: pairing
fraction rises 0.5668 -> 0.7287 (Welch t=13.26, p<0.0001) when the fleet is
filtered to one transmitter, but paired observations/s falls 44% because
tx/s falls 56%. Fraction was the wrong figure of merit; not adopted.

Firmware changes (persistent on-node log, live filter_mac, and the node-3
pilot fixes) that produced this capture are held back pending a port against
main's restructured firmware/main/ tree (config_api.c and ota_update.c are
gone there).


Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-09-09 23:31:00 +00:00
rUv 00a42d7cda fix(sensing-server): don't let edge-vitals sentinel RSSI poison mean_rssi (#1882)
The ESP32 edge-vitals packet (0xC511_0002) carries a raw i8 RSSI byte with
no "valid" flag. When the edge pipeline hasn't sampled a real reading yet,
it has been observed sending near-zero sentinel values (-1, -2 dBm) that
are physically implausible for a WiFi link. These were pushed straight
into the node's rssi_history and the room's fused mean_rssi, producing a
one-tick reading of ~-2 dBm on node 3 while its own CSI path simultaneously
showed a real ~-50 dBm signal.

Add an is_plausible_rssi() guard (mirroring the existing
update_csi_fps_ema() plausibility-filter pattern) so an implausible RSSI
falls back to the node's last known-good reading instead of overwriting it.
2026-09-09 20:10:06 +00:00
rUv bd6f38e2df fix(engine): wire node positions into StreamingEngine's governed fuser (#1880)
StreamingEngine owns its own MultistaticFuser instance -- a different one
from wifi-densepose-sensing-server's AppState, which was fixed for this
same class of bug in #1860. Nothing ever called set_node_positions /
set_node_positions_by_id on this instance, so every FusedSensingFrame
produced by the governed path (fuse_scored_calibrated, which feeds
provenance and the witness) carried every node's position as the origin
regardless of deployment configuration.

Adds StreamingEngine::set_node_positions(HashMap<u8, [f32;3]>), delegating
to the fuser's existing set_node_positions_by_id. Also fixes a related
hazard in set_multistatic_config: it rebuilt the fuser wholesale
(MultistaticFuser::with_config(cfg)), which silently discarded any
positions configured before it. It now preserves them across the rebuild,
so callers don't need to remember a specific ordering between the two
setters.

Two regression tests per the issue's own acceptance criteria: one calling
fuse_scored_calibrated directly and asserting each fused contribution
carries its own node's configured position, and one asserting positions
survive a later set_multistatic_config call.

Fixes #1870.
2026-09-09 17:28:06 +00:00
rUv 203bd66daa Merge pull request #1879 from ruvnet/fix/hf-csi-embedding-metrics
fix(training): export the real contrastive lossHistory/duration, not a no-op baseline pass
2026-09-09 16:27:40 +00:00
ruv 2c3a924231 fix(training): export the real contrastive lossHistory/duration, not a no-op baseline pass
scripts/train-ruvllm.js ran two separate computations during contrastive
pretraining: ContrastiveTrainer.train() (a baseline pass that computes loss
but never updates the encoder) and a hand-written loop that actually applies
gradient updates to encoder.w2. The exported training-metrics.json took
lossHistory and contrastive.durationMs from the first (no-op) pass instead
of the second (real) one, so the published curve was flat/meaningless
(~0.1352 for all 20 epochs) and the duration was ~30x too short.

This did not affect finalLoss/improvement or the held-out temporal-triplet
accuracy metric published for this model on HuggingFace
(ruvnet/wifi-densepose-pretrained) -- those already came from the real loop.
It only affected the diagnostic curve/duration anyone auditing convergence
from the published JSON would see.

Also: config.json's training.steps field (epochs * triplets, technically
consistent but reads like an optimizer-step count) is now reported as
separate epochs/tripletUpdatesPerEpoch fields, and the exported version
bumped 1.0.0 -> 2.0.1 to match the "v2" naming already used elsewhere for
this file set.

Verified by re-running against the real capture
(data/recordings/overnight-1775217646.csi.jsonl): lossHistory now shows a
genuine decreasing curve (0.1037 -> 0.0654 across 20 epochs, matching the
already-published finalLoss exactly), durationMs reports the real ~40s
gradient-loop time instead of ~12s.

Model card updated with the same disclosure (docs/huggingface/MODEL_CARD.md)
plus a real, previously-undisclosed EWC forgetting-rate figure (32.97%,
present in training-metrics.json but not surfaced in the human-readable
card) and a corrected repro-script pointer.
2026-09-09 11:26:17 -04:00
rUv e5422d52e4 Merge pull request #1858 from clonea1/contrib/gate-experiment-findings
docs: measured comparison of three CSI frame-selection gates, and the harness
2026-09-09 13:10:02 +00:00
ruv 26e8ca3f39 Merge upstream/main, resolve README.md table-row conflict 2026-09-09 08:40:14 -04:00
rUv c407e738a3 Merge pull request #1872 from clonea1/contrib/tdm-soft-guard
fix(signal): the derived soft guard flagged the schedule it was derived from
2026-09-09 12:38:26 +00:00
rUv 755e9bca05 Merge pull request #1859 from clonea1/contrib/mesh-aligned-rate-gate
feat(firmware): align the CSI rate gate to mesh time
2026-09-09 12:38:23 +00:00
rUv 73e15a7d29 Merge pull request #1857 from clonea1/contrib/csi-fps-delta-domain
fix(server): recover the true CSI frame rate through bursty UDP delivery
2026-09-09 12:38:18 +00:00
rUv f38beff441 Merge pull request #1867 from ruvnet/feat/c6-xiao-antenna-select
feat(firmware): add XIAO ESP32-C6 antenna selection
2026-09-09 12:35:50 +00:00
rUv 830abd7e18 Merge pull request #1860 from clonea1/contrib/server-node-positions
fix(sensing-server): key node positions by node id, not by list position
2026-09-09 12:35:27 +00:00
rUv b296ea26a4 Merge pull request #1862 from clonea1/contrib/adr-345-per-link-csi
docs(adr): ADR-345 — per-link CSI attribution and node-to-node ranging
2026-09-09 12:35:23 +00:00
rUv 0b29902d8b Merge pull request #1861 from clonea1/contrib/firmware-runbook
docs(firmware): add the ESP32-C6 operational runbook
2026-09-09 12:35:19 +00:00
rUv b18b34e9f5 Merge pull request #1855 from clonea1/contrib/ignore-host-test-binaries
chore: ignore host-test binaries by shape, not by a list that goes stale
2026-09-09 12:35:15 +00:00
Joeandclaude-flow 169597a7d2 fix(signal): the derived soft guard flagged the schedule it was derived from
`MultistaticConfig::for_tdm_schedule` set the soft guard to `cycle_us / 3`,
reasoned about in its own doc comment as "a normal adjacent-slot pair fuses
cleanly". But `fuse_scored` does not compare pairs -- it takes
`max(timestamp) - min(timestamp)` across the whole cohort, so the spread it
measures is always the first-to-last offset of `(N - 1)` slots.

Against that bound:

    soft < stagger  <=>  N*slot/3 < (N-1)*slot  <=>  N/3 < N-1  <=>  N > 1.5

which holds for every N >= 2. The derived soft guard never covered the schedule
it was derived from, at any fleet size, so a complete healthy cycle was recorded
as a `TimestampMismatch` contradiction -- which demotes the BFLD privacy class
one step (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs.

The constructor's own doc example is the smallest demonstration. It exists to
accommodate a MEASURED 18,194 us spread on a 2-slot mesh, and asserts the hard
guard clears it -- but the soft guard it derives for that same mesh is
36,000/3 = 12,000 us, below the measurement that motivated the constructor.

Soft is now the designed stagger, `(slots - 1) * slot_duration_us`, plus the
same 20% jitter headroom the hard guard already takes. The headroom is not
cosmetic: the nominal 2-slot stagger is 18,000 us while the observed spread is
18,194 us, so a bare nominal bound would still flag its own motivating cycle.
`stagger < cycle` always, so the result stays strictly below the hard guard and
a spread approaching a full cycle is still flagged as loose alignment.

Why this survived: every existing assertion on `soft_guard_us` is relational
(`soft < hard`, `soft >= 1`). None ever compared it to a spread the schedule
actually produces. Three tests are added -- the derived guard clears the stagger
across 2..13 slots, a full evenly-staggered cycle raises no contradiction, and a
spread beyond the stagger is still flagged, so the fix cannot be mistaken for
disabling the check. The first two fail against `cycle / 3`; the third passes
either way by design.

Scope: this corrects the DERIVED path only. The hand-set defaults (60 ms hard /
20 ms soft) are unchanged and remain correct for the 2-slot mesh they were sized
against.

Software-only scope. No hardware-accuracy claim is made.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-09 00:23:58 -04:00
Joeandclaude-flow 0d1a247bfe fix(sensing-server): a silent node no longer moves its neighbours' coordinates
`MultistaticFuser::fuse` addressed node positions by the frame's index in the
cohort it was handed. That index is a rank, not an identity: the cohort from
`node_frames_from_states_with_guard` is compacted -- stale, out-of-guard and
empty-history nodes are dropped before `fuse` ever sees them. So one board
missing a beacon re-ranked every node above it and handed each of them its
neighbour's coordinates, silently and fleet-wide.

`MultiBandCsiFrame` already carries its own `node_id`, so the identity was
present at the boundary and simply unused. Positions are now stored keyed by
node id and resolved per frame by that id.

- `set_node_positions_by_id` takes the keyed map; `main` passes the same
  `node_positions_by_id` map that already feeds `NodeInfo`, so one parsed
  identity now serves both the metadata and the effectful path (#1860 fixed
  only the former).
- `set_node_positions(Vec)` is kept as a documented compatibility mapping:
  entry `i` is node `i`, which is exactly what a bare `--node-positions` list
  has always meant. `node_positions()` returns that dense view unchanged.
- `FusedSensingFrame::node_positions` stays parallel to `node_frames`, but each
  entry is resolved from its own frame's id, so the pairing survives compaction.

Behaviour change: with a bare (unprefixed) `--node-positions` list on a fleet
whose ids are not dense and zero-based, a node now reads the entry declared for
its id rather than the entry at its rank. That is the defect being fixed; ids
given explicitly as `node_id:x,y,z` are unaffected, and so is any fleet whose
ids are dense from zero with every node reporting.

Tests: sparse ids, reversed configuration order, a configured-but-absent node,
an unconfigured node, join/leave churn, the preserved legacy positional
mapping, and a negative control pinning the compacted-cohort case. Six of the
eight fail against the pre-fix lookup; the legacy-mapping test is expected to
pass both ways, and order-independence became structural with keyed storage.

Software-only scope. No hardware-accuracy claim is made.

Closes #1866
Refs #1860

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-08 19:55:07 -04:00
Dragan Spiridonov a4d49f76b9 feat(firmware): add XIAO C6 antenna selection 2026-09-08 14:12:41 +02:00
Joeandclaude-flow 51102b3baf docs(adr): ADR-345 — per-link CSI attribution and node-to-node ranging
Records why device-free position kept failing here, measured rather than
argued, and the join key built in response. The implementing PRs land without
this and are harder to read for it.

**Phase is unusable on this hardware.** The bistatic tier needed a signed
per-link radial velocity, which requires the common-mode component of CSI
phase. Measured across three ESP32-C6 nodes over ~40,000 frames, the
frame-to-frame change in common-mode phase is statistically indistinguishable
from uniform random: resultant length R = 0.011-0.032 against a 0.000
reference, sd 1.78-1.81 against pi/sqrt(3) = 1.814, no lag-1 autocorrelation.
A second, unwrap-free estimator agreed.

The mechanism is packet-detection timing quantization. Common-mode phase is
-2*pi*f_c*tau, and one ADC sample of jitter at 20 MHz is about 758 rad -- some
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
is default-off as a result.

**Amplitude works but had no parallax.** The three AP->node links leave a single
distant transmitter inside a 36.5 degree fan; scalar per-node energy weighting
closed with a clean negative result -- magnitude responds to proximity,
direction does not.

What follows from both: a position tier needs several transmitters seen from
several receivers, and fusing those requires knowing that two nodes observed
the SAME transmission. Arrival timestamps cannot say so -- the sink sees network
jitter, not capture time -- and the per-node sequence counter is private to each
node. Hence (tx_mac, rx_seq): the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver, so it names one
transmission without any coordination between receivers, no shared clock and no
negotiation.

Status is Proposed, deliberately. The measurement layer is implemented and
validated on hardware; localization is NOT built, and the ADR says so along
with the rest of its known limitations -- peer links are ~10x weaker than data
frames, three links is below the threshold for tomographic localization
regardless of quality, and no position tier consumes the link table.

References only ADR-018, ADR-060 and ADR-110, all already present here.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:38:40 -04:00
Joeandclaude-flow 977fa26731 docs: list the firmware runbook in the documentation index
Same reason as the operational content itself: a procedure nobody can find is a
procedure nobody follows. The README's documentation table is where a reader
looks, and the runbook was reachable only by browsing the firmware directory.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:03:09 -04:00
Joeandclaude-flow 2841b22cc9 docs: list the frame-selection study in the documentation index
The study was reachable only by knowing the file existed. Nothing in the repo
pointed at it, so a reader following the README would never learn that the
question had been measured at all.

Adds a row to the documentation table with the headline result and the fact
that the harness ships with it. Deliberately states both numbers -- the +31.7 pp
that seq bought and the +0.5 pp that mesh alignment did not -- so the index
entry cannot be read as advocacy for one setting.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:02:52 -04:00
Joeandclaude-flow 3fd3851633 docs(firmware): say why seq needs no sync and mesh alignment does
The two options differ in kind. Mesh alignment is an agreement between
receivers and inherits whatever the sync topology does; selecting by rx_seq
needs no agreement, because the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver. Nodes that have never heard
of each other, sit in different sync domains, or run with no mesh at all still
keep the same subset.

The code shows it: the seq test runs before any sync check and reads only the
frame, while only the mesh branch calls c6_sync_espnow_is_valid(). That
asymmetry is why seq had nothing to degrade in our runs, and it is the thing to
weigh if a fleet's sync is fragile -- seq is not just the better performer
there, it is the option with no dependency that can quietly fail.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:55:38 -04:00
Joeandclaude-flow d6b940184e docs: state that the mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate. Three of nine nodes never
synchronised during these runs, so for those three the mesh and mesh+seq arms
silently ran the elapsed gate.

+0.5 pp is therefore not a clean fleet-wide measurement of window alignment.
Reported as observed rather than reconstructed: the arms were assigned before
we understood the fallback, and a post-hoc per-node reanalysis would not be the
experiment we ran.

Names the cause as environmental rather than a defect. Sync is leader-elected
and hub-and-spoke, so whether every node hears the leader is a property of the
building -- and a house that fragments into sync domains degrades any
shared-time option quietly, for exactly the nodes whose data you would most
want.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:50:49 -04:00
Joeandclaude-flow 60adac05b7 docs(firmware): state that our mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate rather than gating on a
meaningless epoch. Correct behaviour, but it contaminates the comparison we
published: three of nine nodes never synchronised, so for those three the
setting under test was silently not in effect. The +0.5 pp should be read as
"mesh where sync held, elapsed where it did not", not as a clean fleet-wide
measurement.

Records the house-dependent cause as well. Time sync is leader-elected and
hub-and-spoke: one node broadcasts and the rest follow, so whether every node
hears the leader is a property of the building. In a larger or more divided
house the fleet fragments into sync domains and any option depending on shared
time degrades quietly for the nodes that fell out -- which is exactly the case
where someone would most want to trust the measurement.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:50:28 -04:00
Joeandclaude-flow ec9da648b8 docs(firmware): add the gate comparison as a worked example of house-dependence
The tunables table says which options depend on the building. This shows what
that means with real numbers, and is explicit that they are ours.

Nine-node C6 fleet, 2x2 factorial, three Latin-square blocks: seq selection
doubled cross-node pairing (0.5605 vs 0.2434, +31.7 pp, ~70x the drift floor)
while mesh-time alignment bought +0.5 pp, inside noise.

The point is the reasoning, not the ranking. It records WHY that happened here
-- these nodes already overheard 72% of transmissions in common, so there was a
large shared pool for seq to select from and little left for alignment to
recover -- and says plainly that a house with fewer shared transmitters or
thicker walls has less for seq to work with and may find alignment matters
more. We did not measure such a house and do not claim to know.

Also keeps the trade (pairing bought with raw frame rate; edge processing
unaffected because separately rate-limited, so the cost lands on the uplink)
and the prediction we got wrong: seq is NOT a workaround for broken time sync,
which means repairing our mesh split would not have closed the gap.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:46:35 -04:00
Joeandclaude-flow 0fd1c63563 docs(firmware): document the tunables, and which ones depend on the house
The Kconfig options were discoverable only by opening menuconfig and reading
help text, which requires already knowing they exist. Two of them --
CSI_GATE_MESH_ALIGNED and the thermal throttle pair -- have no universally
right value: they depend on walls, transmitters, enclosure and placement.

Lists all eight with their defaults, marks which are house-dependent, and says
what to measure for each. Notes explicitly that in one measured fleet window
alignment bought +0.5 pp of cross-node pairing while rx_seq selection bought
+31.7 pp, so nobody inherits the assumption that the default is doing the work.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:43:35 -04:00
Joeandclaude-flow 521b29ea21 Merge origin/main into contrib/mesh-aligned-rate-gate
Both sides appended to the end of Kconfig.projbuild -- upstream added the
thermal monitoring menu, this branch adds the two CSI gate options. Kept both.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:29:56 -04:00
Joeandclaude-flow e8e4e5cf67 fix(firmware): define the Kconfig options this gate is guarded by
csi_collector.c guards the mesh-aligned gate on CONFIG_CSI_GATE_MESH_ALIGNED,
but nothing defined it. Upstream's Kconfig.projbuild has no such symbol and
this branch did not add one, so the #ifdef would never be true and the gate
would ship dead: present in the diff, absent from the binary, and silent about
it. The A/B this PR offers would have compared the old gate with the old gate.

Adds CSI_GATE_MESH_ALIGNED (default y, the gate) and CSI_SEQ_DIAG (default n,
the probe that measures its effect rather than assuming it).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:28:57 -04:00
Joeandclaude-flow 9af1cc6644 docs(firmware): add the ESP32-C6 operational runbook
README.md explains the project; there was nothing that said what to run, in
what order, and what to check. This is that, written from the procedures that
were actually used on a nine-node C6 fleet.

Everything in it was learned by getting it wrong at least once, and the
failures it prevents are the silent kind:

- The build must concatenate THREE defaults files. IDF discovers
  sdkconfig.defaults and sdkconfig.defaults.<target> on its own but NOT
  sdkconfig.defaults.16mb, which is where CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
  lives. Build without it and the image is correct in every visible respect and
  cannot roll back a bad OTA, while ota_rollback_boot_check() sits in the app
  expecting the capability.
- A five-key pre-flash gate, because a stale sdkconfig reading FLASHSIZE=4MB
  against a 16MB fleet produces an image that silently is not the configuration
  under test.
- What OTA can and cannot reach, per flash region. Bootloader and partition
  table are USB-only, and there is no bootloader version on the wire -- so
  "every node reports health" proves the app is current and proves nothing
  about the bootloader.
- A single-board diagnostic flash with a revert path: parse otadata first, back
  up the 8 KB, and reverting is writing it back rather than rebuilding.
- min_heap_kib is a minimum-since-boot watermark, not an instantaneous reading.
  Sampling it within a minute of a restart produced a wrong "no effect" verdict
  once; the real answer, once settled, was -56 KiB.
- /api/v1/nodes rssi_dbm is not the AP link and swings 20+ dB with no physical
  change.

No code changes. One file.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:26:55 -04:00
Joeandclaude-flow 56fcc49104 Merge origin/main into contrib/server-node-positions
Upstream added a `node_positions_config` of its own while this branch was
waiting, modelled as an ordered `Vec` with a `node_positions_by_active_id`
helper that gives the Nth-lowest ACTIVE node id the Nth entry.

That is the same class of bug this branch exists to fix, one level up: a
position now depends on which OTHER nodes happen to be alive. One node going
quiet re-ranks every node above it and silently moves their coordinates.

Resolved by keying the map by node id throughout -- the struct field, its
initialiser, the local in main() and the helper -- and keeping upstream's
active-node filter unchanged.

Upstream's three helper tests are ported to the keyed form, and one is added
that pins the difference: a stale node must not move its neighbours.

Verified: cargo test -p wifi-densepose-sensing-server --no-default-features
passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 10:42:33 -04:00
Joeandclaude-flow 11c014485e fix(server): recover the true CSI frame rate through bursty UDP delivery
`csi_fps_ema` currently under-reports whenever more than one frame arrives in a
burst, and the under-report is proportional to the burst size.

The present scheme rejects sub-5 ms deltas and, crucially, does NOT re-anchor
on them. Holding the anchor means the next accepted delta spans the whole
burst while contributing a single sample -- so a burst carrying N frames is
counted as one. That is invisible when a burst holds one frame, which is the
case the current comment describes, and an exact halving when it holds two.

Two changes, both in the same direction:

- Average in the DELTA domain and invert, rather than averaging 1/dt. The
  reciprocal is unbounded as dt approaches zero, so one 36 us arrival can
  outweigh hundreds of nominal ones; an average of deltas cannot be dominated
  that way. This is what makes it safe to stop discarding short deltas.
- Re-anchor on every arrival. One delta per frame means the deltas sum to the
  elapsed time, which is the condition that makes the estimator equal
  frames/elapsed.

Simulated against the same arrival stream, a node genuinely producing 40 fps
whose frames are delivered in pairs (four frames per 100 ms):

    clean 40 fps, evenly delivered   before 40.0   after 40.0
    bursty 40 fps, pairs per 50 ms   before 20.0   after 40.6

The clean-cadence control is unchanged; the bursty case stops halving.

MAX_PHYSICAL_CSI_FPS is retained and still enforced at the consumption
boundary in `measured_sample_rate_hz`, so restored or malformed state cannot
overclock the DSP math. The estimator itself is left unclamped so that a rate
above the ceiling remains visible as the anomaly it is rather than being
silently flattened to 50.

TRADE-OFF, stated plainly: alpha moves from 1/8 to 1/32, so the estimate is
about three times slower to settle after a genuine rate change. That is the
price of not letting a single short delta swing it, and the steady-state test
horizon is widened to match.

Tests: `observe_csi_frame_arrival_ignores_subms_bursts` is replaced. It fed
three arrivals per 25 ms group -- 120 frames per second, which no single node
can emit under the firmware's own 50 fps send ceiling -- and asserted the
result should read 40, which only holds if two of every three real frames are
discarded. The replacement uses a physically consistent stream and asserts the
same 40 fps ground truth.

Full suite passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 09:57:16 -04:00
Joeandclaude-flow 86e45980fe chore: ignore host-test binaries by shape, not by a list that goes stale
`make host_tests` writes its binaries beside their sources, inside a tracked
directory. .gitignore named five of them individually, and the list had fallen
behind the Makefile: test_csi_sanitize, test_mmwave_detect and test_thermal
were built by the default target, ignored by nothing, and got committed by
accident on two contribution branches.

Enumerating targets cannot hold -- every new host test needs someone to
remember this file. So ignore by shape instead: in that directory every source
carries an extension and no binary does.

    firmware/esp32-csi-node/test/test_*
    firmware/esp32-csi-node/test/fuzz_*
    !firmware/esp32-csi-node/test/*.*

Verified rather than reasoned about. After building every host test,
`git status --porcelain` reports only this file. `git check-ignore` confirms
all ten binaries are ignored -- including test_edge_grid_he and
test_edge_grid_pre_he, which are not on main yet -- while the .c sources, the
Makefile, the README and the corpus seeds stay tracked.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 08:15:00 -04:00
rUv d613a576ea Merge pull request #1849 from ruvnet/codex/calibration-grid-integrity-20260906
fix(calibration): harden empty room bootstrap integrity
2026-09-06 12:25:17 -04:00
ruv 97578ee401 fix(firmware): make host dead stripping portable 2026-09-06 11:51:09 -04:00
ruv e54b4171ca fix(calibration): bind field models to stable CSI grids 2026-09-06 11:38:31 -04:00
ruv 338c523217 fix(calibration): tolerate measured UDP reordering 2026-09-06 11:38:31 -04:00
ruv 224c9eb671 feat(calibration): expose CSI admission receipts 2026-09-06 11:38:31 -04:00
ruv 2deb6d2d64 fix(calibration): require unique raw CSI samples 2026-09-06 11:38:31 -04:00
ruv 82e05d0664 fix(calibration): expire live field models safely 2026-09-06 11:38:30 -04:00
ruv a2c662f9f7 fix: harden CSI timing and baseline reliability 2026-09-06 11:38:30 -04:00
ruv 48c6244d37 fix(calibration): learn held out empty residuals 2026-09-06 11:38:30 -04:00
ruv 30404dd9cf feat(calibration): select explicit baseline source 2026-09-06 11:38:30 -04:00
rUv c4021f9884 docs: add WiFlow reproduction protocol (#1787) 2026-09-05 09:42:43 -04:00
rUv 2227b42841 feat(calibration): learn empty room startup confidence (#1840) 2026-09-04 23:02:06 -04:00
Joeandclaude-flow 767c36ad12 docs: measured comparison of three CSI frame-selection gates, and the harness
How a node rate-limits CSI callbacks decides whether cross-node fusion works at
all, because it decides whether two nodes keep the SAME frames or merely the
same NUMBER of frames. We measured three gates on a nine-node ESP32-C6 fleet as
a 2x2 factorial, three Latin-square blocks, every arm verified live on every
node.

Selecting frames by 802.11 rx_seq roughly doubled cross-node pairing over the
elapsed-time gate: 0.5605 against 0.2434, +31.7 pp, reproduced in all three
blocks at ~70x the measured drift floor.

Two results we did not want and are reporting anyway:

- Mesh-time alignment -- a gate we built and expected to be the answer -- bought
  +0.5 pp, about one standard deviation of noise. Indistinguishable from doing
  nothing.
- We predicted rx_seq selection would win most on node pairs spanning a broken
  sync domain. Per-pair yield says the opposite: never-synced nodes gained
  LEAST. So it is not a workaround for broken time sync, and repairing our mesh
  split would probably not have closed the gap.

The document leads with the method rather than the numbers, because the numbers
are one house. It records the three mistakes that made an earlier attempt
unreadable -- comparing different node subsets, not verifying the gate was
actually live, and using frame rate as the check that a gate is working when a
bucket gate makes frame rate nearly insensitive to it -- and the null run that
establishes the noise floor without which none of the contrasts can be judged.

run_arms.py is included so a reader can measure their own environment instead
of trusting ours. That is the point of the contribution; our figures are the
worked example.

Stated as a trade, not a win: pairing is bought with raw frame rate. On-device
edge processing was unaffected here (separately rate-limited), so the cost fell
on the uplink -- but whether that is acceptable depends on what consumes the
stream.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 17:46:00 -04:00
rUv 2e9e87c65c feat(calibration): persist safe empty room bootstrap prior (#1826) 2026-09-04 16:31:38 -04:00
rUv 8d1a67d31b Merge pull request #1793 from clonea1/contrib/server-classification-stability
fix(sensing-server): scale smoothing to the measured CSI rate, and debounce the room
2026-09-04 12:19:54 -04:00
rUv c19b846394 Merge pull request #1791 from clonea1/contrib/server-node-positions
fix(sensing-server): wire --node-positions into live NodeInfo output
2026-09-04 12:18:22 -04:00
rUv cf6e955196 Merge pull request #1795 from clonea1/contrib/wifi-retry-watchdog
fix(firmware): retry WiFi forever, and add an uplink watchdog (stacked on #1797)
2026-09-04 12:17:41 -04:00
rUv dddc214a25 Merge pull request #1797 from clonea1/contrib/thermal
feat(firmware): thermal monitoring, radio throttling and node health
2026-09-04 12:17:06 -04:00
rUv 5da64c6fc5 Merge pull request #1800 from TsinbeiLabs/fix/calibration-wall-clock-gate
fix(calibration): gate finalize on wall-clock duration, expose elapsed_s and frames/s
2026-09-04 12:16:45 -04:00
rUv 1c088e67c2 Merge pull request #1794 from clonea1/contrib/ui-no-fabricated-data
fix(ui): never present invented data as measured
2026-09-04 12:16:41 -04:00
rUv 1231088427 Merge pull request #1796 from clonea1/contrib/disable-unused-154-radio
perf(firmware): stop the C6 sharing its radio with a stack that cannot receive
2026-09-04 12:16:36 -04:00
rUv 4d857a908f Merge pull request #1792 from clonea1/contrib/subcarrier-grids-256
fix(firmware): stop truncating HE20 CSI to half its subcarriers on C6/C5
2026-09-04 12:16:32 -04:00
Joeandclaude-flow d6347fe12e fix(sensing-server): key node positions by node id, not by list position
Fixes the indexing mismatch found in review. Positions were inserted keyed by
the entry's index in the parsed list and read back by the node's actual
node_id:

    for (idx, p) in positions.iter().enumerate() {
        node_positions_config.insert(idx as u8, *p);   // key = index
    }
    ...
    s.node_positions_config.get(&id)                   // lookup = node_id

On any fleet not numbered 0,1,2,... every lookup misses and falls through to the
hardcoded [2.0, 0.0, 1.5] that --node-positions exists to replace. The config
reads as applied and does nothing, which is worse than not having wired it up.
It passed here only because these nine boards happen to be 0..8.

--node-positions now accepts an explicit form alongside the positional one:

    x,y,z;...           positional, identity is the index (unchanged)
    node_id:x,y,z;...   explicit, identity is the stated id

Both are accepted and may be mixed, so existing invocations keep working.
Coordinates never contain a colon, so the split is unambiguous. An unparseable
or out-of-range id is skipped rather than guessed at -- falling back to
positional there would reintroduce the ambiguity being removed, and 256 must
not wrap to node 0.

Explicit ids are the better form on a real fleet: with the positional one, the
binding of a position to a board is implied only by argument order, which is
exactly the fragility that produced this bug.

MultistaticFuser::set_node_positions() is deliberately left alone. It consumes
the list positionally, matched to per-frame iteration order rather than to node
id -- a real and separate concern, but this change is about the NodeInfo output
and quietly altering fusion semantics inside it would be the wrong scope. The
fuser receives the same list, in the order given, as before.

The map is now built by field_bridge::node_positions_by_id() rather than a loop
in main(). That is the point: the defect lived in a loop no test could reach.
Nineteen tests cover it, including non-sequential ids (11,12,13), a fleet
re-homed to 0,2,4,...,16 with two-digit ids, a fleet that does not start at
zero, and an assertion that the list indices are NOT keys -- which is the bug
stated directly. The four pre-existing parse tests are untouched and still pass,
so the positional path is unchanged.

265 server tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 11:53:40 -04:00
ruv 9b595b8333 fix(sensing-server): key --node-positions by active-node rank, not node_id
The prior fix stored the parsed --node-positions list keyed by list index
under the assumption that node_id == index, then looked it up by the
node's real node_id. Real fleets use non-sequential logical node IDs
(11, 12, 13, ...), so that lookup silently misses for every realistic
deployment and falls through to the same [2.0, 0.0, 1.5] placeholder the
original bug report was about.

MultistaticFuser::fuse assigns node_positions[i] to the i-th frame in its
node_frames argument, which callers build sorted ascending by node_id.
Match that same convention in the NodeInfo JSON builder: rank currently-
active node_ids ascending and index into the configured position list by
rank, so live position output agrees with what fusion actually used.

Adds node_positions_by_active_id() (shared by both call sites) plus
regression tests for non-sequential IDs, stale-node exclusion from rank
assignment, and the missing-config fallback.
2026-09-04 11:48:11 -04:00
Joe 944b7bea77 fix(firmware): retry WiFi forever; add an uplink watchdog
A node that lost WiFi could give up permanently. The disconnect handler
counted retries in s_retry_num, which is only ever cleared on a successful
IP_EVENT_STA_GOT_IP, so it accumulated across the node's entire uptime.
After ten cumulative disconnects it set WIFI_FAIL_BIT and never called
esp_wifi_connect() again. No backoff reset, no periodic retry, no reboot.
Only a power cycle cleared it.

MEASURED 2026-08-30: the AP serving the fleet took a firmware update and
rebooted at ~03:38. All three wall-mounted nodes stopped delivering within
three minutes of each other (nodes 0 and 1 at 03:37:03, to the second) and
none recovered for 5.5-7.5 hours, until each was physically unplugged. CSI
capture ran the whole time -- node 0's sync sequence advanced 16600 ->
46340 at a steady ~30/s while it was invisible to the server -- because
capture is driver-level and needs no IP. The boards looked alive on a
bench and dead to the aggregator.

Retry is now unbounded with 500 ms -> 30 s exponential backoff, scheduled
on an esp_timer one-shot so esp_wifi_connect() is never reached through a
delay inside the event handler. Two things that were conflated are now
separate: WIFI_BOOT_WAIT_ATTEMPTS only releases app_main from its startup
wait so the rest of the node can boot; nothing stops the node retrying.

The uplink watchdog covers the failures we have not diagnosed: no
successful send for CONFIG_UPLINK_WATCHDOG_TIMEOUT_S restarts the node.
The signal is a successful sendto(), which on UDP means the stack accepted
the datagram, NOT that the server received it -- so stopping the
aggregator cannot reboot the fleet. It arms on the first successful send
rather than at boot, so a node powered up while the AP is down does not
reboot-loop; the unbounded reconnect owns that case.

The timeout is 1200 s, sized from a 112-minute soak rather than a guess:
30 observed silences across two nodes, median 225 s, one 510 s outlier
that looked like two cycles running together. An earlier 120 s would have
rebooted healthy nodes every few minutes, and even 600 s left only 90 s of
margin over an event already seen once. Do not lower it below ~900 s while
the ~225 s stalls are unexplained; a needless reboot re-runs ESP-NOW
leader election and discards mesh time-sync state.

Also guards thermal.c's apply_tx_power behind CONFIG_THERMAL_THROTTLE so a
monitor-only build (the default, and what is on the wall) stops warning
about an unused function.

Kconfig gains CSI_GATE_MESH_ALIGNED here alongside the watchdog options;
its implementation lands in a later commit and the option is inert until
then.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit bb6d954de68fd4d542e191fd5e82c753f67a9874)
2026-09-04 11:31:58 -04:00
Joeandclaude-flow 7f9df37380 fix(firmware): damp every thermal step down, not just the one into OK
Follow-up to the review on #1797. The CRITICAL -> THROTTLED edge was
unconditional: any reading below critical_c dropped the state immediately, so a
node sitting on the critical boundary flapped between TXP_MIN and TXP_STEP2
indefinitely. Every oscillation moves that node's RSSI at both ends, which a
reader cannot distinguish from an obstruction -- exactly the failure the
hysteresis on the other edges was added to prevent.

Now every falling edge requires cooling a full hysteresis band below the
threshold it crossed on the way up; rising edges stay immediate, because heat is
a reason to act now. Verified by dithering across the boundary: six readings
either side of 80 C previously produced five state changes, now zero.

The decision moved into thermal.h as a pure function, following the convention
edge_processing.h already uses for edge_evidence_person_count -- a host test
then exercises the exact function the device build runs rather than a copy free
to drift.

BEHAVIOUR CHANGE worth calling out, beyond what the review asked for. The old
`recover` point was a single global threshold (throttle_c - hyst) that every
state fell back through, so leaving WARN required cooling to 64 C -- a bound
derived from the throttle threshold, not WARN's own. Hysteresis is now relative
to each state's own entry point, so WARN releases at 57 C instead. Leaving
THROTTLED is unchanged at 64 C. Kconfig help updated, since it described the old
throttle-relative semantics.

One consequence the test pins explicitly: with the default constants the
critical exit point (80 - 8 = 72) coincides exactly with the throttle threshold,
so a node cool enough to leave CRITICAL is already below THROTTLED's entry and
lands in WARN. THROTTLED is unreachable descending from CRITICAL at hysteresis
8. That falls out of the chosen constants rather than the logic, and the test
covers hysteresis 4 as well to show the intermediate step reappears when the
bands do not coincide. My first version of the test asserted THROTTLED there and
was simply wrong -- running it caught that.

Verified in espressif/idf:v5.4: against the unfixed transition the test fails
five checks including the dither, passes with the fix, and the C6 firmware
builds. Still no hardware evidence for this PR -- a real boot log is coming
before merge, as promised earlier in the thread.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 11:29:27 -04:00
Hsukqi Lee f574450516 fix(calibration): gate finalize on wall-clock duration, expose elapsed_s and frames/s
Signed-off-by: Hsukqi Lee <team@tsinbei.com>
2026-09-04 20:15:38 +08:00
Joeandclaude-flow 3621a211bd fix(sensing-server): debounce the room-level classification too
`fuse_room` recomputes its plurality vote fresh every cycle with no memory of
its own, unlike each node's classification, which is debounced individually.

With per-node confidences sitting near the classification boundary -- 44%, 83%
and 53% observed simultaneously across three nodes -- whichever two of three
happened to agree on a given cycle could flip the room-level result while every
node's own reading was individually stable. The room read
absent / present_still / absent / present_still in rapid succession.

That cascaded into everything gating on presence: the persons array, the
rendered figure appearing and disappearing, the live position dot. Each of
those looked like its own bug, and none of them was.

Adds the missing room-level hysteresis. A new candidate classification must be
observed continuously for ROOM_DEBOUNCE_DURATION_SECS before it is adopted; an
interrupted run restarts it. The threshold is a DURATION rather than a cycle
count, for the same reason as the per-node fix in the previous commit: a count
means a different real interval at every rate.

Debouncing at the room level does not make the per-node debounce redundant.
They answer different questions -- "is this node's reading stable" and "do the
nodes agree stably" -- and the observed failure was agreement flapping while
every individual reading held steady.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 17:16:02 -04:00
Joe 5eb8ddde12 fix(sensing-server): scale per-node smoothing to actual CSI rate
DEBOUNCE_FRAMES, MOTION_EMA_ALPHA, and BASELINE_EMA_ALPHA were tuned
assuming CSI frames arrive at ~10 FPS (per their own doc comments).
smooth_and_classify_node() runs once per raw incoming UDP frame, and
ESP32-C6 boards measured in the field run at ~48-50 FPS - about 5x
faster. That silently compressed a ~0.4s debounce window to ~0.08s
and a ~1s motion EMA to ~0.2s of real smoothing, letting per-frame
CSI noise flip the reported motion_level almost directly instead of
being smoothed - the reported UI "seizure" of rapid label flicker.

Rather than hardcode a different fixed rate (which would just move
the same bug to whatever chip/capture-mode/config next diverges from
it), derive each frame's effective alpha and frame-count thresholds
from the node's own measured csi_fps_ema, which is already tracked
and burst-filtered elsewhere (observe_csi_frame_arrival). Time
constants are back-derived from the original alphas at the assumed
10 FPS point, so behavior is unchanged for a node at exactly 10 FPS
and now scales correctly to whatever rate a node actually runs at.

smooth_and_classify() (simulated/WiFi-scan sources, already tick-
gated at a known fixed cadence) is unaffected - not exhibiting this
bug, left as-is.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit d29d9fb02078b066be8557af24023c22e99fa3d1)
2026-09-03 17:12:18 -04:00
Joeandclaude-flow fbecae4e02 fix(ui): never present invented data as measured
The dashboard flip-flopped between "absent" and "present_still" after
navigating away and back. The cause was not sensing. On a dropped socket the
client fell back to a built-in simulation and rendered synthetic values that
were indistinguishable from live ones, so the UI was showing made-up readings
and saying nothing about it.

That is the most damaging failure this UI can have. A sensing system that
invents data silently is worse than one that shows nothing, because every
downstream judgement -- including which nodes to move and whether a change
helped -- is then made against fiction.

Changes:

  - Client-side simulation is default-OFF and must be opted into explicitly.
  - An unreachable server retries forever instead of substituting data.
  - A banner states plainly whenever what is displayed is not live. The
    labels say what they mean: "NO DATA - SERVER UNREACHABLE" for an
    unreachable server, and "INVENTED DATA - NOT MEASURED" for simulation,
    replacing "OFFLINE - CLIENT SIMULATION", which read like a status rather
    than a warning.

Also fixes a reconnect race in the same path: two in-flight connects could each
install handlers, so a stale socket kept overwriting fresher state. That race is
what made the flip-flop intermittent and hard to attribute.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 11:42:16 -04:00
rUv de099c1f2d feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
2026-09-03 10:35:12 -04:00
rUv 01460b98b0 research: real zero-shot pretrained-checkpoint eval beats naive baselines (RuForecast)
research: real zero-shot pretrained-checkpoint eval beats naive baselines (RuForecast)
2026-09-03 10:35:09 -04:00
rUv 26f444138e chore(forecast): bump ruforecast submodule to governance/spend enforcement fix
chore(forecast): bump ruforecast submodule to governance/spend enforcement fix
2026-09-03 10:34:57 -04:00
Joeandclaude-flow 00737441c4 perf(firmware): stop the C6 sharing its radio with a stack that cannot receive
Every C6 node spends coexistence time-slicing on 802.15.4 -- a single 2.4 GHz
transceiver is shared between WiFi, BLE and 15.4 -- against a receive path that
provably delivers nothing. That time competes directly with CSI capture, which
is the node's entire purpose, and it matters most on a large fleet where the
50 Hz receive gate is already the binding constraint.

The evidence that it cannot receive is upstream's own (ADR-110 D1,
ruvnet/RuView#762): five configurations tested -- channel 15 and 26, OpenThread
on and off, promiscuous on and off, PAN-matched and broadcast -- and every one
gave rx=0 while TX stayed 100% reliable on IDF v5.4. ESP-NOW shipped as the
workaround and is what this firmware actually syncs on. The stack was
nonetheless still initialised at boot and beaconing every 100 ms.

Safe to remove: the ADR-018 byte 19 bit 4 "sync valid" flag is ORed from both
transports and the 15.4 branch is #ifdef-gated on this symbol, so the working
ESP-NOW path keeps setting it. c6_timesync_init is gated on the same symbol and
is no longer called.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 08:53:09 -04:00
Joe 25d7be3c25 fix(sensing-server): wire --node-positions into live NodeInfo output
Node position parsing/storage for multistatic fusion worked correctly,
but the per-node JSON builder (both the vitals-only and governed/
privacy-gated ESP32 live-data paths) never read the parsed positions -
it always emitted the hardcoded fallback [2.0, 0.0, 1.5] regardless of
--node-positions. Store the parsed positions in AppStateInner, keyed
by 0-based index (= node_id), and read from it at both call sites.

Matches upstream issues #228, #249, and the "Known Issues" #301 symptom
class ("same output regardless of position").

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit d34d76c54e7ba4a466cf9dc27060684f49756b6b)
2026-09-03 08:00:30 -04:00
Joeandclaude-flow 29b3968731 fix(firmware): size the subcarrier buffers for HE20, not just pre-HE
EDGE_MAX_SUBCARRIERS is a flat 128, which silently truncates CSI on any part
that carries HE20. An ESP32-C6 or C5 reports 256 subcarriers per frame; every
buffer sized by this constant holds half of them, and edge_process_frame()
returns early on n_subcarriers > EDGE_MAX_SUBCARRIERS, so on those parts the
edge pipeline stops entirely -- no log past init and no vitals packet ever
reaching the sink.

Gated on CONFIG_SOC_WIFI_HE_SUPPORT, the same switch csi_collector.c already
uses to pick its rx_ctrl layout, so pre-HE parts keep 128 and do not pay ~3.5 KB
of .bss they can never use.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-02 23:53:02 -04:00
Joe ed006abd58 refactor(firmware): separate acting on temperature from measuring it
Reading the sensor is inert. Reducing transmit power moves every RSSI the node
has, at both ends of every link -- introducing that during a three-to-nine node
bring-up would give any surprise two candidate causes and no way to separate
them.

So CONFIG_THERMAL_THROTTLE is now its own option, default off, while
monitoring stays on. The first flash therefore reports die temperature and
peak without ever changing behaviour, which is exactly what is needed to
answer whether these boards run hot in enclosures at all.

If none of them ever does -- the likely outcome at ~0.4 W average in a vented
box -- throttling never needs enabling and the thresholds cost nothing.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 187eb17ba9549a96aa63ce0fda37888c8c65650c)
2026-09-02 22:44:24 -04:00
Joe 5419bff2a8 feat(firmware): thermal monitoring, radio throttling, reset reason, heap health
The C6 carries a temperature sensor this firmware has never read. Tolerable on
a bench board; not once nine nodes are sealed in printed enclosures on a wall
with the radio transmitting continuously.

What is protected is the ENCLOSURE, not the silicon. The part is rated far past
anything it sees here, but PLA softens at its glass transition (~55-60 C) and
sags under its own weight over weeks -- a failure that never announces itself.
Thresholds are set against DIE temperature with that offset in mind: ~72 C on
the die in a small vented box is roughly a 50-55 C enclosure. Setting the trip
at 55 would throttle a perfectly healthy node.

Throttling reduces WiFi transmit power in 3 dB steps, the dominant heat source
and the one that degrades gracefully -- links weaken but keep working, whereas
cutting capture rate would blind the node. Recovery has 8 C of hysteresis
because oscillating around the trip point would put a visible RSSI step on
every link the node has, repeatedly.

`thermal_tx_dbm()` and `thermal_state()` are exported for a specific reason: a
throttled node's RSSI falls at both ends, which is indistinguishable from an
obstruction in the link diagnostics. Anything reading link strength must be
able to tell a hot node from a wall.

Also records two things nothing tracked before:

- **Reset reason at boot.** A node that reboots tells you almost nothing; one
  that reboots with ESP_RST_BROWNOUT tells you its supply sagged, which on
  shared outlets is a wiring problem rather than a firmware one. Three nodes
  dropping together on 2026-08-28 would have been answered by this one line.
- **Free and minimum-free heap** in the periodic log. A slow leak and an
  overheat look identical after ten days unless the minimum has been visibly
  walking downward the whole time.

Kconfig-gated (default y on C6), thresholds configurable. Builds clean at
0xfe990, 45% partition free.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 898295fba7b3873b37e85a5ffc0ec87fe609aa23)
2026-09-02 22:44:09 -04:00
Joe 7cdf5714e8 feat(firmware): align the CSI rate gate to mesh time
HOW the 50 Hz rate is limited decides whether cross-node fusion is
possible at all, and the original gate quietly made it impossible.

The gate was "at least 20 ms since MY last accept". Each node therefore
has an independent phase: node A can accept at t=0,20,40 ms while node B
accepts at 7,27,47. Both are perfectly healthy, both hit 50 Hz, and they
accept disjoint frames -- so no two nodes ever hold a measurement of the
same transmission and there is nothing to pair.

MEASURED 2026-08-30 with two boards side by side, logging (tx_mac,
rx_seq) before and after the gate: they HEARD the same transmissions 72%
of the time but only 25% of ACCEPTED frames were common. The frames were
there; the gate was throwing away the pairing. (Also settles an open
question from ADR-345: rx_seq is populated -- 4099 distinct values across
~44,000 frames, both nodes reporting the identical range.)

Now gates on a bucket of the mesh-aligned epoch, so every node takes the
first frame it hears in the same absolute 20 ms window and frames both
nodes heard are frames both nodes keep. Mesh sync is good to roughly
490 us against a 20 ms bucket -- about 2.5% of a window -- so boundary
disagreements are rare.

Falls back to the original elapsed-time gate whenever
c6_sync_espnow_is_valid() is false, because an unsynced epoch would
otherwise gate on a meaningless number; a node that has not heard a leader
yet simply gets the old behaviour. The half-interval floor is deliberate:
bucket gating alone could accept at the end of one window and the start of
the next, back to back, and this gate exists to prevent an SPI flash cache
crash in the WiFi ISR, not for tidiness.

Behind CONFIG_CSI_GATE_MESH_ALIGNED (default y) so it can be A/B'd against
the old gate; CONFIG_CSI_SEQ_DIAG measures the difference directly.

NOT yet deployed to the fleet, and not yet enough on its own: the wire
format carries a per-node counter, not rx_seq, so the server still cannot
pair frames. Wire v3 has to carry rx_seq before any of this is usable off
a serial cable.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 4b54ef8ecb2a28d173d411a064809dc081c43f52)
2026-09-02 22:43:27 -04:00
ruv f1ea275d5d chore(forecast): bump ruforecast submodule to the governance/spend fix
Points v2/crates/ruforecast at ruvnet/RuForecast@a802067f, the tip of
fix/governance-and-spend-enforcement (ruvnet/RuForecast PR #2), which closes
14 code-review findings against the training stack merged as #1766 -- most
critically, ADR-349's governance-verification and spend-approval-record
requirements were specified but never actually enforced by the fal.ai
hosted path; they are now.

Adds ADR-354 recording this as a RuView-level integration decision and
pointing to the submodule's own ADR-350 for the full technical record,
since that repository is now the source of truth for the forecast
subsystem's decision history.

Depends on ruvnet/RuForecast#2 merging with green CI first; see that PR for
status before merging this one.
2026-09-02 08:53:08 -04:00
ruv 5c50fa8588 feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
Trains a real fal.ai LoRA fine-tune (fal-ai/flux-lora-fast-training) on
frames extracted from the real fal.ai-generated room-visualization MP4s
already captured this session, so display-layer generation has a specific
trained style target instead of prompt engineering against a stock model.

Architecture boundary: this model only ever produces supplementary
synthetic visual/pixel output. It has no path into measured/derived RF,
pose, vitals, identity, or confidence values, which remain local and
authoritative.

Real run: request 01a060b2-c940-7972-abf9-ea48847eb2ae, 1000 steps, 27
training images, ~$2.04 spend, validated with a real inference call
against the trained LoRA (assets/fal-visual-teacher-sample.png).
2026-09-02 08:45:23 -04:00
ruv f19938a7b6 docs(ruforecast): real zero-shot pretrained-checkpoint comparison beats naive baselines
Chronos-Bolt (base/small), Chronos-T5-small, and TimesFM-2.5-200M run
zero-shot (no fine-tuning) on the same BIDMC cross-entity test windows,
baselines, and WQL/MAE metrics as the prior from-scratch RuForecast
BIDMC result. Unlike every from-scratch RuForecast configuration tried
this session, all four pretrained checkpoints beat both last-value and
seasonal-naive baselines on WQL, on both independent patient-partition
judges (12-42% margin), consistent with the massive-pretraining-corpus
hypothesis from the prior literature pass.
2026-09-02 02:15:33 -04:00
ruv db5a898372 docs(readme): move animated hero GIF to top banner position 2026-09-02 01:31:51 -04:00
ruv b11422459e docs(readme): add animated hero GIF
Real-time-style animated visualization illustrating RuView's pose
estimation, breathing, and heart-rate sensing capability, generated
via MiniMax H3 image-to-video from the existing hero graphic.
2026-09-02 01:27:34 -04:00
rUv e5bf0d4892 feat(forecast): add independent Rust multivariate training stack (#1766)
* feat(forecast): add independent Rust training stack

Signed-off-by: Codex <codex@openai.com>

* docs(forecast): record informal HPO exploration note (unaccepted)

Records an exploratory Darwin Mode numeric-genome hyperparameter search
against the tiny_ci synthetic accuracy protocol: WQL improved 0.257 -> 0.161
-> 0.153 over two search rounds, staying ahead of both baselines throughout.
Explicitly scoped as informal/exploratory (not a frozen leakage-free report,
not eligible for the evidence ledger, not validated for large_linux or any
real dataset) per this doc's own evidence-tagging discipline.

* feat(forecast-core): add per-horizon weighted quantile loss

weighted_quantile_loss collapses the whole horizon into one aggregate
number, hiding whether error grows with lead time. Add a sibling
function using the identical per-cell pinball formula and domain
checks, just reduced per horizon step instead of globally. A unit
test reconstructs the aggregate from the per-step numerators/
denominators to prove the two functions agree exactly, not just
approximately.

* feat(forecast-model): add evaluation-only local activation

Adds activate_for_evaluation and build_eval_input, gated behind the
cpu feature. activate_for_evaluation self-signs an unsigned candidate
with a fixed, publicly-known, non-secret Ed25519 key so a CLI operator
can run inference against their own just-trained candidate without a
real release signature -- explicitly never a production trust path,
documented as such in the module doc comment. The schema-digest check
in ArtifactActivationPolicy still applies in full; only signing is
relaxed. build_eval_input constructs a single-window ModelInput for
CPU inference from raw context-major values/mask arrays, matching the
training batch builders exact time-feature/descriptor encoding.

Six unit tests cover: malformed-candidate rejection, digest-mismatch
rejection (proving the schema check has teeth even on this relaxed
path), getting past the policy gate with a matching digest, and
eval-input shape validation.

* feat(forecast-train): add evaluate and prepare-synthetic-dataset CLI

Turns this sessions throwaway HPO scripts into real, tested CLI
surfaces, replacing two example binaries that bypassed the CLI/TOML
training path entirely.

prepare-synthetic-dataset generates a larger, configurable synthetic
training shard (default 24 windows) plus a matching train-local.toml
with the given OptimizerSpec hyperparameters baked in, and a separate
held-out test.jsonl -- the same synthetic-only, local-validation-only
posture as prepare-local-example, just bigger and configurable.

evaluate scores a trained (unsigned) candidate against held-out
windows and the LastValue/SeasonalNaive baselines: overall weighted
quantile loss, a per-horizon breakdown, 80% interval coverage, and
missingness, for the model and both baselines. Explicitly lists what
docs/benchmarks/ruforecast.mds full accuracy protocol additionally
asks for that this does not cover (abstention coverage, selective
risk, site/device slices, interference regime, RuVector-retrieval
ablation) rather than silently omitting them -- each needs
infrastructure a single-entity synthetic fixture does not have.

Together the three real commands (prepare-synthetic-dataset,
train-local, evaluate) reproduce the exact WQL numbers the old
library-bypassing example scripts produced for the same
hyperparameters, confirmed by re-running the harness/ruview HPO
dry-run end to end against the new CLI path.

New integration test trains a real candidate via smoke, evaluates it
against held-out synthetic windows, and asserts the full report shape
and an empty-input rejection.

* feat(forecast-train): add prepare-synthetic-dataset --seed + Autogenous bridge

--seed (default 0, backward compatible) makes prepare-synthetic-dataset's
generator deterministic per offset: same seed -> byte-identical corpus
across runs (needed to train two genomes on the SAME corpus for a fair
comparison); different seeds -> genuinely independent corpora (needed for
honest multi-seed/multi-judge evaluation upstream, both in
harness/ruview/flywheel/ruforecast/gate.mjs's fitness function and in the
new v2/crates/ruforecast-autogenous-bridge crate added here).

ruforecast-autogenous-bridge is a LOCAL-DEV-ONLY (excluded from the v2
workspace, publish = false) bridge from this crate's real evaluate CLI
into ruvnet/autogenous's new regression-candidate promotion path (separate,
unpushed branch feat/regression-candidate-kind): runs N independent
train+evaluate judges on genuinely distinct synthetic corpora, signs
receipts, and gets a real cryptographically-verified PROMOTE/REJECT
decision -- defense in depth on top of, not a replacement for, Darwin's own
promotion gate. Path-depends on a sibling autogenous checkout that does not
exist in CI; build/run directly via `cargo build --manifest-path
crates/ruforecast-autogenous-bridge/Cargo.toml`.

cargo test -p ruview-forecast-train --no-default-features --features
cpu,cli: all green (unaffected existing tests + this backward-compatible
addition).

* docs(forecast): retract round-2 HPO result, record honest multi-seed finding

The earlier "Informal HPO exploration note" claimed a real improvement
(WQL 0.257 -> 0.153) from a 3-round hyperparameter search. That search
evaluated every candidate against one fixed synthetic corpus (seed 0) for
every round -- textbook overfitting. Independent verification against two
fresh corpora (via the new ruforecast-autogenous-bridge crate) showed the
"winner" losing to the baseline on both.

Fixed the root cause in harness/ruview/flywheel/ruforecast/gate.mjs:
candidates are now scored against three independent corpora, worst-case
across them, not one fixed corpus.

Re-ran the search under the fix. It found a new winner that genuinely beat
baseline on all three of its own search seeds -- and that winner ALSO lost
independent verification on fresh seeds. Two independent search rounds,
pre- and post-fix, both produced an illusory "winner." The honest reading:
at this dataset scale (24 synthetic windows) held-out WQL varies enormously
by which corpus is drawn, regardless of hyperparameters -- confirmed
directly by the baseline genome's own primary swinging from 0.83 to a full
regression across the three fixed search seeds with unchanged
hyperparameters. No RuForecast hyperparameter configuration has been shown
to reliably beat the trivial baselines out-of-sample at this scale.

Also fixes a real design inconsistency surfaced by this exploration: the
Autogenous regression-candidate promotion verifier
(envelope::regression::verify_regression_promotion, separate unpushed
ruvnet/autogenous branch feat/regression-candidate-kind) required all
judges to share one corpus_id, which conflicts with this kind's
intentional cross-corpus judge design. Corrected there (commit bfa4c48);
did not change either REJECT verdict, which were already driven by the
real NotBetterThanParent signal on their own.

Append-only: the original round-2 row is kept, read together with this
new amendment section, per this doc's own evidence-ledger discipline.

* refactor(forecast): extract RuForecast to ruvnet/RuForecast submodule

Mirrors the v2/crates/worldgraph pattern: RuForecast becomes its own
independent public repo/workspace (ruforecast-core/model/train), mounted
at v2/crates/ruforecast as a git submodule, with v2/Cargo.toml path-depping
into its sub-crates and excluding the submodule from the v2 workspace.

Full v2 --workspace check passes; the ruforecast CLI builds and runs
correctly from its new location. Real git history for the extracted
crates (7 commits) was preserved via git-filter-repo, not squashed.

* feat(autogenous-bridge): real-data independent verification tool

real_data_verify.rs: signs two real, temporally-independent judge
measurements (different train/test split boundaries on the same real
household vitals corpus, not synthetic seeds) through the real
Autogenous regression-candidate promotion path, and gets a genuine
cryptographically-checked PROMOTE/REJECT verdict.

Result recorded (see docs/benchmarks/ruforecast.md): REJECT. One judge
nominally beat the trivial baseline but by less than the 0.01
non-inferiority margin; the other judge lost outright. Consistent with
every synthetic-data search this session -- no configuration has yet
been shown to reliably beat trivial baselines out-of-sample, now
including a real 6390-sample household corpus.

Bumps the ruforecast submodule to pick up the evaluate real-gap fix.

* docs(forecast): record real-household-data result (REJECT, signed)

6390 real vital-signs samples, two independent temporal splits,
independently verified via the real Autogenous regression-candidate
promotion path. Signed verdict: REJECT -- neither split cleared the
non-inferiority margin. Same conclusion as every synthetic search this
session, now confirmed with real data too: more real data is the
credible next lever, not further search on this scale of fixture.

* chore(forecast): point ruforecast submodule at published main (post gap-tolerance fix)

* ci(forecast): fix ruforecast-ci.yml for the new v2/crates/ruforecast submodule layout

The RuForecast crates were extracted into a standalone submodule (v2/crates/ruforecast, mirroring the existing v2/crates/worldgraph pattern) and are no longer members of the v2 workspace. The CI workflow still referenced the old in-tree package names/paths (ruview-forecast-core/model/train under v2/), which broke every forecast job with "cannot specify features for packages outside of workspace".

Fixed every job to target the submodule: working-directory -> v2/crates/ruforecast, -p ruview-forecast-* -> -p ruforecast-*, all hardcoded Cargo.toml/source paths in the Python assertion and clean-room-scan blocks, the Swatinem/rust-cache workspaces input, the artifact-tree output paths, and the two levels of relative path (../ -> ../../) in the informational-benchmark steps whose working directory moved one level deeper.

Also collapsed the paths: trigger filters internal-crate globs (v2/crates/ruview-forecast-*/**) down to the single v2/crates/ruforecast path, since GitHub Actions path filters only ever see a submodule gitlink change as one entry in the parent tree, never its internal file paths -- the old globs could never have matched anything.

Includes a real cargo fmt fix (submodule commit d9902f9) for formatting drift left over from the earlier real-data gap-tolerance fix, which the contract job's fmt --check step would otherwise have failed on.

All four forecast CI jobs' real commands verified locally before push: cargo test/check for CPU, CUDA/WGPU compile-check, the contract job's Python assertion block, the hosted-boundaries clean-room scan, and cargo fmt --check.

* docs(forecast): record real BIDMC cross-entity holdout result (REJECT)

Two independent real-patient partitions (34/16 and 24/26 splits, 53 total
ICU patients from PhysioNet BIDMC, Open Data Commons Attribution License
v1.0) both show the model losing to trivial baselines by a decisive margin
(+69% and +601% worse WQL). First genuine cross-entity real-data test this
project has run; same conclusion as every prior synthetic and single-household
test. Full Autogenous signed verification was not run for this entry -
flagged explicitly in the note.

* chore(forecast): point ruforecast submodule at published main (example feature-gate fix)

* chore(forecast): point ruforecast submodule at published main (clippy fix)

---------

Signed-off-by: Codex <codex@openai.com>
2026-09-01 23:35:42 -04:00
rUv e04f269f1a Merge pull request #1762 from ruvnet/codex/zone-aware-calibration
firmware: fail closed occupancy and stabilize C6 temporal sensing
2026-08-31 14:41:43 -04:00
ruv 12a61c16e8 docs(firmware): prepare 0.8.8 release 2026-08-31 14:14:08 -04:00
ruv a70803fe31 fix(security): update wasmtime to 36.0.14 2026-08-31 14:14:08 -04:00
ruv 4b295b1b4d docs(firmware): qualify second ESP32 C6 node 2026-08-31 13:57:21 -04:00
ruv 615e2d419b docs(firmware): qualify ESP32 S3 transport 2026-08-31 13:31:19 -04:00
ruv f85896cccb feat(firmware): stabilize rate-aware ESP32 sensing 2026-08-31 12:56:32 -04:00
ruv 0a0b3411f8 fix: fail closed contradictory ESP32 occupancy evidence 2026-08-31 12:56:32 -04:00
rUv 08210b02c9 Update README.md
removed promo imgs.
2026-08-31 12:42:10 -04:00
ruv 27f5540663 feat(server): advertise local RuView installations 2026-08-27 15:52:10 -04:00
rUv b742eae7d6 fix(sensing): fuse only coherent frame cohorts (#1726) 2026-08-27 10:10:31 -04:00
rUv d42c5581f3 feat(rufield): ultrasonic as the field surface's second modality (#1716)
ADR-262 §8 question 5 left the second modality open, asking whether it should
be rvcsi. This answers ultrasonic instead, because the cost collapsed:
rufield-adapters now ships UltrasonicReplayAdapter, the first adapter for
Modality::Ultrasonic (registry code 7, empty since v0.1), which parses,
validates and signs BatVu range profiles upstream. RuView only has to decide
what it will put on a wire.

Bumps vendor/rufield 43b1df3 -> 9955672. Two struct literals in bridge.rs gain
fields added upstream (Observation: track_id, attributes, identity_evidence,
channel_sounding_provenance; SensorDescriptor: coordinate_frame, position_m,
orientation_xyzw). All left empty, each for a stated reason rather than a
convenient default — the pose fields in particular, because a CSI link has no
boresight and §6 makes no validated room-coordinate claim. The nine existing
P1 gates pass unchanged.

The decision this module makes is structural rather than a runtime refusal.
The adapter's full per-bin frame is P0 and would be dropped by the egress gate
after all the work of parsing and signing it; its 32-bin coarse reduction is
P1 and egress-safe. So the module does not offer the choice — it configures
the coarse mode, because a consumer cannot un-coarsen a coarse profile whereas
a check can be reordered. The gate still runs and is asserted to drop nothing.

12 gates in tests/ultrasonic_gates.rs, including the honest negative result: an
ultrasonic scan produces no fused inferences at all, and both independent
reasons are pinned. The adapter declines to populate `presence` — one
transducer pair cannot distinguish a person from a coat over the back of a
chair — and the engine's feature vocabulary is entirely statements about a
body, so range_m has nothing to drive.

The fixture is BatVu's own emitter output, byte-identical to the one in
ruvnet/rufield, so schema drift fails a build in one of three repositories
rather than an ingest in a deployment.

Not wired into the running server; P1 shipped as a library before P3 wired it
in, and this follows the same staging.
2026-08-25 21:40:53 -04:00
rUv 0df48df7b2 feat: add native iPhone LiDAR sensor and web viewer (#1684)
* feat(ios): add RuView LiDAR frame protocol

* feat(ios): capture ARKit scene depth for RuView

* feat(ios): stream compact LiDAR frames over websocket

* feat(ios): add native LiDAR capture UI

* feat(ios): add RuView LiDAR app entrypoint

* feat(web): add LiDAR bridge web package

* feat(web): decode RuView LiDAR wire frames

* feat(web): add local LiDAR websocket relay

* feat(web): add LiDAR browser viewer

* feat(web): render live LiDAR point cloud

* fix(ios): use wall clock time for LiDAR provenance

* feat(web): style LiDAR viewer

* test(web): add LiDAR codec tests

* docs: add iPhone LiDAR integration guide

* docs(adr): define iPhone LiDAR sensor bridge

* fix(ios): harden and validate LiDAR bridge

* fix(ios): qualify LiDAR wire depth type
2026-08-22 18:06:16 -04:00
rUv bd110e0eac fix(homecore): enable standalone Arc serialization (#1682) 2026-08-22 15:55:36 -04:00
rUv f3c361efd1 fix: align multistatic CSI time and clear Rust advisories (#1669)
Use mesh-aligned capture timestamps, remediate Rust advisories, harden the audit gate, and correct deployment claims. Includes the live MQTT subscriber lifetime fix verified against Mosquitto.
2026-08-22 14:59:22 -04:00
rUv a3b6e1d500 docs(harness): publish final Cognitum Spaces evidence (#1653) 2026-08-19 14:52:09 -04:00
rUv 1d2ad6aa8e docs(harness): mark Cognitum Spaces guidance live (#1651) 2026-08-19 14:03:15 -04:00
rUv c929bbc8b3 feat(spaces): add spatial memory and governed actions (#1650) 2026-08-19 13:23:29 -04:00
rUv d36f346bba feat(metaharness): add guarded Cognitum Spaces OAuth (#1644) 2026-08-18 21:58:39 -04:00
rUv 2c249ec8cb docs(adr): record Cognitum Spaces production evidence (#1639)
* docs(adr): record Cognitum Spaces production evidence

* docs(adr): link deferred Spaces milestones
2026-08-18 15:59:00 -04:00
rUv 7927839f4f feat: add governed Cognitum Spaces activation (#1631)
Add explicit spaces:read consent, a bounded read client and CLI surface, and ADR-325's privacy, memory, and action-governance contract.

Closes #1630.
2026-08-18 15:21:48 -04:00
rUv a76adc3c2f Merge pull request #1624 from ruvnet/claude/off-axis-sneaker-ruview-u398g0
ADR-324: off-axis-mode — clean-room Kooima projection in Rust/WASM + demo
2026-08-16 21:02:12 -04:00
Claudeandclaude-flow aae2ed5345 chore: refresh scheduled_tasks session lock
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BU3NcEgTpAVvu5QGtw4czT
2026-08-16 23:53:55 +00:00
Claudeandclaude-flow 3161af52da feat(offaxis): clean-room Kooima off-axis projection in Rust/WASM (ADR-324)
New leaf crate v2/crates/ruview-offaxis implementing ADR-324's projection
core from the published math only (Kooima 2008 generalized perspective
projection; Casiez 2012 one-euro filter) — no code from the unlicensed
prior-art repo. Dependency-free native core; wasm-bindgen surface gated to
wasm32 (cdylib+rlib, ~54 KB wasm after bindgen).

- projection: Screen (3 corners, any orientation) + off_axis() -> typed
  errors, never NaN matrices; column-major f64 (three.js Matrix4 layout).
  Tests pin the defining invariants: screen corners -> NDC corners for a
  grid of eye positions and tilted screens; screen-plane points are
  eye-invariant; centered eye reduces to the symmetric frustum; near/far
  map to NDC -1/+1.
- filter: one-euro with injected timestamps (no clock in crate);
  monotonicity/convergence/NaN-rejection tests.
- rf: field-peak extraction mirroring field_localize.rs constants
  (X_SCALE 0.6, Z_SCALE 0.5, PEAK_THRESHOLD 0.35) and the Tier B
  coarse-parallax stage (deadband, gain, hard clamp) so over-claiming is
  impossible at the API level. No accuracy numbers asserted.
- wasm: OffAxisCamera + RfParallax bindgen classes; per-frame updates hold
  last good state instead of throwing.
- benches (criterion): full Tier B frame ~598 ns; argmax scan optimized
  -18%/-33% (20x20/100x100). MEASURED table + reproducer in README.
- examples/three.js/demos/07-off-axis-window.html: demo with SYNTHETIC
  mouse simulator and labeled RF Tier B mode ('coarse body parallax - not
  head tracking'), physical calibration panel, /ws/sensing input, and a
  build-instructions overlay when the local pkg/ output is missing
  (generated artifacts stay uncommitted; .gitignore entry added).
- Validated 23 unit tests + doctest, clippy clean, wasm32 release build,
  Node smoke test of the bindgen output, and a headless-Chromium run of
  the demo (engine load, eye response, mode labels, ws failure path).
- ADR-324: header + section 2.5 amendment recording the Rust/WASM core.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BU3NcEgTpAVvu5QGtw4czT
2026-08-16 23:52:43 +00:00
Claudeandclaude-flow 501f138360 docs(adr): ADR-324 off-axis head-coupled perspective demo (RF-assisted)
Deep-research ADR answering whether icurtis1/off-axis-sneaker can be used
with RuView. Adopts the head-coupled perspective technique (Kooima
generalized off-axis projection) via a clean-room implementation — the
upstream repo is unlicensed, so no code or assets are reused. Defines a
tiered integration: webcam-fine tracking with RF presence gating and
multi-person arbitration (Tier A), an explicitly labeled RF-only coarse
body-parallax mode (Tier B), and evidence-gated future metric RF head
positioning (Tier C). No server changes; existing /ws/sensing and
/api/v1/stream/pose streams only. Indexed in the ADR README.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BU3NcEgTpAVvu5QGtw4czT
2026-08-16 23:20:11 +00:00
rUv 4685618388 fix: remediate vitals, desktop, and repository integrity issues (#1618)
Fix breathing confidence, Windows desktop launch and clean UI builds, contributor-harness line-ending integrity, runtime secret hygiene, and remove the six ADR-299 CSI/person-data files from the current tree.
2026-08-15 20:09:43 -04:00
rUv 1d50518a70 Merge pull request #1617 from ruvnet/feat/adr-323-pose-physics
feat: add physics-constrained pose refinement
2026-08-15 17:08:02 -04:00
ruv 0370d49e4a feat: add physics-constrained pose refinement 2026-08-15 14:29:07 -04:00
327 changed files with 40876 additions and 152885 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
{"sessionId":"905385c4-b13f-5091-96df-5752fb109cf5","pid":509,"procStart":"527","acquiredAt":1786922977672}
+3
View File
@@ -0,0 +1,3 @@
# The contributor harness hashes provenance inputs byte-for-byte. Keep text
# files in this boundary on LF even when Windows enables core.autocrlf.
harness/ruview/** text=auto eol=lf
+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
node-version: '22'
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
+64 -8
View File
@@ -53,7 +53,9 @@ jobs:
sdkconfig: sdkconfig.defaults
partition_table_name: partitions_display.csv
size_warn_kb: 1100
size_limit_kb: 1152
# Current 8 MB image is 1163 KiB after USB onboarding. The OTA app
# partition is 2048 KiB; retain a 1200 KiB hard gate and warning.
size_limit_kb: 1200
artifact_app: esp32-csi-node.bin
artifact_pt: partition-table.bin
- variant: 4mb
@@ -92,12 +94,11 @@ jobs:
idf.py set-target ${{ matrix.target }}
idf.py build
- name: Build and run host-side ADR-110 unit tests
- name: Build and run host-side firmware unit tests
if: matrix.variant == 'c6-4mb'
working-directory: firmware/esp32-csi-node/test
run: |
make test_adr110
./test_adr110
make host_tests
- name: Verify binary size budget
working-directory: firmware/esp32-csi-node
@@ -162,10 +163,14 @@ jobs:
mkdir -p release-staging
cp build/esp32-csi-node.bin release-staging/${{ matrix.artifact_app }}
cp build/partition_table/partition-table.bin release-staging/${{ matrix.artifact_pt }}
if [ "${{ matrix.variant }}" = "8mb" ]; then
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
fi
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
cp version.txt release-staging/version.txt
(cd release-staging && sha256sum \
"${{ matrix.artifact_app }}" \
"${{ matrix.artifact_pt }}" \
bootloader.bin ota_data_initial.bin version.txt \
> SHA256SUMS.txt)
ls -la release-staging/
- name: Check QEMU ESP32-S3 support status
@@ -180,3 +185,54 @@ jobs:
name: esp32-csi-node-firmware-${{ matrix.variant }}
path: firmware/esp32-csi-node/release-staging/
retention-days: 90
build-c6-idf6:
name: Build XIAO C6 external antenna (ESP-IDF 6.0.2)
runs-on: ubuntu-latest
container:
image: espressif/idf:v6.0.2
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Build the IDF 6 canary image
working-directory: firmware/esp32-csi-node
run: |
. $IDF_PATH/export.sh
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" set-target esp32c6
idf.py build
test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024))
strings build/esp32-csi-node.bin | grep -F "external U.FL"
publish-release-assets:
name: Publish firmware release assets
if: github.ref_type == 'tag'
needs: [version-guard, build, build-c6-idf6]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download the exact-run firmware artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: esp32-csi-node-firmware-*
path: release-downloads
- name: Package variants without filename collisions
run: |
mkdir -p release-assets
for directory in release-downloads/esp32-csi-node-firmware-*; do
variant="${directory##*/}"
tar -C "$directory" -czf "release-assets/${variant}.tar.gz" .
done
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS.txt)
- name: Publish the downloadable firmware bundles
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
name: ESP32 firmware ${{ github.ref_name }}
body: |
Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles.
Verify each archive against `SHA256SUMS.txt` before flashing.
files: release-assets/*
+70
View File
@@ -0,0 +1,70 @@
name: iPhone LiDAR integration
on:
push:
branches: [main]
paths:
- 'integrations/iphone-lidar/**'
- 'docs/adr/ADR-340-iphone-lidar-sensor-bridge.md'
- '.github/workflows/iphone-lidar.yml'
pull_request:
paths:
- 'integrations/iphone-lidar/**'
- 'docs/adr/ADR-340-iphone-lidar-sensor-bridge.md'
- '.github/workflows/iphone-lidar.yml'
permissions:
contents: read
jobs:
web:
name: Node relay and codec
runs-on: ubuntu-latest
defaults:
run:
working-directory: integrations/iphone-lidar/web
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '22'
cache: npm
cache-dependency-path: integrations/iphone-lidar/web/package-lock.json
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Run tests
run: npm test
- name: Audit runtime dependencies
run: npm audit --omit=optional --audit-level=high
ios:
name: iOS 17 compile
runs-on: macos-15
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Compile native sources with strict concurrency
shell: bash
run: |
set -euo pipefail
sdk="$(xcrun --sdk iphoneos --show-sdk-path)"
build_dir="$RUNNER_TEMP/ruview-lidar-build"
mkdir -p "$build_dir"
cd "$build_dir"
xcrun swiftc \
-parse-as-library \
-target arm64-apple-ios17.0 \
-sdk "$sdk" \
-module-name RuViewLiDAR \
-strict-concurrency=complete \
-warnings-as-errors \
-emit-module \
-emit-module-path "$build_dir/RuViewLiDAR.swiftmodule" \
-c "$GITHUB_WORKSPACE"/integrations/iphone-lidar/native/RuViewLiDAR/*.swift
+3 -2
View File
@@ -40,8 +40,9 @@ jobs:
- dir: harness/ruview
build: false
publishable: true
# ADR-283: brain + local hosts + replay assets; still runtime-dependency-free.
unpacked_budget: 131072
# ADR-283/325: brain + local hosts + replay assets + guarded Spaces OAuth adapter;
# still runtime-dependency-free. 160 KiB is the reviewed hard ceiling.
unpacked_budget: 163840
- dir: harness/homecore
build: false
publishable: true
+627
View File
@@ -0,0 +1,627 @@
name: RuForecast Rust CI
on:
push:
branches: [main, develop, 'feat/*', 'feature/*']
paths:
- 'v2/crates/ruforecast'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
- 'v2/rust-toolchain.toml'
- 'scripts/csi-data-policy-check.sh'
- 'scripts/run-ruforecast-benchmarks.sh'
- 'docs/adr/ADR-348-*.md'
- 'docs/adr/ADR-349-*.md'
- 'docs/adr/ADR-350-*.md'
- 'docs/adr/README.md'
- 'docs/benchmarks/ruforecast.md'
- 'docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md'
- 'docs/security/ruview-forecast-*'
- 'docs/validation/ruforecast-requirements-evidence.md'
- '.github/workflows/ruforecast-ci.yml'
pull_request:
branches: [main, develop]
paths:
- 'v2/crates/ruforecast'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
- 'v2/rust-toolchain.toml'
- 'scripts/csi-data-policy-check.sh'
- 'scripts/run-ruforecast-benchmarks.sh'
- 'docs/adr/ADR-348-*.md'
- 'docs/adr/ADR-349-*.md'
- 'docs/adr/ADR-350-*.md'
- 'docs/adr/README.md'
- 'docs/benchmarks/ruforecast.md'
- 'docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md'
- 'docs/security/ruview-forecast-*'
- 'docs/validation/ruforecast-requirements-evidence.md'
- '.github/workflows/ruforecast-ci.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ruforecast-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_PROFILE_DEV_DEBUG: '0'
CARGO_PROFILE_TEST_DEBUG: '0'
CARGO_PROFILE_BENCH_DEBUG: '0'
CARGO_PROFILE_RELEASE_DEBUG: '0'
jobs:
contract-rust-189:
name: Forecast contract and feature-off boundary (Rust 1.89)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout recursively
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
submodules: recursive
- name: Install Rust 1.89 with lint components
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.89.0
components: rustfmt,clippy
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2/crates/ruforecast
key: ruforecast-rust-189
- name: Validate benchmark runner and evidence contract
shell: bash
run: |
set -euo pipefail
bash -n scripts/run-ruforecast-benchmarks.sh
test -x scripts/run-ruforecast-benchmarks.sh
test -f docs/benchmarks/ruforecast.md
test -f docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md
test -f docs/security/ruview-forecast-clean-room.md
test -f docs/security/ruview-forecast-threat-model.md
test -f docs/validation/ruforecast-requirements-evidence.md
for adr_number in 348 349 350; do
mapfile -t adr_files < <(
find docs/adr -maxdepth 1 -type f -name "ADR-${adr_number}-*.md" -print
)
if [[ "${#adr_files[@]}" -ne 1 ]]; then
echo "Expected exactly one ADR-${adr_number} file, found ${#adr_files[@]}" >&2
exit 1
fi
grep -q "^# ADR-${adr_number}:" "${adr_files[0]}"
grep -Eq '^[-*] \*\*Status\*\*: (Proposed|Accepted|Superseded|Deprecated)( |$|—)|^## Status' "${adr_files[0]}"
done
grep -q '^## Requirements' docs/adr/ADR-348-*.md
grep -q '^## Acceptance gates' docs/adr/ADR-348-*.md
grep -q '^## Requirements and acceptance' docs/adr/ADR-349-*.md
grep -q '^## Requirements and acceptance' docs/adr/ADR-350-*.md
for adr_number in 348 349 350; do
grep -q "| \[ADR-${adr_number}\](ADR-${adr_number}-" docs/adr/README.md \
|| { echo "ADR-${adr_number} is missing from the ADR index" >&2; exit 1; }
done
for number in $(seq 1 12); do
requirement="$(printf 'RF-%03d' "$number")"
grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \
|| { echo "Missing evidence row for ${requirement}" >&2; exit 1; }
done
for number in $(seq 1 8); do
requirement="$(printf 'FT-%03d' "$number")"
grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \
|| { echo "Missing evidence row for ${requirement}" >&2; exit 1; }
done
for number in $(seq 1 9); do
requirement="$(printf 'PM-%03d' "$number")"
grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \
|| { echo "Missing evidence row for ${requirement}" >&2; exit 1; }
done
python3 - <<'PY'
from pathlib import Path
import tomllib
root = Path("v2/crates/ruforecast/crates")
core = tomllib.loads((root / "ruforecast-core/Cargo.toml").read_text())
model = tomllib.loads((root / "ruforecast-model/Cargo.toml").read_text())
train = tomllib.loads((root / "ruforecast-train/Cargo.toml").read_text())
assert core.get("features", {}).get("default", []) == []
assert model["features"]["default"] == [], "model defaults must stay backend-free"
assert train["features"]["default"] == [], "train defaults must stay backend-free"
assert {"model", "cpu", "cuda", "wgpu", "ruvector"} <= model["features"].keys()
assert {"cpu", "cuda", "training", "cli", "server", "fal-client"} <= train["features"].keys()
assert {"model", "dep:burn-ndarray"} <= set(model["features"]["cpu"])
assert {"model", "dep:burn-cuda"} <= set(model["features"]["cuda"])
assert {"model", "dep:burn-wgpu"} <= set(model["features"]["wgpu"])
assert "dep:ruvector-core" in model["features"]["ruvector"]
assert {"ruforecast-model/cpu", "training"} <= set(train["features"]["cpu"])
assert {"ruforecast-model/cuda", "training"} <= set(train["features"]["cuda"])
bins = {entry["name"]: entry for entry in train.get("bin", [])}
assert bins["ruforecast"]["required-features"] == ["cli"]
model_benches = {entry["name"]: entry for entry in model.get("bench", [])}
train_benches = {entry["name"]: entry for entry in train.get("bench", [])}
assert model_benches["forecast_inference"]["harness"] is False
assert model_benches["forecast_inference"]["required-features"] == ["cpu"]
assert train_benches["data_pipeline"]["harness"] is False
assert train_benches["data_pipeline"]["required-features"] == ["cpu"]
PY
- name: Check formatting for forecast packages only
working-directory: v2/crates/ruforecast
run: >-
cargo +1.89.0 fmt
-p ruforecast-core
-p ruforecast-model
-p ruforecast-train
-- --check
- name: Prove feature-off graph excludes Burn and CubeCL
working-directory: v2/crates/ruforecast
shell: bash
run: |
set -euo pipefail
: > forecast-feature-off-tree.txt
for package in ruforecast-core ruforecast-model ruforecast-train; do
cargo +1.89.0 tree --locked -e normal,build -p "$package" --no-default-features \
>> forecast-feature-off-tree.txt
done
if grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' forecast-feature-off-tree.txt; then
echo 'Burn/CubeCL entered the Rust 1.89 feature-off dependency graph.' >&2
cat forecast-feature-off-tree.txt >&2
exit 1
fi
- name: Check feature-off packages
working-directory: v2/crates/ruforecast
run: >-
cargo +1.89.0 check --locked
-p ruforecast-core
-p ruforecast-model
-p ruforecast-train
--no-default-features --all-targets
- name: Test model-neutral core
working-directory: v2/crates/ruforecast
run: >-
cargo +1.89.0 test --locked
-p ruforecast-core
--no-default-features --lib --tests
- name: Test model contracts without a model backend
working-directory: v2/crates/ruforecast
run: >-
cargo +1.89.0 test --locked
-p ruforecast-model
--no-default-features --lib --tests
- name: Test RuVector retrieval boundary without a model backend
working-directory: v2/crates/ruforecast
run: >-
cargo +1.89.0 test --locked
-p ruforecast-model
--no-default-features --features ruvector --lib --tests
- name: Clippy RuVector retrieval boundary
working-directory: v2/crates/ruforecast
run: >-
cargo +1.89.0 clippy --locked
-p ruforecast-model
--no-default-features --features ruvector --all-targets -- -D warnings
- name: Clippy forecast packages without backend features
working-directory: v2/crates/ruforecast
run: >-
cargo +1.89.0 clippy --locked
-p ruforecast-core
-p ruforecast-model
-p ruforecast-train
--no-default-features --all-targets -- -D warnings
- name: Upload feature-off dependency tree
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ruforecast-feature-off-tree
path: v2/crates/ruforecast/forecast-feature-off-tree.txt
if-no-files-found: error
hosted-boundaries-rust-189:
name: Forecast hosted boundaries and privacy contract (Rust 1.89)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout recursively
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
submodules: recursive
- name: Install Rust 1.89 with Clippy
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.89.0
components: clippy
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2/crates/ruforecast
key: ruforecast-hosted-rust-189
- name: Enforce clean-room source, secret, and hosted-wire boundary
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
import sys
roots = [
Path("v2/crates/ruforecast/crates/ruforecast-core"),
Path("v2/crates/ruforecast/crates/ruforecast-model"),
Path("v2/crates/ruforecast/crates/ruforecast-train"),
]
text_suffixes = {".json", ".py", ".rs", ".sh", ".toml", ".yaml", ".yml"}
forbidden_source = re.compile(r"(?i)\b(?:times[ _-]?fm|google[ _/-]?research)\b")
secret_patterns = {
"private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
"AWS access key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
"JWT": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
"presigned URL": re.compile(r"(?i)[?&](?:X-Amz-Signature|X-Goog-Signature)="),
"literal FAL_KEY": re.compile(r"(?i)\bFAL_KEY\b\s*[:=]\s*['\"][^'$<{\"\n]{8,}['\"]"),
}
forbidden_blobs = {".ckpt", ".mpk", ".npy", ".npz", ".onnx", ".pt", ".pth", ".safetensors"}
errors = []
for root in roots:
for path in root.rglob("*"):
if not path.is_file() or "target" in path.parts:
continue
if path.suffix.lower() in forbidden_blobs:
errors.append(f"forbidden checked-in model/data blob: {path}")
continue
if path.suffix.lower() not in text_suffixes:
continue
text = path.read_text(encoding="utf-8")
if forbidden_source.search(text):
errors.append(f"forbidden clean-room source/import reference: {path}")
for label, pattern in secret_patterns.items():
if pattern.search(text):
errors.append(f"possible {label} in {path}")
fal_source = Path("v2/crates/ruforecast/crates/ruforecast-train/src/fal.rs").read_text()
for struct_name in ("HostedOptimizer", "HostedBudget", "HostedSyntheticPayload"):
match = re.search(
rf"pub struct {struct_name}\s*\{{(?P<body>.*?)^\}}",
fal_source,
flags=re.MULTILINE | re.DOTALL,
)
if match is None:
errors.append(f"missing hosted wire struct: {struct_name}")
continue
body = match.group("body")
fields = re.findall(r"^\s*pub\s+([A-Za-z0-9_]+)\s*:", body, re.MULTILINE)
forbidden_fields = (
"account", "data_policy", "dataset", "device", "identity", "path",
"person", "room", "session", "site", "split", "subject", "tenant",
"workspace",
)
for field in fields:
lowered = field.lower()
if any(fragment in lowered for fragment in forbidden_fields):
errors.append(f"forbidden hosted field {struct_name}.{field}")
if "bytes" in lowered and field not in {
"max_artifact_bytes",
"max_memory_bytes",
}:
errors.append(f"unapproved hosted byte field {struct_name}.{field}")
if "DataPolicy" in body:
errors.append(f"DataPolicy entered hosted wire struct {struct_name}")
if errors:
print("\n".join(errors), file=sys.stderr)
raise SystemExit(1)
PY
- name: Enforce tracked-data policy
run: |
set -euo pipefail
bash scripts/csi-data-policy-check.sh --self-test
bash scripts/csi-data-policy-check.sh --tracked
- name: Validate minimal fal deployment archive policy
run: >-
python3
v2/crates/ruforecast/crates/ruforecast-train/deploy/fal/deploy.py
self-test
- name: Prove hosted feature graph excludes Burn and CubeCL
working-directory: v2/crates/ruforecast
shell: bash
run: |
set -euo pipefail
cargo +1.89.0 tree --locked -e normal,build \
-p ruforecast-train \
--no-default-features --features cli,server,fal-client \
> forecast-hosted-feature-tree.txt
if grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' \
forecast-hosted-feature-tree.txt; then
echo 'Burn/CubeCL entered the Rust 1.89 hosted dependency graph.' >&2
cat forecast-hosted-feature-tree.txt >&2
exit 1
fi
- name: Require named hosted boundary regressions
working-directory: v2/crates/ruforecast
shell: bash
env:
FAL_KEY: ''
run: |
set -euo pipefail
cargo +1.89.0 test --locked \
-p ruforecast-train \
--no-default-features --features cli,server,fal-client \
--lib --bins --tests -- --list \
| tee hosted-boundary-tests.txt
for regression in \
fal_app_rejects_path_and_host_confusion \
fal_urls_require_exact_origin_path_and_no_query \
fal_submit_headers_disable_retry_and_omit_store_io \
hosted_payload_dto_has_no_customer_fields \
queue_status_discards_provider_error_body \
result_url_accepts_exact_response_suffix \
artifact_file_url_uses_expected_api_path \
fal_key_debug_is_redacted \
hosted_payload_rejects_app_field \
hosted_reservation_cannot_exceed_source_retention \
artifact_handoff_expiry_boundary_is_fail_closed \
fal_result_enforces_cumulative_artifact_budget_boundary \
fal_download_rejects_over_budget_without_writing \
direct_server_train_requires_request_id_header \
request_id_rejects_path_injection \
unknown_cancel_is_not_success \
privacy_external_dataset_payload_is_denied; do
grep -q "$regression" hosted-boundary-tests.txt \
|| { echo "Missing hosted boundary regression: $regression" >&2; exit 1; }
done
- name: Exercise hosted external-data rejection
working-directory: v2/crates/ruforecast
env:
FAL_KEY: ''
run: >-
cargo +1.89.0 test --locked
-p ruforecast-train
--no-default-features --features cli,server,fal-client
privacy_external_dataset_payload_is_denied
- name: Test server and fal client with mock integrations
working-directory: v2/crates/ruforecast
env:
FAL_KEY: ''
run: >-
cargo +1.89.0 test --locked
-p ruforecast-train
--no-default-features --features cli,server,fal-client
--lib --bins --tests
- name: Clippy server and fal client boundaries
working-directory: v2/crates/ruforecast
env:
FAL_KEY: ''
run: >-
cargo +1.89.0 clippy --locked
-p ruforecast-train
--no-default-features --features cli,server,fal-client
--all-targets -- -D warnings
- name: Clippy standalone CLI feature boundary
working-directory: v2/crates/ruforecast
env:
FAL_KEY: ''
run: >-
cargo +1.89.0 clippy --locked
-p ruforecast-train
--no-default-features --features cli
--bin ruforecast -- -D warnings
- name: Upload hosted dependency tree
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ruforecast-hosted-feature-tree
path: v2/crates/ruforecast/forecast-hosted-feature-tree.txt
if-no-files-found: error
burn-cpu-rust-192:
name: Forecast Burn CPU tests and bench compile (Rust 1.92)
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Checkout recursively
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
submodules: recursive
- name: Install Rust 1.92 with Clippy
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.92.0
components: clippy
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2/crates/ruforecast
key: ruforecast-rust-192-cpu
- name: Test model with Burn CPU backend
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 test --locked
-p ruforecast-model
--no-default-features --features cpu --lib --tests
- name: Test trainer and CLI with Burn CPU backend
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 test --locked
-p ruforecast-train
--no-default-features --features cpu,cli --lib --bins
- name: Run one-step local JSONL training smoke
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 test --locked
-p ruforecast-train
--no-default-features --features cpu
--test local_jsonl_smoke --
--exact local_hash_addressed_jsonl_executes_one_real_optimizer_step
- name: Run idempotent synthetic CLI training smoke
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 test --locked
-p ruforecast-train
--no-default-features --features cpu,cli
--test cli_smoke --
--exact cli_smoke_trains_and_writes_the_complete_candidate_set
- name: Test combined CPU and hosted feature surface
working-directory: v2/crates/ruforecast
env:
FAL_KEY: ''
run: >-
cargo +1.92.0 test --locked
-p ruforecast-train
--no-default-features --features cpu,cli,server,fal-client
--lib --bins
- name: Clippy model with Burn CPU backend
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 clippy --locked
-p ruforecast-model
--no-default-features --features cpu --all-targets -- -D warnings
- name: Clippy trainer and CLI with Burn CPU backend
working-directory: v2/crates/ruforecast
env:
FAL_KEY: ''
run: >-
cargo +1.92.0 clippy --locked
-p ruforecast-train
--no-default-features --features cpu,cli,server,fal-client
--all-targets -- -D warnings
- name: Compile forecast inference benchmark
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 bench --locked
-p ruforecast-model
--no-default-features --features cpu
--bench forecast_inference --no-run
- name: Compile forecast data-pipeline benchmark
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 bench --locked
-p ruforecast-train
--no-default-features --features cpu
--bench data_pipeline --no-run
- name: Run quick inference benchmark for trend visibility
continue-on-error: true
timeout-minutes: 15
working-directory: v2/crates/ruforecast
run: |
set -o pipefail
mkdir -p ../../bench-out/ruforecast
cargo +1.92.0 bench --locked \
-p ruforecast-model \
--no-default-features --features cpu \
--bench forecast_inference -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../../bench-out/ruforecast/forecast-inference.txt
- name: Run quick data-pipeline benchmark for trend visibility
continue-on-error: true
timeout-minutes: 15
working-directory: v2/crates/ruforecast
run: |
set -o pipefail
mkdir -p ../../bench-out/ruforecast
cargo +1.92.0 bench --locked \
-p ruforecast-train \
--no-default-features --features cpu \
--bench data_pipeline -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../../bench-out/ruforecast/data-pipeline.txt
- name: Upload informational benchmark logs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ruforecast-informational-cpu-benchmarks
path: bench-out/ruforecast/
if-no-files-found: warn
burn-cuda-compile-rust-192:
name: Forecast Burn CUDA/WGPU compile checks only (Rust 1.92)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout recursively
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
submodules: recursive
- name: Install Rust 1.92
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.92.0
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2/crates/ruforecast
key: ruforecast-rust-192-cuda-check
- name: Check model CUDA feature
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 check --locked
-p ruforecast-model
--no-default-features --features cuda --lib
- name: Check model WGPU feature
working-directory: v2/crates/ruforecast
run: >-
cargo +1.92.0 check --locked
-p ruforecast-model
--no-default-features --features wgpu --lib
- name: Check trainer CUDA and hosted-server feature combination
working-directory: v2/crates/ruforecast
env:
FAL_KEY: ''
run: >-
cargo +1.92.0 check --locked
-p ruforecast-train
--no-default-features --features cuda,cli,server
--lib --bins
+2 -2
View File
@@ -104,8 +104,8 @@ jobs:
run: |
set -euo pipefail
case "${{ inputs.package }}" in
# ADR-283: brain + local hosts + replay assets; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=131072 ;;
# ADR-283/325: brain + hosts + replay + guarded Spaces OAuth; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=163840 ;;
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
harness/homecore) export UNPACKED_BUDGET=180000 ;;
# ADR-264 O2: map-free tarball (was 188 kB with maps).
+27
View File
@@ -14,6 +14,33 @@ env:
PYTHON_VERSION: '3.11'
jobs:
# Rust dependency advisories are deterministic for the checked-in lockfile,
# so this job gates the PR and retains the exact machine-readable report.
rust-audit:
name: Rust Dependency Audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Install cargo-audit
run: cargo install cargo-audit --locked --version 0.22.2
- name: Audit the checked-in Rust lockfile
run: |
set -o pipefail
cargo audit --file v2/Cargo.lock --json | tee v2/cargo-audit.json
- name: Upload Rust advisory report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: cargo-audit-report
path: v2/cargo-audit.json
if-no-files-found: error
# Static Application Security Testing (SAST)
sast:
name: Static Application Security Testing
@@ -28,6 +28,7 @@ on:
- 'v2/crates/wifi-densepose-wifiscan/**'
- 'v2/crates/wifi-densepose-bfld/**'
- 'v2/crates/cog-ha-matter/**'
- 'v2/crates/homecore*/**'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
- 'ui/**'
+23 -5
View File
@@ -17,11 +17,16 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak
firmware/esp32-hello-world/sdkconfig.old
# Host-built firmware test binaries (compiled from test/*.c, not source)
firmware/esp32-csi-node/test/test_adr110
firmware/esp32-csi-node/test/test_vitals
firmware/esp32-csi-node/test/fuzz_serialize
firmware/esp32-csi-node/test/fuzz_edge
firmware/esp32-csi-node/test/fuzz_nvs
# Compiled host-test and fuzz binaries. The Makefile writes them beside their
# sources, so they land in a tracked directory. Ignore them by SHAPE rather
# than by name: every source here carries an extension and no binary does.
#
# This replaced a hand-maintained list of five targets, which had gone stale --
# test_csi_sanitize, test_mmwave_detect and test_thermal were all built by
# `make host_tests`, ignored by nothing, and got committed by accident.
firmware/esp32-csi-node/test/test_*
firmware/esp32-csi-node/test/fuzz_*
!firmware/esp32-csi-node/test/*.*
firmware/esp32-csi-node/test/*.exe
firmware/esp32-csi-node/test/*.obj
@@ -303,4 +308,17 @@ ruvector.db
# sensing-server runtime artifacts written by its test suite (trained model
# snapshots + the generated session-secret) — never tracked
v2/crates/wifi-densepose-sensing-server/data/
# The server also writes this secret when launched from v2/. Keep the rule
# file-specific so tracked datasets below v2/data remain visible.
/v2/data/session-secret
*.proptest-regressions
# ADR-324: wasm-bindgen output for ruview-offaxis is generated locally
# (see the crate README); never commit generated artifacts.
v2/crates/ruview-offaxis/pkg/
# fal.ai visual-teacher LoRA training data/weights -- regenerable, never
# commit training images or model weights (ADR-353)
scripts/fal-visual-teacher/training-data/
scripts/fal-visual-teacher/training-data.zip
scripts/fal-visual-teacher/output/
+4
View File
@@ -33,3 +33,7 @@
path = vendor/metaharness
url = https://github.com/ruvnet/metaharness
branch = main
[submodule "v2/crates/ruforecast"]
path = v2/crates/ruforecast
url = https://github.com/ruvnet/RuForecast.git
branch = main
+8 -7
View File
@@ -47,17 +47,18 @@ from the current tree when needed.
## RuView contributor harness
`@ruvnet/ruview@0.3.1` is the runtime-dependency-free contributor interface
`@ruvnet/ruview@0.5.0` is the runtime-dependency-free contributor interface
defined by ADR-283.
```bash
npx @ruvnet/ruview@0.3.1 doctor
npx @ruvnet/ruview@0.3.1 guidance --topic homecore --query "restore and plugins"
npx @ruvnet/ruview@0.3.1 agent run \
npx @ruvnet/ruview@0.5.0 doctor
npx @ruvnet/ruview@0.5.0 guidance --topic homecore --query "restore and plugins"
npx @ruvnet/ruview@0.5.0 agent run \
--host codex --repo . --prompt "Find the nearest tests and cite files"
npx @ruvnet/ruview@0.3.1 brain search --query "community memory"
npx @ruvnet/ruview@0.3.1 brain verify --repo .
npx @ruvnet/ruview@0.3.1 mcp start
npx @ruvnet/ruview@0.5.0 brain search --query "community memory"
npx @ruvnet/ruview@0.5.0 brain verify --repo .
npx @ruvnet/ruview@0.5.0 spaces
npx @ruvnet/ruview@0.5.0 mcp start
```
Start unfamiliar repository work with `ruview_guidance`. It returns reviewed
+1
View File
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
### Fixed
- **Pose-vitals, desktop, and repository-integrity issue remediation.** Breathing confidence now measures periodic autocorrelation at the estimated respiratory frequency instead of penalizing clean sinusoidal signals via crest factor (#1610). The desktop launcher resolves the Windows `.exe`, uses `where` for PATH lookup, and passes log filtering through `RUST_LOG`; its React versions, Vite type declarations, and Tauri UI hook working directories are aligned (#1516, #1517, #1518). Runtime session secrets written from `v2/` are ignored, and contributor-harness provenance inputs are pinned to LF across Windows checkouts (#1519, #1520). With explicit owner authorization, the six raw CSI/person-data capture and metadata files identified by ADR-299 were removed from the current tree; historical copies remain pending separately coordinated incident response.
- **`docs/huggingface/MODEL_CARD.md` had drifted from the model card actually published on the Hub (issue #1481).** Every filename in its "Files in this repo" table (`pretrained-encoder.onnx`, `pretrained-heads.onnx`, `pretrained.rvf`, `room-profiles.json`) pointed at files never uploaded to `ruvnet/wifi-densepose-pretrained` — only `config.json` existed. Replaced the in-repo card with the content actually live on the Hub (`model.safetensors`, `model-q{2,4,8}.bin`, `node-{1,2}.json`, `presence-head.json`, `csi-embed-v2.*`, honest v1→v2 retraction of the single-class "100%" presence claim) and added a "Using with the Rust sensing server (RVF conversion)" section documenting the `--convert-model`/`--convert-out` and `--model` auto-convert paths that neither card previously mentioned.
- **`--convert-model` failed on the published `model.safetensors`: NUL-padded safetensors header rejected by strict JSON parse (issue #1480, #894 follow-up).** The reference safetensors format pads its JSON header to an 8-byte boundary with trailing NUL bytes; `safetensors_to_rvf` (`wifi-densepose-sensing-server/src/model_format.rs`) fed the full declared-length header slice straight to `serde_json::from_slice`, which rejects the padding as "trailing characters." Since the only published full-precision weight file exercises this padding, `--convert-model` could not convert it at all. Fixed by trimming trailing NUL/whitespace bytes before parsing. Pinned by `safetensors_nul_padded_header_converts` (a header padded to the 8-byte boundary, matching the real HF file, converts and round-trips its weights through `ProgressiveLoader`).
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
+10 -7
View File
@@ -45,7 +45,7 @@ retrieved memories, generated proposals, and old test counts are not.
Do not hardcode crate, ADR, or test counts in instructions; derive them when a
task needs them.
## Contributor metaharness (`@ruvnet/ruview@0.3.1`)
## Contributor metaharness (`@ruvnet/ruview@0.4.0`)
ADR-283 defines the current community metaharness. It adds secure local
Claude/Codex execution, a reviewed shared brain, default-deny MCP mutation
@@ -54,21 +54,24 @@ free of runtime dependencies.
```bash
# Diagnose the installed harness
npx @ruvnet/ruview@0.3.1 doctor
npx @ruvnet/ruview@0.4.0 doctor
# Get a source-cited capability map before unfamiliar work
npx @ruvnet/ruview@0.3.1 guidance --topic homecore --query "restore and plugins"
npx @ruvnet/ruview@0.4.0 guidance --topic homecore --query "restore and plugins"
# Explore this trusted checkout through Claude Code (stdin, plan/safe mode)
npx @ruvnet/ruview@0.3.1 agent run \
npx @ruvnet/ruview@0.4.0 agent run \
--host claude-code --repo . --prompt "Map the relevant subsystem and cite files"
# Search reviewed, source-cited repository knowledge
npx @ruvnet/ruview@0.3.1 brain search --query "community memory"
npx @ruvnet/ruview@0.3.1 brain verify --repo .
npx @ruvnet/ruview@0.4.0 brain search --query "community memory"
npx @ruvnet/ruview@0.4.0 brain verify --repo .
# Read the OAuth-bound Cognitum Spaces projection
npx @ruvnet/ruview@0.4.0 spaces
# Run the dependency-free RuView MCP server
npx @ruvnet/ruview@0.3.1 mcp start
npx @ruvnet/ruview@0.4.0 mcp start
```
`ruview_guidance` returns reviewed capability maturity, repository citations,
+27 -25
View File
@@ -2,20 +2,11 @@
<p align="center">
<a href="https://cognitum.one/seed">
<img src="assets/ruview-seed.png" alt="RuView - WiFi DensePose" width="100%">
</a>
</p>
<p align="center">
<a href="https://cognitum.one/marketplace">
<img src="assets/musica-promo.png" alt="Cognitum Musica" width="100%">
</a>
</p>
<p align="center">
<a href="https://github.com/ruvnet/RuCelium">
<img src="assets/rucelium-hero.png" alt="RuCelium — environmental intelligence" width="100%">
<img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%">
</a>
</p>
## **See through walls with WiFi** ##
**Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
@@ -49,25 +40,26 @@ Every WiFi router already fills your space with radio waves. When people move, b
<details>
<summary><strong>RuView MetaHarness</strong> — guided operation for humans and AI agents</summary>
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides source-cited guidance, guarded Claude Code/Codex agents, deterministic verification, and an honesty check for accuracy claims.
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides source-cited guidance, guarded Claude Code/Codex agents, deterministic verification, an honesty check for accuracy claims, and an explicitly granted OAuth-only Cognitum Spaces read.
```bash
# Check the local setup and get source-cited guidance
npx @ruvnet/ruview@0.3.1 doctor
npx @ruvnet/ruview@0.3.1 guidance --topic sensing --query "model loading"
npx @ruvnet/ruview@0.4.0 doctor
npx @ruvnet/ruview@0.4.0 guidance --topic sensing --query "model loading"
# Run a read-only RuView agent through Codex
npx @ruvnet/ruview@0.3.1 agent run --host codex --repo . \
npx @ruvnet/ruview@0.4.0 agent run --host codex --repo . \
--prompt "Find the nearest tests and cite the source files"
# Search or verify the reviewed contributor brain
npx @ruvnet/ruview@0.3.1 brain search --query "calibration"
npx @ruvnet/ruview@0.3.1 brain verify --repo .
npx @ruvnet/ruview@0.4.0 brain search --query "calibration"
npx @ruvnet/ruview@0.4.0 brain verify --repo .
# Check claims, replay the deterministic proof, or expose the MCP server
npx @ruvnet/ruview@0.3.1 claim-check --file REPORT.md
npx @ruvnet/ruview@0.3.1 verify
npx @ruvnet/ruview@0.3.1 mcp start
npx @ruvnet/ruview@0.4.0 claim-check --file REPORT.md
npx @ruvnet/ruview@0.4.0 verify
npx @ruvnet/ruview@0.4.0 spaces
npx @ruvnet/ruview@0.4.0 mcp start
```
Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`; retrieved brain content is evidence, not authority.
@@ -244,8 +236,8 @@ See the measured benchmarks, witness records, and one-command reproducibility ch
|------|-------|---------|
| **MM-Fi pose model (SOTA)** | [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) | 82.69% torso-PCK@20 (single) · 83.59% (ensemble+TTA) · 75K-param micro variant 74.30% |
| **AetherArena benchmark Space** | [`ruvnet/aether-arena`](https://huggingface.co/spaces/ruvnet/aether-arena) | self-correcting, auditable MM-Fi leaderboard |
| **Full MM-Fi study (honest picture)** | [`docs/benchmarks/mmfi-wifi-sensing-study.md`](docs/benchmarks/mmfi-wifi-sensing-study.md) | pose + action; zero-shot cross-subject ~64%, +~30 s in-room calibration → 72.2% |
| **Efficiency frontier** | [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md) | SOTA-beating WiFi pose in a 20 KB int4 edge model |
| **Full MM-Fi study (honest picture)** | [`docs/benchmarks/mmfi-wifi-sensing-study.md`](docs/benchmarks/mmfi-wifi-sensing-study.md) | pose + action; zero-shot cross-subject ~64%, labeled in-room calibration → 72.2% |
| **Efficiency frontier** | [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md) | SOTA-beating MM-Fi pose in a ~37 KB int4 model; live ESP32 compatibility not established |
| **Pretrained encoder** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) | 82.3% held-out temporal-triplet, 8 KB int4 |
| **Reproducible proof (Trust Kill Switch)** | [`archive/v1/data/proof/verify.py`](archive/v1/data/proof/verify.py) + [`expected_features.sha256`](archive/v1/data/proof/expected_features.sha256) | one-command deterministic pipeline replay (SHA-256 of output vs published hash) |
| **Benchmark-proof ADR** | [ADR-168](docs/adr/ADR-168-benchmark-proof.md) | how the numbers are produced and verified |
@@ -487,12 +479,20 @@ Neural Network: processed signals → 17 body keypoints + vital signs + room mod
Output: real-time pose, breathing, heart rate, room fingerprint, drift alerts
```
No training cameras required — the [Self-Learning system (ADR-024)](docs/adr/ADR-024-contrastive-csi-embedding-model.md) bootstraps from raw WiFi data alone. [MERIDIAN (ADR-027)](docs/adr/ADR-027-cross-environment-domain-generalization.md) ensures the model works in any room, not just the one it trained in.
The [Self-Learning system (ADR-024)](docs/adr/ADR-024-contrastive-csi-embedding-model.md) provides
camera-free representation-learning components. Cross-room pose remains a separate, data-gated
problem: [MERIDIAN (ADR-027)](docs/adr/ADR-027-cross-environment-domain-generalization.md) is
**Proposed**, while the measured calibration reference requires labeled CSI/keypoint pairs and
model-specific adapters. See the [model compatibility boundary](docs/user-guide.md#model-and-capture-compatibility).
---
## 🏢 Use Cases & Applications
> **Safety boundary:** these are research and prototype applications, not medical devices,
> emergency systems, or safety-certified controls. Vital-sign and pose outputs require independent
> validation on the exact hardware, room, subjects, and failure conditions before operational use.
WiFi sensing works anywhere WiFi exists. No new hardware in most cases — just software on existing access points or a $8 ESP32 add-on. Because there are no cameras, deployments avoid privacy regulations (GDPR video, HIPAA imaging) by design.
**Scaling:** Each AP distinguishes ~3-5 people (56 subcarriers). Multi-AP multiplies linearly — a 4-AP retail mesh covers ~15-20 occupants. No hard software limit; the practical ceiling is signal physics.
@@ -527,7 +527,7 @@ WiFi sensing works anywhere WiFi exists. No new hardware in most cases — just
| Use Case | What It Does | Hardware | Key Metric | Edge Module |
|----------|-------------|----------|------------|-------------|
| **Smart home automation** | Room-level presence triggers (lights, HVAC, music) that work through walls — no dead zones, no motion-sensor timeouts | 2-3 ESP32-S3 nodes ($24) | Through-wall range ~5m | [HVAC Presence](docs/edge-modules/building.md), [Lighting Zones](docs/edge-modules/building.md) |
| **Fitness & sports** | Rep counting, posture correction, breathing cadence during exercise — no wearable, no camera in locker rooms | 3+ ESP32-S3 mesh | Pose: 17 keypoints | [Breathing Sync](docs/edge-modules/exotic.md), [Gait Analysis](docs/edge-modules/medical.md) |
| **Fitness & sports research** | Explore motion and breathing cadence without a wearable or camera; reliable posture correction requires a validated compatible pose model | 3+ ESP32-S3 mesh + edge host | Prototype; no live S3 pose accuracy claim | [Breathing Sync](docs/edge-modules/exotic.md), [Gait Analysis](docs/edge-modules/medical.md) |
| **Childcare & schools** | Naptime breathing monitoring, playground headcount, restricted-area alerts — privacy-safe for minors | 2-4 ESP32-S3 per zone | Breathing: ±1 BPM | [Sleep Apnea](docs/edge-modules/medical.md), [Perimeter Breach](docs/edge-modules/security.md) |
| **Event venues & concerts** | Crowd density mapping, crush-risk detection via breathing compression, emergency evacuation flow tracking | Multi-AP mesh (4-8 APs) | Density per m² | [Customer Flow](docs/edge-modules/retail.md), [Panic Motion](docs/edge-modules/security.md) |
| **Stadiums & arenas** | Section-level occupancy for dynamic pricing, concession staffing, emergency egress flow modeling | Enterprise AP grid | 15-20 per AP mesh | [Dwell Heatmap](docs/edge-modules/retail.md), [Queue Length](docs/edge-modules/retail.md) |
@@ -694,7 +694,7 @@ claude --plugin-dir ./plugins/ruview
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.3.1`; the quick commands and fuller explanation are in the collapsed MetaHarness section near the top of this README and in [`harness/ruview/`](harness/ruview/README.md).
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.4.0`; the quick commands and fuller explanation are in the collapsed MetaHarness section near the top of this README and in [`harness/ruview/`](harness/ruview/README.md).
</details>
@@ -719,6 +719,8 @@ Start with the user, build, and calibration guides; expand for the full referenc
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [CSI Frame Selection — Measured](docs/csi-frame-selection-measured.md) | Which rate gate actually buys cross-node pairing, measured on a nine-node ESP32-C6 fleet as a 2x2 factorial in three Latin-square blocks. Selecting frames by 802.11 `rx_seq` roughly doubled pairing (+31.7 pp) while mesh-time alignment bought +0.5 pp, inside noise. Includes the trade it makes, a prediction that turned out wrong, the limits of the measurement, and `run_arms.py` so the comparison can be re-run rather than taken on trust. |
| [ESP32-C6 Firmware Runbook](firmware/esp32-csi-node/RUNBOOK.md) | What to run, in what order, and what to check: the three-file build that keeps OTA rollback compiled in, a five-key pre-flash gate, which flash regions OTA can and cannot reach, a single-board diagnostic flash with a revert path, and the tunables whose right value depends on your house rather than on the chip. |
| [Architecture Decisions](docs/adr/README.md) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
+11 -5
View File
@@ -1,8 +1,14 @@
# RuView Calibration Service (reference implementation)
Turn a **shared WiFi-CSI pose base model** into a room-specific one with a **30-second labeled
calibration** and a **~11 KB per-room LoRA adapter**. This is the deployable resolution of the
cross-subject / cross-environment generalization problem (full study: [ADR-150 §3.3–3.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)).
Fit a room-specific **~11 KB LoRA adapter** for a shared WiFi-CSI pose base from a short **labeled
capture**. This is a measured MM-Fi reference path for cross-subject / cross-environment adaptation
(full study: [ADR-150 §3.3–3.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)); it is not proof of
plug-and-play adaptation from a live ESP32 stream.
> **Not the proposed MERIDIAN fast path.** Both producers below require paired CSI and keypoint
> labels, and their tensor shapes and adapter files are model-specific. ADR-027's automatic,
> unlabeled 10-second MERIDIAN calibration remains **Proposed** and is not implemented as an
> end-to-end deployment command.
## Why
@@ -66,8 +72,8 @@ Adapters are **model-specific**. There are two calibration producers here:
| `cog_calibrate.py` | cog **conv+MLP** (`pose_v1.safetensors`, 56×20) | `[N,56,20]` | `.safetensors` (`fc1.a`/`fc1.b`/`fc2.a`/`fc2.b`) | Rust `cog-pose-estimation run --adapter` |
```bash
# Produce a cog-format per-room adapter for the deployed Rust pose engine:
python cog_calibrate.py --base pose_v1.safetensors --data calib.npz --out room.safetensors
# Produce a cog-format per-room adapter from X:[N,56,20], Y:[N,17,2]:
python cog_calibrate.py --base pose_v1.safetensors --data cog-calib.npz --out room.safetensors
# then in the cog runtime:
cog-pose-estimation run --config <cfg> --adapter room.safetensors
```
+29
View File
@@ -10,6 +10,35 @@ func main() {
exit(1)
}
// One connected-link RSSI sample for the Rust adapter. Keep the legacy
// no-argument 10 Hz stream below unchanged for Python callers.
if CommandLine.arguments.contains("--scan-once") {
guard interface.powerOn(),
let channel = interface.wlanChannel(),
interface.rssiValue() < 0 else {
fputs("WiFi is not connected\n", stderr)
exit(1)
}
let sample: [String: Any] = [
"ssid": interface.ssid() ?? "",
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
"channel": channel.channelNumber,
"rssi": interface.rssiValue(),
"noise": interface.noiseMeasurement(),
"timestamp": Date().timeIntervalSince1970,
"tx_rate": interface.transmitRate()
]
do {
let data = try JSONSerialization.data(withJSONObject: sample, options: [.sortedKeys])
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write(Data([0x0a]))
} catch {
fputs("Could not encode WiFi sample: \(error)\n", stderr)
exit(1)
}
return
}
// Flush stdout automatically to prevent buffering issues with Python subprocess
setbuf(stdout, nil)
+7
View File
@@ -0,0 +1,7 @@
{
"endpoint": "fal-ai/flux-lora",
"lora_url": "https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors",
"prompt": "ruviewstyle room visualization, a living room with a glowing cyan pose skeleton overlay, wifi signal arcs radiating across the space, a vitals readout panel, dark tech aesthetic, synthetic sensor visualization",
"scale": 1.0,
"source_image_url": "https://v3b.fal.media/files/b/0aa8cf67/JH1qyC3GCKQ9ojkrAUXe9.png"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,15 +0,0 @@
{
"id": "pretrain-1775182186",
"name": "pretrain-1775182186",
"label": "mixed-activity",
"started_at": "2026-04-03T02:09:46Z",
"ended_at": "2026-04-03T02:11:46Z",
"duration_secs": 120,
"frame_count": 5783,
"file_size_bytes": 2580539,
"file_path": "data/recordings\\pretrain-1775182186.csi.jsonl",
"nodes": {
"2": 2886,
"1": 2897
}
}
@@ -139,6 +139,32 @@ Implement the §3.3 mapping: `effective_class → PrivacyClass`, `cog-ha-matter`
Add an opt-in `/ws/field` endpoint (or a `field_events` array on `SensingUpdate` behind a flag) carrying the signed `FieldEvent` + a privacy badge. Add an ingest route to `rufield-viewer` (it has none today — `server.rs:63-72`) so it can replay RuView's live feed instead of only `SyntheticSim`. **Gate:** a WS integration test asserting a connected client receives a privacy-badged, signature-verifiable `FieldEvent`; a viewer test asserting the new ingest route renders a live event. The `cognitum` appliance can speak RuField by consuming this endpoint (it already runs `ruview-vitals-worker`); deferred to its own ADR.
**P4 — fusion composition + multi-modality (ARCHITECTURE, optional).**
> **Update — second modality landed as a library.** Open question 5 below asked
> whether the second modality should be `rvcsi`. It is **ultrasonic**, because
> the cost collapsed: `rufield-adapters` now ships `UltrasonicReplayAdapter`,
> the first adapter for `Modality::Ultrasonic` (registry code 7, empty since
> v0.1), which parses, validates and signs [BatVu](https://github.com/ruvnet/batvu)
> range profiles upstream. RuView only has to decide what it will put on a wire.
>
> `wifi-densepose-rufield::ultrasonic` is that decision, and it is expressed
> structurally: the adapter is configured for its 32-bin coarse output (`P1`,
> egress-safe) rather than its full per-bin frame (`P0`, edge-local), because a
> consumer cannot un-coarsen a coarse profile whereas a runtime check can be
> reordered. The `network_egress_allowed` gate still runs and is asserted to
> drop nothing.
>
> Gates: `tests/ultrasonic_gates.rs`, 12 tests — round-trip, signature-verify,
> fusion ingest, P1 on **both** tensor and observation, structural unreachability
> of P4/P5, trust-tier refusal in both directions, determinism, whole-file
> rejection of a malformed recording. Plus one asserting the honest negative
> result: **an ultrasonic scan produces no fused inferences at all**, because the
> adapter declines to populate `presence` (one transducer pair cannot tell a
> person from a coat on a chair) and the engine's feature vocabulary is entirely
> statements about a body. RuField v0.1 has no predicate for static geometry.
>
> Not wired into the running server. P1 shipped as a library before P3 wired it
> in; this follows the same staging.
Wire a second modality (cheapest: an `rvcsi`-sourced event, or recorded mmWave) into `RuFieldFusion` alongside the WiFi event, proving cross-modality fusion above ruvsense. **Gate:** a fusion test with two modalities producing ≥1 cross-modal inference, with provenance coverage 100%.
---
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| **Status** | Accepted — **implemented** (O1–O9 in `@ruvnet/ruview@0.2.0`; security/community extension in `0.3.0`, ADR-283; source-cited guidance in `0.3.1`): fail-closed schemas and MCP policy, async dispatch, zero runtime dependencies, bounded/redacted local Claude/Codex adapters, reviewed shared brain, source-checked capability guidance, and replay-verified Darwin/Flywheel gate. CI gate: `ruview-harness-flywheel.yml` |
| **Status** | Accepted — **implemented** (O1–O9 in `@ruvnet/ruview@0.2.0`; security/community extension in `0.3.0`, ADR-283; source-cited guidance in `0.3.1`; guarded Cognitum Spaces OAuth read in `0.4.0`, ADR-325): fail-closed schemas and MCP policy, async dispatch, zero runtime dependencies, bounded/redacted local Claude/Codex adapters, reviewed shared brain, source-checked capability guidance, credential-gated external reads, and replay-verified Darwin/Flywheel gate. CI gate: `ruview-harness-flywheel.yml` |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-REVIEW-1** |
@@ -31,6 +31,26 @@ bounded output/time, secret redaction and realpath-based RuView checkout
validation. Write mode requires two explicit flags and never uses permission or
sandbox bypasses.
## Credentialed external reads
Read-only cloud access is not equivalent to an uncredentialed local read. The
Cognitum Spaces adapter therefore delegates OAuth and response validation to
the Rust `wifi-densepose` client, never accepts bearer tokens or API keys, and
removes the API-key compatibility environment from the child process. Its MCP
tool is denied unless the server operator grants `credential-use`; MCP callers
cannot select a credential path or API origin. The adapter uses only an
installed `wifi-densepose` binary; it never executes Cargo build scripts from
an auto-detected checkout while holding credential authority. The tool is
marked open-world and independently rechecks response size, structure, privacy
class, and prohibited raw fields.
An expiring access token may rotate the stored refresh credential. The MCP
annotation is therefore non-read-only and non-idempotent even though the cloud
data operation is read-only. That bounded authentication side effect is
disclosed in the schema and result. It does not change the cloud operation from
read-only and confers no write or action authority. ADR-325 remains authoritative
for the Spaces data and policy boundary.
## Shared brain
The public brain is committed JSONL, not a shared mutable database. Canonical
@@ -67,5 +87,6 @@ autonomously promotes or publishes an evolved candidate.
Contributors can explore RuView with either major local CLI and share durable
findings without sharing secrets. Improvements become reproducible proposals
with frozen evaluation evidence. The cost is a larger development-only npm
lockfile, a 128 KiB unpacked-package budget (the current tarball is below that
bound), and explicit maintenance of the corpus, genome and gate.
lockfile, a 160 KiB unpacked-package budget after adding the duplicated host
playbook and bounded OAuth adapter (the package remains runtime-dependency-free),
and explicit maintenance of the corpus, genome and gate.
@@ -1,6 +1,6 @@
# ADR-299: Repository CSI data-incident controls — ignore rules and a pre-commit/CI policy check
- **Status**: Accepted — controls implemented; tree remediation gated on owner sign-off
- **Status**: Accepted — controls and current-tree remediation implemented; history coordination pending
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: privacy, data-governance, ci, security, incident
@@ -27,20 +27,20 @@ formatting nit.
or present as tracked files, with a message pointing here. Tests may use
only synthetic or expressly-consented minimal fixtures.
**Explicitly gated on data-owner sign-off (NOT done autonomously):**
**Owner-authorized current-tree remediation (2026-08-15):**
- Removing the existing recordings from the tree, and any history rewrite, are
outward-facing/destructive and require the data owner to first establish
provenance, consent, purpose, retention authority, and redistribution
rights. The review is correct that rewriting `origin` does not erase forks
and clones; coordination is required. This ADR records the controls and the
required follow-up; it does not delete the data.
- The data owner authorized removal of the six known CSI capture and metadata
files from the current tree. The removal is recoverable from Git history and
does not claim to erase existing clones, forks, caches, or release artifacts.
- Any history rewrite remains a separate coordinated incident-response action.
It requires an inventory of affected refs and releases, downstream notice,
credential and artifact review, and an explicit execution plan.
## Consequences
- No new CSI captures can be committed (ignore + policy check).
- The existing tracked recordings remain until the owner decides; the incident
is documented and the guard prevents worsening it.
- The six known tracked recordings are absent from the current tree. Historical
copies remain until a separately authorized and coordinated history rewrite.
- CI gains one fast policy job; contributors get a local pre-commit check.
## Validation
@@ -48,3 +48,5 @@ formatting nit.
- Policy-check unit tests: a staged `*.csi.jsonl` fails; a synthetic fixture
under an allowed test path passes; the check is deterministic and offline.
- Manual confirmation that the new ignore globs cover both active directories.
- `bash scripts/csi-data-policy-check.sh --tracked` passes after the authorized
current-tree removal.
@@ -0,0 +1,364 @@
# ADR-323: Native Rust physics-constrained pose refinement
- **Status**: Proposed
- **Date**: 2026-08-15
- **Deciders**: ruv
- **Owners**: RuView perception and edge runtime maintainers
- **Tags**: pose, physics, rust, uncertainty, provenance, abstention, edge
- **Numbering note**: ADR-323 is the next free number in the authoring checkout. Re-run the ADR index/collision check immediately before merge and rename if needed.
- **Extends**: ADR-020, ADR-027, ADR-079, ADR-101, ADR-135, ADR-145, ADR-150, ADR-273, ADR-279, ADR-282, ADR-295, ADR-296, ADR-297, ADR-298, ADR-302, ADR-303, ADR-304, ADR-305, ADR-306
- **Supersedes**: None
## Executive decision
RuView will add a clean-room native Rust boundary between RF pose inference and
semantic publication. It will preserve the immutable RF observation, publish a
physics assessment, optionally produce a bounded corrected candidate, and
abstain when required evidence is absent. It must never increase observational
confidence merely because a pose is physically plausible.
Three independently gated layers are adopted:
1. A deterministic kinematic auditor and bounded covariance-weighted projector
using Rust and `nalgebra`.
2. An optional articulated-body dynamics auditor using `rapier3d`.
3. A later optional supervised residual model using Burn.
The first production milestone is deterministic audit. It is not a GRIP port,
not PPO, and not evidence that the current pose observer is production-ready.
## Context
ADR-101's committed Cog emits 17 COCO keypoints as normalized 2D coordinates.
Its model has no per-joint uncertainty head and publishes a constant confidence.
The sensing server also contains renderer-oriented EMA and bone clamping. These
surfaces cannot establish metric 3D physics and can make weak evidence look
more convincing.
Pose output can violate bone length, floor, velocity, acceleration, and temporal
continuity constraints. Downstream consumers also cannot reliably distinguish
observed coordinates from derived correction. The rejected premise is:
"physically plausible means more likely correct." Plausibility is only a prior;
many incorrect poses are plausible.
GRIP is architectural inspiration for an observer/controller split, but it
observes four wearable IMUs and pressure insoles and drives a simulator. RuView
observes RF, so GRIP weights are not input-compatible. External code, weights,
simulators, and datasets require independent license review and never enter the
runtime dependency graph by implication.
## Outcome and actors
For every accepted person track/timestamp, the engine returns exactly one
`PoseRefinementV1`, including off, timeout, rejection, and abstention paths:
- immutable `PoseObservationV2` content hash;
- constraint residuals and quality disposition;
- an optional bounded candidate and an explicit `selected` bit;
- a typed reason when correction is unavailable;
- model, calibration, configuration, and optional learned-artifact provenance.
The RF observer owns observations and calibrated uncertainty; tracking owns
identity stability; physics owns assessment/correction only; the sensing server
owns deadlines, modes, publication, and rollback; the evidence engine owns
release evaluation; clients choose raw/both/refined without silent fallback.
## Input and coordinate contract
Metric correction requires a monotonic nanosecond timestamp, session-scoped
track ID, sequence and sensor epoch, 17 ordered COCO joints in metric X/Y/Z,
per-joint positive-semidefinite covariance calibrated on held-out data, a
versioned right-handed Z-up room frame, a normalized upward floor plane, model
and calibration hashes, ADR-302 trust state, and authenticated/replay-protected
source provenance.
`Image2d` observations may be audited for image-plane ratios and continuity but
must never enter 3D projection/dynamics or be called physically corrected.
Unknown trust, missing calibration, missing uncertainty, stale/non-monotonic
input, non-finite values, invalid covariance, excessive tracks, and room-bound
violations fail to raw output with a typed reason.
## Public contracts
`wifi-densepose-core` owns `PoseObservationV2` and `PoseRefinementV1`; no
duplicate server/Cog contract is permitted. Public output remains COCO17. The
engine derives pelvis and thorax virtually and never labels them observed.
The raw content hash is deterministic and excludes its own hash field. The
idempotency key is `(sensor_epoch, sequence, track_id, raw_hash, config_hash)`.
An exact duplicate returns the cached result; same sequence with different
content is a replay rejection.
Contact is `hypothesis` unless a measured sensor and its provenance say
otherwise. Raw, derived, hypothesis, and unknown labels must survive every
projection.
## Confidence invariant
For upstream calibrated confidence `c_obs`, normalized residual `r`, and
normalized intervention `i`:
```text
c_physics = exp(-(beta_r * r + beta_i * i))
c_effective = min(c_obs, c_obs * c_physics)
0 <= c_effective <= c_obs <= 1
```
Only a separately witnessed multimodal fusion contract may increase fused
confidence.
## Deterministic projector
The default `kinematic` feature has no Rapier, Burn, ONNX, libtorch, Python,
CUDA, or network dependency. Per bounded iteration it:
1. projects observed parent/child distances toward anonymous track-scoped
bone-length posteriors;
2. applies broad joint/trunk validity checks without an upright prior;
3. bounds temporal motion and resets derivatives after gaps;
4. resolves floor penetration only, allowing seated, kneeling, prone, child-
scale, mobility-aid, and genuine-fall poses;
5. recomputes residuals and stops below epsilon.
Initial operator-owned caps are four iterations (hard maximum eight), 0.20 m
single-joint correction, 0.10 m root correction, 250 ms derivative gap, 500 ms
track reset, ten known joints, a 100 m metric room bound, a separate 16,384
image-coordinate audit bound, and a 5 ms one-track Pi 5 p95 gate. Keeping image
and metric bounds separate prevents legitimate pixel observations from
weakening the physical room bound. A candidate over either correction cap is
discarded in full.
Bone posteriors are initialized only from high-confidence frames, anonymous,
memory-only, track-scoped, and deleted on expiry. Persistent personalization is
outside this ADR and requires consent/retention/deletion governance.
## Optional dynamics and learned layers
`dynamics` adds a process-owned Rapier humanoid and begins audit-only. Network
input may never provide Rapier snapshots, bodies, constraints, solver limits,
or arbitrary geometry. Dynamics approval is independent of kinematic approval.
`learned` uses first-party Burn 0.21 core/NN components without `burn-tch`
because this workspace already has a different native libtorch link.
`learned-cpu` adds the ndarray backend. The implemented two-layer GRU uses a
20-frame history and width 128 to predict bounded residuals, uncertainty,
foot-contact hypotheses, and abstention. Verified model records can be loaded
from bytes and executed natively; no trained artifact is shipped or approved.
The resolved Burn/CubeCL graph declares Rust 1.92, while the workspace file
pins Rust 1.89 and the authoring host provides Rust 1.91.1.
`--ignore-rust-version` is diagnostic evidence only: learned activation remains
blocked until an approved Rust 1.92 release-toolchain change builds it without
that override. Residuals are hard-clipped to deterministic caps and cannot
bypass validation or confidence monotonicity. PPO is deferred until measured
evidence identifies a failure supervised residual learning cannot address.
## Feature boundary
```text
default = kinematic
dynamics = rapier3d
learned = burn-core + burn-nn
learned-cpu = learned + burn-ndarray
learned-train = learned + burn-train
learned-wgpu = learned-train + burn-wgpu
learned-cuda = learned-train + burn-cuda
deterministic = rapier3d?/enhanced-determinism
```
The lockfile is release authority. The learned feature currently requires the
toolchain supported by Burn/CubeCL's resolved graph; this does not change the
default edge build.
## Runtime modes and API
Rollout is `OFF -> AUDIT -> SHADOW_CORRECT -> OPT_IN_CORRECT -> DEFAULT_CORRECT`.
Evidence permits forward transitions; any regression returns immediately to
audit/off. Correct selection additionally requires authenticated sensor
identity and replay protection from ADR-305. High model confidence cannot
override missing source authentication.
Existing pose fields stay unchanged and raw remains the migration default:
```text
GET /api/v1/pose/current?view=raw
GET /api/v1/pose/current?view=both
GET /api/v1/pose/current?view=refined
```
Refined-only returns HTTP 409 with `pose_refined_unavailable` when no selected
candidate exists. It never silently returns raw labeled refined.
## Security, privacy, and availability
All frames, model output, geometry, and pre-verification artifacts are
untrusted. Calibration/config/model artifacts become trusted only after signed,
hash-addressed verification and atomic activation. Runtime inference performs
no model retrieval or other network access.
Fixed arrays/caps, bounded iterations, a maximum track count, room geometry
limits, deadlines, and track expiry constrain denial of service. Timeout drops
partial refinement, never raw publication. Backpressure retains the newest raw
frame per track, drops intermediate refinement work, resets derivatives after
250 ms, and never extrapolates beyond 500 ms.
Metrics contain only allowlisted aggregate scalars: mode/disposition/reason,
stage latency, iterations, maximum correction, residuals, confidence delta,
track resets, invalid input, timeout, and raw/refined divergence. They exclude
joint arrays, body dimensions, room coordinates, CSI, and persistent person
identifiers. Bone/gait state is memory-only and excluded from logs.
Refined output is not a sole medical, emergency, industrial-safety, or
autonomous-control source. A real fall is valid state and must never be made
upright to stabilize a simulator.
## Threat model summary
| Threat | Primary control | Residual risk |
|---|---|---|
| Spoofed/replayed sensor | ADR-305 identity, MAC, sequence and replay window; correction gate | Compromised legitimate sensor |
| Altered model/floor/config | Signed hashes, authenticated configuration, atomic activation | Authorized unsafe configuration |
| Poisoned data/splits | Immutable manifests, strict split validator, witnessed benchmarks | Subtle label poisoning |
| Operator repudiation | Append-only witnessed transition with actor/old/new hash/reason | Compromised signer |
| Biometric/log leakage | Track-local retention and fixed metric allowlist | Aggregate inference |
| Track/geometry CPU flood | Authentication, cardinality/geometry/allocation/deadline caps | Valid dense-scene overload |
| Remote mode escalation | Capability-scoped local control plane, deny by default | Compromised operator capability |
| Derived output relabeled observed | Required schema/provenance and signed event envelope | Malicious downstream stripping |
The implementation review records commit, lockfile hash, Rust toolchain,
scanner versions, and advisory-feed timestamp.
## Evidence protocol
Evidence levels are L0 deterministic synthetic, L1 public measured replay, L2
controlled RuView RF plus optical truth, L3 subject/room/hardware/session-
disjoint RuView, L4 privacy-safe shadow fleet aggregates, and L5 independent
vertical validation outside this ADR.
No sequence, contiguous take, subject, room, or calibration session may cross
train/test for the generalization gate. Preprocessing, body priors, and
uncertainty calibration fit training data only. Reports include raw observer,
renderer smoothing, audit, deterministic correction, dynamics audit, and
learned residual on identical observations, plus empty-room, prone/fall,
missing-joint, and OOD subsets.
Primary metrics are 3D MPJPE, declared-threshold PCK, per-joint error, foot
slide, floor penetration, jerk, uncertainty calibration, abstention coverage,
and selective risk. Learned runs use at least five fixed seeds and report mean,
median, standard deviation, and 95% bootstrap intervals. All frames count;
selective metrics report risk and coverage.
## Acceptance gates
- **G0 contract**: real metric 3D/covariance output, round-trip raw hash,
versioned frame/floor, 2D compatibility, non-stub observer, ADR-298 artifact
sanity, and the ADR-079 PCK@20 >=35% gate or adopted successor. The current
committed Cog does not pass G0, so correction remains unavailable.
- **G1 deterministic audit**: property/fuzz tests, deterministic hashes per
platform class, 24-hour accelerated replay without panic/growth, Pi 5 p95
<=5 ms, and universal confidence monotonicity.
- **G2 shadow correction**: strict-disjoint measured median MPJPE improvement
>=10% with positive 95% CI lower bound; foot slide >=30% and jerk >=25%
better; no joint median >5 mm worse; fall/prone sensitivity change <=2 pp;
>=95% corrections below 0.10 m; every correction above 0.20 m abstains.
- **G3 opt-in**: >=30 subjects, 10 rooms, 3 hardware configurations, and 3
independent sessions/room; UNKNOWN never selected; confidence monotonic;
live disable; REST/WebSocket/MQTT/Home Assistant/replay compatibility.
- **G4 default visualization only**: 30 shadow days under 0.1% timeout/internal
error, no open severity 1/2 incidents, and gates still valid for current
model/calibration.
Dynamics and learned engines each repeat G2-G4; approval is not inherited.
## Testing and completion evidence
Unit/property/fuzz/integration/security coverage maps to requirements R1-R13:
raw hash, confidence, modes, malformed/stale/frame/covariance input, caps and
deadlines, provenance, dependency graph, pose diversity/fall preservation,
strict splits, fail-to-raw faults, no network capability, and authenticated
source/replay selection.
Release commands include focused core/physics tests, default/dynamics/learned
feature checks, format/clippy, benches, `cargo deny`, `cargo audit`, strict split
verification, and golden replay verification. Completion also requires JSON
schemas, measured Pi 5/x86 rows, strict manifest hashes, raw/refined metrics,
SBOM/license report, rollback drill, and residual-risk owners. Missing measured
or operational evidence leaves status Proposed and runtime in audit.
## Rollback
Rollback is an authenticated mode transition to audit/off, not a binary
downgrade. Stop selection immediately, keep raw publication and disposition
records, discard track state, and retain only aggregate incident metrics plus
signed configuration history. Failed artifact activation leaves the previous
engine atomically active. Additive schemas remain; refined-only callers receive
the typed unavailable response.
## Consequences
### Positive
- Explicit anti-hallucination and provenance boundary after RF inference.
- Reusable native Rust consistency primitive with measurable abstention.
- Python/CUDA remain absent from the production default.
- Cross-modal teacher data remains possible without wearable runtime inputs.
### Negative
- Full value requires a real metric 3D observer and calibrated uncertainty.
- Stateful tracks add latency/memory; optional backends add supply-chain surface.
- A constrained but wrong pose can look more credible.
- Strict data collection costs more than the software implementation.
### Neutral
- This ADR does not improve RF observability or current weight evidence.
- Existing 2D consumers continue to function.
## Implementation phases
P0 contracts/schemas; P1 deterministic audit; P2 bounded shadow correction; P3
server/Cog publication and evidence ledger; P4 Rapier audit; P5 Burn residual
training/inference. Code may land ahead of evidence, but runtime authority
advances only through the gates above.
## Implementation status at proposal
- P0-P3 are implemented on this branch: canonical contracts, strict schemas,
deterministic audit/projection, authenticated correction receipts,
idempotency, bounded track state, latest-frame backpressure, additive HTTP
and WebSocket publication, live legacy-2D audit, privacy-safe metrics, golden
replay, and strict-split checks.
- P4 is implemented as an optional persistent per-track Rapier dynamics auditor
and remains audit-only pending independent G2-G4 evidence.
- P5 inference architecture, artifact verification, serialization, and native
CPU execution are implemented. Training data, a signed trained artifact, and
G2-G4 accuracy/calibration evidence do not exist, so the layer has no runtime
selection authority. Its resolved Rust 1.92 requirement is also an explicit
activation blocker on the current Rust 1.91.1 release host.
- The live Cog honestly emits `Image2d`, degraded trust, and uncalibrated
uncertainty. It can be audited but cannot be selected for 3D correction.
G0 therefore remains open until an independently released metric-3D observer
with calibrated covariance is integrated.
- Local x86 latency and synthetic contract checks are recorded in the append-
only evidence ledger. Pi 5 measurements, 24-hour replay, 100-million-case
fuzzing, held-out RF/optical accuracy, fleet shadowing, and vertical safety
validation remain release evidence gates rather than software claims.
## References
- [GRIP project](https://ryosukehori.github.io/grip-project/)
- [GRIP paper (arXiv:2603.16233)](https://arxiv.org/abs/2603.16233)
- [Rapier documentation](https://docs.rs/rapier3d/)
- [Burn documentation](https://docs.rs/burn/0.21.0/burn/)
- [ADR-020](./ADR-020-rust-ruvector-ai-model-migration.md)
- [ADR-079](./ADR-079-camera-ground-truth-training.md)
- [ADR-101](./ADR-101-pose-estimation-cog.md)
- [ADR-150](./ADR-150-rf-foundation-encoder.md)
- [ADR-273](./ADR-273-unified-rf-spatial-world-model.md)
- [ADR-279](./ADR-279-native-rf-frame-contract.md)
- [ADR-298](./ADR-298-model-release-sanity-gates.md)
- [ADR-302](./ADR-302-out-of-distribution-detection.md)
- [ADR-303](./ADR-303-ground-truth-synchronization.md)
- [ADR-304](./ADR-304-evidence-engine.md)
- [ADR-305](./ADR-305-authenticated-sensor-identity.md)
- [ADR-306](./ADR-306-canonical-spatial-ontology.md)
@@ -0,0 +1,218 @@
# ADR-323: RTL8721Dx (Realtek AmebaDplus) CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-09-15
- **Deciders**: RuView maintainers
- **Tags**: realtek, ameba, amebadplus, rtl8721dx, csi, protocol, rust, udp
## Context
Unlike the MediaTek/Qualcomm CSI paths (ADR-266..269), which are simulator-first
because no supported public vendor interface exports complex CSI, Realtek's
official Ameba RTOS SDK (`Ameba-AIoT/ameba-rtos`, Apache-2.0) documents and
exposes a real, callback-driven CSI API for the RTL8721Dx ("AmebaDplus")
family: `wifi_csi_config()` plus the `RTW_EVENT_CSI_DONE` event delivering a
`struct rtw_event_csi_report_info` (per-tone signed I/Q, 8-bit or 16-bit
precision, selectable tone decimation, passive or active/triggered capture).
This has been confirmed directly against SDK source
(`component/wifi/api/wifi_api_event.h`) and against the official
`example/wifi/wifi_csi` example, and validated on real PKM8721DAF-C13-F10
hardware (build+flash+boot confirmed on branch `feat/rtl8721dx-csi-bringup`).
The RTL8721Dx is a 1x1 WiFi 4 part — single TX/RX chain, not MIMO — so the
MediaTek/Qualcomm wire format (Tx/Rx dimensioned matrices, chipset chain
counts) does not fit. The vendor SDK's raw struct is also not a safe host
wire format as-is: it has no CRC, a C flexible-array-member payload, and
fields sized for firmware convenience (e.g. a wrapping `u32` microsecond
timestamp) rather than network robustness.
Per the existing repo convention (ESP32/ADR-018, MediaTek/ADR-266-267,
Qualcomm/ADR-268-269, RTL8720F radar/ADR-263-264), each vendor CSI/radar
source gets its own self-delimiting envelope and its own Rust module pair
(`wifi-densepose-hardware` for framing+codec+simulator,
`wifi-densepose-sensing-server` for the bounded JSON-safe snapshot), never a
shared "generic vendor CSI" struct that would either lose vendor-specific
fields or invite magic-space collisions. `SourceKind::Realtek` (value 2) is
already assigned to the RTL8720F FMCW radar path (ADR-263/264) and must not
be reused for CSI.
## Decision
Define `RAC1` ("Realtek Ameba CSI", version 1) as a little-endian,
self-delimiting envelope, magic `0x3143_4152` (distinct from `MTC1`
`0x3143544d`, `QCS1` 0x31535143, `RTR1` 0x31525452, and all ADR-018
`0xC511000x` siblings):
Fixed 49-byte header (explicit field-by-field serialization, no `#[repr(C)]`
padding, matching the MediaTek/Qualcomm modules' style):
| Field | Type | Notes |
|---|---|---|
| magic | u32 LE | `0x3143_4152` |
| version | u8 | 1 |
| header_len | u16 LE | 49 |
| frame_len | u32 LE | header + payload + 4-byte CRC |
| node_id | u8 | disambiguates multi-board illuminator/receiver deployments; 0 = unset |
| csi_mode | u8 | 0=passive, 1=active, 2=board-to-board (`CsiMode`) |
| sequence | u32 LE | from firmware `csi_sequence` |
| timestamp_us | u32 LE | from firmware `hw_assigned_timestamp` (hardware-clock microseconds, wraps ~71 min — consumers must not assume monotonic across a capture longer than that) |
| peer_mac | [u8; 6] | firmware `mac_addr` |
| trig_mac | [u8; 6] | firmware `trig_addr` |
| channel | u8 | operating channel |
| bandwidth | u8 | 0=20MHz, 1=40MHz |
| rx_rate | u8 | firmware `rx_rate` |
| protocol_mode | u8 | 0=OFDM,1=HT,2=VHT,3=HE (`ProtocolMode`) |
| num_sub_carrier | u16 LE | tone count in payload |
| num_bit_per_tone | u8 | 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q) |
| decimation | u8 | tone group (1/2/4/8 — every Nth tone) |
| rssi_dbm | i8 | single RF chain (1x1 hardware) |
| rxsc | u8 | sub-20 MHz channel used |
| csi_valid | u8 | 0/1 from firmware |
| flags | u8 | bit 0 = SYNTHETIC (simulator provenance, never clearable downstream) |
| payload_len | u32 LE | bytes, == `num_sub_carrier * (num_bit_per_tone / 8)` |
Payload: `num_sub_carrier` consecutive (I, Q) tone pairs, each
`num_bit_per_tone / 16` bytes per component (1 byte/component when
`num_bit_per_tone == 16`, 2 bytes/component little-endian signed when `== 32`)
— i.e. exactly the byte layout the firmware already produces in
`csi_data[]`, just framed. CRC-32/IEEE over header+payload, final 4
little-endian bytes. One envelope per UDP datagram.
Parsers reject: unknown version, wrong `header_len`, `frame_len` exceeding
the IPv4 UDP payload cap (65,507 bytes), `num_bit_per_tone` outside
`{16, 32}`, `payload_len` inconsistent with `num_sub_carrier *
(num_bit_per_tone/8)`, trailing bytes, and bad CRC — mirroring the
MediaTek/Qualcomm parser contract exactly.
Host side: add `realtek_csi.rs` to both
`wifi-densepose-hardware` (frame + codec + simulator, mirroring
`mediatek_csi.rs`/`qualcomm_csi.rs`) and `wifi-densepose-sensing-server`
(bounded `RealtekCsiSnapshot`, mirroring `QualcommCsiSnapshot`), wired into
the same UDP listener loop and `/api/v1/csi/realtek/latest` route as the
existing vendor sources. The initial Mac compatibility port keeps Realtek CSI
as a separate bounded diagnostic stream. Fresh ESP32 frames remain the primary
sensing source. This change does not add `SourceKind::RealtekCsi` to a signed
ingest contract; that requires a separate reviewed identity-bound hardware
proof. RAC1 CRC checks corruption, not origin.
Firmware side (`example/wifi/wifi_csi` derivative, built against the
official Ameba RTOS SDK, tracked outside this repository per the existing
`CLAUDE.local.md` convention — the vendor SDK itself is never vendored into
this repo): join the board to the deployment's WiFi AP in STA mode
(passive CSI against the real AP as peer, since only one physical board is
available in this bring-up), read the AP's BSSID at runtime after
association (never hardcode a MAC into firmware source), pack each
`RTW_EVENT_CSI_DONE` report into one `RAC1` datagram, and `sendto()` it over
UDP to the host sensing-server's existing CSI port.
## Consequences
### Positive
- CSI is real, MEASURED hardware output from day one — no simulator-first
bridge phase is needed the way MediaTek/Qualcomm required, because the
vendor SDK genuinely exposes the capability and we have the board in hand.
- One board is sufficient for a first useful signal (passive CSI against the
home AP); active and board-to-board modes are additive later without a
wire-format break (`csi_mode` already reserves the values).
- Reuses the bounded UDP parser and status-route pattern without changing
existing ESP32 sensing. Unsigned RAC1 diagnostics are not authorized as
calibrated presence, person count, pose, or vital evidence.
### Negative
- 1x1 hardware means no spatial (multi-antenna) diversity from a single
board; multi-board deployment is required for the differential/localization
use cases described in the original RTL8721Dx capability analysis.
- The 71-minute microsecond timestamp wraparound (inherited from the
firmware's `u32` field) means long unattended captures need host-side
sequence-based reordering, not raw timestamp arithmetic, across a wrap.
### Neutral
- The RAC1 envelope intentionally does not carry the SDK's raw
`csi_signature` (0xABCD) marker — our own `magic`/CRC supersede it as the
framing integrity check.
## Hardware gates
- [x] Confirm SDK source exposes `RTW_EVENT_CSI_DONE` / `rtw_event_csi_report_info` (done — `component/wifi/api/wifi_api_event.h`).
- [x] Build and flash a baseline image on real PKM8721DAF-C13-F10 hardware, confirm boot over serial (done — `feat/rtl8721dx-csi-bringup`).
- [x] Flash the CSI-streaming firmware and confirm `RAC1` datagrams parse on the host from the real board (done, 2026-09-15 — see below).
- [x] Record MEASURED throughput/latency/jitter from the real running firmware (done, 2026-09-15 — see below).
- [ ] A human walk-test across the link (amplitude perturbation vs. static baseline) is deferred — requires the operator physically present; not claimed until performed.
### MEASURED: real RAC1 capture and sustained throughput (2026-09-15)
Passive CSI (`RTW_CSI_MODE_NORMAL`, `RTW_CSI_TRIG_BEACON | RTW_CSI_TRIG_DATA | RTW_CSI_TRIG_QOS_DATA`)
against the deployment AP. A single `RAC1` datagram was first
cross-checked field-by-field against the firmware's own serial log of the
same report — every byte matched: magic `0x31434152`, RSSI byte exact match,
channel/bandwidth/protocol/subcarrier-count exact match, 157-byte total frame
(49-byte header + 104-byte payload + 4-byte CRC, exactly as specified above).
`RTW_CSI_MODE_RX_RESP` (estimate CSI from ACKs to the STA's own transmissions)
never fired `RTW_EVENT_CSI_DONE` across two full test runs with confirmed
real traffic (not a hang) — this mode does not work for this hardware/AP
combination and `RTW_CSI_MODE_NORMAL` with beacon+data triggers is the
working configuration.
**An earlier note here claimed `NORMAL` mode fires only once per CSI enable
— that was wrong**, caused by a debug log line gated to print only every
50th frame (`frame_count % 50 == 1`), which was misread as "only one report
total." A follow-up capture with a UDP listener running concurrently with a
board reset measured **277 real RAC1 datagrams in 40 seconds (43,489 bytes)**,
with `csi_sequence` climbing continuously and monotonically in the firmware's
own serial log across six separate 50-frame log checkpoints in one 45s
session — CSI reporting is continuous, roughly 7-12 fps, not sparse.
### MEASURED: throughput optimization pass (2026-09-15)
Formalized as a repeatable tool: `scripts/benchmark-rtl8721dx-csi.py` (listens
on the sensing-server's CSI UDP port, reports fps/throughput/RSSI/inter-frame
interval/sequence-gap stats from real received `RAC1` datagrams).
**Baseline** (`trig_period=200` [64ms], `trig_frame_ctrl=0`,
`CSI_REPORT_BUF_NUM=1`), 30s capture:
| Metric | Value |
|---|---|
| RAC1 fps | 14.67 |
| Throughput | 2,302.7 bytes/s |
| Mean inter-frame interval | 68.3 ms |
| RSSI | mean -34.4 dBm |
`trig_period` (units of 320µs) was hypothesized as the rate-limiting
throttle — the observed 68.3ms mean interval tracks the 64ms configured
period almost exactly. Confirmed by tuning:
1. `trig_period`: 200 → 15 (the SDK doc's stated minimum recommended value,
~4.8ms)
2. `trig_frame_ctrl`: 0 → `RTW_CSI_TRIG_ACK` (second trigger source: every
link-layer ACK the AP sends for the heartbeat's own outgoing traffic, on
top of the existing beacon trigger)
3. `CSI_REPORT_BUF_NUM`: 1 → 4 (a "lack of csi buf!" drop was observed even
at the slower baseline rate; more headroom needed at a higher rate)
**Tuned**, 30s capture:
| Metric | Value | vs. baseline |
|---|---|---|
| RAC1 fps | 66.40 | **4.5x** |
| Throughput | 10,424.8 bytes/s | **4.5x** |
| Mean inter-frame interval | 14.9 ms | 4.6x lower |
| Sequence gaps | 78 / ~2036 (~3.8%) | new (0 observed at the slower baseline rate) |
| RSSI | mean -31.1 dBm | (different session/position, not a controlled variable here) |
The `trig_period` hypothesis is confirmed as the primary lever. The ~3.8%
frame-loss rate at 66 fps is a reasonable place to stop for now — it is
UDP-over-WiFi loss, not a firmware defect, and is small enough not to block
downstream sensing use; revisit only if a use case needs stronger delivery
guarantees (sequence-gap-triggered re-request, TCP fallback, etc.).
## Links
- [ADR-266](ADR-266-mediatek-filogic-csi-platform.md) / [ADR-267](ADR-267-mediatek-mimo-csi-wire-protocol.md) — sibling vendor CSI ADR pair (simulator-first precedent)
- [ADR-268](ADR-268-qualcomm-atheros-csi-platform.md) / [ADR-269](ADR-269-qualcomm-csi-wire-protocol.md) — sibling vendor CSI ADR pair
- [ADR-263](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md) / [ADR-264](ADR-264-rtl8720f-radar-wire-protocol.md) — existing Realtek (RTL8720F radar) path; `SourceKind::Realtek` owner
- `CLAUDE.local.md` — RTL8721Dx toolchain setup, board/COM port, first bring-up log
@@ -0,0 +1,276 @@
# ADR-324: off-axis-mode — RF-assisted head-coupled perspective for the three.js realtime demo
| Field | Value |
|-------|-------|
| **Status** | Proposed (core implemented — see §2.5) |
| **Date** | 2026-08-16 |
| **Deciders** | ruv |
| **Codename** | **off-axis-mode** |
| **Scope** | New `examples/three.js/demos/07-off-axis-window.html` (client-side only); no server changes |
| **Relates to** | ADR-019 (sensing-only UI), ADR-035 (live sensing UI accuracy), ADR-169 (adam-mode), ADR-170 (yoga-mode), ADR-282 (L0–L5 evidence ladder), ADR-295 (source provenance), ADR-306 (spatial ontology), ADR-307 (persistent tracking), ADR-323 (pose refinement) |
| **Prior art** | [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker) (reference only — see §2.1 licensing) |
| **Numbering note** | ADR-324 is the next free number in the authoring checkout (322 is unused, 323 is the latest on disk). Re-run the ADR index/collision check immediately before merge and rename if needed. |
| **Tracking issue** | none yet |
---
## 1. Context
### 1.1 The question this ADR answers
"Can we use [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker)
with RuView?" The answer is: **yes for the technique, no for the code, and
only honestly for the RF part.** This ADR records the research behind each of
those three clauses and defines the integration that is actually defensible.
### 1.2 What off-axis-sneaker is
`off-axis-sneaker` is a React + TypeScript + Vite web app that renders a GLB
model (a sneaker) in three.js and creates a *head-coupled perspective*
("fish-tank VR" / "window into the screen") illusion:
- **Tracking input**: MediaPipe Face Mesh (468 facial landmarks) from a
webcam. Head (x, y) comes from the eye midpoint; depth (z) is proxied by
inter-ocular distance. An exponential moving average (default factor 0.3)
smooths jitter; sensitivity multipliers are `strengthX: 4`, `strengthY: 3`,
`strengthZ: 2`.
- **Projection**: `src/utils/offAxisCamera.ts` builds a **true asymmetric
(off-axis) frustum** — `makePerspective(left, right, top, bottom, near, far)`
with `left/right/top/bottom = (screenBound − eyePosition) · (near /
viewerToScreenDistance)` — i.e. Kooima's generalized perspective projection,
plus a matching camera translation. Constants: `nearPlane 0.05`,
`farPlane 1000`, `worldScale 0.01` (cm → world units), `movementScale 1.5`.
- **Calibration**: a wizard captures physical screen width/height (cm),
typical viewing distance, and pixel density, stored locally, so eye position
is computed relative to the *physical* display.
The technique descends from Johnny Chung Lee's 2007 Wii-remote desktop VR
demo and the fish-tank VR literature (Ware, Arthur & Booth, CHI '93). The
projection math is Robert Kooima's "Generalized Perspective Projection"
(2008). Both are public, well-documented techniques independent of any one
implementation.
### 1.3 What the illusion physically requires
The head-coupled illusion is only convincing when the tracked eye position is
**accurate to roughly centimeters** and **low-latency**. The VR literature
puts comfortable motion-to-photon latency below ~20 ms for head-mounted
displays; desktop fish-tank VR tolerates more, but visible lag between head
motion and parallax response is exactly what breaks the "window" illusion.
`CLAIMED` (literature values; no RuView measurement exists for this demo yet).
### 1.4 What RuView RF sensing can actually supply today
This is where honesty is mandatory (repo rule: never present WiFi sensing as
camera-grade).
- **Field-peak position, not metric localization.**
`wifi-densepose-sensing-server/src/field_localize.rs` derives a position
from the strongest peak of the 20×20 `signal_field` carried on
`/ws/sensing` `sensing_update` frames. Its own module doc states the
caveat: the subcarrier→angle mapping is a *representation*; "a single ESP32
link cannot resolve a true (x, z) room position." The emitted position is
"strongest field peak in the room model," mapped with `X_SCALE 0.6`,
`Z_SCALE 0.5`, gated by `PEAK_THRESHOLD 0.35` — real, live, motion-tracking,
but **not a calibrated person fix** and nowhere near eye-position precision.
- **RF pose is 2-D, normalized, constant-confidence.** The committed Cog
(ADR-101, restated by ADR-323) emits 17 COCO keypoints as normalized 2-D
coordinates with a constant confidence and no per-joint uncertainty. A
"nose" keypoint exists (COCO index 0), but it is not a metric 3-D head fix.
- **Tracks are coarse and pseudonymous by design.** `ruview-track` (ADR-307)
maintains `person_N` tracks with container-level ("kitchen → hallway")
continuity, coarse non-reversible features, and asserts **no accuracy
number** — outputs default to evidence level `L1`.
- **Update cadence and latency are unmeasured for this purpose.** The demo
pipeline runs at ~30 Hz on the MediaPipe side (ADR-170), but no end-to-end
RF motion-to-photon latency has been measured. Any figure quoted for the RF
path must be tagged `MEASURED` with a reproducer before it appears in docs
or UI.
Conclusion of the capability match: **RF cannot drive a convincing fish-tank
illusion by itself today**, and this ADR does not claim it can. RF *can*
supply things a webcam cannot: camera-free presence, zone-level position,
person count, approach direction, and pseudonymous continuity — including
when the camera is off.
### 1.5 What this ADR is *not*
- Not a vendoring of `off-axis-sneaker` (see §2.1 — the repo has no license).
- Not a claim of camera-grade RF head tracking, at any tier.
- Not a backend change: no new server endpoints, no new auth surface, no
schema changes. Purely additive client-side HTML/JS, like ADR-169/170.
- Not a React/Vite/Tailwind adoption. The `examples/three.js/demos/*` are
dependency-light single-file HTML demos and stay that way.
## 2. Decision
### 2.1 Licensing: adopt the technique, not the code
`off-axis-sneaker` publishes **no license**. Under default copyright, its
source cannot be copied, vendored, or translated into this repository.
Decision:
1. **No code, assets, or models from `off-axis-sneaker` enter this repo.**
The GLB sneaker model is likewise unlicensed for reuse; demos use assets
already present in `examples/`.
2. The off-axis projection is implemented **clean-room from the public
sources**: Kooima's "Generalized Perspective Projection" (2008) — the
`pa/pb/pc` screen-corner formulation — and three.js's documented
`PerspectiveCamera.projectionMatrix` override path. The repository is cited
as prior art in this ADR only.
3. If upstream later adds a permissive license, revisiting reuse requires a
new ADR note, not silent copying.
### 2.2 Tiered integration — each tier labeled by what it really is
**Tier A (ships first): webcam-fine + RF-context hybrid.**
`07-off-axis-window.html` uses MediaPipe Face Landmarker (already the pattern
in demo 05) for fine head tracking and the Kooima frustum for rendering —
functionally what off-axis-sneaker does, reimplemented. RuView RF adds the
camera-free layer around it:
- **Presence-gated camera**: the webcam pipeline starts only when the RF
presence signal (`/ws/sensing` `sensing_update`) says someone is in the
zone, and stops after a configurable RF-vacancy timeout. The privacy
posture improves: the camera is *off* until physics says there is someone
to track.
- **Multi-person arbitration**: when RF reports more than one person, the HUD
says so and the demo holds the last stable perspective instead of jumping
between faces.
- **Pre-warm**: RF approach direction (field-peak trajectory) warms up
MediaPipe and the scene before the person sits down.
**Tier B (demo mode, prominently labeled): RF-only coarse parallax.**
A toggle drives the off-axis eye position from RF alone — field peak (x, z)
plus the pose nose keypoint when present — through a one-euro filter, a
deadband, and a hard gain clamp. The HUD labels it **"RF coarse body
parallax — not head tracking"** and shows the live evidence level (`L1`
heuristic unless a certificate says otherwise, per ADR-282/ADR-318). The
expected experience is a slow, body-scale parallax sway — a demonstrative
"the room model moves because *you* moved, with no camera" — not a stable
fish-tank illusion. The demo must never present Tier B as equivalent to
Tier A.
**Tier C (future, explicitly gated, not promised): metric RF head position.**
Only a calibrated multistatic deployment (ADR-297 multi-node semantics,
ADR-311 fusion, ADR-303 ground-truth sync) with an evidence-engine ledger
entry (ADR-304) and a capability certificate (ADR-318) could justify feeding
RF positions into the fine path. No current data supports this; Tier C exists
in this ADR solely so nobody ships it informally without those gates.
### 2.3 Implementation surface
- New file `examples/three.js/demos/07-off-axis-window.html` (07, not 06 —
ADR-170 reserves `06-yoga-mode.html`). Single-file demo following the 01–05
conventions: same CSS custom properties, same HUD/helper-panel pattern,
served from the existing static demo server
(`http://127.0.0.1:8765/examples/three.js/demos/…`).
- A small clean-room module (inline `<script type="module">` or
`examples/three.js/lib/off-axis-camera.js` if shared later) that, given
screen corners `pa, pb, pc` (from calibration) and eye point `pe`, sets
`camera.projectionMatrix` via the Kooima formulation each frame.
- Data inputs are the **existing** streams only: `/ws/sensing`
(`sensing_update` → `signal_field` → field peak, using the same
`X_SCALE`/`Z_SCALE`/`PEAK_THRESHOLD` mapping as `field_localize.rs`) and,
when available, `/api/v1/stream/pose` for the nose keypoint. WebSocket
access uses the existing ticket flow (`ws_ticket.rs` / `bearer_auth.rs`);
no endpoint is exempted or added.
- Calibration mirrors the sneaker app's concept without its code: screen
width/height in cm, viewing distance, persisted in `localStorage` under a
demo-scoped key. No calibration data leaves the browser.
- Provenance discipline: if the demo is pointed at a synthetic or replayed
source, the ADR-295 provenance state must surface in the HUD exactly as the
Observatory does — synthetic can never present as live.
### 2.4 Honesty and evidence rules binding this feature
1. Every user-visible latency, accuracy, or precision statement in the demo,
README, or docs carries a `MEASURED` (with reproducer), `CLAIMED`, or
`SYNTHETIC` tag. This ADR itself contains no `MEASURED` claims.
2. Tier B is labeled coarse body parallax in the HUD at all times; there is
no configuration that hides the label while RF drives the camera.
3. No PCK or pose-accuracy number may be quoted for the RF path without the
mean-pose baseline and a leakage-free held-out split (repo rule).
4. The webcam feed never leaves the browser; no frames, landmarks, or
embeddings are sent to the server. RF data continues to obey ADR-307's
privacy invariants (pseudonymous, coarse, rotatable).
### 2.5 Implementation status (2026-08-16 amendment)
The projection core shipped as a **Rust crate compiled to WASM** rather than
the inline JS module §2.3 anticipated — a strict upgrade with the same
surface: `v2/crates/ruview-offaxis` (dependency-free native core; wasm-bindgen
only on wasm32) implements the Kooima projection, the one-euro filter, the
field-peak mapping (constants mirroring `field_localize.rs`), and the Tier B
coarse-parallax stage with its deadband/gain/clamp bounds enforced in Rust.
`examples/three.js/demos/07-off-axis-window.html` consumes the wasm-bindgen
output (built locally per the crate README; generated artifacts are not
committed). Validation and `MEASURED` benchmarks live in the crate README.
The demo ships with a `SYNTHETIC`-labeled mouse simulator and the labeled
Tier B RF mode; a Tier A fine tracker connects through
`OffAxisCamera.update_normalized` and remains host-provided.
## 3. Options considered
| Option | Verdict | Why |
|---|---|---|
| Vendor `off-axis-sneaker` (or fork + point at RuView) | **Rejected** | No license ⇒ no redistribution rights. Also React/Vite stack conflicts with the repo's single-file demo convention. |
| Clean-room Kooima off-axis demo, webcam-fine + RF-context (Tier A/B) | **Chosen** | Legally clean, matches demo conventions, uses RF for what it is actually good at, and demonstrates camera-free presence value honestly. |
| RF-only head-coupled perspective as the headline | **Rejected** | Over-claim. Single-link field peaks are a representation, not metric localization (`field_localize.rs` caveat); shipping this as "head tracking" violates the camera-grade rule. Survives only as the labeled Tier B toggle. |
| Wait for multistatic metric localization (Tier C) before any demo | **Rejected** | Blocks a useful, honest demo on a phase-2/3 program (ADR-303/311/318) with no delivery date. The gates are recorded instead. |
| Add a dedicated server endpoint for head position | **Rejected** | Unnecessary — existing `/ws/sensing` + `/api/v1/stream/pose` suffice; a new endpoint would expand the auth surface for no capability gain. |
## 4. Consequences
**Improves**
- A publicly legible demo of RF sensing's actual differentiator: the scene
knows you are there, where you roughly are, and how many of you there are —
before and without any camera.
- Privacy posture of the head-tracking demo class: camera duty-cycle is
bounded by RF presence instead of always-on.
- Canonical, licensed off-axis projection code the Observatory or future UI
can reuse.
**Costs / risks**
- Tier B can underwhelm viewers primed by webcam demos; the mitigation is the
labeling and the side-by-side toggle, not inflated gain.
- MediaPipe CDN dependency (same as demo 05) remains a network-availability
risk for Tier A; the demo must degrade to Tier B with a visible notice.
- Screen-calibration friction (cm measurements) may deter casual users; a
"skip calibration (approximate)" path with degraded-accuracy labeling is
acceptable.
- Upstream `off-axis-sneaker` may change or add a license; tracking that is
manual.
**Follow-ups (not in this ADR's scope)**
- Measure end-to-end RF motion-to-parallax latency with a reproducer and
publish it `MEASURED`.
- If/when ADR-303/311 land, evaluate Tier C against the ADR-318 certificate
gate.
- Consider promoting the off-axis camera module into the Observatory 3D view.
## 5. Validation
- Demo checklist (manual, per ADR-169/170 practice): loads from the static
server; Tier A activates only on RF presence; Tier B label visible whenever
RF drives the camera; provenance badge correct against a synthetic source;
no network requests carrying webcam-derived data (verified in devtools).
- `rg` gate before merge: no file under `examples/` contains code originating
from `icurtis1/off-axis-sneaker`.
- No workspace, harness, or firmware validation rows are triggered — the
change is a static HTML demo plus this document.
## 6. References
- [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker) — prior-art reference (unlicensed; technique only)
- Robert Kooima, *Generalized Perspective Projection*, 2008 — off-axis frustum math
- Johnny Chung Lee, *Head Tracking for Desktop VR Displays using the Wii Remote*, 2007
- Ware, Arthur & Booth, *Fish Tank Virtual Reality*, CHI '93 — head coupling vs. stereo
- `v2/crates/wifi-densepose-sensing-server/src/field_localize.rs` — field-peak honesty caveat and coordinate mapping
- `v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs`, `bearer_auth.rs` — WebSocket auth pattern
- `v2/crates/ruview-track/src/lib.rs` — ADR-307 privacy invariants and evidence discipline
- ADR-169, ADR-170 — demo-scoped ADR pattern for `examples/three.js/demos/`
- ADR-282 — L0–L5 evidence ladder; ADR-295 — provenance state machine
@@ -0,0 +1,543 @@
# ADR-325: Cognitum Spaces activation and governed spatial exchange
- **Status**: Accepted — legacy and versioned reads, OAuth activation, local spatial memory, governed-action policy, metaharness support, and npm distribution are implemented; HTTPS production evidence is complete
- **Date**: 2026-08-17
- **Deciders**: ruv
- **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous
- **Relates to**: ADR-271, ADR-277, ADR-304, ADR-306, ADR-312, ADR-318, ADR-319, ADR-321; Cognitum API ADR-094; Autogenous ADR-402
## Context
RuView produces camera-free RF perception locally. Cognitum Spaces provides a
tenant-scoped cloud projection of physical places. Autogenous ADR-402 proposes
using that projection as a spatial-intelligence input for agent coordination.
The useful product is not another sensor dashboard: it is a governed chain from
local perception to spatial state, persistent memory, explanation, and action.
Four product pillars define the requested integration:
1. **Spatial state** — sites, buildings, floors, rooms/spaces, zones, entities,
semantic events, and alerts.
2. **RuView perception** — camera-free sensing is normalized locally before any
permitted P2/P3 semantic event synchronizes.
3. **Persistent memory** — RuVector grounds anomaly explanations in
tenant-scoped spatial history.
4. **Governed action** — agents observe or recommend by default; consequential
execution requires explicit policy authorization.
The live API audit on 2026-08-17 established the current production boundary:
- `GET https://api.cognitum.one/v1/spaces` exists and returns a bounded list;
- an unauthenticated request is rejected;
- the current account has no paired sites, so the authenticated result is an
empty list rather than fabricated sample state;
- the projection declares HomeCore Edge authoritative and excludes raw CSI,
CIR, RF tensors, recordings, pose frames, vital waveforms, and identity
observations;
- the first deployed Function revision accepted only legacy `cog_` API keys;
- the gateway was configured to authenticate private Function hops, but the
direct Function endpoint was still publicly invokable; that bypass has now
been closed and the exact gateway runtime service account is the only
invoker;
- OAuth protected-resource metadata and a RuView-scoped OAuth accept path were
absent.
The Autogenous review at commit
`f7fa308b261bac89a8909edae8a3fdbbfb8ce66c` found additional integration risks:
- its Spaces client only listed spaces; no governed ingest contract existed;
- it trusted a loose TypeScript cast, with no response-size, timeout, redirect,
or strict semantic-boundary validation;
- its observation conversion dropped tenant/message/sequence identity;
- missing confidence became zero but could still enter fusion;
- provenance could be substituted for calibration identity;
- a Spaces-derived belief could be converted back into an observation and
counted as independent corroboration, laundering one source into two;
- its API-key exchange returns a `cognitum-cli` OAuth token, but the live Spaces
endpoint accepted only a `cog_` key. Calling this “OAuth Spaces access” was a
contract mismatch.
## Decision
Adopt a one-way-by-default, typed spatial exchange with separate activation,
data, memory, and action authorities.
```text
RuView RF capture (P0/P1, local)
-> calibrated/OOD-gated semantic observation
-> ontology + evidence + witness envelope (P2/P3)
-> HomeCore authoritative edge state
-> Cognitum Spaces tenant/workspace projection
-> RuView bounded read client / Autogenous spatial context
-> RuVector tenant-scoped memory and explanation
-> recommendation
-> ruvview-policy authorization + approval + receipt
-> optional consequential action
```
Cloud state is a projection of edge state, not a second sensor and not an
independent corroborating modality.
### 1. Activation and data-plane credentials are distinct
RuView uses Cognitum's existing Authorization Code + PKCE flow with the public
`ruview` client. A user explicitly requests `spaces:read` with
`wifi-densepose login --spaces`. The authorization-server registration is a
ceiling; ordinary sensing login does not silently gain cloud access.
The Spaces resource server accepts either:
- a legacy API key carrying `spaces:read` (or the migration-compatible
predecessor `devices:manage`); or
- a Cognitum OAuth access token that passes every condition below.
OAuth acceptance is conjunctive:
| Check | Required value |
|---|---|
| Signature | ES256 against `https://auth.cognitum.one/.well-known/jwks.json` |
| Issuer | exact `https://auth.cognitum.one` |
| Audience | exact `ruview` |
| Client claim | exact `ruview` |
| Token type | ordinary `access`; setup/workload tokens denied |
| Lifetime | current `exp`/`nbf`, five-second clock tolerance only |
| Scope | exact token `spaces:read` member |
| Tenant binding | valid non-empty UUID `org_id` and `workspace_id` |
An API key is not called OAuth. An OAuth token is not stored in
`COGNITUM_SPACES_API`. The compatibility environment variable contains an API
key only and is never printed, logged, or committed.
OAuth consent grants identity-bound read access. It does **not** grant device
pairing, data publication, deployment, billing, spending, leases, learning
promotion, automation installation, commands, or actuator authority.
The contributor metaharness exposes this as CLI verb `spaces` and MCP tool
`ruview_spaces_list`. It delegates to the same Rust client rather than parsing
or refreshing OAuth independently. The tool never accepts a bearer token or API
key. MCP use requires an operator-provided `credential-use` grant, and MCP calls
cannot select the credential path or API origin. The adapter requires an
installed `wifi-densepose` binary rather than executing Cargo build scripts
from an auto-detected checkout while holding credential authority. Because
refresh tokens rotate, a read may atomically update the local OAuth credential
before contacting Spaces; this authentication side effect is disclosed and
does not add cloud write authority.
### 2. The gateway owns the private credential relay
The public gateway strips inbound `X-Cognitum-User-Authorization` and
`X-Serverless-Authorization`. For a locked Function upstream it then:
1. retains a legacy `cog_` credential in `X-API-Key`, or, for the exact Spaces
route only, retains a non-key bearer in a gateway-owned internal header;
2. replaces `Authorization` with the gateway's Google invoker ID token;
3. fails closed with `503` if it cannot mint that hop identity;
4. forwards only to the configured Function origin.
The Function's Cloud Run invoker check is enabled. `allUsers` has no invoker
binding; only the exact `apigateway-sa` service account may invoke it. This is
required because otherwise a caller could bypass Cloud Armor and spoof an
internal relay header.
The API publishes RFC 9728 protected-resource metadata naming the authorization
server and `spaces:read` scope. Discovery describes capability; it does not
grant it.
### 3. Tenant isolation is part of authentication
Legacy API-key documents are queried by their existing owner-bound `tenantId`.
OAuth requests are conjunctively queried by both signed `org_id` and
`workspace_id` using stored `tenantId` and `workspaceId` fields. The public
tenant identifier is projected from signed `org_id`. A request cannot supply
either selector in a query string.
No cross-tenant aggregation exists on this path. Pagination, search, memory,
and event endpoints added later must carry the same authoritative principal;
client-provided tenant filters may only narrow within it, never replace it.
### 4. Spatial model and ownership
The canonical RuView vocabulary remains ADR-306:
```text
Site -> Building -> Floor -> Space -> Zone
-> Sensor / Person / Object / Track
-> Observation -> Event -> Alert
```
Cognitum may call a bounded room a “space”; RuView does not create a second
room type. Stable external IDs are namespaced and validated before entering the
ontology. HomeCore remains authoritative for local registry state and local
automation. Cognitum owns tenant/workspace projection and activation. RuVector
owns indexed spatial history, not tenancy or authorization.
The current live endpoint exposes the first `Space` slice only. Sites, floors,
zones, entities, events, and alerts are contract milestones, not inferred from
missing fields. A client must represent absence as unknown/unavailable and must
not fabricate parents, coordinates, people, alerts, or provenance.
### 5. Privacy boundary and synchronization eligibility
Only allow-listed P2/P3 semantic projections may cross the cloud boundary.
| Class | Examples | Cloud default |
|---|---|---|
| P0 | raw CSI, CIR, RF tensors, packet captures | prohibited |
| P1 | pose frames, vital waveforms, identity observations, recordings | prohibited |
| P2 | occupancy count, bounded activity/fall possibility, anomaly score | permitted when policy allows |
| P3 | versions, connection health, signed capability metadata | permitted |
The client independently rejects forbidden raw-field names anywhere in the
response. This is defense in depth, not a substitute for server-side
projection. It also enforces HTTPS except for loopback tests, refuses redirects,
uses bounded connect/total timeouts, caps responses at 1 MiB, caps the list at
100 spaces, bounds nesting/arrays/strings, validates confidence, and rejects
non-P2/P3 space records.
Cloud-bound envelopes must preserve, when available:
- tenant/workspace/site/space/device identity;
- `messageId` and monotonic `eventSequence`;
- `observedAt`, `expiresAt`, freshness, and connection state;
- privacy class and semantic schema version;
- calibrated confidence and explicit uncertainty/abstention;
- model, HomeCore, hardware-manifest, calibration, evidence, and witness
provenance.
Provenance is never used as a calibration identifier. Missing confidence,
calibration, timestamp, or tenant identity stays missing and cannot satisfy an
admission rule.
### 6. No feedback laundering or false corroboration
A Spaces record derived from RuView evidence carries derivation lineage. If it
returns to RuView or Autogenous, it is a **projection/recollection** of that
lineage, not a new observation. It cannot:
- increment corroborating-sensor count;
- raise evidence level;
- be fused as an independent modality;
- reset freshness to retrieval time;
- erase abstention, contradiction, or uncertainty;
- generate a second belief that cites the first as support.
Deduplication keys include tenant, source/witness identity, message ID, and
sequence. Cycles are detected and rejected. Independent corroboration requires
a distinct authenticated source and evidence chain.
### 7. Persistent memory is tenant-scoped and explanation-oriented
RuVector indexes accepted semantic state under at least:
```text
(tenant_id, workspace_id, site_id, space_id, schema_version, time_bucket)
```
It stores bounded semantic features, uncertainty, evidence references, and
witness digests. It does not store OAuth/API credentials or prohibited raw
payloads. Retrieval always applies the authenticated tenant/workspace filter
before similarity ranking.
An anomaly explanation names:
- the current semantic state and its uncertainty;
- the relevant learned baseline/window from ADR-312;
- comparable tenant-local history;
- the measured deviation and contradictory evidence;
- the provenance/witness chain;
- the evidence label (`MEASURED`, `SYNTHETIC`, or `CLAIMED`).
Memory supplies context, not permission. A historically common action is not
automatically authorized.
### 8. Agents observe and recommend; policy authorizes action
Autogenous and other agents receive read-only spatial context by default. Their
normal outputs are observations, explanations, proposals, and recommendations.
Any consequential action must cross the ADR-321 `ruview-policy` gate with:
- an exact action class and target;
- a fresh capability certificate;
- KNOWN/DEGRADED/UNKNOWN domain state;
- bounded uncertainty and sufficient evidence;
- tenant/workspace authorization;
- expiry, nonce, idempotency key, and replay protection;
- required human/policy approval;
- a terminal witness receipt for allow or deny.
Missing policy, unknown action class, stale state, incomplete provenance, or an
unavailable approval service denies. OAuth `spaces:read` can never authorize an
action. This ADR adds no actuator method to the Spaces client.
## Implementation
### RuView
- `ruview-cognitum-spaces` is a reusable, read-only client with typed/redacted
credentials and a bounded response decoder.
- `wifi-densepose login --spaces` explicitly requests `spaces:read` through the
existing PKCE flow and credential store.
- `wifi-densepose spaces` refreshes OAuth through the existing single-flight,
persist-before-return mechanism, verifies that the stored grant contains
`spaces:read`, and lists validated state. `COGNITUM_SPACES_API` remains an
explicit compatibility path.
- the dependency-free contributor metaharness adds `spaces` /
`ruview_spaces_list`, invokes only the OAuth branch, bounds and revalidates
child output, fixes the production API origin, strips the API-key compatibility
environment, requires an installed binary, and default-denies MCP access
without `credential-use`.
### Cognitum Identity
- the `ruview` public client allow-list includes `spaces:read`;
- RFC 8414 metadata advertises it;
- refresh preserves the originally granted scope;
- no new client secret or password grant is introduced.
### Cognitum API
- the gateway preserves caller OAuth through an internal, spoof-resistant
relay while authenticating the private Function hop;
- Spaces verifies the signed OAuth principal and queries by tenant + workspace;
- legacy API-key behavior remains available;
- bounded semantic-state `PUT` is available only to an explicitly scoped API-key
publisher and is not exposed by the RuView OAuth client;
- OpenAPI documents both alternatives and RFC 9728 metadata supports discovery;
- the Function remains gateway-only at Cloud Run IAM.
### Autogenous
Autogenous must consume an explicitly typed credential. It must not imply that
`/v1/cli/session/exchange` produces a RuView-audience token: that exchange
currently produces `client_id=cognitum-cli` and cannot pass the Spaces policy.
An external RuView PKCE token may be supplied after activation, or a scoped API
key may be used as the compatibility path. Response validation and lineage
rules in this ADR apply before agent belief formation.
## Threat model
| Threat | Required control |
|---|---|
| Direct Function bypass | invoker IAM check; gateway SA only; no `allUsers` |
| Forged internal OAuth header | strip inbound relay headers; gateway writes after route classification |
| Token substitution | ES256/JWKS plus exact issuer, audience, client, type, scope, and tenant claims |
| Cross-tenant enumeration | principal-derived Firestore selector; bounded non-enumerating errors |
| Redirect/token exfiltration | redirects disabled; HTTPS required; fixed path |
| Oversized/malformed response | byte/depth/count/string bounds before use |
| Raw-data regression | server allow-list plus client forbidden-field rejection |
| Secret disclosure | redacting types; no token logs/URLs; `.env` untracked |
| Feedback amplification | lineage preservation, dedupe, cycle rejection, no independent corroboration |
| Memory leakage | tenant filter before vector search; no global nearest-neighbor pass |
| Agent overreach | observe/recommend default; ADR-321 fail-closed action gate |
| Stale/replayed state | expiry, sequence, message ID, freshness, witness receipt |
| JWKS outage/rotation | bounded cache; fail closed; refresh after unknown `kid`; no algorithm fallback |
## Deployment and rollback
Rollout order is dependency-safe:
1. merge and deploy Identity scope/metadata;
2. deploy the Spaces Function with OAuth verification while API-key behavior
remains unchanged;
3. deploy the gateway relay and protected-resource metadata;
4. verify gateway API-key access, OAuth denial matrices, direct-origin platform
denial (`401` or `403` before application code), and tenant isolation;
5. merge/release the RuView client and CLI activation;
6. enable Autogenous consumption only after its strict validation/lineage gates
pass.
Rollback disables OAuth advertisement/relay and returns clients to scoped API
keys. It must not restore public Function invocation. Revoking an OAuth session
or API key must not alter paired-site state.
## Validation and acceptance
Required automated gates:
- Identity: metadata test, migration application, PKCE authorize/token/refresh
scope preservation, cross-client scope denial;
- API Function: valid claim matrix and rejection for wrong issuer/audience/
client/type/scope/tenant, API-key regression, tenant query assertion, bounded
projection tests, build and dependency audit;
- gateway: spoofed relay stripped, caller OAuth preserved, Google hop identity
substituted, OpenAPI security alternatives, RFC 9728 metadata, build and
dependency audit;
- RuView: semantic decoder bounds/privacy tests, redaction tests, login scope
tests, CLI compile, and live empty/non-empty response tests without fixtures
masquerading as production;
- policy: no Spaces read can invoke an actuator; denial receipts are witnessed.
Production readback must prove:
- unauthenticated gateway request returns `401`;
- legacy scoped API key returns the authenticated tenant list;
- valid RuView OAuth returns only its workspace;
- wrong client, missing `spaces:read`, setup/workload token, and second-tenant
token are denied;
- the direct Function origin is rejected by the Google platform with `401` or
`403` before application code, even with a valid application credential;
- response remains `no-store` and excludes P0/P1;
- no secret appears in logs, diffs, artifacts, or issue/PR text.
Performance, detection quality, and action-safety numbers are not claimed by
this decision. Any such number requires a named reproducer and the repository's
evidence labels. An empty production tenant is a successful isolation/read-path
test, not sensing-quality evidence.
## Production evidence (2026-08-18)
The bounded Spaces read slice and RuView activation path are deployed. The exact
production release chain is:
- Spaces run `32148530629`, revision `spacesapi-00003-xij`, source
`fc333e634cd918b9d6fdde4eecbe7beac1043ab8`, Node 22, runtime service account
`spacesapi-runtime@cognitum-20260110.iam.gserviceaccount.com`, with
`apigateway-sa@cognitum-20260110.iam.gserviceaccount.com` as sole invoker;
- gateway run `32151485401`, revision `apigateway-00180-peh`, source
`c4e99ebb4ce0d4e1407f435f905621476c1f0166`, image digest
`sha256:bacb81281a54256ff6fdaac253175e76ce6fc225f399163ca0a807a2839bd6a3`;
- Identity run `32163542502`, revision `identity-00052-fid`, source
`fb6320827b879e481cad6caf184d3cbccd8279c4`, image digest
`sha256:0cd5896518bd8ecf042d2f3e9aea58a32e65a68dbddaab1e54f8ae6da2bfab06`,
and runtime service account
`identity-runtime-prod@cognitum-20260110.iam.gserviceaccount.com`.
The live API-key matrix returned `200` with an empty bounded list,
`Cache-Control: private, no-store`, and no prohibited P0/P1 projection fields.
No credential returned `401`. A direct-origin request received a Google
Frontend Bearer challenge (`401`) before application code.
Two independent RuView Authorization Code + PKCE principals also passed the
live matrix. Each token used ES256, exact issuer/audience/client checks,
`sensing:read spaces:read`, signed UUID organization/workspace claims, refresh
rotation, and revocation. Each gateway read returned `200`, an empty bounded
list, and `private, no-store`; a corrupted signature returned `401`; and the
principals had distinct pseudonymous tenant/workspace fingerprints. This proves
the production empty-tenant behavior and independent claim binding. Non-empty
cross-tenant isolation remains emulator/staging evidence because production was
not mutated to manufacture a fixture.
Identity metadata deliberately advertises `spaces:read` for RuView but not
`spaces:write`. The deployed semantic-state `PUT` remains an API-key-only
publisher surface. RuView therefore has no OAuth write, command, policy-approval,
or actuator capability.
That receipt was for the initial flat Space slice. The following production
expansion supersedes only its hierarchy/event/alert deferral. MQTT, commands,
actuators, real-hardware accuracy, and the long-duration operational trial
remain outside the completed claim.
## Completed implementation and production expansion (2026-08-19)
- Cognitum API PRs #211 and #212 shipped the eight `/v1/spatial` collections,
transactional hierarchy integrity, stable pagination, event/alert retention,
strict P2/P3 admission, API-key-only writes, OAuth/API-key reads, and the
additive-only Firestore release authority. Function run `32279092861`
promoted active Node 22 revision `spacesapi-00005-kaf`.
- Edge PRs #214, #215, and #216 preserved canonical UUID routing, kept SQLi
denial, and removed secret-valued API-key rate selection. Gateway run
`32284410107` promoted the reviewed immutable digest to 100% production
traffic. Every versioned collection returned HTTP 200 through the public
edge; the hierarchy composite index is `READY` and both retention TTL fields
are `ACTIVE`.
- The dedicated RuView service credential was rotated to exactly
`spaces:read` and `spaces:write`; its predecessor returns 401. A non-mutating
invalid-body probe reached write validation without persisting customer data.
Other potentially affected owner keys and residual log retention remain
tracked in Cognitum API #217.
- A live RuView Authorization Code + S256 PKCE consent requested exactly
`sensing:read spaces:read`. Its in-memory token read versioned `sites` with
HTTP 200 and schema `1.0`; the verifier then revoked the temporary refresh
credential and persisted no token.
- RuView PR #1650 merged `ruview-cognitum-spaces`,
`ruview-spatial-memory`, the ADR-327 policy extension, CLI paging, and the
guarded `ruview_spaces_list` metaharness surface. PR #1651 removed stale
feature-branch guidance and refreshed the signed package manifest.
- The contributor metaharness fixes the API origin, accepts bounded resource,
limit, and opaque-cursor inputs, strips API-key compatibility authority over
MCP, invokes only the hardened OAuth CLI, and rejects raw sensing or malformed
hierarchy/event/alert output. Its test, security, reviewed-brain, flywheel,
manifest, audit, exact-tarball, and claim-check gates pass.
- Release run `32286297277` rebuilt and smoke-tested the exact package and
provenance-published `@ruvnet/ruview` 0.5.0. The public npm registry resolves
0.5.0 as `latest`; no workstation publish was used.
- `ruview-spatial-memory` keeps one RuVector HNSW index per authenticated
tenant/workspace with replay, derivation, retention, cascading-erasure,
bounded-explanation, encrypted-snapshot, and reload-verified rotation gates.
This is local `SYNTHETIC` evidence, not a production sensing claim.
- `ruview-policy` keeps observe/recommend/execute intents distinct, requires
exact host grants plus signed approval for consequence, rejects nonce replay,
and emits signed hash-chained receipts. `spaces:read` is explicitly denied as
execution authority.
- Focused Rust gates and the Linux workspace/CLI/security lanes pass. Earlier
Windows whole-workspace attempts ended in host compiler failure or timeout;
those attempts are not reclassified as green evidence.
- No OAuth write/action scope, actuator callback, MQTT deployment claim, sensing
accuracy claim, or real-hardware claim is introduced.
## Consequences
### Positive
- One Cognitum identity can explicitly activate RuView's cloud spatial read
capability without sharing a long-lived static bearer.
- Tenant and workspace become cryptographically bound inputs to the data query.
- RuView and Autogenous gain useful spatial context without importing raw RF or
inventing independent evidence.
- The design keeps a path for RuVector-grounded explanations and separately
governed action without treating either as part of the deployed read slice.
- The direct-origin bypass is closed permanently, independent of OAuth rollout.
### Costs and limitations
- Two credential types coexist during migration and must stay visibly distinct.
- OAuth depends on Identity JWKS availability and correct key rotation.
- Production exposes both the legacy Space twins and the versioned hierarchy,
anonymous entities, semantic events, and alerts over HTTPS. MQTT remains a
design contract without deployment evidence.
- OAuth workspace IDs will return only documents populated with `workspaceId`;
legacy owner-only documents require an explicit migration, never a broad query.
- The RuView client exposes no write, command, or agent execution surface. The
separate API-key semantic-state ingress is neither OAuth activation nor
actuator authority.
## Alternatives considered
**Keep API keys only.** Rejected as the target: keys are useful for service
compatibility but do not provide user activation, consent, short lifetime, or
refresh/revocation semantics.
**Treat the CLI API-key exchange token as a Spaces OAuth token.** Rejected: it
is minted for `cognitum-cli`, not `ruview`, and accepting it would remove the
audience/client boundary.
**Trust the gateway without verifying OAuth in Spaces.** Rejected: hop identity
and user authorization are distinct, and authorization must remain valid if the
route topology changes.
**Make Spaces state independent corroboration.** Rejected: it is derived from
the same RuView/HomeCore lineage and would double-count evidence.
**Allow agents to execute from `spaces:read`.** Rejected: read consent is not
action authority, and perception confidence alone cannot authorize consequence.
**Synchronize raw RF for better cloud models.** Rejected by default: it violates
the edge privacy boundary and is unnecessary for the semantic product.
## References
- Autogenous ADR-402, `docs/adr/ADR-402-ruview-cognitum-spaces-spatial-intelligence.md`
- Cognitum API ADR-094, `docs/adr/ADR-094-cognitum-spaces-homecore-edge-boundary.md`
- Cognitum API hierarchy/events/alerts follow-up,
`https://github.com/cognitum-one/api/issues/206`
- RuView metaharness OAuth surface,
`https://github.com/ruvnet/RuView/issues/1643`
- RuVector spatial-history follow-up,
`https://github.com/ruvnet/RuView/issues/1640`
- governed-action and witness-receipt follow-up,
`https://github.com/ruvnet/RuView/issues/1641`
- RFC 7636, Proof Key for Code Exchange
- RFC 8414, OAuth 2.0 Authorization Server Metadata
- RFC 9700, OAuth 2.0 Security Best Current Practice
- RFC 9728, OAuth 2.0 Protected Resource Metadata
@@ -0,0 +1,137 @@
# ADR-326: Tenant-scoped RuVector spatial memory and anomaly explanations
- **Status**: Accepted — implementation complete; repository-wide and deployment gates pending
- **Date**: 2026-08-19
- **Decision owners**: RuView maintainers
- **Extends**: ADR-312, ADR-319, ADR-325
- **Implements**: ruvnet/RuView#1640
- **Tags**: cognitum-spaces, ruvector, memory, tenant-isolation, explanation, privacy
## Context
ADR-325 requires anomaly explanations grounded in tenant-local spatial history,
but the deployed client only returns a current list. A global vector index would
be unsafe: filtering nearest-neighbor results after the search can reveal that a
different tenant has a close match, even when identifiers are removed. A memory
record can also launder returned RuView-derived state into a second independent
observation, reset freshness, or form circular evidence.
Spatial memory must be useful without storing OAuth/API credentials, raw CSI/CIR,
RF tensors, pose frames, vital waveforms, recordings, identity observations, or
unbounded agent transcripts. Persistence also needs explicit retention,
deletion, provenance, and key-rotation behavior.
## Decision
### 1. Partition before similarity
`ruview-spatial-memory` owns a `SpatialMemory` map keyed by the exact authenticated
`(tenant_id, workspace_id)` pair. Each partition owns its own RuVector HNSW index.
Ingest and search resolve the partition first; no global ANN query exists. Site,
space, schema version, and time-window constraints narrow within the selected
partition before results are returned.
### 2. Bounded semantic records
An accepted record contains:
- tenant/workspace/site/space and stable record identity;
- source ID, message ID, record ID, monotonic event sequence, schema version;
- original `observed_at`/`expires_at` and a retention deadline;
- a bounded finite semantic feature vector, uncertainty, and evidence label;
- provenance and witness digests, plus bounded derivation references;
- explicit observation/inference classification.
Credentials and P0/P1 fields have no representation in the type. Strings,
features, references, record counts, and query `k` are bounded. Non-finite
features and uncertainty fail closed.
### 3. Lineage and replay
The partition rejects:
- changed reuse of `(source_id, message_id)`;
- a non-increasing sequence for the same source;
- duplicate derivation references;
- self-reference, missing/forward parents, and therefore every cycle;
- expired input or a provenance/witness substitution.
A recollection keeps its original lineage, timestamp, uncertainty, and evidence
label. It cannot increment corroborating-source count or become independent
support for its own ancestor.
### 4. Persistent encrypted storage
Snapshots are encrypted with XChaCha20-Poly1305 under a caller-supplied 256-bit
key and a non-secret key ID. The authenticated associated data binds the storage
format and key ID. The envelope is bounded and versioned; plaintext spatial
records are never written to disk. Loading requires a keyring containing the
named key. Rotation decrypts with the old key, atomically creates a new
generation under the new key ID, reload-verifies that generation, and leaves
the source intact. Snapshots never overwrite an existing path implicitly.
Deletion supports a tenant/workspace partition, a record, and retention cutoff.
Every deletion rebuilds that partition's HNSW index so removed records cannot be
returned from stale graph nodes.
### 5. Explanations
`explain` compares a bounded query vector with nearest tenant-local history and
returns the exact authenticated partition, generation time, ordered record IDs,
RuVector distances, original uncertainty/evidence labels, and provenance/witness
digests. Its basis explicitly says that similarity is not causation. The API
does not expose the vectors or invent a causal explanation.
History provides context, not authority. An explanation cannot authorize an
action, increase certificate class, or replace a policy decision.
## Consequences
### Positive
- Cross-tenant ANN leakage is structurally unavailable.
- Explanations cite the exact tenant-local records used.
- Replay/cycle/provenance substitution are rejected before indexing.
- Encrypted persistence has explicit key IDs and rotation behavior.
### Costs and limitations
- Partition-local HNSW uses more indexes than a global graph.
- Deletes and key rotation rebuild indexes.
- No detection-quality or latency claim is made; tests are `SYNTHETIC` unless a
reproducer explicitly marks a measurement.
- Cloud Cognitum does not receive the local encrypted memory file.
## Validation
- cross-tenant and cross-workspace nearest-neighbor denial;
- duplicate record/message, stale-sequence, self/duplicate/missing-parent, and
provenance-substitution tests;
- expiry, retention deletion, whole-partition deletion, sealed round-trip,
tamper rejection, wrong-key rejection, and key-rotation tests;
- explanation citations and retained evidence/provenance labels;
- no forbidden raw-field or credential representation;
- the focused `ruview-spatial-memory` crate suite passes with `SYNTHETIC`
evidence on 2026-08-19;
- the whole-workspace Windows gate was non-terminal (compiler crash in parallel,
timeout when serialized), so Linux CI, a RustSec advisory scan, and package
review remain release gates.
## Alternatives considered
**One global HNSW followed by filtering.** Rejected: ranking itself crosses the
tenant boundary.
**Cloud vector memory.** Rejected as the default: it expands the privacy and
credential boundary without being needed for local explanations.
**Plain JSONL persistence.** Rejected because tenant spatial history is sensitive
even when raw sensing is excluded.
## References
- ADR-312: Long-term spatial memory
- ADR-319: Witness chain
- ADR-325: Cognitum Spaces activation and governed exchange
- Cognitum API ADR-101
- ruvnet/RuView#1640
@@ -0,0 +1,133 @@
# ADR-327: Governed action intents, approvals, replay protection, and witness receipts
- **Status**: Accepted — implementation complete; repository-wide and deployment gates pending
- **Date**: 2026-08-19
- **Decision owners**: RuView maintainers
- **Extends**: ADR-318, ADR-319, ADR-321, ADR-325
- **Implements**: ruvnet/RuView#1641
- **Tags**: policy, governed-action, approval, idempotency, witness, cognitum-spaces
## Context
The current `ruview-policy` crate evaluates assurance for an action class, but it
does not define a complete action intent, tenant/workspace binding, policy
version, approval, nonce/idempotency replay behavior, or signed terminal receipt.
An agent recommendation can therefore be mistaken for execution authority, and
`spaces:read` could be accidentally treated as a general capability.
The system needs a framework that can prove why an action was allowed or denied
without adding any actuator. Real actuation remains a separate integration and
requires its own threat model and device evidence.
## Decision
### 1. Typed intent and registered policy
A governed `ActionIntent` binds:
- intent ID, tenant, workspace, action name/class, and exact target;
- requested policy version and parameter/evidence digests;
- creation/expiry, replay nonce, and requesting principal;
- the recommendation/explanation that motivated review, never a hidden command.
The gate accepts only a registered action policy. Unknown action, action-class
mismatch, policy-version mismatch, target mismatch, invalid timestamps, and
missing exact host authority deny before assurance is evaluated. Tenant and
workspace are part of the signed intent/receipt and nonce key. `spaces:read` is
explicitly tested as insufficient for an `alerts:execute` rule.
### 2. Assurance and approval
The existing ADR-321 certificate/domain/uncertainty/evidence gate remains the
assurance authority. The registered policy declares a bounded minimum of
distinct enrolled approvers. An absent, rejected, duplicated, expired,
wrong-intent, wrong-policy-version, or unverifiable approval denies. Approval
resolution fails closed.
Agents observe, explain, or recommend by default. `evaluate` returns a decision
receipt; it does not call an actuator. An executor may consume an `allow` receipt
only if a separate adapter verifies the receipt, target, expiry, and its own
device-specific authority.
### 3. Replay and idempotency
The bounded in-memory gate stores terminal receipts by intent ID and tracks
nonces by `(tenant, workspace, nonce)`.
- exact intent replay returns the original terminal receipt;
- changed reuse of an intent ID returns a fail-closed idempotency error;
- reuse of a nonce by another intent returns a fail-closed replay error;
- expired intents and approvals deny;
- failed or denied attempts are terminal and auditable.
The current state store is bounded and in-memory, intended for local/runtime use
rather than cross-process replay protection. A production executor must place
the same intent/nonce/receipt invariants behind a transactional durable store;
this ADR does not claim that adapter exists.
### 4. Witnessed terminal receipt
Every evaluated observe/recommend/execute request produces a canonical receipt
containing the intent digest, decision/reason, policy version, tenant/workspace,
decision/expiry time, intent ID and nonce, approval count, and previous receipt
digest. The receipt is signed through the `ruview-attest` signer interface and
can be independently verified. Hash chaining makes removal/reordering visible.
Malformed input, ID conflict, nonce replay, capacity exhaustion, and sequence
exhaustion are errors before receipt creation and must be audited by the host.
The reference keyed-BLAKE3 signer remains `SYNTHETIC` evidence only, as documented
by ADR-319. Production asymmetric signing and key custody must be supplied by the
deployment adapter; no symmetric test MAC is represented as hardware identity.
## Consequences
### Positive
- Recommendation, authorization, and execution are distinct typed stages.
- Default-deny covers missing policy, stale evidence, unavailable approval, and replay.
- Every decision has a terminal, verifiable explanation.
- `spaces:read` cannot silently expand into consequence.
### Costs and limitations
- Executors must implement a separate receipt-verifying adapter.
- Distributed replay protection needs a transactional durable store.
- This ADR implements no actuator, command transport, pairing mutation, or device control.
- Simulator tests are not hardware validation.
## Validation
- unknown/missing policy, stale intent, policy-version/target mismatch,
insufficient authority, and `spaces:read`-only denial;
- certificate/domain/uncertainty/evidence denial matrix from ADR-321;
- missing/rejected/expired/duplicate/wrong-intent approval tests;
- exact idempotent replay, changed reuse, nonce replay, and bounded-store tests;
- receipt signature, canonical digest, chain linkage, and tamper rejection;
- tests proving evaluation exposes no actuator callback or network/file side effect.
The focused `ruview-policy` suite passes on 2026-08-19. The reference signer
tests are `SYNTHETIC`; they are not hardware-identity evidence. The non-terminal
whole-workspace Windows gate still requires authoritative Linux CI evidence.
Any future actuator adds a separate ADR, credential boundary, failure/rollback
plan, allow/deny integration tests, and captured target-device evidence.
## Alternatives considered
**Let agents call actuators after a recommendation.** Rejected: recommendation
quality is not authorization.
**Treat OAuth scopes as action policy.** Rejected: `spaces:read` expresses read
consent only and carries no target-specific assurance or approval.
**Emit receipts only for successful actions.** Rejected: denial and unavailable
approval are security-relevant terminal facts.
## References
- ADR-318: Capability certificates
- ADR-319: Witness chain
- ADR-321: Decision policy action authorization
- ADR-325: Cognitum Spaces activation and governed exchange
- ADR-326: Tenant-scoped RuVector spatial memory
- ruvnet/RuView#1641
@@ -0,0 +1,59 @@
# ADR 340: iPhone LiDAR Sensor Bridge
Status: Proposed
## Context
RuView needs a low cost mobile geometry sensor that can contribute calibrated spatial observations without coupling the perception substrate to Apple frameworks.
ARKit exposes rear LiDAR scene depth through `ARFrame.sceneDepth` and `smoothedSceneDepth` on supported devices. Ordinary mobile web pages do not receive this ARKit depth surface directly, so native capture and web visualization must be separated.
## Decision
Use a two layer architecture.
1. Native Swift and ARKit perform acquisition.
2. A modality neutral wire frame transports geometry into browser tools and, next, the RuView HAL.
The native client will capture depth, confidence, camera intrinsics, and world tracking pose. RGB imagery is excluded from the default transport.
The protocol identifier is `ruview.lidar.depth.v1`.
Depth samples are quantized to UInt16 millimeters for transport. Confidence remains UInt8. The default sender downsamples by two spatially and caps transmission at 15 FPS. Full fidelity depth remains available locally for future on device inference.
## RuView integration boundary
The transport must not become a second world model. The production receiver converts each packet into the canonical `ruview-hal::Observation`, then passes it through authenticated sensor identity, provenance, OOD gating, uncertainty aware fusion, spatial memory, and WorldGraph adapters.
Rules:
1. `source=live` is valid only for frames produced by an active ARKit session.
2. Sequence numbers are monotonic per sensor session.
3. Wall clock timestamp is separate from ARKit monotonic frame timing.
4. RGB is off by default and requires an explicit higher privacy capability.
5. Browser clients consume geometry but are not treated as authoritative sensors.
6. Unsupported devices fail closed rather than substituting simulated depth.
## Performance target
`[SYNTHETIC]` A 256 x 192 Float32 depth map is about 196 KB before confidence and metadata. Downsampling to 128 x 96 and encoding each sample as two byte depth plus one byte confidence yields about 36.9 KB raw. At 15 FPS the raw sensor payload is about 553 KB/s. Base64 raises this to roughly 737 KB/s before JSON metadata. These values are arithmetic sizing estimates, not device measurements.
The `[CLAIMED target]` for local network latency is below 150 ms p95. A later binary WebSocket or QUIC transport can remove base64 overhead; the exact end-to-end reduction must be measured before it is claimed.
## Security
The development relay is LAN-facing, requires a random per-run bearer token, bounds message size, and restricts the files it serves. Its default `ws://` transport is not encrypted, so it is not a production trust boundary.
Production requires WSS, authenticated sensor identity, replay protection, message size limits, per tenant authorization, provenance receipts, and explicit retention policy before persistence.
## Consequences
Benefits include commodity hardware, metric depth, tracked camera pose, rapid room scanning, calibration support for RF sensing, and a practical ground truth source for RuView experiments.
The main limitation is that Apple provides processed scene depth rather than the underlying raw transient LiDAR waveform. Therefore this implementation supports direct geometry and sensor fusion now, but does not reproduce research systems that require raw multipath time of flight transients for non line of sight reconstruction.
## Acceptance criteria
A physical LiDAR capable iPhone must stream live geometry to the browser viewer with monotonically increasing sequence numbers, no RGB payload, valid confidence maps, and below 150 ms p95 local network latency over a 60 second run.
CI type-checking and simulator runs do not satisfy this criterion. Until a captured physical-device run records the environment and results, the hardware behavior and latency remain unverified.
@@ -0,0 +1,93 @@
# ADR-344: Adaptive local installation discovery
## Status
Accepted — local advertisement and mobile discovery implemented; physical
peer-link and hosted-relay qualification pending.
## Context
RuView installations previously depended on a manually stored IP address.
DHCP changes, access-point changes, and client isolation could therefore leave
a healthy sensing installation unreachable from the mobile app. Repeated
authentication-policy log entries did not prove that the selected endpoint was
reachable.
Discovery must make commissioning recoverable without turning a service name
into authentication, leaking sensor data, scanning arbitrary subnets, or
silently moving private spatial data to a hosted service.
## Decision
The sensing server advertises one bounded `_ruview._tcp.local.` service when it
is bound to a routable interface. Loopback-only instances do not advertise,
and operators can disable advertisement with `--no-mdns`.
The TXT contract is deliberately small:
| Key | Required | Meaning |
|---|---:|---|
| `schema=ruview.installation.v1` | yes | Fail-closed protocol discriminator |
| `tls=0|1` | yes | HTTP or HTTPS origin construction |
| `installation=<opaque id>` | no | Bounded routing hint, never authentication |
The advertisement contains no node inventory, room identifier, SSID,
credentials, CSI, vital estimates, pose, identity, or learning data. The
hostname is bounded and sanitized before registration. Advertisement failure
is recoverable and does not stop sensing.
RuView Mobile resolves only the matching service and schema, validates the
resulting origin under its private-LAN/HTTPS policy, and requires an application
health probe before selection. Its adaptive broker retains the configured
origin preference and requires two failed configured probes plus two healthy
fallback probes before switching. Credentials remain scoped to their saved
origin.
The recovery ladder is:
1. configured private-LAN or HTTPS origin;
2. verified Bonjour local origin;
3. physically qualified Apple peer-to-peer local path;
4. explicit, authenticated HTTPS relay for bounded derived frames only;
5. optional administrator-managed WireGuard/Tailscale access.
Only levels 1 and 2 are implemented and software-validated by this decision.
Peer-to-peer browsing is enabled on Apple platforms, but it is not a qualified
peer-link data plane. This repository does not provide a hosted relay and must
fail closed when no local endpoint is healthy.
## Security and privacy consequences
- Service discovery is routing evidence, not installation authentication.
- Public HTTP origins, credentials in URLs, malformed records, and records with
the wrong schema are rejected before use.
- Raw CSI, RSSI streams, camera/LiDAR frames, room geometry, pose labels,
identity data, and training examples remain local.
- Future relay work requires a separate consent, authentication, revocation,
minimization, and physical evidence review.
- ESP32-S3/C6 nodes remain provisioned to the sensing installation. This ADR
does not claim direct phone-to-node discovery or invent a firmware protocol.
## Validation
Software acceptance requires:
- unit tests for bounded advertisement construction and hostname sanitation;
- mobile parser rejection, route scoring, anti-flapping, and Settings UI tests;
- Rust, TypeScript, lint, security, metaharness, Expo, and native compile gates;
- a real Bonjour resolve of the TXT contract followed by a successful
`/api/v1/status` probe.
Physical qualification additionally requires installation discovery on an
iPhone, live frame and node-inventory receipt, DHCP-change recovery without
flapping, and a five-to-ten-minute zero-fusion-error burn-in. Simulator and
host-only results remain `MEASURED_SOFTWARE`; peer-link, relay, and physical
reconnection claims remain `NOT_MEASURED` until those captures exist.
## Implementation references
- `v2/crates/wifi-densepose-sensing-server/src/discovery.rs`
- `v2/crates/wifi-densepose-sensing-server/src/main.rs`
- Mobile companion decision: `cognitum-one/ruview-mobile`,
`docs/adr/ADR-026-adaptive-local-installation-discovery-and-transport-recovery.md`
- Related decisions: ADR-034, ADR-054, ADR-296
@@ -0,0 +1,193 @@
# ADR-345: Per-link CSI attribution and node-to-node ranging
- **Status**: Proposed — measurement layer implemented and validated on hardware; localization not built
- **Date**: 2026-08-28
- **Deciders**: Joe
- **Tags**: csi, wire-format, esp32-c6, localization, adr-018, adr-060, adr-110
## Context
Every attempt at device-free position estimation in this repository has failed
against the same wall, and the wall has now been measured twice from different
directions.
**Phase is unusable.** The bistatic tier needed a signed per-link radial
velocity, which requires the common-mode component of CSI phase. Measured
2026-08-28 across all three ESP32-C6 nodes over ~40,000 frames, the
frame-to-frame change in common-mode phase is statistically indistinguishable
from uniform random — resultant length `R` = 0.011–0.032 against a 0.000
reference, standard deviation 1.78–1.81 against `pi/sqrt(3)` = 1.814, and no
lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is
packet-detection timing quantization: common-mode phase is `-2*pi*f_c*tau`, and
one ADC sample of jitter at 20 MHz is `2*pi * 2.412e9 * 50e-9` ~= 758 rad, about
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
is default-off as a result (`bistatic_tier_enabled`).
**Amplitude works but had no parallax.** The three AP -> node links leave a
single distant transmitter within a 36.5 degree fan and converge on the node
cluster. Bearings computed from `room_config.json`: 198.5, 221.2, 235.0 degrees.
Scalar per-node energy weighting was closed with a clean negative result on
2026-08-27 — magnitude responds to proximity, direction does not.
**The receivers were hearing far more than we recorded.** A node in promiscuous
MGMT+DATA mode with no `filter_mac` produces CSI for every transmitter on the
channel. Measured on node 2: the AP accounted for only **34%** of frames; the
two peer nodes supplied 39% and 27%. Those peer frames are valid measurements
of *node-to-node* channels — but ADR-018's wire format carries no transmitter
address, so the sink could not attribute them. They interleaved into one
`frame_history` with mixed geometry, and the only available remedy
(`filter_mac`, ADR-060) fixes the mixing by discarding ~75% of the data.
The node-to-node links between the same three boards cross the room from three
sides at **108.7 degrees** of angular spread (bearings 0.0, 73.2, 108.7) versus
36.5 for the AP links — roughly 3x the parallax, with both endpoints known,
fixed, and under our control rather than wherever the router happens to live.
Link count also grows as `N*(N-1)/2`: 3 nodes give 3 links, 6 give 15, 13 give
78.
## Decision
Carry the transmitter MAC on the CSI wire and model CSI per **link**
(receiver, transmitter) rather than per **node**, as a layer alongside the
existing per-node model.
### 1. Wire format v2
`CSI_MAGIC_V2 = 0xC5110008`, header 26 bytes: the v1 header unchanged through
byte 19, then the transmitter address (`info->mac`, 802.11 addr2) at bytes
20..25, with I/Q from byte 26.
`0xC5110002`..`0xC5110007` were already claimed by the vitals, feature and other
edge packets, so v2 takes the next free value rather than an adjacent one — see
`issue_928_magic_collision_tests` for why that matters. The parser accepts both
magics, so a mixed fleet keeps working and v1 nodes simply report no
transmitter.
### 2. Per-link state (`links.rs`)
`LinkTable` keyed by `LinkId { rx_node, tx_mac }`, each link holding its own
amplitude history, RSSI EMA, learned resting baseline and motion metric.
The metric is the mean over subcarriers of the temporal standard deviation of
**AGC-normalized** amplitude. Removing each frame's own mean across subcarriers
is essential rather than cosmetic: the ESP32 applies automatic gain control per
packet, so absolute amplitude jumps between packets for reasons unrelated to the
channel, and any statistic built on the raw level measures the radio's gain
control instead of the room.
Amplitude only, deliberately — nothing here can depend on phase coherence.
### 3. Beacon rate
`BEACON_PERIOD_MS` 100 -> 20. At 10 Hz a peer samples a node-to-node link with a
5 Hz Nyquist while motion Doppler lives at 1–20 Hz; it aliases before it starts.
20 ms matches both the receiver's own `CSI_MIN_PROCESS_INTERVAL_US` ceiling and
the gateway self-ping already used for the AP link.
### 4. `GET /api/v1/links` and a live UI panel
The link table was initially reachable only through a 30-second log line, which
is the wrong medium for a quantity whose value is watching it respond while you
walk across the room. `GET /api/v1/links` returns one row per link — `motion`,
`raw_motion`, `rssi_dbm`, `frames`, `kind` — and `LinkMeshPanel` renders them in
the Sensing tab at 1 Hz. `raw_motion` is shown beside `motion` deliberately: a
link whose baseline has not settled reports a healthy raw value and a near-zero
motion, and with only the one number that is indistinguishable from a dead link.
Node identity is **inferred, not measured**. Nothing in the system records a
node's own MAC — a node reports the addresses it hears, never its own. But a
radio does not receive its own transmissions, so given the set of receivers
currently reporting, a transmitter heard by all of them except exactly one is
that one receiver's own board; a transmitter heard by every receiver is external
infrastructure. `links::infer_transmitting_node` declines (returns `None`) with
fewer than three receivers, where "heard by all but one" and "heard by exactly
one" are the same observation, and whenever more than one receiver is missing,
which a peer out of range produces indistinguishably. The field is named
`tx_node_inferred` and the UI draws the peer id with a trailing `?`.
Checked against the known MAC map on 2026-08-28 the inference labelled all four
transmitters correctly — nodes 0, 1 and 2 by address, and the AP as external —
with no access to that map. A wrong answer here mislabels a display row; it
never reaches a metric.
## Consequences
### Validated on hardware, 2026-08-28
All three nodes on wire v2 at 20 ms produced the full mesh: **9 links** (3 AP +
6 directional node-to-node).
Reciprocity — each physical channel measured independently from both ends:
| channel | direction A | direction B | ratio |
|---|---|---|---|
| 0 <-> 1 | 2.229 | 1.930 | 1.15 |
| 0 <-> 2 | 1.353 | 1.000 | 1.35 |
| 1 <-> 2 | 1.398 | 1.486 | 1.06 |
Nothing in the implementation enforces this. Three independent radio pairs
agreeing within 6–35% is the strongest available evidence that the per-link
separation is real rather than an artifact of frame bucketing.
Link quality is also far more uniform than the AP links: node-to-node raw motion
spans 1.0–2.2 across all six, versus 3.8–17.8 for the three AP links (which
track RSSI: -67, -72, -80). Uniform link quality is desirable for tomographic
localization — no single link dominates the solution.
### Known limitations
- **Peer links are ~10x weaker.** A 16-byte ESP-NOW beacon offers far less
energy to estimate a channel response from than a full data frame. Whether
1.0–2.2 perturbation is sufficient to localize with is **unknown and untested**.
- **Three links is below threshold.** Tomographic localization needs many
crossing links; 3 will not localize regardless of quality. This ADR delivers
the measurement layer, not localization.
- **Localization is not implemented.** No position tier consumes `LinkTable`.
The live position estimate remains `doppler_centroid`.
- **Per-node path regression.** Removing `filter_mac` to admit peer frames
returned the *per-node* `frame_history` to a mixed-transmitter stream, since
it still consumes every frame regardless of source. Node 2's classification
signal is noisier as a result. The fix is to select a link to feed the
per-node path — software filtering that keeps the other links — and it is not
yet done.
### Scaling limits to settle before more nodes
- **Airtime.** Every node broadcasts, so N nodes put N/period frames per second
on the channel being sensed. 3 nodes at 20 ms is 150 pps; 13 would be 650 pps.
`BEACON_PERIOD_MS` should become an NVS setting scaled to fleet size.
- **`MAX_LINKS` is 64.** 13 nodes give 78 receiver-transmitter pairs plus the AP
plus neighbours, which overflows. Raise deliberately, with the memory implied.
- **Placement.** Links must *cross* the target area. Nodes spread through a
house give roughly one link per room, each attenuated by walls and perturbed
by anyone anywhere along it — whole-house coarse presence, not position.
Perimeter placement around a single room is the geometry that works.
## Related bugs found and fixed while implementing
- **ADR-060 MAC filtering cost ~75% of capture rate.** The 50 Hz rate gate ran
*before* the MAC filter and stamped `s_last_process_us` before the check, so a
frame from any other transmitter consumed the slot and was then discarded.
Enabling `filter_mac` dropped yield from ~42 pps to 6–13. Filtering first
restores it (measured 31–37 pps). A 6-byte `memcmp` is negligible ISR work next
to the CSI processing the gate guards.
- **The entire edge pipeline was dead on ESP32-C6.** `EDGE_MAX_SUBCARRIERS` was
128, an S3-era assumption; a C6 on an HE-capable AP delivers HE20 frames with
256 bins, and `process_frame` rejected every one. ADR-110 onboarded the C6 to
the capture path but not this one. Vitals, presence, fall detection and
per-slot counting had never run on these boards. Now target-conditional on
`CONFIG_SOC_WIFI_HE_SUPPORT`, with the four subcarrier-sized arrays hoisted to
static so peak stack is *lower* than it was at 128.
- **A target-conditional constant that failed silently.** `edge_processing.h`
had no `#include` at all, so `CONFIG_SOC_WIFI_HE_SUPPORT` was undefined and
`#if` evaluated it as 0 — selecting the wrong size with no warning. An
undefined identifier in `#if` is 0 in C. This is the same failure mode as the
bug it was fixing, one layer up.
## References
- `v2/crates/wifi-densepose-sensing-server/src/links.rs` — per-link state, 8 tests
- `v2/crates/wifi-densepose-sensing-server/src/phase_diag.rs` — the phase measurement
- `firmware/esp32-csi-node/main/csi_collector.c` — wire v2 serialization, filter ordering
- `firmware/esp32-csi-node/main/edge_processing.h` — `EDGE_MAX_SUBCARRIERS`
- ADR-018 (wire format), ADR-060 (MAC filtering), ADR-110 (C6 onboarding)
@@ -0,0 +1,52 @@
# ADR 346: Fail closed ESP32 occupancy evidence
## Status
Accepted and implemented. Physical qualification is required after each firmware build.
## Date
2026 08 31
## Context
The ESP32 Tier 2 pipeline produces two different signals. Presence is a debounced room level decision. Person count is a bounded subcarrier diversity heuristic. A live four node installation emitted packets with `presence=false` and `n_persons=3` or `4`. The server eventually gated the aggregate room count, but raw WebSocket consumers and diagnostics could still treat the contradictory count as occupancy evidence.
That contradiction is more dangerous than a missed optional count. It can contaminate empty room calibration, train a room model on false labels, and encourage a product claim that the firmware cannot support. The count is not identity, pose, or a validated multi person estimator.
## Decision
1. Firmware person slots are subordinate to the debounced presence gate.
2. When presence is false, the firmware clears slot activity, slot history, candidate count, persistence streak, and stable count.
3. The serialized person count is always zero when presence is false and is clamped to `EDGE_MAX_PERSONS` when presence is true.
4. The sensing server repeats the invariant for older firmware. A contradictory or out of range count becomes zero and carries `person_count_valid=false`.
5. Fused CSI plus mmWave packets use either CSI presence or mmWave presence as the supporting presence condition.
6. The node inventory and WebSocket diagnostics expose person count validity. Consumers must not infer a person from an invalid count.
7. No count accuracy claim is created by this change. The firmware output remains a heuristic until a leakage free, held out physical dataset demonstrates otherwise.
8. OTA admission uses the selected update partition size rather than a stale fixed 900 KB ceiling. The status endpoint reports that same hardware bound, while image validation and authenticated OTA remain mandatory.
## Security and privacy
The change retains no raw CSI or personal data. It reduces authority by preventing a secondary heuristic from asserting occupancy after the primary gate has closed. The host validates packet length, magic, range, and logical consistency before using count evidence.
## Consequences
Older firmware remains wire compatible. Invalid count evidence becomes visibly unavailable instead of silently affecting calibration. A true multi person event can still be undercounted when the presence gate is false, which is the intended fail closed behavior. Current C6 images larger than 900 KB can use the installed 1,900,544 byte OTA slots after one serial upgrade, without weakening the OTA authentication gate.
The largest uncertainty is whether the current presence gate itself generalizes across the installed rooms. The fix path is a room bound empty baseline plus the fixed room selective held out protocol, not a global threshold reduction.
## Evidence and acceptance
MEASURED before implementation on 2026 08 31: four live nodes streamed for 86 seconds with zero transport errors, while edge packets repeatedly contradicted `presence=false` with counts of three or four.
Software acceptance requires:
1. Host firmware tests prove absent plus four active slots serializes zero.
2. Rust parser tests prove contradictory and out of range counts fail closed.
3. The node API exposes count validity without breaking older firmware.
Physical acceptance requires the updated firmware on a confirmed board, a captured boot log, five minutes of live packets, zero logical count contradictions, and no increase in transport errors. Accuracy remains unmeasured until labelled held out sequences are recorded.
Physical occupancy qualification completed for ESP32 C6 node 4 on 2026 08 31. The five minute run observed 242 edge packets, including 61 absent packets, with zero logical count contradictions and zero parse errors. See `docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md`.
ESP32 C6 node 7 was subsequently identified, upgraded to firmware 0.8.8, and transport qualified for five minutes with zero fused presence count contradictions and zero steady state transport errors. Its controlled empty room sequence remains required before occupancy qualification. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`. Other nodes remain unqualified until separately identified and upgraded.
@@ -0,0 +1,85 @@
# ADR 347: Rate aware ESP32 temporal sensing
## Status
Accepted. Implemented in firmware 0.8.8. The timing and transport path is
physically qualified on ESP32 C6. The independent raw transport path is also
physically qualified on an ESP32 S3 running Tier 0. Held out inference accuracy
remains required.
## Context
The ESP32 firmware creates CSI opportunities by sending one byte ICMP probes to
the connected access point. The traffic source is configured for 50 Hz, but the
delivered CSI cadence varies with channel contention and callback safety gates.
A physical ESP32 C6 produced 28 to 37 callbacks per second during the baseline
capture. Firmware 0.8.5 then exposed that the old per-interval estimator saw
only 12 to 16 Hz because WiFi replies arrived in short bursts separated by
longer gaps. The filters still consumed those burst frames, so excluding them
from the clock estimate was incorrect.
The edge DSP estimates its sample rate from timestamps so that breathing,
heartbeat, motion, and future Doppler features stay in physical Hertz. That
estimator was capped at 30 Hz. Once the actual cadence exceeded the cap, every
temporal feature was scaled against the wrong clock.
Physical firmware 0.8.5 validation corrected that initial diagnosis. Although
the callback path received 26 to 40 frames per second, Tier 2 on the unicore C6
processed an irregular subset that converged toward the 8 Hz estimator floor.
The right design is not to force the edge DSP to match raw capture. The paths
need independent, explicit cadence contracts.
The device free gesture preprint at
`https://www.preprints.org/manuscript/202602.0018` reinforces the importance of
timestamp correct Doppler features, but its 100 Hz controlled link is not a
safe firmware default for RuView. Existing S3 and C6 evidence records WiFi ISR
and packet buffer failures under sustained callback pressure above 50 Hz.
## Decision
1. Make the connected STA probe rate a build time setting from 10 through 50
Hz, with a default and hard ceiling of 50 Hz.
2. Track the delivered DSP cadence by counting every processed frame interval
over one second timestamp windows, then smooth successive windows in an 8
through 60 Hz estimator range. The 60 Hz estimator ceiling accommodates
timestamp jitter; it does not authorize more than 50 Hz callback processing.
3. Reject incomplete windows below one second and stalled windows above three
seconds. Do not discard valid burst frames from the estimated clock.
4. Surface the DSP rate in the one second controller diagnostic so hardware
validation can compare callback yield with the clock used by temporal
filters.
5. Keep raw CSI on the wire at the independent network cadence. Rate-limit the
C6 on-device Tier 1 and Tier 2 DSP input to a uniform 8 Hz. Physical 0.8.7
evidence showed that a requested 10 Hz input still converged to 8.0 through
8.4 Hz under Tier 2 load, while raw delivery remained 30 through 40 pps.
Eight hertz retains a 4 Hz Nyquist limit for the 0.1 through 2.0 Hz vital
bands without creating a backlog. The S3 default remains 20 Hz.
6. STFT, spectrogram gating, and learned temporal
classification remain host or iPhone responsibilities where memory,
rollback, and held out evaluation are stronger.
## Consequences
Heartbeat, respiration, and motion features receive a stable timestamped clock
instead of an accidental subset determined by C6 backlog. Operators can lower
the probe or DSP load for constrained networks without editing source. The host
still receives the higher-rate raw stream for richer Doppler processing.
This does not prove vital sign accuracy or gesture recognition. Higher temporal
fidelity only improves the representation available to a separately validated
model. The 50 Hz ceiling also means the paper's 100 Hz results are not directly
transferable.
## Acceptance test
On a physical C6, run at least five minutes after flashing. Pass when the boot
log reports the configured probe and DSP rates, the controller converges within
one hertz of the configured DSP cadence, raw callback yield remains at least 20
pps, no steady-state ENOMEM, watchdog, panic, or reboot occurs, and the fail
closed occupancy invariant remains zero contradictions for at least 30 absent
packets.
@@ -0,0 +1,336 @@
# ADR-348: Independent Rust multivariate forecasting for RuView
- **Status**: Proposed
- **Date**: 2026-09-01
- **Deciders**: ruv
- **Owners**: RuView perception, model, security, and data-governance maintainers
- **Tags**: forecasting, rust, ruvector, temporal, uncertainty, clean-room, provenance
- **Extends**: ADR-016, ADR-020, ADR-145, ADR-273, ADR-282, ADR-295,
ADR-298, ADR-302, ADR-304, ADR-317, ADR-318, ADR-319
- **Supersedes**: None
## Executive decision
RuView will develop an independently specified and independently trained Rust
multivariate forecasting subsystem. It will forecast bounded, versioned
temporal feature streams, publish calibrated quantiles and an explicit
abstention state, optionally use a split-safe RuVector analogue index, and
remain advisory until all release gates in this ADR pass.
This is not a port, compatibility layer, distillation, or behavioural clone of
Google TimesFM. The implementation API is derived from RuView requirements.
Its weights must descend from an approved random initialization and approved
training data only.
**Evidence status at proposal:** the forecasting accuracy, false-alert
reduction, calibration, CPU latency, memory use, cross-building generalization,
and operational value described below are all **UNMEASURED targets**. This ADR
authorizes implementation and evaluation; it makes no `MEASURED` capability
claim and approves no production model.
## Context
RuView currently observes present and recent RF state. Forecasting could add a
separate answer to questions such as whether a feature trajectory is consistent
with real occupancy, whether motion is likely to transition between zones, or
whether a radio link is degrading. A forecast is not a sensor observation. It
is derived evidence with uncertainty and must never overwrite the immutable
observation that produced it.
TimesFM 3 is useful research context because its public description discusses
multivariate targets, past covariates, known-future covariates, probabilistic
outputs, temporal patching, and cross-series attention. Its licensing creates
two distinct surfaces:
- the Google TimesFM source repository states that source code is Apache-2.0;
- the TimesFM 3 pretrained weights are distributed under a separate
non-commercial, non-production license that also restricts commercial
training, fine-tuning, and distillation from the model.
An Apache-compliant Rust port would be a permissible but attributed derivative
source path, subject to its exact license obligations. It would not support the
strongest independent-development claim. Using the restricted TimesFM 3 model
or its outputs to create a RuView commercial model is outside this ADR. The
project therefore chooses the stricter clean-room protocol in
[`../security/ruview-forecast-clean-room.md`](../security/ruview-forecast-clean-room.md).
Clean-room controls reduce copyright, contract, and provenance risk. They do
not establish freedom to operate against patents, clear trademarks, approve a
dataset, or replace legal review in a launch jurisdiction.
## Options considered
1. **Use the TimesFM 3 checkpoint in RuView.** Rejected for commercial and
production use under the current checkpoint license.
2. **Translate the Apache-licensed implementation into Rust.** Not selected.
This can be evaluated under a separate ADR, with attribution and license
compliance, but must not be mixed with the independent-development path.
3. **Build an independent Rust forecasting specialist for RuView.** Chosen.
This narrows model scope, keeps inference native, controls provenance, and
makes RF-specific evaluation the source of authority.
4. **Do not add forecasting.** Retained as the deployment baseline. Forecasting
must beat simpler deterministic and statistical baselines before acquiring
runtime authority.
## Requirements
| ID | Requirement | Verification authority |
|---|---|---|
| RF-001 | No TimesFM 3 code, configuration, weights, parameters, outputs, activations, or derivatives enter specification, implementation, training, evaluation feedback, or release artifacts. | Clean-room manifest, contributor attestations, repository and artifact scans |
| RF-002 | Every contributor, reference, dependency, dataset, transform, training job, and checkpoint has immutable provenance. | Signed provenance manifests and review receipts |
| RF-003 | Every training byte has documented rights for commercial machine-learning use and applicable privacy approval. | Dataset licensing gate and data-steward approval |
| RF-004 | The default runtime is native Rust, bounded, offline, deterministic for a fixed artifact/input within the declared platform class, and free of runtime model download. | Unit, property, fuzz, replay, and dependency tests |
| RF-005 | Inputs and outputs are versioned, finite, bounded, timestamped, mask-aware, and provenance-labelled. Invalid, stale, insufficient, or OOD inputs abstain. | Contract tests and malformed-input corpus |
| RF-006 | Forecasts remain derived evidence. They cannot rewrite observations, silently increase observation confidence, or be relabelled `MEASURED`. | Schema invariants and evidence-engine tests |
| RF-007 | Quantile ordering, interval calibration, missing-data behaviour, and abstention are evaluated on leakage-free site/session/device holdouts. | Frozen evaluation manifest and reproducible report |
| RF-008 | RuVector retrieval is optional, split-scoped, provenance-recorded, and evaluated against the same model without retrieval. | Retrieval isolation tests and paired ablation |
| RF-009 | RuVLLM may explain or summarize a signed forecast but cannot alter numeric forecasts, bypass policy, spend money, or trigger medical, emergency, access-control, or life-safety action. | Capability tests and downstream policy review |
| RF-010 | Training on local Linux or a hosted accelerator uses identical signed code, container, data, and configuration identities; the provider is an untrusted processor. | Training receipts and digest comparison |
| RF-011 | Accuracy, latency, memory, power, and operational claims remain `UNMEASURED` or `CLAIMED` until a named reproducer satisfies the repository evidence contract. | Documentation and release review |
| RF-012 | Rollout is reversible and cannot advance from offline evaluation to shadow or advisory operation without the mapped gates below. | Signed mode transition and rollback drill |
## Architecture boundary
The initial implementation is split across three crates:
| Crate | Owned responsibility | Dependency/runtime rule |
|---|---|---|
| `ruview-forecast-core` | Backend-neutral schemas, invariants, metrics, forecast receipts, and the `Forecaster` trait | No Burn, CUDA, WGPU, provider SDK, sensing-server, or network dependency |
| `ruview-forecast-model` | Independent Burn 0.21 patch-mixer architecture and artifact execution | CPU, CUDA, and WGPU are explicit optional features; every backend feature is off by default |
| `ruview-forecast-train` | Dataset manifests/splits, trainer, evaluator, the `ruforecast` CLI, and Linux/fal.ai training assets | Training-only authority; no production activation or sensing-server mutation |
This PR does not connect the forecaster to the sensing server. A shadow bridge
requires a follow-up change after the core contract and evidence receipt have
stabilized. Landing crates or passing synthetic tests therefore creates no
live RuView forecasting capability.
Version one activates only the exact reviewed `tiny_ci` and `large_linux`
architecture profiles. The model and training boundaries enforce checked
dimension, parameter, activation-cell, input-cell, and forward multiply-add
limits per batch. Adding or changing a profile is therefore a reviewed code and
artifact-schema decision, not untrusted request configuration.
The proposed logical pipeline is:
```text
authenticated RF observations
|
v
versioned one-second feature windows
|
+----> split-scoped RuVector analogue retrieval (optional)
|
v
independent Rust temporal model
|
v
point forecast + ordered quantiles + validity mask + abstention
|
v
signed derived-evidence record
|
+----> RuView policy/evidence engine
+----> RuVLLM explanation with no numeric mutation authority
```
The initial feature contract should favour compact one-second summaries rather
than feeding an unbounded raw CSI stream. Candidate fields include motion
energy, Doppler summary, amplitude/phase dispersion, coherence, RSSI, packet
loss, channel utilization, current detector confidence, device temperature,
and source-validity masks. Each field needs a schema version, physical unit,
aggregation rule, validity semantics, and provenance. Adding a field is a
schema change, not an implicit positional extension.
The model may independently combine normalization, temporal patch encoding,
temporal mixing, cross-stream fusion, and quantile heads. This is a functional
design space, not permission to reproduce protected source expression,
checkpoint dimensions, constants, tests, diagrams, or API choices. Exact
architecture and parameter count remain implementation decisions recorded in
the model card and training receipt.
## Forecast contract
For each request the runtime returns one typed result, including abstention and
error paths. The result must identify:
- input schema, feature-window, model, configuration, and calibration digests;
- source time range, requested horizon, cadence, target streams, and masks;
- point estimate and declared quantiles for each target/horizon cell;
- whether quantiles were corrected for crossing and which method was used;
- OOD, insufficient-context, stale-input, and non-finite dispositions;
- optional RuVector index version, neighbour identifiers, distances, and
leakage-scope receipt;
- runtime platform class, duration, and peak-memory measurement when enabled;
- evidence label `DERIVED_FORECAST`, never `MEASURED_OBSERVATION`;
- deterministic content hash and optional RVF signature.
The runtime rejects non-finite values, duplicate or non-monotonic timestamps,
unsupported schema versions, unbounded dimensions, invalid quantile requests,
future covariates without an allowed source, and payloads above configured
limits. It abstains rather than imputing an authoritative state when history or
validity coverage is below the model-card threshold.
## RuVector integration
RuVector supplies temporal memory, not ground truth. During training and
evaluation, each split has a separate index built only from records permitted
for that split. A test query may retrieve training analogues, but it may never
retrieve a window from the same subject/session/site holdout, an overlapping
target horizon, or any test record. Fitted normalizers and retrieval thresholds
come from training data only.
Every release reports three comparable rows on identical examples:
1. deterministic/statistical baseline;
2. forecasting model without retrieval;
3. forecasting model with RuVector retrieval.
This prevents retrieval from hiding a weak model or leaking future state.
## RuVLLM and action authority
RuVLLM consumes an immutable, signed forecast record and may produce an
explanation referencing its uncertainty and provenance. Its prose is not the
forecast and receives no higher evidence grade. Numeric values exposed to APIs
come from the forecasting record, not regenerated text.
No forecast or LLM explanation independently triggers a health alert, emergency
response, access decision, actuator, firmware change, model promotion, or
commercial spend. Such actions require a separate governed policy, explicit
capability, and appropriate observed evidence.
## Training and artifact boundary
Training jobs run from a pinned container or reproducible local environment.
The receipt binds the source commit, lockfile, compiler, container, datasets,
transforms, architecture, hyperparameters, seeds, hardware, provider job ID,
start/end time, and every emitted checkpoint. Hosted workers receive only the
least data and credentials necessary. Provider caches, logs, retention, and
reuse rights must pass security and data review before customer-derived data is
uploaded.
A release artifact is immutable and hash addressed. It is loaded locally after
signature, schema, size, and compatibility verification. The inference process
does not fetch models, execute embedded code, accept arbitrary operators, or
contact the training provider.
## Leakage and evaluation protocol
- Split by deployment/site first, then subject, session, device, and contiguous
time block as applicable. No overlapping raw sequence or derived window may
cross a split.
- Freeze test manifests before tuning. A failed test result does not become a
new training target; material architecture changes require a new untouched
holdout.
- Fit normalization, calibration, feature selection, thresholds, and RuVector
index parameters on training/validation data only.
- Report missingness, abstention coverage, selective risk, per-horizon errors,
interval coverage, quantile loss, and results by site/device/interference
regime. Pooled performance cannot hide a failed domain.
- Compare against last value, seasonal naive, and at least one small classical
or recurrent baseline. TimesFM 3 output is not an acceptance oracle.
- Forecast evaluation does not prove presence, pose, fall, respiration, or
medical accuracy. Any downstream claim requires its own labelled protocol.
## Acceptance gates and requirement mapping
Targets below are release criteria, not current results.
| Gate | Requirements | Pass condition | Current state |
|---|---|---|---|
| G0 independent-authoring boundary | RF-001, RF-002 | Approved source allowlist; 100% current contributor attestations; zero restricted artifacts or outputs; all similarity findings adjudicated by the clean-room custodian. | **OPEN / UNMEASURED** |
| G1 data and lineage | RF-002, RF-003, RF-010 | 100% of training/evaluation bytes resolve to approved manifests; zero unknown, noncommercial, research-only, no-ML, or no-derivatives sources; checkpoint lineage reaches approved random initialization; local/hosted receipts bind identical governed inputs. | **OPEN / UNMEASURED** |
| G2 bounded Rust contract | RF-004, RF-005, RF-006 | Unit/property/fuzz tests cover dimensions, non-finite values, timestamp order, masks, quantile order, stale/OOD/insufficient context, deterministic hashes, offline loading, evidence labels, and resource caps; 24-hour accelerated replay has no panic or unbounded growth. | **OPEN / UNMEASURED** |
| G3 leakage-free model evidence | RF-007, RF-008, RF-011 | Frozen site/session/device-disjoint report; all baseline and ablation rows present; nominal 80% interval coverage target is 75%-85%; weighted quantile loss target is at least 10% better than seasonal naive; every metric has a reproducer. | **OPEN / TARGETS UNMEASURED** |
| G4 RuView shadow value | RF-006, RF-007, RF-008, RF-009 | At least 14 days shadow-only; empty-room false-alert target is at least 50% relative reduction without more than 2 percentage points occupied-room recall loss; no safety-critical action authority; drift and abstention reported by deployment. | **OPEN / TARGETS UNMEASURED** |
| G5 deployment fitness | RF-004, RF-009, RF-010, RF-011, RF-012 | CPU p95 target is at most 1 second for 32 declared streams with peak process memory at most 4 GiB; signed model card/SBOM/provenance; privacy, security, trademark, dataset, and patent reviews; rollback drill; no open severity 1/2 issue. | **OPEN / TARGETS UNMEASURED** |
Failure at any gate keeps the model offline or shadow-only. Passing a software
gate is not real-hardware evidence and does not convert an accuracy target into
a measurement.
## Rollout and rollback
The only permitted progression is:
```text
OFF -> OFFLINE_EVAL -> SHADOW -> ADVISORY
```
This ADR does not authorize autonomous action. Each transition records actor,
old/new mode, artifact and configuration digests, evidence report, and reason.
Regression, provenance failure, calibration drift, security incident, or
licensing uncertainty returns immediately to `OFF` or `SHADOW` while observed
RuView sensing continues unchanged.
Rollback deactivates the forecast artifact atomically, clears ephemeral model
and retrieval state, preserves signed aggregate evidence and incident records,
and never downgrades or rewrites the raw observation stream.
## Security and privacy
Forecasting can infer routines from occupancy and movement even without raw
CSI. Feature windows, neighbour identifiers, forecasts, and explanations are
therefore deployment data subject to purpose limitation, tenant isolation,
retention, deletion, access control, and audit.
Model artifacts, feature schemas, calibration, indexes, and manifests are
untrusted until their signature and digest verify. Cardinality, dimensions,
horizon, context, allocations, execution time, and concurrent requests are
bounded. Metrics use allowlisted aggregate values and exclude raw CSI, precise
room coordinates, persistent person identifiers, and unrestricted feature
payloads.
The detailed authoring, data, AI-tool, incident, trademark, and patent controls
are normative in
[`../security/ruview-forecast-clean-room.md`](../security/ruview-forecast-clean-room.md).
## Consequences
### Positive
- RuView gains a provider-neutral predictive evidence primitive with explicit
uncertainty and abstention.
- Rust-native inference can be evaluated on CPU without requiring a Python or
cloud runtime in production.
- RuVector retrieval becomes a measurable ablation rather than an implicit
memory claim.
- Signed provenance supports reproducible Linux and hosted training.
### Negative
- Independent training needs substantial diverse, correctly licensed temporal
data and untouched deployment holdouts.
- Strict source separation and manifests increase contributor and review cost.
- A plausible forecast can make weak sensing look more authoritative unless
downstream evidence labels remain intact.
- Clean-room development does not remove patent, trademark, privacy, or dataset
risk.
### Neutral
- This ADR does not repair an inaccurate upstream presence or pose estimator.
- It does not approve TimesFM 3 for RuView use.
- It does not select a final model size, training budget, or hosted provider.
- No production checkpoint or measured benchmark is created by this decision.
## References
- [TimesFM 3 public research article](https://research.google/blog/timesfm-3-a-zero-shot-foundation-model-for-multivariate-forecasting/)
- [Google TimesFM repository and source-license notice](https://github.com/google-research/timesfm)
- [TimesFM 3 checkpoint license](https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE)
- [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
- [17 U.S.C. section 102](https://www.copyright.gov/title17/92chap1.html)
- [USPTO Patent Public Search](https://www.uspto.gov/patents/search/patent-public-search)
- [USPTO comprehensive trademark clearance](https://www.uspto.gov/trademarks/search/comprehensive-clearance-search-similar-trademarks)
- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md)
- [RuView Forecast model-card template](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md)
- [ADR-145](./ADR-145-ablation-eval-harness-privacy-leakage.md)
- [ADR-282](./ADR-282-ruview-ecosystem-positioning.md)
- [ADR-295](./ADR-295-source-provenance-state-machine.md)
- [ADR-298](./ADR-298-model-release-sanity-gates.md)
- [ADR-302](./ADR-302-out-of-distribution-detection.md)
- [ADR-304](./ADR-304-evidence-engine.md)
- [ADR-317](./ADR-317-benchmark-multi-domain-scorecard.md)
- [ADR-318](./ADR-318-capability-certificates.md)
- [ADR-319](./ADR-319-witness-chain.md)
@@ -0,0 +1,231 @@
# ADR-349: Governed local and fal.ai forecast training
- **Status**: Proposed
- **Date**: 2026-09-01
- **Deciders**: ruv
- **Owners**: RuView forecast training, security, and release maintainers
- **Tags**: forecasting, training, fal, rust, receipts, idempotency, cost-control
- **Parent**: ADR-348
- **Extends**: ADR-010, ADR-145, ADR-298, ADR-319, ADR-348
- **Supersedes**: None
## Decision
RuView Forecast will use one Rust training engine behind two deliberately
different request boundaries. The local Linux request may bind a governed,
hash-addressed RuView JSONL shard. Hosted v1 accepts only a bounded,
deterministic synthetic generator recipe. It cannot serialize a local
`TrainSpec`, `DataPolicy`, path, dataset bytes, tenant/account/workspace/site/
device/session identity, split receipt, or RuVector namespace.
The fal.ai path is a bounded execution adapter, not a remote shell. It may
train the fixed synthetic profile and export fixed artifact kinds, but it may
not choose an arbitrary command, executable, container, URL, environment
variable, output path, or release action. A later real-data hosted path needs a
new accepted ADR, provider/privacy review, and an operator-signed export grant;
it is intentionally not implemented here.
Every hosted result is untrusted and quarantined until it is exported with a
complete receipt, downloaded through the authenticated provider channel,
re-hashed locally, scanned, and bound to the original request. Only the local
release boundary may sign or activate it. Signing keys never enter fal.ai.
This ADR delegates dataset rights, clean-room evidence, model-card, patent,
trademark, and production gates to [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md)
and the [clean-room protocol](../security/ruview-forecast-clean-room.md).
**Evidence status:** typed local and hosted configuration, root-confined file
verification, atomic fixed-kind artifact publication, and cooperative
cancellation source surfaces exist. End-to-end local training and mock hosted
transport are software-testable in this change. Real fal.ai execution,
provider cancellation, cost reconciliation, quarantine promotion, and local
signature verification remain **UNMEASURED and unapproved** until their named
acceptance evidence exists.
## Context
The same training engine needs to run on a 128 GiB Linux workstation and on an
optional hosted accelerator without creating two architecture lineages. A
generic remote-command interface would allow request data to become executable
authority, make cost difficult to bound, and weaken cancellation. Sending a
general local request to fal would also expose governance and customer
identifiers that the hosted smoke path does not need.
The initial `ruview-forecast-train` crate already separates three relevant
modules:
| Module | Current contract |
|---|---|
| [`config.rs`](../../v2/crates/ruview-forecast-train/src/config.rs) | `TrainingRequest`, `ValidatedTrainingRequest`, bounded `JobId`, root-relative dataset path, exact size/SHA-256, named model profiles, typed CPU/CUDA choice, and bounded optimizer values; unknown fields are denied |
| [`artifact.rs`](../../v2/crates/ruview-forecast-train/src/artifact.rs) | verified open dataset handle, root confinement, fixed `Model`/`Manifest`/`Receipt`/`Checkpoint` kinds, atomic writes, content descriptors, and conflict-on-different-bytes idempotency |
| [`cancel.rs`](../../v2/crates/ruview-forecast-train/src/cancel.rs) | `Cancellation`, `CancelToken`, and cooperative cancellation checks for batch/checkpoint boundaries |
The training crate features keep `cli`, `server`, `fal-client`, `cpu`, and
`cuda` explicit, with default features empty. WGPU remains a model-crate
inference backend and is not advertised as a training runner. The `ruforecast`
binary requires the `cli` feature. Source presence is not execution evidence.
## Typed execution contracts
The non-serializable trusted `TrainingRequest` is built locally from a
size-bounded `LocalTrainingRequestWire`. It contains:
- stable job identity;
- a constructor-validated local split/horizon/normalization `TrainSpec`;
- either a root-relative dataset identity with exact byte count and SHA-256,
or a deterministic synthetic recipe;
- one allowlisted model capacity profile and compiled-in local backend;
- bounded optimizer, checkpoint cadence, seed, memory, step, time, checkpoint,
and artifact budgets.
Local JSON and TOML deny unknown fields. Dataset paths resolve below an
operator-configured root, and the exact regular file remains open after
size/hash verification to avoid replacement between validation and use. JSONL
windows are decoded incrementally with per-line and per-window caps rather
than loading the full shard into memory.
The distinct `HostedSyntheticRequestV1` contains only a schema version,
idempotency/request digests, a fixed model profile, deterministic generator
parameters and seed, bounded optimizer/resource caps, and public build
identities. Its constructor needs a non-serializable core governance authority
proving that the synthetic recipe is the approved hosted source. Hosted v1 has
no external dataset field.
Neither request contains a shell string, argument vector, executable path,
container/image selector, package installer, arbitrary URL, callback URL,
environment map, secret, source fragment, or unrestricted output name.
The local client maps `HostedSyntheticRequestV1` to one allowlisted,
process-configured fal app and pre-deployed content-addressed worker image. The
caller cannot override the app, machine, worker entry point, or artifact
destination. The endpoint returns typed status or artifact descriptors only.
Provider paths are derived from validated job identity and fixed artifact
kinds, then downloaded through the authenticated fal Platform Files API; they
are not accepted as remote URLs or local paths.
The deploy wrapper builds from an allowlisted `git archive`, selects the exact
`fal_app.py::run_server` symbol, and passes `--auth private` for both `fal run`
and `fal deploy`; ephemeral Fal runs otherwise default to public. Hosted v1
retains only bounded synthetic request/result metadata so its typed queue result
can be reconciled. It sets `X-Fal-No-Retry: 1` and a bounded timeout rather than
claiming that `X-Fal-Store-IO: 0` or an unverified lifecycle header protects
the result. A live unauthenticated 401/403 probe and provider retention/deletion
reconciliation remain blocking operational evidence.
## Idempotency and cost authority
The local effective idempotency identity is:
```text
job_id + canonical request digest + source commit + lockfile digest
+ container digest + dataset/split digest + initial-weight digest
```
Hosted v1 replaces `dataset/split digest` with the canonical synthetic-recipe
digest. No local governance identifier is part of, or derivable from, the
hosted payload.
Repeating an identical completed request returns the existing verified receipt
and descriptors. Reusing `job_id` with any different governed input fails as a
conflict. A retry after an ambiguous network failure must first query the same
provider job; it must not silently create a second billable run.
Before hosted submission, the caller sets explicit maximum steps, wall-clock
time, memory, checkpoint count, export bytes, and billable units. Submission
also requires an explicit local spend-approval record bound to the request
digest and maximum units. The adapter rejects a request when its configured
cap exceeds that approval. The final receipt records estimated and actual
provider units/cost when the provider supplies them, the price source/time,
and whether the amount is final or provider-reported. A provider estimate is
`CLAIMED`, never `MEASURED`, until reconciled against a bill.
No automatic retry may increase the approved budget. Budget changes create a
new signed authorization record. The initial wire carries operator ceilings
for wall time, billable seconds, and micro-USD, but it does not yet bind a
provider price quote or reconcile a final provider ledger. Monetary enforcement
therefore remains an operator/Fal-account control and the production hosted
spend gate stays open.
## Cancellation and checkpoints
Cancellation is cooperative and idempotent:
1. Local signal or authenticated hosted cancel marks one `job_id` cancelled.
2. Training checks `Cancellation` at every batch boundary and before/after each
checkpoint/export boundary.
3. A cancellation checkpoint is committed atomically when safe and within the
artifact budget; a partial file never becomes a valid artifact.
4. The terminal receipt records `cancelled`, the last completed epoch/step,
checkpoint digest, provider state, elapsed resource units, and final known
cost.
5. Repeating cancel returns the same terminal state.
Cancellation does not promise immediate GPU termination. Cancellation latency,
checkpoint durability, and residual provider billing are **UNMEASURED** until a
real fal.ai run supplies a receipt. A timeout or lost cancellation response
keeps the job and any output quarantined.
## Export, quarantine, and local signing
The worker may emit only the fixed artifact set from `ArtifactKind`:
| Kind | Required purpose |
|---|---|
| `Model` | Burn model record bytes |
| `Manifest` | architecture, data/split, seed, build, and clean-room identity |
| `Receipt` | request, environment, metrics, cost, status, and export lineage |
| `Checkpoint` | final or cancellation-time model weights; v1 does not claim optimizer/cursor resume |
Every descriptor includes kind, size, and SHA-256. Export acceptance requires
all mandatory descriptors, bounded lengths, exact hashes, the expected job and
request digest, no duplicate kind, and a terminal status consistent with the
artifact set.
Downloaded files enter a non-executable quarantine outside the model search
path. Local verification repeats envelope/schema/size/hash checks, SBOM and
malware/policy scans, clean-room declarations, dataset/split lineage, metric
labels, and parent-checkpoint validation. No provider output may update a
symlink, current-model pointer, RuVector index, sensing server, or release tag.
After quarantine passes, an authorized local signer binds the exact export
receipt and artifact digests to an `ArtifactReceipt` and release manifest. The
signature records algorithm, public key ID, signer capability, and time. Fal.ai
receives no private signing material and cannot promote its own output. The
current core supplies canonical digests and receipts, not a complete release
signature implementation; signature-backed activation remains open.
## Requirements and acceptance
| ID | Requirement | ADR-348 gate | Acceptance evidence | Current state |
|---|---|---|---|---|
| FT-001 | Local and hosted adapters invoke the same model/trainer implementation while using intentionally separate local-data and hosted-synthetic request schemas. | G1 | Shared-engine tests plus hosted DTO exclusion and recipe-digest binding tests | **OPEN / UNMEASURED** |
| FT-002 | No request field or endpoint can select arbitrary code, command, image, URL, environment, or output path. | G2 | Schema negatives, endpoint capability test, dependency review | **OPEN / UNMEASURED** |
| FT-003 | Job/request idempotency prevents duplicate execution and conflicts on changed governed input. | G1, G5 | Concurrent/retry/lost-response tests with provider job query | **OPEN / UNMEASURED** |
| FT-004 | Hosted execution has explicit non-escalating resource and monetary caps with estimated/actual cost receipts. | G5 | Over-budget rejection and reconciled provider-bill fixture plus real-run receipt | **OPEN / UNMEASURED** |
| FT-005 | Cancellation is authenticated, cooperative, idempotent, checkpoint-safe, and terminally receipted. | G2, G5 | Local property tests and a real hosted cancellation drill | **OPEN / UNMEASURED** |
| FT-006 | Export is fixed-kind, bounded, complete, hash-verified, and bound to request/job/environment. | G1, G2 | Missing/duplicate/truncated/tampered export tests | **OPEN / UNMEASURED** |
| FT-007 | Hosted outputs remain quarantined until local verification and local-only signing succeed. | G1, G5 | Promotion-denial tests, signing-key absence check, rollback drill | **OPEN / UNMEASURED** |
| FT-008 | Hosted v1 receives synthetic inputs only; provider credentials, logs, retention, region, output reuse, and deletion satisfy ADR-348 privacy/security approval before any real run. | G1, G5 | Hosted DTO exclusion tests, provider review, and deletion/export receipts | **OPEN / UNMEASURED** |
No successful unit test closes a hosted gate. G5 requires one bounded real
fal.ai run, one cancellation drill, one ambiguous-retry/idempotency drill, cost
reconciliation, quarantine rejection of a tampered export, and local signature
verification over the accepted artifact. Until then fal.ai support is a typed
software surface, not an operational capability claim.
## Consequences
The design gives local and hosted training one auditable lineage and keeps
provider output below local release authority. It adds receipt, budget,
quarantine, and signing work, and cooperative cancellation may still incur
provider cost. If a hosted provider cannot support bounded idempotent status,
export, cancellation, and deletion, the Linux path remains the only approved
training environment.
## References
- [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md)
- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md)
- [`ruview-forecast-train` manifest](../../v2/crates/ruview-forecast-train/Cargo.toml)
- [`ruview-forecast-core` receipts](../../v2/crates/ruview-forecast-core/src/receipt.rs)
- [`ruview-forecast-model` public artifact boundary](../../v2/crates/ruview-forecast-model/src/lib.rs)
@@ -0,0 +1,217 @@
# ADR-350: RuVector predictive memory and RuVLLM authority boundary
- **Status**: Proposed
- **Date**: 2026-09-01
- **Deciders**: ruv
- **Owners**: RuView forecast, RuVector, evidence, and RuVLLM integration maintainers
- **Tags**: forecasting, ruvector, ruvllm, retrieval, memory, receipts, authority
- **Parent**: ADR-348
- **Extends**: ADR-004, ADR-010, ADR-016, ADR-145, ADR-261, ADR-295,
ADR-304, ADR-319, ADR-348
- **Supersedes**: None
## Decision
RuVector may augment RuView Forecast by retrieving analogous historical
feature/forecast states from a tenant- and split-scoped predictive-memory
index. Retrieval is optional context, never ground truth. Every release must
report the same frozen examples with retrieval disabled and enabled so its
incremental value and leakage risk remain visible.
Each forecast is published in an immutable, locally signed envelope binding the
model artifact, request, output, source evidence, and retrieval receipt. RuVLLM
may read that envelope to explain uncertainty and analogous history. It may not
rewrite numeric forecasts, create a stronger evidence class, select a model,
promote an artifact, spend money, invoke an actuator, or make medical,
emergency, access-control, or life-safety decisions.
This child ADR delegates forecasting, clean-room, data, and general production
gates to [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md). It
does not authorize a sensing-server bridge.
**Evidence status:** canonical forecast/receipt types and an indexable latent
state exist in source. A RuVector adapter, retrieval receipt, signature wrapper,
outcome reconciler, RuVLLM explanation adapter, paired retrieval evaluation,
latency, storage cost, accuracy lift, and operational value are all
**UNMEASURED and unapproved**.
## Existing implementation boundary
The current crates establish only the preconditions:
| Module | Implemented surface used by this ADR |
|---|---|
| [`ruview-forecast-core/src/forecast.rs`](../../v2/crates/ruview-forecast-core/src/forecast.rs) | validated `ForecastRequest`, backend-neutral `Forecaster`, ordered finite `Forecast`, canonical output digest, and receipt verification |
| [`ruview-forecast-core/src/receipt.rs`](../../v2/crates/ruview-forecast-core/src/receipt.rs) | `SourceState`, `ArtifactReceipt`, and `ForecastReceipt`; derived forecasts cannot retain `MEASURED` merely because measured input existed |
| [`ruview-forecast-core/src/series.rs`](../../v2/crates/ruview-forecast-core/src/series.rs) | bounded feature schema, masked time series, canonical series digest, and training-only scaler fit |
| [`ruview-forecast-model/src/network.rs`](../../v2/crates/ruview-forecast-model/src/network.rs) | `ForecastModelOutput.state` with shape `[batch, variates, d_model]`, intended as a bounded representation candidate for indexing |
`ForecastReceipt` is content-addressed but not itself a digital signature.
`ForecastModelOutput.state` is indexable but not automatically safe, private,
stable across model versions, or useful. Those distinctions remain release
gates.
## Predictive-memory record
RuVector stores a versioned `PredictiveMemoryRecord` containing:
- tenant and index namespace;
- model/artifact/config/feature-schema digests;
- source series and forecast-request digests;
- split, site, subject-class, session, device, calibration, and bounded time
scope using pseudonymous identifiers;
- bounded latent-state or engineered-feature digest plus the approved vector;
- forecast envelope digest and horizon;
- observed validity mask and evidence class;
- optional reconciled outcome digest added only after the forecast horizon;
- retention/deletion class and creation/expiry time.
Raw CSI, unrestricted feature windows, precise room coordinates, persistent
person identity, RuVLLM prompts, and explanation prose are excluded by default.
The vector is still potentially sensitive because it can encode routines or
location. Tenant isolation, encryption, access control, retention, deletion,
and membership-inference review apply.
An outcome is appended through a new immutable record linked to the original;
the historical forecast is never rewritten. Outcome reconciliation can measure
forecast quality but cannot retroactively turn the forecast into a measured
observation.
## Split-scoped analogue retrieval
Every index is bound to an immutable corpus manifest, model version, feature
schema, preprocessing configuration, and split policy. These rules are
mandatory:
1. Tenants never share an index or query result without a separately authorized
privacy-preserving federation protocol.
2. Training, validation, calibration, and test records have distinct
namespaces. A test query may retrieve approved training analogues only; it
may not retrieve test examples or validation/calibration records used to set
thresholds.
3. The same site, subject, session, device, calibration episode, overlapping
context, or overlapping target horizon is excluded when that dimension is a
holdout.
4. Index construction, distance metric, normalization, filter policy, `k`, and
score threshold are fitted on training data and frozen before test.
5. Missing, stale, mismatched, unauthorized, or unverifiable indexes disable
retrieval and return the no-retrieval forecast or abstention according to
the model card. They never trigger an implicit global-index fallback.
6. Every query returns a bounded `RetrievalReceipt` even when zero neighbours
qualify.
The retrieval receipt binds query digest, index/corpus/policy digests,
namespace, exclusion filter, neighbour record IDs and distances, `k`, latency,
and disposition. It excludes raw neighbour payloads from ordinary logs.
## Paired ablation
Retrieval has no deployment authority without a paired evaluation on identical
frozen requests:
| Row | Model and examples | Retrieval |
|---|---|---|
| A | exact candidate artifact and frozen examples | disabled |
| B | exact candidate artifact and frozen examples | enabled with frozen index/policy |
The report includes weighted quantile loss, interval coverage/width,
abstention, per-horizon error, per-site/device/interference slices, retrieval
hit/filtered/empty rates, latency, and memory/storage overhead. It reports both
aggregate and paired deltas with uncertainty. A gain on pooled error cannot
hide a failed deployment domain, calibration regression, or neighbour leakage.
The no-retrieval row remains a supported fallback. Retrieval is removed when
its lower confidence bound does not show useful improvement, when it weakens
calibration beyond the ADR-348 gate, or when its privacy/latency/storage cost
exceeds its measured value. No uplift is claimed today.
## Immutable signed forecast envelope
The signed envelope covers:
```text
envelope schema/version
+ ArtifactReceipt canonical digest
+ ForecastRequest canonical digest
+ Forecast payload/output digest
+ ForecastReceipt canonical digest
+ RetrievalReceipt digest or explicit retrieval-disabled marker
+ policy/calibration/index digests
+ creation/expiry time and tenant namespace
```
Signing occurs at the trusted local boundary after artifact and retrieval
verification. The wrapper records algorithm, public key ID, signer capability,
and signature. Private keys never enter model artifacts, RuVector, RuVLLM, or a
hosted training worker.
Consumers verify signature, expiry, tenant, schemas, every nested digest, and
the `Forecast::verify_receipt` invariant before using numeric values. Any
failure yields unavailable/abstain. Unsigned content-addressed receipts remain
useful for local tests but are not described as signed and cannot cross the
production trust boundary.
## RuVLLM explanation boundary
RuVLLM receives a read-only projection of the verified envelope:
- exact point and quantile values as structured fields;
- units, horizons, calibration/OOD/abstention state;
- bounded analogue summaries and retrieval receipt references;
- evidence labels, model/version, and envelope digest;
- approved explanation policy and audience.
Explanation prose is stored separately and linked to the envelope digest. It
is `CLAIMED_EXPLANATION`, not a replacement forecast. Numeric API fields are
copied from the verified envelope after generation, never parsed back from LLM
text. If prose contradicts a structured number, evidence state, unit, or
disposition, the response fails validation and the numeric envelope remains
authoritative.
The explanation capability exposes no tools for artifact activation, model
promotion, retraining, spending, messaging, emergency dispatch, access control,
or actuation. A separate downstream policy may consume a forecast only under
its own ADR, capabilities, approvals, and observed-evidence requirements. The
LLM cannot grant itself that authority or lower an approval threshold.
## Requirements and acceptance
| ID | Requirement | ADR-348 gate | Acceptance evidence | Current state |
|---|---|---|---|---|
| PM-001 | Every predictive-memory query is tenant-, model-, schema-, corpus-, split-, and time-scope bound. | G3, G5 | Cross-tenant/split/version negative tests and signed index manifest | **OPEN / UNMEASURED** |
| PM-002 | Holdout identities, overlapping contexts, and target horizons cannot appear as neighbours. | G3 | Property tests plus frozen leakage report | **OPEN / UNMEASURED** |
| PM-003 | Every query, including disabled/empty/error, produces a bounded retrieval receipt. | G2, G3 | Receipt round-trip, tamper, bound, and zero-result tests | **OPEN / UNMEASURED** |
| PM-004 | Retrieval-disabled and retrieval-enabled rows use identical artifact/examples and report paired metrics and overhead. | G3 | Frozen paired ablation with reproducer | **OPEN / UNMEASURED** |
| PM-005 | Forecast envelope signatures bind all nested forecast/retrieval identities and fail closed on mutation, expiry, tenant, or key error. | G2, G5 | Signature/tamper/replay/cross-tenant tests and local signing receipt | **OPEN / UNMEASURED** |
| PM-006 | Derived forecast and explanation can never become `MEASURED_OBSERVATION` or exceed source evidence. | G2, G4 | Evidence-monotonicity schema/property tests | **OPEN / UNMEASURED** |
| PM-007 | RuVLLM cannot mutate structured numbers; contradictions fail validation and explanations remain separately labelled. | G4 | Numeric/unit/evidence mutation corpus and fail-closed integration tests | **OPEN / UNMEASURED** |
| PM-008 | RuVLLM explanation has no spending, promotion, messaging, safety, access-control, or actuator capability. | G4, G5 | Default-deny capability and attempted-escalation tests | **OPEN / UNMEASURED** |
| PM-009 | Vector/explanation privacy, retention, deletion, extraction, and membership risks are approved and operationally tested. | G5 | Privacy review, tenant deletion drill, and access audit | **OPEN / UNMEASURED** |
G3 additionally requires the paired retrieval ablation to satisfy ADR-348's
forecast and calibration gates. G4 requires shadow evidence that retrieval and
explanation improve operator comprehension or forecast value without changing
numeric/action authority. G5 requires signed-envelope verification, rollback,
tenant deletion, and key-rotation drills. None has passed.
## Consequences
RuVector can evolve from retrospective search into outcome-linked predictive
memory while preserving a measurable no-retrieval baseline. RuVLLM can make
forecasts understandable without becoming the numeric or action authority. The
cost is additional index isolation, storage, signature, deletion, evaluation,
and policy complexity. If paired evidence does not justify that cost, the
correct release keeps retrieval and explanation disabled.
## References
- [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md)
- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md)
- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md)
- [RuView Forecast model-card template](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md)
- [RuForecast benchmark protocol](../benchmarks/ruforecast.md)
- [ADR-016](./ADR-016-ruvector-integration.md)
- [ADR-145](./ADR-145-ablation-eval-harness-privacy-leakage.md)
- [ADR-261](./ADR-261-ruvector-graph-ann-index.md)
- [ADR-304](./ADR-304-evidence-engine.md)
- [ADR-319](./ADR-319-witness-chain.md)
+162
View File
@@ -0,0 +1,162 @@
# ADR-353: fal.ai synthetic visual teacher LoRA
- **Status**: Accepted
- **Date**: 2026-09-02
- **Deciders**: ruv
- **Owners**: RuView visualization / neural-styling maintainers
- **Tags**: fal, lora, synthetic-visualization, display-only, cost-control
- **Parent**: None
- **Extends**: None
- **Supersedes**: None
## Decision
RuView will use one real, fal.ai-trained LoRA fine-tune (a "synthetic visual
teacher") to steer fal.ai image/video generation toward a consistent
"RuView-style" room visualization — glowing pose overlays, WiFi signal arcs,
vitals readout panels, dark tech aesthetic — instead of relying on ad hoc
prompt engineering against a stock model.
**The architecture boundary is non-negotiable:**
> The implementation boundary is now clear: fal.ai will train an optional
> synthetic visual teacher, while RuView's measured RF, pose, vitals,
> identity, and confidence remain local and authoritative.
Concretely:
- The trained model produced here is ONLY ever allowed to generate
supplementary synthetic visual/pixel output for display or broadcast.
- It must NEVER be a source of, or influence, any measured/derived value: RF/
CSI measurements, pose estimates, vitals (heart rate/breathing rate),
identity, or confidence/quality scores. Those stay local, deterministic,
and authoritative — the existing `wifi-densepose-engine` /
`ruview-twin` / `ruview-witness` stack — completely untouched by this work.
- Any output this model produces must be clearly framed as SYNTHETIC per this
repo's rule: "Never present WiFi sensing as camera-grade."
- This model has no read or write path into the RF/pose/vitals/identity
pipeline. It consumes only a text prompt (and optionally a reference
image) and returns pixels for display.
**Evidence status:** the training data extraction, training submission
script, and validation script are real and committed. The training run
completed and validation succeeded — trained model id, actual spend, and the
validation sample are recorded in "Result" below. Actual-vs-billed cost
reconciliation against the fal.ai account dashboard remains UNMEASURED (the
figure below is a documented-pricing estimate).
## Context
A concurrent workstream (`ruview-fal-room-viz` / `ruview-room-viz-h3-upgrade`)
generated real fal.ai room-visualization videos across several style presets
(architectural, abstract, branded, and an "h3" refresh: architectural,
cinematic, minimal, abstract) using stock fal.ai video models driven by
prompt engineering alone. A separate concurrently-running agent
("realtime-dual-renderer") is building a neural-styling layer that needs a
*specific* trained model id to call, rather than continuing to rely on prompt
engineering against a generic model.
This ADR's job is narrow: turn the real captured style presets into a real
trained fal.ai LoRA, verify it actually works, and hand off the model id. It
does not implement the renderer integration — that is
`realtime-dual-renderer`'s scope — and it does not touch the measured
RF/pose/vitals/identity/confidence pipeline in any way.
## Training data
Source: the real fal.ai-generated room-visualization MP4s already captured
this session (read from `/tmp/room-viz-*/room-viz.mp4` on `ruvultra`, one per
style preset — not committed to the repo; the repo has no camera or person
data in these clips, they are synthetic renders). `scripts/fal-visual-teacher/extract_frames.py`
extracts one JPEG frame every 1.5 seconds from each of the 7 real style-preset
videos (architectural, abstract, branded, h3-architectural, h3-cinematic,
h3-minimal, h3-abstract), producing 27 frames total, each paired with a
caption `.txt` describing the shared RuView visual motifs. Frames and the
training zip are gitignored (`scripts/fal-visual-teacher/training-data/`,
`training-data.zip`) — they are regenerable from the source MP4s and are not
committed, consistent with the repo's no-generated-artifacts rule.
## Training endpoint and pricing (verified 2026-09-02)
- **Endpoint**: `fal-ai/flux-lora-fast-training` — a real, currently-listed
fal.ai LoRA training endpoint for FLUX.1, verified against
`https://fal.ai/models/fal-ai/flux-lora-fast-training/api`.
- **Pricing**: $2 per training run at the default 1000 steps, scaling
linearly with steps (verified against fal.ai's own pricing text on the
model page).
- **Mode**: submitted with `is_style: true` (style LoRA, not
subject/character LoRA) and explicit per-frame caption files, so fal.ai's
auto-captioning/segmentation is skipped in favor of our own captions.
- **Inference endpoint** (validation only): `fal-ai/flux-lora`, $0.035 per
megapixel, accepting `loras: [{ path, scale }]` pointing at the trained
`diffusers_lora_file` URL.
## Budget
- **Original cap**: $15 total (this task's cap only; not shared with other
concurrent tasks' budgets this session).
- **Raised cap**: the user explicitly raised this task's cap to **$100** on
2026-09-02, mid-run, as real headroom for a larger/better run or retries —
not an instruction to spend all of it.
- **Planned spend**: ~$2 for the training run (1000 steps, default) + ~$0.04
per validation inference image.
- **Actual spend**: ~$2.04 (one 1000-step training run at $2/run + one
landscape_16_9 validation image at $0.035/megapixel, rounded up to 1
megapixel). This is a documented-pricing estimate (`CLAIMED`), not yet
reconciled against the fal.ai account billing dashboard (`MEASURED`).
Comfortably under both the original $15 cap and the raised $100 cap.
- **Why no larger follow-up run**: the first validation image already showed
a clean, on-style result (glowing pose overlay, WiFi arcs, vitals panels,
dark aesthetic, all present and legible) on the first attempt. Spending
further into the raised budget for marginal improvement was judged not
worth it against a result that already meets the bar; the fallback path in
"Consequences" was not needed.
## Result
- **Trained model id / LoRA weights URL**:
`https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors`
(131 MB `diffusers_lora_file`, paired `config.json` at
`https://v3b.fal.media/files/b/0aa8c60d/-GU6dd_2ZFqq18I0CuPOB_config.json`).
Use with `fal-ai/flux-lora` (or any fal.ai FLUX.1 endpoint accepting a
`loras: [{ path, scale }]` array) — trigger word `ruviewstyle`. Full
training record: `scripts/fal-visual-teacher/output/training-result.json`
(gitignored — regenerate by re-running `train_lora.py`, or copy the URL
above).
- **Training request id**: `01a060b2-c940-7972-abf9-ea48847eb2ae` (fal.ai
`fal-ai/flux-lora-fast-training`, 1000 steps, `is_style: true`, 27 training
images, completed in 376s on an H100/H200 worker).
- **Actual spend**: ~$2.04 (see Budget above).
- **Validation sample**: `assets/fal-visual-teacher-sample.png` (+ sibling
`assets/fal-visual-teacher-sample.json` with the exact prompt, LoRA scale
1.0, and source fal.ai URL) — a real `fal-ai/flux-lora` inference call
using the trained LoRA, generated 2026-09-02. The image shows a glowing
cyan pose skeleton, WiFi signal arcs, vitals-style readout panels, and a
HUD-style TV overlay in a dark living room — confirming the LoRA learned
and reproduces the intended RuView visual motifs.
- **Consumer**: `realtime-dual-renderer` (separate worktree
`~/projects/ruview-worktrees/realtime-render`) — notified directly with the
model id above.
## Consequences
A real trained LoRA gives the display layer a specific, repeatable style
target instead of prompt engineering against a stock model, at a bounded and
small real cost ($2 training + cents per generated image). Because the model
only ever produces pixels for a prompt, and has no schema path into any
measured/derived RuView value, it cannot silently become a second source of
truth for pose, vitals, identity, or confidence — that boundary is structural
(no shared types, no shared data path), not just documented convention.
If the trained LoRA does not meaningfully improve style consistency over the
stock model (a genuine risk with 27 training images and default steps), the
fallback is to keep using prompt engineering against the stock model and note
that outcome honestly here rather than claim a training win that isn't real.
## References
- `scripts/fal-visual-teacher/extract_frames.py` — real frame extraction
- `scripts/fal-visual-teacher/train_lora.py` — real training submission
- `scripts/fal-visual-teacher/validate.py` — real validation inference
- [fal.ai Flux LoRA Fast Training](https://fal.ai/models/fal-ai/flux-lora-fast-training)
- [fal.ai FLUX.1 with LoRAs (inference)](https://fal.ai/models/fal-ai/flux-lora)
@@ -0,0 +1,89 @@
# ADR-354: fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule
- **Status**: Accepted
- **Date**: 2026-09-02
- **Deciders**: ruv
- **Owners**: RuView forecast training, security, and release maintainers
- **Tags**: forecasting, training, fal, governance, submodule, cost-control
- **Parent**: ADR-349
- **Extends**: ADR-348, ADR-349
- **Supersedes**: None
## Decision
ADR-349 specified two governance controls for RuView Forecast's fal.ai
hosted path: operator-verified dataset/schema/policy/recipe authorization,
and an explicit local spend-approval record bound to the request digest and
maximum units. A 9-agent code review of the training stack merged as PR
#1766 found that neither control was actually wired into the code path that
builds a submittable, fal-destined training contract — `FalGovernanceVerifier`
existed with zero callers outside its own tests, and no spend-approval type
existed anywhere in the codebase.
That code no longer lives directly in this repository. Between the review
and this fix, `v2/crates/ruview-forecast-{core,model,train}` was extracted
into its own repository, `github.com/ruvnet/RuForecast`, and is now vendored
back into `v2/crates/ruforecast` as a git submodule (crates renamed to
`ruforecast-{core,model,train}`). This ADR records two things at the RuView
level:
1. **The governance and spend-approval gaps are fixed**, in
`ruvnet/RuForecast` PR #2 (branch `fix/governance-and-spend-enforcement`).
The full technical decision record — what changed, why, and the
requirements/acceptance table — lives in that repository's own
[ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md),
which this ADR defers to rather than duplicating. In summary: a
fal-destined `TrainSpec` can no longer be constructed without a real,
operator-signed, single-use-verified governance receipt; a hosted
reservation can no longer be built without a real, operator-signed spend
approval bound to the exact request digest and cost ceiling; and several
related correctness/resilience gaps on the same trust boundary
(unauthenticated Direct Server webhook, no execution timeout, mismatched
local/hosted artifact budgets, cancellation permanently wedging a job)
were fixed in the same pass.
2. **The submodule pointer in this repository is bumped** to the commit
containing that fix, once `ruvnet/RuForecast`'s own CI is green on that
PR and it merges. This RuView-side change is deliberately small: a
pointer bump plus this ADR, not a re-implementation or re-review of code
that now lives, is tested, and is reviewed in its own repository.
## Context
RuView's own copies of ADR-348 and ADR-349 predate the extraction and still
describe the crates as if they lived directly under `v2/crates/`. They are
left as-is here (not corrected path-by-path) since the canonical, actively
maintained copies of the forecast-specific ADRs now live in
`ruvnet/RuForecast`'s own `docs/adr/`, which was seeded with copies of
ADR-348/349 at extraction time and now also carries ADR-350. Consult that
repository for the forecast subsystem's own decision history going forward;
this repository's ADR series should record RuView-level integration
decisions about the vendored submodule (like this one), not attempt to
re-host the submodule's internal history.
This repo's CLAUDE.md requires an ADR for load-bearing upstream changes.
The governance-enforcement fix is load-bearing for RuView even though none
of its source lines are: it changes what RuView's vendored forecast
subsystem actually requires before it will reach real, billable fal.ai
infrastructure on behalf of a RuView deployment.
## Consequences
Once the submodule pointer here is bumped, any RuView-side code, CLI
wrapper, or documentation that assumes the old, unenforced fal.ai
submission path (a bare `SourceState::synthetic` claim with no verified
governance receipt or spend approval) will no longer compile or run against
the vendored crate — `TrainSpec::new_fal_synthetic` and
`ReservedSyntheticSubmission::reserve` both gained new required parameters.
A repository-wide search found no such RuView-side caller today; this ADR
records the change in case one is added later without checking the
submodule's current API first.
This pointer bump must not merge before `ruvnet/RuForecast` PR #2 merges and
its CI is green — see that PR for status.
## References
- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md) (this repository's copy; canonical copy is in `ruvnet/RuForecast`)
- [ruvnet/RuForecast PR #2](https://github.com/ruvnet/RuForecast/pull/2)
- [ruvnet/RuForecast ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md)
- [`v2/crates/ruforecast`](../../v2/crates/ruforecast) (submodule)
@@ -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.
@@ -0,0 +1,110 @@
# ADR-361: Point the whole fleet at one transmitter, to remove selection ambiguity
**Status:** Proposed — built, deployed fleet-wide, A-B-A measured. **Hypothesis CONFIRMED, adoption NOT justified.** The pairing *fraction* rises sharply; paired observations per second fall.
**Date:** 2026-09-09
**Follows:** ADR-358 (falsified), ADR-359 (mechanism confirmed, not adopted), ADR-360 (falsified).
## The hypothesis, recorded long before it could be tested
From `docs/BACKLOG.md`, "Next: the actual pairing constraint":
> Two nodes can therefore fire in the same aligned window and still pick frames
> from *different* transmitters, which can never pair under the
> `(tx_mac, rx_seq)` key. Cheapest test and likely fix: **point the whole fleet
> at one strong transmitter with the existing ADR-060 MAC filter.**
It had never been run, and the reason was mechanical rather than scientific:
**`filter_mac` was writable only by `provision.py` over USB.** It is a 6-byte
blob, absent from the `cfg_key_t` scalar table the config API validates
against, so the test would have required nine physical visits. Exposing it over
the network (see the firmware change accompanying this ADR) reduced the whole
experiment to two config pushes.
## What was measured
The associated AP was chosen on measurement, not assumption. Five transmitters
reach all nine receivers; only one carries a usable rate:
```
transmitter n_rx total_fps mean_rssi
8c:30:66:86:a4:21 9 109.81 -66.6 <- the AP, 26x the next
96:30:66:86:a4:21 9 4.24 -65.9
08:36:c9:71:ff:52 9 0.18 -75.9
64:16:66:cb:17:07 9 0.08 -81.7
10:a5:62:24:bb:30 9 0.06 -78.2
```
A-B-A, ten 30 s windows per arm. `/api/v1/fusion` counters are cumulative since
sink start, so they are **differenced over each window** and never divided as
lifetime totals — the trap already recorded for `early_drop`/`csi_fps_samples`.
The filter applied live on all nine nodes with **no restart**, so no reboot
confounds any arm.
```
arm n paired_fraction tx/s paired/s
A1 control 10 0.5668 +/- 0.0107 73.0 41.4
B filtered 10 0.7287 +/- 0.0352 33.9 24.7
A2 control 10 0.5836 +/- 0.0120 81.5 47.6
```
**Pairing fraction +0.1535 against the pooled control (+26.7% relative),
Welch t = 13.26, df = 10.5, p < 0.0001.** Reversible: A2 returned to control,
+0.0168 against A1.
Per-node confirmation that the filter did what it claims, taken on node 3 while
eight nodes were still unfiltered:
```
node 3 (filtered) AP fps 13.56 non-AP fps 0.28 non-AP share 2.0%
node 8 (control) AP fps 12.18 non-AP fps 5.66 non-AP share 31.7%
```
The AP rate is untouched; peer traffic falls to residue.
## The hypothesis is confirmed. The fix is not.
Transmitter ambiguity **was** suppressing pairing, and removing it lifts the
fraction by a quarter. That settles a question that had been open since
2026-09-07 and had resisted three other experiments.
But the backlog called this the "likely fix", and that half does not survive.
It predicted yield would fall to ~13 fps and that "the frames that remain are
pairable". Both are true, and neither is sufficient:
- **tx/s fell 56%** (77.3 -> 33.9)
- **paired observations per second fell 44%** (44.4 -> 24.7)
A higher fraction of a much smaller pie. The quantity downstream sensing
actually consumes is paired observations per second, and that went **down**.
Fraction was the wrong figure of merit, and choosing it is what made this look
like a fix rather than a diagnosis.
## Decision
**Do not adopt. Keep the branch unmerged.** Production stays unfiltered,
`gate_mode=2`, `gate_seq_period=8`.
`filter_mac` **is** kept in the config API. It costs nothing when unset, it is
now the cheapest available probe for "is this node's pairing transmitter-
limited?", and the ADR-060 filter had been effectively unreachable for the
fleet's whole life.
## What this opens
A **partial** filter is the obvious next question and is NOT what was tested
here. Restricting to a handful of widely-heard transmitters rather than exactly
one would trade less yield for some of the gain, and there is no reason to think
the optimum is at either extreme. The five 9-receiver transmitters above are the
natural candidate set, though four of them carry almost no rate.
That is a new experiment with a different independent variable, not a variant
of this one. It should be sized on **paired observations per second**, which is
the lesson this ADR paid for.
## Honest scope
Software-only. One house, one AP, nine nodes, ~15 minutes per arm, at 05:00
local with the house quiet. The effect is far larger than the within-arm
scatter and it reversed cleanly, but it has not been repeated across times of
day, and pairing is known to move with the transmitter population (see
"Transmitter population instability", 2026-09-02).
@@ -0,0 +1,69 @@
# ADR-362: Reading the on-node log is on-demand, never streamed
**Status:** Accepted
**Date:** 2026-09-09
**Follows:** the on-node log engine (persistent 128 B-record ring + coredump-to-flash)
## Context
The firmware now keeps a persistent log on the node's `storage` volume and can
write a coredump to flash. Nothing reads either one. The log is a new concept
in this tree, so there is no existing consumer to extend and the access
pattern is genuinely open.
Two shapes were considered:
1. **Ingest** — the server polls every node on a schedule, drains new records,
and keeps its own copy that the UI reads.
2. **On-demand** — the server fetches a node's log only when a human asks for
it, and keeps nothing.
## Decision
**On-demand only. No streaming, no periodic polling, no server-side copy, no
alerting.**
The log is pulled after a board hangs or crashes, or while troubleshooting a
specific node. That is the whole access pattern.
## Why
- **The persistence already lives on the node.** The ring holds ~208 days at
128 B per 300 s, and surviving a power cycle is the entire reason the module
exists — a power cycle is how a wedged node gets recovered. A second copy on
the server duplicates a durability property that is already satisfied.
- **Airtime is this fleet's binding constraint, and it is the thing being
diagnosed.** Two nodes uplinking at 1 Mbps were measured consuming 91% of
fleet airtime. A periodic log drain across nine nodes would spend exactly the
resource whose exhaustion is the most common thing you would open the log to
investigate. An instrument must not perturb what it measures.
- **Coredumps are 64 KB.** They are only ever worth moving when something has
actually crashed.
- **The use is diagnostic, not observational.** Nobody watches nine logs. You
open one node's history when that node misbehaves.
## What this implies gets built
- A server route that proxies to the node on request — `GET
/api/v1/nodes/{id}/log`, and the same for `/coredump` — authenticating with
the OTA PSK the firmware endpoints already sit behind.
- A page under the existing Tools menu to view one node's records.
- **No** scheduler, **no** new storage, **no** retention policy on the server.
## Open question, deliberately not settled here
Whether the server decodes the 128 B records into JSON, or proxies raw bytes
and lets the decoder at the edge (browser, or `node_log_read.py`) interpret
them.
Decoding server-side means the record layout is defined in two places — C and
Rust — and can drift silently. The pilot already paid for a version of this:
a field whose sentinel the host had to learn to render as `n/a`, where a
plausible wrong value would have been worse than an obvious gap. That argues
for the raw proxy and a single definition of the format.
## Non-goals
Real-time monitoring, log streaming, fleet-wide log aggregation, and alerting
on log contents are all explicitly out of scope. If a future need genuinely
requires one of them, it should revisit this ADR rather than grow into it.
@@ -0,0 +1,93 @@
# ADR 363: Native macOS USB node onboarding
## Status
Accepted and physically qualified with one attached ESP32 S3 and one attached
ESP32 C6 through an unsigned Mac Catalyst development build. Signed
distribution remains a release gate.
## Context
RuView operators currently provision ESP32 nodes with command line tooling.
The macOS app can list server accepted node identities, but it cannot establish
that an attached serial device is RuView firmware, assign a collision free node
identity, preserve its WiFi configuration, or prove that it joined the sensing
server. A UI that merely stores a friendly name would create false success.
Generic shell access from the app would also expand authority beyond the task.
Serial logs can contain operational data, and WiFi credentials must not enter
application diagnostics or provisioning receipts.
## Decision
1. Firmware 0.8.12 provides a bounded line protocol over the physical USB serial
channel. A discovery request carries a random 128 bit nonce. The device
response binds that nonce to chip family, firmware version, current node
identity, private server target, configuration state, and a pseudonymous
64 bit device digest derived from the base MAC under a RuView domain.
2. A configuration request is accepted only for the most recently issued
nonce within 60 seconds. The nonce is single use. Node identity is limited
to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port
is limited to 1 through 65535.
3. Existing WiFi configuration can be preserved. New credentials use strict
base64 framing, bounded lengths, and control character rejection. Firmware
commits NVS before acknowledging and rebooting. Credentials are never
returned, logged, or included in the receipt.
4. The macOS client uses a native serial bridge. It allowlists exact character
device prefixes, configures the port directly, and does not invoke a shell,
Python, or an external process. It rechecks the device digest immediately
before configuration to reduce port substitution risk.
5. The app suggests the lowest unused server node identity and the Mac private
LAN address. Success is shown only after the configured node appears in the
server inventory as active with evidence no older than three seconds.
6. Old firmware is reported as requiring an update. The app must not claim it
provisioned a device based only on generic RuView serial logs.
7. Automatic firmware flashing is outside this decision. Flashing requires an
exact chip and port check, a private backup, a matching image, and captured
boot evidence. Release publication remains a separate maintainer action.
8. The device digest uses the PSA Crypto SHA 256 interface on ESP IDF 5.4 and
6.0. This keeps the stable domain separated identity while avoiding the
legacy Mbed TLS hash API removed by ESP IDF 6.
9. ESP32 C6 mmWave UART defaults use GPIO 4 and GPIO 5. Startup rejects any
configured mmWave pin pair that overlaps the active console RX or TX pins.
This preserves the onboarding input path after a failed mmWave probe.
## Security and privacy consequences
The app gains Mac App Sandbox USB and serial device access, but not general file
or shell authority. The protocol does not authenticate a device with a hardware
root of trust; the digest is stable identification, not a cryptographic device
certificate. Physical USB possession is therefore the trust boundary for this
version. An authenticated fleet identity remains ADR 305 scope.
The largest failure mode is a successful NVS write followed by failure to join
the LAN. The user receives an explicit bounded verification failure instead of
a success screen, while the board retains its committed settings and can be
rescanned over USB.
## Acceptance test
Connect an ESP32 with firmware 0.8.12 to a Mac that is already receiving RuView
nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the
lowest unused identity, and set the Mac private address. Pass when the UI
reports the expected chip and firmware, the reassigned node remains fresh for
five minutes, the stale collided identity expires, app restart preserves the
profile, no server health, send, watchdog, panic, or reboot evidence appears,
and no credential appears in app logs or the provisioning receipt.
The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in
`docs/validation/2026-08-31-macos-usb-node-onboarding.md`.
The 2026-09-11 physical run passed on an ESP32 C6 over
`cu.usbserial-3120`. The Mac app flashed the image, received the nonce bound
hello receipt, preserved node identity 3, committed the server route, and
verified fresh server evidence at minus 34 dBm. The run also exposed and fixed
the GPIO 17 console RX conflict described above.
+13
View File
@@ -92,6 +92,13 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
| [ADR-345](ADR-345-per-link-csi-and-node-to-node-ranging.md) | Per-link CSI attribution and node-to-node ranging — why phase-based position fails on this hardware (measured), and the coordination-free `(tx_mac, rx_seq)` join key that does not need it | Proposed — measurement layer implemented and validated on hardware; localization not built |
| [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed |
| [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed |
| [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed |
| [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted |
| [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted |
| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted |
### Platform and UI
@@ -105,9 +112,14 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-035](ADR-035-live-sensing-ui-accuracy.md) | Live Sensing UI Accuracy and Data Transparency | Accepted |
| [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed |
| [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted |
| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) |
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
| [ADR-363](ADR-363-native-macos-usb-node-onboarding.md) | Native macOS USB node onboarding with nonce bound discovery and fresh server verification | Accepted and physically qualified on one ESP32 S3; C6 and signed distribution pending |
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
| [ADR-324](ADR-324-off-axis-head-coupled-perspective-demo.md) | off-axis-mode — RF-assisted head-coupled perspective demo (clean-room Kooima projection; RF presence gating) | Proposed |
### Architecture and infrastructure
@@ -179,6 +191,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-319](ADR-319-witness-chain.md) | Witness chain — staged, signed epistemic envelope | Accepted (phase 1) |
| [ADR-320](ADR-320-sensor-hal.md) | RuView sensor HAL — abstract all sensing hardware to one Observation type | Proposed (phase 2) |
| [ADR-321](ADR-321-decision-policy-action-authorization.md) | Decision policy — action authorization conditioned on certificate class, freshness, uncertainty, evidence | Accepted (phase 1) |
| [ADR-323](ADR-323-native-rust-physics-constrained-pose-refinement.md) | Native Rust physics-constrained pose refinement | Proposed |
---
@@ -0,0 +1,71 @@
# Physics pose refinement evidence ledger
ADR-323 performance and accuracy targets are gates, not measured claims. Append
rows; never replace prior measurements. Every row must identify the repository
commit, lockfile hash, Rust toolchain, target, engine/features, configuration
hash, corpus/split hash, command, sample count, and evidence label.
## Runtime measurements
| Date | Commit | Lock SHA-256 | Target/toolchain | Engine/config | Tracks | Samples | p50 | p95 | p99/max | RSS delta | Evidence | Reproducer |
|---|---|---|---|---|---:|---:|---:|---:|---:|---:|---|---|
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | deterministic kinematic shadow, config `ef3cf581f75124c1d45a8d6bedcef32e4d1bacb39ee0dfcd4e520171fda2d8cf` | 1 | 20,000 | 0.0080 ms | 0.0097 ms | 0.0195/0.5465 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | `cargo run --release -p wifi-densepose-physics --example latency_probe -- 20000` |
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | deterministic kinematic shadow after final local optimization, same config | 1 | 20,000 | 0.0075 ms | 0.0084 ms | 0.0117/0.1579 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | same release probe command |
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | final deterministic kinematic shadow, config `a44dc696234f31eda54cd4b436bc2d2c69b9638565b729ac9f07435cedfd0dcc` | 1 | 20,000 | 0.0071 ms | 0.0084 ms | 0.0147/1.5994 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | same release probe command |
The probe measures a warm, one-track `PhysicsEngine::process` call. It excludes
transport, publication, resident-memory delta, dynamics, and learned inference.
It is not evidence for the Pi 5 gate.
Criterion separately measured `kinematic_one_track` at
`[11.911, 12.757, 14.069] us` across 100 samples (approximately 369,000 timed
iterations). That benchmark includes observation construction and canonical
hashing in the timed routine and uses fresh engine state; it is **MEASURED** on
the same local host and is not a percentile or Pi 5 claim.
## Accuracy measurements
| Date | Commit | Corpus/split | Variant | Coverage | MPJPE | PCK threshold/result | Foot slide | Jerk | Fall/prone delta | Evidence |
|---|---|---|---|---:|---:|---|---:|---:|---:|---|
No measured accuracy evidence has been recorded. The deterministic tests are
L0/SYNTHETIC contract evidence only and cannot satisfy G2.
## Validation and supply-chain record
- The default dependency graph is checked to exclude Burn, Rapier, Tch, and
ONNX Runtime. Dynamics and learned backends remain opt-in.
- Burn CPU serialization/inference tests pass on the authoring host only with
Cargo's `--ignore-rust-version`; the resolved CubeCL graph requires Rust 1.92.
The workspace file pins Rust 1.89 and the host provides Rust 1.91.1. This is
diagnostic, not release approval.
- `cargo audit 0.22.1` used RustSec database commit
`69f93cf294852cfa9b53751f4ca86de3283dd290` (feed timestamp 2026-08-12).
ADR-323 updates remove resolved advisories in `event-listener`, `rkyv`, and
`wasmtime`. The workspace still has five advisories in pre-existing
`quick-xml` and `rsa` dependency paths; the default physics graph contains
none of them. The optional Burn training graph includes yanked `spin 0.9.8`.
- `cargo-deny` is not installed on the authoring host, so the required license
and policy gate is not claimed complete.
- Strict Clippy passes with warnings denied for core/physics default and
dynamics builds, the diagnostic learned-CPU build, and the Cog itself with
dependency linting excluded. Focused core, physics, dynamics, learned, Cog,
sensing-server adapter/live-audit/HTTP, schema, golden, strict-split,
feature-boundary, and fuzz-build checks pass.
- The repository-wide rustfmt gate is already red across unrelated crates. The
sensing-server library has existing warning debt, and unscoped Cog Clippy is
blocked by existing `wifi-densepose-ruvector` warnings. The prescribed
`cargo test --workspace --no-default-features` did not reach a terminal result
in either a 904-second cold or 604-second warm serial run on this Windows
host. None of these broader gates is represented as green.
- The standalone fuzz lock SHA-256 is
`d386c4edb130bb6b2d1a4ef77334c78e25e0695e90a9d97c01284876acb8c2c6`.
## Required commands
```text
cargo bench -p wifi-densepose-physics
node scripts/pose-physics/verify-feature-boundary.mjs
bash scripts/verify-pose-physics-splits.sh <manifest.json>
bash scripts/replay-pose-physics-golden.sh <golden-results.jsonl>
```
+534
View File
@@ -0,0 +1,534 @@
# RuForecast benchmark protocol
## Evidence status
No RuForecast runtime, accuracy, calibration, memory, or operational result is
recorded here yet. Every threshold below is an ADR-348 target, not a measured
claim. The first accepted row must identify a clean commit, the `Cargo.lock`
digest, the exact Rust toolchain, host, backend, model/configuration digest,
fixture or corpus digest, command, and evidence label.
## Benchmark boundaries
RuForecast uses two different forms of evidence:
1. Deterministic correctness evidence comes from Rust unit, property, replay,
split-isolation, and artifact-tamper tests. A fixed input and artifact must
produce the same output within its declared platform class.
2. Runtime evidence comes from Criterion on a named host. Criterion inputs are
generated from fixed checked-in code and seeds, but elapsed time is not
deterministic. Timing from shared GitHub runners is informational only.
The benchmark implementation must not download a model or dataset, read raw
CSI, use a hosted model output, or enable CUDA implicitly. CPU benchmarks use
the explicit `cpu` feature and the Burn ndarray backend. CUDA validation belongs
to a separately governed Linux or hosted-accelerator receipt.
## Required benchmark targets
| Package | Target | Purpose | CI authority |
|---|---|---|---|
| `ruview-forecast-model` | `forecast_inference` | Fixed-seed forward pass, batch and shape scaling, ordered-quantile output | Compile gate; shared-runner timing is informational |
| `ruview-forecast-train` | `data_pipeline` | Fixed generated records through validation, windowing, masking and batching | Compile gate; shared-runner timing is informational |
Both targets must use code-generated synthetic inputs, fixed seeds, bounded
allocations, `criterion::black_box`, and `required-features = ["cpu"]`. Setup,
artifact construction, and dataset generation stay outside the timed region
unless a benchmark name explicitly says they are included.
The model implementation owns structural parameter-count assertions. The
currently reviewed design values are 35,700 parameters for the tiny CI preset
and 20,285,108 for the large preset. These are design invariants, not benchmark
results, and must be derived by a test from the actual module graph before they
are quoted in a model card.
## Local Linux reproducer
Run from a clean checkout after installing Rust 1.92.0:
```bash
RUFORECAST_CPUSET=0-7 \
RUFORECAST_THREADS=8 \
scripts/run-ruforecast-benchmarks.sh
```
The runner executes the focused contract/model/training tests, one real
optimizer step over a local hash-addressed synthetic JSONL shard, and the
idempotent synthetic CLI smoke. It then compile-checks both Criterion targets,
runs the targets, captures the CPU and toolchain metadata, and hashes every
output. A failed run retains its partial logs with `status=FAILED` and its exit
code rather than looking like a complete report. Results go under
`target/ruforecast-evidence/`, which is excluded from source control.
For a conservative single-thread reproducibility check, omit both environment
variables. To run against an uncommitted tree for diagnosis only, set
`RUFORECAST_ALLOW_DIRTY=1`; the resulting metadata is labelled `SYNTHETIC`
with scope `DIRTY_WORKTREE_DIAGNOSTIC_ONLY` and cannot support a release claim.
A clean run labels its host timing `MEASURED` and its input class `SYNTHETIC`,
but remains `UNREVIEWED`. Only a maintainer may append it to the accepted ledger
after checking the digests, shape, command, Criterion report and host scope.
The runner intentionally has no CUDA option and does not parse Criterion output
into a pass/fail performance verdict. This prevents a noisy host result from
silently acquiring release authority.
To compile the two benchmark targets without measuring them:
```bash
cd v2
cargo +1.92.0 bench --locked -p ruview-forecast-model \
--no-default-features --features cpu --bench forecast_inference --no-run
cargo +1.92.0 bench --locked -p ruview-forecast-train \
--no-default-features --features cpu --bench data_pipeline --no-run
```
For a short informational run, use the same targets without `--no-run`:
```bash
cargo +1.92.0 bench --locked -p ruview-forecast-model \
--no-default-features --features cpu --bench forecast_inference -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10
cargo +1.92.0 bench --locked -p ruview-forecast-train \
--no-default-features --features cpu --bench data_pipeline -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10
```
Running these commands does not add a ledger row automatically. Preserve the
raw report and environment metadata, then have a maintainer assign its evidence
scope before publishing a number.
The inference bench runs only `tiny_ci` by default so a routine CI trend step
cannot accidentally start the very expensive large CPU probe. Set
`RUFORECAST_BENCH_LARGE=1` only on a controlled host when intentionally
measuring the fixed deployment shape:
```bash
RUFORECAST_BENCH_LARGE=1 scripts/run-ruforecast-benchmarks.sh
```
## Deployment measurement shape
The initial CPU deployment probe is batch 1, context 1,024, 32 declared feature
streams, the fixed `large_linux` horizon of 300, and all seven declared
quantiles. Record at least 20 warmup
iterations and 200 measured iterations for a release candidate. Report p50,
p95, p99 or maximum, throughput, and the process peak resident set size.
ADR-348 G5 currently targets p95 at or below one second for 32 declared streams
and peak process memory at or below 4 GiB. A Criterion result alone cannot close
the memory gate because Criterion and Cargo are not the production inference
process. G5 remains open until a standalone inference probe reports its own peak
resident set size.
## Accuracy and calibration protocol
Runtime speed never substitutes for forecasting quality. The frozen evaluation
manifest must report identical examples for:
1. Last-value and seasonal-naive baselines.
2. RuForecast without RuVector retrieval.
3. RuForecast with split-scoped RuVector retrieval.
Required report fields include weighted quantile loss by horizon, nominal 80%
interval coverage, missingness, abstention coverage, selective risk, site and
device slices, interference regime, and retrieval ablation. ADR-348 G3 targets
weighted quantile loss at least 10% better than seasonal naive and 80% interval
coverage between 75% and 85%. Those targets remain unmeasured until a frozen,
leakage-free report is attached.
### Informal HPO exploration note (unaccepted, not a release claim)
**2026-09-01.** An exploratory session ran the accuracy protocol above end to
end against a governed 24-window **synthetic** dataset (`tiny_ci` profile,
context 64 / horizon 12, temporal train/test split, not entity-holdout —
only one synthetic generator was used, so entity holdout does not apply) and
a small `OptimizerSpec` hyperparameter search (learning rate, weight decay,
gradient clip norm, batch size, epochs) using a new Darwin Mode numeric-genome
evolution engine (upstream: `ruvnet/metaharness` PR #260, not yet merged).
This is **exploratory evidence only** — not a frozen, leakage-free,
maintainer-reviewed report, and not eligible for the ledger below until one
is produced.
Prior to this exploration, a **single real household window** (76 real
1&nbsp;Hz vital-signs samples, one physical ESP32 sensor, temporal not entity
holdout) scored **worse than both baselines** (WQL 0.537 vs. last-value
0.106 and seasonal-naive 0.123) — consistent with a single training window
overfitting rather than generalizing.
With a larger (still synthetic, still `tiny_ci`) 24-window training set and
three rounds of hyperparameter search, weighted quantile loss on the held-out
synthetic split improved and stayed ahead of both baselines throughout:
| Round | learning_rate | weight_decay | grad_clip | batch | epochs | WQL (model) | WQL (last-value) | WQL (seasonal-naive) |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| Default config | 0.0010000 | 1.00e-4 | 1.000 | 8 | 60 | 0.257 | 0.277 | 0.514 |
| Search round 1 | 0.0002356 | 3.05e-6 | 0.100 | 27 | 195 | 0.161 | 0.277 | 0.514 |
| Search round 2 | 0.0000298 | 4.09e-11 | 4.746 | 26 | 356 | **0.153** | 0.277 | 0.514 |
Round-2 gain over round 1 (−0.008) was much smaller than round-1's gain over
the default (−0.096) — a diminishing-returns signal consistent with a local
optimum for this model size and dataset, not a converged global result.
`gradient_clip_norm` landed at opposite bound extremes across rounds
(0.1 then 4.7), so no directional recommendation on that parameter should be
drawn from this exploration alone.
**Explicit scope limits — do not generalize beyond these:**
- `tiny_ci` only. Nothing here has been run against `large_linux`; its far
larger parameter count and different compute profile mean these
hyperparameters are not a starting point for it without their own search.
- Synthetic dataset only (24 windows, one generator/seed family). Not
validated against any real corpus at this scale.
- Self-signed, evaluation-only model activation (a throwaway local Ed25519
key, not a release signature) was used to run inference for scoring.
- No security/provenance/maintainer-approval gate has passed — the Darwin
Mode promotion rule correctly refused to promote any candidate here.
Reproducer: `harness/ruview/flywheel/ruforecast/` (genome, gate, evaluator,
dry-run/`--confirm` driver) in the `ruvnet/RuView` repo, paired with
`ruvnet/metaharness` PR #260 (`evolve-numeric`) linked locally via
`npm link`. Neither the genome defaults here nor any repo default config
were changed by this note — it is a record of exploratory evidence, not a
committed recommendation.
### Amendment (2026-09-01, later same day): the round-2 result above did not generalize -- retracted
**The "Search round 2" row above (WQL 0.153, learning_rate=0.0000298 etc.) is
RETRACTED as a claim of improvement.** It is kept in the table (append-only,
never silently edit a prior measurement) but must be read together with this
amendment: independent verification, run the same day using a new
regression-candidate promotion path (`ruvnet/autogenous` PR-in-progress,
branch `feat/regression-candidate-kind`, not yet merged/pushed --
`v2/crates/ruforecast-autogenous-bridge` in this worktree) against TWO FRESH
synthetic corpora that were never part of that search (seeds 1000/1097, vs.
the search's single fixed seed 0), showed that "winner" genome performing
**WORSE than the baseline on both**:
| Judge corpus | Candidate WQL | Baseline WQL | Candidate beats baseline by |
|---|---:|---:|---:|
| seed 1000 | 0.155 | **0.099** | -0.056 (worse) |
| seed 1097 | 0.219 | **0.109** | -0.110 (worse) |
**Root cause**: every evaluation in the three search rounds above (default,
round 1, round 2) trained and scored every candidate against the exact same
fixed synthetic corpus (`prepare-synthetic-dataset`'s implicit `--seed 0`
default). The search had learned to exploit that one corpus's specific
random windows -- textbook overfitting -- not found a genuinely better
hyperparameter configuration. This is a real, measured failure mode, not a
hypothetical caveat.
**Fix applied**: `harness/ruview/flywheel/ruforecast/gate.mjs`'s
`evaluateGenome` now trains and scores every candidate against THREE
independent synthetic corpora (`DEFAULT_SEARCH_SEEDS = [11, 23, 47]`, none
of which overlap the retracted search's seed 0 or the verification seeds
1000/1097) and takes the WORST CASE `primary` across them, not an average --
a candidate only counts as a win if it beats both baselines on every corpus.
See `harness/ruview/flywheel/ruforecast/README.md`'s "Multi-seed fitness"
section for the full account.
**Re-running the search with the fix, still no verified improvement.** A
fresh search under the corrected multi-seed fitness (2 generations x 2
children, cleared `.metaharness-numeric` archive so no stale pre-fix state
leaked in) found a genuine winner ON ITS OWN THREE SEARCH SEEDS --
`learning_rate=0.008012, weight_decay=0.000159, gradient_clip_norm=0.267,
batch_size=24, epochs=20`, beating the baseline on all three (primary
0.099 / 0.556 / 0.576, worst-case 0.099). Independent verification against
the same fresh seeds 1000/1097 (never part of this candidate's own search)
again showed it losing to the baseline on both:
| Judge corpus | Candidate WQL | Baseline WQL | Candidate beats baseline by |
|---|---:|---:|---:|
| seed 1000 | 0.178 | **0.099** | -0.079 (worse) |
| seed 1097 | 0.314 | **0.109** | -0.205 (worse) |
**Honest conclusion**: two independent search rounds (pre- and post- the
multi-seed fitness fix) both produced a genome that looked like a real
improvement on its own search data and both failed independent out-of-sample
verification. This converges on a different, deeper explanation than "the
search methodology was broken" (that part IS fixed): at this dataset scale
(24 synthetic training windows), held-out WQL varies enormously by which
corpus is drawn REGARDLESS of hyperparameters -- the baseline genome itself,
evaluated with the corrected multi-seed fitness function, swings from
primary 0.83 (a strong win) to a full regression (primary 0, WQL worse than
last-value) purely from which of the three fixed search seeds was used, with
identical hyperparameters throughout. The corpus-noise floor at n=24
windows appears to dominate any real hyperparameter effect. No RuForecast
hyperparameter configuration has been shown, by this exploration, to
reliably beat the trivial baselines out-of-sample at this scale. A larger
training corpus (more windows, ideally real governed data under the
`large_linux` profile) is the more promising next lever than further
hyperparameter search on this fixture.
A related implementation-only fix: the promotion verifier
(`envelope::regression::verify_regression_promotion` in the Autogenous
branch above) initially required all judges' receipts to share one
`corpus_id`, inherited unreviewed from a same-evidence review model that
does not fit this kind's intentionally cross-corpus judge design. This was
corrected (`ReceiptCorpusMismatch` is no longer produced by that function);
it did not change either REJECT verdict above, both of which were already
correctly driven by the real `NotBetterThanParent` signal.
Reproducer for both verification runs above: same as below, plus
`v2/crates/ruforecast-autogenous-bridge` (`cargo +1.92.0 run --manifest-path
crates/ruforecast-autogenous-bridge/Cargo.toml -- --ruforecast-bin
./target/debug/ruforecast --candidate-genome <genome>.json --parent-genome
<baseline>.json --judges 2 --work-dir <scratch>`) against the unpushed
`ruvnet/autogenous` branch `feat/regression-candidate-kind`.
### Real-household-data result (2026-09-01, MEASURED, unaccepted, not a release claim)
Following the informal HPO exploration above, this session also collected
**6,390 real 1&nbsp;Hz vital-signs samples** (heart rate, breathing rate,
signal quality) from a real, live, ESP32-sourced household sensing
deployment over a continuous 2-hour window (88.75% real sample coverage;
gaps handled honestly via `observed_mask=0`, never fabricated
interpolation) — the "more real training data" lever flagged as the
credible next step in the note above. This directly answers that open
question.
Two genuinely independent temporal splits of the same real corpus (not
synthetic seeds — real data has no seed to vary, so independence here
means two different train/test boundary choices on the same timeline,
each with its own 90s embargo gap) were trained (default, untuned
`OptimizerSpec`: `lr=0.001, weight_decay=0.0001, gradient_clip_norm=1.0,
batch_size=8, epochs=60`) and scored via the real `evaluate` CLI, then
independently, cryptographically verified through
`ruforecast-autogenous-bridge`'s real signed regression-candidate
promotion path (`ruvnet/autogenous`, `envelope::regression`):
| Judge | Split | Real test windows | Model WQL | Best trivial baseline WQL | Model beats baseline by |
|---|---|---:|---:|---:|---:|
| 1 | 70% train / 90s embargo / 30% test | 27 | 0.0514 | 0.0563 (last-value) | +0.0049 |
| 2 | 50% train / 90s embargo / 50% test | 46 | 0.0670 | 0.0543 (seasonal-naive) | −0.0128 |
**Signed verdict: REJECT.** Judge 1's nominal win (+0.0049) is below the
0.01 non-inferiority margin, so it doesn't clear the promotion bar even
on its own; Judge 2 lost outright. Both rejections are recorded as
`NotBetterThanParent` in the signed promotion envelope.
**Honest conclusion:** even with a real household corpus (n=6,390 real
samples, not synthetic), the result is exactly the same shape as every
synthetic search this session — a result that looks like a win on one
split does not hold up on an independently verified second split. This
is not evidence that real data can't help; it is evidence that this
scale of real data (6,390 samples, one household, one physical sensor)
is not yet enough to distinguish a genuine effect from split-dependent
noise. The credible next lever remains more real data — more households,
longer collection windows, or the `large_linux` profile — not further
hyperparameter search on any fixture this small, synthetic or real.
Reproducer: `v2/crates/ruforecast-autogenous-bridge/examples/real_data_verify.rs`
and `v2/crates/ruforecast/crates/ruforecast-train/examples/real_data_windows.rs`
in this worktree (`train/ruforecast-rust` branch, commits `130f547` in the
`ruforecast` submodule and `788401c5` in this repo — both local, not yet
pushed). Raw real vitals data never left the collecting/training hosts and
was never written to any git-tracked or pushed path.
## Append-only evidence ledger
Never replace a prior measurement. Append a row and retain the failed or stale
row when code, model, configuration, corpus, hardware, or methodology changes.
| Date | Commit | Lock SHA-256 | Host/toolchain | Backend/config | Shape | Samples | p50 | p95 | p99/max | Peak RSS | Evidence | Reproducer |
|---|---|---|---|---|---|---:|---:|---:|---:|---:|---|---|
No rows have been accepted.
### Real public dataset: BIDMC PPG/Respiration (2026-09-02, cross-entity holdout)
Every real-data test up to this point used a single household/entity with only
a **temporal** holdout (same physical sensor, different time windows). This
test is the first with a genuine **cross-entity** holdout: 53 real ICU
patients from the BIDMC PPG and Respiration Dataset (PhysioNet, Open Data
Commons Attribution License v1.0, public and openly licensed — no
credentialing, https://physionet.org/content/bidmc/1.0.0/), splitting by
*patient*, not by time, so the held-out test set contains real people the
model never saw during training.
25,546 real 1 Hz rows across 53 recordings (~8 minutes each), heart rate +
respiratory rate + SpO2. 3 of 53 patients' windows were excluded honestly
(genuine sensor-dropout `NaN` values in the source recordings, not
fabricated/interpolated). Two independent, disjoint patient-partition splits:
| Judge | Train patients | Test patients | Test windows | Model WQL | Best baseline WQL | Result |
|---|---:|---:|---:|---:|---:|---|
| A (contiguous split) | 34 | 16 | 16 | 0.01964 | 0.01159 (last-value) | worse, +69% |
| B (interleaved split) | 24 | 26 | 26 | 0.07022 | 0.01002 (last-value) | worse, +601% |
**Same conclusion as every prior test this session**, now on real, public,
multi-subject clinical data with genuine cross-entity generalization: the
model does not beat trivial forecasting baselines. The margin is decisive on
both independent splits, not a near-miss — the earlier hypothesis that a
larger, genuinely diverse real dataset (many different people, not one
household) might change the picture does not hold at this scale/model
configuration either.
**Honest scope note on verification**: prior real-data tests in this document
were independently checked through Autogenous's signed regression-candidate
promotion path. That additional cryptographic-signing step was **not** run
for this test — the result is reported directly from the `evaluate` CLI's
real output on two genuinely disjoint, real patient-holdout splits, which is
itself real, independent, out-of-sample evidence, but it does not carry a
signed promotion-gate verdict the way the earlier entries do. Flagging this
explicitly rather than presenting it with the same evidentiary weight.
Reproducer: `v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare.rs`
(untracked scratch example, worktree `train/ruforecast-rust`) — downloads
`bidmc_NN_Numerics.csv` for patients 01-53 directly from PhysioNet, builds
one 76-row (context 64 + horizon 12) window per eligible patient, and writes
governed `train.jsonl`/`test.jsonl`/`train-local.toml` per judge split. Raw
data cached at `/tmp/bidmc-raw/` on the training host only.
### Zero-shot pretrained-checkpoint comparison (2026-09-02)
Every RuForecast result above this line is a **from-scratch-trained** model
(RuForecast's own Burn/Rust architecture, trained on a tiny real or synthetic
corpus) evaluated against trivial baselines. All of them lost. A literature
pass after the BIDMC result above (arXiv:2505.11349, "context parroting";
arXiv:2607.20027, zero-shot HRV forecasting with TimesFM/Chronos/MOIRAI on
wearables) converged on an explanation: published foundation-model wins over
naive baselines on short-horizon, smooth physiological signals come from
**massive real pretraining corpora** (Chronos: ~10M real+augmented series;
TimesFM: ~100B real time points), not from architecture alone — and
RuForecast's from-scratch training used a real corpus many orders of
magnitude smaller (tens to low hundreds of windows), so re-implementing
these architectures from scratch and training small was never going to
replicate their wins.
This test checks the other half of that hypothesis directly: does a **real,
public, pretrained checkpoint**, run **zero-shot** (no fine-tuning, no
training of any kind on this repo's data) on the **exact same BIDMC
cross-entity test windows and metrics** as the BIDMC result above, actually
beat the naive baselines? If the massive-pretraining-corpus explanation is
right, it should — unlike every from-scratch attempt this session.
**Setup (kept identical to the BIDMC test above for a fair comparison):**
same `/tmp/bidmc-raw/` cache (53 real ICU patients, PhysioNet BIDMC, Open
Data Commons Attribution License v1.0), same window construction (context
64s + horizon 12s, 1 Hz, variates HR/RESP/SpO2, one window per patient from
that patient's first 76 rows), same two disjoint patient-partition judges (A:
train 1-37/test 38-53 contiguous; B: train odd/test even interleaved — train
patients are irrelevant here since nothing is trained, only the *test*
partition is used), same quantile set
`[0.05,0.10,0.25,0.50,0.75,0.90,0.95]`, same seasonal period (12), and the
same WQL formula (`2*sum(pinball)/(numQuantiles*sum|actual|)`) and MAE
definition as `ruforecast-core::metrics.rs`. Baselines were independently
re-derived from `ruforecast-core::baseline.rs`'s exact logic in Python and
cross-checked against the BIDMC section above: reproduced last-value WQL was
0.01171 (A) / 0.01013 (B) versus the Rust CLI's originally reported
0.01159 / 0.01002 — a ~1% gap attributable to f32 (Rust) vs f64 (Python)
accumulation, not a methodology difference, so this reproduction is treated
as validated.
Four real, public, pretrained-checkpoint models were run zero-shot on GPU
(RTX 5080, `ruvultra`), per-variate univariate (none of these checkpoints has
a native multivariate mode for these two families, so HR/RESP/SpO2 were
forecast independently and recombined into the same window-level WQL/MAE as
the baselines and the from-scratch RuForecast model):
- **Chronos-Bolt base/small** (`amazon/chronos-bolt-base`,
`amazon/chronos-bolt-small`) — direct quantile-regression heads,
deterministic given input (no sampling).
- **Chronos (T5) small** (`amazon/chronos-t5-small`) — the original
sampling-based Chronos family; `predict_quantiles` internally draws
samples (default `num_samples`), so this one model's numbers below carry
run-to-run sampling variance the other three don't.
- **TimesFM 2.5 (200M, PyTorch)** (`google/timesfm-2.5-200m-pytorch`) —
quantile output uses 10 channels; per the installed package's source
(`timesfm_2p5_base.py`, `decode_index: int = 5`) and empirical
verification across multiple windows/variates, channel 5 is the point
forecast and channels 1-9 are the native quantile levels [0.1..0.9] in
order (channel 0 is an unused duplicate, not a real quantile). Levels 0.05
and 0.95 were clamped to the nearest native level (0.10/0.90); 0.25 and
0.75 were linearly interpolated between the adjacent native levels — the
same class of approximation Chronos-Bolt's own pipeline applies
internally when asked for quantile levels outside its trained range (a
warning it printed for 0.05/0.95 on every call).
| Judge | Model | Test windows | MAE | WQL | WQL vs last-value |
|---|---|---:|---:|---:|---:|
| A | last-value (baseline) | 16 | 0.7882 | 0.01171 | — |
| A | seasonal-naive (baseline) | 16 | 1.0885 | 0.01617 | worse |
| A | Chronos-Bolt-base (zero-shot) | 16 | 0.7802 | 0.00756 | **-35.4%** |
| A | Chronos-Bolt-small (zero-shot) | 16 | 0.7212 | 0.00684 | **-41.6%** |
| A | Chronos-T5-small (zero-shot) | 16 | 0.9487 | 0.00955 | **-18.4%** |
| A | TimesFM-2.5-200M (zero-shot) | 16 | 0.7490 | 0.00731 | **-37.6%** |
| B | last-value (baseline) | 26 | 0.6944 | 0.01013 | — |
| B | seasonal-naive (baseline) | 26 | 0.8697 | 0.01269 | worse |
| B | Chronos-Bolt-base (zero-shot) | 26 | 0.7096 | 0.00719 | **-29.0%** |
| B | Chronos-Bolt-small (zero-shot) | 26 | 0.6852 | 0.00676 | **-33.3%** |
| B | Chronos-T5-small (zero-shot) | 26 | 0.8254 | 0.00886 | **-12.5%** |
| B | TimesFM-2.5-200M (zero-shot) | 26 | 0.6511 | 0.00698 | **-31.1%** |
**Honest result: this is the first test all session where a model beats the
trivial baselines, and it does so decisively and consistently** — all four
pretrained checkpoints beat both last-value and seasonal-naive on WQL, on
both independently-partitioned judges, by double-digit-to-40% margins. This
is a materially larger and more consistent margin than the closest published
direct analog found in the literature pass (zero-shot HRV forecasting,
arXiv:2607.20027: Chronos beat EWMA by ~3%, MOIRAI showed no significant
advantage over a mean baseline, p=0.953) — plausibly because BIDMC's 1 Hz
ICU HR/RESP/SpO2 signals are smoother and more autocorrelated than the HRV
series in that paper, which is itself a fair, disclosed difference between
the two comparisons rather than a claim of a stronger general result.
On MAE (point accuracy) the picture is more mixed: Chronos-Bolt-small and
TimesFM beat last-value's MAE on both judges, but Chronos-Bolt-base and
Chronos-T5-small do not consistently (e.g. Chronos-Bolt-base's MAE is
essentially tied with last-value on judge A, worse on judge B, despite a
much better WQL there) — the pretrained models' advantage is concentrated in
calibrated quantile spread (what WQL scores), not in shifting the central
point forecast much past what last-value already gets from strong
autocorrelation in these signals.
**This directly confirms the massive-pretraining-corpus hypothesis from the
literature pass**: the same short-horizon, smooth physiological-signal task
on which every from-scratch RuForecast configuration this session lost to
naive baselines is won, cleanly, by checkpoints that carry a pretraining
corpus many orders of magnitude larger than any real dataset available to
this project. This is evidence *for* zero-shot/foundation-model inference
(or later, fine-tuning a pretrained checkpoint) as the credible next lever
for RuForecast-style short-horizon vitals forecasting — not for continuing
to scale from-scratch training on this project's own small real corpora,
which the BIDMC and household tests above already showed does not close
this gap even with genuine, cross-entity real data.
**Scope and caveats, stated plainly:**
- This is zero-shot inference only — no fine-tuning was attempted on any
checkpoint. A fine-tuned comparison (e.g. LoRA-adapting Chronos-Bolt on
RuForecast's own training windows) is a natural, not-yet-run follow-up.
- Univariate-per-variate forecasting was used for all four models because
neither Chronos nor TimesFM in these releases has a native multivariate
mode matching RuForecast's joint HR/RESP/SpO2 modeling; a true
multivariate foundation model (e.g. MOIRAI, which does support cross-variate
attention) was not tested here and is a natural next step.
- Chronos-T5-small's `predict_quantiles` samples internally (unlike
Chronos-Bolt's direct quantile heads or TimesFM's quantile head); its
numbers above are one run and carry unquantified sampling variance the
other three models' numbers don't.
- No inference-cost/latency comparison against RuForecast's own (much
smaller, ~tens-of-KB) model is included; all four pretrained checkpoints
are orders of magnitude larger (small-to-200M+ parameters) and ran on a
discrete GPU (RTX 5080), not the ESP32-class edge target RuForecast's own
architecture is designed for. A model that wins on WQL here is not
automatically deployable to the same edge budget RuForecast targets — that
tradeoff is unresolved by this test and is itself a reason zero-shot
inference (call out to a hosted/cloud checkpoint) and from-scratch
small-model training remain different tools for different deployment
constraints, not a strict replacement of one by the other.
Reproducer: `scripts/ruforecast-zeroshot/bidmc_windows.py` (window builder,
baselines, and metrics — ported line-for-line from
`ruforecast-train/examples/bidmc_prepare.rs` and
`ruforecast-core/src/{baseline,metrics}.rs`, verified against this document's
own BIDMC last-value/seasonal-naive numbers) and
`scripts/ruforecast-zeroshot/run_zeroshot_eval.py` (zero-shot inference and
scoring), both in this worktree (`research/ruforecast-zeroshot-eval`). Run
against `/tmp/bidmc-raw/` (downloaded fresh from PhysioNet if not already
cached) with `python run_zeroshot_eval.py --models chronos-bolt-base
chronos-bolt-small chronos-t5-small timesfm-2.5-200m --judges a b` inside a
venv with `chronos-forecasting`, `timesfm`, and a CUDA-enabled `torch`
installed (`amazon/chronos-*` and `google/timesfm-2.5-200m-pytorch` download
automatically from HuggingFace Hub on first run; no HF token was required).
Model weights and the raw BIDMC CSVs are not committed — `/tmp/bidmc-raw/`
and the HuggingFace cache live only on the host that ran this evaluation.
@@ -5,10 +5,15 @@ PCK@20 (MultiFormer Table VII metric: `‖pred−gt‖ ≤ 0.2·‖R-shoulder
The flagship [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
reaches **83.59%** torso-PCK@20 (vs MultiFormer 72.25%, CSI2Pose 68.41%). But the headline number
isn't the whole story for **edge deployment** — on a Raspberry Pi / ESP32-class target, *params and
isn't the whole story for **edge deployment** — on a Raspberry Pi-class edge host, *params and
latency* matter as much as accuracy. So we swept model size to map the **accuracy-per-parameter
frontier**: how small can a WiFi-CSI pose model be and still beat the prior published SOTA?
> **Hardware compatibility boundary.** These models consume MM-Fi tensors shaped
> `[3,114,10]`. Parameter size alone does not make that input, model architecture, or runtime
> compatible with an ESP32-S3/C6 capture node. The measurements below are dataset and x86/GPU
> measurements; no ESP32 inference latency or live ESP32-to-MM-Fi adapter is claimed.
## The frontier
| Model | Params | Latency (batch=1) | torso-PCK@20 | vs SOTA (72.25%) |
@@ -38,8 +43,10 @@ Size alone isn't the claim — what matters is **accuracy at the deployed precis
**The honest edge result:** `micro` is **lossless at int8 (73.5 KB, 74.70%)**, and at **int4 (36.7 KB)
naïve post-training quantization falls below SOTA (70.21%) — but quantization-aware training fully
recovers it to 74.46%**, still beating MultiFormer. So a **SOTA-beating WiFi-pose model genuinely runs
in ~37 KB int4** (with QAT) or **~73 KB int8** (no retraining) — deployable on the sensing node itself.
recovers it to 74.46%**, still beating MultiFormer. So a **SOTA-beating WiFi-pose model fits in
~37 KB int4** (with QAT) or **~73 KB int8** (no retraining). That is a model-footprint result, not
evidence that it runs on an ESP32 sensing node; a compatible capture adapter and embedded runtime
still need to be implemented and measured.
`nano` (40K params) sits at the SOTA line in fp32 and is best treated as int8.
(We also tested flagship→tiny **knowledge distillation**: it did *not* help — the tiny students reach
+165
View File
@@ -0,0 +1,165 @@
# Which CSI frames a node keeps decides whether cross-node fusion works
A measured comparison of three frame-selection gates on a nine-node ESP32-C6
fleet, and a method for repeating it in your own environment.
**This is one house.** Nine boards, one floor plan, one RF environment, one
transmitter population. The numbers below are what we measured here; the part
worth reusing is the method, not our figures. `run_arms.py` is included so you
can measure yours.
---
## The problem
Cross-node fusion pairs frames by transmission identity: two nodes are fusing
usefully only when they hold measurements of *the same packet in the air*. The
metric is `paired_fraction` — of the transmissions the fleet reported, what
share were captured by two or more nodes.
Every node also rate-limits which CSI callbacks it keeps, because processing
every callback at 100–500 Hz destabilises the WiFi ISR. **How that limiting is
done turns out to matter far more than we expected**, because it decides
whether two nodes keep the *same* frames or merely the *same number* of frames.
Three mechanisms, all valid rate limits:
| gate | rule |
|---|---|
| `elapsed` | at least 20 ms since **this node's** last accepted frame |
| `mesh` | first frame heard in each shared 20 ms window of a mesh-aligned clock |
| `seq` | keep frames where the 802.11 `rx_seq` is divisible by N |
`elapsed` gives every node an independent phase. `mesh` makes nodes agree on
the *window*. `seq` makes them agree on the *frame*, using a number the
transmitter assigns — so it needs no clock, no leader and no synchronisation.
## Method
Four arms — the three gates plus `mesh`+`seq` stacked — run as a 2x2 factorial.
Three deliberate choices, each of which we got wrong first:
**Whole fleet per arm, never a subset.** An earlier attempt compared four gated
nodes against four ungated ones. Nodes differ enormously in how much traffic
they hear, so that comparison could not be read at all.
**Verify the gate is actually running.** Each switch is read back from every
node before sampling. "Config was stored" is not "gate is live", and an earlier
run could not distinguish a null result from a change that never applied.
**Frame rate is NOT a valid check that a gate is working.** A bucket gate needs
only one survivor per window, so at ~14 candidate frames per bucket even a
perfect 1-in-4 gate moves frame rate about 2%. Use instead
R_window = (delta early_drop + delta accepted) / delta accepted
computed from **differenced** counters. Never divide lifetime totals: they
carry a large post-boot burst. One node read R = 1126 moments after a reboot,
26.5 an hour later, and 19.2 windowed.
**Establish the noise floor first.** A null run — three hours, no changes —
gave drift of sd **0.45 pp** across 20-minute windows, about 2x the ±0.2 pp
binomial counting error. Without that number none of the results below can be
called significant.
Arms are 20 minutes in Latin-square blocks (`U M S MS / S MS M U / MS U S M`),
so slow drift lands on every arm equally rather than on whichever ran first.
## Results
Three blocks, twelve arms, every arm verified live on all nine nodes.
| arm | block 0 | block 1 | block 2 | mean |
|---|---|---|---|---|
| `elapsed` | 0.2881 | 0.2409 | 0.2012 | **0.2434** |
| `mesh` | 0.2692 | 0.2580 | 0.2167 | **0.2480** |
| `seq` (period 8) | 0.5819 | 0.5573 | 0.5422 | **0.5605** |
| `mesh`+`seq` | 0.5639 | 0.5623 | 0.5093 | 0.5452 |
**`seq` roughly doubles pairing: +31.7 pp, 2.30x.** That gap is ~70x the
measured drift floor and reproduces in all three blocks; within-block contrasts
sit at +0.153 to +0.175 every time.
**Mesh-time alignment bought essentially nothing here: +0.5 pp**, about one
standard deviation of the noise floor. We built that gate and expected it to be
the answer; it is indistinguishable from doing nothing. Stacking it on top of
`seq` is marginally *worse* than `seq` alone.
### It is a trade, not a free win
At period 8 the seq arms carried ~75–95k transmissions per 20 minutes against
~120–160k ungated. You are buying pairing with raw frame rate. Absolute paired
transmissions per minute still went **up** (2629 vs 2524), because the frames
it discards are disproportionately ones only one node would have kept — but
whether that trade is right depends on what else consumes the stream.
One measurement that made the trade easier here: on-device edge processing was
**unaffected**. Frames actually processed stayed ~1900 per node per 4 minutes
in both modes, because that pipeline is separately rate-limited. The cost falls
on the uplink, not on local sensing.
### The mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`. A
node without valid sync falls back to the elapsed gate rather than gating on a
meaningless epoch -- correct behaviour, but it weakens this comparison.
Three of the nine nodes never synchronised during these runs. For those three,
the `mesh` and `mesh`+`seq` arms silently ran the elapsed gate. So **+0.5 pp is
not a clean fleet-wide measurement of window alignment**; it is "mesh where sync
held, elapsed where it did not", and the true effect of alignment on a fully
synchronised fleet is not measured here.
We are reporting the number as observed rather than reconstructing it, because
the arms were assigned before we understood the fallback, and a post-hoc
per-node reanalysis would not be the experiment we ran.
The cause is worth naming because it is environmental, not a firmware defect.
Time sync is leader-elected and hub-and-spoke: one node broadcasts and the rest
follow. Whether every node hears that leader is a property of the building. A
larger or more divided house fragments into sync domains, and any option that
depends on shared time then degrades quietly for the nodes that fell out --
which is precisely the situation where someone would most want to trust this
measurement.
### A prediction we got wrong
Our mesh had split into two leader domains with three nodes never synchronising
at all. We predicted `seq` would win *most* on node pairs spanning that split.
Per-pair yield says otherwise:
| pair group | pairs | seq / mesh |
|---|---|---|
| same sync domain | 7 | 5.82x |
| spans the two domains | 8 | 6.24x |
| involves a never-synced node | 21 | **5.16x** |
Cross-domain is marginally best, but the never-synced nodes are the *lowest* —
the opposite of the prediction — and within-group spread (3.6x–8.1x) dwarfs
every between-group difference.
So `seq` is **not** a workaround for broken time sync. It wins roughly
uniformly, including where sync is healthy. The useful consequence is negative:
repairing our mesh split would probably not have closed the gap, and we would
have spent that effort for nothing.
## What we suggest
Not "use `seq`". **Make it selectable and measure your own environment.** The
right answer depends on your transmitter population, your node placement and
what consumes your CSI stream — none of which we can see from here.
`run_arms.py` runs the factorial unattended: it applies each arm fleet-wide,
verifies every node adopted it, samples `/api/v1/fusion`, and writes one row
per arm. Run the null pass first; without your own drift floor the contrasts
cannot be judged.
## Limits
- One environment, nine nodes. Treat the numbers as an existence proof, not a
constant.
- Only period 8 was characterised. 4 and 16 are unmeasured, so the
yield/pairing curve has a single point on it.
- `paired_fraction` measures **frame pairing**, which is a precondition for
cross-receiver work. Nothing here measures sensing accuracy, and none of it
should be read as an accuracy claim.
+36
View File
@@ -81,6 +81,42 @@ v1, with its flat loss, was barely better than random on this same test.
params, trained with InfoNCE (temperature 0.1, in-batch + temporal-far negatives), AdamW, 60 epochs.
Temporal positives within 2 s; negatives >30 s apart. Time-disjoint 80/20 split.
**EWC (catastrophic-forgetting protection):** measured forgetting rate on the 4 protected
tasks is **32.97%** — real and non-trivial, disclosed here rather than only in the raw
`training-metrics.json`. Fisher information is computed and applied (protection strength
2000), but this is not "solved forgetting" — a user fine-tuning per-room LoRA adapters
should expect some drift on earlier tasks, not none.
## 🔧 v2.0.1 — fixed the published training-metrics.json itself (2026-09-09)
Separately from the v1→v2 accuracy retraction above, an unrelated bug was found and fixed
in the **export code** (`scripts/train-ruvllm.js`) that produces `config.json` and
`training-metrics.json` for this file set (`model.safetensors`, `model-q2/q4/q8.bin`,
`presence-head.json`, `node-1/2.json`) — separate from the `csi-embed-v2*` files, which
come from a different script.
The exported `lossHistory` and `contrastive.durationMs` were being read from a disconnected
baseline pass (`ContrastiveTrainer.train()`, which computes loss but never updates the
encoder) instead of from the loop that actually performs gradient updates on the encoder
weights. Concretely: `lossHistory` published a flat, non-decreasing curve around `0.1352`
for all 20 epochs, and `durationMs` reported ~12.4s — both measuring the wrong computation.
**This did not affect `finalLoss`/`improvement`/the 82.3% held-out temporal-triplet
accuracy above** — those were already computed from the real training loop and are
unchanged by this fix. It only affected the exported diagnostic curve and duration, which
anyone auditing convergence from the published JSON would have been misled by. Re-run
against the same real capture (`data/recordings/overnight-1775217646.csi.jsonl`) with the
fix applied: `lossHistory` now shows a genuine decreasing curve (0.1037 -> 0.0654 across 20
epochs) and `durationMs` reports the real ~40s gradient-loop time. `config.json`'s
`training.steps` field (`12212300` — epochs × triplets, arithmetically consistent but read
like optimizer-step count) is now reported as separate `epochs`/`tripletUpdatesPerEpoch`
fields instead.
Repro: `node scripts/train-ruvllm.js --data data/recordings/overnight-1775217646.csi.jsonl
--output <dir> --epochs 20` in [github.com/ruvnet/RuView](https://github.com/ruvnet/RuView)
(the `aether-arena/staging/train_csi_embed.py` path cited above is for the separate
`csi-embed-v2*` files and was not touched by this fix).
### v2 files & proof
| File | Size | Use |
|------|------|-----|
@@ -0,0 +1,616 @@
# RuView Forecast model card template
> **Template only. Do not publish or treat this file as a model release.** Copy
> it for one immutable candidate, replace every `<REQUIRED>` field, and retain
> every unresolved item as an explicit blocker. Deleting a placeholder does not
> satisfy it.
This template implements the model-card requirements in
[ADR-348](../adr/ADR-348-independent-rust-multivariate-forecasting.md) and the
[RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md).
It does not imply that source, training, evaluation, clean-room, security,
privacy, trademark, patent, or production gates have passed.
## Completion rules
1. Create one card per exact weight digest. Do not reuse a card across retrains.
2. Label every numeric claim `MEASURED`, `SYNTHETIC`, `CLAIMED`, or
`UNMEASURED` under the repository evidence policy.
3. A `MEASURED` value needs an immutable dataset/split, artifact digest,
configuration, hardware/environment, metric definition, and reproducer.
4. Use `UNMEASURED` when evidence does not exist. Do not substitute an estimate.
5. State source-code, weight, dataset, and service terms separately.
6. Do not publish raw CSI, customer data, precise room coordinates, identities,
secrets, private contracts, or private contributor records in this card.
7. A completed card documents evidence; it cannot waive an ADR-348 gate.
## Suggested Hugging Face metadata
Copy and complete this front matter in the release card:
```yaml
---
license: <REQUIRED exact weight-license identifier>
tags:
- time-series-forecasting
- multivariate-forecasting
- wifi-sensing
- rust
- ruvector
- probabilistic-forecasting
- edge-ai
language:
- en
library_name: burn
pipeline_tag: time-series-forecasting
---
```
`license: other` is acceptable only when the card links the complete exact
license. Do not infer the weight license from the Rust source license.
## Candidate identity
Replace the template title with `<REQUIRED model name and version>` in the
release copy.
## Release decision and evidence status
| Field | Required value |
|---|---|
| Release candidate ID | `<REQUIRED>` |
| Weight SHA-256 | `<REQUIRED>` |
| Git commit | `<REQUIRED>` |
| Model-card SHA-256 | `<REQUIRED after finalization>` |
| Proposed mode | `OFFLINE_EVAL`, `SHADOW`, or `ADVISORY`; `<REQUIRED>` |
| ADR-348 highest gate passed | `G0` through `G5`, or `NONE`; `<REQUIRED>` |
| Accuracy status | `UNMEASURED` until a qualifying report exists; `<REQUIRED>` |
| Calibration status | `UNMEASURED` until a qualifying report exists; `<REQUIRED>` |
| CPU latency status | `UNMEASURED` until a named CPU reproducer exists; `<REQUIRED>` |
| Memory status | `UNMEASURED` until a named runtime reproducer exists; `<REQUIRED>` |
| Cross-site generalization status | `UNMEASURED` until untouched site holdouts exist; `<REQUIRED>` |
| Clean-room status | `OPEN`, `PASS`, or `FAIL`; `<REQUIRED>` |
| Production approval | `NOT APPROVED` unless all G5 receipts are signed; `<REQUIRED>` |
### Release summary
`<REQUIRED: State what this exact artifact does, what evidence exists, which
mode is requested, and the most important unresolved limitation in no more
than 150 words. Do not copy competitor marketing language.>`
## Model details
| Field | Value |
|---|---|
| Developer/owner | `<REQUIRED>` |
| Model family | `RuView Forecast` |
| Model version | `<REQUIRED immutable version>` |
| Artifact format | `<REQUIRED, including RVF/safetensors/version>` |
| Parameter count | `<REQUIRED or UNMEASURED>` |
| Numeric precision | `<REQUIRED>` |
| Architecture/config digest | `<REQUIRED>` |
| Input schema version | `<REQUIRED>` |
| Output schema version | `<REQUIRED>` |
| Maximum context | `<REQUIRED with cadence and units>` |
| Supported horizons | `<REQUIRED with cadence and units>` |
| Declared quantiles | `<REQUIRED>` |
| Training framework | `<REQUIRED exact Rust crate/features/version>` |
| Inference runtime | `<REQUIRED exact Rust crate/features/version>` |
| Source license | `<REQUIRED SPDX expression>` |
| Weight license | `<REQUIRED exact license and URL>` |
### Rust implementation boundary
Describe the exact code surfaces used by this candidate:
| Crate or binary | Version/digest | Responsibility | Enabled features |
|---|---|---|---|
| `ruview-forecast-core` | `<REQUIRED>` | Backend-neutral schemas, invariants, metrics, receipts, and `Forecaster` trait | `<REQUIRED; expected default only>` |
| `ruview-forecast-model` | `<REQUIRED>` | Independent Burn 0.21 patch mixer and artifact execution | `<REQUIRED; CPU/CUDA/WGPU are opt-in and default off>` |
| `ruview-forecast-train` / `ruforecast` | `<REQUIRED>` | Dataset splits, trainer, evaluator, training receipt, and Linux/fal.ai assets | `<REQUIRED>` |
| `<optional service/runtime>` | `<REQUIRED or NONE>` | `<REQUIRED>` | `<REQUIRED>` |
State whether inference is offline and whether any runtime network capability
exists. The expected production answer is no runtime model download and no
network requirement:
`<REQUIRED>`
State whether a sensing-server bridge exists. For the initial ADR-348 PR the
required answer is `NO: contracts/training only; a separately reviewed shadow
bridge is deferred`:
`<REQUIRED>`
## Intended use
### Approved candidate use
`<REQUIRED: identify the exact RuView feature streams, deployment class,
forecast horizons, mode, users, and decision support purpose.>`
### Out-of-scope and prohibited use
This artifact must not be used unless a later approved card explicitly changes
the boundary:
- as a sensor observation or ground-truth label;
- as the sole source for medical, emergency, fall-response, industrial-safety,
access-control, policing, insurance, employment, or autonomous-actuation
decisions;
- to infer identity or protected characteristics;
- outside the feature schema, cadence, hardware, site, population, and horizon
validated by this card;
- after calibration, feature schema, source provenance, or OOD checks fail;
- to train another model unless the weight, dataset, and output licenses and a
new provenance review explicitly permit it;
- to claim TimesFM compatibility, affiliation, endorsement, or equivalence.
Add candidate-specific exclusions:
`<REQUIRED>`
## Functional architecture
Describe independently implemented components without reproducing external
source expression, diagrams, identifiers, or constants:
```text
<REQUIRED: compact original diagram of feature windows, optional split-safe
retrieval, normalization, temporal/cross-stream model, quantile heads,
abstention, receipt, and policy boundary>
```
| Component | Candidate implementation | Security/resource bound |
|---|---|---|
| Feature validation | `<REQUIRED>` | `<REQUIRED>` |
| Normalization | `<REQUIRED>` | `<REQUIRED>` |
| Temporal encoding/mixing | `<REQUIRED>` | `<REQUIRED>` |
| Cross-stream fusion | `<REQUIRED>` | `<REQUIRED>` |
| Missing-data handling | `<REQUIRED>` | `<REQUIRED>` |
| Point/quantile head | `<REQUIRED>` | `<REQUIRED>` |
| Quantile crossing policy | `<REQUIRED>` | `<REQUIRED>` |
| OOD/abstention | `<REQUIRED>` | `<REQUIRED>` |
| RuVector retrieval | `<REQUIRED or NONE>` | `<REQUIRED>` |
| Artifact verification | `<REQUIRED>` | `<REQUIRED>` |
## Input contract
### Feature schema
| Field | Unit | Cadence/aggregation | Range | Missing/invalid semantics | Source provenance |
|---|---|---|---|---|---|
| `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
State:
- minimum and maximum context length;
- maximum targets, covariates, horizon, and batch;
- timestamp monotonicity and duplicate policy;
- whether any future-known covariate is permitted and how its authority is
verified;
- finite/range checks and allocation limits;
- minimum validity coverage before abstention;
- treatment of cadence gaps, resets, device changes, and calibration changes;
- OOD input behaviour.
`<REQUIRED>`
### Data not accepted
`<REQUIRED: include raw/unbounded payloads, unknown schema, non-finite values,
untrusted future covariates, stale data, unsupported cadence, and candidate
specific exclusions.>`
## Output contract
| Output | Shape/unit | Meaning | Evidence class |
|---|---|---|---|
| Point forecast | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_FORECAST` |
| Quantile forecast | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_FORECAST` |
| Validity mask | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
| Abstention/disposition | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
| OOD score/state | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
| Receipt/provenance | `<REQUIRED>` | `<REQUIRED>` | `SIGNED_METADATA` if signed |
The output must bind model, configuration, input schema, calibration, source
time range, optional retrieval index, and content hashes. Explain whether and
how quantile crossing is corrected:
`<REQUIRED>`
## RuVector retrieval
| Field | Value |
|---|---|
| Enabled | `<REQUIRED true/false>` |
| Index artifact/version/digest | `<REQUIRED or NONE>` |
| Allowed retrieval corpus | `<REQUIRED>` |
| Split-isolation receipt | `<REQUIRED>` |
| Neighbour exclusion rules | `<REQUIRED>` |
| Maximum neighbours/search budget | `<REQUIRED>` |
| Behaviour when index is absent/stale | `<REQUIRED>` |
Report identical-dataset ablations for no retrieval and retrieval. If these do
not exist, state `UNMEASURED`.
## RuVLLM integration
State whether RuVLLM consumes this artifact's signed forecast records. It may
explain results but must not rewrite point/quantile values or acquire action
authority from prose.
| Control | Evidence |
|---|---|
| Numeric outputs originate only from signed forecast record | `<REQUIRED or NOT INTEGRATED>` |
| Explanation cites artifact and forecast receipt | `<REQUIRED or NOT INTEGRATED>` |
| No model promotion, spending, actuation, or safety-critical authority | `<REQUIRED or NOT INTEGRATED>` |
| Prompt/data retention and tenant policy | `<REQUIRED or NOT INTEGRATED>` |
## Independent-development record
| Record | Digest/approval |
|---|---|
| Approved source allowlist | `<REQUIRED>` |
| Specification digest | `<REQUIRED>` |
| Contributor exposure manifest | `<REQUIRED>` |
| Contributor attestation bundle | `<REQUIRED>` |
| AI-tool prompt/session manifest | `<REQUIRED>` |
| Prohibited-artifact scan | `<REQUIRED>` |
| Source-similarity review | `<REQUIRED>` |
| Clean-room custodian approval | `<REQUIRED>` |
| Legal release approval | `<REQUIRED for production>` |
Declaration:
`<REQUIRED: State exactly what was independently authored and trained. Do not
state that clean room eliminates patent, trademark, dataset, privacy, or
jurisdiction risk.>`
Prior exposure disclosures and dispositions, without private details:
`<REQUIRED or NONE>`
## Training data
Do not list a dataset until its licensing/privacy gate passes.
| Dataset ID/version | Role | Origin/owner | License or contract ID | Commercial ML | Privacy class | Records/windows | Source/transform/split digests |
|---|---|---|---|---|---|---:|---|
| `<REQUIRED>` | train/validation/calibration | `<REQUIRED>` | `<REQUIRED>` | `YES` required | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
### Excluded data
Explicitly state that TimesFM 3 weights, outputs, activations, generated labels,
and derivatives were excluded, then list all other exclusions:
`<REQUIRED>`
### Collection, consent, minimization, and retention
`<REQUIRED: collection authority, purpose, consent/contract, controller and
processor, sensitive inferences, pseudonymization, fields removed, geographic
scope, retention, deletion, access, and incident contact.>`
### Synthetic data
| Generator/version | Seed-data rights | Provider/output terms | Seeds/config digest | Proportion | Use |
|---|---|---|---|---:|---|
| `<REQUIRED or NONE>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
## Split and leakage protocol
| Split | Sites | Subjects | Sessions | Devices | Time blocks | Windows | Manifest digest |
|---|---:|---:|---:|---:|---:|---:|---|
| Train | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Validation | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Calibration | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Test | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
Required leakage checks:
- parent raw record and derived-window overlap;
- contiguous sequence/session overlap;
- subject/site/device/calibration leakage;
- target-horizon overlap;
- fitted preprocessing or threshold leakage;
- RuVector neighbour/index leakage;
- duplicate and near-duplicate leakage;
- test-informed architecture or hyperparameter changes.
Report and digest:
`<REQUIRED>`
## Training procedure
| Field | Value |
|---|---|
| Random initialization | `true` required for this model family |
| Parent checkpoint | `NONE` or approved RuView artifact and digest |
| Source commit | `<REQUIRED>` |
| Cargo.lock digest | `<REQUIRED>` |
| Rust toolchain | `<REQUIRED exact>` |
| Build/training container | `<REQUIRED digest>` |
| Training config digest | `<REQUIRED>` |
| Seeds | `<REQUIRED>` |
| Optimizer/schedule/loss | `<REQUIRED>` |
| Batch/epochs/stopping | `<REQUIRED>` |
| Hardware | `<REQUIRED exact CPU/GPU/RAM>` |
| Provider | `<REQUIRED local Linux or approved provider>` |
| Provider job ID | `<REQUIRED non-secret identifier>` |
| Wall time | `<REQUIRED and evidence label>` |
| Peak host/GPU memory | `<REQUIRED and evidence label>` |
| Estimated/actual cost | `<REQUIRED and evidence label>` |
| Emitted checkpoint digests | `<REQUIRED>` |
| Training receipt digest | `<REQUIRED>` |
If local and hosted runs are compared, list numerical-determinism differences
and prove that governed code, data, config, and parent identities match:
`<REQUIRED or NOT APPLICABLE>`
## Evaluation
### Baselines and ablations
Every row uses the same frozen examples and metric implementation.
| Model | Artifact/version | Retrieval | Parameters | Evidence label | Notes |
|---|---|---|---:|---|---|
| Last value | `<REQUIRED>` | no | n/a | `<REQUIRED>` | deterministic baseline |
| Seasonal naive | `<REQUIRED>` | no | n/a | `<REQUIRED>` | `<REQUIRED period>` |
| Small classical/recurrent baseline | `<REQUIRED>` | no | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| RuView Forecast | `<REQUIRED>` | no | `<REQUIRED>` | `<REQUIRED>` | required ablation |
| RuView Forecast | `<REQUIRED>` | yes | `<REQUIRED>` | `<REQUIRED>` | required if retrieval is enabled |
Do not use TimesFM 3 as an implementation oracle, label source, tuning signal,
or required acceptance baseline.
### Forecast metrics
Report per horizon and per site/device/interference regime, plus pooled values.
| Metric/domain/horizon | Result | 95% interval | Evidence label | Reproducer |
|---|---:|---:|---|---|
| Weighted quantile loss | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| MAE or scaled error | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Nominal 80% interval coverage | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Interval width | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Quantile crossing before/after policy | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Abstention coverage/selective risk | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
ADR-348 G3 targets are at least 10% weighted-quantile-loss improvement over
seasonal naive and 75%-85% measured coverage for a nominal 80% interval. These
remain `UNMEASURED targets` until populated by qualifying evidence.
### RuView shadow outcomes
| Metric | Baseline | Candidate | Delta/CI | Evidence label | Reproducer |
|---|---:|---:|---:|---|---|
| Empty-room false alerts | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Occupied-room recall | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Abstention by deployment | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Drift/OOD rate | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
ADR-348 G4 targets at least 50% relative empty-room false-alert reduction with
no more than 2 percentage points occupied-room recall loss over at least 14
shadow days. These are `UNMEASURED targets`, not current capabilities.
### Runtime measurements
| Platform | Build/features | Batch/streams | Context/horizon | p50/p95/p99 | Peak RSS | Evidence label | Reproducer |
|---|---|---:|---|---|---:|---|---|
| `<REQUIRED named CPU>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
| `<optional GPU>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
ADR-348 G5 CPU targets are at most 1 second p95 for 32 declared streams and at
most 4 GiB peak process memory. They are `UNMEASURED targets` until this table
contains a qualifying named-platform reproducer.
## Calibration, OOD, and abstention
| Control | Fit data | Frozen parameters/digest | Test result | Evidence label |
|---|---|---|---|---|
| Input normalization | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Quantile calibration | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| OOD threshold | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Minimum validity/context | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Drift threshold | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
List all typed abstention reasons and demonstrate that unknown schema, missing
calibration, stale data, insufficient history, invalid masks, non-finite input,
OOD state, and artifact failure do not produce an authoritative forecast:
`<REQUIRED>`
## Robustness and failure analysis
Report at minimum:
- unseen site and unseen device;
- empty room and low-motion occupancy;
- burst loss, cadence change, clock reset, and sensor restart;
- interference, channel change, and calibration drift;
- long missing runs and adversarial non-finite/range inputs;
- corrupted/truncated/oversized model and input artifacts;
- absent or stale RuVector index;
- concurrent-load resource caps and timeout;
- false confidence from narrow intervals during distribution shift.
| Scenario | Expected safe behaviour | Observed result | Evidence label | Open risk/owner |
|---|---|---|---|---|
| `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
## Security and privacy
### Threat model summary
| Threat | Control | Evidence | Residual risk/owner |
|---|---|---|---|
| Malicious/corrupt model artifact | Signature, digest, schema and size verification; no embedded code | `<REQUIRED>` | `<REQUIRED>` |
| Oversized/malformed request | Dimension, allocation, horizon, context, batch and deadline caps | `<REQUIRED>` | `<REQUIRED>` |
| Poisoned data or split leakage | Approved manifests, immutable transforms, lineage and overlap checks | `<REQUIRED>` | `<REQUIRED>` |
| Cross-tenant analogue retrieval | Tenant and split-scoped RuVector indexes | `<REQUIRED>` | `<REQUIRED>` |
| Routine/location inference | Minimization, purpose, retention, deletion, access and audit | `<REQUIRED>` | `<REQUIRED>` |
| Model extraction/membership inference | Rate/access control and privacy evaluation | `<REQUIRED>` | `<REQUIRED>` |
| Hosted-worker data retention | Approved provider terms, least data, deletion receipt | `<REQUIRED>` | `<REQUIRED>` |
| LLM numeric mutation or overclaim | Signed numeric record and capability policy | `<REQUIRED or NOT INTEGRATED>` | `<REQUIRED>` |
### Data handling
`<REQUIRED: tenant isolation, encryption, keys, access roles, logs, metrics
allowlist, retention, deletion, export, incident handling, and whether feature
or forecast persistence is enabled.>`
## Deployment
| Field | Value |
|---|---|
| Supported mode | `<REQUIRED>` |
| Supported platform/CPU/GPU | `<REQUIRED>` |
| Minimum RAM/storage | `<REQUIRED and evidence-labelled>` |
| Offline artifact source | `<REQUIRED>` |
| Signature/trust root | `<REQUIRED public key ID>` |
| Configuration/calibration artifact | `<REQUIRED digest>` |
| RuVector index requirement | `<REQUIRED>` |
| Startup self-test | `<REQUIRED>` |
| Health/drift metrics | `<REQUIRED allowlisted fields>` |
| Timeout/backpressure policy | `<REQUIRED>` |
| Rollback artifact/mode | `<REQUIRED>` |
No deployment may fetch an unpinned model at runtime. Describe exact startup
failure and fail-closed behaviour:
`<REQUIRED>`
## Monitoring, rollback, and retirement
| Signal | Threshold | Window | Response | Owner |
|---|---:|---|---|---|
| Calibration/coverage drift | `<REQUIRED>` | `<REQUIRED>` | shadow/off | `<REQUIRED>` |
| OOD/abstention rate | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Latency/resource regression | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Empty/occupied outcome regression | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Provenance/signature failure | any | immediate | disable artifact | `<REQUIRED>` |
| Security/privacy incident | any material incident | immediate | isolate, preserve evidence, disable | `<REQUIRED>` |
Rollback drill receipt and result:
`<REQUIRED>`
Retirement and data/index/checkpoint deletion policy:
`<REQUIRED>`
## Limitations and largest uncertainty
`<REQUIRED: List observed and unmeasured limitations. The default largest
uncertainty is whether training diversity supports unseen-room and unseen-device
generalization without suppressing legitimate occupied states. State the
specific data/evaluation fix path.>`
## Cost, energy, and operational burden
| Item | Value | Evidence label | Method |
|---|---:|---|---|
| Training accelerator hours | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
| Training cost | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
| Training energy/emissions | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
| CPU inference cost/energy | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
| Storage/index overhead | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
| Operator/calibration burden | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
## Licenses and notices
| Surface | Exact license/terms | URL/file | Obligations | Approval |
|---|---|---|---|---|
| Rust source | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Weights | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Each dataset | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Dependencies/SBOM | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Training provider | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
Trademark clearance ID and scope:
`<REQUIRED for public production release>`
Patent freedom-to-operate disposition and launch jurisdictions:
`<REQUIRED for production; keep privileged analysis outside this public card>`
## Provenance and reproducibility
| Artifact | Digest/signature/location |
|---|---|
| Source allowlist | `<REQUIRED>` |
| Specification | `<REQUIRED>` |
| Source commit | `<REQUIRED>` |
| Cargo.lock | `<REQUIRED>` |
| SBOM | `<REQUIRED>` |
| Dataset manifest | `<REQUIRED>` |
| Split/leakage report | `<REQUIRED>` |
| Training config | `<REQUIRED>` |
| Training receipt | `<REQUIRED>` |
| Evaluation report | `<REQUIRED>` |
| Clean-room report | `<REQUIRED>` |
| Model weights | `<REQUIRED>` |
| RVF signature | `<REQUIRED or NONE with blocker>` |
| Reproducer | `<REQUIRED>` |
Reproduction commands must use pinned artifacts and must not contain secrets or
download restricted material:
```bash
<REQUIRED safe, exact commands>
```
## ADR-348 gate traceability
| Gate | Requirements | Evidence in this card/bundle | Result |
|---|---|---|---|
| G0 independent-authoring boundary | RF-001, RF-002 | allowlist, exposure, attestation, scans, similarity review | `<OPEN, PASS, or FAIL>` |
| G1 data and lineage | RF-002, RF-003, RF-010 | dataset approvals, lineage, local/hosted receipts | `<OPEN, PASS, or FAIL>` |
| G2 bounded Rust contract | RF-004, RF-005, RF-006 | contract/fuzz/replay/resource/dependency reports | `<OPEN, PASS, or FAIL>` |
| G3 leakage-free model evidence | RF-007, RF-008, RF-011 | frozen split, baselines, ablations, calibration, reproducers | `<OPEN, PASS, or FAIL>` |
| G4 RuView shadow value | RF-006, RF-007, RF-008, RF-009 | 14-day outcomes, drift, abstention, no action authority | `<OPEN, PASS, or FAIL>` |
| G5 deployment fitness | RF-004, RF-009, RF-010, RF-011, RF-012 | runtime, approvals, signed bundle, rollback | `<OPEN, PASS, or FAIL>` |
Highest permitted deployment mode from these results:
`<REQUIRED>`
## Approvals
Signatures cover this exact model-card digest and weight digest.
| Role | Approver/receipt | Decision | Timestamp |
|---|---|---|---|
| Model owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Clean-room custodian | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Data steward/privacy | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Security owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Runtime owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
| Legal release owner | `<REQUIRED for production>` | `<REQUIRED>` | `<REQUIRED>` |
## Citation
Use the final public model name, exact version, weight digest, repository commit,
and model-card URL:
```bibtex
@software{<REQUIRED citation key>,
title = {<REQUIRED>},
author = {<REQUIRED>},
year = {<REQUIRED>},
version = {<REQUIRED>},
url = {<REQUIRED>},
note = {Weights SHA-256: <REQUIRED>; source commit: <REQUIRED>}
}
```
## Change history
| Card version | Weight digest | Change | Gate impact | Date |
|---|---|---|---|---|
| `<REQUIRED>` | `<REQUIRED>` | Initial candidate | All gates evaluated independently | `<REQUIRED>` |
+111
View File
@@ -0,0 +1,111 @@
# RuView ESP32 firmware 0.8.8
Firmware 0.8.8 is a reliability and correctness release for ESP32-S3 and
ESP32-C6 RuView nodes. It makes the timing used by signal processing explicit,
prevents contradictory occupancy output, and improves update diagnostics.
## What changed
### Empty means zero people
Older firmware could report `presence=false` and a nonzero person count in the
same edge packet. That was internally contradictory and could contaminate an
empty-room calibration. Firmware 0.8.8 clears the count whenever the presence
gate is closed. The sensing server repeats the same check when it receives data
from older nodes.
This is a consistency fix, not proof that the heuristic can count multiple
people accurately. See
[ADR 346](../adr/ADR-346-fail-closed-edge-occupancy-evidence.md).
### Stable time scale on ESP32-C6
Raw CSI and on-device signal processing now have separate clocks. The C6 keeps
raw CSI moving over the network while its Tier 2 filters process a stable 8 Hz
sample stream. The S3 retains its 20 Hz DSP default. A phase-preserving sampler
keeps callback jitter from shifting those clocks.
The result is a correct time base for motion and vital-band features. It does
not by itself prove that heartbeat, respiration, gesture, or pose estimates are
more accurate. See
[ADR 347](../adr/ADR-347-rate-aware-esp32-temporal-sensing.md).
### Better diagnostics and safer updates
The one-second controller log now shows both raw callback yield and DSP rate.
The OTA status endpoint reports the actual selected application partition size
instead of a fixed 900 KB assumption. Firmware upload remains fail closed when
the node has no provisioned OTA signing secret.
## Measured hardware validation
All results below are physical measurements from 2026-08-31. They are not
simulator claims.
| Board | Duration | Raw CSI mean | DSP clock | Live coverage | Steady-state transport errors |
|-------|---------:|-------------:|----------:|--------------:|------------------------------:|
| ESP32-C6 node 4 | 300.64 s | 34.92 pps | 8.00 Hz | 97.62% | 0 |
| ESP32-C6 node 7 | 300.70 s | 36.32 pps | 8.00 Hz | 97.40% | 0 |
| ESP32-S3 node 1 | 300 s | 28.03 pps | Tier 0 | 100.00% | 0 |
The first C6 empty-room qualification observed 61 absent packets with zero
nonzero counts. The second C6 was transport-qualified in an occupied room and
still needs its own controlled empty-room sequence. Full evidence is recorded
in:
1. [C6 timing and transport](../validation/2026-08-31-esp32-c6-rate-aware-sensing.md)
2. [C6 occupancy integrity](../validation/2026-08-31-esp32-c6-occupancy-integrity.md)
3. [Second C6 timing and transport](../validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md)
4. [S3 transport](../validation/2026-08-31-esp32-s3-rate-aware-transport.md)
## Choose the correct download
| Release file | Target |
|--------------|--------|
| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | ESP32-S3 with 8 MB flash |
| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | ESP32-S3 with 4 MB flash |
| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | ESP32-C6 using the supported 4 MB partition layout |
| `esp32-csi-node-v0.8.8-s3-8mb.bin` | S3 8 MB application only |
| `esp32-csi-node-v0.8.8-s3-4mb.bin` | S3 4 MB application only |
| `esp32-csi-node-v0.8.8-c6-4mb.bin` | C6 application only |
Never mix S3 and C6 images. Confirm the chip and physical flash before writing.
## Install or update
For a fresh installation, extract the matching bundle and follow its included
`FLASHING.md`. The standard offsets are:
| Image | Offset |
|-------|-------:|
| Bootloader | `0x0000` |
| Partition table | `0x8000` |
| OTA metadata | `0xf000` |
| Application | `0x20000` |
For an existing provisioned node:
1. Back up the current application partition.
2. Confirm the exact chip, flash layout, logical node, and serial port.
3. Read `http://DEVICE_IP:8032/ota/status`.
4. Use an application-only serial update at `0x20000` only when the running
partition is `ota_0` and the image matches the board.
5. Reboot and confirm version 0.8.8, the preserved node identity, channel, and
sensing-server target.
6. Run a five-minute burn-in before returning the node to calibration duty.
The full bundle does not contain an NVS image. A four-offset install therefore
preserves the existing WiFi and node settings, but operators should still keep
a backup before changing firmware.
## What this release does not prove
Firmware 0.8.8 does not prove medical-grade vital signs, accurate person
counting, identity, dense pose, through-wall video, or room separation. Those
claims require synchronized references and leakage-free held-out sequences.
The practical next acceptance test is a controlled empty-room capture with at
least 30 absent edge packets per updated node, zero absent packets carrying a
nonzero count, and zero transport or parser errors. Accuracy evaluation then
needs held-out occupied, movement, heartbeat-reference, and adjacent-room
sequences.
+10
View File
@@ -0,0 +1,10 @@
ARM_A1_control,30,1242,2162,0.5745,72.07
ARM_A1_control,30,1199,2114,0.5672,70.47
ARM_A1_control,30,1259,2223,0.5664,74.10
ARM_A1_control,30,1221,2206,0.5535,73.53
ARM_A1_control,30,1246,2182,0.5710,72.73
ARM_A1_control,30,1295,2247,0.5763,74.90
ARM_A1_control,30,1307,2242,0.5830,74.73
ARM_A1_control,30,1232,2191,0.5623,73.03
ARM_A1_control,30,1261,2225,0.5667,74.17
ARM_A1_control,30,1152,2107,0.5467,70.23
1 ARM_A1_control 30 1242 2162 0.5745 72.07
2 ARM_A1_control 30 1199 2114 0.5672 70.47
3 ARM_A1_control 30 1259 2223 0.5664 74.10
4 ARM_A1_control 30 1221 2206 0.5535 73.53
5 ARM_A1_control 30 1246 2182 0.5710 72.73
6 ARM_A1_control 30 1295 2247 0.5763 74.90
7 ARM_A1_control 30 1307 2242 0.5830 74.73
8 ARM_A1_control 30 1232 2191 0.5623 73.03
9 ARM_A1_control 30 1261 2225 0.5667 74.17
10 ARM_A1_control 30 1152 2107 0.5467 70.23
+10
View File
@@ -0,0 +1,10 @@
ARM_A2_control,30,1352,2326,0.5813,77.53
ARM_A2_control,30,1454,2429,0.5986,80.97
ARM_A2_control,30,1474,2479,0.5946,82.63
ARM_A2_control,30,1412,2456,0.5749,81.87
ARM_A2_control,30,1387,2443,0.5677,81.43
ARM_A2_control,30,1389,2384,0.5826,79.47
ARM_A2_control,30,1399,2482,0.5637,82.73
ARM_A2_control,30,1410,2375,0.5937,79.17
ARM_A2_control,30,1509,2537,0.5948,84.57
ARM_A2_control,30,1483,2541,0.5836,84.70
1 ARM_A2_control 30 1352 2326 0.5813 77.53
2 ARM_A2_control 30 1454 2429 0.5986 80.97
3 ARM_A2_control 30 1474 2479 0.5946 82.63
4 ARM_A2_control 30 1412 2456 0.5749 81.87
5 ARM_A2_control 30 1387 2443 0.5677 81.43
6 ARM_A2_control 30 1389 2384 0.5826 79.47
7 ARM_A2_control 30 1399 2482 0.5637 82.73
8 ARM_A2_control 30 1410 2375 0.5937 79.17
9 ARM_A2_control 30 1509 2537 0.5948 84.57
10 ARM_A2_control 30 1483 2541 0.5836 84.70
+10
View File
@@ -0,0 +1,10 @@
ARM_B_filtered,30,734,1062,0.6911,35.40
ARM_B_filtered,30,700,982,0.7128,32.73
ARM_B_filtered,30,734,1002,0.7325,33.40
ARM_B_filtered,30,706,927,0.7616,30.90
ARM_B_filtered,30,730,955,0.7644,31.83
ARM_B_filtered,30,712,915,0.7781,30.50
ARM_B_filtered,30,734,971,0.7559,32.37
ARM_B_filtered,30,742,1103,0.6727,36.77
ARM_B_filtered,30,812,1139,0.7129,37.97
ARM_B_filtered,30,785,1114,0.7047,37.13
1 ARM_B_filtered 30 734 1062 0.6911 35.40
2 ARM_B_filtered 30 700 982 0.7128 32.73
3 ARM_B_filtered 30 734 1002 0.7325 33.40
4 ARM_B_filtered 30 706 927 0.7616 30.90
5 ARM_B_filtered 30 730 955 0.7644 31.83
6 ARM_B_filtered 30 712 915 0.7781 30.50
7 ARM_B_filtered 30 734 971 0.7559 32.37
8 ARM_B_filtered 30 742 1103 0.6727 36.77
9 ARM_B_filtered 30 812 1139 0.7129 37.97
10 ARM_B_filtered 30 785 1114 0.7047 37.13
@@ -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.
@@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ruview.net/schemas/pose-observation-v2.schema.json",
"title": "PoseObservationV2",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "timestamp_ns", "sensor_epoch", "sequence", "track_id", "frame", "calibration_id", "floor_plane", "model", "source", "trust_state", "dimensionality", "uncertainty_calibrated", "joints", "observer_confidence", "canonical_hash"],
"properties": {
"schema_version": { "const": 2 },
"timestamp_ns": { "type": "integer", "minimum": 0 },
"sensor_epoch": { "type": "integer", "minimum": 0 },
"sequence": { "type": "integer", "minimum": 0 },
"track_id": { "$ref": "#/$defs/string_id" },
"frame": { "$ref": "#/$defs/frame" },
"calibration_id": { "$ref": "#/$defs/string_id" },
"floor_plane": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/floor" }] },
"model": { "$ref": "#/$defs/model" },
"source": { "$ref": "#/$defs/source" },
"trust_state": { "enum": ["KNOWN", "DEGRADED", "UNKNOWN"] },
"dimensionality": { "enum": ["image2d", "metric3d"] },
"uncertainty_calibrated": { "type": "boolean" },
"joints": { "type": "array", "minItems": 17, "maxItems": 17, "items": { "$ref": "#/$defs/joint" } },
"observer_confidence": { "$ref": "#/$defs/probability" },
"canonical_hash": { "$ref": "#/$defs/hash" }
},
"$defs": {
"probability": { "type": "number", "minimum": 0, "maximum": 1 },
"hash": { "type": "array", "minItems": 32, "maxItems": 32, "items": { "type": "integer", "minimum": 0, "maximum": 255 } },
"string_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"vec3": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } },
"frame": { "type": "object", "additionalProperties": false, "required": ["name", "version", "metric", "right_handed", "z_up"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "version": { "type": "integer", "minimum": 1 }, "metric": { "type": "boolean" }, "right_handed": { "type": "boolean" }, "z_up": { "type": "boolean" } } },
"floor": { "type": "object", "additionalProperties": false, "required": ["normal", "offset_m"], "properties": { "normal": { "$ref": "#/$defs/vec3" }, "offset_m": { "type": "number" } } },
"model": { "type": "object", "additionalProperties": false, "required": ["id", "artifact_hash"], "properties": { "id": { "type": "string", "minLength": 1, "maxLength": 128 }, "artifact_hash": { "$ref": "#/$defs/hash" } } },
"source": { "type": "object", "additionalProperties": false, "required": ["sensor_id", "authenticated", "replay_protected"], "properties": { "sensor_id": { "type": "string", "minLength": 1, "maxLength": 128 }, "authenticated": { "type": "boolean" }, "replay_protected": { "type": "boolean" } } },
"covariance": { "type": "object", "additionalProperties": false, "required": ["xx", "xy", "xz", "yy", "yz", "zz"], "properties": { "xx": { "type": "number", "minimum": 0 }, "xy": { "type": "number" }, "xz": { "type": "number" }, "yy": { "type": "number", "minimum": 0 }, "yz": { "type": "number" }, "zz": { "type": "number", "minimum": 0 } } },
"joint": { "type": "object", "additionalProperties": false, "required": ["kind", "position_m", "covariance_m2", "confidence", "visibility"], "properties": { "kind": { "enum": ["nose", "left_eye", "right_eye", "left_ear", "right_ear", "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", "left_wrist", "right_wrist", "left_hip", "right_hip", "left_knee", "right_knee", "left_ankle", "right_ankle"] }, "position_m": { "$ref": "#/$defs/vec3" }, "covariance_m2": { "$ref": "#/$defs/covariance" }, "confidence": { "$ref": "#/$defs/probability" }, "visibility": { "enum": ["visible", "occluded", "unknown"] } } }
}
}
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ruview.net/schemas/pose-refinement-v1.schema.json",
"title": "PoseRefinementV1",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "raw_observation_hash", "mode", "disposition", "selected", "refined_joints_m", "physics_confidence", "effective_confidence", "intervention", "residuals", "refined_residuals", "contact_hypotheses", "dynamics", "provenance", "reason", "canonical_hash"],
"properties": {
"schema_version": { "const": 1 },
"raw_observation_hash": { "$ref": "#/$defs/hash" },
"mode": { "enum": ["off", "audit", "shadow_correct", "opt_in_correct", "default_correct"] },
"disposition": { "enum": ["bypassed", "audited2d", "audited", "shadowed", "corrected", "abstained", "rejected"] },
"selected": { "type": "boolean" },
"refined_joints_m": { "oneOf": [{ "type": "null" }, { "type": "array", "minItems": 17, "maxItems": 17, "items": { "$ref": "#/$defs/vec3" } }] },
"physics_confidence": { "$ref": "#/$defs/probability" },
"effective_confidence": { "$ref": "#/$defs/probability" },
"intervention": { "$ref": "#/$defs/intervention" },
"residuals": { "$ref": "#/$defs/residuals" },
"refined_residuals": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/residuals" }] },
"contact_hypotheses": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/contact" } },
"dynamics": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/dynamics" }] },
"provenance": { "$ref": "#/$defs/provenance" },
"reason": { "enum": [null, "mode_off", "unsupported_schema", "hash_mismatch", "invalid_number", "invalid_covariance", "stale_input", "coordinate_frame_mismatch", "calibration_unavailable", "uncertainty_uncalibrated", "ood_unknown", "source_unauthenticated", "replay_protection_unavailable", "correction_not_authorized", "replay_rejected", "non_monotonic_input", "too_few_known_joints", "correction_too_large", "deadline_exceeded", "track_capacity", "internal_error"] },
"canonical_hash": { "$ref": "#/$defs/hash" }
},
"$defs": {
"probability": { "type": "number", "minimum": 0, "maximum": 1 },
"hash": { "type": "array", "minItems": 32, "maxItems": 32, "items": { "type": "integer", "minimum": 0, "maximum": 255 } },
"vec3": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } },
"intervention": { "type": "object", "additionalProperties": false, "required": ["max_joint_correction_m", "root_correction_m", "corrected_joint_count", "solver_iterations", "elapsed_us"], "properties": { "max_joint_correction_m": { "type": "number", "minimum": 0 }, "root_correction_m": { "type": "number", "minimum": 0 }, "corrected_joint_count": { "type": "integer", "minimum": 0, "maximum": 17 }, "solver_iterations": { "type": "integer", "minimum": 0, "maximum": 8 }, "elapsed_us": { "type": "integer", "minimum": 0 } } },
"residuals": { "type": "object", "additionalProperties": false, "required": ["bone_m", "joint_limit_rad", "velocity_mps", "acceleration_mps2", "temporal_jerk", "floor_penetration_m", "contact_m", "collision_m", "normalized_total"], "properties": { "bone_m": { "type": "number", "minimum": 0 }, "joint_limit_rad": { "type": "number", "minimum": 0 }, "velocity_mps": { "type": "number", "minimum": 0 }, "acceleration_mps2": { "type": "number", "minimum": 0 }, "temporal_jerk": { "type": "number", "minimum": 0 }, "floor_penetration_m": { "type": "number", "minimum": 0 }, "contact_m": { "type": "number", "minimum": 0 }, "collision_m": { "type": "number", "minimum": 0 }, "normalized_total": { "type": "number", "minimum": 0 } } },
"contact": { "type": "object", "additionalProperties": false, "required": ["state", "probability"], "properties": { "state": { "enum": ["hypothesis", "measured", "unknown"] }, "probability": { "$ref": "#/$defs/probability" } } },
"dynamics": { "type": "object", "additionalProperties": false, "required": ["stable", "segment_count", "joint_count", "contact_count", "substeps", "tracking_error_m", "joint_anchor_error_m", "floor_penetration_m", "control_effort"], "properties": { "stable": { "type": "boolean" }, "segment_count": { "type": "integer", "minimum": 0, "maximum": 255 }, "joint_count": { "type": "integer", "minimum": 0, "maximum": 255 }, "contact_count": { "type": "integer", "minimum": 0, "maximum": 65535 }, "substeps": { "type": "integer", "minimum": 1, "maximum": 8 }, "tracking_error_m": { "type": "number", "minimum": 0 }, "joint_anchor_error_m": { "type": "number", "minimum": 0 }, "floor_penetration_m": { "type": "number", "minimum": 0 }, "control_effort": { "type": "number", "minimum": 0 } } },
"provenance": { "type": "object", "additionalProperties": false, "required": ["engine", "engine_version", "config_hash", "rf_model_hash", "calibration_id", "learned_artifact_hash"], "properties": { "engine": { "type": "string", "minLength": 1, "maxLength": 128 }, "engine_version": { "type": "string", "minLength": 1, "maxLength": 64 }, "config_hash": { "$ref": "#/$defs/hash" }, "rf_model_hash": { "$ref": "#/$defs/hash" }, "calibration_id": { "type": "string", "minLength": 1, "maxLength": 128 }, "learned_artifact_hash": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/hash" }] } } }
}
}
+511
View File
@@ -0,0 +1,511 @@
# RuView Forecast clean-room, data, and release protocol
**Status:** Proposed and mandatory for any artifact claiming independent
development under ADR-348.
**Evidence status:** This document defines controls. It does not assert that the
controls have passed, that a trained model exists, or that any forecasting
capability is measured. Every ADR-348 gate remains **OPEN / UNMEASURED** until a
signed evidence bundle proves otherwise.
## 1. Purpose and authority
This protocol governs the specification, implementation, training, evaluation,
hosting, and release of the independent Rust multivariate forecaster defined by
[ADR-348](../adr/ADR-348-independent-rust-multivariate-forecasting.md).
It applies to source, issue and review text, prompts, dependencies, datasets,
intermediate tensors, checkpoints, containers, CI caches, RuVector indexes,
fal.ai jobs, benchmark results, model cards, and published artifacts.
The protocol is intentionally stricter than the minimum conditions for using
Apache-licensed source. Its purpose is to preserve evidence that the RuView
implementation and weights were independently created. It is not a legal
opinion. The legal release owner must approve the current licenses, contributor
exposure record, patent review, trademark review, datasets, and intended launch
jurisdictions before production.
## 2. License boundary as of 2026-09-01
The authoritative public materials state two different license surfaces:
1. The [Google TimesFM repository](https://github.com/google-research/timesfm)
identifies its source code as Apache-2.0.
2. The [TimesFM 3 checkpoint license](https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE)
permits non-commercial, non-production use and restricts commercial use,
distribution, and use to train, fine-tune, or distill another commercial
model.
The checkpoint license defines a derivative broadly enough that relying on its
logic, parameters, or model-generated material creates avoidable contractual
risk. No contributor may accept gated model terms on behalf of the project or
its owner without written authorization.
The [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) permits
source reproduction and derivative works subject to its conditions, including
license delivery, change notices, retention of applicable notices, and NOTICE
handling. Apache also provides a limited contributor patent grant and no general
trademark grant. If maintainers ever choose an Apache-derived port, it must use
a separate branch, attribution path, artifact name, and superseding ADR. It
must not be described as the clean-room implementation.
Publicly described ideas, procedures, processes, systems, and methods are not
the same as copied expression. United States copyright law states the
idea-expression boundary in [17 U.S.C. section 102(b)](https://www.copyright.gov/title17/92chap1.html).
That does not settle contract, patent, trademark, database-right, trade-secret,
or non-U.S. law questions.
## 3. Non-mixing rule
Only one of these paths may govern a source tree:
| Path | Source basis | Required representation |
|---|---|---|
| Independent implementation | Approved abstract specification, public papers/articles, general literature, RuView requirements, approved data | "Independently developed and independently trained" after all gates pass |
| Apache-derived port | Apache-licensed Google source with full license and notice compliance | "Apache-derived Rust port" with attribution |
There is no hybrid path. A permissive source license makes porting possible; it
does not make a port independent. If exposure cannot be bounded, maintainers
must either quarantine and rewrite the affected component or reclassify the
whole affected component under the Apache-derived path.
## 4. Roles and separation
| Role | May access | Must not access or do |
|---|---|---|
| Clean-room custodian | Exposure records, allowlist, scan reports, quarantined evidence | Contribute core model implementation after reviewing prohibited source fragments |
| Specification reviewer | Frozen public-paper/article allowlist, RuView requirements, generic literature | Google TimesFM implementation, tests, configs, checkpoint files, model outputs; contribute implementation |
| Rust implementer | Approved independent specification, RuView schemas, approved generic dependencies | TimesFM source, tests, configs, weights, outputs, line-by-line/API translation, reference differential testing |
| Data steward | Dataset source, contracts, consent, privacy and transformation records | Approve unknown or incompatible rights; infer that a software license covers data |
| Training operator | Signed code/container/config and approved dataset manifests | Change code/data/config outside a new receipt; add an unapproved parent checkpoint |
| Independent evaluator | Frozen candidate, frozen evaluation manifests, approved baselines | Return reference predictions or implementation hints to developers; tune against the test set |
| Legal release owner | Complete evidence bundle and private legal analysis | Treat clean-room checks as patent clearance or dataset approval by implication |
One person may hold multiple roles only where the forbidden-access rules remain
true. The strongest separation uses different people for public specification
and implementation. A specification reviewer who has inspected prohibited
source cannot become a core implementer for the independent path.
## 5. Source classification
### 5.1 Allowed
- The public TimesFM research articles and peer-reviewed papers, frozen by URL,
retrieval date, and SHA-256 digest.
- General time-series, transformer, probabilistic forecasting, normalization,
missing-data, and calibration literature.
- RuView requirements, independently written feature schemas, and existing
RuView contracts whose provenance already satisfies repository policy.
- Generic Rust dependencies whose license, source, and purpose are recorded and
approved.
- Public benchmark specifications and datasets after the dataset gate passes.
- Published aggregate competitor results used only as contextual background.
### 5.2 Conditional
- Code implementing generic algorithms may be used only after license review
and must be declared as a dependency or attributed source. It cannot be used
to claim that every line was independently authored.
- TimesFM versions up to 2.5 may have permissively licensed code or weights, but
they remain outside the independent implementation and training path. An
evaluator may use an approved permissive baseline only after the candidate
is frozen, and its output cannot become training data or implementation
feedback.
- A contributor with prior exposure to TimesFM code must disclose the exact
material and date. The custodian and legal owner decide whether the person is
limited to non-implementation work or the affected component must be
reclassified.
- Synthetic data is allowed only when source data, generator code/model,
provider terms, output rights, and intended commercial use all pass review.
### 5.3 Prohibited
- TimesFM 3 weights, parameters, checkpoints, tensor dumps, checkpoint
configuration, hidden activations, embeddings, gradients, or derived
fingerprints.
- TimesFM 3 forecasts, quantiles, scores, probabilities, recommendations, or
synthetic labels in development, training, tuning, or release evidence.
- Fine-tuning, distillation, imitation, teacher-student learning, differential
testing, behavioural cloning, or hyperparameter search against TimesFM 3.
- Manual, mechanical, or AI-assisted translation of Google source, tests,
comments, constants, APIs, file layout, or diagrams.
- Third-party code, datasets, or checkpoints whose origin may contain copied or
distilled TimesFM material without documented permission.
- Model, dataset, or service terms labelled non-commercial, research-only,
evaluation-only, no-derivatives, no-ML, no-production, or unknown.
- Confidential third-party information or material obtained under an agreement
that does not authorize this implementation and commercial use.
## 6. Exposure intake and contributor attestation
Before receiving implementation access, every contributor completes an
exposure intake covering:
- TimesFM repositories, model cards, gated checkpoint files, local caches,
notebooks, issues, tutorials, and generated outputs viewed or downloaded;
- whether gated terms were accepted and for which individual or entity;
- source or output material placed in an LLM, IDE assistant, retrieval index,
prompt, or code-generation session;
- prior work for Google, a competitor, customer, or other party involving
confidential forecasting implementation;
- datasets, pretrained models, and external code expected in the contribution.
Each pull request also carries a signed attestation:
```text
RuView Forecast clean-room attestation v1
Contributor:
Employer or represented entity:
Role:
Covered commits:
Prior TimesFM exposure: NONE or complete disclosure
Approved sources used:
Datasets introduced:
AI tools used:
Prompt/session manifest digest:
I attest that the covered contribution was created from the approved
specification and listed sources. I did not access, copy, translate, decompile,
query, distill, or use TimesFM 3 implementation material, configuration,
weights, parameters, outputs, or internal representations. I did not place
prohibited material in an AI tool context. I disclosed all contrary facts
above. Every dependency and dataset I introduced has documented rights for its
stated use.
Signature:
Timestamp:
```
A DCO `Signed-off-by` line does not replace this attestation. The attestation
digest, not private identity material, is referenced from the public release
manifest.
## 7. Specification controls
The custodian freezes an allowlist before implementation begins. Every entry
records title, canonical URL or DOI, publication date, retrieval time, content
digest, license or access terms, reviewer, and the abstract requirement it
supports.
The specification may state mathematical functions, tensor roles, input/output
invariants, resource bounds, and RuView-specific requirements. It must not
include copied source, comments, tests, distinctive identifiers, constants,
checkpoint dimensions extracted from files, API compatibility requirements, or
Google diagrams. RuView API names must be derived from the local domain.
Changes to the specification after test evaluation receive a new version and
must not encode held-out answers. A material architecture revision consumes a
new untouched holdout or remains labelled exploratory.
## 8. AI coding-tool controls
An AI assistant can unintentionally defeat source separation by retrieving or
reproducing reference code. For sessions contributing implementation:
1. Disable web retrieval and repository indexing outside the approved local
tree where the tool supports it.
2. If a user describes the goal as a port, clone, reproduction, or emulation,
stop and restate the task as independently authored RuView functional
requirements. Record that wording in the exposure manifest; do not use it
as permission to retrieve reference implementation material or pursue
behavioral equivalence.
3. Do not attach Google code, checkpoint metadata, outputs, screenshots, or
detailed third-party implementation summaries.
4. State the independent specification and prohibited-source boundary in the
session instructions.
5. Retain a bounded prompt and tool-source manifest digest without committing
raw private transcripts.
6. Record the model/tool version and whether retrieval was enabled.
7. Treat unexplained code that resembles a prohibited implementation as an
incident, not as a harmless generated suggestion.
AI-generated code receives the same authorship, license, security, and
similarity review as human-authored code.
## 9. Dataset licensing and privacy gate
No dataset enters preprocessing, a RuVector index, training, calibration, or
evaluation until a data steward records affirmative answers to every applicable
gate:
| Gate | Required evidence | Automatic rejection examples |
|---|---|---|
| Origin | Named owner/provider, acquisition method, immutable source digest | Scrape or file of unknown origin |
| Training rights | Written right to use for ML training and the intended commercial purpose | Research-only, NC, no-ML, evaluation-only |
| Derivatives | Right to transform, derive windows/features, and create model artifacts | No-derivatives or ambiguous custom terms |
| Redistribution | Whether raw, transformed, manifests, and weights may be redistributed separately | Assumption that public access means redistribution |
| Attribution | Exact attribution and notice obligations | Missing author/source/version |
| Database rights | Jurisdiction and database-right review where applicable | Unreviewed EU database extraction |
| Consent and contract | Collection authority, participant/customer consent, purpose, controller/processor roles | Customer telemetry without explicit model-training authority |
| Sensitive inference | Classification of occupancy, routines, location, health-adjacent and biometric implications | Unbounded identity or health inference |
| Minimization | Required fields only, pseudonymization, retention and deletion schedule | Raw CSI/person identifiers retained without need |
| Split integrity | Site/subject/session/device/time-block lineage and overlap report | Contiguous or derived-window leakage across splits |
| Synthetic lineage | Generator, seed data, service terms, output rights, seeds/config digest | Restricted teacher or prohibited source data |
Typical disposition guidance:
- Owned data, CC0/public-domain data, and data under an explicit commercial ML
contract may pass after privacy and provenance review.
- CC BY data requires attribution plus database and downstream-weight review.
- MIT and Apache are software licenses and do not automatically license nearby
data.
- NC, ND, research-only, evaluation-only, no-ML, no-production, and unknown
terms fail unless a separate written commercial grant is obtained.
Every transform is content-addressed. Normalizers, feature selectors,
calibrators, thresholds, and RuVector indexes fit training data only. Raw
sequence overlap and derived-window overlap are both checked. A parent record
in one split makes all overlapping descendants ineligible for another split.
## 10. Hosted training boundary
The Linux machine and fal.ai are execution environments, not sources of
authority. A hosted job is accepted only when:
- the exact source commit, Cargo lockfile, compiler, container digest, data
manifests, configuration, seeds, and parent checkpoint digest are signed
before upload;
- provider terms, retention, region, subprocessors, logs, cache deletion,
confidentiality, output ownership, and provider-training reuse have been
reviewed for the data classification;
- credentials are short-lived, least-privilege, excluded from images and logs,
and rotated after suspected exposure;
- customer-derived data is not uploaded before controller/processor and
transfer requirements pass;
- emitted checkpoints and logs are hashed immediately and compared with the job
receipt;
- the downloaded artifact is scanned before entering the trusted release
boundary;
- a hosted provider cannot promote, sign, or activate a model.
Hosted and local runs are comparable only when their governed input identities
match. Numerical nondeterminism must be documented; it does not permit an
unrecorded dependency, data, or configuration change.
## 11. Provenance manifest
The release bundle contains a machine-readable manifest with at least:
```yaml
schema_version: <required>
artifact:
id: <required>
version: <required>
git_commit: <sha>
source_spec_digest: <sha256>
cargo_lock_digest: <sha256>
rust_toolchain: <exact>
target_triple: <exact>
build_container_digest: <digest>
sbom_digest: <sha256>
contributors:
- pseudonymous_id: <stable id>
role: <role>
exposure_class: <unexposed|disclosed-reviewed>
attestation_digest: <sha256>
signed_at: <rfc3339>
references:
- title: <title>
canonical_uri: <url-or-doi>
publication_date: <date>
retrieved_at: <rfc3339>
content_digest: <sha256>
license_or_terms: <identifier>
allowed_use: <purpose>
reviewer: <id>
datasets:
- dataset_id: <id>
version: <version>
owner: <owner>
origin: <uri-or-contract-id>
license_or_contract: <identifier>
commercial_ml_allowed: <true>
redistribution_allowed: <true|false>
consent_basis: <identifier>
privacy_class: <class>
jurisdictions: [<jurisdiction>]
retention_policy: <policy-id>
source_digest: <sha256-or-merkle-root>
transform_digest: <sha256>
split_digest: <sha256>
record_count: <count>
training:
random_initialization: true
parent_checkpoint: null
code_commit: <sha>
config_digest: <sha256>
rng_seeds: [<seed>]
hardware: <inventory>
provider: <local-linux|approved-provider>
provider_job_id: <non-secret-id>
environment_digest: <sha256>
started_at: <rfc3339>
ended_at: <rfc3339>
forbidden_artifact_scan_digest: <sha256>
leakage_scan_digest: <sha256>
evaluation:
frozen_manifest_digest: <sha256>
baseline_artifacts: [<id-and-digest>]
metric_schema: <version>
report_digest: <sha256>
calibration_report_digest: <sha256>
domain_holdouts: [<site|subject|session|device>]
release:
weights_digest: <sha256>
model_card_digest: <sha256>
clean_room_report_digest: <sha256>
signature_key_id: <public-key-id>
rvf_signature: <signature>
source_license: <spdx-expression>
weights_license: <exact-identifier>
data_steward_approval: <signed-receipt>
security_approval: <signed-receipt>
legal_approval: <signed-receipt>
```
Secrets, raw personal identifiers, contract text, raw customer data, and
private contributor identities stay outside the public manifest. The manifest
references controlled records by digest or approval ID.
## 12. Source, dependency, and artifact checks
The custodian records tool versions, rules, timestamps, and complete findings.
At minimum:
- search the working tree and Git history for prohibited model IDs, URLs,
filenames, license strings, binary signatures, and unexpected large files;
- scan source similarity against the prohibited implementation in an isolated
custodian environment; implementation contributors do not receive reference
fragments from the report;
- manually adjudicate every material similarity match and record independent
origin, generic necessity, rewrite, or reclassification;
- run dependency license, source, duplicate-version, and vulnerability policy
checks and generate a CycloneDX or SPDX SBOM;
- inspect container layers, CI and provider caches, mounted volumes, model/data
buckets, notebooks, logs, and local tool indexes for restricted artifacts;
- verify that every checkpoint parent is an approved RuView artifact or the
declared random initialization;
- verify that model loading performs no network access and accepts no embedded
executable operator or arbitrary path;
- verify that public packages, endpoints, docs, and metadata do not use Google
trademarks as a product identity.
Keyword scans are a tripwire, not proof of independence. References in this
governance document are expected and must be path allowlisted. The final result
depends on provenance, exposure records, review, and the absence of prohibited
material in implementation/training paths.
## 13. Model card and claim gate
Every candidate copies and completes
[`../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md`](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md).
No placeholder, `UNMEASURED`, unknown license, missing digest, or missing
approval may be silently deleted. It must instead be resolved or retained as an
explicit release blocker.
All accuracy, latency, memory, power, cost, and generalization values are
labelled `MEASURED`, `SYNTHETIC`, `CLAIMED`, or `UNMEASURED` under repository
policy. `MEASURED` requires a reproducer, immutable inputs, exact artifact, and
named hardware/environment. A model-card benchmark does not create authority
for a medical, emergency, security, or autonomous-action claim.
## 14. Naming, trademark, and comparative statements
Approved project identities include `RuView Forecast` and crate names derived
from the RuView domain. Do not use `TimesFM`, `Google`, or a confusingly similar
mark in crate names, binaries, model slugs, endpoints, logos, icons, or product
headlines. Do not copy diagrams or visual branding.
Comparative documentation may accurately identify an external model and its
version when necessary, with a statement that RuView Forecast is independently
developed and not affiliated with or endorsed by Google. Comparative claims
need identical datasets, metric definitions, permitted model use, and evidence
labels.
Before public naming or commercial release, complete a professional trademark
clearance that includes federal, state, common-law, domain, and relevant
international sources. The [USPTO clearance guidance](https://www.uspto.gov/trademarks/search/comprehensive-clearance-search-similar-trademarks)
is a starting point, not a complete legal opinion.
## 15. Residual patent and jurisdiction risk
Clean-room evidence does not prevent patent infringement. Before production,
patent counsel performs a claim-level freedom-to-operate review covering at
least temporal patching, multivariate/variate attention or mixing, masked
horizon prediction, known-future covariates, probabilistic heads, normalization,
retrieval augmentation, and relevant training procedures. Searches include
assignees, inventors, continuations, unpublished timing uncertainty, and launch
jurisdictions. The [USPTO Patent Public Search](https://www.uspto.gov/patents/search/patent-public-search)
supports preliminary searching but is not a freedom-to-operate opinion.
The Apache patent grant applies only within its stated scope. An independent
implementation must not assume it inherits that grant. Public papers may be
prior art yet still coexist with earlier, pending, territorial, or narrower
claims.
## 16. Contamination incident response
Treat any prohibited source, output, model, data, or unreviewed exposure as a
provenance incident:
1. Stop affected implementation, training, evaluation feedback, and release.
2. Preserve hashes, timestamps, actors, locations, access records, and affected
lineage. Do not erase the audit trail.
3. Quarantine the material and revoke it from build, data, model, cache, and
retrieval paths.
4. Identify every affected commit, specification revision, dataset transform,
RuVector index, checkpoint, benchmark, and descendant artifact.
5. Rotate credentials if a provider, cache, or secret may be exposed.
6. Have the custodian and legal owner choose one disposition: proven
non-impact, clean rewrite by unexposed contributors, full retraining from the
last clean ancestor, or explicit Apache-derived reclassification.
7. Repeat all affected ADR-348 gates and record the incident and closure
receipts.
If prohibited output becomes a label or tuning signal, all descendant weights
are affected. Code deletion alone cannot repair model lineage; retraining from
an approved clean initialization is required.
## 17. Requirement-mapped acceptance checklist
| Check | ADR-348 requirements | Gate | Acceptance condition |
|---|---|---|---|
| CR-01 source allowlist | RF-001, RF-002 | G0 | 100% of specification sources frozen, hashed, licensed/termed, and approved |
| CR-02 contributor exposure | RF-001, RF-002 | G0 | 100% of contributors have current intake and signed attestation; every disclosure has a disposition |
| CR-03 prohibited-artifact scan | RF-001 | G0 | Zero unresolved prohibited artifacts or outputs across source, history, caches, containers, jobs, data, indexes, and checkpoints |
| CR-04 similarity review | RF-001, RF-002 | G0 | Every material match adjudicated without exposing implementers to reference fragments |
| CR-05 dependency/SBOM | RF-002, RF-004 | G1, G2 | Zero unknown or denied dependency licenses; vulnerability policy passes; signed SBOM exists |
| CR-06 dataset rights | RF-003 | G1 | 100% of bytes map to approved manifests; zero NC, ND, research-only, no-ML, no-production, or unknown terms |
| CR-07 privacy | RF-003, RF-009 | G1, G5 | Consent/contract, minimization, tenant, retention, deletion, transfer, and sensitive-inference review approved |
| CR-08 checkpoint lineage | RF-001, RF-002, RF-010 | G1 | Every checkpoint reaches approved random initialization with no prohibited parent or label |
| CR-09 split isolation | RF-007, RF-008 | G3 | Zero raw/derived overlap across frozen site/subject/session/device/time splits; split-scoped RuVector indexes verified |
| CR-10 hosted parity | RF-010 | G1, G5 | Local/hosted receipts bind identical governed inputs; provider terms and deletion evidence approved |
| CR-11 model card | RF-002, RF-007, RF-011 | G3-G5 | Template complete; every value evidence-labelled and reproducible; all blockers explicit |
| CR-12 trademark and patent | RF-001, RF-011 | G5 | Naming clearance and claim-level freedom-to-operate disposition signed for launch jurisdictions |
| CR-13 rollback | RF-012 | G5 | Contamination and runtime rollback drills preserve observations and evidence while disabling forecast authority |
| CR-14 bounded forecast contract | RF-004, RF-005, RF-006 | G2 | Property/fuzz/replay evidence proves finite bounded inputs, abstention, offline execution, immutable observation linkage, and derived-only evidence labels |
| CR-15 downstream authority | RF-006, RF-009, RF-012 | G4, G5 | No sensing-server hook in the initial PR; any later bridge proves forecast/LLM output cannot mutate observations or acquire prohibited action authority |
| CR-16 claims and rollout | RF-011, RF-012 | G3-G5 | Every claim has an allowed evidence label and reproducer; mode transitions cannot outrun the highest passed gate |
Any failed or missing check blocks the mapped gate. A maintainer waiver cannot
convert a prohibited license into permission or an unmeasured capability into a
measured claim.
## 18. Release record
The legal, data, security, model, and runtime owners sign the same immutable
release digest. Their approval covers the exact source, data, configuration,
weights, model card, SBOM, intended use, deployment mode, and jurisdictions.
Changing any governed input creates a new candidate and invalidates inherited
approval.
The first production review must also answer one explicit question: does the
business value of the measured forecast exceed the added privacy, operational,
compute, and legal burden compared with deterministic baselines? If not, the
correct outcome is to keep forecasting offline.
File diff suppressed because it is too large. Load diff
+93 -12
View File
@@ -22,6 +22,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
- [Cognitum Spaces activation](#cognitum-spaces-activation)
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
5. [REST API Reference](#rest-api-reference)
6. [WebSocket Streaming](#websocket-streaming)
@@ -74,7 +75,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
| Option | Cost | Capabilities |
|--------|------|-------------|
| ESP32-S3 mesh (3-6 boards) | ~$54 | Full CSI: pose, breathing, heartbeat, presence |
| ESP32-S3 mesh (3-6 boards) | ~$54 | CSI capture for presence, motion, and vital-sign heuristics; live 17-keypoint pose remains below-target and is not a validated capability |
| Intel 5300 / Atheros AR9580 | $50-100 | Full CSI with 3x3 MIMO (Linux only) |
| Any WiFi laptop | $0 | RSSI-only: coarse presence and motion detection |
@@ -425,6 +426,57 @@ curl http://localhost:3000/api/v1/sensing/latest
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
### Cognitum Spaces activation
Cognitum Spaces gives RuView a tenant/workspace-scoped semantic world model
without uploading raw RF/CSI, recordings, pose frames, vital waveforms, or
identity observations. It represents sites, buildings, floors, bounded
rooms/spaces, zones, anonymous entities, semantic events, and alerts.
Activate the public RuView OAuth client with Authorization Code + PKCE:
```bash
wifi-densepose login --spaces
wifi-densepose whoami
wifi-densepose spaces --resource sites --limit 50
wifi-densepose spaces --resource events --limit 25
```
The login requests `sensing:read spaces:read`. That consent is read-only: it
does not grant publication, pairing, policy approval, command, or actuator
authority. Versioned collections are `sites`, `buildings`, `floors`,
`spaces`, `zones`, `entities`, `events`, and `alerts`. A returned
`nextCursor` is opaque and valid only for the same collection.
The dependency-free contributor harness exposes the same read path:
```bash
npx @ruvnet/ruview@0.5.0 spaces --resource alerts --limit 25
npx @ruvnet/ruview@0.5.0 mcp start
```
Its MCP tool is `ruview_spaces_list`. MCP reads are OAuth-only, use the fixed
Cognitum API origin, and require the explicit guarded-tool opt-in. The harness
does not accept an arbitrary credential path or API origin.
For service compatibility, `wifi-densepose spaces` can read
`COGNITUM_SPACES_API` at request time. API-key access to a versioned collection
also requires `--workspace <uuid>`; OAuth derives the workspace from the
signed token. Never print or commit either credential.
Every response is bounded and revalidated. Raw-sensing aliases, malformed
hierarchy, non-anonymous person/track entities, invalid timestamps, stale
confidence, and oversized structures fail closed. Empty data means no
authorized state is present; it does not prove that a physical site is empty.
RuVector spatial memory remains physically separated by tenant and workspace.
Agents observe or recommend by default. Any consequential execution requires a
separate policy/grant/approval decision and produces a signed, hash-chained
receipt; the Spaces read token can never satisfy that gate.
See ADR-325, ADR-326, and ADR-327 for the activation, memory, and governed-action
decisions.
### Cognitum Seed Integration (ADR-069)
Connect an ESP32-S3 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy.
@@ -1162,6 +1214,22 @@ levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-depre
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
### Model and capture compatibility
These artifacts share a pose objective, but not an input contract or adapter format. A checkpoint
is usable only when capture preprocessing, tensor shape, architecture, and runtime all match.
| Artifact/path | Required input | Measured status | Live ESP32 compatibility |
|---------------|----------------|-----------------|--------------------------|
| MM-Fi flagship and `micro` transformer | `[N,3,114,10]` amplitude | **MEASURED** on MM-Fi `random_split`; calibration reference is Python `.npz` LoRA | No direct S3/C6 path is validated; resampling a 56-tone SISO stream does not recreate three-antenna MM-Fi input |
| Cog `pose_v1.safetensors` | `[N,56,20]` amplitude | **MEASURED** PCK@20 = 3.0%, below the ≥35% target; cog-format LoRA is `.safetensors` | Shape matches the canonical window, but the documented live runtime remains below-target/stub and is not a reliable pose claim |
| Viewer `heuristic_pose_from_amplitude` | live canonical amplitude | Skeleton-layout placeholder, not a trained pose model | Renders a demonstrator skeleton only; it is not pose accuracy evidence |
| MERIDIAN automatic unlabeled calibration | proposed ~200-frame target-room capture | **PROPOSED** in ADR-027; no validated end-to-end command | Not available. The current calibration tools require paired CSI/keypoint labels and model-specific inputs |
Calibration files are not interchangeable: `calibrate.py` targets the MM-Fi transformer, while
`cog_calibrate.py` targets the cog conv+MLP. See the
[calibration reference](../aether-arena/calibration/README.md) for their exact schemas.
### Download
```bash
@@ -1406,7 +1474,7 @@ The pipeline runs 10 phases:
3. Subcarrier resampling (114->56 or 30->56 via Catmull-Rom interpolation)
4. Graph transformer construction (17 COCO keypoints, 16 bone edges)
5. Cross-attention training (CSI features -> body pose)
6. **Domain-adversarial training** (MERIDIAN: gradient reversal + virtual domain augmentation)
6. Experimental domain-adversarial components (MERIDIAN research modules; not a validated automatic deployment path)
7. Composite loss optimization (MSE + CE + UV + temporal + bone + symmetry)
8. SONA adaptation (micro-LoRA + EWC++)
9. Sparse inference optimization (hot/cold neuron partitioning)
@@ -1422,14 +1490,18 @@ Progressive loading enables instant startup (Layer A loads in <5ms with basic in
### Cross-Environment Adaptation (MERIDIAN)
Models trained in one room typically lose 40-70% accuracy in a new room due to different WiFi multipath patterns. The MERIDIAN system (ADR-027) solves this with a 10-second automatic calibration:
Models trained in one room can lose substantial accuracy in a new room because the multipath
distribution changes. ADR-027 proposes an automatic adaptation design, and the Rust tree contains
individual research components, but RuView does **not** currently provide a validated command that
turns ~200 unlabeled frames into a working room adapter.
1. **Deploy** the trained model in a new room
2. **Collect** ~200 unlabeled CSI frames (10 seconds at 20 Hz)
3. The system automatically generates environment-specific LoRA weights via contrastive test-time training
4. No labels, no retraining, no user intervention
Current, testable calibration is the separate **labeled** reference in
`aether-arena/calibration/`: collect paired CSI/keypoint samples, then fit a model-specific LoRA
adapter. The MM-Fi transformer expects `[N,3,114,10]`; the cog expects `[N,56,20]`. Neither adapter
loads into the other model, and neither result establishes live ESP32 pose accuracy without a
leakage-free held-out capture and mean-pose baseline.
MERIDIAN components (all pure Rust, +12K parameters):
ADR-027 research components:
| Component | What it does |
|-----------|-------------|
@@ -1437,7 +1509,7 @@ MERIDIAN components (all pure Rust, +12K parameters):
| Domain Factorizer | Separates pose-relevant from room-specific features |
| Geometry Encoder | Encodes AP positions (FiLM conditioning with DeepSets) |
| Virtual Augmentor | Generates synthetic environments for robust training |
| Rapid Adaptation | 10-second unsupervised calibration via contrastive TTT |
| Rapid Adaptation | Proposed unlabeled contrastive TTT; not wired as a validated deployment workflow |
See [ADR-027](adr/ADR-027-cross-environment-domain-generalization.md) for the full design.
@@ -1595,7 +1667,7 @@ Not every ADR-295–296 remediation item is preview-only. Three are live now:
| Target | Use case | Source target flag | Notes |
|---|---|---|---|
| **ESP32-S3** (default) | Production CSI mesh, 17-keypoint pose | `idf.py set-target esp32s3` | Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path |
| **ESP32-S3** (default) | Production CSI capture mesh; presence/motion/vital heuristics | `idf.py set-target esp32s3` | Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path; live 17-keypoint pose is not validated |
| **ESP32-C6** ([ADR-110](adr/ADR-110-esp32-c6-firmware-extension.md)) | Wi-Fi 6 / 802.15.4 research, battery seed nodes | `idf.py set-target esp32c6` | Single-core 160 MHz, no PSRAM, 802.11ax HE PHY, 802.15.4 (Thread/Zigbee), LP-core hibernation ~5 µA |
The same `firmware/esp32-csi-node` source tree builds for both. ESP-IDF picks up `sdkconfig.defaults.esp32c6` automatically when the target is set to `esp32c6`; otherwise it uses `sdkconfig.defaults` (S3). All C6-only modules are `#ifdef`-gated, so the S3 build is byte-identical to today.
@@ -2019,7 +2091,11 @@ Pre-trained models are available on HuggingFace:
- **SOTA MM-Fi pose model** (82.69% torso-PCK@20) — https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose
- **AetherArena leaderboard Space** — https://huggingface.co/spaces/ruvnet/aether-arena
Download and start sensing immediately — no datasets, no GPU, no training needed. Results are reproducible via `python archive/v1/data/proof/verify.py` (deterministic SHA-256 proof) — see [ADR-168](adr/ADR-168-benchmark-proof.md).
The encoder artifact can be downloaded for its documented inference path without retraining. The
MM-Fi pose checkpoint is benchmark evidence, not a drop-in live ESP32 model; it requires its exact
input contract and runtime described in [Model and capture compatibility](#model-and-capture-compatibility).
The deterministic signal-pipeline proof is reproducible via `python archive/v1/data/proof/verify.py`
(see [ADR-168](adr/ADR-168-benchmark-proof.md)), but that proof does not validate pose accuracy.
### Quick Start with Pre-Trained Models
@@ -2584,7 +2660,12 @@ No. Run `docker run -p 3000:3000 ruvnet/wifi-densepose:latest` and open `http://
No. Consumer WiFi exposes only RSSI (one number per access point), not CSI (56+ complex subcarrier values per frame). RSSI supports coarse presence and motion detection. Full pose estimation requires CSI-capable hardware like an ESP32-S3 ($8) or a research NIC.
**Q: How accurate is the pose estimation?**
Accuracy depends on hardware and environment. With a 3-node ESP32 mesh in a single room, the system tracks 17 COCO keypoints. The core algorithm follows the CMU "DensePose From WiFi" paper ([arXiv:2301.00250](https://arxiv.org/abs/2301.00250)). The MERIDIAN domain generalization system (ADR-027) reduces cross-environment accuracy loss from 40-70% to under 15% via 10-second automatic calibration.
The strongest published RuView result is the MM-Fi transformer benchmark (82.69% torso-PCK@20 on
the matched `random_split` protocol), not a live ESP32 result. The committed cog model measured
3.0% PCK@20 on its holdout, below the ≥35% target, and the viewer's live skeleton is a heuristic
placeholder. No measured claim currently shows a 3-node ESP32 mesh reliably tracking 17 keypoints.
ADR-027's 10-second unlabeled MERIDIAN adaptation is Proposed; the available measured calibration
reference uses labeled, model-specific samples.
**Q: Does it work through walls?**
Yes. WiFi signals penetrate non-metallic materials (drywall, wood, concrete up to ~30cm). Metal walls/doors significantly attenuate the signal. With a single AP the effective through-wall range is approximately 5 meters. With a 3-6 node multistatic mesh (ADR-029), attention-weighted cross-viewpoint fusion extends the effective range to ~8 meters through standard residential walls.
@@ -0,0 +1,120 @@
# ESP32 C6 node 7 rate aware sensing qualification
## Scope
This record qualifies the firmware 0.8.8 timing and transport path on a second
physically attached ESP32 C6. It measures raw callback cadence, edge DSP
cadence, process stability, and end to end sensing delivery. It does not
qualify heartbeat, respiration, pose, identity, room separation, or person
count accuracy against labelled ground truth.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 C6 QFN40 revision 0.2 |
| Logical node | 7 |
| Firmware before | 0.8.4 |
| Firmware after | 0.8.8 development build |
| App image | 1,051,552 bytes |
| App SHA 256 | `eab7561d65e302dc33e9331ac591763a46f92fb3fa9f824fef0e9b541daddb3f` |
| OTA slot size | 1,900,544 bytes |
| OTA headroom | 848,992 bytes, 45 percent |
Only the application partition at offset `0x20000` was flashed. WiFi
credentials, logical node identity, sensing server target, channel, edge tier,
bootloader, partition table, OTA metadata, and NVS were preserved. The pre
update application was copied to a private recovery file outside the
repository. Its SHA 256 is
`f5ebc5e0142425adae16e9180bf298ef444ea8862ba0d8809c310a39fe45721d`.
The device partition table was also read before the update and had SHA 256
`0a8d2f192a8fff209d6c75ab639fcf8aa2f43c64abb732c6e74596fbd6971dca`.
The post update boot log reported firmware 0.8.8, node 7, channel 10, Tier 2,
an 8 Hz edge DSP cadence, and the preserved sensing server target. The OTA
status endpoint reported firmware 0.8.8 running from `ota_0`, with `ota_1` as
the next partition and the correct 1,900,544 byte limit. The sensing server
health endpoint remained ready with ESP32 input.
## Before and after
The pre update baseline was a 20 second observation on the same attached board.
The post update observation was a five minute steady state run after boot.
| Observation | Before 0.8.4 | After 0.8.8 |
|---|---:|---:|
| Raw callback mean | 39.05 pps | 36.32 pps |
| Raw callback range | 35 through 42 pps | 24 through 42 pps |
| Server CSI FPS mean | 46.23 Hz | 48.88 Hz |
| WebSocket parser errors | 0 | 0 |
| WebSocket reconnects | 0 | 0 |
Raw callback mean changed by negative 7.0 percent while the server CSI FPS
estimate changed by positive 5.7 percent. Both remain above the 20 pps
transport floor. The result is transport neutral rather than an accuracy lift;
the room and WiFi traffic were not controlled between the two windows.
Firmware 0.8.4 did not expose the edge DSP cadence used by the temporal
filters. Firmware 0.8.8 held that separately governed clock at exactly 8.0 Hz
for every controller sample while preserving the higher rate raw network path.
## Five minute physical result
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
| Device observation | Result |
|---|---:|
| Duration | 300.70 seconds |
| Controller samples | 300 |
| Raw callback mean | 36.32 pps |
| Raw callback range | 24 through 42 pps |
| Edge DSP mean | 8.00 Hz |
| Edge DSP range | 8.00 through 8.00 Hz |
| ENOMEM events | 0 |
| UDP send failures | 0 |
| ESP NOW nonzero failure lines | 0 |
| Other steady state errors | 0 |
| Watchdogs, panics, or reboots | 0 |
| End to end WebSocket observation | Result |
|---|---:|
| Duration | 300.06 seconds |
| Sensing frames | 36,254 |
| JSON parse errors | 0 |
| WebSocket errors or reconnects | 0 |
| Frames containing node 7 | 35,313 |
| Node 7 frame coverage | 97.40 percent |
| Node 7 stale frames | 0 |
| Maximum node 7 staleness | 972 ms |
| Maximum node 7 inference age | 483 ms |
| Maximum WebSocket frame gap | 108 ms |
| Nodes per frame | 0 through 5 |
| Fused presence count contradictions | 0 |
One ENOMEM backoff occurred during startup and recovered in 210 ms. No memory
backoff or send failure recurred in the separate five minute steady state
window. The boot log also reported the documented fail closed OTA behavior:
the status service was available, but image upload remained rejected because
this node has no provisioned OTA signing secret.
## Result and limitation
The node 7 timing and transport update passes. Its configuration survived, the
edge DSP clock remained phase stable at the measured sustainable C6 rate, and
the live service received fresh node 7 data throughout the run. This does not
complete the ADR 346 occupancy qualification for node 7 because the room was
not held empty and the live aggregate did not expose 30 absent edge packets.
The largest uncertainty remains inference accuracy. Timing stability cannot
prove better vital, motion, room separation, or multi person estimates without
synchronized held out labels and a controlled empty room sequence.
## Acceptance test
Repeat this five minute procedure after timing, WiFi, filter, or scheduling
changes. Pass transport only when raw callback yield remains at least 20 pps,
DSP cadence stays within one hertz of the configured target, the device has
zero steady state memory backoff, send failure, watchdog, panic, and reboot
events, the server has zero parse failures and reconnects, and the updated node
stays fresh. Complete occupancy qualification separately with at least 30
absent edge packets and zero absent packets carrying a nonzero person count.
@@ -0,0 +1,60 @@
# ESP32 C6 occupancy evidence qualification
## Scope
This record qualifies the fail closed person count invariant in ADR 346 on one physically attached ESP32 C6. It does not qualify person counting accuracy, identity, pose, room separation, or vital sign accuracy.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 C6 QFN40 revision 0.2 |
| Logical node | 4 |
| Firmware before | 0.7.0 |
| Firmware after | 0.8.4 development build |
| App image | 1,051,168 bytes |
| App SHA 256 | `f9470a31b82612f1740f0cf0943ddb78917cd58784ba16d2e9d57cc8fb39364c` |
| OTA slot size | 1,900,544 bytes |
| CSI stream target | Preserved from NVS |
The device partition table was read before the update. NVS, OTA metadata, bootloader, and partition table were not overwritten. A private recovery copy was created outside the repository and excluded from version control.
## Software gates
| Gate | Result |
|---|---|
| Firmware host tests | PASS, 54 assertions across encoding, vital evidence, and mmWave detection |
| Rust sensing server package | PASS, 532 library tests plus all package integration and documentation tests |
| Mobile Jest suite | PASS, 164 suites and 1,223 tests |
| Mobile TypeScript | PASS |
| Mobile ESLint | PASS |
| Mobile security verifier | PASS |
| Repository wide Rust formatting | PREEXISTING DRIFT outside this change; changed code builds and package tests pass |
## Physical result
MEASURED on 2026 08 31 from the live local sensing WebSocket for 300 seconds:
| Node | Firmware state | Edge packets | Absent packets | Absent with nonzero count | Result |
|---|---|---:|---:|---:|---|
| 4 | Updated | 242 | 61 | 0 | PASS |
| 3 | Unupdated control | 216 | 216 | 216 | Expected control failure |
| 7 | Unupdated control | 280 | 278 | 278 | Expected control failure |
Node 4 reduced the targeted logical contradiction from observed to zero, a 100 percent reduction for this invariant during this run. This is not a person count accuracy result.
The WebSocket run had zero JSON parse errors and one expected client close at completion. The sensing server remained ready with `engine_error_count=0`. A separate 45 second serial observation recorded 120 log lines, 17 CSI callback markers, zero ENOMEM backoffs, and zero other error lines.
The updated OTA status endpoint reports the selected 1,900,544 byte partition rather than the stale 921,600 byte constant. The 1,051,168 byte image therefore fits with 849,376 bytes of partition headroom.
## Remaining qualification
Nodes 3 and 7 still demonstrate the old contradictory behavior and must be upgraded only after their network identity, OTA credential, and rollback path are verified. The current run had no labelled ground truth, so multi person fidelity and adjacent room rejection remain unmeasured.
## Subsequent node 7 status
Later on 2026 08 31, node 7 was separately identified, backed up, upgraded to firmware 0.8.8, and transport qualified for five minutes. That later occupied room run had zero fused presence count contradictions but did not produce the 30 absent edge packets required to supersede the historical control result above. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`.
## Acceptance test
Repeat a five minute capture after every firmware change. Pass only when every updated node has at least 30 absent packets, zero packets where `presence=false` and `n_persons>0`, zero parser errors, and a ready sensing server with zero engine errors.
@@ -0,0 +1,124 @@
# ESP32 C6 rate aware sensing qualification
## Scope
This record qualifies ADR 347 on one physically attached ESP32 C6 and verifies
that the same source compiles for ESP32 S3. It measures transport cadence, edge
DSP cadence, process stability, and end to end sensing delivery. It does not
qualify heartbeat, respiration, gesture, pose, identity, or person count
accuracy against labelled ground truth.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 C6 QFN40 revision 0.2 |
| Logical node | 4 |
| Firmware before | 0.8.4 |
| Firmware after | 0.8.8 development build |
| C6 app image | 1,051,552 bytes |
| C6 app SHA 256 | `f2ea422c9b99ec13c7a168afc2b019229642769ffabfd8f29a85978770236e87` |
| OTA slot size | 1,900,544 bytes |
| OTA headroom | 848,992 bytes, 45 percent |
| S3 compile image | 1,127,104 bytes |
| S3 compile SHA 256 | `63e4f0c484d79e7dd37eb28275951c8beb924e6908942f7fec0b90d92109129c` |
Only the application partition at offset `0x20000` was flashed. WiFi
credentials, node identity, sensing server target, bootloader, partition table,
OTA metadata, and NVS were preserved. The pre update OTA application was read
to a private recovery file outside the repository. Its SHA 256 is
`a2e503f1622b2f3f9c1cfce0a07ba34b9fc5d6a413b6346311622af1fe18a6d8`.
The OTA status endpoint reported firmware 0.8.8 running from `ota_0` after the
update. The sensing server health endpoint remained ready with ESP32 input.
## Software gates
| Gate | Result |
|---|---|
| Rate estimator and occupancy host tests | PASS, 30 assertions |
| ADR 110 encoding host tests | PASS, 21 assertions |
| mmWave frame predicate host tests | PASS, 8 assertions |
| ESP32 C6 IDF 5.4 ARM64 build | PASS |
| ESP32 S3 IDF 5.4 ARM64 build | PASS, compile only |
| Image checksum and validation hash | PASS |
| Repository diff whitespace check | PASS |
| Local libFuzzer aggregate | NOT RUN, local Xcode toolchain lacks `libclang_rt.fuzzer_osx.a` |
For this C6 record, the S3 result was source and toolchain validation only and
no S3 runtime claim is made here. The later physical S3 Tier 0 transport run is
recorded separately in
`docs/validation/2026-08-31-esp32-s3-rate-aware-transport.md`.
## Measured rate correction
The pre update 20 second C6 baseline delivered a mean 34.05 raw callbacks per
second, median 34.5, and range 28 through 37. An intermediate 0.8.7 physical
run requested 10 Hz edge DSP but converged to 8.0 through 8.4 Hz while raw CSI
remained 30 through 40 packets per second. This proved that C6 Tier 2 compute,
not the raw transport, was the limiting path.
Firmware 0.8.8 therefore keeps the 50 Hz probe and independent raw network
path, but sets the C6 Tier 2 DSP clock to its measured sustainable 8 Hz. The
phase preserving sampler prevents callback jitter from shifting the configured
clock, and the filter estimator follows processed timestamps rather than raw
probe intent.
## Five minute physical result
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
| Device observation | Result |
|---|---:|
| Duration | 300.64 seconds |
| Controller ticks | 300 |
| Raw callback mean | 34.92 pps |
| Raw callback range | 22 through 41 pps |
| Edge DSP mean | 8.00 Hz |
| Edge DSP range | 8.00 through 8.00 Hz |
| ENOMEM events | 0 |
| UDP send failures | 0 |
| Other steady state errors | 0 |
| Watchdogs, panics, or reboots | 0 |
| End to end WebSocket observation | Result |
|---|---:|
| Duration | 300.01 seconds |
| Sensing frames | 26,786 |
| JSON parse errors | 0 |
| Reconnects | 0 |
| Frames containing node 4 | 26,148 |
| Node 4 frame coverage | 97.62 percent |
| Node 4 stale frames | 0 |
| Maximum node 4 inference age | 176 ms |
| Maximum WebSocket frame gap | 110 ms |
| Nodes per frame | 0 through 4 |
| Fused `presence=false` with nonzero count contradictions | 0 |
The boot log emitted one expected iTWT negotiation error because the access
point rejected the requested target wake time parameters. Firmware immediately
selected its documented opportunistic CSI fallback. No iTWT or other error
recurred during the five minute steady state window.
## Result and limitation
ADR 347 timing and transport acceptance passes on the attached C6. Raw
throughput did not regress relative to the short baseline, the edge clock now
matches the rate the temporal filters actually receive, and node 4 was never
stale when present in the live sensing service. The separate occupancy
qualification recorded 61 absent node 4 packets with zero contradictions for
the unchanged fail closed invariant. This run did not repeat an empty room
sequence because the room was occupied during qualification.
The largest remaining uncertainty is inference accuracy. Stable timing removes
one source of feature distortion but cannot prove better heartbeat, respiration,
gesture, or multi person classification without synchronized held out labels.
## Acceptance test
Repeat this five minute procedure after any timing, WiFi, filter, or task
scheduling change. Pass only when raw callback yield remains at least 20 pps,
DSP cadence remains within one hertz of the configured target, the device has
zero steady state ENOMEM, send failure, watchdog, panic, and reboot events, the
server has zero parse failures and reconnects, node 4 stays fresh, and fused
presence count contradictions remain zero.
@@ -0,0 +1,126 @@
# ESP32 S3 rate aware transport qualification
## Scope
This record qualifies the firmware 0.8.8 raw transport path on one physically
attached ESP32 S3. The node retained its existing Tier 0 configuration, so this
run does not qualify the S3 edge DSP rate, temporal filters, heartbeat,
respiration, gesture, pose, identity, person count, or localization accuracy.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 S3 QFN56 revision 0.2 with 2 MB embedded PSRAM |
| Logical node | 1 |
| Firmware before | 0.8.4 |
| Firmware after | 0.8.8 development build |
| Edge tier | 0, raw passthrough |
| App image | 1,127,104 bytes |
| App SHA 256 | `b531c76900c07d0d6f6e864a5f28afff3e71f124777af97358bb405b34e339a2` |
| OTA slot size | 2,097,152 bytes |
| OTA headroom | 970,048 bytes, 46 percent |
The production partition table was read from the device before the update.
Only the application partition at offset `0x20000` was flashed. WiFi
credentials, logical node identity, channel, sensing server target, bootloader,
partition table, OTA metadata, and NVS were preserved. A private recovery copy
of the prior 2 MB application partition was saved outside the repository. Its
SHA 256 is
`14e72c060c4f1a465f739949b873f6aade5b8899a8909f6c5bb591ee49837c1b`.
The post update boot log reported firmware 0.8.8, logical node 1, channel 4,
the preserved UDP target, Tier 0 raw passthrough, and successful CSI streaming.
The OTA status endpoint reported firmware 0.8.8 running from `ota_0` with the
correct 2,097,152 byte update limit. The sensing server health endpoint remained
ready with ESP32 input.
## Software and image gates
| Gate | Result |
|---|---|
| Firmware encoding, vitals, occupancy, and mmWave host tests | PASS, 59 assertions |
| Firmware provisioning Python tests | PASS, 14 tests |
| ESP32 S3 IDF 5.4 ARM64 clean build | PASS |
| Image target detection | PASS, ESP32 S3 |
| Image checksum | PASS |
| Image validation hash | PASS |
| Application partition fit | PASS, 46 percent free |
| Physical flash write verification | PASS |
| Preserved runtime configuration | PASS |
The build excluded the optional WASM3 source because it was not present in the
firmware checkout. The boot log therefore reported WASM Tier 3 disabled. That
is not a regression introduced by this update and is outside this transport
qualification.
## Before and after comparison
The pre update baseline was a 20 second serial and WebSocket capture on firmware
0.8.4. The post update stability observation was 300 seconds on firmware 0.8.8.
| Observation | Before 0.8.4 | After 0.8.8 | Change |
|---|---:|---:|---:|
| Raw CSI yield mean | 27.80 pps | 28.03 pps | plus 0.83 percent |
| Server CSI FPS mean | 39.58 | 39.13 | minus 1.15 percent |
| Node frame coverage | 100 percent | 100 percent | unchanged |
| Maximum node staleness | 1,571 ms | 1,565 ms | minus 0.38 percent |
| Maximum WebSocket frame gap | 111 ms | 112 ms | plus 0.90 percent |
| Device or parser errors | 0 | 0 | unchanged |
These small movements are operationally neutral and within uncontrolled room
and WiFi variation. Firmware 0.8.8 did not regress the raw transport. Because
Tier 0 bypasses the DSP task, this run provides no evidence that temporal
features or inference accuracy improved.
## Five minute physical result
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
| Device observation | Result |
|---|---:|
| Duration | 300 seconds |
| Controller yield samples | 300 |
| Raw CSI yield mean | 28.03 pps |
| Raw CSI yield range | 22 through 34 pps |
| ENOMEM or stack errors | 0 |
| UDP send failures | 0 |
| ESP NOW send failures | 0 |
| Unexpected resets | 0 |
| Watchdogs or panics | 0 |
| End to end WebSocket observation | Result |
|---|---:|
| Duration | 300.03 seconds |
| Sensing frames | 10,559 |
| Frames containing node 1 | 10,559 |
| Node 1 frame coverage | 100 percent |
| Source offline frames | 0 |
| JSON parse errors | 0 |
| WebSocket errors | 0 |
| Early closes | 0 |
| Maximum node staleness | 1,565 ms |
| Maximum WebSocket frame gap | 112 ms |
## Result and limitation
The ESP32 S3 raw transport acceptance passes. Firmware 0.8.8 booted from the
existing slot, retained the installation configuration, sustained the prior raw
CSI delivery rate, and completed the burn with zero transport or runtime
errors. The result extends ADR 347 physical coverage to the S3 transport path.
The largest remaining uncertainty is S3 Tier 2 behavior and inference accuracy.
The log line reporting the configured 20 Hz DSP cadence is not proof that DSP
ran because the preserved Tier 0 setting explicitly disables the DSP task. A
separate, rollback protected Tier 2 qualification with synchronized held out
labels is required before making heartbeat, respiration, motion, or accuracy
claims.
## Acceptance test
Repeat this five minute procedure after any S3 timing, WiFi, transport, or task
scheduling change. Pass only when raw callback yield remains at least 20 pps,
node frame coverage remains at least 99 percent, and the device and server have
zero send failures, offline frames, parser errors, WebSocket errors, watchdogs,
panics, and unexpected resets. Qualify Tier 2 separately and require its
measured DSP cadence to stay within one hertz of the configured target.
@@ -0,0 +1,60 @@
# Native macOS USB onboarding firmware witness
## Evidence class
MEASURED on 2026-08-31 with one physical ESP32 S3. This is not an ESP32 C6 or
release distribution qualification.
## Hardware and firmware
The board was identified by the flashing tool as ESP32 S3 QFN56 revision 0.2
with 2 MB embedded PSRAM and 8 MB flash. Firmware 0.8.9 was built for ESP32 S3
with the displayless DevKitC defaults. The private configuration region was
backed up before flashing. The flash operation omitted NVS, preserving the
existing WiFi configuration.
No base MAC, WiFi credential, device digest, or raw CSI is retained in this
witness.
## Results
1. The initial native USB scan failed because the ESP-IDF secondary USB
Serial/JTAG console mirrors output but does not forward input to STDIN.
2. Firmware was corrected to read the bounded onboarding protocol from the
secondary VFS while retaining STDIN for UART console boards.
3. A direct nonce challenge then returned ESP32 S3, firmware 0.8.9, configured
state, the current node identity, and the private server route.
4. The Mac app changed the collided identity from 1 to unused identity 5 while
preserving WiFi. It showed success only after receiving fresh server
evidence at minus 56 dBm.
5. A five minute monitor collected 299 samples with zero node 5 missing events,
zero node 5 stale events, zero server health failures, minus 57 to minus 50
dBm RSSI, and 216 ms maximum packet age.
6. Firmware uptime exceeded 376 seconds. Captured logs advanced from CSI
callback 10,600 through 11,000, emitted a node 5 synchronization packet, and
showed ESP NOW transmit failures equal to zero without reboot, watchdog, or
panic evidence.
7. The final channel isolation hardening image was flashed and challenged
again. A following 60 sample check had zero missing, stale, or health failure
events, a maximum packet age of 100 ms, and minus 57 to minus 50 dBm RSSI.
## Software gates
The nonce and configuration parser host test passed, the complete ESP32 S3
firmware image built under ESP-IDF 5.4, and the application image occupied
941,056 bytes with 55 percent of the smallest application partition free.
## Remaining gates
New WiFi credential entry, signed Mac distribution, and cross platform serial
bridges remain unmeasured.
## ESP32 C6 follow up
On 2026-09-11, an ESP32 C6 on `cu.usbserial-3120` completed the Mac app flash,
hello, configure, and verify sequence with firmware 0.8.12. The app preserved
the existing node identity 3 and verified fresh sensing server evidence at
minus 34 dBm. The board returned `RUVIEW_HELLO_OK_V1` within the bounded serial
exchange after the C6 mmWave defaults moved from the UART0 RX conflict on GPIO
17 to GPIO 4 and GPIO 5. This closes only the physical C6 onboarding gate. It
does not qualify new WiFi credential entry or signed distribution.
@@ -0,0 +1,206 @@
# RuForecast requirement to evidence matrix
## Authority and current state
This matrix operationalizes ADR-348 and its ADR-349/350 children without
changing them. The ADRs own the requirements and gates. Rust tests, immutable
reports, signed receipts, and explicit human approvals supply evidence. A
checked box or green build is not permission to deploy, publish a checkpoint,
upload customer-derived data, or advance the rollout mode.
Current state: all release and operational gates are open. No benchmark result,
trained checkpoint, external training receipt, or production authority is
recorded by this document.
## Focused verification commands
The feature-off contract remains on the workspace Rust 1.89 line:
```bash
cd v2
cargo +1.89.0 test --locked -p ruview-forecast-core --no-default-features --lib --tests
cargo +1.89.0 test --locked -p ruview-forecast-model --no-default-features --lib --tests
cargo +1.89.0 check --locked -p ruview-forecast-model -p ruview-forecast-train --no-default-features --all-targets
cargo +1.89.0 test --locked -p ruview-forecast-model --no-default-features --features ruvector --lib --tests
cargo +1.89.0 clippy --locked -p ruview-forecast-core -p ruview-forecast-model -p ruview-forecast-train --no-default-features --all-targets -- -D warnings
cargo +1.89.0 clippy --locked -p ruview-forecast-model --no-default-features --features ruvector --all-targets -- -D warnings
```
Burn 0.21 CPU activation uses the explicitly separate Rust 1.92 line:
```bash
cd v2
cargo +1.92.0 test --locked -p ruview-forecast-model --no-default-features --features cpu --lib --tests
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli --lib --bins
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu --test local_jsonl_smoke -- --exact local_hash_addressed_jsonl_executes_one_real_optimizer_step
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli --test cli_smoke -- --exact cli_smoke_trains_and_writes_the_complete_candidate_set
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli,server,fal-client --lib --bins
cargo +1.92.0 clippy --locked -p ruview-forecast-model --no-default-features --features cpu --all-targets -- -D warnings
cargo +1.92.0 clippy --locked -p ruview-forecast-train --no-default-features --features cpu,cli,server,fal-client --all-targets -- -D warnings
```
The CUDA line is compile-only. It proves that the explicitly gated types build
on Rust 1.92; it is not a GPU execution, training, latency, or compatibility
claim:
```bash
cd v2
cargo +1.92.0 check --locked -p ruview-forecast-model --no-default-features --features cuda --lib
cargo +1.92.0 check --locked -p ruview-forecast-train --no-default-features --features cuda,cli,server --lib --bins
```
The hosted boundary stays backend-free and is exercised on Rust 1.89 with no
provider credential. Tests must use local mocks; compiling these features does
not authorize network use or a hosted training run:
```bash
cd v2
cargo +1.89.0 tree --locked -e normal,build -p ruview-forecast-train --no-default-features --features cli,server,fal-client > forecast-hosted-feature-tree.txt
! grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' forecast-hosted-feature-tree.txt
FAL_KEY='' cargo +1.89.0 test --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client --lib --bins --tests
FAL_KEY='' cargo +1.89.0 test --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client privacy_external_dataset_payload_is_denied
FAL_KEY='' cargo +1.89.0 clippy --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client --all-targets -- -D warnings
```
The broader repository gate remains:
```bash
cd v2
cargo +1.89.0 test --locked --workspace --no-default-features
cargo +1.89.0 bench --locked --workspace --no-default-features --no-run
```
No CUDA runtime result is implied by these commands. CUDA execution needs a
separately identified GPU, driver, toolkit, source commit, container digest,
and signed training or inference receipt.
The repository-wide security workflow separately hard-gates the checked-in
lockfile with `cargo audit --file v2/Cargo.lock --json`. The forecast workflow
also runs both modes of `scripts/csi-data-policy-check.sh`, rejects forbidden
clean-room source/import names and model blobs, checks the hosted DTO field
surface, and blocks common literal private-key, FAL-key, JWT, cloud-key and
presigned-URL patterns. These focused checks do not close dependency-license or
complete secret-scanning evidence: the repository has no committed
`v2/deny.toml`, inherited advisory/yanked-version debt needs an
owner/date/expiry baseline, and the existing general secret scanners are
non-blocking. Until dedicated retained reports exist, those parts of G0, G1,
and G5 remain open.
### Supply-chain and secret release prerequisites
| Check | Reproducer | Present authority |
|---|---|---|
| Advisory database | `cargo audit --file v2/Cargo.lock --json` | Existing blocking workflow; report retained |
| Advisory warnings | `cargo audit --file v2/Cargo.lock --deny warnings` | Not green by assertion; inherited warning debt needs an expiring reviewed baseline |
| Rust sources/licenses/bans | `cargo deny --manifest-path v2/Cargo.toml --config v2/deny.toml check advisories bans licenses sources` | Blocked until a reviewed `v2/deny.toml` is committed and CI pins `cargo-deny` |
| Forecast-path secrets | `gitleaks detect --no-banner --redact --source .` | Focused CI blocks common literal patterns; the broader GitLeaks job/report is still non-blocking |
| Tracked sensing data | `bash scripts/csi-data-policy-check.sh --self-test && bash scripts/csi-data-policy-check.sh --tracked` | Blocking in the focused hosted-boundary job |
`cargo vet check` becomes a release prerequisite only if the project commits a
vet policy and review process; listing it without that policy would be a false
supply-chain claim.
### Existing baseline debt and scope
- [`../benchmarks/physics-pose-refinement.md`](../benchmarks/physics-pose-refinement.md)
records unrelated workspace rustfmt/warning debt, an incomplete full-workspace
test run on that authoring host, existing RustSec debt, and yanked
`spin 0.9.8` in the optional Burn graph. Forecast CI therefore uses focused
warnings-denied gates and does not describe the whole workspace as green.
- [`.github/workflows/bench-regression.yml`](../../.github/workflows/bench-regression.yml)
records an upstream stable-Rust failure in optional `ruvector-crv`; the
focused forecast benchmarks neither enable nor inherit a CRV claim.
- The current ADR corpus contains pre-existing duplicate ADR-263 and ADR-264
numbers. The focused contract rejects a duplicate ADR-348, ADR-349 or ADR-350
without falsely asserting that the historic corpus already passes a global
uniqueness lint.
- Shared GitHub-hosted timing is noisy by repository policy. Only benchmark
compilation gates the PR; timing logs are informational until repeated on a
named, controlled host.
## Requirements
| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state |
|---|---|---|---|---|
| RF-001 | Forecast-path source and artifact scan; forbidden model/blob fixture scan; clean-room manifest schema and tamper tests | Current contributor attestations and clean-room custodian adjudication | ADR-348 G0 | OPEN |
| RF-002 | Canonical receipt round trip; digest/signature tamper negatives; source, config, dataset and checkpoint identifiers required | Reviewer verifies every dependency, reference, job and checkpoint lineage | ADR-348 G0 and G1 | OPEN |
| RF-003 | Dataset manifest rejects missing license, incompatible use, unknown privacy class and unresolved bytes | Data steward and legal approval for every immutable dataset digest | ADR-348 G1 | OPEN |
| RF-004 | Feature-off dependency boundary excludes Burn/CubeCL; fixed artifact/input CPU replay; offline load tests; bounded property and fuzz corpus | Named platform-class review and 24-hour accelerated replay | ADR-348 G2 | OPEN |
| RF-005 | Shape/product overflow, finite-value, timestamp, mask, schema, horizon, quantile and resource-limit tests | Review of production input caps and abstention thresholds | ADR-348 G2 | OPEN |
| RF-006 | Forecast evidence label is immutable; observation hash round trips; confidence cannot increase; derived values cannot overwrite observations | Evidence-engine and downstream schema review | ADR-348 G2 and G4 | OPEN |
| RF-007 | Frozen split manifest; train-only normalization/calibration; quantile order; interval coverage and loss by horizon/domain | Independent evaluation report on untouched site/session/device holdouts | ADR-348 G3 | OPEN |
| RF-008 | Per-split RuVector index isolation; no overlapping horizon or holdout neighbour; paired retrieval-off/retrieval-on report | Reviewer verifies index manifest and ablation comparability | ADR-348 G3 | OPEN |
| RF-009 | Capability tests prove the forecast and RuVLLM explanation have no actuator, spending, access-control, emergency or model-promotion authority | Downstream policy owner approves any advisory consumer | ADR-348 G4 | OPEN |
| RF-010 | Local and hosted receipts compare source, lock, container, data, config and initial-weight digests; provider result is untrusted until verified | Provider retention, log, credential and data-processing review | ADR-348 G1 and G5 | OPEN |
| RF-011 | Benchmark report binds commit, lock, toolchain, host, backend, config, corpus, command and evidence label | Maintainer adjudicates claim label and model card language | ADR-348 G3 through G5 | OPEN |
| RF-012 | Mode transition and rollback state-machine tests; failed activation retains the prior artifact; raw sensing continues | Signed rollback drill and authorized mode-transition record | ADR-348 G5 | OPEN |
## Governed training requirements
These rows trace ADR-349. A mock-backed test can close a software subcondition,
but cannot close the hosted operational evidence named in the ADR.
| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state |
|---|---|---|---|---|
| FT-001 | Synthetic hosted DTO and reconstructed local request bind the same generator, model, optimizer and build identities; external manifests are rejected | Reviewer compares immutable local and provider receipts | ADR-349 and G1 | OPEN |
| FT-002 | Unknown-field and arbitrary command/image/URL/environment/path negatives | Endpoint capability and worker-image review | ADR-349 and G2 | OPEN |
| FT-003 | Concurrent/retry/lost-response idempotency state-machine tests | Real provider ambiguous-retry drill and cost reconciliation | ADR-349 and G1/G5 | OPEN |
| FT-004 | Budget boundary and over-budget rejection tests | Provider price/bill fixture review and bounded real-run receipt | ADR-349 and G5 | OPEN |
| FT-005 | Authenticated cancellation, checkpoint and terminal-state property tests | Real hosted cancellation and orphan-job drill | ADR-349 and G2/G5 | OPEN |
| FT-006 | Missing/duplicate/truncated/tampered fixed-kind export matrix | Quarantine/export receipt review | ADR-349 and G1/G2 | OPEN |
| FT-007 | Hosted-signing denial, local verification and atomic promotion/rollback tests | Release-key isolation approval and rollback drill | ADR-349 and G1/G5 | OPEN |
| FT-008 | Captured egress and log-redaction tests for credentials/privacy/retention fields | Provider DPA, region, retention, reuse and deletion review | ADR-349 and G1/G5 | OPEN |
## Predictive-memory and explanation requirements
These rows trace ADR-350. This PR creates no sensing-server, RuVector or RuVLLM
runtime authority; the rows remain open until a separately reviewed bridge
provides the evidence.
| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state |
|---|---|---|---|---|
| PM-001 | Cross-tenant/split/version/time-scope query negatives | Signed index-manifest and tenancy review | ADR-350 and G3/G5 | OPEN |
| PM-002 | Property tests reject holdout identities and overlapping contexts/horizons | Frozen leakage-report review | ADR-350 and G3 | OPEN |
| PM-003 | Bounded zero/error/success retrieval receipts and tamper tests | Retrieval receipt schema approval | ADR-350 and G2/G3 | OPEN |
| PM-004 | Same-example, same-artifact paired retrieval-off/on evaluator | Frozen paired ablation and overhead report | ADR-350 and G3 | OPEN |
| PM-005 | Envelope signature mutation, expiry, tenant, key and replay negatives | Local signing/key-rotation receipt | ADR-350 and G2/G5 | OPEN |
| PM-006 | Evidence-monotonicity property tests reject measured-observation promotion | Evidence-engine owner review | ADR-350 and G2/G4 | OPEN |
| PM-007 | Numeric/unit/provenance mutation corpus fails closed | RuVLLM adapter review | ADR-350 and G4 | OPEN |
| PM-008 | Default-deny capability and attempted-escalation tests | Downstream policy/capability review | ADR-350 and G4/G5 | OPEN |
| PM-009 | Tenant deletion, retention and access-audit tests | Privacy and membership/extraction-risk approval | ADR-350 and G5 | OPEN |
## Threat-model contract coverage
The IDs below are defined by
[`../security/ruview-forecast-threat-model.md`](../security/ruview-forecast-threat-model.md).
The feature job compiles and runs the current mock-backed suite, but an ID stays
open until the named negative test and its retained CI report exist.
| Contract IDs | Required machine evidence | Current state |
|---|---|---|
| PRIV-001 through PRIV-009 | Egress-denial, tenant-isolation, log-redaction, nonce-isolation and tracked-data-policy negatives | OPEN |
| AUTH-001 through AUTH-005; STATE-001 through STATE-004 | Fail-closed identity/scope/service-auth tests and property-tested idempotent transition machine | OPEN |
| BOUND-001 through BOUND-007 | Boundary/property cases, parser fuzzing, cumulative resource limits and bounded accelerated replay | OPEN |
| RVEC-001 through RVEC-006 | Scope-authority construction denial, identifier/privacy bounds, isolation, cancellation and non-persistence evidence | OPEN |
| FAL-001 through FAL-012; SSRF-001; PATH-001 through PATH-003 | Mock request capture, exact-body webhook/replay cases, app/build/expiry binding, URL/DNS/redirect denial and filesystem escape negatives | OPEN |
| ART-001 through ART-005; DE-001; EVID-001 through EVID-002 | Artifact tamper/rollback/crash matrix, trusted-type construction denial and evidence-authority invariants | OPEN |
## Gate evidence bundles
| Gate | Minimum bundle before review | Status |
|---|---|---|
| G0 | Clean-room manifest, contributor attestations, repository/artifact scan report, adjudication log | OPEN |
| G1 | Dataset rights manifests, byte-level lineage report, random-initialization receipt, local/hosted digest comparison | OPEN |
| G2 | Focused tests, property/fuzz reports, deterministic replay hash, dependency-boundary tree, 24-hour replay report | OPEN |
| G3 | Frozen split manifest, baseline/model/retrieval rows, weighted quantile loss, interval calibration, reproducer and report digests | OPEN |
| G4 | Fourteen-day shadow report, empty-room/occupied-room confusion matrices, drift/abstention slices, authority audit | OPEN |
| G5 | Dedicated-host latency and process-RSS report, signed model card, SBOM/provenance, rollback drill, security/privacy/legal approvals | OPEN |
## Evidence record template
Copy one row per immutable evidence artifact. Never replace a failed result.
| Evidence ID | Requirement/gate | Commit | Lock/config/data digest | Environment | Command | Result | Evidence label | Artifact digest | Reviewer/date |
|---|---|---|---|---|---|---|---|---|---|
No evidence artifacts have been accepted.
@@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView · ADR-324 · off-axis window (ruview-offaxis WASM)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='7' y='9' width='18' height='14' rx='2' fill='none' stroke='%23e8a634' stroke-width='2'/><circle cx='16' cy='16' r='3' fill='%23e8a634'/></svg>">
<style>
:root {
--bg: #0a0a0a;
--bg-panel: rgba(0, 0, 0, 0.88);
--amber: #e8a634;
--amber-dim: #4a3a1a;
--amber-hot: #ffc04d;
--grid-major: #444444;
--grid-minor: #222222;
--green: #4f4;
--blue: #4cf;
--red: #f66;
--text-mute: #888;
--border: #2a2a2a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--amber);
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
canvas { display: block; }
#info {
position: absolute;
top: 16px;
left: 16px;
padding: 14px 16px;
background: var(--bg-panel);
border: 1px solid var(--amber);
border-radius: 8px;
min-width: 280px;
max-width: 360px;
font-size: 12px;
line-height: 1.55;
z-index: 10;
backdrop-filter: blur(6px);
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
}
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
#info .row .k { color: var(--text-mute); }
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
#info .row .v.live { color: var(--green); }
#info .row .v.warn { color: var(--red); }
/* The mandatory ADR-324 §2.4 mode label: always visible while RF
drives the camera; there is no configuration that hides it. */
#mode-label {
position: absolute;
top: 16px;
right: 16px;
padding: 8px 14px;
background: var(--bg-panel);
border: 1px solid var(--blue);
border-radius: 8px;
color: var(--blue);
font-size: 12px;
z-index: 11;
}
#mode-label.rf { border-color: var(--red); color: var(--red); }
#controls {
position: absolute;
bottom: 16px;
left: 16px;
padding: 12px 16px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
z-index: 10;
max-width: 340px;
}
#controls h2 { margin: 0 0 8px 0; font-size: 12px; color: var(--text-mute); }
#controls label { display: flex; justify-content: space-between; gap: 8px; margin: 4px 0; align-items: center; }
#controls input[type="number"] {
width: 70px; background: #111; border: 1px solid var(--border);
color: var(--amber); font-family: inherit; font-size: 12px; padding: 2px 6px; border-radius: 4px;
}
#controls input[type="text"] {
width: 190px; background: #111; border: 1px solid var(--border);
color: var(--amber); font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;
}
#controls button {
background: var(--amber-dim); border: 1px solid var(--amber); color: var(--amber-hot);
font-family: inherit; font-size: 12px; padding: 4px 10px; border-radius: 5px; cursor: pointer; margin-top: 6px;
}
#controls button:hover { background: var(--amber); color: #000; }
#wasm-missing {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
z-index: 50;
}
#wasm-missing .box {
max-width: 560px; border: 1px solid var(--amber); border-radius: 10px;
background: var(--bg-panel); padding: 22px 26px; font-size: 13px; line-height: 1.7;
}
#wasm-missing code { color: var(--amber-hot); background: #151005; padding: 1px 5px; border-radius: 4px; display: block; margin: 4px 0; }
</style>
</head>
<body>
<div id="info">
<h1>OFF-AXIS WINDOW</h1>
<div class="sub">ADR-324 · clean-room Kooima projection · ruview-offaxis (Rust→WASM)</div>
<div class="row"><span class="k">engine</span><span class="v" id="hud-engine">loading…</span></div>
<div class="row"><span class="k">input</span><span class="v" id="hud-input">mouse (SYNTHETIC)</span></div>
<div class="row"><span class="k">eye x/y/z (m)</span><span class="v" id="hud-eye">—</span></div>
<div class="row"><span class="k">render fps</span><span class="v live" id="hud-fps">—</span></div>
<div class="row"><span class="k">rf socket</span><span class="v" id="hud-ws">not connected</span></div>
<div class="row"><span class="k">rf peak</span><span class="v" id="hud-peak">—</span></div>
<div class="row" style="margin-top:8px"><span class="k" style="font-size:10px">
keys: <b>M</b> mouse · <b>R</b> RF Tier B · wheel = distance</span></div>
</div>
<div id="mode-label">MOUSE SIM — SYNTHETIC INPUT</div>
<div id="controls">
<h2>PHYSICAL CALIBRATION (stored locally)</h2>
<label>screen width (cm) <input id="cal-w" type="number" step="0.5" value="60"></label>
<label>screen height (cm) <input id="cal-h" type="number" step="0.5" value="34"></label>
<label>viewing distance (cm) <input id="cal-d" type="number" step="1" value="65"></label>
<h2 style="margin-top:10px">RF SOURCE (Tier B)</h2>
<label>ws url <input id="ws-url" type="text" value="ws://127.0.0.1:8080/ws/sensing"></label>
<button id="apply">apply calibration</button>
</div>
<div id="wasm-missing">
<div class="box">
<b>ruview-offaxis WASM module not found.</b><br><br>
This demo loads the crate's wasm-bindgen output from
<code>v2/crates/ruview-offaxis/pkg/</code>. Generated artifacts are not
committed (repo rule); build them once:
<code>cd v2 && cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release</code>
<code>wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
target/wasm32-unknown-unknown/release/ruview_offaxis.wasm</code>
(install the CLI with <code>cargo install wasm-bindgen-cli --version 0.2.114</code>)<br>
then reload. Full steps: <code>v2/crates/ruview-offaxis/README.md</code>.
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script type="module">
// ADR-324 demo: the projection math lives in Rust/WASM (ruview-offaxis).
// This file only wires inputs (mouse SYNTHETIC sim, or /ws/sensing
// signal_field for the labeled Tier B coarse-parallax mode) into the
// WASM camera and copies its matrices onto a three.js camera.
const $ = (id) => document.getElementById(id);
// ---- Load the WASM module (not committed; user builds it once). ----
let wasm;
try {
wasm = await import('../../../v2/crates/ruview-offaxis/pkg/ruview_offaxis.js');
await wasm.default();
$('hud-engine').textContent = 'ruview-offaxis wasm';
} catch (e) {
console.error('ruview-offaxis pkg not found', e);
$('wasm-missing').style.display = 'flex';
throw e;
}
const { OffAxisCamera, RfParallax } = wasm;
// ---- Calibration (persisted locally; never leaves the browser). ----
const CAL_KEY = 'ruview-offaxis-demo-cal';
const saved = JSON.parse(localStorage.getItem(CAL_KEY) || 'null');
if (saved) { $('cal-w').value = saved.w; $('cal-h').value = saved.h; $('cal-d').value = saved.d; }
const cal = () => ({ w: +$('cal-w').value || 60, h: +$('cal-h').value || 34, d: +$('cal-d').value || 65 });
let cam = new OffAxisCamera(cal().w, cal().h, cal().d, 0.05, 100.0);
cam.set_filter(1.2, 0.4); // interactive: light smoothing, quick catch-up
let rf = new RfParallax(cal().d / 100);
$('apply').onclick = () => {
const c = cal();
localStorage.setItem(CAL_KEY, JSON.stringify(c));
cam = new OffAxisCamera(c.w, c.h, c.d, 0.05, 100.0);
cam.set_filter(1.2, 0.4);
rf = new RfParallax(c.d / 100);
buildRoom(); // room proportions follow the physical screen
};
// ---- three.js scene: a room extending behind the screen plane. ----
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
// Camera is fully driven by the WASM matrices.
const camera = new THREE.PerspectiveCamera();
camera.matrixAutoUpdate = false;
let room = new THREE.Group();
function buildRoom() {
scene.remove(room);
room = new THREE.Group();
const c = cal();
const W = c.w / 100, H = c.h / 100, DEPTH = Math.max(W, 0.8) * 2.0;
// Wireframe box behind the screen: the classic "window" cue.
const boxGeo = new THREE.BoxGeometry(W, H, DEPTH);
const edges = new THREE.EdgesGeometry(boxGeo);
const box = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe8a634 }));
box.position.z = -DEPTH / 2; // screen plane is z = 0
room.add(box);
// Depth rails: rows of columns receding into the box.
const colMat = new THREE.MeshStandardMaterial({ color: 0x4a3a1a, emissive: 0x2a1f08 });
for (let i = 1; i <= 6; i++) {
for (const sx of [-1, 1]) {
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, H * 0.9, 12), colMat);
col.position.set(sx * W * 0.42, 0, -DEPTH * i / 7);
room.add(col);
}
}
// Floating objects at staggered depths (parallax targets).
const knotMat = new THREE.MeshStandardMaterial({ color: 0xe8a634, metalness: 0.4, roughness: 0.35 });
const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(H * 0.18, H * 0.05, 120, 16), knotMat);
knot.position.set(0, 0, -DEPTH * 0.45);
knot.name = 'knot';
room.add(knot);
const orb = new THREE.Mesh(
new THREE.IcosahedronGeometry(H * 0.08, 1),
new THREE.MeshStandardMaterial({ color: 0x4cf0ff, emissive: 0x0a3540 })
);
orb.position.set(-W * 0.22, H * 0.18, -DEPTH * 0.18);
room.add(orb);
// One object slightly IN FRONT of the screen plane — pops "out".
const pop = new THREE.Mesh(
new THREE.OctahedronGeometry(H * 0.05),
new THREE.MeshStandardMaterial({ color: 0xffc04d, emissive: 0x604010 })
);
pop.position.set(W * 0.28, -H * 0.2, 0.06);
room.add(pop);
room.add(new THREE.AmbientLight(0xffffff, 0.35));
const key = new THREE.PointLight(0xffe0a0, 1.0);
key.position.set(0.3, 0.4, 0.5);
room.add(key);
scene.add(room);
}
buildRoom();
// ---- Input modes. ----
// 'mouse' — SYNTHETIC eye simulator (always available, no hardware).
// 'rf' — Tier B: /ws/sensing signal_field → RfParallax.
// Labeled coarse body parallax, NOT head tracking (ADR-324 §2.4).
let mode = 'mouse';
const modeLabel = $('mode-label');
function setMode(m) {
mode = m;
if (m === 'rf') {
modeLabel.textContent = 'RF COARSE BODY PARALLAX — NOT HEAD TRACKING';
modeLabel.classList.add('rf');
$('hud-input').textContent = 'rf field peak (Tier B)';
connectWs();
} else {
modeLabel.textContent = 'MOUSE SIM — SYNTHETIC INPUT';
modeLabel.classList.remove('rf');
$('hud-input').textContent = 'mouse (SYNTHETIC)';
}
}
window.addEventListener('keydown', (e) => {
if (e.key === 'm' || e.key === 'M') setMode('mouse');
if (e.key === 'r' || e.key === 'R') setMode('rf');
});
// Mouse sim: pointer position maps to a ±0.3 m eye excursion;
// wheel adjusts distance.
let mouseEye = { x: 0, y: 0, d: cal().d / 100 };
window.addEventListener('pointermove', (e) => {
mouseEye.x = (e.clientX / window.innerWidth - 0.5) * 0.6;
mouseEye.y = (0.5 - e.clientY / window.innerHeight) * 0.4;
});
window.addEventListener('wheel', (e) => {
mouseEye.d = Math.min(2.5, Math.max(0.2, mouseEye.d + e.deltaY * 0.0005));
}, { passive: true });
// ---- RF Tier B input: /ws/sensing sensing_update.signal_field. ----
let ws = null;
function connectWs() {
if (ws) { try { ws.close(); } catch (_) {} }
const url = $('ws-url').value;
$('hud-ws').textContent = 'connecting…';
try { ws = new WebSocket(url); } catch (e) {
$('hud-ws').textContent = 'invalid url'; return;
}
ws.onopen = () => { $('hud-ws').textContent = 'connected'; };
ws.onclose = () => { $('hud-ws').textContent = 'closed'; };
ws.onerror = () => { $('hud-ws').textContent = 'error (server up? ticket needed?)'; };
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
const field = msg.signal_field || (msg.data && msg.data.signal_field);
if (!field || !field.values) return;
const nx = field.grid_size || field.nx || 20;
const nz = field.grid_size || field.nz || 20;
const values = Float32Array.from(field.values);
const found = rf.update(values, nx, nz, performance.now() / 1000);
$('hud-peak').textContent = found
? `value ${rf.peak_value().toFixed(2)} (≥ 0.35 gate)`
: 'below 0.35 gate — holding';
// Provenance surfaced verbatim (ADR-295: synthetic never
// presents as live).
if (msg.provenance || msg.source) {
$('hud-ws').textContent = `connected · src: ${msg.provenance || msg.source}`;
}
} catch (_) { /* non-JSON frame */ }
};
}
// ---- Render loop: one WASM call, three matrix copies, render. ----
const tmp = new THREE.Matrix4();
let frames = 0, lastFps = performance.now();
function animate() {
requestAnimationFrame(animate);
const t = performance.now() / 1000;
if (mode === 'mouse') {
cam.update_eye(mouseEye.x, mouseEye.y, mouseEye.d, t);
} else {
const e = rf.eye(); // bounded coarse-parallax eye (metres)
cam.update_eye(e[0], e[1], e[2], t);
}
// Copy the Kooima matrices onto the three.js camera.
camera.projectionMatrix.fromArray(cam.projection());
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
tmp.fromArray(cam.view());
camera.matrixWorld.copy(tmp).invert(); // world = inverse(view)
camera.matrixWorldInverse.copy(tmp);
const knot = room.getObjectByName('knot');
if (knot) { knot.rotation.y += 0.003; knot.rotation.x += 0.001; }
renderer.render(scene, camera);
const eye = cam.eye();
$('hud-eye').textContent = `${eye[0].toFixed(3)} / ${eye[1].toFixed(3)} / ${eye[2].toFixed(3)}`;
frames++;
const now = performance.now();
if (now - lastFps > 1000) {
$('hud-fps').textContent = String(frames);
frames = 0; lastFps = now;
}
}
animate();
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
// NOTE: no camera.aspect update — the frustum is fully determined
// by the physical screen calibration, not the browser viewport.
});
</script>
</body>
</html>
+117 -18
View File
@@ -7,40 +7,100 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr
[![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
[![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE)
[![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget)
[![Binary: up to 1.1 MB](https://img.shields.io/badge/binary-up%20to%201.1%20MB-orange.svg)](#memory-budget)
[![CI: Docker Build](https://img.shields.io/badge/CI-Docker%20Build-brightgreen.svg)](../../.github/workflows/firmware-ci.yml)
> | Capability | Method | Performance |
> |------------|--------|-------------|
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | ~20 Hz, ADR-018 binary format |
> | **Breathing detection** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | 6-30 BPM |
> | **Heart rate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | 40-120 BPM |
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
> | Capability | Method | Current contract |
> |------------|--------|------------------|
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | Radio-dependent cadence with a 20 packets-per-second hardware acceptance floor, ADR-018 binary format |
> | **Breathing estimate** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | Experimental 6-30 BPM output; calibrate against a reference before use |
> | **Heart-rate estimate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | Experimental 40-120 BPM output; not a medical measurement |
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | Fast local indicator; strong RF interference can cause false positives — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
## Firmware 0.8.12: guided Mac onboarding
Firmware 0.8.12 adds the protocol used by the RuView macOS Add
Sensor workflow. A directly attached S3 or C6 can identify itself through a
nonce bound USB serial receipt, preserve its current WiFi settings, accept an
unused node ID and private LAN server address, then reboot. The Mac app does
not report success until the sensing server observes that node as fresh.
This is not a firmware flashing API. A board on 0.8.8 or earlier must receive a
matching chip build through the documented backup and flash process once. The
USB protocol rejects public target addresses and invalid identifiers. New WiFi
credentials are bounded, committed only in NVS, and never included in the
receipt or diagnostic output. See
[ADR 363](../../docs/adr/ADR-363-native-macos-usb-node-onboarding.md).
On ESP32 C6, the default optional mmWave UART uses GPIO 4 and GPIO 5. Firmware
refuses any mmWave configuration that overlaps the active console pins. This
prevents a sensor probe from taking over UART0 RX and disabling Mac onboarding.
## Firmware 0.8.8 in plain language
Release 0.8.8 makes the sensing stream more internally consistent and easier
to diagnose:
1. An empty-room decision can no longer carry a nonzero person count. Older
firmware could expose those two contradictory values at the same time.
2. ESP32-C6 signal processing now uses a stable 8 Hz clock while raw CSI keeps
streaming at the faster radio-dependent rate. This prevents temporal
filters from silently using the wrong time scale.
3. The one-second diagnostic reports both raw callback yield and the DSP rate,
making slow or overloaded nodes visible.
4. OTA reports the application slot selected by the board instead of assuming
a fixed 900 KB limit.
Two ESP32-C6 boards and one ESP32-S3 completed five-minute physical transport
runs. The updated nodes had zero steady-state send failures, parser failures,
watchdogs, panics, or reboots. These results prove timing and transport
stability, not better heartbeat, pose, identity, or person-count accuracy.
See the [0.8.8 release notes](../../docs/releases/v0.8.8-esp32.md) and
[ADR 347](../../docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md) for the
measured evidence and limitations.
---
## Quick Start
For users who want to get running fast. Detailed explanations follow in later sections.
### 0. Pre-built binaries (v0.6.5 — skip the build step)
### 0. Download the 0.8.8 release
Pre-built binaries are in `firmware/esp32-csi-node/release_bins/` (version: see `release_bins/version.txt`).
Flash them directly:
Use the versioned source tag and binaries on the
[v0.8.8 ESP32 release page](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32).
Choose the package that names both your chip and flash size:
| Package | Use it for |
|---------|------------|
| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 8 MB flash |
| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 4 MB flash |
| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | Fresh ESP32-C6 installation using the supported 4 MB layout |
Each bundle contains the matching bootloader, partition table, OTA metadata,
application, checksums, and a short flashing guide. Never flash an S3 bundle
onto a C6, or a C6 bundle onto an S3.
Example for an 8 MB ESP32-S3 after extracting its bundle:
```bash
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 8MB \
0x0 firmware/esp32-csi-node/release_bins/bootloader.bin \
0x8000 firmware/esp32-csi-node/release_bins/partition-table.bin \
0xf000 firmware/esp32-csi-node/release_bins/ota_data_initial.bin \
0x20000 firmware/esp32-csi-node/release_bins/esp32-csi-node.bin
0x0 bootloader.bin \
0x8000 partition-table.bin \
0xf000 ota_data_initial.bin \
0x20000 esp32-csi-node.bin
```
For 4 MB boards use `release_bins/esp32-csi-node-4mb.bin` and `release_bins/partition-table-4mb.bin`
with `--flash_size 4MB`.
For an existing provisioned node, back up its current application and inspect
`http://DEVICE_IP:8032/ota/status` before choosing an application-only update.
Writing only offset `0x20000` is safe only when the status endpoint reports
`running_partition` as `ota_0` and the downloaded image matches the board.
The full bundles do not include NVS, so the documented four-offset install
preserves WiFi and node configuration while replacing the boot and application
images.
### 1. Build (Docker -- the only reliable method)
@@ -74,6 +134,30 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
#### Seeed XIAO ESP32-C6 external antenna
The XIAO ESP32-C6 has a hardware RF switch between its internal ceramic
antenna and U.FL connector. Generic C6 boards do not share this wiring, so the
firmware leaves GPIO3 and GPIO14 untouched by default. For an XIAO with an
external antenna already attached while powered off, build with one of the
opt-in overlays:
```bash
# Controlled A condition: internal antenna, RF path stated in the boot log.
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-internal" \
set-target esp32c6
idf.py build
# Controlled B condition: external U.FL antenna.
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" \
set-target esp32c6
idf.py build
```
The boot log must report the intended `internal ceramic` or `external U.FL`
path before that measurement is labelled. The standard C6 build keeps the
board default and emits no antenna-path claim.
### 3. Provision WiFi credentials (no reflash needed)
```bash
@@ -111,7 +195,7 @@ curl http://<ESP32_IP>:8032/wasm/list
| **Recommended boards** | ESP32-S3-DevKitC-1, XIAO ESP32-S3 | Any ESP32-S3 with 8 MB flash works |
| **Deployment** | 3-6 nodes per room | Multistatic mesh for 360-degree coverage |
> **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps.
> **Tip:** A single node is mainly useful for presence and motion along one RF link. Three or more spatially separated links improve geometry and track separation. Location, pose, and multi-person accuracy still require room-specific calibration and held-out ground-truth evaluation.
> **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing.
@@ -216,10 +300,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)
```
@@ -271,6 +362,14 @@ The `MSYS_NO_PATHCONV=1` prefix prevents Git Bash from mangling the `/project` p
- `build/partition_table/partition-table.bin` -- flash partition layout
- `build/esp32-csi-node.bin` -- application firmware
### Release bundles
Firmware downloads are published from dedicated `vX.Y.Z-esp32` tags after all
S3 and C6 matrix builds pass. Each release contains separate S3 8 MB, S3 4 MB,
and C6 4 MB archives plus an archive checksum manifest. The repository's
automated `vNNNN` server releases (for example `v2655`) are not firmware tags;
do not use their presence or absence of assets as firmware provenance.
### Custom Configuration
To change Kconfig settings before building:
+334
View File
@@ -0,0 +1,334 @@
# Firmware runbook — ESP32-C6 CSI node
Operational procedure. `README.md` explains the project; this says exactly what
to run, in what order, and what to check. Follow it top to bottom.
If you are an agent: **read this file before touching firmware.** Everything
below was learned by getting it wrong at least once.
---
## 1. Build
**One command. Copy it.** From the **repository root**, not this directory:
```bash
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.4 bash -c \
"cat sdkconfig.defaults sdkconfig.defaults.16mb sdkconfig.defaults.esp32c6 \
> sdkconfig.defaults.build && \
SDKCONFIG_DEFAULTS='sdkconfig.defaults.build' idf.py set-target esp32c6 && \
idf.py build"
```
Takes ~3 minutes cold, well under a minute incremental.
### The three defaults files are NOT all automatic
IDF picks up `sdkconfig.defaults` and `sdkconfig.defaults.<target>` on its own.
It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
Build without it and you get an image that is correct in every visible respect
and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in
the app expecting the capability. Silent, and only discoverable by pushing a
deliberately bad image.
That is why the command above concatenates all three explicitly rather than
relying on discovery.
### Never `rm -rf sdkconfig` without a backup
The README's headline example opens with `rm -rf build sdkconfig`. `sdkconfig`
is **untracked and gitignored**. `partitions_16mb.csv` was lost to exactly this,
and `CLAUDE.md` warns about it *even when a README documents it*.
```bash
cp sdkconfig "sdkconfig.backup-$(date +%Y%m%d-%H%M%S)" # then regenerate
```
A stale `sdkconfig` is a real hazard, not a hypothetical: one found on
2026-09-03 said `FLASHSIZE=4MB`, `partitions_4mb.csv`,
`DYNAMIC_TX_BUFFER_NUM=64` — against a fleet running 16MB and 128. Anything
built from it would silently not be the configuration under test.
### Verify before you flash anything
```bash
grep -E "^CONFIG_(IDF_TARGET|ESPTOOLPY_FLASHSIZE|PARTITION_TABLE_CUSTOM_FILENAME|\
ESP_WIFI_DYNAMIC_TX_BUFFER_NUM|BOOTLOADER_APP_ROLLBACK_ENABLE)=" sdkconfig
```
All five must read:
| key | expected |
|---|---|
| `CONFIG_IDF_TARGET` | `"esp32c6"` |
| `CONFIG_ESPTOOLPY_FLASHSIZE` | `"16MB"` |
| `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` | `"partitions_16mb.csv"` |
| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `128` |
| `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` | `y` |
**Do not use the native `C:\Espressif\...esp-idf-v5.5.5`.** Wrong version, its
venv does not match the default python, and a container-built `build/` refuses
a native build outright. Docker only.
---
## 2. What OTA can and cannot reach
This is the distinction that decides whether a change needs a cable.
| region | offset | reachable by OTA? |
|---|---|---|
| bootloader | `0x0` | **NO — USB only** |
| partition table | `0x8000` | **NO — USB only** |
| otadata | `0xF000` | rewritten by OTA |
| ota_0 | `0x20000` | yes (4 MB) |
| ota_1 | `0x420000` | yes (4 MB) |
| coredump | `0x820000` | no |
| storage (fat) | `0x830000` | no |
| nvs | `0x9000` | never written by a flash — provisioning survives |
**OTA replaces the app partition only.** Anything in the bootloader —
`CONFIG_BOOTLOADER_*`, notably `APP_ROLLBACK_ENABLE` — or any partition-table
change **requires USB on every board**.
There is **no bootloader version on the wire**. A node's sync packet reports app
health, not bootloader capability, so you cannot determine remotely which boards
have a rollback-capable bootloader. Track it per board, or do a USB pass across
all of them.
Corollary: "every node reports health" proves the **app** is current and proves
nothing about the bootloader. Do not answer a bootloader question with app
evidence.
---
## 3. Deploying
### Over the air — app-only changes
Works end-to-end and is the default for anything that is not a bootloader or
partition change. Roll to **one node**, soak it, then the rest.
### Over USB — bootloader, partition table, or recovery
```bash
python -m esptool --chip esp32c6 -b 460800 \
--before default_reset --after hard_reset write_flash \
--flash_mode dio --flash_size 16MB --flash_freq 80m \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0xf000 build/ota_data_initial.bin \
0x20000 build/esp32-csi-node.bin
```
`0x9000` is not in that list, so SSID, password and `node_id` survive.
### Safe single-board diagnostic flash, with a revert path
Nodes run from whichever slot `otadata` selects. **Parse it before flashing** —
two 32-byte entries at offsets 0 and 0x1000; active slot is
`(max ota_seq - 1) % 2`.
1. Back up `otadata` (8 KB) **and** the live app slot with `esptool read-flash`.
2. Flash the diagnostic build — it lands in `ota_0` and rewrites `otadata`.
3. **Revert = write the 8 KB `otadata` backup back.** Production in `ota_1` was
never touched, so no rebuild is needed.
Validate any backup image before trusting it: byte 0 must be `0xE9`, and the
`esp_app_desc_t` magic at offset `0x20` must be `0xABCD5432`.
**Never flash the whole fleet from a workstation, and never use the server's OTA
endpoint for a diagnostic.** USB, one board, revert path secured first.
---
## 4. Serial
CH343 USB-serial at 115200.
To read a running board **without resetting it**, open with
`DtrEnable=$false; RtsEnable=$false`. esptool asserts RTS and *will* reset the
board, which loses the state you were trying to observe.
---
## 5. Changing config without a cable
Most parameters do not need a flash at all:
```bash
export RUVIEW_OTA_PSK_FILE=<path to the psk file> # never inline the key
python config_push.py --node <node-ip> --get
python config_push.py --node <node-ip> --set led_brightness=10
```
Keys marked `assoc: true` in `config_api.c` (`wifi_ssid`, `wifi_password`,
`channel_hop_count`, `dwell_ms`, `csi_channel`) are applied as a **trial**: the
node banks its current values, reboots, and keeps the change only if it
re-associates. Everything else applies without that risk.
`config_push.py` has **no unset**. To restore a default you must set it
explicitly. Current defaults:
| key | default | source |
|---|---|---|
| `vital_interval_ms` | 1000 | `Kconfig.projbuild` |
| `swarm_heartbeat_sec` | 30 | `nvs_config.c` |
| `swarm_ingest_sec` | 5 | `nvs_config.c` |
| `beacon_period_ms` | 0 = derive from fleet size | `nvs_config.c` |
---
## 6. Reading a node remotely
`GET /api/v1/mesh` returns per-node `health`: `min_heap_kib`, `die_c`,
`thermal_state`, `tx_dbm`, `reset_reason`, and from sync proto v3 the TX-path
counters `send_fail` / `rate_skip` / `early_drop`.
`min_heap_kib` is a **minimum-since-boot watermark**, not an instantaneous
reading. It resets on reboot and only ever falls. Sampling it within a minute of
a restart tells you nothing — that mistake produced a wrong "no effect" verdict
on 2026-09-03; the real answer, once it had settled, was −56 KiB.
`/api/v1/nodes` `rssi_dbm` is **not** the AP link — it is whatever link landed
most recently and swings 20+ dB with no physical change. Filter `/api/v1/links`
by the AP's BSSIDs instead, and read `csi_fps_ema` alongside, because link count
alone hides a starved node.
---
## 7. Tunables, and which ones depend on your house
`idf.py menuconfig` exposes these. The defaults are what a nine-node ESP32-C6
fleet in one house settled on; several are **not** universal, and the point of
listing them here is that you can pick and measure rather than inherit someone
else's building.
| option | default | depends on your house? |
|---|---|---|
| `CSI_GATE_MESH_ALIGNED` | y | **yes** |
| `CSI_SEQ_DIAG` | n | diagnostic only |
| `THERMAL_MONITOR` | y | no |
| `THERMAL_THROTTLE` | n | **yes** |
| `THERMAL_THROTTLE_C` | 72 | **yes** |
| `THERMAL_HYSTERESIS_C` | 8 | no |
| `UPLINK_WATCHDOG` | y | no |
| `UPLINK_WATCHDOG_TIMEOUT_S` | 1200 | **yes** |
### The ones worth measuring yourself
**`CSI_GATE_MESH_ALIGNED`** decides whether nodes keep the *same* frames or
merely the same *number* of frames, which is what makes cross-node pairing
possible at all. Whether alignment helps depends on how much your nodes
actually overhear in common, and that is a property of your walls and your
transmitters. In one measured nine-node fleet, aligning windows bought
+0.5 pp of pairing while selecting frames by `rx_seq` bought +31.7 pp -- so do
not assume the default is doing the heavy lifting in your building.
Turn on `CSI_SEQ_DIAG` to measure it. It logs `(source MAC, rx_seq)` per CSI
callback, which is what lets you compute the fraction of frames two nodes hold
in common instead of guessing.
**`THERMAL_THROTTLE` / `THERMAL_THROTTLE_C`** depend on enclosure and placement,
not on the chip. A bare bench board never reaches 72 C; the same board sealed in
a printed case on a sunny wall can. Read `die_c` from `/api/v1/mesh` for a few
days before deciding, because throttling transmit power costs range.
**`UPLINK_WATCHDOG_TIMEOUT_S`** is a bet about your AP. 1200 s suits a network
where a wedge is rare and a reboot is cheap. If your uplink drops briefly and
often, a short timeout turns a recoverable gap into a reboot loop; if it wedges
hard, a long one leaves a node dark for twenty minutes.
### A worked example: the same two options, in one house
MEASURED on a nine-node ESP32-C6 fleet in a single detached house. **These are
our numbers, not yours.** They are here to show the shape of the decision, and
how much a building can move it.
Four arms as a 2x2 factorial, 20-minute runs, three Latin-square blocks so slow
drift landed on every arm equally. Metric is the fraction of transmissions held
in common by two or more nodes.
| arm | mean |
|---|---|
| `elapsed` (per-node gate) | 0.2434 |
| `mesh` (aligned windows) | 0.2480 |
| `seq` (keep `rx_seq` divisible by 8) | **0.5605** |
| `mesh` + `seq` | 0.5452 |
`seq` roughly doubled pairing, +31.7 pp, about 70x the measured drift floor and
reproduced in all three blocks. Mesh alignment bought **+0.5 pp** -- inside
noise. Stacking them was very slightly *worse* than `seq` alone.
**Why that happened here, and why yours may differ.** These nodes already
overheard a great deal in common -- two boards side by side heard the same
transmissions 72% of the time -- so there was a large pool of shared
transmissions for `seq` to select from, and little left for window alignment to
recover. A house with fewer shared transmitters, thicker interior walls, or
nodes spread so that few pairs hear the same radios has less for `seq` to work
with, and phase alignment may matter more rather than less. We did not measure
such a house and do not claim to know.
**It is a trade, not a free win.** At period 8 the `seq` arms carried ~75-95k
transmissions per 20 minutes against ~120-160k ungated: pairing is bought with
raw frame rate. Absolute paired transmissions per minute still rose (2629 vs
2524), because the discarded frames are disproportionately ones only one node
would have kept. Whether that trade is right depends on what consumes your
stream. On-device edge processing was **unaffected** here -- frames processed
stayed ~1900 per node per 4 minutes in both modes, because that pipeline is
separately rate-limited -- so the cost fell on the uplink, not on local sensing.
If your uplink is the scarce resource, that changes the answer.
**The two options differ in kind, not just in effect.** Mesh alignment is an
agreement between receivers: it needs a shared clock, so it inherits whatever
the sync topology does. Selecting by `rx_seq` needs no agreement at all. The
802.11 sequence control field is assigned by the TRANSMITTER and is identical
at every receiver, so it is a coordination-free name for one transmission --
two nodes that have never heard of each other, sit in different sync domains,
or run with no mesh whatsoever still keep the same subset of frames.
In the code the seq test runs before any sync check and reads only the frame;
only the mesh branch calls `c6_sync_espnow_is_valid()`. That asymmetry is the
reason `seq` had nothing to degrade in our runs, and it is worth weighing
directly: if your fleet's sync is fragile, `seq` is not merely the better
performer, it is the one with no dependency that can quietly fail.
**A limitation of our own measurement, which cuts against the mesh arm.** The
mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`; a node
with no valid sync falls back to the elapsed gate rather than gating on a
meaningless epoch. That is the right behaviour, but it means our `mesh` arms
were partly `elapsed` arms: three of nine nodes never synchronised, so for those
three the setting under test was silently not in effect. Read the +0.5 pp as
"mesh where sync held, elapsed where it did not" rather than as a clean
fleet-wide measurement of alignment.
This is itself a house-dependent property. Time sync here is leader-elected and
hub-and-spoke -- one node broadcasts and the rest follow. Whether every node can
hear that leader is a question about your building, not about the firmware. In a
larger or more divided house the leader may not reach everyone, the fleet
fragments into sync domains, and any option that depends on shared time degrades
quietly for the nodes that fell out. Check `is_leader` and sync validity in
`/api/v1/mesh` before concluding that an alignment setting did or did not help.
**A prediction we got wrong, kept because it is the useful part.** Our mesh had
split into two leader domains with three nodes never synchronising at all. We
expected `seq` to win most on pairs spanning that split. Per-pair yield said
otherwise: same-domain 5.82x, cross-domain 6.24x, involving a never-synced node
**5.16x** -- the lowest, the opposite of the prediction -- and within-group
spread (3.6x-8.1x) dwarfed every between-group difference.
So `seq` is **not** a workaround for broken time sync; it wins roughly
uniformly, including where sync is healthy. The consequence is negative and
worth having: repairing the mesh split would probably not have closed the gap,
and that effort would have been spent for nothing.
### Measuring rather than guessing
A gate comparison needs a fixed environment, repeated blocks, and every arm run
on every node -- otherwise node placement is confounded with the setting. The
harness used for the published comparison is in the repository; use it rather
than eyeballing a frame rate, which moves for reasons unrelated to the setting.
+15 -1
View File
@@ -1,5 +1,7 @@
set(SRCS
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
"main.c" "csi_collector.c" "node_log.c"
"thermal.c" "stream_sender.c" "nvs_config.c"
"serial_onboarding.c" "serial_onboarding_protocol.c"
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
@@ -13,6 +15,7 @@ set(SRCS
"c6_twt.c"
"c6_timesync.c"
"c6_lp_core.c"
"c6_antenna_select.c"
# ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6)
"c6_sync_espnow.c"
# ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled)
@@ -30,6 +33,9 @@ set(REQUIRES
esp_http_client
esp_app_format
esp_timer
fatfs # node_log.c: the storage FAT volume
wear_levelling # node_log.c: wl_mount behind that volume
espcoredump # coredump-to-flash readback
esp_pm
esp_driver_uart
esp_driver_gpio
@@ -38,6 +44,8 @@ set(REQUIRES
driver
lwip
mbedtls
efuse
led_strip
)
# ADR-110: C6-only components — pulled in when building for esp32c6.
@@ -47,6 +55,12 @@ if(IDF_TARGET STREQUAL "esp32c6")
list(APPEND REQUIRES ieee802154 ulp esp_hw_support)
endif()
# ESP-IDF 6 split the temperature sensor driver out of the legacy `driver`
# component. Keep the 5.4 lane working while declaring the v6 dependency.
if(IDF_VERSION_MAJOR GREATER_EQUAL 6)
list(APPEND REQUIRES esp_driver_tsens)
endif()
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
if(CONFIG_CSI_MOCK_ENABLED)
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
@@ -1,5 +1,27 @@
menu "CSI Node Configuration"
config C6_XIAO_ANTENNA_SELECT
bool "Control the Seeed XIAO ESP32-C6 RF switch"
depends on IDF_TARGET_ESP32C6
default n
help
Enable only for Seeed XIAO ESP32-C6 boards. The XIAO uses GPIO3
to enable RF-switch control and GPIO14 to choose its internal or
external antenna. Leave this disabled for generic ESP32-C6 boards
because GPIO3 and GPIO14 may have unrelated functions there.
choice C6_XIAO_ANTENNA_PATH
prompt "Seeed XIAO ESP32-C6 antenna path"
depends on C6_XIAO_ANTENNA_SELECT
default C6_XIAO_ANTENNA_INTERNAL
config C6_XIAO_ANTENNA_INTERNAL
bool "Internal ceramic antenna"
config C6_XIAO_ANTENNA_EXTERNAL
bool "External U.FL antenna"
endchoice
config CSI_NODE_ID
int "Node ID (0-255)"
default 1
@@ -39,6 +61,17 @@ menu "CSI Node Configuration"
help
WiFi channel to listen on for CSI data.
config CSI_SELF_PING_HZ
int "Connected-STA CSI probe rate (Hz)"
default 50
range 10 50
help
Rate of the one-byte ICMP probes used to create a stable OFDM
CSI source on quiet networks. Fifty hertz is the measured safety
ceiling for the current ESP-IDF WiFi callback path. Higher rates
are intentionally rejected because sustained callback load above
50 Hz has caused WiFi ISR and packet-buffer failures on S3 and C6.
endmenu
menu "Edge Intelligence (ADR-039)"
@@ -66,6 +99,18 @@ menu "Edge Intelligence (ADR-039)"
help
Number of highest-variance subcarriers to use for DSP.
config EDGE_DSP_SAMPLE_HZ
int "On-device edge DSP sample rate (Hz)"
default 8 if IDF_TARGET_ESP32C6
default 20
range 8 50
help
Uniform rate at which CSI callbacks enter the Tier 1 and Tier 2
edge DSP. Raw CSI transmission keeps its independent full-rate
path. Eight hertz is the hardware-measured sustainable C6 Tier 2
setting and preserves a 4 Hz Nyquist limit for the 0.1-2.0 Hz
vital bands.
config EDGE_FALL_THRESH
int "Fall detection threshold (x1000)"
default 15000
@@ -494,3 +539,154 @@ menu "Onboard LED (ADR-183)"
colormap, in milli-units (250 = 0.25). Lower = more sensitive
(reaches yellow with less motion).
endmenu
menu "Thermal monitoring"
config THERMAL_MONITOR
bool "Read the on-chip temperature sensor and throttle the radio"
depends on IDF_TARGET_ESP32C6
default y
help
The C6 has a temperature sensor this firmware otherwise never reads.
Harmless on a bare bench board; not once a node is sealed in a printed
enclosure on a wall with the radio transmitting continuously.
What is being protected is the ENCLOSURE, not the silicon. The part is
rated far past anything it sees here, but PLA softens at its glass
transition (~55-60 C) and sags slowly under its own weight, which never
announces itself.
config THERMAL_THROTTLE
bool "Act on the temperature by reducing transmit power"
depends on THERMAL_MONITOR
default n
help
Separate from monitoring on purpose. Reading the sensor is inert;
reducing transmit power moves every RSSI the node has, and doing that
mid-experiment gives any surprise two candidate causes.
Leave off until the fleet is stable and the logs show a node genuinely
running hot. If none ever does -- which is the likely outcome -- this
never needs enabling, and the thresholds below cost nothing.
config THERMAL_WARN_C
int "Warn above this die temperature (C)"
depends on THERMAL_MONITOR
range 40 100
default 65
config THERMAL_THROTTLE_C
int "Reduce transmit power above this die temperature (C)"
depends on THERMAL_MONITOR
range 45 105
default 72
help
Die temperature, not enclosure temperature -- the sensor is on the chip
and runs hotter than the plastic around it. ~72 C on the die in a small
vented box is roughly a 50-55 C enclosure, which is where PLA begins to
matter. Setting this at 55 would throttle a perfectly healthy node.
PETG (glass transition ~80 C) removes the concern; raise this if the
whole fleet is printed in it.
config THERMAL_CRITICAL_C
int "Minimum transmit power above this die temperature (C)"
depends on THERMAL_MONITOR
range 50 110
default 80
config THERMAL_HYSTERESIS_C
int "Degrees of cooling required before stepping down a thermal state"
depends on THERMAL_MONITOR
range 1 20
default 8
help
Recovering at the same threshold that tripped would oscillate, and every
oscillation is a visible RSSI step on every link the node has.
Applies to EVERY downward transition, each relative to the threshold that
was crossed on the way up: leaving CRITICAL needs CRITICAL_C minus this,
leaving THROTTLED needs THROTTLE_C minus this, leaving WARN needs WARN_C
minus this. Rising transitions are immediate at the plain thresholds --
heat is a reason to act now.
endmenu
menu "Network supervision"
config UPLINK_WATCHDOG
bool "Restart the node if the CSI uplink stops for too long"
default y
help
Bounds how long a node can sit powered, capturing, and unable to reach
the network.
MEASURED 2026-08-30: an AP-side event at 03:37 EDT disconnected all three
wall-mounted nodes within three minutes of each other, and every one of
them stayed dead for 5.5-7.5 hours until it was physically unplugged. The
specific cause of that outage (a reconnect counter that gave up
permanently) is fixed in main.c, but this exists so the NEXT unknown
wedge costs minutes instead of a night of data.
The watchdog counts a successful sendto(), which on UDP means the network
stack accepted the datagram -- not that the aggregator received it. That
is deliberate: stopping the server does NOT reboot the fleet, because
sendto keeps succeeding into a socket whose peer is gone. It fires only
when the node's own network path is broken.
It arms on the first successful send rather than at boot, so a node
powered on while the AP is down does not reboot-loop; the unbounded
reconnect handles that case instead.
config UPLINK_WATCHDOG_TIMEOUT_S
int "Restart after this many seconds with no successful send"
depends on UPLINK_WATCHDOG
range 30 3600
default 1200
help
Must sit above the worst SELF-HEALING gap, or the watchdog reboots nodes
that were about to recover on their own -- which is worse than the fault
it guards against, because every reboot re-runs ESP-NOW leader election
and discards the mesh time-sync state.
This was 120 s on the reasoning that the ENOMEM backoff caps at 2 s so a
healthy node is never quiet for long. A 58-minute soak on 2026-08-30
falsified that: nodes 0 and 2 each went silent repeatedly with a median
gap of 210 s and a maximum of 225 s, and recovered every time without
intervention. 120 s would have rebooted them every few minutes forever.
(The cause of those 210 s gaps is still unknown -- it is not the ENOMEM
backoff, which cannot produce a gap that long.)
Sized from the completed 112-minute soak, not the partial one: 30
silences across two nodes, median 225 s, p90 225 s, and a single 510 s
outlier that looks like two cycles running together. A third such merge
would reach ~735 s, so 600 s was too tight -- it left only 90 s of margin
over an event already observed once.
1200 s is 2.35x the observed maximum while still bounding a true wedge to
twenty minutes rather than the 7.5 hours measured the same day. Revisit
it if the ~225 s flapping is ever explained and fixed; until then do not
lower it below ~900 s.
If nodes do restart unexpectedly, check the reset reason in the sync
packet's health field before assuming the watchdog is responsible.
endmenu
config CSI_GATE_MESH_ALIGNED
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
default y
help
The per-node elapsed gate ("20 ms since MY last accept") gives every node
an independent phase, so two healthy nodes at 50 Hz accept disjoint
frames and nothing can be paired across them. This aligns the 20 ms
window to mesh time so nodes choose the same windows.
config CSI_SEQ_DIAG
bool "Log (source MAC, rx_seq) for every CSI callback (ADR-345 phase 2 probe)"
default n
help
Diagnostic. Emits the transmitter address and 802.11 sequence number for
each CSI callback so the effect of the gate above can be measured rather
than assumed. Verbose; leave off in normal operation.
@@ -13,6 +13,7 @@
*/
#include "adaptive_controller.h"
#include "thermal.h"
#include "rv_radio_ops.h"
#include "rv_feature_state.h"
#include "rv_mesh.h"
@@ -248,9 +249,10 @@ static void medium_loop_cb(TimerHandle_t t)
portEXIT_CRITICAL(&s_obs_lock);
if (s_obs_valid) {
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d",
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps dsp=%.1fHz motion=%.2f presence=%.2f rssi=%d",
(unsigned)s_state,
(unsigned)obs.pkt_yield_per_sec,
(double)edge_get_sample_rate_hz(),
(double)obs.motion_score,
(double)obs.presence_score,
(int)obs.rssi_median_dbm);
@@ -330,6 +332,10 @@ static void emit_feature_state(void)
static void slow_loop_cb(TimerHandle_t t)
{
(void)t;
/* Sample the die and apply the thermal policy. The slow loop (30 s) is the
* right cadence: the die does not change quickly, and reading it more
* often buys nothing but noise. No-op unless CONFIG_THERMAL_MONITOR. */
thermal_tick();
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
* (default 30 s). The coordinator uses these to track liveness and
* detect sync-error drift. */
@@ -0,0 +1,56 @@
#include "c6_antenna_select.h"
#include "sdkconfig.h"
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
#include "driver/gpio.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define XIAO_RF_SWITCH_POWER_GPIO GPIO_NUM_3
#define XIAO_RF_SWITCH_SELECT_GPIO GPIO_NUM_14
static const char *TAG = "c6_antenna";
#endif
esp_err_t c6_xiao_antenna_apply(void)
{
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
gpio_config_t config = {
.pin_bit_mask = (1ULL << XIAO_RF_SWITCH_POWER_GPIO) |
(1ULL << XIAO_RF_SWITCH_SELECT_GPIO),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&config);
if (err != ESP_OK) {
return err;
}
err = gpio_set_level(XIAO_RF_SWITCH_POWER_GPIO, 0);
if (err != ESP_OK) {
return err;
}
vTaskDelay(pdMS_TO_TICKS(100));
#if defined(CONFIG_C6_XIAO_ANTENNA_EXTERNAL)
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_EXTERNAL;
const char *name = "external U.FL";
#else
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_INTERNAL;
const char *name = "internal ceramic";
#endif
err = gpio_set_level(XIAO_RF_SWITCH_SELECT_GPIO,
c6_xiao_antenna_gpio14_level(antenna));
if (err == ESP_OK) {
ESP_LOGI(TAG, "XIAO ESP32-C6 antenna path: %s", name);
}
return err;
#else
return ESP_OK;
#endif
}
@@ -0,0 +1,17 @@
#pragma once
typedef enum {
C6_XIAO_ANTENNA_INTERNAL = 0,
C6_XIAO_ANTENNA_EXTERNAL = 1,
} c6_xiao_antenna_t;
static inline int c6_xiao_antenna_gpio14_level(c6_xiao_antenna_t antenna)
{
return antenna == C6_XIAO_ANTENNA_EXTERNAL ? 1 : 0;
}
#ifdef ESP_PLATFORM
#include "esp_err.h"
esp_err_t c6_xiao_antenna_apply(void);
#endif
+9 -1
View File
@@ -13,7 +13,7 @@
* `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c.
*
* 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up
* (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no
* (ESP-IDF deep-sleep GPIO wake API). No debounce, no
* sub-10 µA floor, but no LP toolchain dependency either.
* This is the path the v0.6.6 firmware shipped with.
*
@@ -26,6 +26,7 @@
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE)
#include "c6_lp_core.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "driver/rtc_io.h"
@@ -134,10 +135,17 @@ esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high)
#else
/* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */
uint64_t mask = 1ULL << wake_gpio;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
esp_sleep_gpio_wake_up_mode_t mode = active_high
? ESP_GPIO_WAKEUP_GPIO_HIGH
: ESP_GPIO_WAKEUP_GPIO_LOW;
esp_err_t err = esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(mask, mode);
#else
esp_deepsleep_gpio_wake_up_mode_t mode = active_high
? ESP_GPIO_WAKEUP_GPIO_HIGH
: ESP_GPIO_WAKEUP_GPIO_LOW;
esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode);
#endif
if (err != ESP_OK) {
ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err));
return err;
+131 -9
View File
@@ -63,6 +63,32 @@ static uint32_t s_send_ok = 0;
static uint32_t s_send_fail = 0;
static uint32_t s_rate_skip = 0;
#ifndef CONFIG_CSI_SELF_PING_HZ
#define CONFIG_CSI_SELF_PING_HZ 50
#endif
#if CONFIG_CSI_SELF_PING_HZ < 10 || CONFIG_CSI_SELF_PING_HZ > 50
#error "CONFIG_CSI_SELF_PING_HZ must stay within the hardware-qualified 10-50 Hz range"
#endif
#define CSI_SELF_PING_INTERVAL_MS (1000U / CONFIG_CSI_SELF_PING_HZ)
#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ
#if CONFIG_IDF_TARGET_ESP32C6
#define CONFIG_EDGE_DSP_SAMPLE_HZ 8
#else
#define CONFIG_EDGE_DSP_SAMPLE_HZ 20
#endif
#endif
#if CONFIG_EDGE_DSP_SAMPLE_HZ < 8 || CONFIG_EDGE_DSP_SAMPLE_HZ > 50
#error "CONFIG_EDGE_DSP_SAMPLE_HZ must stay within the supported 8-50 Hz range"
#endif
#define EDGE_DSP_MIN_INTERVAL_US (1000000U / CONFIG_EDGE_DSP_SAMPLE_HZ)
static int64_t s_next_edge_enqueue_us = 0;
static uint32_t s_edge_rate_skip = 0;
/**
* Minimum interval between UDP sends in microseconds.
* CSI callbacks can fire hundreds of times per second in promiscuous mode.
@@ -88,6 +114,9 @@ static int64_t s_last_send_us = 0;
*/
#define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */
static int64_t s_last_process_us = 0;
/* Mesh-epoch window index of the last accepted frame. UINT64_MAX means "none
* yet", which no real bucket can collide with. */
static uint64_t s_last_gate_bucket = UINT64_MAX;
static uint32_t s_early_drop = 0;
/* ---- ADR-029: Channel-hop state ---- */
@@ -237,8 +266,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
buf[19] = 0;
#endif
/* I/Q data */
/* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid
* when first_word_invalid is set. Never let those hardware artifacts feed
* either the host or edge DSP path. Preserve the fixed ADR-018 geometry by
* zeroing rather than removing bytes, and mark the sanitation in bit 5. */
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
if (info->first_word_invalid && iq_len > 0) {
size_t invalid_len = iq_len < 4 ? iq_len : 4;
memset(&buf[CSI_HEADER_SIZE], 0, invalid_len);
buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED;
}
return frame_size;
}
@@ -251,9 +288,53 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
(void)ctx;
/* Early rate gate: drop excess callbacks to ~50 Hz to prevent
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq).
*
* HOW the rate is limited decides whether cross-node fusion is possible.
*
* The original gate is "at least 20 ms since MY last accept". Each node
* therefore has an independent phase: node A can accept at t=0,20,40 ms
* while node B accepts at 7,27,47. Both are perfectly healthy and both hit
* 50 Hz, yet they accept disjoint frames and nothing can be paired.
*
* MEASURED 2026-08-30, two boards side by side: they HEARD the same
* transmissions 72% of the time but only 25% of accepted frames were
* common. The frames were there; the gate was throwing away the pairing.
*
* So gate on a bucket of the MESH-ALIGNED epoch instead: every node takes
* the first frame it hears in the same absolute 20 ms window. Two nodes
* that heard the same frame now both accept it. Mesh sync is good to
* roughly 490 us against a 20 ms bucket -- about 2.5% of a window -- so
* boundary disagreements are rare.
*
* Falls back to the original elapsed-time gate whenever mesh sync is not
* valid (no leader heard yet, or a node that just booted), because an
* unsynced epoch would otherwise gate on a meaningless number. A node
* running unsynced simply gets the old behaviour rather than no gate.
*
* The half-interval floor below keeps the crash protection honest: bucket
* gating alone could accept at the end of one window and the start of the
* next, back to back. The floor bounds that to ~100 Hz for a single pair
* while the average stays at 50 Hz. Do not remove it; the gate exists for
* a crash, not for tidiness. */
int64_t now_us = esp_timer_get_time();
if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) {
bool take;
#ifdef CONFIG_CSI_GATE_MESH_ALIGNED
if (c6_sync_espnow_is_valid()) {
uint64_t bucket = c6_sync_espnow_get_epoch_us()
/ (uint64_t)CSI_MIN_PROCESS_INTERVAL_US;
take = (bucket != s_last_gate_bucket)
&& ((now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US / 2);
if (take) {
s_last_gate_bucket = bucket;
}
} else {
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
}
#else
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
#endif
if (!take) {
s_early_drop++;
return;
}
@@ -300,10 +381,34 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
}
}
/* ADR-039: Enqueue raw I/Q into edge processing ring buffer. */
if (info->buf && info->len > 0) {
edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
/* ADR-039 / ADR-347: Raw CSI stays at the independent network cadence,
* 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 (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) {
/* 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
* each irregular callback. With roughly 35 raw callbacks per
* second, a last-seen 100 ms gate selected every fourth callback
* and drifted to roughly 8 Hz. Advancing the deadline by complete
* periods alternates the available callbacks around the configured
* phase and prevents both drift and catch-up bursts. */
int64_t periods = ((now_us - s_next_edge_enqueue_us) /
EDGE_DSP_MIN_INTERVAL_US) + 1;
s_next_edge_enqueue_us += periods * EDGE_DSP_MIN_INTERVAL_US;
} else {
s_edge_rate_skip++;
}
}
/* ADR-110 §A0.11/§A0.12 — Emit a sync-packet every N CSI frames so the
@@ -411,7 +516,7 @@ static void csi_start_self_ping(void)
esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG();
cfg.target_addr = target;
cfg.count = ESP_PING_COUNT_INFINITE;
cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */
cfg.interval_ms = CSI_SELF_PING_INTERVAL_MS;
cfg.data_size = 1;
cfg.task_stack_size = 4096;
@@ -424,7 +529,8 @@ static void csi_start_self_ping(void)
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
esp_ping_start(s_self_ping);
ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str);
ESP_LOGI(TAG, "self-ping started -> %s @%dHz (CSI OFDM source, fix #521/#954)",
gw_str, CONFIG_CSI_SELF_PING_HZ);
} else {
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
s_self_ping = NULL;
@@ -592,6 +698,8 @@ void csi_collector_init(void)
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
(unsigned)s_node_id, (unsigned)csi_channel);
ESP_LOGI(TAG, "edge DSP cadence=%dHz; raw CSI network cadence remains independent",
CONFIG_EDGE_DSP_SAMPLE_HZ);
/* RuView#521/#954: start the connected-STA traffic source so the CSI engine
* receives a guaranteed OFDM unicast floor even when promiscuous capture is
@@ -642,6 +750,20 @@ uint16_t csi_collector_get_send_fail_count(void)
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
}
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
* back to elapsed time when unsynced), so there is no mode or period to
* report. Return the explicit sentinel rather than a number that reads as
* a real setting. */
uint8_t csi_collector_get_gate_mode(void)
{
return CSI_GATE_NOT_CONFIGURABLE;
}
uint8_t csi_collector_get_gate_seq_period(void)
{
return CSI_GATE_NOT_CONFIGURABLE;
}
/* ---- ADR-029: Channel hopping ---- */
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
@@ -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)
@@ -136,4 +139,22 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
*/
uint16_t csi_collector_get_send_fail_count(void);
/** Reported by the gate accessors when this build has no selectable gate. */
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
/**
* Report which frame-selection gate is in force, for the on-node log.
*
* This build has exactly one gate -- mesh-aligned bucketing with an
* elapsed-time fallback -- fixed at compile time, so there is no mode or
* period to report and both accessors return CSI_GATE_NOT_CONFIGURABLE.
*
* They exist so the log record keeps a field of fixed width and meaning
* whether or not a build can select a gate at runtime, and so an explicit
* "not applicable" is stored rather than a plausible number that would
* later be reasoned from.
*/
uint8_t csi_collector_get_gate_mode(void);
uint8_t csi_collector_get_gate_seq_period(void);
#endif /* CSI_COLLECTOR_H */
+75 -23
View File
@@ -38,6 +38,16 @@ extern nvs_config_t g_nvs_config;
static const char *TAG = "edge_proc";
#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ
#if CONFIG_IDF_TARGET_ESP32C6
#define CONFIG_EDGE_DSP_SAMPLE_HZ 8
#else
#define CONFIG_EDGE_DSP_SAMPLE_HZ 20
#endif
#endif
#define EDGE_CONFIGURED_SAMPLE_RATE_HZ ((float)CONFIG_EDGE_DSP_SAMPLE_HZ)
/* ======================================================================
* SPSC Ring Buffer (lock-free, single-producer single-consumer)
* ====================================================================== */
@@ -355,11 +365,12 @@ static float s_heartrate_filtered[EDGE_PHASE_HISTORY_LEN];
/** Measured CSI sample rate (Hz), smoothed from frame timestamps.
* #985's self-ping raised the callback rate above the old ~10 Hz beacon
* assumption and made it variable (~13-19 Hz); a fixed rate scaled BPM wrong
* and made HR swing with CSI yield. See update in process_csi_frame(). */
static float s_sample_rate_hz = 15.0f;
static float s_filter_design_fs = 20.0f; /* fs the biquads were last designed at */
static uint32_t s_last_frame_ts_us = 0;
* assumption and made it variable. A fixed rate scales BPM and Doppler bins
* incorrectly. Start from the filter design rate, then follow measured time. */
static float s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
static float s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ; /* fs the biquads were last designed at */
static uint32_t s_rate_window_start_us = 0;
static uint32_t s_rate_window_intervals = 0;
/** Latest vitals state. */
static float s_breathing_bpm;
@@ -409,6 +420,21 @@ static edge_biquad_t s_person_bq_hr[EDGE_MAX_PERSONS];
static float s_person_br_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN];
static float s_person_hr_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN];
/** Clear person slots whenever the room-level presence gate is closed. */
static void reset_person_count_state(void)
{
s_person_count_candidate = 0;
s_person_count_streak = 0;
s_person_count_stable = 0;
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
s_persons[p].active = false;
s_persons[p].history_len = 0;
s_persons[p].history_idx = 0;
s_persons[p].breathing_bpm = 0.0f;
s_persons[p].heartrate_bpm = 0.0f;
}
}
/** Latest vitals packet (thread-safe via volatile copy). */
static volatile edge_vitals_pkt_t s_latest_pkt;
static volatile bool s_pkt_valid;
@@ -898,7 +924,10 @@ static void send_vitals_packet(void)
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
if (s_persons[p].active) n_active++;
}
pkt.n_persons = n_active;
/* Fail closed: the slot heuristic cannot assert occupants while the
* debounced presence gate is false. The host repeats this invariant for
* backward compatibility with older firmware. */
pkt.n_persons = edge_evidence_person_count(s_presence_detected, n_active);
pkt.motion_energy = s_motion_energy;
pkt.presence_score = s_presence_score;
@@ -1038,20 +1067,27 @@ static void process_frame(const edge_ring_slot_t *slot)
s_frame_count++;
s_latest_rssi = slot->rssi;
/* Measure the REAL CSI sample rate from inter-frame timestamps. #985's
* self-ping made the callback rate variable (~13-19 Hz); the old fixed
* 10 Hz both scaled BPM wrong (true ~87 BPM read as ~45) and made HR swing
* as CSI yield fluctuated. EMA-smooth and clamp to a plausible band. */
if (s_last_frame_ts_us != 0 && slot->timestamp_us > s_last_frame_ts_us) {
float dt = (float)(slot->timestamp_us - s_last_frame_ts_us) * 1e-6f;
if (dt > 0.02f && dt < 0.5f) { /* 2-50 Hz plausible; reject gaps/hops */
float inst = 1.0f / dt;
s_sample_rate_hz += 0.05f * (inst - s_sample_rate_hz);
if (s_sample_rate_hz < 8.0f) s_sample_rate_hz = 8.0f;
if (s_sample_rate_hz > 30.0f) s_sample_rate_hz = 30.0f;
/* Measure the real CSI sample rate over one-second timestamp windows. WiFi
* replies arrive in bursts, so filtering individual short intervals made
* a 35 pps stream look like 12-16 Hz. Counting all processed intervals in
* the window preserves the clock actually seen by the temporal filters. */
if (s_rate_window_start_us == 0) {
s_rate_window_start_us = slot->timestamp_us;
s_rate_window_intervals = 0;
} else if (slot->timestamp_us > s_rate_window_start_us) {
s_rate_window_intervals++;
uint32_t elapsed_us = slot->timestamp_us - s_rate_window_start_us;
if (elapsed_us >= EDGE_SAMPLE_RATE_WINDOW_MIN_US) {
s_sample_rate_hz = edge_sample_rate_window_update(
s_sample_rate_hz, s_rate_window_intervals, elapsed_us);
s_rate_window_start_us = slot->timestamp_us;
s_rate_window_intervals = 0;
}
} else {
/* Timer wrap or reset. Start a fresh evidence window. */
s_rate_window_start_us = slot->timestamp_us;
s_rate_window_intervals = 0;
}
s_last_frame_ts_us = slot->timestamp_us;
/* Re-tune the biquads if the measured rate has drifted from their design fs,
* so the breathing (0.1-0.5 Hz) and HR (0.8-2.0 Hz) passbands stay in real
@@ -1202,8 +1238,15 @@ static void process_frame(const edge_ring_slot_t *slot)
}
}
/* --- Step 11: Multi-person vitals --- */
update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate);
/* --- Step 11: Multi-person vitals ---
* Person slots are subordinate to the room presence gate. Processing or
* retaining slots while absent produced contradictory packets such as
* presence=false with n_persons=4. */
if (s_presence_detected) {
update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate);
} else {
reset_person_count_state();
}
/* Yield after multi-person DSP so IDLE1 can feed Core 1 watchdog (#683). */
if (s_cfg.tier >= 2) vTaskDelay(1);
@@ -1314,6 +1357,11 @@ bool edge_get_vitals(edge_vitals_pkt_t *pkt)
return true;
}
float edge_get_sample_rate_hz(void)
{
return s_sample_rate_hz;
}
void edge_get_multi_person(edge_person_vitals_t *persons, uint8_t *n_active)
{
uint8_t active = 0;
@@ -1373,6 +1421,10 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
s_fall_detected = false;
s_latest_rssi = 0;
s_frame_count = 0;
s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
s_rate_window_start_us = 0;
s_rate_window_intervals = 0;
s_prev_phase_velocity = 0.0f;
s_fall_consec_count = 0;
s_fall_last_alert_us = 0;
@@ -1397,9 +1449,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
s_person_count_streak = 0;
s_person_count_stable = 0;
/* Design biquad bandpass filters.
* Sampling rate ~20 Hz (typical ESP32 CSI callback rate). */
const float fs = 20.0f;
/* Design biquad bandpass filters against the configured DSP clock. The
* measured timestamp estimator then follows sustained hardware drift. */
const float fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
biquad_bandpass_design(&s_bq_breathing, fs, 0.1f, 0.5f);
biquad_bandpass_design(&s_bq_heartrate, fs, 0.8f, 2.0f);
+78 -1
View File
@@ -33,11 +33,82 @@
#define EDGE_MAX_IQ_BYTES 1024 /**< Max I/Q payload per slot. */
#define EDGE_PHASE_HISTORY_LEN 256 /**< Phase history buffer depth. */
#define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */
#define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */
/**
* Max subcarriers per frame the edge pipeline will process.
*
* Target-conditional since 2026-08-28. The original 128 was an ESP32-S3
* assumption: pre-HE chips report at most 128 CSI bins. ADR-110 onboarded the
* ESP32-C6 to the *capture* path but not to this one, and a C6 associated to an
* HE-capable AP delivers HE20 frames with 256 bins (iq_len = 512 bytes).
*
* `process_frame()` guards with `n_subcarriers > EDGE_MAX_SUBCARRIERS -> return`,
* so on C6 every frame was rejected and the whole edge pipeline — vitals,
* presence, fall detection, per-slot counting — silently did nothing. The Edge
* DSP task started, logged its banner, and never processed a frame. Confirmed
* on hardware: no edge_proc log past init and no vitals packet ever reaching
* the sink.
*
* HE-capable parts get 256; pre-HE parts keep 128 so they don't pay ~3.5 KB of
* .bss they can never use. CONFIG_SOC_WIFI_HE_SUPPORT is the same switch
* csi_collector.c uses to pick its rx_ctrl layout.
*/
#if CONFIG_SOC_WIFI_HE_SUPPORT
#define EDGE_MAX_SUBCARRIERS 256 /**< HE20 carries 256 CSI bins (C6/C5). */
#else
#define EDGE_MAX_SUBCARRIERS 128 /**< Pre-HE parts (S3 etc). */
#endif
/* ---- Measured sample-rate tracking ----
*
* The connected-STA probe produces up to 50 CSI opportunities per second,
* while contention and callback gating make the delivered cadence variable.
* Temporal filters must follow measured time rather than a fixed frame-rate
* assumption. The 60 Hz estimator ceiling leaves jitter headroom above the
* qualified 50 Hz callback limit. A one-second frame-count window represents
* bursty but valid WiFi arrivals more accurately than averaging only selected
* inter-frame intervals. */
#define EDGE_SAMPLE_RATE_MIN_HZ 8.0f
#define EDGE_SAMPLE_RATE_MAX_HZ 60.0f
#define EDGE_SAMPLE_RATE_EMA_ALPHA 0.25f
#define EDGE_SAMPLE_RATE_WINDOW_MIN_US 1000000U
#define EDGE_SAMPLE_RATE_WINDOW_MAX_US 3000000U
static inline float edge_sample_rate_window_update(float current_hz,
uint32_t frame_intervals,
uint32_t elapsed_us)
{
if (frame_intervals == 0 || elapsed_us < EDGE_SAMPLE_RATE_WINDOW_MIN_US ||
elapsed_us > EDGE_SAMPLE_RATE_WINDOW_MAX_US) {
return current_hz;
}
float instant_hz = (float)frame_intervals * 1000000.0f / (float)elapsed_us;
if (instant_hz < EDGE_SAMPLE_RATE_MIN_HZ) instant_hz = EDGE_SAMPLE_RATE_MIN_HZ;
if (instant_hz > EDGE_SAMPLE_RATE_MAX_HZ) instant_hz = EDGE_SAMPLE_RATE_MAX_HZ;
float next_hz = current_hz + EDGE_SAMPLE_RATE_EMA_ALPHA * (instant_hz - current_hz);
if (next_hz < EDGE_SAMPLE_RATE_MIN_HZ) return EDGE_SAMPLE_RATE_MIN_HZ;
if (next_hz > EDGE_SAMPLE_RATE_MAX_HZ) return EDGE_SAMPLE_RATE_MAX_HZ;
return next_hz;
}
/* ---- Multi-person ---- */
#define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */
/**
* Enforce the wire-level occupancy invariant.
*
* A subcarrier slot estimate is supporting evidence only. It cannot assert an
* occupant when the independently debounced presence gate is false. Keeping
* this helper in the public firmware header lets host tests exercise the exact
* function used by the device build.
*/
static inline uint8_t edge_evidence_person_count(bool presence, uint8_t active_count)
{
if (!presence) return 0;
return active_count > EDGE_MAX_PERSONS ? EDGE_MAX_PERSONS : active_count;
}
/* ---- Multi-person counting gates (issue #998) ----
*
* Over-counting root cause: the multi-person path used to split the top-K
@@ -238,6 +309,12 @@ bool edge_enqueue_csi(const uint8_t *iq_data, uint16_t iq_len,
*/
bool edge_get_vitals(edge_vitals_pkt_t *pkt);
/**
* Return the timestamp-derived CSI cadence used to design temporal filters.
* This is diagnostic evidence, not the raw callback or network delivery rate.
*/
float edge_get_sample_rate_hz(void);
/**
* Get multi-person vitals array.
*
+201 -8
View File
@@ -21,8 +21,11 @@
#include "led_strip.h"
#include "csi_collector.h"
#include "node_log.h"
#include "thermal.h"
#include "stream_sender.h"
#include "nvs_config.h"
#include "serial_onboarding.h"
#include "edge_processing.h"
#include "ota_update.h"
#include "power_mgmt.h"
@@ -36,6 +39,7 @@
#include "c6_twt.h" /* ADR-110: TWT (no-op stub on S3) */
#include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */
#include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */
#include "c6_antenna_select.h" /* XIAO C6 RF switch (opt-in; generic C6 no-op) */
#include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */
#include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */
#ifdef CONFIG_CSI_MOCK_ENABLED
@@ -59,7 +63,52 @@ nvs_config_t g_nvs_config;
static EventGroupHandle_t s_wifi_event_group;
static int s_retry_num = 0;
#define MAX_RETRY 10
/* WiFi reconnection.
*
* This used to be `#define MAX_RETRY 10` with the disconnect handler giving up
* permanently once s_retry_num reached it: it set WIFI_FAIL_BIT and never
* called esp_wifi_connect() again. s_retry_num is only cleared on a successful
* IP_EVENT_STA_GOT_IP, so it accumulated over the node's entire uptime -- ten
* disconnects spread across days left the node permanently off the network
* with nothing but a power cycle to clear it.
*
* MEASURED 2026-08-30: a single AP-side event at 03:37:03 EDT disconnected all
* three wall-mounted nodes within three minutes of each other. Every one of
* them latched off and stayed off for 5.5-7.5 hours, until it was physically
* unplugged. CSI capture kept running throughout (it is driver-level and needs
* no IP), so the boards looked alive on the bench and dead to the server.
*
* Retry is now unbounded with exponential backoff. Two things that were
* conflated are now separate: WIFI_BOOT_WAIT_ATTEMPTS only releases app_main
* from its startup wait so the rest of the node can boot; it does not stop the
* node trying to reconnect. Nothing stops the node trying to reconnect. */
#define WIFI_BOOT_WAIT_ATTEMPTS 10
#define WIFI_RETRY_BASE_MS 500
#define WIFI_RETRY_MAX_MS 30000
static esp_timer_handle_t s_reconnect_timer;
/* 500 ms doubling to a 30 s ceiling. Backoff matters because the failure this
* guards against is an AP that is down or rebooting: hammering
* esp_wifi_connect() at full rate for the minutes an AP takes to come back
* wastes power and floods the log without reconnecting any sooner. */
static uint32_t wifi_retry_delay_ms(int attempt)
{
int shift = attempt - 1;
if (shift < 0) shift = 0;
if (shift > 6) shift = 6;
uint32_t d = (uint32_t)WIFI_RETRY_BASE_MS << shift;
return d > WIFI_RETRY_MAX_MS ? WIFI_RETRY_MAX_MS : d;
}
/* Runs on the esp_timer task, not the event loop task -- esp_wifi_connect()
* must not be reached through a delay inside the event handler itself. */
static void wifi_reconnect_cb(void *arg)
{
(void)arg;
esp_wifi_connect();
}
static void event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
@@ -69,13 +118,26 @@ static void event_handler(void *arg, esp_event_base_t event_base,
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
if (s_retry_num < MAX_RETRY) {
esp_wifi_connect();
s_retry_num++;
ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, MAX_RETRY);
} else {
/* The 7.5 h outage was root-caused to the MAX_RETRY latch only because
* someone was watching the console at the time. Persist it. */
node_log_note_disconnect((uint8_t)disc->reason, (int8_t)disc->rssi);
node_log_event(NODE_LOG_EV_WIFI_DISCONNECT, disc->reason, disc->rssi);
s_retry_num++;
/* Release the boot wait once, so a node that comes up while the AP is
* down still starts CSI capture and the mesh instead of blocking in
* app_main. Retrying continues regardless. */
if (s_retry_num == WIFI_BOOT_WAIT_ATTEMPTS) {
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
uint32_t delay_ms = wifi_retry_delay_ms(s_retry_num);
if (s_reconnect_timer != NULL) {
esp_timer_stop(s_reconnect_timer); /* may not be running; harmless */
esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000);
} else {
esp_wifi_connect(); /* timer unavailable: retry now */
}
ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)",
(unsigned long)delay_ms, s_retry_num);
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
@@ -84,6 +146,41 @@ static void event_handler(void *arg, esp_event_base_t event_base,
}
}
#ifdef CONFIG_UPLINK_WATCHDOG
/* Uplink supervision.
*
* The reconnect fix above addresses the one wedge whose mechanism we have
* proven. This catches the ones we have not: any state in which the node is
* powered, capturing, and unable to put a packet on the wire. It bounds such a
* state to CONFIG_UPLINK_WATCHDOG_TIMEOUT_S instead of the 7.5 hours measured
* on 2026-08-30.
*
* The signal is a successful sendto(), which on UDP means the stack accepted
* the datagram -- NOT that the server received it. That distinction is what
* makes this safe: stopping the aggregator does not reboot the fleet, because
* sendto keeps succeeding into a socket whose peer is gone. It fires only when
* the node's own network path is broken.
*
* It arms on the first successful send rather than at boot, so a node that has
* never reached the network does not reboot-loop while the AP is down; the
* unbounded reconnect above owns that case. */
static void uplink_watchdog_tick(void)
{
int64_t last = stream_sender_last_success_us();
if (last == 0) {
return; /* never delivered -- not armed yet */
}
int64_t idle_s = (esp_timer_get_time() - last) / 1000000;
if (idle_s < CONFIG_UPLINK_WATCHDOG_TIMEOUT_S) {
return;
}
ESP_LOGE(TAG, "uplink watchdog: no successful send for %lld s "
"(limit %d s) -- restarting",
(long long)idle_s, CONFIG_UPLINK_WATCHDOG_TIMEOUT_S);
esp_restart();
}
#endif /* CONFIG_UPLINK_WATCHDOG */
static void wifi_init_sta(void)
{
s_wifi_event_group = xEventGroupCreate();
@@ -135,6 +232,17 @@ static void wifi_init_sta(void)
}
#endif
const esp_timer_create_args_t reconnect_args = {
.callback = &wifi_reconnect_cb,
.name = "wifi_reconnect",
};
esp_err_t rc_ret = esp_timer_create(&reconnect_args, &s_reconnect_timer);
if (rc_ret != ESP_OK) {
ESP_LOGW(TAG, "reconnect timer create failed: %s (falling back to "
"immediate retry)", esp_err_to_name(rc_ret));
s_reconnect_timer = NULL;
}
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid);
@@ -147,7 +255,9 @@ static void wifi_init_sta(void)
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "Connected to WiFi");
} else if (bits & WIFI_FAIL_BIT) {
ESP_LOGE(TAG, "Failed to connect to WiFi after %d retries", MAX_RETRY);
ESP_LOGW(TAG, "Not connected after %d attempts -- continuing boot; "
"reconnection keeps retrying in the background",
WIFI_BOOT_WAIT_ATTEMPTS);
}
}
@@ -228,6 +338,19 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep
* ownership of GPIO3 and GPIO14 because the default implementation is a
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
ESP_ERROR_CHECK(c6_xiao_antenna_apply());
/* The native Mac onboarding bridge must remain available even when WiFi
* credentials or the aggregator address are wrong. Start it before any
* blocking network initialization. The protocol is physical-USB-only,
* nonce-bound, bounded, and never prints credentials. */
esp_err_t onboarding_ret = serial_onboarding_start(&g_nvs_config);
if (onboarding_ret != ESP_OK) {
ESP_LOGW(TAG, "USB onboarding unavailable: %s", esp_err_to_name(onboarding_ret));
}
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the
* common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target.
@@ -318,6 +441,54 @@ void app_main(void)
ESP_LOGI(TAG, "Mock CSI active (scenario=%d)", CONFIG_CSI_MOCK_SCENARIO);
}
#else
/* Why did we just start? Nothing recorded this before, and it is the
* single most useful line in a fleet log: a node that reboots tells you
* almost nothing, but a node that reboots with ESP_RST_BROWNOUT tells you
* its supply sagged, which on shared outlets is a wiring problem rather
* than a firmware one. Three nodes dropping together on 2026-08-28 would
* have been answered by this line. */
{
esp_reset_reason_t rr = esp_reset_reason();
const char *why =
rr == ESP_RST_POWERON ? "power-on" :
rr == ESP_RST_EXT ? "external reset" :
rr == ESP_RST_SW ? "software restart" :
rr == ESP_RST_PANIC ? "PANIC (crash)" :
rr == ESP_RST_INT_WDT ? "interrupt watchdog" :
rr == ESP_RST_TASK_WDT ? "task watchdog" :
rr == ESP_RST_WDT ? "other watchdog" :
rr == ESP_RST_BROWNOUT ? "BROWNOUT (supply sagged)" :
rr == ESP_RST_DEEPSLEEP? "deep-sleep wake" : "unknown";
if (rr == ESP_RST_PANIC || rr == ESP_RST_BROWNOUT ||
rr == ESP_RST_INT_WDT || rr == ESP_RST_TASK_WDT) {
ESP_LOGW(TAG, "reset reason: %s (%d) <-- not a clean start", why, (int)rr);
} else {
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
}
/* And persist it. The console line above is exactly what a power cycle
* destroys, which is the whole reason this fleet has no post-mortem for
* a remote fault. node_log_init failing is non-fatal by design -- the
* node comes up either way, it just has no memory. */
if (node_log_init() == ESP_OK) {
uint32_t prev_uptime_s = 0;
nvs_handle_t nh;
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
nvs_get_u32(nh, "last_up_s", &prev_uptime_s);
nvs_set_u32(nh, "last_up_s", 0);
nvs_commit(nh);
nvs_close(nh);
}
node_log_boot((uint32_t)rr, prev_uptime_s);
ESP_LOGI(TAG, "on-node log active: boot_id=%u, %u records retained",
(unsigned)node_log_boot_id(), (unsigned)node_log_count());
}
}
/* Bring thermal monitoring up before the radio is loaded, so the first
* reading is taken against a known-idle baseline rather than mid-burst. */
thermal_init();
csi_collector_init();
/* ADR-073: Start multi-frequency channel hopping if configured in NVS. */
@@ -510,8 +681,30 @@ void app_main(void)
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off",
(adapt_ret == ESP_OK) ? "on" : "off");
/* Main loop — keep alive */
/* Main loop — keep alive, and supervise the uplink. */
while (1) {
vTaskDelay(pdMS_TO_TICKS(10000));
#ifdef CONFIG_UPLINK_WATCHDOG
uplink_watchdog_tick();
#endif
/* Offered every 10 s; node_log enforces its own floor, so the cadence
* lives in one place rather than being implied by this loop's delay.
* Also refresh the uptime NVS cell, which is what lets the NEXT boot
* record say how long the previous session actually survived -- the
* difference between "it rebooted" and "it wedged after 225 s". */
node_log_periodic();
{
static uint32_t s_last_persist_s;
uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000);
if (up_s - s_last_persist_s >= 60) {
s_last_persist_s = up_s;
nvs_handle_t nh;
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
nvs_set_u32(nh, "last_up_s", up_s);
nvs_commit(nh);
nvs_close(nh);
}
}
}
}
}
@@ -503,9 +503,35 @@ esp_err_t mmwave_sensor_init(int uart_tx_pin, int uart_rx_pin)
return ESP_OK;
#else
/* Default probe pins must never collide with the UART0 console. On the
* ESP32-C6 the console is TX=GPIO16 / RX=GPIO17, so the legacy 17/18 pair
* routed UART1 TX onto the console RX line and silently killed serial
* input (USB onboarding, monitor commands) even when no sensor was found,
* because uart_driver_delete() does not undo the GPIO matrix routing. */
#if defined(CONFIG_IDF_TARGET_ESP32C6)
if (uart_tx_pin < 0) uart_tx_pin = 4;
if (uart_rx_pin < 0) uart_rx_pin = 5;
#else
if (uart_tx_pin < 0) uart_tx_pin = 17;
if (uart_rx_pin < 0) uart_rx_pin = 18;
#endif
#if defined(CONFIG_ESP_CONSOLE_UART_CUSTOM)
const int console_tx = CONFIG_ESP_CONSOLE_UART_TX_GPIO;
const int console_rx = CONFIG_ESP_CONSOLE_UART_RX_GPIO;
#elif defined(CONFIG_IDF_TARGET_ESP32C6)
const int console_tx = 16, console_rx = 17; /* U0TXD / U0RXD IOMUX pins */
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
const int console_tx = 43, console_rx = 44;
#else
const int console_tx = 1, console_rx = 3;
#endif
if (uart_tx_pin == console_tx || uart_tx_pin == console_rx ||
uart_rx_pin == console_tx || uart_rx_pin == console_rx) {
ESP_LOGW(TAG, "mmWave probe pins (TX=%d, RX=%d) overlap the console UART (TX=%d, RX=%d); skipping probe",
uart_tx_pin, uart_rx_pin, console_tx, console_rx);
return ESP_ERR_INVALID_ARG;
}
/* Install UART driver at MR60 baud (will be changed during probe). */
uart_config_t uart_config = {
+424
View File
@@ -0,0 +1,424 @@
#include "node_log.h"
#include <limits.h>
#include <string.h>
#include <unistd.h>
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_vfs_fat.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "c6_sync_espnow.h"
#include "csi_collector.h"
#include "nvs_config.h"
#include "thermal.h"
/* firmware/esp32-csi-node/partitions_4mb.csv has two 1.81 MB app slots and
* only 256 KB free -- no room for the FAT volume this module needs. The
* whole implementation below is compiled out on that profile; every entry
* point still links via the stubs in the #else branch so main.c's call
* sites need no #ifdef of their own. See partitions_display.csv (8MB) and
* partitions_16mb.csv for the profiles where this module is active. */
#ifndef CONFIG_ESPTOOLPY_FLASHSIZE_4MB
static const char *TAG = "node_log";
/* The FAT volume reserved in partitions_16mb.csv (storage, 8000K) and never
* mounted until now. */
#define NODE_LOG_PARTITION "storage"
#define NODE_LOG_MOUNT "/nlog"
#define NODE_LOG_PATH NODE_LOG_MOUNT "/health.bin"
/* Ring capacity. 60000 * 128 B = 7.68 MB, just inside the 8000K volume with
* room for FAT's own metadata. At the 300 s cadence that is ~208 days. */
#define NODE_LOG_MAX_RECORDS 60000
/* Hard floor between periodic samples. The design settled on 300 s; this is
* the enforcement, not the schedule. A caller asking more often is DROPPED
* rather than served, because flash wear is the one way this module can do
* harm and a bug in a caller must not be able to cause it. */
#define NODE_LOG_MIN_PERIOD_S 60
/* Events bypass the periodic floor -- they are rare and they are the whole
* point -- but not without limit, or a disconnect storm becomes a write storm. */
#define NODE_LOG_MIN_EVENT_MS 1000
static bool s_active;
static FILE *s_fp;
static wl_handle_t s_wl = WL_INVALID_HANDLE;
static SemaphoreHandle_t s_lock;
static uint16_t s_boot_id;
static uint32_t s_seq;
static size_t s_count; /* records currently in the ring */
static size_t s_head; /* next slot to write */
static int64_t s_last_periodic_us;
static int64_t s_last_event_us;
/* CRC16/CCITT-FALSE. A record that fails this is skipped by the reader rather
* than trusted, which matters because a power cut mid-write leaves a partial
* record and the ring has no other way to know. */
static uint16_t crc16(const uint8_t *p, size_t n)
{
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < n; i++) {
crc ^= (uint16_t)p[i] << 8;
for (int b = 0; b < 8; b++) {
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1);
}
}
return crc;
}
/* 16 B header, then a type-specific payload. The header is identical across
* types so a reader can order and validate without dispatching first. */
typedef struct __attribute__((packed)) {
uint8_t type;
uint8_t flags;
uint16_t boot_id;
uint32_t seq;
uint32_t uptime_s;
uint16_t payload_len;
uint16_t crc16; /* over the record with this field zeroed */
} node_log_hdr_t;
_Static_assert(sizeof(node_log_hdr_t) == 16, "header must be 16 B");
/* ~72 B used of the 112 B payload budget, leaving ~40 B of deliberate slack so
* a field can be added later WITHOUT changing the record size and invalidating
* every reader already in the field. */
typedef struct __attribute__((packed)) {
int8_t die_c;
uint8_t thermal_state;
int8_t tx_dbm;
int8_t ap_rssi_dbm; /* the one field we did not have and needed */
uint8_t channel;
uint8_t wifi_state;
uint8_t gate_mode;
uint8_t gate_seq_period;
uint16_t free_heap_kib;
uint16_t min_heap_kib;
uint16_t csi_fps_x100;
uint32_t frames_processed;
uint32_t frames_rejected;
uint32_t seq_drop;
uint32_t tx_early_drop;
uint16_t tx_rate_skip;
uint32_t tx_send_fail;
uint16_t disconnect_count;
uint8_t last_disc_reason;
int8_t last_disc_rssi;
uint8_t mesh_flags; /* bit0 valid, bit1 leader */
uint8_t leader_id;
int32_t mesh_offset_us;
uint16_t mesh_staleness_ms;
uint32_t mesh_seq;
} node_log_periodic_t;
typedef struct __attribute__((packed)) {
uint32_t reset_reason;
uint32_t prev_uptime_s;
node_log_periodic_t health; /* a boot record carries a snapshot too */
} node_log_boot_t;
typedef struct __attribute__((packed)) {
uint8_t subtype;
uint8_t _pad[3];
int32_t a;
int32_t b;
} node_log_event_t;
/* Counters main.c owns; set through the setters below rather than reached for,
* so this module has no include cycle with main.c. */
static volatile uint16_t s_disconnect_count;
static volatile uint8_t s_last_disc_reason;
static volatile int8_t s_last_disc_rssi;
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
{
s_disconnect_count++;
s_last_disc_reason = reason;
s_last_disc_rssi = rssi;
}
static void fill_health(node_log_periodic_t *h)
{
memset(h, 0, sizeof(*h));
/* thermal.c initialises s_last_c to -273.0f and only replaces it on the
* first thermal_tick(), which runs from the adaptive controller -- LATER
* than the boot record and later than the first periodic samples. Casting
* that sentinel to int8 yields -17, a PLAUSIBLE temperature, which is
* exactly the kind of invented-but-believable number that must never reach
* a log someone will later reason from. Store INT8_MIN as an explicit
* "no reading" instead; node_log_read.py renders it as n/a. */
float die = thermal_celsius();
h->die_c = (die < -100.0f || die > 125.0f) ? INT8_MIN : (int8_t)die;
h->thermal_state = (uint8_t)thermal_state();
h->tx_dbm = thermal_tx_dbm();
h->gate_mode = csi_collector_get_gate_mode();
h->gate_seq_period = csi_collector_get_gate_seq_period();
/* The association link, NOT the CSI-derived rssi_median_dbm in
* adaptive_controller.c. Nodes 5 and 6 show every symptom of a bad uplink
* and this is the one call that can confirm or refute it. */
wifi_ap_record_t ap;
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
h->ap_rssi_dbm = (int8_t)ap.rssi;
h->channel = ap.primary;
h->wifi_state = 1;
}
h->free_heap_kib = (uint16_t)(esp_get_free_heap_size() / 1024);
h->min_heap_kib = (uint16_t)(esp_get_minimum_free_heap_size() / 1024);
h->csi_fps_x100 = csi_collector_get_pkt_yield_per_sec() * 100;
h->tx_send_fail = csi_collector_get_send_fail_count();
h->disconnect_count = s_disconnect_count;
h->last_disc_reason = s_last_disc_reason;
h->last_disc_rssi = s_last_disc_rssi;
h->mesh_flags = (uint8_t)((c6_sync_espnow_is_valid() ? 1 : 0)
| (c6_sync_espnow_is_leader() ? 2 : 0));
int64_t off = c6_sync_espnow_get_offset_us();
/* Saturate rather than wrap: a wrapped offset reads as a plausible small
* number and would be believed. */
if (off > INT32_MAX) h->mesh_offset_us = INT32_MAX;
else if (off < INT32_MIN) h->mesh_offset_us = INT32_MIN;
else h->mesh_offset_us = (int32_t)off;
}
/* Append one record. Callers hold no lock; this takes it. */
static void append(uint8_t type, const void *payload, size_t len)
{
if (!s_active || len > (NODE_LOG_RECORD_SIZE - sizeof(node_log_hdr_t))) {
return;
}
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(200)) != pdTRUE) {
return; /* never block a caller on the log */
}
uint8_t rec[NODE_LOG_RECORD_SIZE];
memset(rec, 0, sizeof(rec));
node_log_hdr_t *h = (node_log_hdr_t *)rec;
h->type = type;
h->boot_id = s_boot_id;
h->seq = ++s_seq;
h->uptime_s = (uint32_t)(esp_timer_get_time() / 1000000);
h->payload_len = (uint16_t)len;
h->crc16 = 0;
memcpy(rec + sizeof(*h), payload, len);
h->crc16 = crc16(rec, sizeof(rec));
if (fseek(s_fp, (long)(s_head * NODE_LOG_RECORD_SIZE), SEEK_SET) == 0
&& fwrite(rec, 1, sizeof(rec), s_fp) == sizeof(rec)) {
/* fflush only pushes the C stdio buffer into the VFS; FATFS still holds
* the directory entry and FAT chain in RAM. Without fsync a reboot
* loses the file's size, and on the next open "r+b" sees a zero-length
* file -- which is precisely what the node-3 pilot showed: boot 1's
* records vanished while the NVS-backed prev_uptime survived. A log
* that does not survive a power cycle is the exact thing this module
* exists to prevent, so the cost of an fsync per record is accepted:
* at one record per 60 s it is nothing, and the bounded write rate is
* what keeps it that way. */
fflush(s_fp);
fsync(fileno(s_fp));
s_head = (s_head + 1) % NODE_LOG_MAX_RECORDS;
if (s_count < NODE_LOG_MAX_RECORDS) s_count++;
} else {
ESP_LOGW(TAG, "record write failed; disabling to protect the volume");
s_active = false;
}
xSemaphoreGive(s_lock);
}
esp_err_t node_log_init(void)
{
if (s_active) return ESP_OK;
s_lock = xSemaphoreCreateMutex();
if (!s_lock) return ESP_ERR_NO_MEM;
const esp_vfs_fat_mount_config_t cfg = {
.format_if_mount_failed = true,
.max_files = 2,
.allocation_unit_size = CONFIG_WL_SECTOR_SIZE,
};
esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl(
NODE_LOG_MOUNT, NODE_LOG_PARTITION, &cfg, &s_wl);
if (err != ESP_OK) {
ESP_LOGW(TAG, "FAT mount failed (%s); logging DISABLED, node unaffected",
esp_err_to_name(err));
return err;
}
/* boot_id orders records across reboots. Without an RTC it is the only
* ordering that survives a power cycle. */
nvs_handle_t nh;
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
uint16_t b = 0;
nvs_get_u16(nh, "boot_id", &b);
s_boot_id = (uint16_t)(b + 1);
nvs_set_u16(nh, "boot_id", s_boot_id);
nvs_commit(nh);
nvs_close(nh);
}
s_fp = fopen(NODE_LOG_PATH, "r+b");
if (!s_fp) {
/* First boot only. Create it, then fsync so the directory entry is on
* flash before anything is written into it. */
s_fp = fopen(NODE_LOG_PATH, "w+b");
if (s_fp) {
fflush(s_fp);
fsync(fileno(s_fp));
}
}
if (!s_fp) {
ESP_LOGW(TAG, "cannot open %s; logging DISABLED", NODE_LOG_PATH);
esp_vfs_fat_spiflash_unmount_rw_wl(NODE_LOG_MOUNT, s_wl);
s_wl = WL_INVALID_HANDLE;
return ESP_FAIL;
}
/* Recover head/seq by scanning. The ring has no superblock deliberately:
* a superblock is one more thing a power cut can leave inconsistent, and
* the scan is a one-off cost at boot. */
uint8_t rec[NODE_LOG_RECORD_SIZE];
uint32_t best_seq = 0;
for (size_t i = 0; i < NODE_LOG_MAX_RECORDS; i++) {
if (fseek(s_fp, (long)(i * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
if (fread(rec, 1, sizeof(rec), s_fp) != sizeof(rec)) break;
node_log_hdr_t *h = (node_log_hdr_t *)rec;
if (h->type == 0) continue;
uint16_t got = h->crc16;
h->crc16 = 0;
if (crc16(rec, sizeof(rec)) != got) continue;
s_count++;
if (h->seq > best_seq) {
best_seq = h->seq;
s_head = (i + 1) % NODE_LOG_MAX_RECORDS;
}
}
s_seq = best_seq;
s_active = true;
ESP_LOGI(TAG, "logging active: boot_id=%u recovered=%u records, head=%u",
(unsigned)s_boot_id, (unsigned)s_count, (unsigned)s_head);
return ESP_OK;
}
bool node_log_is_active(void) { return s_active; }
uint16_t node_log_boot_id(void) { return s_boot_id; }
size_t node_log_count(void) { return s_count; }
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
{
node_log_boot_t b;
memset(&b, 0, sizeof(b));
b.reset_reason = reset_reason;
b.prev_uptime_s = prev_uptime_s;
fill_health(&b.health);
append(NODE_LOG_TYPE_BOOT, &b, sizeof(b));
}
void node_log_periodic(void)
{
int64_t now = esp_timer_get_time();
if (s_last_periodic_us != 0
&& (now - s_last_periodic_us) < (int64_t)NODE_LOG_MIN_PERIOD_S * 1000000) {
return;
}
s_last_periodic_us = now;
node_log_periodic_t h;
fill_health(&h);
append(NODE_LOG_TYPE_PERIODIC, &h, sizeof(h));
}
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
{
int64_t now = esp_timer_get_time();
if (s_last_event_us != 0
&& (now - s_last_event_us) < (int64_t)NODE_LOG_MIN_EVENT_MS * 1000) {
return;
}
s_last_event_us = now;
node_log_event_t e;
memset(&e, 0, sizeof(e));
e.subtype = subtype;
e.a = a;
e.b = b;
append(NODE_LOG_TYPE_EVENT, &e, sizeof(e));
}
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
{
if (!s_active || !out || max_records == 0) return 0;
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(500)) != pdTRUE) return 0;
size_t written = 0;
for (size_t n = 0; n < s_count && written < max_records; n++) {
if (n < skip) continue;
/* newest-first: step back from head */
size_t idx = (s_head + NODE_LOG_MAX_RECORDS - 1 - n) % NODE_LOG_MAX_RECORDS;
if (fseek(s_fp, (long)(idx * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
if (fread(out + written * NODE_LOG_RECORD_SIZE, 1, NODE_LOG_RECORD_SIZE, s_fp)
!= NODE_LOG_RECORD_SIZE) break;
written++;
}
xSemaphoreGive(s_lock);
return written;
}
esp_err_t node_log_clear(void)
{
if (!s_active) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t err = ESP_OK;
if (s_fp) { fclose(s_fp); s_fp = NULL; }
s_fp = fopen(NODE_LOG_PATH, "w+b");
if (!s_fp) { s_active = false; err = ESP_FAIL; }
s_count = 0;
s_head = 0;
s_seq = 0;
xSemaphoreGive(s_lock);
return err;
}
#else /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
esp_err_t node_log_init(void) { return ESP_ERR_NOT_SUPPORTED; }
bool node_log_is_active(void) { return false; }
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
{
(void)reset_reason;
(void)prev_uptime_s;
}
void node_log_periodic(void) { }
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
{
(void)subtype;
(void)a;
(void)b;
}
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
{
(void)reason;
(void)rssi;
}
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
{
(void)out;
(void)max_records;
(void)skip;
return 0;
}
size_t node_log_count(void) { return 0; }
uint16_t node_log_boot_id(void) { return 0; }
esp_err_t node_log_clear(void) { return ESP_ERR_NOT_SUPPORTED; }
#endif /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
+114
View File
@@ -0,0 +1,114 @@
/* Persistent on-node log -- survives a power cycle, readable without a cable.
*
* The problem this exists for (Joe, 2026-09-07): "a hung node ... doesnt do us
* any good for troubleshooting remotly because the unplug and mount results in
* a clearing of the condtion." Today the post-mortem for any remote fault is
* nothing: main.c decodes the reset reason at boot and logs it to the CONSOLE,
* which is exactly what a power cycle destroys.
*
* The flash was already partitioned for this and the features were off --
* partitions_16mb.csv reserves a 64K coredump partition and an 8000K storage
* FAT volume, neither of which had ever been mounted or written.
*
* # Retention, not wear, is the binding constraint
*
* 128 B records every 300 s fills 8 MB in ~222 days. Endurance at that rate is
* ~61,000 years, so the ring wraps 36 million times before the flash is at
* risk. The write rate is nonetheless bounded explicitly below, because flash
* wear is the one way this module can do harm.
*
* # Why uptime and boot_id rather than a timestamp
*
* These boards have no RTC. Nothing can be timestamped absolutely, so ordering
* comes from boot_id (incremented once per boot, persisted in NVS) plus
* uptime_s within that boot. That ordering survives a power cycle; a wall clock
* would not.
*
* # Counters are stored RAW
*
* Every counter here is cumulative and is written as-is. Differencing happens
* at read time. Storing rates instead would bake in the sampling interval and
* make every record useless the moment the cadence changed.
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/** Every record is exactly this size, so the ring is index-addressable and a
* reader never has to parse forward to find a boundary. */
#define NODE_LOG_RECORD_SIZE 128
/** Record types. A reader dispatches on this byte alone. */
#define NODE_LOG_TYPE_PERIODIC 1
#define NODE_LOG_TYPE_BOOT 2
#define NODE_LOG_TYPE_EVENT 3
/** Event subtypes. Periodic samples give the trend; these give the moment. */
#define NODE_LOG_EV_WIFI_DISCONNECT 1
#define NODE_LOG_EV_WIFI_RECONNECT 2
#define NODE_LOG_EV_THERMAL_STATE 3
#define NODE_LOG_EV_SEND_FAIL_RATE 4
#define NODE_LOG_EV_WATCHDOG 5
/** Mount the FAT volume and open the ring. Call once at boot, after NVS is up
* (the boot counter lives there).
*
* Failure is NOT fatal: on any error this module disables itself and every
* other entry point becomes a no-op. A logging volume that will not mount must
* never take the fleet down with it. */
esp_err_t node_log_init(void);
/** True when the ring mounted and writes are being accepted. */
bool node_log_is_active(void);
/** Append the boot record: reset reason, the previous session's final uptime,
* and a health snapshot.
*
* The highest-value record here. It catches a HANG FOLLOWED BY A WATCHDOG
* RESET, which is the failure that bites this fleet and the one a coredump
* does not capture. */
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s);
/** Append a periodic health sample. Rate-limited internally, so a caller cannot
* wear the flash by calling it in a tight loop. */
void node_log_periodic(void);
/** Append an event record. a/b carry subtype-specific detail -- for a WiFi
* disconnect they are the reason code and the RSSI at that moment, the pair
* main.c currently logs only to the console. */
void node_log_event(uint8_t subtype, int32_t a, int32_t b);
/** Record a WiFi disconnect for the health snapshot's cumulative counters.
* Kept as a setter so this module has no include cycle with main.c, which is
* where the reason and RSSI are actually known. */
void node_log_note_disconnect(uint8_t reason, int8_t rssi);
/** Copy raw records into out, newest-first, for the HTTP reader.
*
* @param out destination, at least max_records * NODE_LOG_RECORD_SIZE
* @param max_records maximum to copy
* @param skip how many of the newest to skip, so a caller can page
* @return number of records written into out */
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip);
/** Total records currently held in the ring. */
size_t node_log_count(void);
/** Boot id of the current session. Records from the same boot share it. */
uint16_t node_log_boot_id(void);
/** Erase every record. Deliberately explicit and never called automatically --
* the whole point of the module is that state survives. */
esp_err_t node_log_clear(void);
#ifdef __cplusplus
}
#endif
+1 -1
View File
@@ -109,7 +109,7 @@ void nvs_config_load(nvs_config_t *cfg)
len = sizeof(buf);
if (nvs_get_str(handle, "ssid", buf, &len) == ESP_OK && len > 1) {
strlcpy(cfg->wifi_ssid, buf, sizeof(cfg->wifi_ssid));
ESP_LOGI(TAG, "NVS override: ssid=%s", cfg->wifi_ssid);
ESP_LOGI(TAG, "NVS override: ssid=<configured>");
}
/* WiFi password */
+11 -11
View File
@@ -23,9 +23,6 @@ static const char *TAG = "ota_update";
/** OTA HTTP server port. */
#define OTA_PORT 8032
/** Maximum firmware size (900 KB — matches CI binary size gate). */
#define OTA_MAX_SIZE (900 * 1024)
/** NVS namespace and key for the OTA pre-shared key. */
#define OTA_NVS_NAMESPACE "security"
#define OTA_NVS_KEY "ota_psk"
@@ -95,11 +92,11 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
int len = snprintf(response, sizeof(response),
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
"\"max_size\":%d}",
"\"max_size\":%lu}",
app->version, app->date, app->time,
running ? running->label : "unknown",
update ? update->label : "none",
OTA_MAX_SIZE);
(unsigned long)(update ? update->size : 0));
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, response, len);
@@ -121,12 +118,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Invalid firmware size (must be 1B - 900KB)");
return ESP_FAIL;
}
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
@@ -134,6 +125,15 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
return ESP_FAIL;
}
if (req->content_len <= 0 || (size_t)req->content_len > update_partition->size) {
ESP_LOGW(TAG, "OTA rejected: content_length=%d exceeds partition '%s' size=%lu",
req->content_len, update_partition->label,
(unsigned long)update_partition->size);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Invalid firmware size for OTA partition");
return ESP_FAIL;
}
esp_ota_handle_t ota_handle;
esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle);
if (err != ESP_OK) {
@@ -0,0 +1,191 @@
#include "serial_onboarding.h"
#include "serial_onboarding_protocol.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "esp_app_desc.h"
#include "esp_efuse.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "psa/crypto.h"
static const char *TAG = "serial_onboard";
static nvs_config_t s_current_config;
static char s_issued_nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1];
static int64_t s_nonce_issued_at_us;
#define ONBOARDING_LINE_MAX 384
#define ONBOARDING_NONCE_TTL_US (60LL * 1000LL * 1000LL)
static const char *chip_name(void)
{
#if defined(CONFIG_IDF_TARGET_ESP32C6)
return "esp32c6";
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
return "esp32s3";
#else
return "esp32";
#endif
}
static void device_digest(char output[17])
{
uint8_t base_mac[6] = {0};
uint8_t digest[32] = {0};
static const uint8_t domain[] = "ruview-device-v1";
uint8_t digest_input[sizeof(domain) - 1 + sizeof(base_mac)];
size_t digest_length = 0;
memcpy(digest_input, domain, sizeof(domain) - 1);
const esp_err_t mac_result = esp_efuse_mac_get_default(base_mac);
if (mac_result == ESP_OK) {
memcpy(digest_input + sizeof(domain) - 1, base_mac, sizeof(base_mac));
}
if (mac_result == ESP_OK &&
psa_crypto_init() == PSA_SUCCESS &&
psa_hash_compute(PSA_ALG_SHA_256, digest_input, sizeof(digest_input),
digest, sizeof(digest), &digest_length) == PSA_SUCCESS &&
digest_length == sizeof(digest)) {
for (size_t index = 0; index < 8; index++) {
snprintf(output + index * 2, 3, "%02x", digest[index]);
}
} else {
memcpy(output, "0000000000000000", 17);
}
}
static void emit_hello(const char *nonce)
{
const esp_app_desc_t *description = esp_app_get_description();
char digest[17] = {0};
device_digest(digest);
const bool configured = s_current_config.wifi_ssid[0] != '\0' &&
s_current_config.target_ip[0] != '\0';
printf("RUVIEW_HELLO_OK_V1 nonce=%s chip=%s version=%s node_id=%u "
"target_ip=%s target_port=%u configured=%u device_digest=%s\n",
nonce, chip_name(), description->version,
(unsigned)s_current_config.node_id, s_current_config.target_ip,
(unsigned)s_current_config.target_port, configured ? 1U : 0U, digest);
fflush(stdout);
}
static esp_err_t commit_config(const ruview_onboarding_config_t *config)
{
nvs_handle_t handle;
esp_err_t result = nvs_open("csi_cfg", NVS_READWRITE, &handle);
if (result != ESP_OK) return result;
if (!config->preserve_wifi) {
result = nvs_set_str(handle, "ssid", config->ssid);
if (result == ESP_OK) result = nvs_set_str(handle, "password", config->password);
}
if (result == ESP_OK) result = nvs_set_str(handle, "target_ip", config->target_ip);
if (result == ESP_OK) result = nvs_set_u16(handle, "target_port", config->target_port);
if (result == ESP_OK) result = nvs_set_u8(handle, "node_id", config->node_id);
if (result == ESP_OK) result = nvs_commit(handle);
nvs_close(handle);
return result;
}
static void process_line(char *line)
{
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1] = {0};
if (ruview_onboarding_parse_hello(line, nonce)) {
memcpy(s_issued_nonce, nonce, sizeof(s_issued_nonce));
s_nonce_issued_at_us = esp_timer_get_time();
emit_hello(nonce);
return;
}
if (strncmp(line, "RUVIEW_CONFIG_V1 ", 17) != 0) return;
ruview_onboarding_config_t request;
const ruview_onboarding_result_t parsed = ruview_onboarding_parse_config(line, &request);
if (parsed != RUVIEW_ONBOARDING_OK) {
printf("RUVIEW_CONFIG_ERR_V1 reason=%s\n", ruview_onboarding_result_name(parsed));
fflush(stdout);
return;
}
const int64_t age_us = esp_timer_get_time() - s_nonce_issued_at_us;
if (s_issued_nonce[0] == '\0' || strcmp(request.nonce, s_issued_nonce) != 0 ||
age_us < 0 || age_us > ONBOARDING_NONCE_TTL_US) {
printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=claim_expired\n", request.nonce);
fflush(stdout);
return;
}
s_issued_nonce[0] = '\0';
const esp_err_t result = commit_config(&request);
if (result != ESP_OK) {
printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=nvs_commit code=%s\n",
request.nonce, esp_err_to_name(result));
fflush(stdout);
return;
}
printf("RUVIEW_CONFIG_OK_V1 nonce=%s node_id=%u rebooting=1\n",
request.nonce, (unsigned)request.node_id);
fflush(stdout);
vTaskDelay(pdMS_TO_TICKS(400));
esp_restart();
}
static void consume_bytes(const uint8_t *input, ssize_t count, char *line,
size_t *length, bool *overflow)
{
for (ssize_t index = 0; index < count; index++) {
const uint8_t byte = input[index];
if (byte == '\r') continue;
if (byte == '\n') {
if (!*overflow && *length > 0) {
line[*length] = '\0';
process_line(line);
}
*length = 0;
*overflow = false;
continue;
}
if (byte < 0x20 || byte == 0x7f) continue;
if (*length + 1 < ONBOARDING_LINE_MAX) line[(*length)++] = (char)byte;
else *overflow = true;
}
}
static void serial_onboarding_task(void *argument)
{
(void)argument;
const int original_flags = fcntl(STDIN_FILENO, F_GETFL, 0);
if (original_flags >= 0) (void)fcntl(STDIN_FILENO, F_SETFL, original_flags | O_NONBLOCK);
char primary_line[ONBOARDING_LINE_MAX];
size_t primary_length = 0;
bool primary_overflow = false;
for (;;) {
uint8_t input[64];
const ssize_t count = read(STDIN_FILENO, input, sizeof(input));
if (count > 0) {
consume_bytes(input, count, primary_line, &primary_length, &primary_overflow);
}
else if (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
ESP_LOGW(TAG, "serial input unavailable: errno=%d", errno);
vTaskDelay(pdMS_TO_TICKS(500));
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
esp_err_t serial_onboarding_start(const nvs_config_t *config)
{
if (config == NULL) return ESP_ERR_INVALID_ARG;
memcpy(&s_current_config, config, sizeof(s_current_config));
const BaseType_t created = xTaskCreate(serial_onboarding_task, "serial_onboard",
4096, NULL, 4, NULL);
if (created != pdPASS) return ESP_ERR_NO_MEM;
ESP_LOGI(TAG, "bounded serial onboarding ready");
return ESP_OK;
}
@@ -0,0 +1,15 @@
#ifndef SERIAL_ONBOARDING_H
#define SERIAL_ONBOARDING_H
#include "esp_err.h"
#include "nvs_config.h"
/** Start the bounded USB serial onboarding listener.
*
* The listener exposes device metadata and accepts one nonce-bound
* configuration transaction. It never prints WiFi credentials and reboots
* only after a successful atomic NVS commit.
*/
esp_err_t serial_onboarding_start(const nvs_config_t *config);
#endif
@@ -0,0 +1,214 @@
#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L /* strtok_r under -std=c11 -pedantic on glibc */
#endif
#include "serial_onboarding_protocol.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define HELLO_COMMAND "RUVIEW_HELLO_V1"
#define CONFIG_COMMAND "RUVIEW_CONFIG_V1"
static bool valid_nonce(const char *value)
{
if (value == NULL || strlen(value) != RUVIEW_ONBOARDING_NONCE_HEX_LEN) return false;
for (size_t index = 0; index < RUVIEW_ONBOARDING_NONCE_HEX_LEN; index++) {
const char c = value[index];
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'))) return false;
}
return true;
}
static bool parse_u32(const char *value, uint32_t maximum, uint32_t *output)
{
if (value == NULL || value[0] == '\0' || output == NULL) return false;
uint32_t result = 0;
for (const char *cursor = value; *cursor != '\0'; cursor++) {
if (*cursor < '0' || *cursor > '9') return false;
const uint32_t digit = (uint32_t)(*cursor - '0');
if (result > (maximum - digit) / 10U) return false;
result = result * 10U + digit;
}
*output = result;
return true;
}
static bool valid_ipv4(const char *value)
{
if (value == NULL || value[0] == '\0' || strlen(value) > 15) return false;
unsigned octets = 0;
const char *cursor = value;
while (*cursor != '\0') {
if (octets == 4) return false;
if (*cursor == '.') return false;
uint32_t octet = 0;
unsigned digits = 0;
while (*cursor != '\0' && *cursor != '.') {
if (*cursor < '0' || *cursor > '9' || digits == 3) return false;
octet = octet * 10U + (uint32_t)(*cursor - '0');
if (octet > 255U) return false;
digits++;
cursor++;
}
if (digits == 0) return false;
octets++;
if (*cursor == '.') cursor++;
}
return octets == 4;
}
static bool valid_private_ipv4(const char *value)
{
if (!valid_ipv4(value)) return false;
unsigned first = 0;
unsigned second = 0;
if (sscanf(value, "%u.%u", &first, &second) != 2) return false;
return first == 10U || (first == 172U && second >= 16U && second <= 31U) ||
(first == 192U && second == 168U);
}
static int base64_value(char c)
{
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
static bool decode_base64(const char *encoded, uint8_t *output,
size_t output_capacity, size_t *output_length)
{
if (encoded == NULL || output == NULL || output_length == NULL) return false;
const size_t length = strlen(encoded);
if (length == 0 || length % 4 != 0) return false;
size_t written = 0;
for (size_t index = 0; index < length; index += 4) {
int values[4];
unsigned padding = 0;
for (unsigned offset = 0; offset < 4; offset++) {
const char c = encoded[index + offset];
if (c == '=') {
if (offset < 2 || index + 4 != length) return false;
values[offset] = 0;
padding++;
} else {
if (padding != 0) return false;
values[offset] = base64_value(c);
if (values[offset] < 0) return false;
}
}
if (padding > 2) return false;
const uint32_t block = ((uint32_t)values[0] << 18) |
((uint32_t)values[1] << 12) |
((uint32_t)values[2] << 6) |
(uint32_t)values[3];
const unsigned bytes = 3U - padding;
if (written + bytes > output_capacity) return false;
output[written++] = (uint8_t)(block >> 16);
if (bytes > 1) output[written++] = (uint8_t)(block >> 8);
if (bytes > 2) output[written++] = (uint8_t)block;
}
*output_length = written;
return true;
}
static bool printable_secret(const uint8_t *bytes, size_t length)
{
if (bytes == NULL) return false;
for (size_t index = 0; index < length; index++) {
if (bytes[index] < 0x20 || bytes[index] == 0x7f) return false;
}
return true;
}
int ruview_onboarding_parse_hello(
const char *line,
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1])
{
if (line == NULL || nonce == NULL) return 0;
const size_t prefix_length = strlen(HELLO_COMMAND);
if (strncmp(line, HELLO_COMMAND, prefix_length) != 0 || line[prefix_length] != ' ')
return 0;
const char *candidate = line + prefix_length + 1;
if (!valid_nonce(candidate)) return 0;
memcpy(nonce, candidate, RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1);
return 1;
}
ruview_onboarding_result_t ruview_onboarding_parse_config(
const char *line,
ruview_onboarding_config_t *config)
{
if (line == NULL || config == NULL || strlen(line) >= 384) return RUVIEW_ONBOARDING_ERR_FORMAT;
char copy[384];
memcpy(copy, line, strlen(line) + 1);
char *tokens[7] = {0};
size_t token_count = 0;
char *save = NULL;
for (char *token = strtok_r(copy, " ", &save); token != NULL;
token = strtok_r(NULL, " ", &save)) {
if (token_count == 7) return RUVIEW_ONBOARDING_ERR_FORMAT;
tokens[token_count++] = token;
}
if (token_count != 7 || strcmp(tokens[0], CONFIG_COMMAND) != 0)
return RUVIEW_ONBOARDING_ERR_FORMAT;
if (!valid_nonce(tokens[1])) return RUVIEW_ONBOARDING_ERR_NONCE;
uint32_t node_id = 0;
if (!parse_u32(tokens[2], 255, &node_id) || node_id == 0)
return RUVIEW_ONBOARDING_ERR_NODE_ID;
if (!valid_private_ipv4(tokens[3])) return RUVIEW_ONBOARDING_ERR_TARGET_IP;
uint32_t target_port = 0;
if (!parse_u32(tokens[4], 65535, &target_port) || target_port == 0)
return RUVIEW_ONBOARDING_ERR_TARGET_PORT;
const bool preserve_wifi = strcmp(tokens[5], "KEEP") == 0 &&
strcmp(tokens[6], "KEEP") == 0;
if ((strcmp(tokens[5], "KEEP") == 0) != (strcmp(tokens[6], "KEEP") == 0))
return RUVIEW_ONBOARDING_ERR_FORMAT;
uint8_t ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES];
size_t ssid_length = 0;
if (!preserve_wifi && (!decode_base64(tokens[5], ssid, sizeof(ssid), &ssid_length) ||
ssid_length == 0 || !printable_secret(ssid, ssid_length)))
return RUVIEW_ONBOARDING_ERR_SSID;
uint8_t password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES];
size_t password_length = 0;
if (!preserve_wifi && (!decode_base64(tokens[6], password, sizeof(password), &password_length) ||
password_length < 8 || !printable_secret(password, password_length)))
return RUVIEW_ONBOARDING_ERR_PASSWORD;
memset(config, 0, sizeof(*config));
memcpy(config->nonce, tokens[1], RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1);
config->node_id = (uint8_t)node_id;
memcpy(config->target_ip, tokens[3], strlen(tokens[3]) + 1);
config->target_port = (uint16_t)target_port;
config->preserve_wifi = preserve_wifi;
if (!preserve_wifi) {
memcpy(config->ssid, ssid, ssid_length);
config->ssid[ssid_length] = '\0';
memcpy(config->password, password, password_length);
config->password[password_length] = '\0';
}
return RUVIEW_ONBOARDING_OK;
}
const char *ruview_onboarding_result_name(ruview_onboarding_result_t result)
{
switch (result) {
case RUVIEW_ONBOARDING_OK: return "ok";
case RUVIEW_ONBOARDING_ERR_FORMAT: return "format";
case RUVIEW_ONBOARDING_ERR_NONCE: return "nonce";
case RUVIEW_ONBOARDING_ERR_NODE_ID: return "node_id";
case RUVIEW_ONBOARDING_ERR_TARGET_IP: return "target_ip";
case RUVIEW_ONBOARDING_ERR_TARGET_PORT: return "target_port";
case RUVIEW_ONBOARDING_ERR_SSID: return "ssid";
case RUVIEW_ONBOARDING_ERR_PASSWORD: return "password";
default: return "unknown";
}
}
@@ -0,0 +1,42 @@
#ifndef SERIAL_ONBOARDING_PROTOCOL_H
#define SERIAL_ONBOARDING_PROTOCOL_H
#include <stddef.h>
#include <stdbool.h>
#include <stdint.h>
#define RUVIEW_ONBOARDING_NONCE_HEX_LEN 32
#define RUVIEW_ONBOARDING_SSID_MAX_BYTES 32
#define RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES 63
typedef struct {
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1];
uint8_t node_id;
char target_ip[16];
uint16_t target_port;
bool preserve_wifi;
char ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES + 1];
char password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES + 1];
} ruview_onboarding_config_t;
typedef enum {
RUVIEW_ONBOARDING_OK = 0,
RUVIEW_ONBOARDING_ERR_FORMAT,
RUVIEW_ONBOARDING_ERR_NONCE,
RUVIEW_ONBOARDING_ERR_NODE_ID,
RUVIEW_ONBOARDING_ERR_TARGET_IP,
RUVIEW_ONBOARDING_ERR_TARGET_PORT,
RUVIEW_ONBOARDING_ERR_SSID,
RUVIEW_ONBOARDING_ERR_PASSWORD,
} ruview_onboarding_result_t;
int ruview_onboarding_parse_hello(const char *line,
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1]);
ruview_onboarding_result_t ruview_onboarding_parse_config(
const char *line,
ruview_onboarding_config_t *config);
const char *ruview_onboarding_result_name(ruview_onboarding_result_t result);
#endif
@@ -37,6 +37,12 @@ static uint32_t s_enomem_suppressed = 0;
* succeeds. */
static uint32_t s_enomem_streak = 0;
/* Timestamp of the last sendto() the stack accepted, on either path; 0 until
* the node has ever delivered anything. Read by the uplink watchdog in main.c
* -- see the rationale there for why "accepted by the stack" is deliberately
* not "received by the server". */
static int64_t s_last_success_us = 0;
static int sender_init_internal(const char *ip, uint16_t port)
{
s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
@@ -118,6 +124,7 @@ int stream_sender_send(const uint8_t *data, size_t len)
/* A send got through — buffer pressure cleared; reset the backoff streak. */
s_enomem_streak = 0;
s_last_success_us = esp_timer_get_time();
return sent;
}
@@ -146,9 +153,18 @@ int stream_sender_send_priority(const uint8_t *data, size_t len)
}
return -1;
}
/* Counts for the watchdog: the mesh sync packet rides this path at ~1 Hz,
* and its getting through proves the node's network path is intact even
* when the bulk CSI stream is being suppressed by the ENOMEM backoff. */
s_last_success_us = esp_timer_get_time();
return sent;
}
int64_t stream_sender_last_success_us(void)
{
return s_last_success_us;
}
void stream_sender_deinit(void)
{
if (s_sock >= 0) {
@@ -50,6 +50,18 @@ int stream_sender_send(const uint8_t *data, size_t len);
*/
int stream_sender_send_priority(const uint8_t *data, size_t len);
/**
* esp_timer timestamp (microseconds) of the last sendto() the network stack
* accepted, on either the bulk or the priority path. Returns 0 if the node has
* not successfully sent anything since boot.
*
* This reports that the datagram was accepted for transmission, NOT that the
* aggregator received it -- UDP gives no such signal. Used by the uplink
* watchdog to detect a node that is powered and capturing but cannot reach the
* network.
*/
int64_t stream_sender_last_success_us(void);
/**
* Close the UDP sender socket.
*/
+201
View File
@@ -0,0 +1,201 @@
/* On-chip thermal monitoring and radio throttling.
*
* The ESP32-C6 carries a temperature sensor (SOC_TEMP_SENSOR_SUPPORTED) that
* nothing in this firmware has ever read. That was tolerable on a bare board
* on a bench; it is not once nine nodes are sealed in printed enclosures and
* left on a wall for months with the radio transmitting continuously.
*
* What we are protecting is the enclosure, not the silicon. The part is rated
* well past anything it will see here, but PLA goes soft at its glass
* transition around 55-60 C -- and that is sagging under its own weight over
* weeks, not a dramatic failure, so it would never announce itself.
*
* The sensor reads DIE temperature, which runs hotter than the plastic around
* it. Thresholds are therefore set against the die with that offset in mind:
* a die at ~72 C in a small vented box is roughly a 50-55 C enclosure, which
* is where PLA starts to matter. They are deliberately not set at 55 C -- that
* would throttle a perfectly healthy node.
*
* Throttling reduces WiFi transmit power, which is the dominant heat source
* and degrades gracefully: links weaken but keep working, where cutting the
* capture rate would blind the node entirely.
*
* IMPORTANT for anyone reading link diagnostics: a throttled node's RSSI drops
* at both ends, which looks exactly like an obstruction. `thermal_state()` and
* `thermal_tx_dbm()` exist so that can be distinguished from a cage or a wall
* rather than misdiagnosed as one.
*/
#include "sdkconfig.h"
#include "thermal.h"
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
#include "driver/temperature_sensor.h"
#include "esp_wifi.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "esp_heap_caps.h"
static const char *TAG = "thermal";
static temperature_sensor_handle_t s_sensor;
static bool s_ready;
static float s_last_c = -273.0f;
static float s_peak_c = -273.0f;
static thermal_state_t s_state = THERMAL_OK;
/* esp_wifi_set_max_tx_power() takes 0.25 dBm units. 80 = 20 dBm, the default
* on this part; each step down is 3 dB, which halves radiated power. */
#define TXP_FULL 80 /* 20 dBm */
#define TXP_STEP1 68 /* 17 dBm */
#define TXP_STEP2 56 /* 14 dBm */
#define TXP_MIN 44 /* 11 dBm */
static int8_t s_txp = TXP_FULL;
#ifdef CONFIG_THERMAL_THROTTLE
/* Only referenced from the throttling block in thermal_tick(). Guarded so a
* monitor-only build (the default, and what is on the wall) does not warn
* about an unused function. */
static void apply_tx_power(int8_t q)
{
if (q == s_txp) {
return;
}
esp_err_t r = esp_wifi_set_max_tx_power(q);
if (r == ESP_OK) {
ESP_LOGW(TAG, "tx power %d.%02d dBm -> %d.%02d dBm (thermal)",
s_txp / 4, (s_txp % 4) * 25, q / 4, (q % 4) * 25);
s_txp = q;
} else {
ESP_LOGW(TAG, "esp_wifi_set_max_tx_power(%d) failed: %s",
q, esp_err_to_name(r));
}
}
#endif /* CONFIG_THERMAL_THROTTLE */
esp_err_t thermal_init(void)
{
/* The requested span must fit ENTIRELY inside one of the sensor's
* predefined measurement ranges -- the driver walks a table and takes the
* first that contains it, so a span crossing two boundaries is rejected
* with ESP_ERR_INVALID_ARG rather than clamped. (-10,110) looks reasonable
* and is invalid for exactly that reason; it failed on real hardware.
*
* (20,100) is the right first choice here: the warn/throttle/critical
* thresholds all sit comfortably inside it, and indoor ambient is
* essentially never below 20 C. The wider fallbacks exist so an unheated
* room, or a different chip revision with a different table, degrades to a
* usable range instead of leaving the node unmonitored. */
static const struct { int lo, hi; } spans[] = {
{20, 100}, {-10, 80}, {-30, 50}, {50, 125},
};
esp_err_t r = ESP_ERR_INVALID_ARG;
for (size_t i = 0; i < sizeof(spans) / sizeof(spans[0]); i++) {
temperature_sensor_config_t cfg =
TEMPERATURE_SENSOR_CONFIG_DEFAULT(spans[i].lo, spans[i].hi);
r = temperature_sensor_install(&cfg, &s_sensor);
if (r == ESP_OK) {
ESP_LOGI(TAG, "sensor range %d..%d C", spans[i].lo, spans[i].hi);
break;
}
}
if (r != ESP_OK) {
ESP_LOGW(TAG, "sensor install failed on every range: %s "
"(continuing unmonitored)", esp_err_to_name(r));
return r;
}
r = temperature_sensor_enable(s_sensor);
if (r != ESP_OK) {
ESP_LOGW(TAG, "sensor enable failed: %s", esp_err_to_name(r));
return r;
}
s_ready = true;
ESP_LOGI(TAG, "monitoring on: warn %d C, throttle %d C, critical %d C "
"(die temperature; protecting the enclosure, not the chip)",
CONFIG_THERMAL_WARN_C, CONFIG_THERMAL_THROTTLE_C,
CONFIG_THERMAL_CRITICAL_C);
return ESP_OK;
}
void thermal_tick(void)
{
if (!s_ready) {
return;
}
float c;
if (temperature_sensor_get_celsius(s_sensor, &c) != ESP_OK) {
return;
}
s_last_c = c;
if (c > s_peak_c) {
s_peak_c = c;
}
/* Hysteresis on the way down: recovering at the same threshold that
* tripped would oscillate, and each oscillation is a visible RSSI step on
* every link this node has. The decision itself lives in thermal.h as a
* pure function so a host test can exercise the same code this runs. */
const thermal_state_t next = thermal_next_state(s_state, c,
CONFIG_THERMAL_WARN_C,
CONFIG_THERMAL_THROTTLE_C,
CONFIG_THERMAL_CRITICAL_C,
CONFIG_THERMAL_HYSTERESIS_C);
if (next != s_state) {
ESP_LOGW(TAG, "%.1f C: %s -> %s",
c, thermal_state_name(s_state), thermal_state_name(next));
s_state = next;
}
#ifdef CONFIG_THERMAL_THROTTLE
/* Acting on the reading is separate from taking it. Measuring is inert;
* changing transmit power mid-experiment moves every RSSI this node has,
* which is not something to introduce during a fleet bring-up when any
* surprise needs a single candidate cause. Turn this on once the fleet is
* stable AND the logs show a node actually gets hot -- if none ever does,
* it never needs turning on at all. */
switch (s_state) {
case THERMAL_CRITICAL: apply_tx_power(TXP_MIN); break;
case THERMAL_THROTTLED: apply_tx_power(TXP_STEP2); break;
case THERMAL_WARN: apply_tx_power(TXP_STEP1); break;
case THERMAL_OK: apply_tx_power(TXP_FULL); break;
}
#endif
static int64_t s_last_log_us;
int64_t now = esp_timer_get_time();
if (now - s_last_log_us > 60LL * 1000 * 1000) {
s_last_log_us = now;
/* Heap rides along because it is the other thing that fails slowly
* and silently over weeks. A node that reboots mysteriously after ten
* days looks identical to one that overheated, unless the minimum
* free heap has been visibly walking downwards the whole time. */
ESP_LOGI(TAG, "die %.1f C (peak %.1f C) state=%s tx=%d.%02d dBm "
"heap %u min %u",
s_last_c, s_peak_c, thermal_state_name(s_state),
s_txp / 4, (s_txp % 4) * 25,
(unsigned)esp_get_free_heap_size(),
(unsigned)esp_get_minimum_free_heap_size());
}
}
float thermal_celsius(void) { return s_last_c; }
float thermal_peak_celsius(void) { return s_peak_c; }
thermal_state_t thermal_state(void) { return s_state; }
int8_t thermal_tx_dbm(void) { return (int8_t)(s_txp / 4); }
const char *thermal_state_name(thermal_state_t s)
{
switch (s) {
case THERMAL_OK: return "ok";
case THERMAL_WARN: return "warn";
case THERMAL_THROTTLED: return "throttled";
case THERMAL_CRITICAL: return "critical";
}
return "?";
}
#endif /* CONFIG_THERMAL_MONITOR && ESP32C6 */
+106
View File
@@ -0,0 +1,106 @@
/* On-chip thermal monitoring and radio throttling. See thermal.c for why the
* thresholds are set against die temperature rather than the plastic. */
#pragma once
#include "esp_err.h"
#include <stdint.h>
typedef enum {
THERMAL_OK = 0,
THERMAL_WARN,
THERMAL_THROTTLED,
THERMAL_CRITICAL,
} thermal_state_t;
/**
* Decide the next thermal state from a reading and the current state.
*
* Pure: no globals, no hardware. Kept in the public header so a host test can
* exercise the exact function the device build runs, rather than a copy of it
* that is free to drift.
*
* Rising edges act immediately -- heat is a reason to react now. EVERY falling
* edge requires cooling a full `hyst_c` below the threshold that was crossed on
* the way up, because recovering at the same point that tripped oscillates, and
* each oscillation steps this node's transmit power, which moves the RSSI of
* every link it has. A reader cannot distinguish that from a wall moving.
*
* The CRITICAL -> THROTTLED edge is the reason this exists as a function. It
* used to be unconditional: any reading below `critical_c` dropped the state
* immediately, so a node sitting on the critical boundary flapped between
* minimum and step-2 transmit power indefinitely -- precisely the oscillation
* the hysteresis elsewhere was added to prevent. With defaults (critical 80,
* hysteresis 8) leaving CRITICAL now needs 72 C, which is exactly the THROTTLED
* entry point.
*
* @param cur Current state.
* @param c Die temperature, degrees C.
* @param warn_c Warn threshold.
* @param throttle_c Throttle threshold.
* @param critical_c Critical threshold.
* @param hyst_c Degrees of cooling required before any step down.
* @return The state to adopt.
*/
static inline thermal_state_t thermal_next_state(thermal_state_t cur, float c,
int warn_c, int throttle_c,
int critical_c, int hyst_c)
{
const float h = (float)hyst_c;
/* Rising: immediate, at the plain thresholds. */
if (c >= (float)critical_c) return THERMAL_CRITICAL;
/* Falling out of CRITICAL needs a full hysteresis band, like every other
* downward step. Without this the state tracks `c` across critical_c with
* no damping at all. */
if (cur == THERMAL_CRITICAL && c >= (float)critical_c - h) {
return THERMAL_CRITICAL;
}
if (c >= (float)throttle_c) return THERMAL_THROTTLED;
if (cur == THERMAL_THROTTLED && c >= (float)throttle_c - h) {
return THERMAL_THROTTLED;
}
if (c >= (float)warn_c) return THERMAL_WARN;
if (cur == THERMAL_WARN && c >= (float)warn_c - h) {
return THERMAL_WARN;
}
return THERMAL_OK;
}
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
esp_err_t thermal_init(void);
/* Sample and apply policy. Call from a slow loop -- the die does not change
* fast, and reading it more often buys nothing. */
void thermal_tick(void);
float thermal_celsius(void);
float thermal_peak_celsius(void);
thermal_state_t thermal_state(void);
/* Current WiFi transmit ceiling in whole dBm.
*
* Exposed because a throttled node's RSSI falls at both ends, which is
* indistinguishable from an obstruction in the link diagnostics. Anything
* reading link strength should check this before concluding a wall moved. */
int8_t thermal_tx_dbm(void);
const char *thermal_state_name(thermal_state_t s);
#else /* stubs: S3 build and when monitoring is off */
static inline esp_err_t thermal_init(void) { return ESP_OK; }
static inline void thermal_tick(void) {}
static inline float thermal_celsius(void) { return -273.0f; }
static inline float thermal_peak_celsius(void) { return -273.0f; }
static inline thermal_state_t thermal_state(void) { return THERMAL_OK; }
static inline int8_t thermal_tx_dbm(void) { return 20; }
static inline const char *thermal_state_name(thermal_state_t s) { (void)s; return "n/a"; }
#endif
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff. Show more