diff --git a/Cargo.lock b/Cargo.lock index 3c782a2bc..59a3dd03d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -926,7 +926,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -1851,7 +1851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2893,7 +2893,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4252,6 +4252,7 @@ dependencies = [ "rayon", "serde", "serde_json", + "tempfile", ] [[package]] @@ -6079,7 +6080,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6135,7 +6136,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6928,10 +6929,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/crates/pecos-engines/Cargo.toml b/crates/pecos-engines/Cargo.toml index 0f217c7b3..609be8205 100644 --- a/crates/pecos-engines/Cargo.toml +++ b/crates/pecos-engines/Cargo.toml @@ -25,6 +25,7 @@ bitflags.workspace = true dyn-clone.workspace = true num-bigint.workspace = true bitvec.workspace = true +tempfile.workspace = true pecos-core.workspace = true pecos-results.workspace = true diff --git a/crates/pecos-engines/src/monte_carlo.rs b/crates/pecos-engines/src/monte_carlo.rs index 43557d7e9..e971317b8 100644 --- a/crates/pecos-engines/src/monte_carlo.rs +++ b/crates/pecos-engines/src/monte_carlo.rs @@ -14,4 +14,4 @@ pub mod builder; pub mod engine; pub use builder::MonteCarloEngineBuilder; -pub use engine::MonteCarloEngine; +pub use engine::{MonteCarloEngine, SeedReport, WorkerSeedRecord}; diff --git a/crates/pecos-engines/src/monte_carlo/engine.rs b/crates/pecos-engines/src/monte_carlo/engine.rs index 6afc7adb9..2a7a9fb8e 100644 --- a/crates/pecos-engines/src/monte_carlo/engine.rs +++ b/crates/pecos-engines/src/monte_carlo/engine.rs @@ -30,6 +30,9 @@ use rayon::{ }; use std::any::Any; use std::collections::BTreeMap; +use std::fs; +use std::io::{BufWriter, Write}; +use std::path::Path; use std::sync::{Arc, Mutex}; use super::builder::MonteCarloEngineBuilder; @@ -52,7 +55,7 @@ use super::builder::MonteCarloEngineBuilder; /// - **Parallelization**: Distributes shots across multiple worker threads /// - **Seed Management**: Hierarchical seeding for reproducible results /// - Base seed → Worker seeds → Component seeds -/// - **Noise Integration**: Applies noise before quantum operations +/// - **Noise Integration**: Routes quantum command batches through the configured noise model /// /// # Tips /// @@ -82,9 +85,8 @@ use super::builder::MonteCarloEngineBuilder; /// // For reproducibility /// engine.set_seed(42); /// -/// // This would run the simulation but we won't actually run it in the doctest -/// # let num_shots = 10; // Using a small number for the doctest -/// # let _results = engine.run(num_shots); +/// let results = engine.run(10).unwrap(); +/// assert_eq!(results.len(), 10); /// ``` pub struct MonteCarloEngine { /// Template `HybridEngine` that is cloned for each worker @@ -140,11 +142,9 @@ impl MonteCarloEngine { /// // Import necessary types for the example /// use pecos_engines::monte_carlo::MonteCarloEngine; /// use pecos_engines::monte_carlo::engine::ExternalClassicalEngine; - /// use pecos_engines::quantum; - /// /// // Create a Monte Carlo engine with default settings /// let classical_engine = Box::new(ExternalClassicalEngine::new()); - /// let mut engine = MonteCarloEngine::new_with_defaults(classical_engine); + /// let engine = MonteCarloEngine::new_with_defaults(classical_engine); /// ``` #[must_use] pub fn new_with_defaults(classical_engine: Box) -> Self { @@ -156,14 +156,16 @@ impl MonteCarloEngine { .build() } - /// Create a Monte Carlo engine with a classical engine and a depolarizing noise model. + /// Create a Monte Carlo engine with a classical engine and uniform depolarizing noise. /// - /// This is a convenience method that sets up a `MonteCarloEngine` with a state vector - /// quantum engine and a depolarizing noise model with the specified probability. + /// This convenience method creates a state-vector quantum engine sized from + /// [`ClassicalEngine::num_qubits`] and applies `p` uniformly to preparation, + /// measurement, single-qubit, and two-qubit errors. /// /// # Parameters /// - `classical_engine`: The classical engine to use for the simulation. - /// - `p`: The probability parameter for the depolarizing noise model (between 0.0 and 1.0). + /// - `p`: The uniform depolarizing error probability, in the inclusive range + /// `0.0..=1.0`. /// /// # Returns /// A configured `MonteCarloEngine` ready for use. @@ -171,27 +173,38 @@ impl MonteCarloEngine { /// # Examples /// /// ``` - /// // Import necessary types for the example + /// use pecos_engines::ByteMessage; /// use pecos_engines::monte_carlo::MonteCarloEngine; /// use pecos_engines::monte_carlo::engine::ExternalClassicalEngine; - /// use pecos_engines::quantum; /// - /// // Create a Monte Carlo engine with depolarizing noise - /// let classical_engine = Box::new(ExternalClassicalEngine::new()); - /// let mut engine = MonteCarloEngine::builder() - /// .with_classical_engine(classical_engine) - /// .with_quantum_engine(quantum::new_quantum_engine_with_seed(2, 42)) - /// .with_depolarizing_noise(0.01) + /// // Prepare |+> on qubit 0, then measure it in the Z basis. + /// let circuit = ByteMessage::quantum_operations_builder() + /// .pz(&[0]) + /// .h(&[0]) + /// .mz(&[0]) /// .build(); + /// let classical_engine = Box::new(ExternalClassicalEngine::new_with_circuit(circuit)); + /// + /// let mut engine = + /// MonteCarloEngine::new_with_depolarizing_noise(classical_engine, 0.1); + /// engine.set_seed(42); + /// let results = engine.run(10).unwrap(); + /// assert_eq!(results.len(), 10); /// ``` + /// + /// # Panics + /// + /// Panics if `p` is outside `0.0..=1.0` or is not a number. #[must_use] pub fn new_with_depolarizing_noise( classical_engine: Box, p: f64, ) -> Self { // Use the builder pattern + let num_qubits = classical_engine.num_qubits(); Self::builder() .with_classical_engine(classical_engine) + .with_quantum_engine(Box::new(StateVecEngine::new(num_qubits))) .with_depolarizing_noise(p) .build() } @@ -200,7 +213,7 @@ impl MonteCarloEngine { /// /// Setting a seed ensures deterministic behavior across runs with the same seed. /// This method sets the seed for: - /// - The internal `PecosRng` used for shot distribution + /// - The internal `PecosRng` used to draw the base seed for each run /// - The template `HybridEngine` (which sets seeds for the noise model and quantum engine) /// /// # Arguments @@ -229,15 +242,13 @@ impl MonteCarloEngine { Ok(self) } - /// Run a Monte Carlo simulation with the specified number of shots and worker threads. + /// Run a Monte Carlo simulation with the configured default worker count. /// - /// This method executes multiple shots of the quantum program in parallel using - /// the configured components. It distributes the shots across the specified number - /// of workers and collects the results. + /// This method executes `num_shots` shots using the worker count configured on + /// the engine and collects their results. /// /// # Parameters /// - `num_shots`: The total number of circuit executions to perform. - /// - `num_workers`: The number of worker threads to use for parallel execution. /// /// # Returns /// Aggregated results from all shots. @@ -247,6 +258,7 @@ impl MonteCarloEngine { /// /// # Panics /// - If `num_shots` is zero. + /// - If the configured default worker count is zero. pub fn run(&mut self, num_shots: usize) -> Result { self.run_with_workers(num_shots, self.default_workers) } @@ -274,8 +286,106 @@ impl MonteCarloEngine { num_shots: usize, num_workers: usize, ) -> Result { + let (shots, _) = self.run_with_workers_report_seeds(num_shots, num_workers)?; + Ok(shots) + } + + /// Runs a Monte Carlo simulation and returns the seeds used by each worker. + /// + /// The seed report records the engine root seed, the base seed drawn for the run, + /// and each worker's deterministic seed and shot count so the run can be reproduced + /// or audited. + /// This method runs the simulation with the specified number of shots and worker threads, + /// overriding the default worker count configured during construction. + /// + /// # Arguments + /// * `num_shots` - The number of shots to run + /// * `num_workers` - The number of parallel worker threads to use + /// + /// # Returns + /// A tuple containing the aggregated shot results and the seed report for the run. + /// + /// # Errors + /// Returns a `PecosError` if any part of the simulation fails. + /// + /// # Panics + /// - If `num_shots` is zero. + /// - If `num_workers` is zero. + pub fn run_with_workers_report_seeds( + &mut self, + num_shots: usize, + num_workers: usize, + ) -> Result<(ShotVec, SeedReport), PecosError> { + assert!(num_shots > 0, "num_shots cannot be zero"); + assert!(num_workers > 0, "num_workers cannot be zero"); + + debug!("Running Monte Carlo simulation: {num_shots} shots, {num_workers} workers"); + + // Determine shots per worker and generate deterministic seeds + let shots_per_worker = distribute_shots(num_shots, num_workers); + let base_seed = self.rng.next_u64(); + + // Create the seed report for this run + let seed_report = SeedReport { + root_seed: self.seed, + base_seed, + num_shots, + num_workers, + workers: (0..num_workers) + .map(|worker_idx| { + let seed = derive_seed(base_seed, &format!("worker_{worker_idx}")); + WorkerSeedRecord { + worker_idx, + shots: shots_per_worker[worker_idx], + seed, + } + }) + .collect(), + }; + + let shotvec = self.run_with_workers_from_seed_report(&seed_report)?; + Ok((shotvec, seed_report)) + } + + /// Runs a Monte Carlo simulation using the worker configuration and seeds + /// recorded in `seed_report`. + /// + /// The returned shots are ordered deterministically by worker and shot index. + /// + /// # Arguments + /// * `seed_report` - The shot count, worker count, and worker seeds to replay + /// + /// # Returns + /// The aggregated shot results. + /// + /// # Errors + /// Returns `PecosError::Input` if the report contains fewer worker seed records + /// than its configured worker count. Returns a `PecosError` if the worker pool + /// cannot be created or any shot fails. + /// + /// # Panics + /// Panics if the report specifies zero shots or workers, or if a worker record's + /// index or shot count does not match the report configuration. + pub fn run_with_workers_from_seed_report( + &mut self, + seed_report: &SeedReport, + ) -> Result { + // Import shot count, worker count, and all seeds from seed report. + let num_shots = seed_report.num_shots; + let num_workers = seed_report.num_workers; + + // check for invalid num_shots or num_workers assert!(num_shots > 0, "num_shots cannot be zero"); assert!(num_workers > 0, "num_workers cannot be zero"); + if seed_report.workers.len() < num_workers { + return Err(PecosError::Input(format!( + "Seed report contains {} worker records, but num_workers is {num_workers}", + seed_report.workers.len() + ))); + } + + let shots_per_worker = distribute_shots(num_shots, num_workers); + self.set_seed(seed_report.root_seed); // make sure to update root seed. debug!("Running Monte Carlo simulation: {num_shots} shots, {num_workers} workers"); @@ -284,10 +394,6 @@ impl MonteCarloEngine { num_shots, ))); - // Determine shots per worker and generate deterministic seeds - let shots_per_worker = distribute_shots(num_shots, num_workers); - let base_seed = self.rng.next_u64(); - // CRITICAL: Pre-create worker engines on the main thread before parallel execution. // This avoids potential deadlocks when worker threads try to clone engines // simultaneously, which can trigger concurrent library loading operations @@ -295,12 +401,19 @@ impl MonteCarloEngine { let worker_engines: Vec<_> = (0..num_workers) .map(|worker_idx| { let mut engine = self.hybrid_engine_template.clone(); - let worker_seed = derive_seed(base_seed, &format!("worker_{worker_idx}")); - engine.set_seed(worker_seed); + engine.set_seed(seed_report.workers[worker_idx].seed); (worker_idx, shots_per_worker[worker_idx], engine) }) .collect(); + // Verify that worker indices and shots per worker match the seed report + for (worker_index, item) in worker_engines.iter().enumerate().take(num_workers) { + // check that worker indices agree + assert!(seed_report.workers[worker_index].worker_idx == item.0, ".."); + // check that worker shot counts agree + assert!(seed_report.workers[worker_index].shots == item.1, ".."); + } + // Create a dedicated thread pool for this simulation to avoid contention // with global Rayon thread pool when multiple simulations run concurrently. // CRITICAL: For QIS programs, we need to ensure each test gets its own @@ -382,6 +495,7 @@ impl MonteCarloEngine { let combined_results = ShotVec::from_measurements(&shot_results); debug!("Monte Carlo simulation completed successfully"); + Ok(combined_results) } @@ -546,13 +660,15 @@ impl MonteCarloEngine { Self::run_with_hybrid_engine(hybrid_engine, num_shots, num_workers, seed) } - /// Static method to run a simulation based on a configuration string. + /// Run a simulation using a uniform depolarizing probability from a string. /// - /// This method is intended for use with configuration management systems where - /// the engine configuration is specified as a string. + /// `config` is parsed as an `f64` and used for preparation, measurement, + /// single-qubit, and two-qubit depolarizing error probabilities. The simulation + /// uses the default [`ExternalClassicalEngine`], which has an empty circuit. /// /// # Parameters - /// - `config`: Configuration string specifying the engine components. + /// - `config`: Uniform depolarizing probability in the inclusive range + /// `0.0..=1.0`. /// - `num_shots`: The total number of circuit executions to perform. /// - `num_workers`: The number of worker threads to use for parallel execution. /// - `seed`: Optional seed for deterministic behavior. @@ -561,7 +677,13 @@ impl MonteCarloEngine { /// Aggregated results from all shots. /// /// # Errors - /// Returns a `PecosError` if any part of the simulation fails. + /// Returns a `PecosError` if `config` cannot be parsed as an `f64` or if the + /// simulation fails. + /// + /// # Panics + /// - If the parsed probability is outside `0.0..=1.0` or is not a number. + /// - If `num_shots` is zero. + /// - If `num_workers` is zero. pub fn run_with_config( config: &str, num_shots: usize, @@ -622,13 +744,112 @@ fn distribute_shots(num_shots: usize, num_workers: usize) -> Vec { result } -/// An external classical engine implementation used for testing and examples. +/// Seed metadata for one Monte Carlo worker. /// -/// This implementation provides a basic classical engine that returns predetermined results -/// for demonstration and testing purposes. -#[derive(Debug, Clone)] +/// Each record captures the worker index, the number of shots assigned to that +/// worker, and the deterministic seed used to initialize its cloned engine. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct WorkerSeedRecord { + pub worker_idx: usize, + pub shots: usize, + pub seed: u64, +} + +/// Reproducibility metadata captured for a Monte Carlo simulation run. +/// +/// The report records the engine's root seed, the base seed drawn for this run, +/// the shot and worker configuration, and the deterministic seed assigned to +/// each worker. +/// +/// # Note +/// `root_seed` alone cannot reproduce `base_seed` if the engine has a job history +/// before this one. The per-worker seeds are the pieces that ensure deterministic replay. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SeedReport { + pub root_seed: u64, + pub base_seed: u64, + pub num_shots: usize, + pub num_workers: usize, + pub workers: Vec, +} + +/// JSON serialization helpers for `SeedReport`. +/// +/// Use these methods to save or reload reproducibility metadata when rerunning +/// or investigating specific worker seeds. +impl SeedReport { + /// Deserializes a `SeedReport` from a JSON string. + /// + /// # Returns + /// `SeedReport` imported from the JSON string. + /// + /// # Errors + /// Returns `PecosError::Input` when the JSON is malformed or does not match + /// the expected seed report schema. + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json) + .map_err(|err| PecosError::Input(format!("Failed to parse seed report JSON: {err}"))) + } + + /// Reads and deserializes a `SeedReport` from a JSON file. + /// + /// # Returns + /// `SeedReport` imported from a JSON file. + /// + /// # Errors + /// Returns `PecosError::Input` when the file cannot be read or the file + /// contents cannot be parsed as a seed report. + pub fn from_json_file>(path: P) -> Result { + let json = fs::read_to_string(path) + .map_err(|err| PecosError::Input(format!("Failed to read seed report JSON: {err}")))?; + Self::from_json_str(&json) + } + + /// Serializes this `SeedReport` to the given JSON file. + /// + /// # Errors + /// Returns `PecosError::Input` if the file cannot be created or written, or + /// if the report cannot be serialized. + pub fn to_json_file>(&self, path: P) -> Result<(), PecosError> { + let file = fs::File::create(path) + .map_err(|err| PecosError::Input(format!("Failed to write seed report JSON: {err}")))?; + let mut writer = BufWriter::new(file); + + serde_json::to_writer(&mut writer, self).map_err(|err| { + PecosError::Input(format!("Failed to serialize seed report JSON: {err}")) + })?; + writer + .flush() + .map_err(|err| PecosError::Input(format!("Failed to write seed report JSON: {err}"))) + } +} + +/// A minimal classical controller for testing and examples. +/// +/// When driven through [`ControlEngine`] (for example, by [`HybridEngine`]), the +/// default controller has no quantum commands and completes each shot with +/// `result = 0`. Use [`Self::new_with_circuit`] to supply a fixed batch of quantum +/// commands that is emitted once per shot. Returned measurement outcomes are exposed +/// in message order as `result` for the first measurement and `result_1`, `result_2`, +/// and so on for subsequent measurements. +/// +/// The controller reports a fixed capacity of two qubits, so configured circuits +/// should target only qubits 0 and 1. Calling [`Engine::process`] directly does not +/// provide a quantum backend; it returns the current result values without executing +/// the configured circuit. +#[derive(Clone)] pub struct ExternalClassicalEngine { results: BTreeMap, + circuit: ByteMessage, +} + +impl std::fmt::Debug for ExternalClassicalEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalClassicalEngine") + .field("results", &self.results) + .field("circuit_bytes", &self.circuit.as_bytes().len()) + .finish() + } } impl Default for ExternalClassicalEngine { @@ -638,14 +859,45 @@ impl Default for ExternalClassicalEngine { } impl ExternalClassicalEngine { - /// Create a new `ExternalClassicalEngine` with default results. + /// Create a controller with an empty circuit and `result` initialized to zero. + /// + /// Because the circuit is empty, the controller completes without invoking a + /// quantum engine. #[must_use] pub fn new() -> Self { + Self::new_with_circuit(ByteMessage::create_empty()) + } + + /// Create a controller that emits `circuit` once per shot. + /// + /// Measurement outcomes returned by the quantum engine are recorded in message order. + /// The first is named `result`; later outcomes are named `result_1`, `result_2`, + /// and so on. Calling `reset` retains the existing result fields and sets every + /// value back to zero before the next shot. + /// + /// This test controller reports two available qubits, so `circuit` should target + /// only qubits 0 and 1. + /// + /// # Examples + /// + /// ``` + /// use pecos_engines::ByteMessage; + /// use pecos_engines::monte_carlo::engine::ExternalClassicalEngine; + /// + /// let circuit = ByteMessage::quantum_operations_builder() + /// .pz(&[0]) + /// .h(&[0]) + /// .mz(&[0]) + /// .build(); + /// let controller = ExternalClassicalEngine::new_with_circuit(circuit); + /// ``` + #[must_use] + pub fn new_with_circuit(circuit: ByteMessage) -> Self { // Initialize with a default results map let mut results = BTreeMap::new(); results.insert("result".to_string(), 0); - Self { results } + Self { results, circuit } } } @@ -654,13 +906,14 @@ impl Engine for ExternalClassicalEngine { type Output = Shot; fn process(&mut self, _input: Self::Input) -> Result { - // For this stub implementation, just generate commands and return results + // Direct processing has no quantum backend, so retrieve the configured batch + // and return the controller's current results without executing it. let _message = self.generate_commands()?; self.get_results() } fn reset(&mut self) -> Result<(), PecosError> { - // Reset all results to 0 + // Retain every result field while resetting its value to zero. for value in self.results.values_mut() { *value = 0; } @@ -671,17 +924,23 @@ impl Engine for ExternalClassicalEngine { impl ClassicalEngine for ExternalClassicalEngine { fn num_qubits(&self) -> usize { - // Default to 2 qubits for testing + // This fixed-circuit test controller exposes a hard-coded two-qubit capacity. 2 } fn generate_commands(&mut self) -> Result { - // Create a simple command that prepares and measures a qubit - Ok(ByteMessage::builder().build()) + Ok(self.circuit.clone()) } - fn handle_measurements(&mut self, _: ByteMessage) -> Result<(), PecosError> { - // Store a random result + fn handle_measurements(&mut self, message: ByteMessage) -> Result<(), PecosError> { + for (index, outcome) in message.outcomes()?.into_iter().enumerate() { + let name = if index == 0 { + "result".to_string() + } else { + format!("result_{index}") + }; + self.results.insert(name, i64::from(outcome)); + } Ok(()) } @@ -710,7 +969,7 @@ impl ClassicalEngine for ExternalClassicalEngine { } fn compile(&self) -> Result<(), PecosError> { - // Nothing to compile for this stub + // The fixed ByteMessage circuit needs no compilation. Ok(()) } @@ -730,15 +989,12 @@ impl ControlEngine for ExternalClassicalEngine { type EngineOutput = ByteMessage; fn start(&mut self, (): ()) -> Result, PecosError> { - // Generate commands and return NeedsProcessing + // Retrieve the configured command batch for this shot. let commands = self.generate_commands()?; - // If the message is empty and we're in compatibility mode, still return NeedsProcessing - // to ensure MonteCarloEngine receives at least one batch + // Empty circuits complete immediately with the current result values. let is_empty = commands.is_empty().unwrap_or(true); if is_empty { - // Decide whether to return Complete or continue with an empty message - // For empty messages, we'll check if it's the first batch (just after reset) let shot_result = self.get_results()?; Ok(EngineStage::Complete(shot_result)) } else { diff --git a/crates/pecos-engines/tests/seed_report.rs b/crates/pecos-engines/tests/seed_report.rs new file mode 100644 index 000000000..c3c54d8ad --- /dev/null +++ b/crates/pecos-engines/tests/seed_report.rs @@ -0,0 +1,225 @@ +use pecos_engines::ByteMessage; +use pecos_engines::monte_carlo::engine::ExternalClassicalEngine; +use pecos_engines::monte_carlo::engine::{MonteCarloEngine, SeedReport}; + +/// Tests that importing a valid `SeedReport` from JSON file works correctly. +#[test] +fn seed_report_from_json_file_reads_valid_report() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("seed_report.json"); + + std::fs::write( + &path, + r#" + { + "root_seed": 42, + "base_seed": 123456789, + "num_shots": 10, + "num_workers": 2, + "workers": [ + { "worker_idx": 0, "shots": 5, "seed": 111 }, + { "worker_idx": 1, "shots": 5, "seed": 222 } + ] + } + "#, + ) + .unwrap(); + + let report = SeedReport::from_json_file(&path).unwrap(); + + assert_eq!(report.root_seed, 42); + assert_eq!(report.num_workers, 2); + assert_eq!(report.workers.len(), 2); +} + +/// Tests that importing a missing `SeedReport` JSON file fails as expected. +#[test] +fn seed_report_from_json_file_returns_error_for_missing_file() { + let err = SeedReport::from_json_file("does-not-exist-seed-report.json").unwrap_err(); + + let msg = format!("{err}"); + assert!(msg.contains("Failed to read seed report JSON")); +} + +/// Verifies that a valid JSON seed report is deserialized with all worker data intact. +#[test] +fn seed_report_from_json_str_parses_valid_report() { + let json = r#" + { + "root_seed": 42, + "base_seed": 123456789, + "num_shots": 10, + "num_workers": 2, + "workers": [ + { "worker_idx": 0, "shots": 5, "seed": 6 }, + { "worker_idx": 1, "shots": 7, "seed": 435 } + ] + } + "#; + let report = SeedReport::from_json_str(json).unwrap(); + assert_eq!(report.root_seed, 42); + assert_eq!(report.base_seed, 123_456_789); + assert_eq!(report.num_shots, 10); + assert_eq!(report.num_workers, 2); + assert_eq!(report.workers.len(), 2); + assert_eq!(report.workers[0].worker_idx, 0); + assert_eq!(report.workers[0].shots, 5); + assert_eq!(report.workers[0].seed, 6); + assert_eq!(report.workers[1].worker_idx, 1); + assert_eq!(report.workers[1].shots, 7); + assert_eq!(report.workers[1].seed, 435); +} + +/// Tests `run_with_workers_report_seeds` method. +/// Ensures the method kicks the job off correctly and creates a +/// `SeedReport` with the right properties. +#[test] +fn run_with_seed_report_returns_expected_worker_metadata() { + fn make_test_monte_carlo_engine() -> MonteCarloEngine { + let circuit = ByteMessage::quantum_operations_builder() + .pz(&[0]) + .h(&[0]) + .mz(&[0]) + .build(); + MonteCarloEngine::new_with_depolarizing_noise( + Box::new(ExternalClassicalEngine::new_with_circuit(circuit)), + 0.1, + ) + } + + let mut engine = make_test_monte_carlo_engine(); + engine.set_seed(42); + + let num_shots = 10; + let num_workers = 2; + + let (_shots, report) = engine + .run_with_workers_report_seeds(num_shots, num_workers) + .unwrap(); + + assert_eq!(report.root_seed, 42); + assert_eq!(report.num_shots, 10); + assert_eq!(report.num_workers, 2); + assert_eq!(report.workers.len(), 2); + + let total_worker_shots: usize = report.workers.iter().map(|w| w.shots).sum(); + assert_eq!(total_worker_shots, num_shots); + + assert_eq!(report.workers[0].worker_idx, 0); + assert_eq!(report.workers[1].worker_idx, 1); +} + +/// Tests seed-report determinism. +/// The two runs with the same root seed (`a` and `b`) should report the same +/// base and worker seeds. The run with a different root seed (`c`) should +/// report different worker seeds. +#[test] +fn run_with_seed_report_is_deterministic_for_same_seed_workers_and_shots() { + fn make_test_monte_carlo_engine() -> MonteCarloEngine { + let circuit = ByteMessage::quantum_operations_builder() + .pz(&[0]) + .h(&[0]) + .mz(&[0]) + .build(); + MonteCarloEngine::new_with_depolarizing_noise( + Box::new(ExternalClassicalEngine::new_with_circuit(circuit)), + 0.1, + ) + } + + let mut engine_a = make_test_monte_carlo_engine(); + let mut engine_b = make_test_monte_carlo_engine(); + let mut engine_c = make_test_monte_carlo_engine(); + + engine_a.set_seed(42); + engine_b.set_seed(42); + engine_c.set_seed(43); + + let (_shots_a, report_a) = engine_a.run_with_workers_report_seeds(10, 2).unwrap(); + + let (_shots_b, report_b) = engine_b.run_with_workers_report_seeds(10, 2).unwrap(); + + let (_shots_c, report_c) = engine_c.run_with_workers_report_seeds(10, 2).unwrap(); + + assert_eq!(report_a.root_seed, report_b.root_seed); + assert_eq!(report_a.base_seed, report_b.base_seed); + assert_eq!(report_a.workers.len(), report_b.workers.len()); + + let seeds_a: Vec = report_a.workers.iter().map(|w| w.seed).collect(); + let seeds_c: Vec = report_c.workers.iter().map(|w| w.seed).collect(); + + assert_ne!(seeds_a, seeds_c); + + for (worker_a, worker_b) in report_a.workers.iter().zip(report_b.workers.iter()) { + assert_eq!(worker_a.worker_idx, worker_b.worker_idx); + assert_eq!(worker_a.shots, worker_b.shots); + assert_eq!(worker_a.seed, worker_b.seed); + } +} + +/// Verifies that replaying a seed report reproduces the original noisy +/// measurement results. +#[test] +fn rerun_from_seed_report_reproduces_original_results() { + fn make_test_monte_carlo_engine() -> MonteCarloEngine { + let circuit = ByteMessage::quantum_operations_builder() + .pz(&[0]) + .h(&[0]) + .mz(&[0]) + .build(); + MonteCarloEngine::new_with_depolarizing_noise( + Box::new(ExternalClassicalEngine::new_with_circuit(circuit)), + 0.1, + ) + } + + let mut original_engine = make_test_monte_carlo_engine(); + original_engine.set_seed(42); + + let (original_results, report) = original_engine + .run_with_workers_report_seeds(20, 2) + .unwrap(); + + let mut replay_engine = make_test_monte_carlo_engine(); + + let replayed_results = replay_engine + .run_with_workers_from_seed_report(&report) + .unwrap(); + + assert_eq!(replayed_results, original_results); +} + +/// Verifies the same noisy-result replay after serializing and deserializing +/// the seed report. +#[test] +fn rerun_from_seed_report_loaded_from_json_reproduces_original_results() { + fn make_test_monte_carlo_engine() -> MonteCarloEngine { + let circuit = ByteMessage::quantum_operations_builder() + .pz(&[0]) + .h(&[0]) + .mz(&[0]) + .build(); + MonteCarloEngine::new_with_depolarizing_noise( + Box::new(ExternalClassicalEngine::new_with_circuit(circuit)), + 0.1, + ) + } + + let mut original_engine = make_test_monte_carlo_engine(); + original_engine.set_seed(42); + + let (original_results, report) = original_engine + .run_with_workers_report_seeds(20, 2) + .unwrap(); + + let json = serde_json::to_string(&report).unwrap(); + let loaded_report = SeedReport::from_json_str(&json).unwrap(); + + let mut replay_engine = make_test_monte_carlo_engine(); + + let replayed_results = replay_engine + .run_with_workers_from_seed_report(&loaded_report) + .unwrap(); + + assert_eq!(replayed_results, original_results); +}