feat(calibration): learn empty room startup confidence (#1840)

This commit is contained in:
rUv authored and GitHub committed 2026-09-04 23:02:06 -04:00
1 parent 2e9e87c65c
commit 2227b42841
6 files changed
+1218 -110

No files matched your search

@@ -23,17 +23,26 @@ directory, which is unreliable for embedded application hosts.
RuView may persist a completed empty room field model as a local bootstrap
prior only after two server controlled stages:
1. Normal calibration reaches both 12,000 accepted frames and 600 seconds.
2. Promotion observes 12 new one second samples. At least 10 must resolve
empty, all ticks must be fresh, and none may publish numeric vital signs.
1. Normal calibration reaches both 1,000 accepted frames and 600 seconds.
The frame target represents twenty complete fifty frame runtime windows.
Collection binds to the first accepted ESP32 source and rejects frames from
other radios for that single link model.
2. Promotion observes 12 new samples spaced by at least one second. Each
sample has a four second bounded acquisition timeout. At least 10 must
resolve empty, all ticks must be fresh, and none may publish numeric vital
signs.
The stored image contains aggregate means, environmental modes, mode energies,
noise statistics, configuration, contributing node IDs, and a server generated
model ID. It excludes raw CSI, waveforms, device addresses, room names, pose,
identity, credentials, and positive human labels.
noise statistics, sorted scalar residual references, configuration, one source
node ID, and a server generated model ID. It excludes raw CSI, waveforms,
device addresses, room names, pose, identity, credentials, and positive human
labels. Runtime matching compares a fifty frame mean with the learned residual
distribution. Its score is empirical background conformance, not an absence
probability.
The image is bound to a SHA 256 digest of the stable installation ID, carries
an integrity digest, is capped at 1 MiB, and inherits the field model expiry.
an integrity digest over the exact stored payload bytes, is capped at 1 MiB,
and inherits the field model expiry.
Files with an invalid schema, dimensions, values, node set, installation
binding, digest, size, or lifetime fail closed.
@@ -43,6 +52,11 @@ evidence or numeric heart and breathing rates. Explicit calibration replaces
it with fresh statistics. Cancelling an unfinished capture restores a valid
prior. Administrator scoped reset removes it.
The status endpoint reports the learned runtime window, residual threshold,
reference count, current background conformance, and the negative only
authority boundary. A restored model never resumes collection and never
persists raw calibration frames.
The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application
host to provide a protected state directory.
@@ -71,12 +85,29 @@ occupied sequences. Empty data is never relabeled as human training.
denies calibrated evidence and numeric vital authority.
5. Numeric vitals fail closed unless an explicit fresh calibration resolves
exactly one occupant at sufficient signal quality and confidence.
6. The legacy `edge_vitals` WebSocket message applies the same publication
gate as `sensing_update`. When the bootstrap background matches, it emits
absent state, zero people, null numeric rates, and an explicit abstention
reason instead of forwarding raw edge candidates.
## Measured installation evidence
One physical empty home run on source Node 5 collected 1,762 accepted frames
over 655 seconds and produced 35 privacy reduced reference windows. Promotion
observed 12 of 12 fresh empty samples, zero stale samples, and zero numeric
vital samples. After process restart, 12 of 12 fresh samples matched the empty
background and contained no numeric vital signs. Background conformance scores
ranged from 50.0 to 64.3 percent with a median of 54.3 percent.
This is measured negative evidence for one installation. No occupied positive
holdout was recorded, so it does not prove person recall, person counting, or
cross home accuracy improvement.
## Acceptance test
With a physically empty room, complete calibration on stable nodes, promote
the model, and restart the server with the same installation ID and data
directory. Status must report `binding_mode=bootstrap_only`, zero collection
frames, and false authority for calibrated evidence and numeric vitals. Twelve
fresh empty samples must contain no numeric vital signs. Starting a new room
calibration must replace the prior and begin at frame zero.
the single source model, and restart the server with the same installation ID
and data directory. Status must report `binding_mode=bootstrap_only`, zero
collection frames, and false authority for calibrated evidence and numeric
vitals. Twelve fresh empty samples must contain no numeric vital signs.
Starting a new room calibration must replace the prior and begin at frame zero.
@@ -30,7 +30,7 @@ mdns-sd = "0.11"
# Serialization
serde = { workspace = true }
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
# Logging
tracing.workspace = true
@@ -6,6 +6,7 @@
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
@@ -19,6 +20,8 @@ pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.
pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only";
pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12;
pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10;
pub const BOOTSTRAP_VALIDATION_MIN_SPACING_MS: u64 = 1_000;
pub const BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS: u64 = 4_000;
const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -41,6 +44,13 @@ struct BootstrapImageV1 {
content_sha256: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BootstrapImageRawV1 {
payload: Box<RawValue>,
content_sha256: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BootstrapBaselineMetadata {
pub authority: &'static str,
@@ -181,11 +191,19 @@ pub fn load(
if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
let image: BootstrapImageV1 = serde_json::from_slice(&bytes)?;
validate_payload(&image.payload, installation_id, current_unix_ms)?;
if payload_digest(&image.payload)? != image.content_sha256 {
// Verify the exact payload bytes written by storage before decoding the
// floating point model. Equivalent JSON number spellings may serialize
// differently, so decode and reserialize is not an integrity contract.
let raw_image: BootstrapImageRawV1 = serde_json::from_slice(&bytes)?;
if hex_sha256(raw_image.payload.get().as_bytes()) != raw_image.content_sha256 {
return Err(BootstrapBaselineError::DigestMismatch);
}
let payload: BootstrapPayloadV1 = serde_json::from_str(raw_image.payload.get())?;
validate_payload(&payload, installation_id, current_unix_ms)?;
let image = BootstrapImageV1 {
payload,
content_sha256: raw_image.content_sha256,
};
let model = FieldModel::from_snapshot(
image.payload.field_model.clone(),
current_unix_ms.saturating_mul(1_000),
@@ -368,6 +386,11 @@ mod tests {
#[test]
fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() {
assert_eq!(BOOTSTRAP_VALIDATION_MIN_SPACING_MS, 1_000);
assert_eq!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, 4_000);
assert!(
BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS
);
let passing = vec![
BootstrapValidationSample {
fresh_tick: true,
@@ -385,4 +408,29 @@ mod tests {
vital[0].vital_signs_absent = false;
assert!(!evaluate_validation(&vital).passed);
}
#[test]
fn restart_verifies_the_exact_stored_payload_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], "model", now_ms, &model).unwrap();
let original = fs::read_to_string(&path).unwrap();
let image: BootstrapImageRawV1 = serde_json::from_str(&original).unwrap();
let rewritten_payload = image.payload.get().replacen(
"\"min_calibration_duration_s\":0.0",
"\"min_calibration_duration_s\":0.00",
1,
);
assert_ne!(rewritten_payload, image.payload.get());
let digest = hex_sha256(rewritten_payload.as_bytes());
let rewritten =
format!("{{\"payload\":{rewritten_payload},\"content_sha256\":\"{digest}\"}}");
fs::write(&path, rewritten).unwrap();
let (_, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
assert_eq!(loaded.content_sha256, digest);
}
}
@@ -40,6 +40,103 @@ const ENERGY_THRESH_3: f64 = 25.0;
/// reliable — genuine higher counts come from the multistatic fusion path.
const MAX_SINGLE_LINK_OCCUPANCY: usize = 3;
/// Low-authority comparison of the current source window with a restored
/// empty-room bootstrap image. This is background conformance only and cannot
/// authorize presence, person count, pose, identity, or vital signs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BootstrapBackgroundMatch {
pub matches_empty: bool,
pub score: Option<f64>,
pub residual_energy: f64,
pub residual_energy_threshold: f64,
pub window_size: usize,
pub reference_window_count: usize,
}
fn mean_frame(frames: &[Vec<f64>]) -> Option<Vec<f64>> {
let width = frames.first()?.len();
if width == 0 {
return None;
}
let mut mean = vec![0.0_f64; width];
let mut count = 0_usize;
for frame in frames {
if frame.len() != width {
continue;
}
for (sum, value) in mean.iter_mut().zip(frame.iter()) {
*sum += value;
}
count += 1;
}
if count == 0 {
return None;
}
for value in &mut mean {
*value /= count as f64;
}
Some(mean)
}
fn perturbation_occupancy(field: &FieldModel, frames: &[Vec<f64>]) -> Option<usize> {
let adaptive_threshold = field.empty_room_residual_energy_threshold();
let frame = match adaptive_threshold {
Some(_) => mean_frame(frames)?,
None => frames.first()?.clone(),
};
let perturbation = field.extract_perturbation(&[frame]).ok()?;
let count = match adaptive_threshold {
Some(threshold) => {
let empty_threshold = threshold.max(1.0);
if perturbation.total_energy > empty_threshold * 25.0 {
3
} else if perturbation.total_energy > empty_threshold * 12.0 {
2
} else if perturbation.total_energy > empty_threshold {
1
} else {
0
}
}
None => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
};
Some(count)
}
/// Provenance for a count produced by the calibrated field model. Callers
/// that attach calibration evidence must never substitute the score heuristic
/// when the calibrated path cannot evaluate the observation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalibratedOccupancyMethod {
Eigenvalue,
PerturbationEnergy,
}
impl CalibratedOccupancyMethod {
pub const fn wire_name(self) -> &'static str {
match self {
Self::Eigenvalue => "field_model_eigenvalue_v1",
Self::PerturbationEnergy => "field_model_perturbation_energy_v1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CalibratedOccupancy {
pub person_count: usize,
pub method: CalibratedOccupancyMethod,
}
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
/// This avoids the DimensionMismatch error when feeding single-frame observations.
pub fn single_link_config() -> FieldModelConfig {
@@ -67,7 +164,7 @@ pub fn occupancy_or_fallback(
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.cloned()
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
@@ -87,26 +184,86 @@ pub fn occupancy_or_fallback(
// Fallback: perturbation energy thresholds.
// FieldModel expects [n_links][n_subcarriers] — we use n_links=1.
let observation = vec![frames[0].clone()];
match field.extract_perturbation(&observation) {
Ok(perturbation) => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
Err(_) => score_to_person_count(smoothed_score, prev_count),
}
perturbation_occupancy(field, &frames)
.unwrap_or_else(|| score_to_person_count(smoothed_score, prev_count))
}
_ => score_to_person_count(smoothed_score, prev_count),
}
}
/// Return true only when a restored low-authority bootstrap model has a full
/// runtime window and that model resolves the window as empty. This helper is
/// deliberately one-way: a bootstrap prior may suppress an obvious background
/// false positive, but cannot authorize presence or vital signs.
pub fn bootstrap_empty_prior_applies(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> bool {
bootstrap_background_match(field, frame_history, observed_at_us)
.is_some_and(|result| result.matches_empty)
}
/// Score the current canonical runtime window against the learned empty-room
/// residual references. Legacy models without scalar references preserve the
/// binary match but intentionally return no score.
pub fn bootstrap_background_match(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<BootstrapBackgroundMatch> {
if frame_history.len() < OCCUPANCY_WINDOW
|| field.check_freshness(observed_at_us) != CalibrationStatus::Fresh
{
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
let result = field.empty_room_match(&frames)?;
Some(BootstrapBackgroundMatch {
matches_empty: result.matches_empty,
score: result.score,
residual_energy: result.residual_energy,
residual_energy_threshold: result.residual_energy_threshold,
window_size: result.window_size,
reference_window_count: result.reference_window_count,
})
}
/// Produce a fail-closed occupancy result from a fresh calibrated model.
pub fn calibrated_occupancy(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<CalibratedOccupancy> {
if field.check_freshness(observed_at_us) != CalibrationStatus::Fresh {
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
return None;
}
if let Ok(person_count) = field.estimate_occupancy(&frames) {
return Some(CalibratedOccupancy {
person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY),
method: CalibratedOccupancyMethod::Eigenvalue,
});
}
Some(CalibratedOccupancy {
person_count: perturbation_occupancy(field, &frames)?,
method: CalibratedOccupancyMethod::PerturbationEnergy,
})
}
/// Feed the latest frame to the FieldModel during calibration collection.
///
/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame
@@ -174,6 +331,33 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
mod tests {
use super::*;
fn fresh_test_model() -> FieldModel {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 10,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 2.0,
};
let mut field = FieldModel::new(config).expect("field model");
for index in 0..20 {
let phase = index as f64 * 0.1;
let observation = vec![(0..56)
.map(|subcarrier| {
1.0 + subcarrier as f64 * 0.01 + (phase + subcarrier as f64 * 0.03).sin() * 0.01
})
.collect()];
field
.feed_calibration(&observation)
.expect("calibration frame");
}
field
.finalize_calibration(1_000_000, 7)
.expect("finalize calibration");
field
}
#[test]
fn test_parse_node_positions() {
let positions = parse_node_positions("0,0,1.5;3,0,1.5;1.5,3,1.5");
@@ -263,4 +447,88 @@ mod tests {
"wide frame must accumulate, not be silently dropped"
);
}
#[test]
fn calibrated_occupancy_is_fresh_model_only_and_normalizes_runtime_frames() {
let field = fresh_test_model();
let mut history = VecDeque::new();
history.push_back(vec![1.02; 128]);
let result =
calibrated_occupancy(&field, &history, 1_500_000).expect("fresh calibrated inference");
assert!(result.person_count <= MAX_SINGLE_LINK_OCCUPANCY);
assert!(matches!(
result.method,
CalibratedOccupancyMethod::Eigenvalue | CalibratedOccupancyMethod::PerturbationEnergy
));
assert_eq!(calibrated_occupancy(&field, &history, 4_000_000), None);
assert_eq!(
calibrated_occupancy(&field, &VecDeque::new(), 1_500_000),
None
);
}
#[test]
fn adaptive_runtime_reference_scores_empty_and_rejects_shift() {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 500,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 60.0,
};
let mut field = FieldModel::new(config).expect("field model");
let mut calibration = Vec::new();
for frame_index in 0..600 {
let time = frame_index as f64 * 0.071;
let frame: Vec<f64> = (0..56)
.map(|subcarrier| {
let carrier = subcarrier as f64;
18.0 + carrier * 0.08
+ (time + carrier * 0.13).sin() * 0.8
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
})
.collect();
field.feed_calibration(&[frame.clone()]).unwrap();
calibration.push(frame);
}
field.finalize_calibration(1_000_000, 7).unwrap();
let empty_history: VecDeque<Vec<f64>> = calibration[550..600].iter().cloned().collect();
let empty = calibrated_occupancy(&field, &empty_history, 1_500_000)
.expect("adaptive perturbation result");
assert_eq!(empty.person_count, 0);
let background = bootstrap_background_match(&field, &empty_history, 1_500_000)
.expect("background score");
assert!(background.matches_empty);
assert_eq!(background.reference_window_count, 12);
assert!(background
.score
.is_some_and(|score| (0.5..=1.0).contains(&score)));
let short_history: VecDeque<Vec<f64>> = empty_history.iter().take(49).cloned().collect();
assert!(!bootstrap_empty_prior_applies(
&field,
&short_history,
1_500_000
));
let shifted_history: VecDeque<Vec<f64>> = calibration[550..600]
.iter()
.map(|frame| {
frame
.iter()
.enumerate()
.map(|(index, value)| value + if index % 3 == 0 { 8.0 } else { -5.0 })
.collect()
})
.collect();
let shifted = calibrated_occupancy(&field, &shifted_history, 1_500_000)
.expect("shifted perturbation result");
assert!(shifted.person_count >= 1);
let shifted_background = bootstrap_background_match(&field, &shifted_history, 1_500_000)
.expect("shifted background result");
assert!(!shifted_background.matches_empty);
assert_eq!(shifted_background.score, Some(0.0));
}
}
@@ -40,7 +40,9 @@ use wifi_densepose_sensing_server::{
trainer,
};
use wifi_densepose_sensing_server::bootstrap_baseline::{
self, BootstrapBaselineMetadata, BootstrapValidationSample, BOOTSTRAP_VALIDATION_SAMPLES,
self, BootstrapBaselineMetadata, BootstrapValidationSample,
BOOTSTRAP_VALIDATION_MIN_SPACING_MS, BOOTSTRAP_VALIDATION_SAMPLES,
BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS,
};
// ADR-295 / ADR-297: canonical provenance state + per-node/room inference.
use wifi_densepose_sensing_server::inference::{fuse_room, NodeInference, RoomInference};
@@ -1715,12 +1717,27 @@ impl AppStateInner {
fn person_count(&self) -> usize {
match self.field_model.as_ref() {
Some(fm) => {
// Prefer global frame_history (populated by wifi/simulate paths).
// Fall back to freshest per-node history (populated by ESP32 paths).
let history = if !self.frame_history.is_empty() {
// A single-link model may score only the source node it was
// calibrated against. Applying one node's baseline to a
// different radio creates deterministic false occupancy from
// hardware-specific amplitude offsets.
let bound_source_node_id = (self.calibration_source_node_ids.len() == 1)
.then(|| self.calibration_source_node_ids.iter().next().copied())
.flatten()
.or_else(|| {
self.bootstrap_baseline.as_ref().and_then(|metadata| {
(metadata.source_node_ids.len() == 1)
.then_some(metadata.source_node_ids[0])
})
});
let history = if let Some(node_id) = bound_source_node_id {
self.node_states
.get(&node_id)
.map(|state| &state.frame_history)
.unwrap_or(&self.frame_history)
} else if !self.frame_history.is_empty() {
&self.frame_history
} else {
// Find the node with the most recent frame
self.node_states
.values()
.filter(|ns| !ns.frame_history.is_empty())
@@ -1739,6 +1756,36 @@ impl AppStateInner {
}
}
/// A restored startup image has negative-only authority. It may suppress
/// an empty-room false positive after one complete runtime window, but it
/// cannot establish positive presence or publish vital signs.
fn bootstrap_empty_prior_applies(&self, observed_at_unix_ms: u64) -> bool {
self.bootstrap_background_match(observed_at_unix_ms)
.is_some_and(|result| result.matches_empty)
}
/// Return current background conformance for the exact source node bound
/// into the restored image. This remains diagnostic and negative-only.
fn bootstrap_background_match(
&self,
observed_at_unix_ms: u64,
) -> Option<field_bridge::BootstrapBackgroundMatch> {
if !self.bootstrap_baseline_active {
return None;
}
let field_model = self.field_model.as_ref()?;
let metadata = self.bootstrap_baseline.as_ref()?;
let [source_node_id] = metadata.source_node_ids.as_slice() else {
return None;
};
let history = &self.node_states.get(source_node_id)?.frame_history;
field_bridge::bootstrap_background_match(
field_model,
history,
observed_at_unix_ms.saturating_mul(1_000),
)
}
fn effective_source(&self) -> String {
if self.source == "esp32" {
if let Some(last) = self.last_esp32_frame {
@@ -6041,6 +6088,50 @@ fn vitals_for_publication(
})
}
fn edge_vitals_message_for_publication(
raw: &Esp32VitalsPacket,
published_vitals: Option<&VitalSigns>,
bootstrap_empty: bool,
explicit_calibration_fresh: bool,
person_count: usize,
) -> serde_json::Value {
let numeric_vitals_authorized = published_vitals.is_some();
let abstention_reason = if numeric_vitals_authorized {
None
} else if bootstrap_empty {
Some("bootstrap_empty_background")
} else if !explicit_calibration_fresh {
Some("explicit_calibration_required")
} else if person_count != 1 {
Some("exactly_one_occupant_required")
} else {
Some("vital_quality_gate_failed")
};
let effective_presence = raw.presence && !bootstrap_empty;
let effective_motion = raw.motion && !bootstrap_empty;
let effective_person_count = if bootstrap_empty { 0 } else { person_count };
serde_json::json!({
"type": "edge_vitals",
"node_id": raw.node_id,
"presence": effective_presence,
"fall_detected": raw.fall_detected && !bootstrap_empty,
"motion": effective_motion,
"breathing_rate_bpm": published_vitals
.and_then(|vitals| vitals.breathing_rate_bpm),
"heartrate_bpm": published_vitals
.and_then(|vitals| vitals.heart_rate_bpm),
"n_persons": effective_person_count,
"person_count_valid": raw.person_count_valid && !bootstrap_empty,
"motion_energy": if bootstrap_empty { 0.0 } else { raw.motion_energy },
"presence_score": if bootstrap_empty { 0.0 } else { raw.presence_score },
"rssi": raw.rssi,
"calibrated_evidence_authorized": explicit_calibration_fresh,
"numeric_vitals_authorized": numeric_vitals_authorized,
"abstention_reason": abstention_reason,
})
}
fn opaque_calibration_model_id() -> String {
let mut bytes = [0_u8; 16];
OsRng.fill_bytes(&mut bytes);
@@ -6048,10 +6139,26 @@ fn opaque_calibration_model_id() -> String {
format!("cal-model-{suffix}")
}
fn calibration_source_accepts(
source_node_ids: &std::collections::BTreeSet<u8>,
node_id: u8,
) -> bool {
source_node_ids.is_empty() || source_node_ids.contains(&node_id)
}
#[cfg(test)]
mod bootstrap_vital_publication_tests {
use super::*;
#[test]
fn single_link_calibration_binds_to_first_source() {
let mut sources = std::collections::BTreeSet::new();
assert!(calibration_source_accepts(&sources, 5));
sources.insert(5);
assert!(calibration_source_accepts(&sources, 5));
assert!(!calibration_source_accepts(&sources, 3));
}
fn strong_candidates() -> VitalSigns {
VitalSigns {
breathing_rate_bpm: Some(15.0),
@@ -6083,6 +6190,61 @@ mod bootstrap_vital_publication_tests {
candidates.signal_quality = 0.2;
assert!(vitals_for_publication(&candidates, true, 1).is_none());
}
fn raw_edge_vitals() -> Esp32VitalsPacket {
Esp32VitalsPacket {
node_id: 5,
presence: true,
fall_detected: true,
motion: true,
breathing_rate_bpm: 15.0,
heartrate_bpm: 72.0,
rssi: -42,
n_persons: 1,
person_count_valid: true,
motion_energy: 0.8,
presence_score: 0.9,
timestamp_ms: 42,
}
}
#[test]
fn bootstrap_empty_background_suppresses_legacy_edge_vitals() {
let message = edge_vitals_message_for_publication(
&raw_edge_vitals(),
None,
true,
false,
0,
);
assert_eq!(message["presence"], false);
assert_eq!(message["motion"], false);
assert_eq!(message["fall_detected"], false);
assert_eq!(message["n_persons"], 0);
assert!(message["breathing_rate_bpm"].is_null());
assert!(message["heartrate_bpm"].is_null());
assert_eq!(message["numeric_vitals_authorized"], false);
assert_eq!(message["abstention_reason"], "bootstrap_empty_background");
}
#[test]
fn explicit_single_occupant_may_publish_legacy_edge_vitals() {
let published = vitals_for_publication(&strong_candidates(), true, 1).unwrap();
let message = edge_vitals_message_for_publication(
&raw_edge_vitals(),
Some(&published),
false,
true,
1,
);
assert_eq!(message["presence"], true);
assert_eq!(message["fall_detected"], true);
assert_eq!(message["n_persons"], 1);
assert_eq!(message["breathing_rate_bpm"], 15.0);
assert_eq!(message["heartrate_bpm"], 72.0);
assert_eq!(message["numeric_vitals_authorized"], true);
assert!(message["abstention_reason"].is_null());
}
}
// ── Field model calibration endpoints (eigenvalue person counting) ──────────
@@ -6194,6 +6356,17 @@ async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::
async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
let bootstrap_background_match = s.bootstrap_background_match(observed_at_unix_ms);
let runtime_reference = s.field_model.as_ref().and_then(|model| {
model.modes().map(|modes| serde_json::json!({
"window_size": modes.baseline_runtime_window_size,
"significant_eigenvalue_p95": modes.baseline_runtime_eigenvalue_count,
"residual_energy_threshold": modes.empty_room_residual_energy_threshold,
"residual_reference_window_count": modes.empty_room_residual_energy_reference.len(),
"raw_calibration_frames_persisted": false,
}))
});
let (active, status, frame_count, min_frames, elapsed_s, frames_per_second, min_duration_s) =
s.field_model.as_ref().map_or(
(false, "none".to_string(), 0, 0, 0.0, 0.0, 0.0),
@@ -6219,6 +6392,7 @@ async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json
"min_duration_s": min_duration_s,
"model_id": s.calibration_model_id,
"source_node_ids": s.calibration_source_node_ids,
"runtime_reference": runtime_reference,
"binding_mode": if s.bootstrap_baseline_active { "bootstrap_only" } else if active { "runtime" } else { "none" },
"bootstrap_baseline": s.bootstrap_baseline.as_ref().map(|metadata| serde_json::json!({
"stored": true,
@@ -6229,6 +6403,15 @@ async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json
"created_at_unix_ms": metadata.created_at_unix_ms,
"expires_at_unix_ms": metadata.expires_at_unix_ms,
"content_sha256": metadata.content_sha256,
"background_match": bootstrap_background_match.map(|result| serde_json::json!({
"state": if result.matches_empty { "matched" } else { "changed" },
"matches_empty": result.matches_empty,
"score": result.score,
"residual_energy": result.residual_energy,
"residual_energy_threshold": result.residual_energy_threshold,
"window_size": result.window_size,
"reference_window_count": result.reference_window_count,
})),
"calibrated_evidence_authorized": false,
"numeric_vitals_authorized": false,
})).unwrap_or_else(|| serde_json::json!({
@@ -6274,8 +6457,11 @@ async fn calibration_promote_bootstrap(
let mut samples = Vec::with_capacity(BOOTSTRAP_VALIDATION_SAMPLES);
let mut last_tick = None;
let mut next_sample_at = tokio::time::Instant::now();
for _ in 0..BOOTSTRAP_VALIDATION_SAMPLES {
let interval_deadline = tokio::time::Instant::now() + Duration::from_secs(1);
tokio::time::sleep_until(next_sample_at).await;
let interval_deadline = tokio::time::Instant::now()
+ Duration::from_millis(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS);
let mut sample = BootstrapValidationSample {
fresh_tick: false,
calibrated_empty: false,
@@ -6310,7 +6496,8 @@ async fn calibration_promote_bootstrap(
tokio::time::sleep(Duration::from_millis(50)).await;
}
samples.push(sample);
tokio::time::sleep_until(interval_deadline).await;
next_sample_at = tokio::time::Instant::now()
+ Duration::from_millis(BOOTSTRAP_VALIDATION_MIN_SPACING_MS);
}
let validation = bootstrap_baseline::evaluate_validation(&samples);
@@ -6341,11 +6528,11 @@ async fn calibration_promote_bootstrap(
}));
};
let source_node_ids: Vec<u8> = s.calibration_source_node_ids.iter().copied().collect();
if source_node_ids.is_empty() || source_node_ids.len() > 16 {
if source_node_ids.len() != 1 {
return Json(serde_json::json!({
"success": false,
"error_code": "calibration_source_nodes_missing",
"error": "The active calibration must contain between 1 and 16 contributing ESP32 nodes.",
"error": "The single-link startup baseline must contain exactly one contributing ESP32 node.",
}));
}
let Some(field_model) = s.field_model.as_ref() else {
@@ -6825,6 +7012,7 @@ async fn mesh_endpoint(State(state): State<SharedState>) -> Json<serde_json::Val
async fn nodes_endpoint(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
let now = std::time::Instant::now();
let observed_at_unix_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
let nodes: Vec<serde_json::Value> = s
.node_states
.iter()
@@ -6836,13 +7024,24 @@ async fn nodes_endpoint(State(state): State<SharedState>) -> Json<serde_json::Va
let stale = elapsed_ms > 5000;
let status = if stale { "stale" } else { "active" };
let rssi = ns.rssi_history.back().copied().unwrap_or(-90.0);
let bootstrap_empty = s.bootstrap_baseline_active
&& s.bootstrap_baseline.as_ref().is_some_and(|metadata| {
metadata.source_node_ids.as_slice() == [id]
})
&& s.field_model.as_ref().is_some_and(|field_model| {
field_bridge::bootstrap_empty_prior_applies(
field_model,
&ns.frame_history,
observed_at_unix_ms.saturating_mul(1_000),
)
});
serde_json::json!({
"node_id": id,
"status": status,
"last_seen_ms": elapsed_ms,
"rssi_dbm": rssi,
"motion_level": &ns.current_motion_level,
"person_count": ns.prev_person_count,
"motion_level": if bootstrap_empty { "absent" } else { &ns.current_motion_level },
"person_count": if bootstrap_empty { 0 } else { ns.prev_person_count },
"person_count_valid": ns.edge_vitals
.as_ref()
.map(|vitals| vitals.person_count_valid),
@@ -7088,24 +7287,6 @@ async fn udp_receiver_task(
vitals.presence
);
let mut s = state.write().await;
// Broadcast vitals via WebSocket.
if let Ok(json) = serde_json::to_string(&serde_json::json!({
"type": "edge_vitals",
"node_id": vitals.node_id,
"presence": vitals.presence,
"fall_detected": vitals.fall_detected,
"motion": vitals.motion,
"breathing_rate_bpm": vitals.breathing_rate_bpm,
"heartrate_bpm": vitals.heartrate_bpm,
"n_persons": vitals.n_persons,
"person_count_valid": vitals.person_count_valid,
"motion_energy": vitals.motion_energy,
"presence_score": vitals.presence_score,
"rssi": vitals.rssi,
})) {
let _ = s.tx.send(json);
}
// Issue #323: Also emit a sensing_update so the UI renders
// detections for ESP32 nodes running the edge DSP pipeline
// (Tier 2+). Without this, vitals arrive but the UI shows
@@ -7153,9 +7334,18 @@ async fn udp_receiver_task(
0.05
};
// Aggregate person count: gate on presence first (matching WiFi path).
let observed_at_unix_ms =
chrono::Utc::now().timestamp_millis().max(0) as u64;
let bootstrap_empty =
s.bootstrap_empty_prior_applies(observed_at_unix_ms);
// A startup prior has negative-only authority. Once a full
// runtime window matches the stored background, raw edge
// presence cannot force the count back to one.
let now = std::time::Instant::now();
let total_persons = if vitals.presence {
let total_persons = if bootstrap_empty {
0
} else if vitals.presence {
let dedup = s.dedup_factor;
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
&s.multistatic_fuser,
@@ -7208,10 +7398,16 @@ async fn udp_receiver_task(
.get(&node_id)
.map(|ns| ns.frame_history.clone())
{
let accepted = if let Some(ref mut fm) = s.field_model {
let before = fm.calibration_frame_count();
field_bridge::maybe_feed_calibration(fm, &frame_history);
fm.calibration_frame_count() > before
let source_allowed =
calibration_source_accepts(&s.calibration_source_node_ids, node_id);
let accepted = if source_allowed {
if let Some(ref mut fm) = s.field_model {
let before = fm.calibration_frame_count();
field_bridge::maybe_feed_calibration(fm, &frame_history);
fm.calibration_frame_count() > before
} else {
false
}
} else {
false
};
@@ -7278,7 +7474,15 @@ async fn udp_receiver_task(
// node's own reading no longer overwrites the room's. The
// old ad-hoc "boost confidence by node count" is replaced by
// `room_inference`'s freshness-weighted multi-node confidence.
let classification = debounce_room_classification(&mut s, &room_inference);
let classification = if bootstrap_empty {
ClassificationInfo {
motion_level: "absent".to_string(),
presence: false,
confidence: 0.0,
}
} else {
debounce_room_classification(&mut s, &room_inference)
};
let signal_field = generate_signal_field(
fused_features.mean_rssi,
@@ -7306,6 +7510,21 @@ async fn udp_receiver_task(
total_persons,
);
// Keep the legacy edge_vitals stream aligned with the
// governed sensing envelope. Raw numeric candidates are
// never published before the explicit calibration and
// single-occupant gates have passed.
let edge_vitals_message = edge_vitals_message_for_publication(
&vitals,
published_vitals.as_ref(),
bootstrap_empty,
explicit_calibration_fresh,
total_persons,
);
if let Ok(json) = serde_json::to_string(&edge_vitals_message) {
let _ = s.tx.send(json);
}
let mut update = SensingUpdate {
msg_type: "sensing_update".to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
@@ -7645,9 +7864,17 @@ async fn udp_receiver_task(
0.05
};
// Aggregate person count: gate on presence first (matching WiFi path).
let observed_at_unix_ms =
chrono::Utc::now().timestamp_millis().max(0) as u64;
let bootstrap_empty =
s.bootstrap_empty_prior_applies(observed_at_unix_ms);
// A restored prior can suppress a background-only raw
// classification. It cannot authorize positive presence.
let now = std::time::Instant::now();
let total_persons = if classification.presence {
let total_persons = if bootstrap_empty {
0
} else if classification.presence {
let dedup = s.dedup_factor;
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
&s.multistatic_fuser,
@@ -7700,10 +7927,16 @@ async fn udp_receiver_task(
.get(&node_id)
.map(|ns| ns.frame_history.clone())
{
let accepted = if let Some(ref mut fm) = s.field_model {
let before = fm.calibration_frame_count();
field_bridge::maybe_feed_calibration(fm, &frame_history);
fm.calibration_frame_count() > before
let source_allowed =
calibration_source_accepts(&s.calibration_source_node_ids, node_id);
let accepted = if source_allowed {
if let Some(ref mut fm) = s.field_model {
let before = fm.calibration_frame_count();
field_bridge::maybe_feed_calibration(fm, &frame_history);
fm.calibration_frame_count() > before
} else {
false
}
} else {
false
};
@@ -7763,7 +7996,15 @@ async fn udp_receiver_task(
active_nodes.iter().filter_map(|ni| ni.node_inference.as_ref()),
NODE_STALE_AFTER_MS,
);
let room_classification = debounce_room_classification(&mut s, &room_inference);
let room_classification = if bootstrap_empty {
ClassificationInfo {
motion_level: "absent".to_string(),
presence: false,
confidence: 0.0,
}
} else {
debounce_room_classification(&mut s, &room_inference)
};
let explicit_calibration_fresh = !s.bootstrap_baseline_active
&& s.field_model.as_ref().is_some_and(|model| {
matches!(model.status(), CalibrationStatus::Fresh)
@@ -7,8 +7,8 @@
//! (the residual).
//!
//! # Algorithm
//! 1. Collect CSI during empty-room calibration (>=10 min wall-clock; the
//! frame target is this duration at the assumed 20 Hz single-node rate)
//! 1. Collect CSI during empty-room calibration (>=10 min wall-clock and at
//! least twenty complete runtime-sized background windows)
//! 2. Compute per-link baseline mean (Welford online accumulator)
//! 3. Decompose covariance via SVD to extract environmental modes
//! 4. At runtime: observation - baseline, project out top-K modes, keep residual
@@ -24,6 +24,7 @@ use ndarray_linalg::Eigh;
#[cfg(feature = "eigenvalue")]
use ndarray_linalg::UPLO;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
// ---------------------------------------------------------------------------
// Calibration window constants
@@ -33,21 +34,30 @@ use serde::{Deserialize, Serialize};
///
/// The Welford statistics exist to absorb slow environmental variation —
/// HVAC cycles, thermal drift — so the calibration gate is expressed in
/// wall-clock time. The historical frame target of 12,000 is this duration at
/// the assumed single-node rate. Every accepted node packet advances the same
/// field model, so N nodes reach any frame target N times faster; frames alone
/// therefore cannot gate calibration (#1756).
/// wall-clock time. Every accepted node packet advances the same field model,
/// so N nodes reach any frame target N times faster; frames alone therefore
/// cannot gate calibration (#1756).
pub const CALIBRATION_DURATION_S: f64 = 600.0;
/// Assumed per-node CSI rate (Hz) used to relate the frame target to
/// [`CALIBRATION_DURATION_S`] ("10 min at 20 Hz").
pub const ASSUMED_CALIBRATION_RATE_HZ: f64 = 20.0;
/// A recent window whose mean remains within this residual energy of the
/// empty room manifold is treated as background before eigenvalue counting.
#[cfg(feature = "eigenvalue")]
const EMPTY_ROOM_RESIDUAL_ENERGY_MAX: f64 = 1.0;
/// Runtime occupancy is evaluated over fifty frames. Keep ten raw windows
/// from the end of calibration for covariance rank learning, plus bounded
/// privacy reduced window means spanning the full capture for residual
/// conformance scoring.
const RUNTIME_OCCUPANCY_WINDOW: usize = 50;
const CALIBRATION_REFERENCE_FRAMES: usize = RUNTIME_OCCUPANCY_WINDOW * 10;
/// Minimum independent runtime sized means required to learn an empirical
/// empty room residual distribution. Wall clock duration remains the primary
/// coverage gate, so slow hardware is not forced to imitate a nominal rate.
pub const MIN_BACKGROUND_REFERENCE_WINDOWS: usize = 20;
pub const MIN_CALIBRATION_FRAMES: usize =
RUNTIME_OCCUPANCY_WINDOW * MIN_BACKGROUND_REFERENCE_WINDOWS;
const CALIBRATION_BACKGROUND_WINDOWS_MAX: usize = 512;
const EMPTY_ROOM_RESIDUAL_MARGIN: f64 = 1.5;
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
@@ -276,12 +286,11 @@ pub struct FieldModelConfig {
pub n_subcarriers: usize,
/// Number of environmental modes to retain (K). Max 5.
pub n_modes: usize,
/// Minimum calibration frames before baseline is valid. Derived from the
/// intended wall-clock window at the assumed single-node rate
/// (`CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ` = 12,000).
/// A frame count alone is not sufficient: N nodes streaming in aggregate
/// reach it in 1/N of the window, so `min_calibration_duration_s` also
/// gates finalization (#1756).
/// Minimum calibration frames before baseline is valid. This is the
/// runtime window size times the minimum number of independent background
/// references. A frame count alone is not sufficient: N nodes streaming
/// in aggregate reach it in 1/N of the window, so
/// `min_calibration_duration_s` also gates finalization (#1756).
pub min_calibration_frames: usize,
/// Minimum wall-clock duration of the calibration window in seconds.
/// Slow environmental variation (HVAC cycles, thermal drift) can only be
@@ -298,8 +307,7 @@ impl Default for FieldModelConfig {
n_links: 6,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: (CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ)
as usize,
min_calibration_frames: MIN_CALIBRATION_FRAMES,
min_calibration_duration_s: CALIBRATION_DURATION_S,
baseline_expiry_s: 86_400.0,
}
@@ -336,6 +344,37 @@ pub struct FieldNormalMode {
/// per-window sample size. Defaults to 0.0 in the diagonal-fallback path.
/// Issue #942.
pub baseline_noise_var: f64,
/// Empty room significant eigenvalue count learned from runtime sized
/// windows. The percentile is stored instead of raw CSI. `None` identifies
/// snapshots created before runtime window calibration was introduced.
#[serde(default)]
pub baseline_runtime_eigenvalue_count: Option<usize>,
/// Number of frames used for the runtime window reference above.
#[serde(default)]
pub baseline_runtime_window_size: Option<usize>,
/// Hardware adaptive upper bound for the residual energy of an empty room
/// window mean. Only this aggregate boundary is persisted.
#[serde(default)]
pub empty_room_residual_energy_threshold: Option<f64>,
/// Sorted residual energies of privacy reduced runtime window means sampled
/// across the full calibration. Raw CSI is never persisted. These scalar
/// references support an empirical background match score without turning
/// an empty room model into positive person evidence.
#[serde(default)]
pub empty_room_residual_energy_reference: Vec<f64>,
}
/// A bounded comparison between one runtime window and the learned empty room
/// residual distribution. `score` is an empirical conformance score, not a
/// probability that a person is absent.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EmptyRoomMatch {
pub matches_empty: bool,
pub score: Option<f64>,
pub residual_energy: f64,
pub residual_energy_threshold: f64,
pub window_size: usize,
pub reference_window_count: usize,
}
/// Body perturbation extracted from a CSI observation.
@@ -405,6 +444,16 @@ pub struct FieldModel {
/// Monotonic timestamp of the first accepted calibration frame of the
/// current session; `None` until collection starts (#1756).
calibration_started: Option<std::time::Instant>,
/// Bounded, memory only tail used to learn inference sized empty room
/// covariance statistics. It is never included in a snapshot.
calibration_reference_tail: VecDeque<Vec<Vec<f64>>>,
/// Per link sum for the current privacy reduced runtime sized window.
calibration_window_sum: Vec<Vec<f64>>,
/// Number of accepted observations in `calibration_window_sum`.
calibration_window_count: usize,
/// Runtime sized per link means spanning the full calibration. This is
/// bounded, memory only, and reduced to scalar residuals at finalization.
calibration_window_means: VecDeque<Vec<Vec<f64>>>,
}
/// Diagonal variance fallback for when full covariance SVD is unavailable.
@@ -460,6 +509,133 @@ fn diagonal_fallback(
(mode_energies, environmental_modes, baseline_count)
}
#[cfg(feature = "eigenvalue")]
fn significant_eigenvalue_count(
frames: &[Vec<f64>],
n_subcarriers: usize,
baseline_noise_var: f64,
) -> Option<usize> {
if frames.len() < 10 {
return None;
}
let mut mean = vec![0.0_f64; n_subcarriers];
let mut count = 0_usize;
for frame in frames {
if frame.len() >= n_subcarriers {
for index in 0..n_subcarriers {
mean[index] += frame[index];
}
count += 1;
}
}
if count < 2 {
return None;
}
for value in &mut mean {
*value /= count as f64;
}
let mut covariance = Array2::<f64>::zeros((n_subcarriers, n_subcarriers));
for frame in frames {
if frame.len() >= n_subcarriers {
for row in 0..n_subcarriers {
let centered_row = frame[row] - mean[row];
for column in row..n_subcarriers {
let value = centered_row * (frame[column] - mean[column]);
covariance[[row, column]] += value;
if row != column {
covariance[[column, row]] += value;
}
}
}
}
}
covariance *= 1.0 / (count as f64 - 1.0);
let (eigenvalues, _) = covariance.eigh(UPLO::Upper).ok()?;
let mut positive: Vec<f64> = eigenvalues
.iter()
.copied()
.filter(|value| *value > 1e-10)
.collect();
positive.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
let local_noise_var = if positive.len() >= 4 {
let half = positive.len() / 2;
positive[..half].iter().sum::<f64>() / half as f64
} else {
positive.first().copied()?
};
let noise_var = if baseline_noise_var > 0.0 {
local_noise_var.max(baseline_noise_var)
} else {
local_noise_var
};
let ratio = n_subcarriers as f64 / count as f64;
let threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
Some(
eigenvalues
.iter()
.filter(|&&value| value > threshold)
.count(),
)
}
fn residual_energy_for_link(
modes: &FieldNormalMode,
link_index: usize,
observation: &[f64],
) -> Option<f64> {
let baseline = modes.baseline.get(link_index)?;
if observation.len() != baseline.len() {
return None;
}
let mut residual: Vec<f64> = observation
.iter()
.zip(baseline.iter())
.map(|(value, reference)| value - reference)
.collect();
for mode in &modes.environmental_modes {
let projection: f64 = residual
.iter()
.zip(mode.iter())
.map(|(value, basis)| value * basis)
.sum();
for (value, basis) in residual.iter_mut().zip(mode.iter()) {
*value -= projection * basis;
}
}
Some(
residual
.iter()
.map(|value| value * value)
.sum::<f64>()
.sqrt(),
)
}
fn percentile_95(mut values: Vec<f64>) -> Option<f64> {
values.retain(|value| value.is_finite());
values.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
let index = values
.len()
.saturating_mul(95)
.div_ceil(100)
.saturating_sub(1);
values.get(index).copied()
}
#[cfg(feature = "eigenvalue")]
fn percentile_95_usize(mut values: Vec<usize>) -> Option<usize> {
values.sort_unstable();
let index = values
.len()
.saturating_mul(95)
.div_ceil(100)
.saturating_sub(1);
values.get(index).copied()
}
impl FieldModel {
/// Create a new field model for the given configuration.
pub fn new(config: FieldModelConfig) -> Result<Self, FieldModelError> {
@@ -480,6 +656,7 @@ impl FieldModel {
let link_stats = (0..config.n_links)
.map(|_| LinkBaselineStats::new(config.n_subcarriers))
.collect();
let calibration_window_sum = vec![vec![0.0_f64; config.n_subcarriers]; config.n_links];
Ok(Self {
config,
@@ -490,6 +667,10 @@ impl FieldModel {
covariance_sum: None,
covariance_count: 0,
calibration_started: None,
calibration_reference_tail: VecDeque::with_capacity(CALIBRATION_REFERENCE_FRAMES),
calibration_window_sum,
calibration_window_count: 0,
calibration_window_means: VecDeque::with_capacity(CALIBRATION_BACKGROUND_WINDOWS_MAX),
})
}
@@ -503,6 +684,14 @@ impl FieldModel {
self.modes.as_ref()
}
/// Hardware adaptive empty room boundary learned at finalization. Returns
/// `None` for legacy snapshots that predate runtime window calibration.
pub fn empty_room_residual_energy_threshold(&self) -> Option<f64> {
self.modes
.as_ref()
.and_then(|modes| modes.empty_room_residual_energy_threshold)
}
/// Export aggregate model state suitable for local restart persistence.
/// Raw calibration observations and Welford accumulators are not exported.
pub fn export_snapshot(&self) -> Result<FieldModelSnapshotV1, FieldModelError> {
@@ -561,7 +750,26 @@ impl FieldModel {
&& (0.0..=10.0).contains(&modes.variance_explained)
&& modes.baseline_noise_var.is_finite()
&& modes.baseline_noise_var >= 0.0
&& modes.baseline_eigenvalue_count <= config.n_subcarriers;
&& modes.baseline_eigenvalue_count <= config.n_subcarriers
&& modes
.baseline_runtime_eigenvalue_count
.is_none_or(|count| count <= config.n_subcarriers)
&& modes
.baseline_runtime_window_size
.is_none_or(|size| (10..=CALIBRATION_REFERENCE_FRAMES).contains(&size))
&& modes
.empty_room_residual_energy_threshold
.is_none_or(|threshold| threshold.is_finite() && threshold >= 0.0)
&& modes.empty_room_residual_energy_reference.len()
<= CALIBRATION_BACKGROUND_WINDOWS_MAX
&& modes
.empty_room_residual_energy_reference
.iter()
.all(|value| value.is_finite() && *value >= 0.0)
&& modes
.empty_room_residual_energy_reference
.windows(2)
.all(|window| window[0] <= window[1]);
if !baseline_shape_valid || !mode_shape_valid || !values_valid {
return Err(FieldModelError::InvalidConfig(
"snapshot modes are malformed or non finite".into(),
@@ -678,6 +886,39 @@ impl FieldModel {
// Count once per frame (not per link) for correct MP ratio
self.covariance_count += 1;
if self.calibration_reference_tail.len() == CALIBRATION_REFERENCE_FRAMES {
self.calibration_reference_tail.pop_front();
}
self.calibration_reference_tail
.push_back(observations.to_vec());
for (link_sum, observation) in self
.calibration_window_sum
.iter_mut()
.zip(observations.iter())
{
for (sum, value) in link_sum.iter_mut().zip(observation.iter()) {
*sum += value;
}
}
self.calibration_window_count += 1;
if self.calibration_window_count == RUNTIME_OCCUPANCY_WINDOW {
let divisor = self.calibration_window_count as f64;
let means: Vec<Vec<f64>> = self
.calibration_window_sum
.iter()
.map(|link_sum| link_sum.iter().map(|value| value / divisor).collect())
.collect();
if self.calibration_window_means.len() == CALIBRATION_BACKGROUND_WINDOWS_MAX {
self.calibration_window_means.pop_front();
}
self.calibration_window_means.push_back(means);
for link_sum in &mut self.calibration_window_sum {
link_sum.fill(0.0);
}
self.calibration_window_count = 0;
}
Ok(())
}
@@ -812,8 +1053,7 @@ impl FieldModel {
}
Err(_) => {
// Fallback to diagonal approximation on SVD failure
let (e, m, b) =
diagonal_fallback(&self.link_stats, n_sc, n_modes);
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
}
}
@@ -865,7 +1105,7 @@ impl FieldModel {
0.0
};
let field_mode = FieldNormalMode {
let mut field_mode = FieldNormalMode {
baseline,
environmental_modes,
mode_energies,
@@ -874,15 +1114,142 @@ impl FieldModel {
geometry_hash,
baseline_eigenvalue_count: baseline_eig_count,
baseline_noise_var,
baseline_runtime_eigenvalue_count: None,
baseline_runtime_window_size: None,
empty_room_residual_energy_threshold: None,
empty_room_residual_energy_reference: Vec::new(),
};
// The full calibration covariance and a fifty frame runtime covariance
// have different Marcenko Pastur aspect ratios. This first version is
// deliberately single link: combining link specific residual scales
// requires a separate multistatic calibration model.
if self.config.n_links == 1 {
let reference_frames: Vec<Vec<f64>> = self
.calibration_reference_tail
.iter()
.filter_map(|observation| observation.first().cloned())
.collect();
let complete_windows: Vec<&[Vec<f64>]> = reference_frames
.chunks(RUNTIME_OCCUPANCY_WINDOW)
.filter(|window| window.len() == RUNTIME_OCCUPANCY_WINDOW)
.collect();
#[cfg(feature = "eigenvalue")]
{
let counts: Vec<usize> = complete_windows
.iter()
.filter_map(|window| {
significant_eigenvalue_count(window, n_sc, baseline_noise_var)
})
.collect();
field_mode.baseline_runtime_eigenvalue_count = percentile_95_usize(counts);
}
let mut residual_energies: Vec<f64> = self
.calibration_window_means
.iter()
.filter_map(|means| means.first())
.filter_map(|mean| residual_energy_for_link(&field_mode, 0, mean))
.filter(|value| value.is_finite() && *value >= 0.0)
.collect();
if residual_energies.is_empty() {
residual_energies = complete_windows
.iter()
.filter_map(|window| {
let mut mean = vec![0.0_f64; n_sc];
for frame in *window {
for (sum, value) in mean.iter_mut().zip(frame.iter()) {
*sum += value;
}
}
for value in &mut mean {
*value /= window.len() as f64;
}
residual_energy_for_link(&field_mode, 0, &mean)
})
.collect();
}
residual_energies.sort_by(|left, right| {
left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)
});
field_mode.empty_room_residual_energy_threshold =
percentile_95(residual_energies.clone()).map(|value| {
(value * EMPTY_ROOM_RESIDUAL_MARGIN).max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX)
});
field_mode.empty_room_residual_energy_reference = residual_energies;
if field_mode.baseline_runtime_eigenvalue_count.is_some()
|| field_mode.empty_room_residual_energy_threshold.is_some()
{
field_mode.baseline_runtime_window_size = Some(RUNTIME_OCCUPANCY_WINDOW);
}
}
self.modes = Some(field_mode);
self.status = CalibrationStatus::Fresh;
self.last_calibration_us = timestamp_us;
self.calibration_reference_tail.clear();
self.calibration_window_means.clear();
for link_sum in &mut self.calibration_window_sum {
link_sum.fill(0.0);
}
self.calibration_window_count = 0;
Ok(self.modes.as_ref().unwrap())
}
/// Compare a runtime sized single link window with the learned empty room
/// distribution. The score is empirical conformance while the learned
/// boundary holds. Crossing the boundary returns zero. Legacy snapshots
/// without residual references retain binary suppression but no score.
pub fn empty_room_match(&self, recent_frames: &[Vec<f64>]) -> Option<EmptyRoomMatch> {
let modes = self.modes.as_ref()?;
if self.config.n_links != 1 {
return None;
}
let window_size = modes.baseline_runtime_window_size?;
if recent_frames.len() < window_size {
return None;
}
let mut mean = vec![0.0_f64; self.config.n_subcarriers];
for frame in recent_frames.iter().take(window_size) {
if frame.len() < self.config.n_subcarriers {
return None;
}
for (sum, value) in mean.iter_mut().zip(frame.iter()) {
*sum += value;
}
}
for value in &mut mean {
*value /= window_size as f64;
}
let residual_energy = residual_energy_for_link(modes, 0, &mean)?;
let residual_energy_threshold = modes
.empty_room_residual_energy_threshold?
.max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX);
let matches_empty = residual_energy <= residual_energy_threshold;
let reference_window_count = modes.empty_room_residual_energy_reference.len();
let score = if !matches_empty {
Some(0.0)
} else if reference_window_count == 0 {
None
} else {
let at_or_below = modes
.empty_room_residual_energy_reference
.partition_point(|value| *value <= residual_energy);
let empirical_quantile = at_or_below as f64 / reference_window_count as f64;
Some((1.0 - empirical_quantile * 0.5).clamp(0.5, 1.0))
};
Some(EmptyRoomMatch {
matches_empty,
score,
residual_energy,
residual_energy_threshold,
window_size,
reference_window_count,
})
}
/// Extract body perturbation from a runtime observation.
///
/// Subtracts baseline, projects out environmental modes, returns residual.
@@ -993,14 +1360,19 @@ impl FieldModel {
*m /= count as f64;
}
// Rank alone is not person evidence. A continuation window can expose
// more significant eigenvalues while its mean remains on the learned
// empty room manifold. Fail toward empty only inside the same
// conservative residual energy boundary used by the server fallback.
if self
// Rank alone is not person evidence. A short continuation window can
// expose more significant eigenvalues while its mean remains on the
// learned empty room manifold. Use the hardware adaptive residual
// boundary when available.
let empty_room_residual_energy_max = modes
.empty_room_residual_energy_threshold
.unwrap_or(EMPTY_ROOM_RESIDUAL_ENERGY_MAX)
.max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX);
let mean_residual_energy = self
.extract_perturbation(&[mean.clone()])
.is_ok_and(|perturbation| perturbation.total_energy <= EMPTY_ROOM_RESIDUAL_ENERGY_MAX)
{
.ok()
.map(|perturbation| perturbation.total_energy);
if mean_residual_energy.is_some_and(|energy| energy <= empty_room_residual_energy_max) {
return Ok(0);
}
@@ -1064,7 +1436,23 @@ impl FieldModel {
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
let significant = eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count();
let occupancy = significant.saturating_sub(modes.baseline_eigenvalue_count);
let reference_count = if modes.baseline_runtime_window_size == Some(count) {
modes
.baseline_runtime_eigenvalue_count
.unwrap_or(modes.baseline_eigenvalue_count)
} else {
modes.baseline_eigenvalue_count
};
let rank_occupancy = significant.saturating_sub(reference_count);
// A stable person can shift the window mean without increasing its
// covariance rank. Preserve at least one occupant after the learned
// empty room residual boundary is exceeded.
let occupancy =
if mean_residual_energy.is_some_and(|energy| energy > empty_room_residual_energy_max) {
rank_occupancy.max(1)
} else {
rank_occupancy
};
Ok(occupancy.min(10)) // Cap at 10 persons
}
@@ -1103,6 +1491,12 @@ impl FieldModel {
self.covariance_sum = None;
self.covariance_count = 0;
self.calibration_started = None;
self.calibration_reference_tail.clear();
self.calibration_window_count = 0;
for link_sum in &mut self.calibration_window_sum {
link_sum.fill(0.0);
}
self.calibration_window_means.clear();
}
}
@@ -1623,6 +2017,131 @@ mod tests {
assert_eq!(occupancy, 0, "Noise-only frames should yield 0 occupancy");
}
#[cfg(feature = "eigenvalue")]
#[test]
fn runtime_sized_empty_reference_scores_held_out_background() {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 500,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 86_400.0,
};
let mut model = FieldModel::new(config).unwrap();
let mut calibration = Vec::new();
for frame_index in 0..600 {
let time = frame_index as f64 * 0.071;
let frame: Vec<f64> = (0..56)
.map(|subcarrier| {
let carrier = subcarrier as f64;
18.0 + carrier * 0.08
+ (time + carrier * 0.13).sin() * 0.8
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
})
.collect();
model.feed_calibration(&[frame.clone()]).unwrap();
calibration.push(frame);
}
model.finalize_calibration(1_000_000, 0).unwrap();
let modes = model.modes().unwrap();
assert_eq!(
modes.baseline_runtime_window_size,
Some(RUNTIME_OCCUPANCY_WINDOW)
);
assert!(modes.baseline_runtime_eigenvalue_count.is_some());
assert!(modes.empty_room_residual_energy_threshold.is_some());
assert_eq!(modes.empty_room_residual_energy_reference.len(), 12);
let held_out_empty = calibration[550..600].to_vec();
assert_eq!(model.estimate_occupancy(&held_out_empty).unwrap(), 0);
let background = model
.empty_room_match(&held_out_empty)
.expect("background match");
assert!(background.matches_empty);
assert_eq!(background.reference_window_count, 12);
assert!(background
.score
.is_some_and(|score| (0.5..=1.0).contains(&score)));
}
#[cfg(feature = "eigenvalue")]
#[test]
fn runtime_reference_rejects_shifted_held_out_window() {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 500,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 86_400.0,
};
let mut model = FieldModel::new(config).unwrap();
for frame_index in 0..600 {
let time = frame_index as f64 * 0.071;
let frame: Vec<f64> = (0..56)
.map(|subcarrier| {
let carrier = subcarrier as f64;
18.0 + carrier * 0.08
+ (time + carrier * 0.13).sin() * 0.8
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
})
.collect();
model.feed_calibration(&[frame]).unwrap();
}
model.finalize_calibration(1_000_000, 0).unwrap();
let shifted: Vec<Vec<f64>> = (0..RUNTIME_OCCUPANCY_WINDOW)
.map(|frame_index| {
let time = (600 + frame_index) as f64 * 0.071;
(0..56)
.map(|subcarrier| {
let carrier = subcarrier as f64;
let body_shift = if subcarrier % 3 == 0 { 8.0 } else { -5.0 };
18.0 + carrier * 0.08
+ (time + carrier * 0.13).sin() * 0.8
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
+ body_shift
})
.collect()
})
.collect();
assert!(model.estimate_occupancy(&shifted).unwrap() >= 1);
let background = model.empty_room_match(&shifted).expect("shifted match");
assert!(!background.matches_empty);
assert_eq!(background.score, Some(0.0));
}
#[test]
fn runtime_background_reference_is_single_link_only() {
let config = FieldModelConfig {
n_links: 2,
n_subcarriers: 8,
n_modes: 2,
min_calibration_frames: 100,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 86_400.0,
};
let mut model = FieldModel::new(config).unwrap();
for frame_index in 0..100 {
let phase = frame_index as f64 * 0.1;
model
.feed_calibration(&[
(0..8).map(|index| phase + index as f64).collect(),
(0..8).map(|index| phase - index as f64).collect(),
])
.unwrap();
}
model.finalize_calibration(1_000_000, 0).unwrap();
let modes = model.modes().unwrap();
assert_eq!(modes.baseline_runtime_window_size, None);
assert_eq!(modes.baseline_runtime_eigenvalue_count, None);
assert_eq!(modes.empty_room_residual_energy_threshold, None);
assert!(modes.empty_room_residual_energy_reference.is_empty());
}
#[test]
fn test_baseline_eigenvalue_count_stored() {
let config = FieldModelConfig {
@@ -1735,14 +2254,15 @@ mod tests {
// -----------------------------------------------------------------
#[test]
fn test_default_frame_target_derives_from_duration() {
fn test_default_frame_target_covers_background_reference_windows() {
let cfg = FieldModelConfig::default();
assert_eq!(cfg.min_calibration_duration_s, CALIBRATION_DURATION_S);
assert_eq!(
cfg.min_calibration_frames,
(CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ) as usize,
"frame target must be the intended window at the assumed rate"
cfg.min_calibration_frames, MIN_CALIBRATION_FRAMES,
"frame target must cover the minimum independent runtime windows"
);
assert_eq!(MIN_CALIBRATION_FRAMES, 1_000);
assert_eq!(MIN_BACKGROUND_REFERENCE_WINDOWS, 20);
}
#[test]