mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
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 <ruv@ruv.net>
This commit is contained in:
1 parent
fd323e483e
commit
a72174c571
25 files changed
+549
-318
No files matched your search
@@ -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
|
||||
@@ -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 <Bo.Jiao@mediatek.com> (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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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: <reason>, 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"<html>proxy login</html>")) 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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<f64>,
|
||||
/// Mean absolute error of predicting `median_count` on test windows with a
|
||||
/// count.
|
||||
pub count_mae: Option<f64>,
|
||||
/// 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::<f64>() / train_counts.len() as f64);
|
||||
let median_count = median(&train_counts);
|
||||
let test_counts: Vec<f64> = 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::<f64>() / 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<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl GaussianExport {
|
||||
/// Context with the default proof tag, `CODE`, and no reproducer.
|
||||
pub fn new(region: impl Into<String>, producer: impl Into<String>) -> 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<String>) -> 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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -229,6 +229,15 @@ pub struct TrainingStatus {
|
||||
pub patience_remaining: u32,
|
||||
pub eta_secs: Option<u64>,
|
||||
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::<String>(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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user