Files
ruvnet--RuView/v2/crates/ruview-unified/src/tokenizer.rs
T
rUv 2e018f4f19 feat(ruview-unified): Unified RF spatial world model — ADR-273..282 (#1437)
Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program.

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

Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
2026-07-26 14:37:56 -07:00

323 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! RF tokenizer — canonical tensor in, encoder-ready tokens out
//! (ADR-274 §3.1).
//!
//! One token per `(link, subcarrier-group)`; each token carries amplitude,
//! delay-spectrum, Doppler-spectrum, phase-dynamics, freshness, geometry,
//! clock-quality, and uncertainty features. The exact 24-dimensional layout
//! is documented on [`RfToken`]; the encoder treats it as an opaque vector,
//! so new feature dims only require bumping [`D_IN`].
use num_complex::Complex64;
use crate::math::DftPlan;
use crate::tensor::{RfTensor, CANONICAL_SNAPSHOTS};
/// Subcarrier-group width: 56 bins / 8 = 7 tokens per link.
pub const GROUP_BINS: usize = 8;
/// Token feature dimension.
pub const D_IN: usize = 24;
/// Sinusoidal position-encoding dimension (used only by masked
/// reconstruction so the decoder knows *which* token it is predicting).
pub const D_POS: usize = 16;
/// One tokenized `(link, group)` cell.
///
/// All amplitude-derived features are computed on window-median-normalized,
/// CFO-aligned samples (see [`RfTokenizer::tokenize`]), so they are
/// invariant to front-end gain and common phase drift.
///
/// Feature layout (all values finite, roughly unit-scale):
///
/// | idx | feature |
/// |-----|---------|
/// | 07 | `ln(1+amp)` per bin, averaged over snapshots |
/// | 811 | delay-domain DFT magnitudes (bins 03) of the snapshot-mean group |
/// | 1215 | `ln(1+100·mag)` Doppler DFT magnitudes (bins 14, DC skipped) across snapshots |
/// | 16 | `ln(1+20·std)` temporal amplitude deviation (motion energy) |
/// | 17 | mean inter-snapshot phase velocity (rad/snapshot) |
/// | 18 | sample age (seconds, clipped to 10) |
/// | 19 | link distance / 10 m |
/// | 20 | link midpoint height / 3 m |
/// | 21 | link azimuth / π |
/// | 22 | clock quality |
/// | 23 | uncertainty |
#[derive(Debug, Clone)]
pub struct RfToken {
/// Feature vector, layout above.
pub features: [f64; D_IN],
/// Link index within the source tensor.
pub link: usize,
/// Subcarrier-group index within the link.
pub group: usize,
}
/// A tokenized tensor window plus the window-level context the encoder's
/// age/geometry paths consume.
#[derive(Debug, Clone)]
pub struct TokenizedWindow {
/// Tokens, link-major then group order.
pub tokens: Vec<RfToken>,
/// Window age in seconds (drives the multiplicative freshness gate).
pub age_s: f64,
/// Window-level geometry summary: mean TX xyz then mean RX xyz, in
/// decametres (÷10) to keep unit scale.
pub geometry: [f64; 6],
}
/// Tokenizer with precomputed DFT plans (delay + Doppler transforms are the
/// hot path; see `benches/unified_bench.rs` for the measured speedup over
/// planless DFTs).
pub struct RfTokenizer {
delay_plan: DftPlan,
doppler_plan: DftPlan,
}
impl Default for RfTokenizer {
fn default() -> Self {
Self::new()
}
}
impl RfTokenizer {
/// Builds the tokenizer (allocates the two DFT twiddle tables once).
#[must_use]
pub fn new() -> Self {
Self {
delay_plan: DftPlan::new(GROUP_BINS, 4),
doppler_plan: DftPlan::new(CANONICAL_SNAPSHOTS, 5),
}
}
/// Tokenizes a canonical tensor. Panics never: the tensor's validated
/// invariants (canonical dims after adapter normalization) are assumed;
/// non-canonical bin counts simply produce fewer/more groups.
///
/// Two hardware-invariance steps happen before feature extraction
/// (ADR-274 §3.1 — without them, chipset gain and CFO drift dominate
/// every downstream feature):
///
/// 1. **Scale**: all samples are divided by the window's median
/// amplitude, so front-end gain and absolute path loss cancel.
/// 2. **CFO alignment**: per link, each snapshot is de-rotated by the
/// common phase between it and snapshot 0
/// (`arg Σ_b H[b,s]·H̄[b,0]`) — carrier-frequency-offset drift is a
/// *common* rotation and cancels, while a moving scatterer's
/// frequency-selective perturbation survives.
#[must_use]
pub fn tokenize(&self, tensor: &RfTensor) -> TokenizedWindow {
let (n_links, n_bins, n_snaps) = tensor.dims();
let n_groups = n_bins / GROUP_BINS;
let mut tokens = Vec::with_capacity(n_links * n_groups);
// Window-level robust amplitude scale.
let amps: Vec<f64> = tensor.data.iter().map(|z| z.norm()).collect();
let scale = crate::math::median(&amps).max(1e-12);
// Per-(link, snapshot) CFO-alignment rotations against snapshot 0.
let mut align = vec![vec![Complex64::new(1.0, 0.0); n_snaps]; n_links];
for l in 0..n_links {
for s in 1..n_snaps {
let mut acc = Complex64::new(0.0, 0.0);
for b in 0..n_bins {
acc += tensor.data[[l, b, s]] * tensor.data[[l, b, 0]].conj();
}
if acc.norm() > 1e-18 {
align[l][s] = (acc / acc.norm()).conj();
}
}
}
let sample = |l: usize, b: usize, s: usize| tensor.data[[l, b, s]] * align[l][s] / scale;
for l in 0..n_links {
let geo = &tensor.links[l];
let dx = geo.rx_pos[0] - geo.tx_pos[0];
let dy = geo.rx_pos[1] - geo.tx_pos[1];
let dist = geo.distance_m().max(1e-6);
let mid_z = (geo.tx_pos[2] + geo.rx_pos[2]) / 2.0;
let azimuth = dy.atan2(dx);
for g in 0..n_groups {
let b0 = g * GROUP_BINS;
let mut f = [0.0f64; D_IN];
// Snapshot-mean complex value per bin (delay features) and
// group-mean complex value per snapshot (Doppler features).
let mut bin_means = [Complex64::new(0.0, 0.0); GROUP_BINS];
let mut snap_means = vec![Complex64::new(0.0, 0.0); n_snaps];
let mut amp_sum = [0.0f64; GROUP_BINS];
let mut amp_all = Vec::with_capacity(GROUP_BINS * n_snaps);
for (bi, bin) in (b0..b0 + GROUP_BINS).enumerate() {
for (s, sm) in snap_means.iter_mut().enumerate() {
let z = sample(l, bin, s);
bin_means[bi] += z;
*sm += z;
amp_sum[bi] += z.norm();
amp_all.push(z.norm());
}
}
for b in &mut bin_means {
*b /= n_snaps as f64;
}
for s in &mut snap_means {
*s /= GROUP_BINS as f64;
}
// 07: log-amplitudes.
for bi in 0..GROUP_BINS {
f[bi] = (1.0 + amp_sum[bi] / n_snaps as f64).ln();
}
// 811: delay spectrum of the group.
for (k, m) in self.delay_plan.magnitudes(&bin_means).iter().enumerate() {
f[8 + k] = *m;
}
// 1215: Doppler spectrum across snapshots (skip DC bin 0),
// log-compressed to O(1): motion magnitudes live at 1e-2 of
// the static field, and leaving them 20× smaller than the
// amplitude dims stalls every downstream linear adapter
// (feature design, not adapter parameters).
let dop_scale = |m: f64| (1.0 + 100.0 * m).ln();
if n_snaps == CANONICAL_SNAPSHOTS {
let dop = self.doppler_plan.magnitudes(&snap_means);
for k in 0..4 {
f[12 + k] = dop_scale(dop[1 + k]);
}
} else {
for (k, m) in
crate::math::dft_magnitudes(&snap_means, 5).iter().skip(1).enumerate()
{
f[12 + k] = dop_scale(*m);
}
}
// 16: temporal amplitude std (motion energy), same treatment.
let mean_amp = amp_all.iter().sum::<f64>() / amp_all.len() as f64;
let var = amp_all.iter().map(|a| (a - mean_amp).powi(2)).sum::<f64>()
/ amp_all.len() as f64;
f[16] = (1.0 + 20.0 * var.sqrt()).ln();
// 17: mean inter-snapshot phase velocity of the group mean.
let mut dphi = 0.0;
for s in 1..n_snaps {
dphi += (snap_means[s] * snap_means[s - 1].conj()).arg();
}
f[17] = dphi / (n_snaps.max(2) - 1) as f64;
// 1823: freshness, geometry, clock, uncertainty.
f[18] = tensor.sample_age_s.min(10.0);
f[19] = dist / 10.0;
f[20] = mid_z / 3.0;
f[21] = azimuth / std::f64::consts::PI;
f[22] = tensor.clock_quality;
f[23] = tensor.uncertainty;
tokens.push(RfToken { features: f, link: l, group: g });
}
}
let mut geometry = [0.0f64; 6];
for geo in &tensor.links {
for i in 0..3 {
geometry[i] += geo.tx_pos[i];
geometry[3 + i] += geo.rx_pos[i];
}
}
for v in &mut geometry {
*v /= 10.0 * n_links as f64;
}
TokenizedWindow { tokens, age_s: tensor.sample_age_s, geometry }
}
}
/// Fixed sinusoidal position encoding for token index `idx` (masked
/// reconstruction target addressing; not a learned parameter).
#[must_use]
pub fn position_encoding(idx: usize) -> [f64; D_POS] {
let mut p = [0.0f64; D_POS];
for k in 0..D_POS / 2 {
let freq = 1.0 / 10_000f64.powf(2.0 * k as f64 / D_POS as f64);
p[2 * k] = (idx as f64 * freq).sin();
p[2 * k + 1] = (idx as f64 * freq).cos();
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tensor::{CalibrationMeta, LinkGeometry, RfModality, CANONICAL_BINS};
use ndarray::Array3;
fn tensor_with(motion: bool) -> RfTensor {
let data = Array3::from_shape_fn((2, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(l, b, s)| {
let base = 1.0 + 0.1 * (b as f64 / 10.0).sin() + 0.05 * l as f64;
let wobble = if motion {
// Snapshot-varying, frequency-selective perturbation — a
// moving scatterer (bin-dependent so CFO alignment, which
// only removes *common* rotations, must not cancel it).
0.3 * (2.0 * std::f64::consts::PI * 2.0 * s as f64 / 8.0).sin()
* (1.0 + b as f64 / 56.0)
} else {
0.0
};
Complex64::new(0.0, wobble).exp() * (base + wobble.abs())
});
RfTensor::new(
RfModality::WifiCsi,
2.437e9,
20e6,
data,
vec![
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] },
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.3, 2.0] },
],
0.05,
0,
"tok-test".into(),
0.8,
0.1,
CalibrationMeta::default(),
)
.expect("valid tensor")
}
#[test]
fn produces_expected_token_grid() {
let w = RfTokenizer::new().tokenize(&tensor_with(false));
assert_eq!(w.tokens.len(), 2 * (CANONICAL_BINS / GROUP_BINS));
for t in &w.tokens {
assert!(t.features.iter().all(|v| v.is_finite()), "non-finite feature");
}
// Context passthrough.
assert!((w.age_s - 0.05).abs() < 1e-12);
assert!(w.tokens[0].features[22] > 0.79 && w.tokens[0].features[22] < 0.81);
}
#[test]
fn motion_raises_doppler_and_variance_features() {
let tok = RfTokenizer::new();
let still = tok.tokenize(&tensor_with(false));
let moving = tok.tokenize(&tensor_with(true));
let dop = |w: &TokenizedWindow| {
w.tokens.iter().map(|t| t.features[12..16].iter().sum::<f64>()).sum::<f64>()
};
let var = |w: &TokenizedWindow| w.tokens.iter().map(|t| t.features[16]).sum::<f64>();
assert!(
dop(&moving) > 10.0 * dop(&still) + 1e-9,
"Doppler features must respond to motion: moving={} still={}",
dop(&moving),
dop(&still)
);
assert!(var(&moving) > var(&still));
}
#[test]
fn position_encoding_is_unique_and_bounded() {
let a = position_encoding(0);
let b = position_encoding(7);
assert_ne!(a, b);
for v in a.iter().chain(b.iter()) {
assert!(v.abs() <= 1.0);
}
}
}