mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
feat(ruview-unified): native frame contract + programmable perception (ADR-279..282)
Second increment of the unified RF spatial world model, applying the architectural correction that the 56-bin canonical tensor must not be the authoritative format, and moving the control plane from passive sensing to programmable perception. - RfFrameV2 (ADR-279): authoritative native RF record — native complex IQ preserved (proven byte-untouched by the derived view), explicit validity masks, declared PhaseState, TX/RX poses + antenna geometry, calibration/quality state, and construction-time provenance rules: Synthetic ⇒ L0Simulation, Measured ⇒ ≥ L1CapturedReplay (the L0–L5 evidence ladder is now a type). Canonical tensor demoted to a mask-aware derived view through the shared adapter normalization. New modalities: WifiCir, WifiBfReport, FmcwRangeAzimuth, FmcwDopplerAzimuth. IEEE P3162 synthetic-aperture import profile. - Active sensing control plane (ADR-280, control.rs): SensingTask admission (raw export always refused; identity requires consent), SensingAction/InformationGoal, age-of-information planner with measured 95% sensing-traffic reduction vs uniform refresh, fail-closed CoherentSensorGroup fusion (time/phase/geometry bounds, five denial paths tested), policy-authorized RIS actuation receipts, purpose-scoped TaskSufficientRepresentation leakage validation. - BLE Channel Sounding (ADR-281): adapter + ble_cs_range with phase-slope and RTT as separate cross-validated evidence — exact recovery on synthetic tones, relay-style divergence flagged instead of averaged. Delay-Doppler-native FieldAxis + delay_doppler_map (unit-peak tone test). - Factorized pose (ADR-281, RePos): relative skeleton on the content representation, root on the geometry-conditioned one, calibrated per-joint uncertainties; room-shortcut leakage experiment: held-out MPJPE 0.0003 m vs 0.2534 m monolithic; 740 params (<2% structured budget). Age gate input now log(1+age_ms); gradient check re-proven. - Gaussian primitives: first_seen_ns, doppler_variance, bounded source_receipts lineage merged on fusion. PartitionKey gains a session dimension; SplitManifest certifies disjointness across all seven leakage dimensions. - ADR-282: ecosystem positioning — RuView as the edge RF perception runtime under RuField/RuVector/MetaHarness; evidence-ladder policy. Validation: ruview-unified 84 unit + 3 acceptance tests, 0 failed, clippy-clean; workspace 3,789 passed 0 failed (--exclude wifi-densepose-desktop, GTK headers unavailable in container); Python proof VERDICT PASS. Docker images unaffected: no shipped binary consumes this crate yet (Dockerfile.rust builds sensing-server / cog-ha-matter / homecore-server only; Dockerfile.python builds untouched archive/v1). Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX
This commit is contained in:
@@ -73,6 +73,20 @@ pub enum RawCapture {
|
||||
/// 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 {
|
||||
@@ -101,11 +115,105 @@ impl RawCapture {
|
||||
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()
|
||||
)));
|
||||
}
|
||||
let df = frame.frequency_steps_hz[1] - frame.frequency_steps_hz[0];
|
||||
if !(df.is_finite() && df > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput("frequency steps must ascend uniformly".into()));
|
||||
}
|
||||
// Unwrap phases across steps, then least-squares slope per Hz.
|
||||
let mut unwrapped = Vec::with_capacity(n);
|
||||
let mut prev = frame.phase_samples_rad[0];
|
||||
unwrapped.push(prev);
|
||||
for &p in &frame.phase_samples_rad[1..] {
|
||||
let mut v = p;
|
||||
while v - prev > std::f64::consts::PI {
|
||||
v -= 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
while v - prev < -std::f64::consts::PI {
|
||||
v += 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
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.
|
||||
@@ -120,8 +228,12 @@ pub trait RfAdapter: Send + Sync {
|
||||
|
||||
/// Shared stage 1–3 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)]
|
||||
fn finish_normalization(
|
||||
pub(crate) fn normalize_grid(
|
||||
modality: RfModality,
|
||||
mut grid: Array3<Complex64>, // (links, bins, snapshots) at source resolution
|
||||
links: Vec<LinkGeometry>,
|
||||
@@ -288,7 +400,7 @@ impl RfAdapter for WifiCsiAdapter {
|
||||
let center_freq_hz = f64::from(meta.frequency_band.center_frequency_mhz()) * 1e6;
|
||||
let ts = &meta.timestamp;
|
||||
let timestamp_ns = u64::try_from(ts.seconds).unwrap_or(0) * 1_000_000_000 + u64::from(ts.nanos);
|
||||
finish_normalization(
|
||||
normalize_grid(
|
||||
RfModality::WifiCsi,
|
||||
grid,
|
||||
links.clone(),
|
||||
@@ -359,7 +471,7 @@ impl RfAdapter for FmcwRadarAdapter {
|
||||
// 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.
|
||||
finish_normalization(
|
||||
normalize_grid(
|
||||
RfModality::FmcwRadar,
|
||||
grid,
|
||||
links.clone(),
|
||||
@@ -405,7 +517,7 @@ impl RfAdapter for UwbCirAdapter {
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
finish_normalization(
|
||||
normalize_grid(
|
||||
RfModality::UwbCir,
|
||||
taps.clone(),
|
||||
links.clone(),
|
||||
@@ -479,7 +591,7 @@ impl RfAdapter for CellularSrsAdapter {
|
||||
}
|
||||
}
|
||||
}
|
||||
finish_normalization(
|
||||
normalize_grid(
|
||||
RfModality::CellularSrs,
|
||||
grid,
|
||||
links.clone(),
|
||||
@@ -495,6 +607,63 @@ impl RfAdapter for CellularSrsAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -517,6 +686,7 @@ impl AdapterRegistry {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -669,12 +839,73 @@ mod tests {
|
||||
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", "oai-srs-xapp"]
|
||||
vec!["dw3000", "esp32s3-csi", "mr60bha2", "nrf54-cs", "oai-srs-xapp"]
|
||||
);
|
||||
|
||||
// Unknown hardware ⇒ error, never a default adapter.
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
//! 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.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());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@
|
||||
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).
|
||||
@@ -256,11 +264,12 @@ impl RfEncoder {
|
||||
*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 * ctx.age_s + b))
|
||||
.map(|(w, b)| sigmoid(w * age_feat + b))
|
||||
.collect();
|
||||
|
||||
let geo = self.wg.forward(&ctx.geometry);
|
||||
|
||||
@@ -32,6 +32,9 @@ pub struct PartitionKey {
|
||||
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.
|
||||
@@ -49,6 +52,8 @@ pub enum PartitionDim {
|
||||
Firmware,
|
||||
/// Hold out complete antenna layouts.
|
||||
Layout,
|
||||
/// Hold out complete capture sessions.
|
||||
Session,
|
||||
}
|
||||
|
||||
impl PartitionDim {
|
||||
@@ -60,8 +65,75 @@ impl PartitionDim {
|
||||
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.
|
||||
@@ -225,6 +297,7 @@ mod tests {
|
||||
chipset: "esp32s3".into(),
|
||||
firmware: "v1.2".into(),
|
||||
layout: "L".into(),
|
||||
session: format!("{room}-{day}-{person}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -253,6 +326,28 @@ mod tests {
|
||||
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.
|
||||
|
||||
@@ -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 5–6).
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -134,12 +134,21 @@ impl GaussianMap {
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -107,20 +107,31 @@ pub struct RfGaussian {
|
||||
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).
|
||||
@@ -175,11 +186,14 @@ impl RfGaussian {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -253,6 +253,237 @@ impl LocalizationHead {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -319,13 +550,128 @@ mod tests {
|
||||
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 < {})",
|
||||
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();
|
||||
|
||||
@@ -30,9 +30,16 @@
|
||||
//! [`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;
|
||||
|
||||
@@ -110,13 +110,15 @@ pub fn masked_loss_and_grads(
|
||||
}
|
||||
loss *= norm;
|
||||
|
||||
// Fusion: z = g ⊙ gate + Wg·geo + bg.
|
||||
// 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 * ctx.age_s;
|
||||
grads.age_w[k] += dgate * dsig * age_feat;
|
||||
grads.age_b[k] += dgate * dsig;
|
||||
}
|
||||
enc.wg.accumulate_grad(&mut grads.wg, &dz, &ctx.geometry);
|
||||
|
||||
@@ -220,6 +220,9 @@ impl SynthGenerator {
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,13 +28,21 @@ pub const CANONICAL_SNAPSHOTS: usize = 8;
|
||||
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 range–azimuth map.
|
||||
FmcwRangeAzimuth,
|
||||
/// FMCW radar Doppler–azimuth map.
|
||||
FmcwDopplerAzimuth,
|
||||
/// Ultra-wideband channel impulse response taps.
|
||||
UwbCir,
|
||||
/// Bluetooth Channel Sounding tones.
|
||||
/// 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).
|
||||
@@ -204,6 +212,37 @@ impl RfTensor {
|
||||
pub fn wavelength_m(&self) -> f64 {
|
||||
299_792_458.0 / self.center_freq_hz
|
||||
}
|
||||
|
||||
/// Delay–Doppler 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.
|
||||
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)"
|
||||
)));
|
||||
}
|
||||
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)]
|
||||
@@ -243,6 +282,29 @@ mod tests {
|
||||
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 rejects_geometry_link_mismatch() {
|
||||
assert!(matches!(
|
||||
|
||||
Reference in New Issue
Block a user