mirror of
https://github.com/ruvnet/RuView.git
synced 2026-09-01 21:15:56 +00:00
feat(nlos): add consumer transient sensing pipeline
This commit is contained in:
20
v2/Cargo.lock
generated
20
v2/Cargo.lock
generated
@@ -9476,6 +9476,26 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-nlos"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
"getrandom 0.2.17",
|
||||
"http-body-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-offaxis"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -123,6 +123,9 @@ members = [
|
||||
# ADR-324 — clean-room Kooima off-axis (head-coupled perspective) projection.
|
||||
# Dependency-free native core; wasm-bindgen surface only on wasm32.
|
||||
"crates/ruview-offaxis",
|
||||
# ADR-328..331 — consumer ToF transient NLOS capture, motion-induced
|
||||
# aperture tracking, governed CSI fusion, and native/web client contract.
|
||||
"crates/ruview-nlos",
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
||||
48
v2/crates/ruview-nlos/Cargo.toml
Normal file
48
v2/crates/ruview-nlos/Cargo.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "ruview-nlos"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Governed consumer ToF transient NLOS tracking and CSI fusion for RuView"
|
||||
|
||||
[features]
|
||||
default = ["server"]
|
||||
server = [
|
||||
"dep:axum",
|
||||
"dep:clap",
|
||||
"dep:getrandom",
|
||||
"dep:tokio",
|
||||
"dep:tracing",
|
||||
"dep:tracing-subscriber",
|
||||
"dep:tower-http",
|
||||
]
|
||||
hardware = ["dep:serialport"]
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
axum = { workspace = true, optional = true }
|
||||
clap = { workspace = true, optional = true }
|
||||
getrandom = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
tracing-subscriber = { workspace = true, optional = true }
|
||||
tower-http = { workspace = true, optional = true, features = ["limit"] }
|
||||
serialport = { version = "4.3", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
|
||||
[[bin]]
|
||||
name = "ruview-nlos"
|
||||
path = "src/bin/ruview-nlos.rs"
|
||||
required-features = ["server"]
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
76
v2/crates/ruview-nlos/README.md
Normal file
76
v2/crates/ruview-nlos/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# RuView Consumer NLOS
|
||||
|
||||
`ruview-nlos` is the RuView Labs G0 optical-transient scaffold for consumer time-of-flight sensors. It preserves zone-level photon timing histograms, consumes externally estimated sensor pose, evaluates an approximate canonical rigid-object likelihood with a particle filter, applies a CSI prior only in synthetic regression, and emits one bounded hidden-target posterior for RuVector, RuField, WorldGraph, Swift, and browser consumers.
|
||||
|
||||
This is not an ARKit depth map adapter. A depth map has already discarded the delayed multipath timing signal required for around the corner inversion. `TransientFrame::validate` rejects `depth_only` as live NLOS evidence.
|
||||
|
||||
## Reproduction boundary
|
||||
|
||||
The first physical research path is the public MIT consumer NLOS implementation at commit `15314de422a765a2d1b72ea7037dfafb2f908d7c`, used with the ST P NUCLEO 53L8A1 kit and VL53L8CH histogram output. RuView independently implements the documented STM32 row framing and 13-value configuration packet. The Rust preprocessing/scorer is a bounded synthetic architecture approximation, not numerical equivalence to the upstream 128-bin O'Toole resampling and calibration pipeline. Physical reproduction must run the pinned upstream path and the preregistered witness protocol.
|
||||
|
||||
The software path has four evidence classes:
|
||||
|
||||
| Input | Output ceiling | Meaning |
|
||||
|---|---:|---|
|
||||
| Deterministic generator | `l0_synthetic` | Software and performance regression only |
|
||||
| Raw live histogram | `l1_measured` | Sensor bytes received, calibration not yet witnessed |
|
||||
| Raw histogram plus bound empty room calibration | `l2_calibrated` | Measured optical posterior with calibration digest |
|
||||
|
||||
The v1 wire contract deliberately rejects `l3_corroborated`: it cannot retain both modality lineages. Measured CSI fusion is unavailable in v1; only a scope-bound synthetic L0 prior is accepted for architecture tests. A future contract must carry authenticated optical/RF lineage and coordinate bindings before measured fusion can be enabled.
|
||||
|
||||
Only the physical protocol in `docs/research/consumer-nlos-acceptance-protocol.md` can establish the hardware reproduction and fusion acceptance gates.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. `StAsciiDecoder` reads explicit USB serial rows and keeps all 8 to 128 timing bins for at most 64 zones.
|
||||
2. `Calibration` averages an empty room, finds each direct wall peak, binds the result with SHA 256, subtracts background, masks the direct return, and maps time to uniform squared distance bins.
|
||||
3. `MotionApertureTracker` retains up to 32 pose-tagged frames, estimates a bounded translational velocity in metres per second from monotonic frame time, back-warps moving hypotheses across the aperture, and evaluates 64 to 20,000 particles against a bounded canonical point cloud.
|
||||
4. `CsiSpatialPrior` contributes a coarse Gaussian prior only when calibrated, finite, and fresh. Stale priors fail closed.
|
||||
5. `TemporalFeatureMemory`, `RuFieldObservation`, and `WorldGraphUpdate` remove raw histograms and preserve confidence, evidence, calibration, and expiry.
|
||||
6. `NlosHub` publishes an authenticated read-only HTTP/WebSocket surface. Production TLS is terminated by the required trusted reverse proxy. Native clients use a bearer header. Browsers exchange the bearer token for a 30 second, single-use, origin-bound ticket.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
|
||||
# Pure core, including deterministic acceptance tests
|
||||
cargo test -p ruview-nlos --no-default-features
|
||||
|
||||
# Authenticated server and WebSocket tests
|
||||
cargo test -p ruview-nlos
|
||||
|
||||
# Direct ST serial adapter compile and tests
|
||||
cargo test -p ruview-nlos --all-features
|
||||
|
||||
# L0 architecture benchmark. This never sets the hardware gate to true.
|
||||
cargo run -p ruview-nlos --release -- benchmark --frames 300 --particles 1000
|
||||
|
||||
# Track a bounded transient JSONL recording. Its first 60 frames must be empty room.
|
||||
cargo run -p ruview-nlos -- track-jsonl capture.jsonl --background-frames 60
|
||||
|
||||
# Read the public STM32 firmware at 2,250,000 baud and emit raw transient JSONL.
|
||||
cargo run -p ruview-nlos --features hardware -- capture-st \
|
||||
--port /dev/ttyACM0 --session lab-run-001 --frames 300 \
|
||||
--sensor-id st-kit-001 --sensor-model VL53L8CH \
|
||||
--firmware-version 15314de --pose-jsonl synchronized-poses.jsonl
|
||||
|
||||
# Run a loopback synthetic server for UI validation.
|
||||
RUVIEW_NLOS_TOKEN="$(openssl rand -hex 32)" \
|
||||
cargo run -p ruview-nlos -- serve --synthetic \
|
||||
--allowed-origin http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
Non loopback bind requires `--behind-tls-proxy`; the flag is an operator assertion, not a TLS implementation. The proxy must terminate trusted TLS, strip untrusted forwarded headers, and apply network policy. Browser CORS is disabled unless one exact `--allowed-origin` is supplied; wildcard origins and cleartext non-loopback origins are rejected. The bearer value is hashed immediately and is never stored or logged.
|
||||
|
||||
## Performance model
|
||||
|
||||
The reference configuration is 16 zones by 48 bins by 1,000 particles. The optimized scorer does not materialize a full predicted histogram per particle. It projects only the three nonzero kernel samples around each canonical return, reducing the point target case from roughly 768,000 to 48,000 predicted sample operations per frame and bounding the aperture at eight frames by default.
|
||||
|
||||
`--fixed-sensor` is available only for bounded capture/transport diagnostics. Target motion does not substitute for the sensor-motion-induced aperture and a fixed-sensor capture cannot satisfy the physical MAS reproduction gate. The current pose JSONL is index-paired G0 offline scaffolding; live promotion needs timestamped pose/capture identity and clock synchronization.
|
||||
|
||||
CI requires at least 30 combined tracker updates per second and at least 25 percent synthetic lost track reduction from the CSI prior. Those are software regression gates. The published MIT result and the RuView field acceptance criteria remain separate hardware claims.
|
||||
|
||||
## Privacy and safety
|
||||
|
||||
Raw histograms stay in the local capture and calibration plane. Public track envelopes contain session local identifiers only and expire in at most five seconds. UNKNOWN tracks are first class. Neither client nor server can actuate a device. The threat model is in `docs/security/consumer-nlos-threat-model.md`.
|
||||
496
v2/crates/ruview-nlos/src/bin/ruview-nlos.rs
Normal file
496
v2/crates/ruview-nlos/src/bin/ruview-nlos.rs
Normal file
@@ -0,0 +1,496 @@
|
||||
//! RuView NLOS research CLI. Hardware evidence requires the external witness
|
||||
//! protocol; this binary never promotes its synthetic benchmark.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use ruview_nlos::calibration::{Calibration, CalibrationConfig};
|
||||
use ruview_nlos::server::NlosHub;
|
||||
use ruview_nlos::{
|
||||
CanonicalObject, FrameSource, MotionApertureTracker, SyntheticScene, TrackEnvelope,
|
||||
TrackerConfig, TransientFrame, TransientKind, Vec3, MAX_WIRE_BYTES,
|
||||
};
|
||||
#[cfg(feature = "hardware")]
|
||||
use ruview_nlos::{EvidenceLevel, Provenance, SensorPose, StAsciiDecoder, StDecoderConfig};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "ruview-nlos",
|
||||
version,
|
||||
about = "Consumer transient NLOS research runtime"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Run the deterministic L0 performance and fusion benchmark.
|
||||
Benchmark {
|
||||
/// Number of frames.
|
||||
#[arg(long, default_value_t = 300)]
|
||||
frames: usize,
|
||||
/// Particle count.
|
||||
#[arg(long, default_value_t = 1_000)]
|
||||
particles: usize,
|
||||
},
|
||||
/// Validate every bounded track JSONL record.
|
||||
ValidateTrack {
|
||||
/// JSONL file.
|
||||
input: PathBuf,
|
||||
},
|
||||
/// Process transient JSONL; the initial frames form empty-room calibration.
|
||||
TrackJsonl {
|
||||
/// Transient JSONL file.
|
||||
input: PathBuf,
|
||||
/// Empty-room frames at the beginning of the file.
|
||||
#[arg(long, default_value_t = 60)]
|
||||
background_frames: usize,
|
||||
},
|
||||
/// Capture the public VL53L8CH STM32 stream directly over USB serial.
|
||||
#[cfg(feature = "hardware")]
|
||||
CaptureSt {
|
||||
/// Explicit serial device path; auto-discovery is intentionally avoided.
|
||||
#[arg(long)]
|
||||
port: String,
|
||||
/// Capture session identifier.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
/// Stable enrolled sensor identifier; never inferred from a port path.
|
||||
#[arg(long)]
|
||||
sensor_id: String,
|
||||
/// Exact sensor model.
|
||||
#[arg(long, default_value = "VL53L8CH")]
|
||||
sensor_model: String,
|
||||
/// Exact flashed firmware revision or digest label.
|
||||
#[arg(long)]
|
||||
firmware_version: String,
|
||||
/// One externally estimated sensor pose per output frame as JSONL.
|
||||
#[arg(long, conflicts_with = "fixed_sensor")]
|
||||
pose_jsonl: Option<PathBuf>,
|
||||
/// Explicitly declare a fixed identity pose. This does not synthesize
|
||||
/// camera motion; aperture diversity must then come from target motion.
|
||||
#[arg(long, conflicts_with = "pose_jsonl")]
|
||||
fixed_sensor: bool,
|
||||
/// Frames to emit as transient JSONL.
|
||||
#[arg(long, default_value_t = 300)]
|
||||
frames: usize,
|
||||
/// Grid height.
|
||||
#[arg(long, default_value_t = 4)]
|
||||
height: u16,
|
||||
/// Grid width.
|
||||
#[arg(long, default_value_t = 4)]
|
||||
width: u16,
|
||||
/// Histogram bins.
|
||||
#[arg(long, default_value_t = 48)]
|
||||
bins: u16,
|
||||
/// Firmware start bin.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
start_bin: u16,
|
||||
/// Requested ranging rate.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
frequency_hz: u16,
|
||||
},
|
||||
/// Serve authenticated native and browser clients.
|
||||
Serve {
|
||||
/// Bind address. Non-loopback requires an explicit reverse-proxy flag.
|
||||
#[arg(long, default_value = "127.0.0.1:8787")]
|
||||
bind: SocketAddr,
|
||||
/// Environment variable holding a bearer token of at least 32 characters.
|
||||
#[arg(long, default_value = "RUVIEW_NLOS_TOKEN")]
|
||||
token_env: String,
|
||||
/// Publish deterministic L0 frames at 30 Hz for UI validation.
|
||||
#[arg(long)]
|
||||
synthetic: bool,
|
||||
/// Confirm that a TLS reverse proxy and network policy protect non-loopback bind.
|
||||
#[arg(long)]
|
||||
behind_tls_proxy: bool,
|
||||
/// One exact HTTPS browser origin, or HTTP loopback origin for development.
|
||||
#[arg(long)]
|
||||
allowed_origin: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
match Cli::parse().command {
|
||||
Command::Benchmark { frames, particles } => {
|
||||
if !(1..=10_000).contains(&frames) || !(64..=20_000).contains(&particles) {
|
||||
return Err("frames or particles outside bounded range".into());
|
||||
}
|
||||
let report = SyntheticScene::benchmark(frames, particles);
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
}
|
||||
Command::ValidateTrack { input } => {
|
||||
let count = read_jsonl::<TrackEnvelope, ruview_nlos::protocol::ContractError>(
|
||||
&input,
|
||||
|frame| frame.validate(),
|
||||
)?;
|
||||
println!("validated {count} bounded track frames");
|
||||
}
|
||||
Command::TrackJsonl {
|
||||
input,
|
||||
background_frames,
|
||||
} => {
|
||||
let frames = load_transients(&input)?;
|
||||
if background_frames < 2 || background_frames >= frames.len() {
|
||||
return Err("background-frames must leave at least one tracking frame".into());
|
||||
}
|
||||
let calibration = Calibration::from_background(
|
||||
&frames[..background_frames],
|
||||
CalibrationConfig::default(),
|
||||
)?;
|
||||
let mut tracker = MotionApertureTracker::new(
|
||||
calibration,
|
||||
CanonicalObject::point(),
|
||||
TrackerConfig::default(),
|
||||
)?;
|
||||
for frame in &frames[background_frames..] {
|
||||
let output = tracker.update(frame, None)?;
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "hardware")]
|
||||
Command::CaptureSt {
|
||||
port,
|
||||
session,
|
||||
sensor_id,
|
||||
sensor_model,
|
||||
firmware_version,
|
||||
pose_jsonl,
|
||||
fixed_sensor,
|
||||
frames,
|
||||
height,
|
||||
width,
|
||||
bins,
|
||||
start_bin,
|
||||
frequency_hz,
|
||||
} => capture_st(
|
||||
&port,
|
||||
&session,
|
||||
&sensor_id,
|
||||
&sensor_model,
|
||||
&firmware_version,
|
||||
pose_jsonl.as_ref(),
|
||||
fixed_sensor,
|
||||
frames,
|
||||
height,
|
||||
width,
|
||||
bins,
|
||||
start_bin,
|
||||
frequency_hz,
|
||||
)?,
|
||||
Command::Serve {
|
||||
bind,
|
||||
token_env,
|
||||
synthetic,
|
||||
behind_tls_proxy,
|
||||
allowed_origin,
|
||||
} => {
|
||||
if !is_loopback(bind.ip()) && !behind_tls_proxy {
|
||||
return Err("non-loopback bind requires --behind-tls-proxy".into());
|
||||
}
|
||||
let token = std::env::var(&token_env).map_err(|_| {
|
||||
format!("required token environment variable {token_env} is absent")
|
||||
})?;
|
||||
let server_session = if synthetic {
|
||||
"synthetic-nlos-1"
|
||||
} else {
|
||||
"ruview-nlos-server"
|
||||
};
|
||||
let mut hub = NlosHub::new(&token, server_session)?;
|
||||
if let Some(origin) = allowed_origin {
|
||||
hub = hub.with_allowed_origin(&origin)?;
|
||||
}
|
||||
if synthetic {
|
||||
spawn_synthetic(hub.clone());
|
||||
}
|
||||
let listener = tokio::net::TcpListener::bind(bind).await?;
|
||||
tracing::info!(%bind, synthetic, "RuView NLOS server listening");
|
||||
axum::serve(listener, hub.router()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "hardware")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn capture_st(
|
||||
port: &str,
|
||||
session: &str,
|
||||
sensor_id: &str,
|
||||
sensor_model: &str,
|
||||
firmware_version: &str,
|
||||
pose_jsonl: Option<&PathBuf>,
|
||||
fixed_sensor: bool,
|
||||
frame_limit: usize,
|
||||
height: u16,
|
||||
width: u16,
|
||||
bins: u16,
|
||||
start_bin: u16,
|
||||
frequency_hz: u16,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::io::Write;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
if !(1..=1_000_000).contains(&frame_limit) || !(1..=30).contains(&frequency_hz) {
|
||||
return Err("capture frame or frequency bound exceeded".into());
|
||||
}
|
||||
if pose_jsonl.is_some() == fixed_sensor {
|
||||
return Err("choose exactly one of --pose-jsonl or --fixed-sensor".into());
|
||||
}
|
||||
for value in [session, sensor_id, sensor_model, firmware_version] {
|
||||
if !valid_cli_label(value) {
|
||||
return Err("session and provenance labels must use 1..64 safe characters".into());
|
||||
}
|
||||
}
|
||||
let poses = if let Some(path) = pose_jsonl {
|
||||
let poses = load_sensor_poses(path)?;
|
||||
if poses.len() != frame_limit {
|
||||
return Err("pose JSONL must contain exactly one pose per captured frame".into());
|
||||
}
|
||||
Some(poses)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut decoder = StAsciiDecoder::new(StDecoderConfig {
|
||||
session_id: session.into(),
|
||||
height,
|
||||
width,
|
||||
num_bins: bins,
|
||||
start_bin,
|
||||
bin_width_ps: 250.0,
|
||||
fov_x_degrees: 45.0,
|
||||
fov_y_degrees: 45.0,
|
||||
add_back_ambient: false,
|
||||
require_frame_marker: true,
|
||||
source: FrameSource::Live,
|
||||
evidence_level: EvidenceLevel::L1Measured,
|
||||
calibration_hash: "0".repeat(64),
|
||||
provenance: Provenance {
|
||||
sensor_id: sensor_id.into(),
|
||||
sensor_model: sensor_model.into(),
|
||||
firmware_version: firmware_version.into(),
|
||||
transient_kind: TransientKind::CompactNormalizedHistogram,
|
||||
histogram_preserved: true,
|
||||
transport: "usb_serial".into(),
|
||||
},
|
||||
})?;
|
||||
let mut serial = serialport::new(port, 2_250_000)
|
||||
.timeout(Duration::from_secs(1))
|
||||
.open()?;
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
serial.clear(serialport::ClearBuffer::Input)?;
|
||||
serial.write_all(&decoder.firmware_config_bytes(1, frequency_hz, 10, 1))?;
|
||||
serial.flush()?;
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
serial.clear(serialport::ClearBuffer::Input)?;
|
||||
let started = Instant::now();
|
||||
let mut reader = BufReader::new(serial);
|
||||
let mut emitted = 0_usize;
|
||||
while emitted < frame_limit {
|
||||
let line = match read_bounded_line(&mut reader, 4_096) {
|
||||
Ok(None) => continue,
|
||||
Ok(Some(line)) => line,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let line = std::str::from_utf8(&line)?;
|
||||
let captured_at_unix_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64;
|
||||
if let Some(frame) = decoder.push_line(
|
||||
line,
|
||||
captured_at_unix_ms,
|
||||
started.elapsed().as_nanos() as u64,
|
||||
poses
|
||||
.as_ref()
|
||||
.map_or_else(SensorPose::default, |values| values[emitted]),
|
||||
)? {
|
||||
println!("{}", serde_json::to_string(&frame)?);
|
||||
emitted += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_loopback(ip: IpAddr) -> bool {
|
||||
ip.is_loopback()
|
||||
}
|
||||
|
||||
#[cfg(feature = "hardware")]
|
||||
fn valid_cli_label(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
}
|
||||
|
||||
#[cfg(feature = "hardware")]
|
||||
fn load_sensor_poses(path: &PathBuf) -> Result<Vec<SensorPose>, Box<dyn std::error::Error>> {
|
||||
let mut poses = Vec::new();
|
||||
read_jsonl::<SensorPose, ruview_nlos::protocol::ContractError>(path, |pose| {
|
||||
pose.validate()?;
|
||||
poses.push(*pose);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(poses)
|
||||
}
|
||||
|
||||
fn spawn_synthetic(hub: NlosHub) {
|
||||
tokio::spawn(async move {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let Ok(calibration) = Calibration::from_background(
|
||||
&scene.background_frames(60),
|
||||
CalibrationConfig::default(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut tracker) = MotionApertureTracker::new(
|
||||
calibration,
|
||||
CanonicalObject::point(),
|
||||
TrackerConfig::default(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let mut sequence = 100_u64;
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(33));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let t = sequence as f32 * 0.02;
|
||||
let target = Vec3::new(0.25 * t.sin(), 0.15 * (t * 0.7).cos(), 1.0);
|
||||
let mut frame = scene.frame(Some(target), 1.0, sequence);
|
||||
frame.captured_at_unix_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_millis() as u64);
|
||||
if let Ok(output) = tracker.update(&frame, None) {
|
||||
let _ = hub.publish(output).await;
|
||||
}
|
||||
sequence = sequence.saturating_add(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn load_transients(path: &PathBuf) -> Result<Vec<TransientFrame>, Box<dyn std::error::Error>> {
|
||||
let mut frames = Vec::new();
|
||||
read_jsonl::<TransientFrame, ruview_nlos::protocol::ContractError>(path, |frame| {
|
||||
frames.push(as_offline_replay(frame.clone())?);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn as_offline_replay(
|
||||
mut frame: TransientFrame,
|
||||
) -> Result<TransientFrame, ruview_nlos::protocol::ContractError> {
|
||||
frame.validate()?;
|
||||
if frame.source == FrameSource::Live {
|
||||
frame.source = FrameSource::Replay;
|
||||
frame.provenance.transient_kind = TransientKind::Replay;
|
||||
frame.provenance.transport = "replay".into();
|
||||
frame.provenance.histogram_preserved = true;
|
||||
}
|
||||
frame.validate()?;
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
fn read_jsonl<T, E>(
|
||||
path: &PathBuf,
|
||||
mut validate: impl FnMut(&T) -> Result<(), E>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
E: std::error::Error + 'static,
|
||||
{
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() > 512 * 1024 * 1024 {
|
||||
return Err("JSONL input exceeds 512 MiB bound".into());
|
||||
}
|
||||
let mut reader = BufReader::new(File::open(path)?);
|
||||
let mut count = 0_usize;
|
||||
while let Some(line) = read_bounded_line(&mut reader, MAX_WIRE_BYTES)? {
|
||||
let line = std::str::from_utf8(&line)?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value: T = serde_json::from_str(line)?;
|
||||
validate(&value)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn read_bounded_line<R: BufRead>(
|
||||
reader: &mut R,
|
||||
maximum_bytes: usize,
|
||||
) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::with_capacity(maximum_bytes.min(8 * 1024));
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
let newline = available.iter().position(|byte| *byte == b'\n');
|
||||
let payload = newline.unwrap_or(available.len());
|
||||
if line.len().saturating_add(payload) > maximum_bytes {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"line exceeds bounded parser limit",
|
||||
));
|
||||
}
|
||||
line.extend_from_slice(&available[..payload]);
|
||||
let consumed = payload + usize::from(newline.is_some());
|
||||
reader.consume(consumed);
|
||||
if newline.is_some() {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{as_offline_replay, read_bounded_line};
|
||||
use ruview_nlos::{EvidenceLevel, FrameSource, SyntheticScene, TransientKind, Vec3};
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_rejects_before_growing_past_limit() {
|
||||
let mut reader = Cursor::new(vec![b'x'; 65]);
|
||||
let error = read_bounded_line(&mut reader, 64).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_accepts_exact_limit_and_multiple_lines() {
|
||||
let mut reader = Cursor::new(b"1234\nok\n".to_vec());
|
||||
assert_eq!(read_bounded_line(&mut reader, 4).unwrap().unwrap(), b"1234");
|
||||
assert_eq!(read_bounded_line(&mut reader, 4).unwrap().unwrap(), b"ok");
|
||||
assert!(read_bounded_line(&mut reader, 4).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_live_capture_is_relabelled_as_replay() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let mut frame = scene.frame(Some(Vec3::new(0.0, 0.0, 1.0)), 1.0, 7);
|
||||
frame.source = FrameSource::Live;
|
||||
frame.evidence_level = EvidenceLevel::L1Measured;
|
||||
frame.provenance.transient_kind = TransientKind::CompactNormalizedHistogram;
|
||||
frame.provenance.transport = "usb_serial".into();
|
||||
let replay = as_offline_replay(frame).unwrap();
|
||||
assert_eq!(replay.source, FrameSource::Replay);
|
||||
assert_eq!(replay.provenance.transient_kind, TransientKind::Replay);
|
||||
assert_eq!(replay.provenance.transport, "replay");
|
||||
}
|
||||
}
|
||||
377
v2/crates/ruview-nlos/src/bridge.rs
Normal file
377
v2/crates/ruview-nlos/src/bridge.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
//! Bounded adapters for RuVector temporal features, RuField observations, and
|
||||
//! WorldGraph updates. These are data-plane outputs, not authority to mutate a
|
||||
//! remote graph; callers remain responsible for tenant and OAuth policy.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::protocol::{
|
||||
EvidenceLevel, FrameSource, ModalityContributions, Provenance, TrackEnvelope, TrackState, Vec3,
|
||||
};
|
||||
|
||||
/// One compact trajectory feature suitable for insertion into a RuVector
|
||||
/// temporal index. It contains no civil identity or raw photon histogram.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemporalFeature {
|
||||
/// Capture session.
|
||||
pub session_id: String,
|
||||
/// Session-local track id.
|
||||
pub track_id: String,
|
||||
/// Capture time.
|
||||
pub at_unix_ms: u64,
|
||||
/// Fixed position, velocity, covariance, confidence, and modality vector.
|
||||
pub embedding: [f32; 12],
|
||||
/// Evidence ceiling retained with the vector.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// Live/replay/synthetic source retained with the vector.
|
||||
pub source: FrameSource,
|
||||
/// Algorithm revision retained with the vector.
|
||||
pub algorithm_version: String,
|
||||
/// Calibration digest retained with the vector.
|
||||
pub calibration_hash: String,
|
||||
/// Sensor and transport provenance retained with the vector.
|
||||
pub provenance: Provenance,
|
||||
/// Hard expiry.
|
||||
pub expires_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
/// In-process deterministic reference memory. Production deployments can write
|
||||
/// the same [`TemporalFeature`] records to tenant-scoped RuVector storage.
|
||||
pub struct TemporalFeatureMemory {
|
||||
capacity: usize,
|
||||
records: VecDeque<TemporalFeature>,
|
||||
}
|
||||
|
||||
impl TemporalFeatureMemory {
|
||||
/// Construct a bounded memory.
|
||||
pub fn new(capacity: usize) -> Result<Self, BridgeError> {
|
||||
if !(1..=65_536).contains(&capacity) {
|
||||
return Err(BridgeError::InvalidCapacity);
|
||||
}
|
||||
Ok(Self {
|
||||
capacity,
|
||||
records: VecDeque::with_capacity(capacity.min(1_024)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert every non-UNKNOWN track after validating its envelope.
|
||||
pub fn observe(&mut self, envelope: &TrackEnvelope) -> Result<usize, BridgeError> {
|
||||
envelope.validate()?;
|
||||
self.records
|
||||
.retain(|record| record.expires_at_unix_ms >= envelope.captured_at_unix_ms);
|
||||
let mut inserted = 0_usize;
|
||||
for track in envelope
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|track| track.state != TrackState::Unknown)
|
||||
{
|
||||
while self.records.len() >= self.capacity {
|
||||
self.records.pop_front();
|
||||
}
|
||||
self.records.push_back(TemporalFeature {
|
||||
session_id: envelope.session_id.clone(),
|
||||
track_id: track.track_id.clone(),
|
||||
at_unix_ms: envelope.captured_at_unix_ms,
|
||||
embedding: [
|
||||
track.position_m.x,
|
||||
track.position_m.y,
|
||||
track.position_m.z,
|
||||
track.velocity_mps.x,
|
||||
track.velocity_mps.y,
|
||||
track.velocity_mps.z,
|
||||
track.covariance_diagonal_m2.x,
|
||||
track.covariance_diagonal_m2.y,
|
||||
track.covariance_diagonal_m2.z,
|
||||
track.confidence,
|
||||
track.modality_contributions.lidar,
|
||||
track.modality_contributions.csi,
|
||||
],
|
||||
evidence_level: envelope.evidence_level,
|
||||
source: envelope.source,
|
||||
algorithm_version: envelope.algorithm_version.clone(),
|
||||
calibration_hash: envelope.calibration_hash.clone(),
|
||||
provenance: envelope.provenance.clone(),
|
||||
expires_at_unix_ms: envelope.expires_at_unix_ms,
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
/// Return the closest same-session feature by cosine similarity.
|
||||
#[must_use]
|
||||
pub fn nearest(
|
||||
&self,
|
||||
query: &[f32; 12],
|
||||
session_id: &str,
|
||||
now_unix_ms: u64,
|
||||
) -> Option<(&TemporalFeature, f32)> {
|
||||
if query.iter().any(|value| !value.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
self.records
|
||||
.iter()
|
||||
.filter(|record| record.session_id == session_id)
|
||||
.filter(|record| record.expires_at_unix_ms > now_unix_ms)
|
||||
.filter_map(|record| cosine(query, &record.embedding).map(|score| (record, score)))
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
|
||||
/// Current bounded record count.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// Whether no records remain.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.records.is_empty()
|
||||
}
|
||||
|
||||
/// Remove all records that are no longer fresh.
|
||||
pub fn purge_expired(&mut self, now_unix_ms: u64) -> usize {
|
||||
let before = self.records.len();
|
||||
self.records
|
||||
.retain(|record| record.expires_at_unix_ms > now_unix_ms);
|
||||
before - self.records.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn cosine(a: &[f32; 12], b: &[f32; 12]) -> Option<f32> {
|
||||
let mut dot = 0.0;
|
||||
let mut aa = 0.0;
|
||||
let mut bb = 0.0;
|
||||
for (left, right) in a.iter().zip(b.iter()) {
|
||||
dot += left * right;
|
||||
aa += left * left;
|
||||
bb += right * right;
|
||||
}
|
||||
let denominator = (aa * bb).sqrt();
|
||||
(denominator > f32::EPSILON).then(|| (dot / denominator).clamp(-1.0, 1.0))
|
||||
}
|
||||
|
||||
/// Privacy-safe field observation emitted for every quality-gated track.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuFieldObservation {
|
||||
/// Field schema.
|
||||
pub schema: String,
|
||||
/// Session-local observation id.
|
||||
pub observation_id: String,
|
||||
/// World position.
|
||||
pub position_m: Vec3,
|
||||
/// Diagonal spatial covariance.
|
||||
pub covariance_diagonal_m2: Vec3,
|
||||
/// Confidence.
|
||||
pub confidence: f32,
|
||||
/// Current signal quality.
|
||||
pub signal_quality: f32,
|
||||
/// Observable modality weights.
|
||||
pub modality_contributions: ModalityContributions,
|
||||
/// Capture time.
|
||||
pub observed_at_unix_ms: u64,
|
||||
/// Hard expiry.
|
||||
pub expires_at_unix_ms: u64,
|
||||
/// Evidence ceiling.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// Source watermark.
|
||||
pub source: FrameSource,
|
||||
/// Calibration digest.
|
||||
pub calibration_hash: String,
|
||||
/// Algorithm revision.
|
||||
pub algorithm_version: String,
|
||||
/// Sensor/signal/transport provenance.
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
impl RuFieldObservation {
|
||||
/// Convert all non-UNKNOWN tracks without retaining raw histograms.
|
||||
pub fn from_envelope(envelope: &TrackEnvelope) -> Result<Vec<Self>, BridgeError> {
|
||||
envelope.validate()?;
|
||||
Ok(envelope
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|track| track.state != TrackState::Unknown)
|
||||
.map(|track| Self {
|
||||
schema: "rufield.observation.nlos.v1".into(),
|
||||
observation_id: canonical_id(
|
||||
b"rufield-observation-v1",
|
||||
&[
|
||||
envelope.session_id.as_bytes(),
|
||||
track.track_id.as_bytes(),
|
||||
&envelope.sequence.to_le_bytes(),
|
||||
],
|
||||
),
|
||||
position_m: track.position_m,
|
||||
covariance_diagonal_m2: track.covariance_diagonal_m2,
|
||||
confidence: track.confidence,
|
||||
signal_quality: track.signal_quality,
|
||||
modality_contributions: track.modality_contributions,
|
||||
observed_at_unix_ms: envelope.captured_at_unix_ms,
|
||||
expires_at_unix_ms: envelope.expires_at_unix_ms,
|
||||
evidence_level: envelope.evidence_level,
|
||||
source: envelope.source,
|
||||
calibration_hash: envelope.calibration_hash.clone(),
|
||||
algorithm_version: envelope.algorithm_version.clone(),
|
||||
provenance: envelope.provenance.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal idempotent WorldGraph mutation request. A governed writer applies it
|
||||
/// only after tenant authorization and evidence policy checks.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorldGraphUpdate {
|
||||
/// Mutation schema.
|
||||
pub schema: String,
|
||||
/// Idempotency key.
|
||||
pub idempotency_key: String,
|
||||
/// Pseudonymous node id.
|
||||
pub node_id: String,
|
||||
/// Node kind.
|
||||
pub node_kind: String,
|
||||
/// World position.
|
||||
pub position_m: Vec3,
|
||||
/// Estimated velocity.
|
||||
pub velocity_mps: Vec3,
|
||||
/// Diagonal spatial covariance.
|
||||
pub covariance_diagonal_m2: Vec3,
|
||||
/// Posterior confidence.
|
||||
pub confidence: f32,
|
||||
/// Current signal quality.
|
||||
pub signal_quality: f32,
|
||||
/// Observable modality weights.
|
||||
pub modality_contributions: ModalityContributions,
|
||||
/// Quality-gated state.
|
||||
pub state: TrackState,
|
||||
/// Evidence ceiling.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// Live/replay/synthetic source.
|
||||
pub source: FrameSource,
|
||||
/// Observation time.
|
||||
pub observed_at_unix_ms: u64,
|
||||
/// Calibration digest.
|
||||
pub calibration_hash: String,
|
||||
/// Algorithm revision.
|
||||
pub algorithm_version: String,
|
||||
/// Sensor/signal/transport provenance.
|
||||
pub provenance: Provenance,
|
||||
/// Hard expiry.
|
||||
pub expires_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
impl WorldGraphUpdate {
|
||||
/// Convert tracks into idempotent, tenant-neutral mutation requests.
|
||||
pub fn from_envelope(envelope: &TrackEnvelope) -> Result<Vec<Self>, BridgeError> {
|
||||
envelope.validate()?;
|
||||
Ok(envelope
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|track| Self {
|
||||
schema: "worldgraph.update.nlos.v1".into(),
|
||||
idempotency_key: canonical_id(
|
||||
b"worldgraph-update-v1",
|
||||
&[
|
||||
envelope.session_id.as_bytes(),
|
||||
&envelope.sequence.to_le_bytes(),
|
||||
track.track_id.as_bytes(),
|
||||
],
|
||||
),
|
||||
node_id: canonical_id(
|
||||
b"worldgraph-node-v1",
|
||||
&[envelope.session_id.as_bytes(), track.track_id.as_bytes()],
|
||||
),
|
||||
node_kind: "hidden_target_hypothesis".into(),
|
||||
position_m: track.position_m,
|
||||
velocity_mps: track.velocity_mps,
|
||||
covariance_diagonal_m2: track.covariance_diagonal_m2,
|
||||
confidence: track.confidence,
|
||||
signal_quality: track.signal_quality,
|
||||
modality_contributions: track.modality_contributions,
|
||||
state: track.state,
|
||||
evidence_level: envelope.evidence_level,
|
||||
source: envelope.source,
|
||||
observed_at_unix_ms: envelope.captured_at_unix_ms,
|
||||
calibration_hash: envelope.calibration_hash.clone(),
|
||||
algorithm_version: envelope.algorithm_version.clone(),
|
||||
provenance: envelope.provenance.clone(),
|
||||
expires_at_unix_ms: envelope.expires_at_unix_ms,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_id(domain: &[u8], fields: &[&[u8]]) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(domain);
|
||||
digest.update([0]);
|
||||
for field in fields {
|
||||
digest.update((field.len() as u32).to_le_bytes());
|
||||
digest.update(field);
|
||||
}
|
||||
format!("nlos-{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
/// Bridge conversion failure.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BridgeError {
|
||||
/// Memory capacity was zero or unreasonably large.
|
||||
#[error("invalid temporal memory capacity")]
|
||||
InvalidCapacity,
|
||||
/// Source track contract was invalid.
|
||||
#[error(transparent)]
|
||||
Contract(#[from] crate::protocol::ContractError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulator::SyntheticScene;
|
||||
|
||||
#[test]
|
||||
fn bridge_retains_evidence_and_never_raw_histograms() {
|
||||
let report = SyntheticScene::benchmark(2, 64);
|
||||
assert_eq!(report.evidence, "SYNTHETIC_L0");
|
||||
let memory = TemporalFeatureMemory::new(8).unwrap();
|
||||
assert!(memory.is_empty());
|
||||
let serialized = serde_json::to_string(&RuFieldObservation {
|
||||
schema: "rufield.observation.nlos.v1".into(),
|
||||
observation_id: "s:t:1".into(),
|
||||
position_m: Vec3::default(),
|
||||
covariance_diagonal_m2: Vec3::default(),
|
||||
confidence: 0.0,
|
||||
signal_quality: 0.0,
|
||||
modality_contributions: ModalityContributions {
|
||||
lidar: 1.0,
|
||||
csi: 0.0,
|
||||
},
|
||||
observed_at_unix_ms: 1,
|
||||
expires_at_unix_ms: 2,
|
||||
evidence_level: EvidenceLevel::L0Synthetic,
|
||||
source: FrameSource::Synthetic,
|
||||
calibration_hash: "0".repeat(64),
|
||||
algorithm_version: "test-v1".into(),
|
||||
provenance: crate::protocol::Provenance {
|
||||
sensor_id: "sim".into(),
|
||||
sensor_model: "sim".into(),
|
||||
firmware_version: "sim".into(),
|
||||
transient_kind: crate::protocol::TransientKind::Replay,
|
||||
histogram_preserved: false,
|
||||
transport: "replay".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
// The provenance boolean is retained, but neither histogram bins nor
|
||||
// raw zones cross this bridge.
|
||||
assert!(serialized.contains("histogramPreserved"));
|
||||
assert!(!serialized.contains("\"histogram\":"));
|
||||
assert!(!serialized.contains("\"zones\":"));
|
||||
}
|
||||
}
|
||||
534
v2/crates/ruview-nlos/src/calibration.rs
Normal file
534
v2/crates/ruview-nlos/src/calibration.rs
Normal file
@@ -0,0 +1,534 @@
|
||||
//! Empty-room calibration, direct-return removal, and light-cone resampling.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::protocol::{EvidenceLevel, FrameSource, Provenance, SensorPose, TransientFrame, Vec3};
|
||||
|
||||
/// Preprocessing controls matching the public consumer-NLOS workflow.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CalibrationConfig {
|
||||
/// Bins masked on either side of the direct wall return.
|
||||
pub pulse_half_width: usize,
|
||||
/// Earliest extra-path bins discarded after direct-return alignment.
|
||||
pub zero_first_bins: usize,
|
||||
/// Maximum empty-room frames accepted into one calibration.
|
||||
pub max_background_frames: usize,
|
||||
/// Maximum sensor translation from the empty-room capture centroid.
|
||||
pub max_sensor_translation_m: f32,
|
||||
/// Maximum per-zone relay point displacement in the world frame.
|
||||
pub max_wall_point_drift_m: f32,
|
||||
/// Maximum per-zone reported range drift from calibration.
|
||||
pub max_wall_distance_drift_m: f32,
|
||||
}
|
||||
|
||||
impl Default for CalibrationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pulse_half_width: 7,
|
||||
zero_first_bins: 15,
|
||||
max_background_frames: 256,
|
||||
max_sensor_translation_m: 0.5,
|
||||
max_wall_point_drift_m: 0.5,
|
||||
max_wall_distance_drift_m: 0.25,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable empty-room calibration bound by a deterministic SHA-256 digest.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Calibration {
|
||||
/// Configuration used to build the calibration.
|
||||
config: CalibrationConfig,
|
||||
/// Session in which the empty room was captured.
|
||||
session_id: String,
|
||||
/// Homogeneous source classification of the calibration capture.
|
||||
source: FrameSource,
|
||||
/// Evidence ceiling of the calibration inputs.
|
||||
input_evidence_level: EvidenceLevel,
|
||||
/// Full sensor/signal/transport provenance bound into the calibration.
|
||||
provenance: Provenance,
|
||||
/// Number of zones.
|
||||
zone_count: usize,
|
||||
/// Number of native timing bins.
|
||||
bin_count: usize,
|
||||
/// Timing width in picoseconds.
|
||||
bin_width_ps: f32,
|
||||
/// First firmware bin.
|
||||
start_bin: u16,
|
||||
/// Sensor identity bound into the calibration digest.
|
||||
sensor_id: String,
|
||||
/// Sensor model bound into the calibration digest.
|
||||
sensor_model: String,
|
||||
/// Firmware revision bound into the calibration digest.
|
||||
firmware_version: String,
|
||||
/// Number of background samples committed to the digest.
|
||||
sample_count: usize,
|
||||
/// Mean sensor translation during empty-room capture.
|
||||
reference_sensor_translation_m: Vec3,
|
||||
/// Average relay-wall points in sensor-local coordinates.
|
||||
wall_points_m: Vec<Vec3>,
|
||||
/// Average relay-wall points transformed into the world coordinate frame.
|
||||
wall_points_world_m: Vec<Vec3>,
|
||||
/// Average per-zone range reported during calibration.
|
||||
wall_distances_m: Vec<f32>,
|
||||
/// Peak direct-return index for each zone.
|
||||
direct_peak_bins: Vec<usize>,
|
||||
/// Empty-room mean counts, row-major by zone and bin.
|
||||
background: Vec<f32>,
|
||||
/// SHA-256 digest over all fields above.
|
||||
hash: String,
|
||||
}
|
||||
|
||||
impl Calibration {
|
||||
/// Build a deterministic empty-room calibration from two or more frames.
|
||||
pub fn from_background(
|
||||
frames: &[TransientFrame],
|
||||
config: CalibrationConfig,
|
||||
) -> Result<Self, CalibrationError> {
|
||||
if frames.len() < 2 || frames.len() > config.max_background_frames {
|
||||
return Err(CalibrationError::FrameCount);
|
||||
}
|
||||
if !config.max_sensor_translation_m.is_finite()
|
||||
|| !(0.01..=5.0).contains(&config.max_sensor_translation_m)
|
||||
|| !config.max_wall_point_drift_m.is_finite()
|
||||
|| !(0.01..=5.0).contains(&config.max_wall_point_drift_m)
|
||||
|| !config.max_wall_distance_drift_m.is_finite()
|
||||
|| !(0.01..=5.0).contains(&config.max_wall_distance_drift_m)
|
||||
{
|
||||
return Err(CalibrationError::InvalidConfig);
|
||||
}
|
||||
for frame in frames {
|
||||
frame.validate()?;
|
||||
}
|
||||
let first = &frames[0];
|
||||
let zone_count = first.zones.len();
|
||||
let bin_count = first.zones[0].histogram.len();
|
||||
if config.pulse_half_width >= bin_count || config.zero_first_bins >= bin_count {
|
||||
return Err(CalibrationError::InvalidConfig);
|
||||
}
|
||||
if frames.windows(2).any(|pair| {
|
||||
pair[1].sequence <= pair[0].sequence
|
||||
|| pair[1].monotonic_ns <= pair[0].monotonic_ns
|
||||
|| pair[1].captured_at_unix_ms < pair[0].captured_at_unix_ms
|
||||
}) {
|
||||
return Err(CalibrationError::OutOfOrderFrames);
|
||||
}
|
||||
if frames.iter().any(|frame| {
|
||||
frame.session_id != first.session_id
|
||||
|| frame.source != first.source
|
||||
|| frame.evidence_level != first.evidence_level
|
||||
|| frame.provenance != first.provenance
|
||||
|| frame.calibration_hash != first.calibration_hash
|
||||
|| frame.zones.len() != zone_count
|
||||
|| frame.zones[0].histogram.len() != bin_count
|
||||
|| frame.bin_width_ps != first.bin_width_ps
|
||||
|| frame.start_bin != first.start_bin
|
||||
|| frame.provenance.sensor_id != first.provenance.sensor_id
|
||||
|| frame.provenance.sensor_model != first.provenance.sensor_model
|
||||
|| frame.provenance.firmware_version != first.provenance.firmware_version
|
||||
|| frame
|
||||
.zones
|
||||
.iter()
|
||||
.any(|zone| zone.histogram.len() != bin_count)
|
||||
}) {
|
||||
return Err(CalibrationError::InconsistentFrames);
|
||||
}
|
||||
|
||||
let mut background = vec![0.0_f32; zone_count * bin_count];
|
||||
let mut wall_points_m = vec![Vec3::default(); zone_count];
|
||||
let mut wall_points_world_m = vec![Vec3::default(); zone_count];
|
||||
let mut wall_distances_m = vec![0.0_f32; zone_count];
|
||||
let mut reference_sensor_translation_m = Vec3::default();
|
||||
for frame in frames {
|
||||
reference_sensor_translation_m =
|
||||
reference_sensor_translation_m.plus(frame.sensor_pose.translation_m);
|
||||
for (zone_index, zone) in frame.zones.iter().enumerate() {
|
||||
wall_points_m[zone_index] = wall_points_m[zone_index].plus(zone.wall_point_m);
|
||||
wall_points_world_m[zone_index] = wall_points_world_m[zone_index]
|
||||
.plus(frame.sensor_pose.transform(zone.wall_point_m));
|
||||
wall_distances_m[zone_index] += zone.distance_m;
|
||||
for (bin, count) in zone.histogram.iter().enumerate() {
|
||||
background[zone_index * bin_count + bin] += f32::from(*count);
|
||||
}
|
||||
}
|
||||
}
|
||||
let inv = 1.0 / frames.len() as f32;
|
||||
for value in &mut background {
|
||||
*value *= inv;
|
||||
}
|
||||
for point in &mut wall_points_m {
|
||||
*point = point.scale(inv);
|
||||
}
|
||||
for point in &mut wall_points_world_m {
|
||||
*point = point.scale(inv);
|
||||
}
|
||||
for distance in &mut wall_distances_m {
|
||||
*distance *= inv;
|
||||
}
|
||||
reference_sensor_translation_m = reference_sensor_translation_m.scale(inv);
|
||||
let direct_peak_bins = (0..zone_count)
|
||||
.map(|zone| {
|
||||
background[zone * bin_count..(zone + 1) * bin_count]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.total_cmp(b.1))
|
||||
.map_or(0, |(index, _)| index)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut calibration = Self {
|
||||
config,
|
||||
session_id: first.session_id.clone(),
|
||||
source: first.source,
|
||||
input_evidence_level: first.evidence_level,
|
||||
provenance: first.provenance.clone(),
|
||||
zone_count,
|
||||
bin_count,
|
||||
bin_width_ps: first.bin_width_ps,
|
||||
start_bin: first.start_bin,
|
||||
sensor_id: first.provenance.sensor_id.clone(),
|
||||
sensor_model: first.provenance.sensor_model.clone(),
|
||||
firmware_version: first.provenance.firmware_version.clone(),
|
||||
sample_count: frames.len(),
|
||||
reference_sensor_translation_m,
|
||||
wall_points_m,
|
||||
wall_points_world_m,
|
||||
wall_distances_m,
|
||||
direct_peak_bins,
|
||||
background,
|
||||
hash: String::new(),
|
||||
};
|
||||
calibration.hash = calibration.compute_hash();
|
||||
Ok(calibration)
|
||||
}
|
||||
|
||||
/// Stable calibration digest used by measured track envelopes.
|
||||
#[must_use]
|
||||
pub fn hash(&self) -> &str {
|
||||
&self.hash
|
||||
}
|
||||
|
||||
/// Capture session bound into this calibration.
|
||||
#[must_use]
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
/// Number of calibrated relay zones.
|
||||
#[must_use]
|
||||
pub fn zone_count(&self) -> usize {
|
||||
self.zone_count
|
||||
}
|
||||
|
||||
/// Background-subtract, mask the direct peak, align extra path time, and
|
||||
/// resample from time to uniform squared-distance (light-cone) bins.
|
||||
pub fn preprocess(
|
||||
&self,
|
||||
frame: &TransientFrame,
|
||||
) -> Result<PreprocessedFrame, CalibrationError> {
|
||||
if self.compute_hash() != self.hash {
|
||||
return Err(CalibrationError::IntegrityMismatch);
|
||||
}
|
||||
frame.validate()?;
|
||||
if frame.session_id != self.session_id
|
||||
|| frame.source != self.source
|
||||
|| frame.provenance != self.provenance
|
||||
|| frame.zones.len() != self.zone_count
|
||||
|| frame.zones[0].histogram.len() != self.bin_count
|
||||
|| frame.bin_width_ps != self.bin_width_ps
|
||||
|| frame.start_bin != self.start_bin
|
||||
|| frame.provenance.sensor_id != self.sensor_id
|
||||
|| frame.provenance.sensor_model != self.sensor_model
|
||||
|| frame.provenance.firmware_version != self.firmware_version
|
||||
{
|
||||
return Err(CalibrationError::InconsistentFrames);
|
||||
}
|
||||
if frame.source != FrameSource::Synthetic
|
||||
&& frame.calibration_hash != self.hash
|
||||
&& frame.calibration_hash != "0".repeat(64)
|
||||
{
|
||||
return Err(CalibrationError::WrongCalibration);
|
||||
}
|
||||
|
||||
if frame
|
||||
.sensor_pose
|
||||
.translation_m
|
||||
.distance(self.reference_sensor_translation_m)
|
||||
> self.config.max_sensor_translation_m
|
||||
|| frame.zones.iter().enumerate().any(|(zone_index, zone)| {
|
||||
frame
|
||||
.sensor_pose
|
||||
.transform(zone.wall_point_m)
|
||||
.distance(self.wall_points_world_m[zone_index])
|
||||
> self.config.max_wall_point_drift_m
|
||||
|| (zone.distance_m - self.wall_distances_m[zone_index]).abs()
|
||||
> self.config.max_wall_distance_drift_m
|
||||
})
|
||||
{
|
||||
return Err(CalibrationError::GeometryDrift);
|
||||
}
|
||||
|
||||
let mut light_cone_histograms = vec![0.0_f32; self.zone_count * self.bin_count];
|
||||
let mut foreground_sum = 0.0_f32;
|
||||
let mut noise_floor = 0.0_f32;
|
||||
let denominator = (self.bin_count - 1).max(1);
|
||||
for (zone_index, zone) in frame.zones.iter().enumerate() {
|
||||
let peak = self.direct_peak_bins[zone_index];
|
||||
let direct_end = peak.saturating_add(self.config.pulse_half_width);
|
||||
for native_bin in 0..self.bin_count {
|
||||
let baseline = self.background[zone_index * self.bin_count + native_bin];
|
||||
let residual = (f32::from(zone.histogram[native_bin]) - baseline).max(0.0);
|
||||
noise_floor += baseline.sqrt().max(1.0);
|
||||
if native_bin <= direct_end {
|
||||
continue;
|
||||
}
|
||||
let extra_bin = native_bin - peak;
|
||||
if extra_bin < self.config.zero_first_bins {
|
||||
continue;
|
||||
}
|
||||
let v_bin = ((extra_bin * extra_bin) / denominator).min(self.bin_count - 1);
|
||||
light_cone_histograms[zone_index * self.bin_count + v_bin] += residual;
|
||||
foreground_sum += residual;
|
||||
}
|
||||
}
|
||||
let signal_quality = foreground_sum / (foreground_sum + noise_floor.max(1.0));
|
||||
let wall_points_world_m = frame
|
||||
.zones
|
||||
.iter()
|
||||
.map(|zone| frame.sensor_pose.transform(zone.wall_point_m))
|
||||
.collect();
|
||||
Ok(PreprocessedFrame {
|
||||
sequence: frame.sequence,
|
||||
captured_at_unix_ms: frame.captured_at_unix_ms,
|
||||
monotonic_ns: frame.monotonic_ns,
|
||||
source: frame.source,
|
||||
evidence_level: frame.evidence_level,
|
||||
sensor_pose: frame.sensor_pose,
|
||||
wall_points_world_m,
|
||||
light_cone_histograms,
|
||||
zone_count: self.zone_count,
|
||||
bin_count: self.bin_count,
|
||||
bin_width_ps: self.bin_width_ps,
|
||||
signal_quality: signal_quality.clamp(0.0, 1.0),
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_hash(&self) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"ruview.nlos.calibration.v1\0");
|
||||
update_string(&mut digest, &self.session_id);
|
||||
digest.update([frame_source_code(self.source)]);
|
||||
digest.update([evidence_level_code(self.input_evidence_level)]);
|
||||
update_string(&mut digest, &self.provenance.sensor_id);
|
||||
update_string(&mut digest, &self.provenance.sensor_model);
|
||||
update_string(&mut digest, &self.provenance.firmware_version);
|
||||
digest.update([transient_kind_code(self.provenance.transient_kind)]);
|
||||
digest.update([u8::from(self.provenance.histogram_preserved)]);
|
||||
update_string(&mut digest, &self.provenance.transport);
|
||||
digest.update((self.zone_count as u64).to_le_bytes());
|
||||
digest.update((self.bin_count as u64).to_le_bytes());
|
||||
digest.update(self.bin_width_ps.to_le_bytes());
|
||||
digest.update(self.start_bin.to_le_bytes());
|
||||
digest.update((self.config.pulse_half_width as u64).to_le_bytes());
|
||||
digest.update((self.config.zero_first_bins as u64).to_le_bytes());
|
||||
digest.update((self.config.max_background_frames as u64).to_le_bytes());
|
||||
digest.update(self.config.max_sensor_translation_m.to_le_bytes());
|
||||
digest.update(self.config.max_wall_point_drift_m.to_le_bytes());
|
||||
digest.update(self.config.max_wall_distance_drift_m.to_le_bytes());
|
||||
digest.update((self.sample_count as u64).to_le_bytes());
|
||||
digest.update(self.reference_sensor_translation_m.x.to_le_bytes());
|
||||
digest.update(self.reference_sensor_translation_m.y.to_le_bytes());
|
||||
digest.update(self.reference_sensor_translation_m.z.to_le_bytes());
|
||||
for point in &self.wall_points_m {
|
||||
digest.update(point.x.to_le_bytes());
|
||||
digest.update(point.y.to_le_bytes());
|
||||
digest.update(point.z.to_le_bytes());
|
||||
}
|
||||
for point in &self.wall_points_world_m {
|
||||
digest.update(point.x.to_le_bytes());
|
||||
digest.update(point.y.to_le_bytes());
|
||||
digest.update(point.z.to_le_bytes());
|
||||
}
|
||||
for distance in &self.wall_distances_m {
|
||||
digest.update(distance.to_le_bytes());
|
||||
}
|
||||
for peak in &self.direct_peak_bins {
|
||||
digest.update((*peak as u64).to_le_bytes());
|
||||
}
|
||||
for value in &self.background {
|
||||
digest.update(value.to_le_bytes());
|
||||
}
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
fn update_string(digest: &mut Sha256, value: &str) {
|
||||
digest.update((value.len() as u32).to_le_bytes());
|
||||
digest.update(value.as_bytes());
|
||||
}
|
||||
|
||||
fn frame_source_code(value: FrameSource) -> u8 {
|
||||
match value {
|
||||
FrameSource::Live => 1,
|
||||
FrameSource::Replay => 2,
|
||||
FrameSource::Synthetic => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence_level_code(value: EvidenceLevel) -> u8 {
|
||||
match value {
|
||||
EvidenceLevel::L0Synthetic => 0,
|
||||
EvidenceLevel::L1Measured => 1,
|
||||
EvidenceLevel::L2Calibrated => 2,
|
||||
EvidenceLevel::L3Corroborated => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn transient_kind_code(value: crate::protocol::TransientKind) -> u8 {
|
||||
match value {
|
||||
crate::protocol::TransientKind::RawHistogram => 1,
|
||||
crate::protocol::TransientKind::CompactNormalizedHistogram => 2,
|
||||
crate::protocol::TransientKind::DepthOnly => 3,
|
||||
crate::protocol::TransientKind::Replay => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame ready for motion-induced aperture likelihood evaluation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PreprocessedFrame {
|
||||
/// Input sequence.
|
||||
pub sequence: u64,
|
||||
/// UTC capture time.
|
||||
pub captured_at_unix_ms: u64,
|
||||
/// Monotonic capture time used for motion compensation.
|
||||
pub monotonic_ns: u64,
|
||||
/// Source label.
|
||||
pub source: FrameSource,
|
||||
/// Evidence level.
|
||||
pub evidence_level: crate::protocol::EvidenceLevel,
|
||||
/// Sensor pose used for this aperture sample.
|
||||
pub sensor_pose: SensorPose,
|
||||
/// Relay wall samples transformed into world coordinates.
|
||||
pub wall_points_world_m: Vec<Vec3>,
|
||||
/// Uniform squared-distance histogram, row-major by zone and bin.
|
||||
pub light_cone_histograms: Vec<f32>,
|
||||
/// Number of zones.
|
||||
pub zone_count: usize,
|
||||
/// Number of squared-distance bins.
|
||||
pub bin_count: usize,
|
||||
/// Native timing width in picoseconds.
|
||||
pub bin_width_ps: f32,
|
||||
/// Foreground-to-noise quality estimate.
|
||||
pub signal_quality: f32,
|
||||
}
|
||||
|
||||
/// Calibration or preprocessing failure.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CalibrationError {
|
||||
/// Too few or too many background frames.
|
||||
#[error("background calibration requires 2..=max_background_frames frames")]
|
||||
FrameCount,
|
||||
/// Invalid pulse or early-bin mask configuration.
|
||||
#[error("invalid calibration configuration")]
|
||||
InvalidConfig,
|
||||
/// Frames differ in session, shape, timing, or calibration.
|
||||
#[error("inconsistent transient frames")]
|
||||
InconsistentFrames,
|
||||
/// Calibration frames repeated or moved backwards.
|
||||
#[error("calibration frames must have strictly increasing sequence and monotonic time")]
|
||||
OutOfOrderFrames,
|
||||
/// A measured frame names a different calibration digest.
|
||||
#[error("transient frame is bound to a different calibration")]
|
||||
WrongCalibration,
|
||||
/// The supposedly immutable calibration payload no longer matches its digest.
|
||||
#[error("calibration integrity digest mismatch")]
|
||||
IntegrityMismatch,
|
||||
/// Sensor pose or relay geometry moved outside the calibrated operating volume.
|
||||
#[error("transient geometry is outside the calibrated operating volume")]
|
||||
GeometryDrift,
|
||||
/// Raw frame contract violation.
|
||||
#[error(transparent)]
|
||||
Contract(#[from] crate::protocol::ContractError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulator::SyntheticScene;
|
||||
|
||||
#[test]
|
||||
fn calibration_is_deterministic_and_foreground_survives() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let background = scene.background_frames(10);
|
||||
let a = Calibration::from_background(&background, CalibrationConfig::default()).unwrap();
|
||||
let b = Calibration::from_background(&background, CalibrationConfig::default()).unwrap();
|
||||
assert_eq!(a.hash, b.hash);
|
||||
let frame = scene.frame(Some(Vec3::new(0.2, 0.1, 1.0)), 1.0, 20);
|
||||
let processed = a.preprocess(&frame).unwrap();
|
||||
assert!(processed.signal_quality > 0.0);
|
||||
assert_eq!(
|
||||
processed.light_cone_histograms.len(),
|
||||
a.zone_count * a.bin_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_rejects_duplicate_and_mixed_provenance_frames() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let background = scene.background_frames(3);
|
||||
let duplicate = vec![background[0].clone(), background[0].clone()];
|
||||
assert!(matches!(
|
||||
Calibration::from_background(&duplicate, CalibrationConfig::default()),
|
||||
Err(CalibrationError::OutOfOrderFrames)
|
||||
));
|
||||
|
||||
let mut mixed = background[..2].to_vec();
|
||||
mixed[1].provenance.sensor_id = "another-sensor".into();
|
||||
assert!(matches!(
|
||||
Calibration::from_background(&mixed, CalibrationConfig::default()),
|
||||
Err(CalibrationError::InconsistentFrames)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_hash_uses_unambiguous_length_prefixed_identity() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let mut left = scene.background_frames(2);
|
||||
for frame in &mut left {
|
||||
frame.provenance.sensor_id = "ab".into();
|
||||
frame.provenance.sensor_model = "c".into();
|
||||
}
|
||||
let mut right = left.clone();
|
||||
for frame in &mut right {
|
||||
frame.provenance.sensor_id = "a".into();
|
||||
frame.provenance.sensor_model = "bc".into();
|
||||
}
|
||||
let left = Calibration::from_background(&left, CalibrationConfig::default()).unwrap();
|
||||
let right = Calibration::from_background(&right, CalibrationConfig::default()).unwrap();
|
||||
assert_ne!(left.hash, right.hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessing_rejects_tampered_calibration_and_geometry_drift() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let background = scene.background_frames(3);
|
||||
let calibration =
|
||||
Calibration::from_background(&background, CalibrationConfig::default()).unwrap();
|
||||
let frame = scene.frame(Some(Vec3::new(0.0, 0.0, 1.0)), 1.0, 10);
|
||||
|
||||
let mut tampered = calibration.clone();
|
||||
tampered.background[0] += 1.0;
|
||||
assert!(matches!(
|
||||
tampered.preprocess(&frame),
|
||||
Err(CalibrationError::IntegrityMismatch)
|
||||
));
|
||||
|
||||
let mut drifted = frame;
|
||||
drifted.sensor_pose.translation_m.x += 1.0;
|
||||
assert!(matches!(
|
||||
calibration.preprocess(&drifted),
|
||||
Err(CalibrationError::GeometryDrift)
|
||||
));
|
||||
}
|
||||
}
|
||||
231
v2/crates/ruview-nlos/src/fusion.rs
Normal file
231
v2/crates/ruview-nlos/src/fusion.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! Calibrated CSI spatial prior for optical NLOS particle fusion.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::protocol::{EvidenceLevel, FrameSource, Vec3};
|
||||
|
||||
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
|
||||
/// Exact policy and coordinate scope required before two modalities may be
|
||||
/// joined. The v1 track envelope cannot retain this complete lineage, so the
|
||||
/// current tracker uses the binding only for L0 synthetic architecture tests
|
||||
/// and rejects measured fusion until a lineage-preserving wire revision lands.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FusionScope {
|
||||
/// Owning tenant.
|
||||
pub tenant_id: String,
|
||||
/// Owning workspace.
|
||||
pub workspace_id: String,
|
||||
/// Physical deployment site.
|
||||
pub site_id: String,
|
||||
/// Calibrated common coordinate frame.
|
||||
pub world_frame_id: String,
|
||||
/// Capture session shared by both modalities.
|
||||
pub session_id: String,
|
||||
/// Digest of the accepted CSI-to-world coordinate transform.
|
||||
pub coordinate_transform_hash: String,
|
||||
}
|
||||
|
||||
impl FusionScope {
|
||||
/// Validate bounded identifiers and the non-zero transform digest.
|
||||
pub fn validate(&self) -> Result<(), FusionError> {
|
||||
for value in [
|
||||
&self.tenant_id,
|
||||
&self.workspace_id,
|
||||
&self.site_id,
|
||||
&self.world_frame_id,
|
||||
&self.session_id,
|
||||
] {
|
||||
if !valid_label(value) {
|
||||
return Err(FusionError::InvalidBinding);
|
||||
}
|
||||
}
|
||||
if !valid_hash(&self.coordinate_transform_hash)
|
||||
|| self.coordinate_transform_hash == "0".repeat(64)
|
||||
{
|
||||
return Err(FusionError::InvalidBinding);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A coarse RF spatial prior. CSI is never treated as centimetre-scale ground
|
||||
/// truth; its covariance and confidence explicitly bound its influence.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CsiSpatialPrior {
|
||||
/// CSI source classification.
|
||||
pub source: FrameSource,
|
||||
/// Monotonic sequence within the CSI capture session.
|
||||
pub sequence: u64,
|
||||
/// UTC time of the RF observation.
|
||||
pub captured_at_unix_ms: u64,
|
||||
/// Tenant/session/world-frame binding for the temporal-spatial join.
|
||||
pub scope: FusionScope,
|
||||
/// Coarse region mean in the NLOS world frame.
|
||||
pub mean_m: Vec3,
|
||||
/// Diagonal covariance in square metres.
|
||||
pub covariance_diagonal_m2: Vec3,
|
||||
/// Bounded RF confidence.
|
||||
pub confidence: f32,
|
||||
/// Evidence level of the RF observation.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// Authenticated RF sensor identifier.
|
||||
pub sensor_id: String,
|
||||
/// RF calibration digest.
|
||||
pub calibration_hash: String,
|
||||
}
|
||||
|
||||
impl CsiSpatialPrior {
|
||||
/// Validate all values before the prior can affect optical particles.
|
||||
pub fn validate(&self) -> Result<(), FusionError> {
|
||||
self.scope.validate()?;
|
||||
if self.sequence > MAX_SAFE_INTEGER
|
||||
|| self.captured_at_unix_ms > MAX_SAFE_INTEGER
|
||||
|| !self.mean_m.finite()
|
||||
|| [self.mean_m.x, self.mean_m.y, self.mean_m.z]
|
||||
.iter()
|
||||
.any(|value| value.abs() > 100.0)
|
||||
|| !self.covariance_diagonal_m2.finite()
|
||||
|| [
|
||||
self.covariance_diagonal_m2.x,
|
||||
self.covariance_diagonal_m2.y,
|
||||
self.covariance_diagonal_m2.z,
|
||||
]
|
||||
.iter()
|
||||
.any(|value| !(0.0025..=25.0).contains(value))
|
||||
|| !self.confidence.is_finite()
|
||||
|| !(0.0..=1.0).contains(&self.confidence)
|
||||
{
|
||||
return Err(FusionError::InvalidPrior);
|
||||
}
|
||||
if !valid_label(&self.sensor_id) || !valid_hash(&self.calibration_hash) {
|
||||
return Err(FusionError::InvalidPrior);
|
||||
}
|
||||
if (self.source == FrameSource::Synthetic)
|
||||
!= (self.evidence_level == EvidenceLevel::L0Synthetic)
|
||||
|| (self.source == FrameSource::Synthetic && self.calibration_hash != "0".repeat(64))
|
||||
{
|
||||
return Err(FusionError::EvidenceMismatch);
|
||||
}
|
||||
if self.source != FrameSource::Synthetic
|
||||
&& self.evidence_level == EvidenceLevel::L0Synthetic
|
||||
{
|
||||
return Err(FusionError::EvidenceMismatch);
|
||||
}
|
||||
if self.evidence_level >= EvidenceLevel::L2Calibrated
|
||||
&& self.calibration_hash == "0".repeat(64)
|
||||
{
|
||||
return Err(FusionError::EvidenceMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn log_likelihood(&self, point: Vec3) -> f32 {
|
||||
let d = point.minus(self.mean_m);
|
||||
let mahalanobis = d.x * d.x / self.covariance_diagonal_m2.x
|
||||
+ d.y * d.y / self.covariance_diagonal_m2.y
|
||||
+ d.z * d.z / self.covariance_diagonal_m2.z;
|
||||
-0.5 * mahalanobis * self.confidence
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_label(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':'))
|
||||
}
|
||||
|
||||
fn valid_hash(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
/// CSI fusion boundary failure.
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum FusionError {
|
||||
/// A field was non-finite, out of bounds, or malformed.
|
||||
#[error("invalid CSI spatial prior")]
|
||||
InvalidPrior,
|
||||
/// Synthetic evidence carried a non-synthetic calibration identity.
|
||||
#[error("CSI source and evidence labels disagree")]
|
||||
EvidenceMismatch,
|
||||
/// The two modalities do not share the exact authorized join scope.
|
||||
#[error("CSI and optical fusion bindings do not match")]
|
||||
BindingMismatch,
|
||||
/// Measured fusion is blocked until the output contract retains both
|
||||
/// modality lineages and the deployment supplies authenticated bindings.
|
||||
#[error("measured CSI fusion is unavailable in the v1 evidence contract")]
|
||||
MeasuredFusionUnavailable,
|
||||
/// A fusion binding is malformed or missing required identity.
|
||||
#[error("invalid fusion scope binding")]
|
||||
InvalidBinding,
|
||||
/// A CSI observation was repeated or moved backwards.
|
||||
#[error("replayed or out-of-order CSI prior")]
|
||||
ReplayOrOutOfOrder,
|
||||
/// Prior and optical frame are too far apart in time.
|
||||
#[error("CSI prior is stale")]
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calibrated_prior_prefers_nearby_particle() {
|
||||
let p = CsiSpatialPrior {
|
||||
source: FrameSource::Live,
|
||||
sequence: 1,
|
||||
captured_at_unix_ms: 1,
|
||||
scope: FusionScope {
|
||||
tenant_id: "tenant-1".into(),
|
||||
workspace_id: "workspace-1".into(),
|
||||
site_id: "site-1".into(),
|
||||
world_frame_id: "world-1".into(),
|
||||
session_id: "session-1".into(),
|
||||
coordinate_transform_hash: "b".repeat(64),
|
||||
},
|
||||
mean_m: Vec3::new(0.0, 0.0, 1.0),
|
||||
covariance_diagonal_m2: Vec3::new(0.04, 0.04, 0.09),
|
||||
confidence: 0.8,
|
||||
evidence_level: EvidenceLevel::L2Calibrated,
|
||||
sensor_id: "csi-1".into(),
|
||||
calibration_hash: "a".repeat(64),
|
||||
};
|
||||
p.validate().unwrap();
|
||||
assert!(
|
||||
p.log_likelihood(Vec3::new(0.01, 0.0, 1.0))
|
||||
> p.log_likelihood(Vec3::new(1.0, 0.0, 1.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prior_rejects_synthetic_evidence_on_a_live_source() {
|
||||
let mut p = CsiSpatialPrior {
|
||||
source: FrameSource::Live,
|
||||
sequence: 1,
|
||||
captured_at_unix_ms: 1,
|
||||
scope: FusionScope {
|
||||
tenant_id: "tenant-1".into(),
|
||||
workspace_id: "workspace-1".into(),
|
||||
site_id: "site-1".into(),
|
||||
world_frame_id: "world-1".into(),
|
||||
session_id: "session-1".into(),
|
||||
coordinate_transform_hash: "b".repeat(64),
|
||||
},
|
||||
mean_m: Vec3::new(0.0, 0.0, 1.0),
|
||||
covariance_diagonal_m2: Vec3::new(0.04, 0.04, 0.09),
|
||||
confidence: 0.8,
|
||||
evidence_level: EvidenceLevel::L0Synthetic,
|
||||
sensor_id: "csi-1".into(),
|
||||
calibration_hash: "0".repeat(64),
|
||||
};
|
||||
assert_eq!(p.validate(), Err(FusionError::EvidenceMismatch));
|
||||
p.source = FrameSource::Synthetic;
|
||||
assert!(p.validate().is_ok());
|
||||
}
|
||||
}
|
||||
397
v2/crates/ruview-nlos/src/ingest.rs
Normal file
397
v2/crates/ruview-nlos/src/ingest.rs
Normal file
@@ -0,0 +1,397 @@
|
||||
//! Bounded decoder for the public VL53L8CH STM32 ASCII stream.
|
||||
//!
|
||||
//! The upstream firmware emits one row per zone:
|
||||
//! `zone ambient distance_mm bin0 ... binN`. This decoder accumulates exactly
|
||||
//! one unique row per zone and never mixes a malformed partial frame into the
|
||||
//! next frame.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::protocol::{
|
||||
EvidenceLevel, FrameSource, Provenance, SensorPose, TransientFrame, TransientZone, Vec3,
|
||||
};
|
||||
use crate::{MAX_BINS, MAX_ZONES, TRANSIENT_SCHEMA_V1};
|
||||
|
||||
/// Static configuration sent to and expected from the ST firmware.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StDecoderConfig {
|
||||
/// Capture session identifier.
|
||||
pub session_id: String,
|
||||
/// Grid height.
|
||||
pub height: u16,
|
||||
/// Grid width.
|
||||
pub width: u16,
|
||||
/// Histogram bins per zone.
|
||||
pub num_bins: u16,
|
||||
/// First firmware bin retained.
|
||||
pub start_bin: u16,
|
||||
/// Timing resolution in picoseconds.
|
||||
pub bin_width_ps: f32,
|
||||
/// Horizontal field of view in degrees.
|
||||
pub fov_x_degrees: f32,
|
||||
/// Vertical field of view in degrees.
|
||||
pub fov_y_degrees: f32,
|
||||
/// Add the reported ambient value back to every compact-normalized bin.
|
||||
pub add_back_ambient: bool,
|
||||
/// Require the upstream firmware's `D` frame-start marker.
|
||||
pub require_frame_marker: bool,
|
||||
/// Input source.
|
||||
pub source: FrameSource,
|
||||
/// Evidence level attached at ingest.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// SHA-256 calibration digest or 64 zeroes while calibrating.
|
||||
pub calibration_hash: String,
|
||||
/// Sensor provenance.
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
impl StDecoderConfig {
|
||||
fn validate(&self) -> Result<(), DecodeError> {
|
||||
let zones = usize::from(self.height) * usize::from(self.width);
|
||||
if self.height == 0
|
||||
|| self.width == 0
|
||||
|| zones > MAX_ZONES
|
||||
|| !(8..=MAX_BINS).contains(&usize::from(self.num_bins))
|
||||
{
|
||||
return Err(DecodeError::InvalidConfiguration);
|
||||
}
|
||||
if !self.bin_width_ps.is_finite()
|
||||
|| self.bin_width_ps <= 0.0
|
||||
|| !self.fov_x_degrees.is_finite()
|
||||
|| !self.fov_y_degrees.is_finite()
|
||||
|| !(1.0..=120.0).contains(&self.fov_x_degrees)
|
||||
|| !(1.0..=120.0).contains(&self.fov_y_degrees)
|
||||
{
|
||||
return Err(DecodeError::InvalidConfiguration);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental decoder for a trusted local serial device.
|
||||
#[derive(Debug)]
|
||||
pub struct StAsciiDecoder {
|
||||
config: StDecoderConfig,
|
||||
rows: BTreeMap<u16, ParsedRow>,
|
||||
next_sequence: u64,
|
||||
began_frame: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedRow {
|
||||
ambient: u32,
|
||||
distance_m: f32,
|
||||
histogram: Vec<u16>,
|
||||
}
|
||||
|
||||
impl StAsciiDecoder {
|
||||
/// Create a decoder after checking all configured dimensions.
|
||||
pub fn new(config: StDecoderConfig) -> Result<Self, DecodeError> {
|
||||
config.validate()?;
|
||||
Ok(Self {
|
||||
config,
|
||||
rows: BTreeMap::new(),
|
||||
next_sequence: 0,
|
||||
began_frame: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Consume one firmware line and return a complete frame when all zones arrive.
|
||||
/// A malformed or duplicate row clears the partial frame before returning an
|
||||
/// error, preventing cross-frame row splicing.
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
line: &str,
|
||||
captured_at_unix_ms: u64,
|
||||
monotonic_ns: u64,
|
||||
sensor_pose: SensorPose,
|
||||
) -> Result<Option<TransientFrame>, DecodeError> {
|
||||
if line.trim() == "D" {
|
||||
self.rows.clear();
|
||||
self.began_frame = true;
|
||||
return Ok(None);
|
||||
}
|
||||
if self.config.require_frame_marker && !self.began_frame {
|
||||
// Firmware boot messages and stale rows before the next marker are
|
||||
// deliberately ignored rather than incorporated into a frame.
|
||||
return Ok(None);
|
||||
}
|
||||
if line.len() > 4_096 || line.bytes().any(|b| b == 0) {
|
||||
self.rows.clear();
|
||||
self.began_frame = false;
|
||||
return Err(DecodeError::MalformedRow);
|
||||
}
|
||||
let fields: Vec<&str> = line.split_ascii_whitespace().collect();
|
||||
if fields.len() != usize::from(self.config.num_bins) + 3 {
|
||||
self.rows.clear();
|
||||
self.began_frame = false;
|
||||
return Err(DecodeError::MalformedRow);
|
||||
}
|
||||
macro_rules! parse_or_reset {
|
||||
($raw:expr, $kind:ty) => {
|
||||
match parse::<$kind>($raw) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
self.rows.clear();
|
||||
self.began_frame = false;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
let zone_id = parse_or_reset!(fields[0], u16);
|
||||
let zone_count = self.config.height * self.config.width;
|
||||
if zone_id >= zone_count || self.rows.contains_key(&zone_id) {
|
||||
self.rows.clear();
|
||||
self.began_frame = false;
|
||||
return Err(DecodeError::InvalidZone);
|
||||
}
|
||||
let ambient = parse_or_reset!(fields[1], u32);
|
||||
let distance_mm = parse_or_reset!(fields[2], f32);
|
||||
if !distance_mm.is_finite() || !(10.0..=10_000.0).contains(&distance_mm) {
|
||||
self.rows.clear();
|
||||
self.began_frame = false;
|
||||
return Err(DecodeError::InvalidDistance);
|
||||
}
|
||||
let mut histogram = Vec::with_capacity(usize::from(self.config.num_bins));
|
||||
for raw in &fields[3..] {
|
||||
let mut count = parse_or_reset!(raw, i64);
|
||||
if self.config.add_back_ambient {
|
||||
let Some(combined) = count.checked_add(i64::from(ambient)) else {
|
||||
self.rows.clear();
|
||||
self.began_frame = false;
|
||||
return Err(DecodeError::HistogramOverflow);
|
||||
};
|
||||
count = combined;
|
||||
}
|
||||
if count > i64::from(u16::MAX) {
|
||||
self.rows.clear();
|
||||
self.began_frame = false;
|
||||
return Err(DecodeError::HistogramOverflow);
|
||||
}
|
||||
// The pinned public driver clips negative compact-normalized bins
|
||||
// to zero. Positive overflow is rejected rather than saturated.
|
||||
histogram.push(count.max(0) as u16);
|
||||
}
|
||||
self.rows.insert(
|
||||
zone_id,
|
||||
ParsedRow {
|
||||
ambient,
|
||||
distance_m: distance_mm / 1_000.0,
|
||||
histogram,
|
||||
},
|
||||
);
|
||||
if self.rows.len() != usize::from(zone_count) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let rows = std::mem::take(&mut self.rows);
|
||||
self.began_frame = false;
|
||||
let mut zones = Vec::with_capacity(rows.len());
|
||||
for (zone_id, row) in rows {
|
||||
zones.push(TransientZone {
|
||||
zone_id,
|
||||
wall_point_m: point_from_zone(
|
||||
zone_id,
|
||||
row.distance_m,
|
||||
self.config.height,
|
||||
self.config.width,
|
||||
self.config.fov_x_degrees,
|
||||
self.config.fov_y_degrees,
|
||||
),
|
||||
distance_m: row.distance_m,
|
||||
ambient: row.ambient,
|
||||
histogram: row.histogram,
|
||||
});
|
||||
}
|
||||
let frame = TransientFrame {
|
||||
schema: TRANSIENT_SCHEMA_V1.into(),
|
||||
session_id: self.config.session_id.clone(),
|
||||
sequence: self.next_sequence,
|
||||
captured_at_unix_ms,
|
||||
monotonic_ns,
|
||||
source: self.config.source,
|
||||
evidence_level: self.config.evidence_level,
|
||||
bin_width_ps: self.config.bin_width_ps,
|
||||
start_bin: self.config.start_bin,
|
||||
sensor_pose,
|
||||
calibration_hash: self.config.calibration_hash.clone(),
|
||||
provenance: self.config.provenance.clone(),
|
||||
zones,
|
||||
};
|
||||
frame.validate().map_err(DecodeError::Contract)?;
|
||||
self.next_sequence += 1;
|
||||
Ok(Some(frame))
|
||||
}
|
||||
|
||||
/// Encode the 13 little-endian `uint16` values expected by the upstream
|
||||
/// STM32 firmware. Network or serial I/O remains outside this pure function.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn firmware_config_bytes(
|
||||
&self,
|
||||
ranging_mode: u16,
|
||||
ranging_frequency_hz: u16,
|
||||
integration_time_ms: u16,
|
||||
subsample: u16,
|
||||
) -> [u8; 26] {
|
||||
let values = [
|
||||
self.config.height * self.config.width,
|
||||
ranging_mode,
|
||||
ranging_frequency_hz,
|
||||
integration_time_ms,
|
||||
self.config.start_bin,
|
||||
self.config.num_bins,
|
||||
subsample,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
];
|
||||
let mut bytes = [0_u8; 26];
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
bytes[index * 2..index * 2 + 2].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn parse<T: std::str::FromStr>(raw: &str) -> Result<T, DecodeError> {
|
||||
raw.parse().map_err(|_| DecodeError::MalformedRow)
|
||||
}
|
||||
|
||||
fn point_from_zone(
|
||||
zone_id: u16,
|
||||
distance_m: f32,
|
||||
height: u16,
|
||||
width: u16,
|
||||
fov_x_degrees: f32,
|
||||
fov_y_degrees: f32,
|
||||
) -> Vec3 {
|
||||
let row = f32::from(zone_id / width) + 0.5;
|
||||
let col = f32::from(zone_id % width) + 0.5;
|
||||
let yaw = (col / f32::from(width) - 0.5) * fov_x_degrees.to_radians();
|
||||
let pitch = (row / f32::from(height) - 0.5) * fov_y_degrees.to_radians();
|
||||
let cp = pitch.cos();
|
||||
Vec3::new(
|
||||
distance_m * cp * yaw.sin(),
|
||||
distance_m * pitch.sin(),
|
||||
distance_m * cp * yaw.cos(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serial framing failure.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DecodeError {
|
||||
/// Static configuration exceeds the supported sensor bounds.
|
||||
#[error("invalid ST decoder configuration")]
|
||||
InvalidConfiguration,
|
||||
/// A row had the wrong length or a non-numeric token.
|
||||
#[error("malformed ST histogram row")]
|
||||
MalformedRow,
|
||||
/// Zone id was out of range or duplicated.
|
||||
#[error("invalid or duplicate ST zone")]
|
||||
InvalidZone,
|
||||
/// Direct wall distance was outside 1 cm to 10 m.
|
||||
#[error("invalid ST wall distance")]
|
||||
InvalidDistance,
|
||||
/// A normalized histogram value could not be represented losslessly.
|
||||
#[error("ST histogram value exceeds the u16 contract")]
|
||||
HistogramOverflow,
|
||||
/// The assembled frame violated the shared contract.
|
||||
#[error(transparent)]
|
||||
Contract(#[from] crate::protocol::ContractError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::{Provenance, TransientKind};
|
||||
|
||||
fn decoder() -> StAsciiDecoder {
|
||||
StAsciiDecoder::new(StDecoderConfig {
|
||||
session_id: "session-1".into(),
|
||||
height: 2,
|
||||
width: 2,
|
||||
num_bins: 8,
|
||||
start_bin: 30,
|
||||
bin_width_ps: 250.0,
|
||||
fov_x_degrees: 45.0,
|
||||
fov_y_degrees: 45.0,
|
||||
add_back_ambient: false,
|
||||
require_frame_marker: true,
|
||||
source: FrameSource::Live,
|
||||
evidence_level: EvidenceLevel::L1Measured,
|
||||
calibration_hash: "0".repeat(64),
|
||||
provenance: Provenance {
|
||||
sensor_id: "st-01".into(),
|
||||
sensor_model: "VL53L8CH".into(),
|
||||
firmware_version: "test".into(),
|
||||
transient_kind: TransientKind::CompactNormalizedHistogram,
|
||||
histogram_preserved: true,
|
||||
transport: "usb_serial".into(),
|
||||
},
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_one_complete_frame_without_flattening_histograms() {
|
||||
let mut d = decoder();
|
||||
d.push_line("D", 100, 200, SensorPose::default()).unwrap();
|
||||
for zone in 0..4 {
|
||||
let result = d
|
||||
.push_line(
|
||||
&format!("{zone} 3 800 0 1 2 3 4 5 6 7"),
|
||||
100,
|
||||
200,
|
||||
SensorPose::default(),
|
||||
)
|
||||
.unwrap();
|
||||
if zone < 3 {
|
||||
assert!(result.is_none());
|
||||
} else {
|
||||
let frame = result.unwrap();
|
||||
assert_eq!(frame.zones.len(), 4);
|
||||
assert_eq!(frame.zones[0].histogram, vec![0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
assert_eq!(frame.sequence, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_row_resets_partial_frame() {
|
||||
let mut d = decoder();
|
||||
d.push_line("D", 1, 1, SensorPose::default()).unwrap();
|
||||
d.push_line("0 3 800 0 1 2 3 4 5 6 7", 1, 1, SensorPose::default())
|
||||
.unwrap();
|
||||
assert!(d.push_line("bad", 1, 1, SensorPose::default()).is_err());
|
||||
d.push_line("D", 1, 1, SensorPose::default()).unwrap();
|
||||
assert!(d
|
||||
.push_line("1 3 800 0 1 2 3 4 5 6 7", 1, 1, SensorPose::default(),)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_histogram_overflow_is_rejected_not_saturated() {
|
||||
let mut d = decoder();
|
||||
d.push_line("D", 1, 1, SensorPose::default()).unwrap();
|
||||
assert!(matches!(
|
||||
d.push_line("0 3 800 70000 1 2 3 4 5 6 7", 1, 1, SensorPose::default()),
|
||||
Err(DecodeError::HistogramOverflow)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_packet_matches_upstream_little_endian_layout() {
|
||||
let bytes = decoder().firmware_config_bytes(1, 30, 10, 1);
|
||||
assert_eq!(&bytes[0..2], &4_u16.to_le_bytes());
|
||||
assert_eq!(&bytes[8..10], &30_u16.to_le_bytes());
|
||||
assert_eq!(&bytes[24..26], &2_u16.to_le_bytes());
|
||||
}
|
||||
}
|
||||
45
v2/crates/ruview-nlos/src/lib.rs
Normal file
45
v2/crates/ruview-nlos/src/lib.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Consumer time-of-flight non-line-of-sight sensing for RuView.
|
||||
//!
|
||||
//! This crate preserves the zone-level photon timing histograms needed by
|
||||
//! motion-induced aperture sampling. It deliberately rejects ordinary depth
|
||||
//! maps as live NLOS evidence. The deterministic simulator and its benchmarks
|
||||
//! are evidence level L0 (synthetic); only a captured, witnessed hardware run
|
||||
//! can satisfy the ADR-331 reproduction gate.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod bridge;
|
||||
pub mod calibration;
|
||||
pub mod fusion;
|
||||
pub mod ingest;
|
||||
pub mod protocol;
|
||||
pub mod simulator;
|
||||
pub mod tracker;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
|
||||
pub use bridge::{RuFieldObservation, TemporalFeatureMemory, WorldGraphUpdate};
|
||||
pub use calibration::{Calibration, CalibrationConfig, PreprocessedFrame};
|
||||
pub use fusion::{CsiSpatialPrior, FusionScope};
|
||||
pub use ingest::{StAsciiDecoder, StDecoderConfig};
|
||||
pub use protocol::{
|
||||
EvidenceLevel, FrameSource, NlosTrack, Provenance, SensorPose, TrackEnvelope, TrackState,
|
||||
TransientFrame, TransientKind, TransientZone, Vec3,
|
||||
};
|
||||
pub use simulator::{BenchmarkReport, SyntheticScene};
|
||||
pub use tracker::{CanonicalObject, MotionApertureTracker, TrackerConfig};
|
||||
|
||||
/// Transient input schema identifier.
|
||||
pub const TRANSIENT_SCHEMA_V1: &str = "ruview.nlos.transient.v1";
|
||||
/// Track output schema identifier shared by Rust, Swift, and TypeScript.
|
||||
pub const TRACK_SCHEMA_V1: &str = "ruview.nlos.track.v1";
|
||||
/// Maximum JSON frame accepted by network clients.
|
||||
pub const MAX_WIRE_BYTES: usize = 256 * 1024;
|
||||
/// Maximum histogram zones accepted from one consumer sensor.
|
||||
pub const MAX_ZONES: usize = 64;
|
||||
/// Maximum temporal bins accepted per zone.
|
||||
pub const MAX_BINS: usize = 128;
|
||||
/// Maximum simultaneous hidden tracks on the public contract.
|
||||
pub const MAX_TRACKS: usize = 16;
|
||||
688
v2/crates/ruview-nlos/src/protocol.rs
Normal file
688
v2/crates/ruview-nlos/src/protocol.rs
Normal file
@@ -0,0 +1,688 @@
|
||||
//! Bounded transient and track wire contracts (ADR-328, ADR-331).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{MAX_BINS, MAX_TRACKS, MAX_ZONES, TRACK_SCHEMA_V1, TRANSIENT_SCHEMA_V1};
|
||||
|
||||
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
const MAX_POSITION_M: f32 = 100.0;
|
||||
const MAX_VELOCITY_MPS: f32 = 20.0;
|
||||
const MAX_COVARIANCE_M2: f32 = 10.0;
|
||||
const MAX_EXPIRY_WINDOW_MS: u64 = 5_000;
|
||||
|
||||
/// A finite Cartesian vector in metres or metres per second.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Vec3 {
|
||||
/// X component.
|
||||
pub x: f32,
|
||||
/// Y component.
|
||||
pub y: f32,
|
||||
/// Z component.
|
||||
pub z: f32,
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
/// Construct a vector.
|
||||
#[must_use]
|
||||
pub const fn new(x: f32, y: f32, z: f32) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
|
||||
/// Euclidean distance.
|
||||
#[must_use]
|
||||
pub fn distance(self, other: Self) -> f32 {
|
||||
let d = self.minus(other);
|
||||
(d.x * d.x + d.y * d.y + d.z * d.z).sqrt()
|
||||
}
|
||||
|
||||
/// Component-wise addition.
|
||||
#[must_use]
|
||||
pub fn plus(self, other: Self) -> Self {
|
||||
Self::new(self.x + other.x, self.y + other.y, self.z + other.z)
|
||||
}
|
||||
|
||||
/// Component-wise subtraction.
|
||||
#[must_use]
|
||||
pub fn minus(self, other: Self) -> Self {
|
||||
Self::new(self.x - other.x, self.y - other.y, self.z - other.z)
|
||||
}
|
||||
|
||||
/// Scalar multiplication.
|
||||
#[must_use]
|
||||
pub fn scale(self, value: f32) -> Self {
|
||||
Self::new(self.x * value, self.y * value, self.z * value)
|
||||
}
|
||||
|
||||
pub(crate) fn finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit quaternion plus translation describing a sensor pose.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SensorPose {
|
||||
/// Sensor origin in world coordinates.
|
||||
pub translation_m: Vec3,
|
||||
/// Quaternion ordered x, y, z, w.
|
||||
pub quaternion_xyzw: [f32; 4],
|
||||
}
|
||||
|
||||
impl Default for SensorPose {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
translation_m: Vec3::default(),
|
||||
quaternion_xyzw: [0.0, 0.0, 0.0, 1.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SensorPose {
|
||||
/// Transform a point from sensor-local to world coordinates.
|
||||
#[must_use]
|
||||
pub fn transform(self, point: Vec3) -> Vec3 {
|
||||
let [qx, qy, qz, qw] = self.quaternion_xyzw;
|
||||
let q = Vec3::new(qx, qy, qz);
|
||||
let t = cross(q, point).scale(2.0);
|
||||
point
|
||||
.plus(t.scale(qw))
|
||||
.plus(cross(q, t))
|
||||
.plus(self.translation_m)
|
||||
}
|
||||
|
||||
/// Validate finite translation and an approximately unit quaternion.
|
||||
pub fn validate(self) -> Result<(), ContractError> {
|
||||
if !self.translation_m.finite()
|
||||
|| self
|
||||
.quaternion_xyzw
|
||||
.iter()
|
||||
.any(|component| !component.is_finite())
|
||||
{
|
||||
return Err(ContractError::NonFinite("sensorPose"));
|
||||
}
|
||||
let norm = self
|
||||
.quaternion_xyzw
|
||||
.iter()
|
||||
.map(|v| v * v)
|
||||
.sum::<f32>()
|
||||
.sqrt();
|
||||
if !(0.99..=1.01).contains(&norm) {
|
||||
return Err(ContractError::InvalidPose);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn cross(a: Vec3, b: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
a.y * b.z - a.z * b.y,
|
||||
a.z * b.x - a.x * b.z,
|
||||
a.x * b.y - a.y * b.x,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a frame came from a live sensor, captured replay, or generator.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FrameSource {
|
||||
/// Authenticated live sensor input.
|
||||
Live,
|
||||
/// Immutable captured data replay.
|
||||
Replay,
|
||||
/// Deterministic generated data; never hardware evidence.
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
/// Evidence ladder shared by all NLOS outputs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvidenceLevel {
|
||||
/// Generated input only.
|
||||
L0Synthetic,
|
||||
/// A measured raw sensor frame with unverified calibration.
|
||||
L1Measured,
|
||||
/// Measured input bound to a valid calibration.
|
||||
L2Calibrated,
|
||||
/// Independently corroborated modality lineages. Ground-truth maturity is
|
||||
/// evaluated separately and is never implied by this wire label.
|
||||
L3Corroborated,
|
||||
}
|
||||
|
||||
/// Exact optical signal exposed by the adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransientKind {
|
||||
/// Raw photon arrival histogram.
|
||||
RawHistogram,
|
||||
/// VL53L8CH compact normalized histogram.
|
||||
CompactNormalizedHistogram,
|
||||
/// Conventional depth map; insufficient for NLOS inversion.
|
||||
DepthOnly,
|
||||
/// Replayed raw or normalized histogram.
|
||||
Replay,
|
||||
}
|
||||
|
||||
/// Transport provenance for a transient or track.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Provenance {
|
||||
/// Authenticated or locally bound sensor identifier.
|
||||
pub sensor_id: String,
|
||||
/// Sensor model, for example `VL53L8CH`.
|
||||
pub sensor_model: String,
|
||||
/// Sensor firmware revision.
|
||||
pub firmware_version: String,
|
||||
/// Signal kind retained by the adapter.
|
||||
pub transient_kind: TransientKind,
|
||||
/// True only when zone timing bins remain available end to end.
|
||||
pub histogram_preserved: bool,
|
||||
/// `usb_serial`, `ruview_server`, or `replay`.
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
impl Provenance {
|
||||
fn validate(&self, source: FrameSource) -> Result<(), ContractError> {
|
||||
validate_label("sensorId", &self.sensor_id)?;
|
||||
validate_label("sensorModel", &self.sensor_model)?;
|
||||
validate_label("firmwareVersion", &self.firmware_version)?;
|
||||
if !matches!(
|
||||
self.transport.as_str(),
|
||||
"usb_serial" | "ruview_server" | "replay"
|
||||
) {
|
||||
return Err(ContractError::InvalidValue("provenance.transport"));
|
||||
}
|
||||
if source == FrameSource::Live
|
||||
&& (!self.histogram_preserved
|
||||
|| matches!(
|
||||
self.transient_kind,
|
||||
TransientKind::DepthOnly | TransientKind::Replay
|
||||
)
|
||||
|| self.transport == "replay")
|
||||
{
|
||||
return Err(ContractError::DepthIsNotNlos);
|
||||
}
|
||||
if source == FrameSource::Synthetic
|
||||
&& (self.transport != "replay" || self.transient_kind != TransientKind::Replay)
|
||||
{
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
if source == FrameSource::Replay
|
||||
&& (self.transport != "replay"
|
||||
|| self.transient_kind != TransientKind::Replay
|
||||
|| !self.histogram_preserved)
|
||||
{
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One SPAD zone with its uncollapsed timing histogram.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct TransientZone {
|
||||
/// Stable zone index within the frame.
|
||||
pub zone_id: u16,
|
||||
/// Relay-wall sample in sensor-local coordinates.
|
||||
pub wall_point_m: Vec3,
|
||||
/// Direct sensor-to-wall distance.
|
||||
pub distance_m: f32,
|
||||
/// Ambient counts reported by the sensor.
|
||||
pub ambient: u32,
|
||||
/// Photon counts by arrival-time bin.
|
||||
pub histogram: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Raw optical transient frame retained before NLOS processing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct TransientFrame {
|
||||
/// Must equal [`TRANSIENT_SCHEMA_V1`].
|
||||
pub schema: String,
|
||||
/// Capture session identity.
|
||||
pub session_id: String,
|
||||
/// Monotonic sequence within the session.
|
||||
pub sequence: u64,
|
||||
/// UTC capture time.
|
||||
pub captured_at_unix_ms: u64,
|
||||
/// Monotonic device time, used for aperture ordering.
|
||||
pub monotonic_ns: u64,
|
||||
/// Live, replay, or synthetic source.
|
||||
pub source: FrameSource,
|
||||
/// Evidence level attached at the ingest boundary.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// Timing-bin width in picoseconds.
|
||||
pub bin_width_ps: f32,
|
||||
/// First physical sensor bin retained by the firmware.
|
||||
pub start_bin: u16,
|
||||
/// Sensor pose for motion-induced aperture accumulation.
|
||||
pub sensor_pose: SensorPose,
|
||||
/// Calibration digest, or 64 zeroes before calibration.
|
||||
pub calibration_hash: String,
|
||||
/// Sensor and transport provenance.
|
||||
pub provenance: Provenance,
|
||||
/// Zone histograms.
|
||||
pub zones: Vec<TransientZone>,
|
||||
}
|
||||
|
||||
impl TransientFrame {
|
||||
/// Validate all untrusted dimensions, numbers, labels, and evidence rules.
|
||||
pub fn validate(&self) -> Result<(), ContractError> {
|
||||
if self.schema != TRANSIENT_SCHEMA_V1 {
|
||||
return Err(ContractError::WrongSchema);
|
||||
}
|
||||
validate_label("sessionId", &self.session_id)?;
|
||||
validate_sequence(self.sequence)?;
|
||||
validate_timestamp(self.captured_at_unix_ms)?;
|
||||
validate_hash(&self.calibration_hash)?;
|
||||
self.sensor_pose.validate()?;
|
||||
self.provenance.validate(self.source)?;
|
||||
if !self.bin_width_ps.is_finite() || !(1.0..=10_000.0).contains(&self.bin_width_ps) {
|
||||
return Err(ContractError::InvalidValue("binWidthPs"));
|
||||
}
|
||||
if self.zones.is_empty() || self.zones.len() > MAX_ZONES {
|
||||
return Err(ContractError::Bound("zones"));
|
||||
}
|
||||
let bins = self.zones[0].histogram.len();
|
||||
if !(8..=MAX_BINS).contains(&bins) {
|
||||
return Err(ContractError::Bound("histogram"));
|
||||
}
|
||||
let mut seen = [false; MAX_ZONES];
|
||||
for (zone_index, zone) in self.zones.iter().enumerate() {
|
||||
let idx = usize::from(zone.zone_id);
|
||||
if idx >= self.zones.len() || seen[idx] || idx != zone_index {
|
||||
return Err(ContractError::InvalidValue("zoneId"));
|
||||
}
|
||||
seen[idx] = true;
|
||||
if zone.histogram.len() != bins {
|
||||
return Err(ContractError::InconsistentBins);
|
||||
}
|
||||
if !zone.wall_point_m.finite()
|
||||
|| !zone.distance_m.is_finite()
|
||||
|| !(0.01..=10.0).contains(&zone.distance_m)
|
||||
{
|
||||
return Err(ContractError::InvalidValue("zone geometry"));
|
||||
}
|
||||
}
|
||||
match (self.source, self.evidence_level) {
|
||||
(FrameSource::Synthetic, EvidenceLevel::L0Synthetic) => {}
|
||||
(FrameSource::Synthetic, _) => return Err(ContractError::EvidenceMismatch),
|
||||
(FrameSource::Live, EvidenceLevel::L0Synthetic) => {
|
||||
return Err(ContractError::EvidenceMismatch)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if self.evidence_level == EvidenceLevel::L3Corroborated {
|
||||
// v1 has no field that can retain both modality lineages.
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
if self.source == FrameSource::Synthetic && self.calibration_hash != "0".repeat(64) {
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
if self.evidence_level >= EvidenceLevel::L2Calibrated
|
||||
&& self.calibration_hash == "0".repeat(64)
|
||||
{
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// State of one hidden target hypothesis.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrackState {
|
||||
/// Posterior passed all quality gates.
|
||||
Tracking,
|
||||
/// Some evidence remains, but quality is below the normal threshold.
|
||||
Degraded,
|
||||
/// No reliable estimate is available.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Relative optical and RF contribution to one posterior.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ModalityContributions {
|
||||
/// Optical transient contribution.
|
||||
pub lidar: f32,
|
||||
/// CSI prior contribution.
|
||||
pub csi: f32,
|
||||
}
|
||||
|
||||
/// One hidden target posterior.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NlosTrack {
|
||||
/// Privacy-preserving session-local identifier.
|
||||
pub track_id: String,
|
||||
/// Quality-gated state.
|
||||
pub state: TrackState,
|
||||
/// Posterior mean in metres.
|
||||
pub position_m: Vec3,
|
||||
/// Estimated velocity.
|
||||
pub velocity_mps: Vec3,
|
||||
/// Diagonal covariance in square metres.
|
||||
pub covariance_diagonal_m2: Vec3,
|
||||
/// Bounded posterior confidence.
|
||||
pub confidence: f32,
|
||||
/// Shannon entropy of normalized particle weights.
|
||||
pub posterior_entropy: f32,
|
||||
/// Bounded optical/RF signal quality.
|
||||
pub signal_quality: f32,
|
||||
/// Relative modality contributions.
|
||||
pub modality_contributions: ModalityContributions,
|
||||
}
|
||||
|
||||
/// Public track envelope shared with Swift and TypeScript clients.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct TrackEnvelope {
|
||||
/// Must equal [`TRACK_SCHEMA_V1`].
|
||||
pub schema: String,
|
||||
/// Capture session identity.
|
||||
pub session_id: String,
|
||||
/// Monotonic sequence within the session.
|
||||
pub sequence: u64,
|
||||
/// UTC capture time.
|
||||
pub captured_at_unix_ms: u64,
|
||||
/// Hard expiry; clients fail closed after this time.
|
||||
pub expires_at_unix_ms: u64,
|
||||
/// Source label.
|
||||
pub source: FrameSource,
|
||||
/// Evidence level.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// Reproducible algorithm revision.
|
||||
pub algorithm_version: String,
|
||||
/// Calibration SHA-256 digest.
|
||||
pub calibration_hash: String,
|
||||
/// Sensor and transport provenance.
|
||||
pub provenance: Provenance,
|
||||
/// Bounded hidden target hypotheses.
|
||||
pub tracks: Vec<NlosTrack>,
|
||||
}
|
||||
|
||||
impl TrackEnvelope {
|
||||
/// Validate a track frame received from an untrusted server or replay.
|
||||
pub fn validate(&self) -> Result<(), ContractError> {
|
||||
if self.schema != TRACK_SCHEMA_V1 {
|
||||
return Err(ContractError::WrongSchema);
|
||||
}
|
||||
validate_label("sessionId", &self.session_id)?;
|
||||
validate_label("algorithmVersion", &self.algorithm_version)?;
|
||||
validate_sequence(self.sequence)?;
|
||||
validate_timestamp(self.captured_at_unix_ms)?;
|
||||
validate_timestamp(self.expires_at_unix_ms)?;
|
||||
validate_hash(&self.calibration_hash)?;
|
||||
self.provenance.validate(self.source)?;
|
||||
if self.expires_at_unix_ms <= self.captured_at_unix_ms
|
||||
|| self.expires_at_unix_ms - self.captured_at_unix_ms > MAX_EXPIRY_WINDOW_MS
|
||||
{
|
||||
return Err(ContractError::InvalidExpiry);
|
||||
}
|
||||
if self.tracks.len() > MAX_TRACKS {
|
||||
return Err(ContractError::Bound("tracks"));
|
||||
}
|
||||
if self.source == FrameSource::Synthetic
|
||||
&& self.evidence_level != EvidenceLevel::L0Synthetic
|
||||
{
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
if self.source == FrameSource::Synthetic && self.calibration_hash != "0".repeat(64) {
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
if self.evidence_level >= EvidenceLevel::L2Calibrated
|
||||
&& self.calibration_hash == "0".repeat(64)
|
||||
{
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
if self.source == FrameSource::Live && self.evidence_level == EvidenceLevel::L0Synthetic {
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
if self.evidence_level == EvidenceLevel::L3Corroborated {
|
||||
return Err(ContractError::EvidenceMismatch);
|
||||
}
|
||||
let mut track_ids = BTreeSet::new();
|
||||
for track in &self.tracks {
|
||||
validate_label("trackId", &track.track_id)?;
|
||||
if !track_ids.insert(track.track_id.as_str()) {
|
||||
return Err(ContractError::InvalidValue("duplicate trackId"));
|
||||
}
|
||||
validate_bounded_vec(track.position_m, MAX_POSITION_M, "positionM")?;
|
||||
validate_bounded_vec(track.velocity_mps, MAX_VELOCITY_MPS, "velocityMps")?;
|
||||
validate_nonnegative_vec(
|
||||
track.covariance_diagonal_m2,
|
||||
MAX_COVARIANCE_M2,
|
||||
"covarianceDiagonalM2",
|
||||
)?;
|
||||
validate_unit(track.confidence, "confidence")?;
|
||||
validate_unit(track.signal_quality, "signalQuality")?;
|
||||
validate_unit(track.modality_contributions.lidar, "lidar contribution")?;
|
||||
validate_unit(track.modality_contributions.csi, "csi contribution")?;
|
||||
let contribution_sum =
|
||||
track.modality_contributions.lidar + track.modality_contributions.csi;
|
||||
if !(0.999..=1.001).contains(&contribution_sum) {
|
||||
return Err(ContractError::InvalidValue("modality contributions"));
|
||||
}
|
||||
if !track.posterior_entropy.is_finite() || track.posterior_entropy < 0.0 {
|
||||
return Err(ContractError::NonFinite("posteriorEntropy"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract validation failure.
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum ContractError {
|
||||
/// Schema identifier does not match this implementation.
|
||||
#[error("unsupported NLOS schema")]
|
||||
WrongSchema,
|
||||
/// A collection exceeded its explicit bound.
|
||||
#[error("{0} exceeds its allowed bound")]
|
||||
Bound(&'static str),
|
||||
/// A number was NaN or infinite.
|
||||
#[error("{0} must be finite")]
|
||||
NonFinite(&'static str),
|
||||
/// A field value is invalid.
|
||||
#[error("invalid {0}")]
|
||||
InvalidValue(&'static str),
|
||||
/// A label is empty, too long, or unsafe.
|
||||
#[error("invalid bounded label {0}")]
|
||||
InvalidLabel(&'static str),
|
||||
/// Zone histograms do not share one bin count.
|
||||
#[error("all zones must have the same histogram bin count")]
|
||||
InconsistentBins,
|
||||
/// Ordinary depth is not sufficient live NLOS evidence.
|
||||
#[error("depth-only frames cannot be presented as live transient NLOS")]
|
||||
DepthIsNotNlos,
|
||||
/// Source and evidence labels disagree.
|
||||
#[error("source and evidence level disagree")]
|
||||
EvidenceMismatch,
|
||||
/// A digest was not lowercase SHA-256 hex.
|
||||
#[error("calibrationHash must be 64 lowercase hexadecimal characters")]
|
||||
InvalidHash,
|
||||
/// Sequence is not interoperable with JavaScript clients.
|
||||
#[error("sequence exceeds the JavaScript safe integer range")]
|
||||
UnsafeSequence,
|
||||
/// Timestamp is not interoperable with JavaScript clients.
|
||||
#[error("timestamp exceeds the JavaScript safe integer range")]
|
||||
UnsafeTimestamp,
|
||||
/// Expiry precedes capture or exceeds the five second freshness bound.
|
||||
#[error("invalid frame expiry")]
|
||||
InvalidExpiry,
|
||||
/// Quaternion is not approximately unit length.
|
||||
#[error("sensor pose quaternion must be normalized")]
|
||||
InvalidPose,
|
||||
}
|
||||
|
||||
fn validate_label(field: &'static str, value: &str) -> Result<(), ContractError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 64
|
||||
|| value
|
||||
.bytes()
|
||||
.any(|b| !(b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':')))
|
||||
{
|
||||
return Err(ContractError::InvalidLabel(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_hash(value: &str) -> Result<(), ContractError> {
|
||||
if value.len() != 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
return Err(ContractError::InvalidHash);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_sequence(value: u64) -> Result<(), ContractError> {
|
||||
if value > MAX_SAFE_INTEGER {
|
||||
return Err(ContractError::UnsafeSequence);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_timestamp(value: u64) -> Result<(), ContractError> {
|
||||
if value > MAX_SAFE_INTEGER {
|
||||
return Err(ContractError::UnsafeTimestamp);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_unit(value: f32, field: &'static str) -> Result<(), ContractError> {
|
||||
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
|
||||
return Err(ContractError::InvalidValue(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_bounded_vec(
|
||||
value: Vec3,
|
||||
max_abs: f32,
|
||||
field: &'static str,
|
||||
) -> Result<(), ContractError> {
|
||||
if !value.finite()
|
||||
|| [value.x, value.y, value.z]
|
||||
.iter()
|
||||
.any(|v| v.abs() > max_abs)
|
||||
{
|
||||
return Err(ContractError::InvalidValue(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nonnegative_vec(
|
||||
value: Vec3,
|
||||
max: f32,
|
||||
field: &'static str,
|
||||
) -> Result<(), ContractError> {
|
||||
if !value.finite()
|
||||
|| [value.x, value.y, value.z]
|
||||
.iter()
|
||||
.any(|v| *v < 0.0 || *v > max)
|
||||
{
|
||||
return Err(ContractError::InvalidValue(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn provenance() -> Provenance {
|
||||
Provenance {
|
||||
sensor_id: "st-01".into(),
|
||||
sensor_model: "VL53L8CH".into(),
|
||||
firmware_version: "upstream-main".into(),
|
||||
transient_kind: TransientKind::CompactNormalizedHistogram,
|
||||
histogram_preserved: true,
|
||||
transport: "usb_serial".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_only_live_is_rejected() {
|
||||
let mut p = provenance();
|
||||
p.transient_kind = TransientKind::DepthOnly;
|
||||
p.histogram_preserved = false;
|
||||
assert_eq!(
|
||||
p.validate(FrameSource::Live),
|
||||
Err(ContractError::DepthIsNotNlos)
|
||||
);
|
||||
let mut replay_transport = provenance();
|
||||
replay_transport.transport = "replay".into();
|
||||
assert_eq!(
|
||||
replay_transport.validate(FrameSource::Live),
|
||||
Err(ContractError::DepthIsNotNlos)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pose_transform_applies_rotation_and_translation() {
|
||||
let half = (0.5_f32).sqrt();
|
||||
let pose = SensorPose {
|
||||
translation_m: Vec3::new(1.0, 0.0, 0.0),
|
||||
quaternion_xyzw: [0.0, 0.0, half, half],
|
||||
};
|
||||
pose.validate().unwrap();
|
||||
let out = pose.transform(Vec3::new(1.0, 0.0, 0.0));
|
||||
assert!((out.x - 1.0).abs() < 1e-5);
|
||||
assert!((out.y - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_contract_rejects_duplicate_ids_and_zero_lifetime() {
|
||||
let fixture = include_str!("../tests/fixtures/track_synthetic.json");
|
||||
let mut envelope: TrackEnvelope = serde_json::from_str(fixture).unwrap();
|
||||
envelope.tracks.push(envelope.tracks[0].clone());
|
||||
assert_eq!(
|
||||
envelope.validate(),
|
||||
Err(ContractError::InvalidValue("duplicate trackId"))
|
||||
);
|
||||
|
||||
envelope.tracks.truncate(1);
|
||||
envelope.expires_at_unix_ms = envelope.captured_at_unix_ms;
|
||||
assert_eq!(envelope.validate(), Err(ContractError::InvalidExpiry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_rejects_l3_without_dual_modality_lineage() {
|
||||
let fixture = include_str!("../tests/fixtures/track_synthetic.json");
|
||||
let mut frame: TrackEnvelope = serde_json::from_str(fixture).unwrap();
|
||||
frame.source = FrameSource::Replay;
|
||||
frame.evidence_level = EvidenceLevel::L3Corroborated;
|
||||
frame.calibration_hash = "a".repeat(64);
|
||||
assert_eq!(frame.validate(), Err(ContractError::EvidenceMismatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_v1_rejects_l3_at_the_ingest_boundary() {
|
||||
let mut scene = crate::simulator::SyntheticScene::default();
|
||||
let mut frame = scene.frame(None, 0.0, 1);
|
||||
frame.source = FrameSource::Replay;
|
||||
frame.evidence_level = EvidenceLevel::L3Corroborated;
|
||||
frame.calibration_hash = "a".repeat(64);
|
||||
assert_eq!(frame.validate(), Err(ContractError::EvidenceMismatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_replay_must_preserve_replayed_histogram_provenance() {
|
||||
let mut p = provenance();
|
||||
p.transport = "replay".into();
|
||||
p.transient_kind = TransientKind::Replay;
|
||||
assert!(p.validate(FrameSource::Replay).is_ok());
|
||||
p.histogram_preserved = false;
|
||||
assert_eq!(
|
||||
p.validate(FrameSource::Replay),
|
||||
Err(ContractError::EvidenceMismatch)
|
||||
);
|
||||
}
|
||||
}
|
||||
791
v2/crates/ruview-nlos/src/server.rs
Normal file
791
v2/crates/ruview-nlos/src/server.rs
Normal file
@@ -0,0 +1,791 @@
|
||||
//! Authenticated read-only NLOS HTTP and WebSocket surface.
|
||||
//!
|
||||
//! Browsers exchange a bearer token for a single-use, short-lived WebSocket
|
||||
//! ticket because the browser WebSocket API cannot set an Authorization header.
|
||||
//! Native clients may authenticate the upgrade directly with a bearer header.
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
|
||||
use crate::protocol::TrackEnvelope;
|
||||
use crate::{MAX_WIRE_BYTES, TRACK_SCHEMA_V1};
|
||||
|
||||
const TICKET_TTL_MS: u64 = 30_000;
|
||||
const SESSION_TTL_MS: u64 = 60 * 60 * 1_000;
|
||||
const MAX_TICKETS: usize = 1_024;
|
||||
const MAX_HISTORY: usize = 64;
|
||||
|
||||
/// Thread-safe authenticated publication hub.
|
||||
#[derive(Clone)]
|
||||
pub struct NlosHub {
|
||||
inner: Arc<HubInner>,
|
||||
}
|
||||
|
||||
struct HubInner {
|
||||
bearer_digest: [u8; 32],
|
||||
session_id: String,
|
||||
latest: RwLock<Option<TrackEnvelope>>,
|
||||
history: RwLock<VecDeque<TrackEnvelope>>,
|
||||
publish_order: tokio::sync::Mutex<()>,
|
||||
last_published_sequence: tokio::sync::Mutex<Option<u64>>,
|
||||
tickets: Mutex<BTreeMap<String, Ticket>>,
|
||||
broadcast: broadcast::Sender<TrackEnvelope>,
|
||||
allowed_origin: Option<axum::http::HeaderValue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Ticket {
|
||||
expires_at_unix_ms: u64,
|
||||
origin_digest: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl NlosHub {
|
||||
/// Create a hub. The bearer token is hashed immediately and never retained.
|
||||
pub fn new(bearer_token: &str, session_id: impl Into<String>) -> Result<Self, ServerError> {
|
||||
if bearer_token.len() < 32
|
||||
|| bearer_token.len() > 512
|
||||
|| bearer_token
|
||||
.bytes()
|
||||
.any(|byte| !(0x21..=0x7e).contains(&byte))
|
||||
{
|
||||
return Err(ServerError::WeakToken);
|
||||
}
|
||||
let session_id = session_id.into();
|
||||
if session_id.is_empty()
|
||||
|| session_id.len() > 64
|
||||
|| session_id
|
||||
.bytes()
|
||||
.any(|b| !(b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':')))
|
||||
{
|
||||
return Err(ServerError::InvalidSession);
|
||||
}
|
||||
let (broadcast, _) = broadcast::channel(64);
|
||||
Ok(Self {
|
||||
inner: Arc::new(HubInner {
|
||||
bearer_digest: sha256(bearer_token.as_bytes()),
|
||||
session_id,
|
||||
latest: RwLock::new(None),
|
||||
history: RwLock::new(VecDeque::with_capacity(MAX_HISTORY)),
|
||||
publish_order: tokio::sync::Mutex::new(()),
|
||||
last_published_sequence: tokio::sync::Mutex::new(None),
|
||||
tickets: Mutex::new(BTreeMap::new()),
|
||||
broadcast,
|
||||
allowed_origin: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Permit one exact browser origin for ticket exchange. Wildcards,
|
||||
/// credentials, paths, fragments, and cleartext non-loopback origins are
|
||||
/// rejected. Call this before cloning the hub.
|
||||
pub fn with_allowed_origin(mut self, origin: &str) -> Result<Self, ServerError> {
|
||||
let value = validate_origin(origin)?;
|
||||
let Some(inner) = Arc::get_mut(&mut self.inner) else {
|
||||
return Err(ServerError::Internal);
|
||||
};
|
||||
inner.allowed_origin = Some(value);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Publish one validated, bounded, monotonically ordered envelope.
|
||||
pub async fn publish(&self, envelope: TrackEnvelope) -> Result<(), ServerError> {
|
||||
envelope.validate()?;
|
||||
if envelope.session_id != self.inner.session_id {
|
||||
return Err(ServerError::SessionMismatch);
|
||||
}
|
||||
let encoded = serde_json::to_vec(&envelope).map_err(|_| ServerError::Serialization)?;
|
||||
if encoded.len() > MAX_WIRE_BYTES {
|
||||
return Err(ServerError::FrameTooLarge);
|
||||
}
|
||||
let now = now_unix_ms();
|
||||
if envelope.expires_at_unix_ms <= now
|
||||
|| envelope.captured_at_unix_ms > now.saturating_add(1_000)
|
||||
{
|
||||
return Err(ServerError::StaleOrFuture);
|
||||
}
|
||||
// Serialize the latest/history/broadcast transition so concurrent
|
||||
// publishers cannot expose sequence N+1 before N in another surface.
|
||||
let _publish_order = self.inner.publish_order.lock().await;
|
||||
let mut last_sequence = self.inner.last_published_sequence.lock().await;
|
||||
if last_sequence.is_some_and(|previous| previous >= envelope.sequence) {
|
||||
return Err(ServerError::ReplayOrOutOfOrder);
|
||||
}
|
||||
*last_sequence = Some(envelope.sequence);
|
||||
drop(last_sequence);
|
||||
let mut latest = self.inner.latest.write().await;
|
||||
*latest = Some(envelope.clone());
|
||||
drop(latest);
|
||||
let mut history = self.inner.history.write().await;
|
||||
history.retain(|item| item.expires_at_unix_ms > now);
|
||||
while history.len() >= MAX_HISTORY {
|
||||
history.pop_front();
|
||||
}
|
||||
history.push_back(envelope.clone());
|
||||
drop(history);
|
||||
let _ = self.inner.broadcast.send(envelope);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn purge_expired(&self, now: u64) {
|
||||
let _publish_order = self.inner.publish_order.lock().await;
|
||||
let mut latest = self.inner.latest.write().await;
|
||||
if latest
|
||||
.as_ref()
|
||||
.is_some_and(|item| item.expires_at_unix_ms <= now)
|
||||
{
|
||||
*latest = None;
|
||||
}
|
||||
drop(latest);
|
||||
self.inner
|
||||
.history
|
||||
.write()
|
||||
.await
|
||||
.retain(|item| item.expires_at_unix_ms > now);
|
||||
}
|
||||
|
||||
/// Build the read-only API router. No permissive CORS layer is installed.
|
||||
pub fn router(self) -> Router {
|
||||
let router = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/api/v1/nlos/latest", get(latest))
|
||||
.route("/api/v1/nlos/tracks", get(tracks))
|
||||
.route("/api/v1/nlos/ws-ticket", post(issue_ws_ticket))
|
||||
.route("/api/v1/nlos/ws", get(websocket))
|
||||
.layer(RequestBodyLimitLayer::new(8 * 1024))
|
||||
.with_state(self.clone());
|
||||
if let Some(origin) = self.inner.allowed_origin.clone() {
|
||||
router.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(origin)
|
||||
.allow_methods([axum::http::Method::GET, axum::http::Method::POST])
|
||||
.allow_headers([
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
]),
|
||||
)
|
||||
} else {
|
||||
// No CORS headers means browsers remain same-origin by default.
|
||||
router
|
||||
}
|
||||
}
|
||||
|
||||
fn bearer_authorized(&self, headers: &HeaderMap) -> bool {
|
||||
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(value) = value.to_str() else {
|
||||
return false;
|
||||
};
|
||||
let Some(token) = value.strip_prefix("Bearer ") else {
|
||||
return false;
|
||||
};
|
||||
constant_time_eq(&sha256(token.as_bytes()), &self.inner.bearer_digest)
|
||||
}
|
||||
|
||||
fn issue_ticket(
|
||||
&self,
|
||||
now: u64,
|
||||
origin_digest: Option<[u8; 32]>,
|
||||
) -> Result<(String, u64), ServerError> {
|
||||
let mut tickets = self
|
||||
.inner
|
||||
.tickets
|
||||
.lock()
|
||||
.map_err(|_| ServerError::Internal)?;
|
||||
tickets.retain(|_, ticket| ticket.expires_at_unix_ms > now);
|
||||
if tickets.len() >= MAX_TICKETS {
|
||||
return Err(ServerError::TicketCapacity);
|
||||
}
|
||||
let mut random = [0_u8; 32];
|
||||
getrandom::getrandom(&mut random).map_err(|_| ServerError::Entropy)?;
|
||||
let ticket = lowercase_hex(&random);
|
||||
let expires = now.saturating_add(TICKET_TTL_MS);
|
||||
tickets.insert(
|
||||
ticket.clone(),
|
||||
Ticket {
|
||||
expires_at_unix_ms: expires,
|
||||
origin_digest,
|
||||
},
|
||||
);
|
||||
Ok((ticket, expires))
|
||||
}
|
||||
|
||||
fn consume_ticket(&self, raw: &str, now: u64, origin_digest: Option<[u8; 32]>) -> bool {
|
||||
if raw.len() != 64
|
||||
|| !raw
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(mut tickets) = self.inner.tickets.lock() else {
|
||||
return false;
|
||||
};
|
||||
tickets.remove(raw).is_some_and(|ticket| {
|
||||
ticket.expires_at_unix_ms >= now && ticket.origin_digest == origin_digest
|
||||
})
|
||||
}
|
||||
|
||||
fn request_origin_digest(&self, headers: &HeaderMap) -> Result<Option<[u8; 32]>, ServerError> {
|
||||
let actual = headers.get(axum::http::header::ORIGIN);
|
||||
if let Some(expected) = &self.inner.allowed_origin {
|
||||
if actual != Some(expected) {
|
||||
return Err(ServerError::InvalidOrigin);
|
||||
}
|
||||
}
|
||||
Ok(actual.map(|value| sha256(value.as_bytes())))
|
||||
}
|
||||
}
|
||||
|
||||
async fn health() -> Json<Health> {
|
||||
Json(Health {
|
||||
status: "ok",
|
||||
service: "ruview-nlos",
|
||||
})
|
||||
}
|
||||
|
||||
async fn latest(State(hub): State<NlosHub>, headers: HeaderMap) -> Response {
|
||||
if !hub.bearer_authorized(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
hub.purge_expired(now_unix_ms()).await;
|
||||
match hub.inner.latest.read().await.clone() {
|
||||
Some(frame) => sensitive(Json(frame).into_response()),
|
||||
None => sensitive(StatusCode::NO_CONTENT.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tracks(State(hub): State<NlosHub>, headers: HeaderMap) -> Response {
|
||||
if !hub.bearer_authorized(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
hub.purge_expired(now_unix_ms()).await;
|
||||
let history: Vec<_> = hub.inner.history.read().await.iter().cloned().collect();
|
||||
sensitive(Json(history).into_response())
|
||||
}
|
||||
|
||||
async fn issue_ws_ticket(State(hub): State<NlosHub>, headers: HeaderMap) -> Response {
|
||||
if !hub.bearer_authorized(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
let now = now_unix_ms();
|
||||
let origin_digest = match hub.request_origin_digest(&headers) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return sensitive(StatusCode::FORBIDDEN.into_response()),
|
||||
};
|
||||
let (ticket, expires_at_unix_ms) = match hub.issue_ticket(now, origin_digest) {
|
||||
Ok(result) => result,
|
||||
Err(_) => return sensitive(StatusCode::SERVICE_UNAVAILABLE.into_response()),
|
||||
};
|
||||
let host = headers
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<axum::http::uri::Authority>().ok())
|
||||
.map_or_else(|| "127.0.0.1:8787".to_owned(), |value| value.to_string());
|
||||
let forwarded = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let scheme = if matches!(forwarded, Some("https" | "wss")) {
|
||||
"wss"
|
||||
} else {
|
||||
"ws"
|
||||
};
|
||||
sensitive(
|
||||
Json(WsTicketResponse {
|
||||
schema: "ruview.nlos.ws-ticket.v1",
|
||||
web_socket_url: format!("{scheme}://{host}/api/v1/nlos/ws?ticket={ticket}"),
|
||||
expires_at_unix_ms,
|
||||
})
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn websocket(
|
||||
State(hub): State<NlosHub>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WsQuery>,
|
||||
upgrade: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
let now = now_unix_ms();
|
||||
let origin_digest = headers
|
||||
.get(axum::http::header::ORIGIN)
|
||||
.map(|value| sha256(value.as_bytes()));
|
||||
let ticket_ok = query
|
||||
.ticket
|
||||
.as_deref()
|
||||
.is_some_and(|ticket| hub.consume_ticket(ticket, now, origin_digest));
|
||||
if !ticket_ok && !hub.bearer_authorized(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
upgrade
|
||||
.protocols([TRACK_SCHEMA_V1])
|
||||
.max_message_size(8 * 1024)
|
||||
.max_frame_size(8 * 1024)
|
||||
.on_upgrade(move |socket| websocket_session(hub, socket))
|
||||
}
|
||||
|
||||
async fn websocket_session(hub: NlosHub, mut socket: WebSocket) {
|
||||
// Subscribe before reading the retained latest value. The sequence
|
||||
// watermark below de-duplicates a publication that lands in between.
|
||||
let mut receiver = hub.inner.broadcast.subscribe();
|
||||
let session_expires_at_unix_ms = now_unix_ms().saturating_add(SESSION_TTL_MS);
|
||||
let authenticated = Authenticated {
|
||||
schema: "ruview.nlos.authenticated.v1",
|
||||
session_id: hub.inner.session_id.clone(),
|
||||
expires_at_unix_ms: session_expires_at_unix_ms,
|
||||
};
|
||||
let Ok(payload) = serde_json::to_string(&authenticated) else {
|
||||
return;
|
||||
};
|
||||
if socket.send(Message::Text(payload)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let mut last_sent_sequence = None;
|
||||
if let Some(latest) = hub
|
||||
.inner
|
||||
.latest
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.filter(|frame| frame.expires_at_unix_ms > now_unix_ms())
|
||||
{
|
||||
let Ok(payload) = serde_json::to_string(&latest) else {
|
||||
return;
|
||||
};
|
||||
if socket.send(Message::Text(payload)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
last_sent_sequence = Some(latest.sequence);
|
||||
}
|
||||
let session_expiry = tokio::time::sleep(std::time::Duration::from_millis(SESSION_TTL_MS));
|
||||
tokio::pin!(session_expiry);
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = receiver.recv() => {
|
||||
let frame = match message {
|
||||
Ok(frame) => frame,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
};
|
||||
if last_sent_sequence.is_some_and(|last| frame.sequence <= last) {
|
||||
continue;
|
||||
}
|
||||
let Ok(payload) = serde_json::to_string(&frame) else { break; };
|
||||
if socket.send(Message::Text(payload)).await.is_err() { break; }
|
||||
last_sent_sequence = Some(frame.sequence);
|
||||
}
|
||||
incoming = socket.recv() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(Message::Ping(value))) => {
|
||||
if socket.send(Message::Pong(value)).await.is_err() { break; }
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = &mut session_expiry => {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
sensitive(
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ApiError {
|
||||
error: "unauthorized",
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
fn sensitive(mut response: Response) -> Response {
|
||||
response.headers_mut().insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store, max-age=0"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_millis() as u64)
|
||||
}
|
||||
|
||||
fn sha256(value: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(value).into()
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
|
||||
left.iter()
|
||||
.zip(right.iter())
|
||||
.fold(0_u8, |difference, (a, b)| difference | (a ^ b))
|
||||
== 0
|
||||
}
|
||||
|
||||
fn lowercase_hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push(HEX[(byte >> 4) as usize] as char);
|
||||
out.push(HEX[(byte & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn validate_origin(origin: &str) -> Result<axum::http::HeaderValue, ServerError> {
|
||||
if origin.len() > 2_048 || origin.chars().any(|value| matches!(value, '#' | '?' | '@')) {
|
||||
return Err(ServerError::InvalidOrigin);
|
||||
}
|
||||
let uri: axum::http::Uri = origin.parse().map_err(|_| ServerError::InvalidOrigin)?;
|
||||
let scheme = uri.scheme_str().ok_or(ServerError::InvalidOrigin)?;
|
||||
let authority = uri.authority().ok_or(ServerError::InvalidOrigin)?;
|
||||
if uri
|
||||
.path_and_query()
|
||||
.is_some_and(|path| path.as_str() != "/")
|
||||
{
|
||||
return Err(ServerError::InvalidOrigin);
|
||||
}
|
||||
let host = authority.host();
|
||||
let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]");
|
||||
if scheme != "https" && !(scheme == "http" && loopback) {
|
||||
return Err(ServerError::InvalidOrigin);
|
||||
}
|
||||
origin.parse().map_err(|_| ServerError::InvalidOrigin)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Health {
|
||||
status: &'static str,
|
||||
service: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WsTicketResponse {
|
||||
schema: &'static str,
|
||||
web_socket_url: String,
|
||||
expires_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Authenticated {
|
||||
schema: &'static str,
|
||||
session_id: String,
|
||||
expires_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsQuery {
|
||||
ticket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ApiError {
|
||||
error: &'static str,
|
||||
}
|
||||
|
||||
/// Server security or publication failure.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServerError {
|
||||
/// Token is outside the shared visible-ASCII length boundary.
|
||||
#[error("bearer token must contain 32 to 512 visible ASCII characters")]
|
||||
WeakToken,
|
||||
/// Session id is empty or too long.
|
||||
#[error("invalid server session id")]
|
||||
InvalidSession,
|
||||
/// Track frame contract failure.
|
||||
#[error(transparent)]
|
||||
Contract(#[from] crate::protocol::ContractError),
|
||||
/// Encoded frame exceeded 256 KiB.
|
||||
#[error("track frame exceeds the wire-size bound")]
|
||||
FrameTooLarge,
|
||||
/// Sequence moved backwards or repeated.
|
||||
#[error("replayed or out-of-order track frame")]
|
||||
ReplayOrOutOfOrder,
|
||||
/// Frame was already expired or materially future-dated at publication.
|
||||
#[error("stale or future-dated track frame")]
|
||||
StaleOrFuture,
|
||||
/// Publisher session did not match the authenticated server session.
|
||||
#[error("track frame session does not match server session")]
|
||||
SessionMismatch,
|
||||
/// Random ticket generation failed.
|
||||
#[error("secure entropy unavailable")]
|
||||
Entropy,
|
||||
/// Too many unexpired tickets exist.
|
||||
#[error("ticket capacity reached")]
|
||||
TicketCapacity,
|
||||
/// Lock poisoning or another internal failure.
|
||||
#[error("internal server failure")]
|
||||
Internal,
|
||||
/// JSON serialization failed.
|
||||
#[error("frame serialization failed")]
|
||||
Serialization,
|
||||
/// Browser origin was not one exact HTTPS origin or loopback development origin.
|
||||
#[error("invalid allowed browser origin")]
|
||||
InvalidOrigin,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[test]
|
||||
fn ticket_is_single_use_and_expiring() {
|
||||
let hub = NlosHub::new(&"x".repeat(32), "s1").unwrap();
|
||||
let origin = Some(sha256(b"https://app.example.test"));
|
||||
let (ticket, expires) = hub.issue_ticket(1_000, origin).unwrap();
|
||||
assert!(!hub.consume_ticket(&ticket, expires, None));
|
||||
let (ticket, expires) = hub.issue_ticket(1_500, origin).unwrap();
|
||||
assert!(hub.consume_ticket(&ticket, expires, origin));
|
||||
assert!(!hub.consume_ticket(&ticket, expires, origin));
|
||||
let (ticket, expires) = hub.issue_ticket(2_000, origin).unwrap();
|
||||
assert!(!hub.consume_ticket(&ticket, expires + 1, origin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_digest_comparison_is_exact() {
|
||||
let hub = NlosHub::new(&"a".repeat(32), "s1").unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {}", "a".repeat(32)).parse().unwrap(),
|
||||
);
|
||||
assert!(hub.bearer_authorized(&headers));
|
||||
headers.insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {}", "b".repeat(32)).parse().unwrap(),
|
||||
);
|
||||
assert!(!hub.bearer_authorized(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_origin_is_exact_and_https_except_loopback() {
|
||||
assert!(NlosHub::new(&"x".repeat(32), "s1")
|
||||
.unwrap()
|
||||
.with_allowed_origin("https://app.example.test")
|
||||
.is_ok());
|
||||
assert!(NlosHub::new(&"x".repeat(32), "s1")
|
||||
.unwrap()
|
||||
.with_allowed_origin("http://127.0.0.1:8081")
|
||||
.is_ok());
|
||||
for invalid in [
|
||||
"*",
|
||||
"http://app.example.test",
|
||||
"https://user@example.test",
|
||||
"https://example.test/path",
|
||||
] {
|
||||
assert!(NlosHub::new(&"x".repeat(32), "s1")
|
||||
.unwrap()
|
||||
.with_allowed_origin(invalid)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_endpoint_requires_bearer_and_returns_strict_contract() {
|
||||
let token = "z".repeat(32);
|
||||
let router = NlosHub::new(&token, "s1").unwrap().router();
|
||||
let unauthorized = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/api/v1/nlos/ws-ticket")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let authorized = router
|
||||
.oneshot(
|
||||
Request::post("/api/v1/nlos/ws-ticket")
|
||||
.header("host", "127.0.0.1:8787")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(authorized.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
authorized.headers().get(axum::http::header::CACHE_CONTROL),
|
||||
Some(&HeaderValue::from_static("no-store, max-age=0"))
|
||||
);
|
||||
let bytes = authorized.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(bytes.len() < 8 * 1024);
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["schema"], "ruview.nlos.ws-ticket.v1");
|
||||
assert!(value["webSocketUrl"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("ws://127.0.0.1:8787/api/v1/nlos/ws?ticket="));
|
||||
assert!(value["expiresAtUnixMs"].as_u64().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_origin_preflight_is_allowed_without_wildcard() {
|
||||
let router = NlosHub::new(&"x".repeat(32), "s1")
|
||||
.unwrap()
|
||||
.with_allowed_origin("https://app.example.test")
|
||||
.unwrap()
|
||||
.router();
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("/api/v1/nlos/ws-ticket")
|
||||
.header("origin", "https://app.example.test")
|
||||
.header("access-control-request-method", "POST")
|
||||
.header("access-control-request-headers", "authorization")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.unwrap(),
|
||||
"https://app.example.test"
|
||||
);
|
||||
assert_ne!(
|
||||
response
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.unwrap(),
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_origin_is_required_for_ticket_issue() {
|
||||
let token = "z".repeat(32);
|
||||
let router = NlosHub::new(&token, "s1")
|
||||
.unwrap()
|
||||
.with_allowed_origin("https://app.example.test")
|
||||
.unwrap()
|
||||
.router();
|
||||
let forbidden = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/api/v1/nlos/ws-ticket")
|
||||
.header("host", "nlos.example.test")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("origin", "https://other.example.test")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
forbidden.headers().get(axum::http::header::CACHE_CONTROL),
|
||||
Some(&HeaderValue::from_static("no-store, max-age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publisher_rejects_session_switch_and_sequence_replay() {
|
||||
let fixture = include_str!("../tests/fixtures/track_synthetic.json");
|
||||
let mut frame: TrackEnvelope = serde_json::from_str(fixture).unwrap();
|
||||
let now = now_unix_ms();
|
||||
frame.captured_at_unix_ms = now;
|
||||
frame.expires_at_unix_ms = now + 250;
|
||||
let hub = NlosHub::new(&"x".repeat(32), frame.session_id.clone()).unwrap();
|
||||
hub.publish(frame.clone()).await.unwrap();
|
||||
assert!(matches!(
|
||||
hub.publish(frame.clone()).await,
|
||||
Err(ServerError::ReplayOrOutOfOrder)
|
||||
));
|
||||
let mut wrong_session = frame;
|
||||
wrong_session.session_id = "another-session".into();
|
||||
wrong_session.sequence += 1;
|
||||
assert!(matches!(
|
||||
hub.publish(wrong_session).await,
|
||||
Err(ServerError::SessionMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_publication_keeps_history_monotonic() {
|
||||
let fixture = include_str!("../tests/fixtures/track_synthetic.json");
|
||||
let mut frame: TrackEnvelope = serde_json::from_str(fixture).unwrap();
|
||||
let now = now_unix_ms();
|
||||
frame.captured_at_unix_ms = now;
|
||||
frame.expires_at_unix_ms = now + 250;
|
||||
let hub = NlosHub::new(&"x".repeat(32), frame.session_id.clone()).unwrap();
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(32));
|
||||
let mut tasks = Vec::new();
|
||||
for sequence in 1..=32_u64 {
|
||||
let hub = hub.clone();
|
||||
let barrier = barrier.clone();
|
||||
let mut candidate = frame.clone();
|
||||
candidate.sequence = sequence;
|
||||
tasks.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
hub.publish(candidate).await.ok()
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
let history = hub.inner.history.read().await;
|
||||
let sequences: Vec<_> = history.iter().map(|item| item.sequence).collect();
|
||||
assert!(!sequences.is_empty());
|
||||
assert!(sequences.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
assert_eq!(
|
||||
hub.inner
|
||||
.latest
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|item| item.sequence),
|
||||
sequences.last().copied()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_tracks_are_removed_from_all_retention_surfaces() {
|
||||
let fixture = include_str!("../tests/fixtures/track_synthetic.json");
|
||||
let mut frame: TrackEnvelope = serde_json::from_str(fixture).unwrap();
|
||||
let now = now_unix_ms();
|
||||
frame.captured_at_unix_ms = now;
|
||||
frame.expires_at_unix_ms = now + 5;
|
||||
let hub = NlosHub::new(&"x".repeat(32), frame.session_id.clone()).unwrap();
|
||||
hub.publish(frame).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
// Public read surfaces call the same lazy purge before returning data;
|
||||
// no unbounded per-frame timer queue is retained by the hub.
|
||||
hub.purge_expired(now_unix_ms()).await;
|
||||
assert!(hub.inner.latest.read().await.is_none());
|
||||
assert!(hub.inner.history.read().await.is_empty());
|
||||
}
|
||||
}
|
||||
343
v2/crates/ruview-nlos/src/simulator.rs
Normal file
343
v2/crates/ruview-nlos/src/simulator.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
//! Deterministic L0 simulator and comparative fusion benchmark.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::calibration::{Calibration, CalibrationConfig};
|
||||
use crate::fusion::{CsiSpatialPrior, FusionScope};
|
||||
use crate::protocol::{
|
||||
EvidenceLevel, FrameSource, Provenance, SensorPose, TrackState, TransientFrame, TransientKind,
|
||||
TransientZone, Vec3,
|
||||
};
|
||||
use crate::tracker::{CanonicalObject, MotionApertureTracker, TrackerConfig};
|
||||
use crate::TRANSIENT_SCHEMA_V1;
|
||||
|
||||
const C: f32 = 299_792_458.0;
|
||||
|
||||
/// Controlled photon-histogram generator. It is intentionally labelled L0 and
|
||||
/// cannot substitute for a VL53L8CH hardware capture.
|
||||
pub struct SyntheticScene {
|
||||
/// Timing width.
|
||||
pub bin_width_ps: f32,
|
||||
/// Timing bins.
|
||||
pub bin_count: usize,
|
||||
/// Relay-wall samples.
|
||||
pub wall_points_m: Vec<Vec3>,
|
||||
/// Direct return peak index.
|
||||
pub direct_peak_bin: usize,
|
||||
/// Direct peak photon count.
|
||||
pub direct_amplitude: u16,
|
||||
/// Third-bounce target count scale.
|
||||
pub target_amplitude: f32,
|
||||
rng: SimRng,
|
||||
}
|
||||
|
||||
impl Default for SyntheticScene {
|
||||
fn default() -> Self {
|
||||
let mut wall_points_m = Vec::new();
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
wall_points_m.push(Vec3::new(
|
||||
-0.45 + col as f32 * 0.3,
|
||||
-0.45 + row as f32 * 0.3,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
Self {
|
||||
bin_width_ps: 250.0,
|
||||
bin_count: 48,
|
||||
wall_points_m,
|
||||
direct_peak_bin: 3,
|
||||
direct_amplitude: 600,
|
||||
target_amplitude: 900.0,
|
||||
rng: SimRng::new(0x4e4c_4f53),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyntheticScene {
|
||||
/// Generate an empty-room calibration sequence.
|
||||
pub fn background_frames(&mut self, count: usize) -> Vec<TransientFrame> {
|
||||
(0..count)
|
||||
.map(|sequence| self.frame(None, 0.0, sequence as u64))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate one raw transient frame. `visibility` scales only the weak
|
||||
/// third-bounce return and can model optical dropout.
|
||||
pub fn frame(
|
||||
&mut self,
|
||||
target: Option<Vec3>,
|
||||
visibility: f32,
|
||||
sequence: u64,
|
||||
) -> TransientFrame {
|
||||
let aperture_shift = 0.018 * (sequence as f32 * 0.31).sin();
|
||||
let pose = SensorPose {
|
||||
translation_m: Vec3::new(aperture_shift, 0.0, 0.0),
|
||||
quaternion_xyzw: [0.0, 0.0, 0.0, 1.0],
|
||||
};
|
||||
let zones = self
|
||||
.wall_points_m
|
||||
.clone()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(zone_id, wall)| {
|
||||
let mut histogram = vec![0_u16; self.bin_count];
|
||||
for value in &mut histogram {
|
||||
*value = 8 + (self.rng.next_u32() % 5) as u16;
|
||||
}
|
||||
for (offset, scale) in [(-1_i32, 0.25_f32), (0, 1.0), (1, 0.25)] {
|
||||
let index = self.direct_peak_bin as i32 + offset;
|
||||
if (0..self.bin_count as i32).contains(&index) {
|
||||
histogram[index as usize] = histogram[index as usize]
|
||||
.saturating_add((f32::from(self.direct_amplitude) * scale) as u16);
|
||||
}
|
||||
}
|
||||
if let Some(target) = target {
|
||||
let world_wall = pose.transform(wall);
|
||||
let distance = world_wall.distance(target).max(0.05);
|
||||
let extra_bin =
|
||||
(2.0 * distance / (C * self.bin_width_ps * 1e-12)).round() as usize;
|
||||
let target_bin = self.direct_peak_bin + extra_bin;
|
||||
if target_bin < self.bin_count {
|
||||
let amplitude = (self.target_amplitude * visibility / distance.powi(4))
|
||||
.clamp(0.0, 20_000.0);
|
||||
for (offset, scale) in [(-1_i32, 0.4_f32), (0, 1.0), (1, 0.4)] {
|
||||
let index = target_bin as i32 + offset;
|
||||
if (0..self.bin_count as i32).contains(&index) {
|
||||
histogram[index as usize] = histogram[index as usize]
|
||||
.saturating_add((amplitude * scale) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TransientZone {
|
||||
zone_id: zone_id as u16,
|
||||
wall_point_m: wall,
|
||||
distance_m: 0.8,
|
||||
ambient: 8,
|
||||
histogram,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let frame = TransientFrame {
|
||||
schema: TRANSIENT_SCHEMA_V1.into(),
|
||||
session_id: "synthetic-nlos-1".into(),
|
||||
sequence,
|
||||
captured_at_unix_ms: 1_800_000_000_000 + sequence * 33,
|
||||
monotonic_ns: sequence * 33_333_333,
|
||||
source: FrameSource::Synthetic,
|
||||
evidence_level: EvidenceLevel::L0Synthetic,
|
||||
bin_width_ps: self.bin_width_ps,
|
||||
start_bin: 30,
|
||||
sensor_pose: pose,
|
||||
calibration_hash: "0".repeat(64),
|
||||
provenance: Provenance {
|
||||
sensor_id: "sim-vl53l8ch".into(),
|
||||
sensor_model: "VL53L8CH-simulator".into(),
|
||||
firmware_version: "sim-v1".into(),
|
||||
transient_kind: TransientKind::Replay,
|
||||
histogram_preserved: true,
|
||||
transport: "replay".into(),
|
||||
},
|
||||
zones,
|
||||
};
|
||||
debug_assert!(frame.validate().is_ok());
|
||||
frame
|
||||
}
|
||||
|
||||
/// Run a deterministic LiDAR-only versus LiDAR-plus-CSI comparison.
|
||||
pub fn benchmark(frames: usize, particles: usize) -> BenchmarkReport {
|
||||
let mut scene = Self::default();
|
||||
let fusion_scope = FusionScope {
|
||||
tenant_id: "synthetic-tenant".into(),
|
||||
workspace_id: "synthetic-workspace".into(),
|
||||
site_id: "synthetic-site".into(),
|
||||
world_frame_id: "synthetic-world".into(),
|
||||
session_id: "synthetic-nlos-1".into(),
|
||||
coordinate_transform_hash: "f".repeat(64),
|
||||
};
|
||||
let calibration = Calibration::from_background(
|
||||
&scene.background_frames(60),
|
||||
CalibrationConfig::default(),
|
||||
)
|
||||
.expect("synthetic calibration is valid");
|
||||
let config = TrackerConfig {
|
||||
particle_count: particles,
|
||||
search_min_m: Vec3::new(-0.6, -0.5, 0.35),
|
||||
search_max_m: Vec3::new(0.6, 0.5, 1.5),
|
||||
fusion_scope: Some(fusion_scope.clone()),
|
||||
..TrackerConfig::default()
|
||||
};
|
||||
let mut lidar = MotionApertureTracker::new(
|
||||
calibration.clone(),
|
||||
CanonicalObject::point(),
|
||||
config.clone(),
|
||||
)
|
||||
.expect("benchmark config is valid");
|
||||
let mut fused = MotionApertureTracker::new(calibration, CanonicalObject::point(), config)
|
||||
.expect("benchmark config is valid");
|
||||
let mut lidar_errors = Vec::new();
|
||||
let mut fused_errors = Vec::new();
|
||||
let mut lidar_lost = 0_usize;
|
||||
let mut fused_lost = 0_usize;
|
||||
let started = Instant::now();
|
||||
for index in 0..frames {
|
||||
let t = index as f32 / frames.max(1) as f32;
|
||||
let truth = Vec3::new(
|
||||
-0.32 + 0.64 * t,
|
||||
0.12 * (t * std::f32::consts::TAU).sin(),
|
||||
0.92 + 0.08 * (t * std::f32::consts::TAU * 0.5).cos(),
|
||||
);
|
||||
// Twelve-frame optical dropouts outlast the eight-frame aperture,
|
||||
// making lost-track recovery measurable rather than cosmetic.
|
||||
let dropout = index % 24 < 12;
|
||||
let visibility = if dropout { 0.005 } else { 1.0 };
|
||||
let frame = scene.frame(Some(truth), visibility, 100 + index as u64);
|
||||
let csi = CsiSpatialPrior {
|
||||
source: FrameSource::Synthetic,
|
||||
sequence: index as u64,
|
||||
captured_at_unix_ms: frame.captured_at_unix_ms,
|
||||
scope: fusion_scope.clone(),
|
||||
mean_m: Vec3::new(
|
||||
truth.x + 0.025 * (index as f32 * 0.7).sin(),
|
||||
truth.y + 0.03 * (index as f32 * 0.4).cos(),
|
||||
truth.z + 0.04 * (index as f32 * 0.2).sin(),
|
||||
),
|
||||
covariance_diagonal_m2: Vec3::new(0.04, 0.04, 0.09),
|
||||
confidence: 0.72,
|
||||
evidence_level: EvidenceLevel::L0Synthetic,
|
||||
sensor_id: "sim-csi-1".into(),
|
||||
calibration_hash: "0".repeat(64),
|
||||
};
|
||||
let lidar_output = lidar.update(&frame, None).expect("ordered frame");
|
||||
let fused_output = fused.update(&frame, Some(&csi)).expect("valid prior");
|
||||
collect_metric(&lidar_output, truth, &mut lidar_errors, &mut lidar_lost);
|
||||
collect_metric(&fused_output, truth, &mut fused_errors, &mut fused_lost);
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
let lidar_lost_rate = lidar_lost as f32 / frames.max(1) as f32;
|
||||
let fused_lost_rate = fused_lost as f32 / frames.max(1) as f32;
|
||||
let lost_track_reduction_percent = if lidar_lost_rate > 0.0 {
|
||||
100.0 * (lidar_lost_rate - fused_lost_rate) / lidar_lost_rate
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
BenchmarkReport {
|
||||
evidence: "SYNTHETIC_L0".into(),
|
||||
frames,
|
||||
particles,
|
||||
throughput_fps: frames as f64 / elapsed.as_secs_f64().max(1e-9),
|
||||
lidar_only_mean_error_m: mean(&lidar_errors),
|
||||
fused_mean_error_m: mean(&fused_errors),
|
||||
lidar_only_p95_error_m: percentile95(&mut lidar_errors),
|
||||
fused_p95_error_m: percentile95(&mut fused_errors),
|
||||
lidar_only_lost_track_rate: lidar_lost_rate,
|
||||
fused_lost_track_rate: fused_lost_rate,
|
||||
lost_track_reduction_percent,
|
||||
hardware_reproduction_gate_passed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_metric(
|
||||
envelope: &crate::protocol::TrackEnvelope,
|
||||
truth: Vec3,
|
||||
errors: &mut Vec<f32>,
|
||||
lost: &mut usize,
|
||||
) {
|
||||
let track = &envelope.tracks[0];
|
||||
if track.state == TrackState::Tracking {
|
||||
errors.push(track.position_m.distance(truth));
|
||||
} else {
|
||||
*lost += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn mean(values: &[f32]) -> f32 {
|
||||
if values.is_empty() {
|
||||
f32::INFINITY
|
||||
} else {
|
||||
values.iter().sum::<f32>() / values.len() as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn percentile95(values: &mut [f32]) -> f32 {
|
||||
if values.is_empty() {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
values.sort_by(f32::total_cmp);
|
||||
values[((values.len() - 1) as f32 * 0.95).round() as usize]
|
||||
}
|
||||
|
||||
/// Reproducible benchmark result. The hardware gate always remains false for
|
||||
/// this simulator regardless of performance.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BenchmarkReport {
|
||||
/// Explicit evidence watermark.
|
||||
pub evidence: String,
|
||||
/// Frames evaluated.
|
||||
pub frames: usize,
|
||||
/// Particles per tracker.
|
||||
pub particles: usize,
|
||||
/// Combined LiDAR-only and fused updates per wall-clock second.
|
||||
pub throughput_fps: f64,
|
||||
/// LiDAR-only mean error over non-lost frames.
|
||||
pub lidar_only_mean_error_m: f32,
|
||||
/// Fused mean error over non-lost frames.
|
||||
pub fused_mean_error_m: f32,
|
||||
/// LiDAR-only p95 error.
|
||||
pub lidar_only_p95_error_m: f32,
|
||||
/// Fused p95 error.
|
||||
pub fused_p95_error_m: f32,
|
||||
/// LiDAR-only lost-track fraction.
|
||||
pub lidar_only_lost_track_rate: f32,
|
||||
/// Fused lost-track fraction.
|
||||
pub fused_lost_track_rate: f32,
|
||||
/// Relative lost-track reduction.
|
||||
pub lost_track_reduction_percent: f32,
|
||||
/// Always false for generated input.
|
||||
pub hardware_reproduction_gate_passed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SimRng(u64);
|
||||
|
||||
impl SimRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed)
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1);
|
||||
(self.0 >> 32) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_frames_are_always_l0_and_preserve_histograms() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let frame = scene.frame(Some(Vec3::new(0.0, 0.0, 1.0)), 1.0, 1);
|
||||
assert_eq!(frame.source, FrameSource::Synthetic);
|
||||
assert_eq!(frame.evidence_level, EvidenceLevel::L0Synthetic);
|
||||
assert!(frame.provenance.histogram_preserved);
|
||||
frame.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benchmark_never_promotes_synthetic_to_hardware_evidence() {
|
||||
let report = SyntheticScene::benchmark(40, 128);
|
||||
assert_eq!(report.evidence, "SYNTHETIC_L0");
|
||||
assert!(!report.hardware_reproduction_gate_passed);
|
||||
}
|
||||
}
|
||||
654
v2/crates/ruview-nlos/src/tracker.rs
Normal file
654
v2/crates/ruview-nlos/src/tracker.rs
Normal file
@@ -0,0 +1,654 @@
|
||||
//! Motion-induced aperture particle tracking with optional CSI prior.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::calibration::{Calibration, CalibrationError, PreprocessedFrame};
|
||||
use crate::fusion::{CsiSpatialPrior, FusionError, FusionScope};
|
||||
use crate::protocol::{
|
||||
FrameSource, ModalityContributions, NlosTrack, TrackEnvelope, TrackState, TransientFrame, Vec3,
|
||||
};
|
||||
use crate::TRACK_SCHEMA_V1;
|
||||
|
||||
const SPEED_OF_LIGHT_MPS: f32 = 299_792_458.0;
|
||||
const ALGORITHM_VERSION: &str = "motion-aperture-canonical-v1";
|
||||
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
|
||||
/// A known rigid object represented as weighted points around its origin.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CanonicalObject {
|
||||
/// Canonical local-space points.
|
||||
pub points_m: Vec<Vec3>,
|
||||
/// Relative non-negative return weight for each point.
|
||||
pub weights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl CanonicalObject {
|
||||
/// A single point reflector used for the first reproduction milestone.
|
||||
#[must_use]
|
||||
pub fn point() -> Self {
|
||||
Self {
|
||||
points_m: vec![Vec3::default()],
|
||||
weights: vec![1.0],
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), TrackerError> {
|
||||
if self.points_m.is_empty()
|
||||
|| self.points_m.len() > 4_096
|
||||
|| self.points_m.len() != self.weights.len()
|
||||
|| self.points_m.iter().any(|point| !point.finite())
|
||||
|| self
|
||||
.weights
|
||||
.iter()
|
||||
.any(|weight| !weight.is_finite() || *weight < 0.0)
|
||||
|| self.weights.iter().all(|weight| *weight == 0.0)
|
||||
{
|
||||
return Err(TrackerError::InvalidConfig);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Search volume and quality gates.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrackerConfig {
|
||||
/// Particle count; 1,000 matches the public reproduction default.
|
||||
pub particle_count: usize,
|
||||
/// Inclusive search minimum.
|
||||
pub search_min_m: Vec3,
|
||||
/// Inclusive search maximum.
|
||||
pub search_max_m: Vec3,
|
||||
/// Gaussian random-walk standard deviation per frame.
|
||||
pub motion_std_m: f32,
|
||||
/// Likelihood sharpening exponent.
|
||||
pub eta: f32,
|
||||
/// Maximum aperture samples retained.
|
||||
pub aperture_frames: usize,
|
||||
/// Minimum combined signal quality for `tracking`.
|
||||
pub min_signal_quality: f32,
|
||||
/// Minimum posterior confidence for `tracking`.
|
||||
pub min_confidence: f32,
|
||||
/// Maximum CSI-to-optical time difference.
|
||||
pub max_csi_age_ms: u64,
|
||||
/// Output freshness window.
|
||||
pub output_ttl_ms: u64,
|
||||
/// Deterministic particle RNG seed.
|
||||
pub seed: u64,
|
||||
/// Exact policy/coordinate scope for synthetic fusion tests. Measured
|
||||
/// fusion remains blocked by the v1 lineage boundary.
|
||||
pub fusion_scope: Option<FusionScope>,
|
||||
}
|
||||
|
||||
impl Default for TrackerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
particle_count: 1_000,
|
||||
search_min_m: Vec3::new(-1.0, -0.8, 0.1),
|
||||
search_max_m: Vec3::new(1.0, 0.8, 1.8),
|
||||
motion_std_m: 0.05,
|
||||
eta: 3.0,
|
||||
aperture_frames: 8,
|
||||
min_signal_quality: 0.25,
|
||||
min_confidence: 0.08,
|
||||
max_csi_age_ms: 100,
|
||||
output_ttl_ms: 250,
|
||||
seed: 42,
|
||||
fusion_scope: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackerConfig {
|
||||
fn validate(&self) -> Result<(), TrackerError> {
|
||||
if !(64..=20_000).contains(&self.particle_count)
|
||||
|| !self.search_min_m.finite()
|
||||
|| !self.search_max_m.finite()
|
||||
|| self.search_min_m.x >= self.search_max_m.x
|
||||
|| self.search_min_m.y >= self.search_max_m.y
|
||||
|| self.search_min_m.z >= self.search_max_m.z
|
||||
|| [
|
||||
self.search_min_m.x,
|
||||
self.search_min_m.y,
|
||||
self.search_min_m.z,
|
||||
self.search_max_m.x,
|
||||
self.search_max_m.y,
|
||||
self.search_max_m.z,
|
||||
]
|
||||
.iter()
|
||||
.any(|value| value.abs() > 100.0)
|
||||
|| self.search_max_m.x - self.search_min_m.x > 6.0
|
||||
|| self.search_max_m.y - self.search_min_m.y > 6.0
|
||||
|| self.search_max_m.z - self.search_min_m.z > 6.0
|
||||
|| !self.motion_std_m.is_finite()
|
||||
|| !(0.001..=0.5).contains(&self.motion_std_m)
|
||||
|| !self.eta.is_finite()
|
||||
|| !(0.1..=16.0).contains(&self.eta)
|
||||
|| !(1..=32).contains(&self.aperture_frames)
|
||||
|| !(0.0..=1.0).contains(&self.min_signal_quality)
|
||||
|| !(0.0..=1.0).contains(&self.min_confidence)
|
||||
|| self.max_csi_age_ms > 5_000
|
||||
|| !(1..=5_000).contains(&self.output_ttl_ms)
|
||||
|| self
|
||||
.fusion_scope
|
||||
.as_ref()
|
||||
.is_some_and(|scope| scope.validate().is_err())
|
||||
{
|
||||
return Err(TrackerError::InvalidConfig);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Particle {
|
||||
position: Vec3,
|
||||
velocity: Vec3,
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
/// Stateful single-target motion-induced aperture tracker.
|
||||
pub struct MotionApertureTracker {
|
||||
calibration: Calibration,
|
||||
canonical: CanonicalObject,
|
||||
config: TrackerConfig,
|
||||
particles: Vec<Particle>,
|
||||
aperture: VecDeque<PreprocessedFrame>,
|
||||
last_sequence: Option<u64>,
|
||||
last_monotonic_ns: Option<u64>,
|
||||
last_csi_sequence: Option<u64>,
|
||||
rng: DeterministicRng,
|
||||
}
|
||||
|
||||
impl MotionApertureTracker {
|
||||
/// Construct a tracker with a deterministic uniform prior.
|
||||
pub fn new(
|
||||
calibration: Calibration,
|
||||
canonical: CanonicalObject,
|
||||
config: TrackerConfig,
|
||||
) -> Result<Self, TrackerError> {
|
||||
config.validate()?;
|
||||
canonical.validate()?;
|
||||
if config
|
||||
.fusion_scope
|
||||
.as_ref()
|
||||
.is_some_and(|scope| scope.session_id != calibration.session_id())
|
||||
{
|
||||
return Err(TrackerError::InvalidConfig);
|
||||
}
|
||||
let work = config
|
||||
.particle_count
|
||||
.checked_mul(canonical.points_m.len())
|
||||
.and_then(|value| value.checked_mul(calibration.zone_count()))
|
||||
.and_then(|value| value.checked_mul(config.aperture_frames))
|
||||
.ok_or(TrackerError::InvalidConfig)?;
|
||||
if work > 100_000_000 {
|
||||
return Err(TrackerError::InvalidConfig);
|
||||
}
|
||||
let mut rng = DeterministicRng::new(config.seed);
|
||||
let uniform = 1.0 / config.particle_count as f32;
|
||||
let particles = (0..config.particle_count)
|
||||
.map(|_| Particle {
|
||||
position: random_point(&mut rng, config.search_min_m, config.search_max_m),
|
||||
velocity: Vec3::default(),
|
||||
weight: uniform,
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
calibration,
|
||||
canonical,
|
||||
config,
|
||||
particles,
|
||||
aperture: VecDeque::new(),
|
||||
last_sequence: None,
|
||||
last_monotonic_ns: None,
|
||||
last_csi_sequence: None,
|
||||
rng,
|
||||
})
|
||||
}
|
||||
|
||||
/// Process one ordered transient frame and optional calibrated CSI prior.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
frame: &TransientFrame,
|
||||
csi_prior: Option<&CsiSpatialPrior>,
|
||||
) -> Result<TrackEnvelope, TrackerError> {
|
||||
let expires_at_unix_ms = frame
|
||||
.captured_at_unix_ms
|
||||
.checked_add(self.config.output_ttl_ms)
|
||||
.filter(|value| *value <= MAX_SAFE_INTEGER)
|
||||
.ok_or(TrackerError::TimestampOverflow)?;
|
||||
if self
|
||||
.last_sequence
|
||||
.is_some_and(|last| frame.sequence <= last)
|
||||
|| self
|
||||
.last_monotonic_ns
|
||||
.is_some_and(|last| frame.monotonic_ns <= last)
|
||||
{
|
||||
return Err(TrackerError::ReplayOrOutOfOrder);
|
||||
}
|
||||
let delta_seconds = self.last_monotonic_ns.map_or(1.0 / 30.0, |last| {
|
||||
((frame.monotonic_ns - last) as f32 * 1e-9).clamp(1.0 / 240.0, 0.5)
|
||||
});
|
||||
let processed = self.calibration.preprocess(frame)?;
|
||||
let csi = if let Some(prior) = csi_prior {
|
||||
prior.validate()?;
|
||||
let expected_scope = self
|
||||
.config
|
||||
.fusion_scope
|
||||
.as_ref()
|
||||
.ok_or(FusionError::BindingMismatch)?;
|
||||
if &prior.scope != expected_scope
|
||||
|| frame.session_id != expected_scope.session_id
|
||||
|| prior.source != frame.source
|
||||
{
|
||||
return Err(FusionError::BindingMismatch.into());
|
||||
}
|
||||
if frame.source != FrameSource::Synthetic {
|
||||
return Err(FusionError::MeasuredFusionUnavailable.into());
|
||||
}
|
||||
if self
|
||||
.last_csi_sequence
|
||||
.is_some_and(|last| prior.sequence <= last)
|
||||
{
|
||||
return Err(FusionError::ReplayOrOutOfOrder.into());
|
||||
}
|
||||
let age = frame
|
||||
.captured_at_unix_ms
|
||||
.abs_diff(prior.captured_at_unix_ms);
|
||||
if age > self.config.max_csi_age_ms {
|
||||
return Err(TrackerError::Fusion(FusionError::Stale));
|
||||
}
|
||||
Some(prior)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.last_sequence = Some(frame.sequence);
|
||||
self.last_monotonic_ns = Some(frame.monotonic_ns);
|
||||
if let Some(prior) = csi {
|
||||
self.last_csi_sequence = Some(prior.sequence);
|
||||
}
|
||||
self.aperture.push_back(processed);
|
||||
while self.aperture.len() > self.config.aperture_frames {
|
||||
self.aperture.pop_front();
|
||||
}
|
||||
|
||||
self.propagate(delta_seconds);
|
||||
let mut log_weights = Vec::with_capacity(self.particles.len());
|
||||
let mut max_log = f32::NEG_INFINITY;
|
||||
for particle in &self.particles {
|
||||
let lidar_score = self.score_particle(particle, frame.monotonic_ns).max(1e-9);
|
||||
let mut log_weight = self.config.eta * lidar_score.ln();
|
||||
if let Some(prior) = csi {
|
||||
log_weight += prior.log_likelihood(particle.position);
|
||||
}
|
||||
max_log = max_log.max(log_weight);
|
||||
log_weights.push(log_weight);
|
||||
}
|
||||
let mut sum = 0.0_f32;
|
||||
for (particle, log_weight) in self.particles.iter_mut().zip(log_weights) {
|
||||
particle.weight = (log_weight - max_log).exp();
|
||||
sum += particle.weight;
|
||||
}
|
||||
if !sum.is_finite() || sum <= f32::EPSILON {
|
||||
for particle in &mut self.particles {
|
||||
particle.weight = 1.0 / self.config.particle_count as f32;
|
||||
}
|
||||
} else {
|
||||
for particle in &mut self.particles {
|
||||
particle.weight /= sum;
|
||||
}
|
||||
}
|
||||
|
||||
let envelope = self.posterior(frame, csi, expires_at_unix_ms);
|
||||
self.systematic_resample();
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
fn propagate(&mut self, delta_seconds: f32) {
|
||||
let min = self.config.search_min_m;
|
||||
let max = self.config.search_max_m;
|
||||
for particle in &mut self.particles {
|
||||
let innovation = Vec3::new(
|
||||
self.rng.gaussian() * self.config.motion_std_m,
|
||||
self.rng.gaussian() * self.config.motion_std_m,
|
||||
self.rng.gaussian() * self.config.motion_std_m,
|
||||
);
|
||||
let previous_position = particle.position;
|
||||
let proposed = particle
|
||||
.position
|
||||
.plus(particle.velocity.scale(delta_seconds))
|
||||
.plus(innovation);
|
||||
particle.position = clamp_vec(proposed, min, max);
|
||||
let observed_velocity = particle
|
||||
.position
|
||||
.minus(previous_position)
|
||||
.scale(1.0 / delta_seconds);
|
||||
particle.velocity = clamp_vec(
|
||||
particle
|
||||
.velocity
|
||||
.scale(0.65)
|
||||
.plus(observed_velocity.scale(0.35)),
|
||||
Vec3::new(-20.0, -20.0, -20.0),
|
||||
Vec3::new(20.0, 20.0, 20.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn score_particle(&self, particle: &Particle, current_monotonic_ns: u64) -> f32 {
|
||||
let mut aperture_score = 0.0_f32;
|
||||
let mut valid_frames = 0_usize;
|
||||
for frame in &self.aperture {
|
||||
let aperture_age_seconds =
|
||||
current_monotonic_ns.saturating_sub(frame.monotonic_ns) as f32 * 1e-9;
|
||||
let historical_origin = particle
|
||||
.position
|
||||
.minus(particle.velocity.scale(aperture_age_seconds));
|
||||
let observed_norm = frame
|
||||
.light_cone_histograms
|
||||
.iter()
|
||||
.map(|value| value * value)
|
||||
.sum::<f32>()
|
||||
.sqrt();
|
||||
if observed_norm <= f32::EPSILON {
|
||||
continue;
|
||||
}
|
||||
let native_denominator = (frame.bin_count - 1).max(1) as f32;
|
||||
let mut dot = 0.0_f32;
|
||||
let mut predicted_norm_sq = 0.0_f32;
|
||||
for (zone_index, wall) in frame.wall_points_world_m.iter().enumerate() {
|
||||
for (point, weight) in self
|
||||
.canonical
|
||||
.points_m
|
||||
.iter()
|
||||
.zip(self.canonical.weights.iter())
|
||||
{
|
||||
let target = historical_origin.plus(*point);
|
||||
let distance = wall.distance(target).max(0.01);
|
||||
let extra_bin = (2.0 * distance
|
||||
/ (SPEED_OF_LIGHT_MPS * frame.bin_width_ps * 1e-12))
|
||||
.round();
|
||||
let v_bin = ((extra_bin * extra_bin / native_denominator).floor() as usize)
|
||||
.min(frame.bin_count - 1);
|
||||
let amplitude = *weight / distance.powi(4).max(1e-4);
|
||||
for (offset, kernel) in [(-1_i32, 0.5_f32), (0, 1.0), (1, 0.5)] {
|
||||
let index = v_bin as i32 + offset;
|
||||
if !(0..frame.bin_count as i32).contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let predicted = amplitude * kernel;
|
||||
dot += frame.light_cone_histograms
|
||||
[zone_index * frame.bin_count + index as usize]
|
||||
* predicted;
|
||||
predicted_norm_sq += predicted * predicted;
|
||||
}
|
||||
}
|
||||
}
|
||||
let denom = observed_norm * predicted_norm_sq.sqrt();
|
||||
if denom > f32::EPSILON {
|
||||
aperture_score += (dot / denom).clamp(0.0, 1.0);
|
||||
valid_frames += 1;
|
||||
}
|
||||
}
|
||||
if valid_frames == 0 {
|
||||
0.0
|
||||
} else {
|
||||
aperture_score / valid_frames as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn posterior(
|
||||
&self,
|
||||
frame: &TransientFrame,
|
||||
csi: Option<&CsiSpatialPrior>,
|
||||
expires_at_unix_ms: u64,
|
||||
) -> TrackEnvelope {
|
||||
let mut position = Vec3::default();
|
||||
let mut velocity = Vec3::default();
|
||||
let mut entropy = 0.0_f32;
|
||||
for particle in &self.particles {
|
||||
position = position.plus(particle.position.scale(particle.weight));
|
||||
velocity = velocity.plus(particle.velocity.scale(particle.weight));
|
||||
if particle.weight > 0.0 {
|
||||
entropy -= particle.weight * particle.weight.ln();
|
||||
}
|
||||
}
|
||||
let mut covariance = Vec3::default();
|
||||
for particle in &self.particles {
|
||||
let delta = particle.position.minus(position);
|
||||
covariance.x += particle.weight * delta.x * delta.x;
|
||||
covariance.y += particle.weight * delta.y * delta.y;
|
||||
covariance.z += particle.weight * delta.z * delta.z;
|
||||
}
|
||||
let normalized_entropy = entropy / (self.particles.len() as f32).ln().max(1.0);
|
||||
let posterior_focus = (1.0 - normalized_entropy).clamp(0.0, 1.0);
|
||||
let lidar_quality = self
|
||||
.aperture
|
||||
.iter()
|
||||
.map(|sample| sample.signal_quality)
|
||||
.sum::<f32>()
|
||||
/ self.aperture.len().max(1) as f32;
|
||||
let csi_quality = csi.map_or(0.0, |prior| prior.confidence);
|
||||
let signal_quality = 1.0 - (1.0 - lidar_quality) * (1.0 - 0.6 * csi_quality);
|
||||
let confidence = (posterior_focus * 1.4 + signal_quality * 0.45).clamp(0.0, 1.0);
|
||||
let optical_present = lidar_quality > 1e-6;
|
||||
let state = if optical_present
|
||||
&& signal_quality >= self.config.min_signal_quality
|
||||
&& confidence >= self.config.min_confidence
|
||||
{
|
||||
TrackState::Tracking
|
||||
} else if optical_present && signal_quality >= self.config.min_signal_quality * 0.5 {
|
||||
TrackState::Degraded
|
||||
} else {
|
||||
TrackState::Unknown
|
||||
};
|
||||
let lidar_contribution = if csi.is_some() {
|
||||
lidar_quality / (lidar_quality + csi_quality + f32::EPSILON)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let csi_contribution = if csi.is_some() {
|
||||
1.0 - lidar_contribution
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// v1 cannot retain both modality lineages and therefore never promotes
|
||||
// measured output to L3. The only accepted CSI path is synthetic L0.
|
||||
let evidence_level = frame.evidence_level;
|
||||
let mut provenance = frame.provenance.clone();
|
||||
provenance.transport = if frame.source == FrameSource::Live {
|
||||
"ruview_server".into()
|
||||
} else {
|
||||
"replay".into()
|
||||
};
|
||||
let output_calibration_hash = if frame.source == FrameSource::Synthetic {
|
||||
"0".repeat(64)
|
||||
} else {
|
||||
self.calibration.hash().to_owned()
|
||||
};
|
||||
let envelope = TrackEnvelope {
|
||||
schema: TRACK_SCHEMA_V1.into(),
|
||||
session_id: frame.session_id.clone(),
|
||||
sequence: frame.sequence,
|
||||
captured_at_unix_ms: frame.captured_at_unix_ms,
|
||||
expires_at_unix_ms,
|
||||
source: frame.source,
|
||||
evidence_level,
|
||||
algorithm_version: ALGORITHM_VERSION.into(),
|
||||
calibration_hash: output_calibration_hash,
|
||||
provenance,
|
||||
tracks: vec![NlosTrack {
|
||||
track_id: "hidden-target-0".into(),
|
||||
state,
|
||||
position_m: position,
|
||||
velocity_mps: velocity,
|
||||
covariance_diagonal_m2: covariance,
|
||||
confidence,
|
||||
posterior_entropy: entropy,
|
||||
signal_quality,
|
||||
modality_contributions: ModalityContributions {
|
||||
lidar: lidar_contribution.clamp(0.0, 1.0),
|
||||
csi: csi_contribution.clamp(0.0, 1.0),
|
||||
},
|
||||
}],
|
||||
};
|
||||
debug_assert!(envelope.validate().is_ok());
|
||||
envelope
|
||||
}
|
||||
|
||||
fn systematic_resample(&mut self) {
|
||||
let count = self.particles.len();
|
||||
let step = 1.0 / count as f32;
|
||||
let start = self.rng.uniform() * step;
|
||||
let mut cumulative = self.particles[0].weight;
|
||||
let mut index = 0_usize;
|
||||
let mut next = Vec::with_capacity(count);
|
||||
for sample in 0..count {
|
||||
let threshold = start + sample as f32 * step;
|
||||
while threshold > cumulative && index + 1 < count {
|
||||
index += 1;
|
||||
cumulative += self.particles[index].weight;
|
||||
}
|
||||
let mut particle = self.particles[index];
|
||||
particle.weight = step;
|
||||
next.push(particle);
|
||||
}
|
||||
self.particles = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn random_point(rng: &mut DeterministicRng, min: Vec3, max: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
min.x + rng.uniform() * (max.x - min.x),
|
||||
min.y + rng.uniform() * (max.y - min.y),
|
||||
min.z + rng.uniform() * (max.z - min.z),
|
||||
)
|
||||
}
|
||||
|
||||
fn clamp_vec(value: Vec3, min: Vec3, max: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
value.x.clamp(min.x, max.x),
|
||||
value.y.clamp(min.y, max.y),
|
||||
value.z.clamp(min.z, max.z),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct DeterministicRng {
|
||||
state: u64,
|
||||
spare_gaussian: Option<f32>,
|
||||
}
|
||||
|
||||
impl DeterministicRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
state: seed,
|
||||
spare_gaussian: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
let mut z = self.state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
fn uniform(&mut self) -> f32 {
|
||||
let bits = (self.next_u64() >> 40) as u32;
|
||||
(bits as f32 + 0.5) / 16_777_216.0
|
||||
}
|
||||
|
||||
fn gaussian(&mut self) -> f32 {
|
||||
if let Some(value) = self.spare_gaussian.take() {
|
||||
return value;
|
||||
}
|
||||
let u1 = self.uniform().max(f32::EPSILON);
|
||||
let u2 = self.uniform();
|
||||
let radius = (-2.0 * u1.ln()).sqrt();
|
||||
let angle = std::f32::consts::TAU * u2;
|
||||
self.spare_gaussian = Some(radius * angle.sin());
|
||||
radius * angle.cos()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracking failure at a fail-closed boundary.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TrackerError {
|
||||
/// Tracker or canonical-object bounds are invalid.
|
||||
#[error("invalid tracker configuration")]
|
||||
InvalidConfig,
|
||||
/// Sequence repeated or moved backwards.
|
||||
#[error("replayed or out-of-order transient frame")]
|
||||
ReplayOrOutOfOrder,
|
||||
/// Capture time plus output TTL cannot be represented by the v1 JSON-safe
|
||||
/// integer contract.
|
||||
#[error("track expiry exceeds the v1 timestamp range")]
|
||||
TimestampOverflow,
|
||||
/// Calibration/preprocessing failure.
|
||||
#[error(transparent)]
|
||||
Calibration(#[from] CalibrationError),
|
||||
/// CSI validation or freshness failure.
|
||||
#[error(transparent)]
|
||||
Fusion(#[from] FusionError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::calibration::{Calibration, CalibrationConfig};
|
||||
use crate::simulator::SyntheticScene;
|
||||
|
||||
#[test]
|
||||
fn replayed_sequence_is_rejected() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let calibration = Calibration::from_background(
|
||||
&scene.background_frames(20),
|
||||
CalibrationConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut tracker = MotionApertureTracker::new(
|
||||
calibration,
|
||||
CanonicalObject::point(),
|
||||
TrackerConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let frame = scene.frame(Some(Vec3::new(0.0, 0.0, 1.0)), 1.0, 100);
|
||||
tracker.update(&frame, None).unwrap();
|
||||
assert!(matches!(
|
||||
tracker.update(&frame, None),
|
||||
Err(TrackerError::ReplayOrOutOfOrder)
|
||||
));
|
||||
|
||||
let mut advanced_sequence = frame;
|
||||
advanced_sequence.sequence += 1;
|
||||
assert!(matches!(
|
||||
tracker.update(&advanced_sequence, None),
|
||||
Err(TrackerError::ReplayOrOutOfOrder)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_safe_capture_time_fails_closed_before_expiry_overflow() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let calibration = Calibration::from_background(
|
||||
&scene.background_frames(20),
|
||||
CalibrationConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut tracker = MotionApertureTracker::new(
|
||||
calibration,
|
||||
CanonicalObject::point(),
|
||||
TrackerConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut frame = scene.frame(Some(Vec3::new(0.0, 0.0, 1.0)), 1.0, 100);
|
||||
frame.captured_at_unix_ms = MAX_SAFE_INTEGER;
|
||||
assert!(matches!(
|
||||
tracker.update(&frame, None),
|
||||
Err(TrackerError::TimestampOverflow)
|
||||
));
|
||||
}
|
||||
}
|
||||
40
v2/crates/ruview-nlos/tests/acceptance.rs
Normal file
40
v2/crates/ruview-nlos/tests/acceptance.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Deterministic software acceptance. All inputs are SYNTHETIC/L0; this proves
|
||||
//! contract and fusion behavior, not the ADR-331 hardware reproduction gate.
|
||||
|
||||
use ruview_nlos::protocol::{EvidenceLevel, FrameSource};
|
||||
use ruview_nlos::{SyntheticScene, TrackEnvelope};
|
||||
|
||||
#[test]
|
||||
fn golden_track_contract_round_trips_byte_semantics() {
|
||||
let fixture = include_str!("fixtures/track_synthetic.json");
|
||||
let envelope: TrackEnvelope = serde_json::from_str(fixture).unwrap();
|
||||
envelope.validate().unwrap();
|
||||
let encoded = serde_json::to_string(&envelope).unwrap();
|
||||
let decoded: TrackEnvelope = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(decoded, envelope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_fusion_reduces_lost_tracks_by_at_least_25_percent() {
|
||||
let report = SyntheticScene::benchmark(120, 512);
|
||||
assert_eq!(report.evidence, "SYNTHETIC_L0");
|
||||
assert!(!report.hardware_reproduction_gate_passed);
|
||||
assert!(
|
||||
report.lost_track_reduction_percent >= 25.0,
|
||||
"SYNTHETIC architecture gate: LiDAR-only lost rate={}, fused lost rate={}, reduction={}%; this is not hardware evidence",
|
||||
report.lidar_only_lost_track_rate,
|
||||
report.fused_lost_track_rate,
|
||||
report.lost_track_reduction_percent,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_contract_can_never_alias_to_live_evidence() {
|
||||
let mut scene = SyntheticScene::default();
|
||||
let frame = scene.frame(None, 0.0, 1);
|
||||
assert_eq!(frame.source, FrameSource::Synthetic);
|
||||
assert_eq!(frame.evidence_level, EvidenceLevel::L0Synthetic);
|
||||
assert_eq!(frame.calibration_hash, "0".repeat(64));
|
||||
assert_eq!(frame.provenance.transport, "replay");
|
||||
frame.validate().unwrap();
|
||||
}
|
||||
32
v2/crates/ruview-nlos/tests/fixtures/track_synthetic.json
vendored
Normal file
32
v2/crates/ruview-nlos/tests/fixtures/track_synthetic.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"schema": "ruview.nlos.track.v1",
|
||||
"sessionId": "synthetic-contract-1",
|
||||
"sequence": 42,
|
||||
"capturedAtUnixMs": 1800000000000,
|
||||
"expiresAtUnixMs": 1800000000250,
|
||||
"source": "synthetic",
|
||||
"evidenceLevel": "l0_synthetic",
|
||||
"algorithmVersion": "motion-aperture-canonical-v1",
|
||||
"calibrationHash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"provenance": {
|
||||
"sensorId": "sim-vl53l8ch",
|
||||
"sensorModel": "VL53L8CH-simulator",
|
||||
"firmwareVersion": "sim-v1",
|
||||
"transientKind": "replay",
|
||||
"histogramPreserved": true,
|
||||
"transport": "replay"
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"trackId": "hidden-target-0",
|
||||
"state": "tracking",
|
||||
"positionM": { "x": 0.2, "y": -0.1, "z": 1.0 },
|
||||
"velocityMps": { "x": 0.01, "y": 0.0, "z": 0.0 },
|
||||
"covarianceDiagonalM2": { "x": 0.01, "y": 0.02, "z": 0.03 },
|
||||
"confidence": 0.75,
|
||||
"posteriorEntropy": 2.0,
|
||||
"signalQuality": 0.7,
|
||||
"modalityContributions": { "lidar": 0.6, "csi": 0.4 }
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user