Files
quantum-bridge-mcp/docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md
Vincent Bourdon 9af114e391 Initial import
2026-06-09 16:14:55 +02:00

76 KiB
Raw Permalink Blame History

quantum-bridge-mcp V1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a zero-dependency MCP server (single Rust binary) that exposes three tools — list_backends, validate_circuit, run_circuit — for local OpenQASM 3.0 quantum circuit simulation.

Architecture: An rmcp stdio server wires three tool handlers; each handler delegates to either CircuitValidator (wrapping oq3_semantics) or LocalSimulator (wrapping spinoza). The tools depend on Backend traits, never on concrete types, to allow IBM backend addition in V1.5 without touching tool code.

Tech Stack: Rust 2021, rmcp (git, MCP SDK), oq3_semantics 0.7 (OpenQASM3 parser), spinoza 0.5 (statevector simulator), tokio, serde/schemars, thiserror, tracing.


File Map

Cargo.toml
src/
  main.rs              — rmcp stdio server, wires QuantumBridgeServer
  error.rs             — BridgeError (thiserror)
  types.rs             — QubitIndex, ShotCount, CircuitSource, ValidationResult, SimulationResult
  executor.rs          — Backend traits + LocalSimulator (spinoza)
  validator.rs         — CircuitValidator (oq3_semantics)
  tools/
    mod.rs             — QuantumBridgeServer struct + tool_router impl
    list_backends.rs   — list_backends handler
    validate_circuit.rs — validate_circuit handler
    run_circuit.rs     — run_circuit handler
tests/
  integration/
    mcp_protocol.rs    — JSON-RPC roundtrip, schema conformance
  proptest/
    invariants.rs      — unitarity, normalisation
  reference/
    bell.qasm          — valid Bell circuit
    bell_counts.json   — expected count distribution
    ghz3.qasm          — 3-qubit GHZ circuit
    ghz3_counts.json
    invalid/
      unsupported_gate.qasm   + unsupported_gate.error.txt
      undeclared_qubit.qasm   + undeclared_qubit.error.txt
      syntax_error.qasm       + syntax_error.error.txt
      qubit_limit.qasm        + qubit_limit.error.txt
benches/
  simulation.rs        — Criterion: Bell < 5 ms, 20 qubits < 500 ms
scripts/
  gen_reference.py     — generates tests/reference/ via Qiskit Aer
.github/workflows/
  ci.yml
  release.yml

Task 1: Bootstrap — Cargo project + dependencies

Files:

  • Create: Cargo.toml

  • Modify: src/main.rs (created by cargo new)

  • Step 1: Initialise the cargo project

cd /home/vincent/src/misc/quantum-bridge-mcp
cargo new --name quantum-bridge-mcp .

Expected: src/main.rs and Cargo.toml created. Git will show them as untracked.

  • Step 2: Write Cargo.toml

Note: spinoza publishes as 0.5.x on crates.io (not 2.x). Run cargo search spinoza and cargo search oq3_semantics to confirm latest patch versions before pinning.

[package]
name = "quantum-bridge-mcp"
version = "0.1.0"
edition = "2021"
description = "Zero-dependency MCP server for local OpenQASM 3.0 quantum circuit simulation"
license = "MIT"

[dependencies]
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", features = ["server", "transport-io", "macros"] }
oq3_semantics = "0.7"
spinoza = "0.5"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"

[dev-dependencies]
proptest = "1"
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "simulation"
harness = false
  • Step 3: Write minimal src/main.rs
fn main() {
    println!("quantum-bridge-mcp");
}
  • Step 4: Verify all dependencies resolve and compile
cargo build

Expected: compiles successfully (first run downloads ~200 MB of deps). If spinoza or oq3_semantics version constraint fails, run cargo search <crate> and update the version in Cargo.toml.

  • Step 5: Commit
git add Cargo.toml Cargo.lock src/main.rs
git commit -m "chore: bootstrap project with all V1 dependencies"

Task 2: Foundation — Error types and domain newtypes

Files:

  • Create: src/error.rs

  • Create: src/types.rs

  • Modify: src/main.rs

  • Step 1: Write src/error.rs

use thiserror::Error;

#[derive(Debug, Error)]
pub enum BridgeError {
    #[error("parse error at line {line}, col {col}: {message}")]
    Parse { line: usize, col: usize, message: String },

    #[error("gate '{gate}' is not supported at line {line} — supported: {supported}")]
    UnsupportedGate { gate: String, line: usize, supported: String },

    #[error("qubit index {index} is out of range (circuit declares {declared} qubits)")]
    QubitOutOfRange { index: usize, declared: usize },

    #[error("circuit requires {requested} qubits, exceeds the local simulator limit of {limit}")]
    QubitLimitExceeded { requested: usize, limit: usize },

    #[error("simulation failed: {0}")]
    Simulation(String),

    #[error("measure at line {line} maps to an undeclared classical bit")]
    MeasurementMapping { line: usize },
}
  • Step 2: Write src/types.rs
use std::collections::HashMap;

/// Newtype for qubit indices — prevents mixing with plain usizes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct QubitIndex(pub usize);

/// Newtype for shot count — enforces range and intent at type level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShotCount(pub u32);

impl ShotCount {
    pub const DEFAULT: ShotCount = ShotCount(1024);
    pub const MAX: ShotCount = ShotCount(100_000);
}

/// Wraps an OpenQASM 3.0 source string.
#[derive(Debug, Clone)]
pub struct CircuitSource(pub String);

#[derive(Debug, Clone)]
pub enum DiagnosticSeverity {
    Error,
    Warning,
}

#[derive(Debug, Clone)]
pub struct ValidationDiagnostic {
    pub line: usize,
    pub column: usize,
    pub message: String,
    pub severity: DiagnosticSeverity,
}

#[derive(Debug, Clone)]
pub struct ValidationResult {
    pub is_valid: bool,
    pub diagnostics: Vec<ValidationDiagnostic>,
    pub num_qubits: Option<usize>,
    pub num_gates: Option<usize>,
}

#[derive(Debug, Clone)]
pub struct SimulationResult {
    /// Bitstring → count, e.g. `{"00": 512, "11": 512}`.
    pub counts: HashMap<String, u64>,
    pub shots: u32,
    pub execution_time_ms: f64,
    /// Optional full statevector as (real, imag) pairs per basis state.
    pub statevector: Option<Vec<(f64, f64)>>,
}
  • Step 3: Declare modules in src/main.rs
mod error;
mod types;

fn main() {
    println!("quantum-bridge-mcp");
}
  • Step 4: Verify compilation
cargo build

Expected: compiles without warnings.

  • Step 5: Commit
git add src/error.rs src/types.rs src/main.rs
git commit -m "feat: add error types and domain newtypes"

Task 3: Backend traits

Files:

  • Create: src/executor.rs (traits only — implementation comes in Task 5)

  • Step 1: Write the failing compilation test

Add to the bottom of src/executor.rs (create the file):

use crate::error::BridgeError;
use crate::types::{CircuitSource, ShotCount, SimulationResult, ValidationResult};

pub const MAX_LOCAL_QUBITS: usize = 28;

pub const SUPPORTED_GATES: &[&str] = &[
    "h", "x", "y", "z", "s", "t", "sdg", "tdg",
    "rx", "ry", "rz", "cx", "cz", "swap", "ccx",
    "measure",
];

pub trait CanIntrospect {
    fn name(&self) -> &str;
    fn max_qubits(&self) -> usize;
    fn supported_gates(&self) -> &[&str];
}

pub trait CanValidate {
    fn validate(&self, circuit: &CircuitSource) -> Result<ValidationResult, BridgeError>;
}

pub trait CanExecute {
    fn run(
        &self,
        circuit: &CircuitSource,
        shots: ShotCount,
        return_statevector: bool,
    ) -> Result<SimulationResult, BridgeError>;
}

/// Marker trait combining all three capabilities. V1.5 IBM backend will impl this too.
pub trait Backend: CanIntrospect + CanValidate + CanExecute + Send + Sync {}

#[cfg(test)]
mod tests {
    use super::*;

