Initial import

This commit is contained in:
Vincent Bourdon
2026-06-09 16:14:55 +02:00
commit 9af114e391
87 changed files with 20848 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
# Quantum Tutor Implementation Plan (rev 2 — split index)
> **For agentic workers:** this plan is split into 18 atomic sub-tasks. Each task has its own file under [`2026-04-29-quantum-tutor/`](2026-04-29-quantum-tutor/). When executing, **load only the matching sub-task file plus the design spec** — never load this index nor sibling tasks. Use `superpowers:executing-plans` or `superpowers:subagent-driven-development`.
**Goal:** Add 4 MCP tools (`get_lesson`, `check_exercise`, `explain_result`, `get_progress`) to quantum-bridge-mcp, backed by a JSON curriculum (7 modules / 18 exercises) and a persistent progress file.
**Spec:** [`docs/superpowers/specs/2026-04-29-quantum-tutor-design.md`](../specs/2026-04-29-quantum-tutor-design.md).
---
## Sub-tasks (in order)
| # | File | Goal | Depends on |
|---|------|------|------------|
| 0 | [`00-prerequisites.md`](2026-04-29-quantum-tutor/00-prerequisites.md) | `QuantumBridgeServer` holds `Arc<dyn Backend>` + `tempfile` dev-dep | — |
| 1 | [`01-curriculum-types.md`](2026-04-29-quantum-tutor/01-curriculum-types.md) | Curriculum types + module 1 JSON | 0 |
| 2 | [`02-curriculum-loader.md`](2026-04-29-quantum-tutor/02-curriculum-loader.md) | `OnceLock`-cached `CurriculumLoader`, no `expect()` | 1 |
| 3 | [`03-progress-store.md`](2026-04-29-quantum-tutor/03-progress-store.md) | Sandboxable `ProgressStore` (`QB_PROGRESS_PATH`) | 0 |
| 4 | [`04-circuit-analyzer.md`](2026-04-29-quantum-tutor/04-circuit-analyzer.md) | AST-based gate listing | 0 |
| 5 | [`05-exercise-checker.md`](2026-04-29-quantum-tutor/05-exercise-checker.md) | `ExerciseChecker` with `&dyn Backend`, 2σ, statevector | 2, 4 |
| 6 | [`06-get-lesson.md`](2026-04-29-quantum-tutor/06-get-lesson.md) | `get_lesson` response (first uncompleted lesson) | 2, 3 |
| 7 | [`07-check-exercise.md`](2026-04-29-quantum-tutor/07-check-exercise.md) | `check_exercise` response (structured diagnostics) | 5, 6 |
| 8 | [`08-explain-result.md`](2026-04-29-quantum-tutor/08-explain-result.md) | `explain_result` response (AST-based) | 4, 7 |
| 9 | [`09-get-progress.md`](2026-04-29-quantum-tutor/09-get-progress.md) | `get_progress` response (current_lesson + unlock) | 8 |
| 10 | [`10-wire-tools.md`](2026-04-29-quantum-tutor/10-wire-tools.md) | Register the 4 MCP tools on the server | 9 |
| 11 | [`11-curriculum-module-2.md`](2026-04-29-quantum-tutor/11-curriculum-module-2.md) | Module 2 — Superposition | 10 |
| 12 | [`12-curriculum-module-3.md`](2026-04-29-quantum-tutor/12-curriculum-module-3.md) | Module 3 — Interférence | 11 |
| 13 | [`13-curriculum-module-4.md`](2026-04-29-quantum-tutor/13-curriculum-module-4.md) | Module 4 — Intrication | 12 |
| 14 | [`14-curriculum-module-5.md`](2026-04-29-quantum-tutor/14-curriculum-module-5.md) | Module 5 — Multi-qubits | 13 |
| 15 | [`15-curriculum-module-6.md`](2026-04-29-quantum-tutor/15-curriculum-module-6.md) | Module 6 — Premiers algorithmes (phase kickback) | 14 |
| 16 | [`16-curriculum-module-7.md`](2026-04-29-quantum-tutor/16-curriculum-module-7.md) | Module 7 — Grover + 18-exercise assertion | 15 |
| 17 | [`17-integration-tests.md`](2026-04-29-quantum-tutor/17-integration-tests.md) | MCP integration tests + golden files | 16 |
---
## Architecture invariants enforced by this plan
(These are the only invariants you need to remember when walking sub-tasks; each task file repeats the relevant ones.)
- `QuantumBridgeServer` owns `Arc<dyn Backend>`; tutor tools route through `self.backend`.
- `ExerciseChecker::check_circuit(backend: &dyn Backend, …)` — never instantiates `LocalSimulator` directly (CLAUDE.md §3 / spec §3).
- `CurriculumLoader` lazily parses an embedded JSON via `OnceLock`; returns `Result`, no `expect()` (CLAUDE.md §10).
- `ProgressStore` is sandboxed via `QB_PROGRESS_PATH`; missing `HOME` returns `Err`, never falls back to `.`.
- `CircuitAnalyzer` exposes AST-based gate listing — `explain_result` does not string-match the QASM source (spec §2.3).
- Statistical tolerance for exercise checks is **2σ** below `min_ratio` (spec §3).
- `check_exercise` distinguishes protocol errors (unknown exercise → `McpError`) from validation errors (circuit invalid → structured `diagnostics` payload, spec §2.2).
- Module 6 uses phase kickback rather than teleportation (the v1 executor walks past `Stmt::If`).
---
## Changelog vs. rev 1 (legacy single-file plan)
The previous monolithic plan was 1767 lines and contained:
1. DIP violations (`LocalSimulator::new()` inside `ExerciseChecker`).
2. String-matching in `explain_result`.
3. `expect()` in production (`CurriculumLoader::load`).
4. `get_lesson` always returning lesson 1.
5. 3σ tolerance instead of spec's 2σ.
6. `statevector_check` defined but unused.
7. Validation errors collapsed to `McpError`.
8. Test bugs (process-id collision in `ProgressStore` tests; integration tests writing to real `~/.config/`).
9. Module 6 teleportation that the v1 executor cannot run.
10. Task 5 (4 tools) and Task 7 (6 modules) bundled into single commits.
Rev 2 fixes all of the above and splits the work into 18 atomic, single-responsibility tasks.
@@ -0,0 +1,73 @@
# Task 0 — Backend injection seam + tempfile dev-dep
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Make `QuantumBridgeServer` hold `Arc<dyn Backend>` so the upcoming tutor tools depend on `&dyn Backend` (spec §3, CLAUDE.md §3). Existing v1 tools (`list_backends`, `validate_circuit`, `run_circuit`) are **not** refactored — they continue to construct their own `LocalSimulator`. Only the new path complies with DIP. Also add `tempfile` for sandboxed tests in later tasks.
## Prerequisites
None. This is the starting point.
## Files
- Modify: `Cargo.toml`
- Modify: `src/tools/mod.rs`
- Modify: `src/main.rs`
## Steps
- [ ] **Step 1: Add `tempfile` to `[dev-dependencies]` in `Cargo.toml`**
```toml
tempfile = "3"
```
- [ ] **Step 2: Replace the unit struct in `src/tools/mod.rs`**
```rust
use std::sync::Arc;
use crate::executor::{Backend, LocalSimulator};
#[derive(Clone)]
pub struct QuantumBridgeServer {
pub backend: Arc<dyn Backend>,
}
impl QuantumBridgeServer {
pub fn new(backend: Arc<dyn Backend>) -> Self {
Self { backend }
}
pub fn with_local_simulator() -> Self {
Self::new(Arc::new(LocalSimulator::new()))
}
}
```
The existing tool methods inside `#[tool_router(server_handler)] impl QuantumBridgeServer { ... }` are not modified.
- [ ] **Step 3: Update the construction in `src/main.rs`**
```rust
let service = QuantumBridgeServer::with_local_simulator()
.serve(stdio())
.await
.inspect_err(|e| tracing::error!("serving error: {:?}", e))?;
```
- [ ] **Step 4: Verify the existing test suite still passes**
```bash
cargo build && cargo test --lib 2>&1 | grep -E "test result|FAILED"
```
Expected: every existing suite green; the change is purely additive.
- [ ] **Step 5: Commit**
```bash
git add Cargo.toml src/tools/mod.rs src/main.rs
git commit -m "refactor: QuantumBridgeServer holds Arc<dyn Backend>; add tempfile dev-dep"
```
@@ -0,0 +1,223 @@
# Task 1 — Curriculum types + module 1 JSON (TDD)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Bootstrap the curriculum: define the Rust types, ship the embedded JSON for module 1 (2 exercises), and assert that every gate listed in `executor::SUPPORTED_GATES` has a description. TDD discipline: the test is written first and fails to compile until the types exist.
## Prerequisites
- Task 0 merged.
## Files
- Create: `src/tutor.rs`
- Create: `curriculum/curriculum.json`
- Modify: `src/lib.rs`
## Steps
- [ ] **Step 1: Create `src/tutor.rs` with the failing test only**
```rust
//! Curriculum types and helpers — see docs/superpowers/specs/2026-04-29-quantum-tutor-design.md.
use std::collections::HashMap;
use serde::Deserialize;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curriculum_json_deserializes_with_module_1() {
let json = include_str!("../curriculum/curriculum.json");
let curriculum: Curriculum = serde_json::from_str(json).expect("curriculum.json must parse");
assert_eq!(curriculum.version, "1.0");
let module_1 = curriculum.modules.iter().find(|m| m.id == 1).expect("module 1");
let exercise_count: usize = module_1.lessons.iter().map(|l| l.exercises.len()).sum();
assert_eq!(exercise_count, 2);
for gate in crate::executor::SUPPORTED_GATES {
if *gate == "measure" { continue; }
assert!(
curriculum.gate_descriptions.contains_key(*gate),
"missing gate_description for '{gate}'"
);
}
}
}
```
- [ ] **Step 2: Confirm the test fails (Red)**
```bash
cargo test tutor::tests 2>&1 | grep -E "cannot find|error\["
```
Expected: a compile error on `Curriculum`.
- [ ] **Step 3: Add the type definitions above the `#[cfg(test)]` block**
```rust
#[derive(Debug, Deserialize, Clone)]
pub struct Curriculum {
pub version: String,
pub gate_descriptions: HashMap<String, GateDescription>,
pub modules: Vec<Module>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct GateDescription {
pub short: String,
#[serde(default)]
pub effect_on_zero: Option<String>,
#[serde(default)]
pub effect_on_one: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Module {
pub id: u32,
pub title: String,
pub lessons: Vec<Lesson>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Lesson {
pub id: u32,
pub title: String,
pub concept: String,
pub example_circuit: String,
pub what_to_observe: String,
pub exercises: Vec<Exercise>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Exercise {
pub id: String,
pub prompt: String,
pub hint: String,
pub criteria: ExerciseCriteria,
pub feedback_pass: String,
pub feedback_fail: String,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct ExerciseCriteria {
#[serde(default)]
pub required_outcomes: Vec<RequiredOutcome>,
#[serde(default)]
pub forbidden_outcomes: Vec<String>,
#[serde(default)]
pub statevector_check: Option<StatevectorCheck>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RequiredOutcome {
pub bitstring: String,
#[serde(default)]
pub min_ratio: Option<f64>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct StatevectorCheck {
pub non_zero_amplitude_indices: Vec<usize>,
pub zero_amplitude_indices: Vec<usize>,
#[serde(default = "default_tolerance")]
pub tolerance: f64,
}
fn default_tolerance() -> f64 { 1e-6 }
```
- [ ] **Step 4: Register the module in `src/lib.rs`**
```rust
pub mod tutor;
```
- [ ] **Step 5: Create `curriculum/curriculum.json` with module 1 and ALL gate descriptions**
```bash
mkdir -p curriculum
```
`curriculum/curriculum.json`:
```json
{
"version": "1.0",
"gate_descriptions": {
"h": {"short": "Hadamard — crée une superposition égale", "effect_on_zero": "Transforme |0⟩ en (|0⟩+|1⟩)/√2", "effect_on_one": "Transforme |1⟩ en (|0⟩-|1⟩)/√2"},
"x": {"short": "Pauli-X — NOT quantique", "effect_on_zero": "Transforme |0⟩ en |1⟩", "effect_on_one": "Transforme |1⟩ en |0⟩"},
"y": {"short": "Pauli-Y — rotation π autour de Y", "effect_on_zero": "Transforme |0⟩ en i|1⟩", "effect_on_one": "Transforme |1⟩ en -i|0⟩"},
"z": {"short": "Pauli-Z — flip de phase", "effect_on_zero": "Laisse |0⟩ inchangé", "effect_on_one": "Transforme |1⟩ en -|1⟩"},
"s": {"short": "Phase S — rotation π/2 autour de Z", "effect_on_zero": "Laisse |0⟩ inchangé", "effect_on_one": "Transforme |1⟩ en i|1⟩"},
"sdg": {"short": "S† — rotation -π/2 autour de Z (inverse de S)", "effect_on_zero": "Laisse |0⟩ inchangé", "effect_on_one": "Transforme |1⟩ en -i|1⟩"},
"t": {"short": "Phase T — rotation π/4 autour de Z", "effect_on_zero": "Laisse |0⟩ inchangé", "effect_on_one": "Transforme |1⟩ en e^(iπ/4)|1⟩"},
"tdg": {"short": "T† — rotation -π/4 autour de Z (inverse de T)", "effect_on_zero": "Laisse |0⟩ inchangé", "effect_on_one": "Transforme |1⟩ en e^(-iπ/4)|1⟩"},
"cx": {"short": "CNOT — flip la cible si le contrôle est |1⟩", "effect_on_zero": "Si contrôle=|0⟩, ne fait rien à la cible"},
"cz": {"short": "CZ — flip de phase sur la cible si contrôle=|1⟩"},
"swap":{"short": "SWAP — échange l'état de deux qubits"},
"ccx": {"short": "Toffoli — flip la cible si les deux contrôles sont |1⟩"},
"rx": {"short": "Rotation autour de l'axe X d'un angle θ"},
"ry": {"short": "Rotation autour de l'axe Y d'un angle θ"},
"rz": {"short": "Rotation autour de l'axe Z d'un angle θ"}
},
"modules": [
{
"id": 1,
"title": "Le qubit",
"lessons": [
{
"id": 1,
"title": "États |0⟩ et |1⟩",
"concept": "Un qubit peut être dans l'état |0⟩ ou |1⟩, exactement comme un bit classique — mais aussi dans une superposition des deux. Sans rien faire, il part de |0⟩. La porte X (NOT quantique) bascule |0⟩ en |1⟩.",
"example_circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;",
"what_to_observe": "Lance run_circuit avec 100 shots. Tu devrais voir uniquement '1' dans les counts.",
"exercises": [
{
"id": "1-1-a",
"prompt": "Écris un circuit OpenQASM 3.0 avec 1 qubit qui produit toujours '1' quand on le mesure. Utilise la porte X.",
"hint": "La porte X s'applique avec 'x q[0];' avant la mesure.",
"criteria": {
"required_outcomes": [{"bitstring": "1", "min_ratio": 0.99}],
"forbidden_outcomes": ["0"]
},
"feedback_pass": "Parfait ! La porte X bascule |0⟩ en |1⟩.",
"feedback_fail": "Ton circuit ne produit pas toujours '1'."
},
{
"id": "1-1-b",
"prompt": "Écris un circuit avec 1 qubit sans aucune porte, juste la mesure.",
"hint": "Un qubit non touché part de |0⟩.",
"criteria": {
"required_outcomes": [{"bitstring": "0", "min_ratio": 0.99}],
"forbidden_outcomes": ["1"]
},
"feedback_pass": "Sans aucune porte, le qubit reste dans |0⟩.",
"feedback_fail": "Ton circuit produit des '1' alors qu'il ne devrait pas."
}
]
}
]
}
]
}
```
- [ ] **Step 6: Run the test (Green)**
```bash
cargo test tutor::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 1 passed`.
- [ ] **Step 7: Commit**
```bash
git add curriculum/curriculum.json src/tutor.rs src/lib.rs
git commit -m "feat: add curriculum types and module 1 content (tutor v1)"
```
@@ -0,0 +1,144 @@
# Task 2 — `CurriculumLoader` (`OnceLock`-cached, no `expect()`)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Provide a lazy, cached accessor over the embedded curriculum that returns `Result<&Curriculum, BridgeError>` instead of panicking. Required by every tutor tool. CLAUDE.md §10 forbids `expect()` in production.
## Prerequisites
- Task 1 merged.
## Files
- Modify: `src/tutor.rs`
- Modify: `src/error.rs`
## Steps
- [ ] **Step 1: Add a `Curriculum` variant to `BridgeError` in `src/error.rs`**
```rust
#[error("curriculum data is malformed: {0}")]
Curriculum(String),
```
- [ ] **Step 2: Append failing tests to `src/tutor.rs` (inside the existing `#[cfg(test)]` block)**
```rust
#[test]
fn loader_returns_curriculum_on_first_call() {
let loader = CurriculumLoader::default();
let c = loader.curriculum().expect("embedded curriculum must parse");
assert_eq!(c.version, "1.0");
}
#[test]
fn find_exercise_by_id_returns_correct_exercise() {
let loader = CurriculumLoader::default();
let exercise = loader.find_exercise("1-1-a").unwrap();
assert_eq!(exercise.id, "1-1-a");
}
#[test]
fn find_exercise_with_unknown_id_returns_none() {
let loader = CurriculumLoader::default();
assert!(loader.find_exercise("99-99-z").is_none());
}
#[test]
fn get_lesson_unknown_module_returns_none() {
let loader = CurriculumLoader::default();
assert!(loader.get_lesson(99, 1).is_none());
}
#[test]
fn loader_reports_curriculum_error_for_malformed_json() {
let result = CurriculumLoader::from_str("{ not valid json");
assert!(result.is_err());
}
```
- [ ] **Step 3: Implement `CurriculumLoader` (above the `#[cfg(test)]` block)**
```rust
use std::sync::OnceLock;
use crate::error::BridgeError;
const CURRICULUM_JSON: &str = include_str!("../curriculum/curriculum.json");
#[derive(Default)]
pub struct CurriculumLoader {
cell: OnceLock<Result<Curriculum, String>>,
}
impl CurriculumLoader {
pub fn curriculum(&self) -> Result<&Curriculum, BridgeError> {
let result = self.cell.get_or_init(|| {
serde_json::from_str::<Curriculum>(CURRICULUM_JSON).map_err(|e| e.to_string())
});
result
.as_ref()
.map_err(|msg| BridgeError::Curriculum(msg.clone()))
}
pub fn from_str(json: &str) -> Result<Self, BridgeError> {
let curriculum: Curriculum =
serde_json::from_str(json).map_err(|e| BridgeError::Curriculum(e.to_string()))?;
let loader = Self::default();
let _ = loader.cell.set(Ok(curriculum));
Ok(loader)
}
pub fn get_module(&self, module_id: u32) -> Option<&Module> {
self.curriculum().ok()?.modules.iter().find(|m| m.id == module_id)
}
pub fn get_lesson(&self, module_id: u32, lesson_id: u32) -> Option<&Lesson> {
self.get_module(module_id)?
.lessons
.iter()
.find(|l| l.id == lesson_id)
}
pub fn find_exercise(&self, exercise_id: &str) -> Option<&Exercise> {
for module in &self.curriculum().ok()?.modules {
for lesson in &module.lessons {
if let Some(ex) = lesson.exercises.iter().find(|e| e.id == exercise_id) {
return Some(ex);
}
}
}
None
}
pub fn all_exercises(&self) -> Vec<&Exercise> {
self.curriculum()
.ok()
.map(|c| {
c.modules
.iter()
.flat_map(|m| m.lessons.iter())
.flat_map(|l| l.exercises.iter())
.collect()
})
.unwrap_or_default()
}
}
```
- [ ] **Step 4: Run tests (Green)**
```bash
cargo test tutor::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 6 passed`.
- [ ] **Step 5: Commit**
```bash
git add src/tutor.rs src/error.rs
git commit -m "feat: implement CurriculumLoader with OnceLock cache and structured error"
```
@@ -0,0 +1,174 @@
# Task 3 — `ProgressStore` (sandboxable, deterministic tests)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Persist solved exercises to disk. The path resolves via `QB_PROGRESS_PATH` first, then `$HOME/.config/quantum-bridge-mcp/progress.json`. If neither is available, return an explicit error (never silently fall back to `.`). Tests use `tempfile::TempDir` so they are deterministic under cargo's parallel test runner.
## Prerequisites
- Task 0 merged (for `tempfile` dev-dep).
## Files
- Create: `src/progress.rs`
- Modify: `src/error.rs`
- Modify: `src/lib.rs`
## Steps
- [ ] **Step 1: Add a `Configuration` variant to `BridgeError` in `src/error.rs`**
```rust
#[error("configuration error: {0}")]
Configuration(String),
```
- [ ] **Step 2: Create `src/progress.rs`**
```rust
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::BridgeError;
pub const PROGRESS_PATH_ENV: &str = "QB_PROGRESS_PATH";
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct UserProgress {
pub solved_exercises: HashSet<String>,
}
impl UserProgress {
pub fn has_solved(&self, exercise_id: &str) -> bool {
self.solved_exercises.contains(exercise_id)
}
pub fn mark_solved(&mut self, exercise_id: &str) {
self.solved_exercises.insert(exercise_id.to_string());
}
}
pub struct ProgressStore {
path: PathBuf,
}
impl ProgressStore {
/// Env var override first, then `$HOME/.config/quantum-bridge-mcp/progress.json`.
/// Errors out if neither is available — never falls back to `.`.
pub fn default_path() -> Result<PathBuf, BridgeError> {
if let Ok(override_path) = std::env::var(PROGRESS_PATH_ENV) {
return Ok(PathBuf::from(override_path));
}
let home = std::env::var("HOME").map_err(|_| {
BridgeError::Configuration(
"neither QB_PROGRESS_PATH nor HOME is set; cannot resolve progress.json location"
.into(),
)
})?;
Ok(PathBuf::from(home)
.join(".config")
.join("quantum-bridge-mcp")
.join("progress.json"))
}
pub fn new(path: PathBuf) -> Self { Self { path } }
pub fn load(&self) -> UserProgress {
match fs::read_to_string(&self.path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => UserProgress::default(),
}
}
pub fn save(&self, progress: &UserProgress) -> Result<(), BridgeError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)
.map_err(|e| BridgeError::Configuration(format!("create_dir_all failed: {e}")))?;
}
let json = serde_json::to_string_pretty(progress)
.map_err(|e| BridgeError::Configuration(format!("serialize failed: {e}")))?;
fs::write(&self.path, json)
.map_err(|e| BridgeError::Configuration(format!("write failed: {e}")))
}
pub fn mark_solved(&self, exercise_id: &str) -> Result<(), BridgeError> {
let mut progress = self.load();
progress.mark_solved(exercise_id);
self.save(&progress)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn isolated_store() -> (ProgressStore, TempDir) {
let dir = TempDir::new().expect("create tempdir");
let path = dir.path().join("progress.json");
(ProgressStore::new(path), dir)
}
#[test]
fn load_returns_empty_progress_when_no_file_exists() {
let (store, _dir) = isolated_store();
assert!(store.load().solved_exercises.is_empty());
}
#[test]
fn save_and_reload_preserves_solved_exercises() {
let (store, _dir) = isolated_store();
store.mark_solved("1-1-a").unwrap();
assert!(store.load().has_solved("1-1-a"));
}
#[test]
fn mark_solved_twice_does_not_duplicate() {
let (store, _dir) = isolated_store();
store.mark_solved("1-1-a").unwrap();
store.mark_solved("1-1-a").unwrap();
assert_eq!(store.load().solved_exercises.len(), 1);
}
#[test]
fn has_solved_returns_false_for_unknown_exercise() {
assert!(!UserProgress::default().has_solved("99-99-z"));
}
#[test]
fn default_path_uses_env_override_when_set() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("override.json");
std::env::set_var(PROGRESS_PATH_ENV, &target);
let resolved = ProgressStore::default_path().unwrap();
std::env::remove_var(PROGRESS_PATH_ENV);
assert_eq!(resolved, target);
}
}
```
- [ ] **Step 3: Register `progress` in `src/lib.rs`**
```rust
pub mod progress;
```
- [ ] **Step 4: Run tests**
```bash
cargo test progress::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 5 passed`.
- [ ] **Step 5: Commit**
```bash
git add src/progress.rs src/error.rs src/lib.rs
git commit -m "feat: implement ProgressStore with QB_PROGRESS_PATH sandbox and tempfile tests"
```
@@ -0,0 +1,119 @@
# Task 4 — `CircuitAnalyzer` (AST-based gate listing)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Expose AST-based gate enumeration as a public utility so `explain_result` (Task 8) and any other consumer can iterate gates without re-parsing or string-matching the QASM source. Wraps the AST traversal already present in `executor::extract_gate_ops`.
## Prerequisites
- Task 0 merged.
## Files
- Create: `src/circuit_analyzer.rs`
- Modify: `src/executor.rs` (add public `list_gate_calls`)
- Modify: `src/lib.rs`
## Steps
- [ ] **Step 1: Create `src/circuit_analyzer.rs` with failing tests**
```rust
//! Pure AST-based gate listing for OpenQASM 3.0 circuits.
use crate::error::BridgeError;
use crate::types::CircuitSource;
#[derive(Debug, Clone, PartialEq)]
pub struct GateCallInfo {
pub name: String,
pub params: Vec<f64>,
pub qubits: Vec<usize>,
}
pub struct CircuitAnalyzer;
impl CircuitAnalyzer {
pub fn list_gates(source: &CircuitSource) -> Result<Vec<GateCallInfo>, BridgeError> {
crate::executor::list_gate_calls(source)
}
}
#[cfg(test)]
mod tests {
use super::*;
const BELL: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;";
#[test]
fn list_gates_for_bell_returns_h_then_cx() {
let gates = CircuitAnalyzer::list_gates(&CircuitSource(BELL.into())).unwrap();
assert_eq!(gates.len(), 2);
assert_eq!(gates[0].name, "h");
assert_eq!(gates[0].qubits, vec![0]);
assert_eq!(gates[1].name, "cx");
assert_eq!(gates[1].qubits, vec![0, 1]);
}
#[test]
fn list_gates_ignores_measure() {
let gates = CircuitAnalyzer::list_gates(&CircuitSource(BELL.into())).unwrap();
assert!(gates.iter().all(|g| g.name != "measure"));
}
#[test]
fn list_gates_returns_empty_for_circuit_with_no_gates() {
let identity = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nc = measure q;";
let gates = CircuitAnalyzer::list_gates(&CircuitSource(identity.into())).unwrap();
assert!(gates.is_empty());
}
}
```
- [ ] **Step 2: Expose `list_gate_calls` in `src/executor.rs`**
Add the public function near the existing `extract_gate_ops` helper (or below it). Reuses the same parser path as `LocalSimulator::run`.
```rust
use crate::circuit_analyzer::GateCallInfo;
/// Public AST-based gate enumeration. Reuses the parser path of `run`.
pub fn list_gate_calls(source: &CircuitSource) -> Result<Vec<GateCallInfo>, BridgeError> {
let parse_result = parse_source_string(&source.0, Some("circuit.qasm"), None::<&[&str]>);
if parse_result.any_syntax_errors() {
return Err(BridgeError::Simulation("circuit contains syntax errors".into()));
}
let context = parse_result.take_context();
let symbol_table = context.symbol_table();
let program = context.program();
let (_, register_offsets) = build_register_map(program, symbol_table);
let ops = extract_gate_ops(program, symbol_table, &register_offsets)?;
Ok(ops
.into_iter()
.map(|(name, params, qubits)| GateCallInfo { name, params, qubits })
.collect())
}
```
- [ ] **Step 3: Register `circuit_analyzer` in `src/lib.rs`**
```rust
pub mod circuit_analyzer;
```
- [ ] **Step 4: Run tests**
```bash
cargo test circuit_analyzer::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 3 passed`.
- [ ] **Step 5: Commit**
```bash
git add src/circuit_analyzer.rs src/executor.rs src/lib.rs
git commit -m "feat: add CircuitAnalyzer for AST-based gate enumeration"
```
@@ -0,0 +1,222 @@
# Task 5 — `ExerciseChecker` (`&dyn Backend`, 2σ tolerance, statevector check)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Verify whether a submitted circuit satisfies an `ExerciseCriteria`. Takes `&dyn Backend` so the checker is testable with mocks and remains compatible with V1.5 IBM (CLAUDE.md §3, spec §3). Runs 1024 fixed shots, applies 2σ tolerance, and validates `statevector_check` when present.
## Prerequisites
- Task 2 merged (CurriculumLoader available).
- Task 4 merged (CircuitAnalyzer not strictly required here, but typically present at this point).
## Files
- Modify: `src/tutor.rs`
## Steps
- [ ] **Step 1: Append failing tests to the existing `#[cfg(test)]` block in `src/tutor.rs`**
```rust
use crate::executor::{Backend, LocalSimulator};
const X_CIRCUIT: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;";
const IDENTITY_CIRCUIT: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nc = measure q;";
const H_CIRCUIT: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;";
fn backend() -> LocalSimulator { LocalSimulator::new() }
#[test]
fn x_circuit_passes_exercise_requiring_bitstring_1() {
let criteria = ExerciseCriteria {
required_outcomes: vec![RequiredOutcome { bitstring: "1".into(), min_ratio: Some(0.99) }],
forbidden_outcomes: vec!["0".into()],
statevector_check: None,
};
let result = ExerciseChecker::check_circuit(&backend() as &dyn Backend, X_CIRCUIT, &criteria);
assert!(result.passed, "counts: {:?}", result.counts);
}
#[test]
fn identity_circuit_fails_exercise_requiring_bitstring_1() {
let criteria = ExerciseCriteria {
required_outcomes: vec![RequiredOutcome { bitstring: "1".into(), min_ratio: Some(0.99) }],
forbidden_outcomes: vec!["0".into()],
statevector_check: None,
};
let result = ExerciseChecker::check_circuit(&backend() as &dyn Backend, IDENTITY_CIRCUIT, &criteria);
assert!(!result.passed);
}
#[test]
fn h_circuit_passes_balanced_outcomes_with_2sigma_tolerance() {
let criteria = ExerciseCriteria {
required_outcomes: vec![
RequiredOutcome { bitstring: "0".into(), min_ratio: Some(0.4) },
RequiredOutcome { bitstring: "1".into(), min_ratio: Some(0.4) },
],
forbidden_outcomes: vec![],
statevector_check: None,
};
let result = ExerciseChecker::check_circuit(&backend() as &dyn Backend, H_CIRCUIT, &criteria);
assert!(result.passed, "counts: {:?}", result.counts);
}
#[test]
fn invalid_circuit_returns_diagnostics_not_panic() {
let criteria = ExerciseCriteria::default();
let result = ExerciseChecker::check_circuit(&backend() as &dyn Backend, "not valid qasm", &criteria);
assert!(!result.passed);
assert!(result.error.is_some());
}
#[test]
fn statevector_check_validates_bell_state_amplitudes() {
const BELL: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;";
let criteria = ExerciseCriteria {
required_outcomes: vec![],
forbidden_outcomes: vec![],
statevector_check: Some(StatevectorCheck {
non_zero_amplitude_indices: vec![0, 3],
zero_amplitude_indices: vec![1, 2],
tolerance: 1e-6,
}),
};
let result = ExerciseChecker::check_circuit(&backend() as &dyn Backend, BELL, &criteria);
assert!(result.passed, "counts: {:?}", result.counts);
}
```
- [ ] **Step 2: Implement `ExerciseChecker` (above the `#[cfg(test)]` block)**
```rust
use crate::executor::{Backend, MAX_LOCAL_QUBITS};
use crate::types::{CircuitSource, ShotCount, ValidationDiagnostic};
use crate::validator::CircuitValidator;
pub struct CheckResult {
pub passed: bool,
pub counts: HashMap<String, u64>,
pub diagnostics: Vec<ValidationDiagnostic>,
pub error: Option<String>,
}
pub struct ExerciseChecker;
impl ExerciseChecker {
const CHECK_SHOTS: u32 = 1024;
/// Spec §3 — pass if `count ≥ (min_ratio - 2σ) × N`.
const SIGMA_MULTIPLIER: f64 = 2.0;
pub fn check_circuit(
backend: &dyn Backend,
circuit_source: &str,
criteria: &ExerciseCriteria,
) -> CheckResult {
let source = CircuitSource(circuit_source.to_string());
let validator = CircuitValidator::new(MAX_LOCAL_QUBITS);
let validation = match validator.validate(&source) {
Err(e) => return CheckResult {
passed: false, counts: HashMap::new(), diagnostics: vec![],
error: Some(e.to_string()),
},
Ok(v) => v,
};
if !validation.is_valid {
let summary = validation
.diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return CheckResult {
passed: false, counts: HashMap::new(),
diagnostics: validation.diagnostics, error: Some(summary),
};
}
let need_sv = criteria.statevector_check.is_some();
let result = match backend.run(&source, ShotCount(Self::CHECK_SHOTS), need_sv) {
Err(e) => return CheckResult {
passed: false, counts: HashMap::new(), diagnostics: vec![],
error: Some(e.to_string()),
},
Ok(r) => r,
};
let total = result.shots as f64;
let counts_pass = Self::counts_pass(&result.counts, criteria, total);
let sv_pass = match (&criteria.statevector_check, &result.statevector) {
(Some(check), Some(sv)) => Self::statevector_pass(sv, check),
(Some(_), None) => false,
(None, _) => true,
};
CheckResult {
passed: counts_pass && sv_pass,
counts: result.counts,
diagnostics: vec![],
error: None,
}
}
fn counts_pass(
counts: &HashMap<String, u64>,
criteria: &ExerciseCriteria,
total: f64,
) -> bool {
for req in &criteria.required_outcomes {
let count = counts.get(&req.bitstring).copied().unwrap_or(0) as f64;
match req.min_ratio {
Some(min_ratio) => {
let sigma = (min_ratio * (1.0 - min_ratio) / total).sqrt();
let threshold = (min_ratio - Self::SIGMA_MULTIPLIER * sigma) * total;
if count < threshold { return false; }
}
None => if count == 0.0 { return false; },
}
}
for forbidden in &criteria.forbidden_outcomes {
if counts.get(forbidden).copied().unwrap_or(0) > 0 { return false; }
}
true
}
fn statevector_pass(sv: &[(f64, f64)], check: &StatevectorCheck) -> bool {
let magnitude = |idx: usize| -> Option<f64> {
sv.get(idx).map(|(r, i)| (r * r + i * i).sqrt())
};
for &idx in &check.non_zero_amplitude_indices {
match magnitude(idx) {
Some(m) if m > check.tolerance => {}
_ => return false,
}
}
for &idx in &check.zero_amplitude_indices {
match magnitude(idx) {
Some(m) if m <= check.tolerance => {}
_ => return false,
}
}
true
}
}
```
- [ ] **Step 3: Run tests (Green)**
```bash
cargo test tutor::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 11 passed`.
- [ ] **Step 4: Commit**
```bash
git add src/tutor.rs
git commit -m "feat: ExerciseChecker with Backend injection, 2σ tolerance, statevector check"
```
@@ -0,0 +1,157 @@
# Task 6 — `get_lesson` response (first uncompleted lesson default)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Pure response function for `get_lesson`. When `lesson_id` is `None`, returns the first lesson in the module that has at least one unsolved exercise (spec §2.1). Each call surfaces the next pending exercise rather than always the first one. The function is pure (no I/O) and parameterized by the caller's `UserProgress`.
## Prerequisites
- Task 2 merged (CurriculumLoader).
- Task 3 merged (UserProgress type).
## Files
- Create: `src/tools/tutor_tools.rs`
- Modify: `src/tools/mod.rs` (declare module)
## Steps
- [ ] **Step 1: Create `src/tools/tutor_tools.rs` with implementation + tests**
```rust
use serde_json::{json, Value};
use crate::progress::UserProgress;
use crate::tutor::CurriculumLoader;
pub fn get_lesson_response(
loader: &CurriculumLoader,
progress: &UserProgress,
module_id: u32,
lesson_id: Option<u32>,
) -> Value {
let module = match loader.get_module(module_id) {
None => return json!({ "error": format!("module {} not found", module_id) }),
Some(m) => m,
};
let resolved = lesson_id
.or_else(|| first_unsolved_lesson(module, progress))
.or_else(|| module.lessons.first().map(|l| l.id));
let resolved = match resolved {
Some(id) => id,
None => return json!({ "error": format!("module {} has no lessons", module_id) }),
};
let lesson = match loader.get_lesson(module_id, resolved) {
None => return json!({ "error": format!("module {} lesson {} not found", module_id, resolved) }),
Some(l) => l,
};
let pending_exercise = lesson
.exercises
.iter()
.find(|e| !progress.has_solved(&e.id));
if pending_exercise.is_none() && lesson.exercises.iter().all(|e| progress.has_solved(&e.id)) {
return json!({
"module_id": module_id,
"lesson_id": resolved,
"title": lesson.title,
"concept": lesson.concept,
"module_completed": all_lessons_completed(module, progress),
"message": "Toutes les exercices de cette leçon sont résolus.",
});
}
json!({
"module_id": module_id,
"lesson_id": resolved,
"title": lesson.title,
"concept": lesson.concept,
"example_circuit": lesson.example_circuit,
"what_to_observe": lesson.what_to_observe,
"exercise": pending_exercise.map(|e| json!({
"id": e.id,
"prompt": e.prompt,
"hint": e.hint,
})),
})
}
fn first_unsolved_lesson(module: &crate::tutor::Module, progress: &UserProgress) -> Option<u32> {
module
.lessons
.iter()
.find(|l| l.exercises.iter().any(|e| !progress.has_solved(&e.id)))
.map(|l| l.id)
}
fn all_lessons_completed(module: &crate::tutor::Module, progress: &UserProgress) -> bool {
module
.lessons
.iter()
.flat_map(|l| l.exercises.iter())
.all(|e| progress.has_solved(&e.id))
}
#[cfg(test)]
mod tests {
use super::*;
fn loader() -> CurriculumLoader { CurriculumLoader::default() }
#[test]
fn get_lesson_returns_first_pending_exercise_for_known_module() {
let resp = get_lesson_response(&loader(), &UserProgress::default(), 1, Some(1));
assert_eq!(resp["module_id"].as_u64().unwrap(), 1);
assert_eq!(resp["lesson_id"].as_u64().unwrap(), 1);
assert_eq!(resp["exercise"]["id"].as_str().unwrap(), "1-1-a");
}
#[test]
fn get_lesson_skips_solved_exercises_within_a_lesson() {
let mut progress = UserProgress::default();
progress.mark_solved("1-1-a");
let resp = get_lesson_response(&loader(), &progress, 1, Some(1));
assert_eq!(resp["exercise"]["id"].as_str().unwrap(), "1-1-b");
}
#[test]
fn get_lesson_default_resolves_to_first_lesson_with_pending_exercises() {
let resp = get_lesson_response(&loader(), &UserProgress::default(), 1, None);
assert_eq!(resp["lesson_id"].as_u64().unwrap(), 1);
}
#[test]
fn get_lesson_unknown_module_returns_error_payload() {
let resp = get_lesson_response(&loader(), &UserProgress::default(), 99, None);
assert!(resp.get("error").is_some());
}
}
```
- [ ] **Step 2: Declare `tutor_tools` in `src/tools/mod.rs`**
Add at the top:
```rust
pub mod tutor_tools;
```
- [ ] **Step 3: Run tests**
```bash
cargo test tools::tutor_tools::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 4 passed`.
- [ ] **Step 4: Commit**
```bash
git add src/tools/tutor_tools.rs src/tools/mod.rs
git commit -m "feat: get_lesson response (first uncompleted lesson default)"
```
@@ -0,0 +1,152 @@
# Task 7 — `check_exercise` response (Backend injection, structured validation errors)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Pure response function for `check_exercise`. Distinguishes:
- **Protocol error** (unknown exercise id) → `{ "protocol_error": "..." }` so the wrapper can map it to `McpError`.
- **Validation error** (circuit invalid) → `{ "passed": false, "diagnostics": [...] }` per spec §2.2 — never escalates to JSON-RPC error.
- **Normal pass/fail** → `{ "passed": ..., "exercise_id", "feedback", "counts", ... }`.
The caller (Task 10) is responsible for persisting `mark_solved` on `passed: true`.
## Prerequisites
- Task 5 merged (`ExerciseChecker`).
- Task 6 merged (`tutor_tools.rs` exists).
## Files
- Modify: `src/tools/tutor_tools.rs`
## Steps
- [ ] **Step 1: Append failing tests to the existing `#[cfg(test)]` block**
```rust
use crate::executor::{Backend, LocalSimulator};
fn backend() -> LocalSimulator { LocalSimulator::new() }
#[test]
fn check_exercise_x_gate_passes_1_1_a() {
let circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;";
let resp = check_exercise_response(
&loader(), &backend() as &dyn Backend, &UserProgress::default(),
"1-1-a", circuit,
);
assert_eq!(resp["passed"], true);
assert_eq!(resp["exercise_id"], "1-1-a");
}
#[test]
fn check_exercise_identity_fails_1_1_a() {
let circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nc = measure q;";
let resp = check_exercise_response(
&loader(), &backend() as &dyn Backend, &UserProgress::default(),
"1-1-a", circuit,
);
assert_eq!(resp["passed"], false);
assert!(resp["hint"].as_str().is_some());
}
#[test]
fn check_exercise_invalid_circuit_returns_diagnostics_not_protocol_error() {
let resp = check_exercise_response(
&loader(), &backend() as &dyn Backend, &UserProgress::default(),
"1-1-a", "not valid qasm",
);
assert_eq!(resp["passed"], false);
assert!(resp["diagnostics"].as_array().is_some());
assert!(resp.get("protocol_error").is_none());
}
#[test]
fn check_exercise_unknown_id_marks_protocol_error() {
let resp = check_exercise_response(
&loader(), &backend() as &dyn Backend, &UserProgress::default(),
"99-99-z", "x q[0];",
);
assert!(resp["protocol_error"].as_str().is_some());
}
```
- [ ] **Step 2: Implementation (add at the top-level of `tutor_tools.rs`)**
```rust
use crate::executor::Backend;
use crate::tutor::ExerciseChecker;
pub fn check_exercise_response(
loader: &CurriculumLoader,
backend: &dyn Backend,
progress: &UserProgress,
exercise_id: &str,
circuit: &str,
) -> Value {
let exercise = match loader.find_exercise(exercise_id) {
None => return json!({
"protocol_error": format!("exercise '{}' not found in curriculum", exercise_id),
}),
Some(e) => e,
};
let check = ExerciseChecker::check_circuit(backend, circuit, &exercise.criteria);
if let Some(err) = &check.error {
let diagnostics: Vec<Value> = check
.diagnostics
.iter()
.map(|d| json!({
"line": d.line,
"column": d.column,
"message": d.message,
}))
.collect();
return json!({
"passed": false,
"exercise_id": exercise_id,
"feedback": exercise.feedback_fail,
"hint": exercise.hint,
"validation_error": err,
"diagnostics": diagnostics,
"counts": check.counts,
});
}
let already_solved = progress.has_solved(exercise_id);
if check.passed {
json!({
"passed": true,
"exercise_id": exercise_id,
"feedback": exercise.feedback_pass,
"counts": check.counts,
"newly_solved": !already_solved,
})
} else {
json!({
"passed": false,
"exercise_id": exercise_id,
"feedback": exercise.feedback_fail,
"hint": exercise.hint,
"counts": check.counts,
})
}
}
```
- [ ] **Step 3: Run tests**
```bash
cargo test tools::tutor_tools::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 8 passed`.
- [ ] **Step 4: Commit**
```bash
git add src/tools/tutor_tools.rs
git commit -m "feat: check_exercise response with backend injection and structured validation errors"
```
@@ -0,0 +1,194 @@
# Task 8 — `explain_result` response (AST-based gate breakdown)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Build a structured pedagogical breakdown of an executed circuit: gate-by-gate description, key concept (entanglement / superposition / interference / rotation / measurement), dominant and missing outcomes, optional statevector summary. Spec §2.3 mandates AST-based analysis — we use `CircuitAnalyzer::list_gates`, no string-matching.
## Prerequisites
- Task 4 merged (`CircuitAnalyzer`).
- Task 7 merged (`tutor_tools.rs` populated).
## Files
- Modify: `src/tools/tutor_tools.rs`
## Steps
- [ ] **Step 1: Append failing tests**
```rust
#[test]
fn explain_result_bell_circuit_lists_h_then_cx_with_descriptions() {
let bell = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;";
let counts = json!({"00": 512, "11": 512});
let resp = explain_result_response(&loader(), bell, &counts, None);
let breakdown = resp["gate_breakdown"].as_array().unwrap();
assert_eq!(breakdown.len(), 2);
assert_eq!(breakdown[0]["name"].as_str().unwrap(), "h");
assert_eq!(breakdown[1]["name"].as_str().unwrap(), "cx");
assert!(breakdown[0]["description"].as_str().unwrap().contains("Hadamard"));
assert_eq!(resp["key_concept"].as_str().unwrap(), "entanglement");
assert_eq!(resp["num_qubits"].as_u64().unwrap(), 2);
}
#[test]
fn explain_result_dominant_outcomes_match_counts() {
let bell = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;";
let counts = json!({"00": 480, "11": 544});
let resp = explain_result_response(&loader(), bell, &counts, None);
let dominant: Vec<&str> = resp["dominant_outcomes"]
.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert!(dominant.contains(&"00"));
assert!(dominant.contains(&"11"));
}
#[test]
fn explain_result_for_invalid_circuit_returns_error_payload() {
let resp = explain_result_response(&loader(), "not valid qasm", &json!({}), None);
assert!(resp.get("error").is_some());
}
```
- [ ] **Step 2: Implementation (top of `tutor_tools.rs`)**
```rust
use crate::circuit_analyzer::{CircuitAnalyzer, GateCallInfo};
use crate::types::CircuitSource;
pub fn explain_result_response(
loader: &CurriculumLoader,
circuit: &str,
counts: &Value,
statevector: Option<&Value>,
) -> Value {
let curriculum = match loader.curriculum() {
Ok(c) => c,
Err(e) => return json!({ "error": e.to_string() }),
};
let gates = match CircuitAnalyzer::list_gates(&CircuitSource(circuit.to_string())) {
Ok(g) => g,
Err(e) => return json!({ "error": e.to_string() }),
};
let gate_breakdown: Vec<Value> = gates
.iter()
.map(|g| gate_entry(g, &curriculum.gate_descriptions))
.collect();
let key_concept = classify_key_concept(&gates);
let num_qubits = max_qubit_index(&gates).map(|m| m + 1).unwrap_or(0);
let (dominant, missing) = outcomes_summary(counts, num_qubits);
let statevector_summary = statevector_summary(statevector);
json!({
"gate_breakdown": gate_breakdown,
"num_qubits": num_qubits,
"key_concept": key_concept,
"dominant_outcomes": dominant,
"missing_outcomes": missing,
"statevector_summary": statevector_summary,
})
}
fn gate_entry(
g: &GateCallInfo,
descs: &std::collections::HashMap<String, crate::tutor::GateDescription>,
) -> Value {
let mut entry = json!({
"name": g.name,
"qubits": g.qubits,
"params": g.params,
});
if let Some(d) = descs.get(&g.name) {
entry["description"] = json!(d.short);
if let Some(eff) = &d.effect_on_zero {
entry["effect_on_zero"] = json!(eff);
}
}
entry
}
fn classify_key_concept(gates: &[GateCallInfo]) -> &'static str {
let names: std::collections::HashSet<&str> = gates.iter().map(|g| g.name.as_str()).collect();
if names.contains("cx") || names.contains("cz") || names.contains("ccx") || names.contains("swap") {
"entanglement"
} else if names.contains("h") && (names.contains("rz") || names.contains("z") || names.contains("s") || names.contains("t")) {
"interference"
} else if names.contains("h") {
"superposition"
} else if names.contains("rx") || names.contains("ry") || names.contains("rz") {
"rotation"
} else {
"measurement"
}
}
fn max_qubit_index(gates: &[GateCallInfo]) -> Option<usize> {
gates.iter().flat_map(|g| g.qubits.iter().copied()).max()
}
fn outcomes_summary(counts: &Value, num_qubits: usize) -> (Vec<String>, Vec<String>) {
let map = match counts.as_object() {
Some(m) => m,
None => return (vec![], vec![]),
};
let total: u64 = map.values().filter_map(|v| v.as_u64()).sum();
if total == 0 { return (vec![], vec![]); }
let threshold = (total as f64 * 0.05) as u64;
let mut dominant: Vec<String> = map
.iter()
.filter(|(_, v)| v.as_u64().unwrap_or(0) > threshold)
.map(|(k, _)| k.clone())
.collect();
dominant.sort();
let missing: Vec<String> = if num_qubits == 0 || num_qubits > 4 {
vec![]
} else {
let n = num_qubits;
let mut out: Vec<String> = (0u64..(1u64 << n))
.map(|i| format!("{i:0>n$b}"))
.filter(|bs| map.get(bs).and_then(|v| v.as_u64()).unwrap_or(0) == 0)
.collect();
out.sort();
out
};
(dominant, missing)
}
fn statevector_summary(sv: Option<&Value>) -> Value {
let sv = match sv.and_then(|v| v.as_array()) {
Some(a) => a,
None => return Value::Null,
};
let mut non_zero = Vec::new();
let mut zero = Vec::new();
for (i, amp) in sv.iter().enumerate() {
let r = amp.as_array().and_then(|a| a.first()).and_then(|v| v.as_f64()).unwrap_or(0.0);
let im = amp.as_array().and_then(|a| a.get(1)).and_then(|v| v.as_f64()).unwrap_or(0.0);
if r * r + im * im > 1e-10 { non_zero.push(i); } else { zero.push(i); }
}
json!({"non_zero_amplitudes": non_zero, "zero_amplitudes": zero})
}
```
- [ ] **Step 3: Run tests**
```bash
cargo test tools::tutor_tools::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 11 passed`.
- [ ] **Step 4: Commit**
```bash
git add src/tools/tutor_tools.rs
git commit -m "feat: explain_result with AST-based gate breakdown (no string matching)"
```
@@ -0,0 +1,138 @@
# Task 9 — `get_progress` response (current_lesson + unlock rule)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Build a structured progress payload: per-module status (`locked | unlocked | in_progress | completed`), current module + current lesson, total exercises solved, percent complete. Spec §4 unlock rule: module `n+1` becomes `unlocked` once at least ⌈2/3⌉ of module `n`'s exercises are solved.
## Prerequisites
- Task 8 merged (`tutor_tools.rs` populated).
## Files
- Modify: `src/tools/tutor_tools.rs`
## Steps
- [ ] **Step 1: Append failing tests**
```rust
#[test]
fn get_progress_with_no_progress_returns_module_1_unlocked_others_locked() {
let resp = get_progress_response(&loader(), &UserProgress::default());
assert_eq!(resp["current_module"].as_u64().unwrap(), 1);
let modules = resp["modules"].as_array().unwrap();
assert_eq!(modules[0]["status"].as_str().unwrap(), "unlocked");
if modules.len() > 1 {
assert_eq!(modules[1]["status"].as_str().unwrap(), "locked");
}
assert_eq!(resp["total_exercises_solved"].as_u64().unwrap(), 0);
}
#[test]
fn get_progress_completed_module_marked_completed() {
let mut progress = UserProgress::default();
progress.mark_solved("1-1-a");
progress.mark_solved("1-1-b");
let resp = get_progress_response(&loader(), &progress);
assert_eq!(resp["modules"].as_array().unwrap()[0]["status"].as_str().unwrap(), "completed");
}
#[test]
fn get_progress_returns_current_lesson_for_active_module() {
let resp = get_progress_response(&loader(), &UserProgress::default());
assert_eq!(resp["current_lesson"].as_u64().unwrap(), 1);
}
```
- [ ] **Step 2: Implementation**
```rust
pub fn get_progress_response(loader: &CurriculumLoader, progress: &UserProgress) -> Value {
let curriculum = match loader.curriculum() {
Ok(c) => c,
Err(e) => return json!({ "error": e.to_string() }),
};
let total_exercises: usize = loader.all_exercises().len();
let solved_count = progress.solved_exercises.len().min(total_exercises);
let mut prev_unlocks_next = true;
let modules: Vec<Value> = curriculum
.modules
.iter()
.map(|m| {
let total_in_module: usize = m.lessons.iter().map(|l| l.exercises.len()).sum();
let solved_in_module = m
.lessons
.iter()
.flat_map(|l| l.exercises.iter())
.filter(|e| progress.has_solved(&e.id))
.count();
let status = if !prev_unlocks_next {
"locked"
} else if total_in_module > 0 && solved_in_module == total_in_module {
"completed"
} else if solved_in_module > 0 {
"in_progress"
} else {
"unlocked"
};
// Spec §4: next module unlocks when ⌈2/3⌉ of current is solved.
let unlock_threshold = (total_in_module * 2 + 2) / 3;
prev_unlocks_next = solved_in_module >= unlock_threshold && total_in_module > 0;
json!({
"id": m.id,
"title": m.title,
"status": status,
"exercises_solved": solved_in_module,
"exercises_total": total_in_module,
})
})
.collect();
let (current_module, current_lesson) = current_position(curriculum, progress);
let percent = if total_exercises == 0 { 0 } else { (solved_count * 100) / total_exercises };
json!({
"current_module": current_module,
"current_lesson": current_lesson,
"modules": modules,
"total_exercises_solved": solved_count,
"total_exercises": total_exercises,
"percent_complete": percent,
})
}
fn current_position(curriculum: &crate::tutor::Curriculum, progress: &UserProgress) -> (u32, u32) {
for module in &curriculum.modules {
for lesson in &module.lessons {
if lesson.exercises.iter().any(|e| !progress.has_solved(&e.id)) {
return (module.id, lesson.id);
}
}
}
let last = curriculum.modules.last();
(
last.map(|m| m.id).unwrap_or(1),
last.and_then(|m| m.lessons.last()).map(|l| l.id).unwrap_or(1),
)
}
```
- [ ] **Step 3: Run tests**
```bash
cargo test tools::tutor_tools::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: `test result: ok. 14 passed`.
- [ ] **Step 4: Commit**
```bash
git add src/tools/tutor_tools.rs
git commit -m "feat: get_progress with current_lesson and module unlock rule (spec §4)"
```
@@ -0,0 +1,157 @@
# Task 10 — Wire 4 tutor tools into `QuantumBridgeServer`
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Register `get_lesson`, `check_exercise`, `explain_result`, `get_progress` as MCP tools. The wrapper:
1. Loads `UserProgress` via `ProgressStore` (sandboxed by env var).
2. Routes through `self.backend` (set up in Task 0).
3. Maps `protocol_error` to `McpError::invalid_params`; all other errors stay in the structured payload.
4. Persists `mark_solved` on `passed: true`.
## Prerequisites
- Task 9 merged (all 4 response functions exist in `tutor_tools.rs`).
## Files
- Modify: `src/tools/mod.rs`
## Steps
- [ ] **Step 1: Add imports + parameter structs at the top of `src/tools/mod.rs`**
```rust
use crate::progress::ProgressStore;
use crate::tutor::CurriculumLoader;
use crate::tools::tutor_tools;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetLessonParams {
/// Module number (17).
pub module_id: u32,
/// Lesson number within the module (optional, defaults to first lesson with pending exercises).
pub lesson_id: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CheckExerciseParams {
/// Exercise identifier, e.g. "1-1-a".
pub exercise_id: String,
/// OpenQASM 3.0 source of the circuit to verify.
pub circuit: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ExplainResultParams {
/// OpenQASM 3.0 source of the circuit that was executed.
pub circuit: String,
/// Measurement counts from run_circuit (bitstring → count).
pub counts: serde_json::Value,
/// Optional statevector from run_circuit with return_statevector=true.
pub statevector: Option<serde_json::Value>,
}
```
- [ ] **Step 2: Add helpers on `QuantumBridgeServer` (outside the `#[tool_router]` block)**
```rust
impl QuantumBridgeServer {
fn loader(&self) -> CurriculumLoader { CurriculumLoader::default() }
fn store(&self) -> Result<ProgressStore, McpError> {
let path = ProgressStore::default_path()
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(ProgressStore::new(path))
}
}
```
- [ ] **Step 3: Add the 4 tool methods inside `#[tool_router(server_handler)] impl QuantumBridgeServer`**
```rust
#[tool(description = "Get a quantum-computing lesson with concept, example circuit, and the next pending exercise.")]
async fn get_lesson(
&self,
Parameters(GetLessonParams { module_id, lesson_id }): Parameters<GetLessonParams>,
) -> Result<CallToolResult, McpError> {
let store = self.store()?;
let progress = store.load();
let json = tutor_tools::get_lesson_response(&self.loader(), &progress, module_id, lesson_id);
if let Some(err) = json.get("error").and_then(|v| v.as_str()).map(str::to_owned) {
return Err(McpError::invalid_params(err, None));
}
Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
}
#[tool(description = "Verify a submitted OpenQASM 3.0 circuit against a curriculum exercise. Returns pass/fail with feedback.")]
async fn check_exercise(
&self,
Parameters(CheckExerciseParams { exercise_id, circuit }): Parameters<CheckExerciseParams>,
) -> Result<CallToolResult, McpError> {
let store = self.store()?;
let progress = store.load();
let mut json = tutor_tools::check_exercise_response(
&self.loader(),
self.backend.as_ref(),
&progress,
&exercise_id,
&circuit,
);
if let Some(err) = json.get("protocol_error").and_then(|v| v.as_str()).map(str::to_owned) {
return Err(McpError::invalid_params(err, None));
}
if json["passed"].as_bool().unwrap_or(false) {
store
.mark_solved(&exercise_id)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json["progress_updated"] = serde_json::json!(true);
}
Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
}
#[tool(description = "Analyze a circuit and its measurement results to produce structured pedagogical data (gate breakdown, key concept, outcome stats).")]
async fn explain_result(
&self,
Parameters(ExplainResultParams { circuit, counts, statevector }): Parameters<ExplainResultParams>,
) -> Result<CallToolResult, McpError> {
let json = tutor_tools::explain_result_response(&self.loader(), &circuit, &counts, statevector.as_ref());
if let Some(err) = json.get("error").and_then(|v| v.as_str()).map(str::to_owned) {
return Err(McpError::invalid_params(err, None));
}
Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
}
#[tool(description = "Get the learner's progress through the curriculum (modules status, current lesson, percent complete).")]
async fn get_progress(&self) -> Result<CallToolResult, McpError> {
let store = self.store()?;
let progress = store.load();
let json = tutor_tools::get_progress_response(&self.loader(), &progress);
Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
}
```
- [ ] **Step 4: Build and run all unit tests**
```bash
cargo build && cargo test --lib 2>&1 | grep -E "test result|FAILED"
```
Expected: every existing suite green; the 4 new methods compile and the existing tests are untouched.
- [ ] **Step 5: Verify the MCP `tools/list` exposes 7 tools**
```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | timeout 3 cargo run 2>/dev/null | tail -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['result']['tools']), 'tools')"
```
Expected: `7 tools`.
- [ ] **Step 6: Commit**
```bash
git add src/tools/mod.rs
git commit -m "feat: register 4 tutor tools on QuantumBridgeServer"
```
@@ -0,0 +1,92 @@
# Task 11 — Curriculum module 2 (Superposition)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Append module 2 (3 exercises on the Hadamard gate and the |+⟩/|−⟩ states) to `curriculum/curriculum.json`.
## Prerequisites
- Task 10 merged (all infrastructure in place).
## Files
- Modify: `curriculum/curriculum.json`
## Steps
- [ ] **Step 1: Insert the module after module 1, inside the `"modules"` array**
```json
,
{
"id": 2,
"title": "Superposition",
"lessons": [
{
"id": 1,
"title": "La porte Hadamard et l'état |+⟩",
"concept": "La porte H (Hadamard) transforme |0⟩ en une superposition égale de |0⟩ et |1⟩. Quand tu mesures, tu obtiens 0 ou 1 avec 50% de probabilité chacun — pas parce que c'est aléatoire au sens classique, mais parce que la mesure fait 'choisir' le qubit.",
"example_circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;",
"what_to_observe": "Lance run_circuit avec 1000 shots. Tu devrais voir ~500 '0' et ~500 '1'. Relance plusieurs fois : la distribution change légèrement à chaque fois.",
"exercises": [
{
"id": "2-1-a",
"prompt": "Écris un circuit qui met un qubit en superposition parfaite (50/50) et le mesure.",
"hint": "La porte H est la seule dont tu as besoin.",
"criteria": {
"required_outcomes": [
{"bitstring": "0", "min_ratio": 0.4},
{"bitstring": "1", "min_ratio": 0.4}
],
"forbidden_outcomes": []
},
"feedback_pass": "Parfait ! La porte H crée bien une superposition équilibrée.",
"feedback_fail": "Tu dois voir à la fois des '0' et des '1' en quantités comparables."
},
{
"id": "2-1-b",
"prompt": "Applique H deux fois de suite sur le même qubit. Quel est le résultat à la mesure ?",
"hint": "H est son propre inverse : H·H = Identité.",
"criteria": {
"required_outcomes": [{"bitstring": "0", "min_ratio": 0.99}],
"forbidden_outcomes": ["1"]
},
"feedback_pass": "Excellent ! H·H = Identité : appliquer Hadamard deux fois ramène au point de départ.",
"feedback_fail": "Deux portes H d'affilée doivent ramener à |0⟩ avec certitude."
},
{
"id": "2-1-c",
"prompt": "Pars de |1⟩ (utilise X d'abord), puis applique H. Que vois-tu ?",
"hint": "H|1⟩ = (|0⟩-|1⟩)/√2 = |−⟩. La mesure donne toujours 50/50.",
"criteria": {
"required_outcomes": [
{"bitstring": "0", "min_ratio": 0.4},
{"bitstring": "1", "min_ratio": 0.4}
],
"forbidden_outcomes": []
},
"feedback_pass": "Bien vu ! H|1⟩ donne aussi 50/50 — la différence (|−⟩ vs |+⟩) est dans la phase.",
"feedback_fail": "Tu devrais voir ~50/50. Utilise X puis H."
}
]
}
]
}
```
- [ ] **Step 2: Verify JSON parses and module count updates**
```bash
cargo test tutor::tests::curriculum_json_deserializes_with_module_1 2>&1 | grep "test result"
```
Expected: `test result: ok. 1 passed`.
- [ ] **Step 3: Commit**
```bash
git add curriculum/curriculum.json
git commit -m "feat: add curriculum module 2 (superposition)"
```
@@ -0,0 +1,89 @@
# Task 12 — Curriculum module 3 (Interférence et phase)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Append module 3 (3 exercises on phase, H·Z·H, Rz(π), H·S·H) to `curriculum/curriculum.json`.
## Prerequisites
- Task 11 merged.
## Files
- Modify: `curriculum/curriculum.json`
## Steps
- [ ] **Step 1: Insert after module 2**
```json
,
{
"id": 3,
"title": "Interférence et phase",
"lessons": [
{
"id": 1,
"title": "La phase quantique et l'interférence",
"concept": "La phase d'un état quantique est invisible à la mesure directe, mais deux états en superposition peuvent interférer : leurs amplitudes s'additionnent ou s'annulent. C'est le moteur de l'avantage quantique.",
"example_circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nh q[0];\nz q[0];\nh q[0];\nc = measure q;",
"what_to_observe": "Ce circuit (H·Z·H) donne toujours '1'. Compare avec un simple X.",
"exercises": [
{
"id": "3-1-a",
"prompt": "Vérifie que H·Z·H = X. Écris le circuit H·Z·H et mesure.",
"hint": "Applique h, puis z, puis h, puis mesure.",
"criteria": {
"required_outcomes": [{"bitstring": "1", "min_ratio": 0.99}],
"forbidden_outcomes": ["0"]
},
"feedback_pass": "Parfait ! H·Z·H = X — l'interférence transforme un flip de phase en flip de bit.",
"feedback_fail": "Assure-toi d'appliquer h, z, h dans cet ordre."
},
{
"id": "3-1-b",
"prompt": "Applique H, puis Rz(π), puis H. Que vois-tu ?",
"hint": "rz(pi) q[0]; — la constante pi est disponible.",
"criteria": {
"required_outcomes": [{"bitstring": "1", "min_ratio": 0.99}],
"forbidden_outcomes": ["0"]
},
"feedback_pass": "Exact ! Rz(π) est équivalent à Z (à une phase globale près).",
"feedback_fail": "Vérifie ta syntaxe pour rz(pi) q[0];"
},
{
"id": "3-1-c",
"prompt": "Crée un circuit H·S·H et mesure. Que devient la distribution ?",
"hint": "S introduit une phase de 90° qui modifie l'interférence.",
"criteria": {
"required_outcomes": [
{"bitstring": "0", "min_ratio": 0.2},
{"bitstring": "1", "min_ratio": 0.2}
],
"forbidden_outcomes": []
},
"feedback_pass": "Bien joué ! H·S·H produit une superposition non triviale.",
"feedback_fail": "Tu dois voir les deux outcomes. Applique h, s, h dans cet ordre."
}
]
}
]
}
```
- [ ] **Step 2: Verify**
```bash
cargo test tutor::tests::curriculum_json_deserializes_with_module_1 2>&1 | grep "test result"
```
Expected: `test result: ok. 1 passed`.
- [ ] **Step 3: Commit**
```bash
git add curriculum/curriculum.json
git commit -m "feat: add curriculum module 3 (interférence et phase)"
```
@@ -0,0 +1,89 @@
# Task 13 — Curriculum module 4 (2 qubits et intrication)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Append module 4 (3 exercises on Bell state and CNOT semantics) to `curriculum/curriculum.json`.
## Prerequisites
- Task 12 merged.
## Files
- Modify: `curriculum/curriculum.json`
## Steps
- [ ] **Step 1: Insert after module 3**
```json
,
{
"id": 4,
"title": "2 qubits et intrication",
"lessons": [
{
"id": 1,
"title": "Le circuit de Bell et l'intrication",
"concept": "Deux qubits peuvent être 'intriqués' : mesurer l'un détermine instantanément le résultat de l'autre. L'état de Bell est le prototype de l'intrication.",
"example_circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;",
"what_to_observe": "Lance avec 1000 shots. Tu dois voir uniquement '00' et '11' en quantités égales — jamais '01' ou '10'.",
"exercises": [
{
"id": "4-1-a",
"prompt": "Crée un état de Bell entre 2 qubits : la mesure doit donner uniquement '00' ou '11'.",
"hint": "H sur q[0], puis CX q[0]→q[1].",
"criteria": {
"required_outcomes": [
{"bitstring": "00", "min_ratio": 0.4},
{"bitstring": "11", "min_ratio": 0.4}
],
"forbidden_outcomes": ["01", "10"]
},
"feedback_pass": "Excellent ! Tu as créé l'état de Bell.",
"feedback_fail": "Tu dois voir uniquement '00' et '11'. Utilise H q[0] puis CX q[0],q[1]."
},
{
"id": "4-1-b",
"prompt": "CNOT sans superposition : pars de |00⟩ et applique CX. Que vois-tu ?",
"hint": "Sans H, q[0] reste |0⟩.",
"criteria": {
"required_outcomes": [{"bitstring": "00", "min_ratio": 0.99}],
"forbidden_outcomes": ["01", "10", "11"]
},
"feedback_pass": "Correct ! CNOT avec contrôle=|0⟩ ne change rien.",
"feedback_fail": "Tu dois obtenir '00' systématiquement."
},
{
"id": "4-1-c",
"prompt": "Prépare q[0]=|1⟩ avec X, puis applique CX. Que voit-on sur q[1] ?",
"hint": "CNOT flip la cible quand le contrôle est |1⟩.",
"criteria": {
"required_outcomes": [{"bitstring": "11", "min_ratio": 0.99}],
"forbidden_outcomes": ["00", "01", "10"]
},
"feedback_pass": "Parfait ! |10⟩ devient |11⟩ après CX.",
"feedback_fail": "Avec q[0]=|1⟩, le CNOT doit flipper q[1]."
}
]
}
]
}
```
- [ ] **Step 2: Verify**
```bash
cargo test tutor::tests::curriculum_json_deserializes_with_module_1 2>&1 | grep "test result"
```
Expected: `test result: ok. 1 passed`.
- [ ] **Step 3: Commit**
```bash
git add curriculum/curriculum.json
git commit -m "feat: add curriculum module 4 (2 qubits et intrication)"
```
@@ -0,0 +1,94 @@
# Task 14 — Curriculum module 5 (Circuits multi-qubits)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Append module 5 (3 exercises on GHZ, Toffoli, uniform superposition) to `curriculum/curriculum.json`.
## Prerequisites
- Task 13 merged.
## Files
- Modify: `curriculum/curriculum.json`
## Steps
- [ ] **Step 1: Insert after module 4**
```json
,
{
"id": 5,
"title": "Circuits multi-qubits",
"lessons": [
{
"id": 1,
"title": "GHZ, Toffoli et superposition uniforme",
"concept": "On peut intriqué N qubits en même temps. L'état GHZ généralise Bell à 3+ qubits. La porte Toffoli (CCX) est un AND quantique réversible.",
"example_circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[3] q;\nbit[3] c;\nh q[0];\ncx q[0], q[1];\ncx q[0], q[2];\nc = measure q;",
"what_to_observe": "Avec 3 qubits GHZ, tu dois voir uniquement '000' et '111'.",
"exercises": [
{
"id": "5-1-a",
"prompt": "Crée l'état GHZ à 3 qubits.",
"hint": "H sur q[0], puis CX q[0]→q[1], puis CX q[0]→q[2].",
"criteria": {
"required_outcomes": [
{"bitstring": "000", "min_ratio": 0.4},
{"bitstring": "111", "min_ratio": 0.4}
],
"forbidden_outcomes": ["001", "010", "011", "100", "101", "110"]
},
"feedback_pass": "Bravo ! L'état GHZ à 3 qubits est créé.",
"feedback_fail": "L'état GHZ doit produire uniquement '000' et '111'."
},
{
"id": "5-1-b",
"prompt": "Toffoli : prépare q[0]=|1⟩ et q[1]=|1⟩, puis applique CCX.",
"hint": "CCX flip q[2] seulement si q[0] ET q[1] sont à |1⟩.",
"criteria": {
"required_outcomes": [{"bitstring": "111", "min_ratio": 0.99}],
"forbidden_outcomes": ["000", "001", "010", "011", "100", "101", "110"]
},
"feedback_pass": "Toffoli est un AND quantique : il flip q[2] seulement si q[0]=q[1]=|1⟩.",
"feedback_fail": "Utilise x q[0]; x q[1]; ccx q[0],q[1],q[2];"
},
{
"id": "5-1-c",
"prompt": "Crée une superposition uniforme de tous les états à 2 qubits (~25% chacun).",
"hint": "Applique H sur chaque qubit indépendamment.",
"criteria": {
"required_outcomes": [
{"bitstring": "00", "min_ratio": 0.15},
{"bitstring": "01", "min_ratio": 0.15},
{"bitstring": "10", "min_ratio": 0.15},
{"bitstring": "11", "min_ratio": 0.15}
],
"forbidden_outcomes": []
},
"feedback_pass": "Avec H sur chaque qubit tu explores 4 états simultanément.",
"feedback_fail": "Tu dois voir les 4 états chacun avec ~25%."
}
]
}
]
}
```
- [ ] **Step 2: Verify**
```bash
cargo test tutor::tests::curriculum_json_deserializes_with_module_1 2>&1 | grep "test result"
```
Expected: `test result: ok. 1 passed`.
- [ ] **Step 3: Commit**
```bash
git add curriculum/curriculum.json
git commit -m "feat: add curriculum module 5 (multi-qubits)"
```
@@ -0,0 +1,77 @@
# Task 15 — Curriculum module 6 (Premiers algorithmes — phase kickback)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Append module 6 (2 exercises: Bernstein-Vazirani for s='1' and a phase-kickback demonstration) to `curriculum/curriculum.json`.
> **Note vs. rev 1 of the master plan:** the original teleportation exercise required classical-controlled gates (`if (c[0]) ...`), which the v1 executor walks past silently — only `Stmt::GateCall` is handled in `executor::extract_gate_ops`. The phase-kickback exercise covers the same pedagogical territory and is executable on the v1 backend.
## Prerequisites
- Task 14 merged.
## Files
- Modify: `curriculum/curriculum.json`
## Steps
- [ ] **Step 1: Insert after module 5**
```json
,
{
"id": 6,
"title": "Premiers algorithmes quantiques",
"lessons": [
{
"id": 1,
"title": "Bernstein-Vazirani et phase kickback",
"concept": "L'algorithme de Bernstein-Vazirani retrouve une chaîne secrète en un seul appel à l'oracle. Le phase kickback est le mécanisme central : faire 'remonter' une phase d'un qubit cible vers un qubit de contrôle en superposition.",
"example_circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[1] c;\nx q[1];\nh q[0];\nh q[1];\ncx q[0], q[1];\nh q[0];\nc[0] = measure q[0];",
"what_to_observe": "Bernstein-Vazirani pour s='1' : q[0] mesure toujours '1'.",
"exercises": [
{
"id": "6-1-a",
"prompt": "Bernstein-Vazirani pour s='1' : prépare l'ancilla en |−⟩ (X+H), mets q[0] en superposition (H), applique CX q[0]→q[1] (oracle pour s=1), puis H sur q[0] et mesure q[0].",
"hint": "X q[1]; H q[1]; H q[0]; CX q[0],q[1]; H q[0]; puis mesure q[0].",
"criteria": {
"required_outcomes": [{"bitstring": "1", "min_ratio": 0.99}],
"forbidden_outcomes": ["0"]
},
"feedback_pass": "Excellent ! BV retrouve s='1' en un seul appel à l'oracle.",
"feedback_fail": "Tu dois obtenir '1' avec certitude. Vérifie l'ordre : X q[1]; H q[1]; H q[0]; cx q[0],q[1]; H q[0]; mesure q[0]."
},
{
"id": "6-1-b",
"prompt": "Phase kickback : prépare q[1] en |1⟩ (X), mets q[0] en superposition (H), applique CZ q[0],q[1]. Puis H q[0] et mesure q[0].",
"hint": "Le CZ avec q[1]=|1⟩ ajoute une phase -1 quand q[0]=|1⟩ — c'est un Z 'remonté' sur q[0].",
"criteria": {
"required_outcomes": [{"bitstring": "1", "min_ratio": 0.99}],
"forbidden_outcomes": ["0"]
},
"feedback_pass": "Parfait ! La phase de q[1] s'est 'kickback' sur q[0]. C'est la clé des algorithmes quantiques.",
"feedback_fail": "Tu dois obtenir '1' sur q[0]. Vérifie : x q[1]; h q[0]; cz q[0],q[1]; h q[0]; mesure q[0]."
}
]
}
]
}
```
- [ ] **Step 2: Verify**
```bash
cargo test tutor::tests::curriculum_json_deserializes_with_module_1 2>&1 | grep "test result"
```
Expected: `test result: ok. 1 passed`.
- [ ] **Step 3: Commit**
```bash
git add curriculum/curriculum.json
git commit -m "feat: add curriculum module 6 (premiers algorithmes - phase kickback)"
```
@@ -0,0 +1,88 @@
# Task 16 — Curriculum module 7 (Grover) + total-count assertion
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
Append module 7 (2 Grover exercises, 1 iteration on 2 qubits with markers `'11'` and `'00'`) and add a final assertion that the curriculum contains exactly 18 exercises.
## Prerequisites
- Task 15 merged.
## Files
- Modify: `curriculum/curriculum.json`
- Modify: `src/tutor.rs` (add the count assertion test)
## Steps
- [ ] **Step 1: Insert after module 6**
```json
,
{
"id": 7,
"title": "Algorithme de Grover",
"lessons": [
{
"id": 1,
"title": "Recherche quantique : amplification d'amplitude",
"concept": "L'algorithme de Grover retrouve un élément marqué dans N éléments en O(√N) appels — quadratiquement plus rapide que la recherche classique. Il utilise un oracle de phase + un opérateur de diffusion.",
"example_circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[1];\ncz q[0], q[1];\nh q[0];\nh q[1];\nx q[0];\nx q[1];\ncz q[0], q[1];\nx q[0];\nx q[1];\nh q[0];\nh q[1];\nc = measure q;",
"what_to_observe": "Grover sur 2 qubits avec marqueur '11' produit '11' avec ~100% après 1 itération.",
"exercises": [
{
"id": "7-1-a",
"prompt": "Implémente Grover sur 2 qubits pour trouver l'état marqué '11'. 1 itération suffit.",
"hint": "H H | CZ (oracle) | H H X X CZ X X H H (diffuseur).",
"criteria": {
"required_outcomes": [{"bitstring": "11", "min_ratio": 0.85}],
"forbidden_outcomes": []
},
"feedback_pass": "Magnifique ! Grover trouve '11' en une itération avec haute probabilité.",
"feedback_fail": "L'état '11' doit dominer (>85%). Vérifie oracle et diffuseur."
},
{
"id": "7-1-b",
"prompt": "Grover pour l'état '00'. Oracle = X X CZ X X.",
"hint": "X q[0]; X q[1]; CZ q[0],q[1]; X q[0]; X q[1]; puis diffuseur.",
"criteria": {
"required_outcomes": [{"bitstring": "00", "min_ratio": 0.85}],
"forbidden_outcomes": []
},
"feedback_pass": "Excellent ! En changeant l'oracle, Grover trouve n'importe quel état marqué.",
"feedback_fail": "L'état '00' doit dominer (>85%). Ton oracle doit marquer '00'."
}
]
}
]
}
```
- [ ] **Step 2: Append a total-count assertion in `src/tutor.rs`**
Inside the existing `#[cfg(test)] mod tests`:
```rust
#[test]
fn curriculum_has_18_exercises_total() {
let loader = CurriculumLoader::default();
assert_eq!(loader.all_exercises().len(), 18);
}
```
- [ ] **Step 3: Verify**
```bash
cargo test tutor::tests 2>&1 | grep -E "test result|FAILED"
```
Expected: every tutor test passes; the new assertion confirms 18 exercises across 7 modules.
- [ ] **Step 4: Commit**
```bash
git add curriculum/curriculum.json src/tutor.rs
git commit -m "feat: add curriculum module 7 (Grover) and 18-exercise total assertion"
```
@@ -0,0 +1,222 @@
# Task 17 — Integration tests + golden files (sandboxed)
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
## Goal
End-to-end MCP roundtrip for the 4 tutor tools, using `QB_PROGRESS_PATH` to point each test process at a private temp file. Add golden files in `tests/reference/tutor/` (spec §6) that exercise pass/fail per module.
## Prerequisites
- Task 16 merged (curriculum complete).
## Files
- Create: `tests/tutor_integration.rs`
- Create: `tests/reference/tutor/exercises_pass.jsonl`
- Create: `tests/reference/tutor/exercises_fail.jsonl`
## Steps
- [ ] **Step 1: Create `tests/tutor_integration.rs`**
```rust
//! MCP JSON-RPC roundtrip tests for the tutor tools. Sandboxed via QB_PROGRESS_PATH.
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};
use tempfile::TempDir;
struct McpProcess {
child: Child,
_tmp: TempDir,
}
impl McpProcess {
fn spawn() -> Self {
let tmp = TempDir::new().expect("tempdir");
let progress_path = tmp.path().join("progress.json");
let child = Command::new(env!("CARGO_BIN_EXE_quantum-bridge-mcp"))
.env("QB_PROGRESS_PATH", &progress_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn");
Self { child, _tmp: tmp }
}
fn send(&mut self, msg: &str) {
let stdin = self.child.stdin.as_mut().unwrap();
writeln!(stdin, "{msg}").unwrap();
stdin.flush().unwrap();
}
fn recv(&mut self) -> serde_json::Value {
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();
serde_json::from_str(line.trim()).expect("response is JSON")
}
}
impl Drop for McpProcess {
fn drop(&mut self) {
let _ = self.child.kill();
}
}
fn initialize(p: &mut McpProcess) {
p.send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}"#);
p.recv();
}
fn extract(resp: &serde_json::Value) -> serde_json::Value {
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
}
#[test]
fn tools_list_exposes_seven_tools() {
let mut p = McpProcess::spawn();
initialize(&mut p);
p.send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#);
let resp = p.recv();
let tools = resp["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 7);
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for required in ["get_lesson", "check_exercise", "explain_result", "get_progress"] {
assert!(names.contains(&required), "missing {required}");
}
}
#[test]
fn check_exercise_persists_progress_in_sandbox() {
let mut p = McpProcess::spawn();
initialize(&mut p);
let circuit = 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":3,"method":"tools/call","params":{{"name":"check_exercise","arguments":{{"exercise_id":"1-1-a","circuit":"{circuit}"}}}}}}"#
);
p.send(&call);
let payload = extract(&p.recv());
assert_eq!(payload["passed"], true);
assert_eq!(payload["progress_updated"], true);
p.send(r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_progress","arguments":{}}}"#);
let progress = extract(&p.recv());
assert!(progress["total_exercises_solved"].as_u64().unwrap() >= 1);
}
#[test]
fn check_exercise_invalid_circuit_returns_diagnostics_in_payload_not_protocol_error() {
let mut p = McpProcess::spawn();
initialize(&mut p);
p.send(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"check_exercise","arguments":{"exercise_id":"1-1-a","circuit":"not valid qasm"}}}"#);
let resp = p.recv();
assert!(resp.get("error").is_none(), "expected payload not error: {resp}");
let payload = extract(&resp);
assert_eq!(payload["passed"], false);
assert!(payload["diagnostics"].as_array().is_some());
}
#[test]
fn explain_result_returns_ast_based_breakdown_with_descriptions() {
let mut p = McpProcess::spawn();
initialize(&mut p);
let call = r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"explain_result","arguments":{"circuit":"OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;","counts":{"00":512,"11":512}}}}"#;
p.send(call);
let payload = extract(&p.recv());
assert_eq!(payload["key_concept"], "entanglement");
assert_eq!(payload["num_qubits"], 2);
let breakdown = payload["gate_breakdown"].as_array().unwrap();
assert_eq!(breakdown.len(), 2);
assert_eq!(breakdown[0]["name"], "h");
assert_eq!(breakdown[1]["name"], "cx");
}
#[test]
fn golden_pass_cases_all_pass() {
let golden = std::fs::read_to_string("tests/reference/tutor/exercises_pass.jsonl").unwrap();
let mut p = McpProcess::spawn();
initialize(&mut p);
for line in golden.lines().filter(|l| !l.trim().is_empty()) {
let case: serde_json::Value = serde_json::from_str(line).unwrap();
let id = case["exercise_id"].as_str().unwrap();
let circuit = case["circuit"].as_str().unwrap();
let call = format!(
r#"{{"jsonrpc":"2.0","id":99,"method":"tools/call","params":{{"name":"check_exercise","arguments":{{"exercise_id":"{id}","circuit":{circuit:?}}}}}}}"#
);
p.send(&call);
let payload = extract(&p.recv());
assert!(payload["passed"].as_bool().unwrap_or(false), "id={id} payload={payload}");
}
}
#[test]
fn golden_fail_cases_all_fail() {
let golden = std::fs::read_to_string("tests/reference/tutor/exercises_fail.jsonl").unwrap();
let mut p = McpProcess::spawn();
initialize(&mut p);
for line in golden.lines().filter(|l| !l.trim().is_empty()) {
let case: serde_json::Value = serde_json::from_str(line).unwrap();
let id = case["exercise_id"].as_str().unwrap();
let circuit = case["circuit"].as_str().unwrap();
let call = format!(
r#"{{"jsonrpc":"2.0","id":98,"method":"tools/call","params":{{"name":"check_exercise","arguments":{{"exercise_id":"{id}","circuit":{circuit:?}}}}}}}"#
);
p.send(&call);
let payload = extract(&p.recv());
assert_eq!(payload["passed"].as_bool().unwrap_or(true), false, "id={id} should fail");
}
}
```
- [ ] **Step 2: Create `tests/reference/tutor/exercises_pass.jsonl`**
One line per test case (≥ 1 per module). Format: `{"exercise_id": "...", "circuit": "..."}`.
```jsonl
{"exercise_id": "1-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;"}
{"exercise_id": "1-1-b", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nc = measure q;"}
{"exercise_id": "2-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;"}
{"exercise_id": "2-1-b", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nh q[0];\nh q[0];\nc = measure q;"}
{"exercise_id": "3-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nh q[0];\nz q[0];\nh q[0];\nc = measure q;"}
{"exercise_id": "4-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;"}
{"exercise_id": "5-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[3] q;\nbit[3] c;\nh q[0];\ncx q[0], q[1];\ncx q[0], q[2];\nc = measure q;"}
{"exercise_id": "6-1-b", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[1] c;\nx q[1];\nh q[0];\ncz q[0], q[1];\nh q[0];\nc[0] = measure q[0];"}
{"exercise_id": "7-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[1];\ncz q[0], q[1];\nh q[0];\nh q[1];\nx q[0];\nx q[1];\ncz q[0], q[1];\nx q[0];\nx q[1];\nh q[0];\nh q[1];\nc = measure q;"}
```
- [ ] **Step 3: Create `tests/reference/tutor/exercises_fail.jsonl`**
```jsonl
{"exercise_id": "1-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nc = measure q;"}
{"exercise_id": "2-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;"}
{"exercise_id": "4-1-a", "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[1];\nc = measure q;"}
```
- [ ] **Step 4: Run the integration suite**
```bash
cargo test --test tutor_integration 2>&1 | grep -E "test result|FAILED"
```
Expected: every test green.
- [ ] **Step 5: Run the full test suite (regression check)**
```bash
cargo test 2>&1 | grep -E "test result|FAILED"
```
Expected: no regression in v1 suites; every tutor suite green.
- [ ] **Step 6: Commit**
```bash
git add tests/tutor_integration.rs tests/reference/tutor/
git commit -m "test: add MCP tutor integration tests + golden files (sandboxed)"
```
@@ -0,0 +1,52 @@
# Quantum Tutor — sub-task index
**Goal:** Add 4 MCP tools (`get_lesson`, `check_exercise`, `explain_result`, `get_progress`) to quantum-bridge-mcp, backed by a JSON curriculum (7 modules / 18 exercises) and a persistent progress file.
**Spec:** [`docs/superpowers/specs/2026-04-29-quantum-tutor-design.md`](../../specs/2026-04-29-quantum-tutor-design.md).
This plan is split into 18 atomic sub-tasks. Each file is self-contained — when executing a task, load only the matching file plus the spec. Do not load the master plan or sibling tasks.
## Execution order
| # | File | Goal | Depends on |
|---|------|------|------------|
| 0 | [`00-prerequisites.md`](00-prerequisites.md) | Backend injection seam + tempfile dev-dep | — |
| 1 | [`01-curriculum-types.md`](01-curriculum-types.md) | Curriculum types + module 1 JSON | 0 |
| 2 | [`02-curriculum-loader.md`](02-curriculum-loader.md) | `OnceLock`-cached `CurriculumLoader` | 1 |
| 3 | [`03-progress-store.md`](03-progress-store.md) | Sandboxable `ProgressStore` | 0 |
| 4 | [`04-circuit-analyzer.md`](04-circuit-analyzer.md) | AST-based gate listing | 0 |
| 5 | [`05-exercise-checker.md`](05-exercise-checker.md) | `ExerciseChecker` with `&dyn Backend` | 2, 4 |
| 6 | [`06-get-lesson.md`](06-get-lesson.md) | `get_lesson` response | 2, 3 |
| 7 | [`07-check-exercise.md`](07-check-exercise.md) | `check_exercise` response | 5, 6 |
| 8 | [`08-explain-result.md`](08-explain-result.md) | `explain_result` response | 4, 7 |
| 9 | [`09-get-progress.md`](09-get-progress.md) | `get_progress` response | 8 |
| 10 | [`10-wire-tools.md`](10-wire-tools.md) | Register the 4 tools on `QuantumBridgeServer` | 9 |
| 11 | [`11-curriculum-module-2.md`](11-curriculum-module-2.md) | Module 2 — Superposition | 10 |
| 12 | [`12-curriculum-module-3.md`](12-curriculum-module-3.md) | Module 3 — Interférence | 11 |
| 13 | [`13-curriculum-module-4.md`](13-curriculum-module-4.md) | Module 4 — Intrication | 12 |
| 14 | [`14-curriculum-module-5.md`](14-curriculum-module-5.md) | Module 5 — Multi-qubits | 13 |
| 15 | [`15-curriculum-module-6.md`](15-curriculum-module-6.md) | Module 6 — Premiers algorithmes | 14 |
| 16 | [`16-curriculum-module-7.md`](16-curriculum-module-7.md) | Module 7 — Grover | 15 |
| 17 | [`17-integration-tests.md`](17-integration-tests.md) | Integration tests + golden files | 16 |
## Architecture summary (the only context you need from the master plan)
- `QuantumBridgeServer` owns `Arc<dyn Backend>`; tutor tools route through `self.backend`.
- `ExerciseChecker::check_circuit(backend: &dyn Backend, ...)` — never instantiates `LocalSimulator` directly.
- `CurriculumLoader` lazily parses an embedded JSON via `OnceLock`; returns `Result`, no `expect()`.
- `ProgressStore` is sandboxed via `QB_PROGRESS_PATH`; missing `HOME` returns `Err`.
- `CircuitAnalyzer` (new module) exposes AST-based gate listing — `explain_result` does not string-match the QASM source.
- Statistical tolerance for exercise checks is **2σ** below `min_ratio` (spec §3).
- `check_exercise` distinguishes protocol errors (unknown exercise → `McpError`) from validation errors (circuit invalid → structured `diagnostics` payload).
- Module 6 teleportation exercise was replaced by phase kickback (the v1 executor ignores classical-controlled gates).
## Convention per task file
Each task file contains:
1. **Goal** — single paragraph.
2. **Prerequisites** — which tasks must already be merged.
3. **Files** — what is created or modified.
4. **Steps**`- [ ]` checkboxes with code blocks and verification commands.
5. **Commit** — exact `git commit` message.
Use `superpowers:executing-plans` or `superpowers:subagent-driven-development` to walk one task at a time.
+308
View File
@@ -0,0 +1,308 @@
# Sub-plan 1 — Foundation (Tasks 13)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Cargo bootstrap + error types + domain newtypes + Backend trait hierarchy. Produces a compiling skeleton with all dependencies resolved.
**Starting state:** Greenfield — only `CLAUDE.md`, `.gitignore`, and `docs/` exist. No `src/`, no `Cargo.toml`.
**Deliverable:** `cargo test` exits 0. Three commits in git history.
**Tech Stack:** Rust 2021, `rmcp` (git), `oq3_semantics 0.7`, `spinoza 0.5`, `tokio`, `serde`, `thiserror 2`, `tracing`.
**Main plan reference:** `docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md` Tasks 13.
---
## Task 1: Bootstrap
**Files:**
- Create: `Cargo.toml`
- Create: `src/main.rs` (via `cargo new`)
- [ ] **Step 1: Initialise project**
```bash
cd /home/vincent/src/misc/quantum-bridge-mcp
cargo new --name quantum-bridge-mcp .
```
Expected: `src/main.rs` and `Cargo.toml` created.
- [ ] **Step 2: Write Cargo.toml**
> 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 dependencies compile**
```bash
cargo build
```
Expected: success (first run ~200 MB). If a version constraint fails, run `cargo search <crate>` and adjust.
- [ ] **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: Update src/main.rs**
```rust
mod error;
mod types;
fn main() {
println!("quantum-bridge-mcp");
}
```
- [ ] **Step 4: Verify**
```bash
cargo build
```
Expected: no 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 + constants only — `LocalSimulator` comes in sub-plan 3)
- [ ] **Step 1: Write src/executor.rs**
```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::*;
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 {}
#[test]
fn mock_backend_satisfies_trait_bounds() {
let b = MockBackend;
assert_eq!(b.name(), "mock");
assert_eq!(b.max_qubits(), 4);
}
}
```
- [ ] **Step 2: Declare module and run tests**
Add to `src/main.rs`:
```rust
mod executor;
```
```bash
cargo test
```
Expected: 1 test passes (`mock_backend_satisfies_trait_bounds`).
- [ ] **Step 3: Commit**
```bash
git add src/executor.rs src/main.rs
git commit -m "feat: define Backend trait hierarchy (CanIntrospect/CanValidate/CanExecute)"
```
---
## Final verification
```bash
cargo fmt --check && cargo clippy -- -D warnings && cargo test
```
Expected: all green. Sub-plan 1 complete — hand off to sub-plan 2.
+358
View File
@@ -0,0 +1,358 @@
# Sub-plan 2 — CircuitValidator (Task 4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Implement `CircuitValidator` wrapping `oq3_semantics`. Validates OpenQASM 3.0 syntax, qubit bounds, and our supported gate subset. Returns structured `ValidationResult` with line/column diagnostics.
**Starting state (after sub-plan 1):**
- `Cargo.toml` with all deps
- `src/main.rs` declares `mod error; mod executor; mod types;`
- `src/error.rs``BridgeError`
- `src/types.rs``CircuitSource`, `ValidationResult`, `ValidationDiagnostic`, `DiagnosticSeverity`
- `src/executor.rs``SUPPORTED_GATES`, `MAX_LOCAL_QUBITS`, trait defs only
- `cargo test` passes
**Deliverable:** `cargo test` passes with 5 validator unit tests green. One commit added.
**Main plan reference:** `docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md` Task 4.
---
## Task 4: CircuitValidator — oq3_semantics integration
**Files:**
- Create: `src/validator.rs`
- Modify: `src/main.rs` (add `mod validator;`)
### 4a — AST exploration
The `oq3_semantics` AST is lightly documented (6.9% rustdoc coverage). You MUST run the exploration test first to discover the real type names before implementing.
- [ ] **Step 1: Create src/validator.rs with exploration tests**
```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")
}
}
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 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());
for err in result.semantic_errors().iter() {
println!("semantic_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());
}
#[test]
fn print_qubit_declaration_ast() {
let qasm = "OPENQASM 3.0;\nqubit[3] myq;\n";
let result = parse_source_string(qasm, Some("q.qasm"), None::<&[&str]>);
println!("program: {:#?}", result.program());
}
}
#[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() {
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: {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);
let msg = &result.diagnostics[0].message;
assert!(msg.contains(&n.to_string()), "expected {n} in: {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 2: Declare the module**
Add to `src/main.rs`:
```rust
mod validator;
```
- [ ] **Step 3: Run exploration tests and read AST output**
```bash
cargo test exploration -- --nocapture 2>&1 | head -300
```
**Read the output carefully. Identify:**
1. How `Program` exposes statements — look for `stmts()`, `statements()`, or `iter()`
2. The `Stmt` enum variant name for gate calls (e.g. `GateCall`, `Gate`)
3. The `Stmt` variant name for qubit register declarations (e.g. `QubitDeclaration`, `QubitDecl`)
4. How to extract the gate name string from a gate call node
5. How to extract the register size (width) from a qubit declaration node
6. What `semantic_errors().iter()` yields — specifically the `.range()` method's return type
Write down the exact variant and method names before continuing.
- [ ] **Step 4: Confirm tests fail at todo!()**
```bash
cargo test validator::tests 2>&1 | tail -10
```
Expected: all 5 panic at `todo!()`.
### 4b — Implementation
- [ ] **Step 5: Implement CircuitValidator::validate**
Replace the `todo!()` with the full implementation. The skeleton below uses placeholder helper names — replace variant patterns with what you observed in Step 3:
```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 oq3_semantics errors (syntax + undeclared symbols)
for error in parse_result.semantic_errors().iter() {
let range = error.range();
// TextRange::start() → rowan::TextSize → u32 → 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,
});
}
if parse_result.any_errors() {
return Ok(ValidationResult {
is_valid: false,
diagnostics,
num_qubits: None,
num_gates: None,
});
}
let program = parse_result.program();
let mut total_qubits: usize = 0;
let mut gate_count: usize = 0;
for stmt in program.stmts() { // ← adjust method name from exploration
// Qubit declarations — adjust variant name from exploration output
if let Some(n) = extract_qubit_count(stmt) {
total_qubits += n;
}
// Gate calls — adjust variant name from exploration output
if let Some(gate_name) = extract_gate_name(stmt) {
if gate_name == "measure" {
continue;
}
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 (supported: {supported})"
),
severity: DiagnosticSeverity::Error,
});
}
}
}
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 },
})
}
```
- [ ] **Step 6: Implement the three AST helper functions**
Add below `validate`. Fill in match arms using the variant names from Step 3:
```rust
use oq3_semantics::asg::Stmt;
fn extract_qubit_count(stmt: &Stmt) -> Option<usize> {
// Fill in from exploration output, e.g.:
// if let Stmt::QubitDeclaration(qd) = stmt {
// return Some(qd.width().unwrap_or(1));
// }
todo!("fill in from AST exploration")
}
fn extract_gate_name(stmt: &Stmt) -> Option<String> {
// Fill in from exploration output, e.g.:
// if let Stmt::GateCall(gc) = stmt {
// return Some(gc.name().to_string().to_lowercase());
// }
todo!("fill in from AST exploration")
}
fn extract_stmt_line(stmt: &Stmt, source: &str) -> usize {
// Fill in from exploration output, e.g.:
// let offset = u32::from(stmt.span().start()) as usize;
// byte_offset_to_line_col(source, offset).0
todo!("fill in from AST exploration")
}
```
- [ ] **Step 7: Run tests and iterate until all 5 pass**
```bash
cargo test validator::tests 2>&1
```
Common issues:
- If `program.stmts()` doesn't exist: use `cargo doc --open` or hover in rust-analyzer to find the correct method on `Program`
- If gate name extraction returns wrong case: add `.to_lowercase()`
- If `too_many_qubits` test fails: `extract_qubit_count` may be returning 0 — verify the qubit declaration variant name
- [ ] **Step 8: Run full test suite**
```bash
cargo test 2>&1
```
Expected: all tests pass (exploration + validator::tests + executor::tests from sub-plan 1).
- [ ] **Step 9: Commit**
```bash
git add src/validator.rs src/main.rs
git commit -m "feat: implement CircuitValidator with oq3_semantics"
```
---
## Final verification
```bash
cargo fmt --check && cargo clippy -- -D warnings && cargo test
```
Expected: all green. Sub-plan 2 complete — hand off to sub-plan 3.
+347
View File
@@ -0,0 +1,347 @@
# Sub-plan 3 — LocalSimulator / Executor (Task 5)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Implement `LocalSimulator` — the spinoza statevector engine. Walks the oq3_semantics AST to extract gate ops, applies them to a spinoza `State`, samples measurement counts, and optionally returns the full statevector.
**Starting state (after sub-plan 2):**
- All of sub-plan 1 files present
- `src/validator.rs``CircuitValidator` fully implemented and tested
- `src/main.rs` declares `mod validator;`
- `cargo test` passes (validator tests green)
**Deliverable:** `cargo test` passes with 7 executor unit tests green. One commit added.
**Key APIs:**
- `spinoza::core::{apply, c_apply, cc_apply, reservoir_sampling, State}`
- `spinoza::gates::Gate` — variants: `H, X, Y, Z, P(f64), RX(f64), RY(f64), RZ(f64), SWAP(usize,usize)`
- No `S/T/Sdg/Tdg` variant — use `Gate::P(PI/2.0)`, `Gate::P(PI/4.0)`, etc.
- `reservoir_sampling(&state, 1<<n, shots)` → call `.get_outcome_count()` for `HashMap<usize,usize>`
- Endianness warning: if Bell test sees `"01"/"10"`, reverse qubit index: `num_qubits - 1 - raw_idx`
**Main plan reference:** `docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md` Task 5.
---
## Task 5: LocalSimulator — spinoza executor
**Files:**
- Modify: `src/executor.rs` (add `LocalSimulator` + helpers below the trait definitions)
### 5a — Write failing tests first
- [ ] **Step 1: Replace the mock test block in src/executor.rs with real tests**
Replace the entire `#[cfg(test)]` block at the bottom of `src/executor.rs` with:
```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}");
}
#[test]
fn x_gate_always_produces_one() {
let result = sim()
.run(&CircuitSource(X_CIRCUIT.to_string()), ShotCount(1_000), false)
.unwrap();
assert_eq!(result.counts.get("1").copied().unwrap_or(0), 1_000);
}
#[test]
fn h_gate_produces_roughly_balanced_single_qubit() {
let result = sim()
.run(&CircuitSource(H_CIRCUIT.to_string()), ShotCount(10_000), false)
.unwrap();
let count_0 = result.counts.get("0").copied().unwrap_or(0) as f64;
let ratio = count_0 / 10_000.0;
assert!((0.45..=0.55).contains(&ratio), "ratio_0={ratio:.3}");
}
#[test]
fn run_returns_statevector_when_requested() {
let result = sim()
.run(&CircuitSource(BELL_CIRCUIT.to_string()), ShotCount(100), true)
.unwrap();
let sv = result.statevector.expect("statevector should be present");
assert_eq!(sv.len(), 4); // 2^2
let norm: f64 = sv.iter().map(|(r, i)| r * r + i * i).sum();
assert!((norm - 1.0).abs() < 1e-9, "norm={norm}");
}
#[test]
fn local_simulator_name_is_local_simulator() {
assert_eq!(sim().name(), "local_simulator");
}
#[test]
fn local_simulator_max_qubits_is_28() {
assert_eq!(sim().max_qubits(), MAX_LOCAL_QUBITS);
}
}
```
- [ ] **Step 2: Confirm tests fail (LocalSimulator undefined)**
```bash
cargo test executor::tests 2>&1 | tail -5
```
Expected: compile error `cannot find struct LocalSimulator`.
### 5b — Implementation
- [ ] **Step 3: Add imports and LocalSimulator struct above the #[cfg(test)] block**
Insert this block between the trait definitions and the `#[cfg(test)]` block in `src/executor.rs`:
```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();
let parse_result = parse_source_string(&circuit.0, Some("circuit.qasm"), None::<&[&str]>);
let program = parse_result.program();
let num_qubits = count_qubits(program);
if num_qubits == 0 {
return Err(BridgeError::Simulation("circuit declares no qubits".to_string()));
}
if num_qubits > MAX_LOCAL_QUBITS {
return Err(BridgeError::QubitLimitExceeded {
requested: num_qubits,
limit: MAX_LOCAL_QUBITS,
});
}
let mut state = State::new(num_qubits);
for (gate_name, params, qubits) in extract_gate_ops(program) {
apply_gate(&gate_name, &params, &qubits, &mut state)?;
}
let statevector = if return_statevector {
Some(
state.reals.iter()
.zip(state.imags.iter())
.map(|(&r, &i)| (r as f64, i as f64))
.collect(),
)
} else {
None
};
let num_states = 1usize << num_qubits;
let mut reservoir = reservoir_sampling(&state, num_states, shots.0 as usize);
let raw_counts = reservoir.get_outcome_count();
let counts: HashMap<String, u64> = raw_counts
.into_iter()
.map(|(idx, cnt)| (format!("{:0>width$b}", idx, width = num_qubits), cnt as u64))
.collect();
Ok(SimulationResult {
counts,
shots: shots.0,
execution_time_ms: start.elapsed().as_secs_f64() * 1000.0,
statevector,
})
}
}
impl Backend for LocalSimulator {}
```
- [ ] **Step 4: Add apply_gate function**
```rust
fn apply_gate(
gate_name: &str,
params: &[f64],
qubits: &[usize],
state: &mut State,
) -> Result<(), BridgeError> {
let supported = SUPPORTED_GATES.join(", ");
match (gate_name, qubits) {
("h", &[t]) => apply(Gate::H, state, t),
("x", &[t]) => apply(Gate::X, state, t),
("y", &[t]) => apply(Gate::Y, state, t),
("z", &[t]) => apply(Gate::Z, state, t),
("s", &[t]) => apply(Gate::P(PI / 2.0), state, t),
("t", &[t]) => apply(Gate::P(PI / 4.0), state, t),
("sdg", &[t]) => apply(Gate::P(-PI / 2.0), state, t),
("tdg", &[t]) => apply(Gate::P(-PI / 4.0), state, t),
("rx", &[t]) if params.len() == 1 => apply(Gate::RX(params[0]), state, t),
("ry", &[t]) if params.len() == 1 => apply(Gate::RY(params[0]), state, t),
("rz", &[t]) if params.len() == 1 => apply(Gate::RZ(params[0]), state, t),
("cx", &[ctrl, tgt]) => c_apply(Gate::X, state, ctrl, tgt),
("cz", &[ctrl, tgt]) => c_apply(Gate::Z, state, ctrl, tgt),
("swap",&[a, b]) => apply(Gate::SWAP(a, b), state, a),
("ccx", &[c0, c1, tgt]) => cc_apply(Gate::X, state, c0, c1, tgt),
("measure", _) => {} // sampling handles measurement
_ => return Err(BridgeError::UnsupportedGate {
gate: gate_name.to_string(),
line: 0,
supported,
}),
}
Ok(())
}
```
- [ ] **Step 5: Add extract_gate_ops and count_qubits**
These functions walk the AST. Use the variant names you identified in **sub-plan 2, Task 4 Step 3**. The skeletons below must be replaced with real match arms:
```rust
use oq3_semantics::asg::Stmt;
/// Extracts (gate_name_lowercase, float_params, qubit_indices) for every gate call.
fn extract_gate_ops(program: &oq3_semantics::asg::Program) -> Vec<(String, Vec<f64>, Vec<usize>)> {
let mut ops = Vec::new();
for stmt in program.stmts() { // ← adjust method name if needed
// Fill in from AST exploration (sub-plan 2):
// if let Stmt::GateCall(gc) = stmt {
// let name = resolve_gate_name(gc).to_lowercase();
// let params = extract_float_params(gc); // Vec<f64>
// let qubits = extract_qubit_indices(gc); // Vec<usize>
// ops.push((name, params, qubits));
// }
let _ = stmt;
}
ops
}
/// Counts total declared qubits (sum of all qubit register widths).
fn count_qubits(program: &oq3_semantics::asg::Program) -> usize {
let mut total = 0;
for stmt in program.stmts() { // ← adjust method name if needed
// Fill in from AST exploration (sub-plan 2):
// if let Stmt::QubitDeclaration(qd) = stmt {
// total += qd.width().unwrap_or(1);
// }
let _ = stmt;
}
total
}
```
- [ ] **Step 6: Run tests and iterate until all 7 pass**
```bash
cargo test executor::tests 2>&1
```
**Debugging guide:**
| Failing test | Likely cause | Fix |
|---|---|---|
| `bell_circuit_only_produces_00_and_11` sees `"01"` or `"10"` | spinoza qubit index convention reversed vs QASM3 | In `extract_gate_ops`, flip qubit index: `num_qubits - 1 - raw_idx` |
| `bell_circuit_only_produces_00_and_11` sees all zeros | `extract_gate_ops` returning empty (match arm wrong) | Re-check variant name from exploration |
| `x_gate_always_produces_one` fails | Same empty ops issue | Same fix |
| `run_returns_statevector_when_requested` norm ≠ 1 | `state.reals`/`state.imags` indexing issue | Verify spinoza `State` fields are `Vec<f32>` — cast with `as f64` |
| `local_simulator_max_qubits_is_28` fails | `MAX_LOCAL_QUBITS` not 28 | Check `src/executor.rs` constant |
- [ ] **Step 7: Run full test suite**
```bash
cargo test 2>&1
```
Expected: all tests pass (validator + executor).
- [ ] **Step 8: Commit**
```bash
git add src/executor.rs
git commit -m "feat: implement LocalSimulator with spinoza statevector engine"
```
---
## Final verification
```bash
cargo fmt --check && cargo clippy -- -D warnings && cargo test
```
Expected: all green. Sub-plan 3 complete — hand off to sub-plan 4.
+608
View File
@@ -0,0 +1,608 @@
# Sub-plan 4 — Tools + MCP Server (Tasks 69)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Implement the three tool handlers (`list_backends`, `validate_circuit`, `run_circuit`) and wire everything into a working rmcp stdio MCP server. Smoke-test with a live JSON-RPC call.
**Starting state (after sub-plan 3):**
- All of sub-plans 13 files present
- `src/executor.rs``LocalSimulator` fully implemented, all 7 tests pass
- `src/validator.rs``CircuitValidator` fully implemented, all 5 tests pass
- `cargo test` passes
**Deliverable:** `cargo build` produces a working MCP binary. `cargo test` passes with 16+ unit tests. Smoke test shows a valid JSON-RPC `initialize` response. Four commits added.
**Key rmcp API:**
- Macros: `#[tool_router]` on `impl`, `#[tool_handler]` on `impl ServerHandler`, `#[tool(description="...")]` on each method
- Parameters: `Parameters(MyParams { field }): Parameters<MyParams>` where `MyParams` derives `Deserialize + JsonSchema`
- Return: `Result<CallToolResult, McpError>` — success via `CallToolResult::success(vec![Content::text("...")])`, error via `McpError::invalid_params("msg", None)`
- Server field: `tool_router: ToolRouter<Self>` initialised with `Self::tool_router()`
- stdio: `QuantumBridgeServer::new().serve(stdio()).await?.waiting().await?`
- Logs go to **stderr** — stdout is the MCP wire
**Main plan reference:** `docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md` Tasks 69.
---
## Task 6: Tool — list_backends
**Files:**
- Create: `src/tools/list_backends.rs`
- Create: `src/tools/mod.rs` (stub, extended in Task 9)
- [ ] **Step 1: Create src/tools/list_backends.rs with failing test**
```rust
use serde_json::json;
use crate::executor::{CanIntrospect, LocalSimulator, 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();
assert_eq!(
resp["backends"][0]["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: Create src/tools/mod.rs (stub)**
```rust
pub mod list_backends;
pub mod run_circuit;
pub mod validate_circuit;
```
- [ ] **Step 3: Declare tools module in src/main.rs**
Add `mod tools;` to `src/main.rs`.
- [ ] **Step 4: Run tests to confirm todo!() panics**
```bash
cargo test tools::list_backends 2>&1 | tail -5
```
- [ ] **Step 5: Implement list_backends_response**
Replace `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": "Statevector memory: 2^n × 16 bytes. Max 28 qubits."
}]
})
}
```
- [ ] **Step 6: Run tests**
```bash
cargo test tools::list_backends 2>&1
```
Expected: 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: Create file with failing test**
```rust
use serde_json::{json, Value};
use crate::executor::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: &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);
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);
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);
assert!(!resp["diagnostics"].as_array().unwrap().is_empty());
}
#[test]
fn unsupported_gate_returns_diagnostic_with_gate_name() {
let circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nu3(0.1,0.2,0.3) q[0];";
let resp = validate_circuit_response(circuit);
assert_eq!(resp["is_valid"], false);
let msg = resp["diagnostics"][0]["message"].as_str().unwrap();
assert!(msg.to_lowercase().contains("u3"), "got: {msg}");
}
#[test]
fn diagnostic_includes_line_and_column() {
let resp = validate_circuit_response("OPENQASM 3.0;\nnot_valid_qasm;\n");
assert!(resp["diagnostics"][0]["line"].as_u64().unwrap() >= 1);
assert!(resp["diagnostics"][0]["column"].as_u64().unwrap() >= 1);
}
}
```
- [ ] **Step 2: Run to confirm todo!() failure**
```bash
cargo test tools::validate_circuit 2>&1 | tail -5
```
- [ ] **Step 3: Implement validate_circuit_response**
```rust
pub fn validate_circuit_response(circuit: &str) -> Value {
use crate::types::DiagnosticSeverity;
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 {
DiagnosticSeverity::Error => "error",
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: 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: Create file with failing test**
```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: &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: &str = r#"OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
bit[1] c;
x q[0];
c = measure q;"#;
#[test]
fn bell_counts_total_matches_shots() {
let resp = run_circuit_response(BELL, 1_000, false);
let total: u64 = resp["counts"].as_object().unwrap().values()
.map(|v| v.as_u64().unwrap()).sum();
assert_eq!(total, 1_000);
}
#[test]
fn bell_only_produces_00_and_11_outcomes() {
let resp = run_circuit_response(BELL, 1_000, false);
for key in resp["counts"].as_object().unwrap().keys() {
assert!(key == "00" || key == "11", "unexpected: {key}");
}
}
#[test]
fn x_gate_produces_only_one_outcome() {
let resp = run_circuit_response(X, 100, false);
assert_eq!(resp["counts"]["1"].as_u64().unwrap(), 100);
}
#[test]
fn response_includes_shots_and_execution_time() {
let resp = run_circuit_response(BELL, 512, false);
assert_eq!(resp["shots"].as_u64().unwrap(), 512);
assert!(resp["execution_time_ms"].as_f64().unwrap() >= 0.0);
}
#[test]
fn statevector_absent_when_not_requested() {
assert!(run_circuit_response(BELL, 100, false)["statevector"].is_null());
}
#[test]
fn statevector_has_correct_length_when_requested() {
let sv = run_circuit_response(BELL, 100, true)["statevector"].as_array().unwrap().len();
assert_eq!(sv, 4);
}
#[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(), "got: {resp}");
}
#[test]
fn shots_clamped_to_max() {
let resp = run_circuit_response(BELL, ShotCount::MAX.0 + 1, false);
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 to confirm todo!() failure**
```bash
cargo test tools::run_circuit 2>&1 | tail -5
```
- [ ] **Step 3: Implement run_circuit_response**
```rust
pub fn run_circuit_response(circuit: &str, shots: u32, return_statevector: bool) -> Value {
use crate::executor::{CanExecute, LocalSimulator, MAX_LOCAL_QUBITS};
use crate::validator::CircuitValidator;
let shot_count = ShotCount(shots.min(ShotCount::MAX.0));
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 msgs: Vec<&str> = validation.diagnostics.iter().map(|d| d.message.as_str()).collect();
return json!({"error": format!("validation failed: {}", msgs.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: 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 + rmcp wiring
**Files:**
- Modify: `src/tools/mod.rs` (replace stub with full QuantumBridgeServer)
- Modify: `src/main.rs` (replace stub with async tokio main)
- [ ] **Step 1: Replace src/tools/mod.rs with full server implementation**
```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,
/// Return 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 = "Validate an OpenQASM 3.0 circuit. Returns structured diagnostics with line/column.")]
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 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. Accepts OpenQASM 3.0. \
Run validate_circuit first to get actionable errors.".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: passes. If macro names are wrong (`#[tool_router]` / `#[tool_handler]`), check `~/.cargo/git/checkouts/rust-sdk-*/examples/servers/src/common/` for the canonical pattern.
- [ ] **Step 3: Replace src/main.rs with async server entry point**
```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<()> {
// 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: Build the binary**
```bash
cargo build 2>&1
```
Expected: compiles without errors.
- [ ] **Step 5: Smoke test**
```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: JSON response with `"result"."serverInfo"."name":"quantum-bridge-mcp"`. Press Ctrl+C to stop.
- [ ] **Step 6: Run full test suite**
```bash
cargo test 2>&1
```
Expected: all tests pass (3 list_backends + 5 validate_circuit + 8 run_circuit + 1 server construction + 7 executor + 5 validator).
- [ ] **Step 7: 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"
```
---
## Final verification
```bash
cargo fmt --check && cargo clippy -- -D warnings && cargo test
```
Expected: all green. Sub-plan 4 complete — hand off to sub-plan 5.
+802
View File
@@ -0,0 +1,802 @@
# Sub-plan 5 — Quality & CI (Tasks 1014)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Integration tests (JSON-RPC roundtrip), property-based tests, Criterion benchmarks, Qiskit cross-validation golden files, and GitHub Actions CI + release workflows.
**Starting state (after sub-plan 4):**
- Full working MCP server binary
- All tool handlers implemented and unit-tested
- `cargo test` passes, `cargo build` succeeds
**Deliverable:** `cargo test` passes all test suites. `cargo bench` compiles. CI YAML is valid. Five commits added.
**Important — lib target:** Tasks 1112 need `use quantum_bridge_mcp::...` from `tests/`. This requires adding `src/lib.rs` and a `[lib]` entry in `Cargo.toml`. Task 11 includes these steps.
**Main plan reference:** `docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md` Tasks 1014.
---
## Task 10: Integration Tests — MCP protocol roundtrip
**Files:**
- Create: `tests/integration/mod.rs`
- Create: `tests/integration/mcp_protocol.rs`
- [ ] **Step 1: Create test directory**
```bash
mkdir -p tests/integration
```
- [ ] **Step 2: Create tests/integration/mod.rs**
```rust
// Integration test module — empty file for cargo test discovery.
```
- [ ] **Step 3: Create tests/integration/mcp_protocol.rs**
```rust
//! Roundtrip: spawn binary, send JSON-RPC 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 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"}}}"#);
serde_json::from_str(&proc.recv_line()).expect("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 = "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_x_gate_returns_100_ones() {
let x_circ = "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:** Uses `env!("CARGO_BIN_EXE_quantum-bridge-mcp")` — cargo sets this automatically. Run with `cargo test --test integration`. If rmcp uses length-prefixed framing instead of newlines, adjust `recv_line` to skip the header bytes.
- [ ] **Step 4: Run integration tests**
```bash
cargo test --test integration 2>&1
```
Expected: all 5 pass.
- [ ] **Step 5: 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 + lib target
**Files:**
- Create: `src/lib.rs`
- Modify: `Cargo.toml` (add `[lib]` section)
- Modify: `src/main.rs` (remove mod declarations now in lib.rs)
- Create: `tests/proptest/mod.rs`
- Create: `tests/proptest/invariants.rs`
- [ ] **Step 1: Create src/lib.rs**
```rust
pub mod error;
pub mod executor;
pub mod types;
pub mod validator;
pub(crate) mod tools;
```
- [ ] **Step 2: Add [lib] to Cargo.toml**
```toml
[lib]
name = "quantum_bridge_mcp"
path = "src/lib.rs"
```
- [ ] **Step 3: Update src/main.rs — remove mod declarations, use crate name**
```rust
use anyhow::Result;
use quantum_bridge_mcp::tools::QuantumBridgeServer;
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(())
}
```
- [ ] **Step 4: Verify cargo test still passes**
```bash
cargo test 2>&1
```
Expected: all existing tests still pass.
- [ ] **Step 5: Create proptest files**
```bash
mkdir -p tests/proptest
```
`tests/proptest/mod.rs`:
```rust
// Property-based test module.
```
`tests/proptest/invariants.rs`:
```rust
//! Quantum mechanics invariants that must hold for any valid circuit.
use proptest::prelude::*;
use quantum_bridge_mcp::executor::{CanExecute, LocalSimulator};
use quantum_bridge_mcp::types::{CircuitSource, ShotCount};
proptest! {
/// 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 (±1e-9).
#[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 length equal to the number of qubits.
#[test]
fn count_bitstrings_have_correct_length(
n_qubits in 1usize..=5usize,
shots in 10u32..=100u32,
) {
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"
);
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, "wrong length for key '{key}'");
}
}
}
```
- [ ] **Step 6: Run proptest**
```bash
cargo test --test proptest 2>&1
```
Expected: 3 property tests pass (each runs 256 cases).
- [ ] **Step 7: Commit**
```bash
git add src/lib.rs src/main.rs Cargo.toml tests/proptest/
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 bench file**
```bash
mkdir -p benches
```
`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: &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: usize, shots: u32) -> (CircuitSource, ShotCount) {
let gates: String = (0..n).map(|i| format!("h q[{i}];\n")).collect();
(
CircuitSource(format!(
"OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n}] q;\nbit[{n}] c;\n{gates}c = measure q;\n"
)),
ShotCount(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.to_string()), ShotCount(1024), false).unwrap())
});
}
fn bench_10_qubits_10k_shots(c: &mut Criterion) {
let sim = LocalSimulator::new();
let (circ, shots) = make_h_circuit(10, 10_000);
c.bench_function("10_qubits_10k_shots", |b| {
b.iter(|| sim.run(&circ, shots, false).unwrap())
});
}
fn bench_20_qubits_10k_shots(c: &mut Criterion) {
let sim = LocalSimulator::new();
let (circ, shots) = make_h_circuit(20, 10_000);
c.bench_function("20_qubits_10k_shots", |b| {
b.iter(|| sim.run(&circ, 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 (smoke pass — verifies they compile and run)**
```bash
cargo bench -- --test 2>&1
```
Expected: all three benchmark functions report a timing without panicking.
- [ ] **Step 3: Run full benchmarks to check SLAs**
```bash
cargo bench 2>&1 | grep -E "bell|qubits"
```
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 likely `extract_gate_ops` allocating on every call. Check if the AST is being re-parsed unnecessarily — consider caching or pre-converting on first call.
- [ ] **Step 4: 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 + Cross-Validation
**Files:**
- Create: `tests/reference/bell.qasm`
- Create: `tests/reference/ghz3.qasm`
- Create: `tests/reference/invalid/` (4 × .qasm + .error.txt pairs)
- Create: `scripts/gen_reference.py`
- Create: `tests/reference/cross_validate.rs`
- [ ] **Step 1: Create directory structure and 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 scripts/gen_reference.py**
```python
#!/usr/bin/env python3
"""Generate reference counts/statevectors via Qiskit Aer. Requires: pip install qiskit qiskit-aer"""
import json, 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")
counts = sim.run(qc, shots=SHOTS, seed_simulator=SEED).result().get_counts()
qc_sv = QuantumCircuit.from_qasm_file(str(qasm_path))
qc_sv.save_statevector()
sv = list(sim.run(qc_sv, shots=0).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],
}
if __name__ == "__main__":
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 tests/reference/cross_validate.rs**
```rust
//! Cross-validates LocalSimulator counts against Qiskit golden files (chi-squared, α=0.01).
use std::collections::HashMap;
use std::path::Path;
use quantum_bridge_mcp::executor::{CanExecute, LocalSimulator, MAX_LOCAL_QUBITS};
use quantum_bridge_mcp::types::{CircuitSource, ShotCount};
use quantum_bridge_mcp::validator::CircuitValidator;
fn load_circuit(name: &str) -> CircuitSource {
CircuitSource(std::fs::read_to_string(Path::new("tests/reference").join(name)).unwrap())
}
fn load_expected_counts(name: &str) -> HashMap<String, u64> {
let path = Path::new("tests/reference").join(name);
if !path.exists() { return HashMap::new(); }
let data: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).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>) -> bool {
if expected.is_empty() { return true; }
let chi_sq: f64 = expected.iter().map(|(k, &exp)| {
let obs = *observed.get(k).unwrap_or(&0) as f64;
let e = exp as f64;
if e == 0.0 { 0.0 } else { (obs - e).powi(2) / e }
}).sum();
let critical = (expected.len() - 1) as f64 * 3.0 + 6.63; // conservative
chi_sq < critical
}
#[test]
fn bell_counts_match_qiskit_golden() {
let expected = load_expected_counts("bell_counts.json");
if expected.is_empty() { return; }
let result = LocalSimulator::new().run(&load_circuit("bell.qasm"), ShotCount(10_000), false).unwrap();
assert!(chi_squared_passes(&result.counts, &expected),
"Bell diverges.\nSpinoza: {:?}\nQiskit: {:?}", result.counts, expected);
}
#[test]
fn ghz3_counts_match_qiskit_golden() {
let expected = load_expected_counts("ghz3_counts.json");
if expected.is_empty() { return; }
let result = LocalSimulator::new().run(&load_circuit("ghz3.qasm"), ShotCount(10_000), false).unwrap();
assert!(chi_squared_passes(&result.counts, &expected),
"GHZ-3 diverges.\nSpinoza: {:?}\nQiskit: {:?}", result.counts, expected);
}
#[test]
fn invalid_circuits_produce_validation_errors() {
use std::fs;
let validator = CircuitValidator::new(MAX_LOCAL_QUBITS);
for entry in fs::read_dir("tests/reference/invalid").unwrap() {
let path = entry.unwrap().path();
if path.extension().map(|e| e == "qasm").unwrap_or(false) {
let expected_frag = fs::read_to_string(path.with_extension("error.txt"))
.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 msgs: String = result.diagnostics.iter().map(|d| d.message.to_lowercase()).collect::<Vec<_>>().join(" ");
assert!(msgs.contains(&expected_frag),
"expected '{}' in messages for {}\nGot: {}", expected_frag, path.display(), msgs);
}
}
}
```
- [ ] **Step 4: Run 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** (no JSON yet); `invalid_circuits_produce_validation_errors` **passes**.
Optional — generate golden files (needs Python + Qiskit):
```bash
python3 scripts/gen_reference.py && cargo test --test cross_validate
```
- [ ] **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: GitHub Actions CI + Release
**Files:**
- Create: `.github/workflows/ci.yml`
- Create: `.github/workflows/release.yml`
- [ ] **Step 1: Create .github/workflows/ci.yml**
```bash
mkdir -p .github/workflows
```
```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
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- run: cargo fmt --check
- run: cargo clippy -- -D warnings
- run: cargo test
- run: cargo bench -- --test
cross-validate:
name: Cross-validate with Qiskit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-qiskit
- run: pip install qiskit qiskit-aer
- run: python3 scripts/gen_reference.py
- run: cargo test --test cross_validate
```
- [ ] **Step 2: Create .github/workflows/release.yml**
```yaml
name: Release
on:
push:
tags:
- "v*"
jobs:
dist:
name: Build ${{ matrix.target }}
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
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross (aarch64 Linux)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: cargo install cross
- name: Build
shell: bash
run: |
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
cross build --release --target ${{ matrix.target }}
else
cargo build --release --target ${{ matrix.target }}
fi
- 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'
shell: pwsh
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 ../../../
- uses: actions/upload-artifact@v4
with:
name: quantum-bridge-mcp-${{ matrix.target }}
path: quantum-bridge-mcp-${{ matrix.target }}.*
github-release:
needs: dist
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: "quantum-bridge-mcp-*"
generate_release_notes: true
```
- [ ] **Step 3: Validate YAML**
```bash
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo "ci.yml OK"
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" && echo "release.yml OK"
```
- [ ] **Step 4: Full local verification pass**
```bash
cargo fmt --check && cargo clippy -- -D warnings && cargo test 2>&1 | tail -10
```
Expected: all green.
- [ ] **Step 5: Commit**
```bash
git add .github/
git commit -m "ci: add GitHub Actions for Rust checks, Qiskit cross-validation, and multi-platform release"
```
---
## Final verification — all sub-plans complete
```bash
cargo fmt --check \
&& cargo clippy -- -D warnings \
&& cargo test \
&& cargo test --test integration \
&& cargo test --test proptest \
&& cargo test --test cross_validate \
&& cargo bench -- --test \
&& echo "ALL CHECKS PASSED"
```
Expected: `ALL CHECKS PASSED`. V1 implementation complete.