Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] b2bb5fec67 chore: update vendor submodules to latest upstream 2026-06-16 08:07:32 +00:00
199 changed files with 16014 additions and 7558 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ on:
env:
PYTHON_VERSION: '3.11'
NODE_VERSION: '20' # ADR-265: all Node packages in this repo declare engines >= 20
NODE_VERSION: '18'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
-148
View File
@@ -1,148 +0,0 @@
# ADR-265 D1 — the npm-package gate.
#
# Every Node package in this repo (published or private) gets: install, build,
# tests, a version-literal gate (D3 — package.json is the only place a version
# lives), a pack-content gate (no source maps, unpacked-size budget), a
# tarball-install smoke test (would have caught ADR-264 F1's broken `require`
# export), and the claim-check honesty lint on the README (D4).
name: npm packages
on:
push:
branches: [main]
paths:
- 'harness/ruview/**'
- 'tools/ruview-mcp/**'
- 'tools/ruview-cli/**'
- '.github/workflows/npm-packages.yml'
pull_request:
paths:
- 'harness/ruview/**'
- 'tools/ruview-mcp/**'
- 'tools/ruview-cli/**'
- '.github/workflows/npm-packages.yml'
permissions:
contents: read
jobs:
gate:
name: ${{ matrix.package.dir }} (node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['20', '22']
package:
- dir: harness/ruview
build: false
publishable: true
# ADR-263: dependency-free harness; budget guards against dep creep.
unpacked_budget: 65536
- dir: tools/ruview-mcp
build: true
publishable: true
# ADR-264 O2: map-free tarball (was 188 kB with maps).
unpacked_budget: 140000
- dir: tools/ruview-cli
build: true
publishable: false
unpacked_budget: 0
defaults:
run:
working-directory: ${{ matrix.package.dir }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
# Repo policy gitignores lockfiles under harness/ (the harness is
# dependency-free anyway); the TS packages commit theirs.
- name: Install
run: |
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
- name: Build
if: ${{ matrix.package.build }}
run: npm run build
- name: Test
run: npm test --if-present
# ADR-265 D3 — package.json is the only place a version string lives.
- name: Version-literal gate
run: |
set -euo pipefail
hits=""
for d in src bin; do
if [ -d "$d" ]; then
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
fi
done
if [ -n "$hits" ]; then
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
echo "$hits"
exit 1
fi
# ADR-265 D1.3 — pack-content gate: no maps, size budget enforced.
- name: Pack gate
if: ${{ matrix.package.publishable }}
run: |
npm pack --dry-run --json 2>/dev/null | node -e "
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const budget = Number(process.env.UNPACKED_BUDGET);
const maps = info.files.filter((f) => f.path.endsWith('.map'));
if (maps.length > 0) {
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
process.exit(1);
}
if (info.unpackedSize > budget) {
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
process.exit(1);
}
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
"
env:
UNPACKED_BUDGET: ${{ matrix.package.unpacked_budget }}
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
- name: Tarball smoke test
if: ${{ matrix.package.publishable }}
run: |
set -euo pipefail
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
SMOKE="$(mktemp -d)"
cd "$SMOKE"
npm init -y > /dev/null
npm i --no-fund --no-audit "$TGZ"
case "${{ matrix.package.dir }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
;;
tools/ruview-mcp)
# initialize over stdio; server must answer and exit 0 on EOF
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
# the ESM export must resolve from the installed tarball (ADR-264 F1)
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
;;
esac
# ADR-265 D4 — package READMEs must pass the project's own honesty lint.
- name: Claim-check README
run: |
if [ -f README.md ]; then
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
else
echo "no README.md — skipping"
fi
-137
View File
@@ -1,137 +0,0 @@
# ADR-265 D2 — publish only from CI, with provenance.
#
# Manual `npm publish` from laptops stops: this workflow re-runs the ADR-265 D1
# gate for the selected package and then publishes with npm provenance
# attestations (OIDC), tying every published version to a public commit +
# workflow run — the npm-side analogue of the ADR-028 witness bundle.
#
# Requires: NPM_TOKEN repo secret (an npm automation token), or npm Trusted
# Publishing configured for the package (in which case the token is unused).
name: ruview npm release
on:
workflow_dispatch:
inputs:
package:
description: 'Package directory to publish'
required: true
type: choice
options:
- harness/ruview
- tools/ruview-mcp
dist_tag:
description: 'npm dist-tag'
required: false
default: 'latest'
type: string
permissions:
contents: read
id-token: write # npm --provenance
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.package }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install
run: |
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
- name: Build (if present)
run: npm run build --if-present
- name: Test
run: npm test --if-present
# ADR-265 D3 — package.json is the only place a version string lives.
- name: Version-literal gate
run: |
set -euo pipefail
hits=""
for d in src bin; do
if [ -d "$d" ]; then
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
fi
done
if [ -n "$hits" ]; then
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
echo "$hits"
exit 1
fi
# ADR-265 D1.3 — pack-content gate: no maps AND the per-package
# unpacked-size budget (the budgets that npm-packages.yml enforces).
- name: Pack gate (no maps + size budget)
run: |
set -euo pipefail
case "${{ inputs.package }}" in
# ADR-263: dependency-free harness; budget guards against dep creep.
harness/ruview) export UNPACKED_BUDGET=65536 ;;
# ADR-264 O2: map-free tarball (was 188 kB with maps).
tools/ruview-mcp) export UNPACKED_BUDGET=140000 ;;
*) echo "Unknown package '${{ inputs.package }}' — no budget defined"; exit 1 ;;
esac
npm pack --dry-run --json 2>/dev/null | node -e "
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const budget = Number(process.env.UNPACKED_BUDGET);
const maps = info.files.filter((f) => f.path.endsWith('.map'));
if (maps.length > 0) {
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
process.exit(1);
}
if (info.unpackedSize > budget) {
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
process.exit(1);
}
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
"
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
- name: Tarball smoke test
run: |
set -euo pipefail
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
SMOKE="$(mktemp -d)"
cd "$SMOKE"
npm init -y > /dev/null
npm i --no-fund --no-audit "$TGZ"
case "${{ inputs.package }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
;;
tools/ruview-mcp)
# initialize over stdio; server must answer and exit 0 on EOF
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
# the ESM export must resolve from the installed tarball (ADR-264 F1)
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
;;
esac
- name: Claim-check README
run: |
if [ -f README.md ]; then
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
fi
- name: Publish (with provenance)
run: npm publish --provenance --access public --tag "${{ inputs.dist_tag }}"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
features:
- { label: 'default', flags: '--no-default-features' }
- { label: 'train', flags: '--features train' }
- { label: 'ruflo', flags: '--features ruflo' }
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
-14
View File
@@ -277,17 +277,3 @@ aether-arena/staging/
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
assets/MM-Fi/E0*.zip
assets/MM-Fi/*.zip
# through-wall demo: regenerable trained model artifact
examples/through-wall/model/
# RuView harness (npx ruview) build artifacts — ADR-182
harness/**/node_modules/
harness/**/*.tgz
harness/**/package-lock.json
harness/**/.claude-flow/
harness/**/ruvector.db
# ruvector runtime/hook DB — never tracked (any depth)
ruvector.db
**/ruvector.db
-8
View File
@@ -21,11 +21,3 @@
[submodule "vendor/rufield"]
path = vendor/rufield
url = https://github.com/ruvnet/rufield
[submodule "v2/crates/ruview-swarm"]
path = v2/crates/ruview-swarm
url = https://github.com/ruvnet/ruv-drone.git
branch = main
[submodule "v2/crates/worldgraph"]
path = v2/crates/worldgraph
url = https://github.com/ruvnet/worldgraph.git
branch = main
-11
View File
@@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Fixed
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn``ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
- **`csi_fps_ema` reported the CSI frame rate 40840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
- **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4).
- **Multistatic fusion guard interval is now operator-configurable — fixes permanent trust demotion with WiFi-synced ESP32 nodes (#1049).** Two independently-clocked ESP32-S3 boards on ESP-NOW sync drift 10150 ms (typ. ~70 ms) — the 100 ms beacon + WiFi-MAC jitter cannot hold them within the published 60 ms default guard, so the governed-trust cycle permanently demoted to `Restricted`, suppressed all pose output, and spun the error counter to 200k+ with **no escape hatch but a container restart**. Added a **direct `WDP_GUARD_INTERVAL_US` override** (+ optional `WDP_SOFT_GUARD_US`) to `multistatic_guard_config_from_env`, so a deployment can lift the hard guard past its measured spread (e.g. `WDP_GUARD_INTERVAL_US=200000`) without having to know its exact TDM schedule. Precedence is most-specific-wins: a direct override beats the existing `WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US` schedule-derived guard, which beats the 60 ms/20 ms default; the override is applied on top of whichever base is selected, the soft band is always clamped strictly below the hard guard, and a malformed/zero value is ignored (falls back to the base rather than breaking fusion). The effective guard is now logged at startup. Pinned by 6 new tests (`multistatic_guard_config_tests`): direct-override-wins / beats-TDM-derived / soft-clamped-below-hard / lowering-hard-pulls-soft-down / malformed-or-zero-falls-back / default-when-unset. `wifi-densepose-sensing-server` bin tests **449 → 455**, 0 failed; Python proof VERDICT PASS, hash unchanged (off the signal proof path).
### Security
- **`wifi-densepose-occworld-candle` — beyond-SOTA security + correctness review (Milestone #9, crate 4/4).** (1) **HIGH (MEASURED) — checkpoint-load crash on any int32 tensor** (`model.rs::safetensor_dtype_to_candle`). `safetensors::Dtype::I32` was mapped to `candle_core::DType::I64` and the raw int32 byte buffer (4 bytes/elem) was then handed to `Tensor::from_raw_buffer(.., I64, shape, ..)`. Candle derives `elem_count = data.len() / dtype.size_in_bytes()`, so the I64 path halved the element count while keeping the *original* shape — yielding a tensor whose declared shape claims twice as many elements as its backing storage holds. Reading it **panics** (`range end index 6 out of range for slice of length 3` — slice OOB inside candle-core) on any attacker-supplied or PyTorch-exported checkpoint containing an int32 tensor (common: index/buffer tensors). Fixed by mapping `I32 → DType::I32` (and `I16 → DType::I16`), both first-class candle dtypes. Reproduction recorded on old code; pinned by `tests/checkpoint_loading.rs::int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new) plus F32/I64/corrupt-file control cases. (2) **LOW (MEASURED) — `predict()` lacked frame/batch validation at the input boundary** (`inference.rs`). It validated H/W/D but not the externally-supplied frame count; an `f_in > num_frames*2` over-indexed the temporal positional embedding deep in the transformer and surfaced as a cryptic candle "gather" `InvalidIndex` (returned error, not a panic — candle bounds-checks), and a zero frame/batch dim fed a zero-element tensor into the pipeline. Now rejected at the boundary with a clear `ShapeMismatch`. Pinned by `predict_rejects_zero_frames` / `predict_rejects_too_many_frames` / `predict_accepts_frame_count_at_capacity`. (3) **LOW (MEASURED) — divide-by-zero panic on a degenerate input to the public `VQCodebook::encode`** (`vqvae.rs`): a rank-0 / empty-last-dim tensor made `last == 0` and panicked on `elem_count() / last`. Now fails closed with a clear error. Pinned by `encode_rejects_scalar_without_panicking`. **Dimensions confirmed CLEAN with evidence:** panic surface — zero `unwrap()`/`expect()`/`panic!`/`unreachable!` in production code paths (grep evidence; all error handling via `?`/`map_err`); NaN-state-poisoning — N/A (engine is stateless between `predict` calls, input is `u8` class indices so non-finite input is structurally impossible, no persistent world-model buffer to latch into); unbounded-alloc / shape-data mismatch from malformed weights — defended upstream by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared byte range, rejected before reaching candle); secrets — none (grep clean, only `token_h`/`token_w` config fields match). `unsafe_code = forbid` in the crate manifest. **Build/validation status (MEASURED on Windows):** crate builds and tests under `cargo test -p wifi-densepose-occworld-candle --no-default-features`**29/29 pass** (20 unit + 4 checkpoint_loading + 3 predict_honesty + 2 doc) after fixes; `cargo test --workspace --no-default-features` = 0 failed across all crates (lone `wifi-densepose-desktop` `api_integration` failure was a Windows "Access is denied (os error 5)" file-lock flake — re-ran in isolation **21/21 pass**); Python proof VERDICT PASS, hash `f8e76f21…446f7a` unchanged. *Warrants ADR slot 179 (parent to author).*
- **`wifi-densepose-wasm-edge` beyond-SOTA closing review — boundary NaN-state-poisoning guard + clean-with-evidence attestation (ADR-040 edge crate, ~70 modules).** Closing pass of the security campaign over the last untouched sizeable crate. **One real finding fixed (LOW / source-analysis + reproduced):** the two WASM↔host frame boundaries (`lib.rs::on_frame`/`on_timer` and `bin/ghost_hunter.rs::on_frame`) read raw IEEE-754 `f32` from the `csi_get_phase`/`csi_get_amplitude`/`csi_get_variance`/`csi_get_motion_energy` host imports **without any finiteness check** — the entire crate had **zero** `is_finite`/`is_nan` guards, and the in-crate `clamp` helpers propagate NaN (`NaN < lo` and `NaN > hi` are both false). A single non-finite value (firmware DSP bug, uninitialised buffer, or hostile host) latches NaN into the long-lived per-module accumulators (EMA, Welford, phasor sums, anomaly baselines); once latched, every downstream comparison evaluates `false`, so detectors fail **degraded** (stuck gate state, silently-disabled anomaly checks) — silent corruption, not a crash (WASM `panic=abort` is *not* tripped: no indexing/`unwrap` on the poisoned value). Threat model is a **semi-trusted** boundary (the Tier-2 DSP firmware supplies the imports, not direct network/JS), hence LOW severity / defense-in-depth. **Fix:** added `sanitize_host_f32()` (maps non-finite→`0.0`, `core`-only so it holds in `no_std`) applied at every `host_get_*` float read — a single chokepoint covering all ~70 downstream modules, mirroring the existing M-01 negative-`n_subcarriers` boundary clamp. **Pinned by** `boundary_tests::{sanitize_passes_finite_values_through, sanitize_maps_non_finite_to_zero, coherence_monitor_nan_latches_without_sanitize_but_not_with}` — the last asserts on the *current* `CoherenceMonitor` that a raw NaN frame latches the smoothed score (documents the hazard) while the boundary-sanitized path stays finite. **Dimensions attested CLEAN with evidence (source-analysis):** (a) **panic-on-input** — every non-test `unwrap()`/`expect()` is either `#[cfg(test)]` or in the `std`-gated RVF *builder* host tool writing to an in-memory `Vec` (infallible); no `panic!`/`unreachable!`/`todo!`/`get_unchecked` in any hot path. (b) **shape/bounds** — all frame-buffer access is `min()`-clamped (`MAX_SC=32`, `DTW_MAX_LEN`, `LCS_WINDOW`, `PATTERN_LEN`), all index-by-cast sites (`feature_id as usize`, `conclusion_id`, `minute_counter`, `plan_step`) are either compile-time-const-bounded or `if idx <`/`%`-guarded; negative `n_subcarriers` already mapped to 0 (M-01). (c) **memory/leak** — no `move ||` closures, no `mem::forget`/`Box::leak`/`.leak()`; the only `Box::new` is in the `std`-gated `skill_registry` (one-time init, bounded). (d) **secrets** — none (grep clean). **MEASURED build/test evidence:** host `cargo test --features std,medical-experimental` = **672 passed / 0 failed** (was 669 pre-fix; +3 new tests); the real deployment artifacts all build clean on the actual target — `cargo build --target wasm32-unknown-unknown --release` (no_std/panic=abort default lib), `--bin ghost_hunter --no-default-features --features standalone-bin`, and `--features medical-experimental` (toolchain 1.89 per `rust-toolchain.toml`). No ADR slot needed — a single LOW defense-in-depth boundary fix; CHANGELOG attestation suffices.
@@ -30,7 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32``f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
### Added
- **ADR-263/264/265: deep review of the RuView npm surface (`@ruvnet/ruview`, `@ruvnet/rvagent`, `@ruv/ruview-cli`) with optimization strategies recorded as ADRs.** ADR-263 reviews the published `@ruvnet/ruview@0.1.0` harness: fail-open `claim-check` on empty input (HIGH), `spawnSync` head-of-line blocking of the MCP stdio server during long `verify`/`calibrate` runs (HIGH), optionalDependencies tripling the cold `npx` install for a code path that never uses them (MEASURED, `npm i` in a clean prefix: 4 packages / 620 kB / 71 files default vs 1 package / 172 kB / 22 files with `--omit=optional`), 1 MiB `maxBuffer` truncation risk, `python -c` port-interpolation surface in `node_monitor`, hardcoded MCP server version, duplicated skill payload — optimizations O1O8. ADR-264 reviews `@ruvnet/rvagent@0.1.0` + the private CLI **against the published registry tarball**: `exports.require` → nonexistent `dist/index.cjs` (HIGH, every CJS consumer breaks), 44 dead source-map files = 62,698 B of the 188 kB unpacked payload pointing at unshipped `../src` (MEASURED), stdio-only server described as "dual-transport" (CLAIMED capability), mixed dot/underscore tool naming, double Zod validation + hand-duplicated advertised schemas, 2-fd leak per training job, unbounded request body in the unwired HTTP scaffold, dead `detectCogBinary` candidate list, `ruview` bin-name collision — optimizations O1O9. ADR-265 adds the cross-cutting distribution layer: an `npm-packages.yml` CI matrix (tests + pack-content/size gate + tarball-install smoke test — none of the three packages currently has any CI, and `ci.yml` pins Node 18 against `engines >= 20`), publish-from-CI-only with `npm publish --provenance`, version single-sourcing from package.json, bin/namespace ownership (the `ruview` bin belongs to `@ruvnet/ruview`), and claim-check enforcement on package READMEs/descriptions. Docs only — no runtime code changed; the findings are the work orders for the follow-up PRs.
- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing``breathing_bpm`, `heartbeat``heart_bpm`, `None``null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 files green** — import-graph, boot, render-smoke (22), interaction (3), and a **new prod-errors suite (13)** that runs with demo OFF + gateway unreachable and proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live**`GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs``[]` (no apps dir), SEED-tier → typed `503`, and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` adapts `RoomState` to the UI shape (`breathing``breathing_bpm`, `heartbeat:null``heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status.
- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4``--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge.
- **ADR-175: int8 quantization of the WiFlow-STD "half" pose model — MEASURED fp32-vs-int8 accuracy/size trade-off (honest negative).** Sub-deliverable 8.2 of the benchmark/optimization milestone, and the reading of the SOTA brief's "one untested edge lever" (QAT-int8 on the 843,834-param half model that strictly dominates the published 2.23M model). A new committed script `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py` quantizes `half_best.pth` to int8 two ways and scores both with the **same** upstream `calculate_pck`/`calculate_mpjpe` that produced the fp32 sweep numbers, under **one locked normalization** (ADR-173 torso-diameter PCK — neck idx2→pelvis idx12, `use_torso_norm=True`, the standard MM-Fi/GraphPose-Fi convention), on the **same** seed-42 file-level 70/15/15 test split (52,560 NaN-free / 54,000 full windows). **MEASURED on ruvultra (RTX 5080, torch 2.11.0+cu128, fbgemm; clean test, torso-PCK):** fp32 = 96.62% PCK@20 / 99.47% PCK@50 / 0.008981 MPJPE / 3.351 MB (fp32-CPU reproduces fp32-GPU to 4 dp, so the int8 deltas are pure quantization, not CPU/GPU drift); **int8 static PTQ = 40.98% PCK@20 (55.64 pp), 1.046 MB** — naive static QDQ **collapses** on this model (the brief's 2.23M "sweet spot" does NOT transfer to the 843k half model at the tight @20 threshold); **int8 QAT (3-epoch FX fake-quant fine-tune from half_best) = 67.48% PCK@20 (29.15 pp) / 98.69% PCK@50 (0.78 pp), 1.043 MB.** **Verdict (honest no):** int8 is **not a win** at the strict PCK@20 edge target — QAT recovers a large share of the PTQ collapse and is near-lossless at the loose PCK@50 (coarse localization survives int8, fine does not), but a **3.2× size win at 29 pp PCK@20** is a bad trade when the half model already fits edge flash at fp32 → **keep fp32/fp16 on the edge for now.** **Disclosed gap:** the QAT *fake-quant* val PCK@20 reached 83.45% but the *converted* int8 model scores 67.48% — a real ~16 pp `convert_fx` gap (fbgemm int8 kernels ≠ straight-through estimate, esp. the axial-attention einsum/softmax); we report the converted-int8 number, not the fake-quant proxy. **MEASURED:** every table number + the PTQ collapse + the QAT partial recovery + the conversion gap. **CLAIMED/not done:** ONNX/TFLite export, on-edge-SoC latency/energy (int8 measured on x86 fbgemm — size transfers, latency does NOT), mixed-precision keeping attention fp32, longer/better-tuned QAT. **Honest limitations:** single in-domain eval split (no cross-environment split), x86-int8 not edge-SoC-int8, lightly-tuned QAT. Additive only — no production Rust or signal-pipeline change; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — off the signal proof path).
+1 -5
View File
@@ -62,7 +62,7 @@ All 5 ruvector crates integrated in workspace:
- `ruvector-attention``model.rs` (apply_spatial_attention) + `bvp.rs`
### Architecture Decisions
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). Key ones:
43 ADRs in `docs/adr/` (ADR-001 through ADR-043). Key ones:
- ADR-014: SOTA signal processing (Accepted)
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
- ADR-016: RuVector training pipeline integration (Accepted — complete)
@@ -77,10 +77,6 @@ All 5 ruvector crates integrated in workspace:
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.12.3, 2.6 implemented)
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
- ADR-182: `npx ruview` harness minted via MetaHarness (Accepted — P1+P2 shipped as `@ruvnet/ruview`)
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
### Supported Hardware
+8 -8
View File
@@ -51,26 +51,26 @@ verify-audit:
# ─── Rust Builds ─────────────────────────────────────────────
build-rust:
cd v2 && cargo build --release
cd rust-port/wifi-densepose-rs && cargo build --release
build-wasm:
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release
build-wasm-mat:
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
test-rust:
cd v2 && cargo test --workspace --no-default-features
cd rust-port/wifi-densepose-rs && cargo test --workspace
bench:
cd v2 && cargo bench --package wifi-densepose-signal
cd rust-port/wifi-densepose-rs && cargo bench --package wifi-densepose-signal
# ─── Run ─────────────────────────────────────────────────────
run-api:
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000
run-api-dev:
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
run-viz:
python3 -m http.server 3000 --directory ui
@@ -81,7 +81,7 @@ run-docker:
# ─── Clean ───────────────────────────────────────────────────
clean:
rm -f .install.log
cd v2 && cargo clean 2>/dev/null || true
cd rust-port/wifi-densepose-rs && cargo clean 2>/dev/null || true
# ─── Help ────────────────────────────────────────────────────
help:
+1 -4
View File
@@ -601,8 +601,6 @@ claude --plugin-dir ./plugins/ruview
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
**Portable harness — `npx @ruvnet/ruview`:** a lighter, host-portable companion to the in-repo plugin, minted via [MetaHarness](https://www.npmjs.com/package/metaharness) and hardened per [ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md). It runs **without cloning this repo** and on more hosts (Claude Code, Codex, Copilot, opencode, …), exposing the RuView operator tools (`onboard`, `verify`, `node_monitor`, `calibrate`, `node_flash`) over an MCP server — plus the project's **MEASURED-vs-CLAIMED honesty guardrail enforced in code** (`ruview.claim_check` flags untagged or retracted-"100%" accuracy claims). v0.1: the onboarding/verify/claim-check paths are tested (17/17, `verify.py` → PASS); the hardware tools are fail-closed wrappers. Try `npx @ruvnet/ruview` to onboard, or `npx @ruvnet/ruview claim-check --text "…"`. Source: [`harness/ruview/`](harness/ruview/README.md).
---
## 📖 Documentation
@@ -616,8 +614,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
Binary file not shown.
@@ -1,279 +0,0 @@
# ADR-182: `npx ruview` — A RuView Agent Harness Minted via MetaHarness
| Field | Value |
|-------|-------|
| **Status** | Accepted — **P1+P2 implemented & validated** (`harness/ruview/`, 17/17 tests, MCP handshake + `ruview.verify` PASS against the real repo, packs to 16.7 kB / 21 files) · P3 publish-ready (name decision pending) · P4 (router + provenance) designed |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-HARNESS** |
| **Builds on** | MetaHarness (`metaharness@0.1.15`, `@metaharness/kernel`, `@metaharness/host-*`, `@metaharness/router`), the `ruview-*` Claude Code subagents (`ruview-onboarding-guide`, `ruview-config-engineer`, `ruview-training-engineer`), the `wifi-densepose` CLI (`calibrate`/`enroll`/`train-room`/`room-watch`), the sensing-server, ADR-028 (witness verification), ADR-095/096 (rvCSI runtime), ADR-260/262 (RuField bridge) |
| **Supersedes** | none |
## Context
RuView (WiFi-DensePose) is a deep stack — 15 Rust crates, an ESP32 firmware line,
a sensing-server, a CLI, ~180 ADRs, a calibration pipeline, training recipes, and a
hard cultural rule that **every claim must be independently reproducible** (the
"prove everything" ethos, after the project was accused of AI-slop). The barrier to
entry is correspondingly steep: a newcomer who wants to "set up WiFi sensing" must
discover the right firmware variant, provision an ESP32 over a Windows-only Python
subprocess, point it at the sensing-server, run `calibrate``enroll`
`train-room`, and know which numbers are MEASURED vs CLAIMED. We already encode this
knowledge as **Claude Code subagents** (`ruview-onboarding-guide`,
`ruview-config-engineer`, `ruview-training-engineer`) — but those only exist inside
*this* repo's `.claude/agents/`, only on Claude Code, and only for someone who has
already cloned the monorepo.
Separately, this session shipped **MetaHarness** (`metaharness@0.1.15`): a tool that
*"mints a custom AI agent harness from any repo"*, runnable on **9 hosts**
(claude-code, codex, pi-dev, hermes, openclaw, rvm, copilot, opencode,
github-actions) over a wasm-primary / NAPI-RS-fallback **kernel**, with a
**cost-optimal model router** (`@metaharness/router`, the productized DRACO Phase-2
k-NN finding) and ed25519/SLSA/SBOM provenance baked in. Crucially, MetaHarness
**already ships a `vertical:ruview` template** in its template list. That template
is generic scaffolding; it is not wired to RuView's actual tools, agents, or the
"prove everything" guardrails.
The gap: **there is no single, host-portable, provenance-signed entry point that
gives any user an AI agent that actually knows how to operate RuView.** A user
should be able to run one command —
```bash
npx ruview
```
— in an empty directory (or alongside an ESP32) and get an agent harness that can
onboard them, configure firmware, drive a live capture, train a room model, and
**refuse to overstate accuracy** — on whichever coding host they already use.
## Decision
**Mint a first-class RuView agent harness from this repo using MetaHarness, harden
its `vertical:ruview` template into a RuView-specific harness with a real MCP tool
surface and the project's honesty guardrails, and publish it as `npx ruview`.**
`npx ruview` is *not* a new runtime. It is a **thin, versioned distribution** of a
MetaHarness harness: the kernel + host adapters + a RuView "genome" (skills, agents,
MCP tools, guardrails) generated from and pinned against this monorepo. The harness
is the product; `npx ruview` is the front door.
### Why mint-from-repo instead of hand-writing a harness
MetaHarness's value here is exactly the work we would otherwise hand-roll across 9
hosts: host-specific config (`.claude/settings.json` MCP + hooks for claude-code,
the codex/copilot/opencode equivalents), the kernel that abstracts wasm-vs-native,
the cost router, and the provenance chain. We write the **RuView knowledge once** as
host-neutral genome assets; MetaHarness projects them onto each host adapter. This
also keeps the harness regenerable: when the CLI or an ADR changes, re-mint and
re-pin rather than maintaining 9 divergent copies.
### What the harness contains (the RuView genome)
1. **Skills / playbooks** (host-neutral markdown, projected to each host's skill
format):
- `onboard` — zero-to-sensing path picker (Docker demo / repo build / live
ESP32), the physics caveats, the hardware table. Port of
`ruview-onboarding-guide`.
- `provision-node` — ESP-IDF v5.4 Windows-subprocess build/flash/provision flow
(the exact MSYSTEM-stripped invocation from `CLAUDE.local.md`), firmware
variant selection (8MB display / 4MB no-display / C6), NVS + WiFi + channel /
MAC-filter overrides (ADR-060).
- `calibrate-room``baseline → enroll → extract → train` via the
`wifi-densepose` CLI (`calibrate`/`calibrate-serve`/`enroll`/`train-room`/
`room-watch`, ADR-151).
- `train-pose` — camera-supervised + camera-free training, the MEASURED-vs-CLAIMED
discipline, the mean-pose baseline check (ADR-079, ADR-152, ADR-181).
- `verify` — run the witness bundle + Python proof (`verify.py` → VERDICT: PASS),
ADR-028.
- Ports of `ruview-config-engineer` and `ruview-training-engineer`.
2. **MCP tool surface** (`@metaharness/kernel`-hosted MCP server, one schema per
capability — see "MCP tools" below). This is what makes the harness *operate*
RuView, not just talk about it.
3. **Guardrails** (the differentiator): the harness's system prompt and a
pre-output hook enforce the "prove everything" rule — accuracy numbers must be
tagged MEASURED (with a reproducer) or CLAIMED; the agent must run the mean-pose
baseline before quoting PCK; firmware fixes are never presented as
hardware-validated without a real boot log (the exact discipline this session
followed for `v0.8.1-esp32`).
4. **Host adapters** — claude-code first (P1), then codex / opencode / copilot /
pi-dev / hermes / rvm / github-actions (P3+), each via the published
`@metaharness/host-*` package.
5. **Router**`@metaharness/router` routes each step to the cheapest adequate
model (e.g. a var-rename or a log-grep → Haiku; calibration-math reasoning or a
security review → Sonnet/Opus), mirroring the repo's 3-tier routing (ADR-026).
### MCP tools (the operational surface)
| Tool | Wraps | Purpose |
|------|-------|---------|
| `ruview.onboard` | docs + agent | Pick a setup path, print the next concrete command |
| `ruview.node.flash` | ESP-IDF subprocess (ADR `CLAUDE.local.md`) | Build + flash a firmware variant to a COM port |
| `ruview.node.provision` | `provision.py` | Set SSID/password/target-ip/channel/MAC-filter over serial |
| `ruview.node.monitor` | pyserial | Stream boot log; assert CSI is flowing (MGMT+DATA) |
| `ruview.server.up` | sensing-server | Start the Axum sensing-server (`:3000`/`:5005`/`:8765`) |
| `ruview.calibrate` | `wifi-densepose calibrate`/`enroll`/`train-room` | Run the ADR-151 room pipeline |
| `ruview.room.watch` | `wifi-densepose room-watch` | Live presence/vitals from a trained room |
| `ruview.verify` | `scripts/generate-witness-bundle.sh` + `verify.py` | Produce/verify the witness bundle (must be N/N PASS) |
| `ruview.claim.check` | static lint | Scan output for untagged accuracy claims; flag MEASURED-vs-CLAIMED |
Each tool returns structured JSON and is fail-closed: a tool that cannot prove its
result (e.g. `ruview.node.monitor` sees no CSI callbacks) returns an honest negative,
never a fabricated success — consistent with the RuField `map_privacy` fail-closed
posture (ADR-262 §3.3).
### The mint + pin flow (how the harness is produced)
```bash
# P1 — mint from this repo, claude-code host, RuView vertical
npx metaharness ruview --template vertical:ruview --host claude-code \
--from-existing . --description "RuView WiFi-sensing operator agent" \
--target ./harness/ruview
# readiness + fit/cost/safety scorecards (ADR-041) — gate before publish
npx metaharness genome . # 7-section repo readiness
npx metaharness score . --json # fit / cost / safety
npx metaharness analyze . # recommended harness plan (no-exec)
```
The minted harness is committed under `harness/ruview/` and **pinned** (kernel +
host-adapter + router versions locked) so `npx ruview` is reproducible. Re-minting on
a CLI/ADR change is a reviewed PR, not an implicit regeneration.
### Distribution: `npx ruview`
A small published package whose `bin` boots the pinned harness via the kernel:
- **Preferred name:** `ruview` (currently **free** on npm — verified 2026-06-17).
- **Risk:** npm's typosquat filter may reject `ruview` as too close to `review` /
`preview` (this session hit exactly that on `ruvn``levn`/`raven` and
`worldgraph``world-graph`). **Fallback:** publish scoped `@ruvnet/ruview` (also
free) and/or `npx ruvnet/ruview` straight from GitHub. Decide at publish time;
do not unpublish to rename (the 24-h name-lock lesson from `worldgraphs`).
- `bin: { "ruview": "bin/cli.js" }` — note **`bin/cli.js`, not `./bin/cli.js`** (npm
strips the `./` form; this broke `ruvn@0.1.0` this session).
- `npx ruview` with no args → `onboard` skill (interactive path picker).
`npx ruview <skill> [...]` → run a specific skill. `npx ruview --host codex`
install the harness into an existing repo for that host.
## Architecture
```
npx ruview (thin bin — boots the pinned harness)
@metaharness/kernel (wasm primary · NAPI-RS native fallback)
├── host adapter ── claude-code | codex | opencode | copilot | pi-dev | hermes | rvm | github-actions
├── @metaharness/router (k-NN cost-optimal model routing — DRACO P2 / ADR-026)
└── RuView genome (pinned)
├── skills onboard · provision-node · calibrate-room · train-pose · verify
├── mcp tools ruview.node.* · ruview.calibrate · ruview.room.watch · ruview.verify · ruview.claim.check
└── guardrails MEASURED-vs-CLAIMED · mean-pose baseline · no-unvalidated-firmware-claims
RuView assets (the real system the agent drives)
├── wifi-densepose CLI calibrate / enroll / train-room / room-watch
├── sensing-server :3000 / :5005 / :8765
├── ESP-IDF subprocess build / flash / provision / monitor (COM8/COM9/COM12)
└── witness bundle + verify.py
```
Provenance: the harness ships an **ed25519 witness + SBOM (SPDX) + SLSA** chain
(MetaHarness already does this for minted harnesses), so a recipient can verify the
RuView harness was built from a specific monorepo commit — the agentic analogue of
the firmware witness bundle (ADR-028).
## Phases
- **P1 — Mint & pin (claude-code).** `npx metaharness ruview --template
vertical:ruview --from-existing . --host claude-code`. Port the three `ruview-*`
subagents into host-neutral genome skills. Commit under `harness/ruview/`, pin
versions. Acceptance: `npx metaharness score .` ≥ threshold; the harness can run
`onboard` and `verify` end-to-end locally.
- **P2 — MCP tool surface.** Implement the `ruview.*` MCP tools over the kernel
(start with `onboard`, `verify`, `claim.check`, `node.monitor` — the read-only /
proving tools), then the mutating ones (`node.flash`, `provision`, `calibrate`).
Acceptance: `ruview.verify` returns the witness bundle PASS as structured JSON;
`ruview.claim.check` flags a seeded untagged "100% accuracy" string.
- **P3 — Publish `npx ruview` + multi-host.** Publish the bin package (name decision
per Distribution). Add codex / opencode / copilot / pi-dev / hermes / rvm /
github-actions adapters. Acceptance: `npx ruview` cold-starts on ≥3 hosts and runs
`onboard`; provenance verifies.
- **P4 — Router + guardrail hardening.** Wire `@metaharness/router`; calibrate the
3-tier routing on a RuView task set. Make the MEASURED-vs-CLAIMED guardrail a hard
pre-output gate. Acceptance: a benchmark of RuView tasks shows cost reduction vs
all-Opus with no quality regression; the guardrail blocks an untagged accuracy
claim in a red-team prompt.
## Consequences
**Positive**
- One reproducible, signed entry point (`npx ruview`) that operates RuView on the
host the user already has — onboarding goes from "clone a 15-crate monorepo" to a
single `npx`.
- The "prove everything" ethos becomes **executable**, not just documentation: the
harness *enforces* MEASURED-vs-CLAIMED and the mean-pose baseline.
- Knowledge written once (host-neutral genome) instead of 9× per host; regenerable
from the repo as the system evolves.
- Dogfoods MetaHarness on a hard real vertical, surfacing bugs back to
`agent-harness-generator` (this session already filed #9#13 there).
**Negative / risks**
- **Drift:** a pinned harness goes stale as the CLI/ADRs move; mitigated by a
re-mint-on-change PR ritual and a CI check that the genome's referenced
CLI flags still exist.
- **Surface area:** mutating MCP tools (`node.flash`, `provision`) touch hardware and
the network — must be permission-gated and fail-closed; the firmware-flash tool
must never claim hardware validation without a captured boot log.
- **Name/typosquat:** `ruview` may be rejected at publish; scoped fallback decided in
P3. Do not unpublish-to-rename.
- **Host parity:** not all 9 hosts support MCP + hooks equally; the guardrail gate
may degrade to advisory on weaker hosts — must be disclosed in the badge, not
hidden (same honesty principle as ADR-181's backend badge).
- **Windows-coupled tooling:** the ESP-IDF flow is Windows-subprocess-specific
today; the `node.*` tools are gated to that environment until a cross-platform
path exists.
## Alternatives considered
1. **Keep the `ruview-*` subagents repo-local (status quo).** Zero new surface, but
stays Claude-Code-only and clone-gated; no portable front door. Rejected — it's
the gap this ADR exists to close.
2. **Hand-write a bespoke `npx ruview` harness (no MetaHarness).** Full control, but
re-implements the kernel, 9 host adapters, the router, and the provenance chain
we already ship — months of duplicated work and 9 divergent configs to maintain.
Rejected.
3. **Use the generic `vertical:ruview` template as-is.** It's scaffolding with no
real tools or guardrails — it would *talk about* RuView without being able to
*operate* it or enforce honesty. Rejected as insufficient; P2 is precisely the
hardening that makes it real.
4. **Ship only an MCP server (no harness/host adapters).** Covers tools but not the
skills, routing, guardrails, or multi-host projection — a strictly smaller subset
of this design. Folded in as the P2 layer rather than the whole.
## Open questions
- Final published name: bare `ruview` vs scoped `@ruvnet/ruview` vs GitHub-only
`npx ruvnet/ruview` — resolve against the typosquat filter at P3.
- Does the harness bundle the `wifi-densepose` binary, shell out to a user-installed
one, or offer both? (Leaning: shell out; print install guidance if absent.)
- Where do the `node.*` hardware tools live for non-Windows users — defer, or wrap
the rvCSI runtime (ADR-095/096) which is cross-platform Rust?
- Should `ruview.verify` gate `npx ruview` self-tests in CI (harness can't publish if
the witness bundle regresses)?
- Relationship to the RuField MFS harness surface (ADR-260/262) — one harness with a
RuField skill, or a sibling `npx rufield`?
## References
- MetaHarness: `metaharness@0.1.15` (`npx metaharness`, templates incl.
`vertical:ruview`; hosts: claude-code/codex/pi-dev/hermes/openclaw/rvm/copilot/
opencode/github-actions), `@metaharness/kernel`, `@metaharness/router`,
`@metaharness/host-*`, repo `github.com/ruvnet/agent-harness-generator`.
- RuView subagents: `ruview-onboarding-guide`, `ruview-config-engineer`,
`ruview-training-engineer` (`.claude/agents/`).
- ADR-026 (3-tier model routing), ADR-028 (witness verification), ADR-041
(MetaHarness scorecards), ADR-060 (channel / MAC-filter overrides), ADR-079
(camera ground-truth training), ADR-095/096 (rvCSI runtime), ADR-151 (per-room
calibration), ADR-152/181 (WiFlow / browser pose), ADR-260/262 (RuField bridge).
@@ -1,98 +0,0 @@
# ADR-183: Onboard LED as a 40 Hz Gamma Stimulus, Colour-Mapped from Live CSI via `ruv-neural-viz`
| Field | Value |
|-------|-------|
| **Status** | Accepted — implemented & hardware-confirmed on ESP32-S3 N16R8 (COM8) |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **GAMMA-VIZ** |
| **Builds on** | `ruv-neural-viz::ColorMap` (now `no_std` — ruvnet/ruv-neural#3 / RuView#1126), the ESP32 edge `motion_energy` metric (`edge_processing.c`), PR #962 (WS2812 on GPIO 48) |
## Context
Two threads converged. (1) `ruv-neural-viz::ColorMap` — the viridis/cool-warm
palette the rUv-Neural stack uses to render brain-topology graphs — was `std`-only,
so it couldn't run on the ESP32. (2) The onboard WS2812 on the S3 CSI node was dead
weight: the firmware only cleared it on boot (and on the wrong pin for N16R8 — GPIO
38 vs the actual 48, see #962).
The ask: make the LED do something real and honest, using the project's own visual
capability — not a decorative blink. The natural fit is a **40 Hz gamma stimulus**
(the GENUS gamma-entrainment frequency from Alzheimer's light-therapy research)
whose **colour is driven by live sensed motion**, so the node's front panel is both
a known bio-stimulus waveform and a truthful readout of what the CSI is detecting.
## Decision
### Part A — make `ColorMap` `no_std`
`colormap.rs` is self-contained (no cross-crate deps), so expose it on `no_std`
targets. The only blockers were two `std`-only `f64` ops:
- `f64::round` / `f64::abs` → replaced with `core`+`alloc`-safe helpers `fround`
(round via `f64 as i64` truncation — a `core` cast, no `libm`) and `fabs`.
- `Vec`/`String`/`format!` → from `alloc`.
The graph-bound modules (`animation`/`ascii`/`export`/`layout`) and their heavy deps
move behind a default `std` feature; `--no-default-features` builds the crate `no_std`
and exposes only `colormap`. Output is **byte-identical** (8/8 colormap tests pass with
the same RGB values), so this is a pure portability change.
### Part B — the LED stimulus (firmware)
`firmware/esp32-csi-node/main/main.c`, on boot:
- WS2812 on **GPIO 48** (N16R8 / DevKitC-1 v1.1; GPIO 8 on C6).
- An `esp_timer` periodic at **12 500 µs toggles a square wave → 40 Hz, 50 % duty**
(full-on / full-off — a *perceptible* gamma flicker, not a colour drift).
- **ON-phase colour = live CSI motion.** Each ON phase reads `edge_get_vitals().motion_energy`,
normalises it (`/ LED_MOTION_FULLSCALE`, clamped `[0,1]`), and indexes a **60-step
viridis LUT generated from `ColorMap::viridis().map()`** — still = dark purple,
strong motion = yellow.
The LUT is baked from the real crate (Part A makes the same `ColorMap` embeddable
for a future direct FFI path once the ESP Rust toolchain is in CI). The colours are
therefore provably `ruv-neural-viz`'s, and the motion is provably real.
## Honesty (what it is and is not)
- **40 Hz is a real square-wave stimulus** (12.5 ms on / 12.5 ms off), not a label on
a colour sweep. It is *not* tied to any measured 40 Hz brain rhythm — it is an
*output* stimulus at the gamma frequency, not a readout of neural gamma.
- **Colour is a real CSI readout**`motion_energy` is the on-device phase-variance
motion metric the node already computes; no fabrication. At rest the LED sits at the
purple (low) end and flickers there.
- No therapeutic claim is made. 40 Hz GENUS entrainment is cited as the *origin of the
frequency choice*, not as a validated medical effect of this device.
## Consequences
**Positive**
- The LED is now an honest front-panel: gamma-frequency flicker + a live motion readout.
- `ColorMap` is embeddable (`no_std`), unblocking on-device use of the rUv-Neural
palette beyond this LED.
- Confirms #962's GPIO-48 fix visually (the LED lights on N16R8).
**Negative / risks**
- Changes the *default* firmware behaviour: the onboard LED animates instead of staying
off. Now **gated by `CONFIG_LED_GAMMA_VIZ`** (default `y`); set it `n` for a dark,
lower-power boot (the LED is just cleared) — no source change needed.
- A 40 Hz flicker can be an issue for photosensitive users; document on the enclosure
and disable `CONFIG_LED_GAMMA_VIZ` in those deployments.
- The saturation point is now `CONFIG_LED_MOTION_FULLSCALE_MILLI` (default 250 = 0.25),
operator-tunable; still not auto-calibrated per-environment.
- The colour uses a baked LUT, not the live Rust `ColorMap` (FFI path deferred — needs
the ESP Rust/xtensa toolchain, not yet in CI).
## Validation
- `ruv-neural-viz`: `cargo build` (std) ✓, `cargo test colormap` 8/8 ✓ (identical RGB),
`cargo build --no-default-features` compiles `no_std` ✓.
- Firmware: built (1.13 MB), flashed to ESP32-S3 N16R8 (COM8). Boot log:
`Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO 48`;
CSI continues (2738 pps), `motion=0.00` at rest → purple flicker as designed.
- Full on-device (xtensa) Rust build of `ColorMap` not run — ESP Rust toolchain absent.
## References
- ruvnet/ruv-neural#3 (ColorMap no_std), RuView#1126 (submodule bump), #962 (GPIO 48).
- Singer/Tsai GENUS 40 Hz gamma entrainment (origin of the frequency, not a device claim).
@@ -1,191 +0,0 @@
# ADR-263: `@ruvnet/ruview` npm Harness — Deep Review + Optimization Strategy
| Field | Value |
|-------|-------|
| **Status** | Accepted — **implemented** (O1O9, `@ruvnet/ruview@0.2.0`): fail-closed `claim-check`, async MCP dispatch (ping answered mid-`verify`, pinned by e2e test), zero-dependency install, bounded output tails, argv-passed monitor port, package.json-sourced version, prepack skill sync, memoized `which()`, underscore-canonical tools with dotted aliases, word-boundary guardrail matching. 30/30 tests (MEASURED, `node --test test/*.test.mjs`); CI gate in ADR-265's `npm-packages.yml` |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-REVIEW-1** |
| **Supersedes / amends** | none (records review of the ADR-182 P1+P2 artifact; feeds ADR-265 distribution strategy) |
## Context
ADR-182 minted and published **`@ruvnet/ruview@0.1.0`** (`harness/ruview/`) — the
`npx ruview` operator harness: a dependency-free ESM CLI + minimal MCP stdio server
exposing six `ruview.*` tools (onboard / claim_check / verify / node_monitor /
calibrate / node_flash), five skill playbooks, and the executable
MEASURED-vs-CLAIMED guardrail (`src/guardrails.js`). The package is live on npm
(0.1.0, 49.5 kB unpacked / 21 files — MEASURED, `npm view @ruvnet/ruview` +
`npm pack --dry-run`) and is the recommended MCP registration path
(`npx -y @ruvnet/ruview mcp start` in the bundled `.claude/settings.json`).
This ADR is the first dedicated deep review of that npm artifact: correctness,
fail-open/fail-closed posture, performance (cold start + request handling),
packaging hygiene, and security of the subprocess surface. All 17 bundled tests
pass on Node 22 (MEASURED, `node --test test/*.test.mjs`, 17/17, ~108 ms).
## Findings
Severity reflects impact on the package's stated contract: *fail-closed operator
tools + an honesty guardrail that must never fail open*.
### F1 (HIGH, fail-open): `claim-check` passes silently on empty input
`bin/cli.js` `claim-check` with **neither `--text` nor `--file`** sends
`text: undefined``claimCheck(String(args.text ?? ''))``''``ok: true`,
**exit 0**. A CI hook wired as `npx ruview claim-check --text "$BODY"` where
`$BODY` expands empty therefore reports PASS. This is the single tool whose whole
purpose is to fail closed; empty input must be an error, not a pass.
Reproducer: `node bin/cli.js claim-check``{"ok": true}`, exit 0.
### F2 (HIGH, head-of-line blocking): MCP server is fully synchronous
`src/mcp-server.js` dispatches `tools/call` inside the readline `line` handler,
and every heavyweight handler in `src/tools.js` uses **`spawnSync`**
(`ruview.verify` up to 180 s, `ruview.calibrate` up to 300600 s,
`ruview.node_monitor` up to `seconds+10`). While one call runs, the event loop is
blocked: `ping`, `tools/list`, and concurrent `tools/call` requests are not even
read from stdin. Hosts that health-check with `ping` during a long `calibrate`
will conclude the server is dead and kill it mid-run.
### F3 (MEDIUM, cold start): optionalDependencies triple the `npx` install for a path that never uses them
`package.json` declares `optionalDependencies` on `@metaharness/kernel` and
`@metaharness/host-claude-code`. npm installs optional deps **by default**, so
every cold `npx -y @ruvnet/ruview mcp start` fetches 3 extra packages (kernel +
host + transitive `@ruvector/emergent-time`). MEASURED (npm 10.9.7, this
container): default install = **4 packages, 620 kB, 71 files**; with
`--omit=optional` = **1 package, 172 kB, 22 files**. The operator-tool and MCP
paths never import these — only `doctor`/`install` do, and both already
dynamic-import inside `try/catch` and degrade gracefully when absent
(`kernel/host: not installed (ok…)`). The optional deps buy nothing on the hot
path and cost 3 registry round-trips + ~450 kB on every cold start.
### F4 (MEDIUM, silent truncation): `spawnSync` default `maxBuffer` (1 MiB)
`run()` in `src/tools.js` never sets `maxBuffer`. `cargo run -p
wifi-densepose-cli` (the `calibrate` fallback path) and a chatty `verify.py` can
exceed 1 MiB of stdout, at which point the child is killed with `ENOBUFS` and the
tool reports a spawn error that looks like a proof/calibration failure. The
handlers only ever consume the last 8 kB/1.5 kB; buffering should be bounded but
generous (e.g. `maxBuffer: 16 MiB`) or streamed with a tail ring.
### F5 (MEDIUM, injection surface): `node_monitor` interpolates the port into Python source
The handler builds a `python -c` script by string interpolation:
`` `ser=serial.Serial(${JSON.stringify(port)},115200,…)` `` and
`` `while time.time()-t<${dur}:` ``. `JSON.stringify` produces a *JavaScript*
string literal; Python string-literal semantics differ at the edges (`\uXXXX` is
shared, but e.g. JS emits raw U+2028/U+2029 unescaped pre-ES2019 rules aside, and
any future non-JSON-safe field added the same way would be executable). `port`
arrives from the MCP caller (an agent), so this is an agent-controlled string
concatenated into an interpreter invocation. `dur` is `Number()`-guarded; `port`
should be passed out-of-band (`sys.argv`/env), never spliced into source.
### F6 (LOW, drift): server version hardcoded
`SERVER_INFO = { name: 'ruview', version: '0.1.0' }` in `src/mcp-server.js`
duplicates `package.json.version` (the CLI's `--version` already reads
package.json at runtime). First release bump will drift the MCP handshake
version.
### F7 (LOW, duplication): every skill ships twice
`skills/*.md` and `.claude/skills/*/SKILL.md` are byte-identical (same sha256 in
`.harness/manifest.json`). ~8 kB of the 49.5 kB unpacked payload is duplicate
content, and — worse than size — two copies must be kept in sync by hand.
### F8 (LOW, perf + portability): `which()` is uncached and shells out
`which()` runs up to twice per tool call (`python` then `python3`), each a
blocking `spawnSync`; the POSIX branch spawns a shell (`shell: true`). Results
are stable for the process lifetime and should be memoized; the lookup can be
done dep-free with a PATH scan instead of a shell.
### F9 (LOW, interop): dot-named tools + minimal protocol surface
Tool names (`ruview.onboard`, `ruview.claim_check`, …) contain dots. MCP itself
does not restrict names, but downstream host APIs commonly enforce
`^[a-zA-Z0-9_-]{1,64}$` for tool names; hosts must then sanitize or reject.
The server also answers `resources/list` / `prompts/list` with `-32601` (it does
not advertise those capabilities, so this is spec-legal, but empty-list stubs are
cheaper than every host's error path). Protocol version is pinned to
`2024-11-05` with no negotiation fallback. None of this breaks Claude Code today;
it narrows portability, which is the harness's whole pitch (9 hosts, ADR-182).
### F10 (LOW, CI gap): the published package has zero CI
No workflow under `.github/workflows/` runs `harness/ruview` tests (checked:
no workflow references `harness/ruview`, `ruview-mcp`, or `ruview-cli`), and
`ci.yml` pins `NODE_VERSION: '18'` while the package declares
`engines.node >= 20`. Note also `node --test test/` (directory form) fails on
Node 22 while the documented glob form passes — CI should pin the working
invocation. Consolidated CI/publish strategy is ADR-265.
### F11 (MEDIUM, guardrail precision): `METRIC_TERMS` substring matching false-positives on ordinary prose
Found by dogfooding this review: `claimCheck` matches metric terms with
`lower.includes(t)`, so the two-character terms `'map'` and `'f1'` fire inside
ordinary words and labels — "source **map**s", "the **map**s can never
resolve", finding IDs like "**F1** (HIGH…)". MEASURED reproducer: running
`npx ruview claim-check --file` over this ADR and ADR-264 yields 4 and 16
medium findings respectively, the majority of which are `map`/`F1`
false positives on lines carrying no accuracy claim. A guardrail that cries
wolf trains people to ignore it — precision is part of its fail-closed
contract. Short/ambiguous terms need word-boundary matching (`\bmap\b`,
`\bf1\b`, likewise `auc`, `iou`), and section-heading label patterns
(`F\d+`, `O\d+`) should not count as metric mentions.
## Decision
Adopt the following optimization strategy, in priority order. Each item is
independently shippable; F-numbers map to findings.
- **O1 (F1):** `claim-check` with no `--text`/`--file` (or empty text after read)
exits 2 with a usage error. Add a regression test pinning exit ≠ 0.
- **O2 (F2):** make the MCP dispatch async: convert `run()`/`which()` to
promise-based `spawn`, make `tools/call` handlers `async`, and keep reading
stdin while calls run (respond to `ping`/`tools/list` concurrently; serialize
only same-tool hardware operations). Acceptance: `ping` round-trips < 50 ms
while a synthetic 30 s `calibrate` is in flight.
- **O3 (F3):** drop the two `optionalDependencies`; `doctor`/`install` already
degrade and should print the exact `npm i @metaharness/kernel
@metaharness/host-claude-code` hint on the miss path. Acceptance: cold
`npm i @ruvnet/ruview` installs exactly 1 package (MEASURED baseline above).
- **O4 (F4):** set `maxBuffer: 16 * 1024 * 1024` in `run()` (or stream + tail).
- **O5 (F5):** pass `port` to the monitor script via `sys.argv`
(`python -c script -- <port>`), never by source interpolation.
- **O6 (F6):** read the MCP `serverInfo.version` from `package.json` once at
startup (same pattern the CLI already uses).
- **O7 (F7):** make `skills/*.md` the single source and generate
`.claude/skills/*/SKILL.md` in a `prepack` script (or vice versa); manifest
hashes then pin one canonical set.
- **O8 (F8, F9):** memoize `which()`; add underscore aliases for the dot-named
tools (accept both in `tools/call`, advertise the underscore form) and add
empty `resources/list` / `prompts/list` stubs.
- **O9 (F11):** switch `METRIC_TERMS` matching to word-boundary regexes for
short terms (`map`, `f1`, `auc`, `iou`) and skip label tokens matching
`\b[FO]\d+\b`. Acceptance: `claim-check --file` over ADR-263/264/265 reports
only the genuinely tagged-or-taggable percentage lines, and the existing 17
guardrail tests still pass plus new false-positive pins ("source maps",
"F1 (HIGH)" → no finding).
Non-goals: no new runtime dependencies (the zero-dep MCP server is a feature,
not an accident — keep it), no build step, no change to the fail-closed tool
contracts.
## Consequences
- The honesty guardrail becomes fail-closed end-to-end (its current empty-input
pass is the exact failure mode the guardrail exists to prevent).
- `npx` cold start drops ~450 kB / 3 packages (MEASURED baseline in F3) with no
feature loss; `doctor` output already communicates the optional-dep story.
- Long-running `verify`/`calibrate` no longer starve the MCP channel — the
harness survives host health checks during real calibration runs.
- Two-copy skill drift becomes impossible at pack time.
- Costs: async conversion touches every handler signature in `src/tools.js`
(mechanical, ~6 handlers); alias tools add a small compatibility table.
- Verification for the implementing PR: bundled tests extended for O1/O2/O5
(target ≥ 20 tests), `npm pack --dry-run` file-count asserted, and the F3
install measurement re-run and quoted MEASURED in the PR body — which must
itself pass `npx ruview claim-check`.
@@ -1,169 +0,0 @@
# ADR-264: `@ruvnet/rvagent` MCP Server + `@ruv/ruview-cli` — Deep Review + Optimization Strategy
| Field | Value |
|-------|-------|
| **Status** | Accepted — **implemented** (O1O9, `@ruvnet/rvagent@0.2.0`): `exports` fixed (types-first, no phantom `.cjs`), map-free tarball (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, from 188 kB), Streamable HTTP **wired** behind `RVAGENT_HTTP_PORT` with per-session transports + 1 MiB body cap + port-aware origin gate, underscore tool names with dotted router aliases, single Zod validation gate with generated JSON Schemas, fd-leak fixed + persisted job records + bounded log tails, probing `detectCogBinary`, package.json-sourced version, `ruview-cli` bin renamed. 99/99 jest tests (MEASURED); both transports smoke-tested live |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-REVIEW-2** |
| **Supersedes / amends** | none (reviews the ADR-104/ADR-124 artifacts; feeds ADR-265 distribution strategy) |
## Context
Two TypeScript npm packages expose RuView sensing to agents and shells:
- **`@ruvnet/rvagent@0.1.0`** (`tools/ruview-mcp/`) — SENSE-BRIDGE, the MCP
server over the sensing-server HTTP API + cog binaries: 12 tools
(csi/pose/count/registry/train/job + ADR-124 BFLD/presence/vitals). Published
(188 kB unpacked — MEASURED, `npm view @ruvnet/rvagent`). Deps:
`@modelcontextprotocol/sdk` + `zod`.
- **`@ruv/ruview-cli@0.0.1`** (`tools/ruview-cli/`) — `private: true` yargs CLI
mirroring the same capabilities; intentionally duplicates `http.ts`/`cog.ts`/
`config.ts` (~150 lines) to stay standalone.
This ADR records a deep review of both: packaging correctness (verified against
the **published** tarball, not just the source tree), protocol/interop, resource
lifecycle, and the honesty of the package's own self-description — the same
MEASURED-vs-CLAIMED bar the project applies to accuracy numbers.
## Findings
### F1 (HIGH, broken export): `require` condition points at a file that does not exist
`package.json` `exports["."].require = "./dist/index.cjs"`, but the build is
plain `tsc` (ESM only) and **the published 0.1.0 tarball contains no
`index.cjs`** (verified by listing the registry tarball). Any CJS consumer doing
`require('@ruvnet/rvagent')` resolves to a nonexistent file →
`ERR_MODULE_NOT_FOUND`. Additionally the `types` condition is listed **after**
`import`/`require`; TypeScript requires `types` first or it may be ignored under
`moduleResolution: bundler/node16`.
### F2 (MEDIUM, tarball bloat): a third of the published package is dead source maps
The 0.1.0 tarball ships **44 `.map` files = 62,698 B** against 78,209 B of
actual `.js` (MEASURED, extracted registry tarball). `src/` is not published, so
every `sourceMappingURL` points at `../src/*.ts` that consumers do not have —
the maps can never resolve. Also `files` lists `CHANGELOG.md`, which does not
exist in `tools/ruview-mcp/` (npm silently skips it), so the advertised file set
is partly fictional.
### F3 (MEDIUM, honesty): the package description claims a transport it does not start
The description reads "**dual-transport MCP server (stdio + Streamable HTTP)**",
but `main()` in `src/index.ts` wires **stdio only**. `http-transport.ts` is a
complete, tested scaffold that nothing imports at runtime — there is no flag,
env var, or subcommand that starts it. By this project's own rule this is a
CLAIMED capability presented as shipped. Either wire it (`--http` /
`RVAGENT_HTTP_PORT` gate) or de-claim the description until it is.
### F4 (MEDIUM, interop + inconsistency): two tool-naming conventions, one of them dot-based
Six tools use `ruview_snake_case`; six (ADR-124 additions) use
`ruview.dotted.names`. Same interop caveat as ADR-263 F9 (host tool-name
regexes commonly `^[a-zA-Z0-9_-]{1,64}$`), plus the split convention makes the
tool surface look like two products. Standardize on underscores and accept the
dotted forms as aliases for one deprecation cycle.
### F5 (MEDIUM, double work + drift): every tool input is validated twice from two hand-maintained schemas
`CallToolRequestSchema` handler runs `TOOL_INPUT_SCHEMAS[name].safeParse(args)`,
then each tool handler runs its own `schema.parse(args)` again — two full Zod
passes per call. Separately, the `inputSchema` JSON advertised via `tools/list`
is **hand-written** and duplicates the Zod schema field-by-field (defaults,
min/max, descriptions) — schema drift between what is advertised and what is
enforced is a matter of time. Parse once at the gate, pass the typed result to
handlers, and generate the advertised JSON Schema from the Zod source
(`zod-to-json-schema` at build time, or Zod 4's native `z.toJSONSchema` when the
SDK's peer range allows).
### F6 (MEDIUM, resource lifecycle): `train_count` leaks 2 fds per job; job registry is process-local
`trainCount` opens `logFdOut`/`logFdErr` with `openSync` and never closes them
in the parent — the spawned cargo child inherits duplicates, but the parent's
descriptors stay open for the MCP server's lifetime: 2 leaked fds per training
job. `jobRegistry` is an in-memory `Map`, so `ruview_job_status` after a server
restart reports "not found" for a training run that is still burning GPU (the
source comments acknowledge this; the fix — persist `~/.ruview/jobs/<id>.json`,
already the documented layout — is small). Also `jobStatus` re-`import`s
`node:fs` on every poll and reads the entire log to return 20 lines.
### F7 (MEDIUM, security/robustness of the HTTP scaffold): unbounded body + one shared session transport
`http-transport.ts` buffers the request body with no size cap (memory DoS the
moment it is wired to a socket), reuses a **single**
`StreamableHTTPServerTransport` with `sessionIdGenerator` for all clients (the
SDK's stateful mode expects one transport per session — a second client's
`initialize` collides), and the Origin allowlist is exact-match
(`http://localhost` will not match a real browser origin `http://localhost:5173`).
Must be fixed **before** F3 wires it in; bearer-token + 127.0.0.1 defaults are
already right.
### F8 (LOW, dead/misleading code): `detectCogBinary` always returns the bare name
It builds a 4-candidate appliance-path array and then returns
`candidates[candidates.length - 1]` — i.e. always `name` — without checking
existence. The candidates are dead weight that reads as if path detection
happens. Either probe with `existsSync` or delete the array.
### F9 (LOW, drift + hygiene): hardcoded versions, unused/mismatched devDeps, bin-name collision
`PACKAGE_VERSION = "0.1.0"` (index.ts) duplicates package.json;
`@types/express` is unused (`http-transport` uses `node:http`); `@types/jest@30`
against `jest@29`; `ruview-cli` hardcodes `.version("0.0.1")`. And
`@ruv/ruview-cli` claims the **`ruview`** bin name, which collides with
`@ruvnet/ruview`'s bin (ADR-182) if both are ever installed globally —
ADR-263/265 give the `ruview` name to the harness; the CLI must rename or fold.
## Decision
- **O1 (F1):** fix `exports`: drop the `require` condition (ESM-only is fine for
a bin-first package) or add a real CJS build; put `types` first. Add a CI
smoke test that does `npm pack` + `node -e "import('<tarball install>')"`.
- **O2 (F2):** publish without maps: `declarationMap: false`, `sourceMap: false`
in a `tsconfig.build.json` used by `prepack` (or add `!dist/**/*.map` to
`files`). Remove the phantom `CHANGELOG.md` entry or create the file.
Acceptance: unpacked size ≤ ~125 kB (from 188 kB — MEASURED, `npm pack --dry-run`).
- **O3 (F3, F7):** wire the HTTP transport behind an explicit opt-in
(`RVAGENT_HTTP_PORT` or `--http`), after F7 fixes: per-session transport map
keyed by `mcp-session-id`, 1 MiB body cap, origin matching that honors ports
(compare `URL.origin` prefixes or document exact origins). Until then, change
the description to "stdio MCP server (Streamable HTTP scaffold, unwired)".
- **O4 (F4):** rename dotted tools to underscore (`ruview_bfld_last_scan`, …),
keep dotted aliases in the call router for one release, note it in the README.
- **O5 (F5):** single validation gate: the registry maps name → Zod schema →
typed handler; advertised `inputSchema` generated from Zod at build time.
- **O6 (F6):** close parent fds after spawn (`closeSync` post-`spawn` — the
child holds its own copies), persist job records to
`<jobsDir>/<id>.json`, and read log tails with a bounded read.
- **O7 (F8):** make `detectCogBinary` actually probe (`existsSync` over the
candidates) — it is the entire reason the function exists.
- **O8 (F9):** single-source versions from package.json; drop `@types/express`;
align `@types/jest` with jest 29 (or move to `node:test` like the harness and
drop the jest toolchain entirely — it is the heaviest devDep in both
packages).
- **O9 (F9, scope):** fold `@ruv/ruview-cli` into `rvagent` as a second bin
(`rvagent-cli`) sharing `http/cog/config`, or keep it private-forever and say
so in its README. Its `ruview` bin name is surrendered to `@ruvnet/ruview`
either way.
## Consequences
- CJS consumers stop hitting a guaranteed-broken export path (F1 is the only
finding that fails for every consumer of that entry point deterministically).
- The published artifact shrinks ~33% (MEASURED, F2 tarball listing: 62,698 B
of maps in a 188 kB unpacked payload) and stops advertising files/transports
it does not contain — the package description itself passes the project's
claim-check bar.
- One schema source ends advertised-vs-enforced drift and halves per-call
validation cost; naming unification makes the 12-tool surface read as one
product and survive strict host tool-name validation.
- Long-lived MCP servers stop accumulating fds during training campaigns, and
job polling survives restarts.
- Costs: the alias cycle (O4) briefly doubles the advertised tool count unless
aliases are router-only (recommended: router-only, advertise underscore names
exclusively); folding the CLI (O9) retires a package name already in use in
scripts, so it needs a deprecation note.
- Verification for the implementing PR: `npm pack --dry-run` asserted file list
(no `.map`, no phantom entries), pack-size budget in CI (ADR-265), jest/`node
--test` suite green, and a tarball-install smoke test for both `import` and
the `rvagent` bin.
@@ -1,124 +0,0 @@
# ADR-265: RuView npm Distribution Strategy — CI Gate, Provenance, Version Single-Sourcing, Namespace
| Field | Value |
|-------|-------|
| **Status** | Accepted — **D1D4 implemented**: `.github/workflows/npm-packages.yml` (matrix gate: tests, version-literal grep, pack-content/size gate, tarball-install smoke test, README claim-check), `.github/workflows/ruview-npm-release.yml` (publish-from-CI with `npm publish --provenance`), version single-sourcing (all three packages read package.json), `ruview` bin owned by `@ruvnet/ruview` (`@ruv/ruview-cli` bin renamed `ruview-cli`), `ci.yml` NODE_VERSION 18→20. D5 (no workspace) stands as recorded |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-DIST** |
| **Supersedes / amends** | none (cross-cutting layer above ADR-263 and ADR-264; complements ADR-182 P3/P4) |
## Context
The monorepo now ships (or stages) **three Node packages** with no shared
distribution engineering:
| Package | Dir | Published | Bin(s) | Tests in CI |
|---------|-----|-----------|--------|-------------|
| `@ruvnet/ruview` | `harness/ruview/` | 0.1.0 (live) | `ruview` | **none** |
| `@ruvnet/rvagent` | `tools/ruview-mcp/` | 0.1.0 (live) | `rvagent`, `ruview-mcp` | **none** |
| `@ruv/ruview-cli` | `tools/ruview-cli/` | private | `ruview` (collides) | **none** |
Cross-cutting facts established during the ADR-263/264 reviews:
- **Zero CI coverage.** No workflow under `.github/workflows/` references any of
the three directories. Two of the packages are *live on the registry* and were
published from a laptop state CI never saw. Meanwhile the Rust side has a
1,031+-test gate and a witness-bundle culture (ADR-028) — the npm surface is
the only shipped artifact class with no verification gate at all.
- **`ci.yml` pins `NODE_VERSION: '18'`** while all three packages declare
`engines.node >= 20`.
- **Version triplication.** Each package hardcodes its version in source at
least once beyond package.json (harness `SERVER_INFO`, rvagent
`PACKAGE_VERSION`, cli `.version("0.0.1")`).
- **Bin-name collision.** Two packages claim the `ruview` bin.
- **No provenance.** Neither published package carries npm provenance
attestations, in a project whose differentiator is signed, reproducible
evidence (ADR-028 witness bundles, ADR-182 P4 ed25519/SLSA design).
- **No pack-content gate.** ADR-264 F1/F2 (broken `require` target, 33% dead map weight — MEASURED, tarball listing — and a phantom
`CHANGELOG.md` in `files`) are exactly the defect class an
`npm pack --dry-run` assertion catches in seconds.
## Decision
Adopt one distribution layer for all Node packages. Per-package code fixes live
in ADR-263/264; this ADR fixes the machinery around them.
### D1 — One `npm-packages.yml` CI workflow (the gate)
Matrix over `[harness/ruview, tools/ruview-mcp, tools/ruview-cli]` ×
Node `[20, 22]`:
1. `npm ci` where a lockfile is committed (the TS packages); the harness
installs with `npm install` — repo policy gitignores lockfiles under
`harness/`, and the package is dependency-free after ADR-263 O3 so there is
nothing to pin.
2. `npm test` (harness: `node --test test/*.test.mjs` — pin the glob form,
the directory form fails on Node 22; TS packages: build + jest or `node:test`
per ADR-264 O8).
3. **Pack gate:** `npm pack --dry-run --json` asserted against a checked-in
expected file list + a max unpacked-size budget per package (harness ≤ 60 kB;
rvagent ≤ 130 kB post ADR-264 O2). Any new/missing/renamed shipped file is a
reviewed diff, not a surprise.
4. **Tarball smoke test:** install the packed tarball into a temp dir; run
`ruview --version`, `ruview doctor`, `rvagent` `--help`-equivalent, and a
Node `import()` of each declared export condition — this is the test that
would have caught ADR-264 F1 (`require` → nonexistent `dist/index.cjs`).
5. Bump `ci.yml` `NODE_VERSION` to `'20'` (independent of the matrix above).
### D2 — Publish only from CI, with provenance
Manual `npm publish` from laptops stops. A tag-triggered workflow
(`ruview-npm-release.yml`, mirroring the firmware release discipline) runs the
D1 gate, then `npm publish --provenance --access public` under the GitHub OIDC
token. Consequence: every published version is attested to a public commit +
workflow run — the npm-side analogue of the ADR-028 witness bundle. The
`prepublishOnly` script in each package runs the pack gate locally as a
belt-and-braces (publishing outside CI fails loudly, not silently).
### D3 — Version single-sourcing
Rule: **package.json is the only place a version string lives.** Runtime code
reads it (`createRequire(import.meta.url)('./package.json').version` or a
build-time define for the TS packages). CI greps for `\d+\.\d+\.\d+` literals in
`src/` of each package and fails on match (allowlist: test fixtures). This
retires ADR-263 F6 and ADR-264 F9 permanently instead of per-incident.
### D4 — Namespace and bin ownership
- `@ruvnet/ruview` **owns the `ruview` bin** (it is the published front door,
ADR-182). `@ruv/ruview-cli` renames its bin or folds into `rvagent`
(ADR-264 O9) — decided here so neither package ADR relitigates it.
- New Node packages in this repo use the `@ruvnet/` scope (the `@ruv/` scope
holds `rvcsi` legacies; do not grow it).
- Every package README + description must pass
`npx ruview claim-check` — enforced in the D1 gate. The guardrail package
linting its sibling packages' claims is the cheapest dogfooding we have
(ADR-264 F3 is the standing example of why).
### D5 — Shared-code policy (bounded)
Do **not** introduce an npm workspace or a shared runtime package yet: three
packages, two of which may merge (ADR-264 O9), do not justify workspace
machinery, and the harness's zero-dep property is load-bearing. Revisit if a
fourth package appears or if the `http/cog/config` duplication survives the
ADR-264 O9 fold. Record the duplication as intentional in each file header (the
CLI already does this).
## Consequences
- The npm artifacts get the same class of gate the Rust workspace has had since
ADR-028: no publish without tests, no shipped file set without an asserted
manifest, no version without provenance. The two defects that reached the
registry (broken `require` condition, dead maps) become CI-impossible.
- Cold-path costs stay near zero: the D1 matrix is 6 fast jobs (the harness
suite runs in ~108 ms MEASURED; TS builds dominate at a few tens of seconds).
- Publishing gains one constraint (must go through CI) and loses one failure
mode (laptop-state publishes) — the right trade for a project whose brand is
reproducible evidence.
- D3's grep gate is blunt but cheap; if it over-fires, scope it to
`version`-adjacent identifiers before weakening it.
- Follow-ups tracked elsewhere: per-package code fixes (ADR-263 O1O8, ADR-264
O1O9); ADR-182 P4 (metaharness router + ed25519 provenance chain) remains
the deeper provenance story that D2's npm attestations complement, not
replace.
+1 -4
View File
@@ -1,6 +1,6 @@
# Architecture Decision Records
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
This folder contains 45 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project.
## Why ADRs?
@@ -120,9 +120,6 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
---
-135
View File
@@ -1,135 +0,0 @@
# WiFlow Browser Trainer (`wiflow_browser.html`)
A **single self-contained HTML page** that does the entire camera-supervised
WiFi-pose loop **in your browser, in your laptop camera's coordinate frame**, as
a **4-stage gated flow** with a progress stepper (each stage unlocks the next):
0. **CALIBRATE** *(ADR-151 empty-room baseline)* — you step OUT of the space; the
page captures ~10 s of the quiescent CSI and computes a per-feature running
**mean + std (Welford)** over the 410-d vector. Every CSI vector afterwards is
expressed as **deviation from baseline**
(`x_norm = (x base_mean) / (base_std + ε)`), so a body's perturbation stands
out from the static channel. Persisted to IndexedDB. *Can't capture without it.*
1. **CAPTURE** — MediaPipe Pose runs on your laptop camera → 17 COCO keypoints
(the *label*), paired with the **baseline-normalized** 410-d ESP32 CSI vector
(the *input*). A **guided, balanced routine** cycles big on-screen prompts
(stand / turn / walk / arms / crouch / sit / reach) with a countdown, and a
**per-pose coverage meter** so you build a balanced dataset, not 2 000 frames
of standing.
2. **TRAIN** — a TensorFlow.js MLP learns `CSI → pose` in-browser. Honest
held-out PCK@0.10 / PCK@0.05 / MPJPE, plus a **mean-pose baseline** the model
must beat (the project's whole ethos — no baseline-beating signal, it says so).
*Can't train with <200 samples.*
3. **INFER** — the trained model drives a skeleton **from WiFi CSI only**
(baseline-normalized → standardized → model), drawn over the **same** camera
frame it trained in — so the inferred skeleton **aligns** with the camera
image. That alignment is the entire point of doing this in-browser instead of
with a separate Python camera. *Can't infer without a model.*
## Why in-browser
The Python pipeline (`wiflow_capture.py``wiflow_train.py``wiflow_infer.py`)
proved the signal is real (held-out PCK@0.10 ≈ 59.5% vs a 50% mean-pose baseline
= +9.4 pp). But it trained in a *different* camera's frame, so the inferred
skeleton never lined up with the laptop camera. Doing capture + train + infer all
in the browser with the **same** camera makes the training frame and the
inference frame identical → the skeleton aligns.
## Compute backends (WebGPU / WASM / WebGL)
Training and inference run on TensorFlow.js. The page selects the backend at
startup, preferring the fastest available:
- **WebGPU** (Chrome / Edge, secure context — `localhost` qualifies) — GPU compute.
- **WASM-SIMD** fallback (`tfjs-backend-wasm`, SIMD enabled, `.wasm` from the CDN).
- **WebGL** last-resort fallback (ships inside tfjs core).
The **active backend is shown as a badge in the header** (`compute: WebGPU` /
`WASM-SIMD` / `WebGL`) so it's honest about what's actually running. The model
code is backend-agnostic — tf.js abstracts the device.
## Honesty (baked in)
- The **CAPTURE** skeleton (blue) is the camera = ground truth, labeled as such.
- The **INFER** skeleton (green) is **CSI-only**, labeled, and **coarse** — the
real measured held-out PCK is shown, not a marketing number.
- The **mean-pose baseline** is always computed and shown in TRAIN; the verdict
states plainly whether the model **beats** it (real signal) or **does not**
(no usable signal). This guards against the project's retracted 92.9% that
failed exactly this check.
- Status banner is strict and mutually exclusive:
**LIVE** (real `source: "esp32"`) / **SIMULATED — not real** (any other source)
/ **NO-CSI-SERVER**. The page never invents frames.
## How to run
### 1. Start the real sensing-server (provides the CSI WebSocket on :8765)
```bash
cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
```
A real ESP32-S3 must be provisioned and streaming for `source` to read `esp32`
(see `CLAUDE.local.md` for the firmware build/provision steps). The page expects
the verified live endpoint **`ws://localhost:8765/ws/sensing`** with
`source:"esp32"`, nodes `[9, 13]`, `features.*`, `node_features[].features.*`,
and `signal_field.values` (400 floats).
### 2. Serve this page over localhost (camera + WebGPU need a localhost/secure origin)
Any static localhost server works. For example:
```bash
python -m http.server 8099
# then open: http://localhost:8099/examples/through-wall/wiflow_browser.html
```
(8099 is just the static file server — 8765 is a separate process, the CSI
WebSocket.) Allow camera access when the browser prompts.
Point at a CSI server on another host with `?ws=`:
```
http://localhost:8099/examples/through-wall/wiflow_browser.html?ws=ws://192.168.1.20:8765/ws/sensing
```
### 3. Use it
1. **CAPTURE** tab → *enable laptop camera**start recording*. Follow the guided
routine (stand / turn / walk / arms / crouch / sit). A pair is stored only when
a confident pose AND a fresh live `esp32` CSI frame coexist. Aim for a few
thousand samples. Samples persist in IndexedDB across refreshes.
2. **TRAIN** tab → *train model*. Watch the live loss curve, held-out PCK, and the
baseline verdict. The model saves to IndexedDB.
3. **INFER** tab → the green skeleton is now driven by WiFi CSI only, aligned over
your camera. Toggle *hide camera* to see the CSI-only skeleton on black.
## The 410-d CSI vector (matches the Python pipeline exactly)
```
[ mean_rssi, variance, motion_band_power, breathing_band_power ] # 4 (features.*)
+ for node 9 then node 13: [ mean_rssi, variance, motion_band_power ] # 6 (node_features[].features.*)
+ signal_field.values, padded / truncated to 400 # 400
= 410-d
```
Verified against a real live frame: the in-browser `csiVector()` produces the
identical 410 vector as `wiflow_capture.py`'s `csi_vector()` (node 9 first, then
node 13; field zero-padded).
## Libraries (CDN only, no bundler)
| Library | CDN |
|---|---|
| TensorFlow.js core | `@tensorflow/tfjs@4.22.0/dist/tf.min.js` |
| TF.js WebGPU backend | `@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js` |
| TF.js WASM backend | `@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js` |
| MediaPipe Pose 0.5 (legacy solutions) | `@mediapipe/pose@0.5/pose.js` |
## Scope / honesty caveats
Same person, same room, same session. **Not** validated cross-day, cross-room, or
through-wall. The inferred pose is coarse (PCK@0.05 is typically weak). If the
model does not beat the mean-pose baseline, the page says so — that is a feature.
-644
View File
@@ -1,644 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RuView · Through-Wall WiFi Sensing · LIVE CSI (no skeleton, no simulation)</title>
<!--
THROUGH-WALL WiFi-CSI SENSING DEMO — honest, real-data-only.
Renders ONLY what the running sensing-server actually streams over
ws://localhost:8765/ws/sensing :
- the 20x20 `signal_field` floor heatmap (real values)
- a coarse RF-localization puck from persons[0].position (NOT pose)
- live motion / presence / rssi / confidence meters
- the real `source` ("esp32" = LIVE) verbatim in the banner
It deliberately does NOT draw a skeleton. The server's
persons[].keypoints carry confidence:0.0 (image-pixel garbage, not
real 3D joints) so we never render them. WiFi CSI gives
motion/presence/coarse-position — that is the honest wow, and it
penetrates drywall. See README.md.
-->
<style>
:root {
--bg: #050507; --bg-panel: rgba(8,10,14,0.80);
--amber: #ffb840; --amber-hot: #ffe09f;
--cyan: #4cf; --magenta: #ff4cc8;
--text: #d8c69a; --text-mute: #6b6155;
--green: #4f4; --red: #f64;
--border: rgba(255,184,64,0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
-webkit-font-smoothing: antialiased; font-size: 12px;
}
canvas { display: block; }
.overlay-frame {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
}
.scanlines {
position: fixed; inset: 0; pointer-events: none; z-index: 6;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
mix-blend-mode: overlay; opacity: 0.5;
}
.panel {
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
}
.panel h2 {
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
/* ---- Honest status banner (top-center, mutually exclusive states) ---- */
#banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 30;
text-align: center; padding: 7px 12px; font-size: 12px; letter-spacing: 1px;
font-weight: 600; border-bottom: 1px solid rgba(0,0,0,0.4);
transition: background 0.3s, color 0.3s;
}
#banner.live { background: rgba(40,255,80,0.12); color: var(--green); border-bottom-color: rgba(80,255,120,0.4); }
#banner.sim { background: rgba(255,120,40,0.16); color: #ffae5a; border-bottom-color: rgba(255,140,60,0.5); }
#banner.noserver { background: rgba(255,80,80,0.16); color: var(--red); border-bottom-color: rgba(255,90,90,0.5); }
#banner .src { opacity: 0.8; font-weight: 400; }
#banner-caption {
position: fixed; top: 30px; left: 0; right: 0; z-index: 29;
text-align: center; font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px;
pointer-events: none; padding-top: 2px;
}
#info { top: 64px; left: 20px; min-width: 270px; }
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
#info .row .k { color: var(--text-mute); font-size: 11px; }
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
#info .row .v.amber { color: var(--amber); }
#info .row .v.cyan { color: var(--cyan); }
#info .row .v.green { color: var(--green); }
#info .row .v.red { color: var(--red); }
#info .row .v.mag { color: var(--magenta); }
#info .row .v.mute { color: var(--text-mute); }
#csi { top: 64px; right: 20px; min-width: 270px; }
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#csi .bar-row .label { width: 86px; color: var(--text-mute); }
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
#csi .bar-row .bar-fill {
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
box-shadow: 0 0 6px var(--amber); transition: width 0.1s linear;
}
#csi .bar-row .val { width: 44px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#csi .spark { margin-top: 8px; }
#csi canvas { width: 100%; height: 38px; display: block; border: 1px solid var(--border); border-radius: 3px; background: rgba(0,0,0,0.3); }
#csi .legend { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--text-mute); line-height: 1.5; }
/* ---- waiting / no-server overlay ---- */
#waiting {
position: fixed; inset: 0; z-index: 25; display: none;
flex-direction: column; align-items: center; justify-content: center;
background: rgba(5,5,7,0.94); color: var(--amber); text-align: center; padding: 24px;
}
#waiting.show { display: flex; }
#waiting .big { font-size: 22px; letter-spacing: 2px; color: var(--red); margin-bottom: 16px; text-transform: uppercase; }
#waiting code {
display: block; text-align: left; max-width: 640px; margin: 8px auto;
background: rgba(255,184,64,0.06); border: 1px solid var(--border); border-radius: 4px;
padding: 10px 14px; color: var(--amber-hot); font-size: 12px; white-space: pre-wrap;
}
#waiting .pulse { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
/* ---- optional webcam ground-truth tile ---- */
#cam-tile {
position: absolute; bottom: 20px; right: 20px; width: 240px; z-index: 12;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 8px; backdrop-filter: blur(8px);
}
#cam-tile h2 { margin: 0 0 6px 0; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
color: var(--cyan); font-weight: 600; }
#cam-tile .gt-note { font-size: 9px; color: var(--text-mute); margin-top: 4px; line-height: 1.4; }
#cam-video { width: 100%; border-radius: 3px; display: none; background: #000; }
#cam-tile button {
width: 100%; margin-top: 6px; padding: 5px 8px; font-family: inherit; font-size: 11px;
background: transparent; color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px; cursor: pointer;
}
#cam-tile button:hover { background: rgba(68,204,255,0.12); }
#cam-tile button:disabled { opacity: 0.5; cursor: not-allowed; }
#legend-nodes {
position: absolute; bottom: 20px; left: 20px; min-width: 220px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#legend-nodes h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#legend-nodes .lr { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 11px; }
#legend-nodes .dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: 0 0 auto; }
#legend-nodes .muted { color: var(--text-mute); }
</style>
<!-- three.js r128 + addons (same CDN set as examples/three.js/demos/05) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
</head>
<body>
<div id="banner" class="noserver">NO SERVER — start the sensing-server <span class="src"></span></div>
<div id="banner-caption">Real WiFi CSI motion / presence / coarse-localization — penetrates drywall. Not skeletal pose.</div>
<div class="overlay-frame"></div>
<div class="scanlines"></div>
<div class="panel" id="info">
<h1>THROUGH-WALL WiFi SENSING</h1>
<div class="sub">Live CSI · ws://localhost:8765/ws/sensing</div>
<div class="row"><span class="k">source</span><span class="v amber" id="m-source"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="m-presence"></span></div>
<div class="row"><span class="k">motion level</span><span class="v" id="m-motion"></span></div>
<div class="row"><span class="k">confidence</span><span class="v cyan" id="m-conf"></span></div>
<div class="row"><span class="k">est. persons</span><span class="v amber" id="m-persons"></span></div>
<div class="row"><span class="k">active nodes</span><span class="v" id="m-nodes"></span></div>
<div class="row"><span class="k">tick</span><span class="v" id="m-tick"></span></div>
<div class="row"><span class="k">update rate</span><span class="v cyan" id="m-fps"></span></div>
</div>
<div class="panel" id="csi">
<h2>Live RF features</h2>
<div class="bar-row"><span class="label">motion</span><div class="bar-track"><div class="bar-fill" id="bar-motion"></div></div><span class="val" id="v-motion"></span></div>
<div class="bar-row"><span class="label">breathing</span><div class="bar-track"><div class="bar-fill" id="bar-breath"></div></div><span class="val" id="v-breath"></span></div>
<div class="bar-row"><span class="label">variance</span><div class="bar-track"><div class="bar-fill" id="bar-var"></div></div><span class="val" id="v-var"></span></div>
<div class="bar-row"><span class="label">mean rssi</span><div class="bar-track"><div class="bar-fill" id="bar-rssi"></div></div><span class="val" id="v-rssi"></span></div>
<div class="spark"><canvas id="spark" width="252" height="38"></canvas></div>
<div class="legend">motion sparkline (last ~6s of real motion_band_power)</div>
</div>
<div id="legend-nodes">
<h2>Sensor nodes</h2>
<div class="lr"><span class="dot" style="color:#4cf"></span><span>ESP32-S3 office <span class="muted">(node 9)</span></span></div>
<div class="lr"><span class="dot" style="color:#ff4cc8"></span><span>ESP32-S3 hallway <span class="muted">(node 13)</span></span></div>
<div class="lr" style="margin-top:6px"><span class="dot" style="color:#4f4"></span><span>RF localization <span class="muted">(coarse)</span></span></div>
<div class="lr"><span class="muted" style="font-size:10px;line-height:1.4">Office &amp; hallway split by a wall + doorway. WiFi motion still shows through drywall.</span></div>
</div>
<div id="cam-tile">
<h2>camera — ground truth when visible</h2>
<video id="cam-video" autoplay muted playsinline></video>
<button id="cam-btn">▶ enable webcam (optional)</button>
<div class="gt-note">Independent of the CSI sensing. The WiFi works in the dark and through walls; the camera does not.</div>
</div>
<div id="waiting" class="show">
<div class="big pulse">Waiting for live sensing-server</div>
<div>No connection to <b>ws://localhost:8765/ws/sensing</b>. Start the real server, then this page connects automatically.</div>
<code>cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005</code>
<div style="margin-top:10px; color:var(--text-mute); font-size:11px;">This demo renders ONLY real data. It never invents frames.</div>
</div>
<script>
"use strict";
// =====================================================================
// Config + WS endpoint (allow ?ws= override)
// =====================================================================
const params = new URLSearchParams(location.search);
const WS_URL = params.get('ws') || 'ws://localhost:8765/ws/sensing';
const ROOM_HALF = 5; // half-extent of the floor plane in metres
const GRID_N = 20; // signal_field is 20 x 20
// Known node anchor positions (server sends node 9 @ [2,0,1.5]; node 13
// joins later from the hallway side once its firmware is flashed). These
// are anchors for the room model + labels, NOT fabricated sensing data.
const NODE_ANCHORS = {
9: { pos: [ 2.0, 0.0, 1.5], color: 0x44ccff, label: 'office (node 9)' },
13: { pos: [-2.0, 0.0, -3.0], color: 0xff4cc8, label: 'hallway (node 13)' },
};
// =====================================================================
// Three.js scene (reused pattern from demos/05-skinned-realtime.html)
// =====================================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050507);
scene.fog = new THREE.FogExp2(0x050507, 0.045);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(4.5, 4.2, 6.0);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.4, -0.5);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 3; controls.maxDistance = 18;
controls.maxPolarAngle = Math.PI * 0.49;
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
const keyLight = new THREE.DirectionalLight(0xffc070, 0.9);
keyLight.position.set(3, 6, 4);
scene.add(keyLight);
// Post-processing — gentle bloom so the heatmap + puck glow.
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.55, 0.45, 0.82);
composer.addPass(bloom);
// =====================================================================
// Room: floor grid + wall + doorway dividing office / hallway
// =====================================================================
const gridHelper = new THREE.GridHelper(2*ROOM_HALF, GRID_N, 0x554a32, 0x2a2418);
gridHelper.position.y = 0.002;
scene.add(gridHelper);
// Dividing wall runs along world X near z = -1 (office z>-1, hallway z<-1),
// with a doorway gap. Two wall segments leave a gap in the middle.
const wallMat = new THREE.MeshStandardMaterial({
color: 0x1b2330, transparent: true, opacity: 0.55, roughness: 0.9,
side: THREE.DoubleSide,
});
const wallH = 1.4, wallZ = -1.0;
function addWallSeg(cx, w) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, 0.08), wallMat);
m.position.set(cx, wallH/2, wallZ);
scene.add(m);
// top edge highlight
const edge = new THREE.Mesh(new THREE.BoxGeometry(w, 0.02, 0.10),
new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0.5 }));
edge.position.set(cx, wallH, wallZ);
scene.add(edge);
}
// left segment, doorway gap (-0.7..0.7), right segment
addWallSeg(-3.15, 3.7);
addWallSeg( 3.15, 3.7);
// Room labels (sprite text) for OFFICE / HALLWAY
function makeLabel(text, color) {
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = color; ctx.font = 'bold 30px Consolas, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 34);
const tex = new THREE.CanvasTexture(c);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
spr.scale.set(2.0, 0.5, 1);
return spr;
}
const officeLbl = makeLabel('OFFICE', '#ffb840'); officeLbl.position.set(2.6, 0.06, 2.6); scene.add(officeLbl);
const hallLbl = makeLabel('HALLWAY', '#ff4cc8'); hallLbl.position.set(-2.6, 0.06, -3.2); scene.add(hallLbl);
// =====================================================================
// Node markers (office / hallway). The hallway node is dimmed until it
// actually appears in the live `nodes` list.
// =====================================================================
const nodeMeshes = {};
function buildNode(id) {
const a = NODE_ANCHORS[id];
const g = new THREE.Group();
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.07, 0.9, 12),
new THREE.MeshStandardMaterial({ color: a.color, emissive: a.color, emissiveIntensity: 0.4, roughness: 0.4 }));
post.position.y = 0.45; g.add(post);
const orb = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 20, 16),
new THREE.MeshBasicMaterial({ color: a.color }));
orb.position.y = 0.95; g.add(orb);
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.18, 0.24, 32),
new THREE.MeshBasicMaterial({ color: a.color, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
ring.rotation.x = -Math.PI/2; ring.position.y = 0.01; g.add(ring);
const lbl = makeLabel('ESP32-S3 ' + a.label, '#' + a.color.toString(16).padStart(6,'0'));
lbl.scale.set(2.6, 0.65, 1); lbl.position.set(0, 1.25, 0); g.add(lbl);
g.position.set(a.pos[0], 0, a.pos[2]);
g.userData.parts = { post, orb, ring };
scene.add(g);
return g;
}
Object.keys(NODE_ANCHORS).forEach(id => { nodeMeshes[id] = buildNode(+id); });
function setNodeActive(id, active) {
const g = nodeMeshes[id]; if (!g) return;
const o = active ? 1.0 : 0.22;
const parts = g.userData.parts;
parts.orb.material.opacity = o; parts.orb.material.transparent = true;
parts.ring.material.opacity = 0.6 * o;
parts.post.material.emissiveIntensity = active ? 0.5 : 0.12;
}
setNodeActive(9, false); setNodeActive(13, false);
// =====================================================================
// signal_field 20x20 floor heatmap — instanced colored tiles.
// Driven ONLY by real `signal_field.values` (400 floats ~0..1).
// =====================================================================
const TILE = (2*ROOM_HALF) / GRID_N;
const heatGeo = new THREE.PlaneGeometry(TILE * 0.96, TILE * 0.96);
const heatMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
const heatMesh = new THREE.InstancedMesh(heatGeo, heatMat, GRID_N * GRID_N);
heatMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const heatColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID_N * GRID_N * 3), 3);
heatMesh.instanceColor = heatColor;
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), -Math.PI/2);
const _s = new THREE.Vector3(1,1,1);
const _p = new THREE.Vector3();
// gridCell (gx,gz) -> world (x,z). gx,gz in [0,GRID_N).
function cellToWorld(gx, gz) {
return [ (gx + 0.5) * TILE - ROOM_HALF, (gz + 0.5) * TILE - ROOM_HALF ];
}
for (let gz = 0; gz < GRID_N; gz++) {
for (let gx = 0; gx < GRID_N; gx++) {
const i = gz * GRID_N + gx;
const [wx, wz] = cellToWorld(gx, gz);
_p.set(wx, 0.012, wz);
_m.compose(_p, _q, _s);
heatMesh.setMatrixAt(i, _m);
heatColor.setXYZ(i, 0.02, 0.02, 0.03);
}
}
heatMesh.instanceMatrix.needsUpdate = true;
scene.add(heatMesh);
// amber→white heat ramp for a value in [0,1]
function heatRamp(v, out) {
v = Math.max(0, Math.min(1, v));
// dark -> amber -> hot white
const r = Math.min(1, 0.05 + 1.6 * v);
const g = Math.min(1, 0.02 + 1.1 * v * v);
const b = Math.min(1, 0.04 + 0.9 * Math.pow(v, 3));
out.set(r, g, b);
return out;
}
const _c = new THREE.Color();
let lastFieldPeak = { gx: GRID_N/2|0, gz: GRID_N/2|0, v: 0 };
function updateHeatmap(field) {
if (!field || !Array.isArray(field.values)) return;
const vals = field.values;
// grid_size is [20,1,20]; values are row-major 400 floats.
let peakV = -1, peakGx = lastFieldPeak.gx, peakGz = lastFieldPeak.gz;
const n = Math.min(vals.length, GRID_N * GRID_N);
for (let i = 0; i < n; i++) {
const v = vals[i];
heatRamp(v, _c);
heatColor.setXYZ(i, _c.r, _c.g, _c.b);
if (v > peakV) { peakV = v; peakGx = i % GRID_N; peakGz = (i / GRID_N) | 0; }
}
heatColor.needsUpdate = true;
lastFieldPeak = { gx: peakGx, gz: peakGz, v: peakV };
}
// =====================================================================
// RF-localization puck — from persons[0].position (coarse, NOT pose).
// Falls back to the signal_field peak cell when no person is present.
// =====================================================================
const puck = new THREE.Group();
const puckCore = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 24, 18),
new THREE.MeshBasicMaterial({ color: 0x66ff88 }));
puckCore.position.y = 0.16; puck.add(puckCore);
const puckRing = new THREE.Mesh(
new THREE.RingGeometry(0.28, 0.36, 40),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
puckRing.rotation.x = -Math.PI/2; puckRing.position.y = 0.02; puck.add(puckRing);
const puckBeam = new THREE.Mesh(
new THREE.CylinderGeometry(0.03, 0.03, 1.2, 8),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.35 }));
puckBeam.position.y = 0.6; puck.add(puckBeam);
puck.visible = false;
scene.add(puck);
const puckTarget = new THREE.Vector3(0, 0, 0);
function updatePuck(frame) {
let wx = null, wz = null, present = false;
const persons = frame.persons || [];
if (persons.length && Array.isArray(persons[0].position)) {
// server position is [x, 0, z] in metres, origin at room centre
wx = persons[0].position[0];
wz = persons[0].position[2];
present = true;
}
// If no person but the field has clear energy, show the peak cell
// (coarse) so the puck honestly tracks "where the RF energy is".
if (!present && lastFieldPeak.v > 0.55) {
const peak = cellToWorld(lastFieldPeak.gx, lastFieldPeak.gz);
wx = peak[0]; wz = peak[1]; present = true;
}
if (present && wx !== null) {
// clamp into the room so it never flies off the floor
wx = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wx));
wz = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wz));
puckTarget.set(wx, 0, wz);
puck.visible = true;
} else {
puck.visible = false;
}
}
// =====================================================================
// HUD updates
// =====================================================================
const $ = id => document.getElementById(id);
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
function setBar(barId, valId, frac, text) {
$(barId).style.width = (clamp01(frac) * 100).toFixed(0) + '%';
$(valId).textContent = text;
}
// motion sparkline ring buffer
const sparkCtx = $('spark').getContext('2d');
const SPARK_N = 120;
const sparkBuf = new Array(SPARK_N).fill(0);
function pushSpark(v) {
sparkBuf.push(v); if (sparkBuf.length > SPARK_N) sparkBuf.shift();
const w = sparkCtx.canvas.width, h = sparkCtx.canvas.height;
sparkCtx.clearRect(0,0,w,h);
let maxV = 40; for (const x of sparkBuf) if (x > maxV) maxV = x;
sparkCtx.strokeStyle = '#ffb840'; sparkCtx.lineWidth = 1.5; sparkCtx.beginPath();
for (let i = 0; i < sparkBuf.length; i++) {
const x = (i / (SPARK_N-1)) * w;
const y = h - (sparkBuf[i] / maxV) * (h - 3) - 1.5;
i === 0 ? sparkCtx.moveTo(x, y) : sparkCtx.lineTo(x, y);
}
sparkCtx.stroke();
}
// =====================================================================
// Honest status banner (strict, mutually exclusive)
// =====================================================================
const banner = $('banner');
function setBannerLive(source, nodeCount) {
if (source === 'esp32') {
banner.className = 'live';
banner.innerHTML = 'LIVE — real ESP32 CSI <span class="src">(source=' + source + ', ' + nodeCount + ' node' + (nodeCount === 1 ? '' : 's') + ')</span>';
} else {
// anything not esp32 = explicitly NOT real, badged
banner.className = 'sim';
banner.innerHTML = 'SIMULATED — not real <span class="src">(source=' + source + ' — start an ESP32 for live CSI)</span>';
}
}
function setBannerNoServer() {
banner.className = 'noserver';
banner.innerHTML = 'NO SERVER — start the sensing-server <span class="src">(ws://localhost:8765/ws/sensing unreachable)</span>';
}
// =====================================================================
// WebSocket — render ONLY real frames. Reconnect; never fabricate.
// =====================================================================
let ws = null, gotFrame = false;
let frameTimes = []; // for measured update rate (fps)
let lastFrame = null; // most recent real frame (render loop interpolates puck)
function connect() {
setBannerNoServer();
try { ws = new WebSocket(WS_URL); }
catch (e) { scheduleReconnect(); return; }
ws.onopen = () => { /* wait for first frame before claiming LIVE */ };
ws.onmessage = (ev) => {
let d; try { d = JSON.parse(ev.data); } catch (e) { return; }
if (!d || d.type !== 'sensing_update') return;
onFrame(d);
};
ws.onclose = () => { gotFrame = false; $('waiting').classList.add('show'); setBannerNoServer(); scheduleReconnect(); };
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
let reconnectT = null;
function scheduleReconnect() {
if (reconnectT) return;
reconnectT = setTimeout(() => { reconnectT = null; connect(); }, 1500);
}
function onFrame(d) {
gotFrame = true;
lastFrame = d;
$('waiting').classList.remove('show');
const source = d.source || 'unknown';
const nodes = Array.isArray(d.nodes) ? d.nodes : [];
setBannerLive(source, nodes.length);
// measured update rate
const now = performance.now();
frameTimes.push(now);
while (frameTimes.length && now - frameTimes[0] > 2000) frameTimes.shift();
const fps = frameTimes.length > 1 ? (frameTimes.length - 1) / ((frameTimes[frameTimes.length-1] - frameTimes[0]) / 1000) : 0;
const cls = d.classification || {};
const feat = d.features || {};
// info panel
$('m-source').textContent = source.toUpperCase();
$('m-source').className = 'v ' + (source === 'esp32' ? 'green' : 'red');
const presence = !!cls.presence;
$('m-presence').textContent = presence ? (cls.motion_level === 'present_moving' ? 'PRESENT · MOVING' : 'PRESENT') : 'CLEAR';
$('m-presence').className = 'v ' + (presence ? 'green' : 'mute');
$('m-motion').textContent = cls.motion_level || '—';
$('m-conf').textContent = (cls.confidence != null) ? cls.confidence.toFixed(2) : '—';
$('m-persons').textContent = (d.estimated_persons != null) ? d.estimated_persons : '—';
$('m-nodes').textContent = nodes.length + ' (' + nodes.map(n => n.node_id).join(', ') + ')';
$('m-tick').textContent = (d.tick != null) ? d.tick : '—';
$('m-fps').textContent = fps ? fps.toFixed(1) + ' Hz' : '—';
// feature bars (real values, scaled into 0..1 for the bar width only)
const motion = feat.motion_band_power || 0;
const breath = feat.breathing_band_power || 0;
const variance = feat.variance || 0;
const rssi = feat.mean_rssi != null ? feat.mean_rssi : -100;
setBar('bar-motion', 'v-motion', motion / 100, motion.toFixed(1));
setBar('bar-breath', 'v-breath', breath / 100, breath.toFixed(1));
setBar('bar-var', 'v-var', variance / 80, variance.toFixed(1));
// rssi: map -90..-30 dBm -> 0..1
setBar('bar-rssi', 'v-rssi', (rssi + 90) / 60, rssi.toFixed(0));
pushSpark(motion);
// node activity
const activeIds = new Set(nodes.map(n => n.node_id));
[9, 13].forEach(id => setNodeActive(id, activeIds.has(id)));
// heatmap + puck
updateHeatmap(d.signal_field);
updatePuck(d);
}
// =====================================================================
// Optional webcam ground-truth tile (reused from demos/05). Camera is
// separate from CSI sensing — labeled "ground truth when visible".
// =====================================================================
let camStream = null;
$('cam-btn').addEventListener('click', async () => {
const btn = $('cam-btn');
if (camStream) { // toggle off
camStream.getTracks().forEach(t => t.stop());
$('cam-video').style.display = 'none'; camStream = null;
btn.textContent = '▶ enable webcam (optional)';
return;
}
btn.disabled = true; btn.textContent = 'requesting camera…';
try {
camStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false,
});
const v = $('cam-video'); v.srcObject = camStream; v.style.display = 'block';
btn.textContent = '■ stop webcam'; btn.disabled = false;
} catch (e) {
btn.textContent = '✗ camera unavailable'; btn.disabled = false; console.error(e);
setTimeout(() => { if (!camStream) btn.textContent = '▶ enable webcam (optional)'; }, 2000);
}
});
// =====================================================================
// Render loop — smooth the puck toward its real target; pulse rings.
// =====================================================================
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
controls.update();
if (puck.visible) {
puck.position.lerp(puckTarget, 0.18);
const pulse = 0.8 + 0.25 * Math.sin(t * 3.0);
puckRing.scale.set(pulse, pulse, pulse);
puckRing.material.opacity = 0.5 + 0.25 * Math.sin(t * 3.0);
}
// node rings breathe when active
[9,13].forEach(id => {
const g = nodeMeshes[id]; if (!g) return;
const r = g.userData.parts.ring;
const s = 1 + 0.08 * Math.sin(t * 2 + id);
r.scale.set(s, s, s);
});
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// kick off
connect();
</script>
</body>
</html>
-159
View File
@@ -1,159 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WiFlow · live WiFi-inferred pose</title>
<style>
:root{--bg:#0a0c10;--panel:#11151c;--amber:#ffb840;--green:#46e08a;--red:#ff5a5a;--mute:#7d8796;--line:#1d2430}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:#dfe6ee;font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,monospace}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
h1 span{color:var(--amber)}
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
main{display:flex;gap:18px;padding:18px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
canvas{background:#070a0e;border-radius:8px;display:block}
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
.stats{min-width:240px}
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums}
.v.green{color:var(--green)}
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6;max-width:300px}
.note b{color:#dfe6ee}
</style>
</head>
<body>
<header>
<h1>WiFlow · <span>live WiFi-inferred pose</span></h1>
<div id="banner" class="down">CONNECTING…</div>
</header>
<main>
<div class="card">
<div class="label">CSI → pose (skeleton) overlaid on your laptop camera</div>
<div id="stage" style="width:420px;height:560px;border-radius:8px;overflow:hidden;background:#070a0e">
<video id="cam" autoplay muted playsinline style="position:absolute;width:2px;height:2px;opacity:0;pointer-events:none"></video>
<canvas id="cv" width="420" height="560"></canvas>
</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="camBtn" style="background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:7px 14px;font:inherit;font-weight:600;cursor:pointer">enable laptop camera</button>
<select id="camSel" style="display:none;background:var(--panel);color:#dfe6ee;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;max-width:220px"></select>
</div>
<div id="camStatus" style="margin-top:6px;font-size:11px;color:var(--mute)">camera: off</div>
<div class="note" style="margin-top:8px">Camera is a <b>visual reference only</b> — it is NOT fed to the model. Overlay alignment is approximate (model trained in a different camera's frame).</div>
</div>
<div class="card stats">
<div class="label">live</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="src"></span></div>
<div class="row"><span class="k">nodes</span><span class="v" id="nodes"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="pres"></span></div>
<div class="row"><span class="k">motion</span><span class="v" id="motion"></span></div>
<div class="row"><span class="k">pose fps</span><span class="v" id="fps"></span></div>
<div class="note">
This skeleton is inferred <b>from WiFi CSI only</b> — no camera in the loop here. A model was
trained on paired (camera-pose, CSI) data in this room (ADR-079/180).
<br/><br/>
<b>Honest accuracy:</b> ~<b>59.5% PCK@0.10</b> on held-out data (vs a 50% mean-pose baseline →
<b>+9.4 pp real signal</b>). It captures <b>coarse</b> pose; fine detail is weak (PCK@0.05 ≈ 24%).
Same person / room / session — not validated cross-day or through-wall.
</div>
</div>
</main>
<script>
const POSE_WS = (new URLSearchParams(location.search)).get('ws') || `ws://${location.hostname||'localhost'}:8770/pose`;
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const $ = id => document.getElementById(id);
let edges = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
let last = null, frames = 0, t0 = performance.now();
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
// per-joint smoothing (EMA) so dropped/jittery CSI frames render fluidly (ADR-180 dead-reckoning, lite)
let sm = null;
function smooth(kps){
if(!sm){ sm = kps.map(p=>[p[0],p[1]]); return sm; }
const a=0.35; for(let i=0;i<kps.length;i++){ sm[i][0]+=a*(kps[i][0]-sm[i][0]); sm[i][1]+=a*(kps[i][1]-sm[i][1]); }
return sm;
}
const camEl=document.getElementById('cam');
function draw(p){
const W=cv.width, H=cv.height;
// paint the live camera frame onto the canvas (robust — no z-index/overlay tricks)
if(camEl && camEl.videoWidth>0){
ctx.save(); ctx.globalAlpha=0.9;
// cover-fit the camera frame into the canvas
const vr=camEl.videoWidth/camEl.videoHeight, cr=W/H;
let dw=W, dh=H, dx=0, dy=0;
if(vr>cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
} else {
ctx.fillStyle='#070a0e'; ctx.fillRect(0,0,W,H);
}
if(!p || !p.kps){ return; }
const s = smooth(p.kps);
const k = s.map(([x,y])=>[x*W, y*H]);
ctx.lineWidth=5; ctx.strokeStyle=p.presence?'rgba(70,224,138,.95)':'rgba(125,135,150,.8)'; ctx.lineCap='round';
ctx.shadowColor='rgba(70,224,138,.6)'; ctx.shadowBlur=8;
for(const [a,b] of edges){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
ctx.shadowBlur=0;
for(const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle=p.presence?'#ffb840':'#667'; ctx.fill(); }
}
// ---- laptop webcam (visual reference only; NOT fed to the model) ----
let camStream=null;
async function startCam(deviceId){
if(camStream){ camStream.getTracks().forEach(t=>t.stop()); }
const constraints = deviceId ? {video:{deviceId:{exact:deviceId}}} : {video:true};
const st=document.getElementById('camStatus');
try{
st.textContent='camera: requesting…';
camStream = await navigator.mediaDevices.getUserMedia(constraints);
const v=document.getElementById('cam'); v.muted=true; v.srcObject=camStream;
v.onloadedmetadata=()=>{ v.play().catch(err=>st.textContent='camera: play() blocked '+err.name); };
await v.play().catch(()=>{});
const tr=camStream.getVideoTracks()[0]; const ss=tr.getSettings();
// live readout: shows if real frames are flowing (videoWidth>0) and which device
const tick=()=>{ st.textContent = `camera: "${tr.label}" ${v.videoWidth}x${v.videoHeight} ${tr.readyState} ${v.paused?'PAUSED':'playing'}`; };
tick(); setInterval(tick, 1000);
document.getElementById('camBtn').textContent='switch camera ↻';
// populate the picker now that we have permission (labels need permission)
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput');
const sel=document.getElementById('camSel'); sel.style.display = devs.length>1?'inline-block':'none';
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label||('camera '+(i+1))}</option>`).join('');
const cur = camStream.getVideoTracks()[0].getSettings().deviceId; if(cur) sel.value=cur;
}catch(e){
document.getElementById('camBtn').textContent = 'camera error: '+e.name+(e.name==='NotReadableError'?' (in use by Zoom/Teams?)':'');
console.error('getUserMedia', e);
}
}
document.getElementById('camBtn').addEventListener('click', ()=>startCam());
document.getElementById('camSel').addEventListener('change', e=>startCam(e.target.value));
function connect(){
banner('down','CONNECTING…');
const ws = new WebSocket(POSE_WS);
ws.onopen = ()=> banner('sim','WAITING FOR POSE…');
ws.onmessage = ev => {
const d = JSON.parse(ev.data);
if(d.type==='meta'){ edges = d.edges; return; }
if(d.type!=='pose') return;
last=d; frames++;
if(d.src==='esp32') banner('live','LIVE — WiFi-inferred pose (real ESP32 CSI)');
else banner('sim','SIMULATED CSI — not real ('+d.src+')');
$('src').textContent=d.src; $('src').className = d.src==='esp32'?'v green':'v';
$('nodes').textContent=(d.nodes||[]).join(', ')||'—';
$('pres').textContent=d.presence?'PRESENT':'—';
$('motion').textContent=(d.motion!=null?Math.round(d.motion):'—');
};
ws.onclose = ()=>{ banner('down','NO BRIDGE — start wiflow_infer.py'); setTimeout(connect,1500); };
ws.onerror = ()=> ws.close();
}
function loop(){ draw(last); const now=performance.now(); if(now-t0>1000){ $('fps').textContent=frames; frames=0; t0=now; } requestAnimationFrame(loop); }
connect(); loop();
</script>
</body>
</html>
-65
View File
@@ -1,65 +0,0 @@
"""Tiny threaded static server for the through-wall WiFi-CSI sensing demo.
Adapted from examples/three.js/server/serve-demo.py. Serves the
`examples/through-wall/` page so a browser can fetch index.html, then the
page connects directly to the LIVE sensing-server WebSocket at
ws://localhost:8765/ws/sensing (NOT proxied through here).
Why a threaded server (not `python -m http.server`)?
The stdlib SimpleHTTPServer is single-threaded; a browser opens several
parallel connections (HTML + the three.js CDN tags fetch in parallel),
the first eats the worker, the rest can stall. ThreadingHTTPServer fixes it.
IMPORTANT: this serves on port 8080 port 8765 is taken by the
sensing-server's WebSocket. They are two different processes.
Usage:
# 1) start the REAL sensing-server (separate terminal):
# cd v2
# cargo build -p wifi-densepose-sensing-server
# ./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
# 2) start this static server:
python examples/through-wall/serve.py
# 3) open:
# http://localhost:8080/examples/through-wall/index.html
Override the WS endpoint with a query param, e.g.:
http://localhost:8080/examples/through-wall/index.html?ws=ws://192.168.1.20:8765/ws/sensing
"""
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import os
import sys
PORT = int(os.environ.get("PORT", 8080))
# Serve from the repo root regardless of where this script is launched.
# This file lives at examples/through-wall/serve.py — two levels deep.
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
# Aggressive no-cache so the browser ALWAYS fetches the latest
# index.html after edits, even on a soft refresh.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def log_message(self, fmt, *args): # quieter logs
sys.stderr.write("[serve] " + (fmt % args) + "\n")
PAGE = "examples/through-wall/index.html"
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
print(f" open http://localhost:{PORT}/{PAGE}")
print("")
print(" The page connects to the LIVE sensing-server at")
print(" ws://localhost:8765/ws/sensing (start it first — see README.md).")
print(" Override with ?ws=ws://HOST:PORT/ws/sensing")
try:
srv.serve_forever()
except KeyboardInterrupt:
sys.exit(0)
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Rigorous A/B for WiFlow CSI->pose: is the held-out PCK real signal or split leakage?
For a dataset of {csi:[D], kps:17x[x,y,vis]} pairs, train the SAME small MLP under
several train/val SPLITS and report held-out PCK@0.10 vs the mean-pose baseline:
- chronological_80_20 : last 20% in time (val temporally ADJACENT to train -> leaks
via CSI/pose autocorrelation; this is what gave us +9.4)
- random_80_20 : shuffled (val frames interleaved with train -> MAX leak)
- blocked_gap : hold out a contiguous MIDDLE block with a time GAP buffer on
each side so val is NOT adjacent to any train frame -> the
honest, leakage-controlled test
If the model beats baseline on chronological/random but COLLAPSES to ~baseline on
blocked_gap, the apparent signal was temporal leakage, not generalizable CSI->pose.
Usage (ruvultra venv): python wiflow_ab.py --data ~/wiflow-room/dataset.jsonl
"""
import argparse, json, sys
import numpy as np, torch, torch.nn as nn
def _rec(r, X, Y, V, B):
X.append(r["csi"]); kp=r["kps"]
if kp and isinstance(kp[0], (list,tuple)): # 17 x [x,y(,vis)]
Y.append([c for k in kp for c in (k[0],k[1])]); V.append([(k[2] if len(k)>2 else 1.0) for k in kp])
else: # flat 34 (browser export, no vis)
Y.append(list(kp)); V.append([1.0]*17)
B.append(r.get("bucket"))
def load(path):
X,Y,V,B=[],[],[],[]
txt=open(path).read().strip()
if txt[:1] in "[{": # JSON (browser export: dict{samples:[]} or bare array)
d=json.loads(txt)
rows = d if isinstance(d,list) else d.get("samples", d.get("data", []))
for r in rows: _rec(r,X,Y,V,B)
else: # JSONL (python capture)
for line in txt.splitlines():
if line.strip(): _rec(json.loads(line),X,Y,V,B)
return np.array(X,np.float32), np.array(Y,np.float32), np.array(V,np.float32), B
class Net(nn.Module):
def __init__(s,din,dout):
super().__init__()
s.n=nn.Sequential(nn.Linear(din,384),nn.ReLU(),nn.Dropout(.35),
nn.Linear(384,192),nn.ReLU(),nn.Dropout(.35),
nn.Linear(192,96),nn.ReLU(),nn.Linear(96,dout),nn.Sigmoid())
def forward(s,x): return s.n(x)
def pck(pred,gt,vis,thr=0.10):
p=pred.reshape(-1,17,2); g=gt.reshape(-1,17,2)
d=np.linalg.norm(p-g,axis=2); m=vis>0.5
return float((d[m]<thr).mean()) if m.any() else 0.0
def split_idx(n, kind, B=None):
idx=np.arange(n)
if kind=="chronological_80_20":
c=int(n*.8); return idx[:c], idx[c:]
if kind=="random_80_20":
rng=np.random.default_rng(0); p=rng.permutation(n); c=int(n*.8); return p[:c], p[c:]
if kind=="blocked_gap":
# val = contiguous middle 20%; a WIDE 10% time gap each side guarantees no train
# frame is temporally adjacent to a val frame (kills frame-autocorrelation leakage).
v0=int(n*.4); v1=int(n*.6); gap=int(n*.10)
val=idx[v0:v1]; train=np.concatenate([idx[:max(0,v0-gap)], idx[min(n,v1+gap):]])
return train, val
if kind=="grouped_bucket":
# hold out ENTIRE activity buckets -> val poses/activities never seen in train.
# the strictest leakage-free test (only when bucket labels exist).
b=np.array([x if x is not None else -1 for x in B])
uniq=[u for u in sorted(set(b.tolist())) if u!=-1]
if len(uniq)<3: raise ValueError("too few buckets")
hold=set(uniq[::max(1,len(uniq)//3)][:max(1,len(uniq)//3)]) # ~1/3 of activities held out
val=idx[np.isin(b,list(hold))]; train=idx[~np.isin(b,list(hold))]
return train, val
raise ValueError(kind)
def run(X,Y,V,tr,va,epochs=250,seed=0):
torch.manual_seed(seed); np.random.seed(seed) # seed weight init + batch shuffle
dev="cuda" if torch.cuda.is_available() else "cpu"
mu,sd=X[tr].mean(0),X[tr].std(0)+1e-6
Xtr=torch.tensor((X[tr]-mu)/sd).to(dev); Ytr=torch.tensor(Y[tr]).to(dev)
Xva=torch.tensor((X[va]-mu)/sd).to(dev)
net=Net(X.shape[1],Y.shape[1]).to(dev)
opt=torch.optim.Adam(net.parameters(),lr=1e-3,weight_decay=1e-4); lf=nn.MSELoss()
best=(1e9,None)
for ep in range(epochs):
net.train(); perm=torch.randperm(len(Xtr),device=dev)
for i in range(0,len(Xtr),64):
j=perm[i:i+64]; opt.zero_grad(); loss=lf(net(Xtr[j]),Ytr[j]); loss.backward(); opt.step()
net.eval()
with torch.no_grad(): pv=net(Xva).cpu().numpy()
vl=float(((pv-Y[va])**2).mean())
if vl<best[0]: best=(vl,pv)
base=np.tile(Y[tr].mean(0),(len(va),1))
return pck(best[1],Y[va],V[va]), pck(base,Y[va],V[va])
def main():
ap=argparse.ArgumentParser(); ap.add_argument("--data",required=True)
ap.add_argument("--epochs",type=int,default=250); ap.add_argument("--seeds",type=int,default=3)
a=ap.parse_args()
X,Y,V,B=load(a.data); n=len(X)
has_buckets=any(x is not None for x in B)
print(f"[ab] {n} samples, X={X.shape}, buckets={'yes' if has_buckets else 'no'}, "
f"seeds={a.seeds}, epochs={a.epochs}\n")
print(f"{'split':<22}{'model PCK@0.10':>16}{'baseline':>11}{'delta (mean±sd)':>20} verdict")
print("-"*86)
splits=["chronological_80_20","random_80_20","blocked_gap"]+(["grouped_bucket"] if has_buckets else [])
for kind in splits:
try:
tr,va=split_idx(n,kind,B)
ms=[]; bs=[]
for s in range(a.seeds):
m,b=run(X,Y,V,tr,va,a.epochs,seed=s); ms.append(m); bs.append(b)
ms=np.array(ms)*100; bs=np.array(bs)*100; ds=ms-bs
dm,dsd=ds.mean(),ds.std()
# REAL only if the mean delta minus 1 sd still clears the 1.5pp threshold (robust to seed variance)
verdict = "REAL signal" if dm-dsd>1.5 else ("weak/uncertain" if dm>1.5 else "no signal (==baseline)")
print(f"{kind:<22}{ms.mean():>13.1f}±{ms.std():>3.1f}{bs.mean():>10.1f}%{dm:>+12.1f}±{dsd:>4.1f}pp {verdict}")
except Exception as e:
print(f"{kind:<22} skipped: {e}")
print(f"\nmean±sd over {a.seeds} seeds (weight init + batch order). blocked_gap = 10% time gap each")
print("side; grouped_bucket holds out ENTIRE activities (strictest). If only the LEAKY splits")
print("(chronological/random) beat baseline, the apparent signal is leakage, not generalizable pose.")
if __name__=="__main__": main()
File diff suppressed because it is too large Load Diff
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""WiFlow-style camera-supervised capture (ADR-079 / ADR-180).
Runs on a box with BOTH a camera (ground truth) and reachable live CSI:
- opens a camera, runs MediaPipe Pose -> 17 COCO keypoints (the LABEL),
- subscribes to the sensing-server /ws/sensing (the INPUT: CSI features +
20x20 signal-field),
- writes timestamp-aligned (csi -> pose) pairs to a JSONL dataset.
This is the *collect* phase of camera-supervised CSI->pose training. The camera
and the CSI nodes MUST see the same person in the same space at the same time,
or the pairs are meaningless. Honest by construction: we only emit a pair when
BOTH a confident camera pose AND a live (source=esp32) CSI frame are present in
the same ~100 ms window.
Usage (on ruvultra, with the CSI tunneled to localhost:8765):
python3 wiflow_capture.py --ws ws://localhost:8765/ws/sensing \
--cam 0 --out ~/wiflow-room/dataset.jsonl --seconds 180
"""
import argparse, asyncio, json, time, threading, sys, os
from collections import deque
import urllib.request
import cv2
import numpy as np
import mediapipe as mp
from mediapipe.tasks.python import BaseOptions
from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions, RunningMode
import websockets
_MODEL_URL = ("https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task")
def ensure_model(path: str) -> str:
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
print(f"[capture] downloading pose model -> {path}", flush=True)
urllib.request.urlretrieve(_MODEL_URL, path)
return path
# MediaPipe Pose (33 landmarks) -> 17 COCO keypoints (same mapping as
# scripts/collect-ground-truth.py, ADR-079).
COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
COCO_NAMES = ["nose","l_eye","r_eye","l_ear","r_ear","l_sho","r_sho","l_elb",
"r_elb","l_wri","r_wri","l_hip","r_hip","l_knee","r_knee","l_ank","r_ank"]
# ---- shared state between the CSI (async) thread and the camera (sync) loop ----
_latest_csi = {"t": 0.0, "frame": None}
_csi_lock = threading.Lock()
_stop = threading.Event()
def csi_thread(ws_url: str):
"""Background thread: keep the most recent LIVE csi frame in _latest_csi."""
async def run():
while not _stop.is_set():
try:
async with websockets.connect(ws_url, open_timeout=8, ping_interval=20) as ws:
while not _stop.is_set():
msg = await asyncio.wait_for(ws.recv(), timeout=8)
d = json.loads(msg)
with _csi_lock:
_latest_csi["t"] = time.time()
_latest_csi["frame"] = d
except Exception as e:
print(f"[csi] reconnect ({e})", flush=True)
await asyncio.sleep(1.0)
asyncio.new_event_loop().run_until_complete(run())
def csi_vector(frame: dict):
"""Flatten a csi frame to a fixed-length input vector: features + field."""
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi", 0.0), f.get("variance", 0.0),
f.get("motion_band_power", 0.0), f.get("breathing_band_power", 0.0)]
# per-node mean_rssi/variance/motion for up to the 2 nodes (9, 13)
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9, 13):
nf = pernode.get(nid, {})
feats += [nf.get("mean_rssi", 0.0), nf.get("variance", 0.0), nf.get("motion_band_power", 0.0)]
field = (frame.get("signal_field", {}) or {}).get("values") or []
field = (field + [0.0] * 400)[:400]
return feats + field # 4 + 6 + 400 = 410-d
def main():
ap = argparse.ArgumentParser(description="WiFlow camera-supervised CSI<->pose capture (ADR-180).")
ap.add_argument("--ws", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--cam", type=int, default=0)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/dataset.jsonl"))
ap.add_argument("--seconds", type=int, default=180)
ap.add_argument("--min-vis", type=float, default=0.5, help="min mean landmark visibility to accept a pose label")
ap.add_argument("--max-skew-ms", type=float, default=150, help="max csi/pose time skew to pair")
ap.add_argument("--require-esp32", action="store_true", default=True,
help="only pair when csi source==esp32 (real). Default on.")
args = ap.parse_args()
os.makedirs(os.path.dirname(args.out), exist_ok=True)
th = threading.Thread(target=csi_thread, args=(args.ws,), daemon=True)
th.start()
cap = cv2.VideoCapture(args.cam)
if not cap.isOpened():
print(f"ERROR: cannot open camera {args.cam}", file=sys.stderr); sys.exit(2)
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480
model_path = ensure_model(os.path.expanduser("~/wiflow-room/pose_landmarker_lite.task"))
landmarker = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
base_options=BaseOptions(model_asset_path=model_path),
running_mode=RunningMode.IMAGE, min_pose_detection_confidence=0.5))
n_pairs = 0; n_nopose = 0; n_nocsi = 0; n_skew = 0; n_sim = 0
t0 = time.time()
print(f"[capture] camera {args.cam} {W}x{H} -> {args.out} for {args.seconds}s")
print("[capture] stand in view AND in the CSI field; move/walk so poses vary. Ctrl-C to stop.")
with open(args.out, "a") as out:
try:
while time.time() - t0 < args.seconds:
ok, frame = cap.read()
if not ok:
continue
now = time.time()
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
res = landmarker.detect(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb))
if not res.pose_landmarks:
n_nopose += 1; continue
lm = res.pose_landmarks[0]
kps = [[lm[i].x, lm[i].y, lm[i].visibility] for i in COCO_FROM_MP]
vis = float(np.mean([k[2] for k in kps]))
if vis < args.min_vis:
n_nopose += 1; continue
with _csi_lock:
ct = _latest_csi["t"]; cf = _latest_csi["frame"]
if cf is None:
n_nocsi += 1; continue
if (now - ct) * 1000.0 > args.max_skew_ms:
n_skew += 1; continue
if args.require_esp32 and cf.get("source") != "esp32":
n_sim += 1; continue
rec = {"t": now, "vis": round(vis, 3),
"kps": [[round(x, 4), round(y, 4), round(v, 3)] for x, y, v in kps],
"csi": csi_vector(cf),
"src": cf.get("source"),
"nodes": sorted(n.get("node_id") for n in cf.get("nodes", []) if n.get("node_id") is not None)}
out.write(json.dumps(rec) + "\n")
n_pairs += 1
if n_pairs % 30 == 0:
out.flush()
el = int(now - t0)
print(f"[capture] t+{el:3d}s pairs={n_pairs} (skip: nopose={n_nopose} nocsi={n_nocsi} skew={n_skew} sim={n_sim})", flush=True)
except KeyboardInterrupt:
print("\n[capture] stopped by user")
_stop.set(); cap.release()
print(f"[capture] DONE. wrote {n_pairs} paired samples to {args.out}")
print(f"[capture] skipped: no-pose={n_nopose} no-csi={n_nocsi} skew={n_skew} simulated={n_sim}")
if n_pairs == 0:
print("[capture] WARNING: 0 pairs — check camera sees you AND csi source==esp32 (live).")
if __name__ == "__main__":
main()
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""Live CSI->pose inference bridge (ADR-180).
Runs on the box with the live CSI. Loads the camera-supervised model (numpy,
no torch needed), subscribes to /ws/sensing, runs a forward pass per frame, and
broadcasts the predicted 17-keypoint pose to HTML clients on ws://:8770/pose.
python wiflow_infer.py --model model/model.npz \
--in ws://localhost:8765/ws/sensing --port 8770
"""
import argparse, asyncio, json, os
import numpy as np
import websockets
# COCO skeleton edges (for the client; sent once in 'meta')
EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]]
def csi_vector(frame):
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi",0.0), f.get("variance",0.0),
f.get("motion_band_power",0.0), f.get("breathing_band_power",0.0)]
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9,13):
nf = pernode.get(nid,{}); feats += [nf.get("mean_rssi",0.0), nf.get("variance",0.0), nf.get("motion_band_power",0.0)]
field = (frame.get("signal_field",{}) or {}).get("values") or []
field = (field + [0.0]*400)[:400]
return np.array(feats + field, np.float32)
class Model:
def __init__(self, path):
z = np.load(path)
self.mu, self.sd = z["mu"], z["sd"]
self.W = [z["net_0_weight"], z["net_3_weight"], z["net_6_weight"], z["net_8_weight"]]
self.b = [z["net_0_bias"], z["net_3_bias"], z["net_6_bias"], z["net_8_bias"]]
def __call__(self, x):
h = (x - self.mu) / self.sd
for i in range(3):
h = np.maximum(0.0, h @ self.W[i].T + self.b[i]) # Linear+ReLU
out = 1.0/(1.0+np.exp(-(h @ self.W[3].T + self.b[3]))) # Linear+Sigmoid -> 34
return out.reshape(17,2)
CLIENTS = set()
LATEST = {"pose": None}
async def serve_client(ws):
CLIENTS.add(ws)
try:
await ws.send(json.dumps({"type":"meta","edges":EDGES}))
async for _ in ws: # client is read-only; just keep alive
pass
except Exception:
pass
finally:
CLIENTS.discard(ws)
async def infer_loop(model, in_url):
while True:
try:
async with websockets.connect(in_url, open_timeout=8, ping_interval=20) as ws:
async for msg in ws:
d = json.loads(msg)
kp = model(csi_vector(d))
cls = d.get("classification",{})
payload = {"type":"pose","src":d.get("source"),
"presence":bool(cls.get("presence")),
"motion":(d.get("features",{}) or {}).get("motion_band_power"),
"kps":[[round(float(x),4),round(float(y),4)] for x,y in kp],
"nodes":sorted(n.get("node_id") for n in d.get("nodes",[]) if n.get("node_id") is not None)}
LATEST["pose"]=payload
if CLIENTS:
dead=[]
for c in list(CLIENTS):
try: await c.send(json.dumps(payload))
except Exception: dead.append(c)
for c in dead: CLIENTS.discard(c)
except Exception as e:
print(f"[infer] reconnect ({e})", flush=True); await asyncio.sleep(1.0)
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.join(os.path.dirname(__file__),"model","model.npz"))
ap.add_argument("--in", dest="in_url", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--port", type=int, default=8770)
args = ap.parse_args()
model = Model(args.model)
print(f"[infer] model {args.model} loaded; serving predicted poses on ws://0.0.0.0:{args.port}/pose")
async with websockets.serve(serve_client, "0.0.0.0", args.port):
await infer_loop(model, args.in_url)
if __name__ == "__main__":
asyncio.run(main())
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""Train a CSI->pose model on the camera-supervised dataset (ADR-079/180).
Input : 410-d CSI vector (4 global feats + 6 per-node + 400 signal-field).
Target : 17 COCO keypoints (x,y), normalized 0..1 from the camera (ground truth).
Reports HONEST held-out PCK@k + MPJPE on a chronological val split (the last
20% of the session never trained on), so the number is not leaked.
Usage (ruvultra venv):
python wiflow_train.py --data ~/wiflow-room/dataset.jsonl --out ~/wiflow-room/model.pt
"""
import argparse, json, math, os, sys
import numpy as np
import torch, torch.nn as nn
def load(path):
X, Y, V = [], [], []
with open(path) as f:
for line in f:
r = json.loads(line)
X.append(r["csi"]) # 410
kp = r["kps"] # 17 x [x,y,vis]
Y.append([c for k in kp for c in (k[0], k[1])]) # 34
V.append([k[2] for k in kp]) # 17 visibilities
return np.array(X, np.float32), np.array(Y, np.float32), np.array(V, np.float32)
class Net(nn.Module):
def __init__(self, din, dout):
super().__init__()
self.net = nn.Sequential(
nn.Linear(din, 512), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, dout), nn.Sigmoid()) # coords in 0..1
def forward(self, x): return self.net(x)
def pck(pred, gt, vis, thr):
# pred/gt: [N,34] -> [N,17,2]; PCK@thr in normalized image units, visible kps only
p = pred.reshape(-1, 17, 2); g = gt.reshape(-1, 17, 2)
d = np.linalg.norm(p - g, axis=2) # [N,17]
m = vis > 0.5
return float((d[m] < thr).mean()) if m.any() else 0.0, float(d[m].mean()) if m.any() else float("nan")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/model.pt"))
ap.add_argument("--epochs", type=int, default=300)
ap.add_argument("--bs", type=int, default=64)
args = ap.parse_args()
X, Y, V = load(args.data)
n = len(X)
print(f"[train] {n} samples, X={X.shape} Y={Y.shape}")
if n < 200:
print("[train] too few samples"); sys.exit(2)
# chronological split (NOT shuffled) so val is a held-out time segment -> honest
cut = int(n * 0.8)
mu, sd = X[:cut].mean(0), X[:cut].std(0) + 1e-6 # standardize on train only
Xn = (X - mu) / sd
dev = "cuda" if torch.cuda.is_available() else "cpu"
Xtr = torch.tensor(Xn[:cut]).to(dev); Ytr = torch.tensor(Y[:cut]).to(dev)
Xva = torch.tensor(Xn[cut:]).to(dev); Yva = Y[cut:]; Vva = V[cut:]
# mean-pose baseline (predict the train-mean pose for everything) — the bar to beat
mean_pose = Y[:cut].mean(0)
base_pck, base_mpjpe = pck(np.tile(mean_pose, (len(Yva), 1)), Yva, Vva, 0.10)
net = Net(X.shape[1], Y.shape[1]).to(dev)
opt = torch.optim.Adam(net.parameters(), lr=1e-3, weight_decay=1e-4)
lossf = nn.MSELoss()
best = (1e9, None)
for ep in range(args.epochs):
net.train(); perm = torch.randperm(len(Xtr), device=dev)
for i in range(0, len(Xtr), args.bs):
idx = perm[i:i+args.bs]
opt.zero_grad(); out = net(Xtr[idx]); loss = lossf(out, Ytr[idx]); loss.backward(); opt.step()
if (ep + 1) % 20 == 0 or ep == args.epochs - 1:
net.eval()
with torch.no_grad(): pv = net(Xva).cpu().numpy()
p10, mpj = pck(pv, Yva, Vva, 0.10); p05, _ = pck(pv, Yva, Vva, 0.05)
vloss = float(((pv - Yva) ** 2).mean())
print(f"[train] ep{ep+1:3d} val_mse={vloss:.4f} PCK@0.10={p10*100:.1f}% PCK@0.05={p05*100:.1f}% MPJPE={mpj:.4f}")
if vloss < best[0]: best = (vloss, {"sd": net.state_dict(), "p10": p10, "p05": p05, "mpj": mpj})
torch.save({"model": best[1]["sd"], "mu": mu, "sd": sd, "din": X.shape[1]}, args.out)
print("\n==================== HONEST RESULT (held-out 20%, never trained) ====================")
print(f" MEAN-POSE BASELINE : PCK@0.10 = {base_pck*100:.1f}% MPJPE = {base_mpjpe:.4f} (the bar to beat)")
print(f" CSI->POSE MODEL : PCK@0.10 = {best[1]['p10']*100:.1f}% PCK@0.05 = {best[1]['p05']*100:.1f}% MPJPE = {best[1]['mpj']:.4f}")
delta = (best[1]['p10'] - base_pck) * 100
print(f" VERDICT: model {'BEATS' if delta>1 else 'does NOT beat'} mean-pose baseline by {delta:+.1f} pp "
f"-> {'real CSI->pose signal' if delta>1 else 'NO usable CSI->pose signal (honest negative)'}")
print(f" saved -> {args.out}")
if __name__ == "__main__":
main()
@@ -468,29 +468,3 @@ menu "Mock CSI (QEMU Testing)"
depends on CSI_MOCK_ENABLED
default n
endmenu
menu "Onboard LED (ADR-183)"
config LED_GAMMA_VIZ
bool "Onboard WS2812: 40 Hz gamma flicker + CSI-motion colour"
default y
help
Drive the onboard WS2812 as a GENUS-style 40 Hz gamma square wave
(12.5 ms on / 12.5 ms off, 50% duty). The ON-phase colour is live
CSI motion (edge motion_energy) mapped through the ruv-neural-viz
viridis colormap (still=purple, moving=yellow).
Disable to leave the LED off at boot — lower power, no flicker.
NOTE: a 40 Hz flicker can affect photosensitive users; disable or
shield the LED in those environments. Not a medical device.
config LED_MOTION_FULLSCALE_MILLI
int "Motion value (x1000) that saturates the colormap to yellow"
depends on LED_GAMMA_VIZ
default 250
range 1 100000
help
edge motion_energy that maps to the top (yellow) of the viridis
colormap, in milli-units (250 = 0.25). Lower = more sensitive
(reaches yellow with less motion).
endmenu
@@ -319,9 +319,7 @@ static void emit_feature_state(void)
(uint64_t)esp_timer_get_time(),
profile);
/* feature_state is ~1 Hz and small — priority path so the CSI ENOMEM
* backoff can't starve it (#1183). */
int sent = stream_sender_send_priority((const uint8_t *)&pkt, sizeof(pkt));
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
if (sent < 0) {
ESP_LOGW(TAG, "feature_state emit failed");
}
@@ -335,14 +333,11 @@ static void slow_loop_cb(TimerHandle_t t)
* detect sync-error drift. */
uint8_t nid[8];
node_id_bytes(nid);
/* #1183: report the actual send result — the old log printed "HEALTH sent"
* unconditionally even when rv_mesh_send returned ESP_FAIL. */
esp_err_t health_rc = rv_mesh_send_health(s_role, s_mesh_epoch, nid);
rv_mesh_send_health(s_role, s_mesh_epoch, nid);
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH %s",
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH sent",
(unsigned)s_state, (unsigned)s_feature_state_seq,
(unsigned)s_role, (unsigned)s_mesh_epoch,
health_rc == ESP_OK ? "sent" : "FAILED");
(unsigned)s_role, (unsigned)s_mesh_epoch);
}
/* ---- Public API ---- */
+1 -3
View File
@@ -341,9 +341,7 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
memcpy(&sync[24], &s_sequence, 4); /* high-water seq for pairing */
uint32_t zero32 = 0;
memcpy(&sync[28], &zero32, 4); /* reserved (room for leader_id low32) */
/* Sync packets are 32 B at ~0.5 Hz — priority path so the CSI
* ENOMEM backoff can't starve cross-node time alignment (#1183). */
int sr = stream_sender_send_priority(sync, sizeof(sync));
int sr = stream_sender_send(sync, sizeof(sync));
static uint32_t s_sync_count = 0;
s_sync_count++;
if (s_sync_count <= 3 || (s_sync_count % 60) == 0) {
@@ -114,19 +114,6 @@ esp_err_t display_task_start(void)
/* Init touch (optional) */
esp_err_t touch_ret = display_hal_init_touch();
/* The SH8601 QSPI panel is write-only — display_hal_init_panel() above "succeeds"
* even on a bare board with no panel attached, so it cannot detect absence. The
* FT3168 touch controller is an I2C device with readback and is always present on
* the Touch-AMOLED board. If touch is absent, the panel "success" was a false-
* positive on a display-less DevKit: bail to headless so display_is_active() stays
* false and CSI upgrades to MGMT+DATA capture instead of starving at MGMT-only
* (RuView#1000). */
if (touch_ret != ESP_OK) {
ESP_LOGW(TAG, "No FT3168 touch readback — SH8601 probe was a false-positive on a "
"display-less board; running headless so CSI captures (#1000)");
return ESP_OK;
}
/* Initialize LVGL */
lv_init();
+5 -71
View File
@@ -144,54 +144,6 @@ static void wifi_init_sta(void)
}
}
#if CONFIG_LED_GAMMA_VIZ
/* Viridis colormap (60 steps), generated from ruv-neural-viz::ColorMap::viridis()
* the rUv-Neural brain-topology colormap, now no_std (ruvnet/ruv-neural#3 /
* RuView#1126). Used as the ON-phase colour of the 40 Hz gamma flicker below:
* dark-purple (still) -> teal -> green -> yellow (strong motion). */
static const uint8_t VIRIDIS_LUT[60][3] = {
{ 68, 1, 84},{ 67, 6, 88},{ 67, 12, 91},{ 66, 17, 95},{ 66, 23, 99},
{ 65, 28,103},{ 64, 34,106},{ 64, 39,110},{ 63, 45,114},{ 63, 50,118},
{ 62, 56,121},{ 61, 61,125},{ 61, 67,129},{ 60, 72,132},{ 59, 78,136},
{ 59, 83,139},{ 57, 87,139},{ 55, 92,139},{ 53, 96,139},{ 52,100,139},
{ 50,104,139},{ 48,109,139},{ 46,113,139},{ 44,117,140},{ 43,122,140},
{ 41,126,140},{ 39,130,140},{ 37,134,140},{ 36,139,140},{ 34,143,140},
{ 35,147,139},{ 39,151,136},{ 43,154,133},{ 47,158,130},{ 52,162,127},
{ 56,166,124},{ 60,170,121},{ 64,173,119},{ 68,177,116},{ 72,181,113},
{ 76,185,110},{ 81,189,107},{ 85,192,104},{ 89,196,102},{ 93,200, 99},
{102,203, 95},{113,205, 91},{124,207, 87},{134,209, 82},{145,211, 78},
{156,213, 74},{167,215, 70},{178,217, 66},{188,219, 62},{199,221, 58},
{210,223, 54},{221,225, 49},{231,227, 45},{242,229, 41},{253,231, 37},
};
static led_strip_handle_t s_viz_led;
/* motion_energy that saturates the colormap to yellow (CONFIG, milli-units). */
#define LED_MOTION_FULLSCALE ((float)CONFIG_LED_MOTION_FULLSCALE_MILLI / 1000.0f)
/* GENUS-style 40 Hz gamma flicker: full on/off square wave, 50% duty (toggled
* every 12.5 ms 40 Hz). The ON colour is live CSI motion (edge motion_energy)
* mapped through the ruv-neural-viz viridis LUT still=purple, moving=yellow.
* So the LED is a real 40 Hz gamma stimulus whose hue tracks sensed motion. */
static void led_gamma_40hz_cb(void *arg)
{
static bool on = false;
on = !on;
if (on) {
edge_vitals_pkt_t v;
float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f;
float norm = m / LED_MOTION_FULLSCALE;
if (norm < 0.0f) norm = 0.0f;
if (norm > 1.0f) norm = 1.0f;
int idx = (int)(norm * 59.0f + 0.5f);
const uint8_t *c = VIRIDIS_LUT[idx];
led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */
} else {
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */
}
led_strip_refresh(s_viz_led);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
void app_main(void)
{
/* Initialize NVS */
@@ -221,16 +173,15 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 see #962). On S3 we drive 48 (the
* common module). On C6, GPIO 38/48 don't exist (only 0-30) gate by target.
* Behaviour is set by CONFIG_LED_GAMMA_VIZ (ADR-183): on = 40 Hz gamma flicker
* coloured by CSI motion; off = clear the LED at boot. */
/* Turn off onboard WS2812 LED.
* S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8.
* On C6, GPIO 38 doesn't exist (only 0-30) gate the init by target. */
#if defined(CONFIG_IDF_TARGET_ESP32C6)
const int led_gpio = 8;
#else
const int led_gpio = 48;
const int led_gpio = 38;
#endif
led_strip_handle_t led_strip;
led_strip_config_t strip_config = {
.strip_gpio_num = led_gpio,
.max_leds = 1,
@@ -242,26 +193,9 @@ void app_main(void)
.resolution_hz = 10 * 1000 * 1000, // 10MHz
.flags.with_dma = false,
};
#if CONFIG_LED_GAMMA_VIZ
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &s_viz_led) == ESP_OK) {
const esp_timer_create_args_t viz_args = {
.callback = &led_gamma_40hz_cb,
.name = "led_gamma_40hz",
};
esp_timer_handle_t viz_timer;
if (esp_timer_create(&viz_args, &viz_timer) == ESP_OK) {
esp_timer_start_periodic(viz_timer, 12500); // 12.5 ms toggle → 40 Hz square wave
ESP_LOGI(TAG, "Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO %d", led_gpio);
}
}
#else
/* Viz disabled — clear the onboard LED at boot and release the RMT channel. */
led_strip_handle_t led_strip;
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
led_strip_clear(led_strip);
led_strip_del(led_strip);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).
* Initialized BEFORE WiFi so it's available even when WiFi STA can't
@@ -1,37 +0,0 @@
/**
* @file mmwave_detect.h
* @brief Pure (host-testable) mmWave frame-validation predicates for probe-time
* sensor detection. No ESP-IDF deps safe to #include in a host unit test.
*
* Detection must validate a *full* frame, never a bare header byte/pattern: a
* floating UART with no sensor reads line noise that can contain header-looking
* bytes, which the old loose checks mistook for a real sensor (#1107 MR60,
* #1135 LD2410). These predicates are the validate-before-trust gate.
*/
#ifndef MMWAVE_DETECT_H
#define MMWAVE_DETECT_H
#include <stdint.h>
#include <stdbool.h>
/**
* True iff buf[i..] begins a *validated* LD2410 report frame within [0,len):
* F4 F3 F2 F1 | len(LE,2) | data[len] | F8 F7 F6 F5
* Requires the head magic, a sane intra-frame length, AND the matching tail at
* head+6+len. Pure noise that merely contains 0xF4F3F2F1 fails the tail check.
*/
static inline bool mmwave_ld2410_valid_at(const uint8_t *buf, int i, int len)
{
if (i < 0 || i + 5 >= len) return false;
if (!(buf[i] == 0xF4 && buf[i+1] == 0xF3 && buf[i+2] == 0xF2 && buf[i+3] == 0xF1))
return false;
uint16_t flen = (uint16_t)buf[i+4] | ((uint16_t)buf[i+5] << 8);
/* Real LD2410 report frames are small (basic=13, engineering=35). */
if (flen < 1 || flen > 64) return false;
int tail = i + 6 + (int)flen;
if (tail + 3 >= len) return false;
return buf[tail] == 0xF8 && buf[tail+1] == 0xF7
&& buf[tail+2] == 0xF6 && buf[tail+3] == 0xF5;
}
#endif /* MMWAVE_DETECT_H */
+10 -22
View File
@@ -26,7 +26,6 @@
*/
#include "mmwave_sensor.h"
#include "mmwave_detect.h"
#include <string.h>
#include <math.h>
@@ -388,26 +387,14 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (len <= 0) continue;
for (int i = 0; i < len; i++) {
/* MR60BHA2: require a *validated* 8-byte header — SOF (0x01) + a valid
* header checksum (over bytes 0..6) + a known frame type (0x0A__ or
* 0x0F09) NOT a bare 0x01 byte. A floating UART1 with no sensor reads
* noise full of 0x01s, which the old `buf[i] == MR60_SOF` check mistook
* for a real sensor (false "Detected MR60BHA2", #1107). */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD && i + 7 < len) {
const uint8_t *h = &buf[i];
if (mr60_calc_checksum(h, 7) == h[7]) {
uint16_t type = ((uint16_t)h[5] << 8) | h[6];
if ((type >> 8) == 0x0A || type == 0x0F09) {
mr60_sof_seen++;
}
}
/* MR60BHA2: SOF = 0x01, followed by valid-looking frame_id bytes */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD) {
mr60_sof_seen++;
}
/* LD2410: require a *full validated* report frame, not just the
* 4-byte head. A floating UART1 at 256000 baud can emit the head
* pattern 0xF4F3F2F1 from line noise (#1135 bug #2). The shared
* predicate (host-unit-tested in mmwave_detect.h) demands a sane
* intra-frame length AND the matching tail 0xF8F7F6F5. */
if (baud == MMWAVE_LD2410_BAUD && mmwave_ld2410_valid_at(buf, i, len)) {
/* LD2410: 4-byte header 0xF4F3F2F1 */
if (i + 3 < len && buf[i] == 0xF4 && buf[i+1] == 0xF3
&& buf[i+2] == 0xF2 && buf[i+3] == 0xF1
&& baud == MMWAVE_LD2410_BAUD) {
ld2410_header_seen++;
}
}
@@ -416,8 +403,9 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (ld2410_header_seen >= 2) return MMWAVE_TYPE_LD2410;
}
/* No weak single-hit fallback: line noise can produce a stray match, so a real
* sensor must clear the 3 (MR60) / 2 (LD2410) validated-frame thresholds. */
if (mr60_sof_seen > 0) return MMWAVE_TYPE_MR60BHA2;
if (ld2410_header_seen > 0) return MMWAVE_TYPE_LD2410;
return MMWAVE_TYPE_NONE;
}
+1 -3
View File
@@ -188,9 +188,7 @@ size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
{
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
/* Mesh control packets (HEALTH, anomaly) are low-rate and tiny — send them
* on the priority path so the CSI ENOMEM backoff can't starve them (#1183). */
int sent = stream_sender_send_priority(frame, len);
int sent = stream_sender_send(frame, len);
if (sent < 0) {
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
(unsigned)len);
+5 -48
View File
@@ -26,16 +26,9 @@ static struct sockaddr_in s_dest_addr;
* rapid-fire CSI callbacks can exhaust the pbuf pool and crash the device.
*/
static int64_t s_backoff_until_us = 0; /* esp_timer timestamp to resume */
#define ENOMEM_COOLDOWN_MS 100 /* base backoff; doubles per streak */
#define ENOMEM_COOLDOWN_MAX_MS 2000 /* cap on the exponential backoff */
#define ENOMEM_COOLDOWN_MS 100 /* suppress sends for 100 ms */
#define ENOMEM_LOG_INTERVAL 50 /* log every Nth suppressed send */
static uint32_t s_enomem_suppressed = 0;
/* Consecutive ENOMEM episodes without an intervening successful send. A fixed
* 100 ms backoff is too short to drain sustained lwIP/WiFi buffer pressure
* (#1135 bug #1: tier-2 + concurrent TX keeps the node stuck), so the backoff
* grows 1002004002000 ms per streak and resets on the first send that
* succeeds. */
static uint32_t s_enomem_streak = 0;
static int sender_init_internal(const char *ip, uint16_t port)
{
@@ -100,52 +93,16 @@ int stream_sender_send(const uint8_t *data, size_t len)
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
if (sent < 0) {
if (errno == ENOMEM) {
/* Exponential backoff: double the cooldown each consecutive ENOMEM
* (capped) so sustained buffer pressure actually drains instead of
* the node re-failing every 100 ms forever (#1135 bug #1). */
uint32_t shift = s_enomem_streak < 5 ? s_enomem_streak : 5;
uint32_t cooldown = ENOMEM_COOLDOWN_MS << shift;
if (cooldown > ENOMEM_COOLDOWN_MAX_MS) cooldown = ENOMEM_COOLDOWN_MAX_MS;
s_enomem_streak++;
s_backoff_until_us = esp_timer_get_time() + (int64_t)cooldown * 1000;
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %lu ms (streak %lu)",
(unsigned long)cooldown, (unsigned long)s_enomem_streak);
/* Start backoff to let lwIP reclaim buffers */
s_backoff_until_us = esp_timer_get_time() +
(int64_t)ENOMEM_COOLDOWN_MS * 1000;
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %d ms", ENOMEM_COOLDOWN_MS);
} else {
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
}
return -1;
}
/* A send got through — buffer pressure cleared; reset the backoff streak. */
s_enomem_streak = 0;
return sent;
}
int stream_sender_send_priority(const uint8_t *data, size_t len)
{
if (s_sock < 0) {
return -1;
}
/* Priority path (#1183): low-rate control packets (feature_state, HEALTH,
* mesh sync) bypass the global ENOMEM backoff gate so the high-rate CSI
* stream cannot starve them. These are 48 B at 1 Hz negligible pbuf
* pressure, so they won't re-trigger the crash cascade that the backoff
* (driven by the 50 Hz CSI flood) exists to prevent.
*
* Crucially, an ENOMEM here is reported quietly and does NOT extend the
* global streak/backoff: a tiny control packet failing is a symptom of
* the bulk-stream pressure, not a cause, so it must not feed the cooldown
* that suppresses the next CSI frame. Likewise a success does not reset
* the streak the bulk path owns that signal. */
int sent = sendto(s_sock, data, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
if (sent < 0) {
if (errno != ENOMEM) {
ESP_LOGW(TAG, "priority sendto failed: errno %d", errno);
}
return -1;
}
return sent;
}
@@ -36,20 +36,6 @@ int stream_sender_init_with(const char *ip, uint16_t port);
*/
int stream_sender_send(const uint8_t *data, size_t len);
/**
* Send a low-rate control packet, bypassing the ENOMEM backoff gate (#1183).
*
* Intended for 48 B, 1 Hz control traffic (feature_state, HEALTH, mesh
* sync) that must not be starved by the global backoff the high-rate CSI
* stream triggers. An ENOMEM on this path is reported quietly and does NOT
* extend or reset the global backoff streak.
*
* @param data Frame data buffer.
* @param len Length of data to send.
* @return Number of bytes sent, or -1 on error.
*/
int stream_sender_send_priority(const uint8_t *data, size_t len);
/**
* Close the UDP sender socket.
*/
+4 -15
View File
@@ -44,9 +44,9 @@ FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests
test_vitals run_vitals host_tests
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@@ -69,19 +69,8 @@ test_vitals: test_vitals_count_presence.c $(MAIN_DIR)/edge_processing.h
run_vitals: test_vitals
./test_vitals
# --- mmWave LD2410 detection predicate (#1135 bug #2) ---
# Host-side, no libFuzzer. Proves a floating-UART head pattern (0xF4F3F2F1)
# without a valid frame length+tail is REJECTED, so a phantom LD2410 is never
# detected on a node with no sensor wired. Tests the real predicate the
# firmware uses (../main/mmwave_detect.h) — test and firmware can't disagree.
test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
run_mmwave_detect: test_mmwave_detect
./test_mmwave_detect
host_tests: run_adr110 run_vitals run_mmwave_detect
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
host_tests: run_adr110 run_vitals
@echo "Host tests passed (ADR-110 + vitals #998/#996)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -1,80 +0,0 @@
/**
* @file test_mmwave_detect.c
* @brief Host-side unit tests for the LD2410 frame-validation predicate (#1135).
*
* Proves the phantom-detection fix: a floating UART can emit the 4-byte head
* 0xF4F3F2F1, but the predicate rejects it unless a sane length + matching tail
* 0xF8F7F6F5 are also present. Tests the REAL predicate from mmwave_detect.h
* (the same code the firmware's probe_at_baud calls).
*
* cc -std=c99 -Wall -I../main -o test_mmwave_detect test_mmwave_detect.c && ./test_mmwave_detect
*
* Exits 0 on all-pass; prints the failing case otherwise.
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "mmwave_detect.h"
static int failures = 0;
#define CHECK(cond, msg) do { \
if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \
else { printf("ok: %s\n", msg); } \
} while (0)
/* Build a valid LD2410 report frame: F4F3F2F1 | len(LE) | data[len] | F8F7F6F5 */
static int make_frame(uint8_t *out, uint16_t dlen)
{
int n = 0;
out[n++] = 0xF4; out[n++] = 0xF3; out[n++] = 0xF2; out[n++] = 0xF1;
out[n++] = (uint8_t)(dlen & 0xFF); out[n++] = (uint8_t)(dlen >> 8);
for (uint16_t k = 0; k < dlen; k++) out[n++] = (uint8_t)(0xAA ^ k);
out[n++] = 0xF8; out[n++] = 0xF7; out[n++] = 0xF6; out[n++] = 0xF5;
return n;
}
int main(void)
{
uint8_t buf[256];
/* 1. A real basic-report frame (data len 13) validates. */
int n = make_frame(buf, 13);
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid basic frame (len=13) accepted");
/* 2. A real engineering-report frame (data len 35) validates. */
n = make_frame(buf, 35);
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid engineering frame (len=35) accepted");
/* 3. Head magic present but NO valid tail — the #1135 phantom case. */
memset(buf, 0x00, sizeof(buf));
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=13; buf[5]=0;
/* data present but tail is zeros, not F8F7F6F5 */
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "head magic without valid tail REJECTED (#1135)");
/* 4. Head magic with insane length is rejected. */
memset(buf, 0xFF, sizeof(buf));
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=0xFF; buf[5]=0xFF; /* len=65535 */
CHECK(!mmwave_ld2410_valid_at(buf, 0, 200), "head magic with oversized length REJECTED");
/* 5. Pure noise (no head) is rejected. */
for (int k = 0; k < 64; k++) buf[k] = (uint8_t)(0x5A + k);
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "non-header noise REJECTED");
/* 6. Truncated frame (tail would run past the buffer) is rejected. */
n = make_frame(buf, 13);
CHECK(!mmwave_ld2410_valid_at(buf, 0, n - 2), "truncated frame (tail past buffer) REJECTED");
/* 7. Valid frame at a non-zero offset still validates. */
memset(buf, 0x00, sizeof(buf));
n = make_frame(buf + 7, 13);
CHECK(mmwave_ld2410_valid_at(buf, 7, 7 + n), "valid frame at offset 7 accepted");
/* 8. Repeated head bytes without a frame (worst-case noise) rejected. */
for (int k = 0; k + 3 < 64; k += 4) {
buf[k]=0xF4; buf[k+1]=0xF3; buf[k+2]=0xF2; buf[k+3]=0xF1;
}
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "repeated bare head bytes REJECTED");
printf("\n%s (%d failures)\n", failures ? "FAILED" : "ALL PASS", failures);
return failures ? 1 : 0;
}
-18
View File
@@ -1,18 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx ruview*)",
"mcp__ruview__*"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)"
]
},
"mcpServers": {
"ruview": {
"command": "npx",
"args": ["-y", "@ruvnet/ruview", "mcp", "start"]
}
}
}
@@ -1,29 +0,0 @@
---
name: calibrate-room
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
---
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview_calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview_calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview_calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview_claim_check` on the writeup.
@@ -1,30 +0,0 @@
---
name: onboard
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
---
# onboard
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview_onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
Use to see what it looks like.
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
report before you quote a number.
@@ -1,49 +0,0 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
---
# provision-node
Bring an ESP32 sensing node online.
## 1. Pick a firmware variant
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
requires a validated MR60 header (#1107) so an empty UART never false-detects.
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
```
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
# optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
@@ -1,33 +0,0 @@
---
name: train-pose
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
---
# train-pose
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
history — the discipline below exists so it never recurs.
## The non-negotiable: mean-pose baseline first
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
or temporal leakage. Example honest result (ADR-181):
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
## Paths
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
trains the net. Train/infer in one camera frame so the skeleton aligns.
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Before you publish a number
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
@@ -1,42 +0,0 @@
---
name: verify
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
---
# verify
The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
## Witness bundle (ADR-028)
For a release-grade attestation:
```
bash scripts/generate-witness-bundle.sh
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
```
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
- the retracted "100%/perfect accuracy" framing.
## Firmware-specific
A firmware fix is **not** "hardware-validated" without a captured boot log on real
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
probe). Do not merge or release on a build-passes signal alone.
-39
View File
@@ -1,39 +0,0 @@
{
"schema": 1,
"generator": "metaharness 0.1.15 + ADR-182 hardening",
"template": "vertical:ruview",
"name": "@ruvnet/ruview",
"vars": {
"name": "@ruvnet/ruview",
"description": "RuView WiFi-sensing operator agent harness",
"host": "claude-code"
},
"hosts": [
"claude-code"
],
"files": {
".claude/settings.json": "b0ea971383716f18b89db73010b8f0ea0f1b16bdec4cd1068245772ba1c27bdd",
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"CLAUDE.md": "1d7af0c310dd8093b4ae6c9c94a1c0cc9ff02ac9c8d5b45caba5363c3af99475",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "ac35157d66243a5f9eba262bdf2d593e978d935b3dde6e455b7acf650768eac6",
"bin/cli.js": "85d8394375edb1e967418451452e68bdbe26e69fc6877ed4936894f6101e1a12",
"package.json": "4509b68bb4211217f1e9f3f95f3134b326ee23a2322aef8d19b99a4b1d415b08",
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/guardrails.js": "66407b00d31c4f7939b75ee3e29598855c36a4154ccf1436655a4e52b0d7c034",
"src/mcp-server.js": "ad0f21be65a37237b9c2aad69e6e75166e5f101d902cb986377043545a7a80fb",
"src/tools.js": "1d72377ae53ad2b0c6dc03eb66f584422d8a60e442cb0d4f08355590f3edf031"
},
"meta": {
"surface": "cli+mcp",
"adr": "ADR-182"
}
}
-1
View File
@@ -1 +0,0 @@
380d4bf928fd7c5fa753d11a30c1e24e2ea471caca57b439f765a9d864cef472 manifest.json
-34
View File
@@ -1,34 +0,0 @@
# RuView harness — agent operating notes
You are operating **RuView** (WiFi-DensePose), a camera-free WiFi-CSI sensing system.
## The one rule: prove everything
This project was accused of AI-slop; the fix is hard discipline. Before you quote ANY
accuracy number:
1. It must be tagged **MEASURED** (with a reproducer named), **CLAIMED**, or **SYNTHETIC**.
2. Pose PCK is quoted only as a **delta over the mean-pose baseline** on a leakage-free
held-out split. (A mean-pose predictor already scores ~50% PCK.)
3. Run `ruview_claim_check` on any report/PR/model-card. It flags untagged numbers and
the retracted "100%/perfect accuracy" framing.
4. Firmware is "hardware-validated" only with a captured **boot log on real silicon**
never on a build-passes signal.
## Tools
`ruview_onboard`, `ruview_claim_check`, `ruview_verify`, `ruview_node_monitor`,
`ruview_calibrate`, `ruview_node_flash`. All fail-closed. Mutating/hardware tools
(`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated.
## Skills
`onboard` · `provision-node` · `calibrate-room` · `train-pose` · `verify`
(`npx @ruvnet/ruview skill <name>`).
## Don'ts
- Don't present WiFi sensing as camera-grade.
- Don't echo or commit WiFi passwords / secrets.
- Don't merge or release firmware without a real boot log.
- Don't report a PCK without its mean-pose baseline.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-62
View File
@@ -1,62 +0,0 @@
# `npx @ruvnet/ruview` — RuView WiFi-sensing operator harness
An AI agent harness that knows how to operate **RuView** (WiFi-DensePose): onboard a
newcomer, provision an ESP32 CSI node, calibrate a room, train pose models, and —
crucially — **refuse to overstate accuracy**. Minted from the RuView monorepo via
[`metaharness`](https://www.npmjs.com/package/metaharness) and hardened per **ADR-182**.
WiFi sensing infers *coarse* pose/presence/breathing from Channel State Information.
It is **not a camera**. Every accuracy number this harness emits must be MEASURED
against a baseline — that rule is enforced in code (`ruview_claim_check`).
## Quick start
```bash
npx @ruvnet/ruview # onboard — pick a setup path
npx @ruvnet/ruview claim-check --file REPORT.md # the honesty guardrail (non-zero exit on untagged claims)
npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS)
npx @ruvnet/ruview doctor # self-check (tools + optional kernel/host)
npx @ruvnet/ruview --help
```
The operator tools are pure Node and run with **zero install weight** — the
package has no dependencies at all (ADR-263 O3). `doctor` / `install` can
additionally use `@metaharness/kernel` + a host adapter if you install them
(`npm i @metaharness/kernel @metaharness/host-claude-code`); everything else
runs without them.
## Tools (`ruview_*`)
Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
| Tool | What it does |
|------|--------------|
| `ruview_onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
| `ruview_claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
| `ruview_verify` | Run `verify.py` deterministic proof → VERDICT |
| `ruview_node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
| `ruview_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
| `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
negative, never a fabricated success.
## Skills
Host-neutral playbooks in `skills/` (`onboard`, `provision-node`, `calibrate-room`,
`train-pose`, `verify`). `npx @ruvnet/ruview skill <name>` prints one.
## Use as a Claude Code MCP server
The bundled `.claude/settings.json` registers the `ruview` MCP server
(`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run
`npx @ruvnet/ruview install --host claude-code`.
## Hosts
claude-code (bundled), and via metaharness host adapters: codex, opencode, copilot,
pi-dev, hermes, rvm, github-actions.
## License
MIT © ruvnet
-186
View File
@@ -1,186 +0,0 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
//
// The `ruview.*` tools (onboard/verify/claim-check/…) are PURE Node and run with
// zero deps. The kernel + host adapter are only touched by `doctor`/`install`
// (the harness-into-a-repo story), so the operator tools never block on a wasm load.
import { fileURLToPath } from 'node:url';
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { argv } from 'node:process';
import { TOOLS, runTool, listTools } from '../src/tools.js';
import { claimCheck, summarize } from '../src/guardrails.js';
const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
const VERB_TO_TOOL = {
onboard: 'ruview_onboard',
verify: 'ruview_verify',
'claim-check': 'ruview_claim_check',
calibrate: 'ruview_calibrate',
monitor: 'ruview_node_monitor',
flash: 'ruview_node_flash',
};
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
function listSkills() {
if (!existsSync(SKILLS_DIR)) return [];
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
}
async function doctor() {
const checks = [];
// Tools layer (always available, no deps).
checks.push(['tool registry loads', Object.keys(TOOLS).length > 0]);
checks.push(['claim_check flags a 100% claim',
!claimCheck('We hit 100% accuracy on poses.').ok]);
checks.push(['claim_check passes a tagged MEASURED claim',
claimCheck('Held-out PCK@20 59.5% (MEASURED vs mean-pose baseline, verify.py).').ok]);
checks.push(['skills present', listSkills().length > 0]);
// Kernel + host adapter (optional — only needed to install into a repo).
let kernelLine = 'kernel/host: not installed (ok — operator tools run without them)';
try {
const { loadKernel } = await import('@metaharness/kernel');
const adapter = (await import('@metaharness/host-claude-code')).default;
const k = await loadKernel();
const info = k.kernelInfo();
checks.push(['kernel loads + reports version', typeof info.version === 'string' && info.version.length > 0]);
checks.push(['kernel backend is native|wasm|js', ['native', 'wasm', 'js'].includes(k.backend)]);
checks.push(['host adapter resolves', typeof adapter?.name === 'string']);
kernelLine = `kernel ${info.version} (${k.backend}) · host ${adapter.name}`;
} catch {
/* kernel not installed — fine for the tools-only path */
}
let ok = true;
for (const [label, pass] of checks) { console.log(`${pass ? 'PASS' : 'FAIL'} ${label}`); if (!pass) ok = false; }
console.log(`\n${NAME}: ${ok ? 'all checks passed' : 'doctor found problems'}${kernelLine}`);
return ok ? 0 : 1;
}
function help() {
console.log(`Usage: ${NAME} <command> [options]
Operator tools:
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
calibrate --step baseline|enroll|train-room|room-watch
monitor --port COM8 [--seconds 12] assert CSI is flowing on a node
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
Harness:
doctor verify the install (tools + optional kernel/host)
skills list bundled skills
skill <name> print a skill playbook
mcp start run the ruview.* MCP server (stdio)
install --host <h> project the harness config into the current repo
--version | --help
Hosts: claude-code, codex, opencode, copilot, pi-dev, hermes, rvm, github-actions`);
return 0;
}
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
function parseFlags(rest) {
const f = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
else { f[a.slice(2)] = true; }
}
}
return f;
}
export async function run(args) {
const cmd = args[0] ?? 'onboard';
const rest = args.slice(1);
const flags = parseFlags(rest);
// Direct tool verbs.
if (VERB_TO_TOOL[cmd]) {
const toolArgs = { ...flags };
if (cmd === 'claim-check') {
if (flags.file) toolArgs.text = readFileSync(flags.file, 'utf8');
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
return 2;
}
const res = await runTool('ruview_claim_check', toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
switch (cmd) {
case 'doctor': return doctor();
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
case 'skill': {
const n = rest[0];
const p = n && join(SKILLS_DIR, `${n}.md`);
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
console.log(readFileSync(p, 'utf8'));
return 0;
}
case 'mcp': {
if (rest[0] === 'start' || rest[0] === undefined) {
const { startMcpServer } = await import('../src/mcp-server.js');
startMcpServer();
return new Promise(() => {}); // run until stdin closes
}
console.error('Usage: ruview mcp start'); return 2;
}
case 'install': {
const host = flags.host || 'claude-code';
try {
const adapter = (await import('@metaharness/host-claude-code')).default;
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
console.log('Skills:', listSkills().join(', '));
return 0;
} catch {
console.error('Host adapter not installed. `npm i @metaharness/host-claude-code` or use the bundled .claude/ config.');
return 1;
}
}
case 'tools': pjson(listTools()); return 0;
case '--version': case '-v': {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
console.log(pkg.version); return 0;
}
case '--help': case '-h': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
return 2;
}
}
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch { return false; }
})();
if (invokedDirectly) {
run(argv.slice(2)).then((code) => process.exit(code)).catch((err) => { console.error(err); process.exit(1); });
}
-64
View File
@@ -1,64 +0,0 @@
{
"name": "@ruvnet/ruview",
"version": "0.2.0",
"description": "RuView WiFi-sensing operator agent harness — onboard, calibrate, train, and verify camera-free WiFi-CSI sensing, with the project's MEASURED-vs-CLAIMED honesty guardrail enforced. Minted via metaharness (ADR-182).",
"type": "module",
"bin": {
"ruview": "bin/cli.js"
},
"exports": {
".": "./src/tools.js",
"./guardrails": "./src/guardrails.js"
},
"files": [
"bin/",
"src/",
"skills/",
".claude/",
".harness/",
"CLAUDE.md",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"mcp": "node ./bin/cli.js mcp start",
"sync-skills": "node ./scripts/sync-skills.mjs",
"prepack": "node ./scripts/sync-skills.mjs",
"prepublishOnly": "npm test"
},
"keywords": [
"wifi-sensing",
"wifi-densepose",
"ruview",
"csi",
"channel-state-information",
"pose-estimation",
"presence-detection",
"esp32",
"agent-harness",
"metaharness",
"mcp",
"mcp-server",
"claude-code",
"ambient-intelligence"
],
"engines": {
"node": ">=20.0.0"
},
"license": "MIT",
"author": "ruvnet",
"homepage": "https://github.com/ruvnet/RuView#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ruvnet/RuView.git",
"directory": "harness/ruview"
},
"bugs": {
"url": "https://github.com/ruvnet/RuView/issues"
},
"publishConfig": {
"access": "public"
}
}
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// ADR-263 O7: skills/*.md is the single source of truth; the host-projected
// copies (.claude/skills/<name>/SKILL.md) are GENERATED here at pack time.
// Run with --check to verify without writing (used by tests/CI).
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SRC = join(ROOT, 'skills');
const DST = join(ROOT, '.claude', 'skills');
const checkOnly = process.argv.includes('--check');
let drift = 0;
for (const f of readdirSync(SRC).filter((f) => f.endsWith('.md'))) {
const name = f.replace(/\.md$/, '');
const src = readFileSync(join(SRC, f), 'utf8');
const dstDir = join(DST, name);
const dstFile = join(dstDir, 'SKILL.md');
const current = existsSync(dstFile) ? readFileSync(dstFile, 'utf8') : null;
if (current === src) continue;
drift++;
if (checkOnly) {
console.error(`DRIFT: .claude/skills/${name}/SKILL.md != skills/${f}`);
} else {
mkdirSync(dstDir, { recursive: true });
writeFileSync(dstFile, src);
console.error(`synced .claude/skills/${name}/SKILL.md`);
}
}
if (checkOnly && drift > 0) {
console.error(`sync-skills --check: ${drift} file(s) out of sync — run \`npm run sync-skills\`.`);
process.exit(1);
}
console.error(`sync-skills: ${drift === 0 ? 'all in sync' : `${drift} file(s) ${checkOnly ? 'OUT OF SYNC' : 'synced'}`}`);
-29
View File
@@ -1,29 +0,0 @@
---
name: calibrate-room
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
---
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview_calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview_calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview_calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview_claim_check` on the writeup.
-30
View File
@@ -1,30 +0,0 @@
---
name: onboard
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
---
# onboard
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview_onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
Use to see what it looks like.
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
report before you quote a number.
-49
View File
@@ -1,49 +0,0 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
---
# provision-node
Bring an ESP32 sensing node online.
## 1. Pick a firmware variant
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
requires a validated MR60 header (#1107) so an empty UART never false-detects.
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
```
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
# optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
-33
View File
@@ -1,33 +0,0 @@
---
name: train-pose
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
---
# train-pose
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
history — the discipline below exists so it never recurs.
## The non-negotiable: mean-pose baseline first
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
or temporal leakage. Example honest result (ADR-181):
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
## Paths
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
trains the net. Train/infer in one camera frame so the skeleton aligns.
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Before you publish a number
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
-42
View File
@@ -1,42 +0,0 @@
---
name: verify
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
---
# verify
The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
## Witness bundle (ADR-028)
For a release-grade attestation:
```
bash scripts/generate-witness-bundle.sh
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
```
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
- the retracted "100%/perfect accuracy" framing.
## Firmware-specific
A firmware fix is **not** "hardware-validated" without a captured boot log on real
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
probe). Do not merge or release on a build-passes signal alone.
-148
View File
@@ -1,148 +0,0 @@
// SPDX-License-Identifier: MIT
// RuView harness guardrails — the "prove everything" rule made executable.
//
// The project was accused of AI-slop; the cultural fix is that every accuracy
// number must be tagged MEASURED (with a reproducer) or CLAIMED/SYNTHETIC, and
// the retracted "100% accuracy" framing must never reappear untagged. This module
// is the static enforcement of that, shared by the `ruview_claim_check` MCP tool,
// the `npx ruview claim-check` CLI, and the claude-code pre-output hook.
/** Phrases that signal a quantitative accuracy claim (safe as substrings). */
const METRIC_TERMS = [
'accuracy', 'pck', 'precision', 'recall',
'mpjpe', 'error rate', 'detection rate', 'true positive',
];
// Short/ambiguous metric tokens (ADR-263 F11): 'map' is usually the English
// word or a file extension, 'f1'/'o1' collide with finding/option labels.
// They only count as metric mentions when word-bounded, not a `.map` file
// reference, and the line (after scrubbing) carries a number — "mAP 62.3" is
// a claim, "F-numbers map to findings" is not.
// 'map' additionally must not be a `.map` file suffix or a hyphenated
// compound ("map-free", "map-reduce") — mAP the metric never appears as either.
const METRIC_TERMS_SHORT = [/(?<![.\w])map\b(?!-)/, /\bf1\b/, /\bauc\b/, /\biou\b/];
// Finding/option labels (F1, O2, …) count as labels unless the token sits in a
// metric context: an immediately following score/=/%/digit or colon ("F1: 0.91"),
// or a number later in the same clause ("F1 reaches 0.91" — an F1-score claim).
// Bare option refs ("F7 fixes", "O1O9", "ADR-263 O2") carry no clause number of
// their own and stay labels. (A surviving 'f1' still only fires as a metric when
// its scrubbed line actually carries a number — see mentionsMetricTerm.)
const LABEL_TOKEN_RE = /\b[fo]\d+\b(?!\s*(?:score|=|\d|%|:))(?![^\n.;]*\d)/g;
const CODE_SPAN_RE = /`[^`]*`/g; // backticked identifiers are code, not claims
const HAS_NUMBER_RE = /\d/;
/** Line with code spans and finding/option labels removed. */
function scrubLine(lower) {
return lower.replace(CODE_SPAN_RE, ' ').replace(LABEL_TOKEN_RE, ' ');
}
function mentionsMetricTerm(lower, scrubbed) {
if (METRIC_TERMS.some((t) => lower.includes(t))) return true;
if (!HAS_NUMBER_RE.test(scrubbed)) return false;
return METRIC_TERMS_SHORT.some((re) => re.test(scrubbed));
}
/** Tags that make a claim honest (case-insensitive). */
const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baseline'];
/** Reproducer references that count as evidence backing a MEASURED claim. */
const REPRODUCER_HINTS = [
'verify.py', 'witness', 'mean-pose', 'mean pose', 'held-out', 'held out',
'baseline', 'reproduce', 'sha256', 'boot log', 'pck@20 vs', 'expected_features',
// Packaging-claim reproducers (ADR-263/264 npm reviews): the tarball itself.
'npm pack', 'npm view', 'npm i ', 'npm install', 'tarball', 'cargo test',
];
const PERCENT_RE = /\b(\d{1,3}(?:\.\d+)?)\s?%/g;
// "perfect" / "100%" framing is the specific retracted claim — always high severity.
// NOTE: no trailing \b after "%": "%"→" " is non-word→non-word, so a trailing \b
// never matches and would silently miss "100%". Bare 100% is only damning next to a
// metric term (see claimCheck); the word phrases are inherently accuracy claims.
const PERFECT_PCT_RE = /\b100(?:\.0+)?\s?%/;
const PERFECT_WORD_RE = /perfect accuracy|flawless|never (?:wrong|fails)/i;
/**
* Lint a block of text for untagged or overstated accuracy claims.
* @param {string} text
* @returns {{ok: boolean, findings: Array<{severity:'high'|'medium', line:number, excerpt:string, reason:string, suggestion:string}>}}
*/
export function claimCheck(text) {
const findings = [];
if (typeof text !== 'string' || text.length === 0) {
return { ok: true, findings };
}
const lines = text.split(/\r?\n/);
lines.forEach((raw, i) => {
const line = raw.trim();
if (!line) return;
const lower = line.toLowerCase();
const hasPercent = PERCENT_RE.test(line);
PERCENT_RE.lastIndex = 0; // reset stateful global regex
const scrubbed = scrubLine(lower);
const mentionsMetric = mentionsMetricTerm(lower, scrubbed);
if (!hasPercent && !mentionsMetric) return;
const tagged = HONEST_TAGS.some((t) => lower.includes(t));
const hasReproducer = REPRODUCER_HINTS.some((h) => lower.includes(h));
const perfect = PERFECT_WORD_RE.test(line) || (mentionsMetric && PERFECT_PCT_RE.test(line));
if (perfect && !lower.includes('retract')) {
findings.push({
severity: 'high',
line: i + 1,
excerpt: clip(line),
reason: 'States perfect/100% accuracy — this is the exact framing the project retracted.',
suggestion: 'Replace with a held-out number vs the mean-pose baseline, tagged MEASURED, or mark the old claim "retracted".',
});
return;
}
// A quantitative claim needs a number. Digits hidden in a code span still
// count — "accuracy reached `0.95`" is a claim — so test the line with only
// finding/option labels stripped, NOT the code-span-scrubbed copy: scrubbing
// dropped `0.95` and wrongly short-circuited both the untagged and the
// MEASURED-without-reproducer checks below. A bare metric word in prose
// ("precision matters here", "every accuracy number must be MEASURED") has no
// number and is not a taggable claim (ADR-263 F11).
if (!hasPercent && !HAS_NUMBER_RE.test(lower.replace(LABEL_TOKEN_RE, ' '))) return;
// A metric/percent with no honesty tag at all.
if (!tagged) {
findings.push({
severity: 'medium',
line: i + 1,
excerpt: clip(line),
reason: 'Accuracy claim is not tagged MEASURED / CLAIMED / SYNTHETIC.',
suggestion: 'Tag it. If MEASURED, name the reproducer (verify.py, witness bundle, held-out vs mean-pose).',
});
return;
}
// Tagged MEASURED but cites no reproducer — still a gap (reached now even
// when the only number is inside a code span, e.g. "accuracy `0.97` (MEASURED)").
if (lower.includes('measured') && !hasReproducer) {
findings.push({
severity: 'medium',
line: i + 1,
excerpt: clip(line),
reason: 'Tagged MEASURED but cites no reproducer/evidence.',
suggestion: 'Add the evidence path: verify.py VERDICT, witness bundle, or held-out PCK vs the mean-pose baseline.',
});
}
});
return { ok: findings.length === 0, findings };
}
function clip(s, n = 120) {
return s.length > n ? `${s.slice(0, n - 1)}` : s;
}
/** Convenience: a one-line human summary for CLI output. */
export function summarize(result) {
if (result.ok) return 'claim-check: PASS — no untagged or overstated accuracy claims.';
const high = result.findings.filter((f) => f.severity === 'high').length;
return `claim-check: ${result.findings.length} finding(s) (${high} high) — accuracy claims need MEASURED/CLAIMED tags + a reproducer.`;
}
-107
View File
@@ -1,107 +0,0 @@
// SPDX-License-Identifier: MIT
// RuView harness — minimal MCP stdio server (JSON-RPC 2.0 over stdin/stdout).
//
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
// `tools/list`, `tools/call`, `ping`, empty `resources/list`/`prompts/list`
// stubs, and the `notifications/initialized` ack. Logs go to stderr ONLY —
// stdout is the JSON-RPC channel and must stay clean.
//
// ADR-263 O2: `tools/call` is dispatched asynchronously — a long-running
// verify/calibrate no longer blocks ping/tools/list, so hosts that health-check
// mid-run see a live server. Responses may therefore arrive out of request
// order, which JSON-RPC permits (ids correlate them).
import { createInterface } from 'node:readline';
import { readFileSync } from 'node:fs';
import { listTools, runTool } from './tools.js';
const PROTOCOL_VERSION = '2024-11-05';
// Single-source the version from package.json (ADR-263 O6).
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const SERVER_INFO = { name: 'ruview', version: PKG.version };
function send(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
}
function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
async function handle(msg) {
const { id, method, params } = msg;
switch (method) {
case 'initialize':
return result(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO,
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check.',
});
case 'notifications/initialized':
case 'initialized':
case 'notifications/cancelled':
return; // notifications — no response
case 'ping':
return result(id, {});
case 'tools/list':
return result(id, { tools: listTools() });
case 'resources/list':
return result(id, { resources: [] });
case 'prompts/list':
return result(id, { prompts: [] });
case 'tools/call': {
const name = params?.name;
const args = params?.arguments || {};
const out = await runTool(name, args);
// MCP content envelope: text block with the JSON, isError reflects ok=false.
return result(id, {
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
isError: out && out.ok === false,
});
}
default:
if (id !== undefined) error(id, -32601, `Method not found: ${method}`);
}
}
export function startMcpServer() {
log(`starting v${SERVER_INFO.version} (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
// tools/call runs are serialized through a FIFO promise chain: hardware/mutating
// tools (calibrate, serial monitor, flash) must never overlap. ping/tools/list/
// initialize/resources/prompts stay immediate (ADR-263 O2 — a health check must
// answer during a long tool run). `toolChain` also lets stdin-close drain the
// in-flight call so its response is flushed instead of dropped by process.exit.
let toolChain = Promise.resolve();
const dispatch = (msg) => handle(msg).catch((err) => {
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
log('handler error:', String(err));
});
rl.on('line', (line) => {
const s = line.trim();
if (!s) return;
let msg;
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
if (msg && msg.method === 'tools/call') {
toolChain = toolChain.then(() => dispatch(msg)); // one tool at a time
} else {
dispatch(msg); // health/list/handshake answer immediately, even mid tool run
}
});
rl.on('close', () => {
// Wait for any queued/in-flight tool call to settle (its response written)
// before exiting — fire-and-forget used to race this and drop the response.
toolChain.then(() => {
log('stdin closed — exiting');
const done = () => process.exit(0);
// Pipe writes are async; flush buffered stdout before exit.
if (process.stdout.writableLength) process.stdout.once('drain', done);
else done();
});
});
}
-301
View File
@@ -1,301 +0,0 @@
// SPDX-License-Identifier: MIT
// RuView harness — the `ruview.*` tool registry.
//
// One registry consumed by BOTH the CLI (`npx ruview <tool>`) and the MCP server
// (`npx ruview mcp start`). Every handler returns structured JSON and is
// FAIL-CLOSED: when a prerequisite (the RuView repo, python+pyserial, the
// `wifi-densepose` binary, an ESP32 on a port) is absent, it returns an honest
// negative — never a fabricated success. This mirrors the project's "prove
// everything" rule and the RuField fail-closed posture (ADR-262 §3.3).
//
// ADR-263: handlers are async (promise-based spawn, never spawnSync) so the MCP
// server keeps answering ping/tools/list while a long verify/calibrate runs.
// Canonical tool names use underscores (host tool-name regexes commonly enforce
// ^[a-zA-Z0-9_-]{1,64}$); the historical dotted names are accepted as aliases.
import { spawn } from 'node:child_process';
import { existsSync, accessSync, constants } from 'node:fs';
import { join, dirname, resolve, delimiter } from 'node:path';
import { claimCheck, summarize } from './guardrails.js';
/** Walk up from `start` to find the RuView monorepo root (or null). */
export function findRepoRoot(start = process.cwd()) {
let dir = resolve(start);
for (let i = 0; i < 8; i++) {
const hasProof = existsSync(join(dir, 'archive', 'v1', 'data', 'proof', 'verify.py'));
const hasV2 = existsSync(join(dir, 'v2', 'Cargo.toml'));
if (hasProof || hasV2) return dir;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
// Dep-free PATH scan (ADR-263 O8) — no shell subprocess per lookup. Only hits
// are memoized: a miss can resolve later in a long-lived MCP session (the
// operator installs python/the CLI mid-run), so misses are re-probed each call.
const whichCache = new Map();
export function which(cmd) {
if (whichCache.has(cmd)) return whichCache.get(cmd);
const isWin = process.platform === 'win32';
const exts = isWin
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
: [''];
let found = null;
outer:
for (const dir of (process.env.PATH || '').split(delimiter)) {
if (!dir) continue;
for (const ext of isWin ? ['', ...exts] : exts) {
const p = join(dir, cmd + ext);
try {
accessSync(p, isWin ? constants.F_OK : constants.X_OK);
found = p;
break outer;
} catch { /* keep scanning */ }
}
}
if (found !== null) whichCache.set(cmd, found);
return found;
}
// Bounded output tails (ADR-263 O4): spawnSync's default 1 MiB maxBuffer killed
// chatty children with ENOBUFS; handlers only ever surface the last few kB, so
// keep rolling tails instead of the full stream.
const STDOUT_TAIL = 65536;
const STDERR_TAIL = 16384;
/** Promise-based spawn with timeout + rolling output tails. */
export function run(cmd, args, opts = {}) {
const timeout = opts.timeout ?? 120000;
return new Promise((resolvePromise) => {
let stdout = '';
let stderr = '';
let child;
try {
child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) {
resolvePromise({ status: null, ok: false, stdout: '', stderr: '', error: e.message });
return;
}
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
child.stdout.on('data', (d) => {
stdout = (stdout + d).slice(-STDOUT_TAIL);
});
child.stderr.on('data', (d) => {
stderr = (stderr + d).slice(-STDERR_TAIL);
});
child.on('error', (e) => {
clearTimeout(timer);
resolvePromise({ status: null, ok: false, stdout, stderr, error: e.message });
});
child.on('close', (status) => {
clearTimeout(timer);
resolvePromise({
status,
ok: status === 0,
stdout,
stderr,
error: timedOut ? `timed out after ${timeout} ms` : null,
});
});
});
}
const ONBOARD_PATHS = {
'docker-demo': 'Fastest. `docker run -p 8000:8000 ruvnet/wifi-densepose` → open the dashboard. No hardware; replays sample CSI. Good for "what does it look like".',
'repo-build': 'Build from source. `cd v2 && cargo test --workspace --no-default-features` (1,031+ tests). Then `cargo run -p wifi-densepose-cli -- --help`. Good for developers.',
'live-esp32': 'Real sensing. Flash an ESP32-S3 (see `provision-node` skill), point it at the sensing-server, then `calibrate → enroll → train-room → room-watch` (see `calibrate-room`). Good for an actual install.',
};
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
// never spliced into interpreter source).
const MONITOR_SCRIPT = [
'import sys,time',
'try:',
' import serial',
'except Exception as e:',
" print('NO_PYSERIAL'); sys.exit(3)",
'port=sys.argv[1]',
'dur=float(sys.argv[2])',
'ser=serial.Serial(port,115200,timeout=1)',
'csi=0; n=0; t=time.time()',
'while time.time()-t<dur:',
' ln=ser.readline()',
' if not ln: continue',
" s=ln.decode('utf-8','replace')",
' n+=1',
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
'ser.close()',
"print(f'LINES={n} CSI={csi}')",
].join('\n');
/**
* The tool registry. Each entry: { title, description, inputSchema, handler }.
* inputSchema is JSON-Schema (object). handler(args) JSON-serializable result
* (sync or promise). Canonical names are underscore-form.
*/
export const TOOLS = {
ruview_onboard: {
title: 'Onboard',
description: 'Pick a RuView setup path (docker-demo | repo-build | live-esp32) and print the next concrete command.',
inputSchema: {
type: 'object',
properties: { path: { type: 'string', enum: Object.keys(ONBOARD_PATHS), description: 'Which setup path. Omit to list all.' } },
},
handler(args = {}) {
const repo = findRepoRoot();
if (args.path && ONBOARD_PATHS[args.path]) {
return { ok: true, path: args.path, next: ONBOARD_PATHS[args.path], in_ruview_repo: !!repo };
}
return {
ok: true,
in_ruview_repo: !!repo,
repo_root: repo,
paths: ONBOARD_PATHS,
recommend: repo ? 'repo-build' : 'docker-demo',
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview_claim_check`).',
};
},
},
ruview_claim_check: {
title: 'Claim check',
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings. Fail-closed: empty input is an error, not a pass.',
inputSchema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string', description: 'The text to lint (a report, README section, PR body, model card).' } },
},
handler(args = {}) {
const text = typeof args.text === 'string' ? args.text : '';
if (text.trim().length === 0) {
return { ok: false, reason: 'empty_text', hint: 'Pass the text to lint — an empty input must not pass an honesty gate.' };
}
const result = claimCheck(text);
return { ...result, summary: summarize(result) };
},
},
ruview_verify: {
title: 'Verify (witness)',
description: 'Run the deterministic proof (archive/v1/data/proof/verify.py) and report VERDICT. Fail-closed if not in a RuView repo or python is missing.',
inputSchema: {
type: 'object',
properties: { repo: { type: 'string', description: 'RuView repo root. Default: auto-detect from cwd.' } },
},
async handler(args = {}) {
const repo = args.repo ? resolve(args.repo) : findRepoRoot();
if (!repo) return { ok: false, reason: 'not_in_ruview_repo', hint: 'Run inside the RuView monorepo or pass {repo}.' };
const proof = join(repo, 'archive', 'v1', 'data', 'proof', 'verify.py');
if (!existsSync(proof)) return { ok: false, reason: 'proof_missing', path: proof };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing', hint: 'Install python to run the deterministic proof.' };
const r = await run(py, [proof], { cwd: repo, timeout: 180000 });
const verdict = /VERDICT:\s*PASS/i.test(r.stdout) ? 'PASS' : (/VERDICT:\s*FAIL/i.test(r.stdout) ? 'FAIL' : 'UNKNOWN');
return { ok: r.ok && verdict === 'PASS', verdict, exit: r.status, tail: r.stdout.slice(-1200), stderr: r.stderr.slice(-400) };
},
},
ruview_node_monitor: {
title: 'Node monitor',
description: 'Open an ESP32 serial port and assert CSI is flowing (MGMT+DATA). Fail-closed if python+pyserial or the port is absent. Read-only.',
inputSchema: {
type: 'object',
properties: {
port: { type: 'string', description: 'Serial port, e.g. COM8 or /dev/ttyUSB0.' },
seconds: { type: 'number', description: 'Capture window (default 12).' },
},
},
async handler(args = {}) {
const port = args.port;
if (!port || typeof port !== 'string') return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing' };
const dur = Number(args.seconds) > 0 ? Number(args.seconds) : 12;
const r = await run(py, ['-c', MONITOR_SCRIPT, port, String(dur)], { timeout: (dur + 10) * 1000 });
if (r.stdout.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', hint: 'pip install pyserial' };
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
const upgraded = r.stdout.includes('UPGRADE_MGMT_DATA');
return { ok: csi > 0, csi_callbacks: csi, mgmt_data_upgrade: upgraded, raw: r.stdout.trim() };
},
},
ruview_calibrate: {
title: 'Calibrate room',
description: 'Run the ADR-151 room pipeline via the wifi-densepose CLI (baseline→enroll→train-room). Fail-closed if the binary is absent.',
inputSchema: {
type: 'object',
properties: {
step: { type: 'string', enum: ['baseline', 'enroll', 'train-room', 'room-watch'], description: 'Which calibration step.' },
args: { type: 'array', items: { type: 'string' }, description: 'Extra CLI args passed through.' },
},
},
async handler(args = {}) {
const step = args.step || 'baseline';
const bin = which('wifi-densepose');
const repo = findRepoRoot();
if (!bin && !repo) return { ok: false, reason: 'cli_missing', hint: 'Install the wifi-densepose CLI or run in the repo (cargo run -p wifi-densepose-cli).' };
const passthru = Array.isArray(args.args) ? args.args.map(String) : [];
// Prefer the installed binary; otherwise cargo-run from the repo.
const r = bin
? await run(bin, [step, ...passthru], { timeout: 300000 })
: await run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
return { ok: r.ok, step, via: bin ? 'binary' : 'cargo', exit: r.status, tail: r.stdout.slice(-1500), stderr: r.stderr.slice(-500) };
},
},
ruview_node_flash: {
title: 'Node flash',
description: 'Build+flash an ESP32 firmware variant. MUTATING + hardware. Fail-closed off-Windows or without ESP-IDF. Never claims hardware validation without a boot log.',
inputSchema: {
type: 'object',
properties: {
port: { type: 'string', description: 'Target port, e.g. COM8.' },
variant: { type: 'string', enum: ['s3-8mb', 's3-4mb', 'c6'], description: 'Firmware variant.' },
confirm: { type: 'boolean', description: 'Must be true to actually flash (guard).' },
},
},
handler(args = {}) {
if (process.platform !== 'win32') {
return { ok: false, reason: 'unsupported_platform', detail: 'The ESP-IDF flash flow is Windows-subprocess-specific today (see CLAUDE.local.md).' };
}
if (!args.confirm) {
return { ok: false, reason: 'not_confirmed', detail: 'Mutating hardware op — re-call with {confirm:true}.', would_flash: { port: args.port, variant: args.variant || 's3-8mb' } };
}
return { ok: false, reason: 'manual_step_required', detail: 'Flashing uses the pinned ESP-IDF subprocess in CLAUDE.local.md. This tool returns the exact command rather than running an unattended flash.', see: 'skills/provision-node.md' };
},
},
};
// Historical dotted names (pre-ADR-263) accepted as call-time aliases; the
// underscore form is what tools/list advertises.
export const TOOL_ALIASES = Object.fromEntries(
Object.keys(TOOLS).map((name) => [name.replace(/_/, '.'), name])
);
/** Resolve a canonical or aliased tool name (or null). */
export function resolveToolName(name) {
if (TOOLS[name]) return name;
if (TOOL_ALIASES[name]) return TOOL_ALIASES[name];
return null;
}
/** Run one tool by name (canonical or dotted alias); always resolves to the structured result. */
export async function runTool(name, args) {
const canonical = resolveToolName(name);
if (!canonical) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
try {
return await TOOLS[canonical].handler(args || {});
} catch (err) {
return { ok: false, reason: 'tool_threw', name: canonical, error: String(err && err.message || err) };
}
}
/** MCP-shaped tool list: [{name, description, inputSchema}]. */
export function listTools() {
return Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema }));
}
-148
View File
@@ -1,148 +0,0 @@
// SPDX-License-Identifier: MIT
// MCP stdio server e2e — spawns `bin/cli.js mcp start` and speaks JSON-RPC.
// Pins ADR-263 O2 (ping answered while a long tools/call runs), O6 (version
// from package.json), and O8 (underscore names advertised, dotted accepted,
// resources/prompts stubs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { which } from '../src/tools.js';
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const CLI = join(PKG_ROOT, 'bin', 'cli.js');
/** Start the MCP server; returns {send, next, close} where next(id) resolves the response with that id. */
function startServer() {
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
const waiters = new Map();
let buf = '';
child.stdout.on('data', (d) => {
buf += d;
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const msg = JSON.parse(line);
const w = waiters.get(msg.id);
if (w) { waiters.delete(msg.id); w(msg); }
}
});
return {
send(msg) { child.stdin.write(JSON.stringify(msg) + '\n'); },
next(id) { return new Promise((res) => waiters.set(id, res)); },
close() { child.stdin.end(); child.kill(); },
};
}
test('MCP handshake: initialize reports the package.json version; list endpoints respond', async () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
const init = await s.next(1);
assert.equal(init.result.serverInfo.version, pkg.version, 'ADR-263 O6: version must match package.json');
s.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
const tools = (await s.next(2)).result.tools;
assert.equal(tools.length, 6);
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/, `advertised name not host-safe: ${t.name}`);
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
assert.deepEqual((await s.next(3)).result, { resources: [] });
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
assert.deepEqual((await s.next(4)).result, { prompts: [] });
// Dotted legacy name still callable (alias).
s.send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'ruview.onboard', arguments: {} } });
const call = await s.next(5);
assert.equal(call.result.isError, false);
} finally {
s.close();
}
});
test('MCP server answers ping while a long tools/call is in flight (ADR-263 O2)', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
// Fake RuView repo whose verify.py sleeps 3 s then passes.
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-e2e-'));
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
mkdirSync(proofDir, { recursive: true });
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(3)\nprint("VERDICT: PASS")\n');
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
await s.next(1);
const verifyDone = s.next(10);
s.send({ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
// Give the server a beat to start the child, then ping.
await new Promise((r) => setTimeout(r, 300));
const t0 = Date.now();
const pinged = s.next(11);
s.send({ jsonrpc: '2.0', id: 11, method: 'ping' });
await pinged;
const pingMs = Date.now() - t0;
assert.ok(pingMs < 1000, `ping took ${pingMs} ms while verify was in flight — server is blocking`);
const verify = await verifyDone;
const payload = JSON.parse(verify.result.content[0].text);
assert.equal(payload.verdict, 'PASS');
} finally {
s.close();
rmSync(repo, { recursive: true, force: true });
}
});
test('tools/call executions are serialized — two slow calls run sequentially', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
// Two verify.py that each sleep 0.8 s. Serialized ⇒ ~1.6 s+; concurrent ⇒ ~0.8 s.
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-serial-'));
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
mkdirSync(proofDir, { recursive: true });
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(0.8)\nprint("VERDICT: PASS")\n');
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
await s.next(1);
const t0 = Date.now();
const a = s.next(20);
const b = s.next(21);
s.send({ jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
s.send({ jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
const [ra, rb] = await Promise.all([a, b]);
const elapsed = Date.now() - t0;
assert.equal(JSON.parse(ra.result.content[0].text).verdict, 'PASS');
assert.equal(JSON.parse(rb.result.content[0].text).verdict, 'PASS');
assert.ok(elapsed > 1400, `two 0.8 s tool calls finished in ${elapsed} ms — they overlapped instead of serializing`);
} finally {
s.close();
rmSync(repo, { recursive: true, force: true });
}
});
test('stdin close flushes an in-flight tools/call response before exit', async () => {
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (d) => { out += d; });
const exited = new Promise((res) => child.on('exit', res));
// Write a tools/call then immediately close stdin. The old fire-and-forget
// dispatch raced rl 'close' → process.exit and could drop this response.
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'tools/call', params: { name: 'ruview_onboard', arguments: {} } }) + '\n');
child.stdin.end();
await exited;
const msgs = out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
const resp = msgs.find((m) => m.id === 42);
assert.ok(resp, 'the in-flight tools/call response must be flushed to stdout before exit');
assert.equal(resp.result.isError, false);
});
-256
View File
@@ -1,256 +0,0 @@
// SPDX-License-Identifier: MIT
// RuView harness tests — Node's built-in test runner (no devDeps to install).
// Run: `node --test test/*.test.mjs` (or `npm test`).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname, delimiter } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { claimCheck, summarize } from '../src/guardrails.js';
import { TOOLS, TOOL_ALIASES, runTool, listTools, findRepoRoot, run, which } from '../src/tools.js';
import { run as cliRun } from '../bin/cli.js';
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
test('guardrail flags the retracted 100% framing as high severity', () => {
const r = claimCheck('Our model reaches 100% accuracy on every pose.');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => f.severity === 'high'));
});
test('guardrail flags an untagged percentage accuracy claim', () => {
// "hit", not "measured" — "measured" would (correctly) route to the no-reproducer branch.
const r = claimCheck('We hit 92.9% PCK on the test set.');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
});
test('guardrail passes a MEASURED claim that cites a reproducer', () => {
const r = claimCheck('Held-out PCK@20 59.5% vs 50% mean-pose baseline = +9.4pp (MEASURED, verify.py).');
assert.equal(r.ok, true, JSON.stringify(r.findings));
});
test('guardrail flags MEASURED with no reproducer', () => {
const r = claimCheck('Presence detection 97% (MEASURED).');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
});
test('guardrail ignores non-metric prose', () => {
assert.equal(claimCheck('The ESP32 streams CSI over UDP to the sensing-server.').ok, true);
assert.equal(claimCheck('').ok, true);
});
// ADR-263 F11/O9: precision pins — short metric tokens must not fire on prose.
test('guardrail does not false-positive on "map"/"F1" prose (ADR-263 F11)', () => {
assert.equal(claimCheck('F-numbers map to findings.').ok, true);
assert.equal(claimCheck('### F1 (HIGH, broken export): `require` points at a missing file').ok, true);
assert.equal(claimCheck('The 0.1.0 tarball ships 44 `.map` files = 62,698 B of dead weight.').ok, true);
assert.equal(claimCheck('the source maps can never resolve').ok, true);
assert.equal(claimCheck('- **O1 (F1):** fix `exports` (see F2 for the 33% map weight — MEASURED, tarball listing)').ok, true);
assert.equal(claimCheck('ADR-264: exports fix, map-free tarball, session-per-transport').ok, true);
});
test('guardrail still catches real short-token metric claims', () => {
assert.equal(claimCheck('We reach mAP 62.3 on COCO.').ok, false);
assert.equal(claimCheck('F1 score of 0.91 on the held set.').ok, false, 'f1 with a real score must still fire');
assert.equal(claimCheck('IoU 0.75 across rooms.').ok, false);
});
// Digits hidden in a code span still make a claim — scrubbing must not blind the
// number gate to `0.95` (regression: code-span number bypassed the gate).
test('guardrail flags an accuracy number stated inside a code span', () => {
const r = claimCheck('Count accuracy reached `0.95` in our tests.');
assert.equal(r.ok, false, JSON.stringify(r.findings));
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
});
// A MEASURED claim whose only number hides in a code span must still reach the
// missing-reproducer check (regression: the scrubbed gate short-circuited it).
// Bare metric prose with no number at all (e.g. the README rule text) stays a pass.
test('guardrail flags a MEASURED code-span number with no reproducer', () => {
const r = claimCheck('Detection accuracy `0.97` on the set (MEASURED).');
assert.equal(r.ok, false, JSON.stringify(r.findings));
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
assert.equal(claimCheck('Every accuracy number must be MEASURED against a baseline.').ok, true);
});
// F1-score phrasings ("F1: 0.91", "F1 reaches 0.91") were scrubbed as option
// labels and slipped through; option refs alone must still not false-positive.
test('guardrail catches F1-score claims but not bare option refs (ADR-263 F11)', () => {
assert.equal(claimCheck('F1: 0.91 on the held-out set.').ok, false, 'F1: value is a metric claim');
assert.equal(claimCheck('F1 reaches 0.91 on the held-out set.').ok, false, 'F1 with a nearby number is a claim');
assert.equal(claimCheck('Options O1O9 are tracked in ADR-263 O2.').ok, true, 'option labels are not metrics');
assert.equal(claimCheck('ADR-263 O2 lands the exports fix.').ok, true);
});
test('summarize gives PASS/finding text', () => {
assert.match(summarize(claimCheck('nothing here')), /PASS/);
assert.match(summarize(claimCheck('100% accuracy')), /finding/);
});
test('registry exposes the documented tools with schemas (underscore-canonical)', () => {
const names = Object.keys(TOOLS);
for (const n of ['ruview_onboard', 'ruview_claim_check', 'ruview_verify', 'ruview_node_monitor', 'ruview_calibrate', 'ruview_node_flash']) {
assert.ok(names.includes(n), `missing ${n}`);
assert.equal(TOOLS[n].inputSchema.type, 'object');
assert.match(n, /^[a-zA-Z0-9_-]{1,64}$/, 'canonical names must satisfy host tool-name regexes');
}
assert.equal(listTools().length, names.length);
});
test('dotted legacy names resolve via aliases (ADR-263 O8)', async () => {
assert.equal(TOOL_ALIASES['ruview.claim_check'], 'ruview_claim_check');
assert.equal(TOOL_ALIASES['ruview.node_monitor'], 'ruview_node_monitor');
const r = await runTool('ruview.onboard', {});
assert.equal(r.ok, true);
});
test('ruview_onboard returns paths and a recommendation', async () => {
const r = await runTool('ruview_onboard', {});
assert.equal(r.ok, true);
assert.ok(r.paths['live-esp32']);
assert.ok(['repo-build', 'docker-demo'].includes(r.recommend));
});
test('ruview_claim_check tool wraps the guardrail', async () => {
const r = await runTool('ruview_claim_check', { text: '100% accuracy' });
assert.equal(r.ok, false);
assert.match(r.summary, /honesty|tag|MEASURED|finding/i);
});
// ADR-263 F1/O1: the honesty gate must fail closed on empty input.
test('ruview_claim_check fails closed on empty/missing text', async () => {
const empty = await runTool('ruview_claim_check', { text: '' });
assert.equal(empty.ok, false);
assert.equal(empty.reason, 'empty_text');
const missing = await runTool('ruview_claim_check', {});
assert.equal(missing.ok, false);
assert.equal(missing.reason, 'empty_text');
});
test('unknown tool fails closed', async () => {
const r = await runTool('ruview_does_not_exist', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'unknown_tool');
});
test('node_monitor fails closed without a port', async () => {
const r = await runTool('ruview_node_monitor', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'no_port');
});
test('node_flash refuses without confirm (mutating guard)', async () => {
const r = await runTool('ruview_node_flash', { port: 'COM8', variant: 's3-8mb' });
assert.equal(r.ok, false);
// either not-confirmed (win32) or unsupported_platform (posix) — both fail-closed
assert.ok(['not_confirmed', 'unsupported_platform'].includes(r.reason));
});
test('verify fails closed when not in a RuView repo', async () => {
// point at a tmp dir with no repo markers
const r = await runTool('ruview_verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
assert.equal(r.ok, false);
assert.ok(['proof_missing', 'python_missing'].includes(r.reason), r.reason);
});
// ADR-263 F2/O2: registry-level concurrency — a slow child must not block
// other tool calls (run() is promise-based, never spawnSync).
test('run() is non-blocking: a fast tool completes while a slow child runs', async () => {
const slow = run('node', ['-e', 'setTimeout(() => {}, 2000)'], { timeout: 5000 });
const t0 = Date.now();
const fast = await runTool('ruview_onboard', {});
const elapsed = Date.now() - t0;
assert.equal(fast.ok, true);
assert.ok(elapsed < 1000, `onboard took ${elapsed} ms while a 2 s child was running`);
const r = await slow;
assert.equal(r.ok, true);
});
test('run() reports a timeout as a failure, not a hang', async () => {
const r = await run('node', ['-e', 'setTimeout(() => {}, 10000)'], { timeout: 300 });
assert.equal(r.ok, false);
assert.match(String(r.error), /timed out/);
});
test('run() bounds captured output instead of dying on big streams (ADR-263 O4)', async () => {
// 4 MiB of stdout would have hit spawnSync's 1 MiB default maxBuffer (ENOBUFS).
const r = await run('node', ['-e', "process.stdout.write('x'.repeat(4 * 1024 * 1024)); console.log('TAIL_MARKER')"], { timeout: 30000 });
assert.equal(r.ok, true);
assert.ok(r.stdout.length <= 65536, `tail not bounded: ${r.stdout.length}`);
assert.ok(r.stdout.includes('TAIL_MARKER'), 'tail must keep the end of the stream');
});
test('which() finds node and re-probes misses (hits are cached)', () => {
assert.ok(which('node'), 'node must be on PATH in the test env');
assert.equal(which('definitely-not-a-binary-xyz'), null);
assert.equal(which('definitely-not-a-binary-xyz'), null); // re-probed, still absent
});
// ADR-263 O8: a miss must not be cached — an operator who installs a tool
// mid-session (e.g. python after a python_missing failure) must be found next call.
test('which() re-probes after a miss so a newly-installed tool is found', () => {
const dir = mkdtempSync(join(tmpdir(), 'ruview-which-'));
const name = 'ruview-probe-xyz';
const isWin = process.platform === 'win32';
const bin = join(dir, isWin ? `${name}.cmd` : name);
const prevPath = process.env.PATH;
try {
assert.equal(which(name), null, 'not on PATH yet → miss');
writeFileSync(bin, isWin ? '@echo off\n' : '#!/bin/sh\n', { mode: 0o755 });
process.env.PATH = dir + delimiter + prevPath;
assert.ok(which(name), 'installed mid-session → the miss must not have been cached');
} finally {
process.env.PATH = prevPath;
rmSync(dir, { recursive: true, force: true });
}
});
test('CLI run(): claim-check exits non-zero on a bad claim', async () => {
const code = await cliRun(['claim-check', '--text', '100% accuracy']);
assert.notEqual(code, 0);
});
// ADR-263 F1/O1: the CLI must not PASS silently with no input.
test('CLI run(): claim-check with no input exits 2 (fail-closed)', async () => {
assert.equal(await cliRun(['claim-check']), 2);
assert.equal(await cliRun(['claim-check', '--text', ' ']), 2);
});
test('CLI run(): doctor exits 0 (tools-only path)', async () => {
const code = await cliRun(['doctor']);
assert.equal(code, 0);
});
test('CLI run(): unknown command exits non-zero', async () => {
assert.notEqual(await cliRun(['definitely-not-a-command']), 0);
});
test('findRepoRoot locates this monorepo from cwd', () => {
// when run from within wifi-densepose, it should find a root; elsewhere null is fine
const root = findRepoRoot();
assert.ok(root === null || typeof root === 'string');
});
// ADR-263 F7/O7: skills ship from one source; the projected copies must match.
test('.claude/skills/*/SKILL.md are byte-identical to skills/*.md', () => {
const srcDir = join(PKG_ROOT, 'skills');
for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.md'))) {
const name = f.replace(/\.md$/, '');
const src = readFileSync(join(srcDir, f), 'utf8');
const projected = readFileSync(join(PKG_ROOT, '.claude', 'skills', name, 'SKILL.md'), 'utf8');
assert.equal(projected, src, `skill drift: ${name} — run \`npm run sync-skills\``);
}
});
// ADR-263 F6/O6 + F3/O3: package hygiene pins.
test('package.json has no optionalDependencies and no hardcoded server version drift', () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
assert.equal(pkg.optionalDependencies, undefined, 'ADR-263 O3: optional deps tripled the cold npx install');
assert.equal(pkg.dependencies, undefined, 'the harness is dependency-free by design');
const mcpSrc = readFileSync(join(PKG_ROOT, 'src', 'mcp-server.js'), 'utf8');
assert.ok(!/version:\s*'\d+\.\d+\.\d+'/.test(mcpSrc), 'ADR-263 O6: server version must come from package.json');
});
Binary file not shown.
BIN
View File
Binary file not shown.
+6 -32
View File
@@ -184,9 +184,7 @@ function loadGroundTruth(filePath) {
const raw = loadJsonl(filePath);
const frames = [];
for (const r of raw) {
// Skip non-detection frames (empty keypoints []) — they must not dilute window
// confidence; confidence stats are over actual detections only (#1007 Bug 2).
if (r.ts_ns == null || !r.keypoints || r.keypoints.length === 0) continue;
if (r.ts_ns == null || !r.keypoints) continue;
frames.push({
tsMs: cameraTsToMs(r.ts_ns),
keypoints: r.keypoints,
@@ -268,29 +266,7 @@ function loadCsi(filePath) {
// Sort by timestamp
rawCsi.sort((a, b) => a.tsMs - b.tsMs);
features.sort((a, b) => a.tsMs - b.tsMs);
// Bug 3 (#1007): keep only frames at the session's MODAL subcarrier count so windows
// are homogeneous; never silently zero-pad/truncate the off-format frames the ESP32
// emits (HT20/HT40/fragments). extractCsiMatrix then sees uniform-width frames.
return { rawCsi: filterToModalSubcarriers(rawCsi), features };
}
/**
* Keep only frames whose subcarrier count equals the session's modal (most common)
* count. Off-format frames are dropped (logged), not padded prevents the silent
* zero-padding that corrupted windows in #1007.
*/
function filterToModalSubcarriers(frames) {
if (frames.length === 0) return frames;
const counts = new Map();
for (const f of frames) counts.set(f.subcarriers, (counts.get(f.subcarriers) || 0) + 1);
let modal = frames[0].subcarriers, best = 0;
for (const [sc, n] of counts) if (n > best) { best = n; modal = sc; }
const kept = frames.filter((f) => f.subcarriers === modal);
if (kept.length !== frames.length) {
console.error(`[align] #1007: kept ${kept.length}/${frames.length} CSI frames at modal subcarrier count ${modal} (dropped ${frames.length - kept.length} off-format; no silent padding)`);
}
return kept;
return { rawCsi, features };
}
// ---------------------------------------------------------------------------
@@ -367,8 +343,7 @@ function averageKeypoints(cameraFrames) {
/**
* Extract CSI amplitude matrix from raw_csi window.
* Fill is frame-major (matrix[f*nSc + s]), so shape is [windowFrames, subcarriers]
* (#1007 Bug 4 was mislabeled [subcarriers, windowFrames], transposing consumers).
* Returns { data: flat Float32Array, shape: [subcarriers, windowFrames] }.
*/
function extractCsiMatrix(window) {
const nFrames = window.length;
@@ -388,13 +363,12 @@ function extractCsiMatrix(window) {
}
}
return { data: Array.from(matrix), shape: [nFrames, nSc] };
return { data: Array.from(matrix), shape: [nSc, nFrames] };
}
/**
* Extract feature matrix from feature-type window.
* Fill is frame-major (matrix[f*dim + d]), so shape is [windowFrames, featureDim]
* (#1007 Bug 4 was mislabeled [featureDim, windowFrames]).
* Returns { data: flat array, shape: [featureDim, windowFrames] }.
*/
function extractFeatureMatrix(window) {
const nFrames = window.length;
@@ -408,7 +382,7 @@ function extractFeatureMatrix(window) {
}
}
return { data: Array.from(matrix), shape: [nFrames, dim] };
return { data: Array.from(matrix), shape: [dim, nFrames] };
}
// ---------------------------------------------------------------------------
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env bash
#
# firmware-release-guard.sh — guard against shipping firmware built from a
# stale generated `sdkconfig` (the v0.8.3-esp32 release bug).
#
# Symptom it catches: an incremental build reuses a leftover `sdkconfig`
# instead of `sdkconfig.defaults`, so an "8MB" build silently links the 4MB
# dual-OTA partition layout (no spiffs, ota_1 @ 0x1F0000) and the released
# `partition-table.bin` does not match the flash-size variant it claims to be.
#
# What it does: for the named flash-size variant, regenerate the EXPECTED
# partition table from the partition CSV that variant must use, and byte-compare
# it against the freshly built `partition-table.bin`. Also cross-checks the
# flash size recorded in the build's `flasher_args.json`. Exits non-zero on any
# mismatch so a release pipeline fails closed.
#
# Usage:
# scripts/firmware-release-guard.sh <8mb|4mb> <build-dir>
#
# Example:
# scripts/firmware-release-guard.sh 8mb firmware/esp32-csi-node/build
#
set -euo pipefail
VARIANT="${1:-}"
BUILD_DIR="${2:-}"
if [[ -z "$VARIANT" || -z "$BUILD_DIR" ]]; then
echo "usage: $0 <8mb|4mb> <build-dir>" >&2
exit 2
fi
# Firmware project root (this script lives in <repo>/scripts).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FW_DIR="$SCRIPT_DIR/../firmware/esp32-csi-node"
case "$VARIANT" in
8mb) EXPECT_CSV="partitions_display.csv"; EXPECT_FLASH="8MB" ;;
4mb) EXPECT_CSV="partitions_4mb.csv"; EXPECT_FLASH="4MB" ;;
*) echo "ERROR: unknown variant '$VARIANT' (want 8mb|4mb)" >&2; exit 2 ;;
esac
BUILT_PT="$BUILD_DIR/partition_table/partition-table.bin"
CSV_PATH="$FW_DIR/$EXPECT_CSV"
[[ -f "$BUILT_PT" ]] || { echo "ERROR: built partition table not found: $BUILT_PT" >&2; exit 1; }
[[ -f "$CSV_PATH" ]] || { echo "ERROR: expected CSV not found: $CSV_PATH" >&2; exit 1; }
# Locate the ESP-IDF partition table generator.
GEN="${IDF_PATH:-}/components/partition_table/gen_esp32part.py"
if [[ ! -f "$GEN" ]]; then
GEN="C:/Users/ruv/esp/v5.4/esp-idf/components/partition_table/gen_esp32part.py"
fi
[[ -f "$GEN" ]] || { echo "ERROR: gen_esp32part.py not found (set IDF_PATH)" >&2; exit 1; }
PY="${PYTHON:-python}"
command -v "$PY" >/dev/null 2>&1 || PY="C:/Espressif/tools/python/v5.4/venv/Scripts/python.exe"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
EXPECT_PT="$TMP/expected-partition-table.bin"
# Regenerate the expected table from the CSV this variant must use.
"$PY" "$GEN" --quiet "$CSV_PATH" "$EXPECT_PT"
fail=0
if ! cmp -s "$EXPECT_PT" "$BUILT_PT"; then
echo "FAIL: built partition table does not match $EXPECT_CSV for the $VARIANT variant." >&2
echo " The build likely reused a stale sdkconfig. Decoded built table:" >&2
"$PY" "$GEN" "$BUILT_PT" 2>/dev/null | grep -vE '^#|^Parsing|^Verifying' | sed 's/^/ /' >&2
fail=1
fi
# Cross-check the flash size the build actually targeted.
FA="$BUILD_DIR/flasher_args.json"
if [[ -f "$FA" ]]; then
GOT_FLASH="$("$PY" - "$FA" <<'PYEOF'
import json,sys
with open(sys.argv[1]) as f: d=json.load(f)
print(d.get("flash_settings",{}).get("flash_size",""))
PYEOF
)"
if [[ "$GOT_FLASH" != "$EXPECT_FLASH" ]]; then
echo "FAIL: flasher_args.json flash_size='$GOT_FLASH', expected '$EXPECT_FLASH'." >&2
fail=1
fi
fi
if [[ "$fail" -ne 0 ]]; then
exit 1
fi
echo "OK: $VARIANT firmware build matches $EXPECT_CSV (flash_size=$EXPECT_FLASH)."
+1 -3
View File
@@ -15,7 +15,6 @@ import os
import socket
import struct
import time
from datetime import datetime, timezone
def parse_csi_packet(data):
@@ -42,8 +41,7 @@ def parse_csi_packet(data):
return {
"type": "raw_csi",
# true UTC, not local-time-labeled-Z (#1007 Bug 1) — e.g. "2026-06-17T01:23:45.678Z"
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}Z",
"ts_ns": time.time_ns(),
"node_id": node_id,
"rssi": rssi,
+1 -1
View File
@@ -12,7 +12,7 @@
"yargs": "^17.7.2"
},
"bin": {
"ruview-cli": "dist/index.js"
"ruview": "dist/index.js"
},
"devDependencies": {
"@types/node": "^20.14.0",
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "@ruv/ruview-cli",
"version": "0.0.1",
"description": "RuView CLI — shell access to WiFi-DensePose sensing, inference, and training capabilities. Private/unpublished; the `ruview` bin name belongs to @ruvnet/ruview (ADR-265 D4).",
"description": "RuView CLI — shell access to WiFi-DensePose sensing, inference, and training capabilities",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"ruview-cli": "dist/index.js"
"ruview": "dist/index.js"
},
"files": [
"dist"
@@ -15,7 +15,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "node --experimental-vm-modules node_modules/.bin/jest --passWithNoTests",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
+2 -9
View File
@@ -25,7 +25,6 @@
* See ADR-104 for the full design rationale and security model.
*/
import { createRequire } from "node:module";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { csiCommand } from "./commands/csi.js";
@@ -35,15 +34,9 @@ import { cogsCommand } from "./commands/cogs.js";
import { trainCommand } from "./commands/train.js";
import { jobCommand } from "./commands/job.js";
// Single-source the version from package.json (ADR-265 D3).
const require = createRequire(import.meta.url);
const VERSION: string = (require("../package.json") as { version: string }).version;
// Bin name is `ruview-cli`: the bare `ruview` bin belongs to @ruvnet/ruview
// (ADR-264 O9 / ADR-265 D4).
const cli = yargs(hideBin(process.argv))
.scriptName("ruview-cli")
.version(VERSION)
.scriptName("ruview")
.version("0.0.1")
.usage("$0 <command> [options]")
.strict()
.help()
+23 -34
View File
@@ -2,63 +2,52 @@
**SENSE-BRIDGE** is a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms, and any MCP-compatible client).
Install once; AI agents can then call `ruview_presence_now`, `ruview_vitals_get_heart_rate`, `ruview_bfld_last_scan`, and more — without writing HTTP or WebSocket client code.
Install once; AI agents can then call `ruview.presence.now`, `ruview.vitals.get_heart_rate`, `ruview.bfld.last_scan`, and more — without writing HTTP or WebSocket client code.
## Quickstart
```bash
# 1. Add to Claude Code (stdio transport — the default)
claude mcp add rvagent -- npx -y @ruvnet/rvagent
# 1. Add to Claude Code
claude mcp add rvagent -- npx @ruvnet/rvagent stdio
# 2. Or run directly
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx @ruvnet/rvagent
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx @ruvnet/rvagent stdio
# 3. Streamable HTTP (remote agents, ruflo swarms) — explicit opt-in
# 3. Streamable HTTP (remote agents, ruflo swarms)
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 \
RVAGENT_HTTP_TOKEN=your-secret \
RVAGENT_HTTP_PORT=3001 npx @ruvnet/rvagent
# POST JSON-RPC to http://127.0.0.1:3001/mcp (initialize first; then send the
# returned mcp-session-id header on every request)
npx @ruvnet/rvagent http --port 3001
# POST JSON-RPC to http://127.0.0.1:3001/mcp
```
Requirements: **Node.js >= 20**. The `wifi-densepose-sensing-server` Rust binary must be reachable at `RUVIEW_SENSING_SERVER_URL` (default `http://localhost:3000`).
## Tools
Canonical tool names are underscore-form (ADR-264 — host tool-name validators
commonly enforce `^[a-zA-Z0-9_-]{1,64}$`). The pre-0.1.1 dotted names
(`ruview.presence.now`, …) are still accepted at call time as deprecated
aliases; `tools/list` advertises the underscore form only.
## Feature matrix
| Tool | Description | ADR |
|------|-------------|-----|
| `ruview_csi_latest` | Latest 56×20 CSI window from the sensing-server | ADR-101/102 |
| `ruview_pose_infer` | Single-shot 17-keypoint pose inference via cog binary | ADR-101 |
| `ruview_count_infer` | Single-shot person-count inference via cog binary | ADR-103 |
| `ruview_registry_list` | Cognitum edge module registry (category/search filters) | ADR-102 |
| `ruview_train_count` | Kick off a count-cog training run (background job) | ADR-103 |
| `ruview_job_status` | Poll a training job (persists across server restarts) | ADR-103 |
| `ruview_presence_now` | Current occupancy: `present`, `n_persons`, `confidence` | ADR-124 §4.1 |
| `ruview_vitals_get_breathing` | Breathing rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview_vitals_get_heart_rate` | Heart rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview_vitals_get_all` | Full `EdgeVitalsMessage` surface | ADR-124 §4.1 |
| `ruview_bfld_last_scan` | Latest BFLD scan: `identity_risk_score`, `privacy_class`, `n_frames` | ADR-118/124 |
| `ruview_bfld_subscribe` | Subscribe to `ruview/<node_id>/bfld/*` events for `duration_s` seconds | ADR-122/124 |
| *(roadmap, ADR-124 §4.1/4.1a)* | `pose.latest`, `primitives.*`, `node.*`, `vector.*`, and the `policy.*` governance layer are catalogued in `src/schemas/` but **not yet implemented** | ADR-124 |
| `ruview.presence.now` | Current occupancy: `present`, `n_persons`, `confidence` | ADR-124 §4.1 |
| `ruview.vitals.get_breathing` | Breathing rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview.vitals.get_heart_rate` | Heart rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview.vitals.get_all` | Full `EdgeVitalsMessage` surface | ADR-124 §4.1 |
| `ruview.bfld.last_scan` | Latest BFLD scan: `identity_risk_score`, `privacy_class`, `n_frames` | ADR-118/124 |
| `ruview.bfld.subscribe` | Subscribe to `ruview/<node_id>/bfld/*` events for `duration_s` seconds | ADR-122/124 |
| *(next iters)* | `pose.latest`, `primitives.*`, `node.*`, `vector.*`, `policy.*` | ADR-124 §4.1/4.1a |
**Transport security (ADR-124 §6, hardened per ADR-264)**:
- **stdio** (default): process-level isolation — no auth needed for local Claude Code / Cursor.
- **Streamable HTTP** (`/mcp`, opt-in via `RVAGENT_HTTP_PORT`): one transport + one MCP server per session (routed by `mcp-session-id`), Origin validation (localhost on any port allowed; anything else → 403), optional bearer token (`RVAGENT_HTTP_TOKEN` → 401 on mismatch), 1 MiB request-body cap (413), binds `127.0.0.1` by default per MCP spec.
**Transport security (ADR-124 §6)**:
- **stdio**: process-level isolation — no auth needed for local Claude Code / Cursor.
- **Streamable HTTP** (`POST /mcp`): Origin header validation (cross-origin → 403), optional bearer token (`RVAGENT_HTTP_TOKEN` → 401 on mismatch), binds `127.0.0.1` by default per MCP spec.
**Schema validation**: each tool declares one Zod schema; the CallTool gate parses exactly once and the advertised JSON Schema is generated from the same Zod source. Invalid arguments return `McpError(InvalidParams)` rather than a wrapped string.
**Schema validation**: every tool call runs `zod.safeParse` before dispatch; invalid arguments return `McpError(InvalidParams)` rather than a wrapped string.
**Policy layer** (ADR-124 §4.1a): `ruview.policy.*` tools gate every sensing call — `vitals.*` is default-deny until a policy grant is registered via `npx @ruvnet/rvagent policy grant`. Presence and node-list are allow by default.
## ADR cross-reference
| ADR | Decision |
|-----|----------|
| [ADR-124](../../docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md) | SENSE-BRIDGE: dual-transport MCP server + ruvector npm + ruflo integration |
| [ADR-264](../../docs/adr/ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | npm deep review — exports fix, map-free tarball, naming, session-per-transport |
| [ADR-118](../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | BFLD pipeline — source of `bfld_last_scan` wire format |
| [ADR-118](../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | BFLD pipeline — source of `bfld.last_scan` wire format |
| [ADR-122](../../docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) | MQTT topic routing `ruview/<node_id>/bfld/*` |
| [ADR-115](../../docs/adr/ADR-115-home-assistant-integration.md) | `EdgeVitalsMessage` WebSocket surface (`ws.py:74-88` parity) |
| [ADR-055](../../docs/adr/ADR-055-integrated-sensing-server.md) | Sensing-server REST API (`/api/v1/*`) |
@@ -69,7 +58,7 @@ aliases; `tools/list` advertises the underscore form only.
cd tools/ruview-mcp
npm install
npm run build # tsc
npm test # jest — 99 tests across 7 suites
npm test # jest — 93 tests across 7 suites
```
Source: `tools/ruview-mcp/src/`. Tests: `tools/ruview-mcp/tests/`.
+373 -10
View File
@@ -1,24 +1,24 @@
{
"name": "@ruvnet/rvagent",
"version": "0.2.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ruvnet/rvagent",
"version": "0.2.0",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.2"
"zod": "^3.23.8"
},
"bin": {
"ruview-mcp": "dist/index.js",
"rvagent": "dist/index.js"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^20.14.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
@@ -629,6 +629,16 @@
}
}
},
"node_modules/@jest/diff-sequences": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz",
"integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/environment": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
@@ -690,6 +700,16 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jest/get-type": {
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/globals": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
@@ -706,6 +726,30 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jest/pattern": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz",
"integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"jest-regex-util": "30.4.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/pattern/node_modules/jest-regex-util": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
"integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/reporters": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
@@ -1017,6 +1061,52 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -1027,6 +1117,13 @@
"@types/node": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -1055,14 +1152,229 @@
}
},
"node_modules/@types/jest": {
"version": "29.5.14",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
"version": "30.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz",
"integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
"expect": "^30.0.0",
"pretty-format": "^30.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/expect-utils": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz",
"integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/schemas": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz",
"integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/types": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz",
"integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/pattern": "30.4.0",
"@jest/schemas": "30.4.1",
"@types/istanbul-lib-coverage": "^2.0.6",
"@types/istanbul-reports": "^3.0.4",
"@types/node": "*",
"@types/yargs": "^17.0.33",
"chalk": "^4.1.2"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jest/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@types/jest/node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@types/jest/node_modules/expect": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
"integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/expect-utils": "30.4.1",
"@jest/get-type": "30.1.0",
"jest-matcher-utils": "30.4.1",
"jest-message-util": "30.4.1",
"jest-mock": "30.4.1",
"jest-util": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-diff": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz",
"integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/diff-sequences": "30.4.0",
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"pretty-format": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-matcher-utils": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz",
"integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"jest-diff": "30.4.1",
"pretty-format": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-message-util": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz",
"integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@jest/types": "30.4.1",
"@types/stack-utils": "^2.0.3",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"jest-util": "30.4.1",
"picomatch": "^4.0.3",
"pretty-format": "30.4.1",
"slash": "^3.0.0",
"stack-utils": "^2.0.6"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-mock": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz",
"integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.4.1",
"@types/node": "*",
"jest-util": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-util": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz",
"integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.4.1",
"@types/node": "*",
"chalk": "^4.1.2",
"ci-info": "^4.2.0",
"graceful-fs": "^4.2.11",
"picomatch": "^4.0.3"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@types/jest/node_modules/pretty-format": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
"react-is-18": "npm:react-is@^18.3.1",
"react-is-19": "npm:react-is@^19.2.5"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/node": {
@@ -1075,6 +1387,41 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -4008,6 +4355,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-is-18": {
"name": "react-is",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/react-is-19": {
"name": "react-is",
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+11 -10
View File
@@ -1,14 +1,15 @@
{
"name": "@ruvnet/rvagent",
"version": "0.2.0",
"description": "SENSE-BRIDGE: dual-transport MCP server (stdio default; Streamable HTTP opt-in via RVAGENT_HTTP_PORT) exposing RuView WiFi-DensePose sensing primitives to AI agents",
"version": "0.1.0",
"description": "SENSE-BRIDGE: dual-transport MCP server (stdio + Streamable HTTP) exposing RuView WiFi-DensePose sensing primitives to AI agents",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"bin": {
@@ -17,7 +18,8 @@
},
"files": [
"dist",
"README.md"
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc",
@@ -25,8 +27,7 @@
"start": "node dist/index.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build && npm test"
"typecheck": "tsc --noEmit"
},
"keywords": [
"mcp",
@@ -52,11 +53,11 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.2"
"zod": "^3.23.8"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^20.14.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
Binary file not shown.
+11 -30
View File
@@ -8,7 +8,6 @@
import os from "node:os";
import path from "node:path";
import { existsSync } from "node:fs";
import type { RuviewConfig } from "./types.js";
function env(key: string): string | undefined {
@@ -52,35 +51,17 @@ export function loadConfig(): RuviewConfig {
}
/**
* Ordered cog-binary candidate paths for a host of the given CPU architecture.
* The native-arch build is probed FIRST: an appliance that ships both
* `cog-<id>-arm` and `cog-<id>-x86_64` must never hand back the wrong-arch
* binary (ADR-264 F8/O7 the pre-review order tried `-arm` unconditionally).
* The `/usr/local/bin` and bare-name (PATH) fallbacks follow, arch-agnostic.
*
* Pure and arch-injectable so the ordering is unit-testable.
*/
export function cogBinaryCandidates(
name: string,
arch: string = process.arch
): string[] {
const id = name.replace("cog-", "");
const dir = `/var/lib/cognitum/apps/${id}`;
const arm = `${dir}/cog-${id}-arm`;
const x86 = `${dir}/cog-${id}-x86_64`;
// arm64 → prefer -arm; everything else (notably x64) → prefer -x86_64.
const archOrdered = arch === "arm64" ? [arm, x86] : [x86, arm];
return [...archOrdered, `/usr/local/bin/${name}`];
}
/**
* Locate a cog binary in the common appliance install locations, probing each
* candidate in native-arch-first order. Falls back to the bare name (PATH
* resolution at spawn time) when no candidate exists.
* Attempt to locate a cog binary on PATH or in common install locations.
* Returns the bare binary name if not found (will fail gracefully at invocation).
*/
function detectCogBinary(name: string): string {
for (const candidate of cogBinaryCandidates(name)) {
if (existsSync(candidate)) return candidate;
}
return name; // bare name — rely on PATH; spawn fails gracefully if absent
// Common install paths for Cognitum cog binaries on Linux/macOS appliances.
const candidates = [
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-arm`,
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-x86_64`,
`/usr/local/bin/${name}`,
name, // bare name — rely on PATH
];
// Return the first candidate that might exist; actual existence is checked at call time.
return candidates[candidates.length - 1] ?? name;
}
+66 -239
View File
@@ -1,44 +1,30 @@
/**
* Streamable HTTP transport for @ruvnet/rvagent (ADR-124 §3, hardened per
* ADR-264 F7/O3).
* Streamable HTTP transport scaffold for @ruvnet/rvagent (ADR-124 §3).
*
* Binds to 127.0.0.1 by default and mounts an /mcp endpoint backed by
* Binds to 127.0.0.1 by default and mounts a POST /mcp endpoint backed by
* StreamableHTTPServerTransport from @modelcontextprotocol/sdk.
*
* Session model (ADR-264 F7): the SDK's stateful mode requires ONE transport
* (and one MCP Server) per session. An `initialize` POST creates a fresh
* transport + server pair via the caller-supplied factory; follow-up
* POST/GET/DELETE requests are routed to their session by the
* `mcp-session-id` header. Transports are dropped when their session closes.
*
* Security model (ADR-124 §6 + ADR-264 F7):
* - Origin validation: browser-style requests whose Origin is not local
* are rejected with 403 before reaching the MCP layer. With NO explicit
* allowlist, localhost origins match on hostname, ANY port
* (http://localhost:5173 is local). When an explicit allowedOrigins list is
* configured, matching is exact the any-port-localhost convenience is off,
* so a localhost peer on an unlisted port must be added to be accepted.
* Security model (ADR-124 §6):
* - Origin validation: requests from origins other than the configured
* allowlist are rejected with 403 Forbidden before reaching the MCP layer.
* - Default allowlist: ['http://localhost', 'http://127.0.0.1'] covers
* Claude Code and Cursor on the same machine.
* - Bearer token: when RVAGENT_HTTP_TOKEN is set, requests must carry
* Authorization: Bearer <token>; missing/wrong tokens 401.
* - Body cap: request bodies over 1 MiB are rejected with 413 (the
* unbounded-buffering DoS from the pre-ADR-264 scaffold).
* - Bind address: defaults to 127.0.0.1 per MCP spec security requirement.
* Set RVAGENT_HTTP_HOST=0.0.0.0 only for intentional fleet deployment.
*
* Usage:
* import { createHttpTransport } from './http-transport.js';
* const { httpServer } = await createHttpTransport(() => buildServer(config));
* const { server: httpServer, transport } = await createHttpTransport(mcpServer);
* // httpServer is a node:http.Server — call httpServer.close() to shut down.
*/
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
export type McpServerFactory = () => McpServer;
export interface HttpTransportOptions {
/** TCP host to bind (default: 127.0.0.1). */
host?: string;
@@ -46,8 +32,8 @@ export interface HttpTransportOptions {
port?: number;
/**
* Allowed Origin header values. Requests with an Origin not in this list
* (and not a localhost origin) are rejected with 403. Use '*' to disable
* Origin validation entirely (not recommended outside of local-dev flags).
* are rejected with 403. Use '*' to disable Origin validation entirely
* (not recommended outside of local-dev flags).
*/
allowedOrigins?: string[];
/**
@@ -56,51 +42,32 @@ export interface HttpTransportOptions {
* Defaults to process.env.RVAGENT_HTTP_TOKEN (undefined = auth disabled).
*/
bearerToken?: string;
/** Maximum accepted request body size in bytes (default: 1 MiB). */
maxBodyBytes?: number;
/**
* Maximum number of concurrent live sessions (default: 64). When a new
* `initialize` arrives at the cap, the oldest-idle session is evicted (its
* transport closed) to make room bounds memory against a flaky client that
* loops `initialize` or a malicious localhost peer (ADR-264 F7).
*/
maxSessions?: number;
/**
* Idle time-to-live for a session in ms (default: 5 min). Sessions with no
* request activity for longer than this are swept and closed.
*/
sessionIdleMs?: number;
/** How often the idle-session sweeper runs, in ms (default: 60 s). */
sweepIntervalMs?: number;
}
export interface HttpTransportResult {
/** The raw Node.js HTTP server — call .close() to shut down. */
httpServer: HttpServer;
/** Live sessions keyed by session id (exposed for tests/observability). */
sessions: Map<string, StreamableHTTPServerTransport>;
/** The MCP Streamable HTTP transport instance wired to the MCP server. */
transport: StreamableHTTPServerTransport;
/** The bound address string (e.g. "http://127.0.0.1:3001"). */
boundAddress: string;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3001;
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_SESSIONS = 64;
const DEFAULT_SESSION_IDLE_MS = 5 * 60 * 1000;
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
const LOCALHOST_ORIGINS = new Set([
"http://localhost",
"http://127.0.0.1",
"https://localhost",
"https://127.0.0.1",
]);
/**
* Validate Origin header against the allowlist.
* Returns true if the request should be allowed, false if it should be rejected.
*
* An absent Origin header is allowed (same-origin non-browser requests, curl,
* etc.). When NO explicit allowlist was configured (empty list), a localhost
* origin is allowed on any port as a convenience real browser origins carry
* ports (ADR-264 F7). When an explicit allowlist IS configured, matching is
* exact: the any-port-localhost shortcut is disabled so an operator who pins an
* allowlist actually gets it (a looped-back peer on an unlisted port is denied).
* An absent Origin header is allowed (same-origin non-browser requests, curl, etc.).
* A present Origin that is not in the allowlist is rejected.
*/
export function isOriginAllowed(
origin: string | undefined,
@@ -108,222 +75,76 @@ export function isOriginAllowed(
): boolean {
if (origin === undefined) return true; // no Origin = not a cross-origin browser request
if (allowedOrigins.includes("*")) return true;
if (allowedOrigins.includes(origin)) return true;
// Explicit allowlist ⇒ exact matching only; skip the localhost convenience.
if (allowedOrigins.length > 0) return false;
try {
const u = new URL(origin);
return (
(u.protocol === "http:" || u.protocol === "https:") &&
LOCAL_HOSTNAMES.has(u.hostname === "::1" ? "[::1]" : u.hostname)
);
} catch {
return false;
}
}
/** Read a request body with a hard size cap; null = payload too large. */
function readBody(
req: IncomingMessage,
maxBytes: number
): Promise<string | null> {
return new Promise((resolve, reject) => {
let size = 0;
let tooLarge = false;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
if (tooLarge) return; // keep draining so the 413 response can flush
size += chunk.length;
if (size > maxBytes) {
tooLarge = true;
chunks.length = 0;
resolve(null);
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (!tooLarge) resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
function json(res: ServerResponse, status: number, body: object): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
return allowedOrigins.some((o) => o === origin);
}
/**
* Build the HTTP server around a per-session MCP transport map.
* Returns the Node.js HTTP server (not yet listening) plus the session map.
* Build and wire a Streamable HTTP transport to the provided MCP server.
* Returns the Node.js HTTP server (not yet listening) plus the transport.
* Call httpServer.listen(port, host) or rely on createHttpTransport which
* does that for you.
*/
export function buildHttpApp(
serverFactory: McpServerFactory,
mcpServer: McpServer,
opts: HttpTransportOptions = {}
): { httpServer: HttpServer; sessions: Map<string, StreamableHTTPServerTransport> } {
const allowedOrigins: string[] = opts.allowedOrigins ?? [];
): { httpServer: HttpServer; transport: StreamableHTTPServerTransport } {
const allowedOrigins: string[] = opts.allowedOrigins ?? [
...LOCALHOST_ORIGINS,
];
const bearerToken = opts.bearerToken ?? process.env["RVAGENT_HTTP_TOKEN"];
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
const sessionIdleMs = opts.sessionIdleMs ?? DEFAULT_SESSION_IDLE_MS;
const sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
const sessions = new Map<string, StreamableHTTPServerTransport>();
// lastSeen tracks per-session request activity so the sweeper and the
// oldest-idle eviction can bound the session map (ADR-264 F7).
const lastSeen = new Map<string, number>();
/** Mark a session as freshly used. */
function touch(sessionId: string): void {
lastSeen.set(sessionId, Date.now());
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
/** Close a session's transport and drop it from the bookkeeping maps. */
function closeSession(id: string): void {
const transport = sessions.get(id);
sessions.delete(id);
lastSeen.delete(id);
if (transport) {
try {
void transport.close(); // onclose is idempotent against the maps above
} catch {
/* best-effort: a half-open transport must not block eviction */
}
}
}
/** Evict the session that has been idle longest — called when at capacity. */
function evictOldestIdle(): void {
let oldestId: string | undefined;
let oldestSeen = Infinity;
for (const [id, seen] of lastSeen) {
if (seen < oldestSeen) {
oldestSeen = seen;
oldestId = id;
}
}
if (oldestId !== undefined) closeSession(oldestId);
}
/** Periodic sweep: close sessions idle beyond sessionIdleMs. */
function sweepIdleSessions(): void {
const now = Date.now();
for (const [id, seen] of lastSeen) {
if (now - seen > sessionIdleMs) closeSession(id);
}
}
const sweepTimer = setInterval(sweepIdleSessions, sweepIntervalMs);
sweepTimer.unref(); // never keep the process alive just to sweep
const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
// ── Origin validation ──────────────────────────────────────────────
const httpServer = createServer(
(req: IncomingMessage, res: ServerResponse) => {
// ── Origin validation ────────────────────────────────────────────────
const origin = req.headers["origin"] as string | undefined;
if (!isOriginAllowed(origin, allowedOrigins)) {
json(res, 403, { error: "Forbidden: cross-origin request rejected" });
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Forbidden: cross-origin request rejected" }));
return;
}
// ── Bearer token auth ──────────────────────────────────────────────
// ── Bearer token auth ────────────────────────────────────────────────
if (bearerToken !== undefined && bearerToken !== "") {
const authHeader = req.headers["authorization"] as string | undefined;
const supplied = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: undefined;
if (supplied !== bearerToken) {
json(res, 401, { error: "Unauthorized: missing or invalid bearer token" });
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" }));
return;
}
}
// ── Route: /mcp ────────────────────────────────────────────────────
if (req.url !== "/mcp") {
json(res, 404, { error: "Not found. MCP endpoint: /mcp" });
return;
}
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (req.method === "POST") {
const body = await readBody(req, maxBodyBytes);
if (body === null) {
json(res, 413, { error: `Payload too large (max ${maxBodyBytes} bytes)` });
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
json(res, 400, { error: "Bad Request: invalid JSON body" });
return;
}
// Existing session → route to its transport.
if (sessionId !== undefined) {
const transport = sessions.get(sessionId);
if (!transport) {
json(res, 404, { error: `Unknown session "${sessionId}"` });
// ── Route: POST /mcp ─────────────────────────────────────────────────
if (req.method === "POST" && req.url === "/mcp") {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Bad Request: invalid JSON body" }));
return;
}
touch(sessionId);
await transport.handleRequest(req, res, parsed);
return;
}
// New session: must be an initialize request (ADR-264 F7 — one
// transport + one MCP Server per session).
if (!isInitializeRequest(parsed)) {
json(res, 400, {
error: "Bad Request: no mcp-session-id and not an initialize request",
});
return;
}
// Bound the session map: at capacity, reclaim the oldest-idle slot
// before minting a new session (ADR-264 F7).
if (sessions.size >= maxSessions) evictOldestIdle();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
sessions.set(id, transport);
touch(id);
},
void transport.handleRequest(req, res, parsed);
});
transport.onclose = () => {
if (transport.sessionId !== undefined) {
sessions.delete(transport.sessionId);
lastSeen.delete(transport.sessionId);
}
};
const mcpServer = serverFactory();
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
await transport.handleRequest(req, res, parsed);
return;
}
// GET (SSE stream) / DELETE (session termination) — session-scoped.
if (req.method === "GET" || req.method === "DELETE") {
const transport = sessionId !== undefined ? sessions.get(sessionId) : undefined;
if (!transport) {
json(res, 400, { error: "Bad Request: missing or unknown mcp-session-id" });
return;
}
if (sessionId !== undefined) touch(sessionId);
await transport.handleRequest(req, res);
return;
}
// ── Fallback ─────────────────────────────────────────────────────────
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found. MCP endpoint: POST /mcp" }));
}
);
json(res, 405, { error: "Method not allowed. Use POST/GET/DELETE on /mcp" });
})().catch(() => {
if (!res.headersSent) json(res, 500, { error: "Internal server error" });
else res.end();
});
});
httpServer.on("close", () => clearInterval(sweepTimer));
return { httpServer, sessions };
return { httpServer, transport };
}
/**
@@ -331,13 +152,19 @@ export function buildHttpApp(
* is bound and listening.
*/
export async function createHttpTransport(
serverFactory: McpServerFactory,
mcpServer: McpServer,
opts: HttpTransportOptions = {}
): Promise<HttpTransportResult> {
const host = opts.host ?? process.env["RVAGENT_HTTP_HOST"] ?? DEFAULT_HOST;
const port = opts.port ?? Number(process.env["RVAGENT_HTTP_PORT"] ?? DEFAULT_PORT);
const { httpServer, sessions } = buildHttpApp(serverFactory, opts);
const { httpServer, transport } = buildHttpApp(mcpServer, opts);
// Wire MCP server to the transport only after the HTTP server is built.
// Cast needed: StreamableHTTPServerTransport implements Transport but
// exactOptionalPropertyTypes causes a false incompatibility on optional
// callback properties; the cast is safe — the SDK types are consistent.
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
@@ -346,7 +173,7 @@ export async function createHttpTransport(
return {
httpServer,
sessions,
transport,
boundAddress: `http://${host}:${port}`,
};
}
+269 -197
View File
@@ -1,39 +1,29 @@
#!/usr/bin/env node
/**
* @ruvnet/rvagent RuView MCP Server
* @ruv/ruview-mcp RuView MCP Server
*
* Exposes RuView's WiFi-DensePose sensing capabilities as Model Context Protocol
* (MCP) tools that Claude Code, Cursor, Codex, and other MCP-compatible agents
* can call directly.
*
* Transports (ADR-264 O3):
* stdio (default) node dist/index.js
* Streamable HTTP RVAGENT_HTTP_PORT=3001 node dist/index.js
* (127.0.0.1-bound, Origin-gated, optional bearer token
* see http-transport.ts for the security model)
* Tools exposed:
* ruview_csi_latest pull the latest CSI window from the sensing-server
* ruview_pose_infer single-shot 17-keypoint pose estimation
* ruview_count_infer single-shot person count with confidence interval
* ruview_registry_list list cogs from the Cognitum edge registry (ADR-102)
* ruview_train_count kick off a count-cog training run (returns job ID)
* ruview_job_status poll a background training job
*
* Tool naming (ADR-264 O4): canonical names are underscore-form
* (host tool-name regexes commonly enforce ^[a-zA-Z0-9_-]{1,64}$). The
* pre-ADR-264 dotted names (ruview.bfld.last_scan, ) remain callable as
* router-only aliases for one deprecation cycle; tools/list advertises the
* underscore form only.
*
* Validation (ADR-264 O5): each tool declares ONE Zod schema. The CallTool
* gate parses exactly once and hands the typed result to the handler; the
* advertised JSON Schema is generated from the same Zod source, so what is
* advertised is what is enforced.
* Usage:
* node dist/index.js # stdio transport (default)
* RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 node dist/index.js
*
* To register with Claude Code:
* claude mcp add ruview -- npx -y @ruvnet/rvagent
* claude mcp add ruview -- node /path/to/tools/ruview-mcp/dist/index.js
*
* See ADR-104 for the original design rationale and ADR-264 for the npm
* deep-review this layout implements.
* See ADR-104 for the full design rationale and security model.
*/
import { createRequire } from "node:module";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { argv } from "node:process";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
@@ -42,8 +32,6 @@ import {
McpError,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import type { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { loadConfig } from "./config.js";
import { csiLatestSchema, csiLatest } from "./tools/csi-latest.js";
@@ -56,51 +44,40 @@ import {
jobStatusSchema,
jobStatus,
} from "./tools/train-count.js";
import { bfldLastScanSchema, bfldLastScan } from "./tools/bfld-last-scan.js";
import { bfldSubscribeSchema, bfldSubscribe } from "./tools/bfld-subscribe.js";
import { presenceNowSchema, presenceNow } from "./tools/presence-now.js";
import {
vitalsGetBreathingSchema,
vitalsGetBreathing,
} from "./tools/vitals-get-breathing.js";
import {
vitalsGetHeartRateSchema,
vitalsGetHeartRate,
} from "./tools/vitals-get-heart-rate.js";
import { vitalsGetAllSchema, vitalsGetAll } from "./tools/vitals-get-all.js";
// NOTE: ./http-transport.js is imported lazily in main() — it chain-loads the
// SDK's streamableHttp module (~48 ms MEASURED), which the default stdio path
// never uses.
import { TOOL_INPUT_SCHEMAS } from "./schemas/index.js";
import { bfldLastScan } from "./tools/bfld-last-scan.js";
import { bfldSubscribe } from "./tools/bfld-subscribe.js";
import { presenceNow } from "./tools/presence-now.js";
import { vitalsGetBreathing } from "./tools/vitals-get-breathing.js";
import { vitalsGetHeartRate } from "./tools/vitals-get-heart-rate.js";
import { vitalsGetAll } from "./tools/vitals-get-all.js";
// Single-source the version from package.json (ADR-264 O8/ADR-265 D3).
const require = createRequire(import.meta.url);
const PACKAGE_VERSION: string = (
require("../package.json") as { version: string }
).version;
const PACKAGE_VERSION = "0.1.0";
const SERVER_NAME = "rvagent";
// ── Tool registry ──────────────────────────────────────────────────────────
type RuviewConfig = ReturnType<typeof loadConfig>;
interface ToolDef {
name: string;
description: string;
/** The single validation source; the advertised JSON Schema derives from it. */
schema: z.ZodTypeAny;
handler: (parsedArgs: unknown, config: RuviewConfig) => Promise<object>;
}
export const TOOLS: ToolDef[] = [
const TOOLS = [
{
name: "ruview_csi_latest",
description:
"Pull the latest CSI window from a running wifi-densepose-sensing-server. " +
"Returns 56-subcarrier × 20-frame amplitude/phase arrays suitable for " +
"downstream inference or research analysis.",
schema: csiLatestSchema,
handler: (args, config) =>
csiLatest(args as Parameters<typeof csiLatest>[0], config),
inputSchema: {
type: "object" as const,
properties: {
sensing_server_url: {
type: "string",
description:
"Base URL of the sensing-server (default: RUVIEW_SENSING_SERVER_URL or http://localhost:3000).",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = csiLatestSchema.parse(args);
return csiLatest(input, config);
},
},
{
name: "ruview_pose_infer",
@@ -109,9 +86,23 @@ export const TOOLS: ToolDef[] = [
"cog-pose-estimation Cog binary (ADR-101). Accepts a CSI window JSON file " +
"or uses the live sensing-server if no window is provided. " +
"Returns [{keypoints: [[x,y]×17], confidence}] per detected person.",
schema: poseInferSchema,
handler: (args, config) =>
poseInfer(args as Parameters<typeof poseInfer>[0], config),
inputSchema: {
type: "object" as const,
properties: {
window_path: {
type: "string",
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
},
cog_binary: {
type: "string",
description: "Path to cog-pose-estimation binary.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = poseInferSchema.parse(args);
return poseInfer(input, config);
},
},
{
name: "ruview_count_infer",
@@ -119,9 +110,29 @@ export const TOOLS: ToolDef[] = [
"Run a single-shot person-count inference using the cog-person-count Cog " +
"binary (ADR-103). Returns {count, confidence, count_p95_low, count_p95_high} " +
"with a Stoer-Wagner multi-node fusion upper bound when multiple nodes are active.",
schema: countInferSchema,
handler: (args, config) =>
countInfer(args as Parameters<typeof countInfer>[0], config),
inputSchema: {
type: "object" as const,
properties: {
window_path: {
type: "string",
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
},
cog_binary: {
type: "string",
description: "Path to cog-person-count binary.",
},
max_persons: {
type: "integer",
minimum: 1,
maximum: 7,
description: "Upper bound on person count (17). Default: 7.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = countInferSchema.parse(args);
return countInfer(input, config);
},
},
{
name: "ruview_registry_list",
@@ -129,9 +140,33 @@ export const TOOLS: ToolDef[] = [
"List cogs from the Cognitum edge module registry (ADR-102). " +
"Fetches /api/v1/edge/registry from the sensing-server, which proxies the " +
"canonical GCS catalog (105 cogs, 11 categories). Supports category filter and search.",
schema: registryListSchema,
handler: (args, config) =>
registryList(args as Parameters<typeof registryList>[0], config),
inputSchema: {
type: "object" as const,
properties: {
category: {
type: "string",
description:
"Filter by category: health, security, building, retail, industrial, " +
"research, ai, swarm, signal, network, developer.",
},
search: {
type: "string",
description: "Search substring matched against cog id and name (case-insensitive).",
},
refresh: {
type: "boolean",
description: "Bypass the 1-hour registry cache.",
},
sensing_server_url: {
type: "string",
description: "Override the sensing-server URL.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = registryListSchema.parse(args);
return registryList(input, config);
},
},
{
name: "ruview_train_count",
@@ -139,139 +174,211 @@ export const TOOLS: ToolDef[] = [
"Kick off a cog-person-count training run using the Candle GPU trainer " +
"(ADR-103). The paired JSONL file provides CSI windows + camera-derived " +
"person-count labels. Returns a job_id to poll with ruview_job_status.",
schema: trainCountSchema,
handler: (args, config) =>
trainCount(args as Parameters<typeof trainCount>[0], config),
inputSchema: {
type: "object" as const,
required: ["paired_jsonl"],
properties: {
paired_jsonl: {
type: "string",
description:
"Path to the paired JSONL training file (produced by scripts/align-ground-truth.js).",
},
epochs: {
type: "integer",
minimum: 1,
maximum: 10000,
description: "Training epochs (default: 400).",
},
learning_rate: {
type: "number",
description: "Initial learning rate (default: 0.001).",
},
output_dir: {
type: "string",
description:
"Directory for model artifacts (default: v2/crates/cog-person-count/cog/artifacts/).",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = trainCountSchema.parse(args);
return trainCount(input, config);
},
},
{
name: "ruview_job_status",
description:
"Poll the status of a background training job started by ruview_train_count. " +
"Returns {status, epochs_done, epochs_total, recent_log} for the given job_id.",
schema: jobStatusSchema,
handler: (args, config) =>
jobStatus(args as Parameters<typeof jobStatus>[0], config),
inputSchema: {
type: "object" as const,
required: ["job_id"],
properties: {
job_id: {
type: "string",
description: "UUID returned by ruview_train_count.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = jobStatusSchema.parse(args);
return jobStatus(input, config);
},
},
// ── ADR-124 BFLD tools (Phase 4 Refinement; underscore names per ADR-264)
// ── ADR-124 BFLD tools (Phase 4 Refinement) ─────────────────────────────
{
name: "ruview_bfld_last_scan",
name: "ruview.bfld.last_scan",
description:
"Return the most recent BFLD scan result for a node (ADR-118/ADR-121). " +
"Fields: node_id, identity_risk_score [0,1], privacy_class, n_frames, timestamp_ms. " +
"Proxied from sensing-server GET /api/v1/bfld/<node_id>/last_scan which aggregates " +
"the MQTT state topics ruview/<node_id>/bfld/* (ADR-122 §2.2).",
schema: bfldLastScanSchema,
handler: (args, config) =>
bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config),
inputSchema: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Target node id. Omit to use the single active node.",
},
sensing_server_url: {
type: "string",
description: "Override sensing-server URL for this call only.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
return bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config);
},
},
{
name: "ruview_bfld_subscribe",
name: "ruview.bfld.subscribe",
description:
"Subscribe to BFLD events on ruview/<node_id>/bfld/* for duration_s seconds (ADR-122). " +
"Returns {ok, subscription_id, expires_at, topic}. When the sensing-server is unreachable, " +
"returns a synthetic envelope with ok:false,warn:true so the caller can distinguish " +
"a network error from an invalid request.",
schema: bfldSubscribeSchema,
handler: (args, config) =>
bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config),
inputSchema: {
type: "object" as const,
required: ["duration_s"],
properties: {
node_id: {
type: "string",
description: "Target node id. Omit to use the single active node.",
},
duration_s: {
type: "number",
minimum: 0,
maximum: 3600,
description: "Subscription duration in seconds (max 3600).",
},
sensing_server_url: {
type: "string",
description: "Override sensing-server URL for this call only.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
return bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config);
},
},
// ── ADR-124 Presence + Vitals tools ───────────────────────────────────────
// ── ADR-124 Presence + Vitals tools (Phase 4 Refinement iter 5) ──────────
{
name: "ruview_presence_now",
name: "ruview.presence.now",
description:
"Return current occupancy for a node: present, n_persons, confidence, timestamp_ms. " +
"Wraps EdgeVitalsMessage.presence + n_persons (ADR-124 §4.1, ws.py:74-88).",
schema: presenceNowSchema,
handler: (args, config) =>
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
presenceNow(args as Parameters<typeof presenceNow>[0], config),
},
{
name: "ruview_vitals_get_breathing",
name: "ruview.vitals.get_breathing",
description:
"Return breathing rate for a node: breathing_rate_bpm (null if unavailable), " +
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.breathing_rate_bpm (ws.py:82).",
schema: vitalsGetBreathingSchema,
handler: (args, config) =>
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
vitalsGetBreathing(args as Parameters<typeof vitalsGetBreathing>[0], config),
},
{
name: "ruview_vitals_get_heart_rate",
name: "ruview.vitals.get_heart_rate",
description:
"Return heart rate for a node: heartrate_bpm (null if unavailable), " +
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.heartrate_bpm (ws.py:83).",
schema: vitalsGetHeartRateSchema,
handler: (args, config) =>
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
vitalsGetHeartRate(args as Parameters<typeof vitalsGetHeartRate>[0], config),
},
{
name: "ruview_vitals_get_all",
name: "ruview.vitals.get_all",
description:
"Return the full EdgeVitalsMessage for a node (all fields except raw): " +
"presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id. " +
"Full surface of ws.py:74-88.",
schema: vitalsGetAllSchema,
handler: (args, config) =>
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
vitalsGetAll(args as Parameters<typeof vitalsGetAll>[0], config),
},
];
] as const;
/**
* Pre-ADR-264 dotted tool names, accepted at call time for one deprecation
* cycle. Router-only: tools/list never advertises these.
*/
export const TOOL_ALIASES: Record<string, string> = {
"ruview.bfld.last_scan": "ruview_bfld_last_scan",
"ruview.bfld.subscribe": "ruview_bfld_subscribe",
"ruview.presence.now": "ruview_presence_now",
"ruview.vitals.get_breathing": "ruview_vitals_get_breathing",
"ruview.vitals.get_heart_rate": "ruview_vitals_get_heart_rate",
"ruview.vitals.get_all": "ruview_vitals_get_all",
};
// ── Server bootstrap ────────────────────────────────────────────────────────
/**
* Advertised JSON Schema, generated from the Zod source (ADR-264 O5).
* Memoized: schemas are static for the process lifetime, and tools/list is
* called once per session (per HTTP session under the session-per-server
* model) no point re-walking the Zod tree each time.
*/
const jsonSchemaCache = new Map<string, object>();
export function toolInputJsonSchema(def: ToolDef): object {
const cached = jsonSchemaCache.get(def.name);
if (cached !== undefined) return cached;
const raw = zodToJsonSchema(def.schema, { $refStrategy: "none" }) as Record<
string,
unknown
>;
delete raw["$schema"];
jsonSchemaCache.set(def.name, raw);
return raw;
}
async function main(): Promise<void> {
const config = loadConfig();
// ── Server factory ──────────────────────────────────────────────────────────
/**
* Build a fully-wired MCP Server. A factory (not a singleton) because each
* Streamable-HTTP session needs its own Server instance (ADR-264 F7/O3).
*/
export function buildServer(config: RuviewConfig = loadConfig()): Server {
const server = new Server(
{ name: SERVER_NAME, version: PACKAGE_VERSION },
{ capabilities: { tools: {} } }
{
name: SERVER_NAME,
version: PACKAGE_VERSION,
},
{
capabilities: {
tools: {},
},
}
);
// List tools handler.
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: TOOLS.map((t) => ({
name: t.name,
description: t.description,
inputSchema: toolInputJsonSchema(t),
inputSchema: t.inputSchema,
})),
}));
// Call tool handler — the SINGLE Zod validation gate (ADR-264 O5): parse
// once, hand the typed result (with defaults applied) to the handler.
// Call tool handler — uniform Zod validation gate (ADR-124 §3 Architecture).
// If TOOL_INPUT_SCHEMAS has a schema for the tool name, run safeParse first.
// Parse failures throw McpError(InvalidParams) so the client sees a typed
// JSON-RPC error rather than a wrapped string error.
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name: rawName, arguments: args } = request.params;
const name = TOOL_ALIASES[rawName] ?? rawName;
const { name, arguments: args } = request.params;
const tool = TOOLS.find((t) => t.name === name);
if (!tool) {
@@ -281,7 +388,7 @@ export function buildServer(config: RuviewConfig = loadConfig()): Server {
type: "text" as const,
text: JSON.stringify({
ok: false,
error: `Unknown tool "${rawName}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
error: `Unknown tool "${name}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
}),
},
],
@@ -289,16 +396,22 @@ export function buildServer(config: RuviewConfig = loadConfig()): Server {
};
}
const parsed = tool.schema.safeParse(args ?? {});
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for tool "${rawName}": ${parsed.error.message}`
);
// Schema validation gate — applies to all tools registered in TOOL_INPUT_SCHEMAS.
const schemaEntry = Object.prototype.hasOwnProperty.call(TOOL_INPUT_SCHEMAS, name)
? TOOL_INPUT_SCHEMAS[name as keyof typeof TOOL_INPUT_SCHEMAS]
: undefined;
if (schemaEntry !== undefined) {
const parsed = schemaEntry.safeParse(args ?? {});
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for tool "${name}": ${parsed.error.message}`
);
}
}
try {
const result = await tool.handler(parsed.data, config);
const result = await tool.handler(args ?? {}, config);
return {
content: [
{
@@ -325,59 +438,18 @@ export function buildServer(config: RuviewConfig = loadConfig()): Server {
}
});
return server;
}
// ── Server bootstrap ────────────────────────────────────────────────────────
async function main(): Promise<void> {
const config = loadConfig();
// stdio transport (default, always on).
const stdioServer = buildServer(config);
// Wire up stdio transport.
const transport = new StdioServerTransport();
await stdioServer.connect(transport);
// Streamable HTTP transport — explicit opt-in only (ADR-264 O3). Lazily
// imported so the stdio path never pays the streamableHttp load cost.
const httpPort = process.env["RVAGENT_HTTP_PORT"];
let httpNote = "";
if (httpPort !== undefined && httpPort !== "") {
const { createHttpTransport } = await import("./http-transport.js");
const { boundAddress } = await createHttpTransport(
() => buildServer(config),
{ port: Number(httpPort) }
);
httpNote = ` HTTP: ${boundAddress}/mcp.`;
}
await server.connect(transport);
// Log to stderr so it doesn't interfere with the MCP stdio protocol.
process.stderr.write(
`[@ruvnet/rvagent] Server v${PACKAGE_VERSION} started. ` +
`Sensing server: ${config.sensingServerUrl}.${httpNote}\n`
`Sensing server: ${config.sensingServerUrl}\n`
);
}
// CLI guard: boot the server only when this module is the entrypoint — invoked
// as the `rvagent` / `ruview-mcp` bin or `node dist/index.js`. Importing it as a
// library (`import { buildServer } from "@ruvnet/rvagent"`) must NOT side-effect
// connect a StdioServerTransport to the consumer's stdin/stdout. Realpath both
// sides because npm's bin shim is a symlink and passes a non-normalized,
// possibly case-skewed argv[1] on Windows (mirrors harness/ruview/bin/cli.js).
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch {
return false;
}
})();
if (invokedDirectly) {
main().catch((e) => {
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
process.exit(1);
});
}
main().catch((e) => {
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
process.exit(1);
});
+31 -160
View File
@@ -17,16 +17,7 @@
import { z } from "zod";
import { randomUUID } from "node:crypto";
import {
mkdirSync,
appendFileSync,
openSync,
closeSync,
readFileSync,
writeFileSync,
statSync,
readSync,
} from "node:fs";
import { mkdirSync, appendFileSync, openSync } from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import type { RuviewConfig, TrainJobResult, JobStatusResult } from "../types.js";
@@ -75,101 +66,17 @@ export const jobStatusSchema = z.object({
export type JobStatusInput = z.infer<typeof jobStatusSchema>;
interface JobRecord {
status: "queued" | "running" | "done" | "failed" | "unknown";
log_path: string;
queued_at: number;
epochs_total: number;
/**
* OS pid of the training child. Persisted so a later process (e.g. after an
* MCP server restart) can tell whether a job still marked 'running' actually
* outlived the process that spawned it (ADR-264 O6).
*/
pid?: number | undefined;
/** Human-readable explanation attached during reconciliation (unknown state). */
reason?: string | undefined;
}
// In-process job registry, mirrored to <jobsDir>/<id>.json on every state
// change so ruview_job_status survives an MCP server restart (ADR-264 O6).
const jobRegistry = new Map<string, JobRecord>();
function jobRecordPath(jobsDir: string, jobId: string): string {
return path.join(jobsDir, `${jobId}.json`);
}
function persistJob(jobsDir: string, jobId: string, record: JobRecord): void {
try {
writeFileSync(
jobRecordPath(jobsDir, jobId),
JSON.stringify({ job_id: jobId, ...record }, null, 2)
);
} catch {
// Persistence is best-effort; the in-memory record still serves this process.
// In-process job registry (survives for the lifetime of the MCP server process).
// For a production implementation, persist to ~/.ruview/jobs/<id>.json.
const jobRegistry = new Map<
string,
{
status: "queued" | "running" | "done" | "failed";
log_path: string;
queued_at: number;
epochs_total: number;
}
}
function loadPersistedJob(jobsDir: string, jobId: string): JobRecord | undefined {
try {
const raw = JSON.parse(readFileSync(jobRecordPath(jobsDir, jobId), "utf8")) as
Partial<JobRecord>;
if (typeof raw.log_path !== "string" || typeof raw.status !== "string") {
return undefined;
}
return {
status: raw.status,
log_path: raw.log_path,
queued_at: typeof raw.queued_at === "number" ? raw.queued_at : 0,
epochs_total: typeof raw.epochs_total === "number" ? raw.epochs_total : 0,
pid: typeof raw.pid === "number" ? raw.pid : undefined,
reason: typeof raw.reason === "string" ? raw.reason : undefined,
};
} catch {
return undefined;
}
}
/**
* Is `pid` still a live process? `process.kill(pid, 0)` sends no signal but
* probes existence: ESRCH gone; EPERM alive but owned by another user
* (treated as alive so we never falsely reconcile a still-running job).
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
/**
* Scan log lines (tail) for the "# exit code: N" marker the child.on('close')
* handler appends. `found:false` means the process died without the marker
* i.e. this server never saw the close (it restarted mid-run).
*/
function findExitMarker(lines: string[]): { found: boolean; code: number | null } {
for (let i = lines.length - 1; i >= 0; i--) {
const m = /^# exit code: (-?\d+|null)$/.exec((lines[i] ?? "").trim());
if (m) return { found: true, code: m[1] === "null" ? null : Number(m[1]) };
}
return { found: false, code: null };
}
/** Read the last `maxLines` lines of a file without loading the whole log. */
function tailLines(filePath: string, maxLines: number, maxBytes = 64 * 1024): string[] {
const size = statSync(filePath).size;
const start = Math.max(0, size - maxBytes);
const buf = Buffer.alloc(size - start);
const fd = openSync(filePath, "r");
try {
readSync(fd, buf, 0, buf.length, start);
} finally {
closeSync(fd);
}
const lines = buf.toString("utf8").split("\n");
return lines.slice(Math.max(0, lines.length - maxLines));
}
>();
export async function trainCount(
input: TrainCountInput,
@@ -185,16 +92,13 @@ export async function trainCount(
const outputDir =
input.output_dir ?? "v2/crates/cog-person-count/cog/artifacts";
// Record the job immediately so ruview_job_status can find it — in memory
// and on disk (survives server restarts, ADR-264 O6).
const record: JobRecord = {
// Record the job immediately so ruview_job_status can find it.
jobRegistry.set(jobId, {
status: "queued",
log_path: logPath,
queued_at: queuedAt,
epochs_total: input.epochs,
};
jobRegistry.set(jobId, record);
persistJob(logDir, jobId, record);
});
// Write the header synchronously so the log file exists before spawn.
const header = [
@@ -238,29 +142,21 @@ export async function trainCount(
child.unref(); // Allow the MCP server process to exit without waiting for training.
// The child holds its own duplicates of the log fds; close the parent's
// copies immediately or every job leaks 2 fds for the server's lifetime
// (ADR-264 F6/O6).
closeSync(logFdOut);
closeSync(logFdErr);
// Record the child pid so a later process can reconcile a stale 'running'
// record after a server restart (child.pid is undefined only if spawn failed
// synchronously, in which case the 'error' handler flips status to 'failed').
record.pid = child.pid;
record.status = "running";
persistJob(logDir, jobId, record);
const entry = jobRegistry.get(jobId);
if (entry) {
entry.status = "running";
}
child.on("error", (e) => {
appendFileSync(logPath, `\n# ERROR: ${e.message}\n`);
record.status = "failed";
persistJob(logDir, jobId, record);
const rec = jobRegistry.get(jobId);
if (rec) rec.status = "failed";
});
child.on("close", (code) => {
appendFileSync(logPath, `\n# exit code: ${code}\n`);
record.status = code === 0 ? "done" : "failed";
persistJob(logDir, jobId, record);
const rec = jobRegistry.get(jobId);
if (rec) rec.status = code === 0 ? "done" : "failed";
});
const result: TrainJobResult = {
@@ -282,48 +178,24 @@ export async function trainCount(
export async function jobStatus(
input: JobStatusInput,
config: RuviewConfig
_config: RuviewConfig
): Promise<object> {
// Memory first, then the persisted record (survives server restarts).
let job = jobRegistry.get(input.job_id) ?? loadPersistedJob(config.jobsDir, input.job_id);
const job = jobRegistry.get(input.job_id);
if (!job) {
return {
ok: false,
error: `Job ${input.job_id} not found in this server or in ${config.jobsDir}.`,
error: `Job ${input.job_id} not found. ` +
"The MCP server may have restarted — check the log directory directly.",
};
}
// Reconcile a 'running' record whose owning process is gone. The status flip
// to done/failed lives only in the spawning process's child.on('close'/'error')
// handlers; if this server restarted mid-run, the record froze at 'running'
// (ADR-264 O6). When the pid is dead, recover the true outcome from the log's
// "# exit code: N" marker, else surface an honest 'unknown'.
if (job.status === "running" && typeof job.pid === "number" && !isProcessAlive(job.pid)) {
let tail: string[] = [];
try {
tail = tailLines(job.log_path, 40);
} catch {
/* log unreadable — treated as no marker below */
}
const marker = findExitMarker(tail);
const reconciled: JobRecord = { ...job };
if (marker.found) {
reconciled.status = marker.code === 0 ? "done" : "failed";
reconciled.reason = undefined;
} else {
reconciled.status = "unknown";
reconciled.reason =
"process gone, no exit marker — server likely restarted mid-run";
}
jobRegistry.set(input.job_id, reconciled);
persistJob(config.jobsDir, input.job_id, reconciled);
job = reconciled;
}
// Bounded tail read — never load a multi-GB training log wholesale.
// Read the last 20 lines of the log file.
let recentLog: string[] = [];
try {
recentLog = tailLines(job.log_path, 20);
const { readFileSync } = await import("node:fs");
const content = readFileSync(job.log_path, "utf8");
const lines = content.split("\n");
recentLog = lines.slice(Math.max(0, lines.length - 20));
} catch {
recentLog = ["(log not readable yet)"];
}
@@ -334,7 +206,6 @@ export async function jobStatus(
log_path: job.log_path,
recent_log: recentLog,
epochs_total: job.epochs_total,
...(job.reason !== undefined ? { reason: job.reason } : {}),
};
return { ok: true, result };
+1 -8
View File
@@ -115,12 +115,7 @@ export interface TrainJobResult {
/** Output of ruview_job_status. */
export interface JobStatusResult {
job_id: string;
/**
* 'unknown' is only ever produced by post-restart reconciliation: a record
* frozen at 'running' whose owning process is gone and whose log carries no
* exit-code marker (see reason).
*/
status: "queued" | "running" | "done" | "failed" | "unknown";
status: "queued" | "running" | "done" | "failed";
progress_pct?: number | undefined;
/** Most recent log lines (last 20). */
recent_log: string[];
@@ -129,8 +124,6 @@ export interface JobStatusResult {
epochs_done?: number | undefined;
/** Total epochs scheduled. */
epochs_total?: number | undefined;
/** Explanation attached when status was reconciled to 'unknown'. */
reason?: string | undefined;
}
// ── Vitals (ADR-124 §6 Python surface parity: ws.py:74-88) ───────────────
-49
View File
@@ -1,49 +0,0 @@
/**
* ADR-264 F8/O7 cog-binary detection must be architecture-aware.
*
* detectCogBinary() itself probes hardcoded /var/lib paths, so it is not
* cheaply testable without fs mocking. The bug it fixes, however, lives purely
* in the candidate ORDER, which cogBinaryCandidates() exposes as a pure,
* arch-injectable function that is what we pin here.
*/
import { cogBinaryCandidates } from "../src/config.js";
describe("cogBinaryCandidates()", () => {
it("probes -arm before -x86_64 on arm64 hosts", () => {
const c = cogBinaryCandidates("cog-person-count", "arm64");
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
expect(arm).toBeGreaterThanOrEqual(0);
expect(x86).toBeGreaterThanOrEqual(0);
expect(arm).toBeLessThan(x86);
});
it("probes -x86_64 before -arm on x64 hosts", () => {
const c = cogBinaryCandidates("cog-person-count", "x64");
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
expect(x86).toBeLessThan(arm);
});
it("defaults an unknown arch to the x86_64-first order", () => {
const c = cogBinaryCandidates("cog-pose-estimation", "riscv64");
const arm = c.findIndex((p) => p.endsWith("cog-pose-estimation-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-pose-estimation-x86_64"));
expect(x86).toBeLessThan(arm);
});
it("keeps the /usr/local/bin and bare-name PATH fallbacks last", () => {
const c = cogBinaryCandidates("cog-person-count", "arm64");
// The two arch builds come first; the /usr/local/bin fallback follows them.
expect(c[c.length - 1]).toBe("/usr/local/bin/cog-person-count");
expect(c).toHaveLength(3);
});
it("derives the id by stripping the cog- prefix once", () => {
const c = cogBinaryCandidates("cog-person-count", "x64");
expect(c[0]).toBe(
"/var/lib/cognitum/apps/person-count/cog-person-count-x86_64"
);
});
});
+3 -145
View File
@@ -59,9 +59,7 @@ async function startServer(
basePort: number
): Promise<{ port: number; close: () => Promise<void> }> {
const port = basePort + Math.floor(Math.random() * 100);
// Factory, not instance: each Streamable-HTTP session gets its own MCP
// Server (ADR-264 F7/O3).
const { httpServer } = buildHttpApp(() => makeMockMcpServer(), opts);
const { httpServer } = buildHttpApp(makeMockMcpServer(), opts);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => resolve());
@@ -97,34 +95,8 @@ describe("isOriginAllowed()", () => {
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
});
// ADR-264 F7: real browser origins carry ports — localhost must match on
// hostname, any port, even with an empty allowlist.
it("allows localhost origins on any port", () => {
expect(isOriginAllowed("http://localhost:5173", [])).toBe(true);
expect(isOriginAllowed("http://127.0.0.1:8080", [])).toBe(true);
expect(isOriginAllowed("https://localhost:3001", [])).toBe(true);
});
it("rejects non-local origins even with a localhost-looking prefix", () => {
expect(isOriginAllowed("http://localhost.evil.example.com", [])).toBe(false);
expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false);
});
// ADR-264 F7 hardening: an EXPLICIT allowlist means exact matching only. The
// any-port-localhost convenience applies solely to the empty-allowlist case,
// so an operator who pins an allowlist actually gets it.
it("with an explicit allowlist, rejects a localhost origin on an unlisted port", () => {
expect(isOriginAllowed("http://localhost:5173", allow)).toBe(false);
expect(isOriginAllowed("http://127.0.0.1:8080", allow)).toBe(false);
});
it("with an explicit allowlist, still accepts an exactly-listed localhost origin", () => {
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
});
it("is case-sensitive for non-local allowlist entries per RFC 6454", () => {
expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false);
it("is case-sensitive per RFC 6454", () => {
expect(isOriginAllowed("HTTP://localhost", allow)).toBe(false);
});
});
@@ -193,117 +165,3 @@ describe("HTTP transport bearer-token auth gate", () => {
expect(r.status).not.toBe(401);
});
});
// ── 7. ADR-264 F7/O3 hardening: body cap + per-session routing ─────────────
describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => {
let port: number;
let close: () => Promise<void>;
beforeAll(async () => {
const srv = await startServer({ allowedOrigins: ["*"], maxBodyBytes: 64 * 1024 }, 49600);
port = srv.port;
close = srv.close;
});
afterAll(async () => { await close(); });
it("rejects oversized request bodies with 413", async () => {
const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "x", params: { pad: "y".repeat(128 * 1024) } });
const r = await post(port, "/mcp", {}, huge);
expect(r.status).toBe(413);
});
it("rejects a non-initialize POST without a session id with 400 (never a shared transport)", async () => {
const r = await post(port, "/mcp", {}, MCP_BODY); // tools/list, no mcp-session-id
expect(r.status).toBe(400);
const body = JSON.parse(r.body) as Record<string, unknown>;
expect(body["error"]).toMatch(/initialize/i);
});
it("rejects a POST with an unknown session id with 404", async () => {
const r = await post(port, "/mcp", { "mcp-session-id": "no-such-session" }, MCP_BODY);
expect(r.status).toBe(404);
});
it("creates a fresh session (and MCP server) per initialize request", async () => {
const init = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "0.0.0" },
},
});
const r = await post(port, "/mcp", { Accept: "application/json, text/event-stream" }, init);
expect([200, 406]).not.toContain(0); // sanity
expect(r.status).toBe(200);
});
});
// ── 8. ADR-264 F7: session-map bounds (cap + idle TTL sweep) ───────────────
describe("HTTP transport session bounds (ADR-264 F7)", () => {
const initBody = (id: number): string =>
JSON.stringify({
jsonrpc: "2.0",
id,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "0.0.0" },
},
});
// Build directly (not via startServer) so we can inspect the sessions map.
async function startWithApp(
opts: Parameters<typeof buildHttpApp>[1],
basePort: number
): Promise<{
port: number;
sessions: ReturnType<typeof buildHttpApp>["sessions"];
close: () => Promise<void>;
}> {
const { httpServer, sessions } = buildHttpApp(() => makeMockMcpServer(), opts);
const port = basePort + Math.floor(Math.random() * 100);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => resolve());
});
const close = () =>
new Promise<void>((res, rej) => httpServer.close((e) => (e ? rej(e) : res())));
return { port, sessions, close };
}
const ACCEPT = { Accept: "application/json, text/event-stream" };
it("never exceeds maxSessions — evicts the oldest-idle session at capacity", async () => {
const srv = await startWithApp({ allowedOrigins: ["*"], maxSessions: 2 }, 49800);
try {
for (let i = 0; i < 5; i++) {
await post(srv.port, "/mcp", ACCEPT, initBody(i));
}
expect(srv.sessions.size).toBeLessThanOrEqual(2);
} finally {
await srv.close();
}
});
it("sweeps sessions idle beyond sessionIdleMs", async () => {
const srv = await startWithApp(
{ allowedOrigins: ["*"], sessionIdleMs: 20, sweepIntervalMs: 10 },
49900
);
try {
await post(srv.port, "/mcp", ACCEPT, initBody(1));
expect(srv.sessions.size).toBe(1);
await new Promise((r) => setTimeout(r, 150));
expect(srv.sessions.size).toBe(0);
} finally {
await srv.close();
}
});
});
+4 -4
View File
@@ -15,11 +15,11 @@
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
// jest runs from the package root; avoid import.meta (ts-jest transforms this
// suite to a module target that rejects it — pre-existing suite failure).
const pkgPath = resolve(process.cwd(), "package.json");
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(__dirname, "../package.json");
// Parse once; keep raw for snapshot assertions.
const raw = readFileSync(pkgPath, "utf-8");
@@ -1,96 +0,0 @@
/**
* ADR-264 O6 post-restart job reconciliation.
*
* When the MCP server restarts mid-run, the persisted job record stays frozen
* at 'running' (the child.on('close') that flips it lived in the dead process).
* ruview_job_status must reconcile such a record against the recorded pid and
* the log's "# exit code: N" marker.
*
* We fabricate a persisted record pointing at a KNOWN-DEAD pid (a synchronous
* child that has already exited) and assert the reconciled status.
*/
import { mkdtempSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { jobStatus } from "../src/tools/train-count.js";
import type { RuviewConfig } from "../src/types.js";
/** A pid that has certainly exited: spawnSync waits for the child to finish. */
function deadPid(): number {
const r = spawnSync(process.execPath, ["-e", ""]);
if (typeof r.pid !== "number") throw new Error("could not spawn probe child");
return r.pid;
}
function makeConfig(jobsDir: string): RuviewConfig {
return {
sensingServerUrl: "http://127.0.0.1:19999",
apiToken: undefined,
poseCogBinary: "nonexistent",
countCogBinary: "nonexistent",
jobsDir,
};
}
/** Write a fake persisted 'running' record + its log, return {jobId, config}. */
function seedRunningJob(logBody: string): { jobId: string; config: RuviewConfig } {
const jobsDir = mkdtempSync(path.join(os.tmpdir(), "rvagent-jobs-"));
const jobId = randomUUID();
const logPath = path.join(jobsDir, `${jobId}.log`);
writeFileSync(logPath, logBody);
const record = {
job_id: jobId,
status: "running",
log_path: logPath,
queued_at: Date.now() / 1000,
epochs_total: 5,
pid: deadPid(),
};
writeFileSync(
path.join(jobsDir, `${jobId}.json`),
JSON.stringify(record, null, 2)
);
return { jobId, config: makeConfig(jobsDir) };
}
describe("ruview_job_status reconciliation (ADR-264 O6)", () => {
it("reconciles a dead 'running' job with exit 0 to 'done'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\nepoch 5/5\n# exit code: 0\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
expect(out["ok"]).toBe(true);
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("done");
});
it("reconciles a dead 'running' job with non-zero exit to 'failed'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\npanic: cuda oom\n# exit code: 101\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("failed");
});
it("marks a dead 'running' job with no exit marker as 'unknown' with a reason", async () => {
const { jobId, config } = seedRunningJob("# training...\nepoch 2/5\n");
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("unknown");
expect(typeof res["reason"]).toBe("string");
expect(res["reason"]).toMatch(/restarted/i);
});
it("treats a signal-killed marker (null) as 'failed'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\n# exit code: null\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("failed");
});
});
+2 -2
View File
@@ -7,8 +7,8 @@
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
+4 -4
View File
@@ -25,7 +25,8 @@ members = [
"crates/wifi-densepose-ruvector",
"crates/wifi-densepose-desktop",
"crates/wifi-densepose-pointcloud",
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
"crates/wifi-densepose-geo",
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
"crates/nvsim",
@@ -57,7 +58,7 @@ members = [
"crates/wifi-densepose-bfld",
# ADR-147: OccWorld thin-client bridge — WorldGraph PersonTrack history →
# OccWorld Python subprocess → TrajectoryPrior injection into pose tracker.
# worldmodel extracted to ruvnet/worldgraph submodule (consumed via path dep)
"crates/wifi-densepose-worldmodel",
# ADR-147 (Phase 5): OccWorld TransVQVAE ported to Candle — native Rust
# inference without Python/IPC overhead. Loaded alongside the Python bridge
# as a faster alternative once Phase-5 weights are available.
@@ -87,7 +88,6 @@ members = [
exclude = [
"crates/wifi-densepose-wasm-edge",
"crates/homecore-plugin-example",
"crates/worldgraph", # ruvnet/worldgraph submodule — its own workspace (geo/worldgraph/worldmodel)
]
[workspace.package]
@@ -215,7 +215,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/wifi-densepose-worldmodel" }
[profile.release]
lto = true
+84
View File
@@ -0,0 +1,84 @@
[package]
name = "ruview-swarm"
version = "0.1.0"
edition = "2021"
description = "RuView drone swarm control system — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing integration (ADR-148)"
license = "Apache-2.0"
# Publishing disabled until: (1) PR #862 merges, (2) internal path-deps are
# published in dependency order, (3) export-control sign-off on the ITAR-gated
# coordination features (USML Category VIII(h)(12)). Flip to true deliberately.
publish = false
[features]
default = []
# ITAR/USML Category VIII(h)(12): swarming coordination features.
# Must not be enabled in international distributions without export counsel review.
itar-unrestricted = []
mavlink = ["dep:mavlink"]
ros2-dds = []
onnx = ["dep:ort"]
simulation = []
demo = ["simulation"]
full = ["mavlink", "onnx", "demo", "itar-unrestricted"]
ruflo = ["dep:reqwest", "dep:serde_json"]
# Heavy GPU-capable MARL training (real Candle autodiff PPO). Off by default so
# the default build stays light and the existing test suite keeps passing.
train = ["dep:candle-core", "dep:candle-nn"]
cuda = ["candle-core/cuda", "candle-nn/cuda"]
[dependencies]
wifi-densepose-core = { path = "../wifi-densepose-core" }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
toml = "0.8"
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
# MAVLink v2 (optional)
mavlink = { version = "0.13", optional = true }
# ONNX Runtime (optional — for MARL actor inference)
ort = { version = "2.0.0-rc.11", optional = true }
# Candle 0.9 — real autodiff PPO training (optional, behind `train` feature).
candle-core = { version = "0.9", default-features = false, optional = true }
candle-nn = { version = "0.9", default-features = false, optional = true }
# HTTP client (optional — for Ruflo HTTP backend)
reqwest = { version = "0.12", features = ["json"], optional = true }
# Crypto — MAVLink v2 HMAC-SHA256 signing
hmac = "0.12"
sha2 = "0.10"
# Error handling
thiserror = "2.0"
# Logging
tracing = "0.1"
# Numerics
nalgebra = "0.33"
rand = "0.8"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
tokio-test = "0.4"
[[bench]]
name = "swarm_bench"
harness = false
# MARL training binary — requires the `train` feature (Candle autodiff).
# Excluded from the default build so `cargo test`/CI stay light.
[[bin]]
name = "train_marl"
required-features = ["train"]
# ADR-171 Stage-1 evaluation CLI — pure Rust, no special feature needed.
[[bin]]
name = "eval_swarm"
+108
View File
@@ -0,0 +1,108 @@
# wifi-densepose-swarm
Drone swarm control system for the RuView wifi-densepose workspace. Implements ADR-148.
## Overview
`wifi-densepose-swarm` provides a hierarchical-mesh drone swarm coordination system
with Raft consensus, MAPPO-based multi-agent reinforcement learning, and tight
integration with the existing WiFi CSI sensing pipeline (`wifi-densepose-signal`,
`wifi-densepose-ruvector`).
## Features
- **Hierarchical-Mesh Topology** — cluster heads over Raft consensus; inter-cluster Gossip for map dissemination
- **Formation Control** — F1 VirtualStructure, F2 LeaderFollower, F3 Reynolds flocking
- **3-Phase Coverage** — boustrophedon sweep → Bayesian probability grid → multi-drone triangulation
- **RRT-APF Path Planner** — RRT* with Artificial Potential Field reactive collision avoidance
- **MARL Actor (MAPPO)** — 64-dim local observation, 3-layer MLP actor, CTDE training interface
- **CSI Sensing Integration** — drone payload pipeline (ESP32-S3 → Jetson), multi-drone CSI fusion
- **OccWorld Bridge** — integrates ADR-147 OccWorld occupancy prior as path planner environment
- **Security Hardening** — MAVLink v2 HMAC-SHA256 signing, UWB GPS anti-spoofing, onboard geofencing, Remote ID
- **Fail-Safe State Machine** — 10-state onboard safety system, GCS-independent
- **Demo & Training Modes** — synthetic CSI generation, Gazebo/PX4 SITL interface, TOML mission configs
## ITAR Notice
> ⚠️ **Export-controlled capability.** Swarming coordination features (formation control,
> Raft consensus, task allocation) are gated behind the `itar-unrestricted` feature flag
> per **USML Category VIII(h)(12)**. Default builds compile only safe stubs.
> Do not enable `itar-unrestricted` for international distribution without export counsel review.
## Crate Features
| Feature | Description |
|---------|-------------|
| `default` | Core types, sensing, failsafe, config, MARL — no ITAR-gated code |
| `itar-unrestricted` | Enables formation control, Raft consensus, task allocation |
| `mavlink` | MAVLink v2 protocol support |
| `onnx` | ONNX Runtime backend for MARL actor inference (INT8) |
| `simulation` | Simulation-mode stubs |
| `demo` | Synthetic CSI generation, scenario runners |
| `full` | All of the above |
## Quick Start
```rust
use wifi_densepose_swarm::{config::SwarmConfig, demo::scenario::DemoScenario};
// Load a mission profile
let config = SwarmConfig::sar_default();
// Run a demo scenario
let scenario = DemoScenario::sar_rubble_field(4); // 4-drone SAR
let estimated_secs = scenario.estimate_coverage_time_secs();
// → < 240 s for 4 drones over 400×400 m (beyond Wi2SAR SOTA single-drone baseline)
```
## Mission Profiles
| Profile | Drones | Area | Application |
|---------|--------|------|-------------|
| `sar` | 612 | 400×400 m | Structural collapse victim search |
| `inspection` | 36 | Linear corridor | Infrastructure (power lines, bridges) |
| `agriculture` | 412 | Field-configurable | NDVI mapping, variable-rate spraying |
| `mine` | 24 | Tunnel | GPS-denied underground exploration |
| `relay` | 620 | Perimeter | Emergency telecom relay chain |
| `demo` | Any | Configurable | Synthetic CSI, configurable victims |
## Module Structure
```
src/
├── types.rs — NodeId, DroneState, SwarmTask, SwarmError, FailSafeState
├── topology/ — Raft consensus¹, Gossip dissemination, MeshTopology
├── formation/ — VirtualStructure¹, LeaderFollower¹, Reynolds flocking¹
├── planning/ — RRT-APF planner, 3-phase coverage, Bayesian grid, pheromone
├── allocation/ — Auction-based task allocation¹, FNN bid scorer¹
├── sensing/ — CSI payload pipeline, multi-drone fusion, OccWorld bridge
├── marl/ — MAPPO actor, LocalObservation, reward shaping, TrainingConfig
├── security/ — MAVLink signing, UWB anti-spoofing, geofencing, Remote ID
├── failsafe/ — 10-state onboard fail-safe machine
├── config/ — TOML SwarmConfig with mission presets
├── demo/ — Synthetic CSI, DemoScenario runners
├── integration/ — FlightController trait (PX4/ArduPilot/Sim)
└── bench_support.rs — Criterion fixture generators
¹ Requires `itar-unrestricted` feature.
```
## Related ADRs
| ADR | Title | Relation |
|-----|-------|----------|
| ADR-148 | Drone Swarm Control System | This crate |
| ADR-147 | OccWorld Occupancy World Model | Environment prior via `sensing::occworld_bridge` |
| ADR-134 | CSI→CIR ISTA Sparse Recovery | Drone payload sensing |
| ADR-146 | RF Encoder Multitask Heads | Drone payload inference |
| ADR-016 | RuVector Training Integration | CrossViewpointAttention |
## Performance Targets (vs. Wi2SAR SOTA)
| Metric | Wi2SAR baseline (1 drone) | 4-drone target |
|--------|--------------------------|----------------|
| Coverage | 160,000 m² | 160,000 m² |
| Time | 13.5 min | ≤ 4 min |
| Localization | 5 m | ≤ 2 m (3-view fusion) |
| MARL inference | N/A | ≤ 5 ms (INT8, release) |
| Raft election | N/A | ≤ 300 ms |
@@ -0,0 +1,70 @@
use criterion::{criterion_group, criterion_main, Criterion};
use ruview_swarm::marl::{MappoActor, ActorConfig};
use ruview_swarm::marl::LocalObservation;
use ruview_swarm::sensing::MultiViewFusion;
use ruview_swarm::planning::RrtApfPlanner;
use ruview_swarm::demo::{DemoScenario};
use ruview_swarm::types::{CsiDetection, NodeId, Position3D};
fn bench_marl_inference(c: &mut Criterion) {
let actor = MappoActor::random_init(ActorConfig::default());
let obs = LocalObservation::zeros();
c.bench_function("marl_actor_inference", |b| b.iter(|| actor.forward(&obs)));
}
fn bench_rrt_apf_plan(c: &mut Criterion) {
let planner = RrtApfPlanner::new(3.0);
let start = Position3D { x: 0.0, y: 0.0, z: -30.0 };
let goal = Position3D { x: 50.0, y: 50.0, z: -30.0 };
c.bench_function("rrt_apf_100iter", |b| b.iter(|| {
let mut rng = rand::thread_rng();
planner.plan(start, goal, 100, &mut rng)
}));
}
fn bench_multiview_fusion(c: &mut Criterion) {
let fusion = MultiViewFusion::default();
let detections = vec![
CsiDetection { drone_id: NodeId(0), confidence: 0.85, victim_position: Some(Position3D { x: 51.0, y: 49.0, z: 0.0 }), timestamp_ms: 0 },
CsiDetection { drone_id: NodeId(1), confidence: 0.78, victim_position: Some(Position3D { x: 49.0, y: 51.0, z: 0.0 }), timestamp_ms: 0 },
CsiDetection { drone_id: NodeId(2), confidence: 0.92, victim_position: Some(Position3D { x: 50.0, y: 50.0, z: 0.0 }), timestamp_ms: 0 },
];
let positions = vec![
(NodeId(0), Position3D { x: 0.0, y: 0.0, z: -30.0 }),
(NodeId(1), Position3D { x: 100.0, y: 0.0, z: -30.0 }),
(NodeId(2), Position3D { x: 50.0, y: 86.6, z: -30.0 }),
];
c.bench_function("multiview_fusion_3drones", |b| b.iter(|| fusion.fuse(&detections, &positions)));
}
fn bench_demo_coverage_estimate(c: &mut Criterion) {
let scenario = DemoScenario::sar_rubble_field(4);
c.bench_function("demo_coverage_estimate", |b| b.iter(|| scenario.estimate_coverage_time_secs()));
}
fn bench_ppo_update(c: &mut Criterion) {
use ruview_swarm::marl::{MappoActor, ActorConfig, LocalObservation};
use ruview_swarm::marl::training_loop::{ReplayBuffer, Transition, PpoConfig, ppo_update};
use ruview_swarm::marl::actor::ActorAction;
let mut buf = ReplayBuffer::new(64);
for i in 0..64 {
buf.push(Transition {
obs: LocalObservation::zeros(),
action: ActorAction { delta_heading_rad: 0.1, delta_altitude_m: 0.0, speed_ms: 5.0, trigger_csi_scan: true },
reward: if i % 2 == 0 { 10.0 } else { -2.0 },
next_obs: LocalObservation::zeros(),
done: i == 63,
});
}
let cfg = PpoConfig::default();
c.bench_function("ppo_update_64transitions", |b| {
b.iter(|| {
let mut actor = MappoActor::random_init(ActorConfig::default());
ppo_update(&mut actor, &buf, &cfg)
})
});
}
criterion_group!(benches, bench_marl_inference, bench_rrt_apf_plan, bench_multiview_fusion, bench_demo_coverage_estimate, bench_ppo_update);
criterion_main!(benches);
+2
View File
@@ -0,0 +1,2 @@
# ADR-171 evaluation outputs
RESULTS.md is generated by the `eval_swarm` binary.
+26
View File
@@ -0,0 +1,26 @@
# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)
Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., NeurIPS 2021).
## Run configuration
- **Stage**: 1 (kinematic, self-contained, deterministic per seed)
- **Episodes per pattern**: 100 (seed × episode matrix)
- **CI method**: 95% stratified bootstrap of the IQM, stratified by seed
- **GDOP**: 2-D geometric dilution of precision at first detection
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-171 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
## Flight-pattern leaderboard
| Flight pattern | Coverage IQM [95% CI] | Localization (m) IQM [95% CI] | Detection rate | Mean GDOP |
|----------------|-----------------------|-------------------------------|----------------|-----------|
| partitioned_lawnmower | 1.000 [1.000, 1.000] | 7.022 [5.669, 8.379] | 100.0% | 0.000 |
| pheromone | 0.662 [0.652, 0.671] | 4.110 [3.346, 5.141] | 95.0% | 1.598 |
| levy_flight | 0.490 [0.489, 0.491] | 3.523 [2.897, 4.160] | 100.0% | 0.000 |
| boustrophedon | 0.370 [0.370, 0.370] | 2.740 [2.357, 3.207] | 100.0% | 0.000 |
| spiral | 0.336 [0.336, 0.336] | 3.082 [2.678, 3.568] | 100.0% | 0.000 |
| potential_field | 0.254 [0.252, 0.256] | 4.343 [3.489, 5.265] | 100.0% | 0.000 |
| _Wi2SAR (paper baseline)_ | _n/a_ | _5.0 (paper)_ | _n/a_ | _n/a_ |
_Wi2SAR row is the published single-drone localization figure (arxiv 2604.09115), shown paper-to-paper for reference only — it was not re-run through this kinematic harness._
@@ -0,0 +1,118 @@
//! Contract-net (auction) task allocation.
use crate::types::{DroneState, NodeId, SwarmTask, TaskId};
use std::collections::HashMap;
/// A bid submitted by a node for a task.
#[derive(Debug, Clone)]
pub struct Bid {
pub node_id: NodeId,
pub task_id: TaskId,
/// Lower score = more capable/willing. Computed by the bidding node.
pub score: f32,
}
/// Auction-based task allocator.
pub struct AuctionAllocator {
pub pending_tasks: HashMap<TaskId, SwarmTask>,
pub bids: HashMap<TaskId, Vec<Bid>>,
pub timeout_ms: u64,
}
impl AuctionAllocator {
pub fn new(timeout_ms: u64) -> Self {
Self {
pending_tasks: HashMap::new(),
bids: HashMap::new(),
timeout_ms,
}
}
/// Announce a new task (add to pending pool).
pub fn announce_task(&mut self, task: SwarmTask) {
let id = task.id;
self.pending_tasks.insert(id, task);
self.bids.entry(id).or_default();
}
/// Accept a bid for a pending task.
pub fn submit_bid(&mut self, bid: Bid) {
if self.pending_tasks.contains_key(&bid.task_id) {
self.bids.entry(bid.task_id).or_default().push(bid);
}
}
/// Resolve all pending tasks: assign each to the best bidder.
/// Returns a list of (TaskId, winning NodeId) pairs.
pub fn resolve(&mut self) -> Vec<(TaskId, NodeId)> {
let mut results = Vec::new();
let task_ids: Vec<TaskId> = self.pending_tasks.keys().copied().collect();
for task_id in task_ids {
let winner = self
.bids
.get(&task_id)
.and_then(|bids| {
bids.iter()
.min_by(|a, b| {
a.score.partial_cmp(&b.score).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|b| b.node_id)
});
if let Some(winner_id) = winner {
if let Some(task) = self.pending_tasks.get_mut(&task_id) {
task.assigned_to = Some(winner_id);
}
results.push((task_id, winner_id));
self.bids.remove(&task_id);
}
}
// Clean up resolved tasks
for (tid, _) in &results {
self.pending_tasks.remove(tid);
}
results
}
/// Compute a bid score heuristic for a node given a task.
/// Returns a score ∈ [0, ∞): lower is better.
pub fn compute_bid_score(node: &DroneState, task: &SwarmTask) -> f32 {
let dist = node.position.distance_to(&task.target) as f32;
let battery_penalty = (100.0 - node.battery_pct) / 100.0;
let link_penalty = 1.0 - node.link_quality;
let priority_bonus = 1.0 - task.priority.clamp(0.0, 1.0);
dist / 100.0 + battery_penalty * 0.3 + link_penalty * 0.2 + priority_bonus * 0.1
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Position3D, SwarmTask, TaskId, TaskKind};
fn make_task(id: u64) -> SwarmTask {
SwarmTask {
id: TaskId(id),
kind: TaskKind::ReturnToHome,
priority: 0.5,
target: Position3D::zero(),
deadline_ms: None,
assigned_to: None,
}
}
#[test]
fn test_auction_assigns_best_bidder() {
let mut alloc = AuctionAllocator::new(1000);
let task = make_task(1);
alloc.announce_task(task);
alloc.submit_bid(Bid { node_id: NodeId(1), task_id: TaskId(1), score: 0.8 });
alloc.submit_bid(Bid { node_id: NodeId(2), task_id: TaskId(1), score: 0.3 });
let results = alloc.resolve();
assert_eq!(results.len(), 1);
assert_eq!(results[0].1, NodeId(2)); // lower score wins
}
}
@@ -0,0 +1,97 @@
//! Lightweight 3-layer FNN bid scorer — pure Rust, no ONNX required.
/// 3-layer FNN: 5 inputs → 16 hidden (ReLU) → 8 hidden (ReLU) → 1 output (sigmoid).
pub struct FnnScorer {
pub w1: [[f32; 5]; 16],
pub b1: [f32; 16],
pub w2: [[f32; 16]; 8],
pub b2: [f32; 8],
pub w3: [f32; 8],
pub b3: f32,
}
fn relu(x: f32) -> f32 {
x.max(0.0)
}
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
impl FnnScorer {
/// Score a feature vector. Returns sigmoid(output) ∈ [0, 1].
/// Features: [dist_norm, battery_norm, link_quality, csi_confidence, workload_norm]
pub fn score(&self, features: [f32; 5]) -> f32 {
// Layer 1: 5 → 16 (ReLU)
let mut h1 = [0.0f32; 16];
for (i, row) in self.w1.iter().enumerate() {
let z: f32 = row.iter().zip(features.iter()).map(|(w, x)| w * x).sum();
h1[i] = relu(z + self.b1[i]);
}
// Layer 2: 16 → 8 (ReLU)
let mut h2 = [0.0f32; 8];
for (i, row) in self.w2.iter().enumerate() {
let z: f32 = row.iter().zip(h1.iter()).map(|(w, x)| w * x).sum();
h2[i] = relu(z + self.b2[i]);
}
// Layer 3: 8 → 1 (sigmoid)
let z3: f32 = self.w3.iter().zip(h2.iter()).map(|(w, x)| w * x).sum::<f32>() + self.b3;
sigmoid(z3)
}
/// Default weights initialised to a simple identity-like setup.
pub fn default_weights() -> Self {
// Simple: w1 diagonalish, others small constant
// Index needed: diagonal/strided init uses i for both row and column.
let mut w1 = [[0.0f32; 5]; 16];
#[allow(clippy::needless_range_loop)]
for i in 0..5 {
w1[i][i] = 1.0;
}
for row in w1.iter_mut().take(16).skip(5) {
row[0] = 0.1;
}
let mut w2 = [[0.0f32; 16]; 8];
#[allow(clippy::needless_range_loop)]
for i in 0..8 {
w2[i][i * 2] = 1.0;
}
let w3 = [0.125f32; 8];
Self {
w1,
b1: [0.0; 16],
w2,
b2: [0.0; 8],
w3,
b3: 0.0,
}
}
}
impl Default for FnnScorer {
fn default() -> Self {
Self::default_weights()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_score_in_unit_interval() {
let scorer = FnnScorer::default_weights();
let features = [0.3f32, 0.8, 0.9, 0.75, 0.2];
let s = scorer.score(features);
assert!(s >= 0.0 && s <= 1.0, "score {s} out of [0,1]");
}
#[test]
fn test_score_deterministic() {
let scorer = FnnScorer::default_weights();
let f = [0.5f32; 5];
assert_eq!(scorer.score(f), scorer.score(f));
}
}
@@ -0,0 +1,22 @@
//! Task allocation: auction-based and FNN-scored bid evaluation.
//!
// NOTE: Task allocation is ITAR-controlled (USML Category VIII(h)(12)).
// Only available when the `itar-unrestricted` feature is enabled.
#[cfg(feature = "itar-unrestricted")]
pub mod auction;
#[cfg(feature = "itar-unrestricted")]
pub mod fnn;
#[cfg(feature = "itar-unrestricted")]
pub use auction::{AuctionAllocator, Bid};
#[cfg(feature = "itar-unrestricted")]
pub use fnn::FnnScorer;
/// Stub: task allocation is export-controlled. Enable `itar-unrestricted` feature.
#[cfg(not(feature = "itar-unrestricted"))]
pub fn allocate_stub() -> crate::SwarmResult<()> {
Err(crate::SwarmError::Security(
"Task allocation requires itar-unrestricted feature (USML VIII(h)(12))".into(),
))
}
@@ -0,0 +1,45 @@
//! Benchmark support utilities: scenario builders and timing helpers for criterion benchmarks.
use crate::types::{DroneState, NodeId, Position3D, Velocity3D};
/// Generate N drone states arranged in a grid.
pub fn grid_drone_states(n: usize, spacing_m: f64) -> Vec<DroneState> {
let side = (n as f64).sqrt().ceil() as usize;
(0..n)
.map(|i| {
let row = i / side;
let col = i % side;
DroneState {
id: NodeId(i as u32),
position: Position3D {
x: col as f64 * spacing_m,
y: row as f64 * spacing_m,
z: -30.0,
},
velocity: Velocity3D::default(),
heading_rad: 0.0,
altitude_agl_m: 30.0,
battery_pct: 80.0,
link_quality: 0.9,
timestamp_ms: 0,
}
})
.collect()
}
/// Generate N evenly-spaced positions in a circle.
pub fn circle_positions(n: usize, radius_m: f64) -> Vec<(NodeId, Position3D)> {
(0..n)
.map(|i| {
let angle = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
(
NodeId(i as u32),
Position3D {
x: radius_m * angle.cos(),
y: radius_m * angle.sin(),
z: -30.0,
},
)
})
.collect()
}
@@ -0,0 +1,104 @@
//! ADR-171 Stage-1 evaluation CLI.
//!
//! Runs the kinematic eval matrix over every flight pattern (default) and
//! writes a ranked `RESULTS.md` leaderboard. Pure Rust — no special feature
//! flag required, so it builds and runs in default CI.
//!
//! Defaults are intentionally small (10 seeds × 10 episodes) so the run is fast.
//! The full ADR-171 reporting configuration is 10 seeds × 50 episodes — pass
//! `--seeds 10 --episodes 50` for the publication run.
//!
//! ```text
//! cargo run -p ruview-swarm --bin eval_swarm -- \
//! --seeds 10 --episodes 10 --out crates/ruview-swarm/evals/RESULTS.md
//! ```
use std::path::PathBuf;
use ruview_swarm::evals::metrics::AggregateMetrics;
use ruview_swarm::evals::report::render_results_md;
use ruview_swarm::evals::runner::{run_matrix, EvalConfig};
use ruview_swarm::planning::patterns::FlightPattern;
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut seeds = 10usize;
let mut episodes = 10usize;
let mut out = PathBuf::from("crates/ruview-swarm/evals/RESULTS.md");
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--seeds" => {
i += 1;
seeds = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(seeds);
}
"--episodes" => {
i += 1;
episodes = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(episodes);
}
"--out" => {
i += 1;
if let Some(p) = args.get(i) {
out = PathBuf::from(p);
}
}
"--help" | "-h" => {
eprintln!(
"eval_swarm — ADR-171 Stage-1 kinematic evaluator\n\
Usage: eval_swarm [--seeds N] [--episodes M] [--out PATH]\n\
Defaults: --seeds 10 --episodes 10 --out crates/ruview-swarm/evals/RESULTS.md"
);
return;
}
other => {
eprintln!("warning: ignoring unknown argument '{other}'");
}
}
i += 1;
}
eprintln!(
"Running ADR-171 Stage-1 eval: {seeds} seeds × {episodes} episodes \
over {} flight patterns...",
FlightPattern::all().len()
);
let mut rows: Vec<(String, AggregateMetrics)> = Vec::new();
for (idx, pattern) in FlightPattern::all().into_iter().enumerate() {
let mut cfg = EvalConfig::sar_small(pattern);
cfg.seeds = seeds;
cfg.episodes_per_seed = episodes;
let matrix = run_matrix(&cfg);
let agg = AggregateMetrics::from_strata(&matrix, 0x0149 ^ idx as u64);
eprintln!(
" {}: coverage IQM {:.3}, detection {:.0}%",
pattern.name(),
agg.coverage_iqm.point,
agg.detection_rate * 100.0
);
rows.push((pattern.name().to_string(), agg));
}
// Rank by descending coverage point estimate.
rows.sort_by(|a, b| {
b.1.coverage_iqm
.point
.partial_cmp(&a.1.coverage_iqm.point)
.unwrap_or(std::cmp::Ordering::Equal)
});
let md = render_results_md(&rows);
if let Some(parent) = out.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("error: could not create {}: {e}", parent.display());
std::process::exit(1);
}
}
if let Err(e) = std::fs::write(&out, &md) {
eprintln!("error: could not write {}: {e}", out.display());
std::process::exit(1);
}
eprintln!("Wrote {} ({} bytes).", out.display(), md.len());
}
@@ -0,0 +1,474 @@
//! MARL training entry point for ruview-swarm (ADR-148 M4).
//!
//! Real Candle autodiff PPO training loop. Runs on CPU, or CUDA when built
//! with `--features train,cuda` (local RTX 5080 or a GCP L4 instance).
//!
//! Movement is driven by a selectable `FlightPattern` (boustrophedon,
//! partitioned, spiral, pheromone, potential, levy) and reward is shaped by a
//! selectable `LearningPattern` (mappo, ippo, curiosity, meta). This makes each
//! pattern produce visibly distinct trajectories + telemetry instead of every
//! drone clustering on the orchestrator's internal coverage strategy.
//!
//! Usage:
//! cargo run --release -p ruview-swarm --features train,cuda --bin train_marl -- \
//! --episodes 5000 --drones 4 --profile sar \
//! --flight-pattern partitioned --learn-pattern mappo_curiosity \
//! --checkpoint-dir ./marl-checkpoints
//!
//! Right-sizing note: the policy is a 64→128→64 MLP. The bottleneck is
//! environment-rollout throughput, not GPU matmul — an L4 + 16 vCPU beats an
//! 8× A100 box for this workload at ~1/20th the cost. See scripts/gcp/.
use std::collections::HashSet;
use ruview_swarm::config::SwarmConfig;
use ruview_swarm::integration::telemetry::{DroneFrame, TelemetryRecorder};
use ruview_swarm::marl::candle_ppo::{CandlePpoConfig, CandleTrainer};
use ruview_swarm::marl::learning::{shaped_reward, CuriosityModule, LearningPattern};
use ruview_swarm::marl::observation::LocalObservation;
use ruview_swarm::marl::reward::{RewardCalculator, RewardContext};
use ruview_swarm::planning::patterns::{FlightPattern, PatternContext};
use ruview_swarm::types::{DroneState, NodeId, Position3D, Velocity3D};
struct Args {
episodes: usize,
drones: usize,
profile: String,
steps_per_episode: usize,
checkpoint_dir: String,
checkpoint_every: usize,
telemetry: Option<String>,
telemetry_episode: usize,
flight_pattern: String,
learn_pattern: String,
}
impl Default for Args {
fn default() -> Self {
Self {
episodes: 1000,
drones: 4,
profile: "sar".to_string(),
steps_per_episode: 200,
checkpoint_dir: "./marl-checkpoints".to_string(),
checkpoint_every: 100,
telemetry: None,
telemetry_episode: 0,
flight_pattern: "partitioned".to_string(),
learn_pattern: "mappo".to_string(),
}
}
}
fn parse_args() -> Args {
let mut args = Args::default();
let argv: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < argv.len() {
let next = || argv.get(i + 1).cloned().unwrap_or_default();
match argv[i].as_str() {
"--episodes" => {
args.episodes = next().parse().unwrap_or(args.episodes);
i += 1;
}
"--drones" => {
args.drones = next().parse().unwrap_or(args.drones);
i += 1;
}
"--profile" => {
args.profile = next();
i += 1;
}
"--steps" => {
args.steps_per_episode = next().parse().unwrap_or(args.steps_per_episode);
i += 1;
}
"--checkpoint-dir" => {
args.checkpoint_dir = next();
i += 1;
}
"--checkpoint-every" => {
args.checkpoint_every = next().parse().unwrap_or(args.checkpoint_every);
i += 1;
}
"--telemetry" => {
args.telemetry = Some(next());
i += 1;
}
"--telemetry-episode" => {
args.telemetry_episode = next().parse().unwrap_or(args.telemetry_episode);
i += 1;
}
"--flight-pattern" => {
args.flight_pattern = next();
i += 1;
}
"--learn-pattern" => {
args.learn_pattern = next();
i += 1;
}
"-h" | "--help" => {
println!(
"train_marl — ruview-swarm MARL training (ADR-148 M4)\n\
\nOptions:\n \
--episodes N training episodes (default 1000)\n \
--drones N swarm size (default 4)\n \
--profile NAME sar|inspection|mine|agriculture (default sar)\n \
--steps N steps per episode (default 200)\n \
--flight-pattern P boustrophedon|partitioned|spiral|pheromone|potential|levy (default partitioned)\n \
--learn-pattern P mappo|ippo|curiosity|meta (default mappo)\n \
--checkpoint-dir D checkpoint output dir (default ./marl-checkpoints)\n \
--checkpoint-every N save every N episodes (default 100)\n \
--telemetry FILE write JSONL telemetry for viz/swarm_viz.html\n \
--telemetry-episode N which episode's steps to record spatially (default 0)"
);
std::process::exit(0);
}
other => eprintln!("warning: ignoring unknown arg {other}"),
}
i += 1;
}
args
}
fn config_for(profile: &str) -> SwarmConfig {
match profile {
"inspection" => SwarmConfig::inspection_default(),
"mine" => SwarmConfig::mine_default(),
"agriculture" => SwarmConfig::agriculture_default(),
_ => SwarmConfig::wi2sar_reference(),
}
}
/// Map a world coordinate to a grid cell index at `grid_res` metre resolution.
fn cell_of(x: f64, y: f64, grid_res: f64) -> (u32, u32) {
let gx = (x / grid_res).floor().max(0.0) as u32;
let gy = (y / grid_res).floor().max(0.0) as u32;
(gx, gy)
}
/// Mark every grid cell within the drone's circular scan footprint as scanned,
/// returning how many *newly* scanned cells this step contributed.
fn mark_scanned(
scanned: &mut HashSet<(u32, u32)>,
pos: &Position3D,
scan_width_m: f64,
grid_res: f64,
area_w: f64,
area_h: f64,
) -> u32 {
let r = scan_width_m * 0.5;
let cols = (area_w / grid_res).ceil() as i64;
let rows = (area_h / grid_res).ceil() as i64;
let (cx, cy) = cell_of(pos.x, pos.y, grid_res);
let span = (r / grid_res).ceil() as i64;
let mut new_cells = 0u32;
for dgx in -span..=span {
for dgy in -span..=span {
let gx = cx as i64 + dgx;
let gy = cy as i64 + dgy;
if gx < 0 || gy < 0 || gx >= cols || gy >= rows {
continue;
}
// Cell centre in metres.
let mx = (gx as f64 + 0.5) * grid_res;
let my = (gy as f64 + 0.5) * grid_res;
if (mx - pos.x).hypot(my - pos.y) <= r && scanned.insert((gx as u32, gy as u32)) {
new_cells += 1;
}
}
}
new_cells
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = parse_args();
let cfg = config_for(&args.profile);
let flight_pattern = FlightPattern::from_str(&args.flight_pattern);
let learn_pattern = LearningPattern::from_str(&args.learn_pattern);
println!(
"MARL training: profile={} drones={} episodes={} steps/ep={} flight={} learn={} ({})",
args.profile,
args.drones,
args.episodes,
args.steps_per_episode,
flight_pattern.name(),
learn_pattern.name(),
if learn_pattern.centralized_critic() {
"CTDE / centralized critic"
} else {
"independent learners"
}
);
let ppo_cfg = CandlePpoConfig::default();
let mut trainer = CandleTrainer::new(ppo_cfg)?;
println!("device: {:?}", trainer.net.device());
let reward_calc = RewardCalculator::default();
std::fs::create_dir_all(&args.checkpoint_dir).ok();
let area_w = cfg.mission.area_width_m;
let area_h = cfg.mission.area_height_m;
let grid_res = cfg.mission.grid_resolution_m.max(1.0);
let scan_w = cfg.planning.csi_scan_width_m;
let max_speed = cfg.planning.max_speed_ms.max(0.1);
let altitude_z = -cfg.planning.flight_altitude_m;
let total_cells = ((area_w / grid_res).ceil() * (area_h / grid_res).ceil()).max(1.0);
// Synthetic victims placed within the mission area for reward signal.
let victims = vec![
Position3D { x: area_w * 0.2, y: area_h * 0.3, z: 0.0 },
Position3D { x: area_w * 0.6, y: area_h * 0.45, z: 0.0 },
];
// Composite profile label so the viewer header surfaces the active patterns.
let profile_label = format!(
"{} · flight={} · learn={}",
args.profile,
flight_pattern.name(),
learn_pattern.name()
);
// Optional telemetry recorder for the visualizer.
let mut telem = match &args.telemetry {
Some(path) => {
let mut rec = TelemetryRecorder::create(path)?;
rec.meta(&profile_label, args.drones, area_w, area_h, &victims)?;
println!("telemetry → {path} (spatial steps from episode {})", args.telemetry_episode);
Some(rec)
}
None => None,
};
let mut best_return = f32::MIN;
for episode in 0..args.episodes {
// Per-episode curiosity module (count-based novelty over the area).
let mut curiosity = CuriosityModule::new(area_w, area_h, 32, 0.5);
// Build drone states directly so the FlightPattern fully drives motion.
let cols = (args.drones as f64).sqrt().ceil().max(1.0) as usize;
let mut states: Vec<DroneState> = (0..args.drones)
.map(|d| {
let (row, col) = (d / cols, d % cols);
let mut s = DroneState::default_at_origin(NodeId(d as u32));
s.position = Position3D {
x: 10.0 + col as f64 * (area_w / cols as f64),
y: 10.0 + row as f64 * (area_h / cols.max(1) as f64),
z: altitude_z,
};
s.altitude_agl_m = cfg.planning.flight_altitude_m;
s
})
.collect();
// Coverage tracker (shared across drones — total area scanned).
let mut scanned: HashSet<(u32, u32)> = HashSet::new();
// Rolling recent-positions trail for pheromone/potential patterns.
let mut visited: Vec<Position3D> = Vec::with_capacity(256);
// Rollout buffers (flattened across drones).
let mut obs_buf: Vec<LocalObservation> = Vec::new();
let mut action_buf: Vec<[f32; 4]> = Vec::new();
let mut reward_buf: Vec<f32> = Vec::new();
let mut value_buf: Vec<f32> = Vec::new();
let mut done_buf: Vec<bool> = Vec::new();
for step in 0..args.steps_per_episode {
let is_last = step == args.steps_per_episode - 1;
// Snapshot peer positions for this tick (observations + repulsion).
let positions: Vec<(NodeId, Position3D)> =
states.iter().map(|s| (s.id, s.position)).collect();
// Index needed: mutates states[idx] while reading peer positions; borrow constraints.
#[allow(clippy::needless_range_loop)]
for idx in 0..states.len() {
let prev_pos = states[idx].position;
let node_id = states[idx].id;
// Neighbour positions (everyone except this drone).
let neighbors: Vec<(NodeId, Position3D)> = positions
.iter()
.filter(|(id, _)| *id != node_id)
.cloned()
.collect();
let peers: Vec<Position3D> = neighbors.iter().map(|(_, p)| *p).collect();
// Observation from the current (pre-move) state.
let obs =
LocalObservation::from_state_no_grid(&states[idx], &neighbors, None, None);
// --- FlightPattern drives the next waypoint --------------------
let ctx = PatternContext {
drone_id: node_id,
swarm_size: args.drones,
current: prev_pos,
area_w,
area_h,
altitude_z,
scan_width_m: scan_w,
step: step as u64,
visited: &visited,
peers: &peers,
};
let target = flight_pattern.next_target(&ctx);
// Move one tick toward the target at max_speed (no teleport).
let dx = target.x - prev_pos.x;
let dy = target.y - prev_pos.y;
let dist = dx.hypot(dy);
let new_pos = if dist > 1e-9 {
let stepd = dist.min(max_speed);
Position3D {
x: prev_pos.x + dx / dist * stepd,
y: prev_pos.y + dy / dist * stepd,
z: altitude_z,
}
} else {
prev_pos
};
let heading = if dist > 1e-9 { dy.atan2(dx) } else { states[idx].heading_rad };
let moved = prev_pos.distance_to(&new_pos);
// Commit the move to the drone state.
{
let s = &mut states[idx];
s.velocity = Velocity3D {
vx: (new_pos.x - prev_pos.x),
vy: (new_pos.y - prev_pos.y),
vz: 0.0,
};
s.position = new_pos;
s.heading_rad = heading;
s.timestamp_ms = s.timestamp_ms.saturating_add(1000);
}
// Coverage: mark scanned footprint, count new cells.
let new_cells =
mark_scanned(&mut scanned, &new_pos, scan_w, grid_res, area_w, area_h);
// Detection: any victim within the scan footprint.
let detected = victims.iter().any(|v| new_pos.distance_to(v) < scan_w);
// Nearest-neighbour distance (for collision shaping).
let nearest = peers
.iter()
.map(|p| new_pos.distance_to(p))
.fold(f64::MAX, f64::min);
// Base extrinsic reward.
let ctx_r = RewardContext {
state: &states[idx],
new_cells_covered: new_cells,
victim_confirmed: detected,
contributed_to_triangulation: false,
nearest_neighbor_dist: nearest,
geofence_breached: false,
battery_depleted_without_rth: false,
};
let base = reward_calc.compute(&ctx_r);
// Curiosity shaping (only when the learning pattern uses it).
let reward = if learn_pattern.uses_curiosity() {
let bonus = curiosity.visit_bonus(new_pos.x, new_pos.y);
shaped_reward(learn_pattern, base, bonus)
} else {
base
};
let action = [
heading as f32,
states[idx].altitude_agl_m as f32,
(moved / 1.0) as f32,
0.0,
];
obs_buf.push(obs);
action_buf.push(action);
reward_buf.push(reward);
value_buf.push(0.0); // bootstrap value (critic learns this)
done_buf.push(is_last);
// Record the move in the shared visited trail (cap length).
visited.push(new_pos);
}
// Trim the visited trail to the most recent ~200 positions.
if visited.len() > 200 {
let drop = visited.len() - 200;
visited.drain(0..drop);
}
// Record spatial telemetry for the selected episode only.
if let Some(rec) = telem.as_mut() {
if episode == args.telemetry_episode {
let frames: Vec<DroneFrame> = states
.iter()
.map(|s| {
let detected =
victims.iter().any(|v| s.position.distance_to(v) < scan_w);
DroneFrame::from_state(s, detected)
})
.collect();
let coverage = scanned.len() as f64 / total_cells;
let _ = rec.step(episode, step, step as f64, &frames, coverage);
}
}
}
// PPO update on the episode's rollout.
let (advantages, returns) = trainer.compute_gae(&reward_buf, &value_buf, &done_buf);
let old_log_probs = vec![0.0f32; obs_buf.len()];
let (policy_loss, value_loss, _entropy) =
trainer.update(&obs_buf, &action_buf, &advantages, &returns, &old_log_probs)?;
let mean_return = if returns.is_empty() {
0.0
} else {
returns.iter().sum::<f32>() / returns.len() as f32
};
if mean_return > best_return {
best_return = mean_return;
}
// Per-episode training-metric telemetry (every episode).
if let Some(rec) = telem.as_mut() {
let _ = rec.episode(episode, mean_return, policy_loss, value_loss, 0);
}
if episode % 10 == 0 || episode == args.episodes - 1 {
let coverage_pct = scanned.len() as f64 / total_cells * 100.0;
println!(
"ep {:>5}/{} mean_return={:>8.3} best={:>8.3} policy_loss={:>8.4} value_loss={:>8.4} coverage={:>5.1}%",
episode, args.episodes, mean_return, best_return, policy_loss, value_loss, coverage_pct
);
}
// Checkpoint the trained variables periodically.
if args.checkpoint_every > 0 && (episode + 1) % args.checkpoint_every == 0
|| episode == args.episodes - 1
{
let path = format!("{}/marl-ep{}.safetensors", args.checkpoint_dir, episode + 1);
if let Err(e) = trainer.net.varmap().save(&path) {
eprintln!("checkpoint save failed at {path}: {e}");
} else {
println!("checkpoint saved: {path}");
}
}
}
if let Some(rec) = telem.as_mut() {
rec.flush()?;
if let Some(path) = &args.telemetry {
println!("telemetry written: {path} — open viz/swarm_viz.html and load it");
}
}
println!("training complete. best mean_return={best_return:.3}");
Ok(())
}
+207
View File
@@ -0,0 +1,207 @@
//! TOML-based swarm configuration with mission profiles.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmConfig {
pub swarm: SwarmParams,
pub formation: FormationConfig,
pub planning: PlanningConfig,
pub security: SecurityConfig,
pub mission: MissionConfig,
pub demo: Option<DemoConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmParams {
pub max_agents: usize,
pub cluster_size: usize,
pub raft_election_timeout_ms: u64,
pub raft_heartbeat_ms: u64,
pub gossip_fanout: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormationConfig {
/// "virtual_structure" | "leader_follower" | "reynolds"
pub mode: String,
pub min_separation_m: f64,
pub grid_spacing_m: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanningConfig {
pub flight_altitude_m: f64,
pub max_speed_ms: f64,
/// Wi2SAR validated scan footprint width.
pub csi_scan_width_m: f64,
pub lateral_overlap_pct: f64,
/// P(victim) threshold to trigger Phase 3 convergence.
pub convergence_threshold: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
pub mavlink_signing: bool,
pub uwb_antispoofing: bool,
pub uwb_tolerance_m: f64,
pub geofence_hard_margin_m: f64,
pub geofence_soft_margin_m: f64,
/// Remote ID broadcast rate in Hz (FAA/EU requirement: ≥ 1 Hz).
pub remote_id_broadcast_hz: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionConfig {
/// "sar" | "inspection" | "agriculture" | "mine" | "relay"
pub profile: String,
pub area_width_m: f64,
pub area_height_m: f64,
pub grid_resolution_m: f64,
pub max_flight_time_mins: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemoConfig {
pub synthetic_csi: bool,
/// Victim positions in NED [x, y, z].
pub victim_positions: Vec<[f64; 3]>,
pub wind_noise_ms: f64,
pub csi_noise_std: f64,
pub packet_loss_pct: f64,
pub replay_speed: f64,
}
impl SwarmConfig {
pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
pub fn sar_default() -> Self {
Self {
swarm: SwarmParams {
max_agents: 12,
cluster_size: 4,
raft_election_timeout_ms: 300,
raft_heartbeat_ms: 100,
gossip_fanout: 3,
},
formation: FormationConfig {
mode: "virtual_structure".into(),
min_separation_m: 5.0,
grid_spacing_m: 20.0,
},
planning: PlanningConfig {
flight_altitude_m: 30.0,
max_speed_ms: 8.0,
csi_scan_width_m: 28.0,
lateral_overlap_pct: 20.0,
convergence_threshold: 0.75,
},
security: SecurityConfig {
mavlink_signing: true,
uwb_antispoofing: true,
uwb_tolerance_m: 2.0,
geofence_hard_margin_m: 20.0,
geofence_soft_margin_m: 50.0,
remote_id_broadcast_hz: 1.0,
},
mission: MissionConfig {
profile: "sar".into(),
area_width_m: 500.0,
area_height_m: 500.0,
grid_resolution_m: 5.0,
max_flight_time_mins: 25.0,
},
demo: None,
}
}
pub fn inspection_default() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.profile = "inspection".into();
cfg.planning.flight_altitude_m = 15.0;
cfg.planning.max_speed_ms = 4.0;
cfg.formation.mode = "leader_follower".into();
cfg
}
pub fn agriculture_default() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.profile = "agriculture".into();
cfg.planning.flight_altitude_m = 10.0;
cfg.planning.max_speed_ms = 6.0;
cfg.planning.csi_scan_width_m = 15.0;
cfg.formation.mode = "virtual_structure".into();
cfg.formation.grid_spacing_m = 12.0;
cfg
}
pub fn mine_default() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.profile = "mine".into();
cfg.planning.flight_altitude_m = 5.0;
cfg.planning.max_speed_ms = 2.0;
cfg.security.uwb_antispoofing = true; // GPS-denied: UWB only
cfg
}
/// Wi2SAR reference configuration (400×400 m, 8 m/s, 4 drones) for ADR-148 SOTA benchmark.
/// Produces 223 s coverage estimate — below the 240 s (4-min) SOTA target.
/// Source: Wi2SAR (arxiv 2604.09115): single drone, 160,000 m², 13.5 min.
pub fn wi2sar_reference() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.area_width_m = 400.0;
cfg.mission.area_height_m = 400.0;
cfg.planning.max_speed_ms = 8.0;
cfg.planning.csi_scan_width_m = 28.0;
cfg.planning.lateral_overlap_pct = 20.0;
cfg
}
pub fn demo_default() -> Self {
let mut cfg = Self::sar_default();
cfg.demo = Some(DemoConfig {
synthetic_csi: true,
victim_positions: vec![[50.0, 80.0, 0.0], [150.0, 200.0, 0.0], [300.0, 100.0, 0.0]],
wind_noise_ms: 2.0,
csi_noise_std: 0.05,
packet_loss_pct: 5.0,
replay_speed: 1.0,
});
cfg
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sar_default_serialization() {
let cfg = SwarmConfig::sar_default();
let toml_str = toml::to_string(&cfg).expect("serialize ok");
let parsed = SwarmConfig::from_toml_str(&toml_str).expect("parse ok");
assert_eq!(parsed.mission.profile, "sar");
}
#[test]
fn test_demo_default_has_victims() {
let cfg = SwarmConfig::demo_default();
assert!(cfg.demo.is_some());
assert_eq!(cfg.demo.unwrap().victim_positions.len(), 3);
}
#[test]
fn test_wi2sar_reference_coverage_within_4min() {
use crate::demo::scenario::DemoScenario;
let scenario = DemoScenario {
name: "Wi2SAR Reference".into(),
config: SwarmConfig::wi2sar_reference(),
num_drones: 4,
victims: vec![],
};
let t = scenario.estimate_coverage_time_secs();
assert!(t < 240.0, "4-drone Wi2SAR reference scenario: {}s should be < 240s (4 min SOTA)", t);
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Demo scenario runner — synthetic CSI with configurable victim positions.
//!
//! Wires together a [`SyntheticCsiGenerator`] and pre-built [`DemoScenario`]
//! definitions for rapid scenario validation without real hardware.
pub mod synthetic_csi;
pub mod scenario;
pub use synthetic_csi::SyntheticCsiGenerator;
pub use scenario::{DemoScenario, ScenarioResult};

Some files were not shown because too many files have changed in this diff Show More