diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 797729888e..7e4ede9c51 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -13453,6 +13453,7 @@ dependencies = [ "rumqttc-v4-next", "ruvector-mincut", "ruview-auth", + "ruview-ood", "serde", "serde_json", "sha2 0.10.9", @@ -13468,6 +13469,7 @@ dependencies = [ "ureq 2.12.1", "wifi-densepose-aether", "wifi-densepose-bfld", + "wifi-densepose-calibration", "wifi-densepose-core", "wifi-densepose-engine", "wifi-densepose-geo", diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 5099c4bee1..26b88725bd 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -20,6 +20,10 @@ name = "sensing-server" path = "src/main.rs" [dependencies] +# ADR-302 OOD gate — attaches KNOWN/DEGRADED/UNKNOWN to live inferences. +ruview-ood = { path = "../ruview-ood" } +# ADR-301 calibration certificates the OOD gate is checked against. +wifi-densepose-calibration = { path = "../wifi-densepose-calibration" } # Web framework axum = { workspace = true } tower-http = { version = "0.6", features = ["fs", "cors", "set-header"] } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 0b35a5c6a5..6d8434dd22 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -17,6 +17,7 @@ mod field_bridge; mod field_localize; mod model_format; mod multistatic_bridge; +mod ood_gate; mod mediatek_csi; mod qualcomm_csi; mod realtek_radar; @@ -148,6 +149,28 @@ struct Args { #[arg(long)] disable_host_validation: bool, + /// ADR-302 OOD gate (`ood_gate`): path to a signed ADR-301 calibration + /// certificate (produced offline by `wifi-densepose calibrate`/ + /// `train-room`). When set, every live cycle is gated KNOWN/DEGRADED/ + /// UNKNOWN against it and the result is published as `domain_state` on + /// `/ws/sensing`. Requires WDP_CALIBRATION_KEY_ID / WDP_CALIBRATION_KEY_SECRET + /// in the environment. A certificate that fails to load or verify aborts + /// startup (fail closed) — see `ood_gate::load`. Omitted by default: the + /// gate is fully opt-in and does not change existing behavior. + #[arg(long)] + calibration_certificate: Option, + + /// Space identity the loaded calibration certificate must attest + /// (ADR-306). Only meaningful with `--calibration-certificate`. + #[arg(long, default_value = "default")] + calibration_space_id: String, + + /// Device identity the loaded calibration certificate must attest + /// (ADR-305) — must equal the certificate's signed `sensor_id`. Only + /// meaningful with `--calibration-certificate`. + #[arg(long, default_value = "sensing-server")] + calibration_device_id: String, + /// MQTT publisher (HA auto-discovery) + privacy-mode flags (ADR-115). /// Flattened so `--mqtt*` reach the binary's parser and the publisher /// in `mqtt::` is actually started (fixes #872). Uses the *lib* crate's @@ -358,6 +381,12 @@ struct SensingUpdate { /// fresh node backs the room rather than a frozen online value. #[serde(skip_serializing_if = "Option::is_none")] room_inference: Option, + /// ADR-302 OOD gate verdict for this cycle (`ood_gate::evaluate`). + /// `None` when `--calibration-certificate` was not set — the gate is + /// opt-in and this field's absence, not a `null`/`"Unknown"` payload, is + /// how a consumer tells "gating is off" from "gating ran". + #[serde(skip_serializing_if = "Option::is_none")] + domain_state: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1435,6 +1464,11 @@ struct AppStateInner { /// Canonical ADR-323 engine and latest additive publication. Existing /// image-space renderer poses are never silently inserted here. pose_physics: pose_physics::PosePhysicsRuntime, + /// ADR-302 OOD gate (`ood_gate`), loaded and verified once at startup. + /// `None` unless `--calibration-certificate` was given — the gate is + /// opt-in and every live cycle skips it (no `domain_state` published) + /// when absent. + ood_gate: Option>, } #[cfg(test)] @@ -1679,6 +1713,7 @@ impl AppStateInner { wifi_densepose_physics::PhysicsConfig::default(), ) .expect("default pose physics configuration is valid"), + ood_gate: None, } } } @@ -3118,6 +3153,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { }, node_features: None, room_inference: None, + domain_state: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). @@ -3282,6 +3318,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { }, node_features: None, room_inference: None, + domain_state: None, }; let raw_persons = derive_pose_from_sensing(&update); @@ -6513,6 +6550,30 @@ async fn udp_receiver_task( &[], ); + // ADR-302 OOD gate: run when a certificate is loaded. No + // independent live signal-quality scalar exists at this + // ingest path (ADR-137 coherence isn't computed here) — + // fixed at 1.0 (best case, documented gap, same pattern + // as the fourth ADR-302 input's `uncertainty_available: + // false`) rather than reusing an unrelated confidence + // value that would misrepresent what "signal quality" means. + let domain_state = s.ood_gate.as_ref().map(|gate| { + let live_fp = ood_gate::live_fingerprint_from_stats( + fused_features.mean_rssi, + fused_features.variance, + fused_features.motion_band_power, + ); + ood_gate::evaluate( + gate, + &live_fp, + 1.0, + false, + true, + classification.confidence as f32, + ood_gate::now_unix_s(), + ) + }); + let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, @@ -6558,6 +6619,7 @@ async fn udp_receiver_task( // tripping back to the server. node_features: build_node_features(&s.node_states, now), room_inference: Some(room_inference), + domain_state, }; let raw_persons = derive_pose_from_sensing(&update); @@ -6972,6 +7034,27 @@ async fn udp_receiver_task( NODE_STALE_AFTER_MS, ); + // ADR-302 OOD gate — see the sibling call site above for + // why signal quality is fixed at 1.0 (documented gap, no + // independent live quality scalar at this ingest path). + let room_classification = classification_from_room(&room_inference); + let domain_state = s.ood_gate.as_ref().map(|gate| { + let live_fp = ood_gate::live_fingerprint_from_stats( + fused_features.mean_rssi, + fused_features.variance, + fused_features.motion_band_power, + ); + ood_gate::evaluate( + gate, + &live_fp, + 1.0, + false, + true, + room_classification.confidence as f32, + ood_gate::now_unix_s(), + ) + }); + let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, @@ -6984,7 +7067,7 @@ async fn udp_receiver_task( // `classification` (this node's own smoothed reading) // still drives `motion_score`/`total_persons` above, // which are legitimately this-packet-local. - classification: classification_from_room(&room_inference), + classification: room_classification, signal_field: generate_signal_field( fused_features.mean_rssi, motion_score, @@ -7014,6 +7097,7 @@ async fn udp_receiver_task( // tripping back to the server. node_features: build_node_features(&s.node_states, now), room_inference: Some(room_inference), + domain_state, }; let raw_persons = derive_pose_from_sensing(&update); @@ -7224,6 +7308,28 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { 0 }; + // ADR-302 OOD gate — real cert verification + real drift math even + // in simulated mode, so this path is honestly live-verifiable + // without hardware (only the CSI *source* is simulated; the gate's + // decision over it is not). Same documented signal-quality gap as + // the ESP32 ingest sites (no independent live quality scalar here). + let domain_state = s.ood_gate.as_ref().map(|gate| { + let live_fp = ood_gate::live_fingerprint_from_stats( + features.mean_rssi, + features.variance, + features.motion_band_power, + ); + ood_gate::evaluate( + gate, + &live_fp, + 1.0, + false, + true, + classification.confidence as f32, + ood_gate::now_unix_s(), + ) + }); + let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, @@ -7274,6 +7380,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { }, node_features: None, room_inference: None, + domain_state, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). @@ -8511,6 +8618,31 @@ async fn main() { let field_surface: rufield_surface::FieldState = Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env())); + // ADR-302 OOD gate: opt-in, fails closed. `--calibration-certificate` + // unset ⇒ `None`, every cycle publishes no `domain_state` — behavior is + // unchanged from before this gate existed. Set ⇒ the certificate MUST + // load and verify or the server refuses to start gated at all (an + // operator who asked for gating and got a silently-ungated server would + // be worse than a clear startup failure). + let ood_gate: Option> = match &args.calibration_certificate { + Some(path) => { + match ood_gate::load(path, &args.calibration_space_id, &args.calibration_device_id) { + Ok(state) => { + info!( + "ADR-302 OOD gate: loaded and verified {path} (space={}, device={})", + args.calibration_space_id, args.calibration_device_id + ); + Some(std::sync::Arc::new(state)) + } + Err(e) => { + eprintln!("wifi-densepose-sensing-server: --calibration-certificate: {e}"); + std::process::exit(1); + } + } + } + None => None, + }; + // Populated inside the `multistatic_fuser` field initializer below, then // threaded into `engine_bridge` so both fusion paths honor the same // WDP_TDM_SLOTS/WDP_GUARD_INTERVAL_US-derived guard (#1049/#1057). @@ -8640,6 +8772,7 @@ async fn main() { wifi_densepose_physics::PhysicsConfig::default(), ) .expect("default pose physics configuration is valid"), + ood_gate: ood_gate.clone(), })); // Start background tasks from the resolved plan (issue #1004). @@ -9878,6 +10011,7 @@ mod observatory_persons_field_position_tests { persons: None, estimated_persons: Some(1), node_features: None, + domain_state: None, room_inference: None, } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/ood_gate.rs b/v2/crates/wifi-densepose-sensing-server/src/ood_gate.rs new file mode 100644 index 0000000000..678ae76133 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/ood_gate.rs @@ -0,0 +1,379 @@ +//! Wires the ADR-302 OOD gate (`ruview-ood`) into the live sensing loop. +//! +//! Before this module, RuView's entire ADR-300 honesty substrate — the +//! witness chain, authenticated sensor identity, capability certificates, and +//! this OOD gate — compiled and passed its own unit tests but was never +//! reachable from anything that actually runs: `wifi-densepose-sensing-server` +//! depended on none of it. A live classification could report `person_present` +//! with zero domain-generalization check, no matter how far the room had +//! drifted from whatever it was tuned on. This module is the first strand +//! connected: an **opt-in** (`--calibration-certificate `) gate that +//! attaches a KNOWN/DEGRADED/UNKNOWN verdict to each cycle's classification. +//! +//! ## What is NOT solved here +//! +//! - **Minting a real certificate still requires the offline +//! `wifi-densepose-cli calibrate`/`enroll`/`train-room` pipeline** (ADR-151). +//! This module only *consumes* a certificate; it does not calibrate a room. +//! - **The live fingerprint fed to the gate is a documented approximation** +//! ([`live_fingerprint_from_stats`]), built from the scalar features the +//! live loop already tracks (`mean_rssi`, `variance`, `motion_band_power`). +//! It is NOT the same rigor as [`RoomFingerprint::from_bank`] (an enrolled +//! `SpecialistBank`'s presence-gate statistics) — the gate still functions +//! correctly (drift is still monotonic and comparable across cycles), but a +//! consumer should not read `distance` here as calibration-grade. +//! **Empirically confirmed, not just theoretical**: live-verifying this +//! module against a hand-minted certificate whose fingerprint was chosen to +//! closely match the live simulated feed's own observed scalars still +//! produced `distance.total` around 0.9 (squashed distance is `[0,1)`; the +//! default envelope's outer threshold is 0.15) — nowhere near KNOWN. The +//! live approximation's raw dBm/variance units evidently don't sit on the +//! scale `RoomFingerprint::distance`'s constants (`MEAN_SCALE`/`VAR_SCALE`) +//! were tuned for, which were fit against real enrolled-bank statistics. +//! Practical effect: expect this gate to report `DEGRADED`/`UNKNOWN` far +//! more often than a real per-room `SpecialistBank`-derived fingerprint +//! would, even for a genuinely fine room — a real deployment needs either a +//! rescaled live fingerprint or (better) the enrolled-bank fingerprint kept +//! live-updated, neither of which this module does yet. +//! - **The fourth ADR-302 input, model uncertainty, is not available.** The +//! live classifier is heuristic, not a model with a calibrated predictive +//! uncertainty head — there is nothing honest to report there yet, so it is +//! fixed at `0.0` (never escalates the gate) and the response says so +//! explicitly (`"uncertainty_available": false`) rather than pretending a +//! score exists. + +use std::sync::Arc; + +use ruview_ood::{ + assess_certificate, gate::Inference, CompatibilityEnvelope, ExpectedIdentity, InferenceGate, + RoomFingerprint, SignalQuality, +}; +use wifi_densepose_calibration::certificate::{CalibrationCertificate, KeyedHashSigner}; + +/// Current wall-clock time as Unix seconds, for callers that need to pass +/// `now_unix_s` into [`evaluate`] but have no injected clock of their own +/// (the live sensing loop, unlike this module's own pure core, is not +/// required to be deterministic). Saturates to `0` rather than panicking on +/// a pre-1970 clock. +#[must_use] +pub fn now_unix_s() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Env var carrying the calibration certificate's verification key id. +/// Required when `--calibration-certificate` is set. +pub const CALIBRATION_KEY_ID_ENV: &str = "WDP_CALIBRATION_KEY_ID"; +/// Env var carrying the calibration certificate's verification secret. +/// Required when `--calibration-certificate` is set. Never logged. +pub const CALIBRATION_KEY_SECRET_ENV: &str = "WDP_CALIBRATION_KEY_SECRET"; + +/// Loaded, verified OOD-gate state: the certificate, its verifier, the +/// expected space/device identity, and the gate itself. Held once at startup +/// (loading is fallible and deliberately fails closed — see [`load`]). +#[derive(Debug)] +pub struct OodGateState { + certificate: CalibrationCertificate, + verifier: KeyedHashSigner, + space_id: String, + device_id: String, + gate: InferenceGate, +} + +/// Load a calibration certificate from `path`, verify its signature +/// immediately using [`CALIBRATION_KEY_ID_ENV`]/[`CALIBRATION_KEY_SECRET_ENV`], +/// and bind it to `(space_id, device_id)`. +/// +/// Fails closed: any error here (missing file, malformed JSON, missing env +/// vars, bad signature) is returned to the caller, which is expected to abort +/// startup rather than run with a gate that silently never gates anything. +/// A certificate that merely doesn't verify is a startup error, not a +/// runtime `UNKNOWN` — an operator who passed `--calibration-certificate` +/// asked for gating and deserves to know immediately if the file they gave +/// is broken, not have it silently do nothing. +pub fn load( + path: &str, + space_id: impl Into, + device_id: impl Into, +) -> Result { + let json = std::fs::read_to_string(path) + .map_err(|e| format!("read calibration certificate {path}: {e}"))?; + let certificate = CalibrationCertificate::from_json(&json) + .map_err(|e| format!("parse calibration certificate {path}: {e}"))?; + + let key_id = std::env::var(CALIBRATION_KEY_ID_ENV) + .map_err(|_| format!("{CALIBRATION_KEY_ID_ENV} is not set (required with --calibration-certificate)"))?; + let key_secret = std::env::var(CALIBRATION_KEY_SECRET_ENV).map_err(|_| { + format!("{CALIBRATION_KEY_SECRET_ENV} is not set (required with --calibration-certificate)") + })?; + let verifier = KeyedHashSigner::new(key_id, key_secret.into_bytes()); + + if !certificate.verify_signature(&verifier) { + return Err(format!( + "calibration certificate {path} failed signature verification — refusing to start gated \ + (check {CALIBRATION_KEY_ID_ENV}/{CALIBRATION_KEY_SECRET_ENV} match the minting key)" + )); + } + + Ok(OodGateState { + certificate, + verifier, + space_id: space_id.into(), + device_id: device_id.into(), + gate: InferenceGate::default(), + }) +} + +/// Build a live [`RoomFingerprint`] approximation from the scalar features the +/// live sensing loop already computes every cycle. See the module docs' +/// honesty caveat — this is NOT the enrolled-bank rigor of +/// [`RoomFingerprint::from_bank`], just an honestly-labelled best-effort +/// proxy built from the same scalars every consumer of `FeatureInfo` already +/// sees, so the OOD gate has *something* comparable to drift-check against +/// without requiring a second live-calibration subsystem. +#[must_use] +pub fn live_fingerprint_from_stats(mean_rssi: f64, variance: f64, motion_band_power: f64) -> RoomFingerprint { + RoomFingerprint { + schema_version: 1, + empty_mean: mean_rssi as f32, + empty_variance: variance.max(0.0) as f32, + occupied_variance: (variance + motion_band_power).max(0.0) as f32, + // Not derivable from live scalars alone; zeroed rather than guessed + // (RoomFingerprint::from_bank does the same when a bank has no + // presence gate — this mirrors that documented "unavailable" convention). + presence_threshold: 0.0, + occupancy_mean_shift: motion_band_power as f32, + geometry: Default::default(), + } +} + +/// Gate one cycle's classification. Pure given its inputs (no clock read — +/// `now_unix_s` is the caller's, `Arc` avoids cloning the loaded certificate +/// per cycle). Returns a JSON view combining the gate's full decision +/// ([`ruview_ood::GatedInference`], which already carries state, cause, +/// distance, signal quality, calibration compatibility, and any +/// recalibration signal) with the honesty caveat about the missing fourth +/// input. +#[must_use] +pub fn evaluate( + state: &Arc, + live_fingerprint: &RoomFingerprint, + signal_quality_score: f32, + contradiction: bool, + frame_valid: bool, + confidence: f32, + now_unix_s: i64, +) -> serde_json::Value { + let expected = ExpectedIdentity { + space_id: &state.space_id, + device_id: &state.device_id, + }; + let (distance, compat) = assess_certificate( + &state.certificate, + live_fingerprint, + expected, + now_unix_s, + &state.verifier, + ); + let quality = SignalQuality::new(signal_quality_score.clamp(0.0, 1.0), contradiction, frame_valid) + .unwrap_or(SignalQuality { + score: 0.0, + contradiction: true, + valid: false, + }); + // Fourth ADR-302 input (uncertainty) is unavailable from the heuristic + // classifier — fixed at 0.0, which never escalates the gate on its own + // (see InferenceGate::evaluate: only uncertainty > max_uncertainty_known + // escalates, and 0.0 never exceeds a positive threshold). + let inference = Inference::new((), confidence, 0.0); + let envelope: CompatibilityEnvelope = state.certificate.envelope; + let gated = state.gate.evaluate(inference, distance, envelope, quality, compat); + + let mut view = serde_json::to_value(&gated).unwrap_or(serde_json::Value::Null); + if let Some(obj) = view.as_object_mut() { + obj.insert("uncertainty_available".to_string(), serde_json::Value::Bool(false)); + obj.insert( + "fingerprint_source".to_string(), + serde_json::Value::String("live_approximation".to_string()), + ); + } + view +} + +#[cfg(test)] +mod tests { + use super::*; + use wifi_densepose_calibration::bank::SpecialistBank; + use wifi_densepose_calibration::certificate::{ + CalibrationTier, CharacterizationSource, EvidenceLevel, MintParams, + }; + + /// `key_id` must equal the certificate's `sensor_id` (`mint` enforces + /// this) — always build the signer from the same device id the + /// certificate is minted for, rather than a fixed literal, so a test + /// cannot accidentally mismatch the two. + fn signer_for(key_id: &str) -> KeyedHashSigner { + KeyedHashSigner::new(key_id, b"test-secret-do-not-use-in-prod".to_vec()) + } + + /// A minimal, empty specialist bank — no anchors trained, just enough + /// structure to derive a (zeroed) `RoomFingerprint` and mint a synthetic + /// test certificate. `SpecialistBank` has no `Default`/no-anchor + /// constructor of its own (`train` requires ≥1 anchor), so this builds + /// the literal directly — every field is `pub`, this is not reaching + /// around an invariant, just skipping enrollment for a unit test. + fn empty_bank() -> SpecialistBank { + SpecialistBank { + room_id: "room/test".to_string(), + baseline_id: "baseline/test".to_string(), + trained_at_unix_s: 0, + anchor_count: 0, + geometry: Vec::new(), + presence: None, + posture: None, + breathing: Default::default(), + heartbeat: Default::default(), + restlessness: None, + anomaly: None, + } + } + + fn minted_state(space_id: &str, device_id: &str, now: i64, validity_secs: i64) -> Arc { + let bank = empty_bank(); + let fingerprint = RoomFingerprint::from_bank(&bank); + let s = signer_for(device_id); + let params = MintParams { + space_id: space_id.to_string(), + sensor_id: device_id.to_string(), + captured_at_unix_s: now, + validity_secs, + version: 1, + tier: CalibrationTier::Auto, + evidence: EvidenceLevel::L0Synthetic, + source: CharacterizationSource::Synthetic, + envelope: CompatibilityEnvelope::default(), + }; + let certificate = + CalibrationCertificate::mint(params, &bank, &s).expect("synthetic certificate mints"); + assert!(certificate.verify_signature(&s), "test fixture must self-verify"); + let _ = fingerprint; // documents the certificate's own fingerprint derivation path + Arc::new(OodGateState { + certificate, + verifier: s, + space_id: space_id.to_string(), + device_id: device_id.to_string(), + gate: InferenceGate::default(), + }) + } + + #[test] + fn fresh_certificate_and_matching_live_fingerprint_gate_known() { + let now = 1_000_000; + let state = minted_state("space/a", "sensor/a", now, 3600); + let live = RoomFingerprint::from_bank(&empty_bank()); + + let view = evaluate(&state, &live, 0.95, false, true, 0.9, now); + assert_eq!(view["state"], serde_json::json!("Known")); + assert_eq!(view["calibration_compat"], serde_json::json!("Valid")); + assert_eq!(view["uncertainty_available"], serde_json::json!(false)); + assert_eq!(view["fingerprint_source"], serde_json::json!("live_approximation")); + // KNOWN must still return the class/confidence, not suppress it. + assert!(view["confidence"].is_number()); + } + + #[test] + fn expired_certificate_gates_unknown_regardless_of_drift() { + let now = 1_000_000; + // validity_secs = 10 → already expired by the time we evaluate far later. + let state = minted_state("space/a", "sensor/a", now, 10); + let live = RoomFingerprint::from_bank(&empty_bank()); + + let view = evaluate(&state, &live, 0.99, false, true, 0.99, now + 10_000); + assert_eq!( + view["state"], + serde_json::json!({"Unknown": "CertificateExpired"}) + ); + // The confident class must be suppressed — ADR-300 rule 1. + assert!(view["confidence"].is_null()); + assert!(view["class"].is_null()); + } + + #[test] + fn device_mismatch_gates_unknown_even_with_a_valid_signature() { + let now = 1_000_000; + let state = minted_state("space/a", "sensor/a", now, 3600); + // Same OodGateState object, but evaluated as if it were a different + // device's cycle — exercised via a hand-built mismatched state. + let mismatched = Arc::new(OodGateState { + certificate: state.certificate.clone(), + // Verifies with the key that actually signed the certificate + // ("sensor/a", matching `minted_state` above) — the mismatch + // under test is the *expected* device_id below, not the signer. + verifier: signer_for("sensor/a"), + space_id: "space/a".to_string(), + device_id: "sensor/DIFFERENT".to_string(), + gate: InferenceGate::default(), + }); + let live = RoomFingerprint::from_bank(&empty_bank()); + + let view = evaluate(&mismatched, &live, 0.95, false, true, 0.9, now); + assert_eq!(view["state"], serde_json::json!({"Unknown": "DeviceMismatch"})); + } + + #[test] + fn live_fingerprint_from_stats_is_deterministic_and_maps_fields_honestly() { + let a = live_fingerprint_from_stats(-50.0, 2.0, 1.5); + let b = live_fingerprint_from_stats(-50.0, 2.0, 1.5); + assert_eq!(a, b, "same inputs must yield byte-identical fingerprints"); + assert_eq!(a.empty_mean, -50.0); + assert_eq!(a.empty_variance, 2.0); + assert_eq!(a.occupied_variance, 3.5); + assert_eq!(a.occupancy_mean_shift, 1.5); + assert_eq!(a.presence_threshold, 0.0, "not derivable live — honestly zeroed, not guessed"); + } + + #[test] + fn load_rejects_a_missing_file() { + // SAFETY: single-threaded test process; no concurrent env mutation. + std::env::set_var(CALIBRATION_KEY_ID_ENV, "x"); + std::env::set_var(CALIBRATION_KEY_SECRET_ENV, "y"); + let err = load("/nonexistent/path/does-not-exist.json", "space/a", "sensor/a") + .expect_err("missing file must fail closed"); + assert!(err.contains("read calibration certificate"), "{err}"); + std::env::remove_var(CALIBRATION_KEY_ID_ENV); + std::env::remove_var(CALIBRATION_KEY_SECRET_ENV); + } + + #[test] + fn load_rejects_when_key_env_vars_are_absent() { + std::env::remove_var(CALIBRATION_KEY_ID_ENV); + std::env::remove_var(CALIBRATION_KEY_SECRET_ENV); + let dir = std::env::temp_dir().join(format!("wdp-ood-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cert.json"); + + let bank = empty_bank(); + let s = signer_for("test-sensor"); + let params = MintParams { + space_id: "space/a".to_string(), + sensor_id: "test-sensor".to_string(), + captured_at_unix_s: 0, + validity_secs: 3600, + version: 1, + tier: CalibrationTier::Auto, + evidence: EvidenceLevel::L0Synthetic, + source: CharacterizationSource::Synthetic, + envelope: CompatibilityEnvelope::default(), + }; + let cert = CalibrationCertificate::mint(params, &bank, &s).unwrap(); + std::fs::write(&path, cert.to_json().unwrap()).unwrap(); + + let err = load(path.to_str().unwrap(), "space/a", "test-sensor") + .expect_err("missing env vars must fail closed, never silently skip verification"); + assert!(err.contains(CALIBRATION_KEY_ID_ENV), "{err}"); + + std::fs::remove_dir_all(&dir).ok(); + } +}