mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
fix(calibration): learn held out empty residuals
This commit is contained in:
1 parent
30404dd9cf
commit
48c6244d37
3 files changed
+373
No files matched your search
@@ -54,6 +54,14 @@ 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.
|
||||
|
||||
A restored image may receive one operator confirmed held out empty refinement.
|
||||
The server measures 12 fresh, source bound runtime residuals at one second
|
||||
spacing and requires zero stale samples and zero numeric vital samples. It
|
||||
persists only the scalar residuals. The new boundary is the larger of the
|
||||
existing threshold and 110 percent of the held out 95th percentile, capped at
|
||||
125 percent of the prior threshold. The one use marker persists in the model,
|
||||
so repeated requests cannot ratchet the threshold upward.
|
||||
|
||||
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
|
||||
@@ -94,6 +102,9 @@ occupied sequences. Empty data is never relabeled as human training.
|
||||
7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for
|
||||
deterministic single link capture. A missing or stale selection fails
|
||||
without disabling the active bootstrap prior.
|
||||
8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N`
|
||||
performs the bounded one time held out refinement. It requires administrator
|
||||
authority when transport authentication is enabled.
|
||||
|
||||
## Measured installation evidence
|
||||
|
||||
@@ -118,3 +129,6 @@ 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.
|
||||
When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of
|
||||
the source set for the entire capture.
|
||||
After restart, a confirmed empty holdout may refine the restored boundary once.
|
||||
The stored snapshot must reject a second refinement and must continue to deny
|
||||
calibrated evidence and numeric vital authority.
|
||||
@@ -6264,6 +6264,13 @@ struct CalibrationStartQuery {
|
||||
source_node_id: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct BootstrapRefineQuery {
|
||||
#[serde(default)]
|
||||
confirmed_empty: bool,
|
||||
source_node_id: Option<u8>,
|
||||
}
|
||||
|
||||
async fn calibration_start(
|
||||
State(state): State<SharedState>,
|
||||
Query(query): Query<CalibrationStartQuery>,
|
||||
@@ -6401,6 +6408,7 @@ async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json
|
||||
"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(),
|
||||
"residual_refinement_count": modes.empty_room_residual_refinement_count,
|
||||
"raw_calibration_frames_persisted": false,
|
||||
}))
|
||||
});
|
||||
@@ -6611,6 +6619,227 @@ async fn calibration_promote_bootstrap(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Measure one separate operator-confirmed empty holdout and make a bounded,
|
||||
/// single-use adjustment to a restored bootstrap residual boundary. Only
|
||||
/// scalar residual energies enter the persisted image; raw CSI never does.
|
||||
async fn calibration_refine_bootstrap(
|
||||
State(state): State<SharedState>,
|
||||
Query(query): Query<BootstrapRefineQuery>,
|
||||
) -> Json<serde_json::Value> {
|
||||
if !query.confirmed_empty {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "empty_confirmation_required",
|
||||
"error": "Explicit empty-room confirmation is required for bootstrap refinement.",
|
||||
}));
|
||||
}
|
||||
|
||||
let (expected_model_id, expected_digest, source_node_id) = {
|
||||
let s = state.read().await;
|
||||
let Some(metadata) = s.bootstrap_baseline.as_ref().filter(|_| s.bootstrap_baseline_active)
|
||||
else {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_not_active",
|
||||
"error": "A restored startup baseline must be active before refinement.",
|
||||
}));
|
||||
};
|
||||
let [source_node_id] = metadata.source_node_ids.as_slice() else {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_source_invalid",
|
||||
"error": "Bootstrap refinement requires exactly one bound source node.",
|
||||
}));
|
||||
};
|
||||
if query
|
||||
.source_node_id
|
||||
.is_some_and(|requested| requested != *source_node_id)
|
||||
{
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_source_mismatch",
|
||||
"error": "The requested source does not match the stored bootstrap source.",
|
||||
}));
|
||||
}
|
||||
(
|
||||
metadata.source_model_id.clone(),
|
||||
metadata.content_sha256.clone(),
|
||||
*source_node_id,
|
||||
)
|
||||
};
|
||||
|
||||
let mut residuals = Vec::with_capacity(BOOTSTRAP_VALIDATION_SAMPLES);
|
||||
let mut stale_sample_count = 0usize;
|
||||
let mut vital_sign_sample_count = 0usize;
|
||||
let mut last_sequence = None;
|
||||
let mut next_sample_at = tokio::time::Instant::now();
|
||||
for _ in 0..BOOTSTRAP_VALIDATION_SAMPLES {
|
||||
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 observed = None;
|
||||
while tokio::time::Instant::now() < interval_deadline {
|
||||
observed = {
|
||||
let s = state.read().await;
|
||||
let identity_matches = s.bootstrap_baseline_active
|
||||
&& s.bootstrap_baseline.as_ref().is_some_and(|metadata| {
|
||||
metadata.source_model_id == expected_model_id
|
||||
&& metadata.content_sha256 == expected_digest
|
||||
&& metadata.source_node_ids.as_slice() == [source_node_id]
|
||||
});
|
||||
if !identity_matches {
|
||||
None
|
||||
} else {
|
||||
s.node_states.get(&source_node_id).and_then(|node| {
|
||||
let sequence = node.latest_csi_sequence?;
|
||||
let fresh = node.last_frame_time.is_some_and(|seen| {
|
||||
std::time::Instant::now().saturating_duration_since(seen)
|
||||
< ESP32_OFFLINE_TIMEOUT
|
||||
}) && Some(sequence) != last_sequence;
|
||||
if !fresh {
|
||||
return None;
|
||||
}
|
||||
let model = s.field_model.as_ref()?;
|
||||
let recent_frames: Vec<Vec<f64>> =
|
||||
node.frame_history.iter().cloned().collect();
|
||||
let residual = model.empty_room_match(&recent_frames)?.residual_energy;
|
||||
let vital_signs_absent = s
|
||||
.latest_update
|
||||
.as_ref()
|
||||
.is_some_and(|update| update.vital_signs.is_none());
|
||||
Some((sequence, residual, vital_signs_absent))
|
||||
})
|
||||
}
|
||||
};
|
||||
if observed.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if let Some((sequence, residual, vital_signs_absent)) = observed {
|
||||
last_sequence = Some(sequence);
|
||||
residuals.push(residual);
|
||||
if !vital_signs_absent {
|
||||
vital_sign_sample_count += 1;
|
||||
}
|
||||
} else {
|
||||
stale_sample_count += 1;
|
||||
}
|
||||
next_sample_at = tokio::time::Instant::now()
|
||||
+ Duration::from_millis(BOOTSTRAP_VALIDATION_MIN_SPACING_MS);
|
||||
}
|
||||
|
||||
if residuals.len() != BOOTSTRAP_VALIDATION_SAMPLES
|
||||
|| stale_sample_count != 0
|
||||
|| vital_sign_sample_count != 0
|
||||
{
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_refinement_validation_failed",
|
||||
"error": "The fresh empty-room refinement gate did not pass. The stored baseline was not changed.",
|
||||
"validation": {
|
||||
"sample_count": residuals.len(),
|
||||
"stale_sample_count": stale_sample_count,
|
||||
"vital_sign_sample_count": vital_sign_sample_count,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
let identity_matches = s.bootstrap_baseline_active
|
||||
&& s.bootstrap_baseline.as_ref().is_some_and(|metadata| {
|
||||
metadata.source_model_id == expected_model_id
|
||||
&& metadata.content_sha256 == expected_digest
|
||||
&& metadata.source_node_ids.as_slice() == [source_node_id]
|
||||
});
|
||||
if !identity_matches {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_changed",
|
||||
"error": "The bootstrap identity changed during refinement. The stored baseline was not changed.",
|
||||
}));
|
||||
}
|
||||
let candidate_snapshot = match s
|
||||
.field_model
|
||||
.as_ref()
|
||||
.ok_or(wifi_densepose_signal::ruvsense::field_model::FieldModelError::NotCalibrated)
|
||||
.and_then(|model| model.export_snapshot())
|
||||
{
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(_) => {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_refinement_rejected",
|
||||
"error": "The bootstrap model rejected this bounded refinement.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
let now_us = chrono::Utc::now().timestamp_micros() as u64;
|
||||
let mut candidate = match FieldModel::from_snapshot(candidate_snapshot, now_us) {
|
||||
Ok(model) => model,
|
||||
Err(_) => {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_refinement_rejected",
|
||||
"error": "The bootstrap model rejected this bounded refinement.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
let refinement = match candidate.refine_empty_room_residual_boundary(&residuals) {
|
||||
Ok(refinement) => refinement,
|
||||
Err(_) => {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "bootstrap_refinement_rejected",
|
||||
"error": "The bootstrap model rejected this bounded refinement.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
let Some(installation_id) = s.installation_id.clone() else {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": "installation_id_required",
|
||||
"error": "A stable installation identity is required to store a local startup baseline.",
|
||||
}));
|
||||
};
|
||||
let path = bootstrap_baseline::path_in(&s.data_dir);
|
||||
let created_at_unix_ms = chrono::Utc::now().timestamp_millis() as u64;
|
||||
let stored = match bootstrap_baseline::store(
|
||||
&path,
|
||||
&installation_id,
|
||||
&[source_node_id],
|
||||
&expected_model_id,
|
||||
created_at_unix_ms,
|
||||
&candidate,
|
||||
) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
return error_response::internal_error_json("bootstrap baseline refinement store", error)
|
||||
}
|
||||
};
|
||||
s.field_model = Some(candidate);
|
||||
s.bootstrap_baseline = Some(stored.clone());
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "The local empty-room startup baseline was refined.",
|
||||
"validation": {
|
||||
"sample_count": residuals.len(),
|
||||
"stale_sample_count": stale_sample_count,
|
||||
"vital_sign_sample_count": vital_sign_sample_count,
|
||||
},
|
||||
"refinement": {
|
||||
"threshold_before": refinement.threshold_before,
|
||||
"threshold_after": refinement.threshold_after,
|
||||
"held_out_p95": refinement.held_out_p95,
|
||||
"held_out_sample_count": refinement.held_out_sample_count,
|
||||
"reference_window_count": refinement.reference_window_count,
|
||||
},
|
||||
"bootstrap_baseline": stored,
|
||||
"calibrated_evidence_authorized": false,
|
||||
"numeric_vitals_authorized": false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel only an unfinished capture. Completed model deletion remains on the
|
||||
/// administrator scoped reset route.
|
||||
async fn calibration_cancel(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
@@ -10054,6 +10283,10 @@ async fn main() {
|
||||
"/api/v1/calibration/bootstrap/promote",
|
||||
post(calibration_promote_bootstrap),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/calibration/bootstrap/refine",
|
||||
post(calibration_refine_bootstrap),
|
||||
)
|
||||
.route("/api/v1/calibration/cancel", post(calibration_cancel))
|
||||
.route("/api/v1/calibration/status", get(calibration_status))
|
||||
.route("/api/v1/calibration/reset", post(calibration_reset))
|
||||
|
||||
@@ -57,6 +57,10 @@ 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;
|
||||
const EMPTY_ROOM_HELD_OUT_MARGIN: f64 = 1.10;
|
||||
const EMPTY_ROOM_REFINEMENT_MAX_LIFT: f64 = 1.25;
|
||||
const EMPTY_ROOM_REFINEMENT_MIN_SAMPLES: usize = 10;
|
||||
const EMPTY_ROOM_REFINEMENT_MAX_SAMPLES: usize = 64;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
@@ -362,6 +366,11 @@ pub struct FieldNormalMode {
|
||||
/// an empty room model into positive person evidence.
|
||||
#[serde(default)]
|
||||
pub empty_room_residual_energy_reference: Vec<f64>,
|
||||
/// Number of operator-confirmed held-out empty refinements. Version one
|
||||
/// permits one bounded refinement so repeated calls cannot ratchet the
|
||||
/// background boundary upward until a weak occupant is suppressed.
|
||||
#[serde(default)]
|
||||
pub empty_room_residual_refinement_count: u8,
|
||||
}
|
||||
|
||||
/// A bounded comparison between one runtime window and the learned empty room
|
||||
@@ -377,6 +386,16 @@ pub struct EmptyRoomMatch {
|
||||
pub reference_window_count: usize,
|
||||
}
|
||||
|
||||
/// Receipt for one bounded, operator-confirmed empty-room refinement.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EmptyRoomRefinement {
|
||||
pub threshold_before: f64,
|
||||
pub threshold_after: f64,
|
||||
pub held_out_p95: f64,
|
||||
pub held_out_sample_count: usize,
|
||||
pub reference_window_count: usize,
|
||||
}
|
||||
|
||||
/// Body perturbation extracted from a CSI observation.
|
||||
///
|
||||
/// After subtracting the baseline and projecting out environmental
|
||||
@@ -692,6 +711,73 @@ impl FieldModel {
|
||||
.and_then(|modes| modes.empty_room_residual_energy_threshold)
|
||||
}
|
||||
|
||||
/// Refine a completed single-link empty-room boundary with scalar residuals
|
||||
/// measured during a separate operator-confirmed empty holdout.
|
||||
///
|
||||
/// This never ingests or persists raw CSI. The lift is capped at 25 percent
|
||||
/// and may happen only once per snapshot, preventing repeated calls from
|
||||
/// silently erasing weak occupied evidence.
|
||||
pub fn refine_empty_room_residual_boundary(
|
||||
&mut self,
|
||||
held_out_residuals: &[f64],
|
||||
) -> Result<EmptyRoomRefinement, FieldModelError> {
|
||||
if self.config.n_links != 1 || self.status != CalibrationStatus::Fresh {
|
||||
return Err(FieldModelError::NotCalibrated);
|
||||
}
|
||||
if !(EMPTY_ROOM_REFINEMENT_MIN_SAMPLES..=EMPTY_ROOM_REFINEMENT_MAX_SAMPLES)
|
||||
.contains(&held_out_residuals.len())
|
||||
|| held_out_residuals
|
||||
.iter()
|
||||
.any(|value| !value.is_finite() || *value < 0.0)
|
||||
{
|
||||
return Err(FieldModelError::InvalidConfig(
|
||||
"held-out empty residuals are missing, excessive, or non-finite".into(),
|
||||
));
|
||||
}
|
||||
let modes = self.modes.as_mut().ok_or(FieldModelError::NotCalibrated)?;
|
||||
if modes.empty_room_residual_refinement_count != 0 {
|
||||
return Err(FieldModelError::InvalidConfig(
|
||||
"the empty-room boundary already has its one permitted held-out refinement"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if modes.empty_room_residual_energy_reference.len() + held_out_residuals.len()
|
||||
> CALIBRATION_BACKGROUND_WINDOWS_MAX
|
||||
{
|
||||
return Err(FieldModelError::InvalidConfig(
|
||||
"the empty-room residual reference limit would be exceeded".into(),
|
||||
));
|
||||
}
|
||||
let threshold_before = modes
|
||||
.empty_room_residual_energy_threshold
|
||||
.ok_or(FieldModelError::NotCalibrated)?
|
||||
.max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX);
|
||||
let held_out_p95 = percentile_95(held_out_residuals.to_vec())
|
||||
.ok_or(FieldModelError::NotCalibrated)?;
|
||||
let proposed = held_out_p95 * EMPTY_ROOM_HELD_OUT_MARGIN;
|
||||
let threshold_after = threshold_before
|
||||
.max(proposed.min(threshold_before * EMPTY_ROOM_REFINEMENT_MAX_LIFT));
|
||||
|
||||
modes
|
||||
.empty_room_residual_energy_reference
|
||||
.extend_from_slice(held_out_residuals);
|
||||
modes
|
||||
.empty_room_residual_energy_reference
|
||||
.sort_by(|left, right| {
|
||||
left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
modes.empty_room_residual_energy_threshold = Some(threshold_after);
|
||||
modes.empty_room_residual_refinement_count = 1;
|
||||
|
||||
Ok(EmptyRoomRefinement {
|
||||
threshold_before,
|
||||
threshold_after,
|
||||
held_out_p95,
|
||||
held_out_sample_count: held_out_residuals.len(),
|
||||
reference_window_count: modes.empty_room_residual_energy_reference.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -762,6 +848,7 @@ impl FieldModel {
|
||||
.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_refinement_count <= 1
|
||||
&& modes
|
||||
.empty_room_residual_energy_reference
|
||||
.iter()
|
||||
@@ -1118,6 +1205,7 @@ impl FieldModel {
|
||||
baseline_runtime_window_size: None,
|
||||
empty_room_residual_energy_threshold: None,
|
||||
empty_room_residual_energy_reference: Vec::new(),
|
||||
empty_room_residual_refinement_count: 0,
|
||||
};
|
||||
|
||||
// The full calibration covariance and a fifty frame runtime covariance
|
||||
@@ -2066,6 +2154,44 @@ mod tests {
|
||||
.is_some_and(|score| (0.5..=1.0).contains(&score)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_out_empty_refinement_is_bounded_and_single_use() {
|
||||
let config = FieldModelConfig {
|
||||
n_links: 1,
|
||||
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..1_000 {
|
||||
let phase = frame_index as f64 * 0.071;
|
||||
let frame = (0..8)
|
||||
.map(|index| 20.0 + index as f64 * 0.1 + (phase + index as f64).sin())
|
||||
.collect();
|
||||
model.feed_calibration(&[frame]).unwrap();
|
||||
}
|
||||
model.finalize_calibration(1_000_000, 0).unwrap();
|
||||
|
||||
let before = model.empty_room_residual_energy_threshold().unwrap();
|
||||
let residuals = vec![before * 1.4; 12];
|
||||
let receipt = model
|
||||
.refine_empty_room_residual_boundary(&residuals)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.threshold_before, before);
|
||||
assert!(receipt.threshold_after > before);
|
||||
assert!(receipt.threshold_after <= before * EMPTY_ROOM_REFINEMENT_MAX_LIFT);
|
||||
assert_eq!(receipt.held_out_sample_count, 12);
|
||||
assert_eq!(
|
||||
model.modes().unwrap().empty_room_residual_refinement_count,
|
||||
1
|
||||
);
|
||||
assert!(model
|
||||
.refine_empty_room_residual_boundary(&residuals)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "eigenvalue")]
|
||||
#[test]
|
||||
fn runtime_reference_rejects_shifted_held_out_window() {
|
||||
|
||||
Reference in new issue
Block a user