    // Compile-time test: a mock struct can satisfy the trait bounds.
    struct _MockBackend;
    impl CanIntrospect for _MockBackend {
        fn name(&self) -> &str { "mock" }
        fn max_qubits(&self) -> usize { 4 }
        fn supported_gates(&self) -> &[&str] { SUPPORTED_GATES }
    }
    impl CanValidate for _MockBackend {
        fn validate(&self, _: &CircuitSource) -> Result<ValidationResult, BridgeError> {
            Ok(ValidationResult { is_valid: true, diagnostics: vec![], num_qubits: None, num_gates: None })
        }
    }
    impl CanExecute for _MockBackend {
        fn run(&self, _: &CircuitSource, _: ShotCount, _: bool) -> Result<SimulationResult, BridgeError> {
            Ok(SimulationResult { counts: Default::default(), shots: 0, execution_time_ms: 0.0, statevector: None })
        }
    }
    impl Backend for _MockBackend {}
}
  • Step 2: Declare executor module and run tests

Add to src/main.rs:

mod executor;
cargo test

Expected: 0 tests run, 0 failures — the compile-time test above just confirms the trait bounds are satisfiable.

  • Step 3: Commit
git add src/executor.rs src/main.rs
git commit -m "feat: define Backend trait hierarchy (CanIntrospect/CanValidate/CanExecute)"

Task 4: CircuitValidator — oq3_semantics integration

Files:

  • Create: src/validator.rs

4a — AST exploration (required before implementing)

The oq3_semantics AST is lightly documented. This step maps the real types so the implementation is correct.

  • Step 1: Write an AST exploration test in src/validator.rs

Create src/validator.rs with this content:

use oq3_semantics::syntax_to_semantics::parse_source_string;

#[cfg(test)]
mod exploration {
    use super::*;

    #[test]
    fn print_bell_circuit_ast() {
        let qasm = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
c = measure q;"#;
        let result = parse_source_string(qasm, Some("bell.qasm"), None::<&[&str]>);
        println!("any_errors: {}", result.any_errors());
        println!("program: {:#?}", result.program());
        println!("---");
        for err in result.semantic_errors().iter() {
            println!("error: {:?}", err);
        }
    }

    #[test]
    fn print_unsupported_gate_ast() {
        let qasm = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
u3(0.1, 0.2, 0.3) q[0];"#;
        let result = parse_source_string(qasm, Some("bad.qasm"), None::<&[&str]>);
        println!("any_errors: {}", result.any_errors());
        println!("program: {:#?}", result.program());
    }
}
  • Step 2: Run exploration and read the output
cargo test exploration -- --nocapture 2>&1 | head -200

Read the output carefully. You need to identify:

  • How Program exposes statements (e.g. program.stmts(), program.statements(), or iterator impl)
  • The name of the Stmt enum variant for gate calls (e.g. Stmt::GateCall(...))
  • The name of the variant for qubit declarations (e.g. Stmt::QubitDecl(...))
  • How to get the gate name string from a GateCall (check if name() returns &str or needs symbol table resolution)
  • How to get qubit register size from a qubit declaration

Write down the variant names — you will use them in the next steps.

4b — Implementation

  • Step 3: Write the failing validator tests

Replace src/validator.rs with:

use oq3_semantics::syntax_to_semantics::parse_source_string;

use crate::error::BridgeError;
use crate::executor::SUPPORTED_GATES;
use crate::types::{CircuitSource, DiagnosticSeverity, ValidationDiagnostic, ValidationResult};

pub struct CircuitValidator {
    max_qubits: usize,
}

impl CircuitValidator {
    pub fn new(max_qubits: usize) -> Self {
        Self { max_qubits }
    }

    pub fn validate(&self, source: &CircuitSource) -> Result<ValidationResult, BridgeError> {
        todo!("implement after AST exploration")
    }
}

/// Converts a byte offset in `source` to (1-based line, 1-based column).
fn byte_offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
    let safe_offset = offset.min(source.len());
    let prefix = &source[..safe_offset];
    let line = prefix.bytes().filter(|&b| b == b'\n').count() + 1;
    let col = prefix.rfind('\n').map(|p| safe_offset - p - 1).unwrap_or(safe_offset) + 1;
    (line, col)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::executor::MAX_LOCAL_QUBITS;

    fn validator() -> CircuitValidator {
        CircuitValidator::new(MAX_LOCAL_QUBITS)
    }

    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;"#;

    #[test]
    fn valid_bell_circuit_is_accepted() {
        let result = validator()
            .validate(&CircuitSource(BELL_CIRCUIT.to_string()))
            .unwrap();
        assert!(result.is_valid, "diagnostics: {:?}", result.diagnostics);
        assert!(result.diagnostics.is_empty());
        assert_eq!(result.num_qubits, Some(2));
    }

    #[test]
    fn unsupported_gate_produces_error_diagnostic() {
        // u3 is a valid QASM3 standard gate but not in our supported set
        let circuit = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
u3(0.1, 0.2, 0.3) q[0];"#;
        let result = validator()
            .validate(&CircuitSource(circuit.to_string()))
            .unwrap();
        assert!(!result.is_valid);
        assert!(!result.diagnostics.is_empty());
        let msg = &result.diagnostics[0].message;
        assert!(
            msg.to_lowercase().contains("u3"),
            "expected 'u3' in message, got: {msg}"
        );
    }

    #[test]
    fn too_many_qubits_produces_error_diagnostic() {
        let n = MAX_LOCAL_QUBITS + 1;
        let circuit = format!("OPENQASM 3.0;\nqubit[{n}] q;\n");
        let result = validator()
            .validate(&CircuitSource(circuit))
            .unwrap();
        assert!(!result.is_valid);
        assert!(!result.diagnostics.is_empty());
        let msg = &result.diagnostics[0].message;
        assert!(
            msg.contains(&n.to_string()),
            "expected qubit count in message, got: {msg}"
        );
    }

    #[test]
    fn invalid_qasm_syntax_produces_error_diagnostic() {
        let circuit = "OPENQASM 3.0;\nthis is not valid qasm;\n";
        let result = validator()
            .validate(&CircuitSource(circuit.to_string()))
            .unwrap();
        assert!(!result.is_valid);
        assert!(!result.diagnostics.is_empty());
    }

    #[test]
    fn undeclared_qubit_produces_error_diagnostic() {
        let circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nh q[5];\n";
        let result = validator()
            .validate(&CircuitSource(circuit.to_string()))
            .unwrap();
        assert!(!result.is_valid);
        assert!(!result.diagnostics.is_empty());
    }
}
  • Step 4: Run tests to confirm they all fail at the todo!()
cargo test validator::tests 2>&1 | tail -20

Expected: all 5 tests panic at todo!().

  • Step 5: Implement CircuitValidator::validate

Replace the todo!() body with the real implementation. Use the variant names you identified in Step 2 above.

pub fn validate(&self, source: &CircuitSource) -> Result<ValidationResult, BridgeError> {
    let parse_result = parse_source_string(&source.0, Some("circuit.qasm"), None::<&[&str]>);
    let mut diagnostics: Vec<ValidationDiagnostic> = Vec::new();

    // Collect errors from oq3_semantics (syntax + undeclared qubits, etc.)
    for error in parse_result.semantic_errors().iter() {
        let range = error.range();
        // TextRange::start() returns a rowan::TextSize; .into() gives u32, cast to usize
        let offset: usize = u32::from(range.start()) as usize;
        let (line, col) = byte_offset_to_line_col(&source.0, offset);
        diagnostics.push(ValidationDiagnostic {
            line,
            column: col,
            message: error.message(),
            severity: DiagnosticSeverity::Error,
        });
    }

    // Stop early on parse/semantic errors — AST may be incomplete
    if parse_result.any_errors() {
        return Ok(ValidationResult {
            is_valid: false,
            diagnostics,
            num_qubits: None,
            num_gates: None,
        });
    }

    let program = parse_result.program();

    // Walk statements to:
    // (a) count total declared qubits
    // (b) collect all gate calls and check against SUPPORTED_GATES
    //
    // NOTE: replace the Stmt variant names below with what you observed in the
    // AST exploration step (Step 2). Common patterns:
    //   Stmt::GateCall(gc)           — a gate application
    //   Stmt::QubitDeclaration(qd)   — qubit register declaration
    // Use `dbg!(stmt)` if unsure.

    let mut total_qubits: usize = 0;
    let mut gate_count: usize = 0;

    for stmt in program.stmts() {
        // --- Qubit declarations ---
        // Adjust the pattern arm to match the real variant name from your exploration.
        if let Some(num) = extract_qubit_count(stmt) {
            total_qubits += num;
        }

        // --- Gate calls ---
        if let Some(gate_name) = extract_gate_name(stmt) {
            if gate_name == "measure" {
                continue; // measure is always allowed
            }
            gate_count += 1;
            if !SUPPORTED_GATES.contains(&gate_name.as_str()) {
                let line = extract_stmt_line(stmt, &source.0);
                let supported = SUPPORTED_GATES.join(", ");
                diagnostics.push(ValidationDiagnostic {
                    line,
                    column: 1,
                    message: format!(
                        "gate '{gate_name}' is not supported by this simulator (supported: {supported})"
                    ),
                    severity: DiagnosticSeverity::Error,
                });
            }
        }
    }

    // Qubit limit check
    if total_qubits > self.max_qubits {
        diagnostics.push(ValidationDiagnostic {
            line: 1,
            column: 1,
            message: format!(
                "circuit requires {total_qubits} qubits, exceeds local simulator limit of {}",
                self.max_qubits
            ),
            severity: DiagnosticSeverity::Error,
        });
    }

    Ok(ValidationResult {
        is_valid: diagnostics.is_empty(),
        diagnostics,
        num_qubits: if total_qubits > 0 { Some(total_qubits) } else { None },
        num_gates: if gate_count > 0 { Some(gate_count) } else { None },
    })
}

