mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9dc0c726d | |||
| 99700c7851 | |||
| 89babb00a9 | |||
| 544b746895 | |||
| fac8cbc129 | |||
| 788d685b9f | |||
| bc0e8fd031 | |||
| 2febbb813c | |||
| 3ed43e9a2f | |||
| 56327d0931 | |||
| 1ed0bc57ef | |||
| f7cc68bd5c | |||
| 89cceaf835 | |||
| 6ce50d5158 | |||
| c72bbc15dd | |||
| 9b9754778f | |||
| 43737941cb | |||
| 4a704acc02 | |||
| 9299a3b137 | |||
| 347698b67c | |||
| 705a167ffe | |||
| b09625ece7 | |||
| 0547fd7344 | |||
| f67a880a1a | |||
| 7a05417493 | |||
| 3347e258e6 | |||
| eb68e07a2c | |||
| 6300b1cbd2 | |||
| 7d6d66941a | |||
| d9dfea2dac | |||
| 6d3fb88677 | |||
| 12635a85b2 | |||
| 88bd88ed0f | |||
| 714dae9a2c | |||
| 31fb3d53f6 | |||
| 408caf35fa | |||
| c2bd33e649 | |||
| 92cbeb0c34 | |||
| 499cec7914 | |||
| 6fd185f8ce | |||
| 36a27b3211 | |||
| 82d5c73396 | |||
| 34d929ae39 | |||
| 65da488add | |||
| e0cb7ed3b3 | |||
| 8409f434c9 | |||
| d99ea153ff | |||
| a47bb71b22 | |||
| f74e73ccf7 | |||
| 99fea9df9b | |||
| daa82092a0 | |||
| 7ed57f0415 | |||
| 65648fe4c2 | |||
| a4a3f456e1 | |||
| 0f405213d3 | |||
| 1c9727f9cf | |||
| 189ac9dfb0 | |||
| 569cd237fb | |||
| d060998e3b | |||
| 5426fe830c | |||
| b1417fb6e5 | |||
| 1fb5397ddf | |||
| dfc4c1abd6 | |||
| cc153e8b56 | |||
| cca5bd8113 | |||
| c6b4d36ba3 | |||
| abf6d2aee0 | |||
| 601370d61a | |||
| 5dbb7f0d02 | |||
| f8ab1fef94 | |||
| aa7cb449bc | |||
| 47dbdb29c0 | |||
| 6ee1b55896 | |||
| 76c80c33d7 | |||
| 232b1c79f6 | |||
| 8a5af5dad4 | |||
| c7b3f0bcc5 | |||
| 8c0bdaef92 | |||
| 1692b16f6e | |||
| 4bab48e15f | |||
| 6f0114c488 | |||
| c8e990c36f | |||
| 7925aa94ab | |||
| c2cf180afe | |||
| 8c232d0894 | |||
| 82c1b8fdf8 | |||
| 9dceb976c7 | |||
| 7b244bdc8c | |||
| 90667d0f1d | |||
| 8c1d3d772a | |||
| 83f549d308 | |||
| d59ca00baa | |||
| da81eab714 | |||
| d840ced2db | |||
| 2ddb6a7b02 | |||
| b5ce60081b | |||
| 0a282d0870 |
+34
-22
@@ -1,14 +1,10 @@
|
||||
name: Continuous Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
workflow_run:
|
||||
workflows: ["Continuous Integration"]
|
||||
workflows: ["wifi-densepose sensing-server → Docker Hub + ghcr.io"]
|
||||
types:
|
||||
- completed
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
@@ -19,6 +15,11 @@ 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
|
||||
@@ -27,7 +28,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
IMAGE_NAME: ruvnet/wifi-densepose
|
||||
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
|
||||
|
||||
jobs:
|
||||
@@ -35,7 +36,9 @@ jobs:
|
||||
pre-deployment:
|
||||
name: Pre-deployment Checks
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch'
|
||||
if: |
|
||||
(github.event_name == 'workflow_run' && 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 }}
|
||||
@@ -43,6 +46,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Determine deployment environment
|
||||
@@ -50,14 +54,12 @@ jobs:
|
||||
env:
|
||||
# Use environment variable to prevent shell injection
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
|
||||
GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
elif [[ "$PUBLISHED_REF" == v* ]]; then
|
||||
echo "environment=production" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
@@ -65,16 +67,23 @@ 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.ref }}" == refs/tags/v* ]]; then
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
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
|
||||
else
|
||||
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
echo "tag=sha-${PUBLISHED_SHA:0:7}" >> $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:
|
||||
@@ -129,7 +138,10 @@ jobs:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: [pre-deployment, deploy-staging]
|
||||
if: needs.pre-deployment.outputs.deploy_env == 'production' || (github.ref == 'refs/tags/v*' && needs.deploy-staging.result == 'success')
|
||||
if: |
|
||||
always() &&
|
||||
needs.pre-deployment.result == 'success' &&
|
||||
needs.pre-deployment.outputs.deploy_env == 'production'
|
||||
environment:
|
||||
name: production
|
||||
url: https://wifi-densepose.com
|
||||
@@ -210,7 +222,7 @@ jobs:
|
||||
# kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0
|
||||
|
||||
- name: Upload deployment artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: production-deployment-${{ github.run_number }}
|
||||
path: |
|
||||
@@ -260,7 +272,7 @@ jobs:
|
||||
post-deployment:
|
||||
name: Post-deployment Monitoring
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy-staging, deploy-production]
|
||||
needs: [pre-deployment, deploy-staging, deploy-production]
|
||||
if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success')
|
||||
steps:
|
||||
- name: Monitor deployment health
|
||||
@@ -281,7 +293,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Update deployment status
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
|
||||
@@ -300,7 +312,7 @@ jobs:
|
||||
notify:
|
||||
name: Notify Deployment Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy-staging, deploy-production, post-deployment]
|
||||
needs: [pre-deployment, deploy-staging, deploy-production, post-deployment]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Notify Slack on success
|
||||
@@ -332,7 +344,7 @@ jobs:
|
||||
|
||||
- name: Create deployment issue on failure
|
||||
if: needs.deploy-production.result == 'failure'
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
@@ -355,4 +367,4 @@ jobs:
|
||||
**Logs:** Check the workflow run for detailed error messages.
|
||||
`,
|
||||
labels: ['deployment', 'production', 'urgent']
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,8 +88,6 @@ 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
|
||||
@@ -146,7 +144,10 @@ jobs:
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
run: cargo test -p wifi-densepose-worldmodel --no-default-features
|
||||
run: >-
|
||||
cargo test
|
||||
--manifest-path crates/worldgraph/wifi-densepose-worldmodel/Cargo.toml
|
||||
--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
|
||||
@@ -170,6 +171,41 @@ 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:
|
||||
@@ -249,7 +285,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
files: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
@@ -500,4 +536,4 @@ jobs:
|
||||
**Docker Image:**
|
||||
`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}`
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: false
|
||||
|
||||
@@ -52,14 +52,16 @@ jobs:
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_limit_kb: 1100
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
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_limit_kb: 1100
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
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)
|
||||
@@ -67,7 +69,8 @@ jobs:
|
||||
target: esp32c6
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
artifact_app: esp32-csi-node-c6.bin
|
||||
artifact_pt: partition-table-c6.bin
|
||||
|
||||
@@ -96,18 +99,23 @@ jobs:
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
|
||||
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
|
||||
- name: Verify binary size budget
|
||||
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,14 +13,29 @@
|
||||
# 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. 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.
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
name: pip-release
|
||||
|
||||
@@ -67,7 +82,7 @@ jobs:
|
||||
arch: x86_64
|
||||
- os: ubuntu-latest
|
||||
arch: aarch64
|
||||
- os: macos-13 # x86_64 runner
|
||||
- os: macos-15-intel # x86_64 runner
|
||||
arch: x86_64
|
||||
- os: macos-14 # arm64 runner
|
||||
arch: arm64
|
||||
@@ -136,6 +151,46 @@ 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)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
@@ -220,18 +275,29 @@ jobs:
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
publish-v2:
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
name: Publish wifi-densepose + ruview
|
||||
needs: [build-wheels, build-sdist, build-ruview]
|
||||
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:
|
||||
@@ -241,12 +307,14 @@ 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.PYPI_API_TOKEN }}
|
||||
password: ${{ secrets.TESTPYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
@@ -257,6 +325,7 @@ jobs:
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
verbose: true
|
||||
|
||||
publish-tombstone:
|
||||
name: Publish v1.99 tombstone
|
||||
@@ -274,12 +343,14 @@ 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.PYPI_API_TOKEN }}
|
||||
password: ${{ secrets.TESTPYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# 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 no auth (LAN-mode default).
|
||||
# 3. /api/v1/info returns 200 with the explicit trusted-LAN opt-in.
|
||||
# 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,11 +124,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm "$IMAGE" sh -c \
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
|
||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
||||
docker run -d --name sm -p 3000:3000 \
|
||||
-e CSI_SOURCE=simulated \
|
||||
-e RUVIEW_ALLOW_UNAUTHENTICATED=1 \
|
||||
"$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
|
||||
|
||||
@@ -8,10 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha` → `5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
|
||||
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
|
||||
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
|
||||
|
||||
### Deprecated
|
||||
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
|
||||
|
||||
### Fixed
|
||||
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
|
||||
- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests.
|
||||
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10–150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
|
||||
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
|
||||
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 40–45 pps).
|
||||
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1–O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn` — `ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1–O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1–D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
|
||||
- **Empty-room field-model calibration collected nothing on real HT40 nodes — raw 128-wide frames rejected by the 56-tone model (follow-up to the deadlock fix below).** Once the status-gate deadlock was fixed, `maybe_feed_calibration` reached `feed_calibration`, but a real ESP32 HT40 node streams 128-wide amplitude vectors while the single-link `FieldModel` is the canonical 56-tone grid — so `LinkStats::update` returned `DimensionMismatch`, `feed_calibration` bubbled the error, and `maybe_feed_calibration` swallowed it at `debug` level. Net effect: `frame_count` stayed pinned at 0 on live hardware even though presence/motion/vitals (which read the global history) worked fine. Fixed by resampling each frame onto the model's canonical 56-tone grid via `HardwareNormalizer::resample_to_canonical` before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by `field_bridge::maybe_feed_calibration_resamples_wide_frames_and_accumulates` (128-wide frame → Collecting + count 1; fails on old code). Verified live on the ESP32-S3 deployment.
|
||||
- **Empty-room field-model calibration could never start — `/api/v1/calibration/*` was a dead endpoint (frame count pinned at 0).** `POST /calibration/start` creates the `FieldModel` in `Uncalibrated`, but the per-frame server feed `field_bridge::maybe_feed_calibration` only fed observations while the model was **already** `Collecting` — and the *only* thing that sets `Collecting` is `feed_calibration` itself (on its first fed frame). The two gates deadlocked: nothing ever fed the first frame, so `calibration_frame_count` stayed 0, `status` never left `Uncalibrated`, and the SVD room eigenstructure (eigenvalue-based person counting / localization) could never calibrate — observed live as `{"status":"Uncalibrated","frame_count":0}` never advancing on a streaming ESP32 node. Fixed the guard to feed while `Uncalibrated | Collecting` so the first frame flips the model to `Collecting` and the count advances. Also made `calibration_stop` return a structured `{success:false, frame_count, frames_needed}` (with a new `FieldModel::min_calibration_frames()` accessor) instead of an opaque 500 when finalized before enough empty-room frames accumulate. Pinned by `field_bridge::maybe_feed_calibration_advances_uncalibrated_to_collecting` (asserts `Uncalibrated → Collecting` + count 0 → 1 → 2; fails on old code). Presence/motion/vitals were unaffected — they use the separate auto rolling baseline, not the field model.
|
||||
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
|
||||
- **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
|
||||
- **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4).
|
||||
@@ -30,6 +42,7 @@ 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.
|
||||
|
||||
@@ -93,6 +93,8 @@ 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/seed">
|
||||
<img src="assets/seed.png" alt="Cognitum Seed" width="100%">
|
||||
<a href="https://cognitum.one/marketplace/musica">
|
||||
<img src="assets/musica-promo.png" alt="Cognitum Musica" 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. 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 (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 |
|
||||
> | 🚶 **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,10 +128,12 @@ 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), 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 — 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 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`
|
||||
@@ -143,7 +145,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</em>
|
||||
<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>
|
||||
<br><br>
|
||||
<a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a>
|
||||
|
|
||||
@@ -155,7 +157,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).
|
||||
> **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).)
|
||||
>
|
||||
> **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).
|
||||
|
||||
@@ -202,7 +204,26 @@ 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-supervised-pose-finetune.md) phases P7–P9 for the camera-supervised fine-tune path.
|
||||
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.
|
||||
|
||||
|
||||
## 🧩 Edge Module Catalog
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# ⚠️ 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,3 +1,19 @@
|
||||
> ## ⚠️ 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,6 +2,7 @@
|
||||
Health check API endpoints
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import psutil
|
||||
from typing import Dict, Any, Optional
|
||||
@@ -168,7 +169,7 @@ async def health_check(request: Request):
|
||||
overall_status = "degraded"
|
||||
|
||||
# Get system metrics
|
||||
system_metrics = get_system_metrics()
|
||||
system_metrics = await asyncio.to_thread(get_system_metrics)
|
||||
|
||||
uptime_seconds = (datetime.now() - _APP_START_TIME).total_seconds()
|
||||
|
||||
@@ -263,11 +264,12 @@ async def get_health_metrics(
|
||||
):
|
||||
"""Get detailed system metrics."""
|
||||
try:
|
||||
metrics = get_system_metrics()
|
||||
metrics = await asyncio.to_thread(get_system_metrics)
|
||||
|
||||
# Add additional metrics if authenticated
|
||||
if current_user:
|
||||
metrics.update(get_detailed_metrics())
|
||||
detailed = await asyncio.to_thread(get_detailed_metrics)
|
||||
metrics.update(detailed)
|
||||
|
||||
return {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
@@ -300,7 +302,7 @@ def get_system_metrics() -> Dict[str, Any]:
|
||||
"""Get basic system metrics."""
|
||||
try:
|
||||
# CPU metrics
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
cpu_count = psutil.cpu_count()
|
||||
|
||||
# Memory metrics
|
||||
|
||||
@@ -180,21 +180,24 @@ class MetricsService:
|
||||
async def _collect_system_metrics(self):
|
||||
"""Collect system-level metrics."""
|
||||
try:
|
||||
# CPU usage
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
# 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
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After 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 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
|
||||
| MediaTek MT7661 | mt76 | **Unverified** — no supported public CSI capture API was found in upstream `mt76` or public MediaTek SDK material | Yes | Research only |
|
||||
|
||||
2. **CSI extraction architecture** (Linux kernel driver modification):
|
||||
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,687 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,486 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,148 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,75 @@
|
||||
# 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/)
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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.
|
||||
+10
-1
@@ -1,6 +1,15 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
|
||||
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.)
|
||||
|
||||
## Why ADRs?
|
||||
|
||||
|
||||
@@ -425,7 +425,7 @@ pub enum WifiChipset {
|
||||
BroadcomBcm43455,
|
||||
/// Realtek RTL8822CS via modified rtw88 driver.
|
||||
RealtekRtl8822cs,
|
||||
/// MediaTek MT7661 via mt76 driver modification.
|
||||
/// Proposed MediaTek MT7661 research target; no public CSI export is verified.
|
||||
MediatekMt7661,
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ pub struct Esp32CompatFrame {
|
||||
```
|
||||
|
||||
**Domain Services:**
|
||||
- `CsiExtractionService` — Reads raw CSI from patched driver via Netlink socket (BCM43455), procfs (RTL8822CS), or UDP (MT7661)
|
||||
- `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.
|
||||
- `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 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.
|
||||
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.
|
||||
|
||||
### Armbian Platform ACL
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ Operations doc for the `.github/workflows/pip-release.yml` CI workflow.
|
||||
|
||||
## Auth
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
## Refreshing the token
|
||||
|
||||
@@ -47,16 +50,19 @@ 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. `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.
|
||||
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/`.
|
||||
|
||||
## Off-loop manual gates
|
||||
|
||||
- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256`
|
||||
from the v2 Rust pipeline before any v2 publish.
|
||||
- **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.
|
||||
- **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:`
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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.
|
||||
+31
-1
@@ -1141,7 +1141,20 @@ 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 | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
|
||||
| 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).
|
||||
|
||||
### Download
|
||||
|
||||
@@ -1861,6 +1874,23 @@ 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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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.2+
|
||||
# Requires ESP-IDF v5.4+
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
set(EXTRA_COMPONENT_DIRS "")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
|
||||
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
|
||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||
[](../../LICENSE)
|
||||
[](#memory-budget)
|
||||
@@ -48,10 +48,18 @@ with `--flash_size 4MB`.
|
||||
# From the repository root:
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||
```
|
||||
|
||||
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
|
||||
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
|
||||
> display support in, and the runtime panel probe false-positives on boards
|
||||
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
|
||||
> collapses CSI yield to 0 pps. See the header of
|
||||
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
|
||||
> build command.
|
||||
|
||||
### 2. Flash
|
||||
|
||||
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
||||
@@ -105,6 +113,8 @@ 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
|
||||
@@ -250,7 +260,7 @@ Offset Size Field
|
||||
# From the repository root:
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||
```
|
||||
|
||||
@@ -268,7 +278,7 @@ To change Kconfig settings before building:
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 docker run --rm -it \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"idf.py set-target esp32s3 && idf.py menuconfig"
|
||||
```
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ 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++;
|
||||
@@ -102,7 +104,10 @@ static void wifi_init_sta(void)
|
||||
|
||||
wifi_config_t wifi_config = {
|
||||
.sta = {
|
||||
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
|
||||
/* 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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# DevKitC-1 (display-less) production overlay.
|
||||
#
|
||||
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
|
||||
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
|
||||
# SH8601 init sequence reports success, display_is_active() returns true, and
|
||||
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
|
||||
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
|
||||
# out makes has_display constant-false so the upgrade always applies.
|
||||
#
|
||||
# Build (from repo root, per README "Docker — the only reliable method"):
|
||||
# MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
# espressif/idf:v5.4 bash -c \
|
||||
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
|
||||
|
||||
# CONFIG_DISPLAY_ENABLE is not set
|
||||
@@ -1 +1 @@
|
||||
0.7.0
|
||||
0.8.4
|
||||
|
||||
@@ -18,6 +18,8 @@ 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
+3072
-24
File diff suppressed because it is too large
Load Diff
+69
-2
@@ -23,6 +23,27 @@ 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).
|
||||
@@ -50,6 +71,52 @@ 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]
|
||||
# Doc-test infrastructure for the Python-facing examples in the bound
|
||||
# Rust functions. Lands properly in P2 once #[pyfunction]s exist to test.
|
||||
# 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"
|
||||
|
||||
@@ -43,6 +43,29 @@ 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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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
|
||||
@@ -0,0 +1,29 @@
|
||||
"""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, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
|
||||
hr.extract(residuals=residuals, weights=weights)
|
||||
residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
|
||||
hr.extract(residuals=residuals, phases=phases)
|
||||
|
||||
def _one_frame():
|
||||
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
|
||||
return hr.extract(residuals=residuals, weights=weights)
|
||||
residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
|
||||
return hr.extract(residuals=residuals, phases=phases)
|
||||
|
||||
benchmark(_one_frame)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""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()
|
||||
+16
-2
@@ -10,7 +10,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wifi-densepose"
|
||||
version = "2.0.0a1"
|
||||
version = "2.0.0"
|
||||
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 :: 3 - Alpha",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
@@ -48,6 +48,20 @@ 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.0a1"
|
||||
version = "2.0.0"
|
||||
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 :: 3 - Alpha",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"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 an alpha-pin `pip install ruview`
|
||||
# always gets a compatible wifi-densepose.
|
||||
"wifi-densepose==2.0.0a1",
|
||||
# Pin to the matching v2 release so `pip install ruview` always gets a
|
||||
# compatible wifi-densepose.
|
||||
"wifi-densepose==2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
client = ["wifi-densepose[client]==2.0.0a1"]
|
||||
client = ["wifi-densepose[client]==2.0.0"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/ruvnet/RuView"
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
//! 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,7 +242,22 @@ impl PyBreathingExtractor {
|
||||
// ─── HeartRateExtractor ──────────────────────────────────────────────
|
||||
|
||||
/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude
|
||||
/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection.
|
||||
/// 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)
|
||||
/// ```
|
||||
#[pyclass(name = "HeartRateExtractor")]
|
||||
pub struct PyHeartRateExtractor {
|
||||
inner: HeartRateExtractor,
|
||||
@@ -265,10 +280,17 @@ impl PyHeartRateExtractor {
|
||||
Self { inner: HeartRateExtractor::esp32_default() }
|
||||
}
|
||||
|
||||
/// 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));
|
||||
/// 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));
|
||||
est.map(PyVitalEstimate::from_rust)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
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;
|
||||
@@ -43,6 +49,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -85,5 +97,23 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[-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]
|
||||
@@ -0,0 +1 @@
|
||||
[[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]]
|
||||
@@ -0,0 +1 @@
|
||||
[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
@@ -0,0 +1 @@
|
||||
ae46351e28c01161c3c20f1a3134d8e32fbbef0cda2fdb9c3a27984da0ce026d
|
||||
@@ -0,0 +1 @@
|
||||
{"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]]}
|
||||
@@ -0,0 +1 @@
|
||||
0486402d5a860f459a319cd779ca44a112d8543442ae9ce9eb7b1a01780aee4b
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"""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,7 +65,29 @@ _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
|
||||
@@ -174,6 +196,160 @@ 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."""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""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
|
||||
@@ -0,0 +1,161 @@
|
||||
"""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,10 +185,44 @@ 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, weights=[])
|
||||
out = hr.extract(residuals=[0.0] * 56, phases=[0.0] * 56)
|
||||
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.0a1"
|
||||
__version__ = "2.0.0"
|
||||
|
||||
# Re-export the compiled module's surface. The leading underscore on
|
||||
# `_native` is intentional — it marks the binding module as internal.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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,8 +31,10 @@ 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
|
||||
|
||||
@@ -48,6 +50,33 @@ 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 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -172,12 +201,18 @@ 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,
|
||||
@@ -188,18 +223,28 @@ 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":
|
||||
self._ws = await websockets.connect(
|
||||
self.url,
|
||||
connect_kwargs: dict[str, Any] = dict(
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""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: ...
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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,6 +273,37 @@
|
||||
],
|
||||
"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,7 +154,14 @@ export default class TrainingPanel {
|
||||
};
|
||||
await trainingService[method](payload);
|
||||
await this.refresh();
|
||||
} catch (e) { this._set({ loading: false, error: `Training failed: ${e.message}` }); }
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
async _stopTraining() {
|
||||
@@ -272,13 +279,29 @@ 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; acts.appendChild(b); });
|
||||
btns.forEach(b => {
|
||||
b.disabled = this.state.loading || disabled;
|
||||
if (disabled) b.title = `In-server training disabled — use: ${cli}`;
|
||||
acts.appendChild(b);
|
||||
});
|
||||
s.appendChild(acts);
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - 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';
|
||||
|
||||
@@ -462,7 +463,7 @@ class Observatory {
|
||||
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
|
||||
this.settings.dataSource = 'ws';
|
||||
this.settings.wsUrl = wsUrl;
|
||||
this._connectWS(wsUrl);
|
||||
void this._connectWS(wsUrl);
|
||||
} else {
|
||||
tryNext(i + 1);
|
||||
}
|
||||
@@ -472,10 +473,13 @@ class Observatory {
|
||||
tryNext(0);
|
||||
}
|
||||
|
||||
_connectWS(url) {
|
||||
// async: `/ws/sensing` is gated (ADR-272); mint a single-use ticket first.
|
||||
async _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(url);
|
||||
this._ws = new WebSocket(wsUrl);
|
||||
this._ws.onopen = () => {
|
||||
console.log('[Observatory] WebSocket connected');
|
||||
this._hud.updateSourceBadge('ws', this._ws);
|
||||
|
||||
@@ -86,6 +86,21 @@ 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(() => ({
|
||||
@@ -147,4 +162,17 @@ export class ApiService {
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const apiService = new ApiService();
|
||||
export const apiService = new ApiService();
|
||||
|
||||
// Storage key shared with the QuickSettings "API Access" panel.
|
||||
export const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
|
||||
|
||||
// Apply a previously-saved bearer token at module load — before app init
|
||||
// dispatches its first request — so a configured RUVIEW_API_TOKEN works from
|
||||
// the very first /api/v1/* call. The server only ever checks the
|
||||
// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally
|
||||
// never puts the token in a URL query string.
|
||||
try {
|
||||
const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY);
|
||||
if (storedToken) apiService.setAuthToken(storedToken);
|
||||
} catch { /* storage unavailable (private browsing etc.) */ }
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withWsTicket } from './ws-ticket.js';
|
||||
/**
|
||||
* Sensing WebSocket Service
|
||||
*
|
||||
@@ -65,7 +66,7 @@ class SensingService {
|
||||
|
||||
/** Start the service (connect or simulate). */
|
||||
start() {
|
||||
this._connect();
|
||||
void this._connect();
|
||||
}
|
||||
|
||||
/** Stop the service entirely. */
|
||||
@@ -120,13 +121,26 @@ class SensingService {
|
||||
|
||||
// ---- Connection --------------------------------------------------------
|
||||
|
||||
_connect() {
|
||||
// async because the server gates `/ws/sensing` (ADR-272) and a browser
|
||||
// cannot set an Authorization header on an upgrade — so we mint a
|
||||
// single-use ticket first. Minted per connect attempt, never cached: a
|
||||
// ticket is valid once and expires in seconds, so reusing one across
|
||||
// reconnects would fail on the second attempt.
|
||||
async _connect() {
|
||||
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
|
||||
|
||||
this._setState('connecting');
|
||||
|
||||
let url = SENSING_WS_URL;
|
||||
try {
|
||||
this._ws = new WebSocket(SENSING_WS_URL);
|
||||
url = await withWsTicket(SENSING_WS_URL);
|
||||
} catch {
|
||||
// Ticket minting is best-effort: against a server with auth off, or one
|
||||
// predating ADR-272, connecting without a ticket is correct.
|
||||
}
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.warn('[Sensing] WebSocket constructor failed:', err.message);
|
||||
this._fallbackToSimulation();
|
||||
@@ -184,7 +198,7 @@ class SensingService {
|
||||
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
this._connect();
|
||||
void this._connect();
|
||||
}, delay);
|
||||
|
||||
// Only start simulation after several failed attempts so a brief hiccup
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withWsTicket } from './ws-ticket.js';
|
||||
// WebSocket Client for Three.js Visualization - WiFi DensePose
|
||||
// Default endpoint is `/ws/sensing` on the same host the page was served from.
|
||||
// Callers (e.g. viz.html) usually pass an explicit `url` derived from
|
||||
@@ -47,7 +48,9 @@ export class WebSocketClient {
|
||||
}
|
||||
|
||||
// Attempt to connect
|
||||
connect() {
|
||||
// async: `/ws/*` is gated (ADR-272) and a browser cannot set an
|
||||
// Authorization header on an upgrade, so mint a single-use ticket first.
|
||||
async connect() {
|
||||
if (this.state === 'connecting' || this.state === 'connected') {
|
||||
console.warn('[WS-VIZ] Already connected or connecting');
|
||||
return;
|
||||
@@ -56,8 +59,12 @@ export class WebSocketClient {
|
||||
this._setState('connecting');
|
||||
console.log(`[WS-VIZ] Connecting to ${this.url}`);
|
||||
|
||||
// Per attempt, never cached — a ticket is single-use and short-lived.
|
||||
let url = this.url;
|
||||
try { url = await withWsTicket(this.url); } catch { /* auth off or pre-ADR-272 server */ }
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => this._handleOpen();
|
||||
@@ -235,7 +242,7 @@ export class WebSocketClient {
|
||||
console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect();
|
||||
void this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Single-use WebSocket tickets (ADR-272).
|
||||
//
|
||||
// A browser's WebSocket constructor cannot set an `Authorization` header on the
|
||||
// upgrade request. That used to mean the sensing WebSocket stayed reachable
|
||||
// with no credential even when the server had auth switched on — the REST
|
||||
// control plane was locked while the live sensing stream was open.
|
||||
//
|
||||
// The server now gates `/ws/*` and `/api/v1/stream/pose`. Browsers exchange
|
||||
// their stored bearer token at `POST /api/v1/ws-ticket` — an ordinary request,
|
||||
// where headers DO work — for a short-lived, single-use ticket, and pass that
|
||||
// as `?ticket=` on the socket URL.
|
||||
//
|
||||
// Why a token in a URL is acceptable here when it normally is not: the ticket
|
||||
// is consumed on first use, lives ~30 seconds, and authorizes one WebSocket
|
||||
// and nothing else. It cannot be replayed against /api/v1/*. The long-lived
|
||||
// bearer token is still never put in a URL.
|
||||
|
||||
import { API_TOKEN_STORAGE_KEY } from './api.service.js';
|
||||
|
||||
function storedToken() {
|
||||
try {
|
||||
return localStorage.getItem(API_TOKEN_STORAGE_KEY) || null;
|
||||
} catch {
|
||||
// Private browsing / storage disabled — treat as "no token configured".
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a ticket. Returns null when no token is configured (auth is off, so no
|
||||
* ticket is needed) or when the server does not offer the endpoint.
|
||||
*/
|
||||
async function mintTicket() {
|
||||
const token = storedToken();
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/ws-ticket', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// 404 means a server predating ADR-272: it still exempts WebSockets, so
|
||||
// connecting without a ticket is correct there. Treated as "no ticket
|
||||
// needed" rather than as an error, so the UI works against both.
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) {
|
||||
console.warn('[ws-ticket] mint failed:', resp.status);
|
||||
return null;
|
||||
}
|
||||
const body = await resp.json();
|
||||
return body.ticket || null;
|
||||
} catch (err) {
|
||||
// Offline, or the server is down. The socket attempt will fail on its own
|
||||
// and the caller's reconnect logic handles it; failing loudly here would
|
||||
// just duplicate that.
|
||||
console.warn('[ws-ticket] mint error:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `url` with a freshly minted ticket appended, or unchanged when no
|
||||
* ticket is available or needed.
|
||||
*
|
||||
* Always call this immediately before opening the socket — a ticket expires in
|
||||
* seconds and is valid exactly once, so one must never be cached or reused
|
||||
* across reconnects.
|
||||
*
|
||||
* @param {string} url ws:// or wss:// URL
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function withWsTicket(url) {
|
||||
const ticket = await mintTicket();
|
||||
if (!ticket) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Executed tests for the WebSocket ticket helper (ADR-272).
|
||||
//
|
||||
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
|
||||
// (a directory argument does not work — Node resolves it as a module)
|
||||
//
|
||||
// WHAT THIS IS AND IS NOT.
|
||||
// This EXECUTES the module in Node with stubbed `fetch` and `localStorage`. It
|
||||
// is strictly more than the `node --check` syntax pass this file replaces, and
|
||||
// strictly less than a browser: it does not exercise a real WebSocket upgrade,
|
||||
// real cookie handling, or the page wiring. The claim "the UI JavaScript has
|
||||
// never been run" is no longer true of this module; "browser-tested" still is
|
||||
// not. Both statements matter and neither should be rounded up.
|
||||
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const STORAGE_KEY = 'ruview-api-token';
|
||||
|
||||
// --- stubs installed before the module under test is imported ---------------
|
||||
let stored = {};
|
||||
let fetchCalls = [];
|
||||
let fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (k in stored ? stored[k] : null),
|
||||
setItem: (k, v) => { stored[k] = String(v); },
|
||||
removeItem: (k) => { delete stored[k]; },
|
||||
};
|
||||
globalThis.fetch = async (...args) => { fetchCalls.push(args); return fetchImpl(...args); };
|
||||
|
||||
const { withWsTicket } = await import('./ws-ticket.js');
|
||||
|
||||
beforeEach(() => {
|
||||
stored = {};
|
||||
fetchCalls = [];
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
|
||||
});
|
||||
|
||||
test('with no stored token the URL is returned unchanged and nothing is fetched', async () => {
|
||||
// Auth is off, so no ticket is needed. Minting one would be a pointless
|
||||
// round-trip on every reconnect.
|
||||
const url = await withWsTicket('ws://host/ws/sensing');
|
||||
assert.equal(url, 'ws://host/ws/sensing');
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test('a minted ticket is appended and the bearer is sent only in the header', async () => {
|
||||
stored[STORAGE_KEY] = 'secret-bearer';
|
||||
const url = await withWsTicket('ws://host/ws/sensing');
|
||||
|
||||
assert.equal(url, 'ws://host/ws/sensing?ticket=T');
|
||||
// The long-lived bearer must never reach a URL — only the bounded ticket does.
|
||||
assert.ok(!url.includes('secret-bearer'), `bearer leaked into URL: ${url}`);
|
||||
|
||||
const [path, init] = fetchCalls[0];
|
||||
assert.equal(path, '/api/v1/ws-ticket');
|
||||
assert.equal(init.method, 'POST');
|
||||
assert.equal(init.headers.Authorization, 'Bearer secret-bearer');
|
||||
});
|
||||
|
||||
test('an existing query string gets & rather than a second ?', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
const url = await withWsTicket('ws://host/ws/sensing?foo=1');
|
||||
assert.equal(url, 'ws://host/ws/sensing?foo=1&ticket=T');
|
||||
});
|
||||
|
||||
test('the ticket value is URL-encoded', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'a+b/c=d' }) });
|
||||
const url = await withWsTicket('ws://host/ws/sensing');
|
||||
assert.ok(url.endsWith('ticket=a%2Bb%2Fc%3Dd'), url);
|
||||
});
|
||||
|
||||
test('404 means a server predating ADR-272, so connect without a ticket', async () => {
|
||||
// That server still exempts WebSockets, so an unticketed connect is correct.
|
||||
// This is what lets one UI work against both old and new servers, which is
|
||||
// what makes the legacy escape hatch removable rather than permanent.
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a 503 does not append a ticket and does not throw', async () => {
|
||||
// Ticket store exhausted. The socket attempt will fail on its own and the
|
||||
// caller's reconnect logic handles it; throwing here would duplicate that.
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a network failure is swallowed rather than breaking the connect path', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => { throw new Error('offline'); };
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a 200 with no ticket field yields no ticket', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a fresh ticket is minted per call and never reused', async () => {
|
||||
// Tickets are single-use and expire in ~30s, so caching one across reconnects
|
||||
// fails on the second attempt.
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
let n = 0;
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: `T${++n}` }) });
|
||||
|
||||
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T1');
|
||||
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
|
||||
assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
|
||||
});
|
||||
@@ -4088,6 +4088,20 @@ a:focus-visible,
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.qs-text-input {
|
||||
padding: var(--space-6) var(--space-8);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.qs-text-input:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* --- Screenshot Flash --- */
|
||||
|
||||
.screenshot-flash {
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
// RuView Service Worker - Offline caching for the dashboard shell
|
||||
// Strategy: Network-first for API calls, Cache-first for static assets
|
||||
|
||||
const CACHE_NAME = 'ruview-v1';
|
||||
// Bumped from v1: an older SW cached `/oauth/status` cache-first, so browsers
|
||||
// that ran it hold a permanently signed-out answer. `activate` deletes every
|
||||
// cache whose name is not CACHE_NAME, so bumping is what evicts it from clients
|
||||
// already in the field. Bump again if a future change poisons the cache.
|
||||
const CACHE_NAME = 'ruview-v2';
|
||||
|
||||
// Requests whose response depends on the caller's credentials. These must never
|
||||
// be served from the Cache API.
|
||||
//
|
||||
// The Cache API is NOT the HTTP cache: it ignores `Cache-Control` completely, so
|
||||
// the `no-store, no-cache, must-revalidate` the server already sends on
|
||||
// `/oauth/status` has no effect here. A cached signed-out response was returned
|
||||
// to the page forever, and only a hard reload — which bypasses the service
|
||||
// worker entirely — showed the true state. (ADR-271.)
|
||||
const NEVER_CACHE_PREFIXES = ['/oauth/'];
|
||||
|
||||
// What may be served cache-first. Previously cache-first was the *catch-all* for
|
||||
// everything outside `/api/` and `/health/`, which meant any endpoint added at a
|
||||
// new path was frozen on first response. An allowlist fails safe instead: an
|
||||
// unrecognised path goes to the network untouched.
|
||||
const STATIC_ASSET = /\.(?:js|mjs|css|html|json|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|map)$/i;
|
||||
const SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@@ -74,25 +94,49 @@ self.addEventListener('fetch', (event) => {
|
||||
// Skip cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Credentialed endpoints: hands off entirely. Not networkFirst — that still
|
||||
// writes a copy into the cache, which would be replayed the moment the server
|
||||
// is briefly unreachable, silently reinstating a stale sign-in state.
|
||||
if (NEVER_CACHE_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
|
||||
// Signing out is the one moment we know cached data belongs to a session
|
||||
// that is ending. Observed, not intercepted — the request itself still goes
|
||||
// straight to the network. `waitUntil` keeps the worker alive for the purge
|
||||
// even though the navigation is what the browser is really waiting on.
|
||||
if (url.pathname === '/oauth/logout') {
|
||||
event.waitUntil(purgeNonShell());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API calls: network-first with cache fallback
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first with network fallback
|
||||
event.respondWith(cacheFirst(request));
|
||||
// Static assets and the app shell: cache-first with network fallback.
|
||||
if (request.mode === 'navigate' || STATIC_ASSET.test(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
|
||||
// Anything else is left alone and goes to the network as normal.
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
// `ignoreSearch` so the shell is a single entry. Sign-in redirects back to
|
||||
// `/ui/?signed_in=<ms>`, which would otherwise mint a fresh cache entry per
|
||||
// sign-in and never hit any of them again.
|
||||
const cached = await caches.match(request, { ignoreSearch: true });
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
// Store under the search-less URL to match how it is looked up above.
|
||||
const key = new URL(request.url);
|
||||
key.search = '';
|
||||
cache.put(key.toString(), response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
@@ -105,20 +149,54 @@ async function cacheFirst(request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-only, with an explicit offline signal.
|
||||
*
|
||||
* This used to cache every successful `/api/` response and replay it whenever
|
||||
* the network failed. Two things are wrong with that now:
|
||||
*
|
||||
* 1. **Authorization.** API responses are per-user once auth is on, but the
|
||||
* cache is keyed by URL alone and nothing purges it at sign-out. Sign in as
|
||||
* A, load sensing data, sign out, sign in as B, lose the network — B is
|
||||
* served A's data with no authorization check at all. That is the same
|
||||
* defect class as the cached `/oauth/status`: the Cache API happily outlives
|
||||
* the session that produced its contents.
|
||||
* 2. **Correctness.** This is a live sensing dashboard. Replaying a stale pose
|
||||
* or presence reading as if it were current is its own defect — it can show
|
||||
* a room as occupied after the person has left.
|
||||
*
|
||||
* The offline shell (HTML/CSS/JS) is still cached; only the data is not. If
|
||||
* offline data replay is wanted back, it needs a per-session cache key and a
|
||||
* purge on sign-out, not a URL-keyed shared cache.
|
||||
*/
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
return await fetch(request);
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response(JSON.stringify({ error: 'offline' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop everything except the static shell.
|
||||
*
|
||||
* Called when the user signs out. Belt-and-braces: nothing user-specific should
|
||||
* be in the cache after the `networkFirst` change above, but a cache populated
|
||||
* by an OLDER worker on this browser can still hold API responses, and that
|
||||
* worker's entries survive into this one under the same name.
|
||||
*/
|
||||
async function purgeNonShell() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const keys = await cache.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((req) => {
|
||||
const p = new URL(req.url).pathname;
|
||||
return p.startsWith('/api/') || p.startsWith('/health/');
|
||||
})
|
||||
.map((req) => cache.delete(req))
|
||||
);
|
||||
}
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// Executed tests for the service worker's request routing (ADR-271/272).
|
||||
//
|
||||
// WHY THIS EXISTS.
|
||||
// Browser sign-in appeared to fail: after a successful OAuth round-trip the
|
||||
// settings panel still offered "Sign in with Cognitum", and only a hard reload
|
||||
// showed the truth. The server was correct throughout — `/oauth/status` fell
|
||||
// through to the service worker's cache-first catch-all, so the first
|
||||
// (signed-out) response was stored in the Cache API and replayed forever.
|
||||
//
|
||||
// The Cache API is not the HTTP cache. It ignores `Cache-Control` entirely, so
|
||||
// the `no-store` the server already sent could not prevent this. Nothing in the
|
||||
// Rust suite, the UI unit tests, or a curl probe could observe it: curl has no
|
||||
// service worker, and a hard reload bypasses one. It was only visible by
|
||||
// driving a real browser.
|
||||
//
|
||||
// These tests load the REAL `sw.js` with stubbed worker globals and assert on
|
||||
// which strategy each path is routed to.
|
||||
//
|
||||
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
|
||||
// (a directory argument does not work — Node resolves it as a module)
|
||||
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SW_SOURCE = readFileSync(fileURLToPath(new URL('./sw.js', import.meta.url)), 'utf8');
|
||||
const ORIGIN = 'http://127.0.0.1:8099';
|
||||
|
||||
/** Load sw.js in a fresh context and return its registered `fetch` listener. */
|
||||
function loadServiceWorker() {
|
||||
const listeners = {};
|
||||
const cachePuts = [];
|
||||
|
||||
// A real in-memory cache, so purge and put behaviour can be observed rather
|
||||
// than assumed.
|
||||
const entries = new Map();
|
||||
const cacheStub = {
|
||||
addAll: async () => {},
|
||||
put: async (req, res) => {
|
||||
const url = String(req.url ?? req);
|
||||
cachePuts.push(url);
|
||||
entries.set(url, res);
|
||||
},
|
||||
keys: async () => Array.from(entries.keys()).map((url) => ({ url })),
|
||||
delete: async (req) => entries.delete(String(req.url ?? req)),
|
||||
match: async () => undefined,
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
self: {
|
||||
addEventListener: (name, fn) => { listeners[name] = fn; },
|
||||
location: { origin: ORIGIN },
|
||||
skipWaiting: () => {},
|
||||
clients: { claim: () => {} },
|
||||
},
|
||||
caches: {
|
||||
open: async () => cacheStub,
|
||||
keys: async () => [],
|
||||
delete: async () => true,
|
||||
match: async () => undefined,
|
||||
},
|
||||
// Never actually reached: every assertion below inspects the routing
|
||||
// decision, not the network.
|
||||
fetch: async () => ({ ok: true, clone: () => ({}) }),
|
||||
URL,
|
||||
Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } },
|
||||
console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SW_SOURCE, sandbox);
|
||||
|
||||
return { listeners, cachePuts, sandbox, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route one request and report the decision.
|
||||
* `handled: false` means the SW called neither respondWith nor cache — the
|
||||
* request goes to the network untouched, which is the only safe outcome for a
|
||||
* credentialed endpoint.
|
||||
*/
|
||||
function route(path, opts = {}) {
|
||||
return dispatch(path, opts).handled;
|
||||
}
|
||||
|
||||
/** Route one request and expose everything the worker did with it. */
|
||||
function dispatch(path, { method = 'GET', mode = 'cors', headers = {}, sw = null } = {}) {
|
||||
const worker = sw ?? loadServiceWorker();
|
||||
let handled = false;
|
||||
let responded = null;
|
||||
const waited = [];
|
||||
const request = {
|
||||
url: `${ORIGIN}${path}`,
|
||||
method,
|
||||
mode,
|
||||
headers: { get: (k) => headers[k] ?? headers[k.toLowerCase()] ?? null },
|
||||
};
|
||||
worker.listeners.fetch({
|
||||
request,
|
||||
respondWith: (p) => { handled = true; responded = p; },
|
||||
waitUntil: (p) => { waited.push(p); },
|
||||
});
|
||||
return { handled, responded, waited, worker };
|
||||
}
|
||||
|
||||
let sw;
|
||||
beforeEach(() => { sw = loadServiceWorker(); });
|
||||
|
||||
// --- the defect this file exists for ----------------------------------------
|
||||
|
||||
test('/oauth/status is never handled by the service worker', () => {
|
||||
// The exact request whose cached signed-out copy made sign-in look broken.
|
||||
assert.equal(route('/oauth/status'), false);
|
||||
});
|
||||
|
||||
test('every /oauth/ path bypasses the worker, not just status', () => {
|
||||
// start/callback/logout all carry or clear credentials. A cached redirect or
|
||||
// Set-Cookie replayed later is worse than a cached status.
|
||||
for (const p of ['/oauth/start', '/oauth/callback?code=x&state=y', '/oauth/logout']) {
|
||||
assert.equal(route(p), false, `${p} must go straight to the network`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- the underlying defect: cache-first was the catch-all -------------------
|
||||
|
||||
test('an unrecognised path is left to the network rather than cached', () => {
|
||||
// This is what made the OAuth bug possible in the first place: any endpoint
|
||||
// added outside /api/ was silently frozen on its first response. An
|
||||
// allowlist means the next such endpoint is safe by default.
|
||||
assert.equal(route('/some/future/endpoint'), false);
|
||||
});
|
||||
|
||||
test('static assets are still served cache-first', () => {
|
||||
// The offline shell is the point of the worker; the fix must not disable it.
|
||||
for (const p of ['/app.js', '/style.css', '/components/TabManager.js', '/icons/logo.svg']) {
|
||||
assert.equal(route(p), true, `${p} should be cache-first`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a navigation request is still served cache-first', () => {
|
||||
assert.equal(route('/ui/', { mode: 'navigate' }), true);
|
||||
});
|
||||
|
||||
test('API paths are still routed through the worker', () => {
|
||||
// Handled, but network-only — see the "not written to the cache" test below.
|
||||
assert.equal(route('/api/v1/models'), true);
|
||||
assert.equal(route('/health/live'), true);
|
||||
});
|
||||
|
||||
// --- pre-existing guards, pinned so the rewrite did not drop them -----------
|
||||
|
||||
test('non-GET requests are ignored', () => {
|
||||
assert.equal(route('/api/v1/models', { method: 'POST' }), false);
|
||||
});
|
||||
|
||||
test('websocket upgrades are ignored', () => {
|
||||
assert.equal(route('/ws/sensing', { headers: { Upgrade: 'websocket' } }), false);
|
||||
});
|
||||
|
||||
test('cross-origin requests are ignored', () => {
|
||||
const { listeners } = loadServiceWorker();
|
||||
let handled = false;
|
||||
listeners.fetch({
|
||||
request: {
|
||||
url: 'https://auth.cognitum.one/oauth/authorize',
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
headers: { get: () => null },
|
||||
},
|
||||
respondWith: () => { handled = true; },
|
||||
});
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
// --- authenticated API responses must not be retained ------------------------
|
||||
// Filed by the cross-vendor prosecutor in the qe-court round after the
|
||||
// /oauth/status fix: closing the /oauth/ leg left the /api/ leg open.
|
||||
|
||||
test('a successful API response is NOT written to the cache', async () => {
|
||||
// The leak: cache keys are URLs, nothing partitions them by session, and
|
||||
// nothing purged them at sign-out. Sign in as A, fetch sensing data, sign
|
||||
// out, sign in as B, lose the network -> B is served A's data.
|
||||
const { responded, worker } = dispatch('/api/v1/sensing/latest');
|
||||
await responded;
|
||||
assert.deepEqual(worker.cachePuts, [], 'API responses must not be cached');
|
||||
});
|
||||
|
||||
test('an API request with no network returns 503 rather than stale data', async () => {
|
||||
// Also a correctness property, not only an authorization one: replaying a
|
||||
// stale pose reading as current can show a room occupied after the person
|
||||
// has left.
|
||||
const worker = loadServiceWorker();
|
||||
worker.sandbox.fetch = async () => { throw new Error('offline'); };
|
||||
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, { stale: true });
|
||||
|
||||
const { responded } = dispatch('/api/v1/sensing/latest', { sw: worker });
|
||||
const res = await responded;
|
||||
assert.equal(res.status, 503);
|
||||
assert.match(String(res.body), /offline/);
|
||||
});
|
||||
|
||||
test('signing out purges cached API data but keeps the offline shell', async () => {
|
||||
const worker = loadServiceWorker();
|
||||
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, {});
|
||||
worker.entries.set(`${ORIGIN}/health/live`, {});
|
||||
worker.entries.set(`${ORIGIN}/app.js`, {});
|
||||
|
||||
const { handled, waited } = dispatch('/oauth/logout', { sw: worker });
|
||||
// Observed, not intercepted — the logout request itself must still reach the
|
||||
// server, or signing out would not actually sign anyone out.
|
||||
assert.equal(handled, false, '/oauth/logout must still go to the network');
|
||||
assert.equal(waited.length, 1, 'the purge must be kept alive via waitUntil');
|
||||
await Promise.all(waited);
|
||||
|
||||
const left = Array.from(worker.entries.keys());
|
||||
assert.deepEqual(left, [`${ORIGIN}/app.js`], 'only the static shell should survive');
|
||||
});
|
||||
|
||||
// --- cache hygiene -----------------------------------------------------------
|
||||
|
||||
test('the cache name is bumped so clients holding the poisoned v1 evict it', () => {
|
||||
// `activate` deletes every cache whose name !== CACHE_NAME. Browsers that
|
||||
// already ran the old worker hold a signed-out /oauth/status in `ruview-v1`;
|
||||
// only a name change removes it for them.
|
||||
assert.ok(!/['"]ruview-v1['"]/.test(SW_SOURCE), 'CACHE_NAME must not still be ruview-v1');
|
||||
assert.ok(/CACHE_NAME\s*=\s*['"]ruview-v[2-9]/.test(SW_SOURCE));
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
// Quick Settings Panel - Centralized configuration for all UI features
|
||||
// Accessible via gear icon in header
|
||||
|
||||
import { apiService, API_TOKEN_STORAGE_KEY } from '../services/api.service.js';
|
||||
|
||||
export class QuickSettings {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
@@ -9,6 +11,8 @@ export class QuickSettings {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
// A stored token is applied at api.service.js module load (before any
|
||||
// request fires) — this panel only saves/clears it.
|
||||
init() {
|
||||
this.createButton();
|
||||
this.createPanel();
|
||||
@@ -70,6 +74,28 @@ export class QuickSettings {
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Cognitum Account</div>
|
||||
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
|
||||
<span id="qs-signin-status" style="font-size: 0.9em; opacity: 0.85;">Checking...</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="qs-btn" id="qs-signin" hidden>Sign in with Cognitum</button>
|
||||
<button class="qs-btn-danger" id="qs-signout" hidden>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">API Access</div>
|
||||
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
|
||||
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
|
||||
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
|
||||
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
|
||||
</div>
|
||||
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Data</div>
|
||||
<div class="qs-row">
|
||||
@@ -87,6 +113,23 @@ export class QuickSettings {
|
||||
// Bind events
|
||||
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
|
||||
|
||||
// Re-check sign-in state whenever the page could be showing a stale view:
|
||||
// `pageshow` fires on a back/forward-cache restore (where no script re-runs
|
||||
// and no fetch would otherwise happen), and `visibilitychange` covers
|
||||
// signing in or out in another tab. Opening the panel alone is not enough —
|
||||
// the panel may already be open, or the page may be restored wholesale.
|
||||
window.addEventListener('pageshow', () => { void refreshSignInPanel(this.panel); });
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) void refreshSignInPanel(this.panel);
|
||||
});
|
||||
|
||||
// ADR-271 sign-in. Bound here as well as in refreshSignInPanel so a click
|
||||
// works even if the status fetch has not resolved yet.
|
||||
this.panel.querySelector('#qs-signin')
|
||||
.addEventListener('click', () => { window.location.href = '/oauth/start'; });
|
||||
this.panel.querySelector('#qs-signout')
|
||||
.addEventListener('click', () => { window.location.href = '/oauth/logout'; });
|
||||
|
||||
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('reduced-motion', e.target.checked);
|
||||
this.saveSetting('reduced-motion', e.target.checked);
|
||||
@@ -112,6 +155,30 @@ export class QuickSettings {
|
||||
}
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
const token = input.value.trim();
|
||||
if (!token) {
|
||||
status.textContent = 'Enter a token first, or use Clear to remove one.';
|
||||
return;
|
||||
}
|
||||
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
|
||||
apiService.setAuthToken(token);
|
||||
status.textContent = 'Token saved and applied. Reloading...';
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
||||
apiService.setAuthToken(null);
|
||||
input.value = '';
|
||||
status.textContent = 'Token cleared. Reloading...';
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
@@ -154,6 +221,10 @@ export class QuickSettings {
|
||||
if (this.getSetting('compact')) {
|
||||
document.body.classList.add('compact-mode');
|
||||
}
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
let hasToken = false;
|
||||
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
||||
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
|
||||
}
|
||||
|
||||
prefersReducedMotion() {
|
||||
@@ -167,6 +238,11 @@ export class QuickSettings {
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.panel.classList.add('open');
|
||||
// Refresh on every open, not once at construction: the session may have
|
||||
// been established in another tab, or expired since the page loaded.
|
||||
// Fire-and-forget — a failure renders as a message in the panel, and must
|
||||
// not stop the panel opening.
|
||||
void refreshSignInPanel(this.panel);
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -189,3 +265,66 @@ export class QuickSettings {
|
||||
this.panel?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cognitum browser sign-in (ADR-271) -------------------------------------
|
||||
//
|
||||
// `/oauth/status` is intentionally UNGATED: a signed-out browser cannot ask a
|
||||
// gated endpoint whether sign-in is available. It returns capability flags and,
|
||||
// when a session exists, who it belongs to — never a credential.
|
||||
//
|
||||
// Sign-in is a full-page navigation, not fetch(): the server replies 302 to
|
||||
// auth.cognitum.one, and the browser must follow it and carry the transaction
|
||||
// cookie. An XHR would follow the redirect invisibly and land nowhere useful.
|
||||
export async function refreshSignInPanel(root = document) {
|
||||
const status = root.querySelector('#qs-signin-status');
|
||||
const signIn = root.querySelector('#qs-signin');
|
||||
const signOut = root.querySelector('#qs-signout');
|
||||
if (!status || !signIn || !signOut) return null;
|
||||
|
||||
let info;
|
||||
try {
|
||||
const resp = await fetch('/oauth/status', { credentials: 'same-origin' });
|
||||
// 404 = a server predating ADR-271. Say so plainly rather than offering a
|
||||
// button that will 404.
|
||||
if (resp.status === 404) {
|
||||
status.textContent = 'This server does not support Cognitum sign-in.';
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
return null;
|
||||
}
|
||||
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
||||
info = await resp.json();
|
||||
} catch (err) {
|
||||
status.textContent = `Could not reach the server (${err.message}).`;
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (info.signed_in) {
|
||||
status.textContent = `Signed in${info.account ? ` as ${info.account}` : ''}${
|
||||
info.scope ? ` - ${info.scope}` : ''
|
||||
}`;
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = false;
|
||||
} else if (info.browser_signin) {
|
||||
status.textContent = info.auth_required
|
||||
? 'This server requires sign-in.'
|
||||
: 'Optional: sign in to use your Cognitum account.';
|
||||
signIn.hidden = false;
|
||||
signOut.hidden = true;
|
||||
} else if (info.auth_required) {
|
||||
// Auth is on but OAuth is not — the static-token panel below is the path.
|
||||
status.textContent = 'This server uses a shared API token (see API Access below).';
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
} else {
|
||||
status.textContent = 'This server does not require sign-in.';
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
}
|
||||
|
||||
signIn.onclick = () => { window.location.href = '/oauth/start'; };
|
||||
signOut.onclick = () => { window.location.href = '/oauth/logout'; };
|
||||
return info;
|
||||
}
|
||||
|
||||
Generated
+188
-17
@@ -392,6 +392,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -1533,6 +1539,18 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1969,6 +1987,20 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
@@ -2002,6 +2034,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array 0.14.7",
|
||||
"group",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -2160,6 +2212,16 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
@@ -2941,6 +3003,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3145,6 +3208,17 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
@@ -4278,6 +4352,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "katexit"
|
||||
version = "0.1.5"
|
||||
@@ -5594,6 +5683,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -6155,6 +6256,15 @@ dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@@ -6931,6 +7041,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
@@ -7511,6 +7631,26 @@ version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
|
||||
|
||||
[[package]]
|
||||
name = "ruview-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"p256",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ureq 2.12.1",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
@@ -7533,7 +7673,6 @@ dependencies = [
|
||||
"tokio-test",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"wifi-densepose-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7667,6 +7806,20 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array 0.14.7",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -8132,6 +8285,18 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simsimd"
|
||||
version = "5.9.11"
|
||||
@@ -10852,6 +11017,13 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-aether"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.1"
|
||||
@@ -10896,6 +11068,8 @@ dependencies = [
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"predicates",
|
||||
"reqwest 0.12.28",
|
||||
"ruview-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tabled",
|
||||
@@ -10961,7 +11135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -11126,29 +11300,38 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-sensing-server"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"clap",
|
||||
"criterion",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"midstreamer-attractor",
|
||||
"midstreamer-temporal-compare",
|
||||
"p256",
|
||||
"proptest",
|
||||
"rand 0.8.5",
|
||||
"rumqttc",
|
||||
"ruvector-mincut",
|
||||
"ruview-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq 2.12.1",
|
||||
"wifi-densepose-aether",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo",
|
||||
@@ -11161,7 +11344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-signal"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
@@ -11220,7 +11403,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"walkdir",
|
||||
"wifi-densepose-nn",
|
||||
"wifi-densepose-signal",
|
||||
]
|
||||
|
||||
@@ -11268,7 +11450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"petgraph",
|
||||
"serde",
|
||||
@@ -11277,17 +11459,6 @@ dependencies = [
|
||||
"wifi-densepose-geo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
@@ -20,6 +20,7 @@ members = [
|
||||
"crates/wifi-densepose-mat",
|
||||
"crates/wifi-densepose-train",
|
||||
"crates/wifi-densepose-sensing-server",
|
||||
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
|
||||
"crates/wifi-densepose-wifiscan",
|
||||
"crates/wifi-densepose-vitals",
|
||||
"crates/wifi-densepose-ruvector",
|
||||
@@ -28,6 +29,12 @@ members = [
|
||||
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
|
||||
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
|
||||
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
|
||||
# ADR-271 — Cognitum OAuth access-token verification. RuView as an OAuth
|
||||
# RESOURCE SERVER: offline ES256/JWKS verification of tokens issued by
|
||||
# auth.cognitum.one, so a user signs in to their own sensing server with
|
||||
# their Cognitum identity instead of a shared static bearer. No login flow
|
||||
# and no outbound Cognitum API calls live here — verification only.
|
||||
"crates/ruview-auth",
|
||||
"crates/nvsim",
|
||||
"crates/nvsim-server",
|
||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user