mirror of
https://github.com/ruvnet/RuView
synced 2026-07-25 17:51:48 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 711644dbb5 |
+22
-34
@@ -1,10 +1,14 @@
|
||||
name: Continuous Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
workflow_run:
|
||||
workflows: ["wifi-densepose sensing-server → Docker Hub + ghcr.io"]
|
||||
workflows: ["Continuous Integration"]
|
||||
types:
|
||||
- completed
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
@@ -15,11 +19,6 @@ on:
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
image_tag:
|
||||
description: 'Existing ghcr.io/ruvnet/wifi-densepose tag to deploy'
|
||||
required: true
|
||||
default: 'latest'
|
||||
type: string
|
||||
force_deploy:
|
||||
description: 'Force deployment (skip checks)'
|
||||
required: false
|
||||
@@ -28,7 +27,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ruvnet/wifi-densepose
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
|
||||
|
||||
jobs:
|
||||
@@ -36,9 +35,7 @@ jobs:
|
||||
pre-deployment:
|
||||
name: Pre-deployment Checks
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch'
|
||||
outputs:
|
||||
deploy_env: ${{ steps.determine-env.outputs.environment }}
|
||||
image_tag: ${{ steps.determine-tag.outputs.tag }}
|
||||
@@ -46,7 +43,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Determine deployment environment
|
||||
@@ -54,12 +50,14 @@ jobs:
|
||||
env:
|
||||
# Use environment variable to prevent shell injection
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT
|
||||
elif [[ "$PUBLISHED_REF" == v* ]]; then
|
||||
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "environment=production" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
@@ -67,23 +65,16 @@ jobs:
|
||||
|
||||
- name: Determine image tag
|
||||
id: determine-tag
|
||||
env:
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
|
||||
PUBLISHED_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
INPUT_IMAGE_TAG: ${{ github.event.inputs.image_tag }}
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "tag=$INPUT_IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
elif [[ "$PUBLISHED_REF" == v* ]]; then
|
||||
echo "tag=$PUBLISHED_REF" >> $GITHUB_OUTPUT
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=sha-${PUBLISHED_SHA:0:7}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Verify image exists
|
||||
run: |
|
||||
docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}"
|
||||
docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}
|
||||
|
||||
# Deploy to staging
|
||||
deploy-staging:
|
||||
@@ -138,10 +129,7 @@ jobs:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: [pre-deployment, deploy-staging]
|
||||
if: |
|
||||
always() &&
|
||||
needs.pre-deployment.result == 'success' &&
|
||||
needs.pre-deployment.outputs.deploy_env == 'production'
|
||||
if: needs.pre-deployment.outputs.deploy_env == 'production' || (github.ref == 'refs/tags/v*' && needs.deploy-staging.result == 'success')
|
||||
environment:
|
||||
name: production
|
||||
url: https://wifi-densepose.com
|
||||
@@ -222,7 +210,7 @@ jobs:
|
||||
# kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0
|
||||
|
||||
- name: Upload deployment artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: production-deployment-${{ github.run_number }}
|
||||
path: |
|
||||
@@ -272,7 +260,7 @@ jobs:
|
||||
post-deployment:
|
||||
name: Post-deployment Monitoring
|
||||
runs-on: ubuntu-latest
|
||||
needs: [pre-deployment, deploy-staging, deploy-production]
|
||||
needs: [deploy-staging, deploy-production]
|
||||
if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success')
|
||||
steps:
|
||||
- name: Monitor deployment health
|
||||
@@ -293,7 +281,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Update deployment status
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
|
||||
@@ -312,7 +300,7 @@ jobs:
|
||||
notify:
|
||||
name: Notify Deployment Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [pre-deployment, deploy-staging, deploy-production, post-deployment]
|
||||
needs: [deploy-staging, deploy-production, post-deployment]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Notify Slack on success
|
||||
@@ -344,7 +332,7 @@ jobs:
|
||||
|
||||
- name: Create deployment issue on failure
|
||||
if: needs.deploy-production.result == 'failure'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
@@ -367,4 +355,4 @@ jobs:
|
||||
**Logs:** Check the workflow run for detailed error messages.
|
||||
`,
|
||||
labels: ['deployment', 'production', 'urgent']
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.11'
|
||||
NODE_VERSION: '20' # ADR-265: all Node packages in this repo declare engines >= 20
|
||||
NODE_VERSION: '18'
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
@@ -88,6 +88,8 @@ jobs:
|
||||
# ADR-262 P1: `wifi-densepose-rufield` path-deps the `vendor/rufield`
|
||||
# submodule. Without a recursive checkout the workspace build fails to
|
||||
# resolve those path deps in CI even though it passes locally.
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
|
||||
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
|
||||
@@ -144,10 +146,7 @@ jobs:
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
run: >-
|
||||
cargo test
|
||||
--manifest-path crates/worldgraph/wifi-densepose-worldmodel/Cargo.toml
|
||||
--no-default-features
|
||||
run: cargo test -p wifi-densepose-worldmodel --no-default-features
|
||||
|
||||
# ADR-134 CIR tests are behind the `cir` feature so the bench dependency
|
||||
# (Criterion) only pulls when actually exercised. Run them as a separate
|
||||
@@ -171,41 +170,6 @@ jobs:
|
||||
- name: ADR-135 calibration witness proof (determinism guard)
|
||||
run: bash scripts/verify-calibration-proof.sh
|
||||
|
||||
# The workspace runs with --no-default-features, which switches OFF
|
||||
# ruview-auth's `login` and `pkce` features. That silently excluded 40 of
|
||||
# its 87 tests — the whole interactive sign-in path: credential storage,
|
||||
# single-flight refresh, the advisory file lock, the loopback callback, and
|
||||
# PKCE generation. They were green locally and never executed here.
|
||||
# Measured: 47 tests with --no-default-features, 87 with --all-features.
|
||||
- name: Run ruview-auth tests with all features (ADR-271 login path)
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
run: cargo test -p ruview-auth --all-features
|
||||
|
||||
# Browser-facing JavaScript.
|
||||
#
|
||||
# These run the dashboard's own modules in Node with stubbed browser globals.
|
||||
# They exist because the Rust suite cannot see them at all: two ADR-271/272
|
||||
# defects (a service worker caching /oauth/status, and the WebSocket ticket
|
||||
# helper) lived entirely in `ui/` and were invisible to a fully green
|
||||
# workspace. Blocking, and fast — no browser, no install step.
|
||||
ui-tests:
|
||||
name: UI JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Run UI unit tests
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
|
||||
|
||||
# Unit and Integration Tests
|
||||
# Python pytest matrix — runs against the archived v1 Python tree.
|
||||
# `continue-on-error: true` for the same reason as code-quality above:
|
||||
@@ -285,7 +249,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
@@ -536,4 +500,4 @@ jobs:
|
||||
**Docker Image:**
|
||||
`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}`
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: false
|
||||
@@ -52,16 +52,14 @@ jobs:
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node.bin
|
||||
artifact_pt: partition-table.bin
|
||||
- variant: 4mb
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults.4mb
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-4mb.bin
|
||||
artifact_pt: partition-table-4mb.bin
|
||||
# ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core)
|
||||
@@ -69,8 +67,7 @@ jobs:
|
||||
target: esp32c6
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-c6.bin
|
||||
artifact_pt: partition-table-c6.bin
|
||||
|
||||
@@ -99,23 +96,18 @@ jobs:
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
|
||||
- name: Verify binary size budget
|
||||
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
BIN=build/esp32-csi-node.bin
|
||||
SIZE=$(stat -c%s "$BIN")
|
||||
MAX=$((${{ matrix.size_limit_kb }} * 1024))
|
||||
WARN=$((${{ matrix.size_warn_kb }} * 1024))
|
||||
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
|
||||
echo "Warning at: $WARN bytes (${{ matrix.size_warn_kb }} KB)"
|
||||
echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)"
|
||||
if [ "$SIZE" -gt "$MAX" ]; then
|
||||
echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SIZE" -gt "$WARN" ]; then
|
||||
echo "::warning::Firmware binary exceeds the ${{ matrix.size_warn_kb }} KB soft budget ($SIZE > $WARN); hard limit is ${{ matrix.size_limit_kb }} KB"
|
||||
fi
|
||||
echo "Binary size OK: $SIZE <= $MAX"
|
||||
|
||||
- name: Verify flash image integrity
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# ADR-265 D1 — the npm-package gate.
|
||||
#
|
||||
# Every Node package in this repo (published or private) gets: install, build,
|
||||
# tests, a version-literal gate (D3 — package.json is the only place a version
|
||||
# lives), a pack-content gate (no source maps, unpacked-size budget), a
|
||||
# tarball-install smoke test (would have caught ADR-264 F1's broken `require`
|
||||
# export), and the claim-check honesty lint on the README (D4).
|
||||
|
||||
name: npm packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'harness/ruview/**'
|
||||
- 'tools/ruview-mcp/**'
|
||||
- 'tools/ruview-cli/**'
|
||||
- '.github/workflows/npm-packages.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'harness/ruview/**'
|
||||
- 'tools/ruview-mcp/**'
|
||||
- 'tools/ruview-cli/**'
|
||||
- '.github/workflows/npm-packages.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: ${{ matrix.package.dir }} (node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['20', '22']
|
||||
package:
|
||||
- dir: harness/ruview
|
||||
build: false
|
||||
publishable: true
|
||||
# ADR-263: dependency-free harness; budget guards against dep creep.
|
||||
unpacked_budget: 65536
|
||||
- dir: tools/ruview-mcp
|
||||
build: true
|
||||
publishable: true
|
||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
||||
unpacked_budget: 140000
|
||||
- dir: tools/ruview-cli
|
||||
build: true
|
||||
publishable: false
|
||||
unpacked_budget: 0
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ matrix.package.dir }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
# Repo policy gitignores lockfiles under harness/ (the harness is
|
||||
# dependency-free anyway); the TS packages commit theirs.
|
||||
- name: Install
|
||||
run: |
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.package.build }}
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm test --if-present
|
||||
|
||||
# ADR-265 D3 — package.json is the only place a version string lives.
|
||||
- name: Version-literal gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=""
|
||||
for d in src bin; do
|
||||
if [ -d "$d" ]; then
|
||||
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
|
||||
fi
|
||||
done
|
||||
if [ -n "$hits" ]; then
|
||||
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
|
||||
echo "$hits"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ADR-265 D1.3 — pack-content gate: no maps, size budget enforced.
|
||||
- name: Pack gate
|
||||
if: ${{ matrix.package.publishable }}
|
||||
run: |
|
||||
npm pack --dry-run --json 2>/dev/null | node -e "
|
||||
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
const budget = Number(process.env.UNPACKED_BUDGET);
|
||||
const maps = info.files.filter((f) => f.path.endsWith('.map'));
|
||||
if (maps.length > 0) {
|
||||
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
|
||||
process.exit(1);
|
||||
}
|
||||
if (info.unpackedSize > budget) {
|
||||
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
|
||||
"
|
||||
env:
|
||||
UNPACKED_BUDGET: ${{ matrix.package.unpacked_budget }}
|
||||
|
||||
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
|
||||
- name: Tarball smoke test
|
||||
if: ${{ matrix.package.publishable }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
|
||||
SMOKE="$(mktemp -d)"
|
||||
cd "$SMOKE"
|
||||
npm init -y > /dev/null
|
||||
npm i --no-fund --no-audit "$TGZ"
|
||||
case "${{ matrix.package.dir }}" in
|
||||
harness/ruview)
|
||||
./node_modules/.bin/ruview --version
|
||||
./node_modules/.bin/ruview doctor
|
||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
||||
if ./node_modules/.bin/ruview claim-check; then
|
||||
echo 'claim-check passed with no input — fail-open regression'; exit 1
|
||||
fi
|
||||
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
|
||||
;;
|
||||
tools/ruview-mcp)
|
||||
# initialize over stdio; server must answer and exit 0 on EOF
|
||||
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
|
||||
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
|
||||
# the ESM export must resolve from the installed tarball (ADR-264 F1)
|
||||
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
|
||||
;;
|
||||
esac
|
||||
|
||||
# ADR-265 D4 — package READMEs must pass the project's own honesty lint.
|
||||
- name: Claim-check README
|
||||
run: |
|
||||
if [ -f README.md ]; then
|
||||
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
|
||||
else
|
||||
echo "no README.md — skipping"
|
||||
fi
|
||||
@@ -13,29 +13,14 @@
|
||||
# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first
|
||||
# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix
|
||||
#
|
||||
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret (API-token
|
||||
# auth). This is the ACTIVE, working publish path — the token is sourced
|
||||
# fresh from GCP Secret Manager per the runbook in
|
||||
# docs/integrations/pypi-release.md (GCP Secret Manager → gh secret set),
|
||||
# which also keeps KICS from flagging the secret name as a generic-secret
|
||||
# literal here.
|
||||
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The
|
||||
# token-refresh runbook (GCP Secret Manager → gh secret set) lives in
|
||||
# docs/integrations/pypi-release.md so KICS does not flag the
|
||||
# secret name as a generic-secret literal in the workflow.
|
||||
#
|
||||
# TODO(ADR-184 P1b): migrate to PyPI OIDC Trusted Publishing to remove
|
||||
# this rotatable/expire-able credential. That switch is GATED on a manual
|
||||
# pypi.org step no CLI/agent can perform: the repo owner must register a
|
||||
# Trusted Publisher on pypi.org for owner=ruvnet / repo=RuView /
|
||||
# workflow=pip-release.yml (BOTH the wifi-densepose and ruview projects;
|
||||
# ruview as a pending publisher) — see docs/adr/ADR-184-*.md. Do NOT grant
|
||||
# the OIDC id-token write permission before that registration exists, or
|
||||
# publishing fails with "no trusted publisher configured" — a silent
|
||||
# regression the `Verify fix markers` guard `RuView#786-pypi-token-auth`
|
||||
# exists to catch (it forbids that permission string in this file). When
|
||||
# the owner confirms both entries are live, do the OIDC switch as a
|
||||
# dedicated follow-up commit (drop `password:`, add the OIDC id-token
|
||||
# permission + `environment: pypi`) so there is no capability gap between.
|
||||
#
|
||||
# Production publishing fails closed until the ADR-117 §11.3 v2 witness
|
||||
# hash exists. TestPyPI remains usable to validate release artifacts.
|
||||
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
|
||||
# before the first v2.0.0 publish. When v2 lands, add a parallel
|
||||
# step that verifies the v2 hash against the Rust pipeline.
|
||||
|
||||
name: pip-release
|
||||
|
||||
@@ -82,7 +67,7 @@ jobs:
|
||||
arch: x86_64
|
||||
- os: ubuntu-latest
|
||||
arch: aarch64
|
||||
- os: macos-15-intel # x86_64 runner
|
||||
- os: macos-13 # x86_64 runner
|
||||
arch: x86_64
|
||||
- os: macos-14 # arm64 runner
|
||||
arch: arm64
|
||||
@@ -151,46 +136,6 @@ jobs:
|
||||
path: sdist/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
build-ruview:
|
||||
name: Build ruview meta-package
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Verify lock-step package versions
|
||||
shell: python
|
||||
run: |
|
||||
import pathlib
|
||||
import tomllib
|
||||
|
||||
root = pathlib.Path("python")
|
||||
core = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
meta = tomllib.loads((root / "ruview-meta" / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
core_version = core["project"]["version"]
|
||||
meta_version = meta["project"]["version"]
|
||||
expected_dependency = f"wifi-densepose=={core_version}"
|
||||
if meta_version != core_version:
|
||||
raise SystemExit(
|
||||
f"package versions differ: wifi-densepose={core_version}, ruview={meta_version}"
|
||||
)
|
||||
if expected_dependency not in meta["project"]["dependencies"]:
|
||||
raise SystemExit(f"ruview must depend on {expected_dependency}")
|
||||
print(f"lock-step version: {core_version}")
|
||||
- name: Build ruview wheel and sdist
|
||||
run: |
|
||||
python -m pip install --upgrade pip build
|
||||
python -m build python/ruview-meta --outdir ruview-dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview
|
||||
path: ruview-dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
@@ -275,29 +220,18 @@ jobs:
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
publish-v2:
|
||||
name: Publish wifi-densepose + ruview
|
||||
needs: [build-wheels, build-sdist, build-ruview]
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-wheels.result == 'success' &&
|
||||
needs.build-sdist.result == 'success' &&
|
||||
needs.build-ruview.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Enforce production witness gate
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v2.') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
run: |
|
||||
test -s archive/v1/data/proof/expected_features_v2.sha256 || {
|
||||
echo "::error::ADR-117 §11.3 release gate is incomplete: archive/v1/data/proof/expected_features_v2.sha256 is missing or empty"
|
||||
exit 1
|
||||
}
|
||||
- name: Gather all artifacts into dist/
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -307,14 +241,12 @@ jobs:
|
||||
mkdir -p dist
|
||||
find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \;
|
||||
ls -lh dist/
|
||||
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
|
||||
# before replacing `password:` with the OIDC id-token permission.
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.TESTPYPI_API_TOKEN }}
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
@@ -325,7 +257,6 @@ jobs:
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
verbose: true
|
||||
|
||||
publish-tombstone:
|
||||
name: Publish v1.99 tombstone
|
||||
@@ -343,14 +274,12 @@ jobs:
|
||||
with:
|
||||
name: tombstone
|
||||
path: dist
|
||||
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
|
||||
# before replacing `password:` with the OIDC id-token permission.
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.TESTPYPI_API_TOKEN }}
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# Python Package CI — gates the `python/` PyO3+maturin wheel (`wifi-densepose`).
|
||||
#
|
||||
# ADR-117 (pip modernization) + ADR-185 (SOTA extras). Unlike the frozen
|
||||
# `archive/v1/` Python app — which is `continue-on-error: true` in ci.yml
|
||||
# because it is reference-only — the `python/` package is an actively-shipped
|
||||
# PyPI wheel (published by pip-release.yml). Before this workflow, `python/`
|
||||
# had ZERO per-PR coverage: pip-release.yml only fires on release triggers
|
||||
# (tags / dispatch), so a PR could break the wheel build, break a native-Rust
|
||||
# parity test, or blow the wheel-size budget and nothing in the normal gating
|
||||
# CI would notice until release day. This workflow closes that gap.
|
||||
#
|
||||
# Path-scoped as a DEDICATED workflow rather than a job inside ci.yml. That is
|
||||
# this repo's own convention for component-scoped CI (cf. firmware-ci.yml,
|
||||
# sensing-server-docker.yml, bfld-mqtt-integration.yml — all separate files
|
||||
# with `paths:` triggers). GitHub only supports workflow-level `paths:`, not
|
||||
# per-job path filters, and no workflow in this repo uses a change-detection
|
||||
# action (dorny/paths-filter, tj-actions/changed-files) — so the idiomatic,
|
||||
# no-new-dependency way to scope to `python/**` is a standalone workflow. It
|
||||
# simply does not run on unrelated PRs.
|
||||
|
||||
name: Python Package CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
# NOTE: kept in sync with the pull_request paths below. GitHub Actions
|
||||
# does not reliably support YAML anchors in workflow files, so the two
|
||||
# lists are duplicated deliberately rather than aliased.
|
||||
paths:
|
||||
- 'python/**'
|
||||
- 'v2/crates/wifi-densepose-core/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/wifi-densepose-aether/**'
|
||||
- 'v2/crates/wifi-densepose-mat/**'
|
||||
- 'v2/crates/wifi-densepose-train/**'
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- '.github/workflows/python-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'python/**'
|
||||
- 'v2/crates/wifi-densepose-core/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/wifi-densepose-aether/**'
|
||||
- 'v2/crates/wifi-densepose-mat/**'
|
||||
- 'v2/crates/wifi-densepose-train/**'
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- '.github/workflows/python-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: python-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Build the wheel with ALL SOTA features and run the full parity suite.
|
||||
# `--features sota` = aether + meridian + mat, so the compiled feature
|
||||
# submodules (wifi_densepose.aether / .meridian / .mat) exist and their
|
||||
# SHA-256 parity tests against the native-Rust reference actually run
|
||||
# (test_aether.py / test_meridian.py / test_mat.py import those submodules
|
||||
# at collection time — without the features they would error, not skip).
|
||||
parity-tests:
|
||||
name: Wheel + parity tests (features=sota)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The python/ crate path-deps v2/crates/* and (transitively via
|
||||
# train) the vendored ruvector submodule — recursive checkout keeps
|
||||
# those path deps resolvable, matching the rust-tests job in ci.yml.
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
v2
|
||||
python
|
||||
|
||||
# Fast-fail with per-crate attribution BEFORE the heavier maturin build,
|
||||
# so a break in a binding-backing crate is reported as "this crate failed
|
||||
# to build" rather than buried in a maturin link error. These are the
|
||||
# crates the [aether]/[mat]/[meridian] compiled extras link.
|
||||
- name: Build binding-backing crates
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
run: cargo build -p wifi-densepose-aether -p wifi-densepose-mat -p wifi-densepose-train
|
||||
|
||||
# maturin develop needs an active virtualenv; create one and expose it
|
||||
# to the later steps via GITHUB_PATH so `maturin` / `pytest` resolve to
|
||||
# it. Test deps: pytest-asyncio (client tests are async, asyncio_mode
|
||||
# auto), numpy (test_bfld), websockets + paho-mqtt (the [client] extra
|
||||
# used by test_client_*).
|
||||
- name: Create venv + install maturin and test deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install "maturin>=1.7,<2.0" pytest pytest-asyncio numpy websockets paho-mqtt
|
||||
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
|
||||
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build + install wheel (maturin develop --features sota)
|
||||
working-directory: python
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
run: maturin develop --features sota
|
||||
|
||||
- name: Run parity + binding tests
|
||||
run: pytest python/tests/ -q
|
||||
|
||||
# Numeric enforcement of the ADR-117 §5.4 default-wheel budget. A fix-marker
|
||||
# can guard the CONFIG that keeps the wheel small (empty default features,
|
||||
# optional SOTA deps, strip=true — see RuView#1387-default-wheel-budget-config
|
||||
# in scripts/fix-markers.json) but it cannot measure bytes. This job builds
|
||||
# the DEFAULT (no-features) wheel and fails if it exceeds the budget — the
|
||||
# real guard against a dependency silently ballooning the shipped wheel.
|
||||
wheel-size-budget:
|
||||
name: Default wheel <= 5 MiB (ADR-117 §5.4)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: python
|
||||
|
||||
- name: Install maturin
|
||||
run: python -m pip install --upgrade pip "maturin>=1.7,<2.0"
|
||||
|
||||
- name: Build default wheel and assert size budget
|
||||
working-directory: python
|
||||
run: |
|
||||
set -euo pipefail
|
||||
maturin build --release --out dist
|
||||
whl="$(ls dist/*.whl | head -1)"
|
||||
bytes="$(stat -c%s "$whl")"
|
||||
limit=$((5 * 1024 * 1024)) # ADR-117 §5.4: 5 MiB
|
||||
printf 'Default wheel: %s = %s bytes (%s MiB); budget = %s bytes\n' \
|
||||
"$whl" "$bytes" "$((bytes / 1024 / 1024))" "$limit"
|
||||
if [ "$bytes" -gt "$limit" ]; then
|
||||
echo "::error::default wheel is $bytes bytes, over the ADR-117 §5.4 $limit-byte (5 MiB) budget"
|
||||
exit 1
|
||||
fi
|
||||
echo "Default wheel is within the 5 MiB budget."
|
||||
@@ -1,137 +0,0 @@
|
||||
# ADR-265 D2 — publish only from CI, with provenance.
|
||||
#
|
||||
# Manual `npm publish` from laptops stops: this workflow re-runs the ADR-265 D1
|
||||
# gate for the selected package and then publishes with npm provenance
|
||||
# attestations (OIDC), tying every published version to a public commit +
|
||||
# workflow run — the npm-side analogue of the ADR-028 witness bundle.
|
||||
#
|
||||
# Requires: NPM_TOKEN repo secret (an npm automation token), or npm Trusted
|
||||
# Publishing configured for the package (in which case the token is unused).
|
||||
|
||||
name: ruview npm release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: 'Package directory to publish'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- harness/ruview
|
||||
- tools/ruview-mcp
|
||||
dist_tag:
|
||||
description: 'npm dist-tag'
|
||||
required: false
|
||||
default: 'latest'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm --provenance
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.package }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
|
||||
|
||||
- name: Build (if present)
|
||||
run: npm run build --if-present
|
||||
|
||||
- name: Test
|
||||
run: npm test --if-present
|
||||
|
||||
# ADR-265 D3 — package.json is the only place a version string lives.
|
||||
- name: Version-literal gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=""
|
||||
for d in src bin; do
|
||||
if [ -d "$d" ]; then
|
||||
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
|
||||
fi
|
||||
done
|
||||
if [ -n "$hits" ]; then
|
||||
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
|
||||
echo "$hits"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ADR-265 D1.3 — pack-content gate: no maps AND the per-package
|
||||
# unpacked-size budget (the budgets that npm-packages.yml enforces).
|
||||
- name: Pack gate (no maps + size budget)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ inputs.package }}" in
|
||||
# ADR-263: dependency-free harness; budget guards against dep creep.
|
||||
harness/ruview) export UNPACKED_BUDGET=65536 ;;
|
||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
||||
tools/ruview-mcp) export UNPACKED_BUDGET=140000 ;;
|
||||
*) echo "Unknown package '${{ inputs.package }}' — no budget defined"; exit 1 ;;
|
||||
esac
|
||||
npm pack --dry-run --json 2>/dev/null | node -e "
|
||||
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
const budget = Number(process.env.UNPACKED_BUDGET);
|
||||
const maps = info.files.filter((f) => f.path.endsWith('.map'));
|
||||
if (maps.length > 0) {
|
||||
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
|
||||
process.exit(1);
|
||||
}
|
||||
if (info.unpackedSize > budget) {
|
||||
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
|
||||
"
|
||||
|
||||
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
|
||||
- name: Tarball smoke test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
|
||||
SMOKE="$(mktemp -d)"
|
||||
cd "$SMOKE"
|
||||
npm init -y > /dev/null
|
||||
npm i --no-fund --no-audit "$TGZ"
|
||||
case "${{ inputs.package }}" in
|
||||
harness/ruview)
|
||||
./node_modules/.bin/ruview --version
|
||||
./node_modules/.bin/ruview doctor
|
||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
||||
if ./node_modules/.bin/ruview claim-check; then
|
||||
echo 'claim-check passed with no input — fail-open regression'; exit 1
|
||||
fi
|
||||
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
|
||||
;;
|
||||
tools/ruview-mcp)
|
||||
# initialize over stdio; server must answer and exit 0 on EOF
|
||||
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
|
||||
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
|
||||
# the ESM export must resolve from the installed tarball (ADR-264 F1)
|
||||
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Claim-check README
|
||||
run: |
|
||||
if [ -f README.md ]; then
|
||||
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
|
||||
fi
|
||||
|
||||
- name: Publish (with provenance)
|
||||
run: npm publish --provenance --access public --tag "${{ inputs.dist_tag }}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
# RUN guard catches missing ones at build time, this re-checks the
|
||||
# pushed artifact post-hoc as belt-and-braces).
|
||||
# 2. /health is up.
|
||||
# 3. /api/v1/info returns 200 with the explicit trusted-LAN opt-in.
|
||||
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
|
||||
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
||||
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
||||
# ---------------------------------------------------------------------
|
||||
@@ -124,14 +124,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
docker run --rm "$IMAGE" sh -c \
|
||||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
|
||||
docker run -d --name sm -p 3000:3000 \
|
||||
-e CSI_SOURCE=simulated \
|
||||
-e RUVIEW_ALLOW_UNAUTHENTICATED=1 \
|
||||
"$IMAGE"
|
||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
||||
# Wait up to 30 s for /health.
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
|
||||
@@ -7,26 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha` → `5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
|
||||
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
|
||||
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
|
||||
|
||||
### Deprecated
|
||||
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
|
||||
|
||||
### Fixed
|
||||
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
|
||||
- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests.
|
||||
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10–150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
|
||||
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
|
||||
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 40–45 pps).
|
||||
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1–O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn` — `ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1–O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1–D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
|
||||
- **Empty-room field-model calibration collected nothing on real HT40 nodes — raw 128-wide frames rejected by the 56-tone model (follow-up to the deadlock fix below).** Once the status-gate deadlock was fixed, `maybe_feed_calibration` reached `feed_calibration`, but a real ESP32 HT40 node streams 128-wide amplitude vectors while the single-link `FieldModel` is the canonical 56-tone grid — so `LinkStats::update` returned `DimensionMismatch`, `feed_calibration` bubbled the error, and `maybe_feed_calibration` swallowed it at `debug` level. Net effect: `frame_count` stayed pinned at 0 on live hardware even though presence/motion/vitals (which read the global history) worked fine. Fixed by resampling each frame onto the model's canonical 56-tone grid via `HardwareNormalizer::resample_to_canonical` before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by `field_bridge::maybe_feed_calibration_resamples_wide_frames_and_accumulates` (128-wide frame → Collecting + count 1; fails on old code). Verified live on the ESP32-S3 deployment.
|
||||
- **Empty-room field-model calibration could never start — `/api/v1/calibration/*` was a dead endpoint (frame count pinned at 0).** `POST /calibration/start` creates the `FieldModel` in `Uncalibrated`, but the per-frame server feed `field_bridge::maybe_feed_calibration` only fed observations while the model was **already** `Collecting` — and the *only* thing that sets `Collecting` is `feed_calibration` itself (on its first fed frame). The two gates deadlocked: nothing ever fed the first frame, so `calibration_frame_count` stayed 0, `status` never left `Uncalibrated`, and the SVD room eigenstructure (eigenvalue-based person counting / localization) could never calibrate — observed live as `{"status":"Uncalibrated","frame_count":0}` never advancing on a streaming ESP32 node. Fixed the guard to feed while `Uncalibrated | Collecting` so the first frame flips the model to `Collecting` and the count advances. Also made `calibration_stop` return a structured `{success:false, frame_count, frames_needed}` (with a new `FieldModel::min_calibration_frames()` accessor) instead of an opaque 500 when finalized before enough empty-room frames accumulate. Pinned by `field_bridge::maybe_feed_calibration_advances_uncalibrated_to_collecting` (asserts `Uncalibrated → Collecting` + count 0 → 1 → 2; fails on old code). Presence/motion/vitals were unaffected — they use the separate auto rolling baseline, not the field model.
|
||||
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
|
||||
- **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
|
||||
- **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4).
|
||||
- **Multistatic fusion guard interval is now operator-configurable — fixes permanent trust demotion with WiFi-synced ESP32 nodes (#1049).** Two independently-clocked ESP32-S3 boards on ESP-NOW sync drift 10–150 ms (typ. ~70 ms) — the 100 ms beacon + WiFi-MAC jitter cannot hold them within the published 60 ms default guard, so the governed-trust cycle permanently demoted to `Restricted`, suppressed all pose output, and spun the error counter to 200k+ with **no escape hatch but a container restart**. Added a **direct `WDP_GUARD_INTERVAL_US` override** (+ optional `WDP_SOFT_GUARD_US`) to `multistatic_guard_config_from_env`, so a deployment can lift the hard guard past its measured spread (e.g. `WDP_GUARD_INTERVAL_US=200000`) without having to know its exact TDM schedule. Precedence is most-specific-wins: a direct override beats the existing `WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US` schedule-derived guard, which beats the 60 ms/20 ms default; the override is applied on top of whichever base is selected, the soft band is always clamped strictly below the hard guard, and a malformed/zero value is ignored (falls back to the base rather than breaking fusion). The effective guard is now logged at startup. Pinned by 6 new tests (`multistatic_guard_config_tests`): direct-override-wins / beats-TDM-derived / soft-clamped-below-hard / lowering-hard-pulls-soft-down / malformed-or-zero-falls-back / default-when-unset. `wifi-densepose-sensing-server` bin tests **449 → 455**, 0 failed; Python proof VERDICT PASS, hash unchanged (off the signal proof path).
|
||||
|
||||
### Security
|
||||
@@ -42,8 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32` → `f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
|
||||
|
||||
### Added
|
||||
- **ADR-184 / ADR-185 / ADR-186 / ADR-187 decision records added under `docs/adr/`** (indexed in the ADR README, count corrected to 193 — commit `cca5bd811`). **ADR-184** (PyPI Trusted Publishing, completes ADR-117) — Proposed; its CI migration has landed but is pending pypi.org registration (see Changed). **ADR-185** (P6 Python bindings for AETHER/MERIDIAN/MAT) and **ADR-186** (training progress API, refs #1233) are **Proposed only** — decision records for work not yet implemented on this branch. **ADR-187** (archive/v1 deprecation + model-weights honest labeling) — Accepted and implemented (see Deprecated).
|
||||
- **ADR-263/264/265: deep review of the RuView npm surface (`@ruvnet/ruview`, `@ruvnet/rvagent`, `@ruv/ruview-cli`) with optimization strategies recorded as ADRs.** ADR-263 reviews the published `@ruvnet/ruview@0.1.0` harness: fail-open `claim-check` on empty input (HIGH), `spawnSync` head-of-line blocking of the MCP stdio server during long `verify`/`calibrate` runs (HIGH), optionalDependencies tripling the cold `npx` install for a code path that never uses them (MEASURED, `npm i` in a clean prefix: 4 packages / 620 kB / 71 files default vs 1 package / 172 kB / 22 files with `--omit=optional`), 1 MiB `maxBuffer` truncation risk, `python -c` port-interpolation surface in `node_monitor`, hardcoded MCP server version, duplicated skill payload — optimizations O1–O8. ADR-264 reviews `@ruvnet/rvagent@0.1.0` + the private CLI **against the published registry tarball**: `exports.require` → nonexistent `dist/index.cjs` (HIGH, every CJS consumer breaks), 44 dead source-map files = 62,698 B of the 188 kB unpacked payload pointing at unshipped `../src` (MEASURED), stdio-only server described as "dual-transport" (CLAIMED capability), mixed dot/underscore tool naming, double Zod validation + hand-duplicated advertised schemas, 2-fd leak per training job, unbounded request body in the unwired HTTP scaffold, dead `detectCogBinary` candidate list, `ruview` bin-name collision — optimizations O1–O9. ADR-265 adds the cross-cutting distribution layer: an `npm-packages.yml` CI matrix (tests + pack-content/size gate + tarball-install smoke test — none of the three packages currently has any CI, and `ci.yml` pins Node 18 against `engines >= 20`), publish-from-CI-only with `npm publish --provenance`, version single-sourcing from package.json, bin/namespace ownership (the `ruview` bin belongs to `@ruvnet/ruview`), and claim-check enforcement on package READMEs/descriptions. Docs only — no runtime code changed; the findings are the work orders for the follow-up PRs.
|
||||
- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing`→`breathing_bpm`, `heartbeat`→`heart_bpm`, `None`→`null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 files green** — import-graph, boot, render-smoke (22), interaction (3), and a **new prod-errors suite (13)** that runs with demo OFF + gateway unreachable and proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live** — `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs` → `[]` (no apps dir), SEED-tier → typed `503`, and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` adapts `RoomState` to the UI shape (`breathing`→`breathing_bpm`, `heartbeat:null`→`heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status.
|
||||
- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4` … `--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge.
|
||||
- **ADR-175: int8 quantization of the WiFlow-STD "half" pose model — MEASURED fp32-vs-int8 accuracy/size trade-off (honest negative).** Sub-deliverable 8.2 of the benchmark/optimization milestone, and the reading of the SOTA brief's "one untested edge lever" (QAT-int8 on the 843,834-param half model that strictly dominates the published 2.23M model). A new committed script `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py` quantizes `half_best.pth` to int8 two ways and scores both with the **same** upstream `calculate_pck`/`calculate_mpjpe` that produced the fp32 sweep numbers, under **one locked normalization** (ADR-173 torso-diameter PCK — neck idx2→pelvis idx12, `use_torso_norm=True`, the standard MM-Fi/GraphPose-Fi convention), on the **same** seed-42 file-level 70/15/15 test split (52,560 NaN-free / 54,000 full windows). **MEASURED on ruvultra (RTX 5080, torch 2.11.0+cu128, fbgemm; clean test, torso-PCK):** fp32 = 96.62% PCK@20 / 99.47% PCK@50 / 0.008981 MPJPE / 3.351 MB (fp32-CPU reproduces fp32-GPU to 4 dp, so the int8 deltas are pure quantization, not CPU/GPU drift); **int8 static PTQ = 40.98% PCK@20 (−55.64 pp), 1.046 MB** — naive static QDQ **collapses** on this model (the brief's 2.23M "sweet spot" does NOT transfer to the 843k half model at the tight @20 threshold); **int8 QAT (3-epoch FX fake-quant fine-tune from half_best) = 67.48% PCK@20 (−29.15 pp) / 98.69% PCK@50 (−0.78 pp), 1.043 MB.** **Verdict (honest no):** int8 is **not a win** at the strict PCK@20 edge target — QAT recovers a large share of the PTQ collapse and is near-lossless at the loose PCK@50 (coarse localization survives int8, fine does not), but a **3.2× size win at −29 pp PCK@20** is a bad trade when the half model already fits edge flash at fp32 → **keep fp32/fp16 on the edge for now.** **Disclosed gap:** the QAT *fake-quant* val PCK@20 reached 83.45% but the *converted* int8 model scores 67.48% — a real ~16 pp `convert_fx` gap (fbgemm int8 kernels ≠ straight-through estimate, esp. the axial-attention einsum/softmax); we report the converted-int8 number, not the fake-quant proxy. **MEASURED:** every table number + the PTQ collapse + the QAT partial recovery + the conversion gap. **CLAIMED/not done:** ONNX/TFLite export, on-edge-SoC latency/energy (int8 measured on x86 fbgemm — size transfers, latency does NOT), mixed-precision keeping attention fp32, longer/better-tuned QAT. **Honest limitations:** single in-domain eval split (no cross-environment split), x86-int8 not edge-SoC-int8, lightly-tuned QAT. Additive only — no production Rust or signal-pipeline change; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — off the signal proof path).
|
||||
|
||||
@@ -62,7 +62,7 @@ All 5 ruvector crates integrated in workspace:
|
||||
- `ruvector-attention` → `model.rs` (apply_spatial_attention) + `bvp.rs`
|
||||
|
||||
### Architecture Decisions
|
||||
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). Key ones:
|
||||
43 ADRs in `docs/adr/` (ADR-001 through ADR-043). Key ones:
|
||||
- ADR-014: SOTA signal processing (Accepted)
|
||||
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
|
||||
- ADR-016: RuVector training pipeline integration (Accepted — complete)
|
||||
@@ -77,10 +77,6 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
|
||||
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.1–2.3, 2.6 implemented)
|
||||
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
|
||||
- ADR-182: `npx ruview` harness minted via MetaHarness (Accepted — P1+P2 shipped as `@ruvnet/ruview`)
|
||||
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
|
||||
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
|
||||
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
@@ -93,8 +89,6 @@ All 5 ruvector crates integrated in workspace:
|
||||
|
||||
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
|
||||
|
||||
**⚠️ Compact boards (SuperMini, ESP32-S3-Zero, other coin-sized clones) run hot:** the firmware keeps the WiFi radio on continuously (`WIFI_PS_NONE`) and runs a full DSP pipeline (`edge_tier=2`), which is sustained high current draw. Full-size dev boards handle this fine; coin-sized clones with minimal PCB copper and budget regulators can run uncomfortably hot and, per at least one field report, have failed to power on again after a hot session. Give them airflow and check by touch during the first few minutes. See `firmware/esp32-csi-node/README.md` for details.
|
||||
|
||||
### Build & Test Commands (this repo)
|
||||
```bash
|
||||
# Rust — full workspace tests (1,031+ tests, ~2 min)
|
||||
|
||||
@@ -51,26 +51,26 @@ verify-audit:
|
||||
|
||||
# ─── Rust Builds ─────────────────────────────────────────────
|
||||
build-rust:
|
||||
cd v2 && cargo build --release
|
||||
cd rust-port/wifi-densepose-rs && cargo build --release
|
||||
|
||||
build-wasm:
|
||||
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release
|
||||
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release
|
||||
|
||||
build-wasm-mat:
|
||||
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
|
||||
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
|
||||
|
||||
test-rust:
|
||||
cd v2 && cargo test --workspace --no-default-features
|
||||
cd rust-port/wifi-densepose-rs && cargo test --workspace
|
||||
|
||||
bench:
|
||||
cd v2 && cargo bench --package wifi-densepose-signal
|
||||
cd rust-port/wifi-densepose-rs && cargo bench --package wifi-densepose-signal
|
||||
|
||||
# ─── Run ─────────────────────────────────────────────────────
|
||||
run-api:
|
||||
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
run-api-dev:
|
||||
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
run-viz:
|
||||
python3 -m http.server 3000 --directory ui
|
||||
@@ -81,7 +81,7 @@ run-docker:
|
||||
# ─── Clean ───────────────────────────────────────────────────
|
||||
clean:
|
||||
rm -f .install.log
|
||||
cd v2 && cargo clean 2>/dev/null || true
|
||||
cd rust-port/wifi-densepose-rs && cargo clean 2>/dev/null || true
|
||||
|
||||
# ─── Help ────────────────────────────────────────────────────
|
||||
help:
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://cognitum.one/marketplace/musica">
|
||||
<img src="assets/musica-promo.png" alt="Cognitum Musica" width="100%">
|
||||
<a href="https://cognitum.one/seed">
|
||||
<img src="assets/seed.png" alt="Cognitum Seed" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -58,7 +58,7 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
|
||||
> | 💓 **Heart rate** | Bandpass 0.8–2.0 Hz, zero-crossing BPM | 40–120 BPM, real-time |
|
||||
> | 👤 **Presence detection** | Trained head on Hugging Face ([`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained); v2 encoder = 82.3% held-out temporal-triplet acc, honestly re-benchmarked) + a phase-variance fallback that needs no model | < 1 ms, ~30 s ambient calibration |
|
||||
> | 🧬 **CSI embeddings** | 128-dim contrastive encoder shipped on Hugging Face, 4-bit quantised variant fits in 8 KB | **164,183 emb/s** on M4 Pro |
|
||||
> | 🦴 **17-keypoint pose estimation** | `cog-pose-estimation` Cog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loads `pose_v1.safetensors` via Candle (the committed `pose_v1` is a **first-cut** on-device model: PCK@20 = 3.0%, below the ADR-079 ≥35% target, and its runtime path is still a `confidence=0` stub — see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not); the **82.69%** figure below is the separate published MM-Fi benchmark, not this live cog). Train your own from paired data in 2.1 s on an RTX 5080 ([ADR-101](docs/adr/ADR-101-pose-estimation-cog.md), [benchmarks](docs/benchmarks/pose-estimation-cog.md)). **SOTA on MM-Fi:** [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) hits **82.69% torso-PCK@20** (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Fi `random_split` protocol — self-corrected and auditable on [AetherArena](https://huggingface.co/spaces/ruvnet/aether-arena) | 8.4 ms cold-start on a Pi 5 |
|
||||
> | 🦴 **17-keypoint pose estimation** | `cog-pose-estimation` Cog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loads `pose_v1.safetensors` via Candle. Train your own from paired data in 2.1 s on an RTX 5080 ([ADR-101](docs/adr/ADR-101-pose-estimation-cog.md), [benchmarks](docs/benchmarks/pose-estimation-cog.md)). **SOTA on MM-Fi:** [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) hits **82.69% torso-PCK@20** (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Fi `random_split` protocol — self-corrected and auditable on [AetherArena](https://huggingface.co/spaces/ruvnet/aether-arena) | 8.4 ms cold-start on a Pi 5 |
|
||||
> | 🚶 **Motion / activity** | Motion-band power + phase acceleration | Real-time |
|
||||
> | 🤸 **Fall detection** | Phase-acceleration threshold + 3-frame debounce + 5 s cooldown ([#263](https://github.com/ruvnet/RuView/issues/263)) | < 200 ms |
|
||||
> | 🧮 **Multi-person count** | Adaptive P95 normalisation + runtime-tunable dedup factor (`/api/v1/config/dedup-factor`, [#491](https://github.com/ruvnet/RuView/pull/491)). Six specialised learned counters available as Cogs: `occupancy-zones`, `elevator-count`, `queue-length`, `customer-flow`, `clean-room`, `person-matching` | Real-time, self-calibrating |
|
||||
@@ -128,12 +128,10 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
|
||||
>
|
||||
> | Option | Hardware | Cost | Full CSI | Capabilities |
|
||||
> |--------|----------|------|----------|-------------|
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary — first-cut on-device model, see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not)), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 Mesh** | 3-6× ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
|
||||
> | **ESP32-C6 research node** ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), [witness](docs/WITNESS-LOG-110.md), [reviewer guide](docs/ADR-110-REVIEW-GUIDE.md), [firmware v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)) | ESP32-C6-DevKit ($6–10) | ~$10 | Yes (Wi-Fi 6 capable) | Same CSI pipeline as S3 with the dual-target firmware. **Firmware-side ADR-110 substrate now closed** (v0.7.0): ESP-NOW cross-board mesh quantified at **99.56 % match / 104 µs smoothed offset stdev / 3.95× EMA suppression** over a 5-min two-board soak (witness §A0.10), 32-byte UDP sync packet with operator-tunable cadence (§A0.12), ADR-018 byte 19 bit 4 wire-fix sourced from the working ESP-NOW path (§A0.13). Wire format ready for HE-LTF PPDU tagging in ADR-018 bytes 18-19 (firmware encoder + Rust + Python decoders verified end-to-end across 23 unit tests). LP-core motion-gate RISC-V program and Wi-Fi 6 soft-AP with TWT Responder both ship as opt-in code paths (default off). **Hardware-gated for measurement**: HE-LTF live subcarrier capture needs an 11ax AP (IDF v5.4 doesn't expose AP-side HE config — §A0.6); ~5 µA LP-core hibernation needs an INA meter to capture; 802.15.4 raw RX is broken in IDF v5.4 (workaround: ESP-NOW transport, shipped + measured). See witness log for the empirical / claimed split. |
|
||||
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
|
||||
> | **Qualcomm CSI beta** ([ADR-268](docs/adr/ADR-268-qualcomm-atheros-csi-platform.md)) | QCA9300 now; QCN9074/QCN9274 experimental | ~$30-200 | Simulator now; hardware adapter gated | Rust `QCS1` codec, deterministic replay, UDP/API integration; modern ath11k/ath12k profiles do not claim public CSI export |
|
||||
> | **Vendor provider beta** ([ADR-270](docs/adr/ADR-270-vendor-rf-sensing-integration-program.md)) | Origin, Plume, Mist, NETGEAR, Electric Imp, RF Solutions, Luma, Nest, Linksys, Wifigarden | Varies | Capability-dependent | Bounded Rust adapters and deterministic fixtures; telemetry/network-only/unsupported states cannot masquerade as CSI |
|
||||
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
|
||||
>
|
||||
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py`
|
||||
@@ -145,7 +143,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
|
||||
<img src="assets/v2-screen.png" alt="WiFi DensePose — Live pose detection with setup guide" width="800">
|
||||
</a>
|
||||
<br>
|
||||
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables (demo visualization; the live CSI-only single-ESP32 17-keypoint model is still first-cut — see <a href="#model-weights-whats-real-whats-not">Model weights: what's real, what's not</a>)</em>
|
||||
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables</em>
|
||||
<br><br>
|
||||
<a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a>
|
||||
|
|
||||
@@ -157,7 +155,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
|
||||
|
||||
> The [server](#-quick-start) is optional for visualization and aggregation — the ESP32 [runs independently](#esp32-s3-hardware-pipeline) for presence detection, vital signs, and fall alerts.
|
||||
>
|
||||
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md). (The webcam supplies ground-truth pose in this dual-modal demo; the CSI-only on-device 17-keypoint model is still first-cut — see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not).)
|
||||
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md).
|
||||
>
|
||||
> **three.js scene gallery** at [`/three.js/`](https://ruvnet.github.io/RuView/three.js/) — five progressively richer ADR-097 demos: helpers, cinematic, GLTF skinned, FBX skinned, and a live MediaPipe→Mixamo retargeting feed driven by ESP32 CSI. Demos 04 and 05 require a local Mixamo `X Bot.fbx` (license boundary — not redistributed).
|
||||
|
||||
@@ -204,26 +202,7 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/
|
||||
python archive/v1/data/proof/verify.py
|
||||
```
|
||||
|
||||
Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md) phases P7–P9 for the camera-supervised fine-tune path.
|
||||
|
||||
### Model weights: what's real, what's not
|
||||
|
||||
"WiFi → pose" means three different things in this repo, at three different maturity
|
||||
levels. Read the label, not the headline ([ADR-187](docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
|
||||
|
||||
| Tier | Checkpoint(s) | Honest status |
|
||||
|------|---------------|---------------|
|
||||
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (CSI encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. These are the pose/presence numbers the project stands behind today. |
|
||||
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`** — the weights are not yet wired in. Full disclosure in the [cog README](v2/crates/cog-pose-estimation/cog/README.md). |
|
||||
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
|
||||
|
||||
**On the ESP32-SISO question ([#509](https://github.com/ruvnet/RuView/issues/509)):** 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 — the cog
|
||||
measurements above show distal/face joints near-random. The shippable pose accuracy the
|
||||
project can stand 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](docs/adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645) — do not advertise the live single-ESP32 17-keypoint feature without the "first-cut, below-target, runtime-stub" caveat until that baseline is measured.
|
||||
Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) phases P7–P9 for the camera-supervised fine-tune path.
|
||||
|
||||
|
||||
## 🧩 Edge Module Catalog
|
||||
@@ -638,7 +617,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
||||
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# ⚠️ DEPRECATED — `archive/v1` is unmaintained and superseded
|
||||
|
||||
**Do not build new work on this tree.** `archive/v1` is the original pure-Python
|
||||
implementation of WiFi-DensePose. It is kept only as a research archive
|
||||
(per [ADR-117 §1.3](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)) and
|
||||
as the host of one still-live deterministic proof (see "What still lives here" below).
|
||||
Everything else in this directory is frozen and receives no fixes, reviews, or support.
|
||||
|
||||
Governed by [ADR-187](../../docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md).
|
||||
|
||||
## The one honest fact that trips people up
|
||||
|
||||
`archive/v1/src/models/densepose_head.py` defines a `DensePoseHead` neural-network
|
||||
architecture (segmentation + UV-regression heads). **It ships no trained weights.** Its
|
||||
`_initialize_weights()` uses `kaiming_normal_` **random initialization only** — there is
|
||||
no checkpoint-loading path in the class, and there are **zero** `.pth` / `.onnx` /
|
||||
`.safetensors` / `.pt` / `.ckpt` / `.bin` files anywhere under `archive/v1/`.
|
||||
|
||||
So: the architecture is *defined*, but it is **architecture-only**. Running it produces
|
||||
random output, not real pose accuracy. This matches the technical review in
|
||||
[#509](https://github.com/ruvnet/RuView/issues/509) — for *this tree*, the "network
|
||||
defined, no pre-trained weights" observation is TRUE.
|
||||
|
||||
Real, trained, benchmarked weights **do** exist — just not here. They live in the
|
||||
maintained `v2/` workspace and on Hugging Face (see next section).
|
||||
|
||||
## Use the maintained path instead
|
||||
|
||||
| You want… | Go here |
|
||||
|-----------|---------|
|
||||
| The maintained implementation | The **`v2/` Rust workspace** (repo root `../../v2/`) |
|
||||
| A pip install | `pip install ruview` **or** `pip install wifi-densepose` (2.x) — the compiled PyO3 wheel ([ADR-117](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)). The `wifi-densepose` **1.x** line is tombstoned on PyPI: `1.99.0` raises an `ImportError` telling you to migrate. |
|
||||
| Real trained presence/encoder weights | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — 82.3% held-out temporal-triplet accuracy |
|
||||
| A real 17-keypoint pose model | [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) — 82.69% torso-PCK@20 on MM-Fi `random_split` |
|
||||
| The honest three-tier weights picture | The "Model weights: what's real, what's not" table in the root [`README.md`](../../README.md) and [`docs/user-guide.md`](../../docs/user-guide.md) |
|
||||
|
||||
## What still lives here (intentionally)
|
||||
|
||||
Only one thing under `archive/v1/` is still a live, cited signal: the deterministic
|
||||
reference-pipeline proof —
|
||||
|
||||
```bash
|
||||
python archive/v1/data/proof/verify.py # must print VERDICT: PASS
|
||||
```
|
||||
|
||||
This is the ADR-028 "Trust Kill Switch": it feeds a fixed reference signal through the
|
||||
signal-processing pipeline and checks the SHA-256 of the output against a published hash.
|
||||
It is a legitimate reproducibility witness and is **not** deprecated. Everything else in
|
||||
this tree is.
|
||||
@@ -1,19 +1,3 @@
|
||||
> ## ⚠️ DEPRECATED — unmaintained and superseded
|
||||
>
|
||||
> This tree is the **original pure-Python implementation** and is kept only as a research
|
||||
> archive. It receives no fixes, reviews, or support. **Read [`DEPRECATED.md`](DEPRECATED.md) before using anything below.**
|
||||
>
|
||||
> - Its `DensePoseHead` is **architecture-only with random-initialized weights and ships no
|
||||
> trained checkpoint** — running it produces random output, not real pose accuracy.
|
||||
> - The maintained path is the **`v2/` Rust workspace** and the `wifi-densepose 2.x` / `ruview`
|
||||
> pip wheel ([ADR-117](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)). The
|
||||
> `wifi-densepose` 1.x line is tombstoned on PyPI (1.99.0 raises `ImportError`).
|
||||
> - Real trained weights live elsewhere: [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained)
|
||||
> (presence, 82.3%) and [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
|
||||
> (17-keypoint pose, 82.69% torso-PCK@20).
|
||||
> - The only still-live artifact here is the deterministic proof `data/proof/verify.py`
|
||||
> (ADR-028), which stays. See [ADR-187](../../docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md).
|
||||
|
||||
# WiFi-DensePose v1 (Python Implementation)
|
||||
|
||||
This directory contains the original Python implementation of WiFi-DensePose.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Health check API endpoints
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import psutil
|
||||
from typing import Dict, Any, Optional
|
||||
@@ -169,7 +168,7 @@ async def health_check(request: Request):
|
||||
overall_status = "degraded"
|
||||
|
||||
# Get system metrics
|
||||
system_metrics = await asyncio.to_thread(get_system_metrics)
|
||||
system_metrics = get_system_metrics()
|
||||
|
||||
uptime_seconds = (datetime.now() - _APP_START_TIME).total_seconds()
|
||||
|
||||
@@ -264,12 +263,11 @@ async def get_health_metrics(
|
||||
):
|
||||
"""Get detailed system metrics."""
|
||||
try:
|
||||
metrics = await asyncio.to_thread(get_system_metrics)
|
||||
metrics = get_system_metrics()
|
||||
|
||||
# Add additional metrics if authenticated
|
||||
if current_user:
|
||||
detailed = await asyncio.to_thread(get_detailed_metrics)
|
||||
metrics.update(detailed)
|
||||
metrics.update(get_detailed_metrics())
|
||||
|
||||
return {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
@@ -302,7 +300,7 @@ def get_system_metrics() -> Dict[str, Any]:
|
||||
"""Get basic system metrics."""
|
||||
try:
|
||||
# CPU metrics
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
|
||||
# Memory metrics
|
||||
|
||||
@@ -180,24 +180,21 @@ class MetricsService:
|
||||
async def _collect_system_metrics(self):
|
||||
"""Collect system-level metrics."""
|
||||
try:
|
||||
# Query OS metrics in a background thread to prevent blocking the event loop
|
||||
def gather_metrics():
|
||||
return (
|
||||
psutil.cpu_percent(interval=None),
|
||||
psutil.virtual_memory().percent,
|
||||
psutil.disk_usage('/'),
|
||||
psutil.net_io_counters()
|
||||
)
|
||||
|
||||
cpu_percent, mem_percent, disk, network = await asyncio.to_thread(gather_metrics)
|
||||
|
||||
# Record metrics on the main loop
|
||||
# CPU usage
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
self._metrics["system_cpu_usage"].add_point(cpu_percent)
|
||||
self._metrics["system_memory_usage"].add_point(mem_percent)
|
||||
|
||||
# Memory usage
|
||||
memory = psutil.virtual_memory()
|
||||
self._metrics["system_memory_usage"].add_point(memory.percent)
|
||||
|
||||
# Disk usage
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_percent = (disk.used / disk.total) * 100
|
||||
self._metrics["system_disk_usage"].add_point(disk_percent)
|
||||
|
||||
# Network I/O
|
||||
network = psutil.net_io_counters()
|
||||
self._metrics["system_network_bytes_sent"].add_point(network.bytes_sent)
|
||||
self._metrics["system_network_bytes_recv"].add_point(network.bytes_recv)
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Add project root and archive/v1 to sys.path so we can import src modules
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../")))
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from archive.v1.src.api.routers.health import get_system_metrics
|
||||
|
||||
async def ticker():
|
||||
"""Asynchronous background ticker to measure event loop latency/freezes."""
|
||||
ticks = []
|
||||
for _ in range(15):
|
||||
ticks.append(time.time())
|
||||
await asyncio.sleep(0.1)
|
||||
return ticks
|
||||
|
||||
async def run_test():
|
||||
print("Starting concurrency verification test...")
|
||||
|
||||
# Start the ticker background task
|
||||
ticker_task = asyncio.create_task(ticker())
|
||||
|
||||
# Let ticker run for a few ticks
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
print("Calling get_system_metrics offloaded to background thread...")
|
||||
start_time = time.time()
|
||||
|
||||
# Query system metrics using to_thread (simulating FastAPI request)
|
||||
metrics = await asyncio.to_thread(get_system_metrics)
|
||||
|
||||
duration = time.time() - start_time
|
||||
print(f"get_system_metrics took: {duration:.4f}s")
|
||||
|
||||
# Wait for the ticker to complete
|
||||
ticks = await ticker_task
|
||||
|
||||
# Calculate gaps between consecutive ticks to check for event loop freezes
|
||||
gaps = [ticks[i+1] - ticks[i] for i in range(len(ticks)-1)]
|
||||
max_gap = max(gaps)
|
||||
|
||||
print(f"All tick gaps: {[round(g, 3) for g in gaps]}")
|
||||
print(f"Max event loop freeze: {max_gap:.4f}s")
|
||||
|
||||
# In pre-fix code, psutil.cpu_percent(interval=1) blocks for 1.0s,
|
||||
# causing a gap of >1.0s. With our fix, it should be close to 0.1s.
|
||||
return max_gap, duration
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_system_metrics_does_not_starve_event_loop():
|
||||
max_gap, duration = await run_test()
|
||||
# ticker sleeps 0.1s; allow slack for CI, but we should not see ~1s gaps
|
||||
assert max_gap < 0.6
|
||||
assert duration < 0.6
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -83,7 +83,7 @@ This ADR covers Phase 1 (TV box as aggregator) and Phase 2 (custom WiFi firmware
|
||||
|---------|--------|-------------|--------------|--------|
|
||||
| Broadcom BCM43455 | brcmfmac | **Proven** (Nexmon CSI) | Yes | Low — patches exist |
|
||||
| Realtek RTL8822CS | rtw88 | **Moderate** — driver is open-source, CSI hooks need adding | Yes (patched) | Medium |
|
||||
| MediaTek MT7661 | mt76 | **Unverified** — no supported public CSI capture API was found in upstream `mt76` or public MediaTek SDK material | Yes | Research only |
|
||||
| MediaTek MT7661 | mt76 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
|
||||
|
||||
2. **CSI extraction architecture** (Linux kernel driver modification):
|
||||
|
||||
|
||||
@@ -1,551 +0,0 @@
|
||||
# ADR-184: Complete ADR-117 via PyPI Trusted Publishing (OIDC) + real v2.0.0 / ruview publish
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-07-21 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **PHOENIX-LANDING** — the PIP-PHOENIX wheel that never actually took off |
|
||||
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX modernization — this ADR completes it), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-115](ADR-115-home-assistant-integration.md) (HA/Matter sibling), [ADR-168](ADR-168-benchmark-proof.md) (measured-not-claimed house style) |
|
||||
| **Tracking issue** | [#785](https://github.com/ruvnet/RuView/issues/785) (ADR-117, still OPEN) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-117 (PIP-PHOENIX) designed the v2.0.0 rewrite of the pip `wifi-densepose`
|
||||
package as a PyO3 + maturin compiled wheel over the Rust core, plus a `ruview`
|
||||
sibling package, replacing the 11.5-month-stale pure-Python `1.1.0` line. The
|
||||
code landed on `main` (the `python/` workspace: `Cargo.toml`, `src/bindings/*.rs`,
|
||||
the `wifi_densepose/` Python package, `tests/`, `bench/`). The tombstone shipped.
|
||||
**But the release itself is broken and the design doc's own P5 intent was never met.**
|
||||
|
||||
This ADR is a **gap analysis and remediation plan**, not a new feature. Every fact
|
||||
below was verified against PyPI and GitHub Actions on 2026-07-21; none are projected.
|
||||
|
||||
### 1.1 What is actually live on PyPI (measured)
|
||||
|
||||
`pip index versions wifi-densepose` returns:
|
||||
|
||||
```
|
||||
wifi-densepose (1.99.0)
|
||||
Available versions: 1.99.0, 1.2.0, 1.1.0, 1.0.0
|
||||
```
|
||||
|
||||
- `1.99.0` — the tombstone wheel **is genuinely live**. `import wifi_densepose`
|
||||
raises `ImportError` pointing users to 2.0+. This part of ADR-117 §7.2 shipped.
|
||||
- `2.0.0a1` — appears in PyPI's release history as a **pre-release** (hidden from
|
||||
the default `pip index` view, surfaced with `--pre`). It is still an **alpha**.
|
||||
- `2.0.0` (stable) — **does not exist.** ADR-117's headline deliverable
|
||||
(`pip install wifi-densepose==2.0.0`) is not installable.
|
||||
|
||||
`pip index versions ruview` returns:
|
||||
|
||||
```
|
||||
ERROR: No matching distribution found for ruview
|
||||
```
|
||||
|
||||
The `ruview` sibling package **was never published.** Commit `b71d243b4`
|
||||
(*"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI"*) claims
|
||||
a publish that did not happen for that package — a real **claimed-vs-measured gap**
|
||||
of exactly the kind [ADR-168](ADR-168-benchmark-proof.md) and the project's
|
||||
"prove everything" posture exist to catch.
|
||||
|
||||
### 1.2 Why the release pipeline was stuck (measured; interim-fixed — see §1.4)
|
||||
|
||||
`gh run list --workflow pip-release` shows the last **4** runs all
|
||||
`conclusion=failure` (most recent `2026-05-24T16:34`). The full failure log for
|
||||
run `26366735779` (job *"Publish v1.99 tombstone"* → step *"Publish to PyPI"*)
|
||||
shows two things:
|
||||
|
||||
1. The publish step uses `pypa/gh-action-pypi-publish` with a `password`
|
||||
(API-token) input and fails:
|
||||
|
||||
```
|
||||
403 Forbidden — Invalid or non-existent authentication information.
|
||||
```
|
||||
|
||||
i.e. the `PYPI_API_TOKEN` GitHub secret is stale / expired / revoked.
|
||||
|
||||
2. The action's own log warns:
|
||||
|
||||
```
|
||||
Warning: the workflow was run with 'attestations: true' ... but an explicit
|
||||
password was also set, disabling Trusted Publishing.
|
||||
```
|
||||
|
||||
The workflow at `.github/workflows/pip-release.yml` wires `password:
|
||||
${{ secrets.PYPI_API_TOKEN }}` into **four** publish steps (lines 249, 258, 282,
|
||||
291) and declares only `permissions: contents: read` (line 49–50). So it is using a
|
||||
rotatable, leak-able, expire-able API token in exactly the place ADR-117 §5.4 / §5.5
|
||||
and the issue #785 P5 row explicitly called for **OIDC Trusted Publishing** ("cp310
|
||||
… abi3-py310, OIDC"; ADR-117 §5.5 line 547: *"PyPI publish via Trusted Publisher
|
||||
(OIDC, no API token in secrets)"*). **The implementation drifted from its own
|
||||
design doc.**
|
||||
|
||||
### 1.3 Why the package is still alpha (measured)
|
||||
|
||||
`python/pyproject.toml` pins `version = "2.0.0a1"` (line 13) and
|
||||
`Development Status :: 3 - Alpha` (line 26). Issue #785's closing criteria
|
||||
(§"Done") require `wifi-densepose==2.0.0` (**not** alpha) published, plus all 10
|
||||
acceptance criteria in §11. None of those can be true today given §1.1–§1.2.
|
||||
|
||||
**Why this matters:** ADR-117 is the sole Python entry point for the whole RuView
|
||||
ecosystem (per its §2 "PyPI org presence check"). A stale token silently blocking
|
||||
every release means the entire "plug-and-play Python entry point for the pip +
|
||||
Jupyter customer base" thesis (issue #785 "Strategic alignment") is stalled behind a
|
||||
one-line credential problem — and a commit message claims otherwise.
|
||||
|
||||
### 1.4 Interim fix applied (2026-07-21) — credential unblocked, migration still pending
|
||||
|
||||
**As of 2026-07-21T22:57:29Z the stale-credential symptom is fixed at the credential
|
||||
layer.** The maintainer fetched a valid `PYPI_TOKEN` from GCP Secret Manager (project
|
||||
`cognitum-20260110`) and ran `gh secret set PYPI_API_TOKEN` to replace the
|
||||
revoked/expired value. Authentication was confirmed non-destructively via a
|
||||
`twine upload --skip-existing` re-upload of the existing `1.99.0` tombstone artifacts,
|
||||
which returned a benign 400/skip response (not the previous `403 Forbidden`) — proving
|
||||
the new token authenticates correctly.
|
||||
|
||||
This means **token-based publishing works again today** — the `403` root cause
|
||||
described in §1.2 no longer reproduces. It does **not**, however, close this ADR:
|
||||
|
||||
- A **manually-rotated token still expires, leaks, and can be revoked over time** — it
|
||||
re-introduces exactly the silent-failure mode that blocked the last 4 runs. It is a
|
||||
stopgap at the same layer as the §3.2 fallback, not the durable fix.
|
||||
- The OIDC **Trusted Publishing migration (§3, P1) remains the decision** — a
|
||||
credential PyPI mints per-run with no secret to rotate is the only fix that removes
|
||||
the recurring-expiry class of failure.
|
||||
- The other three gaps are **untouched** by this rotation: `wifi-densepose` is still
|
||||
`2.0.0a1` (not stable `2.0.0`), and `ruview` is still unpublished.
|
||||
|
||||
**Why/How to apply:** read §1.2's "root cause" as *diagnosed and temporarily
|
||||
mitigated*, not *still broken*. A reviewer re-running the §7.5 check today may now see
|
||||
a green token-based run — that is expected and does not satisfy this ADR, which is
|
||||
Accepted only when §6's criteria pass **and** the workflow no longer carries a static
|
||||
token (§7.4).
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state — evidence
|
||||
|
||||
| Artifact | Value | Source |
|
||||
|---|---|---|
|
||||
| Latest stable `wifi-densepose` on PyPI | **1.99.0** (tombstone) | `pip index versions wifi-densepose` |
|
||||
| `wifi-densepose==2.0.0` stable | **absent** | `pip index versions` (not listed) |
|
||||
| `wifi-densepose==2.0.0a1` pre-release | present (alpha) | PyPI release history (`--pre`) |
|
||||
| `ruview` on PyPI | **No matching distribution found** | `pip index versions ruview` |
|
||||
| `pip-release.yml` last 4 runs | all `failure` | `gh run list --workflow pip-release` |
|
||||
| Most recent failed run | `2026-05-24T16:34` | `gh run list` |
|
||||
| Failing step | Publish v1.99 tombstone → Publish to PyPI | run `26366735779` log |
|
||||
| Failure code | `403 Forbidden — Invalid or non-existent authentication information` | run `26366735779` log |
|
||||
| Root cause | `PYPI_API_TOKEN` stale/revoked; explicit password disables Trusted Publishing | run `26366735779` log warning |
|
||||
| `password:` uses in workflow | 4 (lines 249, 258, 282, 291) | `.github/workflows/pip-release.yml` |
|
||||
| Workflow permissions | `contents: read` only (no `id-token: write`) | `pip-release.yml:49–50` |
|
||||
| pyproject version | `2.0.0a1` | `python/pyproject.toml:13` |
|
||||
| pyproject dev status | `3 - Alpha` | `python/pyproject.toml:26` |
|
||||
| Issue #785 | **OPEN** | GitHub |
|
||||
|
||||
**Why/How to apply:** treat this table as the falsifiable baseline. A reviewer who
|
||||
re-runs each `Source` command must reproduce each `Value`, or this ADR is wrong and
|
||||
should be revised before any remediation is attempted.
|
||||
|
||||
---
|
||||
|
||||
## 3. Decision
|
||||
|
||||
Complete ADR-117 by closing four gaps, in order:
|
||||
|
||||
1. **Migrate `pip-release.yml` to PyPI Trusted Publishing (OIDC)** — as the durable
|
||||
end-state, drop all four `password: ${{ secrets.PYPI_API_TOKEN }}` inputs, grant
|
||||
`id-token: write` to the publish jobs, and add `environment: pypi`. This removes
|
||||
the rotatable/expire-able credential and realigns with ADR-117 §5.5's stated OIDC
|
||||
intent. **This is gated behind sub-phase P1b** (§5): the switch is inert — and in
|
||||
fact 403-breaking — until the manual pypi.org registration (§3.1) exists, so the
|
||||
OIDC change must land *together* with that registration. Until then, token auth
|
||||
(the freshly-rotated `PYPI_API_TOKEN`, §1.4) is the correct active path and is
|
||||
what the `RuView#786-pypi-token-auth` fix-marker guard enforces. An OIDC migration
|
||||
was attempted (`cc153e8b5`) and reverted (`82d5c7339`) for exactly this reason.
|
||||
|
||||
2. **Promote `wifi-densepose` from `2.0.0a1` to stable `2.0.0`** in
|
||||
`python/pyproject.toml` (version + `Development Status :: 5 - Production/Stable`)
|
||||
and record the promotion in `CHANGELOG.md`.
|
||||
|
||||
3. **Actually publish `ruview==2.0.0`** — the sibling package that commit
|
||||
`b71d243b4` claimed but never shipped — and verify it with `pip index versions`.
|
||||
|
||||
4. **Adopt issue #785 §11's 10 acceptance criteria verbatim as this ADR's own
|
||||
acceptance criteria** (§6 below), and only flip ADR-117 → Accepted and close
|
||||
#785 once every one passes against the real index — proven, not claimed.
|
||||
|
||||
### 3.1 Mandatory human prerequisite (cannot be automated)
|
||||
|
||||
**Trusted Publishing requires a one-time manual step on `pypi.org` that no CLI, API,
|
||||
or agent can perform** — PyPI restricts Trusted Publisher configuration to the
|
||||
project owner via the web UI for security reasons. Before P1's workflow change can
|
||||
succeed, a human with owner rights on both PyPI projects must:
|
||||
|
||||
1. Log in to `pypi.org`.
|
||||
2. For **`wifi-densepose`**: Project → *Publishing* → *Add a new pending/trusted
|
||||
publisher* → GitHub, with:
|
||||
- Owner: `ruvnet`
|
||||
- Repository: `RuView`
|
||||
- Workflow filename: `pip-release.yml`
|
||||
- Environment: `pypi`
|
||||
3. Repeat the identical step for the **`ruview`** project. Because `ruview` is not
|
||||
yet on PyPI, register it as a **pending publisher** (PyPI supports configuring a
|
||||
trusted publisher for a project name before its first release — the first OIDC
|
||||
publish then creates the project).
|
||||
|
||||
**Why/How to apply:** the workflow change in P1 is inert until this is done — the
|
||||
publish step will fail with a "no trusted publisher configured" error rather than a
|
||||
403. Land P1 and this manual step together; do not tag a release expecting OIDC to
|
||||
work until a human confirms both entries exist. Treat this section as a blocking
|
||||
checklist item on the release-day runbook, not a footnote.
|
||||
|
||||
### 3.2 Fallback path (if the owner declines Trusted Publishing)
|
||||
|
||||
If the maintainer prefers not to adopt OIDC yet, the code-side remediation is a
|
||||
**token regeneration**, not a redesign:
|
||||
|
||||
- Generate a fresh PyPI API token (scoped to the `wifi-densepose` and `ruview`
|
||||
projects) and store it in GCP Secret Manager (project `cognitum-20260110`, where
|
||||
the project's tokens live), then `gh secret set PYPI_API_TOKEN` from it, following
|
||||
the existing runbook referenced in the workflow header (`docs/integrations/pypi-release.md`).
|
||||
- Keep the current `password:`-based workflow unchanged.
|
||||
|
||||
**Why/How to apply:** this path clears the 403 and unblocks releases immediately,
|
||||
but it re-introduces the exact failure mode this ADR is trying to eliminate — a
|
||||
credential that silently expires and blocks the whole Python entry point again. Use
|
||||
it only as a stopgap; the Trusted Publishing migration (P1) is the durable fix and
|
||||
should remain the default recommendation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Detailed design — workflow migration
|
||||
|
||||
The change to `.github/workflows/pip-release.yml` is small and surgical. It does
|
||||
**not** touch the build matrix (`build-wheels`, `build-sdist`, `build-tombstone`
|
||||
jobs are unchanged — the 403 is a publish-credential problem, not a build problem).
|
||||
|
||||
### 4.1 Grant OIDC token permission on the publish jobs
|
||||
|
||||
The `gh-action-pypi-publish` action mints its OIDC token from the job's
|
||||
`id-token: write` permission. The current top-level `permissions: contents: read`
|
||||
must be extended on the two publish jobs (`publish-v2`, `publish-tombstone`) — plus
|
||||
the future `publish-ruview` job:
|
||||
|
||||
```yaml
|
||||
publish-v2:
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
permissions:
|
||||
id-token: write # ← added: mint the OIDC token for PyPI
|
||||
contents: read
|
||||
environment: pypi # ← added: binds to the PyPI trusted-publisher entry
|
||||
```
|
||||
|
||||
### 4.2 Drop the `password:` inputs
|
||||
|
||||
Every publish step loses its `password:` line. Trusted Publishing needs no secret —
|
||||
the action exchanges the job's OIDC token for a short-lived PyPI upload token
|
||||
automatically:
|
||||
|
||||
```yaml
|
||||
# BEFORE (current — fails with 403 when the token is stale)
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }} # ← remove
|
||||
packages-dir: dist
|
||||
|
||||
# AFTER (Trusted Publishing — no secret, activates once the pypi.org entry exists)
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
```
|
||||
|
||||
The TestPyPI dry-run steps keep `repository-url: https://test.pypi.org/legacy/` and
|
||||
likewise drop `password:` — a matching trusted-publisher entry must be registered on
|
||||
`test.pypi.org` if the dry-run path is to be used (otherwise gate the dry-run behind
|
||||
the fallback token or remove it).
|
||||
|
||||
**Why/How to apply:** the header comment block (lines 16–23) that documents the
|
||||
`PYPI_API_TOKEN` / GCP-Secret-Manager runbook must be rewritten to document the
|
||||
Trusted Publishing setup instead, so the next maintainer does not re-add a token
|
||||
"to fix" a future failure and silently re-disable OIDC.
|
||||
|
||||
### 4.3 Add the `publish-ruview` job
|
||||
|
||||
`ruview` is published by a new job mirroring `publish-v2` (same `id-token: write` +
|
||||
`environment: pypi`, no `password:`), gated on a `ruview`-scoped build. Because the
|
||||
package has never shipped, its first successful OIDC publish creates the PyPI
|
||||
project against the pending trusted-publisher entry from §3.1.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase ledger
|
||||
|
||||
```
|
||||
P1 ──► P1b ──► P2 ──► P3 ──► P4
|
||||
token OIDC version real close
|
||||
unblock (gated) promote publish #785
|
||||
```
|
||||
|
||||
### P1 — Credential unblock (token auth, active)
|
||||
|
||||
- [x] Rotate `PYPI_API_TOKEN` to a validated token (§1.4, `gh secret set`, verified
|
||||
`2026-07-21T22:57:29Z` via `twine upload --skip-existing`). Token-based publishing
|
||||
works today.
|
||||
- [x] Keep `password: ${{ secrets.PYPI_API_TOKEN }}` as the active auth path,
|
||||
satisfying the `RuView#786-pypi-token-auth` fix-marker guard.
|
||||
- [ ] Rewrite the `pip-release.yml` header comment block so the next maintainer
|
||||
knows OIDC is the intended P1b end-state (not a token to keep re-rotating forever).
|
||||
|
||||
> Note: an OIDC migration was attempted (`cc153e8b5`) and **reverted** (`82d5c7339`)
|
||||
> because it tripped the fix-marker guard before the pypi.org registration existed.
|
||||
> The OIDC work is therefore tracked as P1b below, not P1. See the Status note.
|
||||
|
||||
**Status 2026-07-21 — DESIGNED then REVERTED (token auth is the ACTIVE path):**
|
||||
The OIDC migration was implemented (commit `cc153e8b5` — `id-token: write` +
|
||||
`environment: pypi` on both publish jobs, all four `PYPI_API_TOKEN` password
|
||||
inputs removed) but then **reverted** (commit `82d5c7339`) after it tripped the
|
||||
pre-existing `RuView#786-pypi-token-auth` fix-marker guard
|
||||
(`scripts/fix-markers.json`). That guard `require`s
|
||||
`password: ${{ secrets.PYPI_API_TOKEN }}` and `forbid`s `id-token: write`
|
||||
precisely because a half-activated OIDC path (id-token permission present, but no
|
||||
Trusted Publisher yet registered on pypi.org) leaves publishing **403-broken**
|
||||
rather than working — it correctly predicted this exact failure. The revert was
|
||||
verified locally against the real checker (`python scripts/check_fix_markers.py` →
|
||||
all 25 markers pass, exit 0) before pushing.
|
||||
|
||||
**Active path today:** token-based auth via the freshly-rotated `PYPI_API_TOKEN`
|
||||
(§1.4). The current `pip-release.yml` (HEAD `82d5c7339`) carries
|
||||
`password: ${{ secrets.PYPI_API_TOKEN }}` at four publish steps plus a TODO
|
||||
comment marking the OIDC follow-up. The OIDC switch is therefore **not** done — it
|
||||
moves to sub-phase P1b below.
|
||||
|
||||
**Why this revert was correct (measured, not claimed):** OIDC is the better
|
||||
long-term design and matches ADR-117's original §5.5 P5 intent — but implementing
|
||||
it *before* the manual pypi.org registration exists would have shipped a workflow
|
||||
that looks migrated yet 403s on the next real publish. The fix-marker caught a
|
||||
well-intentioned improvement that wasn't the honest, currently-working state, and
|
||||
it was reverted rather than overridden. That is the same "measured not claimed"
|
||||
discipline (per [ADR-168](ADR-168-benchmark-proof.md)) this entire ADR exists to
|
||||
enforce — applied here to our own change.
|
||||
|
||||
### P1b — Switch to OIDC Trusted Publishing (gated follow-up)
|
||||
|
||||
- [ ] **(human, manual, pypi.org — BLOCKING)** Complete the §3.1 Trusted Publisher
|
||||
registration for BOTH `wifi-densepose` and `ruview` (owner=ruvnet, repo=RuView,
|
||||
workflow=pip-release.yml, environment=pypi). P1b must not start until this exists.
|
||||
- [ ] Re-apply the `cc153e8b5` change (add `id-token: write` + `environment: pypi`,
|
||||
drop the four `password:` inputs) as its own follow-up commit.
|
||||
- [ ] Update the `RuView#786-pypi-token-auth` fix-marker in `scripts/fix-markers.json`
|
||||
in the *same* commit — invert it to `require: id-token: write` / `forbid:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}` — so the guard tracks the new intended
|
||||
state instead of blocking it (referencing the TODO comment now in pip-release.yml).
|
||||
- [ ] Confirm a green OIDC publish before removing the token, per §3.2's
|
||||
keep-both-paths recommendation (OIDC first, token fallback until OIDC is proven).
|
||||
- [ ] No capability gap: publishing must keep working across the P1→P1b transition.
|
||||
|
||||
### P2 — Version promotion + changelog
|
||||
|
||||
- [ ] `python/pyproject.toml`: `version = "2.0.0"` (drop the `a1` suffix).
|
||||
- [ ] `python/pyproject.toml`: `Development Status :: 5 - Production/Stable`.
|
||||
- [ ] `CHANGELOG.md`: `[Unreleased]` entry — "wifi-densepose 2.0.0 promoted from
|
||||
alpha; ruview 2.0.0 first stable publish; pip-release migrated to Trusted Publishing".
|
||||
- [ ] Confirm the `ruview` package's own version metadata is set to `2.0.0`.
|
||||
|
||||
### P3 — Real publish + verification
|
||||
|
||||
- [ ] Cut tag `v2.0.0-pip` (per the workflow's `v*-pip` trigger) → OIDC publish of
|
||||
the `wifi-densepose` wheel matrix.
|
||||
- [ ] Publish `ruview==2.0.0` via the new `publish-ruview` job.
|
||||
- [ ] Run every command in §7 against the **real** PyPI index and capture output.
|
||||
- [ ] Generate + commit `expected_features_v2.sha256` (issue #785 §11 criterion 10),
|
||||
resolving ADR-117 §11.3 / the workflow header's Q3 note.
|
||||
|
||||
### P4 — Close issue #785
|
||||
|
||||
- [ ] All 10 acceptance criteria (§6) pass against the real index.
|
||||
- [ ] Flip ADR-117 §Status → **Accepted**.
|
||||
- [ ] Flip this ADR (ADR-184) §Status → **Accepted**.
|
||||
- [ ] Close issue #785.
|
||||
|
||||
**Why/How to apply:** the phases are strictly ordered — P3 cannot succeed until both
|
||||
P1 (working credential path) and the §3.1 human step are done, and P4 must not be
|
||||
marked complete on the strength of a commit message (the failure mode this ADR
|
||||
exists to correct). Nothing in this ledger is checked; this is a Proposed plan.
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria (verbatim from issue #785 §11)
|
||||
|
||||
A reviewer must be able to:
|
||||
|
||||
1. `pip install --pre wifi-densepose==2.0.0a1` from PyPI test index → wheel installs
|
||||
without compile step on Linux/macOS/Windows
|
||||
2. `python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"`
|
||||
→ both versions print
|
||||
3. `python -c "from wifi_densepose import CsiFrame; ..."` → core type round-trips
|
||||
through PyO3
|
||||
4. `python -c "from wifi_densepose import vitals; vitals.detect_hr(...)"` → 4-stage
|
||||
pipeline runs on a sample CSI buffer
|
||||
5. `pip install wifi-densepose[client]; python -c "import wifi_densepose.client; ..."`
|
||||
→ WS client connects to a running sensing-server
|
||||
6. `pytest python/tests/` → ≥30 tests pass (smoke + binding round-trips)
|
||||
7. `maturin build --release --strip` → wheel under 5 MB per platform (ADR §5.4 budget)
|
||||
8. `wifi-densepose==1.99.0` is the latest 1.x; `import wifi_densepose` raises
|
||||
`ImportError` with migration URL
|
||||
9. `wifi-densepose==1.0.0` is yanked from PyPI; `1.1.0` is un-yanked with deprecation
|
||||
notice (90-day window)
|
||||
10. Witness `expected_features_v2.sha256` generated in CI, committed alongside the
|
||||
existing `archive/v1/data/proof/`, re-verifiable from Python via
|
||||
`wifi_densepose.verify_witness(...)`
|
||||
|
||||
**Note (amendment to criterion 1):** issue #785 §11 was written when `2.0.0a1` was
|
||||
the target. This ADR promotes to stable `2.0.0`, so criterion 1 is read as
|
||||
`pip install wifi-densepose==2.0.0` (no `--pre`) against the production index. The
|
||||
`--pre`/`a1` wording is preserved verbatim above per the transcription requirement;
|
||||
the stable form is what P3/P4 must actually satisfy. This ADR additionally requires
|
||||
`ruview==2.0.0` to be installable (the sibling package from commit `b71d243b4`),
|
||||
which #785 §11 did not enumerate but the issue "Done" section implies.
|
||||
|
||||
---
|
||||
|
||||
## 7. How to verify (prove, don't claim)
|
||||
|
||||
Exact commands a reviewer runs to prove — not assume — each gap is closed. Every one
|
||||
produces falsifiable output; capture it in the PR that flips ADR-117 to Accepted.
|
||||
|
||||
### 7.1 Both packages live and stable
|
||||
|
||||
```bash
|
||||
# wifi-densepose 2.0.0 (stable, NOT alpha) must appear
|
||||
pip index versions wifi-densepose
|
||||
# expect: "wifi-densepose (2.0.0)" and 2.0.0 in the available list
|
||||
|
||||
# ruview 2.0.0 must now exist (currently: "No matching distribution found")
|
||||
pip index versions ruview
|
||||
# expect: "ruview (2.0.0)"
|
||||
```
|
||||
|
||||
### 7.2 Clean-venv install + import (criteria 2–4)
|
||||
|
||||
```bash
|
||||
python -m venv /tmp/verify-184 && . /tmp/verify-184/bin/activate
|
||||
pip install wifi-densepose==2.0.0 # stable, no --pre
|
||||
python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"
|
||||
python -c "from wifi_densepose import CsiFrame; print(CsiFrame([1.0]*56,[0.0]*56,56,0,100.0))"
|
||||
python -c "from wifi_densepose import vitals; print(hasattr(vitals,'detect_hr'))"
|
||||
pip install ruview==2.0.0
|
||||
python -c "import ruview; print(ruview.__version__)"
|
||||
```
|
||||
|
||||
### 7.3 Tombstone still guards the 1.x line (criterion 8)
|
||||
|
||||
```bash
|
||||
pip install wifi-densepose==1.99.0
|
||||
python -c "import wifi_densepose" 2>&1 | grep -q "github.com/ruvnet/RuView" \
|
||||
&& echo "PASS: tombstone raises with migration URL" \
|
||||
|| echo "FAIL"
|
||||
```
|
||||
|
||||
### 7.4 Workflow auth state
|
||||
|
||||
**Current state (P1, active today):** token auth is the working path and is what
|
||||
the `RuView#786-pypi-token-auth` fix-marker requires. The honest check today is
|
||||
that token auth is present and the fix-marker guard passes:
|
||||
|
||||
```bash
|
||||
# token auth present (the ACTIVE, working path — expected PASS today)
|
||||
grep -q 'password: ${{ secrets.PYPI_API_TOKEN }}' .github/workflows/pip-release.yml \
|
||||
&& echo "PASS: token auth active" || echo "FAIL"
|
||||
|
||||
# fix-marker regression guard must pass
|
||||
python scripts/check_fix_markers.py && echo "PASS: all markers pass"
|
||||
```
|
||||
|
||||
**P1b end-state (after the manual pypi.org registration):** the checks below flip
|
||||
to PASS *only once P1b lands together with the fix-marker inversion* — they are
|
||||
**not** expected to pass today and their passing now would mean a half-migrated,
|
||||
403-prone workflow:
|
||||
|
||||
```bash
|
||||
# after P1b: no static token should remain in the publish steps
|
||||
grep -nE 'password:|PYPI_API_TOKEN' .github/workflows/pip-release.yml \
|
||||
&& echo "not yet: token still present (expected during P1)" \
|
||||
|| echo "P1b done: no static token"
|
||||
|
||||
# after P1b: id-token permission granted on publish jobs
|
||||
grep -q 'id-token: write' .github/workflows/pip-release.yml \
|
||||
&& echo "P1b done: OIDC permission present" \
|
||||
|| echo "not yet: OIDC not enabled (expected during P1)"
|
||||
```
|
||||
|
||||
### 7.5 The release actually went green
|
||||
|
||||
```bash
|
||||
gh run list --workflow pip-release --limit 1
|
||||
# expect: conclusion=success on the v2.0.0-pip tag run
|
||||
```
|
||||
|
||||
**Why/How to apply:** §7.1 and §7.5 together are the minimal proof that the two
|
||||
headline gaps (no stable 2.0.0, no `ruview`, dead pipeline) are closed. If any
|
||||
command's actual output diverges from the `expect` line, the corresponding phase is
|
||||
not done — regardless of what any commit message or checkbox says.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- The Python entry point for the entire RuView ecosystem (issue #785 "Strategic
|
||||
alignment") is unblocked with a credential that cannot silently expire.
|
||||
- The claimed-vs-measured gap in commit `b71d243b4` (`ruview` never published) is
|
||||
closed with reproducible proof, upholding the project's "prove everything" posture.
|
||||
- Trusted Publishing removes a leak-able long-lived secret from CI entirely — the
|
||||
security posture ADR-117 §5.5 originally specified.
|
||||
- ADR-117 / issue #785 can finally reach a defensible Accepted/closed state instead
|
||||
of sitting open behind a one-line token failure.
|
||||
|
||||
### Negative
|
||||
|
||||
- The `pypi.org` trusted-publisher registration (§3.1) is a hard human dependency
|
||||
with no automated fallback beyond re-introducing a token (§3.2). Release day is
|
||||
blocked on a person, not a pipeline.
|
||||
- Promoting to stable `2.0.0` removes the alpha escape hatch — any binding bug now
|
||||
ships under a stable version and needs a `2.0.1`, not a new `a`-tag.
|
||||
- `test.pypi.org` needs its own trusted-publisher entry if the dry-run path is kept,
|
||||
adding a second manual registration.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The build matrix (`build-wheels`, `build-sdist`, `build-tombstone`) is untouched;
|
||||
the risk surface of this change is confined to the three publish jobs.
|
||||
- The witness-hash-v2 open question (ADR-117 §11.3, workflow header Q3) is pulled
|
||||
into scope as criterion 10 but is orthogonal to the credential migration.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- **ADR-117** — `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` (the design
|
||||
this ADR completes; §5.4/§5.5 OIDC intent, §7.2 tombstone, §11.3 witness hash)
|
||||
- **Issue #785** — https://github.com/ruvnet/RuView/issues/785 (tracking issue,
|
||||
OPEN; §11 acceptance criteria transcribed in §6)
|
||||
- **Workflow** — `.github/workflows/pip-release.yml` (four `password:` inputs at
|
||||
lines 249/258/282/291; `contents: read` only at 49–50)
|
||||
- **pyproject** — `python/pyproject.toml` (`version = "2.0.0a1"` line 13;
|
||||
`3 - Alpha` line 26)
|
||||
- **Failed run** — GitHub Actions `pip-release` run `26366735779`, job "Publish
|
||||
v1.99 tombstone" → step "Publish to PyPI" (403 + Trusted-Publishing-disabled warning)
|
||||
- **Commit `b71d243b4`** — *"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview
|
||||
2.0.0a1 to PyPI"* — the `ruview` publish it claims did not occur
|
||||
- **PyPI Trusted Publishing** — https://docs.pypi.org/trusted-publishers/ (web-UI-only
|
||||
registration; pending-publisher support for not-yet-created projects)
|
||||
- **`pypa/gh-action-pypi-publish`** — https://github.com/pypa/gh-action-pypi-publish
|
||||
(OIDC via `id-token: write`; `password:` disables Trusted Publishing)
|
||||
- **ADR-168** — `docs/adr/ADR-168-benchmark-proof.md` (measured-not-claimed house style)
|
||||
@@ -1,687 +0,0 @@
|
||||
# ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, and MAT via PyO3 extras
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — **P1–P4 implemented & tested** (commits `d060998e3`, `189ac9dfb`, `1c9727f9c`, `0f405213d`) + **leaf-crate hoists done** (`a47bb71b2`/`7ed57f041`/`99fea9df9`); **not yet Accepted** (§6.6 CI gate PARTIAL, §6.7 accuracy bars OPEN — see §13) |
|
||||
| **Date** | 2026-07-21 (impl status recorded 2026-07-21) |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **PIP-TRINITY** — three SOTA subsystems join the `wifi_densepose` wheel |
|
||||
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX — the PyO3 wheel this extends), [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER contrastive embeddings), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN domain generalization), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (WiFlow-STD ~96% PCK@20 SOTA bar) |
|
||||
| **Tracking issue** | TBD — file under RuView issue tracker |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 Where ADR-117 stopped
|
||||
|
||||
ADR-117 (PIP-PHOENIX) shipped the `wifi-densepose` v2.x PyPI wheel as a PyO3 +
|
||||
maturin compiled extension (`wifi_densepose._native`) with a pure-Python facade.
|
||||
The bound surface today (`python/src/bindings/*.rs`, `python/src/lib.rs`):
|
||||
|
||||
| Bound today | Crate | Kind |
|
||||
|---|---|---|
|
||||
| `CsiFrame`, `Keypoint`, `KeypointType`, `BoundingBox`, `PersonPose`, `PoseEstimate` | `wifi-densepose-core` | P2 core types |
|
||||
| 4-stage vitals (`BreathingExtractor`, `HeartRateExtractor`, `VitalEstimate`, `VitalReading`, `VitalStatus`) | `wifi-densepose-vitals` | P3 DSP |
|
||||
| `BfldFrame`, `BfldReport`, `BfldKind` + `PrivacyClass` gate | `wifi-densepose-bfld` | P3.5 / ADR-118 |
|
||||
| `SensingClient` (WS), `RuViewMqttClient` (MQTT), HA helpers | pure-Python `wifi_densepose.client` | P4 `[client]` extra |
|
||||
|
||||
ADR-117's own phase ledger (§6, "P6+ — Deferred") explicitly parked three
|
||||
higher-value subsystems as post-v2.0.0 work:
|
||||
|
||||
> - [ ] `wifi-densepose-nn` bindings … · `wifi-densepose-ruvector` bindings …
|
||||
> - [ ] MQTT/Matter integration helpers …
|
||||
|
||||
and ADR-117 §5.1 deferred `wifi-densepose-mat` (depends on nn) and the RuVector
|
||||
tier for wheel-size reasons. The three SOTA subsystems that a Python researcher
|
||||
most wants — re-identification embeddings, cross-environment transfer, and the
|
||||
disaster-triage tool — are precisely the ones still unreachable from
|
||||
`pip install wifi-densepose`.
|
||||
|
||||
### 1.2 The three subsystems already exist and are tested in Rust
|
||||
|
||||
None of this is new research. Each subsystem is a shipped, tested Rust module:
|
||||
|
||||
| Subsystem | ADR | Rust location (verified HEAD) | Nature |
|
||||
|---|---|---|---|
|
||||
| **AETHER** — contrastive CSI embedding / re-identification | ADR-024 | `wifi-densepose-sensing-server/src/embedding.rs` (`EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `AetherConfig`, `aether_loss`, `info_nce_loss`, `alignment_metric`, `uniformity_metric`) | Pure-sync DSP + linear algebra; 128-dim L2-normalized embeddings |
|
||||
| **MERIDIAN** — cross-environment domain generalization | ADR-027 | `wifi-densepose-train` (`domain::{DomainFactorizer, DomainClassifier, GradientReversalLayer, AdversarialSchedule}`, `geometry::{GeometryEncoder, FourierPositionalEncoding, FilmLayer, MeridianGeometryConfig}`, `rapid_adapt::{RapidAdaptation, AdaptationLoss}`, `virtual_aug::VirtualDomainAugmentor`, `eval::CrossDomainEvaluator`) + `wifi-densepose-signal::hardware_norm::{HardwareNormalizer, HardwareType, CanonicalCsiFrame}` | Inference/adaptation path is pure-Rust and **un-gated**; only `model`/`trainer`/`losses` need `tch-backend` (libtorch) |
|
||||
| **MAT** — Mass Casualty Assessment Tool | (root CLAUDE.md crate table) | `wifi-densepose-mat` (`DisasterResponse`, `DisasterConfig`, `DetectionPipeline`, `EnsembleClassifier`, `TriageCalculator`, `TriageStatus`, `Survivor`, `VitalSignsReading`) | Cargo-feature-gated (`mat`); sync ingest (`push_csi_data`) + async scan loop (`start_scanning`, tokio) |
|
||||
|
||||
### 1.3 Why now, and why gated extras
|
||||
|
||||
Two forces make P6 timely: (a) the v2.0.0 wheel is stable and its abi3-py310
|
||||
build matrix is proven, so adding modules is incremental; (b) integrators reading
|
||||
the ADR-115/ADR-117 notes are asking for Python access to re-identification and
|
||||
cross-room transfer specifically.
|
||||
|
||||
But pulling all three into the **default** wheel would break ADR-117 §5.4's
|
||||
**≤ 5 MB per-platform wheel budget** and its "no heavy system deps" invariant:
|
||||
|
||||
- MAT is already cargo-`mat`-gated upstream *because* it drags in the ML/detection
|
||||
stack; the default wheel must not carry it.
|
||||
- MERIDIAN's training path (`model`/`trainer`/`losses`) is `tch-backend`-gated and
|
||||
would pull libtorch (30 MB+), the exact wheel-size risk ADR-117 §5.1 flagged.
|
||||
|
||||
So P6 mirrors the existing `[client]` extra pattern (ADR-117 §5.6): each subsystem
|
||||
becomes an **optional pip extra**, and the compiled surface is **feature-gated in
|
||||
`wifi-densepose-py`'s `Cargo.toml`** so the default wheel stays lean.
|
||||
|
||||
### 1.4 What this ADR is *not*
|
||||
|
||||
- Not a port of the Rust subsystems to Python — the Rust workspace stays
|
||||
authoritative and unmodified, exactly as ADR-117 §1.3 established.
|
||||
- Not the `wifi-densepose-nn` / libtorch binding (still deferred; MERIDIAN binds
|
||||
only the un-gated inference/adaptation path, not `tch-backend` training).
|
||||
- Not a change to the default wheel's contents, size budget, or abi3 base.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gap analysis
|
||||
|
||||
| Capability | Rust crate(s) | pip v2.x status | Gap severity |
|
||||
|---|---|---|---|
|
||||
| Extract a 128-dim re-ID embedding from a CSI window | `sensing-server::embedding` (AETHER) | Not present | **High** |
|
||||
| Compare two CSI observations by learned similarity (same room? same person?) | AETHER `EmbeddingExtractor` + cosine | Not present | **High** |
|
||||
| Hardware-invariant CSI normalization (ESP32 / Intel 5300 / Atheros → canonical 56) | `signal::hardware_norm` (MERIDIAN) | Not present | **High** |
|
||||
| Geometry-conditioned zero-shot deployment (AP positions → FiLM) | `train::geometry` (MERIDIAN) | Not present | **Medium** |
|
||||
| 10-second unlabeled few-shot room adaptation | `train::rapid_adapt` (MERIDIAN) | Not present | **Medium** |
|
||||
| Cross-domain evaluation protocol (in/cross/few-shot MPJPE) | `train::eval` (MERIDIAN) | Not present | **Medium** |
|
||||
| Disaster-survivor detection + START triage from CSI | `wifi-densepose-mat` | Not present | **Medium** (specialist audience) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Decision
|
||||
|
||||
Adopt **three new optional pip extras**, each binding one SOTA subsystem into the
|
||||
existing `wifi_densepose` wheel as a dedicated Python submodule, gated behind a
|
||||
matching Cargo feature so the default wheel is unchanged:
|
||||
|
||||
```
|
||||
pip install wifi-densepose # unchanged: core + vitals + bfld (≤5 MB)
|
||||
pip install wifi-densepose[aether] # + wifi_densepose.aether
|
||||
pip install wifi-densepose[meridian] # + wifi_densepose.meridian
|
||||
pip install wifi-densepose[mat] # + wifi_densepose.mat (mirrors upstream `mat` cargo feature)
|
||||
pip install wifi-densepose[sota] # convenience: aether + meridian + mat
|
||||
```
|
||||
|
||||
This path is called **PIP-TRINITY**. It reuses ADR-117's established idiom
|
||||
end-to-end: `#[pyclass]` newtype wrappers holding an `inner` Rust value, `#[new]`
|
||||
constructors, `#[getter]` accessors, `__repr__`, a per-module `register(m)` fn,
|
||||
and — critically — **GIL release via `py.allow_threads(|| …)` on every
|
||||
compute-heavy call**, exactly as `bindings/vitals.rs:229` and `:293` already do.
|
||||
|
||||
### 3.1 Feature gating in `wifi-densepose-py`
|
||||
|
||||
New Cargo features and optional path-deps in `python/Cargo.toml`; each binding
|
||||
module is `#[cfg(feature = "…")]`-compiled and conditionally `register()`ed in
|
||||
`src/lib.rs`, so a default build links none of the three:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
aether = ["dep:wifi-densepose-sensing-server"]
|
||||
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
|
||||
mat = ["dep:wifi-densepose-mat"] # upstream `mat` feature flows through
|
||||
sota = ["aether", "meridian", "mat"]
|
||||
|
||||
[dependencies]
|
||||
wifi-densepose-sensing-server = { version = "0.3.0", path = "../v2/crates/wifi-densepose-sensing-server", optional = true, default-features = false }
|
||||
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false } # NO tch-backend
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true }
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false }
|
||||
```
|
||||
|
||||
`[project.optional-dependencies]` in `pyproject.toml` gains `aether`, `meridian`,
|
||||
`mat`, and `sota` keys mirroring the existing `client`/`dev` extras. Because each
|
||||
extra changes the compiled surface, extras map to **cibuildwheel feature-flag
|
||||
builds**, not pure-Python markers — the publish workflow (ADR-117 §5.4) gains a
|
||||
build axis for the `[sota]` wheel variant.
|
||||
|
||||
### 3.2 Binding surface — AETHER (`wifi_densepose.aether`)
|
||||
|
||||
Backing crate: `wifi-densepose-sensing-server::embedding` (ADR-024 §2.6). The
|
||||
crate is Axum/tokio-based, so we depend on it `default-features = false` and bind
|
||||
**only the sync `embedding` types** — never the server/runtime. If the embedding
|
||||
module cannot be reached without a tokio dependency (Open Question §11.1), the
|
||||
fallback is to hoist `embedding.rs` into a leaf crate; that is a Rust-side
|
||||
refactor, not a Python API change.
|
||||
|
||||
| Python symbol | Wraps | Signature (Python) |
|
||||
|---|---|---|
|
||||
| `AetherConfig` | `AetherConfig` | `AetherConfig(d_model=64, d_proj=128, temperature=0.07, vicreg_alpha=1.0, vicreg_beta=25.0, vicreg_gamma=1.0)` — frozen, `__repr__` |
|
||||
| `CsiAugmenter` | `CsiAugmenter` | `CsiAugmenter(seed)`; `.augment(window: list[list[float]]) -> list[list[float]]` |
|
||||
| `EmbeddingExtractor` | `EmbeddingExtractor` | `.embed(csi_features: list[list[float]]) -> list[float]` (128-dim, L2-normed); `.forward_dual(...) -> tuple[PoseEstimate, list[float]]` |
|
||||
| `aether_loss(...)` | `aether_loss` | returns `AetherLossComponents(total, info_nce, variance, covariance)` — frozen dataclass-like |
|
||||
| `cosine_similarity(a, b)` | thin helper | `float`; convenience for re-ID scoring (not a re-impl — calls the same dot product) |
|
||||
| `alignment_metric`, `uniformity_metric` | same | `float` |
|
||||
|
||||
GIL strategy: `embed`, `forward_dual`, `augment`, and `aether_loss` wrap their
|
||||
Rust call in `py.allow_threads(|| …)` — these are pure-sync matrix ops that touch
|
||||
no Python objects, matching the vitals precedent. A single-frame `embed()` is
|
||||
sub-millisecond (ADR-024 §2.8 target <1 ms FP32), but batch/augment calls exceed
|
||||
the 0.5 ms GIL-release threshold ADR-117 §P3 set.
|
||||
|
||||
`.pyi` stubs: add `wifi_densepose/aether.pyi` declaring the five classes/functions
|
||||
with precise numeric types; extend the top-level `wifi_densepose/__init__.pyi`
|
||||
with a `TYPE_CHECKING`-guarded re-export so `mypy --strict` sees them only when
|
||||
the extra is installed.
|
||||
|
||||
### 3.3 Binding surface — MERIDIAN (`wifi_densepose.meridian`)
|
||||
|
||||
Backing crates: `wifi-densepose-train` (inference/adaptation path, **no
|
||||
`tch-backend`**) + `wifi-densepose-signal::hardware_norm`. The `model`/`trainer`/
|
||||
`losses` modules are libtorch-gated and are **out of scope** — Python gets the
|
||||
domain-generalization *inference and calibration* surface, not the training loop.
|
||||
|
||||
| Python symbol | Wraps | Signature (Python) |
|
||||
|---|---|---|
|
||||
| `HardwareType` | `HardwareType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Esp32S3 / Intel5300 / Atheros / Generic`; `HardwareType.detect(subcarrier_count) -> HardwareType` |
|
||||
| `HardwareNormalizer` | `HardwareNormalizer` | `.normalize(frame: CsiFrame, hw: HardwareType) -> CanonicalCsiFrame` |
|
||||
| `CanonicalCsiFrame` | `CanonicalCsiFrame` | frozen; `.amplitudes`, `.phases`, `.hardware_type` getters |
|
||||
| `GeometryEncoder` | `GeometryEncoder` | `GeometryEncoder(MeridianGeometryConfig)`; `.encode(ap_positions: list[tuple[float,float,float]]) -> list[float]` (64-dim, permutation-invariant) |
|
||||
| `MeridianGeometryConfig` | `MeridianGeometryConfig` | frozen config |
|
||||
| `RapidAdaptation` | `RapidAdaptation` | `.calibrate(csi_windows: list[list[list[float]]]) -> AdaptationResult` (10-sec unlabeled few-shot) |
|
||||
| `AdaptationResult` | `AdaptationResult` | frozen result: `.frames_used`, `.converged`, `.loss` |
|
||||
| `CrossDomainEvaluator` | `CrossDomainEvaluator` | `.evaluate(...) -> dict[str, float]` (in/cross/few-shot MPJPE, domain-gap ratio) |
|
||||
|
||||
GIL strategy: `normalize`, `encode`, `calibrate`, and `evaluate` are wrapped in
|
||||
`py.allow_threads`. `normalize` targets <50 µs/frame (ADR-027 §4.1) and `encode`
|
||||
<100 µs (§4.3), but `calibrate` runs contrastive test-time training over 200
|
||||
frames and is the primary GIL-release beneficiary.
|
||||
|
||||
`.pyi` stubs: `wifi_densepose/meridian.pyi`. `DomainFactorizer` /
|
||||
`GradientReversalLayer` / `VirtualDomainAugmentor` are **training-time only** and
|
||||
are *not* bound in P6 (they need the tch training loop) — Open Question §11.2
|
||||
records this boundary.
|
||||
|
||||
### 3.4 Binding surface — MAT (`wifi_densepose.mat`)
|
||||
|
||||
Backing crate: `wifi-densepose-mat`, bound behind the `[mat]` extra so the
|
||||
disaster/ML stack never enters the default wheel — mirroring the upstream `mat`
|
||||
cargo feature exactly. `DisasterResponse::start_scanning` is async (tokio); rather
|
||||
than bind an event loop, P6 binds the **sync ingest + query surface** and a
|
||||
single-shot `scan_once()` helper (a sync wrapper over one `scan_cycle`, added
|
||||
Rust-side if needed — see §11.3).
|
||||
|
||||
| Python symbol | Wraps | Signature (Python) |
|
||||
|---|---|---|
|
||||
| `DisasterType` | `DisasterType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Earthquake / BuildingCollapse / Avalanche / Flood / Mine / Unknown` |
|
||||
| `TriageStatus` | `TriageStatus` | frozen enum (START protocol classes) |
|
||||
| `DisasterConfig` | `DisasterConfig` | builder-style kwargs: `DisasterConfig(disaster_type, sensitivity=0.8, confidence_threshold=0.5, max_depth=5.0)` |
|
||||
| `DisasterResponse` | `DisasterResponse` | `.push_csi_data(amplitudes, phases)`; `.scan_once()`; `.survivors() -> list[Survivor]`; `.survivors_by_triage(status) -> list[Survivor]` |
|
||||
| `Survivor` | `Survivor` | frozen: `.id`, `.triage_status`, `.location`, `.vital_signs` getters |
|
||||
| `VitalSignsReading` | `VitalSignsReading` | frozen: breathing / heartbeat / movement fields |
|
||||
|
||||
GIL strategy: `push_csi_data` and `scan_once` wrap the detection-pipeline call in
|
||||
`py.allow_threads` — the ensemble classifier + localization are the compute-heavy
|
||||
part and touch no Python state.
|
||||
|
||||
`.pyi` stubs: `wifi_densepose/mat.pyi`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Benchmarking & the measured-vs-claimed parity requirement
|
||||
|
||||
A binding that "runs without crashing" is worthless if it silently regresses
|
||||
accuracy versus the native Rust call. The point of P6 is to prove the Python
|
||||
surface reproduces the Rust subsystem **bit-for-bit**, then to hold each binding
|
||||
to the *same* published SOTA bar its ADR already claims.
|
||||
|
||||
### 4.1 Parity harness (bit-for-bit, mandatory)
|
||||
|
||||
Each subsystem ships a golden-vector parity test. A committed input fixture is
|
||||
run through **both** a tiny native-Rust reference binary (in
|
||||
`v2/crates/wifi-densepose-py/tests/golden/`) and the Python binding; the two
|
||||
outputs must hash-match under SHA-256 (the ADR-028 / ADR-117 §5.7 witness scheme):
|
||||
|
||||
- `aether`: identical 128-dim embedding bytes for a fixed CSI window + fixed seed.
|
||||
- `meridian`: identical `CanonicalCsiFrame` bytes for a fixed ESP32 (64-sub) and
|
||||
Intel-5300 (30-sub) frame; identical 64-dim geometry vector for fixed AP set.
|
||||
- `mat`: identical triage classification + survivor count for a fixed CSI stream.
|
||||
|
||||
A mismatch is a **release blocker**, not a warning. This is the "MEASURED, not
|
||||
CLAIMED" gate the project holds itself to.
|
||||
|
||||
**Scope, stated honestly:** parity proves the **strongest claim available today** —
|
||||
the Python binding is bit-identical to native Rust for the bound surface. It is
|
||||
**not** accuracy validation. The bound AETHER surface moreover ships *untrained*
|
||||
(random-init weights; a `load_weights` API exists since `65da488ad` but no trained
|
||||
checkpoint exists to load — §6.7.2, §13.c.a), so byte-equality here says nothing
|
||||
about the SOTA accuracy bars in §4.3; those remain OPEN (§6.7, §13.c).
|
||||
|
||||
### 4.2 pytest-benchmark micro-benchmarks
|
||||
|
||||
Following the existing `python/bench/test_bench_vitals.py` pattern (skipped by
|
||||
default via `addopts`; run with `pytest python/bench/ --benchmark-only`):
|
||||
|
||||
- `python/bench/test_bench_aether.py` — steady-state `embed()` per-window cost;
|
||||
assert < 2 ms (ADR-024 §2.8 FP32 target < 1 ms with headroom) and that batched
|
||||
`embed()` scales linearly (no accidental O(n²)).
|
||||
- `python/bench/test_bench_meridian.py` — `normalize()` < 200 µs/frame,
|
||||
`encode()` < 200 µs (ADR-027 §4.1/§4.3 targets ×2 headroom).
|
||||
- `python/bench/test_bench_mat.py` — `scan_once()` per-cycle cost bounded by the
|
||||
configured scan interval.
|
||||
|
||||
### 4.3 SOTA accuracy bar the binding must reproduce (not merely run)
|
||||
|
||||
The parity harness (§4.1) guarantees the Python path is byte-identical to Rust, so
|
||||
these published numbers are the bar the *binding output* is validated against on a
|
||||
committed labeled fixture — a regression in any is a binding bug:
|
||||
|
||||
| Metric | Bar | Source |
|
||||
|---|---|---|
|
||||
| WiFlow-STD pose accuracy | **~96% PCK@20** (MEASURED-EQUIVALENT) | ADR-152 §2.2 |
|
||||
| Room identification (k-NN on `env_fingerprint`) | **> 95%** | ADR-024 §2.8 |
|
||||
| Person re-ID mAP | **> 80%** (WhoFi bar 95.5% on NTU-Fi) | ADR-024 §2.8, §1.5 |
|
||||
| Anomaly detection F1 | **> 0.90** | ADR-024 §2.8 |
|
||||
| INT8 rank correlation vs FP32 (Spearman) | **> 0.95** | ADR-024 §2.8 |
|
||||
| Cross-domain MPJPE improvement | **> 20%** vs non-adversarial | ADR-027 §4.2 |
|
||||
| Domain-gap ratio (cross/in-domain) | **< 1.5** | ADR-027 §4.6 |
|
||||
| Few-shot MPJPE after 10-sec calibration | within **15%** of in-domain | ADR-027 §4.5 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase ledger
|
||||
|
||||
```
|
||||
P1 ──► P2 ──► P3 ──► P4
|
||||
aether meridian mat docs +
|
||||
bindings bindings behind examples
|
||||
extra
|
||||
```
|
||||
|
||||
> **Implementation note (2026-07-21):** P1–P4 were built against the **real Rust
|
||||
> code at HEAD**, not this ADR's proposed surface. Where §3's proposed API named
|
||||
> functions/fields that do not exist in the crates (e.g. `aether_loss`/VICReg
|
||||
> components/`alignment_metric`/`forward_dual`, `RapidAdaptation.calibrate`,
|
||||
> `AdaptationResult.converged`), the coder **did not fabricate them** — the real
|
||||
> API was bound and the deviation documented in each module header and commit body.
|
||||
> Treat §3 as the original proposal and the commit messages as the authoritative
|
||||
> record of what shipped.
|
||||
|
||||
### P1 — AETHER bindings (`[aether]` extra) — **DONE** (`d060998e3`; leaf-crate hoist `a47bb71b2`)
|
||||
|
||||
- [x] `aether` Cargo feature + gated optional `wifi-densepose-sensing-server` dep;
|
||||
default build links **0** sensing-server refs (base wheel stays lean).
|
||||
- [x] `python/src/bindings/aether.rs` — `AetherConfig` (→ real `EmbeddingConfig`),
|
||||
`CsiAugmenter.augment_pair`, `EmbeddingExtractor.embed` (128-dim L2-normed,
|
||||
GIL-released), `info_nce_loss`, `cosine_similarity`. **Not bound** (absent in
|
||||
`embedding.rs` at HEAD, a Rust-side gap, not fabricated): `aether_loss`/VICReg
|
||||
components, `alignment_metric`, `uniformity_metric`, `forward_dual`, `vicreg_*`.
|
||||
- [x] `#[cfg(feature = "aether")]` gate + facade + `aether.pyi` + `[aether]` extra.
|
||||
- [x] `python/tests/golden/aether_embedding.sha256` parity fixture:
|
||||
`tests/aether_parity.rs` locks the native reference; `tests/test_aether.py`
|
||||
asserts identical SHA-256 of the LE-f32 bytes.
|
||||
- [x] **Verified:** `cargo test --features aether --test aether_parity` → 2/2;
|
||||
`pytest tests/test_aether.py` → 9/9.
|
||||
- [x] **Leaf-crate hoist (`a47bb71b2`):** `embedding.rs` moved into a new
|
||||
`wifi-densepose-aether` crate. Measured stripped wheel **~361 KB → ~312 KB** (was
|
||||
already ~14× under the 5 MB budget — see §13.a; the hoist's value is build-time
|
||||
71 s → 12 s + dep-graph hygiene, not size). No regression: `aether_parity` 2/2,
|
||||
`pytest` 9/9, sensing-server 217+388 tests 0 failed, new `wifi-densepose-aether`
|
||||
crate 96 passed.
|
||||
|
||||
### P2 — MERIDIAN bindings (`[meridian]` extra) — **DONE** (`189ac9dfb`)
|
||||
|
||||
- [x] `meridian` feature + gated optional `wifi-densepose-train` (**no `tch-backend`
|
||||
— libtorch avoided, confirmed**) + `wifi-densepose-signal` deps.
|
||||
- [x] `python/src/bindings/meridian.rs` — `HardwareType`/`HardwareNormalizer`/
|
||||
`CanonicalCsiFrame` (real API: `normalize(amplitude, phase, hw)` over f64 →
|
||||
`Result`; singular `amplitude`/`phase` fields), `MeridianGeometryConfig`/
|
||||
`GeometryEncoder` (64-dim, permutation-invariant), `RapidAdaptation`
|
||||
(**real API: `push_frame` + `adapt()`**, not the ADR's `calibrate`) →
|
||||
`AdaptationResult` (`lora_weights`/`final_loss`/`frames_used`/
|
||||
`adaptation_epochs`; **no `converged`**), `CrossDomainEvaluator` + `mpjpe`. All
|
||||
compute paths GIL-released. Training-time types (`DomainFactorizer`, GRL,
|
||||
`VirtualDomainAugmentor`) correctly left out of P6 scope.
|
||||
- [x] Gate + facade + `meridian.pyi` + `[meridian]` extra; default dep graph has 0
|
||||
train/signal/sensing-server refs.
|
||||
- [x] `tests/golden/meridian_output.sha256` parity fixture (esp32 + intel canonical
|
||||
frames + 64-dim geometry vector + rapid-adapt LoRA weights).
|
||||
- [x] **Verified:** `cargo test --features meridian --test meridian_parity` → 2/2;
|
||||
`pytest tests/test_meridian.py` → 13/13.
|
||||
|
||||
### P3 — MAT bindings behind `[mat]` extra — **DONE** (`1c9727f9c`)
|
||||
|
||||
- [x] `mat` feature + gated optional `wifi-densepose-mat` dep. **§11.3 resolved: no
|
||||
Rust change needed** — the public async `start_scanning()` already runs exactly
|
||||
one `scan_cycle` when `continuous_monitoring == false`; the binding forces that
|
||||
flag off and drives one cycle on a private current-thread tokio runtime.
|
||||
- [x] `python/src/bindings/mat.rs` — `DisasterType` (**9 variants at HEAD**, not the
|
||||
6 the ADR listed), `TriageStatus` (5, START), `DisasterConfig`,
|
||||
`DisasterResponse` (`initialize_event`/`add_zone`/`push_csi_data`/`scan_once`/
|
||||
`survivors`/`survivors_by_triage` — `initialize_event`+`add_zone` are **required
|
||||
additions** the ADR surface omitted), `Survivor` (`latest_vitals`, since real
|
||||
`vital_signs` is a history), `VitalSignsReading`, `ScanZone.rectangle`/`.circle`.
|
||||
`push_csi_data`+`scan_once` GIL-released.
|
||||
- [x] Gate + facade + `mat.pyi` + `[mat]` **and** `[sota]` (superset) extras.
|
||||
- [x] `tests/golden/mat_result.sha256` parity fixture over a canonical
|
||||
`count=<K>;triage_priorities=<sorted>` string (UUIDs/timestamps excluded as
|
||||
non-deterministic). **Honest scope: proves binding==native path, NOT live
|
||||
detection accuracy** — the synthetic stream yields 1 survivor, triage Delayed.
|
||||
- [x] **Verified:** `cargo test --features mat --test mat_parity` → 2/2;
|
||||
`pytest tests/test_mat.py` → 7/7.
|
||||
|
||||
### P4 — Docs, examples, and benchmark suite — **DONE** (`0f405213d`)
|
||||
|
||||
- [x] `python/bench/test_bench_{aether,meridian,mat}.py` (pytest-benchmark, §4.2).
|
||||
Measured on a `--release --features sota` wheel: AETHER `embed()` ~150 µs
|
||||
(target <2 ms), batch 1/8/64 = 140/1091/8509 µs (linear); MERIDIAN `normalize()`
|
||||
~2.2 µs (target <200 µs), `encode()` ~6.9 µs; MAT ingest+`scan_once()` ~40 ms /
|
||||
256-frame (< 500 ms). All pass.
|
||||
- [x] `python/examples/{reid_from_csi,cross_room_calibrate,mat_triage}.py` — typed,
|
||||
runnable, `mypy --strict` clean; README SOTA extras table.
|
||||
- [~] Parity harness wiring into CI as a **release-blocking gate** — golden gates
|
||||
are green locally (`cargo test --features sota` → 6/6; 3/3 SHA gates), but the CI
|
||||
**wiring** is not done (§6.6 PARTIAL — see §13.b).
|
||||
- [ ] Update ADR-117 §6 "P6+ Deferred" to point at this ADR — still open.
|
||||
|
||||
### P5 — New required follow-ups (blocking Accepted)
|
||||
|
||||
See §13. In short: (a) three leaf-crate hoists — **DONE** (`a47bb71b2`/`7ed57f041`/
|
||||
`99fea9df9`; only MAT was a real budget fix, AETHER was a false alarm), (b) wire the
|
||||
parity harness into CI as an actual release gate — **still open**, (c) source/generate
|
||||
labeled fixtures to validate the SOTA accuracy bars (§4.3) for real — **still open**.
|
||||
|
||||
### P6+ — Deferred (unchanged from ADR-117)
|
||||
|
||||
- [ ] `wifi-densepose-nn` / libtorch bindings (MERIDIAN training loop,
|
||||
`DomainFactorizer`, GRL) — still blocked on the libtorch wheel-size question.
|
||||
- [ ] `wifi-densepose-ruvector` RuVector attention bindings.
|
||||
- [ ] Matter integration helpers.
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria
|
||||
|
||||
Status recorded from the P4 self-verification run (`0f405213d`), reference machine
|
||||
per ADR-117 §10. **7 of 9 met; 2 remain** — the ADR is therefore **not** Accepted.
|
||||
|
||||
- [x] **§6.1** `pip install wifi-densepose` (no extras) → default wheel **279 KB**
|
||||
(≤ 5 MB); `build_features()` carries no `p6-*` feature — base wheel byte-for-byte
|
||||
unaffected by P6. **PASS**
|
||||
- [x] **§6.2** `pytest python/tests/test_aether.py -q` — **9/9**, incl. a real
|
||||
128-dim `embed()` round-trip asserting L2-norm ≈ 1.0 and byte-identity to the
|
||||
golden Rust reference. **PASS**
|
||||
- [x] **§6.3** `pytest python/tests/test_meridian.py -q` — **13/13**, incl.
|
||||
ESP32 (64-sub) **and** Intel-5300 (30-sub) canonicalization hash-matching native
|
||||
Rust. **PASS**
|
||||
- [x] **§6.4** `pytest python/tests/test_mat.py -q` — **7/7**, incl. a fixed CSI
|
||||
stream whose triage classification matches native `DisasterResponse` exactly.
|
||||
**PASS**
|
||||
- [x] **§6.5** `pytest python/bench/ --benchmark-only` — all targets met (AETHER
|
||||
`embed()` ~150 µs < 2 ms; MERIDIAN `normalize()` ~2.2 µs, `encode()` ~6.9 µs
|
||||
< 200 µs; MAT `scan_once()` ~40 ms < 500 ms). **PASS**
|
||||
- [~] **§6.6** Parity harness (§4.1): all three golden-vector SHA-256 gates green
|
||||
(`cargo test --features sota` → 6/6). **But CI wiring** as a release-blocking
|
||||
gate is **not done** (out of `python/` scope). **PARTIAL — see §13.b.**
|
||||
- [ ] **§6.7** SOTA-bar reproduction (§4.3): **definitively OPEN** — cannot be
|
||||
closed transitively via the parity harness. Investigated; three concrete reasons:
|
||||
1. **The native SOTA numbers aren't reproduced by any committed, runnable-today
|
||||
test.** ADR-152 ~96% PCK@20 is a frozen result in
|
||||
`benchmarks/wiflow-std/results/eval_retrained.json` that points at an **external
|
||||
checkpoint** (`/home/ruvultra/wiflow-std-bench/upstream/test/best_pose_model.pth`,
|
||||
not in the repo); the only relevant test `test_wiflow_std_parity.rs` is
|
||||
`#![cfg(feature = "tch-backend")]` **and** `#[ignore]`d (needs gitignored
|
||||
fixtures + LibTorch). ADR-027's `eval.rs::CrossDomainEvaluator` tests are pure
|
||||
unit-math on hand-coded 2–3-element vectors, not dataset accuracy. ADR-024's
|
||||
only accuracy-ish test asserts Spearman > 0.90 on **synthetic random**
|
||||
embeddings (not real CSI, not the published > 0.95 bar); no room-ID / mAP /
|
||||
anomaly-F1 test exists at all.
|
||||
2. **The bound AETHER surface ships untrained.** `EmbeddingExtractor`/
|
||||
`ProjectionHead` default to random Xavier init (`Linear::with_seed(…,
|
||||
2024/2025)`, `embedding.rs:97–98`). A weight-loading API now **does** exist
|
||||
(`load_weights`/`save_weights`, `65da488ad` — see §13.c.a), so the earlier
|
||||
"no loading path" blocker is removed; but **no trained checkpoint exists** to
|
||||
load, so the binding still produces untrained embeddings and cannot validate
|
||||
mAP > 80% or any trained-model bar today.
|
||||
3. **No committed labeled CSI input/pose-pair data exists** to reuse (MM-Fi/NTU-Fi
|
||||
appear only as config-default subcarrier counts / external paths;
|
||||
`benchmarks/wiflow-std/results/*.npy` are corruption masks + result summaries,
|
||||
not labeled fixtures).
|
||||
The §4.1 parity harness proves the **strongest claim available today** — the
|
||||
Python binding is bit-identical to native Rust for the bound (untrained) surface.
|
||||
That is **not** accuracy validation. **See §13.c.**
|
||||
- [x] **§6.8** `.pyi` stubs present for all three modules; `mypy --strict` passes on
|
||||
the three examples. **PASS**
|
||||
- [x] **§6.9** `python -c "import wifi_densepose.aether"` (etc.) on the base wheel
|
||||
raises a clear `ImportError` naming the missing extra. **PASS**
|
||||
|
||||
No regression: 76 pre-existing tests pass on the default wheel. The two unmet
|
||||
criteria (§6.6 CI wiring, §6.7 accuracy) plus the wheel-size hoists (§13.a) are the
|
||||
gate to Accepted.
|
||||
|
||||
---
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
### 7.1 Positive
|
||||
|
||||
- **Closes the ADR-117 P6 gap**: the three most-requested SOTA subsystems become
|
||||
scriptable from Python without touching the Rust workspace.
|
||||
- **Default wheel stays lean**: feature-gated extras preserve ADR-117 §5.4's ≤ 5 MB
|
||||
budget and "no heavy system deps" invariant; MAT's ML stack and MERIDIAN's
|
||||
libtorch path never enter the base wheel.
|
||||
- **Reuses the proven idiom**: no new binding machinery — same `#[pyclass]` +
|
||||
`py.allow_threads` + `register()` pattern already shipping in `bindings/vitals.rs`.
|
||||
- **Prove-everything alignment**: the parity harness makes "the Python binding
|
||||
equals the Rust core" a *measured, hash-verified* claim, not an assertion —
|
||||
matching the project's MEASURED-vs-CLAIMED discipline.
|
||||
- **Upstream consistency**: `[mat]` pip extra mirrors the `mat` cargo feature, so
|
||||
the Python packaging story matches the Rust one exactly.
|
||||
|
||||
### 7.2 Negative
|
||||
|
||||
- **cibuildwheel matrix grows**: `[sota]` is a distinct compiled variant, adding a
|
||||
build axis (and CI time) beyond ADR-117's 5-wheel abi3 matrix.
|
||||
- **AETHER's backing crate is server-shaped**: depending on
|
||||
`wifi-densepose-sensing-server` (Axum/tokio) risks pulling a runtime into an
|
||||
extension module; may force a Rust-side refactor to hoist `embedding.rs` into a
|
||||
leaf crate (§11.1).
|
||||
- **MERIDIAN surface is partial**: training-time types (`DomainFactorizer`, GRL,
|
||||
`VirtualDomainAugmentor`) stay unbound until the deferred libtorch tier, so the
|
||||
Python API is inference/adaptation-only — potential user confusion (mitigated by
|
||||
docs + `.pyi` omissions).
|
||||
- **Golden fixtures are maintenance surface**: any intentional numeric change in a
|
||||
Rust subsystem requires regenerating and re-witnessing its golden vector.
|
||||
|
||||
### 7.3 Neutral
|
||||
|
||||
- The `[sota]` convenience extra is purely additive; users who want one subsystem
|
||||
install one extra.
|
||||
- No change to the v2.0.0 semver line; extras ship additively as v2.x.y.
|
||||
|
||||
---
|
||||
|
||||
## 8. Alternatives considered
|
||||
|
||||
### Alt-A: Fold all three into the default wheel
|
||||
|
||||
Rejected — breaks ADR-117 §5.4's ≤ 5 MB budget, drags MAT's ML stack and (via
|
||||
MERIDIAN training) libtorch into every install, and contradicts the upstream
|
||||
`mat` cargo-feature gating.
|
||||
|
||||
### Alt-B: Separate PyPI packages (`wifi-densepose-aether`, etc.)
|
||||
|
||||
Rejected for the SOTA trio — three packages fragment the import namespace and
|
||||
duplicate the abi3/cibuildwheel setup. (This remains the right call for the
|
||||
libtorch `nn` tier per ADR-117 Open Q §11.2, which is genuinely heavy.) Extras of
|
||||
one wheel keep `wifi_densepose.*` coherent.
|
||||
|
||||
### Alt-C: Pure-Python reimplementation of the three subsystems
|
||||
|
||||
Rejected explicitly — this is the exact drift ADR-117 §8 Alt-C was created to
|
||||
exit. A Python reimplementation would immediately begin diverging from the Rust
|
||||
SOTA and could not pass the §4.1 bit-for-bit parity gate.
|
||||
|
||||
### Alt-D: REST/WS client to a running sensing-server for AETHER
|
||||
|
||||
Rejected as the primary path — provides zero offline embedding utility and cannot
|
||||
host the parity harness over local Rust code (same reasoning as ADR-117 §8 Alt-B).
|
||||
The pure-Python client layer (`[client]`) remains available for streaming.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `wifi-densepose-sensing-server` pulls tokio into the extension module | ~~High~~ **Not realized** | ~~High~~ **Low** | **Measured, not realized:** the stripped `[aether]` wheel was **~361 KB** (14× under budget) even before the hoist — linker DCE (`--gc-sections`) strips the server's unreached Axum/tokio/worldgraph code because the binding reaches only pure-compute symbols. Hoist (`a47bb71b2`) still done for build-time / dep-graph hygiene, not budget. See §11.1, §13.a |
|
||||
| MERIDIAN accidentally links `tch-backend` (libtorch) via a default feature | Medium | High | Explicit `default-features = false` on `wifi-densepose-train`; CI `auditwheel`/`ldd` check that no libtorch symbol is present in the `[meridian]` wheel |
|
||||
| `[sota]` build axis blows up cibuildwheel time | Medium | Medium | Build `[sota]` variant only on tagged releases, not every PR |
|
||||
| Golden vectors drift when a Rust subsystem changes intentionally | Medium | Low | Documented regeneration step + ADR-028 witness re-sign; parity mismatch is a loud release blocker, never silent |
|
||||
| MAT async-only surface has no clean sync entry point | Medium | Medium | Add sync `scan_once()` wrapper Rust-side (§11.3) before binding |
|
||||
| Users install base wheel and expect `wifi_densepose.aether` | Low | Low | Clear `ImportError` naming the missing extra (acceptance criterion §6) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Compatibility
|
||||
|
||||
- No change to the default wheel, its abi3-py310 base, or its size budget.
|
||||
- Extras ship additively on the existing v2.x line; no semver break.
|
||||
- `[mat]` pip extra ↔ `mat` cargo feature parity is preserved by construction.
|
||||
- `.pyi` stubs are gated so `mypy --strict` only sees a subsystem when its extra
|
||||
is installed.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **AETHER crate shape** — **RESOLVED (`a47bb71b2`).** The original worry that
|
||||
linking `wifi-densepose-sensing-server` would bloat the wheel was **never
|
||||
measured** — it reasoned from the dependency tree (server has non-optional
|
||||
tokio/Axum ⇒ wheel must be huge). The stripped-release measurement disproves it:
|
||||
`[aether]` was **369,782 B (~361 KB)** *before* the hoist — already ~14× under
|
||||
the 5 MB budget — and **319,719 B (~312 KB)** after. Linker dead-code elimination
|
||||
(`--gc-sections` on the pyo3 cdylib) already strips the server's unreached
|
||||
Axum/tokio/worldgraph/ruvector paths because the binding reaches only
|
||||
pure-compute symbols. The hoist into `wifi-densepose-aether` was still done — its
|
||||
real payoff is **build-time** (`[aether]` alone 71 s → 12 s), **dep-graph
|
||||
hygiene** (`python/Cargo.lock` −1238 lines), and **removing latent risk** (a
|
||||
future change that makes server code reachable would then genuinely bloat the
|
||||
wheel). **Convention note:** measure the stripped release wheel size before
|
||||
assuming a dependency-tree risk requires a hoist — linker DCE handles pure-Rust
|
||||
unreached code, but native/FFI-bundled deps (e.g. `ort`/ONNX Runtime, see §13.a
|
||||
MAT) are *not* stripped and are the real size-risk category.
|
||||
|
||||
2. **MERIDIAN training-time types**: `DomainFactorizer`, `GradientReversalLayer`,
|
||||
and `VirtualDomainAugmentor` are meaningful only with the tch training loop.
|
||||
Confirm they stay unbound in P6 and move with the deferred libtorch tier.
|
||||
*Tentative: yes — P6 is inference/adaptation only.*
|
||||
|
||||
3. **MAT sync entry point**: `DisasterResponse::start_scanning` is an async tokio
|
||||
loop. Does a sync single-cycle `scan_once()` already exist, or must it be added
|
||||
Rust-side? *Tentative: add a thin sync `scan_once()` wrapping one `scan_cycle`;
|
||||
do not bind an event loop into the extension.*
|
||||
|
||||
4. **`[sota]` wheel vs per-extra wheels**: cibuildwheel builds one binary per
|
||||
feature-set. Do we publish one `[sota]` wheel and let pip select, or per-extra
|
||||
wheels? This affects the number of build variants. *Tentative: single `[sota]`
|
||||
superset wheel on tagged releases; base wheel stays feature-free.*
|
||||
|
||||
5. **INT8 embedding path in Python**: ADR-024 §2.8 sets an INT8 rank-correlation
|
||||
bar. Do we expose the INT8 quantized `embed()` in P6, or FP32 only first?
|
||||
*Tentative: FP32 in P6; INT8 follows once the Rust quantized path is stable.*
|
||||
|
||||
---
|
||||
|
||||
## 12. References
|
||||
|
||||
### Internal ADRs
|
||||
- **ADR-117**: pip modernization via PyO3 + maturin — the wheel this ADR extends;
|
||||
§5.1/§5.4/§5.6 (extras + wheel budget), §6 "P6+ Deferred".
|
||||
- **ADR-024**: Project AETHER — contrastive CSI embedding; §2.6 module surface,
|
||||
§2.8 performance/accuracy targets.
|
||||
- **ADR-027**: Project MERIDIAN — cross-environment domain generalization; §4
|
||||
phase acceptance criteria, §4.6 evaluation protocol.
|
||||
- **ADR-152**: WiFi-Pose SOTA 2026 — WiFlow-STD ~96% PCK@20 MEASURED-EQUIVALENT bar.
|
||||
- **ADR-028**: ESP32 capability audit / witness scheme — the SHA-256 parity gate
|
||||
the §4.1 golden harness reuses.
|
||||
|
||||
### Rust source (verified HEAD)
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` — AETHER.
|
||||
- `v2/crates/wifi-densepose-train/src/{domain,geometry,rapid_adapt,virtual_aug,eval}.rs` — MERIDIAN.
|
||||
- `v2/crates/wifi-densepose-signal/src/hardware_norm.rs` — MERIDIAN HardwareNormalizer.
|
||||
- `v2/crates/wifi-densepose-mat/src/lib.rs` — MAT.
|
||||
- `python/src/bindings/vitals.rs` — the `py.allow_threads` GIL-release precedent.
|
||||
- `python/bench/test_bench_vitals.py` — the pytest-benchmark pattern P4 follows.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open follow-ups (blocking Accepted)
|
||||
|
||||
P1–P4 are real, well-tested progress: **32/32 binding tests** (aether 9, meridian
|
||||
13, mat 7, + 3 smoke) and **6/6 native parity tests** all pass, verified on the
|
||||
reference machine. The three leaf-crate hoists (§13.a) are now **done**. Two items
|
||||
still gate Accepted: **§13.b** (wire the parity harness into CI as a release gate)
|
||||
and **§13.c** (the SOTA accuracy gap — bindings are structurally *untrained*, and no
|
||||
eval harness or labeled data exists yet; genuine long-term work, not a quick fix).
|
||||
|
||||
### 13.a — Leaf-crate hoists (all three DONE) — one real fix, one minor, one false alarm
|
||||
|
||||
All three extras' backing crates carry heavy declared deps, so the hoist was applied
|
||||
to each. But **measuring the stripped release wheel** (not reasoning from the
|
||||
dependency tree) showed the wheel-size story differs sharply per extra. Linker
|
||||
dead-code elimination (`--gc-sections` on the pyo3 cdylib) strips **pure-Rust
|
||||
unreached** code, so a heavy declared dep tree does **not** imply a big wheel;
|
||||
**native/FFI-bundled** deps (`ort`/ONNX Runtime's native library) are the exception
|
||||
— DCE cannot strip them, and those are the real size risk.
|
||||
|
||||
| Extra | Commit | Wheel size (stripped) | Verdict |
|
||||
|---|---|---|---|
|
||||
| `[aether]` | `a47bb71b2` | **~361 KB → ~312 KB** | **False alarm.** Never breached the 5 MB budget — DCE already stripped the sensing-server's unreached Axum/tokio/worldgraph/ruvector code. Hoist justified by build-time (71 s → 12 s), dep-graph hygiene (`Cargo.lock` −1238 lines), and latent-risk removal — **not** budget. |
|
||||
| `[mat]` | `7ed57f041` | **8.4 MB → 2.0 MB** | **Real, measured regression.** `wifi-densepose-nn` bundles `ort`/ONNX Runtime, a **native** library DCE does **not** strip → genuine breach. Fix necessary and correctly characterized. |
|
||||
| `[meridian]` | `99fea9df9` | **1.8 MB → 1.7 MB** | **Real but minor.** Measured from the start; a dead dep removed. Already under budget; small win. `libtorch` correctly avoided throughout (`tch` optional, off). |
|
||||
|
||||
These were changes **inside** the upstream `v2/` crates (owned by other agents this
|
||||
session); the default wheel was unaffected throughout because every extra is
|
||||
feature-gated off. All three hoists are now landed — the remaining Accepted blockers
|
||||
are §13.b (CI gate) and §13.c (accuracy fixtures), **not** wheel size.
|
||||
|
||||
### 13.b — Wire the parity harness into CI as a real release gate (§6.6)
|
||||
|
||||
The three golden-vector SHA-256 gates pass locally (`cargo test --features sota` →
|
||||
6/6) but are not yet wired into a CI workflow that **blocks release** on mismatch.
|
||||
Add a job to the ADR-117 §5.4 publish pipeline that runs the native `*_parity.rs`
|
||||
references + the `pytest` binding checks and fails the release on any divergence.
|
||||
|
||||
### 13.c — Close the SOTA accuracy gap (§4.3, §6.7) — genuine long-term work
|
||||
|
||||
This is the most important honesty gap and it is **more fundamental than missing
|
||||
labeled data** (see §6.7 for the three findings). The parity harness proves the
|
||||
Python binding is **byte-identical to the native Rust path** for the bound, **but
|
||||
untrained**, surface — it does **not** prove the cited SOTA numbers (ADR-152
|
||||
~96% PCK@20; ADR-024 room-ID > 95% / re-ID mAP > 80% / anomaly F1 > 0.90; ADR-027
|
||||
cross-domain MPJPE + 20% / domain-gap < 1.5). Those bars remain **CLAIMED, not
|
||||
MEASURED** by this work.
|
||||
|
||||
Closing it requires three steps, in dependency order:
|
||||
|
||||
- **(a) Add trained-weight loading to the AETHER/pose bindings — DONE (`65da488ad`).**
|
||||
`EmbeddingExtractor` gained `save_weights(path)` / `load_weights(path)` /
|
||||
`param_count` on both the native crate and the Python binding (GIL-released,
|
||||
`ValueError`/never-panics on bad input), removing the "structurally untrained, no
|
||||
loading path" blocker: a real checkpoint can now be loaded whenever one exists.
|
||||
Default construction is unchanged (still random `with_seed` init, clearly labeled
|
||||
untrained) — purely additive. **Format tradeoff:** rather than pull in
|
||||
`safetensors`/`serde`/`bincode`, the on-disk format is raw little-endian `f32`
|
||||
with a 12-byte header (8-byte magic `AETHERW1` + `u32` param count), reusing the
|
||||
pre-existing `flatten_weights`/`unflatten_weights` — this deliberately preserves
|
||||
`wifi-densepose-aether`'s zero-dependency std-only leaf-crate property from the
|
||||
§13.a hoist. **Verified:** `cargo test -p wifi-densepose-aether` 98/98; parity 3/3
|
||||
incl. the new cross-language golden `aether_weights_parity.rs` (native Rust and the
|
||||
Python binding load the same weight file and produce a byte-identical embedding
|
||||
SHA-256, and the loaded weights demonstrably move the output off the random-init
|
||||
baseline — not a silent no-op); `pytest test_aether.py` 13/13 (up from 9).
|
||||
**This does NOT close §6.7** — it is the *capability* to load weights, not trained
|
||||
weights; (b) and (c) below remain, and no SOTA number is validated yet.
|
||||
- **(b) Commit or source a small labeled CSI fixture** (input CSI + ground-truth
|
||||
pose/identity/room labels) — **still OPEN.** Genuine **data-acquisition scope**.
|
||||
- **(c) Build a real eval harness** computing PCK / mAP / room-ID / anomaly-F1 /
|
||||
Spearman on (a)+(b) and asserting the published bars — **still OPEN.**
|
||||
|
||||
With (a) landed, the remaining work is (b) and (c): genuine research /
|
||||
data-acquisition scope beyond one session. This is now purely a data-availability +
|
||||
missing-eval-infra problem, **not** a binding defect. Status stays **Proposed**
|
||||
until (b)–(c) land and §4.3 is run for real.
|
||||
@@ -1,486 +0,0 @@
|
||||
# ADR-186: Training progress API — wire the orphaned in-server trainer to `/ws/train/progress`
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted |
|
||||
| **Date** | 2026-07-21 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **TRAIN-RECONNECT** — connecting a trainer that was written, committed, and then never plugged in |
|
||||
| **Relates to** | [ADR-051](ADR-051-sensing-server-decomposition.md) (main.rs decomposition into ~14 modules), [ADR-151](ADR-151-per-room-calibration.md) (`train-room` specialist bank), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (MAE recipe / geometry conditioning), [ADR-166](ADR-166-quality-engineering-security-hardening.md) (WS auth + god-object decomposition) |
|
||||
| **Tracking issue** | [#1233](https://github.com/ruvnet/wifi-densepose/issues/1233) — "Training does not start – /ws/train/progress returns 404 and no model is generated" (open) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The reported gap
|
||||
|
||||
A user starting training from the web dashboard hits
|
||||
`ws://localhost:3000/ws/train/progress`, which **404s**, and the backend never
|
||||
produces a trained `.rvf` model or any further log output beyond a single
|
||||
"Training started" line. Issue #1233 is open, and the repo owner's own comment on
|
||||
it states:
|
||||
|
||||
> The `/ws/train/progress` WebSocket endpoint is not yet exposed in the stable
|
||||
> server — the training pipeline (room-calibration specialists, MAE pretraining)
|
||||
> runs via the CLI (`wifi-densepose train-room`) rather than through the
|
||||
> HTTP/WebSocket API, which is why the Docker image returns 404 for that path.
|
||||
|
||||
So the dashboard has a **"Start Training" button that silently no-ops**: it POSTs a
|
||||
config, receives a `success: true` response, and then nothing happens — no error is
|
||||
surfaced, no model is produced, no progress stream exists. A button that appears to
|
||||
work but does nothing is the definition of slop, and this ADR exists to close that
|
||||
gap honestly.
|
||||
|
||||
### 1.2 What the live server actually does today (evidence)
|
||||
|
||||
The stable server mounts **stub** training handlers. The POST handler flips a string
|
||||
flag, logs one line, and returns success — it starts no job:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/main.rs:4986–5006
|
||||
async fn train_start(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if s.training_status == "running" { /* ... */ }
|
||||
s.training_status = "running".to_string();
|
||||
s.training_config = Some(body.clone());
|
||||
info!("Training started with config: {}", body); // ← the one log line the issue reports
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"status": "running",
|
||||
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
These three stubs — and **nothing else training-related** — are wired into the live
|
||||
router:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/main.rs:8068–8071
|
||||
// Training endpoints
|
||||
.route("/api/v1/train/status", get(train_status))
|
||||
.route("/api/v1/train/start", post(train_start))
|
||||
.route("/api/v1/train/stop", post(train_stop))
|
||||
```
|
||||
|
||||
There is **no `/ws/train/progress` route in the live app** — hence the 404 that
|
||||
issue #1233 reports. The stub state fields backing them are just:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/main.rs:1125–1127
|
||||
training_status: String, // "idle" | "running" | ...
|
||||
training_config: Option<serde_json::Value>,
|
||||
```
|
||||
|
||||
### 1.3 The surprising finding: a real trainer already exists, orphaned
|
||||
|
||||
The gap is **not** that training was never built for the server. A complete
|
||||
in-server training pipeline **already exists in the tree** at
|
||||
`v2/crates/wifi-densepose-sensing-server/src/training_api.rs` (1,860 lines). Its own
|
||||
module doc describes what it does (`training_api.rs:1–25`):
|
||||
|
||||
- Loads recorded CSI from `.csi.jsonl` files, extracts signal features (subcarrier
|
||||
variance, temporal gradients, Goertzel frequency-domain power).
|
||||
- Trains a regularised linear model via batch gradient descent.
|
||||
- Exports a calibrated `.rvf` model container via `RvfBuilder` on completion.
|
||||
- **"No PyTorch / `tch` dependency is required. All linear algebra is implemented
|
||||
inline using standard Rust math."** (`training_api.rs:11–13`)
|
||||
|
||||
It runs training on a **background tokio task** and streams progress over a
|
||||
`tokio::sync::broadcast` channel to a real WebSocket handler:
|
||||
|
||||
- `start_training` spawns the job: `tokio::spawn(async move { ... })`
|
||||
(`training_api.rs:1564`, spawn at `:1610`).
|
||||
- `ws_train_progress_handler` subscribes to `training_progress_tx` and forwards
|
||||
`{"type":"progress", "data": …}` frames (`training_api.rs:1778–1836`).
|
||||
- A `routes()` factory wires the whole surface, **including the missing route**:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:1841–1849
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/train/start", post(start_training))
|
||||
.route("/api/v1/train/stop", post(stop_training))
|
||||
.route("/api/v1/train/status", get(training_status))
|
||||
.route("/api/v1/train/pretrain", post(start_pretrain))
|
||||
.route("/api/v1/train/lora", post(start_lora_training))
|
||||
.route("/ws/train/progress", get(ws_train_progress_handler))
|
||||
}
|
||||
```
|
||||
|
||||
**This module is dead code.** There is no `mod training_api;` declaration anywhere
|
||||
in the crate — a repo-wide search for `training_api` returns only a doc-comment
|
||||
mention in `path_safety.rs:9`. Because Rust never sees the file without a `mod`
|
||||
declaration, `training_api.rs` is **not compiled into the binary at all**, and
|
||||
`training_api::routes()` is never merged into the app. It was written, committed
|
||||
(last touched by commit `9b07dff29`), and then orphaned.
|
||||
|
||||
### 1.4 Why it would not even compile if naively wired in
|
||||
|
||||
The orphan was written against a **different state shape than the one that shipped**.
|
||||
`training_api.rs` expects its parent to expose an `AppStateInner` carrying a training
|
||||
sub-state and a broadcast sender:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:249
|
||||
pub type AppState = Arc<RwLock<super::AppStateInner>>;
|
||||
// handlers read s.training_state.status, s.training_state.task_handle,
|
||||
// s.training_progress_tx (e.g. training_api.rs:1588, :1610, :1788)
|
||||
```
|
||||
|
||||
But the **real** `AppStateInner` (`main.rs:1024`, aliased `SharedState` at
|
||||
`main.rs:1249`) has none of those fields — only the `training_status: String` /
|
||||
`training_config` stubs from §1.2. `training_state: TrainingState` is defined
|
||||
locally in `training_api.rs:232`, and `training_progress_tx` exists nowhere on the
|
||||
live state. So adding `mod training_api;` today produces a compile error: the module
|
||||
references `AppStateInner` fields that do not exist. Wiring it in requires
|
||||
**reconciling the state struct first**, not merely uncommenting a route.
|
||||
|
||||
### 1.5 The working path today
|
||||
|
||||
The path that actually trains a model is the CLI, exactly as the maintainer's
|
||||
comment says:
|
||||
|
||||
- `wifi-densepose train-room` → `room.rs:241` `train_room(...)`, the ADR-151
|
||||
Stage-2–5 per-room specialist-bank trainer (`enroll → train-room → room-watch`).
|
||||
- The heavier `wifi-densepose-train` crate exposes epoch-level metrics
|
||||
(`trainer.rs:43` `pub epoch: usize`, `trainer.rs:64` `best_epoch`) that a progress
|
||||
stream could surface directly — the data a WebSocket needs already exists in the
|
||||
training loop.
|
||||
|
||||
### 1.6 What this ADR is *not*
|
||||
|
||||
- Not a rewrite of the trainer. The pipeline in `training_api.rs` already exists;
|
||||
this ADR reconnects and hardens it.
|
||||
- Not a move of GPU/`tch`-backed training into the Axum server. The in-server
|
||||
trainer is deliberately `tch`-free (§1.3). Heavy MAE/LoRA training stays in the
|
||||
CLI / `wifi-densepose-train` crate; the server streams progress for the light,
|
||||
pure-Rust specialist trainer and (optionally) proxies status for CLI-launched runs.
|
||||
- Not a change to the `train-room` CLI contract (ADR-151). The CLI remains the
|
||||
authoritative path for offline / batch training.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state — evidence
|
||||
|
||||
| Artifact | Value | Source |
|
||||
|---|---|---|
|
||||
| Live POST handler | `train_start` — flips a flag, logs, returns `success:true`, starts no job | `main.rs:4986–5006` |
|
||||
| The "Training started" log line from the issue | `info!("Training started with config: {}", body)` | `main.rs:5000` |
|
||||
| Live training routes | `train/status`, `train/start`, `train/stop` (stubs only) | `main.rs:8068–8071` |
|
||||
| `/ws/train/progress` in live app | **Absent** → 404 | (no route in `main.rs` router) |
|
||||
| Live training state fields | `training_status: String`, `training_config: Option<Value>` | `main.rs:1125–1127` |
|
||||
| Real in-server trainer | 1,860-line implemented pipeline, `tch`-free, exports `.rvf` | `training_api.rs:1–25` |
|
||||
| Real WS progress handler | subscribes to broadcast, streams `progress` frames | `training_api.rs:1778–1836` |
|
||||
| Real route factory (has the missing route) | `routes()` incl. `/ws/train/progress` | `training_api.rs:1841–1849` |
|
||||
| Background job spawn | `tokio::spawn` of the training task | `training_api.rs:1564`, spawn `:1610` |
|
||||
| `mod training_api;` declaration | **None in the crate** (only a doc mention) | `path_safety.rs:9` |
|
||||
| State-shape mismatch | expects `super::AppStateInner.{training_state, training_progress_tx}` | `training_api.rs:249`, `:232` |
|
||||
| Real `AppStateInner` / `SharedState` | has neither field | `main.rs:1024`, `:1249` |
|
||||
| Working training path | CLI `train-room` (ADR-151 specialist bank) | `room.rs:241` |
|
||||
| Epoch metrics available to stream | `TrainMetrics.epoch`, `best_epoch` | `train/src/trainer.rs:43`, `:64` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap analysis
|
||||
|
||||
| Capability | Desired | Today | Gap severity |
|
||||
|---|---|---|---|
|
||||
| `/ws/train/progress` resolves | 101 Switching Protocols, streams epoch/loss/eta | 404 (route absent) | **Critical** — the reported bug |
|
||||
| "Start Training" produces a model | background job trains and writes `.rvf` | flag flip + one log line, no job, no model | **Critical** |
|
||||
| Error surfaced to the user | button reflects real state / disabled with reason | silent no-op, `success:true` | **Critical** (slop) |
|
||||
| In-server trainer compiled | part of the crate, unit-tested | orphaned; not compiled (no `mod`) | **High** |
|
||||
| State supports progress streaming | `training_state` + `training_progress_tx` on `AppStateInner` | absent — orphan won't compile as-is | **High** |
|
||||
| WS auth on the training surface | `/ws/train/progress` under bearer gate (ADR-166 §Sprint-1) | n/a (route absent) | **High** |
|
||||
| `dataset_ids` path safety | validated before file open | `path_safety.rs` exists but unreached by live routes | **Medium** |
|
||||
| Server ↔ CLI parity | shared/consistent training semantics | two divergent trainers (stub vs CLI vs orphan) | **Medium** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision
|
||||
|
||||
**Chosen path: wire the existing in-server trainer into the live server** — reconcile
|
||||
the state struct, declare the module, merge `training_api::routes()`, delete the
|
||||
stub handlers, and expose a real `/ws/train/progress` that streams epoch/loss/eta
|
||||
events from the already-implemented background job.
|
||||
|
||||
This is called **TRAIN-RECONNECT**.
|
||||
|
||||
### 4.1 Why this path, and not "make the button honestly say CLI-only"
|
||||
|
||||
The task framing offered two honest options. Investigation decided it:
|
||||
|
||||
| Consideration | Evidence | Implication |
|
||||
|---|---|---|
|
||||
| Is server-side training genuinely GPU/`tch`-bound (→ keep CLI-only)? | The in-server trainer is explicitly **`tch`-free**, pure Rust, exports `.rvf` (`training_api.rs:11–13`) | The "too heavy for Axum" argument is contradicted by the code |
|
||||
| Does a real streaming implementation already exist? | Full pipeline + broadcast + WS handler + `routes()` present (`training_api.rs:1564,1778,1841`) | The impressive-sounding option is also the *least* new code — it already exists |
|
||||
| Why does it 404 then? | No `mod training_api;`; state-shape mismatch (`:249` vs `main.rs:1024`) | The fix is reconnection + reconciliation, not new invention |
|
||||
|
||||
Because the honest, code-supported reality is "a working trainer was written and left
|
||||
unplugged," the right decision is to plug it in — this is not choosing the flashier
|
||||
option over the code; it *is* what the code says.
|
||||
|
||||
**However**, path B is retained as a **mandatory fallback guarantee** (Phase P5): if,
|
||||
for a given build/deployment, server-side training is disabled (e.g. behind a
|
||||
feature flag, or on the lightweight appliance image where recordings aren't
|
||||
available), the dashboard button MUST be disabled with a tooltip pointing at
|
||||
`wifi-densepose train-room` — never a silent `success:true` no-op again. The slop is
|
||||
eliminated in both the enabled and disabled configurations.
|
||||
|
||||
### 4.2 Scope boundary — light trainer streams, heavy trainer proxies
|
||||
|
||||
- The **pure-Rust specialist trainer** (`training_api.rs`, ADR-151 flavour) runs
|
||||
in-process and streams live epoch/loss/eta over `/ws/train/progress`.
|
||||
- **Heavy MAE/LoRA training** (`wifi-densepose-train`, `tch`/GPU) stays CLI-launched.
|
||||
The server does not host it; at most `/api/v1/train/status` reports on a
|
||||
CLI-launched run if one registers itself. Streaming heavy training is out of scope
|
||||
for this ADR (noted as an open question, §8).
|
||||
|
||||
---
|
||||
|
||||
## 5. Detailed design
|
||||
|
||||
### 5.1 Reconcile `AppStateInner`
|
||||
|
||||
Replace the two stub fields (`main.rs:1125–1127`) with the sub-state the trainer
|
||||
expects, so `training_api.rs` compiles against `super::AppStateInner`:
|
||||
|
||||
```rust
|
||||
// main.rs — inside AppStateInner (replacing training_status / training_config)
|
||||
training_state: training_api::TrainingState, // status, epoch, best_pck, task_handle
|
||||
training_progress_tx: tokio::sync::broadcast::Sender<String>, // progress fan-out
|
||||
```
|
||||
|
||||
`train_status` consumers that read `s.training_status` / `s.training_config` are
|
||||
updated to read `s.training_state.status`. The broadcast sender is created at state
|
||||
init (`main.rs:7826` region, where the stubs are seeded today).
|
||||
|
||||
### 5.2 Declare and merge the module
|
||||
|
||||
- Add `mod training_api;` to `main.rs` (or `pub mod` in `lib.rs` if the router is
|
||||
assembled there).
|
||||
- Delete the stub handlers `train_start` / `train_stop` / `train_status`
|
||||
(`main.rs:4977–5023`) and their three route mounts (`main.rs:8069–8071`).
|
||||
- Merge the real router **after** `.with_state(state.clone())`, the same pattern the
|
||||
RuField surface already uses (`main.rs:8104–8111`):
|
||||
|
||||
```rust
|
||||
// main.rs router assembly
|
||||
.merge(training_api::routes())
|
||||
```
|
||||
|
||||
so that `/api/v1/train/*` and `/ws/train/progress` resolve against the shared state.
|
||||
|
||||
### 5.3 Auth and safety (ADR-166 alignment)
|
||||
|
||||
- `/api/v1/train/*` sits under the existing opt-in bearer gate (`main.rs:8095–8102`,
|
||||
`RUVIEW_API_TOKEN`). `/ws/train/progress` follows the same policy decision made for
|
||||
`/ws/sensing` — document explicitly whether the training WS is gated (recommended:
|
||||
gated when a token is set, since training reads/writes recordings and models).
|
||||
- `dataset_ids` from `StartTrainingRequest` (`training_api.rs:126–130`) are resolved
|
||||
through `path_safety` before any file open — `path_safety.rs:9` already anticipates
|
||||
`{dataset_id}.csi.jsonl` under `RECORDINGS_DIR`; wire it in the load path.
|
||||
- Single-job concurrency guard: `start_training` already rejects a second run while
|
||||
`training_state.status.active` (`training_api.rs:1571`) — keep it.
|
||||
|
||||
### 5.4 Progress event schema (already emitted)
|
||||
|
||||
The WS handler already frames messages as `{"type":"status"|"progress", "data": …}`
|
||||
(`training_api.rs:1796–1815`). Confirm the `data` payload carries at minimum
|
||||
`epoch`, `total_epochs`, `loss`, `best_pck`, and an `eta_seconds`; these map onto the
|
||||
`TrainMetrics`/`TrainingStatus` fields already populated by the loop
|
||||
(`training_api.rs:1251`, `train/src/trainer.rs:43,64`).
|
||||
|
||||
### 5.5 Dashboard honesty (both configurations)
|
||||
|
||||
- **Enabled build:** button POSTs `/api/v1/train/start`, then opens
|
||||
`/ws/train/progress`; the UI renders live epoch/loss/eta and a terminal
|
||||
success/failure with the output `.rvf` path.
|
||||
- **Disabled build:** `/api/v1/train/start` returns a structured
|
||||
`{"enabled": false, "reason": "...", "cli": "wifi-densepose train-room"}` and the
|
||||
button renders disabled with a tooltip — no silent `success:true`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase ledger
|
||||
|
||||
```
|
||||
P0 ──► P1 ──► P2 ──► P3 ──► P4 ──► P5 ──► P6
|
||||
repro state wire stream auth+ dash tests+
|
||||
+audit recon router job safety honesty witness
|
||||
```
|
||||
|
||||
### P0 — Reproduce & audit (evidence lock)
|
||||
- [x] Confirmed the orphan: `grep -rn "mod training_api"` returned **nothing**; the only
|
||||
hit was a doc mention in `path_safety.rs`. `training_api.rs` was uncompiled.
|
||||
- [x] Confirmed the stub no-op (`train_start` at `main.rs:4986` flipped a string + logged
|
||||
one line, no job, no `.rvf`) and the missing `/ws/train/progress` route.
|
||||
|
||||
### P1 — Reconcile `AppStateInner`
|
||||
- [x] Replaced `training_status`/`training_config` with `training_state:
|
||||
training_api::TrainingState` + `training_progress_tx: broadcast::Sender<String>`.
|
||||
- [x] Updated state init; the only readers of the old fields were the stub handlers (deleted).
|
||||
- [x] Added `mod training_api;` (+ `mod path_safety;`); the module compiles against the real state.
|
||||
|
||||
### P2 — Wire the router, delete the stubs
|
||||
- [x] Removed `train_start`/`train_stop`/`train_status` and their 3 route mounts.
|
||||
- [x] `.merge(training_api::routes())` — merged **before** `.with_state(...)` (not after).
|
||||
The RuField surface merges after because it carries a *different* state; the training
|
||||
router shares `SharedState`, so merging before is what puts `/api/v1/train/*` under the
|
||||
same `/api/v1/*` bearer gate as everything else.
|
||||
- [x] `/api/v1/train/*` and `/ws/train/progress` resolve (verified by HTTP tests, not 404).
|
||||
|
||||
### P3 — Confirm the real job streams and produces a model
|
||||
- [x] The spawned job loads `.csi.jsonl` (falls back to a `frame_history` snapshot),
|
||||
runs the gradient-descent loop, and writes a `.rvf` under `data/models`.
|
||||
- [x] Progress frames carry `epoch`, `total_epochs`, `train_loss`, `val_pck`, `eta_secs`.
|
||||
- [x] Server-vs-CLI semantics documented as **intentionally divergent** (§4.2, §9.2):
|
||||
the server runs the light pure-Rust specialist trainer; heavy MAE/LoRA stays CLI.
|
||||
|
||||
### P4 — Auth & path safety
|
||||
- [x] `/api/v1/train/*` sits under the existing `RUVIEW_API_TOKEN` bearer gate (merged
|
||||
before `.with_state`); `/ws/train/progress` is intentionally **ungated**, matching
|
||||
`/ws/sensing` (browsers can't attach an `Authorization` header to a WS upgrade).
|
||||
- [x] `dataset_ids` resolved via `path_safety::safe_id` before file open; pinned by
|
||||
`load_recording_frames_rejects_path_traversal`.
|
||||
- [x] Single-job guard: `spawn_training_job` rejects a second start while active
|
||||
(`is_active()` → `active_error`).
|
||||
|
||||
### P5 — Dashboard honesty (fallback guarantee)
|
||||
- [x] Enabled build: `TrainingPanel` opens `/ws/train/progress` before the POST and renders
|
||||
live epoch/loss/PCK/ETA + a terminal Complete state (already wired; verified).
|
||||
- [x] Disabled build (`RUVIEW_DISABLE_SERVER_TRAINING`): start returns
|
||||
`{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409; the dashboard reads
|
||||
`enabled` off `/api/v1/train/status` and disables the Start buttons with a CLI
|
||||
tooltip — no silent no-op. Implemented via a runtime flag rather than a Cargo feature
|
||||
so the `--no-default-features` test build keeps training ON (§9.4 resolved this way).
|
||||
|
||||
### P6 — Tests & witness
|
||||
- [x] Live-socket test `ws_train_progress_live_101_and_frame`: genuine 101 handshake + a real
|
||||
progress frame after POST start. Plus `ws_train_progress_route_is_wired_not_404`.
|
||||
- [x] `http_train_start_produces_model_and_streams`: POST start → poll status → `.rvf` exists.
|
||||
- [x] CHANGELOG updated. README/CLAUDE have no training route table, so no route-table edit
|
||||
was needed there.
|
||||
|
||||
*(All phases complete. Acceptance criteria verified below — this ADR is Accepted.)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance criteria (concrete verification)
|
||||
|
||||
All must pass before ADR-186 is Accepted:
|
||||
|
||||
- [x] **Orphan is reconnected:**
|
||||
`grep -rn "mod training_api" v2/crates/wifi-densepose-sensing-server/src/`
|
||||
returns a hit (`main.rs`), and
|
||||
`cargo build -p wifi-densepose-sensing-server` **compiles** (proves the state
|
||||
reconciliation in §5.1 is correct — the module cannot compile against the
|
||||
current `AppStateInner`). **VERIFIED.**
|
||||
- [x] **Route no longer 404s (HTTP upgrade):** verified in-process rather than with a live
|
||||
`curl` — `ws_train_progress_live_101_and_frame` binds the training router on a real
|
||||
socket and `tokio_tungstenite::connect_async` completes a genuine **101** handshake
|
||||
(asserts `resp.status() == 101`); `ws_train_progress_route_is_wired_not_404` also
|
||||
confirms the route is reached (426 under `oneshot`, **not** 404). **VERIFIED.**
|
||||
- [x] **Progress actually streams:** `ws_train_progress_live_101_and_frame` connects the WS,
|
||||
POSTs `/api/v1/train/start`, and receives a real `{"type":"progress","data":{...}}`
|
||||
frame within the 10 s ceiling. **VERIFIED.**
|
||||
- [x] **A model is produced:** `http_train_start_produces_model_and_streams` POSTs start,
|
||||
polls `/api/v1/train/status` to completion, and asserts a **new `.rvf`** appeared under
|
||||
`data/models/` (snapshot diff). Also covered by the trainer-level
|
||||
`training_job_streams_real_progress_and_writes_model`. **VERIFIED.**
|
||||
- [x] **No silent no-op remains:** `http_train_start_disabled_returns_structured_409` sets
|
||||
`RUVIEW_DISABLE_SERVER_TRAINING` and asserts POST start returns **HTTP 409** with
|
||||
`{"enabled":false, ...,"cli":"wifi-densepose train-room"}` and never `success:true`.
|
||||
**VERIFIED.**
|
||||
- [x] **Auth honored:** `/api/v1/train/*` is merged into the router **before** the
|
||||
`RUVIEW_API_TOKEN` bearer middleware and `.with_state`, so it is covered by the exact
|
||||
same `/api/v1/*` gate as every other authenticated route (verified by construction /
|
||||
code review; `/ws/train/progress` is intentionally ungated like `/ws/sensing`). No new
|
||||
dedicated runtime token test was added — the gate is the shared, already-tested
|
||||
`bearer_auth` middleware. **VERIFIED (by construction).**
|
||||
- [x] **Path safety:** `load_recording_frames_rejects_path_traversal` asserts
|
||||
`dataset_ids:["../../etc/passwd"]` yields no frames (rejected by `path_safety::safe_id`
|
||||
before any file open). **VERIFIED.**
|
||||
- [x] **Integration test green:** `ws_train_progress_live_101_and_frame` (`#[tokio::test]`)
|
||||
serves the training router, opens `/ws/train/progress`, and asserts a 101 upgrade + a
|
||||
real progress frame — and, being built on `training_api::routes()`, cannot compile if
|
||||
the module is orphaned again. **VERIFIED.**
|
||||
- [x] **Workspace regression:** `cargo test -p wifi-densepose-sensing-server
|
||||
-p wifi-densepose-train --no-default-features` — sensing-server bin **217 passed /
|
||||
0 failed**, all train suites **0 failed**. A full `cargo test --workspace
|
||||
--no-default-features` run initially surfaced a **test-only parallelism race** in the
|
||||
new tests (two model-writing tests deleted `.rvf`s by directory-diff, occasionally
|
||||
removing a file a third test asserted existed) — fixed by removing the cross-test
|
||||
deletions (each test cleans only its own artifact; `data/models` is gitignored).
|
||||
Re-verified post-fix: `cargo test --workspace --no-default-features` — **0 failed**
|
||||
(exit 0). **VERIFIED.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### Positive
|
||||
- Closes issue #1233: the dashboard button either trains-and-streams or honestly says
|
||||
"use the CLI" — the silent no-op is gone in every configuration.
|
||||
- Reclaims 1,860 lines of already-written, already-committed trainer that were dead
|
||||
(uncompiled) code, and adds a test that keeps them wired.
|
||||
- `/ws/train/progress` gives the UI real epoch/loss/eta, matching the maintainer's
|
||||
stated intent.
|
||||
- Forces the state-shape reconciliation that the orphan implied but never landed,
|
||||
removing a latent "two competing training designs" trap in `AppStateInner`.
|
||||
|
||||
### Negative
|
||||
- Editing `AppStateInner` (`main.rs:1024`) and the router (`main.rs:8068`) touches the
|
||||
large `main.rs`; merge-conflict risk with concurrent work on the same file (the
|
||||
ADR-166 decomposition is relevant here).
|
||||
- Adds a live training code path to the server's attack surface — mitigated by the
|
||||
bearer gate and `path_safety`, but it must be reviewed (network/hardware boundary,
|
||||
per the pre-merge security checklist).
|
||||
- Server and CLI now have two trainers that must be kept semantically consistent, or
|
||||
their divergence explicitly documented.
|
||||
|
||||
### Neutral
|
||||
- Heavy MAE/LoRA/`tch` training remains CLI-only; the server streams only the
|
||||
light pure-Rust specialist trainer. Streaming heavy runs is deferred.
|
||||
- The progress event schema (`epoch/loss/best_pck/eta`) is already emitted by the
|
||||
orphan; no new schema is invented, only confirmed and documented.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. **WS auth policy for `/ws/train/progress`:** gate it whenever `RUVIEW_API_TOKEN`
|
||||
is set (like `/api/v1/*`), or leave it open like `/ws/sensing`? *Tentative: gate
|
||||
it — training reads recordings and writes models.*
|
||||
2. **Server ↔ CLI trainer parity:** should the in-server trainer and
|
||||
`wifi-densepose train-room` (ADR-151) share one code path, or remain deliberately
|
||||
separate (server = quick UI-driven specialist fit; CLI = full bank + geometry
|
||||
conditioning)? *Tentative: keep separate, document the split, share feature
|
||||
extraction where cheap.*
|
||||
3. **Heavy-training progress:** can a CLI-launched `wifi-densepose-train` (`tch`)
|
||||
run register itself so `/api/v1/train/status` and the WS can report on it without
|
||||
hosting it in-process? *Tentative: out of scope here; a follow-on ADR.*
|
||||
4. **Feature-flagging server training:** should in-server training be behind a Cargo
|
||||
feature (off on the lightweight appliance image), making the P5 disabled-button
|
||||
path the default there? *Tentative: yes — flag it; default the UI to the honest
|
||||
disabled state on images without recordings.*
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- **Issue #1233**: https://github.com/ruvnet/wifi-densepose/issues/1233 — the reported bug.
|
||||
- **Live stubs**: `v2/crates/wifi-densepose-sensing-server/src/main.rs:4977–5023` (handlers),
|
||||
`:8068–8071` (routes), `:1125–1127` (state fields), `:1024`/`:1249` (`AppStateInner`/`SharedState`).
|
||||
- **Orphaned trainer**: `v2/crates/wifi-densepose-sensing-server/src/training_api.rs` —
|
||||
module doc `:1–25`, `TrainingState` `:232`, `AppState` alias `:249`, `start_training` `:1564`
|
||||
(spawn `:1610`), WS handler `:1778–1836`, `routes()` `:1841–1849`.
|
||||
- **Not-a-module proof**: repo-wide `training_api` only in `path_safety.rs:9` (doc comment).
|
||||
- **CLI working path**: `v2/crates/wifi-densepose-cli/src/room.rs:241` `train_room` (ADR-151).
|
||||
- **Epoch metrics**: `v2/crates/wifi-densepose-train/src/trainer.rs:43`, `:64`.
|
||||
- **ADR-166**: WebSocket authentication + `main.rs` decomposition (security context for this change).
|
||||
- **ADR-151**: per-room calibration / `train-room` specialist bank.
|
||||
@@ -1,198 +0,0 @@
|
||||
# ADR-187: `archive/v1` Deprecation & Model-Weights Honest Labeling
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-07-21
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: archive-v1, deprecation, densepose-head, model-weights, honest-labeling, prove-everything, credibility, pip-tombstone
|
||||
- **Refs**: [#509](https://github.com/ruvnet/RuView/issues/509) (missing model weights / reproducibility), [#1125](https://github.com/ruvnet/RuView/issues/1125) ("has anyone got this to work?")
|
||||
- **Relates to**: [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip modernization + 1.99.0 tombstone), [ADR-160](ADR-160-edge-skill-library-honest-labeling.md) (honest-labeling precedent), [ADR-079](ADR-079-camera-ground-truth-training.md) (camera-supervised pose target), [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) (WiFlow-STD PCK@20 measurement), [ADR-175](ADR-175-int8-quantization-half-pose-model-measured.md) (int8 pose trade-off), [ADR-101](ADR-101-pose-estimation-cog.md) (pose cog)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Two open GitHub issues are, at root, the same complaint: the project's public surface
|
||||
lets a reader believe a WiFi→17-keypoint pose model exists and produces real accuracy,
|
||||
when the specific code they land on cannot back that claim.
|
||||
|
||||
- **#509** — a detailed technical review states: *"While the network architecture for
|
||||
DensePoseHead is defined in the code, there are no pre-trained weights (.pth or .onnx
|
||||
files) available in the repository,"* and questions whether ESP32 1×1 SISO antennas
|
||||
can match the multi-antenna NIC research this project is inspired by.
|
||||
- **#1125** — a user asks for anyone to testify the project actually runs and returns
|
||||
real data. A pure credibility complaint.
|
||||
|
||||
This ADR follows the **prove-everything / anti-"AI-slop"** directive and the
|
||||
**honest-labeling** precedent set by ADR-160: the fix is to make the labels TRUE, not
|
||||
to fabricate a capability. Grading vocabulary (from ADR-152 / ADR-160):
|
||||
|
||||
- **MEASURED** — reproduced in this worktree; the file/absence was directly inspected.
|
||||
- **DATA-GATED** — a real code path exists; honestly flagged where the accuracy is not validated.
|
||||
- **NO-ACTION (already-honest)** — audited, found correct, cited as a positive.
|
||||
|
||||
### What the investigation actually found (MEASURED in this worktree)
|
||||
|
||||
The situation is **more nuanced than either issue implies** — worse in one place, and
|
||||
distinctly *better* in others. Forcing a uniformly negative narrative would itself be
|
||||
dishonest. The findings:
|
||||
|
||||
**1. `archive/v1` — the issue reporter is correct here.**
|
||||
- `archive/v1/src/models/densepose_head.py` defines `DensePoseHead` (segmentation +
|
||||
UV-regression heads). Its `_initialize_weights()` uses **`kaiming_normal_` random
|
||||
initialization only** — there is no checkpoint-loading path in the class.
|
||||
- `Glob archive/v1/**/*.{pth,onnx,safetensors,pt,ckpt,bin}` → **zero files**. There are
|
||||
**no trained weights anywhere under `archive/v1/`.** The "architecture defined, no
|
||||
weights" claim is TRUE for this tree.
|
||||
- `archive/v1/README.md` calls the tree "the legacy Python implementation" in a single
|
||||
closing note but does **not** loudly warn users off it, and there is **no
|
||||
`archive/v1/DEPRECATED.md`.** This is the dead-but-present code that shows up in greps
|
||||
and search and reads as if it were the live implementation.
|
||||
- Per ADR-117, this exact tree is the source of the tombstoned pip package
|
||||
`wifi-densepose 1.x` (1.99.0 raises an `ImportError` telling users to migrate). The
|
||||
code is already tombstoned *on PyPI* but not *in the repo*.
|
||||
|
||||
**2. `v2` (the current, maintained system) — real weights DO exist; the "no weights
|
||||
anywhere" reading is FALSE at the project level.** Git-tracked, committed checkpoints:
|
||||
- `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` (507 KB) +
|
||||
`pose_v1.onnx` (12 KB) + `train_results.json` — a **real committed 17-keypoint
|
||||
model**, trained with Candle on an RTX 5080.
|
||||
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — a committed
|
||||
person-count model.
|
||||
- Externally published on Hugging Face (not committed, but real and released):
|
||||
`ruvnet/wifi-densepose-pretrained` (CSI encoder + presence head, honestly re-labeled
|
||||
at **82.3% held-out temporal-triplet accuracy** — the older "100% presence" figure was
|
||||
already retracted, an existing honest-labeling win) and `ruvnet/wifi-densepose-mmfi-pose`
|
||||
(a pose model reporting **82.69% torso-PCK@20** on the MM-Fi `random_split` protocol).
|
||||
- ADR-152 measurement (a): the *external* WiFlow-STD (DY2434) model was reproduced at
|
||||
**96.09% PCK@20** on an RTX 5080 (graded MEASURED-EQUIVALENT). That is an external
|
||||
baseline, not RuView's own weights.
|
||||
|
||||
**3. The honest gap is narrow and specific — the live, on-device ESP32 17-keypoint
|
||||
pose path.** Per `v2/crates/cog-pose-estimation/cog/README.md` (already an exemplary
|
||||
"Honest reading" section):
|
||||
- The committed `pose_v1` scores **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample
|
||||
holdout — **below the ADR-079 target of PCK@20 ≥ 35%.** It learns coarse structure
|
||||
(`r_hip` 77% PCK@50) but distal/face joints are near-random. `encoder_init` was
|
||||
`random`; it was trained on a single 30-min seated-at-desk recording (1,077 samples,
|
||||
avg confidence 0.44).
|
||||
- The cog's **runtime inference path is still a centred-skeleton stub returning
|
||||
`confidence=0`** — the `pose_v1.safetensors` weights are not yet wired into
|
||||
`src/inference.rs`.
|
||||
- ADR-079 records the proxy-supervised baseline at **PCK@20 = 2.5%**, and ADR-152
|
||||
**retracted** the internal camera-supervised 92.9% PCK@20 figure (it was a
|
||||
constant-output model scored under an absolute threshold on near-static frames; a mean
|
||||
predictor scores 100% under the same broken protocol).
|
||||
|
||||
### The real problem to fix
|
||||
|
||||
Not "the project has no weights" (false) and not "there is a validated pretrained
|
||||
DensePoseHead" (false for the live ESP32 path). The real problem is a **labeling and
|
||||
navigation gap**:
|
||||
1. `archive/v1`'s random-init `DensePoseHead` is indistinguishable, to a grepping
|
||||
reader, from the live implementation, and carries no deprecation notice.
|
||||
2. Nowhere is the split stated plainly: *which* checkpoints are real and validated
|
||||
(presence 82.3%, MM-Fi pose 82.69% torso-PCK@20), *which* are real-but-weak and
|
||||
honestly labeled (`pose_v1` 3% PCK@20, runtime stubbed), and *which* are
|
||||
architecture-only with no weights at all (`archive/v1` `DensePoseHead`).
|
||||
|
||||
## Decision
|
||||
|
||||
Two coordinated honest-labeling actions. Neither invents a capability; both make the
|
||||
public surface match what the code and checkpoints actually deliver.
|
||||
|
||||
### (a) Formally deprecate `archive/v1` in the repo — MEASURED gap, proposed fix
|
||||
|
||||
- **Add `archive/v1/DEPRECATED.md`** — a loud tombstone stating that `archive/v1` is the
|
||||
original pure-Python implementation, is **unmaintained and superseded**, that its
|
||||
`DensePoseHead` is **architecture-only with random-initialized weights and ships no
|
||||
trained checkpoint**, and that the maintained path is the `v2/` Rust workspace + the
|
||||
`wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Mirror the disclaimer tone of
|
||||
ADR-160's `//!` headers and the pip 1.99.0 tombstone text.
|
||||
- **Prepend a loud notice to `archive/v1/README.md`** (the file exists) — a `> ⚠️
|
||||
DEPRECATED` block at the very top pointing to `DEPRECATED.md`, `v2/`, and the pip
|
||||
wheel, before any of the existing "how to install v1" content.
|
||||
- **Rule:** no doc outside `archive/v1/` may reference `archive/v1` code (other than the
|
||||
ADR-028 deterministic proof at `archive/v1/data/proof/verify.py`, which is a
|
||||
legitimately live signal-pipeline witness and stays) as if it were current. The two
|
||||
README references verified (`README.md` lines 139/198/204; `docs/user-guide.md`
|
||||
proof/swift-compile lines) are all proof/utility invocations, not implementation
|
||||
claims — they are acceptable and out of scope.
|
||||
|
||||
### (b) Model-weights honest labeling — state the three tiers explicitly
|
||||
|
||||
Add a **"Model weights: what's real, what's not"** subsection to `README.md` and
|
||||
`docs/user-guide.md` that names the three tiers verified above, so no reader can infer
|
||||
"a pretrained 17-keypoint DensePoseHead produces real pose accuracy on my ESP32":
|
||||
|
||||
| Tier | Checkpoint(s) | Honest status |
|
||||
|------|---------------|---------------|
|
||||
| **Real & validated** | `ruvnet/wifi-densepose-pretrained` (encoder + presence, 82.3% held-out temporal-triplet); `ruvnet/wifi-densepose-mmfi-pose` (82.69% torso-PCK@20, MM-Fi `random_split`); `count_v1` | MEASURED / published; keep current honest labels |
|
||||
| **Real but weak (honestly labeled)** | committed `pose_v1.safetensors` in `cog-pose-estimation` | **PCK@20 = 3.0%**, below the ADR-079 ≥35% target; runtime path is a `confidence=0` stub until weights are wired into `src/inference.rs`. Already disclosed in the cog README; surface the same caveat wherever the live ESP32 pose feature is advertised |
|
||||
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | random-init, no checkpoint; deprecated per (a) |
|
||||
|
||||
- The existing MM-Fi/presence honest labels (retraction of "100% presence", the cog
|
||||
"Honest reading") are **NO-ACTION positives** — cite them, do not weaken them.
|
||||
- The live ESP32 17-keypoint claim stays **DATA-GATED**: the path to a first
|
||||
*reproducible* on-device baseline is ADR-079 (multi-session, full-body-framed,
|
||||
camera-supervised, ≥30K paired samples at conf ≥0.7, target PCK@20 ≥35%), tracked in
|
||||
[#645]. Do not advertise the live ESP32 pose feature without the "first-cut / below
|
||||
target / runtime stub" caveat until that baseline is MEASURED.
|
||||
- Directly answer #509's ESP32-SISO question in the docs, honestly: single-antenna 56-
|
||||
subcarrier CSI at a 20-frame window does **not** carry the fine-grained spatial
|
||||
information the multi-antenna NIC research relies on (the cog README already shows
|
||||
distal/face joints near-random) — the shippable pose accuracy the project *can* stand
|
||||
behind today is the **MM-Fi benchmark** number, not a live single-ESP32 number.
|
||||
|
||||
## Phase ledger
|
||||
|
||||
| Phase | Action | State |
|
||||
|-------|--------|-------|
|
||||
| **P0** | This ADR (investigation + decision) | **DONE** (this file) |
|
||||
| **P1** | Add `archive/v1/DEPRECATED.md` + loud notice atop `archive/v1/README.md` | **DONE** (1fb5397dd) |
|
||||
| **P2** | Add "Model weights: what's real, what's not" tier table to `README.md` + `docs/user-guide.md`; add the caveat wherever the live ESP32 17-keypoint feature is advertised | **DONE** (1fb5397dd; follow-up caveated the hardware table, hero caption, and live-pipeline note) |
|
||||
| **P3** | Answer #509's SISO/no-weights question and #1125's "does it run" in `docs/user-guide.md` (point to the reproducible proofs: MM-Fi arena, `archive/v1/data/proof/verify.py`, cog `train_results.json`) | **DONE** (1fb5397dd) |
|
||||
| **P4** | Close the DATA-GATED live-pose gap via ADR-079 first reproducible on-device baseline (PCK@20 ≥35%) + wire `pose_v1.safetensors` into `cog-pose-estimation/src/inference.rs` | ACCEPTED-FUTURE ([#645]) |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `archive/v1/DEPRECATED.md` exists and names `v2/` + the pip wheel as the maintained path.
|
||||
- [x] `archive/v1/README.md` opens with a `> ⚠️ DEPRECATED` block before any install instructions.
|
||||
- [x] `README.md` and `docs/user-guide.md` no longer let a reader infer that `archive/v1`
|
||||
or an untrained/random-init `DensePoseHead` produces real pose accuracy without the
|
||||
caveats added here.
|
||||
- [x] The live ESP32 17-keypoint pose feature is nowhere advertised without its
|
||||
"first-cut, PCK@20 = 3.0%, below ADR-079 target, runtime stub" caveat.
|
||||
- [x] The three real/published checkpoints (presence 82.3%, MM-Fi pose 82.69% torso-PCK@20,
|
||||
`count_v1`) keep their existing honest labels — nothing is weakened or overclaimed.
|
||||
- [x] No claim is added that is not MEASURED or explicitly DATA-GATED.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A grepping reader can no longer mistake `archive/v1`'s random-init `DensePoseHead` for
|
||||
the live system; the dead code is loudly tombstoned in the repo, matching its PyPI 1.99.0 tombstone.
|
||||
- #509 and #1125 get an honest, verifiable answer: real trained weights *do* exist
|
||||
(presence + MM-Fi pose are published and benchmarked), the *specific* file the reporter
|
||||
found is architecture-only, and the live ESP32 pose path is honestly weak-and-in-progress.
|
||||
- Reinforces the ADR-160 honest-labeling discipline: the project's credibility comes from
|
||||
precise labels, not from a suppressed or inflated narrative.
|
||||
|
||||
### Negative
|
||||
- The docs must openly state that the live single-ESP32 17-keypoint pose is not yet at a
|
||||
citable accuracy — a short-term "looks less finished" cost, paid for by not overclaiming.
|
||||
- Two more files to keep in sync (`DEPRECATED.md`, the tier table) as the checkpoints evolve.
|
||||
|
||||
### Neutral
|
||||
- No code or model behavior changes; `archive/v1` stays in the tree as a research archive
|
||||
(ADR-117 §1.3) and its ADR-028 proof witness is untouched.
|
||||
- Purely documentation/labeling; no crate, wheel, or firmware rebuild required.
|
||||
|
||||
## References
|
||||
|
||||
- `archive/v1/src/models/densepose_head.py` — `DensePoseHead`, random `_initialize_weights()`, no checkpoint load.
|
||||
- `archive/v1/README.md` — legacy note; no loud deprecation (target of P1).
|
||||
- `v2/crates/cog-pose-estimation/cog/README.md` — the "Honest reading" precedent (PCK@20 = 3.0%, runtime stub).
|
||||
- `v2/crates/cog-pose-estimation/cog/artifacts/{pose_v1.safetensors,pose_v1.onnx,train_results.json}` — committed first-cut pose model.
|
||||
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — committed count model.
|
||||
- `ruvnet/wifi-densepose-pretrained`, `ruvnet/wifi-densepose-mmfi-pose` — published, benchmarked checkpoints.
|
||||
- ADR-079 §Target (PCK@20 ≥35%), ADR-152 measurement (a) (96.09% PCK@20 external; internal 92.9% retracted), ADR-160 (honest-labeling method), ADR-117 (pip 1.99.0 tombstone).
|
||||
@@ -1,171 +0,0 @@
|
||||
# ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-07-18
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: realtek, rtl8720f, ameba, fmcw, radar, cfr, csi, hardware
|
||||
- **Relates to**: ADR-018, ADR-063, ADR-064, ADR-095, ADR-097, ADR-260, ADR-262
|
||||
|
||||
## Context
|
||||
|
||||
Realtek's `RTL8720F-2.4G-Radar-Advantages_EN.pptx` describes an RTL8720F mode that shares the
|
||||
2.4 GHz radio between Wi-Fi, Bluetooth, and an active FMCW radar. It offers two data products that
|
||||
are useful to RuView:
|
||||
|
||||
1. **CFR (Channel Frequency Report)**, described by Realtek as the same concept as Wi-Fi CSI.
|
||||
2. **Near and far Range-FFT reports**, preserving near-field content while extending observation to
|
||||
approximately 5–6 m.
|
||||
|
||||
The proposed radio uses one transmit and one receive antenna, 20/40/70 MHz sweeps, configurable
|
||||
8/16/32/64 microsecond chirp symbols, a maximum 2.56 ms FMCW packet, and a configurable frame
|
||||
interval above 15 ms. The deck recommends 40 MHz outside Japan and 20 MHz in Japan. It also
|
||||
describes EDCCA/CTS channel access, Wi-Fi/BT/radar time division, interference reporting, and
|
||||
priority arbitration in the driver.
|
||||
|
||||
This is not a drop-in replacement for ESP32 CSI:
|
||||
|
||||
- it is **active monostatic FMCW**, while the ESP32 path observes Wi-Fi packet CSI;
|
||||
- one Tx/one Rx has no angle-of-arrival or native multi-target separation;
|
||||
- the stated 40 MHz range resolution is about 3.15 m, despite a finer 0.59 m Range-FFT report step;
|
||||
- the presentation is a capability description, not an SDK contract. It contains no header names,
|
||||
function signatures, callback ABI, binary layouts, toolchain version, licensing terms, or public
|
||||
RTL8720F board package.
|
||||
|
||||
Realtek's public Ameba RTOS repository is the base. Release v1.2.1 includes the CSI API and fixes a
|
||||
CSI application-buffer semaphore issue, but does not expose the radar application surface. Open
|
||||
upstream PR #1336 (2026-07-18 snapshot) adds RTL8720F project artifacts, `AT+RAD`, `AT+RADDBG`, and
|
||||
the public configuration call `wifi_radar_config(struct rtw_radar_action_parm *)`. Its public
|
||||
parameter struct confirms mode, channel, 70/40/20 MHz bandwidth selector, trigger period, and
|
||||
enable/config actions. Report reception still crosses non-public/placeholder HAL symbols such as
|
||||
`wifi_hal_radar_recv_data(frame_num, frame_type, data)`, so the report layout and buffer lifetime
|
||||
remain vendor-gated. Therefore the integration stays split at that boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
RuView will support RTL8720F radar as an **optional, capability-negotiated source**, without
|
||||
replacing the ESP32 firmware or treating radar CFR as byte-compatible with ADR-018 CSI.
|
||||
|
||||
The integration has three layers:
|
||||
|
||||
1. **Realtek device firmware**: a small application built in the vendor-supported Ameba SDK calls
|
||||
the radar API, owns coexistence configuration, and emits versioned reports. This code lives under
|
||||
`firmware/rtl8720f-radar/` only after the redistributable SDK/API is available.
|
||||
2. **Transport-neutral wire contract**: CFR and Range-FFT reports are framed independently from the
|
||||
vendor ABI and sent over UDP, USB CDC, or UART. ADR-264 defines this boundary.
|
||||
3. **Rust host adapter**: `wifi-densepose-hardware` parses reports from bytes and converts CFR into
|
||||
the existing CSI-domain representation, while Range-FFT remains a radar modality and feeds the
|
||||
RuField/RuView cross-modality bridge from ADR-260/262.
|
||||
|
||||
The two report types remain semantically distinct:
|
||||
|
||||
| RTL8720F output | RuView representation | Permitted use |
|
||||
|---|---|---|
|
||||
| CFR | `CsiFrame` through a Realtek calibration adapter | CSI feature extraction after validation |
|
||||
| Range-FFT near/far | `RadarFrame` / RuField `mmwave_radar`-class event with a 2.4 GHz descriptor | range, motion, presence, fusion |
|
||||
| Vendor AI presence probability | derived observation with model/version provenance | advisory input, never ground truth |
|
||||
| Interference report | quality/provenance metadata | reject, down-weight, or mark contaminated frames |
|
||||
|
||||
The modality registry should eventually distinguish `fmcw_radar_2_4ghz` from `mmwave_radar`; until
|
||||
that RuField schema revision is accepted, the adapter must attach `carrier_hz = 2.4e9` and must not
|
||||
claim millimetre-wave provenance.
|
||||
|
||||
## Delivery phases and gates
|
||||
|
||||
### P0 — Vendor enablement
|
||||
|
||||
Obtain the PR #1336-or-newer RTL8720F SDK package, radar API headers/libraries, a supported evaluation
|
||||
board, flashing/debug instructions, report definitions, and written redistribution terms.
|
||||
|
||||
**Gate:** compile and run Realtek's unmodified radar example and capture CFR plus near/far
|
||||
Range-FFT output. Until this passes, device firmware is `VENDOR_BLOCKED`, not implemented.
|
||||
|
||||
### P1 — Host-first contract
|
||||
|
||||
Implement ADR-264 types, parsers, fixtures, fuzz tests, and replay support without linking vendor
|
||||
code. Use the Rust `Rtl8720fSimulator` as the only pre-hardware live source. It emits deterministic
|
||||
CFR, near/far Range-FFT, interference, and capabilities frames through the same ADR-264 encoder and
|
||||
parser used by hardware. Every simulated frame sets `RadarFlags::SYNTHETIC`; simulation results are
|
||||
never reported as device measurements.
|
||||
|
||||
**Gate:** malformed inputs never panic; encode/decode round trips; unknown versions and report
|
||||
types fail closed.
|
||||
|
||||
### P2 — RTL8720F firmware adapter
|
||||
|
||||
Wrap only the minimum vendor API surface: initialization, profile configuration, start/stop,
|
||||
callback acquisition, interference status, and report serialization. Keep vendor types out of the
|
||||
wire protocol.
|
||||
|
||||
**Gate:** 30-minute simultaneous Wi-Fi telemetry and radar capture with no watchdog reset, bounded
|
||||
loss, monotonic sequence numbers, and explicit coexistence/interference statistics.
|
||||
|
||||
### P3 — Calibration and signal validation
|
||||
|
||||
Calibrate CFR phase/amplitude, Range-FFT bin spacing, static leakage, and clock drift. Compare
|
||||
reported range against measured targets at multiple distances and bandwidths.
|
||||
|
||||
**Gate:** publish measured error distributions. Do not infer accuracy from report-bin spacing and
|
||||
do not advertise multi-person pose or vital signs from the vendor deck.
|
||||
|
||||
### P4 — Fusion and productization
|
||||
|
||||
Feed calibrated CFR through the CSI path and Range-FFT through RuField, retaining source, mode,
|
||||
bandwidth, calibration, firmware, and interference provenance.
|
||||
|
||||
**Gate:** ablation shows whether the radar stream improves a named RuView metric over ESP32 CSI
|
||||
alone. If it does not, ship it only as an independent presence/range sensor.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- One low-cost radio can provide active radar and CSI-like CFR while retaining Wi-Fi connectivity.
|
||||
- Range-FFT adds an independent physical measurement for presence/range fusion.
|
||||
- The vendor SDK is isolated from the Rust sensing core and from the stable on-wire contract.
|
||||
- Capability negotiation permits future Realtek parts without another application-level fork.
|
||||
|
||||
### Negative
|
||||
|
||||
- The first implementation is blocked on access to the actual RTL8720F radar SDK/API and hardware.
|
||||
- Active 2.4 GHz transmission changes coexistence, privacy, power, and regional compliance concerns.
|
||||
- 1T1R and limited sweep bandwidth cannot provide the spatial resolution of multi-antenna mmWave.
|
||||
- A second embedded toolchain and firmware release process must be maintained.
|
||||
|
||||
### Neutral
|
||||
|
||||
- ESP32 remains the default CSI node.
|
||||
- Existing consumers receive normalized frames and do not link against Realtek code.
|
||||
- Vendor AI output is optional metadata; RuView retains responsibility for its own validation.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
1. **Map Range-FFT directly to `CsiFrame`.** Rejected because range bins and channel-frequency
|
||||
samples have different axes and physical meaning.
|
||||
2. **Link the Realtek SDK into the Rust server.** Rejected because it couples host builds to a
|
||||
proprietary embedded ABI and toolchain.
|
||||
3. **Wait to define any interface until hardware arrives.** Rejected because the host protocol,
|
||||
parser safety, replay, and provenance can be developed and reviewed independently.
|
||||
4. **Replace ESP32 nodes.** Rejected because the modes are complementary and availability differs.
|
||||
|
||||
## Open vendor questions
|
||||
|
||||
- Exact RTL8720F part/board identifier and production availability.
|
||||
- SDK repository/tag, compiler, RTOS, binary blobs, license, and redistribution permissions.
|
||||
- Radar initialization/configuration/callback API signatures and threading/ISR constraints.
|
||||
- CFR and near/far Range-FFT element type, complex ordering, scaling, endianness, and timestamps.
|
||||
- Whether CFR is calibrated complex data and whether phase remains coherent across frames.
|
||||
- Maximum report rates, buffer ownership, DMA/cache constraints, and Wi-Fi throughput impact.
|
||||
- Region/channel enforcement and whether 70 MHz operation is allowed by the supplied firmware.
|
||||
- Secure boot, signed OTA, unique device identity, and firmware attestation support.
|
||||
|
||||
## Sources
|
||||
|
||||
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 3 and 10–19,
|
||||
supplied 2026-07-18. This is product material, not measured RuView validation.
|
||||
- [Ameba-AIoT/ameba-rtos releases](https://github.com/Ameba-AIoT/ameba-rtos/releases), reviewed
|
||||
2026-07-18; v1.2.1 is the current QC release and includes a CSI buffer-semaphore fix.
|
||||
- [Ameba-AIoT/ameba-rtos PR #1336](https://github.com/Ameba-AIoT/ameba-rtos/pull/1336), reviewed
|
||||
2026-07-18; exposes RTL8720F build assets, `wifi_radar_config`, and radar AT commands while report
|
||||
internals remain in binary/private layers.
|
||||
- ADR-063 (mmWave sensor fusion), ADR-095/097 (source normalization), and ADR-260/262 (RuField
|
||||
multimodal event model and live bridge).
|
||||
@@ -1,191 +0,0 @@
|
||||
# ADR-263: `@ruvnet/ruview` npm Harness — Deep Review + Optimization Strategy
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (O1–O9, `@ruvnet/ruview@0.2.0`): fail-closed `claim-check`, async MCP dispatch (ping answered mid-`verify`, pinned by e2e test), zero-dependency install, bounded output tails, argv-passed monitor port, package.json-sourced version, prepack skill sync, memoized `which()`, underscore-canonical tools with dotted aliases, word-boundary guardrail matching. 30/30 tests (MEASURED, `node --test test/*.test.mjs`); CI gate in ADR-265's `npm-packages.yml` |
|
||||
| **Date** | 2026-07-02 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **RUVIEW-NPM-REVIEW-1** |
|
||||
| **Supersedes / amends** | none (records review of the ADR-182 P1+P2 artifact; feeds ADR-265 distribution strategy) |
|
||||
|
||||
## Context
|
||||
|
||||
ADR-182 minted and published **`@ruvnet/ruview@0.1.0`** (`harness/ruview/`) — the
|
||||
`npx ruview` operator harness: a dependency-free ESM CLI + minimal MCP stdio server
|
||||
exposing six `ruview.*` tools (onboard / claim_check / verify / node_monitor /
|
||||
calibrate / node_flash), five skill playbooks, and the executable
|
||||
MEASURED-vs-CLAIMED guardrail (`src/guardrails.js`). The package is live on npm
|
||||
(0.1.0, 49.5 kB unpacked / 21 files — MEASURED, `npm view @ruvnet/ruview` +
|
||||
`npm pack --dry-run`) and is the recommended MCP registration path
|
||||
(`npx -y @ruvnet/ruview mcp start` in the bundled `.claude/settings.json`).
|
||||
|
||||
This ADR is the first dedicated deep review of that npm artifact: correctness,
|
||||
fail-open/fail-closed posture, performance (cold start + request handling),
|
||||
packaging hygiene, and security of the subprocess surface. All 17 bundled tests
|
||||
pass on Node 22 (MEASURED, `node --test test/*.test.mjs`, 17/17, ~108 ms).
|
||||
|
||||
## Findings
|
||||
|
||||
Severity reflects impact on the package's stated contract: *fail-closed operator
|
||||
tools + an honesty guardrail that must never fail open*.
|
||||
|
||||
### F1 (HIGH, fail-open): `claim-check` passes silently on empty input
|
||||
|
||||
`bin/cli.js` `claim-check` with **neither `--text` nor `--file`** sends
|
||||
`text: undefined` → `claimCheck(String(args.text ?? ''))` → `''` → `ok: true`,
|
||||
**exit 0**. A CI hook wired as `npx ruview claim-check --text "$BODY"` where
|
||||
`$BODY` expands empty therefore reports PASS. This is the single tool whose whole
|
||||
purpose is to fail closed; empty input must be an error, not a pass.
|
||||
Reproducer: `node bin/cli.js claim-check` → `{"ok": true}`, exit 0.
|
||||
|
||||
### F2 (HIGH, head-of-line blocking): MCP server is fully synchronous
|
||||
|
||||
`src/mcp-server.js` dispatches `tools/call` inside the readline `line` handler,
|
||||
and every heavyweight handler in `src/tools.js` uses **`spawnSync`**
|
||||
(`ruview.verify` up to 180 s, `ruview.calibrate` up to 300–600 s,
|
||||
`ruview.node_monitor` up to `seconds+10`). While one call runs, the event loop is
|
||||
blocked: `ping`, `tools/list`, and concurrent `tools/call` requests are not even
|
||||
read from stdin. Hosts that health-check with `ping` during a long `calibrate`
|
||||
will conclude the server is dead and kill it mid-run.
|
||||
|
||||
### F3 (MEDIUM, cold start): optionalDependencies triple the `npx` install for a path that never uses them
|
||||
|
||||
`package.json` declares `optionalDependencies` on `@metaharness/kernel` and
|
||||
`@metaharness/host-claude-code`. npm installs optional deps **by default**, so
|
||||
every cold `npx -y @ruvnet/ruview mcp start` fetches 3 extra packages (kernel +
|
||||
host + transitive `@ruvector/emergent-time`). MEASURED (npm 10.9.7, this
|
||||
container): default install = **4 packages, 620 kB, 71 files**; with
|
||||
`--omit=optional` = **1 package, 172 kB, 22 files**. The operator-tool and MCP
|
||||
paths never import these — only `doctor`/`install` do, and both already
|
||||
dynamic-import inside `try/catch` and degrade gracefully when absent
|
||||
(`kernel/host: not installed (ok…)`). The optional deps buy nothing on the hot
|
||||
path and cost 3 registry round-trips + ~450 kB on every cold start.
|
||||
|
||||
### F4 (MEDIUM, silent truncation): `spawnSync` default `maxBuffer` (1 MiB)
|
||||
|
||||
`run()` in `src/tools.js` never sets `maxBuffer`. `cargo run -p
|
||||
wifi-densepose-cli` (the `calibrate` fallback path) and a chatty `verify.py` can
|
||||
exceed 1 MiB of stdout, at which point the child is killed with `ENOBUFS` and the
|
||||
tool reports a spawn error that looks like a proof/calibration failure. The
|
||||
handlers only ever consume the last 8 kB/1.5 kB; buffering should be bounded but
|
||||
generous (e.g. `maxBuffer: 16 MiB`) or streamed with a tail ring.
|
||||
|
||||
### F5 (MEDIUM, injection surface): `node_monitor` interpolates the port into Python source
|
||||
|
||||
The handler builds a `python -c` script by string interpolation:
|
||||
`` `ser=serial.Serial(${JSON.stringify(port)},115200,…)` `` and
|
||||
`` `while time.time()-t<${dur}:` ``. `JSON.stringify` produces a *JavaScript*
|
||||
string literal; Python string-literal semantics differ at the edges (`\uXXXX` is
|
||||
shared, but e.g. JS emits raw U+2028/U+2029 unescaped pre-ES2019 rules aside, and
|
||||
any future non-JSON-safe field added the same way would be executable). `port`
|
||||
arrives from the MCP caller (an agent), so this is an agent-controlled string
|
||||
concatenated into an interpreter invocation. `dur` is `Number()`-guarded; `port`
|
||||
should be passed out-of-band (`sys.argv`/env), never spliced into source.
|
||||
|
||||
### F6 (LOW, drift): server version hardcoded
|
||||
|
||||
`SERVER_INFO = { name: 'ruview', version: '0.1.0' }` in `src/mcp-server.js`
|
||||
duplicates `package.json.version` (the CLI's `--version` already reads
|
||||
package.json at runtime). First release bump will drift the MCP handshake
|
||||
version.
|
||||
|
||||
### F7 (LOW, duplication): every skill ships twice
|
||||
|
||||
`skills/*.md` and `.claude/skills/*/SKILL.md` are byte-identical (same sha256 in
|
||||
`.harness/manifest.json`). ~8 kB of the 49.5 kB unpacked payload is duplicate
|
||||
content, and — worse than size — two copies must be kept in sync by hand.
|
||||
|
||||
### F8 (LOW, perf + portability): `which()` is uncached and shells out
|
||||
|
||||
`which()` runs up to twice per tool call (`python` then `python3`), each a
|
||||
blocking `spawnSync`; the POSIX branch spawns a shell (`shell: true`). Results
|
||||
are stable for the process lifetime and should be memoized; the lookup can be
|
||||
done dep-free with a PATH scan instead of a shell.
|
||||
|
||||
### F9 (LOW, interop): dot-named tools + minimal protocol surface
|
||||
|
||||
Tool names (`ruview.onboard`, `ruview.claim_check`, …) contain dots. MCP itself
|
||||
does not restrict names, but downstream host APIs commonly enforce
|
||||
`^[a-zA-Z0-9_-]{1,64}$` for tool names; hosts must then sanitize or reject.
|
||||
The server also answers `resources/list` / `prompts/list` with `-32601` (it does
|
||||
not advertise those capabilities, so this is spec-legal, but empty-list stubs are
|
||||
cheaper than every host's error path). Protocol version is pinned to
|
||||
`2024-11-05` with no negotiation fallback. None of this breaks Claude Code today;
|
||||
it narrows portability, which is the harness's whole pitch (9 hosts, ADR-182).
|
||||
|
||||
### F10 (LOW, CI gap): the published package has zero CI
|
||||
|
||||
No workflow under `.github/workflows/` runs `harness/ruview` tests (checked:
|
||||
no workflow references `harness/ruview`, `ruview-mcp`, or `ruview-cli`), and
|
||||
`ci.yml` pins `NODE_VERSION: '18'` while the package declares
|
||||
`engines.node >= 20`. Note also `node --test test/` (directory form) fails on
|
||||
Node 22 while the documented glob form passes — CI should pin the working
|
||||
invocation. Consolidated CI/publish strategy is ADR-265.
|
||||
|
||||
### F11 (MEDIUM, guardrail precision): `METRIC_TERMS` substring matching false-positives on ordinary prose
|
||||
|
||||
Found by dogfooding this review: `claimCheck` matches metric terms with
|
||||
`lower.includes(t)`, so the two-character terms `'map'` and `'f1'` fire inside
|
||||
ordinary words and labels — "source **map**s", "the **map**s can never
|
||||
resolve", finding IDs like "**F1** (HIGH…)". MEASURED reproducer: running
|
||||
`npx ruview claim-check --file` over this ADR and ADR-264 yields 4 and 16
|
||||
medium findings respectively, the majority of which are `map`/`F1`
|
||||
false positives on lines carrying no accuracy claim. A guardrail that cries
|
||||
wolf trains people to ignore it — precision is part of its fail-closed
|
||||
contract. Short/ambiguous terms need word-boundary matching (`\bmap\b`,
|
||||
`\bf1\b`, likewise `auc`, `iou`), and section-heading label patterns
|
||||
(`F\d+`, `O\d+`) should not count as metric mentions.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt the following optimization strategy, in priority order. Each item is
|
||||
independently shippable; F-numbers map to findings.
|
||||
|
||||
- **O1 (F1):** `claim-check` with no `--text`/`--file` (or empty text after read)
|
||||
exits 2 with a usage error. Add a regression test pinning exit ≠ 0.
|
||||
- **O2 (F2):** make the MCP dispatch async: convert `run()`/`which()` to
|
||||
promise-based `spawn`, make `tools/call` handlers `async`, and keep reading
|
||||
stdin while calls run (respond to `ping`/`tools/list` concurrently; serialize
|
||||
only same-tool hardware operations). Acceptance: `ping` round-trips < 50 ms
|
||||
while a synthetic 30 s `calibrate` is in flight.
|
||||
- **O3 (F3):** drop the two `optionalDependencies`; `doctor`/`install` already
|
||||
degrade and should print the exact `npm i @metaharness/kernel
|
||||
@metaharness/host-claude-code` hint on the miss path. Acceptance: cold
|
||||
`npm i @ruvnet/ruview` installs exactly 1 package (MEASURED baseline above).
|
||||
- **O4 (F4):** set `maxBuffer: 16 * 1024 * 1024` in `run()` (or stream + tail).
|
||||
- **O5 (F5):** pass `port` to the monitor script via `sys.argv`
|
||||
(`python -c script -- <port>`), never by source interpolation.
|
||||
- **O6 (F6):** read the MCP `serverInfo.version` from `package.json` once at
|
||||
startup (same pattern the CLI already uses).
|
||||
- **O7 (F7):** make `skills/*.md` the single source and generate
|
||||
`.claude/skills/*/SKILL.md` in a `prepack` script (or vice versa); manifest
|
||||
hashes then pin one canonical set.
|
||||
- **O8 (F8, F9):** memoize `which()`; add underscore aliases for the dot-named
|
||||
tools (accept both in `tools/call`, advertise the underscore form) and add
|
||||
empty `resources/list` / `prompts/list` stubs.
|
||||
- **O9 (F11):** switch `METRIC_TERMS` matching to word-boundary regexes for
|
||||
short terms (`map`, `f1`, `auc`, `iou`) and skip label tokens matching
|
||||
`\b[FO]\d+\b`. Acceptance: `claim-check --file` over ADR-263/264/265 reports
|
||||
only the genuinely tagged-or-taggable percentage lines, and the existing 17
|
||||
guardrail tests still pass plus new false-positive pins ("source maps",
|
||||
"F1 (HIGH)" → no finding).
|
||||
|
||||
Non-goals: no new runtime dependencies (the zero-dep MCP server is a feature,
|
||||
not an accident — keep it), no build step, no change to the fail-closed tool
|
||||
contracts.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The honesty guardrail becomes fail-closed end-to-end (its current empty-input
|
||||
pass is the exact failure mode the guardrail exists to prevent).
|
||||
- `npx` cold start drops ~450 kB / 3 packages (MEASURED baseline in F3) with no
|
||||
feature loss; `doctor` output already communicates the optional-dep story.
|
||||
- Long-running `verify`/`calibrate` no longer starve the MCP channel — the
|
||||
harness survives host health checks during real calibration runs.
|
||||
- Two-copy skill drift becomes impossible at pack time.
|
||||
- Costs: async conversion touches every handler signature in `src/tools.js`
|
||||
(mechanical, ~6 handlers); alias tools add a small compatibility table.
|
||||
- Verification for the implementing PR: bundled tests extended for O1/O2/O5
|
||||
(target ≥ 20 tests), `npm pack --dry-run` file-count asserted, and the F3
|
||||
install measurement re-run and quoted MEASURED in the PR body — which must
|
||||
itself pass `npx ruview claim-check`.
|
||||
@@ -1,148 +0,0 @@
|
||||
# ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-07-18
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: realtek, rtl8720f, protocol, cfr, range-fft, udp, serial
|
||||
- **Depends on**: ADR-263
|
||||
- **Relates to**: ADR-018, ADR-095, ADR-097, ADR-099, ADR-260
|
||||
|
||||
## Context
|
||||
|
||||
ADR-263 adopts RTL8720F radar behind an anti-corruption boundary. The Realtek presentation names
|
||||
CFR, near Range-FFT, far Range-FFT, and interference reports, but does not specify their binary ABI.
|
||||
RuView needs a stable, testable contract that can be implemented before the vendor SDK arrives and
|
||||
that will not expose vendor structs, pointer layouts, padding, or callback lifetime rules over the
|
||||
network.
|
||||
|
||||
ADR-018 already defines ESP32 CSI framing. Reusing its magic or pretending that Realtek radar is an
|
||||
ESP32 packet would make source detection ambiguous and erase radar-specific calibration metadata.
|
||||
|
||||
## Decision
|
||||
|
||||
Define a new little-endian `RtlRadarFrameV1` envelope with its own magic and explicit payload type.
|
||||
This is a RuView protocol, not a claim about Realtek's native memory layout.
|
||||
|
||||
### Envelope
|
||||
|
||||
All integer fields are little-endian. Floating-point payloads use IEEE-754 binary32. No C struct is
|
||||
sent by `memcpy`; firmware serializes each field explicitly.
|
||||
|
||||
| Offset | Size | Field | Meaning |
|
||||
|---:|---:|---|---|
|
||||
| 0 | 4 | magic | ASCII `RTR1` (`0x31525452`) |
|
||||
| 4 | 1 | version | `1` |
|
||||
| 5 | 1 | report_type | 1 CFR, 2 range-near, 3 range-far, 4 interference, 5 capabilities |
|
||||
| 6 | 2 | header_len | complete header size, initially 56 |
|
||||
| 8 | 4 | frame_len | header + payload + CRC |
|
||||
| 12 | 4 | sequence | wraps modulo 2^32 |
|
||||
| 16 | 8 | timestamp_us | monotonic device time at acquisition |
|
||||
| 24 | 8 | device_id | stable pseudonymous identifier, not a MAC address |
|
||||
| 32 | 4 | center_freq_khz | RF centre frequency |
|
||||
| 36 | 2 | bandwidth_mhz | 20, 40, or 70 |
|
||||
| 38 | 2 | flags | calibration/interference/saturation/time-sync flags |
|
||||
| 40 | 2 | element_count | complex samples or range bins |
|
||||
| 42 | 1 | element_format | 0 bytes/TLV, 1 complex-i16, 2 complex-f32, 3 power-u16, 4 power-f32 |
|
||||
| 43 | 1 | antenna_count | expected to be 1 for the deck's 1T1R configuration |
|
||||
| 44 | 4 | scale | quantized-to-physical multiplier; `1.0` for float payloads |
|
||||
| 48 | 4 | bin_spacing | Hz for CFR, metres for Range-FFT |
|
||||
| 52 | 4 | calibration_id | device calibration revision/hash prefix |
|
||||
| 56 | variable | payload | determined by type, count, and format |
|
||||
| final-4 | 4 | crc32 | IEEE CRC-32 over header and payload |
|
||||
|
||||
If vendor evidence shows that 56 bytes is too costly, a later protocol version may introduce a
|
||||
compact header. V1 favors auditable provenance over premature byte savings.
|
||||
|
||||
### Payload semantics
|
||||
|
||||
- **CFR** contains ordered complex channel-frequency samples. The adapter must know the frequency
|
||||
origin/order and must not fabricate missing phase. Uncalibrated frames carry the uncalibrated flag
|
||||
and cannot enter phase-sensitive processing.
|
||||
- **Range-near/range-far** contains ordered range bins. Near and far are separate report types so
|
||||
filtering and leakage behavior are never hidden from consumers.
|
||||
- **Interference** contains a versioned TLV set for channel-busy, detected-during-chirp, estimated
|
||||
interference power, and packet jitter. Unknown TLVs are skipped by length.
|
||||
- **Capabilities** is emitted at boot and on request. It declares supported report types, bandwidths,
|
||||
chirp lengths, maximum elements/report, maximum frame rate, firmware version, and SDK identifier.
|
||||
|
||||
### Transport
|
||||
|
||||
The identical envelope is supported over:
|
||||
|
||||
- UDP datagrams for normal RuView ingestion;
|
||||
- USB CDC or UART with COBS framing and a zero-byte delimiter;
|
||||
- file replay as a length-prefixed sequence of envelopes.
|
||||
|
||||
One envelope must fit one UDP datagram. Fragmentation is not part of V1; firmware rejects a profile
|
||||
whose maximum report exceeds the configured MTU and reports the required size through capabilities.
|
||||
|
||||
### Parser and trust rules
|
||||
|
||||
The host parser:
|
||||
|
||||
1. validates magic, version, lengths, enum values, element count/format multiplication, and CRC
|
||||
before allocating or decoding the payload;
|
||||
2. caps frames at 64 KiB and elements at a configured hardware maximum;
|
||||
3. rejects non-finite float metadata/payload values;
|
||||
4. tracks sequence gaps and timestamp regressions per device;
|
||||
5. preserves unknown flags but never interprets them as trusted;
|
||||
6. attaches transport source, firmware/SDK version, calibration ID, and interference state to
|
||||
provenance;
|
||||
7. labels fixture/generated frames as synthetic.
|
||||
|
||||
No vendor-provided presence probability bypasses RuView privacy, provenance, or quality gates.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Firmware, transport, parser, replay, and fusion can evolve independently.
|
||||
- Fuzzing and golden fixtures require no Realtek SDK or board.
|
||||
- CFR and Range-FFT retain correct axes and calibration provenance.
|
||||
- A boot-time capabilities frame makes SDK/API drift observable.
|
||||
|
||||
### Negative
|
||||
|
||||
- Serialization adds CPU and bandwidth overhead compared with dumping a vendor buffer.
|
||||
- V1 fields may need revision after the actual API and report limits are disclosed.
|
||||
- UDP provides integrity/error detection, not authenticity or confidentiality.
|
||||
|
||||
### Neutral
|
||||
|
||||
- Authentication can be layered with ADR-032 device identity or a signed RuField receipt without
|
||||
changing report semantics.
|
||||
- ESP32 ADR-018 framing remains unchanged.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Add `rtl8720f` types/parser module to `wifi-densepose-hardware` behind no vendor dependency.
|
||||
2. Add golden CFR, near/far Range-FFT, interference, and capabilities fixtures.
|
||||
3. Add property/fuzz tests for length arithmetic, enum handling, CRC, and float validation.
|
||||
4. Add a replay CLI that prints normalized metadata without running inference.
|
||||
5. Once SDK access exists, implement the embedded serializer and verify captured frames against the
|
||||
host golden decoder.
|
||||
6. Revise this proposed ADR with measured element counts, rates, and API names before acceptance.
|
||||
|
||||
Host-side steps 1–3 are implemented in `wifi-densepose-hardware::rtl8720f`: typed report and
|
||||
element enums, semantic type/format validation, bounded length arithmetic, CRC verification,
|
||||
finite-float checks, encode/decode round trips, corruption/truncation tests, and deterministic
|
||||
arbitrary-input panic checks. Cross-language vectors remain blocked on the vendor SDK callback ABI.
|
||||
Bit 15 of `flags` is reserved by RuView as `SYNTHETIC`; the Rust simulator always sets it and real
|
||||
firmware must never set it. The simulator is deterministic by seed and exercises the production
|
||||
encoder/parser rather than a parallel mock representation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Rust encode/decode round-trip for every report type.
|
||||
- Cross-language golden vector produced by the RTL8720F firmware.
|
||||
- Zero parser panics over the fuzz corpus and arbitrary byte input.
|
||||
- Detection of single-bit corruption, truncation, count overflow, timestamp regression, and gaps.
|
||||
- Captured CFR frequency order and Range-FFT bin spacing verified against vendor documentation and a
|
||||
measured target.
|
||||
|
||||
## Sources
|
||||
|
||||
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 11–19, supplied
|
||||
2026-07-18.
|
||||
- ADR-018 (ESP32 framing), ADR-095/097 (hardware normalization), ADR-260 (multimodal event model),
|
||||
and ADR-263 (platform decision).
|
||||
@@ -1,169 +0,0 @@
|
||||
# ADR-264: `@ruvnet/rvagent` MCP Server + `@ruv/ruview-cli` — Deep Review + Optimization Strategy
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (O1–O9, `@ruvnet/rvagent@0.2.0`): `exports` fixed (types-first, no phantom `.cjs`), map-free tarball (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, from 188 kB), Streamable HTTP **wired** behind `RVAGENT_HTTP_PORT` with per-session transports + 1 MiB body cap + port-aware origin gate, underscore tool names with dotted router aliases, single Zod validation gate with generated JSON Schemas, fd-leak fixed + persisted job records + bounded log tails, probing `detectCogBinary`, package.json-sourced version, `ruview-cli` bin renamed. 99/99 jest tests (MEASURED); both transports smoke-tested live |
|
||||
| **Date** | 2026-07-02 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **RUVIEW-NPM-REVIEW-2** |
|
||||
| **Supersedes / amends** | none (reviews the ADR-104/ADR-124 artifacts; feeds ADR-265 distribution strategy) |
|
||||
|
||||
## Context
|
||||
|
||||
Two TypeScript npm packages expose RuView sensing to agents and shells:
|
||||
|
||||
- **`@ruvnet/rvagent@0.1.0`** (`tools/ruview-mcp/`) — SENSE-BRIDGE, the MCP
|
||||
server over the sensing-server HTTP API + cog binaries: 12 tools
|
||||
(csi/pose/count/registry/train/job + ADR-124 BFLD/presence/vitals). Published
|
||||
(188 kB unpacked — MEASURED, `npm view @ruvnet/rvagent`). Deps:
|
||||
`@modelcontextprotocol/sdk` + `zod`.
|
||||
- **`@ruv/ruview-cli@0.0.1`** (`tools/ruview-cli/`) — `private: true` yargs CLI
|
||||
mirroring the same capabilities; intentionally duplicates `http.ts`/`cog.ts`/
|
||||
`config.ts` (~150 lines) to stay standalone.
|
||||
|
||||
This ADR records a deep review of both: packaging correctness (verified against
|
||||
the **published** tarball, not just the source tree), protocol/interop, resource
|
||||
lifecycle, and the honesty of the package's own self-description — the same
|
||||
MEASURED-vs-CLAIMED bar the project applies to accuracy numbers.
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 (HIGH, broken export): `require` condition points at a file that does not exist
|
||||
|
||||
`package.json` `exports["."].require = "./dist/index.cjs"`, but the build is
|
||||
plain `tsc` (ESM only) and **the published 0.1.0 tarball contains no
|
||||
`index.cjs`** (verified by listing the registry tarball). Any CJS consumer doing
|
||||
`require('@ruvnet/rvagent')` resolves to a nonexistent file →
|
||||
`ERR_MODULE_NOT_FOUND`. Additionally the `types` condition is listed **after**
|
||||
`import`/`require`; TypeScript requires `types` first or it may be ignored under
|
||||
`moduleResolution: bundler/node16`.
|
||||
|
||||
### F2 (MEDIUM, tarball bloat): a third of the published package is dead source maps
|
||||
|
||||
The 0.1.0 tarball ships **44 `.map` files = 62,698 B** against 78,209 B of
|
||||
actual `.js` (MEASURED, extracted registry tarball). `src/` is not published, so
|
||||
every `sourceMappingURL` points at `../src/*.ts` that consumers do not have —
|
||||
the maps can never resolve. Also `files` lists `CHANGELOG.md`, which does not
|
||||
exist in `tools/ruview-mcp/` (npm silently skips it), so the advertised file set
|
||||
is partly fictional.
|
||||
|
||||
### F3 (MEDIUM, honesty): the package description claims a transport it does not start
|
||||
|
||||
The description reads "**dual-transport MCP server (stdio + Streamable HTTP)**",
|
||||
but `main()` in `src/index.ts` wires **stdio only**. `http-transport.ts` is a
|
||||
complete, tested scaffold that nothing imports at runtime — there is no flag,
|
||||
env var, or subcommand that starts it. By this project's own rule this is a
|
||||
CLAIMED capability presented as shipped. Either wire it (`--http` /
|
||||
`RVAGENT_HTTP_PORT` gate) or de-claim the description until it is.
|
||||
|
||||
### F4 (MEDIUM, interop + inconsistency): two tool-naming conventions, one of them dot-based
|
||||
|
||||
Six tools use `ruview_snake_case`; six (ADR-124 additions) use
|
||||
`ruview.dotted.names`. Same interop caveat as ADR-263 F9 (host tool-name
|
||||
regexes commonly `^[a-zA-Z0-9_-]{1,64}$`), plus the split convention makes the
|
||||
tool surface look like two products. Standardize on underscores and accept the
|
||||
dotted forms as aliases for one deprecation cycle.
|
||||
|
||||
### F5 (MEDIUM, double work + drift): every tool input is validated twice from two hand-maintained schemas
|
||||
|
||||
`CallToolRequestSchema` handler runs `TOOL_INPUT_SCHEMAS[name].safeParse(args)`,
|
||||
then each tool handler runs its own `schema.parse(args)` again — two full Zod
|
||||
passes per call. Separately, the `inputSchema` JSON advertised via `tools/list`
|
||||
is **hand-written** and duplicates the Zod schema field-by-field (defaults,
|
||||
min/max, descriptions) — schema drift between what is advertised and what is
|
||||
enforced is a matter of time. Parse once at the gate, pass the typed result to
|
||||
handlers, and generate the advertised JSON Schema from the Zod source
|
||||
(`zod-to-json-schema` at build time, or Zod 4's native `z.toJSONSchema` when the
|
||||
SDK's peer range allows).
|
||||
|
||||
### F6 (MEDIUM, resource lifecycle): `train_count` leaks 2 fds per job; job registry is process-local
|
||||
|
||||
`trainCount` opens `logFdOut`/`logFdErr` with `openSync` and never closes them
|
||||
in the parent — the spawned cargo child inherits duplicates, but the parent's
|
||||
descriptors stay open for the MCP server's lifetime: 2 leaked fds per training
|
||||
job. `jobRegistry` is an in-memory `Map`, so `ruview_job_status` after a server
|
||||
restart reports "not found" for a training run that is still burning GPU (the
|
||||
source comments acknowledge this; the fix — persist `~/.ruview/jobs/<id>.json`,
|
||||
already the documented layout — is small). Also `jobStatus` re-`import`s
|
||||
`node:fs` on every poll and reads the entire log to return 20 lines.
|
||||
|
||||
### F7 (MEDIUM, security/robustness of the HTTP scaffold): unbounded body + one shared session transport
|
||||
|
||||
`http-transport.ts` buffers the request body with no size cap (memory DoS the
|
||||
moment it is wired to a socket), reuses a **single**
|
||||
`StreamableHTTPServerTransport` with `sessionIdGenerator` for all clients (the
|
||||
SDK's stateful mode expects one transport per session — a second client's
|
||||
`initialize` collides), and the Origin allowlist is exact-match
|
||||
(`http://localhost` will not match a real browser origin `http://localhost:5173`).
|
||||
Must be fixed **before** F3 wires it in; bearer-token + 127.0.0.1 defaults are
|
||||
already right.
|
||||
|
||||
### F8 (LOW, dead/misleading code): `detectCogBinary` always returns the bare name
|
||||
|
||||
It builds a 4-candidate appliance-path array and then returns
|
||||
`candidates[candidates.length - 1]` — i.e. always `name` — without checking
|
||||
existence. The candidates are dead weight that reads as if path detection
|
||||
happens. Either probe with `existsSync` or delete the array.
|
||||
|
||||
### F9 (LOW, drift + hygiene): hardcoded versions, unused/mismatched devDeps, bin-name collision
|
||||
|
||||
`PACKAGE_VERSION = "0.1.0"` (index.ts) duplicates package.json;
|
||||
`@types/express` is unused (`http-transport` uses `node:http`); `@types/jest@30`
|
||||
against `jest@29`; `ruview-cli` hardcodes `.version("0.0.1")`. And
|
||||
`@ruv/ruview-cli` claims the **`ruview`** bin name, which collides with
|
||||
`@ruvnet/ruview`'s bin (ADR-182) if both are ever installed globally —
|
||||
ADR-263/265 give the `ruview` name to the harness; the CLI must rename or fold.
|
||||
|
||||
## Decision
|
||||
|
||||
- **O1 (F1):** fix `exports`: drop the `require` condition (ESM-only is fine for
|
||||
a bin-first package) or add a real CJS build; put `types` first. Add a CI
|
||||
smoke test that does `npm pack` + `node -e "import('<tarball install>')"`.
|
||||
- **O2 (F2):** publish without maps: `declarationMap: false`, `sourceMap: false`
|
||||
in a `tsconfig.build.json` used by `prepack` (or add `!dist/**/*.map` to
|
||||
`files`). Remove the phantom `CHANGELOG.md` entry or create the file.
|
||||
Acceptance: unpacked size ≤ ~125 kB (from 188 kB — MEASURED, `npm pack --dry-run`).
|
||||
- **O3 (F3, F7):** wire the HTTP transport behind an explicit opt-in
|
||||
(`RVAGENT_HTTP_PORT` or `--http`), after F7 fixes: per-session transport map
|
||||
keyed by `mcp-session-id`, 1 MiB body cap, origin matching that honors ports
|
||||
(compare `URL.origin` prefixes or document exact origins). Until then, change
|
||||
the description to "stdio MCP server (Streamable HTTP scaffold, unwired)".
|
||||
- **O4 (F4):** rename dotted tools to underscore (`ruview_bfld_last_scan`, …),
|
||||
keep dotted aliases in the call router for one release, note it in the README.
|
||||
- **O5 (F5):** single validation gate: the registry maps name → Zod schema →
|
||||
typed handler; advertised `inputSchema` generated from Zod at build time.
|
||||
- **O6 (F6):** close parent fds after spawn (`closeSync` post-`spawn` — the
|
||||
child holds its own copies), persist job records to
|
||||
`<jobsDir>/<id>.json`, and read log tails with a bounded read.
|
||||
- **O7 (F8):** make `detectCogBinary` actually probe (`existsSync` over the
|
||||
candidates) — it is the entire reason the function exists.
|
||||
- **O8 (F9):** single-source versions from package.json; drop `@types/express`;
|
||||
align `@types/jest` with jest 29 (or move to `node:test` like the harness and
|
||||
drop the jest toolchain entirely — it is the heaviest devDep in both
|
||||
packages).
|
||||
- **O9 (F9, scope):** fold `@ruv/ruview-cli` into `rvagent` as a second bin
|
||||
(`rvagent-cli`) sharing `http/cog/config`, or keep it private-forever and say
|
||||
so in its README. Its `ruview` bin name is surrendered to `@ruvnet/ruview`
|
||||
either way.
|
||||
|
||||
## Consequences
|
||||
|
||||
- CJS consumers stop hitting a guaranteed-broken export path (F1 is the only
|
||||
finding that fails for every consumer of that entry point deterministically).
|
||||
- The published artifact shrinks ~33% (MEASURED, F2 tarball listing: 62,698 B
|
||||
of maps in a 188 kB unpacked payload) and stops advertising files/transports
|
||||
it does not contain — the package description itself passes the project's
|
||||
claim-check bar.
|
||||
- One schema source ends advertised-vs-enforced drift and halves per-call
|
||||
validation cost; naming unification makes the 12-tool surface read as one
|
||||
product and survive strict host tool-name validation.
|
||||
- Long-lived MCP servers stop accumulating fds during training campaigns, and
|
||||
job polling survives restarts.
|
||||
- Costs: the alias cycle (O4) briefly doubles the advertised tool count unless
|
||||
aliases are router-only (recommended: router-only, advertise underscore names
|
||||
exclusively); folding the CLI (O9) retires a package name already in use in
|
||||
scripts, so it needs a deprecation note.
|
||||
- Verification for the implementing PR: `npm pack --dry-run` asserted file list
|
||||
(no `.map`, no phantom entries), pack-size budget in CI (ADR-265), jest/`node
|
||||
--test` suite green, and a tarball-install smoke test for both `import` and
|
||||
the `rvagent` bin.
|
||||
@@ -1,124 +0,0 @@
|
||||
# ADR-265: RuView npm Distribution Strategy — CI Gate, Provenance, Version Single-Sourcing, Namespace
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **D1–D4 implemented**: `.github/workflows/npm-packages.yml` (matrix gate: tests, version-literal grep, pack-content/size gate, tarball-install smoke test, README claim-check), `.github/workflows/ruview-npm-release.yml` (publish-from-CI with `npm publish --provenance`), version single-sourcing (all three packages read package.json), `ruview` bin owned by `@ruvnet/ruview` (`@ruv/ruview-cli` bin renamed `ruview-cli`), `ci.yml` NODE_VERSION 18→20. D5 (no workspace) stands as recorded |
|
||||
| **Date** | 2026-07-02 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **RUVIEW-NPM-DIST** |
|
||||
| **Supersedes / amends** | none (cross-cutting layer above ADR-263 and ADR-264; complements ADR-182 P3/P4) |
|
||||
|
||||
## Context
|
||||
|
||||
The monorepo now ships (or stages) **three Node packages** with no shared
|
||||
distribution engineering:
|
||||
|
||||
| Package | Dir | Published | Bin(s) | Tests in CI |
|
||||
|---------|-----|-----------|--------|-------------|
|
||||
| `@ruvnet/ruview` | `harness/ruview/` | 0.1.0 (live) | `ruview` | **none** |
|
||||
| `@ruvnet/rvagent` | `tools/ruview-mcp/` | 0.1.0 (live) | `rvagent`, `ruview-mcp` | **none** |
|
||||
| `@ruv/ruview-cli` | `tools/ruview-cli/` | private | `ruview` (collides) | **none** |
|
||||
|
||||
Cross-cutting facts established during the ADR-263/264 reviews:
|
||||
|
||||
- **Zero CI coverage.** No workflow under `.github/workflows/` references any of
|
||||
the three directories. Two of the packages are *live on the registry* and were
|
||||
published from a laptop state CI never saw. Meanwhile the Rust side has a
|
||||
1,031+-test gate and a witness-bundle culture (ADR-028) — the npm surface is
|
||||
the only shipped artifact class with no verification gate at all.
|
||||
- **`ci.yml` pins `NODE_VERSION: '18'`** while all three packages declare
|
||||
`engines.node >= 20`.
|
||||
- **Version triplication.** Each package hardcodes its version in source at
|
||||
least once beyond package.json (harness `SERVER_INFO`, rvagent
|
||||
`PACKAGE_VERSION`, cli `.version("0.0.1")`).
|
||||
- **Bin-name collision.** Two packages claim the `ruview` bin.
|
||||
- **No provenance.** Neither published package carries npm provenance
|
||||
attestations, in a project whose differentiator is signed, reproducible
|
||||
evidence (ADR-028 witness bundles, ADR-182 P4 ed25519/SLSA design).
|
||||
- **No pack-content gate.** ADR-264 F1/F2 (broken `require` target, 33% dead map weight — MEASURED, tarball listing — and a phantom
|
||||
`CHANGELOG.md` in `files`) are exactly the defect class an
|
||||
`npm pack --dry-run` assertion catches in seconds.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt one distribution layer for all Node packages. Per-package code fixes live
|
||||
in ADR-263/264; this ADR fixes the machinery around them.
|
||||
|
||||
### D1 — One `npm-packages.yml` CI workflow (the gate)
|
||||
|
||||
Matrix over `[harness/ruview, tools/ruview-mcp, tools/ruview-cli]` ×
|
||||
Node `[20, 22]`:
|
||||
|
||||
1. `npm ci` where a lockfile is committed (the TS packages); the harness
|
||||
installs with `npm install` — repo policy gitignores lockfiles under
|
||||
`harness/`, and the package is dependency-free after ADR-263 O3 so there is
|
||||
nothing to pin.
|
||||
2. `npm test` (harness: `node --test test/*.test.mjs` — pin the glob form,
|
||||
the directory form fails on Node 22; TS packages: build + jest or `node:test`
|
||||
per ADR-264 O8).
|
||||
3. **Pack gate:** `npm pack --dry-run --json` asserted against a checked-in
|
||||
expected file list + a max unpacked-size budget per package (harness ≤ 60 kB;
|
||||
rvagent ≤ 130 kB post ADR-264 O2). Any new/missing/renamed shipped file is a
|
||||
reviewed diff, not a surprise.
|
||||
4. **Tarball smoke test:** install the packed tarball into a temp dir; run
|
||||
`ruview --version`, `ruview doctor`, `rvagent` `--help`-equivalent, and a
|
||||
Node `import()` of each declared export condition — this is the test that
|
||||
would have caught ADR-264 F1 (`require` → nonexistent `dist/index.cjs`).
|
||||
5. Bump `ci.yml` `NODE_VERSION` to `'20'` (independent of the matrix above).
|
||||
|
||||
### D2 — Publish only from CI, with provenance
|
||||
|
||||
Manual `npm publish` from laptops stops. A tag-triggered workflow
|
||||
(`ruview-npm-release.yml`, mirroring the firmware release discipline) runs the
|
||||
D1 gate, then `npm publish --provenance --access public` under the GitHub OIDC
|
||||
token. Consequence: every published version is attested to a public commit +
|
||||
workflow run — the npm-side analogue of the ADR-028 witness bundle. The
|
||||
`prepublishOnly` script in each package runs the pack gate locally as a
|
||||
belt-and-braces (publishing outside CI fails loudly, not silently).
|
||||
|
||||
### D3 — Version single-sourcing
|
||||
|
||||
Rule: **package.json is the only place a version string lives.** Runtime code
|
||||
reads it (`createRequire(import.meta.url)('./package.json').version` or a
|
||||
build-time define for the TS packages). CI greps for `\d+\.\d+\.\d+` literals in
|
||||
`src/` of each package and fails on match (allowlist: test fixtures). This
|
||||
retires ADR-263 F6 and ADR-264 F9 permanently instead of per-incident.
|
||||
|
||||
### D4 — Namespace and bin ownership
|
||||
|
||||
- `@ruvnet/ruview` **owns the `ruview` bin** (it is the published front door,
|
||||
ADR-182). `@ruv/ruview-cli` renames its bin or folds into `rvagent`
|
||||
(ADR-264 O9) — decided here so neither package ADR relitigates it.
|
||||
- New Node packages in this repo use the `@ruvnet/` scope (the `@ruv/` scope
|
||||
holds `rvcsi` legacies; do not grow it).
|
||||
- Every package README + description must pass
|
||||
`npx ruview claim-check` — enforced in the D1 gate. The guardrail package
|
||||
linting its sibling packages' claims is the cheapest dogfooding we have
|
||||
(ADR-264 F3 is the standing example of why).
|
||||
|
||||
### D5 — Shared-code policy (bounded)
|
||||
|
||||
Do **not** introduce an npm workspace or a shared runtime package yet: three
|
||||
packages, two of which may merge (ADR-264 O9), do not justify workspace
|
||||
machinery, and the harness's zero-dep property is load-bearing. Revisit if a
|
||||
fourth package appears or if the `http/cog/config` duplication survives the
|
||||
ADR-264 O9 fold. Record the duplication as intentional in each file header (the
|
||||
CLI already does this).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The npm artifacts get the same class of gate the Rust workspace has had since
|
||||
ADR-028: no publish without tests, no shipped file set without an asserted
|
||||
manifest, no version without provenance. The two defects that reached the
|
||||
registry (broken `require` condition, dead maps) become CI-impossible.
|
||||
- Cold-path costs stay near zero: the D1 matrix is 6 fast jobs (the harness
|
||||
suite runs in ~108 ms MEASURED; TS builds dominate at a few tens of seconds).
|
||||
- Publishing gains one constraint (must go through CI) and loses one failure
|
||||
mode (laptop-state publishes) — the right trade for a project whose brand is
|
||||
reproducible evidence.
|
||||
- D3's grep gate is blunt but cheap; if it over-fires, scope it to
|
||||
`version`-adjacent identifiers before weakening it.
|
||||
- Follow-ups tracked elsewhere: per-package code fixes (ADR-263 O1–O8, ADR-264
|
||||
O1–O9); ADR-182 P4 (metaharness router + ed25519 provenance chain) remains
|
||||
the deeper provenance story that D2's npm attestations complement, not
|
||||
replace.
|
||||
@@ -1,75 +0,0 @@
|
||||
# ADR-266: MediaTek Filogic CSI Platform
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-18
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: mediatek, filogic, mt76, csi, openwrt, rust
|
||||
|
||||
## Context
|
||||
|
||||
RuView needs a high-antenna-count, router-class Wi-Fi sensing path beyond ESP32.
|
||||
MediaTek Filogic platforms are attractive because the upstream BSD-3-Clause
|
||||
`mt76` driver supports MT7915/MT792x/MT7996 families and OpenWrt supports
|
||||
MT7981/MT7986/MT7988 systems. The OpenWrt One (MT7981B + MT7976C) additionally
|
||||
publishes schematics, platform datasheets, register documentation, serial, and
|
||||
JTAG access. The BPI-R3 (MT7986 + MT7975N/P) offers dual-band 4x4 radios.
|
||||
|
||||
The current upstream `mt76` tree has testmode, debugfs, RX descriptors, and MCU
|
||||
event plumbing, but no supported public interface for exporting per-packet
|
||||
complex channel estimates. Public MediaTek SDK material likewise does not expose
|
||||
an equivalent to Espressif's CSI callback. PHY computation of channel estimates
|
||||
does not imply that firmware transfers those estimates to host memory.
|
||||
|
||||
Existing RuView documents that describe MT7661 CSI-over-UDP or released
|
||||
MediaTek CSI tools are unverified architectural hypotheses, not supported
|
||||
hardware claims.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Use the OpenWrt One as the primary future hardware/upstreaming target and the
|
||||
BPI-R3 as the secondary 4x4 validation target.
|
||||
2. Build a Rust-first simulator and host transport before hardware arrives.
|
||||
3. Keep the transport independent of private firmware structures. A future
|
||||
`mt76` adapter must translate a documented kernel/firmware report into it.
|
||||
4. Prefer Generic Netlink for capability/control messages and relayfs or a
|
||||
bounded character-device stream if sustained CSI volume exceeds Netlink's
|
||||
practical throughput.
|
||||
5. Do not redistribute vendor firmware, private headers, or SDK components.
|
||||
6. Label simulator frames end-to-end and never present them as physical capture.
|
||||
7. Do not claim MediaTek hardware CSI support until complex CSI from a physical
|
||||
device passes calibration, sequence, timestamp, and repeatability tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Development and integration testing can start without fabricating a vendor ABI.
|
||||
- OpenWrt One provides a repairable, upstream-friendly hardware target.
|
||||
- The same RuView ingestion path can accept simulator, replay, and future driver data.
|
||||
- Rust bounds checking isolates untrusted kernel/network input from inference code.
|
||||
|
||||
### Negative
|
||||
|
||||
- The simulator cannot prove firmware export availability or sensing accuracy.
|
||||
- A firmware change or MediaTek cooperation may be required before physical CSI exists.
|
||||
- Router-class builds and driver iteration are slower than MCU firmware development.
|
||||
|
||||
### Neutral
|
||||
|
||||
- NeuroPilot may later accelerate inference but is unrelated to CSI capture.
|
||||
- Wi-Fi 7/MLO support remains a later phase after a single-link contract is stable.
|
||||
|
||||
## Hardware gates
|
||||
|
||||
- Identify a firmware/host report containing complex channel estimates.
|
||||
- Document dimensions, quantization, chain ordering, subcarrier indexing, lifetime,
|
||||
timestamps, sequence behavior, calibration, maximum size, and report rate.
|
||||
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
|
||||
|
||||
## 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)
|
||||
- [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/)
|
||||
@@ -1,61 +0,0 @@
|
||||
# ADR-267: MediaTek MIMO CSI Wire Protocol
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-18
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: mediatek, csi, protocol, rust, udp, replay
|
||||
|
||||
## Context
|
||||
|
||||
The MediaTek simulator, captured regression fixtures, and a future `mt76` agent
|
||||
need one safe host-side representation. Copying an undocumented firmware layout
|
||||
would couple RuView to a private ABI and make malformed kernel/network data risky.
|
||||
MIMO CSI also requires explicit Tx/Rx/subcarrier dimensions and per-Rx-chain RSSI.
|
||||
|
||||
## Decision
|
||||
|
||||
Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
|
||||
|
||||
- 72-byte fixed header with magic, version, report kind, total length, sequence,
|
||||
monotonic timestamp, device ID, chipset profile, frequency, bandwidth, flags,
|
||||
Tx/Rx dimensions, numeric format, PPDU type, subcarrier count, noise floor,
|
||||
scale, subcarrier spacing, calibration ID, and payload length.
|
||||
- CSI payload begins with one signed RSSI byte per Rx chain, followed by
|
||||
`tx_count * rx_count * subcarrier_count` complex values in Tx-major,
|
||||
Rx-major, subcarrier-major order.
|
||||
- Supported numeric formats are complex signed i16 and complex finite f32.
|
||||
- Capability reports use bounded opaque TLVs until a public driver contract exists.
|
||||
- CRC-32/IEEE covers header and payload; the final four bytes carry the checksum.
|
||||
- One envelope maps to one UDP datagram, capped at the IPv4 UDP payload maximum
|
||||
of 65,507 bytes. Replay files prefix each envelope with a little-endian `u32`.
|
||||
- Parsers reject unknown versions/types/formats, invalid dimensions/bandwidth,
|
||||
multiplication overflow, inconsistent payload lengths, non-finite floats,
|
||||
bad CRC, trailing datagram bytes, and frames above the cap.
|
||||
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
|
||||
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Deterministic simulator and future hardware use identical parsing and APIs.
|
||||
- Explicit dimensions prevent ambiguous antenna or subcarrier interpretation.
|
||||
- CRC, finite-value checks, and hard caps make network/replay ingestion robust.
|
||||
- The format supports MT7981, MT7986, and MT7996 profiles without claiming their
|
||||
undocumented firmware layouts.
|
||||
|
||||
### Negative
|
||||
|
||||
- A translation/copy step is required from a future kernel report.
|
||||
- Maximum-size Wi-Fi 7 matrices may need segmentation in a later protocol version.
|
||||
|
||||
### Neutral
|
||||
|
||||
- Version 1 models one link per report; MLO correlation is a future extension.
|
||||
- Capability TLVs are intentionally conservative until hardware metadata is known.
|
||||
|
||||
## Links
|
||||
|
||||
- [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)
|
||||
@@ -1,41 +0,0 @@
|
||||
# ADR-268: Qualcomm Atheros CSI Platform Strategy
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-18
|
||||
- **Tags**: qualcomm, atheros, csi, ath9k, ath11k, ath12k, simulator
|
||||
|
||||
## Context
|
||||
|
||||
RuView needs a Qualcomm path that is useful before vendor hardware access while
|
||||
remaining honest about firmware boundaries. QCA9300 has demonstrated CSI tooling
|
||||
through ath9k/PicoScenes-class systems. QCN9074 and QCN9274 have upstream Linux
|
||||
connectivity drivers, but upstream ath11k/ath12k support does not by itself prove
|
||||
that raw per-packet complex CSI is exported by public firmware.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Use QCA9300 as the first physical baseline: 802.11n, up to 3x3 MIMO and
|
||||
20/40 MHz. Accept translated captures from established research tooling.
|
||||
2. Model QCN9074 (Wi-Fi 6/6E, 4x4, up to 160 MHz) and QCN9274 (Wi-Fi 7, 4x4,
|
||||
up to 160 MHz in protocol v1) as explicitly experimental simulator profiles.
|
||||
3. Keep firmware/kernel formats behind a Rust adapter. RuView ingests only the
|
||||
validated QCS1 application envelope defined by ADR-269.
|
||||
4. Never label simulated frames as hardware. Physical support requires captured
|
||||
fixtures, firmware provenance, antenna ordering, scaling and repeatability tests.
|
||||
5. Prefer an upstream-reviewed Generic Netlink or relay-style export if modern
|
||||
Qualcomm firmware exposes CFR/CSI; do not depend on undisclosed structs.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Development, APIs and downstream sensing can be tested immediately.
|
||||
- QCA9300 offers the shortest path to real Qualcomm data.
|
||||
- Modern profiles may remain simulator-only until firmware cooperation exists.
|
||||
- A translation copy is accepted in exchange for a stable, fuzzable boundary.
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-269: QCS1 wire protocol](ADR-269-qualcomm-csi-wire-protocol.md)
|
||||
- [Linux ath11k supported devices](https://wireless.docs.kernel.org/en/latest/en/users/drivers/ath11k.html)
|
||||
- [PicoScenes supported hardware](https://ps.zpj.io/manual/hardware.html)
|
||||
- [ADR-270: vendor integration portfolio and acceptance gates](ADR-270-vendor-rf-sensing-integration-program.md)
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# ADR-269: Qualcomm CSI Wire Protocol
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-18
|
||||
- **Tags**: qualcomm, csi, protocol, rust, udp, replay
|
||||
|
||||
## Decision
|
||||
|
||||
Define `QCS1` version 1 as a vendor-boundary envelope, not a Qualcomm firmware ABI.
|
||||
It uses a 72-byte little-endian header plus payload and CRC-32/IEEE. The header
|
||||
records report kind, total length, sequence, monotonic timestamp, device ID,
|
||||
chipset profile, center frequency, bandwidth, flags, Tx/Rx counts, numeric format,
|
||||
PPDU type, subcarrier count, noise floor, scale, subcarrier spacing, calibration
|
||||
ID and payload length.
|
||||
|
||||
CSI payloads contain one signed RSSI byte per receive chain followed by
|
||||
`tx * rx * subcarriers` complex i16 or finite f32 values in Tx-major, Rx-major,
|
||||
subcarrier-major order. Capability reports carry bounded opaque bytes. One QCS1
|
||||
frame maps to one UDP datagram; replay files prefix each frame with a little-endian
|
||||
u32 length.
|
||||
|
||||
Parsers fail closed on unknown enums, bad CRC, truncation, trailing datagram data,
|
||||
non-finite values, inconsistent dimensions, chipset chain/bandwidth violations,
|
||||
payload mismatches, arithmetic overflow and the IPv4 UDP payload ceiling. A
|
||||
synthetic flag provides end-to-end simulator provenance.
|
||||
|
||||
Version 1 profiles are QCA9300, QCN9074 and QCN9274. QCA9300 is capped at three
|
||||
chains and 40 MHz; modern profiles are capped at four chains and 160 MHz.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Simulator, replay and future hardware adapters share one validated Rust API.
|
||||
- No private firmware layout is represented or redistributed.
|
||||
- 320 MHz/EHT matrices require segmentation or a later protocol revision.
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-268: Qualcomm platform strategy](ADR-268-qualcomm-atheros-csi-platform.md)
|
||||
- [ADR-267: MediaTek MTC1 protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# ADR-270: Vendor RF Sensing Integration Program
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-18
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: vendors, csi, telemetry, simulator, rust, hardware-validation
|
||||
|
||||
## Context
|
||||
|
||||
RuView is evaluating Qualcomm, RF Solutions, Origin AI, Plume, Linksys,
|
||||
Electric Imp, Mist/Juniper, Luma, Google Nest, NETGEAR and Wifigarden. These
|
||||
names do not represent equivalent integration surfaces: some expose raw CSI,
|
||||
some expose derived sensing events or network telemetry, and some expose no
|
||||
supported developer interface. A repeated implementation process must not turn
|
||||
brand compatibility, Linux connectivity or synthetic fixtures into a false CSI
|
||||
claim.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt a Rust-first provider portfolio with explicit capability negotiation:
|
||||
|
||||
- `ComplexCsi`: calibrated per-packet complex channel matrices.
|
||||
- `DerivedSensing`: vendor-produced motion, occupancy or location events.
|
||||
- `RfTelemetry`: RSSI, radio, client and topology observations.
|
||||
- `NetworkOnly`: useful as excitation/AP infrastructure but not a sensor.
|
||||
- `Unsupported`: no stable, lawful or supportable integration surface.
|
||||
|
||||
Every provider follows the same gated loop:
|
||||
|
||||
1. Verify an authoritative API/SDK, exact model/chipset and licensing boundary.
|
||||
2. Write provider and wire/contract ADRs before coupling core code to a vendor.
|
||||
3. Implement bounded Rust types, explicit capabilities and synthetic provenance.
|
||||
4. Test deterministic replay, corruption, loss, reconnect, backpressure, schema
|
||||
evolution and secrets handling.
|
||||
5. Promote to hardware support only after lawful physical capture on an exact
|
||||
model/firmware, calibration and repeatability tests, and fixture publication
|
||||
rights. Simulator success never satisfies this gate.
|
||||
6. Publish code/release and an upstream or vendor collaboration announcement
|
||||
that states the measured-versus-simulated boundary.
|
||||
|
||||
### Portfolio decisions
|
||||
|
||||
| Provider | Classification | Decision |
|
||||
|---|---|---|
|
||||
| Qualcomm QCA9300 | `ComplexCsi` candidate | Implement first physical baseline via established ath9k research tooling; QCS1 adapter ships simulator-first. |
|
||||
| Qualcomm QCN9074/QCN9274 | experimental `ComplexCsi` | Simulator and protocol now; require confirmed ath11k/ath12k firmware export before hardware claim. |
|
||||
| Origin AI | commercial `DerivedSensing`, possible CSI | Pursue NDA sandbox/API and raw-data rights; isolate proprietary engine behind provider trait/service boundary. |
|
||||
| Plume/OpenSync | `RfTelemetry`; Plume Sense is gated `DerivedSensing` | Build optional OVSDB/control-plane adapter; negotiate Sense separately and do not infer raw CSI. |
|
||||
| Mist/Juniper | `RfTelemetry` + location | Conditional read-only REST/webhook adapter for occupancy, RSSI and coordinates; no CSI claim. |
|
||||
| NETGEAR | partner-gated `RfTelemetry` | Insight adapter only after API access; exact legacy OpenWrt models remain community experiments. |
|
||||
| Luma | discontinued OpenWrt salvage target | Generic OpenWrt telemetry/pcap fixture only when already owned; no procurement or Luma CSI source. |
|
||||
| Google Nest Wifi | `NetworkOnly` | Use as traffic/AP infrastructure; Device Access does not expose router CSI or radio telemetry. |
|
||||
| Linksys | `Unsupported` for sensing | Linksys Aware reached end of support in 2024; record capability probe only, if needed. |
|
||||
| Electric Imp | scalar IoT/RSSI telemetry | Optional agent/impCentral bridge for existing fleets; reject as CSI acquisition hardware. |
|
||||
| RF Solutions | non-Wi-Fi RF/IoT telemetry | Exclude from sensing backend; optional RIoT environmental fusion is a separate future concern. |
|
||||
| Wifigarden | commercial OEM, capability unknown | Hold implementation pending chipset, schema, offline, calibration and data-rights disclosure. |
|
||||
|
||||
### Provider boundary
|
||||
|
||||
Core code consumes a vendor-neutral `RfSource`-style contract whose capability
|
||||
set prevents RSSI, location or derived occupancy from being represented as CSI.
|
||||
Cloud adapters use bounded async queues, regional endpoints, secret-provider
|
||||
credentials and explicit data provenance. Proprietary device SDKs live behind a
|
||||
feature-gated FFI or sidecar boundary and are never redistributed without rights.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- The integration loop can be repeated without duplicating unsafe parsers.
|
||||
- Product integrations remain useful even when only telemetry is available.
|
||||
- Public releases make hardware confidence and simulator confidence distinct.
|
||||
|
||||
### Negative
|
||||
|
||||
- Several named vendors cannot produce a legitimate CSI implementation today.
|
||||
- Commercial providers require contracts, subscriptions, test vectors or NDAs.
|
||||
- Exact hardware revisions and firmware provenance increase validation effort.
|
||||
|
||||
### Neutral
|
||||
|
||||
- A no-go or telemetry-only ADR is a completed research outcome, not a failed port.
|
||||
- Vendor status and APIs must be rechecked before each implementation begins.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
The ADR-270 provider contract is implemented in Rust. Each portfolio entry has
|
||||
a descriptor, bounded decoder or explicit fail-closed access state, deterministic
|
||||
contract fixtures where lawful, registry coverage, and API exposure:
|
||||
|
||||
- Origin AI: contract-configured derived-sensing decoder and request plan.
|
||||
- Plume/OpenSync: read-only OVSDB request plan and RF telemetry decoder.
|
||||
- Mist/Juniper: regional request configuration, paginated RF/location decoder.
|
||||
- NETGEAR Insight: regional partner request configuration and telemetry decoder.
|
||||
- Electric Imp and RF Solutions: bounded scalar telemetry bridges.
|
||||
- Luma: explicitly experimental generic OpenWrt telemetry bridge.
|
||||
- Google Nest: network-only contract events; never represented as CSI.
|
||||
- Linksys: `Unsupported` decoder because Linksys Aware is end-of-support.
|
||||
- Wifigarden: `ContractRequired` decoder pending a disclosed SDK/schema.
|
||||
|
||||
`vendor-rf-sim` generates deterministic, provenance-labelled events for the
|
||||
eight providers with a defined event contract and refuses to fabricate Linksys
|
||||
or Wifigarden events. The sensing server exposes provider descriptors and latest
|
||||
events under `/api/v1/rf/vendors` and accepts validated canonical simulator
|
||||
events over its existing UDP port. Physical/vendor-cloud validation remains
|
||||
separate from implementation completeness and is reflected by
|
||||
`hardware_validated: false` until performed.
|
||||
|
||||
## Evidence and Links
|
||||
|
||||
- [ADR-268: Qualcomm strategy](ADR-268-qualcomm-atheros-csi-platform.md)
|
||||
- [OpenSync developer sandbox](https://www.opensync.io/developer)
|
||||
- [Origin AI Wi-Fi sensing architecture](https://www.originwirelessai.com/wifi-sensing/)
|
||||
- [Juniper Mist webhook hierarchy](https://www.juniper.net/documentation/us/en/software/mist/automation-integration/topics/topic-map/webhook-hierarchy.html)
|
||||
- [Linksys product end-of-life](https://www.linksys.com/pages/linksys-product-end-of-life)
|
||||
- [Google Nest Device Access supported devices](https://developers.google.com/nest/device-access/supported-devices)
|
||||
- [OpenWrt Luma WRTQ-329ACN](https://openwrt.org/toh/hwdata/luma/luma_wrtq-329acn)
|
||||
- [NETGEAR Insight compatible devices](https://kb.netgear.com/000048452/What-devices-can-I-discover-monitor-and-manage-with-Insight)
|
||||
- [Electric Imp imp005 hardware guide](https://developer.electricimp.com/hardware/imp/imp005_hardware_guide)
|
||||
- [RF Solutions company portfolio](https://www.rfsolutions.co.uk/about-us-i1/)
|
||||
- [Wifigarden service terms](https://policies.wifigarden.com/en-us/terms-of-service)
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
# ADR-271: RuView as a Cognitum OAuth resource server
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-22
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: auth, oauth, cognitum, security, sensing-server
|
||||
- **Related**: ADR-055 (integrated sensing server), ADR-102 (edge module registry), ADR-066 (ESP32 seed pairing), cognitum-one/dashboard ADR-060 (OAuth scopes beyond `inference`), cognitum-one/meta-llm ADR-045 (Bearer at completions)
|
||||
|
||||
## Context
|
||||
|
||||
`/api/v1/*` on `wifi-densepose-sensing-server` is gated by `RUVIEW_API_TOKEN`
|
||||
(`bearer_auth.rs`): a single shared secret, compared in constant time, with no
|
||||
expiry, no rotation and no per-user attribution. `homecore-api` has a second,
|
||||
unrelated scheme (`LongLivedTokenStore` over `HOMECORE_TOKENS`) whose own doc
|
||||
comment describes it as "no expiry, no rotation, no per-user attribution yet".
|
||||
|
||||
That is proportionate for the ADR-055 topology — server bundled in the desktop
|
||||
app, spawned as a child, localhost only. It is not proportionate for the other
|
||||
deployment RuView actually has: a sensing server on a Pi or hub, reachable on a
|
||||
LAN, potentially serving more than one person, exposing live presence, pose,
|
||||
breathing and heart-rate data plus destructive operations (model training,
|
||||
model delete, recording delete).
|
||||
|
||||
Cognitum operates a live OAuth 2.1 authorization server at `auth.cognitum.one`.
|
||||
Users of RuView are already Cognitum account holders. The obvious question is
|
||||
whether RuView can accept that identity instead of a shared string.
|
||||
|
||||
### The direction of the integration is the thing most likely to be misread
|
||||
|
||||
Every existing Cognitum OAuth integration in the org — meta-proxy, musica,
|
||||
metaharness, the dashboard CLI — is an OAuth **client**: it obtains a token so
|
||||
the application can *call* a Cognitum service (the completions plane).
|
||||
|
||||
RuView is the opposite. It makes **no authenticated calls to any Cognitum API**.
|
||||
Its only outbound Cognitum dependency is the ADR-102 registry fetch, which is an
|
||||
anonymous GET against a public GCS bucket. What RuView wants is to be a
|
||||
**resource server**: a user signs in to their *own* RuView instance with their
|
||||
Cognitum identity, and RuView verifies the token they present.
|
||||
|
||||
So the client-side prior art in the org, while useful for a future `ruview
|
||||
login` command, addresses a plane RuView does not have. The only relevant
|
||||
precedent is `meta-llm/src/auth/oauthBearer.ts` (ADR-045) — the org's sole
|
||||
resource-server-side verifier of these tokens. It is TypeScript; **RuView is the
|
||||
first Rust one.**
|
||||
|
||||
### Facts about the tokens, verified against a live production token
|
||||
|
||||
- **ES256 JWT**, signed by a single P-256 key published at
|
||||
`https://auth.cognitum.one/.well-known/jwks.json`.
|
||||
- **15-minute lifetime**, with an opaque refresh token that **rotates with reuse
|
||||
detection** (presenting a spent one ends the session).
|
||||
- Claims: `typ`, `sub`, `account_id`, `org_id`, `workspace_id`, `client_id`,
|
||||
`scope`, `family_id`, `jti`, `iat`, `exp`, `setup`, `workload`.
|
||||
- **No `aud` claim.** No `/oauth/introspect`. No `/userinfo`. It is an OAuth 2.1
|
||||
authorization server, not an OpenID Provider, deliberately.
|
||||
|
||||
## Decision
|
||||
|
||||
Verify Cognitum access tokens **offline**, in a new `ruview-auth` crate, and
|
||||
gate RuView's own API surface on the **scope** they carry.
|
||||
|
||||
### 1. Offline verification is a requirement, not an optimisation
|
||||
|
||||
RuView runs on Pi-class hardware that loses WAN, and there is no introspection
|
||||
endpoint to call even when the network is up. Verification is therefore an
|
||||
ES256 signature check against a `kid`-indexed JWKS cache. Two consequences we
|
||||
accept explicitly:
|
||||
|
||||
- **Revocation window = token lifetime.** A compromised access token stays
|
||||
usable until `exp`. This is the same position meta-llm takes, for the same
|
||||
reason, and it is why §3 refuses long-lived credentials.
|
||||
- **A JWKS refetch failure is survivable while a key set is cached.** A key that
|
||||
verified a minute ago has not stopped being valid because the network blipped;
|
||||
failing closed there would log every user out of their own sensing server
|
||||
whenever their internet wobbled. We fail closed in exactly one case: no key
|
||||
set has *ever* been fetched.
|
||||
|
||||
### 2. The accept-rule is ported from meta-llm, not designed
|
||||
|
||||
```
|
||||
typ == "access" AND NOT setup AND NOT workload
|
||||
AND account_id is a non-empty string
|
||||
AND exp is in the future
|
||||
AND the scope required by the route is held
|
||||
```
|
||||
|
||||
**Note there is no `iss` check.** An earlier revision of this section listed
|
||||
"`iss` matches the configured issuer verbatim" — that rule was implemented,
|
||||
shipped, and rejected EVERY real token, because Cognitum access tokens carry no
|
||||
`iss` claim (see §"Facts about the tokens" above, which contradicted this
|
||||
paragraph for a day). Removed in the code; removed here. The JWKS is the issuer
|
||||
binding.
|
||||
|
||||
Divergence from `oauthBearer.ts` would be a bug rather than a preference: a
|
||||
token meta-llm rejects must not be one RuView accepts. The algorithm is **fixed
|
||||
to ES256 by our code** — the header's `alg` is only ever compared against that
|
||||
allowlist, never used to select an algorithm.
|
||||
|
||||
### 3. Long-lived setup and workload credentials are refused outright
|
||||
|
||||
Identity also issues 365-day *setup* and machine *workload* credentials. Their
|
||||
revocation state lives in identity's `oauth_setup_tokens` table. RuView — like
|
||||
meta-llm — has no database and no way to check it, so accepting one would mean
|
||||
honouring a credential that may already have been revoked. A 15-minute token
|
||||
needs no revocation round-trip because it expires faster than revocation
|
||||
propagates; a 365-day one does.
|
||||
|
||||
### 4. Scope is the capability boundary, because nothing else can be
|
||||
|
||||
Tokens carry no `aud`, so RuView cannot verify a token was minted *for* RuView.
|
||||
`client_id` cannot substitute: clients borrow each other's registrations when
|
||||
their own has not been deployed (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`).
|
||||
|
||||
This is not a defect to route around. Cross-product **identity** is intended —
|
||||
one Cognitum account, every Cognitum product. Cross-product **capability** is
|
||||
not, and scope is what carries the difference.
|
||||
|
||||
RuView registers two scopes (dashboard ADR-060, identity migration `0016`):
|
||||
|
||||
| Scope | Grants |
|
||||
|---|---|
|
||||
| `sensing:read` | sensing/pose streams, one-shot inference, reading model and recording metadata |
|
||||
| `sensing:admin` | every mutating route not explicitly allowlisted as read-safe — training (`/api/v1/train/*` AND `/api/v1/adaptive/train`), model and recording deletion, config writes |
|
||||
|
||||
**The gate is fail-closed for writes, and that polarity is load-bearing.** An
|
||||
earlier revision enumerated admin routes by prefix and let everything else fall
|
||||
through to `sensing:read`. `POST /api/v1/adaptive/train` — which trains a
|
||||
classifier, overwrites the on-disk model and swaps the live one — does not match
|
||||
`/api/v1/train/`, so it was reachable with `sensing:read`, the scope
|
||||
`wifi-densepose login` requests by default. Found by adversarial review. Now:
|
||||
reads are open, writes require admin unless the exact path is on a short
|
||||
allowlist of non-destructive mutations. A route added tomorrow is admin-gated
|
||||
until someone classifies it.
|
||||
|
||||
**No hierarchy**: `sensing:admin` does not imply `sensing:read`. Consent means
|
||||
exactly what it said, and a token needing both must have consented to both.
|
||||
`client_id` is retained on the principal for logging and attribution only —
|
||||
never as an authorization input.
|
||||
|
||||
### 5. Additive and fail-closed, never a silent downgrade
|
||||
|
||||
`RUVIEW_API_TOKEN` and `HOMECORE_TOKENS` deployments keep working unchanged.
|
||||
OAuth is opt-in; with it unconfigured, behaviour is byte-identical to today.
|
||||
When OAuth *is* configured but unusable (JWKS unreachable at boot, required
|
||||
scope not registered), the server must refuse to serve `/api/v1/*` rather than
|
||||
fall through to an open or single-secret state.
|
||||
|
||||
### 6. `ureq`, and a transport seam
|
||||
|
||||
`wifi-densepose-sensing-server` deliberately chose `ureq` as "the smallest" HTTP
|
||||
client. Introducing `reqwest` for a JWKS fetch would silently reverse that for
|
||||
the whole dependency graph. The fetch sits behind a `JwksFetcher` trait — the
|
||||
`ureq` implementation is a default-on feature, and a host may supply its own and
|
||||
take no HTTP dependency at all.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Requests become attributable: `sub`, `account_id`, `org_id`, `workspace_id`,
|
||||
`jti`. This closes the gap `homecore-api`'s `tokens.rs` has been deferring as
|
||||
"P3", using claims rather than new RuView machinery.
|
||||
- Destructive operations can be separated from observation for the first time.
|
||||
- **The 15-minute lifetime is the main operational cost.** A long-running client
|
||||
must refresh, and because refresh tokens rotate with reuse detection, a
|
||||
concurrent or naively retried refresh **ends the session** — single-flight is a
|
||||
correctness requirement, not an optimisation. This lands with the login flow,
|
||||
not this crate.
|
||||
- Hosts without a battery-backed clock will fail `exp`/`iat` until NTP lands.
|
||||
The verifier reports that distinguishably so it is diagnosable rather than
|
||||
presenting as a generic 401.
|
||||
- A new dependency, `jsonwebtoken` — the same crate, same major version, that
|
||||
identity itself uses to sign these tokens.
|
||||
|
||||
## ~~Known incomplete: the browser cannot obtain an OAuth token~~ — CLOSED 2026-07-23
|
||||
|
||||
> **Superseded within this same PR.** The text below described the state when
|
||||
> this ADR was first written. It is retained because the reasoning still
|
||||
> explains *why* the browser half was built, but every factual claim in it is
|
||||
> now false — in particular `grep -ril "oauth|cognitum|pkce" ui/` now returns
|
||||
> `ui/sw.js`, `ui/sw.test.mjs` and `ui/utils/quick-settings.js`. An adversarial
|
||||
> review caught the ADR still asserting the old state; see "Browser sign-in"
|
||||
> below for what actually ships.
|
||||
|
||||
<details>
|
||||
<summary>Original text (no longer accurate)</summary>
|
||||
|
||||
`wifi-densepose login` writes to `~/.ruview/credentials.json` — a file a browser
|
||||
cannot read. The UI's `ws-ticket.js` reads a bearer from
|
||||
`localStorage['ruview-api-token']`, which is populated **only** by the
|
||||
QuickSettings manual-paste panel. There is no "Sign in with Cognitum" control,
|
||||
no redirect flow, and `grep -ril "oauth|cognitum|pkce" ui/` returns nothing.
|
||||
|
||||
So a user who signs in via the CLI gets **no benefit in the browser UI**, and
|
||||
the WebSocket ticket mechanism this ADR's sibling (ADR-272) introduces "for
|
||||
browsers" is today only exercisable with the legacy static shared secret that
|
||||
OAuth was meant to replace. The server-side gating is correct and complete; the
|
||||
browser half of the story these ADRs tell is not built.
|
||||
|
||||
</details>
|
||||
|
||||
## Browser sign-in
|
||||
|
||||
`/oauth/start`, `/oauth/callback`, `/oauth/logout` and `/oauth/status`, plus a
|
||||
"Cognitum Account" panel in QuickSettings. The server runs the authorization
|
||||
code + PKCE flow itself and hands the browser a **signed session cookie** —
|
||||
never the access token. The browser gets an assertion that this server already
|
||||
verified a token, which is nothing replayable anywhere else.
|
||||
|
||||
Three things about it are load-bearing and were each found the hard way:
|
||||
|
||||
- **The cookie carries the granted scope**, and the gate re-checks it per
|
||||
request. A `sensing:read` session cannot delete a model.
|
||||
- **`__Host-` is deliberately NOT used.** That prefix requires `Secure`, and
|
||||
RuView is routinely reached over plain HTTP on a LAN; a cookie the browser
|
||||
refuses to set is worse than one without the prefix. The cost is real and is
|
||||
recorded as P3 under "Open problems" below.
|
||||
- **The service worker must never cache `/oauth/*` or authenticated `/api/*`.**
|
||||
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so
|
||||
a cached `/oauth/status` froze sign-in until a hard reload, and cached API
|
||||
responses could be replayed to a different user after sign-out. `ui/sw.js` is
|
||||
now deny-by-default with an allowlist.
|
||||
|
||||
### Still incomplete
|
||||
|
||||
`redirect_uri` defaults to `http://127.0.0.1:8080/oauth/callback` and is
|
||||
overridden only by `RUVIEW_PUBLIC_BASE_URL`. Browser sign-in therefore works
|
||||
only on a host reached at exactly that origin: an operator browsing
|
||||
`http://localhost:8080` or `http://192.168.1.50:8080` cannot complete the flow
|
||||
(PKCE keeps the code unexchangeable, so this is a broken flow, not a token
|
||||
leak). Deriving it from the request is the fix; deferred deliberately, since
|
||||
deriving a redirect URI from attacker-controllable headers is its own class of
|
||||
bug and deserves its own decision.
|
||||
|
||||
The credential `wifi-densepose login` stores is also **not yet consumed by any
|
||||
shipped client** — no CLI subcommand, MCP server or Python client reads
|
||||
`~/.ruview/credentials.json`. The token is obtainable and verifiable; wiring the
|
||||
clients to send it is separate work.
|
||||
|
||||
## Open problems — RESOLVED 2026-07-23
|
||||
|
||||
Three findings from the 2026-07-23 adversarial review. All three are now
|
||||
**fixed**; the analysis is retained because it explains why each fix has the
|
||||
shape it does, and each is guarded by a test that was confirmed to fail against
|
||||
the old behaviour.
|
||||
|
||||
### P1 — the JWKS fetch blocks a tokio worker, and the stale path is unbounded — **FIXED**
|
||||
|
||||
`verify.rs:182` calls `JwksCache::decoding_key_for`, which performs a blocking
|
||||
`ureq` request (`jwks.rs:181`, 3s connect + 3s read) directly on the async
|
||||
worker running `require_bearer`. The same codebase already knows this is wrong:
|
||||
`main.rs:9265` wraps the token exchange in `spawn_blocking`, commenting "the
|
||||
same mistake this codebase had to fix in `jwks.rs`". The hot verification path
|
||||
did not get the same treatment.
|
||||
|
||||
Worse, the rate limiter does not cover the case that matters.
|
||||
`state.fetched_at` is updated **only on success** (`jwks.rs:188`); the error arm
|
||||
leaves it untouched. So once the TTL elapses after the last *successful* fetch,
|
||||
`fresh` is permanently `false`, the `may_force` guard at `:170` is never
|
||||
consulted, and **every** request performs its own blocking fetch attempt.
|
||||
|
||||
This fires with no attacker present. On a Pi that loses WAN — the documented
|
||||
deployment reality — 300 seconds later every API call and every UI poll starts a
|
||||
blocking outbound attempt, and with few tokio workers the whole server stalls,
|
||||
including `/health`. An attacker can reach the same state deliberately by
|
||||
flooding tokens carrying an unknown `kid`.
|
||||
|
||||
**Proposed fix, in dependency order:**
|
||||
|
||||
1. **Rate-limit attempts, not successes.** Add `last_attempt_at`, recorded
|
||||
before the fetch regardless of outcome, and consult it on the stale path too.
|
||||
This alone converts "every request fetches" into "one request per interval".
|
||||
2. **Get the blocking call off the runtime.** Either wrap the call in
|
||||
`spawn_blocking` at the `verify` boundary, or give `JwksCache` an async
|
||||
transport behind the existing transport seam. The seam already exists —
|
||||
`JwksCache::new` takes a boxed transport — so this is an added
|
||||
implementation, not a redesign.
|
||||
3. **Single-flight the refresh.** Concurrent misses for the same `kid` should
|
||||
await one shared fetch rather than each issuing their own.
|
||||
4. **Refresh ahead of expiry** from a background task, so the request path
|
||||
normally never fetches at all.
|
||||
|
||||
Steps 1 and 2 are the ones that remove the stall; 3 and 4 are optimisations.
|
||||
The test that must accompany this: a transport whose fetch blocks on a barrier,
|
||||
asserting that a second concurrent verification is not serialised behind it —
|
||||
the current suite is entirely single-threaded and could not observe a
|
||||
reintroduction (`jwks::tests` contains no concurrency primitive at all).
|
||||
|
||||
### P2 — a 15-minute access token becomes a 12-hour session — **FIXED**
|
||||
|
||||
`issue()` sets `exp: now() + SESSION_TTL_SECS` with `SESSION_TTL_SECS = 12 *
|
||||
3600`, deliberately not inheriting the access token's ~15-minute lifetime. The
|
||||
session cookie is an assertion that this server verified a token, so it is not
|
||||
*wrong* for it to outlive the token — but 12 hours is a long time to hold an
|
||||
authority that cannot be revoked. Cognitum publishes no introspection endpoint
|
||||
(see "Facts about the tokens"), so RuView has no way to ask whether the grant
|
||||
behind a session still stands. A disabled account keeps sensing access, and
|
||||
`sensing:admin` if it had it, until the cookie expires on its own.
|
||||
|
||||
**Correction.** An earlier revision of this section said capping the session at
|
||||
`sensing:read` was "considered and rejected, because the dashboard genuinely
|
||||
performs admin operations". That was wrong, and a cross-vendor pre-merge sweep
|
||||
caught it: `/oauth/start` (`main.rs:9206`) already requests `SENSING_READ` and
|
||||
nothing else, deliberately — "admin work goes through the CLI, which requires an
|
||||
explicit `--admin`". So a browser session is **already** read-only, and the
|
||||
consequence I claimed capping would cause is simply the current behaviour.
|
||||
|
||||
Two things follow, and both are stated here rather than left for the next reader
|
||||
to trip over:
|
||||
|
||||
1. **The UI's admin controls do not work from a browser OAuth session.**
|
||||
`model.service.js:136` issues `DELETE /api/v1/models/{id}`; from a
|
||||
Cognitum-signed-in browser that returns 401. Admin work requires either the
|
||||
CLI (`wifi-densepose login --admin`) or a manually pasted admin bearer in the
|
||||
QuickSettings token field. This is a gap in the browser feature, not a
|
||||
regression — browser sign-in is new here, and the token-paste path still
|
||||
carries whatever authority the pasted token has.
|
||||
|
||||
2. **The step-up control below is therefore a guard ahead of need, not an active
|
||||
one.** No browser session currently holds `sensing:admin`, so
|
||||
`session.has_scope(SENSING_ADMIN)` is false and the freshness branch never
|
||||
fires in production. Its tests pass because the crate-internal test seam
|
||||
mints an admin cookie the real flow does not produce. That is worth naming
|
||||
plainly: it is correct code guarding a case that cannot yet arise, and it
|
||||
becomes load-bearing the moment anyone widens the requested scope — which is
|
||||
the right time for the guard to already exist, but it is not evidence that
|
||||
the control is exercised today.
|
||||
|
||||
### Decision, 2026-07-23: the browser is read-only, permanently
|
||||
|
||||
**Browser-side admin is not wanted.** `BROWSER_SIGNIN_SCOPE` stays
|
||||
`sensing:read`, and the escalate-on-demand design sketched while this was still
|
||||
open is **not** being built.
|
||||
|
||||
The reasoning holds up on its own terms rather than being a concession to
|
||||
scope: the destructive operations — training, model delete, recording delete —
|
||||
already have a home in the CLI, where `--admin` is explicit, typed by a person,
|
||||
and scoped to the session that needed it. Routing them through a browser would
|
||||
mean either asking every user to consent to delete capability in order to watch
|
||||
a stream, or building a second consent flow to avoid that. Neither is worth it
|
||||
for operations that are administrative by nature and rare by frequency.
|
||||
|
||||
What this settles:
|
||||
|
||||
- **The UI's admin controls are unreachable from a Cognitum browser session**
|
||||
and that is now intended, not a gap. `model.service.js` issuing
|
||||
`DELETE /api/v1/models/{id}` returns 401. The manual token-paste field still
|
||||
works and carries whatever authority the pasted token has, so nothing that
|
||||
worked before this change stops working.
|
||||
- **The client-side step-up redirect has been removed** from
|
||||
`ui/services/api.service.js`. It caught a challenge that can never be issued,
|
||||
and it ended in a promise that never settles — so had any other 401 ever grown
|
||||
that header, every caller would have hung forever. Dead code with a trap in it
|
||||
is worse than no code.
|
||||
- **`ADMIN_REVERIFY_SECS` stays as a server-side backstop.** It is fail-closed
|
||||
and costs nothing, so if the requested scope is ever widened the freshness
|
||||
requirement is already there rather than something to remember. It is
|
||||
documented at its definition as a backstop, so nobody mistakes its passing
|
||||
tests for evidence that it is exercised.
|
||||
|
||||
**Three options, with the tradeoff each carries:**
|
||||
|
||||
| Option | Effect | Cost |
|
||||
|---|---|---|
|
||||
| **A. Shorten the TTL** (e.g. 12h → 4h) | Bounds exposure by a factor of 3, one constant | Re-auth is a full-page navigation, which interrupts a live streaming dashboard. Mostly silent while the Cognitum session is alive, but not free. |
|
||||
| **B. Server-side session store** with the refresh token, revalidated periodically | Real revocation: a disabled grant fails at the next refresh | The server now stores refresh tokens — a new and higher-value secret at rest — and refresh rotates with reuse detection, so a bug logs users out. |
|
||||
| **C. Re-verify on privileged operations only** | `sensing:admin` requires a fresh token; reads keep the long session | Best blast-radius-per-unit-cost, but needs a UI affordance for step-up auth that does not exist. |
|
||||
|
||||
**Chosen: A, at one hour** — `SESSION_TTL_SECS` is 3600, down from 12 hours.
|
||||
|
||||
C was implemented too, and then the browser-read-only decision above made it a
|
||||
backstop rather than an active control: with no browser session holding
|
||||
`sensing:admin`, there is no privileged operation to re-verify. It is kept
|
||||
because it is fail-closed and free, not because it is doing work today.
|
||||
|
||||
B is not built. It is only worth its cost — storing refresh tokens at rest,
|
||||
against an authorization server that rotates them with reuse detection — if
|
||||
RuView later needs true cross-device sign-out. Shortening the window addresses
|
||||
the same risk for a fraction of the exposure.
|
||||
|
||||
That leaves a residual this ADR should not pretend away: **within one hour, a
|
||||
revoked Cognitum grant still reads sensing data through an existing browser
|
||||
session.** Cognitum publishes no introspection endpoint, so nothing short of B
|
||||
closes that, and one hour is the size of the hole we accepted.
|
||||
|
||||
### P3 — dropping `__Host-` costs cookie origin-integrity, not just `Secure` — **FIXED**
|
||||
|
||||
The decision above frames omitting `__Host-` as trading away a `Secure`
|
||||
requirement that RuView cannot meet on a plain-HTTP LAN. That framing is
|
||||
incomplete: `__Host-` also guarantees the cookie was set by *this* origin with
|
||||
`Path=/` and no `Domain`. Without it, cookies are not port-scoped and are not
|
||||
integrity-protected against a same-host writer.
|
||||
|
||||
`read_cookie` returns the **first** match in the header, and RFC 6265 §5.4 sends
|
||||
longer-`Path` cookies first. So an attacker who can set a cookie on the same
|
||||
host — any other service on any port on that appliance, or a plain-HTTP MITM
|
||||
injecting `Set-Cookie` — can plant `ruview_session=<their own validly signed
|
||||
session>; Path=/ui`. The victim's browser then sends both, the attacker's first,
|
||||
and it verifies correctly because it *is* genuinely signed. The victim ends up
|
||||
operating inside the attacker's session; `/oauth/status` reports the attacker's
|
||||
account, and anything the victim records is attributed to them.
|
||||
|
||||
Note the shape: the signature is doing its job. Forgery was never the threat
|
||||
`__Host-` addresses, so "the signature is what protects the value" does not
|
||||
answer this.
|
||||
|
||||
**Proposed fix (cheap, no prefix needed):** have `read_cookie` collect *all*
|
||||
values for the name and accept only if exactly one verifies — or, more strictly,
|
||||
reject outright when more than one `ruview_session` is present, since a browser
|
||||
should never legitimately send two. Add `Secure` and the `__Host-` prefix
|
||||
conditionally when the server knows it is behind TLS, keeping the plain-HTTP LAN
|
||||
case working.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `RUVIEW_API_TOKEN` only.** Zero work, and adequate for a single-user
|
||||
localhost install. Rejected because it cannot express who did what, cannot be
|
||||
revoked without a restart, and cannot separate "watch the stream" from "delete
|
||||
the model" — all of which matter the moment the server is on a LAN.
|
||||
|
||||
**Exchange the OAuth token for a `cog_` key.** The pattern ADR-316 (meta-proxy)
|
||||
and ADR-119 (metaharness) originally described. Rejected: it cannot work.
|
||||
`/v1/me/keys` requires a *Firebase* ID token, not an OAuth token — meta-proxy
|
||||
hit the resulting 401 in production, replaced the approach with Bearer-direct
|
||||
under ADR-045, and deleted `mint.rs` as dead code.
|
||||
|
||||
**Call identity to introspect each token.** Rejected: no introspection endpoint
|
||||
exists, and a network round-trip per request would be wrong for an edge sensing
|
||||
server regardless.
|
||||
|
||||
**Wait for an `aud` claim before shipping.** Rejected as sequencing. `aud` would
|
||||
touch every issued token and every verifier in the org; scope is additive and
|
||||
independently correct. Tracked separately; adding `aud` later strengthens this
|
||||
design rather than invalidating it.
|
||||
|
||||
**Use OAuth for the ESP32 device plane too.** Rejected as a category error.
|
||||
Devices have no browser, no user and no human present; they already pair with a
|
||||
`seed_token` bearer (ADR-066) plus a device-bound PSK. Cognitum OAuth is for the
|
||||
API plane only.
|
||||
|
||||
## Implementation
|
||||
|
||||
`v2/crates/ruview-auth` — `jwks` (fetch, TTL cache, `kid` index, one
|
||||
rate-limited forced refetch on an unknown `kid` so rotation is picked up without
|
||||
waiting out the TTL), `verify` (the §2 accept-rule), `principal` (the verified
|
||||
caller and its scopes).
|
||||
|
||||
41 tests pass under both `cargo test --no-default-features` (the repo's
|
||||
canonical gate) and default features. The matrix signs real ES256 tokens with a
|
||||
runtime-generated key — no key material is committed — and covers `alg:none`,
|
||||
forged signatures, spliced payloads, unknown `kid`, expiry on both sides of the
|
||||
leeway, `typ` confusion, `setup`/`workload` smuggled onto a `typ=access` token, missing and
|
||||
empty `account_id`, and scope escalation.
|
||||
|
||||
The load-bearing case is
|
||||
`g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sensing_surface`:
|
||||
a correctly signed, unexpired, right-issuer, right-`typ` token bearing
|
||||
`client_id=meta-proxy` and `scope=inference` is rejected. Nothing about its
|
||||
signature or identity claims distinguishes it — only scope does. A naive
|
||||
verifier accepts it, and an `inference` token becomes a key to someone's home
|
||||
sensor.
|
||||
|
||||
**Not in this crate**: WebSocket authentication (ADR-272) and any outbound
|
||||
Cognitum call.
|
||||
|
||||
### Amendment, 2026-07-22 — the login flow lives here after all, behind a feature
|
||||
|
||||
The paragraph above originally also excluded the login flow. That was written
|
||||
to keep the sensing server lean, which is the right goal but not a reason to put
|
||||
the code somewhere else: the Tauri desktop app needs the same flow, and a second
|
||||
copy of a PKCE + rotating-refresh implementation is exactly the kind of
|
||||
duplication that drifts apart and then disagrees about something subtle.
|
||||
|
||||
So `login` is a **non-default feature** of this crate. A server built with
|
||||
default features gets the verifier and nothing more — no `reqwest`, no tokio
|
||||
networking, no browser launcher. The CLI opts in with
|
||||
`features = ["login"]`, and the desktop app can do the same.
|
||||
|
||||
Shipped as `wifi-densepose login` / `logout` / `whoami`. Two properties worth
|
||||
restating because they are easy to get wrong:
|
||||
|
||||
* **Refresh is serialised and never retried.** Identity rotates refresh tokens
|
||||
with reuse detection, so a concurrent refresh looks like replay and a retry
|
||||
*is* replay — either revokes the session family. `Session::ensure_fresh`
|
||||
holds an async mutex across the network call, re-checks expiry after
|
||||
acquiring it, and persists the rotated token before returning it.
|
||||
* **Least scope by default.** `login` requests `sensing:read`; `--admin` is an
|
||||
explicit escalation and requests both scopes, since there is no hierarchy
|
||||
server-side.
|
||||
@@ -1,185 +0,0 @@
|
||||
# ADR-272: WebSocket authentication tickets
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-22
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: auth, websocket, security, sensing-server
|
||||
- **Related**: ADR-271 (Cognitum OAuth resource server), ADR-055 (integrated sensing server), PR #1313 (the exemption this supersedes), cognitum-one/dashboard ADR-060
|
||||
|
||||
## Context
|
||||
|
||||
`bearer_auth` gates `/api/v1/*`. WebSocket upgrade endpoints were exempt, for a
|
||||
real reason: a browser's `WebSocket` constructor cannot attach an
|
||||
`Authorization` header to the handshake, so a gated socket is simply
|
||||
unreachable from page JavaScript. `/ws/sensing` and `/ws/introspection` sat
|
||||
outside `PROTECTED_PREFIX` entirely; `/api/v1/stream/pose` was added to an
|
||||
explicit `EXEMPT_PATHS` list by PR #1313.
|
||||
|
||||
The reasoning was sound. The consequence was not, and it was measured rather
|
||||
than argued. On a server with `RUVIEW_API_TOKEN` set — an operator who believes
|
||||
authentication is ON — a real WebSocket handshake carrying **no credential at
|
||||
all**:
|
||||
|
||||
```
|
||||
/ws/sensing -> 101 Switching Protocols
|
||||
/ws/introspection -> 101 Switching Protocols
|
||||
/api/v1/stream/pose -> 101 Switching Protocols
|
||||
/api/v1/models -> 401 Unauthorized (control)
|
||||
```
|
||||
|
||||
**The control plane was locked and the data plane was open.** `/ws/sensing`
|
||||
carries the live sensing output — presence, pose, breathing and heart rate.
|
||||
`/ws/introspection` exposes internal pipeline state. For the ADR-055 desktop
|
||||
topology (server bundled in the app, loopback only) that is bounded. For the
|
||||
LAN/hub deployment RuView also supports, anyone who can reach the port can
|
||||
watch the sensor.
|
||||
|
||||
ADR-271 sharpened the contrast rather than causing it: the REST surface is now
|
||||
genuinely strong — offline-verified Cognitum tokens, scope-separated
|
||||
destructive routes — which makes an ungated data plane the obvious way in.
|
||||
|
||||
*Precision about the evidence:* the handshake completing was verified. A
|
||||
payload frame was not captured in that window, so the finding is "the
|
||||
connection is established without a credential", not "data was read".
|
||||
|
||||
## Decision
|
||||
|
||||
Gate every WebSocket upgrade. Accept **either** of two credentials, chosen to
|
||||
match what each kind of client can actually do.
|
||||
|
||||
### 1. Native clients send a bearer on the upgrade
|
||||
|
||||
The Python client, the Rust CLI and the TypeScript MCP client are not browsers
|
||||
and have never been subject to the header limitation. They **can** send a normal
|
||||
`Authorization: Bearer` on the handshake, so the server accepts one there;
|
||||
routing them through a ticket would add a round-trip and a second credential
|
||||
path for no benefit.
|
||||
|
||||
> **Correction, 2026-07-23.** This section previously stated that those clients
|
||||
> **do** send a bearer. The published Python client does not:
|
||||
> `python/wifi_densepose/client/ws.py` calls `websockets.connect(url,
|
||||
> ping_interval, ping_timeout, max_size)` and passes no headers at all — the
|
||||
> file contains zero occurrences of `extra_headers` or `Authorization`. So every
|
||||
> `wifi-densepose[client]` consumer **401s the moment an operator enables
|
||||
> auth**, and this ADR told them they would be fine.
|
||||
>
|
||||
> The server side of the decision stands — a bearer on the upgrade is accepted,
|
||||
> and that is the right contract for a non-browser client. What is missing is
|
||||
> the client implementing it, tracked as ruvnet/RuView#1395. Until then the only
|
||||
> remedy available to those users is
|
||||
> `RUVIEW_WS_LEGACY_UNAUTHENTICATED=1`, which reopens the exposure this ADR
|
||||
> exists to close — so it is a migration aid with a deadline, not an answer.
|
||||
|
||||
### 2. Browsers exchange their credential for a single-use ticket
|
||||
|
||||
`POST /api/v1/ws-ticket` is an ordinary authenticated request — where headers
|
||||
*do* work — and returns an opaque ticket the page appends as
|
||||
`?ticket=<value>` on the socket URL.
|
||||
|
||||
**A credential in a URL is normally a mistake.** URLs reach access logs,
|
||||
`Referer` headers and browser history. Three properties bound this one, and all
|
||||
three are load-bearing:
|
||||
|
||||
| Property | Why it matters |
|
||||
|---|---|
|
||||
| **Single use** — consumed on the first upgrade attempt, valid or not | A ticket found in a log is already spent |
|
||||
| **~30 second TTL** | Long enough to open a socket; not long enough to harvest |
|
||||
| **Not the credential** — authorizes one WebSocket | Cannot be replayed against `/api/v1/*`, cannot be refreshed, carries no reusable identity |
|
||||
|
||||
The long-lived bearer token is still never placed in a URL.
|
||||
|
||||
A ticket **inherits the issuing principal's scopes**, so a `sensing:read`
|
||||
session cannot mint one that outranks itself, and a ticket from a token without
|
||||
`sensing:read` is refused at the upgrade.
|
||||
|
||||
### 3. WebSocket paths are matched by **prefix**, not by an allowlist
|
||||
|
||||
Anything under `/ws/` is treated as an upgrade path, plus the one endpoint that
|
||||
lives outside it (`/api/v1/stream/pose`).
|
||||
|
||||
This is the most important detail in the ADR. An allowlist means every
|
||||
WebSocket route added later is ungated until someone remembers to extend it —
|
||||
the same bug, reintroduced on a delay. It is not hypothetical:
|
||||
`/ws/train/progress` (ADR-186, arriving with PR #1387) is already referenced by
|
||||
`ui/services/training.service.js` and would have shipped unauthenticated under
|
||||
an allowlist. Prefix matching gates it on arrival.
|
||||
|
||||
New WebSocket routes should live under `/ws/` and inherit gating for free.
|
||||
|
||||
### 4. A migration escape hatch, deliberately uncomfortable
|
||||
|
||||
`RUVIEW_WS_LEGACY_UNAUTHENTICATED=1` restores the previous behaviour. Gating
|
||||
these paths **breaks a browser UI that has not yet been updated to fetch a
|
||||
ticket**, and not every deployment can update server and UI in lockstep.
|
||||
|
||||
It is a migration aid, not a supported configuration:
|
||||
|
||||
- It logs a warning on every boot naming the actual exposure — "the live
|
||||
sensing stream — presence, pose and vital signs — is readable by anyone who
|
||||
can reach this port" — rather than something an operator can skim past.
|
||||
- Its blast radius is exactly the WebSocket paths. A test pins that it does not
|
||||
weaken `/api/v1/*`.
|
||||
- It is read **once at construction**, so changing the environment cannot
|
||||
silently open the paths on a running server.
|
||||
|
||||
The alternative — a clean break with no hatch — was considered and rejected as
|
||||
sequencing, not principle: a hard break tempts an operator into turning auth off
|
||||
entirely, which is strictly worse than a narrow, loudly-announced exception.
|
||||
The hatch should be removed once the shipped UI fetches tickets.
|
||||
|
||||
### 5. Deployments with auth off are unchanged
|
||||
|
||||
No credential configured ⇒ the middleware is the same no-op it has always been.
|
||||
Pinned by a test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The measured hole is closed: all three paths now return `401` to a
|
||||
credential-less handshake, while a bearer or a valid ticket returns `101`.
|
||||
- Browser UIs need updating. Shipped in the same change for
|
||||
`sensing.service.js`, `websocket-client.js` and `observatory/js/main.js` via
|
||||
a shared `withWsTicket()` helper; a ticket is minted per connection attempt
|
||||
and never cached, because it is single-use and short-lived.
|
||||
- A UI running against a server that predates this ADR still works: the helper
|
||||
treats `404` from `/api/v1/ws-ticket` as "no ticket needed".
|
||||
- One more round-trip before a browser opens a socket. Negligible against a
|
||||
stream that then runs for minutes.
|
||||
- Tickets live in memory, capped at 512 outstanding and self-healing as they
|
||||
expire, so an authenticated but misbehaving caller cannot grow the store
|
||||
without bound. In-memory is correct rather than convenient: a ticket
|
||||
surviving a restart would outlive the server that vouched for it.
|
||||
|
||||
## Supersedes
|
||||
|
||||
PR #1313's `enabled_exempts_pose_stream_websocket`, which asserted the
|
||||
exemption. Its premise about browsers was correct and is preserved here; its
|
||||
conclusion is replaced. The test was renamed and inverted rather than deleted,
|
||||
with the history in its doc comment, and the half that still matters — the
|
||||
WebSocket rule must not leak to other `/api/v1/*` paths — is kept.
|
||||
|
||||
## Deliberately not done
|
||||
|
||||
- **`/health*` stays ungated.** Orchestrator probes hit it anonymously, and
|
||||
that is the point of a liveness endpoint. `/health/metrics` is included in
|
||||
that exemption; if metrics ever carry occupancy-derived values this should be
|
||||
revisited, because that would make them sensing data wearing an ops label.
|
||||
- **`/ui/*` stays ungated.** It is static assets; the data behind them is
|
||||
gated.
|
||||
- **No revocation of an issued ticket.** It expires in seconds and is
|
||||
single-use; a revocation path would be more machinery than the exposure
|
||||
justifies.
|
||||
- **No ticket for native clients.** They can send a header, so they should.
|
||||
|
||||
## Implementation
|
||||
|
||||
`v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs` (store),
|
||||
`src/bearer_auth.rs` (gating), `src/main.rs` (`POST /api/v1/ws-ticket`),
|
||||
`ui/services/ws-ticket.js` plus the three call sites.
|
||||
|
||||
Tests: 12 store, 9 gating, 4 path-matching. Store coverage includes single-use
|
||||
enforcement, replay refusal, expiry refusal *and* pruning, 256-bit
|
||||
unpredictability, cap enforcement and self-healing, and `?myticket=x` not being
|
||||
read as `?ticket=x`. Gating coverage includes every known WS path refusing an
|
||||
unauthenticated upgrade, bearer acceptance, ticket single-use, a ticket being
|
||||
useless against REST, the escape hatch working *and* not weakening REST, and
|
||||
auth-off behaviour unchanged.
|
||||
+1
-13
@@ -1,15 +1,6 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [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)
|
||||
- [ADR-184: ADR-117 completion via PyPI Trusted Publishing](ADR-184-adr117-completion-pypi-trusted-publishing.md) (refs #785)
|
||||
- [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 193 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
|
||||
This folder contains 45 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project.
|
||||
|
||||
## Why ADRs?
|
||||
|
||||
@@ -129,9 +120,6 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
|
||||
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
|
||||
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
|
||||
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
|
||||
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
|
||||
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -425,7 +425,7 @@ pub enum WifiChipset {
|
||||
BroadcomBcm43455,
|
||||
/// Realtek RTL8822CS via modified rtw88 driver.
|
||||
RealtekRtl8822cs,
|
||||
/// Proposed MediaTek MT7661 research target; no public CSI export is verified.
|
||||
/// MediaTek MT7661 via mt76 driver modification.
|
||||
MediatekMt7661,
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ pub struct Esp32CompatFrame {
|
||||
```
|
||||
|
||||
**Domain Services:**
|
||||
- `CsiExtractionService` — Reads raw CSI from a validated chipset adapter. Nexmon/BCM43455 is the established Linux example; RTL8822CS and MT7661 remain unverified research targets and must not be advertised as working capture paths.
|
||||
- `CsiExtractionService` — Reads raw CSI from patched driver via Netlink socket (BCM43455), procfs (RTL8822CS), or UDP (MT7661)
|
||||
- `SubcarrierResamplerService` — Resamples chipset-specific subcarrier counts to match ESP32 format (e.g., 256 → 128 via decimation or interpolation)
|
||||
- `ProtocolTranslatorService` — Converts `ChipsetCsiFrame` to `Esp32CompatFrame` with ADR-018 binary encoding
|
||||
- `CalibrationService` — Compensates for chipset-specific phase offsets, antenna spacing, and gain differences relative to ESP32 CSI
|
||||
@@ -625,7 +625,7 @@ pub struct EspNodeConnection {
|
||||
|
||||
### ESP32 Protocol ACL (CSI Bridge)
|
||||
|
||||
The WiFi CSI Bridge translates validated chipset-specific CSI formats into a versioned RuView envelope. Nexmon is the established Linux example; rtw88 and mt76 require a verified complex-CSI export before implementation. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
|
||||
The WiFi CSI Bridge translates chipset-specific CSI formats (Nexmon, rtw88, mt76) into the ESP32 binary protocol (ADR-018). The sensing server never knows whether frames came from a real ESP32 or a TV box WiFi chipset. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
|
||||
|
||||
### Armbian Platform ACL
|
||||
|
||||
|
||||
@@ -4,12 +4,9 @@ Operations doc for the `.github/workflows/pip-release.yml` CI workflow.
|
||||
|
||||
## Auth
|
||||
|
||||
Production uses the GitHub Actions secret `PYPI_API_TOKEN`. It is a
|
||||
project token issued by the rUv PyPI account with upload scope for both
|
||||
`wifi-densepose` and `ruview`.
|
||||
|
||||
TestPyPI uses a separate `TESTPYPI_API_TOKEN` secret issued by
|
||||
test.pypi.org. PyPI and TestPyPI accounts and tokens are independent.
|
||||
The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`.
|
||||
It's a project-token issued by the rUv PyPI account with upload
|
||||
scope for both `wifi-densepose` and `ruview`.
|
||||
|
||||
## Refreshing the token
|
||||
|
||||
@@ -50,19 +47,16 @@ Per ADR-117 §7.3, the tombstone publishes first so it claims the
|
||||
tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/`
|
||||
2. Verify: `pip install wifi-densepose==1.99.0; python -c "import
|
||||
wifi_densepose"` → ImportError with migration URL.
|
||||
3. Confirm `archive/v1/data/proof/expected_features_v2.sha256` is
|
||||
committed and non-empty. Production publishing fails closed without it.
|
||||
4. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → the v2
|
||||
`wifi-densepose` wheel matrix and matching `ruview` wheel/sdist are
|
||||
published together. Their versions and dependency pin are checked in CI.
|
||||
5. Verify both `https://pypi.org/project/wifi-densepose/2.0.0/` and
|
||||
`https://pypi.org/project/ruview/2.0.0/`.
|
||||
3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel
|
||||
matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`.
|
||||
4. (Optional, in lock-step) build + publish a matching `ruview`
|
||||
release from `python/ruview-meta/` so the meta-package version
|
||||
stays pinned to the same wifi-densepose version.
|
||||
|
||||
## Off-loop manual gates
|
||||
|
||||
- **Q3** (ADR-117 §11.3) — generate
|
||||
`archive/v1/data/proof/expected_features_v2.sha256` from the v2 Rust
|
||||
pipeline before a production v2 publish. The workflow enforces this gate.
|
||||
- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256`
|
||||
from the v2 Rust pipeline before any v2 publish.
|
||||
- **OIDC Trusted Publisher** — not used. The workflow is token-based;
|
||||
this is a deliberate choice to keep the secret refresh entirely in
|
||||
GCP. If the project migrates to OIDC later, remove `password:`
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# RuView v0.9.0-realtek-beta.1
|
||||
|
||||
This prerelease introduces the Rust-first RTL8720F 2.4 GHz radar transport and
|
||||
RuView ingestion path. It is intentionally simulator-validated until Realtek
|
||||
hardware and the vendor SDK callback ABI arrive.
|
||||
|
||||
## Included
|
||||
|
||||
- ADR-263 records the upstream Ameba integration and licensing boundary.
|
||||
- ADR-264 defines a versioned, bounded, CRC-protected radar envelope.
|
||||
- `rtl8720f-sim` emits deterministic CFR, near-range, far-range, interference,
|
||||
and capability reports to UDP or replay files.
|
||||
- The sensing server validates RTL8720F datagrams, publishes bounded summaries
|
||||
over `/ws/sensing`, and exposes the latest report at
|
||||
`/api/v1/radar/latest`.
|
||||
- Synthetic provenance is retained end to end as `realtek:simulated`; simulator
|
||||
data is never presented as hardware data.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The adapter tracks the radar control surface proposed by Ameba RTOS pull
|
||||
request #1336 (`wifi_radar_config`, `AT+RAD`, and `AT+RADDBG`). The stable Ameba
|
||||
RTOS v1.2.1 release does not yet expose the complete radar receive callback ABI,
|
||||
so no vendor-private headers or binary libraries are copied into this release.
|
||||
|
||||
## Validation status
|
||||
|
||||
- Rust codec round trips, corruption rejection, size bounds, and deterministic
|
||||
simulator tests pass.
|
||||
- RuView server ingestion, REST reporting, and source provenance were exercised
|
||||
end to end over loopback UDP.
|
||||
- Windows release binaries are built from this branch and accompanied by
|
||||
SHA-256 checksums.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- No physical RTL8720F board has been flashed or measured.
|
||||
- The vendor report callback and exact report layouts remain an SDK/hardware
|
||||
validation gate; the adapter boundary may change when those arrive.
|
||||
- This beta exposes transport and aggregate radar observability. Radar-to-pose,
|
||||
vital-sign inference, RF calibration, and accuracy claims are not enabled.
|
||||
- 2.4 GHz radar reports are not mislabeled as mmWave or Wi-Fi CSI events.
|
||||
|
||||
Do not deploy this prerelease for safety-critical, medical, or occupancy billing
|
||||
uses. It is an integration beta for SDK and hardware bring-up.
|
||||
@@ -1,32 +0,0 @@
|
||||
# RuView v0.9.1-mediatek-beta.1
|
||||
|
||||
This simulator-first beta adds a Rust MediaTek Filogic MIMO CSI transport and
|
||||
RuView ingestion path while preserving the boundary between demonstrated host
|
||||
integration and unavailable physical CSI export.
|
||||
|
||||
## Included
|
||||
|
||||
- ADR-266 selects OpenWrt One (MT7981/MT7976) as the primary future hardware
|
||||
target and BPI-R3 (MT7986/MT7975) as the secondary 4x4 target.
|
||||
- ADR-267 defines the bounded, versioned, CRC-protected `MTC1` wire protocol.
|
||||
- `mediatek-csi-sim` provides deterministic MT7981, MT7986, and MT7996 profiles,
|
||||
complex MIMO CSI, per-chain RSSI, UDP streaming, and replay output.
|
||||
- RuView validates MediaTek datagrams, publishes bounded WebSocket summaries,
|
||||
and exposes `/api/v1/csi/mediatek/latest`.
|
||||
- `mediatek:simulated` provenance is retained end to end.
|
||||
|
||||
## Validation
|
||||
|
||||
- Codec round trips, deterministic output, corruption/truncation rejection,
|
||||
dimension limits, finite-value enforcement, and prefix parsing are tested.
|
||||
- All hardware and sensing-server regression tests pass.
|
||||
- All three profiles were streamed over loopback UDP and verified through the
|
||||
RuView REST API.
|
||||
|
||||
## Hardware boundary
|
||||
|
||||
Upstream `mt76` and public MediaTek SDK material do not currently expose a
|
||||
supported raw complex CSI API. This release does not redistribute private SDK
|
||||
material, invent a firmware ABI, or claim physical MediaTek capture. Hardware
|
||||
support requires a documented firmware/driver channel-estimate export followed
|
||||
by calibration and repeatability validation.
|
||||
@@ -1,22 +0,0 @@
|
||||
# RuView v0.9.2-qualcomm-beta.1
|
||||
|
||||
This simulator-first beta adds a Rust Qualcomm Atheros CSI boundary without
|
||||
claiming modern Qualcomm firmware exports that have not been physically verified.
|
||||
|
||||
## Included
|
||||
|
||||
- ADR-268 selects QCA9300 as the first physical baseline and treats QCN9074 and
|
||||
QCN9274 as experimental modern profiles.
|
||||
- ADR-269 defines the bounded, versioned, CRC-protected `QCS1` protocol.
|
||||
- `qualcomm-csi-sim` emits deterministic MIMO CSI over UDP or replay files.
|
||||
- The sensing server validates QCS1 datagrams, broadcasts bounded summaries and
|
||||
exposes `/api/v1/csi/qualcomm/latest`.
|
||||
- `qualcomm:simulated` provenance is retained end to end.
|
||||
|
||||
## Validation boundary
|
||||
|
||||
Codec, corruption, truncation, finite-value, dimensions, chipset bandwidth,
|
||||
determinism and prefix parsing are automated. Loopback UDP/API validation covers
|
||||
all profiles. Physical QCA9300 comparison and modern firmware export validation
|
||||
remain hardware gates and will be published with firmware and calibration details.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# RuView v0.9.3 Vendor Providers Beta 1
|
||||
|
||||
This beta implements ADR-270 as a capability-safe Rust provider program across
|
||||
all ten researched vendors.
|
||||
|
||||
## Included
|
||||
|
||||
- Shared `VendorRfProvider` contract with bounded event validation.
|
||||
- Origin AI, Plume/OpenSync, Mist/Juniper, NETGEAR Insight, Electric Imp,
|
||||
RF Solutions, Luma/OpenWrt and Google Nest contract adapters.
|
||||
- Explicit fail-closed Linksys (`Unsupported`) and Wifigarden
|
||||
(`ContractRequired`) providers.
|
||||
- Deterministic `vendor-rf-sim` JSONL/UDP fixtures for defined contracts.
|
||||
- Provider registry, descriptors, latest-event REST endpoints and WebSocket
|
||||
summaries through the sensing server.
|
||||
|
||||
## Boundary
|
||||
|
||||
This release implements and validates software contracts. It does not claim
|
||||
vendor-cloud credentials, commercial SDK rights, physical hardware validation,
|
||||
or complex CSI support for telemetry-only providers.
|
||||
+1
-31
@@ -1141,20 +1141,7 @@ What it ships (and what it does not):
|
||||
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
|
||||
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
|
||||
| 17-keypoint full-body pose | 🔬 This HF bundle ships no keypoint head — but real pose weights exist elsewhere; see the tier table below |
|
||||
|
||||
### Model weights: what's real, what's not
|
||||
|
||||
"WiFi → pose" means three different things in this repo, at three different maturity
|
||||
levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
|
||||
|
||||
| Tier | Checkpoint(s) | Honest status |
|
||||
|------|---------------|---------------|
|
||||
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. |
|
||||
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`**. Full disclosure in the [cog README](../v2/crates/cog-pose-estimation/cog/README.md). Do not advertise the live single-ESP32 17-keypoint feature without this caveat. |
|
||||
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](../archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
|
||||
|
||||
**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).
|
||||
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
|
||||
|
||||
### Download
|
||||
|
||||
@@ -1874,23 +1861,6 @@ node scripts/eval-wiflow.js \
|
||||
--data data/paired/*.jsonl
|
||||
```
|
||||
|
||||
> **Model format boundary:** `train-wiflow-supervised.js` produces the
|
||||
> JavaScript WiFlow model `wiflow-v1.json`. There is currently no supported
|
||||
> command that converts that JSON model into the sensing server's binary RVF
|
||||
> container, and renaming the file to `.rvf` does not convert it. Use the JSON
|
||||
> model with the JavaScript evaluation/inference tools. To train a model that
|
||||
> the Rust sensing server can load, use its native training path, which writes
|
||||
> RVF directly:
|
||||
>
|
||||
> ```bash
|
||||
> cargo run -p wifi-densepose-sensing-server --release -- \
|
||||
> --train --dataset data/mmfi --dataset-type mmfi \
|
||||
> --epochs 100 --save-rvf models/room-model.rvf
|
||||
> ```
|
||||
>
|
||||
> The camera+CSI paired JSONL workflow and the native RVF trainer are separate
|
||||
> pipelines today. A JSON-to-RVF exporter is future work.
|
||||
|
||||
**Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized
|
||||
PCK@20, the metric comparable to published WiFi-pose results) on a temporal
|
||||
hold-out, and sanity-check that predictions actually vary across frames
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# ADR-270 Vendor RF Providers
|
||||
|
||||
RuView exposes a capability-safe Rust provider layer for vendor sensing and RF
|
||||
telemetry. It never converts RSSI, occupancy, location or network inventory into
|
||||
complex CSI.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/v1/rf/vendors` — all provider descriptors and access states.
|
||||
- `GET /api/v1/rf/vendors/latest` — latest validated event per vendor.
|
||||
- `GET /api/v1/rf/vendors/:vendor/latest` — latest event for one stable vendor ID.
|
||||
- `POST /api/v1/rf/vendors/:vendor/events` — ingest the vendor's documented
|
||||
sidecar/webhook payload through its strict provider decoder. This `/api/v1/*`
|
||||
route uses the server's bearer-token policy when configured.
|
||||
|
||||
Stable IDs are `origin_ai`, `plume`, `mist`, `netgear`, `electric_imp`,
|
||||
`rf_solutions`, `linksys`, `luma`, `google_nest`, and `wifigarden`.
|
||||
|
||||
## Deterministic simulator
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
|
||||
--vendor plume --frames 100 --output plume.jsonl
|
||||
|
||||
# Stream canonical synthetic events to the sensing server UDP port.
|
||||
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
|
||||
--vendor mist --frames 100 --udp 127.0.0.1:5005 --realtime
|
||||
```
|
||||
|
||||
Supported simulator names are `origin-ai`, `plume`, `mist`, `netgear`,
|
||||
`electric-imp`, `rf-solutions`, `luma`, and `google-nest`. Linksys is refused
|
||||
because its sensing service is discontinued. Wifigarden is refused until a
|
||||
contracted event schema exists.
|
||||
|
||||
Every synthetic event includes `synthetic: true`, a deterministic sequence and
|
||||
timestamp, and a source ending in `-sim-01`.
|
||||
|
||||
Canonical UDP JSON is accepted only when `synthetic: true`. Live vendor payloads
|
||||
must use the HTTP ingestion route so provider-specific schemas, metric allowlists,
|
||||
access states and bounds cannot be bypassed.
|
||||
|
||||
## Live/provider payloads
|
||||
|
||||
Provider decoders are strict, bounded and reject unknown schema fields. Origin
|
||||
paths and credentials are supplied by the commercial contract. Plume uses a
|
||||
read-only allow-listed OVSDB request plan. Mist and NETGEAR configurations use
|
||||
regional HTTPS endpoints with redacted tokens. Electric Imp, RF Solutions and
|
||||
Luma accept only allow-listed scalar metrics. Google Nest remains network-only.
|
||||
|
||||
Credentials are never embedded in fixtures or descriptors. Linksys returns
|
||||
`Unsupported`; Wifigarden returns `ContractRequired`. These are usable,
|
||||
test-covered provider outcomes—not simulated integrations.
|
||||
|
||||
## Hardware honesty
|
||||
|
||||
All descriptors remain `hardware_validated: false` until exact hardware/cloud
|
||||
versions, lawful access, repeatable captures, calibration where applicable, and
|
||||
fixture publication rights have been verified. Passing the simulator and API
|
||||
tests validates RuView software only.
|
||||
@@ -1,5 +1,5 @@
|
||||
# ESP32 CSI Node Firmware (ADR-018)
|
||||
# Requires ESP-IDF v5.4+
|
||||
# Requires ESP-IDF v5.2+
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
set(EXTRA_COMPONENT_DIRS "")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
|
||||
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
|
||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||
[](../../LICENSE)
|
||||
[](#memory-budget)
|
||||
@@ -48,18 +48,10 @@ with `--flash_size 4MB`.
|
||||
# From the repository root:
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||
```
|
||||
|
||||
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
|
||||
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
|
||||
> display support in, and the runtime panel probe false-positives on boards
|
||||
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
|
||||
> collapses CSI yield to 0 pps. See the header of
|
||||
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
|
||||
> build command.
|
||||
|
||||
### 2. Flash
|
||||
|
||||
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
||||
@@ -113,8 +105,6 @@ curl http://<ESP32_IP>:8032/wasm/list
|
||||
|
||||
> **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.
|
||||
|
||||
> **⚠️ 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.
|
||||
|
||||
---
|
||||
|
||||
## Firmware Architecture
|
||||
@@ -260,7 +250,7 @@ Offset Size Field
|
||||
# From the repository root:
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||
```
|
||||
|
||||
@@ -278,7 +268,7 @@ To change Kconfig settings before building:
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 docker run --rm -it \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
"idf.py set-target esp32s3 && idf.py menuconfig"
|
||||
```
|
||||
|
||||
|
||||
@@ -319,9 +319,7 @@ static void emit_feature_state(void)
|
||||
(uint64_t)esp_timer_get_time(),
|
||||
profile);
|
||||
|
||||
/* feature_state is ~1 Hz and small — priority path so the CSI ENOMEM
|
||||
* backoff can't starve it (#1183). */
|
||||
int sent = stream_sender_send_priority((const uint8_t *)&pkt, sizeof(pkt));
|
||||
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "feature_state emit failed");
|
||||
}
|
||||
@@ -335,14 +333,11 @@ static void slow_loop_cb(TimerHandle_t t)
|
||||
* detect sync-error drift. */
|
||||
uint8_t nid[8];
|
||||
node_id_bytes(nid);
|
||||
/* #1183: report the actual send result — the old log printed "HEALTH sent"
|
||||
* unconditionally even when rv_mesh_send returned ESP_FAIL. */
|
||||
esp_err_t health_rc = rv_mesh_send_health(s_role, s_mesh_epoch, nid);
|
||||
rv_mesh_send_health(s_role, s_mesh_epoch, nid);
|
||||
|
||||
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH %s",
|
||||
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH sent",
|
||||
(unsigned)s_state, (unsigned)s_feature_state_seq,
|
||||
(unsigned)s_role, (unsigned)s_mesh_epoch,
|
||||
health_rc == ESP_OK ? "sent" : "FAILED");
|
||||
(unsigned)s_role, (unsigned)s_mesh_epoch);
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
@@ -341,9 +341,7 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
memcpy(&sync[24], &s_sequence, 4); /* high-water seq for pairing */
|
||||
uint32_t zero32 = 0;
|
||||
memcpy(&sync[28], &zero32, 4); /* reserved (room for leader_id low32) */
|
||||
/* Sync packets are 32 B at ~0.5 Hz — priority path so the CSI
|
||||
* ENOMEM backoff can't starve cross-node time alignment (#1183). */
|
||||
int sr = stream_sender_send_priority(sync, sizeof(sync));
|
||||
int sr = stream_sender_send(sync, sizeof(sync));
|
||||
static uint32_t s_sync_count = 0;
|
||||
s_sync_count++;
|
||||
if (s_sync_count <= 3 || (s_sync_count % 60) == 0) {
|
||||
|
||||
@@ -67,8 +67,6 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
|
||||
esp_wifi_connect();
|
||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
|
||||
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
|
||||
if (s_retry_num < MAX_RETRY) {
|
||||
esp_wifi_connect();
|
||||
s_retry_num++;
|
||||
@@ -104,10 +102,7 @@ static void wifi_init_sta(void)
|
||||
|
||||
wifi_config_t wifi_config = {
|
||||
.sta = {
|
||||
/* WPA_PSK (not WPA2_PSK) so routers running WPA/WPA2-mixed
|
||||
* compatibility mode aren't rejected with
|
||||
* WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (#1050). */
|
||||
.threshold.authmode = WIFI_AUTH_WPA_PSK,
|
||||
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @file mmwave_detect.h
|
||||
* @brief Pure (host-testable) mmWave frame-validation predicates for probe-time
|
||||
* sensor detection. No ESP-IDF deps — safe to #include in a host unit test.
|
||||
*
|
||||
* Detection must validate a *full* frame, never a bare header byte/pattern: a
|
||||
* floating UART with no sensor reads line noise that can contain header-looking
|
||||
* bytes, which the old loose checks mistook for a real sensor (#1107 MR60,
|
||||
* #1135 LD2410). These predicates are the validate-before-trust gate.
|
||||
*/
|
||||
#ifndef MMWAVE_DETECT_H
|
||||
#define MMWAVE_DETECT_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/**
|
||||
* True iff buf[i..] begins a *validated* LD2410 report frame within [0,len):
|
||||
* F4 F3 F2 F1 | len(LE,2) | data[len] | F8 F7 F6 F5
|
||||
* Requires the head magic, a sane intra-frame length, AND the matching tail at
|
||||
* head+6+len. Pure noise that merely contains 0xF4F3F2F1 fails the tail check.
|
||||
*/
|
||||
static inline bool mmwave_ld2410_valid_at(const uint8_t *buf, int i, int len)
|
||||
{
|
||||
if (i < 0 || i + 5 >= len) return false;
|
||||
if (!(buf[i] == 0xF4 && buf[i+1] == 0xF3 && buf[i+2] == 0xF2 && buf[i+3] == 0xF1))
|
||||
return false;
|
||||
uint16_t flen = (uint16_t)buf[i+4] | ((uint16_t)buf[i+5] << 8);
|
||||
/* Real LD2410 report frames are small (basic=13, engineering=35). */
|
||||
if (flen < 1 || flen > 64) return false;
|
||||
int tail = i + 6 + (int)flen;
|
||||
if (tail + 3 >= len) return false;
|
||||
return buf[tail] == 0xF8 && buf[tail+1] == 0xF7
|
||||
&& buf[tail+2] == 0xF6 && buf[tail+3] == 0xF5;
|
||||
}
|
||||
|
||||
#endif /* MMWAVE_DETECT_H */
|
||||
@@ -26,7 +26,6 @@
|
||||
*/
|
||||
|
||||
#include "mmwave_sensor.h"
|
||||
#include "mmwave_detect.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
@@ -402,12 +401,10 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
|
||||
}
|
||||
}
|
||||
}
|
||||
/* LD2410: require a *full validated* report frame, not just the
|
||||
* 4-byte head. A floating UART1 at 256000 baud can emit the head
|
||||
* pattern 0xF4F3F2F1 from line noise (#1135 bug #2). The shared
|
||||
* predicate (host-unit-tested in mmwave_detect.h) demands a sane
|
||||
* intra-frame length AND the matching tail 0xF8F7F6F5. */
|
||||
if (baud == MMWAVE_LD2410_BAUD && mmwave_ld2410_valid_at(buf, i, len)) {
|
||||
/* LD2410: 4-byte header 0xF4F3F2F1 (already specific enough). */
|
||||
if (i + 3 < len && buf[i] == 0xF4 && buf[i+1] == 0xF3
|
||||
&& buf[i+2] == 0xF2 && buf[i+3] == 0xF1
|
||||
&& baud == MMWAVE_LD2410_BAUD) {
|
||||
ld2410_header_seen++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +188,7 @@ size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
|
||||
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
|
||||
{
|
||||
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
|
||||
/* Mesh control packets (HEALTH, anomaly) are low-rate and tiny — send them
|
||||
* on the priority path so the CSI ENOMEM backoff can't starve them (#1183). */
|
||||
int sent = stream_sender_send_priority(frame, len);
|
||||
int sent = stream_sender_send(frame, len);
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
|
||||
(unsigned)len);
|
||||
|
||||
@@ -26,16 +26,9 @@ static struct sockaddr_in s_dest_addr;
|
||||
* rapid-fire CSI callbacks can exhaust the pbuf pool and crash the device.
|
||||
*/
|
||||
static int64_t s_backoff_until_us = 0; /* esp_timer timestamp to resume */
|
||||
#define ENOMEM_COOLDOWN_MS 100 /* base backoff; doubles per streak */
|
||||
#define ENOMEM_COOLDOWN_MAX_MS 2000 /* cap on the exponential backoff */
|
||||
#define ENOMEM_COOLDOWN_MS 100 /* suppress sends for 100 ms */
|
||||
#define ENOMEM_LOG_INTERVAL 50 /* log every Nth suppressed send */
|
||||
static uint32_t s_enomem_suppressed = 0;
|
||||
/* Consecutive ENOMEM episodes without an intervening successful send. A fixed
|
||||
* 100 ms backoff is too short to drain sustained lwIP/WiFi buffer pressure
|
||||
* (#1135 bug #1: tier-2 + concurrent TX keeps the node stuck), so the backoff
|
||||
* grows 100→200→400→…→2000 ms per streak and resets on the first send that
|
||||
* succeeds. */
|
||||
static uint32_t s_enomem_streak = 0;
|
||||
|
||||
static int sender_init_internal(const char *ip, uint16_t port)
|
||||
{
|
||||
@@ -100,52 +93,16 @@ int stream_sender_send(const uint8_t *data, size_t len)
|
||||
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
|
||||
if (sent < 0) {
|
||||
if (errno == ENOMEM) {
|
||||
/* Exponential backoff: double the cooldown each consecutive ENOMEM
|
||||
* (capped) so sustained buffer pressure actually drains instead of
|
||||
* the node re-failing every 100 ms forever (#1135 bug #1). */
|
||||
uint32_t shift = s_enomem_streak < 5 ? s_enomem_streak : 5;
|
||||
uint32_t cooldown = ENOMEM_COOLDOWN_MS << shift;
|
||||
if (cooldown > ENOMEM_COOLDOWN_MAX_MS) cooldown = ENOMEM_COOLDOWN_MAX_MS;
|
||||
s_enomem_streak++;
|
||||
s_backoff_until_us = esp_timer_get_time() + (int64_t)cooldown * 1000;
|
||||
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %lu ms (streak %lu)",
|
||||
(unsigned long)cooldown, (unsigned long)s_enomem_streak);
|
||||
/* Start backoff to let lwIP reclaim buffers */
|
||||
s_backoff_until_us = esp_timer_get_time() +
|
||||
(int64_t)ENOMEM_COOLDOWN_MS * 1000;
|
||||
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %d ms", ENOMEM_COOLDOWN_MS);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* A send got through — buffer pressure cleared; reset the backoff streak. */
|
||||
s_enomem_streak = 0;
|
||||
return sent;
|
||||
}
|
||||
|
||||
int stream_sender_send_priority(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (s_sock < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Priority path (#1183): low-rate control packets (feature_state, HEALTH,
|
||||
* mesh sync) bypass the global ENOMEM backoff gate so the high-rate CSI
|
||||
* stream cannot starve them. These are ≤48 B at ≤1 Hz — negligible pbuf
|
||||
* pressure, so they won't re-trigger the crash cascade that the backoff
|
||||
* (driven by the 50 Hz CSI flood) exists to prevent.
|
||||
*
|
||||
* Crucially, an ENOMEM here is reported quietly and does NOT extend the
|
||||
* global streak/backoff: a tiny control packet failing is a symptom of
|
||||
* the bulk-stream pressure, not a cause, so it must not feed the cooldown
|
||||
* that suppresses the next CSI frame. Likewise a success does not reset
|
||||
* the streak — the bulk path owns that signal. */
|
||||
int sent = sendto(s_sock, data, len, 0,
|
||||
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
|
||||
if (sent < 0) {
|
||||
if (errno != ENOMEM) {
|
||||
ESP_LOGW(TAG, "priority sendto failed: errno %d", errno);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,20 +36,6 @@ int stream_sender_init_with(const char *ip, uint16_t port);
|
||||
*/
|
||||
int stream_sender_send(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* Send a low-rate control packet, bypassing the ENOMEM backoff gate (#1183).
|
||||
*
|
||||
* Intended for ≤48 B, ≤1 Hz control traffic (feature_state, HEALTH, mesh
|
||||
* sync) that must not be starved by the global backoff the high-rate CSI
|
||||
* stream triggers. An ENOMEM on this path is reported quietly and does NOT
|
||||
* extend or reset the global backoff streak.
|
||||
*
|
||||
* @param data Frame data buffer.
|
||||
* @param len Length of data to send.
|
||||
* @return Number of bytes sent, or -1 on error.
|
||||
*/
|
||||
int stream_sender_send_priority(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* Close the UDP sender socket.
|
||||
*/
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# DevKitC-1 (display-less) production overlay.
|
||||
#
|
||||
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
|
||||
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
|
||||
# SH8601 init sequence reports success, display_is_active() returns true, and
|
||||
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
|
||||
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
|
||||
# out makes has_display constant-false so the upgrade always applies.
|
||||
#
|
||||
# Build (from repo root, per README "Docker — the only reliable method"):
|
||||
# MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
# espressif/idf:v5.4 bash -c \
|
||||
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
|
||||
|
||||
# CONFIG_DISPLAY_ENABLE is not set
|
||||
@@ -44,9 +44,9 @@ FUZZ_DURATION ?= 30
|
||||
FUZZ_JOBS ?= 1
|
||||
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests
|
||||
test_vitals run_vitals host_tests
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
|
||||
|
||||
# --- ADR-110 encoding unit tests ---
|
||||
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
|
||||
@@ -69,19 +69,8 @@ test_vitals: test_vitals_count_presence.c $(MAIN_DIR)/edge_processing.h
|
||||
run_vitals: test_vitals
|
||||
./test_vitals
|
||||
|
||||
# --- mmWave LD2410 detection predicate (#1135 bug #2) ---
|
||||
# Host-side, no libFuzzer. Proves a floating-UART head pattern (0xF4F3F2F1)
|
||||
# without a valid frame length+tail is REJECTED, so a phantom LD2410 is never
|
||||
# detected on a node with no sensor wired. Tests the real predicate the
|
||||
# firmware uses (../main/mmwave_detect.h) — test and firmware can't disagree.
|
||||
test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
|
||||
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
|
||||
|
||||
run_mmwave_detect: test_mmwave_detect
|
||||
./test_mmwave_detect
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect
|
||||
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
|
||||
host_tests: run_adr110 run_vitals
|
||||
@echo "Host tests passed (ADR-110 + vitals #998/#996)"
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* @file test_mmwave_detect.c
|
||||
* @brief Host-side unit tests for the LD2410 frame-validation predicate (#1135).
|
||||
*
|
||||
* Proves the phantom-detection fix: a floating UART can emit the 4-byte head
|
||||
* 0xF4F3F2F1, but the predicate rejects it unless a sane length + matching tail
|
||||
* 0xF8F7F6F5 are also present. Tests the REAL predicate from mmwave_detect.h
|
||||
* (the same code the firmware's probe_at_baud calls).
|
||||
*
|
||||
* cc -std=c99 -Wall -I../main -o test_mmwave_detect test_mmwave_detect.c && ./test_mmwave_detect
|
||||
*
|
||||
* Exits 0 on all-pass; prints the failing case otherwise.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "mmwave_detect.h"
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \
|
||||
else { printf("ok: %s\n", msg); } \
|
||||
} while (0)
|
||||
|
||||
/* Build a valid LD2410 report frame: F4F3F2F1 | len(LE) | data[len] | F8F7F6F5 */
|
||||
static int make_frame(uint8_t *out, uint16_t dlen)
|
||||
{
|
||||
int n = 0;
|
||||
out[n++] = 0xF4; out[n++] = 0xF3; out[n++] = 0xF2; out[n++] = 0xF1;
|
||||
out[n++] = (uint8_t)(dlen & 0xFF); out[n++] = (uint8_t)(dlen >> 8);
|
||||
for (uint16_t k = 0; k < dlen; k++) out[n++] = (uint8_t)(0xAA ^ k);
|
||||
out[n++] = 0xF8; out[n++] = 0xF7; out[n++] = 0xF6; out[n++] = 0xF5;
|
||||
return n;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
uint8_t buf[256];
|
||||
|
||||
/* 1. A real basic-report frame (data len 13) validates. */
|
||||
int n = make_frame(buf, 13);
|
||||
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid basic frame (len=13) accepted");
|
||||
|
||||
/* 2. A real engineering-report frame (data len 35) validates. */
|
||||
n = make_frame(buf, 35);
|
||||
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid engineering frame (len=35) accepted");
|
||||
|
||||
/* 3. Head magic present but NO valid tail — the #1135 phantom case. */
|
||||
memset(buf, 0x00, sizeof(buf));
|
||||
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=13; buf[5]=0;
|
||||
/* data present but tail is zeros, not F8F7F6F5 */
|
||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "head magic without valid tail REJECTED (#1135)");
|
||||
|
||||
/* 4. Head magic with insane length is rejected. */
|
||||
memset(buf, 0xFF, sizeof(buf));
|
||||
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=0xFF; buf[5]=0xFF; /* len=65535 */
|
||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 200), "head magic with oversized length REJECTED");
|
||||
|
||||
/* 5. Pure noise (no head) is rejected. */
|
||||
for (int k = 0; k < 64; k++) buf[k] = (uint8_t)(0x5A + k);
|
||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "non-header noise REJECTED");
|
||||
|
||||
/* 6. Truncated frame (tail would run past the buffer) is rejected. */
|
||||
n = make_frame(buf, 13);
|
||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, n - 2), "truncated frame (tail past buffer) REJECTED");
|
||||
|
||||
/* 7. Valid frame at a non-zero offset still validates. */
|
||||
memset(buf, 0x00, sizeof(buf));
|
||||
n = make_frame(buf + 7, 13);
|
||||
CHECK(mmwave_ld2410_valid_at(buf, 7, 7 + n), "valid frame at offset 7 accepted");
|
||||
|
||||
/* 8. Repeated head bytes without a frame (worst-case noise) rejected. */
|
||||
for (int k = 0; k + 3 < 64; k += 4) {
|
||||
buf[k]=0xF4; buf[k+1]=0xF3; buf[k+2]=0xF2; buf[k+3]=0xF1;
|
||||
}
|
||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "repeated bare head bytes REJECTED");
|
||||
|
||||
printf("\n%s (%d failures)\n", failures ? "FAILED" : "ALL PASS", failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.8.4
|
||||
0.7.0
|
||||
|
||||
@@ -6,24 +6,24 @@ description: Run the ADR-151 per-room calibration pipeline — baseline → enro
|
||||
# calibrate-room
|
||||
|
||||
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
|
||||
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
|
||||
edge-deployable (ADR-151). Use the `ruview.calibrate` tool (installed
|
||||
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
|
||||
|
||||
## Sequence
|
||||
|
||||
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
|
||||
the room empty.
|
||||
`ruview_calibrate {step: "baseline"}`
|
||||
`ruview.calibrate {step: "baseline"}`
|
||||
2. **enroll** — record the occupant(s) doing the target activities.
|
||||
`ruview_calibrate {step: "enroll"}`
|
||||
`ruview.calibrate {step: "enroll"}`
|
||||
3. **train-room** — train the bank of small specialists from baseline + enrollment.
|
||||
`ruview_calibrate {step: "train-room"}`
|
||||
`ruview.calibrate {step: "train-room"}`
|
||||
4. **room-watch** — live presence/posture/breathing from the trained room.
|
||||
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
|
||||
`ruview.calibrate {step: "room-watch"}` (or the `room-watch` skill)
|
||||
|
||||
## Honesty
|
||||
|
||||
The specialists are calibrated to *this* room; cross-room transfer is a separate
|
||||
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
|
||||
tag presence/vitals accuracy MEASURED only with a held-out check — run
|
||||
`ruview_claim_check` on the writeup.
|
||||
`ruview.claim_check` on the writeup.
|
||||
|
||||
@@ -8,12 +8,12 @@ description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick do
|
||||
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
|
||||
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
|
||||
is **not a camera**, and any accuracy number must be MEASURED against a baseline
|
||||
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
|
||||
(use the `verify` skill / `ruview.claim_check` tool). Never present WiFi output as
|
||||
camera-grade.
|
||||
|
||||
## Pick a path
|
||||
|
||||
Run `ruview_onboard {path}` or decide from:
|
||||
Run `ruview.onboard {path}` or decide from:
|
||||
|
||||
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
|
||||
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
|
||||
@@ -26,5 +26,5 @@ Run `ruview_onboard {path}` or decide from:
|
||||
## Then
|
||||
|
||||
- Live sensing → go to **provision-node**, then **calibrate-room**.
|
||||
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
|
||||
- Evaluating a model/claim → go to **verify** and run `ruview.claim_check` on any
|
||||
report before you quote a number.
|
||||
|
||||
@@ -28,7 +28,7 @@ esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
|
||||
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
|
||||
```
|
||||
|
||||
(`ruview_node_flash` returns the exact pinned command rather than running an
|
||||
(`ruview.node_flash` returns the exact pinned command rather than running an
|
||||
unattended flash.)
|
||||
|
||||
## 3. Provision
|
||||
@@ -44,6 +44,6 @@ Never echo or commit the WiFi password.
|
||||
|
||||
## 4. Confirm CSI is flowing
|
||||
|
||||
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
|
||||
`ruview.node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
|
||||
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
|
||||
capturing; do not proceed to calibration.
|
||||
|
||||
@@ -29,5 +29,5 @@ or temporal leakage. Example honest result (ADR-181):
|
||||
1. Run the mean-pose baseline on the same split.
|
||||
2. Report `(model − baseline)` in pp, with the split definition (chronological /
|
||||
blocked-gap / grouped-bucket; no leakage).
|
||||
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
|
||||
3. `ruview.claim_check` the writeup — it flags any untagged or 100%/perfect claim.
|
||||
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
|
||||
|
||||
@@ -9,7 +9,7 @@ The "prove everything" skill. Nothing ships as validated without this.
|
||||
|
||||
## Deterministic proof (Trust Kill Switch)
|
||||
|
||||
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
|
||||
`ruview.verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
|
||||
through the production pipeline and hashes the output against
|
||||
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
|
||||
hash, regenerate with `verify.py --generate-hash` then re-verify.
|
||||
@@ -28,7 +28,7 @@ crate versions — a recipient can re-verify with one command.
|
||||
|
||||
## Claim honesty
|
||||
|
||||
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
|
||||
Run `ruview.claim_check {text}` on any report, README section, PR body, or model card
|
||||
before quoting accuracy. It flags:
|
||||
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
|
||||
- MEASURED claims with no reproducer cited,
|
||||
|
||||
@@ -13,27 +13,27 @@
|
||||
],
|
||||
"files": {
|
||||
".claude/settings.json": "b0ea971383716f18b89db73010b8f0ea0f1b16bdec4cd1068245772ba1c27bdd",
|
||||
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
||||
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
||||
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
||||
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
||||
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
"CLAUDE.md": "1d7af0c310dd8093b4ae6c9c94a1c0cc9ff02ac9c8d5b45caba5363c3af99475",
|
||||
".claude/skills/calibrate-room/SKILL.md": "6a6c8211a7109feb76620c618963c10ad9a9f633ffce7676e631a80a1181986d",
|
||||
".claude/skills/onboard/SKILL.md": "22323732fe746b38b77a7c8c052e952dff2fe87ae939ba125379125827385f21",
|
||||
".claude/skills/provision-node/SKILL.md": "5ffe5a75873e873b80758d9c81005774d4191317227f2e9aa4345cbce3f29751",
|
||||
".claude/skills/train-pose/SKILL.md": "b3ee95bfb0b678eb3d101138b9ea0e7cab3db3a9906d19c4059f9cca0598e87b",
|
||||
".claude/skills/verify/SKILL.md": "c0314d5ead465d9089b6a4917fd125051a5be20dc07ba92d5b601fcaada32e19",
|
||||
"CLAUDE.md": "7ecdb2b9d9abcf4aa22dd3ce553b60216a135e147893a59fa944fc1a8c81f5ef",
|
||||
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
|
||||
"README.md": "ac35157d66243a5f9eba262bdf2d593e978d935b3dde6e455b7acf650768eac6",
|
||||
"bin/cli.js": "85d8394375edb1e967418451452e68bdbe26e69fc6877ed4936894f6101e1a12",
|
||||
"package.json": "4509b68bb4211217f1e9f3f95f3134b326ee23a2322aef8d19b99a4b1d415b08",
|
||||
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
||||
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
||||
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
||||
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
||||
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
"src/guardrails.js": "66407b00d31c4f7939b75ee3e29598855c36a4154ccf1436655a4e52b0d7c034",
|
||||
"src/mcp-server.js": "ad0f21be65a37237b9c2aad69e6e75166e5f101d902cb986377043545a7a80fb",
|
||||
"src/tools.js": "1d72377ae53ad2b0c6dc03eb66f584422d8a60e442cb0d4f08355590f3edf031"
|
||||
"README.md": "b77d30428de8efb6758f2ca3eb22e84849013b2c0e6c601d488d2ea5a6f0da44",
|
||||
"bin/cli.js": "b0d74690cff4329dfe342271fc475eaa140b767bdb66b37cf4992ad209012fe8",
|
||||
"package.json": "2af49561ef0d59cafc4b99885816e580635b2d2ad329dfe17c69b9df6f8afceb",
|
||||
"skills/calibrate-room.md": "6a6c8211a7109feb76620c618963c10ad9a9f633ffce7676e631a80a1181986d",
|
||||
"skills/onboard.md": "22323732fe746b38b77a7c8c052e952dff2fe87ae939ba125379125827385f21",
|
||||
"skills/provision-node.md": "5ffe5a75873e873b80758d9c81005774d4191317227f2e9aa4345cbce3f29751",
|
||||
"skills/train-pose.md": "b3ee95bfb0b678eb3d101138b9ea0e7cab3db3a9906d19c4059f9cca0598e87b",
|
||||
"skills/verify.md": "c0314d5ead465d9089b6a4917fd125051a5be20dc07ba92d5b601fcaada32e19",
|
||||
"src/guardrails.js": "1631cea02c4354fe6126c576300faf5f8b68ae2f5e2e3a658c99eb25a7403e55",
|
||||
"src/mcp-server.js": "e51379f5ebb0b7b4670c7412714e559931ef1be8df20551f8f7309b53f0fb7af",
|
||||
"src/tools.js": "b558f61bb202abf5a967ce3a6ccaea351f2d186238cf49c7fc151d1de028eee8"
|
||||
},
|
||||
"meta": {
|
||||
"surface": "cli+mcp",
|
||||
"adr": "ADR-182"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
380d4bf928fd7c5fa753d11a30c1e24e2ea471caca57b439f765a9d864cef472 manifest.json
|
||||
6c6c1431c37472494c9b309c8b5d761dd4fc41e30313baead6320831fb982e57 manifest.json
|
||||
|
||||
@@ -10,15 +10,15 @@ accuracy number:
|
||||
1. It must be tagged **MEASURED** (with a reproducer named), **CLAIMED**, or **SYNTHETIC**.
|
||||
2. Pose PCK is quoted only as a **delta over the mean-pose baseline** on a leakage-free
|
||||
held-out split. (A mean-pose predictor already scores ~50% PCK.)
|
||||
3. Run `ruview_claim_check` on any report/PR/model-card. It flags untagged numbers and
|
||||
3. Run `ruview.claim_check` on any report/PR/model-card. It flags untagged numbers and
|
||||
the retracted "100%/perfect accuracy" framing.
|
||||
4. Firmware is "hardware-validated" only with a captured **boot log on real silicon** —
|
||||
never on a build-passes signal.
|
||||
|
||||
## Tools
|
||||
|
||||
`ruview_onboard`, `ruview_claim_check`, `ruview_verify`, `ruview_node_monitor`,
|
||||
`ruview_calibrate`, `ruview_node_flash`. All fail-closed. Mutating/hardware tools
|
||||
`ruview.onboard`, `ruview.claim_check`, `ruview.verify`, `ruview.node_monitor`,
|
||||
`ruview.calibrate`, `ruview.node_flash`. All fail-closed. Mutating/hardware tools
|
||||
(`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated.
|
||||
|
||||
## Skills
|
||||
|
||||
+12
-14
@@ -7,36 +7,34 @@ crucially — **refuse to overstate accuracy**. Minted from the RuView monorepo
|
||||
|
||||
WiFi sensing infers *coarse* pose/presence/breathing from Channel State Information.
|
||||
It is **not a camera**. Every accuracy number this harness emits must be MEASURED
|
||||
against a baseline — that rule is enforced in code (`ruview_claim_check`).
|
||||
against a baseline — that rule is enforced in code (`ruview.claim_check`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview # onboard — pick a setup path
|
||||
npx @ruvnet/ruview claim-check --file REPORT.md # the honesty guardrail (non-zero exit on untagged claims)
|
||||
npx @ruvnet/ruview claim-check --text "we hit 100% accuracy" # the honesty guardrail
|
||||
npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS)
|
||||
npx @ruvnet/ruview doctor # self-check (tools + optional kernel/host)
|
||||
npx @ruvnet/ruview --help
|
||||
```
|
||||
|
||||
The operator tools are pure Node and run with **zero install weight** — the
|
||||
package has no dependencies at all (ADR-263 O3). `doctor` / `install` can
|
||||
additionally use `@metaharness/kernel` + a host adapter if you install them
|
||||
(`npm i @metaharness/kernel @metaharness/host-claude-code`); everything else
|
||||
runs without them.
|
||||
The operator tools are pure Node and run with **zero install weight**. The
|
||||
`@metaharness/kernel` + host adapter are `optionalDependencies` — only `doctor` /
|
||||
`install` use them, only if present.
|
||||
|
||||
## Tools (`ruview_*`)
|
||||
## Tools (`ruview.*`)
|
||||
|
||||
Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `ruview_onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
|
||||
| `ruview_claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
|
||||
| `ruview_verify` | Run `verify.py` deterministic proof → VERDICT |
|
||||
| `ruview_node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
|
||||
| `ruview_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
|
||||
| `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
|
||||
| `ruview.onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
|
||||
| `ruview.claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
|
||||
| `ruview.verify` | Run `verify.py` deterministic proof → VERDICT |
|
||||
| `ruview.node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
|
||||
| `ruview.calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
|
||||
| `ruview.node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
|
||||
|
||||
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
|
||||
negative, never a fabricated success.
|
||||
|
||||
@@ -18,14 +18,14 @@ const NAME = 'ruview';
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const SKILLS_DIR = join(ROOT, 'skills');
|
||||
|
||||
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
|
||||
// Map friendly CLI verbs → registry tool names.
|
||||
const VERB_TO_TOOL = {
|
||||
onboard: 'ruview_onboard',
|
||||
verify: 'ruview_verify',
|
||||
'claim-check': 'ruview_claim_check',
|
||||
calibrate: 'ruview_calibrate',
|
||||
monitor: 'ruview_node_monitor',
|
||||
flash: 'ruview_node_flash',
|
||||
onboard: 'ruview.onboard',
|
||||
verify: 'ruview.verify',
|
||||
'claim-check': 'ruview.claim_check',
|
||||
calibrate: 'ruview.calibrate',
|
||||
monitor: 'ruview.node_monitor',
|
||||
flash: 'ruview.node_flash',
|
||||
};
|
||||
|
||||
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
|
||||
@@ -112,18 +112,13 @@ export async function run(args) {
|
||||
const toolArgs = { ...flags };
|
||||
if (cmd === 'claim-check') {
|
||||
if (flags.file) toolArgs.text = readFileSync(flags.file, 'utf8');
|
||||
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
|
||||
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
|
||||
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
|
||||
return 2;
|
||||
}
|
||||
const res = await runTool('ruview_claim_check', toolArgs);
|
||||
const res = runTool('ruview.claim_check', toolArgs);
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
|
||||
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
|
||||
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs);
|
||||
const res = runTool(VERB_TO_TOOL[cmd], toolArgs);
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"description": "RuView WiFi-sensing operator agent harness — onboard, calibrate, train, and verify camera-free WiFi-CSI sensing, with the project's MEASURED-vs-CLAIMED honesty guardrail enforced. Minted via metaharness (ADR-182).",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -23,10 +23,11 @@
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"doctor": "node ./bin/cli.js doctor",
|
||||
"mcp": "node ./bin/cli.js mcp start",
|
||||
"sync-skills": "node ./scripts/sync-skills.mjs",
|
||||
"prepack": "node ./scripts/sync-skills.mjs",
|
||||
"prepublishOnly": "npm test"
|
||||
"mcp": "node ./bin/cli.js mcp start"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@metaharness/kernel": "^0.1.0",
|
||||
"@metaharness/host-claude-code": "^0.1.0"
|
||||
},
|
||||
"keywords": [
|
||||
"wifi-sensing",
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ADR-263 O7: skills/*.md is the single source of truth; the host-projected
|
||||
// copies (.claude/skills/<name>/SKILL.md) are GENERATED here at pack time.
|
||||
// Run with --check to verify without writing (used by tests/CI).
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const SRC = join(ROOT, 'skills');
|
||||
const DST = join(ROOT, '.claude', 'skills');
|
||||
const checkOnly = process.argv.includes('--check');
|
||||
|
||||
let drift = 0;
|
||||
for (const f of readdirSync(SRC).filter((f) => f.endsWith('.md'))) {
|
||||
const name = f.replace(/\.md$/, '');
|
||||
const src = readFileSync(join(SRC, f), 'utf8');
|
||||
const dstDir = join(DST, name);
|
||||
const dstFile = join(dstDir, 'SKILL.md');
|
||||
const current = existsSync(dstFile) ? readFileSync(dstFile, 'utf8') : null;
|
||||
if (current === src) continue;
|
||||
drift++;
|
||||
if (checkOnly) {
|
||||
console.error(`DRIFT: .claude/skills/${name}/SKILL.md != skills/${f}`);
|
||||
} else {
|
||||
mkdirSync(dstDir, { recursive: true });
|
||||
writeFileSync(dstFile, src);
|
||||
console.error(`synced .claude/skills/${name}/SKILL.md`);
|
||||
}
|
||||
}
|
||||
if (checkOnly && drift > 0) {
|
||||
console.error(`sync-skills --check: ${drift} file(s) out of sync — run \`npm run sync-skills\`.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`sync-skills: ${drift === 0 ? 'all in sync' : `${drift} file(s) ${checkOnly ? 'OUT OF SYNC' : 'synced'}`}`);
|
||||
@@ -6,24 +6,24 @@ description: Run the ADR-151 per-room calibration pipeline — baseline → enro
|
||||
# calibrate-room
|
||||
|
||||
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
|
||||
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
|
||||
edge-deployable (ADR-151). Use the `ruview.calibrate` tool (installed
|
||||
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
|
||||
|
||||
## Sequence
|
||||
|
||||
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
|
||||
the room empty.
|
||||
`ruview_calibrate {step: "baseline"}`
|
||||
`ruview.calibrate {step: "baseline"}`
|
||||
2. **enroll** — record the occupant(s) doing the target activities.
|
||||
`ruview_calibrate {step: "enroll"}`
|
||||
`ruview.calibrate {step: "enroll"}`
|
||||
3. **train-room** — train the bank of small specialists from baseline + enrollment.
|
||||
`ruview_calibrate {step: "train-room"}`
|
||||
`ruview.calibrate {step: "train-room"}`
|
||||
4. **room-watch** — live presence/posture/breathing from the trained room.
|
||||
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
|
||||
`ruview.calibrate {step: "room-watch"}` (or the `room-watch` skill)
|
||||
|
||||
## Honesty
|
||||
|
||||
The specialists are calibrated to *this* room; cross-room transfer is a separate
|
||||
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
|
||||
tag presence/vitals accuracy MEASURED only with a held-out check — run
|
||||
`ruview_claim_check` on the writeup.
|
||||
`ruview.claim_check` on the writeup.
|
||||
|
||||
@@ -8,12 +8,12 @@ description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick do
|
||||
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
|
||||
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
|
||||
is **not a camera**, and any accuracy number must be MEASURED against a baseline
|
||||
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
|
||||
(use the `verify` skill / `ruview.claim_check` tool). Never present WiFi output as
|
||||
camera-grade.
|
||||
|
||||
## Pick a path
|
||||
|
||||
Run `ruview_onboard {path}` or decide from:
|
||||
Run `ruview.onboard {path}` or decide from:
|
||||
|
||||
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
|
||||
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
|
||||
@@ -26,5 +26,5 @@ Run `ruview_onboard {path}` or decide from:
|
||||
## Then
|
||||
|
||||
- Live sensing → go to **provision-node**, then **calibrate-room**.
|
||||
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
|
||||
- Evaluating a model/claim → go to **verify** and run `ruview.claim_check` on any
|
||||
report before you quote a number.
|
||||
|
||||
@@ -28,7 +28,7 @@ esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
|
||||
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
|
||||
```
|
||||
|
||||
(`ruview_node_flash` returns the exact pinned command rather than running an
|
||||
(`ruview.node_flash` returns the exact pinned command rather than running an
|
||||
unattended flash.)
|
||||
|
||||
## 3. Provision
|
||||
@@ -44,6 +44,6 @@ Never echo or commit the WiFi password.
|
||||
|
||||
## 4. Confirm CSI is flowing
|
||||
|
||||
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
|
||||
`ruview.node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
|
||||
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
|
||||
capturing; do not proceed to calibration.
|
||||
|
||||
@@ -29,5 +29,5 @@ or temporal leakage. Example honest result (ADR-181):
|
||||
1. Run the mean-pose baseline on the same split.
|
||||
2. Report `(model − baseline)` in pp, with the split definition (chronological /
|
||||
blocked-gap / grouped-bucket; no leakage).
|
||||
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
|
||||
3. `ruview.claim_check` the writeup — it flags any untagged or 100%/perfect claim.
|
||||
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
|
||||
|
||||
@@ -9,7 +9,7 @@ The "prove everything" skill. Nothing ships as validated without this.
|
||||
|
||||
## Deterministic proof (Trust Kill Switch)
|
||||
|
||||
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
|
||||
`ruview.verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
|
||||
through the production pipeline and hashes the output against
|
||||
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
|
||||
hash, regenerate with `verify.py --generate-hash` then re-verify.
|
||||
@@ -28,7 +28,7 @@ crate versions — a recipient can re-verify with one command.
|
||||
|
||||
## Claim honesty
|
||||
|
||||
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
|
||||
Run `ruview.claim_check {text}` on any report, README section, PR body, or model card
|
||||
before quoting accuracy. It flags:
|
||||
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
|
||||
- MEASURED claims with no reproducer cited,
|
||||
|
||||
@@ -4,44 +4,15 @@
|
||||
// The project was accused of AI-slop; the cultural fix is that every accuracy
|
||||
// number must be tagged MEASURED (with a reproducer) or CLAIMED/SYNTHETIC, and
|
||||
// the retracted "100% accuracy" framing must never reappear untagged. This module
|
||||
// is the static enforcement of that, shared by the `ruview_claim_check` MCP tool,
|
||||
// is the static enforcement of that, shared by the `ruview.claim_check` MCP tool,
|
||||
// the `npx ruview claim-check` CLI, and the claude-code pre-output hook.
|
||||
|
||||
/** Phrases that signal a quantitative accuracy claim (safe as substrings). */
|
||||
/** Phrases that signal a quantitative accuracy claim. */
|
||||
const METRIC_TERMS = [
|
||||
'accuracy', 'pck', 'precision', 'recall',
|
||||
'mpjpe', 'error rate', 'detection rate', 'true positive',
|
||||
'accuracy', 'pck', 'pck@', 'f1', 'precision', 'recall', 'map', 'auc',
|
||||
'iou', 'mpjpe', 'error rate', 'detection rate', 'true positive',
|
||||
];
|
||||
|
||||
// Short/ambiguous metric tokens (ADR-263 F11): 'map' is usually the English
|
||||
// word or a file extension, 'f1'/'o1' collide with finding/option labels.
|
||||
// They only count as metric mentions when word-bounded, not a `.map` file
|
||||
// reference, and the line (after scrubbing) carries a number — "mAP 62.3" is
|
||||
// a claim, "F-numbers map to findings" is not.
|
||||
// 'map' additionally must not be a `.map` file suffix or a hyphenated
|
||||
// compound ("map-free", "map-reduce") — mAP the metric never appears as either.
|
||||
const METRIC_TERMS_SHORT = [/(?<![.\w])map\b(?!-)/, /\bf1\b/, /\bauc\b/, /\biou\b/];
|
||||
// Finding/option labels (F1, O2, …) count as labels unless the token sits in a
|
||||
// metric context: an immediately following score/=/%/digit or colon ("F1: 0.91"),
|
||||
// or a number later in the same clause ("F1 reaches 0.91" — an F1-score claim).
|
||||
// Bare option refs ("F7 fixes", "O1–O9", "ADR-263 O2") carry no clause number of
|
||||
// their own and stay labels. (A surviving 'f1' still only fires as a metric when
|
||||
// its scrubbed line actually carries a number — see mentionsMetricTerm.)
|
||||
const LABEL_TOKEN_RE = /\b[fo]\d+\b(?!\s*(?:score|=|\d|%|:))(?![^\n.;]*\d)/g;
|
||||
const CODE_SPAN_RE = /`[^`]*`/g; // backticked identifiers are code, not claims
|
||||
const HAS_NUMBER_RE = /\d/;
|
||||
|
||||
/** Line with code spans and finding/option labels removed. */
|
||||
function scrubLine(lower) {
|
||||
return lower.replace(CODE_SPAN_RE, ' ').replace(LABEL_TOKEN_RE, ' ');
|
||||
}
|
||||
|
||||
function mentionsMetricTerm(lower, scrubbed) {
|
||||
if (METRIC_TERMS.some((t) => lower.includes(t))) return true;
|
||||
if (!HAS_NUMBER_RE.test(scrubbed)) return false;
|
||||
return METRIC_TERMS_SHORT.some((re) => re.test(scrubbed));
|
||||
}
|
||||
|
||||
/** Tags that make a claim honest (case-insensitive). */
|
||||
const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baseline'];
|
||||
|
||||
@@ -49,8 +20,6 @@ const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baselin
|
||||
const REPRODUCER_HINTS = [
|
||||
'verify.py', 'witness', 'mean-pose', 'mean pose', 'held-out', 'held out',
|
||||
'baseline', 'reproduce', 'sha256', 'boot log', 'pck@20 vs', 'expected_features',
|
||||
// Packaging-claim reproducers (ADR-263/264 npm reviews): the tarball itself.
|
||||
'npm pack', 'npm view', 'npm i ', 'npm install', 'tarball', 'cargo test',
|
||||
];
|
||||
|
||||
const PERCENT_RE = /\b(\d{1,3}(?:\.\d+)?)\s?%/g;
|
||||
@@ -80,8 +49,7 @@ export function claimCheck(text) {
|
||||
|
||||
const hasPercent = PERCENT_RE.test(line);
|
||||
PERCENT_RE.lastIndex = 0; // reset stateful global regex
|
||||
const scrubbed = scrubLine(lower);
|
||||
const mentionsMetric = mentionsMetricTerm(lower, scrubbed);
|
||||
const mentionsMetric = METRIC_TERMS.some((t) => lower.includes(t));
|
||||
if (!hasPercent && !mentionsMetric) return;
|
||||
|
||||
const tagged = HONEST_TAGS.some((t) => lower.includes(t));
|
||||
@@ -99,15 +67,6 @@ export function claimCheck(text) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A quantitative claim needs a number. Digits hidden in a code span still
|
||||
// count — "accuracy reached `0.95`" is a claim — so test the line with only
|
||||
// finding/option labels stripped, NOT the code-span-scrubbed copy: scrubbing
|
||||
// dropped `0.95` and wrongly short-circuited both the untagged and the
|
||||
// MEASURED-without-reproducer checks below. A bare metric word in prose
|
||||
// ("precision matters here", "every accuracy number must be MEASURED") has no
|
||||
// number and is not a taggable claim (ADR-263 F11).
|
||||
if (!hasPercent && !HAS_NUMBER_RE.test(lower.replace(LABEL_TOKEN_RE, ' '))) return;
|
||||
|
||||
// A metric/percent with no honesty tag at all.
|
||||
if (!tagged) {
|
||||
findings.push({
|
||||
@@ -120,8 +79,7 @@ export function claimCheck(text) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tagged MEASURED but cites no reproducer — still a gap (reached now even
|
||||
// when the only number is inside a code span, e.g. "accuracy `0.97` (MEASURED)").
|
||||
// Tagged MEASURED but cites no reproducer — still a gap.
|
||||
if (lower.includes('measured') && !hasReproducer) {
|
||||
findings.push({
|
||||
severity: 'medium',
|
||||
|
||||
@@ -3,23 +3,14 @@
|
||||
//
|
||||
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
|
||||
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
|
||||
// `tools/list`, `tools/call`, `ping`, empty `resources/list`/`prompts/list`
|
||||
// stubs, and the `notifications/initialized` ack. Logs go to stderr ONLY —
|
||||
// stdout is the JSON-RPC channel and must stay clean.
|
||||
//
|
||||
// ADR-263 O2: `tools/call` is dispatched asynchronously — a long-running
|
||||
// verify/calibrate no longer blocks ping/tools/list, so hosts that health-check
|
||||
// mid-run see a live server. Responses may therefore arrive out of request
|
||||
// order, which JSON-RPC permits (ids correlate them).
|
||||
// `tools/list`, `tools/call`, and the `notifications/initialized` ack. Logs go to
|
||||
// stderr ONLY — stdout is the JSON-RPC channel and must stay clean.
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { listTools, runTool } from './tools.js';
|
||||
|
||||
const PROTOCOL_VERSION = '2024-11-05';
|
||||
// Single-source the version from package.json (ADR-263 O6).
|
||||
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
const SERVER_INFO = { name: 'ruview', version: PKG.version };
|
||||
const SERVER_INFO = { name: 'ruview', version: '0.1.0' };
|
||||
|
||||
function send(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n');
|
||||
@@ -28,7 +19,7 @@ function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
|
||||
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
|
||||
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
|
||||
|
||||
async function handle(msg) {
|
||||
function handle(msg) {
|
||||
const { id, method, params } = msg;
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
@@ -36,24 +27,19 @@ async function handle(msg) {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: SERVER_INFO,
|
||||
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check.',
|
||||
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview.claim_check.',
|
||||
});
|
||||
case 'notifications/initialized':
|
||||
case 'initialized':
|
||||
case 'notifications/cancelled':
|
||||
return; // notifications — no response
|
||||
return; // notification — no response
|
||||
case 'ping':
|
||||
return result(id, {});
|
||||
case 'tools/list':
|
||||
return result(id, { tools: listTools() });
|
||||
case 'resources/list':
|
||||
return result(id, { resources: [] });
|
||||
case 'prompts/list':
|
||||
return result(id, { prompts: [] });
|
||||
case 'tools/call': {
|
||||
const name = params?.name;
|
||||
const args = params?.arguments || {};
|
||||
const out = await runTool(name, args);
|
||||
const out = runTool(name, args);
|
||||
// MCP content envelope: text block with the JSON, isError reflects ok=false.
|
||||
return result(id, {
|
||||
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
||||
@@ -66,42 +52,17 @@ async function handle(msg) {
|
||||
}
|
||||
|
||||
export function startMcpServer() {
|
||||
log(`starting v${SERVER_INFO.version} (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
|
||||
log(`starting (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
// tools/call runs are serialized through a FIFO promise chain: hardware/mutating
|
||||
// tools (calibrate, serial monitor, flash) must never overlap. ping/tools/list/
|
||||
// initialize/resources/prompts stay immediate (ADR-263 O2 — a health check must
|
||||
// answer during a long tool run). `toolChain` also lets stdin-close drain the
|
||||
// in-flight call so its response is flushed instead of dropped by process.exit.
|
||||
let toolChain = Promise.resolve();
|
||||
|
||||
const dispatch = (msg) => handle(msg).catch((err) => {
|
||||
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
|
||||
log('handler error:', String(err));
|
||||
});
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const s = line.trim();
|
||||
if (!s) return;
|
||||
let msg;
|
||||
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
|
||||
if (msg && msg.method === 'tools/call') {
|
||||
toolChain = toolChain.then(() => dispatch(msg)); // one tool at a time
|
||||
} else {
|
||||
dispatch(msg); // health/list/handshake answer immediately, even mid tool run
|
||||
try { handle(msg); } catch (err) {
|
||||
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
|
||||
log('handler error:', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
// Wait for any queued/in-flight tool call to settle (its response written)
|
||||
// before exiting — fire-and-forget used to race this and drop the response.
|
||||
toolChain.then(() => {
|
||||
log('stdin closed — exiting');
|
||||
const done = () => process.exit(0);
|
||||
// Pipe writes are async; flush buffered stdout before exit.
|
||||
if (process.stdout.writableLength) process.stdout.once('drain', done);
|
||||
else done();
|
||||
});
|
||||
});
|
||||
rl.on('close', () => { log('stdin closed — exiting'); process.exit(0); });
|
||||
}
|
||||
|
||||
+59
-140
@@ -7,15 +7,10 @@
|
||||
// `wifi-densepose` binary, an ESP32 on a port) is absent, it returns an honest
|
||||
// negative — never a fabricated success. This mirrors the project's "prove
|
||||
// everything" rule and the RuField fail-closed posture (ADR-262 §3.3).
|
||||
//
|
||||
// ADR-263: handlers are async (promise-based spawn, never spawnSync) so the MCP
|
||||
// server keeps answering ping/tools/list while a long verify/calibrate runs.
|
||||
// Canonical tool names use underscores (host tool-name regexes commonly enforce
|
||||
// ^[a-zA-Z0-9_-]{1,64}$); the historical dotted names are accepted as aliases.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, accessSync, constants } from 'node:fs';
|
||||
import { join, dirname, resolve, delimiter } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { claimCheck, summarize } from './guardrails.js';
|
||||
|
||||
/** Walk up from `start` to find the RuView monorepo root (or null). */
|
||||
@@ -32,75 +27,22 @@ export function findRepoRoot(start = process.cwd()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dep-free PATH scan (ADR-263 O8) — no shell subprocess per lookup. Only hits
|
||||
// are memoized: a miss can resolve later in a long-lived MCP session (the
|
||||
// operator installs python/the CLI mid-run), so misses are re-probed each call.
|
||||
const whichCache = new Map();
|
||||
export function which(cmd) {
|
||||
if (whichCache.has(cmd)) return whichCache.get(cmd);
|
||||
const isWin = process.platform === 'win32';
|
||||
const exts = isWin
|
||||
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
||||
: [''];
|
||||
let found = null;
|
||||
outer:
|
||||
for (const dir of (process.env.PATH || '').split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const ext of isWin ? ['', ...exts] : exts) {
|
||||
const p = join(dir, cmd + ext);
|
||||
try {
|
||||
accessSync(p, isWin ? constants.F_OK : constants.X_OK);
|
||||
found = p;
|
||||
break outer;
|
||||
} catch { /* keep scanning */ }
|
||||
}
|
||||
}
|
||||
if (found !== null) whichCache.set(cmd, found);
|
||||
return found;
|
||||
function which(cmd) {
|
||||
const probe = process.platform === 'win32'
|
||||
? spawnSync('where', [cmd], { encoding: 'utf8' })
|
||||
: spawnSync('command', ['-v', cmd], { encoding: 'utf8', shell: true });
|
||||
return probe.status === 0 ? (probe.stdout || '').trim().split(/\r?\n/)[0] : null;
|
||||
}
|
||||
|
||||
// Bounded output tails (ADR-263 O4): spawnSync's default 1 MiB maxBuffer killed
|
||||
// chatty children with ENOBUFS; handlers only ever surface the last few kB, so
|
||||
// keep rolling tails instead of the full stream.
|
||||
const STDOUT_TAIL = 65536;
|
||||
const STDERR_TAIL = 16384;
|
||||
|
||||
/** Promise-based spawn with timeout + rolling output tails. */
|
||||
export function run(cmd, args, opts = {}) {
|
||||
const timeout = opts.timeout ?? 120000;
|
||||
return new Promise((resolvePromise) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
} catch (e) {
|
||||
resolvePromise({ status: null, ok: false, stdout: '', stderr: '', error: e.message });
|
||||
return;
|
||||
}
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
|
||||
child.stdout.on('data', (d) => {
|
||||
stdout = (stdout + d).slice(-STDOUT_TAIL);
|
||||
});
|
||||
child.stderr.on('data', (d) => {
|
||||
stderr = (stderr + d).slice(-STDERR_TAIL);
|
||||
});
|
||||
child.on('error', (e) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ status: null, ok: false, stdout, stderr, error: e.message });
|
||||
});
|
||||
child.on('close', (status) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({
|
||||
status,
|
||||
ok: status === 0,
|
||||
stdout,
|
||||
stderr,
|
||||
error: timedOut ? `timed out after ${timeout} ms` : null,
|
||||
});
|
||||
});
|
||||
});
|
||||
function run(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: opts.timeout ?? 120000, ...opts });
|
||||
return {
|
||||
status: r.status,
|
||||
ok: r.status === 0,
|
||||
stdout: (r.stdout || '').slice(-8000),
|
||||
stderr: (r.stderr || '').slice(-4000),
|
||||
error: r.error ? r.error.message : null,
|
||||
};
|
||||
}
|
||||
|
||||
const ONBOARD_PATHS = {
|
||||
@@ -109,36 +51,12 @@ const ONBOARD_PATHS = {
|
||||
'live-esp32': 'Real sensing. Flash an ESP32-S3 (see `provision-node` skill), point it at the sensing-server, then `calibrate → enroll → train-room → room-watch` (see `calibrate-room`). Good for an actual install.',
|
||||
};
|
||||
|
||||
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
|
||||
// never spliced into interpreter source).
|
||||
const MONITOR_SCRIPT = [
|
||||
'import sys,time',
|
||||
'try:',
|
||||
' import serial',
|
||||
'except Exception as e:',
|
||||
" print('NO_PYSERIAL'); sys.exit(3)",
|
||||
'port=sys.argv[1]',
|
||||
'dur=float(sys.argv[2])',
|
||||
'ser=serial.Serial(port,115200,timeout=1)',
|
||||
'csi=0; n=0; t=time.time()',
|
||||
'while time.time()-t<dur:',
|
||||
' ln=ser.readline()',
|
||||
' if not ln: continue',
|
||||
" s=ln.decode('utf-8','replace')",
|
||||
' n+=1',
|
||||
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
|
||||
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
|
||||
'ser.close()',
|
||||
"print(f'LINES={n} CSI={csi}')",
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* The tool registry. Each entry: { title, description, inputSchema, handler }.
|
||||
* inputSchema is JSON-Schema (object). handler(args) → JSON-serializable result
|
||||
* (sync or promise). Canonical names are underscore-form.
|
||||
* inputSchema is JSON-Schema (object). handler(args) → JSON-serializable result.
|
||||
*/
|
||||
export const TOOLS = {
|
||||
ruview_onboard: {
|
||||
'ruview.onboard': {
|
||||
title: 'Onboard',
|
||||
description: 'Pick a RuView setup path (docker-demo | repo-build | live-esp32) and print the next concrete command.',
|
||||
inputSchema: {
|
||||
@@ -156,50 +74,46 @@ export const TOOLS = {
|
||||
repo_root: repo,
|
||||
paths: ONBOARD_PATHS,
|
||||
recommend: repo ? 'repo-build' : 'docker-demo',
|
||||
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview_claim_check`).',
|
||||
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview.claim_check`).',
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
ruview_claim_check: {
|
||||
'ruview.claim_check': {
|
||||
title: 'Claim check',
|
||||
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings. Fail-closed: empty input is an error, not a pass.',
|
||||
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['text'],
|
||||
properties: { text: { type: 'string', description: 'The text to lint (a report, README section, PR body, model card).' } },
|
||||
},
|
||||
handler(args = {}) {
|
||||
const text = typeof args.text === 'string' ? args.text : '';
|
||||
if (text.trim().length === 0) {
|
||||
return { ok: false, reason: 'empty_text', hint: 'Pass the text to lint — an empty input must not pass an honesty gate.' };
|
||||
}
|
||||
const result = claimCheck(text);
|
||||
const result = claimCheck(String(args.text ?? ''));
|
||||
return { ...result, summary: summarize(result) };
|
||||
},
|
||||
},
|
||||
|
||||
ruview_verify: {
|
||||
'ruview.verify': {
|
||||
title: 'Verify (witness)',
|
||||
description: 'Run the deterministic proof (archive/v1/data/proof/verify.py) and report VERDICT. Fail-closed if not in a RuView repo or python is missing.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { repo: { type: 'string', description: 'RuView repo root. Default: auto-detect from cwd.' } },
|
||||
},
|
||||
async handler(args = {}) {
|
||||
handler(args = {}) {
|
||||
const repo = args.repo ? resolve(args.repo) : findRepoRoot();
|
||||
if (!repo) return { ok: false, reason: 'not_in_ruview_repo', hint: 'Run inside the RuView monorepo or pass {repo}.' };
|
||||
const proof = join(repo, 'archive', 'v1', 'data', 'proof', 'verify.py');
|
||||
if (!existsSync(proof)) return { ok: false, reason: 'proof_missing', path: proof };
|
||||
const py = which('python') || which('python3');
|
||||
if (!py) return { ok: false, reason: 'python_missing', hint: 'Install python to run the deterministic proof.' };
|
||||
const r = await run(py, [proof], { cwd: repo, timeout: 180000 });
|
||||
const r = run(py, [proof], { cwd: repo, timeout: 180000 });
|
||||
const verdict = /VERDICT:\s*PASS/i.test(r.stdout) ? 'PASS' : (/VERDICT:\s*FAIL/i.test(r.stdout) ? 'FAIL' : 'UNKNOWN');
|
||||
return { ok: r.ok && verdict === 'PASS', verdict, exit: r.status, tail: r.stdout.slice(-1200), stderr: r.stderr.slice(-400) };
|
||||
},
|
||||
},
|
||||
|
||||
ruview_node_monitor: {
|
||||
'ruview.node_monitor': {
|
||||
title: 'Node monitor',
|
||||
description: 'Open an ESP32 serial port and assert CSI is flowing (MGMT+DATA). Fail-closed if python+pyserial or the port is absent. Read-only.',
|
||||
inputSchema: {
|
||||
@@ -209,13 +123,31 @@ export const TOOLS = {
|
||||
seconds: { type: 'number', description: 'Capture window (default 12).' },
|
||||
},
|
||||
},
|
||||
async handler(args = {}) {
|
||||
handler(args = {}) {
|
||||
const port = args.port;
|
||||
if (!port || typeof port !== 'string') return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
|
||||
if (!port) return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
|
||||
const py = which('python') || which('python3');
|
||||
if (!py) return { ok: false, reason: 'python_missing' };
|
||||
const dur = Number(args.seconds) > 0 ? Number(args.seconds) : 12;
|
||||
const r = await run(py, ['-c', MONITOR_SCRIPT, port, String(dur)], { timeout: (dur + 10) * 1000 });
|
||||
const script = [
|
||||
'import sys,time',
|
||||
'try:',
|
||||
' import serial',
|
||||
'except Exception as e:',
|
||||
" print('NO_PYSERIAL'); sys.exit(3)",
|
||||
`ser=serial.Serial(${JSON.stringify(port)},115200,timeout=1)`,
|
||||
'csi=0; n=0; t=time.time()',
|
||||
`while time.time()-t<${dur}:`,
|
||||
' ln=ser.readline()',
|
||||
' if not ln: continue',
|
||||
" s=ln.decode('utf-8','replace')",
|
||||
' n+=1',
|
||||
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
|
||||
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
|
||||
'ser.close()',
|
||||
"print(f'LINES={n} CSI={csi}')",
|
||||
].join('\n');
|
||||
const r = run(py, ['-c', script], { timeout: (dur + 10) * 1000 });
|
||||
if (r.stdout.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', hint: 'pip install pyserial' };
|
||||
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
|
||||
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
|
||||
@@ -224,7 +156,7 @@ export const TOOLS = {
|
||||
},
|
||||
},
|
||||
|
||||
ruview_calibrate: {
|
||||
'ruview.calibrate': {
|
||||
title: 'Calibrate room',
|
||||
description: 'Run the ADR-151 room pipeline via the wifi-densepose CLI (baseline→enroll→train-room). Fail-closed if the binary is absent.',
|
||||
inputSchema: {
|
||||
@@ -234,7 +166,7 @@ export const TOOLS = {
|
||||
args: { type: 'array', items: { type: 'string' }, description: 'Extra CLI args passed through.' },
|
||||
},
|
||||
},
|
||||
async handler(args = {}) {
|
||||
handler(args = {}) {
|
||||
const step = args.step || 'baseline';
|
||||
const bin = which('wifi-densepose');
|
||||
const repo = findRepoRoot();
|
||||
@@ -242,13 +174,13 @@ export const TOOLS = {
|
||||
const passthru = Array.isArray(args.args) ? args.args.map(String) : [];
|
||||
// Prefer the installed binary; otherwise cargo-run from the repo.
|
||||
const r = bin
|
||||
? await run(bin, [step, ...passthru], { timeout: 300000 })
|
||||
: await run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
|
||||
? run(bin, [step, ...passthru], { timeout: 300000 })
|
||||
: run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
|
||||
return { ok: r.ok, step, via: bin ? 'binary' : 'cargo', exit: r.status, tail: r.stdout.slice(-1500), stderr: r.stderr.slice(-500) };
|
||||
},
|
||||
},
|
||||
|
||||
ruview_node_flash: {
|
||||
'ruview.node_flash': {
|
||||
title: 'Node flash',
|
||||
description: 'Build+flash an ESP32 firmware variant. MUTATING + hardware. Fail-closed off-Windows or without ESP-IDF. Never claims hardware validation without a boot log.',
|
||||
inputSchema: {
|
||||
@@ -271,27 +203,14 @@ export const TOOLS = {
|
||||
},
|
||||
};
|
||||
|
||||
// Historical dotted names (pre-ADR-263) accepted as call-time aliases; the
|
||||
// underscore form is what tools/list advertises.
|
||||
export const TOOL_ALIASES = Object.fromEntries(
|
||||
Object.keys(TOOLS).map((name) => [name.replace(/_/, '.'), name])
|
||||
);
|
||||
|
||||
/** Resolve a canonical or aliased tool name (or null). */
|
||||
export function resolveToolName(name) {
|
||||
if (TOOLS[name]) return name;
|
||||
if (TOOL_ALIASES[name]) return TOOL_ALIASES[name];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run one tool by name (canonical or dotted alias); always resolves to the structured result. */
|
||||
export async function runTool(name, args) {
|
||||
const canonical = resolveToolName(name);
|
||||
if (!canonical) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
|
||||
/** Run one tool by name; returns the structured result (or an error envelope). */
|
||||
export function runTool(name, args) {
|
||||
const tool = TOOLS[name];
|
||||
if (!tool) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
|
||||
try {
|
||||
return await TOOLS[canonical].handler(args || {});
|
||||
return tool.handler(args || {});
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'tool_threw', name: canonical, error: String(err && err.message || err) };
|
||||
return { ok: false, reason: 'tool_threw', name, error: String(err && err.message || err) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// MCP stdio server e2e — spawns `bin/cli.js mcp start` and speaks JSON-RPC.
|
||||
// Pins ADR-263 O2 (ping answered while a long tools/call runs), O6 (version
|
||||
// from package.json), and O8 (underscore names advertised, dotted accepted,
|
||||
// resources/prompts stubs).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { which } from '../src/tools.js';
|
||||
|
||||
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const CLI = join(PKG_ROOT, 'bin', 'cli.js');
|
||||
|
||||
/** Start the MCP server; returns {send, next, close} where next(id) resolves the response with that id. */
|
||||
function startServer() {
|
||||
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
const waiters = new Map();
|
||||
let buf = '';
|
||||
child.stdout.on('data', (d) => {
|
||||
buf += d;
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const msg = JSON.parse(line);
|
||||
const w = waiters.get(msg.id);
|
||||
if (w) { waiters.delete(msg.id); w(msg); }
|
||||
}
|
||||
});
|
||||
return {
|
||||
send(msg) { child.stdin.write(JSON.stringify(msg) + '\n'); },
|
||||
next(id) { return new Promise((res) => waiters.set(id, res)); },
|
||||
close() { child.stdin.end(); child.kill(); },
|
||||
};
|
||||
}
|
||||
|
||||
test('MCP handshake: initialize reports the package.json version; list endpoints respond', async () => {
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
||||
const s = startServer();
|
||||
try {
|
||||
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
||||
const init = await s.next(1);
|
||||
assert.equal(init.result.serverInfo.version, pkg.version, 'ADR-263 O6: version must match package.json');
|
||||
|
||||
s.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
||||
const tools = (await s.next(2)).result.tools;
|
||||
assert.equal(tools.length, 6);
|
||||
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/, `advertised name not host-safe: ${t.name}`);
|
||||
|
||||
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
|
||||
assert.deepEqual((await s.next(3)).result, { resources: [] });
|
||||
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
|
||||
assert.deepEqual((await s.next(4)).result, { prompts: [] });
|
||||
|
||||
// Dotted legacy name still callable (alias).
|
||||
s.send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'ruview.onboard', arguments: {} } });
|
||||
const call = await s.next(5);
|
||||
assert.equal(call.result.isError, false);
|
||||
} finally {
|
||||
s.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('MCP server answers ping while a long tools/call is in flight (ADR-263 O2)', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
|
||||
// Fake RuView repo whose verify.py sleeps 3 s then passes.
|
||||
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-e2e-'));
|
||||
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
|
||||
mkdirSync(proofDir, { recursive: true });
|
||||
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(3)\nprint("VERDICT: PASS")\n');
|
||||
|
||||
const s = startServer();
|
||||
try {
|
||||
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
||||
await s.next(1);
|
||||
|
||||
const verifyDone = s.next(10);
|
||||
s.send({ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
||||
|
||||
// Give the server a beat to start the child, then ping.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const t0 = Date.now();
|
||||
const pinged = s.next(11);
|
||||
s.send({ jsonrpc: '2.0', id: 11, method: 'ping' });
|
||||
await pinged;
|
||||
const pingMs = Date.now() - t0;
|
||||
assert.ok(pingMs < 1000, `ping took ${pingMs} ms while verify was in flight — server is blocking`);
|
||||
|
||||
const verify = await verifyDone;
|
||||
const payload = JSON.parse(verify.result.content[0].text);
|
||||
assert.equal(payload.verdict, 'PASS');
|
||||
} finally {
|
||||
s.close();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('tools/call executions are serialized — two slow calls run sequentially', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
|
||||
// Two verify.py that each sleep 0.8 s. Serialized ⇒ ~1.6 s+; concurrent ⇒ ~0.8 s.
|
||||
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-serial-'));
|
||||
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
|
||||
mkdirSync(proofDir, { recursive: true });
|
||||
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(0.8)\nprint("VERDICT: PASS")\n');
|
||||
|
||||
const s = startServer();
|
||||
try {
|
||||
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
||||
await s.next(1);
|
||||
|
||||
const t0 = Date.now();
|
||||
const a = s.next(20);
|
||||
const b = s.next(21);
|
||||
s.send({ jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
||||
s.send({ jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
assert.equal(JSON.parse(ra.result.content[0].text).verdict, 'PASS');
|
||||
assert.equal(JSON.parse(rb.result.content[0].text).verdict, 'PASS');
|
||||
assert.ok(elapsed > 1400, `two 0.8 s tool calls finished in ${elapsed} ms — they overlapped instead of serializing`);
|
||||
} finally {
|
||||
s.close();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stdin close flushes an in-flight tools/call response before exit', async () => {
|
||||
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
child.stdout.on('data', (d) => { out += d; });
|
||||
const exited = new Promise((res) => child.on('exit', res));
|
||||
|
||||
// Write a tools/call then immediately close stdin. The old fire-and-forget
|
||||
// dispatch raced rl 'close' → process.exit and could drop this response.
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'tools/call', params: { name: 'ruview_onboard', arguments: {} } }) + '\n');
|
||||
child.stdin.end();
|
||||
|
||||
await exited;
|
||||
const msgs = out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
||||
const resp = msgs.find((m) => m.id === 42);
|
||||
assert.ok(resp, 'the in-flight tools/call response must be flushed to stdout before exit');
|
||||
assert.equal(resp.result.isError, false);
|
||||
});
|
||||
@@ -1,18 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// RuView harness tests — Node's built-in test runner (no devDeps to install).
|
||||
// Run: `node --test test/*.test.mjs` (or `npm test`).
|
||||
// Run: `node --test test/` (or `npm test`).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join, dirname, delimiter } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { claimCheck, summarize } from '../src/guardrails.js';
|
||||
import { TOOLS, TOOL_ALIASES, runTool, listTools, findRepoRoot, run, which } from '../src/tools.js';
|
||||
import { run as cliRun } from '../bin/cli.js';
|
||||
|
||||
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
import { TOOLS, runTool, listTools, findRepoRoot } from '../src/tools.js';
|
||||
import { run } from '../bin/cli.js';
|
||||
|
||||
test('guardrail flags the retracted 100% framing as high severity', () => {
|
||||
const r = claimCheck('Our model reaches 100% accuracy on every pose.');
|
||||
@@ -43,190 +37,71 @@ test('guardrail ignores non-metric prose', () => {
|
||||
assert.equal(claimCheck('').ok, true);
|
||||
});
|
||||
|
||||
// ADR-263 F11/O9: precision pins — short metric tokens must not fire on prose.
|
||||
test('guardrail does not false-positive on "map"/"F1" prose (ADR-263 F11)', () => {
|
||||
assert.equal(claimCheck('F-numbers map to findings.').ok, true);
|
||||
assert.equal(claimCheck('### F1 (HIGH, broken export): `require` points at a missing file').ok, true);
|
||||
assert.equal(claimCheck('The 0.1.0 tarball ships 44 `.map` files = 62,698 B of dead weight.').ok, true);
|
||||
assert.equal(claimCheck('the source maps can never resolve').ok, true);
|
||||
assert.equal(claimCheck('- **O1 (F1):** fix `exports` (see F2 for the 33% map weight — MEASURED, tarball listing)').ok, true);
|
||||
assert.equal(claimCheck('ADR-264: exports fix, map-free tarball, session-per-transport').ok, true);
|
||||
});
|
||||
|
||||
test('guardrail still catches real short-token metric claims', () => {
|
||||
assert.equal(claimCheck('We reach mAP 62.3 on COCO.').ok, false);
|
||||
assert.equal(claimCheck('F1 score of 0.91 on the held set.').ok, false, 'f1 with a real score must still fire');
|
||||
assert.equal(claimCheck('IoU 0.75 across rooms.').ok, false);
|
||||
});
|
||||
|
||||
// Digits hidden in a code span still make a claim — scrubbing must not blind the
|
||||
// number gate to `0.95` (regression: code-span number bypassed the gate).
|
||||
test('guardrail flags an accuracy number stated inside a code span', () => {
|
||||
const r = claimCheck('Count accuracy reached `0.95` in our tests.');
|
||||
assert.equal(r.ok, false, JSON.stringify(r.findings));
|
||||
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
|
||||
});
|
||||
|
||||
// A MEASURED claim whose only number hides in a code span must still reach the
|
||||
// missing-reproducer check (regression: the scrubbed gate short-circuited it).
|
||||
// Bare metric prose with no number at all (e.g. the README rule text) stays a pass.
|
||||
test('guardrail flags a MEASURED code-span number with no reproducer', () => {
|
||||
const r = claimCheck('Detection accuracy `0.97` on the set (MEASURED).');
|
||||
assert.equal(r.ok, false, JSON.stringify(r.findings));
|
||||
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
|
||||
assert.equal(claimCheck('Every accuracy number must be MEASURED against a baseline.').ok, true);
|
||||
});
|
||||
|
||||
// F1-score phrasings ("F1: 0.91", "F1 reaches 0.91") were scrubbed as option
|
||||
// labels and slipped through; option refs alone must still not false-positive.
|
||||
test('guardrail catches F1-score claims but not bare option refs (ADR-263 F11)', () => {
|
||||
assert.equal(claimCheck('F1: 0.91 on the held-out set.').ok, false, 'F1: value is a metric claim');
|
||||
assert.equal(claimCheck('F1 reaches 0.91 on the held-out set.').ok, false, 'F1 with a nearby number is a claim');
|
||||
assert.equal(claimCheck('Options O1–O9 are tracked in ADR-263 O2.').ok, true, 'option labels are not metrics');
|
||||
assert.equal(claimCheck('ADR-263 O2 lands the exports fix.').ok, true);
|
||||
});
|
||||
|
||||
test('summarize gives PASS/finding text', () => {
|
||||
assert.match(summarize(claimCheck('nothing here')), /PASS/);
|
||||
assert.match(summarize(claimCheck('100% accuracy')), /finding/);
|
||||
});
|
||||
|
||||
test('registry exposes the documented tools with schemas (underscore-canonical)', () => {
|
||||
test('registry exposes the documented tools with schemas', () => {
|
||||
const names = Object.keys(TOOLS);
|
||||
for (const n of ['ruview_onboard', 'ruview_claim_check', 'ruview_verify', 'ruview_node_monitor', 'ruview_calibrate', 'ruview_node_flash']) {
|
||||
for (const n of ['ruview.onboard', 'ruview.claim_check', 'ruview.verify', 'ruview.node_monitor', 'ruview.calibrate', 'ruview.node_flash']) {
|
||||
assert.ok(names.includes(n), `missing ${n}`);
|
||||
assert.equal(TOOLS[n].inputSchema.type, 'object');
|
||||
assert.match(n, /^[a-zA-Z0-9_-]{1,64}$/, 'canonical names must satisfy host tool-name regexes');
|
||||
}
|
||||
assert.equal(listTools().length, names.length);
|
||||
});
|
||||
|
||||
test('dotted legacy names resolve via aliases (ADR-263 O8)', async () => {
|
||||
assert.equal(TOOL_ALIASES['ruview.claim_check'], 'ruview_claim_check');
|
||||
assert.equal(TOOL_ALIASES['ruview.node_monitor'], 'ruview_node_monitor');
|
||||
const r = await runTool('ruview.onboard', {});
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('ruview_onboard returns paths and a recommendation', async () => {
|
||||
const r = await runTool('ruview_onboard', {});
|
||||
test('ruview.onboard returns paths and a recommendation', () => {
|
||||
const r = runTool('ruview.onboard', {});
|
||||
assert.equal(r.ok, true);
|
||||
assert.ok(r.paths['live-esp32']);
|
||||
assert.ok(['repo-build', 'docker-demo'].includes(r.recommend));
|
||||
});
|
||||
|
||||
test('ruview_claim_check tool wraps the guardrail', async () => {
|
||||
const r = await runTool('ruview_claim_check', { text: '100% accuracy' });
|
||||
test('ruview.claim_check tool wraps the guardrail', () => {
|
||||
const r = runTool('ruview.claim_check', { text: '100% accuracy' });
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.summary, /honesty|tag|MEASURED|finding/i);
|
||||
});
|
||||
|
||||
// ADR-263 F1/O1: the honesty gate must fail closed on empty input.
|
||||
test('ruview_claim_check fails closed on empty/missing text', async () => {
|
||||
const empty = await runTool('ruview_claim_check', { text: '' });
|
||||
assert.equal(empty.ok, false);
|
||||
assert.equal(empty.reason, 'empty_text');
|
||||
const missing = await runTool('ruview_claim_check', {});
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.reason, 'empty_text');
|
||||
});
|
||||
|
||||
test('unknown tool fails closed', async () => {
|
||||
const r = await runTool('ruview_does_not_exist', {});
|
||||
test('unknown tool fails closed', () => {
|
||||
const r = runTool('ruview.does_not_exist', {});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'unknown_tool');
|
||||
});
|
||||
|
||||
test('node_monitor fails closed without a port', async () => {
|
||||
const r = await runTool('ruview_node_monitor', {});
|
||||
test('node_monitor fails closed without a port', () => {
|
||||
const r = runTool('ruview.node_monitor', {});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'no_port');
|
||||
});
|
||||
|
||||
test('node_flash refuses without confirm (mutating guard)', async () => {
|
||||
const r = await runTool('ruview_node_flash', { port: 'COM8', variant: 's3-8mb' });
|
||||
test('node_flash refuses without confirm (mutating guard)', () => {
|
||||
const r = runTool('ruview.node_flash', { port: 'COM8', variant: 's3-8mb' });
|
||||
assert.equal(r.ok, false);
|
||||
// either not-confirmed (win32) or unsupported_platform (posix) — both fail-closed
|
||||
assert.ok(['not_confirmed', 'unsupported_platform'].includes(r.reason));
|
||||
});
|
||||
|
||||
test('verify fails closed when not in a RuView repo', async () => {
|
||||
test('verify fails closed when not in a RuView repo', () => {
|
||||
// point at a tmp dir with no repo markers
|
||||
const r = await runTool('ruview_verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
|
||||
const r = runTool('ruview.verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
|
||||
assert.equal(r.ok, false);
|
||||
assert.ok(['proof_missing', 'python_missing'].includes(r.reason), r.reason);
|
||||
});
|
||||
|
||||
// ADR-263 F2/O2: registry-level concurrency — a slow child must not block
|
||||
// other tool calls (run() is promise-based, never spawnSync).
|
||||
test('run() is non-blocking: a fast tool completes while a slow child runs', async () => {
|
||||
const slow = run('node', ['-e', 'setTimeout(() => {}, 2000)'], { timeout: 5000 });
|
||||
const t0 = Date.now();
|
||||
const fast = await runTool('ruview_onboard', {});
|
||||
const elapsed = Date.now() - t0;
|
||||
assert.equal(fast.ok, true);
|
||||
assert.ok(elapsed < 1000, `onboard took ${elapsed} ms while a 2 s child was running`);
|
||||
const r = await slow;
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('run() reports a timeout as a failure, not a hang', async () => {
|
||||
const r = await run('node', ['-e', 'setTimeout(() => {}, 10000)'], { timeout: 300 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(String(r.error), /timed out/);
|
||||
});
|
||||
|
||||
test('run() bounds captured output instead of dying on big streams (ADR-263 O4)', async () => {
|
||||
// 4 MiB of stdout would have hit spawnSync's 1 MiB default maxBuffer (ENOBUFS).
|
||||
const r = await run('node', ['-e', "process.stdout.write('x'.repeat(4 * 1024 * 1024)); console.log('TAIL_MARKER')"], { timeout: 30000 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.ok(r.stdout.length <= 65536, `tail not bounded: ${r.stdout.length}`);
|
||||
assert.ok(r.stdout.includes('TAIL_MARKER'), 'tail must keep the end of the stream');
|
||||
});
|
||||
|
||||
test('which() finds node and re-probes misses (hits are cached)', () => {
|
||||
assert.ok(which('node'), 'node must be on PATH in the test env');
|
||||
assert.equal(which('definitely-not-a-binary-xyz'), null);
|
||||
assert.equal(which('definitely-not-a-binary-xyz'), null); // re-probed, still absent
|
||||
});
|
||||
|
||||
// ADR-263 O8: a miss must not be cached — an operator who installs a tool
|
||||
// mid-session (e.g. python after a python_missing failure) must be found next call.
|
||||
test('which() re-probes after a miss so a newly-installed tool is found', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ruview-which-'));
|
||||
const name = 'ruview-probe-xyz';
|
||||
const isWin = process.platform === 'win32';
|
||||
const bin = join(dir, isWin ? `${name}.cmd` : name);
|
||||
const prevPath = process.env.PATH;
|
||||
try {
|
||||
assert.equal(which(name), null, 'not on PATH yet → miss');
|
||||
writeFileSync(bin, isWin ? '@echo off\n' : '#!/bin/sh\n', { mode: 0o755 });
|
||||
process.env.PATH = dir + delimiter + prevPath;
|
||||
assert.ok(which(name), 'installed mid-session → the miss must not have been cached');
|
||||
} finally {
|
||||
process.env.PATH = prevPath;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI run(): claim-check exits non-zero on a bad claim', async () => {
|
||||
const code = await cliRun(['claim-check', '--text', '100% accuracy']);
|
||||
const code = await run(['claim-check', '--text', '100% accuracy']);
|
||||
assert.notEqual(code, 0);
|
||||
});
|
||||
|
||||
// ADR-263 F1/O1: the CLI must not PASS silently with no input.
|
||||
test('CLI run(): claim-check with no input exits 2 (fail-closed)', async () => {
|
||||
assert.equal(await cliRun(['claim-check']), 2);
|
||||
assert.equal(await cliRun(['claim-check', '--text', ' ']), 2);
|
||||
});
|
||||
|
||||
test('CLI run(): doctor exits 0 (tools-only path)', async () => {
|
||||
const code = await cliRun(['doctor']);
|
||||
const code = await run(['doctor']);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
|
||||
test('CLI run(): unknown command exits non-zero', async () => {
|
||||
assert.notEqual(await cliRun(['definitely-not-a-command']), 0);
|
||||
assert.notEqual(await run(['definitely-not-a-command']), 0);
|
||||
});
|
||||
|
||||
test('findRepoRoot locates this monorepo from cwd', () => {
|
||||
@@ -234,23 +109,3 @@ test('findRepoRoot locates this monorepo from cwd', () => {
|
||||
const root = findRepoRoot();
|
||||
assert.ok(root === null || typeof root === 'string');
|
||||
});
|
||||
|
||||
// ADR-263 F7/O7: skills ship from one source; the projected copies must match.
|
||||
test('.claude/skills/*/SKILL.md are byte-identical to skills/*.md', () => {
|
||||
const srcDir = join(PKG_ROOT, 'skills');
|
||||
for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.md'))) {
|
||||
const name = f.replace(/\.md$/, '');
|
||||
const src = readFileSync(join(srcDir, f), 'utf8');
|
||||
const projected = readFileSync(join(PKG_ROOT, '.claude', 'skills', name, 'SKILL.md'), 'utf8');
|
||||
assert.equal(projected, src, `skill drift: ${name} — run \`npm run sync-skills\``);
|
||||
}
|
||||
});
|
||||
|
||||
// ADR-263 F6/O6 + F3/O3: package hygiene pins.
|
||||
test('package.json has no optionalDependencies and no hardcoded server version drift', () => {
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
||||
assert.equal(pkg.optionalDependencies, undefined, 'ADR-263 O3: optional deps tripled the cold npx install');
|
||||
assert.equal(pkg.dependencies, undefined, 'the harness is dependency-free by design');
|
||||
const mcpSrc = readFileSync(join(PKG_ROOT, 'src', 'mcp-server.js'), 'utf8');
|
||||
assert.ok(!/version:\s*'\d+\.\d+\.\d+'/.test(mcpSrc), 'ADR-263 O6: server version must come from package.json');
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@ Bring a RuView sensing node online: build firmware → flash → provision WiFi
|
||||
|
||||
**Not supported:** original ESP32, ESP32-C3 (single-core).
|
||||
|
||||
**⚠️ Ask about board form factor before flashing.** If the user's board is a coin-sized clone (ESP32-S3-Zero, SuperMini, or similar — not a full DevKitC/XIAO-style board with a real USB connector and visible regulator), warn them before they walk away from it: this firmware runs the WiFi radio continuously (`WIFI_PS_NONE`) plus a full DSP pipeline (`edge_tier=2`), which is sustained high current draw that full-size dev boards handle fine but tiny clones with minimal copper/budget regulators may not. At least one field report: boards ran hot during a normal session and failed to power on again afterward (regulator damage suspected). Tell them to give the board airflow (don't stack/enclose it) and check it by touch during the first several minutes of any new deployment.
|
||||
|
||||
## 1. Build firmware (Windows — Python subprocess, NOT bash directly)
|
||||
|
||||
ESP-IDF v5.4 does not support MSYS2/Git Bash. Use the Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped. The proven command lives in `CLAUDE.local.md` — reproduce it:
|
||||
|
||||
Generated
+24
-3072
File diff suppressed because it is too large
Load Diff
+2
-69
@@ -23,27 +23,6 @@ name = "wifi_densepose_native"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
# ADR-185 §3.1 — optional pip extras map to Cargo features so the
|
||||
# default wheel links none of the SOTA subsystems. P1 wires `aether`.
|
||||
[features]
|
||||
default = []
|
||||
# ADR-185 P1 — AETHER contrastive CSI embeddings. Binds the std-only
|
||||
# `wifi-densepose-aether` leaf crate (the pure-compute stack hoisted out of
|
||||
# `wifi-densepose-sensing-server` per §13), so this extra links no server tree.
|
||||
aether = ["dep:wifi-densepose-aether"]
|
||||
# ADR-185 P2 — MERIDIAN domain generalization. Binds the tch-free
|
||||
# inference/adaptation path only (see the wheel-size note on the deps
|
||||
# below). `wifi-densepose-train` is depended on WITHOUT `tch-backend`,
|
||||
# so no libtorch is linked.
|
||||
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
|
||||
# ADR-185 P3 — MAT disaster-survivor detection. Mirrors the upstream
|
||||
# disaster/ML gating: bound only under this extra so the default wheel
|
||||
# never carries the detection stack. `tokio`/`geo` are pulled to drive a
|
||||
# single-shot scan (see the wheel-size note on the deps below).
|
||||
mat = ["dep:wifi-densepose-mat", "dep:tokio", "dep:geo"]
|
||||
# ADR-185 §3 — convenience superset: all three SOTA subsystems.
|
||||
sota = ["aether", "meridian", "mat"]
|
||||
|
||||
[dependencies]
|
||||
# PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11,
|
||||
# 3.12, 3.13, and any future 3.x that keeps the stable ABI (ADR-117 §5.4).
|
||||
@@ -71,52 +50,6 @@ wifi-densepose-bfld = { version = "0.3.0", path = "../v2/crates/wifi-densepose-b
|
||||
# the future P3 CsiFrame numpy round-trip.
|
||||
numpy = "0.22"
|
||||
|
||||
# ADR-185 P1 — AETHER backing crate (contrastive `embedding` +
|
||||
# `graph_transformer`/`sona`/`sparse_inference`, ADR-024). Optional +
|
||||
# gated behind the `aether` feature.
|
||||
#
|
||||
# WHEEL-SIZE FIX LANDED (ADR-185 §13): this is now the std-only
|
||||
# `wifi-densepose-aether` leaf crate — zero external deps, no tokio/axum/
|
||||
# worldgraph/ruvector — hoisted out of `wifi-densepose-sensing-server`
|
||||
# (which re-exports it, so the server is unchanged). The `[aether]` wheel
|
||||
# therefore links only pure compute and stays within the ADR-117 §5.4
|
||||
# ≤5 MB budget.
|
||||
wifi-densepose-aether = { version = "0.3.0", path = "../v2/crates/wifi-densepose-aether", optional = true }
|
||||
|
||||
# ADR-185 P2 — MERIDIAN backing crates (optional, `meridian`-gated).
|
||||
#
|
||||
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.2): unlike AETHER, the libtorch
|
||||
# risk is AVOIDED here — `wifi-densepose-train`'s `tch` dep is properly
|
||||
# optional (feature `tch-backend`, OFF by default), so no libtorch links.
|
||||
# BUT `wifi-densepose-train` still carries NON-optional deps: `tokio` (rt
|
||||
# subset), the five `ruvector-*` crates, `wifi-densepose-nn`, petgraph,
|
||||
# memmap2, indicatif, ndarray-npy, csv, toml, clap. So a `[meridian]`
|
||||
# wheel is heavier than the ≤5 MB ADR-117 §5.4 budget (though far lighter
|
||||
# than AETHER's axum/tokio server tree). The clean fix is the same
|
||||
# leaf-crate hoist: move the pure inference modules (geometry,
|
||||
# rapid_adapt, eval, hardware_norm) into a tch/tokio-free leaf crate.
|
||||
# `wifi-densepose-signal` is depended on `default-features = false` to
|
||||
# drop the optional ndarray-linalg/BLAS chain (Windows-friendly).
|
||||
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false }
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true, default-features = false }
|
||||
|
||||
# ADR-185 P3 — MAT backing crate + the tokio/geo needed to drive one scan.
|
||||
#
|
||||
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.3): `default-features = false`
|
||||
# drops MAT's `api` (axum) and `ruvector` features from the wheel, but MAT
|
||||
# still carries NON-optional `tokio` (rt/sync/time), `wifi-densepose-nn`
|
||||
# (which pulls `ort` / ONNX Runtime + reqwest/hyper), `rustfft`, `geo`,
|
||||
# and `ndarray`. So a `[mat]` wheel exceeds the ADR-117 §5.4 ≤5 MB budget
|
||||
# — same leaf-crate-hoist story as AETHER/MERIDIAN, gated the same way so
|
||||
# the DEFAULT wheel is untouched. `tokio` (rt+time) and `geo` are depended
|
||||
# on directly (version-matched to MAT) to build the single-shot scan
|
||||
# runtime and construct the event `geo::Point` in the binding.
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false, features = ["std"] }
|
||||
tokio = { version = "1.35", features = ["rt", "time"], optional = true }
|
||||
geo = { version = "0.27", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# ADR-185 §4.1 parity harness — SHA-256 the native-Rust reference
|
||||
# embedding and read the committed golden fixture.
|
||||
sha2 = "0.10"
|
||||
serde_json = "1"
|
||||
# Doc-test infrastructure for the Python-facing examples in the bound
|
||||
# Rust functions. Lands properly in P2 once #[pyfunction]s exist to test.
|
||||
|
||||
@@ -43,29 +43,6 @@ pip install "wifi-densepose[client]" # + WebSocket/MQTT clients
|
||||
Wheels are published for Linux (x86_64, aarch64), macOS (x86_64, arm64), and
|
||||
Windows (amd64).
|
||||
|
||||
### SOTA extras (ADR-185)
|
||||
|
||||
Three optional subsystems bind the Rust SOTA modules as compiled-feature
|
||||
wheels. Each raises a clear `ImportError` if you import it without the extra:
|
||||
|
||||
| Extra | Module | What it adds |
|
||||
|-------|--------|--------------|
|
||||
| `[aether]` | `wifi_densepose.aether` | Contrastive CSI embeddings / re-identification (ADR-024) — `EmbeddingExtractor`, `cosine_similarity`, `info_nce_loss` |
|
||||
| `[meridian]` | `wifi_densepose.meridian` | Cross-environment domain generalization (ADR-027) — `HardwareNormalizer`, `GeometryEncoder`, `RapidAdaptation`, `CrossDomainEvaluator` |
|
||||
| `[mat]` | `wifi_densepose.mat` | Mass-Casualty Assessment disaster-survivor detection + START triage — `DisasterResponse`, `Survivor`, `TriageStatus` |
|
||||
| `[sota]` | all three | Convenience superset |
|
||||
|
||||
```bash
|
||||
pip install "wifi-densepose[aether]" # re-identification embeddings
|
||||
pip install "wifi-densepose[meridian]" # cross-room calibration
|
||||
pip install "wifi-densepose[mat]" # disaster triage
|
||||
pip install "wifi-densepose[sota]" # all three
|
||||
```
|
||||
|
||||
Runnable examples: [`examples/reid_from_csi.py`](examples/reid_from_csi.py),
|
||||
[`examples/cross_room_calibrate.py`](examples/cross_room_calibrate.py),
|
||||
[`examples/mat_triage.py`](examples/mat_triage.py).
|
||||
|
||||
## Usage
|
||||
|
||||
### Extract breathing rate from a CSI stream
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""ADR-185 §4.2 — AETHER embed() micro-benchmarks.
|
||||
|
||||
Target (release build, ADR-024 §2.8 FP32 <1 ms with headroom): steady-state
|
||||
`embed()` < 2 ms/window, and batched `embed()` scales roughly linearly (no
|
||||
accidental O(n²)).
|
||||
|
||||
Run with:
|
||||
pytest python/bench/test_bench_aether.py --benchmark-only
|
||||
|
||||
Skipped by default (they live in `bench/`, outside `testpaths`). Timing
|
||||
targets are validated on a RELEASE wheel (`maturin develop --release
|
||||
--features sota`); a debug wheel will be several× slower.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import aether
|
||||
|
||||
|
||||
def _window(frames: int = 8, subc: int = 56) -> list[list[float]]:
|
||||
return [[math.sin(0.1 * t + 0.03 * k) for k in range(subc)] for t in range(frames)]
|
||||
|
||||
|
||||
def _extractor() -> aether.EmbeddingExtractor:
|
||||
return aether.EmbeddingExtractor(n_subcarriers=56, config=aether.AetherConfig())
|
||||
|
||||
|
||||
def test_embed_per_window(benchmark) -> None:
|
||||
ext = _extractor()
|
||||
window = _window()
|
||||
out = benchmark(lambda: ext.embed(window))
|
||||
assert len(out) == 128
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch", [1, 8, 64])
|
||||
def test_embed_batch_scaling(benchmark, batch: int) -> None:
|
||||
ext = _extractor()
|
||||
windows = [_window() for _ in range(batch)]
|
||||
out = benchmark(lambda: [ext.embed(w) for w in windows])
|
||||
assert len(out) == batch
|
||||
@@ -1,44 +0,0 @@
|
||||
"""ADR-185 §4.2 — MAT scan micro-benchmark.
|
||||
|
||||
Measures the cost of one full ingest + `scan_once()` cycle over the
|
||||
committed 256-frame CSI stream. The per-cycle cost should stay comfortably
|
||||
below the configured scan interval (default 500 ms) so the binding is not
|
||||
the bottleneck.
|
||||
|
||||
Run with:
|
||||
pytest python/bench/test_bench_mat.py --benchmark-only
|
||||
|
||||
Validated on a RELEASE wheel; a debug wheel will be several× slower.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from wifi_densepose import mat
|
||||
|
||||
_FIXTURE = Path(__file__).resolve().parents[1] / "tests" / "golden" / "mat_input.json"
|
||||
|
||||
|
||||
def _stream() -> list[dict]:
|
||||
return json.loads(_FIXTURE.read_text())["stream"]
|
||||
|
||||
|
||||
def test_scan_cycle_cost(benchmark) -> None:
|
||||
stream = _stream()
|
||||
|
||||
def _run() -> int:
|
||||
cfg = mat.DisasterConfig(
|
||||
mat.DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1
|
||||
)
|
||||
resp = mat.DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "bench")
|
||||
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
|
||||
for frame in stream:
|
||||
resp.push_csi_data(frame["amplitude"], frame["phase"])
|
||||
resp.scan_once()
|
||||
return len(resp.survivors())
|
||||
|
||||
survivors = benchmark(_run)
|
||||
assert survivors == 1
|
||||
@@ -1,29 +0,0 @@
|
||||
"""ADR-185 §4.2 — MERIDIAN micro-benchmarks.
|
||||
|
||||
Targets (release build, ADR-027 §4.1/§4.3 ×2 headroom): `normalize()`
|
||||
< 200 µs/frame, `encode()` < 200 µs.
|
||||
|
||||
Run with:
|
||||
pytest python/bench/test_bench_meridian.py --benchmark-only
|
||||
|
||||
Validated on a RELEASE wheel; a debug wheel will be several× slower.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import meridian as mer
|
||||
|
||||
|
||||
def test_normalize_per_frame(benchmark) -> None:
|
||||
norm = mer.HardwareNormalizer()
|
||||
amp = [10.0 + 0.05 * k for k in range(64)]
|
||||
phase = [0.01 * k for k in range(64)]
|
||||
out = benchmark(lambda: norm.normalize(amp, phase, mer.HardwareType.Esp32S3))
|
||||
assert len(out.amplitude) == 56
|
||||
|
||||
|
||||
def test_geometry_encode(benchmark) -> None:
|
||||
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
|
||||
aps = [[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]]
|
||||
out = benchmark(lambda: enc.encode(aps))
|
||||
assert len(out) == 64
|
||||
@@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
rng = Random(43)
|
||||
for i in range(1500):
|
||||
residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
|
||||
hr.extract(residuals=residuals, phases=phases)
|
||||
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
|
||||
hr.extract(residuals=residuals, weights=weights)
|
||||
|
||||
def _one_frame():
|
||||
residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
|
||||
return hr.extract(residuals=residuals, phases=phases)
|
||||
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
|
||||
return hr.extract(residuals=residuals, weights=weights)
|
||||
|
||||
benchmark(_one_frame)
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""MERIDIAN cross-room calibration (ADR-185 P2, `[meridian]` extra).
|
||||
|
||||
Hardware-invariant CSI normalization, AP-geometry encoding, and few-shot
|
||||
rapid adaptation — the tch-free domain-generalization path.
|
||||
|
||||
pip install wifi-densepose[meridian]
|
||||
python examples/cross_room_calibrate.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from wifi_densepose.meridian import (
|
||||
GeometryEncoder,
|
||||
HardwareNormalizer,
|
||||
HardwareType,
|
||||
MeridianGeometryConfig,
|
||||
RapidAdaptation,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# 1. Normalize a 64-subcarrier ESP32 frame to the canonical 56-tone grid.
|
||||
norm = HardwareNormalizer()
|
||||
amp = [10.0 + 0.05 * k for k in range(64)]
|
||||
phase = [0.01 * k for k in range(64)]
|
||||
frame = norm.normalize(amp, phase, HardwareType.detect(64))
|
||||
print(f"canonical subcarriers: {len(frame.amplitude)} (hw={frame.hardware_type})")
|
||||
|
||||
# 2. Encode AP positions into a permutation-invariant geometry embedding.
|
||||
enc = GeometryEncoder(MeridianGeometryConfig())
|
||||
geometry = enc.encode([[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]])
|
||||
print(f"geometry embedding dim: {len(geometry)}")
|
||||
|
||||
# 3. Few-shot rapid adaptation over a handful of unlabeled frames.
|
||||
ra = RapidAdaptation(min_calibration_frames=10, lora_rank=4)
|
||||
for i in range(12):
|
||||
ra.push_frame([math.sin(0.1 * i + 0.05 * d) for d in range(16)])
|
||||
result = ra.adapt()
|
||||
print(f"adapted over {result.frames_used} frames, final_loss={result.final_loss:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,49 +0,0 @@
|
||||
"""MAT disaster-survivor triage from CSI (ADR-185 P3, `[mat]` extra).
|
||||
|
||||
Ingest a CSI stream, run one detection cycle, and list detected survivors
|
||||
by START triage class.
|
||||
|
||||
pip install wifi-densepose[mat]
|
||||
python examples/mat_triage.py
|
||||
|
||||
Note: the stream here is synthetic (breathing-modulated) — it demonstrates
|
||||
the API and pipeline, not validated detection accuracy on real rubble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterator
|
||||
|
||||
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
|
||||
|
||||
|
||||
def breathing_stream(
|
||||
frames: int = 256, subc: int = 56, fs: float = 20.0
|
||||
) -> Iterator[tuple[list[float], list[float]]]:
|
||||
for t in range(frames):
|
||||
tt = t / fs
|
||||
breath = 2.0 * math.sin(2 * math.pi * 0.3 * tt)
|
||||
amp = [10.0 + 0.05 * k + breath for k in range(subc)]
|
||||
phase = [0.01 * k + 0.1 * math.sin(2 * math.pi * 0.3 * tt) for k in range(subc)]
|
||||
yield amp, phase
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
|
||||
resp = DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "Collapsed Building A")
|
||||
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
|
||||
|
||||
for amp, phase in breathing_stream():
|
||||
resp.push_csi_data(amp, phase)
|
||||
resp.scan_once()
|
||||
|
||||
survivors = resp.survivors()
|
||||
print(f"detected {len(survivors)} survivor(s)")
|
||||
for s in survivors:
|
||||
print(f" {s.id[:8]} triage={s.triage_status} confidence={s.confidence:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,39 +0,0 @@
|
||||
"""AETHER re-identification from CSI (ADR-185 P1, `[aether]` extra).
|
||||
|
||||
Compute 128-dim contrastive embeddings for CSI windows and score them by
|
||||
cosine similarity — the primitive behind room fingerprinting and person
|
||||
re-identification.
|
||||
|
||||
pip install wifi-densepose[aether]
|
||||
python examples/reid_from_csi.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from wifi_densepose.aether import AetherConfig, EmbeddingExtractor, cosine_similarity
|
||||
|
||||
|
||||
def make_window(phase_shift: float, frames: int = 8, subc: int = 56) -> list[list[float]]:
|
||||
"""A synthetic CSI window; `phase_shift` stands in for a different scene."""
|
||||
return [
|
||||
[math.sin(0.1 * t + 0.03 * k + phase_shift) for k in range(subc)]
|
||||
for t in range(frames)
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
|
||||
|
||||
same_a = ext.embed(make_window(0.0))
|
||||
same_b = ext.embed(make_window(0.0)) # same scene
|
||||
other = ext.embed(make_window(1.5)) # different scene
|
||||
|
||||
print(f"embedding dim: {len(same_a)}")
|
||||
print(f"same-scene similarity: {cosine_similarity(same_a, same_b):.4f}")
|
||||
print(f"cross-scene similarity: {cosine_similarity(same_a, other):.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-16
@@ -10,7 +10,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wifi-densepose"
|
||||
version = "2.0.0"
|
||||
version = "2.0.0a1"
|
||||
description = "WiFi-based human pose estimation, vital sign extraction, and ambient intelligence from Channel State Information (CSI). PyO3 bindings for the Rust core."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -23,7 +23,7 @@ keywords = [
|
||||
"biometric", "ambient-intelligence", "home-assistant", "matter",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
@@ -48,20 +48,6 @@ client = [
|
||||
"websockets>=12.0",
|
||||
"paho-mqtt>=2.1",
|
||||
]
|
||||
# ADR-185 P1 — AETHER contrastive embeddings. Unlike `client`, this
|
||||
# extra carries no pure-Python deps: it is a marker for a *compiled*
|
||||
# feature build (`maturin ... --features aether` / a cibuildwheel
|
||||
# feature axis, ADR-185 §3.1). Installing the base wheel and importing
|
||||
# `wifi_densepose.aether` raises a clear ImportError naming this extra.
|
||||
aether = []
|
||||
# ADR-185 P2 — MERIDIAN domain generalization. Same compiled-feature
|
||||
# marker pattern as `aether` (built via `maturin ... --features meridian`).
|
||||
meridian = []
|
||||
# ADR-185 P3 — MAT disaster-survivor detection. Same compiled-feature
|
||||
# marker (built via `maturin ... --features mat`).
|
||||
mat = []
|
||||
# ADR-185 §3 convenience — all three SOTA subsystems at once.
|
||||
sota = []
|
||||
# Developer dependencies for running the test suite + lint.
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
|
||||
@@ -16,7 +16,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ruview"
|
||||
version = "2.0.0"
|
||||
version = "2.0.0a1"
|
||||
description = "RuView — ambient intelligence from WiFi CSI. Meta-package; installs `wifi-densepose` and re-exports it under the `ruview` namespace. See https://github.com/ruvnet/RuView."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -28,7 +28,7 @@ keywords = [
|
||||
"ruview",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
@@ -43,13 +43,13 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
# Pin to the matching v2 release so `pip install ruview` always gets a
|
||||
# compatible wifi-densepose.
|
||||
"wifi-densepose==2.0.0",
|
||||
# Pin to the matching v2 release so an alpha-pin `pip install ruview`
|
||||
# always gets a compatible wifi-densepose.
|
||||
"wifi-densepose==2.0.0a1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
client = ["wifi-densepose[client]==2.0.0"]
|
||||
client = ["wifi-densepose[client]==2.0.0a1"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/ruvnet/RuView"
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
//! ADR-185 P1 — PyO3 bindings for AETHER contrastive CSI embeddings.
|
||||
//!
|
||||
//! Surfaces the **pure-sync** contrastive-embedding compute from
|
||||
//! `wifi-densepose-aether::embedding` (ADR-024; the std-only leaf hoisted per
|
||||
//! ADR-185 §13) into `wifi_densepose.aether`:
|
||||
//!
|
||||
//! - `AetherConfig` — wraps `EmbeddingConfig` (d_model / d_proj /
|
||||
//! temperature / normalize)
|
||||
//! - `CsiAugmenter` — SimCLR-style augmentation pair generator
|
||||
//! - `EmbeddingExtractor`— backbone + projection → 128-dim L2-normed embedding
|
||||
//! - `info_nce_loss` — NT-Xent contrastive loss (module function)
|
||||
//! - `cosine_similarity` — re-ID similarity helper (module function)
|
||||
//!
|
||||
//! ## Honest scope vs ADR-185 §3.2
|
||||
//!
|
||||
//! ADR-185 §3.2 names an aspirational surface (`aether_loss` returning
|
||||
//! VICReg components, `alignment_metric`, `uniformity_metric`,
|
||||
//! `forward_dual`, an `AetherConfig` with `vicreg_*` fields). Those do
|
||||
//! **not** exist in the backing crate at HEAD — `embedding.rs` exposes
|
||||
//! `EmbeddingConfig { d_model, d_proj, temperature, normalize }`,
|
||||
//! `info_nce_loss` (plain `f32`), `CsiAugmenter::augment_pair`, and
|
||||
//! `EmbeddingExtractor::extract`. This binding surfaces **what actually
|
||||
//! exists** rather than fabricating the ADR's wished-for API. The
|
||||
//! VICReg loss / metric surface is a Rust-side gap, not a binding gap.
|
||||
//!
|
||||
//! ## GIL release strategy (per ADR-117 §7, matching bindings/vitals.rs)
|
||||
//!
|
||||
//! `extract`, `augment_pair`, and `info_nce_loss` are pure-sync matrix
|
||||
//! ops touching no Python objects, so they run inside
|
||||
//! `py.allow_threads(|| ...)`.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_aether::embedding::{
|
||||
info_nce_loss as rust_info_nce_loss, CsiAugmenter, EmbeddingConfig, EmbeddingExtractor,
|
||||
};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
/// Upper bound on model/CSI dimensions accepted from Python. The transformer
|
||||
/// allocates weight matrices quadratic in these, so this caps a single
|
||||
/// construction well under a gigabyte and turns an accidental or malicious
|
||||
/// `d_model=100_000` into a `ValueError` instead of an allocation that aborts
|
||||
/// the interpreter. Generous relative to real configs (defaults 64/128); raise
|
||||
/// deliberately if a workload genuinely needs larger.
|
||||
const MAX_DIM: usize = 4096;
|
||||
/// Upper bound on GNN layer count — a sanity cap, not a modelling limit.
|
||||
const MAX_LAYERS: usize = 64;
|
||||
|
||||
// ─── AetherConfig ────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the contrastive embedding model.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose.aether import AetherConfig
|
||||
/// cfg = AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "AetherConfig")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyAetherConfig {
|
||||
inner: EmbeddingConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAetherConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (d_model=64, d_proj=128, temperature=0.07, normalize=true))]
|
||||
fn new(d_model: usize, d_proj: usize, temperature: f32, normalize: bool) -> PyResult<Self> {
|
||||
// Validate at the boundary and raise ValueError. The native constructor
|
||||
// allocates weight matrices quadratic in these dims and (elsewhere)
|
||||
// divides by them, so zero or absurd values would otherwise reach Rust
|
||||
// as a panic (surfacing to Python as an opaque PanicException) or a
|
||||
// multi-gigabyte allocation that aborts the interpreter.
|
||||
if d_model == 0 || d_proj == 0 {
|
||||
return Err(PyValueError::new_err(
|
||||
"d_model and d_proj must be positive",
|
||||
));
|
||||
}
|
||||
if d_model > MAX_DIM || d_proj > MAX_DIM {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"d_model ({d_model}) and d_proj ({d_proj}) must be <= {MAX_DIM}"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
inner: EmbeddingConfig {
|
||||
d_model,
|
||||
d_proj,
|
||||
temperature,
|
||||
normalize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn d_model(&self) -> usize {
|
||||
self.inner.d_model
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn d_proj(&self) -> usize {
|
||||
self.inner.d_proj
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn temperature(&self) -> f32 {
|
||||
self.inner.temperature
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn normalize(&self) -> bool {
|
||||
self.inner.normalize
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"AetherConfig(d_model={}, d_proj={}, temperature={}, normalize={})",
|
||||
self.inner.d_model, self.inner.d_proj, self.inner.temperature, self.inner.normalize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CsiAugmenter ────────────────────────────────────────────────────
|
||||
|
||||
/// SimCLR-style CSI augmentation. `augment_pair` returns two distinct
|
||||
/// augmented views of the same CSI window for contrastive pretraining.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose.aether import CsiAugmenter
|
||||
/// aug = CsiAugmenter()
|
||||
/// view_a, view_b = aug.augment_pair(window, seed=42)
|
||||
/// ```
|
||||
#[pyclass(name = "CsiAugmenter")]
|
||||
pub struct PyCsiAugmenter {
|
||||
inner: CsiAugmenter,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCsiAugmenter {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: CsiAugmenter::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce two augmented views `(view_a, view_b)` of `window`
|
||||
/// (frames × subcarriers) using the deterministic `seed`. GIL is
|
||||
/// released during augmentation.
|
||||
fn augment_pair(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
window: Vec<Vec<f32>>,
|
||||
seed: u64,
|
||||
) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
|
||||
py.allow_threads(|| self.inner.augment_pair(&window, seed))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"CsiAugmenter(SimCLR-style CSI augmentation)".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EmbeddingExtractor ──────────────────────────────────────────────
|
||||
|
||||
/// Full AETHER embedding extractor: CSI→pose transformer backbone +
|
||||
/// projection head → a `d_proj`-dim (default 128) L2-normalized
|
||||
/// embedding. Weights are deterministically seeded, so `embed` is a
|
||||
/// pure function of its input for a fixed config.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose.aether import AetherConfig, EmbeddingExtractor
|
||||
/// ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
|
||||
/// emb = ext.embed(window) # list[float], len == config.d_proj
|
||||
/// ```
|
||||
#[pyclass(name = "EmbeddingExtractor")]
|
||||
pub struct PyEmbeddingExtractor {
|
||||
inner: EmbeddingExtractor,
|
||||
embedding_dim: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEmbeddingExtractor {
|
||||
/// Construct an extractor. The transformer backbone is sized from
|
||||
/// `n_subcarriers` and `config.d_model`; `config.d_proj` sets the
|
||||
/// embedding dimension.
|
||||
#[new]
|
||||
#[pyo3(signature = (n_subcarriers, config, n_keypoints=17, n_heads=4, n_gnn_layers=2))]
|
||||
fn new(
|
||||
n_subcarriers: usize,
|
||||
config: PyAetherConfig,
|
||||
n_keypoints: usize,
|
||||
n_heads: usize,
|
||||
n_gnn_layers: usize,
|
||||
) -> PyResult<Self> {
|
||||
let e_config = config.inner.clone();
|
||||
// n_heads == 0 reaches `d_model % n_heads` in the transformer and panics
|
||||
// (divide-by-zero); a non-divisor trips the native `assert!`. Both would
|
||||
// surface to Python as a PanicException. Reject cleanly instead.
|
||||
if n_heads == 0 {
|
||||
return Err(PyValueError::new_err("n_heads must be positive"));
|
||||
}
|
||||
if e_config.d_model % n_heads != 0 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"d_model ({}) must be divisible by n_heads ({n_heads})",
|
||||
e_config.d_model
|
||||
)));
|
||||
}
|
||||
if n_subcarriers == 0 || n_keypoints == 0 {
|
||||
return Err(PyValueError::new_err(
|
||||
"n_subcarriers and n_keypoints must be positive",
|
||||
));
|
||||
}
|
||||
if n_subcarriers > MAX_DIM || n_keypoints > MAX_DIM || n_gnn_layers > MAX_LAYERS {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"n_subcarriers/n_keypoints must be <= {MAX_DIM} and n_gnn_layers <= {MAX_LAYERS}"
|
||||
)));
|
||||
}
|
||||
let t_config = TransformerConfig {
|
||||
n_subcarriers,
|
||||
n_keypoints,
|
||||
d_model: e_config.d_model,
|
||||
n_heads,
|
||||
n_gnn_layers,
|
||||
};
|
||||
let embedding_dim = e_config.d_proj;
|
||||
Ok(Self {
|
||||
inner: EmbeddingExtractor::new(t_config, e_config),
|
||||
embedding_dim,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract an embedding from a CSI window (frames × subcarriers).
|
||||
/// Returns a `d_proj`-length vector (L2-normed when the config's
|
||||
/// `normalize` is set). GIL released during the forward pass.
|
||||
fn embed(&mut self, py: Python<'_>, csi_features: Vec<Vec<f32>>) -> Vec<f32> {
|
||||
py.allow_threads(|| self.inner.extract(&csi_features))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn embedding_dim(&self) -> usize {
|
||||
self.embedding_dim
|
||||
}
|
||||
|
||||
/// Total trainable parameter count (transformer + projection). Equals the
|
||||
/// number of `f32`s in a weight file for this architecture.
|
||||
#[getter]
|
||||
fn param_count(&self) -> usize {
|
||||
self.inner.param_count()
|
||||
}
|
||||
|
||||
/// Load weights from `path` (a file written by `save_weights` or the Rust
|
||||
/// `EmbeddingExtractor::save_weights`), replacing the current weights.
|
||||
///
|
||||
/// By default an `EmbeddingExtractor` uses deterministic **random** init
|
||||
/// (untrained); this is the additive path to load real weights once a
|
||||
/// trained checkpoint exists (ADR-185 §13.a). Raises `ValueError` on a
|
||||
/// missing/corrupt file or a param-count mismatch with this architecture.
|
||||
/// GIL released during file I/O + deserialization.
|
||||
fn load_weights(&mut self, py: Python<'_>, path: String) -> PyResult<()> {
|
||||
py.allow_threads(|| self.inner.load_weights(&path))
|
||||
.map_err(PyValueError::new_err)
|
||||
}
|
||||
|
||||
/// Serialize the current weights to `path` (magic `AETHERW1` + `u32` count
|
||||
/// + little-endian `f32` payload). GIL released.
|
||||
fn save_weights(&self, py: Python<'_>, path: String) -> PyResult<()> {
|
||||
py.allow_threads(|| self.inner.save_weights(&path))
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("EmbeddingExtractor(embedding_dim={})", self.embedding_dim)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Module functions ────────────────────────────────────────────────
|
||||
|
||||
/// InfoNCE (NT-Xent) contrastive loss between two batches of embeddings.
|
||||
/// Delegates to the identical Rust implementation. GIL released.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (embeddings_a, embeddings_b, temperature=0.07))]
|
||||
fn info_nce_loss(
|
||||
py: Python<'_>,
|
||||
embeddings_a: Vec<Vec<f32>>,
|
||||
embeddings_b: Vec<Vec<f32>>,
|
||||
temperature: f32,
|
||||
) -> f32 {
|
||||
py.allow_threads(|| rust_info_nce_loss(&embeddings_a, &embeddings_b, temperature))
|
||||
}
|
||||
|
||||
/// Cosine similarity between two embeddings — the re-ID scoring
|
||||
/// primitive. Byte-identical to the private `cosine_similarity` in the
|
||||
/// backing crate (same dot-product / norm formula, `f32`).
|
||||
#[pyfunction]
|
||||
fn cosine_similarity(a: Vec<f32>, b: Vec<f32>) -> f32 {
|
||||
let n = a.len().min(b.len());
|
||||
let dot: f32 = (0..n).map(|i| a[i] * b[i]).sum();
|
||||
let na = (0..n).map(|i| a[i] * a[i]).sum::<f32>().sqrt();
|
||||
let nb = (0..n).map(|i| b[i] * b[i]).sum::<f32>().sqrt();
|
||||
if na > 1e-10 && nb > 1e-10 {
|
||||
dot / (na * nb)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyAetherConfig>()?;
|
||||
m.add_class::<PyCsiAugmenter>()?;
|
||||
m.add_class::<PyEmbeddingExtractor>()?;
|
||||
m.add_function(wrap_pyfunction!(info_nce_loss, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(cosine_similarity, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
//! ADR-185 P3 — PyO3 bindings for MAT (Mass Casualty Assessment Tool, ADR-024
|
||||
//! crate table): WiFi-based disaster-survivor detection + START triage.
|
||||
//!
|
||||
//! Bound behind the `[mat]` extra so the disaster/ML stack never enters the
|
||||
//! default wheel.
|
||||
//!
|
||||
//! ## Honest scope vs ADR-185 §3.4
|
||||
//!
|
||||
//! - **`scan_once()`** — ADR-185 §3.4/§11.3 proposed adding a sync
|
||||
//! `scan_once()` wrapper Rust-side. That turned out to be unnecessary: the
|
||||
//! public async `DisasterResponse::start_scanning()` runs **exactly one**
|
||||
//! `scan_cycle` and returns when `continuous_monitoring == false`. So this
|
||||
//! binding forces `continuous_monitoring = false` and drives one scan on a
|
||||
//! private current-thread tokio runtime — no change to `wifi-densepose-mat`.
|
||||
//! - **event + zone are required** — `scan_cycle` errors without an active
|
||||
//! event and an Active zone. ADR-185 §3.4's surface omitted this; the real
|
||||
//! pipeline needs `initialize_event(...)` + `add_zone(...)` first, so both
|
||||
//! are bound (documented additions, not fabrications).
|
||||
//! - **`Survivor.vital_signs`** — the ADR implies a single `VitalSignsReading`;
|
||||
//! the real accessor returns a *history*. Bound here as
|
||||
//! `Survivor.latest_vitals -> Optional[VitalSignsReading]`.
|
||||
//! - **`DisasterType`** has 9 variants at HEAD (adds Landslide, MineCollapse,
|
||||
//! Industrial, TunnelCollapse) vs the ADR's shorter list; all are bound.
|
||||
//!
|
||||
//! ## GIL release
|
||||
//!
|
||||
//! `push_csi_data` and `scan_once` release the GIL (`py.allow_threads`) — the
|
||||
//! detection pipeline + ensemble classifier are the compute-heavy part and
|
||||
//! touch no Python state.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_mat::{
|
||||
DisasterConfig, DisasterResponse, DisasterType, ScanZone, Survivor, TriageStatus,
|
||||
VitalSignsReading, ZoneBounds,
|
||||
};
|
||||
|
||||
// ─── DisasterType ────────────────────────────────────────────────────
|
||||
|
||||
/// Type of disaster event (shapes the debris/attenuation model).
|
||||
#[pyclass(eq, eq_int, frozen, hash, name = "DisasterType")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyDisasterType {
|
||||
BuildingCollapse = 0,
|
||||
Earthquake = 1,
|
||||
Landslide = 2,
|
||||
Avalanche = 3,
|
||||
Flood = 4,
|
||||
MineCollapse = 5,
|
||||
Industrial = 6,
|
||||
TunnelCollapse = 7,
|
||||
Unknown = 8,
|
||||
}
|
||||
|
||||
impl PyDisasterType {
|
||||
fn as_rust(self) -> DisasterType {
|
||||
match self {
|
||||
Self::BuildingCollapse => DisasterType::BuildingCollapse,
|
||||
Self::Earthquake => DisasterType::Earthquake,
|
||||
Self::Landslide => DisasterType::Landslide,
|
||||
Self::Avalanche => DisasterType::Avalanche,
|
||||
Self::Flood => DisasterType::Flood,
|
||||
Self::MineCollapse => DisasterType::MineCollapse,
|
||||
Self::Industrial => DisasterType::Industrial,
|
||||
Self::TunnelCollapse => DisasterType::TunnelCollapse,
|
||||
Self::Unknown => DisasterType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDisasterType {
|
||||
fn __repr__(&self) -> String {
|
||||
format!("DisasterType.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TriageStatus ────────────────────────────────────────────────────
|
||||
|
||||
/// START-protocol triage class.
|
||||
#[pyclass(eq, eq_int, frozen, hash, name = "TriageStatus")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyTriageStatus {
|
||||
Immediate = 0,
|
||||
Delayed = 1,
|
||||
Minor = 2,
|
||||
Deceased = 3,
|
||||
Unknown = 4,
|
||||
}
|
||||
|
||||
impl PyTriageStatus {
|
||||
fn as_rust(self) -> TriageStatus {
|
||||
match self {
|
||||
Self::Immediate => TriageStatus::Immediate,
|
||||
Self::Delayed => TriageStatus::Delayed,
|
||||
Self::Minor => TriageStatus::Minor,
|
||||
Self::Deceased => TriageStatus::Deceased,
|
||||
Self::Unknown => TriageStatus::Unknown,
|
||||
}
|
||||
}
|
||||
fn from_rust(s: &TriageStatus) -> Self {
|
||||
match s {
|
||||
TriageStatus::Immediate => Self::Immediate,
|
||||
TriageStatus::Delayed => Self::Delayed,
|
||||
TriageStatus::Minor => Self::Minor,
|
||||
TriageStatus::Deceased => Self::Deceased,
|
||||
TriageStatus::Unknown => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTriageStatus {
|
||||
/// START priority (1 = highest / Immediate ... 5 = Unknown).
|
||||
#[getter]
|
||||
fn priority(&self) -> u8 {
|
||||
self.as_rust().priority()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("TriageStatus.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VitalSignsReading ───────────────────────────────────────────────
|
||||
|
||||
/// A single vital-signs reading (optional breathing/heartbeat + movement).
|
||||
#[pyclass(frozen, name = "VitalSignsReading")]
|
||||
pub struct PyVitalSignsReading {
|
||||
breathing_rate_bpm: Option<f32>,
|
||||
heartbeat_rate_bpm: Option<f32>,
|
||||
movement_intensity: f32,
|
||||
confidence: f64,
|
||||
}
|
||||
|
||||
impl PyVitalSignsReading {
|
||||
fn from_rust(r: &VitalSignsReading) -> Self {
|
||||
Self {
|
||||
breathing_rate_bpm: r.breathing.as_ref().map(|b| b.rate_bpm),
|
||||
heartbeat_rate_bpm: r.heartbeat.as_ref().map(|h| h.rate_bpm),
|
||||
movement_intensity: r.movement.intensity,
|
||||
confidence: r.confidence.value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVitalSignsReading {
|
||||
#[getter]
|
||||
fn breathing_rate_bpm(&self) -> Option<f32> {
|
||||
self.breathing_rate_bpm
|
||||
}
|
||||
#[getter]
|
||||
fn heartbeat_rate_bpm(&self) -> Option<f32> {
|
||||
self.heartbeat_rate_bpm
|
||||
}
|
||||
#[getter]
|
||||
fn movement_intensity(&self) -> f32 {
|
||||
self.movement_intensity
|
||||
}
|
||||
#[getter]
|
||||
fn confidence(&self) -> f64 {
|
||||
self.confidence
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"VitalSignsReading(breathing={:?}, heartbeat={:?}, movement={:.3}, confidence={:.3})",
|
||||
self.breathing_rate_bpm, self.heartbeat_rate_bpm, self.movement_intensity, self.confidence,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Survivor ────────────────────────────────────────────────────────
|
||||
|
||||
/// A detected survivor: id, triage class, confidence, optional 3-D location,
|
||||
/// and the latest vital-signs reading.
|
||||
#[pyclass(frozen, name = "Survivor")]
|
||||
pub struct PySurvivor {
|
||||
id: String,
|
||||
triage_status: PyTriageStatus,
|
||||
confidence: f64,
|
||||
location: Option<(f64, f64, f64)>,
|
||||
latest_vitals: Option<Py<PyVitalSignsReading>>,
|
||||
}
|
||||
|
||||
impl PySurvivor {
|
||||
fn from_rust(py: Python<'_>, s: &Survivor) -> PyResult<Self> {
|
||||
let latest_vitals = match s.vital_signs().latest() {
|
||||
Some(r) => Some(Py::new(py, PyVitalSignsReading::from_rust(r))?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
id: s.id().as_uuid().to_string(),
|
||||
triage_status: PyTriageStatus::from_rust(s.triage_status()),
|
||||
confidence: s.confidence(),
|
||||
location: s.location().map(|c| (c.x, c.y, c.z)),
|
||||
latest_vitals,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PySurvivor {
|
||||
#[getter]
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
#[getter]
|
||||
fn triage_status(&self) -> PyTriageStatus {
|
||||
self.triage_status
|
||||
}
|
||||
#[getter]
|
||||
fn confidence(&self) -> f64 {
|
||||
self.confidence
|
||||
}
|
||||
#[getter]
|
||||
fn location(&self) -> Option<(f64, f64, f64)> {
|
||||
self.location
|
||||
}
|
||||
#[getter]
|
||||
fn latest_vitals(&self, py: Python<'_>) -> Option<Py<PyVitalSignsReading>> {
|
||||
self.latest_vitals.as_ref().map(|v| v.clone_ref(py))
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Survivor(id={}, triage={:?}, confidence={:.3})",
|
||||
&self.id[..8.min(self.id.len())],
|
||||
self.triage_status.as_rust(),
|
||||
self.confidence,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DisasterConfig ──────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the disaster-response pipeline.
|
||||
///
|
||||
/// Note: the Python binding always runs **single-shot** scans (`scan_once`),
|
||||
/// so `continuous_monitoring` is forced off internally.
|
||||
#[pyclass(frozen, name = "DisasterConfig")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyDisasterConfig {
|
||||
inner: DisasterConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDisasterConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
disaster_type,
|
||||
sensitivity=0.8,
|
||||
confidence_threshold=0.5,
|
||||
max_depth=5.0,
|
||||
scan_interval_ms=500
|
||||
))]
|
||||
fn new(
|
||||
disaster_type: PyDisasterType,
|
||||
sensitivity: f64,
|
||||
confidence_threshold: f64,
|
||||
max_depth: f64,
|
||||
scan_interval_ms: u64,
|
||||
) -> Self {
|
||||
let inner = DisasterConfig::builder()
|
||||
.disaster_type(disaster_type.as_rust())
|
||||
.sensitivity(sensitivity)
|
||||
.confidence_threshold(confidence_threshold)
|
||||
.max_depth(max_depth)
|
||||
.scan_interval_ms(scan_interval_ms)
|
||||
.continuous_monitoring(false)
|
||||
.build();
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn sensitivity(&self) -> f64 {
|
||||
self.inner.sensitivity
|
||||
}
|
||||
#[getter]
|
||||
fn confidence_threshold(&self) -> f64 {
|
||||
self.inner.confidence_threshold
|
||||
}
|
||||
#[getter]
|
||||
fn max_depth(&self) -> f64 {
|
||||
self.inner.max_depth
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"DisasterConfig(disaster_type={:?}, sensitivity={}, confidence_threshold={}, max_depth={})",
|
||||
self.inner.disaster_type,
|
||||
self.inner.sensitivity,
|
||||
self.inner.confidence_threshold,
|
||||
self.inner.max_depth,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ScanZone ────────────────────────────────────────────────────────
|
||||
|
||||
/// A rectangular or circular scan zone (new zones start Active).
|
||||
#[pyclass(name = "ScanZone")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyScanZone {
|
||||
inner: ScanZone,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyScanZone {
|
||||
/// Rectangular zone with corner bounds (metres).
|
||||
#[staticmethod]
|
||||
fn rectangle(name: &str, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
|
||||
Self {
|
||||
inner: ScanZone::new(name, ZoneBounds::rectangle(min_x, min_y, max_x, max_y)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular zone centred at `(center_x, center_y)` with `radius` (metres).
|
||||
#[staticmethod]
|
||||
fn circle(name: &str, center_x: f64, center_y: f64, radius: f64) -> Self {
|
||||
Self {
|
||||
inner: ScanZone::new(name, ZoneBounds::circle(center_x, center_y, radius)),
|
||||
}
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("ScanZone(name={:?})", self.inner.name())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DisasterResponse ────────────────────────────────────────────────
|
||||
|
||||
/// Main disaster-response coordinator: ingest CSI, run one scan cycle, query
|
||||
/// detected survivors by START triage.
|
||||
#[pyclass(name = "DisasterResponse")]
|
||||
pub struct PyDisasterResponse {
|
||||
inner: DisasterResponse,
|
||||
rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDisasterResponse {
|
||||
#[new]
|
||||
fn new(config: PyDisasterConfig) -> PyResult<Self> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|e| PyValueError::new_err(format!("failed to build tokio runtime: {e}")))?;
|
||||
Ok(Self {
|
||||
inner: DisasterResponse::new(config.inner),
|
||||
rt,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize the active disaster event at map coordinate `(x, y)`.
|
||||
/// Required before `add_zone`/`scan_once`.
|
||||
fn initialize_event(&mut self, x: f64, y: f64, description: &str) -> PyResult<()> {
|
||||
self.inner
|
||||
.initialize_event(geo::Point::new(x, y), description)
|
||||
.map(|_| ())
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Add an (Active) scan zone to the current event. Raises if no event.
|
||||
fn add_zone(&mut self, zone: PyScanZone) -> PyResult<()> {
|
||||
self.inner
|
||||
.add_zone(zone.inner)
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Push a raw CSI frame (equal-length `amplitudes`/`phases`) into the
|
||||
/// detection pipeline. Raises on empty/mismatched input. GIL released.
|
||||
fn push_csi_data(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
) -> PyResult<()> {
|
||||
py.allow_threads(|| self.inner.push_csi_data(&litudes, &phases))
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Run exactly one scan cycle over the buffered CSI (detection → ensemble
|
||||
/// → localization → triage). Requires an initialized event with an Active
|
||||
/// zone. GIL released during the scan.
|
||||
fn scan_once(&mut self, py: Python<'_>) -> PyResult<()> {
|
||||
let rt = &self.rt;
|
||||
let inner = &mut self.inner;
|
||||
py.allow_threads(|| rt.block_on(inner.start_scanning()))
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// All detected survivors.
|
||||
fn survivors(&self, py: Python<'_>) -> PyResult<Vec<PySurvivor>> {
|
||||
self.inner
|
||||
.survivors()
|
||||
.into_iter()
|
||||
.map(|s| PySurvivor::from_rust(py, s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Survivors filtered by START triage class.
|
||||
fn survivors_by_triage(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
status: PyTriageStatus,
|
||||
) -> PyResult<Vec<PySurvivor>> {
|
||||
self.inner
|
||||
.survivors_by_triage(status.as_rust())
|
||||
.into_iter()
|
||||
.map(|s| PySurvivor::from_rust(py, s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"DisasterResponse()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyDisasterType>()?;
|
||||
m.add_class::<PyTriageStatus>()?;
|
||||
m.add_class::<PyVitalSignsReading>()?;
|
||||
m.add_class::<PySurvivor>()?;
|
||||
m.add_class::<PyDisasterConfig>()?;
|
||||
m.add_class::<PyScanZone>()?;
|
||||
m.add_class::<PyDisasterResponse>()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,492 +0,0 @@
|
||||
//! ADR-185 P2 — PyO3 bindings for MERIDIAN cross-environment domain
|
||||
//! generalization (ADR-027).
|
||||
//!
|
||||
//! Surfaces the **pure-sync, tch-free** inference/adaptation path into
|
||||
//! `wifi_densepose.meridian`:
|
||||
//!
|
||||
//! - `HardwareType` / `HardwareNormalizer` / `CanonicalCsiFrame`
|
||||
//! (from `wifi-densepose-signal::hardware_norm`)
|
||||
//! - `MeridianGeometryConfig` / `GeometryEncoder`
|
||||
//! - `RapidAdaptation` / `AdaptationResult`
|
||||
//! - `CrossDomainEvaluator` + `mpjpe`
|
||||
//! (from `wifi-densepose-train`, NO `tch-backend`)
|
||||
//!
|
||||
//! ## Honest scope vs ADR-185 §3.3
|
||||
//!
|
||||
//! ADR-185 §3.3 names a surface that partly diverges from the code at HEAD;
|
||||
//! this binding tracks the **real** API and documents each deviation:
|
||||
//!
|
||||
//! - `HardwareType.detect(subcarrier_count)` — the real detector is the
|
||||
//! static `HardwareNormalizer::detect_hardware`; exposed here as a
|
||||
//! `HardwareType.detect` staticmethod delegating to it (no reimpl).
|
||||
//! - `HardwareNormalizer.normalize(frame: CsiFrame, hw)` — the real method
|
||||
//! takes raw `(amplitude, phase)` f64 vectors and returns a `Result`, so
|
||||
//! it is bound as `normalize(amplitude, phase, hw)` (raises on error).
|
||||
//! - `CanonicalCsiFrame.amplitudes/.phases` — the real fields are singular
|
||||
//! `amplitude`/`phase`; bound under their real names.
|
||||
//! - `RapidAdaptation.calibrate(csi_windows) -> AdaptationResult` with a
|
||||
//! `converged` field — **does not exist**. The real engine is
|
||||
//! `push_frame` + `adapt()`, and `AdaptationResult` carries
|
||||
//! `{lora_weights, final_loss, frames_used, adaptation_epochs}` (no
|
||||
//! `converged`). Bound as-is; the `calibrate`/`converged` surface is a
|
||||
//! Rust-side gap, not fabricated here.
|
||||
//!
|
||||
//! Training-time types (`DomainFactorizer`, `GradientReversalLayer`,
|
||||
//! `VirtualDomainAugmentor`) are out of P6 scope (ADR-185 §3.3 / Open Q
|
||||
//! §11.2) — inference/adaptation only.
|
||||
//!
|
||||
//! ## GIL release (per ADR-117 §7, matching bindings/vitals.rs)
|
||||
//!
|
||||
//! `normalize`, `encode`, `adapt`, and `evaluate` are pure-sync numeric
|
||||
//! ops touching no Python objects, so they run inside `py.allow_threads`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_signal::hardware_norm::{
|
||||
CanonicalCsiFrame, HardwareNormalizer, HardwareType,
|
||||
};
|
||||
use wifi_densepose_train::eval::{mpjpe as rust_mpjpe, CrossDomainEvaluator};
|
||||
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
|
||||
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, AdaptationResult, RapidAdaptation};
|
||||
|
||||
// ─── HardwareType ────────────────────────────────────────────────────
|
||||
|
||||
/// WiFi chipset family, keyed by subcarrier count.
|
||||
#[pyclass(eq, eq_int, frozen, hash, name = "HardwareType")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyHardwareType {
|
||||
Esp32S3 = 0,
|
||||
Intel5300 = 1,
|
||||
Atheros = 2,
|
||||
Generic = 3,
|
||||
}
|
||||
|
||||
impl PyHardwareType {
|
||||
fn as_rust(self) -> HardwareType {
|
||||
match self {
|
||||
Self::Esp32S3 => HardwareType::Esp32S3,
|
||||
Self::Intel5300 => HardwareType::Intel5300,
|
||||
Self::Atheros => HardwareType::Atheros,
|
||||
Self::Generic => HardwareType::Generic,
|
||||
}
|
||||
}
|
||||
fn from_rust(hw: HardwareType) -> Self {
|
||||
match hw {
|
||||
HardwareType::Esp32S3 => Self::Esp32S3,
|
||||
HardwareType::Intel5300 => Self::Intel5300,
|
||||
HardwareType::Atheros => Self::Atheros,
|
||||
HardwareType::Generic => Self::Generic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyHardwareType {
|
||||
/// Detect hardware from subcarrier count (64→Esp32S3, 30→Intel5300,
|
||||
/// 56→Atheros, else Generic). Delegates to the real
|
||||
/// `HardwareNormalizer::detect_hardware`.
|
||||
#[staticmethod]
|
||||
fn detect(subcarrier_count: usize) -> Self {
|
||||
Self::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn subcarrier_count(&self) -> usize {
|
||||
self.as_rust().subcarrier_count()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn mimo_streams(&self) -> usize {
|
||||
self.as_rust().mimo_streams()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("HardwareType.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CanonicalCsiFrame ───────────────────────────────────────────────
|
||||
|
||||
/// A CSI frame canonicalized to the normalizer's subcarrier grid
|
||||
/// (default 56): z-scored amplitude + sanitized (unwrapped, detrended)
|
||||
/// phase.
|
||||
#[pyclass(frozen, name = "CanonicalCsiFrame")]
|
||||
pub struct PyCanonicalCsiFrame {
|
||||
inner: CanonicalCsiFrame,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCanonicalCsiFrame {
|
||||
#[getter]
|
||||
fn amplitude(&self) -> Vec<f32> {
|
||||
self.inner.amplitude.clone()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn phase(&self) -> Vec<f32> {
|
||||
self.inner.phase.clone()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn hardware_type(&self) -> PyHardwareType {
|
||||
PyHardwareType::from_rust(self.inner.hardware_type)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"CanonicalCsiFrame(subcarriers={}, hardware_type={:?})",
|
||||
self.inner.amplitude.len(),
|
||||
self.inner.hardware_type,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HardwareNormalizer ──────────────────────────────────────────────
|
||||
|
||||
/// Normalizes CSI frames from heterogeneous chipsets into a canonical
|
||||
/// representation (cubic resample → z-score amplitude → sanitize phase).
|
||||
#[pyclass(name = "HardwareNormalizer")]
|
||||
pub struct PyHardwareNormalizer {
|
||||
inner: HardwareNormalizer,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyHardwareNormalizer {
|
||||
/// Create a normalizer. `canonical_subcarriers` defaults to 56.
|
||||
#[new]
|
||||
#[pyo3(signature = (canonical_subcarriers=56))]
|
||||
fn new(canonical_subcarriers: usize) -> PyResult<Self> {
|
||||
HardwareNormalizer::with_canonical_subcarriers(canonical_subcarriers)
|
||||
.map(|inner| Self { inner })
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Detect hardware from subcarrier count (static).
|
||||
#[staticmethod]
|
||||
fn detect_hardware(subcarrier_count: usize) -> PyHardwareType {
|
||||
PyHardwareType::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn canonical_subcarriers(&self) -> usize {
|
||||
self.inner.canonical_subcarriers()
|
||||
}
|
||||
|
||||
/// Normalize a raw CSI frame given per-subcarrier `amplitude` and
|
||||
/// `phase` (equal length) and its `hardware` type. Raises
|
||||
/// `ValueError` on empty/mismatched input. GIL released.
|
||||
fn normalize(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
amplitude: Vec<f64>,
|
||||
phase: Vec<f64>,
|
||||
hardware: PyHardwareType,
|
||||
) -> PyResult<PyCanonicalCsiFrame> {
|
||||
let hw = hardware.as_rust();
|
||||
py.allow_threads(|| self.inner.normalize(&litude, &phase, hw))
|
||||
.map(|inner| PyCanonicalCsiFrame { inner })
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"HardwareNormalizer(canonical_subcarriers={})",
|
||||
self.inner.canonical_subcarriers()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MeridianGeometryConfig ──────────────────────────────────────────
|
||||
|
||||
/// Config for the geometry encoder (Fourier bands + DeepSets output dim).
|
||||
#[pyclass(frozen, name = "MeridianGeometryConfig")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyMeridianGeometryConfig {
|
||||
inner: MeridianGeometryConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyMeridianGeometryConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (n_frequencies=10, scale=1.0, geometry_dim=64, seed=42))]
|
||||
fn new(n_frequencies: usize, scale: f32, geometry_dim: usize, seed: u64) -> Self {
|
||||
Self {
|
||||
inner: MeridianGeometryConfig {
|
||||
n_frequencies,
|
||||
scale,
|
||||
geometry_dim,
|
||||
seed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn n_frequencies(&self) -> usize {
|
||||
self.inner.n_frequencies
|
||||
}
|
||||
#[getter]
|
||||
fn scale(&self) -> f32 {
|
||||
self.inner.scale
|
||||
}
|
||||
#[getter]
|
||||
fn geometry_dim(&self) -> usize {
|
||||
self.inner.geometry_dim
|
||||
}
|
||||
#[getter]
|
||||
fn seed(&self) -> u64 {
|
||||
self.inner.seed
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"MeridianGeometryConfig(n_frequencies={}, scale={}, geometry_dim={}, seed={})",
|
||||
self.inner.n_frequencies, self.inner.scale, self.inner.geometry_dim, self.inner.seed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GeometryEncoder ─────────────────────────────────────────────────
|
||||
|
||||
/// Permutation-invariant encoder: variable-count AP positions `[x,y,z]`
|
||||
/// → a fixed `geometry_dim` (default 64) vector.
|
||||
#[pyclass(name = "GeometryEncoder")]
|
||||
pub struct PyGeometryEncoder {
|
||||
inner: GeometryEncoder,
|
||||
geometry_dim: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyGeometryEncoder {
|
||||
#[new]
|
||||
#[pyo3(signature = (config=None))]
|
||||
fn new(config: Option<PyMeridianGeometryConfig>) -> Self {
|
||||
let cfg = config.map(|c| c.inner).unwrap_or_default();
|
||||
let geometry_dim = cfg.geometry_dim;
|
||||
Self {
|
||||
inner: GeometryEncoder::new(&cfg),
|
||||
geometry_dim,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode AP positions (a non-empty list of `[x, y, z]`) into a
|
||||
/// `geometry_dim`-length vector. Raises `ValueError` if the list is
|
||||
/// empty or any position is not exactly 3 coordinates. GIL released.
|
||||
fn encode(&self, py: Python<'_>, ap_positions: Vec<Vec<f32>>) -> PyResult<Vec<f32>> {
|
||||
if ap_positions.is_empty() {
|
||||
return Err(PyValueError::new_err(
|
||||
"ap_positions must contain at least one [x, y, z] position",
|
||||
));
|
||||
}
|
||||
let mut coords: Vec<[f32; 3]> = Vec::with_capacity(ap_positions.len());
|
||||
for (i, p) in ap_positions.iter().enumerate() {
|
||||
if p.len() != 3 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"ap_positions[{i}] must have exactly 3 coordinates, got {}",
|
||||
p.len()
|
||||
)));
|
||||
}
|
||||
coords.push([p[0], p[1], p[2]]);
|
||||
}
|
||||
Ok(py.allow_threads(|| self.inner.encode(&coords)))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn geometry_dim(&self) -> usize {
|
||||
self.geometry_dim
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("GeometryEncoder(geometry_dim={})", self.geometry_dim)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RapidAdaptation / AdaptationResult ──────────────────────────────
|
||||
|
||||
/// Result of `RapidAdaptation.adapt()`.
|
||||
#[pyclass(frozen, name = "AdaptationResult")]
|
||||
pub struct PyAdaptationResult {
|
||||
inner: AdaptationResult,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAdaptationResult {
|
||||
#[getter]
|
||||
fn lora_weights(&self) -> Vec<f32> {
|
||||
self.inner.lora_weights.clone()
|
||||
}
|
||||
#[getter]
|
||||
fn final_loss(&self) -> f32 {
|
||||
self.inner.final_loss
|
||||
}
|
||||
#[getter]
|
||||
fn frames_used(&self) -> usize {
|
||||
self.inner.frames_used
|
||||
}
|
||||
#[getter]
|
||||
fn adaptation_epochs(&self) -> usize {
|
||||
self.inner.adaptation_epochs
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"AdaptationResult(final_loss={:.6}, frames_used={}, adaptation_epochs={})",
|
||||
self.inner.final_loss, self.inner.frames_used, self.inner.adaptation_epochs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Few-shot test-time adaptation: accumulate unlabeled CSI frames, then
|
||||
/// `adapt()` to produce LoRA weight deltas that minimize a self-supervised
|
||||
/// proxy loss.
|
||||
///
|
||||
/// Scope caveat (from the Rust module, kept honest): this minimizes a
|
||||
/// self-supervised proxy over a tiny LoRA bottleneck; it is NOT wired to
|
||||
/// the pose model and there is no measured end-to-end PCK gain from this
|
||||
/// path — do not cite a PCK improvement from `adapt()`.
|
||||
#[pyclass(name = "RapidAdaptation")]
|
||||
pub struct PyRapidAdaptation {
|
||||
inner: RapidAdaptation,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRapidAdaptation {
|
||||
/// Build an adaptation engine. `loss_kind` is one of
|
||||
/// `"contrastive"`, `"entropy"`, `"combined"` (default). `lambda_ent`
|
||||
/// is used only by `"combined"`.
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
min_calibration_frames,
|
||||
lora_rank,
|
||||
loss_kind="combined",
|
||||
epochs=5,
|
||||
lr=0.001,
|
||||
lambda_ent=0.5
|
||||
))]
|
||||
fn new(
|
||||
min_calibration_frames: usize,
|
||||
lora_rank: usize,
|
||||
loss_kind: &str,
|
||||
epochs: usize,
|
||||
lr: f32,
|
||||
lambda_ent: f32,
|
||||
) -> PyResult<Self> {
|
||||
let loss = match loss_kind {
|
||||
"contrastive" => AdaptationLoss::ContrastiveTTT { epochs, lr },
|
||||
"entropy" => AdaptationLoss::EntropyMin { epochs, lr },
|
||||
"combined" => AdaptationLoss::Combined {
|
||||
epochs,
|
||||
lr,
|
||||
lambda_ent,
|
||||
},
|
||||
other => {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"unknown loss_kind '{other}'; expected 'contrastive', 'entropy', or 'combined'"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
inner: RapidAdaptation::new(min_calibration_frames, lora_rank, loss),
|
||||
})
|
||||
}
|
||||
|
||||
/// Push a single unlabeled CSI frame into the calibration buffer.
|
||||
fn push_frame(&mut self, frame: Vec<f32>) {
|
||||
self.inner.push_frame(&frame);
|
||||
}
|
||||
|
||||
/// True once at least `min_calibration_frames` have been buffered.
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn buffer_len(&self) -> usize {
|
||||
self.inner.buffer_len()
|
||||
}
|
||||
|
||||
/// Run test-time adaptation over the buffered frames. Raises
|
||||
/// `ValueError` if the buffer is empty or `lora_rank == 0`. GIL
|
||||
/// released during the finite-difference optimization.
|
||||
fn adapt(&self, py: Python<'_>) -> PyResult<PyAdaptationResult> {
|
||||
py.allow_threads(|| self.inner.adapt())
|
||||
.map(|inner| PyAdaptationResult { inner })
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("RapidAdaptation(buffered={})", self.inner.buffer_len())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CrossDomainEvaluator ────────────────────────────────────────────
|
||||
|
||||
/// Cross-domain pose-accuracy evaluator (MPJPE + domain-gap ratio).
|
||||
#[pyclass(name = "CrossDomainEvaluator")]
|
||||
pub struct PyCrossDomainEvaluator {
|
||||
inner: CrossDomainEvaluator,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCrossDomainEvaluator {
|
||||
/// Create an evaluator for `n_joints` (e.g. 17 for COCO).
|
||||
#[new]
|
||||
fn new(n_joints: usize) -> Self {
|
||||
Self {
|
||||
inner: CrossDomainEvaluator::new(n_joints),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate `predictions` (a list of `(pred, gt)` flat `n_joints*3`
|
||||
/// vectors) grouped by `domain_labels` (0 = in-domain). Returns a
|
||||
/// dict of the six cross-domain metrics. Raises `ValueError` on a
|
||||
/// length mismatch. GIL released.
|
||||
fn evaluate(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
predictions: Vec<(Vec<f32>, Vec<f32>)>,
|
||||
domain_labels: Vec<u32>,
|
||||
) -> PyResult<HashMap<String, f32>> {
|
||||
if predictions.len() != domain_labels.len() {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"predictions ({}) and domain_labels ({}) must have equal length",
|
||||
predictions.len(),
|
||||
domain_labels.len()
|
||||
)));
|
||||
}
|
||||
let m = py.allow_threads(|| self.inner.evaluate(&predictions, &domain_labels));
|
||||
let mut out = HashMap::with_capacity(6);
|
||||
out.insert("in_domain_mpjpe".to_string(), m.in_domain_mpjpe);
|
||||
out.insert("cross_domain_mpjpe".to_string(), m.cross_domain_mpjpe);
|
||||
out.insert("few_shot_mpjpe".to_string(), m.few_shot_mpjpe);
|
||||
out.insert("cross_hardware_mpjpe".to_string(), m.cross_hardware_mpjpe);
|
||||
out.insert("domain_gap_ratio".to_string(), m.domain_gap_ratio);
|
||||
out.insert("adaptation_speedup".to_string(), m.adaptation_speedup);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"CrossDomainEvaluator()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean Per Joint Position Error between flat `[n_joints*3]` pose vectors.
|
||||
#[pyfunction]
|
||||
fn mpjpe(pred: Vec<f32>, gt: Vec<f32>, n_joints: usize) -> f32 {
|
||||
rust_mpjpe(&pred, >, n_joints)
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyHardwareType>()?;
|
||||
m.add_class::<PyCanonicalCsiFrame>()?;
|
||||
m.add_class::<PyHardwareNormalizer>()?;
|
||||
m.add_class::<PyMeridianGeometryConfig>()?;
|
||||
m.add_class::<PyGeometryEncoder>()?;
|
||||
m.add_class::<PyAdaptationResult>()?;
|
||||
m.add_class::<PyRapidAdaptation>()?;
|
||||
m.add_class::<PyCrossDomainEvaluator>()?;
|
||||
m.add_function(wrap_pyfunction!(mpjpe, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -242,22 +242,7 @@ impl PyBreathingExtractor {
|
||||
// ─── HeartRateExtractor ──────────────────────────────────────────────
|
||||
|
||||
/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude
|
||||
/// residuals and per-subcarrier unwrapped phases (radians) via
|
||||
/// 0.8–2.0 Hz bandpass + autocorrelation peak detection.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import HeartRateExtractor
|
||||
///
|
||||
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
|
||||
///
|
||||
/// # Feed residuals and matching unwrapped phases from your preprocessor.
|
||||
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
|
||||
/// # extraction because the Rust core requires phase data for each subcarrier.
|
||||
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
|
||||
/// if est is not None:
|
||||
/// print(est.value_bpm, est.confidence)
|
||||
/// ```
|
||||
/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection.
|
||||
#[pyclass(name = "HeartRateExtractor")]
|
||||
pub struct PyHeartRateExtractor {
|
||||
inner: HeartRateExtractor,
|
||||
@@ -280,17 +265,10 @@ impl PyHeartRateExtractor {
|
||||
Self { inner: HeartRateExtractor::esp32_default() }
|
||||
}
|
||||
|
||||
/// Extract heart rate from per-subcarrier residuals and matching
|
||||
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
|
||||
/// and return `None` because the Rust extractor requires phase data.
|
||||
/// GIL released during DSP.
|
||||
fn extract(
|
||||
&mut self,
|
||||
py: Python<'_>,
|
||||
residuals: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
) -> Option<PyVitalEstimate> {
|
||||
let est = py.allow_threads(|| self.inner.extract(&residuals, &phases));
|
||||
/// Extract heart rate from per-subcarrier residuals. GIL released
|
||||
/// during DSP.
|
||||
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
|
||||
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
|
||||
est.map(PyVitalEstimate::from_rust)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,7 @@
|
||||
use pyo3::prelude::*;
|
||||
|
||||
mod bindings {
|
||||
#[cfg(feature = "aether")]
|
||||
pub mod aether;
|
||||
pub mod bfld;
|
||||
#[cfg(feature = "mat")]
|
||||
pub mod mat;
|
||||
#[cfg(feature = "meridian")]
|
||||
pub mod meridian;
|
||||
pub mod keypoint;
|
||||
pub mod pose;
|
||||
pub mod privacy_gate;
|
||||
@@ -49,12 +43,6 @@ fn build_features() -> Vec<&'static str> {
|
||||
feats.push("p2-pose-bindings"); // BoundingBox + PersonPose + PoseEstimate
|
||||
feats.push("p3-vitals-bindings"); // BreathingExtractor + HeartRateExtractor + VitalEstimate
|
||||
feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust)
|
||||
#[cfg(feature = "aether")]
|
||||
feats.push("p6-aether-bindings"); // ADR-185 P1 — AETHER contrastive embeddings
|
||||
#[cfg(feature = "meridian")]
|
||||
feats.push("p6-meridian-bindings"); // ADR-185 P2 — MERIDIAN domain generalization
|
||||
#[cfg(feature = "mat")]
|
||||
feats.push("p6-mat-bindings"); // ADR-185 P3 — MAT disaster survivor detection
|
||||
feats
|
||||
}
|
||||
|
||||
@@ -97,23 +85,5 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
// the published `wifi-densepose-bfld 0.3.0` crate, not the Python port).
|
||||
// Closes ADR-125 §2.1.d at the binding boundary.
|
||||
bindings::privacy_gate::register(m)?;
|
||||
|
||||
// ADR-185 P1 — AETHER contrastive CSI embedding bindings, compiled
|
||||
// and registered only under the `aether` feature so the default
|
||||
// wheel links none of the sensing-server dependency tree.
|
||||
#[cfg(feature = "aether")]
|
||||
bindings::aether::register(m)?;
|
||||
|
||||
// ADR-185 P2 — MERIDIAN cross-environment domain-generalization
|
||||
// bindings (hardware normalization, geometry encoding, rapid
|
||||
// adaptation, cross-domain eval). Gated behind `meridian`; tch-free.
|
||||
#[cfg(feature = "meridian")]
|
||||
bindings::meridian::register(m)?;
|
||||
|
||||
// ADR-185 P3 — MAT disaster-survivor detection + START triage. Gated
|
||||
// behind `mat`, mirroring the upstream disaster/ML stack gating.
|
||||
#[cfg(feature = "mat")]
|
||||
bindings::mat::register(m)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
//! ADR-185 §4.1 — AETHER parity: native-Rust reference half.
|
||||
//!
|
||||
//! Produces the golden 128-dim embedding by calling the canonical
|
||||
//! `wifi-densepose-aether::embedding` code DIRECTLY (no PyO3), for the
|
||||
//! committed `tests/golden/aether_input.json` fixture, and compares it to the
|
||||
//! committed golden VECTOR `tests/golden/aether_embedding.json` within a
|
||||
//! numerical tolerance.
|
||||
//!
|
||||
//! Why a vector + tolerance and not a SHA-256 of the f32 bytes: the embedding
|
||||
//! is pure f32 and uses transcendental ops (ln/sqrt/cos), which are not
|
||||
//! bit-reproducible across CPU architectures or libm implementations. A byte
|
||||
//! hash only ever matched the one arch that generated it and failed on every
|
||||
//! other wheel this project builds (aarch64, macOS-arm). The pytest half
|
||||
//! (`tests/test_aether.py`) compares the Python binding to the SAME golden
|
||||
//! within the same tolerance — native≈golden and binding≈golden together prove
|
||||
//! binding≈native, portably.
|
||||
//!
|
||||
//! Regeneration (only when the Rust subsystem intentionally changes): delete
|
||||
//! `tests/golden/aether_embedding.json` and re-run `cargo test --features aether`.
|
||||
#![cfg(feature = "aether")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
/// Cross-architecture f32 parity tolerance; see the module docs and the
|
||||
/// matching `PARITY_ATOL`/`PARITY_RTOL` in `tests/test_aether.py`.
|
||||
const PARITY_ATOL: f32 = 1e-4;
|
||||
const PARITY_RTOL: f32 = 1e-4;
|
||||
|
||||
/// Assert `embedding` matches the committed golden vector `<name>` within
|
||||
/// tolerance, or (if the golden is absent) write it and fail asking for a re-run.
|
||||
fn assert_matches_golden_vector(embedding: &[f32], name: &str) {
|
||||
let path = golden_dir().join(name);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(raw) => {
|
||||
let golden: Vec<f32> = serde_json::from_str(&raw)
|
||||
.expect("parse golden vector json");
|
||||
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
|
||||
for (i, (&got, &want)) in embedding.iter().zip(&golden).enumerate() {
|
||||
let tol = PARITY_ATOL + PARITY_RTOL * want.abs();
|
||||
assert!(
|
||||
(got - want).abs() <= tol,
|
||||
"{name}: element {i} diverged beyond tolerance \
|
||||
(got {got}, golden {want}, |Δ|={}) — a real regression, \
|
||||
not cross-arch f32 drift",
|
||||
(got - want).abs()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let json = serde_json::to_string(&embedding).expect("serialize golden");
|
||||
fs::write(&path, &json).expect("write golden vector");
|
||||
panic!("no committed golden {name}; wrote it. Re-run to verify parity.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden")
|
||||
}
|
||||
|
||||
fn load_input() -> Vec<Vec<f32>> {
|
||||
let raw = fs::read_to_string(golden_dir().join("aether_input.json"))
|
||||
.expect("read aether_input.json fixture");
|
||||
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
|
||||
rows.into_iter()
|
||||
.map(|row| row.into_iter().map(|x| x as f32).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the extractor identically to the Python binding's default
|
||||
/// construction: `AetherConfig()` + `EmbeddingExtractor(n_subcarriers=56, cfg)`.
|
||||
fn embed_native(input: &[Vec<f32>]) -> Vec<f32> {
|
||||
let e_config = EmbeddingConfig {
|
||||
d_model: 64,
|
||||
d_proj: 128,
|
||||
temperature: 0.07,
|
||||
normalize: true,
|
||||
};
|
||||
let t_config = TransformerConfig {
|
||||
n_subcarriers: 56,
|
||||
n_keypoints: 17,
|
||||
d_model: 64,
|
||||
n_heads: 4,
|
||||
n_gnn_layers: 2,
|
||||
};
|
||||
let mut ext = EmbeddingExtractor::new(t_config, e_config);
|
||||
ext.extract(input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_embedding_is_128_dim_unit_norm() {
|
||||
let emb = embed_native(&load_input());
|
||||
assert_eq!(emb.len(), 128, "AETHER embedding must be 128-dim");
|
||||
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!(
|
||||
(norm - 1.0).abs() < 1e-4,
|
||||
"embedding must be L2-normalized, got norm={norm}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_embedding_matches_committed_golden() {
|
||||
let emb = embed_native(&load_input());
|
||||
assert_matches_golden_vector(&emb, "aether_embedding.json");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user