Then add the helper functions below validate. Adjust variant names to match your exploration output:

use oq3_semantics::asg::Stmt;

/// Returns the qubit register size if this statement is a qubit declaration.
/// Adjust the pattern to match the real Stmt variant name.
fn extract_qubit_count(stmt: &Stmt) -> Option<usize> {
    // Example pattern — replace with actual variant from exploration:
    // if let Stmt::QubitDeclaration(qd) = stmt {
    //     return Some(qd.width().unwrap_or(1));
    // }
    // Fallback: if you can't find the variant, use the approach below
    // and fill in after running the exploration test.
    let _ = stmt;
    None // Replace this with the real implementation
}

/// Returns the lowercase gate name if this statement is a gate call.
fn extract_gate_name(stmt: &Stmt) -> Option<String> {
    // Example pattern — replace with actual variant from exploration:
    // if let Stmt::GateCall(gc) = stmt {
    //     return Some(gc.name().to_string().to_lowercase());
    // }
    let _ = stmt;
    None // Replace this with the real implementation
}

/// Returns the 1-based line number for a statement using its source span.
fn extract_stmt_line(stmt: &Stmt, source: &str) -> usize {
    // Example:
    // let offset: usize = u32::from(stmt.span().start()) as usize;
    // byte_offset_to_line_col(source, offset).0
    let _ = (stmt, source);
    1 // Replace this with the real implementation
}

Important: The three helper functions above contain placeholder None/1 returns. After running Step 2's exploration test, replace each with the real match arms using the AST variant names you observed. This is intentional — AST introspection requires seeing live debug output first. The test suite (Step 6) will fail until these are real.

  • Step 6: Run the tests and fix until all pass
cargo test validator::tests 2>&1

Iterate on the helper functions until all 5 tests pass. If program.stmts() doesn't exist, check what methods Program exposes via cargo doc --open or rust-analyzer hover.

  • Step 7: Declare the module and verify no regressions

Add to src/main.rs:

mod validator;
cargo test

Expected: all tests pass.

  • Step 8: Commit
git add src/validator.rs src/main.rs
git commit -m "feat: implement CircuitValidator with oq3_semantics"

Task 5: LocalSimulator — spinoza executor

Files:

  • Modify: src/executor.rs (add LocalSimulator below the traits)

  • Step 1: Write failing executor tests

Append to src/executor.rs (inside the #[cfg(test)] block, replacing the mock test):

#[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} not in [0.45, 0.55]"
        );
    }

    #[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} not in [0.45, 0.55]"
        );
    }

    #[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 = 4 amplitudes for 2 qubits
        // Bell state: amplitudes 0 and 3 are 1/√2, amplitudes 1 and 2 are 0
        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: Run tests to confirm they all fail (LocalSimulator not defined yet)
cargo test executor::tests 2>&1 | tail -5

Expected: compile error cannot find struct LocalSimulator.

  • Step 3: Add LocalSimulator to src/executor.rs

Add after the trait definitions (before #[cfg(test)]):

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();

        // Parse circuit — validation is assumed to have passed already.
        let parse_result = parse_source_string(&circuit.0, Some("circuit.qasm"), None::<&[&str]>);
        let program = parse_result.program();

        // First pass: count qubits and identify measured qubits.
        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,
            });
        }

        // Initialise statevector |0...0⟩
        let mut state = State::new(num_qubits);

        // Second pass: apply non-measurement gates in order.
        for (gate_name, params, qubits) in extract_gate_ops(program) {
            apply_gate(&gate_name, &params, &qubits, &mut state)?;
        }

        // Collect statevector before sampling (if requested).
        let statevector = if return_statevector {
            let sv: Vec<(f64, f64)> = state
                .reals
                .iter()
                .zip(state.imags.iter())
                .map(|(&r, &i)| (r as f64, i as f64))
                .collect();
            Some(sv)
        } else {
            None
        };

        // Sample N shots from the final statevector.
        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();

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

        let execution_time_ms = start.elapsed().as_secs_f64() * 1000.0;

        Ok(SimulationResult {
            counts,
            shots: shots.0,
            execution_time_ms,
            statevector,
        })
    }
}

impl Backend for LocalSimulator {}

/// Applies a single gate to the state. Returns an error for unsupported gates.
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", _)          => {} // handled via statevector sampling — skip inline
        _ => {
            return Err(BridgeError::UnsupportedGate {
                gate: gate_name.to_string(),
                line: 0,
                supported,
            })
        }
    }
    Ok(())
}

/// Walks the AST and returns gate operations as (name, params, qubit_indices).
/// Fill in AST variant names after running the exploration test in Task 4.
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() {
        // TODO after exploration: match Stmt::GateCall(gc) and extract
        // gate name, parameter float values, and qubit indices.
        // Example skeleton:
        // if let Stmt::GateCall(gc) = stmt {
        //     let name = resolve_gate_name(gc).to_lowercase();
        //     let params = extract_float_params(gc);
        //     let qubits = extract_qubit_indices(gc);
        //     ops.push((name, params, qubits));
        // }
        let _ = stmt;
    }
    ops
}

/// Counts total declared qubits in the program.
/// Fill in AST variant names after running the exploration test in Task 4.
fn count_qubits(program: &oq3_semantics::asg::Program) -> usize {
    let mut total = 0;
    for stmt in program.stmts() {
        // TODO after exploration: match Stmt::QubitDeclaration(qd) and sum sizes.
        let _ = stmt;
    }
    total
}

Note: extract_gate_ops and count_qubits have the same TODO pattern as Task 4's helper functions — fill them in using the AST variant names from the exploration step. The test suite below will guide you.

  • Step 4: Run tests and fix until all pass
cargo test executor::tests 2>&1

The tests will fail until extract_gate_ops and count_qubits correctly walk the AST. Use the exploration output from Task 4 Step 2 to fill in the match arms. Pay attention to:

  • Endianness: if bell_circuit_only_produces_00_and_11 sees "01" or "10", spinoza's qubit ordering is reversed vs. QASM3. Fix by reversing the qubit index mapping: let t = num_qubits - 1 - raw_qubit_idx.

  • Norm: if the statevector test fails, check that state.reals and state.imags are indexed correctly.

  • Step 5: Run full test suite

cargo test 2>&1

Expected: all tests pass.

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

Task 6: Tool — list_backends

Files:

  • Create: src/tools/mod.rs

  • Create: src/tools/list_backends.rs

  • Step 1: Write the failing test

Create src/tools/list_backends.rs:

use serde_json::json;

use crate::executor::{LocalSimulator, SUPPORTED_GATES, MAX_LOCAL_QUBITS};

