Files
Vincent Bourdon 9af114e391 Initial import
2026-06-09 16:14:55 +02:00

12 KiB

Sub-plan 3 — LocalSimulator / Executor (Task 5)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.

Goal: Implement LocalSimulator — the spinoza statevector engine. Walks the oq3_semantics AST to extract gate ops, applies them to a spinoza State, samples measurement counts, and optionally returns the full statevector.

Starting state (after sub-plan 2):

  • All of sub-plan 1 files present
  • src/validator.rsCircuitValidator fully implemented and tested
  • src/main.rs declares mod validator;
  • cargo test passes (validator tests green)

Deliverable: cargo test passes with 7 executor unit tests green. One commit added.

Key APIs:

  • spinoza::core::{apply, c_apply, cc_apply, reservoir_sampling, State}
  • spinoza::gates::Gate — variants: H, X, Y, Z, P(f64), RX(f64), RY(f64), RZ(f64), SWAP(usize,usize)
  • No S/T/Sdg/Tdg variant — use Gate::P(PI/2.0), Gate::P(PI/4.0), etc.
  • reservoir_sampling(&state, 1<<n, shots) → call .get_outcome_count() for HashMap<usize,usize>
  • Endianness warning: if Bell test sees "01"/"10", reverse qubit index: num_qubits - 1 - raw_idx

Main plan reference: docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md Task 5.


Task 5: LocalSimulator — spinoza executor

Files:

  • Modify: src/executor.rs (add LocalSimulator + helpers below the trait definitions)

5a — Write failing tests first

  • Step 1: Replace the mock test block in src/executor.rs with real tests

Replace the entire #[cfg(test)] block at the bottom of src/executor.rs with:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{CircuitSource, ShotCount};

    const BELL_CIRCUIT: &str = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
c = measure q;"#;

    const X_CIRCUIT: &str = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
bit[1] c;
x q[0];
c = measure q;"#;

    const H_CIRCUIT: &str = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
bit[1] c;
h q[0];
c = measure q;"#;

    fn sim() -> LocalSimulator { LocalSimulator::new() }

    #[test]
    fn bell_circuit_only_produces_00_and_11() {
        let result = sim()
            .run(&CircuitSource(BELL_CIRCUIT.to_string()), ShotCount(10_000), false)
            .unwrap();
        assert_eq!(result.shots, 10_000);
        let total: u64 = result.counts.values().sum();
        assert_eq!(total, 10_000);
        for key in result.counts.keys() {
            assert!(key == "00" || key == "11", "unexpected bitstring: {key}");
        }
    }

    #[test]
    fn bell_circuit_is_roughly_balanced() {
        let result = sim()
            .run(&CircuitSource(BELL_CIRCUIT.to_string()), ShotCount(10_000), false)
            .unwrap();
        let count_00 = result.counts.get("00").copied().unwrap_or(0) as f64;
        let ratio = count_00 / 10_000.0;
        assert!((0.45..=0.55).contains(&ratio), "ratio_00={ratio:.3}");
    }

    #[test]
    fn x_gate_always_produces_one() {
        let result = sim()
            .run(&CircuitSource(X_CIRCUIT.to_string()), ShotCount(1_000), false)
            .unwrap();
        assert_eq!(result.counts.get("1").copied().unwrap_or(0), 1_000);
    }

    #[test]
    fn h_gate_produces_roughly_balanced_single_qubit() {
        let result = sim()
            .run(&CircuitSource(H_CIRCUIT.to_string()), ShotCount(10_000), false)
            .unwrap();
        let count_0 = result.counts.get("0").copied().unwrap_or(0) as f64;
        let ratio = count_0 / 10_000.0;
        assert!((0.45..=0.55).contains(&ratio), "ratio_0={ratio:.3}");
    }

    #[test]
    fn run_returns_statevector_when_requested() {
        let result = sim()
            .run(&CircuitSource(BELL_CIRCUIT.to_string()), ShotCount(100), true)
            .unwrap();
        let sv = result.statevector.expect("statevector should be present");
        assert_eq!(sv.len(), 4); // 2^2
        let norm: f64 = sv.iter().map(|(r, i)| r * r + i * i).sum();
        assert!((norm - 1.0).abs() < 1e-9, "norm={norm}");
    }

    #[test]
    fn local_simulator_name_is_local_simulator() {
        assert_eq!(sim().name(), "local_simulator");
    }

    #[test]
    fn local_simulator_max_qubits_is_28() {
        assert_eq!(sim().max_qubits(), MAX_LOCAL_QUBITS);
    }
}
  • Step 2: Confirm tests fail (LocalSimulator undefined)
