feat(ruview-unified): complete Gaussian update loop, separable delay-Doppler, property-tested boundary hardening

Third increment: closes the remaining implementable ADR-275 update-loop
steps, optimizes the delay-Doppler transform, and hardens every boundary
surface with property testing that found and fixed three real
input-controlled defects.

- ADR-275 update loop: GaussianMap::merge_overlapping (step 5 — mutual
  Mahalanobis + semantic-compatibility dedup catching drift the
  insert-time ±1-cell gate misses; orthogonal semantics stay separate)
  and lifetime-aware decay (step 7 — tau_eff = tau*(1+ln(1+lifetime/tau))
  so confirmed structures outlive transients at equal nominal tau).
- Separable delay-Doppler (ADR-281): O(B^2*S + S^2*B) instead of
  O(B^2*S^2), proven equivalent to the direct reference to <1e-10 and
  measured 8.3x faster (520us vs 4.34ms at 56x8). Direct form kept as
  the benchmark baseline + equivalence oracle.
- Security property tests (tests/security_boundaries.rs, 8 proptest
  properties over arbitrary values incl. NaN/inf via f64::from_bits).
  Found and fixed:
  * ble_cs_range unwrap infinite loop on non-finite phase (+inf) and
    ~1e299-iteration loop on finite-huge phase -> O(1) modular unwrap +
    plausibility bound (|phase| <= 1e6 rad);
  * subnormal Gaussian scale (5e-324) overflowing 1/sigma^2 to NaN
    density -> physical bounds (sigma in [1e-6, 1e4] m, occupancy in
    [0, 1e6] nepers/m).
  Properties proven: tensor/Gaussian/BoundedEvent constructors never
  panic; policy engine fail-closed for every (purpose,grants,zone);
  raw export structurally unreachable; coherent fusion rejects every
  non-finite/out-of-bounds sync state; occupancy reps never retain
  identity.
- New criterion benches for all increment-2/3 hot paths (to_canonical
  38us, ble_cs_range 481ns, AoI planner 647ns/200 regions, coherent
  fusion 1.5us/32 members, factorized pose 521ns, delay-Doppler
  separable vs direct).

Validation: ruview-unified 98 tests (87 lib + 3 acceptance + 8
security), 0 failed, clippy-clean; Python proof VERDICT PASS. Witness
bundle regeneration still blocked on the desktop/Tauri crate's GTK dev
headers (unavailable in this container) — pre-existing environment
limitation, flagged for the release owner.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX
This commit is contained in:
Claude
2026-07-26 20:29:58 +00:00
parent 9aae7f04ff
commit 42485495ed
11 changed files with 665 additions and 17 deletions
+1
View File
@@ -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
+1
View File
@@ -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 L0L5 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) BeerLambert 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 — AllenBerkley 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.
@@ -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
@@ -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`).
@@ -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)
@@ -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<f64> = (0..40).map(|k| 2.402e9 + 1e6 * k as f64).collect();
let phases: Vec<f64> = 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<MemberSyncState> = (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);
+30 -8
View File
@@ -176,22 +176,44 @@ pub fn ble_cs_range(frame: &BleCsFrame) -> Result<RangingEvidence> {
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;
}
+140 -1
View File
@@ -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);
@@ -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::<f64>().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!(
+68
View File
@@ -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<ndarray::Array2<f64>> {
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<ndarray::Array2<f64>> {
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!(
@@ -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<Value = f64> {
any::<u64>().prop_map(f64::from_bits)
}
fn any_purpose() -> impl Strategy<Value = SensingPurpose> {
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::<f64>().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::<u32>(),
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::<bool>(),
query_unknown_zone in any::<bool>(),
) {
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::<bool>(),
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::<u64>(),
) {
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");
}
}
}