mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-22 22:52:34 -05:00
fix(sensing): recover saved empty reference without renewing authority
This commit is contained in:
1 parent
ce93244117
commit
05c7ec032d
4 files changed
+250
-2
No files matched your search
@@ -72,6 +72,27 @@ host to provide a protected state directory.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Explicit recovery without restarting, 2026-10-12
|
||||
|
||||
`POST /api/v1/calibration/bootstrap/recover` requires administrator authority,
|
||||
the current server boot epoch and the stored payload digest. It validates the
|
||||
installation-bound file and original expiry before changing live state, and
|
||||
requires at least ten seconds of eligible raw CSI on its original source/grid.
|
||||
An active unfinished capture must be cancelled first.
|
||||
|
||||
Recovery clears old source sequence faults and runtime windows, withdraws any
|
||||
full room session/receipt, and restores only `bootstrap_only`. New packets must
|
||||
build a complete matching runtime window before negative suppression applies.
|
||||
Duplicates and backward discontinuities remain rejected. No calibration frame
|
||||
count, training timestamp, threshold or expiry is renewed, and the stored file
|
||||
is not rewritten. Full calibrated evidence and numeric vitals remain disabled.
|
||||
|
||||
The twelve-hour stale boundary still withdraws full calibrated authority. A
|
||||
restored negative-only prior may match fresh runtime windows while stale until
|
||||
its original twenty-four-hour hard expiry. Expiry stops matching and recovery.
|
||||
This resolves the mismatch between the advertised twenty-four-hour prior and
|
||||
the previous fresh-only runtime gate; it makes no room-selectivity claim.
|
||||
|
||||
Startup can use recent measured background structure instead of only generic
|
||||
heuristics. No accuracy lift is claimed until occupied and empty held out data
|
||||
from multiple installations is recorded.
|
||||
|
||||
@@ -1975,6 +1975,7 @@ mod scope_gate_polarity_tests {
|
||||
(Method::DELETE, "/api/v1/recording/r1"),
|
||||
(Method::POST, "/api/v1/config/ground-truth"),
|
||||
(Method::POST, "/api/v1/calibration/reset"),
|
||||
(Method::POST, "/api/v1/calibration/bootstrap/recover"),
|
||||
] {
|
||||
assert_eq!(
|
||||
required_scope_for(&m, p),
|
||||
|
||||
@@ -264,7 +264,7 @@ pub fn bootstrap_background_match(
|
||||
observed_at_us: u64,
|
||||
) -> Option<BootstrapBackgroundMatch> {
|
||||
if frame_history.len() < OCCUPANCY_WINDOW
|
||||
|| field.check_freshness(observed_at_us) != CalibrationStatus::Fresh
|
||||
|| !matches!(field.check_freshness(observed_at_us), CalibrationStatus::Fresh | CalibrationStatus::Stale)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -923,6 +923,11 @@ mod tests {
|
||||
.is_some_and(|score| (0.5..=1.0).contains(&score)));
|
||||
|
||||
let short_history: VecDeque<Vec<f64>> = empty_history.iter().take(49).cloned().collect();
|
||||
// Staleness withdraws full calibrated authority after half the lifetime,
|
||||
// but the persisted negative-only reference remains useful until expiry.
|
||||
assert!(bootstrap_background_match(&field, &empty_history, 32_000_000).is_some());
|
||||
assert!(calibrated_occupancy(&field, &empty_history, 32_000_000).is_none());
|
||||
assert!(bootstrap_background_match(&field, &empty_history, 62_000_000).is_none());
|
||||
assert!(!bootstrap_empty_prior_applies(
|
||||
&field,
|
||||
&short_history,
|
||||
|
||||
@@ -2646,6 +2646,11 @@ impl AppStateInner {
|
||||
(chrono::Utc::now().timestamp_millis().max(0) as u64)
|
||||
.saturating_mul(1_000),
|
||||
) == CalibrationStatus::Fresh
|
||||
|| (self.bootstrap_baseline_active
|
||||
&& field.check_freshness(
|
||||
(chrono::Utc::now().timestamp_millis().max(0) as u64)
|
||||
.saturating_mul(1_000),
|
||||
) == CalibrationStatus::Stale)
|
||||
}
|
||||
});
|
||||
if accepted {
|
||||
@@ -3109,7 +3114,7 @@ mod calibration_expiry_tests {
|
||||
use super::*;
|
||||
use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig;
|
||||
|
||||
fn finalized_model(calibrated_at_unix_ms: u64) -> FieldModel {
|
||||
pub(super) fn finalized_model(calibrated_at_unix_ms: u64) -> FieldModel {
|
||||
let mut model = FieldModel::new(FieldModelConfig {
|
||||
n_links: 1,
|
||||
n_subcarriers: 56,
|
||||
@@ -9486,6 +9491,221 @@ struct BootstrapRefineQuery {
|
||||
source_node_id: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BootstrapRecoverRequest {
|
||||
boot_epoch: String,
|
||||
content_sha256: String,
|
||||
}
|
||||
|
||||
/// Explicitly restore the verified negative-only prior, just as a clean boot
|
||||
/// would. This never renews its lifetime or restores a full room receipt.
|
||||
async fn calibration_recover_bootstrap(
|
||||
State(state): State<SharedState>,
|
||||
Json(request): Json<BootstrapRecoverRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if request.boot_epoch != s.calibration_boot_epoch
|
||||
|| !valid_binding_digest(&request.content_sha256)
|
||||
|| s.bootstrap_baseline.as_ref().is_none_or(|metadata| {
|
||||
metadata.content_sha256 != request.content_sha256
|
||||
})
|
||||
{
|
||||
return Json(serde_json::json!({
|
||||
"success": false, "error_code": "bootstrap_recovery_identity_mismatch",
|
||||
"error": "The saved reference or server boot changed. Refresh calibration status before restoring it.",
|
||||
}));
|
||||
}
|
||||
if s.field_model.as_ref().is_some_and(|model| matches!(
|
||||
model.status(), CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
|
||||
)) && s.calibration_session_id.is_some() {
|
||||
return Json(serde_json::json!({
|
||||
"success": false, "error_code": "calibration_capture_active",
|
||||
"error": "Cancel the active capture before restoring the saved empty reference.",
|
||||
}));
|
||||
}
|
||||
let Some(installation_id) = s.installation_id.as_deref() else {
|
||||
return Json(serde_json::json!({
|
||||
"success": false, "error_code": "installation_id_required",
|
||||
"error": "A stable installation identity is required to restore the saved reference.",
|
||||
}));
|
||||
};
|
||||
let path = bootstrap_baseline::path_in(&s.data_dir);
|
||||
// Validate disk content before touching the live model, histories or faults.
|
||||
let (model, metadata) = match bootstrap_baseline::load(
|
||||
&path, installation_id, chrono::Utc::now().timestamp_millis().max(0) as u64,
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Json(serde_json::json!({
|
||||
"success": false, "error_code": "bootstrap_reference_unavailable",
|
||||
"error": "The saved empty reference is expired or failed validation. Collect a new baseline.",
|
||||
})),
|
||||
};
|
||||
if metadata.content_sha256 != request.content_sha256 {
|
||||
return Json(serde_json::json!({
|
||||
"success": false, "error_code": "bootstrap_recovery_identity_mismatch",
|
||||
"error": "The saved reference changed. Refresh calibration status before restoring it.",
|
||||
}));
|
||||
}
|
||||
let [source_node_id] = metadata.source_node_ids.as_slice() else {
|
||||
return Json(serde_json::json!({
|
||||
"success": false, "error_code": "bootstrap_source_invalid",
|
||||
"error": "The saved reference must have one source radio.",
|
||||
}));
|
||||
};
|
||||
let grid = CsiGridKey::from_bootstrap_grid(metadata.source_grid);
|
||||
let now = std::time::Instant::now();
|
||||
let evidence = s.node_states.get(source_node_id).and_then(|node| {
|
||||
node.calibration_grid_candidates(now).into_iter().find_map(|(candidate, evidence)| {
|
||||
(candidate == grid
|
||||
&& evidence.span_s >= CALIBRATION_GRID_MIN_SPAN_S
|
||||
&& evidence.rate_hz >= CALIBRATION_GRID_MIN_RATE_HZ
|
||||
&& evidence.max_gap_s < CALIBRATION_GRID_MAX_GAP_S
|
||||
&& evidence.latest_age_s < CALIBRATION_GRID_MAX_GAP_S).then_some(evidence)
|
||||
})
|
||||
});
|
||||
let Some(evidence) = evidence else {
|
||||
return Json(serde_json::json!({
|
||||
"success": false, "error_code": "bootstrap_source_unavailable",
|
||||
"error": "The saved source radio and CSI grid need at least ten seconds of fresh live data before recovery.",
|
||||
"source_node_id": source_node_id,
|
||||
}));
|
||||
};
|
||||
let binding = CalibrationGridBinding { source_node_id: *source_node_id, grid, evidence: Some(evidence) };
|
||||
s.clear_field_model_binding();
|
||||
s.clear_calibration_sequence_state();
|
||||
s.calibration_source_node_ids.clear();
|
||||
s.calibration_source_node_ids.insert(binding.source_node_id);
|
||||
s.calibration_observed_source_node_ids.clear();
|
||||
s.calibration_model_id = None;
|
||||
s.calibration_session_id = None;
|
||||
s.calibration_binding_digest = None;
|
||||
s.calibration_model_receipt = None;
|
||||
s.field_model = Some(model);
|
||||
s.calibration_grid_binding = Some(binding);
|
||||
s.bootstrap_baseline = Some(metadata.clone());
|
||||
s.bootstrap_baseline_active = true;
|
||||
Json(serde_json::json!({
|
||||
"success": true, "boot_epoch": s.calibration_boot_epoch,
|
||||
"status": s.field_model_status_at(chrono::Utc::now().timestamp_millis().max(0) as u64).map(|value| format!("{value:?}").to_lowercase()),
|
||||
"bootstrap_baseline": metadata, "restored_frames": 0,
|
||||
"full_calibration_required": true,
|
||||
"calibrated_evidence_authorized": false, "numeric_vitals_authorized": false,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bootstrap_recovery_tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, SharedState, BootstrapRecoverRequest) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
|
||||
let model = super::calibration_expiry_tests::finalized_model(now_ms);
|
||||
let mut snapshot = model.export_snapshot().unwrap();
|
||||
snapshot.config.baseline_expiry_s = 86_400.0;
|
||||
snapshot.modes.calibrated_at_us = (now_ms - 13 * 3_600_000) * 1_000;
|
||||
let model = FieldModel::from_snapshot(snapshot, now_ms * 1_000).unwrap();
|
||||
let grid = CsiGridKey { n_subcarriers: 64, ppdu_type: 0 };
|
||||
let metadata = bootstrap_baseline::store(
|
||||
&bootstrap_baseline::path_in(directory.path()), "recovery-test", &[5],
|
||||
grid.as_bootstrap_grid(), "cal-model-saved", now_ms, &model,
|
||||
).unwrap();
|
||||
let mut state = AppStateInner::minimal();
|
||||
state.installation_id = Some("recovery-test".into());
|
||||
state.data_dir = directory.path().to_path_buf();
|
||||
state.bootstrap_baseline = Some(metadata.clone());
|
||||
state.field_model = Some(model);
|
||||
state.calibration_session_id = Some("old-session".into());
|
||||
state.calibration_model_id = Some("old-model".into());
|
||||
state.calibration_binding_digest = Some("ab".repeat(32));
|
||||
state.calibration_sequence_fault_node_ids.insert(5);
|
||||
state.calibration_last_sequences.insert(5, 50_000);
|
||||
let mut node = NodeState::new();
|
||||
let now = std::time::Instant::now();
|
||||
for index in 0..=240 {
|
||||
node.observe_raw_grid(grid, now - Duration::from_millis(12_000 - index * 50));
|
||||
}
|
||||
node.field_model_history.push_back(vec![999.0; 56]);
|
||||
node.field_model_latest_sequence = Some(50_000);
|
||||
node.field_model_latest_seen = Some(now);
|
||||
state.node_states.insert(5, node);
|
||||
let request = BootstrapRecoverRequest {
|
||||
boot_epoch: state.calibration_boot_epoch.clone(), content_sha256: metadata.content_sha256,
|
||||
};
|
||||
(directory, Arc::new(RwLock::new(state)), request)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_recovery_retains_expiry_withdraws_full_receipt_and_requires_new_frames() {
|
||||
let (_directory, state, request) = fixture();
|
||||
let expiry = state.read().await.bootstrap_baseline.as_ref().unwrap().expires_at_unix_ms;
|
||||
let Json(response) = calibration_recover_bootstrap(State(state.clone()), Json(request)).await;
|
||||
assert_eq!(response["success"], true);
|
||||
assert_eq!(response["bootstrap_baseline"]["expires_at_unix_ms"], expiry);
|
||||
assert_eq!(response["restored_frames"], 0);
|
||||
assert_eq!(response["calibrated_evidence_authorized"], false);
|
||||
let mut s = state.write().await;
|
||||
assert!(s.bootstrap_baseline_active);
|
||||
assert!(s.calibration_model_receipt.is_none());
|
||||
assert!(s.calibration_session_id.is_none());
|
||||
assert!(s.calibration_sequence_fault_node_ids.is_empty());
|
||||
assert!(s.node_states[&5].field_model_history.is_empty());
|
||||
assert!(s.node_states[&5].field_model_latest_seen.is_none());
|
||||
assert!(!s.bootstrap_empty_prior_applies(chrono::Utc::now().timestamp_millis() as u64));
|
||||
let frame = s.field_model.as_ref().unwrap().modes().unwrap().baseline[0].clone();
|
||||
let grid = s.calibration_grid_binding.unwrap().grid;
|
||||
assert!(s.maybe_feed_calibration_frame(5, grid, 1, &frame, std::time::Instant::now()));
|
||||
assert!(!s.maybe_feed_calibration_frame(5, grid, 1, &frame, std::time::Instant::now()));
|
||||
assert!(s.maybe_feed_calibration_frame(5, grid, 100, &frame, std::time::Instant::now()));
|
||||
assert!(!s.maybe_feed_calibration_frame(5, grid, 2, &frame, std::time::Instant::now()));
|
||||
assert!(s.calibration_sequence_fault_node_ids.contains(&5));
|
||||
assert!(!s.explicit_calibration_fresh_at(chrono::Utc::now().timestamp_millis() as u64));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_recovery_preserves_live_state_and_saved_image() {
|
||||
for invalid_case in ["boot", "digest", "tamper", "source", "grid", "capture", "installation", "expired"] {
|
||||
let (directory, state, mut request) = fixture();
|
||||
let path = bootstrap_baseline::path_in(directory.path());
|
||||
match invalid_case {
|
||||
"boot" => request.boot_epoch = "old-boot".into(),
|
||||
"digest" => request.content_sha256 = "cd".repeat(32),
|
||||
"tamper" => std::fs::write(&path, b"{}").unwrap(),
|
||||
"source" => state.write().await.node_states.clear(),
|
||||
"grid" => {
|
||||
state.write().await.node_states.get_mut(&5).unwrap().raw_grid_observations.clear();
|
||||
}
|
||||
"capture" => {
|
||||
state.write().await.field_model = Some(FieldModel::new(field_bridge::single_link_config()).unwrap());
|
||||
}
|
||||
"installation" => state.write().await.installation_id = Some("another-installation".into()),
|
||||
"expired" => {
|
||||
let mut s = state.write().await;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
|
||||
let old = s.field_model.as_ref().unwrap().export_snapshot().unwrap();
|
||||
let mut old = old;
|
||||
old.modes.calibrated_at_us = (now_ms - 25 * 3_600_000) * 1_000;
|
||||
// Store a valid image when it was still young, then load now.
|
||||
let model = FieldModel::from_snapshot(old, (now_ms - 24 * 3_600_000) * 1_000).unwrap();
|
||||
let metadata = bootstrap_baseline::store(&path, "recovery-test", &[5],
|
||||
s.calibration_grid_binding.map_or(CsiGridKey { n_subcarriers:64, ppdu_type:0 }, |b| b.grid).as_bootstrap_grid(),
|
||||
"cal-model-saved", now_ms - 24 * 3_600_000, &model).unwrap();
|
||||
request.content_sha256 = metadata.content_sha256.clone(); s.bootstrap_baseline = Some(metadata);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let Json(response) = calibration_recover_bootstrap(State(state.clone()), Json(request)).await;
|
||||
assert_eq!(response["success"], false, "{invalid_case}");
|
||||
let s = state.read().await;
|
||||
assert_eq!(s.calibration_session_id.as_deref(), Some("old-session"), "{invalid_case}");
|
||||
assert!(s.calibration_sequence_fault_node_ids.contains(&5), "{invalid_case}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn calibration_start(
|
||||
State(state): State<SharedState>,
|
||||
Query(query): Query<CalibrationStartQuery>,
|
||||
@@ -15236,6 +15456,7 @@ async fn main() {
|
||||
.route("/api/v1/calibration/cancel", post(calibration_cancel))
|
||||
.route("/api/v1/calibration/status", get(calibration_status))
|
||||
.route("/api/v1/calibration/reset", post(calibration_reset))
|
||||
.route("/api/v1/calibration/bootstrap/recover", post(calibration_recover_bootstrap))
|
||||
// ADR-044 §5.3: runtime-configurable dedup factor
|
||||
.route(
|
||||
"/api/v1/config/dedup-factor",
|
||||
|
||||
Reference in new issue
Block a user