Skip to main content

ruqu_core/
replay.rs

1/// Deterministic replay engine for quantum simulation reproducibility.
2///
3/// Captures all parameters that affect simulation output (circuit structure,
4/// seed, noise model, shots) into an [`ExecutionRecord`] so that any run can
5/// be replayed bit-for-bit. Also provides [`StateCheckpoint`] for snapshotting
6/// the raw amplitude vector mid-simulation.
7
8use crate::circuit::QuantumCircuit;
9use crate::gate::Gate;
10use crate::simulator::{SimConfig, Simulator};
11use crate::types::{Complex, NoiseModel};
12
13use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17// ---------------------------------------------------------------------------
18// NoiseConfig (serialisable snapshot of a NoiseModel)
19// ---------------------------------------------------------------------------
20
21/// Snapshot of a noise model configuration suitable for storage and replay.
22#[derive(Debug, Clone, PartialEq)]
23pub struct NoiseConfig {
24    pub depolarizing_rate: f64,
25    pub bit_flip_rate: f64,
26    pub phase_flip_rate: f64,
27}
28
29impl NoiseConfig {
30    /// Create a `NoiseConfig` from the simulator's [`NoiseModel`].
31    pub fn from_noise_model(m: &NoiseModel) -> Self {
32        Self {
33            depolarizing_rate: m.depolarizing_rate,
34            bit_flip_rate: m.bit_flip_rate,
35            phase_flip_rate: m.phase_flip_rate,
36        }
37    }
38
39    /// Convert back to a [`NoiseModel`] for replay.
40    pub fn to_noise_model(&self) -> NoiseModel {
41        NoiseModel {
42            depolarizing_rate: self.depolarizing_rate,
43            bit_flip_rate: self.bit_flip_rate,
44            phase_flip_rate: self.phase_flip_rate,
45        }
46    }
47}
48
49// ---------------------------------------------------------------------------
50// ExecutionRecord
51// ---------------------------------------------------------------------------
52
53/// Complete record of every parameter that can influence simulation output.
54///
55/// Two runs with the same `ExecutionRecord` and the same circuit must produce
56/// identical measurement outcomes (assuming deterministic seeding).
57#[derive(Debug, Clone)]
58pub struct ExecutionRecord {
59    /// Deterministic hash of the circuit structure (gate types, parameters,
60    /// qubit indices). Computed via [`ReplayEngine::circuit_hash`].
61    pub circuit_hash: [u8; 32],
62    /// RNG seed used for measurement sampling and noise channels.
63    pub seed: u64,
64    /// Backend identifier string (e.g. `"state_vector"`).
65    pub backend: String,
66    /// Noise model parameters, if noise was enabled.
67    pub noise_config: Option<NoiseConfig>,
68    /// Number of measurement shots.
69    pub shots: u32,
70    /// Software version that produced this record.
71    pub software_version: String,
72    /// UTC timestamp (seconds since UNIX epoch) when the record was created.
73    pub timestamp_utc: u64,
74}
75
76// ---------------------------------------------------------------------------
77// ReplayEngine
78// ---------------------------------------------------------------------------
79
80/// Engine that records execution parameters and replays simulations for
81/// reproducibility verification.
82pub struct ReplayEngine {
83    /// Software version embedded in every record.
84    version: String,
85}
86
87impl ReplayEngine {
88    /// Create a new `ReplayEngine` using the crate version from `Cargo.toml`.
89    pub fn new() -> Self {
90        Self {
91            version: env!("CARGO_PKG_VERSION").to_string(),
92        }
93    }
94
95    /// Capture all parameters needed to deterministically replay a simulation.
96    ///
97    /// The returned [`ExecutionRecord`] is self-contained: given the same
98    /// circuit, the record holds enough information to reproduce the exact
99    /// measurement outcomes.
100    pub fn record_execution(
101        &self,
102        circuit: &QuantumCircuit,
103        config: &SimConfig,
104        shots: u32,
105    ) -> ExecutionRecord {
106        let seed = config.seed.unwrap_or(0);
107        let noise_config = config.noise.as_ref().map(NoiseConfig::from_noise_model);
108
109        let timestamp_utc = SystemTime::now()
110            .duration_since(UNIX_EPOCH)
111            .map(|d| d.as_secs())
112            .unwrap_or(0);
113
114        ExecutionRecord {
115            circuit_hash: Self::circuit_hash(circuit),
116            seed,
117            backend: "state_vector".to_string(),
118            noise_config,
119            shots,
120            software_version: self.version.clone(),
121            timestamp_utc,
122        }
123    }
124
125    /// Replay a simulation using the parameters in `record` and verify that
126    /// the measurement outcomes match a fresh run.
127    ///
128    /// Returns `true` when the replayed results are identical to a reference
129    /// run seeded with the same parameters. Both runs use the exact same seed
130    /// so the RNG sequences must agree.
131    pub fn replay(&self, record: &ExecutionRecord, circuit: &QuantumCircuit) -> bool {
132        // Verify circuit hash matches the record.
133        let current_hash = Self::circuit_hash(circuit);
134        if current_hash != record.circuit_hash {
135            return false;
136        }
137
138        let noise = record.noise_config.as_ref().map(NoiseConfig::to_noise_model);
139
140        let config = SimConfig {
141            seed: Some(record.seed),
142            noise: noise.clone(),
143            shots: None,
144        };
145
146        // Run twice with the same config and compare measurements.
147        let run_a = Simulator::run_with_config(circuit, &config);
148        let config_b = SimConfig {
149            seed: Some(record.seed),
150            noise,
151            shots: None,
152        };
153        let run_b = Simulator::run_with_config(circuit, &config_b);
154
155        match (run_a, run_b) {
156            (Ok(a), Ok(b)) => {
157                if a.measurements.len() != b.measurements.len() {
158                    return false;
159                }
160                a.measurements
161                    .iter()
162                    .zip(b.measurements.iter())
163                    .all(|(ma, mb)| {
164                        ma.qubit == mb.qubit
165                            && ma.result == mb.result
166                            && (ma.probability - mb.probability).abs() < 1e-12
167                    })
168            }
169            _ => false,
170        }
171    }
172
173    /// Compute a deterministic 32-byte hash of a circuit's structure.
174    ///
175    /// The hash captures, for every gate: its type discriminant, the qubit
176    /// indices it acts on, and any continuous parameters (rotation angles).
177    /// Two circuits with the same gate sequence produce the same hash.
178    ///
179    /// Uses `DefaultHasher` (SipHash-based) run twice with different seeds to
180    /// fill 32 bytes.
181    pub fn circuit_hash(circuit: &QuantumCircuit) -> [u8; 32] {
182        // Build a canonical byte representation of the circuit.
183        let canonical = Self::circuit_canonical_bytes(circuit);
184
185        let mut result = [0u8; 32];
186
187        // First 8 bytes: hash with seed 0.
188        let h0 = hash_bytes_with_seed(&canonical, 0);
189        result[0..8].copy_from_slice(&h0.to_le_bytes());
190
191        // Next 8 bytes: hash with seed 1.
192        let h1 = hash_bytes_with_seed(&canonical, 1);
193        result[8..16].copy_from_slice(&h1.to_le_bytes());
194
195        // Next 8 bytes: hash with seed 2.
196        let h2 = hash_bytes_with_seed(&canonical, 2);
197        result[16..24].copy_from_slice(&h2.to_le_bytes());
198
199        // Final 8 bytes: hash with seed 3.
200        let h3 = hash_bytes_with_seed(&canonical, 3);
201        result[24..32].copy_from_slice(&h3.to_le_bytes());
202
203        result
204    }
205
206    /// Serialise the circuit into a canonical byte sequence.
207    ///
208    /// The encoding is: `[num_qubits:4 bytes LE]` followed by, for each gate,
209    /// `[discriminant:1 byte][qubit indices][f64 parameters as LE bytes]`.
210    fn circuit_canonical_bytes(circuit: &QuantumCircuit) -> Vec<u8> {
211        let mut buf = Vec::new();
212
213        // Circuit metadata.
214        buf.extend_from_slice(&circuit.num_qubits().to_le_bytes());
215
216        for gate in circuit.gates() {
217            // Push a discriminant byte for the gate variant.
218            let (disc, qubits, params) = gate_components(gate);
219            buf.push(disc);
220
221            for q in &qubits {
222                buf.extend_from_slice(&q.to_le_bytes());
223            }
224            for p in &params {
225                buf.extend_from_slice(&p.to_le_bytes());
226            }
227        }
228
229        buf
230    }
231}
232
233impl Default for ReplayEngine {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239// ---------------------------------------------------------------------------
240// StateCheckpoint
241// ---------------------------------------------------------------------------
242
243/// Snapshot of a quantum state-vector that can be serialised and restored.
244///
245/// The internal representation stores amplitudes as interleaved `(re, im)` f64
246/// pairs in little-endian byte order so that the checkpoint is
247/// platform-independent.
248#[derive(Debug, Clone)]
249pub struct StateCheckpoint {
250    data: Vec<u8>,
251    num_amplitudes: usize,
252}
253
254impl StateCheckpoint {
255    /// Capture the current state-vector amplitudes into a checkpoint.
256    pub fn capture(amplitudes: &[Complex]) -> Self {
257        let mut data = Vec::with_capacity(amplitudes.len() * 16);
258        for amp in amplitudes {
259            data.extend_from_slice(&amp.re.to_le_bytes());
260            data.extend_from_slice(&amp.im.to_le_bytes());
261        }
262        Self {
263            data,
264            num_amplitudes: amplitudes.len(),
265        }
266    }
267
268    /// Restore the amplitudes from this checkpoint.
269    pub fn restore(&self) -> Vec<Complex> {
270        let mut amps = Vec::with_capacity(self.num_amplitudes);
271        for i in 0..self.num_amplitudes {
272            let offset = i * 16;
273            let re = f64::from_le_bytes(
274                self.data[offset..offset + 8]
275                    .try_into()
276                    .expect("checkpoint data corrupted"),
277            );
278            let im = f64::from_le_bytes(
279                self.data[offset + 8..offset + 16]
280                    .try_into()
281                    .expect("checkpoint data corrupted"),
282            );
283            amps.push(Complex::new(re, im));
284        }
285        amps
286    }
287
288    /// Total size of the serialised checkpoint in bytes.
289    pub fn size_bytes(&self) -> usize {
290        self.data.len()
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Internal helpers
296// ---------------------------------------------------------------------------
297
298/// Hash a byte slice using `DefaultHasher` seeded deterministically.
299///
300/// `DefaultHasher` does not expose a seed parameter so we prepend the seed
301/// bytes to the data to obtain different digests for different seeds.
302fn hash_bytes_with_seed(data: &[u8], seed: u64) -> u64 {
303    let mut hasher = DefaultHasher::new();
304    seed.hash(&mut hasher);
305    data.hash(&mut hasher);
306    hasher.finish()
307}
308
309/// Decompose a `Gate` into a discriminant byte, qubit indices, and f64
310/// parameters. This is the single source of truth for the canonical encoding.
311fn gate_components(gate: &Gate) -> (u8, Vec<u32>, Vec<f64>) {
312    match gate {
313        Gate::H(q) => (0, vec![*q], vec![]),
314        Gate::X(q) => (1, vec![*q], vec![]),
315        Gate::Y(q) => (2, vec![*q], vec![]),
316        Gate::Z(q) => (3, vec![*q], vec![]),
317        Gate::S(q) => (4, vec![*q], vec![]),
318        Gate::Sdg(q) => (5, vec![*q], vec![]),
319        Gate::T(q) => (6, vec![*q], vec![]),
320        Gate::Tdg(q) => (7, vec![*q], vec![]),
321        Gate::Rx(q, angle) => (8, vec![*q], vec![*angle]),
322        Gate::Ry(q, angle) => (9, vec![*q], vec![*angle]),
323        Gate::Rz(q, angle) => (10, vec![*q], vec![*angle]),
324        Gate::Phase(q, angle) => (11, vec![*q], vec![*angle]),
325        Gate::CNOT(c, t) => (12, vec![*c, *t], vec![]),
326        Gate::CZ(a, b) => (13, vec![*a, *b], vec![]),
327        Gate::SWAP(a, b) => (14, vec![*a, *b], vec![]),
328        Gate::Rzz(a, b, angle) => (15, vec![*a, *b], vec![*angle]),
329        Gate::Measure(q) => (16, vec![*q], vec![]),
330        Gate::Reset(q) => (17, vec![*q], vec![]),
331        Gate::Barrier => (18, vec![], vec![]),
332        Gate::Unitary1Q(q, m) => {
333            // Encode the 4 complex entries (8 f64 values).
334            let params = vec![
335                m[0][0].re, m[0][0].im, m[0][1].re, m[0][1].im,
336                m[1][0].re, m[1][0].im, m[1][1].re, m[1][1].im,
337            ];
338            (19, vec![*q], params)
339        }
340    }
341}
342
343// ---------------------------------------------------------------------------
344// Tests
345// ---------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::circuit::QuantumCircuit;
351    use crate::simulator::SimConfig;
352    use crate::types::Complex;
353
354    /// Same seed produces identical measurement results.
355    #[test]
356    fn same_seed_identical_results() {
357        let mut circuit = QuantumCircuit::new(2);
358        circuit.h(0).cnot(0, 1).measure(0).measure(1);
359
360        let config = SimConfig {
361            seed: Some(42),
362            noise: None,
363            shots: None,
364        };
365
366        let r1 = Simulator::run_with_config(&circuit, &config).unwrap();
367        let r2 = Simulator::run_with_config(&circuit, &config).unwrap();
368
369        assert_eq!(r1.measurements.len(), r2.measurements.len());
370        for (a, b) in r1.measurements.iter().zip(r2.measurements.iter()) {
371            assert_eq!(a.qubit, b.qubit);
372            assert_eq!(a.result, b.result);
373            assert!((a.probability - b.probability).abs() < 1e-12);
374        }
375    }
376
377    /// Different seeds produce different results (probabilistically; with
378    /// measurements on a Bell state the chance of accidental agreement is
379    /// non-zero but small over many runs).
380    #[test]
381    fn different_seed_different_results() {
382        let mut circuit = QuantumCircuit::new(2);
383        circuit.h(0).cnot(0, 1).measure(0).measure(1);
384
385        let mut any_differ = false;
386        // Try several seed pairs to reduce flakiness.
387        for offset in 0..20 {
388            let c1 = SimConfig {
389                seed: Some(100 + offset),
390                noise: None,
391                shots: None,
392            };
393            let c2 = SimConfig {
394                seed: Some(200 + offset),
395                noise: None,
396                shots: None,
397            };
398            let r1 = Simulator::run_with_config(&circuit, &c1).unwrap();
399            let r2 = Simulator::run_with_config(&circuit, &c2).unwrap();
400            if r1.measurements.iter().zip(r2.measurements.iter()).any(|(a, b)| a.result != b.result)
401            {
402                any_differ = true;
403                break;
404            }
405        }
406        assert!(any_differ, "expected at least one pair of seeds to disagree");
407    }
408
409    /// Record + replay round-trip succeeds.
410    #[test]
411    fn record_replay_roundtrip() {
412        let mut circuit = QuantumCircuit::new(2);
413        circuit.h(0).cnot(0, 1).measure(0).measure(1);
414
415        let config = SimConfig {
416            seed: Some(99),
417            noise: None,
418            shots: None,
419        };
420
421        let engine = ReplayEngine::new();
422        let record = engine.record_execution(&circuit, &config, 1);
423
424        assert!(engine.replay(&record, &circuit));
425    }
426
427    /// Circuit hash is deterministic: calling it twice yields the same value.
428    #[test]
429    fn circuit_hash_deterministic() {
430        let mut circuit = QuantumCircuit::new(3);
431        circuit.h(0).rx(1, 1.234).cnot(0, 2).measure(0);
432
433        let h1 = ReplayEngine::circuit_hash(&circuit);
434        let h2 = ReplayEngine::circuit_hash(&circuit);
435        assert_eq!(h1, h2);
436    }
437
438    /// Two structurally different circuits produce different hashes.
439    #[test]
440    fn circuit_hash_differs_for_different_circuits() {
441        let mut c1 = QuantumCircuit::new(2);
442        c1.h(0).cnot(0, 1);
443
444        let mut c2 = QuantumCircuit::new(2);
445        c2.x(0).cnot(0, 1);
446
447        let h1 = ReplayEngine::circuit_hash(&c1);
448        let h2 = ReplayEngine::circuit_hash(&c2);
449        assert_ne!(h1, h2);
450    }
451
452    /// Checkpoint capture/restore preserves amplitudes exactly.
453    #[test]
454    fn checkpoint_capture_restore() {
455        let amplitudes = vec![
456            Complex::new(0.5, 0.5),
457            Complex::new(-0.3, 0.1),
458            Complex::new(0.0, -0.7),
459            Complex::new(0.2, 0.0),
460        ];
461
462        let checkpoint = StateCheckpoint::capture(&amplitudes);
463        let restored = checkpoint.restore();
464
465        assert_eq!(amplitudes.len(), restored.len());
466        for (orig, rest) in amplitudes.iter().zip(restored.iter()) {
467            assert_eq!(orig.re, rest.re);
468            assert_eq!(orig.im, rest.im);
469        }
470    }
471
472    /// Checkpoint size is 16 bytes per amplitude (re: 8 + im: 8).
473    #[test]
474    fn checkpoint_size_bytes() {
475        let amplitudes = vec![Complex::ZERO; 8];
476        let checkpoint = StateCheckpoint::capture(&amplitudes);
477        assert_eq!(checkpoint.size_bytes(), 8 * 16);
478    }
479
480    /// Replay fails if the circuit has been modified after recording.
481    #[test]
482    fn replay_fails_on_modified_circuit() {
483        let mut circuit = QuantumCircuit::new(2);
484        circuit.h(0).cnot(0, 1).measure(0).measure(1);
485
486        let config = SimConfig {
487            seed: Some(42),
488            noise: None,
489            shots: None,
490        };
491
492        let engine = ReplayEngine::new();
493        let record = engine.record_execution(&circuit, &config, 1);
494
495        // Modify the circuit.
496        let mut modified = QuantumCircuit::new(2);
497        modified.x(0).cnot(0, 1).measure(0).measure(1);
498
499        assert!(!engine.replay(&record, &modified));
500    }
501
502    /// ExecutionRecord captures noise config when present.
503    #[test]
504    fn record_captures_noise() {
505        let circuit = QuantumCircuit::new(1);
506        let config = SimConfig {
507            seed: Some(7),
508            noise: Some(NoiseModel {
509                depolarizing_rate: 0.01,
510                bit_flip_rate: 0.005,
511                phase_flip_rate: 0.002,
512            }),
513            shots: None,
514        };
515
516        let engine = ReplayEngine::new();
517        let record = engine.record_execution(&circuit, &config, 100);
518
519        let nc = record.noise_config.as_ref().unwrap();
520        assert!((nc.depolarizing_rate - 0.01).abs() < 1e-15);
521        assert!((nc.bit_flip_rate - 0.005).abs() < 1e-15);
522        assert!((nc.phase_flip_rate - 0.002).abs() < 1e-15);
523        assert_eq!(record.shots, 100);
524        assert_eq!(record.seed, 7);
525    }
526
527    /// Empty circuit hashes deterministically and differently from non-empty.
528    #[test]
529    fn empty_circuit_hash() {
530        let empty = QuantumCircuit::new(2);
531        let mut non_empty = QuantumCircuit::new(2);
532        non_empty.h(0);
533
534        let h1 = ReplayEngine::circuit_hash(&empty);
535        let h2 = ReplayEngine::circuit_hash(&non_empty);
536        assert_ne!(h1, h2);
537
538        // Determinism.
539        assert_eq!(h1, ReplayEngine::circuit_hash(&empty));
540    }
541
542    /// Rotation angle differences produce different hashes.
543    #[test]
544    fn rotation_angle_changes_hash() {
545        let mut c1 = QuantumCircuit::new(1);
546        c1.rx(0, 1.0);
547
548        let mut c2 = QuantumCircuit::new(1);
549        c2.rx(0, 1.0001);
550
551        assert_ne!(
552            ReplayEngine::circuit_hash(&c1),
553            ReplayEngine::circuit_hash(&c2)
554        );
555    }
556}