Skip to main content

quantrs2_device/quantum_ml/
circuit_simulation.rs

1//! Self-contained dense state-vector simulator for QML circuits.
2//!
3//! The quantum-ML algorithms in this crate (VQE, QAOA, QNNs, gradient
4//! estimators, trainers) operate on [`ParameterizedQuantumCircuit`] values
5//! and need *real* measurement statistics / expectation values to drive their
6//! optimisation loops.  Historically the `execute_circuit_helper` methods in
7//! those modules returned a hard-coded 50/50 split of `|0…0⟩` and `|1…1⟩`
8//! counts, which silently fed fabricated data into every gradient and training
9//! computation.
10//!
11//! This module provides a small, exact, in-crate state-vector engine so those
12//! helpers can produce genuine results.  It is intentionally self-contained:
13//! `quantrs2-device` must *not* depend on `quantrs2-sim` (that crate is a
14//! sibling consumer, and adding it here would create a cross-dependency), so we
15//! implement the few gates that [`QuantumGate`] can express directly.
16//!
17//! For paths that genuinely require execution on remote hardware (with
18//! credentials / network access), callers should return an honest
19//! [`DeviceError`] instead of using this local simulator.
20
21use std::collections::HashMap;
22
23use scirs2_core::Complex64;
24
25use super::variational_algorithms::{ParameterizedQuantumCircuit, QuantumGate};
26use crate::{CircuitResult, DeviceError, DeviceResult};
27
28/// Maximum number of qubits this exact simulator will allocate a state vector
29/// for.  `2^30` complex amplitudes is already 16 GiB, so we cap well below that
30/// and return an honest error rather than attempting an impossible allocation.
31const MAX_SIMULATED_QUBITS: usize = 26;
32
33/// Simulate a [`ParameterizedQuantumCircuit`] from the all-zero state and
34/// return the resulting amplitude vector of length `2^num_qubits`.
35///
36/// Amplitudes are indexed in the little-endian convention used throughout the
37/// framework: qubit `q` is bit `q` of the basis index (so qubit 0 is the least
38/// significant bit).
39pub fn simulate_statevector(circuit: &ParameterizedQuantumCircuit) -> DeviceResult<Vec<Complex64>> {
40    let num_qubits = circuit.num_qubits();
41    if num_qubits > MAX_SIMULATED_QUBITS {
42        return Err(DeviceError::InvalidInput(format!(
43            "Local state-vector simulation supports at most {MAX_SIMULATED_QUBITS} qubits, \
44             but circuit has {num_qubits}"
45        )));
46    }
47
48    let dim = 1usize << num_qubits;
49    let mut state = vec![Complex64::new(0.0, 0.0); dim];
50    state[0] = Complex64::new(1.0, 0.0);
51
52    for gate in circuit.gates() {
53        apply_gate(&mut state, num_qubits, gate)?;
54    }
55
56    Ok(state)
57}
58
59/// Apply one [`QuantumGate`] to `state` in place.
60fn apply_gate(state: &mut [Complex64], num_qubits: usize, gate: &QuantumGate) -> DeviceResult<()> {
61    match *gate {
62        QuantumGate::H(q) => {
63            let s = std::f64::consts::FRAC_1_SQRT_2;
64            apply_single_qubit(
65                state,
66                num_qubits,
67                q,
68                [
69                    Complex64::new(s, 0.0),
70                    Complex64::new(s, 0.0),
71                    Complex64::new(s, 0.0),
72                    Complex64::new(-s, 0.0),
73                ],
74            )
75        }
76        QuantumGate::X(q) => apply_single_qubit(
77            state,
78            num_qubits,
79            q,
80            [
81                Complex64::new(0.0, 0.0),
82                Complex64::new(1.0, 0.0),
83                Complex64::new(1.0, 0.0),
84                Complex64::new(0.0, 0.0),
85            ],
86        ),
87        QuantumGate::Y(q) => apply_single_qubit(
88            state,
89            num_qubits,
90            q,
91            [
92                Complex64::new(0.0, 0.0),
93                Complex64::new(0.0, -1.0),
94                Complex64::new(0.0, 1.0),
95                Complex64::new(0.0, 0.0),
96            ],
97        ),
98        QuantumGate::Z(q) => apply_single_qubit(
99            state,
100            num_qubits,
101            q,
102            [
103                Complex64::new(1.0, 0.0),
104                Complex64::new(0.0, 0.0),
105                Complex64::new(0.0, 0.0),
106                Complex64::new(-1.0, 0.0),
107            ],
108        ),
109        QuantumGate::SDagger(q) => apply_single_qubit(
110            state,
111            num_qubits,
112            q,
113            [
114                Complex64::new(1.0, 0.0),
115                Complex64::new(0.0, 0.0),
116                Complex64::new(0.0, 0.0),
117                Complex64::new(0.0, -1.0),
118            ],
119        ),
120        QuantumGate::RX(q, theta) => {
121            let c = (theta / 2.0).cos();
122            let s = (theta / 2.0).sin();
123            apply_single_qubit(
124                state,
125                num_qubits,
126                q,
127                [
128                    Complex64::new(c, 0.0),
129                    Complex64::new(0.0, -s),
130                    Complex64::new(0.0, -s),
131                    Complex64::new(c, 0.0),
132                ],
133            )
134        }
135        QuantumGate::RY(q, theta) => {
136            let c = (theta / 2.0).cos();
137            let s = (theta / 2.0).sin();
138            apply_single_qubit(
139                state,
140                num_qubits,
141                q,
142                [
143                    Complex64::new(c, 0.0),
144                    Complex64::new(-s, 0.0),
145                    Complex64::new(s, 0.0),
146                    Complex64::new(c, 0.0),
147                ],
148            )
149        }
150        QuantumGate::RZ(q, theta) => {
151            let phase_neg = Complex64::from_polar(1.0, -theta / 2.0);
152            let phase_pos = Complex64::from_polar(1.0, theta / 2.0);
153            apply_single_qubit(
154                state,
155                num_qubits,
156                q,
157                [
158                    phase_neg,
159                    Complex64::new(0.0, 0.0),
160                    Complex64::new(0.0, 0.0),
161                    phase_pos,
162                ],
163            )
164        }
165        QuantumGate::CNOT(control, target) => {
166            apply_controlled_x(state, num_qubits, control, target)
167        }
168        QuantumGate::CZ(control, target) => apply_controlled_z(state, num_qubits, control, target),
169    }
170}
171
172/// Apply a 2x2 unitary (row-major `[m00, m01, m10, m11]`) to qubit `q`.
173fn apply_single_qubit(
174    state: &mut [Complex64],
175    num_qubits: usize,
176    q: usize,
177    matrix: [Complex64; 4],
178) -> DeviceResult<()> {
179    if q >= num_qubits {
180        return Err(DeviceError::InvalidInput(format!(
181            "Gate targets qubit {q} but circuit only has {num_qubits} qubits"
182        )));
183    }
184    let bit = 1usize << q;
185    let dim = state.len();
186    for base in 0..dim {
187        if base & bit != 0 {
188            continue;
189        }
190        let i0 = base;
191        let i1 = base | bit;
192        let a0 = state[i0];
193        let a1 = state[i1];
194        state[i0] = matrix[0] * a0 + matrix[1] * a1;
195        state[i1] = matrix[2] * a0 + matrix[3] * a1;
196    }
197    Ok(())
198}
199
200/// Apply a CNOT: flip `target` when `control` is set.
201fn apply_controlled_x(
202    state: &mut [Complex64],
203    num_qubits: usize,
204    control: usize,
205    target: usize,
206) -> DeviceResult<()> {
207    validate_two_qubit(num_qubits, control, target)?;
208    let control_bit = 1usize << control;
209    let target_bit = 1usize << target;
210    let dim = state.len();
211    for base in 0..dim {
212        // Only act when control is set and target is 0, swapping with the
213        // target-is-1 partner exactly once.
214        if (base & control_bit != 0) && (base & target_bit == 0) {
215            let partner = base | target_bit;
216            state.swap(base, partner);
217        }
218    }
219    Ok(())
220}
221
222/// Apply a controlled-Z: phase of -1 on `|11⟩` of (control, target).
223fn apply_controlled_z(
224    state: &mut [Complex64],
225    num_qubits: usize,
226    control: usize,
227    target: usize,
228) -> DeviceResult<()> {
229    validate_two_qubit(num_qubits, control, target)?;
230    let control_bit = 1usize << control;
231    let target_bit = 1usize << target;
232    let dim = state.len();
233    for (idx, amp) in state.iter_mut().enumerate().take(dim) {
234        if (idx & control_bit != 0) && (idx & target_bit != 0) {
235            *amp = -*amp;
236        }
237    }
238    Ok(())
239}
240
241fn validate_two_qubit(num_qubits: usize, control: usize, target: usize) -> DeviceResult<()> {
242    if control >= num_qubits || target >= num_qubits {
243        return Err(DeviceError::InvalidInput(format!(
244            "Two-qubit gate on ({control}, {target}) but circuit only has {num_qubits} qubits"
245        )));
246    }
247    if control == target {
248        return Err(DeviceError::InvalidInput(
249            "Two-qubit gate requires distinct control and target qubits".to_string(),
250        ));
251    }
252    Ok(())
253}
254
255/// Compute the exact probability of each computational-basis outcome.
256///
257/// Returns a vector of length `2^num_qubits` where index `i` is `|⟨i|ψ⟩|²`.
258pub fn outcome_probabilities(state: &[Complex64]) -> Vec<f64> {
259    state.iter().map(|amp| amp.norm_sqr()).collect()
260}
261
262/// Render a basis index as a bitstring with qubit 0 as the **leftmost**
263/// character (matching the `"0".repeat(n)` / `"1".repeat(n)` convention the
264/// previous mock used and that the expectation helpers parse).
265fn index_to_bitstring(index: usize, num_qubits: usize) -> String {
266    let mut s = String::with_capacity(num_qubits);
267    for q in 0..num_qubits {
268        if index & (1usize << q) != 0 {
269            s.push('1');
270        } else {
271            s.push('0');
272        }
273    }
274    s
275}
276
277/// Simulate `circuit` and sample `shots` measurement outcomes from the exact
278/// output distribution, returning a [`CircuitResult`] with real counts.
279///
280/// Sampling uses [`fastrand`] (the crate's existing RNG dependency).  The
281/// returned counts are genuine multinomial draws from `|⟨i|ψ⟩|²`, so they
282/// reflect the true circuit (e.g. a Bell circuit yields only correlated
283/// `00`/`11` outcomes, never the uniform spread the old mock produced).
284pub fn simulate_and_sample(
285    circuit: &ParameterizedQuantumCircuit,
286    shots: usize,
287) -> DeviceResult<CircuitResult> {
288    let num_qubits = circuit.num_qubits();
289    let state = simulate_statevector(circuit)?;
290    let probabilities = outcome_probabilities(&state);
291
292    // Build a cumulative distribution for inverse-transform sampling.
293    let total: f64 = probabilities.iter().sum();
294    if total <= 0.0 || !total.is_finite() {
295        return Err(DeviceError::ExecutionFailed(
296            "Circuit produced a non-normalizable state (zero or non-finite total probability)"
297                .to_string(),
298        ));
299    }
300
301    let mut cumulative = Vec::with_capacity(probabilities.len());
302    let mut running = 0.0;
303    for p in &probabilities {
304        running += p / total;
305        cumulative.push(running);
306    }
307    // Guard the last bin against floating-point shortfall.
308    if let Some(last) = cumulative.last_mut() {
309        *last = 1.0;
310    }
311
312    let mut counts: HashMap<String, usize> = HashMap::new();
313    for _ in 0..shots {
314        let r = fastrand::f64();
315        let idx = match cumulative
316            .binary_search_by(|probe| probe.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Less))
317        {
318            Ok(i) | Err(i) => i.min(cumulative.len().saturating_sub(1)),
319        };
320        *counts
321            .entry(index_to_bitstring(idx, num_qubits))
322            .or_insert(0) += 1;
323    }
324
325    let mut metadata = HashMap::new();
326    metadata.insert("backend".to_string(), "local_statevector".to_string());
327    metadata.insert("num_qubits".to_string(), num_qubits.to_string());
328
329    Ok(CircuitResult {
330        counts,
331        shots,
332        metadata,
333    })
334}
335
336/// Compute the exact expectation value of the total-spin (number-of-ones)
337/// observable `Σ_q (1 - Z_q)/2 = Σ_q n_q`, i.e. the expected Hamming weight of
338/// a measurement outcome, directly from the state vector.
339///
340/// This is the noiseless counterpart of the count-based estimator used by the
341/// gradient/training code and is convenient for analytic tests.
342pub fn expected_hamming_weight(circuit: &ParameterizedQuantumCircuit) -> DeviceResult<f64> {
343    let num_qubits = circuit.num_qubits();
344    let state = simulate_statevector(circuit)?;
345    let mut expectation = 0.0;
346    for (idx, amp) in state.iter().enumerate() {
347        let weight = (idx.count_ones()) as f64;
348        expectation += weight * amp.norm_sqr();
349    }
350    Ok(expectation)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn bell_state_is_correlated_not_uniform() {
359        // |00> -> H on q0 -> CNOT(0,1) gives (|00> + |11>)/sqrt(2).
360        let mut circuit = ParameterizedQuantumCircuit::new(2);
361        circuit.add_h_gate(0).unwrap();
362        circuit.add_cnot_gate(0, 1).unwrap();
363
364        let probs = outcome_probabilities(&simulate_statevector(&circuit).unwrap());
365        // Indices: 00 -> 0, 11 -> 3.
366        assert!((probs[0] - 0.5).abs() < 1e-9, "P(00) should be 0.5");
367        assert!((probs[3] - 0.5).abs() < 1e-9, "P(11) should be 0.5");
368        assert!(probs[1].abs() < 1e-9, "P(01) should be 0");
369        assert!(probs[2].abs() < 1e-9, "P(10) should be 0");
370
371        // Sampled counts must respect the correlation (no 01/10 outcomes), and
372        // must NOT be the old fabricated 50/50 of 00/11-as-all-zeros/all-ones
373        // uniform mock — here all weight is on the two correlated strings.
374        let result = simulate_and_sample(&circuit, 4096).unwrap();
375        let c01 = result.counts.get("10").copied().unwrap_or(0); // qubit0=1,qubit1=0
376        let c10 = result.counts.get("01").copied().unwrap_or(0);
377        assert_eq!(c01, 0, "Bell state must never measure 01");
378        assert_eq!(c10, 0, "Bell state must never measure 10");
379        let c00 = result.counts.get("00").copied().unwrap_or(0);
380        let c11 = result.counts.get("11").copied().unwrap_or(0);
381        assert_eq!(c00 + c11, 4096);
382        // Both should appear with finite frequency (probabilistic but extremely
383        // unlikely to be 0 over 4096 shots).
384        assert!(c00 > 0 && c11 > 0, "both correlated outcomes should appear");
385    }
386
387    #[test]
388    fn x_gate_flips_qubit() {
389        let mut circuit = ParameterizedQuantumCircuit::new(1);
390        circuit.add_x_gate(0).unwrap();
391        let probs = outcome_probabilities(&simulate_statevector(&circuit).unwrap());
392        assert!(probs[1] > 0.999, "X|0> = |1>");
393        assert_eq!(expected_hamming_weight(&circuit).unwrap().round() as i64, 1);
394    }
395
396    #[test]
397    fn ry_rotation_matches_analytic_probability() {
398        // RY(theta)|0> = cos(theta/2)|0> + sin(theta/2)|1>.
399        let theta = 0.7;
400        let mut circuit = ParameterizedQuantumCircuit::new(1);
401        circuit.add_ry_gate(0, theta).unwrap();
402        let probs = outcome_probabilities(&simulate_statevector(&circuit).unwrap());
403        let expected_p1 = (theta / 2.0).sin().powi(2);
404        assert!((probs[1] - expected_p1).abs() < 1e-9);
405        let weight = expected_hamming_weight(&circuit).unwrap();
406        assert!((weight - expected_p1).abs() < 1e-9);
407    }
408
409    #[test]
410    fn rejects_oversized_circuit() {
411        let circuit = ParameterizedQuantumCircuit::new(MAX_SIMULATED_QUBITS + 1);
412        assert!(simulate_statevector(&circuit).is_err());
413    }
414}