Files
ruvnet--RuView/v2/crates/nvsim/src/pipeline.rs
T
rUv 1df6d1e1ee security(nvsim): guard degenerate input — config panic + NaN silent-corruption + ADR-177 (#1098)
* fix(nvsim): guard degenerate input — config-induced panic + NaN-state poisoning

Beyond-SOTA security review of the ADR-089 NV-diamond simulator (milestone #9,
crate 2 of 4). Two real degenerate-input findings, each pinned fails-on-old:

NVSIM-DT-01 (config panic/DoS, pipeline.rs): an external f_s_hz == 0 made
dt == +Inf, dt_us saturated to u64::MAX, and `sample * dt_us` panicked with
"attempt to multiply with overflow" at sample >= 2 (debug/WASM panic=abort;
garbage t_us in release). Fix: sanitise dt (non-finite/non-positive -> 1 µs
fallback), cap the u64 cast, and saturating_mul the timestamp.

NVSIM-NAN-01 (NaN-state poisoning, digitiser.rs): a non-finite scene parameter
(NaN dipole position / Inf moment / NaN loop radius) bypasses the near-field
clamp (NaN < R_MIN_M is false) and yields a NaN field; at the ADC `NaN as i32`
== 0 silently emitted b_pt=[0,0,0] with ADC_SATURATED CLEAR — indistinguishable
from a legit zero-field reading. Fix at the funnel: adc_quantise treats any
non-finite input as out-of-range -> clamps to code 0 AND raises the saturation
flag, so the corruption is visible downstream.

Determinism integrity, panic-free MagFrame deserialisation, and RNG seeding
confirmed clean with evidence. The published cross-machine witness
(cc8de9b0…93b4) is unchanged — guards only affect degenerate inputs.

cargo test -p nvsim --no-default-features: 50 -> 53 passed, 0 failed.
Workspace green; Python deterministic proof unchanged (f8e76f21…46f7a,
nvsim off the signal proof path). Needs ADR slot 177.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-177 — nvsim degenerate-input hardening

Records the 2 MEASURED MEDIUM fixes in 37764be55 (NVSIM-DT-01 config-induced
overflow panic / WASM-abort DoS; NVSIM-NAN-01 non-finite scene param →
silent fake zero-field reading with saturation flag clear) + 3 pins, and the
clean-with-evidence determinism/deser/div-by-zero verdict. Cross-machine
witness cc8de9b0…93b4 reproduces unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 10:55:04 -04:00

302 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end NV-diamond simulator pipeline — Pass 5b of the implementation plan.
//!
//! `Pipeline` wires every module: scene → source synthesis → propagation →
//! NV ensemble → digitiser → MagFrame stream. One `Pipeline::run(n)` call
//! produces an n-sample deterministic frame stream from a scene + config.
//!
//! Determinism: same `(scene, config, seed)` ⇒ byte-identical frame stream
//! across runs and machines. Underwrites the proof-bundle commitment in
//! plan §5 — Pass 6 wraps this in a SHA-256 witness.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::digitiser::{adc_quantise, DigitiserConfig};
use crate::frame::{flag, MagFrame};
use crate::scene::Scene;
use crate::sensor::{NvSensor, NvSensorConfig};
use crate::source::scene_field_at;
/// Pipeline configuration.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct PipelineConfig {
/// Sensor / digitiser sampling parameters.
pub digitiser: DigitiserConfig,
/// NV-ensemble physics parameters.
pub sensor: NvSensorConfig,
/// Per-sample integration time (s). Default 1/f_s.
pub dt_s: Option<f64>,
}
/// Forward-only NV-diamond pipeline.
#[derive(Debug, Clone)]
pub struct Pipeline {
scene: Scene,
config: PipelineConfig,
seed: u64,
}
impl Pipeline {
/// Construct a pipeline. `seed` makes shot-noise reproducible — same
/// `(scene, config, seed)` produces byte-identical output.
pub fn new(scene: Scene, config: PipelineConfig, seed: u64) -> Self {
Self {
scene,
config,
seed,
}
}
/// Run `n_samples` of the pipeline. Returns one [`MagFrame`] per
/// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
/// in scene-major / sample-minor order.
pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
// `dt` is derived from caller-supplied config — an external boundary
// (e.g. the WASM `config_json`). A degenerate `f_s_hz == 0` makes
// `1.0 / f_s_hz == +Inf`; a non-finite or non-positive `dt_s` is
// equally hostile. Sanitise before any arithmetic that could panic.
let raw_dt = self
.config
.dt_s
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
// Fall back to a 1 µs step (the smallest physically meaningful
// sample interval here) when `dt` is non-finite or non-positive, so
// the run produces well-defined frames instead of garbage / a panic.
let dt = if raw_dt.is_finite() && raw_dt > 0.0 {
raw_dt
} else {
1.0e-6
};
// `dt` is now finite & positive, so `dt * 1e6` is finite. Cap the
// `u64` cast defensively (a huge but finite `dt` could still exceed
// `u64::MAX`) and use `saturating_mul` for the per-sample timestamp so
// a pathological config can never trigger a multiply-with-overflow
// panic (debug / WASM panic=abort) or wrap to a garbage timestamp.
let dt_us = (dt * 1.0e6).min(u64::MAX as f64) as u64;
let nv = NvSensor::new(self.config.sensor);
let mut out: Vec<MagFrame> =
Vec::with_capacity(n_samples.saturating_mul(self.scene.sensors.len()));
for (sensor_idx, &sensor_pos) in self.scene.sensors.iter().enumerate() {
for sample in 0..n_samples {
let (b_synth, near_field) = scene_field_at(&self.scene, sensor_pos);
// Per-sample seed mixes the global seed with sample/sensor
// indices so different (sensor, sample) pairs draw from
// independent shot-noise streams while the whole run stays
// reproducible from the global seed.
let per_sample_seed = self
.seed
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add((sensor_idx as u64) << 32)
.wrapping_add(sample as u64);
let reading = nv.sample(b_synth, dt, per_sample_seed);
// ADC quantise each axis independently, raising the
// saturation flag if any axis clips.
let mut adc_sat = false;
let mut b_pt = [0.0_f32; 3];
for (k, b) in b_pt.iter_mut().enumerate() {
let (code, sat) = adc_quantise(reading.b_recovered[k]);
adc_sat |= sat;
let recovered_t = code as f64 * crate::digitiser::ADC_LSB_T;
*b = (recovered_t * 1.0e12) as f32; // T → pT
}
let sigma_pt = [
(reading.sigma_per_axis[0] * 1.0e12) as f32,
(reading.sigma_per_axis[1] * 1.0e12) as f32,
(reading.sigma_per_axis[2] * 1.0e12) as f32,
];
let mut frame = MagFrame::empty(sensor_idx as u16);
frame.t_us = (sample as u64).saturating_mul(dt_us);
frame.b_pt = b_pt;
frame.sigma_pt = sigma_pt;
frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
frame.temperature_k = 295.0;
if near_field {
frame.set_flag(flag::SATURATION_NEAR_FIELD);
}
if adc_sat {
frame.set_flag(flag::ADC_SATURATED);
}
if self.config.sensor.shot_noise_disabled {
frame.set_flag(flag::SHOT_NOISE_DISABLED);
}
out.push(frame);
}
}
out
}
/// Run the pipeline and return a SHA-256 of the concatenated raw frame
/// bytes. The witness is content-addressable: same `(scene, config, seed)`
/// produces byte-identical witnesses across runs and machines. Backbone
/// of Pass 6's proof bundle.
pub fn run_with_witness(&self, n_samples: usize) -> (Vec<MagFrame>, [u8; 32]) {
let frames = self.run(n_samples);
let mut hasher = Sha256::new();
for f in &frames {
hasher.update(f.to_bytes());
}
let digest: [u8; 32] = hasher.finalize().into();
(frames, digest)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::DipoleSource;
fn fixture_scene() -> Scene {
let mut s = Scene::new();
// Strong-ish dipole 50 cm above the sensor.
s.add_dipole(DipoleSource::new([0.0, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
s.add_sensor([0.0, 0.0, 0.0]);
s
}
#[test]
fn determinism_same_seed_byte_identical_witness() {
// Plan §5 acceptance: (scene, seed) → byte-identical proof bundle.
let scene = fixture_scene();
let cfg = PipelineConfig::default();
let p1 = Pipeline::new(scene.clone(), cfg, 42);
let p2 = Pipeline::new(scene, cfg, 42);
let (_, w1) = p1.run_with_witness(64);
let (_, w2) = p2.run_with_witness(64);
assert_eq!(w1, w2, "same seed must produce identical witnesses");
}
#[test]
fn different_seeds_produce_different_witnesses() {
// Sanity: the seed actually does something. Two different seeds
// must produce different witnesses (overwhelmingly likely).
let scene = fixture_scene();
let cfg = PipelineConfig::default();
let (_, w1) = Pipeline::new(scene.clone(), cfg, 1).run_with_witness(64);
let (_, w2) = Pipeline::new(scene, cfg, 2).run_with_witness(64);
assert_ne!(w1, w2);
}
#[test]
fn frame_count_matches_sensor_x_sample_product() {
let scene = fixture_scene();
let cfg = PipelineConfig::default();
let p = Pipeline::new(scene, cfg, 7);
let frames = p.run(32);
assert_eq!(frames.len(), 32);
for (i, f) in frames.iter().enumerate() {
assert_eq!(f.sensor_id, 0);
assert_eq!(f.t_us, (i as u64) * (1.0e6 / 10_000.0) as u64);
}
}
#[test]
fn shot_noise_disabled_propagates_flag_and_yields_clean_signal() {
// With shot noise off, every frame must carry SHOT_NOISE_DISABLED
// and the recovered field must reproduce the analytical value
// within ADC ½-LSB. Plan §5 noise-floor commitment.
let scene = fixture_scene();
let cfg = PipelineConfig {
sensor: NvSensorConfig {
shot_noise_disabled: true,
..NvSensorConfig::default()
},
..PipelineConfig::default()
};
let p = Pipeline::new(scene.clone(), cfg, 0);
let frames = p.run(8);
let (b_analytic, _) = scene_field_at(&scene, scene.sensors[0]);
for f in &frames {
assert!(f.has_flag(flag::SHOT_NOISE_DISABLED));
for (k, (&b_pt, &b_ref)) in f.b_pt.iter().zip(b_analytic.iter()).enumerate() {
let recovered_t = b_pt as f64 * 1.0e-12;
let lsb_t = crate::digitiser::ADC_LSB_T;
assert!(
(recovered_t - b_ref).abs() <= lsb_t,
"noise-off recovery error > 1 LSB for axis {k}"
);
}
}
}
#[test]
fn degenerate_zero_sample_rate_does_not_panic() {
// Security pinning (panic / DoS guard): an externally-supplied
// `f_s_hz == 0` makes `1/f_s_hz == +Inf`; pre-fix that produced
// `dt_us == u64::MAX`, and `sample * dt_us` panicked with
// "attempt to multiply with overflow" (debug / WASM panic=abort) at
// sample >= 2, or wrapped to a garbage timestamp in release. The
// sanitised `dt` + `saturating_mul` must keep the run finite.
let scene = fixture_scene();
let cfg = PipelineConfig {
digitiser: crate::digitiser::DigitiserConfig {
f_s_hz: 0.0,
f_mod_hz: 1000.0,
},
..PipelineConfig::default()
};
let frames = Pipeline::new(scene, cfg, 42).run(8);
assert_eq!(frames.len(), 8);
for f in &frames {
// Timestamps are monotone-well-defined, not garbage.
assert!(f.t_us < u64::MAX);
}
}
#[test]
fn non_finite_scene_input_flags_frame_instead_of_silently_zeroing() {
// Security pinning (NaN-state-poisoning guard): a NaN dipole position
// makes `r_norm` NaN, which bypasses the near-field clamp
// (`NaN < R_MIN_M` is false) and yields a NaN field. Pre-fix the
// digitiser silently coerced that NaN to code 0 with the saturation
// flag CLEAR — a frame indistinguishable from a real zero-field
// reading. Post-fix the frame must carry ADC_SATURATED so the
// corruption is visible downstream.
let mut scene = Scene::new();
scene.add_dipole(DipoleSource::new([f64::NAN, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
scene.add_sensor([0.0, 0.0, 0.0]);
let cfg = PipelineConfig {
sensor: NvSensorConfig {
shot_noise_disabled: true,
..NvSensorConfig::default()
},
..PipelineConfig::default()
};
let frames = Pipeline::new(scene, cfg, 0).run(4);
for f in &frames {
assert!(
f.has_flag(flag::ADC_SATURATED),
"non-finite field must raise ADC_SATURATED, not emit a silent zero frame"
);
// And the emitted value is a defined number, not NaN.
for b in f.b_pt {
assert!(b.is_finite());
}
}
}
#[test]
fn adc_saturation_flag_fires_above_full_scale() {
// Place a dipole close enough to drive the field above ±10 µT FS.
let mut scene = Scene::new();
scene.add_dipole(DipoleSource::new([0.0, 0.0, 0.005], [0.0, 0.0, 1.0])); // 1 A·m² at 5 mm
scene.add_sensor([0.0, 0.0, 0.0]);
let cfg = PipelineConfig {
sensor: NvSensorConfig {
shot_noise_disabled: true,
..NvSensorConfig::default()
},
..PipelineConfig::default()
};
let frames = Pipeline::new(scene, cfg, 0).run(4);
let any_sat = frames.iter().any(|f| f.has_flag(flag::ADC_SATURATED));
assert!(
any_sat,
"ADC_SATURATED flag did not fire on a near-field dipole that should drive FS"
);
}
}