cargo test executor::tests 2>&1 | tail -5

Expected: compile error cannot find struct LocalSimulator.

5b — Implementation

  • Step 3: Add imports and LocalSimulator struct above the #[cfg(test)] block

Insert this block between the trait definitions and the #[cfg(test)] block in src/executor.rs:

use std::collections::HashMap;
use std::f64::consts::PI;
use std::time::Instant;

use oq3_semantics::syntax_to_semantics::parse_source_string;
use spinoza::core::{apply, c_apply, cc_apply, reservoir_sampling, State};
use spinoza::gates::Gate;

pub struct LocalSimulator;

impl LocalSimulator {
    pub fn new() -> Self { Self }
}

impl CanIntrospect for LocalSimulator {
    fn name(&self) -> &str { "local_simulator" }
    fn max_qubits(&self) -> usize { MAX_LOCAL_QUBITS }
    fn supported_gates(&self) -> &[&str] { SUPPORTED_GATES }
}

impl CanValidate for LocalSimulator {
    fn validate(&self, circuit: &CircuitSource) -> Result<ValidationResult, BridgeError> {
        use crate::validator::CircuitValidator;
        CircuitValidator::new(MAX_LOCAL_QUBITS).validate(circuit)
    }
}

impl CanExecute for LocalSimulator {
    fn run(
        &self,
        circuit: &CircuitSource,
        shots: ShotCount,
        return_statevector: bool,
    ) -> Result<SimulationResult, BridgeError> {
        let start = Instant::now();

        let parse_result = parse_source_string(&circuit.0, Some("circuit.qasm"), None::<&[&str]>);
        let program = parse_result.program();

        let num_qubits = count_qubits(program);
        if num_qubits == 0 {
            return Err(BridgeError::Simulation("circuit declares no qubits".to_string()));
        }
        if num_qubits > MAX_LOCAL_QUBITS {
            return Err(BridgeError::QubitLimitExceeded {
                requested: num_qubits,
                limit: MAX_LOCAL_QUBITS,
            });
        }

        let mut state = State::new(num_qubits);

        for (gate_name, params, qubits) in extract_gate_ops(program) {
            apply_gate(&gate_name, &params, &qubits, &mut state)?;
        }

        let statevector = if return_statevector {
            Some(
                state.reals.iter()
                    .zip(state.imags.iter())
                    .map(|(&r, &i)| (r as f64, i as f64))
                    .collect(),
            )
        } else {
            None
        };

        let num_states = 1usize << num_qubits;
        let mut reservoir = reservoir_sampling(&state, num_states, shots.0 as usize);
        let raw_counts = reservoir.get_outcome_count();

        let counts: HashMap<String, u64> = raw_counts
            .into_iter()
            .map(|(idx, cnt)| (format!("{:0>width$b}", idx, width = num_qubits), cnt as u64))
            .collect();

        Ok(SimulationResult {
            counts,
            shots: shots.0,
            execution_time_ms: start.elapsed().as_secs_f64() * 1000.0,
            statevector,
        })
    }
}

impl Backend for LocalSimulator {}
  • Step 4: Add apply_gate function
