wifi-densepose/v2/crates/wifi-densepose-sensing-server/tests/introspection_latency.rs

217 lines
9.3 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.

//! ADR-099 D8 benchmark — latency-floor measurement for the introspection tap
//! vs. the window-aggregated event pipeline.
//!
//! What this measures (and what it doesn't):
//!
//! * It measures the **architectural floor** of each detection path:
//! - The window path's *soonest possible* `MotionDetected` emission is gated
//! by `WindowBuffer::new(16, 1 s)` + `MotionDetector::debounce_windows = 2`
//! = a known function of frames. No simulation of the EventPipeline is
//! needed for that floor — it's a deterministic count.
//! - The introspection path's "shape recognised" emission fires the first
//! frame after which `IntrospectionState::snapshot().top_k_similarity[0]
//! .above_threshold` is `true`. That's what we measure empirically.
//! * It does *not* measure signature-library quality, DTW recall, or false
//! positives — those are P1 / P3 concerns. The bar this test checks is
//! D8's architectural latency-floor reduction (≥10× p99) on a clean
//! in-phase shape.
//! * Per-frame `update()` wall-clock cost is also asserted (D4: ≤1 ms p99 on
//! a Pi-5-class host; checked here against a 10 ms loose bound that any
//! reasonable dev box should clear, leaving thermal/CI noise headroom).
//!
//! Numbers print at INFO level so `cargo test -- --nocapture` shows the
//! comparison directly.
use std::time::Instant;
use wifi_densepose_sensing_server::introspection::{
IntrospectionConfig, IntrospectionState, Signature, SignatureDtw, SignatureLibrary,
};
/// The EventPipeline floor in frames at 30 Hz CSI:
/// 16-frame window + 2 windows of motion debounce = 48 frames *worst case*,
/// 16 frames *best case* (the perturbation arrives at frame 1, window closes
/// at frame 16, the *first* MotionDetected can fire then — but the detector
/// needs 2 consecutive high windows to debounce, so the realistic emission
/// sits between 16 and 48 frames).
///
/// We use the **best-case** floor here so the ratio is *conservative* — i.e.
/// the introspection win has to clear the bar even against the most generous
/// reading of the event path.
const EVENT_PATH_BEST_CASE_FRAMES: usize = 16;
/// ADR-099 D8 bar: ≥10× p99 latency reduction.
const D8_LATENCY_RATIO_BAR: f64 = 10.0;
/// ADR-099 D4 bar: per-frame update ≤ 1 ms p99 on a Pi-5-class host. CI runners
/// vary, so we assert a loose 10 ms ceiling here that still catches real
/// regressions (a midstream API change that pushes update() to 100 ms would
/// blow through this trivially) while leaving headroom for cold-cache /
/// thermally-throttled CI machines.
const PER_FRAME_BUDGET_MS: f64 = 10.0;
fn motion_signature() -> Signature {
// A clean, short, monotonic ramp — exactly the kind of shape the host-side
// L1 stand-in in `signature_score()` scores well on (and that DTW on real
// vec128 will continue to score well on later).
Signature {
id: "motion_ramp".to_string(),
label: "Motion ramp (benchmark fixture)".to_string(),
vectors: vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0], vec![5.0]],
dtw: SignatureDtw {
window: 8,
step_pattern: "symmetric2".to_string(),
},
promotion_threshold: 0.70,
}
}
/// Feed N background-noise frames followed by the motion ramp; return the
/// 0-based frame index at which the snapshot first reports `above_threshold`.
fn frames_until_shape_recognised() -> (usize, Vec<f64>) {
let lib = SignatureLibrary::from_signatures(vec![motion_signature()]);
let cfg = IntrospectionConfig {
trajectory_len: 128,
embedding_dim: 1,
analyze_every_n: 8,
library: lib,
};
let mut state = IntrospectionState::with_config(cfg);
// 100 frames of background noise — small drifty values around 0.
let mut frame_idx = 0usize;
let mut update_ms = Vec::with_capacity(125);
for k in 0..100u64 {
let t0 = Instant::now();
let v = 0.05 * ((k as f64 * 0.31).sin()); // ±0.05 deterministic noise
state.update(k * 33_000_000, v).unwrap();
update_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
assert!(
!state.snapshot().top_k_similarity[0].above_threshold,
"noise frame {k} crossed threshold — signature is too lax for this test"
);
frame_idx += 1;
}
// Now feed the motion ramp. Record the *first* frame whose snapshot says
// `above_threshold` — that's the introspection-path latency in frames.
let mut frames_to_recognise: Option<usize> = None;
for (i, v) in [1.0f64, 2.0, 3.0, 4.0, 5.0, 5.0, 5.0, 5.0]
.iter()
.copied()
.enumerate()
{
let t0 = Instant::now();
state.update((100 + i as u64) * 33_000_000, v).unwrap();
update_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
if state.snapshot().top_k_similarity[0].above_threshold {
frames_to_recognise = Some(i + 1); // +1 → frames *into* the shape
break;
}
frame_idx += 1;
}
let n = frames_to_recognise
.expect("introspection path should recognise the motion ramp within 8 frames");
(n, update_ms)
}
#[test]
fn introspection_recognises_shape_within_window_floor() {
let (intro_frames, _) = frames_until_shape_recognised();
// The whole point of the tap is that "shape recognised" fires before the
// 16-frame window even closes. Anything ≥ 16 means we'd be no better than
// the event path, and ADR-099 D4's whole D4-claim breaks.
assert!(
intro_frames < EVENT_PATH_BEST_CASE_FRAMES,
"introspection took {intro_frames} frames; event-path best-case is \
{EVENT_PATH_BEST_CASE_FRAMES} — the tap is no faster than the window."
);
}
/// Empirical baseline guard. The current implementation uses a host-side
/// length-normalised L1 stand-in for DTW (see `signature_score()` in
/// `introspection.rs`), which requires roughly a full signature length of
/// in-shape frames before the score crosses `promotion_threshold`. On the
/// 5-frame fixture in [`motion_signature`] that's exactly **5 frames** —
/// a **3.20× latency-floor reduction** vs. the event path's 16-frame best
/// case. ADR-099 D8 calls for ≥10×; closing that gap is owned by I6 ("optimise
/// hot spots") which can swap in real DTW partial-match scoring and/or
/// surface the attractor's regime-change as an earlier trigger than full
/// signature match. This guard prevents *regression* below today's 3.20×.
#[test]
fn introspection_latency_floor_ratio_baseline() {
let (intro_frames, _) = frames_until_shape_recognised();
let ratio = EVENT_PATH_BEST_CASE_FRAMES as f64 / intro_frames as f64;
let d8_bar_met = ratio >= D8_LATENCY_RATIO_BAR;
println!(
"ADR-099 D8 floor ratio: event-path best-case {} frames / introspection \
{} frames = {ratio:.2}× (D8 target: ≥{D8_LATENCY_RATIO_BAR}×, met: {d8_bar_met})",
EVENT_PATH_BEST_CASE_FRAMES, intro_frames
);
// Regression bar — empirical baseline of the L1 stand-in. If a future
// change ever drops below this, either the signature scoring regressed
// or the test fixture changed; both deserve a deliberate look.
const BASELINE_RATIO_FLOOR: f64 = 3.0;
assert!(
ratio >= BASELINE_RATIO_FLOOR,
"ratio {ratio:.2}× dropped below the L1-stand-in baseline of {BASELINE_RATIO_FLOOR}×\
either signature scoring regressed or the test fixture changed deliberately"
);
}
#[test]
fn per_frame_update_p99_under_budget() {
let (_, update_ms) = frames_until_shape_recognised();
let mut sorted = update_ms.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p50 = sorted[sorted.len() / 2];
let p99_idx = ((sorted.len() as f64) * 0.99) as usize;
let p99 = sorted[p99_idx.min(sorted.len() - 1)];
let mean = update_ms.iter().sum::<f64>() / update_ms.len() as f64;
let max = sorted.last().copied().unwrap_or(0.0);
println!(
"ADR-099 D4 per-frame update cost (n={}): p50={:.3}ms mean={:.3}ms p99={:.3}ms max={:.3}ms budget=<{}ms",
update_ms.len(),
p50,
mean,
p99,
max,
PER_FRAME_BUDGET_MS
);
assert!(
p99 <= PER_FRAME_BUDGET_MS,
"per-frame update p99 {p99:.3} ms exceeds {PER_FRAME_BUDGET_MS} ms budget"
);
}
#[test]
fn snapshot_carries_regime_after_warmup() {
// Independent of the latency bar — confirms the attractor analyzer feeds
// a non-Unknown regime into the snapshot once the warmup is done (the
// analyzer needs ~100 points before it'll classify).
let cfg = IntrospectionConfig {
trajectory_len: 256,
embedding_dim: 1,
analyze_every_n: 8,
library: SignatureLibrary::new(),
};
let mut state = IntrospectionState::with_config(cfg);
// Feed a periodic signal — should trigger `Regime::Periodic` (or at least
// not stay `Unknown`).
for k in 0..200u64 {
let v = (k as f64 * 0.20).sin();
state.update(k * 33_000_000, v).unwrap();
}
let s = state.snapshot();
println!(
"regime after 200 periodic frames: {:?}, lyapunov={:?}, confidence={}",
s.regime, s.lyapunov_exponent, s.attractor_confidence
);
assert_ne!(
s.regime,
wifi_densepose_sensing_server::introspection::Regime::Unknown,
"regime is still Unknown after 200 frames — attractor analyzer didn't fire"
);
}