Skip to main content

quantrs2_device/
mock_backend.rs

1//! Mock quantum backend for integration testing without real cloud credentials.
2//!
3//! Provides a fully configurable fake quantum backend that generates plausible
4//! measurement results, simulates job latency, and records all submitted jobs.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use quantrs2_device::mock_backend::{MockBackendConfig, MockQuantumBackend};
10//!
11//! let backend = MockQuantumBackend::new(MockBackendConfig::perfect(5));
12//! let qasm = "OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[2];\ncreg c[2];\nh q[0];\ncx q[0],q[1];\nmeasure q[0] -> c[0];\nmeasure q[1] -> c[1];\n";
13//! let counts = backend.run(qasm, 1024).expect("run should succeed");
14//! assert!(!counts.is_empty());
15//! ```
16
17use std::collections::HashMap;
18use std::sync::{Arc, Mutex};
19use std::time::Duration;
20
21// ─── Configuration ──────────────────────────────────────────────────────────
22
23/// Configuration for mock backend behavior.
24#[derive(Debug, Clone)]
25pub struct MockBackendConfig {
26    /// Name shown in backend queries
27    pub name: String,
28    /// Number of available qubits
29    pub max_qubits: usize,
30    /// Maximum shots per job
31    pub max_shots: usize,
32    /// Simulated job queue latency in milliseconds
33    pub latency_ms: u64,
34    /// Depolarizing error rate per gate (0.0 = perfect)
35    pub error_rate: f64,
36    /// Probability that a job fails (for testing error handling)
37    pub fail_rate: f64,
38    /// Supported gate names (empty = all gates supported)
39    pub gate_set: Vec<String>,
40    /// Allowed qubit pairs for 2-qubit gates (empty = all pairs allowed)
41    pub connectivity: Vec<(usize, usize)>,
42    /// Random seed for reproducible results
43    pub rng_seed: u64,
44}
45
46impl Default for MockBackendConfig {
47    fn default() -> Self {
48        Self {
49            name: "mock_backend".to_string(),
50            max_qubits: 32,
51            max_shots: 8192,
52            latency_ms: 0,
53            error_rate: 0.0,
54            fail_rate: 0.0,
55            gate_set: vec![],
56            connectivity: vec![],
57            rng_seed: 42,
58        }
59    }
60}
61
62impl MockBackendConfig {
63    /// Create a "perfect" noiseless backend with the given qubit count.
64    pub fn perfect(n_qubits: usize) -> Self {
65        Self {
66            max_qubits: n_qubits,
67            ..Default::default()
68        }
69    }
70
71    /// Create a backend mimicking IBM Nairobi (7 qubits, T-topology).
72    pub fn ibm_nairobi_like() -> Self {
73        Self {
74            name: "mock_ibm_nairobi".to_string(),
75            max_qubits: 7,
76            max_shots: 4096,
77            latency_ms: 100,
78            error_rate: 0.001,
79            fail_rate: 0.0,
80            gate_set: vec![
81                "cx".to_string(),
82                "rz".to_string(),
83                "sx".to_string(),
84                "x".to_string(),
85            ],
86            connectivity: vec![(0, 1), (1, 2), (1, 3), (3, 5), (4, 5), (5, 6)],
87            rng_seed: 0,
88        }
89    }
90
91    /// Create a backend that always fails — useful for testing error-handling paths.
92    pub fn always_fails() -> Self {
93        Self {
94            name: "failing_backend".to_string(),
95            fail_rate: 1.0,
96            ..Default::default()
97        }
98    }
99
100    /// Create a noisy backend with the given error rate.
101    pub fn with_noise(n_qubits: usize, error_rate: f64) -> Self {
102        Self {
103            name: "noisy_mock_backend".to_string(),
104            max_qubits: n_qubits,
105            error_rate,
106            ..Default::default()
107        }
108    }
109}
110
111// ─── Job records ─────────────────────────────────────────────────────────────
112
113/// Record of a submitted job, persisted in the backend's job log.
114#[derive(Debug, Clone)]
115pub struct MockJobRecord {
116    /// Unique job identifier assigned by the mock backend
117    pub job_id: String,
118    /// Raw QASM string that was submitted
119    pub circuit_qasm: String,
120    /// Number of shots requested
121    pub shots: usize,
122    /// Wall-clock time at submission
123    pub submitted_at: std::time::SystemTime,
124    /// Measurement counts (bitstring → count), `None` on failure
125    pub result: Option<HashMap<String, usize>>,
126    /// Whether the backend simulated a failure for this job
127    pub failed: bool,
128    /// Human-readable error description when `failed` is true
129    pub error_message: Option<String>,
130}
131
132// ─── Error type ──────────────────────────────────────────────────────────────
133
134/// Errors that can be returned by [`MockQuantumBackend::run`].
135#[derive(Debug)]
136pub enum MockBackendError {
137    /// Simulated backend failure (configured via `fail_rate`)
138    JobFailed(String),
139    /// Circuit requires more qubits than the backend supports
140    TooManyQubits {
141        /// Qubits requested by the circuit
142        requested: usize,
143        /// Maximum the backend allows
144        max: usize,
145    },
146    /// Shot count exceeds the backend's limit
147    TooManyShots {
148        /// Shots requested by the caller
149        requested: usize,
150        /// Maximum the backend allows
151        max: usize,
152    },
153    /// QASM string could not be parsed or is structurally invalid
154    InvalidCircuit(String),
155}
156
157impl std::fmt::Display for MockBackendError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            MockBackendError::JobFailed(msg) => write!(f, "mock job failed: {msg}"),
161            MockBackendError::TooManyQubits { requested, max } => {
162                write!(f, "requested {requested} qubits, backend max is {max}")
163            }
164            MockBackendError::TooManyShots { requested, max } => {
165                write!(f, "requested {requested} shots, backend max is {max}")
166            }
167            MockBackendError::InvalidCircuit(msg) => write!(f, "invalid circuit: {msg}"),
168        }
169    }
170}
171
172impl std::error::Error for MockBackendError {}
173
174// ─── Backend ─────────────────────────────────────────────────────────────────
175
176/// A configurable mock quantum backend for use in integration tests.
177///
178/// All submitted jobs are recorded and can be retrieved via [`MockQuantumBackend::all_jobs`].
179/// Measurement results are generated deterministically from the configured seed so that
180/// tests are reproducible.
181pub struct MockQuantumBackend {
182    /// Public backend configuration
183    pub config: MockBackendConfig,
184    job_records: Arc<Mutex<Vec<MockJobRecord>>>,
185}
186
187impl std::fmt::Debug for MockQuantumBackend {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("MockQuantumBackend")
190            .field("config", &self.config)
191            .finish()
192    }
193}
194
195impl MockQuantumBackend {
196    /// Create a new mock backend with the given configuration.
197    pub fn new(config: MockBackendConfig) -> Self {
198        Self {
199            config,
200            job_records: Arc::new(Mutex::new(vec![])),
201        }
202    }
203
204    /// Return the number of jobs submitted so far.
205    pub fn job_count(&self) -> usize {
206        self.job_records
207            .lock()
208            .unwrap_or_else(|e| e.into_inner())
209            .len()
210    }
211
212    /// Return a snapshot of all job records accumulated so far.
213    pub fn all_jobs(&self) -> Vec<MockJobRecord> {
214        self.job_records
215            .lock()
216            .unwrap_or_else(|e| e.into_inner())
217            .clone()
218    }
219
220    /// Return the most recently submitted job, or `None` if no jobs have run.
221    pub fn last_job(&self) -> Option<MockJobRecord> {
222        self.job_records
223            .lock()
224            .unwrap_or_else(|e| e.into_inner())
225            .last()
226            .cloned()
227    }
228
229    /// Submit a QASM 2.0 circuit and return measurement counts.
230    ///
231    /// Validates qubit and shot limits, optionally simulates latency, optionally
232    /// injects a simulated failure, then generates and returns measurement counts.
233    /// Every invocation appends a [`MockJobRecord`] to the backend's log.
234    pub fn run(
235        &self,
236        circuit_qasm: &str,
237        shots: usize,
238    ) -> Result<HashMap<String, usize>, MockBackendError> {
239        // Validate shots
240        if shots > self.config.max_shots {
241            return Err(MockBackendError::TooManyShots {
242                requested: shots,
243                max: self.config.max_shots,
244            });
245        }
246
247        // Parse qubit count from QASM; fall back to 1 qubit for well-formed but
248        // qubit-free strings — malformed input that still results in 0 shots is
249        // handled gracefully by generating an empty count map.
250        let n_qubits = self.parse_qubit_count(circuit_qasm).unwrap_or(1);
251        if n_qubits > self.config.max_qubits {
252            return Err(MockBackendError::TooManyQubits {
253                requested: n_qubits,
254                max: self.config.max_qubits,
255            });
256        }
257
258        // Simulate latency
259        if self.config.latency_ms > 0 {
260            std::thread::sleep(Duration::from_millis(self.config.latency_ms));
261        }
262
263        // Generate job ID using fastrand (already a workspace dep of quantrs2-device)
264        let raw_id = fastrand::u64(..);
265        let job_id = format!("mock-job-{raw_id}");
266
267        // Simulate random failure
268        let failed = self.config.fail_rate > 0.0 && fastrand::f64() < self.config.fail_rate;
269
270        if failed {
271            let record = MockJobRecord {
272                job_id,
273                circuit_qasm: circuit_qasm.to_string(),
274                shots,
275                submitted_at: std::time::SystemTime::now(),
276                result: None,
277                failed: true,
278                error_message: Some("simulated backend failure".to_string()),
279            };
280            self.job_records
281                .lock()
282                .unwrap_or_else(|e| e.into_inner())
283                .push(record);
284            return Err(MockBackendError::JobFailed(
285                "simulated backend failure".to_string(),
286            ));
287        }
288
289        // Generate measurement counts
290        let counts = self.generate_counts(n_qubits, shots);
291
292        let record = MockJobRecord {
293            job_id,
294            circuit_qasm: circuit_qasm.to_string(),
295            shots,
296            submitted_at: std::time::SystemTime::now(),
297            result: Some(counts.clone()),
298            failed: false,
299            error_message: None,
300        };
301        self.job_records
302            .lock()
303            .unwrap_or_else(|e| e.into_inner())
304            .push(record);
305
306        Ok(counts)
307    }
308
309    /// Parse `qreg q[N];` from a QASM 2.0 string and return N.
310    ///
311    /// Returns `None` when no `qreg` line is present.
312    fn parse_qubit_count(&self, qasm: &str) -> Option<usize> {
313        for line in qasm.lines() {
314            let trimmed = line.trim();
315            if trimmed.starts_with("qreg") {
316                if let Some(start) = trimmed.find('[') {
317                    if let Some(end) = trimmed.find(']') {
318                        if start < end {
319                            return trimmed[start + 1..end].parse().ok();
320                        }
321                    }
322                }
323            }
324        }
325        None
326    }
327
328    /// Generate `shots` measurement results for an `n_qubits`-qubit register.
329    ///
330    /// Uses a seeded PRNG for reproducibility. When `error_rate > 0.0`, each
331    /// bit in each outcome is independently flipped with that probability.
332    fn generate_counts(&self, n_qubits: usize, shots: usize) -> HashMap<String, usize> {
333        // Guard: a 0-qubit circuit or 0 shots produces an empty map.
334        if n_qubits == 0 || shots == 0 {
335            return HashMap::new();
336        }
337
338        let mut rng = fastrand::Rng::with_seed(
339            self.config
340                .rng_seed
341                .wrapping_add(n_qubits as u64)
342                .wrapping_add(shots as u64),
343        );
344
345        let n_states = 1usize << n_qubits.min(63); // guard against overflow
346        let mut counts: HashMap<String, usize> = HashMap::new();
347
348        for _ in 0..shots {
349            let state = rng.usize(..n_states);
350
351            // Apply independent per-bit depolarizing noise
352            let noisy_state = if self.config.error_rate > 0.0 {
353                let mut s = state;
354                for bit in 0..n_qubits {
355                    if rng.f64() < self.config.error_rate {
356                        s ^= 1 << bit;
357                    }
358                }
359                s
360            } else {
361                state
362            };
363
364            let bitstring = format!("{noisy_state:0>n_qubits$b}");
365            *counts.entry(bitstring).or_insert(0) += 1;
366        }
367
368        counts
369    }
370
371    /// Return a map of backend capabilities (suitable for display / comparison).
372    pub fn capabilities(&self) -> HashMap<String, String> {
373        let mut caps = HashMap::new();
374        caps.insert("name".to_string(), self.config.name.clone());
375        caps.insert("n_qubits".to_string(), self.config.max_qubits.to_string());
376        caps.insert("max_shots".to_string(), self.config.max_shots.to_string());
377        caps.insert("simulator".to_string(), "true".to_string());
378        caps.insert(
379            "error_rate".to_string(),
380            format!("{:.6}", self.config.error_rate),
381        );
382        caps.insert(
383            "supported_gates".to_string(),
384            if self.config.gate_set.is_empty() {
385                "all".to_string()
386            } else {
387                self.config.gate_set.join(",")
388            },
389        );
390        caps.insert(
391            "connectivity".to_string(),
392            if self.config.connectivity.is_empty() {
393                "all-to-all".to_string()
394            } else {
395                self.config
396                    .connectivity
397                    .iter()
398                    .map(|(a, b)| format!("{a}-{b}"))
399                    .collect::<Vec<_>>()
400                    .join(",")
401            },
402        );
403        caps
404    }
405
406    /// Return `true` when the given gate name is supported by this backend.
407    ///
408    /// An empty gate set (the default) means every gate is accepted.
409    pub fn supports_gate(&self, gate_name: &str) -> bool {
410        self.config.gate_set.is_empty() || self.config.gate_set.iter().any(|g| g == gate_name)
411    }
412
413    /// Return `true` when the pair `(q0, q1)` is a valid 2-qubit connection.
414    ///
415    /// An empty connectivity list (the default) means all pairs are allowed.
416    pub fn is_connected(&self, q0: usize, q1: usize) -> bool {
417        self.config.connectivity.is_empty()
418            || self
419                .config
420                .connectivity
421                .iter()
422                .any(|&(a, b)| (a == q0 && b == q1) || (a == q1 && b == q0))
423    }
424}
425
426// ─── Tests ───────────────────────────────────────────────────────────────────
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    const BELL_QASM: &str = "OPENQASM 2.0;\n\
433        include \"qelib1.inc\";\n\
434        qreg q[2];\n\
435        creg c[2];\n\
436        h q[0];\n\
437        cx q[0],q[1];\n\
438        measure q[0] -> c[0];\n\
439        measure q[1] -> c[1];\n";
440
441    #[test]
442    fn test_default_config() {
443        let cfg = MockBackendConfig::default();
444        assert_eq!(cfg.name, "mock_backend");
445        assert_eq!(cfg.max_qubits, 32);
446        assert_eq!(cfg.max_shots, 8192);
447        assert_eq!(cfg.error_rate, 0.0);
448        assert_eq!(cfg.fail_rate, 0.0);
449    }
450
451    #[test]
452    fn test_parse_qubit_count() {
453        let backend = MockQuantumBackend::new(MockBackendConfig::default());
454        assert_eq!(backend.parse_qubit_count(BELL_QASM), Some(2));
455        assert_eq!(backend.parse_qubit_count("no qreg here"), None);
456        assert_eq!(backend.parse_qubit_count("qreg q[5];"), Some(5));
457    }
458
459    #[test]
460    fn test_counts_sum_to_shots() {
461        let backend = MockQuantumBackend::new(MockBackendConfig::perfect(3));
462        let shots = 1024;
463        let counts = backend.run(BELL_QASM, shots).expect("run succeeded");
464        let total: usize = counts.values().sum();
465        assert_eq!(total, shots);
466    }
467
468    #[test]
469    fn test_capabilities_contains_name() {
470        let backend = MockQuantumBackend::new(MockBackendConfig::ibm_nairobi_like());
471        let caps = backend.capabilities();
472        assert_eq!(
473            caps.get("name").map(String::as_str),
474            Some("mock_ibm_nairobi")
475        );
476        assert_eq!(caps.get("n_qubits").map(String::as_str), Some("7"));
477    }
478
479    #[test]
480    fn test_supports_gate_empty_means_all() {
481        let backend = MockQuantumBackend::new(MockBackendConfig::default());
482        assert!(backend.supports_gate("any_gate"));
483        assert!(backend.supports_gate("cx"));
484    }
485
486    #[test]
487    fn test_supports_gate_restricted() {
488        let backend = MockQuantumBackend::new(MockBackendConfig::ibm_nairobi_like());
489        assert!(backend.supports_gate("cx"));
490        assert!(!backend.supports_gate("ccx"));
491    }
492}