fn apply_gate(
    gate_name: &str,
    params: &[f64],
    qubits: &[usize],
    state: &mut State,
) -> Result<(), BridgeError> {
    let supported = SUPPORTED_GATES.join(", ");
    match (gate_name, qubits) {
        ("h",   &[t])             => apply(Gate::H, state, t),
        ("x",   &[t])             => apply(Gate::X, state, t),
        ("y",   &[t])             => apply(Gate::Y, state, t),
        ("z",   &[t])             => apply(Gate::Z, state, t),
        ("s",   &[t])             => apply(Gate::P(PI / 2.0), state, t),
        ("t",   &[t])             => apply(Gate::P(PI / 4.0), state, t),
        ("sdg", &[t])             => apply(Gate::P(-PI / 2.0), state, t),
        ("tdg", &[t])             => apply(Gate::P(-PI / 4.0), state, t),
        ("rx",  &[t]) if params.len() == 1 => apply(Gate::RX(params[0]), state, t),
        ("ry",  &[t]) if params.len() == 1 => apply(Gate::RY(params[0]), state, t),
        ("rz",  &[t]) if params.len() == 1 => apply(Gate::RZ(params[0]), state, t),
        ("cx",  &[ctrl, tgt])     => c_apply(Gate::X, state, ctrl, tgt),
        ("cz",  &[ctrl, tgt])     => c_apply(Gate::Z, state, ctrl, tgt),
        ("swap",&[a, b])          => apply(Gate::SWAP(a, b), state, a),
        ("ccx", &[c0, c1, tgt])   => cc_apply(Gate::X, state, c0, c1, tgt),
        ("measure", _)            => {} // sampling handles measurement
        _ => return Err(BridgeError::UnsupportedGate {
            gate: gate_name.to_string(),
            line: 0,
            supported,
        }),
    }
    Ok(())
}
  • Step 5: Add extract_gate_ops and count_qubits

These functions walk the AST. Use the variant names you identified in sub-plan 2, Task 4 Step 3. The skeletons below must be replaced with real match arms:

use oq3_semantics::asg::Stmt;

/// Extracts (gate_name_lowercase, float_params, qubit_indices) for every gate call.
fn extract_gate_ops(program: &oq3_semantics::asg::Program) -> Vec<(String, Vec<f64>, Vec<usize>)> {
    let mut ops = Vec::new();
    for stmt in program.stmts() {   // ← adjust method name if needed
        // Fill in from AST exploration (sub-plan 2):
        // if let Stmt::GateCall(gc) = stmt {
        //     let name = resolve_gate_name(gc).to_lowercase();
        //     let params = extract_float_params(gc);   // Vec<f64>
        //     let qubits = extract_qubit_indices(gc);  // Vec<usize>
        //     ops.push((name, params, qubits));
        // }
        let _ = stmt;
    }
    ops
}

/// Counts total declared qubits (sum of all qubit register widths).
fn count_qubits(program: &oq3_semantics::asg::Program) -> usize {
    let mut total = 0;
    for stmt in program.stmts() {   // ← adjust method name if needed
        // Fill in from AST exploration (sub-plan 2):
        // if let Stmt::QubitDeclaration(qd) = stmt {
        //     total += qd.width().unwrap_or(1);
        // }
        let _ = stmt;
    }
    total
}
  • Step 6: Run tests and iterate until all 7 pass
cargo test executor::tests 2>&1

Debugging guide:

Failing test Likely cause Fix
bell_circuit_only_produces_00_and_11 sees "01" or "10" spinoza qubit index convention reversed vs QASM3 In extract_gate_ops, flip qubit index: num_qubits - 1 - raw_idx
bell_circuit_only_produces_00_and_11 sees all zeros extract_gate_ops returning empty (match arm wrong) Re-check variant name from exploration
x_gate_always_produces_one fails Same empty ops issue Same fix
run_returns_statevector_when_requested norm ≠ 1 state.reals/state.imags indexing issue Verify spinoza State fields are Vec<f32> — cast with as f64
local_simulator_max_qubits_is_28 fails MAX_LOCAL_QUBITS not 28 Check src/executor.rs constant
  • Step 7: Run full test suite
cargo test 2>&1

Expected: all tests pass (validator + executor).

  • Step 8: Commit
git add src/executor.rs
git commit -m "feat: implement LocalSimulator with spinoza statevector engine"

Final verification

cargo fmt --check && cargo clippy -- -D warnings && cargo test

Expected: all green. Sub-plan 3 complete — hand off to sub-plan 4.