1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! 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"
);
}
}