fix(geo numerical): parse_hgt underflow/inf-grid (HIGH) + haversine asin-NaN; pointcloud confirmed-robust (NaN-poisoning class, 3rd find) (#1081)
* fix(geo numerical robustness): parse_hgt underflow panic + haversine asin-domain NaN Targeted numerical-robustness audit of wifi-densepose-geo (ADR-154-class sweep). Two real bugs, each pinned by a fails-on-old test: 1. terrain.rs parse_hgt — usize underflow panic on degenerate input. `side = sqrt(n_samples)`; for empty / sub-2x2 buffers side <= 1, so `1.0 / (side - 1)` underflows `usize` (panic "attempt to subtract with overflow" in debug; wraps to a huge value in release → garbage/inf cell_size_deg that poisons every ElevationGrid::get). A truncated HTTP body or a 404 HTML page reaches parse_hgt. Now bails with a clear error when side < 2. 2. coord.rs haversine — asin domain overflow → NaN for (near-)antipodal points. Floating rounding can push `h.sqrt()` to 1.0 + ~4e-16, and `asin(>1)` is NaN (verified: pair (-44.4994,-178.95722)→(44.49939999, 1.04278001) yields h=1.0000000000000004). A NaN distance silently breaks all downstream `<`/`>` comparisons. Clamp into [0,1] before asin. Also pins the ±90° pole-singularity (cos(lat)=0 division) as no-panic; the ENU transform itself is unchanged (no behavior change for valid inputs). Tests: wifi-densepose-geo 9→15 lib (6 new), 8 integration unchanged. 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * test(pointcloud robustness): pin NaN-state-poisoning resistance + degenerate voxel fusion Numerical-robustness audit of wifi-densepose-pointcloud. No bug found — the crate is confirmed-robust against the proven NaN-state-poisoning class that bit calibration/vitals. This adds regression pins documenting why: 1. csi_pipeline.rs — persistent auto-accumulating state (occupancy EMA, vitals) is provably self-healing. The UDP parser only emits finite amplitudes/phases (sqrt/atan2 of i8), and even an adversarial hand-built CsiFrame with NaN/inf amplitudes+phases cannot latch non-finite state: motion_score = (NaN/100).min(1.0) → 1.0; breathing path → 0 → clamp(5,40) → 5.0; tomography EMA uses only integer rssi. The new test injects 40 poisoned frames and asserts occupancy/vitals stay finite AND the pipeline recovers to an in-range estimate afterward — so a future refactor that drops a `.min`/`.clamp` self-heal would fail this pin. 2. fusion.rs — fuse_clouds voxel averaging is div-by-zero-safe (per-voxel count >= 1 by construction). Pins empty / single-point / all-coincident inputs as no-panic with finite output. No behavior change. Tests: wifi-densepose-pointcloud 18→22 (4 new), 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(geo/pointcloud robustness): CHANGELOG + ADR-154 sibling-crate sweep note Record the wifi-densepose-geo + wifi-densepose-pointcloud numerical-robustness audit under CHANGELOG [Unreleased] → Fixed, and a sibling-crate-extension note on the ADR-154 horizon ledger (these crates are outside ADR-154's signal scope but the sweep is the same ADR-154 class). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
9f80b66ae3
commit
e1f4897269
|
|
@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **#3 JWT-in-URL (CWE-598) — VERIFIED ABSENT, regression-pinned.** `require_bearer` reads the token only from the `Authorization` header; the WebSocket handlers take no token query param and the sole `Query` extractor (`EdgeRegistryParams`) is a non-secret `refresh` flag. Added a regression proving `?token=`/`?access_token=` in the URL never authenticates while the header path still does.
|
||||
|
||||
### Fixed
|
||||
- **`wifi-densepose-geo` numerical-robustness audit — `parse_hgt` degenerate-input panic FIXED + `haversine` antipodal NaN FIXED; pole-singularity & pointcloud NaN-state-poisoning confirmed clean (ADR-154-class sweep).** Targeted numerical-robustness audit of `wifi-densepose-geo` + `wifi-densepose-pointcloud`, hunting the proven non-finite-input-poisons-persistent-state class. **Two real bugs in `geo`, each pinned by a fails-on-old test.** (1) **`terrain.rs::parse_hgt` usize-underflow panic** — `side = sqrt(n_samples)`; for an empty / sub-2x2 buffer `side ≤ 1`, so `1.0 / (side - 1)` underflows `usize` (panic "attempt to subtract with overflow" in debug; wraps to a huge value in release → garbage/inf `cell_size_deg` that then poisons every `ElevationGrid::get` lookup). A truncated SRTM download, a 404 HTML body, or an empty response all reach `parse_hgt` — now `bail!`s with a clear error when `side < 2`. Pinned by `parse_hgt_empty_data_errors_not_panics` (panicked pre-fix) + `parse_hgt_single_sample_errors` (returned inf pre-fix) + a `parse_hgt_minimal_2x2_is_finite` guard. (2) **`coord.rs::haversine` asin-domain → NaN** — for (near-)antipodal points floating rounding can push `h.sqrt()` to `1.0 + ~4e-16`, and `asin(>1)` is NaN, silently breaking every downstream `<`/`>` distance comparison (verified: pair `(-44.4994,-178.95722)→(44.49939999,1.04278001)` yields `h=1.0000000000000004`). Fixed by clamping into `[0,1]` before `asin`. Pinned by `haversine_near_antipodal_is_finite_not_nan` (NaN pre-fix). The ±90° pole-singularity (`cos(lat)=0` division in the ENU transforms) is pinned as no-panic without changing the transform (value-identical for valid inputs). **`wifi-densepose-pointcloud` is confirmed-robust — no bug, no manufactured finding:** the only persistent auto-accumulating state (`occupancy` EMA, vitals) is fed exclusively from the integer-rssi/`sqrt`/`atan2` parser, which can only emit finite values, and the persistent state is provably self-healing even under an adversarial hand-built `CsiFrame` carrying NaN/inf amplitudes+phases (`motion_score=(NaN/100).min(1.0)→1.0`; breathing path `→0→clamp(5,40)→5.0`; tomography EMA uses only integer rssi). Pinned by `nonfinite_frame_does_not_poison_persistent_state` (injects 40 poisoned frames, asserts occupancy/vitals stay finite + the pipeline recovers) and three degenerate-voxel-fusion no-panic tests (empty/single/all-coincident). `wifi-densepose-geo --no-default-features`: 9→15 lib (+6), 8 integration unchanged; `wifi-densepose-pointcloud`: 18→22 (+4); 0 failed; workspace green; Python proof unchanged (`f8e76f21…46f7a`, bit-exact — both crates off the signal proof path).
|
||||
- **Vitals IIR filters self-heal after a non-finite CSI frame — a single NaN/inf no longer permanently kills breathing & heart-rate extraction (`wifi-densepose-vitals`, safety; ADR-021 / ADR-158 §A1).** The 2nd-order resonator in `breathing::BreathingExtractor::bandpass_filter` and `heartrate::HeartRateExtractor::bandpass_filter` latches each output `y[n]` into the filter state (`y1`/`y2`). A non-finite input — one NaN/inf amplitude residual from a corrupt CSI frame — produced a NaN `output` that was written into the state. The existing `extract()` `is_finite()` guard correctly dropped that single sample from history, **but never sanitized the poisoned filter state**, so every subsequent output stayed NaN, was rejected too, and the sliding-window history *never refilled*: the extractor went silently dead (returning `None` forever) until `reset()`. On the vitals alert path this is a safety-relevant denial of service — one bad frame and breathing **and** heart-rate monitoring stop, with no error surfaced. Fix: when `bandpass_filter` computes a non-finite `output` it now resets the IIR state to default and returns `0.0`, so the resonator recovers on the next clean frame (the `0.0` is still dropped by the caller's finite-check — no spurious sample enters history). Same class as the calibration NaN bug (ADR-154 §3) and the firmware vitals fixes (#998/#996/#987): the prior hardening guarded the *history boundary* but not the *filter-state boundary*. Pinned by `breathing::tests::nan_frame_does_not_permanently_poison_filter`, `breathing::tests::inf_mid_stream_does_not_freeze_history`, and `heartrate::tests::nan_frame_does_not_permanently_poison_filter` (all three FAIL on the pre-fix code, verified by reverting). Also de-magicked the safety-critical HR physiological plausibility band into named `HR_PLAUSIBLE_MIN_BPM`/`HR_PLAUSIBLE_MAX_BPM` consts (value-identical 40/180 BPM, pinned by `plausibility_band_constants_pinned`) and added a fabricated-vital negative (`pure_noise_is_never_reported_valid` — broadband noise never yields a clinically `Valid` HR). `wifi-densepose-vitals --no-default-features`: 55→60 lib tests, 0 failed; workspace green; Python proof unchanged (vitals is off the deterministic proof's signal path).
|
||||
- **BFLD MQTT `zone_activity` payload now JSON-escapes the zone name (`wifi-densepose-bfld`).** `mqtt_topics::render_events` emitted the zone payload as `format!("\"{zone}\"")` with no escaping, while `ha_discovery.rs` already escapes operator-controlled strings. A zone name containing a `"` or `\` produced malformed/injectable JSON on the Home-Assistant state topic (e.g. zone `a"b` → payload `"a"b"`). Added a `json_string_literal` escaper mirroring `ha_discovery::push_str_field` and applied it to the zone payload — value-identical for normal zone names (`living_room`, …). Pinned by `zone_payload_escapes_json_metacharacters` (FAILED pre-fix; round-trips through `serde_json`); the existing `zone_payload_is_json_string_with_quotes` still passes unchanged.
|
||||
- **ESP32 vitals: `n_persons` over-counted (reported 4 for one person) + presence flag flickered at close range (#998, #996).** Two firmware logic bugs in `firmware/esp32-csi-node/main/edge_processing.c`, both robustness/logic fixes — **not** validated-accuracy claims (true count/PCK vs labelled ground truth stays hardware/data-gated on the COM9 ESP32-S3).
|
||||
|
|
|
|||
|
|
@ -231,6 +231,8 @@ Catalogued so nothing is silently dropped. Priority: **P1** correctness-adjacent
|
|||
|
||||
> **Horizon-ledger one-liner.** Milestone-0 DONE: dead CIR gate (FIXED+proved), NaN/inf adversarial bypass (FIXED+proved), divide-by-(n−1) window trio (FIXED+proved), calibration dead-branch (FIXED), PSD FFT-planner cache (MEASURED), DTW band (MEASURED). **Milestone-1 DONE (2026-06-13): all four P1 backlog items cleared — circular phase variance #1 (RESOLVED/MEASURED metric, DATA-GATED threshold), Welford n=0 guard #10 (RESOLVED/MEASURED), threshold magic-constants #9 & #13 (RESOLVED-PARTIAL/DATA-GATED — de-magicked + boundary-tested, values unchanged).** **Milestone-2 DONE (2026-06-13): bench-first P2 perf subset + missing boundary tests cleared — spectrogram per-subcarrier FFT re-plan #20 (MEASURED-HOT, 1.40–1.84×, bit-identical); attention/tomography/Kalman #5/#6/#7 (MEASURED-NULL — benched, not hot, left as-is); field_model eigendecompose #8 (MEASUREMENT-ONLY, BLAS un-buildable on this Windows host, number deferred to a BLAS box, NOT fabricated); fft_operator tolerance #14, phase-align convergence-cap #16, csi-ratio epsilon #19 (RESOLVED, tests added).** **Milestone-3 DONE (2026-06-13): the lumped §7.4 row #21–45 P3 backlog cleared, and with it residual P3 items #2/#12/#17/#18 — 22 magic constants de-magicked into named EMPIRICAL-DEFAULT consts (each pinned == prior literal) + 6 boundary/characterization tests across 11 modules; ~4 doc-only; not-real findings (unreachable attractor_drift div0, non-existent gesture thresholds, proof-path features.rs) reported + skipped, no churn; no operating value changed; workspace 3,275/0, Python proof bit-exact `f8e76f21…`.** **§7.4 deferred backlog is now FULLY CLEARED across M0–M3 — nothing silently dropped.**
|
||||
|
||||
> **Sibling-crate sweep extension (2026-06-14) — `wifi-densepose-geo` + `wifi-densepose-pointcloud`.** The ADR-154-class numerical-robustness sweep (non-finite-input-poisons-persistent-state + divide-by-zero / asin-domain / degenerate-geometry) was extended to two crates *outside* this ADR's signal scope. **Two real `geo` bugs FIXED, each fails-on-old-pinned:** `terrain.rs::parse_hgt` usize-underflow panic on empty/sub-2x2 SRTM data (`1.0/(side-1)` → panic in debug / inf `cell_size_deg` poisoning `ElevationGrid::get` in release — a truncated download / 404 HTML body reaches it; now `bail!`s when `side < 2`); `coord.rs::haversine` `asin(>1)→NaN` for near-antipodal points (`h` rounds to `1.0+4e-16`; clamped to `[0,1]`). The ±90° pole `cos(lat)=0` ENU singularity is pinned no-panic without changing the transform. **`pointcloud` is confirmed-robust (no manufactured finding):** its only persistent auto-accumulating state (`occupancy` EMA + vitals) is fed solely by the integer-rssi/`sqrt`/`atan2` parser (always finite) and is provably self-healing even under an adversarial NaN/inf `CsiFrame` (`motion_score=(NaN/100).min(1.0)→1.0`; breathing `→0→clamp(5,40)→5.0`) — pinned by `nonfinite_frame_does_not_poison_persistent_state` + degenerate-voxel-fusion no-panic tests. `geo` 9→15 lib / 8 integration; `pointcloud` 18→22; 0 failed; workspace green; Python proof bit-exact `f8e76f21…`. See CHANGELOG `[Unreleased] → Fixed`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ pub fn haversine(a: &GeoPoint, b: &GeoPoint) -> f64 {
|
|||
let lat1 = a.lat.to_radians();
|
||||
let lat2 = b.lat.to_radians();
|
||||
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||
2.0 * WGS84_A * h.sqrt().asin()
|
||||
// `asin` is only defined on [-1, 1]. For (near-)antipodal points floating
|
||||
// rounding can push `h.sqrt()` to 1.0 + epsilon, and `asin(>1)` is NaN —
|
||||
// which would silently poison any distance-based comparison downstream.
|
||||
// Clamp into domain so the result is always a finite distance.
|
||||
2.0 * WGS84_A * h.sqrt().clamp(0.0, 1.0).asin()
|
||||
}
|
||||
|
||||
/// WGS84 to local ENU (East-North-Up) relative to origin, in meters.
|
||||
|
|
@ -83,3 +87,73 @@ pub fn tiles_for_bbox(bbox: &GeoBBox, zoom: u8) -> Vec<TileCoord> {
|
|||
}
|
||||
tiles
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── haversine asin-domain robustness ───────────────────────────────────
|
||||
//
|
||||
// For (near-)antipodal points, floating rounding can push the haversine
|
||||
// term `h` to 1.0 + ~4e-16, and `asin(sqrt(h)) = asin(>1)` is NaN. A NaN
|
||||
// distance silently breaks every downstream comparison (all `<`/`>` become
|
||||
// false), so the result must stay finite. This exact pair produced
|
||||
// h = 1.0000000000000004 pre-fix (verified empirically).
|
||||
|
||||
#[test]
|
||||
fn haversine_near_antipodal_is_finite_not_nan() {
|
||||
let a = GeoPoint {
|
||||
lat: -44.4994,
|
||||
lon: -178.957_22,
|
||||
alt: 0.0,
|
||||
};
|
||||
let b = GeoPoint {
|
||||
lat: 44.499_399_99,
|
||||
lon: 1.042_780_01,
|
||||
alt: 0.0,
|
||||
};
|
||||
let d = haversine(&a, &b);
|
||||
assert!(d.is_finite(), "near-antipodal haversine must be finite, got {d}");
|
||||
// Half-circumference is ~20_037 km; result must be close to that.
|
||||
assert!(
|
||||
(19_000_000.0..21_000_000.0).contains(&d),
|
||||
"antipodal distance should be ~half-circumference, got {d}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn haversine_identical_points_is_zero() {
|
||||
let p = GeoPoint {
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
alt: 0.0,
|
||||
};
|
||||
let d = haversine(&p, &p);
|
||||
assert!(d.is_finite() && d < 1e-6, "identical points → 0, got {d}");
|
||||
}
|
||||
|
||||
// ── pole-singularity robustness (degenerate geometry) ──────────────────
|
||||
//
|
||||
// The ENU transforms divide by cos(lat); at the poles cos(±90°) = 0, so
|
||||
// the longitude term is non-finite. We do not change the transform (that
|
||||
// would alter near-pole results), but we pin that the call does NOT panic.
|
||||
|
||||
#[test]
|
||||
fn wgs84_to_enu_at_pole_does_not_panic() {
|
||||
let origin = GeoPoint {
|
||||
lat: 90.0,
|
||||
lon: 0.0,
|
||||
alt: 0.0,
|
||||
};
|
||||
let point = GeoPoint {
|
||||
lat: 89.99,
|
||||
lon: 10.0,
|
||||
alt: 0.0,
|
||||
};
|
||||
// Must return without panicking. North/up stay finite; east may be
|
||||
// non-finite at the exact pole — assert the bounded components only.
|
||||
let enu = wgs84_to_enu(&point, &origin);
|
||||
assert!(enu[1].is_finite(), "north component must be finite");
|
||||
assert!(enu[2].is_finite(), "up component must be finite");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,21 @@ pub fn parse_hgt(data: &[u8], origin_lat: f64, origin_lon: f64) -> Result<Elevat
|
|||
let n_samples = data.len() / 2;
|
||||
let side = (n_samples as f64).sqrt() as usize;
|
||||
|
||||
// A valid SRTM grid is at least 2x2 — anything smaller has no cell spacing.
|
||||
// Without this guard, `side - 1` underflows (panic in debug, wraps to a
|
||||
// huge value in release) and `1.0 / (side - 1)` yields a garbage/inf
|
||||
// `cell_size_deg` that then poisons every `ElevationGrid::get` lookup. A
|
||||
// truncated download, a 404 HTML body, or an empty response can all reach
|
||||
// here, so fail loudly instead of corrupting the persisted grid.
|
||||
if side < 2 {
|
||||
anyhow::bail!(
|
||||
"HGT data too small: {} bytes ({} samples, side {}) — need at least a 2x2 grid",
|
||||
data.len(),
|
||||
n_samples,
|
||||
side
|
||||
);
|
||||
}
|
||||
|
||||
let heights: Vec<f32> = data
|
||||
.chunks_exact(2)
|
||||
.map(|c| {
|
||||
|
|
@ -129,3 +144,42 @@ pub fn extract_subgrid(grid: &ElevationGrid, center: &GeoPoint, radius_m: f64) -
|
|||
heights,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_hgt degenerate-input robustness ──────────────────────────────
|
||||
//
|
||||
// Before the `side < 2` guard, an empty or sub-2x2 buffer made
|
||||
// `1.0 / (side - 1)` underflow `side` (panic in debug / huge wrap in
|
||||
// release) and produce a garbage `cell_size_deg`. A truncated download or
|
||||
// a 404 HTML page reaches `parse_hgt`, so these must Err, not panic/poison.
|
||||
|
||||
#[test]
|
||||
fn parse_hgt_empty_data_errors_not_panics() {
|
||||
let res = parse_hgt(&[], 40.0, -75.0);
|
||||
assert!(res.is_err(), "empty HGT must Err, got {res:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hgt_single_sample_errors() {
|
||||
// 2 bytes = 1 sample → side 1 → div-by-zero cell_size (inf) pre-fix.
|
||||
let res = parse_hgt(&[0u8, 0u8], 40.0, -75.0);
|
||||
assert!(res.is_err(), "1-sample HGT must Err, got {res:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hgt_minimal_2x2_is_finite() {
|
||||
// 4 samples = 8 bytes → side 2 → cell_size = 1.0 (finite, valid).
|
||||
let data = vec![0u8; 8];
|
||||
let grid = parse_hgt(&data, 40.0, -75.0).expect("2x2 HGT should parse");
|
||||
assert_eq!(grid.cols, 2);
|
||||
assert_eq!(grid.rows, 2);
|
||||
assert!(
|
||||
grid.cell_size_deg.is_finite() && grid.cell_size_deg > 0.0,
|
||||
"cell_size must be finite positive, got {}",
|
||||
grid.cell_size_deg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -700,4 +700,79 @@ mod tests {
|
|||
assert!(conf > 0.7, "self-similarity should exceed match threshold");
|
||||
}
|
||||
}
|
||||
|
||||
// ── NaN-state-poisoning guard (the proven recurring bug class) ──────────
|
||||
//
|
||||
// The calibration/vitals crates were both bitten by a single non-finite
|
||||
// sample latching into persistent state and freezing all outputs forever.
|
||||
// Here the auto-accumulating persistent state is `occupancy` (an EMA:
|
||||
// `*occ = *occ*0.7 + new*0.3`) and `vitals` (motion/breathing/heart).
|
||||
//
|
||||
// The UDP parser can only ever emit finite amplitudes/phases (sqrt and
|
||||
// atan2 of i8 values), so the realistic ingress is already safe. This test
|
||||
// is stronger: it injects an adversarial hand-built `CsiFrame` carrying
|
||||
// NaN/inf amplitudes and phases (possible because the fields are public),
|
||||
// and pins that the persistent state self-heals to finite values rather
|
||||
// than latching NaN and silently freezing — i.e. the bug class is absent.
|
||||
#[test]
|
||||
fn nonfinite_frame_does_not_poison_persistent_state() {
|
||||
let mut s = CsiPipelineState::default();
|
||||
// Warm up with valid frames so vitals/occupancy are populated.
|
||||
seed_state_with_frames(&mut s, 60);
|
||||
|
||||
// A valid baseline must be finite to start.
|
||||
assert!(s.occupancy.iter().all(|d| d.is_finite()));
|
||||
assert!(s.vitals.breathing_rate.is_finite());
|
||||
assert!(s.vitals.motion_score.is_finite());
|
||||
|
||||
// Inject a stream of poisoned frames: NaN/inf amplitudes + phases on a
|
||||
// valid header (node_id 1, finite rssi). Mimics a corrupt sensor.
|
||||
for i in 0..40 {
|
||||
let nan_frame = CsiFrame {
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: 32,
|
||||
channel: 6,
|
||||
rssi: -50,
|
||||
noise_floor: -90,
|
||||
timestamp_us: 10_000 + i,
|
||||
iq_data: vec![0i8; 64],
|
||||
amplitudes: vec![f32::NAN; 32],
|
||||
phases: vec![f32::INFINITY; 32],
|
||||
};
|
||||
s.process_frame(nan_frame);
|
||||
}
|
||||
|
||||
// Persistent auto-accumulating state must remain finite — a single
|
||||
// poisoned frame (or 40) must not permanently corrupt outputs.
|
||||
assert!(
|
||||
s.occupancy.iter().all(|d| d.is_finite()),
|
||||
"occupancy EMA must not latch NaN/inf"
|
||||
);
|
||||
assert!(
|
||||
s.vitals.breathing_rate.is_finite(),
|
||||
"breathing_rate must stay finite, got {}",
|
||||
s.vitals.breathing_rate
|
||||
);
|
||||
assert!(
|
||||
s.vitals.heart_rate.is_finite(),
|
||||
"heart_rate must stay finite, got {}",
|
||||
s.vitals.heart_rate
|
||||
);
|
||||
assert!(
|
||||
s.vitals.motion_score.is_finite(),
|
||||
"motion_score must stay finite, got {}",
|
||||
s.vitals.motion_score
|
||||
);
|
||||
|
||||
// And the pipeline must recover: feeding valid frames again yields a
|
||||
// finite, in-range breathing estimate (not a frozen NaN).
|
||||
seed_state_with_frames(&mut s, 60);
|
||||
assert!(s.vitals.breathing_rate.is_finite());
|
||||
assert!(
|
||||
(0.0..=40.0).contains(&s.vitals.breathing_rate),
|
||||
"breathing must be in clamp range after recovery, got {}",
|
||||
s.vitals.breathing_rate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,4 +184,43 @@ mod tests {
|
|||
let fused = fuse_clouds(&[&a], 0.5);
|
||||
assert_eq!(fused.points.len(), 1, "three close points → one voxel");
|
||||
}
|
||||
|
||||
// ── degenerate-input robustness (no panic, sensible output) ────────────
|
||||
//
|
||||
// These pin that the voxel accumulators handle empty / single / all-
|
||||
// coincident inputs without dividing by zero or panicking. The per-voxel
|
||||
// count is always >= 1 (the entry is created on first insert), so the
|
||||
// `/n` averaging is safe — but make that contract explicit so a future
|
||||
// refactor cannot silently reintroduce a div-by-zero.
|
||||
|
||||
#[test]
|
||||
fn fuse_clouds_empty_input_is_empty() {
|
||||
let fused = fuse_clouds(&[], 0.1);
|
||||
assert!(fused.points.is_empty(), "no clouds → no points");
|
||||
let empty = PointCloud::new("empty");
|
||||
let fused2 = fuse_clouds(&[&empty], 0.1);
|
||||
assert!(fused2.points.is_empty(), "empty cloud → no points");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_clouds_single_point_is_finite() {
|
||||
let a = cloud_with("a", &[(1.0, 2.0, 3.0)]);
|
||||
let fused = fuse_clouds(&[&a], 0.1);
|
||||
assert_eq!(fused.points.len(), 1);
|
||||
let p = &fused.points[0];
|
||||
assert!(
|
||||
p.x.is_finite() && p.y.is_finite() && p.z.is_finite() && p.intensity.is_finite(),
|
||||
"single-point voxel must average to a finite point"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_clouds_all_coincident_collapses_finite() {
|
||||
// Many identical points → one voxel, finite averaged centroid.
|
||||
let a = cloud_with("a", &[(0.5, 0.5, 0.5); 100]);
|
||||
let fused = fuse_clouds(&[&a], 0.25);
|
||||
assert_eq!(fused.points.len(), 1, "coincident points → one voxel");
|
||||
let p = &fused.points[0];
|
||||
assert!((p.x - 0.5).abs() < 1e-4 && p.x.is_finite());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue