diff --git a/.gitignore b/.gitignore index 5c5aa76c..999387bf 100644 --- a/.gitignore +++ b/.gitignore @@ -295,3 +295,4 @@ ruvector.db # sensing-server runtime artifacts written by its test suite (trained model # snapshots + the generated session-secret) — never tracked v2/crates/wifi-densepose-sensing-server/data/ +*.proptest-regressions diff --git a/CHANGELOG.md b/CHANGELOG.md index 1313a10f..d6a5ac59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean. - **`ruview-unified` increment 2 — native frame contract + programmable perception (ADR-279..282).** (1) `RfFrameV2` becomes the authoritative RF record: native complex IQ with explicit validity masks, declared `PhaseState`, TX/RX poses + antenna geometry in one building frame, calibration/quality state, and a provenance rule enforced at construction — `Synthetic ⇒ L0Simulation` and `Measured ⇒ ≥ L1CapturedReplay` can never alias (the public L0–L5 evidence ladder is now a type); the 56-bin canonical tensor is demoted to a derived compatibility view (`to_canonical`, mask-aware gap-filling through the same normalization path as every adapter; native samples proven byte-untouched). (2) Active sensing control plane (`control.rs`): ETSI-ISAC-vocabulary `SensingTask` admission (raw export always refused; identity requires consent), `SensingAction`/`InformationGoal`, an age-of-information `ActiveSensingPlanner` (priority = uncertainty × change rate × criticality ÷ cost; **measured 95% sensing-traffic reduction** vs uniform refresh on a 20-region scenario), fail-closed `CoherentSensorGroup` fusion gates (time/phase/geometry bounds; five denial paths tested), policy-authorized RIS/movable-antenna actuation receipts, and purpose-scoped `TaskSufficientRepresentation` leakage validation. (3) New modality surfaces: BLE Channel Sounding adapter + `ble_cs_range` treating phase-slope and RTT as **separate cross-validated evidence** (exact distance recovery on synthetic tones; relay-style divergence flagged, never averaged), delay-Doppler-native `FieldAxis` + `delay_doppler_map` (unit-peak tone test), IEEE P3162 synthetic-aperture import profile. (4) RePos-factorized pose head (relative skeleton on the content representation, root on the geometry-conditioned one, calibrated per-joint uncertainties): held-out-room MPJPE 0.0003 m vs 0.2534 m for the monolithic baseline in the room-shortcut leakage experiment; ≤2% structured-adapter budget (740 params). (5) Age gate input now `log(1+age_ms)` per the age-aware-CSI recipe (gradient check re-proven); Gaussian primitives gained `first_seen_ns`/`doppler_variance`/bounded `source_receipts` lineage; `PartitionKey` gained a `session` dimension and `SplitManifest` certifies disjointness across all seven dimensions. 87 tests, 0 failed; crate clippy-clean. Docker images unaffected (no shipped binary consumes the crate yet); Python proof re-verified PASS. - **`ruview-unified` — unified RF spatial world model, P1 (ADR-273..278).** New v2 workspace leaf crate implementing the five-pillar architecture: (1) canonical `RfTensor` (`links × 56 bins × 8 snapshots`, complex, validated at the boundary) plus a fail-closed hardware adapter registry with reference adapters for 802.11 CSI (consumes `wifi-densepose-core::CsiFrame`), FMCW radar cubes (fast-time DFT), UWB CIR, and 5G SRS (comb de-interleave); (2) a universal RF foundation encoder — window-median + CFO-aligned tokenizer, masked-reconstruction pretraining with a hand-derived backward pass verified against central finite differences (174 params sampled, max rel err 1.31e-5), the ADR-273 fusion contract `z = Enc(CSI) ⊙ σ(AgeEnc) + GeomEnc(pose)`, and ≤1% task adapters (presence 129 / activity 268 / localization 387 / anomaly 2 vs a 40,856-param backbone); (3) an RF-aware Gaussian spatial memory — anisotropic primitives with per-band×angle reflectivity, confidence-weighted fusion, exponential decay, spatial-hash + semantic queries, closed-form (erf) Beer–Lambert channel-gain queries that degrade to exact Friis on an empty map, inverse gain updates that learn an unseen 6 dB obstruction to <0.5 dB in 20 link observations, and a JITOMA-style task-gated scene graph; (4) a physics-guided synthetic RF world generator — Allen–Berkley image method (order ≤2), complex-permittivity Fresnel materials, bistatic person scattering with *emergent* Doppler (proven against the analytic phase rate), seeded ChaCha20 domain randomization of physics + hardware nuisances (gain/CFO/phase noise/packet loss/interference); (5) an edge sensing control plane — 802.11bf/ETSI-ISAC-aligned purposes and zones, fail-closed authorization, a double-gated identity purpose, retention bounds, and a `BoundedEvent`-only trust boundary that makes raw RF export unrepresentable. Anti-leakage evaluation (`StrictSplit` by room/day/person/chipset/firmware/layout with an independent disjointness verifier, ECE, selective risk, degradation) plus an end-to-end acceptance pipeline: presence F1 1.00 on held-out rooms *and* held-out chipset, degradation 0.0, ECE 0.012, p95 tokenize+encode 2.0 ms debug / 105 µs release — **all SYNTHETIC** (honest labeling propagates from `RfModality::Synthetic` through `Provenance.synthetic`). Criterion benches with an optimization pass: segment-corridor candidate search took `channel_gain` from 139 µs → 27 µs (O(1) in map size; hash/linear crossover at ~4k Gaussians reported honestly), `observe_link` 305 µs → 74 µs, precomputed DFT twiddles 4.9×. 66 unit + 3 acceptance tests, 0 failed. diff --git a/docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md b/docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md index e3f025b2..2b26b58a 100644 --- a/docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md +++ b/docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md @@ -33,7 +33,8 @@ Validated constructor (quaternion normalized, ranges checked); anisotropy and ro `GaussianMap` (`map.rs`): spatial-hash grid (1 m default pitch) over a flat store. - **Fusion, not accumulation**: an insert within Mahalanobis² 9 of a same-entity-kind Gaussian merges — confidence-weighted position/scale/occupancy/semantics/reflectivity/Doppler, noisy-OR confidence (`c₁+c₂−c₁c₂`), newest provenance wins, links union. Test: two 0.5-confidence observations 0.1 m apart fuse to one Gaussian at the weighted midpoint with confidence 0.75. -- **Decay**: exponential confidence decay per Gaussian τ; prune below 0.02; deterministic (replay test). +- **Decay + static persistence** (update-loop step 7): exponential confidence decay per Gaussian τ, **stretched by observed lifetime** — `τ_eff = τ·(1 + ln(1 + lifetime/τ))` with `lifetime = last_seen − first_seen` — so a wall confirmed over 30 min outlives a once-seen transient at equal nominal τ (test `long_lived_structure_outlives_transients_at_equal_tau`); prune below 0.02; deterministic (replay test). +- **Merge pass** (update-loop step 5): `merge_overlapping` collapses pairs that are *mutually* inside each other's Mahalanobis gate **and** semantically compatible (cosine ≥ 0.7, or both unlabeled) — orthogonal-semantic overlaps stay separate (test `merge_pass_collapses_mutual_overlaps_but_respects_semantics`). This catches drift the insert-time gate (±1 cell neighborhood only) misses. - **Queries**: radius (hash + linear reference impl, equivalence-tested on 100-Gaussian grids), kNN (expanding ring), semantic cosine top-k, and the segment-corridor query below. ## 4. Decision — channel gain as a first-class query + inverse update diff --git a/docs/adr/ADR-277-edge-sensing-control-plane.md b/docs/adr/ADR-277-edge-sensing-control-plane.md index 8ab74622..948597dd 100644 --- a/docs/adr/ADR-277-edge-sensing-control-plane.md +++ b/docs/adr/ADR-277-edge-sensing-control-plane.md @@ -45,6 +45,15 @@ The P1 control plane is transport-agnostic and already fronts the cellular seam: - **Reuse BFLD's privacy classes directly** — rejected: BFLD (ADR-120) classifies *captures*; this plane authorizes *outputs by purpose and zone*. They compose (a BFLD-classified capture feeding a head still exits through `TrustBoundary`), and ADR-262's `map_privacy` remains the capture-side mapping. - **Config-file allow-lists without types** — rejected: the 19 ETSI issue classes are mostly "the code path existed" failures; unrepresentability beats configuration. +## 5.5 Boundary hardening (property-tested) + +`tests/security_boundaries.rs` drives every validated constructor and every authorization gate with `proptest` over arbitrary values — including NaN/±inf smuggled via `f64::from_bits` — and asserts the *contract* (valid object **or** typed error, never a panic, never a permissive default). Three real defects surfaced and were fixed, all input-controlled denial-of-service or NaN-propagation: + +1. `ble_cs_range` unwrap looped forever on a **non-finite** phase (`+inf − x = +inf`); a **finite-but-huge** phase (1e300 rad) made the same loop run ~1e299 iterations. Fixed by rejecting implausible phases (> 1e6 rad) and replacing the loop-based unwrap with O(1) modular arithmetic. +2. A **subnormal** Gaussian scale (5e-324) passed `> 0` but overflowed `1/σ²` to ∞, making the density at the primitive's own centre NaN. Fixed with physical plausibility bounds (σ ∈ [1e-6, 1e4] m, occupancy ∈ [0, 1e6] nepers/m). + +The eight properties now proven: tensor/Gaussian/BoundedEvent constructors never panic; `ble_cs_range` never panics and yields only finite non-negative distances; the policy engine is fail-closed for every (purpose, grants, zone) triple; raw export is unreachable for every task configuration; coherent fusion rejects every non-finite or out-of-bounds sync state; occupancy representations can never retain identity. + ## 6. Consequences - Enterprise/telecom conversations get a concrete artifact: a privacy manifest is a serialization of zones + purposes + retention (all types already `serde`). diff --git a/docs/adr/ADR-281-ble-cs-delay-doppler-pose-factorization.md b/docs/adr/ADR-281-ble-cs-delay-doppler-pose-factorization.md index 28360319..badbdf6a 100644 --- a/docs/adr/ADR-281-ble-cs-delay-doppler-pose-factorization.md +++ b/docs/adr/ADR-281-ble-cs-delay-doppler-pose-factorization.md @@ -22,7 +22,7 @@ Single-source evidence (no RTT) is capped at confidence 0.5 — one mechanism al ## 2. Delay-Doppler-native support (§3) -`FieldAxis` (ADR-279) makes delay/Doppler first-class native axes so OTFS-style captures are stored natively, and `RfTensor::delay_doppler_map` provides the standard transform for frequency-time tensors: IDFT over bins (→ delay) × DFT over snapshots (→ Doppler). Measured: a synthetic scatterer at (delay 7, Doppler 3) produces a unit peak with < 1e-9 leakage everywhere else. Rule: derived features may be small, but delay-Doppler maps are not collapsed into scalar motion energy before provenance and local storage. +`FieldAxis` (ADR-279) makes delay/Doppler first-class native axes so OTFS-style captures are stored natively, and `RfTensor::delay_doppler_map` provides the standard transform for frequency-time tensors: IDFT over bins (→ delay) × DFT over snapshots (→ Doppler). Measured: a synthetic scatterer at (delay 7, Doppler 3) produces a unit peak with < 1e-9 leakage everywhere else. The transform is implemented **separably** (delay IDFT per snapshot, then Doppler DFT per delay row — `O(B²S + S²B)` vs the direct form's `O(B²S²)`), proven equivalent to the direct reference to < 1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8 in the criterion bench). Rule: derived features may be small, but delay-Doppler maps are not collapsed into scalar motion energy before provenance and local storage. ## 3. IEEE P3162 synthetic-aperture import (§5) diff --git a/v2/crates/ruview-unified/benches/unified_bench.rs b/v2/crates/ruview-unified/benches/unified_bench.rs index 856bf7e1..cef4839d 100644 --- a/v2/crates/ruview-unified/benches/unified_bench.rs +++ b/v2/crates/ruview-unified/benches/unified_bench.rs @@ -139,6 +139,131 @@ fn bench_gaussian_map(c: &mut Criterion) { }); } +fn bench_increment2_paths(c: &mut Criterion) { + use num_complex::Complex64; + use ruview_unified::adapters::{ble_cs_range, BleCsFrame}; + use ruview_unified::control::{ + ActiveSensingPlanner, CoherentSensorGroup, MemberSyncState, SpatialStateFreshness, + SpatialZone, + }; + use ruview_unified::frame::{ + AntennaElement, CalibrationState, EvidenceLevel, FieldAxis, FrameProvenance, PhaseState, + Pose3, ProvenanceClass, RfFrameV2, SignalQuality, + }; + use ruview_unified::heads::FactorizedPoseHead; + use ruview_unified::tensor::{LinkGeometry, RfModality}; + + // Delay-Doppler: separable vs direct. + let corpus = synth_corpus(); + c.bench_function("delay_doppler_separable_56x8", |b| { + b.iter(|| std::hint::black_box(corpus[0].tensor.delay_doppler_map(0).unwrap())); + }); + c.bench_function("delay_doppler_direct_56x8", |b| { + b.iter(|| std::hint::black_box(corpus[0].tensor.delay_doppler_map_direct(0).unwrap())); + }); + + // Native frame → canonical derived view (114 subcarriers, 12 snapshots). + let n = 114 * 12; + let frame = RfFrameV2::new( + 1, + 0, + RfModality::WifiCsi, + vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time], + 2.437e9, + 20e6, + 100.0, + vec![1, 114, 12], + (0..n).map(|i| Complex64::new(1.0 + 0.01 * (i % 13) as f64, 0.001 * (i % 7) as f64)).collect(), + vec![true; n], + Some(Pose3 { position_m: [0.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }), + Some(Pose3 { position_m: [4.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }), + vec![AntennaElement { position_m: [0.0; 3], gain_dbi: 2.0 }], + 5_000_000, + CalibrationState { + phase_state: PhaseState::Raw, + gain_calibrated: false, + clock_ppm: 12.0, + baseline_id: None, + confidence: 0.8, + }, + SignalQuality { rssi_dbm: -45.0, noise_floor_dbm: -92.0, packet_loss: 0.02, interference: 0.05 }, + FrameProvenance { + class: ProvenanceClass::Measured, + evidence: EvidenceLevel::L2Lab, + device_id: "bench".into(), + firmware: "fw".into(), + receipt_id: 1, + }, + ) + .expect("valid frame"); + let geo = vec![LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] }]; + c.bench_function("rfframe_to_canonical_114x12", |b| { + b.iter(|| std::hint::black_box(frame.to_canonical(geo.clone()).unwrap())); + }); + + // BLE CS ranging (40 steps). + let cs = { + let c_light = 299_792_458.0; + let steps: Vec = (0..40).map(|k| 2.402e9 + 1e6 * k as f64).collect(); + let phases: Vec = steps + .iter() + .map(|f| (-4.0 * std::f64::consts::PI * f * 5.0 / c_light) + .rem_euclid(2.0 * std::f64::consts::PI)) + .collect(); + BleCsFrame { frequency_steps_hz: steps, phase_samples_rad: phases, round_trip_time_ns: Some(33.36) } + }; + c.bench_function("ble_cs_range_40steps", |b| { + b.iter(|| std::hint::black_box(ble_cs_range(&cs).unwrap())); + }); + + // AoI planner over 200 regions. + let mut planner = ActiveSensingPlanner::new(0.01); + for i in 0..200 { + planner.upsert_region(SpatialStateFreshness { + region_id: format!("r{i}"), + region: SpatialZone { id: format!("r{i}"), min_m: [0.0; 3], max_m: [5.0; 3] }, + last_observed_ns: (i as u64) * 1_000_000, + expected_change_rate: 0.01 + (i % 7) as f64 * 0.05, + uncertainty_growth_rate: 0.1, + business_criticality: 1.0 + (i % 3) as f64, + sensing_cost: 1.0, + }); + } + c.bench_function("aoi_planner_next_action_200regions", |b| { + b.iter(|| { + std::hint::black_box(planner.next_action(60_000_000_000, RfModality::WifiCsi)) + }); + }); + + // Coherent fusion gate, 32 members. + let group = CoherentSensorGroup { + group_id: "g".into(), + members: (0..32).map(|i| format!("ap-{i}")).collect(), + maximum_time_error_ns: 50.0, + maximum_phase_error_rad: 0.2, + baseline_geometry_hash: 7, + }; + let states: Vec = (0..32) + .map(|i| MemberSyncState { + member_id: format!("ap-{i}"), + time_error_ns: 10.0, + phase_error_rad: 0.05, + geometry_hash: 7, + }) + .collect(); + c.bench_function("coherent_group_can_fuse_32members", |b| { + b.iter(|| std::hint::black_box(group.can_fuse(&states).is_ok())); + }); + + // Factorized pose predict at deployment dims. + let head = FactorizedPoseHead::new(152, 128, 2); + let content = vec![0.1f64; 152]; + let full = vec![0.1f64; 128]; + c.bench_function("factorized_pose_predict_d152_d128", |b| { + b.iter(|| std::hint::black_box(head.predict(&content, &full))); + }); +} + fn bench_synth_generation(c: &mut Criterion) { c.bench_function("synth_generate_1room_4windows_3links", |b| { b.iter(|| { @@ -162,6 +287,7 @@ criterion_group!( bench_tokenizer, bench_dft_plan_vs_naive, bench_gaussian_map, + bench_increment2_paths, bench_synth_generation ); criterion_main!(benches); diff --git a/v2/crates/ruview-unified/src/adapters.rs b/v2/crates/ruview-unified/src/adapters.rs index 610eb2e7..39d3a655 100644 --- a/v2/crates/ruview-unified/src/adapters.rs +++ b/v2/crates/ruview-unified/src/adapters.rs @@ -176,22 +176,44 @@ pub fn ble_cs_range(frame: &BleCsFrame) -> Result { frame.phase_samples_rad.len() ))); } + // Boundary validation BEFORE unwrapping. Two DoS classes were found + // here by `tests/security_boundaries.rs` property testing: + // (a) a non-finite phase makes a loop-based unwrap spin forever + // (+inf minus anything stays +inf); + // (b) a *finite but huge* phase (e.g. 1e300 rad) makes a loop-based + // unwrap take O(|Δ|/2π) ≈ 1e299 iterations. + // Defense: reject implausible values (a tone phase is physically + // meaningful mod 2π; |p| > 1e6 rad is garbage), and unwrap in O(1) + // via modular arithmetic instead of a loop. + const MAX_PLAUSIBLE_PHASE_RAD: f64 = 1e6; + if frame + .phase_samples_rad + .iter() + .any(|p| !p.is_finite() || p.abs() > MAX_PLAUSIBLE_PHASE_RAD) + || frame.frequency_steps_hz.iter().any(|f| !f.is_finite()) + { + return Err(UnifiedError::InvalidInput( + "CS frame contains non-finite or implausible phase/frequency samples".into(), + )); + } + if let Some(rtt) = frame.round_trip_time_ns { + if !rtt.is_finite() { + return Err(UnifiedError::InvalidInput("non-finite round-trip time".into())); + } + } let df = frame.frequency_steps_hz[1] - frame.frequency_steps_hz[0]; if !(df.is_finite() && df > 0.0) { return Err(UnifiedError::InvalidInput("frequency steps must ascend uniformly".into())); } - // Unwrap phases across steps, then least-squares slope per Hz. + // O(1) unwrap per step: shift each raw phase by the multiple of 2π + // that lands it within ±π of its predecessor. + let tau = 2.0 * std::f64::consts::PI; let mut unwrapped = Vec::with_capacity(n); let mut prev = frame.phase_samples_rad[0]; unwrapped.push(prev); for &p in &frame.phase_samples_rad[1..] { - let mut v = p; - while v - prev > std::f64::consts::PI { - v -= 2.0 * std::f64::consts::PI; - } - while v - prev < -std::f64::consts::PI { - v += 2.0 * std::f64::consts::PI; - } + let delta = (p - prev + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI; + let v = prev + delta; unwrapped.push(v); prev = v; } diff --git a/v2/crates/ruview-unified/src/gaussian/map.rs b/v2/crates/ruview-unified/src/gaussian/map.rs index c64da11e..7ef1deac 100644 --- a/v2/crates/ruview-unified/src/gaussian/map.rs +++ b/v2/crates/ruview-unified/src/gaussian/map.rs @@ -175,15 +175,107 @@ impl GaussianMap { /// Applies exponential confidence decay up to `now_ns` and prunes below /// [`PRUNE_CONFIDENCE`]. Deterministic: same inputs, same result. + /// + /// **Static persistence** (ADR-275 update-loop step 7): the effective + /// decay constant is stretched by how long the Gaussian has been + /// repeatedly observed — `τ_eff = τ · (1 + ln(1 + lifetime/τ))` with + /// `lifetime = last_seen − first_seen`. A wall confirmed for hours + /// outlives a transient echo seen once, even at equal nominal τ. pub fn decay(&mut self, now_ns: u64) { for g in &mut self.gaussians { let dt_s = (now_ns.saturating_sub(g.timestamp_ns)) as f64 / 1e9; - g.confidence *= (-dt_s / g.decay_tau_s).exp(); + let lifetime_s = (g.timestamp_ns.saturating_sub(g.first_seen_ns)) as f64 / 1e9; + let tau_eff = g.decay_tau_s * (1.0 + (1.0 + lifetime_s / g.decay_tau_s).ln()); + g.confidence *= (-dt_s / tau_eff).exp(); } self.gaussians.retain(|g| g.confidence >= PRUNE_CONFIDENCE); self.rebuild_grid(); } + /// Merge pass (ADR-275 update-loop step 5): collapses pairs whose + /// centres lie inside each other's merge gate *mutually* and whose + /// semantics are compatible (cosine ≥ 0.7, or both unlabeled). The + /// survivor absorbs the partner with the same confidence-weighted rule + /// as [`Self::insert`] fusion. Returns the number of merges performed. + pub fn merge_overlapping(&mut self) -> usize { + let mut merged = 0usize; + let mut removed = vec![false; self.gaussians.len()]; + for i in 0..self.gaussians.len() { + if removed[i] { + continue; + } + for j in (i + 1)..self.gaussians.len() { + if removed[j] { + continue; + } + let (a, b) = (&self.gaussians[i], &self.gaussians[j]); + let mutual = a.mahalanobis_sq(b.position) < MERGE_MAHALANOBIS_SQ + && b.mahalanobis_sq(a.position) < MERGE_MAHALANOBIS_SQ; + if !mutual { + continue; + } + let (na, nb): (f64, f64) = ( + a.semantic.iter().map(|v| f64::from(*v).powi(2)).sum(), + b.semantic.iter().map(|v| f64::from(*v).powi(2)).sum(), + ); + let compatible = if na < 1e-12 && nb < 1e-12 { + true // both unlabeled: pure geometry merge + } else if na < 1e-12 || nb < 1e-12 { + false // one labeled, one not: keep separate + } else { + let dot: f64 = a + .semantic + .iter() + .zip(&b.semantic) + .map(|(x, y)| f64::from(*x) * f64::from(*y)) + .sum(); + dot / (na.sqrt() * nb.sqrt()) >= 0.7 + }; + if !compatible { + continue; + } + // Fuse j into i (same math as insert-fusion). + let partner = self.gaussians[j].clone(); + let e = &mut self.gaussians[i]; + let (wa, wb) = (e.confidence, partner.confidence); + let wsum = (wa + wb).max(1e-12); + for k in 0..3 { + e.position[k] = (wa * e.position[k] + wb * partner.position[k]) / wsum; + e.scale[k] = (wa * e.scale[k] + wb * partner.scale[k]) / wsum; + } + e.occupancy = (wa * e.occupancy + wb * partner.occupancy) / wsum; + for k in 0..SEMANTIC_DIM { + e.semantic[k] = ((f64::from(e.semantic[k]) * wa + + f64::from(partner.semantic[k]) * wb) + / wsum) as f32; + } + e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0); + e.first_seen_ns = e.first_seen_ns.min(partner.first_seen_ns); + e.timestamp_ns = e.timestamp_ns.max(partner.timestamp_ns); + for r in partner.source_receipts { + if !e.source_receipts.contains(&r) + && e.source_receipts.len() < super::primitive::MAX_SOURCE_RECEIPTS + { + e.source_receipts.push(r); + } + } + for link in partner.links { + if !e.links.contains(&link) { + e.links.push(link); + } + } + removed[j] = true; + merged += 1; + } + } + if merged > 0 { + let mut keep = removed.iter().map(|r| !r); + self.gaussians.retain(|_| keep.next().unwrap_or(true)); + self.rebuild_grid(); + } + merged + } + /// Indices of Gaussians whose centres lie within `radius` of `p`, via /// the spatial hash (only the covered cell neighborhood is scanned). #[must_use] @@ -405,6 +497,53 @@ mod tests { assert_eq!(replay.gaussians()[0].confidence, map.gaussians()[0].confidence); } + #[test] + fn long_lived_structure_outlives_transients_at_equal_tau() { + let mut map = GaussianMap::new(1.0); + // A wall confirmed over 30 minutes: first_seen 0, last update at + // t = 1800 s. A transient echo seen once at t = 1800 s. Same τ = 60 s. + let mut wall = g_at([0.0, 0.0, 1.0], 0.9, 1_800_000_000_000); + wall.first_seen_ns = 0; + let transient = g_at([6.0, 0.0, 1.0], 0.9, 1_800_000_000_000); + map.insert(wall); + map.insert(transient); + + // 5 minutes after the last observation (τ = 60 s ⇒ transient decays + // by e^{-5} ≈ 0.0067 < prune floor; the wall's stretched τ_eff keeps it). + map.decay(2_100_000_000_000); + assert_eq!(map.len(), 1, "only the long-lived structure survives"); + assert!((map.gaussians()[0].position[0]).abs() < 1e-9, "survivor is the wall"); + } + + #[test] + fn merge_pass_collapses_mutual_overlaps_but_respects_semantics() { + // Two overlapping unlabeled Gaussians that insert-fusion misses + // because its gate only scans the ±1-cell neighborhood: with a + // 0.05 m cell pitch, 0.40 m spacing is far outside the cell window + // yet well inside the 3σ Mahalanobis merge gate (σ = 0.3). + let mut map2 = GaussianMap::new(0.05); + map2.insert(g_at([1.00, 0.0, 1.0], 0.5, 10)); + map2.insert(g_at([1.40, 0.0, 1.0], 0.5, 20)); + assert_eq!(map2.len(), 2, "insert kept them separate (cell-local gate)"); + let merged = map2.merge_overlapping(); + assert_eq!(merged, 1, "merge pass collapses the mutual overlap"); + assert_eq!(map2.len(), 1); + let g = &map2.gaussians()[0]; + assert!((g.position[0] - 1.20).abs() < 1e-9, "confidence-weighted midpoint"); + assert!((g.confidence - 0.75).abs() < 1e-9, "noisy-OR confidence"); + + // Semantically incompatible pair does NOT merge. + let mut c = g_at([5.0, 0.0, 1.0], 0.5, 30); + c.semantic[0] = 1.0; + let mut d = g_at([5.2, 0.0, 1.0], 0.5, 40); + d.semantic[1] = 1.0; // orthogonal embedding + map2.insert(c); + map2.insert(d); + let before = map2.len(); + assert_eq!(map2.merge_overlapping(), 0, "orthogonal semantics must not merge"); + assert_eq!(map2.len(), before); + } + #[test] fn spatial_hash_query_matches_linear_scan() { let mut map = GaussianMap::new(0.7); diff --git a/v2/crates/ruview-unified/src/gaussian/primitive.rs b/v2/crates/ruview-unified/src/gaussian/primitive.rs index 74c91bc7..2fa448d5 100644 --- a/v2/crates/ruview-unified/src/gaussian/primitive.rs +++ b/v2/crates/ruview-unified/src/gaussian/primitive.rs @@ -151,13 +151,22 @@ impl RfGaussian { return Err(UnifiedError::InvalidInput("non-finite Gaussian field".into())); } } - if scale.iter().any(|s| *s <= 0.0) { - return Err(UnifiedError::InvalidInput(format!("scale must be > 0, got {scale:?}"))); + // Physical plausibility bounds, not just positivity: a subnormal + // scale (e.g. 5e-324) passes `> 0` but overflows `1/σ²` to ∞ and + // turns the density at the Gaussian's own centre into NaN (found + // by `tests/security_boundaries.rs` property testing); a + // kilometre-scale σ is equally meaningless indoors. + const MIN_SCALE_M: f64 = 1e-6; + const MAX_SCALE_M: f64 = 1e4; + if scale.iter().any(|s| !(*s >= MIN_SCALE_M && *s <= MAX_SCALE_M)) { + return Err(UnifiedError::InvalidInput(format!( + "scale must be within [{MIN_SCALE_M}, {MAX_SCALE_M}] m, got {scale:?}" + ))); } let norm = orientation.iter().map(|q| q * q).sum::().sqrt(); - if norm < 1e-9 { - return Err(UnifiedError::InvalidInput("zero quaternion".into())); + if norm < 1e-6 { + return Err(UnifiedError::InvalidInput("degenerate quaternion".into())); } let orientation = [ orientation[0] / norm, @@ -165,8 +174,12 @@ impl RfGaussian { orientation[2] / norm, orientation[3] / norm, ]; - if !(occupancy.is_finite() && occupancy >= 0.0) { - return Err(UnifiedError::InvalidInput(format!("occupancy must be >= 0, got {occupancy}"))); + // Extinction beyond 1e6 nepers/m is physically meaningless and + // only serves to smuggle ∞ into downstream gain products. + if !(occupancy.is_finite() && (0.0..=1e6).contains(&occupancy)) { + return Err(UnifiedError::InvalidInput(format!( + "occupancy must be in [0, 1e6] nepers/m, got {occupancy}" + ))); } if !(0.0..=1.0).contains(&confidence) { return Err(UnifiedError::InvalidInput(format!( diff --git a/v2/crates/ruview-unified/src/tensor.rs b/v2/crates/ruview-unified/src/tensor.rs index 58322c0a..f1af57ee 100644 --- a/v2/crates/ruview-unified/src/tensor.rs +++ b/v2/crates/ruview-unified/src/tensor.rs @@ -220,7 +220,49 @@ impl RfTensor { /// Delay-Doppler-native modalities (OTFS ISAC, radar) must not be /// collapsed into scalar motion energy before storage — this transform /// keeps the native representation queryable locally. + /// + /// Implemented **separably** — delay IDFT per snapshot, then Doppler + /// DFT per delay row: `O(B²S + S²B)` instead of the direct form's + /// `O(B²S²)` (equivalence proven in tests, speedup measured in + /// `benches/unified_bench.rs`). pub fn delay_doppler_map(&self, link: usize) -> crate::Result> { + let (n_links, n_bins, n_snaps) = self.dims(); + if link >= n_links { + return Err(crate::UnifiedError::DimensionMismatch(format!( + "link {link} out of range ({n_links} links)" + ))); + } + // Stage 1: per snapshot, IDFT over bins → delay columns. + let mut delay = ndarray::Array2::from_elem((n_bins, n_snaps), Complex64::new(0.0, 0.0)); + for s in 0..n_snaps { + for d in 0..n_bins { + let mut acc = Complex64::new(0.0, 0.0); + for b in 0..n_bins { + let ang = 2.0 * std::f64::consts::PI * (b * d) as f64 / n_bins as f64; + acc += self.data[[link, b, s]] * Complex64::new(ang.cos(), ang.sin()); + } + delay[[d, s]] = acc / n_bins as f64; + } + } + // Stage 2: per delay row, DFT over snapshots → Doppler. + let mut out = ndarray::Array2::zeros((n_bins, n_snaps)); + for d in 0..n_bins { + for v in 0..n_snaps { + let mut acc = Complex64::new(0.0, 0.0); + for s in 0..n_snaps { + let ang = -2.0 * std::f64::consts::PI * (s * v) as f64 / n_snaps as f64; + acc += delay[[d, s]] * Complex64::new(ang.cos(), ang.sin()); + } + out[[d, v]] = acc.norm() / n_snaps as f64; + } + } + Ok(out) + } + + /// Direct (non-separable) reference implementation of + /// [`Self::delay_doppler_map`] — kept for the equivalence test and the + /// benchmark baseline. + pub fn delay_doppler_map_direct(&self, link: usize) -> crate::Result> { let (n_links, n_bins, n_snaps) = self.dims(); if link >= n_links { return Err(crate::UnifiedError::DimensionMismatch(format!( @@ -305,6 +347,32 @@ mod tests { assert!(t.delay_doppler_map(5).is_err(), "out-of-range link is a typed error"); } + #[test] + fn separable_delay_doppler_matches_direct_form() { + // Random-ish structured tensor: several scatterers + noise floor. + let data = Array3::from_shape_fn((1, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(_, b, s)| { + let a1 = -2.0 * std::f64::consts::PI * (b * 3) as f64 / CANONICAL_BINS as f64 + + 2.0 * std::f64::consts::PI * (s * 2) as f64 / CANONICAL_SNAPSHOTS as f64; + let a2 = -2.0 * std::f64::consts::PI * (b * 11) as f64 / CANONICAL_BINS as f64 + - 2.0 * std::f64::consts::PI * s as f64 / CANONICAL_SNAPSHOTS as f64; + Complex64::new(a1.cos() + 0.4 * a2.cos() + 0.01 * ((b * 7 + s) % 5) as f64, + a1.sin() + 0.4 * a2.sin()) + }); + let t = build(data, vec![link()]).expect("valid tensor"); + let fast = t.delay_doppler_map(0).expect("separable"); + let direct = t.delay_doppler_map_direct(0).expect("direct"); + for d in 0..CANONICAL_BINS { + for v in 0..CANONICAL_SNAPSHOTS { + assert!( + (fast[[d, v]] - direct[[d, v]]).abs() < 1e-10, + "mismatch at ({d},{v}): {} vs {}", + fast[[d, v]], + direct[[d, v]] + ); + } + } + } + #[test] fn rejects_geometry_link_mismatch() { assert!(matches!( diff --git a/v2/crates/ruview-unified/tests/security_boundaries.rs b/v2/crates/ruview-unified/tests/security_boundaries.rs new file mode 100644 index 00000000..54eea7c8 --- /dev/null +++ b/v2/crates/ruview-unified/tests/security_boundaries.rs @@ -0,0 +1,268 @@ +//! Security property tests (ADR-273 pre-merge item 12): the crate's +//! system boundaries must never panic on hostile input and must stay +//! fail-closed under arbitrary authorization states. +//! +//! Strategy: proptest drives the validated constructors and policy gates +//! with arbitrary values (including NaN/±inf smuggled through +//! `f64::from_bits`) and asserts the *contract*, not specific values: +//! every input either yields a valid object or a typed error — never a +//! panic, and never a permissive default. + +use proptest::prelude::*; + +use ruview_unified::adapters::{ble_cs_range, BleCsFrame}; +use ruview_unified::control::{ + admit_task, validate_representation, CoherentSensorGroup, MemberSyncState, PrivacyClass, + SensingTask, SpatialZone, TaskSufficientRepresentation, +}; +use ruview_unified::gaussian::primitive::{Provenance, RfGaussian}; +use ruview_unified::policy::{ + BoundedEvent, EventValue, PolicyEngine, PrivacyZone, SensingPurpose, +}; +use ruview_unified::tensor::{CalibrationMeta, LinkGeometry, RfModality, RfTensor}; + +/// Arbitrary f64 including NaN, ±inf, subnormals — the values an attacker +/// or a broken driver would deliver. +fn any_f64() -> impl Strategy { + any::().prop_map(f64::from_bits) +} + +fn any_purpose() -> impl Strategy { + prop_oneof![ + Just(SensingPurpose::Presence), + Just(SensingPurpose::Activity), + Just(SensingPurpose::Vitals), + Just(SensingPurpose::Localization), + Just(SensingPurpose::PoseTracking), + Just(SensingPurpose::IdentityRecognition), + Just(SensingPurpose::ChannelDiagnostics), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// RfTensor::new never panics; invalid numeric fields are typed errors. + #[test] + fn rf_tensor_constructor_never_panics( + re in any_f64(), + im in any_f64(), + freq in any_f64(), + bw in any_f64(), + age in any_f64(), + clock in any_f64(), + unc in any_f64(), + tx in prop::array::uniform3(any_f64()), + ) { + let data = ndarray::Array3::from_elem((1, 4, 2), num_complex::Complex64::new(re, im)); + let links = vec![LinkGeometry { tx_pos: tx, rx_pos: [1.0, 0.0, 1.0] }]; + let result = RfTensor::new( + RfModality::WifiCsi, freq, bw, data, links, age, 0, "prop".into(), + clock, unc, CalibrationMeta::default(), + ); + // Contract: Ok only when every validated field is actually valid. + if let Ok(t) = result { + prop_assert!(t.center_freq_hz.is_finite() && t.center_freq_hz > 0.0); + prop_assert!((0.0..=1.0).contains(&t.clock_quality)); + prop_assert!((0.0..=1.0).contains(&t.uncertainty)); + prop_assert!(t.sample_age_s.is_finite() && t.sample_age_s >= 0.0); + prop_assert!(t.data.iter().all(|z| z.re.is_finite() && z.im.is_finite())); + } + } + + /// RfGaussian::new never panics; accepted Gaussians have a normalized + /// quaternion and in-range trust fields. + #[test] + fn rf_gaussian_constructor_never_panics( + pos in prop::array::uniform3(any_f64()), + scale in prop::array::uniform3(any_f64()), + quat in prop::array::uniform4(any_f64()), + occ in any_f64(), + conf in any_f64(), + tau in any_f64(), + ) { + let result = RfGaussian::new( + pos, scale, quat, occ, conf, 0, tau, + Provenance { device_id: "prop".into(), model_version: 1, synthetic: true }, + ); + if let Ok(g) = result { + let qn: f64 = g.orientation.iter().map(|q| q * q).sum::().sqrt(); + prop_assert!((qn - 1.0).abs() < 1e-9, "quaternion must be normalized"); + prop_assert!(g.scale.iter().all(|s| *s > 0.0)); + prop_assert!(g.occupancy >= 0.0); + prop_assert!((0.0..=1.0).contains(&g.confidence)); + // Density at the centre of a valid Gaussian is exactly 1. + prop_assert!((g.density_at(g.position) - 1.0).abs() < 1e-9); + } + } + + /// BoundedEvent::new never panics; exported accountability fields are + /// always in range. + #[test] + fn bounded_event_constructor_never_panics( + uncertainty in any_f64(), + model_version in any::(), + value in any_f64(), + ) { + let result = BoundedEvent::new( + SensingPurpose::Presence, + EventValue::RespirationBpm(value), + uncertainty, + Provenance { device_id: "prop".into(), model_version: 1, synthetic: false }, + model_version, + 0, + "zone", + ); + if let Ok(e) = result { + prop_assert!((0.0..=1.0).contains(&e.uncertainty)); + prop_assert!(e.model_version != 0, "unassigned model version must never export"); + } + } + + /// ble_cs_range never panics on arbitrary phases/frequencies/RTT, and + /// any Ok evidence has a finite, non-negative distance. + #[test] + fn ble_cs_range_never_panics( + phases in prop::collection::vec(any_f64(), 0..24), + f0 in any_f64(), + df in any_f64(), + rtt in prop::option::of(any_f64()), + ) { + let n = phases.len(); + let frame = BleCsFrame { + frequency_steps_hz: (0..n).map(|k| f0 + df * k as f64).collect(), + phase_samples_rad: phases, + round_trip_time_ns: rtt, + }; + if let Ok(ev) = ble_cs_range(&frame) { + // Non-finite inputs are rejected at the boundary (the unwrap + // loop would otherwise spin forever on +inf — the DoS this + // suite originally caught), so Ok evidence is fully finite. + prop_assert!(ev.phase_distance_m.is_finite() && ev.phase_distance_m >= 0.0); + prop_assert!((0.0..=1.0).contains(&ev.confidence)); + if let Some(d) = ev.rtt_distance_m { + prop_assert!(d.is_finite()); + } + } + } + + /// The policy engine is fail-closed for every purpose against every + /// zone configuration that does not explicitly grant it. + #[test] + fn policy_engine_is_fail_closed_under_arbitrary_grants( + purpose in any_purpose(), + granted in prop::collection::btree_set(any_purpose(), 0..7), + identity_enabled in any::(), + query_unknown_zone in any::(), + ) { + let mut engine = PolicyEngine::new(); + engine.upsert_zone(PrivacyZone { + id: "z".into(), + allowed_purposes: granted.clone(), + retention_s: 60, + identity_explicitly_enabled: identity_enabled, + }); + let zone_id = if query_unknown_zone { "nope" } else { "z" }; + let verdict = engine.authorize(zone_id, purpose); + if query_unknown_zone { + prop_assert!(verdict.is_err(), "unknown zone must always deny"); + } else if !granted.contains(&purpose) { + prop_assert!(verdict.is_err(), "ungranted purpose must deny"); + } else if purpose == SensingPurpose::IdentityRecognition && !identity_enabled { + prop_assert!(verdict.is_err(), "identity single-gate must deny"); + } else { + prop_assert!(verdict.is_ok()); + } + } + + /// Task admission can never approve raw export, whatever else is true. + #[test] + fn raw_export_is_unreachable( + purpose in any_purpose(), + raw in any::(), + confidence in any_f64(), + ) { + let mut engine = PolicyEngine::new(); + engine.upsert_zone(PrivacyZone { + id: "z".into(), + allowed_purposes: [purpose].into_iter().collect(), + retention_s: 60, + identity_explicitly_enabled: true, + }); + let task = SensingTask { + task_id: 1, + purpose, + target_area: SpatialZone { id: "z".into(), min_m: [0.0; 3], max_m: [1.0; 3] }, + modalities: vec![RfModality::WifiCsi], + requested_resolution_m: 0.5, + maximum_latency_ms: 100, + minimum_confidence: confidence, + raw_retention_seconds: 60, + result_retention_seconds: 60, + authorized_consumers: vec![], + consent_reference: Some("consent-1".into()), + raw_export_allowed: raw, + }; + let verdict = admit_task(&engine, &task); + if raw { + prop_assert!(verdict.is_err(), "raw export must be structurally unreachable"); + } + } + + /// Coherent fusion denies whenever any reported error is non-finite or + /// out of bounds — NaN cannot sneak past the gate. + #[test] + fn coherent_fusion_rejects_non_finite_sync_state( + time_err in any_f64(), + phase_err in any_f64(), + hash in any::(), + ) { + let group = CoherentSensorGroup { + group_id: "g".into(), + members: vec!["m".into()], + maximum_time_error_ns: 50.0, + maximum_phase_error_rad: 0.2, + baseline_geometry_hash: 7, + }; + let verdict = group.can_fuse(&[MemberSyncState { + member_id: "m".into(), + time_error_ns: time_err, + phase_error_rad: phase_err, + geometry_hash: hash, + }]); + let in_bounds = time_err.is_finite() + && time_err.abs() <= 50.0 + && phase_err.is_finite() + && phase_err.abs() <= 0.2 + && hash == 7; + prop_assert_eq!(verdict.is_ok(), in_bounds, "NaN/inf must deny, bounds must bind"); + } + + /// Representation validation never approves an identity-retaining + /// occupancy representation, whatever the other fields say. + #[test] + fn occupancy_representations_must_exclude_identity( + class in prop_oneof![ + Just(PrivacyClass::P0), Just(PrivacyClass::P1), Just(PrivacyClass::P2), + Just(PrivacyClass::P3), Just(PrivacyClass::P4), Just(PrivacyClass::P5) + ], + excluded in prop::collection::vec("[a-z]{1,10}", 0..4), + bits in any_f64(), + ) { + let rep = TaskSufficientRepresentation { + task_id: 1, + source_receipts: vec![1], + semantic_state: vec![0.5], + information_bound_bits: bits, + excluded_information: excluded.clone(), + privacy_class: class, + }; + let verdict = validate_representation(&rep, SensingPurpose::Presence); + let excludes_required = excluded.iter().any(|e| e == "identity") + && excluded.iter().any(|e| e == "vitals"); + if verdict.is_ok() { + prop_assert!(excludes_required, "approved rep must exclude identity+vitals"); + prop_assert!(class <= PrivacyClass::P2, "approved rep must respect the ceiling"); + } + } +}