mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e51338e05 |
+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']
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -291,8 +291,3 @@ harness/**/ruvector.db
|
||||
# ruvector runtime/hook DB — never tracked (any depth)
|
||||
ruvector.db
|
||||
**/ruvector.db
|
||||
|
||||
# sensing-server runtime artifacts written by its test suite (trained model
|
||||
# snapshots + the generated session-secret) — never tracked
|
||||
v2/crates/wifi-densepose-sensing-server/data/
|
||||
*.proptest-regressions
|
||||
|
||||
@@ -7,29 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
|
||||
- **`ruview-unified` increment 2 — native frame contract + programmable perception (ADR-279..282).** (1) `RfFrameV2` becomes the authoritative RF record: native complex IQ with explicit validity masks, declared `PhaseState`, TX/RX poses + antenna geometry in one building frame, calibration/quality state, and a provenance rule enforced at construction — `Synthetic ⇒ L0Simulation` and `Measured ⇒ ≥ L1CapturedReplay` can never alias (the public L0–L5 evidence ladder is now a type); the 56-bin canonical tensor is demoted to a derived compatibility view (`to_canonical`, mask-aware gap-filling through the same normalization path as every adapter; native samples proven byte-untouched). (2) Active sensing control plane (`control.rs`): ETSI-ISAC-vocabulary `SensingTask` admission (raw export always refused; identity requires consent), `SensingAction`/`InformationGoal`, an age-of-information `ActiveSensingPlanner` (priority = uncertainty × change rate × criticality ÷ cost; **measured 95% sensing-traffic reduction** vs uniform refresh on a 20-region scenario), fail-closed `CoherentSensorGroup` fusion gates (time/phase/geometry bounds; five denial paths tested), policy-authorized RIS/movable-antenna actuation receipts, and purpose-scoped `TaskSufficientRepresentation` leakage validation. (3) New modality surfaces: BLE Channel Sounding adapter + `ble_cs_range` treating phase-slope and RTT as **separate cross-validated evidence** (exact distance recovery on synthetic tones; relay-style divergence flagged, never averaged), delay-Doppler-native `FieldAxis` + `delay_doppler_map` (unit-peak tone test), IEEE P3162 synthetic-aperture import profile. (4) RePos-factorized pose head (relative skeleton on the content representation, root on the geometry-conditioned one, calibrated per-joint uncertainties): held-out-room MPJPE 0.0003 m vs 0.2534 m for the monolithic baseline in the room-shortcut leakage experiment; ≤2% structured-adapter budget (740 params). (5) Age gate input now `log(1+age_ms)` per the age-aware-CSI recipe (gradient check re-proven); Gaussian primitives gained `first_seen_ns`/`doppler_variance`/bounded `source_receipts` lineage; `PartitionKey` gained a `session` dimension and `SplitManifest` certifies disjointness across all seven dimensions. 87 tests, 0 failed; crate clippy-clean. Docker images unaffected (no shipped binary consumes the crate yet); Python proof re-verified PASS.
|
||||
- **`ruview-unified` — unified RF spatial world model, P1 (ADR-273..278).** New v2 workspace leaf crate implementing the five-pillar architecture: (1) canonical `RfTensor` (`links × 56 bins × 8 snapshots`, complex, validated at the boundary) plus a fail-closed hardware adapter registry with reference adapters for 802.11 CSI (consumes `wifi-densepose-core::CsiFrame`), FMCW radar cubes (fast-time DFT), UWB CIR, and 5G SRS (comb de-interleave); (2) a universal RF foundation encoder — window-median + CFO-aligned tokenizer, masked-reconstruction pretraining with a hand-derived backward pass verified against central finite differences (174 params sampled, max rel err 1.31e-5), the ADR-273 fusion contract `z = Enc(CSI) ⊙ σ(AgeEnc) + GeomEnc(pose)`, and ≤1% task adapters (presence 129 / activity 268 / localization 387 / anomaly 2 vs a 40,856-param backbone); (3) an RF-aware Gaussian spatial memory — anisotropic primitives with per-band×angle reflectivity, confidence-weighted fusion, exponential decay, spatial-hash + semantic queries, closed-form (erf) Beer–Lambert channel-gain queries that degrade to exact Friis on an empty map, inverse gain updates that learn an unseen 6 dB obstruction to <0.5 dB in 20 link observations, and a JITOMA-style task-gated scene graph; (4) a physics-guided synthetic RF world generator — Allen–Berkley image method (order ≤2), complex-permittivity Fresnel materials, bistatic person scattering with *emergent* Doppler (proven against the analytic phase rate), seeded ChaCha20 domain randomization of physics + hardware nuisances (gain/CFO/phase noise/packet loss/interference); (5) an edge sensing control plane — 802.11bf/ETSI-ISAC-aligned purposes and zones, fail-closed authorization, a double-gated identity purpose, retention bounds, and a `BoundedEvent`-only trust boundary that makes raw RF export unrepresentable. Anti-leakage evaluation (`StrictSplit` by room/day/person/chipset/firmware/layout with an independent disjointness verifier, ECE, selective risk, degradation) plus an end-to-end acceptance pipeline: presence F1 1.00 on held-out rooms *and* held-out chipset, degradation 0.0, ECE 0.012, p95 tokenize+encode 2.0 ms debug / 105 µs release — **all SYNTHETIC** (honest labeling propagates from `RfModality::Synthetic` through `Provenance.synthetic`). Criterion benches with an optimization pass: segment-corridor candidate search took `channel_gain` from 139 µs → 27 µs (O(1) in map size; hash/linear crossover at ~4k Gaussians reported honestly), `observe_link` 305 µs → 74 µs, precomputed DFT twiddles 4.9×. 66 unit + 3 acceptance tests, 0 failed.
|
||||
|
||||
### Changed
|
||||
- **crates.io release batch — 10 of the 12 documented crates republished at their next patch version.** `wifi-densepose-core` 0.3.2, `-vitals` 0.3.2, `-wifiscan` 0.3.2, `-hardware` 0.3.2 (picks up the ADR-273..282 review-fix commit's clippy fixes), `-signal` 0.3.6, `-nn` 0.3.2, `-ruvector` 0.3.3, `-train` 0.3.3, `-mat` 0.3.2, `-wasm` 0.3.1 — all published and verified live on crates.io. **`wifi-densepose-sensing-server` and `wifi-densepose-cli` were bumped locally (0.3.5, 0.3.2) but NOT published**: both now path-depend on `ruview-auth`, which is deliberately `publish = false` and not on crates.io — `cargo publish` correctly refuses to publish a crate with an unversioned/unpublishable path dependency. This is a pre-existing gap (the dependency predates this batch); resolving it is a deliberate call for whoever owns whether `ruview-auth` becomes public, not something to route around silently.
|
||||
- **`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).
|
||||
@@ -48,7 +33,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.
|
||||
|
||||
@@ -25,7 +25,6 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 7 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`/`-viewer`), 72 tests/0 failed; `rufield-viewer` is an Axum + vanilla-JS read-only dashboard (`cargo run -p rufield-viewer`) completing ADR-260 §27.9. The WiFi-CSI modality is now **real-replay-backed** via `CsiReplayAdapter` (ingests real captured `.csi.jsonl` → fused presence/breathing inferences; replay-from-file, unlabeled CSI-variance proxy, not validated accuracy); mmWave/thermal + all synthetic-bench F1 numbers remain **SYNTHETIC** (no live hardware — live streaming + labeled accuracy are roadmap). |
|
||||
| `wifi-densepose-rufield` | ADR-262 P1 **anti-corruption bridge** — converts RuView WiFi-CSI sensing output (`SensingSnapshot` mirroring `SensingUpdate` + `TrustedOutput`, owned primitives, no dep on `wifi-densepose-sensing-server`) into **signed RuField `FieldEvent`s** (`Modality::WifiCsi`, real `timestamp_ns`, sha256 + ed25519 provenance, `synthetic=false`). The single coupling point between RuView and the standalone RuField MFS spec (§5.4); path-deps the `vendor/rufield` submodule crates (`rufield-core`/`-provenance`/`-privacy`/`-fusion`). **Critical §3.3 privacy mapping** (`map_privacy`): maps RuView class → RuField P0–P5 by **information content, never byte value**, fail-closed (`Derived → P4/P5`, never P1; `demoted` floors to ≥ P2). 15 tests / 0 failed (round-trip / `is_fusable` / fusion-ingest / privacy-safety / determinism). P1 plumbing — not wired into the live server (P3), no accuracy claim. |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
|
||||
| `ruview-unified` | ADR-273..282 **unified RF spatial world model**: authoritative native `RfFrameV2` frame contract (native IQ never overwritten, phase-state/evidence-ladder/provenance invariants) with the canonical `RfTensor` as a derived view; fail-closed hardware adapter registry (WiFi CSI / FMCW cube / UWB CIR / 5G SRS / BLE Channel Sounding with phase-vs-RTT cross-validated ranging); universal RF foundation encoder (masked-reconstruction pretraining with finite-difference-verified backprop, `z = Enc ⊙ σ(AgeEnc(log age)) + Geom` fusion, ≤1% scalar / <2% structured task adapters incl. RePos-factorized pose); RF-aware Gaussian spatial memory (fusion/decay/channel-gain queries + inverse updates, lineage receipts, task-gated scene graph); physics-guided synthetic RF world generator (image-method multipath, Fresnel materials, emergent Doppler, seeded domain randomization); edge sensing control plane (802.11bf/ETSI-ISAC purposes/zones/tasks, AoI active-sensing planner, fail-closed coherent-aperture fusion, governed RIS actuation; raw RF structurally unexportable); delay-Doppler-native transforms. Pure Rust leaf; all accuracy numbers SYNTHETIC (evidence level L0) until real-data validation. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
@@ -63,7 +62,7 @@ All 5 ruvector crates integrated in workspace:
|
||||
- `ruvector-attention` → `model.rs` (apply_spatial_attention) + `bvp.rs`
|
||||
|
||||
### Architecture Decisions
|
||||
205 ADRs in `docs/adr/` (numbered ADR-001 through ADR-282, with gaps). Key ones:
|
||||
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). 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)
|
||||
@@ -82,16 +81,6 @@ All 5 ruvector crates integrated in workspace:
|
||||
- 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)
|
||||
- ADR-273: Unified RF spatial world model — umbrella + anti-leakage evaluation protocol + acceptance gates (Accepted — P1 implemented in `ruview-unified`)
|
||||
- ADR-274: Universal RF foundation encoder + hardware adapter registry (Accepted — P1 implemented)
|
||||
- ADR-275: RF-aware Gaussian spatial memory — fusion, decay, channel-gain queries, inverse updates, task-gated scene graph (Accepted — P1 implemented)
|
||||
- ADR-276: Physics-guided synthetic RF world generator — randomize physics, not textures (Accepted — P1 implemented)
|
||||
- ADR-277: Edge sensing control plane — purposes/zones/retention/identity double-gate; raw RF unexportable (Accepted — P1 implemented)
|
||||
- ADR-278: Radar inverse rendering + differentiable RF SLAM research program — RISE/DiffRadar/GeRaF reproduction gates (Proposed)
|
||||
- ADR-279: Native RF frame contract — `RfFrameV2` authoritative, canonical tensor demoted to derived view; 7 invariants; split manifest with session dimension (Accepted — implemented)
|
||||
- ADR-280: Active sensing & programmable perception — sensing tasks/actions, AoI freshness scheduler (95% traffic reduction measured), fail-closed coherent-aperture fusion, governed RIS actuation, task-sufficient representations (Accepted — implemented)
|
||||
- ADR-281: BLE Channel Sounding (phase vs RTT cross-validated ranging), delay-Doppler-native tensors, IEEE P3162 import profile, RePos factorized pose (Accepted — implemented)
|
||||
- ADR-282: Ecosystem positioning — RuView as edge RF perception runtime; RuField/RuVector/MetaHarness layering; mandatory L0–L5 evidence ladder (Accepted)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
@@ -104,8 +93,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)
|
||||
|
||||
@@ -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,12 +617,11 @@ 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) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 182 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 |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4/ArduPilot compatibility, Ruflo AI-agent integration |
|
||||
| `ruview-unified` | Unified RF spatial world model ([ADR-273](docs/adr/ADR-273-unified-rf-spatial-world-model.md)..[277](docs/adr/ADR-277-edge-sensing-control-plane.md)) — canonical RF tensor + hardware adapters (WiFi CSI / FMCW radar / UWB / 5G SRS), universal RF foundation encoder with ≤1% task adapters, RF-aware Gaussian spatial memory with channel-gain queries + inverse updates, physics-guided synthetic RF worlds, and an 802.11bf/ETSI-ISAC-aligned sensing policy plane (raw RF structurally unexportable). All accuracy numbers SYNTHETIC until real-data validation. |
|
||||
| [Medical Examples](examples/medical/README.md) | Contactless blood pressure, heart rate, breathing rate via 60 GHz mmWave radar — $15 hardware, no wearable |
|
||||
| [Extended Documentation](docs/readme-details.md) | Latest additions, key features, installation, quick start, signal processing, training, CLI, testing, deployment, and changelog |
|
||||
|
||||
|
||||
@@ -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,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,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,123 +0,0 @@
|
||||
# ADR-273: Unified RF Spatial World Model — one shared representation, not another isolated RF classifier
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (new v2 workspace crate `ruview-unified`; 66 unit + 3 acceptance-pipeline tests, 0 failed; criterion benches) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `v2/crates/ruview-unified/` (new leaf crate; single internal dep on `wifi-densepose-core` for `CsiFrame`) |
|
||||
| **Sub-ADRs** | ADR-274 (universal RF encoder + adapter registry), ADR-275 (RF-aware Gaussian spatial memory), ADR-276 (physics-guided synthetic RF worlds), ADR-277 (edge sensing control plane), ADR-278 (radar inverse rendering research program) |
|
||||
| **Relates to** | ADR-152 (WiFi-Pose SOTA intake: geometry conditioning), ADR-153 (802.11bf protocol model), ADR-260/262 (RuField MFS + bridge), ADR-135/136 (calibration + canonical frame provenance), ADR-024 (AETHER), ADR-027 (MERIDIAN domain generalization) |
|
||||
| **Scope** | Decide the target architecture for RuView + RuVector sensing through 2026-H2: one persistent, queryable spatial world model that vision, WiFi CSI, cellular CFR/SRS, radar, geometry, semantics, uncertainty, and time all update — and the priority order for building it. |
|
||||
|
||||
---
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Every number in this ADR family is one of:
|
||||
|
||||
- **MEASURED-SYNTHETIC** — produced by this repo's tests/benches on data from the ADR-276 physics generator. Reproducible: `cd v2 && cargo test -p ruview-unified` / `cargo bench -p ruview-unified`. **No claim of real-world accuracy is made or implied.**
|
||||
- **MEASURED-CODE** — a structural property of the implementation (parameter counts, gradient-check error, determinism), verified by a named test.
|
||||
- **EXTERNAL-UNVERIFIED** — a number reported by an external paper/preprint (WiFo-2, WiLHPE, RISE, DiffRadar, HybridSim, OAI SRS demo, …) that this repo has **not** reproduced. These motivated design choices; they are never presented as our results.
|
||||
|
||||
## 1. Context
|
||||
|
||||
Through mid-2026 the field moved decisively away from task-specific RF classifiers:
|
||||
|
||||
1. **RF foundation models** (WiFo-2 scaling across 11.6 B CSI points/12 tasks; WiLLM's dataset adapters + shared self-supervised transformer; age-aware CSI fusion) — the architectural signal: *standardize heterogeneous CSI, pretrain with masked reconstruction, attach small task adapters* (all EXTERNAL-UNVERIFIED).
|
||||
2. **Gaussian fields as spatial memory** (EmbodiedSplat online semantic 3-D Gaussian mapping; TGSFormer bounded temporal Gaussian memory; July's physics-informed channel-gain mapping with incremental Gaussian insertion) — the missing bridge between RuView sensing and a queryable digital twin.
|
||||
3. **Synthetic RF worlds** (WaveVerse phase-coherent ray tracing; HybridSim's 92 % vs 54 % synthetic-to-real gap when *physics parameters*, not textures, are randomized) — the fastest path out of data scarcity.
|
||||
4. **Standards became actionable**: IEEE 802.11bf-2025 published (2025-09), 802.11bk (320 MHz positioning), ETSI ISAC architecture (2026-02) + security report (19 privacy/security issue classes), 3GPP Rel-20 sensing studies, OAI SRS xApp localization demo.
|
||||
5. **Generalization lessons**: PerceptAlign (condition on TX/RX geometry), RePos (factor root-relative pose from absolute localization), JITOMA (task-gated scene memory).
|
||||
|
||||
RuView already has the ingredients (calibration ADR-151, canonical frames ADR-136, ruvsense multistatic stack, RuField bridge ADR-262) but they update **separate** state. The decision is to converge on **one shared representation with persistent scene memory**.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build the unified model as five pillars in strict priority order (scored 35 % business value / 25 % readiness / 20 % defensibility / 20 % strategic learning):
|
||||
|
||||
| # | Pillar | Score | Sub-ADR | P1 status |
|
||||
|---|--------|-------|---------|-----------|
|
||||
| 1 | Universal RF foundation encoder + hardware adapter registry | 4.7 | ADR-274 | **implemented** |
|
||||
| 2 | RF-aware Gaussian spatial memory | 4.5 | ADR-275 | **implemented** |
|
||||
| 3 | Age/geometry/uncertainty-aware inference (folded into the encoder contract) | 4.4 | ADR-274 §3 | **implemented** |
|
||||
| 4 | Physics-guided synthetic RF world generator | 4.1 | ADR-276 | **implemented** |
|
||||
| 5 | Edge sensing control plane (802.11bf / ETSI ISAC aligned) | 3.9* | ADR-277 | **implemented** (policy engine; O-RAN xApp is roadmap) |
|
||||
| 6 | Radar inverse rendering + differentiable RF SLAM | 3.6 | ADR-278 | research program (not implemented) |
|
||||
|
||||
\* the 3.9-scored item is the O-RAN SRS xApp; its *policy plane* and its *SRS adapter seam* ship in P1 because they are cheap and gate everything else.
|
||||
|
||||
The representation contract every pillar shares:
|
||||
|
||||
```text
|
||||
z = Encoder(RF tokens) ⊙ σ(AgeEncoder(age)) + GeometryEncoder(sensor_pose)
|
||||
```
|
||||
|
||||
served from one canonical tensor (`RfTensor`, ADR-274 §2) and persisted into one scene memory (`GaussianMap` + task-gated `SceneGraph`, ADR-275).
|
||||
|
||||
## 3. Architecture (implemented, `v2/crates/ruview-unified/src/`)
|
||||
|
||||
```text
|
||||
vendor captures ──▶ adapters.rs (WiFi CSI / FMCW cube / UWB CIR / 5G SRS)
|
||||
│ normalize: layout → gain → phase (ADR-274 §2.3)
|
||||
▼
|
||||
tensor.rs RfTensor (links × 56 bins × 8 snapshots, complex)
|
||||
│
|
||||
tokenizer.rs amplitude/delay/Doppler/phase/age/geometry/
|
||||
│ clock/uncertainty tokens (CFO-aligned,
|
||||
│ median-scale-normalized)
|
||||
▼
|
||||
encoder.rs + pretrain.rs masked-reconstruction pretraining,
|
||||
│ exact hand-derived backprop (gradient-checked)
|
||||
▼
|
||||
┌── heads.rs ≤1 % task adapters (presence/activity/localization/anomaly)
|
||||
│
|
||||
├── gaussian/ RF-aware Gaussian memory: fusion, decay, channel-gain
|
||||
│ queries, inverse updates, task-gated scene graph
|
||||
│
|
||||
└── policy.rs purposes/zones/retention/identity gating; BoundedEvent
|
||||
is the only exportable type (raw RF unrepresentable)
|
||||
```
|
||||
|
||||
`synth/` (ADR-276) generates the labeled physics worlds that train and gate all of it; `eval.rs` implements the anti-leakage protocol below.
|
||||
|
||||
## 4. The non-negotiable evaluation protocol (anti-leakage)
|
||||
|
||||
The biggest failure mode in this field is **domain leakage disguised as accuracy**: random frame splits let a model recognize the room, session, person, device, or trajectory. Bigger models make it worse. Therefore:
|
||||
|
||||
- **No result counts unless the test set holds out complete** rooms, days, people, chipsets, firmware versions, and antenna layouts. `eval::StrictSplit` constructs such splits and `verify()` independently proves disjointness (`eval.rs`; test `verify_catches_a_manufactured_leak`).
|
||||
- Track **relative degradation** known→unknown (`relative_degradation`, gate < 20 %), **calibration** (`expected_calibration_error`), and **abstention quality** (`selective_metrics` — an uncertain result must become *no decision*, not a confident guess).
|
||||
- Every synthetic number is labeled SYNTHETIC in test output and in these ADRs.
|
||||
|
||||
## 5. Acceptance gates — P1 (synthetic analogue) results
|
||||
|
||||
The ADR's acceptance test (frozen shared encoder, adapters < 1 % of backbone, unseen rooms/chipsets/layouts) is implemented end-to-end in `tests/e2e_acceptance.rs`. **MEASURED-SYNTHETIC** results on the ADR-276 generator (8 rooms × 20 windows × 3 links, seed 273273):
|
||||
|
||||
| Gate (ADR target) | P1 synthetic result | Verdict |
|
||||
|---|---|---|
|
||||
| Presence F1 ≥ 0.90, unseen rooms | **1.0000** (rooms 6–7 held out of pretraining *and* head training) | pass |
|
||||
| Presence F1 ≥ 0.90, unseen chipset | **1.0000** (`chip-2` held out; per-room random gain/phase/CFO/noise) | pass |
|
||||
| Cross-environment degradation < 20 % | **0.0000** | pass |
|
||||
| Adapter budget < 1 % of backbone | presence 129 / activity 268 / localization 387 / anomaly 2 params vs 40,856-param backbone (< 408) | pass (MEASURED-CODE) |
|
||||
| Edge latency p95 < 50 ms | **2.0 ms** debug profile (tokenize+encode); 105 µs encode / 67 µs tokenize release (criterion) | pass |
|
||||
| Held-out ECE | **0.0122**; abstention risk monotone in threshold | pass |
|
||||
| Raw RF never crosses the trust boundary | structural: only `policy::BoundedEvent` exports (no tensor-carrying variant exists) | pass |
|
||||
| Every output carries uncertainty, provenance, model version, purpose | enforced at `BoundedEvent::new` (construction fails otherwise) | pass |
|
||||
|
||||
**Honest reading**: a synthetic world where presence ⇔ a moving scatterer is *separable by construction*; F1 = 1.0 here validates the **pipeline and the anti-leakage machinery**, not real-world performance. The real-data gate (5 unseen rooms, 2 unseen chipsets, 2 unseen layouts, measured CSI) is P2 and remains open.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- RuView gains a single, tested substrate that all future sensing work (vision fusion, SRS xApp, radar) updates instead of forking.
|
||||
- The synthetic-first discipline means every accuracy claim is grade-labeled; publishing an unlabeled number is now a process violation.
|
||||
- The Gaussian memory becomes the integration point for RuVector (vector retrieval → graph constraints → geometric verification; the LLM plans the query, the renderer verifies the answer).
|
||||
- Cost: a new crate to maintain (~4.6 k lines incl. tests); mitigations: zero heavy deps, deterministic tests, files < 500 lines each.
|
||||
|
||||
## 7. Roadmap after P1
|
||||
|
||||
| Phase | Content | Gate |
|
||||
|-------|---------|------|
|
||||
| P2 | Replay real `.csi.jsonl` (rvCSI / ADR-262 corpus) through the WiFi adapter; calibrate the anomaly head on real empty-room captures | strict-split F1/ECE on measured data, reported with degradation vs synthetic |
|
||||
| P3 | Wire `GaussianMap` into `wifi-densepose-sensing-server` behind the ADR-277 boundary; RuVector embedding of Gaussian clusters | live map consistency + bounded-event-only egress audit |
|
||||
| P4 | OAI SRS xApp feeding `CellularSrsAdapter` (the adapter + registry seam already exists) | 0.5 m p90 localization under *non-random* splits |
|
||||
| P5 | ADR-278 radar inverse rendering reproduction (RISE first) |
|
||||
@@ -1,95 +0,0 @@
|
||||
# ADR-274: Universal RF foundation encoder + hardware adapter registry
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified`: `tensor.rs`, `adapters.rs`, `tokenizer.rs`, `encoder.rs`, `pretrain.rs`, `heads.rs`, `eval.rs`) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-136 (`CanonicalFrame` provenance — the WiFi adapter consumes `wifi-densepose-core::CsiFrame` directly), ADR-152 §2 (geometry conditioning intake), ADR-016/017 (ruvector integration points) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades as in ADR-273 §0. Every number below is MEASURED-CODE or MEASURED-SYNTHETIC unless marked EXTERNAL-UNVERIFIED.
|
||||
|
||||
## 1. Context
|
||||
|
||||
WiFo-2 and WiLLM (EXTERNAL-UNVERIFIED) demonstrated that heterogeneous CSI standardization + masked-reconstruction pretraining + small task adapters beats per-task models, and the age-aware CSI line showed a cheap win from encoding sample freshness multiplicatively. RuView has four incompatible capture families today (802.11 CSI, FMCW radar cubes, UWB CIR, and — via O-RAN — 5G SRS). Each previously implied its own model.
|
||||
|
||||
## 2. Decision — canonical tensor + adapter registry
|
||||
|
||||
### 2.1 Canonical tensor
|
||||
|
||||
All modalities normalize to `RfTensor` (`tensor.rs`): complex `(links × 56 bins × 8 snapshots)` plus carrier/bandwidth, per-link `LinkGeometry`, `sample_age_s`, `clock_quality ∈ [0,1]`, `uncertainty ∈ [0,1]`, `device_id`, and a `CalibrationMeta` contract. 56 bins = usable 20 MHz 802.11n subcarriers (and the existing 114→56 interpolation in `wifi-densepose-train`), so the most common source resamples trivially.
|
||||
|
||||
**Boundary rule**: `RfTensor::new` is the only constructor and validates every field (finite samples, geometry/link arity, ranges). Downstream code assumes validity. Tests: `tensor.rs::tests` (4).
|
||||
|
||||
### 2.2 Normalization pipeline (every adapter, 3 stages)
|
||||
|
||||
1. **Layout** — vendor shape → `(links, bins, snapshots)`; FMCW gets a fast-time DFT to range bins; SRS gets comb de-interleaving; then linear complex resampling to canonical dims.
|
||||
2. **Amplitude** — per-link division by median amplitude (chipset gain invariance; offset recorded in `CalibrationMeta.gain_offset_db`).
|
||||
3. **Phase** — per (link, snapshot), remove constant offset + least-squares linear ramp across bins (CFO residual + sampling-time offset), with unwrapping. Skipped for delay-domain modalities (radar range profiles, UWB taps) where a detrend would erase ToF structure.
|
||||
|
||||
Measured (test `wifi_adapter_normalizes_shape_gain_and_phase`): a synthetic capture with per-link gains ×3.7/×7.4 and phase ramp `0.9 + 0.11·bin` comes out with median amplitude 1.0 ± 1e-9 and residual phase < 1e-4 rad (the ~7 µrad residue is second-order chord-vs-arc error from complex resampling). The radar adapter localizes a fast-time beat tone to the analytically expected canonical range bin (`radar_adapter_localizes_beat_tone_to_range_bin`).
|
||||
|
||||
### 2.3 Registry
|
||||
|
||||
`AdapterRegistry` maps hardware id → `dyn RfAdapter`, **fail-closed** (unknown hardware is an error; wrong modality is a typed `ModalityMismatch`). Reference adapters ship for `esp32s3-csi`, `mr60bha2` (FMCW), `dw3000` (UWB), `oai-srs-xapp` (5G SRS) — the last being the ADR-273 P4 seam.
|
||||
|
||||
## 3. Decision — encoder, fusion contract, adapters
|
||||
|
||||
### 3.1 Tokenizer
|
||||
|
||||
One token per (link, 8-bin subcarrier group); 24 features: log-amplitudes, delay-spectrum DFT (4), Doppler DFT bins 1–4 (log-compressed `ln(1+100·mag)`), temporal amplitude deviation (`ln(1+20·std)`), phase velocity, sample age, link distance/height/azimuth, clock quality, uncertainty (`tokenizer.rs`, layout table on `RfToken`).
|
||||
|
||||
Two hardware-invariance steps precede feature extraction, and both were *forced by measurement*, not aesthetics (see §5 evidence trail):
|
||||
|
||||
- **window-median amplitude normalization** — raw Friis-scale features (~1e-3) left every head unable to learn;
|
||||
- **CFO alignment** — per link, each snapshot is de-rotated by `arg Σ_b H[b,s]·H̄[b,0]`; carrier-frequency-offset drift is a *common* rotation and cancels, while a moving scatterer's frequency-selective perturbation survives (test `motion_raises_doppler_and_variance_features` uses a bin-dependent perturbation precisely so alignment cannot cancel it).
|
||||
|
||||
### 3.2 Encoder + pretraining
|
||||
|
||||
Pure-Rust, exactly differentiable (`encoder.rs`):
|
||||
|
||||
```text
|
||||
h_i = tanh(W1·x_i + b1) token embedding
|
||||
c = mean_i h_i permutation-invariant pool
|
||||
m = tanh(W2·c + b2); g = tanh(W2b·m + b2b)
|
||||
gate = σ(age_w·age + age_b) multiplicative freshness gate
|
||||
z = g ⊙ gate + Wg·geo + bg ← the ADR-273 fusion contract, verbatim
|
||||
```
|
||||
|
||||
Masked-reconstruction pretraining (`pretrain.rs`): mask 25 % of tokens, reconstruct each from `[z ; sinusoidal-position]` via a linear head discarded at deployment; SGD.
|
||||
|
||||
**Proof of the backward pass** (MEASURED-CODE, `gradients_match_finite_differences`): analytic gradients of **all 12 parameter groups** vs central finite differences — 174 sampled parameters, max relative error **1.31e-5**, with the absolute floor at central-difference roundoff (≈5e-11). Training halves masked loss and beats the constant-predictor variance baseline (`0.2757 → 0.0966` vs baseline `0.1550`; `pretraining_reduces_masked_loss_and_beats_mean_baseline`). Same seed ⇒ bit-identical weights (`training_is_deterministic`).
|
||||
|
||||
Backbone at deployment config (d_model 128): **40,856 parameters** (hand-count asserted in `param_count_matches_hand_computation`).
|
||||
|
||||
### 3.3 Two representation views (the PerceptAlign lesson, applied)
|
||||
|
||||
- `encode()` → full `z` (geometry-conditioned) — for localization/channel-prediction heads where sensor pose is signal.
|
||||
- `encode_content()` → `[g ⊙ gate ; mean token features]` — for environment-invariant heads (presence/activity/anomaly). The additive `Wg·geo` term is a **room-specific offset a linear adapter would memorize** — measured: with it, held-out-room presence F1 was 0.00 while training F1 fit; without it plus the pooled-statistics skip connection, held-out F1 is 1.00 (SYNTHETIC, ADR-273 §5).
|
||||
|
||||
### 3.4 Task adapters, ≤ 1 % budget
|
||||
|
||||
`heads.rs`: presence (logistic, 129 params), activity (rank-2 LoRA-style factorized softmax, 268), localization (linear ℝ³, 387), anomaly (2 calibration statistics on reconstruction error). All < 408 = 1 % of the 40,856-param backbone, asserted in `every_head_fits_the_one_percent_budget_at_deployment_config`. Convex heads train full-batch (deterministic); tests show they fit separable/multiclass toys to ≥ 95 %.
|
||||
|
||||
### 3.5 Anti-leakage evaluation (ADR-273 §4)
|
||||
|
||||
`eval.rs`: `PartitionKey` (room/day/person/chipset/firmware/layout), `StrictSplit::holdout` + independent `verify()`, ECE, coverage/selective-risk, degradation ratio, F1. Six unit tests including a manufactured-leak detection test.
|
||||
|
||||
## 4. Alternatives considered
|
||||
|
||||
- **Candle/ONNX backbone now** — rejected for P1: the deliverable is a *proven contract* (gradient-checked fusion formula, budget enforcement, leakage protocol); porting to `wifi-densepose-nn` backends is mechanical once real-data P2 justifies scale.
|
||||
- **Per-modality encoders with late fusion** — rejected: reproduces the isolated-classifier status quo ADR-273 exists to end.
|
||||
- **Full transformer attention** — deferred: mean-pool + 2 mixing layers passed every P1 gate; attention is a P2 measurement question, not a default.
|
||||
|
||||
## 5. Evidence trail (what the measurements changed)
|
||||
|
||||
P1 development falsified two comfortable assumptions, recorded here because the *fixes are the ADR*:
|
||||
|
||||
1. Raw-scale tokens: presence head stuck at F1 0.47 even on training rooms → window-median normalization + CFO alignment (train F1 → 0.76).
|
||||
2. Geometry-additive `z` for invariant tasks: held-out-room F1 0.00 → content view + pooled-statistic skip (held-out F1 → 1.00) — i.e. *the leak the eval protocol was designed to catch, caught in our own architecture first*.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
One encoder now serves presence, activity, localization, respiration-class, channel prediction, and anomaly through < 1 % adapters; new hardware lands as an adapter, not a model. Cost: the pure-Rust trainer is CPU-bound (fine at 40 k params; a P2 scale-up moves to `wifi-densepose-nn`).
|
||||
@@ -1,79 +0,0 @@
|
||||
# ADR-275: RF-aware Gaussian spatial memory — the persistent scene representation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/gaussian/`: `primitive.rs`, `map.rs`, `gain.rs`, `graph.rs`; 16 unit tests, criterion benches) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-030 (persistent field model — superseded in direction by this), ADR-134 (CIR/ISTA), ADR-147 (OccWorld priors), ADR-261 (RuVector graph-ANN — the retrieval layer this memory will index into) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. The July 2026 external motivators (EmbodiedSplat ~5 fps online semantic Gaussian mapping, ~67× memory efficiency; TGSFormer bounded temporal Gaussian memory; physics-informed channel-gain mapping with incremental Gaussian insertion; JITOMA task-gated activation) are EXTERNAL-UNVERIFIED throughout.
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView's spatial state is currently scattered (pose tracker state, field-model eigenstructure, worldgraph tracks). Vision-side SOTA converged on Gaussian fields as the common continuous scene memory, and — the July signal that matters here — the representation crossed into RF: propagation geometry, opacity, attenuation, and scattering as Gaussian primitives, updated *incrementally* when the environment changes. That is exactly the bridge from RuView sensing to a queryable digital twin: one store that answers both geometric questions ("what is near the sofa") and RF questions ("which object caused the channel anomaly", "where did multipath change").
|
||||
|
||||
## 2. Decision — the primitive
|
||||
|
||||
`RfGaussian` (`primitive.rs`) carries all six ADR-273 attribute groups:
|
||||
|
||||
1. **Geometry**: position, per-axis scale (σ), unit-quaternion orientation → anisotropic metric `Σ⁻¹ = R·diag(1/σ²)·Rᵀ`.
|
||||
2. **Semantics**: 16-d embedding (RuVector-alignable).
|
||||
3. **RF response**: reflectivity `[4 bands × 4 incident-angle bins]` (2.4/5/6/60 GHz), plus `occupancy` = peak extinction coefficient (nepers/m) used by the gain model.
|
||||
4. **Motion**: signed Doppler m/s + `{Static, Slow, Fast}` class.
|
||||
5. **Trust/lifecycle**: confidence ∈ [0,1], timestamp, decay τ, `Provenance {device, model_version, synthetic}`.
|
||||
6. **Links**: typed references into the scene graph / RuVector entities.
|
||||
|
||||
Validated constructor (quaternion normalized, ranges checked); anisotropy and rotation are proven behaviorally (thin axis decays ≥ 80× faster at 0.3 m — the analytic ratio is 86; a 90° quaternion rotates the metric with it).
|
||||
|
||||
## 3. Decision — the map
|
||||
|
||||
`GaussianMap` (`map.rs`): spatial-hash grid (1 m default pitch) over a flat store.
|
||||
|
||||
- **Fusion, not accumulation**: an insert within Mahalanobis² 9 of a same-entity-kind Gaussian merges — confidence-weighted position/scale/occupancy/semantics/reflectivity/Doppler, noisy-OR confidence (`c₁+c₂−c₁c₂`), newest provenance wins, links union. Test: two 0.5-confidence observations 0.1 m apart fuse to one Gaussian at the weighted midpoint with confidence 0.75.
|
||||
- **Decay + static persistence** (update-loop step 7): exponential confidence decay per Gaussian τ, **stretched by observed lifetime** — `τ_eff = τ·(1 + ln(1 + lifetime/τ))` with `lifetime = last_seen − first_seen` — so a wall confirmed over 30 min outlives a once-seen transient at equal nominal τ (test `long_lived_structure_outlives_transients_at_equal_tau`); prune below 0.02; deterministic (replay test).
|
||||
- **Merge pass** (update-loop step 5): `merge_overlapping` collapses pairs that are *mutually* inside each other's Mahalanobis gate **and** semantically compatible (cosine ≥ 0.7, or both unlabeled) — orthogonal-semantic overlaps stay separate (test `merge_pass_collapses_mutual_overlaps_but_respects_semantics`). This catches drift the insert-time gate (±1 cell neighborhood only) misses.
|
||||
- **Queries**: radius (hash + linear reference impl, equivalence-tested on 100-Gaussian grids), kNN (expanding ring), semantic cosine top-k, and the segment-corridor query below.
|
||||
|
||||
## 4. Decision — channel gain as a first-class query + inverse update
|
||||
|
||||
`gain.rs` implements the RF query surface:
|
||||
|
||||
```text
|
||||
H(tx,rx,f) = (λ/4πd)·e^{-j2πd/λ} · exp(−Σ_g occ_g·I_g)
|
||||
```
|
||||
|
||||
with `I_g` the **closed-form** line integral of each Gaussian's density along the TX→RX segment (1-D Gaussian integral via erf; derivation in the module doc).
|
||||
|
||||
**Exactness anchors (MEASURED-CODE):**
|
||||
|
||||
- Empty map ⇒ **exact Friis** amplitude (< 1e-15) and propagation phase (`empty_map_returns_exact_friis`).
|
||||
- Closed-form line integral matches 1 mm trapezoid quadrature through a rotated anisotropic Gaussian to < 1e-6 (`line_integral_matches_numeric_quadrature`).
|
||||
- On-path absorber attenuates strictly monotonically in occupancy; a 10σ off-path absorber changes LoS gain < 1e-6 dB.
|
||||
|
||||
**Inverse update** (`observe_link`) — the incremental-mapping move: measured link amplitude → target optical depth `τ* = ln(friis/measured)`; a projected-gradient step distributes the residual over intersected Gaussians proportional to their path integrals (exact Newton along the link at lr = 1), clamped at occupancy ≥ 0; if nothing intersects and attenuation is demanded, a compact absorber is spawned at the midpoint sized to close the residual. **Measured**: from an empty map, 20 observations of a link with an unseen 0.7-neper (≈6.1 dB) obstruction converge to < 0.06 neper residual and < 0.5 dB prediction error (`inverse_update_learns_a_wall_from_link_residuals`).
|
||||
|
||||
## 5. Decision — task-gated scene graph
|
||||
|
||||
`graph.rs`: sparse typed nodes (`Object/Room/PersonClass/Device/Event` — person *classes* only; identity lives behind ADR-277's double gate) and relations (`Contains/Near/CausedBy/ObservedBy`). The only sanctioned read is `activate(relevant_kinds, seeds, max_nodes)` — bounded BFS that reports truncation instead of silently scanning (the JITOMA lesson). Tests: an "which object caused the anomaly" activation pulls exactly {event, object, room} and gates out devices/person-classes; the node budget is enforced and truncation is flagged.
|
||||
|
||||
## 6. Performance (criterion, release, this machine)
|
||||
|
||||
| Benchmark | Result | Note |
|
||||
|---|---|---|
|
||||
| `channel_gain`, 1 k Gaussians | **26.9 µs** | was 139 µs with the midpoint-ball candidate query |
|
||||
| `channel_gain`, 16 k Gaussians | **27.7 µs** | ~O(1) in map size after the corridor rewrite |
|
||||
| segment corridor query, hash vs linear | 24 µs vs 6 µs (1 k) / 24 µs vs **163 µs** (16 k) | crossover ≈ 4 k Gaussians — reported honestly; both paths kept + equivalence-tested |
|
||||
| radius query, hash vs linear | 4.3 µs vs 101 µs @ 16 k (23×) | hash loses at 1 k (4.0 vs 1.9 µs) — small maps are brute-force territory |
|
||||
| `observe_link` inverse update | **74 µs** | was 305 µs pre-optimization |
|
||||
| map insert+fuse (64 Gaussians, in observe bench setup) | included above | |
|
||||
|
||||
The optimization pass replaced a midpoint-ball candidate search (`(2·(L/2+3)+1)³ ≈ 9,300` cell lookups on a 14 m link) with an AABB sweep prefiltered by cell-centre-to-segment distance (bound `margin + √3/2·cell`), after a first corridor attempt (per-sample cube inserts into a BTreeSet) measured *worse* (1.2 ms) and was discarded — kept in this record as the honest negative result.
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
- The map answers "where is a person likely", "where did multipath change", and "which object caused a channel anomaly" (gain residual → `CausedBy` edge) from one store.
|
||||
- RuVector integration (ADR-261) becomes: vector search retrieves candidate Gaussians/nodes → graph traversal enforces relations → the gain model *verifies* answers against geometry. The LLM plans the query; it never invents the spatial answer.
|
||||
- Not yet done (P3): live wiring into `wifi-densepose-sensing-server`, visual/depth Gaussian ingestion, and RuVector index sync.
|
||||
@@ -1,68 +0,0 @@
|
||||
# ADR-276: Physics-guided synthetic RF world generator — randomize physics, not textures
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/synth/`: `room.rs`, `raytrace.rs`, `generator.rs`; 10 unit tests + the ADR-273 acceptance pipeline consumes it end-to-end) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-015 (MM-Fi/Wi-Pose datasets), ADR-089 (nvsim — the determinism pattern this follows), ADR-135 (empty-room baselines the generator can emulate) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. WaveVerse (released simulator, phase-coherent ray tracing) and HybridSim (92.07 % vs 54.22 % synthetic-only→real activity recognition when physics is modeled explicitly) are EXTERNAL-UNVERIFIED motivators. Every output of this generator is stamped `RfModality::Synthetic` and every number derived from it is labeled SYNTHETIC — that stamp survives into `Provenance.synthetic` at the ADR-277 export boundary.
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView's scarcest resource is labeled, *diverse* RF data: rooms, materials, antenna placements, people, chipsets. The 2026 evidence says synthetic RF transfers **when the physics is explicit and the randomization hits physical parameters** (permittivity, geometry, kinematics, hardware nuisances) rather than cosmetic noise. A physics generator also gives the ADR-273 acceptance machinery something it can never get from captures alone: *ground truth by construction* and unlimited strict-split diversity.
|
||||
|
||||
## 2. Decision — physics core
|
||||
|
||||
### 2.1 Rooms and materials (`room.rs`)
|
||||
|
||||
Shoebox rooms `[0,Lx]×[0,Ly]×[0,Lz]`, one wall material with **complex permittivity** `ε = ε_r − j·σ/(ωε₀)` and normal-incidence Fresnel reflection `Γ = (1−√ε)/(1+√ε)`. Presets (concrete/drywall/glass, ITU-R P.2040 ballpark) plus a perfect absorber for test isolation. Measured sanity: concrete at 2.4 GHz gives |Γ| ≈ 0.39–0.45 with phase inversion; |Γ| < 1 for all passive presets; ε_r = 1, σ = 0 gives Γ = 0 exactly. People are validated-in-room point scatterers with constant velocity and RCS.
|
||||
|
||||
### 2.2 Multipath (`raytrace.rs`)
|
||||
|
||||
Allen–Berkley image method, reflection order ≤ 2 (per-axis images `±x + 2nL`, bounce count `|2n|` / `|2n−1|`), plus single-bounce bistatic person scattering with amplitude `√(σ_rcs/4π)/(d₁·d₂)` (bistatic radar equation, amplitude form):
|
||||
|
||||
```text
|
||||
H(f) = Σ_paths Γ^order · (c/f)/(4π) · s_p · e^{−j2πf·d_p/c}
|
||||
```
|
||||
|
||||
**Doppler is never injected** — it emerges from the person's path length changing between snapshots.
|
||||
|
||||
**Physics gates (MEASURED-CODE):**
|
||||
|
||||
| Gate | Test | Result |
|
||||
|---|---|---|
|
||||
| Direct path ≡ Friis | `direct_path_is_exact_friis` | < 1e-15 per subcarrier (absorber walls) |
|
||||
| Reciprocity `H(a→b) = H(b→a)` | `channel_is_reciprocal` | < 1e-12, with person + concrete walls |
|
||||
| Image geometry | `first_order_reflection_matches_mirror_geometry` | floor/ceiling bounce at exactly the mirror distance; 1 direct + 6 first-order + second-order set |
|
||||
| Doppler | `moving_person_produces_the_analytic_doppler_phase_rate` | residual-phase rotation matches `−2πf·Δd/c` to < 1e-6 rad across 4 steps |
|
||||
|
||||
## 3. Decision — domain randomization (`generator.rs`)
|
||||
|
||||
Per room, seeded ChaCha20 (nvsim discipline — same seed ⇒ byte-identical corpus, cross-machine):
|
||||
|
||||
- **Physics**: dimensions 4–10 × 3–8 × 2.4–3.2 m; ε_r ∈ [2,7], σ ∈ [0.002,0.1] S/m; random TX/RX placements; person start/heading/speed/RCS.
|
||||
- **Hardware nuisances** (what breaks naive models in the field): per-room gain ×0.5–2, static phase offset, **CFO drift** ±0.3 rad/snapshot, thermal noise, 5 % packet loss (snapshot re-delivery), 3 % wideband interference bursts.
|
||||
- **Provenance for strict splits**: every window carries a full `PartitionKey` (room/day/person/chipset/firmware/layout) so ADR-273 §4 holdouts exist by construction.
|
||||
|
||||
Measured: byte-determinism per seed (and divergence across seeds); presence windows carry > 5× the temporal amplitude variance of empty windows (actual measured ratio on the test corpus is far higher); labels/keys complete.
|
||||
|
||||
The CFO nuisance earned its keep immediately: it *defeated the first tokenizer* (empty rooms looked like motion) and forced the CFO-alignment step now documented in ADR-274 §3.1 — exactly the class of failure a physics-parameter randomizer exists to surface before real deployments do.
|
||||
|
||||
## 4. What this generator is NOT
|
||||
|
||||
- Not a WaveVerse replacement: order-2 specular + point scatterers, no diffraction, no diffuse scattering, no angle-dependent Fresnel, no antenna patterns. These are refinements to add *when a P2 real-data gap analysis demands them*, not before.
|
||||
- Not evidence of real-world accuracy: the ADR-273 acceptance numbers on this data validate the pipeline; the synthetic→real transfer claim (HybridSim-style) is untested here and stays EXTERNAL-UNVERIFIED until P2 replay experiments.
|
||||
|
||||
## 5. Performance
|
||||
|
||||
Criterion (release): 1 room × 4 windows × 3 links generates in **3.1 ms** (≈ 260 µs/window) — corpus generation is never the bottleneck; the 8-room acceptance corpus builds in well under a second even in debug.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- Every pipeline stage gains a deterministic, physics-proven test bed; regressions in adapters/tokenizer/encoder now fail loudly against ground truth.
|
||||
- Data scarcity stops gating architecture work: strict-split experiments (rooms/chipsets/layouts) run in CI.
|
||||
- The honest-labeling chain (`RfModality::Synthetic` → `Provenance.synthetic` → SYNTHETIC-graded ADR claims) is structural, not editorial.
|
||||
@@ -1,61 +0,0 @@
|
||||
# ADR-277: Edge sensing control plane — purposes, zones, retention, and a trust boundary raw RF cannot cross
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/policy.rs`; 5 unit tests + the acceptance-pipeline export test) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-153 (802.11bf protocol model), ADR-141/120 (BFLD privacy control plane + privacy classes), ADR-262 §3.3 (RuField P0–P5 fail-closed mapping — the same philosophy, applied to sensing outputs), ADR-032 (mesh security hardening) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. Standards status (EXTERNAL, checkable): IEEE 802.11bf-2025 published 2025-09; IEEE 802.11bk addresses ≤ 320 MHz positioning; ETSI published an ISAC architecture 2026-02 (monostatic/bistatic/multistatic/network/device sensing) followed by a security report identifying **19 privacy and security issue classes**; 3GPP Release 20 sensing studies are active. The OpenAirInterface SRS-xApp demo (0.12 m MAE under a **random** split) is EXTERNAL-UNVERIFIED and its split methodology is exactly the leakage ADR-273 §4 rejects — we cite the *implementation path*, not the number.
|
||||
|
||||
## 1. Context
|
||||
|
||||
Sensing purposes and sensing zones are becoming first-class authorization objects in the standards (802.11bf sensing sessions; ETSI ISAC purposes/exposure). Meanwhile the ETSI security report's issue classes make one thing clear: a sensing stack without a policy plane is a liability. RuView already fails closed at other boundaries (ADR-262 §3.3 maps privacy by information content, never byte value); this ADR gives sensing *outputs* the same discipline, on-device, before any transport.
|
||||
|
||||
## 2. Decision — three structural rules
|
||||
|
||||
### 2.1 Raw RF never leaves the trust boundary
|
||||
|
||||
The only exportable type is `BoundedEvent` — typed verdicts only (`Presence(bool)`, `ActivityClass(u8)`, `RespirationBpm(f64)`, `Location([f64;3])`, `AnomalyScore(f64)`). **No variant can carry RF samples, so raw CSI/radar export is unrepresentable, not merely forbidden**; `TrustBoundary::export` is the single egress and there is deliberately no API that serializes an `RfTensor` outward. External systems receive bounded events + uncertainty, never signal history.
|
||||
|
||||
### 2.2 Fail closed, everywhere
|
||||
|
||||
`PolicyEngine::authorize`: unknown zone ⇒ deny; purpose not granted in the zone ⇒ deny; **identity recognition is double-gated** — it must be in the zone's `allowed_purposes` *and* the zone must set `identity_explicitly_enabled` (either alone denies). Retention: an event older than the zone's `retention_s` at export time is dropped with a typed `PolicyDenied`. Tests cover every branch, including the manufactured cases (identity granted-but-not-enabled; enabled-but-not-granted; stale event).
|
||||
|
||||
### 2.3 Every output is accountable (ADR-273 acceptance item 8)
|
||||
|
||||
`BoundedEvent::new` is the only constructor and *fails* without: uncertainty ∈ [0,1], provenance (device + `synthetic` flag — the ADR-276 honest label survives export), a non-zero model version, timestamp, purpose, and zone. The acceptance test (`outputs_leave_only_through_the_policy_boundary_fully_attributed`) runs the full pipeline — synthetic world → encoder → presence head → event → export — and asserts the attribution and the denial of an ungranted purpose on the same zone.
|
||||
|
||||
## 3. Purpose taxonomy
|
||||
|
||||
`SensingPurpose`: `Presence, Activity, Vitals, Localization, PoseTracking, IdentityRecognition, ChannelDiagnostics` — deliberately aligned with the ETSI ISAC sensing-service classes and WLAN-sensing use cases so a future 802.11bf sensing-session negotiation or ISAC exposure API maps 1:1 onto zone grants. Person *identity* is additionally kept out of the ADR-275 scene graph by type (`EntityKind::PersonClass`, never a person id) — the graph cannot leak what it cannot store.
|
||||
|
||||
## 4. O-RAN / cellular path (roadmap, seams shipped)
|
||||
|
||||
The P1 control plane is transport-agnostic and already fronts the cellular seam:
|
||||
|
||||
- `CellularSrsAdapter` (`oai-srs-xapp`, ADR-274 §2.3) normalizes comb-sampled SRS frequency responses into the canonical tensor — the data-plane contract an OAI xApp needs.
|
||||
- P4 (ADR-273 §7) places the sensing application beside the DU for sub-ms I/Q–CSI–SRS access, with the xApp performing wider-area fusion; **every output of that path still exits through this ADR's `TrustBoundary`**, and its localization claims will be reported only under strict splits (the OAI demo's random split is the cautionary example, not the target).
|
||||
|
||||
## 5. Alternatives considered
|
||||
|
||||
- **Reuse BFLD's privacy classes directly** — rejected: BFLD (ADR-120) classifies *captures*; this plane authorizes *outputs by purpose and zone*. They compose (a BFLD-classified capture feeding a head still exits through `TrustBoundary`), and ADR-262's `map_privacy` remains the capture-side mapping.
|
||||
- **Config-file allow-lists without types** — rejected: the 19 ETSI issue classes are mostly "the code path existed" failures; unrepresentability beats configuration.
|
||||
|
||||
## 5.5 Boundary hardening (property-tested)
|
||||
|
||||
`tests/security_boundaries.rs` drives every validated constructor and every authorization gate with `proptest` over arbitrary values — including NaN/±inf smuggled via `f64::from_bits` — and asserts the *contract* (valid object **or** typed error, never a panic, never a permissive default). Three real defects surfaced and were fixed, all input-controlled denial-of-service or NaN-propagation:
|
||||
|
||||
1. `ble_cs_range` unwrap looped forever on a **non-finite** phase (`+inf − x = +inf`); a **finite-but-huge** phase (1e300 rad) made the same loop run ~1e299 iterations. Fixed by rejecting implausible phases (> 1e6 rad) and replacing the loop-based unwrap with O(1) modular arithmetic.
|
||||
2. A **subnormal** Gaussian scale (5e-324) passed `> 0` but overflowed `1/σ²` to ∞, making the density at the primitive's own centre NaN. Fixed with physical plausibility bounds (σ ∈ [1e-6, 1e4] m, occupancy ∈ [0, 1e6] nepers/m).
|
||||
|
||||
The eight properties now proven: tensor/Gaussian/BoundedEvent constructors never panic; `ble_cs_range` never panics and yields only finite non-negative distances; the policy engine is fail-closed for every (purpose, grants, zone) triple; raw export is unreachable for every task configuration; coherent fusion rejects every non-finite or out-of-bounds sync state; occupancy representations can never retain identity.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- Enterprise/telecom conversations get a concrete artifact: a privacy manifest is a serialization of zones + purposes + retention (all types already `serde`).
|
||||
- Every future surface (sensing-server WS, RuField bridge, SRS xApp, MCP tools) must route sensing outputs through `TrustBoundary` — added to the pre-merge security-review checklist item 12.
|
||||
- Cost: purposes are coarse (no per-consumer grants yet); P3 adds consumer identity when the sensing-server wiring lands.
|
||||
@@ -1,46 +0,0 @@
|
||||
# ADR-278: Radar inverse rendering + differentiable RF SLAM — a gated research program, not a dependency
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — research program (deliberately **no code in P1**) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 (pillar 6, score 3.6 — highest strategic value, highest hardware + reproduction risk) |
|
||||
| **Relates to** | ADR-275 (the Gaussian memory these methods would write into), ADR-263/264 (RTL8720F radar platform + wire protocol), ADR-021 (mmWave vitals hardware), ADR-276 (synthetic worlds as the reproduction sandbox) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Everything numeric in this ADR is **EXTERNAL-UNVERIFIED** — reported by fresh papers/preprints that this repo has not reproduced. That is the point of this ADR: to fix the reproduction gates *before* any of these numbers are allowed to influence the roadmap as if they were ours.
|
||||
|
||||
## 1. Context — what the field reports (July 2026)
|
||||
|
||||
| System | Claim (theirs) | Availability | Risk read |
|
||||
|---|---|---|---|
|
||||
| **RISE** | Single static mmWave radar + multipath inversion → joint room layout + furniture; 16 cm scene Chamfer (baseline 40 cm), 58 % furniture IoU | code available | most reproducible; static sensor matches our appliance posture |
|
||||
| **DiffRadar** | Radar SLAM + Gaussian fields + differentiable rendering; 0.129 m vs 0.823 m ATE, 94.78 % vs 42.59 % map consistency, 70 fps, 40 MB maps | fresh preprint | treat as **reproduction target, not component** — numbers are single-team, single-venue |
|
||||
| **GeRaF** | Differentiable RF renderer + SDF + reflectivity, near-range reconstruction; ~32 h on one H100 for 50 k iterations | published setup | offline calibration / digital-twin tool only; unsuitable for continuous adaptation |
|
||||
|
||||
The strategic pull is real: all three converge on *inverse rendering into continuous scene representations* — exactly the ADR-275 memory. The risks are equally real: single-source numbers, mmWave hardware variance, and compute profiles (GeRaF) incompatible with edge deployment.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
1. **No production dependency** on any of these systems or their claims. ADR-275's gain model + inverse update is the only RF-inverse machinery in the deployment path.
|
||||
2. **Reproduction order: RISE → DiffRadar → GeRaF-lite**, each on one controlled test site, each gated (§3) before the next starts. RISE first because a static radar matches the RuView appliance posture and its inversion writes naturally into `RfGaussian` (occupancy + reflectivity fields already exist for it).
|
||||
3. **Sandbox-first**: before hardware, each method's core inversion is exercised against ADR-276 synthetic worlds extended with a radar-cube output mode (the `FmcwRadarCube` adapter already normalizes such cubes), so failures separate into "our reimplementation" vs "their claim" cleanly.
|
||||
4. **Integration contract**: any reproduced system emits into `GaussianMap` via the existing primitive — no parallel scene store. SLAM trajectories, if any, become `Provenance`-stamped map updates subject to ADR-277 export rules like everything else.
|
||||
|
||||
## 3. Gates (each phase passes all or the program pauses)
|
||||
|
||||
| Gate | Threshold | Split discipline |
|
||||
|---|---|---|
|
||||
| G1 RISE-repro (synthetic) | layout Chamfer within 2× of paper's on our synthetic rooms | held-out room geometries |
|
||||
| G2 RISE-repro (one real site) | qualitative layout recovery + quantified Chamfer vs measured floor plan; report *our* number, whatever it is | site never used in tuning |
|
||||
| G3 DiffRadar-repro | ATE and map consistency on our trajectory rig; publish the delta vs paper | held-out trajectories |
|
||||
| G4 Edge viability | inversion or map-update loop ≤ 50 ms p95 on target hardware, or explicit reclassification as offline-calibration tooling (GeRaF's honest category) | — |
|
||||
|
||||
A gate failure is a *result*, recorded in this ADR's log — the program exists to convert EXTERNAL-UNVERIFIED into MEASURED, in either direction.
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
- The roadmap cannot silently absorb preprint numbers; anything radar-inverse must pass through §3.
|
||||
- ADR-275's primitive already reserves the fields (per-band × angle reflectivity, occupancy, motion) these methods need, so a successful reproduction integrates without schema churn.
|
||||
- Cost of delay is accepted: pillar 6 scored lowest on readiness, and P1–P4 (encoder, memory, synth, control plane, SRS) do not depend on it.
|
||||
@@ -1,54 +0,0 @@
|
||||
# ADR-279: Native RF frame contract — `RfFrameV2` is authoritative, the canonical tensor is a derived view
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (`ruview-unified/src/frame.rs`; 5 invariant tests) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 (amends ADR-274 §2) |
|
||||
| **Relates to** | ADR-136 (`CanonicalFrame` — extended, not replaced), ADR-262 (provenance discipline), ADR-282 (evidence ladder policy) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. This ADR is a **correction** to ADR-274 §2, adopted before any measured-data debt accumulates.
|
||||
|
||||
## 1. Context — the architectural correction
|
||||
|
||||
ADR-274 made the 56-bin × 8-snapshot canonical `RfTensor` the adapter output, which is right for *compatibility* but wrong as the *authoritative* format: resampling every device into one fixed tensor discards bandwidth (a 320 MHz 802.11bk capture and a 20 MHz 802.11n capture become indistinguishable), antenna structure, phase state, and hardware-specific information a foundation encoder should learn from (the WiLLM lesson: lightweight per-device adapters into a shared *latent*, not a shared *tensor*). RuView's own history proves the cost of premature canonicalization — MERIDIAN's normalizer is useful precisely because the native data was still around.
|
||||
|
||||
## 2. Decision — `RfFrameV2`
|
||||
|
||||
The authoritative record preserves the native capture. Fields per the implementation: schema version, frame id, timestamp, modality (now including `WifiCir`, `WifiBfReport`, `FmcwRangeAzimuth`, `FmcwDopplerAzimuth` alongside CSI/SRS/FMCW/UWB/BLE-CS), **declared native axes** (`FieldAxis`: time/frequency/delay/Doppler/range/azimuth/elevation/antenna/polarization), centre frequency, bandwidth, sample rate, arbitrary-rank `native_shape` + `native_iq` + explicit `valid_mask`, TX/RX `Pose3` in one building frame, `AntennaElement` geometry, `sample_age_ns`, `CalibrationState` with a **declared `PhaseState`** (`Raw | Sanitized | Calibrated | Unavailable`), `SignalQuality`, and `FrameProvenance`.
|
||||
|
||||
Seven required invariants, each enforced in the validated constructor or proven by a test:
|
||||
|
||||
1. **Native samples are never overwritten** — `to_canonical(&self)` is read-only; `canonical_view_is_derived_and_native_is_untouched` asserts byte-identical native IQ + mask after derivation.
|
||||
2. Subcarrier/antenna masks are explicit (`valid_mask`, arity-checked).
|
||||
3. Phase declares its state — consumers branch on `PhaseState` instead of guessing whether detrending happened.
|
||||
4. TX/RX geometry uses one building coordinate system (`Pose3`).
|
||||
5. Results retain source identity via `receipt_id` (consumed by the Gaussian memory's `source_receipts` lineage, ADR-275).
|
||||
6. **Synthetic and measured frames can never share a provenance class**, strengthened to an evidence rule: `Synthetic ⇒ exactly L0Simulation`, `Measured ⇒ ≥ L1CapturedReplay` — both directions rejected at construction (`synthetic_and_measured_provenance_can_never_alias`).
|
||||
7. Sample age is carried through the whole path (frame → tensor → age gate → `BoundedEvent`).
|
||||
|
||||
## 3. The canonical tensor is demoted to a compatibility view
|
||||
|
||||
`RfFrameV2::to_canonical()` derives the ADR-274 tensor **through the exact same normalization code path as every adapter** (`adapters::normalize_grid` — one normalization, many entry points), after mask-aware gap-filling (invalid bins interpolated from nearest valid neighbors on the complex plane). Rank ≠ 3 frames have no canonical projection and say so with a typed error. The existing ESP32/Intel/Atheros 114→56 projections stay as-is; they simply stop being the storage format.
|
||||
|
||||
## 4. The mandatory split manifest
|
||||
|
||||
The brief's leakage rule is now code: `PartitionKey` gains a `session` dimension (packet-session leakage is as real as room leakage) and `eval::SplitManifest` certifies per-dimension disjointness across **all seven** dimensions (room/day/person/chipset/firmware/layout/session):
|
||||
|
||||
```text
|
||||
train_rooms ∩ test_rooms = ∅ … train_sessions ∩ test_sessions = ∅
|
||||
```
|
||||
|
||||
`fully_disjoint()` is the bar for reporting a result as leakage-resistant; a room-holdout split that still shares people *says so* in its manifest instead of masquerading (test `split_manifest_certifies_per_dimension_disjointness`). The hidden real-world test set requirement (never accessible to synthetic generation/calibration) is process, recorded in ADR-282 §4.
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
- New hardware (PicoScenes, Intel, Atheros, Realtek radar, 320 MHz 802.11bk) lands as an `RfFrameV2` producer + latent adapter; nothing is lost at ingest. Vendor conformance receipt = the constructor's invariants (native shape preserved, phase state declared, timestamps monotonic, geometry present, loss measured, synthetic flag correct).
|
||||
- The encoder input contract (ADR-274) is unchanged *today* (it consumes the derived view); migrating the tokenizer to native-resolution tokens is the flagged follow-up once real multi-bandwidth data exists (P2).
|
||||
- Storage cost rises (native + derived); accepted — the derived view can always be recomputed, the native never can be.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
`cargo test -p ruview-unified frame::` — 5 tests: provenance aliasing, shape/mask/axes arity, derived-view purity + gap-filling, rank/geometry rejection, P3162 import-profile validation (`SyntheticApertureSoundingDataset`, ADR-281 §5). All MEASURED-CODE.
|
||||
@@ -1,59 +0,0 @@
|
||||
# ADR-280: Active sensing and programmable perception — tasks, freshness, coherence, and governed actuation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (`ruview-unified/src/control.rs`; 6 test suites incl. a measured ≥70 % traffic-reduction gate) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273; extends ADR-277 |
|
||||
| **Relates to** | ADR-277 (policy engine — every contract here composes with it), ADR-262 (P0–P5 privacy classes, reused verbatim), ADR-148 (`ruview-swarm` — the mobile-agent consumer of sensing actions) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. External motivators — ESI-Bench's act-to-uncover formalization, LuLIS's 256-coherent-RF-chain distributed aperture, ETSI's cooperative-ISAC and AI/data-handling work items, age-of-information digital-twin scheduling, semantic/task-sufficient communication architectures — are all EXTERNAL-UNVERIFIED. Everything asserted about *our* behavior is a named test.
|
||||
|
||||
## 1. Context
|
||||
|
||||
The important shift is from **passive sensing** (accept whatever measurements arrive) to **programmable perception**: the system chooses where, when, how, and at what fidelity to sense, then changes the radio environment or moves sensing agents to resolve uncertainty. Simultaneously, the dominant failure mode across the emerging systems is **hidden synchronization and calibration dependence** — shared clocks, known antenna poses, stable phase silently assumed, confidently wrong when violated. Both belong in the control plane, fail-closed, before capture begins.
|
||||
|
||||
## 2. Decision — the evidence-aware sensing task (`SensingTask`)
|
||||
|
||||
ETSI-ISAC-vocabulary contract: purpose, target zone, modalities, requested resolution, latency bound, minimum confidence (below which results become *no decision*), raw + result retention, authorized consumers, consent reference. `admit_task` composes with the ADR-277 engine and is fail-closed on every branch; two rules deserve record:
|
||||
|
||||
- `raw_export_allowed` **exists in the contract** (ISAC vocabulary compatibility) but is **always refused** (`task_admission_is_fail_closed`): ADR-277 §2.1 made raw export unrepresentable, and a config flag does not reopen it.
|
||||
- Identity-purpose tasks without a consent reference are refused before the zone check even runs.
|
||||
|
||||
## 3. Decision — sensing actions (`SensingAction` + `InformationGoal`)
|
||||
|
||||
An action is a deliberate act of evidence-gathering against a stated hypothesis ("the east corridor holds one stationary person or two closely spaced people"), bounded by latency, energy, and a **privacy ceiling** (`PrivacyClass` P0–P5, the ADR-262 ladder). Actions are what the planner (§4), a MetaHarness agent, or a swarm drone consume.
|
||||
|
||||
## 4. Decision — age-of-information scheduler (`ActiveSensingPlanner`)
|
||||
|
||||
A spatial twin is only useful when it knows which parts are stale. Per region: `SpatialStateFreshness` (last observation, expected change rate, uncertainty growth, business criticality, sensing cost), with
|
||||
|
||||
```text
|
||||
priority = uncertainty(age) × change_rate × criticality ÷ cost
|
||||
```
|
||||
|
||||
The planner emits at most the highest-priority action above threshold per cycle. **Measured** (`planner_reduces_sensing_traffic_versus_uniform_refresh`): 20 regions / 100 ticks, one hot region — 100 observations vs 2,000 under uniform refresh = **95 % sensing-traffic reduction** while the hot region stays observed. (The brief's "50–90 %" was an architectural estimate; this is a synthetic-scenario measurement, sensitive to how concentrated change is.) Priority ordering is proven separately (`planner_prioritizes_stale_critical_regions`: emergency-exit > server-room > storage).
|
||||
|
||||
## 5. Decision — coherent distributed apertures fail closed (`CoherentSensorGroup`)
|
||||
|
||||
No coherent fusion unless the group can *prove* compatibility: every member must report sync state, be within the group's time-error and phase-error bounds, and match the calibrated baseline geometry hash; unknown reporters are rejected too. Five denial paths, each tested (`coherent_fusion_fails_closed`): missing member, clock drift, phase drift, geometry change since calibration, non-member injection. This is the antidote to the hidden-synchronization failure mode — a building-scale WiFi aperture (the LuLIS direction) degrades to incoherent processing rather than producing confident nonsense.
|
||||
|
||||
## 6. Decision — programmable radio environments are governed actuators
|
||||
|
||||
RIS / movable / fluid antennas change **which rooms and people are observable**, so actuation is governed like sensing: `request_actuation` is the only way to obtain an `ActuationReceipt`, it verifies the state is supported *and* that the affected zone grants the purpose under the ADR-277 engine (`actuation_requires_policy_authorization`: steering a beam for an ungranted purpose is denied). Receipts carry requested/applied state, time, controller, purpose — the audit trail the RIS governance requirement demands.
|
||||
|
||||
## 7. Decision — task-sufficient representations are leakage-checked
|
||||
|
||||
Semantic compression ("transmit occupancy uncertainty, not CSI") must remain **task-scoped**: a representation sufficient for anonymous occupancy may not retain identity. `TaskSufficientRepresentation` carries source lineage, an information bound, an explicit `excluded_information` list, and a privacy class; `validate_representation` enforces per-purpose ceilings (Presence/Diagnostics ≤ P2 excluding identity+vitals; Activity/Localization ≤ P3 excluding identity; Vitals/Pose ≤ P4; Identity = P5) and refuses lineage-free orphans (`task_sufficient_representation_is_leakage_checked`).
|
||||
|
||||
## 8. Standards alignment (the strongest strategic seam)
|
||||
|
||||
The vocabulary here — sensing task/service/entity, measurement configuration, sensing data/result/consumer/purpose, retention, result exposure — is deliberately the emerging ETSI ISAC data-plane vocabulary, positioning this crate as an open reference implementation candidate for ISAC data handling rather than a parallel dialect. Charging/mobility management are explicitly out of scope until a cellular deployment exists.
|
||||
|
||||
## 9. Consequences
|
||||
|
||||
- MetaHarness/OaK-style agents get a typed surface: read freshness, plan actions, receive receipts — spatial memory meets agentic planning without touching raw RF.
|
||||
- Distributed-aperture work (P4+) inherits a fusion gate that already fails closed.
|
||||
- Not implemented (honest scope): information-gain *estimation* is caller-supplied (the planner uses staleness heuristics, not mutual information); RIS drivers, actual multi-AP coherence measurement, and OTFS waveform control are hardware-dependent roadmap items.
|
||||
@@ -1,49 +0,0 @@
|
||||
# ADR-281: New modality surfaces — BLE Channel Sounding, delay-Doppler-native tensors, P3162 import, and factorized pose
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (`adapters.rs` BLE CS + ranging evidence, `tensor.rs::delay_doppler_map`, `frame.rs` P3162 import profile, `heads.rs` factorized pose; 8 new test suites) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273; extends ADR-274 |
|
||||
| **Relates to** | ADR-279 (`FieldAxis` native axes), ADR-152 (geometry conditioning intake), ADR-021/263 (radar hardware) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. Bluetooth SIG cm-level claims vs the ~20–50 cm practical review, OTFS ISAC field trials, IEEE P3162, PerceptAlign's >60 % cross-domain error reduction, and RePos's 10–21 % MPJPE gains are EXTERNAL-UNVERIFIED design inputs. Our numbers below are MEASURED-CODE / MEASURED-SYNTHETIC.
|
||||
|
||||
## 1. BLE Channel Sounding (§2) — likely the fastest path to consumer-scale spatial anchoring
|
||||
|
||||
`BleCsFrame` carries per-frequency-step round-trip tone phases plus optional RTT. Two rules:
|
||||
|
||||
- **Phase-based ranging and RTT are separate evidence sources.** `ble_cs_range` computes both — `d_phase = |dθ/df|·c/4π` from the unwrapped phase-vs-frequency slope, `d_rtt = rtt·c/2` — and *cross-validates* instead of averaging. Agreement raises confidence; divergence beyond 0.5 m yields `RangingAnomaly::Divergent` (multipath bias, relay attack, timing fault, or calibration problem) with confidence capped ≤ 0.2. Measured: exact recovery at 1.5/5/12 m (< 1 µm error on clean synthetic phases, 40 steps × 1 MHz); a relay-style RTT inflation to ~51 m against a 5 m phase estimate is flagged, not blended (`ble_cs_flags_relay_style_divergence_instead_of_averaging`).
|
||||
- The tensor view (`BleCsAdapter`, `nrf54-cs` in the registry) **never detrends phase** — the ranging ramp *is* the measurement; the preserved ramp is asserted in test.
|
||||
|
||||
Single-source evidence (no RTT) is capped at confidence 0.5 — one mechanism alone is never high-trust ranging.
|
||||
|
||||
## 2. Delay-Doppler-native support (§3)
|
||||
|
||||
`FieldAxis` (ADR-279) makes delay/Doppler first-class native axes so OTFS-style captures are stored natively, and `RfTensor::delay_doppler_map` provides the standard transform for frequency-time tensors: IDFT over bins (→ delay) × DFT over snapshots (→ Doppler). Measured: a synthetic scatterer at (delay 7, Doppler 3) produces a unit peak with < 1e-9 leakage everywhere else. The transform is implemented **separably** (delay IDFT per snapshot, then Doppler DFT per delay row — `O(B²S + S²B)` vs the direct form's `O(B²S²)`), proven equivalent to the direct reference to < 1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8 in the criterion bench). Rule: derived features may be small, but delay-Doppler maps are not collapsed into scalar motion energy before provenance and local storage.
|
||||
|
||||
## 3. IEEE P3162 synthetic-aperture import (§5)
|
||||
|
||||
`SyntheticApertureSoundingDataset` (frequency range, aperture poses, directional PDP, coordinate system, processing-manifest hash) is the validated import profile — the calibration bridge between measured environments, Sionna-class simulators, and learned RF scene models. Schema + validation only; parsers arrive with the first real dataset.
|
||||
|
||||
## 4. Factorized pose (RePos) + log-age gating
|
||||
|
||||
`FactorizedPoseHead` separates what generalizes from what conditions:
|
||||
|
||||
- **relative skeleton** branch reads the environment-invariant content representation (cannot learn room-position shortcuts);
|
||||
- **root localization** branch reads the geometry-conditioned representation (sensor pose is signal there — the PerceptAlign lesson);
|
||||
- `absolute = root + relative` (`PoseOutput::absolute_joints_m`), with **calibrated per-joint and root residual σ** so every pose output carries uncertainty (ADR-273 item 8).
|
||||
|
||||
**The leakage experiment** (`factorized_pose_resists_room_shortcut_leakage`): training rooms where room position *correlates* with body scale (the trap real deployments set), held-out room breaking the correlation — factorized MPJPE **0.0003 m** vs monolithic absolute-head **0.2534 m** (845× worse), on a toy that isolates the mechanism. MEASURED-CODE for the mechanism; not a pose-accuracy claim.
|
||||
|
||||
Budget: the structured pose head is the largest adapter at **740 params vs the 40,856-param backbone (1.8 %)** — documented ceiling for structured heads is **< 2 %** (scalar heads keep the 1 % gate), both asserted in `every_head_fits_the_one_percent_budget_at_deployment_config`.
|
||||
|
||||
Age gating now matches the age-aware-CSI recipe exactly: the freshness gate input is `log(1 + sample_age_ms)` (`encoder::age_feature`), giving millisecond and multi-second staleness comparable input scale; the finite-difference gradient check re-proves the backward pass through the changed input.
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
- Bluetooth/UWB anchors slot in as *geometric* evidence while WiFi carries ambient activity — the complement strategy, in code.
|
||||
- The Gaussian primitive gained the lifecycle fields the update-loop spec requires (`first_seen_ns`, `doppler_variance`, bounded `source_receipts` lineage merged on fusion) — static structure is distinguishable from transients by lifetime, and every primitive traces to source frames.
|
||||
- Roadmap, explicitly not done: real nRF54 CS capture path, OTFS waveform generation, P3162 file parsing, pose heads on real MM-Fi-style data.
|
||||
@@ -1,62 +0,0 @@
|
||||
# ADR-282: Ecosystem positioning — RuView is the camera-free RF perception runtime, not the whole spatial OS
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted (positioning + evidence-ladder policy; ladder implemented as `frame::EvidenceLevel`) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-260/262 (RuField), ADR-261 (RuVector), ADR-182 (MetaHarness-minted harness), ADR-279 (provenance/evidence types), ADR-187 (honest labeling precedent) |
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView currently occupies a valuable but ambiguous position: the README's breadth invites reading every capability as field-validated, and the platform sometimes speaks as if it were the complete spatial intelligence operating system. The defensible identity is narrower and stronger.
|
||||
|
||||
## 2. Decision — the layered identity
|
||||
|
||||
> **RuView is an open, edge-native RF perception runtime that turns heterogeneous radio measurements into governed spatial observations.**
|
||||
|
||||
It is *not* positioned as a complete world model, robotics platform, digital twin, or universal spatial OS. The stack divides:
|
||||
|
||||
| Layer | Responsibility | Owner |
|
||||
|---|---|---|
|
||||
| Applications | healthcare, buildings, robotics, security, retail, industrial | application systems |
|
||||
| Agent & decision | query planning, active sensing, automation, policy | **MetaHarness** |
|
||||
| Spatial memory & reasoning | persistent objects, Gaussian fields, scene graphs, temporal memory | **RuVector** (fed by `ruview-unified::gaussian`) |
|
||||
| Governed sensing plane | evidence, privacy, calibration, lineage, sensing tasks | **RuField** (bridged per ADR-262; contracts in ADR-277/279/280) |
|
||||
| Perception & edge inference | native capture, adapters, shared encoder, task heads, uncertainty, P0 containment | **RuView** |
|
||||
| Radio & physical sensors | WiFi CSI/CIR/BF, radar, UWB, BLE CS, cellular SRS | hardware |
|
||||
|
||||
Competitive posture follows from the layer: **complement vision platforms** (coverage where cameras are unavailable, unwanted, or ineffective — never "replaces cameras universally"); one shared encoder + spatial field across CSI and radar; BLE/UWB as geometric anchors with WiFi for ambient sensing; and against 6G ISAC, be the practical open implementation of the sensing data plane on hardware that exists today.
|
||||
|
||||
## 3. Decision — strengths to invest, weaknesses to fix
|
||||
|
||||
Invest (already differentiated): low-cost ambient perception on commodity radios; camera-free coverage (with the explicit caveat that camera-free ≠ privacy-preserving — that is what ADR-277/280 gates are for); edge-first execution; existing application surfaces (HA/Matter/HomeKit), to be extended toward ROS 2, OpenUSD, MQTT Sparkplug, OPC UA, BIM/digital-twin connectors as demand proves out.
|
||||
|
||||
Fix (each has a concrete ADR): platform/world-model claim mixing → this ADR's ladder; no persistent spatial representation → ADR-275 (feed RuVector, don't contain everything in the sensing server); ESP32-specific pipeline risk → ADR-279 adapters; stream-only operation → ADR-280 sensing tasks.
|
||||
|
||||
## 4. Decision — the public evidence ladder (mandatory)
|
||||
|
||||
`frame::EvidenceLevel` is now a type, and its use is policy:
|
||||
|
||||
| Level | Meaning |
|
||||
|---|---|
|
||||
| L0 | Simulation only |
|
||||
| L1 | Captured replay |
|
||||
| L2 | Controlled laboratory |
|
||||
| L3 | Held-out room + subject validation |
|
||||
| L4 | Multi-site field pilot |
|
||||
| L5 | Production operational evidence |
|
||||
|
||||
Rules: (a) every capability row in README/registry carries exactly one level; (b) `ProvenanceClass::Synthetic` frames are L0 *by type* and measured frames are ≥ L1 — the constructor rejects both aliasing directions (ADR-279 invariant 6); (c) a level upgrade requires the corresponding artifact (a replay corpus, a lab protocol, a strict-split manifest per ADR-279 §4, a pilot report); (d) the hidden real-world test set used for L3+ claims is never accessible to synthetic generation, augmentation, or calibration. Everything shipped in ADR-273..281 is **L0** except the adapter/contract layers, which are code-level (no accuracy claim to grade).
|
||||
|
||||
## 5. Commercial focus (bounded claims per vertical)
|
||||
|
||||
Elder care (decision support and anomaly escalation, **not** diagnosis); smart buildings (occupancy/utilization; value = energy + space + safety − cost); industrial safety (works in dust/darkness/occlusion; **not** a certified safety system until field-validated); security (through-wall occupancy with the surveillance-governance gates of ADR-277/280 as a feature, not friction); robotics (RuView is probabilistic exteroception, never ground truth).
|
||||
|
||||
## 6. The moat
|
||||
|
||||
Not any single detector: the *combination* of broad hardware support (ADR-279 adapters), heterogeneous data with provenance, cross-environment pretrained encoders under anti-leakage evaluation (ADR-273 §4), calibration/uncertainty discipline, privacy-preserving edge execution (ADR-277/280), cryptographic evidence (RuField bridge), persistent spatial memory (ADR-275 → RuVector), and open integration. Harder to reproduce than any model.
|
||||
|
||||
## 7. Acceptance test (ecosystem-fit)
|
||||
|
||||
RuView fits the mature stack when a **frozen** encoder ingests WiFi CSI, radar, and Bluetooth measurements from previously unseen hardware, emits RuField-compliant observations, updates a persistent RuVector spatial model, and supports an agent query with: ≤ 0.5 m p90 localization; < 20 % degradation across unseen rooms; explicit uncertainty on every result; complete calibration + provenance lineage; no P0 RF leaving the edge; replay/lab/live evidence clearly separated; successful fusion with a standard robotics or digital-twin platform. Tracked as the L4 gate; the synthetic analogue machinery already exists (`tests/e2e_acceptance.rs`).
|
||||
+1
-20
@@ -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 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
|
||||
|
||||
## Why ADRs?
|
||||
|
||||
@@ -132,16 +123,6 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [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 |
|
||||
| [ADR-273](ADR-273-unified-rf-spatial-world-model.md) | Unified RF spatial world model — umbrella, anti-leakage protocol, acceptance gates | Accepted (P1 implemented) |
|
||||
| [ADR-274](ADR-274-universal-rf-encoder-adapter-registry.md) | Universal RF foundation encoder + hardware adapter registry | Accepted (P1 implemented) |
|
||||
| [ADR-275](ADR-275-rf-aware-gaussian-spatial-memory.md) | RF-aware Gaussian spatial memory | Accepted (P1 implemented) |
|
||||
| [ADR-276](ADR-276-physics-guided-synthetic-rf-worlds.md) | Physics-guided synthetic RF world generator | Accepted (P1 implemented) |
|
||||
| [ADR-277](ADR-277-edge-sensing-control-plane.md) | Edge sensing control plane (802.11bf / ETSI ISAC aligned) | Accepted (P1 implemented) |
|
||||
| [ADR-278](ADR-278-radar-inverse-rendering-research-program.md) | Radar inverse rendering + differentiable RF SLAM research program | Proposed |
|
||||
| [ADR-279](ADR-279-native-rf-frame-contract.md) | Native RF frame contract — `RfFrameV2` authoritative, canonical tensor derived | Accepted (implemented) |
|
||||
| [ADR-280](ADR-280-active-sensing-programmable-perception.md) | Active sensing & programmable perception control plane | Accepted (implemented) |
|
||||
| [ADR-281](ADR-281-ble-cs-delay-doppler-pose-factorization.md) | BLE Channel Sounding, delay-Doppler tensors, P3162 import, factorized pose | Accepted (implemented) |
|
||||
| [ADR-282](ADR-282-ruview-ecosystem-positioning.md) | Ecosystem positioning + mandatory L0–L5 evidence ladder | Accepted |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -113,8 +113,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
|
||||
|
||||
@@ -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 +1 @@
|
||||
0.8.4
|
||||
0.7.0
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//! ADR-185 §13.a — weight-loading parity: native-Rust reference half.
|
||||
//!
|
||||
//! Proves the AETHER `load_weights` path produces a deterministic, non-random
|
||||
//! embedding, and compares it to the committed golden VECTOR
|
||||
//! `tests/golden/aether_loaded_embedding.json` within tolerance. The pytest
|
||||
//! half (`tests/test_aether.py`) writes a byte-identical weight file (same
|
||||
//! formula + format) through the binding's `load_weights` and compares to the
|
||||
//! SAME golden within the same tolerance — native≈golden and binding≈golden
|
||||
//! prove the binding's weight-loading matches native, portably across arch.
|
||||
//! (See `aether_parity.rs` for why this is a tolerance compare, not a hash.)
|
||||
//!
|
||||
//! Weight formula (shared with the pytest half): `w[i] = k/65536 - 0.5` where
|
||||
//! `k = (i*1103515245 + 12345) mod 65536`. `k/65536` is a multiple of 2⁻¹⁶,
|
||||
//! exactly representable in both f32 and f64, so both languages produce
|
||||
//! byte-identical weights.
|
||||
//!
|
||||
//! File format: 8-byte magic `AETHERW1`, `u32` little-endian param count, then
|
||||
//! that many little-endian `f32`.
|
||||
//!
|
||||
//! Regenerate (only on an intentional change): delete the .json golden and
|
||||
//! re-run `cargo test --features aether --test aether_weights_parity`.
|
||||
#![cfg(feature = "aether")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Same default construction as `aether_parity.rs` / the Python binding default.
|
||||
fn new_extractor() -> EmbeddingExtractor {
|
||||
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,
|
||||
};
|
||||
EmbeddingExtractor::new(t_config, e_config)
|
||||
}
|
||||
|
||||
fn formula_weights(n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let k = (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345) % 65_536;
|
||||
k as f32 / 65_536.0 - 0.5
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_weight_file(path: &PathBuf, weights: &[f32]) {
|
||||
let mut buf = Vec::with_capacity(12 + weights.len() * 4);
|
||||
buf.extend_from_slice(b"AETHERW1");
|
||||
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
|
||||
for v in weights {
|
||||
buf.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
fs::write(path, buf).unwrap();
|
||||
}
|
||||
|
||||
/// Cross-architecture f32 parity tolerance; see `aether_parity.rs` and the
|
||||
/// matching constants in `tests/test_aether.py` for why this is a tolerance
|
||||
/// compare and not a byte hash.
|
||||
const PARITY_ATOL: f32 = 1e-4;
|
||||
const PARITY_RTOL: f32 = 1e-4;
|
||||
|
||||
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}, |Δ|={}) — real regression, not arch 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_loaded_embedding_matches_committed_golden() {
|
||||
let input = load_input();
|
||||
|
||||
let mut ext = new_extractor();
|
||||
let baseline = ext.extract(&input); // random Xavier init
|
||||
|
||||
let weights = formula_weights(ext.param_count());
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"aether_weights_parity_{}.bin",
|
||||
std::process::id()
|
||||
));
|
||||
write_weight_file(&path, &weights);
|
||||
ext.load_weights(&path).expect("load_weights");
|
||||
fs::remove_file(&path).ok();
|
||||
|
||||
let loaded = ext.extract(&input);
|
||||
// Loaded weights must actually take effect.
|
||||
assert!(
|
||||
baseline.iter().zip(&loaded).any(|(a, b)| (a - b).abs() > 1e-6),
|
||||
"load_weights had no effect vs the random-init baseline"
|
||||
);
|
||||
assert_eq!(loaded.len(), 128);
|
||||
|
||||
assert_matches_golden_vector(&loaded, "aether_loaded_embedding.json");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[-0.09882868826389313, 0.08015579730272293, 0.019888414070010185, -0.003239249112084508, -0.07265102863311768, -0.08036628365516663, -0.10324385017156601, -0.21274414658546448, 0.1503465175628662, -0.005489329807460308, 0.10834519565105438, 0.05838076397776604, -0.05911992862820625, 0.13135841488838196, -0.006811900530010462, -0.13742603361606598, -0.015311408787965775, -0.21133442223072052, 0.05134191736578941, -0.027355113998055458, -0.044147003442049026, -0.006108833476901054, -0.033326443284749985, 0.15741342306137085, 0.029073286801576614, 0.0375739224255085, 0.023920813575387, 0.07043211907148361, -0.009550352580845356, 0.028179991990327835, 0.05900105461478233, 0.056598298251628876, -0.0047074914909899235, 0.05960564315319061, 0.049969129264354706, 0.017297586426138878, -0.10153798758983612, -0.002574362326413393, 0.06392877548933029, 0.14119082689285278, -0.04484020546078682, -0.038461778312921524, -0.06095990538597107, -0.04703143611550331, 0.07692500203847885, -0.10256559401750565, -0.07250502705574036, 0.12476003915071487, -0.08511383831501007, -0.006181457545608282, 0.09957090020179749, 0.10756388306617737, -0.08597669750452042, -0.09914427250623703, -0.01648416928946972, -0.25724929571151733, -0.024403633549809456, 0.05453304573893547, -0.03141803294420242, -0.07852566242218018, 0.020048094913363457, -0.06215068697929382, 0.11997628211975098, 0.0977955237030983, -0.0652041882276535, -0.007863834500312805, -0.059089504182338715, 0.12663882970809937, 0.16099494695663452, -0.046197254210710526, 0.04186059534549713, -0.07077737897634506, -0.28093546628952026, 0.017284320667386055, 0.1626843810081482, 0.006352100986987352, -0.07779540121555328, -0.004958702251315117, 0.04892482981085777, 0.013853654265403748, 0.08351490646600723, 0.06772761791944504, -0.028758108615875244, 0.04778963327407837, -0.1131502315402031, 0.005114563275128603, -0.012307023629546165, 0.03301718831062317, 0.09168995916843414, -0.04085332900285721, 0.04984535649418831, -0.05280173197388649, -0.01752082072198391, 0.04126245900988579, -0.09206362813711166, 0.08685162663459778, 0.03651193156838417, -0.144779771566391, -0.03290265053510666, 0.15378770232200623, -0.08842422068119049, 0.12492084503173828, 0.04907738417387009, -0.03483045473694801, -0.09838201850652695, 0.04590783640742302, -0.01383654773235321, 0.03766492009162903, -0.03254647180438042, 0.12435581535100937, 0.06573979556560516, 0.055382076650857925, -0.19347889721393585, 0.0018683894304558635, 0.11095203459262848, 0.04290277138352394, -0.07855042070150375, 0.03853689134120941, -0.06062287464737892, 0.004584986716508865, -0.1223185583949089, 0.0031570768915116787, -0.10847429186105728, 0.0023399614728987217, -0.054206088185310364, -0.1367102563381195, -0.14624592661857605, 0.16953621804714203]
|
||||
@@ -1 +0,0 @@
|
||||
[[0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375, 0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375], [0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375, 0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375], [0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375, 0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375], [0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375, 0.75, 0.75390625, 0.7578125, 0.76171875, 0.765625, 0.76953125, 0.7734375, 0.77734375, 0.78125, 0.78515625, 0.7890625, 0.79296875, 0.796875, 0.80078125, 0.8046875, 0.80859375, 0.8125, 0.81640625, 0.8203125, 0.82421875, 0.828125, 0.83203125, 0.8359375, 0.83984375, 0.84375, 0.84765625, 0.8515625, 0.85546875, 0.859375, 0.86328125, 0.8671875, 0.87109375], [0.875, 0.87890625, 0.8828125, 0.88671875, 0.890625, 0.89453125, 0.8984375, 0.90234375, 0.90625, 0.91015625, 0.9140625, 0.91796875, 0.921875, 0.92578125, 0.9296875, 0.93359375, 0.9375, 0.94140625, 0.9453125, 0.94921875, 0.953125, 0.95703125, 0.9609375, 0.96484375, 0.96875, 0.97265625, 0.9765625, 0.98046875, 0.984375, 0.98828125, 0.9921875, 0.99609375, 0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375], [0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375, 0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375], [0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375, 0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375], [0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375, 0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375]]
|
||||
@@ -1 +0,0 @@
|
||||
[0.10761479288339615, 0.05284854769706726, -0.22418244183063507, -0.015137949027121067, -0.03409634903073311, 0.17326673865318298, 0.11726372689008713, -0.07647006958723068, 0.034259773790836334, -0.11302468180656433, 0.05575002729892731, -0.000968325708527118, 0.12398994714021683, 0.010035112500190735, -0.024740785360336304, 0.03396096080541611, -0.13298068940639496, -0.026409678161144257, -0.00981970690190792, 0.1098528727889061, -0.015091875568032265, -0.06820717453956604, -0.08815668523311615, -0.13947811722755432, 0.12845416367053986, 0.007558434270322323, 0.09278517216444016, 0.023929834365844727, -0.15709640085697174, 0.20790696144104004, -0.0814460963010788, 0.035465411841869354, 0.0621788427233696, -0.022502727806568146, 0.1301409900188446, -0.09830718487501144, -0.04854566603899002, -0.060892220586538315, -0.0039014117792248726, 0.08287017792463303, 0.0968087762594223, -0.05596396327018738, -0.18289180099964142, 0.07555137574672699, -0.0711054727435112, 0.017438538372516632, -0.07017677277326584, 0.08828858286142349, 0.09126422554254532, -0.18576686084270477, -0.08681167662143707, 0.006826931145042181, 0.13380175828933716, 0.15818704664707184, -0.03554683178663254, -0.02037874236702919, -0.0721014142036438, 0.09667330235242844, 0.03744731843471527, -0.019951190799474716, 0.05095838010311127, 0.0136749017983675, -0.04109140485525131, -0.09205742925405502, -0.06173047423362732, 0.028595957905054092, 0.07932677119970322, 0.025831375271081924, -0.029791485518217087, -0.047233421355485916, -0.0985548198223114, 0.056721169501543045, 0.04597408324480057, 0.05116378515958786, 0.06485309451818466, -0.11868073791265488, 0.1164744570851326, -0.040522847324609756, -0.12034964561462402, 0.10310209542512894, 0.01842050999403, 0.16855661571025848, -0.05738396197557449, -0.17958901822566986, -0.022476589307188988, 0.03451419249176979, 0.034107644110918045, 0.13773204386234283, -0.07001013308763504, -0.14196854829788208, 0.11647462099790573, -0.03268979489803314, 0.058361802250146866, -0.029253516346216202, 0.1251915991306305, 0.13218750059604645, -0.14484354853630066, -0.1424856185913086, 0.045242637395858765, 0.039408858865499496, 0.199110209941864, 0.0028688418678939342, -0.14990390837192535, -0.031178129836916924, -0.000643149483948946, 0.0783705934882164, 0.02097206376492977, -0.058810342103242874, 0.05459814518690109, -0.00016812187095638365, -0.1195453405380249, -0.06815463304519653, 0.03833199664950371, 0.12025003135204315, 0.06424705684185028, 0.011131756007671356, -0.006310137454420328, -0.06013919785618782, 0.061071064323186874, 0.018219128251075745, 0.08957944065332413, -0.04298160970211029, -0.07775749266147614, -0.01905573531985283, -0.002107167150825262, -0.0794263556599617, -0.005148472264409065, 0.05683618783950806]
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
ae46351e28c01161c3c20f1a3134d8e32fbbef0cda2fdb9c3a27984da0ce026d
|
||||
@@ -1 +0,0 @@
|
||||
{"esp32_amplitude": [0.51171875, 0.5390625, 0.56640625, 0.59375, 0.62109375, 0.6484375, 0.67578125, 0.703125, 0.73046875, 0.7578125, 0.78515625, 0.8125, 0.83984375, 0.8671875, 0.89453125, 0.921875, 0.94921875, 0.9765625, 1.00390625, 1.03125, 1.05859375, 1.0859375, 1.11328125, 1.140625, 1.16796875, 1.1953125, 1.22265625, 1.25, 1.27734375, 1.3046875, 1.33203125, 1.359375, 1.38671875, 1.4140625, 1.44140625, 1.46875, 1.49609375, 0.5234375, 0.55078125, 0.578125, 0.60546875, 0.6328125, 0.66015625, 0.6875, 0.71484375, 0.7421875, 0.76953125, 0.796875, 0.82421875, 0.8515625, 0.87890625, 0.90625, 0.93359375, 0.9609375, 0.98828125, 1.015625, 1.04296875, 1.0703125, 1.09765625, 1.125, 1.15234375, 1.1796875, 1.20703125, 1.234375], "esp32_phase": [-0.45703125, -0.4375, -0.41796875, -0.3984375, -0.37890625, -0.359375, -0.33984375, -0.3203125, -0.30078125, -0.28125, -0.26171875, -0.2421875, -0.22265625, -0.203125, -0.18359375, -0.1640625, -0.14453125, -0.125, -0.10546875, -0.0859375, -0.06640625, -0.046875, -0.02734375, -0.0078125, 0.01171875, 0.03125, 0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, -0.5, -0.48046875, -0.4609375, -0.44140625, -0.421875, -0.40234375, -0.3828125, -0.36328125, -0.34375, -0.32421875, -0.3046875, -0.28515625, -0.265625, -0.24609375, -0.2265625], "intel_amplitude": [0.50390625, 0.546875, 0.58984375, 0.6328125, 0.67578125, 0.71875, 0.76171875, 0.8046875, 0.84765625, 0.890625, 0.93359375, 0.9765625, 1.01953125, 1.0625, 1.10546875, 1.1484375, 1.19140625, 1.234375, 1.27734375, 1.3203125, 1.36328125, 1.40625, 1.44921875, 1.4921875, 0.53515625, 0.578125, 0.62109375, 0.6640625, 0.70703125, 0.75], "intel_phase": [-0.47265625, -0.4609375, -0.44921875, -0.4375, -0.42578125, -0.4140625, -0.40234375, -0.390625, -0.37890625, -0.3671875, -0.35546875, -0.34375, -0.33203125, -0.3203125, -0.30859375, -0.296875, -0.28515625, -0.2734375, -0.26171875, -0.25, -0.23828125, -0.2265625, -0.21484375, -0.203125, -0.19140625, -0.1796875, -0.16796875, -0.15625, -0.14453125, -0.1328125], "ap_positions": [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]], "rapid_frames": [[0.0, 0.01953125, 0.0390625, 0.05859375, 0.078125, 0.09765625, 0.1171875, 0.13671875, 0.15625, 0.17578125, 0.1953125, 0.21484375, 0.234375, 0.25390625, 0.2734375, 0.29296875], [0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375], [0.1015625, 0.12109375, 0.140625, 0.16015625, 0.1796875, 0.19921875, 0.21875, 0.23828125, 0.2578125, 0.27734375, 0.296875, 0.31640625, 0.3359375, 0.35546875, 0.375, 0.39453125], [0.15234375, 0.171875, 0.19140625, 0.2109375, 0.23046875, 0.25, 0.26953125, 0.2890625, 0.30859375, 0.328125, 0.34765625, 0.3671875, 0.38671875, 0.40625, 0.42578125, 0.4453125], [0.203125, 0.22265625, 0.2421875, 0.26171875, 0.28125, 0.30078125, 0.3203125, 0.33984375, 0.359375, 0.37890625, 0.3984375, 0.41796875, 0.4375, 0.45703125, 0.4765625, 0.49609375], [0.25390625, 0.2734375, 0.29296875, 0.3125, 0.33203125, 0.3515625, 0.37109375, 0.390625, 0.41015625, 0.4296875, 0.44921875, 0.46875, 0.48828125, 0.5078125, 0.52734375, 0.546875], [0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, 0.5, 0.51953125, 0.5390625, 0.55859375, 0.578125, 0.59765625], [0.35546875, 0.375, 0.39453125, 0.4140625, 0.43359375, 0.453125, 0.47265625, 0.4921875, 0.51171875, 0.53125, 0.55078125, 0.5703125, 0.58984375, 0.609375, 0.62890625, 0.6484375], [0.40625, 0.42578125, 0.4453125, 0.46484375, 0.484375, 0.50390625, 0.5234375, 0.54296875, 0.5625, 0.58203125, 0.6015625, 0.62109375, 0.640625, 0.66015625, 0.6796875, 0.69921875], [0.45703125, 0.4765625, 0.49609375, 0.515625, 0.53515625, 0.5546875, 0.57421875, 0.59375, 0.61328125, 0.6328125, 0.65234375, 0.671875, 0.69140625, 0.7109375, 0.73046875, 0.75], [0.5078125, 0.52734375, 0.546875, 0.56640625, 0.5859375, 0.60546875, 0.625, 0.64453125, 0.6640625, 0.68359375, 0.703125, 0.72265625, 0.7421875, 0.76171875, 0.78125, 0.80078125], [0.55859375, 0.578125, 0.59765625, 0.6171875, 0.63671875, 0.65625, 0.67578125, 0.6953125, 0.71484375, 0.734375, 0.75390625, 0.7734375, 0.79296875, 0.8125, 0.83203125, 0.8515625]]}
|
||||
@@ -1 +0,0 @@
|
||||
0486402d5a860f459a319cd779ca44a112d8543442ae9ce9eb7b1a01780aee4b
|
||||
@@ -1,126 +0,0 @@
|
||||
//! ADR-185 §4.1 — MAT bit-for-bit parity: native-Rust reference half.
|
||||
//!
|
||||
//! Drives the canonical `wifi-densepose-mat` `DisasterResponse` pipeline
|
||||
//! DIRECTLY (no PyO3) over the committed `tests/golden/mat_input.json` CSI
|
||||
//! stream and locks the SHA-256 of a canonical result string
|
||||
//! (`count=<K>;triage_priorities=<sorted>`) into
|
||||
//! `tests/golden/mat_result.sha256`.
|
||||
//!
|
||||
//! Only the survivor **count** and **triage classes** are hashed — survivor
|
||||
//! UUIDs and event timestamps are non-deterministic and deliberately
|
||||
//! excluded, so the hash captures exactly the "identical triage +
|
||||
//! survivor count for a fixed CSI stream" invariant of ADR-185 §4.1.
|
||||
//!
|
||||
//! Honesty note: the fixture is a synthetic breathing-modulated stream, so
|
||||
//! this proves the Python binding drives the real pipeline byte-identically
|
||||
//! to native Rust — it is NOT a detection-accuracy claim on real rubble.
|
||||
//!
|
||||
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
|
||||
//! re-run `cargo test --features mat --test mat_parity`.
|
||||
#![cfg(feature = "mat")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use wifi_densepose_mat::{DisasterConfig, DisasterResponse, DisasterType, ScanZone, ZoneBounds};
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden")
|
||||
}
|
||||
|
||||
fn fixture() -> Value {
|
||||
let raw = fs::read_to_string(golden_dir().join("mat_input.json"))
|
||||
.expect("read mat_input.json fixture");
|
||||
serde_json::from_str(&raw).expect("parse mat_input.json")
|
||||
}
|
||||
|
||||
/// Build the response identically to the Python binding's default
|
||||
/// construction and run one scan over the fixture CSI stream. Returns the
|
||||
/// canonical `count=<K>;triage_priorities=<sorted>` string.
|
||||
fn mat_canonical_result(fx: &Value) -> String {
|
||||
let config = DisasterConfig::builder()
|
||||
.disaster_type(DisasterType::Earthquake)
|
||||
.sensitivity(0.9)
|
||||
.confidence_threshold(0.1)
|
||||
.max_depth(5.0)
|
||||
.continuous_monitoring(false)
|
||||
.build();
|
||||
let mut resp = DisasterResponse::new(config);
|
||||
resp.initialize_event(geo::Point::new(0.0, 0.0), "parity-fixture")
|
||||
.expect("initialize_event");
|
||||
resp.add_zone(ScanZone::new(
|
||||
"Zone A",
|
||||
ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
|
||||
))
|
||||
.expect("add_zone");
|
||||
|
||||
for frame in fx["stream"].as_array().unwrap() {
|
||||
let amp: Vec<f64> = frame["amplitude"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap())
|
||||
.collect();
|
||||
let ph: Vec<f64> = frame["phase"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap())
|
||||
.collect();
|
||||
resp.push_csi_data(&, &ph).expect("push_csi_data");
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(resp.start_scanning()).expect("scan");
|
||||
|
||||
let survivors = resp.survivors();
|
||||
let mut priorities: Vec<u8> = survivors
|
||||
.iter()
|
||||
.map(|s| s.triage_status().priority())
|
||||
.collect();
|
||||
priorities.sort_unstable();
|
||||
format!("count={};triage_priorities={:?}", survivors.len(), priorities)
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_mat_result_is_deterministic() {
|
||||
let fx = fixture();
|
||||
// Two independent runs must agree (survivor count + triage classes are
|
||||
// deterministic; UUIDs/timestamps are excluded from the canonical form).
|
||||
assert_eq!(mat_canonical_result(&fx), mat_canonical_result(&fx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_mat_matches_committed_golden() {
|
||||
let canon = mat_canonical_result(&fixture());
|
||||
let got = sha256_hex(&canon);
|
||||
let path = golden_dir().join("mat_result.sha256");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(expected) => assert_eq!(
|
||||
got,
|
||||
expected.trim(),
|
||||
"native MAT result drifted from committed golden (canonical form: {canon})"
|
||||
),
|
||||
Err(_) => {
|
||||
fs::write(&path, &got).expect("write golden sha256");
|
||||
panic!("no committed golden found; wrote {got} for [{canon}]. Re-run to verify.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
//! ADR-185 §4.1 — MERIDIAN bit-for-bit parity: native-Rust reference half.
|
||||
//!
|
||||
//! Calls the canonical `wifi-densepose-signal::hardware_norm` +
|
||||
//! `wifi-densepose-train::{geometry,rapid_adapt}` code DIRECTLY (no PyO3)
|
||||
//! on the committed `tests/golden/meridian_input.json` fixture and locks
|
||||
//! the SHA-256 of the concatenated f32 outputs into
|
||||
//! `tests/golden/meridian_output.sha256`.
|
||||
//!
|
||||
//! Concatenation order (identical in the pytest half, tests/test_meridian.py):
|
||||
//! 1. esp32 canonical amplitude (56) 2. esp32 canonical phase (56)
|
||||
//! 3. intel5300 canonical amplitude 4. intel5300 canonical phase
|
||||
//! 5. geometry.encode(ap_positions) 6. rapid_adapt lora_weights
|
||||
//!
|
||||
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
|
||||
//! re-run `cargo test --features meridian --test meridian_parity`.
|
||||
#![cfg(feature = "meridian")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use wifi_densepose_signal::hardware_norm::{HardwareNormalizer, HardwareType};
|
||||
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
|
||||
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, RapidAdaptation};
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden")
|
||||
}
|
||||
|
||||
fn fixture() -> Value {
|
||||
let raw = fs::read_to_string(golden_dir().join("meridian_input.json"))
|
||||
.expect("read meridian_input.json fixture");
|
||||
serde_json::from_str(&raw).expect("parse meridian_input.json")
|
||||
}
|
||||
|
||||
fn f64_vec(v: &Value, key: &str) -> Vec<f64> {
|
||||
v[key]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn f32_frames(v: &Value, key: &str) -> Vec<Vec<f32>> {
|
||||
v[key]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap() as f32)
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the full concatenated MERIDIAN output vector, mirroring the
|
||||
/// Python binding's default construction exactly.
|
||||
fn meridian_output(fx: &Value) -> Vec<f32> {
|
||||
let mut out: Vec<f32> = Vec::new();
|
||||
|
||||
// 1–4: hardware normalization (default normalizer, canonical 56).
|
||||
let norm = HardwareNormalizer::new();
|
||||
let esp = norm
|
||||
.normalize(
|
||||
&f64_vec(fx, "esp32_amplitude"),
|
||||
&f64_vec(fx, "esp32_phase"),
|
||||
HardwareType::Esp32S3,
|
||||
)
|
||||
.unwrap();
|
||||
out.extend_from_slice(&esp.amplitude);
|
||||
out.extend_from_slice(&esp.phase);
|
||||
let intel = norm
|
||||
.normalize(
|
||||
&f64_vec(fx, "intel_amplitude"),
|
||||
&f64_vec(fx, "intel_phase"),
|
||||
HardwareType::Intel5300,
|
||||
)
|
||||
.unwrap();
|
||||
out.extend_from_slice(&intel.amplitude);
|
||||
out.extend_from_slice(&intel.phase);
|
||||
|
||||
// 5: geometry encoding (default config → 64-dim).
|
||||
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
|
||||
let aps: Vec<[f32; 3]> = fx["ap_positions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = p.as_array().unwrap();
|
||||
[
|
||||
a[0].as_f64().unwrap() as f32,
|
||||
a[1].as_f64().unwrap() as f32,
|
||||
a[2].as_f64().unwrap() as f32,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
out.extend_from_slice(&enc.encode(&aps));
|
||||
|
||||
// 6: rapid adaptation lora weights (Combined, epochs 5, lr 1e-3, λ 0.5).
|
||||
let mut ra = RapidAdaptation::new(
|
||||
10,
|
||||
4,
|
||||
AdaptationLoss::Combined {
|
||||
epochs: 5,
|
||||
lr: 0.001,
|
||||
lambda_ent: 0.5,
|
||||
},
|
||||
);
|
||||
for frame in f32_frames(fx, "rapid_frames") {
|
||||
ra.push_frame(&frame);
|
||||
}
|
||||
out.extend_from_slice(&ra.adapt().unwrap().lora_weights);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn sha256_le(vals: &[f32]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
for &x in vals {
|
||||
hasher.update(x.to_le_bytes());
|
||||
}
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_canonical_frames_are_56_wide() {
|
||||
let fx = fixture();
|
||||
let norm = HardwareNormalizer::new();
|
||||
let esp = norm
|
||||
.normalize(
|
||||
&f64_vec(&fx, "esp32_amplitude"),
|
||||
&f64_vec(&fx, "esp32_phase"),
|
||||
HardwareType::Esp32S3,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(esp.amplitude.len(), 56);
|
||||
assert_eq!(esp.phase.len(), 56);
|
||||
let intel = norm
|
||||
.normalize(
|
||||
&f64_vec(&fx, "intel_amplitude"),
|
||||
&f64_vec(&fx, "intel_phase"),
|
||||
HardwareType::Intel5300,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(intel.amplitude.len(), 56);
|
||||
// 64-dim geometry vector.
|
||||
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
|
||||
assert_eq!(enc.encode(&[[0.25, 0.5, 0.75]]).len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_meridian_matches_committed_golden() {
|
||||
let got = sha256_le(&meridian_output(&fixture()));
|
||||
let path = golden_dir().join("meridian_output.sha256");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(expected) => assert_eq!(
|
||||
got,
|
||||
expected.trim(),
|
||||
"native MERIDIAN hash drifted from committed golden \
|
||||
(intentional? delete the .sha256 and regenerate)"
|
||||
),
|
||||
Err(_) => {
|
||||
fs::write(&path, &got).expect("write golden sha256");
|
||||
panic!("no committed golden found; wrote {got}. Re-run to verify parity.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
"""ADR-185 P1 — AETHER binding tests, incl. the §4.1 bit-for-bit parity gate.
|
||||
|
||||
The parity test compares the binding's embedding to a committed golden VECTOR
|
||||
produced by the native-Rust reference (`tests/aether_parity.rs`), within a
|
||||
numerical tolerance. It is NOT a byte-hash: the embedding is f32 with
|
||||
transcendental ops, so exact bytes are not reproducible across the CPU
|
||||
architectures this project ships wheels for. A mismatch beyond tolerance is a
|
||||
release blocker, not a warning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import aether
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
# Cross-architecture f32 parity tolerance. The AETHER embedding is pure f32 with
|
||||
# transcendental ops that differ in the last bits across CPUs/libm, so exact
|
||||
# byte equality is not portable across the wheels this project builds. 1e-4 is
|
||||
# ~100x the observed cross-arch drift on unit-normed values and ~100x smaller
|
||||
# than any real algorithm change. Combined atol+rtol so both small and larger
|
||||
# components are bounded. (ADR-185 §4.1.)
|
||||
PARITY_ATOL = 1e-4
|
||||
PARITY_RTOL = 1e-4
|
||||
|
||||
|
||||
def load_input() -> list[list[float]]:
|
||||
return json.loads((GOLDEN / "aether_input.json").read_text())
|
||||
|
||||
|
||||
def assert_embedding_matches_golden(embedding: list[float], golden_name: str) -> None:
|
||||
"""Assert `embedding` matches the committed golden vector.
|
||||
|
||||
Two independent checks, because a per-element tolerance alone is not enough:
|
||||
- **Per element**, within atol+rtol — catches a single component drifting.
|
||||
- **Whole-vector cosine** ≥ 1 - 1e-6 — catches a *coherent* shift that stays
|
||||
inside the per-element bound on every component yet moves the vector as a
|
||||
whole (the failure mode a loose element tolerance would hide).
|
||||
|
||||
NaN/inf are rejected explicitly: `abs(nan - b) > tol` is False, so a bare
|
||||
tolerance check would silently PASS an all-NaN embedding. Every value must be
|
||||
finite first.
|
||||
"""
|
||||
golden = json.loads((GOLDEN / golden_name).read_text())
|
||||
assert len(embedding) == len(golden), (
|
||||
f"{golden_name}: length {len(embedding)} != golden {len(golden)}"
|
||||
)
|
||||
for i, x in enumerate(embedding):
|
||||
assert math.isfinite(x), f"{golden_name}: element {i} is not finite ({x})"
|
||||
|
||||
for i, (a, b) in enumerate(zip(embedding, golden)):
|
||||
tol = PARITY_ATOL + PARITY_RTOL * abs(b)
|
||||
assert abs(a - b) <= tol, (
|
||||
f"{golden_name}: element {i} diverged from native golden beyond "
|
||||
f"tolerance (got {a}, golden {b}, |Δ|={abs(a - b):.3e}) — "
|
||||
"a real regression, not cross-arch f32 drift."
|
||||
)
|
||||
|
||||
dot = sum(a * b for a, b in zip(embedding, golden))
|
||||
na = math.sqrt(sum(a * a for a in embedding))
|
||||
nb = math.sqrt(sum(b * b for b in golden))
|
||||
cosine = dot / (na * nb) if na > 0 and nb > 0 else 0.0
|
||||
assert cosine >= 1.0 - 1e-6, (
|
||||
f"{golden_name}: whole-vector cosine similarity to the golden is "
|
||||
f"{cosine:.9f} (< 1 - 1e-6) — a coherent shift the per-element "
|
||||
"tolerance did not catch."
|
||||
)
|
||||
|
||||
|
||||
def build_extractor() -> aether.EmbeddingExtractor:
|
||||
# Must match the native-Rust reference construction exactly.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
|
||||
return aether.EmbeddingExtractor(n_subcarriers=56, config=cfg)
|
||||
|
||||
|
||||
def _formula_weights(n: int) -> list[float]:
|
||||
# Byte-identical to aether_weights_parity.rs (k/65536 is exact in f32+f64).
|
||||
return [((i * 1103515245 + 12345) % 65536) / 65536.0 - 0.5 for i in range(n)]
|
||||
|
||||
|
||||
def _write_weight_file(path: Path, weights: list[float]) -> None:
|
||||
# AETHER weight format: b"AETHERW1" + u32 count + LE f32 payload.
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"AETHERW1")
|
||||
f.write(struct.pack("<I", len(weights)))
|
||||
f.write(b"".join(struct.pack("<f", w) for w in weights))
|
||||
|
||||
|
||||
def test_config_roundtrips_fields() -> None:
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
|
||||
assert cfg.d_model == 64
|
||||
assert cfg.d_proj == 128
|
||||
assert abs(cfg.temperature - 0.07) < 1e-6
|
||||
assert cfg.normalize is True
|
||||
|
||||
|
||||
# ─── Constructor input validation (raise ValueError, never panic) ─────────
|
||||
# Bad dimensions used to reach Rust and either panic (surfacing as an opaque
|
||||
# PanicException) or allocate multi-gigabyte matrices that abort the interpreter.
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [
|
||||
{"d_model": 0, "d_proj": 128},
|
||||
{"d_model": 64, "d_proj": 0},
|
||||
{"d_model": 100_000, "d_proj": 128}, # unbounded allocation guard
|
||||
{"d_model": 64, "d_proj": 100_000},
|
||||
])
|
||||
def test_aether_config_rejects_bad_dims(kwargs: dict) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
aether.AetherConfig(**kwargs)
|
||||
|
||||
|
||||
def test_extractor_rejects_zero_heads_instead_of_panicking() -> None:
|
||||
# THE crash codex flagged: n_heads=0 -> `d_model % n_heads` -> panic.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
with pytest.raises(ValueError):
|
||||
aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=0)
|
||||
|
||||
|
||||
def test_extractor_rejects_indivisible_head_count() -> None:
|
||||
# 64 % 5 != 0 trips the native assert; must be a clean ValueError.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
with pytest.raises(ValueError):
|
||||
aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [
|
||||
{"n_subcarriers": 0},
|
||||
{"n_subcarriers": 100_000},
|
||||
{"n_keypoints": 0},
|
||||
])
|
||||
def test_extractor_rejects_bad_shape(kwargs: dict) -> None:
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
base = {"n_subcarriers": 56, "config": cfg}
|
||||
base.update(kwargs)
|
||||
with pytest.raises(ValueError):
|
||||
aether.EmbeddingExtractor(**base)
|
||||
|
||||
|
||||
def test_valid_extractor_still_constructs() -> None:
|
||||
# The negatives above must not pass by making the constructor reject
|
||||
# everything: a valid config still builds and embeds.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
ext = aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=4)
|
||||
assert len(ext.embed(load_input())) == 128
|
||||
|
||||
|
||||
def test_embedding_shape_and_unit_norm() -> None:
|
||||
emb = build_extractor().embed(load_input())
|
||||
assert len(emb) == 128
|
||||
norm = math.sqrt(sum(x * x for x in emb))
|
||||
assert abs(norm - 1.0) < 1e-4, f"expected unit-norm embedding, got {norm}"
|
||||
|
||||
|
||||
def test_binding_matches_native_golden_within_tolerance() -> None:
|
||||
"""The release-blocking §4.1 gate: binding output == native Rust reference.
|
||||
|
||||
Compares to a committed golden VECTOR within a numerical tolerance, not a
|
||||
SHA-256 of the raw f32 bytes. The embedding is pure f32 and uses
|
||||
transcendental ops (ln/sqrt/cos in the Gaussian init), which are NOT
|
||||
bit-reproducible across CPU architectures or libm implementations. A
|
||||
byte-hash therefore only ever matched the one arch that generated it, and
|
||||
failed on every other wheel this project builds (aarch64, macOS-arm). The
|
||||
tolerance below (1e-4) is orders of magnitude larger than cross-arch f32
|
||||
drift yet far tighter than any real algorithm change, which moves
|
||||
unit-normed elements by ~1e-2 or more. See ADR-185 §4.1.
|
||||
"""
|
||||
emb = build_extractor().embed(load_input())
|
||||
assert_embedding_matches_golden(emb, "aether_embedding.json")
|
||||
|
||||
|
||||
def test_embedding_is_deterministic() -> None:
|
||||
ext = build_extractor()
|
||||
inp = load_input()
|
||||
assert ext.embed(inp) == ext.embed(inp)
|
||||
|
||||
|
||||
def test_cosine_similarity_self_is_one() -> None:
|
||||
v = [0.1 * i - 0.5 for i in range(32)]
|
||||
assert abs(aether.cosine_similarity(v, v) - 1.0) < 1e-5
|
||||
|
||||
|
||||
def test_cosine_similarity_orthogonal_is_zero() -> None:
|
||||
a = [1.0, 0.0, 0.0, 0.0]
|
||||
b = [0.0, 1.0, 0.0, 0.0]
|
||||
assert abs(aether.cosine_similarity(a, b)) < 1e-6
|
||||
|
||||
|
||||
def test_info_nce_loss_identical_batch_is_log_n() -> None:
|
||||
# Identical embeddings → all similarities equal → loss == ln(N).
|
||||
emb = [[1.0, 0.0, 0.0]] * 4
|
||||
loss = aether.info_nce_loss(emb, emb, 0.07)
|
||||
assert abs(loss - math.log(4)) < 0.1
|
||||
|
||||
|
||||
def test_augment_pair_preserves_shape_and_differs() -> None:
|
||||
window = load_input()
|
||||
view_a, view_b = aether.CsiAugmenter().augment_pair(window, seed=42)
|
||||
assert len(view_a) == len(window)
|
||||
assert len(view_b) == len(window)
|
||||
assert len(view_a[0]) == len(window[0])
|
||||
differs = any(
|
||||
abs(x - y) > 1e-6
|
||||
for ra, rb in zip(view_a, view_b)
|
||||
for x, y in zip(ra, rb)
|
||||
)
|
||||
assert differs, "augment_pair should return two distinct views"
|
||||
|
||||
|
||||
def test_missing_feature_message_names_the_real_fix() -> None:
|
||||
# The guard fires only on a from-source build without the feature. Its
|
||||
# message must name the real fix — rebuild with the feature — and must NOT
|
||||
# tell users to `pip install [aether]`, which is an empty extra that cannot
|
||||
# add compiled code to a built wheel.
|
||||
src = (Path(aether.__file__)).read_text()
|
||||
assert "--features aether" in src
|
||||
assert "pip install wifi-densepose[aether]" not in src
|
||||
|
||||
|
||||
# ─── Weight loading (ADR-185 §13.a) ──────────────────────────────────
|
||||
|
||||
def test_load_weights_is_used_and_matches_native_golden() -> None:
|
||||
ext = build_extractor()
|
||||
baseline = ext.embed(load_input()) # random Xavier init
|
||||
|
||||
weights = _formula_weights(ext.param_count)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "weights.bin"
|
||||
_write_weight_file(wpath, weights)
|
||||
ext.load_weights(str(wpath))
|
||||
|
||||
loaded = ext.embed(load_input())
|
||||
|
||||
# (1) The loaded weights actually take effect (not a silent no-op).
|
||||
assert any(abs(a - b) > 1e-6 for a, b in zip(baseline, loaded)), (
|
||||
"load_weights had no effect — embedding still equals the random-init baseline"
|
||||
)
|
||||
# (2) Matches the native-Rust reference that loaded the same weights, within
|
||||
# tolerance. See test_binding_matches_native_golden_within_tolerance for
|
||||
# why this is a tolerance compare and not a byte-hash.
|
||||
assert_embedding_matches_golden(loaded, "aether_loaded_embedding.json")
|
||||
|
||||
|
||||
def test_save_then_load_weights_round_trips() -> None:
|
||||
ext = build_extractor()
|
||||
inp = load_input()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "roundtrip.bin"
|
||||
ext.save_weights(str(wpath)) # serialize current (random) weights
|
||||
emb_before = ext.embed(inp)
|
||||
ext2 = build_extractor()
|
||||
ext2.load_weights(str(wpath)) # load them into a fresh extractor
|
||||
assert ext2.embed(inp) == emb_before
|
||||
|
||||
|
||||
def test_load_weights_rejects_bad_magic() -> None:
|
||||
ext = build_extractor()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "bad.bin"
|
||||
wpath.write_bytes(b"NOTAETHER" + b"\x00" * 8)
|
||||
with pytest.raises(ValueError):
|
||||
ext.load_weights(str(wpath))
|
||||
|
||||
|
||||
def test_load_weights_rejects_wrong_param_count() -> None:
|
||||
ext = build_extractor()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "short.bin"
|
||||
_write_weight_file(wpath, [0.1, 0.2, 0.3]) # far too few params
|
||||
with pytest.raises(ValueError):
|
||||
ext.load_weights(str(wpath))
|
||||
@@ -65,29 +65,7 @@ _FIXTURE_MESSAGES = [
|
||||
]
|
||||
|
||||
|
||||
#: Upgrade-request headers captured by the in-process server, so tests
|
||||
#: can assert what the client actually put on the handshake.
|
||||
_CAPTURED_UPGRADE_HEADERS: dict[str, str] = {}
|
||||
|
||||
|
||||
def _upgrade_headers(websocket: Any) -> Any:
|
||||
"""Read the handshake request headers across websockets versions
|
||||
(`websocket.request.headers` >= 14, `websocket.request_headers` <= 13)."""
|
||||
req = getattr(websocket, "request", None)
|
||||
if req is not None and getattr(req, "headers", None) is not None:
|
||||
return req.headers
|
||||
return getattr(websocket, "request_headers", {})
|
||||
|
||||
|
||||
async def _handler(websocket: Any) -> None:
|
||||
_CAPTURED_UPGRADE_HEADERS.clear()
|
||||
try:
|
||||
headers = _upgrade_headers(websocket)
|
||||
auth = headers.get("Authorization") if hasattr(headers, "get") else None
|
||||
if auth is not None:
|
||||
_CAPTURED_UPGRADE_HEADERS["Authorization"] = auth
|
||||
except Exception:
|
||||
pass
|
||||
for msg in _FIXTURE_MESSAGES:
|
||||
await websocket.send(json.dumps(msg))
|
||||
# Send one malformed frame to assert the client logs+drops it
|
||||
@@ -196,160 +174,6 @@ def test_sensing_client_decoder_directly() -> None:
|
||||
assert msg.rssi is None
|
||||
|
||||
|
||||
# ─── Auth: bearer token on the WS upgrade (issue #1395) ──────────────
|
||||
|
||||
|
||||
def _auth_header_from_kwargs(kwargs: dict) -> Any:
|
||||
"""Pull the Authorization value out of whichever header kwarg the
|
||||
installed `websockets` uses (`additional_headers` >= 14,
|
||||
`extra_headers` <= 13). Returns None if no header kwarg was passed."""
|
||||
for key in ("additional_headers", "extra_headers"):
|
||||
if key in kwargs:
|
||||
return dict(kwargs[key]).get("Authorization")
|
||||
return None
|
||||
|
||||
|
||||
class _DummyWS:
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _CapturingConnect:
|
||||
"""Stand-in for `websockets.connect` that records the kwargs it was
|
||||
called with and returns an awaitable yielding a dummy connection."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
def __call__(self, url: str, **kwargs: Any) -> Any:
|
||||
self.calls.append((url, kwargs))
|
||||
|
||||
async def _coro() -> _DummyWS:
|
||||
return _DummyWS()
|
||||
|
||||
return _coro()
|
||||
|
||||
@property
|
||||
def last_kwargs(self) -> dict:
|
||||
return self.calls[-1][1]
|
||||
|
||||
|
||||
async def test_token_from_constructor_sets_auth_header(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing", token="tok-abc"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer tok-abc"
|
||||
|
||||
|
||||
async def test_token_from_env_sets_auth_header(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok-123")
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer env-tok-123"
|
||||
|
||||
|
||||
async def test_constructor_token_overrides_env(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok")
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing", token="ctor-tok"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer ctor-tok"
|
||||
|
||||
|
||||
async def test_no_token_sends_no_auth_header(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.delenv("RUVIEW_API_TOKEN", raising=False)
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) is None
|
||||
# Auth-disabled path must not smuggle either header kwarg in.
|
||||
assert "additional_headers" not in fake.last_kwargs
|
||||
assert "extra_headers" not in fake.last_kwargs
|
||||
|
||||
|
||||
async def test_empty_token_sends_no_auth_header(monkeypatch: Any) -> None:
|
||||
"""An explicitly empty token (or empty env var) means 'no auth'."""
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.setenv("RUVIEW_API_TOKEN", "")
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header_param,expected",
|
||||
[
|
||||
("additional_headers", "additional_headers"), # websockets >= 14
|
||||
("extra_headers", "extra_headers"), # websockets <= 13
|
||||
],
|
||||
)
|
||||
def test_select_header_kwarg_across_websockets_versions(
|
||||
header_param: str, expected: str
|
||||
) -> None:
|
||||
"""Version-compat: the kwarg is chosen by inspecting the installed
|
||||
`connect` signature, so both the pre-14 (`extra_headers`) and
|
||||
post-14 (`additional_headers`) conventions resolve correctly without
|
||||
two websockets installs."""
|
||||
from wifi_densepose.client.ws import _select_header_kwarg
|
||||
|
||||
# Build a fake `connect` whose signature carries only the one kwarg
|
||||
# the emulated websockets version would expose.
|
||||
ns: dict = {}
|
||||
exec(
|
||||
f"def fake_connect(uri, *, {header_param}=None, ping_interval=None): ...",
|
||||
ns,
|
||||
)
|
||||
assert _select_header_kwarg(ns["fake_connect"]) == expected
|
||||
|
||||
|
||||
def test_select_header_kwarg_matches_installed_websockets() -> None:
|
||||
"""On whatever `websockets` is actually installed, the chosen kwarg
|
||||
must be a real parameter of `websockets.connect`."""
|
||||
import inspect
|
||||
|
||||
import websockets
|
||||
|
||||
from wifi_densepose.client.ws import _select_header_kwarg
|
||||
|
||||
chosen = _select_header_kwarg(websockets.connect)
|
||||
assert chosen in inspect.signature(websockets.connect).parameters
|
||||
|
||||
|
||||
async def test_auth_header_reaches_server_end_to_end(ws_server: str) -> None:
|
||||
"""Real in-process server: assert the bearer actually arrives on the
|
||||
upgrade request (proves the header is wired to the live handshake,
|
||||
not just the connect kwargs)."""
|
||||
async with SensingClient(ws_server, token="e2e-token") as client:
|
||||
await client.recv_one(timeout=2.0)
|
||||
assert _CAPTURED_UPGRADE_HEADERS.get("Authorization") == "Bearer e2e-token"
|
||||
|
||||
|
||||
def test_sensing_client_decoder_handles_None_subfields() -> None:
|
||||
"""When the sensing-server explicitly emits null for HR/BR (no
|
||||
measurement yet), the client should propagate None, not crash."""
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"""ADR-185 P3 — MAT binding tests, incl. the §4.1 bit-for-bit parity gate.
|
||||
|
||||
The parity test drives the same committed CSI stream through the binding's
|
||||
DisasterResponse pipeline and asserts the survivor count + triage classes
|
||||
(as a SHA-256 of a canonical string) match the native-Rust golden. A
|
||||
mismatch is a release blocker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import mat
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
def fixture() -> dict:
|
||||
return json.loads((GOLDEN / "mat_input.json").read_text())
|
||||
|
||||
|
||||
def build_response() -> mat.DisasterResponse:
|
||||
cfg = mat.DisasterConfig(
|
||||
mat.DisasterType.Earthquake,
|
||||
sensitivity=0.9,
|
||||
confidence_threshold=0.1,
|
||||
max_depth=5.0,
|
||||
)
|
||||
resp = mat.DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "parity-fixture")
|
||||
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
|
||||
return resp
|
||||
|
||||
|
||||
def run_scan(resp: mat.DisasterResponse) -> None:
|
||||
for frame in fixture()["stream"]:
|
||||
resp.push_csi_data(frame["amplitude"], frame["phase"])
|
||||
resp.scan_once()
|
||||
|
||||
|
||||
# ─── enums / config ──────────────────────────────────────────────────
|
||||
|
||||
def test_triage_priority_order() -> None:
|
||||
assert mat.TriageStatus.Immediate.priority == 1
|
||||
assert mat.TriageStatus.Delayed.priority == 2
|
||||
assert mat.TriageStatus.Unknown.priority == 5
|
||||
|
||||
|
||||
def test_disaster_config_fields() -> None:
|
||||
cfg = mat.DisasterConfig(mat.DisasterType.Flood, sensitivity=1.5, confidence_threshold=0.3)
|
||||
assert cfg.sensitivity == 1.0 # clamped to [0, 1]
|
||||
assert abs(cfg.confidence_threshold - 0.3) < 1e-9
|
||||
|
||||
|
||||
# ─── pipeline behaviour ──────────────────────────────────────────────
|
||||
|
||||
def test_scan_requires_event() -> None:
|
||||
resp = mat.DisasterResponse(mat.DisasterConfig(mat.DisasterType.Unknown))
|
||||
# No initialize_event / add_zone -> scan_cycle errors "No active event".
|
||||
with pytest.raises(ValueError):
|
||||
resp.scan_once()
|
||||
|
||||
|
||||
def test_push_csi_rejects_mismatched_lengths() -> None:
|
||||
resp = build_response()
|
||||
with pytest.raises(ValueError):
|
||||
resp.push_csi_data([1.0, 2.0], [1.0])
|
||||
|
||||
|
||||
def test_scan_detects_survivor_from_breathing_stream() -> None:
|
||||
resp = build_response()
|
||||
run_scan(resp)
|
||||
survivors = resp.survivors()
|
||||
# The synthetic breathing-modulated stream trips one detection (matches
|
||||
# the native-Rust reference).
|
||||
assert len(survivors) == 1
|
||||
s = survivors[0]
|
||||
assert isinstance(s.id, str) and len(s.id) > 0
|
||||
assert s.triage_status == mat.TriageStatus.Delayed
|
||||
assert 0.0 <= s.confidence <= 1.0
|
||||
# survivors_by_triage is consistent with the survivor's own class.
|
||||
assert len(resp.survivors_by_triage(mat.TriageStatus.Delayed)) == 1
|
||||
assert len(resp.survivors_by_triage(mat.TriageStatus.Immediate)) == 0
|
||||
|
||||
|
||||
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
|
||||
|
||||
def test_bit_for_bit_parity_with_native_rust() -> None:
|
||||
resp = build_response()
|
||||
run_scan(resp)
|
||||
survivors = resp.survivors()
|
||||
priorities = sorted(s.triage_status.priority for s in survivors)
|
||||
canon = f"count={len(survivors)};triage_priorities={priorities}"
|
||||
got = hashlib.sha256(canon.encode()).hexdigest()
|
||||
expected = (GOLDEN / "mat_result.sha256").read_text().strip()
|
||||
assert got == expected, (
|
||||
f"Python MAT result diverged from native-Rust golden "
|
||||
f"(canonical form: {canon}; {got} != {expected})"
|
||||
)
|
||||
|
||||
|
||||
def test_base_wheel_import_error_message() -> None:
|
||||
src = Path(mat.__file__).read_text()
|
||||
assert "--features mat" in src
|
||||
assert "pip install wifi-densepose[mat]" not in src
|
||||
@@ -1,161 +0,0 @@
|
||||
"""ADR-185 P2 — MERIDIAN binding tests, incl. the §4.1 bit-for-bit parity gate.
|
||||
|
||||
The parity test packs the binding's concatenated outputs (2× canonical
|
||||
frame, geometry vector, rapid-adapt LoRA weights) to little-endian f32
|
||||
bytes and asserts SHA-256 equality with the golden produced by the
|
||||
native-Rust reference (`tests/meridian_parity.rs`). A mismatch is a
|
||||
release blocker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import meridian as mer
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
def fixture() -> dict:
|
||||
return json.loads((GOLDEN / "meridian_input.json").read_text())
|
||||
|
||||
|
||||
# ─── HardwareType / HardwareNormalizer / CanonicalCsiFrame ───────────
|
||||
|
||||
def test_hardware_type_detect() -> None:
|
||||
assert mer.HardwareType.detect(64) == mer.HardwareType.Esp32S3
|
||||
assert mer.HardwareType.detect(30) == mer.HardwareType.Intel5300
|
||||
assert mer.HardwareType.detect(56) == mer.HardwareType.Atheros
|
||||
assert mer.HardwareType.detect(128) == mer.HardwareType.Generic
|
||||
|
||||
|
||||
def test_hardware_type_properties() -> None:
|
||||
assert mer.HardwareType.Esp32S3.subcarrier_count == 64
|
||||
assert mer.HardwareType.Esp32S3.mimo_streams == 1
|
||||
assert mer.HardwareType.Intel5300.mimo_streams == 3
|
||||
|
||||
|
||||
def test_normalize_shapes_and_hardware() -> None:
|
||||
fx = fixture()
|
||||
norm = mer.HardwareNormalizer()
|
||||
assert norm.canonical_subcarriers == 56
|
||||
frame = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
|
||||
assert len(frame.amplitude) == 56
|
||||
assert len(frame.phase) == 56
|
||||
assert frame.hardware_type == mer.HardwareType.Esp32S3
|
||||
|
||||
|
||||
def test_normalize_rejects_mismatched_lengths() -> None:
|
||||
norm = mer.HardwareNormalizer()
|
||||
with pytest.raises(ValueError):
|
||||
norm.normalize([1.0, 2.0], [1.0], mer.HardwareType.Generic)
|
||||
|
||||
|
||||
# ─── GeometryEncoder ─────────────────────────────────────────────────
|
||||
|
||||
def test_geometry_encode_dim_and_permutation_invariance() -> None:
|
||||
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
|
||||
aps = [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]]
|
||||
v = enc.encode(aps)
|
||||
assert len(v) == 64
|
||||
# DeepSets mean-pool is permutation-invariant.
|
||||
v_perm = enc.encode([aps[2], aps[0], aps[1]])
|
||||
assert max(abs(a - b) for a, b in zip(v, v_perm)) < 1e-5
|
||||
|
||||
|
||||
def test_geometry_encode_rejects_empty_and_bad_shape() -> None:
|
||||
enc = mer.GeometryEncoder()
|
||||
with pytest.raises(ValueError):
|
||||
enc.encode([])
|
||||
with pytest.raises(ValueError):
|
||||
enc.encode([[0.0, 1.0]]) # not 3 coords
|
||||
|
||||
|
||||
# ─── RapidAdaptation ─────────────────────────────────────────────────
|
||||
|
||||
def test_rapid_adaptation_adapt() -> None:
|
||||
fx = fixture()
|
||||
ra = mer.RapidAdaptation(
|
||||
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
|
||||
epochs=5, lr=0.001, lambda_ent=0.5,
|
||||
)
|
||||
for frame in fx["rapid_frames"]:
|
||||
ra.push_frame(frame)
|
||||
assert ra.is_ready()
|
||||
assert ra.buffer_len == 12
|
||||
res = ra.adapt()
|
||||
assert res.frames_used == 12
|
||||
assert res.adaptation_epochs == 5
|
||||
assert len(res.lora_weights) == 2 * 16 * 4 # 2 * fdim * rank
|
||||
|
||||
|
||||
def test_rapid_adaptation_rejects_bad_loss_kind() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
mer.RapidAdaptation(10, 4, loss_kind="nonsense")
|
||||
|
||||
|
||||
def test_rapid_adaptation_empty_buffer_raises() -> None:
|
||||
ra = mer.RapidAdaptation(1, 4)
|
||||
with pytest.raises(ValueError):
|
||||
ra.adapt()
|
||||
|
||||
|
||||
# ─── CrossDomainEvaluator ────────────────────────────────────────────
|
||||
|
||||
def test_cross_domain_evaluator_gap_ratio() -> None:
|
||||
ev = mer.CrossDomainEvaluator(1)
|
||||
preds = [
|
||||
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), # domain 0, err 1
|
||||
([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]), # domain 1, err 2
|
||||
]
|
||||
m = ev.evaluate(preds, [0, 1])
|
||||
assert abs(m["in_domain_mpjpe"] - 1.0) < 1e-6
|
||||
assert abs(m["cross_domain_mpjpe"] - 2.0) < 1e-6
|
||||
assert abs(m["domain_gap_ratio"] - 2.0) < 1e-6
|
||||
|
||||
|
||||
def test_mpjpe_module_fn() -> None:
|
||||
assert abs(mer.mpjpe([0.0, 0.0, 0.0], [3.0, 4.0, 0.0], 1) - 5.0) < 1e-6
|
||||
|
||||
|
||||
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
|
||||
|
||||
def test_bit_for_bit_parity_with_native_rust() -> None:
|
||||
fx = fixture()
|
||||
out: list[float] = []
|
||||
|
||||
norm = mer.HardwareNormalizer()
|
||||
esp = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
|
||||
out += list(esp.amplitude) + list(esp.phase)
|
||||
intel = norm.normalize(fx["intel_amplitude"], fx["intel_phase"], mer.HardwareType.Intel5300)
|
||||
out += list(intel.amplitude) + list(intel.phase)
|
||||
|
||||
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
|
||||
out += list(enc.encode(fx["ap_positions"]))
|
||||
|
||||
ra = mer.RapidAdaptation(
|
||||
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
|
||||
epochs=5, lr=0.001, lambda_ent=0.5,
|
||||
)
|
||||
for frame in fx["rapid_frames"]:
|
||||
ra.push_frame(frame)
|
||||
out += list(ra.adapt().lora_weights)
|
||||
|
||||
packed = b"".join(struct.pack("<f", x) for x in out)
|
||||
got = hashlib.sha256(packed).hexdigest()
|
||||
expected = (GOLDEN / "meridian_output.sha256").read_text().strip()
|
||||
assert got == expected, (
|
||||
f"Python binding MERIDIAN output diverged from native-Rust golden "
|
||||
f"({got} != {expected})"
|
||||
)
|
||||
|
||||
|
||||
def test_base_wheel_import_error_message() -> None:
|
||||
src = Path(mer.__file__).read_text()
|
||||
assert "--features meridian" in src
|
||||
assert "pip install wifi-densepose[meridian]" not in src
|
||||
@@ -185,44 +185,10 @@ def test_heart_rate_explicit_ctor() -> None:
|
||||
|
||||
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
out = hr.extract(residuals=[0.0] * 56, phases=[0.0] * 56)
|
||||
out = hr.extract(residuals=[0.0] * 56, weights=[])
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_heart_rate_extract_rejects_old_weights_keyword() -> None:
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
with pytest.raises(TypeError):
|
||||
hr.extract(residuals=[0.0] * 56, weights=[0.0] * 56)
|
||||
|
||||
|
||||
def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
|
||||
"""Drive the extractor with a synthetic 1.2 Hz sine (72 BPM) plus
|
||||
same-length phase data. This proves the binding feeds Rust's required
|
||||
`phases` slice instead of an empty vector that would keep returning None."""
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
sample_rate = 100.0
|
||||
target_freq = 1.2 # 72 BPM
|
||||
n_samples = int(60 * sample_rate)
|
||||
phases = [i * 0.01 for i in range(56)]
|
||||
|
||||
produced_estimate = False
|
||||
rng = Random(43)
|
||||
for i in range(n_samples):
|
||||
t = i / sample_rate
|
||||
base = math.sin(2.0 * math.pi * target_freq * t)
|
||||
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)]
|
||||
est = hr.extract(residuals=residuals, phases=phases)
|
||||
if est is not None:
|
||||
produced_estimate = True
|
||||
assert math.isfinite(est.value_bpm)
|
||||
assert 0.0 <= est.confidence <= 1.0
|
||||
break
|
||||
|
||||
assert produced_estimate, (
|
||||
"HeartRateExtractor never produced an estimate after 60s of synthetic data"
|
||||
)
|
||||
|
||||
|
||||
# ─── Build feature flag ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from __future__ import annotations
|
||||
# Public Python version follows the wheel version, NOT the Rust core
|
||||
# version. The Rust core version is surfaced separately as
|
||||
# `__rust_version__` for diagnostics.
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.0.0a1"
|
||||
|
||||
# Re-export the compiled module's surface. The leading underscore on
|
||||
# `_native` is intentional — it marks the binding module as internal.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""AETHER — contrastive CSI embeddings & re-identification (ADR-024, ADR-185 P1).
|
||||
|
||||
Self-supervised 128-dim L2-normalized embeddings for WiFi CSI: room
|
||||
fingerprinting, person re-identification, and anomaly scoring, computed
|
||||
entirely offline by the Rust core (no server, no network).
|
||||
|
||||
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
|
||||
bindings are shipped source-build-only for now to keep the base wheel
|
||||
small). Build from source with ``maturin ... --features aether`` (or
|
||||
``--features sota`` for all three P6 subsystems).
|
||||
|
||||
Quick start::
|
||||
|
||||
from wifi_densepose.aether import AetherConfig, EmbeddingExtractor, cosine_similarity
|
||||
|
||||
ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
|
||||
a = ext.embed(window_a) # list[float], length == config.d_proj (128)
|
||||
b = ext.embed(window_b)
|
||||
score = cosine_similarity(a, b) # re-ID similarity in [-1, 1]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import _native
|
||||
|
||||
# The AETHER symbols are compiled into `_native` only under the Rust `aether`
|
||||
# feature. The binary wheels do NOT enable it yet (ruvnet/RuView#1412);
|
||||
# it is available from a source build with the feature. Name that fix, not
|
||||
# a pip extra, which cannot add compiled code to a built wheel.
|
||||
if not hasattr(_native, "AetherConfig"):
|
||||
raise ImportError(
|
||||
"wifi_densepose.aether is not in the binary wheels yet "
|
||||
"(see ruvnet/RuView#1412). Build from source with "
|
||||
"`maturin ... --features aether` (or `--features sota`)."
|
||||
)
|
||||
|
||||
AetherConfig = _native.AetherConfig
|
||||
CsiAugmenter = _native.CsiAugmenter
|
||||
EmbeddingExtractor = _native.EmbeddingExtractor
|
||||
info_nce_loss = _native.info_nce_loss
|
||||
cosine_similarity = _native.cosine_similarity
|
||||
|
||||
__all__ = [
|
||||
"AetherConfig",
|
||||
"CsiAugmenter",
|
||||
"EmbeddingExtractor",
|
||||
"info_nce_loss",
|
||||
"cosine_similarity",
|
||||
]
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Type stubs for the AETHER bindings (ADR-185 P1).
|
||||
|
||||
Present only when the wheel is built with the ``[aether]`` extra. The
|
||||
top-level ``wifi_densepose`` package does not re-export these names, so
|
||||
``mypy --strict`` sees them only via ``from wifi_densepose.aether import ...``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
class AetherConfig:
|
||||
def __init__(
|
||||
self,
|
||||
d_model: int = ...,
|
||||
d_proj: int = ...,
|
||||
temperature: float = ...,
|
||||
normalize: bool = ...,
|
||||
) -> None: ...
|
||||
@property
|
||||
def d_model(self) -> int: ...
|
||||
@property
|
||||
def d_proj(self) -> int: ...
|
||||
@property
|
||||
def temperature(self) -> float: ...
|
||||
@property
|
||||
def normalize(self) -> bool: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class CsiAugmenter:
|
||||
def __init__(self) -> None: ...
|
||||
def augment_pair(
|
||||
self, window: list[list[float]], seed: int
|
||||
) -> tuple[list[list[float]], list[list[float]]]: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class EmbeddingExtractor:
|
||||
def __init__(
|
||||
self,
|
||||
n_subcarriers: int,
|
||||
config: AetherConfig,
|
||||
n_keypoints: int = ...,
|
||||
n_heads: int = ...,
|
||||
n_gnn_layers: int = ...,
|
||||
) -> None: ...
|
||||
def embed(self, csi_features: list[list[float]]) -> list[float]: ...
|
||||
@property
|
||||
def embedding_dim(self) -> int: ...
|
||||
@property
|
||||
def param_count(self) -> int: ...
|
||||
def load_weights(self, path: str) -> None: ...
|
||||
def save_weights(self, path: str) -> None: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
def info_nce_loss(
|
||||
embeddings_a: list[list[float]],
|
||||
embeddings_b: list[list[float]],
|
||||
temperature: float = ...,
|
||||
) -> float: ...
|
||||
def cosine_similarity(a: list[float], b: list[float]) -> float: ...
|
||||
@@ -31,10 +31,8 @@ asyncio.run(main())
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
@@ -50,33 +48,6 @@ except ImportError: # pragma: no cover
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Environment variable the sensing-server bearer token is read from by
|
||||
#: default. Mirrors the TypeScript MCP client (tools/ruview-mcp).
|
||||
TOKEN_ENV_VAR = "RUVIEW_API_TOKEN"
|
||||
|
||||
|
||||
def _select_header_kwarg(connect_fn: Any) -> str:
|
||||
"""Return the ``websockets.connect`` keyword for extra request headers.
|
||||
|
||||
The keyword was renamed inside the ``websockets>=12`` range this
|
||||
package supports: ``<= 13`` accepts ``extra_headers``, ``>= 14``
|
||||
accepts ``additional_headers``. We inspect the actual signature of
|
||||
the installed ``connect`` rather than guessing from ``__version__``,
|
||||
so a version bump that renames the kwarg again is handled by
|
||||
detection instead of raising ``TypeError`` at connect time.
|
||||
"""
|
||||
try:
|
||||
params = inspect.signature(connect_fn).parameters
|
||||
except (TypeError, ValueError): # pragma: no cover — no introspectable sig
|
||||
return "additional_headers"
|
||||
if "additional_headers" in params:
|
||||
return "additional_headers"
|
||||
if "extra_headers" in params:
|
||||
return "extra_headers"
|
||||
# Neither present (unexpected) — prefer the newer convention.
|
||||
return "additional_headers"
|
||||
|
||||
|
||||
# ─── Typed messages ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -201,18 +172,12 @@ class SensingClient:
|
||||
the ``async with`` in your own retry loop. Auto-reconnect logic is
|
||||
application-specific (e.g., "retry forever" for a long-running
|
||||
automation vs "fail fast" for a CLI tool that should exit).
|
||||
|
||||
Auth: pass ``token=`` to send ``Authorization: Bearer <token>`` on
|
||||
the WS upgrade, for sensing-servers started with ``RUVIEW_API_TOKEN``
|
||||
set. If ``token`` is omitted it defaults to the ``RUVIEW_API_TOKEN``
|
||||
environment variable; when neither is set, no header is sent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
token: Optional[str] = None,
|
||||
ping_interval: float = 20.0,
|
||||
ping_timeout: float = 20.0,
|
||||
max_size: int = 16 * 1024 * 1024,
|
||||
@@ -223,28 +188,18 @@ class SensingClient:
|
||||
"`pip install \"wifi-densepose[client]\"` to enable the client extras."
|
||||
)
|
||||
self.url = url
|
||||
# Bearer token for auth-enabled sensing-servers. Explicit
|
||||
# constructor argument wins; otherwise fall back to the
|
||||
# RUVIEW_API_TOKEN environment variable. An empty value (unset
|
||||
# env, or "") means "no auth" — no Authorization header is sent.
|
||||
self._token = token if token is not None else os.environ.get(TOKEN_ENV_VAR)
|
||||
self._ping_interval = ping_interval
|
||||
self._ping_timeout = ping_timeout
|
||||
self._max_size = max_size
|
||||
self._ws: Any = None # websockets.WebSocketClientProtocol — typed Any to avoid import cost
|
||||
|
||||
async def __aenter__(self) -> "SensingClient":
|
||||
connect_kwargs: dict[str, Any] = dict(
|
||||
self._ws = await websockets.connect(
|
||||
self.url,
|
||||
ping_interval=self._ping_interval,
|
||||
ping_timeout=self._ping_timeout,
|
||||
max_size=self._max_size,
|
||||
)
|
||||
if self._token:
|
||||
# Python (unlike the browser UI) can set Authorization
|
||||
# directly on the WS upgrade — no ticket workaround needed.
|
||||
header_kwarg = _select_header_kwarg(websockets.connect)
|
||||
connect_kwargs[header_kwarg] = {"Authorization": f"Bearer {self._token}"}
|
||||
self._ws = await websockets.connect(self.url, **connect_kwargs)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""MAT — Mass Casualty Assessment Tool (ADR-024 crate, ADR-185 P3).
|
||||
|
||||
WiFi-based disaster-survivor detection and START-protocol triage from CSI:
|
||||
ingest CSI frames, run a scan cycle, and query detected survivors by triage.
|
||||
|
||||
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
|
||||
bindings are shipped source-build-only for now to keep the base wheel
|
||||
small). Build from source with ``maturin ... --features mat`` (or
|
||||
``--features sota`` for all three P6 subsystems).
|
||||
|
||||
Quick start::
|
||||
|
||||
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
|
||||
|
||||
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
|
||||
resp = DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "Building A") # required before scanning
|
||||
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
|
||||
for amp, phase in csi_stream:
|
||||
resp.push_csi_data(amp, phase)
|
||||
resp.scan_once() # one detection cycle
|
||||
for s in resp.survivors():
|
||||
print(s.id, s.triage_status, s.confidence, s.location)
|
||||
|
||||
Honest scope (ADR-185 §3.4): the ADR's Rust-side `scan_once()` wrapper was
|
||||
unnecessary — this binding drives one cycle of the public async
|
||||
`start_scanning()` (with `continuous_monitoring` forced off) on an internal
|
||||
runtime. `initialize_event` + `add_zone` are required before `scan_once`.
|
||||
`Survivor.latest_vitals` returns the latest reading (the Rust accessor is a
|
||||
history). The detection pipeline is real but unvalidated on live rubble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import _native
|
||||
|
||||
# MAT symbols are compiled into `_native` only under the Rust `mat` feature.
|
||||
if not hasattr(_native, "DisasterResponse"):
|
||||
raise ImportError(
|
||||
"wifi_densepose.mat is not in the binary wheels yet "
|
||||
"(see ruvnet/RuView#1412). Build from source with "
|
||||
"`maturin ... --features mat` (or `--features sota`)."
|
||||
)
|
||||
|
||||
DisasterType = _native.DisasterType
|
||||
TriageStatus = _native.TriageStatus
|
||||
DisasterConfig = _native.DisasterConfig
|
||||
DisasterResponse = _native.DisasterResponse
|
||||
ScanZone = _native.ScanZone
|
||||
Survivor = _native.Survivor
|
||||
VitalSignsReading = _native.VitalSignsReading
|
||||
|
||||
__all__ = [
|
||||
"DisasterType",
|
||||
"TriageStatus",
|
||||
"DisasterConfig",
|
||||
"DisasterResponse",
|
||||
"ScanZone",
|
||||
"Survivor",
|
||||
"VitalSignsReading",
|
||||
]
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Type stubs for the MAT bindings (ADR-185 P3).
|
||||
|
||||
Present only when the wheel is built with the ``[mat]`` extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
class DisasterType(enum.Enum):
|
||||
BuildingCollapse = 0
|
||||
Earthquake = 1
|
||||
Landslide = 2
|
||||
Avalanche = 3
|
||||
Flood = 4
|
||||
MineCollapse = 5
|
||||
Industrial = 6
|
||||
TunnelCollapse = 7
|
||||
Unknown = 8
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class TriageStatus(enum.Enum):
|
||||
Immediate = 0
|
||||
Delayed = 1
|
||||
Minor = 2
|
||||
Deceased = 3
|
||||
Unknown = 4
|
||||
@property
|
||||
def priority(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class VitalSignsReading:
|
||||
@property
|
||||
def breathing_rate_bpm(self) -> float | None: ...
|
||||
@property
|
||||
def heartbeat_rate_bpm(self) -> float | None: ...
|
||||
@property
|
||||
def movement_intensity(self) -> float: ...
|
||||
@property
|
||||
def confidence(self) -> float: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class Survivor:
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
@property
|
||||
def triage_status(self) -> TriageStatus: ...
|
||||
@property
|
||||
def confidence(self) -> float: ...
|
||||
@property
|
||||
def location(self) -> tuple[float, float, float] | None: ...
|
||||
@property
|
||||
def latest_vitals(self) -> VitalSignsReading | None: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class DisasterConfig:
|
||||
def __init__(
|
||||
self,
|
||||
disaster_type: DisasterType,
|
||||
sensitivity: float = ...,
|
||||
confidence_threshold: float = ...,
|
||||
max_depth: float = ...,
|
||||
scan_interval_ms: int = ...,
|
||||
) -> None: ...
|
||||
@property
|
||||
def sensitivity(self) -> float: ...
|
||||
@property
|
||||
def confidence_threshold(self) -> float: ...
|
||||
@property
|
||||
def max_depth(self) -> float: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class ScanZone:
|
||||
@staticmethod
|
||||
def rectangle(
|
||||
name: str, min_x: float, min_y: float, max_x: float, max_y: float
|
||||
) -> ScanZone: ...
|
||||
@staticmethod
|
||||
def circle(name: str, center_x: float, center_y: float, radius: float) -> ScanZone: ...
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class DisasterResponse:
|
||||
def __init__(self, config: DisasterConfig) -> None: ...
|
||||
def initialize_event(self, x: float, y: float, description: str) -> None: ...
|
||||
def add_zone(self, zone: ScanZone) -> None: ...
|
||||
def push_csi_data(self, amplitudes: list[float], phases: list[float]) -> None: ...
|
||||
def scan_once(self) -> None: ...
|
||||
def survivors(self) -> list[Survivor]: ...
|
||||
def survivors_by_triage(self, status: TriageStatus) -> list[Survivor]: ...
|
||||
def __repr__(self) -> str: ...
|
||||
@@ -1,62 +0,0 @@
|
||||
"""MERIDIAN — cross-environment domain generalization (ADR-027, ADR-185 P2).
|
||||
|
||||
Hardware-invariant CSI normalization, geometry-conditioned deployment,
|
||||
few-shot room adaptation, and cross-domain evaluation — the tch-free
|
||||
inference/adaptation path of Project MERIDIAN, computed by the Rust core.
|
||||
|
||||
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
|
||||
bindings are shipped source-build-only for now to keep the base wheel
|
||||
small). Build from source with ``maturin ... --features meridian`` (or
|
||||
``--features sota`` for all three P6 subsystems).
|
||||
|
||||
Quick start::
|
||||
|
||||
from wifi_densepose.meridian import HardwareNormalizer, HardwareType
|
||||
|
||||
norm = HardwareNormalizer() # canonical 56 subcarriers
|
||||
hw = HardwareType.detect(64) # -> HardwareType.Esp32S3
|
||||
frame = norm.normalize(amplitude, phase, hw) # -> CanonicalCsiFrame
|
||||
print(len(frame.amplitude), frame.hardware_type)
|
||||
|
||||
Note (honest scope, ADR-185 §3.3): the ADR's ``RapidAdaptation.calibrate``
|
||||
/ ``AdaptationResult.converged`` do not exist in the Rust core — use
|
||||
``push_frame(...)`` then ``adapt()``; the result exposes ``final_loss``,
|
||||
``frames_used``, ``adaptation_epochs``. Training-time types
|
||||
(DomainFactorizer, GradientReversalLayer, VirtualDomainAugmentor) are
|
||||
out of scope for P6 (they need the deferred libtorch training tier).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import _native
|
||||
|
||||
# MERIDIAN symbols are compiled into `_native` only under the Rust
|
||||
# `meridian` feature; absent in a base wheel (ADR-185 §6 acceptance).
|
||||
if not hasattr(_native, "HardwareNormalizer"):
|
||||
raise ImportError(
|
||||
"wifi_densepose.meridian is not in the binary wheels yet "
|
||||
"(see ruvnet/RuView#1412). Build from source with "
|
||||
"`maturin ... --features meridian` (or `--features sota`)."
|
||||
)
|
||||
|
||||
HardwareType = _native.HardwareType
|
||||
CanonicalCsiFrame = _native.CanonicalCsiFrame
|
||||
HardwareNormalizer = _native.HardwareNormalizer
|
||||
MeridianGeometryConfig = _native.MeridianGeometryConfig
|
||||
GeometryEncoder = _native.GeometryEncoder
|
||||
RapidAdaptation = _native.RapidAdaptation
|
||||
AdaptationResult = _native.AdaptationResult
|
||||
CrossDomainEvaluator = _native.CrossDomainEvaluator
|
||||
mpjpe = _native.mpjpe
|
||||
|
||||
__all__ = [
|
||||
"HardwareType",
|
||||
"CanonicalCsiFrame",
|
||||
"HardwareNormalizer",
|
||||
"MeridianGeometryConfig",
|
||||
"GeometryEncoder",
|
||||
"RapidAdaptation",
|
||||
"AdaptationResult",
|
||||
"CrossDomainEvaluator",
|
||||
"mpjpe",
|
||||
]
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Type stubs for the MERIDIAN bindings (ADR-185 P2).
|
||||
|
||||
Present only when the wheel is built with the ``[meridian]`` extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
class HardwareType(enum.Enum):
|
||||
Esp32S3 = 0
|
||||
Intel5300 = 1
|
||||
Atheros = 2
|
||||
Generic = 3
|
||||
@staticmethod
|
||||
def detect(subcarrier_count: int) -> HardwareType: ...
|
||||
@property
|
||||
def subcarrier_count(self) -> int: ...
|
||||
@property
|
||||
def mimo_streams(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class CanonicalCsiFrame:
|
||||
@property
|
||||
def amplitude(self) -> list[float]: ...
|
||||
@property
|
||||
def phase(self) -> list[float]: ...
|
||||
@property
|
||||
def hardware_type(self) -> HardwareType: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class HardwareNormalizer:
|
||||
def __init__(self, canonical_subcarriers: int = ...) -> None: ...
|
||||
@staticmethod
|
||||
def detect_hardware(subcarrier_count: int) -> HardwareType: ...
|
||||
@property
|
||||
def canonical_subcarriers(self) -> int: ...
|
||||
def normalize(
|
||||
self, amplitude: list[float], phase: list[float], hardware: HardwareType
|
||||
) -> CanonicalCsiFrame: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class MeridianGeometryConfig:
|
||||
def __init__(
|
||||
self,
|
||||
n_frequencies: int = ...,
|
||||
scale: float = ...,
|
||||
geometry_dim: int = ...,
|
||||
seed: int = ...,
|
||||
) -> None: ...
|
||||
@property
|
||||
def n_frequencies(self) -> int: ...
|
||||
@property
|
||||
def scale(self) -> float: ...
|
||||
@property
|
||||
def geometry_dim(self) -> int: ...
|
||||
@property
|
||||
def seed(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class GeometryEncoder:
|
||||
def __init__(self, config: MeridianGeometryConfig | None = ...) -> None: ...
|
||||
def encode(self, ap_positions: list[list[float]]) -> list[float]: ...
|
||||
@property
|
||||
def geometry_dim(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class AdaptationResult:
|
||||
@property
|
||||
def lora_weights(self) -> list[float]: ...
|
||||
@property
|
||||
def final_loss(self) -> float: ...
|
||||
@property
|
||||
def frames_used(self) -> int: ...
|
||||
@property
|
||||
def adaptation_epochs(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class RapidAdaptation:
|
||||
def __init__(
|
||||
self,
|
||||
min_calibration_frames: int,
|
||||
lora_rank: int,
|
||||
loss_kind: str = ...,
|
||||
epochs: int = ...,
|
||||
lr: float = ...,
|
||||
lambda_ent: float = ...,
|
||||
) -> None: ...
|
||||
def push_frame(self, frame: list[float]) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
@property
|
||||
def buffer_len(self) -> int: ...
|
||||
def adapt(self) -> AdaptationResult: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class CrossDomainEvaluator:
|
||||
def __init__(self, n_joints: int) -> None: ...
|
||||
def evaluate(
|
||||
self,
|
||||
predictions: list[tuple[list[float], list[float]]],
|
||||
domain_labels: list[int],
|
||||
) -> dict[str, float]: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
def mpjpe(pred: list[float], gt: list[float], n_joints: int) -> float: ...
|
||||
@@ -273,37 +273,6 @@
|
||||
],
|
||||
"rationale": "ADR-117 §P5 — the project is registered with PyPI via API token, not OIDC Trusted Publisher. The token is sourced from GCP Secret Manager (see docs/integrations/pypi-release.md). Re-introducing the `id-token: write` permission would suggest a partial OIDC migration that won't actually work without registering the Trusted Publisher on pypi.org first — a silent regression that would 403 on the next publish.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/786"
|
||||
},
|
||||
{
|
||||
"id": "RuView#1387-rvf-filename-collision",
|
||||
"title": "training_api next_model_id(): microsecond timestamp + AtomicU64 counter keeps exported .rvf filenames collision-resistant",
|
||||
"files": ["v2/crates/wifi-densepose-sensing-server/src/training_api.rs"],
|
||||
"require": [
|
||||
"static MODEL_ID_SEQ: AtomicU64",
|
||||
"%Y%m%d_%H%M%S_%6f",
|
||||
"MODEL_ID_SEQ.fetch_add(1, Ordering::Relaxed)",
|
||||
"model_ids_are_unique_per_call"
|
||||
],
|
||||
"forbid": [
|
||||
"/%Y%m%d_%H%M%S(?!_%6f)/"
|
||||
],
|
||||
"rationale": "next_model_id() builds the exported model path as trained-{type}-{ts}-{seq}. A second-resolution timestamp (%Y%m%d_%H%M%S) alone collided for two runs finishing in the same wall-clock second, silently overwriting each other's .rvf artifact and flaking the concurrent model-writing tests on CI (fixed on this branch in 8409f434c). Uniqueness now relies on BOTH microsecond resolution (%6f) AND a process-monotonic AtomicU64 counter appended to the id. Reverting to a bare %Y%m%d_%H%M%S with no sub-second/counter disambiguator reopens the silent-overwrite regression; the forbid uses a negative lookahead so it fires only on a second-resolution format that is NOT followed by _%6f. The model_ids_are_unique_per_call test (1000 back-to-back ids) locks it in.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/1387"
|
||||
},
|
||||
{
|
||||
"id": "RuView#1387-default-wheel-budget-config",
|
||||
"title": "python wheel: empty default Cargo features + optional SOTA crates + maturin strip keep the no-extras wheel under the ADR-117 §5.4 5 MB budget",
|
||||
"files": ["python/Cargo.toml", "python/pyproject.toml"],
|
||||
"require": [
|
||||
"default = []",
|
||||
"optional = true",
|
||||
"strip = true"
|
||||
],
|
||||
"forbid": [
|
||||
"/default\\s*=\\s*\\[[^\\]]*\"(sota|aether|meridian|mat)\"/"
|
||||
],
|
||||
"rationale": "The [aether]/[meridian]/[mat]/[sota] extras map to Cargo features that link heavy crates (mat -> ort/ONNX Runtime, train -> tokio+ruvector, etc.). The DEFAULT wheel must link none of them to stay under the ADR-117 §5.4 5 MB budget, which requires: (1) `default = []` in python/Cargo.toml [features], (2) every SOTA dep declared `optional = true` so it is pulled only by its own feature, and (3) `strip = true` in pyproject [tool.maturin] to drop debug symbols. Flipping default to include a SOTA feature, or making a SOTA dep non-optional, silently balloons the default wheel. NOTE: a fix-marker is a string guard and cannot measure bytes — it protects the CONFIG that keeps the wheel small. The actual numeric 5 MB ceiling is enforced by the `wheel-size-budget` job in .github/workflows/python-ci.yml, which builds the default (no-features) wheel and fails if it exceeds the budget.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/1387"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -154,14 +154,7 @@ export default class TrainingPanel {
|
||||
};
|
||||
await trainingService[method](payload);
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
// Start was rejected (e.g. server training disabled → HTTP 409). Tear down
|
||||
// the progress socket we opened optimistically and refresh so the button
|
||||
// reflects the real (possibly disabled) state instead of a silent no-op.
|
||||
trainingService.disconnectProgressStream();
|
||||
this._set({ loading: false, error: `Training failed: ${e.message}` });
|
||||
this.refresh();
|
||||
}
|
||||
} catch (e) { this._set({ loading: false, error: `Training failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _stopTraining() {
|
||||
@@ -279,29 +272,13 @@ export default class TrainingPanel {
|
||||
form.appendChild(ir('LoRA Profile (opt.)', 'text', this.config.lora_profile_name, v => { this.config.lora_profile_name = v; }));
|
||||
s.appendChild(form);
|
||||
|
||||
// ADR-186 P5: if the server reports in-server training disabled
|
||||
// (enabled:false), the Start buttons must be disabled with a CLI tooltip —
|
||||
// never a silent no-op. Enablement is surfaced on the status payload.
|
||||
const ts = this.state.trainingStatus;
|
||||
const disabled = ts && ts.enabled === false;
|
||||
const cli = (ts && ts.cli) || 'wifi-densepose train-room';
|
||||
if (disabled) {
|
||||
const note = this._el('div', 'tp-empty',
|
||||
`In-server training is disabled on this build. Train from the CLI: ${cli}`);
|
||||
s.appendChild(note);
|
||||
}
|
||||
|
||||
const acts = this._el('div', 'tp-train-actions');
|
||||
const btns = [
|
||||
this._btn('Start Training', 'tp-btn tp-btn-success', () => this._launchTraining('startTraining', { patience: this.config.patience, base_model: this.config.base_model || undefined })),
|
||||
this._btn('Pretrain', 'tp-btn tp-btn-secondary', () => this._launchTraining('startPretraining')),
|
||||
this._btn('LoRA', 'tp-btn tp-btn-secondary', () => this._launchTraining('startLoraTraining', { base_model: this.config.base_model || undefined, profile_name: this.config.lora_profile_name || 'default' }))
|
||||
];
|
||||
btns.forEach(b => {
|
||||
b.disabled = this.state.loading || disabled;
|
||||
if (disabled) b.title = `In-server training disabled — use: ${cli}`;
|
||||
acts.appendChild(b);
|
||||
});
|
||||
btns.forEach(b => { b.disabled = this.state.loading; acts.appendChild(b); });
|
||||
s.appendChild(acts);
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
|
||||
* - Reflective floor, settings dialog, and practical data HUD
|
||||
*/
|
||||
import { withWsTicket } from '../../services/ws-ticket.js';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
@@ -463,7 +462,7 @@ class Observatory {
|
||||
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
|
||||
this.settings.dataSource = 'ws';
|
||||
this.settings.wsUrl = wsUrl;
|
||||
void this._connectWS(wsUrl);
|
||||
this._connectWS(wsUrl);
|
||||
} else {
|
||||
tryNext(i + 1);
|
||||
}
|
||||
@@ -473,13 +472,10 @@ class Observatory {
|
||||
tryNext(0);
|
||||
}
|
||||
|
||||
// async: `/ws/sensing` is gated (ADR-272); mint a single-use ticket first.
|
||||
async _connectWS(url) {
|
||||
_connectWS(url) {
|
||||
this._disconnectWS();
|
||||
let wsUrl = url;
|
||||
try { wsUrl = await withWsTicket(url); } catch { /* auth off or pre-ADR-272 server */ }
|
||||
try {
|
||||
this._ws = new WebSocket(wsUrl);
|
||||
this._ws = new WebSocket(url);
|
||||
this._ws.onopen = () => {
|
||||
console.log('[Observatory] WebSocket connected');
|
||||
this._hud.updateSourceBadge('ws', this._ws);
|
||||
|
||||
@@ -86,21 +86,6 @@ export class ApiService {
|
||||
// Process response through interceptors
|
||||
const processedResponse = await this.processResponse(response, url);
|
||||
|
||||
// NOTE: there is deliberately no step-up re-authentication branch here.
|
||||
//
|
||||
// An earlier revision caught the server's RFC 6750 "reauthentication
|
||||
// required" challenge and redirected to /oauth/start. That challenge can
|
||||
// never be issued to a browser: browser sign-in requests `sensing:read`
|
||||
// only and always will (see BROWSER_SIGNIN_SCOPE), so no browser session
|
||||
// holds `sensing:admin`, so the freshness gate the challenge announces is
|
||||
// never reached. Admin work goes through the CLI or a pasted bearer.
|
||||
//
|
||||
// Removed rather than left inert, because it was not merely dead — it
|
||||
// ended in a promise that never settles. If any other 401 ever grew that
|
||||
// header, every caller awaiting this would hang forever with no error.
|
||||
// The server-side guard stays as a fail-closed backstop; the client has
|
||||
// nothing to do about a flow that does not exist.
|
||||
|
||||
// Handle errors
|
||||
if (!processedResponse.ok) {
|
||||
const error = await processedResponse.json().catch(() => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user