pub fn list_backends_response() -> serde_json::Value {
    todo!("implement list_backends_response")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn response_contains_local_simulator() {
        let resp = list_backends_response();
        let backends = resp["backends"].as_array().unwrap();
        assert_eq!(backends.len(), 1);
        assert_eq!(backends[0]["name"], "local_simulator");
    }

    #[test]
    fn response_contains_max_qubits() {
        let resp = list_backends_response();
        let backend = &resp["backends"][0];
        assert_eq!(
            backend["max_qubits"].as_u64().unwrap(),
            MAX_LOCAL_QUBITS as u64
        );
    }

    #[test]
    fn response_lists_supported_gates() {
        let resp = list_backends_response();
        let gates = resp["backends"][0]["supported_gates"]
            .as_array()
            .unwrap();
        assert!(!gates.is_empty());
        assert!(gates.iter().any(|g| g == "h"));
        assert!(gates.iter().any(|g| g == "cx"));
    }
}
  • Step 2: Run tests to confirm they fail
cargo test tools::list_backends 2>&1 | tail -5

Expected: compile error (module not declared yet) or panic at todo!().

  • Step 3: Implement list_backends_response

Replace the todo!():

pub fn list_backends_response() -> serde_json::Value {
    let sim = LocalSimulator::new();
    json!({
        "backends": [{
            "name": sim.name(),
            "description": "Local statevector simulator (Spinoza engine). No network, no account required.",
            "max_qubits": sim.max_qubits(),
            "supported_gates": sim.supported_gates(),
            "simulation_type": "statevector",
            "notes": "Use run_circuit shots parameter (default 1024, max 100000). Statevector memory grows as 2^n × 16 bytes."
        }]
    })
}
  • Step 4: Create src/tools/mod.rs
pub mod list_backends;
pub mod run_circuit;
pub mod validate_circuit;
  • Step 5: Declare the tools module in src/main.rs
mod tools;
  • Step 6: Run tests
cargo test tools::list_backends 2>&1

Expected: all 3 tests pass.

  • Step 7: Commit
git add src/tools/mod.rs src/tools/list_backends.rs src/main.rs
git commit -m "feat: implement list_backends tool handler"

Task 7: Tool — validate_circuit

Files:

  • Create: src/tools/validate_circuit.rs

  • Step 1: Write the failing test

Create src/tools/validate_circuit.rs:

use serde_json::{json, Value};

use crate::executor::{LocalSimulator, MAX_LOCAL_QUBITS};
use crate::types::CircuitSource;

pub fn validate_circuit_response(circuit: &str) -> Value {
    todo!("implement validate_circuit_response")
}

#[cfg(test)]
mod tests {
    use super::*;

    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;"#;

    #[test]
    fn valid_circuit_returns_is_valid_true() {
        let resp = validate_circuit_response(BELL_CIRCUIT);
        assert_eq!(resp["is_valid"], true);
        assert_eq!(resp["diagnostics"].as_array().unwrap().len(), 0);
    }

    #[test]
    fn valid_circuit_includes_qubit_count() {
        let resp = validate_circuit_response(BELL_CIRCUIT);
        assert_eq!(resp["num_qubits"].as_u64().unwrap(), 2);
    }

    #[test]
    fn invalid_circuit_returns_is_valid_false_with_message() {
        let resp = validate_circuit_response("OPENQASM 3.0;\nnot_valid_qasm;");
        assert_eq!(resp["is_valid"], false);
        let diags = resp["diagnostics"].as_array().unwrap();
        assert!(!diags.is_empty());
        assert!(diags[0]["message"].as_str().unwrap().len() > 0);
    }

    #[test]
    fn unsupported_gate_returns_diagnostic_with_gate_name() {
        let circuit = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
u3(0.1, 0.2, 0.3) q[0];"#;
        let resp = validate_circuit_response(circuit);
        assert_eq!(resp["is_valid"], false);
        let diags = resp["diagnostics"].as_array().unwrap();
        let msg = diags[0]["message"].as_str().unwrap();
        assert!(msg.to_lowercase().contains("u3"), "msg: {msg}");
    }

    #[test]
    fn diagnostic_includes_line_and_column() {
        let circuit = "OPENQASM 3.0;\nnot_valid_qasm;\n";
        let resp = validate_circuit_response(circuit);
        let diag = &resp["diagnostics"][0];
        assert!(diag["line"].as_u64().unwrap() >= 1);
        assert!(diag["column"].as_u64().unwrap() >= 1);
    }
}
  • Step 2: Run tests to confirm they fail
cargo test tools::validate_circuit 2>&1 | tail -5

Expected: panic at todo!().

  • Step 3: Implement validate_circuit_response

Replace the todo!():

pub fn validate_circuit_response(circuit: &str) -> Value {
    use crate::validator::CircuitValidator;
    let validator = CircuitValidator::new(MAX_LOCAL_QUBITS);
    match validator.validate(&CircuitSource(circuit.to_string())) {
        Err(e) => json!({
            "is_valid": false,
            "diagnostics": [{"line": 1, "column": 1, "message": e.to_string(), "severity": "error"}],
            "num_qubits": null,
            "num_gates": null,
        }),
        Ok(result) => {
            let diagnostics: Vec<Value> = result.diagnostics.iter().map(|d| {
                json!({
                    "line": d.line,
                    "column": d.column,
                    "message": d.message,
                    "severity": match d.severity {
                        crate::types::DiagnosticSeverity::Error => "error",
                        crate::types::DiagnosticSeverity::Warning => "warning",
                    }
                })
            }).collect();
            json!({
                "is_valid": result.is_valid,
                "diagnostics": diagnostics,
                "num_qubits": result.num_qubits,
                "num_gates": result.num_gates,
            })
        }
    }
}
  • Step 4: Run tests
cargo test tools::validate_circuit 2>&1

Expected: all 5 tests pass.

  • Step 5: Commit
git add src/tools/validate_circuit.rs
git commit -m "feat: implement validate_circuit tool handler"

Task 8: Tool — run_circuit

Files:

  • Create: src/tools/run_circuit.rs

  • Step 1: Write the failing test

Create src/tools/run_circuit.rs:

use serde_json::{json, Value};

use crate::types::{CircuitSource, ShotCount};

pub fn run_circuit_response(circuit: &str, shots: u32, return_statevector: bool) -> Value {
    todo!("implement run_circuit_response")
}

#[cfg(test)]
mod tests {
    use super::*;

    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;"#;

    #[test]
    fn bell_circuit_returns_counts_with_total_matching_shots() {
        let resp = run_circuit_response(BELL_CIRCUIT, 1_000, false);
        let counts = resp["counts"].as_object().unwrap();
        let total: u64 = counts.values().map(|v| v.as_u64().unwrap()).sum();
        assert_eq!(total, 1_000);
    }

    #[test]
    fn bell_circuit_only_produces_00_and_11_outcomes() {
        let resp = run_circuit_response(BELL_CIRCUIT, 1_000, false);
        let counts = resp["counts"].as_object().unwrap();
        for key in counts.keys() {
            assert!(key == "00" || key == "11", "unexpected key: {key}");
        }
    }

    #[test]
    fn x_gate_produces_only_one_outcome() {
        let resp = run_circuit_response(X_CIRCUIT, 100, false);
        let counts = resp["counts"].as_object().unwrap();
        assert_eq!(counts.get("1").and_then(|v| v.as_u64()), Some(100));
    }

    #[test]
    fn response_includes_shots_and_execution_time() {
        let resp = run_circuit_response(BELL_CIRCUIT, 512, false);
        assert_eq!(resp["shots"].as_u64().unwrap(), 512);
        assert!(resp["execution_time_ms"].as_f64().unwrap() >= 0.0);
    }

    #[test]
    fn statevector_is_absent_when_not_requested() {
        let resp = run_circuit_response(BELL_CIRCUIT, 100, false);
        assert!(resp["statevector"].is_null());
    }

    #[test]
    fn statevector_has_correct_length_when_requested() {
        let resp = run_circuit_response(BELL_CIRCUIT, 100, true);
        let sv = resp["statevector"].as_array().unwrap();
        assert_eq!(sv.len(), 4); // 2^2 basis states
    }

    #[test]
    fn invalid_circuit_returns_error_field() {
        let resp = run_circuit_response("OPENQASM 3.0;\nnot_valid;", 100, false);
        assert!(resp.get("error").is_some(), "expected 'error' field, got: {resp}");
    }

