diff --git a/.gitattributes b/.gitattributes index 290642ce1..8a73904ea 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ # The contributor harness hashes provenance inputs byte-for-byte. Keep text # files in this boundary on LF even when Windows enables core.autocrlf. harness/ruview/** text=auto eol=lf +harness/homecore/** text=auto eol=lf diff --git a/.github/workflows/fix-regression-guard.yml b/.github/workflows/fix-regression-guard.yml index 742d911dc..8c815a285 100644 --- a/.github/workflows/fix-regression-guard.yml +++ b/.github/workflows/fix-regression-guard.yml @@ -32,6 +32,9 @@ jobs: - name: Validate the manifest is well-formed JSON run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')" + - name: Run unit tests for fix-marker checker + run: python -m unittest scripts/tests/test_check_fix_markers.py + - name: Check fix markers run: python scripts/check_fix_markers.py diff --git a/.github/workflows/nvsim-server-docker.yml b/.github/workflows/nvsim-server-docker.yml index 5bf33402d..8f20e9889 100644 --- a/.github/workflows/nvsim-server-docker.yml +++ b/.github/workflows/nvsim-server-docker.yml @@ -1,6 +1,7 @@ name: nvsim-server → ghcr.io -# Builds and publishes the nvsim-server Docker image to ghcr.io on: +# Builds and publishes the nvsim-server Docker image under the repository +# owner (ghcr.io//nvsim-server) on: # - push to main affecting nvsim-server or nvsim # - tag push matching nvsim-server-v* # - manual workflow_dispatch @@ -31,6 +32,10 @@ jobs: - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + # ghcr.io is always the target; there is no Docker Hub leg here. + # The image path is derived from the repository owner because a fork + # cannot push to the upstream's `ruvnet` namespace — that push + # fails with `denied: The requested installation does not exist`. - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 with: registry: ghcr.io @@ -41,7 +46,7 @@ jobs: id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 with: - images: ghcr.io/ruvnet/nvsim-server + images: ghcr.io/${{ github.repository_owner }}/nvsim-server tags: | type=ref,event=branch type=ref,event=tag @@ -61,11 +66,16 @@ jobs: platforms: linux/amd64 - name: Smoke-test the image + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/nvsim-server run: | - docker pull ghcr.io/ruvnet/nvsim-server:sha-${GITHUB_SHA::7} || \ - docker pull ghcr.io/ruvnet/nvsim-server:latest + # `type=sha,format=short` is emitted on every event, so this is + # the one tag that always exists after a successful push. The + # old `|| latest` fallback hid a failed push and only worked on + # the default branch, so tag builds never reached this step. + docker pull "${IMAGE}:sha-${GITHUB_SHA::7}" docker run --rm -d --name nvsim-test -p 7878:7878 \ - ghcr.io/ruvnet/nvsim-server:latest + "${IMAGE}:sha-${GITHUB_SHA::7}" sleep 4 curl -fsS http://localhost:7878/api/health docker stop nvsim-test diff --git a/.github/workflows/rf-diagnostics.yml b/.github/workflows/rf-diagnostics.yml new file mode 100644 index 000000000..59244a735 --- /dev/null +++ b/.github/workflows/rf-diagnostics.yml @@ -0,0 +1,44 @@ +name: W1700K RF diagnostics +on: + pull_request: + paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml'] + push: + branches: [main] + paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: rf-diagnostics-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Bridge ABI and failure tests + run: make -C tools/w1700k check + - name: Viewer validation tests + run: | + node --test docs/rf-diagnostics/model.test.cjs + node --check docs/rf-diagnostics/app.js + deploy: + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: verify + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Stage static viewer only + run: | + mkdir -p _site + cp docs/rf-diagnostics/index.html docs/rf-diagnostics/model.js docs/rf-diagnostics/app.js docs/rf-diagnostics/style.css _site/ + - name: Preserve existing Pages content + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./_site + destination_dir: rf-diagnostics + keep_files: true + commit_message: 'deploy(rf-diagnostics): ${{ github.sha }}' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 68375748f..4b406ed77 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -29,9 +29,16 @@ jobs: run: cargo install cargo-audit --locked --version 0.22.2 - name: Audit the checked-in Rust lockfile + # cargo-audit resolves .cargo/audit.toml relative to the CURRENT + # WORKING DIRECTORY, not relative to the --file lockfile, and it has no + # flag to point at a config path. Auditing from the repository root + # therefore ran with cargo-audit's defaults and applied none of the + # reviewed ignores in v2/.cargo/audit.toml. Run inside the v2 workspace + # so that policy file is actually loaded. + working-directory: v2 run: | set -o pipefail - cargo audit --file v2/Cargo.lock --json | tee v2/cargo-audit.json + cargo audit --file Cargo.lock --json | tee cargo-audit.json - name: Upload Rust advisory report uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/.github/workflows/sensing-server-docker.yml b/.github/workflows/sensing-server-docker.yml index 746768f94..c8ee4a6a2 100644 --- a/.github/workflows/sensing-server-docker.yml +++ b/.github/workflows/sensing-server-docker.yml @@ -1,12 +1,16 @@ 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: +# Build + publish the `wifi-densepose` sensing-server image under the repository +# owner (`docker.io//wifi-densepose` and `ghcr.io//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. # +# Docker Hub is only published when DOCKERHUB_USERNAME/DOCKERHUB_TOKEN exist; +# ghcr.io always publishes. +# # 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. @@ -61,17 +65,42 @@ jobs: - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - - name: Log in to Docker Hub - # Bypassing docker/login-action@v3: the action kept emitting - # "malformed HTTP Authorization header" against a known-good - # dckr_pat_* token (verified by direct curl against the Hub API). - # `docker login --password-stdin` is the documented credential - # path and avoids whatever encoding step the action injects. + # Publish under the repository owner instead of a hard-coded `ruvnet` + # namespace: a fork cannot push to the upstream's Docker Hub or ghcr.io + # namespaces, and `github.repository_owner` resolves correctly for both + # the upstream and every fork. + # + # Docker Hub is optional. The login was previously hard-required, so a + # repository without DOCKERHUB_USERNAME/DOCKERHUB_TOKEN failed at + # `docker login` ("Must provide --username with --password-stdin") before + # it ever built anything. ghcr.io needs no secret, so it stays the + # always-on target. + # + # Bypassing docker/login-action@v3: the action kept emitting + # "malformed HTTP Authorization header" against a known-good + # dckr_pat_* token (verified by direct curl against the Hub API). + # `docker login --password-stdin` is the documented credential + # path and avoids whatever encoding step the action injects. + - name: Resolve publish targets + id: targets env: DH_USER: ${{ secrets.DOCKERHUB_USERNAME }} DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} run: | - printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin + set -uo pipefail + image="wifi-densepose" + images="ghcr.io/${OWNER}/${image}" + if [ -n "$DH_USER" ] && [ -n "$DH_TOKEN" ]; then + images="docker.io/${OWNER}/${image}"$'\n'"${images}" + printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin + else + echo "::warning::DOCKERHUB_USERNAME/DOCKERHUB_TOKEN are not configured; this run publishes to ghcr.io only." + fi + echo "images<> "$GITHUB_OUTPUT" + echo "$images" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + echo "ghcr_image=ghcr.io/${OWNER}/${image}" >> "$GITHUB_OUTPUT" - name: Log in to ghcr.io uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 @@ -84,9 +113,7 @@ jobs: id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 with: - images: | - docker.io/ruvnet/wifi-densepose - ghcr.io/ruvnet/wifi-densepose + images: ${{ steps.targets.outputs.images }} tags: | type=ref,event=branch type=ref,event=tag @@ -123,7 +150,7 @@ jobs: - name: Smoke-test image assets + LAN-mode HTTP run: | set -euo pipefail - IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}" + IMAGE="${{ steps.targets.outputs.ghcr_image }}:sha-${GITHUB_SHA::7}" docker pull "$IMAGE" docker run --rm --entrypoint sh "$IMAGE" -c \ 'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null' @@ -147,7 +174,7 @@ jobs: - name: Smoke-test the bearer-token auth path run: | set -euo pipefail - IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}" + IMAGE="${{ steps.targets.outputs.ghcr_image }}:sha-${GITHUB_SHA::7}" docker run -d --name auth \ -p 3000:3000 \ -e CSI_SOURCE=simulated \ diff --git a/README.md b/README.md index 98c12d745..2b10abbba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # π RuView +> **W1700K RF diagnostics:** [receiver bridge](tools/w1700k/README.md) · [browser viewer](https://ruvnet.github.io/RuView/rf-diagnostics/) · [integration design](docs/adr/ADR-419-w1700k-rf-diagnostics.md). +> Reads IQ locally and exports bounded quality summaries to RuField. Synthetic contract tests are separate from hardware validation. RF diagnostics only; no CSI extraction or presence detection. +

RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi diff --git a/assets/readme/cognitum-spatial-v4.svg b/assets/readme/cognitum-spatial-v4.svg index 240cff69e..eeba3ff15 100644 --- a/assets/readme/cognitum-spatial-v4.svg +++ b/assets/readme/cognitum-spatial-v4.svg @@ -3,8 +3,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - .scan{animation:scan 7s ease-in-out infinite} .orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite} .spark{animation:spark 3.5s ease-in-out infinite} @@ -12,20 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} - - - .layer{animation:layer 6s ease-in-out infinite} - - - @keyframes layer{50%{transform:translateY(-5px)}} + @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} - - @keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} COGNITUM ONEAmbient Intelligence.At the edge of the physical world.Explore the platform behind the ecosystem.Explore Cognitum diff --git a/assets/readme/feature-agents-spatial-v4.svg b/assets/readme/feature-agents-spatial-v4.svg index 2de89cd32..b22dc3738 100644 --- a/assets/readme/feature-agents-spatial-v4.svg +++ b/assets/readme/feature-agents-spatial-v4.svg @@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} .breathe{animation:breathe 4s ease-in-out infinite} - - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - - - - - - @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} RUVIEW / 06A guided AI operatorSource-cited setup and verification.CLI, MCP and agent workflows.TOOLKIT GUIDE diff --git a/assets/readme/feature-automation-spatial-v4.svg b/assets/readme/feature-automation-spatial-v4.svg index 74f62b20b..e1b9b9569 100644 --- a/assets/readme/feature-automation-spatial-v4.svg +++ b/assets/readme/feature-automation-spatial-v4.svg @@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} .breathe{animation:breathe 4s ease-in-out infinite} - - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - - - - - - @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} RUVIEW / 05Local automationConnect rooms to your smart home.Home Assistant, MQTT and Matter.INTEGRATION GUIDE diff --git a/assets/readme/feature-edge-spatial-v4.svg b/assets/readme/feature-edge-spatial-v4.svg index dce2268e7..270339241 100644 --- a/assets/readme/feature-edge-spatial-v4.svg +++ b/assets/readme/feature-edge-spatial-v4.svg @@ -3,29 +3,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - .layer{animation:layer 6s ease-in-out infinite} - - - @keyframes layer{50%{transform:translateY(-5px)}} @keyframes flow{to{stroke-dashoffset:-136}} - - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} RUVIEW / 04Edge sensor meshProvision, capture and calibrate.Explore ESP32 firmware and runbooks.HARDWARE GUIDE diff --git a/assets/readme/feature-pose-spatial-v6.svg b/assets/readme/feature-pose-spatial-v6.svg index 9340c5627..4fbf829b3 100644 --- a/assets/readme/feature-pose-spatial-v6.svg +++ b/assets/readme/feature-pose-spatial-v6.svg @@ -1,28 +1,11 @@ Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE diff --git a/assets/readme/feature-presence-spatial-v4.svg b/assets/readme/feature-presence-spatial-v4.svg index 591f18684..36902ea6c 100644 --- a/assets/readme/feature-presence-spatial-v4.svg +++ b/assets/readme/feature-presence-spatial-v4.svg @@ -3,29 +3,15 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - - - .spark{animation:spark 3.5s ease-in-out infinite} - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - .sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0} - - - - @keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}} - @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} - - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} RUVIEW / 01Presence & motionExplore changes in room occupancy.Inspect motion features from CSI.SENSING GUIDE diff --git a/assets/readme/feature-vitals-spatial-v4.svg b/assets/readme/feature-vitals-spatial-v4.svg index ac4fd8788..153e079d8 100644 --- a/assets/readme/feature-vitals-spatial-v4.svg +++ b/assets/readme/feature-vitals-spatial-v4.svg @@ -3,29 +3,15 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - .trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite} - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - - .cursor{animation:cursor 5s ease-in-out infinite} - - - @keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}} + @keyframes flow{to{stroke-dashoffset:-136}} - @keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}} - @media(prefers-reduced-motion:reduce){*{animation:none!important}} RUVIEW / 02Breathing & vital signalsInspect phase-derived signal trends.Validate estimates on your hardware.SIGNAL PIPELINE diff --git a/assets/readme/header-docs-spatial-v4.svg b/assets/readme/header-docs-spatial-v4.svg index c5929eb3e..ba9449eff 100644 --- a/assets/readme/header-docs-spatial-v4.svg +++ b/assets/readme/header-docs-spatial-v4.svg @@ -4,7 +4,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} .breathe{animation:breathe 4s ease-in-out infinite} - .scan{animation:scan 7s ease-in-out infinite} .orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite} .spark{animation:spark 3.5s ease-in-out infinite} @@ -15,17 +14,9 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, - - - - - - - @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} - @keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} Choose your next stepGUIDES / HARDWARE / INTEGRATIONS diff --git a/assets/readme/header-edge-spatial-v4.svg b/assets/readme/header-edge-spatial-v4.svg index c60261168..1ffdc6dcb 100644 --- a/assets/readme/header-edge-spatial-v4.svg +++ b/assets/readme/header-edge-spatial-v4.svg @@ -3,8 +3,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - .scan{animation:scan 7s ease-in-out infinite} .orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite} .spark{animation:spark 3.5s ease-in-out infinite} @@ -12,20 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} - - - .layer{animation:layer 6s ease-in-out infinite} - - - @keyframes layer{50%{transform:translateY(-5px)}} + @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} - - @keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} Intelligence at the edgeSENSORS / FIRMWARE / MODULES diff --git a/assets/readme/header-evidence-spatial-v4.svg b/assets/readme/header-evidence-spatial-v4.svg index cdf77aff8..3a7e15df6 100644 --- a/assets/readme/header-evidence-spatial-v4.svg +++ b/assets/readme/header-evidence-spatial-v4.svg @@ -3,8 +3,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - .scan{animation:scan 7s ease-in-out infinite} .orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite} .spark{animation:spark 3.5s ease-in-out infinite} @@ -12,20 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} - - - .layer{animation:layer 6s ease-in-out infinite} - - - @keyframes layer{50%{transform:translateY(-5px)}} + @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} - - @keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} Evidence before accuracy claimsBENCHMARKS / REPRODUCERS / LIMITS diff --git a/assets/readme/header-features-spatial-v4.svg b/assets/readme/header-features-spatial-v4.svg index df0b2191c..9a245d76b 100644 --- a/assets/readme/header-features-spatial-v4.svg +++ b/assets/readme/header-features-spatial-v4.svg @@ -2,9 +2,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif} .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} - - - .scan{animation:scan 7s ease-in-out infinite} .orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite} .spark{animation:spark 3.5s ease-in-out infinite} @@ -13,19 +10,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} .sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0} - - - - @keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}} - @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} - - @keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} Explore the capabilitiesSENSING / MODELS / AUTOMATION diff --git a/assets/readme/header-learning-spatial-v4.svg b/assets/readme/header-learning-spatial-v4.svg index cc49ff710..db5be1b9d 100644 --- a/assets/readme/header-learning-spatial-v4.svg +++ b/assets/readme/header-learning-spatial-v4.svg @@ -2,8 +2,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif} .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} - - .trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite} .scan{animation:scan 7s ease-in-out infinite} .orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite} @@ -12,19 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} - - - - .cursor{animation:cursor 5s ease-in-out infinite} - - - @keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}} + + @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} - @keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}} @keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} diff --git a/assets/readme/hero-spatial-v6.svg b/assets/readme/hero-spatial-v6.svg index 55de901fc..eff58c7e7 100644 --- a/assets/readme/hero-spatial-v6.svg +++ b/assets/readme/hero-spatial-v6.svg @@ -3,26 +3,16 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - .scan{animation:scan 7s ease-in-out infinite} - .spark{animation:spark 3.5s ease-in-out infinite} .wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite} @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} - - - .layer{animation:layer 6s ease-in-out infinite} - - - @keyframes layer{50%{transform:translateY(-5px)}} - .humanTurn{transform-box:fill-box;transform-origin:center 85%;animation:humanTurn 8s ease-in-out infinite} .humanBody{animation:humanBody 1.6s ease-in-out infinite} .armFar{transform-origin:-9px -24px;animation:armSwing 1.6s ease-in-out infinite} @@ -40,8 +30,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} - - @keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} RUVIEW / SPATIAL INTELLIGENCEInvisible signals.Visible possibility.Explore the space between sensing and understanding.WiFi sensing. Edge models. Local automation.Enter the fieldRF SENSING / OPEN RESEARCH / EDGE COMPUTECONCEPTUAL RF FIELD diff --git a/assets/readme/nav-agents-spatial-v4.svg b/assets/readme/nav-agents-spatial-v4.svg index f26b5a49c..4dc011547 100644 --- a/assets/readme/nav-agents-spatial-v4.svg +++ b/assets/readme/nav-agents-spatial-v4.svg @@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} .breathe{animation:breathe 4s ease-in-out infinite} - - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - - - - - - @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} AI toolkit diff --git a/assets/readme/nav-hardware-spatial-v4.svg b/assets/readme/nav-hardware-spatial-v4.svg index 93b6b7f29..63e9d2d92 100644 --- a/assets/readme/nav-hardware-spatial-v4.svg +++ b/assets/readme/nav-hardware-spatial-v4.svg @@ -3,29 +3,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - .layer{animation:layer 6s ease-in-out infinite} - - - @keyframes layer{50%{transform:translateY(-5px)}} @keyframes flow{to{stroke-dashoffset:-136}} - - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} Hardware diff --git a/assets/readme/nav-home-spatial-v4.svg b/assets/readme/nav-home-spatial-v4.svg index 2476bb444..5a270a105 100644 --- a/assets/readme/nav-home-spatial-v4.svg +++ b/assets/readme/nav-home-spatial-v4.svg @@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} .breathe{animation:breathe 4s ease-in-out infinite} - - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - - - - - - @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} Smart home diff --git a/assets/readme/nav-models-spatial-v4.svg b/assets/readme/nav-models-spatial-v4.svg index 5cda54036..742c85249 100644 --- a/assets/readme/nav-models-spatial-v4.svg +++ b/assets/readme/nav-models-spatial-v4.svg @@ -2,30 +2,14 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif} .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} - - .trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite} - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - - .cursor{animation:cursor 5s ease-in-out infinite} - - - @keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}} - @keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}} - @media(prefers-reduced-motion:reduce){*{animation:none!important}} Models diff --git a/assets/readme/nav-seed-spatial-v4.svg b/assets/readme/nav-seed-spatial-v4.svg index 6e5b608dc..c222de216 100644 --- a/assets/readme/nav-seed-spatial-v4.svg +++ b/assets/readme/nav-seed-spatial-v4.svg @@ -3,29 +3,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} .flow{stroke-dasharray:5 12;animation:flow 5s linear infinite} - - - - - - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - - - - .layer{animation:layer 6s ease-in-out infinite} - - - @keyframes layer{50%{transform:translateY(-5px)}} @keyframes flow{to{stroke-dashoffset:-136}} - - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} Cognitum diff --git a/assets/readme/nav-start-spatial-v4.svg b/assets/readme/nav-start-spatial-v4.svg index e8e2d54a7..4224b33fa 100644 --- a/assets/readme/nav-start-spatial-v4.svg +++ b/assets/readme/nav-start-spatial-v4.svg @@ -2,30 +2,14 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif} .mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px} .float{animation:float 5s ease-in-out infinite} - - - - - .spark{animation:spark 3.5s ease-in-out infinite} - @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} - .sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0} - - - - @keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}} - @keyframes spark{50%{opacity:.25}} - - - - @media(prefers-reduced-motion:reduce){*{animation:none!important}} Start here diff --git a/assets/readme/overview-spatial-v4.svg b/assets/readme/overview-spatial-v4.svg index 68a4aae9a..dece1d1e4 100644 --- a/assets/readme/overview-spatial-v4.svg +++ b/assets/readme/overview-spatial-v4.svg @@ -13,15 +13,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} .sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0} - - - .cursor{animation:cursor 5s ease-in-out infinite} @keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}} - - @keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}} + + @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} diff --git a/dashboard/src/components/nv-app-store.ts b/dashboard/src/components/nv-app-store.ts index f6b90f9bc..2e34c4d94 100644 --- a/dashboard/src/components/nv-app-store.ts +++ b/dashboard/src/components/nv-app-store.ts @@ -97,7 +97,7 @@ export class NvAppStore extends LitElement { .chip .swatch { width: 7px; height: 7px; border-radius: 50%; } - .chip .count { color: var(--ink-3); font-size: 10px; } + .chip .count { color: var(--ink-2); font-size: 10px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); @@ -149,7 +149,7 @@ export class NvAppStore extends LitElement { .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); } + .badge.rt-mesh-only { color: var(--ink-2); border-color: var(--line); } .events-feed { background: var(--bg-2); border: 1px solid var(--line); @@ -388,7 +388,7 @@ export class NvAppStore extends LitElement {

- { this.distanceM = Math.pow(10, +(e.target as HTMLInputElement).value); }} /> @@ -407,7 +407,7 @@ export class NvGhostMurmur extends LitElement { Heart dipole moment ${m.toExponential(2)} A·m² - { this.momentLog10 = +(e.target as HTMLInputElement).value; }} /> @@ -536,7 +536,7 @@ export class NvGhostMurmur extends LitElement {

RuView's three-tier mesh — what is actually buildable

-
┌──────────────────────────┐ +
┌──────────────────────────┐ │ Tier 3 — NV-diamond │ Range: 0.1–2 m (lab) │ magnetometer ring │ Status: nvsim simulator only │ (close-confirm) │ Hardware: $$$ (≥$8k DNV-B1) diff --git a/dashboard/src/components/nv-inspector.ts b/dashboard/src/components/nv-inspector.ts index a0d749542..342ca7e88 100644 --- a/dashboard/src/components/nv-inspector.ts +++ b/dashboard/src/components/nv-inspector.ts @@ -423,7 +423,7 @@ export class NvInspector extends LitElement { role="tab" aria-selected=${this.tab === 'witness'} @click=${() => this.tab = 'witness'}>Witness
-
+
${this.renderHeader()} ${this.tab === 'signal' ? this.renderSignalTab() : this.tab === 'frame' ? this.renderFrameTab() diff --git a/dashboard/src/components/nv-onboarding.ts b/dashboard/src/components/nv-onboarding.ts index 29e9cb1ca..f809fbbd7 100644 --- a/dashboard/src/components/nv-onboarding.ts +++ b/dashboard/src/components/nv-onboarding.ts @@ -185,9 +185,20 @@ export class NvOnboarding extends LitElement { z-index: 240; display: grid; place-items: center; opacity: 0; pointer-events: none; - transition: opacity 0.18s; + /* A dismissed modal must leave the rendering + accessibility tree, not + just fade to opacity 0. While the 0.18s fade-out runs, automated + scanners (and mid-animation users) still see the panel, and contrast + tooling composites the fading text over the dimmed backdrop and + reports false failures. Delaying visibility by the fade duration keeps + the animation intact while making the closed state truly absent. */ + visibility: hidden; + transition: opacity 0.18s, visibility 0s linear 0.18s; + } + :host([open]) { + opacity: 1; pointer-events: auto; + visibility: visible; + transition: opacity 0.18s, visibility 0s; } - :host([open]) { opacity: 1; pointer-events: auto; } .card { background: var(--bg-1); border: 1px solid var(--line-2); @@ -329,6 +340,11 @@ export class NvOnboarding extends LitElement { this.open = true; this.setAttribute('open', ''); } + // Signals that the async storage read above has settled. Until it is set, + // "the tour was already seen" and "the decision has not been made yet" + // are indistinguishable, so a caller can act in the gap and then watch the + // modal open underneath it. + this.setAttribute('ready', ''); } override disconnectedCallback(): void { super.disconnectedCallback(); diff --git a/dashboard/src/components/nv-settings-drawer.ts b/dashboard/src/components/nv-settings-drawer.ts index 92544147c..85b69d347 100644 --- a/dashboard/src/components/nv-settings-drawer.ts +++ b/dashboard/src/components/nv-settings-drawer.ts @@ -172,7 +172,7 @@ export class NvSettingsDrawer extends LitElement {
Stops the rotating diamond, animated field lines, and chart easing. Auto-on if your system has the prefers-reduced-motion preference set.
motionReduced.value = !motionReduced.value}>
@@ -185,7 +185,7 @@ export class NvSettingsDrawer extends LitElement {
When you change a Tunables slider or load a new scene, push the change to the worker without a manual restart.
autoUpdate.value = !autoUpdate.value}> diff --git a/dashboard/tests/a11y.spec.ts b/dashboard/tests/a11y.spec.ts index 18b4c0802..1daeb6163 100644 --- a/dashboard/tests/a11y.spec.ts +++ b/dashboard/tests/a11y.spec.ts @@ -15,13 +15,32 @@ 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(); + // Dismiss the welcome modal if it auto-shows, then wait until it has left + // the accessibility tree. A single poll handles every race at once, + // because each of these was observed as an intermittent failure across + // chromium/firefox/webkit: + // - `nv-app` may not have upgraded yet (no shadowRoot to query); + // - Lit may not have upgraded `nv-onboarding` yet, so a `.skip` click + // would be a silent no-op; + // - `nv-onboarding` decides whether to open inside an async storage + // read, so before its `ready` attribute is set "not open" only means + // "not decided yet". Treating that as dismissed let the modal open + // underneath the scan. + // - the modal fades out over 180ms while staying in the DOM. Scanning + // inside that window samples a half-transparent panel, and contrast + // tooling composites the fading text over the dimmed backdrop into + // false failures no user ever sees. + await page.waitForFunction(() => { + const app = document.querySelector('nv-app') as (HTMLElement & { shadowRoot: ShadowRoot }) | null; + if (!app?.shadowRoot) return false; + const ob = app.shadowRoot.querySelector('nv-onboarding') as HTMLElement | null; + if (!ob) return true; + if (!ob.shadowRoot || !ob.hasAttribute('ready')) return false; + if (ob.hasAttribute('open')) { + (ob.shadowRoot.querySelector('.skip') as HTMLElement | null)?.click(); + return false; } + return getComputedStyle(ob).visibility === 'hidden'; }); // Navigate to the view via the rail button (except for home which is default). if (view !== 'home') { diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 9f9a2dff1..6a087ab81 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; @@ -66,6 +67,11 @@ export default defineConfig({ }, }, }, + test: { + // tests/ holds Playwright specs (run via `npm run test:e2e`), not Vitest. + exclude: ['**/node_modules/**', '**/dist/**', 'tests/**'], + passWithNoTests: true, + }, server: { port: 5173, strictPort: true, diff --git a/docs/adr/ADR-419-w1700k-rf-diagnostics.md b/docs/adr/ADR-419-w1700k-rf-diagnostics.md new file mode 100644 index 000000000..2c6d45269 --- /dev/null +++ b/docs/adr/ADR-419-w1700k-rf-diagnostics.md @@ -0,0 +1,40 @@ +# ADR-419: W1700K diagnostics and RuField evidence boundary + +Status: implementation candidate; hardware validation pending. +Date: 2026-10-14 + +## Decision + +Use an original C consumer of the upstream MIT shared memory ABI, compact JSONL diagnostics, a local browser viewer, and a bounded RuField adapter. Spectrum quality is a separate modality and cannot contribute occupancy evidence. + +Upstream: h0m3us3r/w1700k-sdr commit 0b0413e4309f0c083702fa4eb2d1c2bca4532163, host/stream/include/w1700k_stream.h. Do not copy the GPL recorder. + +## Flow and invariants + +1. The upstream daemon publishes native-endian IQ in ABI v1 shared memory. +2. The consumer validates geometry, reads bounded slots, checks tags around copies, and calculates RMS and int16 rail fractions. +3. JSONL retains source mode, sample rate, completion index, loss and quality reasons. Raw IQ stays on the host. +4. The browser imports records locally. Importing live-labelled data is still file replay. +5. RuField preserves source labels, produces untrusted diagnostics and excludes them from occupancy fusion. + +Acceptance: synthetic ABI fixture through the real bridge, JSON validator, viewer and Rust adapter. Malformed geometry, truncation and invalid numbers fail closed. Unknown fields cannot smuggle IQ into downstream evidence. + +## Configuration and timing + +The header does not establish LO, gain, band or an atomic configuration generation. Caller frequency is advisory. All v1 records have config_verified=false and valid=false. Metrics remain useful for inspection; they cannot promote sensing claims. + +Monotonic nanoseconds are host-local, not UTC or cross-device synchronization. Event slots consume no sample time. Unknown sample position remains null. + +IQ scale is 8192. Flattening may legitimately exceed that amplitude; rail fraction counts int16 rails, not calibrated ADC clipping. + +## Alternatives and limits + +A second UDP receiver duplicates transport handling. Direct IQ into a CSI model violates its input contract. Upstream ci16 sidecars truncate gap indices and omit event positions, preventing exact arbitrary replay. Use shared memory and exact synthetic fixtures. + +No CSI extraction, passive radar, pose, hardware accuracy lift or sustained 320 Msps benchmark is claimed. No router flashing or transmission. + +## Security and release + +Bound file sizes, offsets, record lengths and numeric ranges. Reject unknown diagnostic fields. Local file import performs no upload. Trust enrollment is separate from parsing; synthetic evidence stays labelled. + +Publish tested source after independent review. Deploy only the viewer directory, preserving other Pages content. Roll back by reverting this change and its deployment. diff --git a/docs/rf-diagnostics/README.md b/docs/rf-diagnostics/README.md new file mode 100644 index 000000000..d3db42204 --- /dev/null +++ b/docs/rf-diagnostics/README.md @@ -0,0 +1,45 @@ +# RuView RF diagnostics viewer + +Open [index.html](index.html) in a modern browser, or use the deployed documentation page. This is a standalone local JSONL viewer, not a hardware connection. It has no runtime dependencies, telemetry, external assets, uploads or network API. + +1. Run the producer described in [tools/w1700k](../../tools/w1700k/) and save its diagnostic JSONL output. +2. Select **Import local JSONL**. Files must be at most 10 MiB, with 1 to 10,000 records. +3. Inspect relative RMS, numeric rail fraction, reported missing samples, reason counts and the last 500 observations. +4. Select **Clear** to remove the loaded observations, or **Load synthetic demo** for a deterministic synthetic example. + +Raw IQ and unknown fields are rejected. The viewer accepts the frozen `ruview.rf_diagnostics.v1` schema and requires `valid:false`, `config_verified:false` and the `configuration_unverified` reason. Integers must fit JavaScript's safe integer range; larger producer counters are rejected rather than silently rounded. + +## Evidence boundary + +| Producer source_kind | Display | +| --- | --- | +| synthetic | SYNTHETIC | +| captured_replay | CAPTURED REPLAY | +| live | LIVE UNVERIFIED | + +Every imported file is replay. Source labels are claims from the input and do not prove that a device was used. Mixed input displays all applicable labels. Nothing here establishes human presence, motion, pose, CSI or calibrated RF power. + +RMS is relative dBFS; numeric rails are an advisory clipping indicator. Missing sample totals sum the producer's per record values. Record order is preserved rather than interpreted as synchronized time. Host monotonic timestamps cannot establish wall clock freshness. The producer TTL is displayed but is never converted into a valid or fresh verdict for file replay. + +All text is rendered with textContent. A restrictive content security policy blocks network connections. Invalid imports clear the previous dataset to avoid showing stale values as the new result. + +## Verification + +From the repository root: + +```sh +node --test docs/rf-diagnostics/model.test.cjs +node --check docs/rf-diagnostics/app.js +``` + +Optional browser smoke with an existing Playwright installation and Chromium: + +```sh +PLAYWRIGHT_MODULE=/absolute/path/to/playwright node docs/rf-diagnostics/browser-smoke.cjs +``` + +The browser test checks mobile layout, synthetic rendering, actual local file import, unverified live labels, malformed input rejection, clear and zero external requests. It does not establish physical hardware performance. No package is installed by these commands. + +### Numeric contract + +Sample rates are unknown (0), 40, 80, 160 or 320 Msps. Centre frequency is unknown or 2 to 8 GHz. Each record describes zero or 2048 samples; missing sample counts are multiples of 2048. Absolute sample start remains null. RMS is bounded to −200 through 16 dBFS to accommodate numeric filter headroom, and an available RMS requires an available rail fraction. Reason names are from the producer whitelist and cannot repeat. diff --git a/docs/rf-diagnostics/app.js b/docs/rf-diagnostics/app.js new file mode 100644 index 000000000..c546118ee --- /dev/null +++ b/docs/rf-diagnostics/app.js @@ -0,0 +1,48 @@ +'use strict'; +const M=RFDiagnostics,$=id=>document.getElementById(id),ns='http://www.w3.org/2000/svg'; +let generation=0; +function render(rows){ + const last=rows.at(-1),kinds=[...new Set(rows.map(r=>M.modes[r.source_kind]))]; + $('mode').textContent=kinds.join(' + ')||'NO DATA'; + $('count').textContent=rows.length.toLocaleString(); + $('rms').textContent=last?.rms_dbfs==null?'—':last.rms_dbfs.toFixed(2); + $('rails').textContent=last?.rail_fraction==null?'—':(last.rail_fraction*100).toFixed(3)+'%'; + $('gaps').textContent=rows.length?rows.reduce((n,r)=>n+BigInt(r.missing_samples),0n).toLocaleString():'—'; + const reasons=new Map(); + for(const r of rows)for(const reason of new Set(r.invalid_reasons))reasons.set(reason,(reasons.get(reason)||0)+1); + $('reasons').replaceChildren(); + for(const [reason,count] of reasons){const li=document.createElement('li');li.textContent=reason+' · '+count.toLocaleString();$('reasons').append(li);} + $('meta').textContent=last?[ + 'Source: '+last.source_id,'Origin: '+last.source_kind+' (file/demo replay)', + 'Rate: '+(last.sample_rate_hz||'unknown')+' samples/s', + 'Centre: '+(last.center_frequency_hz??'unknown')+' Hz (unverified)', + 'Run / slot: '+last.run+' / '+last.slot_index, + 'Host monotonic ns: '+last.observed_monotonic_ns, + 'Absolute sample start: '+(last.sample_start??'unknown'), + 'Configuration verified: false','Valid for inference: false', + 'TTL: '+last.ttl_ms+' ms; replay freshness unknown' + ].join('\n'):'No observations'; + const tail=rows.slice(-500),values=tail.map(r=>r.rms_dbfs).filter(v=>v!==null); + const low=Math.min(...values),high=Math.max(...values),span=high-low||1; + $('chart').replaceChildren(); + let path=''; + tail.forEach((r,i)=>{ + if(r.rms_dbfs===null){path+=' ';return;} + const x=20+i*960/Math.max(1,tail.length-1),y=220-200*(r.rms_dbfs-low)/span; + path+=(i===0||tail[i-1].rms_dbfs===null?'M':'L')+x+','+y+' '; + if(r.missing_samples){const dot=document.createElementNS(ns,'circle');dot.setAttribute('cx',x);dot.setAttribute('cy',y);dot.setAttribute('r',4);dot.setAttribute('fill','#ffb66c');$('chart').append(dot);} + }); + const line=document.createElementNS(ns,'path');line.setAttribute('d',path);line.setAttribute('fill','none');line.setAttribute('stroke','#71e7db');line.setAttribute('stroke-width',2);$('chart').prepend(line); + $('range').textContent=values.length?'Range '+low.toFixed(2)+' to '+high.toFixed(2)+' dBFS · '+tail.length+' latest records':'No RMS observations'; + $('chart').setAttribute('aria-label',$('range').textContent); +} +$('demo').onclick=()=>{generation++;render(M.demo());$('status').textContent='Synthetic demonstration loaded. No hardware evidence.';}; +$('clear').onclick=()=>{generation++;render([]);$('file').value='';$('status').textContent='Cleared. No observations retained.';}; +$('file').onchange=async()=>{ + const ticket=++generation,file=$('file').files[0];if(!file)return; + try{ + if(file.size>M.MAX_BYTES)throw Error('File exceeds 10 MiB'); + const rows=M.parse(await file.text());if(ticket!==generation)return; + render(rows);$('status').textContent='Imported '+rows.length+' records. Source labels are unverified claims; this is file replay.'; + }catch(e){if(ticket!==generation)return;render([]);$('status').textContent='Import rejected: '+e.message;} +}; diff --git a/docs/rf-diagnostics/browser-smoke.cjs b/docs/rf-diagnostics/browser-smoke.cjs new file mode 100644 index 000000000..84e074804 --- /dev/null +++ b/docs/rf-diagnostics/browser-smoke.cjs @@ -0,0 +1,29 @@ +'use strict'; +const assert=require('node:assert/strict'),path=require('node:path'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); +(async()=>{ + const browser=await chromium.launch({headless:true}); + try{ + const page=await browser.newPage({viewport:{width:390,height:844}}); + const errors=[],external=[]; + page.on('pageerror',e=>errors.push(e.message)); + page.on('request',r=>{if(/^https?:/.test(r.url()))external.push(r.url());}); + await page.goto('file://'+path.join(__dirname,'index.html')); + await page.click('#demo'); + assert.equal(await page.textContent('#mode'),'SYNTHETIC'); + assert.equal(await page.textContent('#count'),'96'); + assert(await page.locator('#chart path').getAttribute('d')); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + const r=require('./model.js').demo()[0];r.source_kind='live'; + await page.setInputFiles('#file',{name:'live.jsonl',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(r))}); + await page.waitForFunction(()=>document.querySelector('#mode').textContent==='LIVE UNVERIFIED'); + assert.match(await page.textContent('#status'),/file replay/); + await page.setInputFiles('#file',{name:'bad.jsonl',mimeType:'application/json',buffer:Buffer.from('{"iq":[1,2]}')}); + await page.waitForFunction(()=>document.querySelector('#status').textContent.startsWith('Import rejected')); + assert.equal(await page.textContent('#count'),'0'); + await page.click('#demo');await page.click('#clear'); + assert.equal(await page.textContent('#count'),'0'); + assert.deepEqual(errors,[]);assert.deepEqual(external,[]); + console.log('PASS mobile render, demo, JSONL import, unverified live label, malformed rejection, clear, zero external requests'); + }finally{await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/docs/rf-diagnostics/index.html b/docs/rf-diagnostics/index.html new file mode 100644 index 000000000..479103c69 --- /dev/null +++ b/docs/rf-diagnostics/index.html @@ -0,0 +1,12 @@ + + + +RuView RF Diagnostics +

COGNITUM / RUVIEW / W1700K

NO DATA

RF diagnostics.

Understand the measurement before trusting the inference.

+

Local processing only. Maximum 10 MiB and 10,000 observations. No uploads.

+

Import bridge diagnostics or explore a clearly labelled synthetic example.

+ +

Latest RMS

—relative dBFS, not calibrated RF power

Latest rails

—samples at numeric limits

Reported missing

—sum of per record missing samples

Observations

0all v1 observations invalid
+

Relative RMS history

Record order, last 500 observations. Missing metrics break the line. Orange dots flag missing samples.

No observations

+

Invalidation reasons

  • No observations

Source metadata

No observations
+
RuField evidence boundary · 1 second TTL is producer metadata. Host monotonic time is not wall time; file replay cannot establish freshness.
diff --git a/docs/rf-diagnostics/model.js b/docs/rf-diagnostics/model.js new file mode 100644 index 000000000..f27e4fa29 --- /dev/null +++ b/docs/rf-diagnostics/model.js @@ -0,0 +1,46 @@ +(function(root){ +'use strict'; +const MAX_BYTES=10*1024*1024, MAX_RECORDS=10000; +const fields='schema source_kind source_id observed_monotonic_ns run slot_index sample_start sample_rate_hz center_frequency_hz config_verified valid invalid_reasons sample_count rms_dbfs rail_fraction missing_samples event_count overrun_slices ttl_ms'.split(' '); +const modes={synthetic:'SYNTHETIC',live:'LIVE UNVERIFIED',captured_replay:'CAPTURED REPLAY'}; +function validate(r){ + const fail=(s)=>{throw Error(s)}; + if(!r||Array.isArray(r)||typeof r!=='object')fail('Expected an object'); + for(const k of Object.keys(r))if(!fields.includes(k))fail('Unsupported field: '+k); + for(const k of fields)if(!Object.hasOwn(r,k))fail('Missing field: '+k); + if(r.schema!=='ruview.rf_diagnostics.v1')fail('Unsupported schema'); + if(!Object.hasOwn(modes,r.source_kind)||r.source_id!=='w1700k')fail('Unsupported source'); + for(const k of ['observed_monotonic_ns','run','slot_index','sample_rate_hz','sample_count','missing_samples','event_count','overrun_slices','ttl_ms']) + if(!Number.isSafeInteger(r[k])||r[k]<0)fail('Invalid '+k); + if(r.ttl_ms!==1000)fail('Invalid rate or TTL'); + for(const k of ['sample_start','center_frequency_hz']) + if(r[k]!==null&&(!Number.isSafeInteger(r[k])||r[k]<0))fail('Invalid '+k); + if(r.rms_dbfs!==null&&(typeof r.rms_dbfs!=='number'||!Number.isFinite(r.rms_dbfs)))fail('Invalid RMS'); + if(r.rail_fraction!==null&&(typeof r.rail_fraction!=='number'||!Number.isFinite(r.rail_fraction)||r.rail_fraction<0||r.rail_fraction>1))fail('Invalid rail fraction'); + if(r.config_verified!==false||r.valid!==false)fail('v1 must remain unverified and invalid'); + if(!Array.isArray(r.invalid_reasons)||r.invalid_reasons.length>32||!r.invalid_reasons.includes('configuration_unverified')||r.invalid_reasons.some(x=>typeof x!=='string'||!/^[a-z_]{1,64}$/.test(x)))fail('Invalid reasons'); + if(![0,40000000,80000000,160000000,320000000].includes(r.sample_rate_hz))fail('Invalid sample rate'); + if(r.sample_start!==null)fail('Absolute sample start is unavailable in v1'); + if(r.center_frequency_hz!==null&&(r.center_frequency_hz<2000000000||r.center_frequency_hz>8000000000))fail('Invalid centre frequency'); + if(![0,2048].includes(r.sample_count)||r.missing_samples%2048!==0)fail('Invalid sample count'); + if(r.rms_dbfs!==null&&(r.rms_dbfs< -200||r.rms_dbfs>16||r.rail_fraction===null))fail('Invalid RMS or missing rail fraction'); + const reasons=['configuration_unverified','lost','event','overrun','stale','tag_mismatch','run_change']; + if(new Set(r.invalid_reasons).size!==r.invalid_reasons.length||r.invalid_reasons.length>7||r.invalid_reasons.some(x=>!reasons.includes(x)))fail('Unsupported invalid reason'); + return r; +} +function parse(text){ + if(typeof text!=='string'||new TextEncoder().encode(text).length>MAX_BYTES)throw Error('File exceeds 10 MiB'); + const lines=text.split(/\r?\n/).filter(x=>x.trim()); + if(!lines.length||lines.length>MAX_RECORDS)throw Error('Expected 1 to 10,000 records'); + return lines.map((s,i)=>{try{return validate(JSON.parse(s))}catch(e){throw Error('Record '+(i+1)+': '+e.message)}}); +} +function demo(){return Array.from({length:96},(_,i)=>({ + schema:'ruview.rf_diagnostics.v1',source_kind:'synthetic',source_id:'w1700k', + observed_monotonic_ns:i*100000000,run:1,slot_index:i,sample_start:null, + sample_rate_hz:40000000,center_frequency_hz:2437000000,config_verified:false,valid:false, + invalid_reasons:i%23===0?['configuration_unverified','lost']:['configuration_unverified'], + sample_count:2048,rms_dbfs:-34+Math.sin(i/8)*6,rail_fraction:i%23===0?0.03:0, + missing_samples:i%23===0?2048:0,event_count:0,overrun_slices:0,ttl_ms:1000}));} +const api={MAX_BYTES,MAX_RECORDS,modes,validate,parse,demo}; +if(typeof module==='object')module.exports=api;else root.RFDiagnostics=api; +})(globalThis); diff --git a/docs/rf-diagnostics/model.test.cjs b/docs/rf-diagnostics/model.test.cjs new file mode 100644 index 000000000..5e7cb82aa --- /dev/null +++ b/docs/rf-diagnostics/model.test.cjs @@ -0,0 +1,23 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'),M=require('./model.js'); +const good=()=>M.demo()[0]; +test('synthetic fixture and JSONL roundtrip',()=>assert.equal(M.parse(M.demo().map(JSON.stringify).join('\n')).length,96)); +test('captured and live labels remain unverified',()=>{for(const source_kind of ['live','captured_replay'])assert.equal(M.validate({...good(),source_kind}).valid,false);}); +test('reject malformed, empty, oversized and too many records',()=>{ + for(const s of ['', '[]','{',' '.repeat(M.MAX_BYTES+1),Array(10002).fill('{}').join('\n')])assert.throws(()=>M.parse(s)); +}); +test('reject inference promotion, raw IQ, nonfinite metrics and unsafe indices',()=>{ + for(const patch of [{valid:true},{config_verified:true},{iq:[1,2]},{rms_dbfs:Infinity},{rail_fraction:2},{rail_fraction:NaN},{observed_monotonic_ns:2**53},{source_kind:'__proto__'},{ttl_ms:0},{invalid_reasons:['