Initial import
This commit is contained in:
@@ -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, ®ister_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 (1–7).
|
||||
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.
|
||||
Reference in New Issue
Block a user