Files
ruvnet--RuView/v2/crates/wifi-densepose-train/src/eval.rs
T
rUv 1d12e8831a refactor(beyond-sota): ADR-155 M2 — host-verifiable §8 closeout (7 de-magic, 9 boundary tests, native-conv honest-null) (#1059)
* refactor(train): ADR-155 M2 §8 — de-magic train non-tch tuning constants + boundary tests

Lift bare numeric literals used as thresholds / guard epsilons in the
non-tch (host-verifiable) train surface into named, documented consts and
pin each set with a *_consts_unchanged_from_literals test. Values are
bit-identical to the prior inline literals — cleanup, no behaviour change.

De-magicked (const + pin test):
- metrics_core.rs: VISIBILITY_THRESHOLD (0.5), MIN_REFERENCE_EXTENT (1e-6),
  OKS_FALLBACK_SIGMA (0.07)
- ruview_metrics.rs: NUM_KEYPOINTS (17), VISIBILITY_THRESHOLD (0.5),
  PCK_THRESHOLD (0.2), MIN_BBOX_DIAG (1e-3), MIN_DURATION_MINUTES (1e-6)
- subcarrier.rs: SPARSE_BASIS_SIGMA (0.15), SPARSE_BASIS_THRESHOLD (1e-4),
  SPARSE_REGULARIZATION_LAMBDA (0.1), SPARSE_COO_PRUNE_EPS (1e-8),
  SPARSE_SOLVER_TOL (1e-5 f64), SPARSE_SOLVER_MAX_ITERS (500)
- eval.rs: MIN_POSITIVE_MPJPE (1e-10)
- domain.rs: LAYER_NORM_EPS (1e-5)
- virtual_aug.rs: BOX_MULLER_U1_FLOOR (1e-10), MIN_ROOM_SCALE (1e-10)

Boundary / characterization tests (pin CURRENT behaviour):
- visibility_threshold_boundary_is_inclusive (>= 0.5 at the edge)
- degenerate_extent_below_floor_is_unscoreable ((0,0,0.0)/0.0, not perfect)
- tracking_zero_duration_does_not_divide_by_zero
- oks_short_array_is_bounded_at_keypoint_count (16 rows, no panic)
- compute_interp_weights_single_target_is_index_zero (target_sc==1)
- sparse_interp_single_target_is_finite
- domain_gap_infinite_when_in_domain_perfect_but_cross_nonzero
- domain_gap_unity_when_everything_perfect
- augment_frame_zero_room_scale_passes_amplitude_finite

Doc-only (no behaviour change):
- rapid_adapt.rs: correct module-doc O(eps) -> O(eps^2) for central differences
- geometry.rs: add # Panics to DeepSets::encode (documents existing assert!)

train --no-default-features: 191 lib (was 176), 303 total (was 288), 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(nn): ADR-155 M2 §3 — pure-Rust LinearHead::try_new input guard + de-magic softplus threshold

ADR-155 §3 found rf_encoder.rs has no adversarial checkpoint-deserialization
assert — its assert_eq!s in LinearHead::new are construction-time API contracts
on programmer-supplied vectors. This adds the honest, in-scope improvement the
M2 task allows: a pure-Rust *fallible* constructor so weights from an untrusted /
deserialized checkpoint can be shape-validated without panicking.

- Add RfHeadError (WeightShape / BiasShape / VarWeightShape) + Display + Error.
- Add LinearHead::try_new returning Result<Self, RfHeadError>; on success the
  head is byte-identical to LinearHead::new. new() is unchanged (still asserts;
  now documents # Panics and points to try_new) — no behaviour change for
  existing callers.
- De-magic softplus's bare 20.0 overflow threshold into
  SOFTPLUS_LINEAR_THRESHOLD (value unchanged) + pin test.

Tests: try_new_accepts_valid_and_rejects_each_bad_shape (valid == new forward;
each bad shape → typed error), softplus_threshold_unchanged_from_literal.

nn --no-default-features lib: 37 passed (was 35), 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* perf(nn): ADR-155 M2 §4 — native-conv bench-first → MEASURED-INCONCLUSIVE (no perf change shipped)

The §8 "native-conv naive-loop rewrite" backlog item: DensePoseHead::
apply_conv_layer is a pure-Rust 6-nested-loop conv (benchable on this host, not
tch/ort-gated). Bench-first per the §0 PROOF discipline.

- Add committed criterion bench benches/native_conv_bench.rs measuring forward()
  through the naive conv on representative single-layer configs (--no-default-
  features; no ort download).
- Prototyped a bit-identical range-clamped variant (hoist the per-tap in-bounds
  branch by pre-clamping kh/kw ranges; same ic→kh→kw MAC order ⇒ bit-identical).
  MEASURED before/after on this host: ~35% faster on padding-heavy small-channel
  maps (4.40→2.84 ms) but a ~3% *regression* on channel-heavy maps (11.09→11.48
  ms), all inside a ±20% run-to-run noise floor. Verdict: INCONCLUSIVE — the
  benefit is not robustly positive, so the rewrite is NOT shipped and NOT a
  fabricated speedup. Reverted to the naive loop; honestly deferred (ADR-155 §8).
- Add native_conv_matches_reference: a hand-computed characterization anchor
  (1×1 = scalar MAC; same-padded 3×3 ones = truncated-window sums 9/6/4) pinning
  CURRENT conv behaviour for any future rewrite.

nn --no-default-features lib: 38 passed (was 37), 0 failed. No behaviour change.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-155): M2 §8.2 — enumerated host-verifiable P3 backlog clearance + CHANGELOG

Replace the §8 bulk "~40 lower-severity findings" line with the real, enumerated
M2 resolution (§8.2): 7 de-magicked (const + pin == prior literal), 9 boundary
tests, 1 input guard (rf_encoder try_new), 2 doc-only, 1 perf bench-first
MEASURED-INCONCLUSIVE (not shipped). Mark native-conv + rf_encoder RESOLVED;
state which §8 items stay data-gated (GraphPose-Fi/INT4/CSI-JEPA) or tch-gated
(proof/trainer/model panic sites, metrics *_v2 dead code) and ONNX read-lock
upstream-gated — blocked, not dropped. Declare the non-tch-verifiable subset of
§8 cleared.

Validation: train --no-default-features 303 passed (was 288); nn lib 38 (was 35);
workspace --no-default-features 3,293 passed, 0 failed; Python proof VERDICT PASS,
hash f8e76f21…46f7a UNCHANGED bit-exact.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 00:07:56 -04:00

258 lines
8.6 KiB
Rust

//! Cross-domain evaluation metrics (MERIDIAN Phase 6).
//!
//! MPJPE, domain gap ratio, and adaptation speedup for measuring how well a
//! WiFi-DensePose model generalizes across environments and hardware.
use std::collections::HashMap;
/// Smallest in-domain / few-shot MPJPE treated as positive before it divides a
/// ratio. Below this the denominator is considered ≈0 and the ratio falls back
/// to a sentinel (`1.0` or `INFINITY`) rather than dividing by ≈0 (ADR-155 M2
/// §8: de-magicked from a bare `1e-10`; value unchanged, no behaviour change).
const MIN_POSITIVE_MPJPE: f32 = 1e-10;
/// Aggregated cross-domain evaluation metrics.
#[derive(Debug, Clone)]
pub struct CrossDomainMetrics {
/// In-domain (source) MPJPE (mm).
pub in_domain_mpjpe: f32,
/// Cross-domain (unseen environment) MPJPE (mm).
pub cross_domain_mpjpe: f32,
/// MPJPE after few-shot adaptation (mm).
pub few_shot_mpjpe: f32,
/// MPJPE across different WiFi hardware (mm).
pub cross_hardware_mpjpe: f32,
/// cross-domain / in-domain MPJPE. Target: < 1.5.
pub domain_gap_ratio: f32,
/// Labelled-sample savings vs training from scratch.
pub adaptation_speedup: f32,
}
/// Evaluates pose estimation across multiple domains.
///
/// Domain 0 = in-domain (source); other IDs = cross-domain.
///
/// ```rust
/// use wifi_densepose_train::eval::{CrossDomainEvaluator, mpjpe};
/// let ev = CrossDomainEvaluator::new(17);
/// let preds = vec![(vec![0.0_f32; 51], vec![0.0_f32; 51])];
/// let m = ev.evaluate(&preds, &[0]);
/// assert!(m.in_domain_mpjpe >= 0.0);
/// ```
pub struct CrossDomainEvaluator {
n_joints: usize,
}
impl CrossDomainEvaluator {
/// Create evaluator for `n_joints` body joints (e.g. 17 for COCO).
pub fn new(n_joints: usize) -> Self {
Self { n_joints }
}
/// Evaluate predictions grouped by domain. Each pair is (predicted, gt)
/// with `n_joints * 3` floats. `domain_labels` must match length.
pub fn evaluate(
&self,
predictions: &[(Vec<f32>, Vec<f32>)],
domain_labels: &[u32],
) -> CrossDomainMetrics {
assert_eq!(predictions.len(), domain_labels.len(), "length mismatch");
let mut by_dom: HashMap<u32, Vec<f32>> = HashMap::new();
for (i, (p, g)) in predictions.iter().enumerate() {
by_dom
.entry(domain_labels[i])
.or_default()
.push(mpjpe(p, g, self.n_joints));
}
let in_dom = mean_of(by_dom.get(&0));
let cross_errs: Vec<f32> = by_dom
.iter()
.filter(|(&d, _)| d != 0)
.flat_map(|(_, e)| e.iter().copied())
.collect();
let cross_dom = if cross_errs.is_empty() {
0.0
} else {
cross_errs.iter().sum::<f32>() / cross_errs.len() as f32
};
let few_shot = if by_dom.contains_key(&2) {
mean_of(by_dom.get(&2))
} else {
(in_dom + cross_dom) / 2.0
};
let cross_hw = if by_dom.contains_key(&3) {
mean_of(by_dom.get(&3))
} else {
cross_dom
};
let gap = if in_dom > MIN_POSITIVE_MPJPE {
cross_dom / in_dom
} else if cross_dom > MIN_POSITIVE_MPJPE {
f32::INFINITY
} else {
1.0
};
let speedup = if few_shot > MIN_POSITIVE_MPJPE {
cross_dom / few_shot
} else {
1.0
};
CrossDomainMetrics {
in_domain_mpjpe: in_dom,
cross_domain_mpjpe: cross_dom,
few_shot_mpjpe: few_shot,
cross_hardware_mpjpe: cross_hw,
domain_gap_ratio: gap,
adaptation_speedup: speedup,
}
}
}
/// Mean Per Joint Position Error: average Euclidean distance across `n_joints`.
///
/// `pred` and `gt` are flat `[n_joints * 3]` (x, y, z per joint).
pub fn mpjpe(pred: &[f32], gt: &[f32], n_joints: usize) -> f32 {
if n_joints == 0 {
return 0.0;
}
let total: f32 = (0..n_joints)
.map(|j| {
let b = j * 3;
let d = |off| {
pred.get(b + off).copied().unwrap_or(0.0) - gt.get(b + off).copied().unwrap_or(0.0)
};
(d(0).powi(2) + d(1).powi(2) + d(2).powi(2)).sqrt()
})
.sum();
total / n_joints as f32
}
fn mean_of(v: Option<&Vec<f32>>) -> f32 {
match v {
Some(e) if !e.is_empty() => e.iter().sum::<f32>() / e.len() as f32,
_ => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ADR-155 M2 §8: the de-magicked division-guard floor must equal the prior
/// inline `1e-10` literal exactly (operating-value guard).
#[test]
fn eval_min_positive_mpjpe_unchanged_from_literal() {
assert_eq!(MIN_POSITIVE_MPJPE, 1e-10_f32);
}
/// Characterize the `in_dom ≈ 0` boundary: a perfect in-domain fit but
/// nonzero cross-domain error yields the `INFINITY` gap sentinel (the
/// middle branch), not a divide-by-≈0 NaN.
#[test]
fn domain_gap_infinite_when_in_domain_perfect_but_cross_nonzero() {
let ev = CrossDomainEvaluator::new(1);
let preds = vec![
(vec![1.0, 2.0, 3.0], vec![1.0, 2.0, 3.0]), // dom 0: err 0
(vec![0.0, 0.0, 0.0], vec![2.0, 0.0, 0.0]), // dom 1: err 2
];
let m = ev.evaluate(&preds, &[0, 1]);
assert!((m.in_domain_mpjpe).abs() < MIN_POSITIVE_MPJPE);
assert!(m.domain_gap_ratio.is_infinite());
}
/// Characterize the all-perfect boundary: in-domain AND cross-domain both ≈0
/// ⇒ gap falls back to the `1.0` sentinel (the final else branch), never NaN.
#[test]
fn domain_gap_unity_when_everything_perfect() {
let ev = CrossDomainEvaluator::new(1);
let preds = vec![
(vec![1.0, 2.0, 3.0], vec![1.0, 2.0, 3.0]),
(vec![4.0, 5.0, 6.0], vec![4.0, 5.0, 6.0]),
];
let m = ev.evaluate(&preds, &[0, 1]);
assert!((m.domain_gap_ratio - 1.0).abs() < 1e-6);
// few_shot derived = (0+0)/2 = 0 ⇒ speedup also falls back to 1.0.
assert!((m.adaptation_speedup - 1.0).abs() < 1e-6);
}
#[test]
fn mpjpe_known_value() {
assert!((mpjpe(&[0.0, 0.0, 0.0], &[3.0, 4.0, 0.0], 1) - 5.0).abs() < 1e-6);
}
#[test]
fn mpjpe_two_joints() {
// Joint 0: dist=5, Joint 1: dist=0 -> mean=2.5
assert!(
(mpjpe(
&[0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
&[3.0, 4.0, 0.0, 1.0, 1.0, 1.0],
2
) - 2.5)
.abs()
< 1e-6
);
}
#[test]
fn mpjpe_zero_when_identical() {
let c = vec![1.5, 2.3, 0.7, 4.1, 5.9, 3.2];
assert!(mpjpe(&c, &c, 2).abs() < 1e-10);
}
#[test]
fn mpjpe_zero_joints() {
assert_eq!(mpjpe(&[], &[], 0), 0.0);
}
#[test]
fn domain_gap_ratio_computed() {
let ev = CrossDomainEvaluator::new(1);
let preds = vec![
(vec![0.0, 0.0, 0.0], vec![1.0, 0.0, 0.0]), // dom 0, err=1
(vec![0.0, 0.0, 0.0], vec![2.0, 0.0, 0.0]), // dom 1, err=2
];
let m = ev.evaluate(&preds, &[0, 1]);
assert!((m.in_domain_mpjpe - 1.0).abs() < 1e-6);
assert!((m.cross_domain_mpjpe - 2.0).abs() < 1e-6);
assert!((m.domain_gap_ratio - 2.0).abs() < 1e-6);
}
#[test]
fn evaluate_groups_by_domain() {
let ev = CrossDomainEvaluator::new(1);
let preds = vec![
(vec![0.0, 0.0, 0.0], vec![1.0, 0.0, 0.0]),
(vec![0.0, 0.0, 0.0], vec![3.0, 0.0, 0.0]),
(vec![0.0, 0.0, 0.0], vec![5.0, 0.0, 0.0]),
];
let m = ev.evaluate(&preds, &[0, 0, 1]);
assert!((m.in_domain_mpjpe - 2.0).abs() < 1e-6);
assert!((m.cross_domain_mpjpe - 5.0).abs() < 1e-6);
}
#[test]
fn domain_gap_perfect() {
let ev = CrossDomainEvaluator::new(1);
let preds = vec![
(vec![1.0, 2.0, 3.0], vec![1.0, 2.0, 3.0]),
(vec![4.0, 5.0, 6.0], vec![4.0, 5.0, 6.0]),
];
assert!((ev.evaluate(&preds, &[0, 1]).domain_gap_ratio - 1.0).abs() < 1e-6);
}
#[test]
fn evaluate_multiple_cross_domains() {
let ev = CrossDomainEvaluator::new(1);
let preds = vec![
(vec![0.0, 0.0, 0.0], vec![1.0, 0.0, 0.0]),
(vec![0.0, 0.0, 0.0], vec![4.0, 0.0, 0.0]),
(vec![0.0, 0.0, 0.0], vec![6.0, 0.0, 0.0]),
];
let m = ev.evaluate(&preds, &[0, 1, 3]);
assert!((m.in_domain_mpjpe - 1.0).abs() < 1e-6);
assert!((m.cross_domain_mpjpe - 5.0).abs() < 1e-6);
assert!((m.cross_hardware_mpjpe - 6.0).abs() < 1e-6);
}
}