diff --git a/README.md b/README.md index 3e26979b..ea1e6f0b 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,10 @@ cargo run -p quonc -- examples/na_qec/repetition_d3_memory.qn \ # Inspect the compiler pipeline or dump intermediate IR. cargo run -p quonc -- --list-passes cargo run -p quonc -- test/verify/bell.qn --dump-ir --emit-qasm + +# qLDPC resource model — standalone, no source or target required (#478). +cargo run -p quonc -- --qldpc-graph examples/na_qec/qldpc_5qubit.json --emit-qldpc-report - +cargo run -p quonc -- --qldpc-net-rate 1/24 --qldpc-logical-qubits 12 --emit-qldpc-report - ``` See the [quonc CLI reference](https://quon.arnabg.me/reference/quonc/) for diff --git a/docs/neutral_atom/architecture_model.md b/docs/neutral_atom/architecture_model.md index 7750203a..dd89c143 100644 --- a/docs/neutral_atom/architecture_model.md +++ b/docs/neutral_atom/architecture_model.md @@ -473,6 +473,17 @@ atom arrays (e.g. 400 logical / 19 600 physical, a 6.9× saving over surface code at p = 10⁻³) — the reason this family is worth modeling on this backend at all. +This family is reviewer-runnable from `quonc` (#478): the net-rate sizing mode +computes `atoms_per_logical` without a source file or backend target: + +```bash +quonc --qldpc-net-rate 1/24 --qldpc-logical-qubits 12 --emit-qldpc-report - +# → atoms_per_logical: 24, logical_qubits: 12, physical_atoms: 288 +``` + +The graph-based estimate (`--qldpc-graph `) emits check weight, +connectivity, movement pressure, and peak atoms from a parity-check graph. + ### 10.4 `AbstractBlockCode` ``` diff --git a/docs/neutral_atom/qldpc_workload_ir.md b/docs/neutral_atom/qldpc_workload_ir.md index d3ad2c93..6db7c418 100644 --- a/docs/neutral_atom/qldpc_workload_ir.md +++ b/docs/neutral_atom/qldpc_workload_ir.md @@ -47,3 +47,26 @@ Unsupported features fail clearly with actionable diagnostics. - `toy_5qubit_graph()` — [[5,1,3]] code (4 checks, weight 5) - `toy_repetition_graph(d)` — repetition code (d-1 checks, weight 2) + +## Reviewer-runnable CLI (#478) + +The qLDPC resource model is reachable from `quonc` without a source file or +backend target — it skips the compile pipeline entirely. + +Graph mode loads a parity-check graph JSON and emits the full estimate: + +```bash +quonc --qldpc-graph examples/na_qec/qldpc_5qubit.json --emit-qldpc-report - +``` + +Net-rate sizing mode computes `atoms_per_logical = ceil(1/r)` for a +`[[144,12,12]]`-style family (12 logical, rate 1/24 → 24 atoms/logical, 288 +physical): + +```bash +quonc --qldpc-net-rate 1/24 --qldpc-logical-qubits 12 --emit-qldpc-report - +``` + +Both emit JSON by default; a `.md` path extension switches to Markdown. The +emitted report is an analytic estimate — not sampled data and not a threshold +claim (ADR-0020). diff --git a/examples/na_qec/qldpc_5qubit.json b/examples/na_qec/qldpc_5qubit.json new file mode 100644 index 00000000..9a0f79f4 --- /dev/null +++ b/examples/na_qec/qldpc_5qubit.json @@ -0,0 +1,27 @@ +{ + "n_data": 5, + "n_checks": 4, + "distance": 3, + "checks": [ + { + "check_id": 0, + "basis": "z", + "data_qubits": [0, 1, 2, 3, 4] + }, + { + "check_id": 1, + "basis": "z", + "data_qubits": [0, 1, 2, 3, 4] + }, + { + "check_id": 2, + "basis": "x", + "data_qubits": [0, 1, 2, 3, 4] + }, + { + "check_id": 3, + "basis": "x", + "data_qubits": [0, 1, 2, 3, 4] + } + ] +} \ No newline at end of file diff --git a/quonc/src/main.rs b/quonc/src/main.rs index 1fff3e1f..c42f7c67 100644 --- a/quonc/src/main.rs +++ b/quonc/src/main.rs @@ -81,6 +81,10 @@ Examples: # Inspect a target without compiling quonc --target targets/neutral_atom/generic_rna_v0.json --print-target + # qLDPC resource model (#478): standalone, no source or target required + quonc --qldpc-graph examples/na_qec/qldpc_5qubit.json --emit-qldpc-report - + quonc --qldpc-net-rate 1/24 --qldpc-logical-qubits 12 --emit-qldpc-report - + Notes: Fixed targets run SABRE routing and emit OpenQASM 3.0. Neutral-atom targets extract an interaction graph, schedule entangling @@ -395,6 +399,48 @@ struct Cli { /// Path to `quon_qec_sinter.py` (default: search up from CWD for `python/`) #[arg(long, value_name = "PATH", help_heading = "QEC validation")] sinter_harness: Option, + + // ── qLDPC resource model (#478) ───────────────────────────────────── + /// qLDPC parity-check graph JSON (compiler resource-model estimate, not + /// a decoder or threshold claim). Skips the compile pipeline; use with + /// `--emit-qldpc-report`. No source file or backend target required. + #[arg(long, value_name = "PATH", help_heading = "qLDPC resource model")] + qldpc_graph: Option, + + /// qLDPC net rate `n/d` for sizing-only mode (e.g. `1/24` for a + /// [[144,12,12]]-style family). Computes `atoms_per_logical = ceil(d/n)` + /// without a parity-check graph; use with `--emit-qldpc-report`. + #[arg(long, value_name = "RATE", help_heading = "qLDPC resource model")] + qldpc_net_rate: Option, + + /// Emit qLDPC resource estimate (`-` = stdout; `.md` → Markdown, else + /// JSON). Analytic estimate — not sampled data, not a threshold claim + /// (ADR-0020). + #[arg(long, value_name = "PATH", help_heading = "qLDPC resource model")] + emit_qldpc_report: Option, + + /// Syndrome-extraction measurement rounds to model (graph mode) + #[arg( + long, + default_value_t = 1, + value_name = "N", + help_heading = "qLDPC resource model" + )] + qldpc_rounds: u32, + + /// Atom grid width for movement-pressure estimation (graph mode; default: + /// ceil(sqrt(n_data))) + #[arg(long, value_name = "W", help_heading = "qLDPC resource model")] + qldpc_grid_width: Option, + + /// Logical qubit count for net-rate sizing mode (default: 1) + #[arg( + long, + default_value_t = 1, + value_name = "N", + help_heading = "qLDPC resource model" + )] + qldpc_logical_qubits: u32, } #[derive(Clone, Copy, Debug, ValueEnum)] @@ -540,6 +586,12 @@ fn run() -> Result { return Ok(ExitCode::SUCCESS); } + // qLDPC resource-model path (#478): skips the compile pipeline entirely; + // no source file or backend target required. + if cli.qldpc_graph.is_some() || cli.qldpc_net_rate.is_some() { + return run_qldpc_resource_model(&cli); + } + let target = load_target(cli.target.as_ref())?; if cli.print_target { @@ -1277,6 +1329,178 @@ fn write_output(path: &str, body: &str, prefer_stderr: bool) -> Result<()> { Ok(()) } +/// Run the qLDPC resource-model path (#478). Skips the compile pipeline; +/// loads a parity-check graph (or a net-rate sizing spec) and emits an +/// analytic resource estimate. Not a decoder, not a threshold claim (ADR-0020). +fn run_qldpc_resource_model(cli: &Cli) -> Result { + use quon_qec::qldpc::{ParityCheckGraph, QldpcResourceEstimate}; + use quon_qec::{CodeFamily, NetRate, atoms_per_logical}; + + let out_path = cli.emit_qldpc_report.as_deref().ok_or_else(|| { + anyhow!( + "--qldpc-graph / --qldpc-net-rate requires --emit-qldpc-report \ + (`-` = stdout; `.md` → Markdown, else JSON)" + ) + })?; + if cli.qldpc_graph.is_some() && cli.qldpc_net_rate.is_some() { + bail!("--qldpc-graph and --qldpc-net-rate are mutually exclusive"); + } + + let (json, md) = if let Some(graph_path) = &cli.qldpc_graph { + let raw = std::fs::read_to_string(graph_path) + .with_context(|| format!("read qLDPC graph {}", graph_path.display()))?; + let graph: ParityCheckGraph = + serde_json::from_str(&raw).context("parse qLDPC parity-check graph JSON")?; + graph.validate().map_err(|e| anyhow!("{e}"))?; + let grid_width = cli + .qldpc_grid_width + .unwrap_or_else(|| (graph.n_data as f64).sqrt().ceil().max(1.0) as u32); + let estimate = QldpcResourceEstimate::estimate(&graph, cli.qldpc_rounds, grid_width) + .map_err(|e| anyhow!("{e}"))?; + qldpc_graph_report(&estimate)? + } else if let Some(rate_str) = &cli.qldpc_net_rate { + let (num, den) = parse_qldpc_net_rate(rate_str)?; + let family = CodeFamily::HighRateQldpcLike { + net_rate: NetRate { + numerator: num, + denominator: den, + }, + }; + let atoms_per_logical = atoms_per_logical(&family).map_err(|e| anyhow!("{e}"))?; + let n_logical = cli.qldpc_logical_qubits; + let physical_atoms = atoms_per_logical + .checked_mul(n_logical) + .ok_or_else(|| anyhow!("physical atom count overflowed u32"))?; + qldpc_sizing_report(num, den, atoms_per_logical, n_logical, physical_atoms)? + } else { + bail!("internal error: qLDPC mode dispatch reached neither graph nor net-rate branch"); + }; + + let text = match resolve_report_format(cli, out_path) { + ReportFormat::Json => &json, + ReportFormat::Markdown => &md, + }; + write_output(out_path, text, false)?; + if !cli.quiet { + let dim = dim_style(); + eprintln!("{dim}qLDPC resource estimate emitted (analytic; not a threshold claim){dim:#}"); + } + Ok(ExitCode::SUCCESS) +} + +/// Parse a net-rate string `n/d` (e.g. `1/24`). +fn parse_qldpc_net_rate(s: &str) -> Result<(u32, u32)> { + let (num_str, den_str) = s + .split_once('/') + .ok_or_else(|| anyhow!("--qldpc-net-rate must be `n/d` (e.g. `1/24`), got `{s}`"))?; + let num: u32 = num_str + .trim() + .parse() + .with_context(|| format!("net-rate numerator `{num_str}` is not a u32"))?; + let den: u32 = den_str + .trim() + .parse() + .with_context(|| format!("net-rate denominator `{den_str}` is not a u32"))?; + if num == 0 { + bail!("--qldpc-net-rate numerator must be > 0"); + } + if den == 0 { + bail!("--qldpc-net-rate denominator must be > 0"); + } + Ok((num, den)) +} + +/// Build the JSON + Markdown report for the graph-based qLDPC resource estimate. +fn qldpc_graph_report( + estimate: &quon_qec::qldpc::QldpcResourceEstimate, +) -> Result<(String, String)> { + let value = serde_json::json!({ + "evidence_kind": "analytic", + "evidence_disclaimer": "Compiler analytic estimate — not sampled data and not a threshold claim (ADR-0020).", + "mode": "parity_check_graph", + "estimate": { + "n_data": estimate.n_data, + "n_checks": estimate.n_checks, + "distance": estimate.distance, + "max_check_weight": estimate.max_check_weight, + "avg_check_weight": estimate.avg_check_weight, + "edge_count": estimate.edge_count, + "measurement_rounds": estimate.measurement_rounds, + "movement_pressure": estimate.movement_pressure, + "peak_atoms": estimate.peak_atoms, + "estimated_cycles_per_round": estimate.estimated_cycles_per_round, + }, + }); + let json = serde_json::to_string_pretty(&value) + .map_err(|e| anyhow!("serialize qLDPC graph report: {e}"))?; + let md = format!( + "# qLDPC resource estimate (parity-check graph)\n\n\ + > Compiler analytic estimate — not sampled data and not a threshold claim (ADR-0020).\n\n\ + ## Code structure\n\ + | Metric | Value |\n| --- | ---: |\n\ + | Data qubits | {} |\n\ + | Check ancillas | {} |\n\ + | Code distance | {} |\n\ + | Peak atoms | {} |\n\n\ + ## Connectivity\n\ + | Metric | Value |\n| --- | ---: |\n\ + | Max check weight | {} |\n\ + | Avg check weight | {} |\n\ + | Total edges (CNOTs/round) | {} |\n\n\ + ## Syndrome extraction\n\ + | Metric | Value |\n| --- | ---: |\n\ + | Measurement rounds | {} |\n\ + | Est. cycles/round (Z-then-X) | {} |\n\ + | Movement pressure (Manhattan sum/edge) | {} |\n", + estimate.n_data, + estimate.n_checks, + estimate.distance, + estimate.peak_atoms, + estimate.max_check_weight, + estimate.avg_check_weight, + estimate.edge_count, + estimate.measurement_rounds, + estimate.estimated_cycles_per_round, + estimate.movement_pressure, + ); + Ok((json, md)) +} + +/// Build the JSON + Markdown report for the net-rate sizing-only mode. +fn qldpc_sizing_report( + num: u32, + den: u32, + atoms_per_logical: u32, + n_logical: u32, + physical_atoms: u32, +) -> Result<(String, String)> { + let value = serde_json::json!({ + "evidence_kind": "analytic", + "evidence_disclaimer": "Compiler analytic estimate — not sampled data and not a threshold claim (ADR-0020).", + "mode": "net_rate_sizing", + "code_family": "high_rate_qldpc_like", + "net_rate": { "numerator": num, "denominator": den }, + "atoms_per_logical": atoms_per_logical, + "logical_qubits": n_logical, + "physical_atoms": physical_atoms, + }); + let json = serde_json::to_string_pretty(&value) + .map_err(|e| anyhow!("serialize qLDPC sizing report: {e}"))?; + let md = format!( + "# qLDPC resource estimate (net-rate sizing)\n\n\ + > Compiler analytic estimate — not sampled data and not a threshold claim (ADR-0020).\n\n\ + ## Sizing\n\ + | Metric | Value |\n| --- | ---: |\n\ + | Code family | high_rate_qldpc_like |\n\ + | Net rate (k/n) | {}/{} |\n\ + | Atoms per logical | {} |\n\ + | Logical qubits | {} |\n\ + | Physical atoms | {} |\n", + num, den, atoms_per_logical, n_logical, physical_atoms, + ); + Ok((json, md)) +} + fn print_pass_list() { println!( "\ diff --git a/quonc/tests/qldpc_resource_model.rs b/quonc/tests/qldpc_resource_model.rs new file mode 100644 index 00000000..93d40adc --- /dev/null +++ b/quonc/tests/qldpc_resource_model.rs @@ -0,0 +1,195 @@ +//! End-to-end `--qldpc-graph` / `--qldpc-net-rate` tests (issue #478). +//! +//! The qLDPC resource model skips the compile pipeline, so these tests do not +//! pass a source file or a backend target — they exercise the standalone +//! resource-estimate path directly. + +use std::path::PathBuf; + +use serde_json::Value; +use std::process::Command; + +fn quonc() -> Command { + Command::new(env!("CARGO_BIN_EXE_quonc")) +} + +fn workspace_path(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel) +} + +/// Graph mode: load the toy [[5,1,3]] parity-check graph and emit JSON to +/// stdout. Asserts the qLDPC-specific estimate fields are present and correct. +#[test] +fn graph_mode_emits_estimate_json() { + let graph = workspace_path("../examples/na_qec/qldpc_5qubit.json"); + let out = quonc() + .arg("--qldpc-graph") + .arg(&graph) + .arg("--emit-qldpc-report") + .arg("-") + .output() + .expect("run quonc"); + assert!( + out.status.success(), + "quonc failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let v: Value = serde_json::from_slice(&out.stdout).expect("valid JSON"); + assert_eq!(v["evidence_kind"], "analytic"); + assert_eq!(v["mode"], "parity_check_graph"); + let est = &v["estimate"]; + assert_eq!(est["n_data"], 5); + assert_eq!(est["n_checks"], 4); + assert_eq!(est["distance"], 3); + assert_eq!(est["max_check_weight"], 5); + assert_eq!(est["peak_atoms"], 9); + assert_eq!(est["edge_count"], 20); + assert_eq!(est["measurement_rounds"], 1); +} + +/// Graph mode with `--qldpc-rounds 3` scales the measurement-rounds field. +#[test] +fn graph_mode_rounds_scales_measurement_rounds() { + let graph = workspace_path("../examples/na_qec/qldpc_5qubit.json"); + let out = quonc() + .arg("--qldpc-graph") + .arg(&graph) + .arg("--qldpc-rounds") + .arg("3") + .arg("--emit-qldpc-report") + .arg("-") + .output() + .expect("run quonc"); + assert!( + out.status.success(), + "quonc failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let v: Value = serde_json::from_slice(&out.stdout).expect("valid JSON"); + assert_eq!(v["estimate"]["measurement_rounds"], 3); +} + +/// Net-rate sizing mode: `1/24` with 12 logical qubits mirrors the +/// [[144,12,12]]-style architecture-model example (288 physical atoms). +#[test] +fn net_rate_mode_emits_sizing_json() { + let out = quonc() + .arg("--qldpc-net-rate") + .arg("1/24") + .arg("--qldpc-logical-qubits") + .arg("12") + .arg("--emit-qldpc-report") + .arg("-") + .output() + .expect("run quonc"); + assert!( + out.status.success(), + "quonc failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let v: Value = serde_json::from_slice(&out.stdout).expect("valid JSON"); + assert_eq!(v["mode"], "net_rate_sizing"); + assert_eq!(v["code_family"], "high_rate_qldpc_like"); + assert_eq!(v["net_rate"]["numerator"], 1); + assert_eq!(v["net_rate"]["denominator"], 24); + assert_eq!(v["atoms_per_logical"], 24); + assert_eq!(v["logical_qubits"], 12); + assert_eq!(v["physical_atoms"], 288); +} + +/// Markdown output: `.md` path extension switches to the Markdown format. +#[test] +fn net_rate_mode_markdown_output() { + let tmp = tempfile::NamedTempFile::with_suffix(".md").expect("temp file"); + let path = tmp.path(); + let out = quonc() + .arg("--qldpc-net-rate") + .arg("1/24") + .arg("--emit-qldpc-report") + .arg(path) + .output() + .expect("run quonc"); + assert!( + out.status.success(), + "quonc failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let body = std::fs::read_to_string(path).expect("read report"); + assert!(body.contains("high_rate_qldpc_like")); + assert!(body.contains("Atoms per logical | 24")); +} + +/// `--emit-qldpc-report` is required; omitting it fails with an actionable +/// error. +#[test] +fn graph_mode_requires_emit_flag() { + let graph = workspace_path("../examples/na_qec/qldpc_5qubit.json"); + let out = quonc() + .arg("--qldpc-graph") + .arg(&graph) + .output() + .expect("run quonc"); + assert!(!out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("--emit-qldpc-report"), + "error should mention --emit-qldpc-report: {err}" + ); +} + +/// `--qldpc-graph` and `--qldpc-net-rate` are mutually exclusive. +#[test] +fn graph_and_net_rate_are_mutually_exclusive() { + let graph = workspace_path("../examples/na_qec/qldpc_5qubit.json"); + let out = quonc() + .arg("--qldpc-graph") + .arg(&graph) + .arg("--qldpc-net-rate") + .arg("1/24") + .arg("--emit-qldpc-report") + .arg("-") + .output() + .expect("run quonc"); + assert!(!out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("mutually exclusive"), + "error should mention mutual exclusivity: {err}" + ); +} + +/// Invalid net-rate format fails with an actionable error. +#[test] +fn net_rate_rejects_bad_format() { + let out = quonc() + .arg("--qldpc-net-rate") + .arg("not-a-rate") + .arg("--emit-qldpc-report") + .arg("-") + .output() + .expect("run quonc"); + assert!(!out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("--qldpc-net-rate"), + "error should mention --qldpc-net-rate: {err}" + ); +} + +/// Missing graph file fails with an actionable error. +#[test] +fn graph_mode_rejects_missing_file() { + let out = quonc() + .arg("--qldpc-graph") + .arg("/nonexistent/qldpc.json") + .arg("--emit-qldpc-report") + .arg("-") + .output() + .expect("run quonc"); + assert!(!out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("read qLDPC graph"), + "error should mention read failure: {err}" + ); +} diff --git a/website/src/content/docs/architecture/na-model.md b/website/src/content/docs/architecture/na-model.md index 1d138456..cc83e9b9 100644 --- a/website/src/content/docs/architecture/na-model.md +++ b/website/src/content/docs/architecture/na-model.md @@ -290,6 +290,13 @@ not $k/r$ — an easy bug, called out in the issue. Constant-rate qLDPC families exist asymptotically, with concrete low-overhead points on reconfigurable arrays, which is the reason this family is worth modeling on this backend at all. +The high-rate qLDPC family is reviewer-runnable from `quonc` (#478) without a +source file: `quonc --qldpc-net-rate 1/24 --qldpc-logical-qubits 12 +--emit-qldpc-report -` emits the sizing (24 atoms/logical, 288 physical), and +`--qldpc-graph ` emits the full parity-check-graph estimate (check +weight, connectivity, movement pressure). Both are analytic estimates, not +threshold claims (ADR-0020). + **The hybrid QEC path.** Code blocks are scheduling units: whole blocks move between zones and logical 2Q gates are physical-parallel transversal interleavings — the operational picture motivating this layer, demonstrated in