Files
ruvnet--RuView/v2/crates/ruview-unified/src/heads.rs
T
rUv 2e018f4f19 feat(ruview-unified): Unified RF spatial world model — ADR-273..282 (#1437)
Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program.

Deep-reviewed and hardware-tested against a live ESP32-C6 CSI node before merge: fixed a reachable panic, a silent NaN-corruption path, a cross-entity Gaussian conflation bug, and a wrong-center-frequency bug in the WiFi adapter (confirmed live: was misreporting channel 4 as 2437 MHz, now correctly reports 2427 MHz matching the hardware parser exactly). Added a standing hardware-in-the-loop test (examples/esp32_live_hardware_test.rs). Also fixed unrelated pre-existing issues surfaced during validation (wifi-densepose-core clippy warnings, a ruview-auth Windows build break, a sensing-server test flake).

Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
2026-07-26 14:37:56 -07:00

739 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Task adapters ("heads") on the frozen encoder representation `z`
//! (ADR-274 §4).
//!
//! The ADR-273 acceptance test allows adapters **smaller than 1 % of the
//! backbone parameters** — enforced here by [`within_adapter_budget`] and by
//! a unit test over every head at the deployment config. Multi-class heads
//! use a low-rank (LoRA-style) factorization to stay inside the budget.
use crate::math::{sigmoid, softmax};
/// True iff the adapter fits the ADR-273 budget: `params(adapter) <
/// 1 % · params(backbone)`.
#[must_use]
pub fn within_adapter_budget(backbone_params: usize, adapter_params: usize) -> bool {
adapter_params * 100 < backbone_params
}
/// Binary presence head: logistic regression on `z`.
#[derive(Debug, Clone)]
pub struct PresenceHead {
/// Weights, length `d_model`.
pub w: Vec<f64>,
/// Bias.
pub b: f64,
}
impl PresenceHead {
/// Zero-initialized head (logistic regression is convex; zero init is
/// the standard, deterministic choice).
#[must_use]
pub fn new(d_model: usize) -> Self {
Self { w: vec![0.0; d_model], b: 0.0 }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.w.len() + 1
}
/// P(present | z).
#[must_use]
pub fn predict_prob(&self, z: &[f64]) -> f64 {
sigmoid(self.w.iter().zip(z).map(|(w, x)| w * x).sum::<f64>() + self.b)
}
/// Full-batch gradient-descent training (convex ⇒ deterministic
/// convergence). Returns the final mean cross-entropy.
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[bool], lr: f64, epochs: usize) -> f64 {
assert_eq!(zs.len(), labels.len());
let n = zs.len() as f64;
let mut final_ce = f64::INFINITY;
for _ in 0..epochs {
let mut gw = vec![0.0; self.w.len()];
let mut gb = 0.0;
let mut ce = 0.0;
for (z, &y) in zs.iter().zip(labels) {
let p = self.predict_prob(z);
let t = if y { 1.0 } else { 0.0 };
ce -= t * p.max(1e-12).ln() + (1.0 - t) * (1.0 - p).max(1e-12).ln();
let err = (p - t) / n;
for (g, x) in gw.iter_mut().zip(z) {
*g += err * x;
}
gb += err;
}
for (w, g) in self.w.iter_mut().zip(&gw) {
*w -= lr * g;
}
self.b -= lr * gb;
final_ce = ce / n;
}
final_ce
}
}
/// Low-rank multi-class head: `logits = U·(V·z) + b` with rank `r`
/// (LoRA-style factorization keeps `C`-class heads inside the 1 % budget).
#[derive(Debug, Clone)]
pub struct ActivityHead {
/// Classes.
pub classes: usize,
/// Rank.
pub rank: usize,
/// `classes × rank`, row-major.
pub u: Vec<f64>,
/// `rank × d_model`, row-major.
pub v: Vec<f64>,
/// Per-class bias.
pub b: Vec<f64>,
d_model: usize,
}
impl ActivityHead {
/// Deterministic small-value init (breaks the U/V symmetry without RNG).
#[must_use]
pub fn new(d_model: usize, classes: usize, rank: usize) -> Self {
let u = (0..classes * rank)
.map(|i| 0.01 * ((i % 7) as f64 - 3.0))
.collect();
let v = (0..rank * d_model)
.map(|i| 0.01 * ((i % 5) as f64 - 2.0))
.collect();
Self { classes, rank, u, v, b: vec![0.0; classes], d_model }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.u.len() + self.v.len() + self.b.len()
}
/// Class probabilities.
#[must_use]
pub fn predict(&self, z: &[f64]) -> Vec<f64> {
let mut probs = self.logits(z);
softmax(&mut probs);
probs
}
fn logits(&self, z: &[f64]) -> Vec<f64> {
let mut vz = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
}
let mut logits = self.b.clone();
for c in 0..self.classes {
let row = &self.u[c * self.rank..(c + 1) * self.rank];
logits[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
}
logits
}
/// Full-batch softmax cross-entropy training. Returns final mean CE.
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[usize], lr: f64, epochs: usize) -> f64 {
assert_eq!(zs.len(), labels.len());
let n = zs.len() as f64;
let mut final_ce = f64::INFINITY;
for _ in 0..epochs {
let mut gu = vec![0.0; self.u.len()];
let mut gv = vec![0.0; self.v.len()];
let mut gb = vec![0.0; self.b.len()];
let mut ce = 0.0;
for (z, &y) in zs.iter().zip(labels) {
let mut vz = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
}
let mut p = self.b.clone();
for c in 0..self.classes {
let row = &self.u[c * self.rank..(c + 1) * self.rank];
p[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
}
softmax(&mut p);
ce -= p[y].max(1e-12).ln();
// dlogits = p one_hot(y), scaled by 1/n.
let mut dvz = vec![0.0; self.rank];
for c in 0..self.classes {
let dl = (p[c] - if c == y { 1.0 } else { 0.0 }) / n;
gb[c] += dl;
for r in 0..self.rank {
gu[c * self.rank + r] += dl * vz[r];
dvz[r] += dl * self.u[c * self.rank + r];
}
}
for r in 0..self.rank {
for (d, zv) in z.iter().enumerate() {
gv[r * self.d_model + d] += dvz[r] * zv;
}
}
}
for (w, g) in self.u.iter_mut().zip(&gu) {
*w -= lr * g;
}
for (w, g) in self.v.iter_mut().zip(&gv) {
*w -= lr * g;
}
for (w, g) in self.b.iter_mut().zip(&gb) {
*w -= lr * g;
}
final_ce = ce / n;
}
final_ce
}
}
/// Linear localization head: `xyz = W·z + b` (metres).
#[derive(Debug, Clone)]
pub struct LocalizationHead {
/// `3 × d_model` weights.
pub w: Vec<f64>,
/// Bias.
pub b: [f64; 3],
d_model: usize,
}
impl LocalizationHead {
/// Zero-initialized (linear least squares; convex).
#[must_use]
pub fn new(d_model: usize) -> Self {
Self { w: vec![0.0; 3 * d_model], b: [0.0; 3], d_model }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.w.len() + 3
}
/// Predicted position.
#[must_use]
pub fn predict(&self, z: &[f64]) -> [f64; 3] {
let mut out = self.b;
for (axis, o) in out.iter_mut().enumerate() {
let row = &self.w[axis * self.d_model..(axis + 1) * self.d_model];
*o += row.iter().zip(z).map(|(a, b)| a * b).sum::<f64>();
}
out
}
/// Full-batch MSE training. Returns final mean squared error (m²).
pub fn train(&mut self, zs: &[Vec<f64>], targets: &[[f64; 3]], lr: f64, epochs: usize) -> f64 {
assert_eq!(zs.len(), targets.len());
let n = zs.len() as f64;
let mut final_mse = f64::INFINITY;
for _ in 0..epochs {
let mut gw = vec![0.0; self.w.len()];
let mut gb = [0.0; 3];
let mut mse = 0.0;
for (z, t) in zs.iter().zip(targets) {
let pred = self.predict(z);
for axis in 0..3 {
let err = pred[axis] - t[axis];
mse += err * err;
let scale = 2.0 * err / (3.0 * n);
gb[axis] += scale;
for (d, zv) in z.iter().enumerate() {
gw[axis * self.d_model + d] += scale * zv;
}
}
}
for (w, g) in self.w.iter_mut().zip(&gw) {
*w -= lr * g;
}
for (axis, g) in gb.iter().enumerate() {
self.b[axis] -= lr * g;
}
final_mse = mse / (3.0 * n);
}
final_mse
}
}
/// Number of skeleton joints (COCO-17 convention, matching the ruvsense
/// pose tracker).
pub const NUM_JOINTS: usize = 17;
/// Generic low-rank linear regressor `y = U·(V·x) + b` — the shared
/// building block for structured heads that must stay inside the adapter
/// budget.
#[derive(Debug, Clone)]
pub struct LowRankLinear {
/// Output dimension.
pub out: usize,
/// Rank.
pub rank: usize,
d_in: usize,
/// `out × rank`, row-major.
pub u: Vec<f64>,
/// `rank × d_in`, row-major.
pub v: Vec<f64>,
/// Bias, length `out`.
pub b: Vec<f64>,
}
impl LowRankLinear {
/// Deterministic small-value init (breaks U/V symmetry without RNG).
#[must_use]
pub fn new(d_in: usize, out: usize, rank: usize) -> Self {
let u = (0..out * rank).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
let v = (0..rank * d_in).map(|i| 0.01 * ((i % 5) as f64 - 2.0)).collect();
Self { out, rank, d_in, u, v, b: vec![0.0; out] }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.u.len() + self.v.len() + self.b.len()
}
/// Prediction.
#[must_use]
pub fn predict(&self, x: &[f64]) -> Vec<f64> {
let mut vx = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_in..(r + 1) * self.d_in];
vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum();
}
let mut y = self.b.clone();
for o in 0..self.out {
let row = &self.u[o * self.rank..(o + 1) * self.rank];
y[o] += row.iter().zip(&vx).map(|(a, b)| a * b).sum::<f64>();
}
y
}
/// Full-batch MSE training; returns the final mean squared error.
pub fn train(&mut self, xs: &[Vec<f64>], ys: &[Vec<f64>], lr: f64, epochs: usize) -> f64 {
assert_eq!(xs.len(), ys.len());
let n = xs.len() as f64;
let mut final_mse = f64::INFINITY;
for _ in 0..epochs {
let mut gu = vec![0.0; self.u.len()];
let mut gv = vec![0.0; self.v.len()];
let mut gb = vec![0.0; self.b.len()];
let mut mse = 0.0;
for (x, y) in xs.iter().zip(ys) {
let mut vx = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_in..(r + 1) * self.d_in];
vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum();
}
let mut dvx = vec![0.0; self.rank];
for o in 0..self.out {
let row = &self.u[o * self.rank..(o + 1) * self.rank];
let pred = self.b[o]
+ row.iter().zip(&vx).map(|(a, b)| a * b).sum::<f64>();
let err = pred - y[o];
mse += err * err;
let scale = 2.0 * err / (self.out as f64 * n);
gb[o] += scale;
for r in 0..self.rank {
gu[o * self.rank + r] += scale * vx[r];
dvx[r] += scale * self.u[o * self.rank + r];
}
}
for r in 0..self.rank {
for (d, xv) in x.iter().enumerate() {
gv[r * self.d_in + d] += dvx[r] * xv;
}
}
}
for (w, g) in self.u.iter_mut().zip(&gu) {
*w -= lr * g;
}
for (w, g) in self.v.iter_mut().zip(&gv) {
*w -= lr * g;
}
for (w, g) in self.b.iter_mut().zip(&gb) {
*w -= lr * g;
}
final_mse = mse / (self.out as f64 * n);
}
final_mse
}
}
/// Factorized pose estimate (ADR-281 §4, the RePos lesson): root-relative
/// skeleton and absolute root are separate quantities with separate
/// uncertainties.
#[derive(Debug, Clone)]
pub struct PoseOutput {
/// Root-relative joint positions, metres.
pub relative_joints_m: [[f64; 3]; NUM_JOINTS],
/// Absolute root position, metres, building frame.
pub root_position_m: [f64; 3],
/// Per-joint 1σ uncertainty, metres (calibrated from training residuals).
pub joint_uncertainty_m: [f64; NUM_JOINTS],
/// Root 1σ uncertainty, metres.
pub root_uncertainty_m: f64,
}
impl PoseOutput {
/// Absolute joints: `root + relative` (the RePos composition).
#[must_use]
pub fn absolute_joints_m(&self) -> [[f64; 3]; NUM_JOINTS] {
let mut out = self.relative_joints_m;
for j in out.iter_mut() {
for k in 0..3 {
j[k] += self.root_position_m[k];
}
}
out
}
}
/// Factorized pose head: the **relative skeleton** branch reads the
/// environment-invariant content representation (so it cannot learn
/// room-specific position shortcuts), while the **root localization**
/// branch reads the geometry-conditioned representation (where sensor
/// pose is signal). This is the anti-leakage factorization measured in
/// `tests::factorized_pose_resists_room_shortcut_leakage`.
#[derive(Debug, Clone)]
pub struct FactorizedPoseHead {
/// Relative-skeleton regressor on the content representation.
pub relative: LowRankLinear,
/// Root regressor on the geometry-conditioned representation.
pub root: LowRankLinear,
/// Calibrated per-joint residual σ, metres.
pub joint_residual_std_m: [f64; NUM_JOINTS],
/// Calibrated root residual σ, metres.
pub root_residual_std_m: f64,
}
impl FactorizedPoseHead {
/// New head for the given representation dims and rank.
#[must_use]
pub fn new(d_content: usize, d_full: usize, rank: usize) -> Self {
Self {
relative: LowRankLinear::new(d_content, NUM_JOINTS * 3, rank),
root: LowRankLinear::new(d_full, 3, rank),
joint_residual_std_m: [f64::INFINITY; NUM_JOINTS],
root_residual_std_m: f64::INFINITY,
}
}
/// Parameter count (both branches + calibration statistics).
#[must_use]
pub fn param_count(&self) -> usize {
self.relative.param_count() + self.root.param_count() + NUM_JOINTS + 1
}
/// Trains both branches and calibrates residual uncertainties.
/// Returns `(relative_mse, root_mse)` in m².
pub fn train(
&mut self,
content_zs: &[Vec<f64>],
full_zs: &[Vec<f64>],
relative_targets: &[[[f64; 3]; NUM_JOINTS]],
root_targets: &[[f64; 3]],
lr: f64,
epochs: usize,
) -> (f64, f64) {
let rel_flat: Vec<Vec<f64>> = relative_targets
.iter()
.map(|j| j.iter().flatten().copied().collect())
.collect();
let root_flat: Vec<Vec<f64>> = root_targets.iter().map(|r| r.to_vec()).collect();
let rel_mse = self.relative.train(content_zs, &rel_flat, lr, epochs);
let root_mse = self.root.train(full_zs, &root_flat, lr, epochs);
// Calibrate per-joint residual σ on the training set.
let n = content_zs.len().max(1) as f64;
let mut joint_sq = [0.0f64; NUM_JOINTS];
let mut root_sq = 0.0;
for i in 0..content_zs.len() {
let rel = self.relative.predict(&content_zs[i]);
for j in 0..NUM_JOINTS {
let mut d2 = 0.0;
for k in 0..3 {
d2 += (rel[j * 3 + k] - relative_targets[i][j][k]).powi(2);
}
joint_sq[j] += d2;
}
let root = self.root.predict(&full_zs[i]);
root_sq += (0..3).map(|k| (root[k] - root_targets[i][k]).powi(2)).sum::<f64>();
}
for j in 0..NUM_JOINTS {
self.joint_residual_std_m[j] = (joint_sq[j] / n).sqrt();
}
self.root_residual_std_m = (root_sq / n).sqrt();
(rel_mse, root_mse)
}
/// Predicts a factorized pose with calibrated uncertainties.
#[must_use]
pub fn predict(&self, content_z: &[f64], full_z: &[f64]) -> PoseOutput {
let rel = self.relative.predict(content_z);
let root = self.root.predict(full_z);
let mut relative_joints_m = [[0.0; 3]; NUM_JOINTS];
for j in 0..NUM_JOINTS {
for k in 0..3 {
relative_joints_m[j][k] = rel[j * 3 + k];
}
}
PoseOutput {
relative_joints_m,
root_position_m: [root[0], root[1], root[2]],
joint_uncertainty_m: self.joint_residual_std_m,
root_uncertainty_m: self.root_residual_std_m,
}
}
}
/// Anomaly head: z-scores the encoder's masked-reconstruction error against
/// a calibration distribution of *normal* windows. No learned weights —
/// two calibration statistics.
#[derive(Debug, Clone)]
pub struct AnomalyHead {
/// Calibration mean of reconstruction error.
pub mean: f64,
/// Calibration standard deviation.
pub std: f64,
}
impl AnomalyHead {
/// Calibrates from reconstruction errors of known-normal windows.
///
/// # Panics
/// If fewer than 2 calibration samples are provided.
#[must_use]
pub fn calibrate(normal_errors: &[f64]) -> Self {
assert!(normal_errors.len() >= 2, "need >= 2 calibration errors");
let n = normal_errors.len() as f64;
let mean = normal_errors.iter().sum::<f64>() / n;
let var = normal_errors.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1.0);
Self { mean, std: var.sqrt().max(1e-12) }
}
/// Parameter count (two statistics).
#[must_use]
pub fn param_count(&self) -> usize {
2
}
/// Anomaly z-score for a window's reconstruction error.
#[must_use]
pub fn score(&self, recon_error: f64) -> f64 {
(recon_error - self.mean) / self.std
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoder::{EncoderConfig, RfEncoder};
#[test]
fn every_head_fits_the_one_percent_budget_at_deployment_config() {
let enc = RfEncoder::new(EncoderConfig::default(), 1);
let backbone = enc.param_count();
let d = EncoderConfig::default().d_model;
let presence = PresenceHead::new(d).param_count();
let activity = ActivityHead::new(d, 4, 2).param_count();
let localization = LocalizationHead::new(d).param_count();
let anomaly = AnomalyHead { mean: 0.0, std: 1.0 }.param_count();
for (name, p) in [
("presence", presence),
("activity", activity),
("localization", localization),
("anomaly", anomaly),
] {
assert!(
within_adapter_budget(backbone, p),
"{name} head has {p} params, budget is <{}",
backbone / 100
);
}
// The structured pose head is the largest adapter; its documented
// budget is < 2 % of the backbone (ADR-281 §4).
let pose = FactorizedPoseHead::new(enc.content_dim(), d, 2).param_count();
assert!(pose * 100 < backbone * 2, "pose head {pose} params vs 2 % of {backbone}");
println!(
"backbone {backbone} params; heads: presence {presence}, activity {activity}, \
localization {localization}, anomaly {anomaly} (budget < {}), pose {pose} (< 2 %)",
backbone / 100
);
}
/// The RePos leakage experiment: in the training rooms, room position
/// correlates with body scale (small people in room A, tall in room B).
/// A monolithic absolute-pose head exploits the room feature as a
/// shortcut and collapses in an unseen room that breaks the
/// correlation; the factorized head's skeleton branch never sees room
/// features and generalizes.
#[test]
fn factorized_pose_resists_room_shortcut_leakage() {
use crate::eval::mpjpe;
// 17 fixed joint directions (deterministic).
let dirs: Vec<[f64; 3]> = (0..NUM_JOINTS)
.map(|j| {
let a = j as f64 * 0.37;
[a.cos() * 0.3, a.sin() * 0.3, 0.1 * ((j % 5) as f64 - 2.0)]
})
.collect();
let skeleton = |scale: f64| {
let mut joints = [[0.0; 3]; NUM_JOINTS];
for (j, d) in dirs.iter().enumerate() {
for k in 0..3 {
joints[j][k] = d[k] * (1.0 + 0.5 * scale);
}
}
joints
};
// content z = [scale, 1]; full z = [scale, room_x/3, 1].
let sample = |scale: f64, room_x: f64| {
let content = vec![scale, 1.0];
let full = vec![scale, room_x / 3.0, 1.0];
let root = [room_x + 0.5, 2.0, 1.0];
(content, full, skeleton(scale), root)
};
// Training: room A (x=0) only small scales, room B (x=3) only large —
// the leakage trap.
let mut content_zs = Vec::new();
let mut full_zs = Vec::new();
let mut rels = Vec::new();
let mut roots = Vec::new();
for i in 0..30 {
let s = -1.0 + i as f64 / 30.0; // [-1, 0)
let (c, f, r, ro) = sample(s, 0.0);
content_zs.push(c);
full_zs.push(f);
rels.push(r);
roots.push(ro);
let s = i as f64 / 30.0; // [0, 1)
let (c, f, r, ro) = sample(s, 3.0);
content_zs.push(c);
full_zs.push(f);
rels.push(r);
roots.push(ro);
}
let mut head = FactorizedPoseHead::new(2, 3, 2);
let (rel_mse, root_mse) = head.train(&content_zs, &full_zs, &rels, &roots, 0.3, 3000);
assert!(rel_mse < 1e-3, "relative branch must fit, mse {rel_mse}");
assert!(root_mse < 1e-3, "root branch must fit, mse {root_mse}");
// Monolithic baseline: absolute joints regressed from the full
// (room-conditioned) representation.
let abs_targets: Vec<Vec<f64>> = rels
.iter()
.zip(&roots)
.map(|(rel, root)| {
rel.iter().flat_map(|j| (0..3).map(move |k| j[k] + root[k])).collect()
})
.collect();
let mut monolithic = LowRankLinear::new(3, NUM_JOINTS * 3, 2);
monolithic.train(&full_zs, &abs_targets, 0.3, 3000);
// Held-out room (x=6) with the correlation broken: both scales.
let mut fact_err = 0.0;
let mut mono_err = 0.0;
let mut count = 0.0;
for i in 0..20 {
let s = -1.0 + i as f64 / 10.0; // [-1, 1)
let (c, f, rel, root) = sample(s, 6.0);
let truth: Vec<[f64; 3]> =
rel.iter().zip(std::iter::repeat(root)).map(|(j, r)| {
[j[0] + r[0], j[1] + r[1], j[2] + r[2]]
}).collect();
let pose = head.predict(&c, &f);
let fact_abs = pose.absolute_joints_m();
fact_err += mpjpe(&fact_abs, &truth);
let mono = monolithic.predict(&f);
let mono_abs: Vec<[f64; 3]> = (0..NUM_JOINTS)
.map(|j| [mono[j * 3], mono[j * 3 + 1], mono[j * 3 + 2]])
.collect();
mono_err += mpjpe(&mono_abs, &truth);
count += 1.0;
// ADR-273 acceptance item: every output carries uncertainty.
assert!(pose.root_uncertainty_m.is_finite());
assert!(pose.joint_uncertainty_m.iter().all(|u| u.is_finite() && *u >= 0.0));
}
fact_err /= count;
mono_err /= count;
println!(
"held-out room MPJPE: factorized {fact_err:.4} m vs monolithic {mono_err:.4} m"
);
assert!(fact_err < 0.15, "factorized head must generalize, MPJPE {fact_err}");
assert!(
mono_err > 1.5 * fact_err,
"monolithic head must show the shortcut collapse: {mono_err} vs {fact_err}"
);
}
fn separable_data(n: usize, d: usize) -> (Vec<Vec<f64>>, Vec<bool>) {
let mut zs = Vec::new();
let mut ys = Vec::new();
for i in 0..n {
let y = i % 2 == 0;
let offset = if y { 1.0 } else { -1.0 };
let z: Vec<f64> =
(0..d).map(|k| offset * (0.5 + (k as f64 / d as f64)) + 0.1 * ((i * k) % 3) as f64).collect();
zs.push(z);
ys.push(y);
}
(zs, ys)
}
#[test]
fn presence_head_learns_separable_data() {
let (zs, ys) = separable_data(60, 16);
let mut head = PresenceHead::new(16);
let ce = head.train(&zs, &ys, 0.5, 200);
assert!(ce < 0.1, "cross-entropy should collapse on separable data, got {ce}");
let correct = zs
.iter()
.zip(&ys)
.filter(|(z, &y)| (head.predict_prob(z) > 0.5) == y)
.count();
assert_eq!(correct, zs.len());
}
#[test]
fn activity_head_learns_multiclass_toy() {
let d = 16;
let mut zs = Vec::new();
let mut ys = Vec::new();
for i in 0..90 {
let c = i % 3;
let z: Vec<f64> = (0..d)
.map(|k| if k % 3 == c { 1.0 } else { 0.0 } + 0.05 * ((i + k) % 5) as f64)
.collect();
zs.push(z);
ys.push(c);
}
let mut head = ActivityHead::new(d, 3, 2);
let ce = head.train(&zs, &ys, 0.5, 400);
assert!(ce < 0.3, "multiclass CE should drop, got {ce}");
let acc = zs
.iter()
.zip(&ys)
.filter(|(z, &y)| {
let p = head.predict(z);
p.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap().0 == y
})
.count() as f64
/ zs.len() as f64;
assert!(acc > 0.95, "accuracy {acc}");
}
#[test]
fn anomaly_head_zscores_against_calibration() {
let normal: Vec<f64> = (0..50).map(|i| 0.10 + 0.001 * (i % 7) as f64).collect();
let head = AnomalyHead::calibrate(&normal);
assert!(head.score(0.103).abs() < 3.0, "in-distribution error must not alarm");
assert!(head.score(0.5) > 10.0, "gross error must alarm loudly");
}
}