mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
1df6d1e1ee
* 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>
302 lines
12 KiB
Rust
302 lines
12 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|