    #[test]
    fn shots_clamped_to_max() {
        // ShotCount::MAX is 100_000 — requesting more should clamp or error
        let resp = run_circuit_response(BELL_CIRCUIT, ShotCount::MAX.0 + 1, false);
        // Expect either clamping (shots == MAX) or an error field
        let has_error = resp.get("error").is_some();
        let shots = resp["shots"].as_u64().unwrap_or(0);
        assert!(has_error || shots == ShotCount::MAX.0 as u64);
    }
}
  • Step 2: Run tests to confirm they fail
cargo test tools::run_circuit 2>&1 | tail -5

Expected: panic at todo!().

  • Step 3: Implement run_circuit_response

Replace the todo!():

pub fn run_circuit_response(circuit: &str, shots: u32, return_statevector: bool) -> Value {
    use crate::executor::{CanExecute, CanValidate, LocalSimulator};
    use crate::validator::CircuitValidator;
    use crate::executor::MAX_LOCAL_QUBITS;

    // Clamp shots to max
    let shot_count = ShotCount(shots.min(ShotCount::MAX.0));

    // Validate first — return structured error if invalid
    let validator = CircuitValidator::new(MAX_LOCAL_QUBITS);
    let validation = match validator.validate(&CircuitSource(circuit.to_string())) {
        Err(e) => return json!({"error": e.to_string()}),
        Ok(v) => v,
    };
    if !validation.is_valid {
        let messages: Vec<&str> = validation.diagnostics.iter().map(|d| d.message.as_str()).collect();
        return json!({
            "error": format!("circuit validation failed: {}", messages.join("; "))
        });
    }

    let sim = LocalSimulator::new();
    match sim.run(&CircuitSource(circuit.to_string()), shot_count, return_statevector) {
        Err(e) => json!({"error": e.to_string()}),
        Ok(result) => {
            let statevector: Value = match result.statevector {
                None => Value::Null,
                Some(sv) => sv.iter()
                    .map(|(r, i)| json!([r, i]))
                    .collect::<Vec<_>>()
                    .into(),
            };
            json!({
                "counts": result.counts,
                "shots": result.shots,
                "execution_time_ms": result.execution_time_ms,
                "statevector": statevector,
                "backend": "local_simulator",
            })
        }
    }
}
  • Step 4: Run tests
cargo test tools::run_circuit 2>&1

Expected: all 8 tests pass.

  • Step 5: Commit
git add src/tools/run_circuit.rs
git commit -m "feat: implement run_circuit tool handler"

Task 9: MCP Server — main.rs with rmcp

Files:

  • Modify: src/main.rs (replace stub)

  • Modify: src/tools/mod.rs (add QuantumBridgeServer)

  • Step 1: Write the failing compilation test

First, write a compile-only test in src/tools/mod.rs to verify the struct can be created:

pub mod list_backends;
pub mod run_circuit;
pub mod validate_circuit;

use rmcp::{
    ErrorData as McpError,
    ServerHandler,
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::*,
    tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::Deserialize;

use crate::tools::list_backends::list_backends_response;
use crate::tools::run_circuit::run_circuit_response;
use crate::tools::validate_circuit::validate_circuit_response;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct ValidateCircuitParams {
    /// OpenQASM 3.0 source string to validate.
    pub circuit: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
pub struct RunCircuitParams {
    /// OpenQASM 3.0 source string to execute.
    pub circuit: String,
    /// Number of measurement shots (default 1024, max 100 000).
    #[serde(default = "default_shots")]
    pub shots: u32,
    /// Whether to include the full statevector in the response.
    #[serde(default)]
    pub return_statevector: bool,
}

fn default_shots() -> u32 {
    1024
}

#[derive(Clone)]
pub struct QuantumBridgeServer {
    tool_router: ToolRouter<QuantumBridgeServer>,
}

#[tool_router]
impl QuantumBridgeServer {
    pub fn new() -> Self {
        Self { tool_router: Self::tool_router() }
    }

    #[tool(description = "List available quantum simulation backends and their capabilities.")]
    async fn list_backends(&self) -> Result<CallToolResult, McpError> {
        let json = list_backends_response();
        Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
    }

    #[tool(description = "Parse and validate an OpenQASM 3.0 circuit. Returns structured diagnostics with line/column for each error.")]
    async fn validate_circuit(
        &self,
        Parameters(ValidateCircuitParams { circuit }): Parameters<ValidateCircuitParams>,
    ) -> Result<CallToolResult, McpError> {
        let json = validate_circuit_response(&circuit);
        Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
    }

    #[tool(description = "Execute an OpenQASM 3.0 circuit on the local statevector simulator. Returns measurement counts, execution time, and optionally the full statevector.")]
    async fn run_circuit(
        &self,
        Parameters(RunCircuitParams { circuit, shots, return_statevector }): Parameters<RunCircuitParams>,
    ) -> Result<CallToolResult, McpError> {
        let json = run_circuit_response(&circuit, shots, return_statevector);
        if json.get("error").is_some() {
            return Err(McpError::invalid_params(
                json["error"].as_str().unwrap_or("unknown error"),
                None,
            ));
        }
        Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
    }
}

#[tool_handler]
impl ServerHandler for QuantumBridgeServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(
            ServerCapabilities::builder()
                .enable_tools()
                .build(),
        )
        .with_server_info(Implementation::from_build_env())
        .with_protocol_version(ProtocolVersion::V_2024_11_05)
        .with_instructions(
            "Quantum circuit simulator MCP server. Accepts OpenQASM 3.0 circuits. \
             Use validate_circuit before run_circuit to get actionable error messages.".to_string(),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn quantum_bridge_server_can_be_constructed() {
        let _server = QuantumBridgeServer::new();
    }
}
  • Step 2: Run compile test
cargo test tools::tests 2>&1

Expected: compiles and the construction test passes. If #[tool_router] / #[tool_handler] macro names are wrong, check cargo doc --open for the rmcp crate or look at ~/.cargo/git/checkouts/rust-sdk-*/examples/.

  • Step 3: Write src/main.rs
mod error;
mod executor;
mod tools;
mod types;
mod validator;

use anyhow::Result;
use rmcp::{ServiceExt, transport::stdio};
use tools::QuantumBridgeServer;

#[tokio::main]
async fn main() -> Result<()> {
    // All logs go to stderr — stdout is the MCP JSON-RPC channel.
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_ansi(false)
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(tracing::Level::INFO.into()),
        )
        .init();

    tracing::info!("quantum-bridge-mcp starting");

    let service = QuantumBridgeServer::new()
        .serve(stdio())
        .await?;

    service.waiting().await?;
    Ok(())
}
  • Step 4: Verify binary compiles
cargo build 2>&1

Expected: compiles without errors. Warnings about unused imports are acceptable at this stage.

  • Step 5: Smoke test — send a JSON-RPC initialize message
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}' | cargo run 2>/dev/null

Expected: a JSON response containing "result" with "serverInfo" including "name":"quantum-bridge-mcp". The server will then wait for more input — press Ctrl+C.

  • Step 6: Commit
git add src/main.rs src/tools/mod.rs
git commit -m "feat: wire rmcp stdio server with list_backends/validate_circuit/run_circuit tools"

Task 10: Integration Tests — MCP protocol conformance

Files:

  • Create: tests/integration/mcp_protocol.rs

  • Create: tests/integration/mod.rs

  • Step 1: Create test infrastructure

mkdir -p tests/integration

Create tests/integration/mod.rs:

// Integration test module — empty re-export for cargo test discovery.
  • Step 2: Write the integration tests

Create tests/integration/mcp_protocol.rs:

//! Roundtrip tests: spawn the binary, send JSON-RPC messages on stdin, assert stdout.

use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};

struct McpProcess {
    child: Child,
}

impl McpProcess {
    fn spawn() -> Self {
        let child = Command::new(env!("CARGO_BIN_EXE_quantum-bridge-mcp"))
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("failed to spawn quantum-bridge-mcp binary");
        Self { child }
    }

    fn send(&mut self, msg: &str) {
        let stdin = self.child.stdin.as_mut().unwrap();
        writeln!(stdin, "{}", msg).unwrap();
        stdin.flush().unwrap();
    }

    fn recv_line(&mut self) -> String {
        let stdout = self.child.stdout.as_mut().unwrap();
        let mut reader = BufReader::new(stdout);
        let mut line = String::new();
        reader.read_line(&mut line).unwrap();
        line.trim().to_string()
    }
}

impl Drop for McpProcess {
    fn drop(&mut self) {
        let _ = self.child.kill();
    }
}

