2613 lines
76 KiB
Markdown
2613 lines
76 KiB
Markdown
# 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, ¶ms, &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 6–8 |
|
||
| 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.
|