wifi-densepose/v2/crates/wifi-densepose-train/src/occupancy_bench.rs

669 lines
25 KiB
Rust

//! Falsifiable occupancy / presence benchmark over labeled CSI sequences.
//!
//! The beyond-SOTA system review found that "beyond SOTA" was *unfalsifiable*:
//! no real-CSI ground-truth benchmark existed, and the eval pyramid (doc 03)
//! lists the field's recurring measurement frauds — subject leakage between
//! train/test, per-environment overfitting, and **mock-mode contamination**
//! (CLAUDE.md: mock missed a real Kconfig bug).
//!
//! This module makes the claim falsifiable. It **grades** predictions against
//! ground truth (it does not run a model — keeping the eval crate light and the
//! scoring model-agnostic), and it enforces, *structurally*, the discipline
//! that prevents overclaiming:
//!
//! 1. **No SOTA claim on non-measured data.** A dataset is tagged
//! [`DataProvenance`]; only [`DataProvenance::Measured`] can release a claim.
//! Synthetic/Mock data can still be scored (useful for CI/regression) but the
//! [`ClaimGate`] returns [`NO_CLAIM`] — you cannot accidentally publish a
//! "beyond SOTA" number computed on simulated CSI.
//! 2. **No leaky splits.** [`EvalSplit::validate`] refuses a split where any
//! subject *or* environment id appears in both train and test.
//! 3. **Pre-registered thresholds + bootstrap CI.** The gate compares the
//! *lower* bound of a deterministic 95% bootstrap CI, not the point estimate,
//! so a lucky small-sample result cannot pass.
//! 4. **No degenerate test sets.** The test set must contain *both* truth
//! classes (present-rate ≥ `min_positive_rate`, and at least one absent
//! sample), with its own failure flag — an all-absent set plus an
//! always-absent predictor must never release a claim. Vacuous F1 (no
//! positives anywhere in the confusion) scores **0.0**, never 1.0.
//!
//! The harness is the same shape as the `ruview-gamma` acceptance gate: a single
//! `claim_allowed` invariant, and the claim string is unreadable except through
//! the gate.
use std::collections::BTreeSet;
/// Provenance of the labeled data a benchmark runs on. Gates whether a SOTA
/// claim is releasable at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataProvenance {
/// Real CSI captured from hardware with independent ground truth. The only
/// provenance that can release a claim.
Measured,
/// Deterministic synthetic CSI (e.g. the proof generator). Scorable for
/// regression, never claimable.
Synthetic,
/// Mock/stub data path. Scorable, never claimable — mock contamination is a
/// documented failure mode (CLAUDE.md Kconfig-bug lesson).
Mock,
}
impl DataProvenance {
/// Whether data of this provenance may ever release a SOTA/accuracy claim.
pub fn is_claimable(self) -> bool {
matches!(self, DataProvenance::Measured)
}
/// Stable lowercase tag for logs/reports.
pub fn tag(self) -> &'static str {
match self {
DataProvenance::Measured => "measured",
DataProvenance::Synthetic => "synthetic",
DataProvenance::Mock => "mock",
}
}
}
/// The research-only string returned when a claim is withheld.
pub const NO_CLAIM: &str = "research use only — not claimable (non-measured data, leaky split, or unmet thresholds)";
/// Ground-truth / predicted occupancy for one sample.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Occupancy {
/// Whether any person is present.
pub present: bool,
/// Estimated number of people.
pub person_count: u32,
}
impl Occupancy {
/// Construct an occupancy label.
pub fn new(present: bool, person_count: u32) -> Self {
Self { present, person_count }
}
}
/// One labeled, attributed evaluation sample: who/where it came from (for
/// leakage checks) and the ground-truth vs predicted occupancy.
#[derive(Debug, Clone)]
pub struct LabeledSample {
/// Subject identity (for subject-disjoint split enforcement).
pub subject_id: String,
/// Capture environment/room (for environment-disjoint split enforcement).
pub environment_id: String,
/// Ground-truth occupancy.
pub truth: Occupancy,
/// Model-predicted occupancy.
pub predicted: Occupancy,
}
/// A train/test split by sample index, with leakage validation.
#[derive(Debug, Clone)]
pub struct EvalSplit {
/// Indices of training samples.
pub train_idx: Vec<usize>,
/// Indices of held-out test samples (graded).
pub test_idx: Vec<usize>,
}
/// Why a split is rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SplitError {
/// A subject id appears in both train and test (subject leakage).
SubjectLeakage(String),
/// An environment id appears in both (per-environment overfitting risk).
EnvironmentLeakage(String),
/// An index is out of range for the sample set.
IndexOutOfRange(usize),
/// The test set is empty.
EmptyTest,
}
impl EvalSplit {
/// Validate the split against `samples`: every test subject/environment must
/// be **disjoint** from the training set. This is the single most common
/// way WiFi-sensing papers overstate accuracy (doc 03).
pub fn validate(&self, samples: &[LabeledSample]) -> Result<(), SplitError> {
if self.test_idx.is_empty() {
return Err(SplitError::EmptyTest);
}
for &i in self.train_idx.iter().chain(&self.test_idx) {
if i >= samples.len() {
return Err(SplitError::IndexOutOfRange(i));
}
}
let train_subjects: BTreeSet<&str> =
self.train_idx.iter().map(|&i| samples[i].subject_id.as_str()).collect();
let train_envs: BTreeSet<&str> =
self.train_idx.iter().map(|&i| samples[i].environment_id.as_str()).collect();
for &i in &self.test_idx {
let s = &samples[i];
if train_subjects.contains(s.subject_id.as_str()) {
return Err(SplitError::SubjectLeakage(s.subject_id.clone()));
}
if train_envs.contains(s.environment_id.as_str()) {
return Err(SplitError::EnvironmentLeakage(s.environment_id.clone()));
}
}
Ok(())
}
}
/// Pre-registered acceptance thresholds (doc 03 acceptance table). Defaults are
/// deliberately conservative; tighten per capability axis.
#[derive(Debug, Clone, Copy)]
pub struct BenchmarkCriteria {
/// Minimum presence F1 (lower CI bound must clear this).
pub min_presence_f1: f64,
/// Maximum person-count mean absolute error.
pub max_count_mae: f64,
/// Minimum test samples to grade at all (small-N guard).
pub min_test_samples: usize,
/// Minimum fraction of ground-truth **present** samples in the test set
/// (degenerate-test-set guard, review finding 2): an all-absent (or
/// nearly all-absent) test set makes presence F1 vacuous — an
/// always-absent predictor must not be able to release a claim. The gate
/// additionally requires at least one ground-truth *absent* sample, so
/// both classes must be represented.
pub min_positive_rate: f64,
/// Bootstrap resamples for the CI.
pub bootstrap_iters: usize,
/// Deterministic bootstrap seed.
pub bootstrap_seed: u64,
}
impl Default for BenchmarkCriteria {
fn default() -> Self {
Self {
min_presence_f1: 0.9,
max_count_mae: 0.5,
min_test_samples: 30,
min_positive_rate: 0.1,
bootstrap_iters: 1000,
bootstrap_seed: 42,
}
}
}
/// The graded result.
#[derive(Debug, Clone, PartialEq)]
pub struct BenchmarkReport {
/// Data provenance tag (`measured`/`synthetic`/`mock`).
pub provenance_tag: &'static str,
/// Number of held-out test samples graded.
pub n_test: usize,
/// Presence accuracy (TP+TN)/N.
pub presence_accuracy: f64,
/// Presence F1 (point estimate).
pub presence_f1: f64,
/// 95% bootstrap CI for presence F1 (lower, upper).
pub presence_f1_ci: (f64, f64),
/// Fraction of samples with an exactly correct person count.
pub count_exact_match: f64,
/// Person-count mean absolute error.
pub count_mae: f64,
/// Data is measured (claimable provenance).
pub provenance_pass: bool,
/// Split is leak-free (subject- and environment-disjoint).
pub split_pass: bool,
/// Presence F1 CI-lower clears the threshold.
pub presence_pass: bool,
/// Count MAE within the threshold.
pub count_pass: bool,
/// Test set is large enough to grade.
pub sample_size_pass: bool,
/// Test set contains both truth classes with at least `min_positive_rate`
/// present-true samples (degenerate test set ⇒ fail, own failure reason).
pub class_balance_pass: bool,
/// All six criteria pass.
pub overall_pass: bool,
/// The released claim string (or [`NO_CLAIM`]).
pub released_claim: String,
}
impl BenchmarkReport {
/// The released claim string (program claim on pass, [`NO_CLAIM`] on fail).
pub fn claim(&self) -> &str {
&self.released_claim
}
}
/// **The single claim invariant.** A SOTA/accuracy claim is releasable only when
/// the data is measured, the split is leak-free, the sample is large enough,
/// the test set is non-degenerate (both classes represented), and both the
/// (CI-lower) presence F1 and the count MAE clear their thresholds.
#[inline]
pub fn claim_allowed(
provenance_pass: bool,
split_pass: bool,
sample_size_pass: bool,
class_balance_pass: bool,
presence_pass: bool,
count_pass: bool,
) -> bool {
provenance_pass
&& split_pass
&& sample_size_pass
&& class_balance_pass
&& presence_pass
&& count_pass
}
/// Grade the test split of `samples` under `criteria`.
///
/// `split` is validated first; on any leakage the report is marked invalid and
/// the claim is withheld (metrics are still computed for visibility).
pub fn evaluate(
samples: &[LabeledSample],
provenance: DataProvenance,
split: &EvalSplit,
criteria: &BenchmarkCriteria,
) -> BenchmarkReport {
let split_pass = split.validate(samples).is_ok();
let test: Vec<&LabeledSample> = split
.test_idx
.iter()
.filter(|&&i| i < samples.len())
.map(|&i| &samples[i])
.collect();
let n_test = test.len();
// Presence confusion counts.
let (mut tp, mut fp, mut tn, mut fn_) = (0u64, 0u64, 0u64, 0u64);
let mut count_abs_err_sum = 0.0;
let mut count_exact = 0u64;
let mut truth_present = 0u64;
for s in &test {
if s.truth.present {
truth_present += 1;
}
match (s.predicted.present, s.truth.present) {
(true, true) => tp += 1,
(true, false) => fp += 1,
(false, false) => tn += 1,
(false, true) => fn_ += 1,
}
count_abs_err_sum +=
(s.predicted.person_count as f64 - s.truth.person_count as f64).abs();
if s.predicted.person_count == s.truth.person_count {
count_exact += 1;
}
}
let presence_accuracy = if n_test > 0 {
(tp + tn) as f64 / n_test as f64
} else {
0.0
};
let presence_f1 = f1_from_confusion(tp, fp, fn_);
let count_mae = if n_test > 0 {
count_abs_err_sum / n_test as f64
} else {
f64::INFINITY
};
let count_exact_match = if n_test > 0 {
count_exact as f64 / n_test as f64
} else {
0.0
};
let presence_f1_ci = bootstrap_f1_ci(&test, criteria.bootstrap_iters, criteria.bootstrap_seed);
let provenance_pass = provenance.is_claimable();
let sample_size_pass = n_test >= criteria.min_test_samples;
// Degenerate-test-set guard (review finding 2): both truth classes must be
// represented — at least `min_positive_rate` present samples AND at least
// one absent sample. Otherwise the F1/accuracy numbers are vacuous (an
// all-absent set is aced by a predictor that always says "absent").
let positive_rate = if n_test > 0 {
truth_present as f64 / n_test as f64
} else {
0.0
};
let class_balance_pass =
n_test > 0 && positive_rate >= criteria.min_positive_rate && truth_present < n_test as u64;
// Gate on the LOWER CI bound, not the point estimate (small-N guard).
let presence_pass = presence_f1_ci.0 >= criteria.min_presence_f1;
let count_pass = count_mae <= criteria.max_count_mae;
let overall_pass = claim_allowed(
provenance_pass,
split_pass,
sample_size_pass,
class_balance_pass,
presence_pass,
count_pass,
);
let released_claim = if overall_pass {
format!(
"presence F1 {:.3} (95% CI {:.3}-{:.3}), count MAE {:.3} on {} held-out measured samples",
presence_f1, presence_f1_ci.0, presence_f1_ci.1, count_mae, n_test
)
} else {
NO_CLAIM.to_string()
};
BenchmarkReport {
provenance_tag: provenance.tag(),
n_test,
presence_accuracy,
presence_f1,
presence_f1_ci,
count_exact_match,
count_mae,
provenance_pass,
split_pass,
presence_pass,
count_pass,
sample_size_pass,
class_balance_pass,
overall_pass,
released_claim,
}
}
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
let denom = 2 * tp + fp + fn_;
if denom == 0 {
// No positives anywhere (tp = fp = fn = 0): F1 is undefined, and the
// vacuous case must score 0.0, never 1.0 — an all-absent test set plus
// an always-absent predictor was previously awarded a perfect F1
// (review finding 2). The class-balance criterion independently fails
// such a degenerate set with its own reason.
return 0.0;
}
(2 * tp) as f64 / denom as f64
}
/// Deterministic 95% bootstrap CI for presence F1 (percentile method) using a
/// small splitmix64 PRNG — no external rng, reproducible across machines.
fn bootstrap_f1_ci(test: &[&LabeledSample], iters: usize, seed: u64) -> (f64, f64) {
let n = test.len();
if n == 0 || iters == 0 {
return (0.0, 0.0);
}
let mut state = seed;
let mut next = || {
// splitmix64
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
let mut f1s = Vec::with_capacity(iters);
for _ in 0..iters {
let (mut tp, mut fp, mut fn_) = (0u64, 0u64, 0u64);
for _ in 0..n {
let idx = (next() % n as u64) as usize;
let s = test[idx];
match (s.predicted.present, s.truth.present) {
(true, true) => tp += 1,
(true, false) => fp += 1,
(false, true) => fn_ += 1,
(false, false) => {}
}
}
f1s.push(f1_from_confusion(tp, fp, fn_));
}
f1s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let pct = |q: f64| {
let rank = ((q * (f1s.len() as f64 - 1.0)).round() as usize).min(f1s.len() - 1);
f1s[rank]
};
(pct(0.025), pct(0.975))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(subj: &str, env: &str, t: (bool, u32), p: (bool, u32)) -> LabeledSample {
LabeledSample {
subject_id: subj.into(),
environment_id: env.into(),
truth: Occupancy::new(t.0, t.1),
predicted: Occupancy::new(p.0, p.1),
}
}
/// A perfect predictor on a leak-free MEASURED split releases a claim.
fn perfect_measured(n: usize) -> (Vec<LabeledSample>, EvalSplit) {
let mut samples = Vec::new();
// train subjects s0.., test subjects t0.. (disjoint); envs likewise.
for i in 0..n {
samples.push(sample(
&format!("train-s{i}"),
&format!("train-e{i}"),
(i % 2 == 0, (i % 3) as u32),
(i % 2 == 0, (i % 3) as u32),
));
}
for i in 0..n {
samples.push(sample(
&format!("test-s{i}"),
&format!("test-e{i}"),
(i % 2 == 0, (i % 3) as u32),
(i % 2 == 0, (i % 3) as u32),
));
}
let split = EvalSplit {
train_idx: (0..n).collect(),
test_idx: (n..2 * n).collect(),
};
(samples, split)
}
#[test]
fn perfect_measured_releases_claim() {
let (samples, split) = perfect_measured(40);
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(r.overall_pass);
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
assert_eq!(r.count_mae, 0.0);
assert!(r.released_claim.contains("F1"));
assert!(!r.released_claim.contains("research use only"));
}
#[test]
fn synthetic_data_is_scored_but_never_claimed() {
let (samples, split) = perfect_measured(40);
let r = evaluate(&samples, DataProvenance::Synthetic, &split, &BenchmarkCriteria::default());
// Metrics are still computed...
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
// ...but no claim, because the data is not measured.
assert!(!r.provenance_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn mock_data_is_never_claimed() {
let (samples, split) = perfect_measured(40);
let r = evaluate(&samples, DataProvenance::Mock, &split, &BenchmarkCriteria::default());
assert!(!r.provenance_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn subject_leakage_is_rejected() {
// Same subject id in train and test.
let samples = vec![
sample("shared", "e0", (true, 1), (true, 1)),
sample("shared", "e1", (true, 1), (true, 1)),
];
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
assert_eq!(
split.validate(&samples),
Err(SplitError::SubjectLeakage("shared".into()))
);
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(!r.split_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn environment_leakage_is_rejected() {
let samples = vec![
sample("s0", "shared-room", (true, 1), (true, 1)),
sample("s1", "shared-room", (true, 1), (true, 1)),
];
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
assert_eq!(
split.validate(&samples),
Err(SplitError::EnvironmentLeakage("shared-room".into()))
);
}
#[test]
fn small_sample_is_withheld_even_if_perfect() {
let (samples, split) = perfect_measured(5); // 5 < default min 30
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(!r.sample_size_pass);
assert!(!r.overall_pass);
}
/// The probative CI-gate case (review finding 10): a test set whose POINT
/// F1 clears the 0.9 threshold while the bootstrap CI LOWER bound falls
/// below it — the claim must be withheld. A point-estimate gate would
/// (wrongly) release here.
#[test]
fn gate_uses_ci_lower_bound_not_point_estimate() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(
&format!("train-{i}"),
&format!("te-{i}"),
(i % 2 == 0, 1),
(i % 2 == 0, 1),
));
}
// Test: 20 truth-present / 20 truth-absent (class-balanced). All
// absents predicted correctly; 3 of the 20 presents missed (FN).
// Point F1 = 2·17/(2·17 + 0 + 3) = 34/37 ≈ 0.919 ≥ 0.9, but resamples
// drawing 4+ of the FNs push F1 below 0.9, so the 2.5th percentile
// lands under the threshold.
for i in 0..40 {
let truth_present = i < 20;
let predicted_present = truth_present && i >= 3; // i 0..3 → FN
samples.push(sample(
&format!("test-{i}"),
&format!("tn-{i}"),
(truth_present, u32::from(truth_present)),
(predicted_present, u32::from(truth_present)),
));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let criteria = BenchmarkCriteria::default();
let r = evaluate(&samples, DataProvenance::Measured, &split, &criteria);
// Construct verified: point estimate above the threshold...
assert!(
r.presence_f1 >= criteria.min_presence_f1,
"fixture must put the point estimate ({:.3}) above the threshold",
r.presence_f1
);
// ...while the CI lower bound is below it...
assert!(
r.presence_f1_ci.0 < criteria.min_presence_f1,
"fixture must put the CI lower bound ({:.3}) below the threshold",
r.presence_f1_ci.0
);
// ...and the claim is therefore withheld.
assert!(!r.presence_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
// Every other criterion passes, isolating the CI gate as the cause.
assert!(r.provenance_pass && r.split_pass && r.sample_size_pass);
assert!(r.class_balance_pass && r.count_pass);
}
/// Degenerate test set (review finding 2): all-absent ground truth plus an
/// always-absent predictor must NOT release a claim — F1 is vacuous (0.0,
/// not 1.0) and the class-balance criterion fails with its own flag.
#[test]
fn all_absent_test_set_is_degenerate_and_withheld() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
}
for i in 0..40 {
// Truth all absent; predictor always says absent → tp=fp=fn=0.
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (false, 0), (false, 0)));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
// Vacuous F1 scores 0.0 (was 1.0 before the fix).
assert_eq!(r.presence_f1, 0.0);
assert_eq!(r.presence_f1_ci, (0.0, 0.0));
// Degeneracy is named as its own failed criterion.
assert!(!r.class_balance_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
/// The mirror degeneracy: an all-PRESENT test set (no absent samples) is
/// also refused — a trivially always-present predictor would ace it.
#[test]
fn all_present_test_set_is_degenerate_and_withheld() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (i % 2 == 0, 1), (i % 2 == 0, 1)));
}
for i in 0..40 {
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (true, 1), (true, 1)));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!((r.presence_f1 - 1.0).abs() < 1e-9, "metric still computed");
assert!(!r.class_balance_pass, "single-class test set is degenerate");
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn bootstrap_ci_is_deterministic() {
let (samples, split) = perfect_measured(40);
let a = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
let b = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert_eq!(a.presence_f1_ci, b.presence_f1_ci);
}
#[test]
fn count_mae_failure_withholds_claim() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
}
// Class-balanced test set (so count MAE is the ONLY failing criterion):
// presence perfect, but the count is always off by 2 -> MAE 2.0 > 0.5.
for i in 0..40 {
let present = i % 2 == 0;
let truth_count = u32::from(present);
samples.push(sample(
&format!("ts-{i}"),
&format!("ev-{i}"),
(present, truth_count),
(present, truth_count + 2),
));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(r.presence_pass);
assert!(r.class_balance_pass);
assert!(!r.count_pass);
assert!(!r.overall_pass);
}
#[test]
fn claim_invariant_requires_all_six() {
assert!(claim_allowed(true, true, true, true, true, true));
// Every single-false combination is denied.
for i in 0..6 {
let v: Vec<bool> = (0..6).map(|j| j != i).collect();
assert!(
!claim_allowed(v[0], v[1], v[2], v[3], v[4], v[5]),
"criterion {i} false must deny the claim"
);
}
}
}