fn initialize(proc: &mut McpProcess) -> serde_json::Value {
    proc.send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}"#);
    let line = proc.recv_line();
    serde_json::from_str(&line).expect("initialize response is valid JSON")
}

#[test]
fn initialize_returns_server_info() {
    let mut proc = McpProcess::spawn();
    let resp = initialize(&mut proc);
    assert!(resp["result"]["serverInfo"]["name"].as_str().is_some());
    assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
}

#[test]
fn tools_list_contains_three_tools() {
    let mut proc = McpProcess::spawn();
    initialize(&mut proc);
    proc.send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#);
    let resp: serde_json::Value = serde_json::from_str(&proc.recv_line()).unwrap();
    let tools = resp["result"]["tools"].as_array().unwrap();
    assert_eq!(tools.len(), 3);
    let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
    assert!(names.contains(&"list_backends"));
    assert!(names.contains(&"validate_circuit"));
    assert!(names.contains(&"run_circuit"));
}

#[test]
fn call_list_backends_returns_local_simulator() {
    let mut proc = McpProcess::spawn();
    initialize(&mut proc);
    proc.send(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_backends","arguments":{}}}"#);
    let resp: serde_json::Value = serde_json::from_str(&proc.recv_line()).unwrap();
    let text = resp["result"]["content"][0]["text"].as_str().unwrap();
    let payload: serde_json::Value = serde_json::from_str(text).unwrap();
    assert_eq!(payload["backends"][0]["name"], "local_simulator");
}

#[test]
fn call_validate_circuit_with_valid_bell_returns_is_valid_true() {
    let bell = r#"OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;"#;
    let call = format!(
        r#"{{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{{"name":"validate_circuit","arguments":{{"circuit":"{}"}}}}}}"#,
        bell
    );
    let mut proc = McpProcess::spawn();
    initialize(&mut proc);
    proc.send(&call);
    let resp: serde_json::Value = serde_json::from_str(&proc.recv_line()).unwrap();
    let text = resp["result"]["content"][0]["text"].as_str().unwrap();
    let payload: serde_json::Value = serde_json::from_str(text).unwrap();
    assert_eq!(payload["is_valid"], true);
}

#[test]
fn call_run_circuit_with_x_gate_returns_count_of_1_for_all_shots() {
    let x_circ = r#"OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;"#;
    let call = format!(
        r#"{{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{{"name":"run_circuit","arguments":{{"circuit":"{}","shots":100}}}}}}"#,
        x_circ
    );
    let mut proc = McpProcess::spawn();
    initialize(&mut proc);
    proc.send(&call);
    let resp: serde_json::Value = serde_json::from_str(&proc.recv_line()).unwrap();
    let text = resp["result"]["content"][0]["text"].as_str().unwrap();
    let payload: serde_json::Value = serde_json::from_str(text).unwrap();
    assert_eq!(payload["counts"]["1"].as_u64().unwrap(), 100);
}

Note: The integration tests use env!("CARGO_BIN_EXE_quantum-bridge-mcp") which cargo sets to the compiled binary path. Run with cargo test --test integration — not with the plain cargo test that only runs unit tests.

  • Step 3: Run integration tests (requires binary build)
cargo test --test integration 2>&1

Expected: all 5 tests pass. If the MCP framing uses length-prefixed lines rather than newlines, adjust recv_line accordingly (check rmcp's stdio transport output format).

  • Step 4: Commit
git add tests/integration/
git commit -m "test: add MCP protocol integration tests (initialize, tools/list, tools/call)"

Task 11: Property-Based Tests

Files:

  • Create: tests/proptest/invariants.rs

  • Create: tests/proptest/mod.rs

  • Step 1: Create proptest directory

mkdir -p tests/proptest

Create tests/proptest/mod.rs:

// Property-based test module.
  • Step 2: Write the property tests

Create tests/proptest/invariants.rs:

//! Quantum mechanics invariants: any valid circuit must preserve unitarity and normalisation.

use proptest::prelude::*;
use quantum_bridge_mcp::executor::{CanExecute, LocalSimulator};
use quantum_bridge_mcp::types::{CircuitSource, ShotCount};

// Make crate types visible — add `pub` to modules in lib.rs (see Step 3).

proptest! {
    /// Normalisation: shot counts always sum to exactly the requested number of shots.
    #[test]
    fn shot_counts_always_sum_to_requested_shots(
        shots in 1u32..=1_000u32
    ) {
        let circuit = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
c = measure q;"#;
        let sim = LocalSimulator::new();
        let result = sim
            .run(&CircuitSource(circuit.to_string()), ShotCount(shots), false)
            .unwrap();
        let total: u64 = result.counts.values().sum();
        prop_assert_eq!(total, shots as u64);
    }

    /// Statevector norm is always 1.0 within floating-point tolerance.
    #[test]
    fn statevector_norm_is_one(
        angle in -std::f64::consts::PI..=std::f64::consts::PI
    ) {
        let circuit = format!(
            "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nrx({angle}) q[0];\nc = measure q;"
        );
        let sim = LocalSimulator::new();
        let result = sim
            .run(&CircuitSource(circuit), ShotCount(1), true)
            .unwrap();
        let sv = result.statevector.unwrap();
        let norm: f64 = sv.iter().map(|(r, i)| r * r + i * i).sum();
        prop_assert!((norm - 1.0).abs() < 1e-9, "norm={norm}");
    }

    /// Bitstrings in counts have correct length (= number of qubits).
    #[test]
    fn count_bitstrings_have_correct_length(
        n_qubits in 1usize..=5usize,
        shots in 10u32..=100u32
    ) {
        let qubit_decls: String = (0..n_qubits).map(|i| format!("h q[{i}];\n")).collect();
        let circuit = format!(
            "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n_qubits}] q;\nbit[{n_qubits}] c;\n{qubit_decls}c = measure q;\n"
        );
        let sim = LocalSimulator::new();
        let result = sim
            .run(&CircuitSource(circuit), ShotCount(shots), false)
            .unwrap();
        for key in result.counts.keys() {
            prop_assert_eq!(
                key.len(), n_qubits,
                "bitstring '{key}' has wrong length for {n_qubits}-qubit circuit"
            );
        }
    }
}
  • Step 3: Expose crate internals for tests

Proptest files live in tests/ and need use quantum_bridge_mcp::.... Add a src/lib.rs so the crate has a library target:

Create src/lib.rs:

pub mod error;
pub mod executor;
pub mod types;
pub mod validator;
pub(crate) mod tools;

Update src/main.rs — remove the mod declarations that are now in lib.rs:

// Remove: mod error; mod executor; mod types; mod validator; mod tools;
// Keep only:
use quantum_bridge_mcp::tools::QuantumBridgeServer;  // or use crate path

use anyhow::Result;
use rmcp::{ServiceExt, transport::stdio};

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_ansi(false)
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(tracing::Level::INFO.into()),
        )
        .init();

    tracing::info!("quantum-bridge-mcp starting");

    let service = QuantumBridgeServer::new()
        .serve(stdio())
        .await?;

    service.waiting().await?;
    Ok(())
}

Add to Cargo.toml:

[lib]
name = "quantum_bridge_mcp"
path = "src/lib.rs"
  • Step 4: Run proptest
cargo test --test proptest 2>&1

Expected: all 3 property tests pass (each runs 256 cases by default).

  • Step 5: Commit
git add tests/proptest/ src/lib.rs src/main.rs Cargo.toml
git commit -m "test: add property-based tests for shot normalisation, statevector norm, and bitstring length"

Task 12: Benchmarks

Files:

  • Create: benches/simulation.rs

  • Step 1: Create the bench file

mkdir -p benches

Create benches/simulation.rs:

use criterion::{criterion_group, criterion_main, Criterion};
use quantum_bridge_mcp::executor::{CanExecute, LocalSimulator};
use quantum_bridge_mcp::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;"#;

fn make_h_circuit(n_qubits: usize, n_shots: u32) -> (CircuitSource, ShotCount) {
    let gates: String = (0..n_qubits).map(|i| format!("h q[{i}];\n")).collect();
    let circuit = format!(
        "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n_qubits}] q;\nbit[{n_qubits}] c;\n{gates}c = measure q;\n"
    );
    (CircuitSource(circuit), ShotCount(n_shots))
}

