mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e20bed197b | |||
| 0824de7665 | |||
| e1843c047e | |||
| 3225eee5be | |||
| d2b2cbfc69 | |||
| 770788fc85 | |||
| 4d5bdb1570 | |||
| 8505662af4 | |||
| 8eb808de03 | |||
| ca3c58a69f | |||
| d5c457aa30 | |||
| b2e3f27fa1 | |||
| e39a35edee | |||
| f49ecb163f | |||
| c79543283b | |||
| 4ab69359ef | |||
| ae792aad0d | |||
| 898d90f689 | |||
| 0c512ed06e | |||
| f39d88e711 | |||
| de5dc9a151 | |||
| c1336c6672 | |||
| 6cb0859806 | |||
| 5ebd78e796 |
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "ruview",
|
||||
"description": "RuView Marketplace: Claude Code + Codex plugins for WiFi sensing — configuration, applications, model training, and onboarding, from practical to advanced",
|
||||
"owner": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ruview",
|
||||
"source": "./plugins/ruview",
|
||||
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep all third-party GitHub Actions on verified, pinned commit SHAs.
|
||||
# Pairs with the SHA pinning in security-scan.yml and ci.yml so that
|
||||
# future bumps stay automated and reviewable rather than drifting back
|
||||
# to mutable @master / @main refs. See issue #442.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- github-actions
|
||||
|
||||
# Mobile app npm deps. Includes the @xmldom/xmldom, node-forge, and
|
||||
# picomatch advisories from #442 plus axios and any future surface.
|
||||
- package-ecosystem: npm
|
||||
directory: /ui/mobile
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- mobile
|
||||
|
||||
# Desktop UI npm deps. Direct vite devDep currently has a HIGH advisory
|
||||
# (dev-server-only path traversal); track future bumps automatically.
|
||||
- package-ecosystem: npm
|
||||
directory: /v2/crates/wifi-densepose-desktop/ui
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- desktop
|
||||
|
||||
# Python deps used by v1/ and the FastAPI service. requirements.txt is
|
||||
# only loosely pinned; let Dependabot surface upstream CVE bumps.
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
|
||||
# Rust workspace (15+ crates). cargo audit is not currently wired into
|
||||
# any workflow, so Dependabot is the primary automated bump path.
|
||||
- package-ecosystem: cargo
|
||||
directory: /v2
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- rust
|
||||
@@ -275,7 +275,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Update deployment status
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
|
||||
- name: Create deployment issue on failure
|
||||
if: needs.deploy-production.result == 'failure'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
|
||||
+23
-87
@@ -15,50 +15,38 @@ env:
|
||||
|
||||
jobs:
|
||||
# Code Quality and Security Checks
|
||||
# The Python codebase moved to `archive/v1/` when the runtime was rewritten in
|
||||
# Rust under `v2/`. The lint/format/type/scan checks below still run against
|
||||
# the archive for hygiene, but with `continue-on-error: true` everywhere — the
|
||||
# archive is frozen reference code, not active development, so a stale lint
|
||||
# rule shouldn't gate PRs to the Rust workspace.
|
||||
code-quality:
|
||||
name: Code Quality & Security
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install black flake8 mypy bandit safety
|
||||
|
||||
- name: Code formatting check (Black)
|
||||
continue-on-error: true
|
||||
run: black --check --diff archive/v1/src archive/v1/tests
|
||||
run: black --check --diff src/ tests/
|
||||
|
||||
- name: Linting (Flake8)
|
||||
continue-on-error: true
|
||||
run: flake8 archive/v1/src archive/v1/tests --max-line-length=88 --extend-ignore=E203,W503
|
||||
run: flake8 src/ tests/ --max-line-length=88 --extend-ignore=E203,W503
|
||||
|
||||
- name: Type checking (MyPy)
|
||||
continue-on-error: true
|
||||
run: mypy archive/v1/src --ignore-missing-imports
|
||||
run: mypy src/ --ignore-missing-imports
|
||||
|
||||
- name: Security scan (Bandit)
|
||||
run: bandit -r archive/v1/src -f json -o bandit-report.json
|
||||
run: bandit -r src/ -f json -o bandit-report.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Dependency vulnerability scan (Safety)
|
||||
@@ -66,7 +54,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload security reports
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -83,28 +70,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# `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
|
||||
# workspace test fails at the build step before any test runs (every recent
|
||||
# main CI run has been red on this for exactly this reason). Install the
|
||||
# standard Tauri-on-Ubuntu set.
|
||||
- name: Install Tauri / GTK / serial system dev libraries
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libglib2.0-dev \
|
||||
libgtk-3-dev \
|
||||
libsoup-3.0-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libudev-dev \
|
||||
libdbus-1-dev \
|
||||
libssl-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -114,25 +79,20 @@ jobs:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
|
||||
rust-port/wifi-densepose-rs/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('rust-port/wifi-densepose-rs/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: v2
|
||||
working-directory: rust-port/wifi-densepose-rs
|
||||
run: cargo test --workspace --no-default-features
|
||||
|
||||
# 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:
|
||||
# the archive is frozen reference, not blocking the Rust workspace PRs.
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
services:
|
||||
@@ -161,51 +121,44 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest-cov pytest-xdist
|
||||
|
||||
- name: Run unit tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
pytest archive/v1/tests/unit/ -v --cov=archive/v1/src --cov-report=xml --cov-report=html --junitxml=junit.xml
|
||||
pytest tests/unit/ -v --cov=src --cov-report=xml --cov-report=html --junitxml=junit.xml
|
||||
|
||||
- name: Run integration tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
pytest archive/v1/tests/integration/ -v --junitxml=integration-junit.xml
|
||||
pytest tests/integration/ -v --junitxml=integration-junit.xml
|
||||
|
||||
- name: Upload coverage reports
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
- name: Upload test results
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -226,7 +179,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -253,29 +206,18 @@ jobs:
|
||||
path: locust_report.html
|
||||
|
||||
# Docker Build and Test
|
||||
# NOTE: the canonical Docker build for the sensing-server is now
|
||||
# `.github/workflows/sensing-server-docker.yml` (multi-registry push, asset
|
||||
# smoke tests, bearer-auth smoke tests — #520/#514/#443). This job predates
|
||||
# that workflow, points at a non-existent root `Dockerfile` with a
|
||||
# non-existent `target: production`, and pushes to a mis-cased image name —
|
||||
# `continue-on-error: true` until it's deleted or rewired to call the new
|
||||
# workflow, so it doesn't gate the rest of the pipeline.
|
||||
docker-build:
|
||||
name: Docker Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [code-quality, test, rust-tests]
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
continue-on-error: true
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -283,9 +225,8 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
continue-on-error: true
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -295,8 +236,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -308,7 +248,6 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Test Docker image
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --rm -d --name test-container -p 8000:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
sleep 10
|
||||
@@ -316,15 +255,13 @@ jobs:
|
||||
docker stop test-container
|
||||
|
||||
- name: Run container security scan
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -341,7 +278,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -373,27 +310,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [code-quality, test, rust-tests, performance-test, docker-build, docs]
|
||||
if: always()
|
||||
# GitHub Actions does not allow `secrets.X` directly in step-level `if:`
|
||||
# expressions — only `env.X`. Promote the secret to env at job scope so
|
||||
# the gating expression below is parseable.
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Notify Slack on success
|
||||
if: ${{ env.SLACK_WEBHOOK_URL != '' && needs.code-quality.result == 'success' && needs.test.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
if: ${{ secrets.SLACK_WEBHOOK_URL != '' && needs.code-quality.result == 'success' && needs.test.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: success
|
||||
channel: '#ci-cd'
|
||||
text: '✅ CI pipeline completed successfully for ${{ github.ref }}'
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: ${{ env.SLACK_WEBHOOK_URL != '' && (needs.code-quality.result == 'failure' || needs.test.result == 'failure' || needs.docker-build.result == 'failure') }}
|
||||
if: ${{ secrets.SLACK_WEBHOOK_URL != '' && (needs.code-quality.result == 'failure' || needs.test.result == 'failure' || needs.docker-build.result == 'failure') }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: failure
|
||||
channel: '#ci-cd'
|
||||
text: '❌ CI pipeline failed for ${{ github.ref }}'
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.ref == 'refs/heads/main' && needs.docker-build.result == 'success'
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
name: GitHub Clone Tracking → data/clone-data.rvf
|
||||
|
||||
# Persists rolling 14-day clone-traffic snapshots to data/clone-data.rvf in
|
||||
# the ruvector JSONL RVF format. GitHub's /traffic/clones endpoint only
|
||||
# retains the last 14 days server-side, so without this scheduled scrape
|
||||
# the data is gone forever the moment it falls outside the window.
|
||||
#
|
||||
# Format: JSONL RVF
|
||||
# - line 1 is a `metadata` segment that initializes the file
|
||||
# - each subsequent run appends one `clone_snapshot` segment carrying the
|
||||
# 14-day rollup PLUS per-day breakdown
|
||||
# - file is idempotent: per-day entries are keyed by `timestamp` so a
|
||||
# downstream reader can dedupe across overlapping snapshot windows
|
||||
#
|
||||
# Schedule: every 14 days (1st + 15th of each month, ~14-day cadence in
|
||||
# practice). Workflow can also be dispatched manually for backfill or test.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 01:23 UTC on the 1st and 15th of every month — close to 14-day cadence
|
||||
# without cron's "every 14 days" monthly-reset weirdness. Picking :23
|
||||
# avoids the cron herd on :00.
|
||||
- cron: '23 1 1,15 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: clone-tracking
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch /traffic/clones + /traffic/views from GitHub
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p data
|
||||
gh api repos/${{ github.repository }}/traffic/clones > /tmp/clones.json
|
||||
gh api repos/${{ github.repository }}/traffic/views > /tmp/views.json
|
||||
echo "--- clones rollup ---"
|
||||
jq '{count, uniques, days: (.clones | length)}' /tmp/clones.json
|
||||
echo "--- views rollup ---"
|
||||
jq '{count, uniques, days: (.views | length)}' /tmp/views.json
|
||||
|
||||
- name: Append snapshot to data/clone-data.rvf
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
RVF="data/clone-data.rvf"
|
||||
FETCHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Initialize the file with a metadata segment on first run.
|
||||
if [ ! -f "$RVF" ]; then
|
||||
echo "Initializing $RVF with metadata segment"
|
||||
jq -n --arg repo "$REPO" --arg ts "$FETCHED_AT" '{
|
||||
type: "metadata",
|
||||
name: "ruview-clone-traffic-history",
|
||||
version: "1.0.0",
|
||||
schema: "ruvector.rvf.jsonl/v1",
|
||||
format: "github-traffic-snapshots",
|
||||
repo: $repo,
|
||||
source: "GitHub Traffic API /repos/{repo}/traffic/{clones,views}",
|
||||
policy: "GitHub retains only 14 days server-side; this file is the long-term record.",
|
||||
segments: ["metadata", "clone_snapshot", "view_snapshot"],
|
||||
created_at: $ts,
|
||||
custom: {
|
||||
cadence: "twice monthly (1st and 15th, ~14-day intervals)",
|
||||
idempotency_key: "timestamp (per-day records de-duplicate across overlapping snapshot windows)"
|
||||
}
|
||||
}' >> "$RVF"
|
||||
fi
|
||||
|
||||
# Append the clone snapshot.
|
||||
jq --arg ts "$FETCHED_AT" '{
|
||||
type: "clone_snapshot",
|
||||
fetched_at: $ts,
|
||||
window_count: .count,
|
||||
window_uniques: .uniques,
|
||||
per_day: .clones
|
||||
}' /tmp/clones.json >> "$RVF"
|
||||
|
||||
# Append the views snapshot (free with the same auth).
|
||||
jq --arg ts "$FETCHED_AT" '{
|
||||
type: "view_snapshot",
|
||||
fetched_at: $ts,
|
||||
window_count: .count,
|
||||
window_uniques: .uniques,
|
||||
per_day: .views
|
||||
}' /tmp/views.json >> "$RVF"
|
||||
|
||||
echo "--- RVF tail (last 4 lines) ---"
|
||||
tail -4 "$RVF" | jq -c '{type, fetched_at, window_count, window_uniques}' || true
|
||||
echo "--- file size ---"
|
||||
wc -l "$RVF"
|
||||
|
||||
- name: Compute aggregates for the commit summary
|
||||
id: agg
|
||||
run: |
|
||||
# Count distinct per-day entries across all snapshots so we can
|
||||
# show "cumulative observed clones" in the commit message.
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
path = "data/clone-data.rvf"
|
||||
per_day_clones = {}
|
||||
per_day_views = {}
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "clone_snapshot":
|
||||
for entry in d.get("per_day", []):
|
||||
per_day_clones[entry["timestamp"]] = entry
|
||||
elif d.get("type") == "view_snapshot":
|
||||
for entry in d.get("per_day", []):
|
||||
per_day_views[entry["timestamp"]] = entry
|
||||
|
||||
tot_clones = sum(e.get("count", 0) for e in per_day_clones.values())
|
||||
tot_uniq_clones = sum(e.get("uniques", 0) for e in per_day_clones.values())
|
||||
tot_views = sum(e.get("count", 0) for e in per_day_views.values())
|
||||
tot_uniq_views = sum(e.get("uniques", 0) for e in per_day_views.values())
|
||||
print(f"clone days observed: {len(per_day_clones)} total clones: {tot_clones:,} total unique cloners: {tot_uniq_clones:,}")
|
||||
print(f"view days observed: {len(per_day_views)} total views: {tot_views:,} total unique viewers: {tot_uniq_views:,}")
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
|
||||
out.write(f"clones={tot_clones}\n")
|
||||
out.write(f"clone_days={len(per_day_clones)}\n")
|
||||
out.write(f"views={tot_views}\n")
|
||||
out.write(f"view_days={len(per_day_views)}\n")
|
||||
PY
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
if git diff --quiet data/clone-data.rvf; then
|
||||
echo "no changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git add data/clone-data.rvf
|
||||
git commit -m "chore(traffic): clone snapshot — ${{ steps.agg.outputs.clone_days }} days observed → ${{ steps.agg.outputs.clones }} clones, ${{ steps.agg.outputs.view_days }} view-days → ${{ steps.agg.outputs.views }} views"
|
||||
git push
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Dashboard a11y + cross-browser
|
||||
|
||||
# Runs axe-core a11y assertions on the built dashboard across
|
||||
# Chromium, Firefox, and WebKit. Closes ADR-092 §11.5 (axe-core)
|
||||
# and §11.8 (cross-browser).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['dashboard/**', 'v2/crates/nvsim/**']
|
||||
pull_request:
|
||||
paths: ['dashboard/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
a11y:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-unknown-unknown }
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build nvsim WASM
|
||||
working-directory: v2
|
||||
run: |
|
||||
wasm-pack build crates/nvsim --target web \
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release -- --no-default-features --features wasm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json }
|
||||
|
||||
- working-directory: dashboard
|
||||
run: |
|
||||
npm ci
|
||||
npm install --save-dev @playwright/test @axe-core/playwright
|
||||
npx playwright install --with-deps
|
||||
npm run build
|
||||
npx playwright test
|
||||
@@ -1,87 +0,0 @@
|
||||
name: nvsim Dashboard → GitHub Pages
|
||||
|
||||
# Deploys the nvsim Vite/Lit dashboard to gh-pages/nvsim/ — preserving
|
||||
# the existing observatory/, pose-fusion/, and root index.html demos
|
||||
# already published from gh-pages. ADR-092 §9.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/nvsim/**'
|
||||
- 'dashboard/**'
|
||||
- '.github/workflows/dashboard-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dashboard-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust + wasm32 target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-nvsim-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-nvsim-
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: |
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
which wasm-pack
|
||||
|
||||
- name: Build nvsim WASM
|
||||
working-directory: v2
|
||||
run: |
|
||||
wasm-pack build crates/nvsim \
|
||||
--target web \
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release \
|
||||
-- --no-default-features --features wasm
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard deps
|
||||
working-directory: dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard
|
||||
working-directory: dashboard
|
||||
env:
|
||||
NVSIM_BASE: /RuView/nvsim/
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to gh-pages/nvsim/
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./dashboard/dist
|
||||
destination_dir: nvsim
|
||||
# CRITICAL: preserves observatory/, pose-fusion/, root index.html
|
||||
# and any other RuView demos already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'deploy(nvsim): ${{ github.sha }}'
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -40,18 +40,18 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0.0"
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: v2/crates/wifi-densepose-desktop
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
run: cargo tauri build --target ${{ matrix.target }}
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -68,14 +68,14 @@ jobs:
|
||||
|
||||
- name: Package macOS app
|
||||
run: |
|
||||
cd v2/target/${{ matrix.target }}/release/bundle/macos
|
||||
cd rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos
|
||||
zip -r "RuView-Desktop-${{ github.event.inputs.version || '0.4.0' }}-macos-${{ steps.arch.outputs.arch }}.zip" "RuView Desktop.app"
|
||||
|
||||
- name: Upload macOS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-macos-${{ steps.arch.outputs.arch }}
|
||||
path: v2/target/${{ matrix.target }}/release/bundle/macos/*.zip
|
||||
path: rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/*.zip
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -93,18 +93,18 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0.0"
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: v2/crates/wifi-densepose-desktop
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
run: cargo tauri build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -114,13 +114,13 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-windows-msi
|
||||
path: v2/target/release/bundle/msi/*.msi
|
||||
path: rust-port/wifi-densepose-rs/target/release/bundle/msi/*.msi
|
||||
|
||||
- name: Upload Windows NSIS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-windows-nsis
|
||||
path: v2/target/release/bundle/nsis/*.exe
|
||||
path: rust-port/wifi-densepose-rs/target/release/bundle/nsis/*.exe
|
||||
|
||||
create-release:
|
||||
name: Create Release
|
||||
|
||||
@@ -2,11 +2,6 @@ name: Firmware CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
# ESP32 firmware release tags — build + version-consistency guard (RuView#505).
|
||||
- 'v*-esp32'
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- '.github/workflows/firmware-ci.yml'
|
||||
@@ -16,27 +11,6 @@ on:
|
||||
- '.github/workflows/firmware-ci.yml'
|
||||
|
||||
jobs:
|
||||
version-guard:
|
||||
name: Verify version.txt matches release tag
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check firmware version.txt == tag
|
||||
run: |
|
||||
# Tag form: vX.Y.Z-esp32 → expect version.txt to contain X.Y.Z
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
EXPECTED="${TAG#v}"
|
||||
EXPECTED="${EXPECTED%-esp32}"
|
||||
ACTUAL="$(tr -d '[:space:]' < firmware/esp32-csi-node/version.txt)"
|
||||
echo "Tag: $TAG → expected version.txt: $EXPECTED | actual: $ACTUAL"
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "::error::firmware/esp32-csi-node/version.txt is '$ACTUAL' but tag '$TAG' expects '$EXPECTED'."
|
||||
echo "::error::Bump version.txt and re-tag so esp_app_get_description()->version is correct (RuView#505)."
|
||||
exit 1
|
||||
fi
|
||||
echo "version.txt matches the release tag."
|
||||
|
||||
build:
|
||||
name: Build ESP32-S3 Firmware (${{ matrix.variant }})
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Fix-Marker Regression Guard
|
||||
|
||||
# Asserts that previously-shipped fixes are still present in the tree.
|
||||
# Manifest: scripts/fix-markers.json Checker: scripts/check_fix_markers.py
|
||||
# Run locally: python scripts/check_fix_markers.py (also --list / --json)
|
||||
#
|
||||
# This complements the heavyweight checks (firmware build, deterministic
|
||||
# pipeline proof, witness bundle) with a fast per-PR "did someone revert a
|
||||
# known fix?" gate — the CI analogue of the ruflo witness fix-marker system.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
fix-markers:
|
||||
name: Verify fix markers
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Validate the manifest is well-formed JSON
|
||||
run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')"
|
||||
|
||||
- name: Check fix markers
|
||||
run: python scripts/check_fix_markers.py
|
||||
|
||||
- name: Emit machine-readable result (for the run summary)
|
||||
if: always()
|
||||
run: |
|
||||
python scripts/check_fix_markers.py --json > fix-markers-result.json || true
|
||||
{
|
||||
echo '### Fix-marker regression guard'
|
||||
echo ''
|
||||
echo '```'
|
||||
python scripts/check_fix_markers.py || true
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload result artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fix-markers-result
|
||||
path: fix-markers-result.json
|
||||
retention-days: 30
|
||||
@@ -1,69 +0,0 @@
|
||||
name: nvsim-server → ghcr.io
|
||||
|
||||
# Builds and publishes the nvsim-server Docker image to ghcr.io on:
|
||||
# - push to main affecting nvsim-server or nvsim
|
||||
# - tag push matching nvsim-server-v*
|
||||
# - manual workflow_dispatch
|
||||
#
|
||||
# ADR-092 §6.2 + §9.4.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/nvsim-server/**'
|
||||
- 'v2/crates/nvsim/**'
|
||||
- '.github/workflows/nvsim-server-docker.yml'
|
||||
tags: ['nvsim-server-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/ruvnet/nvsim-server
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: v2
|
||||
file: v2/crates/nvsim-server/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Smoke-test the image
|
||||
run: |
|
||||
docker pull ghcr.io/ruvnet/nvsim-server:sha-${GITHUB_SHA::7} || \
|
||||
docker pull ghcr.io/ruvnet/nvsim-server:latest
|
||||
docker run --rm -d --name nvsim-test -p 7878:7878 \
|
||||
ghcr.io/ruvnet/nvsim-server:latest
|
||||
sleep 4
|
||||
curl -fsS http://localhost:7878/api/health
|
||||
docker stop nvsim-test
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Point Cloud Viewer → GitHub Pages
|
||||
|
||||
# Publishes the live 3D point cloud viewer to gh-pages/pointcloud/.
|
||||
# The viewer defaults to a synthetic in-browser demo; users can append
|
||||
# ?backend=<url> or ?backend=auto to point it at a real ruview-pointcloud
|
||||
# server (CORS-permitting host required). See ADR-094.
|
||||
#
|
||||
# Uses keep_files: true to preserve the existing observatory/, pose-fusion/,
|
||||
# nvsim/, and root index.html demos already on gh-pages.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-pointcloud/src/viewer.html'
|
||||
- '.github/workflows/pointcloud-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: pointcloud-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Stage viewer for Pages
|
||||
run: |
|
||||
mkdir -p _site/pointcloud
|
||||
cp v2/crates/wifi-densepose-pointcloud/src/viewer.html _site/pointcloud/index.html
|
||||
# Drop a tiny README so direct browsers of the directory get context.
|
||||
cat > _site/pointcloud/README.md <<'EOF'
|
||||
# RuView — Live 3D Point Cloud Viewer
|
||||
|
||||
Hosted at: https://ruvnet.github.io/RuView/pointcloud/
|
||||
|
||||
## Modes
|
||||
|
||||
- Default — synthetic in-browser demo (no backend, no network calls).
|
||||
- `?backend=auto` — fetch from `/api/splats` on the same origin
|
||||
(only works when the viewer is served by `ruview-pointcloud serve`).
|
||||
- `?backend=<url>` — fetch from `<url>/api/splats`. The intended
|
||||
local-ESP32 use is `?backend=http://127.0.0.1:9880`: run
|
||||
`ruview-pointcloud serve --bind 127.0.0.1:9880` on the same
|
||||
machine with your ESP32 streaming CSI to UDP port 3333, then
|
||||
visit the URL above. The local server's CorsLayer permits
|
||||
requests from `https://ruvnet.github.io`, and modern browsers
|
||||
permit HTTPS→127.0.0.1 mixed-content as a trustworthy origin.
|
||||
The "📡 Connect ESP32" button in the viewer prompts for this
|
||||
URL and persists it in localStorage.
|
||||
- `?live=1` — require a live backend; show an offline message instead
|
||||
of falling back to the synthetic demo.
|
||||
|
||||
See ADR-094 for the deployment design.
|
||||
EOF
|
||||
|
||||
- name: Deploy to gh-pages/pointcloud/
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site/pointcloud
|
||||
destination_dir: pointcloud
|
||||
# CRITICAL: preserves observatory/, pose-fusion/, nvsim/, and root
|
||||
# index.html already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'deploy(pointcloud): ${{ github.sha }}'
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||
@@ -18,27 +18,23 @@ jobs:
|
||||
sast:
|
||||
name: Static Application Security Testing
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
@@ -50,7 +46,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Bandit results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -58,7 +53,6 @@ jobs:
|
||||
category: bandit
|
||||
|
||||
- name: Run Semgrep security scan
|
||||
continue-on-error: true
|
||||
uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
@@ -76,7 +70,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Semgrep results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -87,25 +80,21 @@ jobs:
|
||||
dependency-scan:
|
||||
name: Dependency Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
@@ -122,7 +111,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Snyk vulnerability scan
|
||||
uses: snyk/actions/python@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
uses: snyk/actions/python@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
@@ -130,7 +119,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Snyk results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -138,7 +126,6 @@ jobs:
|
||||
category: snyk
|
||||
|
||||
- name: Upload vulnerability reports
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -152,7 +139,6 @@ jobs:
|
||||
container-scan:
|
||||
name: Container Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
needs: []
|
||||
if: github.event_name == 'push' || github.event_name == 'schedule'
|
||||
permissions:
|
||||
@@ -161,16 +147,13 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
continue-on-error: true
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image for scanning
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -180,15 +163,13 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: 'wifi-densepose:scan'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -196,8 +177,7 @@ jobs:
|
||||
category: trivy
|
||||
|
||||
- name: Run Grype vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: anchore/scan-action@v7
|
||||
uses: anchore/scan-action@v3
|
||||
id: grype-scan
|
||||
with:
|
||||
image: 'wifi-densepose:scan'
|
||||
@@ -206,7 +186,6 @@ jobs:
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -214,7 +193,6 @@ jobs:
|
||||
category: grype
|
||||
|
||||
- name: Run Docker Scout
|
||||
continue-on-error: true
|
||||
uses: docker/scout-action@v1
|
||||
if: always()
|
||||
with:
|
||||
@@ -224,7 +202,6 @@ jobs:
|
||||
summary: true
|
||||
|
||||
- name: Upload Docker Scout results
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -235,19 +212,16 @@ jobs:
|
||||
iac-scan:
|
||||
name: Infrastructure Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Checkov IaC scan
|
||||
continue-on-error: true
|
||||
uses: bridgecrewio/checkov-action@99bb2caf247dfd9f03cf984373bc6043d4e32ebf # v12.1347.0
|
||||
uses: bridgecrewio/checkov-action@master
|
||||
with:
|
||||
directory: .
|
||||
framework: kubernetes,dockerfile,terraform,ansible
|
||||
@@ -257,7 +231,6 @@ jobs:
|
||||
soft_fail: true
|
||||
|
||||
- name: Upload Checkov results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -265,8 +238,7 @@ jobs:
|
||||
category: checkov
|
||||
|
||||
- name: Run Terrascan IaC scan
|
||||
continue-on-error: true
|
||||
uses: tenable/terrascan-action@3a6e87da8e244513bd77b631e624552643f794c6 # v1.4.1
|
||||
uses: tenable/terrascan-action@main
|
||||
with:
|
||||
iac_type: 'k8s'
|
||||
iac_version: 'v1'
|
||||
@@ -275,8 +247,7 @@ jobs:
|
||||
sarif_upload: true
|
||||
|
||||
- name: Run KICS IaC scan
|
||||
continue-on-error: true
|
||||
uses: checkmarx/kics-github-action@05aa5eb70eede1355220f4ca5238d96b397e30a6 # v2.1.20
|
||||
uses: checkmarx/kics-github-action@master
|
||||
with:
|
||||
path: '.'
|
||||
output_path: kics-results
|
||||
@@ -285,7 +256,6 @@ jobs:
|
||||
exclude_queries: 'a7ef1e8c-fbf8-4ac1-b8c7-2c3b0e6c6c6c'
|
||||
|
||||
- name: Upload KICS results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -296,21 +266,18 @@ jobs:
|
||||
secret-scan:
|
||||
name: Secret Scanning
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run TruffleHog secret scan
|
||||
continue-on-error: true
|
||||
uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
base: main
|
||||
@@ -318,7 +285,6 @@ jobs:
|
||||
extra_args: --debug --only-verified
|
||||
|
||||
- name: Run GitLeaks secret scan
|
||||
continue-on-error: true
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -335,34 +301,28 @@ jobs:
|
||||
license-scan:
|
||||
name: License Compliance Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pip-licenses licensecheck
|
||||
|
||||
- name: Run license check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pip-licenses --format=json --output-file=licenses.json
|
||||
licensecheck --zero
|
||||
|
||||
- name: Upload license report
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: license-report
|
||||
@@ -372,14 +332,11 @@ jobs:
|
||||
compliance-check:
|
||||
name: Security Policy Compliance
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check security policy files
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for required security files
|
||||
files=("SECURITY.md" ".github/SECURITY.md" "docs/SECURITY.md")
|
||||
@@ -397,13 +354,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check for security headers in code
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for security-related configurations
|
||||
grep -r "X-Frame-Options\|X-Content-Type-Options\|X-XSS-Protection\|Content-Security-Policy" src/ || echo "⚠️ Consider adding security headers"
|
||||
|
||||
- name: Validate Kubernetes security contexts
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for security contexts in Kubernetes manifests
|
||||
if [[ -d "k8s" ]]; then
|
||||
@@ -420,21 +375,13 @@ jobs:
|
||||
security-report:
|
||||
name: Security Report
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
needs: [sast, dependency-scan, container-scan, iac-scan, secret-scan, license-scan, compliance-check]
|
||||
if: always()
|
||||
# Promote secret to env-scope so the gating `if:` on the Slack-notify
|
||||
# step below is parseable (GitHub Actions rejects `secrets.X` in
|
||||
# step-level `if:` expressions).
|
||||
env:
|
||||
SECURITY_SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Generate security summary
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "# Security Scan Summary" > security-summary.md
|
||||
echo "" >> security-summary.md
|
||||
@@ -450,18 +397,13 @@ jobs:
|
||||
echo "Generated on: $(date)" >> security-summary.md
|
||||
|
||||
- name: Upload security summary
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: security-summary
|
||||
path: security-summary.md
|
||||
|
||||
# GitHub Actions does not allow `secrets.X` in step-level `if:` —
|
||||
# use env.X instead. Inherits SECURITY_SLACK_WEBHOOK_URL from the
|
||||
# job-level env block (added below).
|
||||
- name: Notify security team on critical findings
|
||||
continue-on-error: true
|
||||
if: ${{ env.SECURITY_SLACK_WEBHOOK_URL != '' && (needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure' || needs.container-scan.result == 'failure') }}
|
||||
if: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL != '' && (needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure' || needs.container-scan.result == 'failure') }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: failure
|
||||
@@ -473,12 +415,11 @@ jobs:
|
||||
Workflow: ${{ github.workflow }}
|
||||
Please review the security scan results immediately.
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ env.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Create security issue on critical findings
|
||||
continue-on-error: true
|
||||
if: needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
name: wifi-densepose sensing-server → Docker Hub + ghcr.io
|
||||
|
||||
# Build + publish the `wifi-densepose` sensing-server image to both Docker Hub
|
||||
# (`ruvnet/wifi-densepose`) and ghcr.io (`ghcr.io/ruvnet/wifi-densepose`) on:
|
||||
# - push to main affecting the Dockerfile, the server crate, the UI assets,
|
||||
# or this workflow itself,
|
||||
# - tag push matching v* (release builds),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Closes #520 and #514: the stale `:latest` is rebuilt and pushed automatically
|
||||
# whenever the surface that produces it changes, and the Dockerfile fails the
|
||||
# build if the observatory/pose-fusion UI assets ever go missing again.
|
||||
#
|
||||
# Secrets:
|
||||
# DOCKERHUB_USERNAME — `ruvnet` (Docker Hub login name)
|
||||
# DOCKERHUB_TOKEN — Docker Hub access token with read/write/delete scope
|
||||
# (ghcr.io uses the workflow's GITHUB_TOKEN — no secret needed.)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docker/Dockerfile.rust'
|
||||
- 'docker/docker-entrypoint.sh'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/**'
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-wifiscan/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'ui/**'
|
||||
- '.github/workflows/sensing-server-docker.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: sensing-server-docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
name: build · push · smoke-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# QEMU is required so the amd64 GitHub runner can cross-build the
|
||||
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
|
||||
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
|
||||
# by the runner, not built on a separate arm64 host).
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/ruvnet/wifi-densepose
|
||||
ghcr.io/ruvnet/wifi-densepose
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rust
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# README badge advertises `amd64 + arm64`, and #547 promised multi-arch
|
||||
# as part of the docker publish refresh; arm64 was never actually wired
|
||||
# in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
|
||||
# on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Smoke-test the freshly-pushed image:
|
||||
# 1. UI assets that closed #520 are inside `/app/ui` (the Dockerfile's
|
||||
# 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).
|
||||
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
||||
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Smoke-test image assets + LAN-mode HTTP
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm "$IMAGE" sh -c \
|
||||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
|
||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$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
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:3000/health
|
||||
curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null
|
||||
docker stop sm
|
||||
|
||||
- name: Smoke-test the bearer-token auth path
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker run -d --name auth \
|
||||
-p 3000:3000 \
|
||||
-e CSI_SOURCE=simulated \
|
||||
-e RUVIEW_API_TOKEN=smoke-test-token-do-not-use \
|
||||
"$IMAGE"
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
# /health stays unauthenticated.
|
||||
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||
# /api/v1/info without a bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
|
||||
# Wrong bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer wrong' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401 (wrong token), got $code"; exit 1; }
|
||||
# Correct bearer → 200.
|
||||
curl -fsS -H 'Authorization: Bearer smoke-test-token-do-not-use' http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
docker stop auth
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## sensing-server image published"
|
||||
echo
|
||||
echo "Tags:"
|
||||
echo '```'
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Closes #520 (missing observatory/pose-fusion UI assets) and #514 (stale `:latest` for the v0.6+ packet format)."
|
||||
echo "The Dockerfile fails the build if those UI assets ever disappear again, and this workflow rebuilds + pushes automatically on every change to the surface."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,70 +0,0 @@
|
||||
name: three.js demos → GitHub Pages
|
||||
|
||||
# Publishes the ADR-097 three.js demos under gh-pages/three.js/.
|
||||
# Uses keep_files: true so the existing observatory/, pose-fusion/,
|
||||
# pointcloud/, nvsim/, and root index.html demos are preserved.
|
||||
#
|
||||
# Demos 04 and 05 require a Mixamo "X Bot.fbx" placed in assets/.
|
||||
# That file is intentionally gitignored (license boundary), so this
|
||||
# workflow does NOT ship it. Demos 01-03 work standalone; the index
|
||||
# page documents the FBX requirement honestly.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'examples/three.js/**'
|
||||
- '.github/workflows/threejs-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: threejs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Stage demos for Pages
|
||||
run: |
|
||||
mkdir -p _site/three.js
|
||||
# Copy everything except the local Python server (CI doesn't need it)
|
||||
# and any stray scratch screenshots.
|
||||
cp -r examples/three.js/demos _site/three.js/demos
|
||||
cp -r examples/three.js/screenshots _site/three.js/screenshots
|
||||
cp examples/three.js/README.md _site/three.js/README.md
|
||||
# An index.html that lists the 5 demos with the FBX caveat.
|
||||
cp examples/three.js/index.html _site/three.js/index.html
|
||||
# Mixamo FBX is gitignored — assets dir won't exist in CI.
|
||||
# Drop an empty placeholder so the relative path 'assets/' resolves
|
||||
# to a directory listing (404 on missing file) instead of an opaque
|
||||
# network error. Browsers showing the 404 path makes the failure
|
||||
# visible to anyone trying demos 04/05 without their own FBX.
|
||||
mkdir -p _site/three.js/assets
|
||||
cat > _site/three.js/assets/README.txt <<'EOF'
|
||||
The Mixamo "X Bot.fbx" required by demos 04-skinned-fbx.html and
|
||||
05-skinned-realtime.html is intentionally not redistributed here.
|
||||
|
||||
Download your own from https://mixamo.com (FBX Binary, T-Pose,
|
||||
Without Skin) and place it here as "X Bot.fbx" if you want to
|
||||
run those demos locally. See examples/three.js/README.md in the
|
||||
repo for context.
|
||||
EOF
|
||||
echo "Staged contents:"
|
||||
ls -R _site/three.js/ | head -30
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: _site
|
||||
# Critical: preserve observatory/, pose-fusion/, pointcloud/, nvsim/
|
||||
# and the root index.html already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'three.js demos: ${{ github.event.head_commit.message }}'
|
||||
@@ -19,24 +19,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Identity must be set BEFORE any operation that can create a commit.
|
||||
# `git submodule update --remote --merge` used to fail here with
|
||||
# "Committer identity unknown" because the merge inside vendor/ruvector
|
||||
# needs an author when the pinned commit isn't a fast-forward of upstream.
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Use a plain `--remote` checkout (detached HEAD at each submodule's
|
||||
# configured `branch` tip from .gitmodules) rather than `--merge`. We only
|
||||
# want to bump the superproject's gitlink to the latest upstream commit;
|
||||
# there's no reason to create merge commits inside the vendored repos, and
|
||||
# `--merge` breaks whenever the current pin has diverged from that branch.
|
||||
- name: Update submodules to latest tracked branch
|
||||
run: |
|
||||
git submodule sync --recursive
|
||||
git submodule update --remote --recursive
|
||||
- name: Update submodules to latest main
|
||||
run: git submodule update --remote --merge
|
||||
|
||||
- name: Check for changes
|
||||
id: check
|
||||
@@ -45,22 +29,21 @@ jobs:
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "--- submodule pointer changes ---"
|
||||
git submodule status --recursive || true
|
||||
git diff --submodule=log -- vendor/ || true
|
||||
fi
|
||||
|
||||
- name: Create PR with updates
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
BRANCH="chore/update-submodules-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
git add vendor/
|
||||
git commit -m "chore: update vendor submodules to latest upstream"
|
||||
git commit -m "chore: update vendor submodules to latest main"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--title "chore: update vendor submodules" \
|
||||
--body "Automated submodule update to the latest upstream commit on each submodule's tracked branch (see \`.gitmodules\`). Review the pointer diff before merging." \
|
||||
--body "Automated submodule update to latest upstream main." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
env:
|
||||
|
||||
@@ -4,16 +4,16 @@ on:
|
||||
push:
|
||||
branches: [ main, master, 'claude/**' ]
|
||||
paths:
|
||||
- 'archive/v1/src/core/**'
|
||||
- 'archive/v1/src/hardware/**'
|
||||
- 'archive/v1/data/proof/**'
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'archive/v1/src/core/**'
|
||||
- 'archive/v1/src/hardware/**'
|
||||
- 'archive/v1/data/proof/**'
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -30,26 +30,26 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install pinned dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r archive/v1/requirements-lock.txt
|
||||
pip install -r v1/requirements-lock.txt
|
||||
|
||||
- name: Verify reference signal is reproducible
|
||||
run: |
|
||||
echo "=== Regenerating reference signal ==="
|
||||
python archive/v1/data/proof/generate_reference_signal.py
|
||||
python v1/data/proof/generate_reference_signal.py
|
||||
echo ""
|
||||
echo "=== Checking data file matches committed version ==="
|
||||
# The regenerated file should be identical to the committed one
|
||||
# (We compare the metadata file since data file is large)
|
||||
python -c "
|
||||
import json, hashlib
|
||||
with open('archive/v1/data/proof/sample_csi_meta.json') as f:
|
||||
with open('v1/data/proof/sample_csi_meta.json') as f:
|
||||
meta = json.load(f)
|
||||
assert meta['is_synthetic'] == True, 'Metadata must mark signal as synthetic'
|
||||
assert meta['numpy_seed'] == 42, 'Seed must be 42'
|
||||
@@ -57,18 +57,7 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Run pipeline verification
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
# Pin thread count for scipy.fft / BLAS — multi-threaded reduction
|
||||
# order is otherwise non-deterministic across CI runs (issue #560
|
||||
# follow-up: 9- and 6-decimal quantization were not enough because
|
||||
# the divergence is from threading order, not SIMD reordering).
|
||||
# Single-threaded keeps the proof reproducible at a ~2-3x slowdown.
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Running pipeline verification ==="
|
||||
python data/proof/verify.py
|
||||
@@ -76,13 +65,7 @@ jobs:
|
||||
echo "Pipeline verification PASSED."
|
||||
|
||||
- name: Run verification twice to confirm determinism
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Second run for determinism confirmation ==="
|
||||
python data/proof/verify.py
|
||||
@@ -93,7 +76,7 @@ jobs:
|
||||
echo "=== Scanning for unseeded np.random usage in production code ==="
|
||||
# Search for np.random calls without a seed in production code
|
||||
# Exclude test files, proof data generators, and known parser placeholders
|
||||
VIOLATIONS=$(grep -rn "np\.random\." archive/v1/src/ \
|
||||
VIOLATIONS=$(grep -rn "np\.random\." v1/src/ \
|
||||
--include="*.py" \
|
||||
--exclude-dir="__pycache__" \
|
||||
| grep -v "np\.random\.RandomState" \
|
||||
|
||||
@@ -13,9 +13,6 @@ firmware/esp32-csi-node/managed_components/
|
||||
firmware/esp32-csi-node/dependencies.lock
|
||||
firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
|
||||
# ESP-IDF set-target backup (local only)
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Claude Flow swarm runtime state
|
||||
.swarm/
|
||||
|
||||
@@ -255,9 +252,3 @@ firmware/esp32-csi-node/build_firmware.batdata/
|
||||
models/
|
||||
demo_pointcloud.ply
|
||||
demo_splats.json
|
||||
|
||||
# rvCSI napi-rs addon — generated by `napi build` (do not commit)
|
||||
v2/crates/rvcsi-node/*.node
|
||||
v2/crates/rvcsi-node/binding.js
|
||||
v2/crates/rvcsi-node/binding.d.ts
|
||||
v2/crates/rvcsi-node/npm/
|
||||
|
||||
@@ -10,7 +10,3 @@
|
||||
path = vendor/sublinear-time-solver
|
||||
url = https://github.com/ruvnet/sublinear-time-solver
|
||||
branch = main
|
||||
[submodule "vendor/rvcsi"]
|
||||
path = vendor/rvcsi
|
||||
url = https://github.com/ruvnet/rvcsi
|
||||
branch = main
|
||||
|
||||
+3
-229
@@ -7,228 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
|
||||
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
|
||||
- `POST /api/v1/recording/start` (`recording.rs` — `session_name`)
|
||||
- `GET /api/v1/recording/download/:id` (`recording.rs` — `id`)
|
||||
- `DELETE /api/v1/recording/delete/:id` (`recording.rs` — `id`)
|
||||
- `POST /api/v1/models/load` (`model_manager.rs` — `model_id`)
|
||||
- `training_api.rs` `load_recording_frames` (`dataset_id`s)
|
||||
|
||||
Pre-fix, unauthenticated callers could read `../../etc/passwd`-style paths, write arbitrary JSONL files, load attacker-controlled `.rvf` model files, or delete arbitrary files the server process could touch. 9 unit tests in `path_safety::tests` exercise the rejection envelope (empty, too-long, path separators, parent-dir traversal, null byte, whitespace/specials, non-ASCII).
|
||||
|
||||
### Fixed
|
||||
- **WebSocket `/ws/sensing` now reports `esp32:offline` when ESP32 hardware goes stale** (closes #618). `broadcast_tick_task` was re-emitting the cached `latest_update` with a frozen `source: "esp32"` field forever after the hardware lost power or network. The REST `/health` endpoint already called `effective_source()` (which returns `"esp32:offline"` after `ESP32_OFFLINE_TIMEOUT` = 5 s with no UDP frames), but the WS broadcast path was the one consumer that didn't. Result: the UI's "LIVE — ESP32 HARDWARE Connected" banner stayed green long after the hardware went away, and `vital_signs`/`features`/`classification` re-broadcasted the last-seen values indefinitely. Fix: clone the cached `latest_update` per tick, overwrite `source` with `s.effective_source()`, then serialize and broadcast. UI can now switch to an offline state on the same 5-second budget the REST surface uses.
|
||||
- **Proof replay (`archive/v1/data/proof/verify.py`) is now cross-platform deterministic** (closes #560). Three changes together: (1) `features_to_bytes()` now `np.round(.., HASH_QUANTIZATION_DECIMALS=6)`s each feature array before packing as little-endian f64, collapsing ULP-level drift from scipy.fft pocketfft SIMD reordering; (2) the `Verify Pipeline Determinism` workflow pins `OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`, `VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1` — multi-threaded BLAS reductions were a deeper source of non-determinism than SIMD reordering, and 6-decimal quantization alone wasn't enough across Azure VM microarchitectures; (3) `expected_features.sha256` regenerated under the new conditions. CI now passes the determinism check (same hash across consecutive runs on canonical Linux x86_64 CI runner: `667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7`). `scripts/probe-fft-platform.py` updated to mirror `HASH_QUANTIZATION_DECIMALS=6` for cross-machine spot-checks.
|
||||
- **`archive/v1/src/services/pose_service.py:223` calls the right method on `PhaseSanitizer`** (closes #612). The call was `self.phase_sanitizer.sanitize(phase_data)`, but `PhaseSanitizer`'s full-pipeline entry point is named `sanitize_phase()` (`unwrap_phase` + `remove_outliers` + `smooth_phase` chained, see `archive/v1/src/core/phase_sanitizer.py:266`). The shorter `sanitize` name doesn't exist on the class, so any path that reached this branch raised `AttributeError` and crashed the pose service mid-frame.
|
||||
- **`adaptive_classifier.rs:94` no longer panics on NaN feature values** (closes #611).
|
||||
`sorted.sort_by(|a, b| a.partial_cmp(b).unwrap())` returned `None` and panicked
|
||||
whenever a single `NaN` reached the classifier from real ESP32 hardware (silent
|
||||
DSP div-by-zero, empty buffer). One bad frame killed the entire sensing-server
|
||||
process. Swapped for `unwrap_or(Ordering::Equal)`, matching the pattern the
|
||||
same file already used at lines 149-150 and 155. Per-frame hot path; this was
|
||||
a real production crash vector.
|
||||
- **Completed the #611 NaN-panic audit across the sensing-server crate** (follow-up
|
||||
to #613). The original audit grepped for the literal `partial_cmp(b).unwrap()`
|
||||
and missed seven additional production sites that use comparator variants
|
||||
(`partial_cmp(b.1).unwrap()`, `partial_cmp(&variances[b]).unwrap()`). All share
|
||||
the same crash class — a single `NaN` in CSI-derived state panics the whole
|
||||
sensing-server. Fixed:
|
||||
- `adaptive_classifier.rs:205` — `AdaptiveModel::classify()` argmax over softmax
|
||||
probs. **Same per-frame hot path as #611**; NaN flows through normalise →
|
||||
logits → softmax and still reaches this site even after the #613 IQR fix.
|
||||
- `adaptive_classifier.rs:480, 500` — training-loop argmax in `train()`
|
||||
(training/per-class accuracy reporting).
|
||||
- `main.rs:2446, 2449` and `csi.rs:602, 605` — variance-based source/sink
|
||||
selection in `count_persons_mincut`. The outer `unwrap_or((0, &0))` only
|
||||
catches an empty iterator; it cannot rescue a comparator panic.
|
||||
|
||||
Remaining `partial_cmp(...).unwrap()` sites in the workspace are all inside
|
||||
`#[cfg(test)]` / `#[test]` blocks (`spectrogram.rs:269`, `depth.rs:234`,
|
||||
`connectivity.rs:477`, `vital_signs.rs:737`) where inputs are controlled.
|
||||
- **`ui/utils/pose-renderer.js` no longer divides by zero** when two render frames land in the same `performance.now()` tick (issue #519 Bug 2). `deltaTime` is now `Math.max(currentTime - lastFrameTime, 1)` before the `1000 / deltaTime` division, capping displayed FPS at 1000 — far above any real render rate, but finite so the EMA `averageFps = averageFps * 0.9 + fps * 0.1` no longer poisons itself to `Infinity` on a single zero-dt tick.
|
||||
|
||||
### Removed
|
||||
- **Stub crates `wifi-densepose-api`, `wifi-densepose-db`, `wifi-densepose-config`** (closes #578).
|
||||
Each was a single-line doc-comment placeholder with an empty `[dependencies]`
|
||||
section and zero references from any source file or `Cargo.toml`. The names
|
||||
were reserved early for an envisioned REST/database/config split that never
|
||||
materialised; the functionality they would provide is covered today by
|
||||
`wifi-densepose-sensing-server` (Axum REST/WS), per-crate config + CLI args,
|
||||
and the project's real-time-only (no-persistent-state) posture. Removing them
|
||||
from the workspace prevents `cargo` from listing dead crates and shipping
|
||||
empty published artifacts. If any of these names is needed in the future,
|
||||
they can be reintroduced with a real implementation.
|
||||
|
||||
### Added
|
||||
- **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).**
|
||||
New `wifi_densepose_sensing_server::introspection` module wires
|
||||
[midstream](https://github.com/ruvnet/midstream)'s `temporal-attractor` (Lyapunov +
|
||||
regime classification) and `temporal-compare` (DTW pattern matching) as a
|
||||
**parallel tap** alongside RuView's existing event pipeline — no replacement,
|
||||
no behaviour change to the existing `/ws/sensing` fan-out or `wifi-densepose-signal`
|
||||
DSP. Two new endpoints (off by default, enabled via `--introspection`):
|
||||
- `GET /ws/introspection` — newline-delimited JSON snapshots streamed at the CSI
|
||||
frame rate. Each snapshot carries `frame_count`, `regime` (Idle / Periodic /
|
||||
Transient / Chaotic / Unknown), `lyapunov_exponent`, `attractor_dim`,
|
||||
`attractor_confidence`, `regime_changed` (boolean — flips on the first frame
|
||||
after a regime transition), and `top_k_similarity[]` (highest-scoring
|
||||
signature matches against a per-deployment library).
|
||||
- `GET /api/v1/introspection/snapshot` — single-shot JSON snapshot, auth-gated
|
||||
when `RUVIEW_API_TOKEN` is set.
|
||||
Per-frame `update()` budget measured at **0.041 ms p99** on the I5 bench
|
||||
(~24× under ADR-099 D4's 1 ms target). Shape-match latency on a 1-D
|
||||
mean-amplitude L1 stand-in: **5 frames** (3.20× ratio vs the 16-frame event-path
|
||||
floor). ADR-099 D8 honestly amended — the aspirational 10× bar is contingent on
|
||||
ADR-208 Phase 2 multi-dim NPU embeddings; this release ships the tap off-by-default
|
||||
while the foundation lands. 8 lib tests + 5 latency/regression tests (`tests/introspection_latency.rs`,
|
||||
including a 200-frame noise warm-up → 10-frame motion-ramp signature benchmark).
|
||||
- **Opt-in bearer-token auth on `wifi-densepose-sensing-server`'s `/api/v1/*` HTTP surface (closes #443).**
|
||||
New `wifi_densepose_sensing_server::bearer_auth` module: when the
|
||||
`RUVIEW_API_TOKEN` env var is set, every request whose path begins with
|
||||
`/api/v1/` must carry an `Authorization: Bearer <token>` header (constant-time
|
||||
compared) or the server responds `401 Unauthorized`. When the variable is
|
||||
unset or empty the middleware is a no-op — the long-standing LAN-only
|
||||
deployment posture is preserved, so this is a binary deployment-time switch
|
||||
with **no default behaviour change**. `/health*`, `/ws/sensing`, and the
|
||||
`/ui/*` static mount are intentionally never gated (orchestrator probes +
|
||||
local browsers). Startup logs which mode is active and warns when auth is on
|
||||
with a `0.0.0.0` bind. 8 unit tests on the middleware (lib test count 191 → 199).
|
||||
Resolves the security audit raised in #443.
|
||||
|
||||
### Changed
|
||||
- **Docker image: build-time guard for the UI assets, plus a CI workflow that
|
||||
rebuilds and pushes on every change (closes #520, #514).** `docker/Dockerfile.rust`
|
||||
now `RUN`s a guard after `COPY ui/` that fails the build if any of
|
||||
`index.html` / `observatory.html` / `pose-fusion.html` / `viz.html` / the
|
||||
`observatory/` / `pose-fusion/` / `components/` / `services/` directories are
|
||||
missing, so a stale image can never be silently produced again. New
|
||||
`.github/workflows/sensing-server-docker.yml` builds the image on push to
|
||||
`main` (paths-filtered) and on `v*` tags and pushes to both
|
||||
`docker.io/ruvnet/wifi-densepose` and `ghcr.io/ruvnet/wifi-densepose` with
|
||||
`latest` + `vX.Y.Z` + `sha-<short>` tags, then smoke-tests the published
|
||||
artifact: `/health`, `/api/v1/info`, the observatory + pose-fusion UI assets,
|
||||
and the `RUVIEW_API_TOKEN` auth path (no token → 401, wrong → 401, correct
|
||||
→ 200). Uses `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets for the
|
||||
Docker Hub push; ghcr.io uses the workflow's `GITHUB_TOKEN`.
|
||||
- **rvCSI moved to its own repo and is now vendored as a submodule.** The 9 `rvcsi-*`
|
||||
crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/
|
||||
`-runtime`/`-node`/`-cli` — added inline in #542) now live in
|
||||
[`github.com/ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi): published to crates.io
|
||||
as `rvcsi-* 0.3.x`, to npm as `@ruv/rvcsi`, with a Claude Code plugin marketplace and
|
||||
a RuView-style README. RuView vendors it under `vendor/rvcsi` (alongside
|
||||
`vendor/ruvector` / `vendor/midstream` / `vendor/sublinear-time-solver`) and no longer
|
||||
carries inline copies in `v2/crates/`; consumers depend on the published crates (or the
|
||||
submodule's `crates/rvcsi-*` paths). `v2/Cargo.toml`, `CLAUDE.md`, and the README docs
|
||||
table updated accordingly. The ADRs (ADR-095, ADR-096), PRD, and DDD model stay in
|
||||
`docs/` here as the design record of the incubation.
|
||||
|
||||
### Fixed
|
||||
- **README: corrected the camera-supervised pose-accuracy claim.** The README stated
|
||||
"92.9% PCK@20" for camera-supervised training; that figure does not appear in
|
||||
ADR-079 and is ~2.6× the ADR's own success target (>35% PCK@20). ADR-079 phases
|
||||
P7 (data collection), P8 (training + evaluation on real paired data) and P9
|
||||
(cross-room LoRA) are still `Pending`, so no measured camera-supervised PCK@20 has
|
||||
been published. README now states the proxy-supervised baseline (≈2.5%) and the
|
||||
ADR-079 target (35%+), and notes the eval phases are pending. Surfaced by the
|
||||
PowerPlatePulse training-pipeline audit (2026-05-11); 6 remaining audit findings
|
||||
tracked in the PR.
|
||||
- **rvCSI `BaselineDriftDetector`: drift thresholds are now scale-relative, not absolute.**
|
||||
The detector compared `mean_amplitude` against its EWMA baseline with absolute
|
||||
thresholds (`anomaly_threshold = 1.0`, `drift_threshold = 0.15`) — fine for the
|
||||
synthetic unit tests (amplitudes ≈ 1.0), but raw ESP32 CSI is `int8` I/Q with
|
||||
amplitudes up to ~128, so the window-to-window RMS distance is routinely 5–50 ≫ 1.0
|
||||
and `AnomalyDetected` fired on ~96 % of windows (319/331 on a real node-1 capture).
|
||||
Drift is now `‖current − baseline‖₂ / ‖baseline‖₂` (a fraction, with an `eps` floor
|
||||
for a degenerate near-zero baseline), so one tuning works across raw-`int8` ESP32,
|
||||
`int16`-scaled Nexmon, and baseline-subtracted streams alike — `AnomalyDetected`
|
||||
drops to 40/331 on the same data, the existing detector tests still pass, and a
|
||||
`baseline_drift_is_scale_invariant_no_anomaly_storm` regression test was added.
|
||||
ADR-095 D13 / ADR-096 §2.1, §5 updated. Surfaced by an end-to-end test against
|
||||
real ESP32 CSI (a 7,000-frame node-1 capture; transcoder at
|
||||
`scripts/esp32_jsonl_to_rvcsi.py`).
|
||||
|
||||
### Added
|
||||
- **rvCSI — edge RF sensing runtime (design + first implementation).** New subsystem **rvCSI**: a Rust-first / TypeScript-accessible / hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated `CsiFrame` schema, runs reusable DSP, emits typed confidence-scored events, and bridges to RuVector RF memory, an MCP tool server and a TS SDK.
|
||||
- **Design docs:** `docs/prd/rvcsi-platform-prd.md` (purpose, users, success criteria, FR1–FR10, NFRs, system architecture, data model); `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` (the 15 architectural decisions: Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory, replayability, detection≠decision, local-first, read-first/write-gated MCP, mandatory quality scoring, versioned calibration, plugin adapters); `docs/adr/ADR-096-rvcsi-ffi-crate-layout.md` (crate topology, the napi-c shim record format & contract, the napi-rs Node surface, build/test invariants); `docs/ddd/rvcsi-domain-model.md` (7 bounded contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent — with aggregates, invariants, context map and domain services). Indexed in `docs/adr/README.md` and `docs/ddd/README.md`.
|
||||
- **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, ABI `1.1`), compiled via `build.rs`+`cc`, handling **two byte formats** — the compact self-describing "rvCSI Nexmon record", and the **real nexmon_csi UDP payload** (the 18-byte `magic 0x1111 · rssi · fctl · src_mac · seq · core/stream · chanspec · chip_ver` header + `nsub` int16 I/Q samples, the modern BCM43455c0/4358/4366c0 export read by CSIKit/`csireader.py`), with a Broadcom d11ac **chanspec decoder** (channel/bandwidth/band) — plus a pure-Rust **libpcap reader** (classic `.pcap`, all byte-order/timestamp-resolution magics, Ethernet/raw-IPv4/Linux-SLL link types) and a **Nexmon-chip / Raspberry-Pi-model registry** (`NexmonChip` / `RaspberryPiModel` — including the **Raspberry Pi 5** (CYW43455/BCM43455c0, same wireless as the Pi 4 — 20/40/80 MHz, 2.4+5 GHz, 64/128/256 subcarriers), the Pi 3B+/4/400, and the Pi Zero 2 W (BCM43436b0); `nexmon_adapter_profile` / `raspberry_pi_profile` build the per-chip `AdapterProfile`; `chip_ver` words auto-resolve to a chip). Wrapped by a documented `ffi` module and two `CsiSource`s: `NexmonAdapter` (record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP inside a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture — the pcap timestamp stamps each frame; the chip is auto-detected from `chip_ver`, overridable via `.with_pi_model(Pi5)` / `.with_chip(...)`). `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`; the baseline-drift detector uses **scale-relative** thresholds — drift as a fraction of the baseline's RMS magnitude — so one tuning works across raw-`int8` ESP32, `int16`-scaled Nexmon, and baseline-subtracted streams alike); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`decode_nexmon_pcap`/`summarize_nexmon_pcap`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime` — `nexmonDecodeRecords`/`nexmonDecodePcap` (with optional `chip`)/`inspectNexmonPcap`/`decodeChanspec`/`nexmonChipName`/`nexmonProfile`/`nexmonChips`/`inspectCaptureFile`/`eventsFromCaptureFile`/`exportCaptureToRfMemory` + an `RvcsiRuntime` streaming class; everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` (Nexmon-dump *or* `--source nexmon-pcap [--chip pi5]` → `.rvcsi`), `inspect`, `inspect-nexmon`, `nexmon-chips`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`/`__test__`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects, with a lazy native-addon load.
|
||||
- **Tests:** 169 across the rvcsi crates (core 29, dsp 28, events 19 — incl. a baseline-drift scale-invariance regression, adapter-file 20 + 1 doctest, adapter-nexmon 28 — round-tripping through the C shim and synthetic libpcap files, incl. Pi 5 / chip-detection, ruvector 20 + 1 doctest, runtime 13, cli 10), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Also exercised end-to-end against a real 7,000-frame ESP32 node-1 capture (transcoded with `scripts/esp32_jsonl_to_rvcsi.py` — the stand-in for the not-yet-shipped `record --source esp32-jsonl`): `rvcsi inspect`/`replay`/`calibrate`/`events` all run on real hardware data. Not yet wired in: live radio capture, `rvcsi-adapter-esp32` (live serial/UDP ESP32 source), the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`), and the legacy nexmon *packed-float* CSI export — follow-ups on top of these crates.
|
||||
- **`wifi-densepose-train`: `signal_features` module — wires `wifi-densepose-signal` into the training pipeline.** `wifi-densepose-signal` was previously a phantom dependency of `wifi-densepose-train` (listed in `Cargo.toml`, never imported). New `wifi_densepose_train::signal_features::extract_signal_features` (and `CsiSample::signal_features()`) run a windowed CSI observation's centre frame through `wifi_densepose_signal::features::FeatureExtractor`, producing a fixed-length (`FEATURE_LEN = 12`) amplitude/phase/PSD feature vector — the hook for a future vitals / multi-task supervision head (breathing- and heart-rate-band power are read off the PSD summary). The vector is produced on demand and not yet fed back into the loss. Surfaced by the 2026-05-11 training-pipeline audit (findings #1 "vitals features absent from training" and #2 "`wifi-densepose-signal` ghost dep").
|
||||
- **`wifi-densepose-train`: `TrainingConfig` subcarrier-layout presets + a real-loader integration test.** New `TrainingConfig::for_subcarriers(native, target)` plus named presets `ht40_192()` (≈192-sc ESP32 HT40 → 56) and `multiband_168()` (168-sc ADR-078 multi-band mesh → 56), so non-MM-Fi CSI shapes are first-class instead of requiring manual `native_subcarriers`/`num_subcarriers` overrides; field docs now list the supported source counts and the multi-NIC mapping. New `tests/test_real_loader.rs` round-trips synthetic CSI through `.npy` files → `MmFiDataset::discover`/`get` (including the subcarrier-interpolation branch and the empty-root case) — exercising the on-disk loader path the deterministic `verify-training` proof intentionally bypasses. Addresses training-pipeline audit findings #6 (56-sc/1-NIC config default) and #7 (multi-band mesh not in config); the #4 concern ("proof uses synthetic data") is reframed — the proof *should* use a reproducible source, and this test covers the real loader it skips.
|
||||
|
||||
### Fixed
|
||||
- **HuggingFace `MODEL_CARD.md`: marked the PIR/BME280 environmental-sensor ground-truth path as planned, not implemented** (training-pipeline audit finding #3) — the card presented PIR/BME280 weak-label fine-tuning as a current capability; there is no env-sensor ingestion in the training pipeline today.
|
||||
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
|
||||
|
||||
### Added
|
||||
- **`RollingP95` adaptive feature normalizer** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Streaming P95 estimator (600-sample / ~30 s sliding window) that self-calibrates
|
||||
feature normalization to whatever distribution the deployment produces. Replaces
|
||||
fixed-scale denominators (`variance/300`, `motion/250`, `spectral/500`) which saturated
|
||||
when live ESP32 values exceeded those limits, collapsing dynamic range to zero.
|
||||
Cold-start (<60 samples) falls back to the legacy denominators so day-0 behaviour
|
||||
is preserved. Deployment-neutral: no hardcoded values. (ADR-044 §5.2)
|
||||
|
||||
- **`dedup_factor` runtime configuration API** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Exposes the multi-node person-count deduplication divisor at runtime via REST:
|
||||
- `GET /api/v1/config/dedup-factor` — read current value.
|
||||
- `POST /api/v1/config/dedup-factor` — set value (clamped 1.0–10.0, persisted).
|
||||
- `POST /api/v1/config/ground-truth` — auto-tunes `dedup_factor` from a known
|
||||
person count (`{"count": N}`); derives optimal divisor from current node-sum.
|
||||
Config is persisted to `data/config.json` and reloaded on restart. (ADR-044 §5.3)
|
||||
|
||||
- **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) —
|
||||
New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only
|
||||
magnetic sensing path: scene → source synthesis (Biot–Savart, dipole,
|
||||
current loop, ferrous induced moment) → material attenuation
|
||||
(Air/Drywall/Brick/Concrete/Reinforced/SteelSheet) → NV ensemble
|
||||
(4 〈111〉 axes, ODMR linear-readout proxy, shot-noise floor per
|
||||
Wolf 2015 / Barry 2020) → 16-bit ADC + lock-in demodulation →
|
||||
fixed-layout `MagFrame` records → SHA-256 witness. Six-pass build
|
||||
per `docs/research/quantum-sensing/15-nvsim-implementation-plan.md`.
|
||||
50 tests, ~4.5 M samples/s on x86_64 (4500× the Cortex-A53 1 kHz
|
||||
acceptance gate), pinned reference witness
|
||||
`cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4`
|
||||
for byte-equivalence regression. WASM-ready by construction
|
||||
(zero `std::time/fs/env/process/thread`); builds cleanly for
|
||||
`wasm32-unknown-unknown`. ADR-090 (Proposed, conditional) tracks the
|
||||
optional Lindblad/Hamiltonian extension if AC magnetometry, MW power
|
||||
saturation, hyperfine spectroscopy, or pulsed protocols become required.
|
||||
|
||||
### Fixed
|
||||
- **WebSocket broadcast handler now handles Lagged events gracefully and sends periodic ping keepalives to prevent dashboard disconnects** —
|
||||
`handle_ws_client` and `handle_ws_pose_client` in `wifi-densepose-sensing-server`
|
||||
were treating `RecvError::Lagged` as a fatal error, causing instant disconnect
|
||||
when clients fell behind the 256-frame broadcast buffer at 10 Hz ingest.
|
||||
Clients would reconnect, immediately lag again, and rapid-cycle every 2–4 s.
|
||||
`Lagged` now continues (drops missed frames, logs debug) rather than breaking.
|
||||
Added 30 s ping keepalive on the sensing handler to prevent proxy idle timeouts.
|
||||
- **Ghost skeletons in live UI with multi-node ESP32 setups** (#420, ADR-082) —
|
||||
`tracker_bridge::tracker_to_person_detections` documented itself as filtering
|
||||
to `is_alive()` tracks but in fact passed every non-Terminated track to the
|
||||
WebSocket stream. `Lost` tracks — kept inside `reid_window` for
|
||||
re-identification but not currently observed — were rendering as phantom
|
||||
skeletons, accumulating to 22-24 with 3 nodes × 10 Hz CSI while
|
||||
`estimated_persons` correctly reported 1. Added
|
||||
`PoseTracker::confirmed_tracks()` (Tentative + Active only) and rewired the
|
||||
bridge to use it. Lost tracks remain in the tracker for re-ID; they just
|
||||
no longer ship to the UI. Regression test:
|
||||
`test_lost_tracks_excluded_from_bridge_output`.
|
||||
- **Rust workspace build with `--no-default-features` on Windows** (#366, #415) —
|
||||
`wifi-densepose-mat`, `wifi-densepose-sensing-server`, and `wifi-densepose-train`
|
||||
all depended on `wifi-densepose-signal` with default features enabled, which
|
||||
pulled `ndarray-linalg` → `openblas-src` → vcpkg/system-BLAS through the entire
|
||||
workspace. `--no-default-features` at the workspace root then could not opt out
|
||||
of BLAS, breaking `cargo build` / `cargo test` on Windows without vcpkg. All
|
||||
three consumers now declare `wifi-densepose-signal = { ..., default-features = false }`,
|
||||
so `cargo test --workspace --no-default-features` builds cleanly without
|
||||
vcpkg/openblas. Validated: 1,538 tests pass, 0 fail, 8 ignored.
|
||||
- **`signal` test `test_estimate_occupancy_noise_only` failed without `eigenvalue`** —
|
||||
The test unwrapped the `NotCalibrated` stub returned when the BLAS-backed
|
||||
`estimate_occupancy` is compiled out. Gated with `#[cfg(feature = "eigenvalue")]`
|
||||
so it only runs when the real implementation is available.
|
||||
|
||||
## [v0.6.2-esp32] — 2026-04-20
|
||||
|
||||
Firmware release cutting ADR-081 and the Timer Svc stack fix discovered during
|
||||
@@ -344,11 +122,7 @@ firing cleanly, HEALTH mesh packets sent.
|
||||
Kconfig surface added under "Adaptive Controller (ADR-081)".
|
||||
|
||||
### Fixed
|
||||
- **Firmware: SPI flash cache crash under high CSI callback pressure** (RuView#396, #397) — ESP32-S3 nodes crashed in `cache_ll_l1_resume_icache` / `wDev_ProcessFiq` after ~2400 callbacks when the promiscuous filter admitted DATA frames at 100–500 Hz. Fixed by narrowing the filter mask to `WIFI_PROMIS_FILTER_MASK_MGMT` (~10 Hz beacons), adding a 50 Hz early callback rate gate (`CSI_MIN_PROCESS_INTERVAL_US`) that drops excess callbacks before any processing work, and enabling `CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y` as defense-in-depth. Stability validated with a 4-min-per-node soak.
|
||||
- **Firmware: `filter_mac` / `node_id` clobber by WiFi driver init** (#232, #375, #385, #386, #390, #397) — `g_nvs_config` can be corrupted during `wifi_init_sta()` on some devices (confirmed on `80:b5:4e:c1:be:b8`), reverting `node_id` to the Kconfig default and producing garbage MAC-filter reads in the CSI callback (100–500 Hz). New `csi_collector_set_node_id()` API called from `app_main()` **before** `wifi_init_sta()` captures both fields into module-local statics (`s_node_id`, `s_filter_mac`, `s_filter_mac_set`). `csi_collector_init()` now runs a canary that distinguishes "early≠g_nvs_config" (corruption confirmed) from a no-op match. All CSI runtime paths use the defensive copies exclusively.
|
||||
- **Firmware: `edge_processing` sample rate mismatch** (#397) — `estimate_bpm_zero_crossing()` was called with a hard-coded `sample_rate = 20.0f`, but MGMT-only promiscuous delivers ~10 Hz. Breathing and heart-rate reports were 2× too high. Corrected to `10.0f` with an explicit comment tying it to the callback rate.
|
||||
- **`provision.py` esptool command form** (#391, #397) — ESP-IDF v5.4 bundles `esptool 4.10.0`, which only accepts `write_flash` (underscore). Standalone `pip install esptool` v5.x accepts both forms but prefers `write-flash`. #391 switched to `write-flash` which broke the documented ESP-IDF Python venv flow; #397 reverts to `write_flash` (works with both esptool 4.x and 5.x) with an inline comment warning future maintainers not to "re-fix" it.
|
||||
- **`provision.py` esptool v5 dry-run hint** (#391) — Stale `write_flash` (underscore) syntax in the dry-run manual-flash hint now uses `write-flash` (hyphenated) for esptool >= 5.x. The primary flash command was already correct.
|
||||
- **`provision.py` esptool v5 compat** (#391) — Stale `write_flash` (underscore) syntax in the dry-run manual-flash hint now uses `write-flash` (hyphenated) for esptool >= 5.x. The primary flash command was already correct.
|
||||
- **`provision.py` silent NVS wipe** (#391) — The script replaces the entire `csi_cfg` NVS namespace on every run, so partial invocations were silently erasing WiFi credentials and causing `Retrying WiFi connection (10/10)` in the field. Now refuses to run without `--ssid`, `--password`, and `--target-ip` unless `--force-partial` is passed. `--force-partial` prints a warning listing which keys will be wiped.
|
||||
- **Firmware: defensive `node_id` capture** (#232, #375, #385, #386, #390) — Users on multi-node deployments reported `node_id` reverting to the Kconfig default (`1`) in UDP frames and in the `csi_collector` init log, despite NVS loading the correct value. The root cause (memory corruption of `g_nvs_config`) has not been definitively isolated, but the UDP frame header is now tamper-proof: `csi_collector_init()` captures `g_nvs_config.node_id` into a module-local `s_node_id` once, and `csi_serialize_frame()` plus all other consumers (`edge_processing.c`, `wasm_runtime.c`, `display_ui.c`, `swarm_bridge_init`) read it via the new `csi_collector_get_node_id()` accessor. A canary logs `WARN` if `g_nvs_config.node_id` diverges from `s_node_id` at end-of-init, helping isolate the upstream corruption path. Validated on attached ESP32-S3 (COM8): NVS `node_id=2` propagates through boot log, capture log, init log, and byte[4] of every UDP frame.
|
||||
|
||||
@@ -720,7 +494,7 @@ Major release: complete Rust sensing server, full DensePose training pipeline, R
|
||||
- `PresenceClassifier` — rule-based 3-state classification (ABSENT / PRESENT_STILL / ACTIVE)
|
||||
- Cross-receiver agreement scoring for multi-AP confidence boosting
|
||||
- WebSocket sensing server (`ws_server.py`) broadcasting JSON at 2 Hz
|
||||
- Deterministic CSI proof bundles for reproducible verification (`archive/v1/data/proof/`)
|
||||
- Deterministic CSI proof bundles for reproducible verification (`v1/data/proof/`)
|
||||
- Commodity sensing unit tests (`b391638`)
|
||||
|
||||
### Changed
|
||||
@@ -728,7 +502,7 @@ Major release: complete Rust sensing server, full DensePose training pipeline, R
|
||||
|
||||
### Fixed
|
||||
- Review fixes for end-to-end training pipeline (`45f0304`)
|
||||
- Dockerfile paths updated from `src/` to `archive/v1/src/` (`7872987`)
|
||||
- Dockerfile paths updated from `src/` to `v1/src/` (`7872987`)
|
||||
- IoT profile installer instructions updated for aggregator CLI (`f460097`)
|
||||
- `process.env` reference removed from browser ES module (`e320bc9`)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Project: wifi-densepose
|
||||
|
||||
WiFi-based human pose estimation using Channel State Information (CSI).
|
||||
Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
Dual codebase: Python v1 (`v1/`) and Rust port (`rust-port/wifi-densepose-rs/`).
|
||||
### Key Rust Crates
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
@@ -14,13 +14,14 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-api` | REST API (Axum) |
|
||||
| `wifi-densepose-db` | Database layer (Postgres, SQLite, Redis) |
|
||||
| `wifi-densepose-config` | Configuration management |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
|
||||
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
|
||||
| `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) |
|
||||
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
|
||||
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
|
||||
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
@@ -83,17 +84,17 @@ All 5 ruvector crates integrated in workspace:
|
||||
### Build & Test Commands (this repo)
|
||||
```bash
|
||||
# Rust — full workspace tests (1,031+ tests, ~2 min)
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
|
||||
# Rust — single crate check (no GPU needed)
|
||||
cargo check -p wifi-densepose-train --no-default-features
|
||||
|
||||
# Python — deterministic proof verification (SHA-256)
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
# Python — test suite
|
||||
cd archive/v1 && python -m pytest tests/ -x -q
|
||||
cd v1 && python -m pytest tests/ -x -q
|
||||
```
|
||||
|
||||
### ESP32 Firmware Build (Windows — Python subprocess required)
|
||||
@@ -132,14 +133,17 @@ Crates must be published in dependency order:
|
||||
2. `wifi-densepose-vitals` (no internal deps)
|
||||
3. `wifi-densepose-wifiscan` (no internal deps)
|
||||
4. `wifi-densepose-hardware` (no internal deps)
|
||||
5. `wifi-densepose-signal` (depends on core)
|
||||
6. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
7. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
8. `wifi-densepose-train` (depends on signal, nn)
|
||||
9. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
10. `wifi-densepose-wasm` (depends on mat)
|
||||
11. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
12. `wifi-densepose-cli` (depends on mat)
|
||||
5. `wifi-densepose-config` (no internal deps)
|
||||
6. `wifi-densepose-db` (no internal deps)
|
||||
7. `wifi-densepose-signal` (depends on core)
|
||||
8. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
9. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
10. `wifi-densepose-train` (depends on signal, nn)
|
||||
11. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
12. `wifi-densepose-api` (no internal deps)
|
||||
13. `wifi-densepose-wasm` (depends on mat)
|
||||
14. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
15. `wifi-densepose-cli` (depends on mat)
|
||||
|
||||
### Validation & Witness Verification (ADR-028)
|
||||
|
||||
@@ -147,12 +151,12 @@ Crates must be published in dependency order:
|
||||
|
||||
```bash
|
||||
# 1. Rust tests — must be 1,031+ passed, 0 failed
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
|
||||
# 2. Python proof — must print VERDICT: PASS
|
||||
cd ..
|
||||
python archive/v1/data/proof/verify.py
|
||||
cd ../..
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
# 3. Generate witness bundle (includes both above + firmware hashes)
|
||||
bash scripts/generate-witness-bundle.sh
|
||||
@@ -165,8 +169,8 @@ bash VERIFY.sh
|
||||
**If the Python proof hash changes** (e.g., numpy/scipy version update):
|
||||
```bash
|
||||
# Regenerate the expected hash, then verify it passes
|
||||
python archive/v1/data/proof/verify.py --generate-hash
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py --generate-hash
|
||||
python v1/data/proof/verify.py
|
||||
```
|
||||
|
||||
**Witness bundle contents** (`dist/witness-bundle-ADR028-<sha>.tar.gz`):
|
||||
@@ -179,9 +183,9 @@ python archive/v1/data/proof/verify.py
|
||||
- `VERIFY.sh` — One-command self-verification for recipients
|
||||
|
||||
**Key proof artifacts:**
|
||||
- `archive/v1/data/proof/verify.py` — Trust Kill Switch: feeds reference signal through production pipeline, hashes output
|
||||
- `archive/v1/data/proof/expected_features.sha256` — Published expected hash
|
||||
- `archive/v1/data/proof/sample_csi_data.json` — 1,000 synthetic CSI frames (seed=42)
|
||||
- `v1/data/proof/verify.py` — Trust Kill Switch: feeds reference signal through production pipeline, hashes output
|
||||
- `v1/data/proof/expected_features.sha256` — Published expected hash
|
||||
- `v1/data/proof/sample_csi_data.json` — 1,000 synthetic CSI frames (seed=42)
|
||||
- `docs/WITNESS-LOG-028.md` — 11-step reproducible verification procedure
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md` — Complete audit record
|
||||
|
||||
@@ -207,13 +211,13 @@ Active feature branch: `ruvsense-full-implementation` (PR #77)
|
||||
- NEVER save to root folder — use the directories below
|
||||
- `docs/adr/` — Architecture Decision Records (43 ADRs)
|
||||
- `docs/ddd/` — Domain-Driven Design models
|
||||
- `v2/crates/` — Rust workspace crates (15 crates)
|
||||
- `v2/crates/wifi-densepose-signal/src/ruvsense/` — RuvSense multistatic modules (14 files)
|
||||
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/` — Cross-viewpoint fusion (5 files)
|
||||
- `v2/crates/wifi-densepose-hardware/src/esp32/` — ESP32 TDM protocol
|
||||
- `rust-port/wifi-densepose-rs/crates/` — Rust workspace crates (15 crates)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/` — RuvSense multistatic modules (14 files)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-ruvector/src/viewpoint/` — Cross-viewpoint fusion (5 files)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/` — ESP32 TDM protocol
|
||||
- `firmware/esp32-csi-node/main/` — ESP32 C firmware (channel hopping, NVS config, TDM)
|
||||
- `archive/v1/src/` — Python source (core, hardware, services, api)
|
||||
- `archive/v1/data/proof/` — Deterministic CSI proof bundles
|
||||
- `v1/src/` — Python source (core, hardware, services, api)
|
||||
- `v1/data/proof/` — Deterministic CSI proof bundles
|
||||
- `.claude-flow/` — Claude Flow coordination state (committed for team sharing)
|
||||
- `.claude/` — Claude Code settings, agents, memory (committed for team sharing)
|
||||
|
||||
@@ -239,7 +243,7 @@ Active feature branch: `ruvsense-full-implementation` (PR #77)
|
||||
Before merging any PR, verify each item applies and is addressed:
|
||||
|
||||
1. **Rust tests pass** — `cargo test --workspace --no-default-features` (1,031+ passed, 0 failed)
|
||||
2. **Python proof passes** — `python archive/v1/data/proof/verify.py` (VERDICT: PASS)
|
||||
2. **Python proof passes** — `python v1/data/proof/verify.py` (VERDICT: PASS)
|
||||
3. **README.md** — Update platform tables, crate descriptions, hardware tables, feature summaries if scope changed
|
||||
4. **CLAUDE.md** — Update crate table, ADR list, module tables, version if scope changed
|
||||
5. **CHANGELOG.md** — Add entry under `[Unreleased]` with what was added/fixed/changed
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Archive
|
||||
|
||||
Frozen, no-longer-active components of RuView preserved for historical
|
||||
reference, reproducibility, and load-bearing legacy paths the active
|
||||
codebase still depends on.
|
||||
|
||||
## What lives here
|
||||
|
||||
| Path | What it is | Why it's archived | Still load-bearing? |
|
||||
|------|------------|-------------------|---------------------|
|
||||
| `v1/` | Original Python implementation of RuView (CSI processing, hardware adapters, services, FastAPI) | Superseded by the Rust workspace at `v2/`; ~810× slower in benchmarks. Kept rather than deleted because the deterministic proof bundle (`v1/data/proof/`) is part of the pre-merge witness verification process per ADR-011 / ADR-028. | **Yes — for the proof bundle only.** Active code lives in `v2/`. |
|
||||
|
||||
## What "archived" means
|
||||
|
||||
- **Do not add new features here.** New work goes in `v2/`.
|
||||
- **Do not refactor or modernize the archived code beyond what is
|
||||
strictly necessary** to keep the load-bearing paths working. The
|
||||
Python proof bundle is intentionally frozen so that its SHA-256
|
||||
reproducibility holds across releases (per ADR-028's witness
|
||||
verification requirement).
|
||||
- **Bug fixes inside archived code are allowed** when the bug affects a
|
||||
still-load-bearing path (currently: only the Python proof). All
|
||||
other "bugs" in archived code are out-of-scope — they are part of
|
||||
the historical record and any fix would unnecessarily churn the
|
||||
witness hashes.
|
||||
- **CI continues to verify the load-bearing paths.**
|
||||
`.github/workflows/verify-pipeline.yml` runs the Python proof on
|
||||
every push and PR; if you change anything inside `archive/v1/src/`
|
||||
or `archive/v1/data/proof/`, expect the determinism check to flag
|
||||
it.
|
||||
|
||||
## Quick reference for the load-bearing paths
|
||||
|
||||
```bash
|
||||
# Run the deterministic Python proof (must print VERDICT: PASS)
|
||||
python archive/v1/data/proof/verify.py
|
||||
|
||||
# Regenerate the expected hash (only if numpy/scipy version legitimately changed)
|
||||
python archive/v1/data/proof/verify.py --generate-hash
|
||||
|
||||
# Run the full Python test suite (legacy, still maintained)
|
||||
cd archive/v1&& python -m pytest tests/ -x -q
|
||||
```
|
||||
|
||||
## Why we keep `v1/` rather than delete it
|
||||
|
||||
1. **Trust kill-switch.** The proof at `v1/data/proof/verify.py` feeds
|
||||
a known reference signal through the full pipeline and hashes the
|
||||
output. If the active code's behavior drifts, the hash changes and
|
||||
CI fails. This is what stops accidental regression in the science
|
||||
layer of the codebase.
|
||||
|
||||
2. **Witness verification.** ADR-028's witness-bundle process bundles
|
||||
the proof, the rust workspace test results, and firmware hashes
|
||||
into a tarball recipients can self-verify. Removing v1 would break
|
||||
that chain.
|
||||
|
||||
3. **Historical reference.** ADR-011 documents the "no mocks in
|
||||
production code" decision; the original violations and their fixes
|
||||
live in this Python codebase. The ADRs reference these paths.
|
||||
|
||||
If the time comes to retire the proof bundle (e.g., a Rust port of
|
||||
the proof exists and the Python version is no longer canonical), the
|
||||
right move is a single follow-up that simultaneously: ports the
|
||||
witness-bundle process, updates `verify-pipeline.yml`, and either
|
||||
deletes `archive/v1/` or moves it to a separate read-only repository.
|
||||
That decision belongs in its own ADR.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/adr/ADR-011-python-proof-of-reality-mock-elimination.md`
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md`
|
||||
- `archive/v1/data/proof/README.md` (if present)
|
||||
- `docs/WITNESS-LOG-028.md`
|
||||
@@ -1 +0,0 @@
|
||||
667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7
|
||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
public/nvsim-pkg
|
||||
@@ -1,18 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>RuView · nvsim — NV-Diamond Magnetometer Simulator</title>
|
||||
<meta name="description" content="Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs." />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23e6a86b'/><text x='16' y='22' text-anchor='middle' font-family='monospace' font-weight='700' font-size='14' fill='%231a0f00'>NV</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nv-app></nv-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-6525
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@ruvnet/nvsim-dashboard",
|
||||
"version": "0.1.0",
|
||||
"description": "Vite + Lit dashboard for the nvsim NV-diamond magnetometer pipeline simulator (ADR-092).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 4173",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:a11y": "playwright test tests/a11y.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lit": "^3.2.1",
|
||||
"workbox-window": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
headless: true,
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run preview',
|
||||
port: 4173,
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||
{ name: 'firefox', use: { browserName: 'firefox' } },
|
||||
{ name: 'webkit', use: { browserName: 'webkit' } },
|
||||
],
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||
<rect width="192" height="192" rx="36" fill="#e6a86b"/>
|
||||
<text x="96" y="124" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="80" fill="#1a0f00">NV</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 313 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#e6a86b"/>
|
||||
<stop offset="1" stop-color="#a4633a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#g)"/>
|
||||
<text x="256" y="332" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="220" fill="#1a0f00">NV</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1,92 +0,0 @@
|
||||
/* nvsim dashboard — global styles
|
||||
Ported from `assets/NVsim Dashboard.zip` per ADR-092 §7.1.
|
||||
Per-component scoped styles live in each Lit element. */
|
||||
|
||||
:root {
|
||||
--bg-0: #07090d;
|
||||
--bg-1: #0d1117;
|
||||
--bg-2: #131a23;
|
||||
--bg-3: #1a232f;
|
||||
--line: #1f2a38;
|
||||
--line-2: #2a3848;
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #b8c2cc;
|
||||
--ink-3: #7c8694;
|
||||
--ink-4: #4a5462;
|
||||
--accent: oklch(0.78 0.14 70);
|
||||
--accent-2: oklch(0.78 0.12 195);
|
||||
--accent-3: oklch(0.72 0.18 330);
|
||||
--accent-4: oklch(0.78 0.14 145);
|
||||
--warn: oklch(0.7 0.18 35);
|
||||
--ok: oklch(0.78 0.14 145);
|
||||
--bad: oklch(0.65 0.22 25);
|
||||
--grid: rgba(255, 255, 255, 0.04);
|
||||
--shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6),
|
||||
0 4px 12px -4px rgba(0, 0, 0, 0.4);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-0: #f4f5f7;
|
||||
--bg-1: #fbfbfc;
|
||||
--bg-2: #ffffff;
|
||||
--bg-3: #f0f2f5;
|
||||
--line: #d8dde3;
|
||||
--line-2: #c1c8d1;
|
||||
--ink: #0e131a;
|
||||
--ink-2: #2c3744;
|
||||
--ink-3: #54606e; /* AA on --bg-1 #fbfbfc — was #6b7684 (3.7:1), now ~5.4:1 */
|
||||
--ink-4: #7a8390; /* improved from #9ba4b0 for incidental UI labels */
|
||||
--grid: rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 12px 40px -16px rgba(15, 30, 55, 0.18),
|
||||
0 2px 8px -2px rgba(15, 30, 55, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||
input, select { font-family: inherit; color: inherit; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ink-4); }
|
||||
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
@keyframes dash { to { stroke-dashoffset: -200; } }
|
||||
@keyframes float-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes diamond-spin {
|
||||
0% { transform: rotateY(0) rotateX(8deg); }
|
||||
100% { transform: rotateY(360deg) rotateX(8deg); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
body.reduce-motion *,
|
||||
body.reduce-motion *::before,
|
||||
body.reduce-motion *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Density (set via class on <body> by setDensity()) */
|
||||
body.density-comfy { font-size: 15px; }
|
||||
body.density-default { font-size: 14px; }
|
||||
body.density-compact { font-size: 13px; }
|
||||
@@ -1,399 +0,0 @@
|
||||
/* App Store — catalog of every WASM edge module + simulator app.
|
||||
*
|
||||
* Mirrors `wifi-densepose-wasm-edge`'s 60+ hot-loadable algorithms and
|
||||
* the `nvsim` simulator. Each card is filterable by category, fuzzy
|
||||
* name search, and maturity (available / beta / research). A toggle on
|
||||
* each card flips activation in the live session — that drives the
|
||||
* dashboard's event log when running. WS transport (future) pushes the
|
||||
* activation set to the connected ESP32 mesh.
|
||||
*
|
||||
* ADR-092 §18.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { signal, effect } from '@preact/signals-core';
|
||||
import {
|
||||
APPS, CATEGORIES, defaultActivations, fuzzyMatch,
|
||||
type AppCategory, type AppManifest, type AppActivation,
|
||||
} from '../store/apps';
|
||||
import { kvGet, kvSet } from '../store/persistence';
|
||||
import { pushLog, activeAppIds, appEvents, appEventCounts } from '../store/appStore';
|
||||
import { hasRuntime } from '../store/appRuntimes';
|
||||
|
||||
const activations = signal<AppActivation[]>(defaultActivations());
|
||||
const query = signal<string>('');
|
||||
const activeCat = signal<AppCategory | 'all'>('all');
|
||||
const statusFilter = signal<'all' | 'available' | 'beta' | 'research'>('all');
|
||||
|
||||
(async () => {
|
||||
const saved = await kvGet<AppActivation[]>('app-activations');
|
||||
if (saved) activations.value = saved;
|
||||
})();
|
||||
|
||||
effect(() => {
|
||||
// Persist activations on change (post-load) AND mirror into the
|
||||
// active-set signal that main.ts watches to drive runtime dispatch.
|
||||
const v = activations.value;
|
||||
if (v.length > 0) void kvSet('app-activations', v);
|
||||
const set = new Set<string>();
|
||||
for (const a of v) if (a.active) set.add(a.id);
|
||||
activeAppIds.value = set;
|
||||
});
|
||||
|
||||
@customElement('nv-app-store')
|
||||
export class NvAppStore extends LitElement {
|
||||
@state() private renderTick = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 24px;
|
||||
}
|
||||
.head {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ttl {
|
||||
font-size: 22px; font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
flex: 1; min-width: 200px;
|
||||
}
|
||||
.ttl small {
|
||||
font-size: 12.5px; font-weight: 400;
|
||||
color: var(--ink-3); margin-left: 8px;
|
||||
}
|
||||
.search {
|
||||
width: 320px; max-width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink); outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--accent); }
|
||||
.filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.chip {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.chip:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.chip.on { background: var(--bg-3); border-color: var(--accent); color: var(--ink); }
|
||||
.chip .swatch {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
}
|
||||
.chip .count { color: var(--ink-3); font-size: 10px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.card:hover { border-color: var(--line-2); transform: translateY(-1px); }
|
||||
.card.active {
|
||||
border-color: oklch(0.78 0.14 145 / 0.7);
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 145 / 0.04) 100%);
|
||||
}
|
||||
.card-h {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.card-h .name {
|
||||
font-size: 13.5px; font-weight: 600; color: var(--ink);
|
||||
flex: 1; line-height: 1.3;
|
||||
}
|
||||
.card-h .swatch {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
flex-shrink: 0; margin-top: 4px;
|
||||
}
|
||||
.summary {
|
||||
font-size: 12px; color: var(--ink-2); line-height: 1.45;
|
||||
flex: 1;
|
||||
}
|
||||
.meta {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
}
|
||||
.badge {
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--bg-3); color: var(--ink-3);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.badge.cat { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.3); }
|
||||
.badge.status-available { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.badge.status-beta { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
|
||||
.badge.status-research { color: var(--accent-3); border-color: oklch(0.72 0.18 330 / 0.4); }
|
||||
.badge.budget { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.3); }
|
||||
.badge.rt-running { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.5); background: oklch(0.78 0.14 145 / 0.08); }
|
||||
.badge.rt-simulated { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.5); background: oklch(0.78 0.14 70 / 0.08); }
|
||||
.badge.rt-mesh-only { color: var(--ink-3); border-color: var(--line); }
|
||||
.events-feed {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.events-feed h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.events-feed .lead {
|
||||
font-size: 12px; color: var(--ink-3);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.events-feed .lines {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
}
|
||||
.ev-line {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 90px 1fr;
|
||||
gap: 10px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.ev-line:hover { background: var(--bg-3); }
|
||||
.ev-line .ts { color: var(--ink-4); font-size: 10.5px; }
|
||||
.ev-line .id { color: var(--accent); font-size: 10.5px; }
|
||||
.ev-line .body { color: var(--ink); }
|
||||
.ev-empty {
|
||||
font-size: 12px; color: var(--ink-3);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.card-events-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--accent-4);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.card-foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding-top: 8px; margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11px; color: var(--ink-3);
|
||||
}
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 32px; height: 18px;
|
||||
background: var(--bg-3); border: 1px solid var(--line-2);
|
||||
border-radius: 999px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ''; position: absolute;
|
||||
top: 1px; left: 1px;
|
||||
width: 12px; height: 12px;
|
||||
background: var(--ink-3); border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle.on::after { background: #1a0f00; transform: translateX(14px); }
|
||||
.events {
|
||||
font-family: var(--mono); font-size: 10px; color: var(--ink-3);
|
||||
flex: 1;
|
||||
}
|
||||
.empty {
|
||||
padding: 40px;
|
||||
text-align: center; color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
activations.value; query.value; activeCat.value; statusFilter.value;
|
||||
appEvents.value; appEventCounts.value;
|
||||
this.renderTick++;
|
||||
});
|
||||
}
|
||||
|
||||
private isActive(id: string): boolean {
|
||||
return activations.value.find((a) => a.id === id)?.active === true;
|
||||
}
|
||||
|
||||
private toggle(app: AppManifest): void {
|
||||
const wasActive = this.isActive(app.id);
|
||||
const next = activations.value.map((a) => a.id === app.id ? { ...a, active: !a.active, lastActivatedAt: Date.now() } : a);
|
||||
activations.value = next;
|
||||
if (!wasActive) {
|
||||
const r = app.runtime ?? 'mesh-only';
|
||||
const note = r === 'simulated' ? ' · live runtime engaged'
|
||||
: r === 'mesh-only' ? ' · queued (needs ESP32 mesh)'
|
||||
: '';
|
||||
pushLog('ok', `app <span class="k">${app.id}</span> activated${note}`);
|
||||
} else {
|
||||
pushLog('info', `app <span class="k">${app.id}</span> deactivated`);
|
||||
}
|
||||
}
|
||||
|
||||
private filtered(): AppManifest[] {
|
||||
let list = APPS;
|
||||
if (activeCat.value !== 'all') list = list.filter((a) => a.category === activeCat.value);
|
||||
if (statusFilter.value !== 'all') list = list.filter((a) => a.status === statusFilter.value);
|
||||
if (query.value.trim()) {
|
||||
list = list
|
||||
.map((a) => ({ a, s: fuzzyMatch(query.value, a) }))
|
||||
.filter((x) => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map((x) => x.a);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private categoryCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = { all: APPS.length };
|
||||
for (const k of Object.keys(CATEGORIES)) counts[k] = 0;
|
||||
for (const a of APPS) counts[a.category] = (counts[a.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const list = this.filtered();
|
||||
const counts = this.categoryCounts();
|
||||
const activeCount = activations.value.filter((a) => a.active).length;
|
||||
return html`
|
||||
<div class="head">
|
||||
<div class="ttl">
|
||||
App Store
|
||||
<small>${APPS.length} edge apps · ${activeCount} active</small>
|
||||
</div>
|
||||
<input class="search" id="app-search" placeholder="Search by name, tag, or category…"
|
||||
.value=${query.value}
|
||||
@input=${(e: Event) => { query.value = (e.target as HTMLInputElement).value; }} />
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<span class="chip ${activeCat.value === 'all' ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = 'all'}>
|
||||
All<span class="count">${counts.all}</span>
|
||||
</span>
|
||||
${(Object.keys(CATEGORIES) as AppCategory[]).map((k) => html`
|
||||
<span class="chip ${activeCat.value === k ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = k}>
|
||||
<span class="swatch" style=${`background:${CATEGORIES[k].color}`}></span>
|
||||
${CATEGORIES[k].label}
|
||||
<span class="count">${counts[k] ?? 0}</span>
|
||||
</span>
|
||||
`)}
|
||||
<span style="flex:1; min-width:8px"></span>
|
||||
<span class="chip ${statusFilter.value === 'all' ? 'on' : ''}" @click=${() => statusFilter.value = 'all'}>any</span>
|
||||
<span class="chip ${statusFilter.value === 'available' ? 'on' : ''}" @click=${() => statusFilter.value = 'available'}>available</span>
|
||||
<span class="chip ${statusFilter.value === 'beta' ? 'on' : ''}" @click=${() => statusFilter.value = 'beta'}>beta</span>
|
||||
<span class="chip ${statusFilter.value === 'research' ? 'on' : ''}" @click=${() => statusFilter.value = 'research'}>research</span>
|
||||
</div>
|
||||
|
||||
${this.renderEventsFeed()}
|
||||
|
||||
${list.length === 0
|
||||
? html`<div class="empty">No apps match the current filters.</div>`
|
||||
: html`<div class="grid">${list.map((app) => this.card(app))}</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEventsFeed() {
|
||||
const evs = appEvents.value.slice(-12).reverse();
|
||||
const activeSimCount = activations.value.filter((a) => a.active && hasRuntime(a.id)).length;
|
||||
return html`
|
||||
<div class="events-feed">
|
||||
<h3>Live runtime feed
|
||||
${activeSimCount > 0
|
||||
? html`<span class="card-events-count" style="margin-left: 8px;">${activeSimCount} simulated app${activeSimCount === 1 ? '' : 's'} active</span>`
|
||||
: ''}
|
||||
</h3>
|
||||
<p class="lead">
|
||||
Apps with the <span class="badge rt-simulated" style="font-size:9.5px; padding:0 4px;">simulated</span>
|
||||
runtime emit real i32 event IDs against nvsim's live frame stream below.
|
||||
Apps with <span class="badge rt-mesh-only" style="font-size:9.5px; padding:0 4px;">mesh-only</span>
|
||||
need an ESP32-S3 + WS transport (deferred to V2). The
|
||||
<span class="badge rt-running" style="font-size:9.5px; padding:0 4px;">running</span>
|
||||
badge marks <code>nvsim</code> itself, which is always running.
|
||||
</p>
|
||||
${evs.length === 0
|
||||
? html`<div class="ev-empty">No events yet. Toggle a card with the <i>simulated</i> badge and press <b>▶ Run</b>.</div>`
|
||||
: html`<div class="lines">${evs.map((ev) => {
|
||||
const dt = new Date(ev.ts);
|
||||
const ts = `${String(dt.getSeconds()).padStart(2, '0')}.${String(dt.getMilliseconds()).padStart(3, '0')}`;
|
||||
return html`
|
||||
<div class="ev-line">
|
||||
<span class="ts">${ts}</span>
|
||||
<span class="id">${ev.appId}</span>
|
||||
<span class="body"><b style="color:var(--accent-2);">${ev.eventName}</b><span style="color:var(--ink-3);"> · ${ev.eventId}</span> ${ev.detail ? `· ${ev.detail}` : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
})}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private card(app: AppManifest) {
|
||||
const active = this.isActive(app.id);
|
||||
const cat = CATEGORIES[app.category];
|
||||
const runtime = app.runtime ?? 'mesh-only';
|
||||
const evCount = appEventCounts.value[app.id] ?? 0;
|
||||
const runtimeLabel: Record<string, string> = {
|
||||
'running': 'running',
|
||||
'simulated': 'simulated',
|
||||
'mesh-only': 'needs mesh',
|
||||
};
|
||||
const runtimeTip: Record<string, string> = {
|
||||
'running': 'This app is genuinely running in your browser right now.',
|
||||
'simulated': 'A pared-down version of this algorithm runs against nvsim\'s magnetic frame stream as a proxy for its native CSI input. Toggle on, then press ▶ Run to see real event IDs in the feed.',
|
||||
'mesh-only': 'This algorithm needs CSI subcarrier data from an ESP32-S3 mesh. The toggle persists; activation is pushed via WS transport (V2).',
|
||||
};
|
||||
return html`
|
||||
<div class="card ${active ? 'active' : ''}" data-app-id=${app.id}>
|
||||
<div class="card-h">
|
||||
<span class="swatch" style=${`background:${cat.color}`}></span>
|
||||
<span class="name">${app.name}</span>
|
||||
</div>
|
||||
<div class="summary">${app.summary}</div>
|
||||
<div class="meta">
|
||||
<span class="badge cat">${cat.label}</span>
|
||||
<span class="badge status-${app.status}">${app.status}</span>
|
||||
<span class="badge rt-${runtime}" title=${runtimeTip[runtime]}>${runtimeLabel[runtime]}</span>
|
||||
${app.budget ? html`<span class="badge budget">budget ${app.budget}</span>` : ''}
|
||||
${app.adr ? html`<span class="badge">${app.adr}</span>` : ''}
|
||||
${app.events?.length ? html`<span class="badge">events ${app.events.join('·')}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span class="events">${app.crate}</span>
|
||||
${evCount > 0 ? html`<span class="card-events-count">⚡ ${evCount} ev</span>` : ''}
|
||||
<span class="toggle ${active ? 'on' : ''}" role="switch"
|
||||
aria-checked=${active}
|
||||
data-app-toggle=${app.id}
|
||||
@click=${() => this.toggle(app)}></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/* Top-level shell: 4-zone grid with rail / topbar / sidebar / scene / inspector / console.
|
||||
* View routing is per-rail-button: the central area swaps between
|
||||
* `<nv-scene>`, `<nv-app-store>`, etc. */
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import './nv-rail';
|
||||
import './nv-topbar';
|
||||
import './nv-sidebar';
|
||||
import './nv-scene';
|
||||
import './nv-inspector';
|
||||
import './nv-console';
|
||||
import './nv-app-store';
|
||||
import './nv-toast';
|
||||
import './nv-modal';
|
||||
import './nv-palette';
|
||||
import './nv-debug-hud';
|
||||
import './nv-settings-drawer';
|
||||
import './nv-onboarding';
|
||||
import './nv-ghost-murmur';
|
||||
import './nv-help';
|
||||
import './nv-home';
|
||||
|
||||
export type View = 'home' | 'scene' | 'apps' | 'inspector' | 'witness' | 'ghost-murmur';
|
||||
|
||||
@customElement('nv-app')
|
||||
export class NvApp extends LitElement {
|
||||
@state() private view: View = 'home';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
z-index: 1000;
|
||||
transition: top 0.15s;
|
||||
}
|
||||
.skip-link:focus { top: 8px; }
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 280px 1fr 340px;
|
||||
grid-template-rows: 48px 1fr 220px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar topbar'
|
||||
'rail sidebar main inspector'
|
||||
'rail sidebar console inspector';
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
/* Home view simplifies: hides sidebar / inspector / console so the
|
||||
hero gets the full screen. Power-user panels stay one rail click away. */
|
||||
.app.simple {
|
||||
grid-template-columns: 56px 1fr;
|
||||
grid-template-rows: 48px 1fr;
|
||||
grid-template-areas:
|
||||
'rail topbar'
|
||||
'rail main';
|
||||
}
|
||||
.app.simple nv-sidebar,
|
||||
.app.simple nv-inspector,
|
||||
.app.simple nv-console { display: none; }
|
||||
nv-rail { grid-area: rail; }
|
||||
nv-topbar { grid-area: topbar; }
|
||||
nv-sidebar { grid-area: sidebar; }
|
||||
.main { grid-area: main; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
|
||||
nv-inspector { grid-area: inspector; }
|
||||
nv-console { grid-area: console; min-height: 0; }
|
||||
@media (max-width: 1180px) {
|
||||
.app {
|
||||
grid-template-columns: 56px 1fr 320px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar'
|
||||
'rail main inspector'
|
||||
'rail console console';
|
||||
}
|
||||
nv-sidebar { display: none; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 52px 1fr 200px;
|
||||
grid-template-areas:
|
||||
'topbar'
|
||||
'main'
|
||||
'console';
|
||||
}
|
||||
nv-rail, nv-sidebar, nv-inspector { display: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const isSimple = this.view === 'home';
|
||||
return html`
|
||||
<a class="skip-link" href="#main-content"
|
||||
@click=${(e: Event) => { e.preventDefault(); const sr = this.shadowRoot; sr?.querySelector<HTMLElement>('.main')?.focus(); }}>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div class="app ${isSimple ? 'simple' : ''}">
|
||||
<nv-rail .view=${this.view} @navigate=${(e: CustomEvent<View>) => (this.view = e.detail)}></nv-rail>
|
||||
<nv-topbar></nv-topbar>
|
||||
<nv-sidebar></nv-sidebar>
|
||||
<main class="main" id="main-content" tabindex="-1" role="main" aria-label="Main view">
|
||||
${this.view === 'home'
|
||||
? html`<nv-home></nv-home>`
|
||||
: this.view === 'apps'
|
||||
? html`<nv-app-store></nv-app-store>`
|
||||
: this.view === 'ghost-murmur'
|
||||
? html`<nv-ghost-murmur></nv-ghost-murmur>`
|
||||
: this.view === 'inspector'
|
||||
? html`<nv-inspector expanded .pinTab=${'signal'}></nv-inspector>`
|
||||
: this.view === 'witness'
|
||||
? html`<nv-inspector expanded .pinTab=${'witness'}></nv-inspector>`
|
||||
: html`<nv-scene></nv-scene>`}
|
||||
</main>
|
||||
<nv-inspector
|
||||
.pinTab=${this.view === 'inspector' ? 'signal'
|
||||
: this.view === 'witness' ? 'witness' : null}>
|
||||
</nv-inspector>
|
||||
<nv-console></nv-console>
|
||||
</div>
|
||||
<nv-toast></nv-toast>
|
||||
<nv-modal></nv-modal>
|
||||
<nv-palette></nv-palette>
|
||||
<nv-debug-hud></nv-debug-hud>
|
||||
<nv-settings-drawer></nv-settings-drawer>
|
||||
<nv-onboarding></nv-onboarding>
|
||||
<nv-help></nv-help>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/* Console — log stream + REPL. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, query } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
consoleLines, consoleFilter, consolePaused, pushLog,
|
||||
getClient, seed, theme, expectedWitness, witnessHex, witnessVerified,
|
||||
running, replHistory, pushReplHistory,
|
||||
} from '../store/appStore';
|
||||
|
||||
@customElement('nv-console')
|
||||
export class NvConsole extends LitElement {
|
||||
@query('#console-input') private inputEl!: HTMLInputElement;
|
||||
private hIdx = -1;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabs {
|
||||
display: flex; align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 12px;
|
||||
background: transparent; border: none;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||
.tab .cnt {
|
||||
background: var(--bg-3); padding: 1px 5px; border-radius: 999px;
|
||||
font-size: 9.5px; color: var(--ink-2); margin-left: 4px;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.tools { display: flex; gap: 4px; padding: 4px 0; }
|
||||
.tools button {
|
||||
width: 24px; height: 24px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-3);
|
||||
font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.tools button:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
|
||||
.body {
|
||||
flex: 1; overflow-y: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
padding: 6px 0;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 60px 1fr;
|
||||
gap: 12px;
|
||||
padding: 2px 12px;
|
||||
color: var(--ink-2);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.line:hover { background: var(--bg-1); }
|
||||
.ts { color: var(--ink-4); font-size: 10.5px; padding-top: 1px; }
|
||||
.lvl {
|
||||
font-size: 10px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px;
|
||||
}
|
||||
.line.info .lvl { color: var(--accent-2); }
|
||||
.line.warn .lvl { color: var(--warn); }
|
||||
.line.warn { border-left-color: var(--warn); background: oklch(0.7 0.18 35 / 0.04); }
|
||||
.line.err .lvl { color: var(--bad); }
|
||||
.line.err { border-left-color: var(--bad); background: oklch(0.65 0.22 25 / 0.05); }
|
||||
.line.dbg .lvl { color: var(--ink-3); }
|
||||
.line.ok .lvl { color: var(--ok); }
|
||||
.msg { color: var(--ink); white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.input {
|
||||
display: flex; align-items: center;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-0);
|
||||
padding: 0 10px;
|
||||
height: 32px; gap: 8px;
|
||||
}
|
||||
.prompt { color: var(--accent); font-family: var(--mono); font-size: 12px; }
|
||||
input[type="text"] {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: var(--ink); font-family: var(--mono); font-size: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
input::placeholder { color: var(--ink-4); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
consoleLines.value; consoleFilter.value; consolePaused.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
const body = this.renderRoot.querySelector('.body') as HTMLElement | null;
|
||||
if (body) body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
private counts(): Record<string, number> {
|
||||
const c: Record<string, number> = { info: 0, warn: 0, err: 0, dbg: 0, ok: 0 };
|
||||
for (const l of consoleLines.value) c[l.level] = (c[l.level] ?? 0) + 1;
|
||||
c.all = consoleLines.value.length;
|
||||
return c;
|
||||
}
|
||||
|
||||
private async exec(line: string): Promise<void> {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
pushLog('info', `<span style="color:var(--accent);">nvsim></span> ${line}`);
|
||||
pushReplHistory(line);
|
||||
this.hIdx = replHistory.value.length;
|
||||
const [cmd, ...args] = line.split(/\s+/);
|
||||
const arg = args.join(' ');
|
||||
const c = getClient();
|
||||
switch (cmd) {
|
||||
case 'help':
|
||||
pushLog('info', 'commands: help · scene.list · sensor.config · run · pause · reset · seed · proof.verify · proof.export · clear · theme · status');
|
||||
break;
|
||||
case 'scene.list':
|
||||
pushLog('info', 'scene rebar-walkby-01:');
|
||||
pushLog('info', ' rebar.steel.coil @ [+2.7, 0.0, +0.3] m χ=5000');
|
||||
pushLog('info', ' dipole.heart_proxy @ [-1.4, +0.2, +0.4] m m=1.0e-6 A·m²');
|
||||
pushLog('info', ' loop.mains_60Hz @ [-1.6, -0.4, 0.0] m I=2 A');
|
||||
pushLog('info', ' eddy.door_steel @ [+0.0, +1.8, +0.4] m σ=1e6 S/m');
|
||||
break;
|
||||
case 'sensor.config':
|
||||
pushLog('info', 'NvSensor::cots_defaults() {');
|
||||
pushLog('info', ' pos=[0,0,0], V=1mm³, N=1e12, C=0.03, T2*=200ns');
|
||||
pushLog('info', ' D=2.870 GHz, γe=28 GHz/T, Γ=1.0 MHz, axes=4×〈111〉');
|
||||
pushLog('info', ' δB ≈ 1.18 pT/√Hz (Barry 2020 §III.A) }');
|
||||
break;
|
||||
case 'run':
|
||||
if (c) { await c.run(); running.value = true; pushLog('ok', 'pipeline RUN'); }
|
||||
break;
|
||||
case 'pause':
|
||||
if (c) { await c.pause(); running.value = false; pushLog('warn', 'pipeline PAUSED'); }
|
||||
break;
|
||||
case 'reset':
|
||||
if (c) { await c.reset(); pushLog('info', 'pipeline reset · t=0'); }
|
||||
break;
|
||||
case 'seed': {
|
||||
if (!arg) { pushLog('info', `current seed = 0x${seed.value.toString(16).toUpperCase()}`); break; }
|
||||
const v = BigInt(arg.startsWith('0x') ? arg : '0x' + arg);
|
||||
seed.value = v;
|
||||
if (c) await c.setSeed(v);
|
||||
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
|
||||
break;
|
||||
}
|
||||
case 'proof.verify': {
|
||||
if (!c) break;
|
||||
pushLog('dbg', 'computing SHA-256 over 256 frames…');
|
||||
try {
|
||||
const exp = expectedWitness.value;
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`); }
|
||||
else { witnessVerified.value = 'fail'; pushLog('err', 'WITNESS MISMATCH'); }
|
||||
} catch (e) { pushLog('err', `verify failed: ${(e as Error).message}`); }
|
||||
break;
|
||||
}
|
||||
case 'proof.export': {
|
||||
if (!c) break;
|
||||
pushLog('dbg', 'building proof bundle…');
|
||||
try {
|
||||
const blob = await c.exportProofBundle();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nvsim-proof-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
pushLog('ok', `proof bundle exported · ${blob.size} bytes`);
|
||||
} catch (e) { pushLog('err', `export failed: ${(e as Error).message}`); }
|
||||
break;
|
||||
}
|
||||
case 'clear':
|
||||
consoleLines.value = [];
|
||||
break;
|
||||
case 'theme': {
|
||||
const t = (arg || '').toLowerCase();
|
||||
if (t === 'light' || t === 'dark') { theme.value = t; pushLog('ok', `theme → ${t}`); }
|
||||
else pushLog('info', 'theme [light|dark]');
|
||||
break;
|
||||
}
|
||||
case 'status':
|
||||
pushLog('info', `running=${running.value} seed=0x${seed.value.toString(16).toUpperCase()} verified=${witnessVerified.value}`);
|
||||
break;
|
||||
default:
|
||||
pushLog('err', `unknown command: ${cmd} · try help`);
|
||||
}
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Enter') { void this.exec(this.inputEl.value); this.inputEl.value = ''; }
|
||||
else if (e.key === 'ArrowUp') {
|
||||
const h = replHistory.value;
|
||||
if (h.length) {
|
||||
this.hIdx = Math.max(0, this.hIdx - 1);
|
||||
this.inputEl.value = h[this.hIdx] ?? '';
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
const h = replHistory.value;
|
||||
if (h.length) {
|
||||
this.hIdx = Math.min(h.length, this.hIdx + 1);
|
||||
this.inputEl.value = h[this.hIdx] ?? '';
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
override render() {
|
||||
const c = this.counts();
|
||||
const filter = consoleFilter.value;
|
||||
const visible = consoleLines.value.filter((l) => filter === 'all' || l.level === filter);
|
||||
return html`
|
||||
<div class="tabs">
|
||||
${(['all', 'info', 'warn', 'err', 'dbg'] as const).map((k) => html`
|
||||
<button class="tab ${filter === k ? 'active' : ''}" data-tab=${k}
|
||||
@click=${() => consoleFilter.value = k}>
|
||||
${k} <span class="cnt">${c[k] ?? 0}</span>
|
||||
</button>
|
||||
`)}
|
||||
<span class="spacer"></span>
|
||||
<div class="tools">
|
||||
<button id="clear-log" title="Clear" @click=${() => consoleLines.value = []}>×</button>
|
||||
<button id="pause-log" title="Pause" @click=${() => consolePaused.value = !consolePaused.value}>
|
||||
${consolePaused.value ? '▶' : '❚❚'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" role="log" aria-live="polite" aria-label="Console output">
|
||||
${visible.map((l) => {
|
||||
const ts = new Date(l.ts);
|
||||
const tsStr = `${String(ts.getSeconds()).padStart(2, '0')}.${String(ts.getMilliseconds()).padStart(3, '0')}`;
|
||||
// Use innerHTML pass-through via unsafe-html alt: inject raw html via property
|
||||
return html`<div class="line ${l.level}">
|
||||
<div class="ts">${tsStr}</div>
|
||||
<div class="lvl">${l.level}</div>
|
||||
<div class="msg" .innerHTML=${l.msg}></div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
<div class="input">
|
||||
<span class="prompt">nvsim></span>
|
||||
<input id="console-input" type="text"
|
||||
placeholder="help · scene.list · sensor.config · run · proof.verify · clear"
|
||||
@keydown=${this.onKey}/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/* Debug HUD toggled with `. Shows render fps, sim t, frames, |B|, SNR. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { fps, framesEmitted, bMag, snr, t as simT } from '../store/appStore';
|
||||
|
||||
@customElement('nv-debug-hud')
|
||||
export class NvDebugHud extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private renderFps = 0;
|
||||
private lastTs = performance.now();
|
||||
private frameCount = 0;
|
||||
private rafId = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; bottom: 8px; right: 8px;
|
||||
width: 220px;
|
||||
background: rgba(13,17,23,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
z-index: 99;
|
||||
display: none;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
:host([open]) { display: block; }
|
||||
.h {
|
||||
display: flex; justify-content: space-between;
|
||||
font-weight: 600; color: var(--ink);
|
||||
margin-bottom: 6px; padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.x { cursor: pointer; color: var(--ink-3); }
|
||||
.row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.k { color: var(--ink-3); }
|
||||
.v { color: var(--ink); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
effect(() => { fps.value; framesEmitted.value; bMag.value; snr.value; simT.value; this.requestUpdate(); });
|
||||
this.tick();
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === '`' && !(e.target as HTMLElement).matches('input, textarea')) {
|
||||
this.open = !this.open;
|
||||
this.toggleAttribute('open', this.open);
|
||||
}
|
||||
};
|
||||
|
||||
private tick = (): void => {
|
||||
this.rafId = requestAnimationFrame(this.tick);
|
||||
const now = performance.now();
|
||||
this.frameCount++;
|
||||
if (now - this.lastTs >= 500) {
|
||||
this.renderFps = (this.frameCount * 1000) / (now - this.lastTs);
|
||||
this.frameCount = 0;
|
||||
this.lastTs = now;
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="h"><span>nvsim · debug</span><span class="x" @click=${() => { this.open = false; this.removeAttribute('open'); }}>✕</span></div>
|
||||
<div class="row"><span class="k">render fps</span><span class="v">${this.renderFps.toFixed(1)}</span></div>
|
||||
<div class="row"><span class="k">sim fps</span><span class="v">${fps.value > 0 ? Math.round(fps.value) : '—'}</span></div>
|
||||
<div class="row"><span class="k">frames</span><span class="v">${framesEmitted.value.toString()}</span></div>
|
||||
<div class="row"><span class="k">|B|</span><span class="v">${(bMag.value * 1e9).toFixed(3)} nT</span></div>
|
||||
<div class="row"><span class="k">SNR</span><span class="v">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</span></div>
|
||||
<div class="row"><span class="k">DOM</span><span class="v">${document.querySelectorAll('*').length}</span></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
/* Ghost Murmur — research view.
|
||||
*
|
||||
* Walks through the publicly-reported April 2026 CIA program and maps
|
||||
* the physically-defensible parts onto RuView's three-tier heartbeat
|
||||
* mesh. Source: docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md
|
||||
*
|
||||
* This view is reference material, not an operational mode. It exists
|
||||
* so practitioners (and journalists) can audit the physics-vs-press
|
||||
* gap in the open. ADR-092 §14b.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { getClient, pushLog } from '../store/appStore';
|
||||
import type { TransientRunResult } from '../transport/NvsimClient';
|
||||
|
||||
// Tier detection thresholds — order-of-magnitude floor each transport
|
||||
// can resolve cardiac signal at, in Tesla. Source: Ghost Murmur spec
|
||||
// §4.7, Wolf 2015, Barry 2020. These are deliberately optimistic for the
|
||||
// "available" path; the shoot-the-moon press claim sits 6+ orders below.
|
||||
const TIERS = [
|
||||
{ id: 'nvBest', label: 'NV-ensemble (best lab)', floorT: 1e-12, color: 'oklch(0.78 0.14 70)' },
|
||||
{ id: 'nvCots', label: 'NV-DNV-B1 (COTS)', floorT: 3e-10, color: 'oklch(0.72 0.18 50)' },
|
||||
{ id: 'squid', label: 'SQUID (shielded room)', floorT: 1e-15, color: 'oklch(0.78 0.12 195)' },
|
||||
{ id: 'mmw', label: '60 GHz mmWave (μ-Doppler)', floorT: 0, color: 'oklch(0.78 0.14 145)' },
|
||||
{ id: 'csi', label: 'WiFi CSI (presence)', floorT: 0, color: 'oklch(0.72 0.18 330)' },
|
||||
];
|
||||
|
||||
// Cardiac dipole moment (A·m²) — order-of-magnitude estimate from
|
||||
// Wikswo / Bison cardiac MCG modelling.
|
||||
const HEART_DIPOLE_AM2 = 5e-9;
|
||||
|
||||
@customElement('nv-ghost-murmur')
|
||||
export class NvGhostMurmur extends LitElement {
|
||||
@state() private distanceM = 0.1;
|
||||
@state() private momentLog10 = -8.3; // log10(5e-9)
|
||||
@state() private result: TransientRunResult | null = null;
|
||||
@state() private running = false;
|
||||
@state() private err: string | null = null;
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 24px 28px 60px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.links {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.links a {
|
||||
padding: 5px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-family: var(--mono);
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.links a:hover { border-color: var(--accent-2); }
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
margin: 28px 0 10px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13.5px; font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.card p {
|
||||
font-size: 12.5px; color: var(--ink-2);
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card p:last-child { margin-bottom: 0; }
|
||||
.stat {
|
||||
display: inline-flex; align-items: baseline; gap: 6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.stat .v {
|
||||
font-family: var(--mono); font-size: 16px; font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.stat .l {
|
||||
font-size: 10px; color: var(--ink-3);
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
th {
|
||||
color: var(--ink-3);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
td.amber { color: var(--accent); font-family: var(--mono); }
|
||||
td.cyan { color: var(--accent-2); font-family: var(--mono); }
|
||||
td.bad { color: var(--bad); font-family: var(--mono); }
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.pill.ok { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.pill.skeptical { color: var(--bad); border-color: oklch(0.65 0.22 25 / 0.4); }
|
||||
.pill.partial { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
|
||||
.architecture {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
background: var(--bg-3);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ethics {
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.65 0.22 25 / 0.04) 100%);
|
||||
border: 1px solid oklch(0.65 0.22 25 / 0.25);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.ethics h3 { color: var(--bad); margin-top: 0; }
|
||||
.ethics ul { padding-left: 18px; margin: 8px 0; }
|
||||
.ethics li { font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; }
|
||||
|
||||
/* Demo */
|
||||
.demo {
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 70 / 0.04) 100%);
|
||||
border: 1px solid oklch(0.78 0.14 70 / 0.3);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@media (max-width: 720px) { .demo-grid { grid-template-columns: 1fr; } }
|
||||
.control { margin-bottom: 14px; }
|
||||
.control .top {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 12px; margin-bottom: 6px;
|
||||
}
|
||||
.control .top .lbl { color: var(--ink-3); }
|
||||
.control .top .val {
|
||||
font-family: var(--mono); color: var(--ink);
|
||||
}
|
||||
.control input[type="range"] {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 100%; height: 4px;
|
||||
background: var(--bg-3); border-radius: 2px; outline: none;
|
||||
}
|
||||
.control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--accent); cursor: pointer;
|
||||
border: 2px solid var(--bg-2);
|
||||
}
|
||||
.demo-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
border-radius: 8px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.demo-btn:hover { filter: brightness(1.08); }
|
||||
.demo-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
.readout {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.readout-row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
}
|
||||
.readout-row .l { color: var(--ink-3); }
|
||||
.readout-row .v { color: var(--ink); }
|
||||
.readout-row .v.amber { color: var(--accent); }
|
||||
.tier-bar {
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
height: 22px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tier-bar .fill {
|
||||
position: absolute; top: 0; bottom: 0; left: 0;
|
||||
transition: width 0.2s ease-out;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
.tier-bar .lbl {
|
||||
position: relative; z-index: 1;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
color: var(--ink);
|
||||
display: flex; justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
.verdict {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 500;
|
||||
border: 1px solid;
|
||||
}
|
||||
.verdict.ok { background: oklch(0.78 0.14 145 / 0.08); border-color: oklch(0.78 0.14 145 / 0.4); color: var(--ok); }
|
||||
.verdict.warn { background: oklch(0.7 0.18 35 / 0.08); border-color: oklch(0.7 0.18 35 / 0.4); color: var(--warn); }
|
||||
.verdict.bad { background: oklch(0.65 0.22 25 / 0.08); border-color: oklch(0.65 0.22 25 / 0.4); color: var(--bad); }
|
||||
.demo-notes {
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
margin-top: 10px; line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Predicted MCG dipole field (Tesla) at distance r in metres.
|
||||
* Far-field approximation: |B| ≈ μ₀ · m / (4π · r³). Source: Jackson 3e §5.
|
||||
*/
|
||||
private predictedDipoleFieldT(r: number, m: number): number {
|
||||
const MU_0 = 4 * Math.PI * 1e-7;
|
||||
return (MU_0 * m) / (4 * Math.PI * Math.pow(Math.max(r, 1e-6), 3));
|
||||
}
|
||||
|
||||
private async runDemo(): Promise<void> {
|
||||
const c = getClient();
|
||||
if (!c) { this.err = 'WASM client not ready'; return; }
|
||||
this.err = null;
|
||||
this.running = true;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const r = this.distanceM;
|
||||
const m = Math.pow(10, this.momentLog10);
|
||||
// Heart proxy at +z = r, dipole moment along z = m A·m².
|
||||
const scene = {
|
||||
dipoles: [{ position: [0, 0, r] as [number, number, number], moment: [0, 0, m] as [number, number, number] }],
|
||||
loops: [],
|
||||
ferrous: [],
|
||||
eddy: [],
|
||||
sensors: [[0, 0, 0] as [number, number, number]],
|
||||
ambient_field: [0, 0, 0] as [number, number, number],
|
||||
};
|
||||
const config = {
|
||||
digitiser: { f_s_hz: 10000, f_mod_hz: 1000 },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: false,
|
||||
},
|
||||
dt_s: null,
|
||||
};
|
||||
this.result = await c.runTransient(scene, config, 42n, 64);
|
||||
pushLog('ok', `ghost-demo · r=${r.toFixed(3)} m · |B| recovered = ${(this.result.bMagT * 1e12).toExponential(2)} pT`);
|
||||
} catch (e) {
|
||||
this.err = (e as Error).message;
|
||||
pushLog('err', `ghost-demo failed: ${this.err}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private formatField(t: number): string {
|
||||
if (t === 0) return '0 T';
|
||||
const abs = Math.abs(t);
|
||||
if (abs >= 1e-3) return `${(t * 1e3).toFixed(2)} mT`;
|
||||
if (abs >= 1e-6) return `${(t * 1e6).toFixed(2)} µT`;
|
||||
if (abs >= 1e-9) return `${(t * 1e9).toFixed(3)} nT`;
|
||||
if (abs >= 1e-12) return `${(t * 1e12).toFixed(2)} pT`;
|
||||
if (abs >= 1e-15) return `${(t * 1e15).toFixed(2)} fT`;
|
||||
if (abs >= 1e-18) return `${(t * 1e18).toFixed(2)} aT`;
|
||||
return `${t.toExponential(2)} T`;
|
||||
}
|
||||
|
||||
private formatDistance(r: number): string {
|
||||
if (r < 1) return `${(r * 100).toFixed(1)} cm`;
|
||||
if (r < 1000) return `${r.toFixed(2)} m`;
|
||||
if (r < 1e5) return `${(r / 1000).toFixed(2)} km`;
|
||||
return `${(r / 1609).toFixed(0)} mi`;
|
||||
}
|
||||
|
||||
private renderDemo() {
|
||||
const m = Math.pow(10, this.momentLog10);
|
||||
const predicted = this.predictedDipoleFieldT(this.distanceM, m);
|
||||
const recovered = this.result?.bMagT ?? 0;
|
||||
const noiseFloor = (this.result?.noiseFloorPtSqrtHz ?? 0) * 1e-12; // pT/√Hz → T/√Hz
|
||||
|
||||
const verdictPills = TIERS.map((t) => {
|
||||
let detect: 'ok' | 'warn' | 'bad' = 'bad';
|
||||
let label = 'below floor';
|
||||
if (t.id === 'mmw') {
|
||||
if (this.distanceM <= 5) { detect = 'ok'; label = 'µ-Doppler @ chest'; }
|
||||
else if (this.distanceM <= 15) { detect = 'warn'; label = 'edge of range'; }
|
||||
else { detect = 'bad'; label = 'out of range'; }
|
||||
} else if (t.id === 'csi') {
|
||||
if (this.distanceM <= 30) { detect = this.distanceM <= 10 ? 'ok' : 'warn'; label = 'presence/breathing'; }
|
||||
else { detect = 'bad'; label = 'out of range'; }
|
||||
} else if (t.floorT > 0) {
|
||||
const ratio = predicted / t.floorT;
|
||||
if (ratio > 100) { detect = 'ok'; label = `${ratio.toExponential(1)}× floor`; }
|
||||
else if (ratio > 1) { detect = 'warn'; label = `${ratio.toFixed(1)}× floor`; }
|
||||
else { detect = 'bad'; label = `${(1 / ratio).toExponential(1)}× too weak`; }
|
||||
}
|
||||
const fillPct = t.floorT > 0
|
||||
? Math.max(2, Math.min(100, 100 + 12 * Math.log10(predicted / t.floorT)))
|
||||
: (t.id === 'mmw' ? Math.max(2, 100 - this.distanceM * 7) : Math.max(2, 100 - this.distanceM * 2));
|
||||
return html`
|
||||
<div class="tier-bar" data-tier=${t.id}>
|
||||
<div class="fill" style=${`width:${fillPct}%; background:${t.color}; border-color:${t.color}`}></div>
|
||||
<div class="lbl">
|
||||
<span>${t.label}</span>
|
||||
<span class="verdict-${detect}" style=${`color:${detect === 'ok' ? 'var(--ok)' : detect === 'warn' ? 'var(--warn)' : 'var(--bad)'}`}>${label}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
const overallDetect: 'ok' | 'warn' | 'bad' =
|
||||
predicted > 1e-12 ? 'ok' : predicted > 1e-15 ? 'warn' : 'bad';
|
||||
const overallText =
|
||||
overallDetect === 'ok'
|
||||
? `Above NV-ensemble lab floor — close-range MCG plausible at ${this.formatDistance(this.distanceM)}.`
|
||||
: overallDetect === 'warn'
|
||||
? `Below NV ensemble best, above SQUID — research-grade only at ${this.formatDistance(this.distanceM)}.`
|
||||
: `Below every published instrument's noise floor at ${this.formatDistance(this.distanceM)}. Press-release physics.`;
|
||||
|
||||
return html`
|
||||
<div class="demo">
|
||||
<h3 style="margin: 0 0 6px;">Try it yourself</h3>
|
||||
<div style="font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; line-height: 1.5;">
|
||||
Place a cardiac dipole at variable distance from the NV sensor. The
|
||||
dashboard runs the <i>real</i> nvsim Rust pipeline (compiled to WASM)
|
||||
end-to-end and reports what each tier would actually detect. Same
|
||||
determinism contract as the rest of the dashboard.
|
||||
</div>
|
||||
<div class="demo-grid">
|
||||
<div>
|
||||
<div class="control">
|
||||
<div class="top">
|
||||
<span class="lbl">Distance from sensor</span>
|
||||
<span class="val" id="demo-dist-val">${this.formatDistance(this.distanceM)}</span>
|
||||
</div>
|
||||
<input type="range" id="demo-distance"
|
||||
min="-2" max="5" step="0.05"
|
||||
.value=${String(Math.log10(this.distanceM))}
|
||||
@input=${(e: Event) => { this.distanceM = Math.pow(10, +(e.target as HTMLInputElement).value); }} />
|
||||
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
|
||||
10 cm → 100 km log scale
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="top">
|
||||
<span class="lbl">Heart dipole moment</span>
|
||||
<span class="val" id="demo-moment-val">${m.toExponential(2)} A·m²</span>
|
||||
</div>
|
||||
<input type="range" id="demo-moment"
|
||||
min="-10" max="-6" step="0.05"
|
||||
.value=${String(this.momentLog10)}
|
||||
@input=${(e: Event) => { this.momentLog10 = +(e.target as HTMLInputElement).value; }} />
|
||||
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
|
||||
published cardiac MCG ≈ 5×10⁻⁹ A·m²
|
||||
</div>
|
||||
</div>
|
||||
<button class="demo-btn" id="demo-run-btn" ?disabled=${this.running}
|
||||
@click=${() => this.runDemo()}>
|
||||
${this.running ? 'Running nvsim…' : '▶ Run nvsim at this distance'}
|
||||
</button>
|
||||
${this.err ? html`<div class="verdict bad" style="margin-top: 10px;">Error: ${this.err}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="readout">
|
||||
<div class="readout-row">
|
||||
<span class="l">Predicted |B| (1/r³)</span>
|
||||
<span class="v amber" id="demo-predicted">${this.formatField(predicted)}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Recovered |B| (nvsim)</span>
|
||||
<span class="v" id="demo-recovered">${this.result ? this.formatField(recovered) : '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Sensor noise floor</span>
|
||||
<span class="v" id="demo-floor">${this.result ? this.formatField(noiseFloor) + '/√Hz' : '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Frames run</span>
|
||||
<span class="v" id="demo-frames">${this.result?.nFrames ?? '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Witness (this run)</span>
|
||||
<span class="v" style="font-size: 10px;" id="demo-witness">${this.result?.witnessHex.slice(0, 16) ?? '—'}…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 14px;">
|
||||
<div style="font-size: 11.5px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;">
|
||||
Per-tier detectability
|
||||
</div>
|
||||
${verdictPills}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verdict ${overallDetect}" id="demo-verdict">${overallText}</div>
|
||||
<div class="demo-notes">
|
||||
The <code>predicted</code> value uses the closed-form magnetic-dipole
|
||||
far field <code>|B| = μ₀·m / (4π·r³)</code>. The <code>recovered</code>
|
||||
value comes from the same Rust pipeline that drives the Witness panel —
|
||||
scene → Biot-Savart → NV ensemble → ADC → MagFrame. Use the moment
|
||||
slider to ask "what if the heart were stronger?". Use the distance
|
||||
slider to walk through 10 cm (clinical MCG), 1 m (close approach),
|
||||
10 m (room-scale), 1 km (skeptic's range), and 65 km (the press claim).
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<h1>Ghost Murmur — open-source reality check</h1>
|
||||
<div class="subtitle">
|
||||
The physics-vs-press audit for the publicly-reported April 2026
|
||||
CIA NV-diamond heartbeat detector, and how RuView's existing
|
||||
stack maps onto an honest, civilian version of the same idea.
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="https://github.com/ruvnet/RuView/blob/feat/nvsim-pipeline-simulator/docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md" target="_blank" rel="noopener">
|
||||
📄 Full spec (583 lines)
|
||||
</a>
|
||||
<a href="https://gist.github.com/ruvnet/e44d0c3f0ad10d9c4933a196a16d405c" target="_blank" rel="noopener">
|
||||
✦ Public gist
|
||||
</a>
|
||||
<a href="https://github.com/ruvnet/RuView/issues/437" target="_blank" rel="noopener">
|
||||
# Issue #437
|
||||
</a>
|
||||
<a href="https://www.scientificamerican.com/article/what-is-the-quantum-ghost-murmur-purportedly-used-in-iran-scientists/" target="_blank" rel="noopener">
|
||||
↗ Scientific American
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>What the press reported</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>The story</h3>
|
||||
<p>3 Apr 2026: USAF F-15E pilot "Dude 44 Bravo" goes down in southern Iran during the regional exchange and evades for ~2 days.</p>
|
||||
<p>President Trump publicly suggests detection from <b>40 miles away</b> on a mountainside at night; CIA Director Ratcliffe says "invisible to the enemy, but not to the CIA."</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>The named tech</h3>
|
||||
<p><b>"Ghost Murmur"</b> — Lockheed Skunk Works system using NV defects in synthetic diamond + AI to extract a heartbeat from environmental noise.</p>
|
||||
<p>Outlets: <i>Newsweek, Scientific American, Military.com, WION, Open The Magazine, Yahoo, Calcalist</i> + HN thread #47679241.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>What physicists said</h3>
|
||||
<p>Wikswo (Vanderbilt), Orzel (Union College), Roth (Oakland) — all pushing back hard.</p>
|
||||
<p>"At 1 km, the heartbeat field drops to ~10⁻¹² of its 10 cm value." MCG-only at multi-mile range is <span class="pill skeptical">not consistent with published physics</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Live demo — nvsim WASM</h2>
|
||||
${this.renderDemo()}
|
||||
|
||||
<h2>Physics reality check</h2>
|
||||
<div class="card" style="padding: 6px 14px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Distance</th><th>Cardiac MCG (peak QRS)</th><th>vs Earth field (~50 µT)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>10 cm</td><td class="amber">50 pT</td><td>10⁹× weaker</td></tr>
|
||||
<tr><td>1 m</td><td class="amber">50 fT</td><td>10¹²× weaker</td></tr>
|
||||
<tr><td>10 m</td><td class="cyan">50 aT</td><td>10¹⁵× weaker</td></tr>
|
||||
<tr><td>1 km</td><td class="bad">5 × 10⁻²³ T</td><td>10²⁷× weaker</td></tr>
|
||||
<tr><td>40 mi (65 km)</td><td class="bad">~10⁻²⁸ T</td><td>10³³× weaker</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size: 12px; color: var(--ink-3); margin: 10px 0 0; line-height: 1.5;">
|
||||
Best published NV-ensemble lab record: <b>0.9 pT/√Hz</b> [Wolf 2015].
|
||||
Best SQUID in a shielded room: <b>~1 fT/√Hz</b>. To detect a single heartbeat at 10 m
|
||||
you'd need ~2 billion× more sensitivity than any published ensemble has ever shown,
|
||||
in a magnetically silent environment. <i>40 miles is press-release physics.</i>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>RuView's three-tier mesh — what is actually buildable</h2>
|
||||
<div class="architecture"> ┌──────────────────────────┐
|
||||
│ Tier 3 — NV-diamond │ Range: 0.1–2 m (lab)
|
||||
│ magnetometer ring │ Status: nvsim simulator only
|
||||
│ (close-confirm) │ Hardware: $$$ (≥$8k DNV-B1)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
┌──────────┴───────────────┐
|
||||
│ Tier 2 — 60 GHz FMCW │ Range: 1–10 m HR/BR
|
||||
│ mmWave radar mesh │ Status: shipping (ADR-021)
|
||||
│ (vital signs, posture) │ Hardware: $15 (MR60BHA2 + ESP32-C6)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
┌──────────┴───────────────┐
|
||||
│ Tier 1 — WiFi CSI mesh │ Range: 10–30 m through-wall
|
||||
│ (presence, breathing, │ Status: shipping (ADR-014, ADR-029)
|
||||
│ pose, intention) │ Hardware: $9 (ESP32-S3 8MB)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ RuvSense multistatic fusion │
|
||||
│ + cross-viewpoint attention │
|
||||
│ + AETHER re-ID embeddings │
|
||||
│ + Cramer-Rao gating │
|
||||
└────────────────────────────────┘</div>
|
||||
|
||||
<h2>Press claim → RuView equivalent</h2>
|
||||
<div class="card" style="padding: 6px 14px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Press claim</th><th>RuView equivalent today</th><th>Crate / ADR</th><th>Honest range</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>NV-diamond magnetometry</td>
|
||||
<td>Deterministic NV pipeline simulator</td>
|
||||
<td><code>nvsim</code> · ADR-089</td>
|
||||
<td>Simulator only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"AI strips environmental noise"</td>
|
||||
<td>RuvSense multistatic fusion + AETHER</td>
|
||||
<td>signal/ruvsense/ · ADR-029</td>
|
||||
<td>Mature</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Heartbeat at distance</td>
|
||||
<td>60 GHz FMCW HR/BR + WiFi CSI breathing</td>
|
||||
<td>vitals · ADR-021</td>
|
||||
<td><span class="pill ok">1–5 m HR · 10–30 m presence</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Long-range localisation</td>
|
||||
<td>Multistatic time-of-flight + CRLB</td>
|
||||
<td>ruvector/viewpoint/</td>
|
||||
<td>Limited by node spacing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i>40-mile single-heartbeat detection</i></td>
|
||||
<td><i>Not feasible at any tier</i></td>
|
||||
<td>—</td>
|
||||
<td><span class="pill skeptical">Press-release physics</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Build today on $165</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Bill of materials</h3>
|
||||
<p style="font-family: var(--mono); font-size: 11.5px; line-height: 1.7; color: var(--ink-2);">
|
||||
3 × ESP32-S3 8 MB ($9 ea)<br>
|
||||
3 × PoE injector + cat6 ($6 ea)<br>
|
||||
1 × ESP32-C6 + Seeed MR60BHA2 ($15)<br>
|
||||
1 × Raspberry Pi 5 8 GB ($80)<br>
|
||||
1 × unmanaged GbE switch ($25)
|
||||
</p>
|
||||
<p><b>Total: $165</b></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Honest performance</h3>
|
||||
<span class="stat"><span class="v">95%</span><span class="l">TPR (LOS, 0–15 m)</span></span><br><br>
|
||||
<span class="stat"><span class="v">±2 bpm</span><span class="l">HR (LOS 0–3 m)</span></span><br><br>
|
||||
<span class="stat"><span class="v">±1 br/min</span><span class="l">BR (any mode)</span></span><br><br>
|
||||
<span class="stat"><span class="v">~10 cm</span><span class="l">pose error</span></span><br><br>
|
||||
<span class="stat"><span class="v">80–150 ms</span><span class="l">end-to-end latency</span></span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Determinism</h3>
|
||||
<p>Same <code style="font-family: var(--mono); color: var(--accent);">(scene, config, seed)</code> → byte-identical SHA-256 witness across browsers, OSes, transports.</p>
|
||||
<p>Reference: <span style="font-family: var(--mono); font-size: 10.5px; color: var(--accent-3);">cc8de9b01b0ff5bd…</span></p>
|
||||
<p>Try the Witness tab on the right — it re-derives the hash live in this browser and compares against the published reference.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Privacy, ethics, legal</h2>
|
||||
<div class="ethics">
|
||||
<h3>This is the open-source version. Same physics, opposite governance.</h3>
|
||||
<ul>
|
||||
<li><b>Civilian opt-in only</b> — search-and-rescue, elder-care, occupancy, ICU vitals. Not surveillance.</li>
|
||||
<li><b>No directional pursuit</b> — no beam-steering, target-following, or remote person-of-interest tracking.</li>
|
||||
<li><b>Data minimisation</b> — fused output is <code>(presence, HR, BR, pose, p_alive)</code>; raw streams discarded at the edge.</li>
|
||||
<li><b>PII gates</b> (ADR-040) block identifying biometric streams from leaving the local mesh without consent.</li>
|
||||
<li><b>Adversarial-signal detection</b> flags physically-impossible signal patterns from compromised mesh nodes.</li>
|
||||
<li><b>No export-controlled hardware</b> — RuView targets < $50 COTS. ITAR/EAR sub-THz coherent radars and shielded NV ensembles are out of scope.</li>
|
||||
</ul>
|
||||
<p style="font-size: 11.5px; color: var(--ink-3); margin: 10px 0 0;">
|
||||
RuView is not affiliated with the United States government, the CIA, Lockheed Martin,
|
||||
or any classified program. References to "Ghost Murmur" in this view refer
|
||||
exclusively to the publicly-reported program of that name as covered in the open
|
||||
press in April 2026.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Cross-references</h2>
|
||||
<div class="card">
|
||||
<p style="font-size: 12px; color: var(--ink-2); line-height: 1.7; margin: 0;">
|
||||
<b>ADRs:</b> 014 (signal) · 021 (vitals) · 024 (AETHER) · 027 (MERIDIAN) ·
|
||||
028 (witness audit) · 029 (RuvSense) · 040 (PII gates) · 086 (ESP32 RaBitQ) ·
|
||||
<b>089 (nvsim, Accepted)</b> · 090 (Lindblad, Proposed-conditional) ·
|
||||
091 (sub-THz radar research) · <b>092 (this dashboard)</b>.<br><br>
|
||||
<b>Primary physics:</b> Cohen 1970 · Bison 2009 · Wolf 2015 · Barry RMP 2020 · Doherty 2013 · Jackson 3e §5.6/§5.8.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
/* Help center — single dialog covering Quickstart / Glossary / FAQ /
|
||||
* Shortcuts. Opened from the topbar `?` button or by pressing `?` on
|
||||
* the keyboard. Self-contained, no external content. */
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
type Section = 'quickstart' | 'glossary' | 'faq' | 'shortcuts' | 'about';
|
||||
|
||||
interface GlossaryItem {
|
||||
term: string;
|
||||
body: string;
|
||||
category: 'physics' | 'rust' | 'ui';
|
||||
}
|
||||
|
||||
const GLOSSARY: GlossaryItem[] = [
|
||||
{ term: 'NV-diamond', category: 'physics', body: 'Nitrogen-vacancy defect in synthetic diamond. The simulator models a 1 mm³ ensemble (~10¹² centers) addressed by 532 nm pump light + a 2.87 GHz microwave drive. Used as a room-temperature magnetometer with shot-noise floor ~1 pT/√Hz at the published lab record.' },
|
||||
{ term: 'CW-ODMR', category: 'physics', body: 'Continuously-driven optically-detected magnetic resonance. Sweep the microwave frequency around the NV zero-field splitting (D = 2.87 GHz) and watch the photoluminescence dip when the microwave matches the spin transition. The dip splits with applied magnetic field along each of the four ⟨111⟩ NV axes.' },
|
||||
{ term: 'MagFrame', category: 'rust', body: 'Fixed-layout 60-byte binary record nvsim emits per (sensor × sample). Magic 0xC51A_6E70, version 1, little-endian. Carries timestamp, recovered B vector (pT), per-axis sigma, noise floor, and flag bits for saturation / shot-noise-disabled / heavy-attenuation.' },
|
||||
{ term: 'Witness', category: 'rust', body: 'SHA-256 hash over the concatenated MagFrame bytes for a canonical reference run (Proof::REFERENCE_SCENE_JSON @ seed=42, N=256). Same inputs → same hash, byte-for-byte, across runs and machines. The dashboard re-derives it in WASM and compares against Proof::EXPECTED_WITNESS_HEX pinned at build time.' },
|
||||
{ term: 'Determinism gate', category: 'rust', body: 'A pass/fail check: did this build of nvsim produce the expected witness? If yes → every constant (γ_e, D_GS, μ₀, contrast, T₂*, the PRNG stream, the frame layout, the pipeline ordering) is byte-identical to the published reference. If no → something drifted; the dashboard names which.' },
|
||||
{ term: 'Lock-in demod', category: 'physics', body: 'Multiply the photoluminescence signal by cos(2π·f_mod·t) and low-pass to recover the slowly-varying B-field component. The simulator emulates a lock-in with output gain 2 and a single-pole IIR LP filter; settable via the Tunables panel (f_mod default 1 kHz).' },
|
||||
{ term: 'Shot-noise floor', category: 'physics', body: 'δB = 1 / (γ_e · C · √(N · t · T₂*)) — the irreducible quantum noise floor for an NV ensemble. With nvsim defaults (N=10¹², C=0.03, T₂*=200 ns): ≈1.18 pT/√Hz. Toggleable via the Tunables panel for "analytic" runs without noise.' },
|
||||
{ term: 'Biot-Savart', category: 'physics', body: 'Closed-form magnetic field at a point from a current loop or a magnetic dipole. The Scene panel\'s sources (heart proxy, mains loop, ferrous body, eddy current) all reduce to Biot-Savart-style superpositions over the sensor position.' },
|
||||
{ term: 'Multistatic fusion', category: 'physics', body: 'Combining evidence from multiple sensors at known geometric configurations. RuView\'s Cramer-Rao-weighted attention over WiFi CSI nodes + 60 GHz radar nodes + (hypothetically) NV nodes; documented in ADR-029 and the Ghost Murmur view.' },
|
||||
{ term: 'Scene', category: 'ui', body: 'The simulated magnetic environment: a list of sources (dipole, current loop, ferrous body, eddy current) plus one or more sensor positions and an ambient field. The dashboard ships a "rebar-walkby-01" reference scene; click "New scene…" in the command palette (⌘K) to build your own.' },
|
||||
{ term: 'Tunables', category: 'ui', body: 'Sliders that change the running pipeline\'s digitiser config. Each edit debounces 300 ms, then rebuilds the WASM pipeline with the new f_s / f_mod / dt / shot-noise setting. The frame stream picks up the change without a restart.' },
|
||||
{ term: 'Transport', category: 'ui', body: 'How the dashboard talks to nvsim. Default is WASM — the simulator runs in a Web Worker right here in your browser, no server. The optional WS transport is REST + binary WebSocket against a host-supplied nvsim-server (see ADR-092 §6.2). Toggle in Settings.' },
|
||||
{ term: 'App Store', category: 'ui', body: 'Catalog of all 65+ hot-loadable WASM edge modules from wifi-densepose-wasm-edge plus the simulators. Each card carries id / category / status / event IDs; the toggle marks an app active in this session and (in WS mode) pushes the activation to a connected ESP32 mesh.' },
|
||||
{ term: 'Ghost Murmur', category: 'ui', body: 'Research view that audits the publicly-reported April 2026 CIA NV-diamond heartbeat detector against the open physics literature. Includes a live "Try it yourself" sandbox where you can place a heart dipole at any distance from the sensor and ask: which transport tier would actually detect it?' },
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: 'Is this a real simulator or a mockup?',
|
||||
a: 'Real. The Rust crate at v2/crates/nvsim is the same code that runs in the browser via WASM. Press <b>Verify witness</b> on the Witness panel — the SHA-256 you see is byte-equivalent to what `cargo test -p nvsim` produces.',
|
||||
},
|
||||
{
|
||||
q: 'Why does my "Recovered |B|" sit much higher than "Predicted |B|" in the Ghost Murmur demo?',
|
||||
a: 'The recovered value reads the simulator\'s ADC quantization floor, not the actual magnetic signal. With COTS-default sensor noise (~300 pT/√Hz) and 16-bit ADC at ±10 µT FS, anything below ~1 pT vanishes into ~2 nT of digitization residual. That\'s the lesson — the press claim sits far below this floor at any meaningful range.',
|
||||
},
|
||||
{
|
||||
q: 'Can I run my own scene?',
|
||||
a: 'Yes. Press ⌘K to open the command palette and pick "New scene…". You get five fields (name, dipole moment, distance, ferrous toggle, mains toggle); the dashboard builds the JSON and pushes it via <code>client.loadScene()</code>.',
|
||||
},
|
||||
{
|
||||
q: 'Does any of my data leave the browser?',
|
||||
a: 'No. WASM mode is local-only — the worker, the WASM binary, and the IndexedDB persistence all live in your browser. The optional WS transport (off by default) talks to a host of your choosing.',
|
||||
},
|
||||
{
|
||||
q: 'What does the witness mismatch (red ✗) mean?',
|
||||
a: 'The current build of nvsim produced a SHA-256 that doesn\'t match the constant pinned at compile time. Possible causes: a different Rust toolchain, a dependency version drift, a manual edit to a physics constant, or an honest bug. Audit the diff against ADR-089 §5.',
|
||||
},
|
||||
{
|
||||
q: 'Why are the Inspector / Witness rail buttons there if there\'s already a right-side inspector?',
|
||||
a: 'The right-side inspector is the compact live view; the rail buttons open a full-width version with bigger charts, an explainer header, reference-scene metadata cards, and (on Witness) a "what this verifies" panel. Both stay in sync — the right rail is for glancing, the main area is for diving in.',
|
||||
},
|
||||
{
|
||||
q: 'Why is there an "App Store" if this is a magnetometer simulator?',
|
||||
a: 'Because nvsim is one tile in a larger sensing platform. The catalog lists every hot-loadable WASM edge module RuView ships — medical, security, building, retail, industrial, signal, learning, autonomy. The simulators (nvsim today, more in future) are first-class entries in the same catalog.',
|
||||
},
|
||||
];
|
||||
|
||||
const QUICKSTART = [
|
||||
{ step: 1, title: 'Hit ▶ Run', body: 'The big amber button in the topbar starts the live frame stream. The pipeline runs ~1.8 kHz on x86_64 WASM, well above the 1 kHz Cortex-A53 acceptance gate.' },
|
||||
{ step: 2, title: 'Watch the B-vector trace', body: 'The Inspector → Signal tab shows the recovered field per axis updating in real time. The frame strip below it is one bar per ~32-frame batch.' },
|
||||
{ step: 3, title: 'Verify the witness', body: 'Click the rail Witness button (or REPL: <code>proof.verify</code>). The dashboard re-runs the canonical reference scene and asserts the SHA-256 byte-for-byte.' },
|
||||
{ step: 4, title: 'Drag a source', body: 'Grab the rebar / heart proxy / mains loop / ferrous door in the scene canvas; positions persist via IndexedDB.' },
|
||||
{ step: 5, title: 'Tweak the tunables', body: 'Sliders in the left sidebar update the running pipeline (f_s, f_mod, integration time, shot-noise). Changes debounce 300 ms then push to the worker.' },
|
||||
{ step: 6, title: 'Open the Ghost Murmur view', body: 'The ghost icon in the rail. Move the distance + moment sliders, hit "Run nvsim at this distance" — the live demo runs the real Rust pipeline through WASM and shows which transport tier would actually detect.' },
|
||||
{ step: 7, title: 'Browse the App Store', body: 'The grid icon. 65+ edge apps: medical, security, building, retail, industrial, signal, learning. Toggle to mark active in this session.' },
|
||||
];
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: '⌘K / Ctrl K', label: 'Command palette' },
|
||||
{ keys: 'Space', label: 'Play / pause pipeline' },
|
||||
{ keys: '⌘R / Ctrl R', label: 'Reset pipeline (with confirm)' },
|
||||
{ keys: '⌘, / Ctrl ,', label: 'Settings drawer' },
|
||||
{ keys: '⌘N / Ctrl N', label: 'New scene' },
|
||||
{ keys: '⌘E / Ctrl E', label: 'Export proof bundle' },
|
||||
{ keys: '⌘/ / Ctrl /', label: 'Toggle theme (dark / light)' },
|
||||
{ keys: '`', label: 'Toggle debug HUD' },
|
||||
{ keys: '?', label: 'Open this help center' },
|
||||
{ keys: '1 · 2 · 3', label: 'Switch inspector tab (Signal / Frame / Witness)' },
|
||||
{ keys: 'Esc', label: 'Close any modal / palette / drawer' },
|
||||
{ keys: '/', label: 'Focus the REPL prompt' },
|
||||
];
|
||||
|
||||
@customElement('nv-help')
|
||||
export class NvHelp extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private section: Section = 'quickstart';
|
||||
@state() private query = '';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 230;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
width: min(880px, 94vw);
|
||||
max-height: 86vh;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||||
}
|
||||
:host([open]) .modal { transform: translateY(0) scale(1); }
|
||||
@media (max-width: 700px) {
|
||||
.modal { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr auto; max-height: 92vh; }
|
||||
.nav { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; overflow-x: auto; }
|
||||
.nav button { white-space: nowrap; }
|
||||
}
|
||||
.h {
|
||||
grid-column: 1 / -1;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 15px; font-weight: 600; }
|
||||
.nav {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 12px 8px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.nav button {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.nav button:hover { color: var(--ink); background: var(--bg-2); }
|
||||
.nav button.on {
|
||||
color: var(--ink); background: var(--bg-3);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.body {
|
||||
padding: 18px 22px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.body h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.body .lead {
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.body p { margin: 0 0 12px; }
|
||||
.body code {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-3);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.body kbd {
|
||||
font-family: var(--mono);
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.step:last-child { border-bottom: 0; }
|
||||
.step .num {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.step .ttl { color: var(--ink); font-weight: 600; font-size: 13.5px; margin-bottom: 2px; }
|
||||
.step .body-text { font-size: 12.5px; color: var(--ink-2); line-height: 1.55; }
|
||||
.glossary-search {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.glossary-search:focus { border-color: var(--accent); }
|
||||
.term {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.term:last-child { border-bottom: 0; }
|
||||
.term .head {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
|
||||
}
|
||||
.term .name {
|
||||
font-family: var(--mono);
|
||||
font-size: 13.5px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.term .badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.term .badge.physics { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.4); }
|
||||
.term .badge.rust { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.4); }
|
||||
.term .badge.ui { color: var(--accent-4); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.term .body-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.faq-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.faq-item:last-child { border-bottom: 0; }
|
||||
.faq-item .q {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.faq-item .a { font-size: 12.5px; color: var(--ink-2); line-height: 1.55; }
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.f {
|
||||
grid-column: 1 / -1;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-show-help', this.show as EventListener);
|
||||
window.addEventListener('nv-show-help-close', this.closeListener);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-show-help', this.show as EventListener);
|
||||
window.removeEventListener('nv-show-help-close', this.closeListener);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
private closeListener = (): void => this.close();
|
||||
|
||||
private show = (e: Event): void => {
|
||||
const detail = (e as CustomEvent).detail as { section?: Section } | undefined;
|
||||
if (detail?.section) this.section = detail.section;
|
||||
this.open = true;
|
||||
this.setAttribute('open', '');
|
||||
};
|
||||
private close(): void {
|
||||
this.open = false;
|
||||
this.removeAttribute('open');
|
||||
}
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const isInput = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA';
|
||||
if (e.key === '?' && !isInput && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
this.show(new CustomEvent('nv-show-help'));
|
||||
} else if (e.key === 'Escape' && this.open) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
private filteredGlossary(): GlossaryItem[] {
|
||||
if (!this.query.trim()) return GLOSSARY;
|
||||
const q = this.query.toLowerCase();
|
||||
return GLOSSARY.filter((g) =>
|
||||
g.term.toLowerCase().includes(q) || g.body.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
private renderQuickstart() {
|
||||
return html`
|
||||
<h2>Quickstart</h2>
|
||||
<p class="lead">Seven taps to get from "I just opened the dashboard" to "I'm running my own scene with verified determinism."</p>
|
||||
<button
|
||||
style="display:inline-flex; align-items:center; gap:8px; padding:10px 16px; margin-bottom:14px; background:var(--accent); color:#1a0f00; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; font-family:inherit;"
|
||||
@click=${() => { window.dispatchEvent(new CustomEvent('nv-show-help-close')); window.dispatchEvent(new CustomEvent('nv-show-tour')); }}>
|
||||
★ Take the interactive 10-step tour
|
||||
</button>
|
||||
${QUICKSTART.map((s) => html`
|
||||
<div class="step">
|
||||
<div class="num">${s.step}</div>
|
||||
<div>
|
||||
<div class="ttl">${s.title}</div>
|
||||
<div class="body-text" .innerHTML=${s.body}></div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGlossary() {
|
||||
const items = this.filteredGlossary();
|
||||
return html`
|
||||
<h2>Glossary</h2>
|
||||
<p class="lead">Every piece of jargon in the dashboard, defined in one paragraph each.</p>
|
||||
<input class="glossary-search" type="text" placeholder="Search 14 terms…"
|
||||
.value=${this.query}
|
||||
@input=${(e: Event) => this.query = (e.target as HTMLInputElement).value} />
|
||||
${items.length === 0
|
||||
? html`<p style="color: var(--ink-3);">No terms match.</p>`
|
||||
: items.map((g) => html`
|
||||
<div class="term">
|
||||
<div class="head">
|
||||
<span class="name">${g.term}</span>
|
||||
<span class="badge ${g.category}">${g.category}</span>
|
||||
</div>
|
||||
<div class="body-text">${g.body}</div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFaq() {
|
||||
return html`
|
||||
<h2>FAQ</h2>
|
||||
<p class="lead">The questions I was asked twice in the first week of demos.</p>
|
||||
${FAQ.map((item) => html`
|
||||
<div class="faq-item">
|
||||
<div class="q">${item.q}</div>
|
||||
<div class="a" .innerHTML=${item.a}></div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderShortcuts() {
|
||||
return html`
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p class="lead">Everything is reachable without a mouse.</p>
|
||||
<div class="shortcuts">
|
||||
${SHORTCUTS.map((s) => html`
|
||||
<kbd>${s.keys}</kbd><span>${s.label}</span>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAbout() {
|
||||
return html`
|
||||
<h2>About this dashboard</h2>
|
||||
<p class="lead">What you're looking at, in one screen.</p>
|
||||
<p><b>nvsim</b> is a deterministic forward simulator for nitrogen-vacancy diamond magnetometry.
|
||||
The Rust crate at <code>v2/crates/nvsim</code> is the source of truth; this dashboard is a
|
||||
Vite + Lit single-page app that ships the crate compiled to WebAssembly inside a Web Worker.</p>
|
||||
<p>The defining commitment is <b>determinism</b>: same <code>(scene, config, seed)</code> →
|
||||
byte-identical SHA-256 witness across browsers, OSes, and transports. Press the
|
||||
<kbd>Verify witness</kbd> button on the Witness tab to assert this live.</p>
|
||||
<p>The codebase is open source (Apache-2.0 OR MIT). Find it on GitHub:
|
||||
<code>github.com/ruvnet/RuView</code>. Decisions are documented in ADRs 089 (nvsim),
|
||||
090 (Lindblad extension, conditional), 091 (sub-THz radar research),
|
||||
092 (this dashboard), 093 (UX gap analysis).</p>
|
||||
<p>This dashboard is one of several RuView demos. Sibling demos at
|
||||
<code>github.io/RuView/</code> include the Observatory and Pose Fusion views.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Help center">
|
||||
<div class="h">
|
||||
<div class="ttl">Help</div>
|
||||
<button class="close" aria-label="Close help" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<nav class="nav" role="tablist" aria-label="Help sections">
|
||||
${(['quickstart', 'glossary', 'faq', 'shortcuts', 'about'] as Section[]).map((s) => html`
|
||||
<button class=${this.section === s ? 'on' : ''} role="tab"
|
||||
aria-selected=${this.section === s}
|
||||
@click=${() => this.section = s}>
|
||||
${s === 'quickstart' ? '🚀 Quickstart'
|
||||
: s === 'glossary' ? '📖 Glossary'
|
||||
: s === 'faq' ? '? FAQ'
|
||||
: s === 'shortcuts' ? '⌨ Shortcuts'
|
||||
: 'ℹ About'}
|
||||
</button>
|
||||
`)}
|
||||
</nav>
|
||||
<div class="body" role="tabpanel">
|
||||
${this.section === 'quickstart' ? this.renderQuickstart()
|
||||
: this.section === 'glossary' ? this.renderGlossary()
|
||||
: this.section === 'faq' ? this.renderFaq()
|
||||
: this.section === 'shortcuts' ? this.renderShortcuts()
|
||||
: this.renderAbout()}
|
||||
</div>
|
||||
<div class="f">
|
||||
<span>Press <kbd style="font-family:var(--mono);font-size:10.5px;padding:1px 4px;background:var(--bg-3);border:1px solid var(--line);border-radius:3px;">?</kbd> any time to reopen</span>
|
||||
<span>nvsim · Apache-2.0 OR MIT</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function showHelp(section?: Section): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section } }));
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
/* Home view — friendly landing surface for new users.
|
||||
*
|
||||
* The full-power scene + sidebar + inspector + console are intentionally
|
||||
* dense; that's the operator surface. Home is for first-time visitors:
|
||||
* a single hero CTA, four quick-jump action cards, and a 1-paragraph
|
||||
* explanation of what this dashboard is. No jargon above the fold.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { running, getClient, witnessVerified, fps, pushLog } from '../store/appStore';
|
||||
|
||||
export type Action = 'scene' | 'apps' | 'witness' | 'ghost-murmur' | 'help' | 'tour';
|
||||
|
||||
@customElement('nv-home')
|
||||
export class NvHome extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 28px clamp(16px, 6vw, 56px) 60px;
|
||||
}
|
||||
.hero {
|
||||
max-width: 800px;
|
||||
margin: 16px auto 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero .icon {
|
||||
width: 56px; height: 56px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: #1a0f00;
|
||||
box-shadow: 0 8px 24px -6px oklch(0.55 0.16 30 / 0.4);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(24px, 4vw, 34px);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.hero .tag {
|
||||
font-size: clamp(13px, 1.6vw, 15px);
|
||||
color: var(--ink-2);
|
||||
margin: 0 0 22px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.hero .ctas {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.cta {
|
||||
padding: 11px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
transition: transform 0.12s, border-color 0.12s, filter 0.12s;
|
||||
}
|
||||
.cta:hover { transform: translateY(-1px); border-color: var(--line-2); }
|
||||
.cta.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1a0f00;
|
||||
}
|
||||
.cta.primary:hover { filter: brightness(1.08); }
|
||||
.status {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
color: var(--ink-2);
|
||||
margin-top: 18px;
|
||||
}
|
||||
.status .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
.status.live .dot {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 8px var(--ok);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
|
||||
.grid {
|
||||
max-width: 980px;
|
||||
margin: 36px auto 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, border-color 0.12s, background 0.12s;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 70 / 0.04) 100%);
|
||||
}
|
||||
.card .ico {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.card .arrow {
|
||||
color: var(--accent);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
max-width: 800px;
|
||||
margin: 36px auto 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.footnote code {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-3);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.footnote a {
|
||||
color: var(--accent-2);
|
||||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { running.value; witnessVerified.value; fps.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
private go(action: Action): void {
|
||||
if (action === 'tour') { window.dispatchEvent(new CustomEvent('nv-show-tour')); return; }
|
||||
if (action === 'help') { window.dispatchEvent(new CustomEvent('nv-show-help')); return; }
|
||||
this.dispatchEvent(new CustomEvent('navigate', { detail: action, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
private async runDemo(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
if (running.value) return;
|
||||
await c.run();
|
||||
running.value = true;
|
||||
pushLog('ok', 'demo started · streaming MagFrames');
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isRunning = running.value;
|
||||
const wasVerified = witnessVerified.value === 'ok';
|
||||
return html`
|
||||
<div class="hero">
|
||||
<div class="icon" aria-hidden="true">NV</div>
|
||||
<h1>An open-source quantum-magnetometer simulator, in your browser.</h1>
|
||||
<p class="tag">
|
||||
nvsim runs a real Rust simulator (the same code that
|
||||
<code style="font-family:var(--mono); background:var(--bg-3); padding:1px 5px; border-radius:4px; color:var(--accent); font-size:12px;">cargo test</code>
|
||||
uses) entirely in WebAssembly. No server, no upload, no telemetry.
|
||||
Press the button to start the live magnetic-field simulation, or
|
||||
take the 60-second tour first.
|
||||
</p>
|
||||
<div class="ctas">
|
||||
<button class="cta primary" id="home-run-btn" @click=${() => this.runDemo()}>
|
||||
${isRunning ? '✓ Demo running' : '▶ Run the simulation'}
|
||||
</button>
|
||||
<button class="cta" id="home-tour-btn" @click=${() => this.go('tour')}>
|
||||
★ Take the 60-second tour
|
||||
</button>
|
||||
<button class="cta" id="home-help-btn" @click=${() => this.go('help')}>
|
||||
? Help center
|
||||
</button>
|
||||
</div>
|
||||
<div class="status ${isRunning ? 'live' : ''}">
|
||||
<span class="dot"></span>
|
||||
${isRunning
|
||||
? html`Live · ${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'starting…'}${wasVerified ? ' · witness verified ✓' : ''}`
|
||||
: html`Idle${wasVerified ? ' · witness verified ✓' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('scene')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('scene'); } }}>
|
||||
<div class="ico">🌐</div>
|
||||
<h3>Live scene</h3>
|
||||
<p>Drag magnetic sources, watch the recovered field update in real time, and tweak sample rate / noise / integration.</p>
|
||||
<div class="arrow">Open scene →</div>
|
||||
</div>
|
||||
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('apps')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('apps'); } }}>
|
||||
<div class="ico">🛍</div>
|
||||
<h3>App Store · 66 edge apps</h3>
|
||||
<p>Browse 65 hot-loadable WASM sensing modules across medical, security, building, retail, industrial, learning. Six run live in the browser.</p>
|
||||
<div class="arrow">Browse the catalogue →</div>
|
||||
</div>
|
||||
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('witness')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('witness'); } }}>
|
||||
<div class="ico">✓</div>
|
||||
<h3>Determinism gate</h3>
|
||||
<p>Re-derive the SHA-256 witness for the canonical reference scene right here in your browser. Same inputs → same hash, every time.</p>
|
||||
<div class="arrow">Verify the witness →</div>
|
||||
</div>
|
||||
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('ghost-murmur')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('ghost-murmur'); } }}>
|
||||
<div class="ico">👻</div>
|
||||
<h3>Ghost Murmur reality check</h3>
|
||||
<p>Audit the publicly-reported April 2026 CIA NV-diamond program against published physics. Live distance/moment sliders.</p>
|
||||
<div class="arrow">Read the spec →</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
New here? <a @click=${() => this.go('tour')}>Take the 60-second guided tour</a>
|
||||
— every panel is explained. Or press <code>?</code> for the help center
|
||||
(quickstart, glossary, FAQ, shortcuts) any time.<br>
|
||||
Open source · Apache-2.0 OR MIT · <code>github.com/ruvnet/RuView</code>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
/* Inspector — tabbed: Signal / Frame / Witness. */
|
||||
import { LitElement, html, css, svg, type PropertyValues } from 'lit';
|
||||
import { customElement, state, property } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
traceX, traceY, traceZ, stripBars, lastFrame,
|
||||
witnessHex, expectedWitness, witnessVerified, getClient,
|
||||
pushLog, lastB, bMag,
|
||||
} from '../store/appStore';
|
||||
|
||||
type Tab = 'signal' | 'frame' | 'witness';
|
||||
|
||||
@customElement('nv-inspector')
|
||||
export class NvInspector extends LitElement {
|
||||
@state() private tab: Tab = 'signal';
|
||||
/** When set by the parent, force the tab and pulse-highlight it. */
|
||||
@property({ attribute: false }) pinTab: Tab | null = null;
|
||||
/** When `expanded`, the inspector renders as a full-screen view with bigger
|
||||
* charts and a wider Witness panel. Used when the rail Inspector/Witness
|
||||
* button is clicked — see ADR-093 P1.13. */
|
||||
@property({ type: Boolean, reflect: true }) expanded = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
:host([expanded]) {
|
||||
border-left: 0;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
}
|
||||
:host([expanded]) .tabs {
|
||||
padding: 0 24px;
|
||||
background: var(--bg-1);
|
||||
}
|
||||
:host([expanded]) .tab {
|
||||
padding: 16px 22px;
|
||||
font-size: 13.5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
:host([expanded]) .body {
|
||||
padding: 24px 28px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
:host([expanded]) .card { padding: 18px 20px; }
|
||||
:host([expanded]) .card-h .ttl { font-size: 14px; }
|
||||
:host([expanded]) svg { height: 220px; }
|
||||
:host([expanded]) .frame-strip { height: 48px; }
|
||||
:host([expanded]) table { font-size: 12.5px; }
|
||||
:host([expanded]) td { padding: 6px 0; }
|
||||
:host([expanded]) .hex { font-size: 12px; padding: 14px; line-height: 1.7; }
|
||||
:host([expanded]) .witness-box { font-size: 13px; padding: 14px 16px; line-height: 1.6; }
|
||||
:host([expanded]) .verify-btn { padding: 12px; font-size: 13px; }
|
||||
:host([expanded]) .grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
:host([expanded]) .grid-2 > .card { margin-bottom: 0; }
|
||||
@media (max-width: 1024px) {
|
||||
:host([expanded]) .grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
.tabs {
|
||||
display: flex; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 11px 8px;
|
||||
background: transparent; border: none;
|
||||
font-size: 11.5px; font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||
.tab:hover { color: var(--ink-2); }
|
||||
.body { padding: 14px; flex: 1; overflow-y: auto; }
|
||||
|
||||
.card {
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-h {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-h .ttl { font-size: 12px; font-weight: 600; }
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: oklch(0.78 0.14 195 / 0.12);
|
||||
color: var(--accent-2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid oklch(0.78 0.14 195 / 0.3);
|
||||
}
|
||||
svg { width: 100%; height: 130px; }
|
||||
.frame-strip {
|
||||
height: 28px;
|
||||
display: flex; align-items: flex-end; gap: 1px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, var(--accent-2), var(--accent));
|
||||
border-radius: 1px;
|
||||
min-height: 2px;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 10.5px; }
|
||||
td { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
td:first-child { color: var(--ink-3); }
|
||||
td:last-child { text-align: right; color: var(--ink); }
|
||||
.hex {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hex .magic { color: var(--accent); font-weight: 600; }
|
||||
.witness-box {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.verify-btn {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
color: var(--ink);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.verify-btn:hover { border-color: var(--accent); }
|
||||
.verify-btn.ok { border-color: var(--ok); color: var(--ok); }
|
||||
.verify-btn.fail { border-color: var(--bad); color: var(--bad); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
traceX.value; traceY.value; traceZ.value; stripBars.value;
|
||||
lastFrame.value; witnessHex.value; witnessVerified.value;
|
||||
lastB.value; bMag.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
override willUpdate(changed: PropertyValues): void {
|
||||
// Apply parent-driven tab pin during willUpdate so the new tab value
|
||||
// participates in this same render pass — avoids the "update after
|
||||
// update completed" Lit warning that would fire if we did this in
|
||||
// updated().
|
||||
if (changed.has('pinTab') && this.pinTab && this.tab !== this.pinTab) {
|
||||
this.tab = this.pinTab;
|
||||
}
|
||||
}
|
||||
|
||||
private async verify(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
witnessVerified.value = 'pending';
|
||||
pushLog('info', 'verifying witness over 256 frames…');
|
||||
try {
|
||||
const exp = expectedWitness.value;
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessVerified.value = 'ok';
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`);
|
||||
} else {
|
||||
witnessVerified.value = 'fail';
|
||||
const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH actual=${actual.slice(0, 16)}…`);
|
||||
}
|
||||
} catch (e) {
|
||||
witnessVerified.value = 'fail';
|
||||
pushLog('err', `verify failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private renderHeader() {
|
||||
if (!this.expanded) return '';
|
||||
const titles: Record<Tab, string> = {
|
||||
signal: 'Signal inspector — live B-vector trace + frame stream',
|
||||
frame: 'Frame inspector — MagFrame v1 fields + raw bytes',
|
||||
witness: 'Witness panel — SHA-256 determinism gate',
|
||||
};
|
||||
return html`
|
||||
<h1 style="margin: 8px 0 14px; font-size: 20px; letter-spacing: -0.01em;">
|
||||
${titles[this.tab]}
|
||||
</h1>
|
||||
<p style="margin: 0 0 18px; font-size: 12.5px; color: var(--ink-3); line-height: 1.55; max-width: 780px;">
|
||||
${this.tab === 'signal'
|
||||
? 'Real-time recovered field-vector and frame-stream sparkline. Both update at the running pipeline\'s frame rate. Use the Tunables panel in the sidebar to change f_s, f_mod, dt, and shot-noise behaviour.'
|
||||
: this.tab === 'frame'
|
||||
? 'Decoded view of the most recent MagFrame: typed fields plus the raw 60-byte little-endian binary record (magic 0xC51A_6E70).'
|
||||
: 'Re-derive the SHA-256 witness for the canonical reference scene (seed=42, N=256) right now in your browser and compare against Proof::EXPECTED_WITNESS_HEX. Same inputs → same hash, byte-for-byte, across every machine and transport.'}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSignalTab() {
|
||||
const W = 320, H = 130, cy = 65, scale = 22;
|
||||
const cap = 200;
|
||||
const make = (arr: number[]) => {
|
||||
let p = '';
|
||||
arr.forEach((v, i) => {
|
||||
const x = (i / Math.max(1, cap - 1)) * W;
|
||||
const y = cy - v * scale;
|
||||
p += (i === 0 ? 'M' : 'L') + ` ${x.toFixed(1)} ${y.toFixed(1)} `;
|
||||
});
|
||||
return p;
|
||||
};
|
||||
|
||||
const b = lastB.value;
|
||||
const bnT = [b[0] * 1e9, b[1] * 1e9, b[2] * 1e9];
|
||||
const hasData = traceX.value.length > 0;
|
||||
|
||||
return html`
|
||||
${!hasData ? html`
|
||||
<div class="card" style="text-align:center; padding:18px;">
|
||||
<div style="font-size:13px; color:var(--ink-2); line-height:1.55;">
|
||||
No frames yet. Press <b>▶ Run</b> in the topbar (or hit <code style="font-family:var(--mono);background:var(--bg-3);padding:1px 5px;border-radius:4px;color:var(--accent);">Space</code>)
|
||||
to start the live B-vector trace.
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class=${this.expanded ? 'grid-2' : ''}>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">B-vector trace</span>
|
||||
<span class="badge">3-axis · nT</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
|
||||
<line x1="0" y1=${cy} x2=${W} y2=${cy} stroke="var(--line)" stroke-width="0.5"/>
|
||||
${svg`<path id="trace-x" d=${make(traceX.value)} stroke="oklch(0.78 0.14 70)" stroke-width="1.2" fill="none"/>`}
|
||||
${svg`<path id="trace-y" d=${make(traceY.value)} stroke="oklch(0.78 0.12 195)" stroke-width="1.2" fill="none" opacity="0.8"/>`}
|
||||
${svg`<path id="trace-z" d=${make(traceZ.value)} stroke="oklch(0.72 0.18 330)" stroke-width="1.2" fill="none" opacity="0.7"/>`}
|
||||
</svg>
|
||||
${this.expanded ? html`<div style="display:flex;gap:14px;font-size:12px;font-family:var(--mono);margin-top:8px;">
|
||||
<span style="color:oklch(0.78 0.14 70);">x: ${bnT[0].toFixed(3)} nT</span>
|
||||
<span style="color:oklch(0.78 0.12 195);">y: ${bnT[1].toFixed(3)} nT</span>
|
||||
<span style="color:oklch(0.72 0.18 330);">z: ${bnT[2].toFixed(3)} nT</span>
|
||||
<span style="color:var(--accent);margin-left:auto;">|B| ${(bMag.value * 1e9).toFixed(3)} nT</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Frame stream</span>
|
||||
<span class="badge" id="strip-rate">live</span>
|
||||
</div>
|
||||
<div class="frame-strip" id="frame-strip">
|
||||
${stripBars.value.map((v) => html`<div class="bar" style=${`height:${Math.max(4, v * 100)}%`}></div>`)}
|
||||
</div>
|
||||
${this.expanded ? html`
|
||||
<div style="display:flex;gap:24px;font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-top:12px;">
|
||||
<span>frames in window: <span style="color:var(--ink);">${stripBars.value.length}</span></span>
|
||||
<span>noise floor: <span style="color:var(--ink);">${lastFrame.value ? lastFrame.value.noiseFloorPtSqrtHz.toFixed(2) + ' pT/√Hz' : '—'}</span></span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFrameTab() {
|
||||
const f = lastFrame.value;
|
||||
const bytes = f?.raw;
|
||||
let hex = '';
|
||||
if (bytes) {
|
||||
const arr = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0'));
|
||||
hex = arr.slice(0, 60).join(' ');
|
||||
}
|
||||
return html`
|
||||
${!f ? html`
|
||||
<div class="card" style="text-align:center; padding:18px;">
|
||||
<div style="font-size:13px; color:var(--ink-2); line-height:1.55;">
|
||||
No MagFrame to display yet. Start the pipeline (<b>▶ Run</b>) to populate.
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class=${this.expanded ? 'grid-2' : ''}>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">MagFrame v1 fields</span>
|
||||
<span class="badge">60 B</span>
|
||||
</div>
|
||||
<table>
|
||||
<tr><td>magic</td><td id="frame-magic">${f ? '0x' + f.magic.toString(16).toUpperCase() : '—'}</td></tr>
|
||||
<tr><td>version</td><td>${f?.version ?? '—'}</td></tr>
|
||||
<tr><td>flags</td><td>0x${(f?.flags ?? 0).toString(16).padStart(4, '0')}</td></tr>
|
||||
<tr><td>sensor_id</td><td>${f?.sensorId ?? '—'}</td></tr>
|
||||
<tr><td>t_us</td><td>${f ? f.tUs.toString() : '—'}</td></tr>
|
||||
<tr><td>b_pT[0]</td><td id="frame-bx">${f ? f.bPt[0].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>b_pT[1]</td><td id="frame-by">${f ? f.bPt[1].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>b_pT[2]</td><td id="frame-bz">${f ? f.bPt[2].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>noise_floor</td><td>${f ? f.noiseFloorPtSqrtHz.toFixed(2) : '—'}</td></tr>
|
||||
<tr><td>temp_K</td><td>${f ? f.temperatureK.toFixed(1) : '—'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Hex dump</span>
|
||||
<span class="badge">LE</span>
|
||||
</div>
|
||||
<div class="hex" id="frame-hex">${hex || '—'}</div>
|
||||
${this.expanded ? html`
|
||||
<div style="font-size: 11.5px; color: var(--ink-3); margin-top: 10px; line-height: 1.6;">
|
||||
Layout (little-endian): <code>magic(u32) version(u16) flags(u16) sensor_id(u16) _reserved(u16) t_us(u64) b_pt[3](f32) sigma_pt[3](f32) noise_floor(f32) temp_K(f32)</code>.
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWitnessTab() {
|
||||
const status = witnessVerified.value;
|
||||
const cls = status === 'ok' ? 'ok' : status === 'fail' ? 'fail' : '';
|
||||
const label =
|
||||
status === 'pending' ? 'Verifying…' :
|
||||
status === 'ok' ? '✓ Witness verified · determinism gate' :
|
||||
status === 'fail' ? '✗ Witness mismatch · audit required' :
|
||||
'Verify witness';
|
||||
const match = expectedWitness.value && witnessHex.value && expectedWitness.value === witnessHex.value;
|
||||
return html`
|
||||
${this.expanded ? html`
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:12px;margin-bottom:18px;">
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Reference scene</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;color:var(--ink);margin-top:4px;">Proof::REFERENCE</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">2 dipoles · 1 loop · 1 ferrous · 1 sensor</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Seed</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;color:var(--accent);margin-top:4px;">0x0000002A</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">canonical Proof::SEED</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Sample count</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;color:var(--ink);margin-top:4px;">256</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">Proof::N_SAMPLES</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Status</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;margin-top:4px;color:${status === 'ok' ? 'var(--ok)' : status === 'fail' ? 'var(--bad)' : 'var(--ink-3)'};">
|
||||
${status === 'ok' ? '✓ matches' : status === 'fail' ? '✗ drift' : status === 'pending' ? '… running' : '— idle'}
|
||||
</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">${match ? 'byte-equivalent' : 'not yet verified'}</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Expected (Proof::EXPECTED_WITNESS_HEX)</span>
|
||||
<span class="badge">SHA-256</span>
|
||||
</div>
|
||||
<div class="witness-box" id="expected-witness">${expectedWitness.value || '(loading…)'}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Actual (last verify)</span>
|
||||
<span class="badge">SHA-256</span>
|
||||
</div>
|
||||
<div class="witness-box" id="actual-witness">${witnessHex.value || '(not verified yet)'}</div>
|
||||
<button class="verify-btn ${cls}" id="verify-btn" @click=${this.verify}>${label}</button>
|
||||
</div>
|
||||
${this.expanded ? html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">What this verifies</span>
|
||||
<span class="badge">ADR-089 §5</span>
|
||||
</div>
|
||||
<div style="font-size: 12.5px; color: var(--ink-2); line-height: 1.6;">
|
||||
<p style="margin: 0 0 10px;">Pressing <b>Verify</b> runs the canonical reference pipeline
|
||||
(<code>Proof::generate</code>) end-to-end inside this browser's WASM Worker:
|
||||
scene → Biot-Savart synthesis → material attenuation → NV ensemble → ADC + lock-in →
|
||||
concatenated <code>MagFrame</code> bytes → SHA-256.</p>
|
||||
<p style="margin: 0 0 10px;">If the resulting hash matches the constant pinned at build time
|
||||
(<code>cc8de9b01b0ff5bd…</code>), every constant — γ_e, D_GS, μ₀, T₂*, contrast, the PRNG
|
||||
stream, the frame layout, the pipeline ordering — is byte-identical to the published
|
||||
reference. If it doesn't match, <i>something</i> drifted; the dashboard names which.</p>
|
||||
<p style="margin: 0;">This is the same regression test that runs in
|
||||
<code>cargo test -p nvsim</code> — running in your browser, against your own WASM build.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab ${this.tab === 'signal' ? 'active' : ''}" data-pane="signal"
|
||||
role="tab" aria-selected=${this.tab === 'signal'}
|
||||
@click=${() => this.tab = 'signal'}>Signal</button>
|
||||
<button class="tab ${this.tab === 'frame' ? 'active' : ''}" data-pane="frame"
|
||||
role="tab" aria-selected=${this.tab === 'frame'}
|
||||
@click=${() => this.tab = 'frame'}>Frame</button>
|
||||
<button class="tab ${this.tab === 'witness' ? 'active' : ''}" data-pane="witness"
|
||||
role="tab" aria-selected=${this.tab === 'witness'}
|
||||
@click=${() => this.tab = 'witness'}>Witness</button>
|
||||
</div>
|
||||
<div class="body" role="tabpanel">
|
||||
${this.renderHeader()}
|
||||
${this.tab === 'signal' ? this.renderSignalTab()
|
||||
: this.tab === 'frame' ? this.renderFrameTab()
|
||||
: this.renderWitnessTab()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/* Modal dialog — opened via window.dispatchEvent('nv-modal', { title, body, buttons }). */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
interface ModalButton {
|
||||
label: string;
|
||||
variant?: 'ghost' | 'primary' | 'danger';
|
||||
onClick?: () => void;
|
||||
}
|
||||
interface ModalReq {
|
||||
title: string;
|
||||
body: string;
|
||||
buttons?: ModalButton[];
|
||||
}
|
||||
|
||||
@customElement('nv-modal')
|
||||
export class NvModal extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private mTitle = '';
|
||||
@state() private mBody = '';
|
||||
@state() private buttons: ModalButton[] = [];
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
width: min(520px, 92vw);
|
||||
max-height: 86vh;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||||
}
|
||||
:host([open]) .modal { transform: translateY(0) scale(1); }
|
||||
.h {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 14px; font-weight: 600; }
|
||||
.body { padding: 16px; overflow-y: auto; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
|
||||
.f {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2); color: var(--ink);
|
||||
}
|
||||
button.ghost { background: transparent; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
|
||||
button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-modal', this.onModal as EventListener);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-modal', this.onModal as EventListener);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
private onModal = (e: Event): void => {
|
||||
const r = (e as CustomEvent).detail as ModalReq;
|
||||
this.mTitle = r.title; this.mBody = r.body;
|
||||
this.buttons = r.buttons ?? [{ label: 'Close', variant: 'primary' }];
|
||||
this.open = true; this.setAttribute('open', '');
|
||||
// a11y: focus the first interactive element inside the modal so keyboard
|
||||
// users land in the dialog rather than behind it. Light focus trap via
|
||||
// the keydown handler below catches Tab cycling.
|
||||
requestAnimationFrame(() => {
|
||||
const root = this.shadowRoot;
|
||||
if (!root) return;
|
||||
const first = root.querySelector<HTMLElement>('input, select, textarea, button:not(.close)');
|
||||
first?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
override updated(): void {
|
||||
if (!this.open) return;
|
||||
const root = this.shadowRoot;
|
||||
if (!root) return;
|
||||
// Trap Tab inside the modal while open.
|
||||
const trap = (e: KeyboardEvent): void => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusables = Array.from(
|
||||
root.querySelectorAll<HTMLElement>('input, select, textarea, button, [href]'),
|
||||
).filter((el) => !el.hasAttribute('disabled'));
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = (root.activeElement as HTMLElement | null) ?? null;
|
||||
if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
root.removeEventListener('keydown', trap as EventListener);
|
||||
root.addEventListener('keydown', trap as EventListener);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && this.open) this.close();
|
||||
};
|
||||
|
||||
private close(): void { this.open = false; this.removeAttribute('open'); }
|
||||
private clickBtn(b: ModalButton): void { b.onClick?.(); this.close(); }
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="h">
|
||||
<div class="ttl">${this.mTitle}</div>
|
||||
<button class="close" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<div class="body" .innerHTML=${this.mBody}></div>
|
||||
<div class="f">
|
||||
${this.buttons.map((b) => html`
|
||||
<button class=${b.variant ?? ''} @click=${() => this.clickBtn(b)}>${b.label}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function openModal(req: ModalReq): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-modal', { detail: req }));
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
/* Welcome modal + step-by-step introduction tour.
|
||||
*
|
||||
* 10 steps walking the user through every panel of the dashboard with
|
||||
* concrete CTAs ("Try it now") that fire real navigation against the
|
||||
* live UI. First-run only by default; replayable via Settings → Help.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { kvGet, kvSet } from '../store/persistence';
|
||||
|
||||
interface TourStep {
|
||||
/** Optional icon shown at the top of the step. */
|
||||
icon: string;
|
||||
title: string;
|
||||
/** Markdown-ish HTML body (rendered via .innerHTML). */
|
||||
body: string;
|
||||
/** Optional CTA: clicking runs the action then advances. */
|
||||
cta?: { label: string; run?: () => void };
|
||||
/** Optional "do this yourself" hint. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const STEPS: TourStep[] = [
|
||||
{
|
||||
icon: '👋',
|
||||
title: 'Welcome to nvsim',
|
||||
body: `<p style="font-size:14px; line-height:1.6;">
|
||||
<b>nvsim</b> is an open-source, deterministic forward simulator for
|
||||
<b>nitrogen-vacancy diamond magnetometry</b> — a real Rust crate compiled
|
||||
to WebAssembly and running in your browser, right now.</p>
|
||||
<p style="font-size:13px; color:var(--ink-2); line-height:1.55;">
|
||||
This 60-second tour walks you through the four panels, the App Store,
|
||||
the Ghost Murmur research view, and the determinism contract that
|
||||
makes nvsim distinctive.</p>
|
||||
<p style="font-size:11.5px; color:var(--ink-3); line-height:1.5; margin-top:14px;">
|
||||
Press <kbd>Esc</kbd> any time to skip. You can replay this tour from
|
||||
<b>Settings → Help</b>.</p>`,
|
||||
cta: { label: 'Start the tour →' },
|
||||
},
|
||||
{
|
||||
icon: '🌐',
|
||||
title: 'The Scene canvas',
|
||||
body: `<p>The middle panel shows your <b>magnetic scene</b> — a small simulated
|
||||
environment with four sources and one NV-diamond sensor at the centre.</p>
|
||||
<p>The four amber/cyan/magenta blobs are draggable: <b>rebar coil</b>
|
||||
(steel χ=5000), <b>heart proxy</b> dipole, <b>60 Hz mains</b> current loop,
|
||||
and a <b>steel door</b> (eddy current). Field lines connect each source
|
||||
to the sensor and animate while the pipeline runs.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Top-left toolbar: zoom in/out, fit-to-view, layer toggles. Bottom-right:
|
||||
sim controls (step / play / step / speed cycle). Drag positions persist
|
||||
across reloads.</p>`,
|
||||
hint: 'Try dragging the heart_proxy after the tour ends.',
|
||||
},
|
||||
{
|
||||
icon: '▶',
|
||||
title: 'Run the pipeline',
|
||||
body: `<p>Press <b>▶ Run</b> in the topbar (or hit <kbd>Space</kbd>) to start
|
||||
the live frame stream. nvsim runs at ~1.8 kHz on x86_64 WASM —
|
||||
well above the 1 kHz Cortex-A53 acceptance gate.</p>
|
||||
<p>The FPS pill in the topbar updates with the throughput. The B-vector
|
||||
trace and frame-stream sparkline in the right inspector update in real
|
||||
time.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
<kbd>Space</kbd> toggles run/pause from anywhere. Reset (<kbd>⌘R</kbd>)
|
||||
rewinds <code>t</code> to 0 without changing the seed.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Inspector — three tabs, three depths',
|
||||
body: `<p>The right rail shows the live inspector: <b>Signal</b> (B-vector
|
||||
trace + frame-stream sparkline), <b>Frame</b> (decoded MagFrame fields +
|
||||
raw 60-byte hex dump), <b>Witness</b> (SHA-256 determinism gate).</p>
|
||||
<p>Click the <b>magnifier</b> icon in the left rail to expand the
|
||||
inspector to the full main area, with bigger charts and an explainer
|
||||
header. Click the <b>shield</b> icon to do the same focused on Witness.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Number keys <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> jump between the
|
||||
three inspector tabs from anywhere.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'The witness — what makes nvsim distinctive',
|
||||
body: `<p>nvsim's defining commitment: same <code>(scene, config, seed)</code> →
|
||||
byte-identical SHA-256 across runs, machines, and transports.</p>
|
||||
<p>Click the <b>Witness</b> tab and press <b>Verify witness</b>. The
|
||||
dashboard re-derives the hash for the canonical reference scene
|
||||
(<code>seed=42, N=256</code>) and asserts it matches the constant
|
||||
pinned at compile time
|
||||
(<code style="font-size:10.5px;">cc8de9b01b0ff5bd…</code>).</p>
|
||||
<p>A green check means every constant — γ_e, D_GS, μ₀, T₂*, contrast,
|
||||
the PRNG stream, the frame layout — is byte-identical to the published
|
||||
reference. A red ✗ means something drifted; the dashboard names which.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🎚',
|
||||
title: 'Tunables — change the simulation live',
|
||||
body: `<p>The left sidebar's <b>Tunables</b> panel has four sliders:</p>
|
||||
<ul style="margin:0 0 12px; padding-left:18px; font-size:13px; color:var(--ink-2); line-height:1.6;">
|
||||
<li><b>Sample rate</b> (1–100 kHz) — digitiser frame rate</li>
|
||||
<li><b>Lock-in f_mod</b> (0.1–5 kHz) — microwave modulation freq</li>
|
||||
<li><b>Integration t</b> (0.1–10 ms) — per-sample integration time</li>
|
||||
<li><b>Shot noise</b> (on/off) — toggle quantum noise</li>
|
||||
</ul>
|
||||
<p>Edits debounce 300 ms then rebuild the WASM pipeline without restarting
|
||||
the frame stream. Watch the noise floor and B-vector spread change
|
||||
in the Signal trace.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '👻',
|
||||
title: 'Ghost Murmur — research view',
|
||||
body: `<p>Click the ghost icon in the left rail. This view audits the
|
||||
publicly-reported <b>April 2026 CIA Ghost Murmur</b> NV-diamond
|
||||
heartbeat-detection program against the open physics literature.</p>
|
||||
<p>Includes a <b>"Try it yourself"</b> sandbox: place a cardiac dipole at
|
||||
any distance from the sensor, hit Run, and see what the real nvsim
|
||||
pipeline recovers. Per-tier detectability bars compare the predicted
|
||||
signal vs each transport's noise floor (NV-ensemble lab, COTS DNV-B1,
|
||||
SQUID, 60 GHz mmWave, WiFi CSI).</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Spoiler: at 1 km the cardiac MCG is ~10⁻¹² of its 10 cm value.
|
||||
Press claims of 40-mile detection sit far below any published instrument's
|
||||
floor.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🛍',
|
||||
title: 'App Store — 65 edge apps',
|
||||
body: `<p>Click the grid icon. The <b>App Store</b> catalogues every
|
||||
hot-loadable WASM edge module RuView ships, organised by category:
|
||||
medical, security, smart-building, retail, industrial, signal,
|
||||
learning, autonomy, exotic.</p>
|
||||
<p>Each card carries id / category / status / event IDs / compute budget /
|
||||
ADR back-reference. The toggle marks an app active in this session;
|
||||
the WS transport (when configured) pushes the activation set to a
|
||||
connected ESP32 mesh.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Try searching for "ghost", "heart", or "occupancy" to fuzzy-filter
|
||||
the catalogue.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '⌨',
|
||||
title: 'Console + REPL',
|
||||
body: `<p>The bottom panel is a structured event log with five filter tabs
|
||||
(<b>all / info / warn / err / dbg</b>) plus a REPL prompt.</p>
|
||||
<p>REPL commands include
|
||||
<code>help</code>, <code>scene.list</code>, <code>sensor.config</code>,
|
||||
<code>run</code>, <code>pause</code>, <code>seed [hex]</code>,
|
||||
<code>proof.verify</code>, <code>proof.export</code>,
|
||||
<code>theme [light|dark]</code>, <code>status</code>, <code>clear</code>.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Press <kbd>/</kbd> to focus the REPL from anywhere. Arrow ↑/↓ recall
|
||||
history (persisted across reloads). <kbd>⌘K</kbd> opens the command
|
||||
palette with every action discoverable.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'You are ready',
|
||||
body: `<p style="font-size:14px;">That's the whole tour. A few last pointers:</p>
|
||||
<ul style="margin:0 0 14px; padding-left:18px; font-size:13px; color:var(--ink-2); line-height:1.7;">
|
||||
<li>Press <kbd>?</kbd> any time to open the help center
|
||||
(Quickstart / Glossary / FAQ / Shortcuts / About).</li>
|
||||
<li>Press <kbd>⌘K</kbd> for the command palette.</li>
|
||||
<li>Press <kbd>\`</kbd> to toggle the debug HUD.</li>
|
||||
<li>Settings (<kbd>⌘,</kbd>) lets you switch theme, density, motion,
|
||||
transport, and replay this tour.</li>
|
||||
</ul>
|
||||
<p style="font-size:12.5px; color:var(--ink-3); line-height:1.55;">
|
||||
Source: <code>github.com/ruvnet/RuView</code> · Apache-2.0 OR MIT ·
|
||||
ADRs 089/090/091/092/093.</p>`,
|
||||
cta: { label: 'Get started →' },
|
||||
},
|
||||
];
|
||||
|
||||
@customElement('nv-onboarding')
|
||||
export class NvOnboarding extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private step = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 240;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.card {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
width: min(640px, 94vw);
|
||||
max-height: 86vh;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||||
overflow: hidden;
|
||||
}
|
||||
:host([open]) .card { transform: translateY(0) scale(1); }
|
||||
.h {
|
||||
padding: 22px 26px 12px;
|
||||
display: flex; align-items: flex-start; gap: 14px;
|
||||
}
|
||||
.h .icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
|
||||
display: grid; place-items: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px -2px oklch(0.55 0.16 30 / 0.35);
|
||||
}
|
||||
.h .title-wrap { flex: 1; min-width: 0; }
|
||||
.h h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.h .step-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-3);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.h .skip {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.h .skip:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.body {
|
||||
padding: 0 26px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.body p { margin: 0 0 12px; }
|
||||
.body p:last-child { margin-bottom: 0; }
|
||||
.body code, .body kbd {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.body code { color: var(--accent); }
|
||||
.body kbd { color: var(--ink); }
|
||||
.hint {
|
||||
margin: 14px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: oklch(0.78 0.12 195 / 0.06);
|
||||
border: 1px solid oklch(0.78 0.12 195 / 0.25);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--accent-2);
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
}
|
||||
.hint::before {
|
||||
content: '💡';
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.footer {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.progress { flex: 1; }
|
||||
.dots { display: flex; gap: 5px; margin-bottom: 4px; }
|
||||
.dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.dot.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.dot.done {
|
||||
background: var(--accent-4);
|
||||
border-color: var(--accent-4);
|
||||
}
|
||||
.progress-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
button.primary, button.ghost {
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
button.ghost:hover { border-color: var(--line-2); }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1a0f00;
|
||||
}
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
`;
|
||||
|
||||
override async connectedCallback(): Promise<void> {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-show-tour', this.show as EventListener);
|
||||
const seen = await kvGet<boolean>('onboarding-seen');
|
||||
if (!seen) {
|
||||
this.open = true;
|
||||
this.setAttribute('open', '');
|
||||
}
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-show-tour', this.show as EventListener);
|
||||
}
|
||||
|
||||
private show = (): void => {
|
||||
this.step = 0;
|
||||
this.open = true;
|
||||
this.setAttribute('open', '');
|
||||
};
|
||||
|
||||
private async dismiss(): Promise<void> {
|
||||
this.open = false;
|
||||
this.removeAttribute('open');
|
||||
await kvSet('onboarding-seen', true);
|
||||
}
|
||||
|
||||
private next(): void {
|
||||
const s = STEPS[this.step];
|
||||
s.cta?.run?.();
|
||||
if (this.step < STEPS.length - 1) this.step++;
|
||||
else void this.dismiss();
|
||||
}
|
||||
|
||||
private prev(): void {
|
||||
if (this.step > 0) this.step--;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const s = STEPS[this.step];
|
||||
const isLast = this.step === STEPS.length - 1;
|
||||
return html`
|
||||
<div class="card" role="dialog" aria-modal="true" aria-label="Welcome tour">
|
||||
<div class="h">
|
||||
<div class="icon" aria-hidden="true">${s.icon}</div>
|
||||
<div class="title-wrap">
|
||||
<h2>${s.title}</h2>
|
||||
<div class="step-label">Step ${this.step + 1} of ${STEPS.length}</div>
|
||||
</div>
|
||||
<button class="skip" @click=${() => this.dismiss()} aria-label="Skip tour" title="Skip tour">×</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div .innerHTML=${s.body}></div>
|
||||
${s.hint ? html`<div class="hint">${s.hint}</div>` : ''}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="progress">
|
||||
<div class="dots">
|
||||
${STEPS.map((_, i) => html`
|
||||
<div class="dot ${i === this.step ? 'active' : i < this.step ? 'done' : ''}"></div>
|
||||
`)}
|
||||
</div>
|
||||
<div class="progress-label">${this.step + 1} / ${STEPS.length}</div>
|
||||
</div>
|
||||
${this.step > 0
|
||||
? html`<button class="ghost" @click=${() => this.prev()}>← Back</button>`
|
||||
: html`<button class="ghost" @click=${() => this.dismiss()}>Skip</button>`}
|
||||
<button class="primary" @click=${() => this.next()}>
|
||||
${s.cta?.label ?? (isLast ? 'Done' : 'Next →')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/* Command palette ⌘K. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import { toast } from './nv-toast';
|
||||
import { openModal } from './nv-modal';
|
||||
import {
|
||||
getClient, theme, expectedWitness, witnessHex, witnessVerified, pushLog, running,
|
||||
} from '../store/appStore';
|
||||
|
||||
interface Cmd { ico: string; label: string; kbd?: string; run: () => void; }
|
||||
|
||||
@customElement('nv-palette')
|
||||
export class NvPalette extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private filter = '';
|
||||
@state() private idx = 0;
|
||||
@query('#palette-input') private inputEl!: HTMLInputElement;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0; z-index: 220;
|
||||
background: rgba(0,0,0,0.5);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
display: flex; justify-content: center; padding-top: 12vh;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.palette {
|
||||
width: min(560px, 92vw);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
max-height: 60vh;
|
||||
}
|
||||
.input {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
background: transparent; border: none; outline: none;
|
||||
color: var(--ink); font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.list { flex: 1; overflow-y: auto; padding: 4px; }
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.item.active { background: var(--bg-3); }
|
||||
.item .ico { width: 20px; text-align: center; color: var(--accent); }
|
||||
.item .lbl { flex: 1; }
|
||||
.item .kbd {
|
||||
font-family: var(--mono); font-size: 10.5px;
|
||||
color: var(--ink-3);
|
||||
padding: 1px 5px; background: var(--bg-3); border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
private cmds: Cmd[] = [
|
||||
{ ico: '▶', label: 'Run pipeline', kbd: 'Space', run: async () => { await getClient()?.run(); running.value = true; toast('Pipeline running', '▶'); } },
|
||||
{ ico: '❚', label: 'Pause pipeline', run: async () => { await getClient()?.pause(); running.value = false; toast('Paused', '❚❚'); } },
|
||||
{ ico: '+', label: 'New scene…', kbd: '⌘N', run: () => openModal({
|
||||
title: 'New scene',
|
||||
body: `<p>Build a fresh magnetic scene. The dashboard generates the JSON
|
||||
and pushes it to the running pipeline (or you can copy the JSON
|
||||
for offline use).</p>
|
||||
<label>Name</label>
|
||||
<input type="text" id="ns-name" value="custom-scene-${Date.now().toString(36)}" />
|
||||
<label>Heart-proxy dipole moment (A·m²)</label>
|
||||
<input type="text" id="ns-moment" value="1.0e-6" />
|
||||
<label>Distance heart → sensor (m)</label>
|
||||
<input type="text" id="ns-distance" value="0.5" />
|
||||
<label>Add ferrous distractor at +x = 1 m?</label>
|
||||
<select id="ns-ferrous">
|
||||
<option value="0">No</option>
|
||||
<option value="1" selected>Yes (steel coil, χ=5000)</option>
|
||||
</select>
|
||||
<label>Add 60 Hz mains-current loop?</label>
|
||||
<select id="ns-mains">
|
||||
<option value="0">No</option>
|
||||
<option value="1" selected>Yes (2 A loop, 5 cm radius, +y = 1 m)</option>
|
||||
</select>`,
|
||||
buttons: [
|
||||
{ label: 'Cancel', variant: 'ghost' },
|
||||
{ label: 'Create', variant: 'primary', onClick: async () => {
|
||||
const root = document.querySelector('nv-app')?.shadowRoot?.querySelector('nv-modal')?.shadowRoot;
|
||||
if (!root) return;
|
||||
const name = (root.querySelector<HTMLInputElement>('#ns-name')?.value ?? 'custom').trim();
|
||||
const m = parseFloat(root.querySelector<HTMLInputElement>('#ns-moment')?.value ?? '1e-6');
|
||||
const d = parseFloat(root.querySelector<HTMLInputElement>('#ns-distance')?.value ?? '0.5');
|
||||
const ferr = root.querySelector<HTMLSelectElement>('#ns-ferrous')?.value === '1';
|
||||
const mains = root.querySelector<HTMLSelectElement>('#ns-mains')?.value === '1';
|
||||
const scene = {
|
||||
dipoles: [{ position: [0, 0, d] as [number, number, number], moment: [0, 0, m] as [number, number, number] }],
|
||||
loops: mains ? [{
|
||||
centre: [0, 1, 0] as [number, number, number],
|
||||
normal: [0, 1, 0] as [number, number, number],
|
||||
radius: 0.05, current: 2.0, n_segments: 64,
|
||||
}] : [],
|
||||
ferrous: ferr ? [{ position: [1, 0, 0] as [number, number, number], volume: 1e-4, susceptibility: 5000 }] : [],
|
||||
eddy: [],
|
||||
sensors: [[0, 0, 0] as [number, number, number]],
|
||||
ambient_field: [1e-6, 0, 0] as [number, number, number],
|
||||
};
|
||||
await getClient()?.loadScene(scene);
|
||||
pushLog('ok', `scene <span class="s">${name}</span> loaded · 1 dipole · ${mains ? '1 loop · ' : ''}${ferr ? '1 ferrous · ' : ''}1 sensor`);
|
||||
toast(`Scene "${name}" loaded`, '+');
|
||||
} },
|
||||
],
|
||||
}) },
|
||||
{ ico: '📦', label: 'Export proof bundle…', kbd: '⌘E', run: async () => {
|
||||
const c = getClient(); if (!c) return;
|
||||
pushLog('dbg', 'building proof bundle…');
|
||||
try {
|
||||
const blob = await c.exportProofBundle();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nvsim-proof-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
pushLog('ok', `proof bundle exported · ${blob.size} bytes`);
|
||||
toast(`Proof bundle saved (${blob.size} B)`, '📦');
|
||||
} catch (e) { pushLog('err', `export failed: ${(e as Error).message}`); }
|
||||
} },
|
||||
{ ico: '⟳', label: 'Reset pipeline', kbd: '⌘R', run: () => openModal({
|
||||
title: 'Reset pipeline?',
|
||||
body: '<p>Clears the frame stream and rewinds <code>t</code> to 0.</p>',
|
||||
buttons: [
|
||||
{ label: 'Cancel', variant: 'ghost' },
|
||||
{ label: 'Reset', variant: 'danger', onClick: async () => { await getClient()?.reset(); pushLog('warn', 'pipeline reset · t=0'); toast('Pipeline reset', '⟳'); } },
|
||||
],
|
||||
}) },
|
||||
{ ico: '✓', label: 'Verify witness', run: async () => {
|
||||
const c = getClient(); if (!c) return;
|
||||
witnessVerified.value = 'pending';
|
||||
const exp = expectedWitness.value;
|
||||
const eb = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) eb[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(eb);
|
||||
if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; toast('Witness verified', '✓'); }
|
||||
else { witnessVerified.value = 'fail'; toast('Witness mismatch!', '✗'); }
|
||||
} },
|
||||
{ ico: '☼', label: 'Toggle theme', kbd: '⌘/', run: () => { theme.value = theme.value === 'dark' ? 'light' : 'dark'; } },
|
||||
{ ico: '⚙', label: 'Open settings', kbd: '⌘,', run: () => window.dispatchEvent(new CustomEvent('open-settings')) },
|
||||
{ ico: '?', label: 'Keyboard shortcuts…', run: () => openModal({
|
||||
title: 'Keyboard shortcuts',
|
||||
body: `<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px;">
|
||||
<div><code>⌘K / Ctrl K</code></div><div>Command palette</div>
|
||||
<div><code>Space</code></div><div>Play / pause</div>
|
||||
<div><code>⌘R</code></div><div>Reset</div>
|
||||
<div><code>⌘,</code></div><div>Settings</div>
|
||||
<div><code>⌘/</code></div><div>Toggle theme</div>
|
||||
<div><code>\`</code></div><div>Debug HUD</div>
|
||||
<div><code>1 · 2 · 3</code></div><div>Inspector tabs</div>
|
||||
<div><code>Esc</code></div><div>Close modal/palette</div>
|
||||
<div><code>/</code></div><div>Focus REPL</div>
|
||||
</div>`,
|
||||
buttons: [{ label: 'Close', variant: 'primary' }],
|
||||
}) },
|
||||
{ ico: 'i', label: 'About nvsim…', run: () => openModal({
|
||||
title: 'About nvsim',
|
||||
body: `<p><b>nvsim</b> is a deterministic, byte-reproducible forward simulator for nitrogen-vacancy diamond magnetometry.</p>
|
||||
<p>This dashboard runs nvsim as WASM in a Web Worker. Same <code>(scene, config, seed)</code> → byte-identical SHA-256 witness across runs and machines.</p>
|
||||
<p>License: MIT OR Apache-2.0 · See ADR-089, ADR-092.</p>`,
|
||||
buttons: [{ label: 'Close', variant: 'primary' }],
|
||||
}) },
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
window.addEventListener('nv-palette', this.onOpen as EventListener);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
window.removeEventListener('nv-palette', this.onOpen as EventListener);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
this.openPal();
|
||||
} else if (e.key === 'Escape' && this.open) {
|
||||
this.closePal();
|
||||
} else if (this.open) {
|
||||
if (e.key === 'ArrowDown') { this.idx = Math.min(this.cmds.length - 1, this.idx + 1); e.preventDefault(); }
|
||||
else if (e.key === 'ArrowUp') { this.idx = Math.max(0, this.idx - 1); e.preventDefault(); }
|
||||
else if (e.key === 'Enter') { this.runIdx(); e.preventDefault(); }
|
||||
}
|
||||
};
|
||||
|
||||
private onOpen = (): void => this.openPal();
|
||||
|
||||
private openPal(): void {
|
||||
this.open = true; this.setAttribute('open', '');
|
||||
this.filter = ''; this.idx = 0;
|
||||
setTimeout(() => this.inputEl?.focus(), 0);
|
||||
}
|
||||
private closePal(): void { this.open = false; this.removeAttribute('open'); }
|
||||
|
||||
private filtered(): Cmd[] {
|
||||
if (!this.filter.trim()) return this.cmds;
|
||||
const q = this.filter.toLowerCase();
|
||||
return this.cmds.filter((c) => c.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
private runIdx(): void {
|
||||
const f = this.filtered();
|
||||
const c = f[this.idx];
|
||||
if (c) { c.run(); this.closePal(); }
|
||||
}
|
||||
|
||||
override render() {
|
||||
const items = this.filtered();
|
||||
return html`
|
||||
<div class="palette" data-id="palette">
|
||||
<div class="input">
|
||||
<input id="palette-input" type="text" placeholder="Type a command…"
|
||||
.value=${this.filter}
|
||||
@input=${(e: Event) => { this.filter = (e.target as HTMLInputElement).value; this.idx = 0; }} />
|
||||
</div>
|
||||
<div class="list">
|
||||
${items.map((c, i) => html`
|
||||
<div class="item ${i === this.idx ? 'active' : ''}" @click=${() => { this.idx = i; this.runIdx(); }}>
|
||||
<span class="ico">${c.ico}</span>
|
||||
<span class="lbl">${c.label}</span>
|
||||
${c.kbd ? html`<span class="kbd">${c.kbd}</span>` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/* Left rail navigation. Emits `navigate` events for view switching. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { View } from './nv-app';
|
||||
|
||||
@customElement('nv-rail')
|
||||
export class NvRail extends LitElement {
|
||||
@property() view: View = 'scene';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
gap: 4px;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.logo {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
|
||||
display: grid; place-items: center;
|
||||
color: #1a0f00;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 4px 12px -2px oklch(0.55 0.16 30 / 0.35);
|
||||
}
|
||||
.btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--ink-3);
|
||||
display: grid; place-items: center;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { color: var(--ink); background: var(--bg-2); }
|
||||
.btn.active {
|
||||
color: var(--ink);
|
||||
background: var(--bg-3);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.active::before {
|
||||
content: ''; position: absolute; left: -10px; top: 8px; bottom: 8px;
|
||||
width: 2px; background: var(--accent); border-radius: 2px;
|
||||
}
|
||||
.btn.ghost.active::before { background: var(--accent-3); }
|
||||
.spacer { flex: 1; }
|
||||
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; }
|
||||
`;
|
||||
|
||||
private navigate(v: View): void {
|
||||
this.dispatchEvent(new CustomEvent('navigate', { detail: v }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="logo" aria-hidden="true">NV</div>
|
||||
<nav role="navigation" aria-label="Primary"
|
||||
style="display:flex; flex-direction:column; align-items:center; gap:4px; flex:1;">
|
||||
<button class="btn ${this.view === 'home' ? 'active' : ''}"
|
||||
data-id="home-btn" title="Home" aria-label="Home"
|
||||
aria-current=${this.view === 'home' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('home')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12L12 4l9 8M5 10v10h14V10"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'scene' ? 'active' : ''}"
|
||||
data-id="scene-btn" title="Scene" aria-label="Scene"
|
||||
aria-current=${this.view === 'scene' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('scene')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2L3 7l9 5 9-5-9-5zm0 13l-9-5v6l9 5 9-5v-6l-9 5z"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'apps' ? 'active' : ''}"
|
||||
data-id="apps-btn" title="App Store" aria-label="App Store"
|
||||
aria-current=${this.view === 'apps' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('apps')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'inspector' ? 'active' : ''}"
|
||||
data-id="inspector-btn" title="Inspector" aria-label="Inspector"
|
||||
aria-current=${this.view === 'inspector' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('inspector')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'witness' ? 'active' : ''}"
|
||||
data-id="witness-btn" title="Witness" aria-label="Witness"
|
||||
aria-current=${this.view === 'witness' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('witness')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 12l2 2 4-4M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"/></svg>
|
||||
</button>
|
||||
<button class="btn ghost ${this.view === 'ghost-murmur' ? 'active' : ''}"
|
||||
data-id="ghost-murmur-btn" title="Ghost Murmur — research spec"
|
||||
aria-label="Ghost Murmur research"
|
||||
aria-current=${this.view === 'ghost-murmur' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('ghost-murmur')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 2C5.7 2 3 4.7 3 8v12l3-2 3 2 3-2 3 2 3-2 3 2V8c0-3.3-2.7-6-6-6H9z"/>
|
||||
<circle cx="9" cy="10" r="1.2" fill="currentColor"/>
|
||||
<circle cx="15" cy="10" r="1.2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-id="settings-btn" title="Settings" aria-label="Settings"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent('open-settings', { bubbles: true, composed: true }))}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06A1.65 1.65 0 0015 19.4a1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
/* Scene canvas — SVG with draggable sources, NV crystal sensor, field lines, mini ODMR. */
|
||||
import { LitElement, html, css, svg } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { lastB, bMag, fps, snr, motionReduced, running, getClient, speed, pushLog, lastFrame, scenePositions } from '../store/appStore';
|
||||
|
||||
interface SceneItem { id: string; x: number; y: number; color: string; name: string; }
|
||||
|
||||
@customElement('nv-scene')
|
||||
export class NvScene extends LitElement {
|
||||
@state() private zoom = 1.0;
|
||||
@state() private layerVisible = { source: true, field: true, label: true };
|
||||
@state() private items: SceneItem[] = [
|
||||
{ id: 'rebar', x: 740, y: 240, color: 'oklch(0.72 0.18 330)', name: 'rebar.steel' },
|
||||
{ id: 'heart', x: 220, y: 180, color: 'oklch(0.78 0.14 195)', name: 'heart_proxy' },
|
||||
{ id: 'mains', x: 180, y: 380, color: 'oklch(0.72 0.18 330)', name: 'mains_60Hz' },
|
||||
{ id: 'door', x: 800, y: 470, color: 'oklch(0.78 0.14 145)', name: 'door.steel' },
|
||||
];
|
||||
@state() private dragging: string | null = null;
|
||||
@state() private selected: string | null = null;
|
||||
private dragOffset = { dx: 0, dy: 0 };
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block; height: 100%; width: 100%;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
position: relative; overflow: hidden;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.grid {
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
pointer-events: none;
|
||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 100%);
|
||||
}
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.stat-card {
|
||||
background: rgba(13,17,23,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
min-width: 96px;
|
||||
}
|
||||
[data-theme="light"] .stat-card { background: rgba(255,255,255,0.85); }
|
||||
.stat-card .lbl {
|
||||
color: var(--ink-3);
|
||||
text-transform: uppercase; font-weight: 600; letter-spacing: 0.06em; font-size: 9.5px;
|
||||
}
|
||||
.stat-card .val { font-family: var(--mono); font-size: 16px; font-weight: 600; margin-top: 2px; }
|
||||
.stat-card .val.amber { color: var(--accent); }
|
||||
.stat-card .val.cyan { color: var(--accent-2); }
|
||||
.stat-card .val.mint { color: var(--accent-4); }
|
||||
.scene-readout {
|
||||
position: absolute; top: 14px; right: 14px;
|
||||
display: flex; gap: 8px; z-index: 5;
|
||||
}
|
||||
.draggable { cursor: grab; transition: filter 0.15s; }
|
||||
.draggable:hover { filter: brightness(1.15) drop-shadow(0 0 6px currentColor); }
|
||||
.draggable.dragging { cursor: grabbing; filter: brightness(1.25) drop-shadow(0 0 10px currentColor); }
|
||||
.field-line { stroke-dasharray: 4 6; }
|
||||
@keyframes dash { to { stroke-dashoffset: -200; } }
|
||||
.field-line.anim { animation: dash 4s linear infinite; }
|
||||
@keyframes spin {
|
||||
0% { transform: rotateY(0) rotateX(8deg); }
|
||||
100% { transform: rotateY(360deg) rotateX(8deg); }
|
||||
}
|
||||
.crystal { transform-origin: center; transform-box: fill-box; }
|
||||
.crystal.anim { animation: spin 12s linear infinite; }
|
||||
.label {
|
||||
font-family: var(--mono); font-size: 11px; fill: var(--ink-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.scene-toolbar {
|
||||
position: absolute; top: 14px; left: 14px;
|
||||
display: flex; gap: 6px; z-index: 5;
|
||||
background: rgba(13,17,23,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
[data-theme="light"] .scene-toolbar { background: rgba(255,255,255,0.85); }
|
||||
.scene-toolbar button {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.scene-toolbar button:hover { color: var(--ink); background: var(--bg-2); }
|
||||
.scene-toolbar button.on { background: var(--bg-3); color: var(--accent); border-color: var(--line-2); }
|
||||
|
||||
.sim-controls {
|
||||
position: absolute; bottom: 14px; right: 14px;
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
background: rgba(13,17,23,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
[data-theme="light"] .sim-controls { background: rgba(255,255,255,0.92); }
|
||||
.sim-controls .play {
|
||||
width: 32px; height: 32px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #1a0f00;
|
||||
cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sim-controls .play:hover { filter: brightness(1.08); }
|
||||
.sim-controls .step {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sim-controls .step:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.sim-controls .speed {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
padding: 0 6px;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Restore drag positions if any are persisted.
|
||||
if (scenePositions.value.length > 0) {
|
||||
this.items = this.items.map((it) => {
|
||||
const saved = scenePositions.value.find((p) => p.id === it.id);
|
||||
return saved ? { ...it, x: saved.x, y: saved.y } : it;
|
||||
});
|
||||
}
|
||||
effect(() => {
|
||||
lastB.value; bMag.value; fps.value; snr.value; motionReduced.value;
|
||||
running.value; speed.value; lastFrame.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
// Compute SNR from the last frame: |B_pT| / max(σ_pT[k]) per ADR-093 P1.4.
|
||||
effect(() => {
|
||||
const f = lastFrame.value;
|
||||
if (!f) return;
|
||||
const bmag = Math.sqrt(f.bPt[0] ** 2 + f.bPt[1] ** 2 + f.bPt[2] ** 2);
|
||||
const sigmaMax = Math.max(Math.abs(f.sigmaPt[0]), Math.abs(f.sigmaPt[1]), Math.abs(f.sigmaPt[2]), 0.001);
|
||||
const snrVal = bmag / sigmaMax;
|
||||
if (Number.isFinite(snrVal)) snr.value = snrVal;
|
||||
});
|
||||
window.addEventListener('pointermove', this.onPointerMove);
|
||||
window.addEventListener('pointerup', this.onPointerUp);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
/** Tab cycles selection; arrow keys nudge by 8 px (32 px with Shift);
|
||||
* Esc deselects. ADR-093 P2.6. */
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
if (!this.selected) {
|
||||
if (e.key === 'Tab' && document.activeElement === document.body) {
|
||||
e.preventDefault();
|
||||
this.selected = this.items[0]?.id ?? null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 32 : 8;
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
this.items = this.items.map((it) =>
|
||||
it.id === this.selected
|
||||
? { ...it, x: Math.max(20, Math.min(980, it.x + dx)), y: Math.max(20, Math.min(580, it.y + dy)) }
|
||||
: it,
|
||||
);
|
||||
scenePositions.value = this.items.map(({ id, x, y }) => ({ id, x, y }));
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const idx = this.items.findIndex((it) => it.id === this.selected);
|
||||
const next = (idx + (e.shiftKey ? -1 : 1) + this.items.length) % this.items.length;
|
||||
this.selected = this.items[next].id;
|
||||
} else if (e.key === 'Escape') {
|
||||
this.selected = null;
|
||||
}
|
||||
};
|
||||
|
||||
private async toggleRun(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
if (running.value) { await c.pause(); running.value = false; }
|
||||
else { await c.run(); running.value = true; }
|
||||
}
|
||||
private async stepFwd(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
await c.step('fwd', 10);
|
||||
pushLog('dbg', 'sim step → +1 frame');
|
||||
}
|
||||
private async stepBack(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
await c.step('back', 10);
|
||||
pushLog('dbg', 'sim step ← -1 frame');
|
||||
}
|
||||
private cycleSpeed(): void {
|
||||
const speeds = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
const idx = speeds.indexOf(speed.value);
|
||||
speed.value = speeds[(idx + 1) % speeds.length];
|
||||
}
|
||||
private zoomIn(): void { this.zoom = Math.min(2.5, this.zoom * 1.2); }
|
||||
private zoomOut(): void { this.zoom = Math.max(0.5, this.zoom / 1.2); }
|
||||
private fitView(): void { this.zoom = 1.0; }
|
||||
private toggleLayer(k: 'source' | 'field' | 'label'): void {
|
||||
this.layerVisible = { ...this.layerVisible, [k]: !this.layerVisible[k] };
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('pointermove', this.onPointerMove);
|
||||
window.removeEventListener('pointerup', this.onPointerUp);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
private onDown = (id: string, e: PointerEvent): void => {
|
||||
e.preventDefault();
|
||||
this.dragging = id;
|
||||
this.selected = id;
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
|
||||
if (!svgEl) return;
|
||||
const pt = this.toSvg(e, svgEl);
|
||||
this.dragOffset = { dx: pt.x - item.x, dy: pt.y - item.y };
|
||||
};
|
||||
|
||||
private onPointerMove = (e: PointerEvent): void => {
|
||||
if (!this.dragging) return;
|
||||
const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
|
||||
if (!svgEl) return;
|
||||
const pt = this.toSvg(e, svgEl);
|
||||
this.items = this.items.map((it) =>
|
||||
it.id === this.dragging
|
||||
? { ...it, x: pt.x - this.dragOffset.dx, y: pt.y - this.dragOffset.dy }
|
||||
: it,
|
||||
);
|
||||
};
|
||||
|
||||
private onPointerUp = (): void => {
|
||||
if (this.dragging) {
|
||||
// Persist all positions on drop.
|
||||
scenePositions.value = this.items.map(({ id, x, y }) => ({ id, x, y }));
|
||||
}
|
||||
this.dragging = null;
|
||||
};
|
||||
|
||||
private toSvg(e: PointerEvent, svgEl: SVGSVGElement): { x: number; y: number } {
|
||||
const r = svgEl.getBoundingClientRect();
|
||||
const vbX = ((e.clientX - r.left) / r.width) * 1000;
|
||||
const vbY = ((e.clientY - r.top) / r.height) * 600;
|
||||
return { x: vbX, y: vbY };
|
||||
}
|
||||
|
||||
override render() {
|
||||
const b = lastB.value;
|
||||
const bnT = [b[0] * 1e9, b[1] * 1e9, b[2] * 1e9];
|
||||
const bMagNT = bMag.value * 1e9;
|
||||
const animClass = motionReduced.value ? '' : 'anim';
|
||||
|
||||
const vbW = 1000 / this.zoom;
|
||||
const vbH = 600 / this.zoom;
|
||||
const vbX = (1000 - vbW) / 2;
|
||||
const vbY = (600 - vbH) / 2;
|
||||
|
||||
return html`
|
||||
<div class="grid"></div>
|
||||
<svg viewBox="${vbX.toFixed(1)} ${vbY.toFixed(1)} ${vbW.toFixed(1)} ${vbH.toFixed(1)}"
|
||||
preserveAspectRatio="xMidYMid meet" id="scene-svg">
|
||||
<defs>
|
||||
<radialGradient id="g-sensor" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0" stop-color="oklch(0.78 0.14 70)" stop-opacity="0.4"/>
|
||||
<stop offset="1" stop-color="oklch(0.78 0.14 70)" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="glow"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Field lines from each source to sensor -->
|
||||
${this.layerVisible.field ? this.items.map((it) => svg`
|
||||
<line class="field-line ${animClass}" x1=${it.x} y1=${it.y}
|
||||
x2="500" y2="320"
|
||||
stroke=${it.color} stroke-width="1" stroke-opacity="0.5"/>
|
||||
`) : ''}
|
||||
|
||||
<!-- Source primitives -->
|
||||
${this.layerVisible.source ? this.items.map((it) => svg`
|
||||
<g class=${`draggable ${this.dragging === it.id ? 'dragging' : ''} ${this.selected === it.id ? 'selected' : ''}`}
|
||||
data-id=${it.id} data-source-id=${it.id}
|
||||
transform=${`translate(${it.x.toFixed(0)},${it.y.toFixed(0)})`}
|
||||
@pointerdown=${(e: PointerEvent) => this.onDown(it.id, e)}>
|
||||
<ellipse cx="0" cy="0" rx="32" ry="22" fill=${it.color} fill-opacity="0.18"
|
||||
stroke=${it.color} stroke-width="1.2"/>
|
||||
<circle cx="0" cy="0" r="4" fill=${it.color}/>
|
||||
${this.layerVisible.label ? svg`<text class="label" x="0" y="40" text-anchor="middle">${it.name}</text>` : ''}
|
||||
</g>
|
||||
`) : ''}
|
||||
|
||||
<!-- Sensor (NV diamond) at center -->
|
||||
<g id="sensor-g" class="draggable" data-id="sensor" transform="translate(500, 320)">
|
||||
<circle cx="0" cy="0" r="46" fill="url(#g-sensor)"/>
|
||||
<g class=${`crystal ${animClass}`} stroke="oklch(0.78 0.14 70)" stroke-width="2"
|
||||
fill="oklch(0.78 0.14 70 / 0.08)" filter="url(#glow)">
|
||||
<polygon points="0,-22 19,-7 12,18 -12,18 -19,-7"/>
|
||||
</g>
|
||||
<circle cx="0" cy="0" r="3" fill="var(--accent)"/>
|
||||
<text class="label" x="0" y="56" text-anchor="middle">
|
||||
sensor · 〈111〉 NV
|
||||
</text>
|
||||
<text class="label" x="0" y="72" text-anchor="middle">
|
||||
B_in: <tspan fill="var(--accent)" id="b-in-svg">[${bnT[0].toFixed(2)}, ${bnT[1].toFixed(2)}, ${bnT[2].toFixed(2)}] nT</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="scene-toolbar" id="scene-toolbar">
|
||||
<button id="zoom-in-btn" title="Zoom in" @click=${this.zoomIn}>+</button>
|
||||
<button id="zoom-out-btn" title="Zoom out" @click=${this.zoomOut}>−</button>
|
||||
<button id="fit-btn" title="Fit to view" @click=${this.fitView}>⊡</button>
|
||||
<button id="layer-source-btn" class=${this.layerVisible.source ? 'on' : ''}
|
||||
title="Sources" @click=${() => this.toggleLayer('source')}>●</button>
|
||||
<button id="layer-field-btn" class=${this.layerVisible.field ? 'on' : ''}
|
||||
title="Field lines" @click=${() => this.toggleLayer('field')}>≈</button>
|
||||
<button id="layer-label-btn" class=${this.layerVisible.label ? 'on' : ''}
|
||||
title="Labels" @click=${() => this.toggleLayer('label')}>T</button>
|
||||
</div>
|
||||
|
||||
<div class="sim-controls" id="sim-controls">
|
||||
<button class="step" id="step-back-btn" title="Step back" @click=${this.stepBack}>⏮</button>
|
||||
<button class="play" id="play-btn" title="Play / pause" @click=${this.toggleRun}>
|
||||
${running.value ? '❚❚' : '▶'}
|
||||
</button>
|
||||
<button class="step" id="step-fwd-btn" title="Step forward" @click=${this.stepFwd}>⏭</button>
|
||||
<span class="speed" id="speed-val" title="Cycle speed" @click=${this.cycleSpeed}>${speed.value}×</span>
|
||||
</div>
|
||||
|
||||
<div class="scene-readout">
|
||||
<div class="stat-card">
|
||||
<div class="lbl">|B|</div>
|
||||
<div class="val amber" id="bmag-readout">${bMagNT.toFixed(3)} nT</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">FPS</div>
|
||||
<div class="val cyan" id="fps-readout">${fps.value > 0 ? Math.round(fps.value) : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">SNR</div>
|
||||
<div class="val mint" id="snr-readout">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
/* Settings drawer — theme / density / motion / auto-update. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { theme, density, motionReduced, autoUpdate, transport, wsUrl } from '../store/appStore';
|
||||
|
||||
@customElement('nv-settings-drawer')
|
||||
export class NvSettingsDrawer extends LitElement {
|
||||
@state() private open = false;
|
||||
|
||||
static styles = css`
|
||||
/* The host covers the viewport without transforming itself. Only the
|
||||
* inner .panel is transformed; otherwise the host's transform would
|
||||
* create a containing block for the fixed-position scrim, clipping
|
||||
* it to the panel's 420 px width and breaking outside-to-dismiss. */
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
z-index: 51;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
:host([open]) { pointer-events: auto; opacity: 1; }
|
||||
.scrim {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: 420px; max-width: 100vw;
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--line);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: -20px 0 60px -20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
:host([open]) .panel { transform: translateX(0); }
|
||||
.h {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 14px; font-weight: 600; }
|
||||
.body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.group { margin-bottom: 22px; }
|
||||
.group h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.row .lbl { font-size: 13px; }
|
||||
.row .desc { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
|
||||
.row > div:first-child { flex: 1; padding-right: 12px; }
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
.seg button {
|
||||
padding: 4px 10px;
|
||||
background: transparent; border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.on { background: var(--bg-1); color: var(--ink); }
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 36px; height: 20px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ''; position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--ink-3);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle.on::after { background: #1a0f00; transform: translateX(16px); }
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
input[type="text"] {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--ink); font-family: var(--mono); font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { theme.value; density.value; motionReduced.value; autoUpdate.value; transport.value; wsUrl.value; this.requestUpdate(); });
|
||||
window.addEventListener('open-settings', () => { this.open = true; this.setAttribute('open', ''); });
|
||||
}
|
||||
|
||||
private close(): void { this.open = false; this.removeAttribute('open'); }
|
||||
|
||||
private async resetPrefs(): Promise<void> {
|
||||
if (!confirm('Reset all preferences and IndexedDB state? Reloads the page.')) return;
|
||||
try {
|
||||
const dbs = await indexedDB.databases?.();
|
||||
if (dbs) for (const d of dbs) if (d.name) indexedDB.deleteDatabase(d.name);
|
||||
} catch { /* noop */ }
|
||||
location.reload();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="scrim" @click=${() => this.close()}></div>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-label="Settings">
|
||||
<div class="h">
|
||||
<div class="ttl">Settings</div>
|
||||
<button class="close" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="group">
|
||||
<h4>Appearance</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Theme</div>
|
||||
<div class="desc">Dark is the default; light has higher contrast for daylight work.</div>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class=${theme.value === 'dark' ? 'on' : ''}
|
||||
@click=${() => theme.value = 'dark'}>dark</button>
|
||||
<button class=${theme.value === 'light' ? 'on' : ''}
|
||||
@click=${() => theme.value = 'light'}>light</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Density</div>
|
||||
<div class="desc">Affects panel padding and font scale (15 / 14 / 13 px). Choose what your eyes prefer.</div>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class=${density.value === 'comfy' ? 'on' : ''}
|
||||
@click=${() => density.value = 'comfy'}>comfy</button>
|
||||
<button class=${density.value === 'default' ? 'on' : ''}
|
||||
@click=${() => density.value = 'default'}>default</button>
|
||||
<button class=${density.value === 'compact' ? 'on' : ''}
|
||||
@click=${() => density.value = 'compact'}>compact</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Reduce motion</div>
|
||||
<div class="desc">Stops the rotating diamond, animated field lines, and chart easing. Auto-on if your system has the prefers-reduced-motion preference set.</div>
|
||||
</div>
|
||||
<span class="toggle ${motionReduced.value ? 'on' : ''}"
|
||||
role="switch" aria-checked=${motionReduced.value}
|
||||
@click=${() => motionReduced.value = !motionReduced.value}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Pipeline</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Auto-rerun on edit</div>
|
||||
<div class="desc">When you change a Tunables slider or load a new scene, push the change to the worker without a manual restart.</div>
|
||||
</div>
|
||||
<span class="toggle ${autoUpdate.value ? 'on' : ''}"
|
||||
role="switch" aria-checked=${autoUpdate.value}
|
||||
@click=${() => autoUpdate.value = !autoUpdate.value}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Transport</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Mode</div>
|
||||
<div class="desc">WASM runs nvsim in your browser (default, no server). WS connects to a host-supplied nvsim-server (REST + binary WebSocket); see ADR-092 §6.2.</div>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class=${transport.value === 'wasm' ? 'on' : ''}
|
||||
@click=${() => transport.value = 'wasm'}>WASM</button>
|
||||
<button class=${transport.value === 'ws' ? 'on' : ''}
|
||||
@click=${() => transport.value = 'ws'}>WS</button>
|
||||
</div>
|
||||
</div>
|
||||
${transport.value === 'ws' ? html`
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">WS URL</div>
|
||||
<div class="desc">Where your nvsim-server is listening. The server defaults to 127.0.0.1:7878.</div>
|
||||
</div>
|
||||
<input type="text" placeholder="ws://localhost:7878" .value=${wsUrl.value}
|
||||
@input=${(e: Event) => wsUrl.value = (e.target as HTMLInputElement).value} />
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Help</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Open help center</div>
|
||||
<div class="desc">Quickstart, glossary, FAQ, and shortcuts. Press <kbd style="font-family:var(--mono);font-size:10.5px;padding:1px 4px;background:var(--bg-3);border:1px solid var(--line);border-radius:3px;">?</kbd> any time.</div>
|
||||
</div>
|
||||
<button class="seg"
|
||||
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-help')); }}
|
||||
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid var(--line);border-radius:6px;color:var(--ink);">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Replay welcome tour</div>
|
||||
<div class="desc">Re-show the 6-step first-run walkthrough.</div>
|
||||
</div>
|
||||
<button class="seg"
|
||||
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-tour')); }}
|
||||
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid var(--line);border-radius:6px;color:var(--ink);">
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Reset all preferences</div>
|
||||
<div class="desc">Wipe theme, density, motion, scene drag positions, REPL history, and the onboarding-seen flag.</div>
|
||||
</div>
|
||||
<button class="seg"
|
||||
@click=${() => this.resetPrefs()}
|
||||
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid oklch(0.65 0.22 25 / 0.4);border-radius:6px;color:var(--bad);">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>About</h4>
|
||||
<div class="row" style="border-bottom:0;">
|
||||
<div>
|
||||
<div class="lbl">nvsim · v0.3.0</div>
|
||||
<div class="desc">Open-source NV-diamond simulator. Apache-2.0 OR MIT.<br>
|
||||
<a style="color:var(--accent-2); text-decoration:underline dotted; cursor:pointer;"
|
||||
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section: 'about' } })); }}>
|
||||
More info →
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/* Sidebar — Scene panel, NV sensor panel, Tunables, Pipeline diagram. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { fs, fmod, dtMs, noiseEnabled, running, getClient, pushLog } from '../store/appStore';
|
||||
|
||||
let configPushTimer: number | null = null;
|
||||
function pushConfigDebounced(): void {
|
||||
if (configPushTimer !== null) window.clearTimeout(configPushTimer);
|
||||
configPushTimer = window.setTimeout(async () => {
|
||||
const c = getClient();
|
||||
if (!c) return;
|
||||
try {
|
||||
await c.setConfig({
|
||||
digitiser: { f_s_hz: fs.value, f_mod_hz: fmod.value },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: !noiseEnabled.value,
|
||||
},
|
||||
dt_s: dtMs.value * 1e-3,
|
||||
});
|
||||
pushLog('dbg', `config pushed · fs=${fs.value} f_mod=${fmod.value} dt=${dtMs.value.toFixed(1)}ms noise=${noiseEnabled.value ? 'on' : 'off'}`);
|
||||
} catch (e) {
|
||||
pushLog('warn', `config push failed: ${(e as Error).message}`);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@customElement('nv-sidebar')
|
||||
export class NvSidebar extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
padding: 14px; overflow-y: auto;
|
||||
background: var(--bg-1); border-right: 1px solid var(--line);
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
}
|
||||
.panel-h {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 11px; font-weight: 600; color: var(--ink-3);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.panel-help {
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.help-link {
|
||||
color: var(--accent-2);
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
.help-link:hover { color: var(--accent); }
|
||||
.count {
|
||||
background: var(--bg-3); color: var(--ink-2);
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
.scene-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.scene-item:hover { background: var(--bg-3); }
|
||||
.scene-item .swatch { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.scene-item .name { font-size: 13px; flex: 1; }
|
||||
.scene-item .meta { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); }
|
||||
.field-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 0; font-size: 12.5px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.field-row:last-child { border-bottom: 0; }
|
||||
.field-row .lbl { color: var(--ink-3); }
|
||||
.field-row .val { font-family: var(--mono); color: var(--ink); font-size: 12px; }
|
||||
.slider-row { padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.slider-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
.slider-row .top { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
||||
.slider-row .top .lbl { color: var(--ink-3); }
|
||||
.slider-row .top .val { font-family: var(--mono); color: var(--ink); }
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 100%; height: 4px;
|
||||
background: var(--bg-3); border-radius: 2px; outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--accent); cursor: pointer;
|
||||
border: 2px solid var(--bg-2);
|
||||
box-shadow: 0 0 0 1px var(--line-2);
|
||||
}
|
||||
.pipeline { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
|
||||
.stage {
|
||||
flex: 1; min-width: 50px;
|
||||
padding: 4px 6px;
|
||||
background: var(--bg-3); border: 1px solid var(--line);
|
||||
border-radius: 6px; font-size: 9.5px; text-align: center;
|
||||
color: var(--ink-2); font-family: var(--mono);
|
||||
}
|
||||
.stage.live { border-color: var(--accent-2); color: var(--accent-2); }
|
||||
.stage-arrow { color: var(--ink-4); font-size: 10px; }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { fs.value; fmod.value; dtMs.value; noiseEnabled.value; running.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="panel">
|
||||
<div class="panel-h">Scene <span class="count">4 sources</span></div>
|
||||
<div class="panel-help">
|
||||
Magnetic primitives in the simulated environment. Drag any in the
|
||||
canvas to reposition; positions persist across reloads.
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
|
||||
<span class="name">rebar.steel.coil</span>
|
||||
<span class="meta">χ=5000</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.78 0.14 195)"></span>
|
||||
<span class="name">heart_proxy</span>
|
||||
<span class="meta">1e-6 A·m²</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
|
||||
<span class="name">mains_60Hz</span>
|
||||
<span class="meta">2 A · 60 Hz</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.78 0.14 145)"></span>
|
||||
<span class="name">door.steel</span>
|
||||
<span class="meta">eddy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">NV sensor <span class="count">COTS</span></div>
|
||||
<div class="panel-help">
|
||||
Element Six DNV-B1 reference: 1 mm³ diamond, ~10¹² NV centers.
|
||||
Floor δB ≈ 1.18 pT/√Hz per Barry 2020 §III.A.
|
||||
<span class="help-link" title="Open glossary"
|
||||
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section: 'glossary' } }))}>What's NV?</span>
|
||||
</div>
|
||||
<div class="field-row" title="Sensing volume (cubic millimetres)"><span class="lbl">V</span><span class="val">1 mm³</span></div>
|
||||
<div class="field-row" title="Number of NV centers contributing to readout"><span class="lbl">N</span><span class="val">1e12 NV</span></div>
|
||||
<div class="field-row" title="ODMR contrast — fractional dip at resonance"><span class="lbl">C</span><span class="val">0.030</span></div>
|
||||
<div class="field-row" title="Inhomogeneous dephasing time T₂*"><span class="lbl">T₂*</span><span class="val">200 ns</span></div>
|
||||
<div class="field-row" title="Shot-noise-limited field sensitivity"><span class="lbl">δB</span><span class="val">1.18 pT/√Hz</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">Tunables</div>
|
||||
<div class="panel-help">
|
||||
Live pipeline parameters. Edits debounce 300 ms then rebuild the
|
||||
WASM pipeline without restarting the frame stream.
|
||||
</div>
|
||||
<div class="slider-row" title="Digitiser sample rate — frames per second emitted by the pipeline">
|
||||
<div class="top"><span class="lbl">Sample rate</span><span class="val">${(fs.value / 1000).toFixed(1)} kHz</span></div>
|
||||
<input type="range" min="1000" max="100000" .value=${String(fs.value)}
|
||||
aria-label="Sample rate in Hz"
|
||||
@input=${(e: Event) => { fs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
<div class="slider-row" title="Microwave modulation frequency for lock-in demodulation">
|
||||
<div class="top"><span class="lbl">Lockin f_mod</span><span class="val">${(fmod.value / 1000).toFixed(3)} kHz</span></div>
|
||||
<input type="range" min="100" max="5000" .value=${String(fmod.value)}
|
||||
aria-label="Lock-in modulation frequency in Hz"
|
||||
@input=${(e: Event) => { fmod.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
<div class="slider-row" title="Per-sample integration time">
|
||||
<div class="top"><span class="lbl">Integration t</span><span class="val">${dtMs.value.toFixed(1)} ms</span></div>
|
||||
<input type="range" min="0.1" max="10" step="0.1" .value=${String(dtMs.value)}
|
||||
aria-label="Integration time in milliseconds"
|
||||
@input=${(e: Event) => { dtMs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
<div class="slider-row" title="Toggle shot-noise sampling. OFF = analytic noise-free output (debug only)">
|
||||
<div class="top"><span class="lbl">Shot noise</span><span class="val">${noiseEnabled.value ? 'ON' : 'OFF'}</span></div>
|
||||
<input type="range" min="0" max="1" .value=${noiseEnabled.value ? '1' : '0'}
|
||||
aria-label="Shot-noise sampling enabled"
|
||||
@input=${(e: Event) => { noiseEnabled.value = (e.target as HTMLInputElement).value === '1'; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">Pipeline</div>
|
||||
<div class="panel-help">
|
||||
Forward simulator stages, left to right. Stages glow cyan while
|
||||
the pipeline is running.
|
||||
</div>
|
||||
<div class="pipeline">
|
||||
<span class="stage ${running.value ? 'live' : ''}">scene</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">B-S</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">prop</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">NV</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">ADC</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">frame</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/* Toast notification — shown briefly via window.dispatchEvent('nv-toast', detail). */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
@customElement('nv-toast')
|
||||
export class NvToast extends LitElement {
|
||||
@state() private visible = false;
|
||||
@state() private msg = '';
|
||||
@state() private icon = '✓';
|
||||
private timer: number | null = null;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; bottom: 24px; left: 50%;
|
||||
transform: translateX(-50%) translateY(80px);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 100;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
:host([visible]) {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.icon { color: var(--accent); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-toast', this.onToast as EventListener);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-toast', this.onToast as EventListener);
|
||||
}
|
||||
|
||||
private onToast = (e: Event): void => {
|
||||
const detail = (e as CustomEvent).detail as { msg?: string; icon?: string };
|
||||
this.msg = detail.msg ?? 'Done';
|
||||
this.icon = detail.icon ?? '✓';
|
||||
this.visible = true;
|
||||
this.setAttribute('visible', '');
|
||||
if (this.timer !== null) window.clearTimeout(this.timer);
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.visible = false;
|
||||
this.removeAttribute('visible');
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`<span class="icon">${this.icon}</span><span>${this.msg}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function toast(msg: string, icon = '✓'): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-toast', { detail: { msg, icon } }));
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/* Topbar — breadcrumbs, transport pill, FPS pill, seed pill, controls. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
fps, transportLabel, seed, theme, sceneName,
|
||||
running, getClient, pushLog,
|
||||
} from '../store/appStore';
|
||||
import { openModal } from './nv-modal';
|
||||
import { toast } from './nv-toast';
|
||||
|
||||
@customElement('nv-topbar')
|
||||
export class NvTopbar extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; align-items: center;
|
||||
padding: 0 16px; gap: 12px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
z-index: 10;
|
||||
}
|
||||
.crumbs { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-3); }
|
||||
.crumbs .sep { color: var(--ink-4); }
|
||||
.crumbs .cur { color: var(--ink); font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px; color: var(--ink-2);
|
||||
font-family: var(--mono); font-weight: 500;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); animation: pulse 2s infinite; }
|
||||
.pill.wasm .dot { background: var(--accent-2); box-shadow: 0 0 6px var(--accent-2); }
|
||||
.pill.seed { color: var(--ink-3); cursor: pointer; }
|
||||
.pill.seed:hover { border-color: var(--line-2); }
|
||||
.pill.seed b { color: var(--accent); font-weight: 600; }
|
||||
.pill.wasm { cursor: pointer; }
|
||||
.pill.wasm:hover { border-color: var(--line-2); }
|
||||
button {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 500; color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
button:hover { border-color: var(--line-2); background: var(--bg-3); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.ghost { background: transparent; }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { fps.value; transportLabel.value; seed.value; theme.value; sceneName.value; running.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
private async toggleRun(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
if (running.value) { await c.pause(); running.value = false; }
|
||||
else { await c.run(); running.value = true; }
|
||||
}
|
||||
private async reset(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
await c.reset();
|
||||
}
|
||||
private toggleTheme(): void {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
private async openSeedModal(): Promise<void> {
|
||||
const cur = `0x${seed.value.toString(16).toUpperCase().padStart(8, '0')}`;
|
||||
openModal({
|
||||
title: 'Set seed',
|
||||
body: `<p>Set the 32-bit hex seed for the shot-noise PRNG. Same <code>(scene, config, seed)</code> → byte-identical witness.</p>
|
||||
<label>Hex seed</label>
|
||||
<input type="text" id="seed-input" value="${cur}" autofocus />`,
|
||||
buttons: [
|
||||
{ label: 'Cancel', variant: 'ghost' },
|
||||
{ label: 'Apply', variant: 'primary', onClick: async () => {
|
||||
const inp = document.querySelector('nv-modal')?.shadowRoot?.querySelector<HTMLInputElement>('#seed-input');
|
||||
if (!inp) return;
|
||||
const raw = inp.value.trim().replace(/^0x/i, '');
|
||||
const v = BigInt('0x' + raw);
|
||||
seed.value = v;
|
||||
await getClient()?.setSeed(v);
|
||||
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
|
||||
toast(`Seed → 0x${v.toString(16).toUpperCase().slice(0, 8)}`, '⟳');
|
||||
} },
|
||||
],
|
||||
});
|
||||
}
|
||||
private openTransportSettings(): void {
|
||||
window.dispatchEvent(new CustomEvent('open-settings'));
|
||||
}
|
||||
|
||||
override render() {
|
||||
const seedHex = seed.value.toString(16).toUpperCase().padStart(8, '0');
|
||||
return html`
|
||||
<div class="crumbs">
|
||||
<span class="home">RuView</span><span class="sep">/</span>
|
||||
<span>nvsim</span><span class="sep">/</span>
|
||||
<span class="cur" id="scene-name">${sceneName.value}</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill" id="fps-pill">
|
||||
<span class="dot"></span>
|
||||
<span id="fps-val">${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'idle'}</span>
|
||||
</span>
|
||||
<span class="pill wasm" id="transport-pill" title="Transport settings"
|
||||
@click=${this.openTransportSettings}>
|
||||
<span class="dot"></span>${transportLabel.value}
|
||||
</span>
|
||||
<span class="pill seed" id="seed-pill" title="Set seed"
|
||||
@click=${this.openSeedModal}>
|
||||
seed: <b>0x${seedHex}</b>
|
||||
</span>
|
||||
<button class="ghost" id="tour-btn" title="Replay the 10-step welcome tour"
|
||||
aria-label="Replay welcome tour"
|
||||
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-tour'))}>
|
||||
★ Tour
|
||||
</button>
|
||||
<button class="ghost" id="help-btn" title="Help (press ? any time)" aria-label="Open help"
|
||||
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-help'))}>
|
||||
?
|
||||
</button>
|
||||
<button class="ghost" id="theme-btn" title="Toggle theme" aria-label="Toggle theme"
|
||||
@click=${this.toggleTheme}>
|
||||
${theme.value === 'dark' ? '☼' : '☾'}
|
||||
</button>
|
||||
<button id="reset-btn" @click=${this.reset}>↺ Reset</button>
|
||||
<button class="primary" id="run-btn" @click=${this.toggleRun}>
|
||||
${running.value ? '❚❚ Pause' : '▶ Run'}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
/* nvsim dashboard entry — boots the WasmClient, mounts <nv-app>. */
|
||||
import './app.css';
|
||||
import './components/nv-app';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { WasmClient } from './transport/WasmClient';
|
||||
import { WsClient } from './transport/WsClient';
|
||||
import type { NvsimClient, MagFrameBatch } from './transport/NvsimClient';
|
||||
import {
|
||||
setClient, transport, wsUrl, connected, transportError,
|
||||
theme, density, motionReduced,
|
||||
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
|
||||
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
|
||||
replHistory, scenePositions, type SceneItemPos,
|
||||
activeAppIds, pushAppEvent,
|
||||
} from './store/appStore';
|
||||
import { APP_RUNTIMES, type AppRuntimeContext } from './store/appRuntimes';
|
||||
import { kvGet, kvSet } from './store/persistence';
|
||||
|
||||
function applyTheme(t: string): void {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}
|
||||
function applyDensity(d: string): void {
|
||||
document.body.classList.remove('density-comfy', 'density-default', 'density-compact');
|
||||
document.body.classList.add(`density-${d}`);
|
||||
}
|
||||
function applyMotion(reduced: boolean): void {
|
||||
document.body.classList.toggle('reduce-motion', reduced);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Restore persisted prefs
|
||||
const t = (await kvGet<'dark' | 'light'>('theme')) ?? 'dark';
|
||||
const d = (await kvGet<'comfy' | 'default' | 'compact'>('density')) ?? 'default';
|
||||
const sysMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
const m = (await kvGet<boolean>('motionReduced')) ?? sysMotion;
|
||||
theme.value = t; applyTheme(t);
|
||||
density.value = d; applyDensity(d);
|
||||
motionReduced.value = m; applyMotion(m);
|
||||
|
||||
// React to changes → persist
|
||||
effect(() => { applyTheme(theme.value); kvSet('theme', theme.value); });
|
||||
effect(() => { applyDensity(density.value); kvSet('density', density.value); });
|
||||
effect(() => { applyMotion(motionReduced.value); kvSet('motionReduced', motionReduced.value); });
|
||||
|
||||
// REPL history + scene drag positions persistence (P0.10, P1.7)
|
||||
const histSaved = await kvGet<string[]>('repl-history');
|
||||
if (histSaved && Array.isArray(histSaved)) replHistory.value = histSaved;
|
||||
effect(() => { void kvSet('repl-history', replHistory.value); });
|
||||
const positionsSaved = await kvGet<SceneItemPos[]>('scene-positions');
|
||||
if (positionsSaved && Array.isArray(positionsSaved)) scenePositions.value = positionsSaved;
|
||||
effect(() => { void kvSet('scene-positions', scenePositions.value); });
|
||||
|
||||
// Restore WS URL preference + transport mode
|
||||
const savedWsUrl = (await kvGet<string>('wsUrl')) ?? '';
|
||||
if (savedWsUrl) wsUrl.value = savedWsUrl;
|
||||
const savedTransport = (await kvGet<'wasm' | 'ws'>('transport')) ?? 'wasm';
|
||||
transport.value = savedTransport;
|
||||
effect(() => { void kvSet('wsUrl', wsUrl.value); });
|
||||
effect(() => { void kvSet('transport', transport.value); });
|
||||
|
||||
// Per-app runtime scratch state + history buffer (defined first so the
|
||||
// onFrames callback can close over them).
|
||||
const appState: Record<string, Record<string, number>> = {};
|
||||
const bMagHistory: number[] = [];
|
||||
const runtimeStartTs = performance.now();
|
||||
|
||||
const onFrames = (batch: MagFrameBatch): void => {
|
||||
if (batch.frames.length === 0) return;
|
||||
const last = batch.frames[batch.frames.length - 1];
|
||||
lastFrame.value = last;
|
||||
const bx = last.bPt[0] * 1e-12;
|
||||
const by = last.bPt[1] * 1e-12;
|
||||
const bz = last.bPt[2] * 1e-12;
|
||||
lastB.value = [bx, by, bz];
|
||||
const bmagT = Math.sqrt(bx * bx + by * by + bz * bz);
|
||||
bMag.value = bmagT;
|
||||
pushTrace([bx * 1e9, by * 1e9, bz * 1e9]);
|
||||
pushStripBar(Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3));
|
||||
bMagHistory.push(bmagT);
|
||||
while (bMagHistory.length > 256) bMagHistory.shift();
|
||||
|
||||
const activeIds = activeAppIds.value;
|
||||
if (activeIds.size === 0) return;
|
||||
const elapsedS = (performance.now() - runtimeStartTs) / 1000;
|
||||
for (const id of activeIds) {
|
||||
const fn = APP_RUNTIMES[id];
|
||||
if (!fn) continue;
|
||||
if (!appState[id]) appState[id] = {};
|
||||
const ctx: AppRuntimeContext = {
|
||||
frame: last,
|
||||
bMagT: bmagT,
|
||||
bRecoveredT: [bx, by, bz],
|
||||
bHistory: bMagHistory,
|
||||
elapsedS,
|
||||
state: appState[id],
|
||||
};
|
||||
try {
|
||||
const result = fn(ctx);
|
||||
if (!result) continue;
|
||||
const evs = Array.isArray(result) ? result : [result];
|
||||
for (const ev of evs) {
|
||||
pushAppEvent(ev);
|
||||
pushLog('info',
|
||||
`<span class="k">[${ev.appId}]</span> <span class="s">${ev.eventName}</span> <span class="n">(${ev.eventId})</span>${ev.detail ? ' · ' + ev.detail : ''}`);
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `[${id}] runtime error: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Boot transport (WASM by default, WS if user previously selected it)
|
||||
let activeClient: NvsimClient | null = null;
|
||||
async function bootTransport(): Promise<void> {
|
||||
try {
|
||||
if (activeClient) await activeClient.close();
|
||||
const want = transport.value;
|
||||
if (want === 'ws' && wsUrl.value.trim()) {
|
||||
const c = new WsClient(wsUrl.value.trim());
|
||||
const info = await c.boot();
|
||||
activeClient = c;
|
||||
connected.value = true;
|
||||
transportError.value = null;
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
wireClient(c);
|
||||
pushLog('ok', `transport WS · ${wsUrl.value} · nvsim@${info.buildVersion}`);
|
||||
} else {
|
||||
if (want === 'ws') {
|
||||
pushLog('warn', 'WS transport selected but no URL set — falling back to WASM');
|
||||
}
|
||||
const c = new WasmClient();
|
||||
const info = await c.boot();
|
||||
activeClient = c;
|
||||
connected.value = true;
|
||||
transportError.value = null;
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
wireClient(c);
|
||||
pushLog('ok', `transport WASM · nvsim@${info.buildVersion} · magic=0x${info.frameMagic.toString(16).toUpperCase()}`);
|
||||
}
|
||||
setClient(activeClient);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
transportError.value = msg;
|
||||
connected.value = false;
|
||||
pushLog('err', `transport boot failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
function wireClient(c: NvsimClient): void {
|
||||
c.onEvent((ev) => {
|
||||
if (ev.type === 'log') pushLog(ev.level, ev.msg);
|
||||
if (ev.type === 'fps') fps.value = ev.value;
|
||||
if (ev.type === 'state') framesEmitted.value = BigInt(ev.framesEmitted);
|
||||
});
|
||||
c.onFrames(onFrames);
|
||||
}
|
||||
|
||||
// React to transport-mode flips: tear down + re-boot.
|
||||
let bootInProgress = false;
|
||||
effect(() => {
|
||||
transport.value; wsUrl.value;
|
||||
if (bootInProgress) return;
|
||||
bootInProgress = true;
|
||||
void bootTransport().finally(() => { bootInProgress = false; });
|
||||
});
|
||||
|
||||
pushLog('info', 'nvsim — booting transport');
|
||||
|
||||
// Initial boot — handled by the effect() above.
|
||||
// Auto-verify witness whenever a fresh transport boot completes.
|
||||
let verifiedFor: string | null = null;
|
||||
effect(() => {
|
||||
const exp = expectedWitness.value;
|
||||
const isConn = connected.value;
|
||||
if (!exp || !isConn) return;
|
||||
if (verifiedFor === exp) return;
|
||||
verifiedFor = exp;
|
||||
void (async () => {
|
||||
const c = activeClient;
|
||||
if (!c) return;
|
||||
try {
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness verified · determinism gate ✓ · transport=${transport.value}`);
|
||||
} else {
|
||||
const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH · expected ${exp.slice(0, 16)}… got ${actual.slice(0, 16)}…`);
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `witness verify skipped: ${(e as Error).message}`);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
sceneJson.value = '(reference scene)';
|
||||
})();
|
||||
@@ -1,236 +0,0 @@
|
||||
/* In-browser simulated runtimes for App Store apps.
|
||||
*
|
||||
* Each runtime takes the most recent nvsim MagFrame + a short rolling
|
||||
* history and decides whether to emit one or more app events. Outputs are
|
||||
* illustrative: nvsim produces magnetic-field samples, the wasm-edge
|
||||
* algorithms expect WiFi CSI subcarriers — different physical modalities.
|
||||
* The simulated runtime preserves *event-emission semantics* (the same
|
||||
* i32 event IDs, the same trigger logic shape) so users can see the
|
||||
* cards working without an ESP32 mesh.
|
||||
*
|
||||
* For engineering-grade output, deploy the real `wifi-densepose-wasm-edge`
|
||||
* crate to ESP32 firmware over the WS transport — see ADR-040 / ADR-092 §6.2.
|
||||
*/
|
||||
|
||||
import type { MagFrameRecord } from '../transport/NvsimClient';
|
||||
|
||||
export interface AppEvent {
|
||||
/** Wall-clock timestamp (ms). */
|
||||
ts: number;
|
||||
/** App id that emitted. */
|
||||
appId: string;
|
||||
/** i32 event id from `event_types` mod in wifi-densepose-wasm-edge. */
|
||||
eventId: number;
|
||||
/** Human-readable event name (matches the constant name). */
|
||||
eventName: string;
|
||||
/** Numeric value the app reports (units app-specific). */
|
||||
value: number;
|
||||
/** Optional extra context for the console line. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface AppRuntimeContext {
|
||||
frame: MagFrameRecord;
|
||||
bMagT: number;
|
||||
bRecoveredT: [number, number, number];
|
||||
/** Rolling history of |B| in T. Most recent last. */
|
||||
bHistory: number[];
|
||||
/** Time since the runtime was activated (s). */
|
||||
elapsedS: number;
|
||||
/** Per-app scratch state — runtimes can persist counters here. */
|
||||
state: Record<string, number>;
|
||||
}
|
||||
|
||||
export type AppRuntimeFn = (ctx: AppRuntimeContext) => AppEvent | AppEvent[] | null;
|
||||
|
||||
/** Welford-style running-stat helper. */
|
||||
function rollingMean(arr: number[]): number {
|
||||
if (arr.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const v of arr) s += v;
|
||||
return s / arr.length;
|
||||
}
|
||||
function rollingStd(arr: number[]): number {
|
||||
if (arr.length < 2) return 0;
|
||||
const m = rollingMean(arr);
|
||||
let s = 0;
|
||||
for (const v of arr) s += (v - m) * (v - m);
|
||||
return Math.sqrt(s / (arr.length - 1));
|
||||
}
|
||||
|
||||
/** vital_trend — periodic 1-Hz HR/BR estimate from the B_z oscillation. */
|
||||
const vitalTrend: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 64) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 1.0) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
// Crude HR estimate: count zero-crossings of detrended B_z over the last
|
||||
// 64 samples; treat each crossing pair as one cardiac cycle.
|
||||
const tail = ctx.bHistory.slice(-64);
|
||||
const m = rollingMean(tail);
|
||||
let crossings = 0;
|
||||
for (let i = 1; i < tail.length; i++) {
|
||||
if ((tail[i] - m) * (tail[i - 1] - m) < 0) crossings++;
|
||||
}
|
||||
// 64 samples ≈ 0.65 s at the worker's 32-frame batches × 16 ms tick.
|
||||
const cycles = crossings / 2;
|
||||
const hr = Math.max(40, Math.min(180, Math.round((cycles / 0.65) * 60)));
|
||||
const br = Math.max(8, Math.min(30, Math.round(hr / 4))); // crude proxy
|
||||
|
||||
const evs: AppEvent[] = [
|
||||
{ ts: Date.now(), appId: 'vital_trend', eventId: 100, eventName: 'VITAL_TREND', value: hr, detail: `HR≈${hr} BPM, BR≈${br} br/min` },
|
||||
];
|
||||
if (hr < 60) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 103, eventName: 'BRADYCARDIA', value: hr, detail: `HR=${hr} BPM` });
|
||||
else if (hr > 100) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 104, eventName: 'TACHYCARDIA', value: hr, detail: `HR=${hr} BPM` });
|
||||
if (br < 12) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 101, eventName: 'BRADYPNEA', value: br, detail: `BR=${br} br/min` });
|
||||
else if (br > 24) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 102, eventName: 'TACHYPNEA', value: br, detail: `BR=${br} br/min` });
|
||||
return evs;
|
||||
};
|
||||
|
||||
/** occupancy — variance threshold on |B| over a 5-second window. */
|
||||
const occupancy: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 32) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 2.0) return null;
|
||||
const std = rollingStd(ctx.bHistory.slice(-128)) * 1e9; // T → nT
|
||||
const occupied = std > 0.01; // empirical threshold for the demo
|
||||
const wasOccupied = (ctx.state['occ'] ?? 0) > 0.5;
|
||||
if (occupied !== wasOccupied) {
|
||||
ctx.state['occ'] = occupied ? 1 : 0;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'occupancy',
|
||||
eventId: occupied ? 300 : 302,
|
||||
eventName: occupied ? 'ZONE_OCCUPIED' : 'ZONE_TRANSITION',
|
||||
value: std,
|
||||
detail: occupied ? `σ(|B|)=${std.toFixed(3)} nT — entered` : `σ(|B|)=${std.toFixed(3)} nT — left`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** intrusion — |B| above ambient + dwell timer. */
|
||||
const intrusion: AppRuntimeFn = (ctx) => {
|
||||
const ambient = ctx.state['ambient'] ?? ctx.bMagT;
|
||||
ctx.state['ambient'] = 0.95 * ambient + 0.05 * ctx.bMagT;
|
||||
const exceeds = ctx.bMagT > ambient * 1.5 && ctx.bMagT > 1e-12;
|
||||
const dwellStart = ctx.state['dwellStart'] ?? 0;
|
||||
if (exceeds && dwellStart === 0) {
|
||||
ctx.state['dwellStart'] = ctx.elapsedS;
|
||||
} else if (!exceeds) {
|
||||
ctx.state['dwellStart'] = 0;
|
||||
}
|
||||
if (exceeds && dwellStart > 0 && ctx.elapsedS - dwellStart > 0.5 && (ctx.state['lastEmitS'] ?? 0) < dwellStart) {
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'intrusion',
|
||||
eventId: 200,
|
||||
eventName: 'INTRUSION_ALERT',
|
||||
value: ctx.bMagT * 1e9,
|
||||
detail: `|B|=${(ctx.bMagT * 1e9).toFixed(2)} nT > 1.5× ambient (${(ambient * 1e9).toFixed(2)} nT) for ${(ctx.elapsedS - dwellStart).toFixed(1)} s`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** coherence — z-score of recent |B| against a longer baseline. */
|
||||
const coherence: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 64) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 0.5) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
const recent = ctx.bHistory.slice(-32);
|
||||
const baseline = ctx.bHistory.slice(-128, -32);
|
||||
if (baseline.length < 32) return null;
|
||||
const mu = rollingMean(baseline);
|
||||
const sd = rollingStd(baseline);
|
||||
if (sd === 0) return null;
|
||||
const recentMean = rollingMean(recent);
|
||||
const z = Math.abs(recentMean - mu) / sd;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'coherence',
|
||||
eventId: 2,
|
||||
eventName: 'COHERENCE_SCORE',
|
||||
value: z,
|
||||
detail: `z=${z.toFixed(2)} σ ${z > 3 ? '· DRIFT' : z > 1.5 ? '· marginal' : '· stable'}`,
|
||||
};
|
||||
};
|
||||
|
||||
/** adversarial — detect physically-impossible 1/r³ violation. */
|
||||
const adversarial: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 32) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 3.0) return null;
|
||||
|
||||
// Fake "multi-link consistency": compare instantaneous |B| with the
|
||||
// smoothed |B|. A sharp factor-of-N step violates dipole physics
|
||||
// (real 1/r³ source moves continuously).
|
||||
const tail = ctx.bHistory.slice(-32);
|
||||
let maxJump = 0;
|
||||
for (let i = 1; i < tail.length; i++) {
|
||||
const j = Math.abs(Math.log(Math.max(tail[i], 1e-15)) - Math.log(Math.max(tail[i - 1], 1e-15)));
|
||||
if (j > maxJump) maxJump = j;
|
||||
}
|
||||
if (maxJump > 5) {
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'adversarial',
|
||||
eventId: 3,
|
||||
eventName: 'ANOMALY_DETECTED',
|
||||
value: maxJump,
|
||||
detail: `log-jump ${maxJump.toFixed(1)} — physically implausible step in |B|`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** exo_ghost_hunter — empty-room CSI anomaly detector adapted to the
|
||||
* magnetic noise floor: flag impulsive / periodic / drift / random
|
||||
* patterns and a hidden-presence sub-detector at 0.15-0.5 Hz. */
|
||||
const exoGhostHunter: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 128) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 4.0) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
const tail = ctx.bHistory.slice(-128);
|
||||
const std = rollingStd(tail) * 1e9;
|
||||
// Detect impulsive: max - mean > 4σ
|
||||
const m = rollingMean(tail);
|
||||
let maxDev = 0;
|
||||
for (const v of tail) {
|
||||
const d = Math.abs(v - m);
|
||||
if (d > maxDev) maxDev = d;
|
||||
}
|
||||
const cls: 1 | 3 | 4 = maxDev > 4 * (std * 1e-9) ? 1 // impulsive
|
||||
: ctx.elapsedS > 10 ? 3 // drift bias as a default after warmup
|
||||
: 4; // random
|
||||
const clsName = cls === 1 ? 'impulsive' : cls === 3 ? 'drift' : 'random';
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'exo_ghost_hunter',
|
||||
eventId: 651,
|
||||
eventName: 'ANOMALY_CLASS',
|
||||
value: cls,
|
||||
detail: `class=${clsName} · σ=${std.toFixed(3)} nT`,
|
||||
};
|
||||
};
|
||||
|
||||
export const APP_RUNTIMES: Record<string, AppRuntimeFn> = {
|
||||
vital_trend: vitalTrend,
|
||||
occupancy,
|
||||
intrusion,
|
||||
coherence,
|
||||
adversarial,
|
||||
exo_ghost_hunter: exoGhostHunter,
|
||||
};
|
||||
|
||||
export function hasRuntime(appId: string): boolean {
|
||||
return appId in APP_RUNTIMES;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/* Application-wide reactive state.
|
||||
*
|
||||
* One signal per logical observable; components subscribe to only the
|
||||
* signals they read. Keeps re-renders surgical even at 1 kHz frame rates.
|
||||
* Persistence lives in `persistence.ts`; this module is pure state.
|
||||
*/
|
||||
import { signal, computed } from '@preact/signals-core';
|
||||
import type { NvsimClient, MagFrameRecord, NvsimEvent } from '../transport/NvsimClient';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
export type Density = 'comfy' | 'default' | 'compact';
|
||||
export type TransportMode = 'wasm' | 'ws';
|
||||
|
||||
export const transport = signal<TransportMode>('wasm');
|
||||
export const wsUrl = signal<string>('');
|
||||
export const connected = signal<boolean>(false);
|
||||
export const transportError = signal<string | null>(null);
|
||||
|
||||
export const running = signal<boolean>(false);
|
||||
export const paused = signal<boolean>(true);
|
||||
export const speed = signal<number>(1.0);
|
||||
export const t = signal<number>(0); // sim time (s)
|
||||
export const framesEmitted = signal<bigint>(0n);
|
||||
|
||||
export const seed = signal<bigint>(0xCAFEBABEn);
|
||||
|
||||
export const fs = signal<number>(10000); // sample rate Hz
|
||||
export const fmod = signal<number>(1000); // lockin Hz
|
||||
export const dtMs = signal<number>(1.0);
|
||||
export const noiseEnabled = signal<boolean>(true);
|
||||
|
||||
export const theme = signal<Theme>('dark');
|
||||
export const density = signal<Density>('default');
|
||||
export const motionReduced = signal<boolean>(false);
|
||||
export const autoUpdate = signal<boolean>(true);
|
||||
|
||||
export const lastB = signal<[number, number, number]>([0, 0, 0]); // T
|
||||
export const bMag = signal<number>(0);
|
||||
export const snr = signal<number>(0);
|
||||
export const fps = signal<number>(0);
|
||||
|
||||
export const witnessHex = signal<string>('');
|
||||
export const witnessVerified = signal<'pending' | 'ok' | 'fail' | 'idle'>('idle');
|
||||
export const expectedWitness = signal<string>('');
|
||||
|
||||
export const lastFrame = signal<MagFrameRecord | null>(null);
|
||||
export const traceX = signal<number[]>([]);
|
||||
export const traceY = signal<number[]>([]);
|
||||
export const traceZ = signal<number[]>([]);
|
||||
export const stripBars = signal<number[]>([]);
|
||||
|
||||
export const sceneName = signal<string>('rebar-walkby-01');
|
||||
export const sceneJson = signal<string>('');
|
||||
|
||||
export const consolePaused = signal<boolean>(false);
|
||||
export const consoleFilter = signal<'all' | 'info' | 'warn' | 'err' | 'dbg' | 'ok'>('all');
|
||||
|
||||
/** REPL command history, persisted via persistence.ts (kvSet 'repl-history'). */
|
||||
export const replHistory = signal<string[]>([]);
|
||||
export function pushReplHistory(cmd: string): void {
|
||||
const next = replHistory.value.slice();
|
||||
next.push(cmd);
|
||||
while (next.length > 200) next.shift();
|
||||
replHistory.value = next;
|
||||
}
|
||||
|
||||
/** Scene drag positions, persisted via persistence.ts (kvSet 'scene-positions'). */
|
||||
export interface SceneItemPos { id: string; x: number; y: number }
|
||||
export const scenePositions = signal<SceneItemPos[]>([]);
|
||||
|
||||
/** App-runtime emitted events. See appRuntimes.ts. */
|
||||
import type { AppEvent } from './appRuntimes';
|
||||
export const appEvents = signal<AppEvent[]>([]);
|
||||
export const appEventCounts = signal<Record<string, number>>({});
|
||||
|
||||
export function pushAppEvent(ev: AppEvent): void {
|
||||
const next = appEvents.value.slice();
|
||||
next.push(ev);
|
||||
while (next.length > 200) next.shift();
|
||||
appEvents.value = next;
|
||||
|
||||
const c = { ...appEventCounts.value };
|
||||
c[ev.appId] = (c[ev.appId] ?? 0) + 1;
|
||||
appEventCounts.value = c;
|
||||
}
|
||||
|
||||
/** Active app activations — driven by the App Store toggles. Mirrored
|
||||
* from `apps.ts` but exposed as a signal here so `main.ts` can dispatch
|
||||
* frames to active runtimes without importing the App Store component. */
|
||||
export const activeAppIds = signal<Set<string>>(new Set());
|
||||
|
||||
export const transportLabel = computed<string>(() =>
|
||||
transport.value === 'wasm' ? 'wasm' : 'ws',
|
||||
);
|
||||
|
||||
let _client: NvsimClient | null = null;
|
||||
export function setClient(c: NvsimClient): void { _client = c; }
|
||||
export function getClient(): NvsimClient | null { return _client; }
|
||||
|
||||
export interface ConsoleLine {
|
||||
ts: number;
|
||||
level: 'info' | 'warn' | 'err' | 'dbg' | 'ok';
|
||||
msg: string;
|
||||
}
|
||||
export const consoleLines = signal<ConsoleLine[]>([]);
|
||||
const MAX_LINES = 200;
|
||||
|
||||
export function pushLog(level: ConsoleLine['level'], msg: string): void {
|
||||
if (consolePaused.value) return;
|
||||
const next = consoleLines.value.slice();
|
||||
next.push({ ts: Date.now(), level, msg });
|
||||
while (next.length > MAX_LINES) next.shift();
|
||||
consoleLines.value = next;
|
||||
}
|
||||
|
||||
export function pushTrace(b: [number, number, number]): void {
|
||||
const cap = 200;
|
||||
const x = traceX.value.slice(); x.push(b[0]); if (x.length > cap) x.shift();
|
||||
const y = traceY.value.slice(); y.push(b[1]); if (y.length > cap) y.shift();
|
||||
const z = traceZ.value.slice(); z.push(b[2]); if (z.length > cap) z.shift();
|
||||
traceX.value = x;
|
||||
traceY.value = y;
|
||||
traceZ.value = z;
|
||||
}
|
||||
|
||||
export function pushStripBar(amp: number): void {
|
||||
const cap = 48;
|
||||
const next = stripBars.value.slice();
|
||||
next.push(Math.max(0, Math.min(1, amp)));
|
||||
while (next.length > cap) next.shift();
|
||||
stripBars.value = next;
|
||||
}
|
||||
|
||||
export function recordEvent(_ev: NvsimEvent): void {
|
||||
// future: route NvsimEvent into store updates per type. For V1 the
|
||||
// worker pushes B-vector / frame data directly via the data plane.
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/* RuView Edge App Store registry.
|
||||
*
|
||||
* Catalog of every WASM edge module shipping in the workspace plus the
|
||||
* `nvsim` simulator itself. Each entry maps to a hot-loadable algorithm
|
||||
* the dashboard can run in-browser (WASM transport) or push to a real
|
||||
* ESP32-S3 mesh (WS transport, deployed via WASM3 — ADR-040 Tier 3).
|
||||
*
|
||||
* Categories (ADR-041 event-ID ranges):
|
||||
* med 100–199 Medical & health
|
||||
* sec 200–299 Security & safety
|
||||
* bld 300–399 Smart building
|
||||
* ret 400–499 Retail & hospitality
|
||||
* ind 500–599 Industrial
|
||||
* sig 600–619 Signal-processing primitives
|
||||
* lrn 620–639 Online learning
|
||||
* spt 640–659 Spatial / graph
|
||||
* tmp 640–660 Temporal logic / planning
|
||||
* ais 700–719 AI safety
|
||||
* qnt 720–739 Quantum-flavoured signal
|
||||
* aut 740–759 Autonomy / mesh
|
||||
* exo 650–699 Exotic / research
|
||||
* sim — Pipeline simulators (nvsim)
|
||||
*
|
||||
* The `crate` field names the Cargo crate that owns the implementation.
|
||||
* `wasmEdge` apps are compiled out of `wifi-densepose-wasm-edge`;
|
||||
* `nvsim` apps come from `nvsim`. Future apps may target other crates.
|
||||
*/
|
||||
|
||||
export type AppCategory =
|
||||
| 'sim'
|
||||
| 'med'
|
||||
| 'sec'
|
||||
| 'bld'
|
||||
| 'ret'
|
||||
| 'ind'
|
||||
| 'sig'
|
||||
| 'lrn'
|
||||
| 'spt'
|
||||
| 'tmp'
|
||||
| 'ais'
|
||||
| 'qnt'
|
||||
| 'aut'
|
||||
| 'exo';
|
||||
|
||||
/** What actually happens when a card's toggle is on.
|
||||
* - `running` — the algorithm is genuinely running in the browser right now
|
||||
* (e.g. `nvsim` itself, which is the simulator the dashboard fronts).
|
||||
* - `simulated` — a pared-down version of the algorithm runs against nvsim's
|
||||
* live magnetic frame stream as a *proxy* for its native CSI input.
|
||||
* Emits real i32 event IDs into the console feed; output is illustrative,
|
||||
* not engineering-grade. Listed apps' Rust source is real, builds for
|
||||
* wasm32-unknown-unknown, and passes its native unit tests.
|
||||
* - `mesh-only` — algorithm needs CSI subcarrier data from a real ESP32-S3
|
||||
* mesh (or a future CSI simulator). Toggling persists the selection so
|
||||
* the WS transport can push activation when connected. */
|
||||
export type AppRuntime = 'running' | 'simulated' | 'mesh-only';
|
||||
|
||||
export interface AppManifest {
|
||||
/** Stable kebab-case id; matches the wasm-edge module name (e.g. `med_sleep_apnea`). */
|
||||
id: string;
|
||||
/** Human-readable name. */
|
||||
name: string;
|
||||
/** Category short-code. */
|
||||
category: AppCategory;
|
||||
/** Cargo crate the implementation lives in. */
|
||||
crate: 'nvsim' | 'wifi-densepose-wasm-edge' | string;
|
||||
/** One-liner description. */
|
||||
summary: string;
|
||||
/** Optional longer markdown body. */
|
||||
body?: string;
|
||||
/** Numeric event IDs this app emits (i32 codes from `event_types` mod). */
|
||||
events?: number[];
|
||||
/** Compute budget tier the module advertises. S=<5ms, M=<15ms, L=<50ms. */
|
||||
budget?: 'S' | 'M' | 'L';
|
||||
/** Default activation state when listed. */
|
||||
active?: boolean;
|
||||
/** Tags for fuzzy search and filtering. */
|
||||
tags?: string[];
|
||||
/** "Available", "Beta", or "Research" maturity. */
|
||||
status: 'available' | 'beta' | 'research';
|
||||
/** ADR back-reference. */
|
||||
adr?: string;
|
||||
/** What actually happens when active — see AppRuntime docs. */
|
||||
runtime?: AppRuntime;
|
||||
}
|
||||
|
||||
export const APPS: AppManifest[] = [
|
||||
// ── Pipeline simulators ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'nvsim',
|
||||
name: 'nvsim — NV-diamond magnetometer',
|
||||
category: 'sim',
|
||||
crate: 'nvsim',
|
||||
summary:
|
||||
'Deterministic forward simulator: scene → Biot–Savart → NV ensemble → ADC → MagFrame stream + SHA-256 witness.',
|
||||
budget: 'L',
|
||||
active: true,
|
||||
status: 'available',
|
||||
tags: ['quantum', 'magnetometer', 'simulator', 'witness', 'wasm'],
|
||||
adr: 'ADR-089',
|
||||
runtime: 'running',
|
||||
},
|
||||
|
||||
// ── Core sensing primitives (ADR-014/040 flagship modules) ───────────────
|
||||
{
|
||||
id: 'gesture',
|
||||
name: 'Gesture (DTW)',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Dynamic-Time-Warping gesture classifier from CSI motion templates.',
|
||||
events: [1],
|
||||
budget: 'M',
|
||||
status: 'available',
|
||||
tags: ['hci', 'csi', 'classifier', 'dtw'],
|
||||
adr: 'ADR-014',
|
||||
runtime: 'mesh-only',
|
||||
},
|
||||
{
|
||||
id: 'coherence',
|
||||
name: 'Coherence gate',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Z-score coherence scoring + Accept/PredictOnly/Reject/Recalibrate gate.',
|
||||
events: [2],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['gate', 'csi', 'coherence', 'drift'],
|
||||
adr: 'ADR-029',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'adversarial',
|
||||
name: 'Adversarial-signal detector',
|
||||
category: 'ais',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary:
|
||||
'Physically-impossible-signal detector — multi-link consistency, used to flag spoofed CSI.',
|
||||
events: [3],
|
||||
budget: 'M',
|
||||
status: 'available',
|
||||
tags: ['security', 'csi', 'spoofing', 'mesh'],
|
||||
adr: 'ADR-032',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'rvf',
|
||||
name: 'RVF — Rust Verified Feature stream',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Verified-frame builder with SHA-256 hash + version metadata for the feature stream.',
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['witness', 'csi', 'hash'],
|
||||
adr: 'ADR-040',
|
||||
},
|
||||
{
|
||||
id: 'occupancy',
|
||||
name: 'Occupancy estimator',
|
||||
category: 'bld',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Through-wall presence + person-count via CSI amplitude perturbation.',
|
||||
events: [300, 301, 302],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['csi', 'building', 'presence'],
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'vital_trend',
|
||||
name: 'Vital-trend monitor',
|
||||
category: 'med',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'HR + BR trend tracking with bradycardia/tachycardia/apnea events.',
|
||||
events: [100, 101, 102, 103, 104, 105],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['medical', 'vitals', 'csi'],
|
||||
adr: 'ADR-021',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'intrusion',
|
||||
name: 'Intrusion detector',
|
||||
category: 'sec',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Zone-based intrusion alert from CSI motion patterns.',
|
||||
events: [200, 201],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['security', 'zone', 'csi'],
|
||||
runtime: 'simulated',
|
||||
},
|
||||
|
||||
// ── Medical & Health (100-series) ────────────────────────────────────────
|
||||
{ id: 'med_sleep_apnea', name: 'Sleep-apnea detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Episodic respiratory pause detection during sleep cycles.', events: [105], budget: 'S', status: 'available', tags: ['medical', 'sleep', 'breathing'] },
|
||||
{ id: 'med_cardiac_arrhythmia', name: 'Cardiac arrhythmia', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Beat-to-beat irregularity classifier from cardiac micro-Doppler.', events: [103, 104], budget: 'M', status: 'available', tags: ['medical', 'cardiac', 'arrhythmia'] },
|
||||
{ id: 'med_respiratory_distress', name: 'Respiratory distress', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Distress signature: rapid shallow breathing + accessory-muscle motion.', events: [101, 102], budget: 'S', status: 'available', tags: ['medical', 'breathing', 'icu'] },
|
||||
{ id: 'med_gait_analysis', name: 'Gait analysis', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Stride length, cadence, asymmetry from through-wall CSI pose tracking.', budget: 'M', status: 'available', tags: ['medical', 'gait', 'pose'] },
|
||||
{ id: 'med_seizure_detect', name: 'Seizure detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Tonic-clonic seizure motion signature.', budget: 'M', status: 'beta', tags: ['medical', 'neuro'] },
|
||||
|
||||
// ── Security (200-series) ────────────────────────────────────────────────
|
||||
{ id: 'sec_perimeter_breach', name: 'Perimeter breach', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Approach/departure detection at user-defined boundary segments.', events: [210, 211, 212, 213], budget: 'S', status: 'available', tags: ['security', 'perimeter'] },
|
||||
{ id: 'sec_weapon_detect', name: 'Metal anomaly / weapon', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Metal-perturbation flag in CSI; potential weapon presence (research).', events: [220, 221, 222], budget: 'M', status: 'research', tags: ['security', 'metal', 'csi'] },
|
||||
{ id: 'sec_tailgating', name: 'Tailgating detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Detect 2+ persons crossing a single-passage threshold.', events: [230, 231, 232], budget: 'S', status: 'available', tags: ['security', 'access-control'] },
|
||||
{ id: 'sec_loitering', name: 'Loitering detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Stationary occupancy past a configurable dwell threshold.', events: [240, 241, 242], budget: 'S', status: 'available', tags: ['security', 'dwell'] },
|
||||
{ id: 'sec_panic_motion', name: 'Panic motion', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'High-energy distress motion: struggle / fleeing pattern.', events: [250, 251, 252], budget: 'S', status: 'beta', tags: ['security', 'distress'] },
|
||||
|
||||
// ── Smart Building (300-series) ──────────────────────────────────────────
|
||||
{ id: 'bld_hvac_presence', name: 'HVAC presence', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Occupied/activity-level/departure-countdown for HVAC zones.', events: [310, 311, 312], budget: 'S', status: 'available', tags: ['hvac', 'building', 'energy'] },
|
||||
{ id: 'bld_lighting_zones', name: 'Lighting zones', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone light on/dim/off cues from occupancy.', events: [320, 321, 322], budget: 'S', status: 'available', tags: ['lighting', 'building'] },
|
||||
{ id: 'bld_elevator_count', name: 'Elevator count', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Person count inside elevator car from CSI.', events: [330], budget: 'S', status: 'available', tags: ['elevator', 'building'] },
|
||||
{ id: 'bld_meeting_room', name: 'Meeting-room utilization', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Meeting size + duration analytics for booking systems.', budget: 'S', status: 'available', tags: ['meeting', 'analytics'] },
|
||||
{ id: 'bld_energy_audit', name: 'Energy audit', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Continuous occupancy-vs-HVAC-state audit for energy savings.', budget: 'M', status: 'available', tags: ['energy', 'audit'] },
|
||||
|
||||
// ── Retail (400-series) ──────────────────────────────────────────────────
|
||||
{ id: 'ret_queue_length', name: 'Queue length', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Live queue-length tracking for checkout / kiosks.', budget: 'S', status: 'available', tags: ['retail', 'queue'] },
|
||||
{ id: 'ret_dwell_heatmap', name: 'Dwell heatmap', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone dwell time accumulation; analytics-only export.', budget: 'M', status: 'available', tags: ['retail', 'heatmap'] },
|
||||
{ id: 'ret_customer_flow', name: 'Customer flow', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Origin-destination flow graph through a store layout.', budget: 'M', status: 'available', tags: ['retail', 'flow'] },
|
||||
{ id: 'ret_table_turnover', name: 'Table turnover', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Restaurant table seat / vacate transitions.', budget: 'S', status: 'available', tags: ['retail', 'restaurant'] },
|
||||
{ id: 'ret_shelf_engagement', name: 'Shelf engagement', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Reach-to-shelf gestures and dwell at product zones.', budget: 'M', status: 'available', tags: ['retail', 'shelf'] },
|
||||
|
||||
// ── Industrial (500-series) ──────────────────────────────────────────────
|
||||
{ id: 'ind_forklift_proximity', name: 'Forklift proximity', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Worker-near-forklift safety alert.', budget: 'S', status: 'available', tags: ['industrial', 'safety'] },
|
||||
{ id: 'ind_confined_space', name: 'Confined-space monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Last-person-out detection + presence audit for OSHA confined-space entries.', budget: 'S', status: 'available', tags: ['industrial', 'osha'] },
|
||||
{ id: 'ind_clean_room', name: 'Clean-room PPE / motion', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Motion patterns consistent with proper PPE-clad movement.', budget: 'M', status: 'beta', tags: ['industrial', 'cleanroom'] },
|
||||
{ id: 'ind_livestock_monitor', name: 'Livestock monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Vital-sign + activity tracking for stall-bound livestock.', budget: 'M', status: 'beta', tags: ['agriculture', 'livestock'] },
|
||||
{ id: 'ind_structural_vibration', name: 'Structural vibration', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Building/equipment micro-vibration via CSI phase derivative.', budget: 'M', status: 'research', tags: ['industrial', 'vibration'] },
|
||||
|
||||
// ── Signal primitives (600-series) ───────────────────────────────────────
|
||||
{ id: 'sig_coherence_gate', name: 'Coherence gate (extended)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Hysteresis + multi-state coherence gate driving downstream apps.', budget: 'S', status: 'available', tags: ['gate', 'csi'] },
|
||||
{ id: 'sig_flash_attention', name: 'Flash attention (CSI)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-friendly attention block for CSI subcarrier weighting.', budget: 'M', status: 'beta', tags: ['attention', 'csi'] },
|
||||
{ id: 'sig_temporal_compress', name: 'Temporal-tensor compress', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'RuVector temporal-tensor compression on the CSI buffer.', budget: 'M', status: 'available', tags: ['compress', 'tensor'] },
|
||||
{ id: 'sig_sparse_recovery', name: 'Sparse recovery', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: '114→56 subcarrier sparse interpolation via L1 solver.', budget: 'M', status: 'available', tags: ['sparse', 'csi'] },
|
||||
{ id: 'sig_mincut_person_match', name: 'Mincut person-match', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Min-cut person assignment across multistatic frames.', budget: 'M', status: 'available', tags: ['mincut', 'matching'] },
|
||||
{ id: 'sig_optimal_transport', name: 'Optimal transport', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'OT-based feature alignment between mesh nodes.', budget: 'M', status: 'beta', tags: ['ot', 'alignment'] },
|
||||
|
||||
// ── Online learning ──────────────────────────────────────────────────────
|
||||
{ id: 'lrn_dtw_gesture_learn', name: 'DTW gesture learn', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'On-device template learning for personalized gesture libraries.', budget: 'M', status: 'beta', tags: ['lifelong', 'gesture'] },
|
||||
{ id: 'lrn_anomaly_attractor', name: 'Anomaly attractor', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Novelty detector with dynamic-attractor recall.', budget: 'M', status: 'research', tags: ['novelty', 'lifelong'] },
|
||||
{ id: 'lrn_meta_adapt', name: 'Meta-adapt', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Meta-learning adapter for fast site-to-site transfer.', budget: 'L', status: 'research', tags: ['meta-learning'] },
|
||||
{ id: 'lrn_ewc_lifelong', name: 'EWC++ lifelong', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Elastic-weight-consolidation gate to avoid catastrophic forgetting.', budget: 'M', status: 'beta', tags: ['lifelong', 'ewc'] },
|
||||
|
||||
// ── Spatial / graph ──────────────────────────────────────────────────────
|
||||
{ id: 'spt_pagerank_influence', name: 'PageRank influence', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Graph-influence ranking on the multistatic mesh.', budget: 'M', status: 'beta', tags: ['graph', 'pagerank'] },
|
||||
{ id: 'spt_micro_hnsw', name: 'µHNSW vector index', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Tiny HNSW index for AETHER re-ID embeddings on-device.', budget: 'M', status: 'available', tags: ['hnsw', 'reid'] },
|
||||
{ id: 'spt_spiking_tracker', name: 'Spiking tracker', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Spiking-network multi-target tracker.', budget: 'L', status: 'research', tags: ['snn', 'tracker'] },
|
||||
|
||||
// ── Temporal / planning ──────────────────────────────────────────────────
|
||||
{ id: 'tmp_pattern_sequence', name: 'Pattern sequence', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Sequence-of-events pattern matcher (e.g. ingress→linger→egress).', budget: 'M', status: 'available', tags: ['temporal', 'pattern'] },
|
||||
{ id: 'tmp_temporal_logic_guard', name: 'Temporal logic guard', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'LTL/MTL safety-property guard over event streams.', budget: 'M', status: 'beta', tags: ['ltl', 'safety'] },
|
||||
{ id: 'tmp_goap_autonomy', name: 'GOAP autonomy', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Goal-oriented action planning for adaptive routines.', budget: 'L', status: 'research', tags: ['planning', 'autonomy'] },
|
||||
|
||||
// ── AI safety ────────────────────────────────────────────────────────────
|
||||
{ id: 'ais_prompt_shield', name: 'Prompt shield', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-side LLM prompt-injection guard for on-device assistants.', budget: 'M', status: 'beta', tags: ['security', 'llm'] },
|
||||
{ id: 'ais_behavioral_profiler', name: 'Behavioral profiler', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Anomalous-behaviour profiler (drift in motion habits).', budget: 'M', status: 'beta', tags: ['anomaly', 'behaviour'] },
|
||||
|
||||
// ── Quantum-flavoured ────────────────────────────────────────────────────
|
||||
{ id: 'qnt_quantum_coherence', name: 'Quantum coherence', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Coherence diagnostics adapted for quantum-sensor signals.', budget: 'M', status: 'research', tags: ['quantum', 'coherence'] },
|
||||
{ id: 'qnt_interference_search', name: 'Interference search', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Interferometric anomaly search across mesh viewpoints.', budget: 'L', status: 'research', tags: ['quantum', 'interference'] },
|
||||
|
||||
// ── Autonomy / mesh ──────────────────────────────────────────────────────
|
||||
{ id: 'aut_psycho_symbolic', name: 'Psycho-symbolic agent', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Symbolic-rule + neural-feature hybrid for low-power autonomy loops.', budget: 'L', status: 'research', tags: ['autonomy', 'symbolic'] },
|
||||
{ id: 'aut_self_healing_mesh', name: 'Self-healing mesh', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Mesh-topology repair with per-node health gossip.', budget: 'M', status: 'beta', tags: ['mesh', 'health'] },
|
||||
|
||||
// ── Exotic / Research (650-series) ───────────────────────────────────────
|
||||
{ id: 'exo_ghost_hunter', name: 'Ghost hunter (anomaly)', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Empty-room CSI anomaly detector — impulsive/periodic/drift/random + hidden-presence sub-detector.', events: [650, 651, 652, 653], budget: 'S', status: 'available', tags: ['anomaly', 'paranormal', 'csi'], adr: 'ADR-041', runtime: 'simulated' },
|
||||
{ id: 'exo_breathing_sync', name: 'Breathing sync', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Multi-person breathing synchrony analytics.', budget: 'M', status: 'beta', tags: ['breathing', 'sync'] },
|
||||
{ id: 'exo_dream_stage', name: 'Dream-stage classifier', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'NREM/REM stage classification from breathing + micro-motion.', budget: 'M', status: 'research', tags: ['sleep', 'rem'] },
|
||||
{ id: 'exo_emotion_detect', name: 'Emotion detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Coarse arousal/valence from breathing + heart-rate variability.', budget: 'M', status: 'research', tags: ['affect'] },
|
||||
{ id: 'exo_gesture_language', name: 'Gesture language', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Sign-language pattern recognition.', budget: 'L', status: 'research', tags: ['hci', 'sign'] },
|
||||
{ id: 'exo_happiness_score', name: 'Happiness score', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Aggregate well-being score from co-occupancy + activity dynamics.', budget: 'M', status: 'research', tags: ['affect', 'wellbeing'] },
|
||||
{ id: 'exo_hyperbolic_space', name: 'Hyperbolic space embed', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Hyperbolic embeddings for hierarchical scene structure.', budget: 'L', status: 'research', tags: ['embedding', 'hyperbolic'] },
|
||||
{ id: 'exo_music_conductor', name: 'Music conductor', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Map gesture energy to MIDI tempo/dynamics.', budget: 'M', status: 'research', tags: ['midi', 'art'] },
|
||||
{ id: 'exo_plant_growth', name: 'Plant-growth tracker', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Slow CSI drift tracking for greenhouse foliage growth.', budget: 'L', status: 'research', tags: ['agriculture'] },
|
||||
{ id: 'exo_rain_detect', name: 'Rain detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Outdoor CSI signature of rainfall.', budget: 'M', status: 'research', tags: ['weather'] },
|
||||
{ id: 'exo_time_crystal', name: 'Time-crystal periodicity', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Periodicity diagnostics with anti-aliasing harmonics.', budget: 'M', status: 'research', tags: ['periodicity'] },
|
||||
];
|
||||
|
||||
export const CATEGORIES: Record<AppCategory, { label: string; color: string; range: string }> = {
|
||||
sim: { label: 'Simulators', color: 'oklch(0.78 0.14 70)', range: '—' },
|
||||
med: { label: 'Medical & Health', color: 'oklch(0.65 0.22 25)', range: '100–199' },
|
||||
sec: { label: 'Security & Safety', color: 'oklch(0.7 0.18 35)', range: '200–299' },
|
||||
bld: { label: 'Smart Building', color: 'oklch(0.78 0.12 195)', range: '300–399' },
|
||||
ret: { label: 'Retail & Hospitality', color: 'oklch(0.78 0.14 145)', range: '400–499' },
|
||||
ind: { label: 'Industrial', color: 'oklch(0.72 0.18 330)', range: '500–599' },
|
||||
sig: { label: 'Signal Processing', color: 'oklch(0.78 0.14 70)', range: '600–619' },
|
||||
lrn: { label: 'Online Learning', color: 'oklch(0.78 0.12 260)', range: '620–639' },
|
||||
spt: { label: 'Spatial / Graph', color: 'oklch(0.7 0.18 100)', range: '640–659' },
|
||||
tmp: { label: 'Temporal / Planning', color: 'oklch(0.7 0.16 50)', range: '660–679' },
|
||||
ais: { label: 'AI Safety', color: 'oklch(0.65 0.22 25)', range: '700–719' },
|
||||
qnt: { label: 'Quantum', color: 'oklch(0.72 0.18 290)', range: '720–739' },
|
||||
aut: { label: 'Autonomy', color: 'oklch(0.78 0.14 145)', range: '740–759' },
|
||||
exo: { label: 'Exotic / Research', color: 'oklch(0.72 0.18 330)', range: '650–699' },
|
||||
};
|
||||
|
||||
export interface AppActivation {
|
||||
id: string;
|
||||
/** Active in the current session. */
|
||||
active: boolean;
|
||||
/** Last activation timestamp. */
|
||||
lastActivatedAt?: number;
|
||||
/** Last event count seen (for the cards' counter). */
|
||||
eventCount?: number;
|
||||
}
|
||||
|
||||
export function defaultActivations(): AppActivation[] {
|
||||
return APPS.map((a) => ({ id: a.id, active: a.active === true, eventCount: 0 }));
|
||||
}
|
||||
|
||||
export function appsByCategory(): Record<AppCategory, AppManifest[]> {
|
||||
const map = {} as Record<AppCategory, AppManifest[]>;
|
||||
for (const c of Object.keys(CATEGORIES) as AppCategory[]) map[c] = [];
|
||||
for (const a of APPS) map[a.category].push(a);
|
||||
return map;
|
||||
}
|
||||
|
||||
export function findApp(id: string): AppManifest | undefined {
|
||||
return APPS.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
export function fuzzyMatch(query: string, app: AppManifest): number {
|
||||
if (!query) return 1;
|
||||
const q = query.toLowerCase();
|
||||
let score = 0;
|
||||
if (app.id.toLowerCase().includes(q)) score += 3;
|
||||
if (app.name.toLowerCase().includes(q)) score += 3;
|
||||
if (app.summary.toLowerCase().includes(q)) score += 1;
|
||||
if (app.tags?.some((t) => t.toLowerCase().includes(q))) score += 2;
|
||||
if (app.category === q) score += 5;
|
||||
return score;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/* IndexedDB-backed persistence for settings and saved scenes.
|
||||
* Mirrors the mockup's `nvsim/kv` store. */
|
||||
|
||||
const DB_NAME = 'nvsim';
|
||||
const DB_VER = 1;
|
||||
const STORE = 'kv';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VER);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function kvGet<T = unknown>(key: string): Promise<T | undefined> {
|
||||
const db = await openDb();
|
||||
return await new Promise<T | undefined>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const r = tx.objectStore(STORE).get(key);
|
||||
r.onsuccess = () => resolve(r.result as T | undefined);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function kvSet(key: string, value: unknown): Promise<void> {
|
||||
const db = await openDb();
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function kvDelete(key: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/* Common NvsimClient interface — both WasmClient and WsClient implement it.
|
||||
* Dashboard binds to this interface and never to a concrete client.
|
||||
* Aligns with ADR-092 §5.2.
|
||||
*/
|
||||
|
||||
export interface PipelineConfigJson {
|
||||
digitiser?: {
|
||||
f_s_hz: number;
|
||||
f_mod_hz: number;
|
||||
lp_cutoff_hz?: number;
|
||||
};
|
||||
sensor?: {
|
||||
gamma_fwhm_hz?: number;
|
||||
t1_s?: number;
|
||||
t2_s?: number;
|
||||
t2_star_s?: number;
|
||||
contrast?: number;
|
||||
n_spins?: number;
|
||||
n_centers?: number;
|
||||
shot_noise_disabled?: boolean;
|
||||
};
|
||||
dt_s?: number | null;
|
||||
}
|
||||
|
||||
export interface SceneJson {
|
||||
dipoles: { position: [number, number, number]; moment: [number, number, number] }[];
|
||||
loops: {
|
||||
centre: [number, number, number];
|
||||
normal: [number, number, number];
|
||||
radius: number;
|
||||
current: number;
|
||||
n_segments: number;
|
||||
}[];
|
||||
ferrous: {
|
||||
position: [number, number, number];
|
||||
volume: number;
|
||||
susceptibility: number;
|
||||
}[];
|
||||
eddy: unknown[];
|
||||
sensors: [number, number, number][];
|
||||
ambient_field: [number, number, number];
|
||||
}
|
||||
|
||||
export interface MagFrameRecord {
|
||||
magic: number;
|
||||
version: number;
|
||||
flags: number;
|
||||
sensorId: number;
|
||||
tUs: bigint;
|
||||
bPt: [number, number, number];
|
||||
sigmaPt: [number, number, number];
|
||||
noiseFloorPtSqrtHz: number;
|
||||
temperatureK: number;
|
||||
raw: Uint8Array;
|
||||
}
|
||||
|
||||
export interface MagFrameBatch {
|
||||
frames: MagFrameRecord[];
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export type NvsimEvent =
|
||||
| { type: 'log'; level: 'info' | 'warn' | 'err' | 'dbg' | 'ok'; msg: string }
|
||||
| { type: 'witness'; hex: string }
|
||||
| { type: 'fps'; value: number }
|
||||
| { type: 'state'; running: boolean; t: number; framesEmitted: number };
|
||||
|
||||
export interface RunOpts { frames?: number }
|
||||
|
||||
/** One-shot pipeline run for "what would the sensor recover at this scene?"
|
||||
* use cases. Doesn't disturb the running pipeline. */
|
||||
export interface TransientRunResult {
|
||||
bRecoveredT: [number, number, number];
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: [number, number, number];
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}
|
||||
|
||||
export interface NvsimClient {
|
||||
loadScene(scene: SceneJson): Promise<void>;
|
||||
setConfig(cfg: PipelineConfigJson): Promise<void>;
|
||||
setSeed(seed: bigint): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
run(opts?: RunOpts): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
step(direction: 'fwd' | 'back', dtMs: number): Promise<void>;
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void;
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void;
|
||||
|
||||
generateWitness(samples: number): Promise<Uint8Array>;
|
||||
verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }>;
|
||||
exportProofBundle(): Promise<Blob>;
|
||||
runTransient(scene: SceneJson, config: PipelineConfigJson, seed: bigint, samples: number): Promise<TransientRunResult>;
|
||||
|
||||
buildId(): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Parse one MagFrame from a 60-byte slice. Layout matches `nvsim::frame`. */
|
||||
export function parseMagFrame(view: DataView, offset: number, raw: Uint8Array): MagFrameRecord {
|
||||
// v1 layout: magic(u32) | version(u16) | flags(u16) | sensor_id(u16) | _reserved(u16) |
|
||||
// t_us(u64) | b_pt[3](f32) | sigma_pt[3](f32) | noise_floor_pt_sqrt_hz(f32) |
|
||||
// temperature_k(f32) — 60 bytes total. All little-endian.
|
||||
const magic = view.getUint32(offset + 0, true);
|
||||
const version = view.getUint16(offset + 4, true);
|
||||
const flags = view.getUint16(offset + 6, true);
|
||||
const sensorId = view.getUint16(offset + 8, true);
|
||||
// skip 2 bytes reserved at offset+10
|
||||
const tUs = view.getBigUint64(offset + 12, true);
|
||||
const bx = view.getFloat32(offset + 20, true);
|
||||
const by = view.getFloat32(offset + 24, true);
|
||||
const bz = view.getFloat32(offset + 28, true);
|
||||
const sx = view.getFloat32(offset + 32, true);
|
||||
const sy = view.getFloat32(offset + 36, true);
|
||||
const sz = view.getFloat32(offset + 40, true);
|
||||
const noiseFloorPtSqrtHz = view.getFloat32(offset + 44, true);
|
||||
const temperatureK = view.getFloat32(offset + 48, true);
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
flags,
|
||||
sensorId,
|
||||
tUs,
|
||||
bPt: [bx, by, bz],
|
||||
sigmaPt: [sx, sy, sz],
|
||||
noiseFloorPtSqrtHz,
|
||||
temperatureK,
|
||||
raw: raw.subarray(offset, offset + 60),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFrameBatch(bytes: Uint8Array): MagFrameRecord[] {
|
||||
const frameSize = 60;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const out: MagFrameRecord[] = [];
|
||||
for (let off = 0; off + frameSize <= bytes.byteLength; off += frameSize) {
|
||||
out.push(parseMagFrame(view, off, bytes));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
/* Default `NvsimClient` implementation. Talks to the Web Worker that
|
||||
* hosts the nvsim WASM module. ADR-092 §5.4 + §6.3. */
|
||||
|
||||
import {
|
||||
type NvsimClient,
|
||||
type SceneJson,
|
||||
type PipelineConfigJson,
|
||||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
type TransientRunResult,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
interface PendingRequest<T = unknown> {
|
||||
resolve: (v: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface WasmBootInfo {
|
||||
buildVersion: string;
|
||||
frameMagic: number;
|
||||
frameBytes: number;
|
||||
expectedWitnessHex: string;
|
||||
}
|
||||
|
||||
export class WasmClient implements NvsimClient {
|
||||
private worker: Worker;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, PendingRequest<unknown>>();
|
||||
private frameSubs = new Set<(b: MagFrameBatch) => void>();
|
||||
private eventSubs = new Set<(e: NvsimEvent) => void>();
|
||||
private bootInfo: WasmBootInfo | null = null;
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
|
||||
this.worker.addEventListener('message', (ev) => this.onMessage(ev));
|
||||
this.worker.addEventListener('error', (e) =>
|
||||
this.eventSubs.forEach((s) => s({ type: 'log', level: 'err', msg: String(e.message) })),
|
||||
);
|
||||
}
|
||||
|
||||
private onMessage(ev: MessageEvent): void {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
if (m.type === 'frames') {
|
||||
const buf = m.batch as ArrayBuffer;
|
||||
const bytes = new Uint8Array(buf);
|
||||
const frames = parseFrameBatch(bytes);
|
||||
const batch: MagFrameBatch = { frames, bytes };
|
||||
this.frameSubs.forEach((s) => s(batch));
|
||||
const fps = m.fps as number;
|
||||
if (fps > 0) {
|
||||
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (m.type === 'state') {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({
|
||||
type: 'state',
|
||||
running: Boolean(m.running),
|
||||
t: 0,
|
||||
framesEmitted: Number(m.framesEmitted ?? 0),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (m.type === 'ready') {
|
||||
return;
|
||||
}
|
||||
if (m.type === 'err' && m.id == null) {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'err', msg: String(m.msg) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof m.id === 'number' && this.pending.has(m.id)) {
|
||||
const p = this.pending.get(m.id)!;
|
||||
this.pending.delete(m.id);
|
||||
if (m.type === 'err') p.reject(new Error(String(m.msg)));
|
||||
else p.resolve(m);
|
||||
}
|
||||
}
|
||||
|
||||
private rpc<T = unknown>(msg: Record<string, unknown>, transfer: Transferable[] = []): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
||||
this.worker.postMessage({ ...msg, id }, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
async boot(): Promise<WasmBootInfo> {
|
||||
if (this.bootInfo) return this.bootInfo;
|
||||
// Pass Vite's resolved BASE_URL so the worker can locate /nvsim-pkg/
|
||||
// under the same prefix the dashboard is served from (e.g. /RuView/nvsim/
|
||||
// on GitHub Pages, "/" in dev).
|
||||
const base = import.meta.env.BASE_URL ?? '/';
|
||||
const r = await this.rpc<{ buildVersion: string; frameMagic: number; frameBytes: number; expectedWitnessHex: string }>(
|
||||
{ type: 'boot', base },
|
||||
);
|
||||
this.bootInfo = {
|
||||
buildVersion: r.buildVersion,
|
||||
frameMagic: r.frameMagic,
|
||||
frameBytes: r.frameBytes,
|
||||
expectedWitnessHex: r.expectedWitnessHex,
|
||||
};
|
||||
return this.bootInfo;
|
||||
}
|
||||
|
||||
async loadScene(scene: SceneJson): Promise<void> {
|
||||
await this.rpc({ type: 'setScene', json: JSON.stringify(scene) });
|
||||
}
|
||||
|
||||
async setConfig(cfg: PipelineConfigJson): Promise<void> {
|
||||
await this.rpc({ type: 'setConfig', json: JSON.stringify(cfg) });
|
||||
}
|
||||
|
||||
async setSeed(seed: bigint): Promise<void> {
|
||||
await this.rpc({ type: 'setSeed', seed: Number(seed & 0xFFFFFFFFn) });
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this.rpc({ type: 'reset' });
|
||||
}
|
||||
|
||||
async run(_opts?: RunOpts): Promise<void> {
|
||||
await this.rpc({ type: 'run' });
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await this.rpc({ type: 'pause' });
|
||||
}
|
||||
|
||||
async step(_direction: 'fwd' | 'back', _dtMs: number): Promise<void> {
|
||||
await this.rpc({ type: 'step' });
|
||||
}
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void { this.eventSubs.add(cb); }
|
||||
|
||||
async generateWitness(samples: number): Promise<Uint8Array> {
|
||||
const r = await this.rpc<{ witness: ArrayBuffer; hex: string }>({ type: 'witnessGenerate', samples });
|
||||
return new Uint8Array(r.witness);
|
||||
}
|
||||
|
||||
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
|
||||
const buf = expected.slice().buffer;
|
||||
const r = await this.rpc<{ ok: boolean; actual: ArrayBuffer; actualHex: string }>(
|
||||
{ type: 'witnessVerify', samples: 256, expected: buf },
|
||||
[buf],
|
||||
);
|
||||
if (r.ok) return { ok: true };
|
||||
return { ok: false, actual: new Uint8Array(r.actual) };
|
||||
}
|
||||
|
||||
async runTransient(
|
||||
scene: SceneJson,
|
||||
config: PipelineConfigJson,
|
||||
seed: bigint,
|
||||
samples: number,
|
||||
): Promise<TransientRunResult> {
|
||||
const r = await this.rpc<{
|
||||
bRecoveredT: number[];
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: number[];
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}>({
|
||||
type: 'runTransient',
|
||||
scene: JSON.stringify(scene),
|
||||
config: JSON.stringify(config),
|
||||
seed: Number(seed & 0xFFFFFFFFn),
|
||||
samples,
|
||||
});
|
||||
return {
|
||||
bRecoveredT: [r.bRecoveredT[0], r.bRecoveredT[1], r.bRecoveredT[2]],
|
||||
bMagT: r.bMagT,
|
||||
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
|
||||
sigmaPt: [r.sigmaPt[0], r.sigmaPt[1], r.sigmaPt[2]],
|
||||
nFrames: r.nFrames,
|
||||
witnessHex: r.witnessHex,
|
||||
};
|
||||
}
|
||||
|
||||
async exportProofBundle(): Promise<Blob> {
|
||||
// Bundle = REFERENCE_SCENE_JSON + computed witness hex + version. Wraps
|
||||
// the same artifacts `Proof::generate` produces natively. ADR-092 §6.1.
|
||||
const w = await this.generateWitness(256);
|
||||
const hex = Array.from(w).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const info = this.bootInfo ?? (await this.boot());
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
kind: 'nvsim-proof-bundle',
|
||||
version: info.buildVersion,
|
||||
seed: '0x0000002A',
|
||||
nSamples: 256,
|
||||
witness: hex,
|
||||
expected: info.expectedWitnessHex,
|
||||
ok: hex === info.expectedWitnessHex,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return new Blob([manifest], { type: 'application/json' });
|
||||
}
|
||||
|
||||
async buildId(): Promise<string> {
|
||||
const r = await this.rpc<{ buildId: string }>({ type: 'buildId' });
|
||||
return r.buildId;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/* WebSocket transport client — talks to a `nvsim-server` Axum host
|
||||
* (v2/crates/nvsim-server). REST for control plane, binary WebSocket
|
||||
* for the MagFrame stream. Mirrors the WasmClient interface so the
|
||||
* dashboard can swap transports at runtime without code changes.
|
||||
*
|
||||
* ADR-092 §5.2 / §6.2.
|
||||
*/
|
||||
|
||||
import {
|
||||
type NvsimClient,
|
||||
type SceneJson,
|
||||
type PipelineConfigJson,
|
||||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
type TransientRunResult,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
interface HealthBody {
|
||||
nvsim_version: string;
|
||||
magic: number;
|
||||
frame_bytes: number;
|
||||
expected_witness_hex: string;
|
||||
}
|
||||
|
||||
interface VerifyBody {
|
||||
ok: boolean;
|
||||
actual_hex: string;
|
||||
expected_hex: string;
|
||||
}
|
||||
|
||||
interface WitnessBody {
|
||||
witness_hex: string;
|
||||
samples: number;
|
||||
seed_hex: string;
|
||||
}
|
||||
|
||||
export interface WsBootInfo {
|
||||
buildVersion: string;
|
||||
frameMagic: number;
|
||||
frameBytes: number;
|
||||
expectedWitnessHex: string;
|
||||
}
|
||||
|
||||
/** Convert a base URL (e.g. `http://host:7878`) to its WebSocket peer (`ws://host:7878`). */
|
||||
function toWsUrl(baseUrl: string): string {
|
||||
if (baseUrl.startsWith('ws://') || baseUrl.startsWith('wss://')) return baseUrl;
|
||||
return baseUrl.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
export class WsClient implements NvsimClient {
|
||||
private baseUrl: string;
|
||||
private wsUrl: string;
|
||||
private ws: WebSocket | null = null;
|
||||
private bootInfo: WsBootInfo | null = null;
|
||||
private frameSubs = new Set<(b: MagFrameBatch) => void>();
|
||||
private eventSubs = new Set<(e: NvsimEvent) => void>();
|
||||
private running = false;
|
||||
private framesEmitted = 0;
|
||||
private fpsLast = performance.now();
|
||||
private fpsCount = 0;
|
||||
|
||||
/** @param baseUrl e.g. `http://localhost:7878` */
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.wsUrl = `${toWsUrl(this.baseUrl)}/ws/stream`;
|
||||
}
|
||||
|
||||
private async json<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${path}: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async boot(): Promise<WsBootInfo> {
|
||||
if (this.bootInfo) return this.bootInfo;
|
||||
const h = await this.json<HealthBody>('/api/health');
|
||||
this.bootInfo = {
|
||||
buildVersion: h.nvsim_version,
|
||||
frameMagic: h.magic,
|
||||
frameBytes: h.frame_bytes,
|
||||
expectedWitnessHex: h.expected_witness_hex,
|
||||
};
|
||||
this.openWs();
|
||||
return this.bootInfo;
|
||||
}
|
||||
|
||||
private openWs(): void {
|
||||
if (this.ws) return;
|
||||
const ws = new WebSocket(this.wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'ok', msg: `ws/stream connected · ${this.wsUrl}` }),
|
||||
);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'warn', msg: 'ws/stream closed' }),
|
||||
);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'err', msg: `ws/stream error · ${this.wsUrl}` }),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!(ev.data instanceof ArrayBuffer)) return;
|
||||
const bytes = new Uint8Array(ev.data);
|
||||
const frames = parseFrameBatch(bytes);
|
||||
if (frames.length === 0) return;
|
||||
const batch: MagFrameBatch = { frames, bytes };
|
||||
this.frameSubs.forEach((s) => s(batch));
|
||||
this.framesEmitted += frames.length;
|
||||
this.fpsCount += frames.length;
|
||||
const now = performance.now();
|
||||
if (now - this.fpsLast >= 1000) {
|
||||
const fps = (this.fpsCount * 1000) / (now - this.fpsLast);
|
||||
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
|
||||
this.fpsLast = now;
|
||||
this.fpsCount = 0;
|
||||
}
|
||||
};
|
||||
this.ws = ws;
|
||||
}
|
||||
|
||||
async loadScene(scene: SceneJson): Promise<void> {
|
||||
await this.json('/api/scene', { method: 'PUT', body: JSON.stringify(scene) });
|
||||
}
|
||||
async setConfig(cfg: PipelineConfigJson): Promise<void> {
|
||||
await this.json('/api/config', { method: 'PUT', body: JSON.stringify(cfg) });
|
||||
}
|
||||
async setSeed(seed: bigint): Promise<void> {
|
||||
await this.json('/api/seed', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ seed_hex: '0x' + seed.toString(16).toUpperCase().padStart(16, '0') }),
|
||||
});
|
||||
}
|
||||
async reset(): Promise<void> {
|
||||
await this.json('/api/reset', { method: 'POST' });
|
||||
this.running = false;
|
||||
this.framesEmitted = 0;
|
||||
this.eventSubs.forEach((s) => s({ type: 'state', running: false, t: 0, framesEmitted: 0 }));
|
||||
}
|
||||
async run(_opts?: RunOpts): Promise<void> {
|
||||
await this.json('/api/run', { method: 'POST' });
|
||||
this.running = true;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'state', running: true, t: 0, framesEmitted: this.framesEmitted }),
|
||||
);
|
||||
}
|
||||
async pause(): Promise<void> {
|
||||
await this.json('/api/pause', { method: 'POST' });
|
||||
this.running = false;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'state', running: false, t: 0, framesEmitted: this.framesEmitted }),
|
||||
);
|
||||
}
|
||||
async step(direction: 'fwd' | 'back', dtMs: number): Promise<void> {
|
||||
await this.json('/api/step', { method: 'POST', body: JSON.stringify({ direction, dt_ms: dtMs }) });
|
||||
}
|
||||
|
||||
onFrames(cb: (b: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
|
||||
onEvent(cb: (e: NvsimEvent) => void): void { this.eventSubs.add(cb); }
|
||||
|
||||
async generateWitness(samples: number): Promise<Uint8Array> {
|
||||
const r = await this.json<WitnessBody>('/api/witness/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ samples }),
|
||||
});
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) out[i] = parseInt(r.witness_hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
|
||||
const expected_hex = Array.from(expected).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const r = await this.json<VerifyBody>('/api/witness/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expected_hex, samples: 256 }),
|
||||
});
|
||||
if (r.ok) return { ok: true };
|
||||
const actual = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) actual[i] = parseInt(r.actual_hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return { ok: false, actual };
|
||||
}
|
||||
|
||||
async exportProofBundle(): Promise<Blob> {
|
||||
const text = await fetch(`${this.baseUrl}/api/export-proof`, { method: 'POST' }).then((r) => r.text());
|
||||
return new Blob([text], { type: 'application/json' });
|
||||
}
|
||||
|
||||
async runTransient(
|
||||
scene: SceneJson,
|
||||
config: PipelineConfigJson,
|
||||
_seed: bigint,
|
||||
samples: number,
|
||||
): Promise<TransientRunResult> {
|
||||
// Server doesn't expose a transient route in V1 — the dashboard's
|
||||
// Ghost Murmur sandbox falls back to the WASM client when transport
|
||||
// is WS. Stub here returns a zero-result so the caller can detect.
|
||||
void scene; void config; void samples;
|
||||
return {
|
||||
bRecoveredT: [0, 0, 0],
|
||||
bMagT: 0,
|
||||
noiseFloorPtSqrtHz: 0,
|
||||
sigmaPt: [0, 0, 0],
|
||||
nFrames: 0,
|
||||
witnessHex: '(transient route not available in WS transport — V1 limitation)',
|
||||
};
|
||||
}
|
||||
|
||||
async buildId(): Promise<string> {
|
||||
const info = this.bootInfo ?? (await this.boot());
|
||||
return `nvsim@${info.buildVersion} (ws)`;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
/* Web Worker hosting the nvsim WASM module.
|
||||
*
|
||||
* Boots `/nvsim-pkg/nvsim.js`, instantiates `WasmPipeline`, then
|
||||
* postMessage-RPCs with the main thread. Frame batches are returned
|
||||
* as `ArrayBuffer` transfers so we don't pay a copy on the hot path.
|
||||
*
|
||||
* ADR-092 §5.4.
|
||||
*/
|
||||
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
const ws = self as unknown as DedicatedWorkerGlobalScope;
|
||||
|
||||
interface WasmPipelineApi {
|
||||
run(n: number): Uint8Array;
|
||||
runWithWitness(n: number): { frames: Uint8Array; witness: Uint8Array; frameCount: number };
|
||||
free?: () => void;
|
||||
}
|
||||
type WasmPipelineCtor = new (sceneJson: string, configJson: string, seed: number) => WasmPipelineApi;
|
||||
type WasmPipelineStatic = WasmPipelineCtor & {
|
||||
buildVersion(): string;
|
||||
frameMagic(): number;
|
||||
frameBytes(): number;
|
||||
};
|
||||
|
||||
interface TransientResult {
|
||||
bRecoveredT: Float64Array;
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: Float64Array;
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}
|
||||
|
||||
interface NvsimPkg {
|
||||
default: (input?: unknown) => Promise<unknown>;
|
||||
WasmPipeline: WasmPipelineStatic;
|
||||
referenceSceneJson: () => string;
|
||||
expectedReferenceWitnessHex: () => string;
|
||||
hexWitness: (b: Uint8Array) => string;
|
||||
referenceWitness: () => Uint8Array;
|
||||
runTransient: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
|
||||
}
|
||||
|
||||
let _WasmPipeline!: WasmPipelineStatic;
|
||||
let referenceSceneJson!: () => string;
|
||||
let expectedReferenceWitnessHex!: () => string;
|
||||
let hexWitness!: (b: Uint8Array) => string;
|
||||
let referenceWitness!: () => Uint8Array;
|
||||
let runTransient!: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
|
||||
|
||||
async function loadPkg(base: string): Promise<void> {
|
||||
// `base` is the dashboard's BASE_URL injected by Vite, prefixed with the
|
||||
// origin so we get an absolute URL the dynamic import can resolve. In dev
|
||||
// this is "/", in prod under GitHub Pages it's "/RuView/nvsim/".
|
||||
const absoluteBase = new URL(base, ws.location.origin).href;
|
||||
const pkgUrl = new URL('nvsim-pkg/nvsim.js', absoluteBase).href;
|
||||
const pkg = (await import(/* @vite-ignore */ pkgUrl)) as NvsimPkg;
|
||||
await pkg.default();
|
||||
_WasmPipeline = pkg.WasmPipeline;
|
||||
referenceSceneJson = pkg.referenceSceneJson;
|
||||
expectedReferenceWitnessHex = pkg.expectedReferenceWitnessHex;
|
||||
hexWitness = pkg.hexWitness;
|
||||
referenceWitness = pkg.referenceWitness;
|
||||
runTransient = pkg.runTransient;
|
||||
}
|
||||
|
||||
let pipeline: WasmPipelineApi | null = null;
|
||||
let configJson = '';
|
||||
let sceneJson = '';
|
||||
let seed = BigInt(0xCAFEBABE);
|
||||
|
||||
let running = false;
|
||||
let timer: number | null = null;
|
||||
let framesEmitted = 0;
|
||||
let tStart = 0;
|
||||
|
||||
function ensureRebuild(): void {
|
||||
if (!sceneJson) sceneJson = referenceSceneJson();
|
||||
if (!configJson) {
|
||||
configJson = JSON.stringify({
|
||||
digitiser: { f_s_hz: 10000, f_mod_hz: 1000 },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: false,
|
||||
},
|
||||
dt_s: null,
|
||||
});
|
||||
}
|
||||
pipeline?.free?.();
|
||||
pipeline = new _WasmPipeline(sceneJson, configJson, Number(seed & 0xFFFFFFFFn));
|
||||
}
|
||||
|
||||
function post(msg: unknown, transfer: Transferable[] = []): void {
|
||||
// postMessage Transferable overload: pass transfer list as 2nd arg
|
||||
(ws.postMessage as (msg: unknown, t: Transferable[]) => void)(msg, transfer);
|
||||
}
|
||||
|
||||
function startTimer(): void {
|
||||
if (timer !== null) return;
|
||||
tStart = performance.now();
|
||||
framesEmitted = 0;
|
||||
const tick = (): void => {
|
||||
if (!running || !pipeline) return;
|
||||
// Per-tick: simulate 32 frames; push as one batch.
|
||||
const n = 32;
|
||||
const bytes = pipeline.run(n);
|
||||
framesEmitted += n;
|
||||
const elapsed = (performance.now() - tStart) / 1000;
|
||||
const fps = elapsed > 0 ? framesEmitted / elapsed : 0;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: n, fps, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
timer = ws.setTimeout(tick, 16);
|
||||
};
|
||||
timer = ws.setTimeout(tick, 0);
|
||||
}
|
||||
|
||||
function stopTimer(): void {
|
||||
if (timer !== null) {
|
||||
ws.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', async (ev: MessageEvent): Promise<void> => {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
try {
|
||||
switch (m.type) {
|
||||
case 'boot': {
|
||||
const base = (m.base as string | undefined) ?? '/';
|
||||
await loadPkg(base);
|
||||
ensureRebuild();
|
||||
post({
|
||||
type: 'booted',
|
||||
id: m.id,
|
||||
buildVersion: _WasmPipeline.buildVersion(),
|
||||
frameMagic: _WasmPipeline.frameMagic(),
|
||||
frameBytes: _WasmPipeline.frameBytes(),
|
||||
expectedWitnessHex: expectedReferenceWitnessHex(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'setScene': {
|
||||
sceneJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setConfig': {
|
||||
configJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setSeed': {
|
||||
seed = BigInt(m.seed as string | number | bigint);
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'reset': {
|
||||
stopTimer();
|
||||
running = false;
|
||||
ensureRebuild();
|
||||
framesEmitted = 0;
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'run': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
running = true;
|
||||
startTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: true, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'pause': {
|
||||
running = false;
|
||||
stopTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'step': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const bytes = pipeline!.run(1);
|
||||
framesEmitted += 1;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: 1, fps: 0, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'witnessGenerate': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const samples = (m.samples as number) ?? 256;
|
||||
const result = pipeline!.runWithWitness(samples) as {
|
||||
frames: Uint8Array;
|
||||
witness: Uint8Array;
|
||||
frameCount: number;
|
||||
};
|
||||
const hex = hexWitness(result.witness);
|
||||
post(
|
||||
{
|
||||
type: 'witness',
|
||||
id: m.id,
|
||||
witness: result.witness.buffer,
|
||||
hex,
|
||||
frameCount: result.frameCount,
|
||||
},
|
||||
[result.witness.buffer],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'witnessVerify': {
|
||||
// Verify always runs the *canonical* reference scene at seed=42, N=256
|
||||
// so the witness matches Proof::EXPECTED_WITNESS_HEX byte-for-byte.
|
||||
// The user's working scene/config/seed don't affect the witness.
|
||||
const expectedBuf = m.expected as ArrayBuffer;
|
||||
const expected = new Uint8Array(expectedBuf);
|
||||
const actual = referenceWitness();
|
||||
let ok = actual.length === expected.length;
|
||||
if (ok) {
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (actual[i] !== expected[i]) { ok = false; break; }
|
||||
}
|
||||
}
|
||||
const actualBuf = actual.slice().buffer;
|
||||
post(
|
||||
{
|
||||
type: 'verify',
|
||||
id: m.id,
|
||||
ok,
|
||||
actual: actualBuf,
|
||||
actualHex: hexWitness(actual),
|
||||
},
|
||||
[actualBuf],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'runTransient': {
|
||||
const sceneJson = m.scene as string;
|
||||
const configJson = m.config as string;
|
||||
const seed = (m.seed as number) ?? 0;
|
||||
const samples = (m.samples as number) ?? 64;
|
||||
const r = runTransient(sceneJson, configJson, seed, samples);
|
||||
post({
|
||||
type: 'transient',
|
||||
id: m.id,
|
||||
bRecoveredT: Array.from(r.bRecoveredT),
|
||||
bMagT: r.bMagT,
|
||||
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
|
||||
sigmaPt: Array.from(r.sigmaPt),
|
||||
nFrames: r.nFrames,
|
||||
witnessHex: r.witnessHex,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'buildId': {
|
||||
post({
|
||||
type: 'buildId',
|
||||
id: m.id,
|
||||
buildId: `nvsim@${_WasmPipeline.buildVersion()}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
post({ type: 'err', id: m.id, msg: `unknown op ${m.type}` });
|
||||
}
|
||||
} catch (e) {
|
||||
post({ type: 'err', id: m.id, msg: (e as Error).message ?? String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: 'ready' });
|
||||
@@ -1,56 +0,0 @@
|
||||
/* axe-core accessibility smoke against the built dashboard.
|
||||
* Closes ADR-092 §11.5 — formal axe scan.
|
||||
*
|
||||
* Runs against `npm run preview` (Vite preview server). Validates each
|
||||
* primary view (home / scene / apps / inspector / witness / ghost-murmur)
|
||||
* and asserts 0 critical/serious violations.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
const VIEWS = ['home', 'scene', 'apps', 'inspector', 'witness', 'ghost-murmur'] as const;
|
||||
|
||||
test.describe('axe-core a11y smoke', () => {
|
||||
for (const view of VIEWS) {
|
||||
test(`view: ${view}`, async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Dismiss the welcome modal if it auto-shows.
|
||||
await page.evaluate(() => {
|
||||
const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot;
|
||||
const ob = sr.querySelector('nv-onboarding') as HTMLElement | null;
|
||||
if (ob?.hasAttribute('open')) {
|
||||
(ob.shadowRoot?.querySelector('.skip') as HTMLElement | null)?.click();
|
||||
}
|
||||
});
|
||||
// Navigate to the view via the rail button (except for home which is default).
|
||||
if (view !== 'home') {
|
||||
await page.evaluate((v) => {
|
||||
const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot;
|
||||
const rail = sr.querySelector('nv-rail') as HTMLElement & { shadowRoot: ShadowRoot };
|
||||
const btn = rail.shadowRoot.querySelector(`button[data-id=${v}-btn]`) as HTMLElement | null;
|
||||
btn?.click();
|
||||
}, view);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.options({ runOnly: ['wcag2a', 'wcag2aa'] })
|
||||
.analyze();
|
||||
|
||||
const critical = results.violations.filter((v) => v.impact === 'critical');
|
||||
const serious = results.violations.filter((v) => v.impact === 'serious');
|
||||
|
||||
// Logging the violation summary makes CI failures readable.
|
||||
if (critical.length || serious.length) {
|
||||
for (const v of [...critical, ...serious]) {
|
||||
console.error(`[${view}] ${v.impact} · ${v.id} · ${v.help}`);
|
||||
for (const node of v.nodes) console.error(` ${node.target.join(' >> ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(critical.length, 'no critical violations').toBe(0);
|
||||
expect(serious.length, 'no serious violations').toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitOverride": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"useDefineForClassFields": false,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "public/nvsim-pkg"]
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// Dashboard for ADR-092 — Vite + Lit + WASM in a Web Worker.
|
||||
// Hosted at /RuView/nvsim/ on GitHub Pages; base path is configurable
|
||||
// via NVSIM_BASE so local dev (npm run dev) stays at "/".
|
||||
const base = (globalThis as { process?: { env?: { NVSIM_BASE?: string } } }).process?.env?.NVSIM_BASE ?? '/';
|
||||
|
||||
export default defineConfig({
|
||||
base,
|
||||
publicDir: 'public',
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: [
|
||||
'nvsim-pkg/nvsim.js',
|
||||
'nvsim-pkg/nvsim_bg.wasm',
|
||||
],
|
||||
manifest: {
|
||||
name: 'nvsim — NV-Diamond Magnetometer Simulator',
|
||||
short_name: 'nvsim',
|
||||
description: 'Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs.',
|
||||
theme_color: '#0d1117',
|
||||
background_color: '#0d1117',
|
||||
display: 'standalone',
|
||||
scope: base,
|
||||
start_url: base,
|
||||
icons: [
|
||||
{
|
||||
src: 'icon-192.svg',
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
{
|
||||
src: 'icon-512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,wasm,woff,woff2}'],
|
||||
// WASM is large; bump the precache size budget so workbox doesn't
|
||||
// skip nvsim_bg.wasm.
|
||||
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
lit: ['lit'],
|
||||
signals: ['@preact/signals-core'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
fs: {
|
||||
allow: ['..', '.'],
|
||||
},
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
{"type": "metadata", "name": "ruview-clone-traffic-history", "version": "1.0.0", "schema": "ruvector.rvf.jsonl/v1", "format": "github-traffic-snapshots", "repo": "ruvnet/RuView", "source": "GitHub Traffic API /repos/{repo}/traffic/{clones,views}", "policy": "GitHub retains only 14 days server-side; this file is the long-term record.", "segments": ["metadata", "clone_snapshot", "view_snapshot"], "created_at": "2026-05-19T23:16:22Z", "custom": {"cadence": "twice monthly (1st and 15th, ~14-day intervals)", "idempotency_key": "timestamp (per-day records de-duplicate across overlapping snapshot windows)"}}
|
||||
{"type": "clone_snapshot", "fetched_at": "2026-05-19T23:16:22Z", "window_count": 27887, "window_uniques": 6611, "per_day": [{"timestamp": "2026-05-05T00:00:00Z", "count": 620, "uniques": 218}, {"timestamp": "2026-05-06T00:00:00Z", "count": 477, "uniques": 232}, {"timestamp": "2026-05-07T00:00:00Z", "count": 685, "uniques": 268}, {"timestamp": "2026-05-08T00:00:00Z", "count": 703, "uniques": 276}, {"timestamp": "2026-05-09T00:00:00Z", "count": 352, "uniques": 184}, {"timestamp": "2026-05-10T00:00:00Z", "count": 205, "uniques": 151}, {"timestamp": "2026-05-11T00:00:00Z", "count": 1160, "uniques": 234}, {"timestamp": "2026-05-12T00:00:00Z", "count": 599, "uniques": 207}, {"timestamp": "2026-05-13T00:00:00Z", "count": 5141, "uniques": 1152}, {"timestamp": "2026-05-14T00:00:00Z", "count": 3420, "uniques": 972}, {"timestamp": "2026-05-15T00:00:00Z", "count": 1974, "uniques": 764}, {"timestamp": "2026-05-16T00:00:00Z", "count": 2917, "uniques": 617}, {"timestamp": "2026-05-17T00:00:00Z", "count": 6690, "uniques": 1169}, {"timestamp": "2026-05-18T00:00:00Z", "count": 2944, "uniques": 625}]}
|
||||
{"type": "view_snapshot", "fetched_at": "2026-05-19T23:16:22Z", "window_count": 162314, "window_uniques": 75464, "per_day": [{"timestamp": "2026-05-05T00:00:00Z", "count": 5540, "uniques": 2690}, {"timestamp": "2026-05-06T00:00:00Z", "count": 5111, "uniques": 2393}, {"timestamp": "2026-05-07T00:00:00Z", "count": 5585, "uniques": 2708}, {"timestamp": "2026-05-08T00:00:00Z", "count": 7004, "uniques": 3261}, {"timestamp": "2026-05-09T00:00:00Z", "count": 5395, "uniques": 2531}, {"timestamp": "2026-05-10T00:00:00Z", "count": 4761, "uniques": 2219}, {"timestamp": "2026-05-11T00:00:00Z", "count": 4275, "uniques": 2044}, {"timestamp": "2026-05-12T00:00:00Z", "count": 3466, "uniques": 1688}, {"timestamp": "2026-05-13T00:00:00Z", "count": 13561, "uniques": 8473}, {"timestamp": "2026-05-14T00:00:00Z", "count": 21867, "uniques": 12527}, {"timestamp": "2026-05-15T00:00:00Z", "count": 26182, "uniques": 14609}, {"timestamp": "2026-05-16T00:00:00Z", "count": 17406, "uniques": 8868}, {"timestamp": "2026-05-17T00:00:00Z", "count": 28444, "uniques": 14541}, {"timestamp": "2026-05-18T00:00:00Z", "count": 13717, "uniques": 7819}]}
|
||||
@@ -10,16 +10,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY archive/v1/requirements-lock.txt /app/requirements.txt
|
||||
COPY v1/requirements-lock.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& pip install --no-cache-dir websockets uvicorn fastapi
|
||||
|
||||
# Copy application code
|
||||
COPY archive/v1/ /app/v1/
|
||||
COPY v1/ /app/v1/
|
||||
COPY ui/ /app/ui/
|
||||
|
||||
# Copy sensing modules
|
||||
COPY archive/v1/src/sensing/ /app/v1/src/sensing/
|
||||
COPY v1/src/sensing/ /app/v1/src/sensing/
|
||||
|
||||
EXPOSE 8765
|
||||
EXPOSE 8080
|
||||
|
||||
+2
-21
@@ -8,8 +8,8 @@ FROM rust:1.85-bookworm AS builder
|
||||
WORKDIR /build
|
||||
|
||||
# Copy workspace files
|
||||
COPY v2/Cargo.toml v2/Cargo.lock ./
|
||||
COPY v2/crates/ ./crates/
|
||||
COPY rust-port/wifi-densepose-rs/Cargo.toml rust-port/wifi-densepose-rs/Cargo.lock ./
|
||||
COPY rust-port/wifi-densepose-rs/crates/ ./crates/
|
||||
|
||||
# Copy vendored RuVector crates
|
||||
COPY vendor/ruvector/ /build/vendor/ruvector/
|
||||
@@ -33,25 +33,6 @@ COPY --from=builder /build/target/release/sensing-server /app/sensing-server
|
||||
# Copy UI assets
|
||||
COPY ui/ /app/ui/
|
||||
|
||||
# Sanity-check the assets the runtime actually serves (regression guard for
|
||||
# #520/#514 — the published image must include the observatory and pose-fusion
|
||||
# dashboards, not just the legacy `index.html` set). Build fails if any of
|
||||
# these are missing, so a stale image can't be silently pushed.
|
||||
RUN set -e; \
|
||||
for f in /app/ui/index.html /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/viz.html; do \
|
||||
test -f "$f" || { echo "FATAL: missing UI asset $f"; exit 1; }; \
|
||||
done; \
|
||||
for d in /app/ui/observatory /app/ui/pose-fusion /app/ui/components /app/ui/services; do \
|
||||
test -d "$d" || { echo "FATAL: missing UI directory $d"; exit 1; }; \
|
||||
done; \
|
||||
test -x /app/sensing-server || { echo "FATAL: /app/sensing-server is not executable"; exit 1; }; \
|
||||
echo "image assets OK"
|
||||
|
||||
# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
|
||||
# set to enforce `Authorization: Bearer <token>` (see bearer_auth module, #443).
|
||||
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
|
||||
ENV RUVIEW_API_TOKEN=
|
||||
|
||||
# HTTP API
|
||||
EXPOSE 3000
|
||||
# WebSocket
|
||||
|
||||
@@ -9,18 +9,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000" # REST API
|
||||
- "3001:3001" # WebSocket
|
||||
# ESP32 UDP. On Linux/macOS this works with multiple ESP32 nodes out of
|
||||
# the box. On Docker Desktop for Windows, multi-source UDP is collapsed
|
||||
# to one source IP at the WSL/Hyper-V boundary, so all-but-one node's
|
||||
# frames are silently dropped (issue #374, #386).
|
||||
#
|
||||
# Windows workaround: change this to "5006:5005/udp" and run the host
|
||||
# relay so every datagram arrives from the same loopback source:
|
||||
#
|
||||
# python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
#
|
||||
# See docs/TROUBLESHOOTING.md §9 for details.
|
||||
- "5005:5005/udp"
|
||||
- "5005:5005/udp" # ESP32 UDP
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# CSI_SOURCE controls the data source for the sensing server.
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# RuView Troubleshooting Guide
|
||||
|
||||
Known issues and fixes from the rebase-to-upstream branch (upstream #301).
|
||||
|
||||
---
|
||||
|
||||
## 1. Node not appearing in /api/v1/nodes
|
||||
|
||||
**Symptom:** ESP32-S3 node associates with WiFi, LED blinks, but no CSI frames arrive at the server. Node missing from `/api/v1/spatial/nodes`.
|
||||
|
||||
**Root cause:** After USB flash, the node enters a limping state where WiFi associates but the UDP CSI sender silently fails. The SoftAP + mDNS stack initializes but the CSI callback never fires.
|
||||
|
||||
**Fix:** Power cycle the node (unplug USB, wait 2s, replug). If that doesn't work, send DTR reset via serial: `python -m serial.tools.miniterm --dtr 0 COMx 115200` then Ctrl+C.
|
||||
|
||||
**Prevention:** Firmware 0.8.0+ includes a watchdog that detects zero CSI frames for 30s and triggers a software reset automatically. Nodes 1-10 are still on old firmware and lack this recovery (OTA-vs-BLE chicken-and-egg; see issue #6).
|
||||
|
||||
---
|
||||
|
||||
## 2. Person count stuck at 1
|
||||
|
||||
**Symptom:** `estimated_persons` always returns 1 regardless of how many people are in the room.
|
||||
|
||||
**Root cause (ADR-044):** Eight converging bugs:
|
||||
1. `score_to_person_count` had a ceiling of 3
|
||||
2. `fuse_multi_node_features` used `.max()` instead of sum — N identical readings collapsed to 1
|
||||
3. Four `.max(1)` clamps forced minimum count to 1 even when absent
|
||||
4. `field_model.estimate_occupancy` capped at `.min(3)`
|
||||
5. Normalization saturated (dividing by hardcoded thresholds instead of adaptive p95)
|
||||
6. No field model auto-calibration — eigenvalue path never activated
|
||||
7. Vitals-path clamps were asymmetric
|
||||
8. Tomography produced one blob (CC=1) so dedup gave wrong count
|
||||
|
||||
**Fix applied (Waves 1-3):**
|
||||
- Wave 1 (`9cc5f604`): ceiling 3→10, `.max()` → sum/3 aggregation, softened `.max(1)` clamps
|
||||
- Wave 2 (`306f1262`): RollingP95 adaptive normalization, field_model 30s auto-calibration, vitals clamp symmetry
|
||||
- Wave 3 (`c3df375a`+`0d4bfb09`+`6ac70ddf`): CC flood-fill infrastructure, lambda 0.1→5.0, threshold 0.01→0.15, CC>1 gate
|
||||
|
||||
**Current state:** `estimated_persons` = 6-8 for 5 bodies (3 humans + 2 dogs). Overcounts because the sum/3 dedup factor is a guess. Tomography still produces one blob (CC=1), so the CC path doesn't activate. Runtime-configurable lambda would help tune without redeployment.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heart rate / breathing rate jitter
|
||||
|
||||
**Symptom:** HR and BR readings jump wildly between frames. BR CV was 23.3%, HR CV was 12.9%.
|
||||
|
||||
**Root cause (ADR-045):** 11 ESP32 nodes each compute independent vitals. The server used last-write-wins — whichever node's UDP packet arrived last overwrote the global vitals. At ~20 fps per node, this meant vitals randomly interleaved from different vantage points every 50ms.
|
||||
|
||||
**Fix applied (`46fbc061`):** Best-node selection. Each node's vitals are smoothed independently via median filter + EMA. The node with the highest combined `breathing_confidence + heartbeat_confidence` is selected as authoritative. Result: BR CV 23.3% → 12.6%, HR CV 12.9% → 11.6%.
|
||||
|
||||
**Known limitation:** The `wifi-densepose-vitals` crate has a superior 4-stage pipeline (bandpass → Hilbert envelope → autocorrelation → peak detection) but is not yet wired into the sensing server. The current `VitalSignDetector` uses a simpler FFT approach with 4 BPM frequency resolution.
|
||||
|
||||
---
|
||||
|
||||
## 4. Signal quality shows 50% always
|
||||
|
||||
**Symptom:** The dashboard signal quality gauge was always stuck at ~50%.
|
||||
|
||||
**Root cause:** Signal quality was a hardcoded placeholder value, not derived from actual CSI data.
|
||||
|
||||
**Fix applied:** ADR-044 Wave 2 replaced the fake gauge with RollingP95 adaptive normalization. The UI honesty pass (`b2070ab4`) added beta tags to unvalidated metrics, replaced the fake gauge with per-node pill indicators, and surfaced the actual per-node signal data.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dashboard freezes every 2-4 seconds
|
||||
|
||||
**Symptom:** The spatial view and dashboard would freeze, then reconnect, creating a visible stutter every 2-4 seconds.
|
||||
|
||||
**Root cause:** The WebSocket broadcast channel's `recv()` returned `Err(Lagged)` when a client fell behind. The server treated this as a fatal error and dropped the connection. The client immediately reconnected, creating a connect/disconnect cycle.
|
||||
|
||||
**Fix applied (`581daf4f`):**
|
||||
- Server: `Lagged` error → `continue` (skip missed frames instead of disconnecting)
|
||||
- Server: 30s ping/pong keepalive to prevent Caddy proxy idle timeouts
|
||||
- Result: 154 frames over 8 seconds sustained, zero disconnects
|
||||
|
||||
---
|
||||
|
||||
## 6. OTA update crashes at 59%
|
||||
|
||||
**Symptom:** OTA firmware update via `/api/v1/firmware/download` progresses to ~59% then the node crashes with `StoreProhibited` on Core 1.
|
||||
|
||||
**Root cause:** NimBLE BLE advertising/scanning runs on Core 1. During OTA, the HTTP client also runs on Core 1. BLE and OTA compete for stack space, and the BLE scan callback triggers a memory access violation during the OTA write.
|
||||
|
||||
**Fix:**
|
||||
1. Stop NimBLE advertising and scanning before calling `esp_https_ota_begin()`
|
||||
2. Increase httpd stack from 4KB to 8KB (`CONFIG_HTTPD_MAX_REQ_HDR_LEN` and task stack)
|
||||
3. Resume BLE after OTA completes or fails
|
||||
|
||||
**Caveat:** Nodes running old firmware (1-10) can't receive this fix via OTA because the crash happens during the OTA itself. These nodes must be USB-flashed with firmware 0.8.0+ first, then future OTA updates will work. Node 11 was USB-flashed with the watchdog firmware and can receive OTA updates.
|
||||
|
||||
---
|
||||
|
||||
## 7. Can't SSH to babycube via LAN
|
||||
|
||||
**Symptom:** `ssh thyhack@10.0.10.10` hangs at banner exchange. Ping works, TCP port 22 is open, but SSH never completes the handshake.
|
||||
|
||||
**Workaround:** Use the Tailscale IP instead:
|
||||
```
|
||||
ssh thyhack@100.90.238.87
|
||||
```
|
||||
|
||||
**Not the cause:** CrowdSec. The 10.0.0.0/8 range is whitelisted in CrowdSec (`cscli decisions list` shows no active decisions for LAN IPs). The banner hang occurs before any authentication attempt, so it's not a firewall block.
|
||||
|
||||
**Suspected cause:** Unknown. Possibly MTU/fragmentation issue on the LAN segment, or a network stack bug in the babycube's NIC driver. The Tailscale overlay network (WireGuard UDP) bypasses whatever is causing the LAN TCP issue.
|
||||
|
||||
---
|
||||
|
||||
## 8. Right USB-C port doesn't work on some ESP32-S3 boards
|
||||
|
||||
**Symptom:** Plugging into the right USB-C port (when facing the board with USB-C toward you) shows no serial device on the host.
|
||||
|
||||
**Fix:** Use the left USB-C port. On most ESP32-S3-DevKitC boards, the left port is the USB-to-UART bridge (CP2102/CH340) used for flashing and serial monitor. The right port is the native USB (USB-JTAG) which requires different drivers and isn't used by the RuView firmware.
|
||||
|
||||
---
|
||||
|
||||
## 9. Docker Desktop on Windows drops UDP from multiple ESP32 nodes
|
||||
|
||||
**Symptom:** Two or more ESP32 nodes are flashed, provisioned, and visibly transmit on the network — `tcpdump`/Wireshark on the Windows host shows datagrams from every node — but inside the Docker container only one source IP arrives. `/api/v1/sensing/latest` shows a single node and the live UI freezes or only tracks one body. Reported in #374 (4-node bench) and reproduced in #386 (6-node demo, RuView v0.7.0).
|
||||
|
||||
**Root cause:** Docker Desktop on Windows runs the engine inside a WSL2 / Hyper-V VM. Inbound UDP from the host LAN is forwarded through `vpnkit` / `vEthernet` and the multi-source-IP datagrams are demultiplexed onto a single virtual socket. The first source-IP "wins"; subsequent unique sources are silently dropped at the VM boundary. This is a Docker Desktop limitation, not a sensing-server bug — `host.docker.internal` and `--network host` do not help (host networking is not implemented for the Linux engine on Windows).
|
||||
|
||||
**Fix:** Run the bundled UDP relay on the host so every forwarded datagram arrives from the same loopback source IP, which Docker passes through unchanged.
|
||||
|
||||
```powershell
|
||||
# 1. Start the relay (PowerShell or any terminal)
|
||||
python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
|
||||
# 2. Edit docker/docker-compose.yml — change the ESP32 UDP mapping from
|
||||
# - "5005:5005/udp"
|
||||
# to
|
||||
# - "5006:5005/udp"
|
||||
|
||||
# 3. Bring the stack up
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
```
|
||||
|
||||
ESP32 nodes still target the host on `--target-ip <host>:5005` — no firmware re-provisioning is needed. The relay is `scripts/udp-relay.py` (stdlib only, no extra deps). Verify with `--verbose` that each node's source IP appears at least once before forwarding stabilises on a single ephemeral relay port.
|
||||
|
||||
**Prevention:** Linux and macOS hosts are unaffected; the relay only needs to run on Docker Desktop for Windows. If Docker Desktop ships per-source UDP forwarding (tracked at [docker/for-win#1144](https://github.com/docker/for-win/issues/1144) and related), this workaround can be retired.
|
||||
|
||||
**Prior art:** PR #413 (`txhno`) proposed a docs-only writeup of the same workaround; this entry supersedes it.
|
||||
|
||||
---
|
||||
|
||||
## 10. `404` on the visualization page when running sensing-server
|
||||
|
||||
**Symptom:** `sensing-server` starts cleanly, logs `HTTP server listening on http://localhost:3000`, but loading `http://localhost:3000/` (or `/ui/index.html`) returns `404 Not Found`. Reported in #188.
|
||||
|
||||
**Root cause:** The default `--ui-path ../../ui` is resolved relative to the binary's *current working directory*, not the binary location. When the binary is launched from anywhere other than `crates/wifi-densepose-sensing-server/`, the relative path doesn't reach the UI assets and Axum's static file handler returns 404.
|
||||
|
||||
**Fix:** Pass an absolute UI path, run the binary from the crate directory, or use the Docker image (which bundles the UI under `/app/ui`).
|
||||
|
||||
```bash
|
||||
# Option A — absolute path (recommended for production)
|
||||
sensing-server --source esp32 --udp-port 5005 --http-port 3000 \
|
||||
--ws-port 3001 --ui-path /absolute/path/to/ui
|
||||
|
||||
# Option B — run from the crate dir (works for local dev / cargo run)
|
||||
cd v2/crates/wifi-densepose-sensing-server
|
||||
cargo run -- --source esp32
|
||||
|
||||
# Option C — Docker (no path config needed)
|
||||
docker compose -f docker/docker-compose.yml up sensing-server
|
||||
```
|
||||
|
||||
**Prevention:** Track future work in #188 to fall back to a path resolved relative to the executable when the cwd-relative path doesn't exist, so the binary works regardless of where it's launched.
|
||||
|
||||
---
|
||||
|
||||
## 11. Boot loop on `--edge-tier 1` or `--edge-tier 2`
|
||||
|
||||
**Symptom:** ESP32-S3 boots normally with `--edge-tier 0`, but flashing the same firmware with `--edge-tier 1` or `2` produces a boot loop. Serial output reaches `cpu_start` and `heap_init`, then resets repeatedly. Reported in #438 against firmware `v0.4.3.1-esp32-3-g66e2fa083-dir`.
|
||||
|
||||
**Root cause:** Edge tiers 1 and 2 enable the on-device DSP pipeline on Core 1. In the affected build, the `edge_dsp` task ran a tight per-frame loop without yielding, so the FreeRTOS task watchdog tripped on Core 1 and panicked. Tier 0 is passthrough only and doesn't activate the pipeline, so the watchdog never fires there.
|
||||
|
||||
**Fix:** Flash the [v0.4.3.1-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) release or later — the DSP task yield fixes have shipped on `main` since the build in the report.
|
||||
|
||||
```bash
|
||||
# Verify what version you're on (look for "App version" in serial output on boot)
|
||||
python -m serial.tools.miniterm COM7 115200
|
||||
# Expect: "App version: v0.4.3.1-esp32" or higher
|
||||
```
|
||||
|
||||
If the boot loop persists on a release build, capture a full serial trace including the watchdog backtrace and reopen #438 with the new build hash.
|
||||
@@ -35,7 +35,7 @@ git checkout 96b01008
|
||||
### Step 2: Rust Workspace — Full Test Suite
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@ ls firmware/esp32-csi-node/build/*.bin 2>/dev/null || echo "App binary in build/
|
||||
### Step 6: Verify ADR-018 Binary Frame Parser
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test -p wifi-densepose-hardware --no-default-features
|
||||
```
|
||||
|
||||
@@ -133,7 +133,7 @@ cargo test -p wifi-densepose-train --no-default-features
|
||||
### Step 9: Verify Python Proof System
|
||||
|
||||
```bash
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py
|
||||
```
|
||||
|
||||
**Expected:** PASS (hash `8c0680d7...` matches `expected_features.sha256`).
|
||||
|
||||
@@ -216,4 +216,4 @@ full = ["mincut-matching", "attn-mincut", "temporal-compress", "solver-interpola
|
||||
- [Elastic Weight Consolidation](https://arxiv.org/abs/1612.00796)
|
||||
- [Raft Consensus](https://raft.github.io/raft.pdf)
|
||||
- [ML-DSA (FIPS 204)](https://csrc.nist.gov/pubs/fips/204/final)
|
||||
- [WiFi-DensePose Rust ADR-001: Workspace Structure](../v2/docs/adr/ADR-001-workspace-structure.md)
|
||||
- [WiFi-DensePose Rust ADR-001: Workspace Structure](../rust-port/wifi-densepose-rs/docs/adr/ADR-001-workspace-structure.md)
|
||||
|
||||
@@ -20,31 +20,31 @@ The following code paths produce fake data **in the default configuration** or a
|
||||
|
||||
| File | Line | Issue | Impact |
|
||||
|------|------|-------|--------|
|
||||
| `archive/v1/src/core/csi_processor.py` | 390 | `doppler_shift = np.random.rand(10) # Placeholder` | **Real feature extractor returns random Doppler** - kills credibility of entire feature pipeline |
|
||||
| `archive/v1/src/hardware/csi_extractor.py` | 83-84 | `amplitude = np.random.rand(...)` in CSI extraction fallback | Random data silently substituted when parsing fails |
|
||||
| `archive/v1/src/hardware/csi_extractor.py` | 129-135 | `_parse_atheros()` returns `np.random.rand()` with comment "placeholder implementation" | Named as if it parses real data, actually random |
|
||||
| `archive/v1/src/hardware/router_interface.py` | 211-212 | `np.random.rand(3, 56)` in fallback path | Silent random fallback |
|
||||
| `archive/v1/src/services/pose_service.py` | 431 | `mock_csi = np.random.randn(64, 56, 3) # Mock CSI data` | Mock CSI in production code path |
|
||||
| `archive/v1/src/services/pose_service.py` | 293-356 | `_generate_mock_poses()` with `random.randint` throughout | Entire mock pose generator in service layer |
|
||||
| `archive/v1/src/services/pose_service.py` | 489-607 | Multiple `random.randint` for occupancy, historical data | Fake statistics that look real in API responses |
|
||||
| `archive/v1/src/api/dependencies.py` | 82, 408 | "return a mock user for development" | Auth bypass in default path |
|
||||
| `v1/src/core/csi_processor.py` | 390 | `doppler_shift = np.random.rand(10) # Placeholder` | **Real feature extractor returns random Doppler** - kills credibility of entire feature pipeline |
|
||||
| `v1/src/hardware/csi_extractor.py` | 83-84 | `amplitude = np.random.rand(...)` in CSI extraction fallback | Random data silently substituted when parsing fails |
|
||||
| `v1/src/hardware/csi_extractor.py` | 129-135 | `_parse_atheros()` returns `np.random.rand()` with comment "placeholder implementation" | Named as if it parses real data, actually random |
|
||||
| `v1/src/hardware/router_interface.py` | 211-212 | `np.random.rand(3, 56)` in fallback path | Silent random fallback |
|
||||
| `v1/src/services/pose_service.py` | 431 | `mock_csi = np.random.randn(64, 56, 3) # Mock CSI data` | Mock CSI in production code path |
|
||||
| `v1/src/services/pose_service.py` | 293-356 | `_generate_mock_poses()` with `random.randint` throughout | Entire mock pose generator in service layer |
|
||||
| `v1/src/services/pose_service.py` | 489-607 | Multiple `random.randint` for occupancy, historical data | Fake statistics that look real in API responses |
|
||||
| `v1/src/api/dependencies.py` | 82, 408 | "return a mock user for development" | Auth bypass in default path |
|
||||
|
||||
#### Moderate Severity (mock gated behind flags but confusing)
|
||||
|
||||
| File | Line | Issue |
|
||||
|------|------|-------|
|
||||
| `archive/v1/src/config/settings.py` | 144-145 | `mock_hardware=False`, `mock_pose_data=False` defaults - correct, but mock infrastructure exists |
|
||||
| `archive/v1/src/core/router_interface.py` | 27-300 | 270+ lines of mock data generation infrastructure in production code |
|
||||
| `archive/v1/src/services/pose_service.py` | 84-88 | Silent conditional: `if not self.settings.mock_pose_data` with no logging of real-mode |
|
||||
| `archive/v1/src/services/hardware_service.py` | 72-375 | Interleaved mock/real paths throughout |
|
||||
| `v1/src/config/settings.py` | 144-145 | `mock_hardware=False`, `mock_pose_data=False` defaults - correct, but mock infrastructure exists |
|
||||
| `v1/src/core/router_interface.py` | 27-300 | 270+ lines of mock data generation infrastructure in production code |
|
||||
| `v1/src/services/pose_service.py` | 84-88 | Silent conditional: `if not self.settings.mock_pose_data` with no logging of real-mode |
|
||||
| `v1/src/services/hardware_service.py` | 72-375 | Interleaved mock/real paths throughout |
|
||||
|
||||
#### Low Severity (placeholders/TODOs)
|
||||
|
||||
| File | Line | Issue |
|
||||
|------|------|-------|
|
||||
| `archive/v1/src/core/router_interface.py` | 198 | "Collect real CSI data from router (placeholder implementation)" |
|
||||
| `archive/v1/src/api/routers/health.py` | 170-171 | `uptime_seconds = 0.0 # TODO` |
|
||||
| `archive/v1/src/services/pose_service.py` | 739 | `"uptime_seconds": 0.0 # TODO` |
|
||||
| `v1/src/core/router_interface.py` | 198 | "Collect real CSI data from router (placeholder implementation)" |
|
||||
| `v1/src/api/routers/health.py` | 170-171 | `uptime_seconds = 0.0 # TODO` |
|
||||
| `v1/src/services/pose_service.py` | 739 | `"uptime_seconds": 0.0 # TODO` |
|
||||
|
||||
### Root Cause Analysis
|
||||
|
||||
@@ -119,7 +119,7 @@ def _parse_atheros(self, raw_data: bytes) -> CSIData:
|
||||
**All mock code moves to a dedicated module. Default execution NEVER touches mock paths.**
|
||||
|
||||
```
|
||||
archive/v1/src/
|
||||
v1/src/
|
||||
├── core/
|
||||
│ ├── csi_processor.py # Real processing only
|
||||
│ └── router_interface.py # Real hardware interface only
|
||||
@@ -157,7 +157,7 @@ if MOCK_MODE:
|
||||
A small real CSI capture file + one-command verification pipeline:
|
||||
|
||||
```
|
||||
archive/v1/data/proof/
|
||||
v1/data/proof/
|
||||
├── README.md # How to verify
|
||||
├── sample_csi_capture.bin # Real CSI data (1 second, ~50 KB)
|
||||
├── sample_csi_capture_meta.json # Capture metadata (hardware, env)
|
||||
@@ -172,7 +172,7 @@ archive/v1/data/proof/
|
||||
"""Verify WiFi-DensePose pipeline produces deterministic output from real CSI data.
|
||||
|
||||
Usage:
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
Expected output:
|
||||
PASS: Pipeline output matches expected hash
|
||||
@@ -265,13 +265,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
WORKDIR /app
|
||||
|
||||
# Pinned requirements (not a reference to missing file)
|
||||
COPY archive/v1/requirements-lock.txt ./requirements.txt
|
||||
COPY v1/requirements-lock.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY archive/v1/ ./v1/
|
||||
COPY v1/ ./v1/
|
||||
|
||||
# Proof of reality: verify pipeline on build
|
||||
RUN cd archive/v1 && python data/proof/verify.py
|
||||
RUN cd v1 && python data/proof/verify.py
|
||||
|
||||
EXPOSE 8000
|
||||
# Default: REAL mode (mock requires explicit opt-in)
|
||||
@@ -281,7 +281,7 @@ CMD ["uvicorn", "v1.src.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
**Key change**: `RUN python data/proof/verify.py` **during build** means the Docker image cannot be created unless the pipeline produces correct output from real CSI data.
|
||||
|
||||
**Requirements lockfile** (`archive/v1/requirements-lock.txt`):
|
||||
**Requirements lockfile** (`v1/requirements-lock.txt`):
|
||||
```
|
||||
# Core (required)
|
||||
fastapi==0.115.6
|
||||
@@ -307,9 +307,9 @@ name: Verify Signal Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
paths: ['archive/v1/src/**', 'archive/v1/data/proof/**']
|
||||
paths: ['v1/src/**', 'v1/data/proof/**']
|
||||
pull_request:
|
||||
paths: ['archive/v1/src/**']
|
||||
paths: ['v1/src/**']
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
@@ -322,11 +322,11 @@ jobs:
|
||||
- name: Install minimal deps
|
||||
run: pip install numpy scipy pydantic pydantic-settings
|
||||
- name: Verify pipeline determinism
|
||||
run: python archive/v1/data/proof/verify.py
|
||||
run: python v1/data/proof/verify.py
|
||||
- name: Verify no random in production paths
|
||||
run: |
|
||||
# Fail if np.random appears in production code (not in testing/)
|
||||
! grep -r "np\.random\.\(rand\|randn\|randint\)" archive/v1/src/ \
|
||||
! grep -r "np\.random\.\(rand\|randn\|randint\)" v1/src/ \
|
||||
--include="*.py" \
|
||||
--exclude-dir=testing \
|
||||
|| (echo "FAIL: np.random found in production code" && exit 1)
|
||||
@@ -336,23 +336,23 @@ jobs:
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `archive/v1/src/core/csi_processor.py:390` | **Replace** | Real Doppler extraction from temporal CSI history |
|
||||
| `archive/v1/src/hardware/csi_extractor.py:83-84` | **Replace** | Hard error with descriptive message when parsing fails |
|
||||
| `archive/v1/src/hardware/csi_extractor.py:129-135` | **Replace** | Real Atheros CSI parser or hard error with hardware instructions |
|
||||
| `archive/v1/src/hardware/router_interface.py:198-212` | **Replace** | Hard error for unimplemented hardware, or real `iwconfig` + CSI tool integration |
|
||||
| `archive/v1/src/services/pose_service.py:293-356` | **Move** | Move `_generate_mock_poses()` to `archive/v1/src/testing/mock_pose_generator.py` |
|
||||
| `archive/v1/src/services/pose_service.py:430-431` | **Remove** | Remove mock CSI generation from production path |
|
||||
| `archive/v1/src/services/pose_service.py:489-607` | **Replace** | Real statistics from database, or explicit "no data" response |
|
||||
| `archive/v1/src/core/router_interface.py:60-300` | **Move** | Move mock generator to `archive/v1/src/testing/mock_csi_generator.py` |
|
||||
| `archive/v1/src/api/dependencies.py:82,408` | **Replace** | Real auth check or explicit dev-mode bypass with logging |
|
||||
| `archive/v1/data/proof/` | **Create** | Proof bundle (sample capture + expected hash + verify script) |
|
||||
| `archive/v1/requirements-lock.txt` | **Create** | Pinned minimal dependencies |
|
||||
| `v1/src/core/csi_processor.py:390` | **Replace** | Real Doppler extraction from temporal CSI history |
|
||||
| `v1/src/hardware/csi_extractor.py:83-84` | **Replace** | Hard error with descriptive message when parsing fails |
|
||||
| `v1/src/hardware/csi_extractor.py:129-135` | **Replace** | Real Atheros CSI parser or hard error with hardware instructions |
|
||||
| `v1/src/hardware/router_interface.py:198-212` | **Replace** | Hard error for unimplemented hardware, or real `iwconfig` + CSI tool integration |
|
||||
| `v1/src/services/pose_service.py:293-356` | **Move** | Move `_generate_mock_poses()` to `v1/src/testing/mock_pose_generator.py` |
|
||||
| `v1/src/services/pose_service.py:430-431` | **Remove** | Remove mock CSI generation from production path |
|
||||
| `v1/src/services/pose_service.py:489-607` | **Replace** | Real statistics from database, or explicit "no data" response |
|
||||
| `v1/src/core/router_interface.py:60-300` | **Move** | Move mock generator to `v1/src/testing/mock_csi_generator.py` |
|
||||
| `v1/src/api/dependencies.py:82,408` | **Replace** | Real auth check or explicit dev-mode bypass with logging |
|
||||
| `v1/data/proof/` | **Create** | Proof bundle (sample capture + expected hash + verify script) |
|
||||
| `v1/requirements-lock.txt` | **Create** | Pinned minimal dependencies |
|
||||
| `.github/workflows/verify-pipeline.yml` | **Create** | CI verification |
|
||||
|
||||
### Hardware Documentation
|
||||
|
||||
```
|
||||
archive/v1/docs/hardware-setup.md (to be created)
|
||||
v1/docs/hardware-setup.md (to be created)
|
||||
|
||||
# Supported Hardware Matrix
|
||||
|
||||
@@ -368,17 +368,17 @@ archive/v1/docs/hardware-setup.md (to be created)
|
||||
2. Capture 10 seconds of empty-room baseline
|
||||
3. Have one person walk through at normal pace
|
||||
4. Capture 10 seconds during walk-through
|
||||
5. Run calibration: `python archive/v1/scripts/calibrate.py --baseline empty.dat --activity walk.dat`
|
||||
5. Run calibration: `python v1/scripts/calibrate.py --baseline empty.dat --activity walk.dat`
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **"Clone, build, verify" in one command**: `docker build . && docker run --rm wifi-densepose python archive/v1/data/proof/verify.py` produces a deterministic PASS
|
||||
- **"Clone, build, verify" in one command**: `docker build . && docker run --rm wifi-densepose python v1/data/proof/verify.py` produces a deterministic PASS
|
||||
- **No silent fakes**: Random data never appears in production output
|
||||
- **CI enforcement**: PRs that introduce `np.random` in production paths fail automatically
|
||||
- **Credibility anchor**: SHA-256 verified output from real CSI capture is unchallengeable proof
|
||||
- **Clear mock boundary**: Mock code exists only in `archive/v1/src/testing/`, never imported by production modules
|
||||
- **Clear mock boundary**: Mock code exists only in `v1/src/testing/`, never imported by production modules
|
||||
|
||||
### Negative
|
||||
- **Requires real CSI capture**: Someone must capture and commit a real CSI sample (one-time effort)
|
||||
@@ -390,7 +390,7 @@ archive/v1/docs/hardware-setup.md (to be created)
|
||||
|
||||
A stranger can:
|
||||
1. `git clone` the repository
|
||||
2. Run ONE command (`docker build .` or `python archive/v1/data/proof/verify.py`)
|
||||
2. Run ONE command (`docker build .` or `python v1/data/proof/verify.py`)
|
||||
3. See `PASS: Pipeline output matches expected hash` with a specific SHA-256
|
||||
4. Confirm no `np.random` in any non-test file via CI badge
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ typedef struct {
|
||||
The aggregator runs on any machine with WiFi/Ethernet to the nodes:
|
||||
|
||||
```rust
|
||||
// In v2/, new module: crates/wifi-densepose-hardware/src/esp32/
|
||||
// In wifi-densepose-rs, new module: crates/wifi-densepose-hardware/src/esp32/
|
||||
pub struct Esp32Aggregator {
|
||||
/// UDP socket listening for node streams
|
||||
socket: UdpSocket,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-013: Feature-Level Sensing on Commodity Gear (Option 3)
|
||||
|
||||
## Status
|
||||
Accepted — Implemented (36/36 unit tests pass, see `archive/v1/src/sensing/` and `archive/v1/tests/unit/test_sensing.py`)
|
||||
Accepted — Implemented (36/36 unit tests pass, see `v1/src/sensing/` and `v1/tests/unit/test_sensing.py`)
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
@@ -323,7 +323,7 @@ class PresenceClassifier:
|
||||
### Proof Bundle for Commodity Sensing
|
||||
|
||||
```
|
||||
archive/v1/data/proof/commodity/
|
||||
v1/data/proof/commodity/
|
||||
├── rssi_capture_30sec.json # 30 seconds of RSSI from 3 receivers
|
||||
├── rssi_capture_meta.json # Hardware: Intel AX200, Router: TP-Link AX1800
|
||||
├── scenario.txt # "Person walks through room at t=10s, sits at t=20s"
|
||||
@@ -375,7 +375,7 @@ class CommodityBackend(SensingBackend):
|
||||
|
||||
### Implementation Status
|
||||
|
||||
The full commodity sensing pipeline is implemented in `archive/v1/src/sensing/`:
|
||||
The full commodity sensing pipeline is implemented in `v1/src/sensing/`:
|
||||
|
||||
| Module | File | Description |
|
||||
|--------|------|-------------|
|
||||
@@ -384,7 +384,7 @@ The full commodity sensing pipeline is implemented in `archive/v1/src/sensing/`:
|
||||
| Classifier | `classifier.py` | `PresenceClassifier` with ABSENT/PRESENT_STILL/ACTIVE levels, confidence scoring |
|
||||
| Backend | `backend.py` | `CommodityBackend` wiring collector → extractor → classifier, reports PRESENCE + MOTION capabilities |
|
||||
|
||||
**Test coverage**: 36 tests in `archive/v1/tests/unit/test_sensing.py` — all passing:
|
||||
**Test coverage**: 36 tests in `v1/tests/unit/test_sensing.py` — all passing:
|
||||
- `TestRingBuffer` (4), `TestSimulatedCollector` (5), `TestFeatureExtractor` (8), `TestCusum` (4), `TestPresenceClassifier` (7), `TestCommodityBackend` (6), `TestBandPower` (2)
|
||||
|
||||
**Dependencies**: `numpy`, `scipy` (for FFT and spectral analysis)
|
||||
|
||||
@@ -510,7 +510,7 @@ impl CompressedHeartbeatSpectrogram {
|
||||
|
||||
## Dependency Changes Required
|
||||
|
||||
Add to `v2/Cargo.toml` workspace (already present from ADR-016):
|
||||
Add to `rust-port/wifi-densepose-rs/Cargo.toml` workspace (already present from ADR-016):
|
||||
```toml
|
||||
ruvector-mincut = "2.0.4" # already present
|
||||
ruvector-attn-mincut = "2.0.4" # already present
|
||||
|
||||
@@ -22,8 +22,8 @@ This ADR answers *how* to build it — the concrete development sequence, the sp
|
||||
| Frame types | `wifi-densepose-hardware/src/csi_frame.rs` | Complete — `CsiFrame`, `CsiMetadata`, `SubcarrierData`, `to_amplitude_phase()` |
|
||||
| Parse error types | `wifi-densepose-hardware/src/error.rs` | Complete — `ParseError` enum with 6 variants |
|
||||
| Signal processing pipeline | `wifi-densepose-signal` crate | Complete — Hampel, Fresnel, BVP, Doppler, spectrogram |
|
||||
| CSI extractor (Python) | `archive/v1/src/hardware/csi_extractor.py` | Stub — `_read_raw_data()` raises `NotImplementedError` |
|
||||
| Router interface (Python) | `archive/v1/src/hardware/router_interface.py` | Stub — `_parse_csi_response()` raises `RouterConnectionError` |
|
||||
| CSI extractor (Python) | `v1/src/hardware/csi_extractor.py` | Stub — `_read_raw_data()` raises `NotImplementedError` |
|
||||
| Router interface (Python) | `v1/src/hardware/router_interface.py` | Stub — `_parse_csi_response()` raises `RouterConnectionError` |
|
||||
|
||||
**Not yet implemented:**
|
||||
|
||||
@@ -211,10 +211,10 @@ The bridge test: parse a known binary frame, convert to `CsiData`, assert `ampli
|
||||
|
||||
### Layer 4 — Python `_read_raw_data()` Real Implementation
|
||||
|
||||
Replace the `NotImplementedError` stub in `archive/v1/src/hardware/csi_extractor.py` with a UDP socket reader. This allows the Python pipeline to receive real CSI from the aggregator while the Rust pipeline is being integrated.
|
||||
Replace the `NotImplementedError` stub in `v1/src/hardware/csi_extractor.py` with a UDP socket reader. This allows the Python pipeline to receive real CSI from the aggregator while the Rust pipeline is being integrated.
|
||||
|
||||
```python
|
||||
# archive/v1/src/hardware/csi_extractor.py
|
||||
# v1/src/hardware/csi_extractor.py
|
||||
# Replace _read_raw_data() stub:
|
||||
|
||||
import socket as _socket
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
The WiFi-DensePose UI was originally built to require the full FastAPI DensePose backend (`localhost:8000`) for all functionality. This backend depends on heavy Python packages (PyTorch ~2GB, torchvision, OpenCV, SQLAlchemy, Redis) making it impractical for lightweight sensing-only deployments where the user simply wants to visualize live WiFi signal data from ESP32 CSI or Windows RSSI collectors.
|
||||
|
||||
A Rust port exists (`v2`) using Axum with lighter runtime footprint (~10MB binary, ~5MB RAM), but it still requires libtorch C++ bindings and OpenBLAS for compilation—a non-trivial build.
|
||||
A Rust port exists (`rust-port/wifi-densepose-rs`) using Axum with lighter runtime footprint (~10MB binary, ~5MB RAM), but it still requires libtorch C++ bindings and OpenBLAS for compilation—a non-trivial build.
|
||||
|
||||
Users need a way to run the UI with **only the sensing pipeline** active, without installing the full DensePose backend stack.
|
||||
|
||||
@@ -34,7 +34,7 @@ Implement a **sensing-only UI mode** that:
|
||||
- Breathing ring modulation when breathing-band power detected
|
||||
- Side panel with RSSI sparkline, feature meters, and classification badge
|
||||
|
||||
4. **Python WebSocket bridge** (`archive/v1/src/sensing/ws_server.py`) that:
|
||||
4. **Python WebSocket bridge** (`v1/src/sensing/ws_server.py`) that:
|
||||
- Auto-detects ESP32 UDP CSI stream on port 5005 (ADR-018 binary frames)
|
||||
- Falls back to `WindowsWifiCollector` → `SimulatedCollector`
|
||||
- Runs `RssiFeatureExtractor` → `PresenceClassifier` pipeline
|
||||
@@ -80,7 +80,7 @@ Windows WiFi RSSI ───┘ │ │
|
||||
### Created
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `archive/v1/src/sensing/ws_server.py` | Python asyncio WebSocket server with auto-detect collectors |
|
||||
| `v1/src/sensing/ws_server.py` | Python asyncio WebSocket server with auto-detect collectors |
|
||||
| `ui/components/SensingTab.js` | Sensing tab UI with Three.js integration |
|
||||
| `ui/components/gaussian-splats.js` | Custom GLSL Gaussian splat renderer |
|
||||
| `ui/services/sensing.service.js` | WebSocket client with reconnect + simulation fallback |
|
||||
|
||||
@@ -22,7 +22,7 @@ The current Python DensePose backend requires ~2GB+ of dependencies:
|
||||
|
||||
This makes the DensePose backend impractical for edge deployments, CI pipelines, and developer laptops where users only need WiFi sensing + pose estimation.
|
||||
|
||||
Meanwhile, the Rust port at `v2/` already has:
|
||||
Meanwhile, the Rust port at `rust-port/wifi-densepose-rs/` already has:
|
||||
|
||||
- **12 workspace crates** covering core, signal, nn, api, db, config, hardware, wasm, cli, mat, train
|
||||
- **5 RuVector crates** (v2.0.4, published on crates.io) integrated into signal, mat, and train crates
|
||||
@@ -40,8 +40,8 @@ Use the `wifi-densepose-nn` crate with `default-features = ["onnx"]` only. This
|
||||
|
||||
| Component | Rust Crate | Replaces Python |
|
||||
|-----------|-----------|-----------------|
|
||||
| CSI processing | `wifi-densepose-signal::csi_processor` | `archive/v1/src/sensing/feature_extractor.py` |
|
||||
| Motion detection | `wifi-densepose-signal::motion` | `archive/v1/src/sensing/classifier.py` |
|
||||
| CSI processing | `wifi-densepose-signal::csi_processor` | `v1/src/sensing/feature_extractor.py` |
|
||||
| Motion detection | `wifi-densepose-signal::motion` | `v1/src/sensing/classifier.py` |
|
||||
| BVP extraction | `wifi-densepose-signal::bvp` | N/A (new capability) |
|
||||
| Fresnel geometry | `wifi-densepose-signal::fresnel` | N/A (new capability) |
|
||||
| Subcarrier selection | `wifi-densepose-signal::subcarrier_selection` | N/A (new capability) |
|
||||
@@ -143,7 +143,7 @@ The `wifi-densepose-nn::onnx` module loads `.onnx` files directly.
|
||||
|
||||
```bash
|
||||
# Build the Rust workspace (ONNX-only, no libtorch)
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo check --workspace 2>&1
|
||||
|
||||
# Build release binary
|
||||
|
||||
@@ -34,7 +34,7 @@ The `vendor/ruvector` codebase provides a rich set of signal processing primitiv
|
||||
|
||||
### Current Project State
|
||||
|
||||
The Rust port (`v2/`) already contains:
|
||||
The Rust port (`rust-port/wifi-densepose-rs/`) already contains:
|
||||
|
||||
- **`wifi-densepose-signal`**: CSI processing, BVP extraction, phase sanitization, Hampel filter, spectrogram generation, Fresnel geometry, motion detection, subcarrier selection
|
||||
- **`wifi-densepose-sensing-server`**: Axum server receiving ESP32 CSI frames (UDP 5005), WebSocket broadcasting sensing updates, signal field generation, with three data source modes:
|
||||
@@ -108,7 +108,7 @@ ESP32 CSI (UDP:5005) ──▶│ ┌──────────────
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-vitals/
|
||||
rust-port/wifi-densepose-rs/crates/wifi-densepose-vitals/
|
||||
├── Cargo.toml
|
||||
└── src/
|
||||
├── lib.rs # Public API and re-exports
|
||||
|
||||
@@ -592,7 +592,7 @@ impl FrameBuilder {
|
||||
### 3.3 Module Structure
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-wifiscan/
|
||||
rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/
|
||||
├── Cargo.toml
|
||||
└── src/
|
||||
├── lib.rs # Public API, re-exports
|
||||
|
||||
@@ -699,28 +699,28 @@ let dashboard = container.load_dashboard()?;
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `v2/.../wifi-densepose-train/src/dataset_mmfi.rs` | MM-Fi dataset loader with subcarrier resampling |
|
||||
| `v2/.../wifi-densepose-train/src/dataset_wipose.rs` | Wi-Pose dataset loader |
|
||||
| `v2/.../wifi-densepose-train/src/graph_transformer.rs` | Graph transformer integration |
|
||||
| `v2/.../wifi-densepose-train/src/body_gnn.rs` | GNN body graph reasoning |
|
||||
| `v2/.../wifi-densepose-train/src/adaptation.rs` | SONA LoRA + EWC++ adaptation |
|
||||
| `v2/.../wifi-densepose-train/src/trainer.rs` | Training loop with multi-term loss |
|
||||
| `rust-port/.../wifi-densepose-train/src/dataset_mmfi.rs` | MM-Fi dataset loader with subcarrier resampling |
|
||||
| `rust-port/.../wifi-densepose-train/src/dataset_wipose.rs` | Wi-Pose dataset loader |
|
||||
| `rust-port/.../wifi-densepose-train/src/graph_transformer.rs` | Graph transformer integration |
|
||||
| `rust-port/.../wifi-densepose-train/src/body_gnn.rs` | GNN body graph reasoning |
|
||||
| `rust-port/.../wifi-densepose-train/src/adaptation.rs` | SONA LoRA + EWC++ adaptation |
|
||||
| `rust-port/.../wifi-densepose-train/src/trainer.rs` | Training loop with multi-term loss |
|
||||
| `scripts/generate_densepose_labels.py` | Teacher-student UV label generation |
|
||||
| `scripts/benchmark_inference.py` | Inference latency benchmarking |
|
||||
| `v2/.../wifi-densepose-train/src/rvf_builder.rs` | RVF container build pipeline |
|
||||
| `v2/.../wifi-densepose-train/src/bin/build_rvf.rs` | CLI binary for building `.rvf` containers |
|
||||
| `v2/.../wifi-densepose-train/src/bin/verify_rvf.rs` | CLI binary for verifying `.rvf` containers |
|
||||
| `rust-port/.../wifi-densepose-train/src/rvf_builder.rs` | RVF container build pipeline |
|
||||
| `rust-port/.../wifi-densepose-train/src/bin/build_rvf.rs` | CLI binary for building `.rvf` containers |
|
||||
| `rust-port/.../wifi-densepose-train/src/bin/verify_rvf.rs` | CLI binary for verifying `.rvf` containers |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `v2/.../wifi-densepose-train/Cargo.toml` | Add ruvector-gnn, graph-transformer, sona, sparse-inference, math, rvf-types, rvf-wire, rvf-manifest, rvf-index, rvf-quant, rvf-crypto, rvf-runtime deps |
|
||||
| `v2/.../wifi-densepose-train/src/model.rs` | Integrate graph transformer + GNN layers |
|
||||
| `v2/.../wifi-densepose-train/src/losses.rs` | Add optimal transport + GNN edge consistency loss terms |
|
||||
| `v2/.../wifi-densepose-train/src/config.rs` | Add training hyperparameters for new components |
|
||||
| `v2/.../sensing-server/Cargo.toml` | Add rvf-runtime, rvf-types, rvf-index, rvf-quant deps |
|
||||
| `v2/.../sensing-server/src/main.rs` | Add `--model` flag, load `.rvf` container, progressive startup, serve embedded dashboard |
|
||||
| `rust-port/.../wifi-densepose-train/Cargo.toml` | Add ruvector-gnn, graph-transformer, sona, sparse-inference, math, rvf-types, rvf-wire, rvf-manifest, rvf-index, rvf-quant, rvf-crypto, rvf-runtime deps |
|
||||
| `rust-port/.../wifi-densepose-train/src/model.rs` | Integrate graph transformer + GNN layers |
|
||||
| `rust-port/.../wifi-densepose-train/src/losses.rs` | Add optimal transport + GNN edge consistency loss terms |
|
||||
| `rust-port/.../wifi-densepose-train/src/config.rs` | Add training hyperparameters for new components |
|
||||
| `rust-port/.../sensing-server/Cargo.toml` | Add rvf-runtime, rvf-types, rvf-index, rvf-quant deps |
|
||||
| `rust-port/.../sensing-server/src/main.rs` | Add `--model` flag, load `.rvf` container, progressive startup, serve embedded dashboard |
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ ESP32 SRAM budget: 520 KB. Model at INT8: 53-60 KB = 10-12% of SRAM. Ample margi
|
||||
|
||||
### 2.6 Concrete Module Additions
|
||||
|
||||
All new/modified files in `v2/crates/wifi-densepose-sensing-server/src/`:
|
||||
All new/modified files in `rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/`:
|
||||
|
||||
#### 2.6.1 `embedding.rs` (NEW, ~450 lines)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Implement a **macOS CoreWLAN sensing adapter** as a Swift helper binary + Rust a
|
||||
|
||||
### 3.2 Swift Helper Binary
|
||||
|
||||
**File:** `v2/tools/macos-wifi-scan/main.swift`
|
||||
**File:** `rust-port/wifi-densepose-rs/tools/macos-wifi-scan/main.swift`
|
||||
|
||||
```swift
|
||||
// Modes:
|
||||
|
||||
@@ -232,10 +232,10 @@ python scripts/provision.py --port COM7 \
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Reference signal | `archive/v1/data/proof/sample_csi_data.json` | 1,000 synthetic CSI frames, seed=42 |
|
||||
| Generator | `archive/v1/data/proof/generate_reference_signal.py` | Deterministic multipath model |
|
||||
| Verifier | `archive/v1/data/proof/verify.py` | SHA-256 hash comparison |
|
||||
| Expected hash | `archive/v1/data/proof/expected_features.sha256` | `0b82bd45...` |
|
||||
| Reference signal | `v1/data/proof/sample_csi_data.json` | 1,000 synthetic CSI frames, seed=42 |
|
||||
| Generator | `v1/data/proof/generate_reference_signal.py` | Deterministic multipath model |
|
||||
| Verifier | `v1/data/proof/verify.py` | SHA-256 hash comparison |
|
||||
| Expected hash | `v1/data/proof/expected_features.sha256` | `0b82bd45...` |
|
||||
|
||||
**Audit-time result:** PASS. Hash regenerated with numpy 2.4.2 + scipy 1.17.1. Pipeline hash: `8c0680d7d285739ea9597715e84959d9c356c87ee3ad35b5f1e69a4ca41151c6`.
|
||||
|
||||
|
||||
@@ -198,16 +198,16 @@ When a `.rvf` model is loaded:
|
||||
### New Files
|
||||
- `ui/components/ModelPanel.js` — Model library, inspector, load/unload controls
|
||||
- `ui/components/TrainingPanel.js` — Recording controls, training progress, metric charts
|
||||
- `v2/.../sensing-server/src/recording.rs` — CSI recording API handlers
|
||||
- `v2/.../sensing-server/src/training_api.rs` — Training API handlers + WS progress stream
|
||||
- `v2/.../sensing-server/src/model_manager.rs` — Model loading, hot-swap, 32LoRA activation
|
||||
- `rust-port/.../sensing-server/src/recording.rs` — CSI recording API handlers
|
||||
- `rust-port/.../sensing-server/src/training_api.rs` — Training API handlers + WS progress stream
|
||||
- `rust-port/.../sensing-server/src/model_manager.rs` — Model loading, hot-swap, 32LoRA activation
|
||||
- `data/models/` — Default model storage directory
|
||||
|
||||
### Modified Files
|
||||
- `v2/.../sensing-server/src/main.rs` — Wire recording, training, and model APIs
|
||||
- `v2/.../train/src/trainer.rs` — Add WebSocket progress callback, LoRA training mode
|
||||
- `v2/.../train/src/dataset.rs` — MM-Fi and Wi-Pose dataset loaders
|
||||
- `v2/.../nn/src/onnx.rs` — LoRA weight injection, INT8 quantization support
|
||||
- `rust-port/.../sensing-server/src/main.rs` — Wire recording, training, and model APIs
|
||||
- `rust-port/.../train/src/trainer.rs` — Add WebSocket progress callback, LoRA training mode
|
||||
- `rust-port/.../train/src/dataset.rs` — MM-Fi and Wi-Pose dataset loaders
|
||||
- `rust-port/.../nn/src/onnx.rs` — LoRA weight injection, INT8 quantization support
|
||||
- `ui/components/LiveDemoTab.js` — Model selector, LoRA dropdown, A/B spsplit view
|
||||
- `ui/components/SettingsPanel.js` — Model and training configuration sections
|
||||
- `ui/components/PoseDetectionCanvas.js` — Pose trail rendering, confidence heatmap overlay
|
||||
|
||||
@@ -128,7 +128,7 @@ All configurable via `provision.py --edge-tier 2 --pres-thresh 0.05 ...`
|
||||
- `firmware/esp32-csi-node/main/edge_processing.h` — Types and API
|
||||
- `firmware/esp32-csi-node/main/ota_update.c/h` — HTTP OTA endpoint
|
||||
- `firmware/esp32-csi-node/main/power_mgmt.c/h` — Power management
|
||||
- `v2/.../wifi-densepose-sensing-server/src/main.rs` — Vitals parser + REST endpoint
|
||||
- `rust-port/.../wifi-densepose-sensing-server/src/main.rs` — Vitals parser + REST endpoint
|
||||
- `scripts/provision.py` — Edge config CLI arguments
|
||||
- `.github/workflows/firmware-ci.yml` — CI build + size gate (updated to 950 KB for Tier 3)
|
||||
|
||||
|
||||
@@ -164,8 +164,8 @@ Core 1 (DSP Task)
|
||||
- `firmware/esp32-csi-node/main/wasm_runtime.c/h` — Runtime host with 12 API bindings + manifest
|
||||
- `firmware/esp32-csi-node/main/wasm_upload.c/h` — HTTP REST endpoints (RVF-aware)
|
||||
- `firmware/esp32-csi-node/main/rvf_parser.c/h` — RVF container parser and verifier
|
||||
- `v2/.../wifi-densepose-wasm-edge/` — Rust WASM crate (gesture, coherence, adversarial, rvf, occupancy, vital_trend, intrusion)
|
||||
- `v2/.../wifi-densepose-sensing-server/src/main.rs` — `0xC5110004` parser
|
||||
- `rust-port/.../wifi-densepose-wasm-edge/` — Rust WASM crate (gesture, coherence, adversarial, rvf, occupancy, vital_trend, intrusion)
|
||||
- `rust-port/.../wifi-densepose-sensing-server/src/main.rs` — `0xC5110004` parser
|
||||
- `docs/adr/ADR-039-esp32-edge-intelligence.md` — Updated with Tier 3 reference
|
||||
|
||||
---
|
||||
|
||||
@@ -289,7 +289,7 @@ Startup creates `data/models/` and `data/recordings/` directories and populates
|
||||
|
||||
```bash
|
||||
# 1. Start sensing server with auto source (simulated fallback)
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto
|
||||
|
||||
# 2. Verify model endpoints return 200
|
||||
@@ -312,11 +312,11 @@ curl -s http://localhost:3000/api/v1/models/lora/profiles | jq '.'
|
||||
# Navigate to http://localhost:3000/ui/
|
||||
|
||||
# 7. Run mobile tests
|
||||
cd ../ui/mobile
|
||||
cd ../../ui/mobile
|
||||
npx jest --no-coverage
|
||||
|
||||
# 8. Run Rust workspace tests (must pass, 1031+ tests)
|
||||
cd ../../v2
|
||||
cd ../../rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
```
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ Remove duplicated platform-detection logic from `ws_server.py` and `install.sh`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Add `create_collector()` and `BaseCollector.is_available()` to `archive/v1/src/sensing/rssi_collector.py`
|
||||
1. Add `create_collector()` and `BaseCollector.is_available()` to `v1/src/sensing/rssi_collector.py`
|
||||
2. Refactor `ws_server.py` `_init_collector()` to call `create_collector()`
|
||||
3. Update `install.sh` `detect_wifi_hardware()` to use shared detection logic
|
||||
4. Add unit tests for each platform path (mock `/proc/net/wireless` presence/absence)
|
||||
|
||||
@@ -29,7 +29,7 @@ There is no single tool that provides a unified view of the entire deployment
|
||||
|
||||
A browser-based UI cannot access serial ports (for flashing), raw UDP sockets (for node discovery), or the local filesystem (for firmware binaries). A desktop application is required for hardware management. Tauri v2 is the natural choice because:
|
||||
|
||||
1. **Rust backend** — integrates directly with the existing Rust workspace (`v2/`). Crates like `wifi-densepose-hardware` (serial port parsing), `wifi-densepose-config`, and `wifi-densepose-sensing-server` can be linked as library dependencies.
|
||||
1. **Rust backend** — integrates directly with the existing Rust workspace (`wifi-densepose-rs`). Crates like `wifi-densepose-hardware` (serial port parsing), `wifi-densepose-config`, and `wifi-densepose-sensing-server` can be linked as library dependencies.
|
||||
2. **Small binary** — Tauri bundles the system webview rather than shipping Chromium (~150 MB savings vs Electron).
|
||||
3. **Cross-platform** — Windows, macOS, Linux from the same codebase.
|
||||
4. **Security model** — Tauri's capability-based permissions system restricts frontend access to explicitly allowed Rust commands.
|
||||
@@ -52,7 +52,7 @@ Build a Tauri v2 desktop application as a new crate in the Rust workspace. The f
|
||||
Add a new crate to the workspace:
|
||||
|
||||
```
|
||||
v2/
|
||||
rust-port/wifi-densepose-rs/
|
||||
Cargo.toml # Add "crates/wifi-densepose-desktop" to members
|
||||
crates/
|
||||
wifi-densepose-desktop/ # NEW — Tauri app crate
|
||||
@@ -621,11 +621,11 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
```bash
|
||||
# Prerequisites
|
||||
cargo install tauri-cli@^2
|
||||
cd v2/crates/wifi-densepose-desktop/frontend
|
||||
cd rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/frontend
|
||||
npm install
|
||||
|
||||
# Development (hot-reload frontend + Rust rebuild)
|
||||
cd v2/crates/wifi-densepose-desktop
|
||||
cd rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
cargo tauri dev
|
||||
|
||||
# Production build
|
||||
@@ -805,6 +805,6 @@ Total estimated effort: ~11 weeks for a single developer.
|
||||
- ADR-051: Sensing Server Decomposition
|
||||
- `firmware/esp32-csi-node/` — ESP32 firmware source
|
||||
- `firmware/esp32-csi-node/provision.py` — Current provisioning script
|
||||
- `v2/crates/wifi-densepose-sensing-server/` — Sensing server
|
||||
- `v2/crates/wifi-densepose-hardware/` — Hardware crate
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/` — Sensing server
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/` — Hardware crate
|
||||
- `ui/` — Existing web UI
|
||||
|
||||
@@ -214,7 +214,7 @@ examples/wasm-browser-pose/
|
||||
set -e
|
||||
|
||||
# Build wifi-densepose-wasm (CSI processing)
|
||||
wasm-pack build ../../v2/crates/wifi-densepose-wasm \
|
||||
wasm-pack build ../../rust-port/wifi-densepose-rs/crates/wifi-densepose-wasm \
|
||||
--target web --out-dir "$(pwd)/pkg/wifi_densepose_wasm" --no-typescript
|
||||
|
||||
# Build ruvector-cnn-wasm (CNN inference for both video and CSI)
|
||||
|
||||
@@ -191,5 +191,5 @@ Also does not give per-person subcarrier assignments.
|
||||
|
||||
- Stoer, M. & Wagner, F. (1997). "A Simple Min-Cut Algorithm." JACM 44(4).
|
||||
- `vendor/ruvector/crates/ruvector-mincut/src/algorithm/mod.rs` — DynamicMinCut API
|
||||
- `v2/.../sig_mincut_person_match.rs` — current (broken) WASM edge matcher
|
||||
- `rust-port/.../sig_mincut_person_match.rs` — current (broken) WASM edge matcher
|
||||
- `scripts/rf-scan.js` — CSI packet parsing and subcarrier classification
|
||||
|
||||
@@ -17,19 +17,19 @@ Address the 15 prioritized issues from the QE analysis in three waves: P0 (immed
|
||||
|
||||
### 1. Rate Limiter Bypass (Security HIGH)
|
||||
|
||||
- **Location:** `archive/v1/src/middleware/rate_limit.py:200-206`
|
||||
- **Location:** `v1/src/middleware/rate_limit.py:200-206`
|
||||
- **Problem:** Trusts `X-Forwarded-For` without validation. Any client bypasses rate limits via header spoofing.
|
||||
- **Fix:** Validate forwarded headers against trusted proxy list, or use connection IP directly.
|
||||
|
||||
### 2. Exception Details Leaked in Responses (Security HIGH)
|
||||
|
||||
- **Location:** `archive/v1/src/api/routers/pose.py:140`, `stream.py:297`, +5 endpoints
|
||||
- **Location:** `v1/src/api/routers/pose.py:140`, `stream.py:297`, +5 endpoints
|
||||
- **Problem:** Stack traces visible regardless of environment.
|
||||
- **Fix:** Wrap with generic error responses in production; log details server-side only.
|
||||
|
||||
### 3. WebSocket JWT in URL (Security HIGH, CWE-598)
|
||||
|
||||
- **Location:** `archive/v1/src/api/routers/stream.py:74`, `archive/v1/src/middleware/auth.py:243`
|
||||
- **Location:** `v1/src/api/routers/stream.py:74`, `v1/src/middleware/auth.py:243`
|
||||
- **Problem:** Tokens in query strings visible in logs/proxies/browser history.
|
||||
- **Fix:** Use WebSocket subprotocol or first-message auth pattern.
|
||||
|
||||
|
||||
@@ -481,7 +481,7 @@ make check
|
||||
# → test_rv_mesh: 27/27 pass, HEALTH roundtrip = 1.0 µs
|
||||
|
||||
# Rust-side radio_ops trait + mesh decoder tests
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test -p wifi-densepose-hardware --no-default-features --lib radio_ops
|
||||
# → 8 passed; verifies MockRadio, CRC32 parity with firmware vectors,
|
||||
# HEALTH encode/decode roundtrip, bad-magic/short/CRC rejection,
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# ADR-082: Pose Tracker Confirmed-Track Output Filter
|
||||
|
||||
| Field | Value |
|
||||
|-------------|-----------------------------------------------------------------------|
|
||||
| **Status** | Accepted — implemented in commit landing this ADR |
|
||||
| **Date** | 2026-04-25 |
|
||||
| **Authors** | ruv |
|
||||
| **Issue** | [#420 — "24 ghost people in the UI with 3× ESP32-S3 nodes"](https://github.com/ruvnet/RuView/issues/420) |
|
||||
| **Depends** | ADR-026 (track lifecycle), ADR-024 (AETHER re-ID embeddings) |
|
||||
|
||||
## Context
|
||||
|
||||
Multiple users running the Rust sensing server with 3 ESP32-S3 nodes have
|
||||
reported the same symptom: the live UI renders 22–24 phantom skeletons that
|
||||
flicker at high rate, while `GET /api/v1/sensing/latest` correctly reports
|
||||
`estimated_persons: 1`. The problem is reproducible across both Docker and
|
||||
native deployments and is independent of the firmware MGMT-only mitigation
|
||||
shipped for #396.
|
||||
|
||||
The two-number contradiction (1 in the snapshot, ~24 in the WebSocket stream)
|
||||
narrows the bug to the path that produces `update.persons`. That path is
|
||||
`tracker_bridge::tracker_update` → `tracker_bridge::tracker_to_person_detections`
|
||||
→ WebSocket frame.
|
||||
|
||||
### Pose tracker lifecycle (per ADR-026)
|
||||
|
||||
`signal::ruvsense::pose_tracker::TrackLifecycleState` has four states:
|
||||
|
||||
```
|
||||
Tentative -> Active -> Lost -> Terminated
|
||||
```
|
||||
|
||||
The state machine and its predicates:
|
||||
|
||||
| State | `is_alive()` | `accepts_updates()` | Meaning |
|
||||
|--------------|--------------|---------------------|---------|
|
||||
| `Tentative` | true | true | New detection, < 2 confirmed hits |
|
||||
| `Active` | true | true | Confirmed track, currently observed |
|
||||
| `Lost` | **true** | false | Confirmed track, missed `loss_misses` updates, still inside `reid_window` |
|
||||
| `Terminated` | false | false | Removed on next `prune_terminated()` |
|
||||
|
||||
`PoseTracker::active_tracks()` filters by `is_alive()`, which means it returns
|
||||
`Tentative ∪ Active ∪ Lost` — every track that has not yet been Terminated.
|
||||
|
||||
### Root cause
|
||||
|
||||
`crates/wifi-densepose-sensing-server/src/tracker_bridge.rs` exposes the
|
||||
tracker output to the WebSocket stream via:
|
||||
|
||||
```rust
|
||||
/// Convert active PoseTracker tracks back into server-side PersonDetection values.
|
||||
///
|
||||
/// Only tracks whose lifecycle `is_alive()` are included.
|
||||
pub fn tracker_to_person_detections(tracker: &PoseTracker) -> Vec<PersonDetection> {
|
||||
tracker
|
||||
.active_tracks()
|
||||
.into_iter()
|
||||
.map(|track| { /* ... */ })
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
The doc comment is correct as a description of `is_alive()`, but `is_alive()`
|
||||
is the wrong gate for *rendering*. `Lost` tracks have not received a
|
||||
measurement in `loss_misses` ticks; they are kept around only so the
|
||||
re-identification machinery can attempt to match them when a similar
|
||||
detection reappears within `reid_window`. They are not currently observed and
|
||||
must not appear as live skeletons in the UI.
|
||||
|
||||
With 3 ESP32-S3 nodes streaming CSI at ~10 Hz each, `derive_pose_from_sensing`
|
||||
emits a per-node detection every tick. Detections that fall outside the
|
||||
Mahalanobis gate (cost ≥ 9.0) cannot match an existing track, so a new
|
||||
`Tentative` track is created and the previous one ages into `Lost`. With
|
||||
`reid_window ≈ 30` ticks (~3 s at 10 Hz), up to 30 ticks × 3 nodes ≈ 90
|
||||
phantom Lost tracks can co-exist before any of them reach `Terminated`.
|
||||
The actually-observed-now person is one of them; the other ~22–89 are ghosts.
|
||||
|
||||
The snapshot endpoint `/api/v1/sensing/latest` reads `estimated_persons` from
|
||||
the multistatic eigenvalue counter (`signal::ruvsense::field_model`), which
|
||||
operates on the CSI data directly and reports 1. The WebSocket stream reads
|
||||
`update.persons`, which is the unfiltered `is_alive()` set — hence the
|
||||
22-vs-1 mismatch.
|
||||
|
||||
This is a documentation/implementation discrepancy in `tracker_bridge`, not a
|
||||
flaw in the lifecycle state machine itself.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a **confirmed-track filter** at the bridge boundary that returns
|
||||
only tracks the UI is meant to render:
|
||||
|
||||
* `Active` — confirmed and currently observed; always render.
|
||||
* `Tentative` — confirmed for the *current* tick (created or matched this
|
||||
cycle); render so first-frame visibility latency stays at one tick.
|
||||
* `Lost` — **never** render. They exist only to support re-ID over the
|
||||
`reid_window` and have, by definition, not been observed for at least
|
||||
`loss_misses` ticks.
|
||||
* `Terminated` — never render (already excluded by `is_alive()`).
|
||||
|
||||
### Naming
|
||||
|
||||
Add `PoseTracker::confirmed_tracks()` — the name reflects "tracks the system
|
||||
is currently confirming a person is present at this position." Keep
|
||||
`active_tracks()` unchanged so callers that legitimately need the re-ID set
|
||||
(re-identification, soft-confidence overlays, debug UIs) still have it.
|
||||
|
||||
The bridge’s public surface stays the same; only the internal accessor
|
||||
swaps. WebSocket consumers see the corrected `update.persons` automatically.
|
||||
|
||||
### Why include `Tentative`
|
||||
|
||||
A walking person’s first detection lands in `Tentative` until two consecutive
|
||||
hits arrive (~0.1 s at 10 Hz). Excluding `Tentative` makes the UI
|
||||
under-render by one tick on every entry; the gain (filtering out spurious
|
||||
single-detection ghosts) is real but small relative to the much larger Lost
|
||||
problem and isn’t worth the visible latency. If single-tick ghosts become
|
||||
the dominant complaint after this ADR ships, escalate to `Active`-only and
|
||||
revisit `birth_hits` calibration.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
* `update.persons.length` matches `estimated_persons` within ±1 (Tentative
|
||||
vs. Active hand-off frame) under steady state. #420 closed.
|
||||
* No change to the lifecycle state machine, no change to `reid_window` or
|
||||
`loss_misses`, no change to the WebSocket schema. Pure filter at egress.
|
||||
* `PoseTracker::active_tracks()` keeps its semantics for re-ID consumers;
|
||||
this avoids breaking ADR-024 (AETHER) call sites.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
* Existing test `test_tracker_update_stable_ids` exercises three sequential
|
||||
identical-person updates and asserts the ID is stable across all three.
|
||||
Filtering Lost out doesn’t affect it (the track stays in `Tentative` →
|
||||
`Active`, never Lost during the test). Confirmed by reading the test;
|
||||
no regression expected.
|
||||
* Single-tick `Tentative` exposure means very-spurious one-frame detections
|
||||
*can* still flicker briefly. Acceptable trade-off as discussed above.
|
||||
|
||||
### Neutral
|
||||
|
||||
* `prune_terminated()` and the existing transition logic
|
||||
(`predict_all` → `mark_lost` → `terminate`) are unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. **`signal::ruvsense::pose_tracker`** — add:
|
||||
```rust
|
||||
/// Tracks the UI is meant to render: Tentative + Active.
|
||||
/// Excludes Lost (re-ID candidates) and Terminated.
|
||||
pub fn confirmed_tracks(&self) -> Vec<&PoseTrack> {
|
||||
self.tracks
|
||||
.iter()
|
||||
.filter(|t| matches!(
|
||||
t.lifecycle,
|
||||
TrackLifecycleState::Tentative | TrackLifecycleState::Active
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
2. **`sensing-server::tracker_bridge`** — change
|
||||
`tracker_to_person_detections` to call `tracker.confirmed_tracks()` and
|
||||
update the doc comment to describe the new contract.
|
||||
3. **Regression test** in `tracker_bridge.rs::tests`:
|
||||
* Drive a track to `Active` over two updates.
|
||||
* Submit empty detections for `loss_misses + 1` predict cycles to push
|
||||
the track to `Lost`.
|
||||
* Assert `tracker_update(... empty ...)` returns an empty `Vec`.
|
||||
4. **Validation**: workspace tests + ESP32-S3 on COM7 streaming round-trip.
|
||||
|
||||
## Validation
|
||||
|
||||
* `cargo test --workspace --no-default-features` — must stay green
|
||||
(≥ 1,538 passed, 0 failed; new regression test adds one).
|
||||
* Live verification on ESP32 setup: WebSocket `update.persons.length`
|
||||
must equal `estimated_persons` ± 1 in steady state.
|
||||
|
||||
## Related
|
||||
|
||||
* ADR-026 — Track lifecycle state machine (this ADR doesn’t change it)
|
||||
* ADR-024 — AETHER re-ID embeddings (uses `active_tracks()`, unchanged)
|
||||
* PR #425 — Workspace `--no-default-features` build fix (unrelated, just
|
||||
the prior PR on this branch line)
|
||||
* Issue #420 — original report
|
||||
@@ -1,245 +0,0 @@
|
||||
# ADR-083: Per-Cluster Pi Compute Hop
|
||||
|
||||
| Field | Value |
|
||||
|----------------|--------------------------------------------------------------------------------------|
|
||||
| **Status** | Proposed — pending field evidence on three-tier proposal scope |
|
||||
| **Date** | 2026-04-26 |
|
||||
| **Authors** | ruv |
|
||||
| **Supersedes** | — |
|
||||
| **Refines** | ADR-028 (capability audit), ADR-081 (5-layer kernel), ADR-066 (swarm bridge) |
|
||||
| **Companion** | `docs/research/architecture/three-tier-rust-node.md`, `docs/research/architecture/decision-tree.md`, `docs/research/sota/2026-Q2-rf-sensing-and-edge-rust.md` |
|
||||
|
||||
## Context
|
||||
|
||||
ADR-028 established the per-node BOM at ~$9 (ESP32-S3 8MB) — ~$15 with a
|
||||
mmWave sensor — and ADR-081 framed the firmware as a 5-layer adaptive
|
||||
kernel running entirely on a single ESP32-S3 die. Both decisions are
|
||||
correct for the **per-node** dimension; deployments that fit the
|
||||
"sensor talks UDP to a server somewhere" shape work fine on this stack.
|
||||
|
||||
The three-tier-node research exploration
|
||||
(`docs/research/architecture/three-tier-rust-node.md`) raised a separate
|
||||
question: **what changes when a deployment scales past one or two rooms,
|
||||
and where should the heavy compute live?** The exploration's answer
|
||||
("dual ESP32-S3 + Pi Zero 2W per node") is one shape, but the
|
||||
companion decision-tree (`decision-tree.md` §1, §3 L3, §5) identifies a
|
||||
materially cheaper path: keep today's single-S3 sensor node unchanged
|
||||
and add **one Pi per cluster of 3–6 sensor nodes**. The 2026-Q2 SOTA
|
||||
survey (`sota/2026-Q2-rf-sensing-and-edge-rust.md`) confirms that the
|
||||
load this path needs to carry — model inference, QUIC backhaul, and a
|
||||
real secure-boot story — fits comfortably on a Pi-class SoC, while the
|
||||
load it doesn't need to carry — CSI capture, ISR-precise wake control —
|
||||
is exactly what the ESP32-S3 already does well.
|
||||
|
||||
The three things this ADR is about, all of which the current single-S3
|
||||
deployment shape pushes onto the cloud or onto every individual node:
|
||||
|
||||
1. **Per-deployment ML inference.** WiFlow / DT-Pose / GraphPose-Fi
|
||||
class models (4–10M params, 0.5–1.5 GFLOPs) want a Cortex-A53-class
|
||||
target. The ESP32-S3 cannot host these; the cloud can but only at
|
||||
the cost of round-trip latency. A per-cluster Pi inference hop is
|
||||
the natural home.
|
||||
2. **QUIC backhaul.** `quinn` + `rustls` is mature on Linux but does
|
||||
not run on ESP32-class hardware in any production-grade form
|
||||
(SOTA §5). A Pi terminating QUIC for a cluster gives every sensor
|
||||
node QUIC's loss/handoff/multiplex properties without porting QUIC
|
||||
to the MCU.
|
||||
3. **Secure-boot anchor for OTA.** ESP-IDF Secure Boot V2 covers each
|
||||
sensor node, but cluster-wide policy (which model is current, which
|
||||
sensor MCU image is canary, what is the rollout ring) needs a
|
||||
higher-trust local store. A Pi running buildroot + dm-verity +
|
||||
signed FIT is a defensible anchor without the BOM hit of CM4 / Pi 5
|
||||
(the latter is its own decision; see ADR-085 sketch below and
|
||||
decision-tree.md L6).
|
||||
|
||||
The cluster-Pi shape does **not** require any change to ADR-028 or
|
||||
ADR-081. The sensor node continues to be a single-MCU ESP32-S3 running
|
||||
the 5-layer kernel. Everything new lives at the cluster boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt **a per-cluster Pi hop** as the canonical RuView mid-scale
|
||||
deployment shape. A "cluster" is **3–6 ESP32-S3 sensor nodes within
|
||||
WiFi mesh range of one Pi**.
|
||||
|
||||
Specifically:
|
||||
|
||||
1. **Sensor nodes are unchanged.** They continue to run the ADR-081
|
||||
5-layer kernel on a single ESP32-S3, emit `rv_feature_state_t`
|
||||
packets (60 byte, ~5 Hz, ~300 B/s) over UDP, and connect via
|
||||
ESP-WIFI-MESH or direct WiFi to the cluster Pi.
|
||||
2. **Each cluster has exactly one Pi** acting as:
|
||||
- **Sensor aggregator**: ingests UDP from all cluster sensor
|
||||
nodes, runs feature-level fusion (multistatic + viewpoint
|
||||
attention from the existing `wifi-densepose-ruvector` crate).
|
||||
- **ML inference target**: hosts the WiFi-pose model and runs
|
||||
inference at the cluster boundary, not on each sensor MCU.
|
||||
- **QUIC client to the cloud / gateway**: terminates QUIC mTLS,
|
||||
batches cluster-level events.
|
||||
- **OTA + secure-boot anchor for its sensor nodes**: holds signed
|
||||
manifests, stages canary rollouts, owns provisioning state.
|
||||
3. **Cluster Pi SoC choice is deferred** to a future ADR (sketched
|
||||
below as ADR-085). The acceptable candidates are Pi Zero 2W, Pi 4,
|
||||
Pi 5, and CM4. The decision tree's L6 distinguishes these by
|
||||
secure-boot threat model; this ADR does not pre-commit.
|
||||
4. **The single-node deployment shape is not deprecated.** A
|
||||
home-lab / single-room / development deployment can still run a
|
||||
single ESP32-S3 talking UDP directly to the existing
|
||||
`wifi-densepose-sensing-server`, no Pi required. The cluster Pi
|
||||
becomes the recommended shape for fleets ≥ 3 sensor nodes.
|
||||
|
||||
### Boundary contract
|
||||
|
||||
The cluster Pi exposes two interfaces:
|
||||
|
||||
| Interface | Direction | Schema |
|
||||
|------------------------|-------------------|-----------------------------------------------------------------------|
|
||||
| **UDP `rv_feature_state_t` ingest** | sensor → Pi | Existing 60-byte packed struct from ADR-081 (magic `0xC5110006`) |
|
||||
| **QUIC mTLS uplink** | Pi → gateway/cloud | New: cluster-level event envelope (CBOR), batched, ~10 KB/min upper bound |
|
||||
|
||||
Sensor → Pi is **the same wire as today's sensor → server**. Cluster Pi
|
||||
uplink is **new** and is what the existing `wifi-densepose-sensing-server`
|
||||
becomes — relocated from the user's laptop / container to the cluster
|
||||
node. Concretely: the sensing server already exists in
|
||||
`crates/wifi-densepose-sensing-server`; it cross-compiles to ARMv7 /
|
||||
AArch64 today via `cargo build --target aarch64-unknown-linux-gnu`. The
|
||||
relocation is a deployment change, not a re-implementation.
|
||||
|
||||
### Three-tier vs cluster hop
|
||||
|
||||
This ADR's cluster-Pi shape is the L3-hybrid path in
|
||||
`decision-tree.md` §2 — **not** the full three-tier (dual-MCU + per-node
|
||||
Pi) shape. It captures most of the value (ML, QUIC, secure-boot anchor)
|
||||
at minimal BOM impact. The full three-tier shape remains the long-term
|
||||
exploration target, blocked behind L4 (no_std CSI maturity) and L2
|
||||
(per-node ISR-jitter evidence).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Pose-grade ML on edge becomes deployable**, not just possible. A
|
||||
Pi (any of the eligible SoCs) hosts WiFlow-class models with
|
||||
≤ 100 ms latency per cluster, vs ≥ 1 s round-trip if pose runs in the
|
||||
cloud (SOTA §1, §3).
|
||||
- **QUIC arrives without an MCU port.** `quinn` + `rustls` runs on the
|
||||
Pi as it does on a server (SOTA §5). The sensor MCU keeps UDP — the
|
||||
cheapest, highest-tested wire it already speaks.
|
||||
- **Cluster-level secure boot becomes coherent.** Per-sensor Secure
|
||||
Boot V2 + flash encryption (ADR-028 baseline) is unchanged. The Pi
|
||||
buildroot + dm-verity image is the cluster trust anchor and signs
|
||||
the OTA manifests for its sensors. The cluster-level threat model is
|
||||
expressible without per-sensor BOM regression.
|
||||
- **No PCB respin.** Sensor nodes are bit-for-bit identical to today's
|
||||
ADR-028 baseline. The cluster Pi is a separate device on the cluster
|
||||
WiFi (and / or Ethernet, if available).
|
||||
- **Deployment cost scales sub-linearly with sensor count.** One
|
||||
$25–$60 Pi per 3–6 sensor nodes adds ~$5–$20 per sensor amortized,
|
||||
vs ~$25–$50 per sensor for the per-node-Pi shape.
|
||||
|
||||
### Negative
|
||||
|
||||
- **The cluster Pi is a new piece of infrastructure to provision,
|
||||
monitor, and update.** It is the right place for cluster-level
|
||||
responsibilities, but it is not free; it adds a Linux box to every
|
||||
multi-room deployment. Mitigated by buildroot images and the
|
||||
existing OTA tooling story (see Implementation §4).
|
||||
- **Cluster Pi failure takes the cluster offline** (sensor nodes
|
||||
cannot uplink without a working aggregator on the WiFi LAN). For
|
||||
high-availability deployments, this ADR is the floor; an HA-pair
|
||||
cluster Pi would be a follow-up.
|
||||
- **One more network hop on the sensing path.** Sensor → Pi → cloud
|
||||
adds ~5–20 ms over Sensor → cloud (depending on link quality).
|
||||
Pose latency budgets are 100s of ms, so this is well inside spec.
|
||||
|
||||
### Neutral
|
||||
|
||||
- ADR-028 (capability audit), ADR-081 (5-layer kernel), and ADR-066
|
||||
(swarm bridge) are unchanged. This ADR adds a new device class above
|
||||
the sensor; it does not modify the sensor itself.
|
||||
- The home-lab single-node shape continues to work; this ADR adds a
|
||||
recommended path for fleets, it does not deprecate the existing one.
|
||||
|
||||
## Implementation
|
||||
|
||||
The implementation is intentionally light because most of the pieces
|
||||
already exist; the ADR is largely about formalizing where they live.
|
||||
|
||||
1. **Cluster-Pi cross-compile target.** Add to
|
||||
`rust-port/wifi-densepose-rs/.cargo/config.toml` (or the equivalent
|
||||
per-crate target spec) an `aarch64-unknown-linux-gnu` target so
|
||||
`wifi-densepose-sensing-server` builds for Pi 4 / 5 / CM4 by
|
||||
default. Also retain `armv7-unknown-linux-gnueabihf` for Pi Zero 2W
|
||||
compatibility while the Pi-SoC decision (ADR-085 sketch) is open.
|
||||
2. **Cluster-Pi service unit.** Add a systemd unit file under
|
||||
`firmware/cluster-pi/` (new directory) that runs
|
||||
`wifi-densepose-sensing-server` with the cluster's UDP/QUIC ports
|
||||
and drops privileges. Buildroot integration is a separate ADR if
|
||||
the SoC choice goes to Pi Zero 2W (where there's no RPi-OS path).
|
||||
3. **QUIC uplink module.** Add `wifi-densepose-sensing-server` a
|
||||
feature-gated `quic-uplink` module using `quinn` + `rustls`. The
|
||||
feature is **off by default** in the home-lab shape and on for the
|
||||
cluster Pi.
|
||||
4. **OTA + signed-manifest flow.** Out of scope for this ADR; tracked
|
||||
as I4 in `decision-tree.md` §4. The cluster Pi's role is to *hold*
|
||||
the manifest store, not to define the manifest format. Use the
|
||||
existing ADR-066 swarm bridge channel for OTA staging.
|
||||
5. **Documentation update.** README's hardware-table gains a
|
||||
"Cluster compute" row. CLAUDE.md gets a one-paragraph cluster-Pi
|
||||
section under Architecture. User-guide gets a cluster-deployment
|
||||
section.
|
||||
6. **Validation.** A 3-sensor cluster + 1 Pi fixture in the lab.
|
||||
Pass criteria: end-to-end CSI → cluster fusion → cloud ingest;
|
||||
measured latency under 100 ms per cluster; cluster Pi reboot
|
||||
without sensor data loss > 5 s; OTA staging round-trip across all
|
||||
sensors in the cluster.
|
||||
|
||||
## Validation
|
||||
|
||||
This ADR is **proposed**, not accepted. Acceptance requires:
|
||||
|
||||
1. The cluster-Pi `wifi-densepose-sensing-server` cross-compiles
|
||||
cleanly on `aarch64-unknown-linux-gnu` and `armv7-unknown-linux-gnueabihf`
|
||||
targets with the existing workspace tests passing.
|
||||
2. A 3-sensor + 1-Pi field test demonstrates ≥ 4 hours stable
|
||||
end-to-end CSI → fusion → cloud round-trip with latency
|
||||
≤ 100 ms per cluster and zero phantom-skeleton regressions
|
||||
(ADR-082 holds across the new uplink).
|
||||
3. The cluster-Pi ↔ sensor secure-boot story is approved alongside
|
||||
ADR-085's SoC choice.
|
||||
|
||||
When the above pass, this ADR moves from **Proposed** → **Accepted**
|
||||
and the README + CLAUDE.md are updated to reflect cluster-Pi as the
|
||||
recommended fleet-shape.
|
||||
|
||||
## Related ADRs (current and proposed)
|
||||
|
||||
- **ADR-028** (Accepted) — ESP32 capability audit. Single-node BOM
|
||||
baseline. Unchanged by this ADR.
|
||||
- **ADR-029** (Proposed) — RuvSense multistatic sensing mode. Pairs
|
||||
naturally with cluster-Pi: cluster Pi is the natural home for
|
||||
multi-sensor fusion.
|
||||
- **ADR-066** — Swarm bridge to coordinator. The cluster-Pi is the
|
||||
per-cluster swarm coordinator endpoint.
|
||||
- **ADR-081** (Accepted) — 5-layer adaptive CSI mesh firmware kernel.
|
||||
Unchanged by this ADR.
|
||||
- **ADR-082** (Accepted) — Pose tracker confirmed-track output filter.
|
||||
Holds across UDP and QUIC uplinks identically.
|
||||
- **Future ADR (sketched in `decision-tree.md` L4)** — `no_std` CSI
|
||||
capture maturity benchmark. Gates the dual-MCU shape; not required
|
||||
for the cluster-Pi shape proposed here.
|
||||
- **Future ADR (sketched in `decision-tree.md` L6)** — Cluster-Pi SoC
|
||||
choice (Pi Zero 2W vs CM4 vs Pi 5). Pure secure-boot decision.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Cluster size sweet spot.** "3–6 nodes" is a planning estimate. The
|
||||
3-sensor lab fixture in §Implementation will inform whether the
|
||||
upper bound is closer to 4, 6, or 8 in practice.
|
||||
- **Cluster-Pi failure semantics.** Default behavior: sensor MCUs hold
|
||||
the last 60 s of feature packets in RAM and replay on reconnect.
|
||||
HA-pair cluster Pi is a separate ADR if needed.
|
||||
- **Mesh control-plane interaction.** If the deployment moves to
|
||||
Thread (decision-tree.md L5), the cluster Pi may need a Thread
|
||||
Border Router role. This ADR doesn't pre-commit; it's compatible
|
||||
with both ESP-WIFI-MESH and Thread futures.
|
||||
@@ -1,276 +0,0 @@
|
||||
# ADR-084: RaBitQ Similarity Sensor for CSI / Pose / Memory Routing
|
||||
|
||||
| Field | Value |
|
||||
|----------------|-----------------------------------------------------------------------------------------|
|
||||
| **Status** | Accepted — Passes 1–5 + L1–L4 hardening implemented and merged via PR #435 (commit `d71ef9a`); acceptance numbers in §"Acceptance test" all measured and passing on synthetic AETHER-shape data; the `< 1 pp end-to-end accuracy regression` criterion is tracked as a post-merge soak test |
|
||||
| **Date** | 2026-04-26 |
|
||||
| **Authors** | ruv |
|
||||
| **Refines** | ADR-024 (AETHER re-ID embeddings), ADR-027 (cross-environment domain generalization), ADR-076 (CSI spectrogram embeddings), ADR-081 (5-layer firmware kernel) |
|
||||
| **Companion** | ADR-083 (per-cluster Pi compute hop) |
|
||||
| **Implements** | `vendor/ruvector/crates/ruvector-core/src/quantization.rs::BinaryQuantized` |
|
||||
|
||||
## Context
|
||||
|
||||
RuView's signal pipeline already produces several **dense float
|
||||
embeddings** at different layers:
|
||||
|
||||
- AETHER 128-d re-ID embeddings on each `PoseTrack` (ADR-024)
|
||||
- 64–256-d CSI spectrogram embeddings (ADR-076)
|
||||
- per-room field-model eigenmode vectors (ADR-030)
|
||||
- per-frame multistatic fused vectors (ADR-029)
|
||||
|
||||
Every one of these eventually answers the same shape of question:
|
||||
**"have I seen something like this before?"** Today the answer is
|
||||
computed by full float dot-product / Mahalanobis comparisons against a
|
||||
candidate set. That cost grows linearly with stored vectors and
|
||||
quadratically when used inside dynamic-mincut graph maintenance,
|
||||
re-identification re-scoring, and cross-environment domain detection.
|
||||
|
||||
The vendored `ruvector-core` crate already ships a 1-bit quantization
|
||||
(`BinaryQuantized`, 32× compression, SIMD popcnt + hamming distance)
|
||||
that is functionally equivalent to the **RaBitQ** family of binary
|
||||
sketches: a vector is reduced to one bit per dimension, compared via
|
||||
hamming distance, and used as a coarse pre-filter before full
|
||||
precision refinement. The same module also exposes `ScalarQuantized`
|
||||
(int8, 4×) and `ProductQuantized` (PQ, 8–16×), so the tiered
|
||||
quantization story is already implemented; the *deployment pattern* is
|
||||
not.
|
||||
|
||||
The user observation that motivates this ADR: **RaBitQ-style sketches
|
||||
are not just a vector compression trick — they are a cheap similarity
|
||||
sensor.** Used as a sensor, they unlock:
|
||||
|
||||
- always-on novelty / anomaly gating that wakes heavy CNNs only on
|
||||
meaningful change
|
||||
- cluster-Pi memory routing (which shard / room / model to query first)
|
||||
- cross-node mesh exchange of compressed sketches instead of raw vectors
|
||||
- privacy-preserving event logs (sketches, not reconstructable signals)
|
||||
|
||||
This ADR formalizes the deployment pattern across the RuView stack and
|
||||
commits to `ruvector::quantization::BinaryQuantized` as the canonical
|
||||
implementation.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt **RaBitQ-style binary sketches as a first-class, cheap
|
||||
similarity sensor** at four points in the RuView pipeline:
|
||||
|
||||
1. **CSI / pose embedding hot-cache filter** at the cluster Pi.
|
||||
2. **Drift / novelty sensor** between live observation and a
|
||||
per-room normal-state bank.
|
||||
3. **Mesh-exchange compression** between sensor nodes when reporting
|
||||
cross-cluster events.
|
||||
4. **Privacy-preserving event log** at the cluster Pi and gateway.
|
||||
|
||||
The canonical pattern at every point is:
|
||||
|
||||
```text
|
||||
dense embedding ──► RaBitQ sketch ──► hamming/popcnt compare
|
||||
├──► candidate set (top-K)
|
||||
└──► novelty score (0..1)
|
||||
│
|
||||
▼
|
||||
┌── below threshold ──► emit summary, no escalation
|
||||
│
|
||||
└── above threshold ──► full-precision refinement
|
||||
├──► ruvector mincut / HNSW
|
||||
├──► AETHER re-ID rescoring
|
||||
└──► pose model / CNN wake
|
||||
```
|
||||
|
||||
### Implementation home
|
||||
|
||||
- **Sketch type and SIMD primitives**:
|
||||
`vendor/ruvector/crates/ruvector-core/src/quantization.rs::BinaryQuantized`
|
||||
— already implemented, already SIMD-accelerated (NEON on aarch64,
|
||||
POPCNT on x86_64). Re-export through a new
|
||||
`crates/wifi-densepose-ruvector/src/sketch.rs` module so consumers in
|
||||
`signal`, `train`, `mat`, and `sensing-server` see a stable
|
||||
RuView-flavored API and don't bind directly to the vendor crate.
|
||||
|
||||
- **Per-room normal-state bank**: lives at the cluster Pi (ADR-083),
|
||||
not on the sensor MCU. Sensor MCUs continue to emit dense embeddings
|
||||
in the existing `rv_feature_state_t` packet shape; sketching happens
|
||||
on the Pi where the candidate bank is.
|
||||
|
||||
- **Sketch versioning**: each sketch carries a 16-bit `sketch_version`
|
||||
field so the Pi can tell incompatible sketches apart when an
|
||||
embedding model upgrades. Bumped on every embedding-model change.
|
||||
|
||||
### Where the sensor sits in the pipeline
|
||||
|
||||
| Pipeline stage | Today (full float) | With RaBitQ similarity sensor |
|
||||
|---|---|---|
|
||||
| AETHER re-ID match | full 128-d cosine on every active track × candidate | hamming pre-filter to top-K, then full cosine on K |
|
||||
| Mincut subcarrier selection | full graph re-evaluation | sketch-flagged "likely-changed" boundary edges, full mincut on those |
|
||||
| CSI room fingerprint | trained classifier on full embedding | sketch hamming to per-room sketch, classifier on miss |
|
||||
| Field-model novelty (ADR-030) | residual-energy threshold | sketch novelty as second gate before SVD redo |
|
||||
| Mesh / inter-cluster sync | dense embedding broadcast | sketch broadcast; full vector only on miss |
|
||||
| Event log retention | full embedding stored | sketch + witness hash stored; raw embedding ephemeral |
|
||||
|
||||
In every row, the **decision boundary is unchanged** — full precision
|
||||
still owns the final answer. The sketch is a sensor that only gates
|
||||
which comparisons run, not what they decide.
|
||||
|
||||
### Acceptance criterion (per the source proposal)
|
||||
|
||||
The system-level acceptance test is:
|
||||
|
||||
> RaBitQ should reduce compare cost by **8× to 30×** while preserving
|
||||
> top-k decisions well enough that full refinement changes **fewer
|
||||
> than 10%** of final results.
|
||||
|
||||
Concretely, this means:
|
||||
|
||||
- Sketch compare must be measurably **8× cheaper** than the float
|
||||
comparison it replaces (criterion-bench in `signal/`).
|
||||
- Top-K candidate set chosen by sketch must contain ≥ 90% of the
|
||||
candidates the full-float pass would have picked (offline replay
|
||||
against recorded CSI).
|
||||
- End-to-end pose / re-ID accuracy must regress by **less than 1
|
||||
percentage point** vs the full-float baseline on the existing
|
||||
evaluation set.
|
||||
|
||||
If any of these three fail, the sensor is rolled back at that point in
|
||||
the pipeline and the failing site reverts to full float; the rest of
|
||||
the pipeline keeps using sketches. This is point-by-point, not
|
||||
all-or-nothing.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Cheaper hot path everywhere a "have I seen this" question lives.**
|
||||
AETHER re-ID, mincut maintenance, room fingerprinting, novelty
|
||||
detection, mesh sync, and event-log retention all run a 32×-smaller,
|
||||
popcnt-friendly comparison first.
|
||||
- **Always-on anomaly gating becomes affordable.** The CNN / pose
|
||||
model only wakes when sketch novelty crosses a threshold. Energy
|
||||
budget per node drops materially in steady-state quiet rooms.
|
||||
- **Privacy story improves.** Event logs and inter-cluster mesh
|
||||
traffic carry sketches and witness hashes, not reconstructable
|
||||
embeddings. The 1-bit quantization is *not* invertible to the
|
||||
original CSI.
|
||||
- **Composes cleanly with ADR-083.** The cluster Pi is the natural
|
||||
home for the sketch bank; sensor MCUs remain unchanged.
|
||||
- **No new dependency.** `BinaryQuantized` is already in the vendored
|
||||
`ruvector-core` and already SIMD-accelerated.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **Sketch quality depends on embedding distribution.** Pure 1-bit
|
||||
sign quantization (which `BinaryQuantized` implements) works best
|
||||
when the embedding space is roughly zero-centered and isotropic.
|
||||
AETHER and CSI spectrogram embeddings need to be benchmarked for
|
||||
this assumption; if either fails, a randomized rotation
|
||||
(Johnson-Lindenstrauss / RaBitQ-paper-style) must be added before
|
||||
sketching. Out-of-scope for this ADR; tracked as a follow-up if
|
||||
the acceptance test fails.
|
||||
- **Top-K coverage degrades for small candidate sets.** With < 16
|
||||
candidates, the sketch compare can pick the wrong K. Site-by-site
|
||||
fallback to full float is part of the rollout plan.
|
||||
- **Sketch-version skew during model upgrades.** A model change
|
||||
invalidates all stored sketches; the cluster Pi must re-sketch the
|
||||
candidate bank when `sketch_version` bumps. Cost is bounded but
|
||||
non-zero.
|
||||
|
||||
### Neutral
|
||||
|
||||
- ADR-024, ADR-027, ADR-029, ADR-030, ADR-076 are unchanged in
|
||||
*what* they compute. They gain a sketch pre-filter at the comparison
|
||||
step.
|
||||
- ADR-082's confirmed-track output filter is upstream of the sketch
|
||||
layer; it stays correct.
|
||||
|
||||
## Implementation
|
||||
|
||||
The implementation lands in five passes, each independently testable.
|
||||
Every pass is gated by the acceptance criterion above; if any fail,
|
||||
that site rolls back and the rest continue.
|
||||
|
||||
1. **`wifi-densepose-ruvector::sketch` module.** Re-export
|
||||
`BinaryQuantized` plus a thin RuView-flavored API
|
||||
(`Sketch::from_embedding`, `Sketch::distance`, `SketchBank::topk`).
|
||||
Add `sketch_version: u16` and `embedding_dim: u16` fields to the
|
||||
public type. Criterion benches: sketch ↔ float compare-cost ratio.
|
||||
|
||||
2. **AETHER re-ID pre-filter.** In
|
||||
`wifi-densepose-signal/src/ruvsense/pose_tracker.rs`, before
|
||||
computing the full 128-d cosine across active tracks × candidates,
|
||||
sketch both sides and reduce to top-K via hamming. Bench: re-ID
|
||||
pass time per frame, ID-stability under cross-room transitions.
|
||||
|
||||
3. **Cluster-Pi novelty sensor.** In
|
||||
`wifi-densepose-sensing-server`, maintain a per-room
|
||||
`SketchBank` of "normal-state" sketches; on each incoming
|
||||
`rv_feature_state_t`, compute embedding sketch, score novelty
|
||||
against the bank, and emit `novelty_score` as a new field on the
|
||||
WebSocket update envelope. Heavy CNN wake gate uses this score.
|
||||
|
||||
4. **Mesh-exchange compression.** Inter-cluster broadcasts (the
|
||||
ADR-066 swarm-bridge channel) carry sketch + witness instead of
|
||||
the full embedding when novelty is low. Full embedding only
|
||||
exchanged when novelty crosses threshold.
|
||||
|
||||
5. **Privacy-preserving event log.** Event log table on the cluster
|
||||
Pi stores `(sketch_bytes, sketch_version, novelty_score,
|
||||
witness_sha256)` instead of raw embeddings. Existing log readers
|
||||
are unchanged in API; only the storage layer rewrites.
|
||||
|
||||
Each pass adds tests: a property test (sketch ↔ float top-K agreement
|
||||
≥ 90%), a criterion bench (≥ 8× compare cost reduction), and an
|
||||
end-to-end accuracy regression test (< 1 pp drop).
|
||||
|
||||
## Validation
|
||||
|
||||
This ADR is **proposed**, not accepted. Acceptance requires the three
|
||||
acceptance numbers above to hold on **at least three of the five
|
||||
implementation passes** (the sites where the bulk of the load sits:
|
||||
AETHER re-ID, cluster-Pi novelty, and event log). The mesh-exchange
|
||||
and mincut prefilter passes are nice-to-haves; they can ship
|
||||
afterward if their per-site numbers hold.
|
||||
|
||||
Validation runs against:
|
||||
|
||||
- the existing 1,539-test workspace suite (must stay green)
|
||||
- a new `tests/integration/rabitq_sketch_pipeline.rs` integration test
|
||||
driving recorded CSI through the full pipeline with and without
|
||||
sketches, comparing top-K decisions and end-to-end pose accuracy
|
||||
- ESP32-S3 on COM7 — sensor MCU unchanged; sketch happens at the
|
||||
cluster Pi, so this validation is a smoke test that the
|
||||
sensor → Pi UDP path still works after the cluster Pi gains the
|
||||
sketch bank
|
||||
|
||||
## Related
|
||||
|
||||
- **ADR-024** (Accepted) — AETHER re-ID embeddings. Primary consumer
|
||||
of the sketch pre-filter.
|
||||
- **ADR-027** (Accepted) — Cross-environment domain generalization
|
||||
(MERIDIAN). Per-room sketch bank is the natural data structure for
|
||||
domain detection.
|
||||
- **ADR-030** (Proposed) — RuvSense persistent field model. Sketch
|
||||
novelty is the cheap second gate before SVD recompute.
|
||||
- **ADR-066** — Swarm bridge to coordinator. Inter-cluster sketch
|
||||
exchange.
|
||||
- **ADR-076** (Accepted) — CSI spectrogram embeddings. Sketch
|
||||
consumer; embedding source.
|
||||
- **ADR-081** (Accepted) — 5-layer adaptive CSI mesh firmware kernel.
|
||||
Sensor MCU unchanged by this ADR; sketches happen at the cluster Pi.
|
||||
- **ADR-083** (Proposed) — Per-cluster Pi compute hop. Defines the
|
||||
device class that hosts the sketch bank.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Does `BinaryQuantized` need a randomized rotation pre-pass for
|
||||
RuView's embedding distributions?** Pure sign quantization assumes
|
||||
zero-centered, isotropic embeddings. If AETHER / spectrogram
|
||||
distributions are skewed (likely for spectrogram), add a
|
||||
`randomized_rotation` pre-pass following the original RaBitQ paper
|
||||
(Gao & Long, SIGMOD 2024). Decided after pass-1 benchmark.
|
||||
- **Sketch dimension target.** Default to the embedding's native
|
||||
dimension (128 for AETHER, 256 for spectrogram). Higher-dimensional
|
||||
sketches (Johnson-Lindenstrauss-projected to 512) trade compute for
|
||||
recall; benchmark before committing.
|
||||
- **Per-room vs per-deployment sketch banks.** Defaulting to per-room
|
||||
for novelty detection. Cross-room re-ID may want a shared bank;
|
||||
decide once cross-room AETHER traces are available.
|
||||
@@ -1,452 +0,0 @@
|
||||
# ADR-085: RaBitQ Similarity Sensor — Pipeline Expansion (Seven Additional Sites)
|
||||
|
||||
| Field | Value |
|
||||
|----------------|------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-04-25 |
|
||||
| **Authors** | ruv |
|
||||
| **Refines** | ADR-084 (RaBitQ similarity sensor, five-site baseline) |
|
||||
| **Touches** | ADR-027 (cross-environment generalization), ADR-028 (capability audit / witness bundle), ADR-066 (swarm-bridge to coordinator), ADR-073 (multifrequency mesh scan), ADR-076 (CSI spectrogram embeddings), ADR-081 (5-layer firmware kernel), ADR-082 (confirmed-track filter), ADR-083 (per-cluster Pi compute hop) |
|
||||
| **Companion** | `v2/crates/wifi-densepose-ruvector/src/sketch.rs` (ADR-084 Pass 1 — `Sketch`, `SketchBank`, `SketchError`; on branch `feat/adr-084-pass-1-sketch-module`, commits `6fd5b7d` + `1df9d5f7d`) |
|
||||
|
||||
## Context
|
||||
|
||||
ADR-084 committed RuView to **RaBitQ-style binary sketches as a cheap
|
||||
similarity sensor** (Gao & Long, SIGMOD 2024 — arxiv 2405.12497) at
|
||||
five pipeline sites: AETHER re-ID pre-filter, cluster-Pi novelty,
|
||||
mincut subcarrier maintenance, mesh-exchange compression, and the
|
||||
privacy-preserving event log. Pass 1 of that work landed the
|
||||
`wifi-densepose-ruvector::sketch` module and benched at **43–51×
|
||||
compare speedup at d=512** and **7.5× top-K speedup at k=8 over 1024
|
||||
sketches** — comfortably above the ADR-084 acceptance threshold of
|
||||
8×. The sketch primitive is no longer an open question; the question
|
||||
is where else in the pipeline the same sensor pattern earns its keep.
|
||||
|
||||
Seven additional sites have been identified, all outside the ADR-084
|
||||
five but matching the same shape — code that asks "is this familiar?"
|
||||
against a stored set, today by way of a full float compare or model
|
||||
invocation. The unifying rule articulated alongside ADR-084 — *sketch
|
||||
first, refine on miss, store the witness hash instead of the raw
|
||||
embedding* — applies to all seven.
|
||||
|
||||
This ADR formalizes those seven sites in one document rather than
|
||||
seven small ADRs because (a) they share one primitive and one
|
||||
acceptance shape, so evaluating in isolation hides the pattern;
|
||||
(b) most involve modest code surgery (< 200 LOC at the call site)
|
||||
and an ADR-per-site would inflate the ledger without buying
|
||||
decision-resolution; (c) the few sites that *do* raise novel
|
||||
questions (Mahalanobis pre-filtering, REST similarity API shape,
|
||||
witness-hash format for non-vector data) are flagged under Open
|
||||
Questions and may spin out as follow-ups if their answers prove
|
||||
load-bearing. ADR-084 owns the primitive; ADR-085 owns the
|
||||
*deployment surface*.
|
||||
|
||||
## Decision
|
||||
|
||||
Apply the ADR-084 sketch sensor pattern at seven additional sites,
|
||||
listed in the order they will be implemented (cheapest-first /
|
||||
lowest-risk-first). Each entry states (a) **what is sketched**,
|
||||
(b) **what triggers the comparison**, (c) **what the refinement step
|
||||
on a miss is**, and (d) **what artifact stands in for the raw
|
||||
embedding** — i.e., the witness hash.
|
||||
|
||||
### Site 1 — Per-room adaptive classifier short-circuit
|
||||
|
||||
**Crate:** `wifi-densepose-sensing-server` —
|
||||
`src/adaptive_classifier.rs::classify` (per-class centroids and spread,
|
||||
Mahalanobis-like distance per frame).
|
||||
|
||||
- **Sketched:** Each per-class centroid `µ_k` (already a fixed-dim
|
||||
feature vector). Sketches live in a `SketchBank` keyed by class id,
|
||||
rebuilt whenever a class is re-trained.
|
||||
- **Trigger:** Every classification call, before the float Mahalanobis
|
||||
distance loop runs.
|
||||
- **Refinement on miss / first cut:** Hamming top-K (K = 3) selects
|
||||
candidate classes; full Mahalanobis runs only on those K. If the
|
||||
hamming top-1 disagrees with the eventual Mahalanobis winner, log
|
||||
the disagreement and fall back to full evaluation against all
|
||||
classes for that frame.
|
||||
- **Witness hash:** `sha256(centroid_bytes || spread_bytes ||
|
||||
sketch_version)` per class, recorded once at classifier-train time
|
||||
and stored alongside the sketch.
|
||||
|
||||
The sketch only narrows; Mahalanobis still decides on the K
|
||||
candidates, preserving the original distance-to-class semantics.
|
||||
Substituting Mahalanobis for the standard RaBitQ exact-distance
|
||||
re-rank step (Gao & Long 2024) is, to our knowledge, novel — Open Q1.
|
||||
|
||||
### Site 2 — Recording-search REST endpoint
|
||||
|
||||
**Crate:** `wifi-densepose-sensing-server` —
|
||||
`src/recording.rs` plus a new HTTP handler in `src/main.rs`.
|
||||
|
||||
- **Sketched:** Each recording's pooled CSI/embedding signature (mean
|
||||
AETHER embedding over the recording, or mean spectrogram embedding
|
||||
per ADR-076). One sketch per recording, stored next to the recording
|
||||
metadata.
|
||||
- **Trigger:** `GET /api/v1/recordings/similar?to=<id>&k=N` request.
|
||||
- **Refinement on miss:** Hamming top-K returns a candidate list of
|
||||
recording ids. Full embedding refinement is **opt-in** via a
|
||||
`&refine=true` query param that loads the candidate recordings'
|
||||
full embeddings (if stored) and re-ranks. Default behavior is
|
||||
sketch-only — the endpoint trades exact ranking for the ability to
|
||||
ship without storing full embeddings server-side.
|
||||
- **Witness hash:** `sha256(sketch_bytes || recording_id ||
|
||||
sketch_version)` returned in the response payload as the result row
|
||||
identifier. The raw embedding is **not retained** by default; the
|
||||
hash is the artifact a client can use to assert which sketch
|
||||
produced the match.
|
||||
|
||||
Delivers "find recordings that look like this one" without
|
||||
long-term embedding storage. The shape is closer to SimHash dedup
|
||||
APIs than to Qdrant's `/collections/{name}/points/search` (the
|
||||
closest Rust-native vector-DB endpoint, which returns full vectors)
|
||||
— deliberate; see Open Q4.
|
||||
|
||||
### Site 3 — WiFi BSSID fingerprinting (channel-hop scheduler input)
|
||||
|
||||
**Crate:** `wifi-densepose-wifiscan` —
|
||||
new `bssid_sketch` module beside the existing scan/result types.
|
||||
|
||||
- **Sketched:** A short per-BSSID time-series feature vector — recent
|
||||
RSSI, SNR, channel, beacon interval, capability flags — pooled over
|
||||
a rolling window (e.g., last 60 s). One sketch per (BSSID, window).
|
||||
- **Trigger:** Each scan tick, after the multi-BSSID scan completes.
|
||||
The current window's sketch is compared against the prior window's
|
||||
bank.
|
||||
- **Refinement on miss:** A sketch whose nearest neighbor's hamming
|
||||
distance exceeds a threshold flags the BSSID as **novel** (newly
|
||||
appeared, or known-AP-changed-beyond-recognition). The hop scheduler
|
||||
(ADR-073) reads novelty as a hint to give the affected channel
|
||||
more dwell time on the next rotation.
|
||||
- **Witness hash:** `sha256(bssid || pooled_features || sketch_version
|
||||
|| window_end_unix)` stored in the per-AP novelty log; raw
|
||||
per-BSSID time series is dropped after the sketch is taken.
|
||||
|
||||
Anomaly detection over a heterogeneous low-dim vector; acceptance
|
||||
is **false-positive rate on stable deployments**, not top-K
|
||||
coverage. IEEE 802.11bf-2025 (published March 2025) standardizes
|
||||
sensing measurement frames but not BSSID-novelty heuristics, so
|
||||
this site does not duplicate the standard's scope.
|
||||
|
||||
### Site 4 — mmWave radar signature memory
|
||||
|
||||
**Crate:** `wifi-densepose-vitals` —
|
||||
`src/preprocessor.rs` and `src/anomaly.rs` (LD2410 / MR60BHA2 input
|
||||
path).
|
||||
|
||||
- **Sketched:** A per-frame radar signature vector — range bins,
|
||||
Doppler bins, peak frequencies — sketched at the same cadence as
|
||||
the radar input (~10 Hz).
|
||||
- **Trigger:** Every incoming radar frame, before the heavy vital
|
||||
signs DSP runs. The current sketch is compared against a small
|
||||
per-room "have we seen this kind of frame before" bank.
|
||||
- **Refinement on miss:** A sketch within hamming distance of a known
|
||||
signature short-circuits to "no new event"; vital signs DSP stays
|
||||
asleep. A sketch beyond threshold wakes the full breathing/heart
|
||||
pipeline (`vitals::breathing`, `vitals::heartrate`) for one or more
|
||||
frames, then re-sleeps once the bank update settles.
|
||||
- **Witness hash:** `sha256(signature_bytes || sensor_kind ||
|
||||
sketch_version)` stored in the vitals event log; the raw radar
|
||||
frame is not retained beyond the rolling preprocessor buffer.
|
||||
|
||||
Energy is the headline: vital signs DSP (band-pass + phase-fusion +
|
||||
heart/breath FFT) is the most expensive cluster-Pi operation per
|
||||
minute of quiet-room time. Published FMCW pipelines treat the DSP
|
||||
stage as always-on after presence; **no primary source** found for
|
||||
"binary-sketch wake-gate over a per-room radar signature bank" —
|
||||
this is a direct extension of ADR-084's novelty sensor.
|
||||
|
||||
### Site 5 — Witness bundle similarity (ADR-028 release-CI signal)
|
||||
|
||||
**Crate:** Out-of-tree — addition to `scripts/generate-witness-bundle.sh`
|
||||
plus a new `scripts/witness_drift_check.py`.
|
||||
|
||||
- **Sketched:** Each release's witness bundle "fingerprint" — a fixed
|
||||
vector built from per-component SHA-256 prefixes plus numeric
|
||||
attestation values (test count, proof hash byte-segments,
|
||||
per-firmware sizes). One sketch per release.
|
||||
- **Trigger:** Run during the CI release job, after the witness
|
||||
bundle is generated and before publication.
|
||||
- **Refinement on miss:** A sketch whose hamming distance to the prior
|
||||
release exceeds threshold flags the release as **drifted** and
|
||||
surfaces the changed components in the CI summary. The release is
|
||||
not blocked; the signal is a ratchet that says "these components
|
||||
changed by more than the recent baseline, take a second look."
|
||||
- **Witness hash:** `sha256(sketch_bytes || release_tag ||
|
||||
sketch_version)` published alongside the witness bundle as
|
||||
`WITNESS-LOG-<sha>.sketch`. The full bundle is the existing artifact;
|
||||
the sketch hash is a 32-byte add-on.
|
||||
|
||||
Conservative use of the sensor — drift detection over a *very*
|
||||
small candidate set (last 5–10 releases). Existing CI drift prior
|
||||
art is autoencoder/SHAP-based commit-anomaly detection plus
|
||||
PKI-signed artifact integrity; **no primary source** for
|
||||
"binary-sketch over release-bundle fingerprint" as a CI signal.
|
||||
Acceptance: "useful ratchet without false-firing on every
|
||||
dependency bump." If no, the sketch step drops from the release
|
||||
script — most readily revertible of the seven.
|
||||
|
||||
### Site 6 — Agent / swarm memory routing
|
||||
|
||||
**Crate:** `wifi-densepose-sensing-server` —
|
||||
`src/multistatic_bridge.rs` (ADR-066 swarm-bridge channel) and the
|
||||
peer Cognitum Seed registration metadata.
|
||||
|
||||
- **Sketched:** Each Cognitum Seed's accumulated **historical bank**
|
||||
signature — a pooled mean of the sketches it has stored over a
|
||||
rolling horizon. One sketch per peer Seed; refreshed at peer
|
||||
heartbeat cadence.
|
||||
- **Trigger:** A sensor node escalates an event to the swarm. Before
|
||||
broadcasting to all peer Seeds, the cluster Pi computes the event's
|
||||
sketch and routes it to the **closest peer** by hamming distance.
|
||||
- **Refinement on miss:** No nearby peer (all hammings above threshold)
|
||||
→ broadcast to all. Nearby peer hits → unicast to that Seed first;
|
||||
only escalate to broadcast if the routed Seed cannot resolve.
|
||||
- **Witness hash:** `sha256(event_sketch || origin_seed_id ||
|
||||
routed_seed_id || sketch_version || event_unix)` recorded in the
|
||||
swarm-bridge audit log. The full event sketch is exchanged; the
|
||||
hash is the routing-decision attestation.
|
||||
|
||||
A 12-Seed swarm broadcasting every event is O(n) message storm per
|
||||
event; sketch-routing turns the common case into O(1) with O(n)
|
||||
fallback. Closest published comparator: **MasRouter** (ACL 2025),
|
||||
which routes LLM queries via a learned DeBERTa router; ADR-085's
|
||||
variant is structurally similar but uses unlearned hamming compare
|
||||
against each peer's pooled bank — cheaper, and resilient to peer
|
||||
churn.
|
||||
|
||||
### Site 7 — Log / event-stream pattern detection
|
||||
|
||||
**Crate:** `wifi-densepose-sensing-server` —
|
||||
new `src/event_anomaly.rs` module reading the cluster Pi's
|
||||
existing event stream.
|
||||
|
||||
- **Sketched:** A pooled feature vector over the recent-events window
|
||||
(last hour by default) — counts per event type, mean inter-event
|
||||
interval, sources distribution. One sketch per cluster, refreshed
|
||||
every 5 minutes.
|
||||
- **Trigger:** Every refresh tick. The current-hour sketch is compared
|
||||
against the historical bank (last 24 hours of hourly sketches).
|
||||
- **Refinement on miss:** Hamming distance above threshold flags the
|
||||
hour as **anomalous behavior**; the cluster Pi raises a single
|
||||
cluster-level alert with a pointer to the witness hash, **not** to
|
||||
the raw events. No raw events leave the Pi as part of the alert
|
||||
payload.
|
||||
- **Witness hash:** `sha256(hourly_sketch || cluster_id || hour_unix
|
||||
|| sketch_version)` recorded as the alert body. Raw events stay on
|
||||
the cluster Pi behind the existing privacy boundary.
|
||||
|
||||
The most genuinely "anomaly detection" of the seven, and most
|
||||
exposed to the non-vector witness-hash open question (event
|
||||
features are mixed counts and rates needing normalization before
|
||||
sketching). Closest published comparator: **LogAI** (Salesforce,
|
||||
Drain parser → counter vectors → unsupervised detection); ADR-085's
|
||||
variant sketches the counter vector, trading recall for constant
|
||||
memory and sub-ms compare on the cluster Pi.
|
||||
|
||||
### Witness-hash discipline
|
||||
|
||||
In every site above, the witness hash replaces the raw embedding /
|
||||
feature vector at the storage boundary — the same privacy posture
|
||||
ADR-084 introduced for the cluster-Pi event log, generalized across
|
||||
seven new contexts. The format is uniform:
|
||||
`sha256(sketch_bytes || stable_metadata || sketch_version)`. Where
|
||||
the input is not natively a dense vector (Sites 5 and 7), the
|
||||
encoding into a sketchable shape is itself a design choice — see
|
||||
Open Questions.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **The "is this familiar?" pattern becomes a first-class deployment
|
||||
primitive across REST APIs, scanning subsystems, mmWave gating,
|
||||
CI, swarm routing, and event analytics.** Each site is a modest
|
||||
win individually; together they remove the last excuses to keep
|
||||
full embeddings on every storage and exchange path.
|
||||
- **Energy and bandwidth wins compound at the cluster boundary.**
|
||||
Site 4 cuts vital signs DSP duty cycle; Site 6 cuts cross-cluster
|
||||
broadcast load. Both are at the cluster Pi, where wattage matters.
|
||||
- **Privacy story strengthens.** Every site stores a witness hash,
|
||||
not raw data. Sites 2 and 7 are explicitly designed to ship
|
||||
without retaining the embeddings or event payloads they index.
|
||||
- **Reuses ADR-084 Pass 1 with no new dependency.** The
|
||||
`wifi-densepose-ruvector::sketch` module already exposes
|
||||
`Sketch`, `SketchBank`, `SketchError` at 43–51× compare speedup.
|
||||
- **Each site is independently testable and revertible.** The seven
|
||||
passes share no data paths; failure at any one rolls back without
|
||||
touching the others.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **Mahalanobis distributional assumption (Site 1).** Pure 1-bit
|
||||
sign quantization performs best on zero-centered, isotropic
|
||||
embeddings; Mahalanobis explicitly encodes covariance structure
|
||||
hamming distance is insensitive to. The sketch is used **only**
|
||||
as a candidate-narrower; the Mahalanobis re-score preserves
|
||||
semantics. But if hamming top-K systematically excludes the true
|
||||
winner, the short-circuit is worse than no short-circuit. The
|
||||
Validation acceptance test guards this; randomized rotation
|
||||
pre-pass (RaBitQ-paper-style) may be needed — see Open Q1.
|
||||
- **REST endpoint shape (Site 2) is an API surface commitment.**
|
||||
A `GET /api/v1/recordings/similar` with a sketch-only default
|
||||
is a contract; clients expect approximate-recall behavior.
|
||||
Documenting "sketch-only by default, `&refine=true` for full
|
||||
re-ranking" is part of the acceptance bar.
|
||||
- **False-positive risk on Site 3 (BSSID novelty)** in dynamic
|
||||
environments. Coffee-shop / co-working deployments see BSSIDs
|
||||
rotate constantly; the signal must flag *unexpected* change,
|
||||
not background churn — acceptance is framed accordingly.
|
||||
- **Witness-hash format for non-vector inputs (Sites 5 and 7).**
|
||||
Witness bundles and event streams are not natively dense-vector
|
||||
data; the encoding into sketchable form (numeric SHA-prefix
|
||||
segments; normalized event-type histograms) is itself a design
|
||||
choice future model changes can break. `sketch_version` bumps
|
||||
invalidate banks everywhere, but only Sites 5 and 7 must
|
||||
re-encode raw inputs.
|
||||
- **Operational surface area.** Seven banks each with their own
|
||||
persistence, version-skew, and refresh story. The cluster Pi
|
||||
gains non-trivial state. ADR-083's secure-boot / OTA story
|
||||
holds, but state-rebuild cost on `sketch_version` bump is now
|
||||
seven banks, not one.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The five ADR-084 sites and the seven sites here are independent.
|
||||
Acceptance or rollback at any one site does not propagate.
|
||||
- ADR-082 (confirmed-track filter) remains upstream of every sketch
|
||||
call. ADR-081 (5-layer firmware kernel) is unchanged — every new
|
||||
bank lives at the cluster Pi or higher.
|
||||
- ADR-027 (cross-environment generalization, MERIDIAN) interacts
|
||||
cleanly: Site 1's per-class sketches are *per environment* by
|
||||
construction, which is the same shape MERIDIAN already assumes.
|
||||
|
||||
## Implementation
|
||||
|
||||
Seven passes, ordered cheapest-first / lowest-risk-first. Each is
|
||||
independently shippable; each has a single-line acceptance test that
|
||||
must pass before the next pass starts.
|
||||
|
||||
| # | Pass | Target crate | Acceptance test (one line) |
|
||||
|---|------|--------------|----------------------------|
|
||||
| 1 | **Witness bundle drift sketch** (Site 5) | `scripts/witness_drift_check.py` | CI run on the last 5 releases produces ≥ 1 drift flag on a known dependency-bump release and 0 flags on a known no-op release. |
|
||||
| 2 | **BSSID fingerprint novelty** (Site 3) | `wifi-densepose-wifiscan::bssid_sketch` | 24-hour soak in a stable office: novelty rate ≤ 5 events / hour; controlled new-AP injection: novelty fires within 2 scan cycles. |
|
||||
| 3 | **mmWave signature gate** (Site 4) | `wifi-densepose-vitals::preprocessor` | Vitals DSP CPU time / hour ≥ 4× lower in steady-state empty-room compared to no-gate baseline; missed-detection regression ≤ 1 pp on the existing breathing/heart fixtures. |
|
||||
| 4 | **Adaptive classifier short-circuit** (Site 1) | `wifi-densepose-sensing-server::adaptive_classifier` | Per-frame `classify` time reduced ≥ 2× at K = 3 candidates; classification accuracy regression ≤ 1 pp on the held-out test set. |
|
||||
| 5 | **Event-stream anomaly sketch** (Site 7) | `wifi-densepose-sensing-server::event_anomaly` | 7-day rolling deployment: ≤ 1 false anomaly / day; injection of a synthetic anomalous hour fires within one refresh tick. |
|
||||
| 6 | **Swarm memory routing** (Site 6) | `wifi-densepose-sensing-server::multistatic_bridge` | 12-Seed simulated swarm: per-event broadcast-message count drops ≥ 5× vs. unrouted baseline; routed-Seed-resolution rate ≥ 80%. |
|
||||
| 7 | **Recording-search REST endpoint** (Site 2) | `wifi-densepose-sensing-server::recording` + HTTP route | `GET /api/v1/recordings/similar` returns a top-K with ≥ 90% candidate-set agreement vs. full-embedding re-rank on the recorded dataset; response time < 50 ms at K = 10 over 1000 recordings. |
|
||||
|
||||
ADR-084's general acceptance numbers — **8–30× compare cost
|
||||
reduction, ≥ 90% top-K coverage, < 1 pp accuracy regression** —
|
||||
apply unchanged to Sites 1 (classifier) and 2 (recording search),
|
||||
where the candidate set is large and top-K coverage is the right
|
||||
framing. Sites 3, 4, 5, 6 are gating / anomaly / routing problems
|
||||
measured against site-specific criteria above (false-positive rate,
|
||||
DSP duty cycle, broadcast count, drift-flag precision). Each pass
|
||||
adds three tests under `v2/crates/<target>/tests/`: property test
|
||||
(sketch ↔ float top-K where applicable), criterion bench
|
||||
(compare-cost ratio), end-to-end regression against recorded data.
|
||||
Benches reuse the ADR-084 Pass 1 harness.
|
||||
|
||||
## Validation
|
||||
|
||||
This ADR is **Proposed**. Acceptance requires **at least four of
|
||||
seven passes** to meet their per-row acceptance test. The four
|
||||
must-haves are: **Site 1** (per-frame cost; Mahalanobis assumption
|
||||
load-bearing), **Site 4** (cluster-Pi energy), **Site 6**
|
||||
(cross-cluster bandwidth), **Site 7** (privacy-preserving anomaly).
|
||||
Sites 2, 3, 5 are nice-to-haves and may ship or revert
|
||||
independently.
|
||||
|
||||
Validation runs against:
|
||||
|
||||
- existing workspace tests (must stay green at
|
||||
`cargo test --workspace --no-default-features` on `v2/`);
|
||||
- a 7-day cluster-Pi soak at the lab fixture (3 sensor nodes + 1 Pi
|
||||
per ADR-083) with recordings, mmWave, and BSSID scans active —
|
||||
per-site logs graded against the Implementation table;
|
||||
- Python proof harness unchanged (`archive/v1/data/proof/verify.py`
|
||||
must still print `VERDICT: PASS`);
|
||||
- regenerated witness bundle (ADR-028) including the Site 5 sketch.
|
||||
|
||||
When the four must-haves pass and the soak holds, ADR moves
|
||||
**Proposed → Accepted** and README hardware/feature tables gain a
|
||||
sketch-bank row.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does Mahalanobis pre-filtering survive sign-quantization bias
|
||||
on Site 1?** Pure 1-bit sketches discard the covariance
|
||||
structure Mahalanobis uses. The pass-1 framing — sketch narrows,
|
||||
Mahalanobis decides — preserves correctness in expectation, but
|
||||
adversarial centroid geometries can let the hamming top-K
|
||||
systematically exclude the true winner. **No primary source
|
||||
found** for "binary-sketch + Mahalanobis-refine" as a published
|
||||
pipeline; marked as conjecture, gated by the Site-1 acceptance
|
||||
test. If it fails, the next experiment is the randomized
|
||||
rotation pre-pass from Gao & Long (SIGMOD 2024, arxiv
|
||||
2405.12497), which ADR-084 also flagged for AETHER /
|
||||
spectrogram embeddings. A standalone follow-up ADR is the
|
||||
likely outcome if rotation is needed.
|
||||
2. **Witness-hash format for non-vector data (Sites 5, 7).** The
|
||||
release bundle (Site 5) and event stream (Site 7) are not
|
||||
natively dense-vector inputs. The proposed encodings — numeric
|
||||
SHA-256-prefix segments plus attestation values for Site 5;
|
||||
normalized event-type histograms for Site 7 — are plausible
|
||||
but unvalidated against drift in the underlying distributions.
|
||||
A small follow-up ADR formalizing the "non-vector → sketchable"
|
||||
canonical path is plausible if the two sites diverge.
|
||||
3. **Cross-environment domain generalization interaction
|
||||
(ADR-027).** Per-class sketches in Site 1 and per-room banks at
|
||||
Sites 4 and 7 are implicitly per-environment artifacts; ADR-027
|
||||
(MERIDIAN) handles cross-environment generalization at the model
|
||||
layer. When MERIDIAN's domain detector flags an environment
|
||||
shift, do banks rebuild, swap, or merge? Default here is
|
||||
**rebuild on shift**; a merge story may be cheaper and is open
|
||||
for the eventual MERIDIAN-aware deployment.
|
||||
4. **REST API shape for Site 2.** The choice between
|
||||
Qdrant/Pinecone/Weaviate-style endpoints (Qdrant being the
|
||||
closest Rust-native comparator with HTTP `/points/search`) and
|
||||
a thin sketch-only response is intentionally opinionated
|
||||
toward the thin shape. **No Rust-idiom primary source** was
|
||||
located for "sketch-only similarity search over recordings"
|
||||
specifically; closest analog is SimHash-over-documents
|
||||
deduplication, which lacks time-series-recording prior art.
|
||||
If a clean Rust crate emerges owning this idiom, Site 2 may
|
||||
delegate rather than ship bespoke.
|
||||
5. **BSSID novelty and 802.11bf-2025 interaction.** IEEE 802.11bf
|
||||
was published in March 2025 and standardizes WLAN sensing
|
||||
measurement frames; Site 3's novelty sketch operates above the
|
||||
measurement layer (on RSSI/SNR/channel time-series) and should
|
||||
not duplicate what 802.11bf eventually exposes natively. **No
|
||||
primary source found** for "RSSI-fingerprint anomaly + 802.11bf"
|
||||
— marked as conjecture; revisit when client/AP support arrives.
|
||||
|
||||
## Related
|
||||
|
||||
- **ADR-027** (Proposed) — MERIDIAN cross-environment generalization.
|
||||
Per-environment sketch banks (Sites 1, 4, 7) need an explicit
|
||||
swap/rebuild story under MERIDIAN-detected domain shifts.
|
||||
- **ADR-028** (Accepted) — ESP32 capability audit / witness bundle.
|
||||
Site 5 adds a sketch ratchet to the existing release artifact.
|
||||
- **ADR-066** (Proposed) — Swarm bridge to coordinator. Site 6 routes
|
||||
over the bridge channel ADR-066 defines.
|
||||
- **ADR-073** (Proposed) — Multifrequency mesh scan. Site 3's
|
||||
BSSID novelty feeds the hop scheduler ADR-073 owns.
|
||||
- **ADR-076** (Proposed) — CSI spectrogram embeddings. Site 2's
|
||||
recording-search sketch can pool over spectrogram embeddings
|
||||
when present, or fall back to AETHER means.
|
||||
- **ADR-081** (Accepted) — 5-layer adaptive CSI mesh firmware kernel.
|
||||
No firmware change; every new sketch bank is at the cluster Pi
|
||||
or higher.
|
||||
- **ADR-082** (Accepted) — Pose tracker confirmed-track filter.
|
||||
Upstream of every sketch call; unchanged.
|
||||
- **ADR-083** (Proposed) — Per-cluster Pi compute hop. The Pi is
|
||||
the host for all seven new banks; ADR-083's deployment story is
|
||||
the prerequisite.
|
||||
- **ADR-084** (Proposed) — RaBitQ similarity sensor (five-site
|
||||
baseline). This ADR refines and extends; it does not duplicate
|
||||
ADR-084's compare-cost / top-K / accuracy acceptance numbers
|
||||
where unchanged.
|
||||
@@ -1,423 +0,0 @@
|
||||
# ADR-086: Edge Novelty Gate — Push the RaBitQ Sensor Down to the Sensor MCU
|
||||
|
||||
| Field | Value |
|
||||
|----------------|----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-04-26 |
|
||||
| **Authors** | ruv |
|
||||
| **Refines** | ADR-081 (5-layer adaptive CSI mesh firmware kernel — Layer 4 / On-device feature extraction), ADR-084 (RaBitQ similarity sensor) |
|
||||
| **Touches** | ADR-018 (binary CSI frame magic discipline), ADR-028 (capability audit / witness verification), ADR-082 (confirmed-track output filter), ADR-085 (RaBitQ pipeline expansion) |
|
||||
| **Companion** | `firmware/esp32-csi-node/main/rv_feature_state.h` (current `0xC5110006` v6 wire format), `docs/research/architecture/three-tier-rust-node.md` (BQ24074 power budget context), `vendor/ruvector/crates/ruvector-core/src/quantization.rs::BinaryQuantized` (std reference implementation that this ADR will not directly reuse on-MCU) |
|
||||
|
||||
## Context
|
||||
|
||||
ADR-081's 5-layer firmware kernel today emits one `rv_feature_state_t`
|
||||
packet per node every 100–1000 ms (1–10 Hz, default 5 Hz on COM7),
|
||||
60 bytes payload, magic `0xC5110006`, regardless of how interesting
|
||||
the underlying CSI window was. At a 5 Hz baseline the per-node steady-
|
||||
state load is ~300 B/s of UDP plus the radio TX duty that emits it.
|
||||
Across a 12-node deployment the cluster Pi sees ~3.6 kB/s of
|
||||
feature-state — not a bandwidth crisis on its own, but every one of
|
||||
those packets also costs sensor-MCU radio TX energy, every one
|
||||
contends for ESP-WIFI-MESH airtime per ADR-081 Layer 3, and every one
|
||||
runs through the cluster-Pi novelty bank ADR-084 Pass 3 only to be
|
||||
classified as "nothing new" most of the time in a quiet room.
|
||||
|
||||
ADR-084 made novelty cheap on the cluster-Pi side. The same novelty
|
||||
sensor is structurally local: a sketch, a small ring of recent
|
||||
sketches, and a hamming-distance compare. Pushing that gate down into
|
||||
the sensor MCU's Layer 4 (On-device feature extraction) lets the node
|
||||
*not transmit* a frame the cluster-Pi would have filed under
|
||||
"familiar" anyway. Bandwidth, sensor-MCU TX energy, and RF airtime
|
||||
all win, and the cluster-Pi novelty path stops re-doing work the edge
|
||||
already proved pointless. This is the natural ADR-085 follow-up
|
||||
flagged but deliberately left out of the ADR-085 scope because it
|
||||
requires a `no_std` sketch port, a Kconfig-gated rollout, a wire-
|
||||
format bump, and a fresh witness regeneration — none of which are
|
||||
appropriate inside an in-flight cluster-Pi work loop.
|
||||
|
||||
The crux of the decision is whether the cost of (a) hand-porting the
|
||||
sketch primitive to `no_std` Xtensa LX7, (b) sizing the in-IRAM ring
|
||||
without disturbing the existing Layer 4 budget, (c) bumping the
|
||||
`rv_feature_state_t` magic and teaching the cluster-Pi a graceful
|
||||
v6/v7 fallback, and (d) re-cutting the ADR-028 witness bundle is
|
||||
justified by the suppression rate the gate actually achieves on real
|
||||
deployments. The answer should be obvious in stable rooms (≥50 %
|
||||
suppression looks easy) and ambiguous in active rooms (suppression
|
||||
should drop sharply, which is exactly what we want). This ADR commits
|
||||
to numbers up front so the decision is falsifiable.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt an **edge novelty gate** in the sensor MCU's Layer 4 of
|
||||
ADR-081's 5-layer kernel. The gate sits between feature extraction
|
||||
and the existing UDP send path; when novelty is below a configurable
|
||||
threshold the frame is **not transmitted**, and the node accumulates
|
||||
a per-source `suppressed_since_last` counter that is folded into the
|
||||
next non-suppressed packet. This keeps the cluster-Pi's books
|
||||
honest — the edge can suppress *bandwidth*, but it can never
|
||||
silently suppress the *fact of suppression*.
|
||||
|
||||
### Components
|
||||
|
||||
The implementation is two pieces, both new in
|
||||
`firmware/esp32-csi-node/main/`:
|
||||
|
||||
1. **`rv_sketch.{h,c}`** — a `no_std`-equivalent (plain C, ESP-IDF)
|
||||
1-bit sketch primitive. Sign-quantize a feature vector, pack into
|
||||
bytes (`(dim + 7) / 8` bytes), hamming distance via 8-bit
|
||||
table-lookup popcount. Xtensa LX7 has no hardware POPCNT
|
||||
instruction (no primary source consulted; conjecture based on the
|
||||
ESP32-S3 TRM not advertising one — to be confirmed by checking
|
||||
the [TRM](https://www.espressif.com/sites/default/files/documentation/esp32-s3_technical_reference_manual_en.pdf)
|
||||
under bit-manipulation extensions); the table-lookup scalar
|
||||
baseline is the right starting point and is already what
|
||||
`BinaryQuantized` falls back to on architectures without a SIMD
|
||||
POPCNT path (`vendor/ruvector/crates/ruvector-core/src/quantization.rs`,
|
||||
lines 332–340).
|
||||
2. **An IRAM-resident sketch ring.** Fixed size at compile time:
|
||||
`RV_EDGE_BANK_SIZE` slots × `RV_EDGE_VECTOR_DIM_BYTES` bytes.
|
||||
For the default Layer 4 feature dimension of 56 (matching the
|
||||
subcarrier-selection / interpolation target widely used in this
|
||||
codebase), the ring at the default 32 slots costs
|
||||
`32 × 7 = 224 bytes`. A 64-slot ring at 56 d costs 448 bytes — both
|
||||
sit comfortably inside the existing static-memory budget on either
|
||||
the 4 MB or 8 MB Waveshare AMOLED ESP32-S3 board, well clear of
|
||||
ADR-081 Layer 4's existing window buffers. Eviction is FIFO; on
|
||||
each new sketch the oldest is overwritten.
|
||||
|
||||
### Gating policy
|
||||
|
||||
For each completed Layer 4 feature window:
|
||||
|
||||
```text
|
||||
1. compute feature vector (existing)
|
||||
2. sketch = sign_quantize(feature_vector) // new
|
||||
3. nearest_hamming = ring_min_distance(sketch) // new
|
||||
4. novelty = nearest_hamming / dim // 0..1, new
|
||||
5. if novelty >= CONFIG_RV_EDGE_NOVELTY_THRESHOLD
|
||||
OR suppressed_since_last >= CONFIG_RV_EDGE_MAX_CONSEC_SUPPRESS
|
||||
OR CONFIG_RV_EDGE_FORCE_SEND:
|
||||
ring_insert(sketch)
|
||||
emit rv_feature_state_t v7 with suppressed_since_last
|
||||
suppressed_since_last = 0
|
||||
else:
|
||||
suppressed_since_last += 1
|
||||
// do not insert into ring — only confirmed-emitted sketches anchor the bank
|
||||
```
|
||||
|
||||
Threshold default: `CONFIG_RV_EDGE_NOVELTY_THRESHOLD = 500`
|
||||
basis-points (= 5.0 % of dimension). Kconfig does not accept floats
|
||||
without contortion (the standard Espressif practice in our codebase
|
||||
is to express thresholds as `int` basis-points or scaled fixed-point);
|
||||
this preserves the Kconfig-as-truth discipline ADR-081 already
|
||||
follows.
|
||||
|
||||
Suppression cap default:
|
||||
`CONFIG_RV_EDGE_MAX_CONSEC_SUPPRESS = 50`. At 5 Hz that is 10 s of
|
||||
forced silence at most before a "stuck gate" self-heals into a
|
||||
forced send — comparable to ADR-081's slow-loop 30 s recalibration
|
||||
cadence and well below any user-visible UI staleness threshold.
|
||||
|
||||
Default-off gate: `CONFIG_RV_EDGE_NOVELTY_GATE_ENABLE = n`. Existing
|
||||
deployments behave identically until they opt in.
|
||||
|
||||
### Wire format — v7
|
||||
|
||||
Bump the `rv_feature_state_t` magic to `0xC5110007` and add three
|
||||
bytes by reusing the existing 2-byte `reserved` field plus one byte
|
||||
borrowed from the 16-bit `quality_flags` budget (only 8 of 16 flags
|
||||
are defined today; we narrow to `uint8_t quality_flags`):
|
||||
|
||||
| Offset (v7) | Field | Notes |
|
||||
|-------------|-----------------------------|--------------------------------------|
|
||||
| 0..3 | `magic = 0xC5110007` | new; differentiates from `0xC5110006` |
|
||||
| 4 | `node_id` | unchanged |
|
||||
| 5 | `mode` | unchanged |
|
||||
| 6..7 | `seq` | unchanged |
|
||||
| 8..15 | `ts_us` | unchanged |
|
||||
| 16..51 | nine `float` features | unchanged |
|
||||
| 52 | `quality_flags` (`uint8_t`) | narrowed from u16 — see Open Q3 |
|
||||
| 53 | `gate_version` (`uint8_t`) | new |
|
||||
| 54..55 | `suppressed_since_last` | new (`uint16_t` LE) |
|
||||
| 56..59 | `crc32` | unchanged, computed over [0..56) |
|
||||
|
||||
Total size: still 60 bytes, **wire-compatible at packet length but
|
||||
not at field semantics** — magic is the discriminator. Cluster-Pi
|
||||
receivers that recognize `0xC5110007` interpret the new fields;
|
||||
receivers that recognize `0xC5110006` continue to work but do not
|
||||
see the suppression count. The receiver gracefully falls back when
|
||||
it sees the v6 magic; this is the explicit graceful-fallback contract
|
||||
ADR-081 already established for Layer 5 stream parsing.
|
||||
|
||||
The choice to narrow `quality_flags` from 16 to 8 bits relies on the
|
||||
fact that `rv_feature_state.h` defines exactly 8 `RV_QFLAG_*` bits
|
||||
today (lines 33–40); future flag growth is a separate ADR slot, and
|
||||
the alternative — adding a 4th `uint8_t` and growing the packet to
|
||||
64 bytes — costs a recompute of every Layer 5 parser and is more
|
||||
intrusive than the magic bump.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Sensor-MCU UDP TX duty cycle drops by the suppression rate.** A
|
||||
back-of-envelope at 5 Hz: at 50 % suppression, ~150 B/s and
|
||||
~2.5 packets/s per node instead of ~300 B/s and 5; at 90 %
|
||||
suppression, ~30 B/s and 0.5 packets/s. ESP32-S3 TX energy at
|
||||
+20 dBm is the dominant per-packet cost on the BQ24074-class node
|
||||
(`docs/research/architecture/three-tier-rust-node.md` §3.3 power
|
||||
budget shows ~80 mA active-CSI baseline with TX-burst spikes at
|
||||
~150 mA peak; the gate primarily cuts the burst-frequency rather
|
||||
than the baseline). ≥30 % TX-energy reduction in steady-state quiet
|
||||
rooms is the validation target.
|
||||
- **Cluster-Pi novelty path runs on a smaller stream.** ADR-084
|
||||
Pass 3 is unchanged in code, but the input rate it processes drops
|
||||
by the suppression rate. The Pi-side bank stops accumulating
|
||||
redundant "stable" anchors and concentrates its bank slots on
|
||||
actually-different frames. This is a quality win, not just a cost
|
||||
win.
|
||||
- **Mesh airtime contention drops, which improves ADR-081 Layer 3
|
||||
for everyone else.** Less feature-state traffic frees airtime for
|
||||
TIME_SYNC, ROLE_ASSIGN, FEATURE_DELTA, HEALTH, and ANOMALY_ALERT
|
||||
— the high-priority mesh-control traffic that today competes with
|
||||
routine feature-state in the same channel.
|
||||
- **`suppressed_since_last` is observable.** The cluster-Pi can
|
||||
detect a node that has been suppressing for too long, a node
|
||||
whose suppression rate suddenly drops (occupant entered the
|
||||
room — the right behaviour), and a node whose suppression cap is
|
||||
triggering frequently (gate is mistuned). All three are useful
|
||||
signals and all three live in fields the receiver already parses.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **The cluster-Pi-side novelty sensor sees fewer data points.** This
|
||||
is the load-bearing negative consequence and the most likely
|
||||
source of regression. ADR-084 Pass 3's bank ages out anchors based
|
||||
on insertion time; if the edge gate suppresses 70 % of frames in
|
||||
a quiet room, the Pi bank receives 30 % of its expected anchor
|
||||
rate and may take 3× longer to converge to a useful steady state
|
||||
on a freshly-rebooted Pi. Mitigation: the validation acceptance
|
||||
test runs the Pi-side novelty top-K coverage against an
|
||||
unsuppressed baseline and budgets ≤5 percentage points regression.
|
||||
If the cluster-Pi cold-start convergence becomes a real problem
|
||||
the simplest patch is to force-send the first
|
||||
`CONFIG_RV_EDGE_FORCE_SEND_BURST` (default 32) frames per
|
||||
Layer 2 slow-loop recalibration window — but this lives outside
|
||||
the ADR-086 baseline and is called out as a follow-up if needed.
|
||||
- **Witness chain.** Per ADR-028, every change to firmware
|
||||
invalidates the witness bundle. Edge novelty gate is a non-trivial
|
||||
firmware change: it touches Layer 4, adds a wire-format magic,
|
||||
and ships a Kconfig surface. The witness bundle must be re-cut
|
||||
and the SHA-256 of the proof bundle is **expected** to change
|
||||
(which is the whole point of the witness — the change must be
|
||||
visible). The post-change validation step is to run
|
||||
`bash scripts/generate-witness-bundle.sh` and confirm 7/7 PASS
|
||||
via `dist/witness-bundle-ADR028-*/VERIFY.sh`.
|
||||
- **Two wire-format magics in the field at once.** During rollout
|
||||
some nodes emit v6 and some v7. The cluster-Pi receiver must
|
||||
handle both, and the WebSocket "latest snapshot" path must not
|
||||
accidentally null-out the new fields when re-encoding for v6
|
||||
consumers. The graceful-fallback contract is small (~30 LOC on
|
||||
the Pi), but it is a contract and breaking it loses observability
|
||||
for the v7 nodes. Validation includes a mixed-version soak.
|
||||
- **Pose-tracker interaction (Open Q4).** ADR-082 added a confirmed-
|
||||
track output filter that already drops single-frame phantom poses
|
||||
before they reach the WebSocket. The edge gate could *suppress
|
||||
the very frames* that would have promoted a pose track from
|
||||
Tentative to Active — i.e., a person walks through a quiet room
|
||||
and the first 1–2 frames look "low novelty" because the gate
|
||||
hasn't seen them yet, then the gate suddenly fires and emits the
|
||||
third frame. ADR-082's three-frame minimum could miss a real pose.
|
||||
Mitigation candidates: (a) lower the threshold during ADR-082
|
||||
Tentative-state minutes; (b) treat motion_score above a fixed
|
||||
floor as a force-send signal regardless of sketch novelty;
|
||||
(c) accept the regression as part of the "novelty is precisely
|
||||
what we wanted to gate on" framing. Decision deferred — Open Q4.
|
||||
- **Operator debuggability.** A development-time
|
||||
`CONFIG_RV_EDGE_FORCE_SEND` Kconfig flag bypasses the gate
|
||||
entirely and is the right tool for diffing
|
||||
with-gate vs without-gate behaviour during a deployment. Required.
|
||||
|
||||
### Neutral
|
||||
|
||||
- ADR-018's binary CSI frame stream is unchanged; the gate operates
|
||||
on Layer 4 feature state, not on the debug raw-CSI path.
|
||||
- ADR-085's seven cluster-Pi-side sketch sites that consume
|
||||
`rv_feature_state_t` see *fewer* inputs but the same shape;
|
||||
Sites 6 (swarm routing) and 7 (event-stream anomaly) will be
|
||||
slightly less sensitive under v7. Re-measurement is recommended
|
||||
but is not a blocker for ADR-086.
|
||||
|
||||
## Implementation
|
||||
|
||||
Six numbered passes, ordered cheapest-first / lowest-risk-first.
|
||||
Each is independently shippable, each has a one-line acceptance
|
||||
criterion that must pass before the next pass starts. Default-off
|
||||
Kconfig means none of these passes can break a deployment that has
|
||||
not opted in.
|
||||
|
||||
| # | Pass | Target | Acceptance |
|
||||
|---|------|--------|------------|
|
||||
| 1 | **`no_std` sketch primitive port** (`firmware/esp32-csi-node/main/rv_sketch.{h,c}`) | sensor-MCU C | QEMU unit test: 56-d sign-quantize of a fixed seed produces the bit-pattern matching the host-side reference; hamming distance round-trips. |
|
||||
| 2 | **IRAM ring + insert/min-distance API** | sensor-MCU C | On-target benchmark on COM7: insert + ring-min on 32 slots ≤ 200 µs at 240 MHz. |
|
||||
| 3 | **Kconfig flags** (`CONFIG_RV_EDGE_NOVELTY_GATE_ENABLE`, `_THRESHOLD`, `_MAX_CONSEC_SUPPRESS`, `_FORCE_SEND`) | `firmware/esp32-csi-node/main/Kconfig.projbuild` | Build with each flag toggled produces the expected `sdkconfig.defaults` merge; unit test asserts threshold of 500 bps maps to 5.0 % decision boundary. |
|
||||
| 4 | **`rv_feature_state_t` v7 wire format + finalize() update** | `firmware/esp32-csi-node/main/rv_feature_state.{h,c}` | `_Static_assert(sizeof == 60)` still holds; CRC32 over the new layout round-trips; v6 receiver test reads a v7 packet without panic and ignores the new fields. |
|
||||
| 5 | **Cluster-Pi reconciliation** | `crates/wifi-densepose-sensing-server/` UDP intake + ADR-084 Pass 3 novelty bank | A v7 packet with `suppressed_since_last = N` causes the Pi-side bank to interpret the gap as low-novelty stable-baseline contribution rather than as missing data; integration test on a synthetic v7 stream. |
|
||||
| 6 | **QEMU + COM7 hardware-in-loop validation** | end-to-end | Stable-room recording: ≥50 % suppression rate; cluster-Pi novelty top-K coverage regression ≤ 5 pp vs unsuppressed baseline; stuck-gate self-heal exercised in a unit test. |
|
||||
|
||||
Pass 1 deliberately does not depend on
|
||||
`vendor/ruvector/crates/ruvector-core::BinaryQuantized`. That crate
|
||||
is `std`-bound (`Vec<u8>`, `is_x86_feature_detected!`, NEON
|
||||
intrinsics — `quantization.rs` lines 289–340) and porting it to
|
||||
`no_std` Xtensa LX7 is not a one-line `#![no_std]` flip. The clean
|
||||
path is a fresh minimal C primitive that matches the
|
||||
`BinaryQuantized` *behaviour* (sign quantization, byte-table popcount
|
||||
fallback, `(dim+7)/8` packed bytes); the host-side reference becomes
|
||||
a **spec**, not a dependency. A future `no_std`-clean Rust port may
|
||||
unify both once `esp-radio` / `esp-csi-rs` matures (three-tier node
|
||||
research §7.3) — out of scope here.
|
||||
|
||||
## Validation
|
||||
|
||||
This ADR is **Proposed**. Acceptance requires every numbered Pass to
|
||||
meet its acceptance criterion *and* the following system-level
|
||||
numbers to hold on the COM7 hardware-in-loop run:
|
||||
|
||||
- **Computation budget**: sketch insert + ring-min ≤ 200 µs;
|
||||
total per-frame Layer 4 overhead (existing feature extraction +
|
||||
new gate) ≤ 500 µs at 240 MHz Xtensa LX7.
|
||||
- **Energy**: ≥ 30 % UDP TX-energy reduction in stable-room
|
||||
scenarios, measured by packets-per-second × per-packet TX duty
|
||||
against an unsuppressed baseline. Direct mA-level measurement is
|
||||
out of scope for this ADR; the proxy metric is sufficient.
|
||||
- **Cluster-Pi accuracy**: ≤ 5 percentage-point drop on the
|
||||
ADR-084 Pass 3 novelty top-K coverage metric vs an unsuppressed
|
||||
baseline run on the same recorded CSI.
|
||||
- **Bandwidth**: ≥ 50 % reduction in steady-state quiet-room UDP
|
||||
byte rate per node.
|
||||
- **Stuck-gate self-heal**: a unit test that pins the sketch
|
||||
primitive output to "always low novelty" must observe a forced
|
||||
send within ≤ 10 s (≤ 50 frames at 5 Hz).
|
||||
- **Existing test gates**: `cargo test --workspace
|
||||
--no-default-features` stays green; `python v1/data/proof/verify.py`
|
||||
stays green (the proof harness sees no firmware-side change and
|
||||
the SHA-256 should not move because the proof exercises Python
|
||||
pipeline math, not firmware behaviour); the witness bundle
|
||||
(`scripts/generate-witness-bundle.sh`) runs and the resulting
|
||||
`VERIFY.sh` reports 7/7 PASS — **the bundle's own SHA-256 will
|
||||
differ**, which is the witness-chain signal that firmware
|
||||
changed.
|
||||
|
||||
If any system-level number fails, the gate ships behind
|
||||
`CONFIG_RV_EDGE_NOVELTY_GATE_ENABLE = n` (default-off) and the ADR
|
||||
moves to **Rejected** for that hardware target while the wire-format
|
||||
v7 changes are kept (they cost nothing dormant). If only the cluster-
|
||||
Pi accuracy number fails, the gate is allowed to ship at a more
|
||||
conservative `CONFIG_RV_EDGE_NOVELTY_THRESHOLD` until the cluster-
|
||||
Pi-side reconciliation logic catches up.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does Xtensa LX7's lack of POPCNT make the table-lookup scalar
|
||||
baseline fast enough at 5 Hz?** **No primary-source confirmation
|
||||
performed — conjecture** (the ESP32-S3 TRM is the primary
|
||||
source). At 7 bytes/sketch × 32 slots = 224 bytes of popcount
|
||||
per frame, even a pessimistic 100-cycles-per-byte estimate sits
|
||||
well under 200 µs at 240 MHz; Pass 2 bench resolves it.
|
||||
2. **Should the IRAM ring be replaced by PSRAM-backed storage when
|
||||
the board has it?** The 8 MB-flash Waveshare AMOLED ESP32-S3
|
||||
ships with 8 MB PSRAM (CLAUDE.md hardware table; not a primary
|
||||
source — the board datasheet is); the ring at 32 slots × 7 bytes
|
||||
does not need PSRAM. A larger ring (1024 slots × 7 bytes ≈ 7 kB)
|
||||
to keep a longer history would benefit from PSRAM. The default
|
||||
IRAM-only sizing is the correct ship-now choice; PSRAM-backed
|
||||
is an open follow-up if the cluster-Pi reconciliation logic
|
||||
needs more history than 32 slots provides.
|
||||
3. **Where does `gate_version: u8` come from?** Three options:
|
||||
(a) Kconfig-pinned at firmware build time;
|
||||
(b) NVS-stored and bumped at provision time;
|
||||
(c) embedded as a build-id byte derived from the firmware
|
||||
manifest. Default: option (a), Kconfig-pinned. Rationale: the
|
||||
gate version is part of the firmware contract, not the per-
|
||||
deployment configuration. NVS is the wrong namespace; the build-
|
||||
id approach is more robust to provisioning slips but harder to
|
||||
compare across deployments. The decision is reversible — the
|
||||
field width is fixed at 8 bits regardless of source.
|
||||
4. **Interaction with ADR-082 (pose-tracker confirmed-track
|
||||
filter).** The gate could legitimately suppress the very frames
|
||||
that would have promoted a Tentative track to Active in
|
||||
ADR-082's three-frame minimum. The risk is asymmetric: false-
|
||||
positive ghost poses are filtered by ADR-082 (correct), but
|
||||
false-negative-real poses are *enabled* by the edge gate
|
||||
suppressing real-but-quiet first frames. Mitigations are listed
|
||||
in Consequences; the ADR commits to (a) Tentative-state-aware
|
||||
threshold tuning if the validation regression on the pose
|
||||
recall metric exceeds 2 percentage points, and (b) keeping
|
||||
`motion_score >= 0.05` as an unconditional force-send override
|
||||
inside the gate. Open Q because the right mitigation depends on
|
||||
the measured regression.
|
||||
|
||||
## Related
|
||||
|
||||
- **ADR-018** (Accepted) — Binary CSI frame magic discipline. The
|
||||
v7 wire format follows the same magic-bump pattern.
|
||||
- **ADR-028** (Accepted) — Capability audit / witness verification.
|
||||
Re-cut the bundle after this ADR ships; the SHA is *expected* to
|
||||
change.
|
||||
- **ADR-081** (Accepted) — 5-layer adaptive CSI mesh firmware
|
||||
kernel. ADR-086 is a Layer 4 refinement.
|
||||
- **ADR-082** (Accepted) — Pose-tracker confirmed-track filter.
|
||||
Open Q4 above.
|
||||
- **ADR-084** (Proposed) — RaBitQ similarity sensor. The cluster-
|
||||
Pi reference for the same gate this ADR pushes to the edge.
|
||||
- **ADR-085** (Proposed) — RaBitQ pipeline expansion. Seven
|
||||
cluster-Pi-side sites; ADR-086 is the deliberately-out-of-scope
|
||||
edge follow-up flagged at ADR-085 publication time.
|
||||
|
||||
## Related ADR slots
|
||||
|
||||
The user prompt that produced this ADR identified two further
|
||||
follow-ups that should land as their own ADRs *if and when* the
|
||||
triggering condition occurs. They are recorded here as pointer-stubs
|
||||
rather than full ADRs because each is a one-paragraph commitment, not
|
||||
a structured decision; opening a full ADR for either prematurely
|
||||
would inflate the ledger without buying decision resolution.
|
||||
|
||||
### ADR-087 (prospective) — Pass-4 mesh-exchange scope clarification
|
||||
|
||||
ADR-084 §"Decision" lists "mesh-exchange compression" between sensor
|
||||
nodes when reporting cross-cluster events as the fourth of its five
|
||||
sites. The binding intent of that text is **cluster-Pi to cluster-Pi
|
||||
exchange** — i.e., the ADR-066 swarm-bridge channel between peer
|
||||
Cognitum Seeds — not sensor-MCU to cluster-Pi UDP traffic. The two
|
||||
are different problems: cluster-to-cluster is std Rust on Linux/Mac
|
||||
and reuses `BinaryQuantized` directly; sensor-to-Pi is what ADR-086
|
||||
addresses. If the team later reinterprets Pass 4 as
|
||||
sensor→cluster-Pi UDP compression, that would be ADR-086's twin and
|
||||
should land as **ADR-087** with its own firmware release, distinct
|
||||
from ADR-086's release. The clarification is one paragraph because
|
||||
the only decision is "which interpretation does ADR-084's Pass 4
|
||||
mean", and the answer is currently the cluster-to-cluster reading.
|
||||
ADR-087 only opens if that reading is contested.
|
||||
|
||||
### ADR-088 (prospective) — Firmware-release coordination policy
|
||||
|
||||
Issues #386 and #396 (firmware-only fixes — the MGMT-only
|
||||
promiscuous filter and the 50 Hz callback-rate gate) demonstrate
|
||||
that the firmware can need a release independent of any cluster-Pi
|
||||
ADR work. ADR-086 is itself an example: it requires a firmware
|
||||
release that is not driven by ADR-084 or ADR-085, both of which are
|
||||
cluster-Pi-only. Today the implicit policy is "firmware releases
|
||||
when something firmware-only ships." That works but is undocumented.
|
||||
**ADR-088** would formalize *when* a firmware release is required vs
|
||||
deferred, with concrete examples: a Kconfig flag flip (#386 / #396)
|
||||
must release; a Pi-side parser-only addition (ADR-085 Sites 1–7)
|
||||
must not; a wire-format magic bump (ADR-086) must release and must
|
||||
re-cut the witness bundle; a feature-flag-default flip on a shipped
|
||||
v7 firmware should release a config bundle but not a firmware
|
||||
binary. ADR-088 opens when the next firmware-only change after
|
||||
ADR-086 lands and forces the decision; it is recorded here as a
|
||||
slot rather than written speculatively because the actual release-
|
||||
gating questions only become concrete in the presence of a real
|
||||
shipping change.
|
||||
@@ -1,194 +0,0 @@
|
||||
# ADR-089: nvsim — NV-Diamond Magnetometer Pipeline Simulator
|
||||
|
||||
| Field | Value |
|
||||
|----------------|-----------------------------------------------------------------------------------------|
|
||||
| **Status** | Accepted — Passes 1–5 implemented and merged via the `feat/nvsim-pipeline-simulator` branch; Pass 6 (proof bundle + criterion bench) pending in the next iteration |
|
||||
| **Date** | 2026-04-26 |
|
||||
| **Authors** | ruv |
|
||||
| **Companion** | `docs/research/quantum-sensing/14-nv-diamond-sensor-simulator.md`, `docs/research/quantum-sensing/15-nvsim-implementation-plan.md` |
|
||||
|
||||
## Context
|
||||
|
||||
`docs/research/quantum-sensing/14-nv-diamond-sensor-simulator.md` surveyed
|
||||
the state of NV-diamond magnetometry hardware and software in 2026 and
|
||||
landed on a "lean toward skip" verdict for a RuView NV-simulator absent a
|
||||
hardware target. That verdict was honest: the COTS NV-diamond noise floor
|
||||
(~300 pT/√Hz at the Element Six DNV-B1 price point) is 1–2 orders of
|
||||
magnitude worse than QuSpin OPMs at similar cost, so a *biomagnetic-grade*
|
||||
NV simulator would be choosing the wrong modality.
|
||||
|
||||
The user nonetheless chose to build the simulator, with two non-biomagnetic
|
||||
use cases in mind:
|
||||
|
||||
1. **Forward simulation for ferrous-anomaly / metallic-object detection** —
|
||||
where NV-diamond's vector readout and unshielded-room operation matter
|
||||
more than absolute sensitivity, and the 1–10 nT range relevant to
|
||||
detecting steel rebar / vehicles / firearms is well within COTS reach.
|
||||
2. **Open-source educational + reference implementation** — no published
|
||||
open-source end-to-end NV pipeline simulator exists (`14.md` §2.2 gap).
|
||||
QuTiP covers spin Hamiltonians; Magpylib covers analytic dipole +
|
||||
Biot–Savart; nothing covers source → propagation → ODMR → ADC → witness
|
||||
in one tool.
|
||||
|
||||
`docs/research/quantum-sensing/15-nvsim-implementation-plan.md` produced
|
||||
the executable build spec — six passes, one module per pass, each pass
|
||||
shippable independently with a measured acceptance gate.
|
||||
|
||||
## Decision
|
||||
|
||||
Build `nvsim` as a **standalone Rust leaf crate** at `v2/crates/nvsim/`
|
||||
implementing the six-pass plan in doc 15. The crate is deliberately
|
||||
independent of the rest of the RuView workspace — no internal dependencies
|
||||
on `wifi-densepose-core`, `wifi-densepose-signal`, or `wifi-densepose-mat`,
|
||||
because the simulator is generally useful outside RuView's WiFi-CSI
|
||||
context (magnetic-anomaly modelling, NV-physics teaching, COTS sensor
|
||||
noise-floor sanity checks).
|
||||
|
||||
Six-pass implementation:
|
||||
|
||||
1. **Scaffold + scene + frame** — `Scene`, `DipoleSource`, `CurrentLoop`,
|
||||
`FerrousObject`, `EddyCurrent` aggregate types; `MagFrame` 60-byte
|
||||
binary record with magic `0xC51A_6E70`.
|
||||
2. **Source synthesis** — closed-form analytic dipole + numerical
|
||||
Biot–Savart over current loops + linearly-induced ferrous moment
|
||||
(Jackson 3e §5.4–5.6; Cullity & Graham 2e §2; Magpylib reference
|
||||
per Ortner & Bandeira 2020).
|
||||
3. **Propagation** — per-material attenuation table (Air, Drywall,
|
||||
Brick, ConcreteDry, ReinforcedConcrete, SheetSteel) with
|
||||
conjectural defaults explicitly flagged where no primary source
|
||||
exists at RuView geometry.
|
||||
4. **NV ensemble sensor** — Lorentzian ODMR lineshape at FWHM ≈ 1 MHz,
|
||||
shot-noise floor `δB ∝ 1/(γ_e · C · √(N · t · T₂*))`, T₂ decay
|
||||
envelope, 4-axis 〈111〉 crystallographic projection with
|
||||
closed-form `(AᵀA) = (4/3)I` LSQ inversion. Defaults match Barry
|
||||
et al. *Rev. Mod. Phys.* 92 (2020) Table III for COTS bulk diamond.
|
||||
5. **Digitiser + pipeline** — 16-bit signed ADC at ±10 µT FS,
|
||||
1st-order IIR anti-alias at f_s/2.5, lockin demod at f_mod = 1 kHz
|
||||
with f_s/1000 LP cutoff, end-to-end `Pipeline::run_with_witness`
|
||||
producing a deterministic SHA-256 over the frame stream.
|
||||
6. **Proof bundle + criterion bench** — *pending next iteration*.
|
||||
|
||||
Determinism is the load-bearing property: same `(scene, config, seed)`
|
||||
must produce byte-identical output across runs and machines. Underwritten
|
||||
by ChaCha20-seeded shot noise (no global PRNG state, no time-of-day
|
||||
field, no allocator randomness in the hot path) and verified in the
|
||||
test suite.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Open-source end-to-end NV pipeline simulator now exists** — closes
|
||||
the gap `14.md` §2.2 identified.
|
||||
- **Deterministic CI gate**: any future change to the physics constants
|
||||
shifts the SHA-256 witness, surfacing as a test failure rather than
|
||||
silent drift.
|
||||
- **Honest physics**: every formula cited (Jackson, Doherty, Barry, Wolf,
|
||||
Cullity & Graham, Ortner & Bandeira); every conjectural default flagged
|
||||
in code; the Wolf 2015 sanity-floor test is the canary that fires if
|
||||
anyone silently changes the ensemble constants.
|
||||
- **Standalone leaf**: no internal RuView dependencies, so anyone outside
|
||||
RuView can use the crate as-is. RuView integrations land behind opt-in
|
||||
feature flags.
|
||||
- **Forward-simulation niche filled**: gives DSP / ML engineers a known-
|
||||
answer-key stream for regression replay without sourcing a magnetic
|
||||
anomaly chamber.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **Wrong modality risk**: per `14.md`, NV-diamond at COTS price points
|
||||
is 1–2 orders of magnitude worse than OPM in the biomagnetic band.
|
||||
Anyone using nvsim as a stand-in for biomagnetic sensing will get
|
||||
optimistic noise-floor numbers relative to what the same money buys
|
||||
in QuSpin OPMs. Mitigated by the Wolf 2015 sanity-floor test and
|
||||
the README's explicit "if you need fT-floor sensitivity, this is
|
||||
the wrong starting point" caveat.
|
||||
- **Conjectural propagation defaults**: drywall / brick / dry-concrete
|
||||
loss values are conjectural; no systematic primary source exists for
|
||||
residential-wall magnetic-field penetration loss at RuView geometry.
|
||||
Flagged in code and in `15.md` §2.2; the `HEAVY_ATTENUATION` flag
|
||||
surfaces this to downstream consumers.
|
||||
- **No pulsed-protocol simulation**: Rabi nutation, Hahn echo, dynamical
|
||||
decoupling are out of scope. If a use case needs them, the Lindblad
|
||||
extension lives in **ADR-090** (Proposed, conditional).
|
||||
- **Maintenance debt**: 1,800+ LoC of crystallographically-correct
|
||||
physics code is non-trivial to maintain. Mitigated by the
|
||||
Barry-2020-anchored test suite — drift in the constants surfaces
|
||||
as a test failure within ~ms.
|
||||
|
||||
### Neutral
|
||||
|
||||
- ESP32-S3 firmware is **untouched** by this work — `nvsim` is host-side
|
||||
only. Existing firmware tags (`v0.6.2-esp32`) continue to ship
|
||||
unchanged.
|
||||
- The crate uses workspace-pinned dependencies (`ndarray`, `serde`,
|
||||
`thiserror`, `rand`, `rand_chacha`, `sha2`); no new top-level
|
||||
dependencies added.
|
||||
- ADR-086 (edge novelty gate, firmware track) is independent of this
|
||||
ADR — its `0xC51A_6E70` `MagFrame` magic is distinct from ADR-018's
|
||||
CSI magic and ADR-084's sketch magic.
|
||||
|
||||
## Validation
|
||||
|
||||
Acceptance criteria measured per the implementation plan §5:
|
||||
|
||||
| Criterion | Floor | Measured | Verdict |
|
||||
|---|---|---|---|
|
||||
| Same `(scene, seed)` → byte-identical SHA-256 witness | required | `determinism_same_seed_byte_identical_witness` test passes | ✓ |
|
||||
| Shot-noise-OFF reproduction of analytical Biot–Savart | ≤ 0.1% RMS | `shot_noise_disabled_propagates_flag_and_yields_clean_signal` test asserts ≤ 1 ADC LSB (~305 pT, equivalent at relevant amplitudes) | ✓ |
|
||||
| n=8-direction dipole field RMS error | ≤ 0.5% | Pass 2 acceptance gate test passes | ✓ |
|
||||
| NV shot-noise floor at t = 1 s vs Wolf 2015 | within 4× of 0.9 pT/√Hz | Pass 4 sanity-floor test passes; falls in window | ✓ |
|
||||
| Pipeline throughput ≥ 1 kHz on Cortex-A53 | ≥ 1 kHz | _pending_ — Pass 6 criterion bench | _track_ |
|
||||
| Lockin SNR for 1 nT @ 1 kHz vs 100 pT/√Hz floor | ≥ 10 in 1 s | _pending_ — Pass 6 integration test | _track_ |
|
||||
|
||||
Test count: **45 nvsim unit tests** passing (workspace 1,620 total, +45
|
||||
from baseline 1,575), zero failures, zero ignores. ESP32-S3 on COM7
|
||||
unaffected throughout.
|
||||
|
||||
## Implementation status
|
||||
|
||||
| Pass | Module | Commit | Tests |
|
||||
|---|---|---|---|
|
||||
| 1 | scaffold + scene + frame | `9c95bfac0` | 12 |
|
||||
| 2 | source.rs (Biot–Savart) | `a6ac08c66` | +7 |
|
||||
| 3 | propagation.rs | `8c062fbaa` | +7 |
|
||||
| 4 | sensor.rs (NV ensemble) | `177624174` | +8 |
|
||||
| 5 | digitiser.rs + pipeline.rs | `436d383c9` | +11 |
|
||||
| 6 | proof.rs + criterion bench | _pending_ | _≥ 5_ |
|
||||
|
||||
Branch: `feat/nvsim-pipeline-simulator`. README at
|
||||
`v2/crates/nvsim/README.md` — plain-language audience-facing front page.
|
||||
|
||||
## Related
|
||||
|
||||
- **ADR-090** (Proposed, conditional) — full Hamiltonian / Lindblad
|
||||
solver extension for pulsed protocols. Built only if a use case
|
||||
needs Rabi nutation, Hahn echo, or dynamical-decoupling simulation.
|
||||
- **ADR-018** — CSI binary frame magic (`0xC51F...`). nvsim's
|
||||
`MAG_FRAME_MAGIC` (`0xC51A_6E70`) is deliberately distinct.
|
||||
- **ADR-028** — ESP32 capability audit + witness verification. nvsim's
|
||||
proof bundle pattern is the same shape as `archive/v1/data/proof/`.
|
||||
- **ADR-066** — Swarm bridge to Cognitum Seed coordinator. If RuView
|
||||
ever wants to publish nvsim outputs across the mesh, the
|
||||
`MagFrame` shape is the wire format.
|
||||
- **ADR-086** — Edge novelty gate. Independent firmware-track ADR;
|
||||
shares the "Cluster-Pi side is host Rust" framing but not the
|
||||
pipeline.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Should nvsim be published to crates.io as a standalone crate?** It
|
||||
already has no internal RuView deps. The repo's MIT/Apache-2.0
|
||||
license is permissive. The blocker is the dependency on
|
||||
`wifi-densepose-core` going through workspace path — but nvsim
|
||||
doesn't actually depend on it. If the answer is yes, this is a
|
||||
trivial follow-up.
|
||||
- **Does `nvsim::Pipeline` belong in the same crate as `nvsim::scene`?**
|
||||
Some users want just the scene + source primitives without the
|
||||
full pipeline. A future split into `nvsim-core` (scene/source/
|
||||
propagation/sensor) and `nvsim-pipeline` (digitiser/pipeline/proof)
|
||||
is possible if the API surface grows.
|
||||
- **What's the right venue for the deterministic-proof bundle?**
|
||||
Pass 6 will write `expected_witness.sha256` alongside the test
|
||||
suite. Whether that lives in-tree or as a separately-tagged release
|
||||
artifact is a Pass-6 design choice.
|
||||
@@ -1,218 +0,0 @@
|
||||
# ADR-090: nvsim — Full Hamiltonian / Lindblad Solver Extension
|
||||
|
||||
| Field | Value |
|
||||
|----------------|-----------------------------------------------------------------------------------------|
|
||||
| **Status** | Proposed — conditional. Only built if a pulsed-protocol use case emerges. Default-off, opt-in feature gate. |
|
||||
| **Date** | 2026-04-26 |
|
||||
| **Authors** | ruv |
|
||||
| **Refines** | ADR-089 (nvsim simulator) |
|
||||
| **Companion** | `docs/research/quantum-sensing/14-nv-diamond-sensor-simulator.md` §3.1, `docs/research/quantum-sensing/15-nvsim-implementation-plan.md` §6 |
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-089](ADR-089-nvsim-nv-diamond-simulator.md)'s `nvsim::sensor` module
|
||||
implements a **leading-order linear-readout proxy** for NV-ensemble
|
||||
magnetometry per Barry et al. *Rev. Mod. Phys.* 92, 015004 (2020) §III.A.
|
||||
That paper validates the proxy as adequate for ensemble magnetometers in
|
||||
the **linear regime** — which is the CW-ODMR regime RuView's actual
|
||||
use case operates in. The Wolf 2015 sanity-floor test confirms the
|
||||
implementation matches published bulk-diamond results within 4×.
|
||||
|
||||
What the proxy does *not* model:
|
||||
|
||||
- **Pulsed protocols**: Rabi nutation, Hahn echo, CPMG / XY-N dynamical
|
||||
decoupling sequences.
|
||||
- **Microwave-power saturation**: line-broadening at high CW MW power.
|
||||
- **Hyperfine structure**: ¹⁴N (I=1) and ¹⁵N (I=½) nuclear spin couplings
|
||||
to the NV electronic spin.
|
||||
- **Coherent control**: Ramsey-style phase-accumulation experiments,
|
||||
spin-echo magnetometry.
|
||||
|
||||
For RuView's CW-ODMR ensemble use case (ferrous-anomaly detection,
|
||||
metallic-object screening), none of these matter — Barry 2020 §III.A is
|
||||
explicit that the linear-readout proxy is adequate. For *future* use cases
|
||||
that involve pulsed protocols (e.g., AC-magnetometry via Hahn echo to push
|
||||
sensitivity past the T₂* floor), they would matter.
|
||||
|
||||
This ADR documents that decision-tree explicitly: **the Lindblad solver is
|
||||
not built unless and until a pulsed-protocol use case opens**.
|
||||
|
||||
## Decision
|
||||
|
||||
Defer the full Hamiltonian + Lindblad solver to a **conditional, opt-in
|
||||
feature gate** named `lindblad` on the `nvsim` crate. Default-off so that
|
||||
the existing fast linear-readout path stays the default and the build /
|
||||
test budget is unaffected. The ADR is **Proposed** — actual implementation
|
||||
happens only if a triggering use case meets the gate below.
|
||||
|
||||
### Trigger conditions for promoting to Accepted
|
||||
|
||||
This ADR transitions from Proposed → Accepted when **any one** of the
|
||||
following is true:
|
||||
|
||||
1. A use case needs **AC magnetometry**: a Hahn-echo or CPMG / XY-N
|
||||
dynamical-decoupling protocol where the answer cannot be approximated
|
||||
by the linear proxy because T₂* is no longer the relevant timescale.
|
||||
2. A use case needs **microwave-power saturation modelling**: the
|
||||
simulator is asked to predict the ODMR contrast as a function of MW
|
||||
drive amplitude, which the linear proxy does not capture.
|
||||
3. A use case needs **hyperfine spectroscopy**: the simulator is asked to
|
||||
reproduce the ¹⁴N or ¹⁵N hyperfine triplet visible in high-resolution
|
||||
ODMR scans, which the linear proxy collapses.
|
||||
4. A use case needs **pulsed quantum-sensing protocols** more broadly:
|
||||
Ramsey, spin-echo magnetometry, double-quantum coherence, etc.
|
||||
|
||||
If none of those triggers, the linear proxy is sufficient and this ADR
|
||||
remains Proposed indefinitely.
|
||||
|
||||
### Why the deferral is the right call today
|
||||
|
||||
- **Adequacy validated by primary source.** Barry 2020 §III.A explicitly
|
||||
validates the linear-readout proxy for ensemble magnetometers in the
|
||||
linear regime. nvsim's existing `sensor.rs` matches Wolf 2015 within 4×.
|
||||
We're not under-modelling — we're correctly-modelling.
|
||||
- **3–7 days of focused work.** The implementation cost is non-trivial:
|
||||
density-matrix RK4 integrator over a 3-level (or 9-level with hyperfine)
|
||||
Hilbert space, careful sign / basis / normalisation conventions,
|
||||
validation against a published QuTiP reference script. The downside of
|
||||
building it pre-emptively is paying that cost without a downstream
|
||||
consumer.
|
||||
- **No current downstream consumer.** RuView's MAT (Mass Casualty
|
||||
Assessment) consumer needs CW-ODMR ferrous anomaly detection, not
|
||||
pulsed protocols. ADR-066 swarm-bridge (proposed) is similarly
|
||||
CW-amplitude-only.
|
||||
- **Not blocked.** When a triggering use case appears, the work is well-
|
||||
scoped and the build path is documented (see Implementation below).
|
||||
Deferral is reversible at any time.
|
||||
|
||||
### Why we don't just delegate to QuTiP
|
||||
|
||||
QuTiP is the obvious off-the-shelf option and is what `15.md` §6 originally
|
||||
proposed deferring to. Two reasons we'd prefer an in-tree Rust
|
||||
implementation if we ever build it:
|
||||
|
||||
1. **Determinism**. QuTiP runs in Python with potentially non-deterministic
|
||||
ODE solver scheduling depending on threading, BLAS backend, and
|
||||
NumPy version. nvsim's whole-pipeline determinism — same seed →
|
||||
byte-identical witness — would be much harder to maintain across the
|
||||
Python boundary.
|
||||
2. **CI integration**. The Rust workspace's `cargo test --workspace
|
||||
--no-default-features` already runs in seconds. Adding QuTiP would
|
||||
pull a Python dependency into CI and slow the gate.
|
||||
|
||||
If a triggering use case opens but the cost-benefit doesn't justify in-
|
||||
tree implementation, an external QuTiP harness with cached fixture
|
||||
outputs is a viable fallback.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **No premature engineering.** 3–7 days of work not spent on a feature
|
||||
with no consumer; that time goes to Pass 6 of nvsim and to ADR-066
|
||||
swarm-bridge work that has actual downstream demand.
|
||||
- **Honest scope.** ADR-089's README and the `nvsim::sensor` module
|
||||
docstrings already say what's *not* modelled. ADR-090 is the
|
||||
formal accountability for that boundary.
|
||||
- **Reversible.** All four trigger conditions are observable; if any
|
||||
fires, the ADR moves to Accepted and the work begins.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **Risk of premature commitment if triggers fire.** If pulsed-protocol
|
||||
use cases emerge late in the project (e.g., a contributor wants
|
||||
Hahn-echo magnetometry for academic-paper reproducibility), the 3–7-day
|
||||
cost lands at an inconvenient time. Mitigated by the work being
|
||||
well-scoped and bench-bounded — see Implementation.
|
||||
- **Documentation debt.** Every nvsim contributor should be aware that
|
||||
pulsed protocols are out of scope. This ADR is the canonical reference
|
||||
but its Proposed status means contributors might not read it. Mitigated
|
||||
by the README's explicit "out of scope" section linking to this ADR.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The existing linear-readout proxy is already feature-flag-free and
|
||||
always-on; no API changes when ADR-090 lands. The Lindblad path is
|
||||
additive.
|
||||
|
||||
## Implementation (when triggered)
|
||||
|
||||
If this ADR transitions to Accepted, the implementation is:
|
||||
|
||||
1. **Add `lindblad` feature to `nvsim/Cargo.toml`** — opt-in, default-off.
|
||||
Pulls `ndarray` (already a dep) + `num-complex` (already a workspace
|
||||
dep) for complex-matrix algebra.
|
||||
2. **`src/lindblad.rs`** — new module, ≤ 600 LoC:
|
||||
- `NvHamiltonian` — D·Sz² + γ_e·B·S + E·(Sx²−Sy²) on the m_s ∈ {−1, 0, +1}
|
||||
ground-state basis. Optional ¹⁴N or ¹⁵N hyperfine extension.
|
||||
- `LindbladOps` — collapse operators for T₁ (population relaxation,
|
||||
L_∓ between m_s levels) and T₂ (pure dephasing on m_s = ±1).
|
||||
- `LindbladIntegrator::rk4_step(rho, dt)` — fourth-order Runge-Kutta
|
||||
time-step on the density matrix.
|
||||
- `Pulse` enum — supports CW, square, Gaussian-shaped MW pulses.
|
||||
3. **`src/lindblad_protocols.rs`** — new module, ≤ 400 LoC:
|
||||
- `Rabi::run` — fixed MW amplitude sweep, returns nutation curve.
|
||||
- `HahnEcho::run` — π/2 — τ — π — τ — π/2 detection sequence.
|
||||
- `Cpmg::run` — repeated π pulses for dynamical decoupling.
|
||||
4. **Validation suite** — mandatory before merging:
|
||||
- Reproduce a published QuTiP reference Rabi curve (e.g., from a
|
||||
Doherty 2013 supplementary script) within 1% per-bin error.
|
||||
- Reproduce a Hahn-echo decay against published T₂ measurement
|
||||
within 5%.
|
||||
- Reproduce hyperfine triplet splitting against measured A_∥ /
|
||||
A_⊥ values from Doherty 2013 §3.4.
|
||||
5. **Benchmarks** — criterion target: ≥ 100 Hz simulated Rabi-curve
|
||||
evaluation on x86_64 (10× slower than the linear proxy is acceptable).
|
||||
6. **README + ADR update** — promote ADR-089's README "not yet shipped"
|
||||
section to include the new pulsed-protocol capabilities, and move
|
||||
this ADR to Accepted with the merge commit.
|
||||
|
||||
Estimated effort: **3–7 days of focused work**, dominated by validation
|
||||
not implementation.
|
||||
|
||||
## Validation (Proposed → Accepted)
|
||||
|
||||
This ADR is **Proposed** until any of the four trigger conditions in §"
|
||||
Trigger conditions" fires. When that happens:
|
||||
|
||||
1. Open a follow-up issue stating which trigger fired and which use case
|
||||
needs Lindblad.
|
||||
2. The implementation §1–6 above defines the build.
|
||||
3. Acceptance moves on the validation-suite criteria in step 4 (1% Rabi
|
||||
curve, 5% Hahn-echo decay, hyperfine triplet match).
|
||||
4. Merge promotes this ADR Proposed → Accepted with the new measured
|
||||
numbers.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Which Rust complex-matrix library is the right substrate?** Three
|
||||
candidates: (a) `ndarray` + `num-complex` (already workspace deps; lowest
|
||||
surface area but unergonomic for matrix algebra); (b) `nalgebra` with
|
||||
`ComplexField` trait (richer matrix algebra, +1 workspace dep);
|
||||
(c) `faer` (more recent, focused on numerics performance, +1 workspace
|
||||
dep). Decide at trigger time based on which best supports the Lindblad
|
||||
RK4 step ergonomically and which version-pinning matches the workspace
|
||||
conservatism.
|
||||
- **Is hyperfine modelling in v1 or v2?** A pure 3-level NV ground-state
|
||||
Hamiltonian is sufficient for Rabi and Hahn echo. ¹⁴N hyperfine triplet
|
||||
needs 9-level Hilbert space (3 m_s × 3 m_I), 9× more matrix work. v1
|
||||
could ship with hyperfine off behind a sub-feature; v2 enables it.
|
||||
- **Should the Lindblad solver back-validate the linear proxy?** Once
|
||||
Lindblad exists, it could be used to measure the proxy's error
|
||||
envelope across operating points and tighten or loosen the existing
|
||||
Wolf 2015 4× sanity floor accordingly. This is the strongest scientific
|
||||
reason to build Lindblad even without an immediate use case — but
|
||||
"validate the proxy" is itself the use case, so still meets trigger #4.
|
||||
|
||||
## Related
|
||||
|
||||
- **ADR-089** — nvsim NV-diamond simulator. The crate this extension
|
||||
attaches to.
|
||||
- **ADR-018** — CSI binary frame format. Lindblad output would still flow
|
||||
through the existing `MagFrame` (`0xC51A_6E70`) shape; pulsed-protocol
|
||||
results add to the per-frame metadata, not a new frame format.
|
||||
- **ADR-028** — ESP32 capability audit. Lindblad is host-side only; ESP32
|
||||
firmware untouched.
|
||||
- **ADR-066** — Swarm bridge. If the simulator is used for swarm-routed
|
||||
AC-magnetometry experiments, this ADR's outputs flow through that
|
||||
channel.
|
||||
@@ -1,770 +0,0 @@
|
||||
# ADR-091: Stand-off Radar Tier Research — 77 GHz High-Power and 100–200 GHz Coherent Sub-THz
|
||||
|
||||
| Field | Value |
|
||||
|----------------|-----------------------------------------------------------------------------------------|
|
||||
| **Status** | Proposed — Research only. No production hardware integration. Decision deferred pending sub-$1k COTS sub-THz transceiver availability and clear non-export-controlled use case. |
|
||||
| **Date** | 2026-04-26 |
|
||||
| **Authors** | ruv |
|
||||
| **Refines** | ADR-021 (60 GHz / mmWave vital-signs pipeline) |
|
||||
| **Companion** | `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md` §6.3, ADR-029 (RuvSense multistatic), ADR-089 (nvsim simulator), ADR-090 (Lindblad extension) |
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 Why this question now
|
||||
|
||||
On Good Friday 3 April 2026 the press reported a CIA system called "Ghost Murmur"
|
||||
— a Lockheed Skunk Works NV-diamond + AI sensor reportedly used in the recovery
|
||||
of an F-15E pilot in southern Iran. President Trump publicly suggested detection
|
||||
ranges in the "tens of miles" against a single human heartbeat. RuView shipped
|
||||
a research spec (`16-ghost-murmur-ruview-spec.md`) which (a) reality-checked the
|
||||
press claims against published physics, (b) mapped the *honestly-scoped* version
|
||||
onto the existing RuView three-tier mesh, and (c) explicitly deferred one
|
||||
modality — high-power and sub-THz coherent radar — as out of scope. From §6.3
|
||||
of that spec:
|
||||
|
||||
> 77 GHz automotive radars at higher power and 100–200 GHz coherent sub-THz
|
||||
> radars **can** resolve cardiac micro-Doppler at 50–500 m in clear LOS. These
|
||||
> are not COTS at the $15 price point and are not in the RuView stack today.
|
||||
> They are also subject to ITAR / export-control review and **explicitly out of
|
||||
> scope** for this open-source project.
|
||||
|
||||
That sentence is the trigger for this ADR. We need a written, citable record of
|
||||
*why* the decision is "out of scope today", what would change the decision,
|
||||
and — crucially — what shape any future research entry into this band would
|
||||
take, given that even the research itself touches dual-use territory.
|
||||
|
||||
### 1.2 What gap a higher-frequency / higher-power tier would close
|
||||
|
||||
RuView's existing modality coverage (per the CLAUDE.md crate table):
|
||||
|
||||
| Modality | Crate / ADR | Honest LOS range for HR | Through-wall HR |
|
||||
|---|---|---|---|
|
||||
| WiFi CSI 2.4/5/6 GHz | `wifi-densepose-signal`, ADR-014, ADR-029 | 1–3 m (presence to 30 m) | 1 wall, weak |
|
||||
| 60 GHz FMCW (MR60BHA2) | `wifi-densepose-vitals`, ADR-021 | 1–10 m | drywall only |
|
||||
| NV-diamond magnetometer | `nvsim` (simulator), ADR-089/090 | <1 m (gradiometric, shielded) | n/a |
|
||||
|
||||
The ceiling of this stack on cardiac micro-Doppler in clear line-of-sight is
|
||||
**~10 m** (60 GHz tier, ADR-021 / spec §6.1). A higher-frequency / higher-power
|
||||
tier would, in principle, close the 10–500 m gap that the published radar
|
||||
literature has already explored. The two candidate bands:
|
||||
|
||||
1. **77–81 GHz at higher than typical commercial EIRP** — the same band as
|
||||
automotive radar, where the FCC ceiling is 50 dBm average / 55 dBm peak EIRP
|
||||
under 47 CFR §95.M, and where published academic work has measured HR at
|
||||
ranges beyond the typical 1–3 m used by COTS automotive sensors.
|
||||
2. **100–200 GHz coherent sub-THz radar** — where λ ≈ 1.5–3 mm gives
|
||||
sub-millimetre chest-wall displacement resolution and where atmospheric
|
||||
transmission windows at 94 GHz, 140 GHz, and 220 GHz make stand-off sensing
|
||||
physically possible (with caveats on humidity, antenna gain, and integration
|
||||
time).
|
||||
|
||||
This ADR examines both bands — the SOTA, the COTS reality, the regulatory
|
||||
envelope, the physics ceiling, the export-control posture, and the open-source
|
||||
ethics — and lands at a build / research / skip recommendation per row.
|
||||
|
||||
## 2. SOTA: 77–81 GHz automotive radar at higher power
|
||||
|
||||
### 2.1 Current COTS chips at the $20–$200 price point
|
||||
|
||||
The 76–81 GHz band is now densely populated with single-chip CMOS / SiGe
|
||||
transceivers. Representative parts:
|
||||
|
||||
| Chip | Vendor | Tx / Rx | IF BW | Notes |
|
||||
|---|---|---|---|---|
|
||||
| AWR1843 | Texas Instruments | 3 Tx / 4 Rx | up to ~10 MHz IF | Single-chip 76–81 GHz with on-die DSP, MCU, radar accelerator. Long-range automotive ACC, AEB. ([TI AWR1843](https://www.ti.com/product/AWR1843)) |
|
||||
| AWR2243 | Texas Instruments | 3 Tx / 4 Rx | up to ~20 MHz IF | Cascadable for higher angular resolution (up to 12 Tx / 16 Rx with multi-chip cascade). ([TI AWR2243](https://www.ti.com/product/AWR2243)) |
|
||||
| BGT60 family | Infineon | 1–3 Tx / 1–4 Rx | Several MHz IF | 60 GHz primarily; BGT24 family at 24 GHz. Smaller, lower power, gesture / presence focus. |
|
||||
| TEF82xx | NXP | up to 4 Tx / 4 Rx | several MHz IF | Automotive-grade 76–81 GHz. |
|
||||
|
||||
COTS evaluation boards (TI AWR1843BOOST, AWR2243 cascade kits) sit in the
|
||||
$300–$3,000 range; single-board production costs trend toward $20–$100 at
|
||||
volume. None of these chips is, by itself, export-controlled at typical
|
||||
configurations — the band is allocated for civilian automotive use under FCC
|
||||
Part 95 Subpart M and ETSI EN 301 091 in Europe.
|
||||
|
||||
**EIRP envelope**: 47 CFR §95.M (and the historical §15.253 it replaced) caps
|
||||
the 76–81 GHz band at **50 dBm average / 55 dBm peak EIRP** measured in 1 MHz
|
||||
RBW ([Federal Register notice 2017](https://www.federalregister.gov/documents/2017/09/20/2017-18463/permitting-radar-services-in-the-76-81-ghz-band),
|
||||
[eCFR 47 CFR Part 95 Subpart M](https://www.ecfr.gov/current/title-47/chapter-I/subchapter-D/part-95/subpart-M)).
|
||||
That is roughly 100 W EIRP average, 316 W peak. COTS automotive radars
|
||||
typically operate well below this — single-digit dBm transmit power is
|
||||
multiplied by ~25–30 dBi antenna gain to land at 33–40 dBm EIRP.
|
||||
|
||||
### 2.2 What "higher power" actually means in regulatory terms
|
||||
|
||||
Three regulatory paths exist for an open-source project that wants to push
|
||||
beyond typical commercial deployment power:
|
||||
|
||||
1. **Stay inside FCC Part 95 §95.M caps (50 dBm avg / 55 dBm peak EIRP)** —
|
||||
licence-by-rule, no application, no individual approval. The headroom from
|
||||
typical automotive EIRP (~33–40 dBm) to the cap (50 dBm avg) is real:
|
||||
~10 dB of additional EIRP is available *without changing licence class*,
|
||||
purely by using a higher-gain dish or higher Tx power within the existing
|
||||
chip. This is the upper bound of "stand-off radar that is still part-95
|
||||
legal".
|
||||
2. **FCC Part 5 experimental licence** — needed for transmit power, antenna
|
||||
gain, or duty-cycle that exceeds §95.M. Application-based, time-bounded,
|
||||
non-renewable beyond limits. Typical academic radar ranges (e.g. the
|
||||
long-range cardiac measurements in §2.3 below) operate under this regime.
|
||||
3. **No US authorisation at all** — only legal as receive-only, or as a
|
||||
simulator. Any unlicensed transmission above §95.M at 76–81 GHz is a
|
||||
prohibited emission under 47 CFR §15.5 / §95.335.
|
||||
|
||||
For an *open-source mesh node* shipping to anonymous users worldwide, only
|
||||
path (1) is defensible. Anything that requires an individual experimental
|
||||
licence cannot be "ship a binary and let people flash it".
|
||||
|
||||
### 2.3 Published cardiac micro-Doppler at 77 GHz beyond 5 m
|
||||
|
||||
The 77 GHz cardiac literature is dominated by short-range work (0.3–2 m), e.g.:
|
||||
|
||||
- Chen et al. (2024). "Contactless and short-range vital signs detection with
|
||||
doppler radar millimetre-wave (76–81 GHz) sensing firmware." *Healthcare
|
||||
Technology Letters*. ([PMC11665778](https://pmc.ncbi.nlm.nih.gov/articles/PMC11665778/),
|
||||
[Wiley HTL 2024](https://ietresearch.onlinelibrary.wiley.com/doi/full/10.1049/htl2.12075))
|
||||
— TI IWR1443BOOST at 0.30–1.20 m, suggested 0.6 m.
|
||||
- Wang et al. (2020). "Remote Monitoring of Human Vital Signs Based on 77-GHz
|
||||
mm-Wave FMCW Radar." *Sensors* 20, 2999.
|
||||
([PMC7285495](https://pmc.ncbi.nlm.nih.gov/articles/PMC7285495/),
|
||||
[MDPI Sensors 2020](https://www.mdpi.com/1424-8220/20/10/2999)) — typically
|
||||
short-range bench measurements.
|
||||
- Liu et al. (2022). "Real-Time Heart Rate Detection Method Based on 77 GHz
|
||||
FMCW Radar." *Micromachines* 13, 1960.
|
||||
([PMC9693980](https://pmc.ncbi.nlm.nih.gov/articles/PMC9693980/),
|
||||
[MDPI](https://www.mdpi.com/2072-666X/13/11/1960)) — 2.925% mean HR error,
|
||||
short-range.
|
||||
- Iyer et al. (2022). "mm-Wave Radar-Based Vital Signs Monitoring and
|
||||
Arrhythmia Detection Using Machine Learning." *Sensors*.
|
||||
([PMC9104941](https://pmc.ncbi.nlm.nih.gov/articles/PMC9104941/))
|
||||
|
||||
The most cited *long-range* radar cardiac measurement is at 24 GHz, not 77 GHz:
|
||||
|
||||
- **Massagram, W., Lubecke, V. M., Høst-Madsen, A., Boric-Lubecke, O. (2013).
|
||||
"Parametric Study of Antennas for Long Range Doppler Radar Heart Rate
|
||||
Detection."** *IEEE EMBC* / republished in *PMC*.
|
||||
([PMC4900816](https://pmc.ncbi.nlm.nih.gov/articles/PMC4900816/),
|
||||
[PubMed 23366747](https://pubmed.ncbi.nlm.nih.gov/23366747/)) —
|
||||
measured human HR at distances of **1, 3, 6, 9, 12, 15, 18, 21 m** and
|
||||
respiration to **69 m** with a PA24-16 antenna at **24 GHz CW Doppler**.
|
||||
This is the ceiling reference for "what's achievable with serious antenna
|
||||
gain in clear LOS, low band, with subject cued and stationary".
|
||||
|
||||
We could not find an equivalent peer-reviewed cardiac measurement at 77 GHz
|
||||
*beyond ~5 m* with a verifiable antenna gain × power × integration-time
|
||||
budget. The work that exists at 77 GHz is overwhelmingly bench-scale (≤ 2 m).
|
||||
This is itself informative: it suggests that *the open published frontier at
|
||||
77 GHz beyond 5 m is sparse*, not because it's impossible, but because the
|
||||
research community working at automotive bands has been focused on automotive
|
||||
problems (collision avoidance, in-cabin occupancy) where 5 m suffices, and
|
||||
because higher-range cardiac work has historically used 24 GHz where the
|
||||
antenna size for a given gain is more practical.
|
||||
|
||||
### 2.4 Detection range as a function of antenna gain × power × integration time
|
||||
|
||||
The radar equation for chest-wall displacement detection scales roughly as:
|
||||
|
||||
```
|
||||
SNR ∝ (P_t · G_t · G_r · σ_chest) / (R^4 · k T B · NF) · √(t_int / T_coh)
|
||||
```
|
||||
|
||||
where σ_chest ≈ 10⁻³–10⁻² m² for the cardiac scatterer at 77 GHz, NF ≈ 10–15 dB
|
||||
on COTS chips, and integration time t_int is bounded by T_coh ≈ 0.5–1 s
|
||||
(physiological coherence — the heart period itself).
|
||||
|
||||
Doubling range requires 12 dB of system gain (4-th power dependence on R,
|
||||
two-way). At the part-95 §95.M ceiling (50 dBm avg EIRP) and a generous 30 dB
|
||||
antenna gain (a ~30 cm dish at 77 GHz), the addressable HR detection range in
|
||||
clear LOS is roughly **15–30 m for a stationary cued subject**, dropping to
|
||||
3–10 m for an uncued subject in light clutter. Pushing to 100 m+ in an open
|
||||
field would require either (a) a much larger antenna (60+ cm dish), (b)
|
||||
out-of-band EIRP beyond §95.M (experimental licence territory), or (c) much
|
||||
longer integration (incompatible with cardiac coherence times).
|
||||
|
||||
The 2013 Massagram paper achieves 21 m at 24 GHz with a high-gain antenna
|
||||
under tightly controlled conditions. Pushing the same setup to 77 GHz with
|
||||
the same antenna *aperture* would actually help (smaller beamwidth, same
|
||||
free-space path loss), but the chest-wall RCS at 77 GHz is comparable, and
|
||||
clutter / multipath are much harsher. We have **no public reference** for a
|
||||
77 GHz cardiac measurement at 21 m that we could find with the same rigour.
|
||||
|
||||
### 2.5 Cost ceiling for an open-source mesh node
|
||||
|
||||
An open-source mesh node spec implies "ships in a kit, does not require
|
||||
individual licensing, fits the existing PoE / mini-PC edge model". That
|
||||
implies:
|
||||
|
||||
- Single-chip transceiver at $20–$100 BOM.
|
||||
- Antenna assembly at $50–$200 (high-gain dish or printed array).
|
||||
- Mini-PC or Pi 5 host at $80.
|
||||
- Total under $500 to be plausible.
|
||||
|
||||
The chip cost is already met by COTS. The antenna and host are met. The
|
||||
bottleneck is *not* hardware cost — it is regulatory exposure, dual-use
|
||||
ethics, and the fact that the addressable range at part-95 ceilings (15–30 m)
|
||||
is *only marginally beyond* what the existing 60 GHz tier already does for
|
||||
$15. The marginal *technical* benefit of jumping to 77 GHz at the part-95
|
||||
ceiling, for a civilian opt-in mesh, does not clear the marginal *governance*
|
||||
cost.
|
||||
|
||||
## 3. SOTA: 100–200 GHz coherent sub-THz radar
|
||||
|
||||
### 3.1 Why sub-THz
|
||||
|
||||
At 140 GHz, λ ≈ 2.14 mm. A coherent radar with this wavelength can resolve
|
||||
chest-wall displacement at the **sub-millimetre** level by direct phase
|
||||
tracking, which makes the cardiac micro-Doppler signal-to-clutter ratio
|
||||
fundamentally better than at 60 or 77 GHz for the same integration time.
|
||||
Atmospheric *windows* at 94 GHz, 140 GHz, and 220 GHz — between the strong
|
||||
oxygen absorption peaks at 60 GHz and 119 GHz and the water vapour peaks at
|
||||
22, 183, and 325 GHz — make stand-off operation physically possible per
|
||||
**ITU-R Recommendation P.676** ([ITU-R P.676-11](https://www.itu.int/dms_pubrec/itu-r/rec/p/R-REC-P.676-11-201609-I!!PDF-E.pdf),
|
||||
[ITU-R P.676-9](https://www.itu.int/dms_pubrec/itu-r/rec/p/R-REC-P.676-9-201202-S!!PDF-E.pdf)).
|
||||
|
||||
### 3.2 Atmospheric attenuation table (clear-air, ITU-R P.676)
|
||||
|
||||
Order-of-magnitude values for one-way attenuation through standard atmosphere
|
||||
at sea level, taken from ITU-R P.676-11 Annex 1 / 2 figures (approximate
|
||||
values; consult the recommendation for precise numbers at any (T, P, ρ)):
|
||||
|
||||
| Frequency | Dry air, dB/km | 7.5 g/m³ humid, dB/km | Notes |
|
||||
|---|---|---|---|
|
||||
| 60 GHz | ~14 | ~14.5 | O₂ absorption peak — terrible for stand-off |
|
||||
| 77 GHz | ~0.4 | ~0.5 | Allocated for automotive radar |
|
||||
| 94 GHz | ~0.4 | ~0.7 | First major window above 60 GHz |
|
||||
| 119 GHz | ~2.5 | ~3 | O₂ subsidiary peak |
|
||||
| 140 GHz | ~0.5 | ~1.5 | Second major window |
|
||||
| 183 GHz | ~30+ | ~100+ | H₂O peak — unusable for outdoor stand-off |
|
||||
| 220 GHz | ~2 | ~5 | Third window |
|
||||
| 325 GHz | ~10+ | ~50+ | H₂O peak |
|
||||
| 380 GHz | ~3 | ~20 | Imaging-band window, very humidity-sensitive |
|
||||
|
||||
For a 100 m one-way clear-LOS link at 140 GHz in 7.5 g/m³ humidity, atmospheric
|
||||
attenuation alone is ~0.15 dB — negligible compared to free-space path loss
|
||||
(~115 dB at 100 m) and target RCS. The atmosphere is *not* the limiting factor
|
||||
for sub-THz cardiac sensing inside ~100 m. **Beyond ~1 km in humid conditions,
|
||||
atmospheric absorption dominates** and the budget breaks down quickly,
|
||||
especially at 220 GHz and above.
|
||||
|
||||
### 3.3 COTS chipsets and academic platforms
|
||||
|
||||
The sub-THz commercial landscape in 2026 is sparse and expensive:
|
||||
|
||||
- **Analog Devices HMC8108** — 76–81 GHz transceiver. Not sub-THz; named here
|
||||
only to anchor "the most COTS-friendly mmWave part Analog Devices ships".
|
||||
- **Virginia Diodes WR-* multipliers and mixers** — the dominant lab-grade
|
||||
source for 140–500 GHz work. Module prices are $5,000–$50,000 each;
|
||||
building a coherent transceiver typically requires $30,000–$150,000 of VDI
|
||||
hardware plus a stable phase reference and an external RF source.
|
||||
- **Wasa Millimeter Wave imagers** — passive imagers around 90 / 220 / 380 GHz.
|
||||
Receive-only.
|
||||
- **imec 140 GHz FMCW transceiver in 28 nm CMOS** — reported at IEEE ISSCC and
|
||||
in *Microwave Journal* (2019), centred at 145 GHz with 13 GHz RF bandwidth
|
||||
giving 11 mm range resolution, on-chip antennas, integrated Tx / Rx in 28 nm
|
||||
bulk CMOS. ([Microwave Journal 2019](https://www.microwavejournal.com/articles/32446-integrated-140-ghz-fmcw-radar-for-vital-sign-monitoring-and-gesture-recognition),
|
||||
[imec magazine May 2019](https://www.imec-int.com/en/imec-magazine/imec-magazine-may-2019/a-compact-140ghz-radar-chip-for-detecting-small-movements-such-as-heartbeats))
|
||||
This is the most COTS-relevant sub-THz cardiac chip published to date,
|
||||
but it is **not** a buyable part — it is a research demo.
|
||||
- **Academic platforms** at Tampere University, FAU Erlangen-Nürnberg, Bell Labs
|
||||
/ Nokia, MIT Lincoln Lab, and the various US NSF / DARPA-funded sub-THz
|
||||
programmes have produced sub-THz radars in the 100–300 GHz band. None of
|
||||
these is a ship-it part.
|
||||
|
||||
### 3.4 Coherent vs. incoherent
|
||||
|
||||
A *coherent* sub-THz radar maintains phase reference between Tx and Rx (and
|
||||
ideally across multiple Tx / Rx channels for MIMO or multistatic operation).
|
||||
Coherent processing buys:
|
||||
|
||||
- **Matched-filter SNR scaling**: SNR improves linearly with integration
|
||||
time t (vs. √t for incoherent), bounded by the cardiac coherence
|
||||
time T_coh.
|
||||
- **Phase-based displacement extraction**: chest-wall displacement at the
|
||||
micrometre level becomes directly observable as Δφ = 4π·Δd / λ.
|
||||
- **MIMO / multistatic phase coherence**: multiple Tx / Rx phase-coherent
|
||||
channels enable beamforming gain that scales as N_Tx × N_Rx instead of
|
||||
√(N_Tx × N_Rx).
|
||||
|
||||
It costs:
|
||||
|
||||
- **Sub-picosecond clock distribution** between channels at sub-THz frequencies
|
||||
(a 1 ps clock skew at 140 GHz is 50° of phase error).
|
||||
- **Phase-locked LO distribution** — the LO must be coherent across the
|
||||
array; this is non-trivial at 140 GHz (typical solution: distribute a low
|
||||
GHz reference and multiply locally, with cm-precision cable matching).
|
||||
- **Calibration burden** — phase-coherent arrays need per-channel calibration
|
||||
drift correction.
|
||||
|
||||
For a single-aperture monostatic radar (one Tx, one Rx, one chip), coherence
|
||||
is nearly free (the LO is shared on-die). For a *mesh* of coherent sub-THz
|
||||
nodes, the engineering cost is significant — and would require RuView to
|
||||
develop sub-ns mesh clock-synchronisation it does not have today.
|
||||
|
||||
### 3.5 Published cardiac micro-Doppler at sub-THz
|
||||
|
||||
The published peer-reviewed cardiac literature at 100–300 GHz is sparse but
|
||||
not empty:
|
||||
|
||||
- **Mostafanezhad & Boric-Lubecke (2014).** "Benefits of coherent low-IF for
|
||||
vital signs monitoring." *IEEE Microw. Wireless Compon. Lett.* 24. — anchor
|
||||
for *coherent* CW vital-signs radar; not specifically sub-THz, but
|
||||
establishes the coherent-IF advantage.
|
||||
- **imec (2019) — 140 GHz FMCW transceiver demonstration.** Reported real-time
|
||||
measurement of micro-skin motion reflecting respiration and heartbeat at
|
||||
short range using an integrated 28 nm CMOS transceiver with on-chip antennas.
|
||||
Cited above; engineering demo, not a published systematic range study.
|
||||
([Microwave Journal 2019](https://www.microwavejournal.com/articles/32446-integrated-140-ghz-fmcw-radar-for-vital-sign-monitoring-and-gesture-recognition))
|
||||
- **Yamagishi et al. (2022).** "A new principle of pulse detection based on
|
||||
terahertz wave plethysmography." *Scientific Reports* 12, 2022.
|
||||
([Nature SREP](https://www.nature.com/articles/s41598-022-09801-w)) —
|
||||
THz-band plethysmography demonstrator, contactless pulse detection at very
|
||||
short range using THz transmission/reflection through skin. Not a stand-off
|
||||
radar paper, but the only widely-cited THz-cardiac primary source.
|
||||
- **Zhang et al. (2021).** "Non-Contact Monitoring of Human Vital Signs Using
|
||||
FMCW Millimeter Wave Radar in the 120 GHz Band." *Sensors* 21.
|
||||
([PMC8070581](https://pmc.ncbi.nlm.nih.gov/articles/PMC8070581/)) — 120 GHz
|
||||
band, FMCW, short-range cardiac extraction.
|
||||
|
||||
**Honest assessment**: published primary work on cardiac micro-Doppler at
|
||||
*beyond a few meters* in the 100–300 GHz band is limited. The
|
||||
imec / EU-funded demonstrators have shown that the chip exists; the systematic
|
||||
range studies that exist for 24 GHz (Massagram 2013) and 60–77 GHz
|
||||
(Adib / Wang / Liu) do not yet have published sub-THz analogues. Some of this
|
||||
work may exist in the classified or US-Government / EU defence-funded
|
||||
literature; it is **not** in the open record at the level of detail required
|
||||
for a build decision.
|
||||
|
||||
## 4. Physics ceiling for RuView's heartbeat-mesh use case
|
||||
|
||||
### 4.1 Cardiac signal vs. distance, multi-band comparison
|
||||
|
||||
For a stationary, cued, line-of-sight subject with chest-wall displacement
|
||||
~0.2 mm at the heart fundamental and ~5 mm at the breathing fundamental,
|
||||
order-of-magnitude HR-detection range estimates at three bands (compiled from
|
||||
the radar equation, Massagram 2013, ITU-R P.676, and standard chest-RCS
|
||||
estimates):
|
||||
|
||||
| Band | λ | Required Δφ for HR | Free-space loss @ 30 m | Atm loss @ 30 m | Estimated HR range (cued LOS, COTS Tx + 30 dBi antenna, part-95) |
|
||||
|---|---|---|---|---|---|
|
||||
| 24 GHz CW | 12.5 mm | 0.36° | 89 dB | <0.01 dB | 21 m measured (Massagram 2013) |
|
||||
| 60 GHz FMCW | 5.0 mm | 0.9° | 97 dB | 0.4 dB | 5–10 m (ADR-021 / spec §6.1) |
|
||||
| 77 GHz FMCW | 3.9 mm | 1.2° | 99 dB | 0.01 dB | ~15–30 m (estimated, no rigorous public ref beyond 5 m) |
|
||||
| 140 GHz FMCW | 2.1 mm | 2.2° | 105 dB | 0.04 dB | ~30–100 m (estimated, sparse open lit) |
|
||||
| 220 GHz FMCW | 1.4 mm | 3.3° | 109 dB | 0.15 dB | ~30–100 m (estimated, sparse open lit, humidity-sensitive) |
|
||||
|
||||
The phase-displacement resolution *improves* with frequency (Δφ for the same
|
||||
displacement scales as 1/λ), but the link budget *degrades* (R⁻⁴ in
|
||||
two-way path loss, plus atmospheric absorption, plus higher noise figure on
|
||||
sub-THz LNAs). The two effects partially cancel; the net result is that
|
||||
**every doubling in frequency above 60 GHz buys roughly a factor of 2–4× in
|
||||
plausible HR range when antenna aperture is held constant** — but only if
|
||||
the system noise figure and Tx power can be maintained at levels comparable
|
||||
to the lower-band part. Sub-THz CMOS NF is typically 10 dB worse than 77 GHz
|
||||
CMOS, which eats much of the apparent gain.
|
||||
|
||||
### 4.2 Two-way path loss + atmospheric absorption
|
||||
|
||||
| Range | 77 GHz total loss | 140 GHz total loss | 220 GHz total loss |
|
||||
|---|---|---|---|
|
||||
| 1 m | 70 dB + 0 | 76 dB + 0 | 80 dB + 0 |
|
||||
| 10 m | 90 dB + 0.01 | 96 dB + 0.03 | 100 dB + 0.1 |
|
||||
| 100 m | 110 dB + 0.1 | 116 dB + 0.3 | 120 dB + 1 |
|
||||
| 1 km | 130 dB + 1 | 136 dB + 3 | 140 dB + 10 |
|
||||
| 10 km | 150 dB + 10 | 156 dB + 30 | 160 dB + 100 |
|
||||
| 65 km (40 mi) | 168 dB + 65 | 174 dB + 200+ | 178 dB + impossible |
|
||||
|
||||
**Observations**:
|
||||
|
||||
- At 1 km, 220 GHz loses 9 dB more to atmosphere than 77 GHz; at 10 km it
|
||||
loses 90 dB more. Sub-THz is fundamentally a sub-1-km modality in humid air.
|
||||
- At 65 km (the "40 miles" in the press), atmospheric absorption alone makes
|
||||
220 GHz cardiac detection physically impossible at any plausible Tx power.
|
||||
140 GHz needs 200+ dB of antenna gain on each end to close the link in
|
||||
humid air — far beyond any deployable antenna.
|
||||
- **77 GHz is the only band where 1 km cardiac sensing is physically plausible
|
||||
in the open air.** It is also the band that is closest to civilian COTS.
|
||||
|
||||
### 4.3 Required antenna gain × power × integration time
|
||||
|
||||
Holding integration time at 0.5 s (half a cardiac cycle, the rough coherence
|
||||
limit), and assuming a 10 dB SNR target at 0.2 mm displacement, the required
|
||||
EIRP × antenna-gain product to detect HR at various ranges in clear LOS at
|
||||
77 GHz:
|
||||
|
||||
| Range | Required EIRP × G_r (one-way) | Achievable under FCC §95.M? |
|
||||
|---|---|---|
|
||||
| 1 m | 25 dBm + 20 dBi | Yes (commercial COTS) |
|
||||
| 10 m | 45 dBm + 30 dBi | Yes (high-end COTS, 30 cm dish) |
|
||||
| 30 m | 55 dBm + 35 dBi | Marginal — at the §95.M peak ceiling |
|
||||
| 100 m | 70 dBm + 45 dBi | No — above §95.M, experimental-licence territory |
|
||||
| 500 m | 90 dBm + 55 dBi | No — military / experimental only |
|
||||
| 1 km | 100 dBm + 60 dBi | No — military only |
|
||||
| 10+ km | beyond physical antenna realisability for civilian use | No |
|
||||
|
||||
**Bottom line**: 30 m is the honest ceiling for cardiac sensing inside FCC
|
||||
§95.M power limits with a 30 cm dish at 77 GHz. Anything beyond ~30 m is
|
||||
either experimental-licence territory or military.
|
||||
|
||||
### 4.4 Fold-over with the Ghost Murmur "tens of miles" claim
|
||||
|
||||
The press claim of HR detection at "40 miles" (65 km) corresponds to a one-way
|
||||
path loss at 77 GHz of roughly 168 dB (free space) plus ~65 dB of atmospheric
|
||||
absorption (humid). Closing this link to detect a 0.2 mm chest-wall
|
||||
displacement would require:
|
||||
|
||||
- **Required EIRP**: roughly 200 dBm (10²⁰ W) in the simplest analysis. For
|
||||
context, the entire global average solar flux is ~1.4 kW/m². A 65 km
|
||||
radar would need to deliver more transmit power, focused onto a single
|
||||
human chest, than the sun delivers to that chest by daylight.
|
||||
- **Required antenna**: even with 100 dB of combined two-way antenna gain
|
||||
(a 6 m dish at 77 GHz), the EIRP requirement is unphysical.
|
||||
- **Required atmospheric conditions**: dry, stable, no rain, no fog, no
|
||||
intervening terrain.
|
||||
|
||||
The honest reading: **HR detection at "tens of miles" against a single
|
||||
heartbeat is not consistent with any physically realisable open-air radar
|
||||
system at any band the laws of physics allow**. The claim either refers to
|
||||
*cued* detection (i.e., a survival beacon or IR thermal already pinpointed
|
||||
the target, the radar is just confirming "alive"), or it is press-release
|
||||
hyperbole. RuView is not in a position to either confirm or contest the
|
||||
operational reality; we are in a position to say that the *modality alone* —
|
||||
"detect a heartbeat at 40 miles with a radar" — is not what closed the loop.
|
||||
|
||||
This is consistent with the Ghost Murmur spec's analysis (§4 of doc 16) and
|
||||
with `nvsim`'s magnetic-field falloff calculations (1/r³ — even more brutal
|
||||
than radar's 1/r⁴).
|
||||
|
||||
## 5. Regulatory + ethics
|
||||
|
||||
### 5.1 FCC envelope summary
|
||||
|
||||
| Use | FCC path | Practical for open source? |
|
||||
|---|---|---|
|
||||
| 60 GHz unlicensed (existing tier) | Part 15.255 (57–71 GHz) | Yes — current tier |
|
||||
| 76–81 GHz at COTS automotive EIRP | Part 95 Subpart M (50/55 dBm) | Yes — research-allowed |
|
||||
| 76–81 GHz pushing toward §95.M ceiling | Part 95 Subpart M | Yes — single-installation |
|
||||
| 76–81 GHz beyond §95.M | Part 5 experimental licence | **No** for shipping firmware |
|
||||
| 90–300 GHz coherent radar | Mostly experimental-only | **No** for shipping firmware |
|
||||
| 300+ GHz transmitters | Almost all unallocated for civilian active use | **No** for shipping firmware |
|
||||
|
||||
For an *open-source civilian project*, only the unlicensed and part-95
|
||||
licensed-by-rule categories are defensible. The moment a node would need an
|
||||
individual experimental-licence application to operate legally, it cannot be
|
||||
"flash and ship".
|
||||
|
||||
### 5.2 ITAR / EAR posture
|
||||
|
||||
- **ECCN 6A008** controls radar systems and components under the EAR
|
||||
([BIS Commerce Control List Cat. 6](https://www.bis.doc.gov/index.php/documents/regulations-docs/2340-ccl9-4/file)).
|
||||
The general radar control sub-paragraph 6A008.e covers "radar systems,
|
||||
having any of the following characteristics" — including high power,
|
||||
specific frequency / coherence properties, and certain processing
|
||||
capabilities. The exact thresholds change from revision to revision; the
|
||||
current authoritative source is the [BIS Interactive Commerce Control
|
||||
List](https://www.bis.gov/regulations/ear/interactive-commerce-control-list).
|
||||
- **USML Category XI(c)** (ITAR) covers radar that is specifically designed
|
||||
or modified for military application. Sub-THz coherent radar with the
|
||||
combination of frequency, coherence, and antenna gain that would matter
|
||||
for stand-off cardiac sensing tends to fall in or near this category.
|
||||
- **EAR99 / no-licence-required** thresholds for low-power 60–77 GHz
|
||||
automotive radar are clear. Sub-THz coherent radar above certain
|
||||
thresholds (ECCN 6A008) requires an export licence for many destinations.
|
||||
Some open-source firmware that *implements* such a radar may be subject
|
||||
to "publicly available" exemptions; some may not.
|
||||
- **Open-source publication.** EAR §734.7 / §734.8 ("publicly available
|
||||
information") exempts most code that has been or will be published openly.
|
||||
However, this exemption has limits — particularly for "specially designed"
|
||||
technology supporting controlled commodities, and for encryption / certain
|
||||
munitions categories. The line for radar firmware is not fully clear, and
|
||||
the safe path for an open-source project is: **do not publish firmware
|
||||
whose primary purpose is to push a controlled-radar configuration**.
|
||||
|
||||
The correct posture for RuView is: **assume the worst case**. If RuView
|
||||
*shipped* firmware that drove a 140 GHz coherent sub-THz cardiac mesh, even
|
||||
without the hardware in the workspace, that firmware *itself* could fall
|
||||
within ECCN 6A008 / USML XI(c), particularly if it implemented the
|
||||
matched-filter / coherent-array signal processing that distinguishes
|
||||
controlled radars from uncontrolled ones. We do not ship that firmware.
|
||||
|
||||
### 5.3 Open-source ethics and dual-use risk
|
||||
|
||||
The Ghost Murmur spec (§9) is explicit about RuView's civilian-only ethics
|
||||
framing:
|
||||
|
||||
1. Civilian, opt-in deployments only.
|
||||
2. No directional pursuit.
|
||||
3. Data minimisation.
|
||||
4. PII detection on the wire.
|
||||
5. Adversarial-signal detection.
|
||||
6. **No export-controlled hardware.**
|
||||
|
||||
Stand-off radar at 77 GHz with §95.M-ceiling EIRP and a 30 cm dish *can* be
|
||||
used for through-wall surveillance, biometric tracking, target acquisition.
|
||||
Sub-THz coherent radar can do the same with finer resolution. Even *research*
|
||||
into these modalities — building a simulator, publishing range / sensitivity
|
||||
analyses, contributing to the open literature — pushes the open-source
|
||||
ecosystem closer to capabilities that the press already (correctly, in the
|
||||
sense of "physically possible") associates with covert military intelligence.
|
||||
|
||||
Two specific dual-use risks if RuView research were to ship anything beyond
|
||||
this ADR:
|
||||
|
||||
- **Through-wall surveillance**: high-power 77 GHz radar with a wide-band
|
||||
FMCW chirp can resolve human presence and coarse pose through interior
|
||||
drywall at tens of meters. This is the literal Ghost Murmur use case at
|
||||
short range. RuView already discloses this capability for the existing
|
||||
60 GHz tier; pushing it to 77 GHz at higher power expands the addressable
|
||||
surveillance distance.
|
||||
- **Biometric tracking at distance**: cardiac and respiratory micro-Doppler
|
||||
signatures are individually identifying enough for re-identification
|
||||
across short occlusions (this is part of the AETHER / re-ID work in
|
||||
ADR-024). Combining higher-power radar with re-ID at 30+ m is
|
||||
surveillance at distance.
|
||||
- **Target acquisition**: this is the use case RuView explicitly does not
|
||||
build for. Period.
|
||||
|
||||
## 6. Build / Research / Skip decision matrix
|
||||
|
||||
| Tier | Build now | Research only | Skip permanently | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 77 GHz commercial COTS (already shipping at low EIRP via the 60 GHz tier; mentioned for completeness) | — | — | — | Already covered by 60 GHz tier ADR-021. No action. |
|
||||
| 77 GHz higher-power experimental (≤ §95.M ceiling) | — | **✓ Research only** (passive simulator + range analysis) | — | The technical gap to the 60 GHz tier is small; the marginal range gain (30 m vs 10 m) does not justify the marginal regulatory + ethics cost for a *shipped* civilian mesh. Research / simulation only. |
|
||||
| 77 GHz beyond §95.M (Part 5 experimental) | — | — | **✓ Skip permanently** | Cannot ship as open-source firmware. Individual experimental licences are not delegatable. |
|
||||
| 100 GHz coherent mesh | — | **✓ Research only** | — | Document the physics, the COTS gap (no sub-$1k transceiver), the regulatory gap (no civilian allocation for active sensing in the 90–110 GHz band). Build only if all three conditions in §7.4 below trigger. |
|
||||
| 140 GHz coherent stand-off | — | **✓ Research only (simulator only)** | — | The imec 2019 demonstrator shows the chip is realisable at 28 nm CMOS; nothing buyable today at sub-$1k. ECCN 6A008 risk is real. Simulator OK; firmware no. |
|
||||
| 220 GHz coherent stand-off | — | — | **✓ Skip permanently for hardware** (research the physics only) | Atmospheric humidity sensitivity makes outdoor deployment fragile; ECCN 6A008 / ITAR Cat XI(c) risk is highest at this band; no buyable COTS chip at sub-$10k. The marginal sensing benefit over 140 GHz does not justify the regulatory and ethics escalation. |
|
||||
| 380+ GHz imaging | — | — | **✓ Skip permanently** | Imaging-band, not radar; humidity destroys outdoor link; export-controlled at any meaningful aperture. Not RuView's modality at any plausible build. |
|
||||
|
||||
The recommendation density is intentional: **most of the matrix lands on
|
||||
"skip" or "research only"**. Only one row (77 GHz at the §95.M ceiling) sits
|
||||
near a build decision, and even that one is gated on a use case that does not
|
||||
exist in RuView today.
|
||||
|
||||
## 7. If we research: what does RuView ship?
|
||||
|
||||
### 7.1 Mirror the `nvsim` pattern
|
||||
|
||||
ADR-089 / 090 established the precedent: when a sensing modality is
|
||||
*physically interesting but not buildable today*, RuView ships a deterministic
|
||||
forward simulator, not hardware. The simulator becomes the design tool for
|
||||
fusion algorithms, the sanity check for press-release physics, and the
|
||||
honest answer to "what would you actually need to build this?"
|
||||
|
||||
Applied to this ADR, the corresponding artifact would be **a sub-THz radar
|
||||
forward simulator crate**, working name `subthz-radar-sim`. Scope:
|
||||
|
||||
- Forward-model the 77 GHz / 140 GHz / 220 GHz radar equation including
|
||||
ITU-R P.676 atmospheric attenuation, free-space path loss, antenna gain
|
||||
patterns, and chest-RCS models.
|
||||
- Simulate cardiac micro-Doppler displacement → received-signal phase
|
||||
modulation in the FMCW or CW-Doppler regime.
|
||||
- Add deterministic noise (thermal + 1/f LO phase noise + chest-RCS
|
||||
fluctuation) seeded from `rand_chacha` for byte-identical outputs across
|
||||
runs.
|
||||
- Emit `RadarFrame`-shaped output with magic distinct from
|
||||
`0xC51A_6E70` (`nvsim`'s `MagFrame`) and `0xC511_0001` (CSI frames).
|
||||
- SHA-256 witness for end-to-end determinism, mirroring `nvsim::Pipeline::run_with_witness`.
|
||||
|
||||
### 7.2 Hard constraints on what the crate can ship
|
||||
|
||||
- **No firmware.** Not for ESP32, not for any SDR, not for any FPGA. The crate
|
||||
is host-side only. No executable binary capable of *driving* a sub-THz
|
||||
transmitter is published.
|
||||
- **No matched-filter / coherent-array signal processing that exceeds
|
||||
ECCN 6A008 thresholds.** The crate documents the physics and simulates the
|
||||
forward path. It does not implement the inverse / processing pipeline at
|
||||
the level that would constitute a controlled radar processor.
|
||||
- **No beamforming primitives for actively-steered phased arrays.** Simulating
|
||||
a fixed-pattern dish is fine; simulating a steerable phased array used for
|
||||
targeted person-of-interest tracking is not.
|
||||
- **No re-identification across the simulated radar stream.** AETHER-style
|
||||
re-ID exists in `ruvector/viewpoint/`; it must not be wired to the sub-THz
|
||||
radar simulator's output.
|
||||
- **Documented dual-use posture.** The crate's README starts with a section
|
||||
titled "What this crate is not for", linking to this ADR.
|
||||
|
||||
### 7.3 What the simulator answers
|
||||
|
||||
The same questions `nvsim` answers for NV-diamond, the sub-THz simulator
|
||||
would answer for radar:
|
||||
|
||||
- "If a 140 GHz transceiver has noise figure 12 dB and Tx power 0 dBm with a
|
||||
35 dBi antenna, what's the joint posterior P(human alive at (x, y))
|
||||
given my CSI + 60 GHz + 77 GHz + 140 GHz radar evidence at 5 m, 30 m,
|
||||
100 m?"
|
||||
- "What sensitivity does my hypothetical 220 GHz radar need to add useful
|
||||
information beyond the 60 GHz tier at 10 m? And does the answer change
|
||||
in 7.5 g/m³ humidity vs. 1 g/m³ dry air?"
|
||||
- "What does my published witness change if I swap the receiver noise figure
|
||||
from 8 dB to 15 dB? From 15 dB to 25 dB?"
|
||||
|
||||
These are pre-build sanity checks. They cost CI time, not export-control
|
||||
exposure, not dual-use risk, not regulatory exposure.
|
||||
|
||||
### 7.4 Conditional triggers (mirror ADR-090's pattern)
|
||||
|
||||
Promotion of any "research only" row in §6 to "build" requires *all three*
|
||||
of:
|
||||
|
||||
1. **A COTS sub-THz transceiver drops below $1k** at the chip level, with
|
||||
datasheet-confirmed phase coherence and an evaluation board buildable on
|
||||
open hardware. (Today: nothing.)
|
||||
2. **A clear non-export-controlled application emerges** — most plausibly
|
||||
*medical*: contactless vital-sign monitoring at clinical bedside or
|
||||
ambulatory ranges (1–3 m), regulated by the FDA as a medical device, with
|
||||
the commercial / regulatory path paved by another vendor. RuView would
|
||||
then be one of many open-source contributors to a medical sensing modality
|
||||
already cleared for civilian use.
|
||||
3. **RuView core team agrees by RFC**, with explicit sign-off on the dual-use
|
||||
review and the ethics framing in §5.3.
|
||||
|
||||
If *any one* of those three is missing, this ADR remains Proposed indefinitely
|
||||
and the modality stays in the simulator-only tier.
|
||||
|
||||
If only condition (1) fires — sub-$1k chip with no medical clearance and no
|
||||
RFC sign-off — RuView still does not ship. The simulator might be expanded;
|
||||
no firmware ships.
|
||||
|
||||
## 8. Related work / cross-references
|
||||
|
||||
### 8.1 ADRs
|
||||
|
||||
- **ADR-021** — Vital-sign detection via 60 GHz mmWave + WiFi CSI. The tier
|
||||
immediately below this ADR; defines the 1–10 m HR ceiling that a stand-off
|
||||
tier would extend.
|
||||
- **ADR-029** — RuvSense multistatic sensing mode. Defines the cross-viewpoint
|
||||
fusion that any future radar tier would feed. The mathematical framework
|
||||
for combining radar + CSI + NV evidence is already in `ruvector/viewpoint/`.
|
||||
- **ADR-089** — `nvsim` NV-diamond pipeline simulator. The architectural
|
||||
precedent: ship a deterministic forward simulator when the modality is
|
||||
interesting but not buildable. Same proof / witness pattern applies here.
|
||||
- **ADR-090** — `nvsim` Lindblad / Hamiltonian extension. Same "Proposed
|
||||
conditional" pattern with explicit trigger conditions and a deferred build.
|
||||
This ADR follows the same shape.
|
||||
- **ADR-040** — PII detection gates. Any future stand-off radar output stream
|
||||
would need to flow through PII gates before crossing the local mesh
|
||||
boundary, identical to existing CSI / vitals streams.
|
||||
- **ADR-024** — AETHER contrastive embedding. Cross-references the
|
||||
re-identification work that *must not* be combined with stand-off radar.
|
||||
- **ADR-028** — ESP32 capability audit + witness verification. The
|
||||
deterministic-witness pattern applies to any new simulator crate.
|
||||
|
||||
### 8.2 Research docs
|
||||
|
||||
- `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md` — the
|
||||
Ghost Murmur reality-check spec. §6.3 is the explicit boundary that
|
||||
triggered this ADR. §7–§9 establish the architecture, ethics, and legal
|
||||
framework that this ADR inherits.
|
||||
|
||||
### 8.3 Primary literature (radar at 24 / 77 / 120–140 GHz)
|
||||
|
||||
- **Massagram, W., Lubecke, V. M., Høst-Madsen, A., Boric-Lubecke, O.
|
||||
(2013).** "Parametric Study of Antennas for Long Range Doppler Radar
|
||||
Heart Rate Detection." *IEEE EMBC* 2013.
|
||||
([PMC4900816](https://pmc.ncbi.nlm.nih.gov/articles/PMC4900816/))
|
||||
— HR @ 21 m, respiration @ 69 m at 24 GHz CW.
|
||||
- **Mostafanezhad, I., Boric-Lubecke, O. (2014).** "Benefits of Coherent
|
||||
Low-IF for Vital Signs Monitoring." *IEEE Microw. Wireless Compon. Lett.*
|
||||
24(10), 711–713.
|
||||
- **Adib, F. et al. (2015).** "Smart Homes that Monitor Breathing and Heart
|
||||
Rate." *Proc. CHI 2015*. Short-range through-wall.
|
||||
- **Wang, G. et al. (2020).** "Remote Monitoring of Human Vital Signs Based
|
||||
on 77-GHz mm-Wave FMCW Radar." *Sensors* 20(10), 2999.
|
||||
([PMC7285495](https://pmc.ncbi.nlm.nih.gov/articles/PMC7285495/))
|
||||
- **Liu, J. et al. (2022).** "Real-Time Heart Rate Detection Method Based on
|
||||
77 GHz FMCW Radar." *Micromachines* 13(11), 1960.
|
||||
([PMC9693980](https://pmc.ncbi.nlm.nih.gov/articles/PMC9693980/))
|
||||
- **Chen, J. et al. (2024).** "Contactless and Short-Range Vital Signs
|
||||
Detection with Doppler Radar Millimetre-Wave (76–81 GHz) Sensing Firmware."
|
||||
*Healthcare Technology Letters* 11.
|
||||
([Wiley HTL](https://ietresearch.onlinelibrary.wiley.com/doi/full/10.1049/htl2.12075))
|
||||
- **Iyer, S. et al. (2022).** "mm-Wave Radar-Based Vital Signs Monitoring
|
||||
and Arrhythmia Detection Using Machine Learning." *Sensors*.
|
||||
([PMC9104941](https://pmc.ncbi.nlm.nih.gov/articles/PMC9104941/))
|
||||
|
||||
### 8.4 Primary literature (sub-THz)
|
||||
|
||||
- **imec / Peeters et al. (2019).** Integrated 140 GHz FMCW Radar
|
||||
Transceiver in 28 nm CMOS for Vital Sign Monitoring and Gesture
|
||||
Recognition. *Microwave Journal* 2019-06-09; imec magazine May 2019.
|
||||
([Microwave Journal](https://www.microwavejournal.com/articles/32446-integrated-140-ghz-fmcw-radar-for-vital-sign-monitoring-and-gesture-recognition),
|
||||
[imec magazine](https://www.imec-int.com/en/imec-magazine/imec-magazine-may-2019/a-compact-140ghz-radar-chip-for-detecting-small-movements-such-as-heartbeats))
|
||||
- **Zhang, Q. et al. (2021).** "Non-Contact Monitoring of Human Vital
|
||||
Signs Using FMCW Millimeter Wave Radar in the 120 GHz Band." *Sensors*
|
||||
21. ([PMC8070581](https://pmc.ncbi.nlm.nih.gov/articles/PMC8070581/))
|
||||
- **Yamagishi, H. et al. (2022).** "A new principle of pulse detection
|
||||
based on terahertz wave plethysmography." *Scientific Reports* 12,
|
||||
2022. ([Nature SREP](https://www.nature.com/articles/s41598-022-09801-w))
|
||||
- ITU-R Recommendation **P.676-11** (2016). "Attenuation by atmospheric
|
||||
gases." International Telecommunication Union.
|
||||
([P.676-11 PDF](https://www.itu.int/dms_pubrec/itu-r/rec/p/R-REC-P.676-11-201609-I!!PDF-E.pdf))
|
||||
- 47 CFR Part 95 Subpart M — The 76–81 GHz Band Radar Service.
|
||||
([eCFR](https://www.ecfr.gov/current/title-47/chapter-I/subchapter-D/part-95/subpart-M))
|
||||
- US Department of Commerce, Bureau of Industry and Security. **Commerce
|
||||
Control List Category 6 — Sensors and Lasers**, ECCN 6A008.
|
||||
([BIS CCL Cat. 6](https://www.bis.doc.gov/index.php/documents/regulations-docs/2340-ccl9-4/file))
|
||||
|
||||
### 8.5 Reviews
|
||||
|
||||
- **Li, C. et al. (2024).** "Radar-Based Heart Cardiac Activity Measurements:
|
||||
A Review." *Sensors*. ([PMC11645089](https://pmc.ncbi.nlm.nih.gov/articles/PMC11645089/))
|
||||
- **Frontiers in Physiology (2022).** "Radar-based remote physiological
|
||||
sensing: Progress, challenges, and opportunities."
|
||||
([Frontiers](https://www.frontiersin.org/journals/physiology/articles/10.3389/fphys.2022.955208/full))
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
These are the questions that, if answered differently, could move a row of
|
||||
the §6 decision matrix:
|
||||
|
||||
1. **Does a published, peer-reviewed cardiac micro-Doppler measurement at
|
||||
77 GHz beyond 5 m exist that we missed?** A rigorous Massagram-style
|
||||
parametric study at 77 GHz with explicit antenna-gain × Tx-power ×
|
||||
integration-time budgets would change the picture for the "77 GHz higher
|
||||
power" row from "research only" toward "build (simulator + reference
|
||||
implementation)".
|
||||
2. **Does a sub-$1k 140 GHz coherent transceiver chip exist or appear in the
|
||||
next 12 months?** The imec 28 nm CMOS demo from 2019 has not yet led to
|
||||
a buyable part; it is unclear whether this is an engineering / yield issue
|
||||
or a market issue. If a part appears, condition (1) of §7.4 fires.
|
||||
3. **Is there a clear medical FDA-cleared application for sub-THz cardiac
|
||||
sensing?** This is the single most important gating condition. If a
|
||||
commercial vendor clears a 140 GHz contactless vital-sign monitor as a
|
||||
Class II medical device, the entire ethical framing of "open-source
|
||||
contribution to a medical sensing modality" opens up. Without that
|
||||
clearance, RuView remains in the simulator-only tier.
|
||||
4. **Are there current ECCN 6A008 thresholds we should be more concerned
|
||||
about for the *simulator itself* than the §5.2 analysis suggests?** The
|
||||
simulator is forward-only and emits IQ samples and a SHA-256 witness.
|
||||
It does not implement matched-filter / coherent-array processing that
|
||||
would be characteristic of controlled radars. We believe this is on the
|
||||
right side of the line; a formal export-control review by counsel would
|
||||
confirm.
|
||||
5. **Should RuView contribute the sub-THz simulator to a neutral upstream**
|
||||
(e.g., an open-source academic group's repository) rather than shipping
|
||||
it in the wifi-densepose workspace? Decoupling the simulator from RuView
|
||||
reduces the risk that future RuView capability work is interpreted as
|
||||
building toward a stand-off cardiac mesh.
|
||||
6. **What's the right venue for the deterministic-proof bundle for the
|
||||
sub-THz simulator?** Same question that ADR-089 left open. Probably
|
||||
the same answer: in-tree fixture + tagged release artifact.
|
||||
|
||||
## 10. Decision summary
|
||||
|
||||
This ADR is **Proposed — Research only**. The decision matrix in §6 lands on:
|
||||
|
||||
- **Skip permanently**: 77 GHz beyond §95.M, 220 GHz coherent stand-off
|
||||
hardware, 380+ GHz imaging.
|
||||
- **Research only (simulator-class artifact)**: 77 GHz higher-power
|
||||
experimental (≤ §95.M ceiling), 100 GHz coherent mesh, 140 GHz coherent
|
||||
stand-off.
|
||||
- **Build now**: nothing.
|
||||
|
||||
If RuView builds anything in this space, it builds a sub-THz forward
|
||||
simulator (`subthz-radar-sim`) following the `nvsim` pattern: deterministic,
|
||||
host-side, witness-verified, with explicit "what this is not for" framing
|
||||
and no firmware. The simulator does not ship until conditions §7.4 (1)–(3)
|
||||
all fire; the hardware does not ship under any conditions current as of
|
||||
2026-04-26.
|
||||
|
||||
The ADR's job is to make these decisions citable, defensible, and
|
||||
reversible only via explicit RFC. It is not a build commitment.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user