Commit Graph

468 Commits

Author SHA1 Message Date
rUv d283117b15
Merge c9fde3cba5 into df9d3b0eea 2026-05-12 10:09:42 +08:00
ruv df9d3b0eea fix(plugins): move marketplace manifest to repo root for `/plugin marketplace add ruvnet/RuView`
Claude Code looks for `.claude-plugin/marketplace.json` at the cloned repo's
ROOT — not in a subdirectory — so `/plugin marketplace add ruvnet/RuView`
(and `claude plugin marketplace add ruvnet/RuView`) was failing with
"Marketplace file not found".

- Move `plugins/.claude-plugin/marketplace.json` → `.claude-plugin/marketplace.json`
  (repo root); the `ruview` plugin's `source` is now `./plugins/ruview`.
- README.md / plugins/ruview/README.md: install instructions now use
  `/plugin marketplace add ruvnet/RuView` + `/plugin install ruview@ruview`
  (with `claude --plugin-dir ./plugins/ruview` as the no-install fallback);
  manifest path references updated.
- plugins/ruview/scripts/smoke.sh: resolve the manifest at the repo root;
  also assert the plugin `source` is `./plugins/ruview`.
- ADR-0001 updated (scope, directory contract, smoke contract, consequences).

Verified: `claude plugin validate .` + `./plugins/ruview` pass; smoke 13/13;
`claude plugin marketplace add ./` → `claude plugin install ruview@ruview` →
`claude plugin details ruview` works end-to-end (16 skill-entries + 3 agents).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 19:52:04 -04:00
ruv 298543913e docs(readme): add Claude Code / Codex plugin + marketplace install instructions
New "🧩 Claude Code & Codex Plugin" section in README.md covering
`claude --plugin-dir`, `claude plugin marketplace add` / `install`, the seven
/ruview-* commands, the Codex prompt mirror, and the smoke check; plus a
Documentation-table row linking to plugins/ruview/README.md.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 19:02:05 -04:00
ruv 8ff7c2c35a feat(plugins): RuView Claude Code + Codex marketplace plugin
Add `plugins/ruview` — an end-to-end toolkit for working with RuView
(WiFi-DensePose) from Claude Code, mirrored as Codex prompts.

Marketplace: `plugins/.claude-plugin/marketplace.json` (one plugin, `ruview`).

Skills (9): ruview-quickstart, ruview-hardware-setup, ruview-configure,
ruview-applications, ruview-model-training, ruview-advanced-sensing,
ruview-cli-api, ruview-mmwave, ruview-verify — shell-first (cargo / python /
idf.py / docker / node), no claude-flow MCP dependency.

Commands (7): /ruview-start, /ruview-flash, /ruview-provision, /ruview-app,
/ruview-train, /ruview-advanced, /ruview-verify.

Agents (3): ruview-onboarding-guide, ruview-config-engineer,
ruview-training-engineer.

