feat(ruview-unified): Unified RF spatial world model — ADR-273..282 (#1437)

Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program.

Deep-reviewed and hardware-tested against a live ESP32-C6 CSI node before merge: fixed a reachable panic, a silent NaN-corruption path, a cross-entity Gaussian conflation bug, and a wrong-center-frequency bug in the WiFi adapter (confirmed live: was misreporting channel 4 as 2437 MHz, now correctly reports 2427 MHz matching the hardware parser exactly). Added a standing hardware-in-the-loop test (examples/esp32_live_hardware_test.rs). Also fixed unrelated pre-existing issues surfaced during validation (wifi-densepose-core clippy warnings, a ruview-auth Windows build break, a sensing-server test flake).

Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
This commit is contained in:
rUv
2026-07-26 14:37:56 -07:00
committed by GitHub
parent f783df234e
commit 2e018f4f19
49 changed files with 9985 additions and 60 deletions
Generated
+16
View File
@@ -7675,6 +7675,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "ruview-unified"
version = "0.3.0"
dependencies = [
"criterion",
"ndarray 0.17.2",
"num-complex",
"proptest",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"thiserror 2.0.18",
"wifi-densepose-core",
"wifi-densepose-hardware",
]
[[package]]
name = "ryu"
version = "1.0.23"
+5
View File
@@ -78,6 +78,11 @@ members = [
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
"crates/ruview-swarm", # ADR-148 — drone swarm control system
# ADR-273..277 — unified RF spatial world model: canonical RF tensor +
# hardware adapters, universal foundation encoder (masked-reconstruction
# pretraining, ≤1% task adapters), RF-aware Gaussian spatial memory,
# physics-guided synthetic RF worlds, edge sensing control plane.
"crates/ruview-unified",
# ADR-262 P1 — anti-corruption bridge converting RuView WiFi-CSI sensing
# output into signed RuField FieldEvents. Path-deps the `vendor/rufield`
# submodule crates (rufield-core/-provenance/-privacy/-fusion); single
+1
View File
@@ -588,6 +588,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_temp_file_is_never_world_readable_even_for_an_instant() {
// The test above checks the FINAL file. It passed while `save` wrote via
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "ruview-unified"
description = "Unified RF spatial world model (ADR-273): canonical RF tensor + hardware adapters, universal RF foundation encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, and the edge sensing control plane"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
documentation.workspace = true
keywords = ["wifi", "csi", "rf-sensing", "gaussian-splatting", "world-model"]
categories = ["science", "simulation"]
# `ruview-unified` is deliberately a *thin-dependency* crate: pure-Rust math,
# deterministic ChaCha20 randomness (nvsim pattern — same seed ⇒ byte-identical
# output on every machine), and a single internal dependency on
# `wifi-densepose-core` so the WiFi adapter consumes the real `CsiFrame`
# boundary type instead of a parallel invention. No GPU, no ONNX, no tokio.
[dependencies]
wifi-densepose-core = { workspace = true }
ndarray = { workspace = true }
num-complex = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true, features = ["derive"] }
# Deterministic PRNG for domain randomization + weight init (see nvsim §Pass 4
# for the rationale: default features off drops the getrandom OS-entropy path,
# keeping the crate WASM-ready and reproducible).
rand = { version = "0.8", default-features = false }
rand_chacha = { version = "0.3", default-features = false }
[dev-dependencies]
criterion = { workspace = true }
proptest = { workspace = true }
# Dev-only: bridges real ESP32 ADR-018 UDP captures (wifi-densepose-hardware's
# already-proven Esp32CsiParser) into wifi_densepose_core::CsiFrame for the
# `esp32_live_hardware_test` example. Does not affect the published dependency
# graph — the crate's real dependency stays thin-dependency (see above).
wifi-densepose-hardware = { workspace = true }
[[bench]]
name = "unified_bench"
harness = false
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
all = "warn"
@@ -0,0 +1,293 @@
//! Criterion benchmarks for the unified RF spatial world model hot paths
//! (ADR-273 §7): encoder inference (the edge-latency budget), tokenization
//! (with vs without precomputed DFT twiddles), Gaussian map queries (spatial
//! hash vs linear-scan baseline), channel-gain evaluation, and synthetic
//! window generation.
#![allow(missing_docs)]
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use ruview_unified::encoder::{EncoderConfig, RfEncoder};
use ruview_unified::gaussian::map::GaussianMap;
use ruview_unified::gaussian::primitive::{Provenance, RfGaussian};
use ruview_unified::gaussian::{channel_gain, observe_link};
use ruview_unified::math::{dft_magnitudes, DftPlan};
use ruview_unified::synth::{SynthConfig, SynthGenerator};
use ruview_unified::tokenizer::RfTokenizer;
fn synth_corpus() -> Vec<ruview_unified::synth::LabeledWindow> {
SynthGenerator::new(SynthConfig {
seed: 11,
n_rooms: 2,
windows_per_room: 4,
links: 3,
snapshot_dt_s: 0.05,
})
.generate()
}
fn bench_encoder_forward(c: &mut Criterion) {
let corpus = synth_corpus();
let tokenizer = RfTokenizer::new();
let window = tokenizer.tokenize(&corpus[0].tensor);
let encoder = RfEncoder::new(EncoderConfig::default(), 42);
c.bench_function("encoder_encode_window_21tok_d128", |b| {
b.iter(|| std::hint::black_box(encoder.encode(&window)));
});
}
fn bench_tokenizer(c: &mut Criterion) {
let corpus = synth_corpus();
let tokenizer = RfTokenizer::new();
c.bench_function("tokenize_3link_56bin_8snap", |b| {
b.iter(|| std::hint::black_box(tokenizer.tokenize(&corpus[0].tensor)));
});
}
fn bench_dft_plan_vs_naive(c: &mut Criterion) {
use num_complex::Complex64;
let x: Vec<Complex64> =
(0..8).map(|i| Complex64::new((i as f64).sin(), (i as f64).cos())).collect();
let plan = DftPlan::new(8, 4);
c.bench_function("dft8x4_naive", |b| {
b.iter(|| std::hint::black_box(dft_magnitudes(&x, 4)));
});
c.bench_function("dft8x4_planned", |b| {
b.iter(|| std::hint::black_box(plan.magnitudes(&x)));
});
}
fn populated_map(n_side: usize) -> GaussianMap {
let mut map = GaussianMap::new(1.0);
for x in 0..n_side {
for y in 0..n_side {
let g = RfGaussian::new(
[x as f64 * 1.5, y as f64 * 1.5, 1.0],
[0.3, 0.3, 0.3],
[1.0, 0.0, 0.0, 0.0],
0.4,
0.9,
0,
600.0,
Provenance { device_id: "bench".into(), model_version: 1, synthetic: true },
)
.expect("valid");
map.insert(g);
}
}
map
}
fn bench_gaussian_map(c: &mut Criterion) {
// Two sizes to show the hash/linear crossover honestly: at 1,024
// Gaussians a brute-force scan is competitive for small-radius queries;
// at 16,384 the hash wins decisively.
for (label, side) in [("1k", 32usize), ("16k", 128)] {
let map = populated_map(side);
let centre = [side as f64 * 0.75, side as f64 * 0.75, 1.0];
c.bench_function(&format!("map{label}_query_radius_hash"), |b| {
b.iter(|| std::hint::black_box(map.query_radius(centre, 3.0)));
});
c.bench_function(&format!("map{label}_query_radius_linear"), |b| {
b.iter(|| std::hint::black_box(map.query_radius_linear(centre, 3.0)));
});
c.bench_function(&format!("map{label}_segment_corridor_hash"), |b| {
b.iter(|| {
std::hint::black_box(map.query_near_segment(
[0.0, 0.0, 1.0],
[10.0, 10.0, 1.0],
3.0,
))
});
});
c.bench_function(&format!("map{label}_segment_corridor_linear"), |b| {
b.iter(|| {
std::hint::black_box(map.query_near_segment_linear(
[0.0, 0.0, 1.0],
[10.0, 10.0, 1.0],
3.0,
))
});
});
c.bench_function(&format!("map{label}_channel_gain"), |b| {
b.iter(|| {
std::hint::black_box(channel_gain(
&map,
[0.0, 0.0, 1.0],
[10.0, 10.0, 1.0],
2.437e9,
))
});
});
}
c.bench_function("map_observe_link_inverse_update", |b| {
b.iter_batched(
|| populated_map(8),
|mut m| {
std::hint::black_box(observe_link(
&mut m,
[0.0, 0.0, 1.0],
[10.0, 10.0, 1.0],
2.437e9,
1e-4,
0.7,
1,
))
},
BatchSize::SmallInput,
);
});
}
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(|| {
std::hint::black_box(
SynthGenerator::new(SynthConfig {
seed: 3,
n_rooms: 1,
windows_per_room: 4,
links: 3,
snapshot_dt_s: 0.05,
})
.generate(),
)
});
});
}
criterion_group!(
benches,
bench_encoder_forward,
bench_tokenizer,
bench_dft_plan_vs_naive,
bench_gaussian_map,
bench_increment2_paths,
bench_synth_generation
);
criterion_main!(benches);
@@ -0,0 +1,155 @@
//! Hardware-in-the-loop test: feeds REAL ADR-018 CSI frames from a live
//! ESP32 node through `WifiCsiAdapter`, not synthetic data.
//!
//! The PR that introduced this crate is explicit that every reported number
//! is generator-produced (L0) and real-data validation (P2) is future work.
//! This example closes part of that gap for the one adapter that matters
//! most: it binds the real ADR-018 UDP port, parses live packets with the
//! already-proven `wifi_densepose_hardware::Esp32CsiParser` (the same parser
//! `aggregator`/`sensing-server` use in production), converts each frame into
//! the exact `wifi_densepose_core::types::CsiFrame` the adapter expects, and
//! runs it through `AdapterRegistry::normalize`.
//!
//! Usage: `cargo run -p ruview-unified --example esp32_live_hardware_test --
//! --bind 0.0.0.0:5005 --frames 8`
//! (point a live ESP32 CSI node's UDP target at this host's IP:5005 first).
use std::net::UdpSocket;
use std::time::{SystemTime, UNIX_EPOCH};
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame as CoreCsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_hardware::{Esp32CsiParser, ParseError};
use ruview_unified::adapters::{AdapterRegistry, RawCapture};
use ruview_unified::tensor::LinkGeometry;
/// Inverse of `ruview_unified::adapters`'s (now-fixed) channel->frequency
/// map: recovers the 802.11 channel number from the real per-frame
/// `channel_freq_mhz` the hardware parser already computes correctly. Used
/// here only to populate `CsiMetadata::channel` for the adapter under test —
/// exercising the fix end-to-end against a real, independently-computed
/// frequency instead of a value this same test invented.
fn freq_mhz_to_band_and_channel(freq_mhz: u32) -> (FrequencyBand, u8) {
if freq_mhz == 2484 {
(FrequencyBand::Band2_4GHz, 14)
} else if (2412..=2472).contains(&freq_mhz) {
(FrequencyBand::Band2_4GHz, ((freq_mhz - 2407) / 5) as u8)
} else if (5000..6000).contains(&freq_mhz) {
(FrequencyBand::Band5GHz, ((freq_mhz - 5000) / 5) as u8)
} else {
(FrequencyBand::Band6GHz, ((freq_mhz.saturating_sub(5950)) / 5) as u8)
}
}
fn to_core_frame(hw: wifi_densepose_hardware::CsiFrame) -> CoreCsiFrame {
let (band, channel) = freq_mhz_to_band_and_channel(hw.metadata.channel_freq_mhz);
let mut meta = CsiMetadata::new(DeviceId::new(format!("esp32-node-{}", hw.metadata.node_id)), band, channel);
meta.bandwidth_mhz = match hw.metadata.bandwidth {
wifi_densepose_hardware::Bandwidth::Bw20 => 20,
wifi_densepose_hardware::Bandwidth::Bw40 => 40,
wifi_densepose_hardware::Bandwidth::Bw80 => 80,
wifi_densepose_hardware::Bandwidth::Bw160 => 160,
};
meta.antenna_config = AntennaConfig::new(1, hw.metadata.n_antennas.max(1));
meta.rssi_dbm = hw.metadata.rssi_dbm;
meta.noise_floor_dbm = hw.metadata.noise_floor_dbm;
meta.sequence_number = hw.metadata.sequence;
// Single spatial stream (n_antennas isn't broken out per-antenna in the
// ADR-018 wire format consumed here) x real subcarrier count.
let n_bins = hw.subcarriers.len().max(1);
let data = Array2::from_shape_fn((1, n_bins), |(_, b)| {
hw.subcarriers.get(b).map_or(Complex64::new(0.0, 0.0), |sc| {
Complex64::new(f64::from(sc.i), f64::from(sc.q))
})
});
CoreCsiFrame::new(meta, data)
}
fn main() {
let bind = std::env::args()
.collect::<Vec<_>>()
.windows(2)
.find(|w| w[0] == "--bind")
.map_or_else(|| "0.0.0.0:5005".to_string(), |w| w[1].clone());
let want_frames: usize = std::env::args()
.collect::<Vec<_>>()
.windows(2)
.find(|w| w[0] == "--frames")
.and_then(|w| w[1].parse().ok())
.unwrap_or(8);
let socket = UdpSocket::bind(&bind).expect("bind UDP socket");
socket.set_read_timeout(Some(std::time::Duration::from_secs(30))).unwrap();
eprintln!("Listening on {bind} for real ESP32 ADR-018 CSI frames (need {want_frames})...");
let mut buf = [0u8; 2048];
let mut core_frames: Vec<CoreCsiFrame> = Vec::new();
let mut real_channel_freq_hz: Option<f64> = None;
while core_frames.len() < want_frames {
let (n, _src) = socket.recv_from(&mut buf).expect("recv (timed out — is a live node targeting this host?)");
match Esp32CsiParser::parse_frame(&buf[..n]) {
Ok((hw_frame, _consumed)) => {
// Lock onto the first node/shape seen so all frames in the
// window share (n_links, n_bins), as the adapter requires.
if let Some(first) = core_frames.first() {
let n_bins = hw_frame.subcarriers.len();
if n_bins != first.num_subcarriers() {
eprintln!(" [skip: shape changed mid-window ({n_bins} vs {})]", first.num_subcarriers());
continue;
}
}
if real_channel_freq_hz.is_none() {
real_channel_freq_hz = Some(f64::from(hw_frame.metadata.channel_freq_mhz) * 1e6);
}
eprintln!(
" [captured frame {}: sc={} rssi={} node={}]",
core_frames.len() + 1,
hw_frame.subcarriers.len(),
hw_frame.metadata.rssi_dbm,
hw_frame.metadata.node_id,
);
core_frames.push(to_core_frame(hw_frame));
}
Err(ParseError::NonCsiPacket { .. }) => {}
Err(e) => eprintln!(" [parse error: {e}]"),
}
}
let raw = RawCapture::WifiCsi {
frames: core_frames,
links: vec![LinkGeometry { tx_pos: [0.0, 0.0, 1.0], rx_pos: [3.0, 0.0, 1.0] }],
age_s: 0.05,
clock_quality: 0.5, // free-running ESP32 crystal, not disciplined — honest, not 1.0
};
let registry = AdapterRegistry::with_reference_adapters();
let now_ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
match registry.normalize("esp32s3-csi", &raw) {
Ok(tensor) => {
let real_hz = real_channel_freq_hz.unwrap();
println!("=== RfTensor from REAL ESP32 hardware (not synthetic) ===");
println!("dims (links, bins, snapshots): {:?}", tensor.data.dim());
println!("adapter-computed center_freq_hz : {:.0}", tensor.center_freq_hz);
println!("real per-frame channel_freq_hz : {:.0} (from hardware parser)", real_hz);
println!(
"match within 1 MHz: {} (this is the ADR-018 channel-aware fix — the pre-fix \
code always reported the fixed-band constant, 2437000000 Hz for 2.4 GHz, \
regardless of the real channel)",
(tensor.center_freq_hz - real_hz).abs() < 1e6
);
println!("bandwidth_hz: {:.0}", tensor.bandwidth_hz);
println!("uncertainty (from real SNR): {:.3}", tensor.uncertainty);
println!("device_id: {}", tensor.device_id);
println!("timestamp_ns (now, for reference): {now_ns}");
println!("No panic on real hardware data, including subcarrier counts != CANONICAL_BINS=56.");
}
Err(e) => {
println!("ADAPTER REJECTED real hardware data: {e}");
std::process::exit(1);
}
}
}
+978
View File
@@ -0,0 +1,978 @@
//! Hardware adapters — vendor formats in, canonical [`RfTensor`] out
//! (ADR-274 §2.3).
//!
//! Each adapter performs the same three-stage normalization so every
//! downstream consumer sees hardware-invariant data:
//!
//! 1. **Layout**: reshape the vendor capture to `(links, bins, snapshots)`
//! (for FMCW radar this includes the fast-time DFT to range bins), then
//! resample both the bin and snapshot axes to
//! [`CANONICAL_BINS`] × [`CANONICAL_SNAPSHOTS`].
//! 2. **Amplitude**: divide each link by its median amplitude, removing
//! front-end gain differences between chipsets (the offset is recorded in
//! [`CalibrationMeta::gain_offset_db`] for provenance).
//! 3. **Phase**: per (link, snapshot), remove the constant phase offset (CFO
//! residual) and the linear ramp across bins (sampling-time offset) via
//! least squares — unless the capture declares itself phase-calibrated.
use std::collections::HashMap;
use ndarray::{Array3, Axis};
use num_complex::Complex64;
use wifi_densepose_core::types::{CsiFrame, FrequencyBand};
use crate::math::{linear_slope, median, resample_complex};
use crate::tensor::{
CalibrationMeta, LinkGeometry, RfModality, RfTensor, CANONICAL_BINS, CANONICAL_SNAPSHOTS,
};
use crate::{Result, UnifiedError};
/// A raw capture from some hardware front-end, before normalization.
#[derive(Debug, Clone)]
pub enum RawCapture {
/// 802.11 CSI: one [`CsiFrame`] per temporal snapshot (all frames must
/// share the spatial-stream count) plus per-stream geometry.
WifiCsi {
/// Snapshots, oldest first.
frames: Vec<CsiFrame>,
/// One entry per spatial stream (link).
links: Vec<LinkGeometry>,
/// Age of the oldest snapshot at capture handoff, seconds.
age_s: f64,
/// Clock quality in `[0, 1]`.
clock_quality: f64,
},
/// FMCW radar cube `(rx_channels, fast_time_samples, chirps)` before the
/// range FFT, plus chirp parameters.
FmcwRadarCube {
/// Raw ADC cube.
cube: Array3<Complex64>,
/// Per-RX-channel geometry.
links: Vec<LinkGeometry>,
/// Carrier centre frequency, Hz.
center_freq_hz: f64,
/// Sweep bandwidth, Hz.
bandwidth_hz: f64,
/// Age in seconds.
age_s: f64,
/// Device identifier.
device_id: String,
},
/// UWB channel impulse response `(links, taps, repeats)`.
UwbCir {
/// CIR taps.
taps: Array3<Complex64>,
/// Per-link geometry.
links: Vec<LinkGeometry>,
/// Carrier centre frequency, Hz.
center_freq_hz: f64,
/// Bandwidth, Hz.
bandwidth_hz: f64,
/// Age in seconds.
age_s: f64,
/// Device identifier.
device_id: String,
},
/// Bluetooth 6.0 Channel Sounding capture: per-frequency-step phase
/// samples plus optional round-trip timing (ADR-281 §2). Phase-based
/// ranging and RTT are **separate evidence sources** — see
/// [`ble_cs_range`] for the agreement/divergence contract.
BleCs {
/// The channel-sounding frame.
frame: BleCsFrame,
/// Initiator→reflector link geometry (single link).
links: Vec<LinkGeometry>,
/// Age in seconds.
age_s: f64,
/// Device identifier.
device_id: String,
},
/// 5G NR uplink SRS frequency response `(links, comb_res, symbols)` with
/// a comb factor (only every `comb`-th subcarrier is sounded).
CellularSrs {
/// Comb-sampled frequency response.
comb_res: Array3<Complex64>,
/// SRS comb factor (2 or 4).
comb: usize,
/// Per-link geometry.
links: Vec<LinkGeometry>,
/// Carrier centre frequency, Hz.
center_freq_hz: f64,
/// Sounded bandwidth, Hz.
bandwidth_hz: f64,
/// Age in seconds.
age_s: f64,
/// Device identifier.
device_id: String,
},
}
impl RawCapture {
/// Modality this capture belongs to.
#[must_use]
pub fn modality(&self) -> RfModality {
match self {
Self::WifiCsi { .. } => RfModality::WifiCsi,
Self::FmcwRadarCube { .. } => RfModality::FmcwRadar,
Self::UwbCir { .. } => RfModality::UwbCir,
Self::BleCs { .. } => RfModality::BleCs,
Self::CellularSrs { .. } => RfModality::CellularSrs,
}
}
}
/// Bluetooth Channel Sounding frame (ADR-281 §2): tone phases across
/// frequency steps plus optional round-trip timing.
#[derive(Debug, Clone)]
pub struct BleCsFrame {
/// Sounded frequencies, Hz (uniformly spaced, ascending).
pub frequency_steps_hz: Vec<f64>,
/// Measured round-trip tone phase at each step, radians (wrapped).
pub phase_samples_rad: Vec<f64>,
/// Round-trip time measurement, ns, when the mode includes RTT.
pub round_trip_time_ns: Option<f64>,
}
/// Anomaly classes when the two CS evidence sources disagree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangingAnomaly {
/// Phase-based and RTT distances diverge beyond tolerance — multipath
/// bias, a relay attack, a timing fault, or a calibration problem.
Divergent,
}
/// Distance evidence from one CS exchange. Phase-based ranging and RTT
/// are deliberately separate: agreement raises confidence, disagreement
/// is surfaced as an anomaly instead of silently averaged away.
#[derive(Debug, Clone)]
pub struct RangingEvidence {
/// Distance from the unwrapped phase-vs-frequency slope, metres.
pub phase_distance_m: f64,
/// Distance from round-trip timing, metres (when measured).
pub rtt_distance_m: Option<f64>,
/// Whether the two sources agree within tolerance.
pub agreement: bool,
/// Confidence in `[0, 1]` (decays with divergence).
pub confidence: f64,
/// Set when the sources diverge.
pub anomaly: Option<RangingAnomaly>,
}
/// Agreement tolerance between phase and RTT distances, metres.
const CS_AGREEMENT_TOLERANCE_M: f64 = 0.5;
/// Extracts ranging evidence from a CS frame.
///
/// Physics: the round-trip tone phase is `θ(f) = 4π·f·d/c (mod 2π)`, so
/// the unwrapped slope gives `d = |dθ/df|·c/(4π)`. RTT gives
/// `d = rtt·c/2` independently. Cross-validation is the security value:
/// a relay attack that defeats one mechanism generally cannot fake both
/// consistently.
pub fn ble_cs_range(frame: &BleCsFrame) -> Result<RangingEvidence> {
let n = frame.frequency_steps_hz.len();
if n < 2 || frame.phase_samples_rad.len() != n {
return Err(UnifiedError::ShapeMismatch(format!(
"CS frame needs >= 2 steps with matching phases, got {n} steps / {} phases",
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()));
}
// 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 delta = (p - prev + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI;
let v = prev + delta;
unwrapped.push(v);
prev = v;
}
let slope_per_step = crate::math::linear_slope(&unwrapped);
let c = 299_792_458.0;
let phase_distance_m = (slope_per_step / df).abs() * c / (4.0 * std::f64::consts::PI);
let rtt_distance_m = frame.round_trip_time_ns.map(|rtt| rtt * 1e-9 * c / 2.0);
let (agreement, confidence, anomaly) = match rtt_distance_m {
None => (true, 0.5, None), // single-source evidence: capped confidence
Some(d_rtt) => {
let divergence = (phase_distance_m - d_rtt).abs();
if divergence <= CS_AGREEMENT_TOLERANCE_M {
(true, (1.0 - divergence / CS_AGREEMENT_TOLERANCE_M).mul_add(0.5, 0.5), None)
} else {
(false, (-divergence).exp().min(0.2), Some(RangingAnomaly::Divergent))
}
}
};
Ok(RangingEvidence { phase_distance_m, rtt_distance_m, agreement, confidence, anomaly })
}
/// A hardware adapter: normalizes one family of raw captures into the
/// canonical tensor. Object-safe so the registry can hold heterogeneous
/// adapters behind one interface.
pub trait RfAdapter: Send + Sync {
/// Modality this adapter accepts.
fn modality(&self) -> RfModality;
/// Stable hardware identifier, e.g. `"esp32s3-csi"`, `"iwl5300"`.
fn hardware_id(&self) -> &str;
/// Normalize a raw capture into the canonical tensor.
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor>;
}
/// Shared stage 13 pipeline: resample to canonical dims, per-link median
/// amplitude normalization, per-(link, snapshot) phase detrend.
///
/// Crate-visible so [`crate::frame::RfFrameV2::to_canonical`] derives the
/// compatibility view through the exact same code path as every adapter
/// (ADR-279 §3 — one normalization, many entry points).
#[allow(clippy::too_many_arguments)]
pub(crate) fn normalize_grid(
modality: RfModality,
mut grid: Array3<Complex64>, // (links, bins, snapshots) at source resolution
links: Vec<LinkGeometry>,
center_freq_hz: f64,
bandwidth_hz: f64,
age_s: f64,
timestamp_ns: u64,
device_id: String,
clock_quality: f64,
uncertainty: f64,
phase_calibrated: bool,
) -> Result<RfTensor> {
let (n_links, n_bins, n_snaps) = grid.dim();
if n_links == 0 || n_bins == 0 || n_snaps == 0 {
return Err(UnifiedError::ShapeMismatch("empty raw capture".into()));
}
// Stage 1: resample bin axis then snapshot axis to canonical dims.
if n_bins != CANONICAL_BINS {
let mut resampled = Array3::zeros((n_links, CANONICAL_BINS, n_snaps));
for l in 0..n_links {
for s in 0..n_snaps {
let col: Vec<Complex64> = (0..n_bins).map(|b| grid[[l, b, s]]).collect();
for (b, v) in resample_complex(&col, CANONICAL_BINS).into_iter().enumerate() {
resampled[[l, b, s]] = v;
}
}
}
grid = resampled;
}
let n_snaps_now = grid.dim().2;
if n_snaps_now != CANONICAL_SNAPSHOTS {
let mut resampled = Array3::zeros((n_links, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
for l in 0..n_links {
for b in 0..CANONICAL_BINS {
let row: Vec<Complex64> = (0..n_snaps_now).map(|s| grid[[l, b, s]]).collect();
for (s, v) in resample_complex(&row, CANONICAL_SNAPSHOTS).into_iter().enumerate() {
resampled[[l, b, s]] = v;
}
}
}
grid = resampled;
}
// Stage 2: per-link median amplitude normalization (gain invariance).
let mut gain_offset_db = 0.0;
for l in 0..n_links {
let amps: Vec<f64> = grid.index_axis(Axis(0), l).iter().map(|z| z.norm()).collect();
let med = median(&amps);
if med > 0.0 {
gain_offset_db += -20.0 * med.log10();
grid.index_axis_mut(Axis(0), l).mapv_inplace(|z| z / med);
}
}
gain_offset_db /= n_links as f64;
// Stage 3: phase sanitization — remove per-(link, snapshot) constant
// offset and linear ramp across bins (CFO residual + sampling-time
// offset). Skipped when the front-end already phase-calibrates.
if !phase_calibrated {
for l in 0..n_links {
for s in 0..CANONICAL_SNAPSHOTS {
// Unwrap phase across bins before fitting the ramp.
let mut phases = Vec::with_capacity(CANONICAL_BINS);
let mut prev = grid[[l, 0, s]].arg();
phases.push(prev);
for b in 1..CANONICAL_BINS {
let mut p = grid[[l, b, s]].arg();
while p - prev > std::f64::consts::PI {
p -= 2.0 * std::f64::consts::PI;
}
while p - prev < -std::f64::consts::PI {
p += 2.0 * std::f64::consts::PI;
}
phases.push(p);
prev = p;
}
let slope = linear_slope(&phases);
let mean = phases.iter().sum::<f64>() / CANONICAL_BINS as f64;
let mid = (CANONICAL_BINS as f64 - 1.0) / 2.0;
for b in 0..CANONICAL_BINS {
let correction = mean + slope * (b as f64 - mid);
let rot = Complex64::new(correction.cos(), -correction.sin());
grid[[l, b, s]] *= rot;
}
}
}
}
RfTensor::new(
modality,
center_freq_hz,
bandwidth_hz,
grid,
links,
age_s,
timestamp_ns,
device_id,
clock_quality,
uncertainty,
CalibrationMeta {
clock_ppm: (1.0 - clock_quality) * 40.0,
phase_calibrated: true, // after stage 3 the tensor is detrended
gain_offset_db,
baseline_id: None,
},
)
}
/// 802.11 CSI adapter (ESP32-S3 / Intel-style spatial-stream CSI).
pub struct WifiCsiAdapter {
hardware_id: String,
}
impl WifiCsiAdapter {
/// New adapter for the given hardware id (e.g. `"esp32s3-csi"`).
#[must_use]
pub fn new(hardware_id: impl Into<String>) -> Self {
Self { hardware_id: hardware_id.into() }
}
}
/// IEEE 802.11 channel-number → center-frequency, in MHz.
///
/// `CsiMetadata::frequency_band` alone only identifies which ~100 MHz-wide
/// band a capture came from (a fixed per-band constant), not the actual
/// channel — using the band constant directly misreports every channel
/// except the one it happens to match (2.4 GHz channel 6, 5 GHz channel 36),
/// by up to tens of MHz on 2.4 GHz and hundreds of MHz on 5/6 GHz. Falls back
/// to the band constant only when the channel number is out of the known
/// range (e.g. `0`, meaning "unknown").
fn channel_center_freq_mhz(band: FrequencyBand, channel: u8) -> f64 {
match band {
FrequencyBand::Band2_4GHz => match channel {
1..=13 => 2407.0 + 5.0 * f64::from(channel),
14 => 2484.0,
_ => f64::from(band.center_frequency_mhz()),
},
FrequencyBand::Band5GHz if channel > 0 => 5000.0 + 5.0 * f64::from(channel),
FrequencyBand::Band6GHz if channel > 0 => 5950.0 + 5.0 * f64::from(channel),
FrequencyBand::Band5GHz | FrequencyBand::Band6GHz => f64::from(band.center_frequency_mhz()),
}
}
impl RfAdapter for WifiCsiAdapter {
fn modality(&self) -> RfModality {
RfModality::WifiCsi
}
fn hardware_id(&self) -> &str {
&self.hardware_id
}
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
let RawCapture::WifiCsi { frames, links, age_s, clock_quality } = raw else {
return Err(UnifiedError::ModalityMismatch {
adapter: self.hardware_id.clone(),
got: raw.modality(),
});
};
if frames.is_empty() {
return Err(UnifiedError::ShapeMismatch("no CSI frames".into()));
}
let n_links = frames[0].num_spatial_streams();
let n_bins = frames[0].num_subcarriers();
for f in frames {
if f.num_spatial_streams() != n_links || f.num_subcarriers() != n_bins {
return Err(UnifiedError::ShapeMismatch(
"inconsistent CSI frame shapes within window".into(),
));
}
}
let mut grid = Array3::zeros((n_links, n_bins, frames.len()));
for (s, f) in frames.iter().enumerate() {
for l in 0..n_links {
for b in 0..n_bins {
grid[[l, b, s]] = f.data[[l, b]];
}
}
}
let meta = &frames[0].metadata;
// SNR → uncertainty: 0 dB SNR ⇒ 1.0 (noise floor), ≥ 40 dB ⇒ 0.0.
let snr_db =
frames.iter().map(|f| f.metadata.snr_db()).sum::<f64>() / frames.len() as f64;
let uncertainty = (1.0 - snr_db / 40.0).clamp(0.0, 1.0);
let center_freq_hz = channel_center_freq_mhz(meta.frequency_band, meta.channel) * 1e6;
let ts = &meta.timestamp;
let timestamp_ns = u64::try_from(ts.seconds).unwrap_or(0) * 1_000_000_000 + u64::from(ts.nanos);
normalize_grid(
RfModality::WifiCsi,
grid,
links.clone(),
center_freq_hz,
f64::from(meta.bandwidth_mhz) * 1e6,
*age_s,
timestamp_ns,
meta.device_id.as_str().to_string(),
*clock_quality,
uncertainty,
false,
)
}
}
/// FMCW radar adapter: fast-time DFT to range bins, then shared pipeline.
pub struct FmcwRadarAdapter {
hardware_id: String,
}
impl FmcwRadarAdapter {
/// New adapter for the given radar front-end id (e.g. `"mr60bha2"`).
#[must_use]
pub fn new(hardware_id: impl Into<String>) -> Self {
Self { hardware_id: hardware_id.into() }
}
}
impl RfAdapter for FmcwRadarAdapter {
fn modality(&self) -> RfModality {
RfModality::FmcwRadar
}
fn hardware_id(&self) -> &str {
&self.hardware_id
}
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
let RawCapture::FmcwRadarCube { cube, links, center_freq_hz, bandwidth_hz, age_s, device_id } =
raw
else {
return Err(UnifiedError::ModalityMismatch {
adapter: self.hardware_id.clone(),
got: raw.modality(),
});
};
let (n_rx, n_fast, n_chirps) = cube.dim();
if n_rx == 0 || n_fast == 0 || n_chirps == 0 {
return Err(UnifiedError::ShapeMismatch("empty radar cube".into()));
}
// Fast-time DFT: beat-frequency bin b ↔ range bin b. Positive-range
// half only (b < n_fast/2) mirrors what commercial FMCW parts export.
let n_range = (n_fast / 2).max(1);
let mut grid = Array3::zeros((n_rx, n_range, n_chirps));
for l in 0..n_rx {
for c in 0..n_chirps {
for b in 0..n_range {
let mut acc = Complex64::new(0.0, 0.0);
for t in 0..n_fast {
let ang =
-2.0 * std::f64::consts::PI * (b as f64) * (t as f64) / (n_fast as f64);
acc += cube[[l, t, c]] * Complex64::new(ang.cos(), ang.sin());
}
grid[[l, b, c]] = acc / n_fast as f64;
}
}
}
// Range profiles are already phase-meaningful per bin; the linear
// detrend would erase target range information, so radar declares
// itself phase-calibrated and only amplitude-normalizes.
normalize_grid(
RfModality::FmcwRadar,
grid,
links.clone(),
*center_freq_hz,
*bandwidth_hz,
*age_s,
0,
device_id.clone(),
0.9,
0.1,
true,
)
}
}
/// UWB CIR adapter: taps are already a delay-domain profile.
pub struct UwbCirAdapter {
hardware_id: String,
}
impl UwbCirAdapter {
/// New adapter for the given UWB chip id (e.g. `"dw3000"`).
#[must_use]
pub fn new(hardware_id: impl Into<String>) -> Self {
Self { hardware_id: hardware_id.into() }
}
}
impl RfAdapter for UwbCirAdapter {
fn modality(&self) -> RfModality {
RfModality::UwbCir
}
fn hardware_id(&self) -> &str {
&self.hardware_id
}
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
let RawCapture::UwbCir { taps, links, center_freq_hz, bandwidth_hz, age_s, device_id } = raw
else {
return Err(UnifiedError::ModalityMismatch {
adapter: self.hardware_id.clone(),
got: raw.modality(),
});
};
normalize_grid(
RfModality::UwbCir,
taps.clone(),
links.clone(),
*center_freq_hz,
*bandwidth_hz,
*age_s,
0,
device_id.clone(),
0.95, // UWB timestamps are hardware-disciplined
0.05,
true, // delay-domain taps: detrending would destroy ToF structure
)
}
}
/// 5G NR SRS adapter: de-comb by interpolating the unsounded subcarriers.
pub struct CellularSrsAdapter {
hardware_id: String,
}
impl CellularSrsAdapter {
/// New adapter for the given gNB/xApp source id (e.g. `"oai-srs-xapp"`).
#[must_use]
pub fn new(hardware_id: impl Into<String>) -> Self {
Self { hardware_id: hardware_id.into() }
}
}
impl RfAdapter for CellularSrsAdapter {
fn modality(&self) -> RfModality {
RfModality::CellularSrs
}
fn hardware_id(&self) -> &str {
&self.hardware_id
}
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
let RawCapture::CellularSrs {
comb_res,
comb,
links,
center_freq_hz,
bandwidth_hz,
age_s,
device_id,
} = raw
else {
return Err(UnifiedError::ModalityMismatch {
adapter: self.hardware_id.clone(),
got: raw.modality(),
});
};
if *comb == 0 {
return Err(UnifiedError::InvalidInput("SRS comb factor must be >= 1".into()));
}
let (n_links, n_res, n_sym) = comb_res.dim();
if n_links == 0 || n_res == 0 || n_sym == 0 {
return Err(UnifiedError::ShapeMismatch("empty SRS capture".into()));
}
// De-comb: the comb-sampled response is a uniform subsampling of the
// full band, so linear interpolation onto comb×n_res bins restores a
// dense grid before canonical resampling.
let dense_bins = n_res * comb;
let mut grid = Array3::zeros((n_links, dense_bins, n_sym));
for l in 0..n_links {
for s in 0..n_sym {
let col: Vec<Complex64> = (0..n_res).map(|b| comb_res[[l, b, s]]).collect();
for (b, v) in resample_complex(&col, dense_bins).into_iter().enumerate() {
grid[[l, b, s]] = v;
}
}
}
normalize_grid(
RfModality::CellularSrs,
grid,
links.clone(),
*center_freq_hz,
*bandwidth_hz,
*age_s,
0,
device_id.clone(),
0.99, // gNB reference clock
0.1,
false,
)
}
}
/// Bluetooth Channel Sounding adapter: tone phasors over frequency steps
/// become the canonical bin axis (delay structure preserved — the ranging
/// ramp *is* the signal, so phase detrending is skipped).
pub struct BleCsAdapter {
hardware_id: String,
}
impl BleCsAdapter {
/// New adapter for the given CS radio id (e.g. `"nrf54-cs"`).
#[must_use]
pub fn new(hardware_id: impl Into<String>) -> Self {
Self { hardware_id: hardware_id.into() }
}
}
impl RfAdapter for BleCsAdapter {
fn modality(&self) -> RfModality {
RfModality::BleCs
}
fn hardware_id(&self) -> &str {
&self.hardware_id
}
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
let RawCapture::BleCs { frame, links, age_s, device_id } = raw else {
return Err(UnifiedError::ModalityMismatch {
adapter: self.hardware_id.clone(),
got: raw.modality(),
});
};
let n = frame.frequency_steps_hz.len();
if n < 2 || frame.phase_samples_rad.len() != n {
return Err(UnifiedError::ShapeMismatch("malformed CS frame".into()));
}
let mut grid = Array3::zeros((1, n, 1));
for (b, theta) in frame.phase_samples_rad.iter().enumerate() {
grid[[0, b, 0]] = Complex64::from_polar(1.0, *theta);
}
let centre = (frame.frequency_steps_hz[0] + frame.frequency_steps_hz[n - 1]) / 2.0;
let bandwidth = frame.frequency_steps_hz[n - 1] - frame.frequency_steps_hz[0];
normalize_grid(
RfModality::BleCs,
grid,
links.clone(),
centre,
bandwidth.max(1.0),
*age_s,
0,
device_id.clone(),
0.9,
0.15,
true, // the phase ramp is the measurement; never detrend it
)
}
}
/// Registry mapping hardware ids to adapters (ADR-274 §2.4). Lookup is
/// fail-closed: unknown hardware is an error, never a silent default.
#[derive(Default)]
pub struct AdapterRegistry {
adapters: HashMap<String, Box<dyn RfAdapter>>,
}
impl AdapterRegistry {
/// Empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Registry pre-populated with the four reference adapters.
#[must_use]
pub fn with_reference_adapters() -> Self {
let mut r = Self::new();
r.register(Box::new(WifiCsiAdapter::new("esp32s3-csi")));
r.register(Box::new(FmcwRadarAdapter::new("mr60bha2")));
r.register(Box::new(UwbCirAdapter::new("dw3000")));
r.register(Box::new(CellularSrsAdapter::new("oai-srs-xapp")));
r.register(Box::new(BleCsAdapter::new("nrf54-cs")));
r
}
/// Registers an adapter under its hardware id (replaces any previous).
pub fn register(&mut self, adapter: Box<dyn RfAdapter>) {
self.adapters.insert(adapter.hardware_id().to_string(), adapter);
}
/// Normalizes a capture with the adapter registered for `hardware_id`.
pub fn normalize(&self, hardware_id: &str, raw: &RawCapture) -> Result<RfTensor> {
self.adapters
.get(hardware_id)
.ok_or_else(|| UnifiedError::UnknownHardware(hardware_id.to_string()))?
.normalize(raw)
}
/// Registered hardware ids (sorted, for deterministic display).
#[must_use]
pub fn hardware_ids(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self.adapters.keys().map(String::as_str).collect();
ids.sort_unstable();
ids
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::Array2;
use wifi_densepose_core::types::{CsiMetadata, DeviceId, FrequencyBand};
fn test_links(n: usize) -> Vec<LinkGeometry> {
(0..n)
.map(|i| LinkGeometry {
tx_pos: [0.0, 0.0, 2.0],
rx_pos: [4.0, i as f64 * 0.05, 2.0],
})
.collect()
}
/// CSI frames with a known linear phase ramp + constant offset and a
/// gain factor — exactly what stages 23 must remove.
fn ramped_frames(n_frames: usize, n_streams: usize, n_sub: usize) -> Vec<CsiFrame> {
(0..n_frames)
.map(|s| {
let meta = CsiMetadata::new(
DeviceId::new("esp32s3-a1"),
FrequencyBand::Band2_4GHz,
6,
);
let data = Array2::from_shape_fn((n_streams, n_sub), |(l, b)| {
let gain = 3.7 * (1.0 + l as f64);
let phase = 0.9 + 0.11 * b as f64 + 0.01 * s as f64;
Complex64::new(0.0, phase).exp() * gain
});
CsiFrame::new(meta, data)
})
.collect()
}
#[test]
fn wifi_adapter_normalizes_shape_gain_and_phase() {
let adapter = WifiCsiAdapter::new("esp32s3-csi");
let raw = RawCapture::WifiCsi {
frames: ramped_frames(12, 2, 114),
links: test_links(2),
age_s: 0.02,
clock_quality: 0.7,
};
let t = adapter.normalize(&raw).expect("normalizes");
assert_eq!(t.dims(), (2, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
assert_eq!(t.modality, RfModality::WifiCsi);
// Gain invariance: median amplitude per link ≈ 1 after stage 2.
for l in 0..2 {
let amps: Vec<f64> =
t.data.index_axis(Axis(0), l).iter().map(|z| z.norm()).collect();
assert!((median(&amps) - 1.0).abs() < 1e-9, "link {l} median {:?}", median(&amps));
}
// Phase sanitization: the constant-plus-ramp phase must be gone.
// Bound is 1e-4 rad: complex linear resampling (114→56 bins,
// 12→8 snapshots) leaves second-order chord-vs-arc phase residue
// of a few µrad on top of the exact detrend.
for l in 0..2 {
for s in 0..CANONICAL_SNAPSHOTS {
for b in 0..CANONICAL_BINS {
assert!(
t.data[[l, b, s]].arg().abs() < 1e-4,
"residual phase at ({l},{b},{s}): {}",
t.data[[l, b, s]].arg()
);
}
}
}
}
#[test]
fn radar_adapter_localizes_beat_tone_to_range_bin() {
// Beat tone at fast-time bin 9 of 64 ⇒ range profile peak at bin 9,
// which the canonical resampler maps to 9 · (561)/(321) ≈ 16.
let n_fast = 64;
let cube = Array3::from_shape_fn((1, n_fast, 16), |(_, t, _)| {
let ang = 2.0 * std::f64::consts::PI * 9.0 * t as f64 / n_fast as f64;
Complex64::new(ang.cos(), ang.sin())
});
let adapter = FmcwRadarAdapter::new("mr60bha2");
let raw = RawCapture::FmcwRadarCube {
cube,
links: test_links(1),
center_freq_hz: 60e9,
bandwidth_hz: 1e9,
age_s: 0.0,
device_id: "mr60".into(),
};
let t = adapter.normalize(&raw).expect("normalizes");
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
let amps: Vec<f64> =
(0..CANONICAL_BINS).map(|b| t.data[[0, b, 0]].norm()).collect();
let peak = amps
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap()
.0;
let expected = (9.0 * (CANONICAL_BINS as f64 - 1.0) / 31.0).round() as usize;
assert!(
peak.abs_diff(expected) <= 1,
"range peak at bin {peak}, expected ≈{expected}"
);
}
#[test]
fn srs_adapter_decombs_and_normalizes() {
let adapter = CellularSrsAdapter::new("oai-srs-xapp");
let comb_res = Array3::from_shape_fn((1, 24, 4), |(_, b, _)| {
Complex64::new(1.0 + 0.01 * b as f64, 0.0)
});
let raw = RawCapture::CellularSrs {
comb_res,
comb: 2,
links: test_links(1),
center_freq_hz: 3.5e9,
bandwidth_hz: 40e6,
age_s: 0.001,
device_id: "gnb-1".into(),
};
let t = adapter.normalize(&raw).expect("normalizes");
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
assert_eq!(t.modality, RfModality::CellularSrs);
}
/// Synthesizes CS phases for a known distance: θ(f) = 4π·f·d/c.
fn cs_frame(distance_m: f64, rtt_ns: Option<f64>) -> BleCsFrame {
let c = 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| {
let theta = -4.0 * std::f64::consts::PI * f * distance_m / c;
theta.rem_euclid(2.0 * std::f64::consts::PI)
})
.collect();
BleCsFrame { frequency_steps_hz: steps, phase_samples_rad: phases, round_trip_time_ns: rtt_ns }
}
#[test]
fn ble_cs_phase_ranging_recovers_exact_distance() {
let c = 299_792_458.0;
for d in [1.5, 5.0, 12.0] {
let rtt_ns = 2.0 * d / c * 1e9;
let ev = ble_cs_range(&cs_frame(d, Some(rtt_ns))).expect("evidence");
assert!(
(ev.phase_distance_m - d).abs() < 1e-6,
"phase ranging {} vs true {d}",
ev.phase_distance_m
);
assert!((ev.rtt_distance_m.unwrap() - d).abs() < 1e-9);
assert!(ev.agreement, "consistent sources must agree");
assert!(ev.confidence > 0.9);
assert!(ev.anomaly.is_none());
}
}
#[test]
fn ble_cs_flags_relay_style_divergence_instead_of_averaging() {
// Phase says 5 m; a relay/timing fault inflates RTT to ~51 m.
let ev = ble_cs_range(&cs_frame(5.0, Some(340.0))).expect("evidence");
assert!((ev.phase_distance_m - 5.0).abs() < 1e-6);
assert!(ev.rtt_distance_m.unwrap() > 50.0);
assert!(!ev.agreement);
assert_eq!(ev.anomaly, Some(RangingAnomaly::Divergent));
assert!(ev.confidence < 0.25, "divergent evidence must not be trusted");
}
#[test]
fn ble_cs_adapter_produces_canonical_tensor() {
let adapter = BleCsAdapter::new("nrf54-cs");
let raw = RawCapture::BleCs {
frame: cs_frame(3.0, None),
links: test_links(1),
age_s: 0.01,
device_id: "nrf54-a".into(),
};
let t = adapter.normalize(&raw).expect("normalizes");
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
assert_eq!(t.modality, RfModality::BleCs);
// The ranging ramp must survive (no detrend): phase varies across bins.
let p0 = t.data[[0, 0, 0]].arg();
let p_mid = t.data[[0, CANONICAL_BINS / 2, 0]].arg();
assert!((p0 - p_mid).abs() > 1e-3, "phase ramp must be preserved");
}
#[test]
fn registry_is_fail_closed_and_type_safe() {
let registry = AdapterRegistry::with_reference_adapters();
assert_eq!(
registry.hardware_ids(),
vec!["dw3000", "esp32s3-csi", "mr60bha2", "nrf54-cs", "oai-srs-xapp"]
);
// Unknown hardware ⇒ error, never a default adapter.
let raw = RawCapture::UwbCir {
taps: Array3::from_elem((1, 32, 4), Complex64::new(1.0, 0.0)),
links: test_links(1),
center_freq_hz: 6.5e9,
bandwidth_hz: 500e6,
age_s: 0.0,
device_id: "dw".into(),
};
assert!(matches!(
registry.normalize("unknown-chip", &raw),
Err(UnifiedError::UnknownHardware(_))
));
// Wrong modality for the adapter ⇒ typed mismatch error.
assert!(matches!(
registry.normalize("esp32s3-csi", &raw),
Err(UnifiedError::ModalityMismatch { .. })
));
// Right adapter succeeds.
assert!(registry.normalize("dw3000", &raw).is_ok());
}
}
+717
View File
@@ -0,0 +1,717 @@
//! Programmable perception — the active sensing control plane (ADR-280).
//!
//! The shift this module implements: from *passive* sensing (accept
//! whatever measurements arrive) to *programmable* perception (the system
//! chooses where, when, how, and at what fidelity to sense, then resolves
//! uncertainty deliberately). Five contracts:
//!
//! 1. [`SensingTask`] — the evidence-aware task contract (ETSI ISAC
//! sensing-task vocabulary: purpose, area, resolution, latency,
//! confidence, retention, consumers, consent).
//! 2. [`SensingAction`] + [`InformationGoal`] — a request to actively
//! gather evidence against a hypothesis, bounded by latency, energy,
//! and a privacy ceiling.
//! 3. [`ActiveSensingPlanner`] over [`SpatialStateFreshness`] — age-of-
//! information scheduling: refresh what is stale, changing, and
//! important, not everything uniformly.
//! 4. [`CoherentSensorGroup`] — distributed-aperture fusion is allowed
//! **only** when time, phase, and geometry compatibility is proven;
//! out-of-bounds members fail closed (the dominant failure mode of
//! emerging systems is hidden synchronization/calibration dependence).
//! 5. [`FieldActuator`] + [`ActuationReceipt`] — programmable radio
//! environments (RIS, movable antennas) are actuators whose state
//! changes alter *who is observable*, so actuation demands the same
//! policy authorization and auditability as sensing itself.
//!
//! Plus [`TaskSufficientRepresentation`] — semantic, task-scoped
//! compression whose leakage rules are validated, not assumed.
use serde::{Deserialize, Serialize};
use crate::policy::{PolicyEngine, SensingPurpose};
use crate::tensor::RfModality;
use crate::{Result, UnifiedError};
/// RuField-aligned privacy classes (ADR-262 §3.3 vocabulary).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum PrivacyClass {
/// Raw signal — never leaves the trust boundary.
P0,
/// Heavily aggregated, non-personal.
P1,
/// Anonymous presence/occupancy grade.
P2,
/// Behavioral inference grade.
P3,
/// Derived personal inference grade.
P4,
/// Identity-bound grade.
P5,
}
/// Axis-aligned spatial zone in the building frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpatialZone {
/// Zone identifier (matches ADR-277 `PrivacyZone` ids).
pub id: String,
/// Minimum corner, metres.
pub min_m: [f64; 3],
/// Maximum corner, metres.
pub max_m: [f64; 3],
}
impl SpatialZone {
/// Whether a point lies inside the zone.
#[must_use]
pub fn contains(&self, p: [f64; 3]) -> bool {
(0..3).all(|k| p[k] >= self.min_m[k] && p[k] <= self.max_m[k])
}
}
/// The evidence-aware sensing task contract (ADR-280 §2). Enforced
/// *before capture begins*, not applied later as metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensingTask {
/// Task identifier.
pub task_id: u128,
/// Purpose (drives ADR-277 zone authorization).
pub purpose: SensingPurpose,
/// Target area.
pub target_area: SpatialZone,
/// Modalities the task may use.
pub modalities: Vec<RfModality>,
/// Requested spatial resolution, metres.
pub requested_resolution_m: f64,
/// Maximum acceptable result latency, ms.
pub maximum_latency_ms: u32,
/// Minimum confidence below which results become *no decision*.
pub minimum_confidence: f64,
/// Raw (P0) retention bound, seconds — local only.
pub raw_retention_seconds: u64,
/// Result retention bound, seconds.
pub result_retention_seconds: u64,
/// Principals allowed to consume results.
pub authorized_consumers: Vec<String>,
/// Consent reference, when the purpose requires one.
pub consent_reference: Option<String>,
/// Requested raw export. Kept in the contract for ISAC-vocabulary
/// compatibility, but see [`PolicyEngine`]-backed admission: ADR-277's
/// structural rule means this is **always refused** today.
pub raw_export_allowed: bool,
}
/// Admits a sensing task against the ADR-277 policy engine. Fail-closed:
/// unknown zone, ungranted purpose, identity single-gate, raw export, and
/// missing-consent identity tasks all deny.
pub fn admit_task(engine: &PolicyEngine, task: &SensingTask) -> Result<()> {
if task.raw_export_allowed {
return Err(UnifiedError::PolicyDenied(
"raw RF export is structurally disabled (ADR-277 §2.1); \
the contract field exists for ISAC vocabulary compatibility only"
.into(),
));
}
if !(task.minimum_confidence.is_finite() && (0.0..=1.0).contains(&task.minimum_confidence)) {
return Err(UnifiedError::InvalidInput("minimum_confidence must be in [0,1]".into()));
}
if !(task.requested_resolution_m.is_finite() && task.requested_resolution_m > 0.0) {
return Err(UnifiedError::InvalidInput("requested_resolution_m must be finite and > 0".into()));
}
if task.maximum_latency_ms == 0 {
return Err(UnifiedError::InvalidInput("maximum_latency_ms must be > 0".into()));
}
if task.modalities.is_empty() {
return Err(UnifiedError::InvalidInput("a sensing task must declare at least one modality".into()));
}
if task.purpose == SensingPurpose::IdentityRecognition && task.consent_reference.is_none() {
return Err(UnifiedError::PolicyDenied(
"identity recognition tasks require a consent reference".into(),
));
}
engine.authorize(&task.target_area.id, task.purpose)
}
/// What an active sensing request is trying to learn.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InformationGoal {
/// Human-readable hypothesis under test.
pub hypothesis: String,
/// Current uncertainty in `[0, 1]`.
pub current_uncertainty: f64,
/// Target uncertainty in `[0, 1]` (must be below current).
pub target_uncertainty: f64,
/// Expected information gain of the action (heuristic units).
pub expected_information_gain: f64,
}
/// A deliberate act of sensing (ADR-280 §3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensingAction {
/// Action identifier.
pub action_id: String,
/// Region to observe.
pub target_region: SpatialZone,
/// Modality to use.
pub modality: RfModality,
/// Goal that justifies the action.
pub desired_information: InformationGoal,
/// Latency budget, ms.
pub maximum_latency_ms: u32,
/// Energy budget, joules.
pub energy_budget_j: f64,
/// Highest privacy class the action may produce.
pub privacy_ceiling: PrivacyClass,
}
/// Freshness state of one spatial region (age-of-information model,
/// ADR-280 §4).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpatialStateFreshness {
/// Region identifier.
pub region_id: String,
/// Region geometry.
pub region: SpatialZone,
/// Last observation, ns since epoch.
pub last_observed_ns: u64,
/// Expected change rate (events/s scale factor).
pub expected_change_rate: f64,
/// Uncertainty growth per second of staleness.
pub uncertainty_growth_rate: f64,
/// Business criticality weight (≥ 0).
pub business_criticality: f64,
/// Cost of sensing this region (energy/traffic units, > 0).
pub sensing_cost: f64,
}
impl SpatialStateFreshness {
/// Uncertainty accumulated since the last observation, capped at 1.
#[must_use]
pub fn uncertainty_at(&self, now_ns: u64) -> f64 {
let age_s = now_ns.saturating_sub(self.last_observed_ns) as f64 / 1e9;
(self.uncertainty_growth_rate * age_s).min(1.0)
}
/// Refresh priority: `uncertainty × change rate × criticality ÷ cost`.
#[must_use]
pub fn priority(&self, now_ns: u64) -> f64 {
self.uncertainty_at(now_ns) * self.expected_change_rate * self.business_criticality
/ self.sensing_cost.max(1e-9)
}
}
/// Age-of-information sensing scheduler: refreshes regions in priority
/// order instead of uniformly.
#[derive(Debug, Default)]
pub struct ActiveSensingPlanner {
regions: Vec<SpatialStateFreshness>,
/// Priority below which a region is not worth sensing this cycle.
pub priority_threshold: f64,
}
impl ActiveSensingPlanner {
/// New planner with a priority threshold.
#[must_use]
pub fn new(priority_threshold: f64) -> Self {
Self { regions: Vec::new(), priority_threshold }
}
/// Registers or replaces a region.
pub fn upsert_region(&mut self, region: SpatialStateFreshness) {
if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region.region_id) {
*r = region;
} else {
self.regions.push(region);
}
}
/// Marks a region observed at `now_ns`.
pub fn mark_observed(&mut self, region_id: &str, now_ns: u64) {
if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region_id) {
r.last_observed_ns = now_ns;
}
}
/// Highest-priority region above the threshold, as a concrete
/// [`SensingAction`]; `None` when nothing is worth sensing.
#[must_use]
pub fn next_action(&self, now_ns: u64, modality: RfModality) -> Option<SensingAction> {
let best = self
.regions
.iter()
.map(|r| (r.priority(now_ns), r))
.filter(|(p, _)| *p >= self.priority_threshold)
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))?;
let (priority, region) = best;
let uncertainty = region.uncertainty_at(now_ns);
Some(SensingAction {
action_id: format!("aoi-{}-{now_ns}", region.region_id),
target_region: region.region.clone(),
modality,
desired_information: InformationGoal {
hypothesis: format!("state of region {} is stale", region.region_id),
current_uncertainty: uncertainty,
target_uncertainty: (uncertainty * 0.2).min(0.05),
expected_information_gain: priority,
},
maximum_latency_ms: 500,
energy_budget_j: region.sensing_cost,
privacy_ceiling: PrivacyClass::P2,
})
}
}
/// Clock/phase/geometry sync state reported by one member of a
/// distributed aperture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberSyncState {
/// Member identifier.
pub member_id: String,
/// Measured time error vs the group reference, ns.
pub time_error_ns: f64,
/// Measured phase error vs the group reference, rad.
pub phase_error_rad: f64,
/// Hash of the member's calibrated baseline geometry.
pub geometry_hash: u64,
}
/// A coherent sensing group (ADR-280 §5): no coherent fusion unless
/// time, phase, and geometry compatibility is *proven*.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoherentSensorGroup {
/// Group identifier.
pub group_id: String,
/// Member identifiers.
pub members: Vec<String>,
/// Maximum tolerated time error, ns.
pub maximum_time_error_ns: f64,
/// Maximum tolerated phase error, rad.
pub maximum_phase_error_rad: f64,
/// Required baseline geometry hash (all members must match).
pub baseline_geometry_hash: u64,
}
impl CoherentSensorGroup {
/// Fail-closed fusion gate: every group member must report, be within
/// time and phase bounds, and match the baseline geometry hash.
/// Unknown reporters, missing members, or any out-of-bounds member
/// deny fusion with a typed error.
pub fn can_fuse(&self, states: &[MemberSyncState]) -> Result<()> {
for member in &self.members {
let Some(s) = states.iter().find(|s| &s.member_id == member) else {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: member {member:?} did not report sync state"
)));
};
if !s.time_error_ns.is_finite() || s.time_error_ns.abs() > self.maximum_time_error_ns {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {member:?} time error {} ns exceeds {} ns",
s.time_error_ns, self.maximum_time_error_ns
)));
}
if !s.phase_error_rad.is_finite()
|| s.phase_error_rad.abs() > self.maximum_phase_error_rad
{
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {member:?} phase error {} rad exceeds {} rad",
s.phase_error_rad, self.maximum_phase_error_rad
)));
}
if s.geometry_hash != self.baseline_geometry_hash {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {member:?} geometry hash mismatch"
)));
}
}
for s in states {
if !self.members.contains(&s.member_id) {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {:?} is not a group member",
s.member_id
)));
}
}
Ok(())
}
}
/// Kind of radio-environment actuator.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActuatorKind {
/// Reconfigurable intelligent surface.
Ris,
/// Mechanically movable antenna.
MovableAntenna,
/// Fluid antenna.
FluidAntenna,
}
/// A programmable radio-environment actuator (ADR-280 §6).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldActuator {
/// Actuator identifier.
pub actuator_id: String,
/// Kind.
pub kind: ActuatorKind,
/// Pose in the building frame.
pub pose_m: [f64; 3],
/// Named states the actuator supports.
pub supported_states: Vec<String>,
/// Zone whose observability this actuator changes.
pub affected_zone_id: String,
}
/// Audit receipt for an applied actuation. Constructed only by
/// [`request_actuation`] — there is no other way to obtain one, so every
/// state change that alters observability is policy-checked and logged.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActuationReceipt {
/// State that was requested.
pub requested_state: String,
/// State actually applied.
pub applied_state: String,
/// Application time, ns.
pub applied_ns: u64,
/// Controller identity.
pub controller_id: String,
/// Purpose under which the actuation was authorized.
pub purpose: SensingPurpose,
}
/// Requests an actuator state change. Denied unless (a) the actuator
/// supports the state and (b) the affected zone grants the purpose under
/// the ADR-277 engine — changing an RIS configuration can change *which
/// rooms and people are observable*, so it is governed like sensing.
pub fn request_actuation(
engine: &PolicyEngine,
actuator: &FieldActuator,
state: &str,
purpose: SensingPurpose,
controller_id: &str,
now_ns: u64,
) -> Result<ActuationReceipt> {
if !actuator.supported_states.iter().any(|s| s == state) {
return Err(UnifiedError::InvalidInput(format!(
"actuator {:?} does not support state {state:?}",
actuator.actuator_id
)));
}
engine.authorize(&actuator.affected_zone_id, purpose)?;
Ok(ActuationReceipt {
requested_state: state.to_string(),
applied_state: state.to_string(),
applied_ns: now_ns,
controller_id: controller_id.to_string(),
purpose,
})
}
/// A task-scoped semantic compression of observations (ADR-280 §7):
/// transmit only the information the current physical task needs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSufficientRepresentation {
/// Task this representation serves.
pub task_id: u128,
/// Source frame receipt ids (lineage).
pub source_receipts: Vec<u128>,
/// Compressed semantic state.
pub semantic_state: Vec<f32>,
/// Claimed information bound, bits.
pub information_bound_bits: f64,
/// Information classes *explicitly* excluded (e.g. `"identity"`,
/// `"vitals"`, `"trajectory-history"`).
pub excluded_information: Vec<String>,
/// Privacy class of the representation.
pub privacy_class: PrivacyClass,
}
/// Purpose-scoped leakage validation: compression must remain task
/// scoped. A representation sufficient for anonymous occupancy must not
/// retain identity information; each purpose has a privacy-class ceiling
/// and a set of information classes it must exclude.
pub fn validate_representation(
rep: &TaskSufficientRepresentation,
purpose: SensingPurpose,
) -> Result<()> {
let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose {
SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => {
(PrivacyClass::P2, &["identity", "vitals"])
}
SensingPurpose::Activity | SensingPurpose::Localization => {
(PrivacyClass::P3, &["identity"])
}
SensingPurpose::Vitals | SensingPurpose::PoseTracking => (PrivacyClass::P4, &["identity"]),
SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]),
};
if rep.privacy_class > ceiling {
return Err(UnifiedError::PolicyDenied(format!(
"representation class {:?} exceeds ceiling {ceiling:?} for purpose {purpose:?}",
rep.privacy_class
)));
}
for class in must_exclude {
if !rep.excluded_information.iter().any(|e| e == class) {
return Err(UnifiedError::PolicyDenied(format!(
"purpose {purpose:?} requires the representation to explicitly exclude {class:?}"
)));
}
}
if rep.source_receipts.is_empty() {
return Err(UnifiedError::InvalidInput(
"task-sufficient representation must carry source lineage".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::PrivacyZone;
fn zone(id: &str) -> SpatialZone {
SpatialZone { id: id.into(), min_m: [0.0; 3], max_m: [5.0, 4.0, 3.0] }
}
fn engine_with(purposes: &[SensingPurpose]) -> PolicyEngine {
let mut e = PolicyEngine::new();
e.upsert_zone(PrivacyZone {
id: "lab".into(),
allowed_purposes: purposes.iter().copied().collect(),
retention_s: 3600,
identity_explicitly_enabled: false,
});
e
}
fn task(purpose: SensingPurpose, raw_export: bool) -> SensingTask {
SensingTask {
task_id: 1,
purpose,
target_area: zone("lab"),
modalities: vec![RfModality::WifiCsi],
requested_resolution_m: 0.5,
maximum_latency_ms: 100,
minimum_confidence: 0.8,
raw_retention_seconds: 60,
result_retention_seconds: 3600,
authorized_consumers: vec!["ha-bridge".into()],
consent_reference: None,
raw_export_allowed: raw_export,
}
}
#[test]
fn task_admission_is_fail_closed() {
let engine = engine_with(&[SensingPurpose::Presence]);
assert!(admit_task(&engine, &task(SensingPurpose::Presence, false)).is_ok());
// Raw export is refused regardless of any other grant.
assert!(matches!(
admit_task(&engine, &task(SensingPurpose::Presence, true)),
Err(UnifiedError::PolicyDenied(_))
));
// Ungranted purpose denied.
assert!(admit_task(&engine, &task(SensingPurpose::Localization, false)).is_err());
// Identity without consent denied before even reaching the zone check.
assert!(admit_task(&engine, &task(SensingPurpose::IdentityRecognition, false)).is_err());
}
#[test]
fn planner_prioritizes_stale_critical_regions() {
let mut planner = ActiveSensingPlanner::new(0.01);
let mk = |id: &str, change: f64, crit: f64, cost: f64| SpatialStateFreshness {
region_id: id.into(),
region: zone(id),
last_observed_ns: 0,
expected_change_rate: change,
uncertainty_growth_rate: 0.05,
business_criticality: crit,
sensing_cost: cost,
};
planner.upsert_region(mk("server-room", 0.1, 5.0, 1.0));
planner.upsert_region(mk("emergency-exit", 0.5, 8.0, 1.0));
planner.upsert_region(mk("storage", 0.01, 0.5, 1.0));
let now = 10_000_000_000; // 10 s of staleness everywhere
let action = planner.next_action(now, RfModality::WifiCsi).expect("something stale");
assert_eq!(action.target_region.id, "emergency-exit", "highest priority wins");
// After observing it, the next-highest region is selected.
planner.mark_observed("emergency-exit", now);
let action = planner.next_action(now, RfModality::WifiCsi).expect("next region");
assert_eq!(action.target_region.id, "server-room");
}
#[test]
fn planner_reduces_sensing_traffic_versus_uniform_refresh() {
// 20 regions, one hot (changes often, critical), the rest cold.
let mut planner = ActiveSensingPlanner::new(0.05);
for i in 0..20 {
let hot = i == 0;
planner.upsert_region(SpatialStateFreshness {
region_id: format!("r{i}"),
region: zone("lab"),
last_observed_ns: 0,
expected_change_rate: if hot { 1.0 } else { 0.01 },
uncertainty_growth_rate: 0.2,
business_criticality: if hot { 5.0 } else { 0.5 },
sensing_cost: 1.0,
});
}
// Simulate 100 scheduling ticks, 1 s apart. Uniform refresh would
// sense 20 regions × 100 ticks = 2000 observations; the planner
// senses at most one region per tick and only above threshold.
let mut actions = 0;
for tick in 1..=100u64 {
let now = tick * 1_000_000_000;
if let Some(a) = planner.next_action(now, RfModality::WifiCsi) {
planner.mark_observed(&a.target_region.id, now);
actions += 1;
}
}
let uniform = 20 * 100;
let reduction = 1.0 - actions as f64 / uniform as f64;
println!("AoI planner: {actions} observations vs {uniform} uniform ({reduction:.2} reduction)");
assert!(
reduction >= 0.70,
"planner must cut sensing traffic by >= 70 % in sparse environments, got {reduction:.2}"
);
assert!(actions > 0, "the hot region must still be observed");
}
#[test]
fn coherent_fusion_fails_closed() {
let group = CoherentSensorGroup {
group_id: "aisle-3".into(),
members: vec!["ap-1".into(), "ap-2".into()],
maximum_time_error_ns: 50.0,
maximum_phase_error_rad: 0.2,
baseline_geometry_hash: 0xBEEF,
};
let ok = |id: &str| MemberSyncState {
member_id: id.into(),
time_error_ns: 10.0,
phase_error_rad: 0.05,
geometry_hash: 0xBEEF,
};
assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2")]).is_ok());
// Missing member ⇒ deny.
assert!(group.can_fuse(&[ok("ap-1")]).is_err());
// Clock out of bounds ⇒ deny.
let mut drift = ok("ap-2");
drift.time_error_ns = 400.0;
assert!(group.can_fuse(&[ok("ap-1"), drift]).is_err());
// Phase out of bounds ⇒ deny.
let mut phase = ok("ap-2");
phase.phase_error_rad = 1.0;
assert!(group.can_fuse(&[ok("ap-1"), phase]).is_err());
// Geometry changed since calibration ⇒ deny.
let mut moved = ok("ap-2");
moved.geometry_hash = 0xDEAD;
assert!(group.can_fuse(&[ok("ap-1"), moved]).is_err());
// A non-member reporting in ⇒ deny.
assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2"), ok("rogue")]).is_err());
}
#[test]
fn actuation_requires_policy_authorization() {
let engine = engine_with(&[SensingPurpose::Presence]);
let ris = FieldActuator {
actuator_id: "ris-7".into(),
kind: ActuatorKind::Ris,
pose_m: [2.0, 0.0, 2.5],
supported_states: vec!["beam-east".into(), "beam-west".into()],
affected_zone_id: "lab".into(),
};
// Authorized purpose + supported state ⇒ receipt.
let receipt =
request_actuation(&engine, &ris, "beam-east", SensingPurpose::Presence, "ctl-1", 99)
.expect("authorized actuation");
assert_eq!(receipt.applied_state, "beam-east");
assert_eq!(receipt.purpose, SensingPurpose::Presence);
// Unsupported state ⇒ deny.
assert!(request_actuation(&engine, &ris, "beam-up", SensingPurpose::Presence, "c", 0)
.is_err());
// Purpose not granted in the affected zone ⇒ deny (an RIS cannot be
// steered to observe a zone for a purpose the zone never granted).
assert!(request_actuation(&engine, &ris, "beam-east", SensingPurpose::Vitals, "c", 0)
.is_err());
}
#[test]
fn task_sufficient_representation_is_leakage_checked() {
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
task_id: 5,
source_receipts: vec![1, 2],
semantic_state: vec![0.1, 0.9],
information_bound_bits: 8.0,
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
privacy_class: class,
};
// Occupancy-grade representation excluding identity + vitals: fine.
assert!(validate_representation(
&rep(PrivacyClass::P2, &["identity", "vitals"]),
SensingPurpose::Presence
)
.is_ok());
// Same purpose but the representation forgot to exclude identity: deny.
assert!(validate_representation(
&rep(PrivacyClass::P2, &["vitals"]),
SensingPurpose::Presence
)
.is_err());
// Class above the purpose ceiling: deny.
assert!(validate_representation(
&rep(PrivacyClass::P4, &["identity", "vitals"]),
SensingPurpose::Presence
)
.is_err());
// No lineage: deny.
let mut orphan = rep(PrivacyClass::P2, &["identity", "vitals"]);
orphan.source_receipts.clear();
assert!(validate_representation(&orphan, SensingPurpose::Presence).is_err());
}
/// Only `Presence` was ever exercised above; the other three
/// ceiling/exclusion-set branches (Activity/Localization at P3,
/// Vitals/PoseTracking at P4, IdentityRecognition at P5) had zero test
/// coverage — a bug in any of them would go undetected.
#[test]
fn task_sufficient_representation_covers_every_purpose_branch() {
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
task_id: 6,
source_receipts: vec![1],
semantic_state: vec![0.2],
information_bound_bits: 4.0,
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
privacy_class: class,
};
for purpose in [SensingPurpose::Activity, SensingPurpose::Localization] {
// P3 ceiling excluding identity: fine.
assert!(validate_representation(&rep(PrivacyClass::P3, &["identity"]), purpose).is_ok());
// Forgot to exclude identity: deny.
assert!(validate_representation(&rep(PrivacyClass::P3, &[]), purpose).is_err());
// Above the P3 ceiling: deny.
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_err());
}
for purpose in [SensingPurpose::Vitals, SensingPurpose::PoseTracking] {
// P4 ceiling excluding identity: fine.
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_ok());
// Forgot to exclude identity: deny.
assert!(validate_representation(&rep(PrivacyClass::P4, &[]), purpose).is_err());
// Above the P4 ceiling: deny.
assert!(validate_representation(&rep(PrivacyClass::P5, &["identity"]), purpose).is_err());
}
// IdentityRecognition: P5 ceiling, nothing required to be excluded.
assert!(validate_representation(&rep(PrivacyClass::P5, &[]), SensingPurpose::IdentityRecognition)
.is_ok());
// Still bounded — no class exceeds P5, so exercise the lineage guard instead.
let mut orphan = rep(PrivacyClass::P5, &[]);
orphan.source_receipts.clear();
assert!(validate_representation(&orphan, SensingPurpose::IdentityRecognition).is_err());
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Universal RF foundation encoder (ADR-274 §3).
//!
//! A deliberately small, pure-Rust, exactly-differentiable network. The
//! representation contract is the one ADR-273 fixes:
//!
//! ```text
//! z = Encoder(tokens) ⊙ σ(AgeEncoder(age)) + GeometryEncoder(sensor_pose)
//! ```
//!
//! Architecture (all f64, weights row-major):
//!
//! ```text
//! h_i = tanh(W1·x_i + b1) token embedding (unmasked tokens)
//! c = mean_i h_i permutation-invariant context pool
//! m = tanh(W2·c + b2) context mixing 1
//! g = tanh(W2b·m + b2b) context mixing 2
//! gate = σ(age_w·age + age_b) multiplicative freshness gate
//! z = g ⊙ gate + Wg·geo + bg fused window representation
//! ```
//!
//! Pretraining (see [`crate::pretrain`]) reconstructs *masked* tokens from
//! `[z ; position_encoding(j)]` through a linear head `W3, b3` that is
//! discarded at deployment. The backward pass is hand-derived and verified
//! against central finite differences in `pretrain::tests` — the gradient
//! check is the crate's proof that this module computes what it claims.
use rand_chacha::ChaCha20Rng;
use crate::math::{sigmoid, xavier_init};
/// Age input transform for the freshness gate (ADR-281 §4, the age-aware
/// CSI recipe): `log(1 + sample_age_ms)` — log-scaling keeps millisecond
/// and multi-second staleness on comparable input scales.
#[must_use]
pub fn age_feature(age_s: f64) -> f64 {
(1.0 + age_s * 1000.0).ln()
}
use crate::tokenizer::{position_encoding, RfToken, TokenizedWindow, D_IN, D_POS};
/// Dense row-major matrix with a bias vector (one linear layer).
#[derive(Debug, Clone)]
pub struct Linear {
/// Output rows.
pub rows: usize,
/// Input columns.
pub cols: usize,
/// Row-major weights, `rows × cols`.
pub w: Vec<f64>,
/// Bias, length `rows`.
pub b: Vec<f64>,
}
impl Linear {
/// Xavier-initialized layer.
#[must_use]
pub fn new(rng: &mut ChaCha20Rng, rows: usize, cols: usize) -> Self {
Self { rows, cols, w: xavier_init(rng, rows, cols), b: vec![0.0; rows] }
}
/// Zeroed layer with the same shape (gradient accumulator).
#[must_use]
pub fn zeros_like(&self) -> Self {
Self { rows: self.rows, cols: self.cols, w: vec![0.0; self.w.len()], b: vec![0.0; self.rows] }
}
/// `y = W·x + b`.
#[must_use]
pub fn forward(&self, x: &[f64]) -> Vec<f64> {
debug_assert_eq!(x.len(), self.cols);
let mut y = self.b.clone();
for r in 0..self.rows {
let row = &self.w[r * self.cols..(r + 1) * self.cols];
let mut acc = 0.0;
for (wv, xv) in row.iter().zip(x) {
acc += wv * xv;
}
y[r] += acc;
}
y
}
/// `x_grad = Wᵀ·dy` (input gradient).
#[must_use]
pub fn backward_input(&self, dy: &[f64]) -> Vec<f64> {
let mut dx = vec![0.0; self.cols];
for r in 0..self.rows {
let row = &self.w[r * self.cols..(r + 1) * self.cols];
for (c, wv) in row.iter().enumerate() {
dx[c] += wv * dy[r];
}
}
dx
}
/// Accumulates `dW += dy ⊗ x`, `db += dy` into `grad`.
pub fn accumulate_grad(&self, grad: &mut Linear, dy: &[f64], x: &[f64]) {
for r in 0..self.rows {
grad.b[r] += dy[r];
let row = &mut grad.w[r * self.cols..(r + 1) * self.cols];
for (c, xv) in x.iter().enumerate() {
row[c] += dy[r] * xv;
}
}
}
/// SGD step: `p -= lr·g`.
pub fn sgd(&mut self, grad: &Linear, lr: f64) {
for (p, g) in self.w.iter_mut().zip(&grad.w) {
*p -= lr * g;
}
for (p, g) in self.b.iter_mut().zip(&grad.b) {
*p -= lr * g;
}
}
/// Number of parameters (weights + biases).
#[must_use]
pub fn param_count(&self) -> usize {
self.w.len() + self.b.len()
}
}
/// Encoder hyper-parameters. The default is the deployment config; tests use
/// tiny configs for the finite-difference gradient check.
#[derive(Debug, Clone, Copy)]
pub struct EncoderConfig {
/// Token feature dimension.
pub d_in: usize,
/// Position-encoding dimension (pretraining decoder only).
pub d_pos: usize,
/// Model (representation) dimension.
pub d_model: usize,
}
impl Default for EncoderConfig {
fn default() -> Self {
Self { d_in: D_IN, d_pos: D_POS, d_model: 128 }
}
}
/// Window-level context consumed by the fusion stage.
#[derive(Debug, Clone, Copy)]
pub struct WindowContext {
/// Sample age in seconds.
pub age_s: f64,
/// Geometry summary (mean TX xyz, mean RX xyz, decametres).
pub geometry: [f64; 6],
}
impl From<&TokenizedWindow> for WindowContext {
fn from(w: &TokenizedWindow) -> Self {
Self { age_s: w.age_s, geometry: w.geometry }
}
}
/// Intermediate activations kept for the backward pass.
#[derive(Debug, Clone)]
pub struct ForwardCache {
/// Indices of unmasked tokens (context providers).
pub unmasked: Vec<usize>,
/// Embeddings `h_i` for unmasked tokens (parallel to `unmasked`).
pub h: Vec<Vec<f64>>,
/// Pooled context `c`.
pub c: Vec<f64>,
/// Mixing activations `m`, `g`.
pub m: Vec<f64>,
/// Second mixing output.
pub g: Vec<f64>,
/// Freshness gate `σ(age_w·age + age_b)`.
pub gate: Vec<f64>,
/// Fused representation `z`.
pub z: Vec<f64>,
/// Window context used.
pub ctx: WindowContext,
}
/// The universal RF foundation encoder.
#[derive(Debug, Clone)]
pub struct RfEncoder {
/// Config.
pub cfg: EncoderConfig,
/// Token embedding.
pub w1: Linear,
/// Context mixing 1.
pub w2: Linear,
/// Context mixing 2.
pub w2b: Linear,
/// Age gate weight (elementwise on the scalar age).
pub age_w: Vec<f64>,
/// Age gate bias.
pub age_b: Vec<f64>,
/// Geometry encoder.
pub wg: Linear,
/// Masked-token reconstruction head (pretraining only; not counted as a
/// deployment adapter).
pub w3: Linear,
}
impl RfEncoder {
/// Deterministically initialized encoder.
#[must_use]
pub fn new(cfg: EncoderConfig, seed: u64) -> Self {
let mut rng = crate::math::seeded_rng(seed);
let h = cfg.d_model;
Self {
cfg,
w1: Linear::new(&mut rng, h, cfg.d_in),
w2: Linear::new(&mut rng, h, h),
w2b: Linear::new(&mut rng, h, h),
age_w: xavier_init(&mut rng, h, 1),
age_b: vec![0.0; h],
wg: Linear::new(&mut rng, h, 6),
w3: Linear::new(&mut rng, cfg.d_in, h + cfg.d_pos),
}
}
/// Total trainable parameters (the "backbone" for the ≤1 % adapter
/// budget of ADR-273's acceptance test).
#[must_use]
pub fn param_count(&self) -> usize {
self.w1.param_count()
+ self.w2.param_count()
+ self.w2b.param_count()
+ self.age_w.len()
+ self.age_b.len()
+ self.wg.param_count()
+ self.w3.param_count()
}
/// Forward pass over the unmasked token set, producing the fused window
/// representation `z` and the cache needed for backprop.
///
/// `masked` lists token indices excluded from the context pool (empty at
/// inference time).
#[must_use]
pub fn forward(&self, tokens: &[RfToken], masked: &[usize], ctx: WindowContext) -> ForwardCache {
let h_dim = self.cfg.d_model;
let unmasked: Vec<usize> =
(0..tokens.len()).filter(|i| !masked.contains(i)).collect();
assert!(!unmasked.is_empty(), "cannot encode a fully masked window");
let mut h = Vec::with_capacity(unmasked.len());
let mut c = vec![0.0; h_dim];
for &i in &unmasked {
let mut hi = self.w1.forward(&tokens[i].features);
for v in &mut hi {
*v = v.tanh();
}
for (cv, hv) in c.iter_mut().zip(&hi) {
*cv += hv;
}
h.push(hi);
}
for cv in &mut c {
*cv /= unmasked.len() as f64;
}
let mut m = self.w2.forward(&c);
for v in &mut m {
*v = v.tanh();
}
let mut g = self.w2b.forward(&m);
for v in &mut g {
*v = v.tanh();
}
let age_feat = age_feature(ctx.age_s);
let gate: Vec<f64> = self
.age_w
.iter()
.zip(&self.age_b)
.map(|(w, b)| sigmoid(w * age_feat + b))
.collect();
let geo = self.wg.forward(&ctx.geometry);
let z: Vec<f64> =
(0..h_dim).map(|k| g[k] * gate[k] + geo[k]).collect();
ForwardCache { unmasked, h, c, m, g, gate, z, ctx }
}
/// Inference entry point: encode a full window (nothing masked) into the
/// fused representation `z = g ⊙ gate + Wg·geo + bg`.
///
/// Use `z` for geometry-conditioned tasks (localization, channel
/// prediction) where sensor pose is signal, not nuisance.
#[must_use]
pub fn encode(&self, window: &TokenizedWindow) -> Vec<f64> {
self.forward(&window.tokens, &[], WindowContext::from(window)).z
}
/// Environment-invariant content representation
/// `[g ⊙ gate ; mean_i(x_i)]` — the fused `z` *without* the additive
/// geometry term, concatenated with the window-mean token features
/// (dimension `d_model + d_in`).
///
/// Two deliberate choices, both anti-leakage (ADR-273 §5):
/// - The PerceptAlign lesson: geometry should *condition* spatial tasks,
/// but for environment-invariant heads (presence, activity, anomaly)
/// the additive `Wg·geo` term is a room-specific offset a linear
/// adapter would memorize.
/// - The skip connection exposes pooled token statistics (Doppler
/// energy, temporal variance, freshness) whose *semantics are
/// identical in every room*, so a small head can generalize across
/// environments instead of re-deriving them through mixing layers that
/// entangle them with room-specific fading structure.
#[must_use]
pub fn encode_content(&self, window: &TokenizedWindow) -> Vec<f64> {
let cache = self.forward(&window.tokens, &[], WindowContext::from(window));
let mut out: Vec<f64> =
(0..self.cfg.d_model).map(|k| cache.g[k] * cache.gate[k]).collect();
let n = window.tokens.len().max(1) as f64;
let mut mean = vec![0.0; self.cfg.d_in];
for t in &window.tokens {
for (m, v) in mean.iter_mut().zip(&t.features) {
*m += v / n;
}
}
out.extend_from_slice(&mean);
out
}
/// Dimension of the [`Self::encode_content`] representation.
#[must_use]
pub fn content_dim(&self) -> usize {
self.cfg.d_model + self.cfg.d_in
}
/// Reconstruction of masked token `j` from the cache: `W3·[z ; pos(j)]`.
#[must_use]
pub fn reconstruct(&self, cache: &ForwardCache, token_idx: usize) -> Vec<f64> {
let mut u = cache.z.clone();
u.extend_from_slice(&position_encoding(token_idx)[..self.cfg.d_pos]);
self.w3.forward(&u)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn toy_tokens(n: usize) -> Vec<RfToken> {
(0..n)
.map(|i| {
let mut f = [0.0f64; D_IN];
for (k, v) in f.iter_mut().enumerate() {
*v = ((i * 31 + k * 7) % 13) as f64 / 13.0 - 0.5;
}
RfToken { features: f, link: 0, group: i }
})
.collect()
}
#[test]
fn encoder_is_deterministic_given_seed() {
let a = RfEncoder::new(EncoderConfig::default(), 42);
let b = RfEncoder::new(EncoderConfig::default(), 42);
assert_eq!(a.w1.w, b.w1.w);
let tokens = toy_tokens(6);
let ctx = WindowContext { age_s: 0.1, geometry: [0.1; 6] };
assert_eq!(a.forward(&tokens, &[], ctx).z, b.forward(&tokens, &[], ctx).z);
}
#[test]
fn param_count_matches_hand_computation() {
let e = RfEncoder::new(EncoderConfig::default(), 1);
let h = 128;
let expected = (h * D_IN + h) // w1
+ (h * h + h) // w2
+ (h * h + h) // w2b
+ h + h // age_w, age_b
+ (h * 6 + h) // wg
+ (D_IN * (h + D_POS) + D_IN); // w3
assert_eq!(e.param_count(), expected);
}
#[test]
fn stale_windows_are_gated_toward_geometry_prior() {
// As age → ∞ with negative gate logits, σ → 0 or 1 per unit; what we
// verify is the *contract*: z depends on age only through the gate,
// so two ages produce different z while geometry contribution stays.
let e = RfEncoder::new(EncoderConfig::default(), 3);
let tokens = toy_tokens(8);
let fresh = e.forward(&tokens, &[], WindowContext { age_s: 0.0, geometry: [0.2; 6] });
let stale = e.forward(&tokens, &[], WindowContext { age_s: 9.0, geometry: [0.2; 6] });
assert_ne!(fresh.z, stale.z);
// Same age, different geometry ⇒ additive path shifts z.
let moved = e.forward(&tokens, &[], WindowContext { age_s: 0.0, geometry: [0.4; 6] });
assert_ne!(fresh.z, moved.z);
}
#[test]
fn masking_excludes_tokens_from_context() {
let e = RfEncoder::new(EncoderConfig::default(), 5);
let tokens = toy_tokens(8);
let ctx = WindowContext { age_s: 0.1, geometry: [0.0; 6] };
let full = e.forward(&tokens, &[], ctx);
let masked = e.forward(&tokens, &[2, 5], ctx);
assert_eq!(masked.unmasked.len(), 6);
assert_ne!(full.z, masked.z);
}
}
+404
View File
@@ -0,0 +1,404 @@
//! Anti-leakage evaluation protocol (ADR-273 §5).
//!
//! The biggest failure mode of RF sensing results is domain leakage
//! disguised as accuracy: random frame splits let a model recognize the
//! room, session, person, device, or trajectory instead of learning
//! transferable physics. The fix is structural, not statistical:
//!
//! - [`StrictSplit`] holds out **complete values** of a partition dimension
//! (room / day / person / chipset / firmware / antenna layout) and proves
//! the train and test sides share none of them.
//! - [`expected_calibration_error`] and [`selective_metrics`] make
//! confidence quality and abstention first-class metrics: for
//! safety-relevant uses an uncertain result must become *no decision*.
//! - [`relative_degradation`] tracks known→unknown condition degradation
//! (the ADR-273 acceptance gate is < 20 %).
use std::collections::BTreeSet;
/// Provenance key of one sample — every dimension that could leak identity
/// or environment structure into a random split.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PartitionKey {
/// Room / environment identifier.
pub room: String,
/// Capture day (coarse session time).
pub day: String,
/// Person identifier (or "none").
pub person: String,
/// Chipset family.
pub chipset: String,
/// Firmware version.
pub firmware: String,
/// Antenna layout identifier.
pub layout: String,
/// Capture session identifier (packet-session leakage is as real as
/// room leakage — ADR-279 §4 split manifest).
pub session: String,
}
/// Which dimension of [`PartitionKey`] a split holds out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PartitionDim {
/// Hold out complete rooms.
Room,
/// Hold out complete days.
Day,
/// Hold out complete people.
Person,
/// Hold out complete chipsets.
Chipset,
/// Hold out complete firmware versions.
Firmware,
/// Hold out complete antenna layouts.
Layout,
/// Hold out complete capture sessions.
Session,
}
impl PartitionDim {
fn value<'a>(&self, k: &'a PartitionKey) -> &'a str {
match self {
Self::Room => &k.room,
Self::Day => &k.day,
Self::Person => &k.person,
Self::Chipset => &k.chipset,
Self::Firmware => &k.firmware,
Self::Layout => &k.layout,
Self::Session => &k.session,
}
}
/// All partition dimensions, for exhaustive manifest checks.
pub const ALL: [Self; 7] = [
Self::Room,
Self::Day,
Self::Person,
Self::Chipset,
Self::Firmware,
Self::Layout,
Self::Session,
];
}
/// The mandatory split manifest (ADR-279 §4): per-dimension disjointness
/// certificates for a train/test split. A result is only reportable as
/// leakage-resistant along the dimensions this manifest certifies.
#[derive(Debug, Clone)]
pub struct SplitManifest {
/// `(dimension, train∩test == ∅)` for every partition dimension.
pub disjoint: Vec<(PartitionDim, bool)>,
}
impl SplitManifest {
/// Builds the manifest for an arbitrary index split.
#[must_use]
pub fn build(keys: &[PartitionKey], train: &[usize], test: &[usize]) -> Self {
let disjoint = PartitionDim::ALL
.iter()
.map(|dim| {
let train_vals: BTreeSet<&str> =
train.iter().map(|&i| dim.value(&keys[i])).collect();
let test_vals: BTreeSet<&str> =
test.iter().map(|&i| dim.value(&keys[i])).collect();
(*dim, train_vals.is_disjoint(&test_vals))
})
.collect();
Self { disjoint }
}
/// True iff the given dimension is certified disjoint.
#[must_use]
pub fn is_disjoint(&self, dim: PartitionDim) -> bool {
self.disjoint.iter().any(|(d, ok)| *d == dim && *ok)
}
/// True iff every dimension is disjoint (the full anti-leakage bar:
/// `train_rooms ∩ test_rooms = ∅` … `train_sessions ∩ test_sessions = ∅`).
#[must_use]
pub fn fully_disjoint(&self) -> bool {
self.disjoint.iter().all(|(_, ok)| *ok)
}
}
/// Mean per-joint position error (metres) between two joint sets.
///
/// # Panics
/// If the slices have different lengths or are empty.
#[must_use]
pub fn mpjpe(pred: &[[f64; 3]], truth: &[[f64; 3]]) -> f64 {
assert_eq!(pred.len(), truth.len());
assert!(!pred.is_empty());
pred.iter()
.zip(truth)
.map(|(p, t)| ((p[0] - t[0]).powi(2) + (p[1] - t[1]).powi(2) + (p[2] - t[2]).powi(2)).sqrt())
.sum::<f64>()
/ pred.len() as f64
}
/// A train/test index split with a proof-of-disjointness certificate.
#[derive(Debug, Clone)]
pub struct StrictSplit {
/// Training sample indices.
pub train: Vec<usize>,
/// Held-out test sample indices.
pub test: Vec<usize>,
/// Dimension that was held out.
pub dim: PartitionDim,
}
impl StrictSplit {
/// Splits by holding out every sample whose `dim` value is in
/// `holdout_values`. Guaranteed disjoint by construction; [`Self::verify`]
/// re-checks it independently (belt and braces for downstream callers
/// that mutate splits).
#[must_use]
pub fn holdout(keys: &[PartitionKey], dim: PartitionDim, holdout_values: &[&str]) -> Self {
let held: BTreeSet<&str> = holdout_values.iter().copied().collect();
let mut train = Vec::new();
let mut test = Vec::new();
for (i, k) in keys.iter().enumerate() {
if held.contains(dim.value(k)) {
test.push(i);
} else {
train.push(i);
}
}
Self { train, test, dim }
}
/// Independently verifies that no held-out dimension value appears on
/// the training side (and vice versa). Returns `false` on any leak.
#[must_use]
pub fn verify(&self, keys: &[PartitionKey]) -> bool {
let train_vals: BTreeSet<&str> =
self.train.iter().map(|&i| self.dim.value(&keys[i])).collect();
let test_vals: BTreeSet<&str> =
self.test.iter().map(|&i| self.dim.value(&keys[i])).collect();
train_vals.is_disjoint(&test_vals)
}
}
/// Expected Calibration Error over equal-width confidence bins.
///
/// `probs[i]` is the predicted probability of the positive class;
/// `labels[i]` the truth. ECE = Σ (|bin|/N) · |accuracy(bin) confidence(bin)|.
#[must_use]
pub fn expected_calibration_error(probs: &[f64], labels: &[bool], n_bins: usize) -> f64 {
assert_eq!(probs.len(), labels.len());
assert!(n_bins > 0);
let n = probs.len();
if n == 0 {
return 0.0;
}
let mut ece = 0.0;
for b in 0..n_bins {
let lo = b as f64 / n_bins as f64;
let hi = (b + 1) as f64 / n_bins as f64;
let mut count = 0usize;
let mut conf_sum = 0.0;
let mut acc_sum = 0.0;
for (p, &y) in probs.iter().zip(labels) {
// Confidence of the *predicted* class.
let (conf, pred) = if *p >= 0.5 { (*p, true) } else { (1.0 - *p, false) };
let in_bin = if b == n_bins - 1 { conf >= lo && conf <= hi } else { conf >= lo && conf < hi };
if in_bin {
count += 1;
conf_sum += conf;
acc_sum += f64::from(pred == y);
}
}
if count > 0 {
let cf = count as f64;
ece += (cf / n as f64) * ((acc_sum / cf) - (conf_sum / cf)).abs();
}
}
ece
}
/// Coverage/selective-risk pair at a confidence threshold `tau`: predictions
/// with confidence below `tau` abstain (become *no decision*).
#[derive(Debug, Clone, Copy)]
pub struct SelectiveMetrics {
/// Fraction of samples on which a decision was made.
pub coverage: f64,
/// Error rate among decided samples (0 when nothing decided).
pub selective_risk: f64,
}
/// Computes coverage and selective risk at threshold `tau` ∈ [0.5, 1].
#[must_use]
pub fn selective_metrics(probs: &[f64], labels: &[bool], tau: f64) -> SelectiveMetrics {
assert_eq!(probs.len(), labels.len());
let mut decided = 0usize;
let mut wrong = 0usize;
for (p, &y) in probs.iter().zip(labels) {
let (conf, pred) = if *p >= 0.5 { (*p, true) } else { (1.0 - *p, false) };
if conf >= tau {
decided += 1;
if pred != y {
wrong += 1;
}
}
}
SelectiveMetrics {
coverage: decided as f64 / probs.len().max(1) as f64,
selective_risk: if decided == 0 { 0.0 } else { wrong as f64 / decided as f64 },
}
}
/// Relative degradation from a known-condition metric to an
/// unknown-condition metric (higher-is-better metrics such as F1).
/// ADR-273's acceptance gate: `< 0.20`.
#[must_use]
pub fn relative_degradation(known: f64, unknown: f64) -> f64 {
if known <= 0.0 {
return 0.0;
}
(known - unknown) / known
}
/// Binary F1 score.
#[must_use]
pub fn f1_score(predictions: &[bool], labels: &[bool]) -> f64 {
assert_eq!(predictions.len(), labels.len());
let mut tp = 0.0;
let mut fp = 0.0;
let mut fn_ = 0.0;
for (&p, &y) in predictions.iter().zip(labels) {
match (p, y) {
(true, true) => tp += 1.0,
(true, false) => fp += 1.0,
(false, true) => fn_ += 1.0,
(false, false) => {}
}
}
if tp == 0.0 {
return 0.0;
}
let precision = tp / (tp + fp);
let recall = tp / (tp + fn_);
2.0 * precision * recall / (precision + recall)
}
#[cfg(test)]
mod tests {
use super::*;
fn keys() -> Vec<PartitionKey> {
let mut out = Vec::new();
for room in ["room-a", "room-b", "room-c"] {
for day in ["d1", "d2"] {
for person in ["p1", "p2"] {
out.push(PartitionKey {
room: room.into(),
day: day.into(),
person: person.into(),
chipset: "esp32s3".into(),
firmware: "v1.2".into(),
layout: "L".into(),
session: format!("{room}-{day}-{person}"),
});
}
}
}
out
}
#[test]
fn strict_split_holds_out_complete_rooms() {
let keys = keys();
let split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
assert_eq!(split.test.len(), 4);
assert_eq!(split.train.len(), 8);
assert!(split.verify(&keys), "disjointness certificate must hold");
for &i in &split.test {
assert_eq!(keys[i].room, "room-c");
}
}
#[test]
fn verify_catches_a_manufactured_leak() {
let keys = keys();
let mut split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
// Manufacture a leak: push a room-c sample into training.
split.train.push(split.test[0]);
assert!(!split.verify(&keys), "leak must be detected");
}
#[test]
fn split_manifest_certifies_per_dimension_disjointness() {
let keys = keys();
let split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
let manifest = SplitManifest::build(&keys, &split.train, &split.test);
// Rooms are disjoint (and sessions, which embed the room)…
assert!(manifest.is_disjoint(PartitionDim::Room));
assert!(manifest.is_disjoint(PartitionDim::Session));
// …but people/days/chipsets are shared, and the manifest says so
// instead of letting the split masquerade as fully leakage-free.
assert!(!manifest.is_disjoint(PartitionDim::Person));
assert!(!manifest.is_disjoint(PartitionDim::Chipset));
assert!(!manifest.fully_disjoint());
}
#[test]
fn mpjpe_basics() {
let a = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
let b = [[0.0, 0.0, 0.1], [1.0, 0.0, 0.0]];
assert!((mpjpe(&a, &b) - 0.05).abs() < 1e-12);
}
#[test]
fn ece_is_low_for_calibrated_and_high_for_overconfident() {
// Calibrated: p = 0.8 predictions that are right 80 % of the time.
let mut probs = Vec::new();
let mut labels = Vec::new();
for i in 0..100 {
probs.push(0.8);
labels.push(i % 10 < 8);
}
let ece = expected_calibration_error(&probs, &labels, 10);
assert!(ece < 0.02, "calibrated ECE should be tiny, got {ece}");
// Overconfident: p = 0.99 but only 60 % correct.
let mut probs = Vec::new();
let mut labels = Vec::new();
for i in 0..100 {
probs.push(0.99);
labels.push(i % 10 < 6);
}
let ece = expected_calibration_error(&probs, &labels, 10);
assert!(ece > 0.3, "overconfident ECE should be large, got {ece}");
}
#[test]
fn abstention_trades_coverage_for_risk() {
// Confident predictions are correct; near-0.5 ones are coin flips.
let mut probs = Vec::new();
let mut labels = Vec::new();
for i in 0..50 {
probs.push(0.95);
labels.push(true);
probs.push(0.55);
labels.push(i % 2 == 0); // half wrong at low confidence
}
let loose = selective_metrics(&probs, &labels, 0.5);
let strict = selective_metrics(&probs, &labels, 0.9);
assert!(strict.coverage < loose.coverage);
assert!(
strict.selective_risk < loose.selective_risk,
"raising the threshold must lower risk: {strict:?} vs {loose:?}"
);
assert!((strict.selective_risk - 0.0).abs() < 1e-12);
}
#[test]
fn f1_and_degradation_basics() {
let preds = [true, true, false, true];
let labels = [true, false, false, true];
// tp=2 fp=1 fn=0 → precision 2/3, recall 1 → F1 = 0.8.
assert!((f1_score(&preds, &labels) - 0.8).abs() < 1e-12);
assert!((relative_degradation(0.95, 0.85) - 0.105_263).abs() < 1e-4);
assert!((relative_degradation(0.0, 0.5)).abs() < 1e-12);
}
}
+661
View File
@@ -0,0 +1,661 @@
//! Native RF frame contract — `RfFrameV2` (ADR-279).
//!
//! The ADR-273 P1 canonical tensor (`RfTensor`, 56 bins × 8 snapshots) is
//! useful for compatibility, but it must **not** be the authoritative data
//! format: resampling every device into one fixed tensor discards
//! bandwidth, antenna, phase-state, and hardware-specific information.
//! `RfFrameV2` preserves the **native complex tensor** with explicit
//! validity masks, phase state, geometry, calibration, quality, and
//! provenance; the canonical tensor is demoted to a *derived view*
//! ([`RfFrameV2::to_canonical`]) computed on demand and never written back.
//!
//! Required invariants (ADR-279 §2, each enforced by a constructor check or
//! a test):
//! 1. Native complex samples are never overwritten by normalized samples
//! (`to_canonical` takes `&self`; test proves byte-stability).
//! 2. Subcarrier/antenna masks are explicit (`valid_mask`).
//! 3. Phase declares its state: raw, sanitized, calibrated, or unavailable.
//! 4. TX/RX geometry uses one building coordinate frame (`Pose3`).
//! 5. Results retain source frame ids + model version (via `receipt_id`).
//! 6. Synthetic and measured frames can never share a provenance class,
//! and a synthetic frame can never claim evidence above L0.
//! 7. Sample age is carried through the whole inference path.
use num_complex::Complex64;
use serde::{Deserialize, Serialize};
use crate::tensor::{LinkGeometry, RfModality, RfTensor};
use crate::{Result, UnifiedError};
/// Current schema version of [`RfFrameV2`].
pub const SCHEMA_VERSION: u16 = 2;
/// A pose in the building coordinate frame (metres, unit quaternion).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Pose3 {
/// Position `[x, y, z]`, metres.
pub position_m: [f64; 3],
/// Orientation quaternion `[w, x, y, z]`.
pub orientation: [f64; 4],
}
/// One antenna element of an array.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AntennaElement {
/// Element position relative to the device pose, metres.
pub position_m: [f64; 3],
/// Element gain, dBi.
pub gain_dbi: f64,
}
/// Declared state of the phase axis — consumers must branch on this
/// instead of guessing whether detrending already happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PhaseState {
/// As captured; CFO/STO artifacts present.
Raw,
/// Linear ramp + constant offset removed (ADR-274 stage 3).
Sanitized,
/// Hardware/baseline calibrated upstream.
Calibrated,
/// Magnitude-only capture (e.g. some vendor RSSI/BF reports).
Unavailable,
}
/// Calibration state carried by every native frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CalibrationState {
/// Phase axis state.
pub phase_state: PhaseState,
/// Whether amplitude gain has been calibrated.
pub gain_calibrated: bool,
/// Oscillator drift, ppm.
pub clock_ppm: f64,
/// Empty-room baseline applied, if any (ADR-135).
pub baseline_id: Option<String>,
/// Calibration confidence in `[0, 1]`.
pub confidence: f64,
}
/// Front-end quality indicators.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SignalQuality {
/// Received signal strength, dBm.
pub rssi_dbm: f64,
/// Noise floor, dBm.
pub noise_floor_dbm: f64,
/// Fraction of expected packets lost in the capture window `[0, 1]`.
pub packet_loss: f64,
/// Interference score `[0, 1]` (0 = clean).
pub interference: f64,
}
/// Whether the evidence is measured or synthetic. The two classes can
/// never alias: there is no third variant and no default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvenanceClass {
/// Captured from real hardware.
Measured,
/// Produced by a simulator/generator (ADR-276).
Synthetic,
}
/// The public evidence ladder (ADR-282 §4): every capability and every
/// dataset carries exactly one level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum EvidenceLevel {
/// Level 0 — simulation only.
L0Simulation,
/// Level 1 — captured replay of real signals.
L1CapturedReplay,
/// Level 2 — controlled laboratory.
L2Lab,
/// Level 3 — held-out room and subject validation.
L3HeldOutValidation,
/// Level 4 — multi-site field pilot.
L4MultisiteField,
/// Level 5 — production operational evidence.
L5Production,
}
/// Frame provenance: class + evidence level + source identity.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrameProvenance {
/// Measured vs synthetic (invariant 6).
pub class: ProvenanceClass,
/// Evidence-ladder level.
pub evidence: EvidenceLevel,
/// Capturing device identifier.
pub device_id: String,
/// Firmware version string.
pub firmware: String,
/// Receipt id linking results back to this frame (invariant 5).
pub receipt_id: u128,
}
/// Native axes a frame's tensor may be laid out over (delay-Doppler-native
/// modalities such as OTFS ISAC must not be collapsed into scalar motion
/// energy before storage — ADR-281 §3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FieldAxis {
/// Sample time.
Time,
/// Subcarrier / frequency.
Frequency,
/// Delay (multipath arrival).
Delay,
/// Doppler shift.
Doppler,
/// Radar range bin.
Range,
/// Azimuth angle.
Azimuth,
/// Elevation angle.
Elevation,
/// Antenna element.
Antenna,
/// Polarization.
Polarization,
}
/// The authoritative native RF frame (ADR-279 §2).
#[derive(Debug, Clone)]
pub struct RfFrameV2 {
/// Schema version ([`SCHEMA_VERSION`]).
pub schema_version: u16,
/// Unique frame id.
pub frame_id: u128,
/// Capture timestamp, ns since epoch.
pub timestamp_ns: u64,
/// Modality.
pub modality: RfModality,
/// Native axis semantics, one entry per dimension of `native_shape`.
pub axes: Vec<FieldAxis>,
/// Carrier centre frequency, Hz.
pub centre_frequency_hz: f64,
/// Occupied bandwidth, Hz.
pub bandwidth_hz: f64,
/// Native sample rate along the time-like axis, Hz.
pub sample_rate_hz: f64,
/// Native tensor shape (arbitrary rank), row-major over `native_iq`.
pub native_shape: Vec<usize>,
/// Native complex samples — **never overwritten** (invariant 1).
pub native_iq: Vec<Complex64>,
/// Per-sample validity mask (invariant 2), same length as `native_iq`.
pub valid_mask: Vec<bool>,
/// Transmitter pose in the building frame, when known.
pub transmitter_pose: Option<Pose3>,
/// Receiver pose in the building frame, when known.
pub receiver_pose: Option<Pose3>,
/// Antenna elements of the capturing array.
pub antenna_geometry: Vec<AntennaElement>,
/// Age of the capture at hand-off, ns (invariant 7).
pub sample_age_ns: u64,
/// Calibration state (invariant 3).
pub calibration: CalibrationState,
/// Signal quality.
pub quality: SignalQuality,
/// Provenance (invariants 56).
pub provenance: FrameProvenance,
}
impl RfFrameV2 {
/// Validated constructor — the only way to build a native frame.
///
/// Enforced here: shape/product/mask arity, finite samples on valid
/// positions, positive frequencies, axes rank match, and the
/// provenance-class ⇄ evidence-level consistency rule:
/// `Synthetic ⇒ exactly L0Simulation`, `Measured ⇒ at least
/// L1CapturedReplay` — so synthetic evidence can never masquerade as
/// field evidence, and vice versa (invariant 6).
#[allow(clippy::too_many_arguments)]
pub fn new(
frame_id: u128,
timestamp_ns: u64,
modality: RfModality,
axes: Vec<FieldAxis>,
centre_frequency_hz: f64,
bandwidth_hz: f64,
sample_rate_hz: f64,
native_shape: Vec<usize>,
native_iq: Vec<Complex64>,
valid_mask: Vec<bool>,
transmitter_pose: Option<Pose3>,
receiver_pose: Option<Pose3>,
antenna_geometry: Vec<AntennaElement>,
sample_age_ns: u64,
calibration: CalibrationState,
quality: SignalQuality,
provenance: FrameProvenance,
) -> Result<Self> {
let expected: usize = native_shape.iter().product();
if native_shape.is_empty() || expected == 0 {
return Err(UnifiedError::ShapeMismatch("empty native shape".into()));
}
if native_iq.len() != expected {
return Err(UnifiedError::ShapeMismatch(format!(
"native_iq has {} samples, shape {:?} implies {expected}",
native_iq.len(),
native_shape
)));
}
if valid_mask.len() != expected {
return Err(UnifiedError::ShapeMismatch(format!(
"valid_mask has {} entries, expected {expected}",
valid_mask.len()
)));
}
if axes.len() != native_shape.len() {
return Err(UnifiedError::ShapeMismatch(format!(
"{} axes declared for rank-{} tensor",
axes.len(),
native_shape.len()
)));
}
if !(centre_frequency_hz.is_finite()
&& centre_frequency_hz > 0.0
&& bandwidth_hz.is_finite()
&& bandwidth_hz > 0.0
&& sample_rate_hz.is_finite()
&& sample_rate_hz > 0.0)
{
return Err(UnifiedError::InvalidInput(
"frequencies and sample rate must be finite and positive".into(),
));
}
for (z, ok) in native_iq.iter().zip(&valid_mask) {
if *ok && (!z.re.is_finite() || !z.im.is_finite()) {
return Err(UnifiedError::InvalidInput(
"non-finite sample marked valid".into(),
));
}
}
if !(0.0..=1.0).contains(&calibration.confidence) {
return Err(UnifiedError::InvalidInput(
"calibration confidence must be in [0,1]".into(),
));
}
match (provenance.class, provenance.evidence) {
(ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation) => {}
(ProvenanceClass::Synthetic, level) => {
return Err(UnifiedError::InvalidInput(format!(
"synthetic frames are L0Simulation by definition, got {level:?}"
)));
}
(ProvenanceClass::Measured, EvidenceLevel::L0Simulation) => {
return Err(UnifiedError::InvalidInput(
"measured frames cannot claim L0Simulation".into(),
));
}
(ProvenanceClass::Measured, _) => {}
}
Ok(Self {
schema_version: SCHEMA_VERSION,
frame_id,
timestamp_ns,
modality,
axes,
centre_frequency_hz,
bandwidth_hz,
sample_rate_hz,
native_shape,
native_iq,
valid_mask,
transmitter_pose,
receiver_pose,
antenna_geometry,
sample_age_ns,
calibration,
quality,
provenance,
})
}
/// Fraction of valid samples.
#[must_use]
pub fn valid_fraction(&self) -> f64 {
self.valid_mask.iter().filter(|v| **v).count() as f64 / self.valid_mask.len() as f64
}
/// Derived compatibility view (ADR-279 §3): projects a rank-3
/// `(links, bins, snapshots)` native frame into the ADR-274 canonical
/// tensor. Invalid samples are filled by linear interpolation from the
/// nearest valid bins on the same `(link, snapshot)` column before
/// resampling. The native frame is untouched (`&self`).
pub fn to_canonical(&self, links: Vec<LinkGeometry>) -> Result<RfTensor> {
if self.native_shape.len() != 3 {
return Err(UnifiedError::ShapeMismatch(format!(
"canonical view needs a rank-3 (links, bins, snapshots) frame, got rank {}",
self.native_shape.len()
)));
}
let (n_links, n_bins, n_snaps) =
(self.native_shape[0], self.native_shape[1], self.native_shape[2]);
if links.len() != n_links {
return Err(UnifiedError::ShapeMismatch(format!(
"geometry for {} links, frame has {n_links}",
links.len()
)));
}
// Gap-fill invalid bins per (link, snapshot) column, then hand a
// dense grid to the shared normalization used by every adapter.
let mut grid = ndarray::Array3::zeros((n_links, n_bins, n_snaps));
for l in 0..n_links {
for s in 0..n_snaps {
let at = |b: usize| l * n_bins * n_snaps + b * n_snaps + s;
let valid: Vec<usize> = (0..n_bins).filter(|b| self.valid_mask[at(*b)]).collect();
if valid.is_empty() {
return Err(UnifiedError::InvalidInput(format!(
"link {l} snapshot {s} has no valid bins"
)));
}
for b in 0..n_bins {
let v = if self.valid_mask[at(b)] {
self.native_iq[at(b)]
} else {
// Nearest valid neighbors, linear on the complex plane.
let before = valid.iter().rev().find(|x| **x < b);
let after = valid.iter().find(|x| **x > b);
match (before, after) {
(Some(&lo), Some(&hi)) => {
let t = (b - lo) as f64 / (hi - lo) as f64;
self.native_iq[at(lo)] * (1.0 - t) + self.native_iq[at(hi)] * t
}
(Some(&lo), None) => self.native_iq[at(lo)],
(None, Some(&hi)) => self.native_iq[at(hi)],
(None, None) => unreachable!("valid is non-empty"),
}
};
grid[[l, b, s]] = v;
}
}
}
let uncertainty = {
let snr = self.quality.rssi_dbm - self.quality.noise_floor_dbm;
(1.0 - snr / 40.0).clamp(0.0, 1.0)
};
crate::adapters::normalize_grid(
self.modality,
grid,
links,
self.centre_frequency_hz,
self.bandwidth_hz,
self.sample_age_ns as f64 / 1e9,
self.timestamp_ns,
self.provenance.device_id.clone(),
(1.0 - self.calibration.clock_ppm / 40.0).clamp(0.0, 1.0),
uncertainty,
matches!(self.calibration.phase_state, PhaseState::Sanitized | PhaseState::Calibrated),
)
}
}
/// IEEE P3162-profile synthetic-aperture channel-sounding import
/// (ADR-281 §5): the calibration bridge between measured environments,
/// simulators, and learned RF scene models.
#[derive(Debug, Clone)]
pub struct SyntheticApertureSoundingDataset {
/// Sounded frequency range `[low, high]`, Hz.
pub frequency_range_hz: [f64; 2],
/// Aperture element poses (building frame).
pub aperture_geometry: Vec<Pose3>,
/// Directional power-delay profile, `(direction, delay)` row-major.
pub directional_pdp: Vec<f64>,
/// PDP shape.
pub pdp_shape: [usize; 2],
/// Coordinate system identifier (P3162 vocabulary).
pub coordinate_system: String,
/// Hash of the processing manifest that produced the dataset.
pub processing_manifest_hash: u64,
}
impl SyntheticApertureSoundingDataset {
/// Validated constructor.
pub fn new(
frequency_range_hz: [f64; 2],
aperture_geometry: Vec<Pose3>,
directional_pdp: Vec<f64>,
pdp_shape: [usize; 2],
coordinate_system: impl Into<String>,
processing_manifest_hash: u64,
) -> Result<Self> {
if !(frequency_range_hz[0] > 0.0 && frequency_range_hz[1] > frequency_range_hz[0]) {
return Err(UnifiedError::InvalidInput(format!(
"frequency range must be ordered and positive, got {frequency_range_hz:?}"
)));
}
if aperture_geometry.is_empty() {
return Err(UnifiedError::InvalidInput("empty aperture geometry".into()));
}
if directional_pdp.len() != pdp_shape[0] * pdp_shape[1] {
return Err(UnifiedError::ShapeMismatch(format!(
"PDP has {} entries, shape {pdp_shape:?} implies {}",
directional_pdp.len(),
pdp_shape[0] * pdp_shape[1]
)));
}
if directional_pdp.iter().any(|v| !v.is_finite() || *v < 0.0) {
return Err(UnifiedError::InvalidInput("PDP entries must be finite power".into()));
}
Ok(Self {
frequency_range_hz,
aperture_geometry,
directional_pdp,
pdp_shape,
coordinate_system: coordinate_system.into(),
processing_manifest_hash,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tensor::{CANONICAL_BINS, CANONICAL_SNAPSHOTS};
fn quality() -> SignalQuality {
SignalQuality { rssi_dbm: -45.0, noise_floor_dbm: -92.0, packet_loss: 0.02, interference: 0.05 }
}
fn calibration(phase: PhaseState) -> CalibrationState {
CalibrationState {
phase_state: phase,
gain_calibrated: false,
clock_ppm: 12.0,
baseline_id: None,
confidence: 0.8,
}
}
fn provenance(class: ProvenanceClass, evidence: EvidenceLevel) -> FrameProvenance {
FrameProvenance {
class,
evidence,
device_id: "esp32s3-a1".into(),
firmware: "fw-2.1".into(),
receipt_id: 42,
}
}
fn frame(shape: Vec<usize>, mask_off: &[usize]) -> RfFrameV2 {
let n: usize = shape.iter().product();
let iq: Vec<Complex64> = (0..n)
.map(|i| Complex64::new(1.0 + 0.01 * (i % 13) as f64, 0.002 * (i % 7) as f64))
.collect();
let mut mask = vec![true; n];
for &i in mask_off {
mask[i] = false;
}
RfFrameV2::new(
7,
1_000,
RfModality::WifiCsi,
vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time],
2.437e9,
20e6,
100.0,
shape,
iq,
mask,
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,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
)
.expect("valid frame")
}
#[test]
fn synthetic_and_measured_provenance_can_never_alias() {
let build = |class, evidence| {
RfFrameV2::new(
1,
0,
RfModality::Synthetic,
vec![FieldAxis::Frequency],
2.4e9,
20e6,
100.0,
vec![4],
vec![Complex64::new(1.0, 0.0); 4],
vec![true; 4],
None,
None,
vec![],
0,
calibration(PhaseState::Sanitized),
quality(),
provenance(class, evidence),
)
};
// Synthetic above L0 is refused.
assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L3HeldOutValidation).is_err());
// Measured claiming L0 is refused.
assert!(build(ProvenanceClass::Measured, EvidenceLevel::L0Simulation).is_err());
// The two legal pairings work.
assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation).is_ok());
assert!(build(ProvenanceClass::Measured, EvidenceLevel::L1CapturedReplay).is_ok());
}
#[test]
fn constructor_enforces_shape_mask_and_axes_arity() {
let n = 2 * 10 * 4;
let iq = vec![Complex64::new(1.0, 0.0); n];
let bad_mask = RfFrameV2::new(
1,
0,
RfModality::WifiCsi,
vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time],
2.4e9,
20e6,
100.0,
vec![2, 10, 4],
iq.clone(),
vec![true; n - 1],
None,
None,
vec![],
0,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
);
assert!(matches!(bad_mask, Err(UnifiedError::ShapeMismatch(_))));
let bad_axes = RfFrameV2::new(
1,
0,
RfModality::WifiCsi,
vec![FieldAxis::Frequency],
2.4e9,
20e6,
100.0,
vec![2, 10, 4],
iq,
vec![true; n],
None,
None,
vec![],
0,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
);
assert!(matches!(bad_axes, Err(UnifiedError::ShapeMismatch(_))));
}
#[test]
fn canonical_view_is_derived_and_native_is_untouched() {
// 114-subcarrier native with two masked-out bins.
let f = frame(vec![1, 114, 12], &[5 * 12, 60 * 12 + 3]);
let native_before = f.native_iq.clone();
let mask_before = f.valid_mask.clone();
let t = f
.to_canonical(vec![LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] }])
.expect("derived view");
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
assert!(t.data.iter().all(|z| z.re.is_finite() && z.im.is_finite()));
// Invariant 1: the native samples and mask are byte-identical after
// deriving the view — normalization never writes back.
assert_eq!(f.native_iq, native_before);
assert_eq!(f.valid_mask, mask_before);
assert!((f.valid_fraction() - (114.0 * 12.0 - 2.0) / (114.0 * 12.0)).abs() < 1e-12);
}
#[test]
fn canonical_view_rejects_wrong_rank_or_geometry() {
let f = frame(vec![2, 10, 4], &[]);
assert!(f.to_canonical(vec![]).is_err());
// Rank-1 frame has no canonical projection.
let flat = RfFrameV2::new(
9,
0,
RfModality::WifiCsi,
vec![FieldAxis::Frequency],
2.4e9,
20e6,
100.0,
vec![80],
vec![Complex64::new(1.0, 0.0); 80],
vec![true; 80],
None,
None,
vec![],
0,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
)
.expect("rank-1 frame is a valid native frame");
assert!(flat
.to_canonical(vec![LinkGeometry { tx_pos: [0.0; 3], rx_pos: [1.0, 0.0, 0.0] }])
.is_err());
}
#[test]
fn synthetic_aperture_profile_validates() {
let ok = SyntheticApertureSoundingDataset::new(
[3.0e9, 10.0e9],
vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }],
vec![0.5; 8 * 16],
[8, 16],
"P3162-spherical",
0xABCD,
);
assert!(ok.is_ok());
assert!(SyntheticApertureSoundingDataset::new(
[10.0e9, 3.0e9], // unordered
vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }],
vec![0.5; 4],
[2, 2],
"x",
0
)
.is_err());
}
}
@@ -0,0 +1,307 @@
//! Channel-gain queries against the Gaussian map, and the inverse update
//! that makes the map *learn* from measured links (ADR-275 §4).
//!
//! Physics model: free-space Friis amplitude with BeerLambert extinction
//! through the Gaussians,
//!
//! ```text
//! H(tx,rx,f) = (λ / 4πd) · e^{-j·2πd/λ} · exp(-Σ_g occ_g · I_g)
//! ```
//!
//! where `I_g = ∫₀ᴸ exp(-½·q_g(o + t·u)) dt` is the closed-form line
//! integral of Gaussian `g`'s unnormalized density along the TX→RX segment
//! (a 1-D Gaussian integral, evaluated with `erf`). Two exactness anchors
//! make this testable: an **empty map returns exact Friis**, and adding an
//! absorber strictly, monotonically reduces gain.
use num_complex::Complex64;
use super::map::GaussianMap;
use super::primitive::{Provenance, RfGaussian};
use crate::math::erf;
const C: f64 = 299_792_458.0;
/// Closed-form line integral of `exp(-½·(x-μ)ᵀΣ⁻¹(x-μ))` along the segment
/// `o → o + L·u` (`u` unit).
///
/// With `a = uᵀΣ⁻¹u`, `b = uᵀΣ⁻¹(μ−o)`, `c = (μ−o)ᵀΣ⁻¹(μ−o)`:
/// `q(t) = a·(t b/a)² + (c b²/a)`, so
/// `I = e^{-(cb²/a)/2} · √(π/2a) · [erf(√(a/2)(Lt₀)) + erf(√(a/2)·t₀)]`.
#[must_use]
pub fn line_integral(g: &RfGaussian, o: [f64; 3], u: [f64; 3], len: f64) -> f64 {
let si = g.inv_covariance();
let mo = [g.position[0] - o[0], g.position[1] - o[1], g.position[2] - o[2]];
let mut a = 0.0;
let mut b = 0.0;
let mut c = 0.0;
for i in 0..3 {
for j in 0..3 {
a += u[i] * si[i][j] * u[j];
b += u[i] * si[i][j] * mo[j];
c += mo[i] * si[i][j] * mo[j];
}
}
if a <= 0.0 {
return 0.0;
}
let t0 = b / a;
let peak = (-0.5 * (c - b * b / a)).exp();
let s = (a / 2.0).sqrt();
peak * (std::f64::consts::PI / (2.0 * a)).sqrt() * (erf(s * (len - t0)) + erf(s * t0))
}
/// Total optical depth (nepers) of the map along a TX→RX segment, plus the
/// per-Gaussian integrals for gradient use: `(τ, [(idx, I_g)])`.
#[must_use]
pub fn optical_depth(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3]) -> (f64, Vec<(usize, f64)>) {
let d = [rx[0] - tx[0], rx[1] - tx[1], rx[2] - tx[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if len < 1e-9 {
return (0.0, Vec::new());
}
let u = [d[0] / len, d[1] / len, d[2] / len];
// Candidate set: Gaussians within a 3 m corridor of the segment (3σ for
// σ ≤ 1 m primitives), gathered by walking only the hash cells along
// the link instead of a ball around its midpoint — see
// `GaussianMap::query_near_segment` for the measured effect.
let candidates = map.query_near_segment(tx, rx, 3.0);
let mut tau = 0.0;
let mut parts = Vec::new();
for i in candidates {
let g = &map.gaussians()[i];
let integral = line_integral(g, tx, u, len);
if integral > 1e-12 {
tau += g.occupancy * integral;
parts.push((i, integral));
}
}
(tau, parts)
}
/// Complex channel gain between two points at carrier `freq_hz`.
///
/// Empty map ⇒ exact free-space Friis amplitude `λ/(4πd)` with propagation
/// phase `e^{-j2πd/λ}`.
#[must_use]
pub fn channel_gain(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> Complex64 {
let d = ((rx[0] - tx[0]).powi(2) + (rx[1] - tx[1]).powi(2) + (rx[2] - tx[2]).powi(2)).sqrt();
if d < 1e-9 {
return Complex64::new(1.0, 0.0);
}
let lambda = C / freq_hz;
let amp = lambda / (4.0 * std::f64::consts::PI * d);
let phase = -2.0 * std::f64::consts::PI * d / lambda;
let (tau, _) = optical_depth(map, tx, rx);
Complex64::from_polar(amp * (-tau).exp(), phase)
}
/// Power gain in dB.
#[must_use]
pub fn gain_db(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> f64 {
20.0 * channel_gain(map, tx, rx, freq_hz).norm().log10()
}
/// One inverse-update step from a measured link amplitude (ADR-275 §4.2 —
/// the physics-informed incremental mapping move: when the environment
/// changes, adjust or add a *compact* set of Gaussians instead of remapping).
///
/// The measured optical depth is `τ* = ln(friis_amp / measured_amp)`; the
/// map's depth is `τ = Σ occ_g·I_g`. A projected-gradient step moves the
/// intersected occupancies toward the residual `r = τ* τ`:
///
/// `occ_g += lr · r · I_g / (Σ I_g² + ε)`, clamped at 0 —
///
/// which is exact Newton along this link's direction at `lr = 1`. When no
/// Gaussian meaningfully intersects the segment and the residual demands
/// attenuation, a new isotropic Gaussian is spawned at the segment midpoint
/// sized to explain the residual. Returns the post-update residual (nepers).
pub fn observe_link(
map: &mut GaussianMap,
tx: [f64; 3],
rx: [f64; 3],
freq_hz: f64,
measured_amp: f64,
lr: f64,
timestamp_ns: u64,
) -> f64 {
assert!(measured_amp > 0.0, "measured amplitude must be positive");
let d = ((rx[0] - tx[0]).powi(2) + (rx[1] - tx[1]).powi(2) + (rx[2] - tx[2]).powi(2)).sqrt();
let lambda = C / freq_hz;
let friis = lambda / (4.0 * std::f64::consts::PI * d);
let tau_target = (friis / measured_amp).ln();
let (tau, parts) = optical_depth(map, tx, rx);
let residual = tau_target - tau;
let sum_i2: f64 = parts.iter().map(|(_, i)| i * i).sum();
if sum_i2 > 1e-9 {
for (idx, integral) in &parts {
let g = &mut map.gaussians_mut()[*idx];
g.occupancy = (g.occupancy + lr * residual * integral / sum_i2).max(0.0);
g.timestamp_ns = g.timestamp_ns.max(timestamp_ns);
}
map.rebuild_grid();
} else if residual > 0.05 {
// Nothing on the path can explain the attenuation: spawn a compact
// absorber at the midpoint sized to close the residual exactly.
let mid = [(tx[0] + rx[0]) / 2.0, (tx[1] + rx[1]) / 2.0, (tx[2] + rx[2]) / 2.0];
let mut g = RfGaussian::new(
mid,
[0.3, 0.3, 0.3],
[1.0, 0.0, 0.0, 0.0],
0.0,
0.6,
timestamp_ns,
300.0,
Provenance { device_id: "gain-inverse".into(), model_version: 1, synthetic: false },
)
.expect("spawn parameters are statically valid");
let dvec = [rx[0] - tx[0], rx[1] - tx[1], rx[2] - tx[2]];
let u = [dvec[0] / d, dvec[1] / d, dvec[2] / d];
let unit_integral = line_integral(&g, tx, u, d);
if unit_integral > 1e-9 {
g.occupancy = residual / unit_integral;
map.insert(g);
}
}
let (tau_after, _) = optical_depth(map, tx, rx);
tau_target - tau_after
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gaussian::primitive::Provenance;
fn prov() -> Provenance {
Provenance { device_id: "gain-test".into(), model_version: 1, synthetic: true }
}
#[test]
fn empty_map_returns_exact_friis() {
let map = GaussianMap::new(1.0);
let f = 2.437e9;
let tx = [0.0, 0.0, 1.0];
let rx = [4.0, 3.0, 1.0]; // d = 5
let h = channel_gain(&map, tx, rx, f);
let lambda = C / f;
let expected_amp = lambda / (4.0 * std::f64::consts::PI * 5.0);
assert!((h.norm() - expected_amp).abs() < 1e-15, "amplitude must be exact Friis");
let expected_phase = -2.0 * std::f64::consts::PI * 5.0 / lambda;
// Compare phasors (phase is only defined mod 2π).
let diff = (h / Complex64::from_polar(expected_amp, expected_phase)) - 1.0;
assert!(diff.norm() < 1e-9, "propagation phase must match");
}
#[test]
fn absorber_on_path_reduces_gain_monotonically() {
let f = 5.18e9;
let tx = [0.0, 0.0, 1.0];
let rx = [6.0, 0.0, 1.0];
let mut last = f64::INFINITY;
for occ in [0.0, 0.2, 0.5, 1.0, 2.0] {
let mut map = GaussianMap::new(1.0);
let g = RfGaussian::new(
[3.0, 0.0, 1.0],
[0.4, 0.4, 0.4],
[1.0, 0.0, 0.0, 0.0],
occ,
0.9,
0,
300.0,
prov(),
)
.expect("valid");
map.insert(g);
let db = gain_db(&map, tx, rx, f);
assert!(db < last + 1e-12, "gain must fall as occupancy rises");
last = db;
}
}
#[test]
fn off_path_absorber_barely_matters() {
let f = 5.18e9;
let tx = [0.0, 0.0, 1.0];
let rx = [6.0, 0.0, 1.0];
let mut map = GaussianMap::new(1.0);
map.insert(
RfGaussian::new(
[3.0, 4.0, 1.0], // 4 m off the LoS, σ = 0.4 ⇒ 10σ away
[0.4, 0.4, 0.4],
[1.0, 0.0, 0.0, 0.0],
2.0,
0.9,
0,
300.0,
prov(),
)
.expect("valid"),
);
let clean = gain_db(&GaussianMap::new(1.0), tx, rx, f);
let with = gain_db(&map, tx, rx, f);
assert!((clean - with).abs() < 1e-6, "off-path matter must not attenuate LoS");
}
#[test]
fn line_integral_matches_numeric_quadrature() {
let g = RfGaussian::new(
[2.0, 0.5, 1.0],
[0.5, 0.8, 0.3],
[0.9, 0.1, 0.2, 0.1], // arbitrary rotation
1.0,
0.9,
0,
300.0,
prov(),
)
.expect("valid");
let o = [0.0, 0.0, 1.0];
let u = [1.0, 0.0, 0.0];
let len = 6.0;
// Trapezoid quadrature at 1 mm steps as ground truth.
let n = 6000;
let mut acc = 0.0;
for i in 0..=n {
let t = len * i as f64 / n as f64;
let w = if i == 0 || i == n { 0.5 } else { 1.0 };
acc += w * g.density_at([o[0] + t * u[0], o[1] + t * u[1], o[2] + t * u[2]]);
}
acc *= len / n as f64;
let closed = line_integral(&g, o, u, len);
assert!(
(closed - acc).abs() < 1e-6,
"closed form {closed} vs quadrature {acc}"
);
}
#[test]
fn inverse_update_learns_a_wall_from_link_residuals() {
let f = 2.437e9;
let tx = [0.0, 0.0, 1.0];
let rx = [6.0, 0.0, 1.0];
let lambda = C / f;
let friis = lambda / (4.0 * std::f64::consts::PI * 6.0);
// Ground truth: an unseen absorber imposes τ = 0.7 nepers (≈ 6.1 dB).
let measured = friis * (-0.7f64).exp();
let mut map = GaussianMap::new(1.0);
let mut residual = f64::INFINITY;
for step in 0..20 {
residual = observe_link(&mut map, tx, rx, f, measured, 0.7, step);
}
assert!(!map.is_empty(), "an absorber must have been spawned");
assert!(
residual.abs() < 0.06,
"residual after convergence must be < 0.06 nepers (≈0.5 dB), got {residual}"
);
let predicted_db = gain_db(&map, tx, rx, f);
let measured_db = 20.0 * measured.log10();
assert!(
(predicted_db - measured_db).abs() < 0.5,
"map prediction {predicted_db:.2} dB must match measurement {measured_db:.2} dB"
);
}
}
@@ -0,0 +1,245 @@
//! Task-gated scene graph (ADR-275 §5) — sparse symbolic relations over the
//! continuous Gaussian field, activated *only* as far as the current task
//! requires (bounded active memory, the JITOMA lesson: stop trying to
//! remember everything at once).
use std::collections::{BTreeSet, HashMap, VecDeque};
use serde::{Deserialize, Serialize};
/// Kind of entity a scene node represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EntityKind {
/// A physical object (furniture, appliance).
Object,
/// A room / zone.
Room,
/// A person *class* (never an identity — identity inference is gated by
/// the ADR-277 policy engine, not stored in the scene graph).
PersonClass,
/// A sensing device.
Device,
/// A discrete event (channel anomaly, fall alert, …).
Event,
}
/// Relation kinds on scene edges.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationKind {
/// Spatial containment (room contains object).
Contains,
/// Spatial adjacency.
Near,
/// Causal attribution (event caused by object/person-class).
CausedBy,
/// Sensing coverage (device observes room/object).
ObservedBy,
}
/// A node in the scene graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneNode {
/// Stable identifier.
pub id: String,
/// Entity kind.
pub kind: EntityKind,
/// Indices of the Gaussians in the map that ground this node.
pub gaussian_refs: Vec<usize>,
}
/// A directed, typed edge.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneEdge {
/// Source node id.
pub from: String,
/// Target node id.
pub to: String,
/// Relation.
pub relation: RelationKind,
}
/// The sparse relational layer over the Gaussian field.
#[derive(Debug, Default, Clone)]
pub struct SceneGraph {
nodes: HashMap<String, SceneNode>,
edges: Vec<SceneEdge>,
}
/// A bounded, task-relevant activation of the graph.
#[derive(Debug, Clone)]
pub struct ActiveSubgraph {
/// Activated node ids in BFS order from the seeds.
pub nodes: Vec<String>,
/// Edges with both endpoints activated.
pub edges: Vec<SceneEdge>,
/// True when the activation hit `max_nodes` before exhausting reachable
/// relevant nodes (callers can widen the budget deliberately).
pub truncated: bool,
}
impl SceneGraph {
/// Empty graph.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of nodes.
#[must_use]
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Inserts or replaces a node.
pub fn upsert_node(&mut self, node: SceneNode) {
self.nodes.insert(node.id.clone(), node);
}
/// Adds an edge (endpoints need not exist yet; dangling edges are
/// simply never activated).
pub fn add_edge(&mut self, from: impl Into<String>, to: impl Into<String>, relation: RelationKind) {
self.edges.push(SceneEdge { from: from.into(), to: to.into(), relation });
}
/// Node lookup.
#[must_use]
pub fn node(&self, id: &str) -> Option<&SceneNode> {
self.nodes.get(id)
}
/// Task-gated activation: BFS from `seed_ids`, following edges in both
/// directions, keeping only nodes whose kind is in `relevant_kinds`,
/// visiting at most `max_nodes` nodes. This is the *only* sanctioned way
/// for reasoning code to read the graph — full scans are deliberately
/// not offered on the public surface.
#[must_use]
pub fn activate(
&self,
relevant_kinds: &[EntityKind],
seed_ids: &[&str],
max_nodes: usize,
) -> ActiveSubgraph {
let relevant: BTreeSet<EntityKind> = relevant_kinds.iter().copied().collect();
let mut visited: BTreeSet<&str> = BTreeSet::new();
let mut queue: VecDeque<&str> = VecDeque::new();
let mut activated: Vec<String> = Vec::new();
let mut truncated = false;
for &s in seed_ids {
if let Some(n) = self.nodes.get(s) {
if relevant.contains(&n.kind) && visited.insert(s) {
queue.push_back(s);
}
}
}
while let Some(id) = queue.pop_front() {
if activated.len() >= max_nodes {
truncated = true;
break;
}
activated.push(id.to_string());
for e in &self.edges {
let neighbor = if e.from == id {
Some(e.to.as_str())
} else if e.to == id {
Some(e.from.as_str())
} else {
None
};
let Some(nb) = neighbor else { continue };
let Some(node) = self.nodes.get(nb) else { continue };
if relevant.contains(&node.kind) && !visited.contains(nb) {
// Re-borrow with the graph's lifetime for the queue.
let (key, _) = self.nodes.get_key_value(nb).expect("just found");
visited.insert(key);
queue.push_back(key);
}
}
}
if !queue.is_empty() {
truncated = true;
}
let in_set: BTreeSet<&str> = activated.iter().map(String::as_str).collect();
let edges = self
.edges
.iter()
.filter(|e| in_set.contains(e.from.as_str()) && in_set.contains(e.to.as_str()))
.cloned()
.collect();
ActiveSubgraph { nodes: activated, edges, truncated }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_graph() -> SceneGraph {
let mut g = SceneGraph::new();
for (id, kind) in [
("kitchen", EntityKind::Room),
("living", EntityKind::Room),
("fridge", EntityKind::Object),
("sofa", EntityKind::Object),
("esp32-a", EntityKind::Device),
("person-class-1", EntityKind::PersonClass),
("anomaly-7", EntityKind::Event),
] {
g.upsert_node(SceneNode { id: id.into(), kind, gaussian_refs: vec![] });
}
g.add_edge("kitchen", "fridge", RelationKind::Contains);
g.add_edge("living", "sofa", RelationKind::Contains);
g.add_edge("kitchen", "esp32-a", RelationKind::ObservedBy);
g.add_edge("anomaly-7", "fridge", RelationKind::CausedBy);
g.add_edge("person-class-1", "living", RelationKind::Near);
g
}
#[test]
fn activation_is_task_scoped() {
let g = demo_graph();
// Task: "which object caused the channel anomaly?" — events, objects,
// rooms are relevant; devices and person classes are not.
let active = g.activate(
&[EntityKind::Event, EntityKind::Object, EntityKind::Room],
&["anomaly-7"],
10,
);
assert!(active.nodes.contains(&"anomaly-7".to_string()));
assert!(active.nodes.contains(&"fridge".to_string()));
assert!(active.nodes.contains(&"kitchen".to_string()));
assert!(!active.nodes.iter().any(|n| n == "esp32-a"), "devices are gated out");
assert!(!active.nodes.iter().any(|n| n == "person-class-1"));
assert!(!active.truncated);
}
#[test]
fn activation_respects_the_node_budget() {
let g = demo_graph();
let active = g.activate(
&[
EntityKind::Event,
EntityKind::Object,
EntityKind::Room,
EntityKind::Device,
EntityKind::PersonClass,
],
&["anomaly-7"],
2,
);
assert_eq!(active.nodes.len(), 2, "bounded active memory");
assert!(active.truncated, "truncation must be reported, not silent");
}
#[test]
fn unreachable_and_irrelevant_seeds_yield_empty() {
let g = demo_graph();
let active = g.activate(&[EntityKind::Object], &["nonexistent"], 10);
assert!(active.nodes.is_empty());
// Seed exists but its kind is not relevant ⇒ empty activation.
let active = g.activate(&[EntityKind::Object], &["kitchen"], 10);
assert!(active.nodes.is_empty());
}
}
@@ -0,0 +1,632 @@
//! The Gaussian map: spatial-hash-indexed storage with fusion, decay, and
//! spatial/semantic queries (ADR-275 §3).
use std::collections::HashMap;
use super::primitive::{RfGaussian, SEMANTIC_DIM};
/// Confidence floor below which a decayed Gaussian is pruned.
pub const PRUNE_CONFIDENCE: f64 = 0.02;
/// Squared-Mahalanobis merge gate: an incoming Gaussian whose centre lies
/// within this metric distance of an existing one *of the same entity kind*
/// fuses instead of inserting (3² = within 3σ).
pub const MERGE_MAHALANOBIS_SQ: f64 = 9.0;
/// Persistent Gaussian scene memory with an O(1) spatial-hash index.
#[derive(Debug, Default)]
pub struct GaussianMap {
gaussians: Vec<RfGaussian>,
/// Cell → indices. Rebuilt on decay/prune, updated on insert.
grid: HashMap<(i64, i64, i64), Vec<usize>>,
cell_size: f64,
}
impl GaussianMap {
/// New map. `cell_size` is the spatial-hash pitch in metres; it should
/// be on the order of the largest expected Gaussian extent (≈ 1 m for
/// rooms).
#[must_use]
pub fn new(cell_size: f64) -> Self {
Self { gaussians: Vec::new(), grid: HashMap::new(), cell_size: cell_size.max(1e-3) }
}
/// Number of live Gaussians.
#[must_use]
pub fn len(&self) -> usize {
self.gaussians.len()
}
/// Whether the map is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.gaussians.is_empty()
}
/// Read-only view of the store.
#[must_use]
pub fn gaussians(&self) -> &[RfGaussian] {
&self.gaussians
}
/// Mutable access for the inverse-gain updater (crate-internal).
pub(crate) fn gaussians_mut(&mut self) -> &mut Vec<RfGaussian> {
&mut self.gaussians
}
fn cell_of(&self, p: [f64; 3]) -> (i64, i64, i64) {
(
(p[0] / self.cell_size).floor() as i64,
(p[1] / self.cell_size).floor() as i64,
(p[2] / self.cell_size).floor() as i64,
)
}
/// Rebuilds the spatial index from scratch (after decay/prune or
/// occupancy edits that may have moved nothing — cheap: O(n)).
pub(crate) fn rebuild_grid(&mut self) {
self.grid.clear();
for (i, g) in self.gaussians.iter().enumerate() {
let cell = self.cell_of(g.position);
self.grid.entry(cell).or_default().push(i);
}
}
/// Inserts a Gaussian, fusing with an existing same-kind neighbor when
/// the merge gate fires (ADR-275 §3.2).
///
/// Fusion is confidence-weighted: position, occupancy, semantics,
/// reflectivity, and Doppler average with weights `(c_old, c_new)`;
/// confidence combines as noisy-OR `c = c₁ + c₂ c₁c₂` (two independent
/// pieces of evidence); the newer timestamp and provenance win.
/// Returns the index of the stored (new or fused) Gaussian.
pub fn insert(&mut self, g: RfGaussian) -> usize {
// Candidate neighbors from the 3×3×3 cell neighborhood.
let cell = self.cell_of(g.position);
let mut best: Option<usize> = None;
let mut best_d = MERGE_MAHALANOBIS_SQ;
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
let Some(idxs) = self.grid.get(&(cell.0 + dx, cell.1 + dy, cell.2 + dz))
else {
continue;
};
for &i in idxs {
let existing = &self.gaussians[i];
let same_kind = match (existing.links.first(), g.links.first()) {
(Some(a), Some(b)) => a.kind == b.kind,
(None, None) => true,
_ => false,
};
if !same_kind {
continue;
}
let d = existing.mahalanobis_sq(g.position);
if d < best_d {
best_d = d;
best = Some(i);
}
}
}
}
}
if let Some(i) = best {
let old_cell = self.cell_of(self.gaussians[i].position);
{
let e = &mut self.gaussians[i];
let (wa, wb) = (e.confidence, g.confidence);
let wsum = (wa + wb).max(1e-12);
for k in 0..3 {
e.position[k] = (wa * e.position[k] + wb * g.position[k]) / wsum;
e.scale[k] = (wa * e.scale[k] + wb * g.scale[k]) / wsum;
}
e.occupancy = (wa * e.occupancy + wb * g.occupancy) / wsum;
for k in 0..SEMANTIC_DIM {
e.semantic[k] =
((f64::from(e.semantic[k]) * wa + f64::from(g.semantic[k]) * wb) / wsum) as f32;
}
for b in 0..e.reflectivity.len() {
for a in 0..e.reflectivity[b].len() {
e.reflectivity[b][a] =
(wa * e.reflectivity[b][a] + wb * g.reflectivity[b][a]) / wsum;
}
}
e.doppler_mps = (wa * e.doppler_mps + wb * g.doppler_mps) / wsum;
e.doppler_variance = (wa * e.doppler_variance + wb * g.doppler_variance) / wsum;
e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0);
e.first_seen_ns = e.first_seen_ns.min(g.first_seen_ns);
if g.timestamp_ns >= e.timestamp_ns {
e.timestamp_ns = g.timestamp_ns;
e.provenance = g.provenance;
e.motion = g.motion;
}
for r in g.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 g.links {
if !e.links.contains(&link) {
e.links.push(link);
}
}
}
// Re-index if fusion moved the centre across a cell boundary.
let new_cell = self.cell_of(self.gaussians[i].position);
if new_cell != old_cell {
if let Some(v) = self.grid.get_mut(&old_cell) {
v.retain(|&x| x != i);
}
self.grid.entry(new_cell).or_default().push(i);
}
i
} else {
let idx = self.gaussians.len();
let cell = self.cell_of(g.position);
self.gaussians.push(g);
self.grid.entry(cell).or_default().push(idx);
idx
}
}
/// 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;
let lifetime_s = (g.timestamp_ns.saturating_sub(g.first_seen_ns)) as f64 / 1e9;
// `RfGaussian::new` validates `decay_tau_s > 0`, but the field is
// mutable after construction (`gaussians_mut`); re-clamp here so a
// stray zero/negative value can't turn this division into NaN
// instead of a merely-fast decay.
let tau = g.decay_tau_s.max(1e-6);
let tau_eff = tau * (1.0 + (1.0 + lifetime_s / tau).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]);
// Same entity-kind gate as `insert` (§3.2): never conflate
// Gaussians linked to different entity kinds (e.g. a Room
// structure and a PersonClass detection sitting within each
// other's merge gate near a doorway).
let same_kind = match (a.links.first(), b.links.first()) {
(Some(la), Some(lb)) => la.kind == lb.kind,
(None, None) => true,
_ => false,
};
if !same_kind {
continue;
}
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]
pub fn query_radius(&self, p: [f64; 3], radius: f64) -> Vec<usize> {
let r_cells = (radius / self.cell_size).ceil() as i64;
let c = self.cell_of(p);
let mut out = Vec::new();
let r2 = radius * radius;
for dx in -r_cells..=r_cells {
for dy in -r_cells..=r_cells {
for dz in -r_cells..=r_cells {
let Some(idxs) = self.grid.get(&(c.0 + dx, c.1 + dy, c.2 + dz)) else {
continue;
};
for &i in idxs {
let q = self.gaussians[i].position;
let d2 = (q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2);
if d2 <= r2 {
out.push(i);
}
}
}
}
}
out.sort_unstable();
out
}
/// Reference implementation of [`Self::query_radius`] by linear scan —
/// kept for the equivalence test and the benchmark baseline.
#[must_use]
pub fn query_radius_linear(&self, p: [f64; 3], radius: f64) -> Vec<usize> {
let r2 = radius * radius;
let mut out: Vec<usize> = self
.gaussians
.iter()
.enumerate()
.filter(|(_, g)| {
let q = g.position;
(q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2) <= r2
})
.map(|(i, _)| i)
.collect();
out.sort_unstable();
out
}
/// Indices of Gaussians whose centres lie within `margin` of the
/// segment `a → b`, by walking only the hash cells along the corridor —
/// the hot path of [`super::gain::optical_depth`].
///
/// Sweeps the segment's margin-inflated AABB once; each cell is
/// prefiltered by its *centre's* distance to the segment (bound:
/// `margin + √3/2·cell`, which covers any point inside the cell) before
/// the hash lookup, so no per-sample set building and no duplicate
/// visits. Candidates are then exact-filtered by centre-to-segment
/// distance. See `benches/unified_bench.rs` for the measured effect on
/// `channel_gain` versus both the midpoint-ball query this replaced and
/// the linear-scan baseline.
#[must_use]
pub fn query_near_segment(&self, a: [f64; 3], b: [f64; 3], margin: f64) -> Vec<usize> {
let lo = |axis: usize| a[axis].min(b[axis]) - margin;
let hi = |axis: usize| a[axis].max(b[axis]) + margin;
let c_lo: Vec<i64> = (0..3).map(|k| (lo(k) / self.cell_size).floor() as i64).collect();
let c_hi: Vec<i64> = (0..3).map(|k| (hi(k) / self.cell_size).floor() as i64).collect();
let cell_bound = margin + 0.87 * self.cell_size; // √3/2 ≈ 0.866
let mut out: Vec<usize> = Vec::new();
for cx in c_lo[0]..=c_hi[0] {
for cy in c_lo[1]..=c_hi[1] {
for cz in c_lo[2]..=c_hi[2] {
let centre = [
(cx as f64 + 0.5) * self.cell_size,
(cy as f64 + 0.5) * self.cell_size,
(cz as f64 + 0.5) * self.cell_size,
];
if Self::dist_point_segment(centre, a, b) > cell_bound {
continue;
}
let Some(idxs) = self.grid.get(&(cx, cy, cz)) else { continue };
for &i in idxs {
if Self::dist_point_segment(self.gaussians[i].position, a, b) <= margin {
out.push(i);
}
}
}
}
}
out.sort_unstable();
out
}
/// Reference implementation of [`Self::query_near_segment`] by linear
/// scan — equivalence-tested and benchmarked as the baseline.
#[must_use]
pub fn query_near_segment_linear(&self, a: [f64; 3], b: [f64; 3], margin: f64) -> Vec<usize> {
(0..self.gaussians.len())
.filter(|&i| Self::dist_point_segment(self.gaussians[i].position, a, b) <= margin)
.collect()
}
/// Distance from a point to a segment.
fn dist_point_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
let denom = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
let t = if denom < 1e-18 {
0.0
} else {
((ap[0] * ab[0] + ap[1] * ab[1] + ap[2] * ab[2]) / denom).clamp(0.0, 1.0)
};
let q = [a[0] + t * ab[0], a[1] + t * ab[1], a[2] + t * ab[2]];
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
}
/// `k` nearest Gaussians to `p` by centre distance (expanding-ring
/// search over the hash grid).
#[must_use]
pub fn query_nearest(&self, p: [f64; 3], k: usize) -> Vec<usize> {
if self.gaussians.is_empty() || k == 0 {
return Vec::new();
}
let mut radius = self.cell_size;
loop {
let hits = self.query_radius(p, radius);
if hits.len() >= k || radius > 1e4 {
let mut scored: Vec<(f64, usize)> = hits
.into_iter()
.map(|i| {
let q = self.gaussians[i].position;
let d2 = (q[0] - p[0]).powi(2)
+ (q[1] - p[1]).powi(2)
+ (q[2] - p[2]).powi(2);
(d2, i)
})
.collect();
scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
return scored.into_iter().map(|(_, i)| i).collect();
}
radius *= 2.0;
}
}
/// `k` most semantically similar Gaussians by cosine similarity.
#[must_use]
pub fn query_semantic(&self, embedding: &[f32; SEMANTIC_DIM], k: usize) -> Vec<usize> {
let norm_q: f64 = embedding.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>().sqrt();
let mut scored: Vec<(f64, usize)> = self
.gaussians
.iter()
.enumerate()
.map(|(i, g)| {
let dot: f64 = g
.semantic
.iter()
.zip(embedding)
.map(|(a, b)| f64::from(*a) * f64::from(*b))
.sum();
let norm_g: f64 =
g.semantic.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>().sqrt();
let sim = if norm_g * norm_q > 0.0 { dot / (norm_g * norm_q) } else { -1.0 };
(sim, i)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
scored.into_iter().map(|(_, i)| i).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gaussian::primitive::Provenance;
fn prov() -> Provenance {
Provenance { device_id: "map-test".into(), model_version: 1, synthetic: true }
}
fn g_at(p: [f64; 3], confidence: f64, ts: u64) -> RfGaussian {
RfGaussian::new(p, [0.3, 0.3, 0.3], [1.0, 0.0, 0.0, 0.0], 0.4, confidence, ts, 60.0, prov())
.expect("valid gaussian")
}
#[test]
fn nearby_same_kind_observations_fuse() {
let mut map = GaussianMap::new(1.0);
let a = map.insert(g_at([1.0, 1.0, 1.0], 0.5, 10));
let b = map.insert(g_at([1.1, 1.0, 1.0], 0.5, 20));
assert_eq!(a, b, "second observation must fuse, not duplicate");
assert_eq!(map.len(), 1);
let g = &map.gaussians()[0];
// Confidence-weighted midpoint and noisy-OR confidence.
assert!((g.position[0] - 1.05).abs() < 1e-9);
assert!((g.confidence - 0.75).abs() < 1e-9);
assert_eq!(g.timestamp_ns, 20);
// A far observation creates a new Gaussian.
map.insert(g_at([5.0, 5.0, 1.0], 0.5, 30));
assert_eq!(map.len(), 2);
}
#[test]
fn decay_prunes_stale_gaussians_deterministically() {
let mut map = GaussianMap::new(1.0);
map.insert(g_at([0.0; 3], 0.9, 0)); // tau = 60 s
map.insert(g_at([4.0, 0.0, 0.0], 0.9, 240_000_000_000)); // fresh
// 240 s later: first has decayed by e^{-4} ⇒ 0.9·0.0183 ≈ 0.016 < floor.
map.decay(240_000_000_000);
assert_eq!(map.len(), 1);
assert!((map.gaussians()[0].position[0] - 4.0).abs() < 1e-12);
// Determinism: replay the same sequence, get the same state.
let mut replay = GaussianMap::new(1.0);
replay.insert(g_at([0.0; 3], 0.9, 0));
replay.insert(g_at([4.0, 0.0, 0.0], 0.9, 240_000_000_000));
replay.decay(240_000_000_000);
assert_eq!(replay.len(), map.len());
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);
// Grid of well-separated Gaussians (spacing 2 m ≫ merge gate at σ=0.3).
for x in 0..10 {
for y in 0..10 {
map.insert(g_at([x as f64 * 2.0, y as f64 * 2.0, 1.0], 0.9, 0));
}
}
assert_eq!(map.len(), 100);
for (centre, radius) in
[([5.0, 5.0, 1.0], 3.0), ([0.0, 0.0, 1.0], 1.5), ([18.0, 18.0, 1.0], 5.0)]
{
assert_eq!(
map.query_radius(centre, radius),
map.query_radius_linear(centre, radius),
"hash and linear scans must agree at {centre:?} r={radius}"
);
}
}
#[test]
fn segment_corridor_query_matches_linear_scan() {
let mut map = GaussianMap::new(1.0);
for x in 0..12 {
for y in 0..12 {
for z in 0..2 {
map.insert(g_at(
[x as f64 * 1.7, y as f64 * 1.7, 0.8 + z as f64 * 1.4],
0.9,
0,
));
}
}
}
for (a, b, m) in [
([0.0, 0.0, 1.0], [18.0, 18.0, 1.5], 3.0),
([2.0, 15.0, 1.0], [15.0, 2.0, 2.0], 1.5),
([5.0, 5.0, 1.0], [5.0, 5.0, 1.0], 2.0), // degenerate segment
] {
assert_eq!(
map.query_near_segment(a, b, m),
map.query_near_segment_linear(a, b, m),
"corridor hash walk and linear scan must agree for {a:?}→{b:?} m={m}"
);
}
}
#[test]
fn nearest_and_semantic_queries() {
let mut map = GaussianMap::new(1.0);
map.insert(g_at([0.0, 0.0, 0.0], 0.9, 0));
map.insert(g_at([3.0, 0.0, 0.0], 0.9, 0));
let mut tagged = g_at([9.0, 9.0, 0.0], 0.9, 0);
tagged.semantic[0] = 1.0;
tagged.semantic[1] = 0.5;
map.insert(tagged);
let near = map.query_nearest([0.2, 0.0, 0.0], 2);
assert_eq!(near.len(), 2);
assert_eq!(near[0], 0, "closest first");
let mut q = [0.0f32; SEMANTIC_DIM];
q[0] = 1.0;
q[1] = 0.5;
let sem = map.query_semantic(&q, 1);
assert_eq!(sem, vec![2], "semantic query finds the tagged Gaussian");
}
}
@@ -0,0 +1,30 @@
//! RF-aware Gaussian spatial memory (ADR-275).
//!
//! The persistent scene representation: anisotropic 3-D Gaussians that carry
//! *both* geometric/semantic state (position, scale, orientation, occupancy,
//! semantic embedding) *and* RF state (per-band × incident-angle
//! reflectivity, Doppler/motion, extinction), plus the bookkeeping a world
//! model needs (confidence, provenance, timestamp, decay, entity links).
//!
//! Three capabilities distinguish this from a point cloud:
//!
//! 1. **Fusion**: observations of the same region merge by
//! confidence-weighted precision updates instead of accumulating
//! duplicates ([`map::GaussianMap::insert`]).
//! 2. **RF queries**: the map predicts complex channel gain between any two
//! points via BeerLambert transmittance through the Gaussians
//! ([`gain::channel_gain`]) — an empty map degrades to *exact* free-space
//! Friis, and measured-vs-predicted residuals update the map inversely
//! ([`gain::observe_link`]).
//! 3. **Task-gated activation**: reasoning code touches a bounded subgraph
//! ([`graph::SceneGraph::activate`]), never the full memory.
pub mod gain;
pub mod graph;
pub mod map;
pub mod primitive;
pub use gain::{channel_gain, gain_db, observe_link};
pub use graph::{EntityKind, RelationKind, SceneEdge, SceneGraph, SceneNode};
pub use map::GaussianMap;
pub use primitive::{Band, EntityLink, MotionState, Provenance, RfGaussian};
@@ -0,0 +1,351 @@
//! The RF-aware Gaussian primitive (ADR-275 §2).
use serde::{Deserialize, Serialize};
use crate::{Result, UnifiedError};
/// Frequency bands with distinct material interaction (index into the
/// reflectivity table).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Band {
/// 2.4 GHz (WiFi b/g/n, BLE).
Ghz2_4,
/// 56 GHz (WiFi a/ac/ax, NR n77/n78-ish).
Ghz5,
/// 68 GHz (WiFi 6E/7, UWB).
Ghz6,
/// 60 GHz (mmWave radar / 802.11ad).
Ghz60,
}
impl Band {
/// Number of modeled bands.
pub const COUNT: usize = 4;
/// Table index.
#[must_use]
pub fn index(self) -> usize {
match self {
Self::Ghz2_4 => 0,
Self::Ghz5 => 1,
Self::Ghz6 => 2,
Self::Ghz60 => 3,
}
}
/// Band for a carrier frequency in Hz (nearest-band bucketing).
#[must_use]
pub fn from_freq_hz(f: f64) -> Self {
if f < 4.0e9 {
Self::Ghz2_4
} else if f < 6.0e9 {
Self::Ghz5
} else if f < 2.0e10 {
Self::Ghz6
} else {
Self::Ghz60
}
}
}
/// Number of incident-angle bins in the reflectivity table (0°–90° in 4
/// bins of 22.5°).
pub const ANGLE_BINS: usize = 4;
/// Semantic embedding width carried by each Gaussian.
pub const SEMANTIC_DIM: usize = 16;
/// Coarse motion state of the matter a Gaussian represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MotionState {
/// Structural / furniture: no observed Doppler.
Static,
/// Slow biological motion (breathing, posture shifts).
Slow,
/// Walking-speed or faster motion.
Fast,
}
/// Where a Gaussian's evidence came from (ADR-273 acceptance item 8: every
/// output carries provenance and model version).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
/// Device that observed the evidence.
pub device_id: String,
/// Version of the model that produced the update.
pub model_version: u32,
/// Whether the evidence is synthetic (ADR-276 honest labeling).
pub synthetic: bool,
}
/// What kind of entity a Gaussian links to in the scene graph / RuVector.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityLink {
/// Kind of the linked entity.
pub kind: super::graph::EntityKind,
/// Stable identifier of the entity.
pub id: String,
}
/// One anisotropic RF-aware Gaussian (ADR-275 §2.1) — the six attribute
/// groups from the ADR: geometry, semantics, RF response, motion,
/// trust/lifecycle, and entity links.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RfGaussian {
/// Centre, metres, room frame.
pub position: [f64; 3],
/// Standard deviations along the principal axes, metres (> 0).
pub scale: [f64; 3],
/// Orientation quaternion `[w, x, y, z]` (normalized on construction).
pub orientation: [f64; 4],
/// Peak extinction coefficient (nepers/metre at the centre) — the
/// BeerLambert density used by the gain model. `>= 0`.
pub occupancy: f64,
/// Visual/semantic embedding.
pub semantic: [f32; SEMANTIC_DIM],
/// RF reflectivity by `[band][incident-angle bin]`, `0..=1` amplitude.
pub reflectivity: [[f64; ANGLE_BINS]; Band::COUNT],
/// Radial velocity estimate, m/s (signed).
pub doppler_mps: f64,
/// Doppler estimate variance, (m/s)² (ADR-281 lifecycle fields).
pub doppler_variance: f64,
/// Coarse motion class.
pub motion: MotionState,
/// Confidence in `[0, 1]`.
pub confidence: f64,
/// First-observed timestamp, ns since epoch (static structure is
/// distinguishable from transients by lifetime, not just decay τ).
pub first_seen_ns: u64,
/// Last-updated timestamp, ns since epoch.
pub timestamp_ns: u64,
/// Confidence e-folding time in seconds (decay clock).
pub decay_tau_s: f64,
/// Evidence provenance.
pub provenance: Provenance,
/// Receipt ids of the source frames that shaped this Gaussian
/// (measurement→inference lineage; capped on fusion).
pub source_receipts: Vec<u128>,
/// Links into the scene graph / RuVector entities.
pub links: Vec<EntityLink>,
}
/// Maximum receipts retained per Gaussian after fusion (bounded lineage).
pub const MAX_SOURCE_RECEIPTS: usize = 16;
impl RfGaussian {
/// Validated constructor: normalizes the quaternion and range-checks
/// every numeric field (boundary rule — the map assumes validity).
#[allow(clippy::too_many_arguments)]
pub fn new(
position: [f64; 3],
scale: [f64; 3],
orientation: [f64; 4],
occupancy: f64,
confidence: f64,
timestamp_ns: u64,
decay_tau_s: f64,
provenance: Provenance,
) -> Result<Self> {
for v in position.iter().chain(scale.iter()).chain(orientation.iter()) {
if !v.is_finite() {
return Err(UnifiedError::InvalidInput("non-finite Gaussian field".into()));
}
}
// 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-6 {
return Err(UnifiedError::InvalidInput("degenerate quaternion".into()));
}
let orientation = [
orientation[0] / norm,
orientation[1] / norm,
orientation[2] / norm,
orientation[3] / norm,
];
// 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!(
"confidence must be in [0,1], got {confidence}"
)));
}
if !(decay_tau_s.is_finite() && decay_tau_s > 0.0) {
return Err(UnifiedError::InvalidInput(format!(
"decay_tau_s must be > 0, got {decay_tau_s}"
)));
}
Ok(Self {
position,
scale,
orientation,
occupancy,
semantic: [0.0; SEMANTIC_DIM],
reflectivity: [[0.0; ANGLE_BINS]; Band::COUNT],
doppler_mps: 0.0,
doppler_variance: 0.0,
motion: MotionState::Static,
confidence,
first_seen_ns: timestamp_ns,
timestamp_ns,
decay_tau_s,
provenance,
source_receipts: Vec::new(),
links: Vec::new(),
})
}
/// Rotation matrix (row-major 3×3) from the unit quaternion.
#[must_use]
pub fn rotation(&self) -> [[f64; 3]; 3] {
let [w, x, y, z] = self.orientation;
[
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)],
[2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)],
[2.0 * (x * z - w * y), 2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y)],
]
}
/// Inverse covariance `Σ⁻¹ = R·diag(1/σ²)·Rᵀ` (row-major 3×3).
#[must_use]
pub fn inv_covariance(&self) -> [[f64; 3]; 3] {
let r = self.rotation();
let inv_s2 = [
1.0 / (self.scale[0] * self.scale[0]),
1.0 / (self.scale[1] * self.scale[1]),
1.0 / (self.scale[2] * self.scale[2]),
];
let mut out = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
let mut acc = 0.0;
for (k, is2) in inv_s2.iter().enumerate() {
acc += r[i][k] * is2 * r[j][k];
}
out[i][j] = acc;
}
}
out
}
/// Unnormalized density `exp(-½·dᵀΣ⁻¹d)` at a point (1 at the centre).
#[must_use]
pub fn density_at(&self, p: [f64; 3]) -> f64 {
let d = [p[0] - self.position[0], p[1] - self.position[1], p[2] - self.position[2]];
let si = self.inv_covariance();
let mut q = 0.0;
for i in 0..3 {
for j in 0..3 {
q += d[i] * si[i][j] * d[j];
}
}
(-0.5 * q).exp()
}
/// Squared Mahalanobis distance of a point from this Gaussian.
#[must_use]
pub fn mahalanobis_sq(&self, p: [f64; 3]) -> f64 {
let d = [p[0] - self.position[0], p[1] - self.position[1], p[2] - self.position[2]];
let si = self.inv_covariance();
let mut q = 0.0;
for i in 0..3 {
for j in 0..3 {
q += d[i] * si[i][j] * d[j];
}
}
q
}
}
#[cfg(test)]
mod tests {
use super::*;
fn prov() -> Provenance {
Provenance { device_id: "test".into(), model_version: 1, synthetic: true }
}
#[test]
fn constructor_validates_and_normalizes() {
let g = RfGaussian::new(
[1.0, 2.0, 1.0],
[0.5, 0.5, 1.0],
[2.0, 0.0, 0.0, 0.0], // non-unit quaternion → normalized
0.5,
0.9,
0,
300.0,
prov(),
)
.expect("valid");
assert!((g.orientation[0] - 1.0).abs() < 1e-12);
assert!(RfGaussian::new([0.0; 3], [0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.0], 0.1, 0.5, 0, 60.0, prov())
.is_err());
assert!(RfGaussian::new([0.0; 3], [1.0; 3], [1.0, 0.0, 0.0, 0.0], -0.1, 0.5, 0, 60.0, prov())
.is_err());
assert!(RfGaussian::new([0.0; 3], [1.0; 3], [1.0, 0.0, 0.0, 0.0], 0.1, 1.5, 0, 60.0, prov())
.is_err());
}
#[test]
fn density_peaks_at_centre_and_respects_anisotropy() {
let g = RfGaussian::new(
[0.0; 3],
[1.0, 0.1, 1.0], // thin along y
[1.0, 0.0, 0.0, 0.0],
0.5,
0.9,
0,
300.0,
prov(),
)
.expect("valid");
assert!((g.density_at([0.0; 3]) - 1.0).abs() < 1e-12);
// Same offset along x (wide) vs y (thin): y decays far faster.
// Analytic ratio at 0.3 m: exp(-0.045)/exp(-4.5) ≈ 86.
assert!(g.density_at([0.3, 0.0, 0.0]) > g.density_at([0.0, 0.3, 0.0]) * 80.0);
}
#[test]
fn rotated_gaussian_rotates_its_metric() {
// 90° rotation about z maps the thin y-axis onto x.
let s = std::f64::consts::FRAC_1_SQRT_2;
let g = RfGaussian::new(
[0.0; 3],
[1.0, 0.1, 1.0],
[s, 0.0, 0.0, s], // quaternion for Rz(90°)
0.5,
0.9,
0,
300.0,
prov(),
)
.expect("valid");
assert!(g.density_at([0.0, 0.3, 0.0]) > g.density_at([0.3, 0.0, 0.0]) * 80.0);
}
#[test]
fn band_bucketing() {
assert_eq!(Band::from_freq_hz(2.437e9), Band::Ghz2_4);
assert_eq!(Band::from_freq_hz(5.18e9), Band::Ghz5);
assert_eq!(Band::from_freq_hz(6.5e9), Band::Ghz6);
assert_eq!(Band::from_freq_hz(60e9), Band::Ghz60);
}
}
+738
View File
@@ -0,0 +1,738 @@
//! Task adapters ("heads") on the frozen encoder representation `z`
//! (ADR-274 §4).
//!
//! The ADR-273 acceptance test allows adapters **smaller than 1 % of the
//! backbone parameters** — enforced here by [`within_adapter_budget`] and by
//! a unit test over every head at the deployment config. Multi-class heads
//! use a low-rank (LoRA-style) factorization to stay inside the budget.
use crate::math::{sigmoid, softmax};
/// True iff the adapter fits the ADR-273 budget: `params(adapter) <
/// 1 % · params(backbone)`.
#[must_use]
pub fn within_adapter_budget(backbone_params: usize, adapter_params: usize) -> bool {
adapter_params * 100 < backbone_params
}
/// Binary presence head: logistic regression on `z`.
#[derive(Debug, Clone)]
pub struct PresenceHead {
/// Weights, length `d_model`.
pub w: Vec<f64>,
/// Bias.
pub b: f64,
}
impl PresenceHead {
/// Zero-initialized head (logistic regression is convex; zero init is
/// the standard, deterministic choice).
#[must_use]
pub fn new(d_model: usize) -> Self {
Self { w: vec![0.0; d_model], b: 0.0 }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.w.len() + 1
}
/// P(present | z).
#[must_use]
pub fn predict_prob(&self, z: &[f64]) -> f64 {
sigmoid(self.w.iter().zip(z).map(|(w, x)| w * x).sum::<f64>() + self.b)
}
/// Full-batch gradient-descent training (convex ⇒ deterministic
/// convergence). Returns the final mean cross-entropy.
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[bool], lr: f64, epochs: usize) -> f64 {
assert_eq!(zs.len(), labels.len());
let n = zs.len() as f64;
let mut final_ce = f64::INFINITY;
for _ in 0..epochs {
let mut gw = vec![0.0; self.w.len()];
let mut gb = 0.0;
let mut ce = 0.0;
for (z, &y) in zs.iter().zip(labels) {
let p = self.predict_prob(z);
let t = if y { 1.0 } else { 0.0 };
ce -= t * p.max(1e-12).ln() + (1.0 - t) * (1.0 - p).max(1e-12).ln();
let err = (p - t) / n;
for (g, x) in gw.iter_mut().zip(z) {
*g += err * x;
}
gb += err;
}
for (w, g) in self.w.iter_mut().zip(&gw) {
*w -= lr * g;
}
self.b -= lr * gb;
final_ce = ce / n;
}
final_ce
}
}
/// Low-rank multi-class head: `logits = U·(V·z) + b` with rank `r`
/// (LoRA-style factorization keeps `C`-class heads inside the 1 % budget).
#[derive(Debug, Clone)]
pub struct ActivityHead {
/// Classes.
pub classes: usize,
/// Rank.
pub rank: usize,
/// `classes × rank`, row-major.
pub u: Vec<f64>,
/// `rank × d_model`, row-major.
pub v: Vec<f64>,
/// Per-class bias.
pub b: Vec<f64>,
d_model: usize,
}
impl ActivityHead {
/// Deterministic small-value init (breaks the U/V symmetry without RNG).
#[must_use]
pub fn new(d_model: usize, classes: usize, rank: usize) -> Self {
let u = (0..classes * rank)
.map(|i| 0.01 * ((i % 7) as f64 - 3.0))
.collect();
let v = (0..rank * d_model)
.map(|i| 0.01 * ((i % 5) as f64 - 2.0))
.collect();
Self { classes, rank, u, v, b: vec![0.0; classes], d_model }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.u.len() + self.v.len() + self.b.len()
}
/// Class probabilities.
#[must_use]
pub fn predict(&self, z: &[f64]) -> Vec<f64> {
let mut probs = self.logits(z);
softmax(&mut probs);
probs
}
fn logits(&self, z: &[f64]) -> Vec<f64> {
let mut vz = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
}
let mut logits = self.b.clone();
for c in 0..self.classes {
let row = &self.u[c * self.rank..(c + 1) * self.rank];
logits[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
}
logits
}
/// Full-batch softmax cross-entropy training. Returns final mean CE.
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[usize], lr: f64, epochs: usize) -> f64 {
assert_eq!(zs.len(), labels.len());
let n = zs.len() as f64;
let mut final_ce = f64::INFINITY;
for _ in 0..epochs {
let mut gu = vec![0.0; self.u.len()];
let mut gv = vec![0.0; self.v.len()];
let mut gb = vec![0.0; self.b.len()];
let mut ce = 0.0;
for (z, &y) in zs.iter().zip(labels) {
let mut vz = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
}
let mut p = self.b.clone();
for c in 0..self.classes {
let row = &self.u[c * self.rank..(c + 1) * self.rank];
p[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
}
softmax(&mut p);
ce -= p[y].max(1e-12).ln();
// dlogits = p one_hot(y), scaled by 1/n.
let mut dvz = vec![0.0; self.rank];
for c in 0..self.classes {
let dl = (p[c] - if c == y { 1.0 } else { 0.0 }) / n;
gb[c] += dl;
for r in 0..self.rank {
gu[c * self.rank + r] += dl * vz[r];
dvz[r] += dl * self.u[c * self.rank + r];
}
}
for r in 0..self.rank {
for (d, zv) in z.iter().enumerate() {
gv[r * self.d_model + d] += dvz[r] * zv;
}
}
}
for (w, g) in self.u.iter_mut().zip(&gu) {
*w -= lr * g;
}
for (w, g) in self.v.iter_mut().zip(&gv) {
*w -= lr * g;
}
for (w, g) in self.b.iter_mut().zip(&gb) {
*w -= lr * g;
}
final_ce = ce / n;
}
final_ce
}
}
/// Linear localization head: `xyz = W·z + b` (metres).
#[derive(Debug, Clone)]
pub struct LocalizationHead {
/// `3 × d_model` weights.
pub w: Vec<f64>,
/// Bias.
pub b: [f64; 3],
d_model: usize,
}
impl LocalizationHead {
/// Zero-initialized (linear least squares; convex).
#[must_use]
pub fn new(d_model: usize) -> Self {
Self { w: vec![0.0; 3 * d_model], b: [0.0; 3], d_model }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.w.len() + 3
}
/// Predicted position.
#[must_use]
pub fn predict(&self, z: &[f64]) -> [f64; 3] {
let mut out = self.b;
for (axis, o) in out.iter_mut().enumerate() {
let row = &self.w[axis * self.d_model..(axis + 1) * self.d_model];
*o += row.iter().zip(z).map(|(a, b)| a * b).sum::<f64>();
}
out
}
/// Full-batch MSE training. Returns final mean squared error (m²).
pub fn train(&mut self, zs: &[Vec<f64>], targets: &[[f64; 3]], lr: f64, epochs: usize) -> f64 {
assert_eq!(zs.len(), targets.len());
let n = zs.len() as f64;
let mut final_mse = f64::INFINITY;
for _ in 0..epochs {
let mut gw = vec![0.0; self.w.len()];
let mut gb = [0.0; 3];
let mut mse = 0.0;
for (z, t) in zs.iter().zip(targets) {
let pred = self.predict(z);
for axis in 0..3 {
let err = pred[axis] - t[axis];
mse += err * err;
let scale = 2.0 * err / (3.0 * n);
gb[axis] += scale;
for (d, zv) in z.iter().enumerate() {
gw[axis * self.d_model + d] += scale * zv;
}
}
}
for (w, g) in self.w.iter_mut().zip(&gw) {
*w -= lr * g;
}
for (axis, g) in gb.iter().enumerate() {
self.b[axis] -= lr * g;
}
final_mse = mse / (3.0 * n);
}
final_mse
}
}
/// Number of skeleton joints (COCO-17 convention, matching the ruvsense
/// pose tracker).
pub const NUM_JOINTS: usize = 17;
/// Generic low-rank linear regressor `y = U·(V·x) + b` — the shared
/// building block for structured heads that must stay inside the adapter
/// budget.
#[derive(Debug, Clone)]
pub struct LowRankLinear {
/// Output dimension.
pub out: usize,
/// Rank.
pub rank: usize,
d_in: usize,
/// `out × rank`, row-major.
pub u: Vec<f64>,
/// `rank × d_in`, row-major.
pub v: Vec<f64>,
/// Bias, length `out`.
pub b: Vec<f64>,
}
impl LowRankLinear {
/// Deterministic small-value init (breaks U/V symmetry without RNG).
#[must_use]
pub fn new(d_in: usize, out: usize, rank: usize) -> Self {
let u = (0..out * rank).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
let v = (0..rank * d_in).map(|i| 0.01 * ((i % 5) as f64 - 2.0)).collect();
Self { out, rank, d_in, u, v, b: vec![0.0; out] }
}
/// Parameter count.
#[must_use]
pub fn param_count(&self) -> usize {
self.u.len() + self.v.len() + self.b.len()
}
/// Prediction.
#[must_use]
pub fn predict(&self, x: &[f64]) -> Vec<f64> {
let mut vx = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_in..(r + 1) * self.d_in];
vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum();
}
let mut y = self.b.clone();
for o in 0..self.out {
let row = &self.u[o * self.rank..(o + 1) * self.rank];
y[o] += row.iter().zip(&vx).map(|(a, b)| a * b).sum::<f64>();
}
y
}
/// Full-batch MSE training; returns the final mean squared error.
pub fn train(&mut self, xs: &[Vec<f64>], ys: &[Vec<f64>], lr: f64, epochs: usize) -> f64 {
assert_eq!(xs.len(), ys.len());
let n = xs.len() as f64;
let mut final_mse = f64::INFINITY;
for _ in 0..epochs {
let mut gu = vec![0.0; self.u.len()];
let mut gv = vec![0.0; self.v.len()];
let mut gb = vec![0.0; self.b.len()];
let mut mse = 0.0;
for (x, y) in xs.iter().zip(ys) {
let mut vx = vec![0.0; self.rank];
for r in 0..self.rank {
let row = &self.v[r * self.d_in..(r + 1) * self.d_in];
vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum();
}
let mut dvx = vec![0.0; self.rank];
for o in 0..self.out {
let row = &self.u[o * self.rank..(o + 1) * self.rank];
let pred = self.b[o]
+ row.iter().zip(&vx).map(|(a, b)| a * b).sum::<f64>();
let err = pred - y[o];
mse += err * err;
let scale = 2.0 * err / (self.out as f64 * n);
gb[o] += scale;
for r in 0..self.rank {
gu[o * self.rank + r] += scale * vx[r];
dvx[r] += scale * self.u[o * self.rank + r];
}
}
for r in 0..self.rank {
for (d, xv) in x.iter().enumerate() {
gv[r * self.d_in + d] += dvx[r] * xv;
}
}
}
for (w, g) in self.u.iter_mut().zip(&gu) {
*w -= lr * g;
}
for (w, g) in self.v.iter_mut().zip(&gv) {
*w -= lr * g;
}
for (w, g) in self.b.iter_mut().zip(&gb) {
*w -= lr * g;
}
final_mse = mse / (self.out as f64 * n);
}
final_mse
}
}
/// Factorized pose estimate (ADR-281 §4, the RePos lesson): root-relative
/// skeleton and absolute root are separate quantities with separate
/// uncertainties.
#[derive(Debug, Clone)]
pub struct PoseOutput {
/// Root-relative joint positions, metres.
pub relative_joints_m: [[f64; 3]; NUM_JOINTS],
/// Absolute root position, metres, building frame.
pub root_position_m: [f64; 3],
/// Per-joint 1σ uncertainty, metres (calibrated from training residuals).
pub joint_uncertainty_m: [f64; NUM_JOINTS],
/// Root 1σ uncertainty, metres.
pub root_uncertainty_m: f64,
}
impl PoseOutput {
/// Absolute joints: `root + relative` (the RePos composition).
#[must_use]
pub fn absolute_joints_m(&self) -> [[f64; 3]; NUM_JOINTS] {
let mut out = self.relative_joints_m;
for j in out.iter_mut() {
for k in 0..3 {
j[k] += self.root_position_m[k];
}
}
out
}
}
/// Factorized pose head: the **relative skeleton** branch reads the
/// environment-invariant content representation (so it cannot learn
/// room-specific position shortcuts), while the **root localization**
/// branch reads the geometry-conditioned representation (where sensor
/// pose is signal). This is the anti-leakage factorization measured in
/// `tests::factorized_pose_resists_room_shortcut_leakage`.
#[derive(Debug, Clone)]
pub struct FactorizedPoseHead {
/// Relative-skeleton regressor on the content representation.
pub relative: LowRankLinear,
/// Root regressor on the geometry-conditioned representation.
pub root: LowRankLinear,
/// Calibrated per-joint residual σ, metres.
pub joint_residual_std_m: [f64; NUM_JOINTS],
/// Calibrated root residual σ, metres.
pub root_residual_std_m: f64,
}
impl FactorizedPoseHead {
/// New head for the given representation dims and rank.
#[must_use]
pub fn new(d_content: usize, d_full: usize, rank: usize) -> Self {
Self {
relative: LowRankLinear::new(d_content, NUM_JOINTS * 3, rank),
root: LowRankLinear::new(d_full, 3, rank),
joint_residual_std_m: [f64::INFINITY; NUM_JOINTS],
root_residual_std_m: f64::INFINITY,
}
}
/// Parameter count (both branches + calibration statistics).
#[must_use]
pub fn param_count(&self) -> usize {
self.relative.param_count() + self.root.param_count() + NUM_JOINTS + 1
}
/// Trains both branches and calibrates residual uncertainties.
/// Returns `(relative_mse, root_mse)` in m².
pub fn train(
&mut self,
content_zs: &[Vec<f64>],
full_zs: &[Vec<f64>],
relative_targets: &[[[f64; 3]; NUM_JOINTS]],
root_targets: &[[f64; 3]],
lr: f64,
epochs: usize,
) -> (f64, f64) {
let rel_flat: Vec<Vec<f64>> = relative_targets
.iter()
.map(|j| j.iter().flatten().copied().collect())
.collect();
let root_flat: Vec<Vec<f64>> = root_targets.iter().map(|r| r.to_vec()).collect();
let rel_mse = self.relative.train(content_zs, &rel_flat, lr, epochs);
let root_mse = self.root.train(full_zs, &root_flat, lr, epochs);
// Calibrate per-joint residual σ on the training set.
let n = content_zs.len().max(1) as f64;
let mut joint_sq = [0.0f64; NUM_JOINTS];
let mut root_sq = 0.0;
for i in 0..content_zs.len() {
let rel = self.relative.predict(&content_zs[i]);
for j in 0..NUM_JOINTS {
let mut d2 = 0.0;
for k in 0..3 {
d2 += (rel[j * 3 + k] - relative_targets[i][j][k]).powi(2);
}
joint_sq[j] += d2;
}
let root = self.root.predict(&full_zs[i]);
root_sq += (0..3).map(|k| (root[k] - root_targets[i][k]).powi(2)).sum::<f64>();
}
for j in 0..NUM_JOINTS {
self.joint_residual_std_m[j] = (joint_sq[j] / n).sqrt();
}
self.root_residual_std_m = (root_sq / n).sqrt();
(rel_mse, root_mse)
}
/// Predicts a factorized pose with calibrated uncertainties.
#[must_use]
pub fn predict(&self, content_z: &[f64], full_z: &[f64]) -> PoseOutput {
let rel = self.relative.predict(content_z);
let root = self.root.predict(full_z);
let mut relative_joints_m = [[0.0; 3]; NUM_JOINTS];
for j in 0..NUM_JOINTS {
for k in 0..3 {
relative_joints_m[j][k] = rel[j * 3 + k];
}
}
PoseOutput {
relative_joints_m,
root_position_m: [root[0], root[1], root[2]],
joint_uncertainty_m: self.joint_residual_std_m,
root_uncertainty_m: self.root_residual_std_m,
}
}
}
/// Anomaly head: z-scores the encoder's masked-reconstruction error against
/// a calibration distribution of *normal* windows. No learned weights —
/// two calibration statistics.
#[derive(Debug, Clone)]
pub struct AnomalyHead {
/// Calibration mean of reconstruction error.
pub mean: f64,
/// Calibration standard deviation.
pub std: f64,
}
impl AnomalyHead {
/// Calibrates from reconstruction errors of known-normal windows.
///
/// # Panics
/// If fewer than 2 calibration samples are provided.
#[must_use]
pub fn calibrate(normal_errors: &[f64]) -> Self {
assert!(normal_errors.len() >= 2, "need >= 2 calibration errors");
let n = normal_errors.len() as f64;
let mean = normal_errors.iter().sum::<f64>() / n;
let var = normal_errors.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1.0);
Self { mean, std: var.sqrt().max(1e-12) }
}
/// Parameter count (two statistics).
#[must_use]
pub fn param_count(&self) -> usize {
2
}
/// Anomaly z-score for a window's reconstruction error.
#[must_use]
pub fn score(&self, recon_error: f64) -> f64 {
(recon_error - self.mean) / self.std
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoder::{EncoderConfig, RfEncoder};
#[test]
fn every_head_fits_the_one_percent_budget_at_deployment_config() {
let enc = RfEncoder::new(EncoderConfig::default(), 1);
let backbone = enc.param_count();
let d = EncoderConfig::default().d_model;
let presence = PresenceHead::new(d).param_count();
let activity = ActivityHead::new(d, 4, 2).param_count();
let localization = LocalizationHead::new(d).param_count();
let anomaly = AnomalyHead { mean: 0.0, std: 1.0 }.param_count();
for (name, p) in [
("presence", presence),
("activity", activity),
("localization", localization),
("anomaly", anomaly),
] {
assert!(
within_adapter_budget(backbone, p),
"{name} head has {p} params, budget is <{}",
backbone / 100
);
}
// The structured pose head is the largest adapter; its documented
// budget is < 2 % of the backbone (ADR-281 §4).
let pose = FactorizedPoseHead::new(enc.content_dim(), d, 2).param_count();
assert!(pose * 100 < backbone * 2, "pose head {pose} params vs 2 % of {backbone}");
println!(
"backbone {backbone} params; heads: presence {presence}, activity {activity}, \
localization {localization}, anomaly {anomaly} (budget < {}), pose {pose} (< 2 %)",
backbone / 100
);
}
/// The RePos leakage experiment: in the training rooms, room position
/// correlates with body scale (small people in room A, tall in room B).
/// A monolithic absolute-pose head exploits the room feature as a
/// shortcut and collapses in an unseen room that breaks the
/// correlation; the factorized head's skeleton branch never sees room
/// features and generalizes.
#[test]
fn factorized_pose_resists_room_shortcut_leakage() {
use crate::eval::mpjpe;
// 17 fixed joint directions (deterministic).
let dirs: Vec<[f64; 3]> = (0..NUM_JOINTS)
.map(|j| {
let a = j as f64 * 0.37;
[a.cos() * 0.3, a.sin() * 0.3, 0.1 * ((j % 5) as f64 - 2.0)]
})
.collect();
let skeleton = |scale: f64| {
let mut joints = [[0.0; 3]; NUM_JOINTS];
for (j, d) in dirs.iter().enumerate() {
for k in 0..3 {
joints[j][k] = d[k] * (1.0 + 0.5 * scale);
}
}
joints
};
// content z = [scale, 1]; full z = [scale, room_x/3, 1].
let sample = |scale: f64, room_x: f64| {
let content = vec![scale, 1.0];
let full = vec![scale, room_x / 3.0, 1.0];
let root = [room_x + 0.5, 2.0, 1.0];
(content, full, skeleton(scale), root)
};
// Training: room A (x=0) only small scales, room B (x=3) only large —
// the leakage trap.
let mut content_zs = Vec::new();
let mut full_zs = Vec::new();
let mut rels = Vec::new();
let mut roots = Vec::new();
for i in 0..30 {
let s = -1.0 + i as f64 / 30.0; // [-1, 0)
let (c, f, r, ro) = sample(s, 0.0);
content_zs.push(c);
full_zs.push(f);
rels.push(r);
roots.push(ro);
let s = i as f64 / 30.0; // [0, 1)
let (c, f, r, ro) = sample(s, 3.0);
content_zs.push(c);
full_zs.push(f);
rels.push(r);
roots.push(ro);
}
let mut head = FactorizedPoseHead::new(2, 3, 2);
let (rel_mse, root_mse) = head.train(&content_zs, &full_zs, &rels, &roots, 0.3, 3000);
assert!(rel_mse < 1e-3, "relative branch must fit, mse {rel_mse}");
assert!(root_mse < 1e-3, "root branch must fit, mse {root_mse}");
// Monolithic baseline: absolute joints regressed from the full
// (room-conditioned) representation.
let abs_targets: Vec<Vec<f64>> = rels
.iter()
.zip(&roots)
.map(|(rel, root)| {
rel.iter().flat_map(|j| (0..3).map(move |k| j[k] + root[k])).collect()
})
.collect();
let mut monolithic = LowRankLinear::new(3, NUM_JOINTS * 3, 2);
monolithic.train(&full_zs, &abs_targets, 0.3, 3000);
// Held-out room (x=6) with the correlation broken: both scales.
let mut fact_err = 0.0;
let mut mono_err = 0.0;
let mut count = 0.0;
for i in 0..20 {
let s = -1.0 + i as f64 / 10.0; // [-1, 1)
let (c, f, rel, root) = sample(s, 6.0);
let truth: Vec<[f64; 3]> =
rel.iter().zip(std::iter::repeat(root)).map(|(j, r)| {
[j[0] + r[0], j[1] + r[1], j[2] + r[2]]
}).collect();
let pose = head.predict(&c, &f);
let fact_abs = pose.absolute_joints_m();
fact_err += mpjpe(&fact_abs, &truth);
let mono = monolithic.predict(&f);
let mono_abs: Vec<[f64; 3]> = (0..NUM_JOINTS)
.map(|j| [mono[j * 3], mono[j * 3 + 1], mono[j * 3 + 2]])
.collect();
mono_err += mpjpe(&mono_abs, &truth);
count += 1.0;
// ADR-273 acceptance item: every output carries uncertainty.
assert!(pose.root_uncertainty_m.is_finite());
assert!(pose.joint_uncertainty_m.iter().all(|u| u.is_finite() && *u >= 0.0));
}
fact_err /= count;
mono_err /= count;
println!(
"held-out room MPJPE: factorized {fact_err:.4} m vs monolithic {mono_err:.4} m"
);
assert!(fact_err < 0.15, "factorized head must generalize, MPJPE {fact_err}");
assert!(
mono_err > 1.5 * fact_err,
"monolithic head must show the shortcut collapse: {mono_err} vs {fact_err}"
);
}
fn separable_data(n: usize, d: usize) -> (Vec<Vec<f64>>, Vec<bool>) {
let mut zs = Vec::new();
let mut ys = Vec::new();
for i in 0..n {
let y = i % 2 == 0;
let offset = if y { 1.0 } else { -1.0 };
let z: Vec<f64> =
(0..d).map(|k| offset * (0.5 + (k as f64 / d as f64)) + 0.1 * ((i * k) % 3) as f64).collect();
zs.push(z);
ys.push(y);
}
(zs, ys)
}
#[test]
fn presence_head_learns_separable_data() {
let (zs, ys) = separable_data(60, 16);
let mut head = PresenceHead::new(16);
let ce = head.train(&zs, &ys, 0.5, 200);
assert!(ce < 0.1, "cross-entropy should collapse on separable data, got {ce}");
let correct = zs
.iter()
.zip(&ys)
.filter(|(z, &y)| (head.predict_prob(z) > 0.5) == y)
.count();
assert_eq!(correct, zs.len());
}
#[test]
fn activity_head_learns_multiclass_toy() {
let d = 16;
let mut zs = Vec::new();
let mut ys = Vec::new();
for i in 0..90 {
let c = i % 3;
let z: Vec<f64> = (0..d)
.map(|k| if k % 3 == c { 1.0 } else { 0.0 } + 0.05 * ((i + k) % 5) as f64)
.collect();
zs.push(z);
ys.push(c);
}
let mut head = ActivityHead::new(d, 3, 2);
let ce = head.train(&zs, &ys, 0.5, 400);
assert!(ce < 0.3, "multiclass CE should drop, got {ce}");
let acc = zs
.iter()
.zip(&ys)
.filter(|(z, &y)| {
let p = head.predict(z);
p.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap().0 == y
})
.count() as f64
/ zs.len() as f64;
assert!(acc > 0.95, "accuracy {acc}");
}
#[test]
fn anomaly_head_zscores_against_calibration() {
let normal: Vec<f64> = (0..50).map(|i| 0.10 + 0.001 * (i % 7) as f64).collect();
let head = AnomalyHead::calibrate(&normal);
assert!(head.score(0.103).abs() < 3.0, "in-distribution error must not alarm");
assert!(head.score(0.5) > 10.0, "gross error must alarm loudly");
}
}
+88
View File
@@ -0,0 +1,88 @@
//! # ruview-unified — Unified RF Spatial World Model (ADR-273)
//!
//! One shared representation where WiFi CSI, cellular SRS, FMCW radar, UWB
//! CIR, geometry, semantics, uncertainty, and time all update the same
//! persistent scene memory, instead of another isolated RF classifier.
//!
//! The crate implements the five ADR-273 pillars as bounded modules:
//!
//! | Pillar | Module | ADR |
//! |--------|--------|-----|
//! | Canonical RF tensor + hardware adapter registry | [`tensor`], [`adapters`] | ADR-274 |
//! | Universal RF foundation encoder (masked-reconstruction pretraining, age/geometry/uncertainty fusion, ≤1 % task adapters) | [`tokenizer`], [`encoder`], [`pretrain`], [`heads`] | ADR-274 |
//! | Anti-leakage evaluation: strict partitions, calibration, abstention | [`eval`] | ADR-273 §5 |
//! | RF-aware Gaussian spatial memory + task-gated scene graph | [`gaussian`] | ADR-275 |
//! | Physics-guided synthetic RF world generator | [`synth`] | ADR-276 |
//! | Edge sensing control plane (802.11bf / ETSI ISAC-aligned policy) | [`policy`] | ADR-277 |
//!
//! ## Design commitments
//!
//! - **Deterministic**: every stochastic step (weight init, masking, domain
//! randomization) is seeded ChaCha20; same seed ⇒ identical results across
//! machines (the nvsim commitment).
//! - **Proven, not asserted**: the encoder's backward pass is verified against
//! finite differences; the ray tracer is verified against Friis, reciprocity,
//! and image-method geometry; the Gaussian gain model degrades to exact
//! free-space when the map is empty.
//! - **Honest labeling**: every accuracy number produced by this crate's tests
//! is SYNTHETIC (generated by [`synth`]) until validated on measured data.
//! - **Privacy fail-closed**: raw RF never crosses the trust boundary; only
//! [`policy::BoundedEvent`] (uncertainty + provenance + model version +
//! purpose, all mandatory) is exportable, and unknown zones/purposes deny.
// The numeric kernels (encoder forward/backward, low-rank heads) index
// several parallel arrays per iteration; index loops are the clearest and
// equally fast form there.
#![allow(clippy::needless_range_loop)]
pub mod adapters;
pub mod control;
pub mod encoder;
pub mod eval;
pub mod frame;
pub mod gaussian;
pub mod heads;
pub mod math;
pub mod policy;
pub mod pretrain;
pub mod synth;
pub mod tensor;
pub mod tokenizer;
pub use adapters::{AdapterRegistry, RawCapture, RfAdapter};
pub use encoder::{EncoderConfig, RfEncoder, WindowContext};
pub use tensor::{CalibrationMeta, LinkGeometry, RfModality, RfTensor};
/// Errors produced at the crate's system boundaries.
///
/// Input validation happens at construction ([`RfTensor::new`]) and at
/// adapter normalization; downstream modules may assume validated tensors.
#[derive(Debug, thiserror::Error)]
pub enum UnifiedError {
/// A numeric field was non-finite or out of its documented range.
#[error("invalid input at boundary: {0}")]
InvalidInput(String),
/// Tensor shape does not match its declared link geometry.
#[error("shape mismatch: {0}")]
ShapeMismatch(String),
/// An adapter was handed a capture of the wrong modality.
#[error("modality mismatch: adapter {adapter} cannot normalize {got:?}")]
ModalityMismatch {
/// Hardware id of the adapter that rejected the capture.
adapter: String,
/// Modality of the capture that was offered.
got: tensor::RfModality,
},
/// No adapter registered for the requested hardware id.
#[error("no adapter registered for hardware id {0:?}")]
UnknownHardware(String),
/// Model/data dimension disagreement (programmer error surfaced safely).
#[error("dimension mismatch: {0}")]
DimensionMismatch(String),
/// The sensing control plane denied the operation (fail-closed).
#[error("policy denied: {0}")]
PolicyDenied(String),
}
/// Crate-wide result alias.
pub type Result<T> = std::result::Result<T, UnifiedError>;
+282
View File
@@ -0,0 +1,282 @@
//! Small, dependency-free numeric kernels shared across the crate.
//!
//! Everything here is deterministic and exact enough to be tested against
//! closed forms: `erf` is AbramowitzStegun 7.1.26 (|ε| ≤ 1.5e-7), the DFT is
//! the O(n²) definition (n ≤ 64 throughout this crate, so an FFT dependency
//! would buy nothing), and the resampler is linear interpolation on the
//! complex plane (amplitude/phase-continuous for the small bin ratios the
//! adapters use).
use num_complex::Complex64;
use rand::Rng;
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
/// Error function, Abramowitz & Stegun 7.1.26 rational approximation.
///
/// Maximum absolute error 1.5e-7 — far below the opacity resolution the
/// Gaussian gain model needs.
#[must_use]
pub fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let poly = t
* (0.254_829_592
+ t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
sign * (1.0 - poly * (-x * x).exp())
}
/// Numerically stable logistic sigmoid.
#[must_use]
pub fn sigmoid(x: f64) -> f64 {
if x >= 0.0 {
1.0 / (1.0 + (-x).exp())
} else {
let e = x.exp();
e / (1.0 + e)
}
}
/// In-place stable softmax.
pub fn softmax(v: &mut [f64]) {
let max = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let mut sum = 0.0;
for x in v.iter_mut() {
*x = (*x - max).exp();
sum += *x;
}
for x in v.iter_mut() {
*x /= sum;
}
}
/// Magnitudes of the first `k` DFT coefficients of `x` (definition-form DFT).
///
/// Used for delay-domain (across subcarriers) and Doppler-domain (across
/// snapshots) token features. `k ≤ x.len()` is enforced by the callers.
#[must_use]
pub fn dft_magnitudes(x: &[Complex64], k: usize) -> Vec<f64> {
let n = x.len();
let mut out = Vec::with_capacity(k);
for bin in 0..k {
let mut acc = Complex64::new(0.0, 0.0);
for (t, v) in x.iter().enumerate() {
let ang = -2.0 * std::f64::consts::PI * (bin as f64) * (t as f64) / (n as f64);
acc += v * Complex64::new(ang.cos(), ang.sin());
}
out.push(acc.norm() / n as f64);
}
out
}
/// Precomputed twiddle table for repeated fixed-size DFTs.
///
/// The naive [`dft_magnitudes`] recomputes `cos`/`sin` per sample; the
/// tokenizer calls the transform once per token, so the table amortizes the
/// trig. The optimization is *proven equivalent* in `tokenizer::tests` and
/// its speedup is measured in `benches/unified_bench.rs`.
pub struct DftPlan {
n: usize,
k: usize,
/// Row-major `k × n` twiddles: `exp(-2πi·bin·t/n)`.
twiddles: Vec<Complex64>,
}
impl DftPlan {
/// Builds a plan for length-`n` inputs and `k` output bins.
#[must_use]
pub fn new(n: usize, k: usize) -> Self {
let mut twiddles = Vec::with_capacity(k * n);
for bin in 0..k {
for t in 0..n {
let ang = -2.0 * std::f64::consts::PI * (bin as f64) * (t as f64) / (n as f64);
twiddles.push(Complex64::new(ang.cos(), ang.sin()));
}
}
Self { n, k, twiddles }
}
/// DFT magnitudes via the precomputed table; identical (to f64 rounding)
/// to [`dft_magnitudes`] on the same input.
///
/// # Panics
/// If `x.len()` differs from the planned length.
#[must_use]
pub fn magnitudes(&self, x: &[Complex64]) -> Vec<f64> {
assert_eq!(x.len(), self.n, "DftPlan length mismatch");
let mut out = Vec::with_capacity(self.k);
for bin in 0..self.k {
let row = &self.twiddles[bin * self.n..(bin + 1) * self.n];
let mut acc = Complex64::new(0.0, 0.0);
for (v, w) in x.iter().zip(row) {
acc += v * w;
}
out.push(acc.norm() / self.n as f64);
}
out
}
}
/// Linear interpolation of a complex series onto `m` uniformly spaced points.
///
/// Interpolates real and imaginary parts independently — adequate for the
/// small resampling ratios (≤ 2×) the adapters perform, and exactly identity
/// when `m == x.len()`.
#[must_use]
pub fn resample_complex(x: &[Complex64], m: usize) -> Vec<Complex64> {
let n = x.len();
if n == m {
return x.to_vec();
}
if n == 1 {
return vec![x[0]; m];
}
if m == 1 {
// `(m - 1)` would divide by zero below; a single output point is the
// mean of the series rather than an arbitrary NaN-poisoned sample.
let sum: Complex64 = x.iter().copied().sum();
return vec![sum / n as f64];
}
let mut out = Vec::with_capacity(m);
for j in 0..m {
let pos = (j as f64) * ((n - 1) as f64) / ((m - 1) as f64);
let i0 = pos.floor() as usize;
let i1 = (i0 + 1).min(n - 1);
let frac = pos - i0 as f64;
out.push(x[i0] * (1.0 - frac) + x[i1] * frac);
}
out
}
/// Median of a slice (copies; slices here are ≤ a few hundred elements).
#[must_use]
pub fn median(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut v: Vec<f64> = values.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = v.len() / 2;
if v.len() % 2 == 0 {
(v[mid - 1] + v[mid]) / 2.0
} else {
v[mid]
}
}
/// Least-squares slope of `y` against index `0..n` (used to detrend the
/// linear phase ramp that sampling-time offset imprints across subcarriers).
#[must_use]
pub fn linear_slope(y: &[f64]) -> f64 {
let n = y.len();
if n < 2 {
return 0.0;
}
let nf = n as f64;
let mean_x = (nf - 1.0) / 2.0;
let mean_y = y.iter().sum::<f64>() / nf;
let mut num = 0.0;
let mut den = 0.0;
for (i, v) in y.iter().enumerate() {
let dx = i as f64 - mean_x;
num += dx * (v - mean_y);
den += dx * dx;
}
num / den
}
/// Deterministic RNG from a u64 seed (ChaCha20, the nvsim convention).
#[must_use]
pub fn seeded_rng(seed: u64) -> ChaCha20Rng {
ChaCha20Rng::seed_from_u64(seed)
}
/// Xavier/Glorot-uniform init for a `rows × cols` weight matrix, flattened
/// row-major. Deterministic given the RNG state.
pub fn xavier_init(rng: &mut ChaCha20Rng, rows: usize, cols: usize) -> Vec<f64> {
let limit = (6.0 / (rows + cols) as f64).sqrt();
(0..rows * cols).map(|_| rng.gen_range(-limit..limit)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn erf_matches_known_values() {
// erf(0)=0, erf(∞)→1, erf(1)=0.8427007929 (tabulated).
// Tolerances are the A&S 7.1.26 approximation bound (1.5e-7), not
// machine epsilon — at x=0 the rational polynomial leaves ~1e-9.
assert!(erf(0.0).abs() < 2e-7);
assert!((erf(1.0) - 0.842_700_792_9).abs() < 2e-7);
assert!((erf(-1.0) + 0.842_700_792_9).abs() < 2e-7);
assert!((erf(3.0) - 0.999_977_909_5).abs() < 2e-7);
}
#[test]
fn sigmoid_is_stable_and_symmetric() {
assert!((sigmoid(0.0) - 0.5).abs() < 1e-12);
assert!((sigmoid(500.0) - 1.0).abs() < 1e-12);
assert!(sigmoid(-500.0) >= 0.0);
assert!((sigmoid(2.0) + sigmoid(-2.0) - 1.0).abs() < 1e-12);
}
#[test]
fn dft_finds_pure_tone() {
// x[t] = exp(2πi·3t/16) has all its energy in bin 3.
let n = 16;
let x: Vec<Complex64> = (0..n)
.map(|t| {
let ang = 2.0 * std::f64::consts::PI * 3.0 * t as f64 / n as f64;
Complex64::new(ang.cos(), ang.sin())
})
.collect();
let mags = dft_magnitudes(&x, 8);
assert!((mags[3] - 1.0).abs() < 1e-9);
for (i, m) in mags.iter().enumerate() {
if i != 3 {
assert!(*m < 1e-9, "leakage at bin {i}: {m}");
}
}
}
#[test]
fn dft_plan_matches_naive() {
let mut rng = seeded_rng(7);
let x: Vec<Complex64> = (0..24)
.map(|_| Complex64::new(rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
.collect();
let plan = DftPlan::new(24, 10);
let a = dft_magnitudes(&x, 10);
let b = plan.magnitudes(&x);
for (u, v) in a.iter().zip(&b) {
assert!((u - v).abs() < 1e-12);
}
}
#[test]
fn resample_identity_and_endpoints() {
let x: Vec<Complex64> = (0..10).map(|i| Complex64::new(i as f64, -(i as f64))).collect();
assert_eq!(resample_complex(&x, 10), x);
let y = resample_complex(&x, 25);
assert_eq!(y.len(), 25);
assert!((y[0] - x[0]).norm() < 1e-12);
assert!((y[24] - x[9]).norm() < 1e-12);
}
#[test]
fn slope_recovers_linear_ramp() {
let y: Vec<f64> = (0..50).map(|i| 0.37 * i as f64 + 2.0).collect();
assert!((linear_slope(&y) - 0.37).abs() < 1e-12);
}
#[test]
fn seeded_rng_is_deterministic() {
let mut a = seeded_rng(42);
let mut b = seeded_rng(42);
let va: Vec<f64> = (0..8).map(|_| a.gen_range(-1.0..1.0)).collect();
let vb: Vec<f64> = (0..8).map(|_| b.gen_range(-1.0..1.0)).collect();
assert_eq!(va, vb);
}
}
+324
View File
@@ -0,0 +1,324 @@
//! Edge sensing control plane (ADR-277) — purposes, zones, retention,
//! identity gating, and the export trust boundary.
//!
//! Aligned with the sensing-service vocabulary of IEEE 802.11bf-2025 and
//! the ETSI ISAC architecture (sensing purpose + sensing zone as first-class
//! authorization objects; the ETSI security report's issue classes motivate
//! the fail-closed defaults). Three hard rules, all enforced structurally:
//!
//! 1. **Raw RF never leaves the trust boundary.** The only exportable type
//! is [`BoundedEvent`] — it cannot carry a tensor, and
//! [`TrustBoundary::export`] is the only egress. There is deliberately
//! no API that serializes an [`crate::tensor::RfTensor`] outward.
//! 2. **Fail closed.** Unknown zone ⇒ deny. Purpose not granted ⇒ deny.
//! Identity inference ⇒ deny unless the zone *explicitly* enables it in
//! addition to granting the purpose.
//! 3. **Every output is accountable.** A [`BoundedEvent`] cannot be built
//! without uncertainty, provenance, model version, and purpose
//! (ADR-273 acceptance item 8).
use std::collections::{BTreeSet, HashMap};
use serde::{Deserialize, Serialize};
use crate::gaussian::primitive::Provenance;
use crate::{Result, UnifiedError};
/// Sensing purposes (ETSI ISAC sensing-service classes, WLAN-sensing
/// aligned). Ordering matters only for display.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum SensingPurpose {
/// Someone is / is not present.
Presence,
/// Coarse activity class.
Activity,
/// Respiration / heart-rate class vitals.
Vitals,
/// Position estimation.
Localization,
/// Skeletal pose tracking.
PoseTracking,
/// Identity recognition — the high-risk purpose; doubly gated.
IdentityRecognition,
/// RF channel diagnostics (no human inference).
ChannelDiagnostics,
}
/// A spatial sensing zone and what it permits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacyZone {
/// Zone identifier (maps to rooms/regions in the scene graph).
pub id: String,
/// Purposes granted in this zone.
pub allowed_purposes: BTreeSet<SensingPurpose>,
/// Maximum event age at export, seconds (retention bound).
pub retention_s: u64,
/// Second factor for identity: even if `IdentityRecognition` is in
/// `allowed_purposes`, it is denied unless this is also true.
pub identity_explicitly_enabled: bool,
}
/// Payload of a bounded event — semantically typed results only; no
/// variant can carry raw RF samples.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EventValue {
/// Presence verdict.
Presence(bool),
/// Activity class index.
ActivityClass(u8),
/// Respiration rate, breaths/minute.
RespirationBpm(f64),
/// Position estimate, metres, room frame.
Location([f64; 3]),
/// Anomaly z-score.
AnomalyScore(f64),
}
/// The only type allowed across the trust boundary. Construction validates
/// that the accountability fields are present and sane.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundedEvent {
/// Purpose under which this event was produced.
pub purpose: SensingPurpose,
/// Typed result.
pub value: EventValue,
/// Mandatory uncertainty in `[0, 1]` (1 = no information).
pub uncertainty: f64,
/// Evidence provenance (device, model, synthetic flag).
pub provenance: Provenance,
/// Model version that produced the inference.
pub model_version: u32,
/// Event timestamp, ns since epoch.
pub timestamp_ns: u64,
/// Zone the event was sensed in.
pub zone_id: String,
}
impl BoundedEvent {
/// Validated constructor — the only way to build an exportable event.
pub fn new(
purpose: SensingPurpose,
value: EventValue,
uncertainty: f64,
provenance: Provenance,
model_version: u32,
timestamp_ns: u64,
zone_id: impl Into<String>,
) -> Result<Self> {
if !(0.0..=1.0).contains(&uncertainty) {
return Err(UnifiedError::InvalidInput(format!(
"uncertainty must be in [0,1], got {uncertainty}"
)));
}
if model_version == 0 {
return Err(UnifiedError::InvalidInput(
"model_version 0 (unassigned) is not exportable".into(),
));
}
Ok(Self {
purpose,
value,
uncertainty,
provenance,
model_version,
timestamp_ns,
zone_id: zone_id.into(),
})
}
}
/// The policy engine: zone registry + authorization checks.
#[derive(Debug, Default)]
pub struct PolicyEngine {
zones: HashMap<String, PrivacyZone>,
}
impl PolicyEngine {
/// Empty engine (denies everything until zones are configured).
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Registers or replaces a zone.
pub fn upsert_zone(&mut self, zone: PrivacyZone) {
self.zones.insert(zone.id.clone(), zone);
}
/// Authorizes sensing for `purpose` in `zone_id`. Fail-closed on every
/// branch: unknown zone, ungranted purpose, and the identity double
/// gate all deny.
pub fn authorize(&self, zone_id: &str, purpose: SensingPurpose) -> Result<()> {
let zone = self
.zones
.get(zone_id)
.ok_or_else(|| UnifiedError::PolicyDenied(format!("unknown zone {zone_id:?}")))?;
if !zone.allowed_purposes.contains(&purpose) {
return Err(UnifiedError::PolicyDenied(format!(
"purpose {purpose:?} not granted in zone {zone_id:?}"
)));
}
if purpose == SensingPurpose::IdentityRecognition && !zone.identity_explicitly_enabled {
return Err(UnifiedError::PolicyDenied(format!(
"identity recognition requires explicit enablement in zone {zone_id:?}"
)));
}
Ok(())
}
}
/// The egress point. Holds the policy engine and a monotonically supplied
/// "now"; the **only** public method emits [`BoundedEvent`]s — raw RF has no
/// path through here by construction.
#[derive(Debug, Default)]
pub struct TrustBoundary {
engine: PolicyEngine,
}
impl TrustBoundary {
/// New boundary over a configured engine.
#[must_use]
pub fn new(engine: PolicyEngine) -> Self {
Self { engine }
}
/// Zone-config passthrough.
pub fn engine_mut(&mut self) -> &mut PolicyEngine {
&mut self.engine
}
/// Exports an event if — and only if — the zone grants its purpose and
/// the event is inside the zone's retention window at `now_ns`.
/// Returns the event back on success so callers can hand it to a
/// transport; on denial the event is dropped with a typed error.
pub fn export(&self, event: BoundedEvent, now_ns: u64) -> Result<BoundedEvent> {
self.engine.authorize(&event.zone_id, event.purpose)?;
let zone = self
.engine
.zones
.get(&event.zone_id)
.expect("authorize verified the zone exists");
let age_s = now_ns.saturating_sub(event.timestamp_ns) / 1_000_000_000;
if age_s > zone.retention_s {
return Err(UnifiedError::PolicyDenied(format!(
"event age {age_s}s exceeds zone retention {}s",
zone.retention_s
)));
}
Ok(event)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn prov() -> Provenance {
Provenance { device_id: "esp32s3-a1".into(), model_version: 3, synthetic: false }
}
fn zone(purposes: &[SensingPurpose], identity: bool) -> PrivacyZone {
PrivacyZone {
id: "living-room".into(),
allowed_purposes: purposes.iter().copied().collect(),
retention_s: 3600,
identity_explicitly_enabled: identity,
}
}
fn event(purpose: SensingPurpose, ts: u64) -> BoundedEvent {
BoundedEvent::new(
purpose,
EventValue::Presence(true),
0.12,
prov(),
3,
ts,
"living-room",
)
.expect("valid event")
}
#[test]
fn unknown_zone_denies() {
let boundary = TrustBoundary::new(PolicyEngine::new());
let err = boundary.export(event(SensingPurpose::Presence, 0), 0).unwrap_err();
assert!(matches!(err, UnifiedError::PolicyDenied(_)));
}
#[test]
fn ungranted_purpose_denies() {
let mut engine = PolicyEngine::new();
engine.upsert_zone(zone(&[SensingPurpose::Presence], false));
let boundary = TrustBoundary::new(engine);
assert!(boundary.export(event(SensingPurpose::Presence, 0), 0).is_ok());
assert!(matches!(
boundary.export(event(SensingPurpose::Localization, 0), 0),
Err(UnifiedError::PolicyDenied(_))
));
}
#[test]
fn identity_needs_both_grant_and_explicit_enable() {
// Granted in purposes but NOT explicitly enabled ⇒ deny.
let mut engine = PolicyEngine::new();
engine.upsert_zone(zone(
&[SensingPurpose::Presence, SensingPurpose::IdentityRecognition],
false,
));
assert!(matches!(
engine.authorize("living-room", SensingPurpose::IdentityRecognition),
Err(UnifiedError::PolicyDenied(_))
));
// Both factors present ⇒ allow.
engine.upsert_zone(zone(
&[SensingPurpose::Presence, SensingPurpose::IdentityRecognition],
true,
));
assert!(engine.authorize("living-room", SensingPurpose::IdentityRecognition).is_ok());
// Explicit flag alone (purpose not granted) ⇒ still deny.
engine.upsert_zone(zone(&[SensingPurpose::Presence], true));
assert!(engine.authorize("living-room", SensingPurpose::IdentityRecognition).is_err());
}
#[test]
fn retention_bound_is_enforced() {
let mut engine = PolicyEngine::new();
engine.upsert_zone(zone(&[SensingPurpose::Presence], false));
let boundary = TrustBoundary::new(engine);
let e = event(SensingPurpose::Presence, 0);
// Within retention (1 h): fine.
assert!(boundary.export(e.clone(), 3_500 * 1_000_000_000).is_ok());
// Beyond retention: denied.
assert!(matches!(
boundary.export(e, 3_700 * 1_000_000_000),
Err(UnifiedError::PolicyDenied(_))
));
}
#[test]
fn accountability_fields_are_mandatory() {
// Out-of-range uncertainty refuses construction.
assert!(BoundedEvent::new(
SensingPurpose::Presence,
EventValue::Presence(true),
1.5,
prov(),
3,
0,
"z"
)
.is_err());
// Unassigned model version refuses construction.
assert!(BoundedEvent::new(
SensingPurpose::Presence,
EventValue::Presence(true),
0.1,
prov(),
0,
0,
"z"
)
.is_err());
}
}
+447
View File
@@ -0,0 +1,447 @@
//! Masked-reconstruction pretraining for the RF foundation encoder
//! (ADR-274 §3.2), with an exact hand-derived backward pass.
//!
//! The correctness argument is not "the loss went down" alone: the analytic
//! gradients of *every* parameter group are verified against central finite
//! differences (`tests::gradients_match_finite_differences`), which pins the
//! backward pass to the forward pass to ~1e-8 relative error. The training
//! loop is then ordinary SGD.
use rand::seq::SliceRandom;
use rand::Rng;
use crate::encoder::{ForwardCache, Linear, RfEncoder, WindowContext};
use crate::math::seeded_rng;
use crate::tokenizer::{position_encoding, RfToken, TokenizedWindow};
/// Gradient accumulator mirroring [`RfEncoder`]'s parameter groups.
pub struct EncoderGrads {
/// Token embedding grads.
pub w1: Linear,
/// Context mixing 1 grads.
pub w2: Linear,
/// Context mixing 2 grads.
pub w2b: Linear,
/// Age gate weight grads.
pub age_w: Vec<f64>,
/// Age gate bias grads.
pub age_b: Vec<f64>,
/// Geometry encoder grads.
pub wg: Linear,
/// Reconstruction head grads.
pub w3: Linear,
}
impl EncoderGrads {
fn zeros(enc: &RfEncoder) -> Self {
Self {
w1: enc.w1.zeros_like(),
w2: enc.w2.zeros_like(),
w2b: enc.w2b.zeros_like(),
age_w: vec![0.0; enc.age_w.len()],
age_b: vec![0.0; enc.age_b.len()],
wg: enc.wg.zeros_like(),
w3: enc.w3.zeros_like(),
}
}
}
/// Mean-squared masked-reconstruction loss for one window under a fixed mask.
#[must_use]
pub fn masked_loss(enc: &RfEncoder, tokens: &[RfToken], masked: &[usize], ctx: WindowContext) -> f64 {
let cache = enc.forward(tokens, masked, ctx);
loss_from_cache(enc, &cache, tokens, masked)
}
fn loss_from_cache(
enc: &RfEncoder,
cache: &ForwardCache,
tokens: &[RfToken],
masked: &[usize],
) -> f64 {
let d = enc.cfg.d_in;
let mut loss = 0.0;
for &j in masked {
let xhat = enc.reconstruct(cache, j);
for k in 0..d {
loss += (xhat[k] - tokens[j].features[k]).powi(2);
}
}
loss / (masked.len() as f64 * d as f64)
}
/// Loss and analytic gradients for one window under a fixed mask.
///
/// Derivation (matching the forward pass in [`RfEncoder::forward`]):
/// `∂L/∂x̂_j = 2(x̂_j x_j)/(|M|·D)`; the reconstruction input is
/// `u_j = [z ; pos(j)]`, so `∂L/∂z = Σ_j W3[:, :H]ᵀ ∂L/∂x̂_j`; the fusion
/// `z = g⊙gate + Wg·geo + bg` splits the gradient into the tanh chain
/// (`g → m → c → h_i → W1`) and the gate/geometry paths.
#[must_use]
pub fn masked_loss_and_grads(
enc: &RfEncoder,
tokens: &[RfToken],
masked: &[usize],
ctx: WindowContext,
) -> (f64, EncoderGrads) {
let h_dim = enc.cfg.d_model;
let d = enc.cfg.d_in;
let cache = enc.forward(tokens, masked, ctx);
let mut grads = EncoderGrads::zeros(enc);
let norm = 1.0 / (masked.len() as f64 * d as f64);
let mut dz = vec![0.0; h_dim];
let mut loss = 0.0;
for &j in masked {
let mut u = cache.z.clone();
u.extend_from_slice(&position_encoding(j)[..enc.cfg.d_pos]);
let xhat = enc.w3.forward(&u);
let mut dxhat = vec![0.0; d];
for k in 0..d {
let err = xhat[k] - tokens[j].features[k];
loss += err * err;
dxhat[k] = 2.0 * err * norm;
}
enc.w3.accumulate_grad(&mut grads.w3, &dxhat, &u);
let du = enc.w3.backward_input(&dxhat);
for k in 0..h_dim {
dz[k] += du[k];
}
}
loss *= norm;
// Fusion: z = g ⊙ gate + Wg·geo + bg. The gate input is the
// log-scaled age feature, matching the forward pass.
let age_feat = crate::encoder::age_feature(ctx.age_s);
let mut dg = vec![0.0; h_dim];
for k in 0..h_dim {
let dgate = dz[k] * cache.g[k];
dg[k] = dz[k] * cache.gate[k];
let dsig = cache.gate[k] * (1.0 - cache.gate[k]);
grads.age_w[k] += dgate * dsig * age_feat;
grads.age_b[k] += dgate * dsig;
}
enc.wg.accumulate_grad(&mut grads.wg, &dz, &ctx.geometry);
// g = tanh(W2b·m + b2b).
let dg_pre: Vec<f64> = (0..h_dim).map(|k| dg[k] * (1.0 - cache.g[k] * cache.g[k])).collect();
enc.w2b.accumulate_grad(&mut grads.w2b, &dg_pre, &cache.m);
let dm = enc.w2b.backward_input(&dg_pre);
// m = tanh(W2·c + b2).
let dm_pre: Vec<f64> = (0..h_dim).map(|k| dm[k] * (1.0 - cache.m[k] * cache.m[k])).collect();
enc.w2.accumulate_grad(&mut grads.w2, &dm_pre, &cache.c);
let dc = enc.w2.backward_input(&dm_pre);
// c = mean of h_i; h_i = tanh(W1·x_i + b1).
let inv_n = 1.0 / cache.unmasked.len() as f64;
for (slot, &i) in cache.unmasked.iter().enumerate() {
let hi = &cache.h[slot];
let dh_pre: Vec<f64> =
(0..h_dim).map(|k| dc[k] * inv_n * (1.0 - hi[k] * hi[k])).collect();
enc.w1.accumulate_grad(&mut grads.w1, &dh_pre, &tokens[i].features);
}
(loss, grads)
}
/// Applies one SGD step.
pub fn apply_grads(enc: &mut RfEncoder, grads: &EncoderGrads, lr: f64) {
enc.w1.sgd(&grads.w1, lr);
enc.w2.sgd(&grads.w2, lr);
enc.w2b.sgd(&grads.w2b, lr);
for (p, g) in enc.age_w.iter_mut().zip(&grads.age_w) {
*p -= lr * g;
}
for (p, g) in enc.age_b.iter_mut().zip(&grads.age_b) {
*p -= lr * g;
}
enc.wg.sgd(&grads.wg, lr);
enc.w3.sgd(&grads.w3, lr);
}
/// Pretraining hyper-parameters.
#[derive(Debug, Clone, Copy)]
pub struct PretrainConfig {
/// Fraction of tokens masked per window (≥1 token is always masked and
/// ≥1 always kept).
pub mask_fraction: f64,
/// SGD learning rate.
pub lr: f64,
/// Epochs over the window set.
pub epochs: usize,
/// Mask-sampling seed (weight init is seeded separately at
/// [`RfEncoder::new`]).
pub seed: u64,
}
impl Default for PretrainConfig {
fn default() -> Self {
Self { mask_fraction: 0.25, lr: 0.05, epochs: 30, seed: 0x5EED }
}
}
/// What pretraining measured (reported honestly, not smoothed).
#[derive(Debug, Clone, Copy)]
pub struct PretrainReport {
/// Mean masked loss over the corpus before any update (fixed eval mask).
pub initial_loss: f64,
/// Mean masked loss after the final epoch (same fixed eval mask).
pub final_loss: f64,
/// Epochs run.
pub epochs: usize,
}
fn sample_mask(rng: &mut rand_chacha::ChaCha20Rng, n_tokens: usize, fraction: f64) -> Vec<usize> {
// A window with fewer than 2 tokens has nothing left to reconstruct from
// once one token is masked; return an empty mask rather than panicking
// (`clamp(1, n_tokens - 1)` is invalid once `n_tokens - 1 < 1`).
if n_tokens < 2 {
return Vec::new();
}
let n_mask = ((n_tokens as f64 * fraction).round() as usize).clamp(1, n_tokens - 1);
let mut idx: Vec<usize> = (0..n_tokens).collect();
idx.shuffle(rng);
idx.truncate(n_mask);
idx.sort_unstable();
idx
}
/// Runs masked-reconstruction SGD over `windows`, mutating `enc` in place.
pub fn pretrain(
enc: &mut RfEncoder,
windows: &[TokenizedWindow],
cfg: &PretrainConfig,
) -> PretrainReport {
assert!(!windows.is_empty(), "pretrain needs at least one window");
let mut rng = seeded_rng(cfg.seed);
// Fixed evaluation masks so initial/final losses are comparable.
let eval_masks: Vec<Vec<usize>> = windows
.iter()
.map(|w| sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction))
.collect();
let eval = |e: &RfEncoder| {
let mut total = 0.0;
let mut n = 0usize;
for (w, m) in windows.iter().zip(&eval_masks) {
if m.is_empty() {
continue;
}
total += masked_loss(e, &w.tokens, m, WindowContext::from(w));
n += 1;
}
if n == 0 { 0.0 } else { total / n as f64 }
};
let initial_loss = eval(enc);
let mut order: Vec<usize> = (0..windows.len()).collect();
for _ in 0..cfg.epochs {
order.shuffle(&mut rng);
for &wi in &order {
let w = &windows[wi];
if w.tokens.len() < 2 {
continue;
}
let mask = sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction);
let (_, grads) =
masked_loss_and_grads(enc, &w.tokens, &mask, WindowContext::from(w));
apply_grads(enc, &grads, cfg.lr);
}
}
let final_loss = eval(enc);
PretrainReport { initial_loss, final_loss, epochs: cfg.epochs }
}
/// Deterministic pseudo-random corpus where tokens within a window share a
/// latent factor — masked tokens are predictable from context, so a correct
/// learner must beat the constant predictor. Used by tests and benches.
#[must_use]
pub fn correlated_toy_windows(n_windows: usize, tokens_per_window: usize, seed: u64) -> Vec<TokenizedWindow> {
use crate::tokenizer::{RfToken, D_IN};
let mut rng = seeded_rng(seed);
(0..n_windows)
.map(|_| {
let latent: f64 = rng.gen_range(-1.0..1.0);
let tokens = (0..tokens_per_window)
.map(|k| {
let mut f = [0.0f64; D_IN];
for (d, v) in f.iter_mut().enumerate() {
// Smooth deterministic function of (latent, token, dim)
// plus small noise: reconstructable from context.
*v = 0.6 * (latent * (1.0 + d as f64 / 8.0) + k as f64 * 0.3).sin()
+ rng.gen_range(-0.05..0.05);
}
RfToken { features: f, link: 0, group: k }
})
.collect();
TokenizedWindow {
tokens,
age_s: rng.gen_range(0.0..0.5),
geometry: [0.1, 0.0, 0.2, 0.4, 0.0, 0.2],
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoder::EncoderConfig;
/// Central finite differences over EVERY parameter group. This is the
/// crate's proof that the backward pass matches the forward pass.
#[test]
fn gradients_match_finite_differences() {
let cfg = EncoderConfig { d_in: 24, d_pos: 6, d_model: 7 };
let mut enc = RfEncoder::new(cfg, 11);
let windows = correlated_toy_windows(1, 5, 21);
let tokens = &windows[0].tokens;
let ctx = WindowContext { age_s: 0.3, geometry: [0.1, -0.2, 0.3, 0.0, 0.2, -0.1] };
let masked = vec![1, 3];
let (_, grads) = masked_loss_and_grads(&enc, tokens, &masked, ctx);
let eps = 1e-6;
let mut checked = 0usize;
let mut max_rel = 0.0f64;
// Closure-free param walker: (getter, analytic grad) pairs by index.
// Group 0: w1.w, 1: w1.b, 2: w2.w, 3: w2.b, 4: w2b.w, 5: w2b.b,
// 6: age_w, 7: age_b, 8: wg.w, 9: wg.b, 10: w3.w, 11: w3.b.
for group in 0..12 {
let len = match group {
0 => enc.w1.w.len(),
1 => enc.w1.b.len(),
2 => enc.w2.w.len(),
3 => enc.w2.b.len(),
4 => enc.w2b.w.len(),
5 => enc.w2b.b.len(),
6 => enc.age_w.len(),
7 => enc.age_b.len(),
8 => enc.wg.w.len(),
9 => enc.wg.b.len(),
10 => enc.w3.w.len(),
_ => enc.w3.b.len(),
};
// Sample a spread of indices per group to keep the test fast
// while touching every group.
let stride = (len / 17).max(1);
for idx in (0..len).step_by(stride) {
fn param_at(e: &mut RfEncoder, group: usize, idx: usize) -> &mut f64 {
match group {
0 => &mut e.w1.w[idx],
1 => &mut e.w1.b[idx],
2 => &mut e.w2.w[idx],
3 => &mut e.w2.b[idx],
4 => &mut e.w2b.w[idx],
5 => &mut e.w2b.b[idx],
6 => &mut e.age_w[idx],
7 => &mut e.age_b[idx],
8 => &mut e.wg.w[idx],
9 => &mut e.wg.b[idx],
10 => &mut e.w3.w[idx],
_ => &mut e.w3.b[idx],
}
}
let analytic = match group {
0 => grads.w1.w[idx],
1 => grads.w1.b[idx],
2 => grads.w2.w[idx],
3 => grads.w2.b[idx],
4 => grads.w2b.w[idx],
5 => grads.w2b.b[idx],
6 => grads.age_w[idx],
7 => grads.age_b[idx],
8 => grads.wg.w[idx],
9 => grads.wg.b[idx],
10 => grads.w3.w[idx],
_ => grads.w3.b[idx],
};
let orig = *param_at(&mut enc, group, idx);
*param_at(&mut enc, group, idx) = orig + eps;
let lp = masked_loss(&enc, tokens, &masked, ctx);
*param_at(&mut enc, group, idx) = orig - eps;
let lm = masked_loss(&enc, tokens, &masked, ctx);
*param_at(&mut enc, group, idx) = orig;
let numeric = (lp - lm) / (2.0 * eps);
let denom = analytic.abs().max(numeric.abs()).max(1e-8);
let rel = (analytic - numeric).abs() / denom;
// Accept either a tight relative match or an absolute
// difference at the central-difference roundoff floor
// (ε_machine·|L|/ε ≈ 5e-11) — tiny gradients hit the floor.
assert!(
rel < 1e-5 || (analytic - numeric).abs() < 1e-9,
"group {group} idx {idx}: analytic {analytic:.3e} vs numeric {numeric:.3e} (rel {rel:.3e})"
);
max_rel = max_rel.max(rel);
checked += 1;
}
}
assert!(checked > 150, "gradient check must cover a real sample, got {checked}");
println!("gradient check: {checked} params, max relative error {max_rel:.3e}");
}
#[test]
fn pretraining_reduces_masked_loss_and_beats_mean_baseline() {
let windows = correlated_toy_windows(40, 8, 99);
let mut enc = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 32 }, 7);
let report = pretrain(
&mut enc,
&windows,
&PretrainConfig { mask_fraction: 0.25, lr: 0.05, epochs: 40, seed: 123 },
);
assert!(
report.final_loss < 0.5 * report.initial_loss,
"loss must at least halve: {report:?}"
);
// Constant (global-mean) predictor baseline on the same corpus: the
// per-dim variance of token features. The encoder must beat it —
// otherwise it learned nothing about context.
let mut all: Vec<[f64; 24]> = Vec::new();
for w in &windows {
for t in &w.tokens {
all.push(t.features);
}
}
let n = all.len() as f64;
let mut mean = [0.0f64; 24];
for f in &all {
for (m, v) in mean.iter_mut().zip(f) {
*m += v / n;
}
}
let mut var = 0.0;
for f in &all {
for (m, v) in mean.iter().zip(f) {
var += (v - m).powi(2);
}
}
var /= n * 24.0;
assert!(
report.final_loss < 0.8 * var,
"must beat constant predictor: final {} vs baseline variance {}",
report.final_loss,
var
);
println!(
"pretrain: initial {:.4} → final {:.4} (baseline variance {:.4})",
report.initial_loss, report.final_loss, var
);
}
#[test]
fn training_is_deterministic() {
let windows = correlated_toy_windows(10, 6, 5);
let cfg = PretrainConfig { mask_fraction: 0.3, lr: 0.05, epochs: 5, seed: 77 };
let mut a = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 16 }, 2);
let mut b = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 16 }, 2);
let ra = pretrain(&mut a, &windows, &cfg);
let rb = pretrain(&mut b, &windows, &cfg);
assert_eq!(a.w1.w, b.w1.w);
assert!((ra.final_loss - rb.final_loss).abs() < 1e-15);
}
}
@@ -0,0 +1,310 @@
//! Domain-randomized synthetic dataset generator (ADR-276 §4).
//!
//! Randomizes *physics parameters*, not textures: room geometry, wall
//! permittivity/conductivity, antenna placement, person kinematics and RCS,
//! plus the hardware nuisances that break naive models in the field —
//! chipset gain and phase offsets, carrier-frequency-offset drift, phase
//! noise, packet loss (snapshot duplication), and interference bursts.
//! Every window carries a full [`PartitionKey`] so the ADR-273 strict
//! anti-leakage splits (held-out rooms / days / people / chipsets /
//! firmware / layouts) are possible by construction.
use ndarray::Array3;
use num_complex::Complex64;
use rand::Rng;
use crate::eval::PartitionKey;
use crate::math::seeded_rng;
use crate::synth::raytrace::synthesize_csi;
use crate::synth::room::{Material, PersonSpec, RoomSpec};
use crate::tensor::{
CalibrationMeta, LinkGeometry, RfModality, RfTensor, CANONICAL_BINS, CANONICAL_SNAPSHOTS,
};
/// Generator configuration.
#[derive(Debug, Clone, Copy)]
pub struct SynthConfig {
/// Master seed (same seed ⇒ byte-identical corpus).
pub seed: u64,
/// Number of distinct rooms.
pub n_rooms: usize,
/// Windows per room (half with a person, half empty, interleaved).
pub windows_per_room: usize,
/// Links (TX→RX pairs) per room.
pub links: usize,
/// Snapshot spacing in seconds.
pub snapshot_dt_s: f64,
}
impl Default for SynthConfig {
fn default() -> Self {
Self { seed: 0xC0FFEE, n_rooms: 8, windows_per_room: 24, links: 3, snapshot_dt_s: 0.05 }
}
}
/// One labeled synthetic window.
#[derive(Debug, Clone)]
pub struct LabeledWindow {
/// Canonical tensor (modality [`RfModality::Synthetic`]).
pub tensor: RfTensor,
/// Whether a person is present in the room during this window.
pub presence: bool,
/// Person position at the window's mid-time, when present.
pub person_pos: Option<[f64; 3]>,
/// Full provenance key for strict splits.
pub key: PartitionKey,
}
/// Per-room randomized nuisance profile (the "chipset").
#[derive(Debug, Clone)]
struct HardwareProfile {
chipset: String,
firmware: String,
layout: String,
gain: f64,
phase_offset: f64,
cfo_rad_per_snap: f64,
noise_sigma: f64,
}
/// The generator.
pub struct SynthGenerator {
cfg: SynthConfig,
}
impl SynthGenerator {
/// New generator.
#[must_use]
pub fn new(cfg: SynthConfig) -> Self {
Self { cfg }
}
/// 56 subcarrier frequencies over 20 MHz around 2.437 GHz.
#[must_use]
pub fn subcarrier_freqs() -> Vec<f64> {
(0..CANONICAL_BINS)
.map(|k| 2.437e9 - 10e6 + 20e6 * k as f64 / (CANONICAL_BINS - 1) as f64)
.collect()
}
/// Generates the full labeled corpus, deterministically from the seed.
///
/// # Panics
/// Only on internal invariant violation (tensor construction from
/// generated finite values cannot fail).
#[must_use]
pub fn generate(&self) -> Vec<LabeledWindow> {
let mut rng = seeded_rng(self.cfg.seed);
let freqs = Self::subcarrier_freqs();
let mut out = Vec::with_capacity(self.cfg.n_rooms * self.cfg.windows_per_room);
for room_idx in 0..self.cfg.n_rooms {
// --- Randomized physics for this room ---
let size = [
rng.gen_range(4.0..10.0),
rng.gen_range(3.0..8.0),
rng.gen_range(2.4..3.2),
];
let material = Material {
rel_permittivity: rng.gen_range(2.0..7.0),
conductivity_s_m: rng.gen_range(0.002..0.1),
};
let links: Vec<LinkGeometry> = (0..self.cfg.links)
.map(|_| LinkGeometry {
tx_pos: [
rng.gen_range(0.3..size[0] - 0.3),
rng.gen_range(0.3..size[1] - 0.3),
rng.gen_range(1.0..2.0),
],
rx_pos: [
rng.gen_range(0.3..size[0] - 0.3),
rng.gen_range(0.3..size[1] - 0.3),
rng.gen_range(1.0..2.0),
],
})
.collect();
let hw = HardwareProfile {
chipset: format!("chip-{}", room_idx % 3),
firmware: format!("fw-{}", room_idx % 2),
layout: format!("layout-{}", (room_idx / 2) % 2),
gain: rng.gen_range(0.5..2.0),
phase_offset: rng.gen_range(-std::f64::consts::PI..std::f64::consts::PI),
cfo_rad_per_snap: rng.gen_range(-0.3..0.3),
noise_sigma: rng.gen_range(0.01..0.05),
};
let person_id = format!("p{}", room_idx % 4);
// Person kinematics randomized per room; the person walks a
// straight segment that stays inside the room for the corpus
// duration (velocity kept small relative to room size).
let person = PersonSpec {
start: [
rng.gen_range(size[0] * 0.25..size[0] * 0.75),
rng.gen_range(size[1] * 0.25..size[1] * 0.75),
rng.gen_range(1.0..1.6),
],
velocity: {
let speed = rng.gen_range(0.3..1.0);
let ang: f64 = rng.gen_range(0.0..std::f64::consts::TAU);
[speed * ang.cos() * 0.2, speed * ang.sin() * 0.2, 0.0]
},
rcs_m2: rng.gen_range(0.3..0.8),
};
let occupied = RoomSpec::new(size, material, vec![person]).expect("generated in range");
let empty = RoomSpec::new(size, material, vec![]).expect("generated in range");
for w in 0..self.cfg.windows_per_room {
let presence = w % 2 == 0;
let room = if presence { &occupied } else { &empty };
// Window start times cycle so the person oscillates within
// the room instead of walking out of it.
let t0 = (w % 6) as f64 * CANONICAL_SNAPSHOTS as f64 * self.cfg.snapshot_dt_s;
let mut data =
Array3::zeros((self.cfg.links, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
for (l, link) in links.iter().enumerate() {
let mut prev: Option<Vec<Complex64>> = None;
for s in 0..CANONICAL_SNAPSHOTS {
let t = t0 + s as f64 * self.cfg.snapshot_dt_s;
// Packet loss: 5 % of snapshots re-deliver the
// previous frame instead of a fresh capture.
let lost = prev.is_some() && rng.gen_bool(0.05);
let h: Vec<Complex64> = if lost {
prev.clone().expect("guarded by prev.is_some()")
} else {
synthesize_csi(room, link.tx_pos, link.rx_pos, &freqs, t)
};
// Chipset gain + static phase + CFO drift.
let rot = Complex64::from_polar(
hw.gain,
hw.phase_offset + hw.cfo_rad_per_snap * s as f64,
);
// Interference burst: 3 % of snapshots take a strong
// wideband hit; otherwise thermal noise only.
let burst = if rng.gen_bool(0.03) { 10.0 } else { 1.0 };
for (b, hv) in h.iter().enumerate() {
let noise = Complex64::new(
rng.gen_range(-1.0..1.0) * hw.noise_sigma * burst * 1e-4,
rng.gen_range(-1.0..1.0) * hw.noise_sigma * burst * 1e-4,
);
data[[l, b, s]] = hv * rot + noise;
}
prev = Some(h);
}
}
let mid_t = t0 + 0.5 * CANONICAL_SNAPSHOTS as f64 * self.cfg.snapshot_dt_s;
let tensor = RfTensor::new(
RfModality::Synthetic,
2.437e9,
20e6,
data,
links.clone(),
rng.gen_range(0.0..0.2),
(room_idx as u64) << 32 | w as u64,
format!("synth-{}", hw.chipset),
rng.gen_range(0.6..0.95),
(hw.noise_sigma / 0.05).clamp(0.0, 1.0) * 0.5,
CalibrationMeta::default(),
)
.expect("generated tensor is finite and in range");
out.push(LabeledWindow {
tensor,
presence,
person_pos: presence.then(|| occupied.people[0].position_at(mid_t)),
key: PartitionKey {
room: format!("room-{room_idx}"),
day: format!("day-{}", w / (self.cfg.windows_per_room / 2).max(1)),
person: if presence { person_id.clone() } else { "none".into() },
chipset: hw.chipset.clone(),
firmware: hw.firmware.clone(),
layout: hw.layout.clone(),
// Windows sharing a start-time slot within a room
// form one capture session.
session: format!("room-{room_idx}-s{}", w % 6),
},
});
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn small_cfg(seed: u64) -> SynthConfig {
SynthConfig { seed, n_rooms: 3, windows_per_room: 6, links: 2, snapshot_dt_s: 0.05 }
}
#[test]
fn corpus_is_byte_deterministic_per_seed() {
let a = SynthGenerator::new(small_cfg(7)).generate();
let b = SynthGenerator::new(small_cfg(7)).generate();
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.presence, y.presence);
assert_eq!(x.key, y.key);
for (u, v) in x.tensor.data.iter().zip(y.tensor.data.iter()) {
assert!(u == v, "same seed must give identical complex samples");
}
}
// And a different seed gives a different corpus.
let c = SynthGenerator::new(small_cfg(8)).generate();
assert!(a
.iter()
.zip(&c)
.any(|(x, y)| x.tensor.data.iter().zip(y.tensor.data.iter()).any(|(u, v)| u != v)));
}
#[test]
fn presence_windows_carry_more_temporal_energy() {
let corpus = SynthGenerator::new(small_cfg(42)).generate();
let temporal_energy = |w: &LabeledWindow| {
// Mean per-bin variance across snapshots.
let (links, bins, snaps) = w.tensor.dims();
let mut acc = 0.0;
for l in 0..links {
for b in 0..bins {
let vals: Vec<f64> =
(0..snaps).map(|s| w.tensor.data[[l, b, s]].norm()).collect();
let m = vals.iter().sum::<f64>() / snaps as f64;
acc += vals.iter().map(|v| (v - m).powi(2)).sum::<f64>() / snaps as f64;
}
}
acc / (links * bins) as f64
};
let present: Vec<f64> =
corpus.iter().filter(|w| w.presence).map(temporal_energy).collect();
let absent: Vec<f64> =
corpus.iter().filter(|w| !w.presence).map(temporal_energy).collect();
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
assert!(
mean(&present) > 5.0 * mean(&absent),
"a moving person must dominate temporal variance: present {} vs absent {}",
mean(&present),
mean(&absent)
);
}
#[test]
fn labels_and_partition_keys_are_complete() {
let corpus = SynthGenerator::new(small_cfg(1)).generate();
assert_eq!(corpus.len(), 18);
for w in &corpus {
assert_eq!(w.tensor.modality, RfModality::Synthetic, "honest labeling");
assert_eq!(w.presence, w.person_pos.is_some());
assert!(!w.key.room.is_empty());
assert!(!w.key.chipset.is_empty());
if let Some(p) = w.person_pos {
assert!(p.iter().all(|v| v.is_finite()));
}
}
// Multiple rooms exist so strict room-holdout splits are possible.
let rooms: std::collections::BTreeSet<&str> =
corpus.iter().map(|w| w.key.room.as_str()).collect();
assert_eq!(rooms.len(), 3);
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Physics-guided synthetic RF world generator (ADR-276).
//!
//! Generates labeled CSI windows from first-principles multipath physics —
//! shoebox rooms via the AllenBerkley image method (reflection order ≤ 2),
//! Fresnel wall materials with complex permittivity, moving people as
//! bistatic point scatterers (Doppler emerges from path-length change, it is
//! never injected), plus domain randomization of the *physics* parameters
//! (materials, geometry, chipset gain/phase/noise, CFO, packet loss,
//! interference) rather than cosmetic noise.
//!
//! Everything is seeded ChaCha20-deterministic, and every tensor produced
//! here is stamped [`crate::tensor::RfModality::Synthetic`] — the honest
//! label ADR-276 requires until results are validated on measured data.
//!
//! Physics anchors proven in tests:
//! - direct path amplitude ≡ Friis (`raytrace::tests::direct_path_is_exact_friis`)
//! - reciprocity `H(a→b) = H(b→a)`
//! - first-order reflection delay ≡ mirror-image geometry
//! - a walking person produces the analytically expected Doppler phase rate
pub mod generator;
pub mod raytrace;
pub mod room;
pub use generator::{LabeledWindow, SynthConfig, SynthGenerator};
pub use raytrace::{enumerate_paths, synthesize_csi, PathContribution};
pub use room::{Material, PersonSpec, RoomSpec};
@@ -0,0 +1,273 @@
//! Image-method multipath ray tracer for shoebox rooms (ADR-276 §3).
//!
//! AllenBerkley mirror images up to reflection order 2 plus single-bounce
//! bistatic scattering off each person. The channel at frequency `f` is
//!
//! ```text
//! H(f) = Σ_paths Γ_p · (c/f)/(4π) · s_p · e^{-j·2πf·d_p/c}
//! ```
//!
//! with `s_p = 1/d` for wall paths and `s_p = √(σ/4π)/(d₁·d₂)` for person
//! scattering (bistatic radar equation, amplitude form). Doppler is never
//! injected: it emerges from the person's path length changing between
//! snapshots.
use num_complex::Complex64;
use super::room::RoomSpec;
const C: f64 = 299_792_458.0;
/// One propagation path.
#[derive(Debug, Clone, Copy)]
pub struct PathContribution {
/// Total geometric path length (metres) — sets delay and phase.
pub distance_m: f64,
/// Amplitude scale multiplying `λ/(4π)`: `1/d` for wall paths,
/// `√(σ/4π)/(d₁·d₂)` for scatterers.
pub amp_scale: f64,
/// Product of complex reflection coefficients along the path
/// (`Γ^order`, evaluated at the carrier).
pub reflection: Complex64,
/// Number of wall bounces (0 = direct or scatterer path).
pub order: usize,
}
/// Enumerates all wall-image paths of order ≤ `max_order` plus person
/// scattering paths, for the room state at time `t` seconds.
#[must_use]
pub fn enumerate_paths(
room: &RoomSpec,
tx: [f64; 3],
rx: [f64; 3],
carrier_hz: f64,
t: f64,
max_order: usize,
) -> Vec<PathContribution> {
let gamma = room.wall_material.reflection_coefficient(carrier_hz);
let mut paths = Vec::new();
// AllenBerkley images: per axis, sign ∈ {+, } and lattice shift
// n ∈ {1, 0, 1}; bounce count is |2n| for +, |2n1| for .
for sx in [1i64, -1] {
for nx in -1i64..=1 {
let bx = if sx == 1 { (2 * nx).unsigned_abs() } else { (2 * nx - 1).unsigned_abs() };
if bx as usize > max_order {
continue;
}
for sy in [1i64, -1] {
for ny in -1i64..=1 {
let by =
if sy == 1 { (2 * ny).unsigned_abs() } else { (2 * ny - 1).unsigned_abs() };
if (bx + by) as usize > max_order {
continue;
}
for sz in [1i64, -1] {
for nz in -1i64..=1 {
let bz = if sz == 1 {
(2 * nz).unsigned_abs()
} else {
(2 * nz - 1).unsigned_abs()
};
let order = (bx + by + bz) as usize;
if order > max_order {
continue;
}
let img = [
sx as f64 * tx[0] + 2.0 * nx as f64 * room.size[0],
sy as f64 * tx[1] + 2.0 * ny as f64 * room.size[1],
sz as f64 * tx[2] + 2.0 * nz as f64 * room.size[2],
];
let d = ((img[0] - rx[0]).powi(2)
+ (img[1] - rx[1]).powi(2)
+ (img[2] - rx[2]).powi(2))
.sqrt();
if d < 1e-9 {
continue;
}
let reflection = if order == 0 {
Complex64::new(1.0, 0.0)
} else {
gamma.powu(order as u32)
};
// Reflections with |Γ|=0 contribute nothing.
if order > 0 && reflection.norm() < 1e-15 {
continue;
}
paths.push(PathContribution {
distance_m: d,
amp_scale: 1.0 / d,
reflection,
order,
});
}
}
}
}
}
}
// Person scatterers (single bounce TX → person → RX).
for person in &room.people {
let p = person.position_at(t);
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2)).sqrt();
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2)).sqrt();
if d1 < 1e-9 || d2 < 1e-9 {
continue;
}
paths.push(PathContribution {
distance_m: d1 + d2,
amp_scale: (person.rcs_m2 / (4.0 * std::f64::consts::PI)).sqrt() / (d1 * d2),
reflection: Complex64::new(1.0, 0.0),
order: 0,
});
}
paths
}
/// Synthesizes the channel frequency response at each `freqs_hz` for the
/// room state at time `t`.
#[must_use]
pub fn synthesize_csi(
room: &RoomSpec,
tx: [f64; 3],
rx: [f64; 3],
freqs_hz: &[f64],
t: f64,
) -> Vec<Complex64> {
let carrier = freqs_hz[freqs_hz.len() / 2];
let paths = enumerate_paths(room, tx, rx, carrier, t, 2);
freqs_hz
.iter()
.map(|&f| {
let mut h = Complex64::new(0.0, 0.0);
for p in &paths {
let amp = (C / f) / (4.0 * std::f64::consts::PI) * p.amp_scale;
let phase = -2.0 * std::f64::consts::PI * f * p.distance_m / C;
h += p.reflection * Complex64::from_polar(amp, phase);
}
h
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::synth::room::{Material, PersonSpec};
fn freqs() -> Vec<f64> {
// 56 subcarriers over 20 MHz around 2.437 GHz.
(0..56).map(|k| 2.437e9 - 10e6 + 20e6 * k as f64 / 55.0).collect()
}
#[test]
fn direct_path_is_exact_friis() {
// Absorber walls ⇒ only the direct path survives.
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::absorber(), vec![]).unwrap();
let tx = [1.0, 1.0, 1.5];
let rx = [5.0, 4.0, 1.5]; // d = 5
let freqs = freqs();
let h = synthesize_csi(&room, tx, rx, &freqs, 0.0);
for (f, hv) in freqs.iter().zip(&h) {
let expected = (C / f) / (4.0 * std::f64::consts::PI * 5.0);
assert!(
(hv.norm() - expected).abs() < 1e-15,
"at {f} Hz: |H| = {} vs Friis {expected}",
hv.norm()
);
}
}
#[test]
fn channel_is_reciprocal() {
let people = vec![PersonSpec { start: [3.0, 2.0, 1.2], velocity: [0.4, 0.1, 0.0], rcs_m2: 0.5 }];
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), people).unwrap();
let a = [1.0, 1.0, 1.5];
let b = [6.5, 4.2, 1.1];
let freqs = freqs();
let fwd = synthesize_csi(&room, a, b, &freqs, 0.35);
let rev = synthesize_csi(&room, b, a, &freqs, 0.35);
for (x, y) in fwd.iter().zip(&rev) {
assert!((x - y).norm() < 1e-12, "H(a→b) must equal H(b→a): {x} vs {y}");
}
}
#[test]
fn first_order_reflection_matches_mirror_geometry() {
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), vec![]).unwrap();
let tx = [2.0, 3.0, 1.5];
let rx = [6.0, 3.0, 1.5];
let paths = enumerate_paths(&room, tx, rx, 2.437e9, 0.0, 2);
// Floor bounce (z = 0): mirror TX to z = 1.5; d = √(4² + 3²) = 5.
let floor = ((tx[0] - rx[0]).powi(2)
+ (tx[1] - rx[1]).powi(2)
+ (-tx[2] - rx[2]).powi(2))
.sqrt();
assert!((floor - 5.0).abs() < 1e-12, "test geometry sanity");
assert!(
paths.iter().any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12),
"floor-bounce image path at exactly 5 m must exist"
);
// Ceiling bounce (z = 3): mirror TX to z = 4.5; d = √(16 + 9) = 5.
assert!(
paths
.iter()
.any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12
&& p.distance_m > 0.0),
"ceiling-bounce path must exist"
);
// Path count sanity: direct + 6 first-order + second-order set, all
// with |Γ| > 0 for concrete.
assert!(paths.iter().filter(|p| p.order == 1).count() == 6, "6 first-order walls");
assert!(paths.iter().any(|p| p.order == 2));
assert_eq!(paths.iter().filter(|p| p.order == 0).count(), 1, "one direct path");
}
#[test]
fn moving_person_produces_the_analytic_doppler_phase_rate() {
// Person walking radially outward along the TXRX bisector normal;
// compare the residual (person-only) phase rotation between
// snapshots against 2πf·Δd/c.
let person = PersonSpec { start: [4.0, 2.0, 1.2], velocity: [0.0, 0.8, 0.0], rcs_m2: 0.6 };
let with_person =
RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![person]).unwrap();
let empty = RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![]).unwrap();
let tx = [1.0, 2.0, 1.5];
let rx = [7.0, 2.0, 1.5];
let f = [2.437e9];
let dt = 0.05;
for step in 0..4 {
let t0 = step as f64 * dt;
let t1 = t0 + dt;
let resid0 = synthesize_csi(&with_person, tx, rx, &f, t0)[0]
- synthesize_csi(&empty, tx, rx, &f, t0)[0];
let resid1 = synthesize_csi(&with_person, tx, rx, &f, t1)[0]
- synthesize_csi(&empty, tx, rx, &f, t1)[0];
let measured_dphi = (resid1 * resid0.conj()).arg();
let path_len = |t: f64| {
let p = person.position_at(t);
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2))
.sqrt();
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2))
.sqrt();
d1 + d2
};
let expected_dphi = -2.0 * std::f64::consts::PI * f[0] * (path_len(t1) - path_len(t0)) / C;
// Compare on the unit circle (phases are mod 2π).
let diff = (Complex64::from_polar(1.0, measured_dphi)
* Complex64::from_polar(1.0, -expected_dphi))
.arg();
assert!(
diff.abs() < 1e-6,
"step {step}: measured Δφ {measured_dphi} vs analytic {expected_dphi}"
);
}
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Room, material, and person specifications for the synthetic world
//! (ADR-276 §2).
use num_complex::Complex64;
use serde::{Deserialize, Serialize};
use crate::{Result, UnifiedError};
/// Vacuum permittivity (F/m).
const EPS0: f64 = 8.854_187_812_8e-12;
/// Wall material with complex permittivity — the quantity domain
/// randomization varies (randomize physics, not textures).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Material {
/// Relative permittivity ε_r (> 1 for solids).
pub rel_permittivity: f64,
/// Conductivity σ in S/m (loss term).
pub conductivity_s_m: f64,
}
impl Material {
/// Typical poured concrete (ITU-R P.2040 ballpark).
#[must_use]
pub fn concrete() -> Self {
Self { rel_permittivity: 5.3, conductivity_s_m: 0.073 }
}
/// Gypsum drywall.
#[must_use]
pub fn drywall() -> Self {
Self { rel_permittivity: 2.9, conductivity_s_m: 0.016 }
}
/// Window glass.
#[must_use]
pub fn glass() -> Self {
Self { rel_permittivity: 6.3, conductivity_s_m: 0.004 }
}
/// Perfect absorber (anechoic) — kills all reflections; used by tests to
/// isolate the direct path.
#[must_use]
pub fn absorber() -> Self {
Self { rel_permittivity: 1.0, conductivity_s_m: 0.0 }
}
/// Complex relative permittivity at frequency `f`:
/// `ε = ε_r j·σ/(ω·ε₀)`.
#[must_use]
pub fn complex_permittivity(&self, freq_hz: f64) -> Complex64 {
let omega = 2.0 * std::f64::consts::PI * freq_hz;
Complex64::new(self.rel_permittivity, -self.conductivity_s_m / (omega * EPS0))
}
/// Normal-incidence Fresnel amplitude reflection coefficient
/// `Γ = (1 √ε)/(1 + √ε)` against air. (Normal incidence is the
/// standard image-method simplification; angle dependence is a
/// randomizable refinement, not a correctness requirement.)
#[must_use]
pub fn reflection_coefficient(&self, freq_hz: f64) -> Complex64 {
let sqrt_eps = self.complex_permittivity(freq_hz).sqrt();
(Complex64::new(1.0, 0.0) - sqrt_eps) / (Complex64::new(1.0, 0.0) + sqrt_eps)
}
}
/// A person modeled as a moving bistatic point scatterer.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PersonSpec {
/// Position at t = 0, metres.
pub start: [f64; 3],
/// Constant velocity, m/s.
pub velocity: [f64; 3],
/// Radar cross-section, m² (torso ≈ 0.31.0 at WiFi bands).
pub rcs_m2: f64,
}
impl PersonSpec {
/// Position at time `t` seconds.
#[must_use]
pub fn position_at(&self, t: f64) -> [f64; 3] {
[
self.start[0] + self.velocity[0] * t,
self.start[1] + self.velocity[1] * t,
self.start[2] + self.velocity[2] * t,
]
}
}
/// A shoebox room: `[0, Lx] × [0, Ly] × [0, Lz]` with one wall material and
/// zero or more people.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoomSpec {
/// Interior dimensions `[Lx, Ly, Lz]`, metres.
pub size: [f64; 3],
/// Wall/floor/ceiling material.
pub wall_material: Material,
/// Occupants.
pub people: Vec<PersonSpec>,
}
impl RoomSpec {
/// Validated constructor: positive dimensions, finite fields, people
/// starting inside the room.
pub fn new(size: [f64; 3], wall_material: Material, people: Vec<PersonSpec>) -> Result<Self> {
if size.iter().any(|s| !s.is_finite() || *s <= 0.0) {
return Err(UnifiedError::InvalidInput(format!(
"room dimensions must be finite and positive, got {size:?}"
)));
}
for p in &people {
for (axis, v) in p.start.iter().enumerate() {
if !v.is_finite() || *v < 0.0 || *v > size[axis] {
return Err(UnifiedError::InvalidInput(format!(
"person start {:?} outside room {size:?}",
p.start
)));
}
}
if p.rcs_m2 <= 0.0 || !p.rcs_m2.is_finite() {
return Err(UnifiedError::InvalidInput(format!(
"rcs_m2 must be positive, got {}",
p.rcs_m2
)));
}
}
Ok(Self { size, wall_material, people })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresnel_coefficient_sane_for_concrete() {
let g = Material::concrete().reflection_coefficient(2.437e9);
// Concrete at 2.4 GHz: |Γ| ≈ 0.390.45, negative real part
// (denser medium ⇒ phase inversion).
assert!(g.norm() > 0.3 && g.norm() < 0.5, "|Γ| = {}", g.norm());
assert!(g.re < 0.0);
// Physical bound: |Γ| < 1 for any passive material.
for m in [Material::drywall(), Material::glass(), Material::concrete()] {
assert!(m.reflection_coefficient(5.18e9).norm() < 1.0);
}
}
#[test]
fn absorber_reflects_nothing() {
let g = Material::absorber().reflection_coefficient(2.437e9);
assert!(g.norm() < 1e-12, "ε_r = 1, σ = 0 must give Γ = 0, got {g}");
}
#[test]
fn room_validation_rejects_bad_specs() {
assert!(RoomSpec::new([4.0, -3.0, 2.5], Material::drywall(), vec![]).is_err());
let outside = PersonSpec { start: [9.0, 1.0, 1.0], velocity: [0.0; 3], rcs_m2: 0.5 };
assert!(RoomSpec::new([4.0, 3.0, 2.5], Material::drywall(), vec![outside]).is_err());
let ok = PersonSpec { start: [2.0, 1.0, 1.0], velocity: [0.5, 0.0, 0.0], rcs_m2: 0.5 };
let room = RoomSpec::new([4.0, 3.0, 2.5], Material::drywall(), vec![ok]).expect("valid");
assert_eq!(room.people.len(), 1);
assert_eq!(room.people[0].position_at(2.0), [3.0, 1.0, 1.0]);
}
}
+423
View File
@@ -0,0 +1,423 @@
//! Canonical RF tensor — the single normalization target of every hardware
//! adapter (ADR-274 §2).
//!
//! Every modality (WiFi CSI, cellular SRS, FMCW radar range profiles, UWB
//! CIR) is normalized into the same `(links × bins × snapshots)` complex
//! tensor plus calibration metadata. Downstream code (tokenizer, encoder,
//! Gaussian memory) never sees vendor formats.
use ndarray::Array3;
use num_complex::Complex64;
use serde::{Deserialize, Serialize};
use crate::{Result, UnifiedError};
/// Canonical number of frequency/delay bins after adapter resampling.
///
/// 56 matches the usable-subcarrier count of 20 MHz 802.11n CSI after guard
/// removal (and the 114→56 interpolation already used by
/// `wifi-densepose-train::subcarrier`), so the most common source needs no
/// resampling at all.
pub const CANONICAL_BINS: usize = 56;
/// Canonical number of temporal snapshots per tensor window.
pub const CANONICAL_SNAPSHOTS: usize = 8;
/// RF sensing modality of a capture or tensor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RfModality {
/// 802.11 Channel State Information (per-subcarrier frequency response).
WifiCsi,
/// 802.11 channel impulse response (delay-domain taps).
WifiCir,
/// 802.11 beamforming feedback report (BFI/BFLD path).
WifiBfReport,
/// 5G NR uplink Sounding Reference Signal frequency response (O-RAN ISAC path).
CellularSrs,
/// FMCW radar range profile (post range-FFT complex bins).
FmcwRadar,
/// FMCW radar rangeazimuth map.
FmcwRangeAzimuth,
/// FMCW radar Dopplerazimuth map.
FmcwDopplerAzimuth,
/// Ultra-wideband channel impulse response taps.
UwbCir,
/// Bluetooth Channel Sounding tones (phase-based ranging + RTT).
BleCs,
/// Output of the ADR-276 synthetic world generator (honest labeling:
/// tensors of this modality must never be reported as measured).
Synthetic,
}
/// Transmitter/receiver placement for one link, metres, room frame.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LinkGeometry {
/// Transmit antenna position `[x, y, z]` in metres.
pub tx_pos: [f64; 3],
/// Receive antenna position `[x, y, z]` in metres.
pub rx_pos: [f64; 3],
}
impl LinkGeometry {
/// Euclidean TX→RX distance in metres.
#[must_use]
pub fn distance_m(&self) -> f64 {
let d: f64 = (0..3).map(|i| (self.tx_pos[i] - self.rx_pos[i]).powi(2)).sum();
d.sqrt()
}
}
/// Calibration contract carried alongside every canonical tensor (ADR-274 §2.2).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CalibrationMeta {
/// Oscillator quality in parts-per-million drift (lower is better).
pub clock_ppm: f64,
/// Whether per-link phase offsets have been calibrated out upstream.
pub phase_calibrated: bool,
/// Gain offset (dB) applied during normalization, for provenance.
pub gain_offset_db: f64,
/// Identifier of the empty-room baseline applied, if any (ADR-135).
pub baseline_id: Option<String>,
}
impl Default for CalibrationMeta {
fn default() -> Self {
Self { clock_ppm: 20.0, phase_calibrated: false, gain_offset_db: 0.0, baseline_id: None }
}
}
/// The canonical complex RF tensor: `(links, bins, snapshots)` plus the
/// metadata every downstream consumer needs (freshness, geometry, clock
/// quality, uncertainty, provenance).
#[derive(Debug, Clone)]
pub struct RfTensor {
/// Source modality.
pub modality: RfModality,
/// Carrier centre frequency in Hz.
pub center_freq_hz: f64,
/// Occupied bandwidth in Hz (span of the bin axis).
pub bandwidth_hz: f64,
/// Complex samples, shape `(links, bins, snapshots)`.
pub data: Array3<Complex64>,
/// Per-link antenna geometry; `links.len() == data.dim().0`.
pub links: Vec<LinkGeometry>,
/// Age of the *oldest* snapshot in seconds at tensor construction time
/// (the freshness signal the encoder fuses multiplicatively, ADR-274 §3).
pub sample_age_s: f64,
/// Capture timestamp, nanoseconds since epoch.
pub timestamp_ns: u64,
/// Source device identifier (chipset/firmware provenance key).
pub device_id: String,
/// Clock quality in `[0, 1]` (1 = disciplined reference, 0 = free-running).
pub clock_quality: f64,
/// Front-end uncertainty proxy in `[0, 1]` (0 = clean, 1 = at noise floor).
pub uncertainty: f64,
/// Calibration contract.
pub calibration: CalibrationMeta,
}
impl RfTensor {
/// Validated constructor — the only way to build an `RfTensor`.
///
/// Boundary rules enforced here (so downstream modules may assume them):
/// non-empty dims, geometry length matches the link axis, all samples
/// finite, frequencies positive, `clock_quality`/`uncertainty` ∈ [0, 1],
/// `sample_age_s` finite and non-negative.
#[allow(clippy::too_many_arguments)]
pub fn new(
modality: RfModality,
center_freq_hz: f64,
bandwidth_hz: f64,
data: Array3<Complex64>,
links: Vec<LinkGeometry>,
sample_age_s: f64,
timestamp_ns: u64,
device_id: String,
clock_quality: f64,
uncertainty: f64,
calibration: CalibrationMeta,
) -> Result<Self> {
let (n_links, n_bins, n_snaps) = data.dim();
if n_links == 0 || n_bins == 0 || n_snaps == 0 {
return Err(UnifiedError::ShapeMismatch(format!(
"empty tensor axis: ({n_links}, {n_bins}, {n_snaps})"
)));
}
if links.len() != n_links {
return Err(UnifiedError::ShapeMismatch(format!(
"geometry describes {} links, data has {n_links}",
links.len()
)));
}
if !(center_freq_hz.is_finite() && center_freq_hz > 0.0) {
return Err(UnifiedError::InvalidInput(format!(
"center_freq_hz must be finite and positive, got {center_freq_hz}"
)));
}
if !(bandwidth_hz.is_finite() && bandwidth_hz > 0.0) {
return Err(UnifiedError::InvalidInput(format!(
"bandwidth_hz must be finite and positive, got {bandwidth_hz}"
)));
}
if !(0.0..=1.0).contains(&clock_quality) {
return Err(UnifiedError::InvalidInput(format!(
"clock_quality must be in [0,1], got {clock_quality}"
)));
}
if !(0.0..=1.0).contains(&uncertainty) {
return Err(UnifiedError::InvalidInput(format!(
"uncertainty must be in [0,1], got {uncertainty}"
)));
}
if !(sample_age_s.is_finite() && sample_age_s >= 0.0) {
return Err(UnifiedError::InvalidInput(format!(
"sample_age_s must be finite and >= 0, got {sample_age_s}"
)));
}
for link in &links {
for c in link.tx_pos.iter().chain(link.rx_pos.iter()) {
if !c.is_finite() {
return Err(UnifiedError::InvalidInput(
"non-finite antenna coordinate".into(),
));
}
}
}
if data.iter().any(|z| !z.re.is_finite() || !z.im.is_finite()) {
return Err(UnifiedError::InvalidInput("non-finite complex sample".into()));
}
Ok(Self {
modality,
center_freq_hz,
bandwidth_hz,
data,
links,
sample_age_s,
timestamp_ns,
device_id,
clock_quality,
uncertainty,
calibration,
})
}
/// `(links, bins, snapshots)`.
#[must_use]
pub fn dims(&self) -> (usize, usize, usize) {
self.data.dim()
}
/// Carrier wavelength in metres.
#[must_use]
pub fn wavelength_m(&self) -> f64 {
299_792_458.0 / self.center_freq_hz
}
/// DelayDoppler magnitude map for one link (ADR-281 §3): IDFT over the
/// frequency axis (→ delay bins) crossed with a DFT over the snapshot
/// axis (→ Doppler bins). Shape `(bins, snapshots)`.
///
/// 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!(
"link {link} out of range ({n_links} links)"
)));
}
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 b in 0..n_bins {
for s in 0..n_snaps {
let ang = 2.0 * std::f64::consts::PI
* ((b * d) as f64 / n_bins as f64 - (s * v) as f64 / n_snaps as f64);
acc += self.data[[link, b, s]] * Complex64::new(ang.cos(), ang.sin());
}
}
out[[d, v]] = acc.norm() / (n_bins * n_snaps) as f64;
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn link() -> LinkGeometry {
LinkGeometry { tx_pos: [0.0, 0.0, 1.0], rx_pos: [3.0, 4.0, 1.0] }
}
fn valid_data(links: usize) -> Array3<Complex64> {
Array3::from_elem((links, CANONICAL_BINS, CANONICAL_SNAPSHOTS), Complex64::new(1.0, 0.0))
}
fn build(data: Array3<Complex64>, links: Vec<LinkGeometry>) -> Result<RfTensor> {
RfTensor::new(
RfModality::WifiCsi,
2.437e9,
20e6,
data,
links,
0.05,
1_700_000_000_000_000_000,
"test-device".into(),
0.8,
0.1,
CalibrationMeta::default(),
)
}
#[test]
fn accepts_valid_tensor_and_reports_dims() {
let t = build(valid_data(2), vec![link(), link()]).expect("valid tensor");
assert_eq!(t.dims(), (2, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
// 2.437 GHz → λ ≈ 0.1230 m.
assert!((t.wavelength_m() - 0.123_017).abs() < 1e-4);
assert!((t.links[0].distance_m() - 5.0).abs() < 1e-12);
}
#[test]
fn delay_doppler_map_localizes_a_synthetic_target() {
// A scatterer at delay bin 7 with Doppler bin 3:
// H[b,s] = exp(-j2πb·7/B) · exp(+j2πs·3/S) ⇒ single peak at (7, 3).
let (d0, v0) = (7usize, 3usize);
let data = Array3::from_shape_fn((1, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(_, b, s)| {
let ang = -2.0 * std::f64::consts::PI * (b * d0) as f64 / CANONICAL_BINS as f64
+ 2.0 * std::f64::consts::PI * (s * v0) as f64 / CANONICAL_SNAPSHOTS as f64;
Complex64::new(ang.cos(), ang.sin())
});
let t = build(data, vec![link()]).expect("valid tensor");
let map = t.delay_doppler_map(0).expect("map");
assert!((map[[d0, v0]] - 1.0).abs() < 1e-9, "peak must be unit at ({d0},{v0})");
for d in 0..CANONICAL_BINS {
for v in 0..CANONICAL_SNAPSHOTS {
if (d, v) != (d0, v0) {
assert!(map[[d, v]] < 1e-9, "leakage at ({d},{v}): {}", map[[d, v]]);
}
}
}
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!(
build(valid_data(2), vec![link()]),
Err(UnifiedError::ShapeMismatch(_))
));
}
#[test]
fn rejects_non_finite_sample() {
let mut data = valid_data(1);
data[[0, 3, 2]] = Complex64::new(f64::NAN, 0.0);
assert!(matches!(build(data, vec![link()]), Err(UnifiedError::InvalidInput(_))));
}
#[test]
fn rejects_out_of_range_scalars() {
let t = RfTensor::new(
RfModality::WifiCsi,
2.437e9,
20e6,
valid_data(1),
vec![link()],
-1.0, // negative age
0,
"d".into(),
0.8,
0.1,
CalibrationMeta::default(),
);
assert!(matches!(t, Err(UnifiedError::InvalidInput(_))));
let t = RfTensor::new(
RfModality::WifiCsi,
2.437e9,
20e6,
valid_data(1),
vec![link()],
0.0,
0,
"d".into(),
1.5, // clock quality out of range
0.1,
CalibrationMeta::default(),
);
assert!(matches!(t, Err(UnifiedError::InvalidInput(_))));
}
}
+322
View File
@@ -0,0 +1,322 @@
//! RF tokenizer — canonical tensor in, encoder-ready tokens out
//! (ADR-274 §3.1).
//!
//! One token per `(link, subcarrier-group)`; each token carries amplitude,
//! delay-spectrum, Doppler-spectrum, phase-dynamics, freshness, geometry,
//! clock-quality, and uncertainty features. The exact 24-dimensional layout
//! is documented on [`RfToken`]; the encoder treats it as an opaque vector,
//! so new feature dims only require bumping [`D_IN`].
use num_complex::Complex64;
use crate::math::DftPlan;
use crate::tensor::{RfTensor, CANONICAL_SNAPSHOTS};
/// Subcarrier-group width: 56 bins / 8 = 7 tokens per link.
pub const GROUP_BINS: usize = 8;
/// Token feature dimension.
pub const D_IN: usize = 24;
/// Sinusoidal position-encoding dimension (used only by masked
/// reconstruction so the decoder knows *which* token it is predicting).
pub const D_POS: usize = 16;
/// One tokenized `(link, group)` cell.
///
/// All amplitude-derived features are computed on window-median-normalized,
/// CFO-aligned samples (see [`RfTokenizer::tokenize`]), so they are
/// invariant to front-end gain and common phase drift.
///
/// Feature layout (all values finite, roughly unit-scale):
///
/// | idx | feature |
/// |-----|---------|
/// | 07 | `ln(1+amp)` per bin, averaged over snapshots |
/// | 811 | delay-domain DFT magnitudes (bins 03) of the snapshot-mean group |
/// | 1215 | `ln(1+100·mag)` Doppler DFT magnitudes (bins 14, DC skipped) across snapshots |
/// | 16 | `ln(1+20·std)` temporal amplitude deviation (motion energy) |
/// | 17 | mean inter-snapshot phase velocity (rad/snapshot) |
/// | 18 | sample age (seconds, clipped to 10) |
/// | 19 | link distance / 10 m |
/// | 20 | link midpoint height / 3 m |
/// | 21 | link azimuth / π |
/// | 22 | clock quality |
/// | 23 | uncertainty |
#[derive(Debug, Clone)]
pub struct RfToken {
/// Feature vector, layout above.
pub features: [f64; D_IN],
/// Link index within the source tensor.
pub link: usize,
/// Subcarrier-group index within the link.
pub group: usize,
}
/// A tokenized tensor window plus the window-level context the encoder's
/// age/geometry paths consume.
#[derive(Debug, Clone)]
pub struct TokenizedWindow {
/// Tokens, link-major then group order.
pub tokens: Vec<RfToken>,
/// Window age in seconds (drives the multiplicative freshness gate).
pub age_s: f64,
/// Window-level geometry summary: mean TX xyz then mean RX xyz, in
/// decametres (÷10) to keep unit scale.
pub geometry: [f64; 6],
}
/// Tokenizer with precomputed DFT plans (delay + Doppler transforms are the
/// hot path; see `benches/unified_bench.rs` for the measured speedup over
/// planless DFTs).
pub struct RfTokenizer {
delay_plan: DftPlan,
doppler_plan: DftPlan,
}
impl Default for RfTokenizer {
fn default() -> Self {
Self::new()
}
}
impl RfTokenizer {
/// Builds the tokenizer (allocates the two DFT twiddle tables once).
#[must_use]
pub fn new() -> Self {
Self {
delay_plan: DftPlan::new(GROUP_BINS, 4),
doppler_plan: DftPlan::new(CANONICAL_SNAPSHOTS, 5),
}
}
/// Tokenizes a canonical tensor. Panics never: the tensor's validated
/// invariants (canonical dims after adapter normalization) are assumed;
/// non-canonical bin counts simply produce fewer/more groups.
///
/// Two hardware-invariance steps happen before feature extraction
/// (ADR-274 §3.1 — without them, chipset gain and CFO drift dominate
/// every downstream feature):
///
/// 1. **Scale**: all samples are divided by the window's median
/// amplitude, so front-end gain and absolute path loss cancel.
/// 2. **CFO alignment**: per link, each snapshot is de-rotated by the
/// common phase between it and snapshot 0
/// (`arg Σ_b H[b,s]·H̄[b,0]`) — carrier-frequency-offset drift is a
/// *common* rotation and cancels, while a moving scatterer's
/// frequency-selective perturbation survives.
#[must_use]
pub fn tokenize(&self, tensor: &RfTensor) -> TokenizedWindow {
let (n_links, n_bins, n_snaps) = tensor.dims();
let n_groups = n_bins / GROUP_BINS;
let mut tokens = Vec::with_capacity(n_links * n_groups);
// Window-level robust amplitude scale.
let amps: Vec<f64> = tensor.data.iter().map(|z| z.norm()).collect();
let scale = crate::math::median(&amps).max(1e-12);
// Per-(link, snapshot) CFO-alignment rotations against snapshot 0.
let mut align = vec![vec![Complex64::new(1.0, 0.0); n_snaps]; n_links];
for l in 0..n_links {
for s in 1..n_snaps {
let mut acc = Complex64::new(0.0, 0.0);
for b in 0..n_bins {
acc += tensor.data[[l, b, s]] * tensor.data[[l, b, 0]].conj();
}
if acc.norm() > 1e-18 {
align[l][s] = (acc / acc.norm()).conj();
}
}
}
let sample = |l: usize, b: usize, s: usize| tensor.data[[l, b, s]] * align[l][s] / scale;
for l in 0..n_links {
let geo = &tensor.links[l];
let dx = geo.rx_pos[0] - geo.tx_pos[0];
let dy = geo.rx_pos[1] - geo.tx_pos[1];
let dist = geo.distance_m().max(1e-6);
let mid_z = (geo.tx_pos[2] + geo.rx_pos[2]) / 2.0;
let azimuth = dy.atan2(dx);
for g in 0..n_groups {
let b0 = g * GROUP_BINS;
let mut f = [0.0f64; D_IN];
// Snapshot-mean complex value per bin (delay features) and
// group-mean complex value per snapshot (Doppler features).
let mut bin_means = [Complex64::new(0.0, 0.0); GROUP_BINS];
let mut snap_means = vec![Complex64::new(0.0, 0.0); n_snaps];
let mut amp_sum = [0.0f64; GROUP_BINS];
let mut amp_all = Vec::with_capacity(GROUP_BINS * n_snaps);
for (bi, bin) in (b0..b0 + GROUP_BINS).enumerate() {
for (s, sm) in snap_means.iter_mut().enumerate() {
let z = sample(l, bin, s);
bin_means[bi] += z;
*sm += z;
amp_sum[bi] += z.norm();
amp_all.push(z.norm());
}
}
for b in &mut bin_means {
*b /= n_snaps as f64;
}
for s in &mut snap_means {
*s /= GROUP_BINS as f64;
}
// 07: log-amplitudes.
for bi in 0..GROUP_BINS {
f[bi] = (1.0 + amp_sum[bi] / n_snaps as f64).ln();
}
// 811: delay spectrum of the group.
for (k, m) in self.delay_plan.magnitudes(&bin_means).iter().enumerate() {
f[8 + k] = *m;
}
// 1215: Doppler spectrum across snapshots (skip DC bin 0),
// log-compressed to O(1): motion magnitudes live at 1e-2 of
// the static field, and leaving them 20× smaller than the
// amplitude dims stalls every downstream linear adapter
// (feature design, not adapter parameters).
let dop_scale = |m: f64| (1.0 + 100.0 * m).ln();
if n_snaps == CANONICAL_SNAPSHOTS {
let dop = self.doppler_plan.magnitudes(&snap_means);
for k in 0..4 {
f[12 + k] = dop_scale(dop[1 + k]);
}
} else {
for (k, m) in
crate::math::dft_magnitudes(&snap_means, 5).iter().skip(1).enumerate()
{
f[12 + k] = dop_scale(*m);
}
}
// 16: temporal amplitude std (motion energy), same treatment.
let mean_amp = amp_all.iter().sum::<f64>() / amp_all.len() as f64;
let var = amp_all.iter().map(|a| (a - mean_amp).powi(2)).sum::<f64>()
/ amp_all.len() as f64;
f[16] = (1.0 + 20.0 * var.sqrt()).ln();
// 17: mean inter-snapshot phase velocity of the group mean.
let mut dphi = 0.0;
for s in 1..n_snaps {
dphi += (snap_means[s] * snap_means[s - 1].conj()).arg();
}
f[17] = dphi / (n_snaps.max(2) - 1) as f64;
// 1823: freshness, geometry, clock, uncertainty.
f[18] = tensor.sample_age_s.min(10.0);
f[19] = dist / 10.0;
f[20] = mid_z / 3.0;
f[21] = azimuth / std::f64::consts::PI;
f[22] = tensor.clock_quality;
f[23] = tensor.uncertainty;
tokens.push(RfToken { features: f, link: l, group: g });
}
}
let mut geometry = [0.0f64; 6];
for geo in &tensor.links {
for i in 0..3 {
geometry[i] += geo.tx_pos[i];
geometry[3 + i] += geo.rx_pos[i];
}
}
for v in &mut geometry {
*v /= 10.0 * n_links as f64;
}
TokenizedWindow { tokens, age_s: tensor.sample_age_s, geometry }
}
}
/// Fixed sinusoidal position encoding for token index `idx` (masked
/// reconstruction target addressing; not a learned parameter).
#[must_use]
pub fn position_encoding(idx: usize) -> [f64; D_POS] {
let mut p = [0.0f64; D_POS];
for k in 0..D_POS / 2 {
let freq = 1.0 / 10_000f64.powf(2.0 * k as f64 / D_POS as f64);
p[2 * k] = (idx as f64 * freq).sin();
p[2 * k + 1] = (idx as f64 * freq).cos();
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tensor::{CalibrationMeta, LinkGeometry, RfModality, CANONICAL_BINS};
use ndarray::Array3;
fn tensor_with(motion: bool) -> RfTensor {
let data = Array3::from_shape_fn((2, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(l, b, s)| {
let base = 1.0 + 0.1 * (b as f64 / 10.0).sin() + 0.05 * l as f64;
let wobble = if motion {
// Snapshot-varying, frequency-selective perturbation — a
// moving scatterer (bin-dependent so CFO alignment, which
// only removes *common* rotations, must not cancel it).
0.3 * (2.0 * std::f64::consts::PI * 2.0 * s as f64 / 8.0).sin()
* (1.0 + b as f64 / 56.0)
} else {
0.0
};
Complex64::new(0.0, wobble).exp() * (base + wobble.abs())
});
RfTensor::new(
RfModality::WifiCsi,
2.437e9,
20e6,
data,
vec![
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] },
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.3, 2.0] },
],
0.05,
0,
"tok-test".into(),
0.8,
0.1,
CalibrationMeta::default(),
)
.expect("valid tensor")
}
#[test]
fn produces_expected_token_grid() {
let w = RfTokenizer::new().tokenize(&tensor_with(false));
assert_eq!(w.tokens.len(), 2 * (CANONICAL_BINS / GROUP_BINS));
for t in &w.tokens {
assert!(t.features.iter().all(|v| v.is_finite()), "non-finite feature");
}
// Context passthrough.
assert!((w.age_s - 0.05).abs() < 1e-12);
assert!(w.tokens[0].features[22] > 0.79 && w.tokens[0].features[22] < 0.81);
}
#[test]
fn motion_raises_doppler_and_variance_features() {
let tok = RfTokenizer::new();
let still = tok.tokenize(&tensor_with(false));
let moving = tok.tokenize(&tensor_with(true));
let dop = |w: &TokenizedWindow| {
w.tokens.iter().map(|t| t.features[12..16].iter().sum::<f64>()).sum::<f64>()
};
let var = |w: &TokenizedWindow| w.tokens.iter().map(|t| t.features[16]).sum::<f64>();
assert!(
dop(&moving) > 10.0 * dop(&still) + 1e-9,
"Doppler features must respond to motion: moving={} still={}",
dop(&moving),
dop(&still)
);
assert!(var(&moving) > var(&still));
}
#[test]
fn position_encoding_is_unique_and_bounded() {
let a = position_encoding(0);
let b = position_encoding(7);
assert_ne!(a, b);
for v in a.iter().chain(b.iter()) {
assert!(v.abs() <= 1.0);
}
}
}
@@ -0,0 +1,237 @@
//! End-to-end acceptance pipeline for the unified RF spatial world model
//! (ADR-273 §6), on SYNTHETIC data (ADR-276 generator — every number below
//! is a synthetic-world result until validated on measured captures).
//!
//! The pipeline exercised is the deployment pipeline, not a shortcut:
//! physics-simulated CSI → canonical tensor → tokenizer → self-supervised
//! masked-reconstruction pretraining (labels never touch the encoder) →
//! frozen encoder → ≤1 %-budget presence adapter → strict anti-leakage
//! evaluation → policy-wrapped export.
//!
//! Acceptance gates checked here (synthetic analogues of ADR-273 §6):
//! 1. presence F1 ≥ 0.90 on completely held-out rooms;
//! 2. the same gate under a held-out *chipset* split;
//! 3. known→unknown relative degradation < 20 %;
//! 4. adapters < 1 % of backbone parameters;
//! 5. p95 tokenize+encode latency < 50 ms;
//! 6. every exported output carries uncertainty, provenance, model version,
//! and purpose, and leaves only through the policy trust boundary.
use std::time::Instant;
use ruview_unified::encoder::{EncoderConfig, RfEncoder};
use ruview_unified::eval::{
expected_calibration_error, f1_score, relative_degradation, selective_metrics, PartitionDim,
StrictSplit,
};
use ruview_unified::gaussian::primitive::Provenance;
use ruview_unified::heads::{within_adapter_budget, PresenceHead};
use ruview_unified::policy::{
BoundedEvent, EventValue, PolicyEngine, PrivacyZone, SensingPurpose, TrustBoundary,
};
use ruview_unified::pretrain::{pretrain, PretrainConfig};
use ruview_unified::synth::{LabeledWindow, SynthConfig, SynthGenerator};
use ruview_unified::tokenizer::{RfTokenizer, TokenizedWindow};
/// Shared fixture: corpus, tokenized windows, encoder config.
struct Fixture {
corpus: Vec<LabeledWindow>,
windows: Vec<TokenizedWindow>,
cfg: EncoderConfig,
}
fn build_fixture() -> Fixture {
let corpus = SynthGenerator::new(SynthConfig {
seed: 273_273,
n_rooms: 8,
windows_per_room: 20,
links: 3,
snapshot_dt_s: 0.05,
})
.generate();
let tokenizer = RfTokenizer::new();
let windows: Vec<TokenizedWindow> =
corpus.iter().map(|w| tokenizer.tokenize(&w.tensor)).collect();
// d_model = 64 keeps the debug-profile test fast; the ≤1 % adapter
// budget is checked against THIS backbone, not a larger one.
let cfg = EncoderConfig { d_model: 64, ..EncoderConfig::default() };
Fixture { corpus, windows, cfg }
}
/// Pretrains on the training side only, trains a presence head on frozen
/// representations, and returns (known-condition F1, held-out F1, held-out
/// probabilities and labels) for a given strict split.
fn run_split(fx: &Fixture, split: &StrictSplit) -> (f64, f64, Vec<f64>, Vec<bool>) {
// Self-supervised pretraining: training windows only, no labels.
let train_windows: Vec<TokenizedWindow> =
split.train.iter().map(|&i| fx.windows[i].clone()).collect();
let mut encoder = RfEncoder::new(fx.cfg, 7);
let report = pretrain(
&mut encoder,
&train_windows,
&PretrainConfig { mask_fraction: 0.25, lr: 0.03, epochs: 8, seed: 0x5EED },
);
assert!(
report.final_loss < report.initial_loss,
"pretraining must reduce masked loss: {report:?}"
);
// Frozen encoder → environment-invariant content representation for
// every window (presence is a cross-room task; the geometry-conditioned
// `encode()` view is for localization-style heads).
let zs: Vec<Vec<f64>> = fx.windows.iter().map(|w| encoder.encode_content(w)).collect();
// ≤1 % adapter on the frozen backbone.
let mut head = PresenceHead::new(encoder.content_dim());
assert!(
within_adapter_budget(encoder.param_count(), head.param_count()),
"presence adapter {} params vs backbone {}",
head.param_count(),
encoder.param_count()
);
let train_z: Vec<Vec<f64>> = split.train.iter().map(|&i| zs[i].clone()).collect();
let train_y: Vec<bool> = split.train.iter().map(|&i| fx.corpus[i].presence).collect();
head.train(&train_z, &train_y, 1.0, 800);
let eval = |idx: &[usize]| -> (Vec<f64>, Vec<bool>) {
let probs: Vec<f64> = idx.iter().map(|&i| head.predict_prob(&zs[i])).collect();
let labels: Vec<bool> = idx.iter().map(|&i| fx.corpus[i].presence).collect();
(probs, labels)
};
let (train_p, train_l) = eval(&split.train);
let (test_p, test_l) = eval(&split.test);
let known_f1 = f1_score(&train_p.iter().map(|p| *p > 0.5).collect::<Vec<_>>(), &train_l);
let held_f1 = f1_score(&test_p.iter().map(|p| *p > 0.5).collect::<Vec<_>>(), &test_l);
(known_f1, held_f1, test_p, test_l)
}
#[test]
fn acceptance_pipeline_on_synthetic_worlds() {
let fx = build_fixture();
let keys: Vec<_> = fx.corpus.iter().map(|w| w.key.clone()).collect();
// ---- Gate 1: held-out ROOMS (rooms 6 and 7 never seen in any stage).
let room_split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-6", "room-7"]);
assert!(room_split.verify(&keys), "room split must be leak-free");
assert!(room_split.test.len() >= 30, "held-out set must be substantial");
let (known_f1, room_f1, test_p, test_l) = run_split(&fx, &room_split);
println!("SYNTHETIC room-holdout: known F1 {known_f1:.4}, held-out F1 {room_f1:.4}");
assert!(
room_f1 >= 0.90,
"ADR-273 gate: presence F1 on unseen rooms must be >= 0.90, got {room_f1:.4} (SYNTHETIC)"
);
// ---- Gate 3: known → unknown degradation < 20 %.
let degradation = relative_degradation(known_f1, room_f1);
println!("SYNTHETIC degradation known→unknown: {degradation:.4}");
assert!(
degradation < 0.20,
"cross-environment degradation must stay under 20 %, got {degradation:.4}"
);
// ---- Calibration + abstention on the held-out side: raising the
// confidence threshold must never raise selective risk, and full-
// coverage risk is bounded by the F1 gate above.
let ece = expected_calibration_error(&test_p, &test_l, 10);
println!("SYNTHETIC held-out ECE: {ece:.4}");
let full = selective_metrics(&test_p, &test_l, 0.5);
let strict = selective_metrics(&test_p, &test_l, 0.9);
println!(
"SYNTHETIC abstention: coverage {:.2}{:.2}, risk {:.4}{:.4}",
full.coverage, strict.coverage, full.selective_risk, strict.selective_risk
);
assert!(strict.selective_risk <= full.selective_risk + 1e-12);
// ---- Gate 2: held-out CHIPSET (every chip-2 room excluded from
// training; per-room gain/phase/CFO/noise profiles are random, so the
// held-out hardware profile is genuinely unseen).
let chip_split = StrictSplit::holdout(&keys, PartitionDim::Chipset, &["chip-2"]);
assert!(chip_split.verify(&keys), "chipset split must be leak-free");
let (chip_known, chip_f1, _, _) = run_split(&fx, &chip_split);
println!("SYNTHETIC chipset-holdout: known F1 {chip_known:.4}, held-out F1 {chip_f1:.4}");
assert!(
chip_f1 >= 0.90,
"presence F1 on unseen chipset must be >= 0.90, got {chip_f1:.4} (SYNTHETIC)"
);
}
#[test]
fn edge_latency_budget_tokenize_plus_encode() {
let fx = build_fixture();
let tokenizer = RfTokenizer::new();
let encoder = RfEncoder::new(fx.cfg, 7);
// Warm up, then measure 100 full tokenize+encode passes.
let mut latencies_us: Vec<u128> = Vec::with_capacity(100);
for i in 0..110 {
let idx = i % fx.corpus.len();
let start = Instant::now();
let w = tokenizer.tokenize(&fx.corpus[idx].tensor);
let z = encoder.encode(&w);
std::hint::black_box(z);
if i >= 10 {
latencies_us.push(start.elapsed().as_micros());
}
}
latencies_us.sort_unstable();
let p50 = latencies_us[latencies_us.len() / 2];
let p95 = latencies_us[latencies_us.len() * 95 / 100];
println!("edge latency tokenize+encode: p50 {p50} µs, p95 {p95} µs (debug profile)");
// ADR-273 gate is 50 ms at p95 on edge hardware; even the unoptimized
// debug profile must clear it with margin on a dev machine.
assert!(p95 < 50_000, "p95 latency {p95} µs exceeds the 50 ms budget");
}
#[test]
fn outputs_leave_only_through_the_policy_boundary_fully_attributed() {
let fx = build_fixture();
let tokenizer = RfTokenizer::new();
let encoder = RfEncoder::new(fx.cfg, 7);
let mut head = PresenceHead::new(encoder.content_dim());
// Small supervised fit so probabilities are meaningful.
let zs: Vec<Vec<f64>> = fx
.corpus
.iter()
.take(60)
.map(|w| encoder.encode_content(&tokenizer.tokenize(&w.tensor)))
.collect();
let ys: Vec<bool> = fx.corpus.iter().take(60).map(|w| w.presence).collect();
head.train(&zs, &ys, 0.5, 100);
let mut engine = PolicyEngine::new();
engine.upsert_zone(PrivacyZone {
id: "room-0".into(),
allowed_purposes: [SensingPurpose::Presence].into_iter().collect(),
retention_s: 600,
identity_explicitly_enabled: false,
});
let boundary = TrustBoundary::new(engine);
let p = head.predict_prob(&zs[0]);
let event = BoundedEvent::new(
SensingPurpose::Presence,
EventValue::Presence(p > 0.5),
1.0 - (2.0 * p - 1.0).abs(), // confidence → uncertainty
Provenance {
device_id: fx.corpus[0].tensor.device_id.clone(),
model_version: 1,
synthetic: true, // honest labeling: synthetic evidence
},
1,
fx.corpus[0].tensor.timestamp_ns,
"room-0",
)
.expect("attributed event");
// Compliant export passes and the payload is a typed verdict — the
// BoundedEvent type has no variant that can carry RF samples, so raw
// CSI export is unrepresentable, not merely forbidden.
let exported =
boundary.export(event.clone(), fx.corpus[0].tensor.timestamp_ns).expect("export ok");
assert!(exported.uncertainty >= 0.0 && exported.uncertainty <= 1.0);
assert!(exported.provenance.synthetic, "synthetic provenance must survive export");
// An ungranted purpose in the same zone is denied (fail-closed).
let denied = BoundedEvent { purpose: SensingPurpose::IdentityRecognition, ..event };
assert!(boundary.export(denied, fx.corpus[0].tensor.timestamp_ns).is_err());
}
@@ -0,0 +1,295 @@
//! 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 — checked
/// across *every* `SensingPurpose` branch (each has a distinct ceiling
/// and exclusion set in `validate_representation`; testing only
/// `Presence`, as this property previously did, leaves the other three
/// branches — covering P3P5, the higher-risk representations —
/// completely unverified).
#[test]
fn occupancy_representations_must_exclude_identity(
purpose in any_purpose(),
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, purpose);
// Oracle mirrors the (ceiling, must_exclude) table in
// `control::validate_representation` so this property checks the
// *contract* per purpose group, not just the Presence case.
let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose {
SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => {
(PrivacyClass::P2, &["identity", "vitals"])
}
SensingPurpose::Activity | SensingPurpose::Localization => {
(PrivacyClass::P3, &["identity"])
}
SensingPurpose::Vitals | SensingPurpose::PoseTracking => {
(PrivacyClass::P4, &["identity"])
}
SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]),
};
let excludes_required =
must_exclude.iter().all(|c| excluded.iter().any(|e| e == c));
if verdict.is_ok() {
prop_assert!(
excludes_required,
"approved rep for {:?} must exclude {:?}", purpose, must_exclude
);
prop_assert!(
class <= ceiling,
"approved rep for {:?} must respect ceiling {:?}", purpose, ceiling
);
}
}
}
+19 -14
View File
@@ -65,6 +65,7 @@ impl ComplexSample {
/// This is a lossy *view*, never re-serialised as the witness form
/// (ADR-136 §3.3 risk mitigation — one encoder only).
#[must_use]
#[allow(clippy::cast_possible_truncation)] // f64 -> f32 is the documented lossy narrowing above
pub fn as_complex32(&self) -> num_complex::Complex32 {
num_complex::Complex32::new(self.0.re as f32, self.0.im as f32)
}
@@ -581,7 +582,9 @@ impl crate::traits::CanonicalFrame for CsiFrame {
b.extend_from_slice(&m.timestamp.seconds.to_le_bytes());
b.extend_from_slice(&m.timestamp.nanos.to_le_bytes());
let dev = m.device_id.as_str().as_bytes();
b.extend_from_slice(&(dev.len() as u32).to_le_bytes());
b.extend_from_slice(
&u32::try_from(dev.len()).expect("device_id length fits u32").to_le_bytes(),
);
b.extend_from_slice(dev);
b.push(match m.frequency_band {
FrequencyBand::Band2_4GHz => 0,
@@ -592,15 +595,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
b.extend_from_slice(&m.bandwidth_mhz.to_le_bytes());
b.push(m.antenna_config.tx_antennas);
b.push(m.antenna_config.rx_antennas);
match m.antenna_config.spacing_mm {
Some(s) => {
b.push(1);
b.extend_from_slice(&s.to_le_bytes());
}
None => {
b.push(0);
b.extend_from_slice(&[0u8; 4]);
}
if let Some(s) = m.antenna_config.spacing_mm {
b.push(1);
b.extend_from_slice(&s.to_le_bytes());
} else {
b.push(0);
b.extend_from_slice(&[0u8; 4]);
}
b.extend_from_slice(&m.rssi_dbm.to_le_bytes());
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
@@ -623,8 +623,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
b.extend_from_slice(&m.model_version.to_le_bytes());
// Shape, then complex payload stream-major.
b.extend_from_slice(&(self.data.nrows() as u32).to_le_bytes());
b.extend_from_slice(&(self.data.ncols() as u32).to_le_bytes());
b.extend_from_slice(
&u32::try_from(self.data.nrows()).expect("stream count fits u32").to_le_bytes(),
);
b.extend_from_slice(
&u32::try_from(self.data.ncols()).expect("subcarrier count fits u32").to_le_bytes(),
);
for sample in self.data_complex_samples() {
b.extend_from_slice(&sample.to_le_bytes());
}
@@ -714,7 +718,7 @@ impl<'a> Cursor<'a> {
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
Ok(self.take(1)?[0] as i8)
Ok(i8::from_le_bytes(self.take(1)?.try_into().unwrap()))
}
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
@@ -1462,7 +1466,7 @@ mod tests {
fn lcg(state: &mut u64) -> f64 {
*state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
// Map high bits into [-1e6, 1e6) for a wide exponent spread.
((*state >> 11) as f64 / (1u64 << 53) as f64) * 2.0e6 - 1.0e6
((*state >> 11) as f64 / (1u64 << 53) as f64).mul_add(2.0e6, -1.0e6)
}
/// AC1 — `ComplexSample` little-endian round-trip + endianness pin.
@@ -1671,6 +1675,7 @@ mod tests {
/// unwinding panic (panic-on-adversarial-input = 0). Sweep truncations and a
/// deterministic fuzz spread.
#[test]
#[allow(clippy::cast_possible_truncation)] // fuzz byte extraction: truncation IS the point
fn canonical_decode_never_panics_on_arbitrary_bytes() {
use ndarray::Array2;
let mut meta = CsiMetadata::new(DeviceId::new("node"), FrequencyBand::Band5GHz, 36);
@@ -146,7 +146,7 @@ impl PpduType {
/// bit 3 : LDPC (reserved — not yet populated by firmware)
/// bit 4 : 802.15.4 time-sync valid (C6 only)
/// bit 5-7 : reserved
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adr018Flags {
pub bw40: bool,
pub stbc: bool,
@@ -173,12 +173,6 @@ impl Adr018Flags {
}
}
impl Default for Adr018Flags {
fn default() -> Self {
Self { bw40: false, stbc: false, ldpc: false, ieee802154_sync_valid: false }
}
}
/// WiFi channel bandwidth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Bandwidth {
@@ -93,8 +93,10 @@ fn fsm_full_cycle_setup_measure_report_terminate() {
#[test]
fn responder_rejects_unsupported_bandwidth_and_initiator_resets() {
let mut cfg = SessionConfig::default();
cfg.capabilities = SensingCapabilities::esp32_opportunistic(); // max 40 MHz
let cfg = SessionConfig {
capabilities: SensingCapabilities::esp32_opportunistic(), // max 40 MHz
..SessionConfig::default()
};
let mut responder = SensingSession::new_responder(cfg);
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
@@ -204,8 +206,10 @@ fn capacity_and_policy_and_profile_rejections() {
));
// Incompatible profile
let mut cfg = SessionConfig::default();
cfg.profile = SpecProfile::VendorExtension("acme".into());
let cfg = SessionConfig {
profile: SpecProfile::VendorExtension("acme".into()),
..SessionConfig::default()
};
let mut responder = SensingSession::new_responder(cfg);
let actions = responder
.handle(SessionEvent::SetupRequestReceived(setup_request(6)))
@@ -268,8 +268,10 @@ fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() {
// Incompatible profile now surfaces as its own status (the old
// duplicated SBP chain folded it into RejectedUnsupportedParams).
let mut cfg = SessionConfig::default();
cfg.profile = SpecProfile::VendorExtension("acme".into());
let cfg = SessionConfig {
profile: SpecProfile::VendorExtension("acme".into()),
..SessionConfig::default()
};
let mut proxy = SensingSession::new_responder(cfg);
let actions = proxy
.handle(SessionEvent::SbpRequestReceived(sbp_request(41)))
@@ -66,48 +66,64 @@ impl Server {
/// test in the suite that observes real wiring disarmed itself exactly when
/// it mattered; a boot-time panic in the auth path would have shipped green.
fn start(env: &[(&str, &str)]) -> Self {
let (http, ws, udp) = (free_port(), free_port(), free_port());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
cmd.args([
"--http-port", &http.to_string(),
"--ws-port", &ws.to_string(),
"--udp-port", &udp.to_string(),
"--bind-addr", "127.0.0.1",
"--no-edge-registry",
"--source", "simulate",
])
// Inherit nothing auth-related from the developer's shell, or a local
// RUVIEW_* export would silently change what this test proves.
.env_remove("RUVIEW_API_TOKEN")
.env_remove("RUVIEW_OAUTH_ISSUER")
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
.stdout(Stdio::null())
// Captured, not discarded: if the server dies at boot, its stderr is the
// only thing that says why, and the panic below reproduces it.
.stderr(Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn sensing-server");
let http_port = http;
let ws_port = ws;
if !await_ready(http_port, ws_port) {
// `free_port()` releases the port before the child binds it, so under
// parallel `cargo test` execution another test's server can grab the
// same ephemeral port first (observed as `Os { code: 10048/98, kind:
// AddrInUse }` on the child's actual bind, distinct from a genuine
// auth-wiring break). Retry a bounded number of times on THAT specific
// signature only — any other boot failure (including a real wiring
// regression) still panics on the first attempt, preserving the
// fail-loud property described above.
const MAX_ATTEMPTS: u32 = 3;
for attempt in 1..=MAX_ATTEMPTS {
let (http, ws, udp) = (free_port(), free_port(), free_port());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
cmd.args([
"--http-port", &http.to_string(),
"--ws-port", &ws.to_string(),
"--udp-port", &udp.to_string(),
"--bind-addr", "127.0.0.1",
"--no-edge-registry",
"--source", "simulate",
])
// Inherit nothing auth-related from the developer's shell, or a local
// RUVIEW_* export would silently change what this test proves.
.env_remove("RUVIEW_API_TOKEN")
.env_remove("RUVIEW_OAUTH_ISSUER")
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
.stdout(Stdio::null())
// Captured, not discarded: if the server dies at boot, its stderr is the
// only thing that says why, and the panic below reproduces it.
.stderr(Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn sensing-server");
if await_ready(http, ws) {
return Server { child, http, ws };
}
let mut err = String::new();
if let Some(mut s) = child.stderr.take() {
let _ = s.read_to_string(&mut err);
}
let _ = child.kill();
let _ = child.wait();
let looks_like_port_collision =
err.contains("AddrInUse") || err.contains("Address already in use")
|| err.contains("code: 10048") || err.contains("code: 98");
if looks_like_port_collision && attempt < MAX_ATTEMPTS {
continue;
}
panic!(
"sensing-server did not become ready on :{http_port} (http) and :{ws_port} (ws) \
within 30s. This is a FAILURE, not a skip — the wiring assertions below cannot \
run, and a boot-time break in the auth path is exactly what they exist to catch.\n\
"sensing-server did not become ready on :{http} (http) and :{ws} (ws) \
within 30s (attempt {attempt}/{MAX_ATTEMPTS}). This is a FAILURE, not a skip — \
the wiring assertions below cannot run, and a boot-time break in the auth path \
is exactly what they exist to catch.\n\
--- server stderr ---\n{err}"
);
}
Server { child, http: http_port, ws: ws_port }
unreachable!("loop always returns or panics");
}
}
fn await_ready(http: u16, ws: u16) -> bool {