Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] eb7404761c chore: update vendor submodules to latest upstream 2026-06-15 08:13:40 +00:00
66 changed files with 47 additions and 7635 deletions
-199
View File
@@ -1,199 +0,0 @@
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
-13
View File
File diff suppressed because one or more lines are too long
@@ -1,444 +0,0 @@
# 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: 46px`, 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.01.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.30.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 630), heart rate BPM (numeric in `--cyan` with range indicator 40120), restlessness score (01 progress bar), and anomaly score (01 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 15 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 XY 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 W1W6 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.
@@ -1,110 +0,0 @@
# 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 23× run-to-run (a captured 10-sample run showed `float_l2/512` ranging
307444 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` ≈ 37 ns vs `float_l2`
≈ 63371 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
@@ -1,172 +0,0 @@
# 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 487499) 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`
@@ -1,103 +0,0 @@
# 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)
@@ -1,92 +0,0 @@
# 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). BoxMuller 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)
@@ -1,87 +0,0 @@
# 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 132, password 863) **+ 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
@@ -1,81 +0,0 @@
# 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)
Generated
-11
View File
@@ -3595,7 +3595,6 @@ dependencies = [
"anyhow",
"axum",
"clap",
"futures",
"homecore",
"homecore-api",
"homecore-assist",
@@ -3603,13 +3602,8 @@ 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",
]
@@ -3773,7 +3767,6 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.7",
]
[[package]]
@@ -6877,8 +6870,6 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
@@ -6886,7 +6877,6 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.4",
"tower 0.5.3",
"tower-http",
"tower-service",
@@ -6894,7 +6884,6 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]]
+1 -5
View File
@@ -42,11 +42,7 @@ pub fn router(state: SharedState) -> Router {
.with_state(state)
}
/// 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 {
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
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod state;
pub mod tokens;
pub mod ws;
pub use app::{build_cors_layer, router, AppState};
pub use app::{router, AppState};
pub use error::{ApiError, ApiResult};
pub use state::SharedState;
pub use tokens::LongLivedTokenStore;
-20
View File
@@ -37,26 +37,6 @@ 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 = []
-23
View File
@@ -116,29 +116,6 @@ 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
-758
View File
@@ -1,758 +0,0 @@
//! 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"]);
}
}
+2 -226
View File
@@ -27,7 +27,7 @@ use tracing::{info, warn};
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler;
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
use homecore_api::{router, LongLivedTokenStore, SharedState};
use homecore_assist::pipeline::default_pipeline;
use homecore_assist::RegexIntentRecognizer;
use homecore_automation::AutomationEngine;
@@ -35,18 +35,6 @@ 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 {
@@ -54,30 +42,6 @@ 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,
@@ -210,59 +174,15 @@ async fn main() -> Result<()> {
env!("CARGO_PKG_VERSION"),
tokens,
);
// 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 app = router(api_state);
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(
@@ -384,147 +304,3 @@ 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"
);
}
}
-223
View File
@@ -1,223 +0,0 @@
/*
* 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); }
@@ -1,34 +0,0 @@
/*
* 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;
}
-17
View File
@@ -1,17 +0,0 @@
<!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>
-197
View File
@@ -1,197 +0,0 @@
// 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; },
};
}
-141
View File
@@ -1,141 +0,0 @@
// 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 };
-296
View File
@@ -1,296 +0,0 @@
// 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' },
],
};
}
@@ -1,217 +0,0 @@
// §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,
],
});
}
@@ -1,256 +0,0 @@
// §4.7 Calibration Wizard — baseline → enroll → train → verify.
// Stepped wizard (15) 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_- , 164)', 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;
}
@@ -1,194 +0,0 @@
// §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] });
}
@@ -1,153 +0,0 @@
// §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';
}
@@ -1,240 +0,0 @@
// §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;
}
@@ -1,308 +0,0 @@
// §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);
}
@@ -1,198 +0,0 @@
// §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;
}
@@ -1,119 +0,0 @@
// §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');
}
@@ -1,256 +0,0 @@
// §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')));
}
@@ -1,256 +0,0 @@
// §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';
}
-235
View File
@@ -1,235 +0,0 @@
// 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' }));
}
-69
View File
@@ -1,69 +0,0 @@
// 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; },
};
}
-12
View File
@@ -1,12 +0,0 @@
{
"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"
}
}
@@ -1,54 +0,0 @@
// 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);
@@ -1,37 +0,0 @@
// 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');
@@ -1,103 +0,0 @@
// 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 };
@@ -1,86 +0,0 @@
// 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');
@@ -1,45 +0,0 @@
// 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');
@@ -1,109 +0,0 @@
// 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');
@@ -1,101 +0,0 @@
// 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');
@@ -1,67 +0,0 @@
// 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`);
-30
View File
@@ -39,20 +39,7 @@ 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
@@ -166,23 +153,6 @@ 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);
+3 -76
View File
@@ -51,28 +51,11 @@ 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> {
// `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
let dt = self
.config
.dt_s
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
// 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 dt_us = (dt * 1.0e6) as u64;
let nv = NvSensor::new(self.config.sensor);
let mut out: Vec<MagFrame> =
@@ -109,7 +92,7 @@ impl Pipeline {
];
let mut frame = MagFrame::empty(sensor_idx as u16);
frame.t_us = (sample as u64).saturating_mul(dt_us);
frame.t_us = (sample as u64) * 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;
@@ -222,62 +205,6 @@ 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.
+4 -48
View File
@@ -47,18 +47,8 @@ impl FailSafeMachine {
link_alive: bool,
nearest_neighbor_dist: f64,
) -> FailSafeState {
// 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 {
// Collision avoidance has highest priority
if nearest_neighbor_dist < self.collision_dist_m {
self.state = FailSafeState::EmergencyDiverge;
return self.state.clone();
}
@@ -81,11 +71,8 @@ impl FailSafeMachine {
}
}
// 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 {
// Battery checks
if state.battery_pct <= self.battery_rth_pct {
self.state = FailSafeState::ReturnToHome;
} else if state.battery_pct <= self.battery_warn_pct {
self.state = FailSafeState::LowBatteryWarn;
@@ -157,35 +144,4 @@ 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,16 +59,8 @@ 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]
@@ -76,11 +68,7 @@ impl FhssRadio {
/// Advance the hop sequence by one step (call at hop_rate_hz).
pub fn next_hop(&mut self) {
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;
self.hop_index = (self.hop_index + 1) % self.config.channels_mhz.len();
}
/// Update with latest RSSI measurement. Drives jamming detection.
@@ -109,13 +97,9 @@ impl FhssRadio {
.wrapping_mul(lcg_a)
.wrapping_add(self.evasion_count)
.wrapping_add(lcg_c);
let len = self.config.channels_mhz.len();
if len == 0 {
return; // no channels configured — avoid `% 0` panic
}
let n = len as u64;
let n = self.config.channels_mhz.len() as u64;
let offset = (seed % n / 4 + 3) as usize;
self.hop_index = (self.hop_index + offset) % len;
self.hop_index = (self.hop_index + offset) % self.config.channels_mhz.len();
self.evasion_count += 1;
self.rssi_history.clear();
}
@@ -181,23 +165,6 @@ 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,16 +27,6 @@ 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
@@ -156,29 +146,4 @@ 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,25 +64,10 @@ impl MultiViewFusion {
detections: &[CsiDetection],
drone_positions: &[(NodeId, Position3D)],
) -> Option<FusedDetection> {
// 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.
// Filter by confidence and require estimated position
let valid: Vec<(&CsiDetection, &Position3D)> = detections
.iter()
.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(|d| d.confidence >= self.min_confidence && d.victim_position.is_some())
.filter_map(|d| {
let drone_pos = drone_positions
.iter()
@@ -192,46 +177,4 @@ 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");
}
}
@@ -4,6 +4,8 @@
"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","dialog:allow-open","dialog:allow-save"]}}
{"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"]}}
@@ -430,35 +430,6 @@ 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.
@@ -472,10 +443,6 @@ 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
@@ -582,37 +549,6 @@ 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,9 +220,6 @@ 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,27 +206,6 @@ 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
@@ -476,8 +455,4 @@ 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,21 +92,8 @@ 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),
// 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::I32 => Some(DType::I64), // widen for Candle compatibility
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,17 +137,6 @@ 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)
@@ -350,21 +339,6 @@ 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;
@@ -1,185 +0,0 @@
//! 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);
}
@@ -1,139 +0,0 @@
//! 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"
);
}
@@ -6391,71 +6391,32 @@ fn vitals_snapshots_from_sensing_json(
}
}
/// Build the multistatic guard config from the environment (#1031, #1049).
/// Build the multistatic guard config, optionally derived from the TDM schedule
/// declared in the environment (#1031).
///
/// Three precedence layers, most-specific wins:
/// 1. `WDP_GUARD_INTERVAL_US` (+ optional `WDP_SOFT_GUARD_US`) — a **direct**
/// hard-guard override. This is the #1049 escape hatch: WiFi/ESP-NOW-synced
/// ESP32 nodes drift 10150 ms (the 100 ms beacon + WiFi-MAC jitter cannot
/// hold two independently-clocked boards within the published default), so a
/// deployment can simply lift the guard past its measured spread (e.g.
/// `WDP_GUARD_INTERVAL_US=200000`) without knowing its exact TDM schedule.
/// 2. `WDP_TDM_SLOTS` + `WDP_TDM_SLOT_US` (both positive) — derive the guard
/// from the declared schedule via [`MultistaticConfig::for_tdm_schedule`].
/// 3. Otherwise the published default (60 ms hard / 20 ms soft).
///
/// The direct override (1) is applied **on top of** whichever base (2 or 3) is
/// selected, so `WDP_GUARD_INTERVAL_US` always wins for the hard guard while a
/// TDM-derived soft band is preserved unless it would exceed the new hard guard.
/// `min_nodes` is *not* set here — the caller overrides it for single-node
/// passthrough.
/// When both `WDP_TDM_SLOTS` and `WDP_TDM_SLOT_US` parse as positive integers,
/// the guard is derived via [`MultistaticConfig::for_tdm_schedule`] so a
/// deployment can match its exact schedule. Otherwise the published default
/// (60 ms hard / 20 ms soft) is returned. `min_nodes` is *not* set here — the
/// caller overrides it for single-node passthrough.
fn multistatic_guard_config_from_env() -> MultistaticConfig {
multistatic_guard_config_from(
std::env::var("WDP_TDM_SLOTS").ok().as_deref(),
std::env::var("WDP_TDM_SLOT_US").ok().as_deref(),
std::env::var("WDP_GUARD_INTERVAL_US").ok().as_deref(),
std::env::var("WDP_SOFT_GUARD_US").ok().as_deref(),
)
}
/// Pure core of [`multistatic_guard_config_from_env`] for testability.
fn multistatic_guard_config_from(
slots: Option<&str>,
slot_us: Option<&str>,
guard_us: Option<&str>,
soft_us: Option<&str>,
) -> MultistaticConfig {
// Base: TDM-schedule-derived when both slot params are valid, else default.
let mut cfg = match (
fn multistatic_guard_config_from(slots: Option<&str>, slot_us: Option<&str>) -> MultistaticConfig {
match (
slots.and_then(|s| s.trim().parse::<usize>().ok()),
slot_us.and_then(|s| s.trim().parse::<u64>().ok()),
) {
(Some(n), Some(us)) if n >= 1 && us >= 1 => MultistaticConfig::for_tdm_schedule(n, us),
_ => MultistaticConfig::default(),
};
// Direct hard-guard override (#1049). Ignored when unset/zero/unparseable so
// a malformed env var falls back to the base rather than breaking fusion.
if let Some(g) = guard_us
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&g| g >= 1)
{
cfg.guard_interval_us = g;
// Keep the soft band strictly below the (possibly lowered) hard guard.
if cfg.soft_guard_us >= g {
cfg.soft_guard_us = g.saturating_sub(1).max(1);
(Some(n), Some(us)) if n >= 1 && us >= 1 => {
MultistaticConfig::for_tdm_schedule(n, us)
}
_ => MultistaticConfig::default(),
}
// Optional explicit soft-guard override, always clamped strictly below hard.
if let Some(s) = soft_us
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&s| s >= 1)
{
cfg.soft_guard_us = s.min(cfg.guard_interval_us.saturating_sub(1).max(1));
}
cfg
}
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
@@ -7524,16 +7485,11 @@ async fn main() {
pose_tracker: PoseTracker::new(),
last_tracker_instant: None,
multistatic_fuser: {
// #1031/#1049: the default guard (60 ms hard / 20 ms soft)
// accommodates a real TDM slot offset. A deployment overrides it via
// WDP_GUARD_INTERVAL_US (direct, e.g. 200000 for WiFi/ESP-NOW sync —
// #1049) or WDP_TDM_SLOTS + WDP_TDM_SLOT_US (derive from schedule).
// #1031: the default guard (60 ms hard / 20 ms soft) accommodates a
// real TDM slot offset. A deployment can override it to match its
// own schedule via WDP_TDM_SLOTS + WDP_TDM_SLOT_US (both set ⇒ derive
// from the schedule), else the published default is used.
let cfg = multistatic_guard_config_from_env();
info!(
"Multistatic fusion guard: {} µs hard / {} µs soft (override via \
WDP_GUARD_INTERVAL_US / WDP_SOFT_GUARD_US, or WDP_TDM_SLOTS+WDP_TDM_SLOT_US)",
cfg.guard_interval_us, cfg.soft_guard_us
);
let mut fuser = MultistaticFuser::with_config(MultistaticConfig {
min_nodes: 1, // single-node passthrough
..cfg
@@ -7841,72 +7797,6 @@ async fn main() {
info!("Server shut down cleanly");
}
#[cfg(test)]
mod multistatic_guard_config_tests {
//! #1049 — the multistatic guard interval must be operator-configurable so a
//! WiFi/ESP-NOW deployment (10150 ms inter-node clock drift) can lift the
//! guard past its measured timestamp spread instead of being permanently
//! demoted to Restricted with no escape hatch.
use super::*;
#[test]
fn default_guard_when_nothing_set() {
let cfg = multistatic_guard_config_from(None, None, None, None);
assert_eq!(cfg.guard_interval_us, MultistaticConfig::default().guard_interval_us);
assert_eq!(cfg.soft_guard_us, MultistaticConfig::default().soft_guard_us);
}
#[test]
fn direct_guard_override_wins_and_unblocks_wifi_spread() {
// The #1049 reporter's measured ~70 ms spread exceeds the 60 ms default
// → permanent demotion. A direct 200 ms override accepts it.
let cfg = multistatic_guard_config_from(None, None, Some("200000"), None);
assert_eq!(cfg.guard_interval_us, 200_000);
assert!(cfg.soft_guard_us < cfg.guard_interval_us);
// 70 ms spread now sits inside the guard.
assert!(70_000 < cfg.guard_interval_us);
}
#[test]
fn direct_guard_override_beats_tdm_derived() {
// Both TDM params AND a direct override set → the direct hard guard wins,
// the TDM-derived soft band is preserved (still strictly below hard).
let cfg = multistatic_guard_config_from(Some("2"), Some("18000"), Some("200000"), None);
assert_eq!(cfg.guard_interval_us, 200_000);
assert!(cfg.soft_guard_us < cfg.guard_interval_us);
assert!(cfg.soft_guard_us >= 1);
}
#[test]
fn soft_override_is_clamped_strictly_below_hard() {
// A soft guard ≥ hard would be nonsensical → clamped below the hard guard.
let cfg = multistatic_guard_config_from(None, None, Some("50000"), Some("999999"));
assert_eq!(cfg.guard_interval_us, 50_000);
assert!(cfg.soft_guard_us < 50_000);
}
#[test]
fn lowering_hard_below_default_soft_pulls_soft_down() {
// Override hard to 10 ms (< default 20 ms soft) → soft drops below it.
let cfg = multistatic_guard_config_from(None, None, Some("10000"), None);
assert_eq!(cfg.guard_interval_us, 10_000);
assert!(cfg.soft_guard_us < 10_000);
}
#[test]
fn malformed_or_zero_override_falls_back_to_base() {
// Garbage / zero must not break fusion — fall back to the base config.
for bad in ["", "abc", "0", "-5", "12.5"] {
let cfg = multistatic_guard_config_from(None, None, Some(bad), None);
assert_eq!(
cfg.guard_interval_us,
MultistaticConfig::default().guard_interval_us,
"override {bad:?} should be ignored"
);
}
}
}
#[cfg(test)]
mod node_sync_snapshot_serialization_tests {
//! ADR-110 iter 24 — JSON public-API contract for the iter 23
@@ -1,294 +0,0 @@
#!/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()
@@ -20,7 +20,6 @@ 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,
};
@@ -65,16 +64,14 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
for i in 0..max_sc {
unsafe {
// 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));
phases[i] = host_get_phase(i as i32);
amplitudes[i] = host_get_amplitude(i as i32);
variances[i] = host_get_variance(i as i32);
}
}
let presence = unsafe { host_get_presence() };
let motion_energy = sanitize_host_f32(unsafe { host_get_motion_energy() });
let motion_energy = unsafe { host_get_motion_energy() };
let detector = unsafe { &mut *core::ptr::addr_of_mut!(DETECTOR) };
let events = detector.process_frame(
+3 -95
View File
@@ -572,35 +572,6 @@ 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) {
@@ -679,10 +650,8 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
for i in 0..max_sc {
unsafe {
// 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));
phases[i] = host_get_phase(i as i32);
amps[i] = host_get_amplitude(i as i32);
}
}
@@ -708,71 +677,10 @@ 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 = sanitize_host_f32(unsafe { host_get_motion_energy() });
let motion = 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"
);
}
}
+1 -1