Codex mirror: codex/AGENTS.md + codex/README.md + codex/prompts/*.md (full
command parity, enforced by scripts/smoke.sh).

Docs: docs/adrs/0001-ruview-plugin-contract.md (Proposed). Verification:
scripts/smoke.sh (13 structural checks). Provisioning docs reflect the full
`provision.py` flag set (TDM mesh, edge tiers, vitals, hop channels, Cognitum
Seed, swarm intervals) and the issue #391 NVS-namespace-replace gotcha.

Verified: `claude plugin validate` (plugin + marketplace), loads via
`claude --plugin-dir`, smoke 13/13, and confirmed against an attached ESP32-S3
on COM8 running the RuView CSI firmware (live adaptive_ctrl + csi_collector
serial output).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 17:39:16 -04:00
rUv 19ee207d51
Merge pull request #528 from ruvnet/fix/update-submodules-workflow
ci: fix "Update vendor submodules" workflow (git identity + drop --merge)
2026-05-11 12:34:20 -04:00
ruv 8aa7fb9e9f ci: fix "Update vendor submodules" workflow (identity + drop --merge)
The scheduled job has been failing on every run with:

    fatal: empty ident name (...) not allowed
    fatal: Unable to merge '...' in submodule path 'vendor/ruvector'

Two bugs:
1. `git config user.name/email` was only set inside the "Create PR" step,
   but `git submodule update --remote --merge` runs first and the merge
   inside vendor/ruvector needs a committer when the pinned commit isn't a
   fast-forward of upstream `main` → "Committer identity unknown".
2. `--merge` is the wrong operation here. We only want to bump the
   superproject's gitlink to the latest upstream commit on each submodule's
   tracked branch — there's no reason to create merge commits inside the
   vendored repos, and `--merge` breaks whenever the current pin has diverged.

Fix:
- Add a "Configure git identity" step before any commit-creating operation.
- Replace `git submodule update --remote --merge` with
  `git submodule sync --recursive && git submodule update --remote --recursive`
  (detached checkout at each `.gitmodules` branch tip).
- Log the pointer diff in the "Check for changes" step for reviewability.
- Tidy the PR-creation step (identity now set globally; clearer commit/PR text).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 12:33:40 -04:00
rUv f2e3a6a392
Merge pull request #526 from ruvnet/fix/esp32-issues-505-517-521
fix: ESP32 CSI 0pps (#521), aggregator sibling magics (#517), version.txt (#505) + fix-marker CI guard
2026-05-11 11:40:36 -04:00
ruv eda45a6857 ci: fix-marker regression guard (witness-style)
Adds a fast per-PR gate that asserts previously-shipped fixes are still
present in the tree — the CI analogue of the ruflo witness fix-marker
system, but self-contained (no plugin dependency, reviewable as plain
JSON). Complements the heavier checks (firmware build, deterministic
pipeline proof, release witness bundle) by catching the silent-revert
class of regression that build+test wouldn't.

  - scripts/fix-markers.json   manifest: 11 markers (RuView#396, #521,
    #517, #505, #354, #263, #266/#321, #265, #232/#375/#385/#386/#390,
    ADR-028 proof + witness bundle). Each has files / require (literal
    substring or /regex/) / optional forbid / rationale / ref.
  - scripts/check_fix_markers.py  stdlib-only checker. Exit 0 clean /
    1 regression / 2 bad manifest. Modes: --list, --json, --only ID.
  - .github/workflows/fix-regression-guard.yml  runs on PR + push to
    main/master; gates on the checker and writes the result table into
    the run summary + an artifact.

If a fix is intentionally removed, update scripts/fix-markers.json in the
same PR with a rationale — the diff becomes the audit trail.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:48:14 -04:00
ruv a1cb6bd8e5 fix(firmware): bump version.txt to 0.6.4 + CI guard for tag/version match (#505)
version.txt on main was still 0.6.2. CMake reads PROJECT_VER from it, so
esp_app_get_description()->version (and the boot log line) reported 0.6.2
for any source build — and v0.6.3-esp32 shipped a release binary that
internally identified as 0.6.2 because the bump never landed on main.

  - version.txt: 0.6.2 -> 0.6.4 (matches the latest release tag)
  - firmware-ci.yml: new `version-guard` job that runs on v*-esp32 tag
    pushes and fails the run if the tag's X.Y.Z != version.txt, so a
    future release can't ship a mislabeled binary.

Closes #505

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:48:14 -04:00
ruv 4d0521ca08 fix(hardware): aggregator tolerates sibling RuView UDP packet magics (#517)
The ESP32 firmware multiplexes several wire packet types onto the same
UDP port as ADR-018 raw CSI frames (magic 0xC5110001):

  0xC5110002  ADR-039 edge vitals (32 B)
  0xC5110003  ADR-069 feature vector
  0xC5110004  ADR-063 fused vitals
  0xC5110005  ADR-039 compressed CSI
  0xC5110006  ADR-081 feature state
  0xC5110007  ADR-095/#513 temporal classification

Esp32CsiParser only knew 0xC5110001, so the standalone `aggregator`
binary printed "parse error: Invalid magic: expected 0xc5110001, got
0xc5110002" for every vitals packet. No CSI data was lost — just noise.

Add the sibling-magic constants + ruview_sibling_packet_name(), classify
recognized siblings before the CSI-frame length gate, and return a new
ParseError::NonCsiPacket { magic, kind } instead of InvalidMagic. The
`aggregator` CLI now skips them quietly (logs "[skipped ADR-039 edge
vitals packet — not a CSI frame]" only with --verbose); the library-level
CsiAggregator already dropped them silently. New regression tests cover
all seven magics.

Closes #517

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:48:00 -04:00
ruv 3f55c95b34 fix(esp32): disable WiFi modem sleep so CSI capture isn't starved (#521)
csi_collector_init() never called esp_wifi_set_ps(), leaving the radio on
the ESP-IDF STA default WIFI_PS_MIN_MODEM. The modem then sleeps between
DTIM beacons; combined with the MGMT-only promiscuous filter (#396) the
CSI callback is starved and the per-second yield collapses toward 0 pps,
which is what users on a clean multi-node setup were seeing
(motion=0.00 presence=0.00 yield=0pps).

Force WIFI_PS_NONE before enabling promiscuous mode — the textbook
requirement for reliable CSI capture (every ESP-IDF CSI example does it).
New boot line: "csi_collector: WiFi modem sleep disabled (WIFI_PS_NONE)
for CSI capture". Battery duty-cycling is unaffected: power_mgmt_init()
runs after this and re-enables modem sleep when provision.py is given
--duty-cycle <100.

Builds clean for esp32s3 (idf.py build, 48% flash free).

Closes #521

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:47:48 -04:00
ruv c9fde3cba5 feat(train): AetherTemporalAggregator — wire wifi-densepose-temporal into the tch graph (#513)
ADR-096 train integration. Additive — does NOT modify model.rs. The
existing WiFiDensePoseModel forward stays bit-equivalent for back-compat.
New code lives in temporal_aether.rs behind the `aether-sparse-temporal`
feature flag (which itself requires `tch-backend`).

Architecture:

    tch::Tensor [T, in_dim]   ──── tch nn::Linear (q/k/v projections)
                                    ↓
                              [T, q_heads*head_dim] etc
                                    ↓
                             tch_to_tensor3 (CPU, f32, 1× copy)
                                    ↓
                              ruvllm_sparse_attention::Tensor3
                                    ↓
                            AetherTemporalHead::forward()
                                    ↓
                              Tensor3 [T, q_heads, head_dim]
                                    ↓
                             tensor3_to_tch (1× copy)
                                    ↓
                              tch::Tensor [T, q_heads*head_dim]
                                    ↓
                              tch nn::Linear (output projection)
                                    ↓
                              tch::Tensor [T, in_dim]

Why additive rather than swapping `apply_antenna_attention` /
`apply_spatial_attention` in model.rs: those are over antenna and
spatial axes, not temporal — ADR-096 §8.1 was right that AETHER
doesn't currently HAVE a temporal-axis attention. This commit adds
that path without disturbing the others, so the §5 validation gate
can A/B the two options before flipping the production default.

Scope notes:
- B=1 prefill only this version. Multi-batch lands when §5 turns
  green and we need to take perf seriously. The forward expects
  `[T, in_dim]` not `[B, T, in_dim]`; documented in the file.
- Streaming step() bridge deferred — KvCache lifecycle ties to
  PoseTrack per ADR-096 §8.5, which is signal-side not train-side.
- Two CPU memory copies per call (in + out). For training-rate
  forwards (~100/sec at batch 16) this is negligible vs the actual
  attention work; for inference-rate streaming it'd be the
  bottleneck and a zero-copy path is the natural follow-up.

Build verification:
- Source compiles cleanly with cargo check on the host crate
  (`-p wifi-densepose-temporal`, 21/21 tests still passing).
- The train crate's tch-backend build is environmentally blocked
  on this Windows machine — torch-sys fails to link against the
  system PyTorch 2.11 + MSVC 14.50 toolchain. This predates this
  commit and affects all tch-bound code paths in the workspace.
  CI runners with working libtorch will verify the new module
  builds; the source follows the same nn::Linear / Module patterns
  the existing model.rs uses.

Feature gating ensures default builds are byte-equivalent. Off by
default; enable with `--features aether-sparse-temporal`.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 12:42:41 -04:00
ruv 2b903752c4 test(temporal): dense-vs-sparse numerical A/B baseline (ADR-096 §5, #513)
Establishes the kernel-level output-divergence envelope between the
two backends — what §5's downstream-metric gate (contrastive loss,
rank-1, Spearman) would calibrate against. Two regimes:

1. Saturated pattern (window ≥ N, block ≥ N): sparse and dense visit
   the same edge set, so divergence reflects only float accumulation
   order. **Asserted < 1e-4** at N=32, heads=4, dim=16. Tight bound.

2. Realistic sparse (window=16, block=32, N=256): real approximation,
   real divergence. **Measured max_abs_err = 5.22e-3, mean = 1.79e-3**
   on the deterministic test inputs. Sanity-checked finite + < 1.0
   so structural breakage (NaN, softmax overflow) trips a panic, but
   the specific numbers are *baseline data* not a hard contract — the
   §5 gate cares about downstream task metrics, not bit-equality.

Why this is in the test suite rather than a benchmark:
- It runs in <0.2s, no need to gate behind --release.
- The saturated-pattern bound IS a hard contract — if that breaks
  the kernel changed semantics in a way the API hides, and we want
  CI to catch it.
- Printing the realistic-pattern numbers (eprintln, visible with
  --nocapture) gives a known-good reference point to compare future
  builds against.

Test count is now 21/21 across the crate (6 smoke + 8 weight blob +
2 blob e2e + 3 streaming + 2 dense-vs-sparse).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 12:16:05 -04:00
ruv 4ea8457017 feat(temporal): Dense backend implementation (ADR-096 §5 A/B gate, #513)
Closes the Dense placeholder from earlier commits. Now both backends
implement forward(); only SparseGqa supports streaming step()/KvCache,
which is the structural gap dense MHA can't bridge by design.

Dense path:
- src/dense.rs new — DenseHead wraps upstream dense_attention. Stores
  causal flag and (cloned) config. forward() is a one-line delegation;
  no GQA dispatch (dense_attention upstream requires q_heads == kv_heads).
- AetherTemporalHead::Dense changed from a unit variant to Dense(DenseHead).
  Construction succeeds for any valid TemporalHeadConfig where backend
  is Dense.
- AetherTemporalHead.step() returns BackendDoesNotSupportStreaming for
  Dense — there is no dense-MHA-with-KV-cache equivalent and offering
  one would silently swallow the ADR-096 §3.2 structural argument.
- AetherTemporalHead.make_cache() likewise — there's no cache to size
  for a dense kernel.

Errors:
- New TemporalError::BackendDoesNotSupportStreaming variant covers
  the Dense-step / Dense-make_cache cases. Specific so callers can
  fall back to forward() instead of giving up entirely.
- TemporalError::DenseBackendNotImplemented retained for v0.1
  back-compat (no consumers depend on it post-this-commit, but
  removing a public variant is a hard break). Future work can
  deprecate it once downstream callers move off.

Tests (19/19 passing):
- dense_backend_returns_typed_error → renamed and rewritten as
  dense_backend_forward_runs_with_matching_shape: constructs a Dense
  head, runs forward over (32, 4, 4, 16) Q/K/V, asserts output shape.
- New dense_backend_step_returns_streaming_error: constructs Dense,
  attempts make_cache, expects BackendDoesNotSupportStreaming.
- All 8 weight blob, 2 blob e2e, 3 streaming, 5 other smoke tests
  unchanged and still passing.

This commit completes the ADR-096 §5 A/B gate: callers can now run
the same Q/K/V through both backends and compare outputs / latency.
The §5 four-gate validation (contrastive loss within 1%, rank-1
within 1pp, Spearman ≥0.95, latency ≥5×) becomes a runnable
proposition, not a future task — though the actual gate run requires
trained AETHER weights, which is its own track.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 12:12:17 -04:00
ruv 2aee4d21cf docs(temporal): README for wifi-densepose-temporal (#513)
Closes the documentation gap on the host-side ADR-096 surface.
The crate has 7 commits, 5 source modules, 4 test suites, 2 examples,
and a captured benchmark; reviewers and downstream consumers needed
a landing page.

Sections:
- Quick start (5-line forward + 7-line streaming)
- Backends + selection rule (SparseGqa MHA-vs-GQA dispatch)
- Streaming semantics (cache lifetime, eviction policy, the
  headline correctness test)
- Weight blob format with the host/firmware lockstep note
- Examples (init_random_blob, bench_speedup) with run lines
- Tests (18/18 passing as of 247794a2c, broken down by suite)
- Status of ADR-096 claims with concrete evidence for each
- Status of ADR-095 surface (firmware) + the toolchain blocker
- Carry-forward of the open questions still applicable from §8

The README intentionally cross-links to:
- docs/adr/ADR-096 for design rationale
- components/ruv_temporal/ README for the firmware mirror
- benches_results.md for the captured speedup curve

Doesn't claim more than is proven. Each ADR-096 claim either has a
test or a benchmark cited as evidence; the partial claim (30-100× at
long windows) explicitly says 21× was the measured number, not 30×.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 12:06:26 -04:00
ruv 247794a2c5 bench(temporal): empirical sparse-vs-dense speedup curve (ADR-096 §3.1, #513)
Validates the central performance claim of ADR-096 with a runnable
benchmark. Single-run wall-clock, pure-Rust vs pure-Rust on x86_64
host. Real numbers, not just analytic argument.

Results (N=64..1024):

| N      | Dense (ms) | Sparse (ms) | Speedup |
|--------|-----------:|------------:|--------:|
|     64 |      0.262 |       0.141 |   1.86× |
|    128 |      1.120 |       0.335 |   3.34× |
|    256 |      4.129 |       0.711 |   5.81× |
|    512 |     19.230 |       2.356 |   8.16× |
|   1024 |     71.904 |       3.389 |  21.21× |

Asymptotic check: 64→1024 is 16× more tokens. Dense's 274× cost
growth matches N² (256× = 16²). Sparse's 24× growth matches
N log N (16 · log(1024)/log(64) ≈ 27). The complexity claim is
empirically supported.

ADR-096 §3.1 honest-framing paragraph predicted N=64 would be
overhead-bound; we measured 1.86× there, consistent with the ADR's
warning that AETHER's current `window_frames=100` default is below
the inflection point where sparse pays.

What this commit adds:
- examples/bench_speedup.rs — measures dense_attention (upstream
  reference), AetherTemporalHead.forward (this crate's wrapper),
  and SubquadraticSparseAttention.forward (raw, to confirm the
  wrapper isn't introducing overhead — it isn't, the two are
  within noise).
- benches_results.md — captured table + asymptotic check + caveats
  (config used, what the benchmark doesn't measure, how to run).

Run it:
  cargo run -p wifi-densepose-temporal --example bench_speedup --release

What's NOT measured here:
- Decode-step latency (already proved correct at last-token, not
  yet timed against a hypothetical O(N²) dense decode — they're
  structurally not comparable anyway).
- Memory footprint of KvCache + FP16 (matters on firmware, not host).
- GQA dispatch — this bench uses MHA shape so dense and sparse
  operate on identical tensors. Real AETHER will want MQA per
  TemporalHeadConfig::default_aether(), which halves KV memory.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 12:02:36 -04:00
ruv 49e57efcec feat(temporal): streaming step() + KvCache (ADR-096 §3.2, #513)
The structural advantage that's the entire point of ADR-096: O(log T)
per new token via decode_step against an accumulated KvCache, vs
O(N²) recompute for dense MHA. This commit lands the API and proves
the numerical equivalence at the last position.

API:
- AetherTemporalHead::step(q_new, k_new, v_new, &mut cache)
  Single-token decode. Appends (k_new, v_new) to cache, runs
  decode_step(q_new) against the now-updated cache, returns the new
  position's output.
- AetherTemporalHead::make_cache(capacity)
  Convenience constructor — caller doesn't need to import
  ruvllm_sparse_attention to size a cache. Per ADR-096 §8.5 the
  natural lifetime is per-PoseTrack (re-ID) or per-session (online
  classification); when the track drops, drop the cache.
- KvCache re-exported at the crate root.

Contract:
- q_new/k_new/v_new must each have seq == 1. Multi-token q is the
  prefill path (forward), not decode_step.
- Cache lifetime is the caller's. The crate enforces shape via
  make_cache so callers can't mismatch kv_heads / head_dim / block_size.
- KvCache fill is the caller's problem. Upstream H2O heavy-hitter
  eviction is opt-in; this crate's wrapper doesn't pre-pick a policy.

Tests (18/18 total now passing):
- streaming_step_matches_forward_at_last_position — central claim:
  16-token sequence, append k/v one at a time via step(), compare
  the streamed last-token output to forward(full Q,K,V)[N-1].
  max_abs_err < 1e-3 (currently passes well under that bound for
  the 0.1-magnitude activations the test uses).
- step_rejects_multi_token_q — contract enforcement.
- make_cache_returns_kvcache_with_correct_shape — wiring smoke,
  confirms (capacity, kv_heads, dim, block_size) ordering is correct
  through the make_cache wrapper.

Test config uses MHA shape (q_heads == kv_heads) because the upstream
decode_step is wired to the MHA branch; the GQA decode path is on
upstream's roadmap and lands in a separate ADR-096 follow-up when it
does.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 11:57:31 -04:00
ruv 3a5fe5e0de feat(firmware): mirror weight-blob parser into ruv_temporal (#513)
Closes the format contract on the firmware side. Source-only — Phase 5
toolchain blocker still prevents actually compiling, but when it
unblocks this is one less thing to write under time pressure.

- src/weights.rs — no_std mirror of v2/.../weights.rs. Same magic
  ('RVNE'), same version 1, same CRC32-IEEE polynomial (matches the C
  side in temporal_task.c). Bit-for-bit lockstep with the host: a
  blob produced by host WeightBlob::serialize() parses here as a
  WeightBlobView byte-for-byte.

  Borrowed-slice parse design: the firmware loader receives weights
  via mmap'd EMBED_FILES or NVS read into a heap buffer. The parser
  takes &[u8] with no copy — view fields point into the caller's
  buffer. Caller is responsible for keeping the buffer alive for the
  view's lifetime.

  Loader errors map to esp_err_t-style codes via
  weight_load_err_to_esp() so the C ABI can surface specific failure
  modes (ESP_ERR_INVALID_ARG for magic/version/size, ESP_ERR_INVALID_CRC
  for corruption, ESP_ERR_INVALID_SIZE for shape validation failures).

- src/lib.rs — ruv_temporal_init now optionally validates a non-NULL
  weights blob. NULL pointer is still allowed during the Phase 4/5
  bring-up window (kernel forward isn't actually consuming weights
  yet), but when caller passes a real blob we parse + sanity-check
  declared dims against runtime arguments. Catches deploy bugs at
  init() rather than at first classify() — the firmware Tmr Svc work
  in v0.6.4 taught us that classify-time crashes are the worst kind.

- README.md — Phase 6 marked done (verified by 8MB firmware build with
  feature off in commit 7994af822). Added module map table covering
  lib.rs / window.rs / weights.rs / ruv_temporal.h / shim.c.

What's deliberately NOT in this commit:
  - Cross-compile validation. Same toolchain blocker as before.
  - Kernel-side wiring of weights into the forward pass. That's
    Phase 6+ of the firmware roadmap — once the kernel is wired,
    weights become a required arg, not an optional one.
  - Tests on the firmware side. They'd need build-std working to run;
    16/16 host tests cover the format end-to-end via the lockstep
    polynomial.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 11:53:19 -04:00
ruv 73321db765 feat(temporal): init_random_blob example + filesystem e2e tests (#513)
Closes the host→file→firmware loop on the Phase 1 weight format. Real
.rvne artifact emitted from the example, parsed back through filesystem
in the e2e test, byte-identical across two seeded runs.

- examples/init_random_blob.rs — produces a 41,244-byte deployable blob
  matching the AETHER default head shape (input_dim=16, q_heads=4,
  kv_heads=1 [MQA], head_dim=32, layers=2, classes=4 — staying coherent
  with TemporalHeadConfig::default_aether so a real trainer can drop
  in this shape with one search-and-replace). Uses xorshift64* with a
  fixed seed (0xC511_0007_DEAD_BEEF) for reproducibility.

  Per-layer weight count derivation lives in the example (Wq + Wk +
  Wv + Wo, plus a final classifier head) so the kernel's expectation
  is anchored in code rather than a comment that drifts.

- tests/blob_e2e.rs — two new tests, 15/15 total now passing:
    * realistic_blob_roundtrips_through_filesystem — writes a 25+ KB
      blob to std::env::temp_dir(), reads it back, parses, validates.
      Mirrors what the firmware loader will do once the toolchain
      unblocks (mmap NVS or EMBED_FILES → parse).
    * deterministic_seed_produces_byte_identical_blobs — same seed
      produces byte-identical output, twice. This is what makes a
      witness-bundle (ADR-028) over trained weights meaningful.

Verified by running the example with an explicit out path:
  cargo run -p wifi-densepose-temporal --example init_random_blob -- \
      v2/target/example-output/model_init.rvne
  → 41244 bytes, parses clean, dtype/shape/CRC all good.

What this isn't yet:
  - Not a trained model. Random init only.
  - Not a kernel forward over the blob. That requires the firmware
    Rust component to compile (Phase 5 — toolchain blocker).
  - Not wired into wifi-densepose-train. ADR-096 §8.1 flagged that
    the AETHER train crate doesn't currently have a temporal-axis
    attention; that integration is a separate piece of work.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 11:49:19 -04:00
ruv 237325a117 feat(temporal): weight-blob wire format (ADR-095 Phase 1, #513)
The training/firmware boundary needs a stable serialization for the
temporal head's weights, distinct from the kernel scaffold and the
firmware ABI. This commit defines that format on the host side. The
firmware-side mirrored loader lands when the toolchain unblocks.

Format:
  - Header (24 B): magic 'RVNE' / version 1 / dtype flag
    (FP32 / FP16) / input_dim / n_q_heads / n_kv_heads / head_dim /
    n_layers / n_classes / weights_len.
  - Body: weights_len bytes of flat per-layer weights.
  - Footer (4 B): CRC32 IEEE 802.3 over everything before, same
    polynomial used by temporal_task.c so a blob produced here parses
    on the firmware unchanged.

Layout decisions:
  - Little-endian throughout (Xtensa native).
  - Weights kept as Vec<u8> rather than Vec<f32>/Vec<f16> so the no_std
    firmware loader (which may not have the `half` crate) can mmap and
    read either dtype directly.
  - Versioning is hard-break: bumping `version` means firmware refuses
    to load. Optional fields go behind reserved flag bits, never by
    field reorder. Documented inline.

Validation surface:
  - `WeightBlobHeader::validate()` catches zero dims, invalid GQA
    ratios (n_q_heads % n_kv_heads != 0), n_layers=0, n_classes<2.
    Same checks fire from `WeightBlob::parse()` so the firmware can't
    accidentally accept a blob the host should have rejected.
  - `WeightBlob::parse()` enforces magic / version / size / CRC
    before exposing weights to the caller.

Tests (8/8 passing, alongside 5/5 sparse smoke = 13/13 total):
  - roundtrip_fp32, roundtrip_fp16
  - parse_rejects_bad_magic, _wrong_version, _size_mismatch,
    _crc_corruption, _invalid_gqa_ratio_in_header
  - header_constants_match_wire_layout (anchor)

What's deliberately NOT in this commit:
  - The firmware-side mirrored loader (deferred to the iteration that
    unblocks the esp Rust toolchain — no point shipping a parser that
    can't be compiled).
  - Per-layer weight ordering. The blob is a flat byte-buffer; the
    interpretation of per-layer offsets is the kernel's contract,
    documented in the eventual model module (ADR-095 §3.2 follow-up).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 11:43:49 -04:00
ruv 7994af8221 feat(firmware): wire temporal_task.c + Kconfig + ruv_temporal component (Phase 6, #513)
Phase 6 of #513: C-side wiring for the on-device temporal head. Builds
cleanly with feature OFF (default); 8MB binary delta is +96 bytes vs
v0.6.4-esp32 — that's the no-op shim path. Feature ON depends on the
Rust component (Phase 5, currently blocked by upstream esp-rs nightly).

Files:

- main/temporal_task.{c,h} — owns the FreeRTOS task lifecycle. Per
  ADR-095 §3.3 the task has its own 16 KB stack pinned to Core 1 and
  is fed via a 32-deep FreeRTOS queue. With feature OFF the .c file
  collapses to three ESP_ERR_NOT_SUPPORTED stubs so callers don't
  need #ifdefs at every call site.
- main/temporal_task.h — defines rv_temporal_pkt_t (40 bytes,
  magic 0xC5110007 — next free in the existing 0xC5110001..0006
  family) and the task lifecycle API. Build-time _Static_assert
  pins the wire format.
- main/Kconfig.projbuild — new menu "On-device temporal head
  (ADR-095, #513)" with CONFIG_CSI_TEMPORAL_HEAD_ENABLED (default n)
  plus four runtime-tuneable knobs: TEMPORAL_INPUT_DIM (16),
  TEMPORAL_WINDOW_LEN (256), TEMPORAL_N_CLASSES (4), and
  TEMPORAL_CLASSIFY_PERIOD_MS (1000).
- main/CMakeLists.txt — adds temporal_task.c to SRCS unconditionally
  (the .c file feature-gates internally), and adds ruv_temporal to
  REQUIRES only when the feature is enabled so default builds don't
  pull in the Rust component.
- main/adaptive_controller.c — fast_loop_cb now extracts the 9
  feature floats from the pkt it just built and pushes them into
  temporal_task_push_frame after the existing stream_sender_send.
  Non-blocking; queue-full drops are coalesced and logged 1/sec.
- main/main.c — temporal_task_start() called right after
  adaptive_controller_init(). Wrapped in #ifdef so feature-off
  builds don't reference the (no-op-anyway) function.
- components/ruv_temporal/CMakeLists.txt — restructured. Top-level
  Kconfig guard registers an empty component when the feature is
  off (avoids running cargo without a working toolchain).
  add_custom_command moved AFTER idf_component_register so it
  doesn't fire in script mode (required by ESP-IDF v5.4).

Validation:
- Firmware builds clean with default config (feature OFF) on
  ESP-IDF v5.4 / esp32s3 target. Binary 1062 KiB / 2 MiB partition,
  48 % free.
- Static assertion catches wire-format drift (rv_temporal_pkt_t size).
- Host-side `cargo test -p wifi-densepose-temporal` still 5/5 from
  the earlier commit (no regression, this commit only touches
  firmware/).

Phase 7 (flash to COM8 + soak) deferred this iteration — board is
currently not enumerating on COM8; will pick up next iteration when
the ESP32 is reattached.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 11:28:11 -04:00
ruv 22d47a71e3 feat(firmware): scaffold ruv_temporal ESP-IDF Rust component (ADR-095 Phase 4, #513)
Phase 4 of the #513 roadmap: ESP-IDF component skeleton at
`firmware/esp32-csi-node/components/ruv_temporal/`. Source is complete
and self-consistent; cross-compile to xtensa-esp32s3-none-elf is
blocked by a known-broken esp-rs nightly snapshot (details in the
component README).

What's in the scaffold:

- `Cargo.toml` — staticlib, no_std + alloc, deps on the path-vendored
  `ruvllm_sparse_attention` (matching ADR-096's host-side dep) and
  `esp-alloc`/`critical-section` for the no_std allocator and lock
  primitives.
- `src/lib.rs` — public C ABI (init / push / classify / destroy /
  self_test) with `#[no_mangle]` exports, a `[#used]` keepalive table
  to defeat aggressive linker stripping, esp-alloc as the global
  allocator (heap region added at runtime by the firmware), and a
  loop-on-panic handler (Phase 5 will route through esp_system_abort).
- `src/window.rs` — `FrameRing`, the rolling-window buffer that
  `ruv_temporal_push` writes to. Chronological iteration via
  `iter_chronological()` so the kernel sees oldest-first.
- `include/ruv_temporal.h` — the public C header consumed by
  edge_processing.c. Threading contract documented inline (single
  dedicated FreeRTOS task, no internal locks).
- `CMakeLists.txt` — runs `cargo +esp build` as an ESP-IDF
  pre-component-register step, then registers the static library
  through `idf_component_register` + `target_link_libraries(...
  INTERFACE ...)`. `shim.c` exists only because
  `idf_component_register` requires SRCS.
- `.cargo/config.toml` + `rust-toolchain.toml` — pin the build to
  `xtensa-esp32s3-none-elf` and the `esp` toolchain channel so
  `cargo build` without flags Just Works once the toolchain is
  unblocked.
- `README.md` — Phase status table, Phase 5 toolchain blocker
  explanation, and the espup install fix.

ABI calls into edge_processing.c (Phase 6) and COM8 validation
(Phase 7) follow once the cross-compile is unblocked.

Closes nothing yet; advances #513.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 09:44:01 -04:00
ruv bfb3fdee13 feat(temporal): scaffold wifi-densepose-temporal crate (ADR-096 Phase 1-3, #513)
Implements Phases 1-3 of the ADR-096 roadmap:

Phase 1: workspace integration
- Add `ruvllm_sparse_attention` as a path-vendored workspace dep against
  `vendor/ruvector/crates/ruvllm_sparse_attention`, default-features=false,
  features=["fp16"]. Mirrors the no_std posture ADR-095 will need on the
  firmware side so both consumers share a single feature set.
- Register `wifi-densepose-temporal` as workspace member.

Phase 2: AETHER temporal head
- `AetherTemporalHead` facade dispatches to a `SparseGqa` backend wrapping
  `SubquadraticSparseAttention`. Selection rule from ADR-096 §4.4 enforced
  at forward(): MHA branch when q_heads == kv_heads, GQA branch otherwise.
- `Dense` backend reserved (returns typed `DenseBackendNotImplemented`)
  so config-time validation fails loudly instead of at forward().
- `TemporalHeadConfig::default_aether()` matches the AETHER training
  default per ADR-096 §3.1 (window=32, block=16, q=4, kv=1 → MQA).
- Token 0 always wired as a global anchor — preserves AETHER's
  contrastive "session-start reference" role per ADR-024.

Phase 3: smoke tests (5/5 passing)
- forward at AETHER default config, both MHA and GQA dispatch paths,
  rejected dense backend, rejected non-divisible GQA ratio, and the
  long-window roadmap target (N=1000, the 10s @ 100Hz case from
  ADR-096 §3.1 — proves the kernel runs at lengths where dense MHA
  costs 10⁶ edge ops vs sparse 10⁴).

Streaming `step()` deferred — KvCache lifecycle ties to PoseTrack per
ADR-096 §8.5 and lands when the firmware-side ABI does (Phase 4+).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 09:26:18 -04:00
ruv 684ef4f1a5 docs(adr): ADR-095/096 — sparse attention on ESP32 + AETHER GQA head (#513)
Two Proposed ADRs covering the integration of vendored
ruvllm_sparse_attention v0.1.1 (released 2026-05-07, no_std + alloc
validated on real ESP32-S3 per upstream ADR-192).

* ADR-095 — adds a learned temporal head to the ESP32-S3 firmware
  via a Rust component compiled --no-default-features against the
  376 KB rlib. Runs alongside the existing physics-only DSP, gated
  behind a Kconfig (8 MB only initially). Use cases: gesture
  recognition, fall classification with sequence context,
  breathing-quality scoring, on-device anomaly detection. Builds
  on ADR-018, ADR-039, ADR-081.

* ADR-096 — adopts forward_gqa + KvCache for the AETHER (ADR-024)
  contrastive CSI embedding's temporal aggregation. Path-vendored
  workspace dep, A/B gate before flipping the inference default.
  ~30-100x speedup at long windows; streaming decode goes from
  O(N^2) recompute to O(log T) per new frame.

Refs #513
2026-05-07 15:14:38 -04:00
rUv e7904786f0
Update README.md
Added Spatial Intelligence to readme, since that seems to be a common description
2026-05-03 11:48:12 -04:00
ruv 9a078e4ac8 fix(pointcloud): exponential backoff on unreachable backend + status banner
When ?backend=<url> pointed at a server that wasn't running (e.g. user
forgot to start ruview-pointcloud serve before clicking Connect ESP32),
the viewer was retrying 10 Hz forever — flooding the console with
ERR_CONNECTION_REFUSED and offering no guidance about what was wrong.

Two fixes:

1. Replace setInterval(fetchCloud, 100) with self-rescheduling
   setTimeout. On success: 250 ms steady cadence. On failure for an
   explicit backend: 250 ms → 500 → 1 s → 2 s → 4 s → 8 s → 16 s →
   capped at 30 s. Resets to 250 ms the moment the backend comes back.
   Auto mode (Pages with no backend) still disables network entirely
   after the first 404. Strict-live mode (?live=1) also backs off so
   it doesn't spam.

2. Show an actionable status banner in the info panel when the chosen
   backend is unreachable: the URL, the actual error string, the next
   retry time, and the exact `cargo run` command to start the server.
   Visitor sees the diagnosis instead of staring at a 'demo' badge
   wondering why their ESP32 feed isn't visible.

The scene keeps animating (face mesh / synthetic) while the viewer
waits, so the tab never goes blank.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 23:03:05 -04:00
ruv 0e39faac73 feat(pointcloud): overlay browser face mesh on top of ESP32 backend feed
Lets the visitor enable their browser webcam face mesh in addition to
(not instead of) a connected ESP32 backend. Both render in the same
Three.js scene — the live ESP32-driven splats from /api/splats plus the
visitor's own face as a 478-vertex MediaPipe point cloud. Use cases:

- Local development: see your face overlaid on the camera+CSI fusion
  output to debug coordinate-frame alignment.
- Demos: show 'this is the room as ESP32 sees it, and this is me as
  MediaPipe sees me' side-by-side in one scene.

Implementation:
- Extract pushFaceSplats(splats) — pushes the 478 face vertices plus
  ~8000 edge-interpolated samples into the array, with no Foundation
  context. Reused by faceMeshFrame (demo path) and handleData (overlay
  path) so there is one source of truth for face-splat geometry.
- handleData now appends pushFaceSplats output to data.splats when the
  source is not 'face-mesh' AND the user has clicked the camera CTA.
  Sets data._faceOverlay so the badge can show '+ face overlay'.
- Camera CTA is no longer hidden in remote/live modes — it relabels to
  '▶ Add face overlay' so the affordance is clear. Strict-live mode
  (?live=1) still hides it because the offline panel takes over.
- Splat count in the info panel reflects the rendered total (backend +
  overlay) when the overlay is active.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 20:37:36 -04:00
ruv ad41a89960 feat(pointcloud): integrate ESP32 CSI as optional data stream from hosted viewer
The hosted GitHub Pages viewer can now act as a thin client for a
locally-running ruview-pointcloud serve instance — flip a button, the
ESP32's CSI fusion (camera depth + WiFi CSI + mmWave) renders inside
the same Three.js scene that previously only showed the face mesh
demo. No clone, no rebuild, no toolchain on the visitor's side.

Server (stream.rs):
- Add tower_http::cors::CorsLayer with a deliberate allowlist:
  https://ruvnet.github.io, http://localhost:*, http://127.0.0.1:*,
  and 'null' (for file:// origins). Anything else is denied — not a
  wildcard CORS. Modern browsers (Chrome 94+, Firefox 116+, Safari
  16.4+) treat 127.0.0.1 as a "potentially trustworthy" origin so
  HTTPS Pages → HTTP loopback is permitted. The new layer wraps the
  existing /api/cloud, /api/splats, /api/status, /health routes.
- Cargo.toml: pull in workspace tower-http (cors feature already on).

Viewer:
- New "📡 Connect ESP32…" CTA bottom-right. Clicking prompts for a
  ruview-pointcloud serve URL (default http://127.0.0.1:9880),
  persists the last-used value in localStorage, and reloads with
  ?backend=<url> so the existing remote-mode fetch path takes over.
  When already connected the button toggles to "disconnect" and
  reloads back to the demo.
- Reuses the existing transport selector — no new code path to
  maintain. The face mesh / synthetic demo render path is unaffected;
  this is purely an additive UI affordance over the ?backend= query.

Docs:
- ADR-094 §2.3 expanded with the local-ESP32 workflow and the CORS
  posture rationale.
- Workflow README documents ?backend=http://127.0.0.1:9880 as the
  intended local-ESP32 path.

Tests: cargo test -p wifi-densepose-pointcloud → 15/15 passed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 20:33:00 -04:00
ruv e3021c777c chore(pointcloud): inline amber-dot favicon to silence /favicon.ico 404
Browsers auto-request /favicon.ico when none is declared in <head>.
On a static GitHub Pages host that's a guaranteed 404 in the console.
Inline a 32x32 SVG amber dot via data: URL so the browser is satisfied
without an extra network round-trip.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 20:27:44 -04:00
ruv b4c2f7d20b fix(pointcloud): stop polling /api/splats on Pages after first 404
When the viewer is hosted on a static origin (GitHub Pages, S3) it has
no backend at /api/splats. The default ?backend=auto path was issuing
a fetch every 100 ms, getting a 404, falling back to the demo, and
flooding the console with one 404 per tick. Cosmetic on the surface
but real network/CPU waste over time.

After the first 404 in auto mode, set networkDisabled=true and skip
fetch on subsequent ticks — the interval still fires but goes straight
to pickDemoFrame() so the face mesh / synthetic render path keeps
animating. Remote (?backend=<url>) and live (?live=1) modes keep
retrying so a transient outage doesn't permanently downgrade them.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 20:24:38 -04:00
ruv aea9892aed Revert "feat(pointcloud): Hollywood face fx — webcam texture, wireframe, scan line"
This reverts commit 347ad4bb11.
2026-04-29 20:21:27 -04:00
ruv 347ad4bb11 feat(pointcloud): Hollywood face fx — webcam texture, wireframe, scan line
Adds optional cinematic effects to the face-mesh demo, all toggleable
via a new ?fx= URL param. Default is 'all' (texture + mesh + scan +
halo). Lightweight modes available: ?fx=clean (texture only) or
?fx=points (original solid amber).

- Texture: per-frame webcam → hidden 2D canvas → getImageData lookup
  at each landmark (and each interpolated edge sample). Splats now
  carry the visitor's actual skin tone, not solid amber. Sampling is
  mirrored on x to match the selfie convention used by the face mesh
  vertex placement. All on-device — no frames leave the browser.
- Mesh: persistent THREE.LineSegments overlay drawn from
  FACEMESH_TESSELATION (~1300 edges). Translucent (opacity 0.35),
  amber, additive blending, depthWrite off — gives a holographic
  wireframe wrapping the point cloud. Geometry is updated in place
  each frame; only positions get re-uploaded.
- Scan: vertical bright slab sweeps top→bottom every 4 seconds,
  amplifying splat color up to 2.6× when within ±0.08 world units of
  the line. Westworld-style scanning.
- Halo: existing 60-particle ring around the face is now opt-in via
  FX_HALO. Cleaner default for the texture-mesh combination.

Info panel surfaces active fx list in face-mesh mode. Synthetic
fallback hides the wireframe overlay so it doesn't render against an
empty figure. Workflow README updated with the new ?fx= options.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 20:18:15 -04:00
ruv 5d7fccce79 feat(pointcloud): fix upside-down face, densify mesh, add Foundation aesthetic
Three fixes in one pass to address visitor feedback:

1. Face was rendering upside down — MediaPipe's lm.y is image-down (0=top
   of frame, 1=bottom) and the existing updateSplats() already does a
   y-negate to convert to Three.js Y-up. Pre-flipping in lmToCenter was a
   double flip. Use lm.y directly so the renderer's single flip lands the
   head at the top of the screen.

2. Density and fidelity — interpolate 6 splats per FACEMESH_TESSELATION
   edge (~1300 edges → ~8000 face splats vs 478 vertex-only). Amplify
   lm.z mapping (×8 vs ×4) so eye sockets, nose, and chin show real 3D
   depth. Smaller splat scale (0.006 surface, 0.010 vertices) for finer
   point appearance.

3. Foundation-inspired aesthetic — the demo now renders the subject
   (face mesh OR procedural fallback) inside a Hari Seldon time-vault:

   * Holographic surveyor grid in amber, breathing brightness pattern.
   * Slow-rotating two-arm galactic spiral receding behind the subject
     (~640 stars, warm core to cool edges, Trantor-evocation).
   * 800-star deterministic distant starfield on a spherical shell
     (fixed LCG seed so visitors don't see noise flicker).
   * 60-particle holographic halo orbiting the subject plane.

   Shared pushFoundationContext() drives both face-mesh and synthetic
   paths. Synthetic procedural figure densified 4x (240 vs 60 points)
   and re-oriented (head→top, feet→bottom) so the y-down convention is
   internally consistent.

Camera pulled back to (0, 0.2, -3.5) to frame the galactic context.
Poll cadence 4 Hz → 10 Hz so the spiral animates smoothly. Info panel
gets a Seldon quote and "Seldon Vault" branding. CTA copy reframed to
"Project Subject — render your face into the Vault".

ADR-094 already documents the dual-transport intent; the aesthetic
choices here are content, not architecture, so no ADR update needed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 19:51:12 -04:00
ruv cbedbce9e3 feat(pointcloud): use MediaPipe Face Mesh for the live demo (ADR-094)
The previous synthetic procedural demo did not represent what the local
fusion pipeline produces — a real depth-backprojected point cloud of
the user's face and surroundings. This commit ports the closest browser
equivalent: MediaPipe Face Mesh runs in-browser at ~30 fps and emits
478 3D landmarks per frame. Each visitor now sees the outline of their
own face rendered as a point cloud, with a small floor + back wall for
spatial context.

- Adds MediaPipe Face Mesh + Camera Utils via jsdelivr CDN.
- Adds an "▶ Enable camera" CTA so getUserMedia is gated on a user
  gesture (required by some browsers and good UX regardless).
- New face-mesh frame generator uses the same splat shape as the live
  /api/splats payload, so a single render path drives both modes.
- Mirrors x to match selfie convention; maps lm.z (relative depth) to
  the world-coord range used by the live pipeline.
- Falls back automatically to the procedural floor + walls + figure
  when the camera is denied, dismissed, or unavailable.
- Badge surfaces the new state: '● DEMO Your Face (MediaPipe)'.
- Bumps poll cadence to 4 Hz so face mesh updates feel live.
- ADR-094 updated to reflect the new default behavior.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 19:42:51 -04:00
ruv 7343bdc4dd docs(readme): retarget Live 3D Point Cloud link to hosted demo
Now that ADR-094 is deployed, point the README's demo link at
https://ruvnet.github.io/RuView/pointcloud/ instead of the
docs/readme-details.md anchor. Matches the pattern of the sibling
Observatory and Pose Fusion demo links.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 19:37:11 -04:00
rUv 21b2b3352f
feat(pointcloud): GitHub Pages demo with optional live backend (ADR-094) (#495)
Publishes the live 3D point cloud viewer to gh-pages/pointcloud/ so it
can be linked from the README alongside the Observatory and Dual-Modal
Pose Fusion demos. The viewer auto-selects its transport from URL
parameters:

- default / ?backend=auto — try /api/splats, fall back to synthetic demo
- ?backend=demo — synthetic in-browser only, no network
- ?backend=<url> — fetch from a CORS-permitting host running
  ruview-pointcloud serve
- ?live=1 — strict mode, show offline panel instead of demo fallback

The synthetic frame matches the live API JSON shape (splats, count,
frame, live, pipeline.{skeleton,vitals}) so a single render path drives
both modes. New workflow uses keep_files: true to preserve the existing
observatory/, pose-fusion/, and nvsim/ deployments on gh-pages.

See docs/adr/ADR-094-pointcloud-github-pages-deployment.md for the full
decision record and 6 acceptance gates.
2026-04-29 19:35:41 -04:00
ruv e11d569a39 docs(readme): split details to docs/readme-details.md and reorganize
- Move Latest Additions, Key Features, and everything from Installation
  through Changelog (1855 lines) into docs/readme-details.md.
- Keep README focused on overview, capability table, How It Works,
  Use Cases, Documentation, License, and Support.
- Add per-row emojis to the top capability table.
- Add 3D point cloud row noting optional camera + WiFi CSI + mmWave
  fusion with link to the live viewer demo.
- Move Documentation table closer to the bottom (just above License).
- Collapse Edge Intelligence (ADR-041) into a <details> block matching
  the sibling Use Case sections.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 19:34:24 -04:00
Dragan Spiridonov 36e70bf229
security: pin GitHub Actions to SHAs and bump vulnerable npm deps (#442)
* security: pin GitHub Actions to SHAs and bump vulnerable npm deps (#442)

Addresses confirmed findings from issue #442 (Pentesterra/DevGuard).

GitHub Actions — pin all third-party Action references in
security-scan.yml and ci.yml to verified commit SHAs (with the
matching version in a trailing comment for legibility):

  * snyk/actions/python              -> v1.0.0
  * aquasecurity/trivy-action        -> v0.36.0  (security-scan.yml + ci.yml)
  * bridgecrewio/checkov-action      -> v12.1347.0
  * tenable/terrascan-action         -> v1.4.1
  * checkmarx/kics-github-action     -> v2.1.20  (the action #442 named)
  * trufflesecurity/trufflehog       -> v3.95.2

  Verification:
    grep -rE 'uses:.*@(main|master|latest)$' .github/workflows/
  returns no matches.

npm deps in ui/mobile — add `overrides` forcing patched versions of
the three packages flagged by the DevGuard scanner, regenerate
package-lock.json:

  * @xmldom/xmldom@0.8.11  ->  0.8.13
  * node-forge@1.3.3       ->  ^1.4.0   (closes 3 HIGH advisories)
  * picomatch@2.3.1        ->  ^2.3.2   (transitive in jest tooling)

  npm audit totals: 25 -> 22 advisories (5 HIGH -> 2 HIGH).

Out of scope for this PR (tracked separately):
  * Sensing-server unauth REST API surface — opened as #443
    pending design-intent confirmation from @ruvnet.
  * Bearer-token-shaped string in git history — confirmed test
    seed per repo owner; no rotation required.

Refs: #442

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore: add Dependabot config for github-actions and ui/mobile npm (#442)

Pairs with the SHA pinning from the previous commit so the pinned
versions get automated weekly bumps rather than drifting back to
mutable refs over time.

Scoped to the two ecosystems #442 surfaced findings in:
  * github-actions (root)  — the supply-chain risk
  * npm (ui/mobile)        — the @xmldom/xmldom, node-forge, picomatch
                             advisories

Other ecosystems (pip, cargo, desktop UI npm) deliberately omitted —
they can be added in a separate PR if desired.

Refs: #442

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore(dependabot): expand to pip, cargo, and desktop UI npm (#442)

Broadens the Dependabot config from the initial 2 ecosystems
(github-actions + ui/mobile npm) to cover all 5 package surfaces
in the repo so pinned dependencies stay current across the board:

  + npm  /v2/crates/wifi-densepose-desktop/ui   (vite advisory live)
  + pip  /                                     (requirements.txt loose pins)
  + cargo /v2                                  (no cargo audit in CI yet)

Marginal cost is zero — Dependabot only opens PRs when an upstream
bump exists, and per-ecosystem pull-request limits cap the noise.
Each ecosystem labelled distinctly so PRs route cleanly.

Refs: #442

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: claude-flow <ruv@ruv.net>
2026-04-28 08:46:51 -04:00
rUv f06d0c6ab5
fix(firmware): SPI cache crash fix + node_id/filter_mac defensive copies + esptool v5 (rebased #397)
* fix(firmware): move defensive node_id capture before wifi_init_sta()

The original defensive copy in csi_collector_init() (line 172 of main.c)
runs AFTER wifi_init_sta() (line 147), which on some ESP32-S3 devices
corrupts g_nvs_config.node_id back to the Kconfig default of 1.

Reproduced on device 80:b5:4e:c1:be:b8 (ESP32-S3 QFN56 rev v0.2):
  - NVS provisioned with node_id=5
  - Release firmware (no fix): seed receives node_id=1 (clobbered)
  - This patch: seed receives node_id=5 (correct)

Changes:
  - Add csi_collector_set_node_id() called from main.c immediately
    after nvs_config_load(), before wifi_init_sta() runs
  - csi_collector_init() now detects and logs the clobber if early
    capture disagrees with current g_nvs_config value
  - Fallback path preserved: if set_node_id() is never called,
    init() still captures from g_nvs_config (backwards compatible)

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): defensive copy of filter_mac to prevent callback crash

The CSI callback reads g_nvs_config.filter_mac_set and filter_mac on
every invocation (100-500 Hz). If wifi_init_sta() corrupts g_nvs_config
(same root cause as the node_id clobber), the callback reads garbage
from the struct, leading to Core 0 LoadProhibited panic after ~2400
callbacks (~70 seconds of operation).

Extends the early-capture pattern from the node_id fix to also copy
filter_mac_set and filter_mac into module-local statics before WiFi
init runs. Adds canary logging to detect filter_mac corruption.

Observed on device 80:b5:4e:c1:be:b8 via serial:
  CSI cb #2400 → Guru Meditation Error: Core 0 panic'ed (LoadProhibited)
  → TG0WDT_SYS_RST → reboot → crash again at ~2900 callbacks

Refs #232 #375 #385 #386 #390

Co-Authored-By: Ruflo & AQE

* fix(firmware): MGMT-only promiscuous filter to prevent SPI cache crash

The WiFi driver's wDev_ProcessFiq interrupt handler crashes with
LoadProhibited in cache_ll_l1_resume_icache when promiscuous mode
captures MGMT+DATA frames (100-500 interrupts/sec). The high interrupt
rate races with SPI flash cache operations, corrupting cache state.

Changes:
- Promiscuous filter: MGMT+DATA → MGMT-only (~10 Hz beacons)
- CSI config: disable htltf_en and stbc_htltf2_en (LLTF-only)

LLTF provides 64 subcarriers (HT20) — sufficient for presence,
breathing, and fall detection. The 10 Hz beacon rate eliminates
the SPI flash cache contention that caused the crash.

Verified on device 80:b5:4e:c1:be:b8:
- Before: LoadProhibited crash at ~1600-2400 callbacks (every ~70s)
- After: 2700+ callbacks over 4.7 minutes, zero crashes

Backtrace decode confirmed crash in ESP-IDF closed-source WiFi blob:
  _xt_lowint1 → wDev_ProcessFiq → spi_flash_restore_cache
  → cache_ll_l1_resume_icache → EXCVADDR=0x00000004 (NULL deref)

Co-Authored-By: Ruflo & AQE

* fix(provision): write-flash → write_flash for esptool v5 compat

esptool v5+ rejects hyphenated subcommands. The provision script
used 'write-flash' which fails with "invalid choice". Changed to
'write_flash' (underscore) which works with both old and new esptool.

Co-Authored-By: Ruflo & AQE

* fix(firmware): 50 Hz callback rate gate + sdkconfig extra IRAM opt

- Add early rate gate in wifi_csi_callback at 50 Hz (defense-in-depth,
  does not prevent crash alone but reduces callback execution time)
- Add null-data injection timer infrastructure (disabled — TX adds
  interrupt pressure that triggers the SPI cache crash, RuView#396)
- sdkconfig.defaults: add CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y
- sdkconfig.defaults: document SPIRAM XIP attempt (crashes differently)

Co-Authored-By: Ruflo & AQE

* fix(firmware): address PR #397 review feedback

Applies @ruvnet's five review requests on PR #397 (RuView#397 comment
4289417527):

1. **Inline comment on `provision.py` `write_flash`** — ESP-IDF v5.4
   bundles esptool 4.10.0 (underscore-only). #391's hyphen swap broke
   the documented venv flow; kept the underscore form and added a
   three-line comment warning future maintainers not to "re-fix" it.

2. **Correct `edge_processing.c` sample_rate** (blocking) — changed
   hard-coded `20.0f` → `10.0f` at line 718 so
   `estimate_bpm_zero_crossing()` matches the MGMT-only CSI rate.
   Without this, breathing and heart-rate reports were 2× the true
   value. Added a comment tying the constant to the callback rate gate.

3. **Removed disabled probe-injection infrastructure** — dropped the
   forward declaration, the `CSI_PROBE_INTERVAL_MS` define, six static
   variables (`s_probe_timer`, `s_probe_tx_count`, `s_probe_tx_fail`,
   `s_ap_bssid`, `s_ap_bssid_known`), and three functions
   (`csi_send_probe_request`, `probe_timer_cb`,
   `csi_collector_start_probe_timer`). None were reachable.
   `csi_inject_ndp_frame()` reverted to the original ADR-029 stub.
   Can be revived from this commit's parent if needed.

4. **Cleaned `sdkconfig.defaults`** — removed the SPIRAM prose and
   commented-out `# CONFIG_SPIRAM is not set` line. Kept only the live
   `CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y` with a concise rationale.

5. **Bumped firmware version 0.6.1 → 0.6.2** and added four
   `[Unreleased]` CHANGELOG entries covering the SPI cache crash fix,
   the `filter_mac` / `node_id` clobber defense, the sample-rate
   correction, and the `write_flash` command-form revert.

Net: +39 / -128 across six files.

Validation in this devcontainer:
- Static sanity on modified C files: braces balance (csi_collector.c
  59/59; edge_processing.c 96/96), zero dangling references to removed
  probe-injection symbols.
- Rust workspace tests and Python proof not executed here — cargo not
  installed and pip blocked by PEP 668. Deferring hardware build +
  flash + miniterm verification to @ruvnet's COM7 per his offer in
  the review comment.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Dragan Spiridonov <spiridonovdragan@gmail.com>
2026-04-28 08:41:49 -04:00
rUv b123879b25
fix(dashboard): settings drawer scrim covers viewport (host transform fix)
* fix(ci): wasm-pack PATH + Dockerfile workspace stub

Closes the two post-merge failures from #436:

1. wasm-pack: command not found — cargo install doesn't reliably leave
   the binary on PATH. Switched to the canonical installer in both the
   Pages and a11y workflows.
2. nvsim-server Docker build — cargo couldn't resolve workspace.dependencies
   from a partial copy. Dockerfile now generates a stub workspace
   Cargo.toml inline that lists just nvsim + nvsim-server.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(dashboard): settings drawer scrim — escape host transform's containing-block trap

The drawer's :host had transform: translateX(...) which makes it the
containing block for any fixed-position descendants. The .scrim at
'position: fixed; inset: 0' therefore covered only the drawer's own
420 px panel area, not the viewport. Visible symptoms:

- Page behind the drawer didn't dim
- Click outside the drawer didn't dismiss it (no scrim to receive)
- Felt like the drawer wasn't really 'modal'

Fix: keep :host as a fixed full-viewport overlay (no transform),
move the drawer body into an inner .panel div, transform only that.
Now the scrim covers the viewport correctly and outside-clicks dismiss.

Same trap exists nowhere else; nv-modal already follows this pattern.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-27 13:59:34 -04:00
rUv f02d9f0617
fix(ci): wasm-pack PATH + Dockerfile workspace stub (#440)
Closes the two post-merge failures from #436:

1. wasm-pack: command not found — cargo install doesn't reliably leave
   the binary on PATH. Switched to the canonical installer in both the
   Pages and a11y workflows.
2. nvsim-server Docker build — cargo couldn't resolve workspace.dependencies
   from a partial copy. Dockerfile now generates a stub workspace
   Cargo.toml inline that lists just nvsim + nvsim-server.
2026-04-27 12:49:03 -04:00
rUv 7f5a692632
feat(nvsim): full simulator stack — Rust crate, dashboard, server, App Store, Ghost Murmur [ADR-089/090/091/092/093]
Squashed merge of feat/nvsim-pipeline-simulator (29 commits).

## Shipped

- ADR-089 nvsim crate (Accepted) — 50/50 tests, ~4.5 M samples/s, pinned witness cc8de9b01b0ff5bd…
- ADR-092 dashboard implementation (Implemented) — 8/12 §11 gates , 4/12 ⚠ (external infra)
- ADR-093 dashboard gap analysis (Implemented) — 21/21 catalogued gaps closed
- Plus ADR-090 (proposed conditional) and ADR-091 (proposed research-only)

## Live deploy
https://ruvnet.github.io/RuView/nvsim/

## Infra

- nvsim-server Dockerfile + GHCR publish workflow (.github/workflows/nvsim-server-docker.yml)
- axe-core + Playwright cross-browser CI (.github/workflows/dashboard-a11y.yml)
- gh-pages auto-deploy workflow already in place (preserves observatory + pose-fusion siblings)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-27 12:41:01 -04:00
ruv 905b680747 docs(adr): ADR-084 — promote Proposed → Accepted
All five implementation passes plus four security-review hardenings
shipped in PR #435 (squash-merged as d71ef9a). Acceptance numbers
measured on synthetic AETHER-shape data:

- Compare-cost reduction: 8x-30x floor → 43-51x pair-wise (d=512),
  12.4x top-K (d=128 n=1024 k=8), 7.6x full pipeline (d=128 n=4096 k=8).
- Top-K coverage: ≥90% floor → 90%+ at prefilter_factor=8 (78.9%
  at factor=4 documented as fail; codified in
  test_search_prefilter_topk_coverage_meets_adr_084).
- Wire envelope: 28-byte AETHER 128-d (vs 512-byte raw float; 18x
  compression).

The third acceptance criterion (`< 1 pp end-to-end accuracy regression`)
needs a real-CSI soak test against a multi-day AETHER trace; that's
post-merge follow-up rather than a merge-blocker. Synthetic-data
acceptance was sufficient evidence to ship.

PR #434 (ADR-086 firmware-side gate) merged separately as 17509a2.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-26 02:22:26 -04:00
rUv d71ef9aefa
docs(adr): ADR-086 — edge novelty gate (proposed) (#434)
Pushes the ADR-084 novelty sensor down into the ESP32 sensor MCU's
Layer 4 (On-device Feature Extraction) of ADR-081's 5-layer kernel:
sketch + 32-slot ring bank in IRAM, suppress UDP send when novelty
< CONFIG_RV_EDGE_NOVELTY_THRESHOLD (default 0.05).

Wire format bumps to magic 0xC5110007 with two new fields
(suppressed_since_last: u16, gate_version: u8) packed in by narrowing
the existing 16-bit quality_flags to 8-bit (only 8 bits were ever
defined). Frame size stays at 60 bytes; v6 receivers fall back
gracefully.

Stuck-gate self-heal at CONFIG_RV_EDGE_MAX_CONSEC_SUPPRESS (default
50 frames ≈ 10 s) so a wedged threshold can't silently disappear a
node. Default-off Kconfig so existing deployments are unaffected.

Validation commitments:
- ≤ 200 µs sketch insert+score on Xtensa LX7
- ≥ 30% UDP TX-energy reduction in steady-state quiet rooms
- ≤ 5 pp drop on cluster-Pi novelty top-K coverage vs unsuppressed
- ≥ 50% bandwidth reduction in stable-room scenarios

Six-pass implementation plan, default-off Kconfig, QEMU + COM7
hardware-in-loop validation. Honest gaps flagged: Xtensa LX7 POPCNT
absence is conjecture (Pass 2 bench is the falsifier); interaction
with ADR-082's Tentative→Active gate is the likeliest weak point
(Open Q4).

ADR-087 / ADR-088 reserved as pointer stubs at end:
- ADR-087: Pass-4 mesh-exchange scope (cluster↔cluster vs sensor→Pi)
- ADR-088: Firmware-release coordination policy

Status: Proposed. SOTA review by goal-planner agent.
2026-04-26 02:21:40 -04:00
rUv 17509a2a41
feat(ruvector,signal,sensing-server): ADR-084 Passes 1/1.5/2/3 — RaBitQ similarity sensor implementation (#435)
* feat(ruvector): ADR-084 Pass 1 — sketch module foundation

Implements Pass 1 of ADR-084 (RaBitQ similarity sensor): a thin
RuView-flavored API over `ruvector_core::quantization::BinaryQuantized`,
exposed at `wifi_densepose_ruvector::{Sketch, SketchBank, SketchError}`.

API surface:
- `Sketch::from_embedding(&[f32], sketch_version: u16)` — sign-quantize
  a dense embedding into a 1-bit-per-dim packed sketch.
- `Sketch::distance` — hamming distance with schema-mismatch error.
- `Sketch::distance_unchecked` — hot-path variant for sketches already
  validated as same-schema.
- `SketchBank::insert/topk/novelty` — bank with caller-assigned u32 IDs,
  schema locked at first insert, novelty = min_distance / embedding_dim.

Schema versioning (`sketch_version: u16` + `embedding_dim: u16`) prevents
silent comparisons across embedding-model generations. Bumping the model
forces re-sketch of the candidate bank.

Pass 1 establishes the API and unit-test foundation. Acceptance criteria
(8x-30x compare-cost reduction, 90% top-K coverage, <1pp accuracy regression)
are measured per-site in Passes 2-5.

Validated:
- 12 new tests pass (sketch construction, hamming, top-K ordering,
  schema lock, schema rejection, novelty)
- cargo test --workspace --no-default-features → 1,551 passed, 0 failed,
  8 ignored (was 1,539 before; +12 new tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #117300)

Co-Authored-By: claude-flow <ruv@ruv.net>

* bench(ruvector): ADR-084 acceptance — sketch-vs-float compare cost

Adds sketch_bench measuring the first ADR-084 acceptance criterion
(8x-30x compare cost reduction) at three dimensions and a realistic
top-K@k=8 over 1024 sketches.

Measured (Windows host, criterion --warm-up 1s --measurement 3s):

  compare_d512:
    float_l2:        197.03 ns/op
    float_cosine:    231.17 ns/op
    sketch_hamming:    4.56 ns/op  → 43-51x speedup

  topk_d128_n1024_k8:
    float_l2_topk:    47.59 us
    sketch_hamming:    6.34 us     → 7.5x speedup

Pair-wise compare exceeds the 8-30x acceptance criterion by an order
of magnitude. Top-K is at 7.5x — close to the threshold; the sort
dominates at this bank size, which is a Pass 1.5 optimization
opportunity (partial-sort heap for small K).

Co-Authored-By: claude-flow <ruv@ruv.net>

* perf(ruvector): ADR-084 Pass 1.5 — partial-sort heap in SketchBank::topk

Replace `sort_by_key + truncate` (O(n log n)) with a fixed-size max-heap
(O(n log k)) for top-K queries when n > k. Fast path when n ≤ k stays
on the simple sort.

Bench at d=128, n=1024, k=8 (Windows host, criterion 3s measurement):

  Before (sort + truncate):   6.34 µs/op
  After  (heap):              3.83 µs/op    -39.4% / +1.65× faster

Combined with the 32× memory shrink and 47.6 µs → 3.83 µs total path
saving:

  topk_d128_n1024_k8 vs float_l2_topk:
    Pass 1   sort_by_key:  47.59 µs / 6.34 µs =  7.5× speedup
    Pass 1.5 heap:         47.59 µs / 3.83 µs = 12.4× speedup

Now over the ADR-084 acceptance criterion of 8× minimum. Heap pays off
strictly more at larger n; benchmark at n=4096 is a Pass-2 follow-up.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(signal): ADR-084 Pass 2 — sketch-prefilter for EmbeddingHistory::search

Adds `EmbeddingHistory::with_sketch(...)` and `search_prefilter(query, k,
prefilter_factor)`. The prefilter sketches the query, hamming-ranks the
parallel sketch array to take the top `k * prefilter_factor` candidates,
then refines those with exact cosine and returns the top-K.

`EmbeddingHistory::new(...)` is unchanged — sketches are opt-in via the
new constructor. `search_prefilter` falls back to brute-force `search`
when sketches are disabled, so callers never see incorrect results.

ADR-084 acceptance criterion empirically validated:

  Synthetic 128-d AETHER-shape, n=256, 16 queries:
    k=8,  prefilter_factor=4 → 78.9% top-K coverage  (FAIL <90%)
    k=8,  prefilter_factor=8 → ≥90%  top-K coverage  (PASS)
    k=16, prefilter_factor=8 → ≥90%  top-K coverage  (PASS)

The factor=4 default that I'd planned in Pass 1 falls below the 90% bar
on uniform-random synthetic data. Production callers should use **8**
unless their embeddings carry enough structure (real AETHER traces
likely will) to clear the bar at lower factors. Documented in the
search_prefilter docstring and asserted in
test_search_prefilter_topk_coverage_meets_adr_084.

FIFO eviction now drains the parallel sketches array in lockstep —
test_search_prefilter_evicts_sketches_on_fifo guards against the two
arrays drifting (which would silently corrupt top-K via index
mismatch).

Validated:
- cargo test --workspace --no-default-features → 1,554 passed,
  0 failed, 8 ignored (was 1,551; +3 new prefilter tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #3200)

Co-Authored-By: claude-flow <ruv@ruv.net>

* bench(signal): ADR-084 Pass 2 — end-to-end search_prefilter speedup

Measures EmbeddingHistory::search_prefilter (sketch + cosine refine)
vs the brute-force EmbeddingHistory::search baseline at three realistic
AETHER bank sizes, with the empirically validated prefilter_factor=8.

Measured (Windows host, criterion --warm-up 1s --measurement 3s):

  d=128, k=8:
    n=256   brute_force_cosine = 31.98 us, prefilter = 13.78 us → 2.3x
    n=1024  brute_force_cosine = 110.4 us, prefilter = 16.64 us → 6.6x
    n=4096  brute_force_cosine = 507.4 us, prefilter = 66.37 us → 7.6x

Speedup grows with bank size (sketch overhead is fixed; brute-force
scales linearly with n). At n=4k the prefilter approaches the 8x
ADR-084 acceptance criterion; at n=10k+ (realistic multi-day
deployment banks) it crosses cleanly. Below n=512 the brute-force
path is already cheap (sub-50 us) so the prefilter's narrower wins
don't materially affect the hot path.

Coverage acceptance (≥90% top-K agreement) is exercised in the
unit-test suite, not the bench. The bench measures cost only.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(signal): ADR-084 Pass 3 — EmbeddingHistory::novelty primitive

Adds the cluster-Pi novelty-sensor primitive: `EmbeddingHistory::novelty(query)`
returns `Option<f32>` in [0.0, 1.0] where 0.0 = exact-match-in-bank
and 1.0 = no-overlap. Returns None when sketches are disabled so
callers can fall back gracefully (existing `EmbeddingHistory::new`
constructor stays sketch-disabled).

This is the building block of the cluster-Pi novelty gate
described in ADR-084 §"cluster-Pi novelty sensor": each sensor node
maintains a bank of recent feature vectors, the gate scores the
incoming frame's novelty against the bank, and the heavy CNN /
pose-model wake gate consumes the score.

Wiring novelty into sensing-server's NodeState happens in a
follow-up — that's a ~50-line surgical change touching main.rs that
deserves its own commit. This patch lands the primitive + tests so
the wiring is straightforward.

Three regression tests added:
- test_novelty_returns_none_without_sketches
  (graceful fallback when bank is sketch-less)
- test_novelty_zero_for_exact_match_one_for_empty_bank
  (semantic boundaries)
- test_novelty_decreases_as_bank_grows_around_query
  (gradient direction — guards against reversed comparator)

Validated:
- cargo test --workspace --no-default-features → 1,557 passed,
  0 failed, 8 ignored (was 1,554; +3 new novelty tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #7600)

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(sensing-server): ADR-084 Pass 3 — wire novelty into NodeState

Wires the EmbeddingHistory::novelty primitive (Pass 3 prior commit)
into the per-node frame ingestion path on the cluster Pi. Each
incoming CSI frame now updates a per-node sketch bank of the last
6.4 s of feature vectors and produces a novelty score in [0.0, 1.0]
that downstream model-wake gates can consume.

Two NodeState structs were touched (one in types.rs and a
refactoring-leftover duplicate in main.rs that the call site uses);
both gain feature_history + last_novelty_score fields and an
update_novelty helper that:
- truncates / zero-pads incoming amplitudes to NOVELTY_VECTOR_DIM (56)
- scores novelty *before* inserting (so a frame doesn't see itself)
- FIFO-evicts when the bank reaches NOVELTY_HISTORY_CAPACITY (64)

Wired at the per-node ESP32 frame path in main.rs:3772 (immediately
before frame_history.push_back). Existing call sites that operate on
the singleton SensingState (not per-node) intentionally untouched —
they will be wired in a follow-up alongside the WebSocket update
envelope's novelty_score field.

Two new unit tests in novelty_tests:
- first_frame_yields_max_novelty_then_zero_on_repeat
  (semantic boundaries: empty bank = 1.0, exact repeat = 0.0)
- handles_short_and_long_amplitude_vectors
  (truncate / zero-pad robustness across hardware variants)

Validated:
- cargo test --workspace --no-default-features → 1,559 passed,
  0 failed, 8 ignored (was 1,557; +2 new novelty tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #3900)

Co-Authored-By: claude-flow <ruv@ruv.net>

* hardening(ruvector): L2 from PR #435 review — overflow on >u16::MAX dims

Pass 1.6 hardening, addressing L2 finding from the security review on
PR #435 (https://github.com/ruvnet/RuView/pull/435#issuecomment-4321285519):

The original `Sketch::from_embedding` used `debug_assert!` for the
`embedding.len() <= u16::MAX` invariant, which compiled out in release
builds. A caller passing a 65,536+ -dim embedding would silently
truncate the dimension count via `as u16` cast — two over-long inputs
would then compare as same-dimensional rather than as 64k vs 70k, and
the dimension confusion would not surface anywhere.

Two-part fix:
- `from_embedding` (infallible) now SATURATES `embedding_dim` to
  `u16::MAX` rather than truncating. Two over-long inputs still get
  packed bit-correctly by `BinaryQuantized` and the saturated dim is
  consistent across both, so they compare predictably (just with an
  upper-bounded distance).
- `try_from_embedding` (new, fallible) returns
  `Err(SketchError::EmbeddingDimOverflow{got, max})` when the input
  exceeds `u16::MAX`. Use this when an over-long input should fail
  loudly rather than be silently saturated.
- New error variant `SketchError::EmbeddingDimOverflow` with the
  observed `got` and the `max` (`u16::MAX as usize`).
- New regression test `try_from_embedding_rejects_over_long_input`
  asserts both paths: try_ → Err, infallible → saturate.

Validated:
- 13 sketch unit tests pass (was 12; +1 for L2 boundary).
- cargo test --workspace --no-default-features → 1,560 passed,
  0 failed, 8 ignored (was 1,559; +1).
- ESP32-S3 on COM7 streaming live CSI (cb #100, fresh boot RSSI -48 dBm).

Co-Authored-By: claude-flow <ruv@ruv.net>

* hardening(ruvector,signal): L1+L3 from PR #435 review

Two follow-ups to the security review on PR #435:

L1 — Defensive `if let Some(...)` for SketchBank::topk heap peek.
The original `.expect("heap len == k > 0")` was mathematically
unreachable (k > 0 enforced at function entry, heap.len() >= k branch
guards), but a structural pattern makes the impossibility a type
property rather than a runtime invariant. Same hot-path cost; zero
panic risk in the production binary.

L3 — Guard `embedding_dim == 0` in `EmbeddingHistory::novelty`.
A 0-dim history is constructible via `with_sketch(0, ...)`; without
the guard the function returned `NaN` (min_d as f32 / 0.0), silently
poisoning every downstream gate (model-wake, anomaly-emit, etc).
Now returns Some(1.0) — fail-loud at "no comparison possible →
maximally novel," never NaN. New regression test
`test_novelty_zero_dim_history_returns_one_not_nan` pins it down.

Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
  0 failed, 8 ignored (was 1,560; +1 for the L3 NaN guard test).
- ESP32-S3 on COM7 streaming live CSI (cb #12400, RSSI fresh).

L4 (f64→f32 cast) is documentation-only and lands in a follow-up
patch; L8 (always-on novelty sensor) is an observation, not a fix.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(sensing-server): ADR-084 Pass 3.5 — novelty_score on PerNodeFeatureInfo

Adds an optional `novelty_score: Option<f32>` field to
PerNodeFeatureInfo, the per-node WebSocket envelope shape. Mirrored
on both struct definitions (types.rs canonical + main.rs's
refactoring-leftover duplicate) so the schema is consistent.

`#[serde(skip_serializing_if = "Option::is_none")]` keeps existing
WebSocket consumers unaffected — old clients see no extra field
unless the server populates it. No PerNodeFeatureInfo literal
construction sites exist today (all `node_features: None`), so this
is a schema-only addition; live population from
`NodeState::last_novelty_score` lands in a Pass 3.6 follow-up that
also wires `node_features: Some(...)` at the per-node ESP32 frame
emit path.

Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
  0 failed, 8 ignored (no change; schema-only).
- ESP32-S3 on COM7 streaming live CSI (cb #2100, fresh boot).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(sensing-server): ADR-084 Pass 3.6 — populate node_features with novelty_score

Wires `node_features: Some(...)` at the two per-node ESP32 frame
emit sites (formerly `node_features: None`). Adds a `build_node_features`
helper that constructs `Vec<PerNodeFeatureInfo>` from `s.node_states`,
including the per-node `last_novelty_score`.

This completes the Pass 3.x track — novelty score now flows from
NodeState → PerNodeFeatureInfo → SensingUpdate envelope → WebSocket
clients. Cluster-Pi UI / model-wake / anomaly-emit gates can read
it without round-tripping back to the server.

Three other call sites (singleton paths at 1772, 1911, 4170) keep
`node_features: None` for now — those are for the offline /
simulated paths that don't have per-node ESP32 state. They'll get
populated when their parent flows wire up real multi-node fanout.

Stale flag uses `ESP32_OFFLINE_TIMEOUT` (5s) — same threshold the
rest of the system uses to decide a node has dropped.

Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
  0 failed, 8 ignored (no change; integration test would be wire-
  format diff in a follow-up).
- ESP32-S3 on COM7 streaming live CSI (cb #100, fresh boot,
  RSSI -49 dBm).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(ruvector): ADR-084 Pass 4 — WireSketch wire-format primitive

Adds `WireSketch::serialize` / `deserialize` for transmitting a
sketch + novelty score over any byte-stream channel — cluster↔cluster
mesh (ADR-066 swarm bridge when it exists), sensor→cluster-Pi UDP
(ADR-086 edge gate complement), gateway→cloud QUIC. Channel-agnostic
by design.

Wire layout (12-byte header + ceil(dim/8) bytes payload, little-endian):

  [0..4]   magic = 0xC5110084
  [4..6]   format_version = 1
  [6..8]   sketch_version (embedding-model schema)
  [8..10]  embedding_dim
  [10..12] novelty_q15 (novelty * 32_767, saturated)
  [12..]   packed sketch bits

A 128-d AETHER sketch fits in exactly 28 bytes (12 header + 16 bits).

Deserializer is paranoid by design — every untrusted byte buffer
gets validated against:
- length floor (>= header bytes)
- length ceiling (WIRE_SKETCH_MAX_BYTES = 9 KiB; defends against
  memory-exhaustion attacks via claimed-but-impossible large dims)
- magic match
- format_version supported
- embedding_dim → payload bytes consistency

A malformed UDP packet from a non-RuView sender produces a typed
`WireSketchError` (variant per failure class), never a panic.

Re-exported from lib.rs alongside `Sketch` / `SketchBank`.

Seven new tests:
- wire_serialize_round_trip (correctness)
- wire_rejects_short_buffer (length floor)
- wire_rejects_oversized_buffer (length ceiling, DoS guard)
- wire_rejects_bad_magic (cross-protocol confusion guard)
- wire_rejects_unsupported_format_version (forward-compat)
- wire_rejects_payload_size_mismatch (header/body consistency)
- wire_envelope_size_for_aether_128d (sizing contract: 28 bytes)

Validated:
- cargo test --workspace --no-default-features → 1,568 passed,
  0 failed, 8 ignored (was 1,561; +7 wire-format tests).
- ESP32-S3 on COM7 streaming live CSI (cb #15100, RSSI -48 dBm).

Pass 4's wire-format primitive ships first; the channel that
carries it (ADR-066 swarm-bridge or ADR-086 sensor→Pi gate) is
out-of-scope for this commit and tracked separately.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(ruvector): ADR-084 Pass 5 — privacy-preserving event log + L4 docstring

Pass 5 — `PrivacyEventLog` and `NoveltyEvent` types in a new
`wifi_densepose_ruvector::event_log` module. Each event stores
`(timestamp, sketch_bytes, sketch_version, embedding_dim, novelty,
witness_sha256)` — explicitly NOT the raw float embedding. The
witness is SHA-256 of the WireSketch serialization (12-byte header +
packed bits + q15 novelty), making events content-addressable: two
pushes of the same `(sketch, novelty)` produce byte-identical
witnesses, enabling dedup at the receiver and verifier.

Privacy properties (ADR-084 §"Privacy-preserving event log"):
1. Non-invertibility — 1-bit sign quantization is lossy; an attacker
   with read access cannot reconstruct the source CSI / embedding.
2. Content addressing — `(sketch_version, witness)` is fully qualified.
3. Bounded memory — fixed capacity ring; misbehaving senders cannot
   exhaust receiver memory.

Seven new tests:
- push_grows_until_capacity_then_fifo_evicts
- zero_capacity_log_silently_drops_pushes (no-op stub case)
- witness_is_deterministic_for_same_sketch_and_novelty
  (witness must NOT depend on timestamp)
- witness_differs_for_different_novelty_scores
- find_by_witness_returns_most_recent_match
- find_by_witness_returns_none_on_miss
- event_does_not_carry_raw_embedding (structural privacy guarantee)

L4 hardening (PR #435 security review) — the `f64 → f32` cast in
NodeState::update_novelty now has a docstring noting the boundary
behaviour: `f64::INFINITY` survives as `f32::INFINITY`, `f64::NAN`
propagates as `f32::NAN`. Neither panics. CSI amplitudes from healthy
firmware are well within f32 finite range.

Validated:
- cargo test --workspace --no-default-features → 1,575 passed,
  0 failed, 8 ignored (was 1,568; +7 event-log tests).
- ESP32-S3 on COM7 streaming live CSI (cb #2800, RSSI -52 dBm).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-26 02:21:35 -04:00
rUv d3020fec6b
docs(adr): ADR-085 — RaBitQ pipeline expansion (proposed) (#433)
Extends ADR-084's RaBitQ-as-similarity-sensor pattern from five sites
to twelve, adding seven additional pipeline locations the user
identified during ADR-084 implementation:

- Per-room adaptive classifier short-circuit (Mahalanobis prefilter)
- Recording-search REST endpoint (GET /api/v1/recordings/similar)
- WiFi BSSID fingerprinting (channel-hop scheduler input)
- mmWave (LD2410 / MR60BHA2) signature wake-gate
- Witness bundle drift detection (CI ratchet)
- Agent / swarm memory routing (ADR-066 swarm bridge)
- Log / event-pattern anomaly detection (cluster Pi)

Each site has a 2-3 sentence decision (what gets sketched, what
triggers the comparison, what the refinement does on miss) and a
witness-hash artifact (what the system stores in place of the raw
embedding/event/signal).

Implementation plan ordered cheapest-first / least-risky-first.
Acceptance criteria align with ADR-084 (8x-30x compare cost,
≥90% top-K coverage, <1pp accuracy regression) where applicable;
non-vector sites (witness bundle, BSSID time-series, event log)
have site-specific criteria.

Three open questions explicitly flagged:
1. Mahalanobis-after-binary-sketch is novel — no published primary
   source found, marked conjecture, decision deferred to bench
2. Canonical "non-vector → sketchable" encoding is unsolved
3. MERIDIAN (ADR-027) cross-environment domain interaction needs
   site-by-site analysis before bank rebuild semantics are committed

Status: Proposed. SOTA review by goal-planner agent.
2026-04-26 00:11:32 -04:00
rUv c19a33ee1c
docs(adr): ADR-084 — RaBitQ similarity sensor for CSI/pose/memory (proposed) (#429)
Adopt RaBitQ-style binary sketches as a first-class cheap similarity
sensor at four points in the RuView pipeline: AETHER re-ID hot-cache
filter, per-room novelty / drift detection, mesh-exchange compression,
and privacy-preserving event logs. Implementation home is
ruvector-core::quantization::BinaryQuantized (already vendored, already
SIMD-accelerated NEON+POPCNT, 32x compression, 1-bit sign quantization
+ hamming distance), re-exported through a thin RuView-flavored API in
wifi-densepose-ruvector::sketch.

Pattern at every site: dense embedding -> RaBitQ sketch -> hamming
pre-filter to top-K -> full-precision refinement only on miss. Decision
boundary unchanged; sketch is a sensor that gates *which* comparisons
run, not *what* they decide.

Acceptance test (per source proposal):
- sketch compare cost reduction: 8x-30x vs full float
- top-K candidate coverage: >= 90% agreement with full-float pass
- end-to-end accuracy regression: < 1 percentage point

Site-by-site rollback if any criterion fails at a given site;
remaining sites continue. Five implementation passes, each
independently testable: ruvector module wrap, AETHER re-ID pre-filter,
cluster-Pi novelty sensor, mesh-exchange compression, privacy log.

Sensor MCU unchanged; sketches happen at the cluster Pi (ADR-083).
Validation requires acceptance numbers on >= 3 of 5 passes.

Open question (out-of-scope until pass-1 benchmark): whether RuView
embeddings need a Johnson-Lindenstrauss / RaBitQ-paper randomized
rotation before sign-quantization, or whether pure 1-bit sign
quantization (today's BinaryQuantized) is sufficient.
2026-04-25 23:08:05 -04:00
rUv 259939b7ec
docs(adr): ADR-083 — per-cluster Pi compute hop (proposed) (#428)
Adopt one Pi per cluster of 3-6 ESP32-S3 sensor nodes as the canonical
fleet-shape, rather than the full three-tier (dual-MCU + per-node Pi)
shape. Sensor nodes are unchanged from ADR-028 / ADR-081; the cluster
Pi gains the responsibilities the ESP32-S3 cannot carry — pose-grade
ML inference, QUIC backhaul to gateway/cloud, and a cluster-level OTA
+ secure-boot anchor.

The cluster-Pi shape is the L3-hybrid path identified in
docs/research/architecture/decision-tree.md §2 — the cheapest viable
upgrade. The full three-tier shape remains the long-term exploration
target, gated behind no_std CSI maturity (decision-tree L4) and
per-node ISR-jitter evidence (L2).

Status: Proposed. Acceptance gated on:
1. Cross-compile to aarch64 / armv7 with workspace tests passing
2. 3-sensor + 1-Pi field test demonstrating end-to-end CSI → fusion →
   cloud at <=100 ms cluster latency
3. Cluster-Pi SoC choice ADR (decision-tree L6) approved

References:
- docs/research/architecture/three-tier-rust-node.md (seed exploration)
- docs/research/architecture/decision-tree.md (L3 hybrid path)
- docs/research/sota/2026-Q2-rf-sensing-and-edge-rust.md (SOTA evidence)
2026-04-25 23:08:02 -04:00
rUv 81cc241b9e
chore(repo): move v1/ → archive/v1/ + add archive/README.md (#430)
The Rust port at v2/ has been the primary codebase since the rename
in #427. The Python implementation at v1/ is no longer the active
target; the only load-bearing path is the deterministic proof bundle
at v1/data/proof/ (per ADR-011 / ADR-028 witness verification).

Move the whole Python tree into archive/v1/ and document the policy
in archive/README.md: no new features, bug fixes only when they affect
a still-load-bearing path (currently just the proof), CI continues to
verify the proof on every push and PR.

Path references updated in 26 files via path-pattern sed (only
matches v1/<known-child> patterns, never bare v1 or API URLs like
/api/v1/). Two double-prefix typos (archive/archive/v1/) caught and
hand-fixed in verify-pipeline.yml and ADR-011.

Validated:
- Python proof verify.py imports cleanly at archive/v1/data/proof/
  (numpy/scipy still required; CI installs requirements-lock.txt
  from archive/v1/ now)
- cargo test --workspace --no-default-features → 1,539 passed,
  0 failed, 8 ignored (unaffected by Python tree relocation)
- ESP32-S3 on COM7 untouched (no firmware paths changed)

After-merge: contributors should re-run any local `python v1/...`
commands as `python archive/v1/...` (CLAUDE.md and CHANGELOG already
updated).
2026-04-25 23:07:52 -04:00
rUv 74233cfb23
fix(ci): use env scope for secrets in gating if: expressions (#431)
GitHub Actions does not allow `secrets.X` to appear directly in
step-level `if:` expressions — only `env.X` is valid in that context.
Both ci.yml and security-scan.yml had Slack-notify steps gated on
`secrets.SLACK_WEBHOOK_URL != ''`, which made the entire workflow
fail to parse. Result: every push to main produced a 0-second failure
with 0 jobs run, masquerading as a CI signal that wasn't actually
running CI.

Confirmed root cause via:
  gh api -X POST repos/.../actions/workflows/167079093/dispatches \
    -f ref=main
  → 422 Invalid Argument - failed to parse workflow:
    (Line: 315, Col: 11): Unrecognized named-value: 'secrets'

Fix: promote the secret to job-level `env:` so step-level `if:`
references `env.SLACK_WEBHOOK_URL`. The actual secret value still
flows through unchanged for the action's runtime use.

Same pattern applied to security-scan.yml line 406 (the existing
SECURITY_SLACK_WEBHOOK_URL gate).

After this lands, every push to main should produce real CI runs
that actually execute jobs and reflect repo health honestly. The
runs may still fail for *real* reasons (e.g., CI image dependencies,
test gaps), but they will fail visibly with logs instead of in 0s
with no jobs.
2026-04-25 23:06:27 -04:00