fn bench_bell_1024_shots(c: &mut Criterion) {
    let sim = LocalSimulator::new();
    c.bench_function("bell_1024_shots", |b| {
        b.iter(|| {
            sim.run(
                &CircuitSource(BELL_CIRCUIT.to_string()),
                ShotCount(1024),
                false,
            )
            .unwrap()
        })
    });
}

fn bench_10_qubits_10k_shots(c: &mut Criterion) {
    let sim = LocalSimulator::new();
    let (circuit, shots) = make_h_circuit(10, 10_000);
    c.bench_function("10_qubits_10k_shots", |b| {
        b.iter(|| sim.run(&circuit, shots, false).unwrap())
    });
}

fn bench_20_qubits_10k_shots(c: &mut Criterion) {
    let sim = LocalSimulator::new();
    let (circuit, shots) = make_h_circuit(20, 10_000);
    c.bench_function("20_qubits_10k_shots", |b| {
        b.iter(|| sim.run(&circuit, shots, false).unwrap())
    });
}

criterion_group!(
    benches,
    bench_bell_1024_shots,
    bench_10_qubits_10k_shots,
    bench_20_qubits_10k_shots,
);
criterion_main!(benches);
  • Step 2: Run benchmarks to establish baseline
cargo bench 2>&1 | tail -20

Expected output includes:

bell_1024_shots    time: [X ms  ...]
10_qubits_10k_shots time: [X ms ...]
20_qubits_10k_shots time: [X ms ...]

SLA targets (spec §6):

  • bell_1024_shots < 5 ms
  • 10_qubits_10k_shots < 50 ms
  • 20_qubits_10k_shots < 500 ms

If any target is missed, the bottleneck is almost certainly in extract_gate_ops (O(n) loop with AST allocation). Profile with cargo bench -- --profile-time 5 and check if the AST is being cloned unnecessarily.

  • Step 3: Commit
git add benches/simulation.rs
git commit -m "bench: add Criterion benchmarks for Bell, 10-qubit, and 20-qubit circuits"

Task 13: Reference Golden Files + gen_reference.py

Files:

  • Create: tests/reference/bell.qasm

  • Create: tests/reference/bell_counts.json

  • Create: tests/reference/ghz3.qasm

  • Create: tests/reference/ghz3_counts.json

  • Create: tests/reference/invalid/unsupported_gate.qasm

  • Create: tests/reference/invalid/unsupported_gate.error.txt

  • Create: tests/reference/invalid/undeclared_qubit.qasm

  • Create: tests/reference/invalid/undeclared_qubit.error.txt

  • Create: tests/reference/invalid/syntax_error.qasm

  • Create: tests/reference/invalid/syntax_error.error.txt

  • Create: tests/reference/invalid/qubit_limit.qasm

  • Create: tests/reference/invalid/qubit_limit.error.txt

  • Create: scripts/gen_reference.py

  • Create: tests/reference/cross_validate.rs (Rust test that reads the golden files)

  • Step 1: Create reference circuit files

mkdir -p tests/reference/invalid scripts

tests/reference/bell.qasm:

OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
c = measure q;

tests/reference/ghz3.qasm:

OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
bit[3] c;
h q[0];
cx q[0], q[1];
cx q[1], q[2];
c = measure q;

tests/reference/invalid/unsupported_gate.qasm:

OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
u3(0.1, 0.2, 0.3) q[0];

tests/reference/invalid/unsupported_gate.error.txt:

gate 'u3' is not supported

tests/reference/invalid/undeclared_qubit.qasm:

OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
h q[5];

tests/reference/invalid/undeclared_qubit.error.txt:

out of range

tests/reference/invalid/syntax_error.qasm:

OPENQASM 3.0;
this is not valid;

tests/reference/invalid/syntax_error.error.txt:

error

tests/reference/invalid/qubit_limit.qasm:

OPENQASM 3.0;
qubit[29] q;

tests/reference/invalid/qubit_limit.error.txt:

exceeds
  • Step 2: Write gen_reference.py

Create scripts/gen_reference.py:

#!/usr/bin/env python3
"""
Generate reference counts and statevectors via Qiskit Aer.
Run in CI to populate tests/reference/*.json.
Requires: pip install qiskit qiskit-aer
"""
import json
import sys
from pathlib import Path

try:
    from qiskit import QuantumCircuit
    from qiskit_aer import AerSimulator
except ImportError:
    print("ERROR: pip install qiskit qiskit-aer", file=sys.stderr)
    sys.exit(1)

SHOTS = 10_000
SEED = 42
REF_DIR = Path(__file__).parent.parent / "tests" / "reference"

def run_circuit(qasm_path: Path) -> dict:
    qc = QuantumCircuit.from_qasm_file(str(qasm_path))
    sim = AerSimulator(method="statevector")
    job = sim.run(qc, shots=SHOTS, seed_simulator=SEED)
    result = job.result()
    counts = result.get_counts()
    # Statevector from noiseless zero-shot run
    qc_sv = QuantumCircuit.from_qasm_file(str(qasm_path))
    qc_sv.save_statevector()
    sv_job = sim.run(qc_sv, shots=0)
    sv = list(sv_job.result().get_statevector())
    return {
        "shots": SHOTS,
        "seed": SEED,
        "counts": {k: int(v) for k, v in counts.items()},
        "statevector": [[float(a.real), float(a.imag)] for a in sv],
    }

def validate_mode() -> bool:
    """--validate: check that Spinoza counts match Qiskit within chi-squared tolerance."""
    import subprocess, scipy.stats as stats
    all_pass = True
    for qasm in REF_DIR.glob("*.qasm"):
        ref_file = qasm.with_suffix(".json")
        if not ref_file.exists():
            print(f"MISSING {ref_file}")
            all_pass = False
            continue
        ref = json.loads(ref_file.read_text())
        # Run against the local binary
        result = subprocess.run(
            ["cargo", "run", "--", "--validate-only", str(qasm)],
            capture_output=True, text=True
        )
        # Parse Spinoza counts from stdout and chi-squared compare with ref["counts"]
        # ... (implementation depends on CLI output format — add --validate-only flag in V1.5)
        print(f"SKIP {qasm.name} (--validate-only CLI flag not implemented in V1)")
    return all_pass

if __name__ == "__main__":
    if "--validate" in sys.argv:
        sys.exit(0 if validate_mode() else 1)

    for qasm in REF_DIR.glob("*.qasm"):
        out = REF_DIR / qasm.with_suffix(".json").name
        data = run_circuit(qasm)
        out.write_text(json.dumps(data, indent=2))
        print(f"wrote {out}")
  • Step 3: Write cross-validation Rust test

Create tests/reference/cross_validate.rs:

//! Reads the golden .json files and verifies that LocalSimulator produces
//! statistically compatible counts (chi-squared, α=0.01, N=10k shots, seed=42).

use std::collections::HashMap;
use std::path::Path;

use quantum_bridge_mcp::executor::{CanExecute, LocalSimulator};
use quantum_bridge_mcp::types::{CircuitSource, ShotCount};

fn load_circuit(name: &str) -> CircuitSource {
    let path = Path::new("tests/reference").join(name);
    CircuitSource(std::fs::read_to_string(path).unwrap())
}

fn load_expected_counts(name: &str) -> HashMap<String, u64> {
    let path = Path::new("tests/reference").join(name);
    if !path.exists() {
        // JSON not generated yet (Qiskit not installed) — skip
        return HashMap::new();
    }
    let text = std::fs::read_to_string(path).unwrap();
    let data: serde_json::Value = serde_json::from_str(&text).unwrap();
    data["counts"]
        .as_object()
        .unwrap()
        .iter()
        .map(|(k, v)| (k.clone(), v.as_u64().unwrap()))
        .collect()
}

fn chi_squared_passes(observed: &HashMap<String, u64>, expected: &HashMap<String, u64>, total: u64) -> bool {
    if expected.is_empty() { return true; } // skip if no golden file
    let alpha = 0.01;
    // Degrees of freedom = number of outcomes - 1
    let chi_sq: f64 = expected.iter().map(|(k, &exp_count)| {
        let obs_count = *observed.get(k).unwrap_or(&0) as f64;
        let exp_f = exp_count as f64;
        if exp_f == 0.0 { 0.0 } else { (obs_count - exp_f).powi(2) / exp_f }
    }).sum();
    let df = (expected.len() - 1) as f64;
    // Rough critical value for chi-squared at α=0.01 — exact table lookup would need a stats crate
    // For df=1 (Bell): critical ≈ 6.63; for df=7 (3-qubit GHZ): ≈ 18.5
    let critical = df * 3.0 + 6.63; // conservative upper bound
    chi_sq < critical
}

