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:
Claude
2026-07-26 19:29:18 +00:00
parent a1a59baf72
commit 9aae7f04ff
20 changed files with 2352 additions and 14 deletions
+237 -6
View File
@@ -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 13 pipeline: resample to canonical dims, per-link median
/// amplitude normalization, per-(link, snapshot) phase detrend.
///
/// Crate-visible so [`crate::frame::RfFrameV2::to_canonical`] derives the
/// compatibility view through the exact same code path as every adapter
/// (ADR-279 §3 — one normalization, many entry points).
#[allow(clippy::too_many_arguments)]
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.
+666
View File
@@ -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());
}
}
+10 -1
View File
@@ -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);
+95
View File
@@ -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.
+661
View File
@@ -0,0 +1,661 @@
//! Native RF frame contract — `RfFrameV2` (ADR-279).
//!
//! The ADR-273 P1 canonical tensor (`RfTensor`, 56 bins × 8 snapshots) is
//! useful for compatibility, but it must **not** be the authoritative data
//! format: resampling every device into one fixed tensor discards
//! bandwidth, antenna, phase-state, and hardware-specific information.
//! `RfFrameV2` preserves the **native complex tensor** with explicit
//! validity masks, phase state, geometry, calibration, quality, and
//! provenance; the canonical tensor is demoted to a *derived view*
//! ([`RfFrameV2::to_canonical`]) computed on demand and never written back.
//!
//! Required invariants (ADR-279 §2, each enforced by a constructor check or
//! a test):
//! 1. Native complex samples are never overwritten by normalized samples
//! (`to_canonical` takes `&self`; test proves byte-stability).
//! 2. Subcarrier/antenna masks are explicit (`valid_mask`).
//! 3. Phase declares its state: raw, sanitized, calibrated, or unavailable.
//! 4. TX/RX geometry uses one building coordinate frame (`Pose3`).
//! 5. Results retain source frame ids + model version (via `receipt_id`).
//! 6. Synthetic and measured frames can never share a provenance class,
//! and a synthetic frame can never claim evidence above L0.
//! 7. Sample age is carried through the whole inference path.
use num_complex::Complex64;
use serde::{Deserialize, Serialize};
use crate::tensor::{LinkGeometry, RfModality, RfTensor};
use crate::{Result, UnifiedError};
/// Current schema version of [`RfFrameV2`].
pub const SCHEMA_VERSION: u16 = 2;
/// A pose in the building coordinate frame (metres, unit quaternion).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Pose3 {
/// Position `[x, y, z]`, metres.
pub position_m: [f64; 3],
/// Orientation quaternion `[w, x, y, z]`.
pub orientation: [f64; 4],
}
/// One antenna element of an array.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AntennaElement {
/// Element position relative to the device pose, metres.
pub position_m: [f64; 3],
/// Element gain, dBi.
pub gain_dbi: f64,
}
/// Declared state of the phase axis — consumers must branch on this
/// instead of guessing whether detrending already happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PhaseState {
/// As captured; CFO/STO artifacts present.
Raw,
/// Linear ramp + constant offset removed (ADR-274 stage 3).
Sanitized,
/// Hardware/baseline calibrated upstream.
Calibrated,
/// Magnitude-only capture (e.g. some vendor RSSI/BF reports).
Unavailable,
}
/// Calibration state carried by every native frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CalibrationState {
/// Phase axis state.
pub phase_state: PhaseState,
/// Whether amplitude gain has been calibrated.
pub gain_calibrated: bool,
/// Oscillator drift, ppm.
pub clock_ppm: f64,
/// Empty-room baseline applied, if any (ADR-135).
pub baseline_id: Option<String>,
/// Calibration confidence in `[0, 1]`.
pub confidence: f64,
}
/// Front-end quality indicators.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SignalQuality {
/// Received signal strength, dBm.
pub rssi_dbm: f64,
/// Noise floor, dBm.
pub noise_floor_dbm: f64,
/// Fraction of expected packets lost in the capture window `[0, 1]`.
pub packet_loss: f64,
/// Interference score `[0, 1]` (0 = clean).
pub interference: f64,
}
/// Whether the evidence is measured or synthetic. The two classes can
/// never alias: there is no third variant and no default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvenanceClass {
/// Captured from real hardware.
Measured,
/// Produced by a simulator/generator (ADR-276).
Synthetic,
}
/// The public evidence ladder (ADR-282 §4): every capability and every
/// dataset carries exactly one level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum EvidenceLevel {
/// Level 0 — simulation only.
L0Simulation,
/// Level 1 — captured replay of real signals.
L1CapturedReplay,
/// Level 2 — controlled laboratory.
L2Lab,
/// Level 3 — held-out room and subject validation.
L3HeldOutValidation,
/// Level 4 — multi-site field pilot.
L4MultisiteField,
/// Level 5 — production operational evidence.
L5Production,
}
/// Frame provenance: class + evidence level + source identity.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrameProvenance {
/// Measured vs synthetic (invariant 6).
pub class: ProvenanceClass,
/// Evidence-ladder level.
pub evidence: EvidenceLevel,
/// Capturing device identifier.
pub device_id: String,
/// Firmware version string.
pub firmware: String,
/// Receipt id linking results back to this frame (invariant 5).
pub receipt_id: u128,
}
/// Native axes a frame's tensor may be laid out over (delay-Doppler-native
/// modalities such as OTFS ISAC must not be collapsed into scalar motion
/// energy before storage — ADR-281 §3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FieldAxis {
/// Sample time.
Time,
/// Subcarrier / frequency.
Frequency,
/// Delay (multipath arrival).
Delay,
/// Doppler shift.
Doppler,
/// Radar range bin.
Range,
/// Azimuth angle.
Azimuth,
/// Elevation angle.
Elevation,
/// Antenna element.
Antenna,
/// Polarization.
Polarization,
}
/// The authoritative native RF frame (ADR-279 §2).
#[derive(Debug, Clone)]
pub struct RfFrameV2 {
/// Schema version ([`SCHEMA_VERSION`]).
pub schema_version: u16,
/// Unique frame id.
pub frame_id: u128,
/// Capture timestamp, ns since epoch.
pub timestamp_ns: u64,
/// Modality.
pub modality: RfModality,
/// Native axis semantics, one entry per dimension of `native_shape`.
pub axes: Vec<FieldAxis>,
/// Carrier centre frequency, Hz.
pub centre_frequency_hz: f64,
/// Occupied bandwidth, Hz.
pub bandwidth_hz: f64,
/// Native sample rate along the time-like axis, Hz.
pub sample_rate_hz: f64,
/// Native tensor shape (arbitrary rank), row-major over `native_iq`.
pub native_shape: Vec<usize>,
/// Native complex samples — **never overwritten** (invariant 1).
pub native_iq: Vec<Complex64>,
/// Per-sample validity mask (invariant 2), same length as `native_iq`.
pub valid_mask: Vec<bool>,
/// Transmitter pose in the building frame, when known.
pub transmitter_pose: Option<Pose3>,
/// Receiver pose in the building frame, when known.
pub receiver_pose: Option<Pose3>,
/// Antenna elements of the capturing array.
pub antenna_geometry: Vec<AntennaElement>,
/// Age of the capture at hand-off, ns (invariant 7).
pub sample_age_ns: u64,
/// Calibration state (invariant 3).
pub calibration: CalibrationState,
/// Signal quality.
pub quality: SignalQuality,
/// Provenance (invariants 56).
pub provenance: FrameProvenance,
}
impl RfFrameV2 {
/// Validated constructor — the only way to build a native frame.
///
/// Enforced here: shape/product/mask arity, finite samples on valid
/// positions, positive frequencies, axes rank match, and the
/// provenance-class ⇄ evidence-level consistency rule:
/// `Synthetic ⇒ exactly L0Simulation`, `Measured ⇒ at least
/// L1CapturedReplay` — so synthetic evidence can never masquerade as
/// field evidence, and vice versa (invariant 6).
#[allow(clippy::too_many_arguments)]
pub fn new(
frame_id: u128,
timestamp_ns: u64,
modality: RfModality,
axes: Vec<FieldAxis>,
centre_frequency_hz: f64,
bandwidth_hz: f64,
sample_rate_hz: f64,
native_shape: Vec<usize>,
native_iq: Vec<Complex64>,
valid_mask: Vec<bool>,
transmitter_pose: Option<Pose3>,
receiver_pose: Option<Pose3>,
antenna_geometry: Vec<AntennaElement>,
sample_age_ns: u64,
calibration: CalibrationState,
quality: SignalQuality,
provenance: FrameProvenance,
) -> Result<Self> {
let expected: usize = native_shape.iter().product();
if native_shape.is_empty() || expected == 0 {
return Err(UnifiedError::ShapeMismatch("empty native shape".into()));
}
if native_iq.len() != expected {
return Err(UnifiedError::ShapeMismatch(format!(
"native_iq has {} samples, shape {:?} implies {expected}",
native_iq.len(),
native_shape
)));
}
if valid_mask.len() != expected {
return Err(UnifiedError::ShapeMismatch(format!(
"valid_mask has {} entries, expected {expected}",
valid_mask.len()
)));
}
if axes.len() != native_shape.len() {
return Err(UnifiedError::ShapeMismatch(format!(
"{} axes declared for rank-{} tensor",
axes.len(),
native_shape.len()
)));
}
if !(centre_frequency_hz.is_finite()
&& centre_frequency_hz > 0.0
&& bandwidth_hz.is_finite()
&& bandwidth_hz > 0.0
&& sample_rate_hz.is_finite()
&& sample_rate_hz > 0.0)
{
return Err(UnifiedError::InvalidInput(
"frequencies and sample rate must be finite and positive".into(),
));
}
for (z, ok) in native_iq.iter().zip(&valid_mask) {
if *ok && (!z.re.is_finite() || !z.im.is_finite()) {
return Err(UnifiedError::InvalidInput(
"non-finite sample marked valid".into(),
));
}
}
if !(0.0..=1.0).contains(&calibration.confidence) {
return Err(UnifiedError::InvalidInput(
"calibration confidence must be in [0,1]".into(),
));
}
match (provenance.class, provenance.evidence) {
(ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation) => {}
(ProvenanceClass::Synthetic, level) => {
return Err(UnifiedError::InvalidInput(format!(
"synthetic frames are L0Simulation by definition, got {level:?}"
)));
}
(ProvenanceClass::Measured, EvidenceLevel::L0Simulation) => {
return Err(UnifiedError::InvalidInput(
"measured frames cannot claim L0Simulation".into(),
));
}
(ProvenanceClass::Measured, _) => {}
}
Ok(Self {
schema_version: SCHEMA_VERSION,
frame_id,
timestamp_ns,
modality,
axes,
centre_frequency_hz,
bandwidth_hz,
sample_rate_hz,
native_shape,
native_iq,
valid_mask,
transmitter_pose,
receiver_pose,
antenna_geometry,
sample_age_ns,
calibration,
quality,
provenance,
})
}
/// Fraction of valid samples.
#[must_use]
pub fn valid_fraction(&self) -> f64 {
self.valid_mask.iter().filter(|v| **v).count() as f64 / self.valid_mask.len() as f64
}
/// Derived compatibility view (ADR-279 §3): projects a rank-3
/// `(links, bins, snapshots)` native frame into the ADR-274 canonical
/// tensor. Invalid samples are filled by linear interpolation from the
/// nearest valid bins on the same `(link, snapshot)` column before
/// resampling. The native frame is untouched (`&self`).
pub fn to_canonical(&self, links: Vec<LinkGeometry>) -> Result<RfTensor> {
if self.native_shape.len() != 3 {
return Err(UnifiedError::ShapeMismatch(format!(
"canonical view needs a rank-3 (links, bins, snapshots) frame, got rank {}",
self.native_shape.len()
)));
}
let (n_links, n_bins, n_snaps) =
(self.native_shape[0], self.native_shape[1], self.native_shape[2]);
if links.len() != n_links {
return Err(UnifiedError::ShapeMismatch(format!(
"geometry for {} links, frame has {n_links}",
links.len()
)));
}
// Gap-fill invalid bins per (link, snapshot) column, then hand a
// dense grid to the shared normalization used by every adapter.
let mut grid = ndarray::Array3::zeros((n_links, n_bins, n_snaps));
for l in 0..n_links {
for s in 0..n_snaps {
let at = |b: usize| l * n_bins * n_snaps + b * n_snaps + s;
let valid: Vec<usize> = (0..n_bins).filter(|b| self.valid_mask[at(*b)]).collect();
if valid.is_empty() {
return Err(UnifiedError::InvalidInput(format!(
"link {l} snapshot {s} has no valid bins"
)));
}
for b in 0..n_bins {
let v = if self.valid_mask[at(b)] {
self.native_iq[at(b)]
} else {
// Nearest valid neighbors, linear on the complex plane.
let before = valid.iter().rev().find(|x| **x < b);
let after = valid.iter().find(|x| **x > b);
match (before, after) {
(Some(&lo), Some(&hi)) => {
let t = (b - lo) as f64 / (hi - lo) as f64;
self.native_iq[at(lo)] * (1.0 - t) + self.native_iq[at(hi)] * t
}
(Some(&lo), None) => self.native_iq[at(lo)],
(None, Some(&hi)) => self.native_iq[at(hi)],
(None, None) => unreachable!("valid is non-empty"),
}
};
grid[[l, b, s]] = v;
}
}
}
let uncertainty = {
let snr = self.quality.rssi_dbm - self.quality.noise_floor_dbm;
(1.0 - snr / 40.0).clamp(0.0, 1.0)
};
crate::adapters::normalize_grid(
self.modality,
grid,
links,
self.centre_frequency_hz,
self.bandwidth_hz,
self.sample_age_ns as f64 / 1e9,
self.timestamp_ns,
self.provenance.device_id.clone(),
(1.0 - self.calibration.clock_ppm / 40.0).clamp(0.0, 1.0),
uncertainty,
matches!(self.calibration.phase_state, PhaseState::Sanitized | PhaseState::Calibrated),
)
}
}
/// IEEE P3162-profile synthetic-aperture channel-sounding import
/// (ADR-281 §5): the calibration bridge between measured environments,
/// simulators, and learned RF scene models.
#[derive(Debug, Clone)]
pub struct SyntheticApertureSoundingDataset {
/// Sounded frequency range `[low, high]`, Hz.
pub frequency_range_hz: [f64; 2],
/// Aperture element poses (building frame).
pub aperture_geometry: Vec<Pose3>,
/// Directional power-delay profile, `(direction, delay)` row-major.
pub directional_pdp: Vec<f64>,
/// PDP shape.
pub pdp_shape: [usize; 2],
/// Coordinate system identifier (P3162 vocabulary).
pub coordinate_system: String,
/// Hash of the processing manifest that produced the dataset.
pub processing_manifest_hash: u64,
}
impl SyntheticApertureSoundingDataset {
/// Validated constructor.
pub fn new(
frequency_range_hz: [f64; 2],
aperture_geometry: Vec<Pose3>,
directional_pdp: Vec<f64>,
pdp_shape: [usize; 2],
coordinate_system: impl Into<String>,
processing_manifest_hash: u64,
) -> Result<Self> {
if !(frequency_range_hz[0] > 0.0 && frequency_range_hz[1] > frequency_range_hz[0]) {
return Err(UnifiedError::InvalidInput(format!(
"frequency range must be ordered and positive, got {frequency_range_hz:?}"
)));
}
if aperture_geometry.is_empty() {
return Err(UnifiedError::InvalidInput("empty aperture geometry".into()));
}
if directional_pdp.len() != pdp_shape[0] * pdp_shape[1] {
return Err(UnifiedError::ShapeMismatch(format!(
"PDP has {} entries, shape {pdp_shape:?} implies {}",
directional_pdp.len(),
pdp_shape[0] * pdp_shape[1]
)));
}
if directional_pdp.iter().any(|v| !v.is_finite() || *v < 0.0) {
return Err(UnifiedError::InvalidInput("PDP entries must be finite power".into()));
}
Ok(Self {
frequency_range_hz,
aperture_geometry,
directional_pdp,
pdp_shape,
coordinate_system: coordinate_system.into(),
processing_manifest_hash,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tensor::{CANONICAL_BINS, CANONICAL_SNAPSHOTS};
fn quality() -> SignalQuality {
SignalQuality { rssi_dbm: -45.0, noise_floor_dbm: -92.0, packet_loss: 0.02, interference: 0.05 }
}
fn calibration(phase: PhaseState) -> CalibrationState {
CalibrationState {
phase_state: phase,
gain_calibrated: false,
clock_ppm: 12.0,
baseline_id: None,
confidence: 0.8,
}
}
fn provenance(class: ProvenanceClass, evidence: EvidenceLevel) -> FrameProvenance {
FrameProvenance {
class,
evidence,
device_id: "esp32s3-a1".into(),
firmware: "fw-2.1".into(),
receipt_id: 42,
}
}
fn frame(shape: Vec<usize>, mask_off: &[usize]) -> RfFrameV2 {
let n: usize = shape.iter().product();
let iq: Vec<Complex64> = (0..n)
.map(|i| Complex64::new(1.0 + 0.01 * (i % 13) as f64, 0.002 * (i % 7) as f64))
.collect();
let mut mask = vec![true; n];
for &i in mask_off {
mask[i] = false;
}
RfFrameV2::new(
7,
1_000,
RfModality::WifiCsi,
vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time],
2.437e9,
20e6,
100.0,
shape,
iq,
mask,
Some(Pose3 { position_m: [0.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }),
Some(Pose3 { position_m: [4.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }),
vec![AntennaElement { position_m: [0.0; 3], gain_dbi: 2.0 }],
5_000_000,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
)
.expect("valid frame")
}
#[test]
fn synthetic_and_measured_provenance_can_never_alias() {
let build = |class, evidence| {
RfFrameV2::new(
1,
0,
RfModality::Synthetic,
vec![FieldAxis::Frequency],
2.4e9,
20e6,
100.0,
vec![4],
vec![Complex64::new(1.0, 0.0); 4],
vec![true; 4],
None,
None,
vec![],
0,
calibration(PhaseState::Sanitized),
quality(),
provenance(class, evidence),
)
};
// Synthetic above L0 is refused.
assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L3HeldOutValidation).is_err());
// Measured claiming L0 is refused.
assert!(build(ProvenanceClass::Measured, EvidenceLevel::L0Simulation).is_err());
// The two legal pairings work.
assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation).is_ok());
assert!(build(ProvenanceClass::Measured, EvidenceLevel::L1CapturedReplay).is_ok());
}
#[test]
fn constructor_enforces_shape_mask_and_axes_arity() {
let n = 2 * 10 * 4;
let iq = vec![Complex64::new(1.0, 0.0); n];
let bad_mask = RfFrameV2::new(
1,
0,
RfModality::WifiCsi,
vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time],
2.4e9,
20e6,
100.0,
vec![2, 10, 4],
iq.clone(),
vec![true; n - 1],
None,
None,
vec![],
0,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
);
assert!(matches!(bad_mask, Err(UnifiedError::ShapeMismatch(_))));
let bad_axes = RfFrameV2::new(
1,
0,
RfModality::WifiCsi,
vec![FieldAxis::Frequency],
2.4e9,
20e6,
100.0,
vec![2, 10, 4],
iq,
vec![true; n],
None,
None,
vec![],
0,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
);
assert!(matches!(bad_axes, Err(UnifiedError::ShapeMismatch(_))));
}
#[test]
fn canonical_view_is_derived_and_native_is_untouched() {
// 114-subcarrier native with two masked-out bins.
let f = frame(vec![1, 114, 12], &[5 * 12, 60 * 12 + 3]);
let native_before = f.native_iq.clone();
let mask_before = f.valid_mask.clone();
let t = f
.to_canonical(vec![LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] }])
.expect("derived view");
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
assert!(t.data.iter().all(|z| z.re.is_finite() && z.im.is_finite()));
// Invariant 1: the native samples and mask are byte-identical after
// deriving the view — normalization never writes back.
assert_eq!(f.native_iq, native_before);
assert_eq!(f.valid_mask, mask_before);
assert!((f.valid_fraction() - (114.0 * 12.0 - 2.0) / (114.0 * 12.0)).abs() < 1e-12);
}
#[test]
fn canonical_view_rejects_wrong_rank_or_geometry() {
let f = frame(vec![2, 10, 4], &[]);
assert!(f.to_canonical(vec![]).is_err());
// Rank-1 frame has no canonical projection.
let flat = RfFrameV2::new(
9,
0,
RfModality::WifiCsi,
vec![FieldAxis::Frequency],
2.4e9,
20e6,
100.0,
vec![80],
vec![Complex64::new(1.0, 0.0); 80],
vec![true; 80],
None,
None,
vec![],
0,
calibration(PhaseState::Raw),
quality(),
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
)
.expect("rank-1 frame is a valid native frame");
assert!(flat
.to_canonical(vec![LinkGeometry { tx_pos: [0.0; 3], rx_pos: [1.0, 0.0, 0.0] }])
.is_err());
}
#[test]
fn synthetic_aperture_profile_validates() {
let ok = SyntheticApertureSoundingDataset::new(
[3.0e9, 10.0e9],
vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }],
vec![0.5; 8 * 16],
[8, 16],
"P3162-spherical",
0xABCD,
);
assert!(ok.is_ok());
assert!(SyntheticApertureSoundingDataset::new(
[10.0e9, 3.0e9], // unordered
vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }],
vec![0.5; 4],
[2, 2],
"x",
0
)
.is_err());
}
}
@@ -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(),
})
}
+347 -1
View File
@@ -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();
+7
View File
@@ -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;
+4 -2
View File
@@ -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),
},
});
}
+63 -1
View File
@@ -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 rangeazimuth map.
FmcwRangeAzimuth,
/// FMCW radar Dopplerazimuth 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
}
/// DelayDoppler magnitude map for one link (ADR-281 §3): IDFT over the
/// frequency axis (→ delay bins) crossed with a DFT over the snapshot
/// axis (→ Doppler bins). Shape `(bins, snapshots)`.
///
/// Delay-Doppler-native modalities (OTFS ISAC, radar) must not be
/// collapsed into scalar motion energy before storage — this transform
/// keeps the native representation queryable locally.
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!(