From a72174c571277bdc274de5849434a2a39ccb4648 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 5 Oct 2026 12:08:21 -0400 Subject: [PATCH] fix: review findings and cross-PR integration fixes for aepod release set - mediatek: cap per-device maps at 64 with oldest-first eviction; clamp --csi-ring - training: mark live-buffer fallback runs degraded (data_source/degraded) instead of plain "completed" - multistatic: skip CIR blend when any frame in the cohort has a placeholder phase - groundtruth: median count baseline; radar labels carry evidence=CLAIMED - spatial-evidence: default proof CODE; MEASURED requires a reproducer - scripts: collect-ground-truth guards non-JSON replies and unreadable token files - docker: entrypoint accepts 1/true/yes/on for RUVIEW_UDP_INSECURE_LAN - adaptive model: untrack v2/data/adaptive_model.json (#2135) - docs: ADR-379 evidence tags, RUNBOOK/README OTA wording, NOTICE for vendored MediaTek patch - tests: fresh timestamps for privacy tests (#2119 staleness), plan_source Result handling, drop obsolete qualcomm-binds-nothing test (#2126), ignore slow live-binary test (#2157) Co-Authored-By: claude-flow --- .gitignore | 3 + NOTICE | 22 ++ docker/docker-entrypoint.sh | 10 +- .../ADR-379-ota-first-boot-health-check.md | 16 +- firmware/esp32-csi-node/README.md | 3 +- firmware/esp32-csi-node/RUNBOOK.md | 2 + firmware/esp32-csi-node/main/ota_update.c | 5 +- scripts/collect-ground-truth.py | 26 +- .../tests/test_collect_ground_truth_http.py | 118 ++++++++ .../examples/radar_autolabel.rs | 2 +- v2/crates/ruview-groundtruth/src/autolabel.rs | 12 + v2/crates/ruview-groundtruth/src/baseline.rs | 71 ++++- v2/crates/ruview-groundtruth/src/radar.rs | 16 ++ .../ruview-groundtruth/src/radar/tests.rs | 16 ++ .../tests/radar_autolabel_pipeline.rs | 5 +- .../ruview-spatial-evidence/src/gaussian.rs | 33 +++ v2/crates/ruview-spatial-evidence/src/lib.rs | 12 +- v2/crates/ruview-spatial-evidence/src/wire.rs | 3 + .../ruview-spatial-evidence/tests/evidence.rs | 34 +++ .../src/esp32_parser.rs | 4 + .../wifi-densepose-sensing-server/src/main.rs | 93 ++++-- .../src/training_api.rs | 72 ++++- .../tests/multi_node_test.rs | 1 + .../src/ruvsense/multistatic.rs | 21 +- v2/data/adaptive_model.json | 267 ------------------ 25 files changed, 549 insertions(+), 318 deletions(-) create mode 100644 NOTICE create mode 100644 scripts/tests/test_collect_ground_truth_http.py delete mode 100644 v2/data/adaptive_model.json diff --git a/.gitignore b/.gitignore index a22f77004..23178b399 100644 --- a/.gitignore +++ b/.gitignore @@ -322,3 +322,6 @@ v2/crates/ruview-offaxis/pkg/ scripts/fal-visual-teacher/training-data/ scripts/fal-visual-teacher/training-data.zip scripts/fal-visual-teacher/output/ + +# Trained adaptive classifier (auto-created at runtime; #2135) +/v2/data/adaptive_model.json diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..d2ad57ed8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,22 @@ +RuView third-party notices + +This repository is licensed MIT OR Apache-2.0 (see LICENSE). The items below +are vendored third-party material that keeps its own licence and is not +covered by that dual licence. + +firmware/openwrt-wn586x3/patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch + What: MediaTek's mt76 mt7915 CSI patch, rebased onto openwrt/mt76 + eb567bc7 (the revision pinned by OpenWrt 24.10.8). + Author: Bo Jiao (original author line kept in the + patch header); copyright MediaTek Inc. + Origin: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b + Licence: BSD-3-Clause-Clear (SPDX identifier, + https://spdx.org/licenses/BSD-3-Clause-Clear.html). The patch + carries the SPDX tags and MediaTek copyright lines in its added + files; the licence text is not reproduced here. + Scope: A vendored patch applied to an OpenWrt/mt76 build. It is not part + of the Rust crates, is not compiled by the Cargo workspace, and is + not linked into any RuView binary. + +See firmware/openwrt-wn586x3/README.md for the other patches in that +directory and the licence each one keeps. diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 8025a7e17..d8557e063 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -113,9 +113,11 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then # (RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN); without one the server # would refuse a routable bind, so we keep loopback and say why instead. # An explicit RUVIEW_UDP_BIND or --udp-bind always wins. - case "${RUVIEW_UDP_INSECURE_LAN:-}" in - 1|yes|YES|on|ON|TRUE|True) export RUVIEW_UDP_INSECURE_LAN=true ;; - 0|no|NO|off|OFF|FALSE|False) export RUVIEW_UDP_INSECURE_LAN=false ;; + # The binary parses RUVIEW_UDP_INSECURE_LAN itself (1/yes/on/true, any + # case); here we only need to know whether it is truthy to pick the bind. + __insecure_lan=0 + case "$(printf '%s' "${RUVIEW_UDP_INSECURE_LAN:-}" | tr 'A-Z' 'a-z')" in + 1|true|yes|on) __insecure_lan=1 ;; esac __udp_bind_arg=0 __udp_guard_arg=0 @@ -126,7 +128,7 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then esac done if [ -z "${RUVIEW_UDP_BIND:-}" ] && [ "$__udp_bind_arg" = 0 ]; then - if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "${RUVIEW_UDP_INSECURE_LAN:-}" = true ] \ + if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "$__insecure_lan" = 1 ] \ || [ "$__udp_guard_arg" = 1 ]; then export RUVIEW_UDP_BIND=0.0.0.0 echo "[entrypoint] UDP CSI receiver: binding 0.0.0.0 inside the container" >&2 diff --git a/docs/adr/ADR-379-ota-first-boot-health-check.md b/docs/adr/ADR-379-ota-first-boot-health-check.md index cc7144076..a5cadec76 100644 --- a/docs/adr/ADR-379-ota-first-boot-health-check.md +++ b/docs/adr/ADR-379-ota-first-boot-health-check.md @@ -165,7 +165,7 @@ When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and 16 MB, rollback bootloader, OTA key provisioned). The build from this branch was first written over USB. The pushes were made from a Pi 5 with the `sensor-ota-push` cog. - 1. Positive. An image built from this branch was pushed to `ota_1`: HTTP 200, + 1. Positive (MEASURED, S3). An image built from this branch was pushed to `ota_1`: HTTP 200, 1,230,848 B in 22 s. - The node logged "httpd stack after POST /ota: 4900 of 8192 bytes never used", so the upload's peak stack use is about 3.3 KB. With the old 1 KB @@ -175,7 +175,7 @@ When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and - `/ota/status` then read `ota_state: valid`. - After a hard reset it came back on the same build in `ota_1`, still `valid`, so the update is durable. - 2. Negative. A `CONFIG_OTA_HEALTH_FORCE_FAIL=y` build was pushed to `ota_0`: + 2. Negative (MEASURED, S3 forced-fail rollback). A `CONFIG_OTA_HEALTH_FORCE_FAIL=y` build was pushed to `ota_0`: HTTP 200. - `/ota/status` showed it running as `pending_verify` for about 30 s. - The node then rebooted and came back on the previous build in `ota_1`, @@ -184,12 +184,16 @@ When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and node's USB-Serial-JTAG port at the same moment. The node reset and was unharmed. Don't hold the USB console open while pushing OTA to a USB-attached node. -- C6, MEASURED on 2026-09-29 on node 5 (ESP32-C6, 8 MB +- C6 positive, MEASURED on 2026-09-29 on node 5 (ESP32-C6, 8 MB flash, 4 MB layout, rollback bootloader). - The branch build was first written over USB and the OTA key provisioned. - A second build was pushed from the Pi 5 cog to `ota_1`: HTTP 200 in 11 s. - `/ota/status` showed `pending_verify`, then `valid` about 28 s later. - After a hard reset it stayed on the new build, `valid`. -- Not yet verified: the 120 s no-IP timeout path; a real power-cycle (a hard - reset via RTS was used instead); the rollback test on the C6 (run on the S3 - only). +- Not yet verified, so CLAIMED (no log supports these): + - the 120 s no-IP timeout path (CLAIMED); + - a true power cycle (CLAIMED; a hard reset via RTS was used instead); + - the C6 negative/rollback test (CLAIMED; run on the S3 only). +- A pass of this check means only that the node reached the network and sent + CSI. It does not validate sensing quality, calibration, or anything else the + image does. diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index 67b1b7973..52a19d9a1 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -101,7 +101,8 @@ Writing only offset `0x20000` is safe only when the status endpoint reports After an OTA, the same endpoint's `ota_state` must read `valid` before you power-cycle the node or push again. `pending_verify` means the image has not -passed its first-boot health check yet and would revert on reset. See +passed its first-boot health check yet and would revert on reset. A passed +check means only that the node reached the network and sent CSI. See [RUNBOOK §2.1](RUNBOOK.md) and ADR-379. The full bundles do not include NVS, so the documented four-offset install diff --git a/firmware/esp32-csi-node/RUNBOOK.md b/firmware/esp32-csi-node/RUNBOOK.md index 5603a0949..bd6748f10 100644 --- a/firmware/esp32-csi-node/RUNBOOK.md +++ b/firmware/esp32-csi-node/RUNBOOK.md @@ -120,6 +120,8 @@ Until it is confirmed, the **next reset reverts to the previous slot** and 120 s from boot), it logs `health check failed: , rolling back` and reboots into the previous slot. A crash or reset before confirmation is also a rollback. +- A pass means only "reached the network and sent CSI". It does not show that + sensing, calibration, or any other function of the new image works. - `GET /ota/status` reports `ota_state`. `pending_verify` means the image is not yet durable. `valid` means it survives a power cycle. `new` after an OTA means this board's bootloader has **no** rollback support. diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index 2f0f4eb37..3ccfdff88 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -119,7 +119,10 @@ static esp_err_t ota_status_handler(httpd_req_t *req) /* Receive chunk for POST /ota. Static, not on the httpd task stack: handlers * run one at a time on the single httpd task, so one buffer is enough, and - * 1 KB was a quarter of the default 4 KB stack (ADR-379). */ + * 1 KB was a quarter of the default 4 KB stack (ADR-379). + * NOT reentrant: this shared buffer is safe only because a single httpd task + * serves requests. Do not use it from another task or a multi-threaded + * httpd configuration. */ static char s_ota_rx_buf[1024]; void ota_update_log_httpd_stack(const char *what) diff --git a/scripts/collect-ground-truth.py b/scripts/collect-ground-truth.py index d37cb3bbf..4c56c44f2 100644 --- a/scripts/collect-ground-truth.py +++ b/scripts/collect-ground-truth.py @@ -116,7 +116,14 @@ def ensure_model(cache_dir: Path) -> Path: def load_api_token(token_file: str | None) -> str | None: """Bearer token for the sensing server: --token-file, else RUVIEW_API_TOKEN.""" if token_file: - return Path(token_file).read_text(encoding="utf-8").strip() or None + try: + return Path(token_file).read_text(encoding="utf-8").strip() or None + except (OSError, UnicodeDecodeError) as exc: + print( + f"ERROR: cannot read --token-file {token_file!r}: {exc}", + file=sys.stderr, + ) + sys.exit(2) return os.environ.get("RUVIEW_API_TOKEN", "").strip() or None @@ -139,10 +146,25 @@ def post_json( req = urllib.request.Request(url, data=data, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=timeout) as resp: - body = json.loads(resp.read() or b"{}") + raw = resp.read() or b"{}" except Exception as exc: print(f"WARNING: POST {url} failed: {exc}", file=sys.stderr) return False + try: + body = json.loads(raw) + except ValueError as exc: + print( + f"WARNING: POST {url} returned a non-JSON body: {exc}", + file=sys.stderr, + ) + return False + if not isinstance(body, dict): + print( + f"WARNING: POST {url} returned unexpected JSON " + f"({type(body).__name__}), expected an object", + file=sys.stderr, + ) + return False if body.get("success") is False: print(f"WARNING: POST {url} refused: {body.get('error')}", file=sys.stderr) return False diff --git a/scripts/tests/test_collect_ground_truth_http.py b/scripts/tests/test_collect_ground_truth_http.py new file mode 100644 index 000000000..83cbb7262 --- /dev/null +++ b/scripts/tests/test_collect_ground_truth_http.py @@ -0,0 +1,118 @@ +"""Error-path tests for collect-ground-truth's HTTP / token helpers. + +Uses a local stub HTTP server; no camera, no MediaPipe model. + +Run: python -m pytest scripts/tests/test_collect_ground_truth_http.py -q +""" + +from __future__ import annotations + +import importlib.util +import sys +import threading +import types +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "collect-ground-truth.py" + + +def _load_module(): + try: + import mediapipe # noqa: F401 + except Exception: + mp = types.ModuleType("mediapipe") + tasks = types.ModuleType("mediapipe.tasks") + py = types.ModuleType("mediapipe.tasks.python") + vision = types.ModuleType("mediapipe.tasks.python.vision") + py.BaseOptions = object + vision.PoseLandmarker = vision.PoseLandmarkerOptions = vision.RunningMode = object + sys.modules.update({ + "mediapipe": mp, + "mediapipe.tasks": tasks, + "mediapipe.tasks.python": py, + "mediapipe.tasks.python.vision": vision, + }) + spec = importlib.util.spec_from_file_location("collect_ground_truth", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +cgt = _load_module() + + +@pytest.fixture +def stub_server(): + """Yield a function that serves one fixed (status, body) and returns its URL.""" + servers = [] + + def start(body: bytes, status: int = 200) -> str: + class H(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", 0))) + self.send_response(status) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): + pass + + srv = HTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=srv.serve_forever, daemon=True).start() + servers.append(srv) + return f"http://127.0.0.1:{srv.server_port}/rec" + + yield start + for s in servers: + s.shutdown() + s.server_close() + + +def test_post_json_success(stub_server): + assert cgt.post_json(stub_server(b'{"success": true}')) is True + + +def test_post_json_refusal(stub_server, capsys): + url = stub_server(b'{"success": false, "error": "busy"}') + assert cgt.post_json(url) is False + assert "refused" in capsys.readouterr().err + + +def test_post_json_non_json_200_body(stub_server, capsys): + assert cgt.post_json(stub_server(b"proxy login")) is False + assert "non-JSON" in capsys.readouterr().err + + +def test_post_json_non_object_json(stub_server, capsys): + assert cgt.post_json(stub_server(b"[1, 2]")) is False + assert "expected an object" in capsys.readouterr().err + + +def test_post_json_connection_refused(capsys): + assert cgt.post_json("http://127.0.0.1:1/rec", timeout=1.0) is False + assert "failed" in capsys.readouterr().err + + +def test_missing_token_file_exits_nonzero(tmp_path, capsys): + with pytest.raises(SystemExit) as exc: + cgt.load_api_token(str(tmp_path / "nope.token")) + assert exc.value.code not in (0, None) + assert "--token-file" in capsys.readouterr().err + + +def test_unreadable_token_file_exits_nonzero(tmp_path, capsys): + f = tmp_path / "bad.token" + f.write_bytes(b"\xff\xfe\x00bad") + with pytest.raises(SystemExit) as exc: + cgt.load_api_token(str(f)) + assert exc.value.code not in (0, None) + + +def test_token_file_ok(tmp_path): + f = tmp_path / "t" + f.write_text(" secret \n", encoding="utf-8") + assert cgt.load_api_token(str(f)) == "secret" diff --git a/v2/crates/ruview-groundtruth/examples/radar_autolabel.rs b/v2/crates/ruview-groundtruth/examples/radar_autolabel.rs index 8dc215123..31c372a41 100644 --- a/v2/crates/ruview-groundtruth/examples/radar_autolabel.rs +++ b/v2/crates/ruview-groundtruth/examples/radar_autolabel.rs @@ -9,7 +9,7 @@ //! //! Writes one labelled window per line (to `--out`, or stdout) and a JSON //! summary to stderr: per-session kept/skipped counts and, when -//! `--test-session` is given, the session-disjoint majority/mean baseline. +//! `--test-session` is given, the session-disjoint majority/median baseline. //! `--offset-ms` is the recording clock minus the radar clock and applies to //! every session. `--allow-unverified` accepts radar windows whose source is //! not flagged as verified hardware. diff --git a/v2/crates/ruview-groundtruth/src/autolabel.rs b/v2/crates/ruview-groundtruth/src/autolabel.rs index 9212dfe65..0e67a38d9 100644 --- a/v2/crates/ruview-groundtruth/src/autolabel.rs +++ b/v2/crates/ruview-groundtruth/src/autolabel.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::agreement::EvidenceGrade; use crate::error::{check_bound, check_nonempty, GroundTruthError}; use crate::radar::{LabelStatus, RadarLabels, UnknownReason}; use crate::series::MAX_SAMPLES; @@ -146,9 +147,19 @@ fn summarize(frames: &[CsiFrame]) -> CsiWindowSummary { } } +fn claimed() -> EvidenceGrade { + EvidenceGrade::Claimed +} + /// One labelled window of a recording. +/// +/// The label comes from a radar, not a human, so `evidence` is always +/// [`EvidenceGrade::Claimed`]. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LabelledWindow { + /// Always [`EvidenceGrade::Claimed`] (radar-derived, not human ground truth). + #[serde(default = "claimed")] + pub evidence: EvidenceGrade, /// Session (recording) name; the unit of a disjoint split. pub session: String, /// Window start on the recording clock, Unix milliseconds. @@ -294,6 +305,7 @@ pub fn auto_label( } out.windows.push(LabelledWindow { + evidence: l.evidence, session: session.to_owned(), start_ms: start, end_ms: end, diff --git a/v2/crates/ruview-groundtruth/src/baseline.rs b/v2/crates/ruview-groundtruth/src/baseline.rs index a60fba772..219390633 100644 --- a/v2/crates/ruview-groundtruth/src/baseline.rs +++ b/v2/crates/ruview-groundtruth/src/baseline.rs @@ -6,8 +6,18 @@ //! //! An occupancy or count accuracy means little without the score of a model //! that ignores the CSI entirely. [`majority_baseline`] fits that model on the -//! training side (majority occupancy class, mean person count) and scores it +//! training side (majority occupancy class, median person count) and scores it //! on the test side. Report a model's numbers next to this baseline. +//! +//! Limits of what this baseline and split establish: +//! - A session split does not give independence across rooms or days. Two +//! sessions recorded in the same room, or on the same day, share layout, +//! occupants and RF environment, so a model can still look better than it +//! would in a new room. Split by room or day when the claim needs it. +//! - The labeller's empty-hold guard (`empty_hold_ms`) is a backward-looking +//! heuristic: it only suppresses an `Empty` label shortly after an occupied +//! one and cannot see a person who is present but stationary or out of the +//! radar's view. use std::collections::{BTreeMap, BTreeSet}; @@ -76,16 +86,19 @@ pub struct BaselineReport { pub majority_occupancy: Occupancy, /// Accuracy of always predicting the majority class on the test side. pub majority_accuracy: f64, - /// Mean training person count, when any training window has a count. - pub mean_count: Option, - /// Mean absolute error of predicting `mean_count` on test windows with a + /// Median training person count (mean of the two middle values for an even + /// number of windows), when any training window has a count. The median + /// minimises mean absolute error, so it is the strongest constant + /// predictor for [`BaselineReport::count_mae`]. + pub median_count: Option, + /// Mean absolute error of predicting `median_count` on test windows with a /// count. pub count_mae: Option, /// Test windows with a count. pub count_test_windows: usize, } -/// Fit and score the majority-class / mean-count baseline. +/// Fit and score the majority-class / median-count baseline. /// /// # Errors /// [`GroundTruthError::InvalidSplit`] when either side is empty. @@ -122,14 +135,13 @@ pub fn majority_baseline( .filter_map(|w| w.person_count) .map(f64::from) .collect(); - let mean_count = (!train_counts.is_empty()) - .then(|| train_counts.iter().sum::() / train_counts.len() as f64); + let median_count = median(&train_counts); let test_counts: Vec = test .iter() .filter_map(|w| w.person_count) .map(f64::from) .collect(); - let count_mae = match mean_count { + let count_mae = match median_count { Some(m) if !test_counts.is_empty() => { Some(test_counts.iter().map(|c| (c - m).abs()).sum::() / test_counts.len() as f64) } @@ -142,12 +154,28 @@ pub fn majority_baseline( test_class_counts, majority_occupancy, majority_accuracy: hits as f64 / test.len() as f64, - mean_count, + median_count, count_mae, count_test_windows: test_counts.len(), }) } +/// Median of `values`; the mean of the two middle values when the length is +/// even. `None` for an empty slice. +fn median(values: &[f64]) -> Option { + if values.is_empty() { + return None; + } + let mut v = values.to_vec(); + v.sort_by(f64::total_cmp); + let n = v.len(); + Some(if n % 2 == 1 { + v[n / 2] + } else { + (v[n / 2 - 1] + v[n / 2]) / 2.0 + }) +} + #[cfg(test)] mod tests { use super::*; @@ -155,6 +183,7 @@ mod tests { fn w(session: &str, i: i64, count: u32) -> LabelledWindow { LabelledWindow { + evidence: crate::agreement::EvidenceGrade::Claimed, session: session.into(), start_ms: i * 1000, end_ms: i * 1000 + 1000, @@ -213,19 +242,33 @@ mod tests { #[test] fn baseline_matches_hand_computed_fixture() { let s = session_disjoint_split(&windows(), &["tue"]).unwrap(); - // Train: mon [0,1,1,2] + wed [1,0] -> 4 occupied of 6, mean count 5/6. + // Train: mon [0,1,1,2] + wed [1,0] -> 4 occupied of 6, median count 1. // Test: tue [0,0,1] -> majority "occupied" is right once in three. let b = majority_baseline(&s.train, &s.test).unwrap(); assert_eq!(b.majority_occupancy, Occupancy::Occupied); assert!((b.majority_accuracy - 1.0 / 3.0).abs() < 1e-12); - let m = 5.0 / 6.0; - assert!((b.mean_count.unwrap() - m).abs() < 1e-12); - let mae = (m + m + (1.0 - m)) / 3.0; + assert!((b.median_count.unwrap() - 1.0).abs() < 1e-12); + // Test tue [0,0,1] against a constant 1: errors 1, 1, 0. + let mae = 2.0 / 3.0; assert!((b.count_mae.unwrap() - mae).abs() < 1e-12); assert_eq!(b.count_test_windows, 3); assert_eq!(b.test_class_counts.get(&Occupancy::Empty), Some(&2)); } + #[test] + fn median_is_mae_optimal_and_handles_even_and_odd() { + assert_eq!(median(&[]), None); + assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0)); + assert_eq!(median(&[0.0, 0.0, 1.0, 9.0]), Some(0.5)); + // Skewed train set: the mean (2.0) would score worse on MAE than the + // median (0.0) against test counts [0, 0, 0]. + let train = vec![w("a", 0, 0), w("a", 1, 0), w("a", 2, 6)]; + let test = vec![w("b", 0, 0), w("b", 1, 0), w("b", 2, 0)]; + let b = majority_baseline(&train, &test).unwrap(); + assert_eq!(b.median_count, Some(0.0)); + assert_eq!(b.count_mae, Some(0.0)); + } + #[test] fn tie_goes_to_empty_and_missing_counts_are_none() { let mut train = vec![w("a", 0, 0), w("a", 1, 1)]; @@ -236,7 +279,7 @@ mod tests { let b = majority_baseline(&train, &test).unwrap(); assert_eq!(b.majority_occupancy, Occupancy::Empty); assert_eq!(b.majority_accuracy, 1.0); - assert_eq!((b.mean_count, b.count_mae), (None, None)); + assert_eq!((b.median_count, b.count_mae), (None, None)); assert!(majority_baseline(&[], &test).is_err()); } } diff --git a/v2/crates/ruview-groundtruth/src/radar.rs b/v2/crates/ruview-groundtruth/src/radar.rs index a77dca096..7b58a50cd 100644 --- a/v2/crates/ruview-groundtruth/src/radar.rs +++ b/v2/crates/ruview-groundtruth/src/radar.rs @@ -45,6 +45,7 @@ use serde::{Deserialize, Serialize}; +use crate::agreement::EvidenceGrade; use crate::error::{check_bound, GroundTruthError}; use crate::model::Measurand; use crate::series::{ReferenceObservation, ReferenceSeries, MAX_SAMPLES}; @@ -273,9 +274,22 @@ pub enum PresenceBasis { TargetCount, } +fn claimed() -> EvidenceGrade { + EvidenceGrade::Claimed +} + /// One radar window turned into reference labels. +/// +/// Radar labels are **not** human ground truth: a tracker misses stationary +/// people and can ghost on clutter, and its range sigma is a datasheet figure. +/// Every label therefore carries [`EvidenceGrade::Claimed`] in `evidence`; it +/// is never `Measured` until a labeller checks it against an independent +/// human count. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RadarLabel { + /// Always [`EvidenceGrade::Claimed`] (radar-derived, not human-verified). + #[serde(default = "claimed")] + pub evidence: EvidenceGrade, /// Label time, Unix milliseconds (window midpoint, see /// [`RadarWindow::label_time_ms`]). pub at_unix_ms: i64, @@ -302,6 +316,7 @@ pub struct RadarLabel { impl RadarLabel { fn unknown(w: &RadarWindow, index: usize, reason: UnknownReason) -> Self { Self { + evidence: EvidenceGrade::Claimed, at_unix_ms: w.label_time_ms(), window_start_ms: w.window_start_ms.unwrap_or(w.timestamp_ms), window_end_ms: w.timestamp_ms, @@ -377,6 +392,7 @@ pub fn label_window(w: &RadarWindow, index: usize, cfg: &RadarAdapterConfig) -> }); RadarLabel { + evidence: EvidenceGrade::Claimed, at_unix_ms: w.label_time_ms(), window_start_ms: w.window_start_ms.unwrap_or(w.timestamp_ms), window_end_ms: w.timestamp_ms, diff --git a/v2/crates/ruview-groundtruth/src/radar/tests.rs b/v2/crates/ruview-groundtruth/src/radar/tests.rs index a5de7516d..3cf1f2002 100644 --- a/v2/crates/ruview-groundtruth/src/radar/tests.rs +++ b/v2/crates/ruview-groundtruth/src/radar/tests.rs @@ -315,3 +315,19 @@ fn labels_are_deterministic() { w.targets = vec![RadarTarget { x_m: 0.3, y_m: 0.4 }]; assert_eq!(label_window(&w, 0, &cfg()), label_window(&w, 0, &cfg())); } + +#[test] +fn radar_labels_are_claimed_never_measured() { + use crate::agreement::EvidenceGrade; + let mut present = win(0); + present.target_count = Some(1); + let unknown = win(1000); // no observation -> Unknown + for w in [present, unknown] { + assert_eq!(label_window(&w, 0, &cfg()).evidence, EvidenceGrade::Claimed); + } + // A label deserialised from older JSON without the field is also Claimed. + let mut v = serde_json::to_value(label_window(&win(0), 0, &cfg())).unwrap(); + v.as_object_mut().unwrap().remove("evidence"); + let back: RadarLabel = serde_json::from_value(v).unwrap(); + assert_eq!(back.evidence, EvidenceGrade::Claimed); +} diff --git a/v2/crates/ruview-groundtruth/tests/radar_autolabel_pipeline.rs b/v2/crates/ruview-groundtruth/tests/radar_autolabel_pipeline.rs index 724c5a009..33698387f 100644 --- a/v2/crates/ruview-groundtruth/tests/radar_autolabel_pipeline.rs +++ b/v2/crates/ruview-groundtruth/tests/radar_autolabel_pipeline.rs @@ -62,6 +62,8 @@ fn synthetic_sessions_label_split_and_baseline() { assert!(all.iter().all(|w| w.csi.n_frames == 10)); assert_eq!(all[3].person_count, Some(2)); assert_eq!(all[0].occupancy, Occupancy::Empty); + // Radar-derived labels are never human ground truth. + assert!(all.iter().all(|w| w.evidence == ruview_groundtruth::EvidenceGrade::Claimed)); let split = session_disjoint_split(&all, &["b"]).unwrap(); let train: BTreeSet<_> = split.train.iter().map(|w| w.session.clone()).collect(); @@ -72,7 +74,8 @@ fn synthetic_sessions_label_split_and_baseline() { let b = majority_baseline(&split.train, &split.test).unwrap(); assert_eq!(b.majority_occupancy, Occupancy::Occupied); assert!((b.majority_accuracy - 0.25).abs() < 1e-12); - assert!((b.mean_count.unwrap() - 6.0 / 8.0).abs() < 1e-12); + // Train a+c counts sorted: 0,0,0,1,1,1,1,2 -> median 1. + assert!((b.median_count.unwrap() - 1.0).abs() < 1e-12); // Same inputs, same outputs. let again = majority_baseline(&split.train, &split.test).unwrap(); diff --git a/v2/crates/ruview-spatial-evidence/src/gaussian.rs b/v2/crates/ruview-spatial-evidence/src/gaussian.rs index 6c0773a51..2c63ebbc7 100644 --- a/v2/crates/ruview-spatial-evidence/src/gaussian.rs +++ b/v2/crates/ruview-spatial-evidence/src/gaussian.rs @@ -14,6 +14,11 @@ const MIN_UNCERTAINTY_M: f64 = 1e-3; const MAX_UNCERTAINTY_M: f64 = 100.0; /// Caller context for exporting Gaussians. +/// +/// Build it with [`GaussianExport::new`], which defaults the proof tag to +/// `CODE`. A non-synthetic record is `MEASURED` only when the caller also names +/// a reproducer ([`GaussianExport::measured`]); asking for `MEASURED` without +/// one is an error, so the tag cannot be raised by assertion alone. #[derive(Debug, Clone, PartialEq)] pub struct GaussianExport { /// Urth region id the room frame belongs to. @@ -23,6 +28,30 @@ pub struct GaussianExport { /// Strongest proof tag the caller claims. A Gaussian with synthetic /// RuView provenance is emitted as `SYNTHETIC` regardless. pub proof: ProofTag, + /// Id of the reproducer (script, capture or log) behind a `MEASURED` claim. + /// Required, non-blank, when `proof` is `MEASURED`. It is a gate only: v1 + /// has no wire field for it, so it is not serialised. + pub reproducer: Option, +} + +impl GaussianExport { + /// Context with the default proof tag, `CODE`, and no reproducer. + pub fn new(region: impl Into, producer: impl Into) -> Self { + Self { + region: region.into(), + producer: producer.into(), + proof: ProofTag::Code, + reproducer: None, + } + } + + /// Claim `MEASURED`, backed by the named reproducer. + #[must_use] + pub fn measured(mut self, reproducer: impl Into) -> Self { + self.proof = ProofTag::Measured; + self.reproducer = Some(reproducer.into()); + self + } } fn motion(m: MotionState) -> Motion { @@ -51,6 +80,10 @@ pub fn gaussian_to_record( let source_id = sanitize_id(&g.provenance.device_id, FALLBACK_SOURCE); let proof = if g.provenance.synthetic { ProofTag::Synthetic + } else if ctx.proof == ProofTag::Measured + && ctx.reproducer.as_deref().map_or(true, |r| r.trim().is_empty()) + { + return Err(EvidenceError::MissingReproducer); } else { ctx.proof }; diff --git a/v2/crates/ruview-spatial-evidence/src/lib.rs b/v2/crates/ruview-spatial-evidence/src/lib.rs index 11528051a..daa592d1b 100644 --- a/v2/crates/ruview-spatial-evidence/src/lib.rs +++ b/v2/crates/ruview-spatial-evidence/src/lib.rs @@ -21,7 +21,17 @@ //! Each record carries `provenance.proof` (`MEASURED` / `CODE` / //! `SYNTHETIC`). A Gaussian whose RuView provenance says `synthetic` is //! always emitted as `SYNTHETIC`, whatever the caller asks for; the tag can -//! be lowered by the caller, never raised. +//! be lowered by the caller, never raised. The default tag is `CODE` +//! ([`GaussianExport::new`]); `MEASURED` needs a non-empty reproducer id +//! ([`GaussianExport::measured`]) or the export fails with +//! [`EvidenceError::MissingReproducer`]. The link path still takes the proof +//! tag from the caller's [`RecordMeta`] unchecked. +//! +//! ## Golden fixtures +//! +//! The golden lines in `tests/evidence.rs` are copied from WeftOS ADR-107 §7. +//! Origin: WeftOS ADR-107 contract, no in-repo spec beyond ADR-382's summary +//! of it. pub mod gaussian; pub mod link; diff --git a/v2/crates/ruview-spatial-evidence/src/wire.rs b/v2/crates/ruview-spatial-evidence/src/wire.rs index 6f1efd810..3b6a20ffc 100644 --- a/v2/crates/ruview-spatial-evidence/src/wire.rs +++ b/v2/crates/ruview-spatial-evidence/src/wire.rs @@ -44,6 +44,9 @@ pub enum EvidenceError { /// Line exceeds [`MAX_LINE_BYTES`]. #[error("line is {0} bytes, max {MAX_LINE_BYTES}")] LineTooLong(usize), + /// `MEASURED` was requested without a non-empty reproducer id. + #[error("MEASURED proof requires a non-empty reproducer id")] + MissingReproducer, } type R = Result<(), EvidenceError>; diff --git a/v2/crates/ruview-spatial-evidence/tests/evidence.rs b/v2/crates/ruview-spatial-evidence/tests/evidence.rs index 49f4a2c70..07e49a02b 100644 --- a/v2/crates/ruview-spatial-evidence/tests/evidence.rs +++ b/v2/crates/ruview-spatial-evidence/tests/evidence.rs @@ -79,9 +79,43 @@ fn export(proof: ProofTag) -> GaussianExport { region: REGION.into(), producer: "ruview-unified@0.3".into(), proof, + reproducer: Some("test-repro".into()), } } +#[test] +fn export_defaults_to_code() { + let ctx = GaussianExport::new(REGION, "ruview-unified@0.3"); + assert_eq!(ctx.proof, ProofTag::Code); + let rec = gaussian_to_record(&gaussian("esp32-node-1", false), 0, &ctx).unwrap(); + assert_eq!(rec.provenance.proof, ProofTag::Code); +} + +#[test] +fn measured_requires_a_non_empty_reproducer() { + let g = gaussian("esp32-node-1", false); + let mut ctx = GaussianExport::new(REGION, "p@0.1"); + ctx.proof = ProofTag::Measured; + for bad in [None, Some(String::new()), Some(" ".to_string())] { + ctx.reproducer = bad; + assert_eq!( + gaussian_to_record(&g, 0, &ctx), + Err(EvidenceError::MissingReproducer) + ); + } + let ok = gaussian_to_record(&g, 0, &GaussianExport::new(REGION, "p@0.1").measured("run-42")) + .unwrap(); + assert_eq!(ok.provenance.proof, ProofTag::Measured); +} + +#[test] +fn synthetic_gaussian_needs_no_reproducer() { + let mut ctx = GaussianExport::new(REGION, "p@0.1"); + ctx.proof = ProofTag::Measured; + let rec = gaussian_to_record(&gaussian("sim", true), 0, &ctx).unwrap(); + assert_eq!(rec.provenance.proof, ProofTag::Synthetic); +} + #[test] fn adr_examples_parse_and_reserialise_to_the_same_shape() { for line in [ADR_RF_LINK, ADR_RF_GAUSSIAN] { diff --git a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs index 1c908d30c..70f02bd00 100644 --- a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs +++ b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs @@ -261,6 +261,10 @@ impl Esp32CsiParser { // channel, issue #1005). Larger counts come from merged LLTF/HT-LTF // buffers (e.g. 192 = 3 x 64, 306) on 20/40 MHz channels; ESP32-family // radios do not use 80/160 MHz, so anything above 64 maps to 40 MHz. + // + // CLAIMED: the "HT/legacy count above 64 means 40 MHz" rule is inferred + // from the datasheet, not measured on these frames. ESP32-C5/C6 are not + // separately covered by it. let bandwidth = if ppdu_type.is_he() { if adr018_flags.bw40 || n_subcarriers > 256 { Bandwidth::Bw40 diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 7a350b057..63ea44184 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2345,7 +2345,11 @@ mod privacy_mode_surface_tests { fn state(privacy_mode: bool) -> SharedState { let mut inner = AppStateInner::minimal(); inner.privacy_mode = privacy_mode; - inner.latest_update = Some(update_with_biometrics()); + let mut update = update_with_biometrics(); + // Fresh stamp: `/sensing/latest` serves an update older than + // LATEST_UPDATE_STALE_AFTER as {"status":"stale"} (#2119). + update.timestamp = chrono::Utc::now().timestamp_millis() as f64 / 1000.0; + inner.latest_update = Some(update); Arc::new(RwLock::new(inner)) } @@ -5528,7 +5532,7 @@ mod issue_1004_source_plan_tests { // stand down for). #[test] fn explicit_mediatek_binds_udp_no_simulator() { - let plan = plan_source("mediatek", false, false); + let plan = plan_source("mediatek", false, false).expect("valid source"); assert!(plan.bind_udp, "--source mediatek must receive MTC1 frames"); assert!( !plan.run_simulator, @@ -5538,19 +5542,6 @@ mod issue_1004_source_plan_tests { assert_eq!(plan.initial_source, "mediatek"); } - // The bug `explicit_mediatek_binds_udp_no_simulator` guards against is - // general: any vendor name not explicitly matched still falls into the - // catch-all and binds nothing. Pinned here so the next vendor addition - // remembers to add its own arm rather than relying on this default. - #[test] - fn unmatched_vendor_name_still_binds_nothing_by_default() { - let plan = plan_source("qualcomm", false, false); - assert!(!plan.bind_udp); - assert!(!plan.run_simulator); - assert!(!plan.run_wifi); - assert_eq!(plan.initial_source, "qualcomm"); - } - // Promotion check: the runtime promotes by setting `AppStateInner.source` // to "esp32" on the first real frame; `effective_source()` then reports it // (and reverts to "esp32:offline" after a 5 s gap). This asserts the @@ -6123,6 +6114,8 @@ mod latest_update_staleness_tests { values: vec![], }, vital_signs: None, + classifier: None, + activity: None, calibrated_presence_evidence: None, enhanced_motion: None, enhanced_breathing: None, @@ -11484,6 +11477,41 @@ mod processing_state_tests { } } +/// Upper bound on distinct MediaTek `device_id`s tracked at once. `device_id` +/// comes from untrusted UDP frames, so every per-device map must be bounded. +const MAX_MEDIATEK_DEVICES: usize = 64; + +/// Upper bound accepted for `--csi-ring` (frames retained per device). +const MAX_CSI_RING_CAPACITY: usize = 4096; + +/// Clamp the `--csi-ring` flag into `1..=MAX_CSI_RING_CAPACITY`. +fn clamp_csi_ring(requested: usize) -> usize { + requested.clamp(1, MAX_CSI_RING_CAPACITY) +} + +/// Make room for `device_id` in the per-device MediaTek maps. If it is a new +/// device and the cap is reached, evict the oldest-updated device (by +/// `mediatek_csi_by_device` stamp) from every per-device map. +fn evict_mediatek_device_if_full(s: &mut AppStateInner, device_id: &str) { + if s.mediatek_csi_by_device.contains_key(device_id) + || s.mediatek_csi_by_device.len() < MAX_MEDIATEK_DEVICES + { + return; + } + let oldest = s + .mediatek_csi_by_device + .iter() + .min_by_key(|(_, (_, seen))| *seen) + .map(|(id, _)| id.clone()); + if let Some(id) = oldest { + warn!("MediaTek device cap ({MAX_MEDIATEK_DEVICES}) reached; evicting {id}"); + s.mediatek_csi_by_device.remove(&id); + s.mediatek_csi_ring_by_device.remove(&id); + s.mediatek_heuristic_by_device.remove(&id); + s.mediatek_activity_by_device.remove(&id); + } +} + /// Build the MediaTek-heuristic `SensingUpdate` from every currently-fresh /// device in `mediatek_csi_by_device` / `mediatek_heuristic_by_device`, after /// the caller has already folded the arriving frame's amplitude into its own @@ -11771,6 +11799,38 @@ mod mediatek_room_update_tests { /// clears UNKNOWN_CONFIDENCE_THRESHOLD and hysteresis has settled. const SETTLE_SAMPLES: usize = 40; + #[test] + fn device_cap_evicts_oldest_updated_device() { + let mut s = AppStateInner::minimal(); + let start = std::time::Instant::now(); + for i in 0..MAX_MEDIATEK_DEVICES { + let id = format!("{:016x}", i + 1); + // Device 1 is the oldest; later ids are progressively newer. + let t = start + Duration::from_millis(i as u64); + seed_device(&mut s, &id, t, &[1.0], -50); + s.mediatek_activity_by_device + .insert(id, mediatek_activity::ActivityTracker::default()); + } + let oldest = format!("{:016x}", 1); + // Existing device: no eviction. + evict_mediatek_device_if_full(&mut s, &oldest); + assert_eq!(s.mediatek_csi_by_device.len(), MAX_MEDIATEK_DEVICES); + // New device at the cap: oldest goes, from every map. + evict_mediatek_device_if_full(&mut s, "ffffffffffffffff"); + assert_eq!(s.mediatek_csi_by_device.len(), MAX_MEDIATEK_DEVICES - 1); + assert!(!s.mediatek_csi_by_device.contains_key(&oldest)); + assert!(!s.mediatek_heuristic_by_device.contains_key(&oldest)); + assert!(!s.mediatek_activity_by_device.contains_key(&oldest)); + assert!(!s.mediatek_csi_ring_by_device.contains_key(&oldest)); + } + + #[test] + fn csi_ring_is_clamped() { + assert_eq!(clamp_csi_ring(0), 1); + assert_eq!(clamp_csi_ring(256), 256); + assert_eq!(clamp_csi_ring(usize::MAX), MAX_CSI_RING_CAPACITY); + } + #[test] fn empty_room_flat_amplitudes_is_absent() { let mut s = AppStateInner::minimal(); @@ -12215,6 +12275,7 @@ async fn udp_receiver_task( let mut s = state.write().await; s.source = source_label.clone(); s.last_mediatek_frame = Some(now); + evict_mediatek_device_if_full(&mut s, &device_id); s.mediatek_csi_by_device .insert(device_id.clone(), (snapshot.clone(), now)); s.latest_mediatek_csi = Some(snapshot); @@ -14567,7 +14628,7 @@ async fn main() { mediatek_csi_by_device: HashMap::new(), mediatek_heuristic_by_device: HashMap::new(), mediatek_csi_ring_by_device: HashMap::new(), - csi_ring_capacity: args.csi_ring, + csi_ring_capacity: clamp_csi_ring(args.csi_ring), mediatek_activity_by_device: HashMap::new(), activity_floor_seed, room_activity_peak_history: std::collections::VecDeque::new(), diff --git a/v2/crates/wifi-densepose-sensing-server/src/training_api.rs b/v2/crates/wifi-densepose-sensing-server/src/training_api.rs index ee66779ea..59a738582 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/training_api.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/training_api.rs @@ -229,6 +229,15 @@ pub struct TrainingStatus { pub patience_remaining: u32, pub eta_secs: Option, pub phase: String, + /// Where the training frames came from: `"recordings"`, `"live_buffer"` + /// (no dataset requested), or `"live_buffer_fallback"` (recordings were + /// requested but none could be loaded). + #[serde(default)] + pub data_source: String, + /// True when the run did NOT train on the requested recordings (it fell + /// back to the live frame buffer) even though `phase` may read `completed`. + #[serde(default)] + pub degraded: bool, } impl Default for TrainingStatus { @@ -246,6 +255,8 @@ impl Default for TrainingStatus { patience_remaining: 0, eta_secs: None, phase: "idle".to_string(), + data_source: String::new(), + degraded: false, } } } @@ -1135,10 +1146,26 @@ async fn run_training_job( } let mut frames = load_recording_frames(&crate::recordings_dir(&data_dir), &dataset_ids).await; + let mut data_source = "recordings"; + let mut degraded = false; if frames.is_empty() { - info!("No recordings found for dataset_ids; falling back to live frame_history"); + if dataset_ids.is_empty() { + info!("No dataset_ids given; training from live frame_history"); + data_source = "live_buffer"; + } else { + warn!( + "None of the requested recordings {dataset_ids:?} could be loaded; falling back to live frame_history (run marked degraded)" + ); + data_source = "live_buffer_fallback"; + degraded = true; + } frames = frames_from_history(&history_snapshot); } + { + let mut st = status.lock().unwrap(); + st.data_source = data_source.to_string(); + st.degraded = degraded; + } if frames.len() < 10 { warn!( @@ -1418,6 +1445,8 @@ async fn run_training_job( patience_remaining, eta_secs: Some(eta_secs), phase: phase.to_string(), + data_source: data_source.to_string(), + degraded, }; } @@ -1506,6 +1535,8 @@ async fn run_training_job( "best_oks": best_pck * 0.88, "best_val_loss": best_val_loss, "simulated": false, + "data_source": data_source, + "degraded": degraded, "n_train_samples": n_train, "n_val_samples": n_val, "n_features": n_feat, @@ -1582,7 +1613,9 @@ async fn run_training_job( st.phase = completed_phase.to_string(); } - info!("Real {training_type} training finished: phase={completed_phase}"); + info!( + "Real {training_type} training finished: phase={completed_phase} data_source={data_source} degraded={degraded}" + ); written_rvf } @@ -2574,6 +2607,41 @@ mod tests { let _ = std::fs::remove_file(&rvf_path); } + /// A run that asked for recordings but fell back to the live buffer must be + /// flagged `degraded` (and say so in `data_source`) rather than looking like + /// a clean `completed` run. + #[tokio::test] + async fn training_job_marks_live_buffer_fallback_as_degraded() { + let history = synthetic_history(40, 56); + let (tx, _rx) = broadcast::channel::(1024); + let status = Arc::new(Mutex::new(TrainingStatus::default())); + let config = TrainingConfig { + epochs: 2, + batch_size: 8, + warmup_epochs: 1, + early_stopping_patience: 10, + ..Default::default() + }; + let rvf = run_training_job( + status.clone(), + Arc::new(AtomicBool::new(false)), + tx, + config, + vec!["no-such-recording".to_string()], + history, + "supervised", + PathBuf::from("data"), + ) + .await; + let final_status = status.lock().unwrap().clone(); + assert_eq!(final_status.phase, "completed"); + assert!(final_status.degraded, "fallback run must be degraded"); + assert_eq!(final_status.data_source, "live_buffer_fallback"); + if let Some(p) = rvf { + let _ = std::fs::remove_file(p); + } + } + /// ADR-186 P4 (path safety): a `dataset_id` containing directory traversal /// is rejected before any file is opened, so the loader returns no frames /// rather than reading an arbitrary file. diff --git a/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs b/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs index d377c9f4d..9618edcc3 100644 --- a/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs +++ b/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs @@ -367,6 +367,7 @@ fn stream(sock: &UdpSocket, udp: u16, nodes: &[u8], n_sub: u16, seq: &mut u32, d /// `/health` kept answering `"status": "ok"`. The node must re-lock onto the /// grid it is actually sending, and `/health` must report input stopping. #[test] +#[ignore = "slow: spawns the live binary; run with --ignored"] fn multi_node_grid_drift_does_not_freeze_processing() { let server = LiveServer::start(); let sock = UdpSocket::bind("127.0.0.1:0").expect("bind sender"); diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index c2b312f3e..72e143d2a 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -783,8 +783,12 @@ impl MultistaticFuser { return freq_coherence; }; // Issue #1752: a placeholder phase vector would make the CIR describe - // a zero-phase spectrum, not the channel. Skip the blend. - if first_frame.phase_reference == PhaseReference::Unavailable { + // a zero-phase spectrum, not the channel. Skip the blend when ANY frame + // in the cohort has a placeholder phase, not just the first. + if node_frames + .iter() + .any(|f| f.phase_reference == PhaseReference::Unavailable) + { return freq_coherence; } @@ -1959,4 +1963,17 @@ mod tests { let coh_off = off.fuse(&frames).unwrap().cross_node_coherence; assert_eq!(coh_on, coh_off, "no CIR blend from a placeholder phase vector"); } + + #[test] + fn cir_gate_skips_when_later_frame_has_placeholder_phase() { + let on = MultistaticFuser::with_cir_canonical56(); + let off = MultistaticFuser::new(); + let frames = [ + with_reference(make_node_frame(0, 1000, 56, 1.0), PhaseReference::NodeLocal), + with_reference(make_node_frame(1, 1001, 56, 1.2), PhaseReference::Unavailable), + ]; + let coh_on = on.fuse(&frames).unwrap().cross_node_coherence; + let coh_off = off.fuse(&frames).unwrap().cross_node_coherence; + assert_eq!(coh_on, coh_off, "no CIR blend when any cohort frame lacks phase"); + } } diff --git a/v2/data/adaptive_model.json b/v2/data/adaptive_model.json deleted file mode 100644 index 6074b80a9..000000000 --- a/v2/data/adaptive_model.json +++ /dev/null @@ -1,267 +0,0 @@ -{ - "class_stats": [ - { - "label": "absent", - "count": 862, - "mean": [ - 66.68196972264862, - 67.23973219951662, - 65.0340640002779, - 205.65861248066514, - 1.2587006960556917, - 8.192575406032482, - 0.0, - 9.823395623712905, - 6.970045450727901, - -0.04488812678641681, - -0.9594767860850162, - 10.78889030301701, - 0.8330000846014487, - 22.47189099978742, - 22.47189099978742 - ], - "stddev": [ - 64.0493846652119, - 90.27545165651007, - 40.157907144682206, - 161.60550836256004, - 1.3807130815029451, - 3.2814660018571113, - 0.0, - 2.219723108446689, - 1.6521309619598676, - 0.342852106459665, - 0.30620004291079783, - 3.529722483499124, - 0.17574148506941875, - 5.519861526721805, - 5.519861526721805 - ] - }, - { - "label": "present_still", - "count": 852, - "mean": [ - 66.39259262094396, - 64.42298266818027, - 68.34546366405283, - 203.34049479166666, - 1.1900821596244182, - 8.200704225352112, - 0.0, - 10.032339700775715, - 7.234479413048846, - 0.027056637948278107, - -0.9161490234231624, - 10.991429347401095, - 0.8298622589530178, - 23.588978503428145, - 23.588978503428145 - ], - "stddev": [ - 59.144593976065984, - 82.61098004853669, - 40.08306971525127, - 152.89405234329087, - 1.2031203046363153, - 3.0571012493320526, - 0.0, - 2.22294769203091, - 1.6508044238677446, - 0.3315329147240876, - 0.29437997092330526, - 3.3214071045026303, - 0.17096813624285292, - 5.622953396738593, - 5.622953396738593 - ] - }, - { - "label": "present_moving", - "count": 808, - "mean": [ - 65.17005228763453, - 66.55424930761484, - 63.785855267654334, - 208.73719832920793, - 1.3400990099009942, - 7.570544554455446, - 0.0, - 10.069915394050431, - 6.923405617584522, - -0.1440461642917184, - -1.0022460352626226, - 10.664608744841848, - 0.8384559212414682, - 21.798331033369895, - 21.798331033369895 - ], - "stddev": [ - 66.1800697503931, - 93.22042148141067, - 42.07226450730718, - 164.93282045618218, - 1.3706144246607475, - 3.1453995481213224, - 0.0, - 2.431170975696439, - 1.672707406405861, - 0.35643090355922863, - 0.30897080072710387, - 3.325911716352165, - 0.1806597020966414, - 5.418714527442832, - 5.418714527442832 - ] - }, - { - "label": "active", - "count": 794, - "mean": [ - 61.85289600233076, - 61.12723986655727, - 62.468831971775344, - 193.2018524349286, - 1.2329974811083138, - 8.083123425692696, - 0.0, - 9.747035051350043, - 7.009904234422278, - 0.007176072447431498, - -0.9950501087764124, - 11.015545839210892, - 0.8278984910895401, - 22.445656559614797, - 22.445656559614797 - ], - "stddev": [ - 50.44687370766278, - 74.07914900524236, - 31.558067649516538, - 121.0762294406304, - 1.2507304998955402, - 3.4503520526220344, - 0.0, - 2.2730029390882156, - 1.6768264387667406, - 0.3214256392367928, - 0.31003127617615406, - 3.1187829194728285, - 0.1772099351197549, - 5.595050695741912, - 5.595050695741912 - ] - } - ], - "weights": [ - [ - 0.9923736589617821, - -0.4600422332552322, - -0.3922101552522972, - -0.1686954616947851, - -0.08471937018349271, - 0.033940973559074515, - 0.0, - -1.116294981490482, - -0.213861080404439, - -0.41727297566573723, - 0.08025552056009382, - 0.20864577739519874, - 0.36814779033318357, - 0.46242679535538855, - 0.46242679535538855, - 0.09475205040199337 - ], - [ - 0.04661470129518883, - 0.7974124099989739, - 0.3953040913806362, - -1.2708868935843511, - 0.10073070355913086, - 0.0735810797517633, - 0.0, - -0.3957608057630568, - 0.22091779039114648, - -0.43105406953304665, - 0.24907697332262252, - -0.17604200203759515, - -0.5059663705836186, - 0.5740861193153091, - 0.5740861193153091, - 0.020569218347928304 - ], - [ - -0.5295363836864718, - 0.14729609046092632, - 0.16131671233151712, - 0.15039859740752318, - 0.08189110214725194, - -0.1429062024394049, - 0.0, - 2.459247211223509, - -0.162133339181718, - 0.6345474095048843, - 0.16626892477248892, - 0.2710091094981082, - -0.08197569509399917, - -1.2007197895193034, - -1.2007197895193034, - -0.10027402587742726 - ], - [ - -0.5094519765704947, - -0.48466626720467487, - -0.1644106484598614, - 1.2891837578716183, - -0.0979024355228887, - 0.0353841491285671, - 0.0, - -0.9471914239699604, - 0.15507662919500606, - 0.2137796356938993, - -0.49560141865520463, - -0.30361288485571664, - 0.21979427534444013, - 0.16420687484859928, - 0.16420687484859928, - -0.015047242872495047 - ] - ], - "global_mean": [ - 65.08291570815048, - 64.88537161757283, - 64.96650236787292, - 202.8304440905207, - 1.25474969843183, - 8.016887816646562, - 0.0, - 9.918865477040464, - 7.036167472733628, - -0.038097952045357715, - -0.9672836370393502, - 10.86491812646321, - 0.8323017200972911, - 22.58850497890069, - 22.58850497890069 - ], - "global_std": [ - 60.376895354908775, - 85.49291935872783, - 38.814475392686795, - 151.54766198012683, - 1.3049002582695195, - 3.2446975526483737, - 1e-9, - 2.2904371592847603, - 1.667114434239705, - 0.34470363318292857, - 0.3067332188136679, - 3.334427501751985, - 0.17614366955910027, - 5.577838072123601, - 5.577838072123601 - ], - "trained_frames": 3316, - "training_accuracy": 0.4149577804583836, - "version": 1 -} \ No newline at end of file