Commit Graph
1377 Commits
Author SHA1 Message Date
rUv dd02efe2fe Merge pull request #1903 from ruvnet/codex/calibration-process-identity
fix(calibration): bind server receipts to room identity
v2754
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
rUv 45fc41ac4c Merge pull request #1944 from ruvnet/codex/realtek-csi-upstream-main
Add bounded RTL8721Dx RAC1 CSI ingest to current main
v2716 v2715
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)
v2655
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
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>
v2635
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>
v2634 node-diagnostics-and-fusion-fixes-2026-09-09
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>
v2630 v2631
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.
v2629
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
v2624
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
v2614 v2617 multistatic-timing-fixes-2026-09-09 v2609 v2615 v2613 v2612 v2611 v2608 v2610
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