mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user