From ebfaee4437c17917afef0ff513ce5df5856cf82c Mon Sep 17 00:00:00 2001 From: rUv Date: Sun, 14 Jun 2026 17:22:20 -0400 Subject: [PATCH] fix(calibration): NaN-poisoning silently disabled presence specialist (Features::from_series unguarded) + de-magic (#1077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(calibration): drop non-finite samples in Features::from_series (ADR-151) A single NaN/inf scalar sample (corrupt CSI frame) poisoned mean/variance into NaN, which — baked into a persisted PresenceSpecialist::threshold — silently disabled presence detection (every `f.variance > NaN` is false), no error raised. extract.rs is the live-inference + training feature path, yet (unlike geometry_embedding.rs) had no non-finite guard. Fix at the production boundary: filter non-finite samples before computing any statistic; an all-non-finite series degrades to Features::ZERO, same as the empty series. Value-identical for all-finite input (full_loop + existing extract tests unchanged). Pinned by two fails-on-old tests. Co-Authored-By: claude-flow * refactor(calibration): de-magic specialist thresholds to named consts (ADR-151) Promote the bare default min-score literals (breathing 0.25, heartbeat 0.3) and the anomaly score scale / label cutoff (2.0× spread, > 0.5) to documented named consts. Value-identical — pinned by characterization tests asserting the consts equal the prior literals and the gate boundary (score >= floor). Co-Authored-By: claude-flow * docs(calibration): record ADR-151 review — NaN fix + clean dimensions CHANGELOG [Unreleased] Security entry and ADR-151 §6.1 review note for the beyond-SOTA correctness+security review: NaN-poisoning fail-closed fix, file/path (no I/O in crate), untrusted-load, receipt/hash (absent), and the clean numerical paths — all with evidence. Co-Authored-By: claude-flow --- CHANGELOG.md | 1 + ...51-room-calibration-specialist-training.md | 48 ++++++++++++ .../wifi-densepose-calibration/src/extract.rs | 76 +++++++++++++++---- .../src/specialist.rs | 58 ++++++++++++-- 4 files changed, 164 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a766aebd..ca12152e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **HC-API-AUTH-01 (auth-gate gap, LOW) — `GET /api/` was unauthenticated; FIXED.** Every sibling REST route (`/api/config`, `/api/states`, `/api/services`, …) calls `BearerAuth::from_headers` first, but `rest::api_root` took no headers and unconditionally returned `200 {"message":"API running."}`. HA's `APIStatusView` inherits `requires_auth = True`, so an unauthenticated/wrong-token request to `/api/` must be **401** — HA clients use this status route as a token-validation probe, and a 200 both told a bad-token client its token was good and let an unauthenticated party confirm a live endpoint. Severity is LOW (the body is a static string — no entity/state data leaks), reported at true severity, not inflated. **Fix:** `api_root` now validates the bearer like its siblings. Pinned by `api_root_rejects_missing_bearer` + `api_root_rejects_wrong_bearer` (both 200→assert-401 on old code) and guarded by `api_root_accepts_correct_bearer`. - **HC-WS-LAG-01 (DoS-adjacent silent failure, LOW) — `subscribe_events` killed the event stream on a broadcast lag; FIXED.** The per-subscription task matched `Err(_) => break` on both `broadcast::Receiver::recv()` arms, but `Lagged(n)` (a slow consumer falling >4,096 events — `EVENT_CHANNEL_CAPACITY` — behind) is **recoverable**: the bus doc itself says "Lagged receivers must re-sync", and HA's WS contract keeps the subscription alive across a lag. The old code treated the first lag as fatal, so after an event burst the client's stream went **permanently silent** with no error frame — a self-inflicted event-delivery DoS under load. **Fix:** `Lagged(_) => continue` (skip the dropped window, re-sync), `Closed => break`, on both the system and domain arms. Pinned by `subscription_survives_broadcast_lag` (subscribes, floods 6,000 filtered events past the 4,096 capacity to force a `Lagged`, then asserts a subsequent subscribed event is still delivered — 5s-timeout panic on old code). - **Dimensions confirmed clean (with evidence, no invented issues):** (1) **AuthN/AuthZ** — all 7 other REST handlers (`get_config`/`get_states`/`get_state`/`set_state`/`delete_state`/`get_services`/`call_service`) gate on `BearerAuth::from_headers` → `LongLivedTokenStore::is_valid` before any work; the WS handshake validates the `auth` token against the **same** store before entering the command loop and the privileged commands are unreachable pre-`auth_ok` (HC-WS-01, already fixed). Token compare is a `HashSet::contains` (content-independent timing, not the byte-`==` oracle ADR-157 §B4 fixed in hardware) — no timing-oracle finding. No route skips the gate, no result-ignored check, no default/empty token accepted (`is_valid` rejects empty internally; `from_env` is non-dev). (2) **Path traversal** — **no route maps user input to a filesystem path** (state lives in an in-memory `DashMap`); `:entity_id` is funneled through `EntityId::parse`, a strict `[a-z0-9_]+\.[a-z0-9_]+` ASCII allowlist that rejects `..`, `/`, `\`, and absolute paths. No traversal surface exists. (3) **Injection** — no SQL, no shell/subprocess, no `format!`-into-response; `call_service`/`set_state` bodies are typed `serde_json::Value` passed to the in-process service registry (matches HA). (4) **Info-leak** — `ApiError` maps to fixed status + a `{message}` derived only from typed variants; `call_service`'s `ServiceError::HandlerFailed(String)` is integration-controlled (mirrors HA surfacing the handler error), not framework internals/paths/stack-traces (no ADR-080-class leak). (5) **CORS** is an explicit allowlist (`allow_credentials(false)`, HC-05 already fixed), not `permissive()`. (6) **De-magic** — no bare security-relevant literals in this crate worth extracting (`EVENT_CHANNEL_CAPACITY` already named in `homecore`; CORS dev-default ports are documented). `homecore-api --no-default-features`: **25→29 tests**, 0 failed (+2 api-root auth, +1 api-root accept-guard, +1 WS lag-survival); workspace green; Python deterministic proof unchanged (homecore-api is off the signal proof path). Review notes appended to ADR-161. +- **`wifi-densepose-calibration` per-room calibration review — NaN-poisoning fail-closed gap FIXED + file/path & receipt surfaces confirmed clean (ADR-151).** Beyond-SOTA correctness+security review of the ADR-151 `baseline → enroll → extract → train → bank` pipeline (the appliance-deployed per-room specialist core), un-covered by the ADR-154–159 sweep. **One real numerical-robustness bug fixed.** `Features::from_series` — the live-inference *and* training feature path — computed `mean`/`variance`/`motion` over the raw scalar series with **no non-finite guard**, so a single `NaN`/`±inf` sample (a corrupt CSI frame) produced `mean=NaN, variance=NaN` and an all-`NaN` prototype embedding. Baked into a persisted `PresenceSpecialist::threshold`/`empty_mean` at train time, that `NaN` **silently disabled presence detection** for the life of the bank (every `f.variance > NaN` and `|mean − NaN|` comparison is false → presence always reads *absent*, confidence 0), with **no error raised** — the exact "produce NaN that poisons a specialist / silently accept garbage" failure, and an asymmetry vs the meticulously NaN-guarded `geometry_embedding.rs`. **Fix at the production boundary:** filter non-finite samples before any statistic (a corrupt frame counts as no frame); a wholly-non-finite series degrades to the new `Features::ZERO`, exactly like the empty series. **Value-identical for all-finite input** — `full_loop.rs` and every existing `extract` test pass unchanged. Pinned by two fails-on-old tests (`non_finite_samples_do_not_poison_features`, `all_non_finite_series_is_zero`, both FAILED pre-fix). **Dimensions confirmed clean (with evidence, no invented issues):** (1) **file/path handling** — the crate does **zero** file/path I/O (no `std::fs`/`Path`/`File`/`read`/`write` anywhere in `src/`; only in-memory `serde_json`), so path-traversal / unbounded-read / artifact-path concerns do not exist at the crate boundary — they live in the `wifi-densepose-cli` consumer (`room.rs`), out of this crate's scope; (2) **untrusted-load** — `SpecialistBank::from_json` parse-validates shape via serde (malformed → `CalibrationError::Serde`), and per ADR-151 invariant (B) banks are local-first, never network-received; (3) **receipt/hash integrity** — the crate emits **no** hash/receipt/witness/signature (no `CalibrationReceipt` analogue), so the engine's unframed-concatenation bug class is structurally absent — nothing to mis-frame; (4) **other numerical paths already robust** — `geometry_embedding.rs` sanitizes every input + sweeps to finite (verified by its `adversarial_inputs_never_produce_nan` test); presence/restlessness/anomaly divisions are all `.max(1e-3)`-guarded; `autocorr_dominant` guards `r0 ≤ 1e-6`, `n < 16`, empty bands; `SpecialistBank::train` rejects empty anchors; anomaly requires ≥2 anchors. De-magicked the bare specialist threshold literals (breathing 0.25 / heartbeat 0.3 default min-scores, anomaly 2.0× spread / >0.5 label cutoff) into named documented consts, value-identical, pinned by `default_min_score_constants_match_prior_literals` + `anomaly_constants_match_prior_literals`. `wifi-densepose-calibration --no-default-features`: **58→62 unit tests** (+2 NaN fail-closed, +2 de-magic pins) + 1 full-loop integration, 0 failed. Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — calibration is off the signal proof path). Review notes appended to ADR-151 §6. - **`wifi-densepose-engine` governed-trust review — witness domain-separation gap FIXED + privacy monotonicity confirmed clean (ADR-137 / ADR-141 / ADR-032).** Beyond-SOTA correctness+security review of the security-critical composition root (the cycle enforcing RuView's privacy guarantees), not covered by the ADR-154–159 sweep. **One real witness-integrity bug fixed.** `witness_of` concatenated `model_version`, `calibration_version`, and `privacy_decision` boundary-to-boundary and left the variable-length evidence list without a count, so a string straddling a field boundary collided with a *different* trust decision — e.g. a per-room adapter id (ADR-150 §3.4, operator-influenceable) absorbing the leading bytes of the calibration epoch (`model="…cal:00a"`,`cal="b"`) yields the same witness as `model="…"`,`cal="cal:00ab"`. Two distinct privacy-relevant input tuples → one witness defeats the ADR-137 §2.7 "any privacy-relevant delta → different witness" tamper/drift audit. **Fix:** domain-tag the BLAKE3 hash (`ruview.engine.witness.v1`), write an explicit evidence count, and **length-prefix every field** (8-byte LE length ‖ bytes) — unambiguous framing regardless of contents. Witness-layout change by design (prior witness bytes invalidated); downstream consumers (`engine_bridge`, rufield) assert only witness *relationships* (`assert_ne`/`assert_eq` across runs), never absolute bytes, so nothing breaks. Pinned by two fails-on-old tests: `witness_distinguishes_model_calibration_boundary`, `witness_distinguishes_evidence_model_boundary`. **Dimensions confirmed clean (with evidence, no invented issues):** (1) **privacy monotonicity** — `effective_class` is recomputed each cycle from the active mode's floor with at most a single-step `demote_one` (clamped at `Restricted`), no cross-cycle state, proven over **all 5 modes** by `forced_contradiction_never_relaxes_class` (forced contradiction only ever raises the class byte; clean cycle == base); (2) **fail-closed** — empty cycle errors with no degenerate output (`empty_cycle_fails_closed`), single-node boundary characterized (`single_node_cycle_is_well_formed`), NaN coupling → `max(0.0)`→absent edge→at-risk (more restrictive); (3) **witness determinism** — no HashMap iteration / float formatting feeds the hash; (4) **mesh_guard** (ADR-032) — partition-risk → demotion path verified, thresholds already named documented fields. De-magicked the engine-construction literals (coherence accept gate, ADR-143 SLAM discovery + static-anchor thresholds) into named documented consts, value-identical, pinned by `engine_constants_match_prior_values`. `wifi-densepose-engine --no-default-features`: **27→33 tests**, 0 failed (+2 witness, +1 monotonicity property, +2 fail-closed boundary, +1 de-magic pin). Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — the engine is off the signal proof path). Review notes appended to ADR-137 (witness) and ADR-141 (monotonicity). - **ADR-141 BFLD privacy-bypass closed — `process_to_frame` now routes the payload through `PrivacyGate` (`wifi-densepose-bfld`).** `BfldPipeline::process_to_frame` stamped the emitted `BfldFrame` header with the active `PrivacyClass` but serialized the caller-supplied `BfldPayload` **unchanged** via `BfldFrame::from_payload`. A frame labeled `Anonymous`(2) or `Restricted`(3) therefore carried the full identity-leaky `compressed_angle_matrix` (the beamforming-angle identity surface) + amplitude/phase proxies + `csi_delta` — exactly the sections `PrivacyGate::demote` is documented and tested (`privacy_gate_demote.rs`) to strip at those classes. Because a `NetworkSink` accepts class ≥ `Derived`(1), such a frame would publish the identity surface across the node boundary despite its restrictive class byte; the class byte lied about payload content. **Fix:** after building the frame at the active class, apply `PrivacyGate::demote` to the same class — a no-op class transition that strips the sections that class forbids (research classes `Raw`/`Derived` keep the full payload). Pinned by three fails-on-old tests in `pipeline_to_frame.rs` (`…_at_anonymous_strips_identity_leaky_sections`, `…_in_privacy_mode_strips_amplitude_and_phase` — both FAILED pre-fix; `…_at_derived_preserves_full_payload` guards against over-stripping). Grade: privacy-bypass FIXED + regression-pinned. - **ADR-157 Milestone-1 B4 - constant-time HMAC sync-beacon tag compare (`wifi-densepose-hardware`).** `AuthenticatedBeacon::verify` compared the 8-byte HMAC-SHA256 tag with `self.hmac_tag == expected`, which short-circuits on the first differing byte and leaks, through verification latency, how many leading bytes an attacker's forged tag matched - a byte-by-byte tag-recovery oracle (~256*N trials instead of 256^N). Replaced with a hand-rolled branch-free `constant_time_tag_eq` (XOR-accumulate every byte difference into a single `u8`, no early exit, `#[inline(never)]` + `core::hint::black_box` to stop the optimizer reintroducing a short-circuit or a non-constant-time `memcmp`). **No new dependency** - ADR-157 had deferred this only to avoid adding the `subtle` crate; a fixed 8-byte compare needs none. Grade MEASURED (constant-time *construction*; micro-timing on a noisy host is a smoke check only, gated `#[ignore]`). Pinned by `tag_compare_is_constant_time_shape` (equal/first-differ/last-differ/all-differ/length-mismatch + an end-to-end `verify()` last-byte tamper), proven to fail on a last-byte-skipping constant-time bug. ADR-157 §8 B4 -> RESOLVED. diff --git a/docs/adr/ADR-151-room-calibration-specialist-training.md b/docs/adr/ADR-151-room-calibration-specialist-training.md index 6af4e8cb..ecfd70f8 100644 --- a/docs/adr/ADR-151-room-calibration-specialist-training.md +++ b/docs/adr/ADR-151-room-calibration-specialist-training.md @@ -253,6 +253,54 @@ Validation per CLAUDE.md: `cargo test --workspace --no-default-features` green; --- +## 6. Review notes + +### 6.1 Correctness + security review (2026-06-14) + +Beyond-SOTA correctness+security review of `wifi-densepose-calibration` (this +ADR's pipeline), un-covered by the ADR-154–159 sweep. + +**Finding (FIXED) — NaN-poisoning of the feature path (numerical / fail-closed).** +`Features::from_series` — the carrier for both live inference and training-anchor +extraction — computed `mean`/`variance`/`motion` over the raw scalar series with +no non-finite guard. A single `NaN`/`±inf` sample (corrupt CSI frame) yielded +`mean=NaN, variance=NaN` and an all-`NaN` prototype embedding. Persisted into a +`PresenceSpecialist::threshold`/`empty_mean` at train time, the `NaN` **silently +disabled presence detection** for the bank's lifetime (every `>` / `|·|` +comparison against `NaN` is false → always reads *absent*, confidence 0), with no +error — and an asymmetry against the rigorously NaN-guarded `geometry_embedding`. +Fixed at the production boundary: non-finite samples are dropped (a corrupt frame +counts as no frame), an all-non-finite series degrades to `Features::ZERO` like +the empty series. Value-identical for all-finite input (full-loop + extract tests +unchanged); pinned by `non_finite_samples_do_not_poison_features` and +`all_non_finite_series_is_zero` (both fail on the old code). + +**Clean dimensions (evidence, no invented issues).** +- *File/path handling:* the crate performs **zero** file/path I/O (no + `std::fs`/`Path`/`File`/`read`/`write` in `src/`; only in-memory `serde_json`). + Path-traversal / unbounded-read / artifact-path handling live entirely in the + `wifi-densepose-cli` consumer (`room.rs`), outside this crate's boundary. +- *Untrusted-load:* `SpecialistBank::from_json` shape-validates via serde + (malformed → `CalibrationError::Serde`); banks are local-first (invariant B), + never network-received. A well-formed bank with adversarial numerics is trusted + as-is — acceptable under the local-first threat model; a validate-on-load + defense-in-depth pass is a possible future hardening, not a present bug. +- *Receipt/hash integrity:* the crate emits no hash/receipt/witness/signature, so + the unframed-concatenation bug class (cf. the engine `witness_of` fix) is + structurally absent. +- *Other numerical paths:* `geometry_embedding` sanitizes every input and sweeps + to finite; presence/restlessness/anomaly divisions are `.max(1e-3)`-guarded; + `autocorr_dominant` guards `r0`, short signals, and empty bands; `train` rejects + empty anchors; anomaly requires ≥2 anchors. + +De-magicked the bare specialist threshold literals (breathing/heartbeat default +min-scores, anomaly outlier-spread multiple + label cutoff) into named documented +consts, value-identical, pinned by const-equality tests. Tests +**58→62 unit + 1 integration, 0 failed**; Python deterministic proof unchanged +(off the signal proof path). + +--- + ## 5. Summary > Big models understand the world. Small ruVector models understand *your room*. diff --git a/v2/crates/wifi-densepose-calibration/src/extract.rs b/v2/crates/wifi-densepose-calibration/src/extract.rs index 2c458c5e..97e2ec97 100644 --- a/v2/crates/wifi-densepose-calibration/src/extract.rs +++ b/v2/crates/wifi-densepose-calibration/src/extract.rs @@ -43,6 +43,20 @@ pub struct Features { pub const EMBED_MIN_SCORE: f32 = 0.25; impl Features { + /// The all-zero feature vector — the well-defined result of an empty (or + /// wholly non-finite) capture. Total by construction: downstream + /// specialists read it as "no signal" rather than panicking or poisoning a + /// threshold (see [`Features::from_series`]). + pub const ZERO: Features = Features { + mean: 0.0, + variance: 0.0, + motion: 0.0, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + }; + /// A fixed-length numeric embedding for nearest-prototype classifiers. /// /// The hz components are zeroed unless their periodicity score clears @@ -77,29 +91,33 @@ impl Features { } /// Extract features from a per-frame scalar series sampled at `fs` Hz. + /// + /// **Total / fail-closed:** non-finite samples (`NaN`/`±inf`) are dropped + /// before any statistic is computed, so a single garbage CSI frame cannot + /// poison `mean`/`variance` into `NaN` and silently disable a persisted + /// specialist (a `NaN` threshold makes every `>` comparison false). A + /// series with no finite samples yields [`Features::ZERO`], exactly like + /// the empty series. Same defensive contract as + /// [`GeometryEmbedding`](crate::geometry_embedding::GeometryEmbedding): + /// adversarial input degrades to "no signal", never to `NaN`. pub fn from_series(series: &[f32], fs: f32) -> Features { - let n = series.len(); + // Drop non-finite samples: a corrupt frame counts as no frame, not as + // a NaN that propagates through every downstream statistic. + let clean: Vec = series.iter().copied().filter(|v| v.is_finite()).collect(); + let n = clean.len(); if n == 0 { - return Features { - mean: 0.0, - variance: 0.0, - motion: 0.0, - breathing_score: 0.0, - breathing_hz: 0.0, - heart_score: 0.0, - heart_hz: 0.0, - }; + return Features::ZERO; } - let mean = series.iter().copied().sum::() / n as f32; - let variance = series.iter().map(|v| (v - mean) * (v - mean)).sum::() / n as f32; + let mean = clean.iter().copied().sum::() / n as f32; + let variance = clean.iter().map(|v| (v - mean) * (v - mean)).sum::() / n as f32; let motion = if n > 1 { - series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::() / (n - 1) as f32 + clean.windows(2).map(|w| (w[1] - w[0]).abs()).sum::() / (n - 1) as f32 } else { 0.0 }; // De-mean before periodicity search. - let centered: Vec = series.iter().map(|v| v - mean).collect(); + let centered: Vec = clean.iter().map(|v| v - mean).collect(); let (breathing_hz, breathing_score) = autocorr_dominant(¢ered, fs, 0.1, 0.6); let (heart_hz, heart_score) = autocorr_dominant(¢ered, fs, 0.8, 3.0); @@ -254,6 +272,36 @@ mod tests { assert_eq!(f.breathing_hz, 0.0); } + /// Fail-closed regression: a NaN/inf in the scalar series (corrupt CSI + /// frame) must NOT poison the features into `NaN`/`inf`. Pre-fix, a single + /// `NaN` made `mean`/`variance` `NaN`, which — baked into a persisted + /// `PresenceSpecialist::threshold` — silently disabled presence detection + /// (every `f.variance > NaN` is false). Non-finite samples are dropped. + #[test] + fn non_finite_samples_do_not_poison_features() { + let f = Features::from_series(&[1.0, 2.0, f32::NAN, 4.0, f32::INFINITY, 6.0], 15.0); + assert!(f.mean.is_finite(), "mean must stay finite, got {}", f.mean); + assert!(f.variance.is_finite(), "variance must stay finite, got {}", f.variance); + assert!(f.motion.is_finite(), "motion must stay finite, got {}", f.motion); + for x in f.embedding() { + assert!(x.is_finite(), "embedding slot non-finite: {x}"); + } + // Mean is over the 4 finite samples {1,2,4,6} only. + assert!((f.mean - 3.25).abs() < 1e-5, "mean over finite samples, got {}", f.mean); + // Equivalence: dropping the non-finite samples must equal feeding only + // the finite ones — proves the filter, not just finiteness. + let only_finite = Features::from_series(&[1.0, 2.0, 4.0, 6.0], 15.0); + assert_eq!(f, only_finite); + } + + /// A series with no finite samples degrades to the all-zero `ZERO`, exactly + /// like the empty series — never `NaN`. + #[test] + fn all_non_finite_series_is_zero() { + let f = Features::from_series(&[f32::NAN, f32::INFINITY, f32::NEG_INFINITY], 15.0); + assert_eq!(f, Features::ZERO); + } + /// ADR-152 "heart-band leakage" regression: a strong breathing rhythm must /// NOT register as a heart-band periodicity — its in-band autocorr maximum /// sits at the band edge (monotonic leak), not an interior peak. diff --git a/v2/crates/wifi-densepose-calibration/src/specialist.rs b/v2/crates/wifi-densepose-calibration/src/specialist.rs index 19190203..8b41b033 100644 --- a/v2/crates/wifi-densepose-calibration/src/specialist.rs +++ b/v2/crates/wifi-densepose-calibration/src/specialist.rs @@ -15,6 +15,28 @@ use serde::{Deserialize, Serialize}; use crate::anchor::{AnchorLabel, Posture}; use crate::extract::{AnchorFeature, Features}; +/// Default minimum breathing-band periodicity score to report a rate, used when +/// a [`BreathingSpecialist`] carries no explicit `min_score` (the serde / pre- +/// trained-default case). Respiration is a strong, narrowband modulation, so a +/// moderate floor rejects noise windows without dropping real breaths. +pub const DEFAULT_BREATHING_MIN_SCORE: f32 = 0.25; + +/// Default minimum HR-band periodicity score, used when a [`HeartbeatSpecialist`] +/// carries no explicit `min_score`. Higher than breathing's: sub-mm chest +/// displacement at HR frequencies sits near the CSI noise floor (ADR-151 §3.2), +/// so the heartbeat head demands a cleaner peak before reporting. +pub const DEFAULT_HEARTBEAT_MIN_SCORE: f32 = 0.3; + +/// Multiple of the typical inter-anchor spread ([`AnomalySpecialist::scale`]) +/// beyond which a live window is fully out-of-distribution (anomaly score 1.0): +/// a window more than this many spreads from every enrolled prototype is novel. +pub const ANOMALY_OUTLIER_SPREADS: f32 = 2.0; + +/// Anomaly score above which the window is *labelled* "anomalous" (vs "normal"). +/// Distinct from the runtime veto threshold ([`crate::runtime`]); this only +/// drives the human-readable label. +pub const ANOMALY_LABEL_CUTOFF: f32 = 0.5; + /// Which biological signal a specialist estimates. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SpecialistKind { @@ -229,7 +251,7 @@ impl Specialist for BreathingSpecialist { let min = if self.min_score > 0.0 { self.min_score } else { - 0.25 + DEFAULT_BREATHING_MIN_SCORE }; if f.breathing_score < min || f.breathing_hz <= 0.0 { return None; @@ -258,7 +280,7 @@ impl Specialist for HeartbeatSpecialist { let min = if self.min_score > 0.0 { self.min_score } else { - 0.3 + DEFAULT_HEARTBEAT_MIN_SCORE }; if f.heart_score < min || f.heart_hz <= 0.0 { return None; @@ -383,13 +405,13 @@ impl Specialist for AnomalySpecialist { .sqrt(); best = best.min(d); } - // >2× the typical spread → anomalous. - let score = (best / (2.0 * self.scale)).clamp(0.0, 1.0); + // Beyond ANOMALY_OUTLIER_SPREADS× the typical spread → fully anomalous. + let score = (best / (ANOMALY_OUTLIER_SPREADS * self.scale)).clamp(0.0, 1.0); Some(SpecialistReading { kind: SpecialistKind::Anomaly, value: score, confidence: 0.6, - label: Some(if score > 0.5 { "anomalous" } else { "normal" }.into()), + label: Some(if score > ANOMALY_LABEL_CUTOFF { "anomalous" } else { "normal" }.into()), }) } } @@ -505,6 +527,32 @@ mod tests { assert!(b.infer(&feat(5.0, 0.2, 0.3, 0.1)).is_none()); // low score → none } + /// De-magic pin: the named default min-scores must equal the historical + /// literal values, and the gate boundary must be `score >= min` (a window + /// exactly at the default floor reports; a hair below does not). + #[test] + fn default_min_score_constants_match_prior_literals() { + assert_eq!(DEFAULT_BREATHING_MIN_SCORE, 0.25); + assert_eq!(DEFAULT_HEARTBEAT_MIN_SCORE, 0.3); + let b = BreathingSpecialist::default(); // min_score = 0.0 → uses default + assert!( + b.infer(&feat(5.0, 0.2, 0.3, DEFAULT_BREATHING_MIN_SCORE)).is_some(), + "score exactly at the default floor must report" + ); + assert!( + b.infer(&feat(5.0, 0.2, 0.3, DEFAULT_BREATHING_MIN_SCORE - 1e-3)).is_none(), + "score below the default floor must not report" + ); + } + + /// De-magic pin for the anomaly score scale + label cutoff (value-identical + /// to the prior `2.0 * scale` / `> 0.5` literals). + #[test] + fn anomaly_constants_match_prior_literals() { + assert_eq!(ANOMALY_OUTLIER_SPREADS, 2.0); + assert_eq!(ANOMALY_LABEL_CUTOFF, 0.5); + } + #[test] fn restlessness_normalizes() { let anchors = vec![