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:
ruv committed 2026-10-05 12:08:21 -04:00
1 parent fd323e483e
commit a72174c571
25 files changed
+549 -318

No files matched your search

+3
View File
@@ -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
+22
View File
@@ -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.
+6 -4
View File
@@ -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.
+2 -1
View File
@@ -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
+2
View File
@@ -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.
+4 -1
View File
@@ -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)
+24 -2
View File
@@ -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,
+57 -14
View File
@@ -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());
}
}
+16
View File
@@ -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
};
+11 -1
View File
@@ -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");
}
}
-267
View File
@@ -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
}