#[test]
fn bell_counts_match_qiskit_golden() {
    let sim = LocalSimulator::new();
    let circuit = load_circuit("bell.qasm");
    let expected = load_expected_counts("bell_counts.json");
    if expected.is_empty() { return; } // Qiskit not available in this environment

    let result = sim.run(&circuit, ShotCount(10_000), false).unwrap();
    assert!(
        chi_squared_passes(&result.counts, &expected, 10_000),
        "Bell counts diverge from Qiskit golden file.\n\
         Spinoza: {:?}\nQiskit: {:?}",
        result.counts, expected
    );
}

#[test]
fn ghz3_counts_match_qiskit_golden() {
    let sim = LocalSimulator::new();
    let circuit = load_circuit("ghz3.qasm");
    let expected = load_expected_counts("ghz3_counts.json");
    if expected.is_empty() { return; }

    let result = sim.run(&circuit, ShotCount(10_000), false).unwrap();
    assert!(
        chi_squared_passes(&result.counts, &expected, 10_000),
        "GHZ-3 counts diverge from Qiskit golden file.\n\
         Spinoza: {:?}\nQiskit: {:?}",
        result.counts, expected
    );
}

#[test]
fn invalid_circuits_produce_validation_errors() {
    use quantum_bridge_mcp::validator::CircuitValidator;
    use quantum_bridge_mcp::executor::MAX_LOCAL_QUBITS;
    use std::fs;

    let validator = CircuitValidator::new(MAX_LOCAL_QUBITS);
    let invalid_dir = Path::new("tests/reference/invalid");

    for entry in fs::read_dir(invalid_dir).unwrap() {
        let entry = entry.unwrap();
        let path = entry.path();
        if path.extension().map(|e| e == "qasm").unwrap_or(false) {
            let error_file = path.with_extension("error.txt");
            let expected_fragment = fs::read_to_string(&error_file)
                .unwrap()
                .trim()
                .to_lowercase();

            let source = fs::read_to_string(&path).unwrap();
            let result = validator
                .validate(&quantum_bridge_mcp::types::CircuitSource(source))
                .unwrap();

            assert!(
                !result.is_valid,
                "{} should be invalid", path.display()
            );
            let all_messages: String = result.diagnostics
                .iter()
                .map(|d| d.message.to_lowercase())
                .collect::<Vec<_>>()
                .join(" ");
            assert!(
                all_messages.contains(&expected_fragment),
                "expected '{}' in messages for {}\nGot: {}",
                expected_fragment, path.display(), all_messages
            );
        }
    }
}
  • Step 4: Run the cross-validation tests
cargo test --test cross_validate 2>&1

Expected: bell_counts_match_qiskit_golden and ghz3_counts_match_qiskit_golden are skipped (golden JSON not generated yet); invalid_circuits_produce_validation_errors passes.

To generate the golden files (needs Python + Qiskit):

python3 scripts/gen_reference.py
cargo test --test cross_validate 2>&1
  • Step 5: Commit
git add tests/reference/ scripts/gen_reference.py
git commit -m "test: add cross-validation golden files and gen_reference.py script"

Task 14: CI — GitHub Actions

Files:

  • Create: .github/workflows/ci.yml

  • Create: .github/workflows/release.yml

  • Step 1: Write ci.yml

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1

jobs:
  rust:
    name: Rust checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install stable toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy

      - name: Cache cargo registry
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: ${{ runner.os }}-cargo-

      - name: Check formatting
        run: cargo fmt --check

      - name: Clippy (deny warnings)
        run: cargo clippy -- -D warnings

      - name: Run unit and integration tests
        run: cargo test

      - name: Run benchmarks (smoke — compile + one iteration)
        run: cargo bench -- --test

  cross-validate:
    name: Cross-validate with Qiskit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install stable toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-qiskit
          restore-keys: ${{ runner.os }}-pip-

      - name: Install Qiskit Aer
        run: pip install qiskit qiskit-aer

      - name: Generate reference golden files
        run: python3 scripts/gen_reference.py

      - name: Run cross-validation tests
        run: cargo test --test cross_validate
  • Step 2: Write release.yml

Create .github/workflows/release.yml:

name: Release

on:
  push:
    tags:
      - "v*"

jobs:
  dist:
    name: Build release binaries
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
          - os: ubuntu-latest
            target: aarch64-unknown-linux-gnu
          - os: macos-latest
            target: x86_64-apple-darwin
          - os: macos-latest
            target: aarch64-apple-darwin
          - os: windows-latest
            target: x86_64-pc-windows-msvc

    steps:
      - uses: actions/checkout@v4

      - name: Install stable toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Install cross (Linux cross-compilation)
        if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu'
        run: cargo install cross

      - name: Build
        run: |
          if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
            cross build --release --target ${{ matrix.target }}
          else
            cargo build --release --target ${{ matrix.target }}
          fi
        shell: bash

      - name: Package (Unix)
        if: matrix.os != 'windows-latest'
        run: |
          cd target/${{ matrix.target }}/release
          tar czf quantum-bridge-mcp-${{ matrix.target }}.tar.gz quantum-bridge-mcp
          mv quantum-bridge-mcp-${{ matrix.target }}.tar.gz ../../../

      - name: Package (Windows)
        if: matrix.os == 'windows-latest'
        run: |
          cd target/${{ matrix.target }}/release
          Compress-Archive quantum-bridge-mcp.exe quantum-bridge-mcp-${{ matrix.target }}.zip
          mv quantum-bridge-mcp-${{ matrix.target }}.zip ../../../
        shell: pwsh

      - name: Upload release artifact
        uses: actions/upload-artifact@v4
        with:
          name: quantum-bridge-mcp-${{ matrix.target }}
          path: quantum-bridge-mcp-${{ matrix.target }}.*

  github-release:
    name: Create GitHub Release
    needs: dist
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          merge-multiple: true

      - name: Create release
        uses: softprops/action-gh-release@v2
        with:
          files: "quantum-bridge-mcp-*"
          generate_release_notes: true
  • Step 3: Verify CI config is valid YAML
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo OK
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" && echo OK

Expected: both print OK.

  • Step 4: Run the full verification suite locally
cargo fmt --check && cargo clippy -- -D warnings && cargo test 2>&1 | tail -20

Expected: all green.

  • Step 5: Final commit
git add .github/
git commit -m "ci: add GitHub Actions for Rust checks, cross-validation, and multi-platform release"

Spec Coverage Checklist

Self-review against docs/superpowers/specs/2026-04-28-quantum-bridge-mcp-design.md:

Spec requirement Covered by task
list_backends tool Task 6
validate_circuit tool (line/col diagnostics) Tasks 4, 7
run_circuit tool (counts + optional statevector) Tasks 5, 8
Gates: H, X, Y, Z, S, T, Sdg, Tdg, RX, RY, RZ, CX, CZ, SWAP, CCX Task 5
MAX_LOCAL_QUBITS = 28 Tasks 2, 4, 5
Unsupported gate → explicit error with gate name + supported list Tasks 4, 7
rmcp stdio server Task 9
Trait Backend (OCP — IBM extension point) Task 3
DIP: tools depend on &dyn Backend Task 3 (traits), Tasks 68
Unit tests for gates Task 5
Statistical count tests (chi-squared / ratio) Tasks 5, 11
Cross-validation with Qiskit Task 13
Property-based tests (proptest) Task 11
Golden files for invalid circuits Task 13
MCP protocol roundtrip tests Task 10
Criterion benchmarks (Bell < 5 ms, 20q < 500 ms) Task 12
CI green (lint + tests + cross-val) Task 14
Multi-platform release binaries Task 14
No unwrap/panic! in production code Enforced throughout
--f32 mode flag Not covered — add as clap CLI arg in a follow-up
Homebrew tap Not covered — post-V1 per spec

One gap identified: the --f32 mode (spec §6, §7) for 30% speedup is not implemented. This is a small addition: add clap as a dependency, parse a --f32 flag in main.rs, and pass it as a use_f32: bool field on LocalSimulator. Add to a follow-up task or V1 polish.