From 34c9804002ee66fd893bd72708e34728ddafcf65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 00:47:29 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20ADR-292/293/294/295/296=20?= =?UTF-8?q?=E2=80=94=20provenance,=20UDP=20hardening,=20multi-node,=20mode?= =?UTF-8?q?l=20gates,=20CSI=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-292 (sensing-server): SourceState enum + pure transition (provenance.rs); auth-error/unknown can never resolve to LiveVerified; synthetic exports watermarked. Pose-fusion simulator starts SYNTHETIC and only shows LIVE on a real decoded frame (#1557); sensing client no longer labels an unauthorized status endpoint as live (#1526). ADR-294 (sensing-server): NodeInference distinct from RoomInference (inference.rs); deterministic freshness-weighted fuse_room; RateLimiter re-keyed to (NodeId,EntityKind) so nodes do not starve each other (#1541); stale nodes go unavailable not frozen-online (#1555). ADR-293 (sensing-server): --udp-bind (default 127.0.0.1) + --udp-allow allowlist + fail-closed refusal of routable bind without allowlist unless --udp-insecure-lan (udp_bind.rs); crate SECURITY.md documents the threat model and the deferred per-device-auth step two. ADR-295 (train): model_gates.rs — constant-output, unreachable-boundary (the issue-1521 degenerate presence head), class-balance, baseline, and metric-name-provenance gates. ADR-296 (ci): scripts/csi-data-policy-check.sh (+ allowlist) and a workflow that fails on tracked CSI-format/oversized-JSONL files; 6/6 self-tests pass. Per-crate suites reported green by the swarm; CSI policy self-test 6/6 and JS syntax verified here. Full workspace re-verification deferred until the concurrent phase-1 spine build frees the target dir (disk pressure). Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS --- .github/workflows/csi-data-policy.yml | 53 + scripts/csi-data-policy-check.sh | 282 +++++ scripts/csi-data-policy.allow | 14 + ui/pose-fusion/js/csi-simulator.js | 26 +- ui/pose-fusion/js/main.js | 17 +- ui/services/sensing.service.js | 28 +- .../wifi-densepose-sensing-server/SECURITY.md | 61 ++ .../benches/mqtt_throughput.rs | 4 +- .../src/inference.rs | 300 ++++++ .../wifi-densepose-sensing-server/src/lib.rs | 7 + .../wifi-densepose-sensing-server/src/main.rs | 165 ++- .../src/mqtt/publisher.rs | 18 +- .../src/mqtt/state.rs | 86 +- .../src/provenance.rs | 295 ++++++ .../src/udp_bind.rs | 404 +++++++ v2/crates/wifi-densepose-train/src/lib.rs | 12 + .../wifi-densepose-train/src/model_gates.rs | 987 ++++++++++++++++++ 17 files changed, 2706 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/csi-data-policy.yml create mode 100755 scripts/csi-data-policy-check.sh create mode 100644 scripts/csi-data-policy.allow create mode 100644 v2/crates/wifi-densepose-sensing-server/SECURITY.md create mode 100644 v2/crates/wifi-densepose-sensing-server/src/inference.rs create mode 100644 v2/crates/wifi-densepose-sensing-server/src/provenance.rs create mode 100644 v2/crates/wifi-densepose-sensing-server/src/udp_bind.rs create mode 100644 v2/crates/wifi-densepose-train/src/model_gates.rs diff --git a/.github/workflows/csi-data-policy.yml b/.github/workflows/csi-data-policy.yml new file mode 100644 index 00000000..2d0f30dd --- /dev/null +++ b/.github/workflows/csi-data-policy.yml @@ -0,0 +1,53 @@ +name: CSI data policy (ADR-296) + +# ADR-296 repository CSI data-incident guard. Fails when CSI-format files +# (*.csi.jsonl / *.csi.meta.json) or oversized JSONL captures are tracked in +# git. Raw CSI is person data and must never be committed (CLAUDE.md, ADR-296). +# +# NOTE: the tree currently still contains the pre-existing incident recordings +# under data/recordings/ and v2/data/recordings/, whose removal is gated on +# data-owner sign-off (ADR-296). Until they are removed this job is EXPECTED to +# fail, and that failure documents the incident. To make it green in a +# follow-up without weakening the guard for NEW files, set CSI_POLICY_BASELINE +# to a file listing the acknowledged paths (see the script header). +# +# Checker: scripts/csi-data-policy-check.sh Run locally: bash the same script. + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + csi-data-policy: + name: CSI data policy check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + + - name: Self-test the policy checker (deterministic, offline) + run: bash scripts/csi-data-policy-check.sh --self-test + + - name: Enforce CSI data policy on tracked files + # CSI_POLICY_BASELINE can point at an acknowledged-paths file once the + # owner remediates the tree; unset here so a regression fails loudly. + run: bash scripts/csi-data-policy-check.sh --tracked + + - name: Summarize result + if: always() + run: | + { + echo '### CSI data policy (ADR-296)' + echo '' + echo '```' + bash scripts/csi-data-policy-check.sh --tracked 2>&1 || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/csi-data-policy-check.sh b/scripts/csi-data-policy-check.sh new file mode 100755 index 00000000..556e2b36 --- /dev/null +++ b/scripts/csi-data-policy-check.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# +# csi-data-policy-check.sh — ADR-296 CSI data-incident repository guard. +# +# WHY (ADR-296): raw CSI recordings are person data (they encode breathing, +# movement, and presence) and CLAUDE.md prohibits committing CSI or person +# data. A stale `.gitignore` rule let ~64.6 MB of raw captures reach the tree +# under `data/recordings/` and `v2/data/recordings/`. This check is the +# mechanical guard that prevents the incident from getting worse: it fails when +# CSI-format files or oversized JSONL captures are tracked/staged. +# +# WHAT IT FLAGS: +# * `*.csi.jsonl` — raw CSI capture stream (person data) +# * `*.csi.meta.json` — capture sidecar metadata +# * `*.jsonl` larger than CSI_POLICY_MAX_JSONL_BYTES (~5 MB default) — a +# capture-sized JSONL blob that almost never belongs in git. +# +# DETERMINISTIC / OFFLINE: no network, no clock, no randomness. It only reads +# the file list git already knows about (or a list you pass in) and file sizes. +# +# USAGE: +# scripts/csi-data-policy-check.sh # scan tracked files (git ls-files) +# scripts/csi-data-policy-check.sh --staged # scan the staged set (pre-commit) +# scripts/csi-data-policy-check.sh --files-from - # scan a newline list on stdin +# scripts/csi-data-policy-check.sh --files-from FILE +# scripts/csi-data-policy-check.sh --self-test # run built-in self-tests +# +# EXIT CODES: 0 = clean, 1 = policy violation, 2 = usage/environment error. +# +# ------------------------------------------------------------------------------ +# ALLOWLIST (synthetic test fixtures) +# ------------------------------------------------------------------------------ +# Tests may use only synthetic or expressly-consented minimal fixtures (ADR-296). +# A file whose path matches an allow pattern is exempt. Patterns come from: +# * the file `scripts/csi-data-policy.allow` (one glob per line, `#` comments), and +# * the env var `CSI_POLICY_ALLOW` (colon-separated globs). +# Patterns are shell globs matched against the repo-relative path, e.g. +# scripts/tests/fixtures/csi-policy/*.csi.jsonl +# +# ------------------------------------------------------------------------------ +# BASELINE (acknowledged pre-existing incident, ADR-296) +# ------------------------------------------------------------------------------ +# The tree today ALREADY contains the incident recordings under +# `data/recordings/` and `v2/data/recordings/`. Removing them is destructive and +# gated on data-owner sign-off (ADR-296 "Decision"), so this guard is EXPECTED to +# fail on the current tree — that failure documents the incident. +# +# Once the owner removes those files, or to acknowledge them in the interim +# without weakening the guard for NEW files, point `CSI_POLICY_BASELINE` at a +# file listing the acknowledged repo-relative paths (one per line, `#` comments, +# globs allowed). Baseline-matched files are reported as "acknowledged" and do +# NOT fail the check; every other violation still fails. This is the intended +# mechanism to make the CI job green in a follow-up once remediation lands. +# +set -euo pipefail + +# --- configuration ----------------------------------------------------------- +# ~5 MB default. Override with CSI_POLICY_MAX_JSONL_BYTES for tests/tuning. +MAX_JSONL_BYTES="${CSI_POLICY_MAX_JSONL_BYTES:-5242880}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ALLOW_FILE="${CSI_POLICY_ALLOW_FILE:-$SCRIPT_DIR/csi-data-policy.allow}" + +# --- allow / baseline pattern loading ---------------------------------------- +# Read glob patterns from a file (skip blank lines and `#` comments) into the +# named array. Missing files are treated as empty (not an error). +read_patterns() { + local file="$1" __arrname="$2" line + eval "$__arrname=()" + [[ -f "$file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + # trim leading/trailing whitespace + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + eval "$__arrname+=(\"\$line\")" + done < "$file" +} + +ALLOW_PATTERNS=() +read_patterns "$ALLOW_FILE" ALLOW_PATTERNS +# Append colon-separated CSI_POLICY_ALLOW entries. +if [[ -n "${CSI_POLICY_ALLOW:-}" ]]; then + local_ifs="$IFS"; IFS=':' + for p in $CSI_POLICY_ALLOW; do [[ -n "$p" ]] && ALLOW_PATTERNS+=("$p"); done + IFS="$local_ifs" +fi + +BASELINE_PATTERNS=() +if [[ -n "${CSI_POLICY_BASELINE:-}" ]]; then + [[ -f "$CSI_POLICY_BASELINE" ]] || { + echo "ERROR: CSI_POLICY_BASELINE points at a missing file: $CSI_POLICY_BASELINE" >&2 + exit 2 + } + read_patterns "$CSI_POLICY_BASELINE" BASELINE_PATTERNS +fi + +# Return 0 if $1 matches any glob in the array named by $2. +matches_any() { + local path="$1" __arrname="$2" pat + local -a arr + eval "arr=(\"\${${__arrname}[@]}\")" + for pat in "${arr[@]:-}"; do + [[ -z "$pat" ]] && continue + # shellcheck disable=SC2053 # intentional glob match + [[ "$path" == $pat ]] && return 0 + done + return 1 +} + +# --- per-file classification ------------------------------------------------- +# Echo a violation reason for $1, or nothing if the file is fine. CSI-format +# files are always flagged; other .jsonl files are flagged only when oversized. +classify() { + local f="$1" + case "$f" in + *.csi.jsonl) echo "CSI capture stream (*.csi.jsonl)"; return 0 ;; + *.csi.meta.json) echo "CSI capture metadata (*.csi.meta.json)"; return 0 ;; + esac + case "$f" in + *.jsonl) + local size + size="$(file_size "$f")" + if [[ "$size" -gt "$MAX_JSONL_BYTES" ]]; then + echo "oversized JSONL capture (${size} bytes > ${MAX_JSONL_BYTES})" + return 0 + fi + ;; + esac + return 0 +} + +# Best-effort byte size for a repo-relative path. Prefer the working-tree file; +# fall back to git's stored blob so the check works on a bare/partial checkout. +# Prints 0 when the size cannot be determined (glob rules still apply). +file_size() { + local f="$1" sz + if [[ -f "$f" ]]; then + sz="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo 0)" + echo "${sz:-0}"; return 0 + fi + if command -v git >/dev/null 2>&1; then + sz="$(git cat-file -s ":$f" 2>/dev/null || git cat-file -s "HEAD:$f" 2>/dev/null || echo 0)" + echo "${sz:-0}"; return 0 + fi + echo 0 +} + +# --- core scan --------------------------------------------------------------- +# Read newline-separated repo-relative paths on stdin and enforce policy. +# Exits 1 if any non-baseline, non-allowlisted violation is found. +scan_stdin() { + local violations=0 acknowledged=0 f reason + while IFS= read -r f || [[ -n "$f" ]]; do + [[ -z "$f" ]] && continue + reason="$(classify "$f")" + [[ -z "$reason" ]] && continue + if matches_any "$f" ALLOW_PATTERNS; then + continue # synthetic fixture, expressly allowed + fi + if matches_any "$f" BASELINE_PATTERNS; then + echo "ack: $f — $reason (acknowledged baseline, ADR-296)" >&2 + acknowledged=$((acknowledged + 1)) + continue + fi + echo "BLOCK: $f — $reason" >&2 + violations=$((violations + 1)) + done + + if [[ "$acknowledged" -gt 0 ]]; then + echo "note: $acknowledged file(s) acknowledged via CSI_POLICY_BASELINE (ADR-296)." >&2 + fi + + if [[ "$violations" -gt 0 ]]; then + echo "" >&2 + echo "FAIL: $violations CSI/person-data policy violation(s) (ADR-296)." >&2 + echo " Raw CSI is person data and must not be tracked in git. See" >&2 + echo " docs/adr/ADR-296-csi-data-incident-repo-controls.md." >&2 + echo " Synthetic test fixtures can be allowlisted in $ALLOW_FILE." >&2 + return 1 + fi + echo "OK: no CSI/person-data policy violations." >&2 + return 0 +} + +# --- input sources ----------------------------------------------------------- +emit_tracked() { + command -v git >/dev/null 2>&1 || { echo "ERROR: git not found" >&2; exit 2; } + git ls-files +} + +emit_staged() { + command -v git >/dev/null 2>&1 || { echo "ERROR: git not found" >&2; exit 2; } + git diff --cached --name-only --diff-filter=ACMR +} + +# --- self-tests -------------------------------------------------------------- +# Deterministic, offline. Builds synthetic fixtures in a temp dir and asserts +# the check flags a *.csi.jsonl / oversized JSONL and passes allowlisted ones. +self_test() { + local tmp rc pass=0 fail=0 + tmp="$(mktemp -d)" + + mkdir -p "$tmp/fixtures" + printf '{"csi":[1,2,3]}\n' > "$tmp/real.csi.jsonl" + printf '{"schema":1}\n' > "$tmp/real.csi.meta.json" + printf '{"note":"ok"}\n' > "$tmp/small.jsonl" + printf '{"synthetic":true}\n' > "$tmp/fixtures/synthetic.csi.jsonl" + # Oversized JSONL: 40 bytes, checked against a 10-byte threshold below. + printf '%0.sX' {1..40} > "$tmp/big.jsonl"; printf '\n' >> "$tmp/big.jsonl" + + assert() { # desc expected_rc actual_rc + if [[ "$2" -eq "$3" ]]; then echo " PASS: $1"; pass=$((pass+1)); + else echo " FAIL: $1 (expected rc=$2, got rc=$3)"; fail=$((fail+1)); fi + } + + echo "self-test: fixtures in $tmp" + + # 1. A raw *.csi.jsonl must be blocked. + rc=0; printf '%s\n' "$tmp/real.csi.jsonl" | scan_stdin >/dev/null 2>&1 || rc=$? + assert "blocks *.csi.jsonl" 1 "$rc" + + # 2. A *.csi.meta.json must be blocked. + rc=0; printf '%s\n' "$tmp/real.csi.meta.json" | scan_stdin >/dev/null 2>&1 || rc=$? + assert "blocks *.csi.meta.json" 1 "$rc" + + # 3. A small, ordinary .jsonl must pass. + rc=0; printf '%s\n' "$tmp/small.jsonl" | scan_stdin >/dev/null 2>&1 || rc=$? + assert "passes small ordinary .jsonl" 0 "$rc" + + # 4. An oversized .jsonl must be blocked (tiny threshold, deterministic). + rc=0; CSI_POLICY_MAX_JSONL_BYTES=10 bash "$0" --files-from - <<<"$tmp/big.jsonl" >/dev/null 2>&1 || rc=$? + assert "blocks oversized .jsonl" 1 "$rc" + + # 5. An allowlisted synthetic fixture must pass despite matching *.csi.jsonl. + rc=0; CSI_POLICY_ALLOW="$tmp/fixtures/*.csi.jsonl" bash "$0" --files-from - \ + <<<"$tmp/fixtures/synthetic.csi.jsonl" >/dev/null 2>&1 || rc=$? + assert "passes allowlisted synthetic fixture" 0 "$rc" + + # 6. A baseline-acknowledged CSI file must pass (job made green post-cleanup). + local bl="$tmp/baseline.txt"; printf '%s\n' "$tmp/real.csi.jsonl" > "$bl" + rc=0; CSI_POLICY_BASELINE="$bl" bash "$0" --files-from - \ + <<<"$tmp/real.csi.jsonl" >/dev/null 2>&1 || rc=$? + assert "passes baseline-acknowledged file" 0 "$rc" + + echo "self-test: $pass passed, $fail failed" + rm -rf "$tmp" + [[ "$fail" -eq 0 ]] +} + +# --- entrypoint -------------------------------------------------------------- +main() { + local mode="tracked" from="" + while [[ $# -gt 0 ]]; do + case "$1" in + --staged) mode="staged" ;; + --tracked) mode="tracked" ;; + --files-from) mode="files-from"; from="${2:-}"; shift ;; + --self-test) mode="self-test" ;; + -h|--help) grep '^#' "$0" | sed 's/^#\s\{0,1\}//'; exit 0 ;; + *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; + esac + shift + done + + case "$mode" in + self-test) self_test ;; + tracked) emit_tracked | scan_stdin ;; + staged) emit_staged | scan_stdin ;; + files-from) + if [[ "$from" == "-" || -z "$from" ]]; then + scan_stdin + else + [[ -f "$from" ]] || { echo "ERROR: --files-from file not found: $from" >&2; exit 2; } + scan_stdin < "$from" + fi + ;; + esac +} + +main "$@" diff --git a/scripts/csi-data-policy.allow b/scripts/csi-data-policy.allow new file mode 100644 index 00000000..2d5ce01a --- /dev/null +++ b/scripts/csi-data-policy.allow @@ -0,0 +1,14 @@ +# csi-data-policy.allow — ADR-296 synthetic-fixture allowlist. +# +# One shell glob per line (repo-relative paths). `#` starts a comment; blank +# lines are ignored. A tracked/staged file whose path matches any pattern here +# is exempt from the CSI data-policy check (scripts/csi-data-policy-check.sh). +# +# ONLY synthetic or expressly-consented minimal fixtures belong here (ADR-296). +# Never allowlist a real capture to silence the guard — real CSI is person data. +# The CSI_POLICY_ALLOW env var appends extra patterns (colon-separated) for +# one-off/local use. +# +# Conventional location for synthetic CSI test fixtures generated by tests: +scripts/tests/fixtures/csi-policy/*.csi.jsonl +scripts/tests/fixtures/csi-policy/*.csi.meta.json diff --git a/ui/pose-fusion/js/csi-simulator.js b/ui/pose-fusion/js/csi-simulator.js index f234d593..5c496a51 100644 --- a/ui/pose-fusion/js/csi-simulator.js +++ b/ui/pose-fusion/js/csi-simulator.js @@ -55,11 +55,15 @@ export class CsiSimulator { this.ws = new WebSocket(url); this.ws.binaryType = 'arraybuffer'; this.ws.onmessage = (evt) => this._handleLiveFrame(evt.data); - this.ws.onopen = () => { this.mode = 'live'; resolve(true); }; + // ADR-292 (issue #1557): a socket that merely *opened* is NOT live — + // synthetic demo data keeps flowing until a real frame is decoded. We + // stay in demo mode (watermarked) on open; `_handleLiveFrame` flips to + // live only once it has parsed a verified frame. + this.ws.onopen = () => { this.socketOpen = true; resolve(true); }; this.ws.onerror = () => resolve(false); - this.ws.onclose = () => { this.mode = 'demo'; }; + this.ws.onclose = () => { this.mode = 'demo'; this.verifiedFrame = false; this.socketOpen = false; }; // Timeout after 3s - setTimeout(() => { if (this.mode !== 'live') resolve(false); }, 3000); + setTimeout(() => { if (!this.socketOpen) resolve(false); }, 3000); } catch { resolve(false); } @@ -69,9 +73,12 @@ export class CsiSimulator { disconnect() { if (this.ws) { this.ws.close(); this.ws = null; } this.mode = 'demo'; + this.verifiedFrame = false; + this.socketOpen = false; } - get isLive() { return this.mode === 'live'; } + /** True only once a real frame has been decoded — not merely on socket open. */ + get isLive() { return this.mode === 'live' && this.verifiedFrame === true; } /** * Update person state from video detection (for correlated demo data). @@ -292,6 +299,15 @@ export class CsiSimulator { this._liveAmplitude[i] = Math.sqrt(real * real + imag * imag) / 2048; this._livePhase[i] = Math.atan2(imag, real); } + // ADR-292 (issue #1557): a real frame was decoded — only now is this live. + this._markVerifiedFrame(); + } + + /** ADR-292: promote from watermarked demo to live once a real frame lands. */ + _markVerifiedFrame() { + this.verifiedFrame = true; + this.mode = 'live'; + if (typeof this.onVerifiedFrame === 'function') this.onVerifiedFrame(); } _handleJsonFrame(msg) { @@ -311,6 +327,8 @@ export class CsiSimulator { for (let i = 0; i < n; i++) { this._liveAmplitude[i] = Math.abs(ampArr[i]) * scale; } + // ADR-292 (issue #1557): a real frame carrying amplitude was decoded. + this._markVerifiedFrame(); } // Phase from node (if available) diff --git a/ui/pose-fusion/js/main.js b/ui/pose-fusion/js/main.js index 7b7eb4af..d6860ac5 100644 --- a/ui/pose-fusion/js/main.js +++ b/ui/pose-fusion/js/main.js @@ -151,12 +151,21 @@ function init() { if (wsUrlInput) wsUrlInput.value = defaultWsUrl; // ADR-272: exchange the stored bearer for a single-use ?ticket= before the // upgrade — a browser cannot set an Authorization header on a WebSocket. - withWsTicket(defaultWsUrl).then(u => csiSimulator.connectLive(u)).then(ok => { - if (ok && connectWsBtn) { + // ADR-292 (issue #1557): opening the socket does NOT mean live — the + // simulator keeps producing watermarked SYNTHETIC data until a real CSI frame + // is decoded. Only the verified-frame callback promotes the label to LIVE. + csiSimulator.onVerifiedFrame = () => { + if (connectWsBtn) { connectWsBtn.textContent = '✓ Live ESP32'; connectWsBtn.classList.add('active'); - statusLabel.textContent = 'LIVE CSI'; - statusDot.classList.remove('offline'); + } + statusLabel.textContent = 'LIVE CSI'; + statusDot.classList.remove('offline'); + }; + withWsTicket(defaultWsUrl).then(u => csiSimulator.connectLive(u)).then(ok => { + if (ok) { + // Socket open, but no verified frame yet — stay honest. + statusLabel.textContent = 'SYNTHETIC'; } }); diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js index e07a09cd..9644572a 100644 --- a/ui/services/sensing.service.js +++ b/ui/services/sensing.service.js @@ -304,25 +304,41 @@ class SensingService { * hardware or simulation. Called once on WebSocket open. */ async _detectServerSource() { + // ADR-292 (issue #1526): an unreachable or unauthorized status endpoint is + // an *unknown* state — it must NOT collapse to "live". Prefer the canonical + // `source_state` the server now returns; on any error stay conservative + // (server-simulated) until a real frame's `source` field promotes us. try { const resp = await fetch('/api/v1/status'); if (resp.ok) { const json = await resp.json(); - this._applyServerSource(json.source); + this._applyServerSource(json.source, json.source_state); } else { - // Can't reach status endpoint — assume live until first frame tells us - this._setDataSource('live'); + this._setDataSource('server-simulated'); } } catch { - this._setDataSource('live'); + this._setDataSource('server-simulated'); } } /** - * Map a raw server source string to the UI data-source label. + * Map a raw server source string (and optional canonical ADR-292 + * `source_state`) to the UI data-source label. */ - _applyServerSource(rawSource) { + _applyServerSource(rawSource, sourceState) { this._serverSource = rawSource; + // ADR-292: only the verified/unverified live states may show "live"; any + // synthetic/stale/disconnected state must not. + if (sourceState) { + if (sourceState === 'live_verified' || sourceState === 'live_unverified') { + this._setDataSource('live'); + } else if (sourceState === 'synthetic') { + this._setDataSource('server-simulated'); + } else { + this._setDataSource('server-simulated'); + } + return; + } if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') { this._setDataSource('live'); } else if (rawSource === 'simulated' || rawSource === 'simulate') { diff --git a/v2/crates/wifi-densepose-sensing-server/SECURITY.md b/v2/crates/wifi-densepose-sensing-server/SECURITY.md new file mode 100644 index 00000000..1c07798b --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/SECURITY.md @@ -0,0 +1,61 @@ +# Security notes — wifi-densepose-sensing-server + +## UDP CSI data plane (ADR-293) + +The sensing server ingests CSI/radar frames over UDP from ESP32, MediaTek, +Qualcomm, and RTL8720F sensor nodes. A valid-shaped frame flips an +auto-detecting server into a live source state and influences +presence/vital/automation outputs. + +### Threat model + +Any host that can reach the UDP port can inject a valid-shaped frame. Prior to +ADR-293 the receiver bound `0.0.0.0` unconditionally, so on a routable +deployment the data plane was open to the entire LAN. + +The controls in ADR-293 (step one) are: + +- **`--udp-bind` (env `RUVIEW_UDP_BIND`), default `127.0.0.1`.** The receiver is + loopback-only by default and not reachable off-host. Binding to a routable + address (`0.0.0.0` or a LAN IP) is now an explicit operator choice, mirroring + the HTTP `--bind-addr` path. +- **`--udp-allow ` (env `RUVIEW_UDP_ALLOW`).** An optional source + allowlist. When set, frames from non-matching sources are dropped and counted; + loopback is always allowed. +- **`--udp-insecure-lan` (env `RUVIEW_UDP_INSECURE_LAN`).** A routable bind with + no allowlist is *refused at boot* unless this override is passed. The name + makes the residual risk legible. + +A startup security log line states the resolved bind scope and whether an +allowlist is active. + +### Residual risk — the allowlist is not authentication + +An IP/CIDR allowlist restricts *which addresses* may deliver frames. It does +**not** authenticate the sender. On a trusted LAN an attacker who can spoof a +source IP, or who controls an allowlisted host, can still inject frames. Treat a +routable bind as a soft control, not a security boundary. + +### Deferred to a follow-up ADR (step two) + +The following are **not** implemented yet and the data plane must not be +presented as authenticated: + +- per-device provisioned keys +- message authentication / AEAD (MAC over each frame) +- device identifiers +- monotonic sequence numbers +- a freshness window +- replay rejection + +Real-silicon validation of the LAN path remains required before any deployment +claim. + +### Safe deployment + +- Prefer the loopback default. Co-locate sensor decoding on the same host, or + place a trusted gateway in front. +- If you must bind routable, always pass `--udp-allow` scoped to the sensor + subnet, and segregate sensors on their own VLAN. +- Do not rely on the allowlist alone against an on-LAN adversary until step two + ships. diff --git a/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs b/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs index 7ad87de6..da2df633 100644 --- a/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs +++ b/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs @@ -136,6 +136,7 @@ fn bench_rate_limit(c: &mut Criterion) { RateLimiter::new, |mut rl| { black_box(rl.allow( + black_box("bench-node"), black_box(EntityKind::HeartRate), Duration::from_secs(0), &r, @@ -148,11 +149,12 @@ fn bench_rate_limit(c: &mut Criterion) { bench.iter_batched( || { let mut rl = RateLimiter::new(); - rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r); + rl.allow("bench-node", EntityKind::HeartRate, Duration::from_secs(0), &r); rl }, |mut rl| { black_box(rl.allow( + black_box("bench-node"), black_box(EntityKind::HeartRate), Duration::from_secs(1), &r, diff --git a/v2/crates/wifi-densepose-sensing-server/src/inference.rs b/v2/crates/wifi-densepose-sensing-server/src/inference.rs new file mode 100644 index 00000000..e1599985 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/inference.rs @@ -0,0 +1,300 @@ +//! ADR-294 — per-node inference vs. fused room inference. +//! +//! The multi-node path used to collapse two distinct concepts into one: +//! +//! 1. A **per-node** classification (what one node sees), and +//! 2. A **room aggregate** (the fused belief across all nodes). +//! +//! Because the top-level room classification was taken from the latest-arriving +//! node while other features were fused, disagreeing nodes made room presence +//! flip at packet frequency (issue #1555); and because the MQTT mapper fell back +//! to the room aggregate when a node lacked its own classification, every node +//! could publish the same value (issues #1540, #1554). +//! +//! This module separates the two types and provides a **pure, deterministic** +//! room fusion ([`fuse_room`]): identical input sets yield identical output, +//! independent of node ordering, and a node that has gone silent longer than +//! the stale window contributes nothing (it goes unavailable/stale rather than +//! holding a frozen value). Freshness is carried as milliseconds so the types +//! serialize cleanly and the logic needs no clock. + +use serde::{Deserialize, Serialize}; + +/// One node's own inference — never the room aggregate. `NodeInfo` carries this +/// so a node reports what *it* sees, with no silent fallback to the room value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NodeInference { + /// Coarse classification label this node reports (e.g. `"present_moving"`, + /// `"present_still"`, `"absent"`). Node-local; never overwritten by fusion. + pub classification: String, + /// Confidence in `classification`, clamped to `0.0..=1.0`. + pub confidence: f64, + /// Age of the backing frame in milliseconds. `None` when the node has never + /// reported. + #[serde(skip_serializing_if = "Option::is_none")] + pub age_ms: Option, +} + +impl NodeInference { + /// Construct a per-node inference, clamping `confidence` into range. + pub fn new(classification: impl Into, confidence: f64, age_ms: Option) -> Self { + Self { + classification: classification.into(), + confidence: clamp01(confidence), + age_ms, + } + } + + /// A node is stale when it has never reported, or its last frame is at least + /// `stale_after_ms` old. Stale nodes do not vote in [`fuse_room`]. + pub fn is_stale(&self, stale_after_ms: u64) -> bool { + match self.age_ms { + None => true, + Some(a) => a >= stale_after_ms, + } + } + + /// Freshness weight in `[0.0, 1.0]`: `1.0` when brand new, decaying linearly + /// to `0.0` at `stale_after_ms`, and exactly `0.0` once stale. Deterministic + /// and clock-free. + pub fn freshness_weight(&self, stale_after_ms: u64) -> f64 { + if stale_after_ms == 0 { + return 0.0; + } + match self.age_ms { + None => 0.0, + Some(a) if a >= stale_after_ms => 0.0, + Some(a) => { + let remaining = stale_after_ms.saturating_sub(a) as f64; + clamp01(remaining / stale_after_ms as f64) + } + } + } +} + +/// The fused room aggregate — computed explicitly from the set of per-node +/// inferences, never by overwriting any node's state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoomInference { + /// Winning classification across the contributing nodes. `"unavailable"` + /// when no fresh node contributed (never a frozen last value). + pub classification: String, + /// Freshness-weighted mean confidence of the nodes that voted for the + /// winning class, `0.0..=1.0`. + pub confidence: f64, + /// How many non-stale nodes contributed to this aggregate. + pub contributing_nodes: usize, +} + +impl RoomInference { + /// The state a room takes when no node currently backs it. Explicit so + /// callers never invent an online value out of nothing. + pub fn unavailable() -> Self { + Self { + classification: "unavailable".to_string(), + confidence: 0.0, + contributing_nodes: 0, + } + } + + /// Migration accessor for consumers of the pre-ADR-294 aggregate-only shape: + /// with a single node, that node's inference *is* the room aggregate. This + /// preserves single-node behavior (one node = one inference) exactly. + pub fn from_single_node(node: &NodeInference, stale_after_ms: u64) -> Self { + fuse_room(std::iter::once(node), stale_after_ms) + } +} + +/// Fuse per-node inferences into one room aggregate. +/// +/// Pure and deterministic (ADR-294): the result depends only on the *set* of +/// inputs, not on their order or on which arrived last. Each non-stale node +/// votes for its classification with its freshness weight; the class with the +/// greatest total freshness weight wins, ties broken by classification string +/// order (via the sorted `BTreeMap`) so the outcome is stable. Room confidence +/// is the freshness-weighted mean confidence of the winning class's voters. +/// +/// A node silent longer than `stale_after_ms` contributes nothing; if no node +/// contributes, the room is [`RoomInference::unavailable`] rather than a frozen +/// online value. +pub fn fuse_room<'a, I>(nodes: I, stale_after_ms: u64) -> RoomInference +where + I: IntoIterator, +{ + use std::collections::BTreeMap; + + // Per class: (sum of freshness weights, sum of freshness*confidence). + let mut tally: BTreeMap<&str, (f64, f64)> = BTreeMap::new(); + let mut contributing = 0usize; + + for n in nodes { + let w = n.freshness_weight(stale_after_ms); + if w <= 0.0 { + continue; + } + contributing += 1; + let entry = tally.entry(n.classification.as_str()).or_insert((0.0, 0.0)); + entry.0 += w; + entry.1 += w * n.confidence; + } + + if contributing == 0 { + return RoomInference::unavailable(); + } + + // BTreeMap iterates in sorted key order; `>` keeps the first (lowest-order) + // class on a tie, so the winner is order-independent and deterministic. + let mut best: Option<(&str, f64, f64)> = None; + for (&class, &(weight, conf_weight)) in &tally { + match best { + Some((_, bw, _)) if weight <= bw => {} + _ => best = Some((class, weight, conf_weight)), + } + } + + let (class, weight, conf_weight) = best.expect("contributing > 0 guarantees a winner"); + let confidence = if weight > 0.0 { clamp01(conf_weight / weight) } else { 0.0 }; + RoomInference { + classification: class.to_string(), + confidence, + contributing_nodes: contributing, + } +} + +fn clamp01(v: f64) -> f64 { + if v.is_nan() { + 0.0 + } else { + v.clamp(0.0, 1.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STALE: u64 = 10_000; // 10 s + + fn node(c: &str, conf: f64, age_ms: u64) -> NodeInference { + NodeInference::new(c, conf, Some(age_ms)) + } + + // ── NodeInference basics ───────────────────────────────────────────── + + #[test] + fn new_clamps_confidence() { + assert_eq!(NodeInference::new("absent", 1.7, Some(0)).confidence, 1.0); + assert_eq!(NodeInference::new("absent", -0.5, Some(0)).confidence, 0.0); + assert_eq!(NodeInference::new("absent", f64::NAN, Some(0)).confidence, 0.0); + } + + #[test] + fn never_reported_node_is_stale() { + let n = NodeInference::new("present_moving", 0.9, None); + assert!(n.is_stale(STALE)); + assert_eq!(n.freshness_weight(STALE), 0.0); + } + + #[test] + fn fresh_node_is_not_stale_and_weighted() { + let n = node("present_moving", 0.9, 0); + assert!(!n.is_stale(STALE)); + assert_eq!(n.freshness_weight(STALE), 1.0); + // Half the window ⇒ half weight. + assert!((node("x", 1.0, STALE / 2).freshness_weight(STALE) - 0.5).abs() < 1e-9); + } + + #[test] + fn node_at_window_boundary_is_stale() { + let n = node("present_still", 0.8, STALE); + assert!(n.is_stale(STALE)); + assert_eq!(n.freshness_weight(STALE), 0.0); + } + + // ── Single-node preservation + migration accessor ──────────────────── + + #[test] + fn single_node_room_equals_that_node() { + let n = node("present_moving", 0.8, 100); + let room = fuse_room(std::iter::once(&n), STALE); + assert_eq!(room.classification, "present_moving"); + assert!((room.confidence - 0.8).abs() < 1e-9, "confidence preserved"); + assert_eq!(room.contributing_nodes, 1); + } + + #[test] + fn migration_accessor_matches_single_node_fusion() { + let n = node("present_still", 0.6, 250); + assert_eq!(RoomInference::from_single_node(&n, STALE), fuse_room([&n], STALE)); + } + + // ── Deterministic, order-independent fusion ────────────────────────── + + #[test] + fn agreeing_nodes_fuse_to_shared_class() { + let a = node("present_moving", 0.7, 0); + let b = node("present_moving", 0.9, 0); + let room = fuse_room([&a, &b], STALE); + assert_eq!(room.classification, "present_moving"); + assert_eq!(room.contributing_nodes, 2); + // Freshness-weighted mean of 0.7 and 0.9 at equal weight = 0.8. + assert!((room.confidence - 0.8).abs() < 1e-9); + } + + #[test] + fn disagreeing_equal_freshness_is_deterministic_and_order_independent() { + let a = node("present_moving", 0.9, 0); + let b = node("absent", 0.9, 0); + let one = fuse_room([&a, &b], STALE); + let two = fuse_room([&b, &a], STALE); + // Same result regardless of input order — no last-writer-wins. + assert_eq!(one, two); + // Tie broken by classification string order: "absent" < "present_moving". + assert_eq!(one.classification, "absent"); + } + + #[test] + fn fresher_node_outvotes_stale_disagreement() { + // Fresh "present_moving" vs. an older-but-still-fresh "absent". + let fresh = node("present_moving", 0.9, 0); + let older = node("absent", 0.9, STALE - 1); // weight ~0 + let room = fuse_room([&older, &fresh], STALE); + assert_eq!(room.classification, "present_moving"); + } + + // ── Stale handling: unavailable, never frozen-online ───────────────── + + #[test] + fn all_stale_nodes_yield_unavailable() { + let a = node("present_moving", 0.9, STALE + 1); + let b = NodeInference::new("present_still", 0.8, None); + let room = fuse_room([&a, &b], STALE); + assert_eq!(room, RoomInference::unavailable()); + assert_eq!(room.classification, "unavailable"); + assert_eq!(room.contributing_nodes, 0); + assert_eq!(room.confidence, 0.0); + } + + #[test] + fn stale_node_excluded_but_fresh_node_still_counts() { + let stale_node = node("absent", 0.9, STALE + 5); + let fresh_node = node("present_moving", 0.7, 10); + let room = fuse_room([&stale_node, &fresh_node], STALE); + assert_eq!(room.classification, "present_moving"); + assert_eq!(room.contributing_nodes, 1, "stale node does not vote"); + } + + #[test] + fn empty_input_is_unavailable() { + let room = fuse_room(std::iter::empty(), STALE); + assert_eq!(room, RoomInference::unavailable()); + } + + #[test] + fn node_inference_round_trips_json() { + let n = node("present_moving", 0.75, 120); + let json = serde_json::to_string(&n).unwrap(); + let back: NodeInference = serde_json::from_str(&json).unwrap(); + assert_eq!(n, back); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 2bcb849d..f85a44f9 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -16,11 +16,16 @@ pub mod dataset; pub mod edge_registry; pub mod error_response; pub mod host_validation; +/// ADR-294: per-node vs. fused room inference, with deterministic fusion. +pub mod inference; pub mod introspection; pub mod matter; pub mod model_format; pub mod mqtt; pub mod path_safety; +/// ADR-292: canonical source-provenance state machine (synthetic can never +/// present as live). +pub mod provenance; pub mod semantic; /// ADR-262 P3: the live RuField surface — turns the governed sensing cycle into /// signed RuField `FieldEvent`s on the additive `/api/field` + `/ws/field` @@ -32,6 +37,8 @@ pub mod semconv; pub mod telemetry; #[allow(dead_code)] pub mod trainer; +/// ADR-293: UDP data-plane bind scope decision + source IP/CIDR allowlist. +pub mod udp_bind; pub mod vital_signs; /// ADR-270 Mist and NETGEAR telemetry providers. pub mod vendor_mist_netgear; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index c972bb1f..4deb8b3d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -38,6 +38,9 @@ use wifi_densepose_sensing_server::{ dataset, embedding, error_response, graph_transformer, rufield_surface, semconv, telemetry, trainer, }; +// ADR-292 / ADR-294: canonical provenance state + per-node/room inference. +use wifi_densepose_sensing_server::inference::{fuse_room, NodeInference, RoomInference}; +use wifi_densepose_sensing_server::provenance::SourceState; use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; use std::collections::{BTreeMap, HashMap, VecDeque}; @@ -97,6 +100,26 @@ struct Args { #[arg(long, default_value = "5005")] udp_port: u16, + /// UDP bind address for the CSI receiver (ADR-293). Defaults to + /// `127.0.0.1` (loopback only). Binding to a routable address (`0.0.0.0` + /// or a LAN IP) is an explicit operator choice and requires `--udp-allow` + /// or `--udp-insecure-lan`. + #[arg(long, default_value = "127.0.0.1", env = "RUVIEW_UDP_BIND")] + udp_bind: String, + + /// Source IP/CIDR allowlist for inbound UDP CSI frames (comma-separated, + /// repeatable; env `RUVIEW_UDP_ALLOW`). When set, frames from non-matching + /// sources are dropped and counted. Loopback is always allowed. + /// Example: `--udp-allow 192.168.1.0/24,10.0.0.5`. + #[arg(long = "udp-allow", value_name = "IP/CIDR", env = "RUVIEW_UDP_ALLOW")] + udp_allow: Vec, + + /// Accept a routable UDP bind with no source allowlist, explicitly opting + /// into the LAN-spoofing risk (ADR-293). The UDP data plane is NOT + /// authenticated; see the crate SECURITY.md. + #[arg(long, env = "RUVIEW_UDP_INSECURE_LAN")] + udp_insecure_lan: bool, + /// Path to UI static files (repo `ui/`; from `v2/` use `../ui` or rely on auto-detect) #[arg(long, default_value = "../ui")] ui_path: PathBuf, @@ -325,6 +348,12 @@ struct SensingUpdate { /// Per-node feature breakdown for multi-node deployments. #[serde(skip_serializing_if = "Option::is_none")] node_features: Option>, + /// ADR-294 — the explicitly-fused room aggregate over the current per-node + /// inferences (freshness-weighted vote). Deterministic and order-independent, + /// unlike the legacy last-writer `classification`; `"unavailable"` when no + /// fresh node backs the room rather than a frozen online value. + #[serde(skip_serializing_if = "Option::is_none")] + room_inference: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -340,6 +369,12 @@ struct NodeInfo { /// `NodeState::latest_sync` and the iter 18 fps EMA. #[serde(skip_serializing_if = "Option::is_none")] sync: Option, + /// ADR-294 — this node's *own* inference (classification + confidence + + /// freshness). Distinct from the room aggregate; a node reports what it + /// sees, with no silent fallback to the room value. `None` on synthetic / + /// placeholder frames that carry no per-node classification. + #[serde(skip_serializing_if = "Option::is_none")] + node_inference: Option, } /// ADR-110 iter 23 — per-node mesh-sync snapshot embedded in NodeInfo. @@ -416,6 +451,26 @@ fn classify_vitals(motion: bool, presence: bool, presence_score: f32) -> Classif } } +/// ADR-294 — the window a node may be silent before it stops contributing to +/// the fused room aggregate (its entities go stale/unavailable rather than +/// holding a frozen online value). Mirrors the 10 s active-node filter used to +/// assemble the nodes array. +const NODE_STALE_AFTER_MS: u64 = 10_000; + +/// Build a node's *own* [`NodeInference`] from its smoothed per-node state +/// (ADR-294). Uses the node's own `current_motion_level` — never the room +/// aggregate — with a confidence from its smoothed person score and freshness +/// from its last frame time. Pure given the state snapshot + `now`. +fn node_inference_for(n: &NodeState, now: std::time::Instant) -> NodeInference { + let age_ms = n + .last_frame_time + .map(|t| now.duration_since(t).as_millis() as u64); + let present = !matches!(n.current_motion_level.as_str(), "absent"); + let score = n.smoothed_person_score.clamp(0.0, 1.0); + let confidence = if present { score } else { 1.0 - score }; + NodeInference::new(n.current_motion_level.clone(), confidence, age_ms) +} + #[cfg(test)] mod classify_vitals_tests { use super::classify_vitals; @@ -1310,6 +1365,21 @@ impl AppStateInner { } self.source.clone() } + + /// ADR-292 — canonical provenance state for the current source. Derived + /// from the freshness-gated [`effective_source`](Self::effective_source) + /// label so ambiguity can never collapse to "live": a synthetic source is + /// always `Synthetic`, an `":offline"` label is `Disconnected`, and a fresh + /// hardware feed is `LiveUnverified` — never `LiveVerified`, since this path + /// carries no attestation. `effective_source()` has already applied the + /// freshness gate, so a non-offline live label means a fresh frame. + fn source_state(&self) -> SourceState { + SourceState::from_source_label( + &self.effective_source(), + Some(Duration::ZERO), + ESP32_OFFLINE_TIMEOUT, + ) + } } /// Number of frames retained in `frame_history` for temporal analysis. @@ -2801,6 +2871,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { amplitude: multi_ap_frame.amplitudes, subcarrier_count: obs_count, sync: None, // multi-BSSID scan path — no mesh peer + node_inference: None, // single aggregate frame; no per-node split }], features, classification, @@ -2827,6 +2898,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { None }, node_features: None, + room_inference: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). @@ -2961,6 +3033,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { amplitude: vec![signal_pct], subcarrier_count: 1, sync: None, // synthetic-RSSI fallback path — no mesh peer + node_inference: None, // synthetic fallback; no per-node inference }], features, classification, @@ -2987,6 +3060,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { None }, node_features: None, + room_inference: None, }; let raw_persons = derive_pose_from_sensing(&update); @@ -4599,6 +4673,9 @@ async fn health_ready(State(state): State) -> Json Html { // ── UDP receiver task ──────────────────────────────────────────────────────── -async fn udp_receiver_task(state: SharedState, udp_port: u16) { - let addr = format!("0.0.0.0:{udp_port}"); +async fn udp_receiver_task( + state: SharedState, + bind_ip: std::net::IpAddr, + udp_port: u16, + allowlist: std::sync::Arc, +) { + let addr = format!("{bind_ip}:{udp_port}"); let socket = match UdpSocket::bind(&addr).await { Ok(s) => { info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm CSI, and RTL8720F radar frames"); @@ -5719,6 +5801,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { + // ADR-293: drop frames from sources outside the allowlist + // (loopback is always admitted). Counted for observability. + if !allowlist.admit(src.ip()) { + debug!( + "Dropped UDP frame from disallowed source {src} (allowlist active; total dropped={})", + allowlist.dropped() + ); + continue; + } if len > 0 && buf[0] == b'{' { match serde_json::from_slice::(&buf[..len]) .map_err(|error| error.to_string()) @@ -5950,9 +6041,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // Vitals-only path; still expose the sync snapshot // if the node also speaks ESP-NOW. sync: n.sync_snapshot(), + // ADR-294 — each node carries its own inference. + node_inference: Some(node_inference_for(n, now)), }) .collect(); + // ADR-294 — explicit, deterministic room aggregate over the + // per-node inferences (freshness-weighted vote). Not the + // latest-writer classification (issue #1555). + let room_inference = fuse_room( + active_nodes.iter().filter_map(|ni| ni.node_inference.as_ref()), + NODE_STALE_AFTER_MS, + ); + let features = FeatureInfo { mean_rssi: vitals.rssi as f64, variance: vitals.motion_energy as f64, @@ -6041,6 +6142,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), + room_inference: Some(room_inference), }; let raw_persons = derive_pose_from_sensing(&update); @@ -6439,9 +6541,18 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { }, // ADR-110 iter 23 / iter 30 — single source of truth. sync: n.sync_snapshot(), + // ADR-294 — each node carries its own inference. + node_inference: Some(node_inference_for(n, now)), }) .collect(); + // ADR-294 — explicit deterministic room aggregate over the + // per-node inferences (not last-writer; issue #1555). + let room_inference = fuse_room( + active_nodes.iter().filter_map(|ni| ni.node_inference.as_ref()), + NODE_STALE_AFTER_MS, + ); + let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, @@ -6478,6 +6589,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), + room_inference: Some(room_inference), }; let raw_persons = derive_pose_from_sensing(&update); @@ -6699,6 +6811,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { amplitude: frame_amplitudes, subcarrier_count: frame_n_sub as usize, sync: None, // simulated frame path — no mesh peer + node_inference: None, // simulated frame; source is synthetic }], features: features.clone(), classification, @@ -6735,6 +6848,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { None }, node_features: None, + room_inference: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). @@ -7720,7 +7834,7 @@ async fn main() { info!("WiFi-DensePose Sensing Server (Rust + Axum + RuVector)"); info!(" HTTP: http://localhost:{}", args.http_port); info!(" WebSocket: ws://localhost:{}/ws/sensing", args.ws_port); - info!(" UDP: 0.0.0.0:{} (ESP32 CSI)", args.udp_port); + info!(" UDP: {}:{} (ESP32 CSI)", args.udp_bind, args.udp_port); info!(" UI path: {}", args.ui_path.display()); info!(" Source: {}", args.source); @@ -8098,7 +8212,48 @@ async fn main() { // promoted — see `simulated_data_task`). Explicit `--source simulated` has // `bind_udp = false`, so it serves simulated data only, with no live binding. if plan.bind_udp { - tokio::spawn(udp_receiver_task(state.clone(), args.udp_port)); + // ADR-293: resolve the UDP bind scope + source allowlist and fail closed + // on an unguarded routable bind, mirroring the OAuth boot refusal below. + use wifi_densepose_sensing_server::udp_bind; + let udp_bind_ip: std::net::IpAddr = match args.udp_bind.parse() { + Ok(ip) => ip, + Err(_) => { + error!( + "Invalid --udp-bind '{}' (use 127.0.0.1 or 0.0.0.0)", + args.udp_bind + ); + std::process::exit(1); + } + }; + let udp_allowlist = match udp_bind::UdpSourceAllowlist::parse(args.udp_allow.iter()) { + Ok(a) => std::sync::Arc::new(a), + Err(e) => { + error!("Invalid --udp-allow: {e}"); + std::process::exit(1); + } + }; + match udp_bind::decide_udp_bind( + udp_bind_ip, + udp_allowlist.is_active(), + args.udp_insecure_lan, + ) { + Ok(decision) => { + info!( + "UDP data plane security: {}", + udp_bind::startup_summary(decision, udp_bind_ip, args.udp_port, &udp_allowlist) + ); + } + Err(e) => { + error!("{e}"); + std::process::exit(1); + } + } + tokio::spawn(udp_receiver_task( + state.clone(), + udp_bind_ip, + args.udp_port, + udp_allowlist, + )); tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms)); } if plan.run_wifi { @@ -8526,6 +8681,7 @@ mod node_sync_snapshot_serialization_tests { amplitude: vec![], subcarrier_count: 0, sync, + node_inference: None, } } @@ -9247,6 +9403,7 @@ mod observatory_persons_field_position_tests { persons: None, estimated_persons: Some(1), node_features: None, + room_inference: None, } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs b/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs index 6c3e3285..f696299f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs @@ -332,15 +332,17 @@ async fn publish_snapshot( } } - // Numeric rate-limited entities. + // Numeric rate-limited entities. Rate limiting is per (node, entity) + // (ADR-294, issue #1541) so nodes never starve one another. + let node = snap.node_id.as_str(); for (entity, allowed) in [ - (EntityKind::PersonCount, rl.allow(EntityKind::PersonCount, elapsed, &cfg.rates)), - (EntityKind::HeartRate, !cfg.privacy_mode && rl.allow(EntityKind::HeartRate, elapsed, &cfg.rates)), - (EntityKind::BreathingRate, !cfg.privacy_mode && rl.allow(EntityKind::BreathingRate, elapsed, &cfg.rates)), - (EntityKind::MotionLevel, rl.allow(EntityKind::MotionLevel, elapsed, &cfg.rates)), - (EntityKind::MotionEnergy, rl.allow(EntityKind::MotionEnergy, elapsed, &cfg.rates)), - (EntityKind::PresenceScore, rl.allow(EntityKind::PresenceScore, elapsed, &cfg.rates)), - (EntityKind::Rssi, rl.allow(EntityKind::Rssi, elapsed, &cfg.rates)), + (EntityKind::PersonCount, rl.allow(node, EntityKind::PersonCount, elapsed, &cfg.rates)), + (EntityKind::HeartRate, !cfg.privacy_mode && rl.allow(node, EntityKind::HeartRate, elapsed, &cfg.rates)), + (EntityKind::BreathingRate, !cfg.privacy_mode && rl.allow(node, EntityKind::BreathingRate, elapsed, &cfg.rates)), + (EntityKind::MotionLevel, rl.allow(node, EntityKind::MotionLevel, elapsed, &cfg.rates)), + (EntityKind::MotionEnergy, rl.allow(node, EntityKind::MotionEnergy, elapsed, &cfg.rates)), + (EntityKind::PresenceScore, rl.allow(node, EntityKind::PresenceScore, elapsed, &cfg.rates)), + (EntityKind::Rssi, rl.allow(node, EntityKind::Rssi, elapsed, &cfg.rates)), ] { if !allowed { continue; diff --git a/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs b/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs index fd3a02a7..8a991e67 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs @@ -58,24 +58,38 @@ impl StateMessage { } } -/// Sample-rate-limit decisions, per entity. Tracks the last-emitted -/// instant per entity and gates further emissions accordingly. Time is -/// supplied by the caller so the limiter is testable without a clock. +/// Sample-rate-limit decisions, per `(node, entity)`. Tracks the +/// last-emitted instant for each entity *on each node* and gates further +/// emissions accordingly. Time is supplied by the caller so the limiter is +/// testable without a clock. +/// +/// ADR-294 (issue #1541): the key is `(NodeId, EntityKind)`, not `EntityKind` +/// alone. With an entity-only key one node consumed the numeric publish slot +/// and every other node was suppressed until the interval expired — while +/// availability still reported them online. Keying by node keeps each node's +/// per-entity budget independent so nodes no longer starve one another. #[derive(Debug, Default)] pub struct RateLimiter { - last: HashMap, + last: HashMap<(String, EntityKind), Duration>, } impl RateLimiter { - /// Build a fresh limiter with no per-entity history. + /// Build a fresh limiter with no per-`(node, entity)` history. pub fn new() -> Self { Self { last: HashMap::new() } } - /// Decide whether a sample for `entity` is allowed to publish at - /// `now`, given the configured `rates`. Returns true to publish - /// (and updates last-emitted state); false to drop. - pub fn allow(&mut self, entity: EntityKind, now: Duration, rates: &PublishRates) -> bool { + /// Decide whether a sample for `entity` on node `node_id` is allowed to + /// publish at `now`, given the configured `rates`. Returns true to publish + /// (and updates last-emitted state); false to drop. Each node's budget for + /// an entity is independent of every other node's (ADR-294). + pub fn allow( + &mut self, + node_id: &str, + entity: EntityKind, + now: Duration, + rates: &PublishRates, + ) -> bool { let min_gap = match rate_hz_for(entity, rates) { // Zero / negative Hz → emit only on change (caller path). // Here we treat it as "always allow" because the caller is @@ -83,13 +97,15 @@ impl RateLimiter { rate if rate <= 0.0 => return true, rate => Duration::from_secs_f64(1.0 / rate), }; - match self.last.get(&entity) { - Some(&prev) if now.saturating_sub(prev) < min_gap => false, - _ => { - self.last.insert(entity, now); - true + // Borrow the key without allocating on the hot lookup path; only + // allocate the owned `String` when inserting a new node/entity slot. + if let Some(&prev) = self.last.get(&(node_id.to_string(), entity)) { + if now.saturating_sub(prev) < min_gap { + return false; } } + self.last.insert((node_id.to_string(), entity), now); + true } /// Reset all per-entity history. Used after a reconnect so the first @@ -352,10 +368,12 @@ mod tests { // ─── Rate limiter ──────────────────────────────────────────────── + const NODE: &str = "node-a"; + #[test] fn rate_limiter_first_sample_always_passes() { let mut rl = RateLimiter::new(); - assert!(rl.allow(EntityKind::HeartRate, Duration::ZERO, &rates())); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::ZERO, &rates())); } #[test] @@ -363,27 +381,27 @@ mod tests { let mut rl = RateLimiter::new(); let r = rates(); // 0.2 Hz → 5 s gap. - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); - assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r)); - assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(4), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(!rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(1), &r)); + assert!(!rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(4), &r)); } #[test] fn rate_limiter_allows_after_gap() { let mut rl = RateLimiter::new(); let r = rates(); - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); // 5 s gap met → allow. - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(5), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(5), &r)); } #[test] fn rate_limiter_per_entity_independent() { let mut rl = RateLimiter::new(); let r = rates(); - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); // Different entity, same instant → independent budget. - assert!(rl.allow(EntityKind::MotionLevel, Duration::from_secs(0), &r)); + assert!(rl.allow(NODE, EntityKind::MotionLevel, Duration::from_secs(0), &r)); } #[test] @@ -392,7 +410,7 @@ mod tests { let r = rates(); // Presence is change-only → rate=0 → unlimited; caller does change detection. for s in 0..3 { - assert!(rl.allow(EntityKind::Presence, Duration::from_secs(s), &r)); + assert!(rl.allow(NODE, EntityKind::Presence, Duration::from_secs(s), &r)); } } @@ -400,11 +418,27 @@ mod tests { fn rate_limiter_reset_re_enables_immediate_publish() { let mut rl = RateLimiter::new(); let r = rates(); - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); - assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(!rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(1), &r)); rl.reset(); // Post-reset: first sample passes. - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(1), &r)); + } + + #[test] + fn rate_limiter_nodes_do_not_starve_each_other() { + // ADR-294 (issue #1541): with an entity-only key, node-a consuming the + // slot suppressed node-b. Keyed by (node, entity), both publish. + let mut rl = RateLimiter::new(); + let r = rates(); + assert!(rl.allow("node-a", EntityKind::PersonCount, Duration::from_secs(0), &r)); + assert!( + rl.allow("node-b", EntityKind::PersonCount, Duration::from_secs(0), &r), + "second node must not be starved by the first" + ); + // Each node still rate-limits itself. + assert!(!rl.allow("node-a", EntityKind::PersonCount, Duration::from_millis(100), &r)); + assert!(!rl.allow("node-b", EntityKind::PersonCount, Duration::from_millis(100), &r)); } // ─── Boolean / binary_sensor encoder ───────────────────────────── diff --git a/v2/crates/wifi-densepose-sensing-server/src/provenance.rs b/v2/crates/wifi-densepose-sensing-server/src/provenance.rs new file mode 100644 index 00000000..ba0f0257 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/provenance.rs @@ -0,0 +1,295 @@ +//! ADR-292 — canonical source-provenance state machine. +//! +//! Source state used to be a boolean (`live` vs. not), so any ambiguous +//! condition — an unauthenticated status-endpoint error, a simulator that has +//! not yet produced a frame — collapsed to "live". Two release-path defects +//! (issues #1526, #1557) both trace to that collapse. +//! +//! This module defines one canonical, mutually-exclusive [`SourceState`] and a +//! **pure** transition function of `(last_frame_age, auth_status, source_kind)`. +//! It touches no clock and no socket, so every rule below is unit-testable: +//! +//! - `Unknown` is not a state. An ambiguous condition resolves to +//! `LiveUnverified`, `Stale`, or `Disconnected` — never `LiveVerified`. +//! - A status-endpoint error resolves to `Disconnected`/`LiveUnverified`, +//! never live-verified (issue #1526). +//! - A `Synthetic` source can never transition to any `Live*` state without +//! being reconstructed as a live source *and* presenting a verified frame +//! (issue #1557). +//! - `Synthetic` is watermarked in every export ([`SourceState::export_watermark`]). + +use std::time::Duration; + +/// Where a source's data originates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + /// Generated data — the simulator, or replay of synthetic fixtures. + Synthetic, + /// A real external capture source (hardware or a network vendor feed). + Live, +} + +/// Result of authenticating / attesting the source, e.g. from a status-endpoint +/// probe. Deliberately distinguishes "not confirmed yet" from "the probe itself +/// errored" so an authorization failure can never masquerade as verified. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthStatus { + /// Source authenticated and attested. + Verified, + /// Reachable, but provenance is not yet confirmed. + Unverified, + /// The auth / status probe itself failed (401/403, unreachable, malformed). + Error, + /// No information available yet. + Unknown, +} + +/// Canonical, mutually-exclusive source state (ADR-292). There is intentionally +/// no `Unknown` / `Live` boolean — every ambiguous input maps to one of these. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceState { + /// Generated data. Always watermarked; never presented as real. + Synthetic, + /// Fresh frames from an authenticated, attested source. + LiveVerified, + /// Fresh frames arriving, but provenance not yet confirmed. + LiveUnverified, + /// Last frame is older than the freshness window. + Stale, + /// No source / no frame. + Disconnected, +} + +impl SourceState { + /// Pure transition. Resolves the canonical state from the only three inputs + /// that may influence it — no clock, no socket. + /// + /// * `last_frame_age` — age of the most recent frame; `None` when no frame + /// has ever been observed. + /// * `auth` — outcome of the most recent auth/attestation probe. + /// * `kind` — whether the source is synthetic or a live capture. + /// * `freshness_window` — maximum age a frame may have and still count as + /// fresh. + pub fn resolve( + last_frame_age: Option, + auth: AuthStatus, + kind: SourceKind, + freshness_window: Duration, + ) -> SourceState { + // A synthetic source is *always* synthetic. It can only become live by + // being reconstructed as `SourceKind::Live` presenting a verified frame, + // never by a transition here (ADR-292, issue #1557). + if kind == SourceKind::Synthetic { + return SourceState::Synthetic; + } + + match last_frame_age { + // No frame ever ⇒ no source. An auth error with no frame is + // Disconnected, never live (issue #1526). + None => SourceState::Disconnected, + // A frame exists but is older than the freshness window. + Some(age) if age > freshness_window => SourceState::Stale, + // A fresh frame is present. Only an explicitly `Verified` auth + // status may promote to `LiveVerified`; `Unknown` / `Error` / + // `Unverified` can never be verified-live (issue #1526). + Some(_) => match auth { + AuthStatus::Verified => SourceState::LiveVerified, + AuthStatus::Unverified | AuthStatus::Error | AuthStatus::Unknown => { + SourceState::LiveUnverified + } + }, + } + } + + /// Compatibility accessor for callers migrating off a boolean source flag. + /// True only for the two states that represent real, arriving frames. + pub fn is_live(self) -> bool { + matches!(self, SourceState::LiveVerified | SourceState::LiveUnverified) + } + + /// True when this state must be watermarked as synthetic in every view and + /// export (ADR-292). + pub fn is_synthetic(self) -> bool { + matches!(self, SourceState::Synthetic) + } + + /// Short, stable, machine-readable label for wire/JSON surfaces. + pub fn as_str(self) -> &'static str { + match self { + SourceState::Synthetic => "synthetic", + SourceState::LiveVerified => "live_verified", + SourceState::LiveUnverified => "live_unverified", + SourceState::Stale => "stale", + SourceState::Disconnected => "disconnected", + } + } + + /// Watermark that must accompany a synthetic export so generated data can + /// never be mistaken for a real capture. `None` for non-synthetic states. + pub fn export_watermark(self) -> Option<&'static str> { + if self.is_synthetic() { + Some("SYNTHETIC") + } else { + None + } + } + + /// Adapter that maps this server's free-form `source` label plus freshness + /// into a canonical [`SourceState`], so the existing string surface can + /// report the honest enum instead of a boolean collapse. + /// + /// Hardware / vendor feeds resolve to `LiveUnverified` — frames arrive but + /// provenance is not attested on this path — never `LiveVerified`. An + /// `":offline"`-suffixed label (issue #1004) resolves to `Disconnected`. + pub fn from_source_label( + source: &str, + last_frame_age: Option, + freshness_window: Duration, + ) -> SourceState { + if source.ends_with(":offline") { + return SourceState::Disconnected; + } + let kind = match source { + "simulated" | "simulate" | "synthetic" | "test" => SourceKind::Synthetic, + _ => SourceKind::Live, + }; + // This path carries no attestation, so the best a live feed earns is + // `Unverified` — the honest label. `resolve` still short-circuits a + // synthetic kind to `Synthetic` regardless of frame age. + SourceState::resolve(last_frame_age, AuthStatus::Unverified, kind, freshness_window) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const WINDOW: Duration = Duration::from_secs(5); + fn fresh() -> Option { + Some(Duration::from_millis(100)) + } + fn old() -> Option { + Some(WINDOW + Duration::from_secs(1)) + } + + // ── Core honesty rules ─────────────────────────────────────────────── + + #[test] + fn verified_fresh_live_source_is_live_verified() { + assert_eq!( + SourceState::resolve(fresh(), AuthStatus::Verified, SourceKind::Live, WINDOW), + SourceState::LiveVerified + ); + } + + #[test] + fn auth_error_never_resolves_to_live_verified() { + // Fresh frame but the status probe errored ⇒ unverified, not verified. + assert_eq!( + SourceState::resolve(fresh(), AuthStatus::Error, SourceKind::Live, WINDOW), + SourceState::LiveUnverified + ); + // No frame and an auth error ⇒ Disconnected, never live (issue #1526). + assert_eq!( + SourceState::resolve(None, AuthStatus::Error, SourceKind::Live, WINDOW), + SourceState::Disconnected + ); + } + + #[test] + fn unknown_never_resolves_to_live_verified() { + let s = SourceState::resolve(fresh(), AuthStatus::Unknown, SourceKind::Live, WINDOW); + assert_eq!(s, SourceState::LiveUnverified); + assert_ne!(s, SourceState::LiveVerified); + } + + #[test] + fn synthetic_never_becomes_live_even_with_verified_fresh_frame() { + // Issue #1557: the simulator constructs Synthetic and cannot transition + // to any Live* state without being reconstructed as a live source. + assert_eq!( + SourceState::resolve(fresh(), AuthStatus::Verified, SourceKind::Synthetic, WINDOW), + SourceState::Synthetic + ); + assert!(!SourceState::resolve(fresh(), AuthStatus::Verified, SourceKind::Synthetic, WINDOW) + .is_live()); + } + + #[test] + fn freshness_expiry_is_stale() { + assert_eq!( + SourceState::resolve(old(), AuthStatus::Verified, SourceKind::Live, WINDOW), + SourceState::Stale + ); + } + + #[test] + fn no_frame_is_disconnected() { + assert_eq!( + SourceState::resolve(None, AuthStatus::Verified, SourceKind::Live, WINDOW), + SourceState::Disconnected + ); + } + + // ── Watermark / compat accessors ───────────────────────────────────── + + #[test] + fn synthetic_export_is_watermarked() { + assert_eq!(SourceState::Synthetic.export_watermark(), Some("SYNTHETIC")); + assert!(SourceState::Synthetic.is_synthetic()); + assert_eq!(SourceState::LiveVerified.export_watermark(), None); + assert_eq!(SourceState::Disconnected.export_watermark(), None); + } + + #[test] + fn is_live_only_for_live_states() { + assert!(SourceState::LiveVerified.is_live()); + assert!(SourceState::LiveUnverified.is_live()); + assert!(!SourceState::Synthetic.is_live()); + assert!(!SourceState::Stale.is_live()); + assert!(!SourceState::Disconnected.is_live()); + } + + // ── Label adapter ──────────────────────────────────────────────────── + + #[test] + fn simulated_label_is_synthetic_regardless_of_frames() { + // Even with fresh frames arriving, a simulated source stays synthetic. + let s = SourceState::from_source_label("simulated", fresh(), WINDOW); + assert_eq!(s, SourceState::Synthetic); + assert!(!s.is_live()); + assert_eq!(SourceState::from_source_label("simulate", None, WINDOW), SourceState::Synthetic); + } + + #[test] + fn hardware_label_is_live_unverified_never_verified() { + let s = SourceState::from_source_label("esp32", fresh(), WINDOW); + assert_eq!(s, SourceState::LiveUnverified); + assert_ne!(s, SourceState::LiveVerified); + } + + #[test] + fn offline_label_is_disconnected() { + assert_eq!( + SourceState::from_source_label("esp32:offline", fresh(), WINDOW), + SourceState::Disconnected + ); + } + + #[test] + fn stale_hardware_label_is_stale() { + assert_eq!( + SourceState::from_source_label("wifi:home", old(), WINDOW), + SourceState::Stale + ); + } + + #[test] + fn as_str_is_stable() { + assert_eq!(SourceState::Synthetic.as_str(), "synthetic"); + assert_eq!(SourceState::LiveVerified.as_str(), "live_verified"); + assert_eq!(SourceState::LiveUnverified.as_str(), "live_unverified"); + assert_eq!(SourceState::Stale.as_str(), "stale"); + assert_eq!(SourceState::Disconnected.as_str(), "disconnected"); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/udp_bind.rs b/v2/crates/wifi-densepose-sensing-server/src/udp_bind.rs new file mode 100644 index 00000000..59072565 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/udp_bind.rs @@ -0,0 +1,404 @@ +//! ADR-293: sensor data-plane bind hardening — UDP bind scope + source allowlist. +//! +//! The CSI UDP receiver historically bound `0.0.0.0` unconditionally, with no +//! equivalent of the HTTP `--bind-addr` flag, no source allowlist, and no +//! message authentication. Any host that could reach the port could inject a +//! valid-shaped frame and influence presence/vital/automation outputs. +//! +//! This module supplies the *step one* controls: a pure, socket-free bind +//! decision (loopback default, fail-closed on an unguarded routable bind, +//! mirroring the HTTP/OAuth refusal pattern) and a dependency-free source +//! IP/CIDR allowlist with a drop counter. +//! +//! # Threat model & residual risk +//! +//! An IP/CIDR allowlist restricts *which addresses* may deliver frames; it does +//! **not** authenticate the sender. On a trusted LAN an attacker who can spoof a +//! source address, or who controls an allowlisted host, can still inject frames. +//! The safe default is therefore loopback-only. Binding to a routable address +//! is an explicit operator choice and requires either `--udp-allow` (restrict +//! sources) or `--udp-insecure-lan` (accept the residual risk explicitly). +//! +//! **Deferred to a follow-up ADR (step two):** per-device provisioned keys, +//! MAC/AEAD, device identifiers, monotonic sequence numbers, a freshness +//! window, and replay rejection. Until those land the UDP data plane is NOT +//! authenticated. See the crate `SECURITY.md`. + +use std::net::IpAddr; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Upper bound on parsed allowlist entries. The list comes from operator CLI +/// input, but we cap it anyway to keep allocation bounded at the boundary. +const MAX_ENTRIES: usize = 4096; + +/// Outcome of evaluating the requested UDP bind scope against the allowlist and +/// override flags. Pure and socket-free so the decision can be unit-tested +/// without binding a real socket. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UdpBindDecision { + /// Bind is loopback-only (`127.0.0.1` / `::1`); not reachable off-host. + Loopback, + /// Bind is routable and a source allowlist is enforced. + RoutableAllowlisted, + /// Bind is routable with no allowlist, explicitly accepted via + /// `--udp-insecure-lan`. + RoutableInsecure, +} + +/// Decide whether the requested UDP bind is permitted. +/// +/// Fail-closed, mirroring the HTTP/OAuth boot refusals: a routable bind with no +/// source allowlist is rejected unless the operator passes the explicit +/// `--udp-insecure-lan` override. Loopback is always fine. +pub fn decide_udp_bind( + bind: IpAddr, + has_allowlist: bool, + insecure_lan: bool, +) -> Result { + if bind.is_loopback() { + return Ok(UdpBindDecision::Loopback); + } + if has_allowlist { + return Ok(UdpBindDecision::RoutableAllowlisted); + } + if insecure_lan { + return Ok(UdpBindDecision::RoutableInsecure); + } + Err(format!( + "Refusing to bind the UDP CSI receiver to routable address {bind} with no \ + source allowlist. Pass --udp-allow to restrict sources, or \ + --udp-insecure-lan to accept the LAN-spoofing risk explicitly. The default \ + is loopback (127.0.0.1); see the crate SECURITY.md (ADR-293)." + )) +} + +/// One-line startup security summary describing the resolved bind scope and +/// allowlist state, for the boot log. +pub fn startup_summary( + decision: UdpBindDecision, + bind: IpAddr, + port: u16, + allowlist: &UdpSourceAllowlist, +) -> String { + let scope = match decision { + UdpBindDecision::Loopback => "loopback-only (not reachable off-host)", + UdpBindDecision::RoutableAllowlisted => "ROUTABLE, source allowlist enforced", + UdpBindDecision::RoutableInsecure => { + "ROUTABLE, NO allowlist (--udp-insecure-lan; data plane is UNAUTHENTICATED)" + } + }; + if allowlist.is_active() { + format!( + "bind {bind}:{port} — {scope}; allowlist active ({} entr{}), loopback always allowed", + allowlist.len(), + if allowlist.len() == 1 { "y" } else { "ies" } + ) + } else { + format!("bind {bind}:{port} — {scope}; no source allowlist") + } +} + +/// A single parsed IP or CIDR allowlist entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CidrEntry { + network: IpAddr, + prefix_len: u8, +} + +impl CidrEntry { + /// Whether `ip` falls inside this network. Family mismatches never match. + fn contains(&self, ip: IpAddr) -> bool { + match (self.network, ip) { + (IpAddr::V4(net), IpAddr::V4(addr)) => { + let mask = v4_mask(self.prefix_len); + (u32::from(net) & mask) == (u32::from(addr) & mask) + } + (IpAddr::V6(net), IpAddr::V6(addr)) => { + let mask = v6_mask(self.prefix_len); + (u128::from(net) & mask) == (u128::from(addr) & mask) + } + _ => false, + } + } +} + +fn v4_mask(prefix: u8) -> u32 { + match prefix { + 0 => 0, + p if p >= 32 => u32::MAX, + p => u32::MAX << (32 - p), + } +} + +fn v6_mask(prefix: u8) -> u128 { + match prefix { + 0 => 0, + p if p >= 128 => u128::MAX, + p => u128::MAX << (128 - p), + } +} + +/// Parse one `IP` or `IP/prefix` entry. Never panics on malformed input. +fn parse_entry(raw: &str) -> Result { + let s = raw.trim(); + if let Some((ip_str, pfx_str)) = s.split_once('/') { + let ip: IpAddr = ip_str + .trim() + .parse() + .map_err(|_| format!("invalid IP address in allowlist entry '{s}'"))?; + let max = if ip.is_ipv4() { 32u8 } else { 128u8 }; + let pfx: u8 = pfx_str + .trim() + .parse() + .map_err(|_| format!("invalid prefix length in allowlist entry '{s}'"))?; + if pfx > max { + return Err(format!( + "prefix /{pfx} exceeds maximum /{max} for {ip} in allowlist entry '{s}'" + )); + } + Ok(CidrEntry { + network: ip, + prefix_len: pfx, + }) + } else { + let ip: IpAddr = s + .parse() + .map_err(|_| format!("invalid IP address in allowlist entry '{s}'"))?; + let prefix_len = if ip.is_ipv4() { 32 } else { 128 }; + Ok(CidrEntry { + network: ip, + prefix_len, + }) + } +} + +/// Source IP/CIDR allowlist for inbound UDP CSI frames. +/// +/// When no entries are configured the allowlist is *inactive* and accepts every +/// source (the bind decision, not this filter, gates an unguarded routable +/// bind). When entries are present, only loopback and matching sources are +/// allowed; everything else is dropped and counted. +#[derive(Debug, Default)] +pub struct UdpSourceAllowlist { + entries: Vec, + dropped: AtomicU64, +} + +impl UdpSourceAllowlist { + /// Parse an allowlist from CLI/env specs. Each item may itself be a + /// comma-separated list; whitespace and empty items are ignored. Returns an + /// error on the first malformed entry rather than silently dropping it. + pub fn parse(specs: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut entries: Vec = Vec::new(); + for spec in specs { + for part in spec.as_ref().split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if entries.len() >= MAX_ENTRIES { + return Err(format!( + "too many allowlist entries (limit {MAX_ENTRIES})" + )); + } + entries.push(parse_entry(part)?); + } + } + Ok(Self { + entries, + dropped: AtomicU64::new(0), + }) + } + + /// Whether any allowlist entries are configured (i.e. filtering is on). + pub fn is_active(&self) -> bool { + !self.entries.is_empty() + } + + /// Number of configured entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the allowlist has no configured entries. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Whether `src` is permitted. Loopback is always allowed; an inactive + /// allowlist accepts everything. Does not touch the drop counter. + pub fn is_allowed(&self, src: IpAddr) -> bool { + if src.is_loopback() { + return true; + } + if self.entries.is_empty() { + return true; + } + self.entries.iter().any(|e| e.contains(src)) + } + + /// Like [`is_allowed`](Self::is_allowed) but records a drop when the source + /// is rejected. Use this on the hot receive path. + pub fn admit(&self, src: IpAddr) -> bool { + let ok = self.is_allowed(src); + if !ok { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + ok + } + + /// Total frames dropped due to a non-matching source. + pub fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + #[test] + fn default_bind_is_loopback() { + let d = decide_udp_bind(IpAddr::V4(Ipv4Addr::LOCALHOST), false, false).unwrap(); + assert_eq!(d, UdpBindDecision::Loopback); + // IPv6 loopback too. + let d6 = decide_udp_bind(IpAddr::V6(Ipv6Addr::LOCALHOST), false, false).unwrap(); + assert_eq!(d6, UdpBindDecision::Loopback); + } + + #[test] + fn routable_bind_without_allowlist_is_refused() { + // 0.0.0.0 is not loopback → refused with no allowlist and no override. + let err = decide_udp_bind(IpAddr::V4(Ipv4Addr::UNSPECIFIED), false, false).unwrap_err(); + assert!(err.contains("Refusing"), "{err}"); + let err_lan = decide_udp_bind(v4(192, 168, 1, 10), false, false).unwrap_err(); + assert!(err_lan.contains("--udp-allow"), "{err_lan}"); + } + + #[test] + fn routable_bind_allowed_with_allowlist_or_override() { + assert_eq!( + decide_udp_bind(v4(0, 0, 0, 0), true, false).unwrap(), + UdpBindDecision::RoutableAllowlisted + ); + assert_eq!( + decide_udp_bind(v4(0, 0, 0, 0), false, true).unwrap(), + UdpBindDecision::RoutableInsecure + ); + } + + #[test] + fn loopback_bind_ignores_missing_allowlist() { + // Even without allowlist/override, loopback is never refused. + assert_eq!( + decide_udp_bind(IpAddr::V4(Ipv4Addr::LOCALHOST), false, false).unwrap(), + UdpBindDecision::Loopback + ); + } + + #[test] + fn allowlist_accept_drop_and_count() { + let a = UdpSourceAllowlist::parse(["192.168.1.0/24"]).unwrap(); + assert!(a.is_active()); + assert!(a.admit(v4(192, 168, 1, 42))); + assert!(!a.admit(v4(10, 0, 0, 1))); + assert!(!a.admit(v4(192, 168, 2, 1))); + assert_eq!(a.dropped(), 2); + // Accepts did not touch the counter. + assert!(a.admit(v4(192, 168, 1, 200))); + assert_eq!(a.dropped(), 2); + } + + #[test] + fn loopback_source_always_allowed() { + let a = UdpSourceAllowlist::parse(["10.0.0.0/8"]).unwrap(); + assert!(a.admit(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(a.admit(IpAddr::V6(Ipv6Addr::LOCALHOST))); + assert_eq!(a.dropped(), 0); + // A non-loopback outside the list is still dropped. + assert!(!a.admit(v4(192, 168, 0, 1))); + assert_eq!(a.dropped(), 1); + } + + #[test] + fn inactive_allowlist_accepts_everything() { + let a = UdpSourceAllowlist::parse(Vec::::new()).unwrap(); + assert!(!a.is_active()); + assert!(a.admit(v4(203, 0, 113, 7))); + assert_eq!(a.dropped(), 0); + } + + #[test] + fn exact_ip_entry_matches_only_itself() { + let a = UdpSourceAllowlist::parse(["10.0.0.5"]).unwrap(); + assert!(a.is_allowed(v4(10, 0, 0, 5))); + assert!(!a.is_allowed(v4(10, 0, 0, 6))); + } + + #[test] + fn comma_and_multi_spec_parsing() { + let a = UdpSourceAllowlist::parse(["192.168.1.0/24, 10.0.0.5", "172.16.0.0/12"]).unwrap(); + assert_eq!(a.len(), 3); + assert!(a.is_allowed(v4(172, 20, 5, 5))); + assert!(a.is_allowed(v4(10, 0, 0, 5))); + assert!(!a.is_allowed(v4(8, 8, 8, 8))); + } + + #[test] + fn ipv6_cidr_matching() { + let a = UdpSourceAllowlist::parse(["2001:db8::/32"]).unwrap(); + assert!(a.is_allowed("2001:db8:1234::1".parse().unwrap())); + assert!(!a.is_allowed("2001:dead::1".parse().unwrap())); + // v4 source never matches a v6 entry. + assert!(!a.is_allowed(v4(192, 168, 1, 1))); + } + + #[test] + fn malformed_entries_error_without_panic() { + assert!(UdpSourceAllowlist::parse(["not-an-ip"]).is_err()); + assert!(UdpSourceAllowlist::parse(["192.168.1.0/33"]).is_err()); + assert!(UdpSourceAllowlist::parse(["10.0.0.0/x"]).is_err()); + assert!(UdpSourceAllowlist::parse(["::1/129"]).is_err()); + } + + #[test] + fn prefix_zero_matches_all_of_family() { + let a = UdpSourceAllowlist::parse(["0.0.0.0/0"]).unwrap(); + assert!(a.is_allowed(v4(1, 2, 3, 4))); + assert!(a.is_allowed(v4(203, 0, 113, 9))); + // But not IPv6 — different family. + assert!(!a.is_allowed("2001:db8::1".parse().unwrap())); + } + + #[test] + fn startup_summary_mentions_scope_and_allowlist() { + let a = UdpSourceAllowlist::parse(["192.168.1.0/24"]).unwrap(); + let s = startup_summary( + UdpBindDecision::RoutableAllowlisted, + v4(0, 0, 0, 0), + 5005, + &a, + ); + assert!(s.contains("allowlist active"), "{s}"); + assert!(s.contains("loopback always allowed"), "{s}"); + + let empty = UdpSourceAllowlist::default(); + let loop_s = startup_summary( + UdpBindDecision::Loopback, + IpAddr::V4(Ipv4Addr::LOCALHOST), + 5005, + &empty, + ); + assert!(loop_s.contains("loopback-only"), "{loop_s}"); + assert!(loop_s.contains("no source allowlist"), "{loop_s}"); + } +} diff --git a/v2/crates/wifi-densepose-train/src/lib.rs b/v2/crates/wifi-densepose-train/src/lib.rs index 67c8eff4..0014b5a9 100644 --- a/v2/crates/wifi-densepose-train/src/lib.rs +++ b/v2/crates/wifi-densepose-train/src/lib.rs @@ -59,6 +59,11 @@ pub mod mae; /// `oks_canonical`, available **without** the `tch-backend` feature so the /// single metric definition is reachable from the workspace test gate. pub mod metrics_core; +/// Model release sanity gates (ADR-295) — block degenerate and mislabeled +/// classifier artifacts (unreachable decision boundary, constant output, +/// degenerate class balance, missing baseline, metric-name provenance) before +/// release. Prevention only; withdraws nothing already published. +pub mod model_gates; /// Public-benchmark split protocols and leakage guards (ADR-288 §2–3) — /// deterministic cross-subject / cross-environment / cross-orientation /// assignment plus the structural [`protocols::leakage::LeakageAudit`], @@ -115,6 +120,13 @@ pub use protocols::leakage::{ }; pub use protocols::{SampleMeta, SplitPlan, SplitProtocol, SplitSide}; +// ADR-295 — model release sanity gates. +pub use model_gates::{ + check_baseline, check_class_balance, check_constant_output, check_metric_provenance, + check_unreachable_boundary, evaluate_linear_head, GateError, GateFailure, GateOutcomeError, + LabeledMetric, LinearHead, MetricKind, ModelGateReport, ProbeSet, +}; + pub use error::{ConfigError, DatasetError, MaeError, ProtocolError, SubcarrierError, TrainError}; // TrainResult is the generic Result alias from error.rs; the concrete // TrainResult struct from trainer.rs is accessed via trainer::TrainResult. diff --git a/v2/crates/wifi-densepose-train/src/model_gates.rs b/v2/crates/wifi-densepose-train/src/model_gates.rs new file mode 100644 index 00000000..65f2f332 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/model_gates.rs @@ -0,0 +1,987 @@ +//! Model release sanity gates (ADR-295) — block degenerate and mislabeled +//! classifier artifacts before they can ship. +//! +//! # Why this module exists +//! +//! The external review (corroborating issue 1521) showed the published presence +//! head is mathematically degenerate: with L2-normalized embeddings, a weight +//! norm `‖w‖ ≈ 3.67` against a bias `≈ 8.19` makes the *smallest possible* +//! logit positive (`8.19 − 3.67 = 4.52 > 0`), so predicted presence probability +//! is `≥ ~0.989` for **every** valid input — the decision boundary is +//! unreachable and the head is effectively constant. The README then labeled a +//! temporal-triplet accuracy (a representation-ordering metric) as "presence +//! accuracy" — a category error. Nothing in the release path caught any of it. +//! +//! This module adds structural machine checks for those failure shapes: +//! +//! - [`check_unreachable_boundary`] — for a normalized-embedding linear head, +//! fail when `‖bias‖` dominates `‖weight‖` so the logit cannot change sign. +//! - [`check_constant_output`] — fail when the head's output variance across a +//! diverse, L2-normalized probe set is below a threshold. +//! - [`check_class_balance`] — fail when the predicted-positive rate on a +//! balanced probe set sits at/above a ceiling (e.g. `> 99 %`). +//! - [`check_baseline`] — fail a report with no paired mean-pose/majority +//! baseline (ties into the ADR-288 [`EvaluationReport`]). +//! - [`check_metric_provenance`] — a metric carries its computed +//! [`MetricKind`] and its display label derives from it, so a temporal-triplet +//! metric can never be surfaced under the `presence` task name. +//! +//! Each gate emits a structured, human-readable [`GateFailure`] naming the +//! defect and the offending numbers. +//! +//! # This is prevention, not a correctness proof +//! +//! The gates are heuristic. They catch the *known* failure shapes above, not +//! all bad models (ADR-295 §Consequences). This module does **not** withdraw +//! any already-published artifact — an outward-facing action requiring +//! maintainer sign-off — it prevents recurrence. +//! +//! All checks are deterministic: probe sets are generated in code from a linear +//! head with no RNG state, and the sweep exercises the full reachable logit +//! range so the constant-output verdict is dimension-robust (random unit +//! vectors concentrate near-orthogonal to the weight in high dimensions and +//! would hide a live boundary). + +use thiserror::Error; + +use crate::protocols::leakage::EvaluationReport; + +// --------------------------------------------------------------------------- +// Tunable thresholds (documented defaults; every gate also takes an explicit +// argument so a workflow can tighten them). +// --------------------------------------------------------------------------- + +/// Default population-variance floor for [`check_constant_output`]. Below this, +/// the head's probability output is treated as effectively constant. The +/// issue-1521 head sits far under it (all logits in `[4.52, 11.86]` ⇒ sigmoid in +/// `[0.989, 1.0)`); a head that sweeps `[-‖w‖, ‖w‖]` around a reachable boundary +/// sits far above it. +pub const DEFAULT_MIN_OUTPUT_VARIANCE: f64 = 1e-4; + +/// Default predicted-positive-rate ceiling for [`check_class_balance`]. A head +/// that predicts positive for `> 99 %` of a balanced probe is degenerate. +pub const DEFAULT_MAX_POSITIVE_RATE: f64 = 0.99; + +/// Default number of probe points swept across the reachable logit range. +pub const DEFAULT_PROBE_POINTS: usize = 64; + +// --------------------------------------------------------------------------- +// GateError — malformed input at the construction boundary (never a panic). +// --------------------------------------------------------------------------- + +/// Errors from constructing a gate input from untrusted numbers. +/// +/// These are *malformed artifact* errors (empty weight vector, non-finite +/// parameters), distinct from a [`GateFailure`] which is a well-formed head +/// that a gate rejected. +#[derive(Debug, Error, PartialEq)] +pub enum GateError { + /// The weight vector was empty; a linear head needs at least one dimension. + #[error("linear head weight vector is empty")] + EmptyWeight, + + /// A weight or bias value was NaN or ±inf; corrupted parameters must be + /// rejected upstream, never scored. + #[error("non-finite parameter `{what}`: {value}")] + NonFiniteParameter { + /// Which parameter (`"weight[i]"` or `"bias"`). + what: String, + /// The offending value. + value: f64, + }, + + /// A probe set was empty. + #[error("probe set is empty")] + EmptyProbe, + + /// A probe embedding length did not match the head dimension. + #[error("probe embedding has dimension {actual}, expected {expected}")] + ProbeDimMismatch { + /// Head dimension. + expected: usize, + /// Probe embedding length. + actual: usize, + }, + + /// A metric value was NaN or ±inf. + #[error("metric value is not finite: {0}")] + NonFiniteMetric(f64), +} + +// --------------------------------------------------------------------------- +// GateFailure — a well-formed head/report that a gate rejected. +// --------------------------------------------------------------------------- + +/// A structured, human-readable gate failure carrying the offending numbers. +#[derive(Debug, Error, Clone, PartialEq)] +pub enum GateFailure { + /// The decision boundary (`logit = 0`) is analytically unreachable: with + /// L2-normalized embeddings the logit is confined to + /// `[bias − ‖w‖, bias + ‖w‖]`, and this interval does not straddle zero. + #[error( + "unreachable decision boundary: L2-normalized logit is confined to \ + [{min_logit:.4}, {max_logit:.4}] (bias {bias:.4}, ‖weight‖ {weight_norm:.4}); \ + it never crosses 0, so the classifier is effectively constant" + )] + UnreachableBoundary { + /// `‖weight‖₂`. + weight_norm: f64, + /// Head bias. + bias: f64, + /// Minimum achievable logit (`bias − ‖w‖`). + min_logit: f64, + /// Maximum achievable logit (`bias + ‖w‖`). + max_logit: f64, + }, + + /// The output variance across the probe set is below the threshold — the + /// head is effectively constant. + #[error( + "constant output: probability variance {variance:.3e} across {probe_size} \ + probes is below threshold {threshold:.3e} (outputs in [{min_output:.4}, \ + {max_output:.4}])" + )] + ConstantOutput { + /// Population variance of the probability outputs. + variance: f64, + /// Variance floor that was violated. + threshold: f64, + /// Minimum probability output over the probe set. + min_output: f64, + /// Maximum probability output over the probe set. + max_output: f64, + /// Number of probes evaluated. + probe_size: usize, + }, + + /// The predicted-positive rate on a balanced probe is at/above the ceiling. + #[error( + "degenerate class balance: predicted-positive rate {positive_rate:.4} on \ + {probe_size} balanced probes is at/above ceiling {ceiling:.4}" + )] + ClassBalance { + /// Fraction of probes classified positive (`logit ≥ 0`). + positive_rate: f64, + /// Ceiling that was violated. + ceiling: f64, + /// Number of probes evaluated. + probe_size: usize, + }, + + /// A report was surfaced without a paired baseline (ADR-288). + #[error("missing baseline: {reason}")] + MissingBaseline { + /// Why the baseline is considered missing/blank. + reason: String, + }, + + /// A metric computed under one kind was surfaced under another task name. + #[error( + "metric-name provenance mismatch: metric computed as `{computed_kind}` \ + ({computed_label}) may not be surfaced as `{surfaced_kind}` \ + ({surfaced_label})" + )] + MetricProvenance { + /// Kind the metric was actually computed under. + computed_kind: &'static str, + /// Display label of the computed kind. + computed_label: &'static str, + /// Kind the metric was being surfaced under. + surfaced_kind: &'static str, + /// Display label of the surfaced kind. + surfaced_label: &'static str, + }, +} + +// --------------------------------------------------------------------------- +// LinearHead +// --------------------------------------------------------------------------- + +/// A single-logit linear classification head `logit(x) = weight · x + bias`, +/// the shape of the published presence head. Kept parameter-only (no `tch`) so +/// the gates run on the workspace test gate without libtorch. +#[derive(Debug, Clone, PartialEq)] +pub struct LinearHead { + weight: Vec, + bias: f32, +} + +impl LinearHead { + /// Build a head from raw parameters, validating them at the boundary. + /// + /// # Errors + /// + /// - [`GateError::EmptyWeight`] when `weight` is empty. + /// - [`GateError::NonFiniteParameter`] when any weight or the bias is + /// NaN/±inf. + pub fn new(weight: Vec, bias: f32) -> Result { + if weight.is_empty() { + return Err(GateError::EmptyWeight); + } + for (i, &w) in weight.iter().enumerate() { + if !w.is_finite() { + return Err(GateError::NonFiniteParameter { + what: format!("weight[{i}]"), + value: w as f64, + }); + } + } + if !bias.is_finite() { + return Err(GateError::NonFiniteParameter { + what: "bias".to_string(), + value: bias as f64, + }); + } + Ok(LinearHead { weight, bias }) + } + + /// Embedding dimension. + #[must_use] + pub fn dim(&self) -> usize { + self.weight.len() + } + + /// Head bias as `f64`. + #[must_use] + pub fn bias(&self) -> f64 { + self.bias as f64 + } + + /// `‖weight‖₂`. + #[must_use] + pub fn weight_norm(&self) -> f64 { + self.weight + .iter() + .map(|&w| (w as f64) * (w as f64)) + .sum::() + .sqrt() + } + + /// Minimum achievable logit for a unit-norm embedding (`bias − ‖w‖`). + #[must_use] + pub fn min_logit_normalized(&self) -> f64 { + self.bias() - self.weight_norm() + } + + /// Maximum achievable logit for a unit-norm embedding (`bias + ‖w‖`). + #[must_use] + pub fn max_logit_normalized(&self) -> f64 { + self.bias() + self.weight_norm() + } + + /// Compute the logit `weight · embedding + bias`. + /// + /// # Errors + /// + /// [`GateError::ProbeDimMismatch`] when `embedding.len() != self.dim()`. + pub fn logit(&self, embedding: &[f32]) -> Result { + if embedding.len() != self.dim() { + return Err(GateError::ProbeDimMismatch { + expected: self.dim(), + actual: embedding.len(), + }); + } + let dot: f64 = self + .weight + .iter() + .zip(embedding) + .map(|(&w, &x)| (w as f64) * (x as f64)) + .sum(); + Ok(dot + self.bias()) + } + + /// Presence probability `σ(logit)`, numerically stable. + /// + /// # Errors + /// + /// [`GateError::ProbeDimMismatch`] when `embedding.len() != self.dim()`. + pub fn presence_prob(&self, embedding: &[f32]) -> Result { + Ok(sigmoid(self.logit(embedding)?)) + } +} + +/// Numerically stable logistic sigmoid. +fn sigmoid(x: f64) -> f64 { + if x >= 0.0 { + 1.0 / (1.0 + (-x).exp()) + } else { + let e = x.exp(); + e / (1.0 + e) + } +} + +// --------------------------------------------------------------------------- +// ProbeSet +// --------------------------------------------------------------------------- + +/// A deterministic set of L2-normalized embeddings used to probe a head. +/// +/// [`ProbeSet::sweep_for_head`] sweeps the embedding whose projection onto the +/// weight direction ranges over `[-1, 1]`, so the logit ranges over the full +/// reachable interval `[bias − ‖w‖, bias + ‖w‖]`. This is the degenerate +/// normalized-embedding probe from issue 1521: every embedding is unit norm, +/// and the sweep is exactly what exposes an unreachable boundary or a constant +/// output, independent of embedding dimension. +#[derive(Debug, Clone, PartialEq)] +pub struct ProbeSet { + embeddings: Vec>, +} + +impl ProbeSet { + /// Build a probe set from explicit embeddings, validating shape/finiteness. + /// + /// # Errors + /// + /// - [`GateError::EmptyProbe`] when `embeddings` is empty. + /// - [`GateError::NonFiniteParameter`] when any coordinate is NaN/±inf. + pub fn from_embeddings(embeddings: Vec>) -> Result { + if embeddings.is_empty() { + return Err(GateError::EmptyProbe); + } + for row in &embeddings { + if row.is_empty() { + return Err(GateError::EmptyProbe); + } + for (i, &v) in row.iter().enumerate() { + if !v.is_finite() { + return Err(GateError::NonFiniteParameter { + what: format!("probe[{i}]"), + value: v as f64, + }); + } + } + } + Ok(ProbeSet { embeddings }) + } + + /// Sweep the reachable logit range with `num` L2-normalized embeddings. + /// + /// Each embedding is `x = c·û_w + √(1−c²)·û⊥` for a cosine `c` linearly + /// spaced over `[-1, 1]`, where `û_w` is the weight direction and `û⊥` is a + /// fixed unit vector orthogonal to it — so `‖x‖ = 1` and the projection + /// onto `w` is exactly `c·‖w‖`. `num` is clamped to at least 2. When the + /// weight norm is ~0 (a genuinely constant head) the sweep falls back to + /// signed basis vectors, which still expose the constant output. + #[must_use] + pub fn sweep_for_head(head: &LinearHead, num: usize) -> Self { + let num = num.max(2); + let dim = head.dim(); + let norm = head.weight_norm(); + + // Degenerate weight: no direction to sweep. Use signed basis vectors; + // the head is constant regardless of input, which the gate will catch. + if norm < 1e-12 { + let mut embeddings = Vec::with_capacity(num); + for k in 0..num { + let mut e = vec![0.0f32; dim]; + let idx = k % dim; + e[idx] = if k % 2 == 0 { 1.0 } else { -1.0 }; + embeddings.push(e); + } + return ProbeSet { embeddings }; + } + + let u_w: Vec = head.weight.iter().map(|&w| (w as f64) / norm).collect(); + + // dim == 1: the only unit embeddings are ±1. + if dim == 1 { + return ProbeSet { + embeddings: vec![vec![-1.0f32], vec![1.0f32]], + }; + } + + // Pick the axis least aligned with the weight for a stable orthogonal + // direction; project it off u_w and normalize. + let k = argmin_abs(&u_w); + let dot_k = u_w[k]; + let perp_norm = (1.0 - dot_k * dot_k).sqrt(); + let u_perp: Vec = (0..dim) + .map(|i| { + let e_i = if i == k { 1.0 } else { 0.0 }; + (e_i - dot_k * u_w[i]) / perp_norm + }) + .collect(); + + let mut embeddings = Vec::with_capacity(num); + for j in 0..num { + let c = -1.0 + 2.0 * (j as f64) / ((num - 1) as f64); + let s = (1.0 - c * c).max(0.0).sqrt(); + let x: Vec = (0..dim) + .map(|i| (c * u_w[i] + s * u_perp[i]) as f32) + .collect(); + embeddings.push(x); + } + ProbeSet { embeddings } + } + + /// Number of probes. + #[must_use] + pub fn len(&self) -> usize { + self.embeddings.len() + } + + /// Whether the probe set is empty (never true after construction). + #[must_use] + pub fn is_empty(&self) -> bool { + self.embeddings.is_empty() + } + + /// Probability outputs of `head` over every probe. + /// + /// # Errors + /// + /// [`GateError::ProbeDimMismatch`] when a probe length differs from the head + /// dimension. + pub fn probabilities(&self, head: &LinearHead) -> Result, GateError> { + self.embeddings + .iter() + .map(|e| head.presence_prob(e)) + .collect() + } +} + +/// Index of the smallest-magnitude entry (ties resolved to the first). +fn argmin_abs(v: &[f64]) -> usize { + let mut best = 0usize; + let mut best_abs = f64::INFINITY; + for (i, &x) in v.iter().enumerate() { + let a = x.abs(); + if a < best_abs { + best_abs = a; + best = i; + } + } + best +} + +// --------------------------------------------------------------------------- +// Gates +// --------------------------------------------------------------------------- + +/// Fail when the decision boundary is analytically unreachable for a +/// normalized-embedding linear head. +/// +/// With `‖x‖ = 1`, Cauchy–Schwarz confines the logit to +/// `[bias − ‖w‖, bias + ‖w‖]`. If this interval does not straddle `0` (i.e. +/// `|bias| ≥ ‖w‖`), the sign of the logit is fixed and the classifier is +/// effectively constant. The issue-1521 head (`‖w‖ ≈ 3.67`, `bias ≈ 8.19`) has +/// `min_logit ≈ 4.52 > 0` and fails here. +/// +/// # Errors +/// +/// [`GateFailure::UnreachableBoundary`] with the confining interval. +pub fn check_unreachable_boundary(head: &LinearHead) -> Result<(), GateFailure> { + let min_logit = head.min_logit_normalized(); + let max_logit = head.max_logit_normalized(); + // The boundary is reachable only if the interval straddles zero. At the + // exact touch (`min_logit == 0` or `max_logit == 0`) it is reachable at a + // single antipodal point only — treat as unreachable. + if min_logit >= 0.0 || max_logit <= 0.0 { + return Err(GateFailure::UnreachableBoundary { + weight_norm: head.weight_norm(), + bias: head.bias(), + min_logit, + max_logit, + }); + } + Ok(()) +} + +/// Fail when the head's probability output has variance below `min_variance` +/// across the probe set — an effectively constant classifier. +/// +/// # Errors +/// +/// - [`GateError`] when a probe length is wrong. +/// - [`GateFailure::ConstantOutput`] when the variance is below `min_variance`. +pub fn check_constant_output( + head: &LinearHead, + probe: &ProbeSet, + min_variance: f64, +) -> Result<(), GateOutcomeError> { + let probs = probe.probabilities(head).map_err(GateOutcomeError::Input)?; + let n = probs.len() as f64; + let mean = probs.iter().sum::() / n; + let variance = probs.iter().map(|p| (p - mean).powi(2)).sum::() / n; + if variance < min_variance { + let min_output = probs.iter().cloned().fold(f64::INFINITY, f64::min); + let max_output = probs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + return Err(GateOutcomeError::Failure(GateFailure::ConstantOutput { + variance, + threshold: min_variance, + min_output, + max_output, + probe_size: probs.len(), + })); + } + Ok(()) +} + +/// Fail when the predicted-positive rate (`logit ≥ 0`) on a balanced probe set +/// is at/above `max_positive_rate`. +/// +/// # Errors +/// +/// - [`GateError`] when a probe length is wrong. +/// - [`GateFailure::ClassBalance`] when the positive rate is at/above the +/// ceiling. +pub fn check_class_balance( + head: &LinearHead, + probe: &ProbeSet, + max_positive_rate: f64, +) -> Result<(), GateOutcomeError> { + let probs = probe.probabilities(head).map_err(GateOutcomeError::Input)?; + let positives = probs.iter().filter(|&&p| p >= 0.5).count(); + let positive_rate = positives as f64 / probs.len() as f64; + if positive_rate >= max_positive_rate { + return Err(GateOutcomeError::Failure(GateFailure::ClassBalance { + positive_rate, + ceiling: max_positive_rate, + probe_size: probs.len(), + })); + } + Ok(()) +} + +/// Fail when a metric is surfaced without a paired baseline (ADR-288). +/// +/// A well-formed [`EvaluationReport`] structurally carries its `baseline_metric` +/// (a model number can never be built without one), so this gate's job is to +/// reject the *absence* of a report and any non-finite baseline slipped in from +/// elsewhere. +/// +/// # Errors +/// +/// [`GateFailure::MissingBaseline`] when `report` is `None` or its baseline is +/// not finite. +pub fn check_baseline(report: Option<&EvaluationReport>) -> Result<(), GateFailure> { + match report { + None => Err(GateFailure::MissingBaseline { + reason: "no EvaluationReport was provided; a model number must be \ + paired with a mean-pose/majority baseline (ADR-288)" + .to_string(), + }), + Some(r) if !r.baseline_metric.is_finite() => Err(GateFailure::MissingBaseline { + reason: format!( + "baseline for `{}` is not finite ({})", + r.metric_name, r.baseline_metric + ), + }), + Some(_) => Ok(()), + } +} + +/// The result of running a gate that consumes a probe set: either a malformed +/// input ([`GateError`]) or a well-formed head that the gate rejected +/// ([`GateFailure`]). +#[derive(Debug, Error)] +pub enum GateOutcomeError { + /// Malformed input reached the gate. + #[error("gate input error: {0}")] + Input(#[from] GateError), + + /// The gate rejected a well-formed artifact. + #[error("gate failed: {0}")] + Failure(#[from] GateFailure), +} + +// --------------------------------------------------------------------------- +// Metric-name provenance +// --------------------------------------------------------------------------- + +/// The computed kind of a scalar metric. The kind is the source of truth; the +/// display label ([`MetricKind::label`]) derives from it, so a metric computed +/// as [`MetricKind::TemporalTriplet`] can never present itself as +/// [`MetricKind::Presence`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetricKind { + /// Binary presence-detection accuracy. + Presence, + /// Temporal-triplet (representation-ordering) accuracy — an ordering task, + /// **not** presence detection. + TemporalTriplet, + /// Percentage of correct keypoints. + Pck, + /// Mean per-joint position error. + Mpjpe, +} + +impl MetricKind { + /// Stable short kind name (kebab-case), for machine comparison. + #[must_use] + pub fn name(&self) -> &'static str { + match self { + MetricKind::Presence => "presence", + MetricKind::TemporalTriplet => "temporal-triplet", + MetricKind::Pck => "pck", + MetricKind::Mpjpe => "mpjpe", + } + } + + /// Human display label derived from the kind — the *only* way a metric is + /// named, so the label always matches how the number was computed. + #[must_use] + pub fn label(&self) -> &'static str { + match self { + MetricKind::Presence => "presence accuracy", + MetricKind::TemporalTriplet => "temporal-triplet accuracy", + MetricKind::Pck => "PCK", + MetricKind::Mpjpe => "MPJPE", + } + } +} + +/// A scalar metric that carries its computed [`MetricKind`]. There is no +/// constructor that accepts a free-form label — the label is always derived +/// from `kind` — so a temporal-triplet number cannot be built as "presence". +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LabeledMetric { + kind: MetricKind, + value: f64, +} + +impl LabeledMetric { + /// Build a metric of a given kind, validating the value is finite. + /// + /// # Errors + /// + /// [`GateError::NonFiniteMetric`] when `value` is NaN/±inf. + pub fn new(kind: MetricKind, value: f64) -> Result { + if !value.is_finite() { + return Err(GateError::NonFiniteMetric(value)); + } + Ok(LabeledMetric { kind, value }) + } + + /// The computed kind (source of truth). + #[must_use] + pub fn kind(&self) -> MetricKind { + self.kind + } + + /// The scalar value. + #[must_use] + pub fn value(&self) -> f64 { + self.value + } + + /// Display label derived from [`MetricKind::label`]. + #[must_use] + pub fn label(&self) -> &'static str { + self.kind.label() + } + + /// One-line self-describing string, e.g. `"temporal-triplet accuracy: 0.9000"`. + #[must_use] + pub fn describe(&self) -> String { + format!("{}: {:.4}", self.label(), self.value) + } +} + +/// Fail when a metric would be surfaced under a task name that does not match +/// the kind it was computed as (temporal-triplet ≠ presence). +/// +/// # Errors +/// +/// [`GateFailure::MetricProvenance`] when `metric.kind() != surfaced_as`. +pub fn check_metric_provenance( + metric: &LabeledMetric, + surfaced_as: MetricKind, +) -> Result<(), GateFailure> { + if metric.kind() != surfaced_as { + return Err(GateFailure::MetricProvenance { + computed_kind: metric.kind().name(), + computed_label: metric.kind().label(), + surfaced_kind: surfaced_as.name(), + surfaced_label: surfaced_as.label(), + }); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Aggregate report — a single entry point for the CI model-check gate. +// --------------------------------------------------------------------------- + +/// Aggregated verdict of the head-level release gates. Runs +/// [`check_unreachable_boundary`], [`check_constant_output`], and +/// [`check_class_balance`] against a swept probe set, plus [`check_baseline`] +/// when a report is supplied. Collects *every* failure rather than short- +/// circuiting so a maintainer sees all defects at once. +#[derive(Debug, Clone, Default)] +pub struct ModelGateReport { + /// Failures collected across the gates. + pub failures: Vec, +} + +impl ModelGateReport { + /// Whether every gate passed. + #[must_use] + pub fn passed(&self) -> bool { + self.failures.is_empty() + } + + /// Human-readable multi-line summary of the failures (or a pass line). + #[must_use] + pub fn summary(&self) -> String { + if self.passed() { + return "model release gates: PASS".to_string(); + } + let mut s = format!("model release gates: FAIL ({} issue(s))", self.failures.len()); + for f in &self.failures { + s.push_str("\n - "); + s.push_str(&f.to_string()); + } + s + } +} + +/// Run the head-level release gates and collect all failures. +/// +/// Uses [`DEFAULT_MIN_OUTPUT_VARIANCE`], [`DEFAULT_MAX_POSITIVE_RATE`], and a +/// [`DEFAULT_PROBE_POINTS`] sweep. Malformed probe evaluation is impossible here +/// because the sweep is generated to match the head dimension. +#[must_use] +pub fn evaluate_linear_head( + head: &LinearHead, + baseline: Option<&EvaluationReport>, +) -> ModelGateReport { + let mut failures = Vec::new(); + + if let Err(f) = check_unreachable_boundary(head) { + failures.push(f); + } + + let probe = ProbeSet::sweep_for_head(head, DEFAULT_PROBE_POINTS); + if let Err(GateOutcomeError::Failure(f)) = + check_constant_output(head, &probe, DEFAULT_MIN_OUTPUT_VARIANCE) + { + failures.push(f); + } + if let Err(GateOutcomeError::Failure(f)) = + check_class_balance(head, &probe, DEFAULT_MAX_POSITIVE_RATE) + { + failures.push(f); + } + + if let Err(f) = check_baseline(baseline) { + failures.push(f); + } + + ModelGateReport { failures } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::SplitProtocol; + + /// The issue-1521 presence head: `‖w‖ ≈ 3.67`, `bias ≈ 8.19`. We build a + /// weight vector whose norm is 3.67 by spreading it over 4 dims. + fn issue_1521_head() -> LinearHead { + // 4 equal components with norm 3.67 ⇒ each = 3.67 / 2 = 1.835. + let c = 3.67f32 / 2.0; + LinearHead::new(vec![c, c, c, c], 8.19).unwrap() + } + + /// A healthy synthetic head: reachable boundary, balanced, diverse output. + fn healthy_head() -> LinearHead { + // ‖w‖ = 4, bias = 0 ⇒ logit sweeps [-4, 4], sigmoid [0.018, 0.982]. + LinearHead::new(vec![2.0, 2.0, 2.0, 2.0], 0.0).unwrap() + } + + #[test] + fn issue_1521_norm_and_bias_match_the_review() { + let h = issue_1521_head(); + assert!((h.weight_norm() - 3.67).abs() < 1e-4, "norm {}", h.weight_norm()); + assert!((h.bias() - 8.19).abs() < 1e-6); + // Smallest achievable logit stays positive — the degenerate signature. + assert!(h.min_logit_normalized() > 0.0); + } + + #[test] + fn issue_1521_fails_unreachable_boundary() { + let h = issue_1521_head(); + let err = check_unreachable_boundary(&h).unwrap_err(); + match err { + GateFailure::UnreachableBoundary { min_logit, max_logit, .. } => { + assert!(min_logit > 0.0); + assert!(max_logit > 0.0); + } + other => panic!("expected UnreachableBoundary, got {other:?}"), + } + // The failure message must carry the offending numbers. + let msg = check_unreachable_boundary(&h).unwrap_err().to_string(); + assert!(msg.contains("unreachable decision boundary"), "{msg}"); + } + + #[test] + fn issue_1521_fails_constant_output() { + let h = issue_1521_head(); + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + let err = check_constant_output(&h, &probe, DEFAULT_MIN_OUTPUT_VARIANCE).unwrap_err(); + match err { + GateOutcomeError::Failure(GateFailure::ConstantOutput { + variance, + threshold, + min_output, + .. + }) => { + assert!(variance < threshold); + // Even the minimum output is a near-certain positive. + assert!(min_output > 0.98, "min_output {min_output}"); + } + other => panic!("expected ConstantOutput, got {other:?}"), + } + } + + #[test] + fn issue_1521_aggregate_fails_boundary_and_constant() { + let h = issue_1521_head(); + let report = evaluate_linear_head(&h, None); + assert!(!report.passed()); + let has_boundary = report + .failures + .iter() + .any(|f| matches!(f, GateFailure::UnreachableBoundary { .. })); + let has_constant = report + .failures + .iter() + .any(|f| matches!(f, GateFailure::ConstantOutput { .. })); + assert!(has_boundary, "expected unreachable-boundary failure"); + assert!(has_constant, "expected constant-output failure"); + } + + #[test] + fn healthy_head_passes_head_gates() { + let h = healthy_head(); + assert!(check_unreachable_boundary(&h).is_ok()); + + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + assert!(check_constant_output(&h, &probe, DEFAULT_MIN_OUTPUT_VARIANCE).is_ok()); + assert!(check_class_balance(&h, &probe, DEFAULT_MAX_POSITIVE_RATE).is_ok()); + + // With a baseline report supplied, the aggregate passes entirely. + let rep = EvaluationReport::synthetic( + SplitProtocol::CrossSubject, + "presence", + 0.71, + 0.50, + ) + .unwrap(); + let gate = evaluate_linear_head(&h, Some(&rep)); + assert!(gate.passed(), "{}", gate.summary()); + } + + #[test] + fn constant_weight_head_fails_constant_output() { + // Zero weight ⇒ logit == bias for every input ⇒ genuinely constant. + let h = LinearHead::new(vec![0.0, 0.0, 0.0], 0.3).unwrap(); + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + let err = check_constant_output(&h, &probe, DEFAULT_MIN_OUTPUT_VARIANCE).unwrap_err(); + assert!(matches!( + err, + GateOutcomeError::Failure(GateFailure::ConstantOutput { .. }) + )); + } + + #[test] + fn degenerate_class_balance_fails() { + // Issue-1521 head classifies 100% positive. + let h = issue_1521_head(); + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + let err = check_class_balance(&h, &probe, DEFAULT_MAX_POSITIVE_RATE).unwrap_err(); + match err { + GateOutcomeError::Failure(GateFailure::ClassBalance { positive_rate, .. }) => { + assert!((positive_rate - 1.0).abs() < 1e-9); + } + other => panic!("expected ClassBalance, got {other:?}"), + } + } + + #[test] + fn missing_baseline_fails_and_present_passes() { + assert!(check_baseline(None).is_err()); + let rep = EvaluationReport::synthetic( + SplitProtocol::CrossSubject, + "pck@0.2", + 0.61, + 0.41, + ) + .unwrap(); + assert!(check_baseline(Some(&rep)).is_ok()); + } + + #[test] + fn temporal_triplet_cannot_be_labeled_presence() { + let m = LabeledMetric::new(MetricKind::TemporalTriplet, 0.90).unwrap(); + // The label derives from the computed kind — it is NOT "presence accuracy". + assert_eq!(m.label(), "temporal-triplet accuracy"); + assert_ne!(m.label(), MetricKind::Presence.label()); + assert!(m.describe().contains("temporal-triplet accuracy")); + + // Surfacing it under the presence task name is a structural error. + let err = check_metric_provenance(&m, MetricKind::Presence).unwrap_err(); + match err { + GateFailure::MetricProvenance { + computed_kind, + surfaced_kind, + .. + } => { + assert_eq!(computed_kind, "temporal-triplet"); + assert_eq!(surfaced_kind, "presence"); + } + other => panic!("expected MetricProvenance, got {other:?}"), + } + } + + #[test] + fn matching_metric_provenance_passes() { + let m = LabeledMetric::new(MetricKind::Presence, 0.88).unwrap(); + assert!(check_metric_provenance(&m, MetricKind::Presence).is_ok()); + } + + #[test] + fn malformed_inputs_are_errors_not_panics() { + assert_eq!(LinearHead::new(vec![], 0.0).unwrap_err(), GateError::EmptyWeight); + assert!(matches!( + LinearHead::new(vec![f32::NAN], 0.0).unwrap_err(), + GateError::NonFiniteParameter { .. } + )); + assert!(matches!( + LinearHead::new(vec![1.0], f32::INFINITY).unwrap_err(), + GateError::NonFiniteParameter { .. } + )); + assert!(matches!( + LabeledMetric::new(MetricKind::Pck, f64::NAN).unwrap_err(), + GateError::NonFiniteMetric(_) + )); + assert_eq!( + ProbeSet::from_embeddings(vec![]).unwrap_err(), + GateError::EmptyProbe + ); + } + + #[test] + fn logit_rejects_dimension_mismatch() { + let h = healthy_head(); // dim 4 + assert!(matches!( + h.logit(&[1.0, 2.0]).unwrap_err(), + GateError::ProbeDimMismatch { expected: 4, actual: 2 } + )); + } + + #[test] + fn sweep_embeddings_are_unit_norm() { + let h = healthy_head(); + let probe = ProbeSet::sweep_for_head(&h, 16); + for e in &probe.embeddings { + let n: f64 = e.iter().map(|&x| (x as f64) * (x as f64)).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-5, "norm {n}"); + } + } +}