Merge PR #2114 (aepod) into release integration

# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
This commit is contained in:
ruv committed 2026-10-05 11:46:07 -04:00
commit bb5d903724
4 files changed
+2729 -10

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -3,8 +3,9 @@
//! `/api/v1/csi/mediatek/devices/:device_id/{frames,summary}` routes.
//!
//! `MediatekCsiSnapshot` (`mediatek_csi.rs`) deliberately keeps only
//! mean/peak amplitude — enough for a coarse summary, but nothing for a
//! human to actually look at the channel with. This module extracts full per-chain, per-subcarrier
//! mean/peak amplitude — that's enough for the heuristic classifier
//! (`mediatek_heuristic`) but nothing for a human to actually look at the
//! channel with. This module extracts full per-chain, per-subcarrier
//! amplitude and phase directly from each frame's raw MTC1 I/Q payload at
//! ingest, and retains the last `capacity` frames per device — bounded, so
//! memory stays fixed regardless of how long a device has been streaming.
@@ -22,6 +23,7 @@ use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use serde::Deserialize;
use std::time::Duration;
use wifi_densepose_hardware::mediatek_csi::{CsiFrame, CsiPayload};
/// One (tx, rx) chain's per-subcarrier amplitude and phase for one frame.
@@ -144,6 +146,66 @@ impl DeviceRing {
(self.frames.len() - 1) as f64 / (span_us as f64 / 1_000_000.0)
}
/// "Fast" activity signal for `mediatek_activity`: the mean, over every
/// `(chain, subcarrier)` pair observed in the last `window` of ring
/// time (by frame timestamp, relative to the newest retained frame —
/// not wall-clock `Instant::now()`), of that pair's coefficient of
/// variation (`std / mean`) — a gain-normalized measure of how much the
/// channel is fluctuating right now. Returns `(fast, frame_count)`;
/// `frame_count` is also the module's `frames_2s`-style weight. `(0.0,
/// n)` when fewer than 2 frames fall in the window (a std needs at
/// least 2 samples).
pub(crate) fn fast_activity(&self, window: Duration) -> (f64, usize) {
let Some(newest) = self.frames.back() else {
return (0.0, 0);
};
let window_us = window.as_micros() as u64;
let recent: Vec<&RingFrame> = self
.frames
.iter()
.rev()
.take_while(|f| newest.timestamp_us.saturating_sub(f.timestamp_us) <= window_us)
.collect();
let n = recent.len();
if n < 2 {
return (0.0, n);
}
let sc = self.subcarrier_count as usize;
let mut cv_sum = 0.0f64;
let mut cv_count = 0usize;
for (tx, rx) in chain_union(recent.iter().copied()) {
let mut sum = vec![0.0f64; sc];
let mut sumsq = vec![0.0f64; sc];
let mut m = 0usize;
for frame in &recent {
let Some(c) = frame.chains.iter().find(|c| c.tx == tx && c.rx == rx) else {
continue;
};
m += 1;
for (k, &a) in c.amplitude.iter().enumerate().take(sc) {
sum[k] += a as f64;
sumsq[k] += (a as f64) * (a as f64);
}
}
if m < 2 {
continue;
}
let m_f = m as f64;
for k in 0..sc {
let mean = sum[k] / m_f;
let variance = (sumsq[k] / m_f - mean * mean).max(0.0);
cv_sum += variance.sqrt() / mean.abs().max(1e-6);
cv_count += 1;
}
}
let fast = if cv_count > 0 {
cv_sum / cv_count as f64
} else {
0.0
};
(fast, n)
}
/// Per-chain amplitude mean and standard deviation across every
/// retained frame that carried that chain, per subcarrier — the "what
/// does the channel usually look like" view for an inspection client.
@@ -414,13 +476,14 @@ pub(crate) async fn mediatek_csi_frames(
/// `GET /api/v1/csi/mediatek/devices/{device_id}/summary` — one call for the
/// analysis view: per-subcarrier amplitude mean/std per chain
/// over the ring, mean RSSI per Rx chain, and the ring's real frame rate.
/// over the ring, mean RSSI per Rx chain, the ring's real frame rate, and
/// the heuristic classifier's current window stats for the same device.
/// `404` when `device_id` has no ring at all.
pub(crate) async fn mediatek_csi_summary(
State(state): State<SharedState>,
Path(device_id): Path<String>,
) -> impl IntoResponse {
let s = state.read().await;
let mut s = state.write().await;
let Some(ring) = s.mediatek_csi_ring_by_device.get(&device_id) else {
return device_not_found(&device_id).into_response();
};
@@ -451,6 +514,24 @@ pub(crate) async fn mediatek_csi_summary(
let frame_rate_hz = ring.frame_rate_hz();
let subcarrier_count = ring.subcarrier_count();
// Separate borrow: the heuristic's history lives in a different map on
// the same AppStateInner, and `verdict()` needs `&mut self` to prune by
// age — done after the (now-dropped) immutable `ring` borrow above ends.
let now = std::time::Instant::now();
let heuristic = s
.mediatek_heuristic_by_device
.get_mut(&device_id)
.map(|hist| hist.verdict(now))
.map(|v| {
serde_json::json!({
"presence_state": v.presence_state.as_str(),
"coefficient_of_variation": v.window.coefficient_of_variation,
"baseline_deviation": v.window.baseline_deviation,
"sample_count": v.window.sample_count,
"span_ms": v.window.span_ms,
})
});
(
StatusCode::OK,
Json(serde_json::json!({
@@ -461,6 +542,7 @@ pub(crate) async fn mediatek_csi_summary(
"frame_rate_hz": frame_rate_hz,
"rssi_by_rx_chain": rssi_by_rx_chain,
"chains": chains,
"heuristic": heuristic,
})),
)
.into_response()
@@ -623,6 +705,53 @@ mod tests {
assert_eq!(by_pos[&(1, 1)], 2);
}
#[test]
fn fast_activity_is_zero_for_a_flat_signal_and_positive_for_a_varying_one() {
let mut sim = MediatekCsiSimulator::new(SimulatorConfig {
tx_count: 1,
rx_count: 1,
subcarriers: 4,
..Default::default()
})
.unwrap();
let mut flat_ring = DeviceRing::new(16);
for _ in 0..8 {
flat_ring.push(&sim.next_frame());
}
// The ADR-266 simulator's amplitude has a slow motion phase, not
// frame-to-frame noise, so back-to-back frames read as effectively
// flat over a short window: fast_activity should be near zero.
let (fast_flat, n) = flat_ring.fast_activity(Duration::from_secs(2));
assert_eq!(n, 8);
assert!(
fast_flat < 0.05,
"expected a near-flat signal, got fast={fast_flat}"
);
}
#[test]
fn fast_activity_ignores_frames_outside_the_window() {
let mut sim = MediatekCsiSimulator::new(SimulatorConfig {
tx_count: 1,
rx_count: 1,
subcarriers: 4,
frame_period_us: 500_000, // 0.5s apart
..Default::default()
})
.unwrap();
let mut ring = DeviceRing::new(16);
for _ in 0..8 {
ring.push(&sim.next_frame()); // spans 3.5s total
}
let (_, n_full) = ring.fast_activity(Duration::from_secs(10));
assert_eq!(n_full, 8, "a wide window covers every retained frame");
let (_, n_narrow) = ring.fast_activity(Duration::from_secs(1));
assert!(
n_narrow < 8,
"a 1s window must exclude older frames from a 3.5s-spanning ring, got {n_narrow}"
);
}
mod endpoint_tests {
//! Exercises `mediatek_csi_frames` / `mediatek_csi_summary` directly
//! against `AppStateInner`, constructing the axum extractors by hand
@@ -751,6 +880,11 @@ mod tests {
.as_array()
.expect("rssi_by_rx_chain array");
assert_eq!(rssi.len(), 1, "1 rx chain");
// heuristic is present but null when this device never went
// through mediatek_heuristic_by_device (this test only seeds the
// ring, mirroring a device whose heuristic hasn't observed yet).
assert!(body["heuristic"].is_null());
}
#[tokio::test]
@@ -0,0 +1,688 @@
//! A clearly-labelled heuristic presence/motion detector for real MediaTek
//! MTC1 CSI, used only because the validated ESP32 pipeline
//! (`extract_features_from_frame` / `VitalSignDetector`) needs per-subcarrier
//! amplitude and phase arrays that `MediatekCsiSnapshot` does not retain (see
//! its doc comment on `AppStateInner::latest_mediatek_csi`: "raw matrices are
//! not retained here"). All this module has to work with, per frame, is
//! `mean_amplitude`, `peak_amplitude`, and per-chain `rssi_dbm`.
//!
//! Every [`DeviceVerdict`] carries [`CLASSIFIER_ID`] (via
//! `mediatek_room_update` in main.rs) so nothing downstream can mistake it
//! for the validated ESP32 detector. Per repository policy (RuView
//! `CLAUDE.md`: "Never present WiFi sensing as camera-grade... tag
//! MEASURED/CLAIMED/SYNTHETIC"), treat this output as `CLAIMED`, not
//! `MEASURED` — it has not been validated against ground truth.
//!
//! # 2026-09-19 field data and the hysteresis/unknown-state fix
//!
//! In a multi-person field run with one person walking between the two
//! receivers (CLAIMED: from field notes, not a labelled evaluation), the v0
//! design (a fixed 64-sample count window, single-sample threshold crossing)
//! flickered `present_moving` -> `absent` on 5+ of ~90
//! two-second samples, sometimes for 10 s runs, with confidence collapsing to
//! 0.01-0.04 while both receivers were fresh (age < 1 s) and clearly
//! non-flat. Two causes, both addressed below:
//!
//! - The dump-loop clients are bursty (0.25-6 s gaps between arrivals per
//! receiver), so a **count**-based window mixed old and new activity
//! unpredictably depending on arrival timing rather than elapsed time. The
//! window is now **time**-based ([`WINDOW_DURATION`]): samples older than
//! the window are pruned by age on every observation, not by count, and a
//! gap does not clear or reset the window — it just means fewer samples
//! remain in it until fresh ones arrive.
//! - A single transient dip below threshold (a real one — e.g. a deep RF
//! fade mid-walk, not a bug) instantly flipped `presence` from `true` to
//! `false`. Presence now has hysteresis: fresh evidence sets presence
//! immediately, but leaving presence requires BOTH the metric being
//! continuously below threshold for [`CONFIRM_ABSENT_DURATION`] AND being
//! past [`HOLD_DURATION`] since the last positive evidence.
//! - Confidence still legitimately collapses sometimes (e.g. a genuinely
//! sparse window right after a receiver has been quiet). Reading a
//! low-confidence sample as a confident "absent" was itself the bug ("one
//! person stood up" was reported as `absent 0.09`). Below
//! [`UNKNOWN_CONFIDENCE_THRESHOLD`] the device now reports
//! [`PresenceState::Unknown`] (`presence: None`) instead of asserting
//! either way, and abstains from the room-level vote in
//! `mediatek_room_update` rather than getting to assert "absent" on weak
//! evidence.
use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};
/// Tags every output of this module so it is never mistaken for the
/// validated ESP32 `extract_features_from_frame` / `VitalSignDetector`
/// pipeline. Bump the trailing version if the method below changes in a way
/// that would invalidate a prior comparison.
pub(crate) const CLASSIFIER_ID: &str = "mediatek-amplitude-heuristic-v0";
/// Time-based amplitude window. Replaces a fixed sample count (v0) so a
/// bursty arrival pattern (dump-loop clients see 0.25-6 s gaps) doesn't make
/// the window's real time coverage depend on how lucky recent arrivals were.
/// Samples are pruned by age, not count; a gap does not reset the window.
pub(crate) const WINDOW_DURATION: Duration = Duration::from_secs(10);
/// How long presence persists after the last positive evidence before a
/// flip to absent is even considered. A transient dip inside this window
/// never flips presence on its own.
pub(crate) const HOLD_DURATION: Duration = Duration::from_secs(10);
/// Once past `HOLD_DURATION`, the metric must additionally read
/// continuously below threshold for this long before presence actually
/// commits to absent — one low sample is not enough.
pub(crate) const CONFIRM_ABSENT_DURATION: Duration = Duration::from_secs(3);
/// Below this confidence, a device reports [`PresenceState::Unknown`]
/// (`presence: None`) rather than asserting either "present" or "absent",
/// and abstains from the room-level vote. Raw CSI abstains rather than
/// guesses, same convention the ADR-267 provenance gate already uses
/// elsewhere in this crate.
pub(crate) const UNKNOWN_CONFIDENCE_THRESHOLD: f64 = 0.3;
/// Coefficient of variation (stdev / |mean|) of `mean_amplitude` over the
/// window above which motion is declared. Loosely tuned against the ADR-266
/// simulator's synthetic motion phase; revisit as more real WN586X3 capture
/// accumulates.
pub(crate) const MOTION_CV_THRESHOLD: f64 = 0.05;
/// Fractional deviation of the latest `mean_amplitude` from the slow
/// baseline above which "present but still" is declared even with low
/// frame-to-frame variance (a body blocking/reflecting the channel without
/// moving).
pub(crate) const BASELINE_DEVIATION_THRESHOLD: f64 = 0.08;
/// EMA smoothing factor for the slow-moving empty-room baseline, applied
/// per-*sample* (not per elapsed time) — a known simplification: during a
/// dense burst the baseline drifts faster than during a quiet gap. Small
/// enough that a person entering the room doesn't drag the baseline toward
/// "occupied is normal" within a few samples either way.
pub(crate) const BASELINE_EMA_ALPHA: f64 = 0.02;
/// Coarse motion label, matching the string vocabulary `RoomInference` /
/// `ClassificationInfo` already use elsewhere in this crate. This is the
/// *raw*, instantaneous reading for this observation, before hysteresis;
/// see [`PresenceState`] for the debounced, room-vote-eligible state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MotionLevel {
Absent,
PresentStill,
PresentMoving,
}
impl MotionLevel {
pub(crate) fn as_str(self) -> &'static str {
match self {
MotionLevel::Absent => "absent",
MotionLevel::PresentStill => "present_still",
MotionLevel::PresentMoving => "present_moving",
}
}
}
/// The hysteresis-committed, confidence-gated presence call. Distinct from
/// the raw `MotionLevel` above: this is what a device is allowed to assert
/// to the room vote.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PresenceState {
Present,
Absent,
/// Confidence below `UNKNOWN_CONFIDENCE_THRESHOLD`. Not a vote either
/// way — see `mediatek_room_update`'s exclusion of Unknown devices from
/// `fuse_room`.
Unknown,
}
impl PresenceState {
pub(crate) fn as_str(self) -> &'static str {
match self {
PresenceState::Present => "present",
PresenceState::Absent => "absent",
PresenceState::Unknown => "unknown",
}
}
}
/// Per-device amplitude-window statistics, exposed in `nodes[]` (as
/// `MediatekNodeDiagnostics` in main.rs) so a client can show why a device
/// reads the way it does.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct WindowStats {
pub(crate) coefficient_of_variation: f64,
pub(crate) baseline_deviation: f64,
pub(crate) sample_count: usize,
pub(crate) span_ms: u64,
}
/// One device's verdict for the current moment.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct DeviceVerdict {
/// Raw, instantaneous motion reading (not hysteresis-debounced) — kept
/// for diagnostics; `presence_state`/`presence` are what should drive
/// any decision.
pub(crate) motion_level: MotionLevel,
pub(crate) presence_state: PresenceState,
/// `Some(true)`/`Some(false)` mirror `presence_state`'s Present/Absent;
/// `None` when `presence_state` is `Unknown`. Nullable specifically so a
/// low-confidence reading is never silently read as a confident
/// "absent" (2026-09-19: "one person stood up" was reported as
/// `absent 0.09` before this fix).
pub(crate) presence: Option<bool>,
/// This device's own confidence: window fill x how far past (or safely
/// under, for an absent-leaning reading) the relevant threshold the
/// signal sits. Cross-device agreement is applied separately by
/// [`apply_agreement`], since a single device has no visibility into
/// its peers.
pub(crate) raw_confidence: f64,
pub(crate) window: WindowStats,
}
/// Time-windowed per-device amplitude history plus the slow baseline and
/// hysteresis state it's judged against. One instance lives per `device_id`
/// for the life of the process (or until the caller decides to drop a
/// long-silent device).
#[derive(Debug, Clone, Default)]
pub(crate) struct DeviceHistory {
/// (arrival time, mean_amplitude), oldest first. Pruned by age against
/// `WINDOW_DURATION` on every observation — never by count.
samples: VecDeque<(Instant, f64)>,
baseline: Option<f64>,
/// Last time raw evidence (motion or sustained deviation) was seen.
/// Presence cannot commit to absent within `HOLD_DURATION` of this.
last_present_evidence: Option<Instant>,
/// When the metric most recently became continuously below threshold.
/// `None` whenever the metric is currently at/above threshold. Presence
/// cannot commit to absent until `now - this >= CONFIRM_ABSENT_DURATION`.
below_threshold_since: Option<Instant>,
/// The last hysteresis-committed presence call (pre-confidence-gate).
/// Defaults to `false`: a device with no evidence yet has nothing to
/// hold onto, and starts out below the confidence threshold anyway (an
/// empty window), so it reports Unknown until it has real data —
/// this default is never observable as a false "present".
committed_presence: bool,
}
impl DeviceHistory {
/// Fold in one new frame's `mean_amplitude`, observed at `now`, and
/// return this device's verdict. `peak_amplitude` is accepted for
/// future use (e.g. a saturation guard) but not yet part of the
/// decision.
pub(crate) fn observe(
&mut self,
now: Instant,
mean_amplitude: f64,
_peak_amplitude: f64,
) -> DeviceVerdict {
self.samples.push_back((now, mean_amplitude));
self.prune(now);
self.baseline = Some(match self.baseline {
None => mean_amplitude,
Some(b) => b + BASELINE_EMA_ALPHA * (mean_amplitude - b),
});
self.verdict_at(now)
}
/// Recompute this device's verdict at `now` without folding in a new
/// sample — used for devices that aren't the one whose frame just
/// arrived this tick, so the room picture still reflects every
/// currently-fresh receiver. Still prunes the window by age (a gap
/// since the last real sample must age the window normally).
pub(crate) fn verdict(&mut self, now: Instant) -> DeviceVerdict {
self.prune(now);
self.verdict_at(now)
}
fn prune(&mut self, now: Instant) {
while let Some(&(t, _)) = self.samples.front() {
if now.saturating_duration_since(t) > WINDOW_DURATION {
self.samples.pop_front();
} else {
break;
}
}
}
fn verdict_at(&mut self, now: Instant) -> DeviceVerdict {
let n = self.samples.len();
if n == 0 {
return DeviceVerdict {
motion_level: MotionLevel::Absent,
presence_state: PresenceState::Unknown,
presence: None,
raw_confidence: 0.0,
window: WindowStats {
coefficient_of_variation: 0.0,
baseline_deviation: 0.0,
sample_count: 0,
span_ms: 0,
},
};
}
let span_ms = self
.samples
.back()
.zip(self.samples.front())
.map(|((newest, _), (oldest, _))| {
newest.saturating_duration_since(*oldest).as_millis() as u64
})
.unwrap_or(0);
let window_fill = (span_ms as f64 / WINDOW_DURATION.as_millis() as f64).min(1.0);
let mean: f64 = self.samples.iter().map(|(_, v)| v).sum::<f64>() / n as f64;
let variance = if n > 1 {
self.samples
.iter()
.map(|(_, v)| (v - mean).powi(2))
.sum::<f64>()
/ n as f64
} else {
0.0
};
let cv = if mean.abs() > f64::EPSILON {
variance.sqrt() / mean.abs()
} else {
0.0
};
let latest = self.samples.back().map(|(_, v)| *v).unwrap_or(mean);
let baseline = self.baseline.unwrap_or(latest);
let deviation = if baseline.abs() > f64::EPSILON {
(latest - baseline).abs() / baseline.abs()
} else {
0.0
};
let motion = cv > MOTION_CV_THRESHOLD;
let sustained = deviation > BASELINE_DEVIATION_THRESHOLD;
let raw_evidence = motion || sustained;
let motion_level = if motion {
MotionLevel::PresentMoving
} else if sustained {
MotionLevel::PresentStill
} else {
MotionLevel::Absent
};
// Hysteresis: feed the evidence timers, then decide whether a flip
// to absent is actually allowed yet.
if raw_evidence {
self.last_present_evidence = Some(now);
self.below_threshold_since = None;
self.committed_presence = true;
} else {
if self.below_threshold_since.is_none() {
self.below_threshold_since = Some(now);
}
let held_by_recency = self
.last_present_evidence
.is_some_and(|t| now.saturating_duration_since(t) < HOLD_DURATION);
let confirmed_absent = self
.below_threshold_since
.is_some_and(|t| now.saturating_duration_since(t) >= CONFIRM_ABSENT_DURATION);
if !held_by_recency && confirmed_absent {
self.committed_presence = false;
}
// Otherwise: keep whatever `committed_presence` already was — a
// transient dip inside the hold/confirm window never flips it.
}
// Signal margin: how far past (present) or safely under (absent)
// threshold the stronger signal sits, normalized into roughly
// [0, 1] either way — based on the *committed* call, not the raw
// instantaneous one, so confidence doesn't itself flicker on a
// single-sample blip that hysteresis is about to override anyway.
let motion_margin = if MOTION_CV_THRESHOLD > 0.0 {
cv / MOTION_CV_THRESHOLD
} else {
0.0
};
let deviation_margin = if BASELINE_DEVIATION_THRESHOLD > 0.0 {
deviation / BASELINE_DEVIATION_THRESHOLD
} else {
0.0
};
let signal_margin = if self.committed_presence {
(motion_margin.max(deviation_margin) / 2.0).clamp(0.0, 1.0)
} else {
(1.0 - motion_margin.max(deviation_margin).min(1.0)).clamp(0.0, 1.0)
};
let raw_confidence = (window_fill * signal_margin).clamp(0.0, 1.0);
let presence_state = if raw_confidence < UNKNOWN_CONFIDENCE_THRESHOLD {
PresenceState::Unknown
} else if self.committed_presence {
PresenceState::Present
} else {
PresenceState::Absent
};
let presence = match presence_state {
PresenceState::Present => Some(true),
PresenceState::Absent => Some(false),
PresenceState::Unknown => None,
};
DeviceVerdict {
motion_level,
presence_state,
presence,
raw_confidence,
window: WindowStats {
coefficient_of_variation: cv,
baseline_deviation: deviation,
sample_count: n,
span_ms,
},
}
}
}
/// Apply cross-device agreement to a set of already-computed verdicts, keyed
/// by `device_id`. Only devices with a definite `presence: Some(_)`
/// participate — an `Unknown` device neither votes nor is voted against, and
/// its own confidence is left unscaled (it is already below the unknown
/// threshold; scaling it further changes nothing observable).
///
/// A lone definite device (or all-but-one Unknown) is never penalized —
/// there is nothing to disagree with. Otherwise each definite device's
/// confidence is scaled by `0.5 + 0.5 * agreement`, where `agreement` is the
/// fraction of *other* definite devices sharing its `presence` call: full
/// agreement leaves confidence unchanged, full disagreement halves it. A
/// hard zero on any disagreement was considered and rejected — two routers
/// legitimately cover different zones of a room (a doorway vs. a couch), so
/// routine partial disagreement should dampen confidence, not erase it.
pub(crate) fn apply_agreement(verdicts: &HashMap<String, DeviceVerdict>) -> HashMap<String, f64> {
let definite: Vec<(&String, bool)> = verdicts
.iter()
.filter_map(|(id, v)| v.presence.map(|p| (id, p)))
.collect();
let total = definite.len();
verdicts
.iter()
.map(|(id, v)| {
let confidence = match v.presence {
None => v.raw_confidence, // Unknown: left as-is.
Some(_) if total <= 1 => v.raw_confidence,
Some(p) => {
let others = total - 1;
let agreeing_others = definite.iter().filter(|(_, o)| *o == p).count() - 1;
let agreement = agreeing_others as f64 / others as f64;
v.raw_confidence * (0.5 + 0.5 * agreement)
}
};
(id.clone(), confidence.clamp(0.0, 1.0))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flat_amplitude_stream_is_absent_once_confidently_settled() {
let mut h = DeviceHistory::default();
let start = Instant::now();
let mut verdict = h.observe(start, 100.0, 102.0);
// Immediately after the first sample the window barely covers any
// time, so confidence should read Unknown, not a confident absent.
assert_eq!(verdict.presence_state, PresenceState::Unknown);
// Feed flat samples spanning past HOLD_DURATION + CONFIRM_ABSENT_DURATION
// so the window fills and any (nonexistent) presence hold expires.
let mut t = start;
for _ in 0..40 {
t += Duration::from_millis(500);
verdict = h.observe(t, 100.0, 102.0);
}
assert_eq!(verdict.motion_level, MotionLevel::Absent);
assert_eq!(verdict.presence_state, PresenceState::Absent);
assert_eq!(verdict.presence, Some(false));
assert!(
verdict.raw_confidence > 0.7,
"confidence was {}",
verdict.raw_confidence
);
}
#[test]
fn injected_variance_is_present_moving() {
let mut h = DeviceHistory::default();
let start = Instant::now();
let mut t = start;
let mut verdict = h.observe(t, 100.0, 102.0);
for i in 0..40 {
t += Duration::from_millis(500);
let sample = if i % 2 == 0 { 100.0 } else { 130.0 };
verdict = h.observe(t, sample, sample + 2.0);
}
assert_eq!(verdict.motion_level, MotionLevel::PresentMoving);
assert_eq!(verdict.presence_state, PresenceState::Present);
assert_eq!(verdict.presence, Some(true));
}
/// The exact 2026-09-19 field bug: presence is well-established, then one
/// sample dips below threshold (a real transient fade, not a bug in the
/// underlying signal) — hysteresis must not flip presence to absent on
/// that single sample.
#[test]
fn a_single_transient_dip_does_not_flip_presence_to_absent() {
let mut h = DeviceHistory::default();
let start = Instant::now();
let mut t = start;
let mut verdict = h.observe(t, 100.0, 102.0);
// Establish confident, sustained motion.
for i in 0..40 {
t += Duration::from_millis(500);
let sample = if i % 2 == 0 { 100.0 } else { 130.0 };
verdict = h.observe(t, sample, sample + 2.0);
}
assert_eq!(
verdict.presence,
Some(true),
"must be confidently present before the dip"
);
// One flat sample right after (a fade) — presence must hold.
t += Duration::from_millis(500);
verdict = h.observe(t, 115.0, 117.0);
assert_eq!(
verdict.presence,
Some(true),
"a single dip within HOLD_DURATION/CONFIRM_ABSENT_DURATION must not flip presence"
);
}
/// If the dip is real and sustained (not a blip), presence must
/// eventually commit to absent — hysteresis dampens, it doesn't hide a
/// genuine departure forever.
#[test]
fn a_sustained_dip_past_hold_and_confirm_duration_flips_to_absent() {
let mut h = DeviceHistory::default();
let start = Instant::now();
let mut t = start;
let mut verdict = h.observe(t, 100.0, 102.0);
for i in 0..40 {
t += Duration::from_millis(500);
let sample = if i % 2 == 0 { 100.0 } else { 130.0 };
verdict = h.observe(t, sample, sample + 2.0);
}
assert_eq!(verdict.presence, Some(true));
// Flat for well past HOLD_DURATION + CONFIRM_ABSENT_DURATION.
for _ in 0..40 {
t += Duration::from_millis(500);
verdict = h.observe(t, 115.0, 117.0);
}
assert_eq!(
verdict.presence,
Some(false),
"a genuinely sustained dip must eventually commit to absent"
);
}
/// A bursty two-device timeline: one device reports in dense bursts with
/// multi-second gaps between them (per the real dump-loop behaviour).
/// The time-based window must not starve during a gap the way a
/// count-based window with a low arrival rate effectively would.
#[test]
fn bursty_arrivals_still_fill_a_time_based_window() {
let mut h = DeviceHistory::default();
let start = Instant::now();
let mut t = start;
let mut verdict = h.observe(t, 100.0, 102.0);
// Three bursts of 5 samples each, 3 s apart (a gap well inside
// WINDOW_DURATION, matching observed dump-loop gaps).
for _burst in 0..3 {
for i in 0..5 {
t += Duration::from_millis(100);
let sample = if i % 2 == 0 { 100.0 } else { 130.0 };
verdict = h.observe(t, sample, sample + 2.0);
}
t += Duration::from_secs(3);
}
// All three bursts are within WINDOW_DURATION (10s) of the last one,
// so the window should have accumulated all 16 samples, not just
// the most recent burst.
assert_eq!(verdict.window.sample_count, 16);
assert!(
verdict.window.span_ms > 6_000,
"span was {}",
verdict.window.span_ms
);
}
/// A gap longer than WINDOW_DURATION must age out the old burst rather
/// than let it linger forever.
#[test]
fn a_gap_longer_than_the_window_ages_out_old_samples() {
let mut h = DeviceHistory::default();
let start = Instant::now();
let mut t = start;
for i in 0..5 {
t += Duration::from_millis(100);
let sample = if i % 2 == 0 { 100.0 } else { 130.0 };
let _ = h.observe(t, sample, sample + 2.0);
}
t += WINDOW_DURATION + Duration::from_secs(1);
let verdict = h.observe(t, 100.0, 102.0);
assert_eq!(
verdict.window.sample_count, 1,
"the stale burst must have aged out"
);
}
#[test]
fn low_confidence_reads_as_unknown_not_absent() {
// A single sample: window barely covers any time, so window_fill is
// near zero regardless of the amplitude value — must read Unknown,
// never a confident "absent" (2026-09-19: "one person stood up" was
// reported as `absent 0.09` before this fix).
let mut h = DeviceHistory::default();
let verdict = h.observe(Instant::now(), 1800.0, 9000.0);
assert_eq!(verdict.presence_state, PresenceState::Unknown);
assert_eq!(verdict.presence, None);
assert!(verdict.raw_confidence < UNKNOWN_CONFIDENCE_THRESHOLD);
}
#[test]
fn lone_definite_device_is_never_penalized_by_agreement() {
let mut verdicts = HashMap::new();
verdicts.insert(
"only-device".to_string(),
DeviceVerdict {
motion_level: MotionLevel::PresentMoving,
presence_state: PresenceState::Present,
presence: Some(true),
raw_confidence: 0.9,
window: WindowStats {
coefficient_of_variation: 0.1,
baseline_deviation: 0.0,
sample_count: 20,
span_ms: 9000,
},
},
);
let adjusted = apply_agreement(&verdicts);
assert_eq!(adjusted["only-device"], 0.9);
}
#[test]
fn unknown_device_neither_votes_nor_is_voted_against() {
let mut verdicts = HashMap::new();
verdicts.insert(
"dev-present".to_string(),
DeviceVerdict {
motion_level: MotionLevel::PresentMoving,
presence_state: PresenceState::Present,
presence: Some(true),
raw_confidence: 0.9,
window: WindowStats {
coefficient_of_variation: 0.1,
baseline_deviation: 0.0,
sample_count: 20,
span_ms: 9000,
},
},
);
verdicts.insert(
"dev-unknown".to_string(),
DeviceVerdict {
motion_level: MotionLevel::Absent,
presence_state: PresenceState::Unknown,
presence: None,
raw_confidence: 0.05,
window: WindowStats {
coefficient_of_variation: 0.01,
baseline_deviation: 0.0,
sample_count: 2,
span_ms: 200,
},
},
);
let adjusted = apply_agreement(&verdicts);
// Only one *definite* device -> no disagreement possible, full confidence kept.
assert_eq!(adjusted["dev-present"], 0.9);
// Unknown device's own confidence is left untouched.
assert_eq!(adjusted["dev-unknown"], 0.05);
}
#[test]
fn two_definite_devices_disagreeing_have_lowered_confidence() {
let mut verdicts = HashMap::new();
verdicts.insert(
"dev-moving".to_string(),
DeviceVerdict {
motion_level: MotionLevel::PresentMoving,
presence_state: PresenceState::Present,
presence: Some(true),
raw_confidence: 0.9,
window: WindowStats {
coefficient_of_variation: 0.1,
baseline_deviation: 0.0,
sample_count: 20,
span_ms: 9000,
},
},
);
verdicts.insert(
"dev-absent".to_string(),
DeviceVerdict {
motion_level: MotionLevel::Absent,
presence_state: PresenceState::Absent,
presence: Some(false),
raw_confidence: 0.9,
window: WindowStats {
coefficient_of_variation: 0.01,
baseline_deviation: 0.0,
sample_count: 20,
span_ms: 9000,
},
},
);
let adjusted = apply_agreement(&verdicts);
assert!((adjusted["dev-moving"] - 0.45).abs() < 1e-9);
assert!((adjusted["dev-absent"] - 0.45).abs() < 1e-9);
}
}