Compare commits

...
301 Commits
Author SHA1 Message Date
rUv 0ef6b96fe1 docs: describe RuView-focused README animation 2026-10-06 09:25:36 -07:00
rUv 2d5472372f docs: focus animation on RuView sensing and spatial awareness 2026-10-06 09:25:34 -07:00
rUv efdf97ecc1 docs: add ruOS animation below RuView header 2026-10-06 09:12:38 -07:00
rUv 46d80ffc3a docs: upload Ruflo-powered ruOS desktop animation 2026-10-06 09:12:36 -07:00
rUv 0b15c0ebc9 Merge pull request #2163 from ruvnet/release/aepod-integration
release: integrate aepod PRs #2100-#2157 (38 PRs, review fixes, validated)
2026-10-05 13:52:42 -07:00
ruv 682a6f4538 docs(changelog): community batch PRs #2100-#2157
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 12:21:07 -04:00
ruv a72174c571 fix: review findings and cross-PR integration fixes for aepod release set
- mediatek: cap per-device maps at 64 with oldest-first eviction; clamp --csi-ring
- training: mark live-buffer fallback runs degraded (data_source/degraded) instead of plain "completed"
- multistatic: skip CIR blend when any frame in the cohort has a placeholder phase
- groundtruth: median count baseline; radar labels carry evidence=CLAIMED
- spatial-evidence: default proof CODE; MEASURED requires a reproducer
- scripts: collect-ground-truth guards non-JSON replies and unreadable token files
- docker: entrypoint accepts 1/true/yes/on for RUVIEW_UDP_INSECURE_LAN
- adaptive model: untrack v2/data/adaptive_model.json (#2135)
- docs: ADR-379 evidence tags, RUNBOOK/README OTA wording, NOTICE for vendored MediaTek patch
- tests: fresh timestamps for privacy tests (#2119 staleness), plan_source Result handling,
  drop obsolete qualcomm-binds-nothing test (#2126), ignore slow live-binary test (#2157)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 12:08:21 -04:00
ruv fd323e483e Merge PR #2128 (aepod) into release integration
# Conflicts:
#	.github/workflows/ci.yml
2026-10-05 11:48:40 -04:00
ruv 9f0821cf69 Merge PR #2104 (aepod) into release integration 2026-10-05 11:48:25 -04:00
ruv 69fc9efff7 Merge PR #2145 (aepod) into release integration 2026-10-05 11:48:25 -04:00
ruv 20386cf313 Merge PR #2141 (aepod) into release integration
# Conflicts:
#	docs/adr/README.md
2026-10-05 11:48:24 -04:00
ruv c4fd6d9b36 Merge PR #2101 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/training_api.rs
2026-10-05 11:48:16 -04:00
ruv e223d86ba2 Merge PR #2144 (aepod) into release integration 2026-10-05 11:47:35 -04:00
ruv 52e5ebefbd Merge PR #2143 (aepod) into release integration 2026-10-05 11:47:35 -04:00
ruv ca44cd1a7b Merge PR #2117 (aepod) into release integration
# Conflicts:
#	docs/integrations/home-assistant.md
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:47:34 -04:00
ruv 5fe0a6a48d Merge PR #2105 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:46:33 -04:00
ruv da1ef6a17d Merge PR #2102 (aepod) into release integration
# Conflicts:
#	docs/adr/README.md
2026-10-05 11:46:15 -04:00
ruv b6c2fb5b7d Merge PR #2116 (aepod) into release integration 2026-10-05 11:46:08 -04:00
ruv b8b70b7df7 Merge PR #2115 (aepod) into release integration 2026-10-05 11:46:07 -04:00
ruv bb5d903724 Merge PR #2114 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:46:07 -04:00
ruv 1e2348abef Merge PR #2113 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:45:52 -04:00
ruv 2493185ab8 Merge PR #2112 (aepod) into release integration 2026-10-05 11:45:33 -04:00
ruv 678f63f4f3 Merge PR #2125 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/cli.rs
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:45:26 -04:00
ruv 0aba0fc467 Merge PR #2127 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:44:53 -04:00
ruv 6f550c39ed Merge PR #2157 (aepod) into release integration 2026-10-05 11:44:34 -04:00
ruv e9d0cef610 Merge PR #2133 (aepod) into release integration 2026-10-05 11:44:34 -04:00
ruv bb22f1b185 Merge PR #2109 (aepod) into release integration 2026-10-05 11:44:33 -04:00
ruv 7bc5fe1a53 Merge PR #2103 (aepod) into release integration 2026-10-05 11:44:33 -04:00
ruv 51e1788c7e Merge PR #2129 (aepod) into release integration 2026-10-05 11:44:32 -04:00
ruv 7b62297f3e Merge PR #2126 (aepod) into release integration 2026-10-05 11:44:31 -04:00
ruv 890e521a3a Merge PR #2132 (aepod) into release integration 2026-10-05 11:44:31 -04:00
ruv 444184f9ef Merge PR #2119 (aepod) into release integration 2026-10-05 11:44:30 -04:00
ruv 7955af5bc0 Merge PR #2107 (aepod) into release integration 2026-10-05 11:44:29 -04:00
ruv 273a2a8536 Merge PR #2108 (aepod) into release integration 2026-10-05 11:44:29 -04:00
ruv add0755958 Merge PR #2134 (aepod) into release integration 2026-10-05 11:44:28 -04:00
ruv de5f7edd16 Merge PR #2100 (aepod) into release integration 2026-10-05 11:44:22 -04:00
ruv 69e0729c61 Merge PR #2156 (aepod) into release integration 2026-10-05 11:44:22 -04:00
ruv 9baaf8434f Merge PR #2155 (aepod) into release integration 2026-10-05 11:44:21 -04:00
ruv 27eefb6a7f Merge PR #2140 (aepod) into release integration 2026-10-05 11:44:20 -04:00
ruv c6f7e8ca0d Merge PR #2120 (aepod) into release integration 2026-10-05 11:44:20 -04:00
ruv 474f1a904e Merge PR #2118 (aepod) into release integration 2026-10-05 11:44:19 -04:00
ruv ddaa54eba0 Merge PR #2110 (aepod) into release integration 2026-10-05 11:44:19 -04:00
ruv b3d31510ee Merge PR #2130 (aepod) into release integration 2026-10-05 11:44:18 -04:00
ruv bce512cb8d Merge PR #2131 (aepod) into release integration 2026-10-05 11:44:18 -04:00
ruv 9950ecc340 fix(provision): refuse to write credentials through a symlink with a clear error
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 11:44:17 -04:00
ruv e0c4207bd5 Merge PR #2152 (aepod) into release integration
# Conflicts:
#	firmware/esp32-csi-node/provision.py
2026-10-05 11:43:47 -04:00
ruv 044a41d2de Merge PR #2153 (aepod) into release integration 2026-10-05 11:43:14 -04:00
ruv 8d243a67f4 Merge PR #2111 (aepod) into release integration 2026-10-05 11:43:13 -04:00
Mathew Beaneandclaude-flow ee6717f035 fix(sensing-server): lock the CSI grid gate on the majority grid and report a stalled pipeline on /health (#1894)
The per-node grid gate locked onto the densest CSI grid a node had ever
sent and rejected every other grid. Real ESP32-S3 nodes interleave
several grids: one node sent 192 bins on 88% of frames and 306 on 6%, so
the gate locked onto the 306 minority and dropped about 90% of that
node's frames. A node whose radio moved to a sparser grid for good was
rejected forever; when every node did this, `tick` froze while /health
still reported ok.

The gate now votes over each node's last 64 frames and locks onto the
grid the node sends most. A challenger must outnumber the active grid
3:2 to take over, so interleaved grids don't flap the lock. A switch
clears the window as before. On an ESP32-C6 the HE-SU majority still
wins and the HT minority is still rejected.

/health keeps `status: "ok"` (the UI's server probe keys on it) and adds
a `processing` block: a live/stalled/no_input/idle state, the age of the
last published update, and lock-free UDP counters (datagrams, age of the
last datagram, datagrams dropped by the source allowlist). A frozen
pipeline can now be told apart from input that stopped arriving.
Allowlist drops also log a warning on the 1st, 2nd, 4th, 8th... drop
instead of only at debug level.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 17:31:47 -04:00
Mathew Beaneandclaude-flow f5a36666fc fix(hardware): accept ESP32 frames up to the firmware buffer size
Esp32CsiParser capped n_subcarriers at 256. Real ESP32-S3 nodes on
firmware 0.8.12 send 1-antenna frames with 306 subcarriers (612 I/Q
bytes), so the parser dropped about a third of live frames. Pointcloud
started hitting that cap once it switched to this parser.

The bound now comes from the firmware: frames are serialized into
CSI_MAX_FRAME_SIZE = 20 + 4 * 256 * 2 bytes, so a frame carries at most
1024 I/Q pairs across all antennas. The check is n_antennas *
n_subcarriers <= 1024.

HT/legacy bandwidth no longer maps subcarrier counts above 128 to 80 or
160 MHz. ESP32 radios use 20/40 MHz channels, and the larger counts
(192, 306) come from merged LLTF/HT-LTF buffers, so anything above 64
is reported as 40 MHz.

The pointcloud UDP receive buffer grows from 2048 to 4096 bytes so the
largest legal frame (2068 bytes) is not truncated.

Refs #1348

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:52:10 -04:00
Mathew Beaneandclaude-flow d78a221607 feat(provision): read the WiFi password from a file or a prompt
--password puts the WiFi password in argv, where ps and shell history can
see it (#1754). This adds two ways to give it without that:

- --password-file PATH reads it from a file, dropping one trailing newline
  (\n or \r\n). The file must exist and not be empty. If group or others can
  read it, the script refuses and suggests chmod 600.
  --allow-insecure-password-file accepts such a file with a warning, for
  read-only mounts such as 0444 secrets.
- When --ssid is given with no password flag and stdin is a terminal, the
  script asks with getpass. It reuses a saved password only if it was saved
  for the same SSID. Without a terminal (scripts, CI) nothing is asked and
  the existing missing-credentials check applies.

--password still works, but now prints a one-line warning on stderr that
points at --password-file. The two flags are mutually exclusive. The
firmware README examples now use --password-file.

Tests: 10 new tests in tests/test_provision_secrets.py, with placeholder
credentials and getpass and the flasher stubbed. 8 fail on the previous
commit; the other 2 check that nothing is asked without a terminal or when a
saved password matches the SSID. The suite goes from 25 to 35 passing.

Refs #1754

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:50:43 -04:00
Mathew Beaneandclaude-flow db81dce7ba fix(multistatic): gate phase combining on a declared shared reference
The sensing-server bridge builds every node frame with a zero phase
placeholder and the fuser combined those phases across nodes as if they
were measured against one reference. ESP32 nodes each run an independent
crystal with a random PLL phase after reset; mesh time sync aligns frames,
not RF phase, so there is nothing to combine.

- Add PhaseReference (Unavailable / NodeLocal / Shared{id}) to
  MultiBandCsiFrame. The server bridge declares Unavailable.
- MultistaticFuser combines phase only when every frame declares the same
  Shared id. Otherwise fused_phase is empty and FusedSensingFrame carries
  PhaseFusion::AmplitudeOnly(reason). Amplitude fusion is unchanged.
- The ADR-134 CIR blend is skipped when the phase is a placeholder.
- TrustedOutput and /health/ready (trust.phase_fusion) report the mode
  and reason.
- ADR-029 status note updated.

Refs #1752, #1710, #1750.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:42:03 -04:00
Mathew Beaneandclaude-flow 3a8ec8139e fix(pointcloud): parse ESP32 CSI frames with the reference ADR-018 parser
The pointcloud parser read channel from byte 8, RSSI from byte 9, noise
floor from byte 10 and a "timestamp" from bytes 16..19. The firmware
(csi_serialize_frame) writes freq_mhz as u32 at 8..11, sequence at
12..15, RSSI at 16, noise floor at 17 and PPDU type/flags at 18..19. So
RSSI came out as the high byte of the frequency (+9 for 2437 MHz), and
the tomography attenuation was built from it.

parse_adr018 now delegates to wifi_densepose_hardware::Esp32CsiParser,
the same parser the rest of the workspace uses, and only reshapes its
output. CsiFrame.channel/timestamp_us become freq_mhz/sequence, which
are what the header actually carries. ADR-081 feature-state packets
(0xC5110006) are no longer decoded as CSI frames.

build_test_frame (used by csi-test) now emits the real layout. Golden
byte-by-byte tests cover single and two-antenna frames, truncation and
the feature-state packet; a cross-check test compares against the
reference parser.

Refs #1348

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:30:45 -04:00
Mathew Beaneandclaude-flow 98c1d4a1e9 fix(provision): key saved state by board MAC, not serial port
provision.py saved each board's settings under the serial port path. Ports
get reused when boards are swapped (a second board on the same hub slot often
comes up as the same /dev/cu.usbmodem*), so the next board inherited the
previous board's node_id, target_port and channel without any sign (#1755).

- On a real flash the script reads the base MAC with `esptool read_mac` and
  keeps state in mac-<aabbccddeeff>.json. The record also stores the MAC and
  the port it was last written from.
- EUI-64 chips (C5, C6, H2) print an 8-byte "MAC:" line before "BASE MAC:";
  the parser takes the base MAC on those and the single MAC line elsewhere.
- Migration: if the board has no record yet and a port-keyed file from an
  earlier version exists, it is used once, with a notice that points at
  --reset, then removed after the save so no other board can pick it up.
- --state and --dry-run still never open the port. --state shows the board
  last provisioned from that port, and --mac selects a board by hand.
  --dry-run without --mac keeps the port-keyed file, which the QEMU swarm test
  relies on (--port /dev/null --dry-run).
- If the MAC can't be read, state stays keyed by port and the script says so.

Tests: tests/test_provision_mac_state.py adds 18 tests, using placeholder
MACs, with esptool, the NVS generator and the flasher stubbed. 15 of them
fail on the base; the other 3 guard behaviour the base already has (no serial
access on --dry-run, --reset, rejecting a bad --mac). The suite goes from 14
to 32 passing.

Not verified on hardware: the real esptool read_mac call. The output shapes
in the tests come from esptool 4.12 and 5.4.0 cmds.py.

Refs #1755

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:28:47 -04:00
Mathew Beaneandclaude-flow c00deadaf7 fix(provision): keep the WiFi password owner-only and out of --state output
The per-port state file holds the WiFi password and seed token in clear, but
save_state() created the directory and file with the default umask, so they
landed 0755/0644 and any local user could read them (#1754). --state also
printed both values verbatim, and that output gets pasted into issues.

- The state dir is created 0700 and state files 0600. Staging uses mkstemp
  (unique name, O_EXCL, 0600) plus fchmod, so a stale temp file or a planted
  symlink can't receive the secret.
- harden_state_dir() tightens the dir and every .json/.tmp file in it,
  including ones left by older versions. main() runs it before any read or
  early exit, so --state repairs old files too. It skips symlinks and files
  owned by other users, and only warns if chmod fails.
- The --dry-run NVS binary and the generator-failure CSV fallback are written
  0600 even when the file already exists. The generator's CSV and binary now
  live in a private mkdtemp() dir instead of the shared temp dir.
- --state shows password and seed_token as (set)/(empty). --show-secrets
  prints them.

Windows is unchanged: chmod can't set owner-only ACLs there.

Tests: tests/test_provision_secrets.py adds 11 tests; all 11 fail on the
base and the suite goes from 14 to 25 passing.

Refs #1754

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:25:43 -04:00
Dragan Spiridonov 7c8aeacea2 Merge pull request #2147 from ruvnet/codex/calibrated-vitals-empty-20261004
fix(sensing): gate vital outputs on fresh calibrated occupancy
2026-10-04 15:07:58 +02:00
Dragan Spiridonov c3764a429f fix(sensing): require calibrated occupancy before publishing vitals 2026-10-04 11:50:13 +02:00
Mathew Beaneandclaude-flow 985e5d9449 feat(groundtruth): radar + CSI occupancy auto-labeller with session split and baseline
auto_label cuts a RuView .csi.jsonl recording at a radar log's window
boundaries and labels each cut empty/occupied with the radar's person
count and count interval, plus a small CSI summary (frame count, mean
RSSI, mean and variance of per-frame amplitude). The radar-to-recording
clock offset is a required, reported parameter. Windows are dropped with
a counted reason when the radar window is UNKNOWN, when too few CSI
frames fall inside it, or when an "empty" comes within empty_hold_ms of
an occupied window (a motion tracker can lose someone who sits still).

session_disjoint_split splits by session only and refuses a split where
either side would be empty or a named test session has no windows.
majority_baseline fits the no-CSI model on the training side (majority
occupancy class, mean count) and scores it on the test side, so any
occupancy accuracy built on these labels has a baseline next to it.

examples/radar_autolabel.rs is a small CLI over the three. Tests use
synthetic data only; nothing here has been run on a real capture yet.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:55:11 -04:00
Mathew Beaneandclaude-flow b033fd309e feat(groundtruth): radar window adapter for presence, count and range labels
ruview-groundtruth had an MmWave reference modality but no way to read a
radar log. RadarLabels reads one JSON summary per window (the shape a
UART radar reader writes: timestamp_ms, window_start_ms, health, source
flags, presence, target_count, max_target_count, distance_cm, targets
x/y) and turns each window into presence, person-count and nearest-range
labels.

Each label carries its basis and an uncertainty: a count interval from
the counts the window reports, the configured range sigma (a datasheet
figure, so CLAIMED), and the reader's quality. Windows from a simulated
or unverified source, a no_source reader, or with a presence flag that
disagrees with the count are UNKNOWN with a reason rather than guessed.
Labelled windows convert to ReferenceSeries for the existing alignment
and agreement code. Malformed lines are row-numbered InvalidRow errors.

serde_json moves from a dev-dependency to a dependency for the parser.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:55:10 -04:00
Mathew Beaneandclaude-flow 76af08a9b0 docs(adr): ADR-381 node self-survey from pairwise ranging
Proposed. Defines the RangeObservation record, a warn-only check mode
against --node-positions with tolerances from MEASURED ESP32-S3 FTM
bench data, per-boot calibration, and a fill mode gated off until a
source shows MEASURED p90 link error <= 0.3 m (expected from UWB).
Records the FTM bias between responder starts plainly and parks FTM
for check mode, coarse gating and time sync rather than dropping it.
Links ADR-144, ADR-281, rufield ADR-261, #2109 and #2133.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:08:04 -04:00
Mathew Beaneandclaude-flow 951ffcc254 feat(survey): add check-mode node self-survey crate (ADR-381)
Adds ruview-survey, a host-side check that compares pairwise node ranges
with the configured --node-positions and warns, never writes. It reports
per-link residuals, a fitted scale with feet/inch/cm/mm unit detection,
a suggested id swap (exhaustive up to 8 nodes, fewest-changes rule,
explicit ambiguity for symmetric layouts), moved nodes, and unknown or
unranged ids.

RangeObservation is source-agnostic (FTM, UWB, BLE CS, manual). Per-boot
calibration keys offsets by (initiator, responder, responder_boot)
because the measured FTM bias moved with every responder start. Fill
mode is not implemented; fill_gate keeps it off until a source has
MEASURED p90 link error <= 0.3 m over >= 6 links.

FTM tolerances come from a two-board ESP32-S3 bench (20 MHz, ch 1):
spread within a start <= 0.31 m, bias between starts -0.5 to +1.9 m at
1.14 m, clamp to 0 below ~0.8 m. Tests are SYNTHETIC.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:08:04 -04:00
Mathew Beaneandclaude-flow cbd84acf6c fix(unified): stop GaussianMap::decay compounding across calls
decay(now) multiplied confidence by exp(-(now - timestamp_ns)/tau_eff)
on every call, but timestamp_ns is the last-seen time and does not move
when decay runs. Calling decay(t1) then decay(t2) applied
exp(-(t1-ts)/tau) * exp(-(t2-ts)/tau) instead of exp(-(t2-ts)/tau), so
confidence fell faster the more often the caller ticked.

Track how far each Gaussian has already been decayed (decayed_to_ns)
and only apply the interval since max(decayed_to_ns, timestamp_ns).
A fresh observation still restarts the clock, and the lifetime-stretched
tau_eff is unchanged. Fusion and the merge pass keep the later of the
two decayed_to_ns values. The new field is #[serde(default)], so maps
serialized before this change load with 0 and decay from timestamp_ns
as before.

Adds two tests: one-shot vs. per-second decay must agree to 1e-12, and
the wall-vs-transient persistence case must hold when decay runs every
10 s.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 22:58:13 -04:00
Mathew Beaneandclaude-flow a1d7eb0bfd docs(spatial-evidence): ADR-382 for the RF wire contract, pin the v1 yaw convention
The spatial.evidence.v1 format is specified in WeftOS ADR-107 section 7.
ADR-382 records the part RuView emits (envelope, room_enu frame,
rf_gaussian, rf_link_observation, validation ranges, versioning) so the
contract is readable from this repository, and names ADR-107 as its origin.

ADR-107 was amended on 2026-10-03: one yaw convention (degrees
counter-clockwise from room +x), new tof_depth and radar_range types, and
an optional sensor block on radar_track_point. The two RF bodies did not
change. The crate now documents the yaw sense of an rf_gaussian
orientation and tests it (+90 degrees about +z maps local x onto room +y),
and tests that the amended example lines are rejected as unknown types by
this RF-only mirror.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 22:57:08 -04:00
Mathew Beaneandclaude-flow 3741e7ba43 feat(spatial-evidence): emit RF Gaussians and links as spatial.evidence.v1
New leaf crate ruview-spatial-evidence converts ruview-unified output
into the spatial.evidence.v1 JSONL wire format (WeftOS ADR-107 section 7):

- RfGaussian / GaussianMap -> rf_gaussian records. A Gaussian with
  synthetic provenance is always tagged SYNTHETIC; the caller can lower
  the proof tag, never raise it.
- Link measurements (tx/rx, measured vs free-space amplitude) ->
  rf_link_observation with excess_loss_db = 20 log10(free / measured).
  The Friis reference comes from ruview-unified's channel model.

The v1 envelope and the two RF bodies are mirrored with local serde
structs, so there is no WeftOS dependency. Validation follows the v1
rules: schema version, id charset and length, finite values, coordinate,
frequency, scale, occupancy, confidence and excess-loss ranges, and the
16 KiB line limit. Golden tests compare against the ADR example lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 18:18:41 -04:00
Mathew Beaneandclaude-flow fabb0c8233 fix(sensing-server): refuse a routable HTTP bind when API auth is off
The UDP receiver already refuses a routable bind without a source
allowlist (ADR-296), and the Docker entrypoint refuses a routable bind
without RUVIEW_API_TOKEN (#864). Running the binary directly with
--bind-addr 0.0.0.0 and no auth only logged that auth was off.

The server now exits at startup when the HTTP/WebSocket bind is not
loopback and API auth (RUVIEW_API_TOKEN or RUVIEW_OAUTH_ISSUER) is off,
unless RUVIEW_ALLOW_UNAUTHENTICATED is set, in which case it starts and
logs a warning. Loopback binds are unaffected. The conditions match the
entrypoint's, so a container that starts today still starts.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:26:26 -04:00
Mathew Beaneandclaude-flow da2304985a fix(sensing): stop absent nodes reporting a person count of 1
Every person count is already gated on presence, but two presence
decisions disagreed with the motion level published next to them:

- smooth_and_classify[_node] set presence = smoothed_motion > 0.03 while
  the debounced level only leaves "absent" above 0.04. A quiet room that
  settles in (0.03, 0.04] read motion_level "absent" with presence true,
  so the count stayed at 1.
- The raw-CSI per-node count was gated on the per-frame classification,
  which an adaptive-model label overrides. /api/v1/nodes could show a
  node as "absent" with person_count 1 while the ADR-297 room vote (which
  reads the node's debounced level) already said the room was empty.

Presence now follows the debounced level, as the room vote already does,
and the per-node count is gated on the node's own published level.
score_to_person_count keeps its floor of 1: it only runs when presence is
true, and an empty room reports 0 through the gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:21:48 -04:00
Mathew Beaneandclaude-flow 8c6d29bf83 fix(sensing-server): report measured stream fps and real WebSocket client count
/api/v1/stream/status returned "fps": 10 whenever tick > 1, whatever the
real rate, and both it and /health counted every broadcast subscriber
(recorder, MQTT bridge) as a client. The dashboard Streaming card read
"IDLE, 0 client(s)" for up to 30 s while the page was streaming, because
its first /health poll lands before the page's socket connects; with auth
on, the ticket round-trip widens that gap.

- New stream_stats module: UpdateRateMeter (sliding 5 s window, bounded at
  1024 samples) and WsClientCounter with an RAII guard.
- AppStateInner::advance_tick() replaces the five `s.tick += 1` sites and
  records each sensing update in the meter.
- /api/v1/stream/status now returns the measured update rate as `fps`
  (0 when nothing flows), `active` from that rate, `clients` from the
  WebSocket counter, `nodes` with each fresh node's measured csi_fps (null
  until its EMA has 5 samples) and `csi_fps_total`.
- /health and the /health/health stream component count WebSocket clients
  on /ws/sensing and /api/v1/stream/pose; the stream component also carries
  the measured rate.
- DashboardTab re-reads health 500 ms after the sensing socket connects or
  drops, so the card follows the socket instead of waiting for the next
  30 s poll.

Fixes #2087

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:15:23 -04:00
Mathew Beaneandclaude-flow 5cffe9e7db fix(sensing-server): accept vendor --source values and bind UDP for them
The previous commit limited --source to auto/esp32/wifi/simulated, which
rejected the vendor names the server already keys on: mediatek, qualcomm,
realtek (RTL8720F radar) and realtek_csi (RTL8721Dx CSI). They are the
labels the UDP receiver writes and effective_source() ages.

On 03a89793 those names fell into the catch-all plan, which bound no UDP
receiver, so vendor frames were never ingested. They now plan like esp32
(UDP receiver bound) without the ESP32-shaped simulator. Anything else,
including case variants and runtime-only labels like "mediatek:simulated",
is still rejected.

Refs #2097

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:13:38 -04:00
Mathew Beaneandclaude-flow c51a1cf888 fix(sensing-server): apply --privacy-mode to REST, WebSocket and recordings
--privacy-mode was declared in the MQTT arg group and read only by the
MQTT publisher, so /api/v1/sensing/latest, /api/v1/pose/current, the
/ws/sensing and /api/v1/stream/pose streams and recordings kept serving
pose keypoints (and heart/breathing rate when vitals are published).

- Move --privacy-mode (env RUVIEW_PRIVACY_MODE) out of MqttArgs to a
  top-level server flag. Spelling and env var are unchanged. The MQTT
  config takes its value from the server flag.
- Add privacy_filter: one key filter for vital_signs, HR/BR leaves,
  enhanced_breathing, keypoints, pose_keypoints and joints_m.
- REST: middleware strips those keys from every JSON response on the
  HTTP and WS ports. Unparseable JSON is withheld with a 500.
- WebSocket: /ws/sensing frames and /api/v1/stream/pose messages are
  filtered; the pose physics assessment is withheld.
- /api/v1/pose/current?view=both|refined returns 403 privacy_mode.
- Recordings write filtered frames.
- Default (flag off) behaviour is unchanged; the middleware is a no-op.

Fixes #2094

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:12:45 -04:00
Mathew Beaneandclaude-flow 522374584c fix(ui): stop retrying and ask for the API token when the server requires one
With RUVIEW_API_TOKEN set on the server and no token in the browser, the
sensing socket was refused, closed with 1006, and retried forever while the
header said "Connecting...". A browser cannot see the 401 on a refused
upgrade, so the service could not tell this from an outage.

- ws-ticket.js: resolveWsUrl() reports a stored token the server refuses
  (401/403) as 'rejected'. probeWsAuthRequired() repeats the ticket request
  with no bearer after an unticketed socket fails to open; a 401 means the
  server wants a token this browser does not have ('missing'). The probe
  only runs on the failure path, so auth-off servers see no extra request.
  withWsTicket() keeps its string contract for the other callers.
- sensing.service.js: new 'auth-required' state and dataSource. Entering it
  clears the retry timer; reconnect() leaves it.
- The header widget shows "Token required", the global banner explains it,
  and both open QuickSettings on API Access (once automatically per page
  load, then on click). Tab banners and the dashboard indicator get labels.
- Tests: ws-ticket.test.mjs (+6) and a new sensing.service.test.mjs (5),
  added to the CI UI test step.

Fixes #2099

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:12:23 -04:00
Mathew Beaneandclaude-flow 5755d9f11f fix(sensing-server): status waits for frames; reject unknown --source
/api/v1/status reported source_state "live_unverified" for a configured
live source before any frame had arrived, because source_state() fed a
fixed zero frame age into the ADR-295 resolver. It now uses the real age
of the last accepted frame for the current source, so the states are:

- disconnected (waiting_for_frames: true) until the first frame
- live_unverified while frames arrive within ESP32_OFFLINE_TIMEOUT (5 s)
- stale once they stop for longer than that

The response also carries waiting_for_frames and last_frame_age_ms.
ADR-270 vendor events get their own receive clock so they age the same
way.

An unknown --source value (for example "macos") used to start a server
with no data task. Clap now rejects it with the list of valid values
(auto, esp32, wifi, simulated, simulate), and plan_source returns an
error for anything else.

Docs: user guide lists the valid --source values and status states, the
macOS section uses --source wifi, and the Linux section says there is no
Linux RSSI source in the server yet.

Refs #2097

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:09:50 -04:00
Mathew Beaneandclaude-flow 3bf993275e fix(sensing-server): give the governed fuser the configured node positions
EngineBridge built its StreamingEngine without ever calling
set_node_positions, so the governed fuser placed every node at the
origin even when --node-positions was set. Startup now passes the same
keyed map the sibling multistatic_fuser gets, via
EngineBridge::with_node_positions. The fuser resolves positions by
node_id per frame, so nodes discovered later need no extra call.

/api/v1/status (and /health/ready) now reports the map under
trust.node_positions so the configuration can be checked at runtime.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:08:58 -04:00
Mathew Beaneandclaude-flow 05ea4feaed fix(sensing-server): keep recordings and models under --data-dir
Recordings, the recording list/delete routes, the trainer's dataset
loader and model export, the models routes and the adaptive classifier
all used paths relative to the process cwd (`data/recordings`,
`data/models`, `data/adaptive_model.json`), so `--data-dir` only covered
config and the session secret.

Add `recordings_dir(data_dir)` and `models_dir(data_dir)` and use them
everywhere. `MODELS_DIR` still overrides the models directory, and the
trainer now honours it too. With the default `--data-dir data` the paths
are unchanged.

Fixes #2088

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:07:11 -04:00
Mathew Beaneandclaude-flow 7af5477f04 fix(scripts): parse the ADR-018 header layout in the UDP capture scripts
Both capture scripts decoded a header layout the firmware does not send.
csi_collector.c and esp32_parser.rs use: magic u32 @0, node_id @4,
n_antennas @5, n_subcarriers u16 @6, freq_mhz u32 @8, sequence u32 @12,
rssi i8 @16, noise_floor i8 @17, ppdu_type @18, flags @19, then
n_antennas * n_subcarriers int8 I/Q pairs from @20.

- collect-training-data.py read a 16-byte header and int16 I/Q, so its
  length check rejected every real single-antenna frame. It now reads the
  20-byte header and int8 pairs, records freq/channel/seq/ppdu/flags, and
  lets the recorder stamp receive time (the header has no timestamp).
- record-csi-udp.py read rssi from byte 6 and channel from byte 7 (the
  n_subcarriers bytes) and treated bytes 8-19 as I/Q, so a 64-subcarrier
  frame came out as 70 subcarriers with header bytes in the first six
  amplitudes. It now checks the magic, reads the real fields, and writes
  only the I/Q block to iq_hex.
- Add scripts/tests/test_capture_adr018.py, which builds frames with the
  esp32_parser.rs test layout and checks the decoded fields.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:01:22 -04:00
Mathew Beaneandclaude-flow 84f5e19e9d fix(scripts): run the proof with python3 and report INCOMPLETE when a gate is skipped
prove.sh looked only for `python`, so on macOS (python3 only) it skipped
the deterministic proof and still printed RESULT: PASS with exit 0.

- Pick $PYTHON, then python3, then python.
- Check numpy/scipy import before running verify.py and print the
  lockfile install command when they are missing; warn when installed
  versions differ from archive/v1/requirements-lock.txt.
- Run verify.py single-threaded, as CI does.
- Hard gates that cannot run (no cargo, no Python, missing deps) are
  reported as [NOT RUN] and the run ends INCOMPLETE with exit 2.
  Data/hardware-gated claims are unchanged.
- Tell people to stop any local server on UDP 5005 first (#2086).

Fixes #2095

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 21:57:49 -04:00
Mathew Beaneandclaude-flow 1a50162598 fix(sensing-server): stop serving a frozen /sensing/latest as current
latest_update is only rewritten when a source produces a new update. When
every source went quiet, /api/v1/sensing/latest kept returning the last
classification indefinitely, with no marker that it was old. A one-off
bogus frame could leave "absent 0.97" frozen there.

Past 10 s, the endpoint now returns
{"status":"stale","age_ms":...,"source":effective_source()} instead of
the old payload. Fresh payloads gain an age_ms field. "no data yet" is
unchanged. This is separate from #2060, which excludes stale nodes from
the room vote: that changes what a new update says, while this bounds how
long an old update is served.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 0eb952a9c5 fix(sensing-server): evict stale node_states on a timer, not only on frame arrival
Stale node eviction (60 s) lived inside the ESP32 receive path and ran
every 100 ticks, so it only happened while ESP32 frames kept arriving.
After a burst of bogus node ids, or once every node went quiet, nothing
ever removed the entries, and they stayed listed in /api/v1/nodes for the
life of the process.

Move the existing rule unchanged into evict_stale_node_states (60 s,
nodes that never reported included). Call it from the receive path as
before, and from a new node_states_eviction_task that sweeps every 30 s
alongside the UDP receiver.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 08f0f8859b fix(sensing-server): reject ESP32 CSI frames with zero antennas or subcarriers
parse_esp32_frame accepted any datagram that started with the 0xC511_0001
magic and had a 20-byte header. With n_antennas or n_subcarriers at zero,
the expected length collapses to the header itself, so the length check
passed and the frame minted a node carrying empty amplitude/phase arrays.
A non-ESP32 sender on the shared UDP port produced exactly that: bogus
nodes in /api/v1/nodes.

Return None when either count is zero. Well-formed frames parse as before.
The new tests cover zero antennas, zero subcarriers, and a bare header,
and check that a well-formed frame still parses.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 8c63d7b558 docs(mqtt): document the published entity set and add an ADR-115 P4.5 status note
home-assistant.md now describes current behaviour: the 15 announced
entities (13 with --privacy-mode) with their source and availability
rule, the entities that are not announced and why, publish rates and
retention, the persisted client id, and what privacy mode does and does
not cover. The guide no longer documents --semantic* flags that
sensing-server does not accept, and it drops the HIPAA/GDPR/CCPA
claims.

ADR-115 §11 records what P4.5 wired and what it did not, with a
before/after measurement against a real broker (MEASURED, one live
node).

Refs #2085, #2093

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:46:21 -04:00
Mathew Beaneandclaude-flow e351d0fce1 feat(mqtt): wire semantic states into the publisher, announce only sourced entities
Discovery announced 20 entities per device, but only 6 ever received
state (#2085). presence/state was published on every CSI frame, and the
per-process default client id gave Home Assistant new devices on every
restart (#2093).

- mqtt::planner maps each snapshot to availability, state and events.
  It runs one SemanticBus per node at 1 Hz (ADR-115 P4.5) and gates
  binary states on change with a 60 s retained heartbeat. Sensors keep
  their configured rates. Each entity is `online` only while the server
  has a source for it; otherwise it is `offline` and gets no state.
- Entities with no source on the sensing broadcast are no longer
  announced: zone_occupancy, bathroom_occupied, bed_exit,
  multi_room_transition (no zones), meeting_in_progress (motion is
  three-level) and pose (pose_keypoints is never set). 15 remain, 13
  with --privacy-mode.
- mqtt::bridge, moved from main.rs, reads only sensing_update. That
  removes the phantom aggregate device that edge_vitals frames created.
  It takes fall events from the edge_vitals rising edge and
  motion_energy from motion_band_power (previously always 0.0), and maps
  present_still to 0 % motion.
- The default client id is wifi-densepose-<8 hex>, persisted in
  <data-dir>/mqtt_client_id. --mqtt-client-id still overrides it.

Refs #2085, #2093

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:46:11 -04:00
Mathew Beaneandclaude-flow ea6da967d2 docs(mediatek): OpenWrt WN586X3 CSI patch set, router CSI guide, ADR-266 amendment
- firmware/openwrt-wn586x3/: the patch set that made a Wavlink WL-WN586X3
  Rev A (MT7981B + MT7976C) emit CSI on OpenWrt 24.10.8. It contains:
  MediaTek's mt7915 CSI driver patch rebased onto the mt76 revision 24.10.8
  pins (BSD-3-Clause-Clear, MediaTek author line kept); the Rev A board
  patch (GPL-2.0); a buffer-overrun fix for `mt76-vendor dump csi`; and
  an attribute-enum fix for MtkCSIdump. Each patch keeps the licence of
  the code it modifies. There are no images, firmware blobs, or captures.
- docs/mediatek-router-csi.md: an operator guide covering supported
  silicon, the image build route that was tested, arming CSI, the
  bridge, `--source mediatek`, endpoints and provenance labels, what the
  data is not, evidence, and troubleshooting. Every claim is tagged
  MEASURED, CODE-DERIVED, CLAIMED, or SYNTHETIC.
- scripts/mediatek-csi-phase-stability.py: a read-only reproducer for
  the one MEASURED offline result. On one MT7976C radio, the cross-chain
  phase arg(H0*conj(H1)) moves 0.014-0.039 rad (p50) between adjacent
  frames for well-sampled transmitters, against about 1.56 rad for raw
  per-chain phase, which is what random phase gives. It never prints a
  transmitter address. The captures it was run on are not in the tree.
- ADR-266 Amendment 1: vendor CSI exists on MT7981 through MediaTek's
  public feed patch, demonstrated on one board model, and still
  `mediatek:physical-unvalidated`. The hardware gates remain open.
- user-guide.md and the bridge README link to the guide.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:32:25 -04:00
Mathew Beaneandclaude-flow f44f25d596 feat(mtk-bridge): bridge MT7981 vendor CSI into MTC1
Adds `wifi-densepose-mtk-bridge`. It converts MediaTek mt76 vendor CSI
into ADR-267 MTC1 frames for the sensing server's UDP ingest.

Inputs:
- `--replay`: an `mt76-vendor dump csi` JSON file, or a `.mtkcap`
  recording. Records are grouped into frames by the driver's last-chain
  bit, per transmitter address, so HT 2x2 and legacy 1x2 runs from one
  radio each close with their real dimensions. Concatenated and duplicate
  dumps (the vendor tool appends) are reported, not hidden.
- `--listen`: MtkCSIdump UDP datagrams. They carry no packet identity, so
  each datagram becomes one 1x1 frame rather than a guessed pairing.
  Missing RSSI/noise is the -128 dBm sentinel, and PPDU type is `Unknown`.

Provenance:
- The bridge never sets CALIBRATED.
- A replayed file must be either attested (`--captured-on
  <model>/<firmware>`) or declared `--synthetic`, or the bridge refuses to
  start.
- Synthetic is sticky. It can come from a sidecar, a `.synthetic.` file
  name, or a capture header, and none of these can be overridden.
- `--listen` requires a `--listen-allow` CIDR list. Other sources are
  dropped and counted, never decoded.

Fixtures are fabricated and use locally administered addresses. The
assembler tests that model the two live dump loops use stand-in addresses,
not the real clients'. Captures written under the bridge's earlier format
tag still load.

Tests: 51 unit and 39 integration tests, plus clippy -D warnings.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:30:00 -04:00
Mathew Beaneandclaude-flow 8ecac18978 feat(sensing-server): labelled MediaTek amplitude heuristic and activity index
Real MediaTek frames now produce a room update, but from a heuristic that
is labelled as one on every output. The validated ESP32 pipeline needs
per-subcarrier phase history this path does not feed it, so it is not
used here and is never impersonated.

- `mediatek-amplitude-heuristic-v0` (CLAIMED, no labelled evaluation):
  presence and motion from per-device mean and peak amplitude over a 10 s
  time window (not a sample count, because arrivals are bursty).
  Hysteresis means one dip cannot flip the room to absent. Below a
  confidence of 0.3, a device reports `unknown` and abstains from the
  room vote rather than asserting "absent".
  `SensingUpdate.classifier` names it; `nodes[].device_id` and
  `nodes[].mediatek_diagnostics` say why each device reads as it does.
- `activity-index-v0` (CLAIMED): a fast/slow fluctuation index against
  each device's own baseline, plus an absolute level against a learned
  quiet floor. It is served in `SensingUpdate.activity` and
  `GET /api/v1/csi/mediatek/activity`, with
  `is_occupancy_estimate: false` on every block. `--activity-state <path>`
  persists learned floors across restarts.
- The summary endpoint adds the heuristic's window statistics.
- The MQTT per-node topics key on `device_id` when it is present, so two
  64-bit receiver ids cannot collide on `node_id` 0.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:28:26 -04:00
Mathew Beaneandclaude-flow e5f88b88fe feat(sensing-server): per-device MediaTek CSI ingest and inspection
The server kept one MediaTek snapshot in total, so two MT7981 receivers
overwrote each other, and `--source mediatek` fell through to the
catch-all source plan and bound no UDP socket at all.

- `--source mediatek` binds the UDP receiver and does not start the
  ESP32-shaped simulator, which has no MediaTek branch and would otherwise
  publish a simulated room classification next to real frames.
- Snapshots are kept per `device_id`.
  `GET /api/v1/csi/mediatek/devices` lists them with `age_ms` and drops
  any device silent for over 30 s.
- A bounded per-device ring (`--csi-ring`, default 256 frames, about
  0.5 MiB for a 2x2 device at 64 subcarriers) keeps per-chain,
  per-subcarrier amplitude and phase. It serves
  `/devices/:device_id/frames` and `/devices/:device_id/summary`. Chains
  are keyed by (tx, rx) identity, so a device that interleaves 1x2 legacy
  and 2x2 HT frames is reported correctly.
- New provenance label `mediatek:physical-unvalidated` for real-silicon
  frames that are not calibrated. `SYNTHETIC` still takes precedence over
  every other flag. Plain `mediatek` stays reserved for calibrated frames.

No classifier changes in this commit.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:27:19 -04:00
Mathew Beaneandclaude-flow ebb303cbb8 feat(hardware): carry legacy and unknown PPDU types in MediaTek MTC1
A real MT7981 (WL-WN586X3, MT7981B + MT7976C) CSI report arrived with
rx_mode = MT_PHY_TYPE_OFDM for a 2.4 GHz client. The MTC1 PpduType had no
pre-HT value, so a bridge had to drop the frame. MediaTek's CSI driver
treats legacy OFDM as its own tone-mask group, and association traffic is
legacy-rate, so these frames are routine rather than a corner case.

Add PpduType::Legacy = 6 and PpduType::Unknown = 7. Values 1-5 and the
72-byte header are unchanged. A decoder built before this change rejects
6 and 7 as UnknownPpduType (fails safe, drops the frame), so MTC1
consumers need a rebuild.

ADR-267 gains Amendment 1 recording the change, its compatibility, and the
observed 1x1x64 dimensions of the motivating capture. The capture is not
in the tree: it holds a client transmitter address and raw CSI.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:17:36 -04:00
Mathew Beaneandclaude-flow c80f618119 fix(sensing-server): show which nodes use the default position (#1804)
Nodes not named in --node-positions report [2.0, 0.0, 1.5], and the boot
line counts parsed entries whether or not any matches a live node id. A
config keyed by the wrong ids looked applied and did nothing.

- WARN once per node id when a fresh node has no configured position,
  naming the configured ids (INFO when no positions are configured).
- /api/v1/nodes: add position_configured per node.
- /api/v1/status: add node_positions with configured_node_ids,
  default_position and default_position_node_ids.
- At boot, WARN that entries without a node_id: prefix are keyed by list
  index from 0, with the entry -> node mapping. Semantics are unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:17:25 -04:00
Mathew Beaneandclaude-flow 3b542f8a85 fix(docker): make UDP CSI ingest posture explicit in the container
Since ADR-296 the sensing server binds its UDP receiver to 127.0.0.1 by
default, and docker-entrypoint.sh never set a UDP bind. Inside a
container that address cannot receive from the published 5005/udp port,
so the default image never shows a node (#2089, follow-up to #1394).

The entrypoint now binds UDP on 0.0.0.0 only when the operator has set
an ADR-296 source guard (RUVIEW_UDP_ALLOW, RUVIEW_UDP_INSECURE_LAN, or
the matching CLI flags). Without a guard it keeps the loopback bind and
prints a note explaining why no node will appear and what to set,
including the macOS bridge-gateway allowlist. An explicit RUVIEW_UDP_BIND
or --udp-bind still wins.

I chose this over defaulting the bind to 0.0.0.0 unconditionally. With
no guard that would make the server refuse to start (exit 1) in auto
mode, which binds UDP, and the guard would still have to be supplied. The
change keeps ADR-296's fail-closed rule as is: a routable bind never
happens without an allowlist or the explicit insecure opt-in. Auth
defaults are unchanged.

The entrypoint also normalizes RUVIEW_UDP_INSECURE_LAN=1/yes/on to true,
since clap rejects 1 for that flag.

The compose service passes RUVIEW_API_TOKEN, the RUVIEW_UDP_* variables
and SENSING_ALLOWED_HOSTS through from the shell, with notes on the
exit-64 auth check, the macOS gateway allowlist and the 421 Host check.
The README and user-guide Docker quickstarts now set a token, and the
user guide gains a "Receiving ESP32 frames in Docker" section.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:13:10 -04:00
Mathew Beaneandclaude-flow ef01c9e2be fix(sensing-server): accept 1/0/yes/no/on/off for boolean env flags
clap's default bool parser only takes true/false, so
RUVIEW_UDP_INSECURE_LAN=1 exited 2 with "invalid value '1'". Add a
shared parse_env_bool value parser (1/0, true/false, yes/no, on/off,
case-insensitive; anything else is still an error) and apply it to every
bool+env flag: RUVIEW_UDP_INSECURE_LAN, RUVIEW_NO_MDNS,
RUVIEW_NO_EDGE_REGISTRY, RUVIEW_MQTT, RUVIEW_MQTT_TLS,
RUVIEW_PRIVACY_MODE and RUVIEW_MATTER. The bare --flag form is
unchanged.

The Docker entrypoint now accepts RUVIEW_ALLOW_UNAUTHENTICATED=
true/yes/on (any case) as well as 1.

Fixes #2091

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:07:20 -04:00
Mathew Beaneandclaude-flow abeca0f0ed fix(sensing-server): send multi-node test frames to an ephemeral port
The multi_node_test UDP smoke tests sent synthetic ADR-018 CSI and
vitals frames to 127.0.0.1:5005, the sensing server's default port.
Because the tests are not ignored, a routine `cargo test` (and
scripts/prove.sh) could inject up to 255 fake node IDs into a locally
running server, and the hardcoded port could collide with other
processes.

Each test now binds its own receiver on 127.0.0.1:0 and sends to the
OS-assigned address, keeping the receiver alive for the test's
duration. The tests still only exercise the sender and frame-builder
path; no server is involved.

Ported from whitsentry, a RuView-based project, which carries the same fix.

Closes #2086

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:04:54 -04:00
Mathew Beaneandclaude-flow 5a36be17d2 fix(provision): make --dry-run and failed --reset leave state intact
--dry-run persisted the merged state record, so the next real run merged
on top of values that never reached the device. It now skips save_state.
The NVS binary is still written to nvs_provision.bin because ADR-061 and
scripts/qemu-mesh-test.sh consume it.

--reset deleted the per-port state file before argument validation, so a
run that then failed (e.g. missing WiFi credentials) still lost the record.
It now only ignores the prior state; a successful flash overwrites the file
as before.

Also fix the module docstring's package name: the PyPI package is
esp-idf-nvs-partition-gen, matching the runtime hint.

Fixes #2096

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:03:30 -04:00
Mathew Beaneandclaude-flow 559528155f fix(sensing-server): train the adaptive classifier per node, as it runs
Runtime classification is per node, from that node's own features and
amplitudes. The trainer instead built one sample per recorded line from the
fused frame-level features plus the first node's amplitudes, so a model
trained on a multi-node recording learned a different input distribution
from the one it is later asked to classify.

- load_recording now emits one sample per node per new frame, from that
  node's node_features entry and amplitude vector. A node contributes only
  when its amplitude vector changed since the previous line (every broadcast
  snapshots all nodes), and stale nodes are skipped. Lines without
  node_features (older single-node recordings) fall back to the frame-level
  features.
- Runtime subcarrier statistics use the same 56-subcarrier window that
  sensing_update, and therefore every recording, stores
  (RECORDED_AMPLITUDE_LEN, now shared with the broadcast in main.rs).
- The trainer stamps model version 2 (TRAINED_MODEL_VERSION), so a freshly
  trained model consumes the measured temporal dominant frequency (ADR-356)
  instead of being switched to the legacy subcarrier proxy at runtime.
- The training accuracy log line is labelled in-sample and printed as a
  percentage (it printed the 0..1 fraction with a % sign).

Tests: cargo test -p wifi-densepose-sensing-server --no-default-features:
bin 337 passed (5 new node_sample_tests), lib 539 passed,
integration tests pass. Clippy reports nothing on the changed lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:15:05 -04:00
Mathew Beaneandclaude-flow f161abf582 feat(sensing-server): loopback UDP tee for second consumers (ADR-380)
Add --udp-tee / RUVIEW_UDP_TEE: every datagram the source allowlist admits
is copied unchanged to up to four loopback targets, so a fusion host or
Cognitum cog can read full I/Q, sequence numbers and sync packets without
binding the CSI port. Targets must be loopback and may not reuse the
listener port or port 0. Sends are non-blocking and drops are counted. Off
by default.

Tests: cargo test -p wifi-densepose-sensing-server --no-default-features
--lib udp_tee (6 passed) and --bin sensing-server (332 passed). Loopback
smoke test of the debug binary: a 288-byte datagram sent to the UDP port
arrived byte for byte at the tee target, and a non-loopback --udp-tee
target made the server exit 1 with "must be a loopback address".

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:12:19 -04:00
Mathew Beaneandclaude-flow a4f80e63a2 docs(adr): ADR-379 OTA first-boot health check, with hardware evidence
Records the decision behind the two preceding firmware commits: why an
OTA'd image has to confirm itself, the health signals (STA IP plus one CSI
frame accepted by the stream sender), the 30 s minimum uptime and 120 s
deadline, the FORCE_FAIL test hook, ota_state in /ota/status, and the httpd
stack sizing.

MEASURED 2026-09-29:
- ESP32-S3 (16 MB, rollback bootloader): OTA push to ota_1, HTTP 200;
  "httpd stack after POST /ota: 4900 of 8192 bytes never used";
  pending_verify then valid; still valid on ota_1 after a hard reset.
  A FORCE_FAIL build pushed to ota_0 rolled back to ota_1 after ~30 s.
- ESP32-C6 (4 MB layout, rollback bootloader): push to ota_1, HTTP 200;
  pending_verify then valid ~28 s later; still valid after a hard reset.

Not yet verified: the 120 s no-IP timeout path, a true power cycle (an RTS
hard reset was used), and forced rollback on the C6.

Numbered 379: main has up to ADR-378 and open PRs claim nothing higher (2026-10-02).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:12:12 -04:00
Mathew Beaneandclaude-flow 43f5f99108 fix(scripts): send the API token from collect-ground-truth and check the reply
With authentication enabled, collect-ground-truth.py could not start a CSI
recording: it sent no bearer token. It also treated any HTTP 200 as success,
but the recording endpoints answer 200 with {"success": false, "error": ...}
for refusals such as "recording already in progress". Either way it went on
capturing camera keypoints with no paired CSI and only printed a warning.

- Read the token from --token-file, else RUVIEW_API_TOKEN, and send it as
  Authorization: Bearer on start and stop.
- post_json() returns False when the body says success: false.
- Name the CSI recording gt_<timestamp> so it pairs with
  keypoints_<timestamp>.jsonl.
- Exit with an error when the recording cannot start, unless
  --allow-no-csi is given.

Tests: py_compile; scripts/tests (15 passed); a local smoke test against a
stub HTTP server checked the bearer header, the request id, the
success:false refusal, and token precedence (file, then env).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:08 -04:00
Mathew Beaneandclaude-flow f8ab204711 fix(wifiscan): keep the redacted macOS connected link; auto+wifi keeps UDP bound
On macOS 27, without Location Services, CoreWLAN redacts both the SSID and
the BSSID, while rssi, noise and channel stay real. resolve_bssid abstained
when both identities were blank, so every macOS scan was dropped and
`--source wifi` silently produced nothing.

- mac_wifi --scan-once marks its sample `"connected": true`. It is always the
  single connected link, so a synthetic id cannot merge two networks.
- A redacted connected sample gets a stable locally administered synthetic
  BSSID; the SSID stays empty. Unmarked redacted lines still abstain.
- `--source auto` that resolves to host WiFi now also binds UDP, and wifi_task
  yields while the source is a live esp32, so ESP32 nodes that start after
  the boot probe still promote the source (the #1004 rule). Without this, the
  fix would make stock Macs ignore late-starting nodes.
- A missing helper is reported once, with the build command.
- ADR-025: Updated row + Amendment 1 recording the measured behaviour.

Measured on macOS 27: --source wifi gives source wifi:unnamed with sensing
ticks advancing; --source auto on a quiet port starts on wifi with
udp_receiver=true and promotes to esp32 (5 nodes) when real frames arrive.
Tests: wifiscan 158+1, sensing-server bin 332, all passing.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:08 -04:00
Mathew Beaneandclaude-flow 0df5069831 fix(training): train from real sensing recordings, per node
training_api loaded `{id}.csi.jsonl` in a flat {subcarriers, rssi} schema,
but the recorder writes `{id}.jsonl` sensing_update lines (nodes[] amplitude,
node_features[]). Training on a real recording therefore saw zero frames
(#1703).

- Accept sensing_update lines as well as legacy flat frames; try `{id}.jsonl`
  first, then `{id}.csi.jsonl`.
- Emit one frame per node per update, skip repeated snapshots and nodes
  without amplitudes, and keep frames grouped by node (stable sort).
- RecordedFrame gains an optional node_id; temporal features (window, prev)
  only use frames from the same node.
- Malformed lines are counted, not fatal.

Tests: recording_format_tests (4 new);
cargo test -p wifi-densepose-sensing-server --no-default-features
--bin sensing-server: 336 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
Mathew Beaneandclaude-flow f6a3a3579f fix(firmware): confirm OTA'd images with a bounded health check
Rollback-enabled nodes (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y) boot an
OTA'd image as PENDING_VERIFY, but nothing called
esp_ota_mark_app_valid_cancel_rollback(), so every OTA reverted at the next
reset, a second OTA was refused until then, and a bad image was never
deliberately rolled back.

- main/ota_health.{c,h}: at boot, if the running image is PENDING_VERIFY,
  poll for STA got-IP and >=1 CSI frame accepted by the stream sender.
  Confirm after CONFIG_OTA_HEALTH_MIN_UPTIME_S (30 s) once both are in;
  roll back via esp_ota_mark_app_invalid_rollback_and_reboot() if they are
  not in by CONFIG_OTA_HEALTH_TIMEOUT_S (120 s from boot). VALID, UNDEFINED,
  NEW and no-otadata/factory images are left alone. The decision is a pure
  header function, host-tested in test/test_ota_health.c (make host_tests).
- CONFIG_OTA_HEALTH_FORCE_FAIL (test only) rolls back where it would have
  confirmed, for proving rollback on hardware.
- GET /ota/status reports ota_state so durability is visible remotely.
- RUNBOOK: replace the nonexistent ota_rollback_boot_check() claim and
  document the behaviour and the hardware check (2.1).

Hardware-verified on an ESP32-S3 (push, confirm, hard reset, forced
rollback) and an ESP32-C6 (push, confirm, hard reset); see ADR-379, next in
this series. Host tests: make host_tests passes, including run_ota_health.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
Mathew Beaneandclaude-flow d25fb8192e fix(firmware): stop POST /ota overflowing the httpd stack
MEASURED 2026-09-29 on an ESP32-S3 (v0.8.12, 16 MB): every POST /ota of a
valid 1,228,672 B image died with "stack overflow in task httpd" and
RTC_SW_CPU_RST, so no OTA had ever completed on this firmware.

- ota_update.c: set config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE (new
  Kconfig, default 8192; HTTPD_DEFAULT_CONFIG gave 4096). Move the 1 KB
  receive buffer off the stack into a static (handlers are serialised on
  the one httpd task). Integer progress percent instead of %f.
  recv_wait_timeout stays 30 s.
- Log the httpd stack high-water mark after every POST /ota and
  POST /wasm/upload so the size rests on device data.
- wasm_upload.c: GET /wasm/list builds its 2 KB JSON on the heap.
- RUNBOOK §3 (over the air) records the failure and the high-water-mark
  log line.

Device check after the fix (same S3): "httpd stack after POST /ota: 4900 of
8192 bytes never used", i.e. about 3.3 KB peak. The ADR with the full
hardware evidence follows in this series (ADR-379).

This overlaps PR #1594, which raises the same stack. If #1594 lands first,
drop this commit; the rest of the series does not depend on it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
rUv 03a897938f Merge pull request #2062 from ruvnet/claude/ruview-npm-package-9uc5x3
feat(npm): RuView npm toolkit @ruvnet/ruview 0.9.1 — kernel, SDK, flashing, device access, MCP Apps, live Claude Code views (ADR-368..378)
2026-10-01 20:53:26 -04:00
ruv 15d48d4481 style(homecore-hap): rustfmt the new verifier test
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 19:55:28 -04:00
ruv c764c8916b fix(homecore-hap): pad the SRP verifier to 384 bytes (flaky provisioning)
compute_verifier returns the minimal big-endian encoding, so about one
provisioning in 256 produced a verifier with a zero top byte, encoded to
383 bytes, and failed the store's own 384-byte check
("SRP verifier must contain exactly 384 bytes"). Seen in CI on
first_provisioning_returns_code_but_restart_does_not. Left-pad to the
3072-bit group width at creation: the same number, the fixed length.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 19:55:05 -04:00
rUv 5efc7e2c2c Merge pull request #2073 from ruvnet/feat/ruview-mcp-apps-ui
feat(npm): live sensor views in Claude Code, MCP Apps console, ESPHome radar, ruview umbrella — @ruvnet/ruview 0.9.1 (ADR-375..378)
2026-10-01 19:23:17 -04:00
ruv 39b4ae9ae1 release: @ruvnet/ruview 0.9.1 and ruview umbrella 0.9.1
Bump the harness and the umbrella (which pins it), point README and the
user guide at 0.9.1, sync the stale lockfile header (0.5.1, old bin name)
with package.json, and add a plain-language changelog entry for the
live sensing views in Claude Code (ADR-377, ADR-378).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:57:49 -04:00
ruv 4a472926e1 fix(ruview-live): code-review findings on the waterfall (ADR-378)
- Stale frames shown as live: spectrumWith now keeps only nodes in the
  latest capture, so a node that stops streaming (or a failed capture)
  leaves the waterfall and the "no CSI frames" card returns.
- Multi-antenna ESP32 nodes never reached the waterfall: with --spectrum
  the parser adds chain-0 amplitudes (ADR-018 antenna-major) in a separate
  field the SpectrumCollector reads; the kernel path is unchanged.
- Frames from different CSI layouts could be joined: spectrum results
  carry their shape, and frames merge only when shape and bins match.
- Animation ran on assumed time: frames read the real clock (the one the
  full render uses), so pulses keep the reported rates and the replay
  keeps up when a frame fires late; replay lag is pruned with the nodes.

Tests: regression tests for each; npm test 193 pass, test:security 91
pass, engine tests 4 pass, validate passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:47:18 -04:00
ruv c4f9d95e11 fix(ruview-live): open as a dialog that takes the keys, sized per view
Hotkeys reach a pane only while it holds the keyboard, and `focus` alone
was not taking it. The engine's documented dialog form does: open with
focus + closeOnEscape + holdToasts (Escape closes, as originally). Each
open also requests `rows` for its view (overview 16, waterfall 30,
radar 26) so the inline pane opens tall enough for the bordered design
instead of a third of the screen; switching views re-requests the size,
and picture heights leave room for the chrome (rows - 15).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:35:19 -04:00
ruv bf21c8c7a9 Revert "fix(ruview-live): fit the inline pane; actions on top; show focus state"
The compact inline layout read as broken next to the bordered cards the
user prefers; restore the previous design (78a147405). This reverts
commit 629e1b9c6.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:33:28 -04:00
ruv 629e1b9c6c fix(ruview-live): fit the inline pane; actions on top; show focus state
Outside fullscreen the pane sits inline above the prompt with a few rows,
so the bordered cards, rule and footer were cut off (Refresh/Close never
visible). Inline (or under 18 rows) the pane is now compact: views and
actions share the top row, no borders/rule/gaps, pictures sized to the
rows left, radar readings beside a small fan. The top row always says
whether the pane holds the keyboard ("keys active · Esc returns them" or
"click the pane or press ctrl+x tab to use the keys").

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:32:12 -04:00
ruv 78a1474057 fix(ruview-live): pane hotkeys need the keyboard — ask for focus, say how
Button hotkeys reach a Pane only while it holds the keyboard; the pane
opened without it, so 1/2/3/r/c went to the prompt. Open (and
/ruview waterfall|radar) now request `focus: true`; closeOnEscape is
dropped so Escape hands the keys back without closing; an unfocused pane
shows "click the pane or press ctrl+x tab to use the keys".

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:27:52 -04:00
ruv 751b7ae17b feat(ruview-live): showcase views — CSI waterfall, radar fan, animated cells (ADR-378)
The /ruview pane gains three views (keys 1/2/3) drawn in terminal Rasters
and animated in place with $.ui.blit (~12 fps, no redraws):

- CSI waterfall: per-subcarrier amplitude over time from the new
  `esp32 --spectrum` capture, two frames per row via the half block,
  5-95th percentile colour limits, replayed at the frames' arrival rate,
  labelled MEASURED or SYNTHETIC from the packets' own flag; n cycles nodes.
- Radar: a 120-degree range fan with rings, a fading distance trail and a
  sonar ping to the measured range; braille heart/breathing/distance charts
  scaled to the data; a heart and breathing gauge that pulse at the
  device-reported rates ("a metronome, not a waveform").
- A shimmer rule under the title; surfaces without Raster get a note.

Harness: captureEsp32 / ruview_esp32_capture take spectrum,
spectrum_bins (8-128) and spectrum_frames (8-256): a bounded ring of
binned amplitude frames per node, dominant shape, rate and synthetic flag.

The mod is split into model/raster/anim/views/register (own-file imports
only). Tests: 11 showcase + 11 mod Node tests, a --spectrum loopback test,
4 engine tests (waterfall tab sends --spectrum, draws the Raster, blits);
npm test 189 pass, test:security 90 pass, manifest verified, validate passes.

Evidence: MEASURED on an ESP32-C6 (COM11, release c6-adr110 v0.7.0, node 6)
streaming 7-18 Hz CSI to this host; a 1x256 capture renders the occupied
band against guard/null tones. Unpacked budget 384 -> 448 KiB (+~37 KB).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:22:40 -04:00
ruv c4d7ef8086 feat(ruview-live): redesigned pane — LIVE badge, cards, trends, plausibility flags
- Header: RuView + LIVE/STALE badge (fresh within 2 refresh periods),
  "updated <time> (Ns ago)", a 5 s redraw tick keeps the age current.
- Bordered cards for the radar and the CSI nodes, side by side from 100
  columns, stacked below.
- Radar: PRESENCE DETECTED (+ target count) in green; distance, heart and
  breathing with per-refresh sparklines; device-reported vitals outside
  resting ranges (heart 40-180, breathing 4-40 bpm) are flagged in yellow
  instead of shown as fact (the live kit reported breathing 1.0 bpm).
- Nodes: per-node rate sparkline; "no packets" is a short hint in the card
  rather than a long top alert (status no longer counts it as an alert);
  an unconfigured radar says how to configure it.
- Buttons show their hotkeys.

Tests: 11 Node unit tests, 3 engine tests (mounted pane validates on the
terminal surface), validate passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:50:09 -04:00
ruv bd7c506076 fix(ruview-live): a reloaded pane waited forever for its first capture
A hot reload (or a resumed session) re-runs register with fresh variables
while the engine keeps the pane open, so no timer ran and the pane stayed
on "waiting for the first capture". Drawing the pane now resumes polling
when no timer is running. Engine test mounts the pane with no /ruview in
the module instance: fails without the fix, passes with it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:44:00 -04:00
ruv 8e12606c6a fix(ruview-live): pane showed "updated Invalid Date"
$.clock.now() resolves a promise of epoch milliseconds; refresh passed the
promise into the model. Await it, and have the pane print "—" for any
non-finite timestamp. Seen live in Claude Code 2.1.287 (/ruview pane).
Regression tests fail without the fix (2/2) and pass with it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:40:59 -04:00
ruv 8b857e41cd docs: README and user guide for @ruvnet/ruview 0.9.0 (devices, radar, MCP/ChatGPT, Claude Code mod)
- README MetaHarness section pinned to 0.9.0 with the hardware, MCP-over-HTTP,
  mod, Homecore and kernel commands; plugin section lists the ruview-live mod.
- User guide: new "RuView npm toolkit" section (hardware CLI, alert meanings,
  ESPHome kits, MCP/ChatGPT connection, the /ruview mod, Homecore + kernel),
  TOC entry, 0.5.0 pins updated.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:26:40 -04:00
ruv 093e3bbeae feat(ruview): ruview-live Claude Code mod shipped in the npm package (ADR-377)
- mod/: a Claude Code mod (function hooks). /ruview opens a pane beside
  the transcript with CSI nodes (rate, loss, RSSI, shape, SYNTHETIC) and an
  optional ESPHome radar (presence, distance, device-reported vitals),
  alerts (heartbeat-only boards + buffer-starvation hint), Refresh/Close
  buttons and a one-line status; refreshed on $.clock.every.
- Reads only through the harness CLI beside it (fixed read-only argv,
  --json) located via $.plugin.root (mods may import nothing else).
- Packaged in @ruvnet/ruview (mod/.claude-plugin, mod/hooks), listed in the
  repo marketplace as ruview-live@ruview; `ruview mod` prints how to load it.
- @ruvnet/ruview 0.9.0; umbrella ruview 0.9.0 (still unpublished).

Verified: claude plugin validate (mod + marketplace); claude plugin test
2/2 x3 runs; harness 172 tests + 89 security; real Claude Code headless
`/ruview refresh` from an installed tarball and with the live radar kit.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:12:53 -04:00
ruv 4128661789 docs(adr-376): record release outcome — components published, unscoped ruview refused by npm (403, similar to iview)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 16:45:20 -04:00
ruv b6f3abd35e feat(npm): ruview umbrella package + ESPHome radar source (ADR-376, ADR-373 A3)
- harness/ruview-umbrella -> npm `ruview` 0.8.0: one CLI and one MCP server
  for the operator harness, Homecore and the vitals kernel. `ruview homecore
  ...`, `ruview kernel <doctor|info|selftest|parity|synth|analyze|bench>`,
  `ruview capabilities`; everything else delegates to @ruvnet/ruview. MCP
  serves ruview_* + homecore_* tools and the ui:// console (stdio + HTTP).
- @ruvnet/ruview 0.8.0: bin renamed `ruview-harness` (the umbrella owns
  `ruview`; a clean tarball install showed the bin collision). New exports
  (cli, cli-ui, mcp, mcp-http, ui, esphome, kernel); injectable MCP handler;
  kernel as optional peer + setKernelImporter so the kernel resolves from
  the umbrella's tree under linked/pnpm installs.
- mmwave --source esphome: dependency-free read-only ESPHome native API
  client (private hosts only; encrypted/password APIs reported). Live Seeed
  MR60BHA2 kit: presence, distance, device-reported HR/BR (MEASURED).
- monitor detects Ameba CSI buffer starvation; radar views in terminal UI
  and console widget.
- homecore 0.1.0 and @ruvnet/ruview-kernel 0.1.0 gain ./cli exports.

Gates: harness 166 + 89 security, homecore 27 + 23, kernel 21 + 12,
umbrella 4; clean-dir tarball install smoke incl. live radar over HTTP MCP.
Local publish is a one-off exception to ADR-265 D2, recorded in ADR-376.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 16:30:23 -04:00
ruv 45796e4e3e feat(ruview): MCP Apps console widget, HTTP transport, terminal UI (ADR-375)
- ui://ruview/console-v1.html: one self-contained widget for MCP Apps hosts
  and ChatGPT (ui.resourceUri + openai/outputTemplate). Renders node
  streams, device scans and doctor results; Refresh re-runs the tool via
  window.openai.callTool or an MCP Apps tools/call. Empty CSP allowlist, no
  network, textContent only, parent-frame messages only.
- MCP: protocol negotiation (2025-06-18/2025-03-26/2024-11-05),
  structuredContent + compact JSON text (17-33% smaller, MEASURED),
  resources/list|read, tool titles, canonical tool name in result _meta.
  Transport-neutral handleRpc + shared FIFO dispatcher.
- `mcp start --http`: Streamable HTTP for ChatGPT/remote clients. Bearer or
  secret-path token (constant-time), loopback default, Origin allowlist,
  256 KiB cap, no batches, write grants never honoured over HTTP.
- CLI: formatted TTY output (tables, rate bars, alerts first, NO_COLOR),
  JSON for pipes/--json, capture countdown, `esp32 --watch` with per-node
  sparklines, lazy module loading (--help ~20% faster, MEASURED).
- monitor detects Ameba CSI report-buffer starvation ("lack of csi buf"),
  the root cause of the Realtek heartbeat-only stall seen live.
- Size budget 320 -> 384 KiB (package ~339 kB).

Verified: 160 tests + 83 security tests; headless-Chrome e2e of the MCP
Apps flow against live boards (refresh round trip 108 -> 105 packets);
HTTP security probes; clean tarball smoke (stdio + HTTP).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:52:26 -04:00
ruv 3cd360f9e2 fix(ruview): CSI loss survives stray and reordered sequence numbers
Live RTL8721Dx capture (node 3, COM10) reported 97% loss: one stray
sequence value (+52,835, then straight back) was counted as 52,834 lost
frames, and every late UDP packet re-counted its gap. The tracker now
treats a single out-of-window value as a stray, needs two agreeing frames
to accept a counter reset, and lets a late packet fill its own gap
(seqReordered / seqStrays / seqResyncs in the summary). Against ground
truth on the live board (unique sequence numbers over the span): 4,783 of
4,783 present, loss 0, 9 strays; the tracker matched both.

ADR-373 Amendment 2 records the live Realtek run: devices -> realtek,
7,404/7,404 packets decoded, monitor at 1500000 baud without a reset, and
12,549 live frames analyzed by the WASM kernel.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:20:21 -04:00
Claudeandclaude-flow d31d9daba2 fix(nightly-sota): ignore stdin EPIPE from children that never read input
runProcess() ends the child's stdin with no error handler. A child that
never reads stdin (git check-ignore) can exit and close the pipe before the
write flushes; the unhandled EPIPE then crashes `agent.mjs validate`. This
failed harness/ruview (node 22) on PR #2062 and reproduces 12/12 locally
under CPU load (0/12 after this change). The command's real outcome is
still reported by the 'close' handler; this mirrors the existing handler in
harness/ruview/src/process-runner.js.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-10-01 19:07:40 +00:00
ruv b25729acd6 fix(ruview): boot-log capture aborted on non-ASCII firmware logs on Windows
The flash flow's boot-log capture decoded serial lines and wrote text to a
stdout that the scrubbed child env leaves on cp1252. The ESP32-C6 release
firmware logs U+2192, so the capture died mid-boot (bootLog.captured=false);
an earlier abort would have hidden the CSI evidence and reported a good
flash as unvalidated. Write raw bytes instead. Regression test forces
PYTHONIOENCODING=cp1252 through a stand-in serial module (fails without
the fix). ADR-373 Amendment 2 records the real C6 flash evidence.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:02:09 -04:00
ruv 8d2be98ed7 feat(ruview): Realtek RAC1 nodes, honest capture, live CSI analysis (ADR-373 Amendment 2)
Findings from a hardware run of this branch against an RTL8721Dx board
(PL2303GC) and an ESP32-C6 (CP210x) on Windows 11:

- esp32 capture decodes Realtek RAC1 (ADR-323, CRC-verified, 8/16-bit
  tones) and RHB1 heartbeats, ADR-110 sync and ADR-081 mesh packets, and
  reports every CSI shape a node sends.
- Capture fails (no_decodable_packets / heartbeat_only) instead of
  returning ok:true + MEASURED when nothing decodes; names
  heartbeat-only senders.
- --analyze runs live single-antenna CSI through @ruvnet/ruview-kernel at
  the measured frame rate with a bounded buffer.
- monitor opens the port with DTR/RTS deasserted (no node reboot), takes
  --baud, and explains the C6/S3 USB-Serial/JTAG console.
- devices classifies Prolific 067B:23A3 as a Realtek board.
- Windows: owner-only ACL on the hosts file (fails closed); symlink test
  skips only where Windows refuses symlinks.
- kernel build names uninitialized submodules when cargo fails.
- Parser ~3.2x faster (MEASURED, 0.42-0.52M -> 1.33-1.67M packets/s).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 14:40:32 -04:00
Claudeandclaude-flow 43f16e5638 perf(npm): binary kernel transport, faster device e2e, and CI fixes (ADR-368/373 amendments)
CI fixes (both failures on PR #2062 were in this branch):
- harness/ruview: remove `2.11.0` literals from training remedies; the
  ADR-265 D3 version-literal gate rejects them.
- ruview-kernel workflow: install wasm32 for the toolchain pinned by
  v2/rust-toolchain (the action only added it to `stable`); build.mjs now
  fails fast with the exact `rustup target add` remedy.

Kernel optimization (ADR-368 amendment):
- New `analyze_flat` / `session_push_flat` ops via `call_f64`: WASM
  `rvk_call_f64` (alignment-agnostic bytes) and napi `callF64(Float64Array)`.
  `analyze()`/`session.push()` pack uniform frames automatically; irregular
  input keeps the JSON path and its per-frame errors. `analyzeFlat()` is the
  zero-copy entry. Shared `Session::step` makes both paths byte-identical on
  identical f64 inputs (Rust test), and the binary path avoids serde_json's
  non-correctly-rounded float parsing.
- MEASURED (`ruview-kernel bench --seconds 120`, one Linux x64 host):
  2.82x (wasm) and 2.45x (napi) faster than the JSON transport.
- Profiled remaining cost (examples/profile_stages.rs): heart-rate ACF in
  wifi-densepose-vitals dominates; left for a separate, output-reviewed
  change. Rejected WASM +simd128 and opt-level=s after three measured runs
  (bench/wasm-variants.mjs).

Device e2e (ADR-373 amendment):
- test/e2e/devices.e2e.mjs + `npm run test:e2e:devices` + new workflow
  ruview-device-e2e.yml: pty-emulated MR60BHA2 and RPLIDAR through real
  pyserial, a UDP ESP32 node, and the real iPhone relay, via the CLI.
- mmWave auto-detect keeps probe frames and reads only the remainder;
  iPhone LiDAR gains max_frames and returns immediately when refused.
  Suite wall time 19.6 s -> 5.6 s (MEASURED, dev container).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-10-01 14:45:55 +00:00
Claudeandclaude-flow 6112a185df feat(npm): host device + SSH remote access — ESP32, 60/24 GHz mmWave, LiDAR (ADR-373/374)
@ruvnet/ruview 0.7.0 gives CLI, MCP, and SDK access to RuView sensing
hardware on the machine it is attached to, and to other machines over SSH.

Devices (ADR-373, src/devices/):
- ruview_devices_scan: pyserial port list classified by USB VID:PID
  (Espressif native USB, CP210x, CH34x, FTDI) into ESP32 / mmWave / RPLIDAR
  roles with the command that confirms each.
- ruview_esp32_capture: receive-only UDP listener parsing ADR-018 CSI
  frames, vitals, and the other 0xC511000x packets per node: rate,
  sequence loss, RSSI, subcarriers, frequency, device vitals.
- ruview_mmwave_read: MR60BHA2 (60 GHz) and LD2410 (24 GHz) parsers ported
  byte-for-byte from firmware mmwave_sensor.c; baud/frame auto-detect.
- ruview_lidar_read: RPLIDAR SCAN over serial (start/stop, DTR best-effort)
  and the iPhone LiDAR relay as a WebSocket client (token only via
  RUVIEW_LIDAR_TOKEN; statistics only, never raw depth).
- Dependency-free serial via a fixed pyserial pump (argv-only, bounded,
  hex output immune to secret redaction). Schema-level port pattern
  validation added to the policy validator.
- All device tools require the new `device-access` MCP grant.

Remote hosts (ADR-374, src/remote.js):
- ~/.config/ruview/hosts.json (0600), CLI-only `hosts add`; strict
  name/target validation against ssh option and shell injection.
- `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -- <target>` running a
  version-pinned `ruview call <tool> --read-only`, every token single-quoted;
  read-only enforced locally and remotely; no mutations, credentialed reads,
  or host hopping. MCP needs `remote-host` plus the remote tool's grant.

Also: device-access skill, doctor `devices`/`remote` groups, SDK
`devices.*`/`hosts.*` with types, generic `ruview call` verb, guidance
capability, 320 KiB package budget, CI smoke expects 22 tools.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 17:01:15 +00:00
Claudeandclaude-flow 888222277e feat(npm): RuView operator surface — SDK, cross-platform flashing, training, doctor (ADR-369..372)
@ruvnet/ruview 0.6.0 turns the harness into a complete npm operator
surface: one policy-checked tool registry behind the CLI, the MCP server,
and a new typed SDK.

- SDK (ADR-369): `@ruvnet/ruview/sdk` createRuView() with doctor,
  firmware.{ports,plan,flash,monitor}, training.{plan,run,gate,calibrate},
  kernel.{selfTest,load}, guidance/claimCheck/verify/spaces; strict mode
  throws RuViewError; TypeScript declarations in src/sdk.d.ts.
- Firmware (ADR-370): cross-platform `python -m esptool` flashing replaces
  the Windows-only stub. SHA256SUMS verification (mismatch always fatal),
  bundle containment, variant/partition checks, argv-only command, chip_id
  preflight that refuses a mismatched chip, boot-log capture;
  hardwareValidated only when CSI callbacks appear without a panic.
  Read-only ruview_firmware_plan / ruview_firmware_ports.
- Training (ADR-371): ruview_train / ruview_train_plan run pose-smoke,
  pose (MM-Fi), or room training from the trusted checkout with in-repo
  paths only, logs/checkpoints under v2/target/ruview-train, and libtorch
  remedies. ruview_train_gate enforces mean-pose baseline, leakage-free
  splits, subject/time disjointness, and evidence tagging.
- Doctor (ADR-372): structured pass/warn/fail/skip checks with remedies
  across runtime, integrity, hosts, repo, Rust, Python/esptool, firmware
  bundles, serial, sensing server, and the compute kernel; --json/--group;
  hardware probe and network checks are CLI/SDK-only.
- Plan tools cannot take `confirm`; acting tools keep grant+confirm over
  MCP. Policy files and TOOL_POLICY are drift-tested.
- Unpacked budget 160 KiB → 224 KiB in both npm workflows; CI tarball smoke
  now checks the SDK and the training gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 16:47:30 +00:00
Claudeandclaude-flow 1938f0251f feat(npm): @ruvnet/ruview-kernel — WASM + napi-rs compute kernel with metaharness e2e (ADR-368)
Adds a portable RuView compute kernel for JavaScript, built from the Rust
source of truth and wired into the @ruvnet/ruview metaharness.

Rust
- v2/crates/ruview-kernel: bounded single-function JSON ABI
  (call(op, json) -> envelope) over the ADR-021 wifi-densepose-vitals
  pipeline: streaming sessions, batch analyze, deterministic SplitMix64
  synthetic CSI (SYNTHETIC), bounded session registry, strict input
  validation (deny_unknown_fields, width/finite/magnitude checks, size and
  count limits). wasm32-only zero-import C ABI (rvk_alloc/free/call).
- v2/crates/ruview-kernel-napi: napi-rs transport exposing only
  abiVersion()/call(); standalone workspace (excluded from v2) so napi never
  enters `cargo test --workspace`; panic=unwind + catch_unwind.

npm (harness/ruview-kernel, zero runtime deps)
- loadKernel({backend: wasm|napi|auto}); WASM default (no host authority),
  napi never silently falls back, auto reports fallbackReason.
- SHA256SUMS integrity verification; mismatch is always fatal; loader
  rejects any WASM module that imports host functions.
- Browser-safe ./wasm entry; CLI doctor/info/selftest/parity/synth/
  analyze/bench; build.mjs builds artifacts from source (no shell).
- Unit, security, and e2e tests (CLI round trip, cross-backend parity).

Metaharness
- New read-only tool ruview_kernel_selftest / `ruview kernel`: imports the
  fixed optional package, fails closed with kernel_not_installed; policy,
  claims, MCP policy, guidance capability, and manifest updated.

CI: .github/workflows/ruview-kernel-npm.yml builds both transports on
Linux/macOS/Windows, requires napi in tests, runs parity, verifies
artifacts, packs, and runs the cross-package e2e. No publish step.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 16:23:29 +00:00
rUv b90b592d4f fix(vitals): reject respiratory peaks and cap filtered confidence (#2061) 2026-09-30 10:20:41 -04:00
rUv e1fbbede61 fix(sensing): exclude stale nodes from room occupancy (#2060) 2026-09-30 09:25:20 -04:00
rUv aac4155584 feat(bfi): add validated capture and bounded research tooling (#2055)
* feat(bfi): add validated capture and bounded research tooling

* fix(deps): patch Wasmtime for RUSTSEC-2026-0316
2026-09-29 23:53:48 -04:00
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
rUv de27336fa1 Merge pull request #1588 from ruvnet/fix/issue-triage-batch-1
fix: issue triage batch (#1521-1557) + wire the certificate spine together
2026-08-11 19:37:31 -04:00
ruv 73e82313ac fix: issue triage batch (#1521-1557) + wire the certificate spine together
Reviews and fixes RuView's 10 most recent substantive issues, plus closes
the wiring gap flagged in the perception-substrate review (docs/adr,
gist, release notes from the PR #1579 review).

## Fixed

- #1526: UI falsely claimed "LIVE — ESP32 Hardware Connected" whenever the
  unauthenticated /api/v1/status probe 401'd. Now sends the bearer token
  on the probe (the fallback direction was already fixed by ADR-295).
- #1554: top-level `classification` (GET /api/v1/sensing/latest) was the
  last UDP packet's single node, not the fused room aggregate, so it
  flapped at packet rate with 2+ disagreeing nodes. Now derived from
  `RoomInference` (ADR-297's `fuse_room`) at both call sites.
- #1541: per-node MQTT `presence`/`presence_score` read a `classification`
  JSON key that does not exist on `NodeInfo` (the real field is
  `node_inference`), silently falling back to the room aggregate for
  every node — reproducing the exact "lockstep publish" the field report
  measured. Also fixed the *existing* regression test for this bug,
  which used the same wrong key in its own fixture and so never caught it.
- #1557: pose-fusion's `wsPortMap` only knew port 3000, so a remapped host
  port fell through to `localhost:8765` (nothing there for a remote
  viewer). WS port is now derived from `location.port` instead of a
  2-entry lookup table (the "never render simulated as live" half was
  already fixed by ADR-295's `onVerifiedFrame` gate).
- #1525: the no-model pose path already clamps keypoint confidence to a
  0.1 floor, but the renderer's own threshold is also 0.1 compared with
  `<=`/`>` — a keypoint at exactly the floor was still invisible. Floor
  raised to 0.15 to clear the client's gate.
- #1556: `--mqtt-ca-file`/`--mqtt-client-cert`/`--mqtt-client-key` were
  parsed and stored but never applied — TLS always used the system trust
  store, so a self-signed broker always failed UnknownIssuer. Now builds
  `rumqttc::TlsConfiguration::Simple` from the real PEM files (no new TLS
  dependency needed). A file that can't be read logs why and falls back
  to system trust instead of failing opaquely later.
- #1555: the MQTT availability heartbeat asserted "online" for every known
  node on a fixed 30s timer regardless of whether that node's data was
  still arriving. Now tracks each node's last-seen broadcast snapshot and
  only reports "online" within a 10s freshness window, otherwise
  "offline" — a frozen sensor can no longer look available. (The other
  half — restarting a publisher that goes permanently silent — needs a
  reproduction the reporter themselves weren't certain of; left for a
  follow-up rather than guessing at the trigger.)
- #1540: already fixed on main (node-keyed RateLimiter, ADR-297).
- #1521/#1522: not fixable in this repo (published HF model artifact);
  replied with the ADR-298 gate status and the exact byte-level fix for
  the safetensors header, and corrected the README row that claimed the
  file loads with the reference loader.
- #1542, #1527: replied — #1542 is a real, larger firmware+server feature
  left open for follow-up; #1527's suggested fixes were already applied,
  the one residual sample is an inherent first-frame paint gap.

## Certificate spine wiring (closes the gap flagged in the PR #1579 review)

`ruview-certify` and `ruview-policy` now depend on `ruview-ood` and provide
real `From<ruview_ood::DomainState>` adapters plus a composed entry point,
`ruview_policy::authorize_from_certificate`, matching the adapter contract
`ruview-policy`'s own doc comment already described but that no code
actually implemented. A new cross-crate integration test
(`acceptance_test_b_real_integration`) mints a real signed
`CapabilityCertificate` and proves a real post-drift `ruview_ood::Unknown`
denies a `SafetyCritical` action through the composed pipeline — not two
disconnected unit tests hand-setting the same enum value.

Also:
- Wires `evaluate_linear_head` (ADR-298 model-release gate) into a new CI
  job so the checker itself can't silently regress; documents that gating
  an actual model publish is still a manual step (no HF automation here).
- Wires `SourceState::export_watermark()` into `start_recording`: a
  recording captured while the source is synthetic is now stamped in its
  metadata (not the filename or per-line JSON, to avoid breaking
  `delete_recording`'s path reconstruction or the training dataset
  loader's schema).
- Updates docs/user-guide.md's "Developer Preview" section to describe
  what's now genuinely wired vs. still not (no live continuous
  calibration/OOD loop in the running server yet).

## Validation

- cargo test --workspace --no-default-features: 4391 passed, 0 failed
- cargo build --release -p wifi-densepose-sensing-server --features mqtt:
  clean
- Server smoke-tested end-to-end against the simulator (real startup,
  UDP/WS/HTTP listeners, /api/v1/sensing/latest stable across calls)
- Real ESP32-S3 hardware was NOT reachable this session (no COM port
  present, zero UDP frames received after 40s bound to 0.0.0.0:5005) —
  the multi-node fixes are validated by the new unit/integration tests
  and full-workspace regression, not by live hardware.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-08-11 19:08:54 -04:00
rUv bf17fc0407 Merge pull request #1579 from ruvnet/claude/adr-288-290-sota-gaps
ADR-288/289/290/291: benchmark harness, wideband CSI ingest, vitals ground-truth rig, wifi-veil integration
2026-08-11 13:24:42 -04:00
ruv ba978041ae Merge remote-tracking branch 'origin/main' into resolve-1579-conflict
# Conflicts:
#	docs/adr/README.md
2026-08-11 13:04:00 -04:00
rUv 90c6ecc530 Merge pull request #1561 from ruvnet/claude/privacy-shield-wifi-sensing-i8vz1b
WiFi Veil — compliant-waveform privacy shield: reference crate, E2E firmware, standalone repo + CI honesty guard
2026-08-11 12:56:26 -04:00
ruv 5aa204a168 docs(user-guide): add Perception Certificate Spine developer-preview section (ADR-297)
Documents the 9 new spine crates (calibrate -> certify -> govern) as a
composable library, explicit about current status: each crate is tested in
isolation but not yet wired together or into the live sensing-server
request path (DomainState is 3 separate enum types across
ruview-ood/certify/policy with no automatic bridge). Contrasts against the
two ADR-292-296 remediation items that ARE genuinely enforced today: UDP
source allowlisting (checked on every packet) and the CSI data-policy CI
guard.
2026-08-11 12:46:17 -04:00
Claudeandclaude-flow e46fcc6862 feat: implement ADR-297 phase-3 — RF twin, placement, spatial memory, counterfactual, info-gain, active sensing
The higher-ceiling primitives on the fused world state. Six crates, all
deterministic SYNTHETIC/L0 model scaffolds (a twin predicts, it never measures);
70 tests + 6 doctests, verified green independently.

ruview-twin (ADR-312): per-deployment RF twin — radio geometry, a documented
synthetic log-distance + wall-attenuation propagation model, per-link expected
distributions, and the load-bearing delta(observed,expected) that localizes a
physical change (moved node / new reflector) to specific links. 8 tests.

ruview-infogain (ADR-311): Value(sensor) = expected uncertainty reduction /
weighted cost; pure bounded-greedy selection under a multi-dimension budget;
unknown-value candidates handled explicitly (defer/probe, never silent zero). 15.

ruview-active (ADR-306): closed-loop control vocabulary (channel/bandwidth/
cadence/antenna as validated ranges); step() proposes the next measurement to
reduce uncertainty, widening exploration when the last response is UNKNOWN;
emits a plan, never RF. 13.

ruview-placement (ADR-305): floorplan + inventory -> ranked placement via the
twin's propagation model; blind-spot flags; predicted-vs-observed adjustment. 11.

ruview-memory (ADR-309): learns per-zone normal physics; anomalies are
significant deltas vs baseline emitted as evidence records; UNKNOWN before a
baseline exists (no false positives). 14.

ruview-counterfactual (ADR-310): scores hypotheses under the twin — empty-room
vs occupied, one person vs two; UNKNOWN when indistinguishable. 8.

Flips ADR-305/306/309/310/311/312 to implemented. Completes all three phases of
the ADR-297 perception-substrate program. No hardware/MEASURED claims.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 13:13:04 +00:00
Claudeandclaude-flow 49c594822f feat: implement ADR-297 phase-2 world-model core — HAL, ground-truth, tracking, fusion
The layer that turns the certificate spine into a modality-agnostic perception
substrate. Four crates, all deterministic and green independently (43 tests).

ruview-hal (ADR-317): one abstraction mapping any modality (CSI/802.11bf/BLE/
UWB/mmWave/acoustic/camera/lidar/IMU/custom) to a canonical ontology Observation.
SensorHal trait + two SYNTHETIC/L0 reference adapters; malformed input yields a
degraded UNKNOWN observation, never a panic; synthetic can never alias measured.
8 tests.

ruview-groundtruth (ADR-300): reference sensors as a formal VALIDATION plane
(never an estimator input, enforced by the type boundary); modality-agnostic
ReferenceSeries, deterministic cross-correlation alignment, AgreementReport with
mandatory SessionScope, emitting per-context ruview-evidence records; Measured
requires reference + coverage + reproducer. 15 tests.

ruview-track (ADR-304): privacy-preserving persistent tracks (opaque person ids,
coarse non-reversible features, no civil-identity binding); ambiguous detections
stay tentative rather than misassigned; cross-zone hand-off. 8 tests.

ruview-fusion (ADR-308): multiple HalObservations -> one probabilistic WorldState,
uncertainty-aware (confidence-weighted, not naive averaging); irreconcilable
conflict or insufficient coverage yields UNKNOWN, not a confident average.
9+ tests incl. irreconcilable_conflict_yields_unknown.

Flips ADR-300/304/308/317 to implemented; registers the four crates as workspace
members. SYNTHETIC/L0 throughout; no hardware/MEASURED claims.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 03:16:33 +00:00
Claudeandclaude-flow 516331461a feat: implement ADR-297 phase-1 dependent wave — OOD, witness, certify, scorecard, policy
Completes the phase-1 certificate spine; both acceptance tests now pass as code.

ruview-ood (ADR-299): domain-distance vs the certified fingerprint and a pure
DomainState KNOWN/DEGRADED/UNKNOWN classifier implementing the ADR-297
VALID->DEGRADED->UNKNOWN staleness guard; InferenceGate suppresses the class
(UNKNOWN as a first-class value) when domain is not KNOWN; RecalibrationRequest
signalled on DEGRADED/UNKNOWN. 25 tests.

ruview-witness (ADR-316): ordered, append-only, BLAKE3 hash-linked stage chain
(observation->DSP->inference->corroboration->spatial->policy) rooted in an
attest VerifiedMeasurement; verify() catches mutation/reorder/dropped/broken
links; effective level is the minimum across stages. 19 tests.

ruview-certify (ADR-315): signed CapabilityCertificate minted from a single-
context evidence slice (never pooled), evidence level capped at the slice floor,
valid_until bounded by calibration validity; is_valid(now, domain) returns false
when expired OR domain != KNOWN (certificate conditional on the live domain
signature). 17 tests incl. non_known_domain_invalidates.

ruview-scorecard (ADR-314): multi-domain scorecard with per-domain CIs,
worst_domain(), and a promotion gate that fails when only pooled average
improved while a worst-domain slice regressed. 17 tests.

ruview-policy (ADR-318): fail-closed action gate; Convenience/Security/
SafetyCritical assurance classes; authorize() denies with a named failed
condition; UNKNOWN denies high-assurance actions. Includes
acceptance_test_b_post_drift_unknown_denies_safety_critical. 10 tests.

All five verified green independently (88 tests). Registers the five crates as
workspace members. SYNTHETIC/L0 reference crypto; no hardware claims.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 02:06:31 +00:00
Claudeandclaude-flow 6506438b83 feat: implement ADR-297 phase-1 spine roots — ontology, attest, evidence, calibration cert
Four foundational perception-substrate crates (dependency roots of the
certificate spine), each pure/leaf and deterministically tested:

ruview-ontology (ADR-303): canonical Site>Building>Floor>Space>Zone +
Sensor/Person/Object/Observation/Track/Event, typed serde-transparent ids,
WorldGraph registry with dangling-parent/duplicate rejection and containment
resolution, EvidenceLevel L0-L5, and a docs-only migration table for the
per-surface shapes. 8 tests + doctest.

ruview-attest (ADR-302): DeviceId + SignedMeasurement envelope binding
{device, monotonic sequence, timestamp, payload hash, calibration ref};
AttestationVerifier enforces unknown-device, signature, tamper, strict
per-device sequence (replay), and freshness; Signer/Verifier traits with a
blake3-keyed SYNTHETIC-grade reference MAC (Ed25519 is a drop-in). 14 tests.

ruview-evidence (ADR-301): append-only per-(room,device,subject,model) ledger;
immutable records with provenance-gated constructors (synthetic forces L0,
measured needs a reproducer); summarize() never pools across contexts; a slice
reports the floor evidence level, never above its weakest record. 8 tests.

wifi-densepose-calibration (ADR-298): CalibrationCertificate — signed, versioned
room fingerprint minted from existing calibration/bank state, compare/drift, and
invalidate-on-drift/expiry. 78 tests green.

Registers the three new crates as workspace members. All four verified green
independently. SYNTHETIC/L0 reference crypto; no hardware claims.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:50:28 +00:00
Claudeandclaude-flow 34c9804002 feat: implement ADR-292/293/294/295/296 — provenance, UDP hardening, multi-node, model gates, CSI policy
ADR-292 (sensing-server): SourceState enum + pure transition (provenance.rs);
auth-error/unknown can never resolve to LiveVerified; synthetic exports
watermarked. Pose-fusion simulator starts SYNTHETIC and only shows LIVE on a
real decoded frame (#1557); sensing client no longer labels an unauthorized
status endpoint as live (#1526).
ADR-294 (sensing-server): NodeInference distinct from RoomInference (inference.rs);
deterministic freshness-weighted fuse_room; RateLimiter re-keyed to
(NodeId,EntityKind) so nodes do not starve each other (#1541); stale nodes go
unavailable not frozen-online (#1555).
ADR-293 (sensing-server): --udp-bind (default 127.0.0.1) + --udp-allow allowlist
+ fail-closed refusal of routable bind without allowlist unless --udp-insecure-lan
(udp_bind.rs); crate SECURITY.md documents the threat model and the deferred
per-device-auth step two.
ADR-295 (train): model_gates.rs — constant-output, unreachable-boundary (the
issue-1521 degenerate presence head), class-balance, baseline, and
metric-name-provenance gates.
ADR-296 (ci): scripts/csi-data-policy-check.sh (+ allowlist) and a workflow that
fails on tracked CSI-format/oversized-JSONL files; 6/6 self-tests pass.

Per-crate suites reported green by the swarm; CSI policy self-test 6/6 and JS
syntax verified here. Full workspace re-verification deferred until the
concurrent phase-1 spine build frees the target dir (disk pressure).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:47:29 +00:00
Claudeandclaude-flow 8bb55aac05 docs: ADR-318 decision policy + amend ADR-297 with epistemic-reliability invariants
Elevates decision policy (action authorization) into phase 1 (ADR-318): a
fail-closed gate that authorizes a governed action only when the capability
certificate class, freshness, uncertainty ceiling, evidence floor, and live
domain state (KNOWN, not DEGRADED/UNKNOWN) all satisfy the action class —
convenience vs security vs safety-critical. UNKNOWN denies high-assurance
actions; every decision is the terminal witness-chain stage.

Amends ADR-297 with: the epistemic-reliability pipeline as the product thesis;
four non-negotiable rules (UNKNOWN first-class; certificates bind
cryptographically; one canonical downstream semantics; benchmarks expose
worst-domain + CIs); the certificate-staleness guard (certificates conditional
on a continuously evaluated domain signature; VALID->DEGRADED->UNKNOWN
auto-degrade triggers recalibration); the three-primitive commercial framing
(Runtime / Certify / Trust-Fleet); and acceptance test B (drift invalidation
before a false inference reaches an actuator).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:36:05 +00:00
Claudeandclaude-flow 559ad56aa4 docs: ADR-298..317 — 20 child ADRs of the perception-substrate program
Phase 1 (certificate spine, initial implementation planned): ADR-298 calibration
certificate, ADR-299 OOD KNOWN/DEGRADED/UNKNOWN gating, ADR-301 evidence engine,
ADR-302 authenticated sensor identity, ADR-303 canonical spatial ontology,
ADR-314 multi-domain benchmark scorecard, ADR-315 capability certificates,
ADR-316 witness chain.
Phase 2 (Proposed): ADR-300 ground truth, ADR-304 tracking, ADR-307 802.11bf-native,
ADR-308 fusion, ADR-313 fleet, ADR-317 HAL.
Phase 3 (Proposed): ADR-305 placement, ADR-306 active sensing, ADR-309 spatial
memory, ADR-310 counterfactual, ADR-311 info-gain, ADR-312 RF twin.

Each references ADR-297 and cross-references its dependencies; phase-1 ADRs carry
implementation intent, phase-2/3 are design-intent Proposed.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:30:00 +00:00
Claudeandclaude-flow ca1f0b9e8a docs: ADR-297 — RuView perception substrate phased program (20 primitives)
Frames the calibration/evidence/trust/deployment layer as a 20-primitive
phased program with a dependency DAG and phase assignments. Phase 1 is the
certificate spine (spatial ontology, authenticated identity, witness chain,
calibration cert, OOD gating, evidence engine, capability certificate,
multi-domain benchmark scorecard) — the acceptance test decomposed and
buildable without new hardware. Phase 2/3 are integration and research-forward
primitives. Child ADRs 298-317 follow.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:18:29 +00:00
Claudeandclaude-flow 2cafa1fdcc docs: ADR-292..296 — remediation ADRs from Aug-2026 external review
Turns the review's code-implementable P0/P1 items into ADRs: source
provenance state machine (synthetic never presents as live), UDP data-plane
bind hardening (loopback default + allowlist, step one), multi-node semantic
correctness (per-node inference, node-keyed rate limiter, stale state), model
release sanity gates (block degenerate/mislabeled heads), and CSI data-incident
repo controls. Also fixes the stale .gitignore rule so the active recordings
directories and CSI globs are covered going forward.

Tree removal of existing recordings, history rewrite, and withdrawal of the
published presence head are gated on maintainer sign-off (outward-facing /
destructive) and intentionally not done here.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:09:12 +00:00
Claudeandclaude-flow 79d1fff99a feat: implement ADR-288/289/290 — benchmark harness, wideband CSI ingest, vitals ground-truth rig
ADR-288 (wifi-densepose-train): Widar3.0 Intel-5300 .dat bfee parser
(bounded, panic-free), WidarDataset over the CsiDataset trait, deterministic
SplitProtocol (cross-subject/environment/orientation + leakage-prone random
baseline), LeakageAudit that Errs on subject/environment/recording overlap,
train-only MeanPoseBaseline, and EvidenceGrade where Measured requires an
embedded reproducer. Criterion bench for parser + split + audit.

ADR-289 (wifi-densepose-mat): FeitCSI binary record parser (layout verified
against upstream source) with dimension-vs-buffer validation and bounded
allocation, DeviceType::FeitCsi replay/stream modes, subcarrier-agnostic
frame metadata (bandwidth/band/native->pipeline mapping). Criterion bench at
1992-subcarrier frames.

ADR-290 (wifi-densepose-vitals): reference-series CSV ingest, cross-correlation
time alignment with optional drift fit, Bland-Altman/MAE/RMSE agreement with
mandatory SessionScope, and EvidenceGrade gating Measured on reference device +
coverage + reproducer. Criterion bench for hour-scale alignment.

All three crate suites green; benches compile. Numbers are SYNTHETIC (in-code
fixtures); no hardware claims.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:06:39 +00:00
Claudeandclaude-flow 01c42d0900 feat(bfld): ADR-291 — wifi-veil emission-shaping countermeasure as advisory dependency
Adds wifi-veil (pinned git rev, dependency-free, MIT/Apache-2.0) to the
workspace and gates it behind a new 'veil' feature in wifi-densepose-bfld.
The bfld::veil module exposes deterministic attacker-vs-protector shield
assessments (re-ID collapse, throughput ratio, energy-conservation audit)
with a mandatory SYNTHETIC/L0 evidence label. Advisory only: no RF
emission, no frame mutation, BFLD invariants I1-I3 untouched. Default
build unchanged; 6 new feature-gated tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-10 23:46:55 +00:00
Claudeandclaude-flow de88e37de5 docs: ADR-288/289/290 — benchmark harness, wideband CSI ingest, vitals ground-truth rig
Three ADRs closing the highest-impact gaps from the 2026 SOTA research
sweep: public-benchmark comparability (Widar3.0 ingest + leakage-guarded
split protocols), wideband 802.11ax CSI ingest (FeitCSI/AX210), and a
vitals ground-truth rig (reference ingest, alignment, Bland-Altman
agreement, evidence grading). Implementations follow in this branch.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-10 23:18:54 +00:00
ruv e737b1a7bc fix(esp32): correct mqtt component name + %u format casts; add build-verified examples
Building veil_ris_controller/veil_sensing_detector for real against ESP-IDF
v5.4 (esp32s3 target) surfaced two compile bugs, now fixed in both the
firmware/privshield/ and wifi-veil/ copies:
- veil_sensing_detector/CMakeLists.txt: PRIV_REQUIRES esp_mqtt -> mqtt
  (esp_mqtt is not a real ESP-IDF v5.4 component name; the real one is mqtt)
- veil_ris_controller.c / veil_sensing_detector.c: ESP_LOGI("%u", ...) calls
  passed a bare uint32_t; -Werror=format= requires (unsigned) casts

Also adds esp32/examples/ — minimal ESP-IDF apps wrapping each component's
public API, added purely to prove they compile+link on a real toolchain.
Still SYNTHETIC / L0, build-only — never flashed, no hardware exists.
2026-08-09 15:46:39 -02:30
ruv 50bcf0e215 fix(privshield/wifi-veil): assert data_source on the replayBundle, not the top-level result
FlywheelResult has no dataSource field — @metaharness/flywheel stamps it
as replayBundle.data_source (snake_case). This test never actually ran in
CI here (nested .github/workflows/ under wifi-veil/ is inert on GitHub);
caught only once the wifi-veil/ tree was extracted to its own repo and its
own top-level Actions ran for real.
2026-08-09 15:25:04 -02:30
Claudeandclaude-flow e2ffecde9a ci(wifi-veil): add honesty / anti-slop guard
Add scripts/ci-guard.sh and a `guard` CI job that statically enforce the
project's honesty invariants so they cannot silently regress:

- no telemetry (.claude-flow/), build artifacts, lockfile, or scratch/probe
  files committed;
- no debug / mock-probe / slop markers in source
  (panic!("probe...), dbg!, println!("DEBUG, TODO(ai), LOREM IPSUM, ...);
- the SYNTHETIC evidence label present on every firmware provider README, and
  the "never jamming" compliance disclaimer present in the root + firmware READMEs;
- no dishonest hardware-validation claims — honest negated / TODO(hw) /
  build-only mentions are explicitly allowed (negation-aware);
- no stale monorepo crate/harness identifiers in the code/manifest surface.

Scans only git-tracked files under the tree, so it works both in-monorepo and
in the extracted standalone repo, and never trips on untracked local scratch or
target/. Documented in CONTRIBUTING.md; passes clean on the current tree.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 17:20:03 +00:00
Claudeandclaude-flow 17ba9df19a ci(wifi-veil): add GitHub Pages workflow for the Console
Publish ui/veil-console.html as the site landing page (index.html) via GitHub
Pages on push to main. The console is a single self-contained file (inline
CSS/JS, no network), so the build just stages it. Enable once under
Settings → Pages → Source: "GitHub Actions".

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 17:14:55 +00:00
Claudeandclaude-flow 5114ed183f feat(wifi-veil): add self-contained standalone repository tree
Assemble `wifi-veil/` as an extraction-ready standalone repository for the WiFi
Veil privacy shield, decoupled from the RuView monorepo. The RuView copies under
v2/, harness/, firmware/, and docs/ are left untouched; this is an additive,
self-contained tree that can be split out to its own repo (e.g. ruvnet/wifi-veil).

Optimized for a standalone identity, with all monorepo coupling removed:

- Rust crate at the repo root: renamed `wifi-veil` (lib `wifi_veil`, bin `veil`),
  workspace-metadata inheritance inlined, own `[workspace]` root, release profile.
  Dependency-free and WASM-ready — it builds and tests OFFLINE, unlike the
  monorepo copy (which needs sibling submodules). Code is byte-identical, so the
  deterministic proof witness is unchanged.
- Portable C shield core + per-provider firmware scaffolds (openwifi/openwrt/
  nexmon/esp32) under firmware/; host C-core test passes.
- npm harness renamed `wifi-veil-harness`; its guidance paths/commands repointed
  to the standalone layout; manifest SHA-256 digests regenerated and verified.
- Docs: ADR-288/289/290 and the privacy-shield research bundle; research build
  commands/links normalized to the standalone crate.
- Root scaffolding: product README, dual LICENSE-MIT / LICENSE-APACHE,
  .gitignore, CHANGELOG, CONTRIBUTING, and a GitHub Actions CI workflow
  (Rust test/clippy/fmt + wasm build, C-core host test, harness smoke).

Validated locally: cargo fmt --check, cargo clippy --all-targets -D warnings,
cargo test (43 tests + witness), cargo build --lib --target wasm32-unknown-unknown,
make -C firmware/core test, and `node harness/bin/cli.js guidance` — all green.
No telemetry, build artifacts, or lockfile committed. All defense figures remain
SYNTHETIC / L0; compliant waveform controls only, never jamming.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 17:06:48 +00:00
Claudeandclaude-flow 1c2b383075 docs(privshield): rebrand project to "WiFi Veil"
Adopt "WiFi Veil" as the product name across all user-facing surfaces, keeping
VEIL (Verifiable Emission-shaping for Identity-Leakage prevention) as the
technical codename it's built on. Only prose, titles, descriptions, and the
console UI change — no code identifiers, file names, crate/npm `name` fields,
or the deterministic proof witness are touched, so `cargo test` and the C-core
host test are unaffected.

- Crate & research READMEs: title + defining line now "WiFi Veil (codename VEIL — …)".
- Cargo.toml / package.json / plugin.json descriptions: "WiFi Veil …".
- Console UI (veil-console.html): title, brand, and copy say "WiFi Veil".
- Firmware tree (README, per-provider READMEs, BUILD/INTEGRATION/MEASUREMENT):
  "WiFi Veil protector/core/shield".
- Harness manifest: recomputed SHA-256 digests for the four changed packaged
  files (README, package.json, CLAUDE.md, plugin.json) — all verified consistent.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 16:52:40 +00:00
Claudeandclaude-flow b827dc40b1 feat(privshield): E2E hardware program — validated C core + multi-provider firmware scaffolds
Take VEIL from the synthetic Rust reference model toward real WiFi silicon
across multiple hardware providers, around one shared, host-validated core.
Answers the questions "can OpenWRT / open WiFi software implement this?" and
"can ESP32 help scramble signals?" with an honest per-platform feasibility map.

Portable C shield core (firmware/privshield/core/) — VALIDATED (host test):
- veil_shield.{h,c}: keyed Givens-rotation obfuscation of the identity-bearing
  "fine" subspace, C99, no malloc / no libc I/O, only <math.h>. SplitMix64 key
  schedule byte-identical to the Rust crate, so on-air behavior is consistent
  everywhere and every adapter links the same math.
- make test passes: energy conservation (orthogonal => "not jamming"),
  reversibility (recover inverts apply), wrong-key-fails, and PRNG stream parity
  with the Rust crate. This is build/host evidence, NOT silicon.

Per-provider adapters (all SYNTHETIC / L0, build-only, TODO(hw) markers):
- openwifi/  grade B (ceiling A, effort D): only open PHY/MAC (FPGA) that can
  host the full keyed rotation + inverse; needs new HDL + 2nd TX chain. Carries
  the P5 measurement protocol (MEASUREMENT.md) for the first MEASURED result.
- openwrt/   grade C: per-packet keyed unitary is blob-blocked on commodity APs;
  coarse compliant knobs (TX antenna map, sounding-cadence jitter) reachable
  from userspace/hostapd; ath9k is the one credible driver-patch route.
- nexmon/    grade C: reading the compressed-BF angles is solved (nexmon_csi /
  Wi-BFI); shaping the transmitted report is research-grade (D11 ucode-adjacent).
- esp32/     grade F (self) / B (supporting): cannot shape its own BF feedback
  (closed esp-phy-lib blob); legitimate as a sensing detector and external-RIS
  controller — the honest way ESP32 "helps scramble", via an external surface.

Docs:
- firmware/privshield/README.md: architecture, layout, and the feasibility matrix.
- ADR-290: the E2E hardware program, PROOF discipline, and per-provider decision;
  added to docs/adr/README.md index.

Compliant waveform controls only, never jamming. No adapter has run on silicon;
no MEASURED claim is made (that is roadmap P5, gated on a captured log).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 16:34:11 +00:00
Claudeandclaude-flow 192ed2a236 feat(privshield): implement SOTA-driven attackers and compliant controls (ADR-288 §sota)
From the verified 2025-2026 deep-research findings, all four approved code items,
each opt-in so the reference witness stays byte-identical (0x350d…f448):

- attacker: BFI->CSI Reconstruction adversary (BFIAttack) — recovers the
  direction of the CSI consistent with the *captured* report; a secret
  orthogonal rotation leaves it at chance (no key to invert). AdaptivePooling
  adversary (PrivISAC) — pools + whitens per identity; still collapses.
  `AttackerKind` selects the shape.
- protector: `ObfMode::PerPacketUnitary` — fresh per-packet unitary, AP-side and
  client-transparent (LeakyBeam family). `dp_epsilon` — ε-DP angular dither,
  renormalized to preserve emission energy (still not jamming).
- throughput: `dp_residual` makes ε a real privacy<->throughput knob (smaller ε
  costs more gain).
- experiment: `attacker_kind` + mode-aware keying dispatch.

Tests (43 pass, +5): reconstruction & adaptive-pooling collapse (and win
unprotected); per-packet mode collapses + compliant; ε-DP still collapses +
compliant; DP throughput frontier monotonic. clippy -D warnings clean, fmt
clean, wasm --lib builds. All new numbers remain SYNTHETIC/L0.

Docs: 09-sota-update backlog items 1-4 marked implemented; crate README module
table refreshed.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 16:20:18 +00:00
Claudeandclaude-flow c63b26034b docs(privshield): fold verified 2025-2026 SOTA into threat model, compliance, and roadmap
From a fan-out deep-research run (20 primary sources, 25 claims 3-vote verified,
24 confirmed / 1 refuted):

- New docs/research/privacy-shield/09-sota-update-2026.md: cited, evidence-classed
  SOTA update + prioritized VEIL improvement backlog.
- ADR-288 gains a "2025-2026 evidence update" section: broader threat (BFId
  99.5%/N=197; LeakyBeam through-wall vitals @20m; WiKI-Eve/SThief keystrokes;
  BFIAttack BFI->CSI reconstruction), VEIL's family independently validated
  (LeakyBeam per-packet unitary 89.7->51%; PrivISAC RIS 93->30%), BeamDancer
  (IEEE TWC 2024) as compliance precedent, shield-security-is-CLAIMED honesty,
  and the unfilled governance gap. Do NOT cite BeamDancer's refuted >96% PDR.
- Roadmap §3.1: answers "does this need custom WiFi firmware?" — yes; ESP32 is an
  attacker/sensor node only (closed blob, CSI read only), the protector needs
  openwifi / Nexmon / vendor firmware; keyed-reversible needs both ends + key.

Docs only. All VEIL numbers remain SYNTHETIC/L0; no code or claims upgraded.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 16:09:33 +00:00
Claudeandclaude-flow 0cb348da72 docs(privshield): clearly explain how VEIL protects against unauthorized WiFi surveillance
Plain-language "how it protects you" for non-experts, in both surfaces:

- README: new "How this protects you from unauthorized WiFi surveillance"
  section — the silent device-free threat, the per-session keyed-twist defense
  (own router undoes it, outside listener can't average it back → identity guess
  collapses to chance; ~98% throughput; compliant/not-jamming), and honest
  limits (defends vs. third-party sniffers not the AP; SYNTHETIC/L0).
- UI (ui/veil-console.html): a new always-visible "How this protects you" card
  (Threat / Shield / Kept honest) plus a deeper `protect` explainer dialog.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 15:52:37 +00:00
Claudeandclaude-flow aea8c8c66a docs(privshield): add veil TUI walkthrough GIF, ship the web console in-repo
- docs/veil-tui.gif: an animated walkthrough of the `veil` terminal harness
  (shield off/on, 32 passes → out-of-spec, 96 → pass, ward preset, optimize,
  witness check), embedded at the top of the harness section in the crate README.
- ui/veil-console.html: the self-contained VEIL Console web dashboard now lives
  in the repo (no build/network), linked from the README.
- veil: honor CLICOLOR_FORCE so piped captures keep ANSI color (standard flag).
- veil-console.html footer now points at the `veil` terminal harness/TUI.

Validated: 38 tests + doctest pass, clippy --all-targets -D warnings clean,
rustfmt clean. GIF is 800×520, ~113 KB, verified to decode/animate in a browser.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 15:39:01 +00:00
Claudeandclaude-flow cb67be117a feat(privshield): add veil custom terminal harness + TUI (ADR-288 §harness)
A dependency-free native binary (`src/bin/veil.rs`) — the in-repo counterpart to
the npm metaharness — that drives the same crate API the tests use:

- Interactive ANSI dashboard (TUI): live status, re-ID off/on vs chance,
  throughput, compliance (energy 1.000× / not jamming), a block-sparkline
  collapse curve, config, and PASS/OUT-OF-SPEC verdict. Command-driven redraw
  loop (std-only, no crossterm/ratatui): on/off, passes/bits/n/snr,
  metric euclid|cosine, preset scif|board|ward|hotel, optimize, proof.
- Scriptable subcommands: report, sweep, optimize, adaptive <N>, proof, doctor
  (exit 0 = healthy). Auto-picks TUI on a terminal, one-shot report when piped;
  honors NO_COLOR.

Std-only, so it builds with no extra deps and runs in any pipe/CI. The wasm leaf
story is unchanged (validated with `--lib`; the bin is native-only). All
readouts are SYNTHETIC/L0 and never relabeled.

Validated: 38 tests + doctest pass, clippy --all-targets -D warnings clean,
rustfmt clean, wasm --lib builds; all subcommands + a scripted TUI session
exercised. Documented in the crate README and ADR-288.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 15:26:07 +00:00
Claudeandclaude-flow 80b1715cb8 docs(privshield): add VEIL Console landscape banner to top of crate README
Adds a rendered screenshot of the VEIL management console (dark theme, shield
engaged: the room's WiFi identity clusters collapsed to the chance floor, re-ID
4.7%, PROTECTED) as a banner at the top of the crate README. Captured at a 16:11
landscape viewport (2400x1752, 2x).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 15:07:41 +00:00
Claudeandclaude-flow 18060b9c77 Add per-deployment adaptive optimization + VEIL npm metaharness (ADR-289)
Two additions on top of the hyper-optimized VEIL shield.

1) Adaptive optimization (v2/crates/wifi-densepose-privshield/src/optimize.rs):
   - optimal_bits_across_snr / model_optimal_bits_for_snr: the throughput-
     optimal feedback resolution shifts with SNR (unconstrained optimum 4 bits
     at 5-10 dB, 3 bits at 20-40 dB); within the spec {5,7,9} set it stays 5,
     which is why the shipped shield is SNR-stable.
   - adaptive_shield / min_passes_for_n: derive a shield for a specific
     deployment. Finding: the collapse budget is N-independent in this model
     (48 passes collapses N in {8,64} alike) — it is set by the fine-subspace
     dimension, not the candidate count. Defaults unchanged, so the proof
     witness is untouched. 38 tests + doctest pass; clippy -D warnings clean.

2) npm metaharness harness/wifi-densepose-privshield/ (ADR-289), mirroring
   wifi-densepose-sar-harness (ADR-286) with two improvements:
   - @metaharness/* imported dynamically inside the commands that need them, so
     `guidance` and `--help` run with ZERO dependencies installed (offline / pre
     `npm install`).
   - a dependency-free VEIL `guidance` command: a source-cited, evidence-
     labelled, read-only capability map (topics: overview, threat,
     countermeasure, compliance, optimization, experiment).
   Standard router + flywheel (SYNTHETIC) + Darwin wiring, tailored to VEIL
   task axes and policy levers. Tests: smoke + router + flywheel (need install)
   and guidance (offline). .harness manifest generated with real per-file
   hashes. Validated offline: cli syntax, --help, guidance topics, exit codes,
   graceful degradation when deps are absent.

Docs: research bundle 08 gains a per-deployment adaptivity section; 07 and the
crate README point at the harness; ADR-289 added and indexed.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 14:23:12 +00:00
Claudeandclaude-flow 006a66ca20 Hyper-optimize VEIL shield: derive the optimal config instead of hand-picking it
Adds an `optimize` module that replaces the hand-picked shield config with a
derived, robustness-verified optimum, and hardens the experiment so the
collapse is proven to be signal-level, not classifier-level.

Model changes:
- throughput.rs: add a feedback-airtime term (cost rises with feedback bits)
  alongside the falling quantization residual, giving a genuine interior
  throughput optimum in feedback resolution.
- attacker.rs: add a selectable distance metric (Euclidean + Cosine) so the
  optimizer can require the collapse to hold under multiple classifiers.
- experiment.rs: thread the attacker metric through; build the channel once.

optimize.rs:
- optimal_feedback_bits / spec_optimal_feedback_bits: throughput-best resolution
  (3 bits unconstrained, matching DySPAN-2026; 5 bits within the 802.11 {5,7,9}
  set).
- min_givens_passes: smallest mixing budget that collapses re-ID robustly across
  both metrics AND N in {16,32}.
- pareto_frontier and hyper_optimize.

Findings and adopted defaults:
- Proven-minimum robust passes = 48; the hand-picked 112 was 2.3x over-
  provisioned. Rotation mixing is keyed (never signaled), so extra passes are
  throughput-free -> ship 96 (2x margin).
- Feedback resolution 5 bits (spec-optimal), down from 7.
- ShieldConfig::default() now equals hyper_optimize()'s output; a test guards
  against drift.

Net vs. the original: strictly better on BOTH privacy and throughput.
Reference (SYNTHETIC/L0, N=16): re-ID 100% shield-off -> 4.7% shield-on
(chance 6.25%, below chance), throughput 97.6%, energy ratio 1.000000. 35 tests
+ doctest pass; clippy -D warnings clean; builds for wasm32.

Docs: new docs/research/privacy-shield/08-optimization.md; updated bundle
README/03/05/07 and ADR-288 with the derived operating point.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 14:08:46 +00:00
Claudeandclaude-flow 16b2a629d1 Add VEIL privacy shield: compliant-waveform defense against WiFi sensing (ADR-288)
VEIL (Verifiable Emission-shaping for Identity-Leakage prevention) is the
countermeasure counterpart to BFLD (ADR-118/121): where BFLD detects when
beamforming feedback becomes identifying, VEIL shapes a node's own outgoing
feedback so an unauthorized passive sniffer cannot re-identify people, while
a legitimate receiver that shares the per-session key sees an unchanged link.

Mechanism: identity leaks through the fine cross-subcarrier phase structure of
a compressed beamforming report; throughput rides the dominant beam direction.
These are (mostly) separable subspaces. VEIL composes extra keyed Givens
rotations (the report's native primitive) over the fine subspace only. The
rotation is orthogonal (energy-preserving -> not jamming), keyed per session
(the AP inverts it -> throughput preserved), and fresh each session (a sniffer
cannot average it back -> re-identification collapses to chance).

Contents:
- v2/crates/wifi-densepose-privshield: deterministic, dependency-free,
  WASM-ready pure-compute leaf implementing the attacker-vs-protector
  experiment, the four compliant controls, a throughput model, a
  machine-checkable "not jamming" compliance audit, and a pinned witness.
  29 tests + doctest pass; clippy -D warnings clean; builds for
  wasm32-unknown-unknown.
- docs/research/privacy-shield: 8-file research bundle (SOTA, threat model,
  design, compliance/regulatory, experiment protocol, market, roadmap).
- docs/adr/ADR-288: formal decision record.

Reference results (SYNTHETIC / L0, N=16 identities): passive re-ID accuracy
100% shield-off -> 7.8% shield-on (chance 6.25%); modeled throughput ratio
98.0%; emission energy ratio 1.000000 (compliant). All defense numbers are
SYNTHETIC until a two-node hardware capture with a witness exists.

Compliant waveform controls only; never jamming (47 U.S.C. 333/302a analysis
in the bundle).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 13:51:12 +00:00
ruv 5780c239e4 security: repair scanning and close stale alert sources 2026-08-02 15:22:13 -04:00
961 changed files with 149311 additions and 154982 deletions

No files matched your search

+5
View File
@@ -10,6 +10,11 @@
"name": "ruview",
"source": "./plugins/ruview",
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
},
{
"name": "ruview-live",
"source": "./harness/ruview/mod",
"description": "Claude Code mod: /ruview opens a live pane with your CSI nodes (ESP32, Realtek) and ESPHome radar (presence, distance, device-reported vitals), refreshed on a timer, plus a one-line status. Read-only; ships inside @ruvnet/ruview. Mods are early access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)."
}
]
}
+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
+4
View File
@@ -135,6 +135,10 @@ async function runProcess(command, args, {
resolve(result);
});
});
// A child that never reads stdin (e.g. `git check-ignore`) may exit and
// close the pipe before this write flushes; the resulting EPIPE is not a
// failure of the command, whose real outcome the 'close' handler reports.
child.stdin.on('error', () => {});
child.stdin.end(input);
});
}
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
run:
working-directory: v2
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -40,7 +40,7 @@ jobs:
run: rustup show && rustc --version
- name: Cache cargo
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
+7 -7
View File
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive — wifi-densepose-rufield path-deps vendor/rufield)
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
# The workspace includes `wifi-densepose-rufield`, which path-deps the
# `vendor/rufield` submodule crates. Without a recursive checkout the
@@ -100,10 +100,10 @@ jobs:
pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2
# Distinct cache scope from ci.yml's rust-tests so the bench profile
@@ -150,15 +150,15 @@ jobs:
needs: [bench-compile]
steps:
- name: Checkout (recursive)
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2
key: bench-regression
@@ -192,7 +192,7 @@ jobs:
- name: Upload informational bench logs
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: bench-fast-run-logs
path: bench-out/
+43
View File
@@ -0,0 +1,43 @@
name: BFI Research Tools
on:
pull_request:
paths:
- 'tools/bfi/**'
- 'tools/bfi_learning/**'
- '.github/workflows/bfi-research.yml'
push:
branches: [main]
paths:
- 'tools/bfi/**'
- 'tools/bfi_learning/**'
- '.github/workflows/bfi-research.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: bfi-research-${{ github.ref }}
cancel-in-progress: true
jobs:
offline-tests:
name: BFI offline tests (Linux, CPU)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.13'
- name: Install numeric test dependencies
run: python -m pip install numpy==2.4.2 scipy==1.17.1
- name: Install CPU PyTorch
run: python -m pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu
- name: Test capture parsing and bounded runners
run: python -B -m unittest discover -s tools/bfi -v
- name: Test offline learning and data admission
run: python -B -m unittest discover -s tools/bfi_learning -v
+3 -3
View File
@@ -52,17 +52,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
components: clippy
- name: Cache cargo registry + target
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
+11 -11
View File
@@ -44,7 +44,7 @@ jobs:
image_tag: ${{ steps.determine-tag.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
submodules: recursive
@@ -96,12 +96,12 @@ jobs:
url: https://staging.wifi-densepose.com
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up kubectl
uses: azure/setup-kubectl@v3
uses: azure/setup-kubectl@901a10e89ea615cf61f57ac05cecdf23e7de06d8
with:
version: 'v1.28.0'
@@ -147,12 +147,12 @@ jobs:
url: https://wifi-densepose.com
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up kubectl
uses: azure/setup-kubectl@v3
uses: azure/setup-kubectl@901a10e89ea615cf61f57ac05cecdf23e7de06d8
with:
version: 'v1.28.0'
@@ -222,7 +222,7 @@ jobs:
# kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0
- name: Upload deployment artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: production-deployment-${{ github.run_number }}
path: |
@@ -239,7 +239,7 @@ jobs:
name: ${{ needs.pre-deployment.outputs.deploy_env }}
steps:
- name: Set up kubectl
uses: azure/setup-kubectl@v3
uses: azure/setup-kubectl@901a10e89ea615cf61f57ac05cecdf23e7de06d8
with:
version: 'v1.28.0'
@@ -293,7 +293,7 @@ jobs:
done
- name: Update deployment status
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
with:
script: |
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
@@ -317,7 +317,7 @@ jobs:
steps:
- name: Notify Slack on success
if: needs.deploy-production.result == 'success' || needs.deploy-staging.result == 'success'
uses: 8398a7/action-slack@v3
uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e
with:
status: success
channel: '#deployments'
@@ -331,7 +331,7 @@ jobs:
- name: Notify Slack on failure
if: needs.deploy-production.result == 'failure' || needs.deploy-staging.result == 'failure'
uses: 8398a7/action-slack@v3
uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e
with:
status: failure
channel: '#deployments'
@@ -344,7 +344,7 @@ jobs:
- name: Create deployment issue on failure
if: needs.deploy-production.result == 'failure'
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
with:
script: |
github.rest.issues.create({
+34 -28
View File
@@ -27,14 +27,14 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
fetch-depth: 0
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -68,7 +68,7 @@ jobs:
- name: Upload security reports
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: security-reports
@@ -82,7 +82,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
# ADR-262 P1: `wifi-densepose-rufield` path-deps the `vendor/rufield`
@@ -112,7 +112,7 @@ jobs:
pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
# Swatinem/rust-cache replaces a naive `actions/cache` of the whole
# `v2/target`. That manual cache of a 38-crate target dir (multi-GB) was an
@@ -123,7 +123,7 @@ jobs:
# reliably (and faster) on large workspaces. `workspaces: v2` points it at
# the v2/ cargo workspace (keys on v2/Cargo.lock, caches v2/target).
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2
@@ -196,15 +196,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
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 ui/services/sensing.service.test.mjs ui/components/DashboardTab.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.
@@ -222,6 +222,8 @@ jobs:
postgres:
image: postgres:15
env:
# Ephemeral CI-only credential; this service is isolated to the job.
# kics-scan ignore-line
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_wifi_densepose
options: >-
@@ -245,13 +247,13 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up Python ${{ matrix.python-version }}
continue-on-error: true
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -266,6 +268,8 @@ jobs:
- name: Run unit tests
continue-on-error: true
env:
# Ephemeral CI-only service URL; never used outside this job.
# kics-scan ignore-line
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
REDIS_URL: redis://localhost:6379/0
ENVIRONMENT: test
@@ -275,6 +279,8 @@ jobs:
- name: Run integration tests
continue-on-error: true
env:
# Ephemeral CI-only service URL; never used outside this job.
# kics-scan ignore-line
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
REDIS_URL: redis://localhost:6379/0
ENVIRONMENT: test
@@ -283,7 +289,7 @@ jobs:
- name: Upload coverage reports
continue-on-error: true
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f
with:
files: ./coverage.xml
flags: unittests
@@ -291,7 +297,7 @@ jobs:
- name: Upload test results
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: test-results-${{ matrix.python-version }}
@@ -312,12 +318,12 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -361,7 +367,7 @@ jobs:
- name: Upload performance results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: performance-results
path: archive/v1/perf-junit.xml
@@ -382,17 +388,17 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up Docker Buildx
continue-on-error: true
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to Container Registry
continue-on-error: true
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -401,7 +407,7 @@ jobs:
- name: Extract metadata
continue-on-error: true
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -412,7 +418,7 @@ jobs:
- name: Build and push Docker image
continue-on-error: true
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: .
target: production
@@ -441,7 +447,7 @@ jobs:
- name: Upload Trivy scan results
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3
if: always()
with:
sarif_file: 'trivy-results.sarif'
@@ -456,12 +462,12 @@ jobs:
contents: write # gh-pages deploy needs write (GITHUB_TOKEN is read-only by default -> 403)
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -484,7 +490,7 @@ jobs:
"
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453
continue-on-error: true # openapi generation above is the real validation; deploy is best-effort (Pages may be disabled)
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -507,7 +513,7 @@ jobs:
steps:
- name: Notify Slack on success
if: ${{ env.SLACK_WEBHOOK_URL != '' && needs.code-quality.result == 'success' && needs.test.result == 'success' && needs.docker-build.result == 'success' }}
uses: 8398a7/action-slack@v3
uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e
with:
status: success
channel: '#ci-cd'
@@ -515,7 +521,7 @@ jobs:
- name: Notify Slack on failure
if: ${{ env.SLACK_WEBHOOK_URL != '' && (needs.code-quality.result == 'failure' || needs.test.result == 'failure' || needs.docker-build.result == 'failure') }}
uses: 8398a7/action-slack@v3
uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e
with:
status: failure
channel: '#ci-cd'
@@ -523,7 +529,7 @@ jobs:
- name: Create GitHub Release
if: github.ref == 'refs/heads/main' && needs.docker-build.result == 'success'
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
tag_name: v${{ github.run_number }}
name: Release v${{ github.run_number }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
+13 -13
View File
@@ -27,17 +27,17 @@ jobs:
name: Build x86_64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: x86_64-unknown-linux-gnu
- name: Cache cargo registry
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
@@ -66,7 +66,7 @@ jobs:
echo "Signed cog-ha-matter-x86_64 ($(wc -c < dist/cog-ha-matter-x86_64.sig) bytes)"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: cog-ha-matter-x86_64
path: |
@@ -79,12 +79,12 @@ jobs:
name: Build aarch64 (arm)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: aarch64-unknown-linux-gnu
@@ -94,7 +94,7 @@ jobs:
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Cache cargo registry
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
@@ -130,7 +130,7 @@ jobs:
echo "Signed cog-ha-matter-arm ($(wc -c < dist/cog-ha-matter-arm.sig) bytes)"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: cog-ha-matter-arm
path: |
@@ -148,29 +148,29 @@ jobs:
github.event_name == 'push' &&
vars.HAS_GCP_CREDENTIALS == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Download x86_64 artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: cog-ha-matter-x86_64
path: dist/
- name: Download arm artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: cog-ha-matter-arm
path: dist/
- name: Auth to GCP
uses: google-github-actions/auth@v2
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed
with:
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f
- name: Upload binaries + sidecars
run: |
+53
View File
@@ -0,0 +1,53 @@
name: CSI data policy (ADR-299)
# ADR-299 repository CSI data-incident guard. Fails when CSI-format files
# (*.csi.jsonl / *.csi.meta.json) or oversized JSONL captures are tracked in
# git. Raw CSI is person data and must never be committed (CLAUDE.md, ADR-299).
#
# NOTE: the tree currently still contains the pre-existing incident recordings
# under data/recordings/ and v2/data/recordings/, whose removal is gated on
# data-owner sign-off (ADR-299). Until they are removed this job is EXPECTED to
# fail, and that failure documents the incident. To make it green in a
# follow-up without weakening the guard for NEW files, set CSI_POLICY_BASELINE
# to a file listing the acknowledged paths (see the script header).
#
# Checker: scripts/csi-data-policy-check.sh Run locally: bash the same script.
on:
push:
branches:
- main
- master
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
csi-data-policy:
name: CSI data policy check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- name: Self-test the policy checker (deterministic, offline)
run: bash scripts/csi-data-policy-check.sh --self-test
- name: Enforce CSI data policy on tracked files
# CSI_POLICY_BASELINE can point at an acknowledged-paths file once the
# owner remediates the tree; unset here so a regression fails loudly.
run: bash scripts/csi-data-policy-check.sh --tracked
- name: Summarize result
if: always()
run: |
{
echo '### CSI data policy (ADR-299)'
echo ''
echo '```'
bash scripts/csi-data-policy-check.sh --tracked 2>&1 || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+3 -3
View File
@@ -19,11 +19,11 @@ jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with: { targets: wasm32-unknown-unknown }
- name: Install wasm-pack
@@ -36,7 +36,7 @@ jobs:
--out-dir ../../dashboard/public/nvsim-pkg \
--release -- --no-default-features --features wasm
- uses: actions/setup-node@v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json }
- working-directory: dashboard
+5 -5
View File
@@ -25,17 +25,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Install Rust + wasm32 target
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: wasm32-unknown-unknown
- name: Cache cargo registry
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
@@ -59,7 +59,7 @@ jobs:
-- --no-default-features --features wasm
- name: Setup Node 20
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 20
cache: npm
@@ -76,7 +76,7 @@ jobs:
run: npm run build
- name: Deploy to gh-pages/nvsim/
uses: peaceiris/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dashboard/dist
+12 -12
View File
@@ -27,17 +27,17 @@ jobs:
target: [aarch64-apple-darwin, x86_64-apple-darwin]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: '20'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: ${{ matrix.target }}
@@ -74,7 +74,7 @@ jobs:
zip -r "RuView-Desktop-${{ github.event.inputs.version || '0.4.0' }}-macos-${{ steps.arch.outputs.arch }}.zip" "RuView Desktop.app"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ruview-macos-${{ steps.arch.outputs.arch }}
path: v2/target/${{ matrix.target }}/release/bundle/macos/*.zip
@@ -84,17 +84,17 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: '20'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- name: Install frontend dependencies
working-directory: v2/crates/wifi-densepose-desktop/ui
@@ -115,13 +115,13 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Upload Windows MSI artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ruview-windows-msi
path: v2/target/release/bundle/msi/*.msi
- name: Upload Windows NSIS artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ruview-windows-nsis
path: v2/target/release/bundle/nsis/*.exe
@@ -134,12 +134,12 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: artifacts
@@ -147,7 +147,7 @@ jobs:
run: find artifacts -type f
- name: Create or Update Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
name: RuView Desktop v${{ github.event.inputs.version || '0.4.0' }}
tag_name: ${{ github.event.inputs.attach_to_existing || format('desktop-v{0}', github.event.inputs.version || '0.4.0') }}
+67 -11
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
if: github.ref_type == 'tag'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Check firmware version.txt == tag
@@ -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
@@ -75,7 +77,7 @@ jobs:
artifact_pt: partition-table-c6.bin
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -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
@@ -175,8 +180,59 @@ jobs:
echo "See: https://github.com/espressif/qemu/wiki"
- name: Upload firmware artifact (${{ matrix.variant }})
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
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/*
+11 -11
View File
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Cache QEMU build
id: cache-qemu
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: /opt/qemu-esp32
# Include date component so cache refreshes monthly when branch updates
@@ -73,7 +73,7 @@ jobs:
echo "QEMU binary size: $(file_size /opt/qemu-esp32/bin/qemu-system-xtensa) bytes"
- name: Upload QEMU artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: qemu-esp32
path: /opt/qemu-esp32/
@@ -99,12 +99,12 @@ jobs:
- boundary-min
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Download QEMU artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: qemu-esp32
path: /opt/qemu-esp32
@@ -203,7 +203,7 @@ jobs:
- name: Upload test logs
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: qemu-logs-${{ matrix.nvs_config }}
path: |
@@ -215,7 +215,7 @@ jobs:
name: Fuzz Testing (ADR-061 Layer 6)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -253,7 +253,7 @@ jobs:
- name: Upload fuzz artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fuzz-crashes
path: |
@@ -266,7 +266,7 @@ jobs:
name: NVS Matrix Generation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -322,12 +322,12 @@ jobs:
image: espressif/idf:v5.4
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Download QEMU artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: qemu-esp32
path: /opt/qemu-esp32
@@ -370,7 +370,7 @@ jobs:
- name: Upload swarm results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: swarm-results
path: |
+3 -3
View File
@@ -21,11 +21,11 @@ jobs:
name: Verify fix markers
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- uses: actions/setup-python@v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.11'
@@ -49,7 +49,7 @@ jobs:
- name: Upload result artifact
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fix-markers-result
path: fix-markers-result.json
+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
+67
View File
@@ -0,0 +1,67 @@
name: Model release gate (ADR-298)
# ADR-298 model-release sanity gates (issue #1521): structural checks that
# block a degenerate/mislabeled classifier head (unreachable decision
# boundary, near-constant output, degenerate class balance, a metric
# surfaced under a task name it wasn't computed as) before it ships.
#
# Checker: v2/crates/wifi-densepose-train/src/model_gates.rs
#
# IMPORTANT — the honest scope of this job: it protects the *checker itself*
# from regressing (the gate logic + its issue-1521 regression fixture are
# exercised on every push/PR that touches this crate), and running it is
# required before ADR-298 can be called "wired in" at all. It does NOT gate
# an actual model publish — this repository does not automate uploading to
# the HuggingFace model repo (`ruvnet/wifi-densepose-pretrained`); that
# remains a manual, human-run step. Before publishing or replacing a model
# artifact there, run this gate against the real head weights locally:
#
# cargo test -p wifi-densepose-train model_gates
#
# and, until a CLI entry point exists to run `evaluate_linear_head` against an
# arbitrary `.safetensors`/`.rvf` file, load the head's `weight`/`bias` in a
# short script and call `wifi_densepose_train::evaluate_linear_head` directly.
on:
push:
branches:
- main
- master
paths:
- "v2/crates/wifi-densepose-train/**"
pull_request:
paths:
- "v2/crates/wifi-densepose-train/**"
workflow_dispatch:
permissions:
contents: read
jobs:
model-release-gate:
name: Model release gate check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
submodules: recursive
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: Run the model-release gate's own test suite
working-directory: v2
run: cargo test -p wifi-densepose-train --no-default-features model_gates -- --nocapture
- name: Summarize result
if: always()
run: |
{
echo '### Model release gate (ADR-298)'
echo ''
echo 'This job protects `model_gates.rs` from regressing. It does not itself'
echo 'gate a real HuggingFace model publish — that upload is a manual step'
echo 'outside this repository; run `cargo test -p wifi-densepose-train model_gates`'
echo 'against real head weights before publishing one.'
} >> "$GITHUB_STEP_SUMMARY"
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
RUST_BACKTRACE: 1
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -70,12 +70,12 @@ jobs:
exit 1
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: stable
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: v2 -> target
+16 -5
View File
@@ -40,8 +40,13 @@ 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. ADR-369..374 add the SDK, firmware
# flashing, training runner/gate, doctor, device access, and SSH
# hosts. ADR-375 adds the MCP Apps console widget (~12 KB), the
# Streamable HTTP transport and the terminal UI (384 KiB); ADR-378 the
# ruview-live showcase views (~37 KB): 448 KiB reviewed ceiling.
unpacked_budget: 458752
- dir: harness/homecore
build: false
publishable: true
@@ -130,13 +135,19 @@ jobs:
npm i --no-fund --no-audit "$TGZ"
case "${{ matrix.package.dir }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
./node_modules/.bin/ruview-harness --version
./node_modules/.bin/ruview-harness doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
if ./node_modules/.bin/ruview-harness claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
# ADR-369: the typed SDK resolves and the evidence gate refuses a baseline-free score
node --input-type=module -e "const { createRuView } = await import('@ruvnet/ruview/sdk'); const r = createRuView(); if (r.tools().length < 22) process.exit(1); const g = await r.training.gate({ model_score: 0.93 }); if (g.verdict !== 'FAIL') process.exit(1);"
./node_modules/.bin/ruview-harness doctor --json --group runtime,harness,firmware > doctor.json
if ./node_modules/.bin/ruview-harness train-gate --model-score 0.93 > /dev/null; then
echo 'train-gate passed without a baseline'; exit 1
fi
;;
harness/homecore)
./node_modules/.bin/homecore --version
+5 -5
View File
@@ -25,13 +25,13 @@ jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- uses: docker/login-action@v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -39,7 +39,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: ghcr.io/ruvnet/nvsim-server
tags: |
@@ -49,7 +49,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build + push
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: v2
file: v2/crates/nvsim-server/Dockerfile
+19 -19
View File
@@ -90,19 +90,19 @@ jobs:
arch: AMD64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
# Linux aarch64 needs QEMU for cross-build on x86_64 runners.
- name: Set up QEMU
if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64'
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130
# ADR-117 §5.4: abi3-py310 — one binary per OS/arch covers all
# Python minor versions ≥ 3.10. Build only cp310 wheels.
- name: Build wheels (cibuildwheel)
uses: pypa/cibuildwheel@v2.21
uses: pypa/cibuildwheel@7940a4c0e76eb2030e473a5f864f291f63ee879b
env:
CIBW_BUILD: "cp310-*"
CIBW_ARCHS_LINUX: ${{ matrix.arch }}
@@ -124,7 +124,7 @@ jobs:
package-dir: python
output-dir: wheelhouse
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: wheels-${{ matrix.os }}-${{ matrix.arch }}
path: wheelhouse/*.whl
@@ -137,7 +137,7 @@ jobs:
startsWith(github.ref, 'refs/tags/v2.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Install maturin
@@ -145,7 +145,7 @@ jobs:
- name: Build sdist
working-directory: python
run: maturin sdist --out ../sdist
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: sdist
path: sdist/*.tar.gz
@@ -158,8 +158,8 @@ jobs:
startsWith(github.ref, 'refs/tags/v2.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v6
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.12'
- name: Verify lock-step package versions
@@ -185,7 +185,7 @@ jobs:
run: |
python -m pip install --upgrade pip build
python -m build python/ruview-meta --outdir ruview-dist
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ruview
path: ruview-dist/*
@@ -202,10 +202,10 @@ jobs:
startsWith(github.ref, 'refs/tags/v1.99')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- uses: actions/setup-python@v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.12'
- name: Install build backend
@@ -264,7 +264,7 @@ jobs:
exit 1
fi
echo "Tombstone wheel correctly raises ImportError with migration URL."
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: tombstone
path: tombstone-dist/*
@@ -288,7 +288,7 @@ jobs:
)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Enforce production witness gate
if: |
startsWith(github.ref, 'refs/tags/v2.') ||
@@ -299,7 +299,7 @@ jobs:
exit 1
}
- name: Gather all artifacts into dist/
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: dist-staging
- name: Flatten artifacts
@@ -311,7 +311,7 @@ jobs:
# before replacing `password:` with the OIDC id-token permission.
- name: Publish to TestPyPI (dry-run target)
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.TESTPYPI_API_TOKEN }}
@@ -321,7 +321,7 @@ jobs:
if: |
startsWith(github.ref, 'refs/tags/v2.') ||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
with:
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist
@@ -339,7 +339,7 @@ jobs:
)
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: tombstone
path: dist
@@ -347,7 +347,7 @@ jobs:
# before replacing `password:` with the OIDC id-token permission.
- name: Publish to TestPyPI (dry-run target)
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.TESTPYPI_API_TOKEN }}
@@ -357,7 +357,7 @@ jobs:
if: |
startsWith(github.ref, 'refs/tags/v1.99') ||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
with:
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -63,7 +63,7 @@ jobs:
EOF
- name: Deploy to gh-pages/pointcloud/
uses: peaceiris/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./_site/pointcloud
+8 -8
View File
@@ -68,7 +68,7 @@ jobs:
name: Wheel + parity tests (features=sota)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
# The python/ crate path-deps v2/crates/* and (transitively via
# train) the vendored ruvector submodule — recursive checkout keeps
@@ -76,15 +76,15 @@ jobs:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.11'
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: |
v2
@@ -133,20 +133,20 @@ jobs:
name: Default wheel <= 5 MiB (ADR-117 §5.4)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.11'
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: python
+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
+68
View File
@@ -0,0 +1,68 @@
# ADR-373/374 — device-access end-to-end through the real @ruvnet/ruview CLI.
# MR60BHA2 (60 GHz) and RPLIDAR are emulated on pseudo-terminals and read via
# real pyserial; an ESP32 node is emulated over UDP; the iPhone LiDAR path
# uses the repository's real relay. All results are SYNTHETIC: this proves
# the transports and parsers, not hardware behaviour.
name: ruview device e2e
on:
push:
branches: [main]
paths:
- 'harness/ruview/src/**'
- 'harness/ruview/bin/**'
- 'harness/ruview/test/e2e/**'
- 'integrations/iphone-lidar/web/**'
- '.github/workflows/ruview-device-e2e.yml'
pull_request:
paths:
- 'harness/ruview/src/**'
- 'harness/ruview/bin/**'
- 'harness/ruview/test/e2e/**'
- 'integrations/iphone-lidar/web/**'
- '.github/workflows/ruview-device-e2e.yml'
permissions:
contents: read
concurrency:
group: ruview-device-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
devices:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.12'
- name: Install pyserial
run: python -m pip install pyserial==3.5
- name: Install the iPhone LiDAR relay
working-directory: integrations/iphone-lidar/web
run: npm ci --ignore-scripts
- name: Install harness
working-directory: harness/ruview
run: npm ci --ignore-scripts
# Missing prerequisites fail instead of skipping (RUVIEW_E2E_REQUIRE).
# The suite links each emulator pty to /dev/ttyRUVIEWE2E<n> with
# `sudo -n`, because the harness refuses /dev/pts/* port names.
- name: Device e2e (SYNTHETIC devices)
working-directory: harness/ruview
env:
RUVIEW_E2E_REQUIRE: '1'
run: npm run test:e2e:devices
+132
View File
@@ -0,0 +1,132 @@
# ADR-368 — @ruvnet/ruview-kernel: build the WASM and napi-rs transports from
# source on every OS, run the Rust ABI tests, the package unit/security/e2e
# tests with the native addon REQUIRED, the wasm↔napi parity check, artifact
# verification, and the cross-package e2e through the @ruvnet/ruview
# metaharness. No publish step: publication stays CI-only via the release
# workflow and requires explicit maintainer approval (ADR-265).
name: ruview-kernel npm
on:
push:
branches: [main]
paths:
- 'v2/crates/ruview-kernel/**'
- 'v2/crates/ruview-kernel-napi/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'harness/ruview-kernel/**'
- 'harness/ruview/src/kernel.js'
- 'harness/ruview/test/kernel.test.mjs'
- '.github/workflows/ruview-kernel-npm.yml'
pull_request:
paths:
- 'v2/crates/ruview-kernel/**'
- 'v2/crates/ruview-kernel-napi/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'harness/ruview-kernel/**'
- 'harness/ruview/src/kernel.js'
- 'harness/ruview/test/kernel.test.mjs'
- '.github/workflows/ruview-kernel-npm.yml'
permissions:
contents: read
concurrency:
group: ruview-kernel-${{ github.ref }}
cancel-in-progress: true
jobs:
kernel:
name: kernel (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The v2 workspace lists submodule crates as members; cargo must be
# able to load every member manifest.
submodules: recursive
- name: Install Rust + wasm32 target
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: wasm32-unknown-unknown
# v2/rust-toolchain pins its own channel; the target must be installed
# for that toolchain, not only for the action's default `stable`.
- name: Add wasm32 target to the pinned v2 toolchain
working-directory: v2
run: rustup target add wasm32-unknown-unknown
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: |
v2 -> target
v2/crates/ruview-kernel-napi -> target
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Rust ABI tests
run: cargo test --manifest-path v2/Cargo.toml -p ruview-kernel --locked
- name: Build WASM + napi-rs artifacts from source
working-directory: harness/ruview-kernel
run: npm run build
- name: Verify artifacts (checksums, zero WASM imports, ABI)
working-directory: harness/ruview-kernel
run: node scripts/verify-artifacts.mjs --require-wasm
- name: Package tests (napi required)
working-directory: harness/ruview-kernel
env:
RUVIEW_KERNEL_REQUIRE_NAPI: '1'
run: npm test
- name: Doctor, self-test, and wasm↔napi parity (SYNTHETIC)
working-directory: harness/ruview-kernel
run: |
node bin/cli.js doctor --strict-napi
node bin/cli.js selftest --backend wasm
node bin/cli.js selftest --backend napi
node bin/cli.js parity
- name: Pack
working-directory: harness/ruview-kernel
run: |
npm pack --dry-run
# Relative destination: Windows runner temp paths contain
# backslashes that bash globbing would mangle.
mkdir -p dist-pack
npm pack --pack-destination dist-pack
- name: Cross-package e2e through the @ruvnet/ruview metaharness
working-directory: harness/ruview
run: |
npm ci --ignore-scripts
npm install --no-save --ignore-scripts ../ruview-kernel/dist-pack/ruvnet-ruview-kernel-*.tgz
node --test test/kernel.test.mjs
node bin/cli.js kernel --backend auto
- name: Upload built artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ruview-kernel-linux-x64
path: |
harness/ruview-kernel/wasm/ruview_kernel.wasm
harness/ruview-kernel/wasm/SHA256SUMS
harness/ruview-kernel/native/*.node
harness/ruview-kernel/native/SHA256SUMS
retention-days: 14
+8 -6
View File
@@ -104,8 +104,10 @@ 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/369-374: brain + hosts + replay + Spaces OAuth + SDK,
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
# widget, HTTP transport, terminal UI; ADR-378 mod showcase; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=458752 ;;
# 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).
@@ -140,12 +142,12 @@ jobs:
npm i --no-fund --no-audit "$TGZ"
case "${{ inputs.package }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
./node_modules/.bin/ruview guidance --topic homecore --query restore --limit 1 \
./node_modules/.bin/ruview-harness --version
./node_modules/.bin/ruview-harness doctor
./node_modules/.bin/ruview-harness guidance --topic homecore --query restore --limit 1 \
| grep -q '"homecore-runtime-restore"'
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
if ./node_modules/.bin/ruview-harness claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
+10 -10
View File
@@ -39,12 +39,12 @@ jobs:
- { label: 'ruflo', flags: '--features ruflo' }
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- name: Cache cargo
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
@@ -61,7 +61,7 @@ jobs:
name: clippy (-D warnings, --no-deps)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
# v2/rust-toolchain.toml pins channel "1.89" with profile "minimal" (no
@@ -69,12 +69,12 @@ jobs:
# toolchain, but the override makes cargo use the separate "1.89"
# toolchain — so `cargo clippy` errors "cargo-clippy is not installed for
# 1.89". Install clippy on the pinned toolchain that cargo actually uses.
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: "1.89"
components: clippy
- name: Cache cargo
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
@@ -96,12 +96,12 @@ jobs:
name: build train_marl bin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- name: Cache cargo
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/registry
@@ -132,7 +132,7 @@ jobs:
name: ITAR / publish guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: publish = false is present (no accidental crates.io publish)
+76 -107
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
@@ -26,14 +53,13 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: recursive
fetch-depth: 0
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -47,15 +73,18 @@ jobs:
- name: Run Bandit security scan
run: |
# The Python codebase lives under archive/v1/src (it moved there when
# the runtime was rewritten in Rust). Scanning `src/` matched nothing,
# so this SAST step was a silent no-op.
bandit -r archive/v1/src/ -f sarif -o bandit-results.sarif
# archive/v1 is frozen research code and is not shipped. Scan the
# maintained Python packages and operator scripts instead.
# Keep the Security tab actionable: publish high-severity findings.
# Medium/low findings are reviewed during focused local audits.
bandit -lll -r python/ scripts/ firmware/esp32-csi-node/ aether-arena/ \
-x '*/tests/*,*/test/*,*/test_*.py,*/bench/*' \
-f sarif -o bandit-results.sarif
continue-on-error: true
- name: Upload Bandit results to GitHub Security
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3 # v3
if: always()
with:
sarif_file: bandit-results.sarif
@@ -74,12 +103,16 @@ jobs:
semgrep \
--config=p/security-audit --config=p/secrets --config=p/python \
--config=p/docker --config=p/kubernetes \
--sarif --output=semgrep.sarif archive/v1/src/
--severity=ERROR \
--exclude='**/tests/**' --exclude='**/test/**' \
--exclude='**/test_*.py' --exclude='**/bench/**' \
--sarif --output=semgrep.sarif \
python/ scripts/ firmware/esp32-csi-node/ aether-arena/
continue-on-error: true
- name: Upload Semgrep results to GitHub Security
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3 # v3
if: always()
with:
sarif_file: semgrep.sarif
@@ -97,13 +130,11 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -135,7 +166,7 @@ jobs:
- name: Upload Snyk results to GitHub Security
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3 # v3
if: always()
with:
sarif_file: snyk-results.sarif
@@ -143,7 +174,7 @@ jobs:
- name: Upload vulnerability reports
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: vulnerability-reports
@@ -157,7 +188,6 @@ jobs:
name: Container Security Scan
runs-on: ubuntu-latest
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
needs: []
if: github.event_name == 'push' || github.event_name == 'schedule'
permissions:
security-events: write
@@ -166,20 +196,20 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: recursive
- name: Set up Docker Buildx
continue-on-error: true
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build Docker image for scanning
continue-on-error: true
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
target: production
file: docker/Dockerfile.rust
load: true
tags: wifi-densepose:scan
cache-from: type=gha
@@ -192,50 +222,21 @@ jobs:
image-ref: 'wifi-densepose:scan'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
limit-severities-for-sarif: true
- name: Upload Trivy results to GitHub Security
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3 # v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
category: trivy
- name: Run Grype vulnerability scanner
continue-on-error: true
uses: anchore/scan-action@v7
id: grype-scan
with:
image: 'wifi-densepose:scan'
fail-build: false
severity-cutoff: high
output-format: sarif
- name: Upload Grype results to GitHub Security
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.grype-scan.outputs.sarif }}
category: grype
- name: Run Docker Scout
continue-on-error: true
uses: docker/scout-action@v1
if: always()
with:
command: cves
image: wifi-densepose:scan
sarif-file: scout-results.sarif
summary: true
- name: Upload Docker Scout results
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: scout-results.sarif
category: docker-scout
# Trivy is the single container SARIF authority. Grype and Docker Scout
# produced duplicate alerts for the same image packages and obscured the
# actionable high/critical findings.
# Infrastructure as Code security scanning
iac-scan:
@@ -249,52 +250,25 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Run Checkov IaC scan
continue-on-error: true
uses: bridgecrewio/checkov-action@99bb2caf247dfd9f03cf984373bc6043d4e32ebf # v12.1347.0
with:
directory: .
framework: kubernetes,dockerfile,terraform,ansible
output_format: sarif
output_file_path: checkov-results.sarif
quiet: true
soft_fail: true
- name: Upload Checkov results to GitHub Security
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: checkov-results.sarif
category: checkov
- name: Run Terrascan IaC scan
continue-on-error: true
uses: tenable/terrascan-action@3a6e87da8e244513bd77b631e624552643f794c6 # v1.4.1
with:
iac_type: 'k8s'
iac_version: 'v1'
policy_type: 'k8s'
only_warn: true
sarif_upload: true
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Run KICS IaC scan
continue-on-error: true
uses: checkmarx/kics-github-action@05aa5eb70eede1355220f4ca5238d96b397e30a6 # v2.1.20
with:
path: '.'
# Scan RuView-owned operational IaC only. Submodules are audited and
# fixed in their owning repositories; archived/benchmark fixtures are
# intentionally not production infrastructure.
path: '.github/workflows,docker,logging,v2/crates/nvsim-server/Dockerfile'
output_path: kics-results
output_formats: 'sarif'
exclude_paths: '.git,node_modules'
exclude_queries: 'a7ef1e8c-fbf8-4ac1-b8c7-2c3b0e6c6c6c'
exclude_severities: 'info'
- name: Upload KICS results to GitHub Security
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3 # v3
if: always()
with:
sarif_file: kics-results/results.sarif
@@ -312,9 +286,8 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: recursive
fetch-depth: 0
- name: Run TruffleHog secret scan
@@ -328,7 +301,7 @@ jobs:
- name: Run GitLeaks secret scan
continue-on-error: true
uses: gitleaks/gitleaks-action@v2
uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
@@ -348,13 +321,11 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -374,7 +345,7 @@ jobs:
- name: Upload license report
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: license-report
path: licenses.json
@@ -387,9 +358,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Check security policy files
continue-on-error: true
@@ -444,7 +413,7 @@ jobs:
steps:
- name: Download all artifacts
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- name: Generate security summary
continue-on-error: true
@@ -464,7 +433,7 @@ jobs:
- name: Upload security summary
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: security-summary
path: security-summary.md
@@ -475,7 +444,7 @@ jobs:
- name: Notify security team on critical findings
continue-on-error: true
if: ${{ env.SECURITY_SLACK_WEBHOOK_URL != '' && (needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure' || needs.container-scan.result == 'failure') }}
uses: 8398a7/action-slack@v3
uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e # v3
with:
status: failure
channel: '#security'
@@ -491,7 +460,7 @@ jobs:
- name: Create security issue on critical findings
continue-on-error: true
if: needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure'
uses: actions/github-script@v6
uses: actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325 # v6
with:
script: |
github.rest.issues.create({
@@ -518,4 +487,4 @@ jobs:
**Security Dashboard:** Check the Security tab for detailed findings.
`,
labels: ['security', 'vulnerability', 'urgent']
})
})
+2 -2
View File
@@ -32,10 +32,10 @@ jobs:
WEAVER_SHA256: a9822c712d6871bd89d6530f18c5df5cea3821f642e7b8e5e49e985917f7d12d
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
components: rustfmt
- name: Install weaver
+7 -6
View File
@@ -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/**'
@@ -48,7 +49,7 @@ jobs:
name: build · push · smoke-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -56,9 +57,9 @@ jobs:
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
# by the runner, not built on a separate arm64 host).
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to Docker Hub
# Bypassing docker/login-action@v3: the action kept emitting
@@ -73,7 +74,7 @@ jobs:
printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin
- name: Log in to ghcr.io
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -81,7 +82,7 @@ jobs:
- name: Compute tags
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: |
docker.io/ruvnet/wifi-densepose
@@ -94,7 +95,7 @@ jobs:
- name: Build + push
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: .
file: docker/Dockerfile.rust
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
@@ -62,7 +62,7 @@ jobs:
ls -R _site/three.js/ | head -30
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: _site
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: true
fetch-depth: 0
+2 -2
View File
@@ -29,12 +29,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: ${{ matrix.python-version }}
+32 -6
View File
@@ -17,19 +17,29 @@ 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
# Claude Flow swarm runtime state
.swarm/
# CSI recordings (local training data, machine-specific)
# CSI recordings (local training/capture data — CSI is person data per
# CLAUDE.md; never commit). Covers current and legacy layouts. See ADR-299.
data/recordings/
v2/data/recordings/
rust-port/wifi-densepose-rs/data/recordings/
**/*.csi.jsonl
**/*.csi.meta.json
# NVS partition images and CSVs (contain WiFi credentials)
nvs.bin
@@ -298,4 +308,20 @@ 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/
# Trained adaptive classifier (auto-created at runtime; #2135)
/v2/data/adaptive_model.json
+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
+22
View File
@@ -7,8 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Community batch (PRs #2100-#2157 by aepod, reviewed and integrated)
Integrated as one set: conflicts resolved, review findings fixed, validated on the integrated branch. Hardware figures cited by the PRs are the author's own live runs on ESP32-S3 nodes and were not independently reproduced here.
- **Security and honest status.** Refuse a routable HTTP bind when API auth is off (#2134). `--privacy-mode` now strips biometrics from REST, WebSocket and recordings, not only MQTT (#2125; the stripped-key list is an exact-match allowlist, so a new biometric field must be added to it). `/api/v1/status` waits for a real frame and unknown `--source` values are rejected (#2126; breaking for `--source macos|linux|windows`). A stale `/sensing/latest` reports `{"status":"stale"}` after 10 s (#2119). Measured stream fps and real WebSocket client counts (#2127). Absent nodes no longer count as one person (#2132). ESP32 frames with zero antennas or subcarriers are dropped (#2118). Dead nodes are evicted on a timer (#2120). Recordings and models stay under `--data-dir` (#2129). Boolean env flags accept 1/0/yes/no/on/off (#2108). Docker UDP ingest posture is explicit (#2107).
- **CSI pipeline.** The grid gate locks on the majority frame shape and `/health` reports a stalled pipeline (#2157; a partial fix, mixed 192/306 frame shapes still drop frames, so it does not close #1894). Phase is combined across nodes only when they declare a shared reference (#2156). Point-cloud and hardware parsers use the ADR-018 layout and accept more than 256 subcarriers (#2155; the bandwidth rule for counts above 64 is CLAIMED, inferred from the datasheet). Per-node adaptive training from real recordings (#2101, #2103); a run that fell back to the live buffer now reports `degraded: true`. Gaussian decay no longer compounds across calls (#2140).
- **MediaTek (ADR-266/267).** Legacy and unknown PPDU types in MTC1 (#2112), per-device ingest capped at 64 devices (#2113), a labelled amplitude heuristic and activity index (#2114; CLAIMED, not an occupancy estimate), an MT7981 bridge (#2115), and the OpenWrt WN586X3 patch set and guide (#2116; the vendored CSI patch is BSD-3-Clause-Clear, see `NOTICE`).
- **MQTT / Home Assistant.** Semantic states wired into the publisher, entities announced only when sourced, persisted client id (#2117). Existing Home Assistant installs keep retained discovery configs for the six removed entities until they are cleared manually.
- **Provisioning and firmware.** Wi-Fi password kept owner-only and out of argv and `--state` (#2153); state keyed by board MAC (#2152); `--dry-run` and a failed `--reset` leave state intact (#2111); OTA images confirmed by a bounded health check, which means only "reached the network and sent CSI" (#2100, ADR-379).
- **Ground truth and spatial.** Radar window adapter and auto-labeller with a session-disjoint split and a median count baseline; radar labels carry `evidence: CLAIMED` (#2143, #2144). Node self-survey, check-only (#2145, ADR-381). The `spatial.evidence.v1` export defaults to the CODE proof grade and MEASURED requires a reproducer (#2141, ADR-382). Loopback UDP tee (#2102, ADR-380).
- **Tooling.** `prove.sh` reports INCOMPLETE instead of PASS when a gate is skipped (#2130); ADR-018 capture scripts (#2131); API token on the ground-truth script (#2104); the UI asks for the API token instead of retrying forever (#2128); macOS redacted-link handling (#2105).
- **Not included:** #2106 (MacWifi.app, draft) and #2142 (MR60 probe change, needs a captured MR60BHA2 boot log). `v2/data/adaptive_model.json` (41.5% training accuracy) is no longer tracked (#2135).
### Added
- **`@ruvnet/ruview` 0.9.1: live sensing views inside Claude Code (ADR-377, ADR-378).** The package now ships `ruview-live`, a Claude Code mod. Type `/ruview` and a pane opens next to the conversation showing what your sensors see right now, refreshed automatically. It has three views, switched with keys `1` `2` `3` or with `/ruview overview|waterfall|radar`:
- **Overview**: the CSI nodes streaming to your machine (rate, packet loss, signal strength) and an optional 60 GHz radar kit (presence, distance, heart and breathing rates).
- **CSI waterfall**: the Wi-Fi signal itself, the strength of every subcarrier over time, drawn in colour and scrolling as frames arrive. The new `ruview esp32 --spectrum` option supplies the data.
- **Radar**: a top-down range fan with an animated ping out to the measured distance, plus heart, breathing and distance charts.
Every reading is labelled honestly: MEASURED or SYNTHETIC from the packets themselves; radar vitals as "device-reported, not validated"; range-only radar as "distance, not direction". The pane only reads; it never flashes or configures a device.
Verified on real hardware: an ESP32-C6 streaming CSI and a Seeed MR60BHA2 radar kit (MEASURED; reproducer `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json`). The package's size ceiling rises from 384 KiB to 448 KiB; it still has no runtime dependencies.
- **`wifi-densepose-sar` — coherent wideband RF tomography research crate (ADR-287).** New standalone leaf crate (the `nvsim` pattern; zero coupling to `wifi-densepose-hardware` or any real ingestion path) implementing the synthetic-aperture-radar reconstruction primitive a handheld through-wall RF imaging device would need — motivated by comparison against Applied Electrodynamics' "WaveSight" launch, and explicitly scoped below ADR-278's RISE/DiffRadar/GeRaF reproduction gates. Ships: (1) a stepped-frequency, multi-position complex forward measurement simulator (`y_{m,k} = Σ σ_j/R² · exp(-i·4π·f·R/c) + noise`, deterministic ChaCha20 seeding); (2) delay-and-sum backprojection reconstruction onto a 3D voxel grid, rayon-parallelized over voxels; (3) threshold + local-maximum point-cloud extraction; (4) closed-form range/cross-range resolution and antenna-pose coherence-budget formulas (`ΔR=c/2B`, `δ_CR≈λR/2L`, `Δp≤λ/8`) checked against the reconstruction's *actual* behavior in `tests/physics_validation.rs` rather than merely documented — forward-simulating two targets at controlled separations and proving they resolve or merge exactly where the formulas predict, and that reconstructed focus at a known target degrades as injected antenna-pose error grows. Every number is SYNTHETIC/L0 (ADR-282) — no real wideband RF hardware backs this crate; see the crate README and `docs/tutorials/coherent-rf-tomography-backprojection.md` for the full honesty boundary and a worked walkthrough. `focus_at_point` exploits the evenly-spaced-by-construction frequency sweep (an arithmetic progression in per-term phase) to evaluate each pose's phasor once and advance it by a fixed complex-multiply step per frequency instead of one `sin`/`cos` pair per frequency — **MEASURED ~4.4-4.5x faster** (criterion regression detection, p < 0.001) than the first-shipped direct-computation version, proven equivalent (not just faster) to an independently reimplemented reference across four sweep sizes and on-/off-target points. 25 tests (22 unit + 3 integration), 0 failed, clippy-clean; MEASURED backprojection throughput ~1.7-2.3M voxels/sec (criterion, 21 poses × 32 freq steps).
- **HOMECORE platform runtime completion — secure native/Wasmtime plugins, authenticated HAP IP, expanded Home Assistant APIs, durable restoration/migration, and voice protocols.** `homecore-server` now owns deterministic compiled-in native plugin registration plus explicitly configured, path-bounded, Ed25519 publisher-verified Wasm packages executed through Wasmtime with setup/state-change/teardown lifecycle; arbitrary native dynamic libraries remain intentionally unsupported. The optional HAP server implements persisted accessory identity and controller records, SRP-6a Pair-Setup M1–M6, X25519/Ed25519 Pair-Verify M1–M4, HKDF-SHA512/ChaCha20-Poly1305 record framing, authenticated/admin endpoint gates, replay/tamper closure, live entity synchronization, and paired-state `_hap._tcp` mDNS updates (45 focused tests; external Apple certification is not claimed). Startup restores device/entity registries and deterministic latest recorder states before plugins, and migration now atomically preserves forward-compatible device/config-entry fields. The HA-compatible surface adds events, templates, config checks, components, registries, history/logbook with SQL-enforced global response bounds, calendar/camera provider routes, and modern WebSocket negotiation while retaining a machine-readable limitations matrix for integration-specific behavior. Assist adds bounded PCM16, async STT/TTS contracts, an end-to-end speech pipeline, and an authenticated satellite session protocol; real deployments still provide the speech engines.
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
@@ -25,6 +46,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,
+22
View File
@@ -0,0 +1,22 @@
RuView third-party notices
This repository is licensed MIT OR Apache-2.0 (see LICENSE). The items below
are vendored third-party material that keeps its own licence and is not
covered by that dual licence.
firmware/openwrt-wn586x3/patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch
What: MediaTek's mt76 mt7915 CSI patch, rebased onto openwrt/mt76
eb567bc7 (the revision pinned by OpenWrt 24.10.8).
Author: Bo Jiao <Bo.Jiao@mediatek.com> (original author line kept in the
patch header); copyright MediaTek Inc.
Origin: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
Licence: BSD-3-Clause-Clear (SPDX identifier,
https://spdx.org/licenses/BSD-3-Clause-Clear.html). The patch
carries the SPDX tags and MediaTek copyright lines in its added
files; the licence text is not reproduced here.
Scope: A vendored patch applied to an OpenWrt/mt76 build. It is not part
of the Rust crates, is not compiled by the Cargo workspace, and is
not linked into any RuView binary.
See firmware/openwrt-wn586x3/README.md for the other patches in that
directory and the licence each one keeps.
+72 -28
View File
@@ -2,20 +2,17 @@
<p align="center">
<a href="https://cognitum.one/seed">
<img src="assets/ruview-seed.png" alt="RuView - WiFi DensePose" 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>
<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%">
<a href="https://ruos.cognitum.one">
<img src="assets/ruos-animated.svg" alt="RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace" 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,28 +46,56 @@ 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;
- device access for CSI nodes, radar and LiDAR;
- firmware flashing with boot evidence;
- one MCP server, usable over stdio or HTTP for ChatGPT, with a live console widget;
- a Claude Code mod;
- guarded Claude Code/Codex agents;
- deterministic verification;
- an honesty check for accuracy claims.
Companion packages: [`homecore`](harness/homecore/README.md) (Homecore developer metaharness) and [`@ruvnet/ruview-kernel`](harness/ruview-kernel/README.md) (the vitals pipeline as WASM).
```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.9.1 doctor
npx @ruvnet/ruview@0.9.1 guidance --topic sensing --query "model loading"
# Hardware: what is plugged in, live CSI from your nodes, a 60 GHz radar kit
npx @ruvnet/ruview@0.9.1 devices
npx @ruvnet/ruview@0.9.1 esp32 --watch # ESP32 + Realtek RAC1 nodes, live view
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host <kit-ip>
# Agents: MCP over stdio, or over HTTP for ChatGPT (token-protected, read tools only)
npx @ruvnet/ruview@0.9.1 mcp start
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http
# Claude Code: a live sensing pane (/ruview), shipped in the package
npx @ruvnet/ruview@0.9.1 mod
# Run a read-only RuView agent through Codex
npx @ruvnet/ruview@0.3.1 agent run --host codex --repo . \
npx @ruvnet/ruview@0.9.1 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 .
# 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
# Check claims, replay the deterministic proof, search the reviewed brain
npx @ruvnet/ruview@0.9.1 claim-check --file REPORT.md
npx @ruvnet/ruview@0.9.1 verify
npx @ruvnet/ruview@0.9.1 brain search --query "calibration"
```
Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`; retrieved brain content is evidence, not authority.
**Safety:**
- Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`.
- Hardware reads need the `device-access` grant.
- Flashing and calibration never run over the HTTP transport.
- Device-reported vitals are labelled as unvalidated.
- Retrieved brain content is evidence, not authority.
A single `npx ruview` package bundling all of this is ready (ADR-376). It is waiting on npm to release the unscoped name.
Full walkthrough: [user guide → RuView npm toolkit](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
</details>
@@ -122,8 +147,11 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
```bash
# Option 1: Docker (simulated data, no hardware needed)
docker pull ruvnet/wifi-densepose:latest
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
export RUVIEW_API_TOKEN=$(openssl rand -hex 32) # required; the container exits 64 without it
docker run -p 127.0.0.1:3000:3000 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
# Open http://localhost:3000
# ESP32 nodes in Docker also need a UDP source guard (RUVIEW_UDP_ALLOW);
# see docs/user-guide.md "Receiving ESP32 frames in Docker".
# Option 2a: Live sensing with ESP32-S3 hardware ($9)
# Flash firmware, provision WiFi, and start sensing:
@@ -223,7 +251,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/wif
| Consumer | Format used | Status |
|----------|-------------|--------|
| Python training / evaluation / embedding extraction | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
| Python training / evaluation / embedding extraction | `model.safetensors` | ⚠️ The published file's header is NUL-padded, which the reference `safetensors.torch.load_file` rejects (issue [#1522](https://github.com/ruvnet/RuView/issues/1522)) — pending a corrected re-upload. `csi-embed-v2.safetensors` in the same repo is unaffected and loads normally. |
| Inspect / re-export the bundle | `model.rvf.jsonl` (line-by-line JSON) | ✅ Works — plain JSONL |
| Sensing-server `--model <PATH>` flag | native RVF, `model.safetensors`, or `model.rvf.jsonl` | ✅ Native RVF loads directly; safetensors and JSONL auto-convert in memory |
@@ -244,8 +272,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 +515,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 +563,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 +730,13 @@ 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).
**Live sensing pane (Claude Code mod):** the same marketplace also lists `ruview-live`, a Claude Code mod.
- Install it with `/plugin install ruview-live@ruview`, or run `npx @ruvnet/ruview@0.9.1 mod` for its path inside the npm package.
- `/ruview` opens a pane beside the transcript with your CSI nodes and radar, refreshed on a timer, plus a one-line status.
- It is read-only.
- Mods are early access; if `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. See [ADR-377](docs/adr/ADR-377-ruview-live-claude-code-mod.md).
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick commands and a fuller explanation are in the collapsed MetaHarness section near the top of this README, in [`harness/ruview/`](harness/ruview/README.md), and in the [user guide](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
</details>
@@ -719,6 +761,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
```
+4 -1
View File
@@ -36,7 +36,10 @@ def main():
dev = a.device
net = PoseNet().to(dev)
net.load_state_dict(torch.load(a.base, map_location=dev), strict=False)
# Checkpoints are tensor state dictionaries; never invoke pickle object loading.
net.load_state_dict(
torch.load(a.base, map_location=dev, weights_only=True), strict=False
)
net.add_lora(r=a.rank).to(dev)
for k, p in net.named_parameters():
p.requires_grad = k.endswith(".A") or k.endswith(".B")
+4 -1
View File
@@ -25,7 +25,10 @@ def main():
dev = a.device
net = PoseNet().to(dev)
net.load_state_dict(torch.load(a.base, map_location=dev), strict=False)
# Checkpoints are tensor state dictionaries; never invoke pickle object loading.
net.load_state_dict(
torch.load(a.base, map_location=dev, weights_only=True), strict=False
)
if a.adapter:
net.add_lora(r=a.rank).to(dev)
z = np.load(a.adapter)
+33
View File
@@ -10,6 +10,39 @@ 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] = [
// Always the connected link. macOS redacts ssid/bssid without
// Location Services; rssi/noise/channel stay real, so mark the
// sample as the connected link for the Rust adapter.
"connected": true,
"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

+87
View File
@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">
<title id="title">RuView · WiFi becomes spatial awareness.</title><desc id="desc">RuView-focused animation showing WiFi sensors, spatial perception, Ruflo coordination and an illustrative ruOS sensing workspace. Room occupancy and actions are synthetic examples, not live measurements.</desc>
<defs><radialGradient id="bg"><stop stop-color="#151518" /><stop offset="1" stop-color="#07080b" /></radialGradient><radialGradient id="aura"><stop stop-color="#edc65a" stop-opacity=".23" /><stop offset=".4" stop-color="#ba8823" stop-opacity=".07" /><stop offset="1" stop-color="#ba8823" stop-opacity="0" /></radialGradient><linearGradient id="gold" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff4b1" /><stop offset=".5" stop-color="#ebc456" /><stop offset="1" stop-color="#96702f" /></linearGradient><linearGradient id="panel" x2="0" y2="1"><stop stop-color="#191a20" /><stop offset="1" stop-color="#0d0e13" /></linearGradient><pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse"><circle cx="18" cy="18" r=".65" fill="#d4c78e" opacity=".22" /></pattern><filter id="bloom" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="4" /></filter><clipPath id="frame"><rect width="960" height="540" rx="18" /></clipPath><clipPath id="line1"><rect x="64" y="155" width="850" height="93" /></clipPath><clipPath id="line2"><rect x="64" y="244" width="850" height="100" /></clipPath><g id="crown" fill="url(#gold)"><path d="M0 5l8 7L15 0l7 12 8-7-4 23H4z" /><rect x="4" y="31" width="22" height="3" rx="1" /></g>
<style>
text{font-family:Arial,Helvetica,sans-serif;fill:#f4f2ed}.mono{font-family:monospace}.muted{fill:#888993}.gold{fill:#e8c565}.scene{opacity:0}.end{opacity:1}.wire{fill:none;stroke:#c2a155;stroke-width:1}.all{animation-duration:18s;animation-iteration-count:infinite;animation-timing-function:cubic-bezier(.22,.65,.3,1)}
@keyframes intro{0%,17%{opacity:1;transform:translateY(0)}22%,94%{opacity:0;transform:translateY(-12px)}100%{opacity:1;transform:translateY(0)}}
@keyframes network{0%,20%{opacity:0;transform:translateY(18px) scale(.98)}25%,44%{opacity:1;transform:translateY(0) scale(1)}49%,100%{opacity:0;transform:translateY(-15px) scale(1.02)}}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}51%,66%{opacity:1;transform:translateY(0)}71%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,69%{opacity:0}74%,92%{opacity:1}97%,100%{opacity:0}}
.intro{animation-name:intro}.network{animation-name:network;transform-origin:480px 270px}.work{animation-name:work}.end{animation-name:finale}
@keyframes reveal1{0%,69%{transform:translateY(100px)}77%,100%{transform:translateY(0)}}@keyframes reveal2{0%,73%{transform:translateY(110px)}81%,100%{transform:translateY(0)}}.headline1{animation-name:reveal1}.headline2{animation-name:reveal2}
@keyframes breathe{50%{opacity:.6;transform:scale(1.12)}}.breathe{animation:breathe 4.5s ease-in-out infinite;transform-origin:480px 239px}
@keyframes spin{to{transform:rotate(360deg)}}.orbit{animation:spin 18s linear infinite;transform-origin:480px 239px}
@keyframes send{0%,9%{transform:translate(0,0)}14%,100%{transform:translate(-14px,-19px)}}.cursor{animation-name:send}
@keyframes type{0%,2%{clip-path:inset(0 100% 0 0)}12%,100%{clip-path:inset(0 0 0 0)}}.typing{animation-name:type;animation-timing-function:steps(26,end)}
@keyframes task{0%,49%{transform:translateY(25px);opacity:0}55%,100%{transform:translateY(0);opacity:1}}.task{animation-name:task}.task2{animation-delay:.3s}.task3{animation-delay:.6s}
@keyframes grow{0%,54%{transform:scaleX(0)}63%,100%{transform:scaleX(1)}}.grow{animation-name:grow;transform-box:fill-box;transform-origin:left}
@keyframes tick{0%,62%{opacity:0}65%,100%{opacity:1}}.tick{animation-name:tick}
@keyframes trace{0%,22%{stroke-dashoffset:1}38%,100%{stroke-dashoffset:0}}.trace{stroke-dasharray:1;animation-name:trace}
@keyframes drift{50%{transform:translateY(-10px);opacity:.8}}.dust{animation:drift 9s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){*{animation:none!important}.scene{opacity:0}.end{opacity:1}.moving{display:none}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(215px,155px)}55%{transform:translate(479px,155px)}61%{transform:translate(743px,155px)}72%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
.signal{animation-name:signal}.signal-glow{animation:shimmer 3s ease-in-out infinite}@keyframes shimmer{50%{opacity:.45}}
@keyframes haloExpand{0%,12%{opacity:0;transform:scale(.5)}16%{opacity:.7}23%,100%{opacity:0;transform:scale(2.5)}}.clickring{animation-name:haloExpand;transform-origin:708px 301px}
@keyframes aurora{0%,100%{transform:translate(-30px,10px) rotate(-4deg)}50%{transform:translate(30px,-10px) rotate(4deg)}}.aurora{animation:aurora 18s ease-in-out infinite;transform-origin:480px 270px}
@keyframes dash{to{stroke-dashoffset:-80}}.stream{stroke-dasharray:2 18;animation:dash 3s linear infinite}
@keyframes count{0%,52%{opacity:0;transform:translateY(8px)}58%,100%{opacity:1;transform:translateY(0)}}.detail{animation-name:count}
@keyframes buildLine{0%,51%{stroke-dashoffset:1}64%,100%{stroke-dashoffset:0}}.buildLine{stroke-dasharray:1;animation-name:buildLine}
@keyframes underline{0%,78%{transform:scaleX(0)}85%,100%{transform:scaleX(1)}}.underline{animation-name:underline;transform-origin:66px 350px}
@keyframes dock{0%,49%{opacity:0;transform:translateY(14px)}55%,100%{opacity:1;transform:translateY(0)}}.dock{animation-name:dock}
@media(prefers-reduced-motion:reduce){.signal,.clickring{display:none}}
@keyframes vortexCW{to{transform:rotate(360deg)}}@keyframes vortexCCW{to{transform:rotate(-360deg)}}
.vortex{transform-origin:480px 270px;animation:vortexCW 36s linear infinite}.counter{transform-origin:480px 270px;animation:vortexCCW 54s linear infinite}
@keyframes current{to{stroke-dashoffset:-1200}}.current{animation:current 9s linear infinite}
@keyframes bgEnergy{0%,12%,100%{opacity:.22}22%,40%{opacity:.6}52%,65%{opacity:.4}74%,84%{opacity:.7}94%{opacity:.28}}.bgEnergy{animation:bgEnergy 18s ease-in-out infinite}
@keyframes wave{0%{opacity:0;transform:scale(.3)}20%{opacity:.35}80%{opacity:.07}100%{opacity:0;transform:scale(1.9)}}.wave{transform-origin:480px 270px;animation:wave 9s cubic-bezier(.15,.35,.5,1) infinite}
@keyframes glimmer{0%,100%{opacity:.08}50%{opacity:.72}}.glimmer{animation:glimmer 6s ease-in-out infinite}
@keyframes comet{0%{opacity:0;transform:translate(-160px,70px)}12%{opacity:.65}58%{opacity:.25}75%,100%{opacity:0;transform:translate(1060px,-300px)}}.comet{animation:comet 9s linear infinite}
@keyframes beam{0%,100%{transform:translate(-150px,0) rotate(-12deg);opacity:0}35%{opacity:.18}65%{opacity:.07;transform:translate(1050px,0) rotate(-12deg)}80%{opacity:0}}.beam{animation:beam 18s ease-in-out infinite;transform-origin:center}
@media(prefers-reduced-motion:reduce){.dynamic-bg{display:none}}
.all{animation-duration:24s}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}50%,72%{opacity:1;transform:translateY(0)}76%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,75%{opacity:0}79%,93%{opacity:1}97%,100%{opacity:0}}
@keyframes reveal1{0%,75%{transform:translateY(100px)}82%,100%{transform:translateY(0)}}
@keyframes reveal2{0%,78%{transform:translateY(110px)}85%,100%{transform:translateY(0)}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(803px,182px)}55%{transform:translate(803px,235px)}63%,72%{transform:translate(803px,288px)}80%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
@keyframes browserOpen{0%,48%{opacity:0;transform:translateY(20px) scale(.96)}53%,100%{opacity:1;transform:translateY(0) scale(1)}}.browserOpen{animation-name:browserOpen;transform-origin:380px 230px}
@keyframes terminalOpen{0%,53%{opacity:0;transform:translateY(14px)}58%,100%{opacity:1;transform:translateY(0)}}.terminalOpen{animation-name:terminalOpen}
@keyframes terminalText{0%,57%{opacity:0}60%,100%{opacity:1}}.terminalText{animation-name:terminalText}
@keyframes done{0%,65%{opacity:0}69%,100%{opacity:1}}.done{animation-name:done}
@keyframes pointerMove{0%,50%{transform:translate(350px,275px)}55%{transform:translate(529px,267px)}59%{transform:translate(570px,325px)}66%,100%{transform:translate(738px,346px)}}.desktopPointer{animation-name:pointerMove}
@keyframes chartGrow{0%,53%{transform:scaleY(.05)}63%,100%{transform:scaleY(1)}}.chartGrow{animation-name:chartGrow;transform-box:fill-box;transform-origin:bottom}
</style><radialGradient id="readability"><stop stop-color="#08090e" stop-opacity=".58" /><stop offset=".65" stop-color="#08090e" stop-opacity=".38" /><stop offset="1" stop-color="#08090e" stop-opacity="0" /></radialGradient><linearGradient id="beamGold"><stop stop-color="#e7bf56" stop-opacity="0" /><stop offset=".5" stop-color="#efd57f" stop-opacity=".45" /><stop offset="1" stop-color="#e7bf56" stop-opacity="0" /></linearGradient></defs><g clip-path="url(#frame)"><rect width="960" height="540" fill="url(#bg)" /><rect width="960" height="540" fill="url(#grid)" opacity=".35" /><ellipse cx="490" cy="250" rx="460" ry="320" fill="url(#aura)" />
<g><g class="aurora" fill="none" stroke="#c6a457" opacity=".11"><ellipse cx="480" cy="260" rx="470" ry="156" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="480" ry="166" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="495" ry="180" transform="rotate(-18 480 260)" /></g></g><g><g class="dynamic-bg"><g class="bgEnergy"><rect class="beam" x="0" y="-120" width="160" height="850" fill="url(#beamGold)" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-0s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-3s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-6s" /><g class="vortex" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><g class="counter" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><circle cx="10.1" cy="303.9" r="1.3" fill="#ffe4a0Line truncated
<g class="scene intro all"><circle cx="480" cy="205" r="125" fill="url(#aura)" /><circle cx="480" cy="205" r="28" stroke="#f3d576" stroke-width="1" fill="none" /><text x="480" y="126" text-anchor="middle" font-size="32" letter-spacing="-1">Your room is already sending signals.</text>
<rect x="215" y="269" width="530" height="64" rx="12" fill="url(#panel)" stroke="#554b31" /><text x="239" y="307" font-size="17" class="mono typing all">Sense. Understand. Act.</text><rect x="689" y="282" width="38" height="38" rx="9" fill="url(#gold)" /><path d="M708 307v-13m-5 5 5-5 5 5" fill="none" stroke="#222018" stroke-width="2" stroke-linecap="round" />
<path class="cursor all" d="M731 323l0 27 7-8 8 1z" fill="#faf8e7" stroke="#151515" stroke-width="1.4" />
<text x="480" y="382" text-anchor="middle" font-size="11" letter-spacing="3" class="mono muted">WIFI SENSING. NO CAMERAS.</text><g><circle class="clickring all" cx="708" cy="301" r="25" fill="none" stroke="#fae29b" /></g><g><g fill="none" stroke="#e7c467" opacity=".65"><path d="M437 214Q480 161 523 214" /><path d="M420 205Q480 133 540 205" opacity=".4" /><path d="M404 196Q480 108 556 196" opacity=".2" /></g></g></g>
<g class="scene network all"><text x="480" y="107" text-anchor="middle" font-size="30" letter-spacing="-.7">From radio signals to useful actions.</text>
<g><path d="M480 239C548 323 639 357 735 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C420 328 311 358 225 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C520 143 640 148 714 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C440 148 322 143 247 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><path d="M480 239C440 148 322 143 247 193" class="wire" opacity=".2" /><path d="M480 239C440 148 322 143 247 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle></g><path d="M480 239C520 143 640 148 714 193" class="wire" opacity=".2" /><path d="M480 239C520 143 640 148 714 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle></g><path d="M480 239C420 328 311 358 225 350" class="wire" opacity=".2" /><path d="M480 239C420 328 311 358 225 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle></g><path d="M480 239C548 323 639 357 735 350" class="wire" opacity=".2" /><path d="M480 239C548 323 639 357 735 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle></g><circle cx="480" cy="239" r="98" fill="url(#aura)" class="breathe" /><circle cx="480" cy="239" r="57" fill="#111115" stroke="#6c5830" /><circle cx="480" cy="239" r="66" fill="none" stroke="#eac55e" stroke-opacity=".6" stroke-dasharray="38 170" class="orbit" /><use href="#crown" transform="translate(465 221)" /><rect x="147" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="164" cy="191" r="3" fill="#e8c667" /><text x="177" y="195" font-size="18">WiFi sensors</text><text x="177" y="212" font-size="8" class="mono muted" letter-spacing="1.8">CAPTURE</text><rect x="614" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="631" cy="191" r="3" fill="#e8c667" /><text x="644" y="195" font-size="18">RuView</text><text x="644" y="212" font-size="8" class="mono muted" letter-spacing="1.8">PERCEIVE</text><rect x="125" y="326" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="142" cy="348" r="3" fill="#e8c667" /><text x="155" y="352" font-sizeLine truncated
<g class="scene work all"><g><text x="480" y="92" text-anchor="middle" font-size="28" letter-spacing="-.6">A sensing workspace, powered by RuView.</text>
<rect x="58" y="113" width="844" height="331" rx="12" fill="#090c12" stroke="#6a5931" />
<path d="M70 144H890" stroke="#292b31" /><circle cx="76" cy="129" r="3" fill="#dcbf65" /><circle cx="87" cy="129" r="3" fill="#5b5c64" /><circle cx="98" cy="129" r="3" fill="#5b5c64" /><text x="480" y="133" text-anchor="middle" class="mono muted" font-size="9">ruOS / Desktops / ruview-workspace</text>
<rect x="59" y="145" width="125" height="287" fill="#0c0e13" />
<use href="#crown" transform="translate(72 158) scale(.4)" /><text x="91" y="170" font-size="15" font-weight="700">ruOS</text>
<rect x="67" y="209" width="107" height="25" rx="5" fill="#302819" stroke="#63512c" />
<rect x="76" y="194" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="201" font-size="10" class="muted">Overview</text><rect x="76" y="222" width="7" height="6" rx="1" fill="none" stroke="#e1c577" /><text x="91" y="229" font-size="10" class="gold">Desktops</text><rect x="76" y="250" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="257" font-size="10" class="muted">Terminal</text><rect x="76" y="278" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="285" font-size="10" class="muted">Swarms</text><rect x="76" y="306" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="313" font-size="10" class="muted">Network</text><rect x="76" y="334" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="341" font-size="10" class="muted">Usage</text><rect x="76" y="362" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="369" font-size="10" class="muted">Settings</text><text x="200" y="169" font-size="14" font-weight="700">RuView workspace</text><circle cx="364" cy="165" r="3" fill="#b9cb91" /><text x="374" y="169" font-size="9" class="muted">Running</text>
<text x="882" y="167" text-anchor="end" font-size="8" class="mono gold">SYNTHETIC DEMO</text>
<rect x="197" y="183" width="469" height="244" rx="7" fill="#14131a" stroke="#32313d" /><path d="M197 373Q370 180 666 285M197 330Q440 390 666 210" fill="none" stroke="#b49548" opacity=".18" />
<g class="browserOpen all"><rect x="209" y="195" width="308" height="163" rx="7" fill="#191d26" stroke="#5a5961" />
<rect x="209" y="195" width="308" height="24" rx="7" fill="#262a33" /><text x="221" y="211" font-size="8" class="mono muted">RuView / Room overview</text><circle cx="503" cy="207" r="2" fill="#e3c477" />
<text x="225" y="241" font-size="13" font-weight="700">Room activity</text><text x="225" y="257" font-size="9" class="muted">Synthetic scene · Presence and motion</text>
<rect x="224" y="329" width="83" height="16" rx="4" fill="#4a3d22" /><text x="233" y="340" font-size="8" class="gold">Sensor connected</text><g><path d="M229 270H494V318H229zM390 270v18m0 12v18" fill="#121921" stroke="#727781" stroke-width="1.5" /><path d="M235 278h34v15h-34zM464 280h22v24h-22z" fill="#303843" stroke="#707786" /><circle cx="285" cy="300" r="4" fill="#f0cd70" /><circle cx="285" cy="300" r="16" fill="none" stroke="#d8b353" opacity=".4" /><circle cx="326" cy="287" r="5" fill="#b7cbd0" /><path d="M326 293v12m-7-8h14m-7 8-5 8m5-8 5 8" fill="none" stroke="#b7cbd0" stroke-width="2" /><path d="M292 290Q310 273 326 287M294 306Q312 312 326 302" fill="none" stroke="#e1bd5f" stroke-dasharray="3 5" class="stream" /></g></g>
<g class="terminalOpen all"><rect x="366" y="287" width="284" height="117" rx="6" fill="#090c12" stroke="#777061" /><rect x="367" y="288" width="282" height="21" rx="5" fill="#25272f" /><text x="380" y="302" font-size="9" class="mono">Terminal</text>
<text x="380" y="324" font-size="9" class="mono gold">› Room event received</text><g class="terminalText all"><text x="380" y="343" font-size="9" class="mono muted">✓ Presence detected (demo)</text><text x="380" y="358" font-size="9" class="mono muted">✓ Rule checked</text></g><text x="380" y="384" font-size="10" class="mono gold done all">✓ Action ready for review</text></g>
<rect x="680" y="183" width="207" height="244" rx="7" fill="#12151d" stroke="#33333c" /><text x="695" y="204" font-size="12" font-weight="700">Ruflo agent team</text><path d="M694 216h178" stroke="#34323a" />
<circle cx="699" cy="235" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="239" font-size="11">Sense</text><text x="713" y="254" font-size="8" class="muted">Read room events from RuView</text><circle cx="699" cy="288" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="292" font-size="11">Evaluate</text><text x="713" y="307" font-size="8" class="muted">Check the configured rule</text><circle cx="699" cy="341" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="345" font-size="11">Act</text><text x="713" y="360" font-size="8" class="muted">Request an approved action</text><g class="done all"><rect x="693" y="381" width="181" height="29" rx="5" fill="#332b1b" stroke="#89713b" /><text x="784" y="400" text-anchor="middle" font-size="10" class="gold">✓ Room event processed</text></g>
<g class="desktopPointer all"><path d="M0 0v20l5-6 8 1z" fill="#fff2ce" stroke="#18181a" stroke-width="1" /></g>
<text x="480" y="469" text-anchor="middle" class="gold" font-size="14">Example: room activity → rule check → action review.</text></g></g><g class="scene end all"><ellipse cx="810" cy="290" rx="260" ry="240" fill="url(#aura)" />
<text x="66" y="133" class="mono gold" font-size="11" letter-spacing="3">RUVIEW PERCEPTION. RUFLO COORDINATION.</text>
<g clip-path="url(#line1)"><text class="headline1 all" x="61" y="233" font-size="72" font-weight="700" letter-spacing="-3">WiFi becomes</text></g>
<g clip-path="url(#line2)"><text class="headline2 all" x="61" y="322" font-size="72" font-weight="700" letter-spacing="-3">spatial awareness<tspan class="gold">.</tspan></text></g>
<path d="M66 382H894" stroke="#3b3528" /><text x="66" y="420" class="mono gold" font-size="17">ruos.cognitum.one</text><text x="892" y="420" text-anchor="end" font-size="10" class="mono muted" letter-spacing="2">BY COGNITUM</text><g><path class="underline all" d="M66 350h310" stroke="url(#gold)" stroke-width="2" /></g></g>
<path d="M40 490H920" stroke="#26242a" /><text x="40" y="513" class="mono muted" font-size="9" letter-spacing="1.5">RUVIEW + RUFLO + RUOS</text><circle cx="853" cy="509" r="2.5" fill="#dfbf67" /><text x="866" y="512" class="mono gold" font-size="9">READY</text><g><g class="signal all"><circle r="45" fill="url(#aura)" class="signal-glow" /><circle r="10" fill="#ebc65d" opacity=".5" filter="url(#bloom)" /><circle r="4.5" fill="#ffe5a1" /><circle r="8" fill="none" stroke="#e9c56c" opacity=".3" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 37 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
}
}
-2
View File
@@ -75,8 +75,6 @@ RUN set -e; \
# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
# set to enforce `Authorization: Bearer <token>` (see bearer_auth module, #443).
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
ENV RUVIEW_API_TOKEN=
# HTTP API
EXPOSE 3000
# WebSocket
+59 -6
View File
@@ -1,14 +1,14 @@
version: "3.9"
services:
sensing-server:
build:
context: ..
dockerfile: docker/Dockerfile.rust
image: ruvnet/wifi-densepose:latest
# ESP32 CSI must accept LAN UDP; TCP APIs below remain loopback-only.
# kics-scan ignore-line
ports:
- "3000:3000" # REST API
- "3001:3001" # WebSocket
- "127.0.0.1:3000:3000" # REST API
- "127.0.0.1:3001:3001" # WebSocket
# ESP32 UDP. On Linux/macOS this works with multiple ESP32 nodes out of
# the box. On Docker Desktop for Windows, multi-source UDP is collapsed
# to one source IP at the WSL/Hyper-V boundary, so all-but-one node's
@@ -37,6 +37,45 @@ services:
# volumes: ["/path/to/models:/app/models"]
# MODELS_DIR=/app/models
- MODELS_DIR=${MODELS_DIR:-data/models}
# Auth (issue #864). The entrypoint refuses to start (exit 64) unless
# RUVIEW_API_TOKEN is set. Export it before `docker compose up`:
# export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
# Bare names pass the host value through only when it is set.
- RUVIEW_API_TOKEN
# ESP32 UDP ingest (ADR-296, issue #2089). The UDP receiver defaults to
# 127.0.0.1, which cannot receive from the published 5005/udp port.
# Set ONE source guard and the entrypoint binds 0.0.0.0:
# RUVIEW_UDP_ALLOW=<node-ip-or-cidr> restrict sources (preferred)
# RUVIEW_UDP_INSECURE_LAN=true accept the spoofing risk
# Docker Desktop and OrbStack on macOS rewrite the source address to the
# bridge gateway, so a LAN-subnet allowlist drops every frame. There,
# allow the gateway instead:
# docker network inspect <project>_default \
# --format '{{(index .IPAM.Config 0).Gateway}}'
# RUVIEW_UDP_ALLOW=<gateway>/32
# The gateway admits anything that reaches the published port, so keep
# 5005/udp off untrusted networks.
- RUVIEW_UDP_ALLOW
- RUVIEW_UDP_INSECURE_LAN
- RUVIEW_UDP_BIND
# Browsing the UI from another machine also needs the host name or IP
# in SENSING_ALLOWED_HOSTS (otherwise 421), and the TCP ports above
# published beyond 127.0.0.1.
- SENSING_ALLOWED_HOSTS
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "kill -0 1"]
interval: 30s
timeout: 3s
retries: 3
# No explicit command needed — docker-entrypoint.sh uses CSI_SOURCE.
# Override with: command: ["--source", "esp32", "--tick-ms", "500"]
@@ -46,7 +85,21 @@ services:
dockerfile: docker/Dockerfile.python
image: ruvnet/wifi-densepose:python
ports:
- "8765:8765" # WebSocket
- "8080:8080" # UI
- "127.0.0.1:8765:8765" # WebSocket
- "127.0.0.1:8080:8080" # UI
environment:
- PYTHONUNBUFFERED=1
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
healthcheck:
test: ["CMD", "python", "-c", "import socket; socket.create_connection(('127.0.0.1', 8765), 2).close()"]
interval: 30s
timeout: 3s
retries: 3
+56 -3
View File
@@ -22,6 +22,10 @@
# Default is `auto`. Set CSI_SOURCE=simulated when you want
# fake data tagged as such; never set it implicitly.
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
# RUVIEW_UDP_ALLOW / RUVIEW_UDP_INSECURE_LAN — ADR-296 UDP source guard.
# Setting either makes the entrypoint bind UDP on 0.0.0.0 so
# ESP32 frames on the published 5005/udp port can arrive.
# RUVIEW_UDP_BIND — explicit UDP bind; overrides the choice above.
set -e
# ── Issue #864: fail-closed on default posture ───────────────────────────────
@@ -34,8 +38,8 @@ set -e
#
# Escape hatches (operator must opt in explicitly):
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
# only safe on an isolated trust boundary.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 (or true/yes/on, any case) →
# preserves the pre-fix behaviour; only safe on an isolated trust boundary.
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
#
@@ -45,7 +49,12 @@ set -e
case "${1:-}" in
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
*)
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
# Same spellings the sensing-server accepts for its boolean env flags.
case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in
1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) __allow_unauth=1 ;;
*) __allow_unauth=0 ;;
esac
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "$__allow_unauth" != "1" ]; then
# If the operator hasn't overridden the bind, refuse outright on
# the default 0.0.0.0. If they've nailed it to loopback (or a
# specific private address they trust), let it run.
@@ -97,6 +106,50 @@ esac
# server binary so users can just pass flags:
# docker run <image> --source esp32 --tick-ms 500
if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
# ── Issue #2089: UDP CSI ingest posture inside a container ───────────────
# ADR-296 defaults the UDP receiver to 127.0.0.1. Inside a container that
# bind can never receive from a published port, so no node ever appears.
# We bind 0.0.0.0 only when the operator has configured an ADR-296 guard
# (RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN); without one the server
# would refuse a routable bind, so we keep loopback and say why instead.
# An explicit RUVIEW_UDP_BIND or --udp-bind always wins.
# The binary parses RUVIEW_UDP_INSECURE_LAN itself (1/yes/on/true, any
# case); here we only need to know whether it is truthy to pick the bind.
__insecure_lan=0
case "$(printf '%s' "${RUVIEW_UDP_INSECURE_LAN:-}" | tr 'A-Z' 'a-z')" in
1|true|yes|on) __insecure_lan=1 ;;
esac
__udp_bind_arg=0
__udp_guard_arg=0
for __arg in "$@"; do
case "$__arg" in
--udp-bind|--udp-bind=*) __udp_bind_arg=1 ;;
--udp-allow|--udp-allow=*|--udp-insecure-lan) __udp_guard_arg=1 ;;
esac
done
if [ -z "${RUVIEW_UDP_BIND:-}" ] && [ "$__udp_bind_arg" = 0 ]; then
if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "$__insecure_lan" = 1 ] \
|| [ "$__udp_guard_arg" = 1 ]; then
export RUVIEW_UDP_BIND=0.0.0.0
echo "[entrypoint] UDP CSI receiver: binding 0.0.0.0 inside the container" >&2
echo "[entrypoint] (a source guard is set: RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN)." >&2
else
case "${CSI_SOURCE:-auto}" in
simulated|simulate) ;;
*)
echo "[entrypoint] NOTE: UDP CSI receiver stays on 127.0.0.1 (ADR-296 default)." >&2
echo "[entrypoint] Inside a container that bind cannot receive ESP32 frames from" >&2
echo "[entrypoint] a published 5005/udp port, so no node will appear. To ingest:" >&2
echo "[entrypoint] -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> (restrict sources), or" >&2
echo "[entrypoint] -e RUVIEW_UDP_INSECURE_LAN=true (accept spoofing risk)" >&2
echo "[entrypoint] Docker Desktop/OrbStack on macOS rewrite the source to the bridge" >&2
echo "[entrypoint] gateway, so the allowlist must name <gateway>/32, not the LAN subnet." >&2
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/2089" >&2
;;
esac
fi
fi
set -- /app/sensing-server \
--source "${CSI_SOURCE:-auto}" \
--tick-ms 100 \
+49 -5
View File
@@ -18,9 +18,11 @@ services:
# only activates when OTEL_EXPORTER_OTLP_ENDPOINT is set.
SENSING_FEATURES: mqtt,otel
image: ruvnet/wifi-densepose:otel
# ESP32 CSI must accept LAN UDP; TCP APIs below remain loopback-only.
# kics-scan ignore-line
ports:
- "3000:3000" # REST API
- "3001:3001" # WebSocket
- "127.0.0.1:3000:3000" # REST API
- "127.0.0.1:3001:3001" # WebSocket
- "5005:5005/udp" # ESP32 CSI (see docker-compose.yml for Windows notes)
environment:
- RUST_LOG=info
@@ -30,6 +32,20 @@ services:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on:
- otel-collector
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "kill -0 1"]
interval: 30s
timeout: 3s
retries: 3
otel-collector:
image: otel/opentelemetry-collector-contrib:0.116.0@sha256:70217a89d27c678ead44f196d80aa8c2717cb68d0301dbdc40331dbec0a3e605
@@ -37,10 +53,24 @@ services:
volumes:
- ./otel-collector.yaml:/etc/otelcol-contrib/config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC (also reachable from the host)
- "4318:4318" # OTLP HTTP
- "127.0.0.1:4317:4317" # OTLP gRPC (also reachable from the host)
- "127.0.0.1:4318:4318" # OTLP HTTP
depends_on:
- ourios
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
healthcheck:
test: ["CMD-SHELL", "kill -0 1"]
interval: 30s
timeout: 3s
retries: 3
# Ourios — OTLP-native log backend (Parquet + Drain-derived template
# mining + DataFusion). Local-disk storage; the tenant derives from the
@@ -57,10 +87,24 @@ services:
- OURIOS_QUERIER_ENABLED=1
- OURIOS_QUERIER_HTTP_ADDR=0.0.0.0:4319
ports:
- "4319:4319" # query endpoint (http://localhost:4319/v1/query)
- "127.0.0.1:4319:4319" # query endpoint (http://localhost:4319/v1/query)
volumes:
- ourios-data:/data
- ourios-wal:/wal
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
healthcheck:
test: ["CMD-SHELL", "kill -0 1"]
interval: 30s
timeout: 3s
retries: 3
volumes:
ourios-data:
@@ -4,6 +4,7 @@
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-01 |
| **Updated** | 2026-10-01: macOS 27 also redacts SSID without Location Services; the connected link is kept with a synthetic id, and `auto`+wifi keeps UDP bound (see Amendment 1) |
| **Deciders** | ruv |
| **Codename** | **ORCA** — OS-native Radio Channel Acquisition |
| **Relates to** | ADR-013 (Feature-Level Sensing Commodity Gear), ADR-022 (Windows WiFi Enhanced Fidelity), ADR-014 (SOTA Signal Processing), ADR-018 (ESP32 Dev Implementation) |
@@ -313,3 +314,22 @@ All verification on Mac Mini (M2 Pro, macOS 26.3).
- ADR-022: Windows WiFi Enhanced Fidelity (analogous platform adapter)
- ADR-013: Feature-Level Sensing from Commodity Gear
- Issue [#56](https://github.com/ruvnet/wifi-densepose/issues/56): macOS support request
---
## Amendment 1 (2026-10-01): SSID is also redacted; keep the connected link
**Observed on macOS 27 (M-series Mac, CoreWLAN, unsigned CLI helper).** Without Location Services, `mac_wifi --scan-once` returns `"ssid":""` and `"bssid":"00:00:00:00:00:00"`, while `rssi`, `noise`, `channel` and `tx_rate` are real. The §1 assumption that "SSID + RSSI + channel" survive no longer holds. An unsigned CLI helper never calls `CLLocationManager`, so it never appears in Location Services and cannot be granted access.
Before this amendment, `resolve_bssid` abstained when both identities were blank (to avoid merging unrelated networks), so every macOS scan was dropped and `--source wifi` produced nothing, with no error.
**Change.**
1. `--scan-once` marks its single sample `"connected": true`. It is always the one connected link, so it cannot collide with another network.
2. A redacted sample marked `connected` gets a stable, locally administered synthetic BSSID derived from a reserved key plus the channel. The SSID stays empty; none is invented. Unmarked redacted lines still abstain.
3. `--source auto` that resolves to host WiFi now also binds the UDP receiver, and `wifi_task` yields while the source is a live `esp32`. ESP32 nodes that start after the 2 s boot probe still promote the source, matching the #1004 rule for the simulator.
4. A missing helper logs one clear error with the build command instead of a warning on every tick.
**Measured (2026-10-01, this change):** `--source wifi` → source `wifi:unnamed`, sensing ticks advance. `--source auto` on a quiet port → `wifi` with `udp_receiver=true`; relaying real ADR-018 frames promoted the source to `esp32`, with all 5 nodes listed.
**Still open:** real BSSID/SSID would need a signed `.app` helper with `NSLocationWhenInUseUsageDescription` that requests authorization. The helper still lives under `archive/v1` with no build step.
@@ -4,6 +4,7 @@
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-02 |
| **Updated** | 2026-10-04 — multistatic fusion combines phase across nodes only when every frame declares the same shared phase reference; independent ESP32 nodes and the amplitude-only server bridge fuse amplitude only and report the reason (`phase_fusion` in `/health/ready`, issue #1752). |
| **Deciders** | ruv |
| **Codename** | **RuvSense** -- RuVector-Enhanced Sensing for Multistatic Fidelity |
| **Relates to** | ADR-012 (ESP32 Mesh), ADR-014 (SOTA Signal Processing), ADR-016 (RuVector Training), ADR-017 (RuVector Signal+MAT), ADR-018 (ESP32 Implementation), ADR-024 (AETHER Embeddings), ADR-026 (Survivor Track Lifecycle), ADR-027 (MERIDIAN Generalization) |
+55 -2
View File
@@ -3,7 +3,7 @@
| Field | Value |
|-------|-------|
| **Status** | **Accepted** (MQTT track P1–P7 + P8a + P9 + P10 shipped 2026-05-23 in PR #778, 410 lib tests, witness bundle VERIFIED) / **Proposed** (Matter SDK wiring P8b deferred to v0.7.1 per §9.10) |
| **Date** | 2026-05-23 |
| **Date** | 2026-05-23 (amended 2026-10-02: P4.5 status note, §11) |
| **Deciders** | ruv |
| **Codename** | **HA-DISCO** (MQTT) + **HA-FABRIC** (Matter) + **HA-MIND** (semantic primitives) |
| **Relates to** | ADR-018 (CSI binary frame format), ADR-021 (ESP32 vitals), ADR-031 (RuView sensing-first), ADR-039 (edge vitals packet 0xC511_0002), ADR-079 (camera ground-truth), ADR-103 (cog-person-count), ADR-110 (ESP32-C6 firmware), ADR-114 (cog-quantum-vitals) |
@@ -440,7 +440,7 @@ This means: **adding a new primitive is one file change**. No MQTT schema rev, n
| **P2** | HA discovery message emitter. New module `mqtt_discovery.rs`. Emits all entity `config` topics on connect + every `--mqtt-refresh-secs`. Schema-validated against HA's published JSON schema. | pending |
| **P3** | State publication. Subscribe to internal `tokio::broadcast` channel (the one `tx.send(json)` writes to on line 3983 of `main.rs`). Translate `edge_vitals` / `sensing_update` / `pose_data` messages into per-entity state payloads. Apply rate-limit + privacy-mode filters. | pending |
| **P4** | Integration tests: dockerised mosquitto in CI (extend `.github/workflows/firmware-qemu.yml` pattern), schema-validate every emitted config against HA's `homeassistant/components/mqtt` JSON schemas (pin to a tested HA version). Add a smoke test that brings up sensing-server in `--source mock --mqtt`, subscribes with `paho-mqtt` test client, asserts on entity creation. | pending |
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | pending |
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | partial — 6 of 10 primitives on the live MQTT publisher; see §11 |
| **P5** | Docs: new `docs/integrations/home-assistant.md` with screenshots of the HA UI after auto-discovery completes, example HA dashboard YAML (Lovelace card configs), 8 starter blueprints from §3.12.2 (distress notify, wake routine, hallway dim, elderly anomaly alert, meeting lights, bathroom fan, fall-risk escalate, auto-arm security), and the raw-channel example automations: "turn on hall light when presence ON", "send notification on fall_detected event", "log HR/BR to InfluxDB". | pending |
| **P6** | Ship `--mqtt` in the next sensing-server release (target: v0.7.0). Demo end-to-end on `cognitum-v0` against a Mosquitto add-on running on a Home Assistant OS install. Update README hardware-options table with "Works with Home Assistant" badge. | pending |
| **P7** | Matter Bridge spike: build a throwaway prototype with `matter-rs` exposing one `OccupancySensor` endpoint + one `GenericSwitch` for fall. Pair against Apple Home, Google Home, and HA's Matter integration. Decision gate: if pairing works on all three, proceed to P8; if blocked, switch to chip-tool FFI and re-spike. | pending |
@@ -667,4 +667,57 @@ Empty as of 2026-05-23. New questions discovered during implementation will be f
---
## 11. Status note: P4.5 semantic states on the live publisher (2026-10-02)
Issues [#2085](https://github.com/ruvnet/RuView/issues/2085) and [#2093](https://github.com/ruvnet/RuView/issues/2093) found that discovery announced 20 entities per device while only 6 ever received state, that `presence/state` was published on every CSI frame, and that HA device ids changed on every restart. The FSMs in `crate::semantic` existed but had no caller outside their tests. This note records what is now wired and what is not.
### 11.1 What changed
- **Semantic primitives run on the live path.** `mqtt::planner` holds one `SemanticBus` per node, sampled at 1 Hz (the rate the FSM constants assume). The bus feeds only the MQTT publisher. Matter does not consume it yet, so the "output broadcast channel shared with Matter" in P4.5 is not built.
- **Announce only what has a source.** Six primitives are announced: `room_active`, `no_movement`, `elderly_inactivity_anomaly`, `someone_sleeping`, `possible_distress` and `fall_risk_elevated`. Four are not: `meeting_in_progress` needs a 1–20 % motion level, but per-node motion is a three-level classification; `bathroom_occupied`, `bed_exit` and `multi_room_transition` need zones, and the broadcast carries none (the §3.12.5 zone file is not implemented). For the same reason `zone_occupancy` (§3.1) and `pose` are not announced: `pose_keypoints` is never populated on the broadcast, and `persons[].keypoints` are synthesized.
- **Per-entity availability (§3.6).** An announced entity is `online` only while its input exists: vitals while the calibration-gated estimate is published, `fall` while the node sends `edge_vitals`, `motion_energy` and `rssi` while present, and semantic primitives after the 60 s warmup (sleeping and distress also need breathing or heart rate, fall risk needs the fall detector). Otherwise it is `offline` and no state is sent. Transitions publish at once; all availability topics are re-sent every 30 s.
- **No default values.** `motion_energy` was always `0.0` (MEASURED: 176 of 176 samples) because the bridge never set it. It now carries the node's `motion_band_power`, or the room value, or nothing. `fall` is driven by the rising edge of `edge_vitals.fall_detected`. Only `sensing_update` produces snapshots, which removes the phantom aggregate device that `edge_vitals` frames created. `present_still` now maps to 0 % motion instead of 100 %.
- **Rates (§3.7).** Binary states publish on change, plus a 60 s retained heartbeat, as §3.5 already specified. Sensors keep their configured rates.
- **Stable device identity (§3.8).** The default client id is now `wifi-densepose-<8 hex>`, persisted in `<data-dir>/mqtt_client_id`. §3.8 specified `wifi-densepose-<hostname>`; a persisted random id avoids a hostname dependency and survives hostname changes. `--mqtt-client-id` still overrides it.
### 11.2 Evidence
Unit tests (CODE-DERIVED, run with `cargo test -p wifi-densepose-sensing-server --features mqtt --lib -- mqtt:: semantic::`): `mqtt::planner::tests` covers the entity → state mapping, unavailable-without-source, warmup, on-change with heartbeat at a 73 frame/s input, sensor rates, reset and privacy mode. `mqtt::bridge::tests` covers message-type filtering, fall edges and motion energy. `mqtt::config::tests` covers client-id persistence across two "restarts" in a temp dir, override and malformed-file recovery.
Live measurement (MEASURED, 2026-10-02): one ESP32 node streaming CSI and `edge_vitals` over UDP to a macOS host; `sensing-server --source esp32 --mqtt` against `eclipse-mosquitto:2` in Docker on a non-default port; every topic captured with a `paho-mqtt` subscriber on `#`. "Before" is `03a89793` (main), "after" is this change, built `--release --features mqtt`, same command and window.
```bash
# Broker: mosquitto.conf = "listener 1883 0.0.0.0 / allow_anonymous true / persistence false"
docker run -d --rm -p 127.0.0.1:18830:1883 \
-v "$PWD/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" eclipse-mosquitto:2
# Run A (180 s, then SIGTERM) and run B (restart, 45 s) share one --data-dir.
# A paho-mqtt subscriber on '#' starts 2 s before each run.
RUVIEW_API_TOKEN=<random> sensing-server --source esp32 --udp-port 5006 \
--udp-bind 0.0.0.0 --udp-allow <node-subnet> --http-port 3000 --ws-port 3001 \
--no-mdns --data-dir <dir> --mqtt --mqtt-host 127.0.0.1 --mqtt-port 18830
```
| Measure (180 s window unless noted) | Before (`03a89793`) | After |
|---|---|---|
| HA devices announced in run A | 2 (one phantom aggregate + `-node1`) | 1 (`-node1`) |
| Entities announced per device | 20 | 15 |
| Entities that received state (node device) | 6 | 10 |
| Announced entities without state that still reported `online` | 14 | 1 (`fall`: an event, and no fall occurred) |
| `presence/state` messages (node device) | 6,814 (37.9 / s) | 4 (one per change, plus 60 s heartbeat) |
| `motion_energy` values | `0.0` in 176 of 176 | node band power, 177 samples |
| Device id, run A → run B (restart, same `--data-dir`) | `wifi-densepose-<pid A>` → `<pid B>` (4 devices on the broker after B) | same persisted id in both runs (1 device) |
| `offline` published on SIGTERM | no | no (unchanged; see 11.3) |
| Same, plus `--privacy-mode` (75 s, fresh `--data-dir`) | — | 13 entities announced; no `heart_rate`, `breathing_rate` or `pose` topic for that device |
After the change, five announced entities had no state: `heart_rate` and `breathing_rate`, because no calibration had been run; `someone_sleeping` and `possible_distress`, which depend on them; and `fall`, which is an event and no fall occurred. The first four were `offline`. `fall` was `online`, because the node sent `edge_vitals`. Before the change all 14 entities without state still reported `online`. Only one node was streaming during this measurement. #2085 used five nodes and measured about 73 `presence` messages per second per node.
### 11.3 Not done
- Semantic precision/recall on real captures. The figures in `semantic-primitives-metrics.md` have no reproducer in the repository and stay CLAIMED.
- Zone and threshold files, `--semantic*` / `--no-semantic` flags on `sensing-server`, the `reason` attribute (§3.12.4), and `semantic_events.jsonl`.
- MQTT last-will and offline-on-shutdown (§3.6). The server registers no LWT and SIGTERM does not publish `offline`, so availability stays as last published after the server stops.
- Matter consumption of the semantic states.
---
*ADR-115 is the integration story that turns RuView from "another sensing platform" into "drop-in upgrade for any HA install **and** any Matter-controller home." MQTT carries the rich, differentiated telemetry; Matter carries the standardised subset across every controller ecosystem. Numbers 111 and 112 remain reserved per the project ADR-numbering policy.*
@@ -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** |
@@ -66,10 +66,64 @@ hardware claims.
timestamps, sequence behavior, calibration, maximum size, and report rate.
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
## Amendment 1 — vendor CSI on MT7981, still unvalidated
- **Date**: 2026-09-19, recorded here 2026-10-01
- **Status**: proposed. One board model (two units). The hardware gates below are still open.
The 2026-07-18 decision said the public `mt76` tree has no supported CSI export,
and that a firmware change might be required before physical CSI exists. Both
statements need a narrower reading.
Mainline `openwrt/mt76` still has no CSI export. MediaTek's public OpenWrt feed
does: vendor id `0x0ce7`, MCU event `0xc2`, patch
`1001-wifi-mt76-mt7915-csi-implement-csi-support`. On a Wavlink WL-WN586X3
Rev A (MT7981BA + MT7976CN + MT7531AE) that patch, rebased onto the `mt76`
revision pinned by OpenWrt 24.10.8, produced complex samples without a firmware
blob swap. The `0xc2` hook is already in the MT7981 firmware that the OpenWrt
24.10.8 image carries. The
rebased patch and the two userspace fixes are in `firmware/openwrt-wn586x3/`.
What the first capture was: one record, 2.4 GHz, 20 MHz, legacy OFDM, 64
subcarriers, a single chain. CSI arrived only while a client sent frames.
The rate is client traffic, not a sensor sample clock. The capture is not in
this repository.
What it was not:
- Not a calibration, a sequence check, a timestamp check, or a repeatability run.
The label on the wire is `mediatek:physical-unvalidated`. Plain `mediatek` stays
reserved for a frame that has passed those gates.
- Not a 4x4 array. Each radio is two chains. The four antennas are a 2.4 GHz pair
and a 5 GHz pair. Two routers are two receivers. Cross-box phase is not a
shared local oscillator, so it is not a beam.
- Not the OpenWrt One or the BPI-R3. Those remain the upstream-friendly targets
in the original decision. The WN586X3 is the board this path was actually run on.
`wifi-densepose-mtk-bridge` translates `mt76-vendor` JSON, or an MtkCSIdump
datagram, into ADR-267 MTC1 and sends it to the sensing server. The server keeps
one snapshot and a bounded ring of per-subcarrier amplitude and phase per
`device_id`. It runs `mediatek-amplitude-heuristic-v0` on mean and peak amplitude,
and `activity-index-v0` on per-subcarrier fluctuation. Both are `CLAIMED`. Neither
is the ESP32 vital-sign path.
One measured property supports further work, and nothing more. Offline, on six
local dump captures from two units (2.4 GHz, HT20, chains rx0 and rx1 of transmit
stream 0), the cross-chain phase `arg(H0·conj(H1))`, summed over subcarriers,
moved 0.014-0.039 rad (p50) between adjacent frames for well-sampled
transmitters. Raw per-chain phase moved about 1.56 rad (p50), which is what
uniformly random phase gives (MEASURED; reproducer
`scripts/mediatek-csi-phase-stability.py`). That shows a stable observable inside
one radio. It does not show that the observable tracks people.
The operator guide is [`docs/mediatek-router-csi.md`](../mediatek-router-csi.md).
## Links
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-267: MediaTek MIMO CSI wire protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
- [MediaTek router CSI guide](../mediatek-router-csi.md)
- [upstream mt76](https://github.com/openwrt/mt76)
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
@@ -34,6 +34,81 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
## Amendment 1 — PPDU types `Legacy` and `Unknown`
- **Date**: 2026-09-19
- **Status**: proposed. Demonstrated on one WL-WN586X3 capture. Not a maintainer acceptance.
- **Scope**: `PpduType` discriminants only. The 72-byte header layout, CRC,
payload ordering, flags, and caps are unchanged.
### What changed
`PpduType` gains two variants:
| Value | Name | Meaning |
|---|---|---|
| 6 | `Legacy` | Pre-HT: 802.11b CCK or 802.11a/g OFDM |
| 7 | `Unknown` | A PPDU format the producer could not classify |
Values 1-5 (`Ht`, `Vht`, `HeSu`, `HeMu`, `Eht`) are untouched.
### Why
The original set assumed every CSI report would be HT or later. The first real
MT7981 capture disproved that. A Wavlink WL-WN586X3 (MT7981B + MT7976C) running
an OpenWrt 24.10.8 image built with the patches in `firmware/openwrt-wn586x3/`
reported `rx_mode = 1` (`MT_PHY_TYPE_OFDM`) for a client associated on 2.4 GHz
channel 6 at 20 MHz. The bridge had no way to express it and dropped the frame.
That was a real data loss, not a corner case. MediaTek's CSI driver treats legacy
OFDM as a first-class mode: `mt7915_vendor_csi_tone_mask` skips only
`MT_PHY_TYPE_CCK`, and its `mode_map` is keyed by `MT_PHY_TYPE_OFDM`, `_HT`,
`_VHT` and `_HE_SU`, giving OFDM its own tone-mask group
(`1001-wifi-mt76-mt7915-csi-implement-csi-support.patch:1070-1088`). Association
and management exchanges are legacy-rate by design, so a sensing deployment will
see these continuously.
`Unknown` exists so the enum's gaps (5-7 and 12 in `mt76_phy_type`) and future
modes are carried explicitly rather than guessed at or dropped. It also covers
transports that omit the field: the MtkCSIdump UDP datagram carries no `rx_mode`,
and reporting `Unknown` there is honest where defaulting to a concrete format was
not.
### Compatibility
- **Forward, not backward.** A decoder built before this amendment rejects
discriminants 6 and 7 with `UnknownPpduType`, because the original parser
rejects unknown types by design. `TryFrom<u8>` returns `Err` rather than
panicking, so an old decoder fails safely — it just drops legacy frames, and
does so silently. **Rebuild MTC1 consumers after this codec change.**
- **Scope is narrower than "the workspace".** `wifi-densepose-hardware` defines
three independent `PpduType` enums — `csi_frame.rs` (ESP32),
`qualcomm_csi.rs`, and `mediatek_csi.rs` — and this amendment touches only the
MediaTek one. Neither of the others changes, so no other vendor bridge is
affected. The complete set of `mediatek_csi::PpduType` consumers is:
`wifi-densepose-hardware`'s own `mediatek-csi-sim` binary,
`wifi-densepose-mtk-bridge`, and `wifi-densepose-sensing-server`, which renders the
value with `{:?}` and so needed no code change. In particular the
`ppdu_type > 3` bound in `bootstrap_baseline.rs` is fed from the **ESP32**
enum via `CsiGridKey::from_frame(&Esp32Frame)`, not from MediaTek frames, so
`Legacy` and `Unknown` cannot reach it.
- Because the variants were added rather than renumbered, and Rust requires
exhaustive matches, the compiler locates any consumer that must be updated.
Nothing outside the files above needed one.
- Producers must emit 6 only for frames that genuinely are pre-HT, and 7 only
where the format is truly unclassified. Neither is a dumping ground for a
mapping the producer has not done.
- The mapping from `rx_mode` to `PpduType` is now infallible, so no MTC1 producer
may drop a frame on PPDU grounds.
- Unchanged: synthetic provenance still cannot be cleared by downstream code.
### Observed dimensions
The same capture assembled to 1x1x64. That is the client's behaviour, not a
protocol limit: the handset sent single-stream frames to a 2.4 GHz 20 MHz AP.
Expect 1x2 or 2x2 on 5 GHz, and size consumers for the chipset profile's
`max_chains` rather than for what one capture happened to show.
## Consequences
### Positive
@@ -59,3 +134,8 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- Amendment 1 implementation: `v2/crates/wifi-densepose-hardware/src/mediatek_csi.rs`
(codec) and `v2/crates/wifi-densepose-mtk-bridge` (`ppdu_from_rx_mode`). The
motivating capture is a lab record and is not in this tree: it contains a
client transmitter address and raw CSI. The in-repo check is a fabricated
legacy-OFDM record in `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`.
@@ -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.
@@ -0,0 +1,231 @@
# ADR-288: VEIL — a compliant-waveform privacy shield against unauthorized WiFi sensing
| Field | Value |
|-------|-------|
| **Status** | Proposed — implemented (P1 reference model) |
| **Date** | 2026-08-09 |
| **Deciders** | ruv |
| **Codename** | **VEIL** — Verifiable Emission-shaping for Identity-Leakage prevention |
| **Codebase target** | new leaf crate `v2/crates/wifi-densepose-privshield` |
| **Parent** | ADR-118 (BFLD — the detection layer VEIL is the countermeasure to), ADR-282 (mandatory L0–L5 evidence ladder) |
| **Relates to** | ADR-120/121 (BFLD privacy class + identity-risk scoring — the trigger source), ADR-141 (privacy control plane / runtime attestation — the audit consumer), ADR-280 (active sensing / governed actuation — VEIL is a defensive sensing action), ADR-185 §13 (`wifi-densepose-aether` — the pure-compute leaf pattern this crate follows) |
| **Research bundle** | [`docs/research/privacy-shield/`](../research/privacy-shield/) (9 files) |
| **Tracking issue** | TBD |
## 0. PROOF discipline
Every defense number this crate produces is **SYNTHETIC / evidence level L0**
(ADR-282): generated by the crate's own model (`identity::Channel`), attacked by
the crate's own classifier (`attacker::NearestCentroidAttacker`), and scored
against its own known labels. Nothing here has been validated against real WiFi
silicon, and the crate contains no radio integration and cannot emit RF. External
attack/defense results cited from the literature (BFId, LeakyBeam, DySPAN-2026,
IRShield, FCC statutes) are **EXTERNAL** evidence and labelled MEASURED/CLAIMED in
the research bundle. The single measured claim about *our own behavior* is the
pinned deterministic witness in `proof.rs`.
## 1. Context
### 1.1 The gap
IEEE 802.11ac/ax beamforming feedback (BFI) — the compressed Givens-rotation
angle matrices (φ/ψ) a client sends the AP — is transmitted **unencrypted on the
management plane**. Any device in monitor mode can capture it for every station
at once, no network access, and the target need carry no device. The literature
establishes the severity: **BFId** (ACM CCS 2025) re-identifies individuals from
BFI; **LeakyBeam** (NDSS 2025) detects occupancy through walls at 20 m from BFI;
**BeamSense** recognizes activities at up to 99.28%. IEEE Std **802.11bf-2025**
(published 26 Sep 2025) standardizes the sensing measurement/feedback surface
these attacks abuse — and a 2023 proposal for a BFI secure-transmission mechanism
(802.11-23/0782) was **withdrawn**, so the standard shipped with no privacy
protections.
RuView already has a *detection* layer for this: **BFLD** (ADR-118/121) measures
the identity-leakage of each frame and gates what leaves the node. But BFLD
protects *RuView's own outputs*; it does nothing about a **third-party sniffer**
capturing the room's plaintext BFI off the air. There is no RuView component, and
per our market survey no shipping product anywhere, that prevents that.
### 1.2 Constraint: compliant waveform controls, never jamming
The defense must preserve normal communications and must not interfere with any
other station. Jamming (47 U.S.C. §333/§302a) is defined by *adding energy to
interfere with others' transmissions*. Any acceptable control must shape only the
node's **own** standards-conformant emission.
### 1.3 The separability insight
Identity leaks through the *fine* cross-subcarrier phase structure of a
beamforming report; data throughput rides the *dominant* beam direction. These
are (mostly) separable subspaces — so a transform confined to the fine subspace
can wreck re-identification while sparing the beam the link depends on. DySPAN-2026
independently MEASURED that shaping fine-resolution feedback is near-free in
throughput, corroborating the insight.
## 2. Decision
Ship **`wifi-densepose-privshield`** (VEIL) as a standalone pure-compute leaf
crate (the `wifi-densepose-aether`/`nvsim` pattern: dependency-free, deterministic,
WASM-ready, zero coupling to any radio or ingestion path), implementing:
1. **A SYNTHETIC two-subspace BFI model** (`identity.rs`): each identity owns a
stable fine-block signature; sessions add environmental nuisance; the comm
block is identity-free and carries throughput.
2. **The protector** (`protector.rs`): compliant waveform controls, primarily a
**per-session keyed orthogonal rotation of the fine subspace, composed from
extra Givens rotations** — the report's native primitive. Plus feedback
quantization/dither, sounding-cadence randomization, and a `SensingDetector`
that engages the shield only when sensing activity is observed.
3. **The adversary** (`attacker.rs`): a passive nearest-centroid re-identifier
modeling the BFId threat, with selectable Euclidean/Cosine metrics.
4. **A throughput model** (`throughput.rs`):
`(1 − sounding − feedback_airtime) · C(SNR·(1−ρ))/C(SNR)`, where the residual
`ρ` falls with feedback bits and the feedback airtime rises with them — giving
a genuine interior throughput optimum in feedback resolution.
5. **A compliance audit** (`compliance.rs`): the rotation is orthogonal ⇒
energy-preserving ⇒ adds no interfering energy ⇒ **not jamming**, turned into a
checked `ComplianceReport` (energy ratio ≈ 1.0).
6. **The experiment** (`experiment.rs`): runs the attacker against unprotected and
protected traffic and reports both accuracies vs. chance, plus throughput and
compliance, with a single `passed()` verdict.
7. **The hyper-optimizer** (`optimize.rs`): derives the shipped shield config
rather than hand-picking it — the throughput-optimal feedback resolution and
the minimum rotation-mixing budget that collapses re-ID robustly (across both
attacker metrics and N∈{16,32}), plus a Pareto frontier.
8. **A deterministic proof** (`proof.rs`): a pinned FNV-1a witness over the
reference experiment (the `nvsim`/`verify.py` discipline).
### 2.1 Why the keyed Givens rotation
It is simultaneously **orthogonal** (energy-preserving ⇒ compliant),
**key-reversible** (the associated AP shares the session key and recovers the true
precoder ⇒ throughput preserved), and **fresh per session** (a sniffer sees a new
random rotation of the signature each session and cannot average it back ⇒ the
enrollment attack collapses; over unknown rotations the signature carries no
stable discriminative information ⇒ re-ID → chance). It is the shared-secret
precoding idea (cf. MIMOCrypt) specialized to the identity-bearing subspace.
### 2.2 Measured behavior (SYNTHETIC / L0)
Reference experiment at the hyper-optimized operating point (§opt), default
scene, N=16 identities, `cargo test`:
| Metric | Shield off | Shield on |
|---|---|---|
| Passive re-ID accuracy | 100.0% | **4.7%** (chance 6.25%) |
| Link throughput ratio | 100% | **97.6%** |
| Emission energy ratio | — | **1.000000** (compliant) |
All 35 unit/proof tests + doctest pass; the crate builds for
`wasm32-unknown-unknown` and is clippy-clean.
### opt. Hyper-optimization (`optimize.rs`)
The shipped shield config is the optimizer's output, not a guess, and
`ShieldConfig::default()` is asserted equal to it:
- **Feedback resolution = 5 bits.** Throughput has an interior optimum in
feedback bits (residual falls, feedback airtime rises); the unconstrained
optimum is 3 bits (matching DySPAN-2026), and 5 is the throughput-best value in
the spec-allowed 802.11 {5,7,9} set.
- **Givens passes = 96.** The proven minimum for robust collapse — across both
attacker metrics *and* N∈{16,32} — is **48**; the shipped 96 is a free 2×
privacy margin, since the keyed rotation is derived from the shared secret and
never signaled (extra passes cost compute, not airtime). The original
hand-picked 112 was 2.3× over-provisioned.
Net vs. the original hand-picked (112 passes / 7 bits): the optimum is strictly
better on **both** privacy (re-ID 0.047 vs 0.078) and throughput (0.976 vs 0.974),
and is now verified rather than assumed. See
`docs/research/privacy-shield/08-optimization.md`.
### harness. Native terminal harness + TUI (`src/bin/veil.rs`)
A custom, dependency-free binary (`veil`) ships with the crate — the in-repo,
native counterpart to the npm metaharness (ADR-289). It drives the same public
API the tests use, as an interactive ANSI dashboard plus scriptable subcommands
(`report`, `sweep`, `optimize`, `adaptive <N>`, `proof`, `doctor`, `tui`).
Std-only (no `crossterm`/`ratatui`): the TUI is a command-driven redraw loop, so
it runs in any terminal, pipe, or CI and keeps the crate a pure leaf. It reports
only SYNTHETIC/L0 numbers and never relabels them. The wasm leaf story is
unchanged (validated with `--lib`; the bin is native-only).
### sota. 2025–2026 evidence update (verified)
A cited, adversarially-verified SOTA sweep
(`docs/research/privacy-shield/09-sota-update-2026.md`) refines the threat and
positioning. Load-bearing points for this ADR:
- **Threat is broader and cheaper than §1.1 stated.** A passive, keyless,
single-antenna sniffer at ~20 m and *through walls* can identify people
(BFId, 99.5%/N=197, `MEASURED`), read **breathing** from stationary occupants
and **keystrokes/PINs** (LeakyBeam / WiKI-Eve / SThief, `MEASURED`), and —
decisively — **reconstruct full CSI from the sniffed BFI** (BFIAttack,
≥93% single-antenna, `MEASURED`). VEIL's obfuscation must therefore degrade
*reconstructed-CSI* utility, not merely raw-BFI feature noise; because VEIL's
rotation is a **secret orthogonal** transform, the attacker has no key and no
closed-form to invert — this is now a claim to **test**, not assume.
- **VEIL's family is independently validated.** AP-side per-packet random
unitary on the LTF (LeakyBeam defense, 89.7%→~51%, `MEASURED`) and RIS
obfuscation (PrivISAC, 93%→~30%, robust to a retrained multi-location
attacker, `MEASURED`) confirm standard-permitted beamforming-surface
obfuscation works; DP-Givens quantization (`SYNTHETIC`) offers a formal ε knob.
- **Compliance precedent.** BeamDancer (IEEE TWC 2024, `MEASURED`) argues
native-beamforming obfuscation is 802.11-compliant while jamming/geofencing
are not — cite it as precedent. (Its ">96% PDR" figure was **refuted** in
verification; do not cite it.)
- **Security honesty.** Obfuscation shields have published counter-attacks
("Defeating CSI obfuscation", SnoopFi), so VEIL's own shield security is
`CLAIMED`, not proven-secure, until it withstands learned de-obfuscation.
- **Governance gap.** No claim on 802.11bf-2025 privacy provisions survived
verification; that pillar remains an open question, not an asserted fact.
The derived, prioritized improvement backlog lives in the SOTA-update file (§4).
## 3. What this explicitly is NOT
- **Not a radio driver.** No RF frontend, no transmit path, no
`wifi-densepose-hardware` coupling. VEIL cannot emit and cannot jam.
- **Not a defense against the associated AP.** That party holds the session key by
construction (threat class A3); protecting against a malicious AP is BFLD's
detection/privacy-class problem (ADR-118/141), not this shield's.
- **Not a full motion-obfuscation claim.** A fixed per-session rotation does not
hide coarse within-session motion; identity *re-ID* is the guaranteed target,
motion is partial/future work.
- **Not a real-hardware performance claim.** All defense numbers are SYNTHETIC/L0
until a two-node capture with a boot/runtime-log witness exists (CLAUDE.md
hardware rule; roadmap P5).
- **Not RF denial or camera-grade anything.**
## 4. Simplifications (honesty boundary)
- The two-subspace split is an abstraction; on real radios comm and identity
information are only *approximately* separable, so the real throughput cost of
fully hiding identity may exceed the model's ~2%. DySPAN-2026's MEASURED curve
bounds it as *small* at fine resolution, not zero.
- The attacker is nearest-centroid. The collapse argument is classifier-independent
(it is about the marginalized signal), but P2/P5 must confirm a learned attacker
also collapses.
- The crate's PRNG is SplitMix64 — deterministic and WASM-safe but **not
cryptographic**; a deployment derives the rotation key from the negotiated link
secret, never from this PRNG.
## 5. Consequences
- RuView gains the *countermeasure* half of its RF-privacy story: BFLD detects
leakage, VEIL acts on it — a defensible, standards-anchored, gap-filling
position (see `docs/research/privacy-shield/06-market-and-buyers.md`).
- The compliance audit gives regulators/auditors a machine-checkable "not jamming"
artifact that composes with ADR-141 attestation.
- Future integration (BFLD `identity_risk` → `SensingDetector`, ADR-280 governed
actuation, firmware feedback shaping, two-node hardware measurement) is staged in
the research bundle roadmap and deliberately deferred so the model validates in
isolation first.
## 6. Validation
```bash
cargo test -p wifi-densepose-privshield --no-default-features
cargo build -p wifi-densepose-privshield --target wasm32-unknown-unknown
cargo clippy -p wifi-densepose-privshield --all-targets
```
@@ -0,0 +1,95 @@
# ADR-289: `wifi-densepose-privshield-harness` — a MetaHarness for the VEIL privacy shield
| Field | Value |
|-------|-------|
| **Status** | Proposed — implemented (P1) |
| **Date** | 2026-08-09 |
| **Parent** | ADR-288 (`wifi-densepose-privshield` / VEIL, the crate this harness assists development on) |
| **Relates to** | ADR-286 (`wifi-densepose-sar-harness`, the per-crate harness scaffold this one mirrors), ADR-285 (`harness/homecore/`, the WASM-first `@metaharness/kernel` pattern), ADR-182 (`harness/ruview/`, the first minted harness), ADR-282 (L0–L5 evidence ladder) |
| **Location** | `harness/wifi-densepose-privshield/` |
## 0. PROOF discipline
Every claim below about what is "real" versus "illustrative"/"SYNTHETIC" is
checked by a test in this harness's own suite (router + flywheel + install-smoke
+ guidance). The dependency-free `guidance` surface is covered by
`__tests__/guidance.test.ts`, which runs even before `npm install`. Nothing here
asserts a MEASURED defense result — the harness surfaces the VEIL crate's
SYNTHETIC/L0 numbers with that label intact.
## 1. Context
`wifi-densepose-privshield` (ADR-288) is the VEIL privacy shield — a new,
narrowly-scoped crate. Following the pattern ADR-286 set for
`wifi-densepose-sar`, it gets a dedicated per-crate MetaHarness rather than a
bespoke setup: the `vertical:coding` scaffold (architect/implementer/reviewer/
test-writer, `doctor`) with `@metaharness/router`, `@metaharness/flywheel`, and
Darwin Mode wired in, plus a VEIL-specific, dependency-free `guidance` surface.
## 2. Decision
Land the harness at `harness/wifi-densepose-privshield/`, mirroring
`wifi-densepose-sar-harness`, with two deliberate improvements:
1. **Dynamic dependency imports.** `bin/cli.js` imports the `@metaharness/*`
packages *inside* the commands that need them, not at module top. So
`guidance`, `--help`, and the guidance test run with **zero dependencies
installed** — useful for offline/air-gapped review and for this repo's CI
before `npm install`. Only `init`/`doctor`/`route`/`flywheel` touch the
kernel/host/router/flywheel packages.
2. **A VEIL `guidance` command.** A self-contained, source-cited, read-only
capability map (topics: `overview`, `threat`, `countermeasure`,
`compliance`, `optimization`, `experiment`), each entry carrying a summary,
repo-relative source citations, focused validation commands, and explicit
limitations — the `ruview_guidance` shape, specialized to VEIL. It labels all
defense evidence `SYNTHETIC/L0` and states plainly that guidance is
navigation, not authority.
The standard three self-improvement/cost pieces are wired as real npm
dependencies (not stubs):
- **`@metaharness/darwin`** (devDependency) — `npm run evolve` / `evolve:dry`
mutates the harness's own operating config, keeping only measurable gains.
- **`@metaharness/router`** — `src/router.ts` wires a real cost-optimal `Router`
(`qualityBar: 0.8`, k=1) over two model tiers, with four VEIL-shaped task axes
(threatModeling / complianceReview / optimizerTuning / docWriting). Labelled
examples are illustrative seed data (honesty note in-file).
- **`@metaharness/flywheel`** — `src/flywheel.ts` wires the real
`runFlywheelGenerations` promotion loop (propose → evaluate → gate → promote,
Ed25519-signed, independently replayable) with a SYNTHETIC proposer/evaluator
(`dataSource: 'SYNTHETIC'`, no model call), over VEIL policy levers
(`complianceReview`, `threatTriage`).
## 3. What this explicitly is NOT
- **Not a VEIL runtime.** The harness does not run a radio, emit RF, or jam. It
assists *development* on the crate; it cannot execute the shield on hardware.
- **Not evolving the crate.** Darwin/Flywheel mutate the harness's own policy
(agent prompts, review-checklist depth), not VEIL's Rust code. The crate's
actual hyper-optimization (ADR-288 §opt) was done directly, in the crate.
- **Not a live routing/promotion system.** The router's examples are seed data;
the flywheel's proposer/evaluator are deterministic stand-ins — both honestly
labelled in-source and in `CLAUDE.md`.
- **Not a replacement for the crate's gates.** The authoritative check for a
VEIL change remains `cargo test -p wifi-densepose-privshield`.
- **Not a re-labeller.** The harness must never present VEIL's SYNTHETIC results
as MEASURED, and never scaffold interference-based ("jamming") defenses — both
are hard rules in the harness `CLAUDE.md`.
## 4. Consequences
- The harness ships `guidance`/`doctor`/`init`/`route`/`flywheel`; `guidance`
and `--help` work offline (validated here via `node bin/cli.js`), the rest
after `npm install` + `npm run build` (CI).
- `.harness/manifest.json` + `manifest.sha256` are generated with real per-file
hashes at creation (unlike ADR-286's scaffold, whose manifest was historical).
- Scoped to its own name: its plugin, permissions, and (future) MCP surface only
read/assist on `wifi-densepose-privshield`. No risk to other harnesses/crates.
## 5. Validation
```bash
cd harness/wifi-densepose-privshield
node bin/cli.js guidance --topic overview # dependency-free
npm ci && npm run build && npm test # full suite (CI; needs registry access)
```
@@ -0,0 +1,94 @@
# ADR-290: VEIL end-to-end hardware implementation program (multi-provider firmware)
| Field | Value |
|-------|-------|
| **Status** | Proposed — P4 scaffolding (build-only); portable core validated on host |
| **Date** | 2026-08-09 |
| **Parent** | ADR-288 (VEIL shield), ADR-289 (harness), ADR-282 (L0–L5 evidence ladder) |
| **Location** | `firmware/privshield/` |
| **Relates to** | `firmware/esp32-csi-node/` (the CSI sensor/attacker node), ADR-280 (governed actuation), ADR-141 (attestation) |
## 0. PROOF discipline
The **only** artifact validated here is the portable C core
(`firmware/privshield/core/`): a host test (`make test`) checks energy
conservation, reversibility, wrong-key failure, and — pinned — that its
SplitMix64 key schedule is **byte-identical to the Rust crate's** PRNG. That is
`build`/host-level evidence, not silicon. Every per-provider adapter is a
**build-only scaffold** with `TODO(hw)` markers: `SYNTHETIC / L0`, no captured
log, no `MEASURED` claim. Nothing in this ADR asserts VEIL works on real
hardware; it asserts a *plan and a shared core* to get there (P5).
## 1. Context
ADR-288 shipped VEIL as a deterministic, no-radio Rust model, and the 2025–2026
SOTA sweep (ADR-288 §sota) confirmed the mechanism's family is real and
standard-permitted. The open question left was **"does this run on real WiFi
hardware, and on which?"** — including the user asks: *can OpenWRT / open WiFi
software implement it, and can ESP32 help scramble signals?* Answering requires
committing to the platform reality rather than assuming a uniform "firmware"
target.
## 2. Decision
Stand up `firmware/privshield/` as a **multi-provider E2E program** around one
shared, validated core:
1. **A portable C shield core** (`core/veil_shield.{h,c}`) — the keyed
Givens-rotation obfuscation, `no_std`-friendly C99 (no malloc/libc I/O), with
a SplitMix64 key schedule matching the Rust crate so on-air behavior is
identical everywhere and every adapter links the *same* math. Host-tested.
2. **Per-provider adapters**, each built and graded by a hardware research
agent, honest about what its stack can actually touch:
- **`openwifi/`** (open PHY/MAC on SDR/FPGA) — the highest-capability path and
the one that can host the **keyed-reversible** design end-to-end
(protector + AP-side compensation). Carries the **P5 measurement protocol**
(`MEASUREMENT.md`) that yields the first `MEASURED` result with a witness.
- **`openwrt/`** (Linux `mac80211`, mt76/ath9k…) — the commodity path.
Sounding-cadence randomization, MU-group and stream-mapping control are
feasible from the driver/hostapd; the per-packet unitary on the LTF spatial
mapping is firmware-deep on most parts. Partial.
- **`nexmon/`** (Broadcom/Cypress C firmware patches) — the commodity
C-firmware route; the read path is proven (Wi-BFI/nexmon_csi), the transmit
report-shaping path is research-grade/partial.
- **`esp32/`** (ESP-IDF) — **not** a feedback protector (the beamforming path
is a closed blob): ESP32 shapes CSI *read*, not transmitted feedback. Its
legitimate roles are a **sensing detector** (trigger the AP-side shield) and
an **RIS controller** (drive an external reconfigurable surface to scramble
the sensing direction — the honest way ESP32 "helps scramble", via an
external surface, not its own PHY).
3. **Compliance stance carried into hardware:** every control shapes the node's
own standards-conformant emission and preserves energy; the ESP32
decoy/cover-traffic idea is documented as *legally sensitive / not
recommended* precisely because it edges toward the interference line.
Per-provider feasibility grades live in each subdir README and the top-level
feasibility matrix; they are the answer to the "which hardware" question.
## 3. What this explicitly is NOT
- **Not validated firmware.** No adapter has run on silicon; there is no witness.
The scaffolds compile-*shaped*, not compile-*guaranteed* on their toolchains
(which are absent in this environment).
- **Not a claim that ESP32 can shield beamforming feedback** — it cannot; it is a
detector/RIS-controller only.
- **Not jamming, on any platform.** Compliant waveform shaping only.
- **Not a MEASURED result.** That is P5, gated on a captured log.
## 4. Consequences
- One validated core, four honest provider scaffolds, and a concrete P5
measurement plan — a real path from model to silicon, with the effort/blocker
reality made explicit per platform.
- The shared core keeps every future hardware result consistent with the crate
and with each other.
- Scope stays inside `firmware/privshield/`; no other crate/firmware is touched
(the existing `esp32-csi-node` remains the sensor/attacker node).
## 5. Validation
```bash
cd firmware/privshield/core && make test # host: energy/reversibility/PRNG parity
# per-provider builds require their toolchains (ESP-IDF, OpenWRT SDK, Nexmon,
# Vivado) and real hardware — see each subdir's BUILD/INTEGRATION notes.
```
@@ -0,0 +1,106 @@
# ADR-291: Public-benchmark evaluation harness — Widar3.0 ingest, standard split protocols, leakage guards
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-10
- **Deciders**: ruv
- **Tags**: training, evaluation, benchmarks, widar, mm-fi, leakage, honesty
## Context
RuView implements the field's key techniques (CSI ratio, BVP features, MAE
pretraining, rapid adaptation) but reports results only on self-collected data
with self-defined metrics (e.g. the README's held-out temporal-triplet
accuracy). A 2026 deep-research sweep of the WiFi-sensing literature found:
1. Cross-domain generalization is the field's central unsolved problem; the
only widely reproduced cross-domain result is Widar3.0's BVP benchmark.
2. MM-Fi (NeurIPS 2023) is the standard WiFi-pose benchmark, with defined
cross-subject and cross-environment protocols.
3. The field had a documented leakage reckoning in 2024–2025: window-level
random splits on continuous recordings inflate accuracy (one dataset's F1
collapsed from ~90% to ~22% under subject-disjoint splits — Sensors
24(10):3159; Signals 6(4):59).
`wifi-densepose-train` already has an `MmFiDataset` NPY loader and a
deterministic `SyntheticCsiDataset`, but no Widar3.0 ingest, no standard split
protocols, and no structural leakage guard. CLAUDE.md already requires
mean-pose baselines and leakage-free held-out splits for pose PCK; nothing in
the code enforces this.
Without leaderboard-comparable numbers, RuView's claims cannot be ranked
against published systems, which blocks both scientific credibility and
commercial (OEM licensing) conversations.
## Options considered
1. **Do nothing; keep self-collected metrics.** Rejected: perpetuates the
comparability gap.
2. **Port a Python eval stack (SenseFi) alongside the Rust pipeline.**
Rejected: violates the v2 Rust-workspace direction and adds an unreviewed
dependency surface.
3. **Extend `wifi-densepose-train` with native loaders + protocol machinery.**
Chosen.
## Decision
Extend `v2/crates/wifi-densepose-train` with three additions:
### 1. Widar3.0 ingest (`dataset::widar`)
- A parser for the Intel 5300 `.dat` CSI log format ("bfee" records) used by
the Widar3.0 raw distribution: framed records with a 3-byte header
(2-byte little-endian length + 1-byte code 0xBB), a 20-byte bfee header
(timestamp_low, bfee_count, Nrx, Ntx, RSSI a/b/c, noise, agc, antenna_sel,
len, rate), and a packed 10-bit-per-component complex CSI payload of
30 subcarrier groups. Invalid records are skipped with a warning, not a
panic — untrusted file input is validated at the boundary per CLAUDE.md.
- A `WidarDataset` implementing the existing `CsiDataset` trait, mapping
Widar's `Nrx × Ntx × 30` CSI into windowed `CsiSample`s via the existing
subcarrier interpolation, with domain metadata (user, room, orientation,
gesture) parsed from Widar's documented directory/file naming convention.
- No network access: the loader reads a local dataset root. Dataset download
remains a documented manual step.
### 2. Split protocols (`protocols`)
- A `SplitProtocol` type expressing the standard evaluations: cross-subject
(MM-Fi style), cross-environment/room, cross-orientation (Widar style), and
random-baseline (explicitly labelled as leakage-prone, for comparison only).
- Split assignment is a pure function of sample metadata + a seed — fully
deterministic, no RNG state.
### 3. Leakage guards (`protocols::leakage`)
- A structural `LeakageAudit` that, given a proposed train/test split,
verifies: (a) subject-disjointness, (b) environment-disjointness where the
protocol claims it, (c) no two windows from the same continuous recording
span both sides of the split. A failed audit is an `Err`, not a warning.
- PCK/accuracy reporting requires a `MeanPoseBaseline` computed from the
training split only, and reports model-vs-baseline together, enforcing the
CLAUDE.md rule in the type system rather than by convention.
- Evaluation output is an evidence-tagged report (`MEASURED` requires a
reproducer command line embedded in the report; anything else is emitted as
`SYNTHETIC` or `CLAIMED`).
## Consequences
- RuView results become comparable to published numbers (Widar3.0 cross-domain
gesture; MM-Fi cross-subject pose) for the first time.
- The leakage audit will make some existing internal numbers look worse. That
is the point.
- Parsing a legacy binary format adds maintenance surface; mitigated by
fixture-based tests with synthetic, deterministically generated `.dat`
bytes (no dataset redistribution).
- Widar's raw distribution is Intel 5300-specific; ESP32-captured data
continues through existing loaders. The protocols/leakage machinery is
loader-agnostic.
## Validation
- `cargo test -p wifi-densepose-train` — unit tests for the bfee parser
(truncated, corrupt, and valid synthetic fixtures), split determinism,
leakage-audit rejection cases, and mean-pose baseline math.
- `cargo bench -p wifi-densepose-train` — criterion benchmark for parser
throughput and split assignment on synthetic corpora.
- No accuracy numbers are claimed by this ADR; it delivers the machinery to
produce MEASURED ones.
@@ -0,0 +1,91 @@
# ADR-292: Wideband 802.11ax CSI ingest — FeitCSI/AX210 adapter and subcarrier-agnostic plumbing
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-10
- **Deciders**: ruv
- **Tags**: hardware, csi, 80211ax, ax210, feitcsi, ingest, mat
## Context
RuView's CSI ingest (`wifi-densepose-mat/src/integration/hardware_adapter.rs`)
supports ESP32 serial streams, the legacy Intel 5300 tool, and Atheros/Nexmon
paths. All of these are 802.11n-class: ≤40 MHz bandwidth, ≤114 subcarriers,
2.4/5 GHz.
The 2026 research sweep found the field's center of gravity has moved to
Intel AX200/AX210 NICs via PicoScenes (closed-source core) and FeitCSI
(open-source, GPL): 802.11ax CSI at up to 160 MHz / 1992 subcarriers,
including the 6 GHz band. This is both the research-grade tier today and the
shape of the data 802.11bf silicon will deliver from ~2026 onward. RuView's
`wifi-densepose-hardware` crate already models 802.11bf session types, but no
ingest path can carry wideband CSI into the pipeline.
Without a wideband path, RuView cannot develop against the best available
signal, cannot compare ESP32-grade results to wideband upper bounds, and will
meet 802.11bf silicon with no tested plumbing for >114-subcarrier frames.
## Options considered
1. **PicoScenes `.csi` ingest.** Rejected for now: the format is produced by a
closed-source core and is versioned/complex; parsing it without a
maintained spec invites silent corruption.
2. **Raw pcap + radiotap parsing.** Rejected: duplicates what FeitCSI already
does on-device, and pulls a packet-capture dependency into the pipeline.
3. **FeitCSI file/stream ingest.** Chosen: FeitCSI is open-source (its header
layout is auditable against the source), targets AX200/AX210, covers
20–160 MHz including 6 GHz, and emits a compact binary record per frame.
## Decision
Extend `v2/crates/wifi-densepose-mat/src/integration` with:
### 1. `feitcsi` record parser
- A validated parser for FeitCSI's binary CSI record layout (header with
CSI buffer length, rate/bandwidth/channel metadata, antenna counts, RSSI,
timestamp, followed by interleaved complex CSI). The parser is written
against the documented layout, is version-checked, and rejects
records whose declared dimensions disagree with the buffer length —
untrusted file/stream input is validated at the boundary.
- Bounded allocation: a hard cap on subcarrier count (4096) and antenna
count (8) so a corrupt length field cannot cause unbounded allocation.
### 2. `DeviceType::FeitCsi` in the hardware adapter
- File-replay mode (read a recorded FeitCSI capture deterministically) and a
streaming mode fed by an external process writing to a path/pipe. No
privileged operations inside the crate: RuView does not configure the NIC;
FeitCSI's own tooling owns that, per least-authority.
### 3. Subcarrier-agnostic plumbing
- Ingest carries native subcarrier dimensionality end-to-end and converts to
pipeline width explicitly via the existing interpolation/decimation stage,
recording the native → pipeline mapping in frame metadata so downstream
consumers know the true spectral resolution. Bandwidth (20–160 MHz) and
band (2.4/5/6 GHz) become first-class frame metadata.
## Consequences
- RuView gains a research-grade wideband development path and a tested
ingest shape for future 802.11bf reporting (truncated CIR is a natural
extension of the same plumbing).
- GPL FeitCSI is used as an external tool, never linked: only its output
format is parsed. No licensing contamination of the MIT workspace.
- The parser tracks an external project's format; version checks fail loudly
on mismatch rather than misparse.
- ESP32 remains the deployed sensor tier; wideband is a development/
validation tier. Accuracy claims from wideband captures must be tagged with
the capture hardware.
## Validation
- `cargo test -p wifi-densepose-mat` — parser tests over synthetic fixtures:
valid records at 20/80/160 MHz shapes, truncated buffer, dimension
mismatch, version mismatch, allocation-cap enforcement; adapter replay
determinism.
- `cargo bench -p wifi-densepose-mat` — criterion benchmark for record parse
throughput at 1992-subcarrier frames.
- Hardware validation on real AX210 silicon is explicitly out of scope for
this PR and remains required (per CLAUDE.md) before any capture-path
hardware claim; the file-replay path is testable without silicon.
@@ -0,0 +1,93 @@
# ADR-293: Vitals ground-truth rig — reference ingest, time alignment, and agreement metrics
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-10
- **Deciders**: ruv
- **Tags**: vitals, validation, ground-truth, bland-altman, evidence, honesty
## Context
`wifi-densepose-vitals` (ADR-021) extracts breathing (0.1–0.5 Hz) and heart
rate (0.8–2.0 Hz) from CSI. The 2026 research sweep found that every credible
vitals result in the literature ships with reference-sensor ground truth
(chest strap, pulse oximeter, ECG, or PSG), and that WiFi heart-rate numbers
without stated scope (single person, static, line-of-sight, short range) are
systematically misleading. RuView currently has no way to produce a MEASURED
vitals number: there is no reference-signal ingest, no time alignment between
CSI-derived estimates and a reference device, and no agreement statistics.
CLAUDE.md requires accuracy statements to be tagged MEASURED (with a
reproducer), CLAIMED, or SYNTHETIC. For vitals, MEASURED is currently
unreachable.
## Options considered
1. **Live BLE/ANT+ integration with reference devices.** Rejected for now:
drivers and pairing are a hardware/product concern; the blocking gap is
the evaluation math, not the radio link.
2. **File-based reference ingest + offline agreement analysis.** Chosen:
every consumer reference device (Polar, Garmin, oximeters) exports
timestamped series; a file boundary keeps the crate dependency-free and
the pipeline deterministic.
## Decision
Add a `groundtruth` module to `v2/crates/wifi-densepose-vitals`:
### 1. Reference series ingest
- `ReferenceSeries`: timestamped samples (unix millis + value) for one
measurand (`HeartRateBpm` or `BreathingRateBrpm`), with device metadata
(make/model, measurement principle). Parsed from CSV (`timestamp_ms,value`
with a header line); malformed rows are rejected with row-numbered errors —
untrusted file input validated at the boundary. Non-monotonic timestamps
are an error, not silently sorted.
### 2. Time alignment
- Constant-offset estimation by maximizing normalized cross-correlation of
the estimate series against the reference over a bounded lag window
(default ±30 s), on a common resampled grid (nearest-sample, no
interpolation of physiological values across gaps larger than a
configurable limit).
- Optional linear clock-drift fit (offset + rate) for long sessions.
Alignment parameters are reported, never silently applied.
### 3. Agreement metrics
- `AgreementReport`: n paired samples, coverage fraction (time where both
series had valid samples), MAE, RMSE, mean error (bias), Bland–Altman
95% limits of agreement, and percentage-within-tolerance (configurable,
default ±2 bpm HR / ±1 brpm breathing).
- Session scope is mandatory metadata: subject count, motion state
(static/moving), line-of-sight (LOS/NLOS/through-wall), distance band.
A report without scope cannot be constructed.
### 4. Evidence tagging
- `EvidenceGrade::Measured` is only constructible when the report carries a
reference device, non-zero paired samples, minimum coverage, and a
reproducer command string; otherwise the report grades as `Claimed` (real
data, no reference) or `Synthetic` (generated input). This mirrors
ADR-291's enforcement-in-types approach and the CLAUDE.md tagging rule.
## Consequences
- RuView can convert vitals claims from CLAIMED to MEASURED with a
reproducible offline analysis, session by session, scope by scope.
- Honest reporting will likely show heart-rate performance below marketing
intuition, especially NLOS/moving — that is the purpose.
- CSV ingest means a manual export step per session; acceptable at current
scale, and the format is the de-facto export of consumer reference gear.
- No clinical claim is implied: agreement statistics against consumer
reference devices are engineering evidence, not medical validation.
## Validation
- `cargo test -p wifi-densepose-vitals` — CSV rejection cases, alignment
recovery of known synthetic offsets/drifts, agreement metrics against
hand-computed fixtures, evidence-grade constructibility rules.
- `cargo bench -p wifi-densepose-vitals` — criterion benchmark for alignment
over hour-scale synthetic sessions.
- Real-session validation (ESP32 capture + chest strap) remains a follow-up
requiring hardware evidence per CLAUDE.md.
+83
View File
@@ -0,0 +1,83 @@
# ADR-294: WiFi Veil integration — emission-shaping countermeasure as an advisory BFLD dependency
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-10
- **Deciders**: ruv
- **Tags**: privacy, bfld, bfi, wifi-veil, countermeasure, dependency
## Context
RuView's BFLD layer (ADR-118, ADR-141) senses via beamforming feedback while
enforcing structural privacy invariants on data entering the node. The 2026
research sweep identified the complementary, unaddressed surface: a node's own
*outgoing* BFI is unencrypted and enables passive third-party
re-identification (BFId, ACM CCS 2025); IEEE 802.11bf-2025 shipped with no
privacy mechanism; and no commercial product occupies the countermeasure
category.
[`wifi-veil`](https://github.com/ruvnet/wifi-veil) (codename VEIL, extracted
from this monorepo as a standalone crate) models a compliant emission-shaping
defense: keyed Givens rotations over the fine subspace of compressed
beamforming reports, energy-preserving (never jamming), reversible by a
keyed legitimate receiver. The crate is dependency-free, deterministic,
std-only, WASM-ready, dual MIT/Apache-2.0, and explicitly SYNTHETIC/L0: it
models waveform controls and never drives a radio.
RuView should consume this capability rather than re-implement it, giving the
sensing stack a defensive counterpart under one evidence regime.
## Options considered
1. **Vendor the veil sources into a RuView crate.** Rejected: forks the
witness-pinned upstream and duplicates maintenance.
2. **crates.io dependency.** Not yet available (v0.1.0 unpublished at
decision time); revisit when released.
3. **Git dependency pinned to an exact rev, feature-gated in
`wifi-densepose-bfld`.** Chosen.
## Decision
- Add `wifi-veil` to `v2/Cargo.toml` `[workspace.dependencies]` as a git
dependency pinned to rev `018468b5d2bf41f35c552910f35659830af0eb91`
(v0.1.0). Exact-rev pinning preserves provenance and reproducibility for a
pre-release upstream; bumping the rev is an explicit, reviewable change.
- Gate it in `wifi-densepose-bfld` behind a new `veil` feature
(`veil = ["std", "dep:wifi-veil"]`), off by default — the default build
remains dependency-light and unchanged.
- New `bfld::veil` module (advisory-only):
- `ShieldAssessment`: stable projection of wifi-veil's deterministic
attacker-vs-protector `ExperimentReport` (re-ID accuracy shield-off/on,
chance level, throughput ratio, energy-conservation audit), always
carrying the `SYNTHETIC/L0` evidence label.
- `assess` / `assess_default`: run the deterministic experiment.
- `optimized_shield`: wrap `hyper_optimize` to derive the
optimizer-shipped shield config plus its verifying assessment.
- Boundaries, stated structurally and in docs:
- **Advisory only.** Nothing in the integration emits RF, alters frames,
or relaxes any BFLD gate/invariant (I1–I3 untouched).
- **Evidence honesty.** Every veil-derived figure is labeled
`SYNTHETIC/L0`; no MEASURED claim is possible from this path (hardware
validation lives in wifi-veil's own P5 roadmap).
- ESP32 nodes cannot shield their own feedback (per wifi-veil's platform
matrix); the integration therefore informs posture and reporting, not
on-node emission control.
## Consequences
- RuView gains a sense-and-defend posture no commercial offering has, under
a single claim taxonomy.
- First git dependency in the workspace: builds now fetch one pinned
external rev. Acceptable: the crate is dependency-free, small, witness-
pinned upstream, and license-compatible (MIT OR Apache-2.0 into MIT).
- Feature-gated consumers (e.g. sensing-server privacy reporting, the
desktop UI) can surface shield assessments later without new deps.
- When wifi-veil publishes to crates.io, switch the workspace entry to a
version requirement in a follow-up ADR amendment.
## Validation
- `cargo test -p wifi-densepose-bfld --features veil` — determinism,
shield-reduces-re-ID, compliance (energy conservation), chance-band
attainment, evidence labeling, optimizer wrapper.
- `cargo test -p wifi-densepose-bfld` (default features) — unchanged
behavior with the feature off.
@@ -0,0 +1,64 @@
# ADR-295: Source provenance state machine — synthetic can never present as live
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: provenance, honesty, ui, sensing-server, security
## Context
An August 2026 external review found two provenance defects on the release
path:
1. The pose-fusion simulator starts in demo mode; on any page port other than
3000 the WebSocket target falls back to `localhost:8765`, and if the
connection fails the simulator keeps running while the status still reads
"ready" — producing a convincing moving visualization with no live CSI
(issue 1557).
2. The main sensing client labels the source **live** when the authenticated
status endpoint returns an error for lack of authorization, until a real
frame happens to correct it (issue 1526).
The common root cause: source state is a boolean (live vs not), so "unknown"
collapses to "live". CLAUDE.md requires MEASURED/CLAIMED/SYNTHETIC labeling
and forbids presenting synthetic output as real.
## Decision
Define one canonical, mutually exclusive `SourceState` enum shared by the
sensing server and every UI/client that renders a source:
- `Synthetic` — generated data (simulator/replay of synthetic fixtures).
- `LiveVerified` — frames from an authenticated, attested source.
- `LiveUnverified` — frames arriving but provenance not yet confirmed.
- `Stale` — last frame older than a configured freshness window.
- `Disconnected` — no source.
Rules enforced structurally:
- **`Unknown` is not a state.** Any ambiguous condition resolves to
`LiveUnverified`, `Stale`, or `Disconnected` — never `LiveVerified`.
- A status-endpoint error resolves to `Disconnected`/`LiveUnverified`, never
live-verified.
- The simulator constructs `Synthetic` and cannot transition to any `Live*`
state without a verified frame.
- `Synthetic` is watermarked in every view and every export.
- Transitions are a pure function of (last-frame-age, auth-status,
source-kind) so they are unit-testable without a clock or a socket.
Scope of this PR: the shared `SourceState` type + transition function + tests
in the sensing server, and wiring of the two identified surfaces (pose-fusion
simulator status, sensing client source label). Broader UI adoption follows.
## Consequences
- Closes the "synthetic shown as live" and "unknown shown as live" classes.
- A small breaking change to any consumer currently reading a boolean source
flag; mitigated by exposing a compatibility accessor during migration.
## Validation
- Unit tests for every transition, especially: auth-error → not-live;
simulator → never live without a verified frame; freshness expiry → `Stale`;
watermark present on synthetic export.
- `cargo test -p wifi-densepose-sensing-server`.
@@ -0,0 +1,59 @@
# ADR-296: Sensor data-plane hardening — UDP bind control and source allowlist (step one)
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: security, udp, sensor-ingest, sensing-server
## Context
The CSI UDP receiver binds `0.0.0.0:{udp_port}` unconditionally
(`main.rs:5706`), with no equivalent of the HTTP `--bind-addr` flag (which
correctly defaults to `127.0.0.1`), no source allowlist, no message
authentication, no device identity, and no replay defense. Any host that can
reach the UDP port can inject a valid-shaped frame, flip an auto-detecting
server into a live source state, and influence presence/vital/automation
outputs (issue 1394).
An IP allowlist does not stop LAN spoofing, but bind control plus an allowlist
is the correct, shippable first step; per-device keys + authenticated
encryption + monotonic sequence + freshness window + replay rejection is the
full fix and is larger.
## Decision
**This PR (step one):**
- Add `--udp-bind` (env `RUVIEW_UDP_BIND`), **defaulting to `127.0.0.1`**.
Binding to a routable address is now an explicit operator choice, mirroring
the HTTP path. Desktop/appliance defaults stay loopback.
- Add an optional source IP/CIDR allowlist (`--udp-allow`); when set, frames
from other sources are dropped and counted. Loopback is always allowed.
- Emit a startup security log line stating the bind scope and whether an
allowlist is active; refuse a routable bind without an allowlist unless an
explicit `--udp-insecure-lan` override is passed (parallel to the existing
Docker HTTP refusal).
- Publish a `SECURITY.md`/advisory note describing the threat model and safe
deployment.
**Explicitly deferred to a follow-up ADR (step two):** per-device provisioned
keys, MAC/AEAD, device identifiers, monotonic sequence numbers, freshness
window, and replay rejection. This ADR documents that gap rather than
implying the data plane is authenticated.
## Consequences
- Removes the default open-to-LAN exposure with a one-line-safe default.
- Not spoof-proof on a trusted LAN — the advisory says so plainly, and the
override name (`--udp-insecure-lan`) makes the residual risk legible.
- A behavior change for anyone relying on the old implicit `0.0.0.0` default;
called out in the changelog and the startup log.
## Validation
- Unit tests: default bind is loopback; routable bind without allowlist is
refused unless overridden; allowlist accept/drop with counting; loopback
always allowed.
- `cargo test -p wifi-densepose-sensing-server`.
- Real-silicon validation of the LAN path remains required before any
deployment claim.
@@ -0,0 +1,58 @@
# ADR-297: Multi-node semantic correctness — per-node inference, node-keyed rate limiting, stale state
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: multi-node, mqtt, home-assistant, correctness, sensing-server
## Context
The external review confirmed three defects on the multi-node path — the core
mechanism RuView uses to reduce blind spots and room dependence:
1. The active `NodeInfo` payload carries RSSI/position/subcarrier/sync but **no
per-node classification**; the MQTT mapper reads `node.classification` and
falls back to the room aggregate when absent, so every node can publish the
same aggregate presence value (issues 1540, 1554).
2. The MQTT `RateLimiter` is keyed by `EntityKind` only
(`mqtt/state.rs:65`), so one node consumes the numeric publish slot and the
others are suppressed until the interval expires, while availability still
says online (issue 1541).
3. In the UDP vital path, top-level classification is taken from the
latest-arriving node while other features are fused, so with disagreeing
nodes room presence can flip at packet frequency (issue 1555).
## Decision
- **Separate the types.** Introduce `NodeInference` (per-node classification +
confidence + freshness) distinct from `RoomInference` (the fused room
aggregate). `NodeInfo` carries a `NodeInference`; the room aggregate is
computed explicitly and never overwrites node state. No silent fallback from
node to room.
- **Key the rate limiter by (node, entity).** `RateLimiter` becomes keyed on
`(NodeId, EntityKind)` so nodes no longer starve each other; per-entity
behavior per node is preserved.
- **Deterministic fusion.** Room classification is a pure function of the set
of current per-node inferences (e.g. freshness-weighted vote), not
last-writer-wins; identical inputs yield identical room state.
- **Stale entities cannot stay online.** An entity whose backing node has not
reported within N expected publish intervals transitions to unavailable/
stale rather than holding a frozen value while availability says online.
## Consequences
- Multi-node HA/MQTT output becomes semantically correct; distinct nodes
report distinct state and no longer suppress one another.
- Schema change to `NodeInfo`/the MQTT contract; existing single-node
deployments keep working (one node = one inference). Consumers reading the
old aggregate-only shape need the migration accessor.
- Aligns with ADR-295 (freshness) and the review's call for one canonical
`NodeInference`/`RoomInference` contract.
## Validation
- Unit/integration tests: per-node classification round-trips through the MQTT
mapper with no room fallback; two nodes with different rates both publish
(no starvation); disagreeing nodes produce deterministic, non-flapping room
state; a silent node's entities go stale, not frozen-online.
- `cargo test -p wifi-densepose-sensing-server`.
@@ -0,0 +1,60 @@
# ADR-298: Model release sanity gates — block degenerate and mislabeled model artifacts
- **Status**: Accepted — initial implementation (this PR)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: models, evaluation, release-gate, honesty, presence
## Context
The external review (corroborating issue 1521) showed the published presence
head is mathematically degenerate: with L2-normalized embeddings, a weight
norm ≈ 3.67 against a bias ≈ 8.19 makes the smallest possible logit positive,
so predicted presence probability is ≥ ~0.989 for every valid input — the
decision boundary is unreachable and the head is effectively constant. The
README then labeled a temporal-triplet accuracy (a representation-ordering
metric) as "presence accuracy" — a category error.
Nothing in the release path catches a constant classifier, an unreachable
boundary, or a metric-name mismatch. A machine check would have.
## Decision
Add a `model_gates` module (in `wifi-densepose-train`) plus a CI gate that,
for any classifier artifact proposed for release, fails on:
- **Constant output** — output variance below a threshold across a diverse
probe set (including the degenerate-embedding probe from issue 1521).
- **Unreachable decision boundary** — for a normalized-embedding linear head,
check whether `bias` sign dominates `‖weight‖` so the logit cannot change
sign; fail if the boundary is analytically unreachable.
- **Degenerate class balance** — predicted-positive rate at/above a ceiling
(e.g. > 99%) on a balanced probe set.
- **Missing/blank baseline** — a report without a paired mean-pose/majority
baseline (ties into ADR-291 `EvaluationReport`).
- **Metric-name provenance** — a metric may not be surfaced under a task name
that does not match its computed kind (temporal-triplet ≠ presence);
enforced by making the metric carry its kind and the label derive from it.
Each gate emits a structured, human-readable failure explaining the defect and
the offending numbers.
## Consequences
- The specific degenerate presence head cannot ship again, and the
temporal-triplet-as-presence mislabel is structurally prevented.
- Some existing artifacts will fail the gate on introduction — intended; they
should fail.
- The gate is heuristic, not a correctness proof; it catches the known
failure shapes, not all bad models.
## Validation
- Unit tests: the issue-1521 weights fail the unreachable-boundary and
constant-output gates; a healthy synthetic head passes; a temporal-triplet
metric cannot be constructed with a presence label.
- `cargo test -p wifi-densepose-train`; the CI gate runs in the model-check
workflow.
- This ADR does **not** withdraw the already-published artifact (an
outward-facing action requiring maintainer sign-off) — it prevents
recurrence and documents the model-card correction.
@@ -0,0 +1,52 @@
# ADR-299: Repository CSI data-incident controls — ignore rules and a pre-commit/CI policy check
- **Status**: Accepted — controls and current-tree remediation implemented; history coordination pending
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: privacy, data-governance, ci, security, incident
## Context
The external review found ~64.6 MB of tracked raw CSI recordings under
`data/recordings/` and `v2/data/recordings/` (largest an ~61.8 MB overnight
capture). CLAUDE.md explicitly prohibits committing CSI or person data. The
`.gitignore` rule pointed only at a pre-rename path
(`rust-port/wifi-densepose-rs/data/recordings/`) and did not cover the active
directories, which is how the captures were committed. Raw CSI is person data
(it encodes breathing, movement, presence), so this is a data incident, not a
formatting nit.
## Decision
**Implemented now (mechanical, no data-ownership judgment):**
- Fix `.gitignore` to cover `data/recordings/`, `v2/data/recordings/`, the
legacy path, and `*.csi.jsonl` / `*.csi.meta.json` globs (done in this PR).
- Add a policy check (pre-commit hook + CI job) that fails when CSI-format
files (`*.csi.jsonl`, `*.csi.meta.json`) or large JSONL captures are staged
or present as tracked files, with a message pointing here. Tests may use
only synthetic or expressly-consented minimal fixtures.
**Owner-authorized current-tree remediation (2026-08-15):**
- 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 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
- 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,189 @@
# ADR-300: RuView perception substrate — a phased program for the calibration, evidence, trust, and deployment layer
- **Status**: Accepted — program framing; child ADRs carry their own status
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: program, architecture, calibration, evidence, provenance, fusion, fleet, epic
## Context
Three independent analyses converged on the same conclusion in 2026: a deep
research sweep of the WiFi-sensing state of the art, an external technical and
industry review, and an internal strategic assessment. All three found that
RuView's gap is **not another sensing modality** but the horizontal layer that
turns RF research into repeatable spatial infrastructure — measurement,
calibration, out-of-distribution awareness, evidence accounting, authenticated
identity, a canonical spatial model, and fleet deployment.
Several of these primitives already have foundations in the tree and should be
**unified and made to produce signed, expiring certificates**, not rebuilt:
- `wifi-densepose-calibration` (enrollment, bank, anchor, runtime, specialist).
- `frame::EvidenceLevel` L0–L5 as mandatory policy (ADR-282).
- AetherArena benchmark infrastructure — v0 complete, CI-gated, witness ledger,
live HF Space (ADR-149); board intentionally empty (benchmark-first).
- RuField provenance/signature types (ADR-260/262/277/279) and BFLD
attestation (ADR-141).
- `worldgraph` crate; `wifi-densepose-mat/tracking` (tracker, fingerprint).
- The in-flight ADR-295 (provenance state machine), ADR-296 (authenticated
data plane, step one), ADR-298 (model sanity gates) — the first bricks.
## What RuView is optimizing for
Not inference capability — **epistemic reliability**:
```
signal → observation → calibration → inference → uncertainty → evidence
→ certificate → policy → governed action
```
That pipeline is the product. The defensible category is not "RuView perceives
the physical world" but "RuView determines what machines are justified in
believing about it, proves why, and constrains what they may do with that
belief."
### Four non-negotiable program rules
Every child ADR and implementation is bound by these:
1. **UNKNOWN is a first-class output, never an error condition.** A surface that
cannot answer says UNKNOWN and stays legible; it does not throw, default to a
confident class, or silently hold a stale value.
2. **Capability certificates bind cryptographically.** Hardware, environment,
model, calibration, metrics, expiry, and evidence level are bound under one
signature (ADR-318/ADR-305). An unsigned or partially-bound certificate is
not a certificate.
3. **One canonical semantics downstream.** Every surface (MQTT, REST, WebSocket,
RuField, Matter, agents, UI) consumes the same Observation → Inference →
GovernedEvent types (ADR-306). No transport- or UI-specific reinterpretation.
4. **Benchmarks expose worst-domain performance and confidence intervals.**
Pooled accuracy is never sufficient for promotion (ADR-317).
### Certificate conditionality (the staleness guard)
The central architectural risk is **certificate staleness**: a room can remain
syntactically calibrated while its RF distribution has drifted enough to
invalidate the certificate. Therefore a capability certificate is **conditional
on a continuously evaluated domain signature** (ADR-302), not a one-time stamp.
Crossing the OOD threshold automatically degrades state and triggers
recalibration rather than silently continuing:
```
VALID → DEGRADED → UNKNOWN (auto-degrade on domain drift; triggers recalibration)
```
This binds ADR-301 (calibration), ADR-302 (OOD), ADR-318 (certificate), and
ADR-321 (policy): a degraded/unknown domain must invalidate the affected
capability *before* a false confident inference reaches an actuator.
### Commercial framing — three primitives, not one product
- **RuView Runtime** — provides perception.
- **RuView Certify** — establishes what a deployment can legitimately claim
(calibration + evidence + capability certificate + policy).
- **RuView Trust / Fleet** — keeps that claim valid across hardware, firmware,
models, and environmental drift (ADR-316).
Certify and Trust are the parts that are hard to commoditize; presence
detection alone is not.
## Decision
Adopt a **21-primitive phased program**. Each primitive gets a child ADR
(ADR-301…ADR-321) that owns its detailed decision, status, and validation.
This ADR owns the framing, the dependency order, and the phase assignment.
### Primitive → ADR map
| # | Primitive | ADR | Phase |
|---|---|---|---|
| 1 | Automatic domain calibration | ADR-301 | 1 |
| 2 | Out-of-distribution detection | ADR-302 | 1 |
| 3 | Ground-truth synchronization | ADR-303 | 2 |
| 4 | Evidence engine | ADR-304 | 1 |
| 5 | Authenticated sensor identity | ADR-305 | 1 |
| 6 | Canonical spatial ontology | ADR-306 | 1 |
| 7 | Persistent identity & tracking | ADR-307 | 2 |
| 8 | Sensor placement optimizer | ADR-308 | 3 |
| 9 | Active sensing | ADR-309 | 3 |
| 10 | 802.11bf-native architecture | ADR-310 | 2 |
| 11 | Real sensor fusion | ADR-311 | 2 |
| 12 | Long-term spatial memory | ADR-312 | 3 |
| 13 | Counterfactual inference | ADR-313 | 3 |
| 14 | Information-gain scheduler | ADR-314 | 3 |
| 15 | Digital RF twin | ADR-315 | 3 |
| 16 | Fleet control plane | ADR-316 | 2 |
| 17 | Real benchmark service (multi-domain scorecard) | ADR-317 | 1 |
| 18 | Capability certificates | ADR-318 | 1 |
| 19 | Witness chain | ADR-319 | 1 |
| 20 | RuView sensor HAL | ADR-320 | 2 |
| 21 | Decision policy — action authorization | ADR-321 | 1 |
### Dependency order (why phase, not score, drives sequencing)
```
ADR-306 spatial ontology ──┐
ADR-305 auth identity ─────┼──► ADR-301 calibration cert ──► ADR-302 OOD gating
│ │ │
└──► ADR-319 witness chain │ (VALID→DEGRADED→UNKNOWN)
│ ▼
ADR-304 evidence engine ──► ADR-318 capability certificate
│ │ (conditional on domain signature)
│ ▼
│ ADR-321 decision policy ──► governed action
└──► ADR-317 benchmark scorecard (per-PR gate)
```
- **Phase 1 (the certificate spine, built now):** foundational roots 303, 302,
301, 298 (implemented first, in their own crates); then the dependent wave
316, 299, 315, 314, 318. This set is exactly the acceptance test decomposed
and is buildable without new hardware (types, logic, signatures, tests). The
dependent wave adds the staleness guard (299 auto-degrades 315) and the
action gate (318) that denies at the actuator on a degraded/unknown domain.
- **Phase 2 (integration & operations):** 300 ground truth, 304 tracking, 307
802.11bf-native, 308 fusion, 313 fleet, 317 HAL. Depends on the spine.
- **Phase 3 (higher-ceiling, research-forward):** 305 placement optimizer, 306
active sensing, 309 spatial memory, 310 counterfactual, 311 info-gain
scheduler, 312 RF twin. Sit on top of the fused world state.
Phase-2 and phase-3 child ADRs are authored as **Proposed** (design intent,
validation plan) and are not implemented by the phase-1 swarm.
### Acceptance test A — onboarding (from the strategic assessment)
> Connect a new sensor type in an unseen room. Within 30 minutes RuView should
> identify the hardware (HAL, ADR-320), calibrate the environment (ADR-301),
> quantify whether it can reliably sense the requested phenomenon (ADR-302),
> generate a signed capability certificate (ADR-318), expose governed spatial
> events (ADR-306), and return UNKNOWN whenever evidence falls outside that
> certificate (ADR-302).
### Acceptance test B — drift invalidation (the staleness guard)
> Deliberately change the room after certification — move furniture, change the
> AP channel, or substitute hardware. RuView should detect distribution drift
> (ADR-302), invalidate the affected capability (ADR-318) **before** a false
> confident inference reaches an actuator (ADR-321 denies with the specific
> failed condition), emit UNKNOWN, preserve the complete witness chain
> (ADR-319), and explain exactly which certificate condition failed.
Test B is the load-bearing one: it proves the substrate fails safe, not just
that it perceives well. Phase 1 makes every clause except HAL testable in
software; HAL (phase 2)
closes the "identify the hardware" clause.
## Consequences
- One coherent substrate replaces overlapping ad-hoc schemas; every surface
(MQTT, REST, WebSocket, RuField, Matter, agents) eventually consumes the
ADR-306 ontology and the ADR-318 certificate.
- Headline applications (pose/vitals/pointcloud models) are explicitly **not**
the investment focus during this program, per the strategic direction.
- Later ADRs may be revised as the spine lands; that is expected for a phased
program and is why phase-2/3 ADRs ship as Proposed.
## Validation
- Each child ADR defines its own tests. The program-level exit is the
acceptance test above, run end-to-end once phase 1 lands, and encoded as an
AetherArena scenario (ADR-317).
@@ -0,0 +1,149 @@
# ADR-301: Automatic domain calibration — signed, versioned, invalidatable room fingerprint
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: calibration, provenance, drift, evidence, honesty, substrate
## Context
This ADR is primitive 1 of the perception-substrate program (ADR-300) and the
first brick of that program's "certificate spine" (ADR-300 phase 1). It depends
on the canonical spatial ontology (ADR-306) to name *which space* it
characterizes, on authenticated sensor identity (ADR-305) to bind a fingerprint
to *which signed device* produced it, and on the witness chain (ADR-319) to
anchor the resulting artifact. Its output is consumed directly by
out-of-distribution detection (ADR-302).
WiFi sensing is only reproducible inside the environment it was tuned for.
Multipath, furniture geometry, transceiver placement, and AP channel all shape
the CSI distribution, so a model that reads a room correctly one week can drift
silently the next. RuView already has the raw ingredients for room-aware
sensing but not a single portable, signed, expiring artifact that says "this is
the room, here is when it was measured, and here is the evidence that it is
still the same room."
Existing scaffolding to build on, not rebuild (`v2/crates/wifi-densepose-calibration`):
- `enrollment` / `anchor` — guided human anchors with an adaptive quality gate.
- `bank` / `specialist` / `runtime` — a versioned bank of small specialist
models and a confidence-gated mixture runtime (`RoomState`), including the
crate's existing honest `STALE` degradation when the ADR-135 empty-room
baseline drifts.
- `geometry` / `geometry_embedding` — transceiver-geometry record and its
fixed-length conditioning featurization (ADR-152).
What is missing is (a) an *automatic* observe-only characterization phase that
does not require a human enrollment ritual, (b) empty-vs-occupied baseline
separation as a first-class pair, (c) a signed, versioned, comparable
`CalibrationCertificate` artifact, and (d) explicit invalidation on drift rather
than a soft `STALE` flag buried in the runtime.
## Options considered
1. **Keep calibration internal to the runtime (status quo).** Rejected: the
room characterization exists only as in-process state; it cannot be signed,
shipped, compared across time, or presented as evidence to ADR-302/ADR-318.
2. **Build a new calibration crate.** Rejected: `wifi-densepose-calibration`
already owns enrollment, the specialist bank, geometry embedding, and the
baseline-drift concept. A parallel crate would fork the room model.
3. **Extend `wifi-densepose-calibration` with an automatic characterization
phase and a signed certificate artifact.** Chosen.
## Decision
Extend `v2/crates/wifi-densepose-calibration` with an `autocal` characterization
phase and a `certificate` artifact module. The target UX is:
> install → observe (~10 min) → room fingerprint → calibration certificate →
> sensing.
### 1. Automatic characterization (`autocal`)
- An observe-only pass (default ~10 minutes, configurable) that collects CSI
without requiring guided human anchors, reusing the `anchor` quality gate to
reject frames it cannot trust. It layers on the existing ADR-135 empty-room
baseline rather than replacing it.
- Produces a `RoomFingerprint`: a bounded, fixed-length statistical summary of
the room's CSI distribution (subcarrier amplitude/phase moments, multipath
structure, occupancy-band energy), plus the `geometry_embedding` when a
geometry record is present. The fingerprint is the distance-comparable object
ADR-302 measures against; its schema is versioned.
### 2. Empty / occupied baseline pair
- Characterization establishes a paired baseline: an **empty** distribution
(no occupant motion) and an **occupied** distribution (motion present),
separated by the existing occupancy signal rather than a manual label. Both
are stored on the fingerprint so downstream OOD gating can distinguish "the
empty room changed" (furniture/geometry drift) from "occupancy statistics
changed" (different subject dynamics).
### 3. `CalibrationCertificate` artifact
- A serializable `CalibrationCertificate` binding: the `RoomFingerprint`; a
space identifier from the ADR-306 ontology; the signing sensor identity from
ADR-305; `captured_at_unix_s`; a monotonic `version`; a schema version; the
calibration `tier`; and an `EvidenceLevel` (L0–L5, ADR-282) — an automatic
characterization on real captured CSI is at most L1/L2 and is labelled as
such, never L3+.
- The certificate is **signed** using RuField provenance/signature types
(ADR-260/262/277/279) and anchored in the witness chain (ADR-319). Signature
and witness anchoring are mandatory: an unsigned certificate is not a valid
certificate.
- Two certificates for the same space are **comparable**: `distance(a, b)`
returns a bounded fingerprint distance, which is the primitive ADR-302 uses
to gate KNOWN → DEGRADED → UNKNOWN.
### 4. Invalidation and continuous drift compensation
- A certificate carries an explicit validity policy: it is invalidated when
fingerprint distance against live traffic exceeds a threshold, when the AP
channel or transceiver geometry changes, when the signing device identity
changes, or on age expiry. Invalidation is an explicit state transition that
emits a witness record (ADR-319), not a silent `STALE` flag.
- Continuous drift compensation runs as a bounded online update of the
fingerprint within a **compatibility envelope**: small drift is absorbed and
logged; drift beyond the envelope invalidates the certificate and forces
re-characterization. Compensation never silently rewrites a signed
certificate — it produces a new version, preserving the append-only history.
### Provenance and honesty discipline
- No accuracy number is claimed by this ADR; it delivers the artifact and the
distance/invalidation machinery. Any certificate produced from generated CSI
is L0/`Synthetic` by construction; the constructor rejects labelling
synthetic characterization as measured (ADR-279 invariant 6, ADR-282 ladder).
- Certificates never leave the edge except through the governed control plane
(ADR-277); a room fingerprint is treated as potentially sensitive spatial
data, not free telemetry.
## Consequences
- Room characterization becomes a portable, signed, versioned artifact that
ADR-302 (OOD), ADR-318 (capability certificates), and ADR-317 (benchmark)
can consume without re-deriving room state.
- The automatic observe-only path lowers deployment friction (no mandatory
enrollment ritual) but yields a weaker evidence level than guided enrollment;
the certificate states which path produced it so consumers can weight it.
- Explicit invalidation means RuView will sometimes refuse to sense a changed
room until re-characterization. That refusal is the intended honest behavior,
surfaced by ADR-302, not a regression.
- The existing enrollment/bank/runtime path is preserved; `autocal` is an
additional entry point that produces the same `RoomFingerprint` object the
guided path can also emit.
## Validation
- `cargo test -p wifi-densepose-calibration` — fingerprint determinism from
fixed synthetic CSI; empty/occupied separation on synthetic occupancy;
certificate signing/verification round-trip and tamper rejection;
`distance()` monotonicity on progressively perturbed fixtures; invalidation
transitions (channel change, geometry change, age, drift-envelope breach)
each emit the expected witness record; constructor rejects synthetic→measured
mislabeling.
- Cross-ADR: an ADR-302 test consumes a certificate and asserts the gating
state transitions on a drifted fingerprint.
- Real-silicon characterization (ESP32 capture over a real 10-minute window)
remains a follow-up requiring hardware evidence per CLAUDE.md; a successful
build or synthetic run is not hardware evidence.
@@ -0,0 +1,135 @@
# ADR-302: Out-of-distribution detection — KNOWN / DEGRADED / UNKNOWN gating
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: ood, calibration, uncertainty, quality, evidence, honesty, substrate
## Context
This ADR is primitive 2 of the perception-substrate program (ADR-300) and part
of the phase-1 certificate spine. It sits directly downstream of automatic
domain calibration (ADR-301): the `CalibrationCertificate` and its
`RoomFingerprint` are the reference distribution this ADR measures against. It
reuses fusion-layer quality scoring (ADR-137) as one of its inputs and feeds
its state into the evidence engine (ADR-304) and capability certificates
(ADR-318).
The central unsolved problem of WiFi sensing is cross-domain generalization: a
model trained (or calibrated) in one room degrades unpredictably in another, or
in the same room after furniture moves, the AP changes channel, or the radio
hardware is swapped. A model that keeps returning confident classifications
under these conditions is the single most misleading failure mode in the field,
and it is the failure the strategic assessment (ADR-300) named explicitly.
Confidence alone is insufficient: a softmax head is perfectly capable of being
confidently wrong on out-of-distribution input. RuView must be able to say
"I do not recognize this situation" instead of guessing.
Today RuView has partial signals but no unified gate:
- ADR-301 produces a comparable `RoomFingerprint` and a `distance()` metric.
- ADR-137 `QualityScore` carries fusion coherence, evidence references, and
contradiction flags per fused frame.
- Model heads emit confidence/uncertainty, but nothing combines domain
distance, signal quality, calibration compatibility, and uncertainty into a
single decision, and nothing forces a model to stop emitting confident labels
when it leaves its calibrated domain.
## Options considered
1. **Threshold on model confidence alone.** Rejected: confidently-wrong OOD
predictions are exactly the failure mode; confidence is necessary but not
sufficient.
2. **A per-model bespoke OOD check inside each task head.** Rejected:
duplicates logic, cannot be audited uniformly, and does not compose with the
calibration certificate or the evidence engine.
3. **A shared OOD gate that every inference passes through, fusing four signals
against the ADR-301 certificate.** Chosen.
## Decision
Add an out-of-distribution gate — implemented in a shared crate consumed by the
task-head runtime (`wifi-densepose-calibration::runtime` and the model serving
path) — that attaches a `DomainState` to **every** inference.
### 1. Four inputs, one decision
Each inference carries four measured quantities:
1. **Domain distance** — fingerprint distance (ADR-301 `distance()`) between
live traffic and the active `CalibrationCertificate`, split into the
empty-baseline and occupied-baseline components so geometry drift and
occupancy-statistics drift are distinguishable.
2. **Signal quality** — reuse the ADR-137 quality scoring signals (fusion
coherence, contradiction flags) plus per-frame SNR/validity.
3. **Calibration compatibility** — is a valid, non-invalidated certificate
present for this space (ADR-306) and this signed device (ADR-305)? An
expired, invalidated, or device-mismatched certificate is itself a
compatibility failure.
4. **Uncertainty** — the model head's own predictive uncertainty.
### 2. State machine: KNOWN → DEGRADED → UNKNOWN
- **KNOWN** — domain distance within the certificate's compatibility envelope,
quality above threshold, certificate valid and compatible, uncertainty low.
Confident classifications are returned.
- **DEGRADED** — one or more signals crossed a soft threshold (e.g. moderate
fingerprint drift within the envelope, elevated uncertainty, a tolerated
ADR-137 contradiction flag). Classifications are returned but flagged
degraded with the specific reason; downstream consumers must treat them as
lower-evidence.
- **UNKNOWN** — the room changed materially (empty-baseline drift beyond the
envelope, AP channel change, transceiver-geometry change, hardware/device
change, or an invalidated/absent certificate). RuView **stops returning
confident classifications** and returns UNKNOWN with the triggering cause.
This is the required behavior, not an error.
State transitions are hysteretic (separate enter/exit thresholds) so the gate
does not flap on noise. The state, the four input values, and the triggering
cause are all reported — never a bare label.
### 3. Certificate-bound, honest by construction
- The gate is meaningless without a certificate: with no valid ADR-301
certificate for the current space/device, the default state is UNKNOWN, not
KNOWN. Absence of evidence is treated as absence of capability.
- The `DomainState` and its inputs are emitted to the evidence engine
(ADR-304) as part of every inference record, and are an input to the ADR-318
capability certificate (a model's capability is bounded by the domain it can
hold KNOWN in).
- No accuracy number is claimed here; the ADR delivers the gating machinery.
The gate's own thresholds are calibration parameters, reported with each
decision.
## Consequences
- RuView gains a uniform, auditable answer to "should I trust this inference?"
that combines domain, quality, calibration, and uncertainty rather than
confidence alone.
- Deployments will see more DEGRADED/UNKNOWN results than a
confidence-only system, especially right after a room changes. That increase
is the product working: it is the difference between honest RF perception and
confidently-wrong output.
- Every task head that opts into the substrate must route through the gate;
heads that bypass it cannot claim a KNOWN state or earn an ADR-318
certificate.
- The gate couples model serving to the presence of a live calibration
certificate, making ADR-301 a hard dependency of confident inference — the
intended coupling.
## Validation
- `cargo test` on the OOD crate — state-machine transitions on synthetic
fixtures: in-envelope drift stays KNOWN; soft-threshold breach → DEGRADED;
empty-baseline drift beyond envelope, channel change, geometry change,
device mismatch, and invalidated/absent certificate each → UNKNOWN;
hysteresis prevents flapping under injected noise; missing certificate
defaults to UNKNOWN.
- Cross-ADR: consumes an ADR-301 certificate and asserts a drifted fingerprint
drives the expected transition; asserts the `DomainState` is present on every
emitted inference record consumed by ADR-304.
- No confident classification is emitted in the UNKNOWN state in any test —
enforced as an assertion, not a convention.
- Real-silicon OOD behavior (moving furniture / changing AP channel on a live
ESP32 capture and observing the transition) remains a follow-up requiring
hardware evidence per CLAUDE.md.
@@ -0,0 +1,125 @@
# ADR-303: Ground-truth synchronization — reference sensors as a formal validation plane
- **Status**: Accepted — initial implementation (ADR-300 phase 2)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: ground-truth, validation, fusion, evidence, benchmark, honesty, substrate
## Context
This ADR is primitive 3 of the perception-substrate program (ADR-300), authored
as **Proposed** in phase 2: it is design intent and a validation plan, not
implemented by the phase-1 swarm. It sits on top of the phase-1 certificate
spine and feeds the evidence engine (ADR-304) and the real benchmark service
(ADR-317). It generalizes the vitals ground-truth rig (ADR-293) from a single
measurand to a modality-agnostic plane.
RuView's evidence discipline (CLAUDE.md; ADR-282 ladder) requires MEASURED
accuracy claims to be backed by an independent reference. ADR-293 built exactly
this for vitals: reference-series ingest, time alignment (cross-correlation
lag + optional clock-drift fit), and agreement statistics (MAE/RMSE/bias/
Bland–Altman/within-tolerance), with an `EvidenceGrade` that is only
constructible as `Measured` when a real reference, non-zero paired samples,
minimum coverage, and a reproducer are present. That machinery is measurand- and
device-shaped: it knows about heart rate and breathing rate.
The substrate needs the same discipline for *every* phenomenon RuView senses —
presence, count, localization, pose, posture, activity — and for reference
sources of many modalities (cameras, mmWave, pressure mats, wearables, pulse
oximeters, microphones, manual labels). The critical design decision is that
these reference sensors form a **validation plane**, not additional inference
inputs.
## Options considered
1. **Fuse reference sensors as extra inference inputs.** Rejected on principle:
folding cameras/mmWave into the estimator would make RuView's RF claims
unfalsifiable — the reference would be training the thing it is meant to
check, and a camera-fed result is no longer a camera-free RF result. It
would also violate the ADR-282 layering (RuView is probabilistic
exteroception, never ground truth) and the honesty rule against presenting
fused-with-camera output as WiFi sensing.
2. **One-off rigs per measurand (extend ADR-293 ad hoc each time).** Rejected:
duplicates alignment/agreement code per phenomenon and never yields a shared
validation surface for the benchmark.
3. **A first-class, modality-agnostic `GroundTruth` API that is strictly a
validation plane.** Chosen.
## Decision
Introduce a `GroundTruth` API — a modality-agnostic validation plane that
compares RF inference against independent observation and never feeds it.
### 1. Modality-agnostic reference ingest
- A `ReferenceObservation` generalizing ADR-293's `ReferenceSeries`: a
timestamped, typed observation of a `Phenomenon` (presence, count,
localization, pose keypoints, posture, activity, heart rate, breathing rate)
from a `ReferenceModality` (camera, mmWave, pressure, wearable, pulse
oximeter, microphone, manual label), with device/source metadata and the
measurement principle recorded.
- Untrusted reference files are validated at the boundary (row-numbered
rejections, non-monotonic timestamps are errors), reusing ADR-293's ingest
discipline. Camera/mmWave references arrive as exported label/keypoint
streams, not live model feeds.
### 2. Synchronization
- Generalize ADR-293's time alignment (bounded-lag normalized cross-correlation
+ optional linear clock-drift fit) to arbitrary measurands on a common
resampled grid, with no interpolation across gaps beyond a configurable
limit. Alignment parameters are always reported, never silently applied.
- Spatial synchronization where relevant: reference observations are expressed
in the ADR-306 spatial ontology so an RF localization/pose result and a
camera/mmWave observation are compared in one coordinate frame.
### 3. Agreement as validation, not fusion
- A modality-appropriate `AgreementReport` per phenomenon: continuous
measurands reuse ADR-293's MAE/RMSE/bias/Bland–Altman/within-tolerance;
categorical/detection phenomena (presence, activity) report confusion-matrix
metrics; spatial phenomena report localization error percentiles and pose
PCK **with the mandatory mean-pose baseline and leakage-free split**
(CLAUDE.md; ADR-291).
- Session scope is mandatory metadata (subject count, motion state, LOS/NLOS/
through-wall, distance band) — a report without scope cannot be constructed,
as in ADR-293.
### 4. Evidence and isolation guarantees
- The plane is one-directional by type: the inference path has no read access
to `GroundTruth` at runtime. A build/test-time isolation check (and the type
boundary) prevents a reference observation from becoming an estimator input.
- Reports carry an `EvidenceLevel` (ADR-282) and an `EvidenceGrade`
constructible as `Measured` only with a real reference, paired samples,
coverage, and a reproducer (ADR-293 rule). Reports feed the ADR-304 evidence
engine and are the substrate ADR-317 scores against.
## Consequences
- Every phenomenon RuView senses gets the same MEASURED-vs-independent-observer
discipline vitals already has, in one shared surface.
- Keeping references strictly as validation preserves the falsifiability and
the camera-free identity of RF results; it costs the (tempting) accuracy a
camera-fused estimator would show, which is the correct trade.
- Reference capture is an operational burden (a camera/mmWave rig per validated
session); acceptable because it is a validation activity, not a runtime
requirement, and it is what turns CLAIMED into MEASURED.
- Because this is Proposed (phase 2), the API shape may be revised once the
phase-1 spine (ADR-301/299/301/303) lands and the benchmark (ADR-317)
exercises it.
## Validation
- Unit tests (planned): modality-agnostic ingest rejection cases; alignment
recovery of known synthetic offsets/drifts across measurands; agreement math
per phenomenon against hand-computed fixtures; pose PCK path requires a
mean-pose baseline and rejects leaky splits; evidence-grade constructibility;
the isolation check fails a build that wires a reference into the inference
path.
- Cross-ADR: an ADR-317 benchmark scenario consumes `GroundTruth` reports as
its scored reference; ADR-304 ingests the agreement reports as evidence
records.
- Real-session validation (RF capture synchronized with a real camera/mmWave/
pressure/wearable reference) is the phase-2 exit and requires hardware
evidence per CLAUDE.md; a synthetic run is not hardware evidence.
+117
View File
@@ -0,0 +1,117 @@
# ADR-304: Evidence engine — MLflow for physical sensing
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: evidence, provenance, ledger, accuracy, drift, benchmark, honesty, substrate
## Context
This ADR is primitive 4 of the perception-substrate program (ADR-300) and a
central pillar of the phase-1 certificate spine. It consumes the domain state
from out-of-distribution detection (ADR-302) and the calibration age from the
calibration certificate (ADR-301), it is the store that capability certificates
(ADR-318) are minted from, and it is the accuracy source the real benchmark
service (ADR-317) reads. In phase 2 it ingests agreement reports from the
ground-truth plane (ADR-303).
The strategic assessment (ADR-300) judged this primitive **more commercially
important than another pose architecture**: what unblocks OEM and integrator
conversations is not a higher headline number but a defensible, auditable record
of how a model actually performs, per room, per device, per subject, over time.
MLflow made ML experiments trackable; physical sensing needs the equivalent for
deployed accuracy, drift, and evidence level — an append-only ledger, not a
dashboard that overwrites yesterday's number.
RuView already has the constituent evidence types; what is missing is the ledger
that unifies them per deployment context:
- RuField provenance/signature types (ADR-260/262/277/279) — the signed,
provenance-bearing record types to reuse rather than reinvent.
- The AetherArena witness-ledger pattern (ADR-149) — an append-only,
witness-anchored ledger of scored results, the structural template here.
- `frame::EvidenceLevel` L0–L5 (ADR-282) — the mandatory evidence tag every
record carries.
- ADR-302 `DomainState`, ADR-137 `QualityScore`, ADR-301 certificate version
and age — the per-inference signals to accumulate.
## Options considered
1. **Log accuracy to flat files / metrics dashboards.** Rejected: mutable,
un-signed, un-scoped, and not comparable over time — the exact gap.
2. **Reuse a general experiment tracker (MLflow itself).** Rejected: it is
experiment-time, not deployment-time; it has no notion of room/device/
subject context, calibration age, evidence level, or signed provenance, and
it would add an external service dependency contrary to the substrate's
edge-first, dependency-light direction.
3. **A native append-only evidence ledger reusing RuField record types and the
AetherArena ledger pattern.** Chosen.
## Decision
Build an **evidence engine**: a per-`(room, device, subject)` append-only
accuracy ledger that every model automatically writes to.
### 1. The evidence record
- An `EvidenceRecord` keyed by context — space id (ADR-306), signed device id
(ADR-305), and subject id where consented and available — carrying: model
version; calibration certificate version and **age** (ADR-301); the ADR-302
`DomainState` (KNOWN/DEGRADED/UNKNOWN) and its four inputs; the ADR-137
quality signals; predictive uncertainty; and, when a reference is present
(ADR-303), the agreement result (accuracy, false-positive rate). Each record
carries exactly one `EvidenceLevel` (L0–L5, ADR-282).
- Records are **append-only** and signed with RuField signature types
(ADR-260/262/277/279); the ledger is anchored in the witness chain (ADR-319),
following the AetherArena witness-ledger pattern (ADR-149). No record is ever
mutated in place — a correction is a new record.
### 2. Per-context accuracy accounting
- The engine maintains, per `(room, device, subject)` context: measured
accuracy (only where an ADR-303 reference backs it — otherwise the record is
CLAIMED/SYNTHETIC, never MEASURED), false-positive rate, drift trajectory
(fingerprint distance over time from ADR-301), the fraction of inferences in
each domain state, calibration age distribution, and model-version history.
- Aggregation is a pure function over the append-only log at a queried time —
the ledger is the source of truth; summaries are derived, never authoritative
(mirroring CLAUDE.md's "source over summaries" rule).
### 3. Honesty enforced in the record
- The engine cannot upgrade an evidence level; a level is set by the record's
provenance at write time (synthetic input → L0/`Synthetic`; no reference →
CLAIMED; reference + reproducer → MEASURED), reusing the ADR-282/ADR-291/
ADR-293 constructor discipline. A benchmark or certificate reading the ledger
gets the honest level, not an optimistic rollup.
- No benchmark numbers are invented by this ADR; it delivers the ledger and the
accounting. Empty contexts report "no evidence," which downstream (ADR-318)
must treat as no capability.
## Consequences
- RuView gains a single auditable answer to "how well does this model actually
work, here, on this device, for this subject, and how fresh is the
calibration?" — the artifact OEM/integrator diligence actually asks for.
- ADR-318 capability certificates become derivable (a certificate is a signed
attestation over a slice of the ledger) and ADR-317 gains a real accuracy
source per PR instead of self-reported numbers.
- The append-only, signed design has storage and key-management cost; bounded
by per-context retention policy and by reusing the existing RuField/witness
infrastructure rather than a new store.
- Some contexts will show sparse or unflattering evidence. Surfacing that is the
point; the engine must never paper over a thin context with a global average.
## Validation
- `cargo test` on the evidence-engine crate — append-only invariant (no
in-place mutation; corrections are new records); per-context aggregation math
against fixtures; evidence-level is set by provenance and cannot be upgraded;
signature round-trip and tamper rejection; witness anchoring; empty-context
queries return "no evidence" not a fabricated number.
- Cross-ADR: ingests ADR-302 `DomainState` and (phase 2) ADR-303 agreement
reports; an ADR-318 test mints a certificate from a ledger slice and an
ADR-317 test reads accuracy from the ledger.
- Real-deployment evidence (a populated ledger from live ESP32 captures with
ADR-303 references) is the maturity milestone and requires hardware evidence
per CLAUDE.md; a synthetic ledger is L0 by construction.
@@ -0,0 +1,147 @@
# ADR-305: Authenticated sensor identity — RF chain of custody
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: security, identity, provenance, sensor-ingest, attestation, phase-1
## Context
This ADR is a child of **ADR-300** (perception substrate program) and owns
primitive #5, *authenticated sensor identity*. In the ADR-300 dependency DAG it
is a spine root that, together with **ADR-306** (canonical spatial ontology),
feeds **ADR-301** (calibration certificate) and **ADR-319** (witness chain).
RuView's inference outputs are only as trustworthy as the measurements that
produced them, yet today a measurement's origin is essentially assertional. The
UDP data plane accepts frames from any reachable host: **ADR-296** shipped step
one — a loopback-default bind (`--udp-bind`) and an optional source
IP/CIDR allowlist — and explicitly deferred to a follow-up ADR "per-device
provisioned keys, MAC/AEAD, device identifiers, monotonic sequence numbers,
freshness window, and replay rejection." **This ADR is that step two.** ADR-296
correctly documented that an IP allowlist does not stop LAN spoofing; a
cryptographic device identity is what closes that gap.
Foundations already exist in the tree and must be reused rather than rebuilt:
- `wifi-densepose-rufield` provides `DeviceId`, `Signature`, `SignatureBlock`,
`FrameProvenance`, `ProvenanceClass`, and `SignatureVerifyError` — the type
vocabulary for a signed frame.
- `wifi-densepose-bfld` provides `CapabilityAttestation` and
`PrivacyAttestationProof` (BFLD attestation, ADR-141) — the device-side
attestation surface.
- **ADR-295** defines the source-provenance state machine and freshness
(`SpatialStateFreshness`); a monotonic sequence and freshness window slot
into that machine rather than duplicating it.
The gap is not new primitives but an **end-to-end chain of custody**: a frame
must be traceable as `device → signed measurement → sequence → timestamp →
calibration → inference → signed event`, with every link verified at the
ingest boundary per CLAUDE.md ("validate untrusted input at every network,
hardware, and FFI boundary; default to least authority").
## Options considered
1. **Stop at ADR-296 (bind + IP allowlist).** Rejected: ADR-296 itself names
this insufficient on a trusted LAN; any on-subnet host can still spoof a
device.
2. **TLS/DTLS transport authentication only.** Rejected: authenticates the
*channel*, not the *measurement*. It does not survive store-and-forward,
does not bind a sequence number into the signed object, and gives the
downstream evidence/witness layers nothing to re-verify offline.
3. **Per-device signing keys with a signed measurement envelope, monotonic
sequence, and freshness window, reusing the RuField/BFLD types.** Chosen.
## Decision
Introduce an **authenticated frame envelope** carried through the sensing
server, built from existing RuField/BFLD types.
### 1. Per-device provisioned identity
- Each radio (ESP32-S3/C6 node or adapter) is provisioned with a keypair; the
device holds the private key, the server holds the enrolled public key bound
to a `DeviceId`. Provisioning is an explicit, authorized enrollment step — a
device is untrusted until an operator enrolls its public key. Private keys are
never logged or committed (CLAUDE.md credential rule); the ESP32 side follows
`firmware/esp32-csi-node` key-handling notes.
- The enrollment record binds `DeviceId → public key → capabilities`
(via `CapabilityAttestation`, ADR-141), so a device can only assert
measurements for phenomena it is attested to sense. This is what **ADR-318**
(capability certificate) later consumes.
### 2. Signed measurement envelope
- A frame on the wire becomes a `SignatureBlock` over the canonical
serialization of `{DeviceId, sequence, timestamp, measurement-hash}`. The
measurement itself (CSI/CIR payload) is covered by the hash so tampering is
detectable without embedding the whole payload twice.
- Verification uses `Signature`/`SignatureVerifyError` from
`wifi-densepose-rufield`. A frame that fails signature verification is
dropped and counted, exactly as ADR-296 drops disallowed sources — an `Err`
at the boundary, never a warning that proceeds.
### 3. Monotonic sequence + freshness (replay defense)
- Each device maintains a strictly monotonic per-device sequence number. The
server tracks the last accepted sequence per `DeviceId`; a non-increasing
sequence is rejected as a replay.
- A freshness window bounds `timestamp` against the server clock skew budget;
stale frames are rejected. This reuses ADR-295's `SpatialStateFreshness`
rather than inventing a parallel notion of staleness, and composes with
ADR-297's stale-node handling.
### 4. Chain of custody into the event
- On successful verification the frame's `FrameProvenance` records the verified
`DeviceId`, sequence, and timestamp. Calibration (ADR-301) and inference
annotate their transforms, and the emitted spatial event (ADR-306 ontology)
carries a signed provenance lineage. `ProvenanceClass` still enforces the
synthetic/measured invariant from ADR-282/ADR-279 (invariant 6): a measured
chain of custody can never be aliased to synthetic and vice-versa.
- This end-to-end signed lineage is the substrate the **ADR-319** witness chain
serializes and the **ADR-318** capability certificate points at as evidence.
### Compatibility
- The envelope is **opt-in per deployment** and negotiated at enrollment. An
un-enrolled single-node desktop deployment keeps working unauthenticated
behind ADR-296's loopback default; a routable, multi-node, or fleet
deployment (ADR-316) requires enrolled identities. The startup security log
(ADR-296) is extended to state whether frame authentication is active.
## Consequences
- LAN spoofing and replay — the residual risks ADR-296 named plainly — are
closed for enrolled deployments. The measurement, not merely the channel, is
authenticated, so the guarantee survives store-and-forward into the witness
chain.
- Enrollment/key-management is now an operational responsibility (provisioning,
rotation, revocation). This is documented as a deployment step; key rotation
and revocation lists are specified here but their fleet distribution is
owned by ADR-316.
- Signature verification adds per-frame CPU cost at ingest; bounded and
measured in validation below. It is a deliberate cost for a verifiable chain
of custody.
- A schema addition to the frame contract; un-enrolled deployments are
unaffected, and the migration accessor mirrors ADR-297's approach.
- **No spoof-resistance claim is MEASURED until validated on real silicon**
(CLAUDE.md hardware rule): a passing unit/integration suite demonstrates the
logic, not the fielded device path.
## Validation
- Unit tests (`cargo test -p wifi-densepose-sensing-server`,
`-p wifi-densepose-rufield`): valid envelope accepted; bad signature
rejected and counted; non-monotonic sequence rejected as replay; out-of-
window timestamp rejected; un-enrolled `DeviceId` rejected; measured/synthetic
provenance aliasing rejected (ADR-279 invariant 6).
- Integration test: a captured/synthesized multi-frame stream produces a
verifiable `device → … → signed event` lineage that ADR-319 can serialize and
re-verify offline.
- Benchmark (`cargo bench`): per-frame verification cost, to bound ingest
overhead.
- **Real-silicon evidence required** before any deployment-grade
authentication claim: a captured boot/runtime log from an enrolled ESP32 node
signing frames end-to-end. A successful build or simulator run is not
hardware evidence.
@@ -0,0 +1,142 @@
# ADR-306: Canonical spatial ontology — one Site→…→Event model for every surface
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: ontology, worldgraph, schema, mqtt, matter, rufield, phase-1
## Context
This ADR is a child of **ADR-300** and owns primitive #6, *canonical spatial
ontology*. In the ADR-300 DAG it is a spine root alongside **ADR-305**
(authenticated identity) and feeds every downstream primitive that must speak
about *where* and *what*: **ADR-301** (calibration), **ADR-307** (tracking,
consumes `Track`/`Person`), **ADR-319** (witness chain), and every external
surface named in the ADR-300 consequences (MQTT, REST, WebSocket, RuField,
Matter, agents).
RuView currently expresses "where something is" in several overlapping,
per-surface schemas: the MQTT/Home-Assistant mapper has its own node/room
shapes (**ADR-297** just introduced `NodeInference`/`RoomInference` to
disambiguate node vs. room state); the `worldgraph` crate models a spatial
graph; RuField carries `SemanticProvenance`; Matter/HomeKit has its own area
model. The same physical fact — "a person is in the kitchen" — is re-encoded
differently on each surface, and the review called for "one canonical
`NodeInference`/`RoomInference` contract" (ADR-297 consequences). Without a
single semantic model, every new surface multiplies the translation matrix and
each translation is a place where provenance and evidence level (ADR-282) can
be silently dropped.
Substantial scaffolding already exists and must be **reused/extended, not
rebuilt**. `v2/crates/worldgraph/wifi-densepose-worldgraph` already defines:
- `WorldNode` variants including `Room { area_id, name, bounds_enu, floor }`,
`Zone { parent_room, … }`, `Wall { rf_attenuation_db }`, and `Doorway`.
- `WorldEdge` variants including `Observes { quality, last_seen_unix_ms }`,
`LocatedIn { since_unix_ms }`, `AdjacentTo { via_doorway }`, and `Supports`.
- `WorldGraph`, `WorldGraphSnapshot`, `WorldId`, `SemanticProvenance`,
`PersonPosition`, and a HomeCore `area_id` linkage join key (ADR-127).
The `worldgraph` crate is therefore the natural home for the canonical model.
What is missing is (a) the full `Site → Building → Floor → Space → Zone`
containment spine above `Room`, (b) first-class `Sensor`, `Object`,
`Observation`, `Track`, and `Event` node types, (c) one canonical serialization
that every surface consumes, and (d) a documented migration path from the
existing per-surface schemas.
## Options considered
1. **Leave each surface with its own schema; add adapters pairwise.** Rejected:
O(surfaces²) translations, and provenance/evidence loss at each hop.
2. **Invent a new top-level ontology crate.** Rejected: `worldgraph` already
models rooms, zones, walls, doorways, observation edges, and HomeCore
linkage; a parallel crate would fork the world model.
3. **Extend `worldgraph` into the canonical ontology and make every surface a
projection of it.** Chosen.
## Decision
Adopt **one canonical spatial ontology**, hosted in the `worldgraph` crate,
that every RuView surface reads from and writes to.
### 1. The containment spine and entity types
Define the full node taxonomy as an extension of the existing `WorldNode`:
```
Site ▸ Building ▸ Floor ▸ Space ▸ Zone
└─▸ { Sensor, Person, Object,
Observation, Track, Event }
```
- `Site`, `Building`, `Floor`, `Space` are new containment `WorldNode`
variants above the existing `Room` (mapped to `Space`, keeping its `area_id`
and `bounds_enu`) and `Zone`. `Wall`/`Doorway` remain as topological
elements. Containment reuses the existing `LocatedIn`/`AdjacentTo` edge
vocabulary; a new `PartOf` edge expresses the pure hierarchy
(Zone `PartOf` Space `PartOf` Floor …).
- `Sensor` is the entity **ADR-305** authenticates (`DeviceId` as its stable
identity) and **ADR-320** (HAL, phase 2) describes the hardware of. `Person`,
`Object`, `Observation`, `Track`, and `Event` are first-class nodes.
`Observes`/`LocatedIn` edges already carry quality and dwell timestamps.
- `Track` and `Person` are defined **here** as the ontology contract that
**ADR-307** (persistent tracking) produces and updates. `Observation` is what
an authenticated frame (ADR-305) becomes after calibration (ADR-301), and
`Event` is the governed output that ADR-318 certifies and ADR-319 witnesses.
### 2. Canonical serialization
- A single, versioned serialization (serde-based, stable field names) is the
one wire/at-rest representation. Every surface — MQTT/Home-Assistant, REST,
WebSocket, RuField observations, Matter/HomeKit, agent queries — is a
**projection** of this model, not an independent schema. `NodeInference` and
`RoomInference` (ADR-297) become projections of `Sensor→Observes` and the
`Space`-level fused inference respectively, so ADR-297's node/room separation
is preserved by construction rather than re-encoded per surface.
- Every node and edge carries `SemanticProvenance` and exactly one
`EvidenceLevel` (L0–L5, ADR-282 policy): the evidence ladder travels *with*
the fact across every projection, so no surface can silently upgrade or drop
it.
### 3. Migration path
- Each existing per-surface schema gets a documented, tested bidirectional
mapping to/from the canonical model, plus a migration accessor for consumers
reading the old shape (mirroring ADR-297's migration accessor). Surfaces are
cut over one at a time; a surface is "canonical" once its projection is the
only encoder it uses. Until cutover, the mapping layer is authoritative and
round-trip-tested so no fact is lost in translation.
- The `worldgraph` HomeCore `area_id` linkage (ADR-127) remains the join key
between the ontology's `Space` and external area registries.
## Consequences
- The translation matrix collapses from O(surfaces²) to O(surfaces): each
surface implements one projection. New surfaces (ROS 2, OpenUSD, OPC UA per
ADR-282's roadmap) plug in as additional projections.
- Provenance and evidence level are carried uniformly; a fact cannot cross a
surface boundary and lose its lineage or its L-level.
- A schema change reaching every surface; managed by the versioned
serialization and per-surface migration accessors. Single-node deployments
keep working (one `Sensor`, one `Space`).
- The ontology is a *representation*, not an inference engine: it says nothing
about *how* a `Track` or `Event` is produced — that is owned by ADR-307,
ADR-301, ADR-302, and the model layer. This ADR does not itself make any
accuracy claim to grade.
- Extending `worldgraph` grows one crate's surface rather than forking a second
world model; the geo/worldmodel sub-crates continue to build on the same node
vocabulary.
## Validation
- Unit tests (`cargo test -p wifi-densepose-worldgraph`): containment-spine
construction and invariants (a `Zone` is `PartOf` exactly one `Space`, a
`Space` on exactly one `Floor`, etc.); round-trip serialization of every node
and edge type; every node/edge carries exactly one `EvidenceLevel`.
- Migration tests: each per-surface schema maps to the canonical model and back
with no loss of provenance or evidence level; `NodeInference`/`RoomInference`
(ADR-297) project and re-project identically.
- Contract test: a single canonical `Event` renders correctly through the MQTT,
REST, and WebSocket projections from one source of truth.
- No accuracy numbers are claimed; this ADR delivers the shared representation
the rest of the phase-1 spine writes into.
@@ -0,0 +1,135 @@
# ADR-307: Persistent identity & tracking — privacy-preserving probabilistic tracks
- **Status**: Accepted — initial implementation (ADR-300 phase 2)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: tracking, identity, privacy, fusion, worldgraph, phase-2
## Context
This ADR is a child of **ADR-300** and owns primitive #7, *persistent identity
& tracking*. In the ADR-300 DAG it is a phase-2 primitive sitting on the
phase-1 spine: it **consumes the ADR-306 ontology** (producing and updating the
`Track` and `Person` node types defined there), it relies on **ADR-305**
authenticated identity so that the observations it associates have a verified
origin, and its outputs are governed `Event`s that ADR-318/ADR-319 can certify
and witness.
The product need is to reason about *persistent entities* — "person_7 entered
the kitchen, then the hallway, then the bedroom" — across radios, modalities,
rooms, and time. The hard constraint is that this must happen **without
establishing civil identity**. RuView is camera-free (ADR-282), and a
persistent pseudonymous track must never become, or be joinable to, a real-
world named individual. This is a privacy property to be enforced *by
construction*, not a policy footnote.
Substantial scaffolding already exists in
`v2/crates/wifi-densepose-mat/src/tracking` and must be **reused/extended, not
rebuilt**:
- `SurvivorTracker`, `TrackedSurvivor`, `TrackId`, `TrackerConfig`,
`TrackLifecycle`, and `TrackState` — a multi-target tracker with lifecycle
(tentative/active/lost/terminal) and a `TrackId` backed by a UUID
(`as_uuid`).
- `KalmanState` with `predict`/`update`, `position`, `velocity`,
`position_uncertainty`, and `mahalanobis_distance_sq` — the motion model and
gating distance.
- `CsiFingerprint`, `DetectionObservation`, `AssociationResult`, and the
`can_reidentify`/`matches`/`mark_rescued`/`rescue` re-identification surface —
the appearance/fingerprint channel for track continuity.
What is missing is (a) continuity **across radios, modalities, and rooms** (the
tracker today reasons within a node/room context), (b) a **persistent** entity
that survives track loss and hand-off between spaces, and (c) an explicit
**privacy boundary** that guarantees no civil-identity binding.
## Options considered
1. **Per-room independent trackers, no cross-room identity.** Rejected: cannot
express "person_7 moved kitchen → hallway → bedroom"; loses the entity at
every room boundary.
2. **Global identity keyed on a strong biometric fingerprint.** Rejected: a
fingerprint strong enough to re-identify across long gaps trends toward a
civil-identity-grade biometric — exactly what the privacy constraint
forbids.
3. **Probabilistic persistent tracks with bounded, decaying pseudonymous
association, built on the existing MAT tracker.** Chosen.
## Decision
Extend `wifi-densepose-mat/tracking` into a **cross-domain persistent track
layer** that produces ADR-306 `Track`/`Person` nodes.
### 1. Persistent probabilistic entity
- A persistent entity is a pseudonymous `Person` node (ADR-306) with a stable
synthetic id (e.g. `person_7`) backed by the existing `TrackId`/UUID. It
aggregates one or more `SurvivorTracker` tracks over time and space and holds
a **probabilistic** continuity belief — association is never asserted as
certain, and every hand-off carries a confidence.
- Continuity across a track-loss gap reuses the existing re-identification
surface (`can_reidentify`, `CsiFingerprint`, `AssociationResult`), extended
with a **time- and distance-decayed** association prior so that confidence in
"same entity" falls with the size of the gap. Beyond a bounded horizon the
association is dropped and a new pseudonym is minted rather than forcing a
join — under-linking is the privacy-safe failure mode.
### 2. Cross-radio / cross-modality / cross-room continuity
- Association operates over the ADR-306 ontology graph: `Observes` edges from
multiple `Sensor`s and `AdjacentTo`/`Doorway` topology constrain plausible
hand-offs (a person can only move between adjacent spaces). The existing
`mahalanobis_distance_sq` gating extends to a fused observation across
modalities rather than a single node's detections.
- Fusion here is track-level association; the underlying multi-modality fusion
(radar/mmWave per ADR-063, multistatic per ADR-029, and real sensor fusion
per ADR-311) supplies the observations. This ADR depends on those for the raw
cross-modality evidence and does not re-implement sensor fusion.
### 3. Privacy boundary (by construction)
- **No civil-identity binding.** The persistent id is a synthetic pseudonym
with no field, edge, or join key to any name, account, phone, MAC, or other
civil identifier. The type carries no such field, so binding is impossible in
the schema, not merely discouraged.
- The `CsiFingerprint` used for re-identification is **bounded and decaying**:
it is scoped to short-horizon continuity, is not persisted as a long-term
biometric template, and expires. This keeps re-identification useful for
"same person across the hallway" while structurally unable to serve "this is
the same person who visited last month."
- Every `Track`/`Person`/`Event` produced carries `SemanticProvenance` and an
`EvidenceLevel` (ADR-282), and honors the ADR-277/ADR-280 edge governance and
ADR-141 attestation — a pseudonymous track is still governed P-class data.
Tracking accuracy is a per-domain claim to be tagged MEASURED/CLAIMED/
SYNTHETIC with a reproducer; **this ADR claims no accuracy number.**
## Consequences
- RuView can express persistent, cross-room trajectories for automation and
analytics while remaining camera-free and civil-identity-free.
- The privacy-safe failure mode is **under-linking** (mint a fresh pseudonym
when unsure), which will fragment a trajectory across long gaps or sparse
coverage. This is a deliberate trade: a fragmented pseudonym is safe, a
wrong civil-identity join is not.
- Extends an existing tracker rather than forking one; single-room single-radio
deployments keep the current behavior (one entity = one track).
- Cross-modality quality depends on ADR-311/ADR-063/ADR-029 landing; until then
continuity is WiFi-primary and its limits are stated, not hidden.
- Being phase 2, this ADR is design intent; it will be revised as the ADR-306
ontology and ADR-305 identity spine finalize.
## Validation
- Unit tests (`cargo test -p wifi-densepose-mat`): decayed association prior
(confidence falls with gap; drops beyond horizon → new pseudonym);
topology-constrained hand-off (no association across non-adjacent spaces);
schema check that a `Person`/`Track` carries no civil-identifier field.
- Integration test against a synthetic multi-room, multi-radio scenario:
a scripted walk kitchen → hallway → bedroom yields one persistent pseudonym
with per-hand-off confidence, and a deliberately ambiguous crossing produces
two pseudonyms rather than a false join.
- Evidence discipline: any tracking-continuity accuracy is reported only with
the ADR-291 leakage-free protocol and an evidence tag; no number is asserted
here.
- Privacy review: confirm no persisted long-term biometric template and no
civil-identity join path, as an explicit checklist item before any pilot.
@@ -0,0 +1,138 @@
# ADR-308: Sensor placement optimizer — floorplan + inventory → recommended positions
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: placement, planning, rf-twin, coverage, worldgraph, phase-3
## Context
This ADR is a child of **ADR-300** and owns primitive #8, *sensor placement
optimizer*. In the ADR-300 DAG it is a phase-3, research-forward primitive that
sits on top of the fused world state and is tightly coupled to **ADR-315**
(digital RF twin): the twin provides the propagation simulation this optimizer
plans against. It reads the **ADR-306** canonical ontology for the physical
scene and, after install, compares its predictions against ADR-302 observability
and the ADR-318 capability certificate.
The problem it solves is the single most common cause of a bad RuView
deployment: sensors placed by guesswork. Whether a room can be reliably sensed
depends on AP/sensor geometry relative to walls, Fresnel-zone clearance,
multipath structure, and where people actually move. Today an installer has no
principled way to answer "where do I put the two nodes I have so the kitchen is
observable?" — and no way, after install, to know whether reality matched the
plan. This is a genuine **differentiator**: it turns RuView from "sense
whatever the given placement happens to allow" into "recommend the placement
that makes the requested sensing feasible."
Relevant existing assets to build on rather than duplicate:
- The `worldgraph` crate models the physical scene the optimizer plans over:
`Room`/`Space` with `bounds_enu`, `Wall { rf_attenuation_db }` (drywall ≈ 3
dB, brick ≈ 12 dB), `Doorway`, and `Zone` — enough geometry and coarse RF
attenuation to seed a coverage model, plus `Sensor` nodes (ADR-306) for
candidate positions.
- **ADR-315** (RF twin, phase 3) is the propagation/multipath simulator; this
optimizer is a *consumer* of the twin, not a second simulator.
- **ADR-302** (OOD/observability) and **ADR-318** (capability certificate)
define what "reliably sense the requested phenomenon" means, so the optimizer
can optimize against the same observability metric the runtime later gates on.
- **ADR-029** (multistatic) and **ADR-063** (mmWave fusion) inform which link
geometries are useful for which phenomena.
## Options considered
1. **Static placement guidelines in docs (e.g. "one node per room, opposite
the door").** Rejected: ignores the specific floorplan, wall materials, and
the actual hardware inventory; gives no uncertainty and no post-install
feedback.
2. **Full electromagnetic solver per site.** Rejected for the default path:
too heavy for an installer workflow and overkill relative to the coarse
`rf_attenuation_db` scene RuView actually has; reserved as an optional
high-fidelity backend inside ADR-315.
3. **A coverage optimizer that consumes the ADR-315 RF twin over the ADR-306
scene, then validates predicted vs. measured observability after install.**
Chosen.
## Decision
Define a **placement optimizer** that takes a floor plan (ADR-306 scene) and a
hardware inventory and recommends sensor positions, then closes the loop after
install.
### 1. Inputs
- The ADR-306 canonical scene: `Space`/`Zone` bounds, `Wall` segments with
`rf_attenuation_db`, `Doorway` topology, and any already-placed `Sensor`
nodes.
- A hardware inventory: the count and type of available radios (ESP32-S3/C6
nodes, mmWave, adapters) with their capability envelopes (what each can
sense, per ADR-318 / ADR-320 HAL descriptors).
- A sensing objective: which phenomenon must be observable in which
`Space`/`Zone` (presence, vitals, pose), expressed against the ADR-302
observability metric.
### 2. Prediction
- For a candidate placement, query the **ADR-315 RF twin** for simulated RF
coverage: path loss through `Wall` attenuation, **Fresnel-zone clearance**
between link endpoints, and coarse **multipath** structure. From that derive
an **expected observability** and an **uncertainty** for each objective in
each space — reusing the same observability definition ADR-302 gates on so the
plan and the runtime speak one language.
- Search over candidate positions (the inventory bounds the count; the scene
bounds the geometry) to recommend the placement that maximizes objective
observability, reporting expected observability **and its uncertainty** per
space — never a single confident number for a simulated result.
### 3. Post-install loop
- After install, compare **predicted vs. measured** observability using the
ADR-302 runtime observability signal from the freshly enrolled (ADR-305),
calibrated (ADR-301) sensors. Where measurement disagrees with prediction,
recommend adjustments (move, re-aim, add a node) and feed the residual back
to improve the ADR-315 twin's scene parameters (e.g. a wall's effective
attenuation).
### Evidence discipline
- Predicted coverage is a **simulation** (evidence level L0 per ADR-282) and is
labelled `SYNTHETIC`; it is a *recommendation*, never a sensing claim.
- The predicted-vs-measured comparison is the only place a `MEASURED` statement
appears, and only with a reproducer and real-silicon observability data
(CLAUDE.md hardware rule). The optimizer never presents a simulated coverage
map as evidence that a room *is* being sensed.
## Consequences
- Installers get a principled, floorplan-specific placement plan and, crucially,
a post-install check that says whether reality matched the plan — a
differentiating capability over guess-and-check deployment.
- Quality is bounded by the fidelity of the ADR-315 RF twin and the coarseness
of the `worldgraph` scene (2D walls, coarse attenuation). The optimizer
reports uncertainty rather than overstating a coarse model; higher fidelity
is an ADR-315 concern.
- Hard dependency on ADR-315 (twin), ADR-302 (observability metric), and
ADR-306 (scene); this ADR does not build a simulator or an observability
metric of its own.
- Being phase 3, this is design intent sitting on the fused world state; it is
expected to be revised as ADR-315 and the phase-1 spine land.
- No claim that recommended placement *guarantees* sensing — it maximizes
modelled observability subject to inventory and geometry, with explicit
uncertainty.
## Validation
- Unit tests: coverage/observability prediction is a deterministic function of
scene + placement + twin parameters; Fresnel-zone and wall-attenuation math
against known analytic cases; search returns the modelled-optimal placement on
small synthetic scenes.
- Integration test: on a synthetic floorplan with a known-good and a
known-bad placement, the optimizer ranks them correctly and reports higher
uncertainty for the marginal case.
- Post-install loop test: injected predicted-vs-measured disagreement produces a
sensible adjustment recommendation and a twin-parameter residual.
- Field validation (deferred, real-silicon): predicted vs. measured
observability on an instrumented real site, reported as `MEASURED` with a
reproducer. Until then all coverage output is `SYNTHETIC`/L0. No coverage or
accuracy number is asserted by this ADR.
+152
View File
@@ -0,0 +1,152 @@
# ADR-309: Active sensing — closed-loop RF experiment control
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: active-sensing, control-plane, closed-loop, information-gain, actuation, phase-3
## Context
This ADR is a child of **ADR-300** and owns primitive #9, *active sensing*. In
the ADR-300 phasing it is a phase-3 primitive that sits on top of the fused
world state produced by **ADR-311** (real sensor fusion) and is driven by the
information budget of **ADR-314** (information-gain scheduler). It is authored
as **Proposed**: design intent and validation plan, not a phase-1 build.
The default posture of every current RuView path is **passive**: RF traffic
happens for its own reasons (a device transmits, a beacon fires), RuView
observes whatever CSI/CIR arrives, and the pipeline extracts what it can from
that incidental signal. The strategic assessment behind ADR-300 named the next
step: move from *RF-happens → observe* to **RuView-controls-RF → observe the
response → optimize the next measurement**. That turns sensing into a
closed-loop experiment — the system chooses what to measure to resolve the
uncertainty it currently has, rather than accepting the measurements the
environment happens to offer.
Substantial control-plane scaffolding already exists and must be
**reused/extended, not rebuilt**:
- **ADR-280** (active sensing / programmable perception, *implemented* in
`ruview-unified/src/control.rs`) already defines the governed control surface
this ADR closes the loop over: `SensingTask` (evidence-aware, fail-closed
admission), `SensingAction` + `InformationGoal` (a deliberate act of
evidence-gathering against a stated hypothesis, bounded by a `PrivacyClass`
P0–P5 ceiling), `ActiveSensingPlanner` (age-of-information scheduler),
`CoherentSensorGroup` (coherent fusion fails closed), and `request_actuation`
→ `ActuationReceipt` for governed RIS/movable/fluid-antenna actuation.
- ADR-280 explicitly recorded that **information-gain *estimation* is not
implemented** — "the planner uses staleness heuristics, not mutual
information; RIS drivers, actual multi-AP coherence measurement, and OTFS
waveform control are hardware-dependent roadmap items." ADR-309 is the ADR
that closes exactly those gaps, in coordination with ADR-314.
The missing piece is not the actuation surface — ADR-280 built that and made it
fail closed — but the **loop**: a controller that reads the current fused-state
uncertainty, selects a *controllable measurement configuration* expected to
reduce it most, requests it through the ADR-280 governed surface, observes the
response, and updates its belief before choosing the next measurement.
## Options considered
1. **Stay passive; only schedule which incidental observations to keep.** This
is roughly today's `ActiveSensingPlanner` (staleness-priority over regions).
Rejected as the endpoint: it optimizes *attention* over uncontrolled RF, not
the *measurement* itself. It remains the fallback when nothing is
controllable.
2. **Open-loop measurement scripting** (a fixed sweep of channels/bandwidths).
Rejected: a fixed sweep spends the RF/energy/privacy budget the same way
regardless of what is already known; it cannot concentrate measurement where
uncertainty actually is.
3. **Closed-loop experiment control** — read uncertainty, pick the controllable
configuration with highest expected information gain per unit cost/privacy,
actuate through the ADR-280 governed surface, observe, update, repeat.
Chosen.
## Decision
Adopt **closed-loop RF experiment control** as a phase-3 controller layered on
the ADR-280 surface. RuView selects and drives the controllable degrees of
freedom of the RF measurement, then optimizes the next measurement from the
observed response.
### 1. Controllable degrees of freedom
Define an `ExperimentControl` vocabulary over the configuration axes RuView can
influence on hardware that exposes them (each axis is optional and
capability-gated by ADR-320's HAL, so an ESP32-only deployment simply has an
empty controllable set and degrades to the passive planner):
- **Channel / band** and **bandwidth** (which spectrum to probe; reuses the
ADR-292 wideband subcarrier-agnostic metadata).
- **Packet timing / cadence** (when to solicit a sounding, and at what rate).
- **Antenna / chain selection** (which subset of a distributed aperture to
activate — bounded by the ADR-280 `CoherentSensorGroup` compatibility proof).
- **Beam / RIS configuration** (which rooms and people become observable —
governed exactly as ADR-280 §6 requires, via `request_actuation` and an
`ActuationReceipt`).
- **802.11bf measurement parameters** (TB/non-TB, reporting config) once
ADR-310 exposes standardized sensing as a native measurement type.
### 2. The loop
```
fused-state uncertainty (ADR-311)
│
▼
info-gain ranking of ExperimentControl options (ADR-314)
│ select argmax E[ΔI] / (cost, energy, privacy ceiling)
▼
governed request (ADR-280 admit_task / request_actuation, fail-closed)
│
▼
observe response → update belief (ADR-311) → repeat
```
The controller never bypasses the ADR-280 admission and actuation gates: every
solicited measurement is a `SensingTask`/`SensingAction`, every environment
change is an `ActuationReceipt`, and every step composes with the ADR-277
policy engine. Information gain is what **ADR-314** supplies (the mutual-
information estimate ADR-280 deferred); ADR-309 owns the *control loop* that
consumes that estimate and drives the hardware.
### 3. Governance and honesty boundary
- Actuation and solicitation stay fail-closed and privacy-ceilinged: a
closed-loop experiment cannot widen the P0–P5 ceiling of the task it serves,
and cannot steer a beam into a zone that does not grant the purpose (ADR-280
`actuation_requires_policy_authorization`).
- Any accuracy or "traffic-reduction" claim from the closed loop is tagged
**MEASURED** only with a named reproducer over a stated scenario, **SYNTHETIC**
for simulated apertures, and **CLAIMED** otherwise. Real multi-AP coherent
measurement and RIS actuation remain **hardware-dependent** and require
real-silicon evidence (a captured runtime log) before any hardware claim, per
CLAUDE.md. No number is invented here.
## Consequences
- Sensing becomes an experiment: RuView spends its RF/energy/privacy budget on
the measurements that most reduce current uncertainty, instead of processing
whatever incidental traffic arrives.
- The loop is only as strong as its two dependencies: ADR-311 must expose a
usable uncertainty surface and ADR-314 must produce trustworthy information-
gain estimates. Where either is absent, the controller degrades to the
ADR-280 staleness planner rather than acting on a fabricated gain estimate.
- Controllability is hardware-bounded. On commodity ESP32 sensors the
controllable set may be limited to cadence; the full loop (bandwidth, antenna,
beam) needs NICs/RIS that expose those axes, surfaced through ADR-320.
- This ADR adds a controller; it does not re-open ADR-280's raw-export or
actuation-governance decisions, which remain authoritative and fail-closed.
## Validation
- Design-level acceptance (phase 3): a simulated closed loop over a synthetic
scene reduces terminal fused-state uncertainty faster than (a) the passive
ADR-280 staleness planner and (b) an open-loop fixed sweep, at equal
measurement budget — reported **SYNTHETIC**, with the scenario and seed named.
- Governance tests: every solicited measurement and actuation in the loop is
admitted through the ADR-280 fail-closed path; a loop step that would exceed
the task's privacy ceiling or steer into an ungranted zone is denied.
- Degradation test: with an empty controllable set (ESP32-only), the controller
falls back to the staleness planner with no error and no fabricated gain.
- Hardware validation of bandwidth/antenna/beam actuation is explicitly out of
scope until real silicon exposes those axes and produces a captured log.
@@ -0,0 +1,147 @@
# ADR-310: 802.11bf-native architecture — standardized WLAN sensing as native measurement types
- **Status**: Proposed (ADR-300 phase 2)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: 80211bf, wlan-sensing, standards, measurement-types, hal, phase-2
## Context
This ADR is a child of **ADR-300** and owns primitive #10, *802.11bf-native
architecture*. In the ADR-300 phasing it is a phase-2 integration primitive: it
sits on the phase-1 spine (authenticated identity ADR-305, spatial ontology
ADR-306, evidence engine ADR-304) and **feeds ADR-320** (the RuView sensor HAL),
which is the clause of the acceptance test that "identifies the hardware." It is
authored as **Proposed**.
**IEEE 802.11bf-2025 ("WLAN Sensing") was published 2025-09-26** — verified
against the IEEE SA record in `wifi-densepose-hardware` (`ieee80211bf/mod.rs`
header, "evidence grade MEASURED", ADR-152 §1.1). Standardization is complete
for sub-7 GHz and >45 GHz (DMG) bands: formal sensing measurement setup,
measurement instances, feedback/reporting, and sensing-by-proxy (SBP). This
changes RuView's strategic frame: rather than treating every WiFi measurement as
an *opportunistic* extraction from incidental traffic, RuView can be the **open
reference sensing stack around the standard** — the day commodity silicon
exposes it.
Substantial scaffolding already exists and must be **reused/extended, not
rebuilt**. `v2/crates/wifi-densepose-hardware/src/ieee80211bf/` already models
the standardized procedure surface as forward-compatible types (ADR-152/153):
- `types` — `SpecProfile` version gates, `SensingRole`/`TransceiverRole`,
`MeasurementSetupParams`, `SensingCapabilities` negotiation, and required
`ConsentMode` governance metadata on every setup.
- `messages` — `SensingMeasurementSetupRequest/Response`,
`SensingMeasurementInstance`, `SensingMeasurementReport`, `CsiReportPayload`,
`SbpRequest/Response`, `SensingSessionTermination`.
- `session` — a deterministic FSM (`Idle → SetupNegotiating → Active →
Terminating → Idle`) with rejection paths, single-role enforcement, and SBP
proxy mode; `table` (responder-side setup registry); `transport` (the
`SensingTransport` seam, a `SimTransport` test double, and an
`OpportunisticCsiBridge` that maps today's opportunistic CSI onto the
standardized report path).
The module's own honesty note is authoritative and carried forward here: it is
**not a certified 802.11bf implementation**, and **no commodity silicon — ESP32
included — implements the standard yet**; the OTA frame binding lands when a
chipset exposes it. Wideband ingest plumbing is already in place too: **ADR-292**
(FeitCSI/AX210) carries native subcarrier dimensionality end-to-end and records
the native→pipeline mapping, and noted that "truncated CIR is a natural
extension of the same plumbing."
What is missing is architectural, not protocol scaffolding: normalized CSI is
still treated as *the* WiFi input. The standardized sensing measurements
(TB/non-TB soundings, truncated CIR / PDP reports) are modeled as protocol
messages but are **not yet first-class native measurement types** that flow
through calibration (ADR-301), fusion (ADR-311), and the ontology (ADR-306) on
equal footing with normalized CSI.
## Options considered
1. **Keep 802.11bf as a protocol model only; always down-convert its reports to
normalized CSI at ingest.** Rejected: truncated CIR/PDP carry range-resolved
multipath structure that flattening to a CSI matrix discards; it also wastes
the standard's native report semantics.
2. **Fork a parallel "bf pipeline" alongside the CSI pipeline.** Rejected:
duplicates calibration, fusion, ontology, and evidence plumbing, and re-opens
the O(surfaces²) translation problem ADR-306 exists to close.
3. **Promote standardized sensing measurements to native measurement types
inside the existing pipeline**, with normalized CSI as one measurement type
among several. Chosen.
## Decision
Adopt an **802.11bf-native architecture**: standardized WLAN sensing
measurements become **additional native measurement types**, alongside — not
replacing — normalized CSI.
### 1. Native measurement types
- Define the standardized reports the `ieee80211bf` module already models
(TB and non-TB soundings; truncated CIR; PDP) as first-class
`MeasurementType` variants that the pipeline carries end-to-end, each tagged
with its `SpecProfile` and band. Normalized CSI remains one such type; the
`OpportunisticCsiBridge` remains the path for silicon that only offers
incidental CSI.
- Truncated CIR/PDP reuse the **ADR-292** subcarrier-agnostic / native-
dimensionality plumbing (truncated CIR is the stated natural extension); the
native→pipeline mapping is recorded in frame metadata so downstream stages
know the true range/spectral resolution of a bf report vs. an interpolated CSI
frame.
### 2. Ontology and governance binding
- Each standardized measurement becomes an ADR-306 `Observation` node from an
ADR-305-authenticated `Sensor`, carrying `SemanticProvenance` and exactly one
`EvidenceLevel` (L0–L5, ADR-282). The `ieee80211bf` `ConsentMode` metadata —
required on every setup — composes with the ADR-277 policy engine, so a
standardized session is admitted under the same governance as any other
sensing task (ADR-280).
- SBP (sensing-by-proxy) sessions attribute the report to the proxying and the
sensing entities distinctly, so provenance is not laundered through the proxy.
### 3. HAL feed (ADR-320)
- The capability set a device advertises — which `MeasurementType`s, bands,
bandwidths, roles, and `SpecProfile` it supports — is exactly the descriptor
**ADR-320** (HAL) needs to "identify the hardware." ADR-310 defines that
capability descriptor as the projection of `SensingCapabilities`; ADR-320
consumes it. A device that implements no bf profile advertises only the
opportunistic-CSI capability.
## Consequences
- RuView is positioned as the open reference stack *around* the standard: when a
chipset exposes 802.11bf, its native reports flow through calibration, fusion,
ontology, and evidence with no bespoke pipeline — the plumbing is already
tested against `SimTransport` and synthetic fixtures.
- Normalized CSI is demoted from "the WiFi input" to "one measurement type,"
which is the correct framing for a multi-measurement future and prevents the
bf path from being a second-class citizen.
- **No hardware claim is made or implied.** No commodity silicon implements
802.11bf yet; this ADR wires the *types and flow*, tested in simulation. Any
OTA/native-report accuracy claim requires real silicon evidence (a captured
log) per CLAUDE.md, and any wideband number must be tagged with the capture
hardware (ADR-292). No benchmark number is invented here.
- This ADR does not re-open ADR-152/153's decision to avoid OTA frame binding
until silicon exists; it consumes that surface and adds the pipeline
integration.
## Validation
- `cargo test -p wifi-densepose-hardware` — existing `ieee80211bf` FSM,
table, and transport tests continue to pass; new tests assert that a
`SensingMeasurementReport` (TB and non-TB) and a truncated-CIR/PDP report
round-trip through the pipeline as native `MeasurementType`s.
- `cargo test -p wifi-densepose-mat` — truncated CIR ingest reuses the ADR-292
subcarrier-agnostic path and records the native→pipeline mapping; dimension/
version validation on standardized reports mirrors the FeitCSI parser gates.
- Ontology/governance tests: each standardized measurement becomes an ADR-306
`Observation` from an ADR-305-authenticated `Sensor` with one `EvidenceLevel`;
`ConsentMode` composes with ADR-277 admission; SBP attributes proxy vs. sensor
provenance distinctly.
- HAL contract test: the ADR-320 capability descriptor is derivable from
`SensingCapabilities`; a bf-less device advertises only opportunistic CSI.
- All measurement-type flows are simulation-tested (`SimTransport`, synthetic
fixtures); OTA binding and any hardware accuracy claim remain out of scope
until real silicon exposes the standard.
+140
View File
@@ -0,0 +1,140 @@
# ADR-311: Real sensor fusion — uncertainty-aware, multiple observations → one world state
- **Status**: Accepted — initial implementation (ADR-300 phase 2)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: fusion, uncertainty, multimodal, world-state, ontology, phase-2
## Context
This ADR is a child of **ADR-300** and owns primitive #11, *real sensor fusion*.
In the ADR-300 DAG it is a phase-2 integration primitive: it **consumes ADR-306**
(canonical spatial ontology) and **produces the single fused world state** that
the phase-3 primitives build on — **ADR-312** (long-term spatial memory),
**ADR-313** (counterfactual inference), and **ADR-315** (digital RF twin). It is
authored as **Proposed**.
The defining invariant is not "support more modalities" but the *shape of the
output*: **multiple observations must resolve to one probabilistic world state,
not many feeds into a visualization.** A dashboard that shows a WiFi layer, a
mmWave layer, and a BLE layer side by side is not fusion; it pushes the
reconciliation onto the human. Real fusion produces one uncertainty-aware state
that every downstream consumer reads, with each contributing observation's
provenance and confidence still recoverable.
Substantial scaffolding already exists and must be **reused/extended, not
rebuilt**:
- **ADR-063** (60 GHz mmWave ↔ WiFi CSI fusion, *Proposed*) established the
first cross-modal fusion case: pairing noisy CSI-derived vitals with clinical-
grade mmWave FMCW radar (Seeed MR60BHA2 over UART, with a **live hardware
capture** logged on 2026-03-15). ADR-311 generalizes that pairwise case into
an N-modality, uncertainty-aware fusion.
- **ADR-137** (fusion-engine quality scoring, *Accepted — partial*) already
built the auditable-quality building block: it identified that the multistatic
fusers (`wifi-densepose-signal/src/ruvsense/multistatic.rs`,
`wifi-densepose-ruvector/src/viewpoint/fusion.rs`) discarded the evidence they
used, and specified a single auditable record — "this fused output is
trustworthy because X, Y, Z, but be aware of contradiction C" — with evidence
references and contradiction flags. ADR-311 reuses that record as the
provenance/quality carrier of the fused state.
- **ADR-280** `CoherentSensorGroup` (fail-closed coherent fusion) and
**ADR-306** `Observation`/`Track`/`Event` node types are the input and output
vocabulary respectively.
What is missing is the **uncertainty-aware combiner across heterogeneous
modalities**: a fusion stage that takes authenticated observations from WiFi,
BLE, UWB, mmWave, acoustic, IMU, lidar, and cameras (only where policy permits),
each with its own uncertainty, and emits one probabilistic `WorldState` — with
per-observation contradiction flags, not a stack of independent feeds.
## Options considered
1. **Per-modality feeds rendered together** (today's implicit model on some
surfaces). Rejected: it is visualization, not fusion; contradictions are
never reconciled and there is no single state to reason over.
2. **Hard-switch "best modality wins"** (e.g., always prefer mmWave vitals over
CSI vitals). Rejected: throws away corroborating evidence and cannot express
*disagreement* — the very thing ADR-137's contradiction flags exist to
surface — and degrades badly when the preferred modality is absent or OOD.
3. **Uncertainty-weighted probabilistic fusion into one world state**, reusing
ADR-137's auditable quality record and ADR-280's fail-closed coherence gate.
Chosen.
## Decision
Adopt **uncertainty-aware multimodal fusion** whose invariant output is one
probabilistic world state.
### 1. Inputs: authenticated, ontology-typed observations
- Inputs are ADR-306 `Observation` nodes from **ADR-305-authenticated** sensors.
Supported modalities: WiFi (CSI / 802.11bf native reports via ADR-310), BLE,
UWB, mmWave (ADR-063), acoustic, IMU, lidar, and cameras. Cameras and any
higher privacy-class modality enter fusion **only where the ADR-277 policy
engine permits** — camera-free coverage is a RuView invariant (ADR-282), so
cameras are an opt-in, policy-gated input, never assumed present.
- Each observation carries its own uncertainty and exactly one `EvidenceLevel`
(ADR-282). An observation flagged out-of-distribution by **ADR-302** is
down-weighted or excluded per its OOD verdict rather than silently averaged in.
### 2. Combiner: uncertainty-weighted, contradiction-aware
- Observations are combined by their uncertainty into one probabilistic
`WorldState` over the ADR-306 entities (`Person`, `Object`, `Track`, and the
per-`Space` inference). The combiner does **not** collapse disagreement: when
modalities conflict beyond their stated uncertainty, the fused output carries
ADR-137 **contradiction flags** and the evidence references that produced
them, so a consumer can see *that* WiFi and mmWave disagree and *why*.
- Coherent multi-node fusion inherits ADR-280's fail-closed
`CoherentSensorGroup` gate: no coherent combination unless sync, phase, and
geometry compatibility are proven; otherwise the group degrades to incoherent
combination rather than producing confident nonsense.
### 3. Output: one world state, provenance preserved
- The output is a single `WorldState` written into the ADR-306 ontology, with
every fused value retaining recoverable per-observation provenance and the
ADR-137 quality record. This is the state ADR-312/310/312 consume; they read
one probabilistic world, not a modality stack.
- The fused state carries an aggregate uncertainty and an evidence level derived
from its inputs (never upgraded above the weakest contributing L-level for a
given claim).
## Consequences
- Downstream primitives (spatial memory, counterfactual, RF twin) build on one
probabilistic world state with uniform uncertainty and provenance, instead of
re-implementing reconciliation per consumer.
- Contradictions become first-class signal, not noise: ADR-137's record means a
disagreement between mmWave and CSI is surfaced and auditable, which is also
what lets ADR-302 and the evidence engine (ADR-304) reason about reliability.
- Fusion is uncertainty-honest: an OOD or low-evidence observation is
down-weighted, not averaged in as if trustworthy; a fused claim never presents
a stronger evidence level than its weakest necessary input.
- **No accuracy or "camera-grade" claim is made.** ADR-063's mmWave path has a
real-silicon capture; the multimodal combiner's accuracy is not asserted here.
Any fused-accuracy number requires a named reproducer tagged MEASURED /
SYNTHETIC / CLAIMED, and WiFi sensing is never presented as camera-grade
(CLAUDE.md, ADR-282). No number is invented.
- Cameras remain a governed, opt-in input; enabling them does not weaken the
camera-free coverage guarantee for deployments that exclude them.
## Validation
- `cargo test -p wifi-densepose-ruvector` / `-p wifi-densepose-signal` — the
ADR-137 quality record and contradiction flags travel with the fused output;
the ADR-280 `CoherentSensorGroup` gate still fails closed under
clock/phase/geometry violation.
- Fusion invariant test: N modality observations over one scene resolve to a
single `WorldState` node in the ADR-306 ontology (not N feeds), with
per-observation provenance recoverable and one aggregate evidence level.
- Uncertainty tests: a high-uncertainty or ADR-302-flagged-OOD observation is
down-weighted/excluded; conflicting modalities produce a contradiction flag
rather than a silently averaged value; the fused evidence level never exceeds
the weakest necessary input.
- Governance test: a camera or higher-privacy modality is admitted into fusion
only when the ADR-277 policy engine permits; otherwise it is excluded and the
fused state notes the exclusion.
- Any accuracy comparison (e.g., fused vitals vs. mmWave-only) is reported with
its evidence tag and reproducer; none is asserted in this ADR.
@@ -0,0 +1,146 @@
# ADR-312: Long-term spatial memory — learn the normal physics of a location
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: spatial-memory, ruvector, anomaly-detection, temporal, world-state, phase-3
## Context
This ADR is a child of **ADR-300** and owns primitive #12, *long-term spatial
memory*. In the ADR-300 phasing it is a phase-3 primitive that sits on the fused
world state produced by **ADR-311** (real sensor fusion) and **ties to ADR-315**
(digital RF twin): spatial memory is the *learned normal* that a twin can
simulate against and that anomaly detection compares against. It is authored as
**Proposed**.
The capability is to **learn the normal physics of a location** so anomalies
surface *without training a detector for every anomaly*. Concretely, the system
should learn statements like: "a chair is normally here"; "this bedroom is
usually occupied between these hours"; "the RF propagation of this space
changed"; "this machine's vibration signature changed"; "a new reflector
appeared." None of these is a labeled anomaly class — they are *deviations from
a learned baseline of normality*. This is the difference between supervised
anomaly detection (which needs examples of every failure) and **baseline-relative
anomaly detection** (which needs only a well-characterized normal).
Substantial substrate already exists and must be **reused/extended, not
rebuilt**:
- **RuVector** (`v2/crates/wifi-densepose-ruvector`) is the designated substrate
in the ADR-282 layer stack ("persistent objects, Gaussian fields, scene
graphs, temporal memory"). It already provides the vector/temporal machinery
this ADR needs — HNSW indexing (`hnsw.rs`, `hnsw_quantized.rs`), an event log
(`event_log.rs`), coverage and estimator surfaces, and the `crv`/`mat`
temporal sub-modules — so long-term spatial memory is a *consumer and
organizer* of RuVector primitives, not a new store.
- **ADR-306** supplies the entity vocabulary the memory is indexed by (`Space`,
`Object`, `Sensor`, `Track`, `Event`); **ADR-311** supplies the fused,
uncertainty-carrying `WorldState` snapshots that memory accumulates over time.
- **ADR-135** (empty-room baseline calibration) and **ADR-301** (automatic
domain calibration) already establish a *calibration-time* baseline of a
space; ADR-312 extends that from a one-shot baseline to a **continuously
learned, time-of-day-aware** model of normal.
What is missing is the **temporal normality model**: a per-`Space` learned
distribution of fused world states over time (including periodicity — hour of
day, day of week), plus RF-propagation and modality-signature baselines, against
which a live fused state is scored for deviation.
## Options considered
1. **Supervised anomaly classifiers per anomaly type.** Rejected: it needs
labeled examples of every anomaly (fall, intrusion, machine fault, moved
furniture), which do not exist for most spaces and do not transfer between
rooms; it also cannot catch a *novel* anomaly it was never trained on.
2. **Single static baseline** (the ADR-135 empty-room snapshot, used forever).
Rejected as the endpoint: it cannot express *when* a space is normally
occupied, cannot track slow legitimate drift (furniture rearranged on
purpose), and flags every diurnal change as anomalous.
3. **Continuously learned, time-aware normality model on the RuVector
substrate**, scoring live fused state against learned normal. Chosen.
## Decision
Adopt a **long-term spatial memory** that learns each location's normal physics
on the RuVector substrate and scores live fused state against it.
### 1. What "normal" is learned over
Per ADR-306 `Space` (and the entities within it), accumulate the ADR-311 fused
`WorldState` over time into a learned normality model covering:
- **Occupancy / activity periodicity** — the distribution of presence and
activity by hour-of-day and day-of-week (the "bedroom usually occupied certain
hours" case).
- **Static scene layout** — persistent `Object` positions and the expected
reflector set (the "chair normally here" / "new reflector appeared" cases),
building on the ADR-135/298 baseline.
- **RF-propagation baseline** — the space's normal multipath/propagation
signature (the "RF propagation changed" case).
- **Per-modality signatures** — e.g., a machine's normal vibration/acoustic/IMU
signature (the "vibration signature changed" case).
Each learned baseline carries its own uncertainty and an `EvidenceLevel`
(ADR-282); a baseline learned from replay is L1, from a field pilot L4, and is
never presented above the evidence of the observations it was learned from.
### 2. Substrate: RuVector, temporally compressed
- The memory is stored and indexed on RuVector (HNSW for nearest-normal recall,
the event log for the temporal stream, the temporal sub-modules for
compression). Long-horizon history is temporally compressed — recent detail
retained, older history summarized — so memory cost is bounded rather than
growing linearly forever.
- The memory is *keyed by* the ADR-306 ontology, so "normal for this `Space` at
this hour" is a first-class query, and slow legitimate drift updates the
baseline (with provenance) instead of accumulating as permanent anomaly.
### 3. Anomaly = deviation from learned normal
- A live fused `WorldState` is scored against the applicable learned baseline
(matched by space and time context). A deviation beyond the baseline's
uncertainty is surfaced as an ADR-306 `Event` — *without* a per-anomaly
detector — carrying the baseline it deviated from, the deviation magnitude,
and its evidence level. Whether that event is actionable is a policy/consumer
decision (ADR-277), not this layer's.
- The learned normal is exactly what **ADR-315** (RF twin) can simulate against:
the twin proposes an expected state, spatial memory supplies the learned
actual-normal, and their divergence is a physically grounded anomaly signal.
## Consequences
- Anomaly detection generalizes: a space gets deviation detection from its own
learned normal, so a novel anomaly (never labeled anywhere) still registers as
a deviation, and the model transfers to a new room by *learning that room's*
normal rather than importing a foreign detector.
- Bounded memory: temporal compression keeps long-horizon memory finite; the
trade-off is that fine detail of old history is summarized, which is acceptable
for a normality baseline.
- Legitimate change is not a permanent false positive: slow drift updates the
baseline with provenance, distinguishing "furniture deliberately rearranged"
(baseline shifts) from "reflector appeared unexpectedly" (deviation event).
- **No accuracy claim is made.** Deviation-detection quality is not asserted
here; any detection-rate or false-positive number requires a named reproducer
tagged MEASURED / SYNTHETIC / CLAIMED, and a health/safety framing stays within
the ADR-282 bounded-claims discipline (decision support, not diagnosis). No
number is invented.
- The memory is governed: learned baselines are observations of a space, subject
to the same ADR-277 retention/privacy policy as the fused state they summarize;
no raw P0 RF is retained to build a baseline.
## Validation
- `cargo test -p wifi-densepose-ruvector` — the normality model builds on the
existing HNSW/event-log/temporal primitives; nearest-normal recall and
temporal-compression bounds are exercised on synthetic streams.
- Baseline/deviation tests: a synthetic scene with a known injected change (moved
`Object`, altered propagation, altered modality signature) produces a deviation
`Event` against the learned normal *without* a per-anomaly detector; an
unchanged diurnal cycle produces none (no false positive on normal periodicity).
- Drift test: a slow legitimate change updates the baseline (with provenance)
rather than emitting a persistent anomaly; an abrupt change does emit one.
- Evidence test: a learned baseline carries the evidence level of its source
observations and is never presented above it; retention honors ADR-277.
- Twin-linkage design check (with ADR-315): divergence between a twin-simulated
expected state and the learned normal is expressible as a deviation signal.
@@ -0,0 +1,142 @@
# ADR-313: Counterfactual inference — generative spatial reasoning
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: inference, generative, counterfactual, rf-twin, fusion, uncertainty, phase-3
## Context
This ADR is a child of **ADR-300** (perception substrate program) and owns
primitive #13, *counterfactual inference*. In the ADR-300 DAG it is a phase-3,
research-forward primitive that sits on top of the fused world state: it
**consumes ADR-311** (real sensor fusion) for the current fused estimate and
**ADR-315** (digital RF twin) for the twin's expected measurement
distributions. It is design intent, authored as Proposed, and is expected to be
revised as the phase-1 spine and the phase-2 fusion layer land.
RuView today reasons discriminatively: a task head maps measurements to a label
or a pose. That answers "what does the classifier say?" but not the questions an
operator actually asks — *would these RF measurements still make sense if nobody
were present? Does one person explain the observation better than two?* Those
are counterfactual questions, and a classifier cannot answer them because it has
no model of what a measurement *should* look like under a hypothesized world
state. A discriminative head asked about an empty room simply emits its
best-effort label; it cannot say "the observation is better explained by
absence."
The step this ADR proposes is toward a **generative spatial model**: given a
hypothesized scene state (occupancy, count, coarse positions) and the ADR-315
twin's propagation model for the deployment, predict the *expected* measurement
distribution, then score how well each hypothesis explains the observed
measurement. The best-explaining hypothesis — including the *nobody-present*
null hypothesis — is the answer, and the margin between hypotheses is a
first-class uncertainty signal.
Relevant existing assets to build on rather than duplicate:
- **ADR-311** (fusion) already produces the fused world estimate and its
covariance; the counterfactual layer scores hypotheses *relative to* that
estimate rather than re-fusing raw measurements.
- **ADR-315** (RF twin) is the generative forward model — per-deployment
geometry, radio locations, and expected measurement distributions. This ADR
is a *consumer* of the twin's forward simulator, not a second simulator.
- **ADR-302** (OOD/observability) already owns the `UNKNOWN` verdict; the
null-hypothesis ("nobody present better explains this than any occupancy
hypothesis") and the "no hypothesis explains this" case route through ADR-302,
not a parallel gate.
- `frame::EvidenceLevel` L0–L5 (ADR-282) and the ADR-304 evidence engine
account for the resulting confidence.
## Options considered
1. **Keep only discriminative heads.** Rejected: cannot express absence,
cannot compare "one person vs. two" as competing explanations, and gives a
confident label even when no world state explains the data.
2. **A second, independently trained generative network with its own forward
model.** Rejected for the default path: duplicates the ADR-315 twin's
propagation model, invites the two models to disagree, and multiplies the
surface that must be validated. Reserved only if the twin's analytic forward
model proves insufficient for a phenomenon.
3. **A hypothesis-scoring layer that uses the ADR-315 twin as the forward model
and the ADR-311 fused state as the hypothesis prior, routing low-margin and
null-dominant cases to the ADR-302 UNKNOWN verdict.** Chosen.
## Decision
Define a **counterfactual inference layer** that scores a small set of scene
hypotheses against observed measurements using the digital RF twin as the
generative forward model.
### 1. Hypothesis set
- Hypotheses are drawn from the ADR-311 fused state and its neighbourhood: the
current estimate, the **null hypothesis** (nobody present), and a bounded set
of nearby alternatives (±1 occupant, shifted position). The fused estimate
supplies the prior so the search stays small and grounded rather than
enumerating an open world.
- The hypothesis space is expressed over the **ADR-306** canonical ontology
(`Space`/`Zone`, occupant count, coarse position), so a counterfactual result
is a governed spatial statement, not an opaque score.
### 2. Forward model and scoring
- For each hypothesis, query the **ADR-315 twin** for the expected measurement
distribution given that scene state and the deployment's propagation model.
Score the observed measurement's likelihood under each hypothesis's expected
distribution.
- The answer is the maximum-likelihood hypothesis; the **margin** between the
top hypotheses (and between the top hypothesis and the null) is the
confidence signal, carried into the ADR-304 evidence engine.
### 3. Routing to UNKNOWN
- When the null hypothesis dominates, the layer reports *absence*, not a
low-confidence occupancy label.
- When **no** hypothesis explains the observation well (all likelihoods low, or
the winning margin below threshold), the result routes to the **ADR-302**
`UNKNOWN` verdict — the observation is outside what the twin can explain, and
the honest output is "I cannot account for this," never a forced label.
### Evidence discipline
- Twin-predicted distributions are a **simulation** (evidence level L0 per
ADR-282) labelled `SYNTHETIC`; a counterfactual verdict inherits the evidence
level of its weakest input and is never presented as camera-grade ground
truth (CLAUDE.md honesty rule).
- Any accuracy statement about counterfactual discrimination (e.g. "distinguishes
one occupant from two") requires the mean-pose-style baseline discipline of
CLAUDE.md, a leakage-free held-out split, and a reproducer before it may be
tagged `MEASURED`. This ADR asserts **no** such number.
## Consequences
- RuView gains the ability to answer absence and "which explanation is better"
questions that discriminative heads structurally cannot — a step toward
generative spatial reasoning and a differentiator for security and
facility-monitoring applications where *absence* is the valuable signal.
- Quality is bounded by the fidelity of the ADR-315 twin's forward model and the
ADR-311 fused prior; the layer reports margins and defers to ADR-302 UNKNOWN
rather than overstating a coarse model.
- Hard dependency on ADR-311 (fused state and covariance) and ADR-315 (forward
model); this ADR builds neither a fusion engine nor a propagation simulator of
its own.
- Being phase 3, this is design intent sitting on the fused world state; it is
expected to be revised as ADR-311 and ADR-315 land, and it is not implemented
by the phase-1 swarm.
## Validation
- Unit tests: hypothesis likelihood scoring is a deterministic function of
observed measurement + hypothesis + twin parameters; the null hypothesis wins
on a synthesized empty-room measurement; a two-occupant measurement scores the
two-occupant hypothesis above the one-occupant hypothesis on a controlled
synthetic case.
- Integration test: measurements the twin cannot explain (out-of-model
scattering) drive the layer to the ADR-302 UNKNOWN verdict rather than a
forced occupancy label; margins propagate into the ADR-304 evidence engine.
- Held-out discrimination (deferred, real-silicon): one-vs-two and
presence-vs-absence discrimination on a leakage-free held-out split with a
mean-pose baseline, reported as `MEASURED` with a reproducer. Until then all
counterfactual output is `SYNTHETIC`/L0. No discrimination accuracy number is
asserted by this ADR.
@@ -0,0 +1,138 @@
# ADR-314: Information-gain scheduler — sample the most informative radios
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: scheduling, active-sensing, information-gain, edge, energy, fusion, phase-3
## Context
This ADR is a child of **ADR-300** (perception substrate program) and owns
primitive #14, *information-gain scheduler*. In the ADR-300 DAG it is a phase-3,
research-forward primitive that sits on top of the fused world state and
**pairs with ADR-309** (active sensing): ADR-309 decides *what to probe*
(waveform, sensing task); this ADR decides *which radios/modalities to spend
budget on next*. It is authored as Proposed and is not implemented by the
phase-1 swarm.
With multiple sensors, processing every stream at full rate is wasteful: many
radios are, at any moment, contributing little to the current estimate while
consuming compute, energy, and bandwidth — the three scarce resources on the
edge nodes RuView targets (ESP32-S3/C6 and small gateways). Treating all sensors
equally is precisely the design that does not survive a real deployment of
"hundreds of sensors."
The scheduler assigns each candidate sensor/modality a value
```
Value(sensor) ≈ expected uncertainty reduction / (compute + energy + bandwidth)
```
and spends the next sampling/processing budget on the highest-value sensors.
Expected uncertainty reduction is estimated *before* paying for the measurement,
which is why the scheduler needs a model of what each sensor is likely to tell
it — supplied by the fused state's covariance and the RF twin's forward model,
not by actually sampling.
Relevant existing assets to build on rather than duplicate:
- **ADR-311** (fusion) maintains the fused state and its covariance — the
current uncertainty the scheduler is trying to reduce. Expected uncertainty
reduction is computed against that covariance, not a private one.
- **ADR-315** (RF twin) provides the per-sensor forward model used to predict a
candidate measurement's expected informativeness before sampling.
- **ADR-320** (RuView sensor HAL, phase 2) exposes each radio's real
compute/energy/bandwidth cost descriptors; the denominator is read from the
HAL, not guessed per platform.
- **ADR-309** (active sensing) is the paired actuator: the scheduler ranks
sensors, ADR-309 chooses the probe on the chosen sensor.
- **ADR-302** (observability) defines the phenomenon the estimate is *for*, so
the scheduler prioritizes uncertainty reduction on the objective that matters,
not on nuisance dimensions.
## Options considered
1. **Round-robin / process-everything scheduling.** Rejected: burns edge
compute and energy on redundant streams and does not scale to large fleets;
the strategic and external reviews named exactly this as an edge-deployment
blocker.
2. **Static priority per sensor type (e.g. always prefer mmWave).** Rejected:
ignores that a sensor's *current* informativeness depends on the scene and
the present uncertainty — a well-placed WiFi link can dominate an occluded
mmWave node in a given moment.
3. **A value-of-information scheduler that ranks sensors by expected uncertainty
reduction per unit cost, using the ADR-311 covariance and ADR-315 forward
model, with costs from the ADR-320 HAL.** Chosen.
## Decision
Define an **information-gain scheduler** that allocates the next
sampling/processing budget across available radios by value of information.
### 1. Value function
- For each candidate sensor/modality, estimate **expected uncertainty
reduction** on the ADR-302 objective by evaluating how much a predicted
measurement (via the **ADR-315** forward model) would shrink the **ADR-311**
fused-state covariance — a value-of-information estimate made *before* paying
for the measurement.
- Divide by the sensor's **cost** — compute + energy + bandwidth — read from the
**ADR-320** HAL descriptors. The exact weighting of the three cost terms is a
deployment policy (a battery node weights energy heavily; a wired gateway
weights bandwidth), configured, not hardcoded.
### 2. Allocation
- Rank candidates by value and spend the budget on the top set, subject to a
configurable floor that guarantees each sensor is sampled at least
occasionally (so a sensor whose value is currently low is not starved into
permanent blindness and can be re-evaluated as the scene changes).
- The scheduler emits an allocation, not a measurement; **ADR-309** active
sensing chooses the probe/waveform on each selected sensor, and the fusion
layer (ADR-311) incorporates the result.
### 3. Governance and honesty
- Skipping a sensor for a cycle is a *deliberate* reduction in coverage; the
scheduler records which sensors were sampled so downstream evidence (ADR-304)
reflects the actual sensing that occurred, and observability (ADR-302) can
raise `UNKNOWN` for a zone that went under-sampled rather than reporting a
stale estimate as current.
### Evidence discipline
- Expected-uncertainty-reduction estimates are model predictions from the
ADR-315 twin (simulation, L0 per ADR-282, `SYNTHETIC`); a scheduling decision
is a resource choice, never a sensing claim.
- Any energy/latency/throughput improvement figure requires real-silicon
measurement with a reproducer before it is tagged `MEASURED` (CLAUDE.md
hardware rule). This ADR asserts **no** efficiency number.
## Consequences
- Edge deployments spend scarce compute, energy, and bandwidth where they buy
the most certainty, making "hundreds of sensors" operationally tractable — a
capability the reviews flagged as critical for edge deployment.
- Quality is bounded by the accuracy of the ADR-315 forward model (informativeness
prediction) and ADR-320 cost descriptors; a poor forward model degrades to
near-round-robin, which is safe but not optimal. The sampling floor bounds the
worst case.
- Hard dependency on ADR-311 (covariance), ADR-315 (forward model), and ADR-320
(cost descriptors), and paired with ADR-309; this ADR builds none of those.
- Being phase 3, this is design intent sitting on the fused world state and is
expected to be revised as ADR-309, ADR-311, ADR-315, and the ADR-320 HAL land.
## Validation
- Unit tests: the value function is a deterministic function of covariance +
forward model + cost descriptors; a sensor predicted to reduce objective
uncertainty more per unit cost ranks above one that reduces it less; the
sampling floor guarantees eventual re-evaluation of a low-value sensor.
- Integration test: on a synthetic multi-sensor scene, the scheduler reduces
objective uncertainty faster per unit modelled cost than round-robin, and
raises ADR-302 UNKNOWN for a deliberately starved zone rather than reporting a
stale estimate.
- Field validation (deferred, real-silicon): energy/latency/throughput on an
instrumented multi-node deployment, reported as `MEASURED` with a reproducer.
Until then all informativeness and cost figures are `SYNTHETIC`/L0. No
efficiency number is asserted by this ADR.
+159
View File
@@ -0,0 +1,159 @@
# ADR-315: Digital RF twin — persistent per-deployment RF model
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: rf-twin, digital-twin, propagation, calibration, spatial-memory, worldgraph, phase-3
## Context
This ADR is a child of **ADR-300** (perception substrate program) and owns
primitive #15, *digital RF twin*. In the ADR-300 DAG it is a phase-3,
research-forward primitive that underpins several other phase-3 primitives:
**ADR-308** (placement optimizer) plans against the twin's propagation model,
**ADR-313** (counterfactual inference) uses it as the generative forward model,
and **ADR-314** (information-gain scheduler) uses it to predict per-sensor
informativeness. It ties directly to **ADR-301** (calibration), **ADR-308**
(placement), and **ADR-312** (long-term spatial memory). It is authored as
Proposed and is not implemented by the phase-1 swarm.
RuView today has no persistent, per-deployment model of the RF environment.
Calibration state, observed multipath, and radio geometry exist transiently
inside a running session; when the process restarts or a change happens
overnight, there is nothing that says "this is what this room's RF looked like
yesterday." Without a persistent baseline, a physical change — furniture moved,
a wall opened, a machine relocated, an intruder present — has nothing to be a
*delta against*. It is just a different measurement, indistinguishable from
noise or drift.
The **digital RF twin** is that persistent baseline: a per-deployment model
holding
- **geometry and radio locations** (from the ADR-306 scene / worldgraph),
- **propagation history** and **observed multipath** structure,
- **calibration state** (from ADR-301),
- **expected measurement distributions** for each link and phenomenon.
Once the twin exists, a physical change becomes a **measurable delta against the
twin** rather than an unexplained measurement. This is what connects RuView to
facility management (what changed in this space?), security (is there an
unexplained presence?), robotics (has the map drifted?), and industrial
monitoring (did the plant layout change?) — the applications the strategic
assessment named as the value beyond a single detector.
Relevant existing assets to build on rather than duplicate:
- The `worldgraph` crate already models the physical scene — `Room`/`Space`
with `bounds_enu`, `Wall { rf_attenuation_db }`, `Doorway`, `Zone`, and
`Sensor` nodes (ADR-306). The twin *annotates and persists* this scene with RF
state; it does not invent a second geometry.
- `wifi-densepose-calibration` (enrollment, bank, anchor, runtime, specialist)
holds the calibration state the twin persists; the twin references and
versions calibration records, it does not reimplement calibration.
- **ADR-312** (long-term spatial memory, phase 3) is the persistence and
temporal-history substrate; the twin is a *structured occupant* of that
memory, not a separate database.
- **ADR-305** (authenticated identity) and **ADR-295** (provenance) mean the
measurements that update the twin carry verified lineage, so a delta is
attributable rather than anonymous.
## Options considered
1. **No persistent RF model (status quo).** Rejected: every change looks like
noise; nothing supports "what changed since yesterday?", which is the
question the facility/security/industrial applications actually ask.
2. **A full electromagnetic digital twin (per-site ray-tracing / FDTD kept in
sync in real time).** Rejected for the default path: far heavier than the
coarse `rf_attenuation_db` scene RuView actually has and impractical on edge
hardware. A high-fidelity solver is retained as an *optional backend* the
twin can call, not the baseline.
3. **A persistent, per-deployment RF model layered over the ADR-306 scene and
ADR-312 memory: geometry + radio locations + calibration state + observed
multipath + expected measurement distributions, updated by verified
measurements, exposing changes as deltas.** Chosen.
## Decision
Define the **digital RF twin** as a persistent, versioned, per-deployment model
of the RF environment, layered over existing scene, calibration, and memory
assets.
### 1. State the twin holds
- **Geometry and radio locations** referenced from the ADR-306 / worldgraph
scene (not copied).
- **Calibration state** referenced and versioned from
`wifi-densepose-calibration` (ADR-301), so the twin knows *which* calibration
a stored distribution was captured under.
- **Observed multipath and propagation history** — a bounded temporal summary
of per-link channel structure, stored in ADR-312 spatial memory.
- **Expected measurement distributions** per link and phenomenon — the forward
model ADR-308, ADR-313, and ADR-314 consume.
### 2. Update and delta
- Verified measurements (ADR-305 identity, ADR-295 provenance) update the twin's
distributions online, bounded by ADR-301 calibration validity. A new
observation is compared to the twin's expected distribution; the **delta** —
and its statistical significance against the twin's own variance — is the
primary output. A change large relative to the twin's modelled variance is a
*detected physical change*, not noise.
- The twin is **versioned**: a calibration event, a deliberate geometry edit, or
an accepted physical change advances the twin version, so history is
auditable and a delta is always relative to a named baseline.
### 3. Consumers
- **ADR-308** queries the twin's propagation model to plan placements.
- **ADR-313** uses the twin's expected distributions as the generative forward
model for hypothesis scoring.
- **ADR-314** uses per-sensor expected informativeness from the twin.
- Facility/security/robotics/industrial integrations read the twin's change
deltas as governed ADR-306 spatial events.
### Evidence discipline
- The twin's expected distributions and any propagation simulation are
**simulation** (evidence level L0 per ADR-282), labelled `SYNTHETIC`. A delta
computed against them is a model-relative statement.
- A change/anomaly detection *claim* (e.g. "detects furniture-scale changes")
requires real-silicon measurement against a leakage-free protocol with a
reproducer before it is tagged `MEASURED` (CLAUDE.md hardware rule). The twin
never presents a modelled expected distribution as evidence that a physical
state *is* the case; it presents a *delta and its significance*. This ADR
asserts **no** detection-accuracy number.
## Consequences
- RuView gains a persistent per-deployment baseline, turning "a different
measurement" into "a measurable, attributable, versioned change" — the bridge
from a sensing runtime to facility management, security, robotics, and
industrial monitoring.
- The twin is the shared forward model for ADR-308/310/311, so those primitives
speak one propagation model rather than three inconsistent ones — a
deliberate reason to build the twin before its consumers mature.
- Quality is bounded by the coarseness of the worldgraph scene and the fidelity
of the forward model; the twin reports deltas *with significance against its
own variance* rather than asserting confident change detection on a coarse
model. The optional high-fidelity backend is where higher accuracy lives.
- Hard dependency on ADR-306 (scene), ADR-301 (calibration state), and ADR-312
(persistence); it reuses `worldgraph` and `wifi-densepose-calibration` rather
than rebuilding geometry or calibration.
- Being phase 3, this is design intent; it is expected to be revised as the
phase-1 spine, ADR-311 fusion, and ADR-312 memory land.
## Validation
- Unit tests: the twin's expected distribution is a deterministic function of
scene + calibration + propagation history; delta computation and its
significance against stored variance are correct on synthetic distributions;
versioning advances on calibration/geometry/accepted-change events and history
is retained.
- Integration test: on a synthetic deployment, an injected physical change (a
wall attenuation shift) produces a significant delta against the twin while
ordinary noise does not; the delta surfaces as a governed ADR-306 event with
provenance (ADR-305/292).
- Field validation (deferred, real-silicon): change detection on an instrumented
real deployment with a controlled physical-change protocol, reported as
`MEASURED` with a reproducer. Until then all twin distributions and deltas are
`SYNTHETIC`/L0. No detection-accuracy number is asserted by this ADR.
+156
View File
@@ -0,0 +1,156 @@
# ADR-316: Fleet control plane — provisioning to audit trails
- **Status**: Proposed (ADR-300 phase 2)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: fleet, operations, provisioning, firmware, updates, audit, identity, phase-2
## Context
This ADR is a child of **ADR-300** (perception substrate program) and owns
primitive #16, *fleet control plane*. In the ADR-300 DAG it is a phase-2
integration-and-operations primitive that sits on the phase-1 spine: it
**consumes ADR-305** (authenticated sensor identity) for per-device identity and
enrollment, and **ADR-318** (capability certificate) for the signed models,
calibration validity, and capability envelopes a device is allowed to run. It is
authored as Proposed and is not implemented by the phase-1 swarm.
The external and internal reviews both named the same operational gap: RuView
has strong per-device primitives but no **release identity** and no **bill of
materials** binding a fielded sensor to the exact firmware, model, and
calibration it is running — and no plane to manage that across many devices.
Without this, a handful of nodes is fine but *hundreds* of sensors become an
operational nightmare: no coherent way to provision, roll certificates, verify
firmware compatibility, distribute signed models, track calibration lifecycle,
watch health, stage updates, roll back, diagnose remotely, enforce data
retention, or produce an audit trail. This ADR addresses that release-identity /
BOM gap directly.
The scope is deliberately the **control plane**, not the data plane. The
authenticated measurement path is **ADR-296** (bind + allowlist) plus **ADR-305**
(signed envelope); this ADR governs the *devices and artifacts*, not the
per-frame stream.
Relevant existing assets to build on rather than duplicate:
- **ADR-305** already defines per-device keypairs, the `DeviceId → public key →
capabilities` enrollment record, key rotation and revocation *semantics* — and
explicitly deferred their **fleet distribution** to this ADR. The control
plane is the distribution and lifecycle layer over ADR-305 identity, not a new
identity scheme.
- **ADR-318** (capability certificate) defines the signed, expiring artifact a
device is authorized to run; the fleet plane is what *distributes, stages, and
revokes* those certificates and the signed models they point at.
- **ADR-301** (calibration) owns calibration validity/expiry; the fleet plane
tracks calibration *lifecycle* across the fleet (which nodes are due, which are
stale) rather than redefining calibration.
- **ADR-319** (witness chain) provides the append-only, re-verifiable record;
fleet audit trails are witness-chain entries, not a parallel log format.
- **ADR-320** (RuView sensor HAL, phase 2) provides hardware/firmware capability
descriptors used for firmware-compatibility checks before staging an update.
- `wifi-densepose-bfld` `CapabilityAttestation` (ADR-141) is the device-side
attestation the plane checks against declared cohort capabilities.
## Options considered
1. **Manual per-device operations (SSH/flash by hand).** Rejected: does not
scale past a handful of nodes, produces no release identity, no audit trail,
and no safe rollback — exactly the operational nightmare the reviews named.
2. **Adopt a generic third-party IoT device-management platform wholesale.**
Rejected as the core: generic platforms do not understand RuView's signed
capability certificate, calibration validity, or witness chain, and would
fork trust away from the phase-1 spine. A generic transport/agent *may* be a
backend, but identity, certificates, and audit remain RuView's.
3. **A RuView-native control plane layered on ADR-305 identity, ADR-318
certificates, ADR-301 calibration lifecycle, and ADR-319 audit — covering
provisioning through rollback and retention.** Chosen.
## Decision
Define a **fleet control plane** that manages RuView sensors and their signed
artifacts across their lifecycle, built on the phase-1 identity/certificate
spine.
### 1. Release identity and bill of materials
- Each fielded device has a **BOM record** binding `DeviceId` (ADR-305) → exact
firmware version → signed model set → active capability certificate (ADR-318)
→ current calibration record (ADR-301) → HAL/hardware descriptor (ADR-320).
This *is* the release identity the reviews found missing: given a device you
can state precisely what it is running and prove it is signed.
### 2. Provisioning, certificates, firmware compatibility
- **Provisioning** is the authorized ADR-305 enrollment step at fleet scale:
minting a keypair, registering the public key and capabilities, and issuing
the initial ADR-318 certificate. A device is untrusted until provisioned.
- **Certificate lifecycle**: issue, rotate, expire, and **revoke** ADR-318
certificates and the ADR-305 keys behind them; revocation lists are
distributed here (the distribution ADR-305 deferred).
- **Firmware compatibility**: before staging a firmware or model, check the
target's ADR-320 HAL descriptor and ADR-141 capability attestation so an
incompatible or under-capable device is never sent an artifact it cannot
honestly run.
### 3. Cohorts, staged updates, rollback
- Devices group into **cohorts** (by site, hardware, capability). Updates —
signed models and firmware — roll out **staged** (canary → cohort → fleet)
with health gates between stages, and **roll back** to the previously recorded
BOM on a failed health check. Only signed artifacts are ever staged.
### 4. Health telemetry, remote diagnostics, retention, audit
- **Health telemetry** and **remote diagnostics** report device liveness,
calibration staleness (ADR-301), certificate expiry (ADR-318), and error
state — read-only diagnostics by default, mutations authorized explicitly.
- **Data retention** policy is enforced per cohort, and P0/CSI/person data never
leaves the edge except under the ADR-277/280 governance already in force
(CLAUDE.md: never commit or exfiltrate CSI/person data).
- Every lifecycle action — provision, rotate, revoke, stage, roll back — is
written as an **ADR-319 witness-chain** entry, giving a re-verifiable **audit
trail** rather than a mutable log.
### Authority and least privilege
- The control plane is default-deny (CLAUDE.md: default to least authority).
Provisioning, key rotation, revocation, staging, and rollback are each
separately authorized operations; no fleet action is implied by another.
Credentials and private keys are never logged or committed.
## Consequences
- Hundreds of sensors become operable: coherent release identity, signed-artifact
distribution, staged updates with rollback, and a re-verifiable audit trail —
closing the release-identity / BOM gap the reviews raised.
- The plane concentrates operational authority; that is mitigated by
default-deny, per-action authorization, signed-only artifacts, and
witness-chained audit. A compromised plane must still forge signatures the
phase-1 spine verifies.
- Hard dependency on ADR-305 (identity), ADR-318 (certificate), ADR-301
(calibration lifecycle), ADR-319 (audit), and ADR-320 (firmware/HAL
compatibility). This ADR distributes and sequences those artifacts; it does
not redefine identity, certificates, calibration, or the witness format.
- Being phase 2, this is design intent depending on the spine; it is expected to
be revised as ADR-318, ADR-319, and ADR-320 land.
- **No fielded fleet-operation claim is MEASURED without real-silicon evidence**
(CLAUDE.md hardware rule): staged update and rollback on real nodes require a
captured runtime log. A passing simulation is not fleet evidence.
## Validation
- Unit tests: BOM records bind identity/firmware/model/certificate/calibration
consistently and reject inconsistent bindings; certificate issue/rotate/revoke
transitions are correct; a firmware-incompatible target is refused staging;
every lifecycle action emits a well-formed ADR-319 witness entry.
- Integration test: a synthetic cohort undergoes a canary→cohort→fleet staged
update; an injected health failure triggers rollback to the prior BOM; the
full sequence is re-verifiable from the witness chain offline; a revoked
certificate is rejected fleet-wide.
- Security test (`npm run test:security` analogue for the plane): default-deny
is enforced; unauthorized provision/rotate/revoke/stage is rejected and
counted; no credential or P0 data appears in telemetry or audit output.
- Field validation (deferred, real-silicon): a real multi-node staged update and
rollback with a captured boot/runtime log, reported as `MEASURED` with a
reproducer. Until then all fleet-operation results are simulator-level. No
fielded reliability number is asserted by this ADR.
@@ -0,0 +1,140 @@
# ADR-317: Multi-domain benchmark scorecard — regressions cannot hide behind pooled accuracy
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: benchmark, aetherarena, ci-gate, evidence, honesty, domain-generalization, substrate
## Context
This ADR is primitive 17 of the perception-substrate program (ADR-300) and the
per-PR enforcement edge of the phase-1 certificate spine. In the ADR-300
dependency DAG it reads accuracy from the evidence engine (ADR-304), consumes
the domain state produced by out-of-distribution detection (ADR-302), scores
against calibration certificates (ADR-301), and is anchored in the witness chain
(ADR-319). It is the surface that makes the rest of the spine testable on every
change to sensing code.
A single pooled accuracy number is the classic way a domain-generalization
regression hides. A model can raise mean PCK or mean presence accuracy while
quietly collapsing on unseen rooms, unseen devices, or stationary subjects —
exactly the conditions WiFi sensing fails in and exactly the conditions a
pooled average washes out. The strategic assessment (ADR-300) named this: what
distinguishes infrastructure from a demo is that a regression on *any* operating
domain is caught before merge, not discovered in the field.
RuView does not need a new benchmark to do this. AetherArena is already
**v0-complete infrastructure** (ADR-149): a deterministic scoring engine
reusing `wifi-densepose-train` (`src/ruview_metrics.rs`, `src/ablation.rs`,
`src/eval.rs`, `src/proof.rs`), a `PROOF_SEED=42` determinism substrate that
SHA-256-hashes outputs against an expected hash, an append-only witness ledger,
and a live Hugging Face Space. ADR-145's ablation harness already computes
presence accuracy, localization error, FP/FN, latency percentiles, a
privacy-leakage score, and **cross-room degradation**. The board is
intentionally empty (benchmark-first). What is missing is not a scorer but a
**scorecard format** that reports per-domain rather than pooled, and a
**sensing-crate CI gate** that runs it on every PR.
## Options considered
1. **Keep the single pooled score / `RuViewTier`.** Rejected: it is exactly the
surface a per-domain regression hides behind; a Gold tier can coexist with a
broken unseen-room slice.
2. **Add a new benchmark repo/harness for domains.** Rejected: AetherArena's
scorer, determinism binding, and witness ledger already exist and are the
right engine; a parallel harness would fork the scoring substrate and its
anti-gaming/leakage discipline.
3. **Extend the AetherArena scorer with a per-domain scorecard and wire it as a
per-PR sensing-crate gate.** Chosen.
## Decision
Reuse the AetherArena scorer and witness ledger (ADR-149) and add two things: a
**multi-domain scorecard** format and a **sensing-crate PR gate** that produces
it.
### 1. The multi-domain scorecard
The scorecard reports each capability broken out by operating domain, never
pooled into one figure. The v0 domain axes:
- **Presence**: `room-known`, `room-unseen`, `device-unseen`, `stationary-10m`
(a stationary subject at range — the canonical WiFi failure case).
- **Pose**: `matched`, `subject-unseen`, `room-unseen`.
- **OOD rejection**: the rate at which genuinely out-of-distribution input is
correctly returned as UNKNOWN by ADR-302 (a capability, not a failure) and
the false-UNKNOWN rate on in-distribution input.
- **Calibration drift**: fingerprint-distance trajectory against the ADR-301
certificate over the scored window, and the fraction of inferences in each
ADR-302 `DomainState` (KNOWN / DEGRADED / UNKNOWN).
Each cell carries exactly one `EvidenceLevel` (L0–L5, ADR-282). A slice scored
on synthetic input is L0/`Synthetic` by construction; a slice on a leakage-free
held-out real split is graded higher and only then may a per-domain number be
labelled MEASURED. Pose PCK cells additionally require the mean-pose baseline
and a leakage-free held-out split (CLAUDE.md) or they are not reported as pose
accuracy at all.
### 2. Per-domain regression gate
- The gate compares each scorecard cell against the merged-baseline scorecard
stored in the AetherArena witness ledger. A regression **in any single
domain** beyond its configured threshold fails the PR, even if the pooled
average improved. Improvement on `room-known` cannot buy a regression on
`room-unseen`.
- Thresholds are per-domain and per-capability; the unseen/stationary/OOD
domains carry the strictest budgets because they are the ones a pooled score
hides. The baseline is append-only and witness-anchored — a new baseline is a
new signed ledger entry, never an in-place overwrite (ADR-149 ledger pattern,
ADR-319 anchoring).
### 3. Sensing-crate CI wiring
- Every PR that touches a sensing crate runs the scorecard across all domains
under the ADR-011/ADR-149 determinism binding (`PROOF_SEED=42`), so the run
is reproducible and tamper-evident. The gate is added to
`.github/workflows/` as an authoritative check.
- The held-out real split remains private and is never accessible to synthetic
generation, augmentation, or calibration (ADR-149 leakage constraint, ADR-282
rule d). Submitters/PRs provide a model, not predictions on data they hold.
### Provenance and honesty discipline
- No benchmark numbers are invented by this ADR. It delivers the scorecard
format, the per-domain gate, and the CI wiring; the numbers come from the
ADR-304 evidence ledger and the AetherArena scorer on real data, labelled at
the honest evidence level. Empty domains report "no evidence," which the gate
treats as no coverage — never as a pass.
## Consequences
- A domain-generalization regression can no longer merge behind a flattering
pooled average; the failure mode that most distinguishes fielded sensing from
a demo is caught at PR time.
- Every PR touching sensing pays a per-domain scoring cost. Bounded by reusing
the existing deterministic scorer and by tiered compute (CPU smoke vs full
score, ADR-149), but it is a deliberate cost for per-domain safety.
- The empty AetherArena board fills with honest, per-domain, evidence-labelled
results rather than a single headline tier — consistent with the
benchmark-first posture and with ADR-282's ecosystem positioning.
- Some domains will show weak or absent coverage. Surfacing that per-domain is
the point; the scorecard must never paper over a thin domain with a pooled
number.
- The program-level acceptance test (ADR-300) is encoded here as an AetherArena
scenario, closing the loop once the phase-1 spine lands.
## Validation
- `cargo test` on the AetherArena scorer extension — per-domain slicing math
against fixtures; per-domain regression gate fails on a single-domain
regression while pooled improves, and passes when all domains hold; empty
domains report "no evidence," not a pass; every cell carries exactly one
`EvidenceLevel`; synthetic slices are L0 by construction.
- Determinism: a scored run reproduces its SHA-256 hash under `PROOF_SEED=42`
(ADR-011/ADR-149 binding); the baseline scorecard is append-only and
witness-anchored (ADR-319), never mutated in place.
- CI: the sensing-crate gate runs on a PR touching a sensing crate and blocks a
planted single-domain regression.
- Real-data scorecards (a leakage-free held-out split with ADR-303 references)
are the maturity milestone; a synthetic scorecard is L0 and no per-domain
number is MEASURED without a reproducer per CLAUDE.md.
+138
View File
@@ -0,0 +1,138 @@
# ADR-318: Capability certificates — validated-for-this-environment claims
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: capability, certificate, evidence, provenance, signature, honesty, substrate
## Context
This ADR is primitive 18 of the perception-substrate program (ADR-300) and,
per the strategic assessment, among the strongest ideas in the program: it is
where the whole certificate spine becomes a consumable contract. In the ADR-300
dependency DAG it **consumes the evidence engine (ADR-304)** — a capability
certificate is a signed attestation minted over a slice of that ledger — the
**calibration certificate (ADR-301)** for the environment it is validated
against, and the **RuField signature types (ADR-305 / ADR-260/262/277/279)** to
sign it. It reports domain state via ADR-302 and is anchored in the witness
chain (ADR-319).
RuView must stop making unconditional capability claims. "Supports presence" is
not a true statement — presence detection works in some rooms, on some hardware,
for some subject dynamics, and fails on a stationary subject at range in an
uncalibrated room. A capability is only ever *validated for a specific
environment*, and the honest unit of that claim is a signed, expiring
certificate, not a feature flag in a README.
The ingredients now exist across the phase-1 spine: ADR-304 accumulates
per-`(room, device, subject)` accuracy, false-positive rate, drift, and domain
state; ADR-301 produces the signed room fingerprint the environment is keyed to;
ADR-305 provides the authenticated device identity and `CapabilityAttestation`
(BFLD, ADR-141) that bounds *what a device is even attested to sense*; ADR-282
provides the mandatory `EvidenceLevel`. What is missing is the artifact that
binds them into a single, verifiable "validated here, until then" claim and the
consumer-side rule that refuses capabilities lacking one.
## Options considered
1. **Static capability flags / a `supports_presence` boolean.** Rejected: it is
the exact dishonest claim — environment-independent, unsigned, non-expiring,
and false the moment the room, device, or subject dynamics differ.
2. **Report raw ledger accuracy to consumers directly.** Rejected: the ledger
(ADR-304) is the source of truth but not a portable, signed, bounded contract;
handing consumers raw records pushes evidence-weighting and expiry logic into
every consumer and drops the single verifiable object.
3. **Mint a signed, expiring `CapabilityCertificate` over an ADR-304 ledger
slice, and make consumers refuse capabilities without a valid one.** Chosen.
## Decision
Introduce a signed **`CapabilityCertificate`**: a bounded attestation that a
specific capability has been validated for a specific environment, for a bounded
time.
### 1. The certificate
A serializable `CapabilityCertificate` binding:
- `capability` — the phenomenon (e.g. `presence`, `pose`), which must be within
the device's ADR-305/ADR-141 `CapabilityAttestation` (a device cannot be
certified for something it is not even attested to sense).
- `room` — the ADR-306 space identifier, tied to the ADR-301 calibration
certificate version the validation was performed against.
- `hardware` — the ADR-305 authenticated `DeviceId` (and, in phase 2, the
ADR-320 HAL descriptor of the sensor).
- `model` — the model version scored.
- `calibrated_date` — the calibration certificate age at validation time.
- `moving_recall`, `stationary_recall`, `false_presence_per_24h` — the measured
operating metrics, sliced from the ADR-304 ledger for this exact context (not
a global average), each honestly labelled. These are per-capability; a pose
certificate carries pose metrics with the mean-pose baseline and a
leakage-free split (CLAUDE.md) or it is not issued.
- `valid_until` — an explicit expiry; a certificate is never open-ended.
- `evidence_level` — exactly one L0–L5 (ADR-282). A certificate minted from a
synthetic ledger slice is L0/`Synthetic`; a MEASURED metric requires an
ADR-303 reference and a reproducer. The certificate cannot upgrade the level
of the ledger it is minted from (ADR-304 honesty rule).
- `signature` — a RuField `SignatureBlock` (ADR-305 / ADR-260/262/277/279) over
the canonical serialization; an unsigned certificate is not a valid
certificate. The certificate is anchored in the witness chain (ADR-319).
### 2. Minting
- A certificate is minted from a slice of the ADR-304 evidence ledger for one
`(room, device, subject-class, model)` context. If the ledger reports "no
evidence" for that context, **no certificate is issued** — absence of evidence
is never a capability. Minting is a pure function over the append-only ledger
at mint time; the metrics are frozen into the signed object.
- Expiry (`valid_until`) is derived from calibration validity (ADR-301) and an
evidence-freshness policy: a certificate cannot outlive the calibration it was
validated against, and drift beyond the ADR-301 envelope invalidates both.
### 3. Consumer refusal rule
- Applications and surfaces **refuse to consume a capability that lacks a valid
certificate for the current environment**. "Valid" means: signature verifies,
`room`/`hardware`/`model` match the running context, `valid_until` is in the
future, and the referenced calibration certificate is itself still valid
(ADR-301 not invalidated). A failed check yields UNKNOWN via ADR-302, not a
best-effort guess.
- This makes the ADR-300 acceptance clause "quantify whether it can reliably
sense the requested phenomenon → generate a signed capability certificate"
a hard gate rather than a hope.
## Consequences
- RuView can no longer claim a capability it has not validated for the caller's
environment; the honest failure — "not certified here" → UNKNOWN — is
surfaced by construction rather than by discipline.
- OEM/integrator diligence gets a single verifiable artifact ("presence,
validated in *this* room, on *this* device, with *these* recall/false-alarm
numbers, until *this* date, at *this* evidence level, signed") — the strongest
commercial output of the spine.
- Certificates expire and get refused; some environments will have no
certificate and therefore no capability until validated. That refusal is the
intended honest behavior, not a regression.
- Key management and expiry policy are operational responsibilities, reusing the
ADR-305 enrollment/rotation and ADR-301 validity machinery rather than new
infrastructure; fleet distribution of certificates is owned by ADR-316.
- No capability number is invented here; every metric on a certificate is sliced
from the ADR-304 ledger at its honest evidence level.
## Validation
- `cargo test` on the certificate crate — mint from a ledger slice produces the
frozen metrics; "no evidence" context yields no certificate; signature
round-trip and tamper rejection; `valid_until` and calibration-linked expiry
enforced; consumer refusal on room/hardware/model mismatch, expiry, or
invalidated calibration resolves to UNKNOWN (ADR-302), not a guess; evidence
level is inherited from the ledger and cannot be upgraded; a certificate
cannot be issued for a capability outside the device's ADR-305/ADR-141
attestation.
- Cross-ADR: an ADR-304 ledger fixture mints a certificate; an ADR-302 test
asserts an expired/mismatched certificate gates to UNKNOWN; the ADR-300
acceptance test consumes a minted certificate end-to-end.
- Real-deployment certificates (minted from a populated ledger with ADR-303
references on live ESP32 captures) are the maturity milestone and require
hardware evidence per CLAUDE.md; a certificate minted from a synthetic ledger
is L0 by construction.
+146
View File
@@ -0,0 +1,146 @@
# ADR-319: Witness chain — epistemic infrastructure for physical AI
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: provenance, witness, evidence, signature, epistemics, ontology, substrate
## Context
This ADR is primitive 19 of the perception-substrate program (ADR-300) and a
spine root of its phase-1 certificate stack. In the ADR-300 dependency DAG it
**extends the source-provenance state machine (ADR-295)** and the RuField
provenance types, **ties to the signature machinery (ADR-305 /
ADR-260/262/277/279)**, and anchors the artifacts produced by ADR-301
(calibration certificates), ADR-304 (evidence records), ADR-317 (benchmark
scorecards), and ADR-318 (capability certificates). In phase 2 it carries the
independent-corroboration link from ADR-303.
The strategic assessment (ADR-300) framed RuView's real product as **epistemic
infrastructure for physical AI**: the value is not the claim "a person is
present" but the *auditable reasoning* behind it. A bare boolean output discards
everything a downstream system needs to trust or contest it — which radio
observed it, what DSP evidence supported it, which model inferred it, whether an
independent sensor agreed, what spatial state it updated, and what policy acted
on it. Once the answer is a boolean, "why do you believe that?" has no answer.
RuView already has the pieces of a chain but not the chain itself. ADR-295
defines a canonical `SourceState` (`Synthetic` / `LiveVerified` /
`LiveUnverified` / `Stale` / `Disconnected`) with `Unknown` structurally
forbidden from collapsing to live. ADR-305 defines the signed
`device → measurement → sequence → timestamp → … → signed event` chain of
custody. RuField carries `FrameProvenance`, `SemanticProvenance`, and signature
types; the AetherArena witness ledger (ADR-149) demonstrates an append-only,
witness-anchored ledger. What is missing is a single **staged, signed envelope**
that travels the whole pipeline and records, at each stage, the confidence and
provenance of that stage.
## Options considered
1. **Keep provenance as scattered per-stage fields (status quo).** Rejected:
`FrameProvenance`, `SourceState`, calibration state, and model uncertainty
live in different structures and are re-encoded per surface; there is no
single object a consumer can re-verify offline to answer "why."
2. **Log a free-form audit trail alongside the output.** Rejected: mutable,
unsigned, and not structurally tied to the output — the classic
dashboard-that-overwrites-yesterday failure the evidence engine (ADR-304)
already rejects.
3. **A staged, signed witness envelope carried through the pipeline, each stage
appended and signed, anchored in an append-only ledger.** Chosen.
## Decision
Define the **witness chain**: a staged, append-only, signed envelope that
accompanies an observation from radio to policy decision. Instead of emitting
"person present," RuView emits a chain whose stages are:
```
RF observation ▸ DSP evidence ▸ model inference ▸ independent corroboration
▸ spatial state ▸ policy decision
```
### 1. The staged envelope
- Each stage is a signed record carrying its **confidence** and its
**provenance**:
- **RF observation** — the ADR-305 authenticated frame envelope
(`DeviceId`, sequence, timestamp, measurement hash) and its ADR-295
`SourceState`. This is the root link; a `Synthetic` root can never present
as a `LiveVerified` one (ADR-295 invariant).
- **DSP evidence** — the deterministic signal features and the ADR-137
quality signals that support (or fail to support) an inference.
- **model inference** — the model version, its raw output, and its predictive
uncertainty; the ADR-302 `DomainState` (KNOWN / DEGRADED / UNKNOWN) gate
result, so a low-confidence or out-of-distribution inference is recorded as
such, not silently promoted.
- **independent corroboration** — the phase-2 ADR-303 agreement link
(a reference/second modality that agreed or disagreed); absent in phase 1,
the stage records "no corroboration," never a fabricated one.
- **spatial state** — the ADR-306 ontology `Observation`/`Track`/`Event` the
inference updated, carrying `SemanticProvenance` and its `EvidenceLevel`.
- **policy decision** — the governed action taken (or withheld), with the
certificate (ADR-318) it relied on.
- Each stage carries exactly one `EvidenceLevel` (L0–L5, ADR-282); the envelope's
effective level is the **minimum** across its stages — a synthetic root or an
unreferenced inference caps the whole chain, so the chain cannot claim more
than its weakest link.
### 2. Signing and anchoring
- Each stage is signed with RuField signature types (ADR-305 /
ADR-260/262/277/279) over the canonical serialization of that stage plus the
hash of the prior stage, so the chain is tamper-evident end to end and any
broken link is detectable. The completed chain is anchored in an append-only,
witness-anchored ledger following the AetherArena pattern (ADR-149); it is the
same anchoring ADR-301/ADR-304/ADR-317/ADR-318 write into.
- The chain is **append-only**: a correction is a new chain referencing the
prior one, never an in-place edit (mirroring ADR-304 and CLAUDE.md's "source
over summaries").
### 3. Offline re-verification
- A consumer with the enrolled public keys (ADR-305) can re-verify a chain
offline: check each stage signature, check each prior-stage hash, and read the
per-stage confidence and evidence level — answering "why do you believe this?"
without trusting the emitting host. This is the property store-and-forward
channel authentication (rejected in ADR-305) cannot provide.
### Provenance and honesty discipline
- The witness chain never manufactures confidence: a stage that lacks evidence
records the absence. A `Synthetic` root, a missing corroboration, or an
UNKNOWN gate is carried faithfully and caps the chain's evidence level. No
accuracy number is invented here; the chain records the numbers the other
primitives produce at their honest level.
## Consequences
- Every RuView output becomes contestable and auditable: a downstream physical-AI
system can inspect the reasoning, weight it by per-stage confidence, and reject
a chain whose weakest link is too weak — the defining property of epistemic
infrastructure the strategic assessment asked for.
- The certificate spine (ADR-301/301/314/315) gains a single anchoring substrate;
each of those artifacts is a specialization of a witness record rather than a
bespoke signed blob.
- Carrying and signing a staged envelope adds per-observation size and CPU cost;
bounded by reusing RuField signatures and the existing ledger, and by the
minimum-level rule keeping the object honest rather than exhaustive.
- The chain will frequently reveal weak links (synthetic root, no corroboration,
DEGRADED gate). Surfacing that is the point; the envelope must never smooth a
weak stage into a confident summary.
## Validation
- `cargo test` on the witness-chain crate — stage-by-stage signature round-trip
and tamper rejection (a mutated stage or a broken prior-stage hash fails
verification); effective evidence level equals the minimum across stages; a
`Synthetic` root caps the chain and cannot present as `LiveVerified`
(ADR-295 invariant); an UNKNOWN gate (ADR-302) and a "no corroboration" stage
are recorded faithfully; append-only correction produces a new chain
referencing the prior one.
- Cross-ADR: an ADR-305 signed frame lineage serializes into a chain that
re-verifies offline with only the enrolled public keys; ADR-301/301/314/315
artifacts anchor into the same ledger.
- Real-deployment chains (from live ESP32 captures with ADR-303 corroboration)
are the maturity milestone and require hardware evidence per CLAUDE.md; a
chain rooted in synthetic input is L0 by construction.
Loaded 100 of 961 files, more files were not shown because too many files have changed in this diff. Show more