feat: implement ADR-292/293/294/295/296 — provenance, UDP hardening, multi-node, model gates, CSI policy

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 <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
This commit is contained in:
Claude 2026-08-11 00:47:29 +00:00
parent 8bb55aac05
commit 34c9804002
No known key found for this signature in database
17 changed files with 2706 additions and 53 deletions

53
.github/workflows/csi-data-policy.yml vendored Normal file
View file

@ -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"

282
scripts/csi-data-policy-check.sh Executable file
View file

@ -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 "$@"

View file

@ -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

View file

@ -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)

View file

@ -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';
}
});

View file

@ -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') {

View file

@ -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 <IP/CIDR,...>` (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.

View file

@ -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,

View file

@ -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<u64>,
}
impl NodeInference {
/// Construct a per-node inference, clamping `confidence` into range.
pub fn new(classification: impl Into<String>, confidence: f64, age_ms: Option<u64>) -> 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<Item = &'a NodeInference>,
{
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);
}
}

View file

@ -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;

View file

@ -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<String>,
/// 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<Vec<PerNodeFeatureInfo>>,
/// 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<RoomInference>,
}
#[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<NodeSyncSnapshot>,
/// 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<NodeInference>,
}
/// 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<SharedState>) -> Json<serde_json::Valu
Json(serde_json::json!({
"status": "ready",
"source": s.effective_source(),
// ADR-292 — canonical provenance state so a status-endpoint consumer
// never has to infer "live" from the absence of a signal (issue #1526).
"source_state": s.source_state().as_str(),
// Governed trust-path state (ADR-135..146; review finding 1b): latest
// witness + privacy class + recalibration flag, and the engine error
// audit — previously write-only on AppState, now readable here.
@ -5702,8 +5779,13 @@ async fn info_page() -> Html<String> {
// ── 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<wifi_densepose_sensing_server::udp_bind::UdpSourceAllowlist>,
) {
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::<wifi_densepose_hardware::vendor_rf::VendorRfEvent>(&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,
}
}

View file

@ -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;

View file

@ -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<EntityKind, Duration>,
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 ─────────────────────────────

View file

@ -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<Duration>,
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<Duration>,
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<Duration> {
Some(Duration::from_millis(100))
}
fn old() -> Option<Duration> {
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");
}
}

View file

@ -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<UdpBindDecision, String> {
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 <IP/CIDR,...> 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<CidrEntry, String> {
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<CidrEntry>,
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<I, S>(specs: I) -> Result<Self, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut entries: Vec<CidrEntry> = 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::<String>::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}");
}
}

View file

@ -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 §23) —
/// 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<T> is the generic Result alias from error.rs; the concrete
// TrainResult struct from trainer.rs is accessed via trainer::TrainResult.

View file

@ -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<f32>,
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<f32>, bias: f32) -> Result<Self, GateError> {
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::<f64>()
.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<f64, GateError> {
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<f64, GateError> {
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<Vec<f32>>,
}
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<Vec<f32>>) -> Result<Self, GateError> {
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 + √(1c²)·û⊥` 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<f64> = 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<f64> = (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<f32> = (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<Vec<f64>, 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`, CauchySchwarz 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::<f64>() / n;
let variance = probs.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / 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<Self, GateError> {
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<GateFailure>,
}
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::<f64>().sqrt();
assert!((n - 1.0).abs() < 1e-5, "norm {n}");
}
}
}