mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-20 05:40:14 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b15c0ebc9 | ||
|
|
682a6f4538 | ||
|
|
a72174c571 | ||
|
|
fd323e483e | ||
|
|
9f0821cf69 | ||
|
|
69fc9efff7 | ||
|
|
20386cf313 | ||
|
|
c4fd6d9b36 | ||
|
|
e223d86ba2 | ||
|
|
52e5ebefbd | ||
|
|
ca44cd1a7b | ||
|
|
5fe0a6a48d | ||
|
|
da1ef6a17d | ||
|
|
b6c2fb5b7d | ||
|
|
b8b70b7df7 | ||
|
|
bb5d903724 | ||
|
|
1e2348abef | ||
|
|
2493185ab8 | ||
|
|
678f63f4f3 | ||
|
|
0aba0fc467 | ||
|
|
6f550c39ed | ||
|
|
e9d0cef610 | ||
|
|
bb22f1b185 | ||
|
|
7bc5fe1a53 | ||
|
|
51e1788c7e | ||
|
|
7b62297f3e | ||
|
|
890e521a3a | ||
|
|
444184f9ef | ||
|
|
7955af5bc0 | ||
|
|
273a2a8536 | ||
|
|
add0755958 | ||
|
|
de5f7edd16 | ||
|
|
69e0729c61 | ||
|
|
9baaf8434f | ||
|
|
27eefb6a7f | ||
|
|
c6f7e8ca0d | ||
|
|
474f1a904e | ||
|
|
ddaa54eba0 | ||
|
|
b3d31510ee | ||
|
|
bce512cb8d | ||
|
|
9950ecc340 | ||
|
|
e0c4207bd5 | ||
|
|
044a41d2de | ||
|
|
8d243a67f4 | ||
|
|
ee6717f035 | ||
|
|
f5a36666fc | ||
|
|
d78a221607 | ||
|
|
db81dce7ba | ||
|
|
3a8ec8139e | ||
|
|
98c1d4a1e9 | ||
|
|
c00deadaf7 | ||
|
|
7c8aeacea2 | ||
|
|
c3764a429f | ||
|
|
985e5d9449 | ||
|
|
b033fd309e | ||
|
|
76af08a9b0 | ||
|
|
951ffcc254 | ||
|
|
cbd84acf6c | ||
|
|
a1d7eb0bfd | ||
|
|
3741e7ba43 | ||
|
|
fabb0c8233 | ||
|
|
da2304985a | ||
|
|
8c6d29bf83 | ||
|
|
5cffe9e7db | ||
|
|
c51a1cf888 | ||
|
|
522374584c | ||
|
|
5755d9f11f | ||
|
|
3bf993275e | ||
|
|
05ea4feaed | ||
|
|
7af5477f04 | ||
|
|
84f5e19e9d | ||
|
|
1a50162598 | ||
|
|
0eb952a9c5 | ||
|
|
08f0f8859b | ||
|
|
8c63d7b558 | ||
|
|
e351d0fce1 | ||
|
|
ea6da967d2 | ||
|
|
f44f25d596 | ||
|
|
8ecac18978 | ||
|
|
e5f88b88fe | ||
|
|
ebb303cbb8 | ||
|
|
c80f618119 | ||
|
|
3b542f8a85 | ||
|
|
ef01c9e2be | ||
|
|
abeca0f0ed | ||
|
|
5a36be17d2 | ||
|
|
559528155f | ||
|
|
f161abf582 | ||
|
|
a4f80e63a2 | ||
|
|
43f5f99108 | ||
|
|
f8ab204711 | ||
|
|
0df5069831 | ||
|
|
f6a3a3579f | ||
|
|
d25fb8192e | ||
|
|
03a897938f | ||
|
|
15d48d4481 | ||
|
|
c764c8916b | ||
|
|
5efc7e2c2c | ||
|
|
39b4ae9ae1 | ||
|
|
4a472926e1 | ||
|
|
c4f9d95e11 | ||
|
|
bf21c8c7a9 | ||
|
|
629e1b9c6c | ||
|
|
78a1474057 | ||
|
|
751b7ae17b | ||
|
|
c4d7ef8086 | ||
|
|
bd7c506076 | ||
|
|
8e12606c6a | ||
|
|
8b857e41cd | ||
|
|
093e3bbeae | ||
|
|
4128661789 | ||
|
|
b6f3abd35e | ||
|
|
45796e4e3e | ||
|
|
3cd360f9e2 | ||
|
|
d31d9daba2 | ||
|
|
b25729acd6 | ||
|
|
8d2be98ed7 | ||
|
|
43f16e5638 | ||
|
|
6112a185df | ||
|
|
888222277e | ||
|
|
1938f0251f | ||
|
|
b90b592d4f | ||
|
|
e1fbbede61 | ||
|
|
aac4155584 | ||
|
|
5ef001b4fe | ||
|
|
6524712900 | ||
|
|
dd02efe2fe | ||
|
|
7d15e9fb4a | ||
|
|
69106970dd | ||
|
|
686e4bad00 | ||
|
|
b713a23cac | ||
|
|
0c1efda425 | ||
|
|
b1be91478c | ||
|
|
6eb79cad26 | ||
|
|
45fc41ac4c | ||
|
|
901cf16eb3 | ||
|
|
af2d6981a3 | ||
|
|
a04fbc4694 | ||
|
|
dbaa3c7f52 | ||
|
|
6cf0356554 | ||
|
|
b2ad3a65dc | ||
|
|
815384dcc4 | ||
|
|
8cd8742d2b | ||
|
|
dbf4788628 | ||
|
|
cdad032296 | ||
|
|
0decc5c3fa | ||
|
|
d4a3c7f17f | ||
|
|
2c0abcc802 | ||
|
|
f1dbaac452 | ||
|
|
33a9e90896 | ||
|
|
bbbb14169d | ||
|
|
4fbe58f736 | ||
|
|
a64e9e98fb | ||
|
|
130afabae8 | ||
|
|
36e4df95b7 | ||
|
|
b370d4fcd4 | ||
|
|
500cfe4787 | ||
|
|
f7c2c6587e | ||
|
|
30391bfbd2 | ||
|
|
00a42d7cda | ||
|
|
bd6f38e2df | ||
|
|
203bd66daa | ||
|
|
2c3a924231 | ||
|
|
e5422d52e4 | ||
|
|
26e8ca3f39 | ||
|
|
c407e738a3 | ||
|
|
755e9bca05 | ||
|
|
73e15a7d29 | ||
|
|
f38beff441 | ||
|
|
830abd7e18 | ||
|
|
b296ea26a4 | ||
|
|
0b29902d8b | ||
|
|
b18b34e9f5 | ||
|
|
169597a7d2 | ||
|
|
0d1a247bfe | ||
|
|
a4d49f76b9 | ||
|
|
51102b3baf | ||
|
|
977fa26731 | ||
|
|
2841b22cc9 | ||
|
|
3fd3851633 | ||
|
|
d6b940184e | ||
|
|
60adac05b7 | ||
|
|
ec9da648b8 | ||
|
|
0fd1c63563 | ||
|
|
521b29ea21 | ||
|
|
e8e4e5cf67 | ||
|
|
9af1cc6644 | ||
|
|
56fcc49104 | ||
|
|
11c014485e | ||
|
|
86e45980fe | ||
|
|
d613a576ea | ||
|
|
97578ee401 | ||
|
|
e54b4171ca | ||
|
|
338c523217 | ||
|
|
224c9eb671 | ||
|
|
2deb6d2d64 | ||
|
|
82e05d0664 | ||
|
|
a2c662f9f7 | ||
|
|
48c6244d37 | ||
|
|
30404dd9cf | ||
|
|
c4021f9884 | ||
|
|
2227b42841 | ||
|
|
767c36ad12 | ||
|
|
2e9e87c65c | ||
|
|
8d1a67d31b | ||
|
|
c19b846394 | ||
|
|
cf6e955196 | ||
|
|
dddc214a25 | ||
|
|
5da64c6fc5 | ||
|
|
1c088e67c2 | ||
|
|
1231088427 | ||
|
|
4d857a908f | ||
|
|
d6347fe12e | ||
|
|
9b595b8333 | ||
|
|
944b7bea77 | ||
|
|
7f9df37380 | ||
|
|
f574450516 | ||
|
|
3621a211bd | ||
|
|
5eb8ddde12 | ||
|
|
fbecae4e02 | ||
|
|
de099c1f2d | ||
|
|
01460b98b0 | ||
|
|
26f444138e | ||
|
|
00737441c4 | ||
|
|
25d7be3c25 | ||
|
|
29b3968731 | ||
|
|
ed006abd58 | ||
|
|
5419bff2a8 | ||
|
|
7cdf5714e8 | ||
|
|
f1ea275d5d | ||
|
|
5c50fa8588 | ||
|
|
f19938a7b6 | ||
|
|
db5a898372 | ||
|
|
b11422459e | ||
|
|
e5bf0d4892 | ||
|
|
e04f269f1a | ||
|
|
12a61c16e8 | ||
|
|
a70803fe31 | ||
|
|
4b295b1b4d | ||
|
|
615e2d419b | ||
|
|
f85896cccb | ||
|
|
0a0b3411f8 | ||
|
|
08210b02c9 | ||
|
|
27f5540663 | ||
|
|
b742eae7d6 | ||
|
|
d42c5581f3 | ||
|
|
0df48df7b2 | ||
|
|
bd110e0eac | ||
|
|
f3c361efd1 | ||
|
|
a3b6e1d500 |
No files matched your search
@@ -10,6 +10,11 @@
|
||||
"name": "ruview",
|
||||
"source": "./plugins/ruview",
|
||||
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
|
||||
},
|
||||
{
|
||||
"name": "ruview-live",
|
||||
"source": "./harness/ruview/mod",
|
||||
"description": "Claude Code mod: /ruview opens a live pane with your CSI nodes (ESP32, Realtek) and ESPHome radar (presence, distance, device-reported vitals), refreshed on a timer, plus a one-line status. Read-only; ships inside @ruvnet/ruview. Mods are early access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -135,6 +135,10 @@ async function runProcess(command, args, {
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
// A child that never reads stdin (e.g. `git check-ignore`) may exit and
|
||||
// close the pipe before this write flushes; the resulting EPIPE is not a
|
||||
// failure of the command, whose real outcome the 'close' handler reports.
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: BFI Research Tools
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'tools/bfi/**'
|
||||
- 'tools/bfi_learning/**'
|
||||
- '.github/workflows/bfi-research.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/bfi/**'
|
||||
- 'tools/bfi_learning/**'
|
||||
- '.github/workflows/bfi-research.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: bfi-research-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
offline-tests:
|
||||
name: BFI offline tests (Linux, CPU)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Install numeric test dependencies
|
||||
run: python -m pip install numpy==2.4.2 scipy==1.17.1
|
||||
- name: Install CPU PyTorch
|
||||
run: python -m pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu
|
||||
- name: Test capture parsing and bounded runners
|
||||
run: python -B -m unittest discover -s tools/bfi -v
|
||||
- name: Test offline learning and data admission
|
||||
run: python -B -m unittest discover -s tools/bfi_learning -v
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
node-version: '22'
|
||||
|
||||
- name: Run UI unit tests
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
|
||||
|
||||
# Unit and Integration Tests
|
||||
# Python pytest matrix — runs against the archived v1 Python tree.
|
||||
|
||||
@@ -53,7 +53,9 @@ jobs:
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
# Current 8 MB image is 1163 KiB after USB onboarding. The OTA app
|
||||
# partition is 2048 KiB; retain a 1200 KiB hard gate and warning.
|
||||
size_limit_kb: 1200
|
||||
artifact_app: esp32-csi-node.bin
|
||||
artifact_pt: partition-table.bin
|
||||
- variant: 4mb
|
||||
@@ -92,12 +94,11 @@ jobs:
|
||||
idf.py set-target ${{ matrix.target }}
|
||||
idf.py build
|
||||
|
||||
- name: Build and run host-side ADR-110 unit tests
|
||||
- name: Build and run host-side firmware unit tests
|
||||
if: matrix.variant == 'c6-4mb'
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
make host_tests
|
||||
|
||||
- name: Verify binary size budget
|
||||
working-directory: firmware/esp32-csi-node
|
||||
@@ -162,10 +163,14 @@ jobs:
|
||||
mkdir -p release-staging
|
||||
cp build/esp32-csi-node.bin release-staging/${{ matrix.artifact_app }}
|
||||
cp build/partition_table/partition-table.bin release-staging/${{ matrix.artifact_pt }}
|
||||
if [ "${{ matrix.variant }}" = "8mb" ]; then
|
||||
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
|
||||
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
|
||||
fi
|
||||
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
|
||||
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
|
||||
cp version.txt release-staging/version.txt
|
||||
(cd release-staging && sha256sum \
|
||||
"${{ matrix.artifact_app }}" \
|
||||
"${{ matrix.artifact_pt }}" \
|
||||
bootloader.bin ota_data_initial.bin version.txt \
|
||||
> SHA256SUMS.txt)
|
||||
ls -la release-staging/
|
||||
|
||||
- name: Check QEMU ESP32-S3 support status
|
||||
@@ -180,3 +185,54 @@ jobs:
|
||||
name: esp32-csi-node-firmware-${{ matrix.variant }}
|
||||
path: firmware/esp32-csi-node/release-staging/
|
||||
retention-days: 90
|
||||
|
||||
build-c6-idf6:
|
||||
name: Build XIAO C6 external antenna (ESP-IDF 6.0.2)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v6.0.2
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Build the IDF 6 canary image
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" set-target esp32c6
|
||||
idf.py build
|
||||
test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024))
|
||||
strings build/esp32-csi-node.bin | grep -F "external U.FL"
|
||||
|
||||
publish-release-assets:
|
||||
name: Publish firmware release assets
|
||||
if: github.ref_type == 'tag'
|
||||
needs: [version-guard, build, build-c6-idf6]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download the exact-run firmware artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
pattern: esp32-csi-node-firmware-*
|
||||
path: release-downloads
|
||||
|
||||
- name: Package variants without filename collisions
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
for directory in release-downloads/esp32-csi-node-firmware-*; do
|
||||
variant="${directory##*/}"
|
||||
tar -C "$directory" -czf "release-assets/${variant}.tar.gz" .
|
||||
done
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS.txt)
|
||||
|
||||
- name: Publish the downloadable firmware bundles
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
|
||||
with:
|
||||
name: ESP32 firmware ${{ github.ref_name }}
|
||||
body: |
|
||||
Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles.
|
||||
Verify each archive against `SHA256SUMS.txt` before flashing.
|
||||
files: release-assets/*
|
||||
@@ -0,0 +1,70 @@
|
||||
name: iPhone LiDAR integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'integrations/iphone-lidar/**'
|
||||
- 'docs/adr/ADR-340-iphone-lidar-sensor-bridge.md'
|
||||
- '.github/workflows/iphone-lidar.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'integrations/iphone-lidar/**'
|
||||
- 'docs/adr/ADR-340-iphone-lidar-sensor-bridge.md'
|
||||
- '.github/workflows/iphone-lidar.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
web:
|
||||
name: Node relay and codec
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/iphone-lidar/web
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: integrations/iphone-lidar/web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=optional --audit-level=high
|
||||
|
||||
ios:
|
||||
name: iOS 17 compile
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
|
||||
- name: Compile native sources with strict concurrency
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sdk="$(xcrun --sdk iphoneos --show-sdk-path)"
|
||||
build_dir="$RUNNER_TEMP/ruview-lidar-build"
|
||||
mkdir -p "$build_dir"
|
||||
cd "$build_dir"
|
||||
xcrun swiftc \
|
||||
-parse-as-library \
|
||||
-target arm64-apple-ios17.0 \
|
||||
-sdk "$sdk" \
|
||||
-module-name RuViewLiDAR \
|
||||
-strict-concurrency=complete \
|
||||
-warnings-as-errors \
|
||||
-emit-module \
|
||||
-emit-module-path "$build_dir/RuViewLiDAR.swiftmodule" \
|
||||
-c "$GITHUB_WORKSPACE"/integrations/iphone-lidar/native/RuViewLiDAR/*.swift
|
||||
@@ -41,8 +41,12 @@ jobs:
|
||||
build: false
|
||||
publishable: true
|
||||
# ADR-283/325: brain + local hosts + replay assets + guarded Spaces OAuth adapter;
|
||||
# still runtime-dependency-free. 160 KiB is the reviewed hard ceiling.
|
||||
unpacked_budget: 163840
|
||||
# still runtime-dependency-free. ADR-369..374 add the SDK, firmware
|
||||
# flashing, training runner/gate, doctor, device access, and SSH
|
||||
# hosts. ADR-375 adds the MCP Apps console widget (~12 KB), the
|
||||
# Streamable HTTP transport and the terminal UI (384 KiB); ADR-378 the
|
||||
# ruview-live showcase views (~37 KB): 448 KiB reviewed ceiling.
|
||||
unpacked_budget: 458752
|
||||
- dir: harness/homecore
|
||||
build: false
|
||||
publishable: true
|
||||
@@ -131,13 +135,19 @@ jobs:
|
||||
npm i --no-fund --no-audit "$TGZ"
|
||||
case "${{ matrix.package.dir }}" in
|
||||
harness/ruview)
|
||||
./node_modules/.bin/ruview --version
|
||||
./node_modules/.bin/ruview doctor
|
||||
./node_modules/.bin/ruview-harness --version
|
||||
./node_modules/.bin/ruview-harness doctor
|
||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
||||
if ./node_modules/.bin/ruview claim-check; then
|
||||
if ./node_modules/.bin/ruview-harness claim-check; then
|
||||
echo 'claim-check passed with no input — fail-open regression'; exit 1
|
||||
fi
|
||||
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
|
||||
# ADR-369: the typed SDK resolves and the evidence gate refuses a baseline-free score
|
||||
node --input-type=module -e "const { createRuView } = await import('@ruvnet/ruview/sdk'); const r = createRuView(); if (r.tools().length < 22) process.exit(1); const g = await r.training.gate({ model_score: 0.93 }); if (g.verdict !== 'FAIL') process.exit(1);"
|
||||
./node_modules/.bin/ruview-harness doctor --json --group runtime,harness,firmware > doctor.json
|
||||
if ./node_modules/.bin/ruview-harness train-gate --model-score 0.93 > /dev/null; then
|
||||
echo 'train-gate passed without a baseline'; exit 1
|
||||
fi
|
||||
;;
|
||||
harness/homecore)
|
||||
./node_modules/.bin/homecore --version
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
name: RuForecast Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'feat/*', 'feature/*']
|
||||
paths:
|
||||
- 'v2/crates/ruforecast'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'v2/rust-toolchain.toml'
|
||||
- 'scripts/csi-data-policy-check.sh'
|
||||
- 'scripts/run-ruforecast-benchmarks.sh'
|
||||
- 'docs/adr/ADR-348-*.md'
|
||||
- 'docs/adr/ADR-349-*.md'
|
||||
- 'docs/adr/ADR-350-*.md'
|
||||
- 'docs/adr/README.md'
|
||||
- 'docs/benchmarks/ruforecast.md'
|
||||
- 'docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md'
|
||||
- 'docs/security/ruview-forecast-*'
|
||||
- 'docs/validation/ruforecast-requirements-evidence.md'
|
||||
- '.github/workflows/ruforecast-ci.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'v2/crates/ruforecast'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'v2/rust-toolchain.toml'
|
||||
- 'scripts/csi-data-policy-check.sh'
|
||||
- 'scripts/run-ruforecast-benchmarks.sh'
|
||||
- 'docs/adr/ADR-348-*.md'
|
||||
- 'docs/adr/ADR-349-*.md'
|
||||
- 'docs/adr/ADR-350-*.md'
|
||||
- 'docs/adr/README.md'
|
||||
- 'docs/benchmarks/ruforecast.md'
|
||||
- 'docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md'
|
||||
- 'docs/security/ruview-forecast-*'
|
||||
- 'docs/validation/ruforecast-requirements-evidence.md'
|
||||
- '.github/workflows/ruforecast-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ruforecast-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_PROFILE_DEV_DEBUG: '0'
|
||||
CARGO_PROFILE_TEST_DEBUG: '0'
|
||||
CARGO_PROFILE_BENCH_DEBUG: '0'
|
||||
CARGO_PROFILE_RELEASE_DEBUG: '0'
|
||||
|
||||
jobs:
|
||||
contract-rust-189:
|
||||
name: Forecast contract and feature-off boundary (Rust 1.89)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout recursively
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust 1.89 with lint components
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
|
||||
with:
|
||||
toolchain: 1.89.0
|
||||
components: rustfmt,clippy
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: v2/crates/ruforecast
|
||||
key: ruforecast-rust-189
|
||||
|
||||
- name: Validate benchmark runner and evidence contract
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash -n scripts/run-ruforecast-benchmarks.sh
|
||||
test -x scripts/run-ruforecast-benchmarks.sh
|
||||
test -f docs/benchmarks/ruforecast.md
|
||||
test -f docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md
|
||||
test -f docs/security/ruview-forecast-clean-room.md
|
||||
test -f docs/security/ruview-forecast-threat-model.md
|
||||
test -f docs/validation/ruforecast-requirements-evidence.md
|
||||
|
||||
for adr_number in 348 349 350; do
|
||||
mapfile -t adr_files < <(
|
||||
find docs/adr -maxdepth 1 -type f -name "ADR-${adr_number}-*.md" -print
|
||||
)
|
||||
if [[ "${#adr_files[@]}" -ne 1 ]]; then
|
||||
echo "Expected exactly one ADR-${adr_number} file, found ${#adr_files[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "^# ADR-${adr_number}:" "${adr_files[0]}"
|
||||
grep -Eq '^[-*] \*\*Status\*\*: (Proposed|Accepted|Superseded|Deprecated)( |$|—)|^## Status' "${adr_files[0]}"
|
||||
done
|
||||
|
||||
grep -q '^## Requirements' docs/adr/ADR-348-*.md
|
||||
grep -q '^## Acceptance gates' docs/adr/ADR-348-*.md
|
||||
grep -q '^## Requirements and acceptance' docs/adr/ADR-349-*.md
|
||||
grep -q '^## Requirements and acceptance' docs/adr/ADR-350-*.md
|
||||
for adr_number in 348 349 350; do
|
||||
grep -q "| \[ADR-${adr_number}\](ADR-${adr_number}-" docs/adr/README.md \
|
||||
|| { echo "ADR-${adr_number} is missing from the ADR index" >&2; exit 1; }
|
||||
done
|
||||
|
||||
for number in $(seq 1 12); do
|
||||
requirement="$(printf 'RF-%03d' "$number")"
|
||||
grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \
|
||||
|| { echo "Missing evidence row for ${requirement}" >&2; exit 1; }
|
||||
done
|
||||
for number in $(seq 1 8); do
|
||||
requirement="$(printf 'FT-%03d' "$number")"
|
||||
grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \
|
||||
|| { echo "Missing evidence row for ${requirement}" >&2; exit 1; }
|
||||
done
|
||||
for number in $(seq 1 9); do
|
||||
requirement="$(printf 'PM-%03d' "$number")"
|
||||
grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \
|
||||
|| { echo "Missing evidence row for ${requirement}" >&2; exit 1; }
|
||||
done
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
root = Path("v2/crates/ruforecast/crates")
|
||||
core = tomllib.loads((root / "ruforecast-core/Cargo.toml").read_text())
|
||||
model = tomllib.loads((root / "ruforecast-model/Cargo.toml").read_text())
|
||||
train = tomllib.loads((root / "ruforecast-train/Cargo.toml").read_text())
|
||||
|
||||
assert core.get("features", {}).get("default", []) == []
|
||||
assert model["features"]["default"] == [], "model defaults must stay backend-free"
|
||||
assert train["features"]["default"] == [], "train defaults must stay backend-free"
|
||||
assert {"model", "cpu", "cuda", "wgpu", "ruvector"} <= model["features"].keys()
|
||||
assert {"cpu", "cuda", "training", "cli", "server", "fal-client"} <= train["features"].keys()
|
||||
assert {"model", "dep:burn-ndarray"} <= set(model["features"]["cpu"])
|
||||
assert {"model", "dep:burn-cuda"} <= set(model["features"]["cuda"])
|
||||
assert {"model", "dep:burn-wgpu"} <= set(model["features"]["wgpu"])
|
||||
assert "dep:ruvector-core" in model["features"]["ruvector"]
|
||||
assert {"ruforecast-model/cpu", "training"} <= set(train["features"]["cpu"])
|
||||
assert {"ruforecast-model/cuda", "training"} <= set(train["features"]["cuda"])
|
||||
|
||||
bins = {entry["name"]: entry for entry in train.get("bin", [])}
|
||||
assert bins["ruforecast"]["required-features"] == ["cli"]
|
||||
|
||||
model_benches = {entry["name"]: entry for entry in model.get("bench", [])}
|
||||
train_benches = {entry["name"]: entry for entry in train.get("bench", [])}
|
||||
assert model_benches["forecast_inference"]["harness"] is False
|
||||
assert model_benches["forecast_inference"]["required-features"] == ["cpu"]
|
||||
assert train_benches["data_pipeline"]["harness"] is False
|
||||
assert train_benches["data_pipeline"]["required-features"] == ["cpu"]
|
||||
PY
|
||||
|
||||
- name: Check formatting for forecast packages only
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.89.0 fmt
|
||||
-p ruforecast-core
|
||||
-p ruforecast-model
|
||||
-p ruforecast-train
|
||||
-- --check
|
||||
|
||||
- name: Prove feature-off graph excludes Burn and CubeCL
|
||||
working-directory: v2/crates/ruforecast
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: > forecast-feature-off-tree.txt
|
||||
for package in ruforecast-core ruforecast-model ruforecast-train; do
|
||||
cargo +1.89.0 tree --locked -e normal,build -p "$package" --no-default-features \
|
||||
>> forecast-feature-off-tree.txt
|
||||
done
|
||||
if grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' forecast-feature-off-tree.txt; then
|
||||
echo 'Burn/CubeCL entered the Rust 1.89 feature-off dependency graph.' >&2
|
||||
cat forecast-feature-off-tree.txt >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check feature-off packages
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.89.0 check --locked
|
||||
-p ruforecast-core
|
||||
-p ruforecast-model
|
||||
-p ruforecast-train
|
||||
--no-default-features --all-targets
|
||||
|
||||
- name: Test model-neutral core
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.89.0 test --locked
|
||||
-p ruforecast-core
|
||||
--no-default-features --lib --tests
|
||||
|
||||
- name: Test model contracts without a model backend
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.89.0 test --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --lib --tests
|
||||
|
||||
- name: Test RuVector retrieval boundary without a model backend
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.89.0 test --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --features ruvector --lib --tests
|
||||
|
||||
- name: Clippy RuVector retrieval boundary
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.89.0 clippy --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --features ruvector --all-targets -- -D warnings
|
||||
|
||||
- name: Clippy forecast packages without backend features
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.89.0 clippy --locked
|
||||
-p ruforecast-core
|
||||
-p ruforecast-model
|
||||
-p ruforecast-train
|
||||
--no-default-features --all-targets -- -D warnings
|
||||
|
||||
- name: Upload feature-off dependency tree
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: ruforecast-feature-off-tree
|
||||
path: v2/crates/ruforecast/forecast-feature-off-tree.txt
|
||||
if-no-files-found: error
|
||||
|
||||
hosted-boundaries-rust-189:
|
||||
name: Forecast hosted boundaries and privacy contract (Rust 1.89)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout recursively
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust 1.89 with Clippy
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
|
||||
with:
|
||||
toolchain: 1.89.0
|
||||
components: clippy
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: v2/crates/ruforecast
|
||||
key: ruforecast-hosted-rust-189
|
||||
|
||||
- name: Enforce clean-room source, secret, and hosted-wire boundary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
roots = [
|
||||
Path("v2/crates/ruforecast/crates/ruforecast-core"),
|
||||
Path("v2/crates/ruforecast/crates/ruforecast-model"),
|
||||
Path("v2/crates/ruforecast/crates/ruforecast-train"),
|
||||
]
|
||||
text_suffixes = {".json", ".py", ".rs", ".sh", ".toml", ".yaml", ".yml"}
|
||||
forbidden_source = re.compile(r"(?i)\b(?:times[ _-]?fm|google[ _/-]?research)\b")
|
||||
secret_patterns = {
|
||||
"private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
"AWS access key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
"JWT": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
|
||||
"presigned URL": re.compile(r"(?i)[?&](?:X-Amz-Signature|X-Goog-Signature)="),
|
||||
"literal FAL_KEY": re.compile(r"(?i)\bFAL_KEY\b\s*[:=]\s*['\"][^'$<{\"\n]{8,}['\"]"),
|
||||
}
|
||||
forbidden_blobs = {".ckpt", ".mpk", ".npy", ".npz", ".onnx", ".pt", ".pth", ".safetensors"}
|
||||
|
||||
errors = []
|
||||
for root in roots:
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file() or "target" in path.parts:
|
||||
continue
|
||||
if path.suffix.lower() in forbidden_blobs:
|
||||
errors.append(f"forbidden checked-in model/data blob: {path}")
|
||||
continue
|
||||
if path.suffix.lower() not in text_suffixes:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if forbidden_source.search(text):
|
||||
errors.append(f"forbidden clean-room source/import reference: {path}")
|
||||
for label, pattern in secret_patterns.items():
|
||||
if pattern.search(text):
|
||||
errors.append(f"possible {label} in {path}")
|
||||
|
||||
fal_source = Path("v2/crates/ruforecast/crates/ruforecast-train/src/fal.rs").read_text()
|
||||
for struct_name in ("HostedOptimizer", "HostedBudget", "HostedSyntheticPayload"):
|
||||
match = re.search(
|
||||
rf"pub struct {struct_name}\s*\{{(?P<body>.*?)^\}}",
|
||||
fal_source,
|
||||
flags=re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if match is None:
|
||||
errors.append(f"missing hosted wire struct: {struct_name}")
|
||||
continue
|
||||
body = match.group("body")
|
||||
fields = re.findall(r"^\s*pub\s+([A-Za-z0-9_]+)\s*:", body, re.MULTILINE)
|
||||
forbidden_fields = (
|
||||
"account", "data_policy", "dataset", "device", "identity", "path",
|
||||
"person", "room", "session", "site", "split", "subject", "tenant",
|
||||
"workspace",
|
||||
)
|
||||
for field in fields:
|
||||
lowered = field.lower()
|
||||
if any(fragment in lowered for fragment in forbidden_fields):
|
||||
errors.append(f"forbidden hosted field {struct_name}.{field}")
|
||||
if "bytes" in lowered and field not in {
|
||||
"max_artifact_bytes",
|
||||
"max_memory_bytes",
|
||||
}:
|
||||
errors.append(f"unapproved hosted byte field {struct_name}.{field}")
|
||||
if "DataPolicy" in body:
|
||||
errors.append(f"DataPolicy entered hosted wire struct {struct_name}")
|
||||
|
||||
if errors:
|
||||
print("\n".join(errors), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
- name: Enforce tracked-data policy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash scripts/csi-data-policy-check.sh --self-test
|
||||
bash scripts/csi-data-policy-check.sh --tracked
|
||||
|
||||
- name: Validate minimal fal deployment archive policy
|
||||
run: >-
|
||||
python3
|
||||
v2/crates/ruforecast/crates/ruforecast-train/deploy/fal/deploy.py
|
||||
self-test
|
||||
|
||||
- name: Prove hosted feature graph excludes Burn and CubeCL
|
||||
working-directory: v2/crates/ruforecast
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo +1.89.0 tree --locked -e normal,build \
|
||||
-p ruforecast-train \
|
||||
--no-default-features --features cli,server,fal-client \
|
||||
> forecast-hosted-feature-tree.txt
|
||||
if grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' \
|
||||
forecast-hosted-feature-tree.txt; then
|
||||
echo 'Burn/CubeCL entered the Rust 1.89 hosted dependency graph.' >&2
|
||||
cat forecast-hosted-feature-tree.txt >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require named hosted boundary regressions
|
||||
working-directory: v2/crates/ruforecast
|
||||
shell: bash
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo +1.89.0 test --locked \
|
||||
-p ruforecast-train \
|
||||
--no-default-features --features cli,server,fal-client \
|
||||
--lib --bins --tests -- --list \
|
||||
| tee hosted-boundary-tests.txt
|
||||
for regression in \
|
||||
fal_app_rejects_path_and_host_confusion \
|
||||
fal_urls_require_exact_origin_path_and_no_query \
|
||||
fal_submit_headers_disable_retry_and_omit_store_io \
|
||||
hosted_payload_dto_has_no_customer_fields \
|
||||
queue_status_discards_provider_error_body \
|
||||
result_url_accepts_exact_response_suffix \
|
||||
artifact_file_url_uses_expected_api_path \
|
||||
fal_key_debug_is_redacted \
|
||||
hosted_payload_rejects_app_field \
|
||||
hosted_reservation_cannot_exceed_source_retention \
|
||||
artifact_handoff_expiry_boundary_is_fail_closed \
|
||||
fal_result_enforces_cumulative_artifact_budget_boundary \
|
||||
fal_download_rejects_over_budget_without_writing \
|
||||
direct_server_train_requires_request_id_header \
|
||||
request_id_rejects_path_injection \
|
||||
unknown_cancel_is_not_success \
|
||||
privacy_external_dataset_payload_is_denied; do
|
||||
grep -q "$regression" hosted-boundary-tests.txt \
|
||||
|| { echo "Missing hosted boundary regression: $regression" >&2; exit 1; }
|
||||
done
|
||||
|
||||
- name: Exercise hosted external-data rejection
|
||||
working-directory: v2/crates/ruforecast
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: >-
|
||||
cargo +1.89.0 test --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cli,server,fal-client
|
||||
privacy_external_dataset_payload_is_denied
|
||||
|
||||
- name: Test server and fal client with mock integrations
|
||||
working-directory: v2/crates/ruforecast
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: >-
|
||||
cargo +1.89.0 test --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cli,server,fal-client
|
||||
--lib --bins --tests
|
||||
|
||||
- name: Clippy server and fal client boundaries
|
||||
working-directory: v2/crates/ruforecast
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: >-
|
||||
cargo +1.89.0 clippy --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cli,server,fal-client
|
||||
--all-targets -- -D warnings
|
||||
|
||||
- name: Clippy standalone CLI feature boundary
|
||||
working-directory: v2/crates/ruforecast
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: >-
|
||||
cargo +1.89.0 clippy --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cli
|
||||
--bin ruforecast -- -D warnings
|
||||
|
||||
- name: Upload hosted dependency tree
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: ruforecast-hosted-feature-tree
|
||||
path: v2/crates/ruforecast/forecast-hosted-feature-tree.txt
|
||||
if-no-files-found: error
|
||||
|
||||
burn-cpu-rust-192:
|
||||
name: Forecast Burn CPU tests and bench compile (Rust 1.92)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout recursively
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust 1.92 with Clippy
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
components: clippy
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: v2/crates/ruforecast
|
||||
key: ruforecast-rust-192-cpu
|
||||
|
||||
- name: Test model with Burn CPU backend
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 test --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --features cpu --lib --tests
|
||||
|
||||
- name: Test trainer and CLI with Burn CPU backend
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 test --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cpu,cli --lib --bins
|
||||
|
||||
- name: Run one-step local JSONL training smoke
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 test --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cpu
|
||||
--test local_jsonl_smoke --
|
||||
--exact local_hash_addressed_jsonl_executes_one_real_optimizer_step
|
||||
|
||||
- name: Run idempotent synthetic CLI training smoke
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 test --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cpu,cli
|
||||
--test cli_smoke --
|
||||
--exact cli_smoke_trains_and_writes_the_complete_candidate_set
|
||||
|
||||
- name: Test combined CPU and hosted feature surface
|
||||
working-directory: v2/crates/ruforecast
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: >-
|
||||
cargo +1.92.0 test --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cpu,cli,server,fal-client
|
||||
--lib --bins
|
||||
|
||||
- name: Clippy model with Burn CPU backend
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 clippy --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --features cpu --all-targets -- -D warnings
|
||||
|
||||
- name: Clippy trainer and CLI with Burn CPU backend
|
||||
working-directory: v2/crates/ruforecast
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: >-
|
||||
cargo +1.92.0 clippy --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cpu,cli,server,fal-client
|
||||
--all-targets -- -D warnings
|
||||
|
||||
- name: Compile forecast inference benchmark
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 bench --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --features cpu
|
||||
--bench forecast_inference --no-run
|
||||
|
||||
- name: Compile forecast data-pipeline benchmark
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 bench --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cpu
|
||||
--bench data_pipeline --no-run
|
||||
|
||||
- name: Run quick inference benchmark for trend visibility
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p ../../bench-out/ruforecast
|
||||
cargo +1.92.0 bench --locked \
|
||||
-p ruforecast-model \
|
||||
--no-default-features --features cpu \
|
||||
--bench forecast_inference -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../../bench-out/ruforecast/forecast-inference.txt
|
||||
|
||||
- name: Run quick data-pipeline benchmark for trend visibility
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p ../../bench-out/ruforecast
|
||||
cargo +1.92.0 bench --locked \
|
||||
-p ruforecast-train \
|
||||
--no-default-features --features cpu \
|
||||
--bench data_pipeline -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../../bench-out/ruforecast/data-pipeline.txt
|
||||
|
||||
- name: Upload informational benchmark logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: ruforecast-informational-cpu-benchmarks
|
||||
path: bench-out/ruforecast/
|
||||
if-no-files-found: warn
|
||||
|
||||
burn-cuda-compile-rust-192:
|
||||
name: Forecast Burn CUDA/WGPU compile checks only (Rust 1.92)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout recursively
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust 1.92
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: v2/crates/ruforecast
|
||||
key: ruforecast-rust-192-cuda-check
|
||||
|
||||
- name: Check model CUDA feature
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 check --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --features cuda --lib
|
||||
|
||||
- name: Check model WGPU feature
|
||||
working-directory: v2/crates/ruforecast
|
||||
run: >-
|
||||
cargo +1.92.0 check --locked
|
||||
-p ruforecast-model
|
||||
--no-default-features --features wgpu --lib
|
||||
|
||||
- name: Check trainer CUDA and hosted-server feature combination
|
||||
working-directory: v2/crates/ruforecast
|
||||
env:
|
||||
FAL_KEY: ''
|
||||
run: >-
|
||||
cargo +1.92.0 check --locked
|
||||
-p ruforecast-train
|
||||
--no-default-features --features cuda,cli,server
|
||||
--lib --bins
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-373/374 — device-access end-to-end through the real @ruvnet/ruview CLI.
|
||||
# MR60BHA2 (60 GHz) and RPLIDAR are emulated on pseudo-terminals and read via
|
||||
# real pyserial; an ESP32 node is emulated over UDP; the iPhone LiDAR path
|
||||
# uses the repository's real relay. All results are SYNTHETIC: this proves
|
||||
# the transports and parsers, not hardware behaviour.
|
||||
|
||||
name: ruview device e2e
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'harness/ruview/src/**'
|
||||
- 'harness/ruview/bin/**'
|
||||
- 'harness/ruview/test/e2e/**'
|
||||
- 'integrations/iphone-lidar/web/**'
|
||||
- '.github/workflows/ruview-device-e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'harness/ruview/src/**'
|
||||
- 'harness/ruview/bin/**'
|
||||
- 'harness/ruview/test/e2e/**'
|
||||
- 'integrations/iphone-lidar/web/**'
|
||||
- '.github/workflows/ruview-device-e2e.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ruview-device-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
devices:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install pyserial
|
||||
run: python -m pip install pyserial==3.5
|
||||
|
||||
- name: Install the iPhone LiDAR relay
|
||||
working-directory: integrations/iphone-lidar/web
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Install harness
|
||||
working-directory: harness/ruview
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# Missing prerequisites fail instead of skipping (RUVIEW_E2E_REQUIRE).
|
||||
# The suite links each emulator pty to /dev/ttyRUVIEWE2E<n> with
|
||||
# `sudo -n`, because the harness refuses /dev/pts/* port names.
|
||||
- name: Device e2e (SYNTHETIC devices)
|
||||
working-directory: harness/ruview
|
||||
env:
|
||||
RUVIEW_E2E_REQUIRE: '1'
|
||||
run: npm run test:e2e:devices
|
||||
@@ -0,0 +1,132 @@
|
||||
# ADR-368 — @ruvnet/ruview-kernel: build the WASM and napi-rs transports from
|
||||
# source on every OS, run the Rust ABI tests, the package unit/security/e2e
|
||||
# tests with the native addon REQUIRED, the wasm↔napi parity check, artifact
|
||||
# verification, and the cross-package e2e through the @ruvnet/ruview
|
||||
# metaharness. No publish step: publication stays CI-only via the release
|
||||
# workflow and requires explicit maintainer approval (ADR-265).
|
||||
|
||||
name: ruview-kernel npm
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/ruview-kernel/**'
|
||||
- 'v2/crates/ruview-kernel-napi/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'harness/ruview-kernel/**'
|
||||
- 'harness/ruview/src/kernel.js'
|
||||
- 'harness/ruview/test/kernel.test.mjs'
|
||||
- '.github/workflows/ruview-kernel-npm.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/ruview-kernel/**'
|
||||
- 'v2/crates/ruview-kernel-napi/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'harness/ruview-kernel/**'
|
||||
- 'harness/ruview/src/kernel.js'
|
||||
- 'harness/ruview/test/kernel.test.mjs'
|
||||
- '.github/workflows/ruview-kernel-npm.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ruview-kernel-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
kernel:
|
||||
name: kernel (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# The v2 workspace lists submodule crates as members; cargo must be
|
||||
# able to load every member manifest.
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust + wasm32 target
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
# v2/rust-toolchain pins its own channel; the target must be installed
|
||||
# for that toolchain, not only for the action's default `stable`.
|
||||
- name: Add wasm32 target to the pinned v2 toolchain
|
||||
working-directory: v2
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: |
|
||||
v2 -> target
|
||||
v2/crates/ruview-kernel-napi -> target
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Rust ABI tests
|
||||
run: cargo test --manifest-path v2/Cargo.toml -p ruview-kernel --locked
|
||||
|
||||
- name: Build WASM + napi-rs artifacts from source
|
||||
working-directory: harness/ruview-kernel
|
||||
run: npm run build
|
||||
|
||||
- name: Verify artifacts (checksums, zero WASM imports, ABI)
|
||||
working-directory: harness/ruview-kernel
|
||||
run: node scripts/verify-artifacts.mjs --require-wasm
|
||||
|
||||
- name: Package tests (napi required)
|
||||
working-directory: harness/ruview-kernel
|
||||
env:
|
||||
RUVIEW_KERNEL_REQUIRE_NAPI: '1'
|
||||
run: npm test
|
||||
|
||||
- name: Doctor, self-test, and wasm↔napi parity (SYNTHETIC)
|
||||
working-directory: harness/ruview-kernel
|
||||
run: |
|
||||
node bin/cli.js doctor --strict-napi
|
||||
node bin/cli.js selftest --backend wasm
|
||||
node bin/cli.js selftest --backend napi
|
||||
node bin/cli.js parity
|
||||
|
||||
- name: Pack
|
||||
working-directory: harness/ruview-kernel
|
||||
run: |
|
||||
npm pack --dry-run
|
||||
# Relative destination: Windows runner temp paths contain
|
||||
# backslashes that bash globbing would mangle.
|
||||
mkdir -p dist-pack
|
||||
npm pack --pack-destination dist-pack
|
||||
|
||||
- name: Cross-package e2e through the @ruvnet/ruview metaharness
|
||||
working-directory: harness/ruview
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm install --no-save --ignore-scripts ../ruview-kernel/dist-pack/ruvnet-ruview-kernel-*.tgz
|
||||
node --test test/kernel.test.mjs
|
||||
node bin/cli.js kernel --backend auto
|
||||
|
||||
- name: Upload built artifacts
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ruview-kernel-linux-x64
|
||||
path: |
|
||||
harness/ruview-kernel/wasm/ruview_kernel.wasm
|
||||
harness/ruview-kernel/wasm/SHA256SUMS
|
||||
harness/ruview-kernel/native/*.node
|
||||
harness/ruview-kernel/native/SHA256SUMS
|
||||
retention-days: 14
|
||||
@@ -104,8 +104,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ inputs.package }}" in
|
||||
# ADR-283/325: brain + hosts + replay + guarded Spaces OAuth; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=163840 ;;
|
||||
# ADR-283/325/369-374: brain + hosts + replay + Spaces OAuth + SDK,
|
||||
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
|
||||
# widget, HTTP transport, terminal UI; ADR-378 mod showcase; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=458752 ;;
|
||||
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
|
||||
harness/homecore) export UNPACKED_BUDGET=180000 ;;
|
||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
||||
@@ -140,12 +142,12 @@ jobs:
|
||||
npm i --no-fund --no-audit "$TGZ"
|
||||
case "${{ inputs.package }}" in
|
||||
harness/ruview)
|
||||
./node_modules/.bin/ruview --version
|
||||
./node_modules/.bin/ruview doctor
|
||||
./node_modules/.bin/ruview guidance --topic homecore --query restore --limit 1 \
|
||||
./node_modules/.bin/ruview-harness --version
|
||||
./node_modules/.bin/ruview-harness doctor
|
||||
./node_modules/.bin/ruview-harness guidance --topic homecore --query restore --limit 1 \
|
||||
| grep -q '"homecore-runtime-restore"'
|
||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
||||
if ./node_modules/.bin/ruview claim-check; then
|
||||
if ./node_modules/.bin/ruview-harness claim-check; then
|
||||
echo 'claim-check passed with no input — fail-open regression'; exit 1
|
||||
fi
|
||||
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
|
||||
|
||||
@@ -14,6 +14,33 @@ env:
|
||||
PYTHON_VERSION: '3.11'
|
||||
|
||||
jobs:
|
||||
# Rust dependency advisories are deterministic for the checked-in lockfile,
|
||||
# so this job gates the PR and retains the exact machine-readable report.
|
||||
rust-audit:
|
||||
name: Rust Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked --version 0.22.2
|
||||
|
||||
- name: Audit the checked-in Rust lockfile
|
||||
run: |
|
||||
set -o pipefail
|
||||
cargo audit --file v2/Cargo.lock --json | tee v2/cargo-audit.json
|
||||
|
||||
- name: Upload Rust advisory report
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always()
|
||||
with:
|
||||
name: cargo-audit-report
|
||||
path: v2/cargo-audit.json
|
||||
if-no-files-found: error
|
||||
|
||||
# Static Application Security Testing (SAST)
|
||||
sast:
|
||||
name: Static Application Security Testing
|
||||
|
||||
@@ -28,6 +28,7 @@ on:
|
||||
- 'v2/crates/wifi-densepose-wifiscan/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/cog-ha-matter/**'
|
||||
- 'v2/crates/homecore*/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'ui/**'
|
||||
|
||||
+19
-5
@@ -17,11 +17,16 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Host-built firmware test binaries (compiled from test/*.c, not source)
|
||||
firmware/esp32-csi-node/test/test_adr110
|
||||
firmware/esp32-csi-node/test/test_vitals
|
||||
firmware/esp32-csi-node/test/fuzz_serialize
|
||||
firmware/esp32-csi-node/test/fuzz_edge
|
||||
firmware/esp32-csi-node/test/fuzz_nvs
|
||||
# Compiled host-test and fuzz binaries. The Makefile writes them beside their
|
||||
# sources, so they land in a tracked directory. Ignore them by SHAPE rather
|
||||
# than by name: every source here carries an extension and no binary does.
|
||||
#
|
||||
# This replaced a hand-maintained list of five targets, which had gone stale --
|
||||
# test_csi_sanitize, test_mmwave_detect and test_thermal were all built by
|
||||
# `make host_tests`, ignored by nothing, and got committed by accident.
|
||||
firmware/esp32-csi-node/test/test_*
|
||||
firmware/esp32-csi-node/test/fuzz_*
|
||||
!firmware/esp32-csi-node/test/*.*
|
||||
firmware/esp32-csi-node/test/*.exe
|
||||
firmware/esp32-csi-node/test/*.obj
|
||||
|
||||
@@ -311,3 +316,12 @@ v2/crates/wifi-densepose-sensing-server/data/
|
||||
# ADR-324: wasm-bindgen output for ruview-offaxis is generated locally
|
||||
# (see the crate README); never commit generated artifacts.
|
||||
v2/crates/ruview-offaxis/pkg/
|
||||
|
||||
# fal.ai visual-teacher LoRA training data/weights -- regenerable, never
|
||||
# commit training images or model weights (ADR-353)
|
||||
scripts/fal-visual-teacher/training-data/
|
||||
scripts/fal-visual-teacher/training-data.zip
|
||||
scripts/fal-visual-teacher/output/
|
||||
|
||||
# Trained adaptive classifier (auto-created at runtime; #2135)
|
||||
/v2/data/adaptive_model.json
|
||||
@@ -33,3 +33,7 @@
|
||||
path = vendor/metaharness
|
||||
url = https://github.com/ruvnet/metaharness
|
||||
branch = main
|
||||
[submodule "v2/crates/ruforecast"]
|
||||
path = v2/crates/ruforecast
|
||||
url = https://github.com/ruvnet/RuForecast.git
|
||||
branch = main
|
||||
@@ -7,8 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Community batch (PRs #2100-#2157 by aepod, reviewed and integrated)
|
||||
|
||||
Integrated as one set: conflicts resolved, review findings fixed, validated on the integrated branch. Hardware figures cited by the PRs are the author's own live runs on ESP32-S3 nodes and were not independently reproduced here.
|
||||
|
||||
- **Security and honest status.** Refuse a routable HTTP bind when API auth is off (#2134). `--privacy-mode` now strips biometrics from REST, WebSocket and recordings, not only MQTT (#2125; the stripped-key list is an exact-match allowlist, so a new biometric field must be added to it). `/api/v1/status` waits for a real frame and unknown `--source` values are rejected (#2126; breaking for `--source macos|linux|windows`). A stale `/sensing/latest` reports `{"status":"stale"}` after 10 s (#2119). Measured stream fps and real WebSocket client counts (#2127). Absent nodes no longer count as one person (#2132). ESP32 frames with zero antennas or subcarriers are dropped (#2118). Dead nodes are evicted on a timer (#2120). Recordings and models stay under `--data-dir` (#2129). Boolean env flags accept 1/0/yes/no/on/off (#2108). Docker UDP ingest posture is explicit (#2107).
|
||||
- **CSI pipeline.** The grid gate locks on the majority frame shape and `/health` reports a stalled pipeline (#2157; a partial fix, mixed 192/306 frame shapes still drop frames, so it does not close #1894). Phase is combined across nodes only when they declare a shared reference (#2156). Point-cloud and hardware parsers use the ADR-018 layout and accept more than 256 subcarriers (#2155; the bandwidth rule for counts above 64 is CLAIMED, inferred from the datasheet). Per-node adaptive training from real recordings (#2101, #2103); a run that fell back to the live buffer now reports `degraded: true`. Gaussian decay no longer compounds across calls (#2140).
|
||||
- **MediaTek (ADR-266/267).** Legacy and unknown PPDU types in MTC1 (#2112), per-device ingest capped at 64 devices (#2113), a labelled amplitude heuristic and activity index (#2114; CLAIMED, not an occupancy estimate), an MT7981 bridge (#2115), and the OpenWrt WN586X3 patch set and guide (#2116; the vendored CSI patch is BSD-3-Clause-Clear, see `NOTICE`).
|
||||
- **MQTT / Home Assistant.** Semantic states wired into the publisher, entities announced only when sourced, persisted client id (#2117). Existing Home Assistant installs keep retained discovery configs for the six removed entities until they are cleared manually.
|
||||
- **Provisioning and firmware.** Wi-Fi password kept owner-only and out of argv and `--state` (#2153); state keyed by board MAC (#2152); `--dry-run` and a failed `--reset` leave state intact (#2111); OTA images confirmed by a bounded health check, which means only "reached the network and sent CSI" (#2100, ADR-379).
|
||||
- **Ground truth and spatial.** Radar window adapter and auto-labeller with a session-disjoint split and a median count baseline; radar labels carry `evidence: CLAIMED` (#2143, #2144). Node self-survey, check-only (#2145, ADR-381). The `spatial.evidence.v1` export defaults to the CODE proof grade and MEASURED requires a reproducer (#2141, ADR-382). Loopback UDP tee (#2102, ADR-380).
|
||||
- **Tooling.** `prove.sh` reports INCOMPLETE instead of PASS when a gate is skipped (#2130); ADR-018 capture scripts (#2131); API token on the ground-truth script (#2104); the UI asks for the API token instead of retrying forever (#2128); macOS redacted-link handling (#2105).
|
||||
- **Not included:** #2106 (MacWifi.app, draft) and #2142 (MR60 probe change, needs a captured MR60BHA2 boot log). `v2/data/adaptive_model.json` (41.5% training accuracy) is no longer tracked (#2135).
|
||||
|
||||
### Added
|
||||
|
||||
- **`@ruvnet/ruview` 0.9.1: live sensing views inside Claude Code (ADR-377, ADR-378).** The package now ships `ruview-live`, a Claude Code mod. Type `/ruview` and a pane opens next to the conversation showing what your sensors see right now, refreshed automatically. It has three views, switched with keys `1` `2` `3` or with `/ruview overview|waterfall|radar`:
|
||||
- **Overview**: the CSI nodes streaming to your machine (rate, packet loss, signal strength) and an optional 60 GHz radar kit (presence, distance, heart and breathing rates).
|
||||
- **CSI waterfall**: the Wi-Fi signal itself, the strength of every subcarrier over time, drawn in colour and scrolling as frames arrive. The new `ruview esp32 --spectrum` option supplies the data.
|
||||
- **Radar**: a top-down range fan with an animated ping out to the measured distance, plus heart, breathing and distance charts.
|
||||
|
||||
Every reading is labelled honestly: MEASURED or SYNTHETIC from the packets themselves; radar vitals as "device-reported, not validated"; range-only radar as "distance, not direction". The pane only reads; it never flashes or configures a device.
|
||||
|
||||
Verified on real hardware: an ESP32-C6 streaming CSI and a Seeed MR60BHA2 radar kit (MEASURED; reproducer `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json`). The package's size ceiling rises from 384 KiB to 448 KiB; it still has no runtime dependencies.
|
||||
- **`wifi-densepose-sar` — coherent wideband RF tomography research crate (ADR-287).** New standalone leaf crate (the `nvsim` pattern; zero coupling to `wifi-densepose-hardware` or any real ingestion path) implementing the synthetic-aperture-radar reconstruction primitive a handheld through-wall RF imaging device would need — motivated by comparison against Applied Electrodynamics' "WaveSight" launch, and explicitly scoped below ADR-278's RISE/DiffRadar/GeRaF reproduction gates. Ships: (1) a stepped-frequency, multi-position complex forward measurement simulator (`y_{m,k} = Σ σ_j/R² · exp(-i·4π·f·R/c) + noise`, deterministic ChaCha20 seeding); (2) delay-and-sum backprojection reconstruction onto a 3D voxel grid, rayon-parallelized over voxels; (3) threshold + local-maximum point-cloud extraction; (4) closed-form range/cross-range resolution and antenna-pose coherence-budget formulas (`ΔR=c/2B`, `δ_CR≈λR/2L`, `Δp≤λ/8`) checked against the reconstruction's *actual* behavior in `tests/physics_validation.rs` rather than merely documented — forward-simulating two targets at controlled separations and proving they resolve or merge exactly where the formulas predict, and that reconstructed focus at a known target degrades as injected antenna-pose error grows. Every number is SYNTHETIC/L0 (ADR-282) — no real wideband RF hardware backs this crate; see the crate README and `docs/tutorials/coherent-rf-tomography-backprojection.md` for the full honesty boundary and a worked walkthrough. `focus_at_point` exploits the evenly-spaced-by-construction frequency sweep (an arithmetic progression in per-term phase) to evaluate each pose's phasor once and advance it by a fixed complex-multiply step per frequency instead of one `sin`/`cos` pair per frequency — **MEASURED ~4.4-4.5x faster** (criterion regression detection, p < 0.001) than the first-shipped direct-computation version, proven equivalent (not just faster) to an independently reimplemented reference across four sweep sizes and on-/off-target points. 25 tests (22 unit + 3 integration), 0 failed, clippy-clean; MEASURED backprojection throughput ~1.7-2.3M voxels/sec (criterion, 21 poses × 32 freq steps).
|
||||
- **HOMECORE platform runtime completion — secure native/Wasmtime plugins, authenticated HAP IP, expanded Home Assistant APIs, durable restoration/migration, and voice protocols.** `homecore-server` now owns deterministic compiled-in native plugin registration plus explicitly configured, path-bounded, Ed25519 publisher-verified Wasm packages executed through Wasmtime with setup/state-change/teardown lifecycle; arbitrary native dynamic libraries remain intentionally unsupported. The optional HAP server implements persisted accessory identity and controller records, SRP-6a Pair-Setup M1–M6, X25519/Ed25519 Pair-Verify M1–M4, HKDF-SHA512/ChaCha20-Poly1305 record framing, authenticated/admin endpoint gates, replay/tamper closure, live entity synchronization, and paired-state `_hap._tcp` mDNS updates (45 focused tests; external Apple certification is not claimed). Startup restores device/entity registries and deterministic latest recorder states before plugins, and migration now atomically preserves forward-compatible device/config-entry fields. The HA-compatible surface adds events, templates, config checks, components, registries, history/logbook with SQL-enforced global response bounds, calendar/camera provider routes, and modern WebSocket negotiation while retaining a machine-readable limitations matrix for integration-specific behavior. Assist adds bounded PCM16, async STT/TTS contracts, an end-to-end speech pipeline, and an authenticated satellite session protocol; real deployments still provide the speech engines.
|
||||
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
RuView third-party notices
|
||||
|
||||
This repository is licensed MIT OR Apache-2.0 (see LICENSE). The items below
|
||||
are vendored third-party material that keeps its own licence and is not
|
||||
covered by that dual licence.
|
||||
|
||||
firmware/openwrt-wn586x3/patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch
|
||||
What: MediaTek's mt76 mt7915 CSI patch, rebased onto openwrt/mt76
|
||||
eb567bc7 (the revision pinned by OpenWrt 24.10.8).
|
||||
Author: Bo Jiao <Bo.Jiao@mediatek.com> (original author line kept in the
|
||||
patch header); copyright MediaTek Inc.
|
||||
Origin: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
|
||||
Licence: BSD-3-Clause-Clear (SPDX identifier,
|
||||
https://spdx.org/licenses/BSD-3-Clause-Clear.html). The patch
|
||||
carries the SPDX tags and MediaTek copyright lines in its added
|
||||
files; the licence text is not reproduced here.
|
||||
Scope: A vendored patch applied to an OpenWrt/mt76 build. It is not part
|
||||
of the Rust crates, is not compiled by the Cargo workspace, and is
|
||||
not linked into any RuView binary.
|
||||
|
||||
See firmware/openwrt-wn586x3/README.md for the other patches in that
|
||||
directory and the licence each one keeps.
|
||||
@@ -2,20 +2,11 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cognitum.one/seed">
|
||||
<img src="assets/ruview-seed.png" alt="RuView - WiFi DensePose" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://cognitum.one/marketplace">
|
||||
<img src="assets/musica-promo.png" alt="Cognitum Musica" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/ruvnet/RuCelium">
|
||||
<img src="assets/rucelium-hero.png" alt="RuCelium — environmental intelligence" width="100%">
|
||||
<img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
## **See through walls with WiFi** ##
|
||||
|
||||
**Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
|
||||
@@ -49,29 +40,56 @@ Every WiFi router already fills your space with radio waves. When people move, b
|
||||
<details>
|
||||
<summary><strong>RuView MetaHarness</strong> — guided operation for humans and AI agents</summary>
|
||||
|
||||
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides source-cited guidance, guarded Claude Code/Codex agents, deterministic verification, an honesty check for accuracy claims, and an explicitly granted OAuth-only Cognitum Spaces read.
|
||||
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides:
|
||||
- source-cited guidance;
|
||||
- device access for CSI nodes, radar and LiDAR;
|
||||
- firmware flashing with boot evidence;
|
||||
- one MCP server, usable over stdio or HTTP for ChatGPT, with a live console widget;
|
||||
- a Claude Code mod;
|
||||
- guarded Claude Code/Codex agents;
|
||||
- deterministic verification;
|
||||
- an honesty check for accuracy claims.
|
||||
|
||||
Companion packages: [`homecore`](harness/homecore/README.md) (Homecore developer metaharness) and [`@ruvnet/ruview-kernel`](harness/ruview-kernel/README.md) (the vitals pipeline as WASM).
|
||||
|
||||
```bash
|
||||
# Check the local setup and get source-cited guidance
|
||||
npx @ruvnet/ruview@0.4.0 doctor
|
||||
npx @ruvnet/ruview@0.4.0 guidance --topic sensing --query "model loading"
|
||||
npx @ruvnet/ruview@0.9.1 doctor
|
||||
npx @ruvnet/ruview@0.9.1 guidance --topic sensing --query "model loading"
|
||||
|
||||
# Hardware: what is plugged in, live CSI from your nodes, a 60 GHz radar kit
|
||||
npx @ruvnet/ruview@0.9.1 devices
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --watch # ESP32 + Realtek RAC1 nodes, live view
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
|
||||
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host <kit-ip>
|
||||
|
||||
# Agents: MCP over stdio, or over HTTP for ChatGPT (token-protected, read tools only)
|
||||
npx @ruvnet/ruview@0.9.1 mcp start
|
||||
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http
|
||||
|
||||
# Claude Code: a live sensing pane (/ruview), shipped in the package
|
||||
npx @ruvnet/ruview@0.9.1 mod
|
||||
|
||||
# Run a read-only RuView agent through Codex
|
||||
npx @ruvnet/ruview@0.4.0 agent run --host codex --repo . \
|
||||
npx @ruvnet/ruview@0.9.1 agent run --host codex --repo . \
|
||||
--prompt "Find the nearest tests and cite the source files"
|
||||
|
||||
# Search or verify the reviewed contributor brain
|
||||
npx @ruvnet/ruview@0.4.0 brain search --query "calibration"
|
||||
npx @ruvnet/ruview@0.4.0 brain verify --repo .
|
||||
|
||||
# Check claims, replay the deterministic proof, or expose the MCP server
|
||||
npx @ruvnet/ruview@0.4.0 claim-check --file REPORT.md
|
||||
npx @ruvnet/ruview@0.4.0 verify
|
||||
npx @ruvnet/ruview@0.4.0 spaces
|
||||
npx @ruvnet/ruview@0.4.0 mcp start
|
||||
# Check claims, replay the deterministic proof, search the reviewed brain
|
||||
npx @ruvnet/ruview@0.9.1 claim-check --file REPORT.md
|
||||
npx @ruvnet/ruview@0.9.1 verify
|
||||
npx @ruvnet/ruview@0.9.1 brain search --query "calibration"
|
||||
```
|
||||
|
||||
Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`; retrieved brain content is evidence, not authority.
|
||||
**Safety:**
|
||||
- Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`.
|
||||
- Hardware reads need the `device-access` grant.
|
||||
- Flashing and calibration never run over the HTTP transport.
|
||||
- Device-reported vitals are labelled as unvalidated.
|
||||
- Retrieved brain content is evidence, not authority.
|
||||
|
||||
A single `npx ruview` package bundling all of this is ready (ADR-376). It is waiting on npm to release the unscoped name.
|
||||
|
||||
Full walkthrough: [user guide → RuView npm toolkit](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -123,8 +141,11 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
|
||||
```bash
|
||||
# Option 1: Docker (simulated data, no hardware needed)
|
||||
docker pull ruvnet/wifi-densepose:latest
|
||||
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32) # required; the container exits 64 without it
|
||||
docker run -p 127.0.0.1:3000:3000 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
|
||||
# Open http://localhost:3000
|
||||
# ESP32 nodes in Docker also need a UDP source guard (RUVIEW_UDP_ALLOW);
|
||||
# see docs/user-guide.md "Receiving ESP32 frames in Docker".
|
||||
|
||||
# Option 2a: Live sensing with ESP32-S3 hardware ($9)
|
||||
# Flash firmware, provision WiFi, and start sensing:
|
||||
@@ -245,8 +266,8 @@ See the measured benchmarks, witness records, and one-command reproducibility ch
|
||||
|------|-------|---------|
|
||||
| **MM-Fi pose model (SOTA)** | [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) | 82.69% torso-PCK@20 (single) · 83.59% (ensemble+TTA) · 75K-param micro variant 74.30% |
|
||||
| **AetherArena benchmark Space** | [`ruvnet/aether-arena`](https://huggingface.co/spaces/ruvnet/aether-arena) | self-correcting, auditable MM-Fi leaderboard |
|
||||
| **Full MM-Fi study (honest picture)** | [`docs/benchmarks/mmfi-wifi-sensing-study.md`](docs/benchmarks/mmfi-wifi-sensing-study.md) | pose + action; zero-shot cross-subject ~64%, +~30 s in-room calibration → 72.2% |
|
||||
| **Efficiency frontier** | [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md) | SOTA-beating WiFi pose in a 20 KB int4 edge model |
|
||||
| **Full MM-Fi study (honest picture)** | [`docs/benchmarks/mmfi-wifi-sensing-study.md`](docs/benchmarks/mmfi-wifi-sensing-study.md) | pose + action; zero-shot cross-subject ~64%, labeled in-room calibration → 72.2% |
|
||||
| **Efficiency frontier** | [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md) | SOTA-beating MM-Fi pose in a ~37 KB int4 model; live ESP32 compatibility not established |
|
||||
| **Pretrained encoder** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) | 82.3% held-out temporal-triplet, 8 KB int4 |
|
||||
| **Reproducible proof (Trust Kill Switch)** | [`archive/v1/data/proof/verify.py`](archive/v1/data/proof/verify.py) + [`expected_features.sha256`](archive/v1/data/proof/expected_features.sha256) | one-command deterministic pipeline replay (SHA-256 of output vs published hash) |
|
||||
| **Benchmark-proof ADR** | [ADR-168](docs/adr/ADR-168-benchmark-proof.md) | how the numbers are produced and verified |
|
||||
@@ -488,12 +509,20 @@ Neural Network: processed signals → 17 body keypoints + vital signs + room mod
|
||||
Output: real-time pose, breathing, heart rate, room fingerprint, drift alerts
|
||||
```
|
||||
|
||||
No training cameras required — the [Self-Learning system (ADR-024)](docs/adr/ADR-024-contrastive-csi-embedding-model.md) bootstraps from raw WiFi data alone. [MERIDIAN (ADR-027)](docs/adr/ADR-027-cross-environment-domain-generalization.md) ensures the model works in any room, not just the one it trained in.
|
||||
The [Self-Learning system (ADR-024)](docs/adr/ADR-024-contrastive-csi-embedding-model.md) provides
|
||||
camera-free representation-learning components. Cross-room pose remains a separate, data-gated
|
||||
problem: [MERIDIAN (ADR-027)](docs/adr/ADR-027-cross-environment-domain-generalization.md) is
|
||||
**Proposed**, while the measured calibration reference requires labeled CSI/keypoint pairs and
|
||||
model-specific adapters. See the [model compatibility boundary](docs/user-guide.md#model-and-capture-compatibility).
|
||||
|
||||
---
|
||||
|
||||
## 🏢 Use Cases & Applications
|
||||
|
||||
> **Safety boundary:** these are research and prototype applications, not medical devices,
|
||||
> emergency systems, or safety-certified controls. Vital-sign and pose outputs require independent
|
||||
> validation on the exact hardware, room, subjects, and failure conditions before operational use.
|
||||
|
||||
WiFi sensing works anywhere WiFi exists. No new hardware in most cases — just software on existing access points or a $8 ESP32 add-on. Because there are no cameras, deployments avoid privacy regulations (GDPR video, HIPAA imaging) by design.
|
||||
|
||||
**Scaling:** Each AP distinguishes ~3-5 people (56 subcarriers). Multi-AP multiplies linearly — a 4-AP retail mesh covers ~15-20 occupants. No hard software limit; the practical ceiling is signal physics.
|
||||
@@ -528,7 +557,7 @@ WiFi sensing works anywhere WiFi exists. No new hardware in most cases — just
|
||||
| Use Case | What It Does | Hardware | Key Metric | Edge Module |
|
||||
|----------|-------------|----------|------------|-------------|
|
||||
| **Smart home automation** | Room-level presence triggers (lights, HVAC, music) that work through walls — no dead zones, no motion-sensor timeouts | 2-3 ESP32-S3 nodes ($24) | Through-wall range ~5m | [HVAC Presence](docs/edge-modules/building.md), [Lighting Zones](docs/edge-modules/building.md) |
|
||||
| **Fitness & sports** | Rep counting, posture correction, breathing cadence during exercise — no wearable, no camera in locker rooms | 3+ ESP32-S3 mesh | Pose: 17 keypoints | [Breathing Sync](docs/edge-modules/exotic.md), [Gait Analysis](docs/edge-modules/medical.md) |
|
||||
| **Fitness & sports research** | Explore motion and breathing cadence without a wearable or camera; reliable posture correction requires a validated compatible pose model | 3+ ESP32-S3 mesh + edge host | Prototype; no live S3 pose accuracy claim | [Breathing Sync](docs/edge-modules/exotic.md), [Gait Analysis](docs/edge-modules/medical.md) |
|
||||
| **Childcare & schools** | Naptime breathing monitoring, playground headcount, restricted-area alerts — privacy-safe for minors | 2-4 ESP32-S3 per zone | Breathing: ±1 BPM | [Sleep Apnea](docs/edge-modules/medical.md), [Perimeter Breach](docs/edge-modules/security.md) |
|
||||
| **Event venues & concerts** | Crowd density mapping, crush-risk detection via breathing compression, emergency evacuation flow tracking | Multi-AP mesh (4-8 APs) | Density per m² | [Customer Flow](docs/edge-modules/retail.md), [Panic Motion](docs/edge-modules/security.md) |
|
||||
| **Stadiums & arenas** | Section-level occupancy for dynamic pricing, concession staffing, emergency egress flow modeling | Enterprise AP grid | 15-20 per AP mesh | [Dwell Heatmap](docs/edge-modules/retail.md), [Queue Length](docs/edge-modules/retail.md) |
|
||||
@@ -695,7 +724,13 @@ claude --plugin-dir ./plugins/ruview
|
||||
|
||||
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
|
||||
|
||||
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.4.0`; the quick commands and fuller explanation are in the collapsed MetaHarness section near the top of this README and in [`harness/ruview/`](harness/ruview/README.md).
|
||||
**Live sensing pane (Claude Code mod):** the same marketplace also lists `ruview-live`, a Claude Code mod.
|
||||
- Install it with `/plugin install ruview-live@ruview`, or run `npx @ruvnet/ruview@0.9.1 mod` for its path inside the npm package.
|
||||
- `/ruview` opens a pane beside the transcript with your CSI nodes and radar, refreshed on a timer, plus a one-line status.
|
||||
- It is read-only.
|
||||
- Mods are early access; if `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. See [ADR-377](docs/adr/ADR-377-ruview-live-claude-code-mod.md).
|
||||
|
||||
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick commands and a fuller explanation are in the collapsed MetaHarness section near the top of this README, in [`harness/ruview/`](harness/ruview/README.md), and in the [user guide](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -720,6 +755,8 @@ Start with the user, build, and calibration guides; expand for the full referenc
|
||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
|
||||
| [CSI Frame Selection — Measured](docs/csi-frame-selection-measured.md) | Which rate gate actually buys cross-node pairing, measured on a nine-node ESP32-C6 fleet as a 2x2 factorial in three Latin-square blocks. Selecting frames by 802.11 `rx_seq` roughly doubled pairing (+31.7 pp) while mesh-time alignment bought +0.5 pp, inside noise. Includes the trade it makes, a prediction that turned out wrong, the limits of the measurement, and `run_arms.py` so the comparison can be re-run rather than taken on trust. |
|
||||
| [ESP32-C6 Firmware Runbook](firmware/esp32-csi-node/RUNBOOK.md) | What to run, in what order, and what to check: the three-file build that keeps OTA rollback compiled in, a five-key pre-flash gate, which flash regions OTA can and cannot reach, a single-board diagnostic flash with a revert path, and the tunables whose right value depends on your house rather than on the chip. |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# RuView Calibration Service (reference implementation)
|
||||
|
||||
Turn a **shared WiFi-CSI pose base model** into a room-specific one with a **30-second labeled
|
||||
calibration** and a **~11 KB per-room LoRA adapter**. This is the deployable resolution of the
|
||||
cross-subject / cross-environment generalization problem (full study: [ADR-150 §3.3–3.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)).
|
||||
Fit a room-specific **~11 KB LoRA adapter** for a shared WiFi-CSI pose base from a short **labeled
|
||||
capture**. This is a measured MM-Fi reference path for cross-subject / cross-environment adaptation
|
||||
(full study: [ADR-150 §3.3–3.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)); it is not proof of
|
||||
plug-and-play adaptation from a live ESP32 stream.
|
||||
|
||||
> **Not the proposed MERIDIAN fast path.** Both producers below require paired CSI and keypoint
|
||||
> labels, and their tensor shapes and adapter files are model-specific. ADR-027's automatic,
|
||||
> unlabeled 10-second MERIDIAN calibration remains **Proposed** and is not implemented as an
|
||||
> end-to-end deployment command.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -66,8 +72,8 @@ Adapters are **model-specific**. There are two calibration producers here:
|
||||
| `cog_calibrate.py` | cog **conv+MLP** (`pose_v1.safetensors`, 56×20) | `[N,56,20]` | `.safetensors` (`fc1.a`/`fc1.b`/`fc2.a`/`fc2.b`) | Rust `cog-pose-estimation run --adapter` |
|
||||
|
||||
```bash
|
||||
# Produce a cog-format per-room adapter for the deployed Rust pose engine:
|
||||
python cog_calibrate.py --base pose_v1.safetensors --data calib.npz --out room.safetensors
|
||||
# Produce a cog-format per-room adapter from X:[N,56,20], Y:[N,17,2]:
|
||||
python cog_calibrate.py --base pose_v1.safetensors --data cog-calib.npz --out room.safetensors
|
||||
# then in the cog runtime:
|
||||
cog-pose-estimation run --config <cfg> --adapter room.safetensors
|
||||
```
|
||||
|
||||
@@ -10,6 +10,39 @@ func main() {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// One connected-link RSSI sample for the Rust adapter. Keep the legacy
|
||||
// no-argument 10 Hz stream below unchanged for Python callers.
|
||||
if CommandLine.arguments.contains("--scan-once") {
|
||||
guard interface.powerOn(),
|
||||
let channel = interface.wlanChannel(),
|
||||
interface.rssiValue() < 0 else {
|
||||
fputs("WiFi is not connected\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let sample: [String: Any] = [
|
||||
// Always the connected link. macOS redacts ssid/bssid without
|
||||
// Location Services; rssi/noise/channel stay real, so mark the
|
||||
// sample as the connected link for the Rust adapter.
|
||||
"connected": true,
|
||||
"ssid": interface.ssid() ?? "",
|
||||
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
|
||||
"channel": channel.channelNumber,
|
||||
"rssi": interface.rssiValue(),
|
||||
"noise": interface.noiseMeasurement(),
|
||||
"timestamp": Date().timeIntervalSince1970,
|
||||
"tx_rate": interface.transmitRate()
|
||||
]
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: sample, options: [.sortedKeys])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write(Data([0x0a]))
|
||||
} catch {
|
||||
fputs("Could not encode WiFi sample: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Flush stdout automatically to prevent buffering issues with Python subprocess
|
||||
setbuf(stdout, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"endpoint": "fal-ai/flux-lora",
|
||||
"lora_url": "https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors",
|
||||
"prompt": "ruviewstyle room visualization, a living room with a glowing cyan pose skeleton overlay, wifi signal arcs radiating across the space, a vitals readout panel, dark tech aesthetic, synthetic sensor visualization",
|
||||
"scale": 1.0,
|
||||
"source_image_url": "https://v3b.fal.media/files/b/0aa8cf67/JH1qyC3GCKQ9ojkrAUXe9.png"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
@@ -37,6 +37,31 @@ services:
|
||||
# volumes: ["/path/to/models:/app/models"]
|
||||
# MODELS_DIR=/app/models
|
||||
- MODELS_DIR=${MODELS_DIR:-data/models}
|
||||
# Auth (issue #864). The entrypoint refuses to start (exit 64) unless
|
||||
# RUVIEW_API_TOKEN is set. Export it before `docker compose up`:
|
||||
# export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
# Bare names pass the host value through only when it is set.
|
||||
- RUVIEW_API_TOKEN
|
||||
# ESP32 UDP ingest (ADR-296, issue #2089). The UDP receiver defaults to
|
||||
# 127.0.0.1, which cannot receive from the published 5005/udp port.
|
||||
# Set ONE source guard and the entrypoint binds 0.0.0.0:
|
||||
# RUVIEW_UDP_ALLOW=<node-ip-or-cidr> restrict sources (preferred)
|
||||
# RUVIEW_UDP_INSECURE_LAN=true accept the spoofing risk
|
||||
# Docker Desktop and OrbStack on macOS rewrite the source address to the
|
||||
# bridge gateway, so a LAN-subnet allowlist drops every frame. There,
|
||||
# allow the gateway instead:
|
||||
# docker network inspect <project>_default \
|
||||
# --format '{{(index .IPAM.Config 0).Gateway}}'
|
||||
# RUVIEW_UDP_ALLOW=<gateway>/32
|
||||
# The gateway admits anything that reaches the published port, so keep
|
||||
# 5005/udp off untrusted networks.
|
||||
- RUVIEW_UDP_ALLOW
|
||||
- RUVIEW_UDP_INSECURE_LAN
|
||||
- RUVIEW_UDP_BIND
|
||||
# Browsing the UI from another machine also needs the host name or IP
|
||||
# in SENSING_ALLOWED_HOSTS (otherwise 421), and the TCP ports above
|
||||
# published beyond 127.0.0.1.
|
||||
- SENSING_ALLOWED_HOSTS
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
# Default is `auto`. Set CSI_SOURCE=simulated when you want
|
||||
# fake data tagged as such; never set it implicitly.
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
# RUVIEW_UDP_ALLOW / RUVIEW_UDP_INSECURE_LAN — ADR-296 UDP source guard.
|
||||
# Setting either makes the entrypoint bind UDP on 0.0.0.0 so
|
||||
# ESP32 frames on the published 5005/udp port can arrive.
|
||||
# RUVIEW_UDP_BIND — explicit UDP bind; overrides the choice above.
|
||||
set -e
|
||||
|
||||
# ── Issue #864: fail-closed on default posture ───────────────────────────────
|
||||
@@ -34,8 +38,8 @@ set -e
|
||||
#
|
||||
# Escape hatches (operator must opt in explicitly):
|
||||
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
|
||||
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
|
||||
# only safe on an isolated trust boundary.
|
||||
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 (or true/yes/on, any case) →
|
||||
# preserves the pre-fix behaviour; only safe on an isolated trust boundary.
|
||||
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
|
||||
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
|
||||
#
|
||||
@@ -45,7 +49,12 @@ set -e
|
||||
case "${1:-}" in
|
||||
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
|
||||
*)
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
|
||||
# Same spellings the sensing-server accepts for its boolean env flags.
|
||||
case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in
|
||||
1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) __allow_unauth=1 ;;
|
||||
*) __allow_unauth=0 ;;
|
||||
esac
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "$__allow_unauth" != "1" ]; then
|
||||
# If the operator hasn't overridden the bind, refuse outright on
|
||||
# the default 0.0.0.0. If they've nailed it to loopback (or a
|
||||
# specific private address they trust), let it run.
|
||||
@@ -97,6 +106,50 @@ esac
|
||||
# server binary so users can just pass flags:
|
||||
# docker run <image> --source esp32 --tick-ms 500
|
||||
if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
|
||||
# ── Issue #2089: UDP CSI ingest posture inside a container ───────────────
|
||||
# ADR-296 defaults the UDP receiver to 127.0.0.1. Inside a container that
|
||||
# bind can never receive from a published port, so no node ever appears.
|
||||
# We bind 0.0.0.0 only when the operator has configured an ADR-296 guard
|
||||
# (RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN); without one the server
|
||||
# would refuse a routable bind, so we keep loopback and say why instead.
|
||||
# An explicit RUVIEW_UDP_BIND or --udp-bind always wins.
|
||||
# The binary parses RUVIEW_UDP_INSECURE_LAN itself (1/yes/on/true, any
|
||||
# case); here we only need to know whether it is truthy to pick the bind.
|
||||
__insecure_lan=0
|
||||
case "$(printf '%s' "${RUVIEW_UDP_INSECURE_LAN:-}" | tr 'A-Z' 'a-z')" in
|
||||
1|true|yes|on) __insecure_lan=1 ;;
|
||||
esac
|
||||
__udp_bind_arg=0
|
||||
__udp_guard_arg=0
|
||||
for __arg in "$@"; do
|
||||
case "$__arg" in
|
||||
--udp-bind|--udp-bind=*) __udp_bind_arg=1 ;;
|
||||
--udp-allow|--udp-allow=*|--udp-insecure-lan) __udp_guard_arg=1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "${RUVIEW_UDP_BIND:-}" ] && [ "$__udp_bind_arg" = 0 ]; then
|
||||
if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "$__insecure_lan" = 1 ] \
|
||||
|| [ "$__udp_guard_arg" = 1 ]; then
|
||||
export RUVIEW_UDP_BIND=0.0.0.0
|
||||
echo "[entrypoint] UDP CSI receiver: binding 0.0.0.0 inside the container" >&2
|
||||
echo "[entrypoint] (a source guard is set: RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN)." >&2
|
||||
else
|
||||
case "${CSI_SOURCE:-auto}" in
|
||||
simulated|simulate) ;;
|
||||
*)
|
||||
echo "[entrypoint] NOTE: UDP CSI receiver stays on 127.0.0.1 (ADR-296 default)." >&2
|
||||
echo "[entrypoint] Inside a container that bind cannot receive ESP32 frames from" >&2
|
||||
echo "[entrypoint] a published 5005/udp port, so no node will appear. To ingest:" >&2
|
||||
echo "[entrypoint] -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> (restrict sources), or" >&2
|
||||
echo "[entrypoint] -e RUVIEW_UDP_INSECURE_LAN=true (accept spoofing risk)" >&2
|
||||
echo "[entrypoint] Docker Desktop/OrbStack on macOS rewrite the source to the bridge" >&2
|
||||
echo "[entrypoint] gateway, so the allowlist must name <gateway>/32, not the LAN subnet." >&2
|
||||
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/2089" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
set -- /app/sensing-server \
|
||||
--source "${CSI_SOURCE:-auto}" \
|
||||
--tick-ms 100 \
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-03-01 |
|
||||
| **Updated** | 2026-10-01: macOS 27 also redacts SSID without Location Services; the connected link is kept with a synthetic id, and `auto`+wifi keeps UDP bound (see Amendment 1) |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **ORCA** — OS-native Radio Channel Acquisition |
|
||||
| **Relates to** | ADR-013 (Feature-Level Sensing Commodity Gear), ADR-022 (Windows WiFi Enhanced Fidelity), ADR-014 (SOTA Signal Processing), ADR-018 (ESP32 Dev Implementation) |
|
||||
@@ -313,3 +314,22 @@ All verification on Mac Mini (M2 Pro, macOS 26.3).
|
||||
- ADR-022: Windows WiFi Enhanced Fidelity (analogous platform adapter)
|
||||
- ADR-013: Feature-Level Sensing from Commodity Gear
|
||||
- Issue [#56](https://github.com/ruvnet/wifi-densepose/issues/56): macOS support request
|
||||
|
||||
---
|
||||
|
||||
## Amendment 1 (2026-10-01): SSID is also redacted; keep the connected link
|
||||
|
||||
**Observed on macOS 27 (M-series Mac, CoreWLAN, unsigned CLI helper).** Without Location Services, `mac_wifi --scan-once` returns `"ssid":""` and `"bssid":"00:00:00:00:00:00"`, while `rssi`, `noise`, `channel` and `tx_rate` are real. The §1 assumption that "SSID + RSSI + channel" survive no longer holds. An unsigned CLI helper never calls `CLLocationManager`, so it never appears in Location Services and cannot be granted access.
|
||||
|
||||
Before this amendment, `resolve_bssid` abstained when both identities were blank (to avoid merging unrelated networks), so every macOS scan was dropped and `--source wifi` produced nothing, with no error.
|
||||
|
||||
**Change.**
|
||||
1. `--scan-once` marks its single sample `"connected": true`. It is always the one connected link, so it cannot collide with another network.
|
||||
2. A redacted sample marked `connected` gets a stable, locally administered synthetic BSSID derived from a reserved key plus the channel. The SSID stays empty; none is invented. Unmarked redacted lines still abstain.
|
||||
3. `--source auto` that resolves to host WiFi now also binds the UDP receiver, and `wifi_task` yields while the source is a live `esp32`. ESP32 nodes that start after the 2 s boot probe still promote the source, matching the #1004 rule for the simulator.
|
||||
4. A missing helper logs one clear error with the build command instead of a warning on every tick.
|
||||
|
||||
**Measured (2026-10-01, this change):** `--source wifi` → source `wifi:unnamed`, sensing ticks advance. `--source auto` on a quiet port → `wifi` with `udp_receiver=true`; relaying real ADR-018 frames promoted the source to `esp32`, with all 5 nodes listed.
|
||||
|
||||
**Still open:** real BSSID/SSID would need a signed `.app` helper with `NSLocationWhenInUseUsageDescription` that requests authorization. The helper still lives under `archive/v1` with no build step.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-03-02 |
|
||||
| **Updated** | 2026-10-04 — multistatic fusion combines phase across nodes only when every frame declares the same shared phase reference; independent ESP32 nodes and the amplitude-only server bridge fuse amplitude only and report the reason (`phase_fusion` in `/health/ready`, issue #1752). |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **RuvSense** -- RuVector-Enhanced Sensing for Multistatic Fidelity |
|
||||
| **Relates to** | ADR-012 (ESP32 Mesh), ADR-014 (SOTA Signal Processing), ADR-016 (RuVector Training), ADR-017 (RuVector Signal+MAT), ADR-018 (ESP32 Implementation), ADR-024 (AETHER Embeddings), ADR-026 (Survivor Track Lifecycle), ADR-027 (MERIDIAN Generalization) |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | **Accepted** (MQTT track P1–P7 + P8a + P9 + P10 shipped 2026-05-23 in PR #778, 410 lib tests, witness bundle VERIFIED) / **Proposed** (Matter SDK wiring P8b deferred to v0.7.1 per §9.10) |
|
||||
| **Date** | 2026-05-23 |
|
||||
| **Date** | 2026-05-23 (amended 2026-10-02: P4.5 status note, §11) |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HA-DISCO** (MQTT) + **HA-FABRIC** (Matter) + **HA-MIND** (semantic primitives) |
|
||||
| **Relates to** | ADR-018 (CSI binary frame format), ADR-021 (ESP32 vitals), ADR-031 (RuView sensing-first), ADR-039 (edge vitals packet 0xC511_0002), ADR-079 (camera ground-truth), ADR-103 (cog-person-count), ADR-110 (ESP32-C6 firmware), ADR-114 (cog-quantum-vitals) |
|
||||
@@ -440,7 +440,7 @@ This means: **adding a new primitive is one file change**. No MQTT schema rev, n
|
||||
| **P2** | HA discovery message emitter. New module `mqtt_discovery.rs`. Emits all entity `config` topics on connect + every `--mqtt-refresh-secs`. Schema-validated against HA's published JSON schema. | pending |
|
||||
| **P3** | State publication. Subscribe to internal `tokio::broadcast` channel (the one `tx.send(json)` writes to on line 3983 of `main.rs`). Translate `edge_vitals` / `sensing_update` / `pose_data` messages into per-entity state payloads. Apply rate-limit + privacy-mode filters. | pending |
|
||||
| **P4** | Integration tests: dockerised mosquitto in CI (extend `.github/workflows/firmware-qemu.yml` pattern), schema-validate every emitted config against HA's `homeassistant/components/mqtt` JSON schemas (pin to a tested HA version). Add a smoke test that brings up sensing-server in `--source mock --mqtt`, subscribes with `paho-mqtt` test client, asserts on entity creation. | pending |
|
||||
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | pending |
|
||||
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | partial — 6 of 10 primitives on the live MQTT publisher; see §11 |
|
||||
| **P5** | Docs: new `docs/integrations/home-assistant.md` with screenshots of the HA UI after auto-discovery completes, example HA dashboard YAML (Lovelace card configs), 8 starter blueprints from §3.12.2 (distress notify, wake routine, hallway dim, elderly anomaly alert, meeting lights, bathroom fan, fall-risk escalate, auto-arm security), and the raw-channel example automations: "turn on hall light when presence ON", "send notification on fall_detected event", "log HR/BR to InfluxDB". | pending |
|
||||
| **P6** | Ship `--mqtt` in the next sensing-server release (target: v0.7.0). Demo end-to-end on `cognitum-v0` against a Mosquitto add-on running on a Home Assistant OS install. Update README hardware-options table with "Works with Home Assistant" badge. | pending |
|
||||
| **P7** | Matter Bridge spike: build a throwaway prototype with `matter-rs` exposing one `OccupancySensor` endpoint + one `GenericSwitch` for fall. Pair against Apple Home, Google Home, and HA's Matter integration. Decision gate: if pairing works on all three, proceed to P8; if blocked, switch to chip-tool FFI and re-spike. | pending |
|
||||
@@ -667,4 +667,57 @@ Empty as of 2026-05-23. New questions discovered during implementation will be f
|
||||
|
||||
---
|
||||
|
||||
## 11. Status note: P4.5 semantic states on the live publisher (2026-10-02)
|
||||
|
||||
Issues [#2085](https://github.com/ruvnet/RuView/issues/2085) and [#2093](https://github.com/ruvnet/RuView/issues/2093) found that discovery announced 20 entities per device while only 6 ever received state, that `presence/state` was published on every CSI frame, and that HA device ids changed on every restart. The FSMs in `crate::semantic` existed but had no caller outside their tests. This note records what is now wired and what is not.
|
||||
|
||||
### 11.1 What changed
|
||||
|
||||
- **Semantic primitives run on the live path.** `mqtt::planner` holds one `SemanticBus` per node, sampled at 1 Hz (the rate the FSM constants assume). The bus feeds only the MQTT publisher. Matter does not consume it yet, so the "output broadcast channel shared with Matter" in P4.5 is not built.
|
||||
- **Announce only what has a source.** Six primitives are announced: `room_active`, `no_movement`, `elderly_inactivity_anomaly`, `someone_sleeping`, `possible_distress` and `fall_risk_elevated`. Four are not: `meeting_in_progress` needs a 1–20 % motion level, but per-node motion is a three-level classification; `bathroom_occupied`, `bed_exit` and `multi_room_transition` need zones, and the broadcast carries none (the §3.12.5 zone file is not implemented). For the same reason `zone_occupancy` (§3.1) and `pose` are not announced: `pose_keypoints` is never populated on the broadcast, and `persons[].keypoints` are synthesized.
|
||||
- **Per-entity availability (§3.6).** An announced entity is `online` only while its input exists: vitals while the calibration-gated estimate is published, `fall` while the node sends `edge_vitals`, `motion_energy` and `rssi` while present, and semantic primitives after the 60 s warmup (sleeping and distress also need breathing or heart rate, fall risk needs the fall detector). Otherwise it is `offline` and no state is sent. Transitions publish at once; all availability topics are re-sent every 30 s.
|
||||
- **No default values.** `motion_energy` was always `0.0` (MEASURED: 176 of 176 samples) because the bridge never set it. It now carries the node's `motion_band_power`, or the room value, or nothing. `fall` is driven by the rising edge of `edge_vitals.fall_detected`. Only `sensing_update` produces snapshots, which removes the phantom aggregate device that `edge_vitals` frames created. `present_still` now maps to 0 % motion instead of 100 %.
|
||||
- **Rates (§3.7).** Binary states publish on change, plus a 60 s retained heartbeat, as §3.5 already specified. Sensors keep their configured rates.
|
||||
- **Stable device identity (§3.8).** The default client id is now `wifi-densepose-<8 hex>`, persisted in `<data-dir>/mqtt_client_id`. §3.8 specified `wifi-densepose-<hostname>`; a persisted random id avoids a hostname dependency and survives hostname changes. `--mqtt-client-id` still overrides it.
|
||||
|
||||
### 11.2 Evidence
|
||||
|
||||
Unit tests (CODE-DERIVED, run with `cargo test -p wifi-densepose-sensing-server --features mqtt --lib -- mqtt:: semantic::`): `mqtt::planner::tests` covers the entity → state mapping, unavailable-without-source, warmup, on-change with heartbeat at a 73 frame/s input, sensor rates, reset and privacy mode. `mqtt::bridge::tests` covers message-type filtering, fall edges and motion energy. `mqtt::config::tests` covers client-id persistence across two "restarts" in a temp dir, override and malformed-file recovery.
|
||||
|
||||
Live measurement (MEASURED, 2026-10-02): one ESP32 node streaming CSI and `edge_vitals` over UDP to a macOS host; `sensing-server --source esp32 --mqtt` against `eclipse-mosquitto:2` in Docker on a non-default port; every topic captured with a `paho-mqtt` subscriber on `#`. "Before" is `03a89793` (main), "after" is this change, built `--release --features mqtt`, same command and window.
|
||||
|
||||
```bash
|
||||
# Broker: mosquitto.conf = "listener 1883 0.0.0.0 / allow_anonymous true / persistence false"
|
||||
docker run -d --rm -p 127.0.0.1:18830:1883 \
|
||||
-v "$PWD/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" eclipse-mosquitto:2
|
||||
# Run A (180 s, then SIGTERM) and run B (restart, 45 s) share one --data-dir.
|
||||
# A paho-mqtt subscriber on '#' starts 2 s before each run.
|
||||
RUVIEW_API_TOKEN=<random> sensing-server --source esp32 --udp-port 5006 \
|
||||
--udp-bind 0.0.0.0 --udp-allow <node-subnet> --http-port 3000 --ws-port 3001 \
|
||||
--no-mdns --data-dir <dir> --mqtt --mqtt-host 127.0.0.1 --mqtt-port 18830
|
||||
```
|
||||
|
||||
| Measure (180 s window unless noted) | Before (`03a89793`) | After |
|
||||
|---|---|---|
|
||||
| HA devices announced in run A | 2 (one phantom aggregate + `-node1`) | 1 (`-node1`) |
|
||||
| Entities announced per device | 20 | 15 |
|
||||
| Entities that received state (node device) | 6 | 10 |
|
||||
| Announced entities without state that still reported `online` | 14 | 1 (`fall`: an event, and no fall occurred) |
|
||||
| `presence/state` messages (node device) | 6,814 (37.9 / s) | 4 (one per change, plus 60 s heartbeat) |
|
||||
| `motion_energy` values | `0.0` in 176 of 176 | node band power, 177 samples |
|
||||
| Device id, run A → run B (restart, same `--data-dir`) | `wifi-densepose-<pid A>` → `<pid B>` (4 devices on the broker after B) | same persisted id in both runs (1 device) |
|
||||
| `offline` published on SIGTERM | no | no (unchanged; see 11.3) |
|
||||
| Same, plus `--privacy-mode` (75 s, fresh `--data-dir`) | — | 13 entities announced; no `heart_rate`, `breathing_rate` or `pose` topic for that device |
|
||||
|
||||
After the change, five announced entities had no state: `heart_rate` and `breathing_rate`, because no calibration had been run; `someone_sleeping` and `possible_distress`, which depend on them; and `fall`, which is an event and no fall occurred. The first four were `offline`. `fall` was `online`, because the node sent `edge_vitals`. Before the change all 14 entities without state still reported `online`. Only one node was streaming during this measurement. #2085 used five nodes and measured about 73 `presence` messages per second per node.
|
||||
|
||||
### 11.3 Not done
|
||||
|
||||
- Semantic precision/recall on real captures. The figures in `semantic-primitives-metrics.md` have no reproducer in the repository and stay CLAIMED.
|
||||
- Zone and threshold files, `--semantic*` / `--no-semantic` flags on `sensing-server`, the `reason` attribute (§3.12.4), and `semantic_events.jsonl`.
|
||||
- MQTT last-will and offline-on-shutdown (§3.6). The server registers no LWT and SIGTERM does not publish `offline`, so availability stays as last published after the server stops.
|
||||
- Matter consumption of the semantic states.
|
||||
|
||||
---
|
||||
|
||||
*ADR-115 is the integration story that turns RuView from "another sensing platform" into "drop-in upgrade for any HA install **and** any Matter-controller home." MQTT carries the rich, differentiated telemetry; Matter carries the standardised subset across every controller ecosystem. Numbers 111 and 112 remain reserved per the project ADR-numbering policy.*
|
||||
@@ -139,6 +139,32 @@ Implement the §3.3 mapping: `effective_class → PrivacyClass`, `cog-ha-matter`
|
||||
Add an opt-in `/ws/field` endpoint (or a `field_events` array on `SensingUpdate` behind a flag) carrying the signed `FieldEvent` + a privacy badge. Add an ingest route to `rufield-viewer` (it has none today — `server.rs:63-72`) so it can replay RuView's live feed instead of only `SyntheticSim`. **Gate:** a WS integration test asserting a connected client receives a privacy-badged, signature-verifiable `FieldEvent`; a viewer test asserting the new ingest route renders a live event. The `cognitum` appliance can speak RuField by consuming this endpoint (it already runs `ruview-vitals-worker`); deferred to its own ADR.
|
||||
|
||||
**P4 — fusion composition + multi-modality (ARCHITECTURE, optional).**
|
||||
|
||||
> **Update — second modality landed as a library.** Open question 5 below asked
|
||||
> whether the second modality should be `rvcsi`. It is **ultrasonic**, because
|
||||
> the cost collapsed: `rufield-adapters` now ships `UltrasonicReplayAdapter`,
|
||||
> the first adapter for `Modality::Ultrasonic` (registry code 7, empty since
|
||||
> v0.1), which parses, validates and signs [BatVu](https://github.com/ruvnet/batvu)
|
||||
> range profiles upstream. RuView only has to decide what it will put on a wire.
|
||||
>
|
||||
> `wifi-densepose-rufield::ultrasonic` is that decision, and it is expressed
|
||||
> structurally: the adapter is configured for its 32-bin coarse output (`P1`,
|
||||
> egress-safe) rather than its full per-bin frame (`P0`, edge-local), because a
|
||||
> consumer cannot un-coarsen a coarse profile whereas a runtime check can be
|
||||
> reordered. The `network_egress_allowed` gate still runs and is asserted to
|
||||
> drop nothing.
|
||||
>
|
||||
> Gates: `tests/ultrasonic_gates.rs`, 12 tests — round-trip, signature-verify,
|
||||
> fusion ingest, P1 on **both** tensor and observation, structural unreachability
|
||||
> of P4/P5, trust-tier refusal in both directions, determinism, whole-file
|
||||
> rejection of a malformed recording. Plus one asserting the honest negative
|
||||
> result: **an ultrasonic scan produces no fused inferences at all**, because the
|
||||
> adapter declines to populate `presence` (one transducer pair cannot tell a
|
||||
> person from a coat on a chair) and the engine's feature vocabulary is entirely
|
||||
> statements about a body. RuField v0.1 has no predicate for static geometry.
|
||||
>
|
||||
> Not wired into the running server. P1 shipped as a library before P3 wired it
|
||||
> in; this follows the same staging.
|
||||
Wire a second modality (cheapest: an `rvcsi`-sourced event, or recorded mmWave) into `RuFieldFusion` alongside the WiFi event, proving cross-modality fusion above ruvsense. **Gate:** a fusion test with two modalities producing ≥1 cross-modal inference, with provenance coverage 100%.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,10 +66,64 @@ hardware claims.
|
||||
timestamps, sequence behavior, calibration, maximum size, and report rate.
|
||||
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
|
||||
|
||||
## Amendment 1 — vendor CSI on MT7981, still unvalidated
|
||||
|
||||
- **Date**: 2026-09-19, recorded here 2026-10-01
|
||||
- **Status**: proposed. One board model (two units). The hardware gates below are still open.
|
||||
|
||||
The 2026-07-18 decision said the public `mt76` tree has no supported CSI export,
|
||||
and that a firmware change might be required before physical CSI exists. Both
|
||||
statements need a narrower reading.
|
||||
|
||||
Mainline `openwrt/mt76` still has no CSI export. MediaTek's public OpenWrt feed
|
||||
does: vendor id `0x0ce7`, MCU event `0xc2`, patch
|
||||
`1001-wifi-mt76-mt7915-csi-implement-csi-support`. On a Wavlink WL-WN586X3
|
||||
Rev A (MT7981BA + MT7976CN + MT7531AE) that patch, rebased onto the `mt76`
|
||||
revision pinned by OpenWrt 24.10.8, produced complex samples without a firmware
|
||||
blob swap. The `0xc2` hook is already in the MT7981 firmware that the OpenWrt
|
||||
24.10.8 image carries. The
|
||||
rebased patch and the two userspace fixes are in `firmware/openwrt-wn586x3/`.
|
||||
|
||||
What the first capture was: one record, 2.4 GHz, 20 MHz, legacy OFDM, 64
|
||||
subcarriers, a single chain. CSI arrived only while a client sent frames.
|
||||
The rate is client traffic, not a sensor sample clock. The capture is not in
|
||||
this repository.
|
||||
|
||||
What it was not:
|
||||
|
||||
- Not a calibration, a sequence check, a timestamp check, or a repeatability run.
|
||||
The label on the wire is `mediatek:physical-unvalidated`. Plain `mediatek` stays
|
||||
reserved for a frame that has passed those gates.
|
||||
- Not a 4x4 array. Each radio is two chains. The four antennas are a 2.4 GHz pair
|
||||
and a 5 GHz pair. Two routers are two receivers. Cross-box phase is not a
|
||||
shared local oscillator, so it is not a beam.
|
||||
- Not the OpenWrt One or the BPI-R3. Those remain the upstream-friendly targets
|
||||
in the original decision. The WN586X3 is the board this path was actually run on.
|
||||
|
||||
`wifi-densepose-mtk-bridge` translates `mt76-vendor` JSON, or an MtkCSIdump
|
||||
datagram, into ADR-267 MTC1 and sends it to the sensing server. The server keeps
|
||||
one snapshot and a bounded ring of per-subcarrier amplitude and phase per
|
||||
`device_id`. It runs `mediatek-amplitude-heuristic-v0` on mean and peak amplitude,
|
||||
and `activity-index-v0` on per-subcarrier fluctuation. Both are `CLAIMED`. Neither
|
||||
is the ESP32 vital-sign path.
|
||||
|
||||
One measured property supports further work, and nothing more. Offline, on six
|
||||
local dump captures from two units (2.4 GHz, HT20, chains rx0 and rx1 of transmit
|
||||
stream 0), the cross-chain phase `arg(H0·conj(H1))`, summed over subcarriers,
|
||||
moved 0.014-0.039 rad (p50) between adjacent frames for well-sampled
|
||||
transmitters. Raw per-chain phase moved about 1.56 rad (p50), which is what
|
||||
uniformly random phase gives (MEASURED; reproducer
|
||||
`scripts/mediatek-csi-phase-stability.py`). That shows a stable observable inside
|
||||
one radio. It does not show that the observable tracks people.
|
||||
|
||||
The operator guide is [`docs/mediatek-router-csi.md`](../mediatek-router-csi.md).
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
|
||||
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
|
||||
- [ADR-267: MediaTek MIMO CSI wire protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
|
||||
- [MediaTek router CSI guide](../mediatek-router-csi.md)
|
||||
- [upstream mt76](https://github.com/openwrt/mt76)
|
||||
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
|
||||
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
|
||||
@@ -34,6 +34,81 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
|
||||
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
|
||||
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
|
||||
|
||||
## Amendment 1 — PPDU types `Legacy` and `Unknown`
|
||||
|
||||
- **Date**: 2026-09-19
|
||||
- **Status**: proposed. Demonstrated on one WL-WN586X3 capture. Not a maintainer acceptance.
|
||||
- **Scope**: `PpduType` discriminants only. The 72-byte header layout, CRC,
|
||||
payload ordering, flags, and caps are unchanged.
|
||||
|
||||
### What changed
|
||||
|
||||
`PpduType` gains two variants:
|
||||
|
||||
| Value | Name | Meaning |
|
||||
|---|---|---|
|
||||
| 6 | `Legacy` | Pre-HT: 802.11b CCK or 802.11a/g OFDM |
|
||||
| 7 | `Unknown` | A PPDU format the producer could not classify |
|
||||
|
||||
Values 1-5 (`Ht`, `Vht`, `HeSu`, `HeMu`, `Eht`) are untouched.
|
||||
|
||||
### Why
|
||||
|
||||
The original set assumed every CSI report would be HT or later. The first real
|
||||
MT7981 capture disproved that. A Wavlink WL-WN586X3 (MT7981B + MT7976C) running
|
||||
an OpenWrt 24.10.8 image built with the patches in `firmware/openwrt-wn586x3/`
|
||||
reported `rx_mode = 1` (`MT_PHY_TYPE_OFDM`) for a client associated on 2.4 GHz
|
||||
channel 6 at 20 MHz. The bridge had no way to express it and dropped the frame.
|
||||
|
||||
That was a real data loss, not a corner case. MediaTek's CSI driver treats legacy
|
||||
OFDM as a first-class mode: `mt7915_vendor_csi_tone_mask` skips only
|
||||
`MT_PHY_TYPE_CCK`, and its `mode_map` is keyed by `MT_PHY_TYPE_OFDM`, `_HT`,
|
||||
`_VHT` and `_HE_SU`, giving OFDM its own tone-mask group
|
||||
(`1001-wifi-mt76-mt7915-csi-implement-csi-support.patch:1070-1088`). Association
|
||||
and management exchanges are legacy-rate by design, so a sensing deployment will
|
||||
see these continuously.
|
||||
|
||||
`Unknown` exists so the enum's gaps (5-7 and 12 in `mt76_phy_type`) and future
|
||||
modes are carried explicitly rather than guessed at or dropped. It also covers
|
||||
transports that omit the field: the MtkCSIdump UDP datagram carries no `rx_mode`,
|
||||
and reporting `Unknown` there is honest where defaulting to a concrete format was
|
||||
not.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- **Forward, not backward.** A decoder built before this amendment rejects
|
||||
discriminants 6 and 7 with `UnknownPpduType`, because the original parser
|
||||
rejects unknown types by design. `TryFrom<u8>` returns `Err` rather than
|
||||
panicking, so an old decoder fails safely — it just drops legacy frames, and
|
||||
does so silently. **Rebuild MTC1 consumers after this codec change.**
|
||||
- **Scope is narrower than "the workspace".** `wifi-densepose-hardware` defines
|
||||
three independent `PpduType` enums — `csi_frame.rs` (ESP32),
|
||||
`qualcomm_csi.rs`, and `mediatek_csi.rs` — and this amendment touches only the
|
||||
MediaTek one. Neither of the others changes, so no other vendor bridge is
|
||||
affected. The complete set of `mediatek_csi::PpduType` consumers is:
|
||||
`wifi-densepose-hardware`'s own `mediatek-csi-sim` binary,
|
||||
`wifi-densepose-mtk-bridge`, and `wifi-densepose-sensing-server`, which renders the
|
||||
value with `{:?}` and so needed no code change. In particular the
|
||||
`ppdu_type > 3` bound in `bootstrap_baseline.rs` is fed from the **ESP32**
|
||||
enum via `CsiGridKey::from_frame(&Esp32Frame)`, not from MediaTek frames, so
|
||||
`Legacy` and `Unknown` cannot reach it.
|
||||
- Because the variants were added rather than renumbered, and Rust requires
|
||||
exhaustive matches, the compiler locates any consumer that must be updated.
|
||||
Nothing outside the files above needed one.
|
||||
- Producers must emit 6 only for frames that genuinely are pre-HT, and 7 only
|
||||
where the format is truly unclassified. Neither is a dumping ground for a
|
||||
mapping the producer has not done.
|
||||
- The mapping from `rx_mode` to `PpduType` is now infallible, so no MTC1 producer
|
||||
may drop a frame on PPDU grounds.
|
||||
- Unchanged: synthetic provenance still cannot be cleared by downstream code.
|
||||
|
||||
### Observed dimensions
|
||||
|
||||
The same capture assembled to 1x1x64. That is the client's behaviour, not a
|
||||
protocol limit: the handset sent single-stream frames to a 2.4 GHz 20 MHz AP.
|
||||
Expect 1x2 or 2x2 on 5 GHz, and size consumers for the chipset profile's
|
||||
`max_chains` rather than for what one capture happened to show.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
@@ -59,3 +134,8 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
|
||||
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
|
||||
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
|
||||
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
|
||||
- Amendment 1 implementation: `v2/crates/wifi-densepose-hardware/src/mediatek_csi.rs`
|
||||
(codec) and `v2/crates/wifi-densepose-mtk-bridge` (`ppdu_from_rx_mode`). The
|
||||
motivating capture is a lab record and is not in this tree: it contains a
|
||||
client transmitter address and raw CSI. The in-repo check is a fabricated
|
||||
legacy-OFDM record in `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`.
|
||||
@@ -0,0 +1,218 @@
|
||||
# ADR-323: RTL8721Dx (Realtek AmebaDplus) CSI Wire Protocol
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-09-15
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: realtek, ameba, amebadplus, rtl8721dx, csi, protocol, rust, udp
|
||||
|
||||
## Context
|
||||
|
||||
Unlike the MediaTek/Qualcomm CSI paths (ADR-266..269), which are simulator-first
|
||||
because no supported public vendor interface exports complex CSI, Realtek's
|
||||
official Ameba RTOS SDK (`Ameba-AIoT/ameba-rtos`, Apache-2.0) documents and
|
||||
exposes a real, callback-driven CSI API for the RTL8721Dx ("AmebaDplus")
|
||||
family: `wifi_csi_config()` plus the `RTW_EVENT_CSI_DONE` event delivering a
|
||||
`struct rtw_event_csi_report_info` (per-tone signed I/Q, 8-bit or 16-bit
|
||||
precision, selectable tone decimation, passive or active/triggered capture).
|
||||
This has been confirmed directly against SDK source
|
||||
(`component/wifi/api/wifi_api_event.h`) and against the official
|
||||
`example/wifi/wifi_csi` example, and validated on real PKM8721DAF-C13-F10
|
||||
hardware (build+flash+boot confirmed on branch `feat/rtl8721dx-csi-bringup`).
|
||||
|
||||
The RTL8721Dx is a 1x1 WiFi 4 part — single TX/RX chain, not MIMO — so the
|
||||
MediaTek/Qualcomm wire format (Tx/Rx dimensioned matrices, chipset chain
|
||||
counts) does not fit. The vendor SDK's raw struct is also not a safe host
|
||||
wire format as-is: it has no CRC, a C flexible-array-member payload, and
|
||||
fields sized for firmware convenience (e.g. a wrapping `u32` microsecond
|
||||
timestamp) rather than network robustness.
|
||||
|
||||
Per the existing repo convention (ESP32/ADR-018, MediaTek/ADR-266-267,
|
||||
Qualcomm/ADR-268-269, RTL8720F radar/ADR-263-264), each vendor CSI/radar
|
||||
source gets its own self-delimiting envelope and its own Rust module pair
|
||||
(`wifi-densepose-hardware` for framing+codec+simulator,
|
||||
`wifi-densepose-sensing-server` for the bounded JSON-safe snapshot), never a
|
||||
shared "generic vendor CSI" struct that would either lose vendor-specific
|
||||
fields or invite magic-space collisions. `SourceKind::Realtek` (value 2) is
|
||||
already assigned to the RTL8720F FMCW radar path (ADR-263/264) and must not
|
||||
be reused for CSI.
|
||||
|
||||
## Decision
|
||||
|
||||
Define `RAC1` ("Realtek Ameba CSI", version 1) as a little-endian,
|
||||
self-delimiting envelope, magic `0x3143_4152` (distinct from `MTC1`
|
||||
`0x3143544d`, `QCS1` 0x31535143, `RTR1` 0x31525452, and all ADR-018
|
||||
`0xC511000x` siblings):
|
||||
|
||||
Fixed 49-byte header (explicit field-by-field serialization, no `#[repr(C)]`
|
||||
padding, matching the MediaTek/Qualcomm modules' style):
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| magic | u32 LE | `0x3143_4152` |
|
||||
| version | u8 | 1 |
|
||||
| header_len | u16 LE | 49 |
|
||||
| frame_len | u32 LE | header + payload + 4-byte CRC |
|
||||
| node_id | u8 | disambiguates multi-board illuminator/receiver deployments; 0 = unset |
|
||||
| csi_mode | u8 | 0=passive, 1=active, 2=board-to-board (`CsiMode`) |
|
||||
| sequence | u32 LE | from firmware `csi_sequence` |
|
||||
| timestamp_us | u32 LE | from firmware `hw_assigned_timestamp` (hardware-clock microseconds, wraps ~71 min — consumers must not assume monotonic across a capture longer than that) |
|
||||
| peer_mac | [u8; 6] | firmware `mac_addr` |
|
||||
| trig_mac | [u8; 6] | firmware `trig_addr` |
|
||||
| channel | u8 | operating channel |
|
||||
| bandwidth | u8 | 0=20MHz, 1=40MHz |
|
||||
| rx_rate | u8 | firmware `rx_rate` |
|
||||
| protocol_mode | u8 | 0=OFDM,1=HT,2=VHT,3=HE (`ProtocolMode`) |
|
||||
| num_sub_carrier | u16 LE | tone count in payload |
|
||||
| num_bit_per_tone | u8 | 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q) |
|
||||
| decimation | u8 | tone group (1/2/4/8 — every Nth tone) |
|
||||
| rssi_dbm | i8 | single RF chain (1x1 hardware) |
|
||||
| rxsc | u8 | sub-20 MHz channel used |
|
||||
| csi_valid | u8 | 0/1 from firmware |
|
||||
| flags | u8 | bit 0 = SYNTHETIC (simulator provenance, never clearable downstream) |
|
||||
| payload_len | u32 LE | bytes, == `num_sub_carrier * (num_bit_per_tone / 8)` |
|
||||
|
||||
Payload: `num_sub_carrier` consecutive (I, Q) tone pairs, each
|
||||
`num_bit_per_tone / 16` bytes per component (1 byte/component when
|
||||
`num_bit_per_tone == 16`, 2 bytes/component little-endian signed when `== 32`)
|
||||
— i.e. exactly the byte layout the firmware already produces in
|
||||
`csi_data[]`, just framed. CRC-32/IEEE over header+payload, final 4
|
||||
little-endian bytes. One envelope per UDP datagram.
|
||||
|
||||
Parsers reject: unknown version, wrong `header_len`, `frame_len` exceeding
|
||||
the IPv4 UDP payload cap (65,507 bytes), `num_bit_per_tone` outside
|
||||
`{16, 32}`, `payload_len` inconsistent with `num_sub_carrier *
|
||||
(num_bit_per_tone/8)`, trailing bytes, and bad CRC — mirroring the
|
||||
MediaTek/Qualcomm parser contract exactly.
|
||||
|
||||
Host side: add `realtek_csi.rs` to both
|
||||
`wifi-densepose-hardware` (frame + codec + simulator, mirroring
|
||||
`mediatek_csi.rs`/`qualcomm_csi.rs`) and `wifi-densepose-sensing-server`
|
||||
(bounded `RealtekCsiSnapshot`, mirroring `QualcommCsiSnapshot`), wired into
|
||||
the same UDP listener loop and `/api/v1/csi/realtek/latest` route as the
|
||||
existing vendor sources. The initial Mac compatibility port keeps Realtek CSI
|
||||
as a separate bounded diagnostic stream. Fresh ESP32 frames remain the primary
|
||||
sensing source. This change does not add `SourceKind::RealtekCsi` to a signed
|
||||
ingest contract; that requires a separate reviewed identity-bound hardware
|
||||
proof. RAC1 CRC checks corruption, not origin.
|
||||
|
||||
Firmware side (`example/wifi/wifi_csi` derivative, built against the
|
||||
official Ameba RTOS SDK, tracked outside this repository per the existing
|
||||
`CLAUDE.local.md` convention — the vendor SDK itself is never vendored into
|
||||
this repo): join the board to the deployment's WiFi AP in STA mode
|
||||
(passive CSI against the real AP as peer, since only one physical board is
|
||||
available in this bring-up), read the AP's BSSID at runtime after
|
||||
association (never hardcode a MAC into firmware source), pack each
|
||||
`RTW_EVENT_CSI_DONE` report into one `RAC1` datagram, and `sendto()` it over
|
||||
UDP to the host sensing-server's existing CSI port.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- CSI is real, MEASURED hardware output from day one — no simulator-first
|
||||
bridge phase is needed the way MediaTek/Qualcomm required, because the
|
||||
vendor SDK genuinely exposes the capability and we have the board in hand.
|
||||
- One board is sufficient for a first useful signal (passive CSI against the
|
||||
home AP); active and board-to-board modes are additive later without a
|
||||
wire-format break (`csi_mode` already reserves the values).
|
||||
- Reuses the bounded UDP parser and status-route pattern without changing
|
||||
existing ESP32 sensing. Unsigned RAC1 diagnostics are not authorized as
|
||||
calibrated presence, person count, pose, or vital evidence.
|
||||
|
||||
### Negative
|
||||
|
||||
- 1x1 hardware means no spatial (multi-antenna) diversity from a single
|
||||
board; multi-board deployment is required for the differential/localization
|
||||
use cases described in the original RTL8721Dx capability analysis.
|
||||
- The 71-minute microsecond timestamp wraparound (inherited from the
|
||||
firmware's `u32` field) means long unattended captures need host-side
|
||||
sequence-based reordering, not raw timestamp arithmetic, across a wrap.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The RAC1 envelope intentionally does not carry the SDK's raw
|
||||
`csi_signature` (0xABCD) marker — our own `magic`/CRC supersede it as the
|
||||
framing integrity check.
|
||||
|
||||
## Hardware gates
|
||||
|
||||
- [x] Confirm SDK source exposes `RTW_EVENT_CSI_DONE` / `rtw_event_csi_report_info` (done — `component/wifi/api/wifi_api_event.h`).
|
||||
- [x] Build and flash a baseline image on real PKM8721DAF-C13-F10 hardware, confirm boot over serial (done — `feat/rtl8721dx-csi-bringup`).
|
||||
- [x] Flash the CSI-streaming firmware and confirm `RAC1` datagrams parse on the host from the real board (done, 2026-09-15 — see below).
|
||||
- [x] Record MEASURED throughput/latency/jitter from the real running firmware (done, 2026-09-15 — see below).
|
||||
- [ ] A human walk-test across the link (amplitude perturbation vs. static baseline) is deferred — requires the operator physically present; not claimed until performed.
|
||||
|
||||
### MEASURED: real RAC1 capture and sustained throughput (2026-09-15)
|
||||
|
||||
Passive CSI (`RTW_CSI_MODE_NORMAL`, `RTW_CSI_TRIG_BEACON | RTW_CSI_TRIG_DATA | RTW_CSI_TRIG_QOS_DATA`)
|
||||
against the deployment AP. A single `RAC1` datagram was first
|
||||
cross-checked field-by-field against the firmware's own serial log of the
|
||||
same report — every byte matched: magic `0x31434152`, RSSI byte exact match,
|
||||
channel/bandwidth/protocol/subcarrier-count exact match, 157-byte total frame
|
||||
(49-byte header + 104-byte payload + 4-byte CRC, exactly as specified above).
|
||||
|
||||
`RTW_CSI_MODE_RX_RESP` (estimate CSI from ACKs to the STA's own transmissions)
|
||||
never fired `RTW_EVENT_CSI_DONE` across two full test runs with confirmed
|
||||
real traffic (not a hang) — this mode does not work for this hardware/AP
|
||||
combination and `RTW_CSI_MODE_NORMAL` with beacon+data triggers is the
|
||||
working configuration.
|
||||
|
||||
**An earlier note here claimed `NORMAL` mode fires only once per CSI enable
|
||||
— that was wrong**, caused by a debug log line gated to print only every
|
||||
50th frame (`frame_count % 50 == 1`), which was misread as "only one report
|
||||
total." A follow-up capture with a UDP listener running concurrently with a
|
||||
board reset measured **277 real RAC1 datagrams in 40 seconds (43,489 bytes)**,
|
||||
with `csi_sequence` climbing continuously and monotonically in the firmware's
|
||||
own serial log across six separate 50-frame log checkpoints in one 45s
|
||||
session — CSI reporting is continuous, roughly 7-12 fps, not sparse.
|
||||
|
||||
### MEASURED: throughput optimization pass (2026-09-15)
|
||||
|
||||
Formalized as a repeatable tool: `scripts/benchmark-rtl8721dx-csi.py` (listens
|
||||
on the sensing-server's CSI UDP port, reports fps/throughput/RSSI/inter-frame
|
||||
interval/sequence-gap stats from real received `RAC1` datagrams).
|
||||
|
||||
**Baseline** (`trig_period=200` [64ms], `trig_frame_ctrl=0`,
|
||||
`CSI_REPORT_BUF_NUM=1`), 30s capture:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| RAC1 fps | 14.67 |
|
||||
| Throughput | 2,302.7 bytes/s |
|
||||
| Mean inter-frame interval | 68.3 ms |
|
||||
| RSSI | mean -34.4 dBm |
|
||||
|
||||
`trig_period` (units of 320µs) was hypothesized as the rate-limiting
|
||||
throttle — the observed 68.3ms mean interval tracks the 64ms configured
|
||||
period almost exactly. Confirmed by tuning:
|
||||
|
||||
1. `trig_period`: 200 → 15 (the SDK doc's stated minimum recommended value,
|
||||
~4.8ms)
|
||||
2. `trig_frame_ctrl`: 0 → `RTW_CSI_TRIG_ACK` (second trigger source: every
|
||||
link-layer ACK the AP sends for the heartbeat's own outgoing traffic, on
|
||||
top of the existing beacon trigger)
|
||||
3. `CSI_REPORT_BUF_NUM`: 1 → 4 (a "lack of csi buf!" drop was observed even
|
||||
at the slower baseline rate; more headroom needed at a higher rate)
|
||||
|
||||
**Tuned**, 30s capture:
|
||||
|
||||
| Metric | Value | vs. baseline |
|
||||
|---|---|---|
|
||||
| RAC1 fps | 66.40 | **4.5x** |
|
||||
| Throughput | 10,424.8 bytes/s | **4.5x** |
|
||||
| Mean inter-frame interval | 14.9 ms | 4.6x lower |
|
||||
| Sequence gaps | 78 / ~2036 (~3.8%) | new (0 observed at the slower baseline rate) |
|
||||
| RSSI | mean -31.1 dBm | (different session/position, not a controlled variable here) |
|
||||
|
||||
The `trig_period` hypothesis is confirmed as the primary lever. The ~3.8%
|
||||
frame-loss rate at 66 fps is a reasonable place to stop for now — it is
|
||||
UDP-over-WiFi loss, not a firmware defect, and is small enough not to block
|
||||
downstream sensing use; revisit only if a use case needs stronger delivery
|
||||
guarantees (sequence-gap-triggered re-request, TCP fallback, etc.).
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-266](ADR-266-mediatek-filogic-csi-platform.md) / [ADR-267](ADR-267-mediatek-mimo-csi-wire-protocol.md) — sibling vendor CSI ADR pair (simulator-first precedent)
|
||||
- [ADR-268](ADR-268-qualcomm-atheros-csi-platform.md) / [ADR-269](ADR-269-qualcomm-csi-wire-protocol.md) — sibling vendor CSI ADR pair
|
||||
- [ADR-263](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md) / [ADR-264](ADR-264-rtl8720f-radar-wire-protocol.md) — existing Realtek (RTL8720F radar) path; `SourceKind::Realtek` owner
|
||||
- `CLAUDE.local.md` — RTL8721Dx toolchain setup, board/COM port, first bring-up log
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-325: Cognitum Spaces activation and governed spatial exchange
|
||||
|
||||
- **Status**: Accepted — legacy read live; versioned hierarchy, local memory, and governed-action implementation validated on feature branches; deployment pending
|
||||
- **Status**: Accepted — legacy and versioned reads, OAuth activation, local spatial memory, governed-action policy, metaharness support, and npm distribution are implemented; HTTPS production evidence is complete
|
||||
- **Date**: 2026-08-17
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous
|
||||
@@ -423,41 +423,56 @@ Identity metadata deliberately advertises `spaces:read` for RuView but not
|
||||
publisher surface. RuView therefore has no OAuth write, command, policy-approval,
|
||||
or actuator capability.
|
||||
|
||||
This production evidence does not claim deployment of sites/buildings/floors/
|
||||
zones, entities, semantic event or alert resources, tenant-scoped RuVector
|
||||
spatial history, MQTT reconciliation, governed actions, commands, or actuators.
|
||||
The first three are implemented and locally validated in the 2026-08-19 feature
|
||||
branches described below, but remain non-production until their workflow and
|
||||
readback gates pass. Commands and actuators remain out of scope.
|
||||
That receipt was for the initial flat Space slice. The following production
|
||||
expansion supersedes only its hierarchy/event/alert deferral. MQTT, commands,
|
||||
actuators, real-hardware accuracy, and the long-duration operational trial
|
||||
remain outside the completed claim.
|
||||
|
||||
## Feature-branch implementation evidence (2026-08-19)
|
||||
## Completed implementation and production expansion (2026-08-19)
|
||||
|
||||
- Cognitum API ADR-101 implements all eight `/v1/spatial` collections, coherent
|
||||
transactional hierarchy checks, stable pagination, event/alert retention,
|
||||
strict P2/P3 admission, API-key-only writes, and OAuth/API-key reads. The
|
||||
Firestore emulator passed both the legacy and versioned verification scripts,
|
||||
including cross-resource message replay denial.
|
||||
- `ruview-cognitum-spaces` adds a strictly decoded, paged, read-only client for
|
||||
every versioned collection. `wifi-densepose spaces --resource ...` exposes it
|
||||
while preserving the legacy flat command. The focused client suite and the
|
||||
CLI no-default-features test gate pass.
|
||||
- The contributor metaharness now accepts `resource`, `limit`, and opaque
|
||||
`cursor` on `ruview_spaces_list`, fixes the API origin, strips API-key
|
||||
compatibility authority, and revalidates hierarchy/event/alert contracts.
|
||||
Full metaharness, security, brain, flywheel, manifest, audit, and pack gates
|
||||
pass for the `@ruvnet/ruview` 0.5.0 release candidate.
|
||||
- ADR-326 adds `ruview-spatial-memory`: one RuVector HNSW index per authenticated
|
||||
tenant/workspace, replay/derivation/retention gates, cascading erasure,
|
||||
bounded explanations, and XChaCha20-Poly1305 snapshots with reload-verified
|
||||
key rotation. Its focused crate suite passes with `SYNTHETIC` evidence.
|
||||
- ADR-327 extends `ruview-policy` with typed observe/recommend/execute intents,
|
||||
exact host grants, signed approvals, nonce/idempotency defense, the existing
|
||||
ADR-321 assurance matrix, and signed hash-chained receipts. `spaces:read` is
|
||||
explicitly denied as execution authority. Its focused crate suite passes.
|
||||
- The required whole-workspace Rust command was attempted twice on Windows:
|
||||
parallel compilation ended in a compiler-process stack-buffer-overrun and a
|
||||
single-job retry reached the configured timeout without a source/test
|
||||
diagnostic. This is not recorded as a green gate; Linux CI remains required.
|
||||
- Cognitum API PRs #211 and #212 shipped the eight `/v1/spatial` collections,
|
||||
transactional hierarchy integrity, stable pagination, event/alert retention,
|
||||
strict P2/P3 admission, API-key-only writes, OAuth/API-key reads, and the
|
||||
additive-only Firestore release authority. Function run `32279092861`
|
||||
promoted active Node 22 revision `spacesapi-00005-kaf`.
|
||||
- Edge PRs #214, #215, and #216 preserved canonical UUID routing, kept SQLi
|
||||
denial, and removed secret-valued API-key rate selection. Gateway run
|
||||
`32284410107` promoted the reviewed immutable digest to 100% production
|
||||
traffic. Every versioned collection returned HTTP 200 through the public
|
||||
edge; the hierarchy composite index is `READY` and both retention TTL fields
|
||||
are `ACTIVE`.
|
||||
- The dedicated RuView service credential was rotated to exactly
|
||||
`spaces:read` and `spaces:write`; its predecessor returns 401. A non-mutating
|
||||
invalid-body probe reached write validation without persisting customer data.
|
||||
Other potentially affected owner keys and residual log retention remain
|
||||
tracked in Cognitum API #217.
|
||||
- A live RuView Authorization Code + S256 PKCE consent requested exactly
|
||||
`sensing:read spaces:read`. Its in-memory token read versioned `sites` with
|
||||
HTTP 200 and schema `1.0`; the verifier then revoked the temporary refresh
|
||||
credential and persisted no token.
|
||||
- RuView PR #1650 merged `ruview-cognitum-spaces`,
|
||||
`ruview-spatial-memory`, the ADR-327 policy extension, CLI paging, and the
|
||||
guarded `ruview_spaces_list` metaharness surface. PR #1651 removed stale
|
||||
feature-branch guidance and refreshed the signed package manifest.
|
||||
- The contributor metaharness fixes the API origin, accepts bounded resource,
|
||||
limit, and opaque-cursor inputs, strips API-key compatibility authority over
|
||||
MCP, invokes only the hardened OAuth CLI, and rejects raw sensing or malformed
|
||||
hierarchy/event/alert output. Its test, security, reviewed-brain, flywheel,
|
||||
manifest, audit, exact-tarball, and claim-check gates pass.
|
||||
- Release run `32286297277` rebuilt and smoke-tested the exact package and
|
||||
provenance-published `@ruvnet/ruview` 0.5.0. The public npm registry resolves
|
||||
0.5.0 as `latest`; no workstation publish was used.
|
||||
- `ruview-spatial-memory` keeps one RuVector HNSW index per authenticated
|
||||
tenant/workspace with replay, derivation, retention, cascading-erasure,
|
||||
bounded-explanation, encrypted-snapshot, and reload-verified rotation gates.
|
||||
This is local `SYNTHETIC` evidence, not a production sensing claim.
|
||||
- `ruview-policy` keeps observe/recommend/execute intents distinct, requires
|
||||
exact host grants plus signed approval for consequence, rejects nonce replay,
|
||||
and emits signed hash-chained receipts. `spaces:read` is explicitly denied as
|
||||
execution authority.
|
||||
- Focused Rust gates and the Linux workspace/CLI/security lanes pass. Earlier
|
||||
Windows whole-workspace attempts ended in host compiler failure or timeout;
|
||||
those attempts are not reclassified as green evidence.
|
||||
- No OAuth write/action scope, actuator callback, MQTT deployment claim, sensing
|
||||
accuracy claim, or real-hardware claim is introduced.
|
||||
|
||||
@@ -478,8 +493,9 @@ readback gates pass. Commands and actuators remain out of scope.
|
||||
|
||||
- Two credential types coexist during migration and must stay visibly distinct.
|
||||
- OAuth depends on Identity JWKS availability and correct key rotation.
|
||||
- Production currently exposes the legacy spaces slice; the implemented full
|
||||
hierarchy/events/alerts model remains staged until deployment/readback.
|
||||
- Production exposes both the legacy Space twins and the versioned hierarchy,
|
||||
anonymous entities, semantic events, and alerts over HTTPS. MQTT remains a
|
||||
design contract without deployment evidence.
|
||||
- OAuth workspace IDs will return only documents populated with `workspaceId`;
|
||||
legacy owner-only documents require an explicit migration, never a broad query.
|
||||
- The RuView client exposes no write, command, or agent execution surface. The
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# ADR 340: iPhone LiDAR Sensor Bridge
|
||||
|
||||
Status: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
RuView needs a low cost mobile geometry sensor that can contribute calibrated spatial observations without coupling the perception substrate to Apple frameworks.
|
||||
|
||||
ARKit exposes rear LiDAR scene depth through `ARFrame.sceneDepth` and `smoothedSceneDepth` on supported devices. Ordinary mobile web pages do not receive this ARKit depth surface directly, so native capture and web visualization must be separated.
|
||||
|
||||
## Decision
|
||||
|
||||
Use a two layer architecture.
|
||||
|
||||
1. Native Swift and ARKit perform acquisition.
|
||||
2. A modality neutral wire frame transports geometry into browser tools and, next, the RuView HAL.
|
||||
|
||||
The native client will capture depth, confidence, camera intrinsics, and world tracking pose. RGB imagery is excluded from the default transport.
|
||||
|
||||
The protocol identifier is `ruview.lidar.depth.v1`.
|
||||
|
||||
Depth samples are quantized to UInt16 millimeters for transport. Confidence remains UInt8. The default sender downsamples by two spatially and caps transmission at 15 FPS. Full fidelity depth remains available locally for future on device inference.
|
||||
|
||||
## RuView integration boundary
|
||||
|
||||
The transport must not become a second world model. The production receiver converts each packet into the canonical `ruview-hal::Observation`, then passes it through authenticated sensor identity, provenance, OOD gating, uncertainty aware fusion, spatial memory, and WorldGraph adapters.
|
||||
|
||||
Rules:
|
||||
|
||||
1. `source=live` is valid only for frames produced by an active ARKit session.
|
||||
2. Sequence numbers are monotonic per sensor session.
|
||||
3. Wall clock timestamp is separate from ARKit monotonic frame timing.
|
||||
4. RGB is off by default and requires an explicit higher privacy capability.
|
||||
5. Browser clients consume geometry but are not treated as authoritative sensors.
|
||||
6. Unsupported devices fail closed rather than substituting simulated depth.
|
||||
|
||||
## Performance target
|
||||
|
||||
`[SYNTHETIC]` A 256 x 192 Float32 depth map is about 196 KB before confidence and metadata. Downsampling to 128 x 96 and encoding each sample as two byte depth plus one byte confidence yields about 36.9 KB raw. At 15 FPS the raw sensor payload is about 553 KB/s. Base64 raises this to roughly 737 KB/s before JSON metadata. These values are arithmetic sizing estimates, not device measurements.
|
||||
|
||||
The `[CLAIMED target]` for local network latency is below 150 ms p95. A later binary WebSocket or QUIC transport can remove base64 overhead; the exact end-to-end reduction must be measured before it is claimed.
|
||||
|
||||
## Security
|
||||
|
||||
The development relay is LAN-facing, requires a random per-run bearer token, bounds message size, and restricts the files it serves. Its default `ws://` transport is not encrypted, so it is not a production trust boundary.
|
||||
|
||||
Production requires WSS, authenticated sensor identity, replay protection, message size limits, per tenant authorization, provenance receipts, and explicit retention policy before persistence.
|
||||
|
||||
## Consequences
|
||||
|
||||
Benefits include commodity hardware, metric depth, tracked camera pose, rapid room scanning, calibration support for RF sensing, and a practical ground truth source for RuView experiments.
|
||||
|
||||
The main limitation is that Apple provides processed scene depth rather than the underlying raw transient LiDAR waveform. Therefore this implementation supports direct geometry and sensor fusion now, but does not reproduce research systems that require raw multipath time of flight transients for non line of sight reconstruction.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
A physical LiDAR capable iPhone must stream live geometry to the browser viewer with monotonically increasing sequence numbers, no RGB payload, valid confidence maps, and below 150 ms p95 local network latency over a 60 second run.
|
||||
|
||||
CI type-checking and simulator runs do not satisfy this criterion. Until a captured physical-device run records the environment and results, the hardware behavior and latency remain unverified.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR-344: Adaptive local installation discovery
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — local advertisement and mobile discovery implemented; physical
|
||||
peer-link and hosted-relay qualification pending.
|
||||
|
||||
## Context
|
||||
|
||||
RuView installations previously depended on a manually stored IP address.
|
||||
DHCP changes, access-point changes, and client isolation could therefore leave
|
||||
a healthy sensing installation unreachable from the mobile app. Repeated
|
||||
authentication-policy log entries did not prove that the selected endpoint was
|
||||
reachable.
|
||||
|
||||
Discovery must make commissioning recoverable without turning a service name
|
||||
into authentication, leaking sensor data, scanning arbitrary subnets, or
|
||||
silently moving private spatial data to a hosted service.
|
||||
|
||||
## Decision
|
||||
|
||||
The sensing server advertises one bounded `_ruview._tcp.local.` service when it
|
||||
is bound to a routable interface. Loopback-only instances do not advertise,
|
||||
and operators can disable advertisement with `--no-mdns`.
|
||||
|
||||
The TXT contract is deliberately small:
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---:|---|
|
||||
| `schema=ruview.installation.v1` | yes | Fail-closed protocol discriminator |
|
||||
| `tls=0|1` | yes | HTTP or HTTPS origin construction |
|
||||
| `installation=<opaque id>` | no | Bounded routing hint, never authentication |
|
||||
|
||||
The advertisement contains no node inventory, room identifier, SSID,
|
||||
credentials, CSI, vital estimates, pose, identity, or learning data. The
|
||||
hostname is bounded and sanitized before registration. Advertisement failure
|
||||
is recoverable and does not stop sensing.
|
||||
|
||||
RuView Mobile resolves only the matching service and schema, validates the
|
||||
resulting origin under its private-LAN/HTTPS policy, and requires an application
|
||||
health probe before selection. Its adaptive broker retains the configured
|
||||
origin preference and requires two failed configured probes plus two healthy
|
||||
fallback probes before switching. Credentials remain scoped to their saved
|
||||
origin.
|
||||
|
||||
The recovery ladder is:
|
||||
|
||||
1. configured private-LAN or HTTPS origin;
|
||||
2. verified Bonjour local origin;
|
||||
3. physically qualified Apple peer-to-peer local path;
|
||||
4. explicit, authenticated HTTPS relay for bounded derived frames only;
|
||||
5. optional administrator-managed WireGuard/Tailscale access.
|
||||
|
||||
Only levels 1 and 2 are implemented and software-validated by this decision.
|
||||
Peer-to-peer browsing is enabled on Apple platforms, but it is not a qualified
|
||||
peer-link data plane. This repository does not provide a hosted relay and must
|
||||
fail closed when no local endpoint is healthy.
|
||||
|
||||
## Security and privacy consequences
|
||||
|
||||
- Service discovery is routing evidence, not installation authentication.
|
||||
- Public HTTP origins, credentials in URLs, malformed records, and records with
|
||||
the wrong schema are rejected before use.
|
||||
- Raw CSI, RSSI streams, camera/LiDAR frames, room geometry, pose labels,
|
||||
identity data, and training examples remain local.
|
||||
- Future relay work requires a separate consent, authentication, revocation,
|
||||
minimization, and physical evidence review.
|
||||
- ESP32-S3/C6 nodes remain provisioned to the sensing installation. This ADR
|
||||
does not claim direct phone-to-node discovery or invent a firmware protocol.
|
||||
|
||||
## Validation
|
||||
|
||||
Software acceptance requires:
|
||||
|
||||
- unit tests for bounded advertisement construction and hostname sanitation;
|
||||
- mobile parser rejection, route scoring, anti-flapping, and Settings UI tests;
|
||||
- Rust, TypeScript, lint, security, metaharness, Expo, and native compile gates;
|
||||
- a real Bonjour resolve of the TXT contract followed by a successful
|
||||
`/api/v1/status` probe.
|
||||
|
||||
Physical qualification additionally requires installation discovery on an
|
||||
iPhone, live frame and node-inventory receipt, DHCP-change recovery without
|
||||
flapping, and a five-to-ten-minute zero-fusion-error burn-in. Simulator and
|
||||
host-only results remain `MEASURED_SOFTWARE`; peer-link, relay, and physical
|
||||
reconnection claims remain `NOT_MEASURED` until those captures exist.
|
||||
|
||||
## Implementation references
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/discovery.rs`
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs`
|
||||
- Mobile companion decision: `cognitum-one/ruview-mobile`,
|
||||
`docs/adr/ADR-026-adaptive-local-installation-discovery-and-transport-recovery.md`
|
||||
- Related decisions: ADR-034, ADR-054, ADR-296
|
||||
@@ -0,0 +1,193 @@
|
||||
# ADR-345: Per-link CSI attribution and node-to-node ranging
|
||||
|
||||
- **Status**: Proposed — measurement layer implemented and validated on hardware; localization not built
|
||||
- **Date**: 2026-08-28
|
||||
- **Deciders**: Joe
|
||||
- **Tags**: csi, wire-format, esp32-c6, localization, adr-018, adr-060, adr-110
|
||||
|
||||
## Context
|
||||
|
||||
Every attempt at device-free position estimation in this repository has failed
|
||||
against the same wall, and the wall has now been measured twice from different
|
||||
directions.
|
||||
|
||||
**Phase is unusable.** The bistatic tier needed a signed per-link radial
|
||||
velocity, which requires the common-mode component of CSI phase. Measured
|
||||
2026-08-28 across all three ESP32-C6 nodes over ~40,000 frames, the
|
||||
frame-to-frame change in common-mode phase is statistically indistinguishable
|
||||
from uniform random — resultant length `R` = 0.011–0.032 against a 0.000
|
||||
reference, standard deviation 1.78–1.81 against `pi/sqrt(3)` = 1.814, and no
|
||||
lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is
|
||||
packet-detection timing quantization: common-mode phase is `-2*pi*f_c*tau`, and
|
||||
one ADC sample of jitter at 20 MHz is `2*pi * 2.412e9 * 50e-9` ~= 758 rad, about
|
||||
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
|
||||
is default-off as a result (`bistatic_tier_enabled`).
|
||||
|
||||
**Amplitude works but had no parallax.** The three AP -> node links leave a
|
||||
single distant transmitter within a 36.5 degree fan and converge on the node
|
||||
cluster. Bearings computed from `room_config.json`: 198.5, 221.2, 235.0 degrees.
|
||||
Scalar per-node energy weighting was closed with a clean negative result on
|
||||
2026-08-27 — magnitude responds to proximity, direction does not.
|
||||
|
||||
**The receivers were hearing far more than we recorded.** A node in promiscuous
|
||||
MGMT+DATA mode with no `filter_mac` produces CSI for every transmitter on the
|
||||
channel. Measured on node 2: the AP accounted for only **34%** of frames; the
|
||||
two peer nodes supplied 39% and 27%. Those peer frames are valid measurements
|
||||
of *node-to-node* channels — but ADR-018's wire format carries no transmitter
|
||||
address, so the sink could not attribute them. They interleaved into one
|
||||
`frame_history` with mixed geometry, and the only available remedy
|
||||
(`filter_mac`, ADR-060) fixes the mixing by discarding ~75% of the data.
|
||||
|
||||
The node-to-node links between the same three boards cross the room from three
|
||||
sides at **108.7 degrees** of angular spread (bearings 0.0, 73.2, 108.7) versus
|
||||
36.5 for the AP links — roughly 3x the parallax, with both endpoints known,
|
||||
fixed, and under our control rather than wherever the router happens to live.
|
||||
Link count also grows as `N*(N-1)/2`: 3 nodes give 3 links, 6 give 15, 13 give
|
||||
78.
|
||||
|
||||
## Decision
|
||||
|
||||
Carry the transmitter MAC on the CSI wire and model CSI per **link**
|
||||
(receiver, transmitter) rather than per **node**, as a layer alongside the
|
||||
existing per-node model.
|
||||
|
||||
### 1. Wire format v2
|
||||
|
||||
`CSI_MAGIC_V2 = 0xC5110008`, header 26 bytes: the v1 header unchanged through
|
||||
byte 19, then the transmitter address (`info->mac`, 802.11 addr2) at bytes
|
||||
20..25, with I/Q from byte 26.
|
||||
|
||||
`0xC5110002`..`0xC5110007` were already claimed by the vitals, feature and other
|
||||
edge packets, so v2 takes the next free value rather than an adjacent one — see
|
||||
`issue_928_magic_collision_tests` for why that matters. The parser accepts both
|
||||
magics, so a mixed fleet keeps working and v1 nodes simply report no
|
||||
transmitter.
|
||||
|
||||
### 2. Per-link state (`links.rs`)
|
||||
|
||||
`LinkTable` keyed by `LinkId { rx_node, tx_mac }`, each link holding its own
|
||||
amplitude history, RSSI EMA, learned resting baseline and motion metric.
|
||||
|
||||
The metric is the mean over subcarriers of the temporal standard deviation of
|
||||
**AGC-normalized** amplitude. Removing each frame's own mean across subcarriers
|
||||
is essential rather than cosmetic: the ESP32 applies automatic gain control per
|
||||
packet, so absolute amplitude jumps between packets for reasons unrelated to the
|
||||
channel, and any statistic built on the raw level measures the radio's gain
|
||||
control instead of the room.
|
||||
|
||||
Amplitude only, deliberately — nothing here can depend on phase coherence.
|
||||
|
||||
### 3. Beacon rate
|
||||
|
||||
`BEACON_PERIOD_MS` 100 -> 20. At 10 Hz a peer samples a node-to-node link with a
|
||||
5 Hz Nyquist while motion Doppler lives at 1–20 Hz; it aliases before it starts.
|
||||
20 ms matches both the receiver's own `CSI_MIN_PROCESS_INTERVAL_US` ceiling and
|
||||
the gateway self-ping already used for the AP link.
|
||||
|
||||
### 4. `GET /api/v1/links` and a live UI panel
|
||||
|
||||
The link table was initially reachable only through a 30-second log line, which
|
||||
is the wrong medium for a quantity whose value is watching it respond while you
|
||||
walk across the room. `GET /api/v1/links` returns one row per link — `motion`,
|
||||
`raw_motion`, `rssi_dbm`, `frames`, `kind` — and `LinkMeshPanel` renders them in
|
||||
the Sensing tab at 1 Hz. `raw_motion` is shown beside `motion` deliberately: a
|
||||
link whose baseline has not settled reports a healthy raw value and a near-zero
|
||||
motion, and with only the one number that is indistinguishable from a dead link.
|
||||
|
||||
Node identity is **inferred, not measured**. Nothing in the system records a
|
||||
node's own MAC — a node reports the addresses it hears, never its own. But a
|
||||
radio does not receive its own transmissions, so given the set of receivers
|
||||
currently reporting, a transmitter heard by all of them except exactly one is
|
||||
that one receiver's own board; a transmitter heard by every receiver is external
|
||||
infrastructure. `links::infer_transmitting_node` declines (returns `None`) with
|
||||
fewer than three receivers, where "heard by all but one" and "heard by exactly
|
||||
one" are the same observation, and whenever more than one receiver is missing,
|
||||
which a peer out of range produces indistinguishably. The field is named
|
||||
`tx_node_inferred` and the UI draws the peer id with a trailing `?`.
|
||||
|
||||
Checked against the known MAC map on 2026-08-28 the inference labelled all four
|
||||
transmitters correctly — nodes 0, 1 and 2 by address, and the AP as external —
|
||||
with no access to that map. A wrong answer here mislabels a display row; it
|
||||
never reaches a metric.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Validated on hardware, 2026-08-28
|
||||
|
||||
All three nodes on wire v2 at 20 ms produced the full mesh: **9 links** (3 AP +
|
||||
6 directional node-to-node).
|
||||
|
||||
Reciprocity — each physical channel measured independently from both ends:
|
||||
|
||||
| channel | direction A | direction B | ratio |
|
||||
|---|---|---|---|
|
||||
| 0 <-> 1 | 2.229 | 1.930 | 1.15 |
|
||||
| 0 <-> 2 | 1.353 | 1.000 | 1.35 |
|
||||
| 1 <-> 2 | 1.398 | 1.486 | 1.06 |
|
||||
|
||||
Nothing in the implementation enforces this. Three independent radio pairs
|
||||
agreeing within 6–35% is the strongest available evidence that the per-link
|
||||
separation is real rather than an artifact of frame bucketing.
|
||||
|
||||
Link quality is also far more uniform than the AP links: node-to-node raw motion
|
||||
spans 1.0–2.2 across all six, versus 3.8–17.8 for the three AP links (which
|
||||
track RSSI: -67, -72, -80). Uniform link quality is desirable for tomographic
|
||||
localization — no single link dominates the solution.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Peer links are ~10x weaker.** A 16-byte ESP-NOW beacon offers far less
|
||||
energy to estimate a channel response from than a full data frame. Whether
|
||||
1.0–2.2 perturbation is sufficient to localize with is **unknown and untested**.
|
||||
- **Three links is below threshold.** Tomographic localization needs many
|
||||
crossing links; 3 will not localize regardless of quality. This ADR delivers
|
||||
the measurement layer, not localization.
|
||||
- **Localization is not implemented.** No position tier consumes `LinkTable`.
|
||||
The live position estimate remains `doppler_centroid`.
|
||||
- **Per-node path regression.** Removing `filter_mac` to admit peer frames
|
||||
returned the *per-node* `frame_history` to a mixed-transmitter stream, since
|
||||
it still consumes every frame regardless of source. Node 2's classification
|
||||
signal is noisier as a result. The fix is to select a link to feed the
|
||||
per-node path — software filtering that keeps the other links — and it is not
|
||||
yet done.
|
||||
|
||||
### Scaling limits to settle before more nodes
|
||||
|
||||
- **Airtime.** Every node broadcasts, so N nodes put N/period frames per second
|
||||
on the channel being sensed. 3 nodes at 20 ms is 150 pps; 13 would be 650 pps.
|
||||
`BEACON_PERIOD_MS` should become an NVS setting scaled to fleet size.
|
||||
- **`MAX_LINKS` is 64.** 13 nodes give 78 receiver-transmitter pairs plus the AP
|
||||
plus neighbours, which overflows. Raise deliberately, with the memory implied.
|
||||
- **Placement.** Links must *cross* the target area. Nodes spread through a
|
||||
house give roughly one link per room, each attenuated by walls and perturbed
|
||||
by anyone anywhere along it — whole-house coarse presence, not position.
|
||||
Perimeter placement around a single room is the geometry that works.
|
||||
|
||||
## Related bugs found and fixed while implementing
|
||||
|
||||
- **ADR-060 MAC filtering cost ~75% of capture rate.** The 50 Hz rate gate ran
|
||||
*before* the MAC filter and stamped `s_last_process_us` before the check, so a
|
||||
frame from any other transmitter consumed the slot and was then discarded.
|
||||
Enabling `filter_mac` dropped yield from ~42 pps to 6–13. Filtering first
|
||||
restores it (measured 31–37 pps). A 6-byte `memcmp` is negligible ISR work next
|
||||
to the CSI processing the gate guards.
|
||||
- **The entire edge pipeline was dead on ESP32-C6.** `EDGE_MAX_SUBCARRIERS` was
|
||||
128, an S3-era assumption; a C6 on an HE-capable AP delivers HE20 frames with
|
||||
256 bins, and `process_frame` rejected every one. ADR-110 onboarded the C6 to
|
||||
the capture path but not this one. Vitals, presence, fall detection and
|
||||
per-slot counting had never run on these boards. Now target-conditional on
|
||||
`CONFIG_SOC_WIFI_HE_SUPPORT`, with the four subcarrier-sized arrays hoisted to
|
||||
static so peak stack is *lower* than it was at 128.
|
||||
- **A target-conditional constant that failed silently.** `edge_processing.h`
|
||||
had no `#include` at all, so `CONFIG_SOC_WIFI_HE_SUPPORT` was undefined and
|
||||
`#if` evaluated it as 0 — selecting the wrong size with no warning. An
|
||||
undefined identifier in `#if` is 0 in C. This is the same failure mode as the
|
||||
bug it was fixing, one layer up.
|
||||
|
||||
## References
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/links.rs` — per-link state, 8 tests
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/phase_diag.rs` — the phase measurement
|
||||
- `firmware/esp32-csi-node/main/csi_collector.c` — wire v2 serialization, filter ordering
|
||||
- `firmware/esp32-csi-node/main/edge_processing.h` — `EDGE_MAX_SUBCARRIERS`
|
||||
- ADR-018 (wire format), ADR-060 (MAC filtering), ADR-110 (C6 onboarding)
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR 346: Fail closed ESP32 occupancy evidence
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. Physical qualification is required after each firmware build.
|
||||
|
||||
## Date
|
||||
|
||||
2026 08 31
|
||||
|
||||
## Context
|
||||
|
||||
The ESP32 Tier 2 pipeline produces two different signals. Presence is a debounced room level decision. Person count is a bounded subcarrier diversity heuristic. A live four node installation emitted packets with `presence=false` and `n_persons=3` or `4`. The server eventually gated the aggregate room count, but raw WebSocket consumers and diagnostics could still treat the contradictory count as occupancy evidence.
|
||||
|
||||
That contradiction is more dangerous than a missed optional count. It can contaminate empty room calibration, train a room model on false labels, and encourage a product claim that the firmware cannot support. The count is not identity, pose, or a validated multi person estimator.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Firmware person slots are subordinate to the debounced presence gate.
|
||||
2. When presence is false, the firmware clears slot activity, slot history, candidate count, persistence streak, and stable count.
|
||||
3. The serialized person count is always zero when presence is false and is clamped to `EDGE_MAX_PERSONS` when presence is true.
|
||||
4. The sensing server repeats the invariant for older firmware. A contradictory or out of range count becomes zero and carries `person_count_valid=false`.
|
||||
5. Fused CSI plus mmWave packets use either CSI presence or mmWave presence as the supporting presence condition.
|
||||
6. The node inventory and WebSocket diagnostics expose person count validity. Consumers must not infer a person from an invalid count.
|
||||
7. No count accuracy claim is created by this change. The firmware output remains a heuristic until a leakage free, held out physical dataset demonstrates otherwise.
|
||||
8. OTA admission uses the selected update partition size rather than a stale fixed 900 KB ceiling. The status endpoint reports that same hardware bound, while image validation and authenticated OTA remain mandatory.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
The change retains no raw CSI or personal data. It reduces authority by preventing a secondary heuristic from asserting occupancy after the primary gate has closed. The host validates packet length, magic, range, and logical consistency before using count evidence.
|
||||
|
||||
## Consequences
|
||||
|
||||
Older firmware remains wire compatible. Invalid count evidence becomes visibly unavailable instead of silently affecting calibration. A true multi person event can still be undercounted when the presence gate is false, which is the intended fail closed behavior. Current C6 images larger than 900 KB can use the installed 1,900,544 byte OTA slots after one serial upgrade, without weakening the OTA authentication gate.
|
||||
|
||||
The largest uncertainty is whether the current presence gate itself generalizes across the installed rooms. The fix path is a room bound empty baseline plus the fixed room selective held out protocol, not a global threshold reduction.
|
||||
|
||||
## Evidence and acceptance
|
||||
|
||||
MEASURED before implementation on 2026 08 31: four live nodes streamed for 86 seconds with zero transport errors, while edge packets repeatedly contradicted `presence=false` with counts of three or four.
|
||||
|
||||
Software acceptance requires:
|
||||
|
||||
1. Host firmware tests prove absent plus four active slots serializes zero.
|
||||
2. Rust parser tests prove contradictory and out of range counts fail closed.
|
||||
3. The node API exposes count validity without breaking older firmware.
|
||||
|
||||
Physical acceptance requires the updated firmware on a confirmed board, a captured boot log, five minutes of live packets, zero logical count contradictions, and no increase in transport errors. Accuracy remains unmeasured until labelled held out sequences are recorded.
|
||||
|
||||
Physical occupancy qualification completed for ESP32 C6 node 4 on 2026 08 31. The five minute run observed 242 edge packets, including 61 absent packets, with zero logical count contradictions and zero parse errors. See `docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md`.
|
||||
|
||||
ESP32 C6 node 7 was subsequently identified, upgraded to firmware 0.8.8, and transport qualified for five minutes with zero fused presence count contradictions and zero steady state transport errors. Its controlled empty room sequence remains required before occupancy qualification. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`. Other nodes remain unqualified until separately identified and upgraded.
|
||||
@@ -0,0 +1,85 @@
|
||||
# ADR 347: Rate aware ESP32 temporal sensing
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented in firmware 0.8.8. The timing and transport path is
|
||||
physically qualified on ESP32 C6. The independent raw transport path is also
|
||||
physically qualified on an ESP32 S3 running Tier 0. Held out inference accuracy
|
||||
remains required.
|
||||
|
||||
## Context
|
||||
|
||||
The ESP32 firmware creates CSI opportunities by sending one byte ICMP probes to
|
||||
the connected access point. The traffic source is configured for 50 Hz, but the
|
||||
delivered CSI cadence varies with channel contention and callback safety gates.
|
||||
A physical ESP32 C6 produced 28 to 37 callbacks per second during the baseline
|
||||
capture. Firmware 0.8.5 then exposed that the old per-interval estimator saw
|
||||
only 12 to 16 Hz because WiFi replies arrived in short bursts separated by
|
||||
longer gaps. The filters still consumed those burst frames, so excluding them
|
||||
from the clock estimate was incorrect.
|
||||
|
||||
The edge DSP estimates its sample rate from timestamps so that breathing,
|
||||
heartbeat, motion, and future Doppler features stay in physical Hertz. That
|
||||
estimator was capped at 30 Hz. Once the actual cadence exceeded the cap, every
|
||||
temporal feature was scaled against the wrong clock.
|
||||
|
||||
Physical firmware 0.8.5 validation corrected that initial diagnosis. Although
|
||||
the callback path received 26 to 40 frames per second, Tier 2 on the unicore C6
|
||||
processed an irregular subset that converged toward the 8 Hz estimator floor.
|
||||
The right design is not to force the edge DSP to match raw capture. The paths
|
||||
need independent, explicit cadence contracts.
|
||||
|
||||
The device free gesture preprint at
|
||||
`https://www.preprints.org/manuscript/202602.0018` reinforces the importance of
|
||||
timestamp correct Doppler features, but its 100 Hz controlled link is not a
|
||||
safe firmware default for RuView. Existing S3 and C6 evidence records WiFi ISR
|
||||
and packet buffer failures under sustained callback pressure above 50 Hz.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Make the connected STA probe rate a build time setting from 10 through 50
|
||||
Hz, with a default and hard ceiling of 50 Hz.
|
||||
|
||||
2. Track the delivered DSP cadence by counting every processed frame interval
|
||||
over one second timestamp windows, then smooth successive windows in an 8
|
||||
through 60 Hz estimator range. The 60 Hz estimator ceiling accommodates
|
||||
timestamp jitter; it does not authorize more than 50 Hz callback processing.
|
||||
|
||||
3. Reject incomplete windows below one second and stalled windows above three
|
||||
seconds. Do not discard valid burst frames from the estimated clock.
|
||||
|
||||
4. Surface the DSP rate in the one second controller diagnostic so hardware
|
||||
validation can compare callback yield with the clock used by temporal
|
||||
filters.
|
||||
|
||||
5. Keep raw CSI on the wire at the independent network cadence. Rate-limit the
|
||||
C6 on-device Tier 1 and Tier 2 DSP input to a uniform 8 Hz. Physical 0.8.7
|
||||
evidence showed that a requested 10 Hz input still converged to 8.0 through
|
||||
8.4 Hz under Tier 2 load, while raw delivery remained 30 through 40 pps.
|
||||
Eight hertz retains a 4 Hz Nyquist limit for the 0.1 through 2.0 Hz vital
|
||||
bands without creating a backlog. The S3 default remains 20 Hz.
|
||||
|
||||
6. STFT, spectrogram gating, and learned temporal
|
||||
classification remain host or iPhone responsibilities where memory,
|
||||
rollback, and held out evaluation are stronger.
|
||||
|
||||
## Consequences
|
||||
|
||||
Heartbeat, respiration, and motion features receive a stable timestamped clock
|
||||
instead of an accidental subset determined by C6 backlog. Operators can lower
|
||||
the probe or DSP load for constrained networks without editing source. The host
|
||||
still receives the higher-rate raw stream for richer Doppler processing.
|
||||
|
||||
This does not prove vital sign accuracy or gesture recognition. Higher temporal
|
||||
fidelity only improves the representation available to a separately validated
|
||||
model. The 50 Hz ceiling also means the paper's 100 Hz results are not directly
|
||||
transferable.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
On a physical C6, run at least five minutes after flashing. Pass when the boot
|
||||
log reports the configured probe and DSP rates, the controller converges within
|
||||
one hertz of the configured DSP cadence, raw callback yield remains at least 20
|
||||
pps, no steady-state ENOMEM, watchdog, panic, or reboot occurs, and the fail
|
||||
closed occupancy invariant remains zero contradictions for at least 30 absent
|
||||
packets.
|
||||
@@ -0,0 +1,336 @@
|
||||
# ADR-348: Independent Rust multivariate forecasting for RuView
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-09-01
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView perception, model, security, and data-governance maintainers
|
||||
- **Tags**: forecasting, rust, ruvector, temporal, uncertainty, clean-room, provenance
|
||||
- **Extends**: ADR-016, ADR-020, ADR-145, ADR-273, ADR-282, ADR-295,
|
||||
ADR-298, ADR-302, ADR-304, ADR-317, ADR-318, ADR-319
|
||||
- **Supersedes**: None
|
||||
|
||||
## Executive decision
|
||||
|
||||
RuView will develop an independently specified and independently trained Rust
|
||||
multivariate forecasting subsystem. It will forecast bounded, versioned
|
||||
temporal feature streams, publish calibrated quantiles and an explicit
|
||||
abstention state, optionally use a split-safe RuVector analogue index, and
|
||||
remain advisory until all release gates in this ADR pass.
|
||||
|
||||
This is not a port, compatibility layer, distillation, or behavioural clone of
|
||||
Google TimesFM. The implementation API is derived from RuView requirements.
|
||||
Its weights must descend from an approved random initialization and approved
|
||||
training data only.
|
||||
|
||||
**Evidence status at proposal:** the forecasting accuracy, false-alert
|
||||
reduction, calibration, CPU latency, memory use, cross-building generalization,
|
||||
and operational value described below are all **UNMEASURED targets**. This ADR
|
||||
authorizes implementation and evaluation; it makes no `MEASURED` capability
|
||||
claim and approves no production model.
|
||||
|
||||
## Context
|
||||
|
||||
RuView currently observes present and recent RF state. Forecasting could add a
|
||||
separate answer to questions such as whether a feature trajectory is consistent
|
||||
with real occupancy, whether motion is likely to transition between zones, or
|
||||
whether a radio link is degrading. A forecast is not a sensor observation. It
|
||||
is derived evidence with uncertainty and must never overwrite the immutable
|
||||
observation that produced it.
|
||||
|
||||
TimesFM 3 is useful research context because its public description discusses
|
||||
multivariate targets, past covariates, known-future covariates, probabilistic
|
||||
outputs, temporal patching, and cross-series attention. Its licensing creates
|
||||
two distinct surfaces:
|
||||
|
||||
- the Google TimesFM source repository states that source code is Apache-2.0;
|
||||
- the TimesFM 3 pretrained weights are distributed under a separate
|
||||
non-commercial, non-production license that also restricts commercial
|
||||
training, fine-tuning, and distillation from the model.
|
||||
|
||||
An Apache-compliant Rust port would be a permissible but attributed derivative
|
||||
source path, subject to its exact license obligations. It would not support the
|
||||
strongest independent-development claim. Using the restricted TimesFM 3 model
|
||||
or its outputs to create a RuView commercial model is outside this ADR. The
|
||||
project therefore chooses the stricter clean-room protocol in
|
||||
[`../security/ruview-forecast-clean-room.md`](../security/ruview-forecast-clean-room.md).
|
||||
|
||||
Clean-room controls reduce copyright, contract, and provenance risk. They do
|
||||
not establish freedom to operate against patents, clear trademarks, approve a
|
||||
dataset, or replace legal review in a launch jurisdiction.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Use the TimesFM 3 checkpoint in RuView.** Rejected for commercial and
|
||||
production use under the current checkpoint license.
|
||||
2. **Translate the Apache-licensed implementation into Rust.** Not selected.
|
||||
This can be evaluated under a separate ADR, with attribution and license
|
||||
compliance, but must not be mixed with the independent-development path.
|
||||
3. **Build an independent Rust forecasting specialist for RuView.** Chosen.
|
||||
This narrows model scope, keeps inference native, controls provenance, and
|
||||
makes RF-specific evaluation the source of authority.
|
||||
4. **Do not add forecasting.** Retained as the deployment baseline. Forecasting
|
||||
must beat simpler deterministic and statistical baselines before acquiring
|
||||
runtime authority.
|
||||
|
||||
## Requirements
|
||||
|
||||
| ID | Requirement | Verification authority |
|
||||
|---|---|---|
|
||||
| RF-001 | No TimesFM 3 code, configuration, weights, parameters, outputs, activations, or derivatives enter specification, implementation, training, evaluation feedback, or release artifacts. | Clean-room manifest, contributor attestations, repository and artifact scans |
|
||||
| RF-002 | Every contributor, reference, dependency, dataset, transform, training job, and checkpoint has immutable provenance. | Signed provenance manifests and review receipts |
|
||||
| RF-003 | Every training byte has documented rights for commercial machine-learning use and applicable privacy approval. | Dataset licensing gate and data-steward approval |
|
||||
| RF-004 | The default runtime is native Rust, bounded, offline, deterministic for a fixed artifact/input within the declared platform class, and free of runtime model download. | Unit, property, fuzz, replay, and dependency tests |
|
||||
| RF-005 | Inputs and outputs are versioned, finite, bounded, timestamped, mask-aware, and provenance-labelled. Invalid, stale, insufficient, or OOD inputs abstain. | Contract tests and malformed-input corpus |
|
||||
| RF-006 | Forecasts remain derived evidence. They cannot rewrite observations, silently increase observation confidence, or be relabelled `MEASURED`. | Schema invariants and evidence-engine tests |
|
||||
| RF-007 | Quantile ordering, interval calibration, missing-data behaviour, and abstention are evaluated on leakage-free site/session/device holdouts. | Frozen evaluation manifest and reproducible report |
|
||||
| RF-008 | RuVector retrieval is optional, split-scoped, provenance-recorded, and evaluated against the same model without retrieval. | Retrieval isolation tests and paired ablation |
|
||||
| RF-009 | RuVLLM may explain or summarize a signed forecast but cannot alter numeric forecasts, bypass policy, spend money, or trigger medical, emergency, access-control, or life-safety action. | Capability tests and downstream policy review |
|
||||
| RF-010 | Training on local Linux or a hosted accelerator uses identical signed code, container, data, and configuration identities; the provider is an untrusted processor. | Training receipts and digest comparison |
|
||||
| RF-011 | Accuracy, latency, memory, power, and operational claims remain `UNMEASURED` or `CLAIMED` until a named reproducer satisfies the repository evidence contract. | Documentation and release review |
|
||||
| RF-012 | Rollout is reversible and cannot advance from offline evaluation to shadow or advisory operation without the mapped gates below. | Signed mode transition and rollback drill |
|
||||
|
||||
## Architecture boundary
|
||||
|
||||
The initial implementation is split across three crates:
|
||||
|
||||
| Crate | Owned responsibility | Dependency/runtime rule |
|
||||
|---|---|---|
|
||||
| `ruview-forecast-core` | Backend-neutral schemas, invariants, metrics, forecast receipts, and the `Forecaster` trait | No Burn, CUDA, WGPU, provider SDK, sensing-server, or network dependency |
|
||||
| `ruview-forecast-model` | Independent Burn 0.21 patch-mixer architecture and artifact execution | CPU, CUDA, and WGPU are explicit optional features; every backend feature is off by default |
|
||||
| `ruview-forecast-train` | Dataset manifests/splits, trainer, evaluator, the `ruforecast` CLI, and Linux/fal.ai training assets | Training-only authority; no production activation or sensing-server mutation |
|
||||
|
||||
This PR does not connect the forecaster to the sensing server. A shadow bridge
|
||||
requires a follow-up change after the core contract and evidence receipt have
|
||||
stabilized. Landing crates or passing synthetic tests therefore creates no
|
||||
live RuView forecasting capability.
|
||||
|
||||
Version one activates only the exact reviewed `tiny_ci` and `large_linux`
|
||||
architecture profiles. The model and training boundaries enforce checked
|
||||
dimension, parameter, activation-cell, input-cell, and forward multiply-add
|
||||
limits per batch. Adding or changing a profile is therefore a reviewed code and
|
||||
artifact-schema decision, not untrusted request configuration.
|
||||
|
||||
The proposed logical pipeline is:
|
||||
|
||||
```text
|
||||
authenticated RF observations
|
||||
|
|
||||
v
|
||||
versioned one-second feature windows
|
||||
|
|
||||
+----> split-scoped RuVector analogue retrieval (optional)
|
||||
|
|
||||
v
|
||||
independent Rust temporal model
|
||||
|
|
||||
v
|
||||
point forecast + ordered quantiles + validity mask + abstention
|
||||
|
|
||||
v
|
||||
signed derived-evidence record
|
||||
|
|
||||
+----> RuView policy/evidence engine
|
||||
+----> RuVLLM explanation with no numeric mutation authority
|
||||
```
|
||||
|
||||
The initial feature contract should favour compact one-second summaries rather
|
||||
than feeding an unbounded raw CSI stream. Candidate fields include motion
|
||||
energy, Doppler summary, amplitude/phase dispersion, coherence, RSSI, packet
|
||||
loss, channel utilization, current detector confidence, device temperature,
|
||||
and source-validity masks. Each field needs a schema version, physical unit,
|
||||
aggregation rule, validity semantics, and provenance. Adding a field is a
|
||||
schema change, not an implicit positional extension.
|
||||
|
||||
The model may independently combine normalization, temporal patch encoding,
|
||||
temporal mixing, cross-stream fusion, and quantile heads. This is a functional
|
||||
design space, not permission to reproduce protected source expression,
|
||||
checkpoint dimensions, constants, tests, diagrams, or API choices. Exact
|
||||
architecture and parameter count remain implementation decisions recorded in
|
||||
the model card and training receipt.
|
||||
|
||||
## Forecast contract
|
||||
|
||||
For each request the runtime returns one typed result, including abstention and
|
||||
error paths. The result must identify:
|
||||
|
||||
- input schema, feature-window, model, configuration, and calibration digests;
|
||||
- source time range, requested horizon, cadence, target streams, and masks;
|
||||
- point estimate and declared quantiles for each target/horizon cell;
|
||||
- whether quantiles were corrected for crossing and which method was used;
|
||||
- OOD, insufficient-context, stale-input, and non-finite dispositions;
|
||||
- optional RuVector index version, neighbour identifiers, distances, and
|
||||
leakage-scope receipt;
|
||||
- runtime platform class, duration, and peak-memory measurement when enabled;
|
||||
- evidence label `DERIVED_FORECAST`, never `MEASURED_OBSERVATION`;
|
||||
- deterministic content hash and optional RVF signature.
|
||||
|
||||
The runtime rejects non-finite values, duplicate or non-monotonic timestamps,
|
||||
unsupported schema versions, unbounded dimensions, invalid quantile requests,
|
||||
future covariates without an allowed source, and payloads above configured
|
||||
limits. It abstains rather than imputing an authoritative state when history or
|
||||
validity coverage is below the model-card threshold.
|
||||
|
||||
## RuVector integration
|
||||
|
||||
RuVector supplies temporal memory, not ground truth. During training and
|
||||
evaluation, each split has a separate index built only from records permitted
|
||||
for that split. A test query may retrieve training analogues, but it may never
|
||||
retrieve a window from the same subject/session/site holdout, an overlapping
|
||||
target horizon, or any test record. Fitted normalizers and retrieval thresholds
|
||||
come from training data only.
|
||||
|
||||
Every release reports three comparable rows on identical examples:
|
||||
|
||||
1. deterministic/statistical baseline;
|
||||
2. forecasting model without retrieval;
|
||||
3. forecasting model with RuVector retrieval.
|
||||
|
||||
This prevents retrieval from hiding a weak model or leaking future state.
|
||||
|
||||
## RuVLLM and action authority
|
||||
|
||||
RuVLLM consumes an immutable, signed forecast record and may produce an
|
||||
explanation referencing its uncertainty and provenance. Its prose is not the
|
||||
forecast and receives no higher evidence grade. Numeric values exposed to APIs
|
||||
come from the forecasting record, not regenerated text.
|
||||
|
||||
No forecast or LLM explanation independently triggers a health alert, emergency
|
||||
response, access decision, actuator, firmware change, model promotion, or
|
||||
commercial spend. Such actions require a separate governed policy, explicit
|
||||
capability, and appropriate observed evidence.
|
||||
|
||||
## Training and artifact boundary
|
||||
|
||||
Training jobs run from a pinned container or reproducible local environment.
|
||||
The receipt binds the source commit, lockfile, compiler, container, datasets,
|
||||
transforms, architecture, hyperparameters, seeds, hardware, provider job ID,
|
||||
start/end time, and every emitted checkpoint. Hosted workers receive only the
|
||||
least data and credentials necessary. Provider caches, logs, retention, and
|
||||
reuse rights must pass security and data review before customer-derived data is
|
||||
uploaded.
|
||||
|
||||
A release artifact is immutable and hash addressed. It is loaded locally after
|
||||
signature, schema, size, and compatibility verification. The inference process
|
||||
does not fetch models, execute embedded code, accept arbitrary operators, or
|
||||
contact the training provider.
|
||||
|
||||
## Leakage and evaluation protocol
|
||||
|
||||
- Split by deployment/site first, then subject, session, device, and contiguous
|
||||
time block as applicable. No overlapping raw sequence or derived window may
|
||||
cross a split.
|
||||
- Freeze test manifests before tuning. A failed test result does not become a
|
||||
new training target; material architecture changes require a new untouched
|
||||
holdout.
|
||||
- Fit normalization, calibration, feature selection, thresholds, and RuVector
|
||||
index parameters on training/validation data only.
|
||||
- Report missingness, abstention coverage, selective risk, per-horizon errors,
|
||||
interval coverage, quantile loss, and results by site/device/interference
|
||||
regime. Pooled performance cannot hide a failed domain.
|
||||
- Compare against last value, seasonal naive, and at least one small classical
|
||||
or recurrent baseline. TimesFM 3 output is not an acceptance oracle.
|
||||
- Forecast evaluation does not prove presence, pose, fall, respiration, or
|
||||
medical accuracy. Any downstream claim requires its own labelled protocol.
|
||||
|
||||
## Acceptance gates and requirement mapping
|
||||
|
||||
Targets below are release criteria, not current results.
|
||||
|
||||
| Gate | Requirements | Pass condition | Current state |
|
||||
|---|---|---|---|
|
||||
| G0 independent-authoring boundary | RF-001, RF-002 | Approved source allowlist; 100% current contributor attestations; zero restricted artifacts or outputs; all similarity findings adjudicated by the clean-room custodian. | **OPEN / UNMEASURED** |
|
||||
| G1 data and lineage | RF-002, RF-003, RF-010 | 100% of training/evaluation bytes resolve to approved manifests; zero unknown, noncommercial, research-only, no-ML, or no-derivatives sources; checkpoint lineage reaches approved random initialization; local/hosted receipts bind identical governed inputs. | **OPEN / UNMEASURED** |
|
||||
| G2 bounded Rust contract | RF-004, RF-005, RF-006 | Unit/property/fuzz tests cover dimensions, non-finite values, timestamp order, masks, quantile order, stale/OOD/insufficient context, deterministic hashes, offline loading, evidence labels, and resource caps; 24-hour accelerated replay has no panic or unbounded growth. | **OPEN / UNMEASURED** |
|
||||
| G3 leakage-free model evidence | RF-007, RF-008, RF-011 | Frozen site/session/device-disjoint report; all baseline and ablation rows present; nominal 80% interval coverage target is 75%-85%; weighted quantile loss target is at least 10% better than seasonal naive; every metric has a reproducer. | **OPEN / TARGETS UNMEASURED** |
|
||||
| G4 RuView shadow value | RF-006, RF-007, RF-008, RF-009 | At least 14 days shadow-only; empty-room false-alert target is at least 50% relative reduction without more than 2 percentage points occupied-room recall loss; no safety-critical action authority; drift and abstention reported by deployment. | **OPEN / TARGETS UNMEASURED** |
|
||||
| G5 deployment fitness | RF-004, RF-009, RF-010, RF-011, RF-012 | CPU p95 target is at most 1 second for 32 declared streams with peak process memory at most 4 GiB; signed model card/SBOM/provenance; privacy, security, trademark, dataset, and patent reviews; rollback drill; no open severity 1/2 issue. | **OPEN / TARGETS UNMEASURED** |
|
||||
|
||||
Failure at any gate keeps the model offline or shadow-only. Passing a software
|
||||
gate is not real-hardware evidence and does not convert an accuracy target into
|
||||
a measurement.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
The only permitted progression is:
|
||||
|
||||
```text
|
||||
OFF -> OFFLINE_EVAL -> SHADOW -> ADVISORY
|
||||
```
|
||||
|
||||
This ADR does not authorize autonomous action. Each transition records actor,
|
||||
old/new mode, artifact and configuration digests, evidence report, and reason.
|
||||
Regression, provenance failure, calibration drift, security incident, or
|
||||
licensing uncertainty returns immediately to `OFF` or `SHADOW` while observed
|
||||
RuView sensing continues unchanged.
|
||||
|
||||
Rollback deactivates the forecast artifact atomically, clears ephemeral model
|
||||
and retrieval state, preserves signed aggregate evidence and incident records,
|
||||
and never downgrades or rewrites the raw observation stream.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
Forecasting can infer routines from occupancy and movement even without raw
|
||||
CSI. Feature windows, neighbour identifiers, forecasts, and explanations are
|
||||
therefore deployment data subject to purpose limitation, tenant isolation,
|
||||
retention, deletion, access control, and audit.
|
||||
|
||||
Model artifacts, feature schemas, calibration, indexes, and manifests are
|
||||
untrusted until their signature and digest verify. Cardinality, dimensions,
|
||||
horizon, context, allocations, execution time, and concurrent requests are
|
||||
bounded. Metrics use allowlisted aggregate values and exclude raw CSI, precise
|
||||
room coordinates, persistent person identifiers, and unrestricted feature
|
||||
payloads.
|
||||
|
||||
The detailed authoring, data, AI-tool, incident, trademark, and patent controls
|
||||
are normative in
|
||||
[`../security/ruview-forecast-clean-room.md`](../security/ruview-forecast-clean-room.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- RuView gains a provider-neutral predictive evidence primitive with explicit
|
||||
uncertainty and abstention.
|
||||
- Rust-native inference can be evaluated on CPU without requiring a Python or
|
||||
cloud runtime in production.
|
||||
- RuVector retrieval becomes a measurable ablation rather than an implicit
|
||||
memory claim.
|
||||
- Signed provenance supports reproducible Linux and hosted training.
|
||||
|
||||
### Negative
|
||||
|
||||
- Independent training needs substantial diverse, correctly licensed temporal
|
||||
data and untouched deployment holdouts.
|
||||
- Strict source separation and manifests increase contributor and review cost.
|
||||
- A plausible forecast can make weak sensing look more authoritative unless
|
||||
downstream evidence labels remain intact.
|
||||
- Clean-room development does not remove patent, trademark, privacy, or dataset
|
||||
risk.
|
||||
|
||||
### Neutral
|
||||
|
||||
- This ADR does not repair an inaccurate upstream presence or pose estimator.
|
||||
- It does not approve TimesFM 3 for RuView use.
|
||||
- It does not select a final model size, training budget, or hosted provider.
|
||||
- No production checkpoint or measured benchmark is created by this decision.
|
||||
|
||||
## References
|
||||
|
||||
- [TimesFM 3 public research article](https://research.google/blog/timesfm-3-a-zero-shot-foundation-model-for-multivariate-forecasting/)
|
||||
- [Google TimesFM repository and source-license notice](https://github.com/google-research/timesfm)
|
||||
- [TimesFM 3 checkpoint license](https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE)
|
||||
- [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
- [17 U.S.C. section 102](https://www.copyright.gov/title17/92chap1.html)
|
||||
- [USPTO Patent Public Search](https://www.uspto.gov/patents/search/patent-public-search)
|
||||
- [USPTO comprehensive trademark clearance](https://www.uspto.gov/trademarks/search/comprehensive-clearance-search-similar-trademarks)
|
||||
- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md)
|
||||
- [RuView Forecast model-card template](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md)
|
||||
- [ADR-145](./ADR-145-ablation-eval-harness-privacy-leakage.md)
|
||||
- [ADR-282](./ADR-282-ruview-ecosystem-positioning.md)
|
||||
- [ADR-295](./ADR-295-source-provenance-state-machine.md)
|
||||
- [ADR-298](./ADR-298-model-release-sanity-gates.md)
|
||||
- [ADR-302](./ADR-302-out-of-distribution-detection.md)
|
||||
- [ADR-304](./ADR-304-evidence-engine.md)
|
||||
- [ADR-317](./ADR-317-benchmark-multi-domain-scorecard.md)
|
||||
- [ADR-318](./ADR-318-capability-certificates.md)
|
||||
- [ADR-319](./ADR-319-witness-chain.md)
|
||||
@@ -0,0 +1,231 @@
|
||||
# ADR-349: Governed local and fal.ai forecast training
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-09-01
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView forecast training, security, and release maintainers
|
||||
- **Tags**: forecasting, training, fal, rust, receipts, idempotency, cost-control
|
||||
- **Parent**: ADR-348
|
||||
- **Extends**: ADR-010, ADR-145, ADR-298, ADR-319, ADR-348
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
RuView Forecast will use one Rust training engine behind two deliberately
|
||||
different request boundaries. The local Linux request may bind a governed,
|
||||
hash-addressed RuView JSONL shard. Hosted v1 accepts only a bounded,
|
||||
deterministic synthetic generator recipe. It cannot serialize a local
|
||||
`TrainSpec`, `DataPolicy`, path, dataset bytes, tenant/account/workspace/site/
|
||||
device/session identity, split receipt, or RuVector namespace.
|
||||
|
||||
The fal.ai path is a bounded execution adapter, not a remote shell. It may
|
||||
train the fixed synthetic profile and export fixed artifact kinds, but it may
|
||||
not choose an arbitrary command, executable, container, URL, environment
|
||||
variable, output path, or release action. A later real-data hosted path needs a
|
||||
new accepted ADR, provider/privacy review, and an operator-signed export grant;
|
||||
it is intentionally not implemented here.
|
||||
|
||||
Every hosted result is untrusted and quarantined until it is exported with a
|
||||
complete receipt, downloaded through the authenticated provider channel,
|
||||
re-hashed locally, scanned, and bound to the original request. Only the local
|
||||
release boundary may sign or activate it. Signing keys never enter fal.ai.
|
||||
|
||||
This ADR delegates dataset rights, clean-room evidence, model-card, patent,
|
||||
trademark, and production gates to [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md)
|
||||
and the [clean-room protocol](../security/ruview-forecast-clean-room.md).
|
||||
|
||||
**Evidence status:** typed local and hosted configuration, root-confined file
|
||||
verification, atomic fixed-kind artifact publication, and cooperative
|
||||
cancellation source surfaces exist. End-to-end local training and mock hosted
|
||||
transport are software-testable in this change. Real fal.ai execution,
|
||||
provider cancellation, cost reconciliation, quarantine promotion, and local
|
||||
signature verification remain **UNMEASURED and unapproved** until their named
|
||||
acceptance evidence exists.
|
||||
|
||||
## Context
|
||||
|
||||
The same training engine needs to run on a 128 GiB Linux workstation and on an
|
||||
optional hosted accelerator without creating two architecture lineages. A
|
||||
generic remote-command interface would allow request data to become executable
|
||||
authority, make cost difficult to bound, and weaken cancellation. Sending a
|
||||
general local request to fal would also expose governance and customer
|
||||
identifiers that the hosted smoke path does not need.
|
||||
|
||||
The initial `ruview-forecast-train` crate already separates three relevant
|
||||
modules:
|
||||
|
||||
| Module | Current contract |
|
||||
|---|---|
|
||||
| [`config.rs`](../../v2/crates/ruview-forecast-train/src/config.rs) | `TrainingRequest`, `ValidatedTrainingRequest`, bounded `JobId`, root-relative dataset path, exact size/SHA-256, named model profiles, typed CPU/CUDA choice, and bounded optimizer values; unknown fields are denied |
|
||||
| [`artifact.rs`](../../v2/crates/ruview-forecast-train/src/artifact.rs) | verified open dataset handle, root confinement, fixed `Model`/`Manifest`/`Receipt`/`Checkpoint` kinds, atomic writes, content descriptors, and conflict-on-different-bytes idempotency |
|
||||
| [`cancel.rs`](../../v2/crates/ruview-forecast-train/src/cancel.rs) | `Cancellation`, `CancelToken`, and cooperative cancellation checks for batch/checkpoint boundaries |
|
||||
|
||||
The training crate features keep `cli`, `server`, `fal-client`, `cpu`, and
|
||||
`cuda` explicit, with default features empty. WGPU remains a model-crate
|
||||
inference backend and is not advertised as a training runner. The `ruforecast`
|
||||
binary requires the `cli` feature. Source presence is not execution evidence.
|
||||
|
||||
## Typed execution contracts
|
||||
|
||||
The non-serializable trusted `TrainingRequest` is built locally from a
|
||||
size-bounded `LocalTrainingRequestWire`. It contains:
|
||||
|
||||
- stable job identity;
|
||||
- a constructor-validated local split/horizon/normalization `TrainSpec`;
|
||||
- either a root-relative dataset identity with exact byte count and SHA-256,
|
||||
or a deterministic synthetic recipe;
|
||||
- one allowlisted model capacity profile and compiled-in local backend;
|
||||
- bounded optimizer, checkpoint cadence, seed, memory, step, time, checkpoint,
|
||||
and artifact budgets.
|
||||
|
||||
Local JSON and TOML deny unknown fields. Dataset paths resolve below an
|
||||
operator-configured root, and the exact regular file remains open after
|
||||
size/hash verification to avoid replacement between validation and use. JSONL
|
||||
windows are decoded incrementally with per-line and per-window caps rather
|
||||
than loading the full shard into memory.
|
||||
|
||||
The distinct `HostedSyntheticRequestV1` contains only a schema version,
|
||||
idempotency/request digests, a fixed model profile, deterministic generator
|
||||
parameters and seed, bounded optimizer/resource caps, and public build
|
||||
identities. Its constructor needs a non-serializable core governance authority
|
||||
proving that the synthetic recipe is the approved hosted source. Hosted v1 has
|
||||
no external dataset field.
|
||||
|
||||
Neither request contains a shell string, argument vector, executable path,
|
||||
container/image selector, package installer, arbitrary URL, callback URL,
|
||||
environment map, secret, source fragment, or unrestricted output name.
|
||||
|
||||
The local client maps `HostedSyntheticRequestV1` to one allowlisted,
|
||||
process-configured fal app and pre-deployed content-addressed worker image. The
|
||||
caller cannot override the app, machine, worker entry point, or artifact
|
||||
destination. The endpoint returns typed status or artifact descriptors only.
|
||||
Provider paths are derived from validated job identity and fixed artifact
|
||||
kinds, then downloaded through the authenticated fal Platform Files API; they
|
||||
are not accepted as remote URLs or local paths.
|
||||
|
||||
The deploy wrapper builds from an allowlisted `git archive`, selects the exact
|
||||
`fal_app.py::run_server` symbol, and passes `--auth private` for both `fal run`
|
||||
and `fal deploy`; ephemeral Fal runs otherwise default to public. Hosted v1
|
||||
retains only bounded synthetic request/result metadata so its typed queue result
|
||||
can be reconciled. It sets `X-Fal-No-Retry: 1` and a bounded timeout rather than
|
||||
claiming that `X-Fal-Store-IO: 0` or an unverified lifecycle header protects
|
||||
the result. A live unauthenticated 401/403 probe and provider retention/deletion
|
||||
reconciliation remain blocking operational evidence.
|
||||
|
||||
## Idempotency and cost authority
|
||||
|
||||
The local effective idempotency identity is:
|
||||
|
||||
```text
|
||||
job_id + canonical request digest + source commit + lockfile digest
|
||||
+ container digest + dataset/split digest + initial-weight digest
|
||||
```
|
||||
|
||||
Hosted v1 replaces `dataset/split digest` with the canonical synthetic-recipe
|
||||
digest. No local governance identifier is part of, or derivable from, the
|
||||
hosted payload.
|
||||
|
||||
Repeating an identical completed request returns the existing verified receipt
|
||||
and descriptors. Reusing `job_id` with any different governed input fails as a
|
||||
conflict. A retry after an ambiguous network failure must first query the same
|
||||
provider job; it must not silently create a second billable run.
|
||||
|
||||
Before hosted submission, the caller sets explicit maximum steps, wall-clock
|
||||
time, memory, checkpoint count, export bytes, and billable units. Submission
|
||||
also requires an explicit local spend-approval record bound to the request
|
||||
digest and maximum units. The adapter rejects a request when its configured
|
||||
cap exceeds that approval. The final receipt records estimated and actual
|
||||
provider units/cost when the provider supplies them, the price source/time,
|
||||
and whether the amount is final or provider-reported. A provider estimate is
|
||||
`CLAIMED`, never `MEASURED`, until reconciled against a bill.
|
||||
|
||||
No automatic retry may increase the approved budget. Budget changes create a
|
||||
new signed authorization record. The initial wire carries operator ceilings
|
||||
for wall time, billable seconds, and micro-USD, but it does not yet bind a
|
||||
provider price quote or reconcile a final provider ledger. Monetary enforcement
|
||||
therefore remains an operator/Fal-account control and the production hosted
|
||||
spend gate stays open.
|
||||
|
||||
## Cancellation and checkpoints
|
||||
|
||||
Cancellation is cooperative and idempotent:
|
||||
|
||||
1. Local signal or authenticated hosted cancel marks one `job_id` cancelled.
|
||||
2. Training checks `Cancellation` at every batch boundary and before/after each
|
||||
checkpoint/export boundary.
|
||||
3. A cancellation checkpoint is committed atomically when safe and within the
|
||||
artifact budget; a partial file never becomes a valid artifact.
|
||||
4. The terminal receipt records `cancelled`, the last completed epoch/step,
|
||||
checkpoint digest, provider state, elapsed resource units, and final known
|
||||
cost.
|
||||
5. Repeating cancel returns the same terminal state.
|
||||
|
||||
Cancellation does not promise immediate GPU termination. Cancellation latency,
|
||||
checkpoint durability, and residual provider billing are **UNMEASURED** until a
|
||||
real fal.ai run supplies a receipt. A timeout or lost cancellation response
|
||||
keeps the job and any output quarantined.
|
||||
|
||||
## Export, quarantine, and local signing
|
||||
|
||||
The worker may emit only the fixed artifact set from `ArtifactKind`:
|
||||
|
||||
| Kind | Required purpose |
|
||||
|---|---|
|
||||
| `Model` | Burn model record bytes |
|
||||
| `Manifest` | architecture, data/split, seed, build, and clean-room identity |
|
||||
| `Receipt` | request, environment, metrics, cost, status, and export lineage |
|
||||
| `Checkpoint` | final or cancellation-time model weights; v1 does not claim optimizer/cursor resume |
|
||||
|
||||
Every descriptor includes kind, size, and SHA-256. Export acceptance requires
|
||||
all mandatory descriptors, bounded lengths, exact hashes, the expected job and
|
||||
request digest, no duplicate kind, and a terminal status consistent with the
|
||||
artifact set.
|
||||
|
||||
Downloaded files enter a non-executable quarantine outside the model search
|
||||
path. Local verification repeats envelope/schema/size/hash checks, SBOM and
|
||||
malware/policy scans, clean-room declarations, dataset/split lineage, metric
|
||||
labels, and parent-checkpoint validation. No provider output may update a
|
||||
symlink, current-model pointer, RuVector index, sensing server, or release tag.
|
||||
|
||||
After quarantine passes, an authorized local signer binds the exact export
|
||||
receipt and artifact digests to an `ArtifactReceipt` and release manifest. The
|
||||
signature records algorithm, public key ID, signer capability, and time. Fal.ai
|
||||
receives no private signing material and cannot promote its own output. The
|
||||
current core supplies canonical digests and receipts, not a complete release
|
||||
signature implementation; signature-backed activation remains open.
|
||||
|
||||
## Requirements and acceptance
|
||||
|
||||
| ID | Requirement | ADR-348 gate | Acceptance evidence | Current state |
|
||||
|---|---|---|---|---|
|
||||
| FT-001 | Local and hosted adapters invoke the same model/trainer implementation while using intentionally separate local-data and hosted-synthetic request schemas. | G1 | Shared-engine tests plus hosted DTO exclusion and recipe-digest binding tests | **OPEN / UNMEASURED** |
|
||||
| FT-002 | No request field or endpoint can select arbitrary code, command, image, URL, environment, or output path. | G2 | Schema negatives, endpoint capability test, dependency review | **OPEN / UNMEASURED** |
|
||||
| FT-003 | Job/request idempotency prevents duplicate execution and conflicts on changed governed input. | G1, G5 | Concurrent/retry/lost-response tests with provider job query | **OPEN / UNMEASURED** |
|
||||
| FT-004 | Hosted execution has explicit non-escalating resource and monetary caps with estimated/actual cost receipts. | G5 | Over-budget rejection and reconciled provider-bill fixture plus real-run receipt | **OPEN / UNMEASURED** |
|
||||
| FT-005 | Cancellation is authenticated, cooperative, idempotent, checkpoint-safe, and terminally receipted. | G2, G5 | Local property tests and a real hosted cancellation drill | **OPEN / UNMEASURED** |
|
||||
| FT-006 | Export is fixed-kind, bounded, complete, hash-verified, and bound to request/job/environment. | G1, G2 | Missing/duplicate/truncated/tampered export tests | **OPEN / UNMEASURED** |
|
||||
| FT-007 | Hosted outputs remain quarantined until local verification and local-only signing succeed. | G1, G5 | Promotion-denial tests, signing-key absence check, rollback drill | **OPEN / UNMEASURED** |
|
||||
| FT-008 | Hosted v1 receives synthetic inputs only; provider credentials, logs, retention, region, output reuse, and deletion satisfy ADR-348 privacy/security approval before any real run. | G1, G5 | Hosted DTO exclusion tests, provider review, and deletion/export receipts | **OPEN / UNMEASURED** |
|
||||
|
||||
No successful unit test closes a hosted gate. G5 requires one bounded real
|
||||
fal.ai run, one cancellation drill, one ambiguous-retry/idempotency drill, cost
|
||||
reconciliation, quarantine rejection of a tampered export, and local signature
|
||||
verification over the accepted artifact. Until then fal.ai support is a typed
|
||||
software surface, not an operational capability claim.
|
||||
|
||||
## Consequences
|
||||
|
||||
The design gives local and hosted training one auditable lineage and keeps
|
||||
provider output below local release authority. It adds receipt, budget,
|
||||
quarantine, and signing work, and cooperative cancellation may still incur
|
||||
provider cost. If a hosted provider cannot support bounded idempotent status,
|
||||
export, cancellation, and deletion, the Linux path remains the only approved
|
||||
training environment.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md)
|
||||
- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md)
|
||||
- [`ruview-forecast-train` manifest](../../v2/crates/ruview-forecast-train/Cargo.toml)
|
||||
- [`ruview-forecast-core` receipts](../../v2/crates/ruview-forecast-core/src/receipt.rs)
|
||||
- [`ruview-forecast-model` public artifact boundary](../../v2/crates/ruview-forecast-model/src/lib.rs)
|
||||
@@ -0,0 +1,217 @@
|
||||
# ADR-350: RuVector predictive memory and RuVLLM authority boundary
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-09-01
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView forecast, RuVector, evidence, and RuVLLM integration maintainers
|
||||
- **Tags**: forecasting, ruvector, ruvllm, retrieval, memory, receipts, authority
|
||||
- **Parent**: ADR-348
|
||||
- **Extends**: ADR-004, ADR-010, ADR-016, ADR-145, ADR-261, ADR-295,
|
||||
ADR-304, ADR-319, ADR-348
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
RuVector may augment RuView Forecast by retrieving analogous historical
|
||||
feature/forecast states from a tenant- and split-scoped predictive-memory
|
||||
index. Retrieval is optional context, never ground truth. Every release must
|
||||
report the same frozen examples with retrieval disabled and enabled so its
|
||||
incremental value and leakage risk remain visible.
|
||||
|
||||
Each forecast is published in an immutable, locally signed envelope binding the
|
||||
model artifact, request, output, source evidence, and retrieval receipt. RuVLLM
|
||||
may read that envelope to explain uncertainty and analogous history. It may not
|
||||
rewrite numeric forecasts, create a stronger evidence class, select a model,
|
||||
promote an artifact, spend money, invoke an actuator, or make medical,
|
||||
emergency, access-control, or life-safety decisions.
|
||||
|
||||
This child ADR delegates forecasting, clean-room, data, and general production
|
||||
gates to [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md). It
|
||||
does not authorize a sensing-server bridge.
|
||||
|
||||
**Evidence status:** canonical forecast/receipt types and an indexable latent
|
||||
state exist in source. A RuVector adapter, retrieval receipt, signature wrapper,
|
||||
outcome reconciler, RuVLLM explanation adapter, paired retrieval evaluation,
|
||||
latency, storage cost, accuracy lift, and operational value are all
|
||||
**UNMEASURED and unapproved**.
|
||||
|
||||
## Existing implementation boundary
|
||||
|
||||
The current crates establish only the preconditions:
|
||||
|
||||
| Module | Implemented surface used by this ADR |
|
||||
|---|---|
|
||||
| [`ruview-forecast-core/src/forecast.rs`](../../v2/crates/ruview-forecast-core/src/forecast.rs) | validated `ForecastRequest`, backend-neutral `Forecaster`, ordered finite `Forecast`, canonical output digest, and receipt verification |
|
||||
| [`ruview-forecast-core/src/receipt.rs`](../../v2/crates/ruview-forecast-core/src/receipt.rs) | `SourceState`, `ArtifactReceipt`, and `ForecastReceipt`; derived forecasts cannot retain `MEASURED` merely because measured input existed |
|
||||
| [`ruview-forecast-core/src/series.rs`](../../v2/crates/ruview-forecast-core/src/series.rs) | bounded feature schema, masked time series, canonical series digest, and training-only scaler fit |
|
||||
| [`ruview-forecast-model/src/network.rs`](../../v2/crates/ruview-forecast-model/src/network.rs) | `ForecastModelOutput.state` with shape `[batch, variates, d_model]`, intended as a bounded representation candidate for indexing |
|
||||
|
||||
`ForecastReceipt` is content-addressed but not itself a digital signature.
|
||||
`ForecastModelOutput.state` is indexable but not automatically safe, private,
|
||||
stable across model versions, or useful. Those distinctions remain release
|
||||
gates.
|
||||
|
||||
## Predictive-memory record
|
||||
|
||||
RuVector stores a versioned `PredictiveMemoryRecord` containing:
|
||||
|
||||
- tenant and index namespace;
|
||||
- model/artifact/config/feature-schema digests;
|
||||
- source series and forecast-request digests;
|
||||
- split, site, subject-class, session, device, calibration, and bounded time
|
||||
scope using pseudonymous identifiers;
|
||||
- bounded latent-state or engineered-feature digest plus the approved vector;
|
||||
- forecast envelope digest and horizon;
|
||||
- observed validity mask and evidence class;
|
||||
- optional reconciled outcome digest added only after the forecast horizon;
|
||||
- retention/deletion class and creation/expiry time.
|
||||
|
||||
Raw CSI, unrestricted feature windows, precise room coordinates, persistent
|
||||
person identity, RuVLLM prompts, and explanation prose are excluded by default.
|
||||
The vector is still potentially sensitive because it can encode routines or
|
||||
location. Tenant isolation, encryption, access control, retention, deletion,
|
||||
and membership-inference review apply.
|
||||
|
||||
An outcome is appended through a new immutable record linked to the original;
|
||||
the historical forecast is never rewritten. Outcome reconciliation can measure
|
||||
forecast quality but cannot retroactively turn the forecast into a measured
|
||||
observation.
|
||||
|
||||
## Split-scoped analogue retrieval
|
||||
|
||||
Every index is bound to an immutable corpus manifest, model version, feature
|
||||
schema, preprocessing configuration, and split policy. These rules are
|
||||
mandatory:
|
||||
|
||||
1. Tenants never share an index or query result without a separately authorized
|
||||
privacy-preserving federation protocol.
|
||||
2. Training, validation, calibration, and test records have distinct
|
||||
namespaces. A test query may retrieve approved training analogues only; it
|
||||
may not retrieve test examples or validation/calibration records used to set
|
||||
thresholds.
|
||||
3. The same site, subject, session, device, calibration episode, overlapping
|
||||
context, or overlapping target horizon is excluded when that dimension is a
|
||||
holdout.
|
||||
4. Index construction, distance metric, normalization, filter policy, `k`, and
|
||||
score threshold are fitted on training data and frozen before test.
|
||||
5. Missing, stale, mismatched, unauthorized, or unverifiable indexes disable
|
||||
retrieval and return the no-retrieval forecast or abstention according to
|
||||
the model card. They never trigger an implicit global-index fallback.
|
||||
6. Every query returns a bounded `RetrievalReceipt` even when zero neighbours
|
||||
qualify.
|
||||
|
||||
The retrieval receipt binds query digest, index/corpus/policy digests,
|
||||
namespace, exclusion filter, neighbour record IDs and distances, `k`, latency,
|
||||
and disposition. It excludes raw neighbour payloads from ordinary logs.
|
||||
|
||||
## Paired ablation
|
||||
|
||||
Retrieval has no deployment authority without a paired evaluation on identical
|
||||
frozen requests:
|
||||
|
||||
| Row | Model and examples | Retrieval |
|
||||
|---|---|---|
|
||||
| A | exact candidate artifact and frozen examples | disabled |
|
||||
| B | exact candidate artifact and frozen examples | enabled with frozen index/policy |
|
||||
|
||||
The report includes weighted quantile loss, interval coverage/width,
|
||||
abstention, per-horizon error, per-site/device/interference slices, retrieval
|
||||
hit/filtered/empty rates, latency, and memory/storage overhead. It reports both
|
||||
aggregate and paired deltas with uncertainty. A gain on pooled error cannot
|
||||
hide a failed deployment domain, calibration regression, or neighbour leakage.
|
||||
|
||||
The no-retrieval row remains a supported fallback. Retrieval is removed when
|
||||
its lower confidence bound does not show useful improvement, when it weakens
|
||||
calibration beyond the ADR-348 gate, or when its privacy/latency/storage cost
|
||||
exceeds its measured value. No uplift is claimed today.
|
||||
|
||||
## Immutable signed forecast envelope
|
||||
|
||||
The signed envelope covers:
|
||||
|
||||
```text
|
||||
envelope schema/version
|
||||
+ ArtifactReceipt canonical digest
|
||||
+ ForecastRequest canonical digest
|
||||
+ Forecast payload/output digest
|
||||
+ ForecastReceipt canonical digest
|
||||
+ RetrievalReceipt digest or explicit retrieval-disabled marker
|
||||
+ policy/calibration/index digests
|
||||
+ creation/expiry time and tenant namespace
|
||||
```
|
||||
|
||||
Signing occurs at the trusted local boundary after artifact and retrieval
|
||||
verification. The wrapper records algorithm, public key ID, signer capability,
|
||||
and signature. Private keys never enter model artifacts, RuVector, RuVLLM, or a
|
||||
hosted training worker.
|
||||
|
||||
Consumers verify signature, expiry, tenant, schemas, every nested digest, and
|
||||
the `Forecast::verify_receipt` invariant before using numeric values. Any
|
||||
failure yields unavailable/abstain. Unsigned content-addressed receipts remain
|
||||
useful for local tests but are not described as signed and cannot cross the
|
||||
production trust boundary.
|
||||
|
||||
## RuVLLM explanation boundary
|
||||
|
||||
RuVLLM receives a read-only projection of the verified envelope:
|
||||
|
||||
- exact point and quantile values as structured fields;
|
||||
- units, horizons, calibration/OOD/abstention state;
|
||||
- bounded analogue summaries and retrieval receipt references;
|
||||
- evidence labels, model/version, and envelope digest;
|
||||
- approved explanation policy and audience.
|
||||
|
||||
Explanation prose is stored separately and linked to the envelope digest. It
|
||||
is `CLAIMED_EXPLANATION`, not a replacement forecast. Numeric API fields are
|
||||
copied from the verified envelope after generation, never parsed back from LLM
|
||||
text. If prose contradicts a structured number, evidence state, unit, or
|
||||
disposition, the response fails validation and the numeric envelope remains
|
||||
authoritative.
|
||||
|
||||
The explanation capability exposes no tools for artifact activation, model
|
||||
promotion, retraining, spending, messaging, emergency dispatch, access control,
|
||||
or actuation. A separate downstream policy may consume a forecast only under
|
||||
its own ADR, capabilities, approvals, and observed-evidence requirements. The
|
||||
LLM cannot grant itself that authority or lower an approval threshold.
|
||||
|
||||
## Requirements and acceptance
|
||||
|
||||
| ID | Requirement | ADR-348 gate | Acceptance evidence | Current state |
|
||||
|---|---|---|---|---|
|
||||
| PM-001 | Every predictive-memory query is tenant-, model-, schema-, corpus-, split-, and time-scope bound. | G3, G5 | Cross-tenant/split/version negative tests and signed index manifest | **OPEN / UNMEASURED** |
|
||||
| PM-002 | Holdout identities, overlapping contexts, and target horizons cannot appear as neighbours. | G3 | Property tests plus frozen leakage report | **OPEN / UNMEASURED** |
|
||||
| PM-003 | Every query, including disabled/empty/error, produces a bounded retrieval receipt. | G2, G3 | Receipt round-trip, tamper, bound, and zero-result tests | **OPEN / UNMEASURED** |
|
||||
| PM-004 | Retrieval-disabled and retrieval-enabled rows use identical artifact/examples and report paired metrics and overhead. | G3 | Frozen paired ablation with reproducer | **OPEN / UNMEASURED** |
|
||||
| PM-005 | Forecast envelope signatures bind all nested forecast/retrieval identities and fail closed on mutation, expiry, tenant, or key error. | G2, G5 | Signature/tamper/replay/cross-tenant tests and local signing receipt | **OPEN / UNMEASURED** |
|
||||
| PM-006 | Derived forecast and explanation can never become `MEASURED_OBSERVATION` or exceed source evidence. | G2, G4 | Evidence-monotonicity schema/property tests | **OPEN / UNMEASURED** |
|
||||
| PM-007 | RuVLLM cannot mutate structured numbers; contradictions fail validation and explanations remain separately labelled. | G4 | Numeric/unit/evidence mutation corpus and fail-closed integration tests | **OPEN / UNMEASURED** |
|
||||
| PM-008 | RuVLLM explanation has no spending, promotion, messaging, safety, access-control, or actuator capability. | G4, G5 | Default-deny capability and attempted-escalation tests | **OPEN / UNMEASURED** |
|
||||
| PM-009 | Vector/explanation privacy, retention, deletion, extraction, and membership risks are approved and operationally tested. | G5 | Privacy review, tenant deletion drill, and access audit | **OPEN / UNMEASURED** |
|
||||
|
||||
G3 additionally requires the paired retrieval ablation to satisfy ADR-348's
|
||||
forecast and calibration gates. G4 requires shadow evidence that retrieval and
|
||||
explanation improve operator comprehension or forecast value without changing
|
||||
numeric/action authority. G5 requires signed-envelope verification, rollback,
|
||||
tenant deletion, and key-rotation drills. None has passed.
|
||||
|
||||
## Consequences
|
||||
|
||||
RuVector can evolve from retrospective search into outcome-linked predictive
|
||||
memory while preserving a measurable no-retrieval baseline. RuVLLM can make
|
||||
forecasts understandable without becoming the numeric or action authority. The
|
||||
cost is additional index isolation, storage, signature, deletion, evaluation,
|
||||
and policy complexity. If paired evidence does not justify that cost, the
|
||||
correct release keeps retrieval and explanation disabled.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md)
|
||||
- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md)
|
||||
- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md)
|
||||
- [RuView Forecast model-card template](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md)
|
||||
- [RuForecast benchmark protocol](../benchmarks/ruforecast.md)
|
||||
- [ADR-016](./ADR-016-ruvector-integration.md)
|
||||
- [ADR-145](./ADR-145-ablation-eval-harness-privacy-leakage.md)
|
||||
- [ADR-261](./ADR-261-ruvector-graph-ann-index.md)
|
||||
- [ADR-304](./ADR-304-evidence-engine.md)
|
||||
- [ADR-319](./ADR-319-witness-chain.md)
|
||||
@@ -0,0 +1,162 @@
|
||||
# ADR-353: fal.ai synthetic visual teacher LoRA
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-09-02
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView visualization / neural-styling maintainers
|
||||
- **Tags**: fal, lora, synthetic-visualization, display-only, cost-control
|
||||
- **Parent**: None
|
||||
- **Extends**: None
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
RuView will use one real, fal.ai-trained LoRA fine-tune (a "synthetic visual
|
||||
teacher") to steer fal.ai image/video generation toward a consistent
|
||||
"RuView-style" room visualization — glowing pose overlays, WiFi signal arcs,
|
||||
vitals readout panels, dark tech aesthetic — instead of relying on ad hoc
|
||||
prompt engineering against a stock model.
|
||||
|
||||
**The architecture boundary is non-negotiable:**
|
||||
|
||||
> The implementation boundary is now clear: fal.ai will train an optional
|
||||
> synthetic visual teacher, while RuView's measured RF, pose, vitals,
|
||||
> identity, and confidence remain local and authoritative.
|
||||
|
||||
Concretely:
|
||||
|
||||
- The trained model produced here is ONLY ever allowed to generate
|
||||
supplementary synthetic visual/pixel output for display or broadcast.
|
||||
- It must NEVER be a source of, or influence, any measured/derived value: RF/
|
||||
CSI measurements, pose estimates, vitals (heart rate/breathing rate),
|
||||
identity, or confidence/quality scores. Those stay local, deterministic,
|
||||
and authoritative — the existing `wifi-densepose-engine` /
|
||||
`ruview-twin` / `ruview-witness` stack — completely untouched by this work.
|
||||
- Any output this model produces must be clearly framed as SYNTHETIC per this
|
||||
repo's rule: "Never present WiFi sensing as camera-grade."
|
||||
- This model has no read or write path into the RF/pose/vitals/identity
|
||||
pipeline. It consumes only a text prompt (and optionally a reference
|
||||
image) and returns pixels for display.
|
||||
|
||||
**Evidence status:** the training data extraction, training submission
|
||||
script, and validation script are real and committed. The training run
|
||||
completed and validation succeeded — trained model id, actual spend, and the
|
||||
validation sample are recorded in "Result" below. Actual-vs-billed cost
|
||||
reconciliation against the fal.ai account dashboard remains UNMEASURED (the
|
||||
figure below is a documented-pricing estimate).
|
||||
|
||||
## Context
|
||||
|
||||
A concurrent workstream (`ruview-fal-room-viz` / `ruview-room-viz-h3-upgrade`)
|
||||
generated real fal.ai room-visualization videos across several style presets
|
||||
(architectural, abstract, branded, and an "h3" refresh: architectural,
|
||||
cinematic, minimal, abstract) using stock fal.ai video models driven by
|
||||
prompt engineering alone. A separate concurrently-running agent
|
||||
("realtime-dual-renderer") is building a neural-styling layer that needs a
|
||||
*specific* trained model id to call, rather than continuing to rely on prompt
|
||||
engineering against a generic model.
|
||||
|
||||
This ADR's job is narrow: turn the real captured style presets into a real
|
||||
trained fal.ai LoRA, verify it actually works, and hand off the model id. It
|
||||
does not implement the renderer integration — that is
|
||||
`realtime-dual-renderer`'s scope — and it does not touch the measured
|
||||
RF/pose/vitals/identity/confidence pipeline in any way.
|
||||
|
||||
## Training data
|
||||
|
||||
Source: the real fal.ai-generated room-visualization MP4s already captured
|
||||
this session (read from `/tmp/room-viz-*/room-viz.mp4` on `ruvultra`, one per
|
||||
style preset — not committed to the repo; the repo has no camera or person
|
||||
data in these clips, they are synthetic renders). `scripts/fal-visual-teacher/extract_frames.py`
|
||||
extracts one JPEG frame every 1.5 seconds from each of the 7 real style-preset
|
||||
videos (architectural, abstract, branded, h3-architectural, h3-cinematic,
|
||||
h3-minimal, h3-abstract), producing 27 frames total, each paired with a
|
||||
caption `.txt` describing the shared RuView visual motifs. Frames and the
|
||||
training zip are gitignored (`scripts/fal-visual-teacher/training-data/`,
|
||||
`training-data.zip`) — they are regenerable from the source MP4s and are not
|
||||
committed, consistent with the repo's no-generated-artifacts rule.
|
||||
|
||||
## Training endpoint and pricing (verified 2026-09-02)
|
||||
|
||||
- **Endpoint**: `fal-ai/flux-lora-fast-training` — a real, currently-listed
|
||||
fal.ai LoRA training endpoint for FLUX.1, verified against
|
||||
`https://fal.ai/models/fal-ai/flux-lora-fast-training/api`.
|
||||
- **Pricing**: $2 per training run at the default 1000 steps, scaling
|
||||
linearly with steps (verified against fal.ai's own pricing text on the
|
||||
model page).
|
||||
- **Mode**: submitted with `is_style: true` (style LoRA, not
|
||||
subject/character LoRA) and explicit per-frame caption files, so fal.ai's
|
||||
auto-captioning/segmentation is skipped in favor of our own captions.
|
||||
- **Inference endpoint** (validation only): `fal-ai/flux-lora`, $0.035 per
|
||||
megapixel, accepting `loras: [{ path, scale }]` pointing at the trained
|
||||
`diffusers_lora_file` URL.
|
||||
|
||||
## Budget
|
||||
|
||||
- **Original cap**: $15 total (this task's cap only; not shared with other
|
||||
concurrent tasks' budgets this session).
|
||||
- **Raised cap**: the user explicitly raised this task's cap to **$100** on
|
||||
2026-09-02, mid-run, as real headroom for a larger/better run or retries —
|
||||
not an instruction to spend all of it.
|
||||
- **Planned spend**: ~$2 for the training run (1000 steps, default) + ~$0.04
|
||||
per validation inference image.
|
||||
- **Actual spend**: ~$2.04 (one 1000-step training run at $2/run + one
|
||||
landscape_16_9 validation image at $0.035/megapixel, rounded up to 1
|
||||
megapixel). This is a documented-pricing estimate (`CLAIMED`), not yet
|
||||
reconciled against the fal.ai account billing dashboard (`MEASURED`).
|
||||
Comfortably under both the original $15 cap and the raised $100 cap.
|
||||
- **Why no larger follow-up run**: the first validation image already showed
|
||||
a clean, on-style result (glowing pose overlay, WiFi arcs, vitals panels,
|
||||
dark aesthetic, all present and legible) on the first attempt. Spending
|
||||
further into the raised budget for marginal improvement was judged not
|
||||
worth it against a result that already meets the bar; the fallback path in
|
||||
"Consequences" was not needed.
|
||||
|
||||
## Result
|
||||
|
||||
- **Trained model id / LoRA weights URL**:
|
||||
`https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors`
|
||||
(131 MB `diffusers_lora_file`, paired `config.json` at
|
||||
`https://v3b.fal.media/files/b/0aa8c60d/-GU6dd_2ZFqq18I0CuPOB_config.json`).
|
||||
Use with `fal-ai/flux-lora` (or any fal.ai FLUX.1 endpoint accepting a
|
||||
`loras: [{ path, scale }]` array) — trigger word `ruviewstyle`. Full
|
||||
training record: `scripts/fal-visual-teacher/output/training-result.json`
|
||||
(gitignored — regenerate by re-running `train_lora.py`, or copy the URL
|
||||
above).
|
||||
- **Training request id**: `01a060b2-c940-7972-abf9-ea48847eb2ae` (fal.ai
|
||||
`fal-ai/flux-lora-fast-training`, 1000 steps, `is_style: true`, 27 training
|
||||
images, completed in 376s on an H100/H200 worker).
|
||||
- **Actual spend**: ~$2.04 (see Budget above).
|
||||
- **Validation sample**: `assets/fal-visual-teacher-sample.png` (+ sibling
|
||||
`assets/fal-visual-teacher-sample.json` with the exact prompt, LoRA scale
|
||||
1.0, and source fal.ai URL) — a real `fal-ai/flux-lora` inference call
|
||||
using the trained LoRA, generated 2026-09-02. The image shows a glowing
|
||||
cyan pose skeleton, WiFi signal arcs, vitals-style readout panels, and a
|
||||
HUD-style TV overlay in a dark living room — confirming the LoRA learned
|
||||
and reproduces the intended RuView visual motifs.
|
||||
- **Consumer**: `realtime-dual-renderer` (separate worktree
|
||||
`~/projects/ruview-worktrees/realtime-render`) — notified directly with the
|
||||
model id above.
|
||||
|
||||
## Consequences
|
||||
|
||||
A real trained LoRA gives the display layer a specific, repeatable style
|
||||
target instead of prompt engineering against a stock model, at a bounded and
|
||||
small real cost ($2 training + cents per generated image). Because the model
|
||||
only ever produces pixels for a prompt, and has no schema path into any
|
||||
measured/derived RuView value, it cannot silently become a second source of
|
||||
truth for pose, vitals, identity, or confidence — that boundary is structural
|
||||
(no shared types, no shared data path), not just documented convention.
|
||||
|
||||
If the trained LoRA does not meaningfully improve style consistency over the
|
||||
stock model (a genuine risk with 27 training images and default steps), the
|
||||
fallback is to keep using prompt engineering against the stock model and note
|
||||
that outcome honestly here rather than claim a training win that isn't real.
|
||||
|
||||
## References
|
||||
|
||||
- `scripts/fal-visual-teacher/extract_frames.py` — real frame extraction
|
||||
- `scripts/fal-visual-teacher/train_lora.py` — real training submission
|
||||
- `scripts/fal-visual-teacher/validate.py` — real validation inference
|
||||
- [fal.ai Flux LoRA Fast Training](https://fal.ai/models/fal-ai/flux-lora-fast-training)
|
||||
- [fal.ai FLUX.1 with LoRAs (inference)](https://fal.ai/models/fal-ai/flux-lora)
|
||||
@@ -0,0 +1,89 @@
|
||||
# ADR-354: fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-09-02
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView forecast training, security, and release maintainers
|
||||
- **Tags**: forecasting, training, fal, governance, submodule, cost-control
|
||||
- **Parent**: ADR-349
|
||||
- **Extends**: ADR-348, ADR-349
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
ADR-349 specified two governance controls for RuView Forecast's fal.ai
|
||||
hosted path: operator-verified dataset/schema/policy/recipe authorization,
|
||||
and an explicit local spend-approval record bound to the request digest and
|
||||
maximum units. A 9-agent code review of the training stack merged as PR
|
||||
#1766 found that neither control was actually wired into the code path that
|
||||
builds a submittable, fal-destined training contract — `FalGovernanceVerifier`
|
||||
existed with zero callers outside its own tests, and no spend-approval type
|
||||
existed anywhere in the codebase.
|
||||
|
||||
That code no longer lives directly in this repository. Between the review
|
||||
and this fix, `v2/crates/ruview-forecast-{core,model,train}` was extracted
|
||||
into its own repository, `github.com/ruvnet/RuForecast`, and is now vendored
|
||||
back into `v2/crates/ruforecast` as a git submodule (crates renamed to
|
||||
`ruforecast-{core,model,train}`). This ADR records two things at the RuView
|
||||
level:
|
||||
|
||||
1. **The governance and spend-approval gaps are fixed**, in
|
||||
`ruvnet/RuForecast` PR #2 (branch `fix/governance-and-spend-enforcement`).
|
||||
The full technical decision record — what changed, why, and the
|
||||
requirements/acceptance table — lives in that repository's own
|
||||
[ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md),
|
||||
which this ADR defers to rather than duplicating. In summary: a
|
||||
fal-destined `TrainSpec` can no longer be constructed without a real,
|
||||
operator-signed, single-use-verified governance receipt; a hosted
|
||||
reservation can no longer be built without a real, operator-signed spend
|
||||
approval bound to the exact request digest and cost ceiling; and several
|
||||
related correctness/resilience gaps on the same trust boundary
|
||||
(unauthenticated Direct Server webhook, no execution timeout, mismatched
|
||||
local/hosted artifact budgets, cancellation permanently wedging a job)
|
||||
were fixed in the same pass.
|
||||
2. **The submodule pointer in this repository is bumped** to the commit
|
||||
containing that fix, once `ruvnet/RuForecast`'s own CI is green on that
|
||||
PR and it merges. This RuView-side change is deliberately small: a
|
||||
pointer bump plus this ADR, not a re-implementation or re-review of code
|
||||
that now lives, is tested, and is reviewed in its own repository.
|
||||
|
||||
## Context
|
||||
|
||||
RuView's own copies of ADR-348 and ADR-349 predate the extraction and still
|
||||
describe the crates as if they lived directly under `v2/crates/`. They are
|
||||
left as-is here (not corrected path-by-path) since the canonical, actively
|
||||
maintained copies of the forecast-specific ADRs now live in
|
||||
`ruvnet/RuForecast`'s own `docs/adr/`, which was seeded with copies of
|
||||
ADR-348/349 at extraction time and now also carries ADR-350. Consult that
|
||||
repository for the forecast subsystem's own decision history going forward;
|
||||
this repository's ADR series should record RuView-level integration
|
||||
decisions about the vendored submodule (like this one), not attempt to
|
||||
re-host the submodule's internal history.
|
||||
|
||||
This repo's CLAUDE.md requires an ADR for load-bearing upstream changes.
|
||||
The governance-enforcement fix is load-bearing for RuView even though none
|
||||
of its source lines are: it changes what RuView's vendored forecast
|
||||
subsystem actually requires before it will reach real, billable fal.ai
|
||||
infrastructure on behalf of a RuView deployment.
|
||||
|
||||
## Consequences
|
||||
|
||||
Once the submodule pointer here is bumped, any RuView-side code, CLI
|
||||
wrapper, or documentation that assumes the old, unenforced fal.ai
|
||||
submission path (a bare `SourceState::synthetic` claim with no verified
|
||||
governance receipt or spend approval) will no longer compile or run against
|
||||
the vendored crate — `TrainSpec::new_fal_synthetic` and
|
||||
`ReservedSyntheticSubmission::reserve` both gained new required parameters.
|
||||
A repository-wide search found no such RuView-side caller today; this ADR
|
||||
records the change in case one is added later without checking the
|
||||
submodule's current API first.
|
||||
|
||||
This pointer bump must not merge before `ruvnet/RuForecast` PR #2 merges and
|
||||
its CI is green — see that PR for status.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md) (this repository's copy; canonical copy is in `ruvnet/RuForecast`)
|
||||
- [ruvnet/RuForecast PR #2](https://github.com/ruvnet/RuForecast/pull/2)
|
||||
- [ruvnet/RuForecast ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md)
|
||||
- [`v2/crates/ruforecast`](../../v2/crates/ruforecast) (submodule)
|
||||
@@ -0,0 +1,134 @@
|
||||
# ADR 355: Local empty room bootstrap prior
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-04
|
||||
|
||||
## Context
|
||||
|
||||
Most installations are occupied when RuView first starts. An uncalibrated
|
||||
signal heuristic can interpret HVAC, clock error, neighboring motion, or a
|
||||
short covariance window as a person. It can also surface plausible numeric
|
||||
vitals before a room model establishes occupant attribution.
|
||||
|
||||
One empty home is not representative training data for all homes and contains
|
||||
no positive human examples. It is useful only as a recent negative prior for
|
||||
the same installation. It cannot define a universal human signal model.
|
||||
|
||||
The field model was memory only. The server also used a relative `data/`
|
||||
directory, which is unreliable for embedded application hosts.
|
||||
|
||||
## Decision
|
||||
|
||||
RuView may persist a completed empty room field model as a local bootstrap
|
||||
prior only after two server controlled stages:
|
||||
|
||||
1. Normal calibration reaches both 1,000 accepted frames and 600 seconds.
|
||||
The frame target represents twenty complete fifty frame runtime windows.
|
||||
A caller may select one currently live ESP32 source. The server checks its
|
||||
freshness before replacing an active bootstrap prior, binds collection to
|
||||
that source, and rejects frames from other radios. When no source is
|
||||
selected, the first accepted ESP32 source preserves legacy behavior.
|
||||
2. Promotion observes 12 new samples spaced by at least one second. Each
|
||||
sample has a four second bounded acquisition timeout. At least 10 must
|
||||
resolve empty, all ticks must be fresh, and none may publish numeric vital
|
||||
signs.
|
||||
|
||||
The stored image contains aggregate means, environmental modes, mode energies,
|
||||
noise statistics, sorted scalar residual references, configuration, one source
|
||||
node ID, and a server generated model ID. It excludes raw CSI, waveforms,
|
||||
device addresses, room names, pose, identity, credentials, and positive human
|
||||
labels. Runtime matching compares a fifty frame mean with the learned residual
|
||||
distribution. Its score is empirical background conformance, not an absence
|
||||
probability.
|
||||
|
||||
The image is bound to a SHA 256 digest of the stable installation ID, carries
|
||||
an integrity digest over the exact stored payload bytes, is capped at 1 MiB,
|
||||
and inherits the field model expiry.
|
||||
Files with an invalid schema, dimensions, values, node set, installation
|
||||
binding, digest, size, or lifetime fail closed.
|
||||
|
||||
Restoring an image creates a field model in `bootstrap_only` mode. It may
|
||||
reduce an uncalibrated false person count, but it cannot authorize calibrated
|
||||
evidence or numeric heart and breathing rates. Explicit calibration replaces
|
||||
it with fresh statistics. Cancelling an unfinished capture restores a valid
|
||||
prior. Administrator scoped reset removes it.
|
||||
|
||||
A restored image may receive one operator confirmed held out empty refinement.
|
||||
The server measures 12 fresh, source bound runtime residuals at one second
|
||||
spacing and requires zero stale samples and zero numeric vital samples. It
|
||||
persists only the scalar residuals. The new boundary is the larger of the
|
||||
existing threshold and 110 percent of the held out 95th percentile, capped at
|
||||
125 percent of the prior threshold. The one use marker persists in the model,
|
||||
so repeated requests cannot ratchet the threshold upward.
|
||||
|
||||
The status endpoint reports the learned runtime window, residual threshold,
|
||||
reference count, current background conformance, and the negative only
|
||||
authority boundary. A restored model never resumes collection and never
|
||||
persists raw calibration frames.
|
||||
|
||||
The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application
|
||||
host to provide a protected state directory.
|
||||
|
||||
## Consequences
|
||||
|
||||
Startup can use recent measured background structure instead of only generic
|
||||
heuristics. No accuracy lift is claimed until occupied and empty held out data
|
||||
from multiple installations is recorded.
|
||||
|
||||
The largest failure mode is overfitting to a temporarily quiet home and
|
||||
suppressing a weak occupant. Controls are low authority, 24 hour expiry,
|
||||
installation binding, vital abstention, explicit recalibration, and reset.
|
||||
|
||||
A human signal model still requires positive, consented, leakage separated
|
||||
occupied sequences. Empty data is never relabeled as human training.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. `wifi-densepose-signal::ruvsense::field_model` exports and validates
|
||||
`FieldModelSnapshotV1`.
|
||||
2. `wifi-densepose-sensing-server::bootstrap_baseline` owns schema, integrity,
|
||||
storage, lifetime, and the held out gate.
|
||||
3. `POST /api/v1/calibration/bootstrap/promote` measures the validation window
|
||||
and stores the image only on pass.
|
||||
4. `GET /api/v1/calibration/status` reports bootstrap authority and explicitly
|
||||
denies calibrated evidence and numeric vital authority.
|
||||
5. Numeric vitals fail closed unless an explicit fresh calibration resolves
|
||||
exactly one occupant at sufficient signal quality and confidence.
|
||||
6. The legacy `edge_vitals` WebSocket message applies the same publication
|
||||
gate as `sensing_update`. When the bootstrap background matches, it emits
|
||||
absent state, zero people, null numeric rates, and an explicit abstention
|
||||
reason instead of forwarding raw edge candidates.
|
||||
7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for
|
||||
deterministic single link capture. A missing or stale selection fails
|
||||
without disabling the active bootstrap prior.
|
||||
8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N`
|
||||
performs the bounded one time held out refinement. It requires administrator
|
||||
authority when transport authentication is enabled.
|
||||
|
||||
## Measured installation evidence
|
||||
|
||||
One physical empty home run on source Node 5 collected 1,762 accepted frames
|
||||
over 655 seconds and produced 35 privacy reduced reference windows. Promotion
|
||||
observed 12 of 12 fresh empty samples, zero stale samples, and zero numeric
|
||||
vital samples. After process restart, 12 of 12 fresh samples matched the empty
|
||||
background and contained no numeric vital signs. Background conformance scores
|
||||
ranged from 50.0 to 64.3 percent with a median of 54.3 percent.
|
||||
|
||||
This is measured negative evidence for one installation. No occupied positive
|
||||
holdout was recorded, so it does not prove person recall, person counting, or
|
||||
cross home accuracy improvement.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
With a physically empty room, complete calibration on stable nodes, promote
|
||||
the single source model, and restart the server with the same installation ID
|
||||
and data directory. Status must report `binding_mode=bootstrap_only`, zero
|
||||
collection frames, and false authority for calibrated evidence and numeric
|
||||
vitals. Twelve fresh empty samples must contain no numeric vital signs.
|
||||
Starting a new room calibration must replace the prior and begin at frame zero.
|
||||
When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of
|
||||
the source set for the entire capture.
|
||||
After restart, a confirmed empty holdout may refine the restored boundary once.
|
||||
The stored snapshot must reject a second refinement and must continue to deny
|
||||
calibrated evidence and numeric vital authority.
|
||||
@@ -0,0 +1,151 @@
|
||||
# ADR 356: Rate truthful CSI and reliability gated room adaptation
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-05
|
||||
|
||||
## Context
|
||||
|
||||
RuView mixed three different notions of time. ESP32 firmware requested a packet
|
||||
rate, the server observed a delivered rate, and several server paths still used
|
||||
fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named
|
||||
`dominant_freq_hz` was not a temporal frequency at all. It was the index of the
|
||||
strongest subcarrier multiplied by 0.05. A second defect compared a newly
|
||||
inserted frame with itself, forcing that motion delta to zero.
|
||||
|
||||
These errors can move a spectral peak into the breathing or heartbeat band and
|
||||
produce plausible but false values. They also make models sensitive to WiFi
|
||||
traffic and processor scheduling rather than physical motion.
|
||||
|
||||
ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four
|
||||
CSI bytes invalid because of a hardware limitation. It also documents that the
|
||||
CSI receive callback runs in the WiFi task and should hand work to a lower
|
||||
priority task rather than perform lengthy processing. RuView serialized the
|
||||
invalid bytes unchanged and fed the original buffer to edge processing.
|
||||
|
||||
Recent work suggests four useful directions with different maturity:
|
||||
|
||||
1. OpenCSI represents each link as a dimensionless residual against its own
|
||||
quiet period temporal standard deviation and exposes baseline maturity. Its
|
||||
reported transfer results are limited to binary presence.
|
||||
2. MORIC derives motion centered delay and Doppler views to reduce dependence
|
||||
on static environment specific CSI structure.
|
||||
3. Parameter efficient CSI crowd counting combines self supervised pretraining,
|
||||
small room adapters, and a stateful counting machine.
|
||||
4. WiFi JEPA masks complete radio links and predicts their latent embeddings,
|
||||
using real and ray traced CSI for pose representation learning.
|
||||
|
||||
The papers report promising external results. None is measured RuView evidence,
|
||||
and none authorizes deployment without leakage free occupied and empty held out
|
||||
evaluation.
|
||||
|
||||
## Decision
|
||||
|
||||
### Firmware ingestion
|
||||
|
||||
1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized
|
||||
CSI bytes while retaining the fixed packet shape.
|
||||
2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred.
|
||||
3. Feed edge DSP from the sanitized serialized payload, never the original
|
||||
invalid driver buffer.
|
||||
4. Keep callback work bounded to sanitation, serialization, and the existing
|
||||
queue handoff. Do not add transforms to the WiFi task.
|
||||
|
||||
### Server time model
|
||||
|
||||
1. Estimate delivered CSI rate from accepted monotonic frame timestamps after
|
||||
five samples. Use 20 Hz only during startup.
|
||||
2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or
|
||||
timestamp artifact cannot retune downstream filters above their supported
|
||||
range.
|
||||
3. Reconfigure the vital detector only after a 20 percent rate change. Clear
|
||||
temporal and smoothing state whenever its clock changes. Samples calculated
|
||||
with different clocks must never be blended.
|
||||
4. Compute temporal dominant frequency from the recent mean amplitude series
|
||||
with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to
|
||||
the smaller of 4 Hz or 45 percent of sample rate.
|
||||
5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows.
|
||||
6. Compare the current frame with the prior frame, not itself, for motion delta.
|
||||
7. Export zero sample rate until the estimate is mature. Never present a startup
|
||||
fallback as a measured node property.
|
||||
|
||||
### Model compatibility and room reliability
|
||||
|
||||
1. Preserve the historical fifth input feature for version 1 adaptive models at
|
||||
the model boundary. Version 2 and later models consume the true temporal
|
||||
frequency. This prevents an existing model from silently receiving a
|
||||
different input distribution after a server update.
|
||||
2. A background model is unreliable until it contains at least ten finite,
|
||||
nonnegative residual references. An unreliable model cannot suppress a
|
||||
runtime change and exposes no conformance score.
|
||||
3. Expose background maturity, reliability, and a robust residual Z score based
|
||||
on the median and median absolute deviation.
|
||||
4. Keep the persisted empty home prior negative only. It cannot authorize
|
||||
calibrated presence, person count, pose, or numeric vitals.
|
||||
|
||||
### Model roadmap
|
||||
|
||||
The following work remains proposal only:
|
||||
|
||||
1. Evaluate an OpenCSI style per link quiet residual normalization for binary
|
||||
presence without replacing absolute magnitude features used for motion.
|
||||
2. Evaluate delay and Doppler views after phase sanitation for activity.
|
||||
3. Pretrain a frozen multilink encoder and adapt small room specific modules for
|
||||
counting.
|
||||
4. Evaluate link masked self supervision for pose only when at least three
|
||||
spatially distinct links and synchronized positive pose labels exist.
|
||||
|
||||
Every candidate uses immutable manifests and splits by room, subject,
|
||||
recording, and time. It is compared with the frozen baseline under identical
|
||||
seeds. Counting includes confusion matrices and mean absolute error. Pose
|
||||
includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled
|
||||
label control must fail.
|
||||
|
||||
Promotion requires at least 10 percent held out lift, less than 10 milliseconds
|
||||
added p95 inference latency, no more than 20 percent peak memory overhead, zero
|
||||
leakage or secret findings, frozen anchor retention, and no regression in empty
|
||||
rejection or occupied recall. Promotion remains an explicit maintainer action.
|
||||
|
||||
## Consequences
|
||||
|
||||
The firmware no longer preserves a known invalid prefix as sensor evidence. The
|
||||
server uses an observed physical clock for spectral features and vital filters.
|
||||
Existing version 1 adaptive models remain compatible. Sparse background samples
|
||||
fail open to observation and fail closed for suppression.
|
||||
|
||||
The direct Fourier estimator is intentionally small and bounded to 128 temporal
|
||||
samples. It is portable and deterministic, but it is not a replacement for a
|
||||
phase sanitized delay and Doppler pipeline.
|
||||
|
||||
The largest remaining uncertainty is human recall. Empty home evidence can
|
||||
measure false presence but cannot show whether weak occupied signals are being
|
||||
suppressed. The fix path is a consented, leakage separated occupied holdout with
|
||||
multiple spatial links and synchronized reference labels.
|
||||
|
||||
## Research basis
|
||||
|
||||
1. Espressif ESP-IDF WiFi CSI programming guide:
|
||||
https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html
|
||||
2. Espressif ESP CSI reference implementation:
|
||||
https://github.com/espressif/esp-csi
|
||||
3. OpenCSI:
|
||||
https://arxiv.org/abs/2607.26665
|
||||
4. MORIC:
|
||||
https://arxiv.org/abs/2506.12997
|
||||
5. Parameter efficient CSI crowd counting:
|
||||
https://arxiv.org/abs/2601.02203
|
||||
6. WiFi JEPA:
|
||||
https://arxiv.org/abs/2607.11064
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Run the firmware serializer host tests and the focused hardware, signal, and
|
||||
sensing server Rust suites. The deterministic serializer test must prove the
|
||||
first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must
|
||||
round trip through the Rust parser. The temporal estimator must recover a
|
||||
1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and
|
||||
remain below 10 milliseconds p95 on the target Mac. Existing version 1 model
|
||||
input must retain the legacy proxy. A background model with nine references
|
||||
must never report a match or suppress a runtime change. Physical firmware is
|
||||
not qualified until an S3 and C6 boot and stream the sanitized flag without
|
||||
watchdog, memory, or transport regressions during a five minute burn in.
|
||||
@@ -0,0 +1,143 @@
|
||||
# ADR 357: Raw CSI calibration sample integrity
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-06
|
||||
|
||||
## Context
|
||||
|
||||
The empty room field model requires 1,000 accepted observations over at least
|
||||
600 seconds. The sensing server previously invoked calibration from both the
|
||||
raw CSI path and the one second edge vital path. Edge vital packets did not add
|
||||
a CSI observation, so that path repeatedly fed the existing history tail. A
|
||||
counter could therefore satisfy the frame gate without representing 1,000
|
||||
independent CSI packets.
|
||||
|
||||
Node liveness also combined edge vital and CSI traffic. A source emitting only
|
||||
edge vital summaries could appear live enough to start calibration even though
|
||||
it had no fresh raw CSI. Finally, cloning the complete per node history before
|
||||
each feed copied as much as 204,800 bytes for a 100 frame, 256 subcarrier
|
||||
window even though calibration consumes only the current observation.
|
||||
|
||||
The earlier single installation result recorded in ADR 355 predates this
|
||||
admission boundary. Its frame count is not accepted as evidence of independent
|
||||
CSI coverage. It remains useful only as diagnosis of the failure mode and must
|
||||
not be promoted as a production bootstrap prior.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Only a packet on the frozen, model specific CSI grid may advance field
|
||||
calibration. Edge vital packets update their own health and presentation
|
||||
state but never feed the field model. The independent live inference grid
|
||||
remains unchanged.
|
||||
2. Each calibration session stores the last admitted wrapping sequence for
|
||||
every contributing node. Duplicate sequences are ignored. Normal forward
|
||||
progress and the `u32::MAX` to zero wrap are accepted.
|
||||
3. A packet one through sixteen sequence values behind the current high water mark
|
||||
is bounded UDP reordering. It is recorded and dropped without feeding the
|
||||
model or moving the high water mark. A backward depth above sixteen, or an
|
||||
ambiguous half range sequence, latches that node as faulted for the remainder
|
||||
of the session. Status exposes the policy, reorder window, per node reorder
|
||||
count, maximum observed depth, and `sequence_fault_node_ids`. Finalization
|
||||
returns `calibration_sequence_discontinuity` after a fault. The operator must
|
||||
cancel and restart the empty room capture.
|
||||
4. A requested source may start calibration only after a header only, bounded
|
||||
observation window identifies one stable grid. The selected grid needs at
|
||||
least ten seconds of evidence, a measured rate of at least 2 Hz, a latest
|
||||
sample newer than five seconds, and no observed gap of five seconds or more.
|
||||
Calibration status reports the immutable grid, selection evidence, current
|
||||
grid age, and `last_sequence_by_node` without exposing CSI values.
|
||||
5. Start, cancel, reset, and process initialization clear sequence admission
|
||||
state. A failed feed does not advance the sequence cursor, so the same
|
||||
packet may be retried after a transient model input failure.
|
||||
6. The field bridge receives the current amplitude slice rather than a cloned
|
||||
history. A separate bounded runtime history retains only that same frozen
|
||||
grid, so the model is never trained on one symbol layout and evaluated on a
|
||||
different layout. Canonical grid normalization remains unchanged.
|
||||
7. The exact source grid is stored inside the digest protected bootstrap v2
|
||||
payload. A restored model rebuilds only its matching runtime history. An
|
||||
unbound legacy image cannot become an active startup prior.
|
||||
8. Successful finalization clears the selected grid runtime history. Bootstrap
|
||||
promotion waits up to 35 seconds for a complete fresh runtime window before
|
||||
scoring twelve additional samples. This covers a 50 frame window at the 2 Hz
|
||||
admission floor plus ten seconds of bounded jitter. Calibration observations
|
||||
therefore cannot enter the held out decision window.
|
||||
9. Stop and promotion require the frozen grid to remain fresh and reject any
|
||||
latched sequence fault immediately before persistence. Bootstrap v2 accepts
|
||||
exactly one source node because the current runtime supports one grid bound
|
||||
field model.
|
||||
10. The legacy `--calibrate` startup switch fails closed before opening ports.
|
||||
Grid evidence does not exist at process start, so operators start the server,
|
||||
wait ten seconds, then call the explicit single source calibration API.
|
||||
|
||||
## Consequences
|
||||
|
||||
Calibration frame count now means unique, forward raw CSI observations within
|
||||
one uninterrupted sequence epoch. A vitals only node cannot produce a
|
||||
bootstrap model. Late packets never become calibration evidence.
|
||||
|
||||
One physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563,
|
||||
4,119,564, and 4,119,565 in one receive batch. A second independent 90 second
|
||||
capture observed bounded late packets at depths 9, 10, and 11. The window of
|
||||
sixteen covers the measured maximum with five positions of margin. At the
|
||||
firmware acceptance ceiling of 50 Hz, the window represents no more than 320
|
||||
milliseconds. Late packets are always dropped. Larger regressions remain
|
||||
failed closed.
|
||||
|
||||
The same 90 second capture measured three interleaved HT grids on Node 5. The
|
||||
64 subcarrier stream delivered 436 frames at 4.84 Hz with a 1.56 second maximum
|
||||
gap. The 128 stream delivered 100 frames at 1.11 Hz with a 5.05 second maximum
|
||||
gap. The 192 stream delivered 49 frames at 0.54 Hz with an 11.53 second maximum
|
||||
gap. Only the 64 grid exceeded the 2 Hz admission floor and stayed inside the
|
||||
five second freshness boundary, so the final field model binds to that grid.
|
||||
|
||||
The current wire format still has no authenticated boot epoch or stable device
|
||||
identity. A restart very early in a session or a same endpoint adversary cannot
|
||||
be distinguished cryptographically. A future ADR 018 revision must carry an
|
||||
authenticated device identifier and a random per boot identifier before the
|
||||
server claims exact restart or identity collision detection.
|
||||
|
||||
No new raw CSI is persisted. Sequence cursors and fault markers remain bounded
|
||||
in memory to active calibration sources. The bootstrap schema advances to v2
|
||||
to protect the grid identity while its negative only authority remains
|
||||
unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. `NodeState` records header only recent grid evidence and a separate bounded
|
||||
field model history.
|
||||
2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence
|
||||
ordering, fault latching, model feed, and cursor commit.
|
||||
3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude
|
||||
slice and rejects an empty observation.
|
||||
4. Calibration start, status, stop, cancel, reset, promotion, refinement, and
|
||||
restart enforce the frozen source and grid contract.
|
||||
5. `bootstrap_baseline` persists the source grid inside the verified v2 image.
|
||||
6. Finalization clears training history, and promotion blocks until the model
|
||||
has a complete fresh scoring window plus twelve separately spaced samples.
|
||||
7. Startup calibration without measured grid evidence exits with a typed
|
||||
operator instruction instead of creating a model that can never receive a
|
||||
frame.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Unit tests must prove stable grid selection, rate and gap rejection, first
|
||||
packet acceptance, forward progress, duplicate rejection, source and grid
|
||||
isolation, maximum counter wrap, empty input retry, measured depth eleven
|
||||
reordering without a model feed, depth sixteen acceptance, depth seventeen
|
||||
latching, half range rejection, session clear, receipt visibility, protected
|
||||
grid persistence, and typed stop refusal.
|
||||
Tests must also prove startup flag refusal, missing and stale grid refusal,
|
||||
complete holdout isolation, and sequence fault rejection during holdout.
|
||||
|
||||
On physical hardware, begin a Node 5 empty room capture and observe a fresh
|
||||
sixty second interval. Calibration frame growth must match unique 64 subcarrier
|
||||
Node 5 sequences while the independent global inference grid remains unchanged.
|
||||
Edge vital packets and other grids must produce zero growth. The bound grid
|
||||
must remain active, sequence faults must remain empty, and numeric vital output
|
||||
must remain absent. Any bounded late packet must appear in the reorder receipts
|
||||
while leaving the model count unchanged. Complete both the 1,000 frame and 600
|
||||
second gates, then run the twelve sample server controlled bootstrap holdout.
|
||||
Restart and prove that only the persisted grid rebuilds the runtime history.
|
||||
Do not claim human detection improvement until a separate consented occupied
|
||||
holdout demonstrates retained recall.
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-361: Point the whole fleet at one transmitter, to remove selection ambiguity
|
||||
|
||||
**Status:** Proposed — built, deployed fleet-wide, A-B-A measured. **Hypothesis CONFIRMED, adoption NOT justified.** The pairing *fraction* rises sharply; paired observations per second fall.
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** ADR-358 (falsified), ADR-359 (mechanism confirmed, not adopted), ADR-360 (falsified).
|
||||
|
||||
## The hypothesis, recorded long before it could be tested
|
||||
|
||||
From `docs/BACKLOG.md`, "Next: the actual pairing constraint":
|
||||
|
||||
> Two nodes can therefore fire in the same aligned window and still pick frames
|
||||
> from *different* transmitters, which can never pair under the
|
||||
> `(tx_mac, rx_seq)` key. Cheapest test and likely fix: **point the whole fleet
|
||||
> at one strong transmitter with the existing ADR-060 MAC filter.**
|
||||
|
||||
It had never been run, and the reason was mechanical rather than scientific:
|
||||
**`filter_mac` was writable only by `provision.py` over USB.** It is a 6-byte
|
||||
blob, absent from the `cfg_key_t` scalar table the config API validates
|
||||
against, so the test would have required nine physical visits. Exposing it over
|
||||
the network (see the firmware change accompanying this ADR) reduced the whole
|
||||
experiment to two config pushes.
|
||||
|
||||
## What was measured
|
||||
|
||||
The associated AP was chosen on measurement, not assumption. Five transmitters
|
||||
reach all nine receivers; only one carries a usable rate:
|
||||
|
||||
```
|
||||
transmitter n_rx total_fps mean_rssi
|
||||
8c:30:66:86:a4:21 9 109.81 -66.6 <- the AP, 26x the next
|
||||
96:30:66:86:a4:21 9 4.24 -65.9
|
||||
08:36:c9:71:ff:52 9 0.18 -75.9
|
||||
64:16:66:cb:17:07 9 0.08 -81.7
|
||||
10:a5:62:24:bb:30 9 0.06 -78.2
|
||||
```
|
||||
|
||||
A-B-A, ten 30 s windows per arm. `/api/v1/fusion` counters are cumulative since
|
||||
sink start, so they are **differenced over each window** and never divided as
|
||||
lifetime totals — the trap already recorded for `early_drop`/`csi_fps_samples`.
|
||||
The filter applied live on all nine nodes with **no restart**, so no reboot
|
||||
confounds any arm.
|
||||
|
||||
```
|
||||
arm n paired_fraction tx/s paired/s
|
||||
A1 control 10 0.5668 +/- 0.0107 73.0 41.4
|
||||
B filtered 10 0.7287 +/- 0.0352 33.9 24.7
|
||||
A2 control 10 0.5836 +/- 0.0120 81.5 47.6
|
||||
```
|
||||
|
||||
**Pairing fraction +0.1535 against the pooled control (+26.7% relative),
|
||||
Welch t = 13.26, df = 10.5, p < 0.0001.** Reversible: A2 returned to control,
|
||||
+0.0168 against A1.
|
||||
|
||||
Per-node confirmation that the filter did what it claims, taken on node 3 while
|
||||
eight nodes were still unfiltered:
|
||||
|
||||
```
|
||||
node 3 (filtered) AP fps 13.56 non-AP fps 0.28 non-AP share 2.0%
|
||||
node 8 (control) AP fps 12.18 non-AP fps 5.66 non-AP share 31.7%
|
||||
```
|
||||
|
||||
The AP rate is untouched; peer traffic falls to residue.
|
||||
|
||||
## The hypothesis is confirmed. The fix is not.
|
||||
|
||||
Transmitter ambiguity **was** suppressing pairing, and removing it lifts the
|
||||
fraction by a quarter. That settles a question that had been open since
|
||||
2026-09-07 and had resisted three other experiments.
|
||||
|
||||
But the backlog called this the "likely fix", and that half does not survive.
|
||||
It predicted yield would fall to ~13 fps and that "the frames that remain are
|
||||
pairable". Both are true, and neither is sufficient:
|
||||
|
||||
- **tx/s fell 56%** (77.3 -> 33.9)
|
||||
- **paired observations per second fell 44%** (44.4 -> 24.7)
|
||||
|
||||
A higher fraction of a much smaller pie. The quantity downstream sensing
|
||||
actually consumes is paired observations per second, and that went **down**.
|
||||
Fraction was the wrong figure of merit, and choosing it is what made this look
|
||||
like a fix rather than a diagnosis.
|
||||
|
||||
## Decision
|
||||
|
||||
**Do not adopt. Keep the branch unmerged.** Production stays unfiltered,
|
||||
`gate_mode=2`, `gate_seq_period=8`.
|
||||
|
||||
`filter_mac` **is** kept in the config API. It costs nothing when unset, it is
|
||||
now the cheapest available probe for "is this node's pairing transmitter-
|
||||
limited?", and the ADR-060 filter had been effectively unreachable for the
|
||||
fleet's whole life.
|
||||
|
||||
## What this opens
|
||||
|
||||
A **partial** filter is the obvious next question and is NOT what was tested
|
||||
here. Restricting to a handful of widely-heard transmitters rather than exactly
|
||||
one would trade less yield for some of the gain, and there is no reason to think
|
||||
the optimum is at either extreme. The five 9-receiver transmitters above are the
|
||||
natural candidate set, though four of them carry almost no rate.
|
||||
|
||||
That is a new experiment with a different independent variable, not a variant
|
||||
of this one. It should be sized on **paired observations per second**, which is
|
||||
the lesson this ADR paid for.
|
||||
|
||||
## Honest scope
|
||||
|
||||
Software-only. One house, one AP, nine nodes, ~15 minutes per arm, at 05:00
|
||||
local with the house quiet. The effect is far larger than the within-arm
|
||||
scatter and it reversed cleanly, but it has not been repeated across times of
|
||||
day, and pairing is known to move with the transmitter population (see
|
||||
"Transmitter population instability", 2026-09-02).
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR-362: Reading the on-node log is on-demand, never streamed
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** the on-node log engine (persistent 128 B-record ring + coredump-to-flash)
|
||||
|
||||
## Context
|
||||
|
||||
The firmware now keeps a persistent log on the node's `storage` volume and can
|
||||
write a coredump to flash. Nothing reads either one. The log is a new concept
|
||||
in this tree, so there is no existing consumer to extend and the access
|
||||
pattern is genuinely open.
|
||||
|
||||
Two shapes were considered:
|
||||
|
||||
1. **Ingest** — the server polls every node on a schedule, drains new records,
|
||||
and keeps its own copy that the UI reads.
|
||||
2. **On-demand** — the server fetches a node's log only when a human asks for
|
||||
it, and keeps nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
**On-demand only. No streaming, no periodic polling, no server-side copy, no
|
||||
alerting.**
|
||||
|
||||
The log is pulled after a board hangs or crashes, or while troubleshooting a
|
||||
specific node. That is the whole access pattern.
|
||||
|
||||
## Why
|
||||
|
||||
- **The persistence already lives on the node.** The ring holds ~208 days at
|
||||
128 B per 300 s, and surviving a power cycle is the entire reason the module
|
||||
exists — a power cycle is how a wedged node gets recovered. A second copy on
|
||||
the server duplicates a durability property that is already satisfied.
|
||||
- **Airtime is this fleet's binding constraint, and it is the thing being
|
||||
diagnosed.** Two nodes uplinking at 1 Mbps were measured consuming 91% of
|
||||
fleet airtime. A periodic log drain across nine nodes would spend exactly the
|
||||
resource whose exhaustion is the most common thing you would open the log to
|
||||
investigate. An instrument must not perturb what it measures.
|
||||
- **Coredumps are 64 KB.** They are only ever worth moving when something has
|
||||
actually crashed.
|
||||
- **The use is diagnostic, not observational.** Nobody watches nine logs. You
|
||||
open one node's history when that node misbehaves.
|
||||
|
||||
## What this implies gets built
|
||||
|
||||
- A server route that proxies to the node on request — `GET
|
||||
/api/v1/nodes/{id}/log`, and the same for `/coredump` — authenticating with
|
||||
the OTA PSK the firmware endpoints already sit behind.
|
||||
- A page under the existing Tools menu to view one node's records.
|
||||
- **No** scheduler, **no** new storage, **no** retention policy on the server.
|
||||
|
||||
## Open question, deliberately not settled here
|
||||
|
||||
Whether the server decodes the 128 B records into JSON, or proxies raw bytes
|
||||
and lets the decoder at the edge (browser, or `node_log_read.py`) interpret
|
||||
them.
|
||||
|
||||
Decoding server-side means the record layout is defined in two places — C and
|
||||
Rust — and can drift silently. The pilot already paid for a version of this:
|
||||
a field whose sentinel the host had to learn to render as `n/a`, where a
|
||||
plausible wrong value would have been worse than an obvious gap. That argues
|
||||
for the raw proxy and a single definition of the format.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Real-time monitoring, log streaming, fleet-wide log aggregation, and alerting
|
||||
on log contents are all explicitly out of scope. If a future need genuinely
|
||||
requires one of them, it should revisit this ADR rather than grow into it.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR 363: Native macOS USB node onboarding
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and physically qualified with one attached ESP32 S3 and one attached
|
||||
ESP32 C6 through an unsigned Mac Catalyst development build. Signed
|
||||
distribution remains a release gate.
|
||||
|
||||
## Context
|
||||
|
||||
RuView operators currently provision ESP32 nodes with command line tooling.
|
||||
The macOS app can list server accepted node identities, but it cannot establish
|
||||
that an attached serial device is RuView firmware, assign a collision free node
|
||||
identity, preserve its WiFi configuration, or prove that it joined the sensing
|
||||
server. A UI that merely stores a friendly name would create false success.
|
||||
|
||||
Generic shell access from the app would also expand authority beyond the task.
|
||||
Serial logs can contain operational data, and WiFi credentials must not enter
|
||||
application diagnostics or provisioning receipts.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Firmware 0.8.12 provides a bounded line protocol over the physical USB serial
|
||||
channel. A discovery request carries a random 128 bit nonce. The device
|
||||
response binds that nonce to chip family, firmware version, current node
|
||||
identity, private server target, configuration state, and a pseudonymous
|
||||
64 bit device digest derived from the base MAC under a RuView domain.
|
||||
|
||||
2. A configuration request is accepted only for the most recently issued
|
||||
nonce within 60 seconds. The nonce is single use. Node identity is limited
|
||||
to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port
|
||||
is limited to 1 through 65535.
|
||||
|
||||
3. Existing WiFi configuration can be preserved. New credentials use strict
|
||||
base64 framing, bounded lengths, and control character rejection. Firmware
|
||||
commits NVS before acknowledging and rebooting. Credentials are never
|
||||
returned, logged, or included in the receipt.
|
||||
|
||||
4. The macOS client uses a native serial bridge. It allowlists exact character
|
||||
device prefixes, configures the port directly, and does not invoke a shell,
|
||||
Python, or an external process. It rechecks the device digest immediately
|
||||
before configuration to reduce port substitution risk.
|
||||
|
||||
5. The app suggests the lowest unused server node identity and the Mac private
|
||||
LAN address. Success is shown only after the configured node appears in the
|
||||
server inventory as active with evidence no older than three seconds.
|
||||
|
||||
6. Old firmware is reported as requiring an update. The app must not claim it
|
||||
provisioned a device based only on generic RuView serial logs.
|
||||
|
||||
7. Automatic firmware flashing is outside this decision. Flashing requires an
|
||||
exact chip and port check, a private backup, a matching image, and captured
|
||||
boot evidence. Release publication remains a separate maintainer action.
|
||||
|
||||
8. The device digest uses the PSA Crypto SHA 256 interface on ESP IDF 5.4 and
|
||||
6.0. This keeps the stable domain separated identity while avoiding the
|
||||
legacy Mbed TLS hash API removed by ESP IDF 6.
|
||||
|
||||
9. ESP32 C6 mmWave UART defaults use GPIO 4 and GPIO 5. Startup rejects any
|
||||
configured mmWave pin pair that overlaps the active console RX or TX pins.
|
||||
This preserves the onboarding input path after a failed mmWave probe.
|
||||
|
||||
## Security and privacy consequences
|
||||
|
||||
The app gains Mac App Sandbox USB and serial device access, but not general file
|
||||
or shell authority. The protocol does not authenticate a device with a hardware
|
||||
root of trust; the digest is stable identification, not a cryptographic device
|
||||
certificate. Physical USB possession is therefore the trust boundary for this
|
||||
version. An authenticated fleet identity remains ADR 305 scope.
|
||||
|
||||
The largest failure mode is a successful NVS write followed by failure to join
|
||||
the LAN. The user receives an explicit bounded verification failure instead of
|
||||
a success screen, while the board retains its committed settings and can be
|
||||
rescanned over USB.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Connect an ESP32 with firmware 0.8.12 to a Mac that is already receiving RuView
|
||||
nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the
|
||||
lowest unused identity, and set the Mac private address. Pass when the UI
|
||||
reports the expected chip and firmware, the reassigned node remains fresh for
|
||||
five minutes, the stale collided identity expires, app restart preserves the
|
||||
profile, no server health, send, watchdog, panic, or reboot evidence appears,
|
||||
and no credential appears in app logs or the provisioning receipt.
|
||||
|
||||
The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in
|
||||
`docs/validation/2026-08-31-macos-usb-node-onboarding.md`.
|
||||
|
||||
The 2026-09-11 physical run passed on an ESP32 C6 over
|
||||
`cu.usbserial-3120`. The Mac app flashed the image, received the nonce bound
|
||||
hello receipt, preserved node identity 3, committed the server route, and
|
||||
verified fresh server evidence at minus 34 dBm. The run also exposed and fixed
|
||||
the GPIO 17 console RX conflict described above.
|
||||
@@ -0,0 +1,96 @@
|
||||
# ADR-364: BFI capture admission and continuity
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: bfi, capture, evidence, continuity, hardware
|
||||
|
||||
## Context
|
||||
|
||||
The Archer BE550, associated iPhone and MT7927 capture host have produced
|
||||
readable beamforming feedback information (BFI). Monitor-mode support alone
|
||||
does not establish this capability, and readable reports do not establish
|
||||
continuous sensing. Router channel width also does not guarantee an identical
|
||||
feedback grid in every report.
|
||||
|
||||
**MEASURED:** two saved trials contain 66 and 96 unique reports. During their
|
||||
approximately 30-second sender intervals, rates were 2.2 and 3.2 reports/s,
|
||||
with maximum silence of 8.392 and 6.437 seconds. No reports followed in the
|
||||
approximately 15-second post-traffic intervals. All 162 reports received an
|
||||
independent tshark angle/control/SNR comparison. Reproducer commands, capture
|
||||
hashes and limitations are in the [capture results](../../tools/bfi/OPTIMIZATION.md)
|
||||
and [continuity report](../../tools/bfi_learning/CAPTURE-CONTINUITY.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Admit a hardware/driver/profile combination only from its actual packet
|
||||
evidence. Apply these boundaries before using its output for sensing:
|
||||
|
||||
1. Keep originals and receipts outside Git, with raw own-network captures on
|
||||
the capture host. Record hardware, driver, channel, source hashes, capture
|
||||
interval and restoration outcome. Select the authorized AP/client direction
|
||||
explicitly; missing provenance cannot be inferred from an SSID.
|
||||
2. Enforce container, radiotap, MAC, length, fragmentation and supported-format
|
||||
checks in [bfi_decode.py](../../tools/bfi/bfi_decode.py). Validate FCS when
|
||||
present and reject flagged corruption; absent FCS is a recorded limitation.
|
||||
Unsupported tuples remain counted rejections. Compare every admitted angle
|
||||
against an independent dissector before extending a format's support claim.
|
||||
3. Deduplicate retries over the whole capture before slicing time intervals.
|
||||
Preserve integer timestamps and separate feedback grids. Sequence numbers
|
||||
and sounding tokens do not measure lost packets. BFI steering matrices are
|
||||
not full CSI; the identity product of a square unitary matrix with its
|
||||
conjugate transpose cannot serve as a changing channel feature.
|
||||
4. Preserve full-observation quality metrics and add separate before-traffic,
|
||||
sender-interval and after-traffic metrics. Include leading/trailing silence,
|
||||
report rate, maximum gap and duration-weighted occupied one-second bins.
|
||||
Sender timestamps bound attempted UDP sends, not verified over-air sounding.
|
||||
5. Use an initial continuity gate of reports in at least 27 of the first 30
|
||||
complete one-second sender bins and no silence longer than two seconds
|
||||
anywhere in the actual sender interval, repeated in three comparable trials.
|
||||
Report any partial final bin separately; never count it as a complete second.
|
||||
This is a project engineering gate, not a published sensing accuracy result.
|
||||
|
||||
The current traffic receipt lacks a PCAP hash and AP/client binding. The
|
||||
analyzer checks caller-selected file associations and hashes all inputs in its
|
||||
output; this establishes content integrity, not authenticated session binding.
|
||||
Stronger binding is required before automated capture admission can treat
|
||||
receipt association as independently verified.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Monitor-mode declarations, average report rate alone and interpolating missing
|
||||
reports would each hide a different failure. Retain explicit packet validation
|
||||
and interval coverage instead. ESP32/Realtek CSI remains a separate modality;
|
||||
multiple unsynchronized boards do not establish a coherent BFI array.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Packet validity, capture continuity and sensing accuracy have separate,
|
||||
reproducible acceptance criteria. Changes in denominators cannot appear as RF
|
||||
performance gains.
|
||||
|
||||
### Negative
|
||||
|
||||
Strict format and continuity gates can exclude usable but sparse observations.
|
||||
New PHY formats and hardware require additional captures and independent checks.
|
||||
|
||||
### Neutral
|
||||
|
||||
The current trials fail continuity. Further motion modeling requires a new
|
||||
capture result; this ADR does not authorize hardware changes or establish
|
||||
identity recognition.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Run `python -B -m unittest discover -s tools/bfi -v` and the focused
|
||||
`test_traffic_coverage.py` suite under `tools/bfi_learning`. Synthetic tests
|
||||
check parsing and interval rules; acceptance additionally requires retained
|
||||
real captures and the three successful trials above.
|
||||
|
||||
- Extends proposed [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md)
|
||||
and [ADR-123](ADR-123-bfld-capture-path-nexmon-and-esp32.md); neither is superseded.
|
||||
- Preserves [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md)'s privacy boundary.
|
||||
- Public-data admission has a separate contract in
|
||||
[ADR-365](ADR-365-public-bfi-dataset-and-decoder-contract.md).
|
||||
@@ -0,0 +1,121 @@
|
||||
# ADR-365: Public BFI dataset and decoder contract
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: bfi, datasets, decoding, provenance, benchmarks
|
||||
|
||||
## Context
|
||||
|
||||
Public activity recordings offer a separate route to BFI model development
|
||||
while local capture continuity is insufficient. They introduce different frame
|
||||
formats, collection assumptions and licenses. Public HAR activity labels do
|
||||
not reproduce KIT's person-identification experiment.
|
||||
|
||||
**MEASURED:** the pinned CSI-BFI-HAR train/validation download passed exact
|
||||
size/hash checks for 120 captures plus its license card. A separate body decoder
|
||||
matched 13,104 quantized angles across 14 sample bodies against tshark 4.2.2 in
|
||||
two independent executions. Whole-packet admission and activity training remain
|
||||
incomplete. See [results](../../tools/bfi_learning/RESULTS.md) and the
|
||||
[decoder audit and reproducer](../../tools/bfi_learning/PUBLIC-BFI-DECODE.md).
|
||||
|
||||
## Decision
|
||||
|
||||
### Pin provenance and partitions
|
||||
|
||||
Use the [author dataset](https://huggingface.co/datasets/foysalhaque/CSI-BFI-HAR-Dataset/tree/c3013b3edc5a563a9cf6759f5d464a9d9b6f456a)
|
||||
at revision `c3013b3edc5a563a9cf6759f5d464a9d9b6f456a`. The
|
||||
[manifest](../../tools/bfi_learning/manifests/csi-bfi-har-c3013b3-subset.json)
|
||||
pins URLs, lengths, SHA-256, labels and split membership. Preserve the pinned
|
||||
dataset card's GPL-3.0 declaration and attribution separately from code licenses.
|
||||
|
||||
| Partition | Author directory | Domain |
|
||||
| --- | --- | --- |
|
||||
| Training | HAR-1/BFI/M1 | Day 1, kitchen |
|
||||
| Validation | HAR-3/BFI/M1 | Day 3, classroom |
|
||||
| Held out | HAR-5/BFI/M1 and M2 | Day 5, living room; two views |
|
||||
|
||||
Keep all views of one event in the same partition. Day and room are confounded;
|
||||
M1/M2 labels do not establish heterogeneous-chipset transfer. The selected
|
||||
filenames cover A–T activities and P1–P3 participants.
|
||||
|
||||
Default downloads exclude held-out views. The bounded
|
||||
[downloader](../../tools/bfi_learning/download_public_bfi.py) verifies exact
|
||||
bytes before atomic completion, rehashes resumed files, retains failed partials
|
||||
and enforces transfer deadlines and byte limits. Data stays outside Git;
|
||||
downloaded code is never executed by the downloader.
|
||||
|
||||
### Keep body decoding separate from packet admission
|
||||
|
||||
[public_bfi_decode.py](../../tools/bfi_learning/public_bfi_decode.py) accepts
|
||||
only the audited VHT MU 3x1, 80 MHz, Ng=1, codebook-1 complete action body:
|
||||
1,003 bytes containing one SNR byte, 936 angle bytes and 61 opaque MU-exclusive
|
||||
bytes. It exposes quantized/radian angles and tone ordinals. Frequency indices,
|
||||
exclusive-field interpretation and validated matrix reconstruction remain
|
||||
separate acceptance work.
|
||||
|
||||
Twelve sample frames have nonzero fragment-number bits. Successful tshark
|
||||
decoding and valid CRCs do not resolve their semantics. Retain live MAC guards;
|
||||
quarantine these frames until an explicit, independently reviewed dataset
|
||||
policy can justify admission. Require lossless pcapng conversion evidence for
|
||||
integer timestamps, packet lengths, link types and all original packet bytes.
|
||||
Do not silently strip headers or rewrite fragment fields to gain admission.
|
||||
|
||||
The natural circular representation needs 1,404 features, exceeding the current
|
||||
trainer's 1,024-feature limit. Approve a bounded representation or revised limit
|
||||
with allocation tests before integration; do not truncate tones implicitly.
|
||||
|
||||
### Admit recording quality before scoring models
|
||||
|
||||
Inventory every manifest file, including tiny or unusable traces. Freeze a
|
||||
five-second observed-interval rule: at least 20 valid unique reports, with no
|
||||
inter-report gap above one second and no interpolation. Require at least two
|
||||
independent recordings per activity in each partition/view group that supply
|
||||
an eligible interval. Missing A–T coverage fails benchmark admission.
|
||||
Before implementing the census, also freeze interval anchoring, stride and
|
||||
leading/trailing gap rules: an inter-report gap bound alone does not bound
|
||||
silence at interval edges. These details remain required acceptance work.
|
||||
|
||||
Public files lack trusted recorder start/end manifests. Observed packet spans
|
||||
cannot establish unobserved leading/trailing coverage. Report rejection reasons,
|
||||
eligible recording/time coverage and accuracy together. Freeze event/file groups
|
||||
before generating windows; inspect held-out quality without model selection
|
||||
against held-out outcomes.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Reusing the HE/SU parser without format admission, weakening live fragment
|
||||
guards, or silently dropping sparse classes would change the task while hiding
|
||||
the change. Keep this adapter explicit and fail incomplete benchmark coverage.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Data provenance, protocol support and score coverage remain independently
|
||||
auditable. New public formats cannot silently weaken live capture validation.
|
||||
|
||||
### Negative
|
||||
|
||||
Sparse files or unresolved framing may prevent a benchmark despite a successful
|
||||
download. Format and representation work must precede model optimization.
|
||||
|
||||
### Neutral
|
||||
|
||||
This is offline research/reference tooling. Production integration must reuse
|
||||
the Rust protocols in [ADR-291](ADR-291-public-benchmark-evaluation-harness.md)
|
||||
and scorecards in [ADR-317](ADR-317-benchmark-multi-domain-scorecard.md); this ADR
|
||||
does not establish a second production trainer or an integrated benchmark score.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Use `test_download_public_bfi.py` and `test_public_bfi_decode.py` under
|
||||
`tools/bfi_learning`, then the full packet oracle and quality census specified
|
||||
in the [decode audit](../../tools/bfi_learning/PUBLIC-BFI-DECODE.md) and
|
||||
[benchmark protocol](../../tools/bfi_learning/RESEARCH-NEXT.md).
|
||||
|
||||
- Depends on [ADR-364](ADR-364-bfi-capture-admission-and-continuity.md)'s distinction
|
||||
between packet validity and coverage.
|
||||
- Preserves [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md).
|
||||
- [ADR-119](ADR-119-bfld-frame-format-and-wire-protocol.md) defines a BFLD envelope;
|
||||
that envelope does not replace raw 802.11 decoding.
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADR-366: CSI controls and frozen evaluation
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: csi, evaluation, controls, holdout, evidence
|
||||
|
||||
## Context
|
||||
|
||||
The public NTU-Fi HumanID experiment uses 14 classes and a custom directory
|
||||
split of 238 training, 56 validation and 546 held-out recordings. It differs
|
||||
from the author's folder mapping. Physical acquisition-session independence
|
||||
is unknown; absence of exact duplicates cannot establish it.
|
||||
|
||||
**MEASURED:** each of six LSTM/GRU runs classified 56/56 validation recordings
|
||||
correctly. A fixed-alpha ridge model using only mean amplitudes also scored
|
||||
56/56. In a matched three-seed GRU diagnostic, raw/original, raw/shuffled and
|
||||
demeaned/original inputs each scored 56/56 for every seed; demeaned/shuffled
|
||||
scored 52/56, 55/56 and 56/56. Chronological motion is unnecessary for perfect
|
||||
accuracy on this validation split. The physical source of the predictive signal
|
||||
remains unresolved. [Measured results and reproduction receipts](../../tools/bfi_learning/RESULTS.md)
|
||||
|
||||
## Decision
|
||||
|
||||
### Separate diagnostics from claims of generalization
|
||||
|
||||
Use static and temporal controls before interpreting a sequence-model score.
|
||||
Keep input files, optimizer budgets and seeds matched across raw/original,
|
||||
raw/shuffled, demeaned/original and demeaned/shuffled conditions. Permute whole
|
||||
feature rows within one recording, using source/seed information without labels.
|
||||
Never mix partitions. Mark shuffled time coordinates as administrative.
|
||||
|
||||
Whole-recording demeaning is an offline diagnostic that sees the complete
|
||||
recording. It cannot substantiate causal streaming inference. These nondefault
|
||||
controls are restricted to the custom public identity protocol and cannot alter
|
||||
next-observation BFI forecasting. Fit normalization on training inputs only.
|
||||
|
||||
Report recording-level accuracy, macro-F1, included recordings and seed variation
|
||||
alongside validation cross entropy averaged over selected windows. Recording
|
||||
predictions aggregate softmax probabilities across their selected windows;
|
||||
the current loss is not computed from those recording aggregates. Treat window
|
||||
accuracy as secondary because overlapping windows and repeated seeds on the same
|
||||
recordings are not independent participant trials. Retain session-independence
|
||||
limitations beside every custom-split result.
|
||||
|
||||
### Freeze selection before final evaluation
|
||||
|
||||
Keep the [predeclared control protocol](../../tools/bfi_learning/CONTROLS.md):
|
||||
the twelve 50-epoch diagnostic runs are excluded from final-model selection.
|
||||
Only the six original raw/original 200-epoch LSTM/GRU runs are eligible. Select
|
||||
lowest window-averaged validation cross entropy, breaking ties by earlier
|
||||
completion. Do not substitute recording-aggregated loss or change this rule in
|
||||
response to a held-out outcome.
|
||||
|
||||
Freeze source, dataset, metadata, configuration and checkpoint hashes before
|
||||
the scheduled final evaluation. Load frozen weights without retraining. The
|
||||
trainer creates an exclusive one-use claim before holdout scoring and retains
|
||||
it even if scoring fails; a failure requires explicit review, not an automatic
|
||||
retry. This claim is scoped to the selected frozen-run directory; the controller
|
||||
enforces one final evaluation across the task's candidate directories. Stop
|
||||
tuning against that holdout after its result is inspected. At this decision's
|
||||
evidence checkpoint, final holdout metrics remain absent.
|
||||
|
||||
### Preserve executable provenance
|
||||
|
||||
Use bounded numeric NPZ inputs/checkpoints with pickle disabled and validate ZIP
|
||||
central-directory bounds before allocation. Verify hashes and restore recorded
|
||||
transformations during frozen evaluation. The launcher checks returned seed,
|
||||
protocol, configuration and `test: null` during tuning. Save incomplete runs
|
||||
and denominator changes explicitly rather than substituting successful runs.
|
||||
|
||||
Current Python tools are bounded offline reference experiments. Production
|
||||
benchmark integration continues through the Rust protocols and per-domain
|
||||
scorecards of ADR-291/317. A fair state-of-the-art comparison needs matched data,
|
||||
task, representation, partition, adaptation access and evaluation; this custom
|
||||
CSI result establishes neither BFI identity performance nor paper reproduction.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Validation accuracy alone cannot distinguish temporal learning from static
|
||||
cues. Random window splits would leak recordings across partitions. Expanding
|
||||
the eligible model set after diagnostics would change the predeclared selection
|
||||
procedure. Retain the frozen recording split and candidate set.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Controls expose when model complexity or temporal claims lack experimental
|
||||
support. The final evaluation has a traceable selection rule and one-use record.
|
||||
|
||||
### Negative
|
||||
|
||||
A saturated validation set limits further optimization, and strict one-use
|
||||
evaluation makes interrupted scoring an explicit recovery decision. New-session
|
||||
evidence requires new data rather than additional fitting to this split.
|
||||
|
||||
### Neutral
|
||||
|
||||
The GRU has 24.8% fewer parameters than the tested LSTM, but measured peak GPU
|
||||
allocation was higher and elapsed times do not isolate an architecture speedup.
|
||||
Preserve these separate resource findings when comparing models.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Run the focused `test_prepare.py`, `test_train.py`, `test_static_baseline.py`
|
||||
and `test_run_batch.py` suites under `tools/bfi_learning`. Synthetic isolation
|
||||
and restore tests complement the retained real-data receipts; neither establishes
|
||||
new-session generalization.
|
||||
|
||||
- Extends [ADR-291](ADR-291-public-benchmark-evaluation-harness.md) and
|
||||
[ADR-317](ADR-317-benchmark-multi-domain-scorecard.md) without superseding them.
|
||||
- [Comparison contract](../../tools/bfi_learning/RESEARCH.md),
|
||||
[trainer](../../tools/bfi_learning/train.py) and
|
||||
[overnight frozen-selection procedure](../../tools/bfi_learning/OVERNIGHT.md).
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-367: Bounded research swarm and compute ownership
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: orchestration, ruflo, codex, compute, provenance
|
||||
|
||||
## Context
|
||||
|
||||
Research, implementation and independent review can run concurrently, while
|
||||
GPU training, capture hardware and final evaluation need clear ownership.
|
||||
Registration in a swarm registry does not prove that a worker executed a task.
|
||||
Unbounded retries, duplicate schedulers or overlapping jobs can corrupt evidence
|
||||
and spend resources without improving an experiment.
|
||||
|
||||
**MEASURED at the September 29 checkpoint:** local GPU runs and Codex reviews
|
||||
completed with receipts. Federation enrollment returned `registration paused`
|
||||
and relay membership was rejected; no cross-host worker accepted a task.
|
||||
Shared-memory writes were blocked by the native-WAL guard. These limitations
|
||||
and the actual work locations are recorded in the
|
||||
[overnight procedure](../../tools/bfi_learning/OVERNIGHT.md).
|
||||
|
||||
## Decision
|
||||
|
||||
### Assign one owner to each mutable resource
|
||||
|
||||
The root controller owns each capture action, GPU batch, checkpoint-selection
|
||||
decision and final evaluation. Delegated workers receive bounded source/review
|
||||
tasks and explicit file ownership. Worker findings are untrusted proposals for
|
||||
controller review. Distinguish registration, dispatch, execution and verified
|
||||
completion in receipts; only execution evidence supports a completed-work claim.
|
||||
|
||||
Use the existing app heartbeat as the sole recurring scheduler for this task.
|
||||
Finite serial GPU loops are jobs within a wake, not independent recurring
|
||||
schedulers. Resume from receipts and verified lock ownership; completed runs
|
||||
are not repeated merely because a wake or supervisor restarts.
|
||||
|
||||
### Bound execution and audit outputs
|
||||
|
||||
The [GPU launcher](../../tools/bfi_learning/run_batch.py) uses Linux process
|
||||
groups, an exclusive batch lock, a 900-second trainer budget and a 1,020-second
|
||||
child timeout. The current experiment requires at least 4 GiB free GPU memory,
|
||||
caps the trainer's PyTorch allocator fraction at 20%, and bounds logs/metrics
|
||||
to 8 MiB. This allocator limit is not a whole-device memory reservation.
|
||||
Verify child hashes, seed, protocol, configuration and absence of test metrics
|
||||
before declaring success. Preserve unrelated GPU services; cleanup targets only
|
||||
owned processes. Audit matrix and batch ownership before changing stale locks.
|
||||
|
||||
The task-specific [Codex review wrapper](../../tools/bfi_learning/codex_iteration.py)
|
||||
uses bounded source snapshots on stdin, read-only sandboxing, execution rules,
|
||||
an exclusive lock, timeout and private event logs. This differs from the generic
|
||||
contributor adapter's configuration contract. Check events for unexpected tool
|
||||
execution; never execute suggestions automatically or grant child text authority.
|
||||
|
||||
Preserve federation and memory safety guards when a backend is unavailable.
|
||||
Use private local receipts as the fallback and report degraded coordination.
|
||||
Retry only after a relevant service/writer change or a concrete causal fix.
|
||||
|
||||
### Bind compute to an explicit task budget and end time
|
||||
|
||||
Use local capacity first. A paid GPU needs a measured capacity requirement and
|
||||
an explicitly authorized cumulative budget covering actual spend, outstanding
|
||||
reservations and fees. Before rental, check current prices, active instances
|
||||
and remaining allowance; reserve maximum bounded cost, enforce termination and
|
||||
verify resource cleanup. Transfer only authorized public licensed data and
|
||||
reviewed source. A future run needs its own applicable authorization.
|
||||
|
||||
For the current run, the authorized cap is USD 200, local RTX 5080 capacity has
|
||||
been sufficient and the recorded spend is USD 0. The existing schedule ends
|
||||
September 30, 2026 at 07:00 America/Toronto. Its final wake performs the frozen
|
||||
evaluation, reconciles owned jobs/resources and the ledger, records results,
|
||||
then pauses the heartbeat. These are run-specific values, not durable spending
|
||||
or scheduling authority granted by this ADR.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Multiple controllers per GPU, detached indefinite loops, and treating a
|
||||
registration response as an executed agent task make ownership unverifiable.
|
||||
Retain explicit owners, finite jobs and execution receipts instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Parallel review remains useful while resource mutation and final evaluation
|
||||
have one accountable owner. Receipts support recovery without silent reruns.
|
||||
|
||||
### Negative
|
||||
|
||||
Serial GPU ownership can leave capacity unused. Federation or memory outages
|
||||
reduce coordination features, and bounded jobs may need reviewed continuation.
|
||||
|
||||
### Neutral
|
||||
|
||||
Source and sanitized documentation belong in the isolated worktree. Raw RF
|
||||
data, subject recordings, weights, logs, credentials and transcripts stay outside
|
||||
Git. A completed experiment does not authorize model promotion, firmware
|
||||
flashing, merging or publication.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Run `test_run_batch.py` and `test_codex_iteration.py` under `tools/bfi_learning`.
|
||||
Audit real execution/cleanup receipts, lock owners, ledger and scheduler state
|
||||
at completion; successful synthetic tests alone cannot establish these outcomes.
|
||||
|
||||
- Preserves [ADR-283](ADR-283-ruview-community-metaharness-flywheel.md)'s
|
||||
proposal-only learning and maintainer promotion boundary.
|
||||
- Follows [ADR-321](ADR-321-decision-policy-action-authorization.md).
|
||||
- Depends on [ADR-366](ADR-366-csi-controls-and-frozen-evaluation.md) for frozen
|
||||
candidate selection and final holdout handling.
|
||||
@@ -0,0 +1,187 @@
|
||||
# ADR-368: RuView compute kernel as a WASM + napi-rs npm package
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: npm, wasm, napi-rs, metaharness, vitals, supply-chain
|
||||
- **Related**: ADR-021 (vitals), ADR-263/265 (npm harness review and
|
||||
distribution), ADR-283 (community metaharness), ADR-285 (WASM-first Homecore
|
||||
metaharness)
|
||||
|
||||
## Context
|
||||
|
||||
`@ruvnet/ruview` (ADR-283) is a dependency-free operator/contributor harness:
|
||||
it guides, verifies, and delegates, but it cannot run RuView signal processing.
|
||||
JavaScript consumers (MCP hosts, dashboards, browser demos, agents) that want
|
||||
real RuView computation must today start the Rust sensing server or re-implement
|
||||
DSP in JS, which drifts from the Rust source of truth.
|
||||
|
||||
We want one Rust implementation reachable from npm with:
|
||||
|
||||
1. a portable default that works on every Node platform and in browsers;
|
||||
2. an optional native path for hosts that want it;
|
||||
3. the metaharness trust posture: least authority by default, the backend that
|
||||
actually ran is always reported, fail-closed on missing or tampered
|
||||
artifacts, and no accuracy claim without an evidence tag.
|
||||
|
||||
## Decision
|
||||
|
||||
### One core, one ABI, two transports
|
||||
|
||||
- **`v2/crates/ruview-kernel`** (workspace member) wraps the ADR-021 pipeline
|
||||
from `wifi-densepose-vitals` (EMA preprocessing, breathing 0.1–0.5 Hz, heart
|
||||
0.8–2.0 Hz, anomaly z-score) in a streaming `Session`, a deterministic
|
||||
SplitMix64 synthetic-CSI generator, and a bounded session registry.
|
||||
- The entire ABI is one function: `call(op, request_json) -> envelope_json`,
|
||||
returning `{"ok":true,"result":…}` or
|
||||
`{"ok":false,"error":{"code","message"}}`. Operations: `info`,
|
||||
`validate_config`, `analyze`, `synthesize`, `session_open|push|summary|close`.
|
||||
Keeping the ABI to a single string function makes backend parity a property
|
||||
of the transport rather than of duplicated bindings.
|
||||
- **WASM transport** (`src/wasm_abi.rs`, `wasm32-unknown-unknown` only):
|
||||
`rvk_alloc`/`rvk_free`/`rvk_call`/`rvk_abi_version`. The module has **zero
|
||||
imports** — no filesystem, network, clock, or randomness. The JS loader
|
||||
refuses any module that imports host functions.
|
||||
- **napi-rs transport** (`v2/crates/ruview-kernel-napi`): exports only
|
||||
`abiVersion()` and `call(op, json)`. It is a standalone Cargo workspace
|
||||
(listed in the v2 `exclude`) so napi never enters
|
||||
`cargo test --workspace`, and it builds with `panic = "unwind"` plus
|
||||
`catch_unwind` so a defect becomes an error envelope, not a Node crash.
|
||||
|
||||
### Input is untrusted at the ABI
|
||||
|
||||
Requests are bounded before processing: 16 MiB per request, 512 subcarriers,
|
||||
100 000 frames per call or synthesis, 64 concurrent sessions. Unknown JSON
|
||||
fields are rejected (`deny_unknown_fields`); frame widths must match the
|
||||
configured subcarrier count; values must be finite with magnitude ≤ 1e9; all
|
||||
frames of a batch are validated before any is processed. The core returns an
|
||||
error envelope instead of panicking on caller data (release builds of the WASM
|
||||
module use `panic = "abort"`, which the loader surfaces as a `trap` and
|
||||
recovers from with a fresh instance).
|
||||
|
||||
### npm package `@ruvnet/ruview-kernel` (`harness/ruview-kernel`)
|
||||
|
||||
- Zero runtime dependencies. `loadKernel({backend})` with
|
||||
`backend ∈ {wasm (default), napi, auto}` or `RUVIEW_KERNEL_BACKEND`.
|
||||
WASM is the default because it carries no host authority. `napi` never
|
||||
silently falls back; `auto` prefers napi and records `fallbackReason`.
|
||||
- Artifacts are built from source by `npm run build` (`scripts/build.mjs`,
|
||||
no shell) into `wasm/` and `native/ruview_kernel.<triple>.node`, with
|
||||
`SHA256SUMS`. The loader verifies checksums; a mismatch is always fatal and
|
||||
never an `auto` fallback trigger; `requireIntegrity` also refuses
|
||||
unchecksummed artifacts. Built artifacts are git-ignored and never committed.
|
||||
- `src/wasm.js` is filesystem-free, so browsers can use
|
||||
`loadWasmKernelFromBytes(bytes)`.
|
||||
- CLI `ruview-kernel`: `doctor`, `info`, `selftest`, `parity`, `synth`,
|
||||
`analyze --file`, `bench`. `synth --out` refuses to overwrite files;
|
||||
`analyze` reads only a bounded regular file.
|
||||
- `selfTest()` runs synthesize → analyze → chunked streaming session → hostile
|
||||
input, and asserts known-tone recovery. `parity()` diffs every output of
|
||||
both backends with a 1e-9 relative tolerance, because `sin`/`exp` come from
|
||||
different libm implementations on wasm32 and native targets; exact JSON
|
||||
equality is reported separately and is not required.
|
||||
|
||||
### Metaharness integration
|
||||
|
||||
`@ruvnet/ruview` gains the read-only tool `ruview_kernel_selftest`
|
||||
(CLI: `ruview kernel [--backend …]`). It dynamically imports the fixed
|
||||
specifier `@ruvnet/ruview-kernel`, so `@ruvnet/ruview` keeps zero runtime
|
||||
dependencies, and it returns `kernel_not_installed` when the package is absent.
|
||||
MCP arguments are limited to `backend` and `seconds`; they cannot choose a
|
||||
module or artifact path. The capability appears in `ruview_guidance` as
|
||||
`portable-compute-kernel`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **SYNTHETIC** (`cd harness/ruview-kernel && node bin/cli.js selftest`):
|
||||
60 s at 20 Hz, 56 subcarriers, 15 bpm breathing and 72 bpm heart tones →
|
||||
recovered 15.0 bpm respiratory (valid) and 70.6 bpm heart (degraded
|
||||
confidence 0.44). Tolerances asserted: ±3 bpm respiratory, ±8 bpm heart.
|
||||
- **SYNTHETIC** parity (`node bin/cli.js parity`): 900 frames / 45 readings,
|
||||
no difference above 1e-9 relative between wasm32 and linux-x64-gnu napi;
|
||||
outputs are not byte-identical.
|
||||
- **MEASURED** on one Linux x64 development container
|
||||
(`node bin/cli.js bench --backend wasm|napi --seconds 120`): about 78 ms
|
||||
(wasm) and 63 ms (napi) per 2 400-frame `analyze`, including JSON transfer.
|
||||
Single-host timing, not a cross-platform claim.
|
||||
- The WASM release artifact is about 230 KB and imports nothing
|
||||
(`node scripts/verify-artifacts.mjs --require-wasm`).
|
||||
|
||||
None of these numbers says anything about real-world vital-sign accuracy.
|
||||
That still requires the ADR-293 ground-truth protocol with a reference device.
|
||||
|
||||
## Amendment 1 (2026-10-01): binary transport and optimization evidence
|
||||
|
||||
**Profile (MEASURED on one Linux x64 development container, 2 400 frames ×
|
||||
56 subcarriers).** JSON was the dominant cost:
|
||||
|
||||
- JS `JSON.stringify` of the request alone took 52–78 ms against
|
||||
107–124 ms for a whole `analyze`.
|
||||
- Rust then re-parsed about 270 000 numbers.
|
||||
|
||||
**Decision.** Add a binary fast path without changing the ABI version.
|
||||
|
||||
- New operations `analyze_flat` and `session_push_flat`, carried by
|
||||
`call_f64(op, json, &[f64])`:
|
||||
- WASM: `rvk_call_f64`, which reads bytes so alignment does not matter;
|
||||
- napi: `callF64(op, json, Float64Array)`.
|
||||
- `analyze()` and `session.push()` pack uniform frames into one
|
||||
`Float64Array` (all amplitudes, then all phases) and use the binary path
|
||||
automatically. Irregular input still takes the JSON path so its error names
|
||||
the bad frame.
|
||||
- `analyzeFlat(Float64Array, …)` is the zero-copy entry.
|
||||
- `Session::step` is shared by both paths.
|
||||
|
||||
**Evidence.**
|
||||
|
||||
- **Byte-identical outputs.** On identical `f64` inputs the two paths
|
||||
produce byte-identical JSON (`tests/abi.rs`).
|
||||
- **A JSON precision finding.** serde_json's default float parser is not
|
||||
correctly rounded, so the JSON transport perturbs inputs by ULPs (34 of 160
|
||||
amplitude values in a 2 s, 4-subcarrier SYNTHETIC sample). The binary path is bit-exact.
|
||||
- **Speed.** MEASURED, `ruview-kernel bench --seconds 120`, same host:
|
||||
|
||||
| Backend | JSON transport | Binary transport | Speed-up |
|
||||
|---|---|---|---|
|
||||
| wasm | 134.8 ms | 47.8 ms (~50 000 frames/s) | 2.82× |
|
||||
| napi | 109.7 ms | 44.8 ms (~53 600 frames/s) | 2.45× |
|
||||
|
||||
- **Remaining cost is DSP.** Native timing gives heart-rate extraction
|
||||
20.8–22.9 ms of 30.6–38.3 ms (about 60–68%, MEASURED; reproducer:
|
||||
`cargo run --release -p ruview-kernel --example profile_stages`): the per-frame autocorrelation over the 15 s window
|
||||
in `wifi-densepose-vitals`. That crate is shared with the production sensing
|
||||
server, and an incremental ACF would change floating-point summation order
|
||||
and therefore outputs. It is left as a follow-up that needs its own review
|
||||
and regression evidence.
|
||||
|
||||
**Rejected after measurement.** MEASURED, three runs on the same host;
|
||||
reproducer: `node bench/wasm-variants.mjs` in `harness/ruview-kernel`. Each
|
||||
run is the median of 15 `analyzeFlat` calls on 2 400 frames. All variants
|
||||
produced byte-identical outputs.
|
||||
|
||||
| Variant | Size | Median ms (3 runs) | Decision |
|
||||
|---|---|---|---|
|
||||
| baseline (`opt-level = 3`) | 244 KB | 38.3–40.6 | kept |
|
||||
| `+simd128` | 242 KB | 37.4–54.7 | rejected: no consistent gain, and it needs SIMD-capable engines |
|
||||
| `opt-level = "s"` | 227 KB | 40.8–70.1 | rejected: consistently slower for a 17 KB size saving |
|
||||
|
||||
## Consequences
|
||||
|
||||
- JavaScript consumers get real RuView DSP without a server, in Node or the
|
||||
browser, with one Rust source of truth.
|
||||
- A second native build matrix exists. Until per-platform packages
|
||||
(`@ruvnet/ruview-kernel-<triple>`) are published by CI, only WASM is
|
||||
portable; the loader already resolves those package names.
|
||||
- JSON transfer dominates the cost of small calls; a typed-array fast path can
|
||||
be added later behind a new ABI version.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
1. CI (`ruview-kernel-npm.yml`) builds both transports from source, runs the
|
||||
Rust tests, the package tests with `RUVIEW_KERNEL_REQUIRE_NAPI=1`, parity,
|
||||
artifact verification, and `npm pack --dry-run`.
|
||||
2. Publication stays CI-only with provenance (ADR-265) and requires explicit
|
||||
maintainer approval; this ADR does not add a publish job.
|
||||
3. Rollback: remove the `ruview_kernel_selftest` tool (the harness has no
|
||||
dependency on the package) and deprecate the npm version; the Rust crates
|
||||
are additive.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-369: npm RuView operator surface — CLI, MCP, and SDK over one registry
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: npm, mcp, sdk, cli, metaharness, least-authority
|
||||
- **Related**: ADR-263/265 (npm harness review and distribution), ADR-283
|
||||
(community metaharness), ADR-368 (compute kernel), ADR-370 (flashing),
|
||||
ADR-371 (training), ADR-372 (doctor)
|
||||
|
||||
## Context
|
||||
|
||||
`@ruvnet/ruview` 0.5.x could onboard, lint claims, verify the proof, monitor a
|
||||
node, and read Cognitum Spaces. Three operator jobs still left the package:
|
||||
|
||||
- **Flashing** was a Windows-only stub that returned `manual_step_required`.
|
||||
- **Training** had only a skill document; nothing ran a trainer or checked
|
||||
results mechanically.
|
||||
- **Diagnostics**: `doctor` printed six PASS/FAIL lines without remedies.
|
||||
|
||||
There was also no programmatic API. Integrators had to shell out to the CLI or
|
||||
speak MCP.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **One registry, three front ends.** Every capability is a `ruview_*` tool
|
||||
in `src/tools.js`, with a JSON schema, a `TOOL_POLICY` class, and a
|
||||
structured, fail-closed result. The CLI verbs, the MCP server, and the new
|
||||
SDK all go through `runTool`. There is one argument validator and one
|
||||
authorization path.
|
||||
2. **SDK** (`@ruvnet/ruview/sdk`, typed by `src/sdk.d.ts`). `createRuView()`
|
||||
returns a frozen object with `doctor`, `firmware.{ports,plan,flash,monitor}`,
|
||||
`training.{plan,run,gate,calibrate}`, `kernel.{selfTest,load}`, `guidance`,
|
||||
`claimCheck`, `verify`, `spaces`, `memorySearch`, `call`, `tools`, and
|
||||
`startMcpServer`.
|
||||
- Calls run with `source: 'sdk'`: trusted in-process code, like the CLI.
|
||||
Mutating tools still refuse to act without `confirm: true`.
|
||||
- `{ strict: true }` turns `ok:false` into a `RuViewError`.
|
||||
3. **Plan/act split for every mutation.**
|
||||
- `ruview_firmware_plan` and `ruview_train_plan` are read-only and their
|
||||
schemas do not accept `confirm`. An MCP client without write grants can
|
||||
review the exact command.
|
||||
- The acting tools (`ruview_node_flash`, `ruview_train`) keep the existing
|
||||
grant-plus-confirm gate: `hardware-write` and `workspace-write`.
|
||||
4. **New tools**: `ruview_doctor`, `ruview_firmware_plan`,
|
||||
`ruview_firmware_ports`, `ruview_train`, `ruview_train_plan`,
|
||||
`ruview_train_gate`. `ruview_node_flash` is re-implemented (ADR-370).
|
||||
`TOOL_POLICY`, `.harness/claims.json`, and `.harness/mcp-policy.json` are
|
||||
kept in lock-step, and a test fails on any drift.
|
||||
5. **Process execution** for operator tools goes through `src/exec.js`:
|
||||
- argv only, never a shell;
|
||||
- a scrubbed environment: the base allowlist plus toolchain locations such
|
||||
as `LIBTORCH`, `CARGO_HOME`, `VIRTUAL_ENV` and `IDF_PATH`, and no
|
||||
credentials;
|
||||
- bounded output, and secrets redacted.
|
||||
6. **Zero runtime dependencies stays.** The optional compute kernel
|
||||
(ADR-368) is loaded dynamically. Version 0.6.0. The unpacked-size ceiling
|
||||
rises from 160 KiB to 224 KiB for the new modules, with the budget recorded
|
||||
in both npm workflows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Agents, scripts, and humans get the same behaviour and the same refusals.
|
||||
- The package now drives real hardware and training. Keeping the plan/act
|
||||
split and argv-only execution is essential, and tests pin both.
|
||||
- The surface is larger (16 MCP tools). Hosts that cap tool counts can
|
||||
filter by annotations (`readOnlyHint`).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `cd harness/ruview && npm test && npm run test:security` (drift test in
|
||||
`test/sdk.test.mjs`).
|
||||
- `npx @ruvnet/ruview tools` lists 16 tools. `node -e "import('@ruvnet/ruview/sdk')"`
|
||||
resolves from an installed tarball.
|
||||
@@ -0,0 +1,83 @@
|
||||
# ADR-370: Cross-platform, verified ESP32 firmware flashing from npm
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: firmware, esp32, esptool, supply-chain, hardware-evidence
|
||||
- **Related**: ADR-028 (capability audit / witness), ADR-369, firmware
|
||||
`README.md` §0 and §2
|
||||
|
||||
## Context
|
||||
|
||||
The documented flash procedure is four `esptool write_flash` offsets. It is
|
||||
typed by hand, and a wrong chip, wrong flash size, or tampered image can brick
|
||||
a board or silently run the wrong firmware. The previous `ruview_node_flash`
|
||||
was Windows-only and never flashed. Project rules also say that a successful
|
||||
build or write is **not** hardware validation; that requires a captured
|
||||
boot/runtime log from real silicon.
|
||||
|
||||
## Decision
|
||||
|
||||
`src/firmware.js` implements plan → verify → preflight → write → evidence:
|
||||
|
||||
1. **Variants**:
|
||||
- `s3-8mb` → `esp32s3` / 8MB
|
||||
- `s3-4mb` → `esp32s3` / 4MB; prefers `*-4mb.bin` images
|
||||
- `c6` → `esp32c6` / 4MB
|
||||
|
||||
Offsets follow the shipped partition tables: bootloader `0x0`, partition
|
||||
table `0x8000`, optional OTA data `0xf000`, app `0x20000`. NVS is never
|
||||
written, so WiFi and node configuration survive.
|
||||
2. **Bundle verification**. A bundle is an extracted release flash bundle,
|
||||
`release_bins/<variant>`, or an ESP-IDF `build/` directory.
|
||||
- Every image must match `SHA256SUMS` or `SHA256SUMS.txt`. A mismatch is
|
||||
always fatal.
|
||||
- Missing checksums are refused unless `allow_unverified` is set, which is
|
||||
for local builds only.
|
||||
- Images must be regular, non-empty files of at most 16 MiB, inside the
|
||||
bundle; symlinks that escape the bundle are refused.
|
||||
- An 8 MB variant refuses a 4 MB partition table.
|
||||
- A bundle without OTA data produces an explicit warning (a node on `ota_1`
|
||||
keeps booting `ota_1`).
|
||||
3. **Argv, not shell.**
|
||||
- The port must match `COM<n>` or `/dev/(tty|cu)*`.
|
||||
- Baud comes from a fixed set.
|
||||
- The command is `python -m esptool --chip … --port … --baud … write_flash
|
||||
--flash_mode dio --flash_size … <offset> <file>…`. The underscore forms
|
||||
work in both esptool 4.x and 5.x.
|
||||
4. **Chip preflight.** `esptool chip_id` must report the variant's chip. The
|
||||
parser covers the 4.x "Chip is" and 5.x "Chip type:" / "Detecting chip
|
||||
type…" formats. A mismatch or missing response stops the flow before any
|
||||
write.
|
||||
5. **Evidence.** After a write, a read-only pyserial capture (default 15 s,
|
||||
port and duration passed as argv) is summarized: line count, CSI
|
||||
callbacks, MGMT+DATA upgrade, panic markers, firmware version.
|
||||
- `hardwareValidated: true` only when CSI callbacks appear with no panic;
|
||||
the result's `evidence` is then `MEASURED` with a boot-log reason.
|
||||
- Otherwise the result states the board is not validated.
|
||||
6. **Authority.** Without `confirm` the result is a plan. MCP needs the
|
||||
`hardware-write` grant plus `confirm`. `ruview_firmware_plan` and
|
||||
`ruview_firmware_ports` are read-only.
|
||||
7. **Provisioning stays out.** WiFi credentials remain in `provision.py`, so
|
||||
they never enter harness arguments, logs, or MCP transcripts.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `npx @ruvnet/ruview doctor --group firmware` verified all four checked-in
|
||||
`release_bins` bundles (c6-adr110, c6-onboarding, s3-adr110,
|
||||
s3-fair-adr110) against their `SHA256SUMS.txt` in this change's development
|
||||
container.
|
||||
- The write and preflight paths are covered by tests with injected fake
|
||||
hardware: mismatch refusal, probe failure, flash failure, boot-log
|
||||
validation, and a silent log that yields no validation.
|
||||
- **No real board was flashed** during this change, so real-silicon validation
|
||||
is outstanding.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Operators on any OS get one verified command. Agents can review plans
|
||||
without hardware authority.
|
||||
- The workflow depends on Python, esptool, and pyserial. `doctor` reports each
|
||||
one with a fix.
|
||||
- Follow-up: an ADR-028 witness record could store the boot-log summary and
|
||||
image digests.
|
||||
@@ -0,0 +1,91 @@
|
||||
# ADR-371: Harness training runner and mean-pose evidence gate
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: training, evaluation, evidence, pose, honesty
|
||||
- **Related**: ADR-079 (camera-supervised pose), ADR-151 (room calibration),
|
||||
ADR-181 (baseline-relative PCK), ADR-369
|
||||
|
||||
## Context
|
||||
|
||||
RuView has a retracted pose result (a CLAIMED 92.9%/100% that did not survive
|
||||
baseline and leakage review). The project rule is that pose
|
||||
PCK is quoted only with the mean-pose baseline, on a leakage-free held-out
|
||||
split, with an evidence tag. The `train-pose` skill says this in prose, but
|
||||
nothing enforces it. Running the trainers also needs repository knowledge:
|
||||
the `tch-backend` feature, a libtorch version match, and where checkpoints go.
|
||||
|
||||
## Decision
|
||||
|
||||
### Gate (`ruview_train_gate`, pure, read-only)
|
||||
|
||||
The input is a small report: metric, model and baseline score, split,
|
||||
train/test subjects, train end and test start, `n_test`, reproducer, and data
|
||||
source.
|
||||
|
||||
**Hard failures:**
|
||||
- no baseline;
|
||||
- a random-frame split, or any split outside chronological / blocked-gap /
|
||||
grouped-subject / grouped-session;
|
||||
- subject overlap in a grouped-subject split;
|
||||
- missing or overlapping time bounds for chronological or blocked-gap splits;
|
||||
- no improvement over the baseline.
|
||||
|
||||
**Warnings:**
|
||||
- subjects not reported;
|
||||
- subjects overlapping in non-subject splits;
|
||||
- `n_test` < 100;
|
||||
- a near-perfect score: at or above the `implausible_score` threshold in
|
||||
`src/training.js`.
|
||||
|
||||
**Evidence tag:** `SYNTHETIC` when `data: synthetic`, `MEASURED` only with a
|
||||
reproducer, otherwise `CLAIMED`.
|
||||
|
||||
On PASS the gate returns the one acceptable sentence:
|
||||
`Held-out <metric> X% vs Y% mean-pose baseline = +Z pp (<tag>, …, <split> split).`
|
||||
|
||||
### Runner (`ruview_train`, `ruview_train_plan`)
|
||||
|
||||
Modes:
|
||||
- `pose-smoke`: `wifi-densepose-train` `train --dry-run` on the synthetic
|
||||
dataset.
|
||||
- `pose`: `--data-dir`, MM-Fi recordings.
|
||||
- `room`: `wifi-densepose train-room`, or `cargo run -p wifi-densepose-cli`
|
||||
when the binary is absent.
|
||||
|
||||
Rules:
|
||||
- All caller paths (config, data, checkpoints, enrollment, output) must
|
||||
resolve inside the trusted checkout. Realpaths are checked for inputs that
|
||||
already exist.
|
||||
- Pose runs use `cargo run --manifest-path v2/Cargo.toml` with the working
|
||||
directory set to the checkpoint directory, default
|
||||
`v2/target/ruview-train/<mode>`. The trainer's relative `logs/` therefore
|
||||
never lands in the source tree.
|
||||
- Without `confirm` it returns a plan. MCP requires `workspace-write` plus
|
||||
`confirm`.
|
||||
- Failures are classified with remedies:
|
||||
- libtorch absent;
|
||||
- libtorch version mismatch (tch 0.24 expects torch 2.11.0);
|
||||
- link failure.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **SYNTHETIC** (`npx @ruvnet/ruview train --mode pose-smoke --samples 16
|
||||
--confirm`, CPU PyTorch 2.11.0, `LIBTORCH_USE_PYTORCH=1`, Linux x64): the
|
||||
real trainer compiled, ran 20 epochs with early stopping, and wrote
|
||||
safetensors checkpoints and `logs/training_log.csv` under
|
||||
`v2/target/ruview-train/pose-smoke`. The trainer labels its own smoke
|
||||
metrics "DO NOT REPORT". None are reported here.
|
||||
- With PyTorch 2.14.1 the tch 0.24 C++ shim failed to compile. The runner
|
||||
classified this as a version mismatch rather than a missing install.
|
||||
- The gate is covered by `test/training.test.mjs`, including the leakage
|
||||
patterns behind the retraction.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A number can pass through the harness only with its baseline and split
|
||||
attached. The gate does not verify the arithmetic that produced the input;
|
||||
reviewers must still re-run the reproducer.
|
||||
- Training needs the Rust toolchain and a matching libtorch. `doctor` covers
|
||||
Rust; libtorch is detected at run time.
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-372: Structured debugging doctor
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: diagnostics, onboarding, support, mcp
|
||||
- **Related**: ADR-369, ADR-370, ADR-371, docs/TROUBLESHOOTING.md
|
||||
|
||||
## Context
|
||||
|
||||
Most failed RuView setups come down to a missing driver, pyserial, esptool, a
|
||||
wrong port, uninitialized submodules, a missing wasm32 target, or a sensing
|
||||
server that is not running. The old `doctor` checked only the harness itself,
|
||||
gave no remedies, and had no machine-readable output for agents.
|
||||
|
||||
## Decision
|
||||
|
||||
`src/doctor.js` returns
|
||||
`{ok, summary{pass,warn,fail,skip}, checks[{group,id,status,detail,remedy}], nextSteps}`.
|
||||
|
||||
Groups:
|
||||
|
||||
| Group | Checks |
|
||||
|---|---|
|
||||
| `runtime` | Node ≥ 20 |
|
||||
| `harness` | packaged-file manifest integrity, claim-guardrail self-test, reviewed brain load |
|
||||
| `hosts` | `claude` / `codex` on PATH |
|
||||
| `repo` | checkout, workspace submodules |
|
||||
| `rust` | cargo, wasm32 target (run inside `v2/` so the pinned toolchain is used), `wifi-densepose` binary |
|
||||
| `python` | interpreter, pyserial, esptool version |
|
||||
| `firmware` | checksum verification of every `release_bins` bundle |
|
||||
| `serial` | pyserial port enumeration; optional named port; optional chip probe |
|
||||
| `sensing` | optional sensing-server `/health` |
|
||||
| `kernel` | optional `@ruvnet/ruview-kernel` WASM/napi status |
|
||||
|
||||
Rules:
|
||||
|
||||
- `fail` is reserved for a requested capability that cannot work. Absent
|
||||
optional tooling is `warn`. Every non-pass check carries a remedy. The CLI
|
||||
exit code is non-zero only on `fail`.
|
||||
- The doctor is read-only. Two exceptions exist, both CLI/SDK options that are
|
||||
absent from the MCP schema:
|
||||
- `--probe` runs `esptool chip_id`, which resets the board but writes
|
||||
nothing.
|
||||
- `--url` makes an HTTP(S) request.
|
||||
- Output is human-readable by default. `--json` gives the structured report.
|
||||
`--group a,b` limits the scope.
|
||||
|
||||
## Evidence
|
||||
|
||||
Development container (Linux x64, Node 22):
|
||||
|
||||
- Before `pip install pyserial esptool`, the doctor reported them as `warn`
|
||||
with the exact pip commands.
|
||||
- After installation it showed `esptool 5.4.0` and enumerated `/dev/ttyS0`.
|
||||
- All four firmware bundles verified.
|
||||
- It caught manifest drift during development, and a missing wasm32 target
|
||||
when run outside `v2/`. That second case led to the `v2/` working-directory
|
||||
rule.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Support threads can ask for `npx @ruvnet/ruview doctor --json`, and agents
|
||||
can act on `nextSteps`.
|
||||
- Checks run real subprocesses, so a full run takes seconds; `--group` keeps
|
||||
targeted runs fast.
|
||||
@@ -0,0 +1,244 @@
|
||||
# ADR-373: Host device access layer — ESP32, mmWave, LiDAR
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: hardware, esp32, mmwave, lidar, serial, udp, mcp, least-authority
|
||||
- **Related**: ADR-018 (CSI frame), ADR-063 (mmWave fusion), ADR-320
|
||||
(sensor HAL), ADR-369 (npm operator surface), ADR-370 (flashing),
|
||||
ADR-374 (remote hosts), `integrations/iphone-lidar`
|
||||
|
||||
## Context
|
||||
|
||||
Operators and agents need to answer basic hardware questions, such as "is the
|
||||
node streaming?", "is the radar talking?" and "does the LiDAR see the room?",
|
||||
before calibration, fusion or training. Until now each modality had its own
|
||||
tool:
|
||||
|
||||
- the ESP32 serial monitor;
|
||||
- `scripts/mmwave_fusion_bridge.py`;
|
||||
- the iPhone LiDAR web relay;
|
||||
- the sensing server itself.
|
||||
|
||||
None of these was reachable through the npm harness, its MCP server or its
|
||||
SDK.
|
||||
|
||||
The package must stay free of runtime dependencies (ADR-263). Node has no
|
||||
serial API. Hardware access is also authority: an MCP client should not get
|
||||
it by default.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `src/devices/` to `@ruvnet/ruview` (0.7.0) with four read-oriented tools.
|
||||
|
||||
| Tool | Transport | What it returns |
|
||||
|---|---|---|
|
||||
| `ruview_devices_scan` | pyserial port list | ports with USB VID:PID, bridge chip, likely roles (esp32 / mmwave / rplidar), and the command that confirms each role |
|
||||
| `ruview_esp32_capture` | `node:dgram` UDP, receive-only | per node: packet counts by kind (magics `0xC5110001`–`0xC5110007`), CSI rate, sequence loss, RSSI, antennas/subcarriers/frequency, latest device vitals |
|
||||
| `ruview_mmwave_read` | serial | MR60BHA2 (60 GHz, 115200) and LD2410 (24 GHz, 256000) frames, checksum errors, presence, distance, device-reported heart and breathing rate |
|
||||
| `ruview_lidar_read` | serial or WebSocket | RPLIDAR SCAN summary (points, revolutions, range, angular coverage), or iPhone relay depth statistics |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Firmware-identical parsing.**
|
||||
- The MR60BHA2 and LD2410 parsers are ports of
|
||||
`firmware/esp32-csi-node/main/mmwave_sensor.c`: same SOF, `~XOR` header
|
||||
and data checksums, 30-byte payload cap, and F4F3F2F1…F8F7F6F5 framing.
|
||||
- ESP32 packets are parsed as the little-endian structs in `csi_collector`
|
||||
and `edge_processing.h`.
|
||||
- Host and node therefore agree on what a valid frame is.
|
||||
2. **Serial without dependencies.** A fixed Python script (the pyserial
|
||||
"pump") reads the port for a bounded time and number of bytes.
|
||||
- Port, baud, duration and command bytes are passed as argv and validated
|
||||
first: the port regex, a fixed baud set, and command bytes of at most 64
|
||||
hex bytes.
|
||||
- Output is lowercase hex, so the secret-redaction layer cannot corrupt
|
||||
binary data.
|
||||
- DTR control is best-effort and reported when a port does not support it.
|
||||
3. **Receive-only network.**
|
||||
- The ESP32 capture binds a local UDP port (bind address from a fixed set,
|
||||
port ≥ 1024) and never sends to a node.
|
||||
- `EADDRINUSE` is reported as "sensing server already bound".
|
||||
4. **Minimal actuation.** An RPLIDAR scan sends `A5 20` (start) and `A5 25`
|
||||
(stop), and drives DTR low (the A1 motor). It spins the motor but writes no
|
||||
configuration. Nothing else is written to any device.
|
||||
5. **No raw sensor data in results.**
|
||||
- iPhone depth frames are validated with the same rules as
|
||||
`integrations/iphone-lidar/web/codec.mjs` and reduced to statistics.
|
||||
- The relay token comes only from `RUVIEW_LIDAR_TOKEN`. It is rejected in
|
||||
the URL and never echoed.
|
||||
6. **Authority.**
|
||||
- All four tools require the new `device-access` MCP grant.
|
||||
- The CLI and SDK run them directly, as trusted local code.
|
||||
- Serial port names are pattern-checked in the schema and again in the
|
||||
driver. `/dev/pts/*` is deliberately excluded: it could read another
|
||||
terminal's input.
|
||||
7. **Evidence.**
|
||||
- A successful read is tagged `MEASURED` as a *reading* on this host.
|
||||
- mmWave vitals are labelled as the radar's own estimates.
|
||||
- None of these outputs is an accuracy claim.
|
||||
|
||||
## Evidence
|
||||
|
||||
Development container, Linux x64, Node 22. No physical sensors were attached.
|
||||
|
||||
- **ESP32 UDP**: a simulated node sent 60 ADR-018 CSI frames plus 3 vitals
|
||||
packets to `127.0.0.1:5005` through `ruview esp32`.
|
||||
- Result: 1 node, CSI rate 15 Hz, zero sequence loss, vitals decoded
|
||||
(15.2 bpm / 69 bpm, SYNTHETIC).
|
||||
- Unit test on real loopback: 2 dropped sequence numbers out of 7 produce a
|
||||
loss fraction of 0.2857.
|
||||
- **MR60BHA2**, emulated on a pseudo-terminal and read through real pyserial:
|
||||
- auto-detected `mr60bha2`;
|
||||
- 200 frames, 0 checksum errors;
|
||||
- presence 1.0, distance 87 cm, 14.8 / 68.5 bpm (the emulator's values,
|
||||
SYNTHETIC).
|
||||
- **RPLIDAR**, emulated on a pseudo-terminal that answers `A5 20`:
|
||||
- 3564 points, 99 revolutions, full angular coverage;
|
||||
- the missing DTR support was reported.
|
||||
- **iPhone LiDAR**, the real relay (`integrations/iphone-lidar/web/relay.mjs`)
|
||||
with a simulated phone:
|
||||
- 30 frames at 10 fps, median depth 1.824 m (SYNTHETIC);
|
||||
- a wrong token gives `connect_failed` with a remedy.
|
||||
- **Not yet done**: real ESP32-S3/C6, MR60BHA2, LD2410, RPLIDAR and iPhone
|
||||
captures. Each modality needs one real capture before its parser is called
|
||||
hardware-validated.
|
||||
|
||||
## Amendment 1 (2026-10-01): e2e suite and latency
|
||||
|
||||
- `harness/ruview/test/e2e/devices.e2e.mjs` (`npm run test:e2e:devices`, CI
|
||||
workflow `ruview-device-e2e.yml`) automates the emulated-device runs above
|
||||
through the real CLI:
|
||||
- MR60BHA2 and RPLIDAR on pseudo-terminals, read via real pyserial;
|
||||
- an ESP32 UDP node;
|
||||
- the real iPhone relay.
|
||||
- The harness still refuses `/dev/pts/*` port names. The suite links each pty
|
||||
to `/dev/ttyRUVIEWE2E<n>` with root or `sudo -n`.
|
||||
- `RUVIEW_E2E_REQUIRE=1` turns missing prerequisites into failures.
|
||||
- mmWave auto-detect keeps its probe frames (≤ 2 s per model) and reads only
|
||||
the remainder of the window. A 5 s request previously cost about 9 s.
|
||||
- `ruview_lidar_read` (iPhone) gains `max_frames` and returns immediately on
|
||||
a refused connection.
|
||||
- MEASURED wall time for the device e2e suite on the development container:
|
||||
19.6 s before these changes, 5.6 s after (concurrent cases, no redundant
|
||||
re-reads).
|
||||
|
||||
## Amendment 2 (2026-10-01): Realtek nodes, honest capture, live analysis
|
||||
|
||||
Driven by a hardware run of this branch against an RTL8721Dx board (PL2303GC,
|
||||
COM10) and an ESP32-C6 (CP210x, COM16) on Windows 11:
|
||||
|
||||
- **Realtek RAC1/RHB1 (ADR-323).** `ruview_esp32_capture` decodes the 49-byte
|
||||
RAC1 envelope (8- and 16-bit tones, CRC-32 verified, SYNTHETIC flag kept)
|
||||
and counts RHB1 heartbeats per sender. Before this, 3,439 live RAC1 frames
|
||||
in 15 s were all reported as unknown.
|
||||
- **No false success.** The capture now fails with `no_decodable_packets`
|
||||
or `heartbeat_only` when packets arrive but none decode. It previously
|
||||
returned `ok: true` with a MEASURED label for zero decoded packets.
|
||||
`heartbeatOnlySenders` names boards that are alive but deliver no CSI — the
|
||||
stall observed on a Realtek board before a reset.
|
||||
- **More ESP32 packet kinds.** ADR-110 sync (`0xC511A110`) and ADR-081 mesh
|
||||
envelopes (`0xC5118100`, counted by message type, never a phantom node).
|
||||
A node sending several CSI shapes (the C6 sends 1x256, 1x64 and 1x128)
|
||||
reports each in `csiShapes` instead of only the last one.
|
||||
- **Live CSI → kernel.** `--analyze` runs the busiest (or `--node-id`) node's
|
||||
dominant single-antenna shape through `@ruvnet/ruview-kernel` at the
|
||||
measured arrival rate, with a bounded frame buffer (`analyze_max_frames`,
|
||||
default 6,000). Results are signal-processing estimates with no reference.
|
||||
- **Serial monitor no longer reboots nodes.** The monitor opens the port with
|
||||
DTR/RTS deasserted. MEASURED on the C6: uptime kept rising across a monitor
|
||||
run (1,612 s → 1,625 s); before, opening the port reset it (uptime ~15 s).
|
||||
It takes `--baud` (Realtek logs at 1,500,000) and explains the C6/S3
|
||||
USB-Serial/JTAG console when the UART is silent.
|
||||
- **Device scan.** Prolific `067B:23A3` (PL2303GC) is classified `realtek`.
|
||||
- **Windows.** The hosts file gets an owner-only ACL (`icacls`), failing
|
||||
closed; POSIX `0o600` is ignored on Windows. The symlink-escape test skips
|
||||
only where Windows refuses to create symlinks.
|
||||
- **Parser speed.** Int8Array view and `sqrt` instead of per-byte reads and
|
||||
`Math.hypot`: MEASURED 0.42–0.52 M → 1.33–1.67 M packets/s (about 3.2x;
|
||||
three interleaved runs, median of seven each, Windows 11 x64, Node 24).
|
||||
|
||||
Hardware evidence (MEASURED, this host): C6 node 42, 45 s, 1,160 packets,
|
||||
1,157 decoded, 0 sequence loss, 541 1x256 frames at 12.1 Hz analyzed by the
|
||||
napi kernel (integrity verified). The RAC1 decoder is cross-checked against
|
||||
the Rust `realtek-csi-sim` encoder (200/200 frames at 8- and 16-bit tones);
|
||||
a live RAC1 re-run is pending reconnection of the Realtek board.
|
||||
|
||||
Real flash (MEASURED, same C6, COM16): `ruview flash --variant c6 --confirm`
|
||||
with the checksum-verified `release_bins/c6-adr110` bundle wrote 4 images
|
||||
(hash verified by esptool, NVS kept: node 42 rejoined). The boot log showed
|
||||
13 CSI callbacks, the MGMT+DATA upgrade and no panic. After boot, `monitor`
|
||||
saw CSI without a reset (uptime 36 s → 62 s) and the UDP capture decoded
|
||||
183/183 packets with zero loss. The run exposed one more Windows bug, now
|
||||
fixed: the boot-log capture wrote decoded text to a cp1252 stdout (the
|
||||
scrubbed child env) and aborted on the firmware's U+2192 log character, so
|
||||
`bootLog.captured` was false and an earlier abort would have hidden the CSI
|
||||
evidence. It now writes raw bytes; a regression test forces cp1252.
|
||||
|
||||
Live RTL8721Dx re-run (MEASURED, board 4 on COM10, node 3, alongside the C6):
|
||||
`devices` classified COM10 as `realtek`; a 20 s capture decoded 7,404/7,404
|
||||
packets (6,959 RAC1 frames at 348 Hz, RSSI −38.9 dBm, 200 RHB1 heartbeats);
|
||||
`monitor --baud 1500000` saw 62 CSI log lines and did not reset the board
|
||||
(sequence kept rising, 80,856 → 88,257). `--analyze --node-id 3` ran 12,549
|
||||
live frames at a measured 313.7 Hz through the WASM kernel (integrity
|
||||
verified). The board also exposed a loss-accounting bug carried over from the
|
||||
original code: one stray sequence value (+52,835, then straight back) was
|
||||
counted as 52,834 lost frames, giving 97% "loss". The tracker now treats a
|
||||
single out-of-window value as a stray, needs two agreeing frames to accept a
|
||||
counter reset, and lets a late packet fill its own gap. Against ground truth
|
||||
(unique sequence numbers over the span, 15 s): 4,783/4,783 present, true loss
|
||||
0, 9 strays — the tracker reported loss 0 and 9 strays.
|
||||
|
||||
## Amendment 3 (2026-10-01): ESPHome radar kits and CSI buffer starvation
|
||||
|
||||
- **ESPHome source for radar.** The Seeed MR60BHA2 kit (a XIAO ESP32-C6 with
|
||||
the 60 GHz radar) runs ESPHome, which owns the radar UART. Raw frames never
|
||||
reach USB, so `mmwave --model auto` correctly returned `no_valid_frames`.
|
||||
`ruview_mmwave_read` now takes `source: "esphome"` with a `host`.
|
||||
- **Client:** a dependency-free, read-only client for the plaintext ESPHome
|
||||
native API (TCP 6053) runs hello → connect → device info → list entities
|
||||
→ subscribe states for a bounded window. It never sends a command.
|
||||
- **Roles:** presence, heart, breathing, distance and target count are
|
||||
found by entity name. The result uses the same summary fields as the
|
||||
serial path.
|
||||
- **Decoding:** ESPHome's `missing_state` flag and proto3 zero defaults are
|
||||
honoured, so absent readings are excluded rather than averaged as 0.
|
||||
- **Address policy:** hosts must resolve to private, link-local, CGNAT
|
||||
(Tailscale) or loopback addresses.
|
||||
- **Unsupported cases:** encrypted (Noise) and password-protected APIs are
|
||||
reported as unsupported.
|
||||
- **Live kit (MEASURED, 192.168.1.102, ESPHome 2026.9.0, project
|
||||
`seeedstudio.mr60bha2_kit` `spaces-1.0`).**
|
||||
- The kit had been configured for an out-of-range SSID. It was moved to
|
||||
the operator's network through its own captive portal, without
|
||||
reflashing.
|
||||
- A 15 s read returned 154 state updates:
|
||||
- presence: detected, 1 target;
|
||||
- mean distance: 40.0 cm;
|
||||
- device-reported heart rate: mean 75.3 bpm;
|
||||
- device-reported breathing rate: mean 10.7 bpm.
|
||||
- These are firmware-computed values, not validated against a reference.
|
||||
- **Realtek heartbeat-only stall: root cause.** Reading the stalled board's
|
||||
console without a reset showed continuous `[WLAN-W] lack of csi buf!` /
|
||||
`csi buf not enough`.
|
||||
- The radio produces CSI reports, but the firmware does not return report
|
||||
buffers, so CSI stops while heartbeats continue.
|
||||
- `ruview monitor` now counts these lines and fails with
|
||||
`csi_buffer_starvation`. Observed live: 399 lines in 8 s, 0 CSI.
|
||||
- The lasting fix belongs in the RTL8721Dx firmware (outside this
|
||||
repository).
|
||||
- **Views.** Radar results render in the terminal UI and in the console
|
||||
widget (`ruview_mmwave_read` is now a UI tool). Both label vitals
|
||||
"device-reported, not validated".
|
||||
|
||||
## Consequences
|
||||
|
||||
- One command per modality works across CLI, MCP and SDK, with actionable
|
||||
failures (`pyserial_missing`, `port_open_failed`, `no_valid_frames`,
|
||||
`port_in_use`, `connect_failed`).
|
||||
- Python and pyserial become the serial dependency. `doctor` reports both.
|
||||
- The package's unpacked-size ceiling rises to 320 KiB (from 224 KiB) in
|
||||
both npm workflows, with the published package still free of runtime
|
||||
dependencies.
|
||||
- Follow-up: feed these readers into `ruview-hal` observations (ADR-320)
|
||||
for fusion.
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-374: Remote host access over SSH (read-only)
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: remote, ssh, hardware, least-authority, mcp
|
||||
- **Related**: ADR-369, ADR-373
|
||||
|
||||
## Context
|
||||
|
||||
RuView hardware usually hangs off a different machine from the one the
|
||||
operator or agent works on: a Raspberry Pi next to the ESP32s, a NUC running
|
||||
the sensing server, a Cognitum Seed. Cloud agent sessions cannot reach local
|
||||
USB at all.
|
||||
|
||||
"Host access" should not mean opening a new network service with its own
|
||||
credentials: that adds attack surface and secret handling. Operators already
|
||||
have SSH.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Where work runs.**
|
||||
- Hardware tools always execute on the machine the hardware is attached
|
||||
to.
|
||||
- Interactive work runs in a local Claude Code session on that machine. It
|
||||
can be driven from the Claude app through Remote Control
|
||||
(`claude remote-control` in the repo).
|
||||
- Other machines are reached over SSH.
|
||||
2. **Hosts registry.**
|
||||
- Stored in `~/.config/ruview/hosts.json` (directory 0700, file 0600).
|
||||
- Written only by the CLI: `ruview hosts add --name --ssh [--port]
|
||||
[--version]`. MCP can list hosts but never add them.
|
||||
- Names must match `^[a-z0-9][a-z0-9-]{0,31}$`. Targets must be
|
||||
`[user@]hostname`: no options, spaces, shell metacharacters or leading
|
||||
dash.
|
||||
3. **Transport.**
|
||||
- Local ssh arguments: `ssh -o BatchMode=yes -o ConnectTimeout=10 -o
|
||||
StrictHostKeyChecking=yes -T [-p N] -- <target> <command>`.
|
||||
- No password prompts, and no trust-on-first-use from the tool. The
|
||||
operator pins the host key once interactively.
|
||||
- The remote command is `npx -y @ruvnet/ruview@<exact version> call <tool>
|
||||
--read-only --args-json <json>`, with every token single-quoted for the
|
||||
remote POSIX shell.
|
||||
4. **Read-only, twice.**
|
||||
- Locally, `ruview_host_run` accepts only tools that are read-only, use no
|
||||
credentials, and are not `ruview_host_*` (no host hopping). It validates
|
||||
arguments against the tool schema before sending.
|
||||
- Remotely, `call --read-only` repeats the check and schema validation.
|
||||
- Flash, train, calibrate and Cognitum Spaces reads are never forwarded.
|
||||
5. **Grants.** Over MCP, `ruview_host_run` needs `remote-host` plus the
|
||||
remote tool's own grant (for example `device-access`).
|
||||
6. **Failures** are classified with remedies: `host_key_unverified`,
|
||||
`ssh_auth_failed`, `host_unreachable`, `remote_node_missing`,
|
||||
`unknown_host`, `remote_tool_not_allowed`.
|
||||
|
||||
## Evidence
|
||||
|
||||
Tests in `harness/ruview/test/remote.test.mjs` cover:
|
||||
|
||||
- host-entry injection cases (`-oProxyCommand=…`, `;`, spaces, `$(…)`,
|
||||
backticks);
|
||||
- quoting, verified by running the quoted command through a real `sh`;
|
||||
- the refusal list;
|
||||
- grant composition;
|
||||
- the file mode;
|
||||
- `call --read-only` refusing `ruview_node_flash` through the real CLI.
|
||||
|
||||
No SSH server was available in the development container, so a live
|
||||
round-trip to a real host is still to be done.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Remote hosts get the same tools with no new daemon, port or credential
|
||||
store.
|
||||
- The remote needs Node 20+ and npm registry access (or a pre-installed
|
||||
version), and `ssh` must be on the local PATH. `doctor --group remote`
|
||||
checks the local side.
|
||||
- Remote mutations stay manual by design.
|
||||
@@ -0,0 +1,202 @@
|
||||
# ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: npm, mcp, mcp-apps, chatgpt, ui, cli, http, least-authority, performance
|
||||
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution and size
|
||||
budget), ADR-369 (operator surface and SDK), ADR-373 (device access),
|
||||
ADR-374 (remote hosts)
|
||||
|
||||
## Context
|
||||
|
||||
The harness exposes one tool registry through the CLI, an MCP stdio server and
|
||||
an SDK (ADR-369). Hardware testing of the device tools (ADR-373 Amendment 2)
|
||||
showed three gaps in how results reach people and models:
|
||||
|
||||
- **MCP results were text only.** Every tool returned pretty-printed JSON in a
|
||||
text block, with no `structuredContent`. The server declared protocol
|
||||
`2024-11-05`, and `resources/list` was always empty, so MCP Apps hosts and
|
||||
ChatGPT had nothing to render.
|
||||
- **ChatGPT could not connect.** ChatGPT connectors reach MCP servers over
|
||||
HTTPS. The harness spoke stdio only.
|
||||
- **The CLI printed raw JSON to humans.** A live capture of two nodes was a
|
||||
1.2 kB JSON object in the terminal, with alerts such as heartbeat-only
|
||||
senders easy to miss.
|
||||
|
||||
Reference implementations for the target hosts (the `web-based-chatgpt-mcp-starter`
|
||||
and `signal-to-swarm` sites) declare a `ui://` HTML resource and point UI tools
|
||||
at it through both the MCP Apps key (`_meta.ui.resourceUri`) and the ChatGPT
|
||||
keys (`openai/outputTemplate`, `openai/widgetAccessible`).
|
||||
|
||||
The package must stay free of runtime dependencies (ADR-263) and inside a
|
||||
reviewed size budget (ADR-265).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. One console widget, two host families
|
||||
|
||||
`src/ui/console-widget.js` holds one self-contained HTML resource,
|
||||
`ui://ruview/console-v1.html`, with mimeType `text/html;profile=mcp-app`.
|
||||
|
||||
- **Tools.** `ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor`
|
||||
carry `_meta.ui.resourceUri`, `ui/resourceUri`, `openai/outputTemplate` and
|
||||
`openai/widgetAccessible`.
|
||||
- **Data intake.** The widget reads results from either host family:
|
||||
- MCP Apps: a `ui/initialize` handshake over `postMessage`, then
|
||||
`ui/notifications/tool-input` and `ui/notifications/tool-result`;
|
||||
- ChatGPT: `window.openai.toolOutput` and the `openai:set_globals` event.
|
||||
- **Refresh.** A Refresh button re-runs the same tool with the same
|
||||
arguments, through `window.openai.callTool` or an MCP Apps `tools/call`
|
||||
request.
|
||||
- **Safety.**
|
||||
- The resource declares an empty CSP allowlist and makes no network
|
||||
requests.
|
||||
- Every value reaches the DOM through `textContent`; there is no
|
||||
`innerHTML`.
|
||||
- Messages are accepted only from the parent frame.
|
||||
- A test pins all of this.
|
||||
- **Design.** The visual language follows the reference sites: dark ground,
|
||||
`#d0ff72` accent, monospace labels, and plain wording about what each value
|
||||
is. Device vitals are labelled "device-reported (not validated)". Kernel
|
||||
outputs carry the "no reference measurement" note, and SYNTHETIC frames
|
||||
raise a warning.
|
||||
|
||||
### 2. Structured results and protocol negotiation
|
||||
|
||||
- **Protocol negotiation.** `initialize` negotiates `2025-06-18`,
|
||||
`2025-03-26` or `2024-11-05`, and advertises `resources`.
|
||||
- **Result shape.** `tools/call` returns `structuredContent` (always an
|
||||
object), a text block with the same JSON in compact form, and
|
||||
`_meta['ruview/tool']` set to the canonical tool name (so an aliased call
|
||||
still refreshes correctly).
|
||||
- **Compact text.** Compact rather than pretty-printed JSON cuts each result
|
||||
by 17–33% (MEASURED on this host: guidance 17.5%, device scan 20.7%, live
|
||||
capture 33.4%). Clients that read only `content` still receive the
|
||||
complete result.
|
||||
- **Titles.** Tools gain `title` and `annotations.title`.
|
||||
|
||||
`handleRpc` is transport-neutral, and `createDispatcher` holds the FIFO tool
|
||||
chain both transports share (ADR-263 O2).
|
||||
|
||||
### 3. Streamable HTTP transport
|
||||
|
||||
`ruview mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]`
|
||||
starts `src/mcp-http.js`.
|
||||
|
||||
- **Authentication on every request.** A request must carry either
|
||||
`Authorization: Bearer <token>`, or the secret-path form `/mcp/<token>`.
|
||||
The second exists because ChatGPT connectors cannot send a static header.
|
||||
- Tokens are compared in constant time and must be at least 16 characters.
|
||||
- The token comes from `RUVIEW_MCP_TOKEN`, never argv. If unset, one is
|
||||
generated and printed once to stderr.
|
||||
- **Least authority.**
|
||||
- It binds to loopback by default.
|
||||
- Browser `Origin`s are refused unless allowlisted (DNS-rebinding defence).
|
||||
Server-to-server clients send no Origin.
|
||||
- `workspace-write` and `hardware-write` are never honoured over HTTP, even
|
||||
when granted in `RUVIEW_MCP_GRANTS`. Flashing and calibration stay
|
||||
stdio/CLI-only.
|
||||
- **Protocol surface.**
|
||||
- POST only; single JSON responses with no SSE stream and no sessions.
|
||||
- Batches and unsupported `MCP-Protocol-Version` values are refused.
|
||||
- Bodies are capped at 256 KiB.
|
||||
- Notifications return 202.
|
||||
- `/healthz` reports only the name and version.
|
||||
|
||||
Exposing the port to ChatGPT (a tunnel or reverse proxy with TLS) is the
|
||||
operator's choice. The startup banner says so and warns on a non-loopback
|
||||
bind.
|
||||
|
||||
### 4. Terminal UI
|
||||
|
||||
`src/cli-ui.js` renders results for a TTY:
|
||||
- aligned tables, with unicode rate bars;
|
||||
- colour that respects `NO_COLOR` and `TERM=dumb`;
|
||||
- alerts and fixes ahead of the data;
|
||||
- a stderr countdown for timed captures.
|
||||
|
||||
Pipes and `--json` keep receiving exact JSON. `ruview call` always prints
|
||||
JSON, because remote hosts parse it.
|
||||
|
||||
`ruview esp32 --watch [--seconds 3]` redraws repeated capture windows with a
|
||||
per-node rate sparkline.
|
||||
|
||||
`ruview monitor` now flags Ameba CSI report-buffer starvation (`lack of csi
|
||||
buf` / `csi buf not enough`). This is the root cause of the Realtek
|
||||
heartbeat-only stall, observed live: 399 starvation lines in 8 s, 0 CSI.
|
||||
|
||||
### 5. Startup
|
||||
|
||||
- **Launcher split.** `bin/cli.js` is a thin launcher, and `src/cli.js` loads
|
||||
the tool registry, doctor, brain, host adapters and transports on demand.
|
||||
- **Measured effect.**
|
||||
- `--help`/`--version`/`skills` skip the registry: about 20% faster, median
|
||||
of 9 interleaved runs against the previous build on this host (MEASURED).
|
||||
- The MCP path needs the whole registry to list tools, so its start time is
|
||||
unchanged within noise.
|
||||
- **Compile cache rejected.** Node's on-disk compile cache
|
||||
(`module.enableCompileCache`) was evaluated: module load was 27.7 ms with
|
||||
it and 28.5 ms without (25 interleaved runs, MEASURED). That is within
|
||||
noise, so it was not adopted.
|
||||
|
||||
### 6. Size budget
|
||||
|
||||
The additions cost about 38 kB:
|
||||
- widget, 12.6 kB;
|
||||
- HTTP transport;
|
||||
- terminal UI;
|
||||
- CLI split.
|
||||
|
||||
The `@ruvnet/ruview` unpacked budget rises from 320 KiB to 384 KiB in
|
||||
`npm-packages.yml` and `ruview-npm-release.yml`. The package is about 338 kB.
|
||||
|
||||
## Evidence
|
||||
|
||||
All on Windows 11 x64, Node 24, with an ESP32-C6 (CP210x, COM16) and an
|
||||
RTL8721Dx (PL2303GC, COM10) attached. All results MEASURED unless noted.
|
||||
|
||||
**Browser e2e.** A real MCP Apps host flow in headless Chrome:
|
||||
- The host lists tools and reads the `ui://` resource over HTTP MCP, then
|
||||
loads it in a sandboxed iframe (`allow-scripts` only).
|
||||
- It answers `ui/initialize` and delivers a live `ruview_esp32_capture`
|
||||
result.
|
||||
- The widget's Refresh button triggered a second real capture through the
|
||||
host (108 → 105 packets).
|
||||
- The devices and doctor views rendered, in dark and light themes. The only
|
||||
console error was the test host's own missing favicon.
|
||||
|
||||
**Security probes** against the live HTTP server:
|
||||
- no token: 401;
|
||||
- wrong token: 401;
|
||||
- foreign Origin: 403;
|
||||
- GET: 405;
|
||||
- `ruview_node_flash` with `hardware-write` granted in the environment:
|
||||
`authority_denied`.
|
||||
|
||||
**Tests.**
|
||||
- `npm test`: 160 pass, 1 skip.
|
||||
- `test:security`: 83 pass, 1 skip.
|
||||
- New suites: `mcp-apps.test.mjs` (protocol, metadata, widget safety, HTTP
|
||||
auth/origin/size/write denial) and `cli-ui.test.mjs` (renderers, colour
|
||||
rules, the starvation detector run against a stand-in serial module).
|
||||
|
||||
**Tarball smoke** from a clean install:
|
||||
- CLI and SDK work;
|
||||
- stdio MCP negotiates `2025-06-18` and serves the widget and structured
|
||||
results;
|
||||
- HTTP MCP lists 22 tools, 3 with `ui://` metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
- MCP Apps hosts and ChatGPT render node streams, device scans and
|
||||
diagnostics instead of raw JSON, and can refresh them in place.
|
||||
- Remote MCP clients can use the read tools over authenticated HTTP. Write
|
||||
tools remain local by construction.
|
||||
- Each tool result costs fewer model tokens.
|
||||
- The widget is a second place where result shapes are interpreted. Changes
|
||||
to the capture, devices or doctor result shapes must update it, and its
|
||||
version suffix (`console-v1`) changes when the contract does.
|
||||
- Host behaviour for MCP Apps and ChatGPT keys is followed as documented by
|
||||
the reference implementations. A live ChatGPT connector session (public
|
||||
HTTPS URL) has not been run yet: CLAIMED, pending.
|
||||
@@ -0,0 +1,142 @@
|
||||
# ADR-376: `ruview` — one npm install for every RuView component
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv (requested and approved the release); RuView maintainers
|
||||
- **Tags**: npm, packaging, umbrella, mcp, homecore, kernel, release
|
||||
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution, D2 CI-only
|
||||
publishing, D4 bin ownership), ADR-285 (Homecore metaharness), ADR-368
|
||||
(kernel), ADR-369 (operator surface), ADR-373 (device access), ADR-375 (MCP
|
||||
Apps console, HTTP transport, terminal UI)
|
||||
|
||||
## Context
|
||||
|
||||
RuView is positioned as an ambient intelligence platform rather than a sensor.
|
||||
Its sensing is ahead of comparable packaged options; packaging is the
|
||||
bottleneck. Today the capabilities are split across three packages:
|
||||
|
||||
| Package | Contents | Published before this ADR |
|
||||
|---|---|---|
|
||||
| `@ruvnet/ruview` | operator harness | 0.5.1 |
|
||||
| `homecore` | Homecore developer metaharness | never |
|
||||
| `@ruvnet/ruview-kernel` | WASM vitals kernel | never |
|
||||
|
||||
The unscoped name `ruview` was unclaimed. A user has to know all three
|
||||
packages, install them separately, and run three CLIs and two MCP servers.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **The umbrella.** Publish an unscoped `ruview` package
|
||||
(`harness/ruview-umbrella`). It depends on exact versions of the three
|
||||
component packages and adds no capability of its own:
|
||||
- **CLI.** One binary, `ruview`:
|
||||
- `ruview homecore …` → the Homecore CLI;
|
||||
- `ruview kernel <doctor|info|selftest|parity|synth|analyze|bench>` → the
|
||||
kernel CLI;
|
||||
- bare `ruview kernel [--backend …]` keeps its harness meaning (the
|
||||
SYNTHETIC self-test);
|
||||
- `ruview capabilities` lists every component, version and tool;
|
||||
- everything else goes to the `@ruvnet/ruview` CLI unchanged.
|
||||
- **MCP server.** One server, `ruview mcp start [--http]`. It serves the
|
||||
harness tools (`ruview_*`, including the `ui://` console) plus the
|
||||
Homecore tools (`homecore_*`, already prefixed, so no collision).
|
||||
- Non-Homecore traffic goes to the harness handler, so the harness
|
||||
authority model, grants and HTTP rules (ADR-375) apply unchanged.
|
||||
- Homecore calls go to Homecore's `runTool`, with Homecore's own
|
||||
policy.
|
||||
2. **Bin ownership (amends ADR-265 D4).** The `ruview` command name moves to
|
||||
the umbrella. `@ruvnet/ruview`'s bin is renamed `ruview-harness`.
|
||||
- **Why:** a clean-directory tarball install showed the collision. With
|
||||
both packages declaring `ruview`, `node_modules/.bin/ruview` linked to
|
||||
the harness CLI, so `ruview capabilities` and `ruview homecore` failed.
|
||||
Which package wins that link is not guaranteed.
|
||||
- **Unaffected:** `npx @ruvnet/ruview …` still works, because npx runs a
|
||||
package's only bin whatever its name. The umbrella delegates every
|
||||
non-umbrella verb to the harness, so `ruview <verb>` behaves as before.
|
||||
- **Changed:** a global install of `@ruvnet/ruview` alone now provides
|
||||
`ruview-harness`.
|
||||
3. **Harness changes needed to embed it** (no new runtime dependencies):
|
||||
- **New exports:** `./cli`, `./cli-ui`, `./mcp`, `./mcp-http`, `./ui`,
|
||||
`./esphome` and `./kernel`.
|
||||
- **Injectable MCP handler:** `run(args, { mcpHandler })` and
|
||||
`startMcpServer({ handler })`, so the merged server reuses the stdio and
|
||||
HTTP transports.
|
||||
- **Kernel declared as an optional peer:** `@ruvnet/ruview-kernel`.
|
||||
- **`setKernelImporter`:** lets the umbrella resolve the kernel from its
|
||||
own dependency tree. Node resolves bare specifiers from the importing
|
||||
file's real path, which misses a sibling dependency under linked or
|
||||
pnpm-style installs.
|
||||
- Found while testing: `kernel_not_installed` through a linked umbrella.
|
||||
- Only embedding code can call it; MCP arguments cannot choose the
|
||||
module.
|
||||
- **Homecore** gains a `./cli` export; **the kernel** gains `./cli`.
|
||||
4. **Versions for this release.**
|
||||
|
||||
| Package | Version | Notes |
|
||||
|---|---|---|
|
||||
| `@ruvnet/ruview` | 0.8.0 | 0.7.0 was never published |
|
||||
| `homecore` | 0.1.0 | first publish |
|
||||
| `@ruvnet/ruview-kernel` | 0.1.0 | first publish; WASM only |
|
||||
| `ruview` | 0.8.0 | umbrella |
|
||||
|
||||
The kernel ships WASM only. The napi backend stays optional and absent,
|
||||
because a workstation build would cover only win32-x64. `loadKernel({
|
||||
backend: 'auto' })` reports the honest fallback.
|
||||
5. **Publishing exception (one-off, amends ADR-265 D2 for this release).**
|
||||
- rUv explicitly chose a local `npm publish` from the operator
|
||||
workstation (ruvzen) over the CI-only release workflow.
|
||||
- Packages are published in dependency order: kernel → homecore →
|
||||
@ruvnet/ruview → ruview.
|
||||
- Each is published only after its full gate passes locally (tests,
|
||||
security tests, manifest, version-literal and pack-content/size gates),
|
||||
plus a clean-directory tarball install and smoke test of the umbrella.
|
||||
- These versions carry no npm provenance attestation.
|
||||
- Subsequent releases return to the CI workflow. Adding `ruview` and
|
||||
`@ruvnet/ruview-kernel` to `ruview-npm-release.yml` is follow-up work.
|
||||
|
||||
## Release outcome (2026-10-01)
|
||||
|
||||
**Published from ruvzen** (exact tarballs that passed the clean-directory
|
||||
smoke test; sha256 in the release thread):
|
||||
|
||||
| Package | Version |
|
||||
|---|---|
|
||||
| `@ruvnet/ruview-kernel` | 0.1.0 |
|
||||
| `homecore` | 0.1.0 |
|
||||
| `@ruvnet/ruview` | 0.8.0 |
|
||||
|
||||
All three were re-installed from the registry and smoke-tested:
|
||||
- `npx -y @ruvnet/ruview@0.8.0` works;
|
||||
- the harness self-test resolves the published kernel;
|
||||
- a live ESPHome radar read succeeded.
|
||||
|
||||
**`ruview` was refused by npm:** `403 Forbidden — Package name too similar to
|
||||
existing package iview` (typosquat protection).
|
||||
- The umbrella is ready in `harness/ruview-umbrella`. Until npm support grants
|
||||
the name, it is unpublished.
|
||||
- If the request is declined, the fallback is a scoped name
|
||||
(`@ruvnet/ruview-platform`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- `npx ruview` is the single entry point for operators and agents. One MCP
|
||||
connector gives an agent, or ChatGPT, every RuView and Homecore tool.
|
||||
- The component packages stay independently installable and testable.
|
||||
`@ruvnet/ruview` remains free of runtime dependencies.
|
||||
- The umbrella pins exact component versions, so each component release needs
|
||||
an umbrella release to reach `npx ruview` users.
|
||||
- The local publish trades provenance for speed for this release only. The
|
||||
exception is recorded here and in the #swarm coordination thread.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **Umbrella tests:** 4/4, covering CLI dispatch to all three components,
|
||||
merged MCP tool listing and routing, the harness authority model through
|
||||
the merged server, and stdio and HTTP transports.
|
||||
- **Component gates:**
|
||||
- harness: 166 tests, 89 security tests;
|
||||
- Homecore: 27 tests, 23 security tests;
|
||||
- kernel: 21 tests, 3 skipped native cases; 12 security tests;
|
||||
- all manifests verified, 0 audit findings.
|
||||
- **Clean-directory tarball smoke:** recorded in the release commit and the
|
||||
#development post.
|
||||
@@ -0,0 +1,101 @@
|
||||
# ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv (requested integration into the npm package); RuView maintainers
|
||||
- **Tags**: claude-code, mods, function-hooks, ui, npm, least-authority
|
||||
- **Related**: ADR-373 (device access), ADR-375 (MCP Apps console, terminal UI),
|
||||
ADR-376 (umbrella package); Claude Code mods (function hooks): the
|
||||
claude-code issue 91870 and the `mods/` built-ins (`diff` is the closest
|
||||
analogue)
|
||||
|
||||
## Context
|
||||
|
||||
Claude Code mods are plugins whose behaviour is a function-hooks module: one
|
||||
`register(on, options)` that hooks engine events as `($, e, next)`. They can:
|
||||
- register slash commands;
|
||||
- open panes beside the transcript and draw them with the surface's elements;
|
||||
- set a status line;
|
||||
- run processes through `$.process.run`.
|
||||
|
||||
That fits RuView's goal of putting the live state of the sensors where an
|
||||
operator or agent already works.
|
||||
|
||||
RuView already has:
|
||||
- a read-only CLI with `--json` results (ADR-373, ADR-375);
|
||||
- an MCP server and the ChatGPT/MCP Apps console;
|
||||
- a plugin marketplace in this repository.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Ship a mod, `ruview-live`, inside `@ruvnet/ruview`** (`mod/`, packaged
|
||||
files `mod/.claude-plugin/`, `mod/hooks/`).
|
||||
- It is listed in the repository marketplace as `ruview-live@ruview`, with
|
||||
source `./harness/ruview/mod`.
|
||||
- One copy serves both installs: the npm package and the marketplace.
|
||||
2. **Behaviour.**
|
||||
- **`/ruview`** toggles a pane (`ruview-live`). While it is open, a
|
||||
`$.clock.every` timer (default 15 s, minimum 5 s) runs one read-only
|
||||
capture.
|
||||
- **`/ruview refresh`** runs one capture and returns the summary.
|
||||
- **`/ruview off`** closes the pane.
|
||||
- **The pane** shows nodes (rate, loss, RSSI, shape, SYNTHETIC flag), the
|
||||
optional ESPHome radar (presence, distance, device-reported vitals), and
|
||||
alerts, including heartbeat-only boards with the CSI-buffer-starvation
|
||||
hint. It has Refresh (`r`) and Close (`c`) buttons.
|
||||
- **The status line** is set on every refresh.
|
||||
3. **Data comes only from the harness CLI beside the mod.**
|
||||
- The mod runs `node <plugin root>/../bin/cli.js esp32 … --json` and
|
||||
`mmwave --source esphome … --json`, as fixed argv arrays, through
|
||||
`$.process.run`. So the pane shows what the tested tools return.
|
||||
- Mods may import nothing but their own files and `claude-code` (the
|
||||
validator refuses `node:url`). The CLI is therefore located through
|
||||
`$.plugin.root`.
|
||||
4. **Least authority.**
|
||||
- The mod never runs a write verb (flash, calibrate, train). A unit test
|
||||
pins the argv.
|
||||
- The radar host is validated before use.
|
||||
- Options are bounded: ports 1024–65535, refresh ≥ 5 s, capture 1–10 s.
|
||||
5. **Discovery.** `ruview mod` (and `ruview mod --json`) prints the installed
|
||||
path, the `claude --plugin-dir` command, the marketplace commands, usage,
|
||||
settings, and the early-access note.
|
||||
|
||||
## Evidence
|
||||
|
||||
All on Claude Code 2.1.286, Windows 11 x64.
|
||||
- **Validation:** `claude plugin validate harness/ruview/mod` passes. It lists
|
||||
hooks `session.start`, `command.run{ruview}`, `ui.render{Pane}`,
|
||||
`ui.close{ruview-live}` and `session.end`, and calls `$.clock.every`,
|
||||
`$.clock.now`, `$.command.register`, `$.process.run` and `$.ui.*`.
|
||||
`claude plugin validate .` passes for the marketplace.
|
||||
- **Engine tests:** `claude plugin test harness/ruview/mod`, with
|
||||
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, passed 2/2 in three consecutive
|
||||
runs. They check:
|
||||
- `/ruview` opens the pane and runs the exact read-only argv;
|
||||
- the status reads `RuView · 2 nodes · 1 alert`;
|
||||
- the timer refreshes while open and **never after close**;
|
||||
- an honest `no_packets` status.
|
||||
- **Harness unit tests:** `test/mod.test.mjs`, 6/6, under plain Node:
|
||||
settings bounds, host validation, argv, failure parsing, model/status, pane
|
||||
drawing and button wiring, and the no-imports rule.
|
||||
- **Real engine, headless (MEASURED):**
|
||||
`claude --plugin-dir ./mod -p "/ruview refresh"` answered
|
||||
`RuView · 0 nodes · 1 alert` (no CSI node was streaming). With `radarHost`
|
||||
set to the live Seeed MR60BHA2 kit, it answered
|
||||
`RuView · 0 nodes · radar clear · 1 alert`.
|
||||
- **Note:** under Git Bash a leading `/ruview` is rewritten into a Windows path
|
||||
unless `MSYS_NO_PATHCONV=1` is set. This is not an issue inside the Claude
|
||||
Code prompt.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An operator sees node and radar health beside the conversation without
|
||||
asking for it. Agents see the same facts through MCP.
|
||||
- **Mods are early access.** The API may change between Claude Code releases,
|
||||
and hooks modules load only where function hooks are enabled. The mod is
|
||||
therefore optional, and nothing else in the package depends on it.
|
||||
- **Trust.** Like every mod, it runs inside Claude Code with Claude Code's
|
||||
access. It is read-only by construction, and the README says to install it
|
||||
only from a trusted source.
|
||||
- A refresh binds UDP 5005 for the capture window. While the sensing server
|
||||
holds the port, the pane reports `port_in_use` instead of reading.
|
||||
@@ -0,0 +1,144 @@
|
||||
# ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv ("make this a showcase … the art of the possible for mods"); RuView maintainers
|
||||
- **Tags**: claude-code, mods, ui, raster, animation, csi, radar, honesty
|
||||
- **Related**: ADR-377 (the `ruview-live` mod), ADR-373 (device access),
|
||||
ADR-375 (terminal UI), ADR-018 / ADR-323 (CSI formats)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-377 shipped `/ruview`: a pane of cards built from text and sparklines. The
|
||||
mod engine can do much more on the terminal surface:
|
||||
|
||||
- `Raster` is a grid of cells, each with a glyph and a 24-bit foreground and
|
||||
background.
|
||||
- `$.ui.blit` repaints a mounted Raster in place, up to 60 frames a second,
|
||||
without redrawing the tree.
|
||||
- Buttons take hotkeys.
|
||||
|
||||
The pane only showed summaries. It never showed the signal itself: per-subcarrier
|
||||
CSI amplitude over time, or the radar's range as geometry.
|
||||
|
||||
## Decision
|
||||
|
||||
### Three views, keys `1` `2` `3`
|
||||
|
||||
| View | What it draws | Data |
|
||||
|---|---|---|
|
||||
| Overview | the ADR-377 cards | unchanged |
|
||||
| CSI waterfall | subcarrier bins across, time down; two frames per text row through `▀` (foreground = upper frame, background = lower); 5th–95th percentile colour limits with a colour bar | `ruview esp32 --spectrum` (new) |
|
||||
| Radar | top-down 120° fan: range rings, a fading trail of past distances, the current distance as an arc; braille line charts of heart, breathing and distance | ESPHome radar kit (ADR-373) |
|
||||
|
||||
`n` cycles nodes in the waterfall when more than one streams. The live views
|
||||
refresh every `liveRefreshSeconds` (default 4, range 2–60); the overview keeps
|
||||
`refreshSeconds`.
|
||||
|
||||
### Harness: `--spectrum`
|
||||
|
||||
`captureEsp32` and the `ruview_esp32_capture` tool take three new arguments:
|
||||
|
||||
- `spectrum`: boolean;
|
||||
- `spectrum_bins`: 8–128, default 48;
|
||||
- `spectrum_frames`: 8–256, default 64.
|
||||
|
||||
A `SpectrumCollector` keeps a ring of the newest frames for each node and CSI
|
||||
shape. It averages each frame's amplitudes into equal bins, and reports the
|
||||
dominant shape per node with its arrival rate and `synthetic` flag. The
|
||||
collector is read-only and bounded (8 nodes), and it adds nothing to a capture
|
||||
that does not ask for it.
|
||||
|
||||
### Animation with `$.ui.blit`
|
||||
|
||||
One 80 ms timer (about 12 fps) repaints only these keyed Rasters:
|
||||
|
||||
- **shimmer**: a highlight sweeping along the rule under the title. Pure
|
||||
decoration.
|
||||
- **pulse**: a ♥ that beats at the device-reported heart rate and a gauge that
|
||||
fills and empties at the device-reported breathing rate. It is labelled *a
|
||||
metronome, not a waveform*.
|
||||
- **fan**: a sonar ring that leaves the sensor and reaches the measured range.
|
||||
- **waterfall replay**: frames arrive a capture at a time, so the view drains
|
||||
them at their measured arrival rate instead of jumping every refresh. It is
|
||||
labelled *replayed at the arrival rate*.
|
||||
|
||||
The first paint and every frame come from the same function (`picturesOf`). A
|
||||
test asserts that each picture keeps its size across frames, because a blit of
|
||||
another size is refused.
|
||||
|
||||
Blits are fire-and-forget: a blit resolves only once a frame is painted, and
|
||||
the surface folds blits between frames anyway.
|
||||
|
||||
### Honesty rules (CLAUDE.md)
|
||||
|
||||
- The waterfall says **MEASURED — live CSI amplitude received on this host** or
|
||||
**SYNTHETIC — simulator frames**, from the packets' own flag.
|
||||
- The radar view states *range only: this kit reports distance, not bearing*.
|
||||
The arc covers every bearing at that range; nothing implies a position.
|
||||
- Vitals stay *device-reported, not validated*. Values outside 40–180 bpm
|
||||
(heart) or 4–40 bpm (breathing) are flagged.
|
||||
- Chart scales fit the data so a rhythm is visible. The plausible band is
|
||||
shaded only where it overlaps the scale.
|
||||
- Surfaces without `Raster` (desktop, VS Code, mobile) get a note in the
|
||||
showcase views instead of a tree the surface would refuse. The overview works
|
||||
everywhere.
|
||||
- Only nodes in the latest capture stay in the waterfall. A node that stops
|
||||
streaming, or a failed capture, clears its frames, so old frames are never
|
||||
shown as live. Frames join across captures only when the CSI shape and bin
|
||||
count both match.
|
||||
- Multi-antenna ESP32 nodes are drawn from their first chain. ADR-018 lays the
|
||||
tones out antenna-major; this applies to the waterfall only, and the vitals
|
||||
kernel still takes single-chain frames.
|
||||
- Animation runs on the real clock, the same one the full render reads. The
|
||||
pulses keep the reported rates even when a frame fires late.
|
||||
|
||||
### Structure
|
||||
|
||||
Mods may import only their own files, so the hooks module is split into:
|
||||
|
||||
- `model.mjs`: settings, argv, results, model and history (pure);
|
||||
- `raster.mjs`: Grid, base64, colour map, waterfall, braille, radar fan, line
|
||||
chart (pure);
|
||||
- `anim.mjs`: frame functions of time (pure);
|
||||
- `views.mjs`: drawing;
|
||||
- `register.mjs`: wiring, timers and blits. It re-exports the rest so tests
|
||||
import one module.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **MEASURED** (real silicon, 2026-10-01):
|
||||
- Hardware: an ESP32-C6 on COM11, flashed with the hardware-verified
|
||||
`release_bins/c6-adr110` image (v0.7.0; SHA-256 checked) and provisioned to
|
||||
ruv.net, node 6.
|
||||
- Boot log: `Got IP: 192.168.1.103` and `UDP sender initialized:
|
||||
192.168.1.45:5005`.
|
||||
- Captures: `ruview esp32 --spectrum` received 29–50 frames per 2 s at 7–18
|
||||
Hz and −37 to −78 dBm, in 1×64 and 1×256 shapes.
|
||||
- Waterfall: a 1×256 capture rendered as 114 bins showing the occupied band
|
||||
against guard and null tones.
|
||||
- Reproducer: `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json` with
|
||||
a node targeting the host.
|
||||
- **MEASURED** (device-reported): the Seeed MR60BHA2 kit at 192.168.1.102 drives
|
||||
the radar view.
|
||||
- **SYNTHETIC**: the `realtek-csi-sim` simulator exercises the waterfall in
|
||||
development; its frames carry the synthetic flag and the view says so.
|
||||
- **Tests**:
|
||||
- 11 mod tests and 11 showcase tests: Grid/base64 against Node's encoder,
|
||||
colour map monotone, waterfall ordering, braille bits, fan scaling and
|
||||
ping, chart scaling, frame functions, constant picture sizes, MEASURED and
|
||||
SYNTHETIC labels, surfaces without Raster;
|
||||
- a loopback test of `--spectrum`;
|
||||
- 4 engine tests (`claude plugin test mod`), including the waterfall tab
|
||||
sending `--spectrum`, drawing the Raster and animating it with blits.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The package grows by about 37 KB. The reviewed unpacked ceiling rises from
|
||||
384 KiB to 448 KiB in `npm-packages.yml` and `ruview-npm-release.yml`. There
|
||||
are still no runtime dependencies.
|
||||
- The live views poll more often: a 2 s capture every 4 s by default. A
|
||||
capture binds the UDP port briefly, so a sensing server on the same port
|
||||
still reports `port_in_use`, as in ADR-377.
|
||||
- The animation timer runs only while the pane is open, and stops on close and
|
||||
session end.
|
||||
@@ -0,0 +1,199 @@
|
||||
# ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back
|
||||
|
||||
**Status:** Proposed. Hardware-verified on 2026-09-29 on an ESP32-S3 (node 4) and an ESP32-C6 (node 5).
|
||||
**Date:** 2026-09-29
|
||||
**Numbering:** On 2026-10-02 `main` had up to ADR-378 and open PRs claimed
|
||||
nothing higher. If another branch also takes 379, renumber whichever merges
|
||||
second.
|
||||
|
||||
## Context
|
||||
|
||||
Every 16 MB node was flashed with `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`
|
||||
(`sdkconfig.defaults.16mb`). With that option the bootloader starts a freshly
|
||||
OTA'd image in `ESP_OTA_IMG_PENDING_VERIFY`. The app must then call
|
||||
`esp_ota_mark_app_valid_cancel_rollback()`. If it doesn't, the bootloader marks
|
||||
the image `ABORTED` at the next reset and boots the previous slot.
|
||||
|
||||
Nothing in `firmware/esp32-csi-node/main` made that call, and `nm` on the ELF
|
||||
confirmed the symbol was not linked. RUNBOOK.md said an
|
||||
`ota_rollback_boot_check()` "sits in the app", but no such function existed
|
||||
anywhere in the tree. The consequences:
|
||||
|
||||
- An image delivered by `POST /ota` ran until the next reset, then reverted
|
||||
silently. OTA updates were not durable.
|
||||
- While the image was pending, `esp_ota_begin()` refused a second OTA with
|
||||
`ESP_ERR_OTA_ROLLBACK_INVALID_STATE`.
|
||||
- Nothing checked health, so a bad image was never rolled back on purpose. It
|
||||
only reverted if it happened to crash or be power-cycled.
|
||||
|
||||
A second defect meant none of this was ever reached: **OTA never succeeded
|
||||
on this firmware before this change.** MEASURED on 2026-09-29, on node 4 (an
|
||||
ESP32-S3 running the v0.8.12 16 MB build, with the OTA key provisioned):
|
||||
|
||||
- A `POST /ota` of a valid 1,228,672 B image logged `OTA update started`, then
|
||||
`stack overflow in task httpd`, then `rst:0xc RTC_SW_CPU_RST`. The node came
|
||||
back on its old slot. Both attempts failed the same way.
|
||||
- The cause: the server used `HTTPD_DEFAULT_CONFIG()`, which gives a 4096 B
|
||||
task stack. The handler held a 1 KB receive buffer on that stack, while
|
||||
calling `esp_ota_*` and formatting a float progress log.
|
||||
|
||||
## Decision
|
||||
|
||||
At boot, `ota_health_start()` (in `main/ota_health.c`, called early in
|
||||
`app_main`) reads the running partition's state:
|
||||
|
||||
| state | meaning here | action |
|
||||
|---|---|---|
|
||||
| `PENDING_VERIFY` | just OTA'd, rollback armed | run the health check |
|
||||
| `VALID` | USB-flashed with `ota_data_initial.bin` (the bootloader writes VALID), or already confirmed | nothing |
|
||||
| `UNDEFINED` | selected by a build without rollback; boots unconditionally | nothing |
|
||||
| `NEW` | bootloader lacks rollback, so it never promoted the image | nothing |
|
||||
| error (`NOT_FOUND`/`NOT_SUPPORTED`) | no otadata entry, or factory partition | nothing |
|
||||
|
||||
For this table I read the ESP-IDF v5.5 source: `bootloader_utility.c`
|
||||
(`set_actual_ota_seq`, the NEW to PENDING_VERIFY to ABORTED transitions) and
|
||||
`esp_ota_ops.c`.
|
||||
|
||||
**The health check.** A low-priority task polls once a second and feeds
|
||||
`ota_health_step()`, which is a pure function in `ota_health.h`:
|
||||
|
||||
- **Signals, both latched:** the STA got an IP (`IP_EVENT_STA_GOT_IP`, via
|
||||
`ota_health_note_got_ip()` in `main.c`'s handler), and the stream sender
|
||||
accepted at least one CSI frame (`csi_collector_get_send_ok_count() > 0`).
|
||||
The second signal covers driver callback, serialization and `sendto()`
|
||||
together.
|
||||
- **Minimum uptime** `CONFIG_OTA_HEALTH_MIN_UPTIME_S` (default 30 s): the image
|
||||
is not confirmed before this point even with both signals present. An image
|
||||
that crashes soon after starting its pipelines still reverts, because a crash
|
||||
while pending is a rollback.
|
||||
- **Deadline** `CONFIG_OTA_HEALTH_TIMEOUT_S` (default 120 s, measured from
|
||||
boot). Both signals are required by then. The minimum uptime is clamped to
|
||||
the deadline.
|
||||
- **Pass:** `esp_ota_mark_app_valid_cancel_rollback()`, which logs
|
||||
`marked valid after <reason> in <ms> ms`.
|
||||
- **Fail:** logs `health check failed: <reason>, rolling back`, then calls
|
||||
`esp_ota_mark_app_invalid_rollback_and_reboot()`. If no other bootable slot
|
||||
exists, that call returns an error. The node then logs and stays up rather
|
||||
than reboot-looping.
|
||||
- **Terminal verdict:** once the check has confirmed or rolled back, it never
|
||||
changes.
|
||||
|
||||
**Negative-test hook.** `CONFIG_OTA_HEALTH_FORCE_FAIL` (default n, test only)
|
||||
makes the check roll back at the point where it would have confirmed. Use it to
|
||||
prove rollback end to end.
|
||||
|
||||
**Remote visibility.** `GET /ota/status` now includes `ota_state` (`valid`,
|
||||
`pending_verify`, `new`, `undefined`, `other`, or `none`). An operator can
|
||||
confirm durability without a serial console.
|
||||
|
||||
**The httpd stack.** The OTA/WASM HTTP server now sets `config.stack_size =
|
||||
CONFIG_OTA_HTTPD_STACK_SIZE`, which defaults to 8192. Three smaller changes go
|
||||
with it:
|
||||
|
||||
- The OTA receive buffer is static rather than on the stack. Handlers run one
|
||||
at a time on the single httpd task, so one buffer is enough.
|
||||
- The progress log prints an integer percent instead of a float.
|
||||
- `GET /wasm/list` keeps its 2 KB JSON buffer on the heap, not the stack.
|
||||
|
||||
`recv_wait_timeout` stays at 30 s. Each `POST /ota` and `POST /wasm/upload` now
|
||||
logs `httpd stack after <handler>: N of M bytes never used`, so the size can
|
||||
be re-tuned from device measurements.
|
||||
|
||||
Sizing: this is an estimate from static data, not a device measurement. It
|
||||
comes from a `-fstack-usage` build of the S3 16 MB configuration with IDF v5.5.
|
||||
|
||||
- **Frame sizes along the OTA path** (bytes):
|
||||
|
||||
| function | bytes |
|
||||
|---|---|
|
||||
| `httpd_thread` | 144 |
|
||||
| `httpd_sess_process` | 32 |
|
||||
| `httpd_req_new` | 176 |
|
||||
| `httpd_uri` | 64 |
|
||||
| `ota_upload_handler` | 192 (1,216 with the old 1 KB buffer) |
|
||||
| `esp_ota_end` | 48 |
|
||||
| `image_validate` | 304 |
|
||||
| `esp_image_verify` | 32 |
|
||||
| `image_load` | 80 |
|
||||
| `process_segments` | 112 |
|
||||
| SHA-256 | about 110 |
|
||||
| mmap/flash read | about 190 |
|
||||
|
||||
- That chain comes to about 1.5 KB now, and about 2.5 KB with the old buffer.
|
||||
- Two things are not in the static data:
|
||||
- libc's `vfprintf` behind every `ESP_LOG`. This build uses full newlib,
|
||||
not nano, and the old progress line formatted a float.
|
||||
- The flash-chip driver, which is reached through function pointers.
|
||||
|
||||
Together they plausibly add 1 to 1.5 KB. That explains an overflow at 4096
|
||||
with the old handler.
|
||||
- The WASM upload path adds `wasm_upload_process` (336) and
|
||||
`rvf_verify_signature` (352), plus a wasm3 module load that wasn't measured.
|
||||
- 8192 leaves roughly 4 to 5 KB above the estimated worst case, and costs
|
||||
4 KB of internal RAM. The high-water-mark log is the check on that estimate.
|
||||
|
||||
When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and
|
||||
4 MB C6 CI lanes), both entry points compile to no-ops.
|
||||
|
||||
## Consequences
|
||||
|
||||
- OTA updates become durable on rollback-enabled nodes once the image passes
|
||||
the check. A second OTA is accepted after confirmation, which can come as
|
||||
early as `MIN_UPTIME_S`.
|
||||
- A bad image now reverts instead of limping, but "bad" only means it failed
|
||||
to reach the network or deliver CSI. An image that passes and then
|
||||
misbehaves later is not caught.
|
||||
- If the AP is down when a node boots an OTA'd image, a good image rolls back.
|
||||
That is the safe direction: push it again later.
|
||||
- Mock/QEMU builds that skip Wi-Fi can never pass. They must not enable
|
||||
bootloader rollback, and none do today.
|
||||
- Bootloader capability is still invisible remotely (RUNBOOK §2). A node
|
||||
whose bootloader lacks rollback reports `ota_state: "new"` after an OTA. That
|
||||
is the first remote hint of which bootloader a board has.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Host test `firmware/esp32-csi-node/test/test_ota_health.c` runs in
|
||||
`make host_tests`, which CI runs. It covers pass, each timeout reason, the
|
||||
terminal verdict, force-fail, the clamp, and zero soak. SYNTHETIC: it
|
||||
exercises the decision function, not a device.
|
||||
- ESP-IDF v5.5 S3 and C6 builds with rollback enabled link
|
||||
`esp_ota_mark_app_valid_cancel_rollback` (verified with `nm`). A build is not
|
||||
hardware evidence.
|
||||
- The httpd overflow is MEASURED on hardware (node 4, above).
|
||||
- Hardware verification, MEASURED on 2026-09-29 on node 4 (ESP32-S3,
|
||||
16 MB, rollback bootloader, OTA key provisioned). The
|
||||
build from this branch was first written over USB. The pushes were made from
|
||||
a Pi 5 with the `sensor-ota-push` cog.
|
||||
1. Positive (MEASURED, S3). An image built from this branch was pushed to `ota_1`: HTTP 200,
|
||||
1,230,848 B in 22 s.
|
||||
- The node logged "httpd stack after POST /ota: 4900 of 8192 bytes never
|
||||
used", so the upload's peak stack use is about 3.3 KB. With the old 1 KB
|
||||
on-stack buffer that is over 4 KB, which is consistent with the
|
||||
overflow.
|
||||
- It rebooted and logged "OTA image pending verify on ota_1".
|
||||
- `/ota/status` then read `ota_state: valid`.
|
||||
- After a hard reset it came back on the same build in `ota_1`, still
|
||||
`valid`, so the update is durable.
|
||||
2. Negative (MEASURED, S3 forced-fail rollback). A `CONFIG_OTA_HEALTH_FORCE_FAIL=y` build was pushed to `ota_0`:
|
||||
HTTP 200.
|
||||
- `/ota/status` showed it running as `pending_verify` for about 30 s.
|
||||
- The node then rebooted and came back on the previous build in `ota_1`,
|
||||
`valid`, so rollback works.
|
||||
3. One push attempt failed mid-upload when a serial capture was opened on the
|
||||
node's USB-Serial-JTAG port at the same moment. The node reset and was
|
||||
unharmed. Don't hold the USB console open while pushing OTA to a
|
||||
USB-attached node.
|
||||
- C6 positive, MEASURED on 2026-09-29 on node 5 (ESP32-C6, 8 MB
|
||||
flash, 4 MB layout, rollback bootloader).
|
||||
- The branch build was first written over USB and the OTA key provisioned.
|
||||
- A second build was pushed from the Pi 5 cog to `ota_1`: HTTP 200 in 11 s.
|
||||
- `/ota/status` showed `pending_verify`, then `valid` about 28 s later.
|
||||
- After a hard reset it stayed on the new build, `valid`.
|
||||
- Not yet verified, so CLAIMED (no log supports these):
|
||||
- the 120 s no-IP timeout path (CLAIMED);
|
||||
- a true power cycle (CLAIMED; a hard reset via RTS was used instead);
|
||||
- the C6 negative/rollback test (CLAIMED; run on the S3 only).
|
||||
- A pass of this check means only that the node reached the network and sent
|
||||
CSI. It does not validate sensing quality, calibration, or anything else the
|
||||
image does.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR 380: Loopback UDP tee for second consumers of the CSI stream
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Unit tested, and smoke tested on loopback: a datagram sent to the
|
||||
server arrived byte for byte at the tee target. Not yet exercised against a
|
||||
live multi node fleet.
|
||||
|
||||
Date: 2026-09-28
|
||||
|
||||
Numbering: On 2026-10-02 `main` had up to ADR 378 and open PRs claimed nothing
|
||||
higher; ADR 379 is the OTA first-boot health check. If another branch also
|
||||
takes 380, renumber whichever merges second.
|
||||
|
||||
## Context
|
||||
|
||||
A five node deployment (four ESP32 S3, one ESP32 C6) streams ADR 018 frames to
|
||||
one sensing server. A second consumer cannot see the raw stream.
|
||||
|
||||
Each node sends to a single `target_ip` (`firmware/esp32-csi-node/main/stream_sender.c`).
|
||||
The server's WebSocket and REST outputs carry per node amplitude only: no
|
||||
phase, no I/Q, no sequence number, and no `0xC511A110` sync packets. A fusion
|
||||
host that aligns CSI with camera, radar, or lidar needs all of those. Binding a
|
||||
second listener to the CSI port is not an option, and an external relay in
|
||||
front of the port would make every source loopback, which the ADR 296
|
||||
allowlist always admits.
|
||||
|
||||
## Decision
|
||||
|
||||
`--udp-tee` (env `RUVIEW_UDP_TEE`) copies every datagram the source allowlist
|
||||
admits, byte for byte, to up to four `ip:port` targets.
|
||||
|
||||
- Targets must be loopback, so raw CSI never leaves the host through the tee.
|
||||
- A target may not use port 0 or the listener's own port, which would loop.
|
||||
- Sends are non blocking. A copy that cannot be queued is dropped and counted,
|
||||
so a slow consumer never stalls the receive loop.
|
||||
- The copy happens after the ADR 296 allowlist check and before any parsing,
|
||||
so consumers see exactly what the server admitted, in every wire format.
|
||||
- The tee is off unless configured.
|
||||
|
||||
## Consequences
|
||||
|
||||
A fusion host or Cognitum cog can subscribe to the full data plane by reading
|
||||
a loopback port. A consumer that today binds the CSI port itself can keep its
|
||||
code and move to the tee port instead.
|
||||
|
||||
The tee is byte transparent, so it needs no change when the ADR 018 header or
|
||||
a later wire version changes.
|
||||
|
||||
Each target costs one `send_to` per admitted datagram. Four targets is the
|
||||
limit.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo test -p wifi-densepose-sensing-server --no-default-features --lib udp_tee
|
||||
```
|
||||
@@ -0,0 +1,272 @@
|
||||
# ADR-381: Node self-survey from pairwise ranging (check mode first)
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-10-04
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: node-positions, ranging, ftm, uwb, multistatic, least-authority
|
||||
- **Related**: ADR-018 (ESP32 wire format), ADR-110 (ESP-NOW time sync),
|
||||
ADR-141 (privacy control plane), ADR-144 (UWB range-constraint fusion),
|
||||
ADR-281 (BLE Channel Sounding ranging evidence), ADR-308 (placement
|
||||
optimizer), rufield ADR-261 (BLE evidence and Channel Sounding adapter
|
||||
boundary, `vendor/rufield/docs/ADR-261-ble-evidence-and-channel-sounding.md`)
|
||||
- **Issues and PRs**: #1804, #1866, #2109 (show which nodes use the default
|
||||
position), #2133 (give the governed fuser the configured node positions)
|
||||
|
||||
## Context
|
||||
|
||||
Node positions reach the sensing server only through `--node-positions`. The
|
||||
operator types it by hand, keyed by node id (`id:x,y,z;...`) or by list index
|
||||
when the prefix is omitted. Three kinds of mistake pass silently today:
|
||||
|
||||
- **Wrong ids.** A config keyed by ids that do not match the live nodes looks
|
||||
applied and does nothing (#1804, #1866). #2109 makes the fallback to the
|
||||
default position visible, and #2133 makes sure both fusers receive the
|
||||
map. Neither can tell whether a correctly-keyed position is *right*.
|
||||
- **Wrong units.** Positions measured in feet and typed as metres are 3.28×
|
||||
too large. Nothing checks the scale.
|
||||
- **Stale positions.** A node moved after setup keeps its old coordinates.
|
||||
|
||||
Each one degrades multistatic fusion without an error. The repository has no
|
||||
node-to-node ranging and no anchor-free solver. ADR-144 models
|
||||
anchor-to-*tag* UWB ranges for people tracking
|
||||
(`wifi-densepose-mat/src/localization/range_constraint.rs`), not ranges
|
||||
between the fixed nodes themselves. ADR-281 computes BLE Channel Sounding
|
||||
ranging evidence, but there is no CS-capable radio in the fleet: rufield
|
||||
ADR-261 notes that the ESP32-S3 does not implement CS.
|
||||
|
||||
The ESP32-S3 and C6 nodes do support Wi-Fi Fine Timing Measurement (802.11mc
|
||||
RTT, station initiator ↔ SoftAP responder) in mainline ESP-IDF. So a ranging
|
||||
source exists on hardware we already have. The question is how good it is.
|
||||
|
||||
### What FTM measured
|
||||
|
||||
Bench, 2026-10-04 (UTC): two ESP32-S3 boards (rev v0.2), ESP-IDF v5.5
|
||||
`examples/wifi/ftm`, SoftAP responder on channel 1 at 20 MHz, 32 FTM frames
|
||||
per session, line of sight, tape ground truth. All rows MEASURED; the
|
||||
reproducer is in Appendix A.
|
||||
|
||||
| Tape (m) | Result |
|
||||
|---|---|
|
||||
| 0.00 | median 0.00 m |
|
||||
| 0.76 | median 0.00 m. Readings clamp to 0 below about 0.8 m. The first three sessions after a responder start read 1.8 to 3.0 m |
|
||||
| 1.14 | five responder starts: medians 0.60, 1.50, 2.25 and 3.08 m, plus a 7.7 m run at −68 dBm with a 1.92 m session spread (placement outlier) |
|
||||
| 3.71 | median 7.80 m (one start) |
|
||||
|
||||
- **Precision within one responder start:** ±0.2 to 0.4 m (standard
|
||||
deviation 0.19 to 0.31 m). A 40-session run over about two minutes showed
|
||||
no drift beyond that.
|
||||
- **Bias between responder starts:** −0.5 to +1.9 m at 1.14 m, with the
|
||||
boards untouched. A plain-versus-auto-start responder A/B test showed the
|
||||
responder build is not the cause.
|
||||
- **Quantisation:** every distance is a multiple of 0.15 m (1 ns of RTT).
|
||||
- **Not tested:** 40 MHz, other channels, `ftm -R -o` offset calibration, a
|
||||
second pair, non-line-of-sight, CSI coexistence, raw t1..t4 timestamps.
|
||||
|
||||
FTM is precise inside one start and inaccurate across starts. It can catch
|
||||
mistakes worth metres. It cannot place a node to decimetres. UWB (Qorvo
|
||||
DW3000 family) is the expected accuracy path: about 0.1 m line of sight is
|
||||
CLAIMED by the vendor, and we have no measurement of our own yet.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. A source-agnostic `RangeObservation` record
|
||||
|
||||
```text
|
||||
RangeObservation {
|
||||
initiator: u8, // node_id, as in --node-positions
|
||||
responder: u8,
|
||||
range_m: f64, // session median
|
||||
sigma_m: f64, // session spread, 0 if unknown
|
||||
method: Ftm | Uwb | BleCs | Manual,
|
||||
samples: u16, // valid frames / exchanges
|
||||
rssi_dbm: Option<i16>,
|
||||
bandwidth_mhz: Option<u16>,
|
||||
at_us: u64, // host receive time
|
||||
responder_boot: Option<u32>, // boot counter or nonce of the responder
|
||||
calibrated: bool, // set only by the host calibration step
|
||||
}
|
||||
```
|
||||
|
||||
`responder_boot` is required for calibration. The FTM bias moved with every
|
||||
responder start, so an offset is only valid within the boot it was measured
|
||||
in.
|
||||
|
||||
On the wire, nodes report ranges either in a new ADR-018-style packet with
|
||||
its own magic, or as an extension of the existing sync packet. This ADR does
|
||||
not choose between them. The host-side record and solver come first. The
|
||||
wire format follows the first firmware that emits ranges.
|
||||
|
||||
### 2. Host-side check mode (warn-only)
|
||||
|
||||
Given the configured positions and a batch of observations, check mode
|
||||
reports:
|
||||
|
||||
- **Per-link residuals.** `measured − ‖p_a − p_b‖`, with a tolerance of
|
||||
`floor + k·σ`. σ is the larger of the median session sigma and the spread
|
||||
across sessions. Each link is `ok`, `outlier`, `too_short` (configured
|
||||
length below the source's minimum), `weak` (every session below the RSSI
|
||||
gate), or `noisy` (σ above the source's maximum).
|
||||
- **Unit mismatch.** A least-squares scale `s` in `measured ≈ s·expected`.
|
||||
If `s` sits within a band of 0.3048 (feet), 0.0254 (inches), 0.01 (cm) or
|
||||
0.001 (mm), and rescaling removes outliers, the check reports the unit.
|
||||
- **Id swap.** For up to 8 ranged nodes, it scores every assignment of the
|
||||
configured positions to ids. The cost is a truncated quadratic in residual
|
||||
over tolerance, so one bad link cannot dominate. If another assignment
|
||||
cuts the cost to at most half and the configured cost is material, the
|
||||
check suggests that assignment, reported as `(node, position_of)` pairs.
|
||||
- **Moved node.** If most of a node's links are outliers (at least 2, and a
|
||||
strict majority), the node was probably moved. Outlier links left over are
|
||||
reported one at a time as possible bias, a body or wall in the path, or a
|
||||
bad session, and not as a configuration error.
|
||||
- **Unknown and unranged nodes.** Observations naming unconfigured ids, and
|
||||
configured nodes that no scored link reaches.
|
||||
|
||||
Distances do not change under rotation, reflection or translation, so check
|
||||
mode compares distances and never coordinates. Where the layout is
|
||||
symmetric, it says so instead of guessing:
|
||||
|
||||
- A relabelling that is a symmetry of the layout is **invisible**. For
|
||||
example, ids rotated 180° in a rectangle of corner nodes. Check mode
|
||||
reports such a layout as consistent.
|
||||
- When several assignments fit equally well, check mode prefers the one that
|
||||
changes the fewest ids, since a typo moves few. It reports how many
|
||||
assignments tied. If the fewest-changes assignments are themselves tied,
|
||||
as with a swap along one wall of a rectangle (its mirror image is the
|
||||
opposite wall's swap), it reports `id_swap_ambiguous` and names nothing.
|
||||
|
||||
Corner-mounted nodes in a rectangular room are the common case and are
|
||||
nearly symmetric. Operators should expect ambiguity reports there, and a
|
||||
fifth node off the axes removes most of them.
|
||||
|
||||
**Authority.** Check mode never writes positions, never edits config, and
|
||||
never changes fusion. Its output is a report for the startup log and a
|
||||
read-only status field. The report shape is JSON-serialisable; the field
|
||||
name follows #2109's `node_positions` block once that lands.
|
||||
|
||||
**Tolerances come from measured data:**
|
||||
|
||||
| Source | `floor_m` | `calibrated_floor_m` | `max_sigma_m` | `min_expected_m` | Basis |
|
||||
|---|---|---|---|---|---|
|
||||
| FTM, ESP32-S3, 20 MHz | 2.0 | 0.6 | 1.0 | 1.0 | MEASURED, bench above: bias −0.5 to +1.9 m between starts; spread ≤ 0.31 m within a start (the 1.92 m outlier excepted); clamp below about 0.8 m |
|
||||
| UWB | 0.3 | 0.3 | 0.3 | 0.0 | ESTIMATE until our own UWB bench. Vendor accuracy is CLAIMED |
|
||||
|
||||
With `floor_m = 2.0`, the one 3.71 m start (+4.1 m) would be flagged as a
|
||||
single-link outlier. That is uncalibrated FTM as it stands. Single-link
|
||||
outliers stay low-severity on purpose, and the strong findings (unit, swap,
|
||||
moved node) need several links to agree.
|
||||
|
||||
### 3. Per-boot calibration
|
||||
|
||||
An offset is recorded per `(initiator, responder, responder_boot)` from
|
||||
sessions at a known reference distance of at least 1.0 m:
|
||||
`offset = median(range) − reference`. It applies only to observations with
|
||||
the same key. After a responder reboot, observations pass through
|
||||
uncalibrated and get the wider floor.
|
||||
|
||||
Zero-separation calibration (ESP-IDF's `cm0` / `ftm -R -o`) is not used,
|
||||
because short ranges clamp to 0 on this hardware. In practice, the reference
|
||||
is one tape-measured link per responder per boot, or a UWB link once UWB
|
||||
exists. That the bias is stable over a whole boot, and not only for two
|
||||
minutes, is untested (ESTIMATE). Measuring it is the first FTM follow-up.
|
||||
|
||||
### 4. Fill mode, gated off
|
||||
|
||||
Fill mode (solve positions with classical-MDS initialisation, weighted
|
||||
robust SMACOF and gauge fixing, then write a file the operator passes to
|
||||
`--node-positions`) is **not implemented** and stays disabled for a source
|
||||
until all of these hold:
|
||||
|
||||
- the source has **MEASURED** link-error evidence with a reproducer;
|
||||
- the evidence covers at least 6 ground-truth links;
|
||||
- the p90 link error is **≤ 0.3 m**.
|
||||
|
||||
FTM's measured errors are metres, so it fails this gate. UWB is expected to
|
||||
pass it, but that is unproven. Even when the gate opens, fill mode writes a
|
||||
proposal file. The server never rewrites its own positions.
|
||||
|
||||
### 5. Pluggable sources
|
||||
|
||||
Every source produces `RangeObservation`, and check mode does not care which
|
||||
one did. Only the tolerance profile changes.
|
||||
|
||||
| Source | Status | How it plugs in |
|
||||
|---|---|---|
|
||||
| **FTM** (ESP32-S3/C6) | Now, for check mode | APSTA node runs a short scheduled survey window. One node is the SoftAP responder and the others initiate a directed session. `rtt_ns`, RSSI and session spread map to `range_m`, `rssi_dbm`, `sigma_m`. Airtime cost on the CSI channel is unmeasured |
|
||||
| **UWB** (DW3000 family, e.g. DWM3001CDK over USB) | After its own bench | Two-way ranging between nodes. Shares hardware with ADR-144's anchor-tag model, but node↔node ranges feed this ADR and anchor↔tag ranges keep feeding ADR-144 |
|
||||
| **BLE CS** | When a CS-capable radio exists | Through the rufield ADR-261 adapter boundary, using ADR-281's cross-validated phase/RTT ranging. A `Divergent` anomaly drops the observation. Single-mechanism evidence gets the wider floor |
|
||||
| **Manual** | Now | A taped distance entered by the operator. Mainly useful as the per-boot calibration reference |
|
||||
|
||||
### 6. Prototype
|
||||
|
||||
`v2/crates/ruview-survey` implements sections 1 to 4 on the host, with no
|
||||
I/O, no clock and no randomness, and bounded inputs: 64 nodes, 10 000
|
||||
observations, 100 m range, and the swap search capped at 8 nodes.
|
||||
Integration with the sensing server is a follow-up and depends on #2109 for
|
||||
where the report surfaces. The crate's tests are SYNTHETIC: generated layouts
|
||||
with FTM-like bias and noise from the envelope above. They cover consistent
|
||||
configs, feet and centimetre mistakes, a two-id swap, a three-id rotation,
|
||||
symmetric invisibility and ambiguity, a moved node, a bench-style single-link
|
||||
outlier, a noisy link, per-boot calibration, and the fill gate.
|
||||
`examples/check_ranges.rs` runs check mode on a JSONL file.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An operator gets a warning when ranges and `--node-positions` disagree
|
||||
grossly, with no new authority: check mode is read-only.
|
||||
- FTM on nodes needs APSTA and a SoftAP beacon on the sensing channel. Its
|
||||
cost to CSI yield must be measured before the firmware side ships.
|
||||
- The FTM tolerances rest on one evening with one pair. They are parameters,
|
||||
not constants, and must be revised from more data (40 MHz, more pairs,
|
||||
non-line-of-sight).
|
||||
- Ranges between fixed nodes are not person data. Ranges to phones or tags
|
||||
are, and remain under ADR-141 and ADR-144.
|
||||
- A symmetric layout can hide a relabelling entirely. Check mode documents
|
||||
this and does not claim to catch it.
|
||||
|
||||
## Evidence rule
|
||||
|
||||
No self-survey accuracy claim without a MEASURED reproducer: tape or laser
|
||||
ground truth, at least 4 nodes, line of sight plus at least one
|
||||
non-line-of-sight case, every number tagged. The prototype's tests are
|
||||
SYNTHETIC and show logic, not radio performance.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Treat FTM as accurate enough to fill positions.** Rejected: the measured
|
||||
bias between starts (−0.5 to +1.9 m at 1.14 m) and the +4.1 m at 3.71 m
|
||||
rule it out.
|
||||
- **Drop FTM.** Rejected for now. It costs nothing on existing hardware, it
|
||||
catches metre-scale mistakes, and it may suit coarse gating, calibrated
|
||||
pairs within a boot, and time sync. It is parked and revisited after UWB.
|
||||
- **Zero-separation `cm0` calibration.** Rejected: readings clamp to 0 below
|
||||
about 0.8 m.
|
||||
- **Server auto-corrects positions.** Rejected under least authority. Fill
|
||||
mode, when enabled, writes a proposal file only.
|
||||
- **LoRa SX1280 ranging, router (MT7981) FTM, raw I/Q phase ranging,
|
||||
CSI-amplitude ranging.** Rejected or deferred: wrong distance regime, no
|
||||
driver support, unproven, or too inaccurate respectively.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does one calibration offset hold for a whole responder boot? For both
|
||||
roles? For other initiators against the same responder?
|
||||
- Is the post-start transient (1.8 to 3.0 m in the first three sessions at
|
||||
0.76 m) real, and how long does it last?
|
||||
- Does 40 MHz shrink the bias between starts?
|
||||
- What does an FTM survey window cost in CSI frames on the same channel?
|
||||
- Can FTM t1..t4 timestamps be tied to the CSI timestamp clock for sync?
|
||||
- The wire format: new magic, or an extension of the sync packet?
|
||||
|
||||
## Appendix A: FTM bench reproducer
|
||||
|
||||
1. Flash ESP-IDF v5.5 `examples/wifi/ftm` on two ESP32-S3 boards
|
||||
(`CONFIG_ESP_WIFI_FTM_ENABLE=y`, the example's default).
|
||||
2. Responder console: `ap <ssid> <password>` (channel 1, 20 MHz defaults).
|
||||
Use throwaway credentials and do not commit them.
|
||||
3. Initiator console, at a taped separation: `ftm -I -s <ssid>`, 32 frames,
|
||||
10 to 40 sessions per distance. Record `Avg raw RTT`, `Avg RSSI`,
|
||||
`Estimated Distance` and the valid-frame count for each session.
|
||||
4. Reset the responder and repeat at the same distance to see the bias move
|
||||
between starts.
|
||||
5. Report the median, spread and error per run, tagged MEASURED, with the
|
||||
raw per-session data alongside.
|
||||
@@ -0,0 +1,137 @@
|
||||
# ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-03
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: rf, gaussian, spatial, evidence, wire-format, interop
|
||||
- **Related**: ADR-273 (unified RF world model), ADR-275 (RF-aware Gaussian
|
||||
memory), ADR-142 (temporal voxel evidence). Origin of the wire format:
|
||||
WeftOS ADR-107 §7 ("Spatial evidence engine and workspace"), amended
|
||||
2026-10-03.
|
||||
- **Crate**: `v2/crates/ruview-spatial-evidence`
|
||||
|
||||
Numbering: on 2026-10-03 `main` had up to ADR-378, open PRs claimed 379
|
||||
and 380, and 381 was taken by another branch.
|
||||
|
||||
## Context
|
||||
|
||||
`ruview-unified` keeps a map of RF Gaussians (ADR-275) and a Friis channel
|
||||
model that turns a TX/RX link into an expected amplitude. A spatial evidence
|
||||
engine fuses many sensor types (tape measurements, radar, ToF, UWB, RF) into
|
||||
an occupancy octree and promotes stable geometry (walls, furniture) into room
|
||||
models. That engine reads a versioned JSONL format, `spatial.evidence.v1`,
|
||||
specified in WeftOS ADR-107 §7.
|
||||
|
||||
RuView should be able to feed it without depending on it, and without a
|
||||
reader of this repository having to go to another project to learn what the
|
||||
export writes. This ADR records the part of the contract RuView emits.
|
||||
|
||||
## Decision
|
||||
|
||||
1. A leaf crate, `ruview-spatial-evidence`, converts `ruview-unified` output
|
||||
to `spatial.evidence.v1` lines. Its only internal dependency is
|
||||
`ruview-unified`. The wire shapes are local serde structs; the contract is
|
||||
the JSON, not a shared crate.
|
||||
2. It emits two record types, `rf_gaussian` and `rf_link_observation`. It
|
||||
does not mirror the other v1 types and rejects them as unknown when
|
||||
parsing.
|
||||
3. Every record is validated against the v1 rules below before it is
|
||||
serialised, so a bad value fails in RuView rather than in the consumer.
|
||||
4. If WeftOS ADR-107 §7 changes the envelope or either RF body, this ADR is
|
||||
amended in the same change that updates the crate.
|
||||
|
||||
### Envelope (every record)
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `schema` | exactly `spatial.evidence.v1` |
|
||||
| `type` | `rf_gaussian` or `rf_link_observation` here |
|
||||
| `t_ns` | u64, ns since the Unix epoch |
|
||||
| `frame` | `room_enu` |
|
||||
| `region` | region id, e.g. `region/urth/meso/<room>` |
|
||||
| `source_id` | device or operator id, never a person |
|
||||
| `uncertainty_m` | 1σ position uncertainty, metres, in (0, 100] |
|
||||
| `provenance` | `{receipt, producer, proof}`; `proof` is `MEASURED`, `CODE` or `SYNTHETIC` |
|
||||
|
||||
Ids (`region`, `source_id`, `receipt`, `producer`) match
|
||||
`[A-Za-z0-9._:/-@]{1,128}`. A line is at most 16 KiB.
|
||||
|
||||
**Frame.** `room_enu` is room-local, origin at the room's SW floor corner,
|
||||
x east, y north, z up, metres. Coordinates are within ±1000 m.
|
||||
|
||||
**Angles.** Every yaw in v1 is degrees counter-clockwise from room +x
|
||||
(east): 0 faces +x, 90 faces +y. The two RF types have no yaw field. The
|
||||
`rf_gaussian` orientation is a body-to-room quaternion, and a positive
|
||||
rotation about +z turns the Gaussian's local x axis counter-clockwise from
|
||||
east, the same sense.
|
||||
|
||||
### `rf_gaussian`
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `position` | [x, y, z] m |
|
||||
| `scale` | per-axis σ, m, each in [1e-6, 1e4] |
|
||||
| `orientation` | quaternion `[w, x, y, z]`, finite, norm > 1e-6 |
|
||||
| `occupancy` | peak extinction, nepers/m, in [0, 1e6] |
|
||||
| `confidence` | [0, 1] |
|
||||
| `motion` | `static`, `slow` or `fast` |
|
||||
| `role`? | `absorber` or `reflector`; set only on priors sent back to RuView |
|
||||
|
||||
The exporter maps `RfGaussian` field for field. `uncertainty_m` is the
|
||||
geometric mean of the three σ, clamped to the envelope range, because
|
||||
`RfGaussian` has no separate position covariance. The receipt is
|
||||
`gauss:<device>:<t_ns>:<index>`. A Gaussian whose provenance is `synthetic`
|
||||
is always tagged `SYNTHETIC`; a caller can lower the proof tag, never raise
|
||||
it.
|
||||
|
||||
### `rf_link_observation`
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `tx`, `rx` | antenna positions, m |
|
||||
| `freq_hz` | [1e8, 3e11] |
|
||||
| `excess_loss_db` | `20·log10(free / measured)`, in [−60, 200]; negative is constructive multipath |
|
||||
|
||||
The free-space reference comes from `ruview-unified`'s channel model with an
|
||||
empty map, so it is the same Friis amplitude the ADR-275 inverse update uses.
|
||||
|
||||
### Examples
|
||||
|
||||
These two lines are the WeftOS ADR-107 §7 examples. The crate's tests parse
|
||||
them and check that its own output has the same shape.
|
||||
|
||||
```jsonl
|
||||
{"schema":"spatial.evidence.v1","type":"rf_link_observation","t_ns":1759500001300000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"node-2","uncertainty_m":1.0,"provenance":{"receipt":"csi:node1-node2:000881","producer":"ruview-adapter@0.1","proof":"MEASURED"},"tx":[6.20,3.50,1.10],"rx":[0.20,0.20,1.10],"freq_hz":2437000000.0,"excess_loss_db":4.2}
|
||||
{"schema":"spatial.evidence.v1","type":"rf_gaussian","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.5,"provenance":{"receipt":"gauss:7f3a","producer":"ruview-unified@0.3","proof":"CODE"},"position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1,0,0,0],"occupancy":0.7,"confidence":0.8,"motion":"static","role":"absorber"}
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Additive optional fields may appear inside v1 and older readers ignore them.
|
||||
New record types may also be added inside v1 (the 2026-10-03 amendment added
|
||||
`tof_depth` and `radar_range`, and an optional `sensor` block on
|
||||
`radar_track_point`); a reader that does not know a type rejects it as
|
||||
unknown rather than misreading it. Removing, renaming or changing the
|
||||
meaning of a field requires `spatial.evidence.v2`, and the parser reports a
|
||||
v2 line as a version mismatch.
|
||||
|
||||
## Consequences
|
||||
|
||||
- RuView output can feed a spatial evidence engine with no new runtime
|
||||
dependency, and the contract is readable from this repository.
|
||||
- The engine can send promoted walls and objects back as `rf_gaussian`
|
||||
priors with a `role`. That gives ADR-275's inverse update a geometric
|
||||
starting point instead of an empty map.
|
||||
- The struct definitions are duplicated between the two projects. The
|
||||
golden-line tests catch drift in the RF types; a change on the WeftOS side
|
||||
still needs someone to amend this ADR and the crate.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Nothing calls the crate yet. The sensing server does not export records,
|
||||
and there is no adapter that turns an inbound `rf_gaussian` prior into an
|
||||
`RfGaussian` in the live map.
|
||||
- The tests are CODE-level (deterministic conversions and validation). No
|
||||
MEASURED claim is made about what the exported evidence does to a room
|
||||
model.
|
||||
- `uncertainty_m` for a Gaussian is a stand-in derived from its extent.
|
||||
+32
-1
@@ -2,6 +2,24 @@
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back](ADR-379-ota-first-boot-health-check.md)
|
||||
- [ADR-380: Loopback UDP tee for second consumers of the CSI stream](ADR-380-loopback-udp-tee.md)
|
||||
- [ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations](ADR-382-spatial-evidence-rf-export.md)
|
||||
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
|
||||
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
|
||||
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
|
||||
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
|
||||
- [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md)
|
||||
- [ADR-373: Host device access layer — ESP32, mmWave, LiDAR](ADR-373-host-device-access-layer.md)
|
||||
- [ADR-372: Structured debugging doctor](ADR-372-structured-debugging-doctor.md)
|
||||
- [ADR-371: Harness training runner and mean-pose evidence gate](ADR-371-harness-training-runner-and-evidence-gate.md)
|
||||
- [ADR-370: Cross-platform, verified ESP32 firmware flashing from npm](ADR-370-cross-platform-firmware-flashing.md)
|
||||
- [ADR-369: npm RuView operator surface — CLI, MCP, and SDK](ADR-369-ruview-npm-operator-surface-and-sdk.md)
|
||||
- [ADR-368: RuView compute kernel as a WASM + napi-rs npm package](ADR-368-ruview-kernel-wasm-napi-npm.md)
|
||||
- [ADR-367: Bounded research swarm and compute ownership](ADR-367-bounded-research-swarm-and-compute-ownership.md)
|
||||
- [ADR-366: CSI controls and frozen evaluation](ADR-366-csi-controls-and-frozen-evaluation.md)
|
||||
- [ADR-365: Public BFI dataset and decoder contract](ADR-365-public-bfi-dataset-and-decoder-contract.md)
|
||||
- [ADR-364: BFI capture admission and continuity](ADR-364-bfi-capture-admission-and-continuity.md)
|
||||
- [ADR-187: archive/v1 deprecation + model-weights honest labeling](ADR-187-archive-v1-deprecation-honest-labeling.md) (refs #509, #1125)
|
||||
- [ADR-186: Training progress API — wire the orphaned in-server trainer to /ws/train/progress](ADR-186-training-progress-api.md) (refs #1233)
|
||||
- [ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, MAT](ADR-185-python-p6-sota-bindings.md)
|
||||
@@ -9,7 +27,7 @@ Latest proposed decisions:
|
||||
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
|
||||
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
|
||||
|
||||
This folder contains 210 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 records significant architectural choices in the RuView / WiFi-DensePose project. The index tables below list a curated subset per domain; see the directory listing for the full set.
|
||||
|
||||
## Why ADRs?
|
||||
|
||||
@@ -59,6 +77,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-040](ADR-040-wasm-programmable-sensing.md) | WASM Programmable Sensing (Tier 3) | Accepted |
|
||||
| [ADR-041](ADR-041-wasm-module-collection.md) | WASM Module Collection (65 edge modules) | Accepted (hardware-validated) |
|
||||
| [ADR-044](ADR-044-provisioning-tool-enhancements.md) | Provisioning Tool Enhancements | Proposed |
|
||||
| [ADR-379](ADR-379-ota-first-boot-health-check.md) | OTA'd image confirms itself with a bounded health check (IP + CSI frame) or rolls back; OTA httpd stack sized from device data | Proposed (host-tested; hardware-verified on one S3 and one C6) |
|
||||
| [ADR-110](ADR-110-esp32-c6-firmware-extension.md) | ESP32-C6 firmware extension — Wi-Fi 6 / 802.15.4 / TWT / LP-core | Accepted, P1-P10 complete, firmware-side substrate closed at **[v0.7.0-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)**. Companion docs: [`WITNESS-LOG-110`](../WITNESS-LOG-110.md) (13 §A0.x entries · 99.56 % cross-board RX · **104.1 µs smoothed sync stdev** · ≤100 µs target met), [`ADR-110-REVIEW-GUIDE`](../ADR-110-REVIEW-GUIDE.md) (one-page reviewer tour), [`ADR-110-BRANCH-STATE`](../ADR-110-BRANCH-STATE.md) (coordination map vs `feat/adr-115-ha-mqtt-matter`). Host decoders + tests: Python `SyncPacketParser` (10) + Rust `wifi_densepose_hardware::SyncPacket` (15), cross-language hex pin gates drift. |
|
||||
|
||||
### Signal processing and sensing
|
||||
@@ -92,6 +111,13 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
|
||||
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
|
||||
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
|
||||
| [ADR-345](ADR-345-per-link-csi-and-node-to-node-ranging.md) | Per-link CSI attribution and node-to-node ranging — why phase-based position fails on this hardware (measured), and the coordination-free `(tx_mac, rx_seq)` join key that does not need it | Proposed — measurement layer implemented and validated on hardware; localization not built |
|
||||
| [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed |
|
||||
| [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed |
|
||||
| [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed |
|
||||
| [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted |
|
||||
| [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted |
|
||||
| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted |
|
||||
|
||||
### Platform and UI
|
||||
|
||||
@@ -105,6 +131,10 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-035](ADR-035-live-sensing-ui-accuracy.md) | Live Sensing UI Accuracy and Data Transparency | Accepted |
|
||||
| [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed |
|
||||
| [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted |
|
||||
| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) |
|
||||
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
|
||||
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
|
||||
| [ADR-363](ADR-363-native-macos-usb-node-onboarding.md) | Native macOS USB node onboarding with nonce bound discovery and fresh server verification | Accepted and physically qualified on one ESP32 S3; C6 and signed distribution pending |
|
||||
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
|
||||
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
|
||||
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
|
||||
@@ -155,6 +185,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-294](ADR-294-wifi-veil-integration.md) | WiFi Veil integration — emission-shaping countermeasure as advisory BFLD dependency | Accepted (initial implementation) |
|
||||
| [ADR-295](ADR-295-source-provenance-state-machine.md) | Source provenance state machine — synthetic can never present as live | Accepted (initial implementation) |
|
||||
| [ADR-296](ADR-296-sensor-data-plane-bind-hardening.md) | Sensor data-plane hardening — UDP bind control and source allowlist (step one) | Accepted (initial implementation) |
|
||||
| [ADR-380](ADR-380-loopback-udp-tee.md) | Loopback UDP tee for second consumers of the CSI stream (`--udp-tee`) | Proposed |
|
||||
| [ADR-297](ADR-297-multi-node-semantic-correctness.md) | Multi-node semantic correctness — per-node inference, node-keyed rate limiting, stale state | Accepted (initial implementation) |
|
||||
| [ADR-298](ADR-298-model-release-sanity-gates.md) | Model release sanity gates — block degenerate and mislabeled model artifacts | Accepted (initial implementation) |
|
||||
| [ADR-299](ADR-299-csi-data-incident-repo-controls.md) | Repository CSI data-incident controls — ignore rules and pre-commit/CI policy check | Accepted (controls implemented; tree remediation gated) |
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# RuForecast benchmark protocol
|
||||
|
||||
## Evidence status
|
||||
|
||||
No RuForecast runtime, accuracy, calibration, memory, or operational result is
|
||||
recorded here yet. Every threshold below is an ADR-348 target, not a measured
|
||||
claim. The first accepted row must identify a clean commit, the `Cargo.lock`
|
||||
digest, the exact Rust toolchain, host, backend, model/configuration digest,
|
||||
fixture or corpus digest, command, and evidence label.
|
||||
|
||||
## Benchmark boundaries
|
||||
|
||||
RuForecast uses two different forms of evidence:
|
||||
|
||||
1. Deterministic correctness evidence comes from Rust unit, property, replay,
|
||||
split-isolation, and artifact-tamper tests. A fixed input and artifact must
|
||||
produce the same output within its declared platform class.
|
||||
2. Runtime evidence comes from Criterion on a named host. Criterion inputs are
|
||||
generated from fixed checked-in code and seeds, but elapsed time is not
|
||||
deterministic. Timing from shared GitHub runners is informational only.
|
||||
|
||||
The benchmark implementation must not download a model or dataset, read raw
|
||||
CSI, use a hosted model output, or enable CUDA implicitly. CPU benchmarks use
|
||||
the explicit `cpu` feature and the Burn ndarray backend. CUDA validation belongs
|
||||
to a separately governed Linux or hosted-accelerator receipt.
|
||||
|
||||
## Required benchmark targets
|
||||
|
||||
| Package | Target | Purpose | CI authority |
|
||||
|---|---|---|---|
|
||||
| `ruview-forecast-model` | `forecast_inference` | Fixed-seed forward pass, batch and shape scaling, ordered-quantile output | Compile gate; shared-runner timing is informational |
|
||||
| `ruview-forecast-train` | `data_pipeline` | Fixed generated records through validation, windowing, masking and batching | Compile gate; shared-runner timing is informational |
|
||||
|
||||
Both targets must use code-generated synthetic inputs, fixed seeds, bounded
|
||||
allocations, `criterion::black_box`, and `required-features = ["cpu"]`. Setup,
|
||||
artifact construction, and dataset generation stay outside the timed region
|
||||
unless a benchmark name explicitly says they are included.
|
||||
|
||||
The model implementation owns structural parameter-count assertions. The
|
||||
currently reviewed design values are 35,700 parameters for the tiny CI preset
|
||||
and 20,285,108 for the large preset. These are design invariants, not benchmark
|
||||
results, and must be derived by a test from the actual module graph before they
|
||||
are quoted in a model card.
|
||||
|
||||
## Local Linux reproducer
|
||||
|
||||
Run from a clean checkout after installing Rust 1.92.0:
|
||||
|
||||
```bash
|
||||
RUFORECAST_CPUSET=0-7 \
|
||||
RUFORECAST_THREADS=8 \
|
||||
scripts/run-ruforecast-benchmarks.sh
|
||||
```
|
||||
|
||||
The runner executes the focused contract/model/training tests, one real
|
||||
optimizer step over a local hash-addressed synthetic JSONL shard, and the
|
||||
idempotent synthetic CLI smoke. It then compile-checks both Criterion targets,
|
||||
runs the targets, captures the CPU and toolchain metadata, and hashes every
|
||||
output. A failed run retains its partial logs with `status=FAILED` and its exit
|
||||
code rather than looking like a complete report. Results go under
|
||||
`target/ruforecast-evidence/`, which is excluded from source control.
|
||||
|
||||
For a conservative single-thread reproducibility check, omit both environment
|
||||
variables. To run against an uncommitted tree for diagnosis only, set
|
||||
`RUFORECAST_ALLOW_DIRTY=1`; the resulting metadata is labelled `SYNTHETIC`
|
||||
with scope `DIRTY_WORKTREE_DIAGNOSTIC_ONLY` and cannot support a release claim.
|
||||
|
||||
A clean run labels its host timing `MEASURED` and its input class `SYNTHETIC`,
|
||||
but remains `UNREVIEWED`. Only a maintainer may append it to the accepted ledger
|
||||
after checking the digests, shape, command, Criterion report and host scope.
|
||||
|
||||
The runner intentionally has no CUDA option and does not parse Criterion output
|
||||
into a pass/fail performance verdict. This prevents a noisy host result from
|
||||
silently acquiring release authority.
|
||||
|
||||
To compile the two benchmark targets without measuring them:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo +1.92.0 bench --locked -p ruview-forecast-model \
|
||||
--no-default-features --features cpu --bench forecast_inference --no-run
|
||||
cargo +1.92.0 bench --locked -p ruview-forecast-train \
|
||||
--no-default-features --features cpu --bench data_pipeline --no-run
|
||||
```
|
||||
|
||||
For a short informational run, use the same targets without `--no-run`:
|
||||
|
||||
```bash
|
||||
cargo +1.92.0 bench --locked -p ruview-forecast-model \
|
||||
--no-default-features --features cpu --bench forecast_inference -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10
|
||||
cargo +1.92.0 bench --locked -p ruview-forecast-train \
|
||||
--no-default-features --features cpu --bench data_pipeline -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10
|
||||
```
|
||||
|
||||
Running these commands does not add a ledger row automatically. Preserve the
|
||||
raw report and environment metadata, then have a maintainer assign its evidence
|
||||
scope before publishing a number.
|
||||
|
||||
The inference bench runs only `tiny_ci` by default so a routine CI trend step
|
||||
cannot accidentally start the very expensive large CPU probe. Set
|
||||
`RUFORECAST_BENCH_LARGE=1` only on a controlled host when intentionally
|
||||
measuring the fixed deployment shape:
|
||||
|
||||
```bash
|
||||
RUFORECAST_BENCH_LARGE=1 scripts/run-ruforecast-benchmarks.sh
|
||||
```
|
||||
|
||||
## Deployment measurement shape
|
||||
|
||||
The initial CPU deployment probe is batch 1, context 1,024, 32 declared feature
|
||||
streams, the fixed `large_linux` horizon of 300, and all seven declared
|
||||
quantiles. Record at least 20 warmup
|
||||
iterations and 200 measured iterations for a release candidate. Report p50,
|
||||
p95, p99 or maximum, throughput, and the process peak resident set size.
|
||||
|
||||
ADR-348 G5 currently targets p95 at or below one second for 32 declared streams
|
||||
and peak process memory at or below 4 GiB. A Criterion result alone cannot close
|
||||
the memory gate because Criterion and Cargo are not the production inference
|
||||
process. G5 remains open until a standalone inference probe reports its own peak
|
||||
resident set size.
|
||||
|
||||
## Accuracy and calibration protocol
|
||||
|
||||
Runtime speed never substitutes for forecasting quality. The frozen evaluation
|
||||
manifest must report identical examples for:
|
||||
|
||||
1. Last-value and seasonal-naive baselines.
|
||||
2. RuForecast without RuVector retrieval.
|
||||
3. RuForecast with split-scoped RuVector retrieval.
|
||||
|
||||
Required report fields include weighted quantile loss by horizon, nominal 80%
|
||||
interval coverage, missingness, abstention coverage, selective risk, site and
|
||||
device slices, interference regime, and retrieval ablation. ADR-348 G3 targets
|
||||
weighted quantile loss at least 10% better than seasonal naive and 80% interval
|
||||
coverage between 75% and 85%. Those targets remain unmeasured until a frozen,
|
||||
leakage-free report is attached.
|
||||
|
||||
### Informal HPO exploration note (unaccepted, not a release claim)
|
||||
|
||||
**2026-09-01.** An exploratory session ran the accuracy protocol above end to
|
||||
end against a governed 24-window **synthetic** dataset (`tiny_ci` profile,
|
||||
context 64 / horizon 12, temporal train/test split, not entity-holdout —
|
||||
only one synthetic generator was used, so entity holdout does not apply) and
|
||||
a small `OptimizerSpec` hyperparameter search (learning rate, weight decay,
|
||||
gradient clip norm, batch size, epochs) using a new Darwin Mode numeric-genome
|
||||
evolution engine (upstream: `ruvnet/metaharness` PR #260, not yet merged).
|
||||
This is **exploratory evidence only** — not a frozen, leakage-free,
|
||||
maintainer-reviewed report, and not eligible for the ledger below until one
|
||||
is produced.
|
||||
|
||||
Prior to this exploration, a **single real household window** (76 real
|
||||
1 Hz vital-signs samples, one physical ESP32 sensor, temporal not entity
|
||||
holdout) scored **worse than both baselines** (WQL 0.537 vs. last-value
|
||||
0.106 and seasonal-naive 0.123) — consistent with a single training window
|
||||
overfitting rather than generalizing.
|
||||
|
||||
With a larger (still synthetic, still `tiny_ci`) 24-window training set and
|
||||
three rounds of hyperparameter search, weighted quantile loss on the held-out
|
||||
synthetic split improved and stayed ahead of both baselines throughout:
|
||||
|
||||
| Round | learning_rate | weight_decay | grad_clip | batch | epochs | WQL (model) | WQL (last-value) | WQL (seasonal-naive) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| Default config | 0.0010000 | 1.00e-4 | 1.000 | 8 | 60 | 0.257 | 0.277 | 0.514 |
|
||||
| Search round 1 | 0.0002356 | 3.05e-6 | 0.100 | 27 | 195 | 0.161 | 0.277 | 0.514 |
|
||||
| Search round 2 | 0.0000298 | 4.09e-11 | 4.746 | 26 | 356 | **0.153** | 0.277 | 0.514 |
|
||||
|
||||
Round-2 gain over round 1 (−0.008) was much smaller than round-1's gain over
|
||||
the default (−0.096) — a diminishing-returns signal consistent with a local
|
||||
optimum for this model size and dataset, not a converged global result.
|
||||
`gradient_clip_norm` landed at opposite bound extremes across rounds
|
||||
(0.1 then 4.7), so no directional recommendation on that parameter should be
|
||||
drawn from this exploration alone.
|
||||
|
||||
**Explicit scope limits — do not generalize beyond these:**
|
||||
- `tiny_ci` only. Nothing here has been run against `large_linux`; its far
|
||||
larger parameter count and different compute profile mean these
|
||||
hyperparameters are not a starting point for it without their own search.
|
||||
- Synthetic dataset only (24 windows, one generator/seed family). Not
|
||||
validated against any real corpus at this scale.
|
||||
- Self-signed, evaluation-only model activation (a throwaway local Ed25519
|
||||
key, not a release signature) was used to run inference for scoring.
|
||||
- No security/provenance/maintainer-approval gate has passed — the Darwin
|
||||
Mode promotion rule correctly refused to promote any candidate here.
|
||||
|
||||
Reproducer: `harness/ruview/flywheel/ruforecast/` (genome, gate, evaluator,
|
||||
dry-run/`--confirm` driver) in the `ruvnet/RuView` repo, paired with
|
||||
`ruvnet/metaharness` PR #260 (`evolve-numeric`) linked locally via
|
||||
`npm link`. Neither the genome defaults here nor any repo default config
|
||||
were changed by this note — it is a record of exploratory evidence, not a
|
||||
committed recommendation.
|
||||
|
||||
### Amendment (2026-09-01, later same day): the round-2 result above did not generalize -- retracted
|
||||
|
||||
**The "Search round 2" row above (WQL 0.153, learning_rate=0.0000298 etc.) is
|
||||
RETRACTED as a claim of improvement.** It is kept in the table (append-only,
|
||||
never silently edit a prior measurement) but must be read together with this
|
||||
amendment: independent verification, run the same day using a new
|
||||
regression-candidate promotion path (`ruvnet/autogenous` PR-in-progress,
|
||||
branch `feat/regression-candidate-kind`, not yet merged/pushed --
|
||||
`v2/crates/ruforecast-autogenous-bridge` in this worktree) against TWO FRESH
|
||||
synthetic corpora that were never part of that search (seeds 1000/1097, vs.
|
||||
the search's single fixed seed 0), showed that "winner" genome performing
|
||||
**WORSE than the baseline on both**:
|
||||
|
||||
| Judge corpus | Candidate WQL | Baseline WQL | Candidate beats baseline by |
|
||||
|---|---:|---:|---:|
|
||||
| seed 1000 | 0.155 | **0.099** | -0.056 (worse) |
|
||||
| seed 1097 | 0.219 | **0.109** | -0.110 (worse) |
|
||||
|
||||
**Root cause**: every evaluation in the three search rounds above (default,
|
||||
round 1, round 2) trained and scored every candidate against the exact same
|
||||
fixed synthetic corpus (`prepare-synthetic-dataset`'s implicit `--seed 0`
|
||||
default). The search had learned to exploit that one corpus's specific
|
||||
random windows -- textbook overfitting -- not found a genuinely better
|
||||
hyperparameter configuration. This is a real, measured failure mode, not a
|
||||
hypothetical caveat.
|
||||
|
||||
**Fix applied**: `harness/ruview/flywheel/ruforecast/gate.mjs`'s
|
||||
`evaluateGenome` now trains and scores every candidate against THREE
|
||||
independent synthetic corpora (`DEFAULT_SEARCH_SEEDS = [11, 23, 47]`, none
|
||||
of which overlap the retracted search's seed 0 or the verification seeds
|
||||
1000/1097) and takes the WORST CASE `primary` across them, not an average --
|
||||
a candidate only counts as a win if it beats both baselines on every corpus.
|
||||
See `harness/ruview/flywheel/ruforecast/README.md`'s "Multi-seed fitness"
|
||||
section for the full account.
|
||||
|
||||
**Re-running the search with the fix, still no verified improvement.** A
|
||||
fresh search under the corrected multi-seed fitness (2 generations x 2
|
||||
children, cleared `.metaharness-numeric` archive so no stale pre-fix state
|
||||
leaked in) found a genuine winner ON ITS OWN THREE SEARCH SEEDS --
|
||||
`learning_rate=0.008012, weight_decay=0.000159, gradient_clip_norm=0.267,
|
||||
batch_size=24, epochs=20`, beating the baseline on all three (primary
|
||||
0.099 / 0.556 / 0.576, worst-case 0.099). Independent verification against
|
||||
the same fresh seeds 1000/1097 (never part of this candidate's own search)
|
||||
again showed it losing to the baseline on both:
|
||||
|
||||
| Judge corpus | Candidate WQL | Baseline WQL | Candidate beats baseline by |
|
||||
|---|---:|---:|---:|
|
||||
| seed 1000 | 0.178 | **0.099** | -0.079 (worse) |
|
||||
| seed 1097 | 0.314 | **0.109** | -0.205 (worse) |
|
||||
|
||||
**Honest conclusion**: two independent search rounds (pre- and post- the
|
||||
multi-seed fitness fix) both produced a genome that looked like a real
|
||||
improvement on its own search data and both failed independent out-of-sample
|
||||
verification. This converges on a different, deeper explanation than "the
|
||||
search methodology was broken" (that part IS fixed): at this dataset scale
|
||||
(24 synthetic training windows), held-out WQL varies enormously by which
|
||||
corpus is drawn REGARDLESS of hyperparameters -- the baseline genome itself,
|
||||
evaluated with the corrected multi-seed fitness function, swings from
|
||||
primary 0.83 (a strong win) to a full regression (primary 0, WQL worse than
|
||||
last-value) purely from which of the three fixed search seeds was used, with
|
||||
identical hyperparameters throughout. The corpus-noise floor at n=24
|
||||
windows appears to dominate any real hyperparameter effect. No RuForecast
|
||||
hyperparameter configuration has been shown, by this exploration, to
|
||||
reliably beat the trivial baselines out-of-sample at this scale. A larger
|
||||
training corpus (more windows, ideally real governed data under the
|
||||
`large_linux` profile) is the more promising next lever than further
|
||||
hyperparameter search on this fixture.
|
||||
|
||||
A related implementation-only fix: the promotion verifier
|
||||
(`envelope::regression::verify_regression_promotion` in the Autogenous
|
||||
branch above) initially required all judges' receipts to share one
|
||||
`corpus_id`, inherited unreviewed from a same-evidence review model that
|
||||
does not fit this kind's intentionally cross-corpus judge design. This was
|
||||
corrected (`ReceiptCorpusMismatch` is no longer produced by that function);
|
||||
it did not change either REJECT verdict above, both of which were already
|
||||
correctly driven by the real `NotBetterThanParent` signal.
|
||||
|
||||
Reproducer for both verification runs above: same as below, plus
|
||||
`v2/crates/ruforecast-autogenous-bridge` (`cargo +1.92.0 run --manifest-path
|
||||
crates/ruforecast-autogenous-bridge/Cargo.toml -- --ruforecast-bin
|
||||
./target/debug/ruforecast --candidate-genome <genome>.json --parent-genome
|
||||
<baseline>.json --judges 2 --work-dir <scratch>`) against the unpushed
|
||||
`ruvnet/autogenous` branch `feat/regression-candidate-kind`.
|
||||
|
||||
### Real-household-data result (2026-09-01, MEASURED, unaccepted, not a release claim)
|
||||
|
||||
Following the informal HPO exploration above, this session also collected
|
||||
**6,390 real 1 Hz vital-signs samples** (heart rate, breathing rate,
|
||||
signal quality) from a real, live, ESP32-sourced household sensing
|
||||
deployment over a continuous 2-hour window (88.75% real sample coverage;
|
||||
gaps handled honestly via `observed_mask=0`, never fabricated
|
||||
interpolation) — the "more real training data" lever flagged as the
|
||||
credible next step in the note above. This directly answers that open
|
||||
question.
|
||||
|
||||
Two genuinely independent temporal splits of the same real corpus (not
|
||||
synthetic seeds — real data has no seed to vary, so independence here
|
||||
means two different train/test boundary choices on the same timeline,
|
||||
each with its own 90s embargo gap) were trained (default, untuned
|
||||
`OptimizerSpec`: `lr=0.001, weight_decay=0.0001, gradient_clip_norm=1.0,
|
||||
batch_size=8, epochs=60`) and scored via the real `evaluate` CLI, then
|
||||
independently, cryptographically verified through
|
||||
`ruforecast-autogenous-bridge`'s real signed regression-candidate
|
||||
promotion path (`ruvnet/autogenous`, `envelope::regression`):
|
||||
|
||||
| Judge | Split | Real test windows | Model WQL | Best trivial baseline WQL | Model beats baseline by |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| 1 | 70% train / 90s embargo / 30% test | 27 | 0.0514 | 0.0563 (last-value) | +0.0049 |
|
||||
| 2 | 50% train / 90s embargo / 50% test | 46 | 0.0670 | 0.0543 (seasonal-naive) | −0.0128 |
|
||||
|
||||
**Signed verdict: REJECT.** Judge 1's nominal win (+0.0049) is below the
|
||||
0.01 non-inferiority margin, so it doesn't clear the promotion bar even
|
||||
on its own; Judge 2 lost outright. Both rejections are recorded as
|
||||
`NotBetterThanParent` in the signed promotion envelope.
|
||||
|
||||
**Honest conclusion:** even with a real household corpus (n=6,390 real
|
||||
samples, not synthetic), the result is exactly the same shape as every
|
||||
synthetic search this session — a result that looks like a win on one
|
||||
split does not hold up on an independently verified second split. This
|
||||
is not evidence that real data can't help; it is evidence that this
|
||||
scale of real data (6,390 samples, one household, one physical sensor)
|
||||
is not yet enough to distinguish a genuine effect from split-dependent
|
||||
noise. The credible next lever remains more real data — more households,
|
||||
longer collection windows, or the `large_linux` profile — not further
|
||||
hyperparameter search on any fixture this small, synthetic or real.
|
||||
|
||||
Reproducer: `v2/crates/ruforecast-autogenous-bridge/examples/real_data_verify.rs`
|
||||
and `v2/crates/ruforecast/crates/ruforecast-train/examples/real_data_windows.rs`
|
||||
in this worktree (`train/ruforecast-rust` branch, commits `130f547` in the
|
||||
`ruforecast` submodule and `788401c5` in this repo — both local, not yet
|
||||
pushed). Raw real vitals data never left the collecting/training hosts and
|
||||
was never written to any git-tracked or pushed path.
|
||||
|
||||
## Append-only evidence ledger
|
||||
|
||||
Never replace a prior measurement. Append a row and retain the failed or stale
|
||||
row when code, model, configuration, corpus, hardware, or methodology changes.
|
||||
|
||||
| Date | Commit | Lock SHA-256 | Host/toolchain | Backend/config | Shape | Samples | p50 | p95 | p99/max | Peak RSS | Evidence | Reproducer |
|
||||
|---|---|---|---|---|---|---:|---:|---:|---:|---:|---|---|
|
||||
|
||||
No rows have been accepted.
|
||||
|
||||
### Real public dataset: BIDMC PPG/Respiration (2026-09-02, cross-entity holdout)
|
||||
|
||||
Every real-data test up to this point used a single household/entity with only
|
||||
a **temporal** holdout (same physical sensor, different time windows). This
|
||||
test is the first with a genuine **cross-entity** holdout: 53 real ICU
|
||||
patients from the BIDMC PPG and Respiration Dataset (PhysioNet, Open Data
|
||||
Commons Attribution License v1.0, public and openly licensed — no
|
||||
credentialing, https://physionet.org/content/bidmc/1.0.0/), splitting by
|
||||
*patient*, not by time, so the held-out test set contains real people the
|
||||
model never saw during training.
|
||||
|
||||
25,546 real 1 Hz rows across 53 recordings (~8 minutes each), heart rate +
|
||||
respiratory rate + SpO2. 3 of 53 patients' windows were excluded honestly
|
||||
(genuine sensor-dropout `NaN` values in the source recordings, not
|
||||
fabricated/interpolated). Two independent, disjoint patient-partition splits:
|
||||
|
||||
| Judge | Train patients | Test patients | Test windows | Model WQL | Best baseline WQL | Result |
|
||||
|---|---:|---:|---:|---:|---:|---|
|
||||
| A (contiguous split) | 34 | 16 | 16 | 0.01964 | 0.01159 (last-value) | worse, +69% |
|
||||
| B (interleaved split) | 24 | 26 | 26 | 0.07022 | 0.01002 (last-value) | worse, +601% |
|
||||
|
||||
**Same conclusion as every prior test this session**, now on real, public,
|
||||
multi-subject clinical data with genuine cross-entity generalization: the
|
||||
model does not beat trivial forecasting baselines. The margin is decisive on
|
||||
both independent splits, not a near-miss — the earlier hypothesis that a
|
||||
larger, genuinely diverse real dataset (many different people, not one
|
||||
household) might change the picture does not hold at this scale/model
|
||||
configuration either.
|
||||
|
||||
**Honest scope note on verification**: prior real-data tests in this document
|
||||
were independently checked through Autogenous's signed regression-candidate
|
||||
promotion path. That additional cryptographic-signing step was **not** run
|
||||
for this test — the result is reported directly from the `evaluate` CLI's
|
||||
real output on two genuinely disjoint, real patient-holdout splits, which is
|
||||
itself real, independent, out-of-sample evidence, but it does not carry a
|
||||
signed promotion-gate verdict the way the earlier entries do. Flagging this
|
||||
explicitly rather than presenting it with the same evidentiary weight.
|
||||
|
||||
Reproducer: `v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare.rs`
|
||||
(untracked scratch example, worktree `train/ruforecast-rust`) — downloads
|
||||
`bidmc_NN_Numerics.csv` for patients 01-53 directly from PhysioNet, builds
|
||||
one 76-row (context 64 + horizon 12) window per eligible patient, and writes
|
||||
governed `train.jsonl`/`test.jsonl`/`train-local.toml` per judge split. Raw
|
||||
data cached at `/tmp/bidmc-raw/` on the training host only.
|
||||
|
||||
### Zero-shot pretrained-checkpoint comparison (2026-09-02)
|
||||
|
||||
Every RuForecast result above this line is a **from-scratch-trained** model
|
||||
(RuForecast's own Burn/Rust architecture, trained on a tiny real or synthetic
|
||||
corpus) evaluated against trivial baselines. All of them lost. A literature
|
||||
pass after the BIDMC result above (arXiv:2505.11349, "context parroting";
|
||||
arXiv:2607.20027, zero-shot HRV forecasting with TimesFM/Chronos/MOIRAI on
|
||||
wearables) converged on an explanation: published foundation-model wins over
|
||||
naive baselines on short-horizon, smooth physiological signals come from
|
||||
**massive real pretraining corpora** (Chronos: ~10M real+augmented series;
|
||||
TimesFM: ~100B real time points), not from architecture alone — and
|
||||
RuForecast's from-scratch training used a real corpus many orders of
|
||||
magnitude smaller (tens to low hundreds of windows), so re-implementing
|
||||
these architectures from scratch and training small was never going to
|
||||
replicate their wins.
|
||||
|
||||
This test checks the other half of that hypothesis directly: does a **real,
|
||||
public, pretrained checkpoint**, run **zero-shot** (no fine-tuning, no
|
||||
training of any kind on this repo's data) on the **exact same BIDMC
|
||||
cross-entity test windows and metrics** as the BIDMC result above, actually
|
||||
beat the naive baselines? If the massive-pretraining-corpus explanation is
|
||||
right, it should — unlike every from-scratch attempt this session.
|
||||
|
||||
**Setup (kept identical to the BIDMC test above for a fair comparison):**
|
||||
same `/tmp/bidmc-raw/` cache (53 real ICU patients, PhysioNet BIDMC, Open
|
||||
Data Commons Attribution License v1.0), same window construction (context
|
||||
64s + horizon 12s, 1 Hz, variates HR/RESP/SpO2, one window per patient from
|
||||
that patient's first 76 rows), same two disjoint patient-partition judges (A:
|
||||
train 1-37/test 38-53 contiguous; B: train odd/test even interleaved — train
|
||||
patients are irrelevant here since nothing is trained, only the *test*
|
||||
partition is used), same quantile set
|
||||
`[0.05,0.10,0.25,0.50,0.75,0.90,0.95]`, same seasonal period (12), and the
|
||||
same WQL formula (`2*sum(pinball)/(numQuantiles*sum|actual|)`) and MAE
|
||||
definition as `ruforecast-core::metrics.rs`. Baselines were independently
|
||||
re-derived from `ruforecast-core::baseline.rs`'s exact logic in Python and
|
||||
cross-checked against the BIDMC section above: reproduced last-value WQL was
|
||||
0.01171 (A) / 0.01013 (B) versus the Rust CLI's originally reported
|
||||
0.01159 / 0.01002 — a ~1% gap attributable to f32 (Rust) vs f64 (Python)
|
||||
accumulation, not a methodology difference, so this reproduction is treated
|
||||
as validated.
|
||||
|
||||
Four real, public, pretrained-checkpoint models were run zero-shot on GPU
|
||||
(RTX 5080, `ruvultra`), per-variate univariate (none of these checkpoints has
|
||||
a native multivariate mode for these two families, so HR/RESP/SpO2 were
|
||||
forecast independently and recombined into the same window-level WQL/MAE as
|
||||
the baselines and the from-scratch RuForecast model):
|
||||
|
||||
- **Chronos-Bolt base/small** (`amazon/chronos-bolt-base`,
|
||||
`amazon/chronos-bolt-small`) — direct quantile-regression heads,
|
||||
deterministic given input (no sampling).
|
||||
- **Chronos (T5) small** (`amazon/chronos-t5-small`) — the original
|
||||
sampling-based Chronos family; `predict_quantiles` internally draws
|
||||
samples (default `num_samples`), so this one model's numbers below carry
|
||||
run-to-run sampling variance the other three don't.
|
||||
- **TimesFM 2.5 (200M, PyTorch)** (`google/timesfm-2.5-200m-pytorch`) —
|
||||
quantile output uses 10 channels; per the installed package's source
|
||||
(`timesfm_2p5_base.py`, `decode_index: int = 5`) and empirical
|
||||
verification across multiple windows/variates, channel 5 is the point
|
||||
forecast and channels 1-9 are the native quantile levels [0.1..0.9] in
|
||||
order (channel 0 is an unused duplicate, not a real quantile). Levels 0.05
|
||||
and 0.95 were clamped to the nearest native level (0.10/0.90); 0.25 and
|
||||
0.75 were linearly interpolated between the adjacent native levels — the
|
||||
same class of approximation Chronos-Bolt's own pipeline applies
|
||||
internally when asked for quantile levels outside its trained range (a
|
||||
warning it printed for 0.05/0.95 on every call).
|
||||
|
||||
| Judge | Model | Test windows | MAE | WQL | WQL vs last-value |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| A | last-value (baseline) | 16 | 0.7882 | 0.01171 | — |
|
||||
| A | seasonal-naive (baseline) | 16 | 1.0885 | 0.01617 | worse |
|
||||
| A | Chronos-Bolt-base (zero-shot) | 16 | 0.7802 | 0.00756 | **-35.4%** |
|
||||
| A | Chronos-Bolt-small (zero-shot) | 16 | 0.7212 | 0.00684 | **-41.6%** |
|
||||
| A | Chronos-T5-small (zero-shot) | 16 | 0.9487 | 0.00955 | **-18.4%** |
|
||||
| A | TimesFM-2.5-200M (zero-shot) | 16 | 0.7490 | 0.00731 | **-37.6%** |
|
||||
| B | last-value (baseline) | 26 | 0.6944 | 0.01013 | — |
|
||||
| B | seasonal-naive (baseline) | 26 | 0.8697 | 0.01269 | worse |
|
||||
| B | Chronos-Bolt-base (zero-shot) | 26 | 0.7096 | 0.00719 | **-29.0%** |
|
||||
| B | Chronos-Bolt-small (zero-shot) | 26 | 0.6852 | 0.00676 | **-33.3%** |
|
||||
| B | Chronos-T5-small (zero-shot) | 26 | 0.8254 | 0.00886 | **-12.5%** |
|
||||
| B | TimesFM-2.5-200M (zero-shot) | 26 | 0.6511 | 0.00698 | **-31.1%** |
|
||||
|
||||
**Honest result: this is the first test all session where a model beats the
|
||||
trivial baselines, and it does so decisively and consistently** — all four
|
||||
pretrained checkpoints beat both last-value and seasonal-naive on WQL, on
|
||||
both independently-partitioned judges, by double-digit-to-40% margins. This
|
||||
is a materially larger and more consistent margin than the closest published
|
||||
direct analog found in the literature pass (zero-shot HRV forecasting,
|
||||
arXiv:2607.20027: Chronos beat EWMA by ~3%, MOIRAI showed no significant
|
||||
advantage over a mean baseline, p=0.953) — plausibly because BIDMC's 1 Hz
|
||||
ICU HR/RESP/SpO2 signals are smoother and more autocorrelated than the HRV
|
||||
series in that paper, which is itself a fair, disclosed difference between
|
||||
the two comparisons rather than a claim of a stronger general result.
|
||||
|
||||
On MAE (point accuracy) the picture is more mixed: Chronos-Bolt-small and
|
||||
TimesFM beat last-value's MAE on both judges, but Chronos-Bolt-base and
|
||||
Chronos-T5-small do not consistently (e.g. Chronos-Bolt-base's MAE is
|
||||
essentially tied with last-value on judge A, worse on judge B, despite a
|
||||
much better WQL there) — the pretrained models' advantage is concentrated in
|
||||
calibrated quantile spread (what WQL scores), not in shifting the central
|
||||
point forecast much past what last-value already gets from strong
|
||||
autocorrelation in these signals.
|
||||
|
||||
**This directly confirms the massive-pretraining-corpus hypothesis from the
|
||||
literature pass**: the same short-horizon, smooth physiological-signal task
|
||||
on which every from-scratch RuForecast configuration this session lost to
|
||||
naive baselines is won, cleanly, by checkpoints that carry a pretraining
|
||||
corpus many orders of magnitude larger than any real dataset available to
|
||||
this project. This is evidence *for* zero-shot/foundation-model inference
|
||||
(or later, fine-tuning a pretrained checkpoint) as the credible next lever
|
||||
for RuForecast-style short-horizon vitals forecasting — not for continuing
|
||||
to scale from-scratch training on this project's own small real corpora,
|
||||
which the BIDMC and household tests above already showed does not close
|
||||
this gap even with genuine, cross-entity real data.
|
||||
|
||||
**Scope and caveats, stated plainly:**
|
||||
- This is zero-shot inference only — no fine-tuning was attempted on any
|
||||
checkpoint. A fine-tuned comparison (e.g. LoRA-adapting Chronos-Bolt on
|
||||
RuForecast's own training windows) is a natural, not-yet-run follow-up.
|
||||
- Univariate-per-variate forecasting was used for all four models because
|
||||
neither Chronos nor TimesFM in these releases has a native multivariate
|
||||
mode matching RuForecast's joint HR/RESP/SpO2 modeling; a true
|
||||
multivariate foundation model (e.g. MOIRAI, which does support cross-variate
|
||||
attention) was not tested here and is a natural next step.
|
||||
- Chronos-T5-small's `predict_quantiles` samples internally (unlike
|
||||
Chronos-Bolt's direct quantile heads or TimesFM's quantile head); its
|
||||
numbers above are one run and carry unquantified sampling variance the
|
||||
other three models' numbers don't.
|
||||
- No inference-cost/latency comparison against RuForecast's own (much
|
||||
smaller, ~tens-of-KB) model is included; all four pretrained checkpoints
|
||||
are orders of magnitude larger (small-to-200M+ parameters) and ran on a
|
||||
discrete GPU (RTX 5080), not the ESP32-class edge target RuForecast's own
|
||||
architecture is designed for. A model that wins on WQL here is not
|
||||
automatically deployable to the same edge budget RuForecast targets — that
|
||||
tradeoff is unresolved by this test and is itself a reason zero-shot
|
||||
inference (call out to a hosted/cloud checkpoint) and from-scratch
|
||||
small-model training remain different tools for different deployment
|
||||
constraints, not a strict replacement of one by the other.
|
||||
|
||||
Reproducer: `scripts/ruforecast-zeroshot/bidmc_windows.py` (window builder,
|
||||
baselines, and metrics — ported line-for-line from
|
||||
`ruforecast-train/examples/bidmc_prepare.rs` and
|
||||
`ruforecast-core/src/{baseline,metrics}.rs`, verified against this document's
|
||||
own BIDMC last-value/seasonal-naive numbers) and
|
||||
`scripts/ruforecast-zeroshot/run_zeroshot_eval.py` (zero-shot inference and
|
||||
scoring), both in this worktree (`research/ruforecast-zeroshot-eval`). Run
|
||||
against `/tmp/bidmc-raw/` (downloaded fresh from PhysioNet if not already
|
||||
cached) with `python run_zeroshot_eval.py --models chronos-bolt-base
|
||||
chronos-bolt-small chronos-t5-small timesfm-2.5-200m --judges a b` inside a
|
||||
venv with `chronos-forecasting`, `timesfm`, and a CUDA-enabled `torch`
|
||||
installed (`amazon/chronos-*` and `google/timesfm-2.5-200m-pytorch` download
|
||||
automatically from HuggingFace Hub on first run; no HF token was required).
|
||||
Model weights and the raw BIDMC CSVs are not committed — `/tmp/bidmc-raw/`
|
||||
and the HuggingFace cache live only on the host that ran this evaluation.
|
||||
@@ -5,10 +5,15 @@ PCK@20 (MultiFormer Table VII metric: `‖pred−gt‖ ≤ 0.2·‖R-shoulder
|
||||
|
||||
The flagship [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
|
||||
reaches **83.59%** torso-PCK@20 (vs MultiFormer 72.25%, CSI2Pose 68.41%). But the headline number
|
||||
isn't the whole story for **edge deployment** — on a Raspberry Pi / ESP32-class target, *params and
|
||||
isn't the whole story for **edge deployment** — on a Raspberry Pi-class edge host, *params and
|
||||
latency* matter as much as accuracy. So we swept model size to map the **accuracy-per-parameter
|
||||
frontier**: how small can a WiFi-CSI pose model be and still beat the prior published SOTA?
|
||||
|
||||
> **Hardware compatibility boundary.** These models consume MM-Fi tensors shaped
|
||||
> `[3,114,10]`. Parameter size alone does not make that input, model architecture, or runtime
|
||||
> compatible with an ESP32-S3/C6 capture node. The measurements below are dataset and x86/GPU
|
||||
> measurements; no ESP32 inference latency or live ESP32-to-MM-Fi adapter is claimed.
|
||||
|
||||
## The frontier
|
||||
|
||||
| Model | Params | Latency (batch=1) | torso-PCK@20 | vs SOTA (72.25%) |
|
||||
@@ -38,8 +43,10 @@ Size alone isn't the claim — what matters is **accuracy at the deployed precis
|
||||
|
||||
**The honest edge result:** `micro` is **lossless at int8 (73.5 KB, 74.70%)**, and at **int4 (36.7 KB)
|
||||
naïve post-training quantization falls below SOTA (70.21%) — but quantization-aware training fully
|
||||
recovers it to 74.46%**, still beating MultiFormer. So a **SOTA-beating WiFi-pose model genuinely runs
|
||||
in ~37 KB int4** (with QAT) or **~73 KB int8** (no retraining) — deployable on the sensing node itself.
|
||||
recovers it to 74.46%**, still beating MultiFormer. So a **SOTA-beating WiFi-pose model fits in
|
||||
~37 KB int4** (with QAT) or **~73 KB int8** (no retraining). That is a model-footprint result, not
|
||||
evidence that it runs on an ESP32 sensing node; a compatible capture adapter and embedded runtime
|
||||
still need to be implemented and measured.
|
||||
`nano` (40K params) sits at the SOTA line in fp32 and is best treated as int8.
|
||||
|
||||
(We also tested flagship→tiny **knowledge distillation**: it did *not* help — the tiny students reach
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Which CSI frames a node keeps decides whether cross-node fusion works
|
||||
|
||||
A measured comparison of three frame-selection gates on a nine-node ESP32-C6
|
||||
fleet, and a method for repeating it in your own environment.
|
||||
|
||||
**This is one house.** Nine boards, one floor plan, one RF environment, one
|
||||
transmitter population. The numbers below are what we measured here; the part
|
||||
worth reusing is the method, not our figures. `run_arms.py` is included so you
|
||||
can measure yours.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Cross-node fusion pairs frames by transmission identity: two nodes are fusing
|
||||
usefully only when they hold measurements of *the same packet in the air*. The
|
||||
metric is `paired_fraction` — of the transmissions the fleet reported, what
|
||||
share were captured by two or more nodes.
|
||||
|
||||
Every node also rate-limits which CSI callbacks it keeps, because processing
|
||||
every callback at 100–500 Hz destabilises the WiFi ISR. **How that limiting is
|
||||
done turns out to matter far more than we expected**, because it decides
|
||||
whether two nodes keep the *same* frames or merely the *same number* of frames.
|
||||
|
||||
Three mechanisms, all valid rate limits:
|
||||
|
||||
| gate | rule |
|
||||
|---|---|
|
||||
| `elapsed` | at least 20 ms since **this node's** last accepted frame |
|
||||
| `mesh` | first frame heard in each shared 20 ms window of a mesh-aligned clock |
|
||||
| `seq` | keep frames where the 802.11 `rx_seq` is divisible by N |
|
||||
|
||||
`elapsed` gives every node an independent phase. `mesh` makes nodes agree on
|
||||
the *window*. `seq` makes them agree on the *frame*, using a number the
|
||||
transmitter assigns — so it needs no clock, no leader and no synchronisation.
|
||||
|
||||
## Method
|
||||
|
||||
Four arms — the three gates plus `mesh`+`seq` stacked — run as a 2x2 factorial.
|
||||
Three deliberate choices, each of which we got wrong first:
|
||||
|
||||
**Whole fleet per arm, never a subset.** An earlier attempt compared four gated
|
||||
nodes against four ungated ones. Nodes differ enormously in how much traffic
|
||||
they hear, so that comparison could not be read at all.
|
||||
|
||||
**Verify the gate is actually running.** Each switch is read back from every
|
||||
node before sampling. "Config was stored" is not "gate is live", and an earlier
|
||||
run could not distinguish a null result from a change that never applied.
|
||||
|
||||
**Frame rate is NOT a valid check that a gate is working.** A bucket gate needs
|
||||
only one survivor per window, so at ~14 candidate frames per bucket even a
|
||||
perfect 1-in-4 gate moves frame rate about 2%. Use instead
|
||||
|
||||
R_window = (delta early_drop + delta accepted) / delta accepted
|
||||
|
||||
computed from **differenced** counters. Never divide lifetime totals: they
|
||||
carry a large post-boot burst. One node read R = 1126 moments after a reboot,
|
||||
26.5 an hour later, and 19.2 windowed.
|
||||
|
||||
**Establish the noise floor first.** A null run — three hours, no changes —
|
||||
gave drift of sd **0.45 pp** across 20-minute windows, about 2x the ±0.2 pp
|
||||
binomial counting error. Without that number none of the results below can be
|
||||
called significant.
|
||||
|
||||
Arms are 20 minutes in Latin-square blocks (`U M S MS / S MS M U / MS U S M`),
|
||||
so slow drift lands on every arm equally rather than on whichever ran first.
|
||||
|
||||
## Results
|
||||
|
||||
Three blocks, twelve arms, every arm verified live on all nine nodes.
|
||||
|
||||
| arm | block 0 | block 1 | block 2 | mean |
|
||||
|---|---|---|---|---|
|
||||
| `elapsed` | 0.2881 | 0.2409 | 0.2012 | **0.2434** |
|
||||
| `mesh` | 0.2692 | 0.2580 | 0.2167 | **0.2480** |
|
||||
| `seq` (period 8) | 0.5819 | 0.5573 | 0.5422 | **0.5605** |
|
||||
| `mesh`+`seq` | 0.5639 | 0.5623 | 0.5093 | 0.5452 |
|
||||
|
||||
**`seq` roughly doubles pairing: +31.7 pp, 2.30x.** That gap is ~70x the
|
||||
measured drift floor and reproduces in all three blocks; within-block contrasts
|
||||
sit at +0.153 to +0.175 every time.
|
||||
|
||||
**Mesh-time alignment bought essentially nothing here: +0.5 pp**, about one
|
||||
standard deviation of the noise floor. We built that gate and expected it to be
|
||||
the answer; it is indistinguishable from doing nothing. Stacking it on top of
|
||||
`seq` is marginally *worse* than `seq` alone.
|
||||
|
||||
### It is a trade, not a free win
|
||||
|
||||
At period 8 the seq arms carried ~75–95k transmissions per 20 minutes against
|
||||
~120–160k ungated. You are buying pairing with raw frame rate. Absolute paired
|
||||
transmissions per minute still went **up** (2629 vs 2524), because the frames
|
||||
it discards are disproportionately ones only one node would have kept — but
|
||||
whether that trade is right depends on what else consumes the stream.
|
||||
|
||||
One measurement that made the trade easier here: on-device edge processing was
|
||||
**unaffected**. Frames actually processed stayed ~1900 per node per 4 minutes
|
||||
in both modes, because that pipeline is separately rate-limited. The cost falls
|
||||
on the uplink, not on local sensing.
|
||||
|
||||
### The mesh arm was partly an elapsed arm
|
||||
|
||||
The mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`. A
|
||||
node without valid sync falls back to the elapsed gate rather than gating on a
|
||||
meaningless epoch -- correct behaviour, but it weakens this comparison.
|
||||
|
||||
Three of the nine nodes never synchronised during these runs. For those three,
|
||||
the `mesh` and `mesh`+`seq` arms silently ran the elapsed gate. So **+0.5 pp is
|
||||
not a clean fleet-wide measurement of window alignment**; it is "mesh where sync
|
||||
held, elapsed where it did not", and the true effect of alignment on a fully
|
||||
synchronised fleet is not measured here.
|
||||
|
||||
We are reporting the number as observed rather than reconstructing it, because
|
||||
the arms were assigned before we understood the fallback, and a post-hoc
|
||||
per-node reanalysis would not be the experiment we ran.
|
||||
|
||||
The cause is worth naming because it is environmental, not a firmware defect.
|
||||
Time sync is leader-elected and hub-and-spoke: one node broadcasts and the rest
|
||||
follow. Whether every node hears that leader is a property of the building. A
|
||||
larger or more divided house fragments into sync domains, and any option that
|
||||
depends on shared time then degrades quietly for the nodes that fell out --
|
||||
which is precisely the situation where someone would most want to trust this
|
||||
measurement.
|
||||
|
||||
### A prediction we got wrong
|
||||
|
||||
Our mesh had split into two leader domains with three nodes never synchronising
|
||||
at all. We predicted `seq` would win *most* on node pairs spanning that split.
|
||||
Per-pair yield says otherwise:
|
||||
|
||||
| pair group | pairs | seq / mesh |
|
||||
|---|---|---|
|
||||
| same sync domain | 7 | 5.82x |
|
||||
| spans the two domains | 8 | 6.24x |
|
||||
| involves a never-synced node | 21 | **5.16x** |
|
||||
|
||||
Cross-domain is marginally best, but the never-synced nodes are the *lowest* —
|
||||
the opposite of the prediction — and within-group spread (3.6x–8.1x) dwarfs
|
||||
every between-group difference.
|
||||
|
||||
So `seq` is **not** a workaround for broken time sync. It wins roughly
|
||||
uniformly, including where sync is healthy. The useful consequence is negative:
|
||||
repairing our mesh split would probably not have closed the gap, and we would
|
||||
have spent that effort for nothing.
|
||||
|
||||
## What we suggest
|
||||
|
||||
Not "use `seq`". **Make it selectable and measure your own environment.** The
|
||||
right answer depends on your transmitter population, your node placement and
|
||||
what consumes your CSI stream — none of which we can see from here.
|
||||
|
||||
`run_arms.py` runs the factorial unattended: it applies each arm fleet-wide,
|
||||
verifies every node adopted it, samples `/api/v1/fusion`, and writes one row
|
||||
per arm. Run the null pass first; without your own drift floor the contrasts
|
||||
cannot be judged.
|
||||
|
||||
## Limits
|
||||
|
||||
- One environment, nine nodes. Treat the numbers as an existence proof, not a
|
||||
constant.
|
||||
- Only period 8 was characterised. 4 and 16 are unmeasured, so the
|
||||
yield/pairing curve has a single point on it.
|
||||
- `paired_fraction` measures **frame pairing**, which is a precondition for
|
||||
cross-receiver work. Nothing here measures sensing accuracy, and none of it
|
||||
should be read as an accuracy claim.
|
||||
@@ -81,6 +81,42 @@ v1, with its flat loss, was barely better than random on this same test.
|
||||
params, trained with InfoNCE (temperature 0.1, in-batch + temporal-far negatives), AdamW, 60 epochs.
|
||||
Temporal positives within 2 s; negatives >30 s apart. Time-disjoint 80/20 split.
|
||||
|
||||
**EWC (catastrophic-forgetting protection):** measured forgetting rate on the 4 protected
|
||||
tasks is **32.97%** — real and non-trivial, disclosed here rather than only in the raw
|
||||
`training-metrics.json`. Fisher information is computed and applied (protection strength
|
||||
2000), but this is not "solved forgetting" — a user fine-tuning per-room LoRA adapters
|
||||
should expect some drift on earlier tasks, not none.
|
||||
|
||||
## 🔧 v2.0.1 — fixed the published training-metrics.json itself (2026-09-09)
|
||||
|
||||
Separately from the v1→v2 accuracy retraction above, an unrelated bug was found and fixed
|
||||
in the **export code** (`scripts/train-ruvllm.js`) that produces `config.json` and
|
||||
`training-metrics.json` for this file set (`model.safetensors`, `model-q2/q4/q8.bin`,
|
||||
`presence-head.json`, `node-1/2.json`) — separate from the `csi-embed-v2*` files, which
|
||||
come from a different script.
|
||||
|
||||
The exported `lossHistory` and `contrastive.durationMs` were being read from a disconnected
|
||||
baseline pass (`ContrastiveTrainer.train()`, which computes loss but never updates the
|
||||
encoder) instead of from the loop that actually performs gradient updates on the encoder
|
||||
weights. Concretely: `lossHistory` published a flat, non-decreasing curve around `0.1352`
|
||||
for all 20 epochs, and `durationMs` reported ~12.4s — both measuring the wrong computation.
|
||||
|
||||
**This did not affect `finalLoss`/`improvement`/the 82.3% held-out temporal-triplet
|
||||
accuracy above** — those were already computed from the real training loop and are
|
||||
unchanged by this fix. It only affected the exported diagnostic curve and duration, which
|
||||
anyone auditing convergence from the published JSON would have been misled by. Re-run
|
||||
against the same real capture (`data/recordings/overnight-1775217646.csi.jsonl`) with the
|
||||
fix applied: `lossHistory` now shows a genuine decreasing curve (0.1037 -> 0.0654 across 20
|
||||
epochs) and `durationMs` reports the real ~40s gradient-loop time. `config.json`'s
|
||||
`training.steps` field (`12212300` — epochs × triplets, arithmetically consistent but read
|
||||
like optimizer-step count) is now reported as separate `epochs`/`tripletUpdatesPerEpoch`
|
||||
fields instead.
|
||||
|
||||
Repro: `node scripts/train-ruvllm.js --data data/recordings/overnight-1775217646.csi.jsonl
|
||||
--output <dir> --epochs 20` in [github.com/ruvnet/RuView](https://github.com/ruvnet/RuView)
|
||||
(the `aether-arena/staging/train_csi_embed.py` path cited above is for the separate
|
||||
`csi-embed-v2*` files and was not touched by this fix).
|
||||
|
||||
### v2 files & proof
|
||||
| File | Size | Use |
|
||||
|------|------|-----|
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
# RuView Forecast model card template
|
||||
|
||||
> **Template only. Do not publish or treat this file as a model release.** Copy
|
||||
> it for one immutable candidate, replace every `<REQUIRED>` field, and retain
|
||||
> every unresolved item as an explicit blocker. Deleting a placeholder does not
|
||||
> satisfy it.
|
||||
|
||||
This template implements the model-card requirements in
|
||||
[ADR-348](../adr/ADR-348-independent-rust-multivariate-forecasting.md) and the
|
||||
[RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md).
|
||||
It does not imply that source, training, evaluation, clean-room, security,
|
||||
privacy, trademark, patent, or production gates have passed.
|
||||
|
||||
## Completion rules
|
||||
|
||||
1. Create one card per exact weight digest. Do not reuse a card across retrains.
|
||||
2. Label every numeric claim `MEASURED`, `SYNTHETIC`, `CLAIMED`, or
|
||||
`UNMEASURED` under the repository evidence policy.
|
||||
3. A `MEASURED` value needs an immutable dataset/split, artifact digest,
|
||||
configuration, hardware/environment, metric definition, and reproducer.
|
||||
4. Use `UNMEASURED` when evidence does not exist. Do not substitute an estimate.
|
||||
5. State source-code, weight, dataset, and service terms separately.
|
||||
6. Do not publish raw CSI, customer data, precise room coordinates, identities,
|
||||
secrets, private contracts, or private contributor records in this card.
|
||||
7. A completed card documents evidence; it cannot waive an ADR-348 gate.
|
||||
|
||||
## Suggested Hugging Face metadata
|
||||
|
||||
Copy and complete this front matter in the release card:
|
||||
|
||||
```yaml
|
||||
---
|
||||
license: <REQUIRED exact weight-license identifier>
|
||||
tags:
|
||||
- time-series-forecasting
|
||||
- multivariate-forecasting
|
||||
- wifi-sensing
|
||||
- rust
|
||||
- ruvector
|
||||
- probabilistic-forecasting
|
||||
- edge-ai
|
||||
language:
|
||||
- en
|
||||
library_name: burn
|
||||
pipeline_tag: time-series-forecasting
|
||||
---
|
||||
```
|
||||
|
||||
`license: other` is acceptable only when the card links the complete exact
|
||||
license. Do not infer the weight license from the Rust source license.
|
||||
|
||||
## Candidate identity
|
||||
|
||||
Replace the template title with `<REQUIRED model name and version>` in the
|
||||
release copy.
|
||||
|
||||
## Release decision and evidence status
|
||||
|
||||
| Field | Required value |
|
||||
|---|---|
|
||||
| Release candidate ID | `<REQUIRED>` |
|
||||
| Weight SHA-256 | `<REQUIRED>` |
|
||||
| Git commit | `<REQUIRED>` |
|
||||
| Model-card SHA-256 | `<REQUIRED after finalization>` |
|
||||
| Proposed mode | `OFFLINE_EVAL`, `SHADOW`, or `ADVISORY`; `<REQUIRED>` |
|
||||
| ADR-348 highest gate passed | `G0` through `G5`, or `NONE`; `<REQUIRED>` |
|
||||
| Accuracy status | `UNMEASURED` until a qualifying report exists; `<REQUIRED>` |
|
||||
| Calibration status | `UNMEASURED` until a qualifying report exists; `<REQUIRED>` |
|
||||
| CPU latency status | `UNMEASURED` until a named CPU reproducer exists; `<REQUIRED>` |
|
||||
| Memory status | `UNMEASURED` until a named runtime reproducer exists; `<REQUIRED>` |
|
||||
| Cross-site generalization status | `UNMEASURED` until untouched site holdouts exist; `<REQUIRED>` |
|
||||
| Clean-room status | `OPEN`, `PASS`, or `FAIL`; `<REQUIRED>` |
|
||||
| Production approval | `NOT APPROVED` unless all G5 receipts are signed; `<REQUIRED>` |
|
||||
|
||||
### Release summary
|
||||
|
||||
`<REQUIRED: State what this exact artifact does, what evidence exists, which
|
||||
mode is requested, and the most important unresolved limitation in no more
|
||||
than 150 words. Do not copy competitor marketing language.>`
|
||||
|
||||
## Model details
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Developer/owner | `<REQUIRED>` |
|
||||
| Model family | `RuView Forecast` |
|
||||
| Model version | `<REQUIRED immutable version>` |
|
||||
| Artifact format | `<REQUIRED, including RVF/safetensors/version>` |
|
||||
| Parameter count | `<REQUIRED or UNMEASURED>` |
|
||||
| Numeric precision | `<REQUIRED>` |
|
||||
| Architecture/config digest | `<REQUIRED>` |
|
||||
| Input schema version | `<REQUIRED>` |
|
||||
| Output schema version | `<REQUIRED>` |
|
||||
| Maximum context | `<REQUIRED with cadence and units>` |
|
||||
| Supported horizons | `<REQUIRED with cadence and units>` |
|
||||
| Declared quantiles | `<REQUIRED>` |
|
||||
| Training framework | `<REQUIRED exact Rust crate/features/version>` |
|
||||
| Inference runtime | `<REQUIRED exact Rust crate/features/version>` |
|
||||
| Source license | `<REQUIRED SPDX expression>` |
|
||||
| Weight license | `<REQUIRED exact license and URL>` |
|
||||
|
||||
### Rust implementation boundary
|
||||
|
||||
Describe the exact code surfaces used by this candidate:
|
||||
|
||||
| Crate or binary | Version/digest | Responsibility | Enabled features |
|
||||
|---|---|---|---|
|
||||
| `ruview-forecast-core` | `<REQUIRED>` | Backend-neutral schemas, invariants, metrics, receipts, and `Forecaster` trait | `<REQUIRED; expected default only>` |
|
||||
| `ruview-forecast-model` | `<REQUIRED>` | Independent Burn 0.21 patch mixer and artifact execution | `<REQUIRED; CPU/CUDA/WGPU are opt-in and default off>` |
|
||||
| `ruview-forecast-train` / `ruforecast` | `<REQUIRED>` | Dataset splits, trainer, evaluator, training receipt, and Linux/fal.ai assets | `<REQUIRED>` |
|
||||
| `<optional service/runtime>` | `<REQUIRED or NONE>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
State whether inference is offline and whether any runtime network capability
|
||||
exists. The expected production answer is no runtime model download and no
|
||||
network requirement:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
State whether a sensing-server bridge exists. For the initial ADR-348 PR the
|
||||
required answer is `NO: contracts/training only; a separately reviewed shadow
|
||||
bridge is deferred`:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Intended use
|
||||
|
||||
### Approved candidate use
|
||||
|
||||
`<REQUIRED: identify the exact RuView feature streams, deployment class,
|
||||
forecast horizons, mode, users, and decision support purpose.>`
|
||||
|
||||
### Out-of-scope and prohibited use
|
||||
|
||||
This artifact must not be used unless a later approved card explicitly changes
|
||||
the boundary:
|
||||
|
||||
- as a sensor observation or ground-truth label;
|
||||
- as the sole source for medical, emergency, fall-response, industrial-safety,
|
||||
access-control, policing, insurance, employment, or autonomous-actuation
|
||||
decisions;
|
||||
- to infer identity or protected characteristics;
|
||||
- outside the feature schema, cadence, hardware, site, population, and horizon
|
||||
validated by this card;
|
||||
- after calibration, feature schema, source provenance, or OOD checks fail;
|
||||
- to train another model unless the weight, dataset, and output licenses and a
|
||||
new provenance review explicitly permit it;
|
||||
- to claim TimesFM compatibility, affiliation, endorsement, or equivalence.
|
||||
|
||||
Add candidate-specific exclusions:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Functional architecture
|
||||
|
||||
Describe independently implemented components without reproducing external
|
||||
source expression, diagrams, identifiers, or constants:
|
||||
|
||||
```text
|
||||
<REQUIRED: compact original diagram of feature windows, optional split-safe
|
||||
retrieval, normalization, temporal/cross-stream model, quantile heads,
|
||||
abstention, receipt, and policy boundary>
|
||||
```
|
||||
|
||||
| Component | Candidate implementation | Security/resource bound |
|
||||
|---|---|---|
|
||||
| Feature validation | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Normalization | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Temporal encoding/mixing | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Cross-stream fusion | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Missing-data handling | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Point/quantile head | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Quantile crossing policy | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| OOD/abstention | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| RuVector retrieval | `<REQUIRED or NONE>` | `<REQUIRED>` |
|
||||
| Artifact verification | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Input contract
|
||||
|
||||
### Feature schema
|
||||
|
||||
| Field | Unit | Cadence/aggregation | Range | Missing/invalid semantics | Source provenance |
|
||||
|---|---|---|---|---|---|
|
||||
| `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
State:
|
||||
|
||||
- minimum and maximum context length;
|
||||
- maximum targets, covariates, horizon, and batch;
|
||||
- timestamp monotonicity and duplicate policy;
|
||||
- whether any future-known covariate is permitted and how its authority is
|
||||
verified;
|
||||
- finite/range checks and allocation limits;
|
||||
- minimum validity coverage before abstention;
|
||||
- treatment of cadence gaps, resets, device changes, and calibration changes;
|
||||
- OOD input behaviour.
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
### Data not accepted
|
||||
|
||||
`<REQUIRED: include raw/unbounded payloads, unknown schema, non-finite values,
|
||||
untrusted future covariates, stale data, unsupported cadence, and candidate
|
||||
specific exclusions.>`
|
||||
|
||||
## Output contract
|
||||
|
||||
| Output | Shape/unit | Meaning | Evidence class |
|
||||
|---|---|---|---|
|
||||
| Point forecast | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_FORECAST` |
|
||||
| Quantile forecast | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_FORECAST` |
|
||||
| Validity mask | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
|
||||
| Abstention/disposition | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
|
||||
| OOD score/state | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
|
||||
| Receipt/provenance | `<REQUIRED>` | `<REQUIRED>` | `SIGNED_METADATA` if signed |
|
||||
|
||||
The output must bind model, configuration, input schema, calibration, source
|
||||
time range, optional retrieval index, and content hashes. Explain whether and
|
||||
how quantile crossing is corrected:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## RuVector retrieval
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Enabled | `<REQUIRED true/false>` |
|
||||
| Index artifact/version/digest | `<REQUIRED or NONE>` |
|
||||
| Allowed retrieval corpus | `<REQUIRED>` |
|
||||
| Split-isolation receipt | `<REQUIRED>` |
|
||||
| Neighbour exclusion rules | `<REQUIRED>` |
|
||||
| Maximum neighbours/search budget | `<REQUIRED>` |
|
||||
| Behaviour when index is absent/stale | `<REQUIRED>` |
|
||||
|
||||
Report identical-dataset ablations for no retrieval and retrieval. If these do
|
||||
not exist, state `UNMEASURED`.
|
||||
|
||||
## RuVLLM integration
|
||||
|
||||
State whether RuVLLM consumes this artifact's signed forecast records. It may
|
||||
explain results but must not rewrite point/quantile values or acquire action
|
||||
authority from prose.
|
||||
|
||||
| Control | Evidence |
|
||||
|---|---|
|
||||
| Numeric outputs originate only from signed forecast record | `<REQUIRED or NOT INTEGRATED>` |
|
||||
| Explanation cites artifact and forecast receipt | `<REQUIRED or NOT INTEGRATED>` |
|
||||
| No model promotion, spending, actuation, or safety-critical authority | `<REQUIRED or NOT INTEGRATED>` |
|
||||
| Prompt/data retention and tenant policy | `<REQUIRED or NOT INTEGRATED>` |
|
||||
|
||||
## Independent-development record
|
||||
|
||||
| Record | Digest/approval |
|
||||
|---|---|
|
||||
| Approved source allowlist | `<REQUIRED>` |
|
||||
| Specification digest | `<REQUIRED>` |
|
||||
| Contributor exposure manifest | `<REQUIRED>` |
|
||||
| Contributor attestation bundle | `<REQUIRED>` |
|
||||
| AI-tool prompt/session manifest | `<REQUIRED>` |
|
||||
| Prohibited-artifact scan | `<REQUIRED>` |
|
||||
| Source-similarity review | `<REQUIRED>` |
|
||||
| Clean-room custodian approval | `<REQUIRED>` |
|
||||
| Legal release approval | `<REQUIRED for production>` |
|
||||
|
||||
Declaration:
|
||||
|
||||
`<REQUIRED: State exactly what was independently authored and trained. Do not
|
||||
state that clean room eliminates patent, trademark, dataset, privacy, or
|
||||
jurisdiction risk.>`
|
||||
|
||||
Prior exposure disclosures and dispositions, without private details:
|
||||
|
||||
`<REQUIRED or NONE>`
|
||||
|
||||
## Training data
|
||||
|
||||
Do not list a dataset until its licensing/privacy gate passes.
|
||||
|
||||
| Dataset ID/version | Role | Origin/owner | License or contract ID | Commercial ML | Privacy class | Records/windows | Source/transform/split digests |
|
||||
|---|---|---|---|---|---|---:|---|
|
||||
| `<REQUIRED>` | train/validation/calibration | `<REQUIRED>` | `<REQUIRED>` | `YES` required | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
### Excluded data
|
||||
|
||||
Explicitly state that TimesFM 3 weights, outputs, activations, generated labels,
|
||||
and derivatives were excluded, then list all other exclusions:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
### Collection, consent, minimization, and retention
|
||||
|
||||
`<REQUIRED: collection authority, purpose, consent/contract, controller and
|
||||
processor, sensitive inferences, pseudonymization, fields removed, geographic
|
||||
scope, retention, deletion, access, and incident contact.>`
|
||||
|
||||
### Synthetic data
|
||||
|
||||
| Generator/version | Seed-data rights | Provider/output terms | Seeds/config digest | Proportion | Use |
|
||||
|---|---|---|---|---:|---|
|
||||
| `<REQUIRED or NONE>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Split and leakage protocol
|
||||
|
||||
| Split | Sites | Subjects | Sessions | Devices | Time blocks | Windows | Manifest digest |
|
||||
|---|---:|---:|---:|---:|---:|---:|---|
|
||||
| Train | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Validation | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Calibration | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Test | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
Required leakage checks:
|
||||
|
||||
- parent raw record and derived-window overlap;
|
||||
- contiguous sequence/session overlap;
|
||||
- subject/site/device/calibration leakage;
|
||||
- target-horizon overlap;
|
||||
- fitted preprocessing or threshold leakage;
|
||||
- RuVector neighbour/index leakage;
|
||||
- duplicate and near-duplicate leakage;
|
||||
- test-informed architecture or hyperparameter changes.
|
||||
|
||||
Report and digest:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Training procedure
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Random initialization | `true` required for this model family |
|
||||
| Parent checkpoint | `NONE` or approved RuView artifact and digest |
|
||||
| Source commit | `<REQUIRED>` |
|
||||
| Cargo.lock digest | `<REQUIRED>` |
|
||||
| Rust toolchain | `<REQUIRED exact>` |
|
||||
| Build/training container | `<REQUIRED digest>` |
|
||||
| Training config digest | `<REQUIRED>` |
|
||||
| Seeds | `<REQUIRED>` |
|
||||
| Optimizer/schedule/loss | `<REQUIRED>` |
|
||||
| Batch/epochs/stopping | `<REQUIRED>` |
|
||||
| Hardware | `<REQUIRED exact CPU/GPU/RAM>` |
|
||||
| Provider | `<REQUIRED local Linux or approved provider>` |
|
||||
| Provider job ID | `<REQUIRED non-secret identifier>` |
|
||||
| Wall time | `<REQUIRED and evidence label>` |
|
||||
| Peak host/GPU memory | `<REQUIRED and evidence label>` |
|
||||
| Estimated/actual cost | `<REQUIRED and evidence label>` |
|
||||
| Emitted checkpoint digests | `<REQUIRED>` |
|
||||
| Training receipt digest | `<REQUIRED>` |
|
||||
|
||||
If local and hosted runs are compared, list numerical-determinism differences
|
||||
and prove that governed code, data, config, and parent identities match:
|
||||
|
||||
`<REQUIRED or NOT APPLICABLE>`
|
||||
|
||||
## Evaluation
|
||||
|
||||
### Baselines and ablations
|
||||
|
||||
Every row uses the same frozen examples and metric implementation.
|
||||
|
||||
| Model | Artifact/version | Retrieval | Parameters | Evidence label | Notes |
|
||||
|---|---|---|---:|---|---|
|
||||
| Last value | `<REQUIRED>` | no | n/a | `<REQUIRED>` | deterministic baseline |
|
||||
| Seasonal naive | `<REQUIRED>` | no | n/a | `<REQUIRED>` | `<REQUIRED period>` |
|
||||
| Small classical/recurrent baseline | `<REQUIRED>` | no | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| RuView Forecast | `<REQUIRED>` | no | `<REQUIRED>` | `<REQUIRED>` | required ablation |
|
||||
| RuView Forecast | `<REQUIRED>` | yes | `<REQUIRED>` | `<REQUIRED>` | required if retrieval is enabled |
|
||||
|
||||
Do not use TimesFM 3 as an implementation oracle, label source, tuning signal,
|
||||
or required acceptance baseline.
|
||||
|
||||
### Forecast metrics
|
||||
|
||||
Report per horizon and per site/device/interference regime, plus pooled values.
|
||||
|
||||
| Metric/domain/horizon | Result | 95% interval | Evidence label | Reproducer |
|
||||
|---|---:|---:|---|---|
|
||||
| Weighted quantile loss | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| MAE or scaled error | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Nominal 80% interval coverage | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Interval width | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Quantile crossing before/after policy | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Abstention coverage/selective risk | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
ADR-348 G3 targets are at least 10% weighted-quantile-loss improvement over
|
||||
seasonal naive and 75%-85% measured coverage for a nominal 80% interval. These
|
||||
remain `UNMEASURED targets` until populated by qualifying evidence.
|
||||
|
||||
### RuView shadow outcomes
|
||||
|
||||
| Metric | Baseline | Candidate | Delta/CI | Evidence label | Reproducer |
|
||||
|---|---:|---:|---:|---|---|
|
||||
| Empty-room false alerts | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Occupied-room recall | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Abstention by deployment | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Drift/OOD rate | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
ADR-348 G4 targets at least 50% relative empty-room false-alert reduction with
|
||||
no more than 2 percentage points occupied-room recall loss over at least 14
|
||||
shadow days. These are `UNMEASURED targets`, not current capabilities.
|
||||
|
||||
### Runtime measurements
|
||||
|
||||
| Platform | Build/features | Batch/streams | Context/horizon | p50/p95/p99 | Peak RSS | Evidence label | Reproducer |
|
||||
|---|---|---:|---|---|---:|---|---|
|
||||
| `<REQUIRED named CPU>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| `<optional GPU>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
ADR-348 G5 CPU targets are at most 1 second p95 for 32 declared streams and at
|
||||
most 4 GiB peak process memory. They are `UNMEASURED targets` until this table
|
||||
contains a qualifying named-platform reproducer.
|
||||
|
||||
## Calibration, OOD, and abstention
|
||||
|
||||
| Control | Fit data | Frozen parameters/digest | Test result | Evidence label |
|
||||
|---|---|---|---|---|
|
||||
| Input normalization | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Quantile calibration | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| OOD threshold | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Minimum validity/context | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Drift threshold | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
List all typed abstention reasons and demonstrate that unknown schema, missing
|
||||
calibration, stale data, insufficient history, invalid masks, non-finite input,
|
||||
OOD state, and artifact failure do not produce an authoritative forecast:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Robustness and failure analysis
|
||||
|
||||
Report at minimum:
|
||||
|
||||
- unseen site and unseen device;
|
||||
- empty room and low-motion occupancy;
|
||||
- burst loss, cadence change, clock reset, and sensor restart;
|
||||
- interference, channel change, and calibration drift;
|
||||
- long missing runs and adversarial non-finite/range inputs;
|
||||
- corrupted/truncated/oversized model and input artifacts;
|
||||
- absent or stale RuVector index;
|
||||
- concurrent-load resource caps and timeout;
|
||||
- false confidence from narrow intervals during distribution shift.
|
||||
|
||||
| Scenario | Expected safe behaviour | Observed result | Evidence label | Open risk/owner |
|
||||
|---|---|---|---|---|
|
||||
| `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Security and privacy
|
||||
|
||||
### Threat model summary
|
||||
|
||||
| Threat | Control | Evidence | Residual risk/owner |
|
||||
|---|---|---|---|
|
||||
| Malicious/corrupt model artifact | Signature, digest, schema and size verification; no embedded code | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Oversized/malformed request | Dimension, allocation, horizon, context, batch and deadline caps | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Poisoned data or split leakage | Approved manifests, immutable transforms, lineage and overlap checks | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Cross-tenant analogue retrieval | Tenant and split-scoped RuVector indexes | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Routine/location inference | Minimization, purpose, retention, deletion, access and audit | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Model extraction/membership inference | Rate/access control and privacy evaluation | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Hosted-worker data retention | Approved provider terms, least data, deletion receipt | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| LLM numeric mutation or overclaim | Signed numeric record and capability policy | `<REQUIRED or NOT INTEGRATED>` | `<REQUIRED>` |
|
||||
|
||||
### Data handling
|
||||
|
||||
`<REQUIRED: tenant isolation, encryption, keys, access roles, logs, metrics
|
||||
allowlist, retention, deletion, export, incident handling, and whether feature
|
||||
or forecast persistence is enabled.>`
|
||||
|
||||
## Deployment
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Supported mode | `<REQUIRED>` |
|
||||
| Supported platform/CPU/GPU | `<REQUIRED>` |
|
||||
| Minimum RAM/storage | `<REQUIRED and evidence-labelled>` |
|
||||
| Offline artifact source | `<REQUIRED>` |
|
||||
| Signature/trust root | `<REQUIRED public key ID>` |
|
||||
| Configuration/calibration artifact | `<REQUIRED digest>` |
|
||||
| RuVector index requirement | `<REQUIRED>` |
|
||||
| Startup self-test | `<REQUIRED>` |
|
||||
| Health/drift metrics | `<REQUIRED allowlisted fields>` |
|
||||
| Timeout/backpressure policy | `<REQUIRED>` |
|
||||
| Rollback artifact/mode | `<REQUIRED>` |
|
||||
|
||||
No deployment may fetch an unpinned model at runtime. Describe exact startup
|
||||
failure and fail-closed behaviour:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Monitoring, rollback, and retirement
|
||||
|
||||
| Signal | Threshold | Window | Response | Owner |
|
||||
|---|---:|---|---|---|
|
||||
| Calibration/coverage drift | `<REQUIRED>` | `<REQUIRED>` | shadow/off | `<REQUIRED>` |
|
||||
| OOD/abstention rate | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Latency/resource regression | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Empty/occupied outcome regression | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Provenance/signature failure | any | immediate | disable artifact | `<REQUIRED>` |
|
||||
| Security/privacy incident | any material incident | immediate | isolate, preserve evidence, disable | `<REQUIRED>` |
|
||||
|
||||
Rollback drill receipt and result:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
Retirement and data/index/checkpoint deletion policy:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Limitations and largest uncertainty
|
||||
|
||||
`<REQUIRED: List observed and unmeasured limitations. The default largest
|
||||
uncertainty is whether training diversity supports unseen-room and unseen-device
|
||||
generalization without suppressing legitimate occupied states. State the
|
||||
specific data/evaluation fix path.>`
|
||||
|
||||
## Cost, energy, and operational burden
|
||||
|
||||
| Item | Value | Evidence label | Method |
|
||||
|---|---:|---|---|
|
||||
| Training accelerator hours | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Training cost | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Training energy/emissions | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| CPU inference cost/energy | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Storage/index overhead | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Operator/calibration burden | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Licenses and notices
|
||||
|
||||
| Surface | Exact license/terms | URL/file | Obligations | Approval |
|
||||
|---|---|---|---|---|
|
||||
| Rust source | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Weights | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Each dataset | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Dependencies/SBOM | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Training provider | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
Trademark clearance ID and scope:
|
||||
|
||||
`<REQUIRED for public production release>`
|
||||
|
||||
Patent freedom-to-operate disposition and launch jurisdictions:
|
||||
|
||||
`<REQUIRED for production; keep privileged analysis outside this public card>`
|
||||
|
||||
## Provenance and reproducibility
|
||||
|
||||
| Artifact | Digest/signature/location |
|
||||
|---|---|
|
||||
| Source allowlist | `<REQUIRED>` |
|
||||
| Specification | `<REQUIRED>` |
|
||||
| Source commit | `<REQUIRED>` |
|
||||
| Cargo.lock | `<REQUIRED>` |
|
||||
| SBOM | `<REQUIRED>` |
|
||||
| Dataset manifest | `<REQUIRED>` |
|
||||
| Split/leakage report | `<REQUIRED>` |
|
||||
| Training config | `<REQUIRED>` |
|
||||
| Training receipt | `<REQUIRED>` |
|
||||
| Evaluation report | `<REQUIRED>` |
|
||||
| Clean-room report | `<REQUIRED>` |
|
||||
| Model weights | `<REQUIRED>` |
|
||||
| RVF signature | `<REQUIRED or NONE with blocker>` |
|
||||
| Reproducer | `<REQUIRED>` |
|
||||
|
||||
Reproduction commands must use pinned artifacts and must not contain secrets or
|
||||
download restricted material:
|
||||
|
||||
```bash
|
||||
<REQUIRED safe, exact commands>
|
||||
```
|
||||
|
||||
## ADR-348 gate traceability
|
||||
|
||||
| Gate | Requirements | Evidence in this card/bundle | Result |
|
||||
|---|---|---|---|
|
||||
| G0 independent-authoring boundary | RF-001, RF-002 | allowlist, exposure, attestation, scans, similarity review | `<OPEN, PASS, or FAIL>` |
|
||||
| G1 data and lineage | RF-002, RF-003, RF-010 | dataset approvals, lineage, local/hosted receipts | `<OPEN, PASS, or FAIL>` |
|
||||
| G2 bounded Rust contract | RF-004, RF-005, RF-006 | contract/fuzz/replay/resource/dependency reports | `<OPEN, PASS, or FAIL>` |
|
||||
| G3 leakage-free model evidence | RF-007, RF-008, RF-011 | frozen split, baselines, ablations, calibration, reproducers | `<OPEN, PASS, or FAIL>` |
|
||||
| G4 RuView shadow value | RF-006, RF-007, RF-008, RF-009 | 14-day outcomes, drift, abstention, no action authority | `<OPEN, PASS, or FAIL>` |
|
||||
| G5 deployment fitness | RF-004, RF-009, RF-010, RF-011, RF-012 | runtime, approvals, signed bundle, rollback | `<OPEN, PASS, or FAIL>` |
|
||||
|
||||
Highest permitted deployment mode from these results:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Approvals
|
||||
|
||||
Signatures cover this exact model-card digest and weight digest.
|
||||
|
||||
| Role | Approver/receipt | Decision | Timestamp |
|
||||
|---|---|---|---|
|
||||
| Model owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Clean-room custodian | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Data steward/privacy | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Security owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Runtime owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Legal release owner | `<REQUIRED for production>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Citation
|
||||
|
||||
Use the final public model name, exact version, weight digest, repository commit,
|
||||
and model-card URL:
|
||||
|
||||
```bibtex
|
||||
@software{<REQUIRED citation key>,
|
||||
title = {<REQUIRED>},
|
||||
author = {<REQUIRED>},
|
||||
year = {<REQUIRED>},
|
||||
version = {<REQUIRED>},
|
||||
url = {<REQUIRED>},
|
||||
note = {Weights SHA-256: <REQUIRED>; source commit: <REQUIRED>}
|
||||
}
|
||||
```
|
||||
|
||||
## Change history
|
||||
|
||||
| Card version | Weight digest | Change | Gate impact | Date |
|
||||
|---|---|---|---|---|
|
||||
| `<REQUIRED>` | `<REQUIRED>` | Initial candidate | All gates evaluated independently | `<REQUIRED>` |
|
||||
@@ -33,59 +33,82 @@ cargo run --release -p wifi-densepose-sensing-server \
|
||||
--mqtt-username homeassistant
|
||||
```
|
||||
|
||||
Within ~5 seconds of starting, Home Assistant should auto-create:
|
||||
Within ~5 seconds of the first CSI frame, Home Assistant should auto-create:
|
||||
|
||||
- One **device** per RuView node (named after the MAC or the `friendly_name` from your zones config)
|
||||
- 17+ **entities** per device (presence, person count, heart rate, breathing rate, motion, fall events, signal strength, zones, and the 10 semantic primitives)
|
||||
- One **device** per RuView node, named `RuView node <client-id>-node<N>` (see [Device identity](#device-identity))
|
||||
- **15 entities** per device (13 with `--privacy-mode`): the raw signals and six semantic primitives listed in the [Entity reference](#entity-reference)
|
||||
|
||||
If nothing appears in HA's Settings → Devices, see [Troubleshooting](#troubleshooting).
|
||||
|
||||
### 3. Stop the publisher cleanly
|
||||
|
||||
Ctrl-C — the publisher pushes `offline` to every availability topic before disconnect so HA marks all entities unavailable instantly. A `kill -9` triggers MQTT LWT, which has the same effect within ~30 s.
|
||||
Ctrl-C (SIGINT/SIGTERM) stops the server. It does **not** publish `offline` first, and no MQTT last-will is registered, so every availability topic keeps its last retained value until the server starts again (MEASURED: no availability message in the last 6 s of a SIGTERM'd run, see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)). While the server runs, a node that stops sending frames goes `offline` at the next 30 s availability heartbeat.
|
||||
|
||||
---
|
||||
|
||||
## Entity reference
|
||||
|
||||
RuView publishes three classes of entity. Names below are the `unique_id` slugs — Home Assistant assigns friendly names automatically.
|
||||
Names below are the `unique_id` slugs; Home Assistant assigns friendly names automatically. Every entity has its own availability topic (`…/<slug>/availability`, retained). An entity is `online` only while the server has a real source for it. Otherwise it is `offline`, no state is published, and Home Assistant shows **unavailable** rather than a made-up value.
|
||||
|
||||
### Raw signals (11 entities)
|
||||
### Raw signals (9 entities)
|
||||
|
||||
| HA entity | Slug | HA component | Unit | Source field |
|
||||
| HA entity | Slug | HA component | Unit | Source | Available when |
|
||||
|---|---|---|---|---|---|
|
||||
| Presence | `presence` | `binary_sensor` | — | `nodes[].node_inference.classification != "absent"` | always |
|
||||
| Person count | `person_count` | `sensor` | persons | `persons[]` length, else `estimated_persons` (room-level, same on every node) | always |
|
||||
| Motion level | `motion_level` | `sensor` | % | node classification: `present_moving` = 100, `present_still`/`absent` = 0 | always |
|
||||
| Presence score | `presence_score` | `sensor` | % | node classification confidence × 100 while present, else 0 | always |
|
||||
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `node_features[].features.motion_band_power`, else the room `features.motion_band_power` | a band-power value was seen in the last 30 s |
|
||||
| Signal strength | `rssi` | `sensor` | dBm | `nodes[].rssi_dbm` | an RSSI was seen in the last 30 s |
|
||||
| Heart rate | `heart_rate` | `sensor` | bpm | `vital_signs.heart_rate_bpm` (room-level) | a published estimate in the last 30 s |
|
||||
| Breathing rate | `breathing_rate` | `sensor` | bpm | `vital_signs.breathing_rate_bpm` (room-level) | a published estimate in the last 30 s |
|
||||
| Fall detected | `fall` | `event` | — | rising edge of the node's `edge_vitals.fall_detected` | the node sent `edge_vitals` in the last 30 s |
|
||||
|
||||
The server publishes heart and breathing rate only after explicit calibration, with exactly one occupant and a passing quality gate. Until then both entities stay unavailable. Heart rate and breathing rate are **biometric**: `--privacy-mode` removes them from discovery and state (see [Privacy](#privacy)).
|
||||
|
||||
### Semantic automation primitives (6 entities)
|
||||
|
||||
Small finite-state machines run inside the server, one set per node, sampled once per second ([ADR-115 §3.12](../adr/ADR-115-home-assistant-integration.md#312-semantic-automation-primitives-ha-mind), P4.5). All of them are unavailable for the first 60 s after start (warmup). After that each publishes its current state, then on every change.
|
||||
|
||||
| HA entity | Slug | HA component | What it fires on | Available when |
|
||||
|---|---|---|---|---|
|
||||
| Presence | `presence` | `binary_sensor` | — | `edge_vitals.presence` |
|
||||
| Person count | `person_count` | `sensor` | persons | `edge_vitals.n_persons` |
|
||||
| Heart rate | `heart_rate` | `sensor` | bpm | `edge_vitals.heartrate_bpm` |
|
||||
| Breathing rate | `breathing_rate` | `sensor` | bpm | `edge_vitals.breathing_rate_bpm` |
|
||||
| Motion level | `motion_level` | `sensor` | % | `edge_vitals.motion` × 100 |
|
||||
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `edge_vitals.motion_energy` |
|
||||
| Fall detected | `fall` | `event` | — | `edge_vitals.fall_detected` |
|
||||
| Presence score | `presence_score` | `sensor` | % | `edge_vitals.presence_score` × 100 |
|
||||
| Signal strength | `rssi` | `sensor` | dBm | `edge_vitals.rssi` |
|
||||
| Zone occupancy | `zone_occupancy` | `binary_sensor` | — | `sensing_update.zones` |
|
||||
| Pose keypoints | `pose` | `sensor` (attrs) | — | `pose_data.keypoints` (opt-in via `--mqtt-publish-pose`) |
|
||||
| Room active | `room_active` | `binary_sensor` | presence + motion >10% within a 30 s window; off after 10 min idle | after warmup |
|
||||
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min | after warmup |
|
||||
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | stillness > 2× the longest stillness seen since start (floor 30 min) | after warmup |
|
||||
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion <5% + BR ∈ [8,20] bpm for 5 min | after warmup, while breathing rate is available |
|
||||
| Possible distress | `possible_distress` | `binary_sensor` | HR ≥ 1.5× resting baseline + motion >20% + no fall, for 60 s | after warmup, while heart rate is available |
|
||||
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score: 10 per fall in 24 h + 50 × motion variance over 60 s | after warmup, while the fall detector is available |
|
||||
|
||||
Heart rate, breathing rate, and pose are **biometric** entities — they are stripped from MQTT (and never published over Matter) when `--privacy-mode` is set. See [Privacy](#privacy) below.
|
||||
Per-node motion is a three-level classification, so the motion thresholds above effectively mean "moving" or "not moving". The precision and recall figures in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md) are CLAIMED: the replay tooling they cite is not in the repository. No `reason` attribute is published yet; the explanation tags stay server-side.
|
||||
|
||||
### Semantic automation primitives (10 entities)
|
||||
### Not announced
|
||||
|
||||
These are the inferred high-level states that customer automations actually use. Each one is a small finite-state machine running server-side with explicit warmup, hysteresis, and refractory windows. Per-primitive precision/recall is published in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md).
|
||||
These entities from ADR-115 §3.1 and §3.12 are not announced, because the sensing broadcast has nothing to drive them. Announcing them only produced entities stuck at "unknown" ([#2085](https://github.com/ruvnet/RuView/issues/2085)).
|
||||
|
||||
| HA entity | Slug | HA component | What it fires on |
|
||||
|---|---|---|---|
|
||||
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion<5% + BR ∈ [8,20] bpm sustained for 5 min |
|
||||
| Possible distress | `possible_distress` | `binary_sensor` | HR > 1.5× baseline + motion >20% + no fall, sustained 60 s |
|
||||
| Room active | `room_active` | `binary_sensor` | motion >10% in a 30-s rolling window |
|
||||
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | idle > 2× observed-max-idle baseline |
|
||||
| Meeting in progress | `meeting_in_progress` | `binary_sensor` | ≥2 persons + low-amplitude motion for 10 min |
|
||||
| Bathroom occupied | `bathroom_occupied` | `binary_sensor` | presence + active zone tagged `bathroom` |
|
||||
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score; event fires on ≥70 crossing |
|
||||
| Bed exit (overnight) | `bed_exit` | `event` | sleeping → presence leaves bed zone between 22:00–06:00 |
|
||||
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min |
|
||||
| Multi-room transition | `multi_room_transition` | `event` | zone X exit + zone Y enter within 10 s |
|
||||
| Slug | Why not |
|
||||
|---|---|
|
||||
| `zone_occupancy`, `bathroom_occupied`, `bed_exit`, `multi_room_transition` | the broadcast carries no zones, and the server loads no zone-tag map |
|
||||
| `meeting_in_progress` | needs a 1–20 % motion level; per-node motion is three-level |
|
||||
| `pose` | `pose_keypoints` is never populated on the broadcast, and the `persons[].keypoints` skeletons are synthesized. `--mqtt-publish-pose` is accepted but logs a warning and announces nothing |
|
||||
|
||||
Every state change carries a `reason` attribute (e.g. `["motion<5%", "br=12bpm", "presence=true"]`) so you can template against it in HA automations to understand why an automation triggered.
|
||||
### Publish rates and retention
|
||||
|
||||
| Entity kind | When it publishes | QoS / retain |
|
||||
|---|---|---|
|
||||
| `presence` and semantic `binary_sensor`s | on change, plus a heartbeat every 60 s if unchanged | 1 / retained |
|
||||
| `person_count`, `motion_level`, `motion_energy`, `presence_score`, `fall_risk_elevated` | at most `--mqtt-rate-motion` / `--mqtt-rate-count` (default 1 Hz) | 0 / not retained |
|
||||
| `heart_rate`, `breathing_rate` | at most `--mqtt-rate-vitals` (default 0.2 Hz) | 0 / not retained |
|
||||
| `rssi` | at most `--mqtt-rate-rssi` (default 0.1 Hz) | 0 / not retained |
|
||||
| `fall` | once per detected fall | 1 / not retained |
|
||||
| availability | on every transition, and all topics every 30 s | 1 / retained |
|
||||
|
||||
Before [#2093](https://github.com/ruvnet/RuView/issues/2093), `presence` was published on every CSI frame (MEASURED 37.9 msg/s with one node, 1 msg per ~45 s after the fix; see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)).
|
||||
|
||||
### Device identity
|
||||
|
||||
Each node's HA device id is `wifi_densepose_<client-id>-node<N>`, where `<N>` is the node id from the CSI frames. Without `--mqtt-client-id`, the server creates `wifi-densepose-<8 hex digits>` on first start and keeps it in `<data-dir>/mqtt_client_id`, so device ids survive restarts. Keep `--data-dir` on persistent storage (a Docker volume, for example). If the file cannot be written, the server logs a warning and falls back to a per-process id for that run. `--mqtt-client-id` overrides the persisted id. Use it to pin ids across reinstalls or to run two servers against one broker.
|
||||
|
||||
Releases before this change used `wifi-densepose-<pid>`, so every restart created a new set of devices. To remove the leftovers, publish an empty retained message to each stale `homeassistant/+/wifi_densepose_<old-id>…/+/config` topic, or delete the devices in Home Assistant.
|
||||
|
||||
### Matter device-type mapping
|
||||
|
||||
@@ -115,7 +138,7 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
|
||||
| `--mqtt-port <PORT>` | 1883 (8883 with TLS) | Broker port |
|
||||
| `--mqtt-username <U>` | — | Username for broker auth |
|
||||
| `--mqtt-password-env <VAR>` | `MQTT_PASSWORD` | Env var holding the password |
|
||||
| `--mqtt-client-id <ID>` | `wifi-densepose-<hostname>` | MQTT client ID |
|
||||
| `--mqtt-client-id <ID>` | persisted `wifi-densepose-<hex>` in `<data-dir>/mqtt_client_id` | MQTT client ID; prefixes every HA device id |
|
||||
| `--mqtt-prefix <PREFIX>` | `homeassistant` | Discovery topic prefix |
|
||||
| `--mqtt-tls` | off | Encrypt connection |
|
||||
| `--mqtt-ca-file <PATH>` | — | Pinned CA for TLS / mTLS |
|
||||
@@ -126,43 +149,21 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
|
||||
| `--mqtt-rate-motion <HZ>` | 1.0 | Motion publish rate (Hz) |
|
||||
| `--mqtt-rate-count <HZ>` | 1.0 | Person-count publish rate (Hz) |
|
||||
| `--mqtt-rate-rssi <HZ>` | 0.1 | RSSI publish rate (Hz) |
|
||||
| `--mqtt-publish-pose` | off | Enable pose-keypoint publication |
|
||||
| `--mqtt-rate-pose <HZ>` | 1.0 | Pose publish rate when enabled |
|
||||
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT and Matter |
|
||||
| `--mqtt-publish-pose` | off | Accepted; no pose source exists, so it only logs a warning |
|
||||
| `--mqtt-rate-pose <HZ>` | 1.0 | Unused while no pose entity is announced |
|
||||
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT, Matter, REST, WebSocket and recordings |
|
||||
| `--data-dir <DIR>` | `data` | Holds `mqtt_client_id` (and other server state) |
|
||||
| `--matter` | off | Enable the HA-FABRIC Matter Bridge |
|
||||
| `--matter-setup-file <PATH>` | — | Where to write the QR + manual code |
|
||||
| `--matter-reset` | off | Wipe fabric credentials and re-commission |
|
||||
| `--matter-vendor-id <VID>` | `0xFFF1` (dev) | CSA-assigned vendor ID |
|
||||
| `--matter-product-id <PID>` | `0x8001` | Product ID |
|
||||
| `--semantic` | on | Enable inference layer |
|
||||
| `--semantic-thresholds-file <PATH>` | — | Per-primitive threshold overrides |
|
||||
| `--semantic-zones-file <PATH>` | — | Zone-tag map (`bathroom`, `bedroom`, …) |
|
||||
| `--no-semantic <PRIMITIVE>` | — | Disable a specific primitive (repeatable) |
|
||||
|
||||
### Zone tag file format
|
||||
The `--semantic`, `--semantic-thresholds-file`, `--semantic-zones-file` and `--no-semantic` flags from ADR-115 §3.12.5 are **not accepted by `sensing-server` yet**. The primitives always run with the defaults in `PrimitiveConfig`, and no zone map is loaded.
|
||||
|
||||
```yaml
|
||||
# semantic-zones.yaml — passed to --semantic-zones-file
|
||||
zones:
|
||||
bathroom: ["zone_3", "zone_7"]
|
||||
bedroom: ["zone_1"]
|
||||
kitchen: ["zone_2"]
|
||||
living: ["zone_5"]
|
||||
bed_zones: ["zone_1"]
|
||||
```
|
||||
### Zone tags and threshold overrides (not implemented)
|
||||
|
||||
### Threshold overrides
|
||||
|
||||
```yaml
|
||||
# semantic-thresholds.yaml — passed to --semantic-thresholds-file
|
||||
sleep_dwell_secs: 300
|
||||
distress_hr_multiple: 1.5
|
||||
room_active_motion_threshold: 0.10
|
||||
elderly_anomaly_multiple: 2.0
|
||||
meeting_min_persons: 2
|
||||
no_movement_dwell_secs: 1800
|
||||
fall_risk_event_threshold: 70.0
|
||||
```
|
||||
ADR-115 §3.12.5 specifies a zone-tag file and a threshold-override file. Neither is read by `sensing-server` today, which is why the zone-based primitives are not announced.
|
||||
|
||||
---
|
||||
|
||||
@@ -170,9 +171,11 @@ fall_risk_event_threshold: 70.0
|
||||
|
||||
When deploying in **healthcare**, **AAL (aging-in-place)**, or **commercial** settings, set `--privacy-mode`. This:
|
||||
|
||||
- **Strips** heart rate, breathing rate, and pose keypoints from every outbound MQTT publication.
|
||||
- **Suppresses discovery** for those entities entirely — HA never even sees they exist.
|
||||
- **Keeps every semantic primitive enabled.** Sleeping / distress / room-active / etc are *inferred* states. The inference happens server-side and only the boolean or score crosses the wire. This is the architectural win that makes the platform deployable in regulated contexts.
|
||||
- **Strips** heart rate and breathing rate from every outbound MQTT publication and, in the same way, from REST responses, WebSocket frames and recordings (pose is never announced over MQTT; see [Not announced](#not-announced)).
|
||||
- **Suppresses discovery** for those entities entirely: Home Assistant never sees them, and no availability or state topic is published for them.
|
||||
- **Keeps the semantic primitives enabled.** `someone_sleeping` and `possible_distress` still use breathing and heart rate *inside the server*; only the ON/OFF state crosses the wire (ADR-115 §3.12.3). If that is too much for your deployment, drop those two entities in Home Assistant.
|
||||
|
||||
With `--privacy-mode`, 13 entities are announced per node. Privacy mode reduces what leaves the server. It does not by itself make a deployment compliant with any regulation.
|
||||
|
||||
Always pair `--privacy-mode` with `--mqtt-tls` on non-localhost brokers.
|
||||
|
||||
@@ -210,7 +213,6 @@ action:
|
||||
title: "Possible distress detected"
|
||||
message: >
|
||||
RuView flagged sustained elevated heart rate + agitated motion.
|
||||
Reason: {{ state_attr(trigger.entity_id, 'reason') }}.
|
||||
```
|
||||
|
||||
### 2. Dim hallway when someone is sleeping
|
||||
@@ -256,6 +258,8 @@ action:
|
||||
|
||||
### 3. Wake-up routine on bed exit
|
||||
|
||||
> `bed_exit` is not published by `sensing-server` today (it needs a bed-zone map; see [Not announced](#not-announced)). This blueprint will not fire until that lands.
|
||||
|
||||
```yaml
|
||||
blueprint:
|
||||
name: RuView — wake-up routine on bed exit
|
||||
@@ -335,8 +339,8 @@ cards:
|
||||
entity: binary_sensor.ruview_kitchen_presence
|
||||
name: Kitchen
|
||||
- type: tile
|
||||
entity: binary_sensor.ruview_bathroom_occupied
|
||||
name: Bathroom
|
||||
entity: binary_sensor.ruview_bedroom_room_active
|
||||
name: Bedroom active
|
||||
```
|
||||
|
||||
---
|
||||
@@ -373,6 +377,15 @@ All three accept the same HA discovery topics RuView publishes. Performance and
|
||||
wscat -c ws://localhost:8765/ws/sensing
|
||||
```
|
||||
3. Confirm rate limits aren't dropping everything: `--mqtt-rate-vitals 1.0` for diagnosis (default 0.2 Hz = every 5 s).
|
||||
4. Unchanged binary states (presence, the semantic primitives) publish once per change and then every 60 s. A flat line in HA history is expected while nothing changes.
|
||||
|
||||
### Entity shows "unavailable"
|
||||
|
||||
The server has no source for it right now: heart and breathing rate need explicit calibration and a single occupant, `fall` needs a node that sends `edge_vitals`, and the semantic primitives wait 60 s after start (`someone_sleeping` and `possible_distress` also need breathing or heart rate). Check the availability topic with `mosquitto_sub -t 'homeassistant/+/+/<slug>/availability' -v`.
|
||||
|
||||
### Duplicate devices after a restart
|
||||
|
||||
Older releases derived device ids from the process id. See [Device identity](#device-identity) for how to remove the stale devices; the current default id is persisted in `--data-dir`.
|
||||
|
||||
### "Plaintext MQTT on non-localhost broker" WARN
|
||||
|
||||
@@ -391,7 +404,7 @@ Per [ADR-115 §3.9](../adr/ADR-115-home-assistant-integration.md#39-tls--auth),
|
||||
|
||||
## Applications — what people actually do with this
|
||||
|
||||
The 21 entities per node — 11 raw signals (presence, person count, breathing, heart rate, motion, RSSI, etc.) and 10 inferred semantic states (someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting-in-progress, bathroom-occupied, fall-risk-elevated, bed-exit, no-movement, multi-room-transition) — slot into Home Assistant like any other sensor. The list below groups real-world uses so you can pick the ones that match your space.
|
||||
The 15 entities per node (9 raw signals and 6 semantic states, see the [Entity reference](#entity-reference)) slot into Home Assistant like any other sensor. Some uses below rely on entities that are not announced yet (zones, bathroom, bed exit, meeting); those wait on a zone source or a finer motion level. The list below groups real-world uses so you can pick the ones that match your space.
|
||||
|
||||
### Personal & home
|
||||
|
||||
@@ -495,13 +508,9 @@ A few patterns appear over and over; if you understand these you can build most
|
||||
|
||||
### What about regulated environments?
|
||||
|
||||
Run RuView with `--privacy-mode` and only the 10 inferred semantic states reach Home Assistant — heart rate, breathing rate, and pose values are stripped at the MQTT wire. Per ADR-115 §6, this passes:
|
||||
With `--privacy-mode`, heart rate and breathing rate never reach the broker; their entities are not even announced. The semantic states still reach Home Assistant, and two of them (`someone_sleeping`, `possible_distress`) are derived from those vitals inside the server, so their ON/OFF values are themselves health-related information.
|
||||
|
||||
- **HIPAA-style minimum-necessary** (no biometric numbers leave the device)
|
||||
- **GDPR purpose-limitation** (the inferred states are the smallest dataset that supports the automation)
|
||||
- **CCPA "sensitive personal information"** (no health data crosses the wire)
|
||||
|
||||
The fall-risk-elevated / possible-distress / someone-sleeping flags still work — they're computed *inside* the sensor pipeline and only the boolean outputs are published. That's the architectural win that makes RuView deployable in care homes, hospitals, schools, and shared-housing scenarios where raw biometrics would be a non-starter.
|
||||
Privacy mode narrows what crosses the MQTT wire. It is not a compliance certification, and RuView makes no HIPAA, GDPR or CCPA compliance claim. Whether a deployment meets a regulation depends on the whole system: broker access control and TLS, Home Assistant's recorder retention, who can see the dashboards, consent, and local law. Assess that with whoever is responsible for compliance in your setting.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# MediaTek Router CSI (MT7981, experimental)
|
||||
|
||||
RuView can take channel state information (CSI) from a MediaTek Filogic
|
||||
router, such as an MT7981B with an MT7976C radio running OpenWrt with
|
||||
MediaTek's vendor CSI patch. The router reports CSI. A host-side bridge
|
||||
converts it to RuView's MTC1 wire format (ADR-267). The sensing server ingests
|
||||
it like any other UDP source.
|
||||
|
||||
**Status: experimental.** This page documents a working data path, measured
|
||||
on one board model. It makes no sensing-quality claim. Frames from this path
|
||||
carry the provenance label `mediatek:physical-unvalidated`. The ADR-266
|
||||
hardware gates are still open: calibration, sequence, timestamp, and
|
||||
repeatability.
|
||||
|
||||
Developed in whitsentry, a RuView-based project.
|
||||
|
||||
Evidence tags used below: **MEASURED** (with a reproducer), **CODE-DERIVED**
|
||||
(read from source or build scripts, not run here), **CLAIMED** (stated, not
|
||||
independently verified), **SYNTHETIC** (simulated or fabricated data).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Supported hardware](#supported-hardware)
|
||||
- [How the path works](#how-the-path-works)
|
||||
- [Build the OpenWrt image](#build-the-openwrt-image)
|
||||
- [Arm CSI on the router](#arm-csi-on-the-router)
|
||||
- [Run the bridge](#run-the-bridge)
|
||||
- [Start the sensing server](#start-the-sensing-server)
|
||||
- [What the data is, and is not](#what-the-data-is-and-is-not)
|
||||
- [Evidence](#evidence)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Reference](#reference)
|
||||
|
||||
## Supported hardware
|
||||
|
||||
| Board | SoC + radio | Status |
|
||||
|---|---|---|
|
||||
| Wavlink WL-WN586X3 **Rev A** | MT7981BA + MT7976CN (switch MT7531AE) | Run on two units. CSI captured from both is analysed offline below (MEASURED). Live delivery to the sensing server is CLAIMED from local run logs that are not in the tree. |
|
||||
| OpenWrt One | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table and ADR-266. Not run with this patch set. |
|
||||
| Xiaomi AX3000T | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table. Not run with this patch set. |
|
||||
| Other MT7981/MT7986 boards | — | Untested. The CSI patch targets the `mt7915` driver family, so other Filogic radios may work, but nothing here shows that. |
|
||||
|
||||
The board patch in `firmware/openwrt-wn586x3/` is specific to the WN586X3
|
||||
**Rev A**. The CSI driver patch and the two userspace fixes do not depend on
|
||||
the board.
|
||||
|
||||
Each MT7976C radio has **two receive chains**. The WN586X3's four external
|
||||
antennas are a 2.4 GHz pair and a 5 GHz pair. It is not a 4x4 array, and two
|
||||
routers are two independent receivers, not one phased array.
|
||||
|
||||
## How the path works
|
||||
|
||||
```
|
||||
router (OpenWrt 24.10.8) host
|
||||
┌────────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ mt76 + MediaTek CSI patch │ │ wifi-densepose-mtk-bridge │
|
||||
│ firmware MCU event 0xc2 │ JSON dump │ groups chains into frames, │
|
||||
│ mt76-vendor dump csi ─────────┼──────────────►│ applies provenance gate, │
|
||||
│ CSIdump (UDP, optional) ───────┼──── UDP ─────►│ emits ADR-267 MTC1 │
|
||||
└────────────────────────────────┘ └──────────────┬───────────────┘
|
||||
│ UDP :5005
|
||||
┌──────────────▼───────────────┐
|
||||
│ sensing-server │
|
||||
│ --source mediatek │
|
||||
│ /api/v1/csi/mediatek/* │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
Two transports leave the router:
|
||||
|
||||
- **`mt76-vendor <iface> dump csi <n> <file>`** writes a JSON array of
|
||||
per-chain records: timestamp, transmitter address, RSSI, SNR, bandwidth,
|
||||
PPDU mode, tx/rx chain index, chain marker, and I/Q. This is the richer
|
||||
path, and the one that carried sustained use. You copy the file to the host
|
||||
(for example, a loop over SSH) and replay it through the bridge.
|
||||
- **`CSIdump`** (MtkCSIdump) streams one antenna per UDP datagram and drops
|
||||
RSSI, PPDU mode, and transmitter identity. The bridge emits one 1x1 frame per
|
||||
datagram. This path was verified end to end once, as a recording. It has not
|
||||
run as a standing service.
|
||||
|
||||
## Build the OpenWrt image
|
||||
|
||||
The patch set lives in [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md).
|
||||
It is not a firmware image. Build and hash your own.
|
||||
|
||||
| Patch | Applies to |
|
||||
|---|---|
|
||||
| `1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | `mt76` package (`package/kernel/mt76/patches/`) |
|
||||
| `mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor` source (`feed/app/mt76-vendor/src/csi.c` in `mediatek/mtk-openwrt-feeds`) |
|
||||
| `csidump-nl80211-attr-enum.patch` | MtkCSIdump `src/wifi_drv_api/mt76_api.cpp` (only if you use CSIdump) |
|
||||
| `0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | `target/linux/mediatek` (WN586X3 Rev A only) |
|
||||
|
||||
The route that produced the tested image (CODE-DERIVED from its build scripts)
|
||||
used the stock OpenWrt **24.10.8** `mediatek/filogic` SDK and ImageBuilder in
|
||||
an x86_64 Linux container:
|
||||
|
||||
1. **SDK.** Unpack `openwrt-sdk-24.10.8-mediatek-filogic`, then run
|
||||
`./scripts/feeds update -a && ./scripts/feeds install -a`.
|
||||
2. **CSI driver.** Copy the rebased `1001-...` patch into
|
||||
`feeds/base/package/kernel/mt76/patches/`. Raise `PKG_RELEASE` in that
|
||||
package's `Makefile` (the tested build used `99`) so the ImageBuilder
|
||||
prefers your build over the CDN package of the same version.
|
||||
3. **mt76-vendor.** Add MediaTek's `mt76-vendor` package from
|
||||
`mtk-openwrt-feeds` as `package/mt76-vendor`, with the bounds patch applied
|
||||
to `src/csi.c`.
|
||||
4. **Configure and compile.** Select `CONFIG_TARGET_mediatek_filogic_DEVICE_wavlink_wl-wn586x3`,
|
||||
`kmod-mt7915e`, `mt76-vendor`, and `libnl-tiny`. Then run
|
||||
`make package/feeds/base/mt76/compile package/mt76-vendor/compile`.
|
||||
5. **Image.** Copy the built `.ipk` files into the ImageBuilder's `packages/`
|
||||
and run:
|
||||
```bash
|
||||
make image PROFILE=wavlink_wl-wn586x3 \
|
||||
PACKAGES="kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware mt76-vendor libnl-tiny"
|
||||
```
|
||||
6. **Check the manifest.** The image's `.manifest` must list `kmod-mt7915e`
|
||||
at your raised release (for example `-r99`) and `mt76-vendor`. If it lists
|
||||
the CDN release, the image has no CSI.
|
||||
|
||||
**Rev A board patch.** An ImageBuilder cannot compile a device tree, and its
|
||||
`make image` reuses the prebuilt DTB and FIT kernel. The tested Rev A build
|
||||
compiled the patched DTB itself, rebuilt the FIT kernel around it, added the
|
||||
patched `lib/upgrade/platform.sh` as a rootfs overlay, then checked that the
|
||||
final sysupgrade image embedded that DTB. A full OpenWrt buildroot applies
|
||||
`target/` patches directly and avoids those steps. That route was not tested
|
||||
here.
|
||||
|
||||
Flash only a Rev A unit, keep the stock firmware backup, and follow the
|
||||
OpenWrt wiki and forum thread for this board. Neither this guide nor the patch
|
||||
set names a LAN, an account, or a password.
|
||||
|
||||
**Firmware blobs.** The CSI report is MCU event `0xc2`. The MT7981 firmware in
|
||||
the tested image (`kmod-mt7981-firmware`, `mt7981-wo-firmware` from OpenWrt
|
||||
24.10.8) already emits it, so no firmware blob swap was needed on the tested
|
||||
board. Other firmware versions are untested.
|
||||
|
||||
## Arm CSI on the router
|
||||
|
||||
CSI follows traffic from **associated clients**. With nothing associated and
|
||||
transmitting, there is no CSI. Run these on the router, as root, against the AP
|
||||
interface (`phy0-ap0` was the 2.4 GHz AP on the tested board). The sequence
|
||||
below is CODE-DERIVED from the capture scripts used on the tested board:
|
||||
|
||||
```sh
|
||||
mt76-vendor phy0-ap0 set csi ctrl=0,0,0,0 # disarm and reset
|
||||
mt76-vendor phy0-ap0 set csi interval=1000
|
||||
mt76-vendor phy0-ap0 set csi ctrl=2,3,0,34 # frame-type filter, as used on
|
||||
mt76-vendor phy0-ap0 set csi ctrl=2,9,1,0 # the tested board (data frames)
|
||||
mt76-vendor phy0-ap0 set csi ctrl=1,0,0,0 # arm
|
||||
```
|
||||
|
||||
Check that the driver is collecting:
|
||||
|
||||
```sh
|
||||
cat /sys/kernel/debug/ieee80211/phy0/mt76/csi_stats # data_cnt should climb
|
||||
iw dev phy0-ap0 station dump | grep -E "Station|signal"
|
||||
```
|
||||
|
||||
Pull a batch:
|
||||
|
||||
```sh
|
||||
rm -f /tmp/csi.json # the tool APPENDS; remove first
|
||||
mt76-vendor phy0-ap0 dump csi 64 /tmp/csi.json
|
||||
```
|
||||
|
||||
`dump csi` needs the filename argument. Without it, the tool returns nothing.
|
||||
|
||||
## Run the bridge
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo build --release -p wifi-densepose-mtk-bridge
|
||||
B=./target/release/wifi-densepose-mtk-bridge
|
||||
```
|
||||
|
||||
**Dump path (recommended).** Copy `/tmp/csi.json` to the host, then replay it.
|
||||
A file cannot prove it came off a radio, so `--replay` refuses to start until
|
||||
you either attest the hardware or declare the file synthetic:
|
||||
|
||||
```bash
|
||||
$B --replay csi.json --captured-on <model>/<firmware> \
|
||||
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005
|
||||
```
|
||||
|
||||
- `--node` is hashed into the 64-bit `device_id`. Give each router its own name.
|
||||
- `--center-freq-khz` is the AP channel's centre frequency, for example
|
||||
`2437000` for 2.4 GHz channel 6.
|
||||
- `--captured-on` takes `<model>/<firmware>`, for example
|
||||
`WN586X3-RevA/openwrt-24.10.8-csi`. It is logged at startup.
|
||||
|
||||
For a continuous feed, repeat dump, copy, and replay in a loop, one loop per
|
||||
router. Keep a copy of each dump only if you need it, and never commit one
|
||||
(see below).
|
||||
|
||||
**Streaming path (CSIdump).** Start `CSIdump phy0-ap0 <rate> 8888` on the
|
||||
router. Then, on the host:
|
||||
|
||||
```bash
|
||||
$B --listen 0.0.0.0:8888 --csidump <router-ip>:8888 --listen-allow <router-ip> \
|
||||
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005 \
|
||||
--record captures/<node-name>.mtkcap
|
||||
```
|
||||
|
||||
`--listen-allow` is mandatory. Datagrams from any other source are dropped and
|
||||
counted. CSIdump disarms CSI when it receives SIGTERM. The dump path and
|
||||
CSIdump cannot share the driver's ring, so run one or the other.
|
||||
|
||||
**No router.** The fabricated fixture exercises the whole host path. It must
|
||||
be replayed with `--synthetic`, and the server labels it `mediatek:simulated`:
|
||||
|
||||
```bash
|
||||
$B --replay crates/wifi-densepose-mtk-bridge/fixtures/FABRICATED-mt76-vendor-dump-mt7981-bw80.synthetic.json \
|
||||
--synthetic --node demo --sink 127.0.0.1:5005
|
||||
```
|
||||
|
||||
The ADR-266 simulator is another synthetic source:
|
||||
|
||||
```bash
|
||||
cargo run -p wifi-densepose-hardware --bin mediatek-csi-sim -- \
|
||||
--profile mt7981 --udp 127.0.0.1:5005 --frames 1000 --realtime
|
||||
```
|
||||
|
||||
Full flag reference: [`v2/crates/wifi-densepose-mtk-bridge/README.md`](../v2/crates/wifi-densepose-mtk-bridge/README.md).
|
||||
|
||||
## Start the sensing server
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo run --release -p wifi-densepose-sensing-server -- --source mediatek \
|
||||
--activity-state <state-dir>/activity-floor.json
|
||||
```
|
||||
|
||||
- `--source mediatek` binds the UDP receiver (loopback by default, ADR-296) and
|
||||
does **not** start the ESP32-shaped simulator. Before this source existed,
|
||||
`--source mediatek` bound nothing.
|
||||
- If the bridge runs on another host, bind a routable address with
|
||||
`--udp-bind` and name the bridge host in `--udp-allow`.
|
||||
- `--activity-state` is optional. It persists each device's learned
|
||||
quiet-room activity floor so that a restart does not relearn it.
|
||||
- `--csi-ring <n>` sets frames retained per device for inspection (default
|
||||
256, about 0.5 MiB per 2x2 device at 64 subcarriers).
|
||||
|
||||
Endpoints:
|
||||
|
||||
| Route | Returns |
|
||||
|---|---|
|
||||
| `GET /api/v1/csi/mediatek/latest` | Last MTC1 snapshot, any device |
|
||||
| `GET /api/v1/csi/mediatek/devices` | One snapshot per `device_id`, with `age_ms`. Devices silent for over 30 s are omitted. |
|
||||
| `GET /api/v1/csi/mediatek/devices/:device_id/frames?n=&fields=amp,phase` | Newest `n` frames of per-chain, per-subcarrier amplitude and phase |
|
||||
| `GET /api/v1/csi/mediatek/devices/:device_id/summary` | Per-chain amplitude mean/std per subcarrier, RSSI per Rx chain, frame rate, heuristic window stats |
|
||||
| `GET /api/v1/csi/mediatek/activity?seconds=300` | About 1 Hz history of the relative activity index |
|
||||
| `GET /api/v1/sensing/latest` | Room update. On this source, `classifier` is `mediatek-amplitude-heuristic-v0`. |
|
||||
|
||||
Check the label on every snapshot:
|
||||
|
||||
| `source` | Meaning |
|
||||
|---|---|
|
||||
| `mediatek:simulated` | `SYNTHETIC` flag set. Nothing downstream can clear it. |
|
||||
| `mediatek:physical-unvalidated` | Real silicon, not calibrated. Everything this path produces today. |
|
||||
| `mediatek` | Real and calibrated. Reserved. Nothing emits it yet. |
|
||||
|
||||
## What the data is, and is not
|
||||
|
||||
**It is** complex channel estimates per receive chain from a real MT7976C
|
||||
radio, carried end to end with provenance intact.
|
||||
|
||||
**It is not:**
|
||||
|
||||
- **A sensor with its own clock.** The report rate is the clients' traffic
|
||||
rate. An idle phone gives a few frames per second, and a busy one gives
|
||||
hundreds. With no associated, transmitting client, there is no data. A
|
||||
stationary device that keeps transmitting to the AP (an "illuminator", for
|
||||
example an ESP32 sending a steady packet stream) gives a regular rate
|
||||
(CLAIMED: the 09-20 and 09-24 captures below include an illuminator's
|
||||
frames; transmitter identity was not re-checked in this analysis).
|
||||
- **Calibrated.** `scale` is 1.0 in raw firmware units. Nothing has been
|
||||
checked against a reference. The ADR-266 gates are open.
|
||||
- **Validated for presence, motion, or vital signs.** The room output on this
|
||||
source comes from `mediatek-amplitude-heuristic-v0` (presence and motion from
|
||||
mean amplitude) and `activity-index-v0` (fluctuation against a learned
|
||||
floor). Both are **CLAIMED**. Neither has been scored against labelled ground
|
||||
truth. `activity` blocks carry `is_occupancy_estimate: false`. These outputs
|
||||
do not feed the validated ESP32 vital-sign pipeline.
|
||||
- **Coherent across routers.** Each radio has its own oscillator. Cross-chain
|
||||
phase is meaningful within one radio, not between two boxes.
|
||||
- **Free of personal data.** A raw dump holds client transmitter addresses
|
||||
(MACs) and channel samples that encode movement. RuView policy (ADR-299)
|
||||
forbids committing captures. Keep them out of the repository, and run
|
||||
`scripts/csi-data-policy-check.sh --staged` before committing near capture
|
||||
directories.
|
||||
|
||||
## Evidence
|
||||
|
||||
| Claim | Tag | Basis |
|
||||
|---|---|---|
|
||||
| A real WL-WN586X3 frame decodes and reaches the server as `mediatek:physical-unvalidated` | MEASURED (path) | First record, 2026-09-19: legacy OFDM, 20 MHz, 64 subcarriers, one chain. Its shape is pinned by `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`, which uses a fabricated record. The capture itself is not in the tree. |
|
||||
| Cross-chain phase is steady frame to frame on one MT7976C radio, and raw per-chain phase is not | MEASURED (offline) | See below |
|
||||
| `mediatek-amplitude-heuristic-v0`, `activity-index-v0` | CLAIMED | No labelled evaluation |
|
||||
| ADR-266 simulator and bridge fixtures | SYNTHETIC | Labelled on the wire |
|
||||
|
||||
**Cross-chain phase stability.** Six local dump captures from two WN586X3 units,
|
||||
2.4 GHz, 20 MHz HT frames carrying chains (tx0, rx0) and (tx0, rx1), adjacent
|
||||
frames under 100 ms apart, 56 subcarriers (DC and guard bins excluded). The
|
||||
two 2026-09-19 captures were analysed whole; the four 09-20 and 09-24 captures
|
||||
by their first 20 MB each.
|
||||
|
||||
| Quantity, per adjacent-frame step | Well-sampled transmitters (6, at 825 to 14,104 frames each) |
|
||||
|---|---|
|
||||
| Raw per-chain phase `arg(H0[k])`, per bin, p50 | 1.557 to 1.601 rad, with 49.6 to 50.9 % of steps over π/2 |
|
||||
| Cross-chain phase `arg(H0[k]·conj(H1[k]))`, per bin, p50 | 0.023 to 0.056 rad |
|
||||
| Cross-chain phase summed coherently over bins, p50 / p90 / p99 | 0.014–0.039 / 0.205–0.358 / 0.437–0.820 rad |
|
||||
|
||||
Uniformly random phase gives a step p50 of π/2 = 1.571 rad, and half of all
|
||||
steps exceed π/2. Raw per-chain phase is indistinguishable from that, as
|
||||
expected with per-packet CFO and phase offset. In the cross-chain term those
|
||||
offsets cancel, and the step drops to hundredths of a radian. Steps are
|
||||
wrapped, so p99 can never exceed π, and is not evidence by itself.
|
||||
|
||||
Transmitters with few, irregular frames (229 to 595 frames) were much worse:
|
||||
coherent-sum p50 0.177 to 0.456 rad, p99 1.857 to 2.849 rad. So the result
|
||||
holds only with a steady frame rate.
|
||||
|
||||
This shows a **stable phase observable exists** within one radio. It does
|
||||
**not** show that the observable tracks people, breathing, or anything else.
|
||||
|
||||
Reproduce it on your own capture (one `dump csi` array per line):
|
||||
|
||||
```bash
|
||||
python3 scripts/mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
|
||||
```
|
||||
|
||||
The script prints transmitters as `TX-A`, `TX-B`, and so on, never addresses.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `csi_stats` `data_cnt` stays at 0 | No associated client transmitting, or CSI not armed | Associate a client and generate traffic, then re-run the arm sequence |
|
||||
| `data_cnt` stopped after a client roamed or the filter changed | Changing the CSI filter stops capture | Re-run the arm sequence ending in `ctrl=1,0,0,0`. Re-arming periodically is a reasonable guard. |
|
||||
| `mt76-vendor dump csi` prints nothing | No filename argument | Use `dump csi <n> <file>` |
|
||||
| `mt76-vendor dump csi` segfaults | Unpatched tool: the driver can return n+1 records | Rebuild with `mt76-vendor-csi-dump-bounds.patch` |
|
||||
| Bridge warns about repeated `(ta, ts, tx_idx, rx_idx)` chains | The dump file was not removed between dumps, and the tool appends | `rm -f` the file before every dump |
|
||||
| `--replay` refuses to start | Provenance not declared | Add `--captured-on <model>/<firmware>`, or `--synthetic` for fabricated input |
|
||||
| `--captured-on` is rejected | The input declares itself synthetic (sidecar, `.synthetic.` name, or capture header) | Expected. Synthetic is sticky. Use `--synthetic`. |
|
||||
| `--listen` refuses to start | No `--listen-allow` | Name the router's address or subnet |
|
||||
| `rejected_source` counts climb on `--listen` | The router's address is not in `--listen-allow` | Fix the allowlist |
|
||||
| CSIdump runs but nothing arrives | Unpatched CSIdump uses an off-by-one netlink attribute enum against this driver | Rebuild with `csidump-nl80211-attr-enum.patch` |
|
||||
| CSI stopped after CSIdump exited | CSIdump disarms CSI on SIGTERM | Re-run the arm sequence |
|
||||
| `/api/v1/csi/mediatek/devices` is empty | Server not on `--source mediatek` (or `auto` without UDP), wrong `--sink` port, or a non-loopback bridge not in `--udp-allow` | Check `--source`, port 5005, `--udp-bind`, and `--udp-allow` |
|
||||
| Two routers show as one device | Same `--node` on both bridges | Give each router its own `--node` (or `--device-id`) |
|
||||
| A frame shows `ppdu_type: legacy` | Normal. Association and management traffic is pre-HT. | None. ADR-267 Amendment 1 carries these frames rather than dropping them. |
|
||||
| Room status reads `unknown` for a device | The heuristic abstains below its confidence threshold | Expected with sparse frames. It is not an "absent" call. |
|
||||
| Room reads `absent` with `confidence: 0.0` | Every device abstained, so there is no evidence either way | Treat confidence 0 as "no information", not as an empty room |
|
||||
| Image flashes, but there is no `mt76-vendor` or CSI | The ImageBuilder used the CDN `kmod-mt7915e` | Check the manifest for your raised `PKG_RELEASE` |
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-266: MediaTek Filogic CSI platform](adr/ADR-266-mediatek-filogic-csi-platform.md) (Amendment 1: vendor CSI on MT7981)
|
||||
- [ADR-267: MediaTek MIMO CSI wire protocol](adr/ADR-267-mediatek-mimo-csi-wire-protocol.md) (Amendment 1: `Legacy` and `Unknown` PPDU types)
|
||||
- [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md): patches, licences, origins
|
||||
- [`v2/crates/wifi-densepose-mtk-bridge/`](../v2/crates/wifi-densepose-mtk-bridge/README.md): bridge transports, provenance rules, frame mapping
|
||||
- [MediaTek OpenWrt feeds](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/), [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump), [OpenWrt One](https://openwrt.org/toh/openwrt/one)
|
||||
@@ -0,0 +1,111 @@
|
||||
# RuView ESP32 firmware 0.8.8
|
||||
|
||||
Firmware 0.8.8 is a reliability and correctness release for ESP32-S3 and
|
||||
ESP32-C6 RuView nodes. It makes the timing used by signal processing explicit,
|
||||
prevents contradictory occupancy output, and improves update diagnostics.
|
||||
|
||||
## What changed
|
||||
|
||||
### Empty means zero people
|
||||
|
||||
Older firmware could report `presence=false` and a nonzero person count in the
|
||||
same edge packet. That was internally contradictory and could contaminate an
|
||||
empty-room calibration. Firmware 0.8.8 clears the count whenever the presence
|
||||
gate is closed. The sensing server repeats the same check when it receives data
|
||||
from older nodes.
|
||||
|
||||
This is a consistency fix, not proof that the heuristic can count multiple
|
||||
people accurately. See
|
||||
[ADR 346](../adr/ADR-346-fail-closed-edge-occupancy-evidence.md).
|
||||
|
||||
### Stable time scale on ESP32-C6
|
||||
|
||||
Raw CSI and on-device signal processing now have separate clocks. The C6 keeps
|
||||
raw CSI moving over the network while its Tier 2 filters process a stable 8 Hz
|
||||
sample stream. The S3 retains its 20 Hz DSP default. A phase-preserving sampler
|
||||
keeps callback jitter from shifting those clocks.
|
||||
|
||||
The result is a correct time base for motion and vital-band features. It does
|
||||
not by itself prove that heartbeat, respiration, gesture, or pose estimates are
|
||||
more accurate. See
|
||||
[ADR 347](../adr/ADR-347-rate-aware-esp32-temporal-sensing.md).
|
||||
|
||||
### Better diagnostics and safer updates
|
||||
|
||||
The one-second controller log now shows both raw callback yield and DSP rate.
|
||||
The OTA status endpoint reports the actual selected application partition size
|
||||
instead of a fixed 900 KB assumption. Firmware upload remains fail closed when
|
||||
the node has no provisioned OTA signing secret.
|
||||
|
||||
## Measured hardware validation
|
||||
|
||||
All results below are physical measurements from 2026-08-31. They are not
|
||||
simulator claims.
|
||||
|
||||
| Board | Duration | Raw CSI mean | DSP clock | Live coverage | Steady-state transport errors |
|
||||
|-------|---------:|-------------:|----------:|--------------:|------------------------------:|
|
||||
| ESP32-C6 node 4 | 300.64 s | 34.92 pps | 8.00 Hz | 97.62% | 0 |
|
||||
| ESP32-C6 node 7 | 300.70 s | 36.32 pps | 8.00 Hz | 97.40% | 0 |
|
||||
| ESP32-S3 node 1 | 300 s | 28.03 pps | Tier 0 | 100.00% | 0 |
|
||||
|
||||
The first C6 empty-room qualification observed 61 absent packets with zero
|
||||
nonzero counts. The second C6 was transport-qualified in an occupied room and
|
||||
still needs its own controlled empty-room sequence. Full evidence is recorded
|
||||
in:
|
||||
|
||||
1. [C6 timing and transport](../validation/2026-08-31-esp32-c6-rate-aware-sensing.md)
|
||||
2. [C6 occupancy integrity](../validation/2026-08-31-esp32-c6-occupancy-integrity.md)
|
||||
3. [Second C6 timing and transport](../validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md)
|
||||
4. [S3 transport](../validation/2026-08-31-esp32-s3-rate-aware-transport.md)
|
||||
|
||||
## Choose the correct download
|
||||
|
||||
| Release file | Target |
|
||||
|--------------|--------|
|
||||
| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | ESP32-S3 with 8 MB flash |
|
||||
| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | ESP32-S3 with 4 MB flash |
|
||||
| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | ESP32-C6 using the supported 4 MB partition layout |
|
||||
| `esp32-csi-node-v0.8.8-s3-8mb.bin` | S3 8 MB application only |
|
||||
| `esp32-csi-node-v0.8.8-s3-4mb.bin` | S3 4 MB application only |
|
||||
| `esp32-csi-node-v0.8.8-c6-4mb.bin` | C6 application only |
|
||||
|
||||
Never mix S3 and C6 images. Confirm the chip and physical flash before writing.
|
||||
|
||||
## Install or update
|
||||
|
||||
For a fresh installation, extract the matching bundle and follow its included
|
||||
`FLASHING.md`. The standard offsets are:
|
||||
|
||||
| Image | Offset |
|
||||
|-------|-------:|
|
||||
| Bootloader | `0x0000` |
|
||||
| Partition table | `0x8000` |
|
||||
| OTA metadata | `0xf000` |
|
||||
| Application | `0x20000` |
|
||||
|
||||
For an existing provisioned node:
|
||||
|
||||
1. Back up the current application partition.
|
||||
2. Confirm the exact chip, flash layout, logical node, and serial port.
|
||||
3. Read `http://DEVICE_IP:8032/ota/status`.
|
||||
4. Use an application-only serial update at `0x20000` only when the running
|
||||
partition is `ota_0` and the image matches the board.
|
||||
5. Reboot and confirm version 0.8.8, the preserved node identity, channel, and
|
||||
sensing-server target.
|
||||
6. Run a five-minute burn-in before returning the node to calibration duty.
|
||||
|
||||
The full bundle does not contain an NVS image. A four-offset install therefore
|
||||
preserves the existing WiFi and node settings, but operators should still keep
|
||||
a backup before changing firmware.
|
||||
|
||||
## What this release does not prove
|
||||
|
||||
Firmware 0.8.8 does not prove medical-grade vital signs, accurate person
|
||||
counting, identity, dense pose, through-wall video, or room separation. Those
|
||||
claims require synchronized references and leakage-free held-out sequences.
|
||||
|
||||
The practical next acceptance test is a controlled empty-room capture with at
|
||||
least 30 absent edge packets per updated node, zero absent packets carrying a
|
||||
nonzero count, and zero transport or parser errors. Accuracy evaluation then
|
||||
needs held-out occupied, movement, heartbeat-reference, and adjacent-room
|
||||
sequences.
|
||||
@@ -0,0 +1,10 @@
|
||||
ARM_A1_control,30,1242,2162,0.5745,72.07
|
||||
ARM_A1_control,30,1199,2114,0.5672,70.47
|
||||
ARM_A1_control,30,1259,2223,0.5664,74.10
|
||||
ARM_A1_control,30,1221,2206,0.5535,73.53
|
||||
ARM_A1_control,30,1246,2182,0.5710,72.73
|
||||
ARM_A1_control,30,1295,2247,0.5763,74.90
|
||||
ARM_A1_control,30,1307,2242,0.5830,74.73
|
||||
ARM_A1_control,30,1232,2191,0.5623,73.03
|
||||
ARM_A1_control,30,1261,2225,0.5667,74.17
|
||||
ARM_A1_control,30,1152,2107,0.5467,70.23
|
||||
|
@@ -0,0 +1,10 @@
|
||||
ARM_A2_control,30,1352,2326,0.5813,77.53
|
||||
ARM_A2_control,30,1454,2429,0.5986,80.97
|
||||
ARM_A2_control,30,1474,2479,0.5946,82.63
|
||||
ARM_A2_control,30,1412,2456,0.5749,81.87
|
||||
ARM_A2_control,30,1387,2443,0.5677,81.43
|
||||
ARM_A2_control,30,1389,2384,0.5826,79.47
|
||||
ARM_A2_control,30,1399,2482,0.5637,82.73
|
||||
ARM_A2_control,30,1410,2375,0.5937,79.17
|
||||
ARM_A2_control,30,1509,2537,0.5948,84.57
|
||||
ARM_A2_control,30,1483,2541,0.5836,84.70
|
||||
|
@@ -0,0 +1,10 @@
|
||||
ARM_B_filtered,30,734,1062,0.6911,35.40
|
||||
ARM_B_filtered,30,700,982,0.7128,32.73
|
||||
ARM_B_filtered,30,734,1002,0.7325,33.40
|
||||
ARM_B_filtered,30,706,927,0.7616,30.90
|
||||
ARM_B_filtered,30,730,955,0.7644,31.83
|
||||
ARM_B_filtered,30,712,915,0.7781,30.50
|
||||
ARM_B_filtered,30,734,971,0.7559,32.37
|
||||
ARM_B_filtered,30,742,1103,0.6727,36.77
|
||||
ARM_B_filtered,30,812,1139,0.7129,37.97
|
||||
ARM_B_filtered,30,785,1114,0.7047,37.13
|
||||
|
@@ -0,0 +1,63 @@
|
||||
# WiFlow Reproduction Protocol
|
||||
|
||||
Status: Proposed research protocol
|
||||
|
||||
Tracks issue #1786.
|
||||
|
||||
## Objective
|
||||
|
||||
Determine whether a CSI optical flow style representation improves reusable camera free motion intelligence in RuView rather than only improving a proxy flow metric.
|
||||
|
||||
## Required capture metadata
|
||||
|
||||
Every run records hardware family, firmware, antenna count and geometry, channel width, center frequency, packet rate, timestamp source, synchronization method, room identity, participant identity class, label source, calibration, packet loss, missing subcarriers, preprocessing version, model commit, accelerator, software versions, power mode, and random seeds.
|
||||
|
||||
## Baselines
|
||||
|
||||
1. current RuView CSI preprocessing plus strongest task specific sensing head
|
||||
2. WiFlow style motion field representation
|
||||
3. simpler temporal derivative or phase and amplitude motion baseline
|
||||
|
||||
All conditions use identical train, validation, and held out environment splits.
|
||||
|
||||
## Metrics
|
||||
|
||||
Primary intermediate metrics:
|
||||
|
||||
* endpoint error or equivalent flow error
|
||||
* directional consistency
|
||||
* temporal stability
|
||||
* uncertainty calibration
|
||||
|
||||
Required downstream metrics:
|
||||
|
||||
* tracking quality
|
||||
* occupancy or presence quality
|
||||
* pose or activity metric where available
|
||||
* spatial state consistency in RuField or WorldGraph
|
||||
|
||||
Systems metrics:
|
||||
|
||||
* p50 and p95 inference latency
|
||||
* peak memory
|
||||
* CPU, GPU, or accelerator utilization
|
||||
* energy per inference where measurable
|
||||
* bytes per observation and sustained bandwidth
|
||||
|
||||
## Stress tests
|
||||
|
||||
Test unseen rooms, unseen participants, changed antenna geometry, a second RF hardware family, packet loss, burst loss, missing subcarriers, lower packet rate, clock jitter, and partial calibration failure.
|
||||
|
||||
## Promotion gate
|
||||
|
||||
The representation graduates only when the same learned representation improves at least two downstream RuView tasks on held out environments, or reaches equivalent downstream quality with materially lower edge cost.
|
||||
|
||||
A better optical flow proxy score alone is insufficient.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
Visual or LiDAR labels may be used as privileged training evidence but must not become a runtime dependency for camera free deployment claims. Dataset provenance and consent requirements remain explicit.
|
||||
|
||||
## Rollback
|
||||
|
||||
The experiment is isolated behind a representation interface. Existing RuView sensing heads remain the production fallback.
|
||||
@@ -0,0 +1,511 @@
|
||||
# RuView Forecast clean-room, data, and release protocol
|
||||
|
||||
**Status:** Proposed and mandatory for any artifact claiming independent
|
||||
development under ADR-348.
|
||||
|
||||
**Evidence status:** This document defines controls. It does not assert that the
|
||||
controls have passed, that a trained model exists, or that any forecasting
|
||||
capability is measured. Every ADR-348 gate remains **OPEN / UNMEASURED** until a
|
||||
signed evidence bundle proves otherwise.
|
||||
|
||||
## 1. Purpose and authority
|
||||
|
||||
This protocol governs the specification, implementation, training, evaluation,
|
||||
hosting, and release of the independent Rust multivariate forecaster defined by
|
||||
[ADR-348](../adr/ADR-348-independent-rust-multivariate-forecasting.md).
|
||||
It applies to source, issue and review text, prompts, dependencies, datasets,
|
||||
intermediate tensors, checkpoints, containers, CI caches, RuVector indexes,
|
||||
fal.ai jobs, benchmark results, model cards, and published artifacts.
|
||||
|
||||
The protocol is intentionally stricter than the minimum conditions for using
|
||||
Apache-licensed source. Its purpose is to preserve evidence that the RuView
|
||||
implementation and weights were independently created. It is not a legal
|
||||
opinion. The legal release owner must approve the current licenses, contributor
|
||||
exposure record, patent review, trademark review, datasets, and intended launch
|
||||
jurisdictions before production.
|
||||
|
||||
## 2. License boundary as of 2026-09-01
|
||||
|
||||
The authoritative public materials state two different license surfaces:
|
||||
|
||||
1. The [Google TimesFM repository](https://github.com/google-research/timesfm)
|
||||
identifies its source code as Apache-2.0.
|
||||
2. The [TimesFM 3 checkpoint license](https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE)
|
||||
permits non-commercial, non-production use and restricts commercial use,
|
||||
distribution, and use to train, fine-tune, or distill another commercial
|
||||
model.
|
||||
|
||||
The checkpoint license defines a derivative broadly enough that relying on its
|
||||
logic, parameters, or model-generated material creates avoidable contractual
|
||||
risk. No contributor may accept gated model terms on behalf of the project or
|
||||
its owner without written authorization.
|
||||
|
||||
The [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) permits
|
||||
source reproduction and derivative works subject to its conditions, including
|
||||
license delivery, change notices, retention of applicable notices, and NOTICE
|
||||
handling. Apache also provides a limited contributor patent grant and no general
|
||||
trademark grant. If maintainers ever choose an Apache-derived port, it must use
|
||||
a separate branch, attribution path, artifact name, and superseding ADR. It
|
||||
must not be described as the clean-room implementation.
|
||||
|
||||
Publicly described ideas, procedures, processes, systems, and methods are not
|
||||
the same as copied expression. United States copyright law states the
|
||||
idea-expression boundary in [17 U.S.C. section 102(b)](https://www.copyright.gov/title17/92chap1.html).
|
||||
That does not settle contract, patent, trademark, database-right, trade-secret,
|
||||
or non-U.S. law questions.
|
||||
|
||||
## 3. Non-mixing rule
|
||||
|
||||
Only one of these paths may govern a source tree:
|
||||
|
||||
| Path | Source basis | Required representation |
|
||||
|---|---|---|
|
||||
| Independent implementation | Approved abstract specification, public papers/articles, general literature, RuView requirements, approved data | "Independently developed and independently trained" after all gates pass |
|
||||
| Apache-derived port | Apache-licensed Google source with full license and notice compliance | "Apache-derived Rust port" with attribution |
|
||||
|
||||
There is no hybrid path. A permissive source license makes porting possible; it
|
||||
does not make a port independent. If exposure cannot be bounded, maintainers
|
||||
must either quarantine and rewrite the affected component or reclassify the
|
||||
whole affected component under the Apache-derived path.
|
||||
|
||||
## 4. Roles and separation
|
||||
|
||||
| Role | May access | Must not access or do |
|
||||
|---|---|---|
|
||||
| Clean-room custodian | Exposure records, allowlist, scan reports, quarantined evidence | Contribute core model implementation after reviewing prohibited source fragments |
|
||||
| Specification reviewer | Frozen public-paper/article allowlist, RuView requirements, generic literature | Google TimesFM implementation, tests, configs, checkpoint files, model outputs; contribute implementation |
|
||||
| Rust implementer | Approved independent specification, RuView schemas, approved generic dependencies | TimesFM source, tests, configs, weights, outputs, line-by-line/API translation, reference differential testing |
|
||||
| Data steward | Dataset source, contracts, consent, privacy and transformation records | Approve unknown or incompatible rights; infer that a software license covers data |
|
||||
| Training operator | Signed code/container/config and approved dataset manifests | Change code/data/config outside a new receipt; add an unapproved parent checkpoint |
|
||||
| Independent evaluator | Frozen candidate, frozen evaluation manifests, approved baselines | Return reference predictions or implementation hints to developers; tune against the test set |
|
||||
| Legal release owner | Complete evidence bundle and private legal analysis | Treat clean-room checks as patent clearance or dataset approval by implication |
|
||||
|
||||
One person may hold multiple roles only where the forbidden-access rules remain
|
||||
true. The strongest separation uses different people for public specification
|
||||
and implementation. A specification reviewer who has inspected prohibited
|
||||
source cannot become a core implementer for the independent path.
|
||||
|
||||
## 5. Source classification
|
||||
|
||||
### 5.1 Allowed
|
||||
|
||||
- The public TimesFM research articles and peer-reviewed papers, frozen by URL,
|
||||
retrieval date, and SHA-256 digest.
|
||||
- General time-series, transformer, probabilistic forecasting, normalization,
|
||||
missing-data, and calibration literature.
|
||||
- RuView requirements, independently written feature schemas, and existing
|
||||
RuView contracts whose provenance already satisfies repository policy.
|
||||
- Generic Rust dependencies whose license, source, and purpose are recorded and
|
||||
approved.
|
||||
- Public benchmark specifications and datasets after the dataset gate passes.
|
||||
- Published aggregate competitor results used only as contextual background.
|
||||
|
||||
### 5.2 Conditional
|
||||
|
||||
- Code implementing generic algorithms may be used only after license review
|
||||
and must be declared as a dependency or attributed source. It cannot be used
|
||||
to claim that every line was independently authored.
|
||||
- TimesFM versions up to 2.5 may have permissively licensed code or weights, but
|
||||
they remain outside the independent implementation and training path. An
|
||||
evaluator may use an approved permissive baseline only after the candidate
|
||||
is frozen, and its output cannot become training data or implementation
|
||||
feedback.
|
||||
- A contributor with prior exposure to TimesFM code must disclose the exact
|
||||
material and date. The custodian and legal owner decide whether the person is
|
||||
limited to non-implementation work or the affected component must be
|
||||
reclassified.
|
||||
- Synthetic data is allowed only when source data, generator code/model,
|
||||
provider terms, output rights, and intended commercial use all pass review.
|
||||
|
||||
### 5.3 Prohibited
|
||||
|
||||
- TimesFM 3 weights, parameters, checkpoints, tensor dumps, checkpoint
|
||||
configuration, hidden activations, embeddings, gradients, or derived
|
||||
fingerprints.
|
||||
- TimesFM 3 forecasts, quantiles, scores, probabilities, recommendations, or
|
||||
synthetic labels in development, training, tuning, or release evidence.
|
||||
- Fine-tuning, distillation, imitation, teacher-student learning, differential
|
||||
testing, behavioural cloning, or hyperparameter search against TimesFM 3.
|
||||
- Manual, mechanical, or AI-assisted translation of Google source, tests,
|
||||
comments, constants, APIs, file layout, or diagrams.
|
||||
- Third-party code, datasets, or checkpoints whose origin may contain copied or
|
||||
distilled TimesFM material without documented permission.
|
||||
- Model, dataset, or service terms labelled non-commercial, research-only,
|
||||
evaluation-only, no-derivatives, no-ML, no-production, or unknown.
|
||||
- Confidential third-party information or material obtained under an agreement
|
||||
that does not authorize this implementation and commercial use.
|
||||
|
||||
## 6. Exposure intake and contributor attestation
|
||||
|
||||
Before receiving implementation access, every contributor completes an
|
||||
exposure intake covering:
|
||||
|
||||
- TimesFM repositories, model cards, gated checkpoint files, local caches,
|
||||
notebooks, issues, tutorials, and generated outputs viewed or downloaded;
|
||||
- whether gated terms were accepted and for which individual or entity;
|
||||
- source or output material placed in an LLM, IDE assistant, retrieval index,
|
||||
prompt, or code-generation session;
|
||||
- prior work for Google, a competitor, customer, or other party involving
|
||||
confidential forecasting implementation;
|
||||
- datasets, pretrained models, and external code expected in the contribution.
|
||||
|
||||
Each pull request also carries a signed attestation:
|
||||
|
||||
```text
|
||||
RuView Forecast clean-room attestation v1
|
||||
|
||||
Contributor:
|
||||
Employer or represented entity:
|
||||
Role:
|
||||
Covered commits:
|
||||
Prior TimesFM exposure: NONE or complete disclosure
|
||||
Approved sources used:
|
||||
Datasets introduced:
|
||||
AI tools used:
|
||||
Prompt/session manifest digest:
|
||||
|
||||
I attest that the covered contribution was created from the approved
|
||||
specification and listed sources. I did not access, copy, translate, decompile,
|
||||
query, distill, or use TimesFM 3 implementation material, configuration,
|
||||
weights, parameters, outputs, or internal representations. I did not place
|
||||
prohibited material in an AI tool context. I disclosed all contrary facts
|
||||
above. Every dependency and dataset I introduced has documented rights for its
|
||||
stated use.
|
||||
|
||||
Signature:
|
||||
Timestamp:
|
||||
```
|
||||
|
||||
A DCO `Signed-off-by` line does not replace this attestation. The attestation
|
||||
digest, not private identity material, is referenced from the public release
|
||||
manifest.
|
||||
|
||||
## 7. Specification controls
|
||||
|
||||
The custodian freezes an allowlist before implementation begins. Every entry
|
||||
records title, canonical URL or DOI, publication date, retrieval time, content
|
||||
digest, license or access terms, reviewer, and the abstract requirement it
|
||||
supports.
|
||||
|
||||
The specification may state mathematical functions, tensor roles, input/output
|
||||
invariants, resource bounds, and RuView-specific requirements. It must not
|
||||
include copied source, comments, tests, distinctive identifiers, constants,
|
||||
checkpoint dimensions extracted from files, API compatibility requirements, or
|
||||
Google diagrams. RuView API names must be derived from the local domain.
|
||||
|
||||
Changes to the specification after test evaluation receive a new version and
|
||||
must not encode held-out answers. A material architecture revision consumes a
|
||||
new untouched holdout or remains labelled exploratory.
|
||||
|
||||
## 8. AI coding-tool controls
|
||||
|
||||
An AI assistant can unintentionally defeat source separation by retrieving or
|
||||
reproducing reference code. For sessions contributing implementation:
|
||||
|
||||
1. Disable web retrieval and repository indexing outside the approved local
|
||||
tree where the tool supports it.
|
||||
2. If a user describes the goal as a port, clone, reproduction, or emulation,
|
||||
stop and restate the task as independently authored RuView functional
|
||||
requirements. Record that wording in the exposure manifest; do not use it
|
||||
as permission to retrieve reference implementation material or pursue
|
||||
behavioral equivalence.
|
||||
3. Do not attach Google code, checkpoint metadata, outputs, screenshots, or
|
||||
detailed third-party implementation summaries.
|
||||
4. State the independent specification and prohibited-source boundary in the
|
||||
session instructions.
|
||||
5. Retain a bounded prompt and tool-source manifest digest without committing
|
||||
raw private transcripts.
|
||||
6. Record the model/tool version and whether retrieval was enabled.
|
||||
7. Treat unexplained code that resembles a prohibited implementation as an
|
||||
incident, not as a harmless generated suggestion.
|
||||
|
||||
AI-generated code receives the same authorship, license, security, and
|
||||
similarity review as human-authored code.
|
||||
|
||||
## 9. Dataset licensing and privacy gate
|
||||
|
||||
No dataset enters preprocessing, a RuVector index, training, calibration, or
|
||||
evaluation until a data steward records affirmative answers to every applicable
|
||||
gate:
|
||||
|
||||
| Gate | Required evidence | Automatic rejection examples |
|
||||
|---|---|---|
|
||||
| Origin | Named owner/provider, acquisition method, immutable source digest | Scrape or file of unknown origin |
|
||||
| Training rights | Written right to use for ML training and the intended commercial purpose | Research-only, NC, no-ML, evaluation-only |
|
||||
| Derivatives | Right to transform, derive windows/features, and create model artifacts | No-derivatives or ambiguous custom terms |
|
||||
| Redistribution | Whether raw, transformed, manifests, and weights may be redistributed separately | Assumption that public access means redistribution |
|
||||
| Attribution | Exact attribution and notice obligations | Missing author/source/version |
|
||||
| Database rights | Jurisdiction and database-right review where applicable | Unreviewed EU database extraction |
|
||||
| Consent and contract | Collection authority, participant/customer consent, purpose, controller/processor roles | Customer telemetry without explicit model-training authority |
|
||||
| Sensitive inference | Classification of occupancy, routines, location, health-adjacent and biometric implications | Unbounded identity or health inference |
|
||||
| Minimization | Required fields only, pseudonymization, retention and deletion schedule | Raw CSI/person identifiers retained without need |
|
||||
| Split integrity | Site/subject/session/device/time-block lineage and overlap report | Contiguous or derived-window leakage across splits |
|
||||
| Synthetic lineage | Generator, seed data, service terms, output rights, seeds/config digest | Restricted teacher or prohibited source data |
|
||||
|
||||
Typical disposition guidance:
|
||||
|
||||
- Owned data, CC0/public-domain data, and data under an explicit commercial ML
|
||||
contract may pass after privacy and provenance review.
|
||||
- CC BY data requires attribution plus database and downstream-weight review.
|
||||
- MIT and Apache are software licenses and do not automatically license nearby
|
||||
data.
|
||||
- NC, ND, research-only, evaluation-only, no-ML, no-production, and unknown
|
||||
terms fail unless a separate written commercial grant is obtained.
|
||||
|
||||
Every transform is content-addressed. Normalizers, feature selectors,
|
||||
calibrators, thresholds, and RuVector indexes fit training data only. Raw
|
||||
sequence overlap and derived-window overlap are both checked. A parent record
|
||||
in one split makes all overlapping descendants ineligible for another split.
|
||||
|
||||
## 10. Hosted training boundary
|
||||
|
||||
The Linux machine and fal.ai are execution environments, not sources of
|
||||
authority. A hosted job is accepted only when:
|
||||
|
||||
- the exact source commit, Cargo lockfile, compiler, container digest, data
|
||||
manifests, configuration, seeds, and parent checkpoint digest are signed
|
||||
before upload;
|
||||
- provider terms, retention, region, subprocessors, logs, cache deletion,
|
||||
confidentiality, output ownership, and provider-training reuse have been
|
||||
reviewed for the data classification;
|
||||
- credentials are short-lived, least-privilege, excluded from images and logs,
|
||||
and rotated after suspected exposure;
|
||||
- customer-derived data is not uploaded before controller/processor and
|
||||
transfer requirements pass;
|
||||
- emitted checkpoints and logs are hashed immediately and compared with the job
|
||||
receipt;
|
||||
- the downloaded artifact is scanned before entering the trusted release
|
||||
boundary;
|
||||
- a hosted provider cannot promote, sign, or activate a model.
|
||||
|
||||
Hosted and local runs are comparable only when their governed input identities
|
||||
match. Numerical nondeterminism must be documented; it does not permit an
|
||||
unrecorded dependency, data, or configuration change.
|
||||
|
||||
## 11. Provenance manifest
|
||||
|
||||
The release bundle contains a machine-readable manifest with at least:
|
||||
|
||||
```yaml
|
||||
schema_version: <required>
|
||||
artifact:
|
||||
id: <required>
|
||||
version: <required>
|
||||
git_commit: <sha>
|
||||
source_spec_digest: <sha256>
|
||||
cargo_lock_digest: <sha256>
|
||||
rust_toolchain: <exact>
|
||||
target_triple: <exact>
|
||||
build_container_digest: <digest>
|
||||
sbom_digest: <sha256>
|
||||
|
||||
contributors:
|
||||
- pseudonymous_id: <stable id>
|
||||
role: <role>
|
||||
exposure_class: <unexposed|disclosed-reviewed>
|
||||
attestation_digest: <sha256>
|
||||
signed_at: <rfc3339>
|
||||
|
||||
references:
|
||||
- title: <title>
|
||||
canonical_uri: <url-or-doi>
|
||||
publication_date: <date>
|
||||
retrieved_at: <rfc3339>
|
||||
content_digest: <sha256>
|
||||
license_or_terms: <identifier>
|
||||
allowed_use: <purpose>
|
||||
reviewer: <id>
|
||||
|
||||
datasets:
|
||||
- dataset_id: <id>
|
||||
version: <version>
|
||||
owner: <owner>
|
||||
origin: <uri-or-contract-id>
|
||||
license_or_contract: <identifier>
|
||||
commercial_ml_allowed: <true>
|
||||
redistribution_allowed: <true|false>
|
||||
consent_basis: <identifier>
|
||||
privacy_class: <class>
|
||||
jurisdictions: [<jurisdiction>]
|
||||
retention_policy: <policy-id>
|
||||
source_digest: <sha256-or-merkle-root>
|
||||
transform_digest: <sha256>
|
||||
split_digest: <sha256>
|
||||
record_count: <count>
|
||||
|
||||
training:
|
||||
random_initialization: true
|
||||
parent_checkpoint: null
|
||||
code_commit: <sha>
|
||||
config_digest: <sha256>
|
||||
rng_seeds: [<seed>]
|
||||
hardware: <inventory>
|
||||
provider: <local-linux|approved-provider>
|
||||
provider_job_id: <non-secret-id>
|
||||
environment_digest: <sha256>
|
||||
started_at: <rfc3339>
|
||||
ended_at: <rfc3339>
|
||||
forbidden_artifact_scan_digest: <sha256>
|
||||
leakage_scan_digest: <sha256>
|
||||
|
||||
evaluation:
|
||||
frozen_manifest_digest: <sha256>
|
||||
baseline_artifacts: [<id-and-digest>]
|
||||
metric_schema: <version>
|
||||
report_digest: <sha256>
|
||||
calibration_report_digest: <sha256>
|
||||
domain_holdouts: [<site|subject|session|device>]
|
||||
|
||||
release:
|
||||
weights_digest: <sha256>
|
||||
model_card_digest: <sha256>
|
||||
clean_room_report_digest: <sha256>
|
||||
signature_key_id: <public-key-id>
|
||||
rvf_signature: <signature>
|
||||
source_license: <spdx-expression>
|
||||
weights_license: <exact-identifier>
|
||||
data_steward_approval: <signed-receipt>
|
||||
security_approval: <signed-receipt>
|
||||
legal_approval: <signed-receipt>
|
||||
```
|
||||
|
||||
Secrets, raw personal identifiers, contract text, raw customer data, and
|
||||
private contributor identities stay outside the public manifest. The manifest
|
||||
references controlled records by digest or approval ID.
|
||||
|
||||
## 12. Source, dependency, and artifact checks
|
||||
|
||||
The custodian records tool versions, rules, timestamps, and complete findings.
|
||||
At minimum:
|
||||
|
||||
- search the working tree and Git history for prohibited model IDs, URLs,
|
||||
filenames, license strings, binary signatures, and unexpected large files;
|
||||
- scan source similarity against the prohibited implementation in an isolated
|
||||
custodian environment; implementation contributors do not receive reference
|
||||
fragments from the report;
|
||||
- manually adjudicate every material similarity match and record independent
|
||||
origin, generic necessity, rewrite, or reclassification;
|
||||
- run dependency license, source, duplicate-version, and vulnerability policy
|
||||
checks and generate a CycloneDX or SPDX SBOM;
|
||||
- inspect container layers, CI and provider caches, mounted volumes, model/data
|
||||
buckets, notebooks, logs, and local tool indexes for restricted artifacts;
|
||||
- verify that every checkpoint parent is an approved RuView artifact or the
|
||||
declared random initialization;
|
||||
- verify that model loading performs no network access and accepts no embedded
|
||||
executable operator or arbitrary path;
|
||||
- verify that public packages, endpoints, docs, and metadata do not use Google
|
||||
trademarks as a product identity.
|
||||
|
||||
Keyword scans are a tripwire, not proof of independence. References in this
|
||||
governance document are expected and must be path allowlisted. The final result
|
||||
depends on provenance, exposure records, review, and the absence of prohibited
|
||||
material in implementation/training paths.
|
||||
|
||||
## 13. Model card and claim gate
|
||||
|
||||
Every candidate copies and completes
|
||||
[`../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md`](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md).
|
||||
No placeholder, `UNMEASURED`, unknown license, missing digest, or missing
|
||||
approval may be silently deleted. It must instead be resolved or retained as an
|
||||
explicit release blocker.
|
||||
|
||||
All accuracy, latency, memory, power, cost, and generalization values are
|
||||
labelled `MEASURED`, `SYNTHETIC`, `CLAIMED`, or `UNMEASURED` under repository
|
||||
policy. `MEASURED` requires a reproducer, immutable inputs, exact artifact, and
|
||||
named hardware/environment. A model-card benchmark does not create authority
|
||||
for a medical, emergency, security, or autonomous-action claim.
|
||||
|
||||
## 14. Naming, trademark, and comparative statements
|
||||
|
||||
Approved project identities include `RuView Forecast` and crate names derived
|
||||
from the RuView domain. Do not use `TimesFM`, `Google`, or a confusingly similar
|
||||
mark in crate names, binaries, model slugs, endpoints, logos, icons, or product
|
||||
headlines. Do not copy diagrams or visual branding.
|
||||
|
||||
Comparative documentation may accurately identify an external model and its
|
||||
version when necessary, with a statement that RuView Forecast is independently
|
||||
developed and not affiliated with or endorsed by Google. Comparative claims
|
||||
need identical datasets, metric definitions, permitted model use, and evidence
|
||||
labels.
|
||||
|
||||
Before public naming or commercial release, complete a professional trademark
|
||||
clearance that includes federal, state, common-law, domain, and relevant
|
||||
international sources. The [USPTO clearance guidance](https://www.uspto.gov/trademarks/search/comprehensive-clearance-search-similar-trademarks)
|
||||
is a starting point, not a complete legal opinion.
|
||||
|
||||
## 15. Residual patent and jurisdiction risk
|
||||
|
||||
Clean-room evidence does not prevent patent infringement. Before production,
|
||||
patent counsel performs a claim-level freedom-to-operate review covering at
|
||||
least temporal patching, multivariate/variate attention or mixing, masked
|
||||
horizon prediction, known-future covariates, probabilistic heads, normalization,
|
||||
retrieval augmentation, and relevant training procedures. Searches include
|
||||
assignees, inventors, continuations, unpublished timing uncertainty, and launch
|
||||
jurisdictions. The [USPTO Patent Public Search](https://www.uspto.gov/patents/search/patent-public-search)
|
||||
supports preliminary searching but is not a freedom-to-operate opinion.
|
||||
|
||||
The Apache patent grant applies only within its stated scope. An independent
|
||||
implementation must not assume it inherits that grant. Public papers may be
|
||||
prior art yet still coexist with earlier, pending, territorial, or narrower
|
||||
claims.
|
||||
|
||||
## 16. Contamination incident response
|
||||
|
||||
Treat any prohibited source, output, model, data, or unreviewed exposure as a
|
||||
provenance incident:
|
||||
|
||||
1. Stop affected implementation, training, evaluation feedback, and release.
|
||||
2. Preserve hashes, timestamps, actors, locations, access records, and affected
|
||||
lineage. Do not erase the audit trail.
|
||||
3. Quarantine the material and revoke it from build, data, model, cache, and
|
||||
retrieval paths.
|
||||
4. Identify every affected commit, specification revision, dataset transform,
|
||||
RuVector index, checkpoint, benchmark, and descendant artifact.
|
||||
5. Rotate credentials if a provider, cache, or secret may be exposed.
|
||||
6. Have the custodian and legal owner choose one disposition: proven
|
||||
non-impact, clean rewrite by unexposed contributors, full retraining from the
|
||||
last clean ancestor, or explicit Apache-derived reclassification.
|
||||
7. Repeat all affected ADR-348 gates and record the incident and closure
|
||||
receipts.
|
||||
|
||||
If prohibited output becomes a label or tuning signal, all descendant weights
|
||||
are affected. Code deletion alone cannot repair model lineage; retraining from
|
||||
an approved clean initialization is required.
|
||||
|
||||
## 17. Requirement-mapped acceptance checklist
|
||||
|
||||
| Check | ADR-348 requirements | Gate | Acceptance condition |
|
||||
|---|---|---|---|
|
||||
| CR-01 source allowlist | RF-001, RF-002 | G0 | 100% of specification sources frozen, hashed, licensed/termed, and approved |
|
||||
| CR-02 contributor exposure | RF-001, RF-002 | G0 | 100% of contributors have current intake and signed attestation; every disclosure has a disposition |
|
||||
| CR-03 prohibited-artifact scan | RF-001 | G0 | Zero unresolved prohibited artifacts or outputs across source, history, caches, containers, jobs, data, indexes, and checkpoints |
|
||||
| CR-04 similarity review | RF-001, RF-002 | G0 | Every material match adjudicated without exposing implementers to reference fragments |
|
||||
| CR-05 dependency/SBOM | RF-002, RF-004 | G1, G2 | Zero unknown or denied dependency licenses; vulnerability policy passes; signed SBOM exists |
|
||||
| CR-06 dataset rights | RF-003 | G1 | 100% of bytes map to approved manifests; zero NC, ND, research-only, no-ML, no-production, or unknown terms |
|
||||
| CR-07 privacy | RF-003, RF-009 | G1, G5 | Consent/contract, minimization, tenant, retention, deletion, transfer, and sensitive-inference review approved |
|
||||
| CR-08 checkpoint lineage | RF-001, RF-002, RF-010 | G1 | Every checkpoint reaches approved random initialization with no prohibited parent or label |
|
||||
| CR-09 split isolation | RF-007, RF-008 | G3 | Zero raw/derived overlap across frozen site/subject/session/device/time splits; split-scoped RuVector indexes verified |
|
||||
| CR-10 hosted parity | RF-010 | G1, G5 | Local/hosted receipts bind identical governed inputs; provider terms and deletion evidence approved |
|
||||
| CR-11 model card | RF-002, RF-007, RF-011 | G3-G5 | Template complete; every value evidence-labelled and reproducible; all blockers explicit |
|
||||
| CR-12 trademark and patent | RF-001, RF-011 | G5 | Naming clearance and claim-level freedom-to-operate disposition signed for launch jurisdictions |
|
||||
| CR-13 rollback | RF-012 | G5 | Contamination and runtime rollback drills preserve observations and evidence while disabling forecast authority |
|
||||
| CR-14 bounded forecast contract | RF-004, RF-005, RF-006 | G2 | Property/fuzz/replay evidence proves finite bounded inputs, abstention, offline execution, immutable observation linkage, and derived-only evidence labels |
|
||||
| CR-15 downstream authority | RF-006, RF-009, RF-012 | G4, G5 | No sensing-server hook in the initial PR; any later bridge proves forecast/LLM output cannot mutate observations or acquire prohibited action authority |
|
||||
| CR-16 claims and rollout | RF-011, RF-012 | G3-G5 | Every claim has an allowed evidence label and reproducer; mode transitions cannot outrun the highest passed gate |
|
||||
|
||||
Any failed or missing check blocks the mapped gate. A maintainer waiver cannot
|
||||
convert a prohibited license into permission or an unmeasured capability into a
|
||||
measured claim.
|
||||
|
||||
## 18. Release record
|
||||
|
||||
The legal, data, security, model, and runtime owners sign the same immutable
|
||||
release digest. Their approval covers the exact source, data, configuration,
|
||||
weights, model card, SBOM, intended use, deployment mode, and jurisdictions.
|
||||
Changing any governed input creates a new candidate and invalidates inherited
|
||||
approval.
|
||||
|
||||
The first production review must also answer one explicit question: does the
|
||||
business value of the measured forecast exceed the added privacy, operational,
|
||||
compute, and legal burden compared with deterministic baselines? If not, the
|
||||
correct outcome is to keep forecasting offline.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -111,7 +111,12 @@ handler (`health_ready`, `main.rs:8128,8133`) and return a `trust` block
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"source": "esp32",
|
||||
"source_state": "live_unverified",
|
||||
"waiting_for_frames": false,
|
||||
"last_frame_age_ms": 42,
|
||||
"trust": {
|
||||
"node_positions": { "1": [0.5, 0.0, 1.0], "2": [4.0, 3.0, 1.0] },
|
||||
"last_witness": "…64 hex chars or null…",
|
||||
"effective_class": "Anonymous | Restricted | …",
|
||||
"demoted": false,
|
||||
@@ -122,6 +127,11 @@ handler (`health_ready`, `main.rs:8128,8133`) and return a `trust` block
|
||||
}
|
||||
```
|
||||
|
||||
`node_positions` is the `--node-positions` map the governed fuser holds,
|
||||
keyed by node id. It is `{}` when no positions are configured, and then every
|
||||
node fuses at the origin. A node the map does not name also fuses at the
|
||||
origin.
|
||||
|
||||
**This is a real, currently-shipped diagnostic surface — but it is honestly
|
||||
limited.** It tells you *that* errors are occurring and *that* the current
|
||||
class is demoted, and the total count, but not *why* for your specific run:
|
||||
|
||||
+300
-37
@@ -21,12 +21,18 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
|
||||
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
|
||||
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
|
||||
- [MediaTek Router CSI (Experimental)](#mediatek-router-csi-experimental)
|
||||
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
|
||||
- [Cognitum Spaces activation](#cognitum-spaces-activation)
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
5. [REST API Reference](#rest-api-reference)
|
||||
6. [WebSocket Streaming](#websocket-streaming)
|
||||
7. [Web UI](#web-ui)
|
||||
7. [RuView npm toolkit (`@ruvnet/ruview`)](#ruview-npm-toolkit-ruvnetruview)
|
||||
- [Hardware from the command line](#hardware-from-the-command-line)
|
||||
- [MCP for agents, ChatGPT and MCP Apps](#mcp-for-agents-chatgpt-and-mcp-apps)
|
||||
- [Claude Code mod: live sensing pane](#claude-code-mod-live-sensing-pane)
|
||||
- [Homecore and the vitals kernel](#homecore-and-the-vitals-kernel)
|
||||
8. [Web UI](#web-ui)
|
||||
8. [Vital Sign Detection](#vital-sign-detection)
|
||||
9. [CLI Reference](#cli-reference)
|
||||
10. [Observatory Visualization](#observatory-visualization)
|
||||
@@ -75,7 +81,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
|
||||
| Option | Cost | Capabilities |
|
||||
|--------|------|-------------|
|
||||
| ESP32-S3 mesh (3-6 boards) | ~$54 | Full CSI: pose, breathing, heartbeat, presence |
|
||||
| ESP32-S3 mesh (3-6 boards) | ~$54 | CSI capture for presence, motion, and vital-sign heuristics; live 17-keypoint pose remains below-target and is not a validated capability |
|
||||
| Intel 5300 / Atheros AR9580 | $50-100 | Full CSI with 3x3 MIMO (Linux only) |
|
||||
| Any WiFi laptop | $0 | RSSI-only: coarse presence and motion detection |
|
||||
|
||||
@@ -104,7 +110,7 @@ Multi-architecture image (amd64 + arm64). Works on Intel/AMD and Apple Silicon M
|
||||
| `simulated` | Generate synthetic CSI frames (no hardware required) |
|
||||
| `wifi` | Host Wi-Fi RSSI (not available inside containers) |
|
||||
|
||||
Example: `docker run -e CSI_SOURCE=esp32 -p 3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`
|
||||
Example: `docker run -e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> -p 127.0.0.1:3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`. The container exits with code 64 unless `RUVIEW_API_TOKEN` is set, and receives no ESP32 frames until a UDP source guard is set; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
|
||||
|
||||
### From Source (Rust)
|
||||
|
||||
@@ -256,8 +262,9 @@ Non-interactive:
|
||||
### 30-Second Demo (Docker)
|
||||
|
||||
```bash
|
||||
# Pull and run
|
||||
docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
|
||||
# Pull and run (the container exits with code 64 without an API token)
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
|
||||
|
||||
# Open the UI in your browser
|
||||
# http://localhost:3000
|
||||
@@ -297,7 +304,11 @@ All endpoints return JSON. In simulated mode, data is generated from a determini
|
||||
|
||||
## Data Sources
|
||||
|
||||
The `--source` flag controls where CSI data comes from.
|
||||
The `--source` flag controls where CSI data comes from. Valid values are
|
||||
`auto`, `esp32`, `wifi`, and `simulated` (alias `simulate`), plus the vendor
|
||||
feeds `mediatek`, `qualcomm`, `realtek` (RTL8720F radar), and `realtek_csi`
|
||||
(RTL8721Dx CSI), which bind the UDP receiver like `esp32`. Any other value
|
||||
stops the server at startup with an error that lists them.
|
||||
|
||||
### Simulated Mode (No Hardware)
|
||||
|
||||
@@ -330,26 +341,24 @@ docker run --network host ruvnet/wifi-densepose:latest --source wifi --tick-ms 5
|
||||
|
||||
### macOS WiFi (RSSI Only)
|
||||
|
||||
Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works.
|
||||
Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works. On macOS, `--source wifi` selects the CoreWLAN scanner; there is no separate `macos` value.
|
||||
|
||||
```bash
|
||||
# Compile the Swift helper (once)
|
||||
swiftc -O archive/v1/src/sensing/mac_wifi.swift -o mac_wifi
|
||||
|
||||
# Run natively
|
||||
./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
./target/release/sensing-server --source wifi --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
```
|
||||
|
||||
See [ADR-025](adr/ADR-025-macos-corewlan-wifi-sensing.md) for details.
|
||||
|
||||
### Linux WiFi (RSSI Only)
|
||||
|
||||
Uses `iw dev <iface> scan` to capture RSSI. Requires `CAP_NET_ADMIN` (root) for active scans; use `scan dump` for cached results without root.
|
||||
|
||||
```bash
|
||||
# Run natively (requires root for active scanning)
|
||||
sudo ./target/release/sensing-server --source linux --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
```
|
||||
The sensing server does not have a Linux RSSI source yet. `--source wifi`
|
||||
on Linux runs the Windows `netsh` scanner, which is not present there, and
|
||||
`--source linux` is rejected at startup. Use an ESP32 node (`--source esp32`)
|
||||
on Linux.
|
||||
|
||||
### ESP32-S3 (Full CSI)
|
||||
|
||||
@@ -360,11 +369,39 @@ Real Channel State Information at 20 Hz with 56-192 subcarriers. Required for po
|
||||
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
|
||||
|
||||
# Docker (use CSI_SOURCE environment variable)
|
||||
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
|
||||
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
|
||||
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
The ESP32 nodes stream binary CSI frames over UDP to port 5005. See [Hardware Setup](#esp32-s3-mesh) for flashing instructions.
|
||||
|
||||
#### Receiving ESP32 frames in Docker
|
||||
|
||||
The sensing server binds its UDP receiver to `127.0.0.1` by default (ADR-296). Inside a container that address cannot receive anything from a published `5005/udp` port, so no node ever appears. The image's entrypoint binds UDP on `0.0.0.0` only when you set a source guard, and prints a note at startup when you have not:
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `RUVIEW_API_TOKEN` | Required. Without it (or `RUVIEW_ALLOW_UNAUTHENTICATED=1`) the container exits with code 64. |
|
||||
| `RUVIEW_UDP_ALLOW=<ip-or-cidr>` | Accept frames only from these sources. Preferred. |
|
||||
| `RUVIEW_UDP_INSECURE_LAN=true` | Accept frames from any source. The UDP data plane is not authenticated, so only use this on a trusted network. |
|
||||
| `RUVIEW_UDP_BIND` | Explicit UDP bind address. Overrides the entrypoint's choice. |
|
||||
| `SENSING_ALLOWED_HOSTS=<host-ip>` | Needed to open the UI from another machine; otherwise the server answers 421. That also requires publishing the TCP ports beyond `127.0.0.1`. |
|
||||
|
||||
**macOS (Docker Desktop, OrbStack):** datagrams reach the container from the Docker bridge gateway, not from the node's LAN address, so an allowlist that names your LAN subnet drops every frame. Allow the gateway instead:
|
||||
|
||||
```bash
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
GW=$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}')
|
||||
docker run --rm -d --name ruview \
|
||||
-p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
|
||||
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW="$GW/32" \
|
||||
ruvnet/wifi-densepose:latest
|
||||
curl -s -H "Authorization: Bearer $RUVIEW_API_TOKEN" localhost:3000/api/v1/nodes
|
||||
```
|
||||
|
||||
Allowing the gateway admits anything that can reach the published port, so keep `5005/udp` off untrusted networks. Node identity still comes from the frame payload. With Docker Compose the service runs on the project network (`<project>_default`), so inspect that network's gateway rather than `bridge`. Docker Desktop on Windows has a related source-address problem; see [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
|
||||
|
||||
### ESP32 Multistatic Mesh (Advanced)
|
||||
|
||||
For higher accuracy with through-wall tracking, deploy 3-6 ESP32-S3 nodes in a **multistatic mesh** configuration. Each node acts as both transmitter and receiver, creating multiple sensing paths through the environment.
|
||||
@@ -386,6 +423,16 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
|
||||
|
||||
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
|
||||
|
||||
### MediaTek Router CSI (Experimental)
|
||||
|
||||
An OpenWrt router with an MT7981B + MT7976C radio (tested on a Wavlink WL-WN586X3 Rev A) can report CSI through MediaTek's vendor CSI patch. A host-side bridge converts it to MTC1 (ADR-267) for the sensing server:
|
||||
|
||||
```bash
|
||||
./target/release/sensing-server --source mediatek --udp-port 5005 --http-port 3000 --ws-port 3001
|
||||
```
|
||||
|
||||
Frames are labelled `mediatek:physical-unvalidated`. The presence and activity outputs on this source are uncalibrated heuristics with no sensing-quality validation. CSI exists only while associated clients transmit. See the [MediaTek Router CSI guide](mediatek-router-csi.md) for the OpenWrt build, arming CSI, the bridge, and limitations.
|
||||
|
||||
### Connect Mesh Data to the Dashboard and Observatory
|
||||
|
||||
If a standalone `aggregator` command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run `sensing-server` on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
|
||||
@@ -402,13 +449,17 @@ cargo run -p wifi-densepose-sensing-server -- \
|
||||
|
||||
# Docker
|
||||
docker run --rm \
|
||||
-e RUVIEW_API_TOKEN \
|
||||
-e CSI_SOURCE=esp32 \
|
||||
-p 3000:3000 \
|
||||
-p 3001:3001 \
|
||||
-e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-p 5005:5005/udp \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
On macOS Docker the allowlist must name the bridge gateway; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
|
||||
|
||||
Open the UI from the sensing server, not from a local file:
|
||||
|
||||
| View | URL |
|
||||
@@ -420,10 +471,20 @@ Use these checks before debugging the browser:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
curl http://localhost:3000/api/v1/status
|
||||
curl http://localhost:3000/api/v1/nodes
|
||||
curl http://localhost:3000/api/v1/sensing/latest
|
||||
```
|
||||
|
||||
`/api/v1/status` reports whether frames are actually arriving:
|
||||
|
||||
| `source_state` | Meaning |
|
||||
|---|---|
|
||||
| `disconnected` | A live source is configured but no frame has arrived yet. `waiting_for_frames` is `true` and `last_frame_age_ms` is `null`. |
|
||||
| `live_unverified` | Frames are arriving. `last_frame_age_ms` is under 5000. |
|
||||
| `stale` | Frames arrived, then stopped for more than 5 seconds. For a hardware source, `source` gains an `:offline` suffix (for example `esp32:offline`). |
|
||||
| `synthetic` | Simulated data. Never shown as live. |
|
||||
|
||||
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
|
||||
|
||||
### Cognitum Spaces activation
|
||||
@@ -451,8 +512,8 @@ authority. Versioned collections are `sites`, `buildings`, `floors`,
|
||||
The dependency-free contributor harness exposes the same read path:
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.5.0 spaces --resource alerts --limit 25
|
||||
npx @ruvnet/ruview@0.5.0 mcp start
|
||||
npx @ruvnet/ruview@0.9.1 spaces --resource alerts --limit 25
|
||||
npx @ruvnet/ruview@0.9.1 mcp start
|
||||
```
|
||||
|
||||
Its MCP tool is `ruview_spaces_list`. MCP reads are OAuth-only, use the fixed
|
||||
@@ -582,6 +643,8 @@ Base URL: `http://localhost:3000` (Docker) or `http://localhost:8080` (binary de
|
||||
| `GET` | `/api/v1/mesh/metrics` | ADR-110 mesh state in Prometheus exposition format ([iter 36](adr/ADR-110-esp32-c6-firmware-extension.md)) | `wifi_densepose_mesh_offset_us{node="9"} 1163565\n…` |
|
||||
| `GET` | `/api/field` | ADR-262 P3 — latest **signed RuField `FieldEvent`s** from the live sensing cycle, plus the signer pubkey + a `dev_signing_key` flag. Only egress-safe (P1/P2) events are surfaced; identity/biometric (P4/P5) and raw (P0) are held edge-local | `{"spec":"rufield","signer_pubkey_hex":"…","dev_signing_key":true,"events":[…]}` |
|
||||
|
||||
Recording and model files live under the server's `--data-dir` (env `RUVIEW_DATA_DIR`, default `data`, resolved against the directory the server was started from): recordings in `<data-dir>/recordings/`, `.rvf` models in `<data-dir>/models/` unless `MODELS_DIR` is set, and the adaptive classifier in `<data-dir>/adaptive_model.json`.
|
||||
|
||||
### RuField surface (ADR-262 P3)
|
||||
|
||||
RuView's live WiFi-CSI sensing now also speaks the standalone **RuField MFS** wire format. Each governed sensing cycle is converted (via the `wifi-densepose-rufield` anti-corruption bridge) into a **signed** `FieldEvent` (`Modality::WifiCsi`, ed25519 `ProvenanceRef`) and surfaced on two additive endpoints:
|
||||
@@ -838,7 +901,14 @@ Full design + operator guide: [`docs/integrations/home-assistant.md`](integratio
|
||||
sensing-server --mqtt --mqtt-host <broker> --mqtt-tls --privacy-mode
|
||||
```
|
||||
|
||||
`--privacy-mode` strips heart rate, breathing rate, and pose keypoints from MQTT **and** Matter — they never reach the wire. Semantic primitives stay published because they're inferred *states* server-side, not biometric *values*. This is the architectural win that makes ADR-115 healthcare- and enterprise-deployable.
|
||||
`--privacy-mode` (env `RUVIEW_PRIVACY_MODE`) is a server-wide flag. It withholds heart rate, breathing rate, and pose keypoints from every output:
|
||||
|
||||
- REST responses: the fields are removed from every JSON body (`/api/v1/sensing/latest`, `/api/v1/vital-signs`, `/api/v1/edge-vitals`, `/api/v1/pose/current`, ...). `/api/v1/pose/current?view=both|refined` returns `403` with `"code": "privacy_mode"`.
|
||||
- WebSocket streams: `/ws/sensing` and `/api/v1/stream/pose` frames are filtered the same way.
|
||||
- Recordings started with `POST /api/v1/recording/start` are written already filtered.
|
||||
- MQTT and Matter: those entities are neither announced nor published.
|
||||
|
||||
Presence, motion, person count, zones and the coarse posture label are still served, and semantic primitives stay published because they're inferred *states*, not biometric *values*. Some of those states (sleeping, possible distress) are derived from vitals, so they are still health-related information. Privacy mode reduces what leaves the server. It does not by itself make a deployment compliant with any regulation.
|
||||
|
||||
### Matter Bridge (Apple Home / Google Home / Alexa / SmartThings)
|
||||
|
||||
@@ -958,6 +1028,164 @@ Source: [`tools/ruview-mcp/`](../tools/ruview-mcp/README.md). Tracking issue: [#
|
||||
|
||||
---
|
||||
|
||||
## RuView npm toolkit (`@ruvnet/ruview`)
|
||||
|
||||
`@ruvnet/ruview` is RuView's operator toolkit on npm. It has no runtime
|
||||
dependencies and runs on Node 20+. It is tested on Windows and Linux. One tool
|
||||
registry serves:
|
||||
- a CLI;
|
||||
- an MCP server (stdio, or HTTP for ChatGPT);
|
||||
- a typed SDK;
|
||||
- a Claude Code mod.
|
||||
|
||||
Every result is honest about what it is:
|
||||
- **MEASURED** when it comes from live packets on your machine;
|
||||
- **SYNTHETIC** for simulators and self-tests;
|
||||
- **device-reported** for values a radar's own firmware computes, which are
|
||||
not validated against a reference.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.9.1 --help
|
||||
npx @ruvnet/ruview@0.9.1 doctor # what works on this machine, with fixes
|
||||
```
|
||||
|
||||
In a terminal, commands print a formatted view. Pipes and `--json` print JSON,
|
||||
for scripts.
|
||||
|
||||
### Hardware from the command line
|
||||
|
||||
Run these on the machine the hardware is plugged into.
|
||||
|
||||
```bash
|
||||
# What is plugged in (USB VID:PID → ESP32, Realtek RTL8721Dx, radar, LiDAR)
|
||||
npx @ruvnet/ruview@0.9.1 devices
|
||||
|
||||
# CSI nodes streaming to this machine (UDP 5005): ESP32 ADR-018 frames and
|
||||
# Realtek RAC1 frames, per node rate, loss, RSSI and CSI shape
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --seconds 10
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --watch # live view, redrawn every few seconds
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
|
||||
|
||||
# A board's serial console, without resetting it
|
||||
npx @ruvnet/ruview@0.9.1 monitor --port COM9
|
||||
npx @ruvnet/ruview@0.9.1 monitor --port COM10 --baud 1500000 # Realtek Ameba
|
||||
|
||||
# 60 GHz / 24 GHz radar: raw UART, or an ESPHome kit on your network
|
||||
npx @ruvnet/ruview@0.9.1 mmwave --port COM5 --model auto
|
||||
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host 192.168.1.50
|
||||
|
||||
# Flash ESP32 firmware: plan first, then write with --confirm (records boot evidence)
|
||||
npx @ruvnet/ruview@0.9.1 flash-plan --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6
|
||||
npx @ruvnet/ruview@0.9.1 flash --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6 --confirm
|
||||
```
|
||||
|
||||
**What the alerts mean:**
|
||||
|
||||
| Alert | Meaning |
|
||||
|---|---|
|
||||
| `heartbeat-only sender` | A board is alive but sends no CSI. On a Realtek board, run `monitor --baud 1500000`. If it shows `lack of csi buf`, the board ran out of CSI report buffers (`csi_buffer_starvation`). Reset it. |
|
||||
| `port_in_use` | The sensing server already holds UDP 5005. Stop it, or capture on another port your nodes target. |
|
||||
| `no_decodable_packets` | Packets arrived, but none were in a known RuView format. Check the sender. |
|
||||
| `seqReordered` / `seqStrays` | Out-of-order or stray sequence numbers. These are not counted as loss. |
|
||||
|
||||
**ESPHome radar kits** (for example the Seeed MR60BHA2): the kit's firmware
|
||||
owns the radar, so read it over the network with `--source esphome`.
|
||||
- If the kit cannot join your WiFi, it starts a fallback hotspot. Join it and
|
||||
set your network in its captive portal, at `http://192.168.4.1`.
|
||||
- Only private-network addresses are accepted.
|
||||
- Encrypted or password-protected ESPHome APIs are reported as unsupported.
|
||||
|
||||
### MCP for agents, ChatGPT and MCP Apps
|
||||
|
||||
```bash
|
||||
# Claude Code, Codex or any MCP client (stdio)
|
||||
claude mcp add ruview -- npx -y @ruvnet/ruview@0.9.1 mcp start
|
||||
|
||||
# HTTP, for ChatGPT and remote clients
|
||||
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
|
||||
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http --port 8790
|
||||
```
|
||||
|
||||
**Connecting ChatGPT.** Clients that can send headers use
|
||||
`Authorization: Bearer <token>`. ChatGPT connectors cannot, so:
|
||||
1. Expose the port over HTTPS, with a tunnel or reverse proxy.
|
||||
2. Add `https://<your-host>/mcp/<token>` as the connector URL.
|
||||
|
||||
**Least authority:**
|
||||
- Hardware tools need the `device-access` grant.
|
||||
- The HTTP transport binds to localhost by default.
|
||||
- It refuses unknown browser origins.
|
||||
- It never allows flashing or calibration, whatever grants are set.
|
||||
|
||||
Node captures, radar reads, device scans and doctor results render in the
|
||||
**RuView console**: a widget that ChatGPT and other MCP Apps hosts show inline,
|
||||
with a Refresh button that re-runs the tool.
|
||||
|
||||
### Claude Code mod: live sensing pane
|
||||
|
||||
The package ships `ruview-live`, a Claude Code mod. `/ruview` opens a pane
|
||||
beside the conversation, refreshed on a timer, showing:
|
||||
- your CSI nodes;
|
||||
- an optional ESPHome radar;
|
||||
- alerts.
|
||||
|
||||
It also keeps a status line such as `RuView · 2 nodes · radar present`.
|
||||
|
||||
Keys `1`, `2` and `3` switch between three views (ADR-378):
|
||||
- **Overview**: the cards.
|
||||
- **CSI waterfall**: per-subcarrier amplitude over time, labelled MEASURED or
|
||||
SYNTHETIC; `n` cycles nodes.
|
||||
- **Radar**: a range fan with an animated ping, plus heart, breathing and
|
||||
distance charts. The values are device-reported and not validated, and the
|
||||
kit reports range, not bearing.
|
||||
|
||||
The two live views draw in terminal cells and animate in place.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.9.1 mod # prints the mod's path in your install and how to load it
|
||||
claude --plugin-dir "<that path>" # try it for one session
|
||||
# or from the RuView marketplace, inside Claude Code:
|
||||
# /plugin marketplace add ruvnet/RuView
|
||||
# /plugin install ruview-live@ruview
|
||||
```
|
||||
|
||||
| In Claude Code | Does |
|
||||
|---|---|
|
||||
| `/ruview` | Open or close the pane |
|
||||
| `/ruview refresh` | Run one capture now; the result goes in the status line |
|
||||
| `/ruview off` | Close the pane |
|
||||
| `/ruview waterfall` / `/ruview radar` | Open on that view |
|
||||
|
||||
**Settings** (`claude plugin configure ruview-live`): `udpPort`, `radarHost`,
|
||||
`refreshSeconds`, `captureSeconds` and `liveRefreshSeconds` (live views,
|
||||
default 4 s).
|
||||
|
||||
The mod only runs the package's read-only CLI commands. Mods are early access:
|
||||
if `/ruview` is missing, start Claude Code with
|
||||
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`.
|
||||
|
||||
### Homecore and the vitals kernel
|
||||
|
||||
```bash
|
||||
npx homecore@0.1.0 guidance --topic plugins --query Wasmtime # Homecore developer metaharness
|
||||
npx @ruvnet/ruview-kernel@0.1.0 selftest # vitals pipeline as WASM (SYNTHETIC self-test)
|
||||
```
|
||||
|
||||
`esp32 --analyze` uses the kernel when it is installed. Its heart and breathing
|
||||
estimates carry "no reference measurement": they show the signal path works,
|
||||
not that the readings are accurate.
|
||||
|
||||
Design records:
|
||||
- [ADR-373](adr/ADR-373-host-device-access-layer.md): device access;
|
||||
- [ADR-375](adr/ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md):
|
||||
MCP Apps console, HTTP transport and terminal UI;
|
||||
- [ADR-376](adr/ADR-376-ruview-umbrella-npm-package.md): the one-install
|
||||
`ruview` package, pending the npm name;
|
||||
- [ADR-377](adr/ADR-377-ruview-live-claude-code-mod.md): the Claude Code mod.
|
||||
- [ADR-378](adr/ADR-378-ruview-live-showcase-views.md): the mod's CSI waterfall and radar views (keys 1/2/3, `esp32 --spectrum`).
|
||||
|
||||
---
|
||||
|
||||
## Web UI
|
||||
|
||||
The built-in Three.js UI is served at `http://localhost:3000/ui/` (Docker) or the configured HTTP port.
|
||||
@@ -1114,7 +1342,7 @@ The Rust sensing server binary accepts the following flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--source` | `auto` | Data source: `auto`, `simulate`, `wifi`, `esp32` |
|
||||
| `--source` | `auto` | Data source: `auto`, `esp32`, `wifi`, `simulated` (alias `simulate`), `mediatek`, `qualcomm`, `realtek`, `realtek_csi`. Other values are rejected at startup |
|
||||
| `--http-port` | `8080` | HTTP port for REST API and UI |
|
||||
| `--ws-port` | `8765` | WebSocket port |
|
||||
| `--udp-port` | `5005` | UDP port for ESP32 CSI frames |
|
||||
@@ -1214,6 +1442,22 @@ levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-depre
|
||||
|
||||
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
|
||||
|
||||
### Model and capture compatibility
|
||||
|
||||
These artifacts share a pose objective, but not an input contract or adapter format. A checkpoint
|
||||
is usable only when capture preprocessing, tensor shape, architecture, and runtime all match.
|
||||
|
||||
| Artifact/path | Required input | Measured status | Live ESP32 compatibility |
|
||||
|---------------|----------------|-----------------|--------------------------|
|
||||
| MM-Fi flagship and `micro` transformer | `[N,3,114,10]` amplitude | **MEASURED** on MM-Fi `random_split`; calibration reference is Python `.npz` LoRA | No direct S3/C6 path is validated; resampling a 56-tone SISO stream does not recreate three-antenna MM-Fi input |
|
||||
| Cog `pose_v1.safetensors` | `[N,56,20]` amplitude | **MEASURED** PCK@20 = 3.0%, below the ≥35% target; cog-format LoRA is `.safetensors` | Shape matches the canonical window, but the documented live runtime remains below-target/stub and is not a reliable pose claim |
|
||||
| Viewer `heuristic_pose_from_amplitude` | live canonical amplitude | Skeleton-layout placeholder, not a trained pose model | Renders a demonstrator skeleton only; it is not pose accuracy evidence |
|
||||
| MERIDIAN automatic unlabeled calibration | proposed ~200-frame target-room capture | **PROPOSED** in ADR-027; no validated end-to-end command | Not available. The current calibration tools require paired CSI/keypoint labels and model-specific inputs |
|
||||
|
||||
Calibration files are not interchangeable: `calibrate.py` targets the MM-Fi transformer, while
|
||||
`cog_calibrate.py` targets the cog conv+MLP. See the
|
||||
[calibration reference](../aether-arena/calibration/README.md) for their exact schemas.
|
||||
|
||||
### Download
|
||||
|
||||
```bash
|
||||
@@ -1315,7 +1559,7 @@ curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
curl -X POST http://localhost:3000/api/v1/recording/stop
|
||||
```
|
||||
|
||||
Recordings are saved as JSONL files in `data/recordings/`. Filenames must start with `train_` and contain a class keyword:
|
||||
Recordings are saved as JSONL files in `recordings/` under the server's `--data-dir` (default `data`, so `data/recordings/` relative to the directory the server was started from). Filenames must start with `train_` and contain a class keyword:
|
||||
|
||||
| Filename pattern | Class |
|
||||
|-----------------|-------|
|
||||
@@ -1332,7 +1576,7 @@ Train the adaptive classifier from your labeled recordings:
|
||||
curl -X POST http://localhost:3000/api/v1/adaptive/train
|
||||
```
|
||||
|
||||
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to `data/adaptive_model.json` and automatically loaded on server restart.
|
||||
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to `adaptive_model.json` in the `--data-dir` (default `data/adaptive_model.json`) and automatically loaded on server restart.
|
||||
|
||||
**Check model status:**
|
||||
|
||||
@@ -1352,7 +1596,7 @@ Once trained, the adaptive model runs automatically:
|
||||
|
||||
1. Each CSI frame is classified using the learned weights instead of static thresholds
|
||||
2. Model confidence is blended with smoothed threshold confidence (70/30 split)
|
||||
3. The model persists across server restarts (loaded from `data/adaptive_model.json`)
|
||||
3. The model persists across server restarts (loaded from `<data-dir>/adaptive_model.json`)
|
||||
|
||||
**Tips for better accuracy:**
|
||||
|
||||
@@ -1458,7 +1702,7 @@ The pipeline runs 10 phases:
|
||||
3. Subcarrier resampling (114->56 or 30->56 via Catmull-Rom interpolation)
|
||||
4. Graph transformer construction (17 COCO keypoints, 16 bone edges)
|
||||
5. Cross-attention training (CSI features -> body pose)
|
||||
6. **Domain-adversarial training** (MERIDIAN: gradient reversal + virtual domain augmentation)
|
||||
6. Experimental domain-adversarial components (MERIDIAN research modules; not a validated automatic deployment path)
|
||||
7. Composite loss optimization (MSE + CE + UV + temporal + bone + symmetry)
|
||||
8. SONA adaptation (micro-LoRA + EWC++)
|
||||
9. Sparse inference optimization (hot/cold neuron partitioning)
|
||||
@@ -1474,14 +1718,18 @@ Progressive loading enables instant startup (Layer A loads in <5ms with basic in
|
||||
|
||||
### Cross-Environment Adaptation (MERIDIAN)
|
||||
|
||||
Models trained in one room typically lose 40-70% accuracy in a new room due to different WiFi multipath patterns. The MERIDIAN system (ADR-027) solves this with a 10-second automatic calibration:
|
||||
Models trained in one room can lose substantial accuracy in a new room because the multipath
|
||||
distribution changes. ADR-027 proposes an automatic adaptation design, and the Rust tree contains
|
||||
individual research components, but RuView does **not** currently provide a validated command that
|
||||
turns ~200 unlabeled frames into a working room adapter.
|
||||
|
||||
1. **Deploy** the trained model in a new room
|
||||
2. **Collect** ~200 unlabeled CSI frames (10 seconds at 20 Hz)
|
||||
3. The system automatically generates environment-specific LoRA weights via contrastive test-time training
|
||||
4. No labels, no retraining, no user intervention
|
||||
Current, testable calibration is the separate **labeled** reference in
|
||||
`aether-arena/calibration/`: collect paired CSI/keypoint samples, then fit a model-specific LoRA
|
||||
adapter. The MM-Fi transformer expects `[N,3,114,10]`; the cog expects `[N,56,20]`. Neither adapter
|
||||
loads into the other model, and neither result establishes live ESP32 pose accuracy without a
|
||||
leakage-free held-out capture and mean-pose baseline.
|
||||
|
||||
MERIDIAN components (all pure Rust, +12K parameters):
|
||||
ADR-027 research components:
|
||||
|
||||
| Component | What it does |
|
||||
|-----------|-------------|
|
||||
@@ -1489,7 +1737,7 @@ MERIDIAN components (all pure Rust, +12K parameters):
|
||||
| Domain Factorizer | Separates pose-relevant from room-specific features |
|
||||
| Geometry Encoder | Encodes AP positions (FiLM conditioning with DeepSets) |
|
||||
| Virtual Augmentor | Generates synthetic environments for robust training |
|
||||
| Rapid Adaptation | 10-second unsupervised calibration via contrastive TTT |
|
||||
| Rapid Adaptation | Proposed unlabeled contrastive TTT; not wired as a validated deployment workflow |
|
||||
|
||||
See [ADR-027](adr/ADR-027-cross-environment-domain-generalization.md) for the full design.
|
||||
|
||||
@@ -1647,7 +1895,7 @@ Not every ADR-295–296 remediation item is preview-only. Three are live now:
|
||||
|
||||
| Target | Use case | Source target flag | Notes |
|
||||
|---|---|---|---|
|
||||
| **ESP32-S3** (default) | Production CSI mesh, 17-keypoint pose | `idf.py set-target esp32s3` | Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path |
|
||||
| **ESP32-S3** (default) | Production CSI capture mesh; presence/motion/vital heuristics | `idf.py set-target esp32s3` | Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path; live 17-keypoint pose is not validated |
|
||||
| **ESP32-C6** ([ADR-110](adr/ADR-110-esp32-c6-firmware-extension.md)) | Wi-Fi 6 / 802.15.4 research, battery seed nodes | `idf.py set-target esp32c6` | Single-core 160 MHz, no PSRAM, 802.11ax HE PHY, 802.15.4 (Thread/Zigbee), LP-core hibernation ~5 µA |
|
||||
|
||||
The same `firmware/esp32-csi-node` source tree builds for both. ESP-IDF picks up `sdkconfig.defaults.esp32c6` automatically when the target is set to `esp32c6`; otherwise it uses `sdkconfig.defaults` (S3). All C6-only modules are `#ifdef`-gated, so the S3 build is byte-identical to today.
|
||||
@@ -1864,8 +2112,10 @@ Binary size: 990 KB (8MB flash, 52% free) or 773 KB (4MB flash). v0.5.0 adds mmW
|
||||
# From source
|
||||
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
|
||||
|
||||
# Docker (use CSI_SOURCE environment variable)
|
||||
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
|
||||
# Docker (see "Receiving ESP32 frames in Docker" for the UDP source guard)
|
||||
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
|
||||
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
See [ADR-018](../docs/adr/ADR-018-esp32-dev-implementation.md), [ADR-029](../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md), and [Tutorial #34](https://github.com/ruvnet/RuView/issues/34).
|
||||
@@ -2071,7 +2321,11 @@ Pre-trained models are available on HuggingFace:
|
||||
- **SOTA MM-Fi pose model** (82.69% torso-PCK@20) — https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose
|
||||
- **AetherArena leaderboard Space** — https://huggingface.co/spaces/ruvnet/aether-arena
|
||||
|
||||
Download and start sensing immediately — no datasets, no GPU, no training needed. Results are reproducible via `python archive/v1/data/proof/verify.py` (deterministic SHA-256 proof) — see [ADR-168](adr/ADR-168-benchmark-proof.md).
|
||||
The encoder artifact can be downloaded for its documented inference path without retraining. The
|
||||
MM-Fi pose checkpoint is benchmark evidence, not a drop-in live ESP32 model; it requires its exact
|
||||
input contract and runtime described in [Model and capture compatibility](#model-and-capture-compatibility).
|
||||
The deterministic signal-pipeline proof is reproducible via `python archive/v1/data/proof/verify.py`
|
||||
(see [ADR-168](adr/ADR-168-benchmark-proof.md)), but that proof does not validate pose accuracy.
|
||||
|
||||
### Quick Start with Pre-Trained Models
|
||||
|
||||
@@ -2190,6 +2444,8 @@ For production deployments with both Rust and Python services:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
export RUVIEW_UDP_ALLOW=<node-ip-or-cidr> # macOS Docker: <gateway>/32
|
||||
docker compose up
|
||||
```
|
||||
|
||||
@@ -2197,6 +2453,8 @@ This starts:
|
||||
- Rust sensing server on ports 3000 (HTTP), 3001 (WS), 5005 (UDP)
|
||||
- Python legacy server on ports 8080 (HTTP), 8765 (WS)
|
||||
|
||||
The sensing server exits with code 64 when `RUVIEW_API_TOKEN` is unset. Without `RUVIEW_UDP_ALLOW` or `RUVIEW_UDP_INSECURE_LAN=true` it starts but receives no ESP32 frames. `docker/docker-compose.yml` passes these variables through from your shell, along with `RUVIEW_UDP_BIND` and `SENSING_ALLOWED_HOSTS`. See [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
|
||||
|
||||
---
|
||||
|
||||
## Testing Firmware Without Hardware (QEMU)
|
||||
@@ -2636,7 +2894,12 @@ No. Run `docker run -p 3000:3000 ruvnet/wifi-densepose:latest` and open `http://
|
||||
No. Consumer WiFi exposes only RSSI (one number per access point), not CSI (56+ complex subcarrier values per frame). RSSI supports coarse presence and motion detection. Full pose estimation requires CSI-capable hardware like an ESP32-S3 ($8) or a research NIC.
|
||||
|
||||
**Q: How accurate is the pose estimation?**
|
||||
Accuracy depends on hardware and environment. With a 3-node ESP32 mesh in a single room, the system tracks 17 COCO keypoints. The core algorithm follows the CMU "DensePose From WiFi" paper ([arXiv:2301.00250](https://arxiv.org/abs/2301.00250)). The MERIDIAN domain generalization system (ADR-027) reduces cross-environment accuracy loss from 40-70% to under 15% via 10-second automatic calibration.
|
||||
The strongest published RuView result is the MM-Fi transformer benchmark (82.69% torso-PCK@20 on
|
||||
the matched `random_split` protocol), not a live ESP32 result. The committed cog model measured
|
||||
3.0% PCK@20 on its holdout, below the ≥35% target, and the viewer's live skeleton is a heuristic
|
||||
placeholder. No measured claim currently shows a 3-node ESP32 mesh reliably tracking 17 keypoints.
|
||||
ADR-027's 10-second unlabeled MERIDIAN adaptation is Proposed; the available measured calibration
|
||||
reference uses labeled, model-specific samples.
|
||||
|
||||
**Q: Does it work through walls?**
|
||||
Yes. WiFi signals penetrate non-metallic materials (drywall, wood, concrete up to ~30cm). Metal walls/doors significantly attenuate the signal. With a single AP the effective through-wall range is approximately 5 meters. With a 3-6 node multistatic mesh (ADR-029), attention-weighted cross-viewpoint fusion extends the effective range to ~8 meters through standard residential walls.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# ESP32 C6 node 7 rate aware sensing qualification
|
||||
|
||||
## Scope
|
||||
|
||||
This record qualifies the firmware 0.8.8 timing and transport path on a second
|
||||
physically attached ESP32 C6. It measures raw callback cadence, edge DSP
|
||||
cadence, process stability, and end to end sensing delivery. It does not
|
||||
qualify heartbeat, respiration, pose, identity, room separation, or person
|
||||
count accuracy against labelled ground truth.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
| Field | Measured value |
|
||||
|---|---|
|
||||
| Board | ESP32 C6 QFN40 revision 0.2 |
|
||||
| Logical node | 7 |
|
||||
| Firmware before | 0.8.4 |
|
||||
| Firmware after | 0.8.8 development build |
|
||||
| App image | 1,051,552 bytes |
|
||||
| App SHA 256 | `eab7561d65e302dc33e9331ac591763a46f92fb3fa9f824fef0e9b541daddb3f` |
|
||||
| OTA slot size | 1,900,544 bytes |
|
||||
| OTA headroom | 848,992 bytes, 45 percent |
|
||||
|
||||
Only the application partition at offset `0x20000` was flashed. WiFi
|
||||
credentials, logical node identity, sensing server target, channel, edge tier,
|
||||
bootloader, partition table, OTA metadata, and NVS were preserved. The pre
|
||||
update application was copied to a private recovery file outside the
|
||||
repository. Its SHA 256 is
|
||||
`f5ebc5e0142425adae16e9180bf298ef444ea8862ba0d8809c310a39fe45721d`.
|
||||
The device partition table was also read before the update and had SHA 256
|
||||
`0a8d2f192a8fff209d6c75ab639fcf8aa2f43c64abb732c6e74596fbd6971dca`.
|
||||
|
||||
The post update boot log reported firmware 0.8.8, node 7, channel 10, Tier 2,
|
||||
an 8 Hz edge DSP cadence, and the preserved sensing server target. The OTA
|
||||
status endpoint reported firmware 0.8.8 running from `ota_0`, with `ota_1` as
|
||||
the next partition and the correct 1,900,544 byte limit. The sensing server
|
||||
health endpoint remained ready with ESP32 input.
|
||||
|
||||
## Before and after
|
||||
|
||||
The pre update baseline was a 20 second observation on the same attached board.
|
||||
The post update observation was a five minute steady state run after boot.
|
||||
|
||||
| Observation | Before 0.8.4 | After 0.8.8 |
|
||||
|---|---:|---:|
|
||||
| Raw callback mean | 39.05 pps | 36.32 pps |
|
||||
| Raw callback range | 35 through 42 pps | 24 through 42 pps |
|
||||
| Server CSI FPS mean | 46.23 Hz | 48.88 Hz |
|
||||
| WebSocket parser errors | 0 | 0 |
|
||||
| WebSocket reconnects | 0 | 0 |
|
||||
|
||||
Raw callback mean changed by negative 7.0 percent while the server CSI FPS
|
||||
estimate changed by positive 5.7 percent. Both remain above the 20 pps
|
||||
transport floor. The result is transport neutral rather than an accuracy lift;
|
||||
the room and WiFi traffic were not controlled between the two windows.
|
||||
|
||||
Firmware 0.8.4 did not expose the edge DSP cadence used by the temporal
|
||||
filters. Firmware 0.8.8 held that separately governed clock at exactly 8.0 Hz
|
||||
for every controller sample while preserving the higher rate raw network path.
|
||||
|
||||
## Five minute physical result
|
||||
|
||||
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
|
||||
|
||||
| Device observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300.70 seconds |
|
||||
| Controller samples | 300 |
|
||||
| Raw callback mean | 36.32 pps |
|
||||
| Raw callback range | 24 through 42 pps |
|
||||
| Edge DSP mean | 8.00 Hz |
|
||||
| Edge DSP range | 8.00 through 8.00 Hz |
|
||||
| ENOMEM events | 0 |
|
||||
| UDP send failures | 0 |
|
||||
| ESP NOW nonzero failure lines | 0 |
|
||||
| Other steady state errors | 0 |
|
||||
| Watchdogs, panics, or reboots | 0 |
|
||||
|
||||
| End to end WebSocket observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300.06 seconds |
|
||||
| Sensing frames | 36,254 |
|
||||
| JSON parse errors | 0 |
|
||||
| WebSocket errors or reconnects | 0 |
|
||||
| Frames containing node 7 | 35,313 |
|
||||
| Node 7 frame coverage | 97.40 percent |
|
||||
| Node 7 stale frames | 0 |
|
||||
| Maximum node 7 staleness | 972 ms |
|
||||
| Maximum node 7 inference age | 483 ms |
|
||||
| Maximum WebSocket frame gap | 108 ms |
|
||||
| Nodes per frame | 0 through 5 |
|
||||
| Fused presence count contradictions | 0 |
|
||||
|
||||
One ENOMEM backoff occurred during startup and recovered in 210 ms. No memory
|
||||
backoff or send failure recurred in the separate five minute steady state
|
||||
window. The boot log also reported the documented fail closed OTA behavior:
|
||||
the status service was available, but image upload remained rejected because
|
||||
this node has no provisioned OTA signing secret.
|
||||
|
||||
## Result and limitation
|
||||
|
||||
The node 7 timing and transport update passes. Its configuration survived, the
|
||||
edge DSP clock remained phase stable at the measured sustainable C6 rate, and
|
||||
the live service received fresh node 7 data throughout the run. This does not
|
||||
complete the ADR 346 occupancy qualification for node 7 because the room was
|
||||
not held empty and the live aggregate did not expose 30 absent edge packets.
|
||||
|
||||
The largest uncertainty remains inference accuracy. Timing stability cannot
|
||||
prove better vital, motion, room separation, or multi person estimates without
|
||||
synchronized held out labels and a controlled empty room sequence.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Repeat this five minute procedure after timing, WiFi, filter, or scheduling
|
||||
changes. Pass transport only when raw callback yield remains at least 20 pps,
|
||||
DSP cadence stays within one hertz of the configured target, the device has
|
||||
zero steady state memory backoff, send failure, watchdog, panic, and reboot
|
||||
events, the server has zero parse failures and reconnects, and the updated node
|
||||
stays fresh. Complete occupancy qualification separately with at least 30
|
||||
absent edge packets and zero absent packets carrying a nonzero person count.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ESP32 C6 occupancy evidence qualification
|
||||
|
||||
## Scope
|
||||
|
||||
This record qualifies the fail closed person count invariant in ADR 346 on one physically attached ESP32 C6. It does not qualify person counting accuracy, identity, pose, room separation, or vital sign accuracy.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
| Field | Measured value |
|
||||
|---|---|
|
||||
| Board | ESP32 C6 QFN40 revision 0.2 |
|
||||
| Logical node | 4 |
|
||||
| Firmware before | 0.7.0 |
|
||||
| Firmware after | 0.8.4 development build |
|
||||
| App image | 1,051,168 bytes |
|
||||
| App SHA 256 | `f9470a31b82612f1740f0cf0943ddb78917cd58784ba16d2e9d57cc8fb39364c` |
|
||||
| OTA slot size | 1,900,544 bytes |
|
||||
| CSI stream target | Preserved from NVS |
|
||||
|
||||
The device partition table was read before the update. NVS, OTA metadata, bootloader, and partition table were not overwritten. A private recovery copy was created outside the repository and excluded from version control.
|
||||
|
||||
## Software gates
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| Firmware host tests | PASS, 54 assertions across encoding, vital evidence, and mmWave detection |
|
||||
| Rust sensing server package | PASS, 532 library tests plus all package integration and documentation tests |
|
||||
| Mobile Jest suite | PASS, 164 suites and 1,223 tests |
|
||||
| Mobile TypeScript | PASS |
|
||||
| Mobile ESLint | PASS |
|
||||
| Mobile security verifier | PASS |
|
||||
| Repository wide Rust formatting | PREEXISTING DRIFT outside this change; changed code builds and package tests pass |
|
||||
|
||||
## Physical result
|
||||
|
||||
MEASURED on 2026 08 31 from the live local sensing WebSocket for 300 seconds:
|
||||
|
||||
| Node | Firmware state | Edge packets | Absent packets | Absent with nonzero count | Result |
|
||||
|---|---|---:|---:|---:|---|
|
||||
| 4 | Updated | 242 | 61 | 0 | PASS |
|
||||
| 3 | Unupdated control | 216 | 216 | 216 | Expected control failure |
|
||||
| 7 | Unupdated control | 280 | 278 | 278 | Expected control failure |
|
||||
|
||||
Node 4 reduced the targeted logical contradiction from observed to zero, a 100 percent reduction for this invariant during this run. This is not a person count accuracy result.
|
||||
|
||||
The WebSocket run had zero JSON parse errors and one expected client close at completion. The sensing server remained ready with `engine_error_count=0`. A separate 45 second serial observation recorded 120 log lines, 17 CSI callback markers, zero ENOMEM backoffs, and zero other error lines.
|
||||
|
||||
The updated OTA status endpoint reports the selected 1,900,544 byte partition rather than the stale 921,600 byte constant. The 1,051,168 byte image therefore fits with 849,376 bytes of partition headroom.
|
||||
|
||||
## Remaining qualification
|
||||
|
||||
Nodes 3 and 7 still demonstrate the old contradictory behavior and must be upgraded only after their network identity, OTA credential, and rollback path are verified. The current run had no labelled ground truth, so multi person fidelity and adjacent room rejection remain unmeasured.
|
||||
|
||||
## Subsequent node 7 status
|
||||
|
||||
Later on 2026 08 31, node 7 was separately identified, backed up, upgraded to firmware 0.8.8, and transport qualified for five minutes. That later occupied room run had zero fused presence count contradictions but did not produce the 30 absent edge packets required to supersede the historical control result above. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Repeat a five minute capture after every firmware change. Pass only when every updated node has at least 30 absent packets, zero packets where `presence=false` and `n_persons>0`, zero parser errors, and a ready sensing server with zero engine errors.
|
||||
@@ -0,0 +1,124 @@
|
||||
# ESP32 C6 rate aware sensing qualification
|
||||
|
||||
## Scope
|
||||
|
||||
This record qualifies ADR 347 on one physically attached ESP32 C6 and verifies
|
||||
that the same source compiles for ESP32 S3. It measures transport cadence, edge
|
||||
DSP cadence, process stability, and end to end sensing delivery. It does not
|
||||
qualify heartbeat, respiration, gesture, pose, identity, or person count
|
||||
accuracy against labelled ground truth.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
| Field | Measured value |
|
||||
|---|---|
|
||||
| Board | ESP32 C6 QFN40 revision 0.2 |
|
||||
| Logical node | 4 |
|
||||
| Firmware before | 0.8.4 |
|
||||
| Firmware after | 0.8.8 development build |
|
||||
| C6 app image | 1,051,552 bytes |
|
||||
| C6 app SHA 256 | `f2ea422c9b99ec13c7a168afc2b019229642769ffabfd8f29a85978770236e87` |
|
||||
| OTA slot size | 1,900,544 bytes |
|
||||
| OTA headroom | 848,992 bytes, 45 percent |
|
||||
| S3 compile image | 1,127,104 bytes |
|
||||
| S3 compile SHA 256 | `63e4f0c484d79e7dd37eb28275951c8beb924e6908942f7fec0b90d92109129c` |
|
||||
|
||||
Only the application partition at offset `0x20000` was flashed. WiFi
|
||||
credentials, node identity, sensing server target, bootloader, partition table,
|
||||
OTA metadata, and NVS were preserved. The pre update OTA application was read
|
||||
to a private recovery file outside the repository. Its SHA 256 is
|
||||
`a2e503f1622b2f3f9c1cfce0a07ba34b9fc5d6a413b6346311622af1fe18a6d8`.
|
||||
|
||||
The OTA status endpoint reported firmware 0.8.8 running from `ota_0` after the
|
||||
update. The sensing server health endpoint remained ready with ESP32 input.
|
||||
|
||||
## Software gates
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| Rate estimator and occupancy host tests | PASS, 30 assertions |
|
||||
| ADR 110 encoding host tests | PASS, 21 assertions |
|
||||
| mmWave frame predicate host tests | PASS, 8 assertions |
|
||||
| ESP32 C6 IDF 5.4 ARM64 build | PASS |
|
||||
| ESP32 S3 IDF 5.4 ARM64 build | PASS, compile only |
|
||||
| Image checksum and validation hash | PASS |
|
||||
| Repository diff whitespace check | PASS |
|
||||
| Local libFuzzer aggregate | NOT RUN, local Xcode toolchain lacks `libclang_rt.fuzzer_osx.a` |
|
||||
|
||||
For this C6 record, the S3 result was source and toolchain validation only and
|
||||
no S3 runtime claim is made here. The later physical S3 Tier 0 transport run is
|
||||
recorded separately in
|
||||
`docs/validation/2026-08-31-esp32-s3-rate-aware-transport.md`.
|
||||
|
||||
## Measured rate correction
|
||||
|
||||
The pre update 20 second C6 baseline delivered a mean 34.05 raw callbacks per
|
||||
second, median 34.5, and range 28 through 37. An intermediate 0.8.7 physical
|
||||
run requested 10 Hz edge DSP but converged to 8.0 through 8.4 Hz while raw CSI
|
||||
remained 30 through 40 packets per second. This proved that C6 Tier 2 compute,
|
||||
not the raw transport, was the limiting path.
|
||||
|
||||
Firmware 0.8.8 therefore keeps the 50 Hz probe and independent raw network
|
||||
path, but sets the C6 Tier 2 DSP clock to its measured sustainable 8 Hz. The
|
||||
phase preserving sampler prevents callback jitter from shifting the configured
|
||||
clock, and the filter estimator follows processed timestamps rather than raw
|
||||
probe intent.
|
||||
|
||||
## Five minute physical result
|
||||
|
||||
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
|
||||
|
||||
| Device observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300.64 seconds |
|
||||
| Controller ticks | 300 |
|
||||
| Raw callback mean | 34.92 pps |
|
||||
| Raw callback range | 22 through 41 pps |
|
||||
| Edge DSP mean | 8.00 Hz |
|
||||
| Edge DSP range | 8.00 through 8.00 Hz |
|
||||
| ENOMEM events | 0 |
|
||||
| UDP send failures | 0 |
|
||||
| Other steady state errors | 0 |
|
||||
| Watchdogs, panics, or reboots | 0 |
|
||||
|
||||
| End to end WebSocket observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300.01 seconds |
|
||||
| Sensing frames | 26,786 |
|
||||
| JSON parse errors | 0 |
|
||||
| Reconnects | 0 |
|
||||
| Frames containing node 4 | 26,148 |
|
||||
| Node 4 frame coverage | 97.62 percent |
|
||||
| Node 4 stale frames | 0 |
|
||||
| Maximum node 4 inference age | 176 ms |
|
||||
| Maximum WebSocket frame gap | 110 ms |
|
||||
| Nodes per frame | 0 through 4 |
|
||||
| Fused `presence=false` with nonzero count contradictions | 0 |
|
||||
|
||||
The boot log emitted one expected iTWT negotiation error because the access
|
||||
point rejected the requested target wake time parameters. Firmware immediately
|
||||
selected its documented opportunistic CSI fallback. No iTWT or other error
|
||||
recurred during the five minute steady state window.
|
||||
|
||||
## Result and limitation
|
||||
|
||||
ADR 347 timing and transport acceptance passes on the attached C6. Raw
|
||||
throughput did not regress relative to the short baseline, the edge clock now
|
||||
matches the rate the temporal filters actually receive, and node 4 was never
|
||||
stale when present in the live sensing service. The separate occupancy
|
||||
qualification recorded 61 absent node 4 packets with zero contradictions for
|
||||
the unchanged fail closed invariant. This run did not repeat an empty room
|
||||
sequence because the room was occupied during qualification.
|
||||
|
||||
The largest remaining uncertainty is inference accuracy. Stable timing removes
|
||||
one source of feature distortion but cannot prove better heartbeat, respiration,
|
||||
gesture, or multi person classification without synchronized held out labels.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Repeat this five minute procedure after any timing, WiFi, filter, or task
|
||||
scheduling change. Pass only when raw callback yield remains at least 20 pps,
|
||||
DSP cadence remains within one hertz of the configured target, the device has
|
||||
zero steady state ENOMEM, send failure, watchdog, panic, and reboot events, the
|
||||
server has zero parse failures and reconnects, node 4 stays fresh, and fused
|
||||
presence count contradictions remain zero.
|
||||
@@ -0,0 +1,126 @@
|
||||
# ESP32 S3 rate aware transport qualification
|
||||
|
||||
## Scope
|
||||
|
||||
This record qualifies the firmware 0.8.8 raw transport path on one physically
|
||||
attached ESP32 S3. The node retained its existing Tier 0 configuration, so this
|
||||
run does not qualify the S3 edge DSP rate, temporal filters, heartbeat,
|
||||
respiration, gesture, pose, identity, person count, or localization accuracy.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
| Field | Measured value |
|
||||
|---|---|
|
||||
| Board | ESP32 S3 QFN56 revision 0.2 with 2 MB embedded PSRAM |
|
||||
| Logical node | 1 |
|
||||
| Firmware before | 0.8.4 |
|
||||
| Firmware after | 0.8.8 development build |
|
||||
| Edge tier | 0, raw passthrough |
|
||||
| App image | 1,127,104 bytes |
|
||||
| App SHA 256 | `b531c76900c07d0d6f6e864a5f28afff3e71f124777af97358bb405b34e339a2` |
|
||||
| OTA slot size | 2,097,152 bytes |
|
||||
| OTA headroom | 970,048 bytes, 46 percent |
|
||||
|
||||
The production partition table was read from the device before the update.
|
||||
Only the application partition at offset `0x20000` was flashed. WiFi
|
||||
credentials, logical node identity, channel, sensing server target, bootloader,
|
||||
partition table, OTA metadata, and NVS were preserved. A private recovery copy
|
||||
of the prior 2 MB application partition was saved outside the repository. Its
|
||||
SHA 256 is
|
||||
`14e72c060c4f1a465f739949b873f6aade5b8899a8909f6c5bb591ee49837c1b`.
|
||||
|
||||
The post update boot log reported firmware 0.8.8, logical node 1, channel 4,
|
||||
the preserved UDP target, Tier 0 raw passthrough, and successful CSI streaming.
|
||||
The OTA status endpoint reported firmware 0.8.8 running from `ota_0` with the
|
||||
correct 2,097,152 byte update limit. The sensing server health endpoint remained
|
||||
ready with ESP32 input.
|
||||
|
||||
## Software and image gates
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| Firmware encoding, vitals, occupancy, and mmWave host tests | PASS, 59 assertions |
|
||||
| Firmware provisioning Python tests | PASS, 14 tests |
|
||||
| ESP32 S3 IDF 5.4 ARM64 clean build | PASS |
|
||||
| Image target detection | PASS, ESP32 S3 |
|
||||
| Image checksum | PASS |
|
||||
| Image validation hash | PASS |
|
||||
| Application partition fit | PASS, 46 percent free |
|
||||
| Physical flash write verification | PASS |
|
||||
| Preserved runtime configuration | PASS |
|
||||
|
||||
The build excluded the optional WASM3 source because it was not present in the
|
||||
firmware checkout. The boot log therefore reported WASM Tier 3 disabled. That
|
||||
is not a regression introduced by this update and is outside this transport
|
||||
qualification.
|
||||
|
||||
## Before and after comparison
|
||||
|
||||
The pre update baseline was a 20 second serial and WebSocket capture on firmware
|
||||
0.8.4. The post update stability observation was 300 seconds on firmware 0.8.8.
|
||||
|
||||
| Observation | Before 0.8.4 | After 0.8.8 | Change |
|
||||
|---|---:|---:|---:|
|
||||
| Raw CSI yield mean | 27.80 pps | 28.03 pps | plus 0.83 percent |
|
||||
| Server CSI FPS mean | 39.58 | 39.13 | minus 1.15 percent |
|
||||
| Node frame coverage | 100 percent | 100 percent | unchanged |
|
||||
| Maximum node staleness | 1,571 ms | 1,565 ms | minus 0.38 percent |
|
||||
| Maximum WebSocket frame gap | 111 ms | 112 ms | plus 0.90 percent |
|
||||
| Device or parser errors | 0 | 0 | unchanged |
|
||||
|
||||
These small movements are operationally neutral and within uncontrolled room
|
||||
and WiFi variation. Firmware 0.8.8 did not regress the raw transport. Because
|
||||
Tier 0 bypasses the DSP task, this run provides no evidence that temporal
|
||||
features or inference accuracy improved.
|
||||
|
||||
## Five minute physical result
|
||||
|
||||
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
|
||||
|
||||
| Device observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300 seconds |
|
||||
| Controller yield samples | 300 |
|
||||
| Raw CSI yield mean | 28.03 pps |
|
||||
| Raw CSI yield range | 22 through 34 pps |
|
||||
| ENOMEM or stack errors | 0 |
|
||||
| UDP send failures | 0 |
|
||||
| ESP NOW send failures | 0 |
|
||||
| Unexpected resets | 0 |
|
||||
| Watchdogs or panics | 0 |
|
||||
|
||||
| End to end WebSocket observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300.03 seconds |
|
||||
| Sensing frames | 10,559 |
|
||||
| Frames containing node 1 | 10,559 |
|
||||
| Node 1 frame coverage | 100 percent |
|
||||
| Source offline frames | 0 |
|
||||
| JSON parse errors | 0 |
|
||||
| WebSocket errors | 0 |
|
||||
| Early closes | 0 |
|
||||
| Maximum node staleness | 1,565 ms |
|
||||
| Maximum WebSocket frame gap | 112 ms |
|
||||
|
||||
## Result and limitation
|
||||
|
||||
The ESP32 S3 raw transport acceptance passes. Firmware 0.8.8 booted from the
|
||||
existing slot, retained the installation configuration, sustained the prior raw
|
||||
CSI delivery rate, and completed the burn with zero transport or runtime
|
||||
errors. The result extends ADR 347 physical coverage to the S3 transport path.
|
||||
|
||||
The largest remaining uncertainty is S3 Tier 2 behavior and inference accuracy.
|
||||
The log line reporting the configured 20 Hz DSP cadence is not proof that DSP
|
||||
ran because the preserved Tier 0 setting explicitly disables the DSP task. A
|
||||
separate, rollback protected Tier 2 qualification with synchronized held out
|
||||
labels is required before making heartbeat, respiration, motion, or accuracy
|
||||
claims.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Repeat this five minute procedure after any S3 timing, WiFi, transport, or task
|
||||
scheduling change. Pass only when raw callback yield remains at least 20 pps,
|
||||
node frame coverage remains at least 99 percent, and the device and server have
|
||||
zero send failures, offline frames, parser errors, WebSocket errors, watchdogs,
|
||||
panics, and unexpected resets. Qualify Tier 2 separately and require its
|
||||
measured DSP cadence to stay within one hertz of the configured target.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Native macOS USB onboarding firmware witness
|
||||
|
||||
## Evidence class
|
||||
|
||||
MEASURED on 2026-08-31 with one physical ESP32 S3. This is not an ESP32 C6 or
|
||||
release distribution qualification.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
The board was identified by the flashing tool as ESP32 S3 QFN56 revision 0.2
|
||||
with 2 MB embedded PSRAM and 8 MB flash. Firmware 0.8.9 was built for ESP32 S3
|
||||
with the displayless DevKitC defaults. The private configuration region was
|
||||
backed up before flashing. The flash operation omitted NVS, preserving the
|
||||
existing WiFi configuration.
|
||||
|
||||
No base MAC, WiFi credential, device digest, or raw CSI is retained in this
|
||||
witness.
|
||||
|
||||
## Results
|
||||
|
||||
1. The initial native USB scan failed because the ESP-IDF secondary USB
|
||||
Serial/JTAG console mirrors output but does not forward input to STDIN.
|
||||
2. Firmware was corrected to read the bounded onboarding protocol from the
|
||||
secondary VFS while retaining STDIN for UART console boards.
|
||||
3. A direct nonce challenge then returned ESP32 S3, firmware 0.8.9, configured
|
||||
state, the current node identity, and the private server route.
|
||||
4. The Mac app changed the collided identity from 1 to unused identity 5 while
|
||||
preserving WiFi. It showed success only after receiving fresh server
|
||||
evidence at minus 56 dBm.
|
||||
5. A five minute monitor collected 299 samples with zero node 5 missing events,
|
||||
zero node 5 stale events, zero server health failures, minus 57 to minus 50
|
||||
dBm RSSI, and 216 ms maximum packet age.
|
||||
6. Firmware uptime exceeded 376 seconds. Captured logs advanced from CSI
|
||||
callback 10,600 through 11,000, emitted a node 5 synchronization packet, and
|
||||
showed ESP NOW transmit failures equal to zero without reboot, watchdog, or
|
||||
panic evidence.
|
||||
7. The final channel isolation hardening image was flashed and challenged
|
||||
again. A following 60 sample check had zero missing, stale, or health failure
|
||||
events, a maximum packet age of 100 ms, and minus 57 to minus 50 dBm RSSI.
|
||||
|
||||
## Software gates
|
||||
|
||||
The nonce and configuration parser host test passed, the complete ESP32 S3
|
||||
firmware image built under ESP-IDF 5.4, and the application image occupied
|
||||
941,056 bytes with 55 percent of the smallest application partition free.
|
||||
|
||||
## Remaining gates
|
||||
|
||||
New WiFi credential entry, signed Mac distribution, and cross platform serial
|
||||
bridges remain unmeasured.
|
||||
|
||||
## ESP32 C6 follow up
|
||||
|
||||
On 2026-09-11, an ESP32 C6 on `cu.usbserial-3120` completed the Mac app flash,
|
||||
hello, configure, and verify sequence with firmware 0.8.12. The app preserved
|
||||
the existing node identity 3 and verified fresh sensing server evidence at
|
||||
minus 34 dBm. The board returned `RUVIEW_HELLO_OK_V1` within the bounded serial
|
||||
exchange after the C6 mmWave defaults moved from the UART0 RX conflict on GPIO
|
||||
17 to GPIO 4 and GPIO 5. This closes only the physical C6 onboarding gate. It
|
||||
does not qualify new WiFi credential entry or signed distribution.
|
||||
@@ -0,0 +1,206 @@
|
||||
# RuForecast requirement to evidence matrix
|
||||
|
||||
## Authority and current state
|
||||
|
||||
This matrix operationalizes ADR-348 and its ADR-349/350 children without
|
||||
changing them. The ADRs own the requirements and gates. Rust tests, immutable
|
||||
reports, signed receipts, and explicit human approvals supply evidence. A
|
||||
checked box or green build is not permission to deploy, publish a checkpoint,
|
||||
upload customer-derived data, or advance the rollout mode.
|
||||
|
||||
Current state: all release and operational gates are open. No benchmark result,
|
||||
trained checkpoint, external training receipt, or production authority is
|
||||
recorded by this document.
|
||||
|
||||
## Focused verification commands
|
||||
|
||||
The feature-off contract remains on the workspace Rust 1.89 line:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo +1.89.0 test --locked -p ruview-forecast-core --no-default-features --lib --tests
|
||||
cargo +1.89.0 test --locked -p ruview-forecast-model --no-default-features --lib --tests
|
||||
cargo +1.89.0 check --locked -p ruview-forecast-model -p ruview-forecast-train --no-default-features --all-targets
|
||||
cargo +1.89.0 test --locked -p ruview-forecast-model --no-default-features --features ruvector --lib --tests
|
||||
cargo +1.89.0 clippy --locked -p ruview-forecast-core -p ruview-forecast-model -p ruview-forecast-train --no-default-features --all-targets -- -D warnings
|
||||
cargo +1.89.0 clippy --locked -p ruview-forecast-model --no-default-features --features ruvector --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Burn 0.21 CPU activation uses the explicitly separate Rust 1.92 line:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo +1.92.0 test --locked -p ruview-forecast-model --no-default-features --features cpu --lib --tests
|
||||
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli --lib --bins
|
||||
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu --test local_jsonl_smoke -- --exact local_hash_addressed_jsonl_executes_one_real_optimizer_step
|
||||
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli --test cli_smoke -- --exact cli_smoke_trains_and_writes_the_complete_candidate_set
|
||||
cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli,server,fal-client --lib --bins
|
||||
cargo +1.92.0 clippy --locked -p ruview-forecast-model --no-default-features --features cpu --all-targets -- -D warnings
|
||||
cargo +1.92.0 clippy --locked -p ruview-forecast-train --no-default-features --features cpu,cli,server,fal-client --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
The CUDA line is compile-only. It proves that the explicitly gated types build
|
||||
on Rust 1.92; it is not a GPU execution, training, latency, or compatibility
|
||||
claim:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo +1.92.0 check --locked -p ruview-forecast-model --no-default-features --features cuda --lib
|
||||
cargo +1.92.0 check --locked -p ruview-forecast-train --no-default-features --features cuda,cli,server --lib --bins
|
||||
```
|
||||
|
||||
The hosted boundary stays backend-free and is exercised on Rust 1.89 with no
|
||||
provider credential. Tests must use local mocks; compiling these features does
|
||||
not authorize network use or a hosted training run:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo +1.89.0 tree --locked -e normal,build -p ruview-forecast-train --no-default-features --features cli,server,fal-client > forecast-hosted-feature-tree.txt
|
||||
! grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' forecast-hosted-feature-tree.txt
|
||||
FAL_KEY='' cargo +1.89.0 test --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client --lib --bins --tests
|
||||
FAL_KEY='' cargo +1.89.0 test --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client privacy_external_dataset_payload_is_denied
|
||||
FAL_KEY='' cargo +1.89.0 clippy --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
The broader repository gate remains:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo +1.89.0 test --locked --workspace --no-default-features
|
||||
cargo +1.89.0 bench --locked --workspace --no-default-features --no-run
|
||||
```
|
||||
|
||||
No CUDA runtime result is implied by these commands. CUDA execution needs a
|
||||
separately identified GPU, driver, toolkit, source commit, container digest,
|
||||
and signed training or inference receipt.
|
||||
|
||||
The repository-wide security workflow separately hard-gates the checked-in
|
||||
lockfile with `cargo audit --file v2/Cargo.lock --json`. The forecast workflow
|
||||
also runs both modes of `scripts/csi-data-policy-check.sh`, rejects forbidden
|
||||
clean-room source/import names and model blobs, checks the hosted DTO field
|
||||
surface, and blocks common literal private-key, FAL-key, JWT, cloud-key and
|
||||
presigned-URL patterns. These focused checks do not close dependency-license or
|
||||
complete secret-scanning evidence: the repository has no committed
|
||||
`v2/deny.toml`, inherited advisory/yanked-version debt needs an
|
||||
owner/date/expiry baseline, and the existing general secret scanners are
|
||||
non-blocking. Until dedicated retained reports exist, those parts of G0, G1,
|
||||
and G5 remain open.
|
||||
|
||||
### Supply-chain and secret release prerequisites
|
||||
|
||||
| Check | Reproducer | Present authority |
|
||||
|---|---|---|
|
||||
| Advisory database | `cargo audit --file v2/Cargo.lock --json` | Existing blocking workflow; report retained |
|
||||
| Advisory warnings | `cargo audit --file v2/Cargo.lock --deny warnings` | Not green by assertion; inherited warning debt needs an expiring reviewed baseline |
|
||||
| Rust sources/licenses/bans | `cargo deny --manifest-path v2/Cargo.toml --config v2/deny.toml check advisories bans licenses sources` | Blocked until a reviewed `v2/deny.toml` is committed and CI pins `cargo-deny` |
|
||||
| Forecast-path secrets | `gitleaks detect --no-banner --redact --source .` | Focused CI blocks common literal patterns; the broader GitLeaks job/report is still non-blocking |
|
||||
| Tracked sensing data | `bash scripts/csi-data-policy-check.sh --self-test && bash scripts/csi-data-policy-check.sh --tracked` | Blocking in the focused hosted-boundary job |
|
||||
|
||||
`cargo vet check` becomes a release prerequisite only if the project commits a
|
||||
vet policy and review process; listing it without that policy would be a false
|
||||
supply-chain claim.
|
||||
|
||||
### Existing baseline debt and scope
|
||||
|
||||
- [`../benchmarks/physics-pose-refinement.md`](../benchmarks/physics-pose-refinement.md)
|
||||
records unrelated workspace rustfmt/warning debt, an incomplete full-workspace
|
||||
test run on that authoring host, existing RustSec debt, and yanked
|
||||
`spin 0.9.8` in the optional Burn graph. Forecast CI therefore uses focused
|
||||
warnings-denied gates and does not describe the whole workspace as green.
|
||||
- [`.github/workflows/bench-regression.yml`](../../.github/workflows/bench-regression.yml)
|
||||
records an upstream stable-Rust failure in optional `ruvector-crv`; the
|
||||
focused forecast benchmarks neither enable nor inherit a CRV claim.
|
||||
- The current ADR corpus contains pre-existing duplicate ADR-263 and ADR-264
|
||||
numbers. The focused contract rejects a duplicate ADR-348, ADR-349 or ADR-350
|
||||
without falsely asserting that the historic corpus already passes a global
|
||||
uniqueness lint.
|
||||
- Shared GitHub-hosted timing is noisy by repository policy. Only benchmark
|
||||
compilation gates the PR; timing logs are informational until repeated on a
|
||||
named, controlled host.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state |
|
||||
|---|---|---|---|---|
|
||||
| RF-001 | Forecast-path source and artifact scan; forbidden model/blob fixture scan; clean-room manifest schema and tamper tests | Current contributor attestations and clean-room custodian adjudication | ADR-348 G0 | OPEN |
|
||||
| RF-002 | Canonical receipt round trip; digest/signature tamper negatives; source, config, dataset and checkpoint identifiers required | Reviewer verifies every dependency, reference, job and checkpoint lineage | ADR-348 G0 and G1 | OPEN |
|
||||
| RF-003 | Dataset manifest rejects missing license, incompatible use, unknown privacy class and unresolved bytes | Data steward and legal approval for every immutable dataset digest | ADR-348 G1 | OPEN |
|
||||
| RF-004 | Feature-off dependency boundary excludes Burn/CubeCL; fixed artifact/input CPU replay; offline load tests; bounded property and fuzz corpus | Named platform-class review and 24-hour accelerated replay | ADR-348 G2 | OPEN |
|
||||
| RF-005 | Shape/product overflow, finite-value, timestamp, mask, schema, horizon, quantile and resource-limit tests | Review of production input caps and abstention thresholds | ADR-348 G2 | OPEN |
|
||||
| RF-006 | Forecast evidence label is immutable; observation hash round trips; confidence cannot increase; derived values cannot overwrite observations | Evidence-engine and downstream schema review | ADR-348 G2 and G4 | OPEN |
|
||||
| RF-007 | Frozen split manifest; train-only normalization/calibration; quantile order; interval coverage and loss by horizon/domain | Independent evaluation report on untouched site/session/device holdouts | ADR-348 G3 | OPEN |
|
||||
| RF-008 | Per-split RuVector index isolation; no overlapping horizon or holdout neighbour; paired retrieval-off/retrieval-on report | Reviewer verifies index manifest and ablation comparability | ADR-348 G3 | OPEN |
|
||||
| RF-009 | Capability tests prove the forecast and RuVLLM explanation have no actuator, spending, access-control, emergency or model-promotion authority | Downstream policy owner approves any advisory consumer | ADR-348 G4 | OPEN |
|
||||
| RF-010 | Local and hosted receipts compare source, lock, container, data, config and initial-weight digests; provider result is untrusted until verified | Provider retention, log, credential and data-processing review | ADR-348 G1 and G5 | OPEN |
|
||||
| RF-011 | Benchmark report binds commit, lock, toolchain, host, backend, config, corpus, command and evidence label | Maintainer adjudicates claim label and model card language | ADR-348 G3 through G5 | OPEN |
|
||||
| RF-012 | Mode transition and rollback state-machine tests; failed activation retains the prior artifact; raw sensing continues | Signed rollback drill and authorized mode-transition record | ADR-348 G5 | OPEN |
|
||||
|
||||
## Governed training requirements
|
||||
|
||||
These rows trace ADR-349. A mock-backed test can close a software subcondition,
|
||||
but cannot close the hosted operational evidence named in the ADR.
|
||||
|
||||
| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state |
|
||||
|---|---|---|---|---|
|
||||
| FT-001 | Synthetic hosted DTO and reconstructed local request bind the same generator, model, optimizer and build identities; external manifests are rejected | Reviewer compares immutable local and provider receipts | ADR-349 and G1 | OPEN |
|
||||
| FT-002 | Unknown-field and arbitrary command/image/URL/environment/path negatives | Endpoint capability and worker-image review | ADR-349 and G2 | OPEN |
|
||||
| FT-003 | Concurrent/retry/lost-response idempotency state-machine tests | Real provider ambiguous-retry drill and cost reconciliation | ADR-349 and G1/G5 | OPEN |
|
||||
| FT-004 | Budget boundary and over-budget rejection tests | Provider price/bill fixture review and bounded real-run receipt | ADR-349 and G5 | OPEN |
|
||||
| FT-005 | Authenticated cancellation, checkpoint and terminal-state property tests | Real hosted cancellation and orphan-job drill | ADR-349 and G2/G5 | OPEN |
|
||||
| FT-006 | Missing/duplicate/truncated/tampered fixed-kind export matrix | Quarantine/export receipt review | ADR-349 and G1/G2 | OPEN |
|
||||
| FT-007 | Hosted-signing denial, local verification and atomic promotion/rollback tests | Release-key isolation approval and rollback drill | ADR-349 and G1/G5 | OPEN |
|
||||
| FT-008 | Captured egress and log-redaction tests for credentials/privacy/retention fields | Provider DPA, region, retention, reuse and deletion review | ADR-349 and G1/G5 | OPEN |
|
||||
|
||||
## Predictive-memory and explanation requirements
|
||||
|
||||
These rows trace ADR-350. This PR creates no sensing-server, RuVector or RuVLLM
|
||||
runtime authority; the rows remain open until a separately reviewed bridge
|
||||
provides the evidence.
|
||||
|
||||
| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state |
|
||||
|---|---|---|---|---|
|
||||
| PM-001 | Cross-tenant/split/version/time-scope query negatives | Signed index-manifest and tenancy review | ADR-350 and G3/G5 | OPEN |
|
||||
| PM-002 | Property tests reject holdout identities and overlapping contexts/horizons | Frozen leakage-report review | ADR-350 and G3 | OPEN |
|
||||
| PM-003 | Bounded zero/error/success retrieval receipts and tamper tests | Retrieval receipt schema approval | ADR-350 and G2/G3 | OPEN |
|
||||
| PM-004 | Same-example, same-artifact paired retrieval-off/on evaluator | Frozen paired ablation and overhead report | ADR-350 and G3 | OPEN |
|
||||
| PM-005 | Envelope signature mutation, expiry, tenant, key and replay negatives | Local signing/key-rotation receipt | ADR-350 and G2/G5 | OPEN |
|
||||
| PM-006 | Evidence-monotonicity property tests reject measured-observation promotion | Evidence-engine owner review | ADR-350 and G2/G4 | OPEN |
|
||||
| PM-007 | Numeric/unit/provenance mutation corpus fails closed | RuVLLM adapter review | ADR-350 and G4 | OPEN |
|
||||
| PM-008 | Default-deny capability and attempted-escalation tests | Downstream policy/capability review | ADR-350 and G4/G5 | OPEN |
|
||||
| PM-009 | Tenant deletion, retention and access-audit tests | Privacy and membership/extraction-risk approval | ADR-350 and G5 | OPEN |
|
||||
|
||||
## Threat-model contract coverage
|
||||
|
||||
The IDs below are defined by
|
||||
[`../security/ruview-forecast-threat-model.md`](../security/ruview-forecast-threat-model.md).
|
||||
The feature job compiles and runs the current mock-backed suite, but an ID stays
|
||||
open until the named negative test and its retained CI report exist.
|
||||
|
||||
| Contract IDs | Required machine evidence | Current state |
|
||||
|---|---|---|
|
||||
| PRIV-001 through PRIV-009 | Egress-denial, tenant-isolation, log-redaction, nonce-isolation and tracked-data-policy negatives | OPEN |
|
||||
| AUTH-001 through AUTH-005; STATE-001 through STATE-004 | Fail-closed identity/scope/service-auth tests and property-tested idempotent transition machine | OPEN |
|
||||
| BOUND-001 through BOUND-007 | Boundary/property cases, parser fuzzing, cumulative resource limits and bounded accelerated replay | OPEN |
|
||||
| RVEC-001 through RVEC-006 | Scope-authority construction denial, identifier/privacy bounds, isolation, cancellation and non-persistence evidence | OPEN |
|
||||
| FAL-001 through FAL-012; SSRF-001; PATH-001 through PATH-003 | Mock request capture, exact-body webhook/replay cases, app/build/expiry binding, URL/DNS/redirect denial and filesystem escape negatives | OPEN |
|
||||
| ART-001 through ART-005; DE-001; EVID-001 through EVID-002 | Artifact tamper/rollback/crash matrix, trusted-type construction denial and evidence-authority invariants | OPEN |
|
||||
|
||||
## Gate evidence bundles
|
||||
|
||||
| Gate | Minimum bundle before review | Status |
|
||||
|---|---|---|
|
||||
| G0 | Clean-room manifest, contributor attestations, repository/artifact scan report, adjudication log | OPEN |
|
||||
| G1 | Dataset rights manifests, byte-level lineage report, random-initialization receipt, local/hosted digest comparison | OPEN |
|
||||
| G2 | Focused tests, property/fuzz reports, deterministic replay hash, dependency-boundary tree, 24-hour replay report | OPEN |
|
||||
| G3 | Frozen split manifest, baseline/model/retrieval rows, weighted quantile loss, interval calibration, reproducer and report digests | OPEN |
|
||||
| G4 | Fourteen-day shadow report, empty-room/occupied-room confusion matrices, drift/abstention slices, authority audit | OPEN |
|
||||
| G5 | Dedicated-host latency and process-RSS report, signed model card, SBOM/provenance, rollback drill, security/privacy/legal approvals | OPEN |
|
||||
|
||||
## Evidence record template
|
||||
|
||||
Copy one row per immutable evidence artifact. Never replace a failed result.
|
||||
|
||||
| Evidence ID | Requirement/gate | Commit | Lock/config/data digest | Environment | Command | Result | Evidence label | Artifact digest | Reviewer/date |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|
||||
No evidence artifacts have been accepted.
|
||||
@@ -7,40 +7,107 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
|
||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||
[](../../LICENSE)
|
||||
[](#memory-budget)
|
||||
[](#memory-budget)
|
||||
[](../../.github/workflows/firmware-ci.yml)
|
||||
|
||||
> | Capability | Method | Performance |
|
||||
> |------------|--------|-------------|
|
||||
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | ~20 Hz, ADR-018 binary format |
|
||||
> | **Breathing detection** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | 6-30 BPM |
|
||||
> | **Heart rate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | 40-120 BPM |
|
||||
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
|
||||
> | Capability | Method | Current contract |
|
||||
> |------------|--------|------------------|
|
||||
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | Radio-dependent cadence with a 20 packets-per-second hardware acceptance floor, ADR-018 binary format |
|
||||
> | **Breathing estimate** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | Experimental 6-30 BPM output; calibrate against a reference before use |
|
||||
> | **Heart-rate estimate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | Experimental 40-120 BPM output; not a medical measurement |
|
||||
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | Fast local indicator; strong RF interference can cause false positives — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
|
||||
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
|
||||
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
|
||||
|
||||
## Firmware 0.8.12: guided Mac onboarding
|
||||
|
||||
Firmware 0.8.12 adds the protocol used by the RuView macOS Add
|
||||
Sensor workflow. A directly attached S3 or C6 can identify itself through a
|
||||
nonce bound USB serial receipt, preserve its current WiFi settings, accept an
|
||||
unused node ID and private LAN server address, then reboot. The Mac app does
|
||||
not report success until the sensing server observes that node as fresh.
|
||||
|
||||
This is not a firmware flashing API. A board on 0.8.8 or earlier must receive a
|
||||
matching chip build through the documented backup and flash process once. The
|
||||
USB protocol rejects public target addresses and invalid identifiers. New WiFi
|
||||
credentials are bounded, committed only in NVS, and never included in the
|
||||
receipt or diagnostic output. See
|
||||
[ADR 363](../../docs/adr/ADR-363-native-macos-usb-node-onboarding.md).
|
||||
|
||||
On ESP32 C6, the default optional mmWave UART uses GPIO 4 and GPIO 5. Firmware
|
||||
refuses any mmWave configuration that overlaps the active console pins. This
|
||||
prevents a sensor probe from taking over UART0 RX and disabling Mac onboarding.
|
||||
|
||||
## Firmware 0.8.8 in plain language
|
||||
|
||||
Release 0.8.8 makes the sensing stream more internally consistent and easier
|
||||
to diagnose:
|
||||
|
||||
1. An empty-room decision can no longer carry a nonzero person count. Older
|
||||
firmware could expose those two contradictory values at the same time.
|
||||
2. ESP32-C6 signal processing now uses a stable 8 Hz clock while raw CSI keeps
|
||||
streaming at the faster radio-dependent rate. This prevents temporal
|
||||
filters from silently using the wrong time scale.
|
||||
3. The one-second diagnostic reports both raw callback yield and the DSP rate,
|
||||
making slow or overloaded nodes visible.
|
||||
4. OTA reports the application slot selected by the board instead of assuming
|
||||
a fixed 900 KB limit.
|
||||
|
||||
Two ESP32-C6 boards and one ESP32-S3 completed five-minute physical transport
|
||||
runs. The updated nodes had zero steady-state send failures, parser failures,
|
||||
watchdogs, panics, or reboots. These results prove timing and transport
|
||||
stability, not better heartbeat, pose, identity, or person-count accuracy.
|
||||
See the [0.8.8 release notes](../../docs/releases/v0.8.8-esp32.md) and
|
||||
[ADR 347](../../docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md) for the
|
||||
measured evidence and limitations.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
For users who want to get running fast. Detailed explanations follow in later sections.
|
||||
|
||||
### 0. Pre-built binaries (v0.6.5 — skip the build step)
|
||||
### 0. Download the 0.8.8 release
|
||||
|
||||
Pre-built binaries are in `firmware/esp32-csi-node/release_bins/` (version: see `release_bins/version.txt`).
|
||||
Flash them directly:
|
||||
Use the versioned source tag and binaries on the
|
||||
[v0.8.8 ESP32 release page](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32).
|
||||
Choose the package that names both your chip and flash size:
|
||||
|
||||
| Package | Use it for |
|
||||
|---------|------------|
|
||||
| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 8 MB flash |
|
||||
| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 4 MB flash |
|
||||
| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | Fresh ESP32-C6 installation using the supported 4 MB layout |
|
||||
|
||||
Each bundle contains the matching bootloader, partition table, OTA metadata,
|
||||
application, checksums, and a short flashing guide. Never flash an S3 bundle
|
||||
onto a C6, or a C6 bundle onto an S3.
|
||||
|
||||
Example for an 8 MB ESP32-S3 after extracting its bundle:
|
||||
|
||||
```bash
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
write_flash --flash_mode dio --flash_size 8MB \
|
||||
0x0 firmware/esp32-csi-node/release_bins/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/release_bins/partition-table.bin \
|
||||
0xf000 firmware/esp32-csi-node/release_bins/ota_data_initial.bin \
|
||||
0x20000 firmware/esp32-csi-node/release_bins/esp32-csi-node.bin
|
||||
0x0 bootloader.bin \
|
||||
0x8000 partition-table.bin \
|
||||
0xf000 ota_data_initial.bin \
|
||||
0x20000 esp32-csi-node.bin
|
||||
```
|
||||
|
||||
For 4 MB boards use `release_bins/esp32-csi-node-4mb.bin` and `release_bins/partition-table-4mb.bin`
|
||||
with `--flash_size 4MB`.
|
||||
For an existing provisioned node, back up its current application and inspect
|
||||
`http://DEVICE_IP:8032/ota/status` before choosing an application-only update.
|
||||
Writing only offset `0x20000` is safe only when the status endpoint reports
|
||||
`running_partition` as `ota_0` and the downloaded image matches the board.
|
||||
|
||||
After an OTA, the same endpoint's `ota_state` must read `valid` before you
|
||||
power-cycle the node or push again. `pending_verify` means the image has not
|
||||
passed its first-boot health check yet and would revert on reset. A passed
|
||||
check means only that the node reached the network and sent CSI. See
|
||||
[RUNBOOK §2.1](RUNBOOK.md) and ADR-379.
|
||||
|
||||
The full bundles do not include NVS, so the documented four-offset install
|
||||
preserves WiFi and node configuration while replacing the boot and application
|
||||
images.
|
||||
|
||||
### 1. Build (Docker -- the only reliable method)
|
||||
|
||||
@@ -74,13 +141,43 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
#### Seeed XIAO ESP32-C6 external antenna
|
||||
|
||||
The XIAO ESP32-C6 has a hardware RF switch between its internal ceramic
|
||||
antenna and U.FL connector. Generic C6 boards do not share this wiring, so the
|
||||
firmware leaves GPIO3 and GPIO14 untouched by default. For an XIAO with an
|
||||
external antenna already attached while powered off, build with one of the
|
||||
opt-in overlays:
|
||||
|
||||
```bash
|
||||
# Controlled A condition: internal antenna, RF path stated in the boot log.
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-internal" \
|
||||
set-target esp32c6
|
||||
idf.py build
|
||||
|
||||
# Controlled B condition: external U.FL antenna.
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" \
|
||||
set-target esp32c6
|
||||
idf.py build
|
||||
```
|
||||
|
||||
The boot log must report the intended `internal ceramic` or `external U.FL`
|
||||
path before that measurement is labelled. The standard C6 build keeps the
|
||||
board default and emits no antenna-path claim.
|
||||
|
||||
### 3. Provision WiFi credentials (no reflash needed)
|
||||
|
||||
```bash
|
||||
# Keep the WiFi password in an owner-only file, not on the command line.
|
||||
printf '%s\n' 'YourPass' > ~/.wifi-pass && chmod 600 ~/.wifi-pass
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
|
||||
--ssid "YourSSID" --password-file ~/.wifi-pass --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
Leave out both `--password` and `--password-file` and the script asks for the
|
||||
password when run from a terminal. `--password` still works, but it shows up in
|
||||
`ps` and shell history.
|
||||
|
||||
### 4. Start the sensing server
|
||||
|
||||
```bash
|
||||
@@ -111,7 +208,7 @@ curl http://<ESP32_IP>:8032/wasm/list
|
||||
| **Recommended boards** | ESP32-S3-DevKitC-1, XIAO ESP32-S3 | Any ESP32-S3 with 8 MB flash works |
|
||||
| **Deployment** | 3-6 nodes per room | Multistatic mesh for 360-degree coverage |
|
||||
|
||||
> **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps.
|
||||
> **Tip:** A single node is mainly useful for presence and motion along one RF link. Three or more spatially separated links improve geometry and track separation. Location, pose, and multi-person accuracy still require room-specific calibration and held-out ground-truth evaluation.
|
||||
|
||||
> **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing.
|
||||
|
||||
@@ -216,10 +313,17 @@ Offset Size Field
|
||||
12 4 Sequence number (LE u32)
|
||||
16 1 RSSI (i8)
|
||||
17 1 Noise floor (i8)
|
||||
18 2 Reserved
|
||||
18 1 PPDU type (ADR-110; zero when tagging is disabled)
|
||||
19 1 Flags: bit0=40 MHz, bit2=STBC, bit4=sync valid,
|
||||
bit5=invalid first CSI word zeroed by firmware
|
||||
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
```
|
||||
|
||||
When ESP-IDF marks `first_word_invalid`, the firmware preserves packet geometry,
|
||||
zeros the first four CSI bytes, and sets byte 19 bit 5. The same sanitized bytes
|
||||
feed the edge DSP path. This prevents a documented hardware artifact from being
|
||||
learned as motion while keeping older readers wire compatible.
|
||||
|
||||
### Vitals Packet (32 bytes)
|
||||
|
||||
```
|
||||
@@ -271,6 +375,14 @@ The `MSYS_NO_PATHCONV=1` prefix prevents Git Bash from mangling the `/project` p
|
||||
- `build/partition_table/partition-table.bin` -- flash partition layout
|
||||
- `build/esp32-csi-node.bin` -- application firmware
|
||||
|
||||
### Release bundles
|
||||
|
||||
Firmware downloads are published from dedicated `vX.Y.Z-esp32` tags after all
|
||||
S3 and C6 matrix builds pass. Each release contains separate S3 8 MB, S3 4 MB,
|
||||
and C6 4 MB archives plus an archive checksum manifest. The repository's
|
||||
automated `vNNNN` server releases (for example `v2655`) are not firmware tags;
|
||||
do not use their presence or absence of assets as firmware provenance.
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
To change Kconfig settings before building:
|
||||
@@ -341,10 +453,15 @@ The easiest way to write NVS settings:
|
||||
```bash
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "MyWiFi" \
|
||||
--password "MyPassword" \
|
||||
--password-file ~/.wifi-pass \
|
||||
--target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
The password file must be owner-only (`chmod 600`); one trailing newline is
|
||||
dropped. Without a password flag the script prompts on a terminal; in scripts
|
||||
and CI it doesn't prompt and fails if no password was given now or saved
|
||||
earlier for that port.
|
||||
|
||||
### NVS Key Reference
|
||||
|
||||
#### Network Settings
|
||||
@@ -533,6 +650,7 @@ cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --releas
|
||||
| `main/nvs_config.c` / `.h` | Runtime configuration: loads Kconfig defaults, overrides from NVS |
|
||||
| `main/edge_processing.c` / `.h` | Tier 0-2 DSP pipeline: SPSC ring buffer, biquad IIR filters, Welford stats, BPM extraction, presence, fall detection |
|
||||
| `main/ota_update.c` / `.h` | HTTP OTA firmware update server on port 8032 |
|
||||
| `main/ota_health.c` / `.h` | First-boot health check that confirms an OTA'd image or rolls it back (ADR-379) |
|
||||
| `main/power_mgmt.c` / `.h` | Battery-aware light sleep duty cycling |
|
||||
| `main/wasm_runtime.c` / `.h` | WASM3 interpreter: module slots, host API bindings, budget guard, per-frame dispatch |
|
||||
| `main/wasm_upload.c` / `.h` | HTTP endpoints for WASM module upload, list, start, stop, delete |
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# Firmware runbook — ESP32-C6 CSI node
|
||||
|
||||
Operational procedure. `README.md` explains the project; this says exactly what
|
||||
to run, in what order, and what to check. Follow it top to bottom.
|
||||
|
||||
If you are an agent: **read this file before touching firmware.** Everything
|
||||
below was learned by getting it wrong at least once.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build
|
||||
|
||||
**One command. Copy it.** From the **repository root**, not this directory:
|
||||
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"cat sdkconfig.defaults sdkconfig.defaults.16mb sdkconfig.defaults.esp32c6 \
|
||||
> sdkconfig.defaults.build && \
|
||||
SDKCONFIG_DEFAULTS='sdkconfig.defaults.build' idf.py set-target esp32c6 && \
|
||||
idf.py build"
|
||||
```
|
||||
|
||||
Takes ~3 minutes cold, well under a minute incremental.
|
||||
|
||||
### The three defaults files are NOT all automatic
|
||||
|
||||
IDF picks up `sdkconfig.defaults` and `sdkconfig.defaults.<target>` on its own.
|
||||
It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
|
||||
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
|
||||
|
||||
Build without it and you get an image that is correct in every visible respect
|
||||
and **cannot roll back a bad OTA**, while `main/ota_health.c` sits in the app
|
||||
expecting the capability (it compiles to a no-op without it). Silent, and only
|
||||
discoverable by pushing a deliberately bad image. See §2.1.
|
||||
|
||||
That is why the command above concatenates all three explicitly rather than
|
||||
relying on discovery.
|
||||
|
||||
### Never `rm -rf sdkconfig` without a backup
|
||||
|
||||
The README's headline example opens with `rm -rf build sdkconfig`. `sdkconfig`
|
||||
is **untracked and gitignored**. `partitions_16mb.csv` was lost to exactly this,
|
||||
and `CLAUDE.md` warns about it *even when a README documents it*.
|
||||
|
||||
```bash
|
||||
cp sdkconfig "sdkconfig.backup-$(date +%Y%m%d-%H%M%S)" # then regenerate
|
||||
```
|
||||
|
||||
A stale `sdkconfig` is a real hazard, not a hypothetical: one found on
|
||||
2026-09-03 said `FLASHSIZE=4MB`, `partitions_4mb.csv`,
|
||||
`DYNAMIC_TX_BUFFER_NUM=64` — against a fleet running 16MB and 128. Anything
|
||||
built from it would silently not be the configuration under test.
|
||||
|
||||
### Verify before you flash anything
|
||||
|
||||
```bash
|
||||
grep -E "^CONFIG_(IDF_TARGET|ESPTOOLPY_FLASHSIZE|PARTITION_TABLE_CUSTOM_FILENAME|\
|
||||
ESP_WIFI_DYNAMIC_TX_BUFFER_NUM|BOOTLOADER_APP_ROLLBACK_ENABLE)=" sdkconfig
|
||||
```
|
||||
|
||||
All five must read:
|
||||
|
||||
| key | expected |
|
||||
|---|---|
|
||||
| `CONFIG_IDF_TARGET` | `"esp32c6"` |
|
||||
| `CONFIG_ESPTOOLPY_FLASHSIZE` | `"16MB"` |
|
||||
| `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` | `"partitions_16mb.csv"` |
|
||||
| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `128` |
|
||||
| `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` | `y` |
|
||||
|
||||
**Do not use the native `C:\Espressif\...esp-idf-v5.5.5`.** Wrong version, its
|
||||
venv does not match the default python, and a container-built `build/` refuses
|
||||
a native build outright. Docker only.
|
||||
|
||||
---
|
||||
|
||||
## 2. What OTA can and cannot reach
|
||||
|
||||
This is the distinction that decides whether a change needs a cable.
|
||||
|
||||
| region | offset | reachable by OTA? |
|
||||
|---|---|---|
|
||||
| bootloader | `0x0` | **NO — USB only** |
|
||||
| partition table | `0x8000` | **NO — USB only** |
|
||||
| otadata | `0xF000` | rewritten by OTA |
|
||||
| ota_0 | `0x20000` | yes (4 MB) |
|
||||
| ota_1 | `0x420000` | yes (4 MB) |
|
||||
| coredump | `0x820000` | no |
|
||||
| storage (fat) | `0x830000` | no |
|
||||
| nvs | `0x9000` | never written by a flash — provisioning survives |
|
||||
|
||||
**OTA replaces the app partition only.** Anything in the bootloader —
|
||||
`CONFIG_BOOTLOADER_*`, notably `APP_ROLLBACK_ENABLE` — or any partition-table
|
||||
change **requires USB on every board**.
|
||||
|
||||
There is **no bootloader version on the wire**. A node's sync packet reports app
|
||||
health, not bootloader capability, so you cannot determine remotely which boards
|
||||
have a rollback-capable bootloader. Track it per board, or do a USB pass across
|
||||
all of them.
|
||||
|
||||
Corollary: "every node reports health" proves the **app** is current and proves
|
||||
nothing about the bootloader. Do not answer a bootloader question with app
|
||||
evidence.
|
||||
|
||||
### 2.1 An OTA'd image must confirm itself (ADR-379)
|
||||
|
||||
On a rollback-capable bootloader an OTA'd image boots **`PENDING_VERIFY`**.
|
||||
Until it is confirmed, the **next reset reverts to the previous slot** and
|
||||
`POST /ota` refuses a second update. `main/ota_health.c` does the confirming:
|
||||
|
||||
- It runs only when the running image is `PENDING_VERIFY`. USB-flashed images
|
||||
(the bootloader writes `VALID`), already-confirmed images, `UNDEFINED` and
|
||||
`NEW` are left alone.
|
||||
- It confirms once the STA has an IP **and** the stream sender has accepted at
|
||||
least one CSI frame, but not before `CONFIG_OTA_HEALTH_MIN_UPTIME_S`
|
||||
(default 30 s). Log: `ota_health: marked valid after ... in <ms> ms`.
|
||||
- If both signals have not arrived by `CONFIG_OTA_HEALTH_TIMEOUT_S` (default
|
||||
120 s from boot), it logs `health check failed: <reason>, rolling back` and
|
||||
reboots into the previous slot. A crash or reset before confirmation is also
|
||||
a rollback.
|
||||
- A pass means only "reached the network and sent CSI". It does not show that
|
||||
sensing, calibration, or any other function of the new image works.
|
||||
- `GET /ota/status` reports `ota_state`. `pending_verify` means the image is
|
||||
not yet durable. `valid` means it survives a power cycle. `new` after an OTA
|
||||
means this board's bootloader has **no** rollback support.
|
||||
|
||||
Consequence for rollouts: wait for `ota_state: "valid"` before pushing again,
|
||||
and before power-cycling. If the AP is down when the node boots, a good image
|
||||
rolls back. That is expected; re-push it.
|
||||
|
||||
**Hardware check, one node.** It needs a node with the OTA PSK provisioned and
|
||||
a rollback bootloader.
|
||||
|
||||
1. Note `version` and `running_partition` from `/ota/status`.
|
||||
2. OTA-push the new image. Watch the log for `OTA image pending verify`, then
|
||||
`marked valid after ... in <ms> ms`. `/ota/status` must show the new
|
||||
version, the other partition, and `ota_state: "valid"`.
|
||||
3. Power-cycle it. It must come back on the same version and partition with
|
||||
`ota_state: "valid"`. Before ADR-379 it came back on the old one.
|
||||
|
||||
**Negative check.** Build with `CONFIG_OTA_HEALTH_FORCE_FAIL=y` and push it.
|
||||
The log must show `health check failed: forced failure ..., rolling back` at
|
||||
about `MIN_UPTIME_S`, and the node must come back on the step-2 version and
|
||||
partition. Then push a normal image to restore it. Never leave a FORCE_FAIL
|
||||
image in `release_bins`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploying
|
||||
|
||||
### Over the air — app-only changes
|
||||
|
||||
Works end-to-end and is the default for anything that is not a bootloader or
|
||||
partition change. Roll to **one node**, soak it, then the rest.
|
||||
|
||||
**Before ADR-379, `POST /ota` never completed on this firmware.** MEASURED
|
||||
2026-09-29 on node 4 (S3, v0.8.12): it overflowed the 4 KB httpd stack
|
||||
(`stack overflow in task httpd`, `RTC_SW_CPU_RST`), and the node came back on
|
||||
its old slot. The client sees `connection reset`. Any board still on an older
|
||||
build has to be moved to a fixed one **over USB**.
|
||||
|
||||
The server now runs with `CONFIG_OTA_HTTPD_STACK_SIZE` (8192). Each upload
|
||||
logs `httpd stack after POST /ota: N of 8192 bytes never used`. Record that
|
||||
number when you verify a node, because it is the evidence the size rests on.
|
||||
|
||||
### Over USB — bootloader, partition table, or recovery
|
||||
|
||||
```bash
|
||||
python -m esptool --chip esp32c6 -b 460800 \
|
||||
--before default_reset --after hard_reset write_flash \
|
||||
--flash_mode dio --flash_size 16MB --flash_freq 80m \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
0xf000 build/ota_data_initial.bin \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
`0x9000` is not in that list, so SSID, password and `node_id` survive.
|
||||
|
||||
### Safe single-board diagnostic flash, with a revert path
|
||||
|
||||
Nodes run from whichever slot `otadata` selects. **Parse it before flashing** —
|
||||
two 32-byte entries at offsets 0 and 0x1000; active slot is
|
||||
`(max ota_seq - 1) % 2`.
|
||||
|
||||
1. Back up `otadata` (8 KB) **and** the live app slot with `esptool read-flash`.
|
||||
2. Flash the diagnostic build — it lands in `ota_0` and rewrites `otadata`.
|
||||
3. **Revert = write the 8 KB `otadata` backup back.** Production in `ota_1` was
|
||||
never touched, so no rebuild is needed.
|
||||
|
||||
Validate any backup image before trusting it: byte 0 must be `0xE9`, and the
|
||||
`esp_app_desc_t` magic at offset `0x20` must be `0xABCD5432`.
|
||||
|
||||
**Never flash the whole fleet from a workstation, and never use the server's OTA
|
||||
endpoint for a diagnostic.** USB, one board, revert path secured first.
|
||||
|
||||
---
|
||||
|
||||
## 4. Serial
|
||||
|
||||
CH343 USB-serial at 115200.
|
||||
|
||||
To read a running board **without resetting it**, open with
|
||||
`DtrEnable=$false; RtsEnable=$false`. esptool asserts RTS and *will* reset the
|
||||
board, which loses the state you were trying to observe.
|
||||
|
||||
---
|
||||
|
||||
## 5. Changing config without a cable
|
||||
|
||||
Most parameters do not need a flash at all:
|
||||
|
||||
```bash
|
||||
export RUVIEW_OTA_PSK_FILE=<path to the psk file> # never inline the key
|
||||
python config_push.py --node <node-ip> --get
|
||||
python config_push.py --node <node-ip> --set led_brightness=10
|
||||
```
|
||||
|
||||
Keys marked `assoc: true` in `config_api.c` (`wifi_ssid`, `wifi_password`,
|
||||
`channel_hop_count`, `dwell_ms`, `csi_channel`) are applied as a **trial**: the
|
||||
node banks its current values, reboots, and keeps the change only if it
|
||||
re-associates. Everything else applies without that risk.
|
||||
|
||||
`config_push.py` has **no unset**. To restore a default you must set it
|
||||
explicitly. Current defaults:
|
||||
|
||||
| key | default | source |
|
||||
|---|---|---|
|
||||
| `vital_interval_ms` | 1000 | `Kconfig.projbuild` |
|
||||
| `swarm_heartbeat_sec` | 30 | `nvs_config.c` |
|
||||
| `swarm_ingest_sec` | 5 | `nvs_config.c` |
|
||||
| `beacon_period_ms` | 0 = derive from fleet size | `nvs_config.c` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Reading a node remotely
|
||||
|
||||
`GET /api/v1/mesh` returns per-node `health`: `min_heap_kib`, `die_c`,
|
||||
`thermal_state`, `tx_dbm`, `reset_reason`, and from sync proto v3 the TX-path
|
||||
counters `send_fail` / `rate_skip` / `early_drop`.
|
||||
|
||||
`min_heap_kib` is a **minimum-since-boot watermark**, not an instantaneous
|
||||
reading. It resets on reboot and only ever falls. Sampling it within a minute of
|
||||
a restart tells you nothing — that mistake produced a wrong "no effect" verdict
|
||||
on 2026-09-03; the real answer, once it had settled, was −56 KiB.
|
||||
|
||||
`/api/v1/nodes` `rssi_dbm` is **not** the AP link — it is whatever link landed
|
||||
most recently and swings 20+ dB with no physical change. Filter `/api/v1/links`
|
||||
by the AP's BSSIDs instead, and read `csi_fps_ema` alongside, because link count
|
||||
alone hides a starved node.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tunables, and which ones depend on your house
|
||||
|
||||
`idf.py menuconfig` exposes these. The defaults are what a nine-node ESP32-C6
|
||||
fleet in one house settled on; several are **not** universal, and the point of
|
||||
listing them here is that you can pick and measure rather than inherit someone
|
||||
else's building.
|
||||
|
||||
| option | default | depends on your house? |
|
||||
|---|---|---|
|
||||
| `CSI_GATE_MESH_ALIGNED` | y | **yes** |
|
||||
| `CSI_SEQ_DIAG` | n | diagnostic only |
|
||||
| `THERMAL_MONITOR` | y | no |
|
||||
| `THERMAL_THROTTLE` | n | **yes** |
|
||||
| `THERMAL_THROTTLE_C` | 72 | **yes** |
|
||||
| `THERMAL_HYSTERESIS_C` | 8 | no |
|
||||
| `UPLINK_WATCHDOG` | y | no |
|
||||
| `UPLINK_WATCHDOG_TIMEOUT_S` | 1200 | **yes** |
|
||||
|
||||
### The ones worth measuring yourself
|
||||
|
||||
**`CSI_GATE_MESH_ALIGNED`** decides whether nodes keep the *same* frames or
|
||||
merely the same *number* of frames, which is what makes cross-node pairing
|
||||
possible at all. Whether alignment helps depends on how much your nodes
|
||||
actually overhear in common, and that is a property of your walls and your
|
||||
transmitters. In one measured nine-node fleet, aligning windows bought
|
||||
+0.5 pp of pairing while selecting frames by `rx_seq` bought +31.7 pp -- so do
|
||||
not assume the default is doing the heavy lifting in your building.
|
||||
|
||||
Turn on `CSI_SEQ_DIAG` to measure it. It logs `(source MAC, rx_seq)` per CSI
|
||||
callback, which is what lets you compute the fraction of frames two nodes hold
|
||||
in common instead of guessing.
|
||||
|
||||
**`THERMAL_THROTTLE` / `THERMAL_THROTTLE_C`** depend on enclosure and placement,
|
||||
not on the chip. A bare bench board never reaches 72 C; the same board sealed in
|
||||
a printed case on a sunny wall can. Read `die_c` from `/api/v1/mesh` for a few
|
||||
days before deciding, because throttling transmit power costs range.
|
||||
|
||||
**`UPLINK_WATCHDOG_TIMEOUT_S`** is a bet about your AP. 1200 s suits a network
|
||||
where a wedge is rare and a reboot is cheap. If your uplink drops briefly and
|
||||
often, a short timeout turns a recoverable gap into a reboot loop; if it wedges
|
||||
hard, a long one leaves a node dark for twenty minutes.
|
||||
|
||||
### A worked example: the same two options, in one house
|
||||
|
||||
MEASURED on a nine-node ESP32-C6 fleet in a single detached house. **These are
|
||||
our numbers, not yours.** They are here to show the shape of the decision, and
|
||||
how much a building can move it.
|
||||
|
||||
Four arms as a 2x2 factorial, 20-minute runs, three Latin-square blocks so slow
|
||||
drift landed on every arm equally. Metric is the fraction of transmissions held
|
||||
in common by two or more nodes.
|
||||
|
||||
| arm | mean |
|
||||
|---|---|
|
||||
| `elapsed` (per-node gate) | 0.2434 |
|
||||
| `mesh` (aligned windows) | 0.2480 |
|
||||
| `seq` (keep `rx_seq` divisible by 8) | **0.5605** |
|
||||
| `mesh` + `seq` | 0.5452 |
|
||||
|
||||
`seq` roughly doubled pairing, +31.7 pp, about 70x the measured drift floor and
|
||||
reproduced in all three blocks. Mesh alignment bought **+0.5 pp** -- inside
|
||||
noise. Stacking them was very slightly *worse* than `seq` alone.
|
||||
|
||||
**Why that happened here, and why yours may differ.** These nodes already
|
||||
overheard a great deal in common -- two boards side by side heard the same
|
||||
transmissions 72% of the time -- so there was a large pool of shared
|
||||
transmissions for `seq` to select from, and little left for window alignment to
|
||||
recover. A house with fewer shared transmitters, thicker interior walls, or
|
||||
nodes spread so that few pairs hear the same radios has less for `seq` to work
|
||||
with, and phase alignment may matter more rather than less. We did not measure
|
||||
such a house and do not claim to know.
|
||||
|
||||
**It is a trade, not a free win.** At period 8 the `seq` arms carried ~75-95k
|
||||
transmissions per 20 minutes against ~120-160k ungated: pairing is bought with
|
||||
raw frame rate. Absolute paired transmissions per minute still rose (2629 vs
|
||||
2524), because the discarded frames are disproportionately ones only one node
|
||||
would have kept. Whether that trade is right depends on what consumes your
|
||||
stream. On-device edge processing was **unaffected** here -- frames processed
|
||||
stayed ~1900 per node per 4 minutes in both modes, because that pipeline is
|
||||
separately rate-limited -- so the cost fell on the uplink, not on local sensing.
|
||||
If your uplink is the scarce resource, that changes the answer.
|
||||
|
||||
**The two options differ in kind, not just in effect.** Mesh alignment is an
|
||||
agreement between receivers: it needs a shared clock, so it inherits whatever
|
||||
the sync topology does. Selecting by `rx_seq` needs no agreement at all. The
|
||||
802.11 sequence control field is assigned by the TRANSMITTER and is identical
|
||||
at every receiver, so it is a coordination-free name for one transmission --
|
||||
two nodes that have never heard of each other, sit in different sync domains,
|
||||
or run with no mesh whatsoever still keep the same subset of frames.
|
||||
|
||||
In the code the seq test runs before any sync check and reads only the frame;
|
||||
only the mesh branch calls `c6_sync_espnow_is_valid()`. That asymmetry is the
|
||||
reason `seq` had nothing to degrade in our runs, and it is worth weighing
|
||||
directly: if your fleet's sync is fragile, `seq` is not merely the better
|
||||
performer, it is the one with no dependency that can quietly fail.
|
||||
|
||||
**A limitation of our own measurement, which cuts against the mesh arm.** The
|
||||
mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`; a node
|
||||
with no valid sync falls back to the elapsed gate rather than gating on a
|
||||
meaningless epoch. That is the right behaviour, but it means our `mesh` arms
|
||||
were partly `elapsed` arms: three of nine nodes never synchronised, so for those
|
||||
three the setting under test was silently not in effect. Read the +0.5 pp as
|
||||
"mesh where sync held, elapsed where it did not" rather than as a clean
|
||||
fleet-wide measurement of alignment.
|
||||
|
||||
This is itself a house-dependent property. Time sync here is leader-elected and
|
||||
hub-and-spoke -- one node broadcasts and the rest follow. Whether every node can
|
||||
hear that leader is a question about your building, not about the firmware. In a
|
||||
larger or more divided house the leader may not reach everyone, the fleet
|
||||
fragments into sync domains, and any option that depends on shared time degrades
|
||||
quietly for the nodes that fell out. Check `is_leader` and sync validity in
|
||||
`/api/v1/mesh` before concluding that an alignment setting did or did not help.
|
||||
|
||||
**A prediction we got wrong, kept because it is the useful part.** Our mesh had
|
||||
split into two leader domains with three nodes never synchronising at all. We
|
||||
expected `seq` to win most on pairs spanning that split. Per-pair yield said
|
||||
otherwise: same-domain 5.82x, cross-domain 6.24x, involving a never-synced node
|
||||
**5.16x** -- the lowest, the opposite of the prediction -- and within-group
|
||||
spread (3.6x-8.1x) dwarfed every between-group difference.
|
||||
|
||||
So `seq` is **not** a workaround for broken time sync; it wins roughly
|
||||
uniformly, including where sync is healthy. The consequence is negative and
|
||||
worth having: repairing the mesh split would probably not have closed the gap,
|
||||
and that effort would have been spent for nothing.
|
||||
|
||||
### Measuring rather than guessing
|
||||
|
||||
A gate comparison needs a fixed environment, repeated blocks, and every arm run
|
||||
on every node -- otherwise node placement is confounded with the setting. The
|
||||
harness used for the published comparison is in the repository; use it rather
|
||||
than eyeballing a frame rate, which moves for reasons unrelated to the setting.
|
||||
@@ -1,6 +1,8 @@
|
||||
set(SRCS
|
||||
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"main.c" "csi_collector.c" "node_log.c"
|
||||
"thermal.c" "stream_sender.c" "nvs_config.c"
|
||||
"serial_onboarding.c" "serial_onboarding_protocol.c"
|
||||
"edge_processing.c" "ota_update.c" "ota_health.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
"swarm_bridge.c"
|
||||
@@ -13,6 +15,7 @@ set(SRCS
|
||||
"c6_twt.c"
|
||||
"c6_timesync.c"
|
||||
"c6_lp_core.c"
|
||||
"c6_antenna_select.c"
|
||||
# ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6)
|
||||
"c6_sync_espnow.c"
|
||||
# ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled)
|
||||
@@ -30,6 +33,9 @@ set(REQUIRES
|
||||
esp_http_client
|
||||
esp_app_format
|
||||
esp_timer
|
||||
fatfs # node_log.c: the storage FAT volume
|
||||
wear_levelling # node_log.c: wl_mount behind that volume
|
||||
espcoredump # coredump-to-flash readback
|
||||
esp_pm
|
||||
esp_driver_uart
|
||||
esp_driver_gpio
|
||||
@@ -38,6 +44,8 @@ set(REQUIRES
|
||||
driver
|
||||
lwip
|
||||
mbedtls
|
||||
efuse
|
||||
led_strip
|
||||
)
|
||||
|
||||
# ADR-110: C6-only components — pulled in when building for esp32c6.
|
||||
@@ -47,6 +55,12 @@ if(IDF_TARGET STREQUAL "esp32c6")
|
||||
list(APPEND REQUIRES ieee802154 ulp esp_hw_support)
|
||||
endif()
|
||||
|
||||
# ESP-IDF 6 split the temperature sensor driver out of the legacy `driver`
|
||||
# component. Keep the 5.4 lane working while declaring the v6 dependency.
|
||||
if(IDF_VERSION_MAJOR GREATER_EQUAL 6)
|
||||
list(APPEND REQUIRES esp_driver_tsens)
|
||||
endif()
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
menu "CSI Node Configuration"
|
||||
|
||||
config C6_XIAO_ANTENNA_SELECT
|
||||
bool "Control the Seeed XIAO ESP32-C6 RF switch"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
default n
|
||||
help
|
||||
Enable only for Seeed XIAO ESP32-C6 boards. The XIAO uses GPIO3
|
||||
to enable RF-switch control and GPIO14 to choose its internal or
|
||||
external antenna. Leave this disabled for generic ESP32-C6 boards
|
||||
because GPIO3 and GPIO14 may have unrelated functions there.
|
||||
|
||||
choice C6_XIAO_ANTENNA_PATH
|
||||
prompt "Seeed XIAO ESP32-C6 antenna path"
|
||||
depends on C6_XIAO_ANTENNA_SELECT
|
||||
default C6_XIAO_ANTENNA_INTERNAL
|
||||
|
||||
config C6_XIAO_ANTENNA_INTERNAL
|
||||
bool "Internal ceramic antenna"
|
||||
|
||||
config C6_XIAO_ANTENNA_EXTERNAL
|
||||
bool "External U.FL antenna"
|
||||
endchoice
|
||||
|
||||
config CSI_NODE_ID
|
||||
int "Node ID (0-255)"
|
||||
default 1
|
||||
@@ -39,6 +61,17 @@ menu "CSI Node Configuration"
|
||||
help
|
||||
WiFi channel to listen on for CSI data.
|
||||
|
||||
config CSI_SELF_PING_HZ
|
||||
int "Connected-STA CSI probe rate (Hz)"
|
||||
default 50
|
||||
range 10 50
|
||||
help
|
||||
Rate of the one-byte ICMP probes used to create a stable OFDM
|
||||
CSI source on quiet networks. Fifty hertz is the measured safety
|
||||
ceiling for the current ESP-IDF WiFi callback path. Higher rates
|
||||
are intentionally rejected because sustained callback load above
|
||||
50 Hz has caused WiFi ISR and packet-buffer failures on S3 and C6.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Edge Intelligence (ADR-039)"
|
||||
@@ -66,6 +99,18 @@ menu "Edge Intelligence (ADR-039)"
|
||||
help
|
||||
Number of highest-variance subcarriers to use for DSP.
|
||||
|
||||
config EDGE_DSP_SAMPLE_HZ
|
||||
int "On-device edge DSP sample rate (Hz)"
|
||||
default 8 if IDF_TARGET_ESP32C6
|
||||
default 20
|
||||
range 8 50
|
||||
help
|
||||
Uniform rate at which CSI callbacks enter the Tier 1 and Tier 2
|
||||
edge DSP. Raw CSI transmission keeps its independent full-rate
|
||||
path. Eight hertz is the hardware-measured sustainable C6 Tier 2
|
||||
setting and preserves a 4 Hz Nyquist limit for the 0.1-2.0 Hz
|
||||
vital bands.
|
||||
|
||||
config EDGE_FALL_THRESH
|
||||
int "Fall detection threshold (x1000)"
|
||||
default 15000
|
||||
@@ -287,6 +332,25 @@ menu "WASM Programmable Sensing (ADR-040)"
|
||||
|
||||
endmenu
|
||||
|
||||
menu "OTA / WASM HTTP server (ADR-379)"
|
||||
|
||||
config OTA_HTTPD_STACK_SIZE
|
||||
int "Stack size of the OTA / WASM HTTP server task (bytes)"
|
||||
range 4096 16384
|
||||
default 8192
|
||||
help
|
||||
HTTPD_DEFAULT_CONFIG() gives 4096. MEASURED 2026-09-29 on an ESP32-S3
|
||||
(node 4, v0.8.12): every POST /ota overflowed it ("stack overflow in
|
||||
task httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this
|
||||
firmware. The same task runs POST /wasm/upload (signature check plus
|
||||
wasm3 module load) and GET /wasm/list.
|
||||
|
||||
Every OTA and WASM upload logs "httpd stack after <handler>: N of M
|
||||
bytes never used". Lower this only with those numbers in hand, and keep
|
||||
at least 1 KB unused on the worst handler.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "ESP32-C6 capabilities (ADR-110)"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
|
||||
@@ -494,3 +558,198 @@ menu "Onboard LED (ADR-183)"
|
||||
colormap, in milli-units (250 = 0.25). Lower = more sensitive
|
||||
(reaches yellow with less motion).
|
||||
endmenu
|
||||
|
||||
|
||||
menu "Thermal monitoring"
|
||||
|
||||
config THERMAL_MONITOR
|
||||
bool "Read the on-chip temperature sensor and throttle the radio"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
default y
|
||||
help
|
||||
The C6 has a temperature sensor this firmware otherwise never reads.
|
||||
Harmless on a bare bench board; not once a node is sealed in a printed
|
||||
enclosure on a wall with the radio transmitting continuously.
|
||||
|
||||
What is being protected is the ENCLOSURE, not the silicon. The part is
|
||||
rated far past anything it sees here, but PLA softens at its glass
|
||||
transition (~55-60 C) and sags slowly under its own weight, which never
|
||||
announces itself.
|
||||
|
||||
config THERMAL_THROTTLE
|
||||
bool "Act on the temperature by reducing transmit power"
|
||||
depends on THERMAL_MONITOR
|
||||
default n
|
||||
help
|
||||
Separate from monitoring on purpose. Reading the sensor is inert;
|
||||
reducing transmit power moves every RSSI the node has, and doing that
|
||||
mid-experiment gives any surprise two candidate causes.
|
||||
|
||||
Leave off until the fleet is stable and the logs show a node genuinely
|
||||
running hot. If none ever does -- which is the likely outcome -- this
|
||||
never needs enabling, and the thresholds below cost nothing.
|
||||
|
||||
config THERMAL_WARN_C
|
||||
int "Warn above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 40 100
|
||||
default 65
|
||||
|
||||
config THERMAL_THROTTLE_C
|
||||
int "Reduce transmit power above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 45 105
|
||||
default 72
|
||||
help
|
||||
Die temperature, not enclosure temperature -- the sensor is on the chip
|
||||
and runs hotter than the plastic around it. ~72 C on the die in a small
|
||||
vented box is roughly a 50-55 C enclosure, which is where PLA begins to
|
||||
matter. Setting this at 55 would throttle a perfectly healthy node.
|
||||
|
||||
PETG (glass transition ~80 C) removes the concern; raise this if the
|
||||
whole fleet is printed in it.
|
||||
|
||||
config THERMAL_CRITICAL_C
|
||||
int "Minimum transmit power above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 50 110
|
||||
default 80
|
||||
|
||||
config THERMAL_HYSTERESIS_C
|
||||
int "Degrees of cooling required before stepping down a thermal state"
|
||||
depends on THERMAL_MONITOR
|
||||
range 1 20
|
||||
default 8
|
||||
help
|
||||
Recovering at the same threshold that tripped would oscillate, and every
|
||||
oscillation is a visible RSSI step on every link the node has.
|
||||
|
||||
Applies to EVERY downward transition, each relative to the threshold that
|
||||
was crossed on the way up: leaving CRITICAL needs CRITICAL_C minus this,
|
||||
leaving THROTTLED needs THROTTLE_C minus this, leaving WARN needs WARN_C
|
||||
minus this. Rising transitions are immediate at the plain thresholds --
|
||||
heat is a reason to act now.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Network supervision"
|
||||
|
||||
config UPLINK_WATCHDOG
|
||||
bool "Restart the node if the CSI uplink stops for too long"
|
||||
default y
|
||||
help
|
||||
Bounds how long a node can sit powered, capturing, and unable to reach
|
||||
the network.
|
||||
|
||||
MEASURED 2026-08-30: an AP-side event at 03:37 EDT disconnected all three
|
||||
wall-mounted nodes within three minutes of each other, and every one of
|
||||
them stayed dead for 5.5-7.5 hours until it was physically unplugged. The
|
||||
specific cause of that outage (a reconnect counter that gave up
|
||||
permanently) is fixed in main.c, but this exists so the NEXT unknown
|
||||
wedge costs minutes instead of a night of data.
|
||||
|
||||
The watchdog counts a successful sendto(), which on UDP means the network
|
||||
stack accepted the datagram -- not that the aggregator received it. That
|
||||
is deliberate: stopping the server does NOT reboot the fleet, because
|
||||
sendto keeps succeeding into a socket whose peer is gone. It fires only
|
||||
when the node's own network path is broken.
|
||||
|
||||
It arms on the first successful send rather than at boot, so a node
|
||||
powered on while the AP is down does not reboot-loop; the unbounded
|
||||
reconnect handles that case instead.
|
||||
|
||||
config UPLINK_WATCHDOG_TIMEOUT_S
|
||||
int "Restart after this many seconds with no successful send"
|
||||
depends on UPLINK_WATCHDOG
|
||||
range 30 3600
|
||||
default 1200
|
||||
help
|
||||
Must sit above the worst SELF-HEALING gap, or the watchdog reboots nodes
|
||||
that were about to recover on their own -- which is worse than the fault
|
||||
it guards against, because every reboot re-runs ESP-NOW leader election
|
||||
and discards the mesh time-sync state.
|
||||
|
||||
This was 120 s on the reasoning that the ENOMEM backoff caps at 2 s so a
|
||||
healthy node is never quiet for long. A 58-minute soak on 2026-08-30
|
||||
falsified that: nodes 0 and 2 each went silent repeatedly with a median
|
||||
gap of 210 s and a maximum of 225 s, and recovered every time without
|
||||
intervention. 120 s would have rebooted them every few minutes forever.
|
||||
(The cause of those 210 s gaps is still unknown -- it is not the ENOMEM
|
||||
backoff, which cannot produce a gap that long.)
|
||||
|
||||
Sized from the completed 112-minute soak, not the partial one: 30
|
||||
silences across two nodes, median 225 s, p90 225 s, and a single 510 s
|
||||
outlier that looks like two cycles running together. A third such merge
|
||||
would reach ~735 s, so 600 s was too tight -- it left only 90 s of margin
|
||||
over an event already observed once.
|
||||
|
||||
1200 s is 2.35x the observed maximum while still bounding a true wedge to
|
||||
twenty minutes rather than the 7.5 hours measured the same day. Revisit
|
||||
it if the ~225 s flapping is ever explained and fixed; until then do not
|
||||
lower it below ~900 s.
|
||||
|
||||
If nodes do restart unexpectedly, check the reset reason in the sync
|
||||
packet's health field before assuming the watchdog is responsible.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "OTA health check (ADR-379)"
|
||||
|
||||
config OTA_HEALTH_TIMEOUT_S
|
||||
int "Seconds an OTA'd image has to prove itself before rolling back"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
range 30 900
|
||||
default 120
|
||||
help
|
||||
Only applies when the running image is ESP_OTA_IMG_PENDING_VERIFY, i.e.
|
||||
it was just delivered by OTA to a node whose bootloader has
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE. USB-flashed and already-confirmed
|
||||
images are never checked.
|
||||
|
||||
Healthy means: the STA got an IP, and the stream sender accepted at least
|
||||
one CSI frame. Both must happen within this many seconds of boot, or the
|
||||
image is marked invalid and the node reboots into the previous slot.
|
||||
|
||||
Measured from boot, and the WiFi boot wait alone can take about two
|
||||
minutes against an absent AP, so an AP outage during a rollout rolls a
|
||||
good image back. That is the safe direction: re-push it later.
|
||||
|
||||
config OTA_HEALTH_MIN_UPTIME_S
|
||||
int "Minimum uptime before an OTA'd image may be confirmed"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
range 0 600
|
||||
default 30
|
||||
help
|
||||
Holds confirmation back for this long after boot even once both signals
|
||||
are in, so an image that crashes shortly after starting its pipelines
|
||||
still reverts (a crash while PENDING_VERIFY is a rollback). Clamped to
|
||||
OTA_HEALTH_TIMEOUT_S.
|
||||
|
||||
config OTA_HEALTH_FORCE_FAIL
|
||||
bool "TEST ONLY: fail the OTA health check to exercise rollback"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
default n
|
||||
help
|
||||
Builds an image that rolls itself back at the point it would otherwise
|
||||
confirm. OTA-push it to a node to prove rollback works end to end: the
|
||||
node should reboot into the previous image. Has no effect when USB
|
||||
flashed (such an image is not PENDING_VERIFY). Never ship this.
|
||||
|
||||
endmenu
|
||||
|
||||
config CSI_GATE_MESH_ALIGNED
|
||||
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
|
||||
default y
|
||||
help
|
||||
The per-node elapsed gate ("20 ms since MY last accept") gives every node
|
||||
an independent phase, so two healthy nodes at 50 Hz accept disjoint
|
||||
frames and nothing can be paired across them. This aligns the 20 ms
|
||||
window to mesh time so nodes choose the same windows.
|
||||
|
||||
config CSI_SEQ_DIAG
|
||||
bool "Log (source MAC, rx_seq) for every CSI callback (ADR-345 phase 2 probe)"
|
||||
default n
|
||||
help
|
||||
Diagnostic. Emits the transmitter address and 802.11 sequence number for
|
||||
each CSI callback so the effect of the gate above can be measured rather
|
||||
than assumed. Verbose; leave off in normal operation.
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "thermal.h"
|
||||
#include "rv_radio_ops.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_mesh.h"
|
||||
@@ -248,9 +249,10 @@ static void medium_loop_cb(TimerHandle_t t)
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
if (s_obs_valid) {
|
||||
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d",
|
||||
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps dsp=%.1fHz motion=%.2f presence=%.2f rssi=%d",
|
||||
(unsigned)s_state,
|
||||
(unsigned)obs.pkt_yield_per_sec,
|
||||
(double)edge_get_sample_rate_hz(),
|
||||
(double)obs.motion_score,
|
||||
(double)obs.presence_score,
|
||||
(int)obs.rssi_median_dbm);
|
||||
@@ -330,6 +332,10 @@ static void emit_feature_state(void)
|
||||
static void slow_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* Sample the die and apply the thermal policy. The slow loop (30 s) is the
|
||||
* right cadence: the die does not change quickly, and reading it more
|
||||
* often buys nothing but noise. No-op unless CONFIG_THERMAL_MONITOR. */
|
||||
thermal_tick();
|
||||
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
|
||||
* (default 30 s). The coordinator uses these to track liveness and
|
||||
* detect sync-error drift. */
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "c6_antenna_select.h"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define XIAO_RF_SWITCH_POWER_GPIO GPIO_NUM_3
|
||||
#define XIAO_RF_SWITCH_SELECT_GPIO GPIO_NUM_14
|
||||
|
||||
static const char *TAG = "c6_antenna";
|
||||
#endif
|
||||
|
||||
esp_err_t c6_xiao_antenna_apply(void)
|
||||
{
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
|
||||
gpio_config_t config = {
|
||||
.pin_bit_mask = (1ULL << XIAO_RF_SWITCH_POWER_GPIO) |
|
||||
(1ULL << XIAO_RF_SWITCH_SELECT_GPIO),
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
.pull_up_en = GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
esp_err_t err = gpio_config(&config);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
err = gpio_set_level(XIAO_RF_SWITCH_POWER_GPIO, 0);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
#if defined(CONFIG_C6_XIAO_ANTENNA_EXTERNAL)
|
||||
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_EXTERNAL;
|
||||
const char *name = "external U.FL";
|
||||
#else
|
||||
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_INTERNAL;
|
||||
const char *name = "internal ceramic";
|
||||
#endif
|
||||
|
||||
err = gpio_set_level(XIAO_RF_SWITCH_SELECT_GPIO,
|
||||
c6_xiao_antenna_gpio14_level(antenna));
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "XIAO ESP32-C6 antenna path: %s", name);
|
||||
}
|
||||
return err;
|
||||
#else
|
||||
return ESP_OK;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
typedef enum {
|
||||
C6_XIAO_ANTENNA_INTERNAL = 0,
|
||||
C6_XIAO_ANTENNA_EXTERNAL = 1,
|
||||
} c6_xiao_antenna_t;
|
||||
|
||||
static inline int c6_xiao_antenna_gpio14_level(c6_xiao_antenna_t antenna)
|
||||
{
|
||||
return antenna == C6_XIAO_ANTENNA_EXTERNAL ? 1 : 0;
|
||||
}
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t c6_xiao_antenna_apply(void);
|
||||
#endif
|
||||
@@ -13,7 +13,7 @@
|
||||
* `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c.
|
||||
*
|
||||
* 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up
|
||||
* (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no
|
||||
* (ESP-IDF deep-sleep GPIO wake API). No debounce, no
|
||||
* sub-10 µA floor, but no LP toolchain dependency either.
|
||||
* This is the path the v0.6.6 firmware shipped with.
|
||||
*
|
||||
@@ -26,6 +26,7 @@
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE)
|
||||
|
||||
#include "c6_lp_core.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "driver/rtc_io.h"
|
||||
@@ -134,10 +135,17 @@ esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high)
|
||||
#else
|
||||
/* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */
|
||||
uint64_t mask = 1ULL << wake_gpio;
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
esp_sleep_gpio_wake_up_mode_t mode = active_high
|
||||
? ESP_GPIO_WAKEUP_GPIO_HIGH
|
||||
: ESP_GPIO_WAKEUP_GPIO_LOW;
|
||||
esp_err_t err = esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(mask, mode);
|
||||
#else
|
||||
esp_deepsleep_gpio_wake_up_mode_t mode = active_high
|
||||
? ESP_GPIO_WAKEUP_GPIO_HIGH
|
||||
: ESP_GPIO_WAKEUP_GPIO_LOW;
|
||||
esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode);
|
||||
#endif
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
|
||||
@@ -63,6 +63,32 @@ static uint32_t s_send_ok = 0;
|
||||
static uint32_t s_send_fail = 0;
|
||||
static uint32_t s_rate_skip = 0;
|
||||
|
||||
#ifndef CONFIG_CSI_SELF_PING_HZ
|
||||
#define CONFIG_CSI_SELF_PING_HZ 50
|
||||
#endif
|
||||
|
||||
#if CONFIG_CSI_SELF_PING_HZ < 10 || CONFIG_CSI_SELF_PING_HZ > 50
|
||||
#error "CONFIG_CSI_SELF_PING_HZ must stay within the hardware-qualified 10-50 Hz range"
|
||||
#endif
|
||||
|
||||
#define CSI_SELF_PING_INTERVAL_MS (1000U / CONFIG_CSI_SELF_PING_HZ)
|
||||
|
||||
#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
#define CONFIG_EDGE_DSP_SAMPLE_HZ 8
|
||||
#else
|
||||
#define CONFIG_EDGE_DSP_SAMPLE_HZ 20
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if CONFIG_EDGE_DSP_SAMPLE_HZ < 8 || CONFIG_EDGE_DSP_SAMPLE_HZ > 50
|
||||
#error "CONFIG_EDGE_DSP_SAMPLE_HZ must stay within the supported 8-50 Hz range"
|
||||
#endif
|
||||
|
||||
#define EDGE_DSP_MIN_INTERVAL_US (1000000U / CONFIG_EDGE_DSP_SAMPLE_HZ)
|
||||
static int64_t s_next_edge_enqueue_us = 0;
|
||||
static uint32_t s_edge_rate_skip = 0;
|
||||
|
||||
/**
|
||||
* Minimum interval between UDP sends in microseconds.
|
||||
* CSI callbacks can fire hundreds of times per second in promiscuous mode.
|
||||
@@ -88,6 +114,9 @@ static int64_t s_last_send_us = 0;
|
||||
*/
|
||||
#define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */
|
||||
static int64_t s_last_process_us = 0;
|
||||
/* Mesh-epoch window index of the last accepted frame. UINT64_MAX means "none
|
||||
* yet", which no real bucket can collide with. */
|
||||
static uint64_t s_last_gate_bucket = UINT64_MAX;
|
||||
static uint32_t s_early_drop = 0;
|
||||
|
||||
/* ---- ADR-029: Channel-hop state ---- */
|
||||
@@ -237,8 +266,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
|
||||
buf[19] = 0;
|
||||
#endif
|
||||
|
||||
/* I/Q data */
|
||||
/* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid
|
||||
* when first_word_invalid is set. Never let those hardware artifacts feed
|
||||
* either the host or edge DSP path. Preserve the fixed ADR-018 geometry by
|
||||
* zeroing rather than removing bytes, and mark the sanitation in bit 5. */
|
||||
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
|
||||
if (info->first_word_invalid && iq_len > 0) {
|
||||
size_t invalid_len = iq_len < 4 ? iq_len : 4;
|
||||
memset(&buf[CSI_HEADER_SIZE], 0, invalid_len);
|
||||
buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED;
|
||||
}
|
||||
|
||||
return frame_size;
|
||||
}
|
||||
@@ -251,9 +288,53 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
(void)ctx;
|
||||
|
||||
/* Early rate gate: drop excess callbacks to ~50 Hz to prevent
|
||||
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */
|
||||
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq).
|
||||
*
|
||||
* HOW the rate is limited decides whether cross-node fusion is possible.
|
||||
*
|
||||
* The original gate is "at least 20 ms since MY last accept". Each node
|
||||
* therefore has an independent phase: node A can accept at t=0,20,40 ms
|
||||
* while node B accepts at 7,27,47. Both are perfectly healthy and both hit
|
||||
* 50 Hz, yet they accept disjoint frames and nothing can be paired.
|
||||
*
|
||||
* MEASURED 2026-08-30, two boards side by side: they HEARD the same
|
||||
* transmissions 72% of the time but only 25% of accepted frames were
|
||||
* common. The frames were there; the gate was throwing away the pairing.
|
||||
*
|
||||
* So gate on a bucket of the MESH-ALIGNED epoch instead: every node takes
|
||||
* the first frame it hears in the same absolute 20 ms window. Two nodes
|
||||
* that heard the same frame now both accept it. Mesh sync is good to
|
||||
* roughly 490 us against a 20 ms bucket -- about 2.5% of a window -- so
|
||||
* boundary disagreements are rare.
|
||||
*
|
||||
* Falls back to the original elapsed-time gate whenever mesh sync is not
|
||||
* valid (no leader heard yet, or a node that just booted), because an
|
||||
* unsynced epoch would otherwise gate on a meaningless number. A node
|
||||
* running unsynced simply gets the old behaviour rather than no gate.
|
||||
*
|
||||
* The half-interval floor below keeps the crash protection honest: bucket
|
||||
* gating alone could accept at the end of one window and the start of the
|
||||
* next, back to back. The floor bounds that to ~100 Hz for a single pair
|
||||
* while the average stays at 50 Hz. Do not remove it; the gate exists for
|
||||
* a crash, not for tidiness. */
|
||||
int64_t now_us = esp_timer_get_time();
|
||||
if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) {
|
||||
bool take;
|
||||
#ifdef CONFIG_CSI_GATE_MESH_ALIGNED
|
||||
if (c6_sync_espnow_is_valid()) {
|
||||
uint64_t bucket = c6_sync_espnow_get_epoch_us()
|
||||
/ (uint64_t)CSI_MIN_PROCESS_INTERVAL_US;
|
||||
take = (bucket != s_last_gate_bucket)
|
||||
&& ((now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US / 2);
|
||||
if (take) {
|
||||
s_last_gate_bucket = bucket;
|
||||
}
|
||||
} else {
|
||||
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
|
||||
}
|
||||
#else
|
||||
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
|
||||
#endif
|
||||
if (!take) {
|
||||
s_early_drop++;
|
||||
return;
|
||||
}
|
||||
@@ -300,10 +381,34 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-039: Enqueue raw I/Q into edge processing ring buffer. */
|
||||
if (info->buf && info->len > 0) {
|
||||
edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
|
||||
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
/* ADR-039 / ADR-347: Raw CSI stays at the independent network cadence,
|
||||
* while the on-device Tier 1/2 pipeline receives a uniform, sustainable
|
||||
* stream. Enqueuing every burst frame overloaded the unicore C6 DSP and
|
||||
* turned 30-40 callback pps into an irregular approximately 8 Hz subset. */
|
||||
if (frame_len > CSI_HEADER_SIZE) {
|
||||
if (s_next_edge_enqueue_us == 0) {
|
||||
s_next_edge_enqueue_us = now_us;
|
||||
}
|
||||
|
||||
if (now_us >= s_next_edge_enqueue_us) {
|
||||
/* Reuse the sanitized ADR-018 payload. Feeding info->buf here
|
||||
* would reintroduce first_word_invalid artifacts on device. */
|
||||
(void)edge_enqueue_csi(&frame_buf[CSI_HEADER_SIZE],
|
||||
(uint16_t)(frame_len - CSI_HEADER_SIZE),
|
||||
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
|
||||
/* Preserve the configured sample clock instead of resetting it to
|
||||
* each irregular callback. With roughly 35 raw callbacks per
|
||||
* second, a last-seen 100 ms gate selected every fourth callback
|
||||
* and drifted to roughly 8 Hz. Advancing the deadline by complete
|
||||
* periods alternates the available callbacks around the configured
|
||||
* phase and prevents both drift and catch-up bursts. */
|
||||
int64_t periods = ((now_us - s_next_edge_enqueue_us) /
|
||||
EDGE_DSP_MIN_INTERVAL_US) + 1;
|
||||
s_next_edge_enqueue_us += periods * EDGE_DSP_MIN_INTERVAL_US;
|
||||
} else {
|
||||
s_edge_rate_skip++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-110 §A0.11/§A0.12 — Emit a sync-packet every N CSI frames so the
|
||||
@@ -411,7 +516,7 @@ static void csi_start_self_ping(void)
|
||||
esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG();
|
||||
cfg.target_addr = target;
|
||||
cfg.count = ESP_PING_COUNT_INFINITE;
|
||||
cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */
|
||||
cfg.interval_ms = CSI_SELF_PING_INTERVAL_MS;
|
||||
cfg.data_size = 1;
|
||||
cfg.task_stack_size = 4096;
|
||||
|
||||
@@ -424,7 +529,8 @@ static void csi_start_self_ping(void)
|
||||
|
||||
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
|
||||
esp_ping_start(s_self_ping);
|
||||
ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str);
|
||||
ESP_LOGI(TAG, "self-ping started -> %s @%dHz (CSI OFDM source, fix #521/#954)",
|
||||
gw_str, CONFIG_CSI_SELF_PING_HZ);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
|
||||
s_self_ping = NULL;
|
||||
@@ -592,6 +698,8 @@ void csi_collector_init(void)
|
||||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
|
||||
(unsigned)s_node_id, (unsigned)csi_channel);
|
||||
ESP_LOGI(TAG, "edge DSP cadence=%dHz; raw CSI network cadence remains independent",
|
||||
CONFIG_EDGE_DSP_SAMPLE_HZ);
|
||||
|
||||
/* RuView#521/#954: start the connected-STA traffic source so the CSI engine
|
||||
* receives a guaranteed OFDM unicast floor even when promiscuous capture is
|
||||
@@ -642,6 +750,25 @@ uint16_t csi_collector_get_send_fail_count(void)
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
uint32_t csi_collector_get_send_ok_count(void)
|
||||
{
|
||||
return s_send_ok;
|
||||
}
|
||||
|
||||
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
|
||||
* back to elapsed time when unsynced), so there is no mode or period to
|
||||
* report. Return the explicit sentinel rather than a number that reads as
|
||||
* a real setting. */
|
||||
uint8_t csi_collector_get_gate_mode(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
uint8_t csi_collector_get_gate_seq_period(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
|
||||
|
||||
Loaded 100 of 487 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user