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

2613 lines
76 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```bash
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.
```toml
[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**
```rust
fn main() {
println!("quantum-bridge-mcp");
}
```
- [ ] **Step 4: Verify all dependencies resolve and compile**
```bash
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**
```bash
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**
```rust
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**
```rust
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**
```rust
mod error;
mod types;
fn main() {
println!("quantum-bridge-mcp");
}
```
- [ ] **Step 4: Verify compilation**
```bash
cargo build
```
Expected: compiles without warnings.
- [ ] **Step 5: Commit**
```bash
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):
```rust
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`:
```rust
mod executor;
```
```bash
cargo test
```
Expected: 0 tests run, 0 failures — the compile-time test above just confirms the trait bounds are satisfiable.
- [ ] **Step 3: Commit**
```bash
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:
```rust
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**
```bash
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:
```rust
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!()`**
```bash
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.
```rust
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:
```rust
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**
```bash
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`:
```rust
mod validator;
```
```bash
cargo test
```
Expected: all tests pass.
- [ ] **Step 8: Commit**
```bash
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):
```rust
#[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)**
```bash
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)]`):
```rust
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**
```bash
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**
```bash
cargo test 2>&1
```
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
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`:
```rust
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**
```bash
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!()`:
```rust
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**
```rust
pub mod list_backends;
pub mod run_circuit;
pub mod validate_circuit;
```
- [ ] **Step 5: Declare the tools module in src/main.rs**
```rust
mod tools;
```
- [ ] **Step 6: Run tests**
```bash
cargo test tools::list_backends 2>&1
```
Expected: all 3 tests pass.
- [ ] **Step 7: Commit**
```bash
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`:
```rust
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**
```bash
cargo test tools::validate_circuit 2>&1 | tail -5
```
Expected: panic at `todo!()`.
- [ ] **Step 3: Implement validate_circuit_response**
Replace the `todo!()`:
```rust
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**
```bash
cargo test tools::validate_circuit 2>&1
```
Expected: all 5 tests pass.
- [ ] **Step 5: Commit**
```bash
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`:
```rust
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**
```bash
cargo test tools::run_circuit 2>&1 | tail -5
```
Expected: panic at `todo!()`.
- [ ] **Step 3: Implement run_circuit_response**
Replace the `todo!()`:
```rust
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**
```bash
cargo test tools::run_circuit 2>&1
```
Expected: all 8 tests pass.
- [ ] **Step 5: Commit**
```bash
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:
```rust
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**
```bash
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**
```rust
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**
```bash
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**
```bash
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**
```bash
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**
```bash
mkdir -p tests/integration
```
Create `tests/integration/mod.rs`:
```rust
// Integration test module — empty re-export for cargo test discovery.
```
- [ ] **Step 2: Write the integration tests**
Create `tests/integration/mcp_protocol.rs`:
```rust
//! 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)**
```bash
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**
```bash
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**
```bash
mkdir -p tests/proptest
```
Create `tests/proptest/mod.rs`:
```rust
// Property-based test module.
```
- [ ] **Step 2: Write the property tests**
Create `tests/proptest/invariants.rs`:
```rust
//! 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`:
```rust
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`:
```rust
// 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`:
```toml
[lib]
name = "quantum_bridge_mcp"
path = "src/lib.rs"
```
- [ ] **Step 4: Run proptest**
```bash
cargo test --test proptest 2>&1
```
Expected: all 3 property tests pass (each runs 256 cases by default).
- [ ] **Step 5: Commit**
```bash
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**
```bash
mkdir -p benches
```
Create `benches/simulation.rs`:
```rust
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**
```bash
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**
```bash
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**
```bash
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`:
```python
#!/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`:
```rust
//! 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**
```bash
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):
```bash
python3 scripts/gen_reference.py
cargo test --test cross_validate 2>&1
```
- [ ] **Step 5: Commit**
```bash
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`:
```yaml
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`:
```yaml
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**
```bash
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**
```bash
cargo fmt --check && cargo clippy -- -D warnings && cargo test 2>&1 | tail -20
```
Expected: all green.
- [ ] **Step 5: Final commit**
```bash
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.