Initial import
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
struct McpProcess {
|
||||
child: Child,
|
||||
reader: BufReader<std::process::ChildStdout>,
|
||||
}
|
||||
|
||||
impl McpProcess {
|
||||
fn spawn() -> Self {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_quantum-bridge-mcp"))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn quantum-bridge-mcp binary");
|
||||
let stdout = child.stdout.take().expect("failed to take stdout");
|
||||
let reader = BufReader::new(stdout);
|
||||
Self { child, reader }
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: &str) {
|
||||
let stdin = self.child.stdin.as_mut().expect("failed to get stdin");
|
||||
writeln!(stdin, "{}", msg).expect("failed to write to stdin");
|
||||
stdin.flush().expect("failed to flush stdin");
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Value {
|
||||
let mut line = String::new();
|
||||
self.reader
|
||||
.read_line(&mut line)
|
||||
.expect("failed to read line from stdout");
|
||||
serde_json::from_str(line.trim()).expect("failed to parse JSON response")
|
||||
}
|
||||
|
||||
/// Send initialize and consume the response, returning self for chaining.
|
||||
fn initialize(&mut self) -> &mut Self {
|
||||
self.send(
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}"#,
|
||||
);
|
||||
let _ = self.recv();
|
||||
self
|
||||
}
|
||||
|
||||
/// Extract the text payload from a tools/call response content array.
|
||||
fn tool_text(response: &Value) -> Value {
|
||||
let text = response["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("expected text field in content[0]");
|
||||
serde_json::from_str(text).expect("failed to parse tool response text as JSON")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_returns_server_info() {
|
||||
let mut proc = McpProcess::spawn();
|
||||
proc.send(
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}"#,
|
||||
);
|
||||
let resp = proc.recv();
|
||||
|
||||
assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
|
||||
assert!(
|
||||
resp["result"]["serverInfo"]["name"].is_string(),
|
||||
"serverInfo.name should be a string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_list_contains_core_tools() {
|
||||
let mut proc = McpProcess::spawn();
|
||||
proc.initialize();
|
||||
proc.send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#);
|
||||
let resp = proc.recv();
|
||||
|
||||
let tools = resp["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("tools should be an array");
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().expect("tool name should be a string"))
|
||||
.collect();
|
||||
assert!(names.contains(&"list_backends"));
|
||||
assert!(names.contains(&"validate_circuit"));
|
||||
assert!(names.contains(&"run_circuit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_list_backends_returns_local_simulator() {
|
||||
let mut proc = McpProcess::spawn();
|
||||
proc.initialize();
|
||||
proc.send(
|
||||
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_backends","arguments":{}}}"#,
|
||||
);
|
||||
let resp = proc.recv();
|
||||
|
||||
let payload = McpProcess::tool_text(&resp);
|
||||
let backends = payload["backends"]
|
||||
.as_array()
|
||||
.expect("backends should be an array");
|
||||
assert!(!backends.is_empty(), "expected at least one backend");
|
||||
assert_eq!(
|
||||
backends[0]["name"], "local_simulator",
|
||||
"first backend name should be local_simulator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_validate_circuit_with_valid_bell_returns_is_valid_true() {
|
||||
let bell_circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;";
|
||||
let params = serde_json::json!({
|
||||
"name": "validate_circuit",
|
||||
"arguments": { "circuit": bell_circuit }
|
||||
});
|
||||
let request = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": params
|
||||
});
|
||||
|
||||
let mut proc = McpProcess::spawn();
|
||||
proc.initialize();
|
||||
proc.send(&request.to_string());
|
||||
let resp = proc.recv();
|
||||
|
||||
let payload = McpProcess::tool_text(&resp);
|
||||
assert_eq!(
|
||||
payload["is_valid"], true,
|
||||
"Bell circuit should be valid, got: {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_run_circuit_with_x_gate_returns_count_of_1_for_all_shots() {
|
||||
let x_circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;";
|
||||
let params = serde_json::json!({
|
||||
"name": "run_circuit",
|
||||
"arguments": { "circuit": x_circuit, "shots": 100 }
|
||||
});
|
||||
let request = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": params
|
||||
});
|
||||
|
||||
let mut proc = McpProcess::spawn();
|
||||
proc.initialize();
|
||||
proc.send(&request.to_string());
|
||||
let resp = proc.recv();
|
||||
|
||||
let payload = McpProcess::tool_text(&resp);
|
||||
let counts = payload["counts"]
|
||||
.as_object()
|
||||
.expect("counts should be an object");
|
||||
assert_eq!(
|
||||
counts.get("1").and_then(|v| v.as_u64()),
|
||||
Some(100),
|
||||
"all 100 shots should produce outcome '1', got counts: {counts:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Quantum mechanics invariants: any valid circuit must preserve unitarity and normalisation.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use quantum_bridge_mcp::executor::{CanExecute, LocalSimulator};
|
||||
use quantum_bridge_mcp::types::{CircuitSource, ShotCount};
|
||||
|
||||
proptest! {
|
||||
/// Normalisation: shot counts always sum to exactly the requested number of shots.
|
||||
#[test]
|
||||
fn shot_counts_always_sum_to_requested_shots(
|
||||
shots in 1u32..=1_000u32
|
||||
) {
|
||||
let circuit = r#"OPENQASM 3.0;
|
||||
include "stdgates.inc";
|
||||
qubit[2] q;
|
||||
bit[2] c;
|
||||
h q[0];
|
||||
cx q[0], q[1];
|
||||
c = measure q;"#;
|
||||
let sim = LocalSimulator::new();
|
||||
let result = sim
|
||||
.run(&CircuitSource(circuit.to_string()), ShotCount(shots), false)
|
||||
.unwrap();
|
||||
let total: u64 = result.counts.values().sum();
|
||||
prop_assert_eq!(total, shots as u64);
|
||||
}
|
||||
|
||||
/// Statevector norm is always 1.0 within floating-point tolerance.
|
||||
#[test]
|
||||
fn statevector_norm_is_one(
|
||||
angle in -std::f64::consts::PI..=std::f64::consts::PI
|
||||
) {
|
||||
let circuit = format!(
|
||||
"OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nrx({angle}) q[0];\nc = measure q;"
|
||||
);
|
||||
let sim = LocalSimulator::new();
|
||||
let result = sim
|
||||
.run(&CircuitSource(circuit), ShotCount(1), true)
|
||||
.unwrap();
|
||||
let sv = result.statevector.unwrap();
|
||||
let norm: f64 = sv.iter().map(|(r, i)| r * r + i * i).sum();
|
||||
prop_assert!((norm - 1.0).abs() < 1e-9, "norm={norm}");
|
||||
}
|
||||
|
||||
/// Bitstrings in counts have correct length (= number of qubits).
|
||||
#[test]
|
||||
fn count_bitstrings_have_correct_length(
|
||||
n_qubits in 1usize..=5usize,
|
||||
shots in 10u32..=100u32
|
||||
) {
|
||||
let qubit_decls: String = (0..n_qubits).map(|i| format!("h q[{i}];\n")).collect();
|
||||
let circuit = format!(
|
||||
"OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n_qubits}] q;\nbit[{n_qubits}] c;\n{qubit_decls}c = measure q;\n"
|
||||
);
|
||||
let sim = LocalSimulator::new();
|
||||
let result = sim
|
||||
.run(&CircuitSource(circuit), ShotCount(shots), false)
|
||||
.unwrap();
|
||||
for key in result.counts.keys() {
|
||||
prop_assert_eq!(
|
||||
key.len(), n_qubits,
|
||||
"bitstring '{}' has wrong length for {}-qubit circuit",
|
||||
key, n_qubits
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"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;"}
|
||||
@@ -0,0 +1,9 @@
|
||||
{"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[2] c;\nx q[1];\nh q[0];\ncz q[0], q[1];\nh q[0];\nc = measure q;"}
|
||||
{"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;"}
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct McpProcess {
|
||||
child: Child,
|
||||
reader: BufReader<std::process::ChildStdout>,
|
||||
_tmp: TempDir,
|
||||
}
|
||||
|
||||
impl McpProcess {
|
||||
fn spawn() -> Self {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let progress_path = tmp.path().join("progress.json");
|
||||
let mut 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");
|
||||
let stdout = child.stdout.take().expect("take stdout");
|
||||
let reader = BufReader::new(stdout);
|
||||
Self { child, reader, _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 mut line = String::new();
|
||||
self.reader.read_line(&mut line).unwrap();
|
||||
serde_json::from_str(line.trim()).expect("response is JSON")
|
||||
}
|
||||
|
||||
fn initialize(&mut self) {
|
||||
self.send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}"#);
|
||||
let _ = self.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()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_list_exposes_seven_tools() {
|
||||
let mut p = McpProcess::spawn();
|
||||
p.initialize();
|
||||
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();
|
||||
p.initialize();
|
||||
let circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;";
|
||||
let call = serde_json::json!({
|
||||
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||
"params": {"name": "check_exercise", "arguments": {"exercise_id": "1-1-a", "circuit": circuit}}
|
||||
});
|
||||
p.send(&call.to_string());
|
||||
let payload = McpProcess::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 = McpProcess::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();
|
||||
p.initialize();
|
||||
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 = McpProcess::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();
|
||||
p.initialize();
|
||||
let call = serde_json::json!({
|
||||
"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.to_string());
|
||||
let payload = McpProcess::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();
|
||||
p.initialize();
|
||||
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 = serde_json::json!({
|
||||
"jsonrpc": "2.0", "id": 99, "method": "tools/call",
|
||||
"params": {"name": "check_exercise", "arguments": {"exercise_id": id, "circuit": circuit}}
|
||||
});
|
||||
p.send(&call.to_string());
|
||||
let payload = McpProcess::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();
|
||||
p.initialize();
|
||||
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 = serde_json::json!({
|
||||
"jsonrpc": "2.0", "id": 98, "method": "tools/call",
|
||||
"params": {"name": "check_exercise", "arguments": {"exercise_id": id, "circuit": circuit}}
|
||||
});
|
||||
p.send(&call.to_string());
|
||||
let payload = McpProcess::extract(&p.recv());
|
||||
assert_eq!(payload["passed"].as_bool().unwrap_or(true), false, "id={id} should fail");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user