From 898793622e26899079f6dbb298723f6046d4334f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 07:47:19 -0500 Subject: [PATCH 1/7] wai-quantum: any exercise is checked as a lesson is (engine::course::check_exercise) The course's check held a program to a lesson's exercise: its cases and intents, its constraints, the engine's verdict at each, and what went wrong in words. Only the lesson lookup tied it to the course. check_exercise takes the exercise as a document in the course's own form (wai.quantum.exercise/1) and checks a program against it the same way, with `lesson` null. It is the first step toward the job service writing a program for an intent: each attempt is held to the task as a lesson's answer is, and what went wrong is the feedback for the next attempt. check now finds its lesson and calls the same function, so a lesson's check is the same bytes as before. A new test holds both: - a lesson's own exercise, given as a document, checks byte for byte as the lesson does, for a right and a wrong program; - an exercise written from nothing but a distribution passes a Bell pair by proof and fails a program without its CNOT, naming the distribution. The course's ten tests pass (cargo test --features full --lib engine::course). Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 8 ++++++ wai/wai-quantum/src/engine/course.rs | 39 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 257c43ad3..6306a85b1 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -144,6 +144,14 @@ wire format may change and such changes are recorded here. request included (`--maintainer-products-only`); the private list still runs on the push to main. CONTRIBUTING states both. +### wai-quantum: any exercise is checked as a lesson is + +`engine::course::check_exercise` checks a program against an exercise document in the course's form +(`wai.quantum.exercise/1`): the same verdicts, constraints and words as a lesson's check, with `lesson` null. It is how +a task that is not in the course is held to what it must do, the first step toward the job service writing a program +for an intent and checking each attempt. `check` now looks up its lesson and calls it, and its answers are the same +bytes. + ### Contribution links: a packet taken after its sequence number's drop point is late, whatever the timer did - **[live-contribution](extensions/live-contribution.md) §6.1:** a packet *arrives* when the receiver takes it. An diff --git a/wai/wai-quantum/src/engine/course.rs b/wai/wai-quantum/src/engine/course.rs index 7a56fa806..64ff0822e 100644 --- a/wai/wai-quantum/src/engine/course.rs +++ b/wai/wai-quantum/src/engine/course.rs @@ -694,8 +694,20 @@ fn limit_text(b: &Value, loc: &str) -> String { /// proof or as evidence, and every constraint holds. pub fn check(id: &str, src: &str, job_bytes: u64, loc: &str) -> Result { let (c, i) = find(id)?; + check_exercise_as(&c.lessons[i].exercise, Some(id), src, job_bytes, loc) +} + +/// A program checked against any exercise in the form [`EXERCISE_SCHEMA`] +/// names, as [`check`] checks a lesson's: the same verdicts, constraints and +/// words, with `lesson` null. It is how a task that is not in the course (a +/// program a person or the job service is asked to write) is held to what it +/// must do. +pub fn check_exercise(exercise: &Value, src: &str, job_bytes: u64, loc: &str) -> Result { + check_exercise_as(exercise, None, src, job_bytes, loc) +} + +fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, loc: &str) -> Result { let loc = locale(loc); - let ex = &c.lessons[i].exercise; let cases = cases_of(ex); let named = cases.len() > 1 || ex.get("cases").is_some(); let declared = qasm::inputs(src).unwrap_or_default(); @@ -1035,6 +1047,31 @@ mod tests { } } + #[test] + fn an_exercise_outside_the_course_is_checked_as_a_lesson_is() { + // A lesson's own exercise, given as a document: the same check, byte for byte, but for + // the lesson it names (none). + let (c, i) = find("first-circuit").unwrap(); + let ex = &c.lessons[i].exercise; + let right = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;\n"; + let wrong = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;\n"; + for src in [right, wrong] { + let mut lesson = check("first-circuit", src, CAP, "en").unwrap(); + let doc = check_exercise(ex, src, CAP, "en").unwrap(); + assert_eq!(doc["lesson"], Value::Null, "{doc:#}"); + lesson["lesson"] = Value::Null; + assert_eq!(lesson, doc); + } + // An exercise written from nothing but what the program must do. + let mine = json!({ "schema": EXERCISE_SCHEMA, "intent": { "distribution": { "00": 0.5, "11": 0.5 } } }); + 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;\n"; + let ok = check_exercise(&mine, bell, CAP, "en").unwrap(); + assert_eq!((ok["passed"].as_bool(), ok["level"].as_str()), (Some(true), Some("proof")), "{ok:#}"); + let no = check_exercise(&mine, &bell.replace("cx q[0], q[1];\n", ""), CAP, "en").unwrap(); + assert_eq!(no["passed"], false, "{no:#}"); + assert_eq!(no["what_went_wrong"][0]["kind"], "distribution", "{no:#}"); + } + #[test] fn a_check_says_what_went_wrong_from_the_engine_and_never_shows_the_solution() { // A distribution: the largest gap is named, and the pointer is to it. -- 2.54.0 (Apple Git-157) From af83fbbe9f41c88c49f9af2fd20dfefc2d01c778 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 10:46:59 -0500 Subject: [PATCH 2/7] wai-quantum-docs.yml: the course's new test is required by name, and its count is ten The workflow pins how many course tests pass and names each one, since a module filter is satisfied by any one test. check_exercise added a tenth, so the step failed with every test passing. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-quantum-docs.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wai-quantum-docs.yml b/.github/workflows/wai-quantum-docs.yml index 007641dc8..4a8156e7c 100644 --- a/.github/workflows/wai-quantum-docs.yml +++ b/.github/workflows/wai-quantum-docs.yml @@ -125,7 +125,7 @@ jobs: run: | set -euo pipefail cargo test --locked --release --features full --lib -- engine::course:: 2>&1 | tee "$RUNNER_TEMP/course.log" - grep -qE '^test result: ok\. 9 passed; 0 failed' "$RUNNER_TEMP/course.log" || { echo "::error::the course's tests did not all pass"; exit 1; } + grep -qE '^test result: ok\. 10 passed; 0 failed' "$RUNNER_TEMP/course.log" || { echo "::error::the course's tests did not all pass"; exit 1; } for t in every_solution_passes_its_exercise_and_every_known_wrong_answer_fails_it \ a_check_says_what_went_wrong_from_the_engine_and_never_shows_the_solution \ no_hint_or_lesson_gives_the_solution \ @@ -134,7 +134,8 @@ jobs: the_schemas_name_the_fields_the_reader_takes \ a_check_is_the_same_bytes_every_time \ the_estimate_lesson_prices_the_program \ - a_locale_tag_picks_the_course_s_locale; do + a_locale_tag_picks_the_course_s_locale \ + an_exercise_outside_the_course_is_checked_as_a_lesson_is; do grep -q "^test engine::course::tests::$t \.\.\. ok$" "$RUNNER_TEMP/course.log" || { echo "::error::$t did not run"; exit 1; } done - name: The command line's course and its languages -- 2.54.0 (Apple Git-157) From eef9fa1095d9b03f9ce078a5609d6285cc5f0758 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 16:55:39 -0500 Subject: [PATCH 3/7] wai-quantum: check_exercise's changelog entry is a file of wai/changes/ Main now takes one file an entry (wai/changes/README.md), and its repository checks fail a pull request opened since that adds to the head of wai/CHANGELOG.md, which is what this branch did. The entry moves, word for word, to wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md, and wai/CHANGELOG.md is main's again. Co-Authored-By: Claude Fable 5.1 --- wai/CHANGELOG.md | 8 -------- .../2026-11-05-quantum-any-exercise-checked-as-lesson.md | 7 +++++++ 2 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 6306a85b1..257c43ad3 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -144,14 +144,6 @@ wire format may change and such changes are recorded here. request included (`--maintainer-products-only`); the private list still runs on the push to main. CONTRIBUTING states both. -### wai-quantum: any exercise is checked as a lesson is - -`engine::course::check_exercise` checks a program against an exercise document in the course's form -(`wai.quantum.exercise/1`): the same verdicts, constraints and words as a lesson's check, with `lesson` null. It is how -a task that is not in the course is held to what it must do, the first step toward the job service writing a program -for an intent and checking each attempt. `check` now looks up its lesson and calls it, and its answers are the same -bytes. - ### Contribution links: a packet taken after its sequence number's drop point is late, whatever the timer did - **[live-contribution](extensions/live-contribution.md) §6.1:** a packet *arrives* when the receiver takes it. An diff --git a/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md new file mode 100644 index 000000000..c8970890e --- /dev/null +++ b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md @@ -0,0 +1,7 @@ +### wai-quantum: any exercise is checked as a lesson is + +`engine::course::check_exercise` checks a program against an exercise document in the course's form +(`wai.quantum.exercise/1`): the same verdicts, constraints and words as a lesson's check, with `lesson` null. It is how +a task that is not in the course is held to what it must do, the first step toward the job service writing a program +for an intent and checking each attempt. `check` now looks up its lesson and calls it, and its answers are the same +bytes. -- 2.54.0 (Apple Git-157) From 4dcd8ff4a591e7874e94b6775988a55d5439ad26 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 6 Nov 2026 02:47:34 -0500 Subject: [PATCH 4/7] wai-quantum: check_exercise reads its exercise first, binds every case's inputs, and counts T gates as gates A check of an exercise that is no lesson's held vacuously where it read nothing, and the writer makes that check the only gate on a pass. Now: - engine::course::exercise_form holds a document to a lesson's form as far as a check reads it, and check_exercise refuses what it refuses before anything runs: a bound that is not whole numbers, a constraint or field the course does not name, a job beyond shots, seed and noise (a non-object job panicked the check; a repair or dyadic_bits made the engine prove another program than the one recorded), more than MOST_CASES (16) cases or MOST_INTENTS (8) intents. read_exercise shares the same reading, and a lesson's cases are still named in every locale. - A case's inputs bind: a program that does not declare one is refused in that case (Q114), where it ran without it (the bit-flip lesson passed with no flip). A case whose intent is null takes the exercise's, and a case named with one string is shown by it. - The constraints hold in every case's program, not the first one's, and a broken one names its case. - t_count counts T gates, not names: p and rz by an odd multiple of pi/4 count one, a Clifford gate none, and any other gate does not hold to a T count. A three-qubit gate does not hold to a two-qubit count. The t-count lesson passed with every T written as p(pi/4). Three tests, each failing on the branch's previous head for the reason it states (13015f77a: a {"t_count": 0} bound was checked and passed; the no-flip program passed the bit-flip lesson; the respelled t-count answer passed): course tests 10 -> 13 in wai-quantum-docs.yml, run on Linux x86_64 and macOS aarch64. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-quantum-docs.yml | 7 +- ...-quantum-any-exercise-checked-as-lesson.md | 17 +- wai/wai-quantum/course/locales/en.json | 4 + wai/wai-quantum/course/locales/zh-CN.json | 4 + wai/wai-quantum/schemas/exercise.v1.json | 3 +- wai/wai-quantum/src/engine/course.rs | 480 +++++++++++++++--- 6 files changed, 438 insertions(+), 77 deletions(-) diff --git a/.github/workflows/wai-quantum-docs.yml b/.github/workflows/wai-quantum-docs.yml index 4a8156e7c..d439a3daf 100644 --- a/.github/workflows/wai-quantum-docs.yml +++ b/.github/workflows/wai-quantum-docs.yml @@ -125,7 +125,7 @@ jobs: run: | set -euo pipefail cargo test --locked --release --features full --lib -- engine::course:: 2>&1 | tee "$RUNNER_TEMP/course.log" - grep -qE '^test result: ok\. 10 passed; 0 failed' "$RUNNER_TEMP/course.log" || { echo "::error::the course's tests did not all pass"; exit 1; } + grep -qE '^test result: ok\. 13 passed; 0 failed' "$RUNNER_TEMP/course.log" || { echo "::error::the course's tests did not all pass"; exit 1; } for t in every_solution_passes_its_exercise_and_every_known_wrong_answer_fails_it \ a_check_says_what_went_wrong_from_the_engine_and_never_shows_the_solution \ no_hint_or_lesson_gives_the_solution \ @@ -135,7 +135,10 @@ jobs: a_check_is_the_same_bytes_every_time \ the_estimate_lesson_prices_the_program \ a_locale_tag_picks_the_course_s_locale \ - an_exercise_outside_the_course_is_checked_as_a_lesson_is; do + an_exercise_outside_the_course_is_checked_as_a_lesson_is \ + a_malformed_exercise_is_refused_before_anything_is_checked \ + every_case_binds_its_inputs_and_is_held_to_the_constraints \ + a_t_count_counts_t_gates_however_they_are_written; do grep -q "^test engine::course::tests::$t \.\.\. ok$" "$RUNNER_TEMP/course.log" || { echo "::error::$t did not run"; exit 1; } done - name: The command line's course and its languages diff --git a/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md index c8970890e..1d3cddefd 100644 --- a/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md +++ b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md @@ -4,4 +4,19 @@ (`wai.quantum.exercise/1`): the same verdicts, constraints and words as a lesson's check, with `lesson` null. It is how a task that is not in the course is held to what it must do, the first step toward the job service writing a program for an intent and checking each attempt. `check` now looks up its lesson and calls it, and its answers are the same -bytes. +bytes for a program that passes. +- **The exercise is read first.** `engine::course::exercise_form` holds a document to the form a lesson's exercise is + held to, as far as a check reads it, and `check_exercise` refuses what it refuses before anything runs: a bound that + is not whole numbers (`{"max": 1.5}`, `{"max": "0"}`, a bare number), a constraint or a field the course does not + name, a job with more than shots, seed and noise (so a task cannot ask for a repair the transcript does not show), + more than 16 cases (`MOST_CASES`) or 8 intents in one place (`MOST_INTENTS`). Before, such a constraint held + vacuously, an unknown one was dropped, and a job that was not an object stopped the check with a panic. +- **Every case binds its inputs.** A program that does not declare an input a case gives is refused in that case + (`Q114`), where it was run without the input: the bit-flip code's lesson passed with no flip at all. A case whose + `intent` is null takes the exercise's, as one with none does. +- **The constraints hold in every case**, not only the first: a gate one case alone adds is counted, and the case is + named in what went wrong. +- **A T count counts T gates however they are written:** a `p` or `rz` by an odd multiple of π/4 counts one, a + Clifford gate none, and any other gate (a rotation by another angle, a Toffoli) does not hold to a T count, since + its count is not fixed. A gate on three qubits does not hold to a count of two-qubit gates. Before, `t_count` + counted gate names, so the t-count lesson passed with every T written as `p(pi/4)`. diff --git a/wai/wai-quantum/course/locales/en.json b/wai/wai-quantum/course/locales/en.json index c9af186af..a42757224 100644 --- a/wai/wai-quantum/course/locales/en.json +++ b/wai/wai-quantum/course/locales/en.json @@ -46,6 +46,8 @@ "wrong.count": "{gate}: your program has {found}, and the exercise asks for {limit}.", "wrong.t_count": "Your program has {found} T and T† gates, and the exercise allows {limit}.", "wrong.two_qubit": "Your program has {found} two-qubit gates, and the exercise allows {limit}.", + "wrong.t_count_other": "Your program has a {gate} gate, which is neither a Clifford nor a T gate, so its T count is not fixed: the exercise allows {limit} T and T† gates.", + "wrong.two_qubit_wide": "Your program has a {gate} gate, which acts on three qubits, so a count of two-qubit gates does not bound it: the exercise allows {limit} two-qubit gates.", "wrong.smell": "The critique finds a smell the exercise rules out, at line(s) {lines}.", "wrong.note": "The critique notes something at line(s) {lines}.", "wrong.estimate": "The resource estimate counts {found} {what}, and the exercise allows {limit}.", @@ -60,6 +62,8 @@ "pointer.count": "Count your {gate} gates.", "pointer.t_count": "Look for T and T† gates that combine into Clifford gates.", "pointer.two_qubit": "Look for two-qubit gates you can do without.", + "pointer.t_count_other": "Write each {gate} with T, T† and Clifford gates.", + "pointer.two_qubit_wide": "Write each {gate} with one- and two-qubit gates.", "pointer.smell": "Look at line(s) {lines}: the critique flags them.", "pointer.estimate": "Look for work the program does and then undoes.", "pointer.case": "Try the case {name} first.", diff --git a/wai/wai-quantum/course/locales/zh-CN.json b/wai/wai-quantum/course/locales/zh-CN.json index 27cd83af4..22b979c6e 100644 --- a/wai/wai-quantum/course/locales/zh-CN.json +++ b/wai/wai-quantum/course/locales/zh-CN.json @@ -46,6 +46,8 @@ "wrong.count": "{gate}:你的程序有 {found} 个,练习要求 {limit}。", "wrong.t_count": "你的程序有 {found} 个 T 与 T† 门,练习只允许 {limit}。", "wrong.two_qubit": "你的程序有 {found} 个双比特门,练习只允许 {limit}。", + "wrong.t_count_other": "你的程序有 {gate} 门,它既不是 Clifford 门也不是 T 门,所以它的 T 门数不确定:练习只允许 {limit} 个 T 与 T† 门。", + "wrong.two_qubit_wide": "你的程序有 {gate} 门,它作用于三个量子比特,双比特门的计数约束不了它:练习只允许 {limit} 个双比特门。", "wrong.smell": "评析在第 {lines} 行发现了这道练习所排除的问题模式。", "wrong.note": "评析在第 {lines} 行有一条提示。", "wrong.estimate": "资源估算计入了 {found} 个{what},练习只允许 {limit}。", @@ -60,6 +62,8 @@ "pointer.count": "数一数你的 {gate} 门。", "pointer.t_count": "找一找可以合并成 Clifford 门的 T 与 T† 门。", "pointer.two_qubit": "找一找可以省掉的双比特门。", + "pointer.t_count_other": "用 T、T† 与 Clifford 门写出每个 {gate}。", + "pointer.two_qubit_wide": "用单比特门与双比特门写出每个 {gate}。", "pointer.smell": "看第 {lines} 行:评析标出了它们。", "pointer.estimate": "找一找程序先做了又撤销掉的工作。", "pointer.case": "先试试{name}的情况。", diff --git a/wai/wai-quantum/schemas/exercise.v1.json b/wai/wai-quantum/schemas/exercise.v1.json index e9b84940e..1bde4662d 100644 --- a/wai/wai-quantum/schemas/exercise.v1.json +++ b/wai/wai-quantum/schemas/exercise.v1.json @@ -133,7 +133,8 @@ "cases": { "type": "array", "minItems": 1, - "description": "A generator's inputs to check the program at, each a case with its own verdict; a case without an intent takes the exercise's", + "maxItems": 16, + "description": "A generator's inputs to check the program at, each a case with its own verdict, at most 16 (engine::course::MOST_CASES); a case without an intent takes the exercise's. Every input a case gives must be one the program declares, and the constraints hold in every case", "items": { "type": "object", "additionalProperties": false, diff --git a/wai/wai-quantum/src/engine/course.rs b/wai/wai-quantum/src/engine/course.rs index 64ff0822e..cfc0b244b 100644 --- a/wai/wai-quantum/src/engine/course.rs +++ b/wai/wai-quantum/src/engine/course.rs @@ -65,6 +65,11 @@ pub const JOB_FIELDS: [&str; 3] = ["shots", "seed", "noise"]; pub const ESTIMATE_COUNTS: [&str; 5] = ["toffolis", "t_gates", "rotations", "logical_qubits", "t_states"]; /// Why a known wrong answer fails. pub const FAILS: [&str; 3] = ["intent", "constraint", "refused"]; +/// The most cases an exercise may have: each is a run of the program, and its +/// verdict, in every check. +pub const MOST_CASES: usize = 16; +/// The most intents an exercise, or one of its cases, may give. +pub const MOST_INTENTS: usize = 8; const COURSE: &str = include_str!("../../course/course.json"); const MESSAGES: [(&str, &str); 2] = [("en", include_str!("../../course/locales/en.json")), ("zh-CN", include_str!("../../course/locales/zh-CN.json"))]; @@ -179,60 +184,84 @@ fn bound(v: &Value, what: &str) -> Result<(), String> { Ok(()) } -/// An exercise read from its JSON and checked in its form, its programs -/// joined from their lines. -fn read_exercise(id: &str, text: &str) -> Result { - let mut v: Value = serde_json::from_str(text).map_err(|e| format!("{id}/exercise.json is not JSON: {e}"))?; - let o = v.as_object().ok_or_else(|| format!("{id}/exercise.json is not an object"))?; - if let Some(k) = o.keys().find(|k| !EXERCISE_FIELDS.contains(&k.as_str())) { - return Err(format!("{id}: `{k}` is not a field of an exercise")); +/// A field of an exercise or a case, null read as absent. +fn present<'a>(o: &'a Map, k: &str) -> Option<&'a Value> { + o.get(k).filter(|v| !v.is_null()) +} + +/// An intent (one, or a list) checked in its shape and its number. +fn intent_list(v: &Value, what: &str) -> Result<(), String> { + let list = verdict::intents(v).map_err(|e| format!("{what}: {e}"))?; + if list.len() > MOST_INTENTS { + return Err(format!("{what}: {} intents, and one may give at most {MOST_INTENTS}", list.len())); } - if let Some(k) = EXERCISE_REQUIRED.iter().find(|k| !o.contains_key(**k)) { - return Err(format!("{id}: an exercise needs `{k}`")); - } - if o["schema"] != EXERCISE_SCHEMA || o["id"] != id { - return Err(format!("{id}: schema must be {EXERCISE_SCHEMA} and id must be {id}")); - } - localized(o.get("title"), &format!("{id}: title"))?; - localized(o.get("task"), &format!("{id}: task"))?; - let hints = o["hints"].as_object().ok_or_else(|| format!("{id}: hints is an object of lists, one for each locale"))?; - let counts: Vec = LOCALES.iter().map(|l| hints.get(*l).and_then(Value::as_array).map_or(0, Vec::len)).collect(); - if counts[0] == 0 || counts.iter().any(|c| *c != counts[0]) || hints.len() != LOCALES.len() { - return Err(format!("{id}: hints needs the same number of hints, at least one, in each locale ({counts:?})")); - } - if let Some(job) = o.get("job") { + Ok(()) +} + +/// What an exercise asks of a program, checked in its form as a check reads +/// it: its job (shots, seed and noise, nothing else), its intent, its cases +/// (at most [`MOST_CASES`], each with its inputs as whole numbers and an +/// intent of its own or the exercise's) and its constraints (each one the +/// course names, each bound whole numbers). A field that is null is absent. +/// Nothing malformed is read as no bound, or dropped: a check of an exercise +/// that does not read here would hold vacuously where it reads nothing. +fn asks(o: &Map, id: &str) -> Result<(), String> { + if let Some(job) = present(o, "job") { let j = job.as_object().ok_or_else(|| format!("{id}: job is an object"))?; if let Some(k) = j.keys().find(|k| !JOB_FIELDS.contains(&k.as_str())) { return Err(format!("{id}: job takes {}, not {k}", JOB_FIELDS.join(", "))); } + if present(j, "shots").is_some_and(|s| !s.as_u64().is_some_and(|n| n >= 1)) { + return Err(format!("{id}: job: shots is a whole number, at least 1")); + } + if present(j, "seed").is_some_and(|s| s.as_u64().is_none()) { + return Err(format!("{id}: job: seed is a whole number")); + } + if let Some(n) = present(j, "noise") { + let n = n.as_object().ok_or_else(|| format!("{id}: job: noise is an object of channels and their probabilities"))?; + if let Some((k, _)) = n.iter().find(|(_, p)| !p.as_f64().is_some_and(|p| (0.0..=1.0).contains(&p))) { + return Err(format!("{id}: job: noise: {k} takes a probability, 0 to 1")); + } + } } - if !o.contains_key("intent") && !o.contains_key("cases") { + let intent = present(o, "intent"); + let cases = present(o, "cases"); + if intent.is_none() && cases.is_none() { return Err(format!("{id}: an exercise needs an intent, or cases that each carry one")); } - if let Some(i) = o.get("intent") { - verdict::intents(i).map_err(|e| format!("{id}: intent: {e}"))?; + if let Some(i) = intent { + intent_list(i, &format!("{id}: intent"))?; } - if let Some(cases) = o.get("cases") { + if let Some(cases) = cases { let list = cases.as_array().filter(|a| !a.is_empty()).ok_or_else(|| format!("{id}: cases is a list of at least one case"))?; + if list.len() > MOST_CASES { + return Err(format!("{id}: {} cases, and an exercise may have at most {MOST_CASES}", list.len())); + } for (n, c) in list.iter().enumerate() { let what = format!("{id}: case {}", n + 1); let co = c.as_object().ok_or_else(|| format!("{what} is an object"))?; if let Some(k) = co.keys().find(|k| !["inputs", "name", "intent"].contains(&k.as_str())) { return Err(format!("{what}: `{k}` is not a field of a case (inputs, name, intent)")); } - let ins = co.get("inputs").and_then(Value::as_object).ok_or_else(|| format!("{what}: inputs is an object of whole numbers"))?; - if ins.values().any(|x| x.as_i64().is_none()) { - return Err(format!("{what}: inputs is an object of whole numbers")); + if let Some(ins) = present(co, "inputs") { + let ins = ins.as_object().ok_or_else(|| format!("{what}: inputs is an object of whole numbers"))?; + if ins.values().any(|x| x.as_i64().is_none()) { + return Err(format!("{what}: inputs is an object of whole numbers")); + } } - localized(co.get("name"), &format!("{what}: name"))?; - match co.get("intent") { - Some(i) => drop(verdict::intents(i).map_err(|e| format!("{what}: intent: {e}"))?), - None if !o.contains_key("intent") => return Err(format!("{what}: no intent, and the exercise has none")), + if let Some(nm) = present(co, "name") + && !(nm.is_string() || nm.as_object().is_some_and(|m| m.values().all(Value::is_string))) + { + return Err(format!("{what}: name is a text, or one for each locale")); + } + match present(co, "intent") { + Some(i) => intent_list(i, &format!("{what}: intent"))?, + None if intent.is_none() => return Err(format!("{what}: no intent, and the exercise has none")), None => {} } } } - if let Some(c) = o.get("constraints") { + if let Some(c) = present(o, "constraints") { let co = c.as_object().ok_or_else(|| format!("{id}: constraints is an object"))?; if let Some(k) = co.keys().find(|k| !CONSTRAINTS.contains(&k.as_str())) { return Err(format!("{id}: `{k}` is not a constraint ({})", CONSTRAINTS.join(", "))); @@ -271,6 +300,78 @@ fn read_exercise(id: &str, text: &str) -> Result { } } } + Ok(()) +} + +/// Whether `exercise` is in the form [`EXERCISE_SCHEMA`] names, as far as a +/// check reads it: a JSON object of an exercise's fields only, naming that +/// form if it names one, with its texts and programs where it gives them, and +/// what it asks of a program (its job, intent, cases and constraints) in the +/// form a lesson's exercise is held to. An exercise that is no lesson's (a +/// task a program is written for) need not give a lesson's title in every +/// locale, its hints, solution or known wrong answers, nor name each case. +/// [`check_exercise`] refuses what this refuses, before it runs anything. +pub fn exercise_form(exercise: &Value) -> Result<(), String> { + let id = "the exercise"; + let o = exercise.as_object().ok_or_else(|| format!("{id} is not a JSON object"))?; + if let Some(k) = o.keys().find(|k| !EXERCISE_FIELDS.contains(&k.as_str())) { + return Err(format!("{id}: `{k}` is not a field of an exercise ({})", EXERCISE_FIELDS.join(", "))); + } + if let Some(s) = present(o, "schema") + && *s != EXERCISE_SCHEMA + { + return Err(format!("{id}: its schema is {s}, not \"{EXERCISE_SCHEMA}\"")); + } + for k in ["title", "task"] { + if let Some(t) = present(o, k) + && !(t.is_string() || t.as_object().is_some_and(|m| m.values().all(Value::is_string))) + { + return Err(format!("{id}: {k} is a text, or one for each locale")); + } + } + for k in ["starter", "reference"] { + if let Some(p) = present(o, k) { + program_text(p, &format!("{id}: {k}"))?; + } + } + for (k, ok) in [("id", Value::is_string as fn(&Value) -> bool), ("translations", Value::is_object), ("hints", Value::is_object), ("wrong", Value::is_array)] { + if present(o, k).is_some_and(|v| !ok(v)) { + return Err(format!("{id}: {k} is not in an exercise's form")); + } + } + asks(o, id) +} + +/// An exercise read from its JSON and checked in its form, its programs +/// joined from their lines. +fn read_exercise(id: &str, text: &str) -> Result { + let mut v: Value = serde_json::from_str(text).map_err(|e| format!("{id}/exercise.json is not JSON: {e}"))?; + let o = v.as_object().ok_or_else(|| format!("{id}/exercise.json is not an object"))?; + if let Some(k) = o.keys().find(|k| !EXERCISE_FIELDS.contains(&k.as_str())) { + return Err(format!("{id}: `{k}` is not a field of an exercise")); + } + if let Some(k) = EXERCISE_REQUIRED.iter().find(|k| !o.contains_key(**k)) { + return Err(format!("{id}: an exercise needs `{k}`")); + } + if o["schema"] != EXERCISE_SCHEMA || o["id"] != id { + return Err(format!("{id}: schema must be {EXERCISE_SCHEMA} and id must be {id}")); + } + localized(o.get("title"), &format!("{id}: title"))?; + localized(o.get("task"), &format!("{id}: task"))?; + let hints = o["hints"].as_object().ok_or_else(|| format!("{id}: hints is an object of lists, one for each locale"))?; + let counts: Vec = LOCALES.iter().map(|l| hints.get(*l).and_then(Value::as_array).map_or(0, Vec::len)).collect(); + if counts[0] == 0 || counts.iter().any(|c| *c != counts[0]) || hints.len() != LOCALES.len() { + return Err(format!("{id}: hints needs the same number of hints, at least one, in each locale ({counts:?})")); + } + asks(o, id)?; + // A lesson's cases each name their inputs, and are named in every locale. + for (n, c) in present(o, "cases").and_then(Value::as_array).into_iter().flatten().enumerate() { + let what = format!("{id}: case {}", n + 1); + if !c.get("inputs").is_some_and(Value::is_object) { + return Err(format!("{what}: inputs is an object of whole numbers")); + } + localized(c.get("name"), &format!("{what}: name"))?; + } let wrong = o["wrong"].as_array().filter(|w| !w.is_empty()).ok_or_else(|| format!("{id}: wrong is a list of at least one known wrong answer"))?; for (n, w) in wrong.iter().enumerate() { let what = format!("{id}: wrong {}", n + 1); @@ -367,6 +468,12 @@ fn word<'a>(v: &'a Value, loc: &str) -> &'a str { v.get(loc).or_else(|| v.get(LOCALES[0])).and_then(Value::as_str).unwrap_or("") } +/// A text given once, or in each locale ([`word`]): an exercise that is no +/// lesson's may name a case with one string. +fn text_in<'a>(v: &'a Value, loc: &str) -> &'a str { + v.as_str().unwrap_or_else(|| word(v, loc)) +} + /// A probability or a distance, as the course writes one: up to four /// decimals, without trailing zeros, or in scientific notation when small. fn num(x: f64) -> String { @@ -456,13 +563,15 @@ fn constraint_texts(c: &Value, loc: &str) -> Vec<(String, String, String)> { /// The cases a program is checked in: each with its name, the inputs it /// gives a generator, and its intent. -fn cases_of(ex: &Value) -> Vec<(Option<&Value>, qasm::Inputs, Value)> { +fn cases_of<'a>(ex: &'a Value) -> Vec<(Option<&'a Value>, qasm::Inputs, Value)> { match ex.get("cases").and_then(Value::as_array) { Some(list) => list .iter() .map(|c| { let ins: qasm::Inputs = c["inputs"].as_object().into_iter().flatten().map(|(k, v)| (k.clone(), v.as_i64().unwrap_or(0))).collect(); - (Some(&c["name"]), ins, c.get("intent").or(ex.get("intent")).cloned().unwrap_or(Value::Null)) + // A case's intent, or the exercise's when it gives none (null is none). + let own = |v: &'a Value| v.get("intent").filter(|i| !i.is_null()); + (Some(&c["name"]), ins, own(c).or(own(ex)).cloned().unwrap_or(Value::Null)) }) .collect(), None => vec![(None, qasm::Inputs::new(), ex["intent"].clone())], @@ -703,26 +812,73 @@ pub fn check(id: &str, src: &str, job_bytes: u64, loc: &str) -> Result Result { + exercise_form(exercise)?; check_exercise_as(exercise, None, src, job_bytes, loc) } +/// The T gates a program comes to, as a T count bounds it: a T or T† one each, +/// a Clifford gate none, and a phase rotation (`p`, `rz`) by an odd multiple of +/// π/4 one, since it is a T or T† up to Clifford gates and a global phase. Any +/// other gate's T count is not fixed (it depends on how the gate is written in +/// Clifford and T gates, or it has no exact form in them): that gate's name. +fn t_gates(p: &qasm::Program) -> Result { + let mut n = 0; + for op in &p.ops { + let odd_quarter = |theta: f64| { + let q = theta / std::f64::consts::FRAC_PI_4; + (q - q.round()).abs() < 1e-9 && (q.round() as i64).rem_euclid(2) == 1 + }; + if qasm::Program::lower_clifford(std::slice::from_ref(op)).is_ok() { + continue; + } + match op.kind { + qasm::Kind::T | qasm::Kind::Tdg => n += 1, + qasm::Kind::P | qasm::Kind::Rz if op.params.first().is_some_and(|t| odd_quarter(*t)) => n += 1, + k => return Err(critique::kind_name(k)), + } + } + Ok(n) +} + +/// The two-qubit gates of a program, as a two-qubit count bounds it; a gate on +/// three qubits is not one, and is not bounded by their count: its name. +fn two_qubit_gates(p: &qasm::Program) -> Result { + if let Some(op) = p.ops.iter().find(|o| o.qubits.len() > 2) { + return Err(critique::kind_name(op.kind)); + } + Ok(p.ops.iter().filter(|o| o.qubits.len() == 2).count() as u64) +} + +/// One case's program as the constraints measure it: the case's number and +/// name where the cases are named, its text, the critique of it, and it read. +struct Measured<'a> { + case: Option, + name: Option, + text: &'a str, + critique: Value, + program: Option, +} + fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, loc: &str) -> Result { let loc = locale(loc); let cases = cases_of(ex); - let named = cases.len() > 1 || ex.get("cases").is_some(); - let declared = qasm::inputs(src).unwrap_or_default(); + let named = cases.len() > 1 || ex.get("cases").is_some_and(Value::is_array); let mut wrongs: Vec = Vec::new(); let mut case_out = Vec::new(); - let mut first_text: Option = None; + // Each case's program as it reads there (a generator at the case's inputs), + // with the case's number and name: what the constraints are held to. + let mut texts: Vec<(Option, Option, String)> = Vec::new(); let mut decided = true; let mut levels: Vec = Vec::new(); for (n, (name, inputs, intent)) in cases.iter().enumerate() { - let ins: qasm::Inputs = inputs.iter().filter(|(k, _)| declared.contains(k)).map(|(k, v)| (k.clone(), *v)).collect(); let case = named.then_some(n); - let v = match qasm::read_with(src, &ins) { + // Every input a case gives binds: the reader refuses a value for an input + // the program does not declare (Q114), so a program that ignores the + // perturbation a case is about does not pass that case. + let v = match qasm::read_with(src, inputs) { Err(d) => verdict::of_diagnostic(&d), Ok(_) => { - let text = if ins.is_empty() { Ok(src.to_string()) } else { qasm::instantiate(src, &ins) }; + let text = if inputs.is_empty() { Ok(src.to_string()) } else { qasm::instantiate(src, inputs) }; let text = match text { Ok(t) => t, Err(e) => { @@ -730,14 +886,12 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo levels.push(verdict::Level::Refused); decided = false; wrongs.extend(verdict_wrongs(&v, case, loc)); - case_out.push(json!({ "name": name.map(|x| word(x, loc)), "inputs": inputs, "passed": false, "level": v["level"], "holds": v["holds"], "statement": v["statement"], "verdict": v })); + case_out.push(json!({ "name": name.map(|x| text_in(x, loc)), "inputs": inputs, "passed": false, "level": v["level"], "holds": v["holds"], "statement": v["statement"], "verdict": v })); continue; } }; - if first_text.is_none() { - first_text = Some(text.clone()); - } - let mut req = ex.get("job").cloned().unwrap_or(json!({})); + texts.push((case, name.map(|x| text_in(x, loc).to_string()), text.clone())); + let mut req = ex.get("job").filter(|j| j.is_object()).cloned().unwrap_or(json!({})); req["qasm"] = json!(text); req["intent"] = intent.clone(); if req.get("shots").is_none() { @@ -758,7 +912,7 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo if v["holds"].is_null() { decided = false; } - let name_text = name.map(|x| word(x, loc).to_string()); + let name_text = name.map(|x| text_in(x, loc).to_string()); for mut w in verdict_wrongs(&v, case, loc) { if let Some(nm) = &name_text { w.text = say(loc, "wrong.case", &[("name", nm.clone()), ("text", w.text)]); @@ -775,18 +929,29 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo "verdict": v, })); } - // The constraints, on the program as it reads (a generator at its first case). + // The constraints, held on the program as it reads in every case: a gate + // that one case alone uses is held to them as the first case's are. A + // constraint holds when it holds in every case; what it found is the first + // case's, or the first case's where it does not hold. + let first_text = texts.first().map(|(_, _, t)| t.clone()); let mut constraints = Vec::new(); let mut measures = Value::Null; let mut estimate_out = Value::Null; - let cons = ex.get("constraints").cloned().unwrap_or(json!({})); - if let Some(text) = &first_text { - let m = critique::critique(text); - if m["ok"] == true { - measures = json!({ "qubits": m["qubits"], "gates": m["gates"], "two_qubit": m["two_qubit"], "t_count": m["t_count"], "depth": m["depth"], "by_gate": m["by_gate"] }); - let by = m["by_gate"].as_object().cloned().unwrap_or_default(); - let count = |g: &str| by.get(g).and_then(Value::as_u64).unwrap_or(0); - for (rule, subject, text_c) in constraint_texts(&cons, loc) { + let cons = ex.get("constraints").filter(|c| c.is_object()).cloned().unwrap_or(json!({})); + let measured: Vec = texts.iter().map(|(c, nm, t)| Measured { case: *c, name: nm.clone(), text: t, critique: critique::critique(t), program: qasm::parse(t).ok() }).collect(); + if let Some(m0) = measured.first().map(|x| &x.critique) + && m0["ok"] == true + { + measures = json!({ "qubits": m0["qubits"], "gates": m0["gates"], "two_qubit": m0["two_qubit"], "t_count": m0["t_count"], "depth": m0["depth"], "by_gate": m0["by_gate"] }); + let mut estimates: Vec = Vec::new(); + for (rule, subject, text_c) in constraint_texts(&cons, loc) { + let mut row: Option<(bool, Value, Option)> = None; + for (k, Measured { case, name: case_name, critique: m, program, .. }) in measured.iter().enumerate() { + if m["ok"] != true { + continue; + } + let by = m["by_gate"].as_object().cloned().unwrap_or_default(); + let count = |g: &str| by.get(g).and_then(Value::as_u64).unwrap_or(0); let (holds, found, wrong) = match rule.as_str() { "allow" => { let allowed: Vec<&str> = cons["allow"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); @@ -816,16 +981,38 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo } "t_count" | "two_qubit" => { let b = &cons[&rule]; - let n = m[&rule].as_u64().unwrap_or(0); - let w = (!within(n, b)).then(|| Wrong { - kind: "constraint", - text: say(loc, &format!("wrong.{rule}"), &[("found", n.to_string()), ("limit", b["max"].as_u64().map_or("?".into(), |x| x.to_string()))]), - engine: None, - lines: Vec::new(), - case: None, - pointer: say(loc, &format!("pointer.{rule}"), &[]), - }); - (within(n, b), json!(n), w) + let limit = b["max"].as_u64().map_or("?".into(), |x| x.to_string()); + let counted = match program { + Some(p) if rule == "t_count" => t_gates(p), + Some(p) => two_qubit_gates(p), + None => Ok(m[&rule].as_u64().unwrap_or(0)), + }; + match counted { + Ok(n) => { + let w = (!within(n, b)).then(|| Wrong { + kind: "constraint", + text: say(loc, &format!("wrong.{rule}"), &[("found", n.to_string()), ("limit", limit.clone())]), + engine: None, + lines: Vec::new(), + case: None, + pointer: say(loc, &format!("pointer.{rule}"), &[]), + }); + (within(n, b), json!(n), w) + } + // A gate the count cannot bound: the constraint does not hold. + Err(gate) => { + let other = if rule == "t_count" { "t_count_other" } else { "two_qubit_wide" }; + let w = Wrong { + kind: "constraint", + text: say(loc, &format!("wrong.{other}"), &[("gate", gate.to_string()), ("limit", limit)]), + engine: None, + lines: Vec::new(), + case: None, + pointer: say(loc, &format!("pointer.{other}"), &[("gate", gate.to_string())]), + }; + (false, Value::Null, Some(w)) + } + } } "no_smells" => { let kinds: Vec<&str> = cons["no_smells"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); @@ -839,10 +1026,14 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo } "estimate" => { let b = &cons["estimate"][&subject]; - if estimate_out.is_null() { - estimate_out = estimate_of(text, loc); + while estimates.len() <= k { + estimates.push(estimate_of(measured[estimates.len()].text, loc)); } - match estimate_out["counts"][&subject].as_u64() { + if estimate_out.is_null() { + estimate_out = estimates[0].clone(); + } + let e = &estimates[k]; + match e["counts"][&subject].as_u64() { Some(n) => { let w = (!within(n, b)).then(|| Wrong { kind: "constraint", @@ -857,16 +1048,32 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo None => ( false, Value::Null, - Some(Wrong { kind: "constraint", text: say(loc, "wrong.estimate_refused", &[]), engine: estimate_out["error"].as_str().map(str::to_string), lines: Vec::new(), case: None, pointer: say(loc, "pointer.unchecked", &[]) }), + Some(Wrong { kind: "constraint", text: say(loc, "wrong.estimate_refused", &[]), engine: e["error"].as_str().map(str::to_string), lines: Vec::new(), case: None, pointer: say(loc, "pointer.unchecked", &[]) }), ), } } _ => continue, }; - constraints.push(json!({ "rule": rule, "subject": (!subject.is_empty()).then_some(subject), "text": text_c, "holds": holds, "found": found })); - if let Some(w) = wrong { - wrongs.push(w); + // Where the cases are named, a constraint broken in one says which. + let wrong = wrong.map(|mut w| { + if let (Some(c), Some(nm)) = (case, case_name) { + w.case = Some(*c); + w.text = say(loc, "wrong.case", &[("name", nm.clone()), ("text", w.text)]); + } + w + }); + let fails = !holds; + if row.is_none() || fails { + row = Some((holds, found, wrong)); } + if fails { + break; + } + } + let Some((holds, found, wrong)) = row else { continue }; + constraints.push(json!({ "rule": rule, "subject": (!subject.is_empty()).then_some(subject), "text": text_c, "holds": holds, "found": found })); + if let Some(w) = wrong { + wrongs.push(w); } } } @@ -1072,6 +1279,133 @@ mod tests { assert_eq!(no["what_went_wrong"][0]["kind"], "distribution", "{no:#}"); } + const HEAD: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\n"; + + /// An exercise that is no lesson's is held to the form a lesson's is before + /// anything runs: a bound that is not whole numbers, a constraint the + /// course does not name, a job that is not shots, seed and noise, too many + /// cases or intents, or a field an exercise does not have, is refused, never + /// read as no bound or dropped. Each program here does what the intent + /// asks, so a vacuous constraint would let it pass. + #[test] + fn a_malformed_exercise_is_refused_before_anything_is_checked() { + let src = format!("{HEAD}qubit[1] q;\nbit[1] c;\nx q[0];\nt q[0];\nt q[0];\nc = measure q;\n"); + let with = |k: &str, v: Value| { + let mut ex = json!({ "schema": EXERCISE_SCHEMA, "intent": { "distribution": { "1": 1.0 } } }); + ex[k] = v; + ex + }; + // Well formed, it reads and the program passes; bounded, it fails. + let ok = check_exercise(&with("constraints", json!({})), &src, CAP, "en").unwrap(); + assert_eq!(ok["passed"], true, "{ok:#}"); + let bounded = check_exercise(&with("constraints", json!({ "t_count": { "max": 0 } })), &src, CAP, "en").unwrap(); + assert_eq!((bounded["passed"].as_bool(), bounded["constraints"][0]["found"].as_u64()), (Some(false), Some(2)), "{bounded:#}"); + let bad_constraints = [ + json!({ "t_count": 0 }), + json!({ "t_count": { "max": -1 } }), + json!({ "t_count": { "max": 1.5 } }), + json!({ "t_count": { "max": 0.0 } }), + json!({ "t_count": { "max": "0" } }), + json!({ "t_count": { "maximum": 0 } }), + json!({ "t_count": { "min": 3, "max": 1 } }), + json!({ "two_qubit": {} }), + json!({ "counts": { "CX": { "max": 0 } } }), + json!({ "counts": 5 }), + json!({ "estimate": 5 }), + json!({ "estimate": { "t_gates": { "max": 1.0 } } }), + json!({ "depth": { "max": 3 } }), + json!({ "no_smells": "cancels" }), + json!({ "no_smells": ["everything"] }), + json!(5), + ]; + let many = |n: usize, one: Value| Value::Array(vec![one; n]); + let bad: Vec = bad_constraints + .into_iter() + .map(|c| with("constraints", c)) + .chain([json!(1), json!("x"), json!([]), json!(true), json!({ "repair": true }), json!({ "dyadic_bits": 3 }), json!({ "compile": { "level": 2 } }), json!({ "shots": 2.5 }), json!({ "shots": 0 }), json!({ "seed": -1 }), json!({ "noise": { "depolarizing_1q": 2 } })].map(|j| with("job", j))) + .chain([ + with("cases", many(100, json!({ "inputs": {} }))), + with("intent", many(100, json!({ "distribution": { "1": 1.0 } }))), + with("cases", json!([{ "inputs": { "err": 3.0 } }])), + with("cases", json!([{ "inputs": {}, "colour": 1 }])), + with("cases", json!(5)), + with("constrains", json!({ "t_count": { "max": 0 } })), + with("schema", json!("wai.quantum.job/1")), + json!({ "cases": [{ "inputs": {} }] }), + json!([1]), + ]) + .collect(); + for ex in bad { + let r = std::panic::catch_unwind(|| check_exercise(&ex, &src, CAP, "en")); + let r = r.unwrap_or_else(|_| panic!("the check panicked on {ex}")); + assert!(r.is_err(), "{ex} was checked, not refused: {:#}", r.unwrap()); + } + } + + /// A case's inputs bind: a program that does not declare an input a case + /// gives is refused there, not run without it. And the constraints are held + /// in every case, not only the first: a gate one case alone adds is counted. + #[test] + fn every_case_binds_its_inputs_and_is_held_to_the_constraints() { + // The bit-flip code with no flip at all: it would pass every case unbound. + let no_flip = format!("{HEAD}qubit[3] q;\nbit[1] c;\nx q[0];\nc[0] = measure q[0];\n"); + let c = check("bit-flip-code", &no_flip, CAP, "en").unwrap(); + assert_eq!(c["passed"], false, "{c:#}"); + assert!(c["what_went_wrong"].as_array().unwrap().iter().any(|w| w["text"].as_str().unwrap().contains("Q114")), "{c:#}"); + // Two x gates more in the second case only: the intent holds in both. + let ex = json!({ + "intent": { "distribution": { "1": 1.0 } }, + "cases": [{ "name": "no flip", "inputs": { "err": 0 } }, { "name": "a flip", "inputs": { "err": 1 } }], + "constraints": { "counts": { "x": { "max": 1 } } }, + }); + let src = format!("{HEAD}input uint err;\nqubit[1] q;\nbit[1] c;\nx q[0];\nif (err == 1) {{\n x q[0];\n x q[0];\n}}\nc[0] = measure q[0];\n"); + let c = check_exercise(&ex, &src, CAP, "en").unwrap(); + assert!(c["cases"].as_array().unwrap().iter().all(|k| k["passed"] == true), "{c:#}"); + assert_eq!((c["passed"].as_bool(), c["constraints"][0]["holds"].as_bool(), c["constraints"][0]["found"].as_u64()), (Some(false), Some(false), Some(3)), "{c:#}"); + let w = &c["what_went_wrong"][0]; + assert_eq!((w["kind"].as_str(), w["case"].as_u64()), (Some("constraint"), Some(1)), "{c:#}"); + assert!(w["text"].as_str().unwrap().starts_with("With a flip:"), "{w}"); + // Within the bound in every case, it passes, and says what the first case found. + let ok = check_exercise(&ex, &src.replace(" x q[0];\n x q[0];\n", " z q[0];\n"), CAP, "en").unwrap(); + assert_eq!((ok["passed"].as_bool(), ok["constraints"][0]["found"].as_u64()), (Some(true), Some(1)), "{ok:#}"); + // A case whose intent is null takes the exercise's, as one without does. + let null = json!({ "intent": { "distribution": { "1": 1.0 } }, "cases": [{ "name": "a", "inputs": {}, "intent": null }] }); + let one = format!("{HEAD}qubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;\n"); + let c = check_exercise(&null, &one, CAP, "en").unwrap(); + assert_eq!((c["passed"].as_bool(), c["level"].as_str()), (Some(true), Some("proof")), "{c:#}"); + } + + /// A T count counts T gates however they are written: a phase rotation by + /// an odd multiple of π/4 is a T up to Clifford gates, and a gate whose T + /// count is not fixed does not hold to a bound on it. A gate on three qubits + /// is not bounded by a count of two-qubit gates. + #[test] + fn a_t_count_counts_t_gates_however_they_are_written() { + // The t-count lesson's own intent, each T written as p(π/4): the same circuit. + let (c, i) = find("t-count").unwrap(); + let reference = c.lessons[i].exercise["intent"]["equivalent"].as_str().unwrap(); + let respelled = reference.replace("tdg q[1];", "p(-pi/4) q[1];").replace("t q[0];", "p(pi/4) q[0];").replace("t q[1];", "p(pi/4) q[1];"); + assert!(!respelled.contains("\nt ") && !respelled.contains("tdg q"), "{respelled}"); + let r = check("t-count", &respelled, CAP, "en").unwrap(); + assert_eq!(r["cases"][0]["passed"], true, "the respelling is the same circuit: {r:#}"); + assert_eq!((r["passed"].as_bool(), r["constraints"][0]["found"].as_u64()), (Some(false), Some(8)), "{r:#}"); + let task = |c: Value, intent: &str| json!({ "intent": { "distribution": { intent: 1.0 } }, "constraints": c }); + // A rotation that is no T: its T count is not fixed, so no bound holds. + let rz = check_exercise(&task(json!({ "t_count": { "max": 0 } }), "0"), &format!("{HEAD}qubit[1] q;\nbit[1] c;\nrz(0.3) q[0];\nc = measure q;\n"), CAP, "en").unwrap(); + assert_eq!((rz["passed"].as_bool(), rz["constraints"][0]["holds"].as_bool()), (Some(false), Some(false)), "{rz:#}"); + assert!(rz["what_went_wrong"][0]["text"].as_str().unwrap().contains("rz gate"), "{rz:#}"); + // A Clifford rotation counts none; T and p(π/4) count one each. + let mixed = check_exercise(&task(json!({ "t_count": { "max": 1 } }), "0"), &format!("{HEAD}qubit[1] q;\nbit[1] c;\np(pi/2) q[0];\nt q[0];\np(pi/4) q[0];\nc = measure q;\n"), CAP, "en").unwrap(); + assert_eq!((mixed["passed"].as_bool(), mixed["constraints"][0]["found"].as_u64()), (Some(false), Some(2)), "{mixed:#}"); + // A Toffoli is not a two-qubit gate, so a bound on them does not bound it. + let ccx = format!("{HEAD}qubit[3] q;\nbit[3] c;\nx q[0];\nx q[1];\nccx q[0], q[1], q[2];\nc = measure q;\n"); + let wide = check_exercise(&task(json!({ "two_qubit": { "max": 0 } }), "111"), &ccx, CAP, "en").unwrap(); + assert_eq!((wide["passed"].as_bool(), wide["constraints"][0]["holds"].as_bool()), (Some(false), Some(false)), "{wide:#}"); + assert!(wide["what_went_wrong"][0]["text"].as_str().unwrap().contains("ccx gate"), "{wide:#}"); + let two = check_exercise(&task(json!({ "two_qubit": { "max": 1 } }), "11"), &format!("{HEAD}qubit[2] q;\nbit[2] c;\nx q[0];\ncx q[0], q[1];\nc = measure q;\n"), CAP, "en").unwrap(); + assert_eq!((two["passed"].as_bool(), two["constraints"][0]["found"].as_u64()), (Some(true), Some(1)), "{two:#}"); + } + #[test] fn a_check_says_what_went_wrong_from_the_engine_and_never_shows_the_solution() { // A distribution: the largest gap is named, and the pointer is to it. -- 2.54.0 (Apple Git-157) From 423af2a3257ab6cc2d1a8cd79b1dec60131e9953 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 6 Nov 2026 03:09:51 -0500 Subject: [PATCH 5/7] wai-quantum: a T count, a two-qubit count and an estimate's bound are "at most n" alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The course words these bounds as "at most n" (constraint.t_count, two_qubit, estimate), and the check read a min too: {"t_count": {"min": 2}} showed the model "at most ? T and T† gates" while it failed every program with fewer than two. The reader now takes {"max": n} alone for them (engine::course::at_most), and exercise.v1.json says so ($defs/at_most), with the T count's description saying how T gates are counted. No lesson uses a min there. Found sweeping the review's family of claims the check makes that the code does not keep. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/schemas/exercise.v1.json | 32 +++++++++++++++++------- wai/wai-quantum/src/engine/course.rs | 18 +++++++++++-- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/wai/wai-quantum/schemas/exercise.v1.json b/wai/wai-quantum/schemas/exercise.v1.json index 1bde4662d..16361789d 100644 --- a/wai/wai-quantum/schemas/exercise.v1.json +++ b/wai/wai-quantum/schemas/exercise.v1.json @@ -78,6 +78,20 @@ "minimum": 0 } } + }, + "at_most": { + "type": "object", + "description": "A bound the course says as at most n: its max alone", + "additionalProperties": false, + "required": [ + "max" + ], + "properties": { + "max": { + "type": "integer", + "minimum": 0 + } + } } }, "properties": { @@ -179,12 +193,12 @@ "description": "Bounds on how many of a gate the program has" }, "t_count": { - "$ref": "#/$defs/bound", - "description": "A bound on the T and T† gates, as the critique counts them" + "$ref": "#/$defs/at_most", + "description": "A bound on the T gates: a t or tdg counts one, a p or rz by an odd multiple of π/4 one (a T up to Clifford gates), a Clifford gate none; a program with any other gate holds to no T count" }, "two_qubit": { - "$ref": "#/$defs/bound", - "description": "A bound on the two-qubit gates" + "$ref": "#/$defs/at_most", + "description": "A bound on the two-qubit gates; a program with a gate on three qubits holds to none" }, "no_smells": { "type": "array", @@ -206,19 +220,19 @@ "additionalProperties": false, "properties": { "toffolis": { - "$ref": "#/$defs/bound" + "$ref": "#/$defs/at_most" }, "t_gates": { - "$ref": "#/$defs/bound" + "$ref": "#/$defs/at_most" }, "rotations": { - "$ref": "#/$defs/bound" + "$ref": "#/$defs/at_most" }, "logical_qubits": { - "$ref": "#/$defs/bound" + "$ref": "#/$defs/at_most" }, "t_states": { - "$ref": "#/$defs/bound" + "$ref": "#/$defs/at_most" } } } diff --git a/wai/wai-quantum/src/engine/course.rs b/wai/wai-quantum/src/engine/course.rs index cfc0b244b..9b6171985 100644 --- a/wai/wai-quantum/src/engine/course.rs +++ b/wai/wai-quantum/src/engine/course.rs @@ -184,6 +184,17 @@ fn bound(v: &Value, what: &str) -> Result<(), String> { Ok(()) } +/// A bound the course says as "at most n" (a T count, a two-qubit count, an +/// estimate's count): `{"max": n}` alone, so what the check holds a program to +/// is what its words say. +fn at_most(v: &Value, what: &str) -> Result<(), String> { + bound(v, what)?; + if v.as_object().is_none_or(|o| o.len() != 1 || !o.contains_key("max")) { + return Err(format!("{what}: {{\"max\": n}} alone: the course says this bound as at most n")); + } + Ok(()) +} + /// A field of an exercise or a case, null read as absent. fn present<'a>(o: &'a Map, k: &str) -> Option<&'a Value> { o.get(k).filter(|v| !v.is_null()) @@ -282,7 +293,7 @@ fn asks(o: &Map, id: &str) -> Result<(), String> { } for k in ["t_count", "two_qubit"] { if let Some(b) = co.get(k) { - bound(b, &format!("{id}: {k}"))?; + at_most(b, &format!("{id}: {k}"))?; } } if let Some(s) = co.get("no_smells") { @@ -296,7 +307,7 @@ fn asks(o: &Map, id: &str) -> Result<(), String> { if !ESTIMATE_COUNTS.contains(&k.as_str()) { return Err(format!("{id}: estimate: {k} is not a count the estimate gives ({})", ESTIMATE_COUNTS.join(", "))); } - bound(b, &format!("{id}: estimate: {k}"))?; + at_most(b, &format!("{id}: estimate: {k}"))?; } } } @@ -1308,6 +1319,9 @@ mod tests { json!({ "t_count": { "max": "0" } }), json!({ "t_count": { "maximum": 0 } }), json!({ "t_count": { "min": 3, "max": 1 } }), + json!({ "t_count": { "min": 1 } }), + json!({ "two_qubit": { "min": 0, "max": 3 } }), + json!({ "estimate": { "toffolis": { "min": 1 } } }), json!({ "two_qubit": {} }), json!({ "counts": { "CX": { "max": 0 } } }), json!({ "counts": 5 }), -- 2.54.0 (Apple Git-157) From 12496404b5c57eac2bda955b61c0b9f0dbc4bb89 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 6 Nov 2026 07:58:08 -0500 Subject: [PATCH 6/7] wai-quantum: an injected error is corrected from the qubits, never read: the error-correction lessons check behaviour The second review found finding 1 closed only for undeclared inputs. A program that declared `input uint err;` and never used it passed the bit-flip lesson at proof, and one that branched on the classical err passed the syndrome lesson. A new constraint, `inject`, names the input whose value is the qubit an error flips. The cases' programs may then differ in that one gate alone, an x on the qubit the case names, every other gate, measurement and step the same (course::injected). So the error is in the quantum state, the program cannot read it classically, and what finds and corrects it is the program's quantum operations, the same in every case. A program that never applies the error, reads it elsewhere, or flips another qubit fails, saying which. A second, `qubits` ({"max": n}), closes the last way round: an answer kept on a qubit no error reaches. bit-flip-code and syndrome set both; their solutions and wrong answers keep their results. The exercise schema names both, and a case set an injection cannot be made from is refused. Test an_injected_error_is_corrected_from_the_qubits_and_never_read, failing on ba59c776e for the reason it states (the declared, unused input passed bit-flip-code). Course tests 13 -> 14 in wai-quantum-docs.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-quantum-docs.yml | 5 +- ...-quantum-any-exercise-checked-as-lesson.md | 5 + .../lessons/bit-flip-code/exercise.json | 2 +- .../course/lessons/syndrome/exercise.json | 2 +- wai/wai-quantum/course/locales/en.json | 8 ++ wai/wai-quantum/course/locales/zh-CN.json | 8 ++ wai/wai-quantum/schemas/exercise.v1.json | 9 ++ wai/wai-quantum/src/engine/course.rs | 118 +++++++++++++++++- 8 files changed, 148 insertions(+), 9 deletions(-) diff --git a/.github/workflows/wai-quantum-docs.yml b/.github/workflows/wai-quantum-docs.yml index d439a3daf..75dedeef7 100644 --- a/.github/workflows/wai-quantum-docs.yml +++ b/.github/workflows/wai-quantum-docs.yml @@ -125,7 +125,7 @@ jobs: run: | set -euo pipefail cargo test --locked --release --features full --lib -- engine::course:: 2>&1 | tee "$RUNNER_TEMP/course.log" - grep -qE '^test result: ok\. 13 passed; 0 failed' "$RUNNER_TEMP/course.log" || { echo "::error::the course's tests did not all pass"; exit 1; } + grep -qE '^test result: ok\. 14 passed; 0 failed' "$RUNNER_TEMP/course.log" || { echo "::error::the course's tests did not all pass"; exit 1; } for t in every_solution_passes_its_exercise_and_every_known_wrong_answer_fails_it \ a_check_says_what_went_wrong_from_the_engine_and_never_shows_the_solution \ no_hint_or_lesson_gives_the_solution \ @@ -138,7 +138,8 @@ jobs: an_exercise_outside_the_course_is_checked_as_a_lesson_is \ a_malformed_exercise_is_refused_before_anything_is_checked \ every_case_binds_its_inputs_and_is_held_to_the_constraints \ - a_t_count_counts_t_gates_however_they_are_written; do + a_t_count_counts_t_gates_however_they_are_written \ + an_injected_error_is_corrected_from_the_qubits_and_never_read; do grep -q "^test engine::course::tests::$t \.\.\. ok$" "$RUNNER_TEMP/course.log" || { echo "::error::$t did not run"; exit 1; } done - name: The command line's course and its languages diff --git a/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md index 1d3cddefd..cb66587a3 100644 --- a/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md +++ b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md @@ -16,6 +16,11 @@ bytes for a program that passes. `intent` is null takes the exercise's, as one with none does. - **The constraints hold in every case**, not only the first: a gate one case alone adds is counted, and the case is named in what went wrong. +- **An injected error is corrected from the qubits.** A new constraint, `inject`, names the input whose value is the + qubit an error flips: the cases' programs may differ in that one x alone, so the program cannot read the error and + must find and correct it with its quantum operations. Another, `qubits` (`{"max": n}`), bounds the qubits a program + declares. The bit-flip and syndrome lessons set both: before, a program that declared the error and never applied + it passed the bit-flip lesson, and one that branched on it classically passed the syndrome lesson. - **A T count counts T gates however they are written:** a `p` or `rz` by an odd multiple of π/4 counts one, a Clifford gate none, and any other gate (a rotation by another angle, a Toffoli) does not hold to a T count, since its count is not fixed. A gate on three qubits does not hold to a count of two-qubit gates. Before, `t_count` diff --git a/wai/wai-quantum/course/lessons/bit-flip-code/exercise.json b/wai/wai-quantum/course/lessons/bit-flip-code/exercise.json index 70270c302..55ae3f5f1 100644 --- a/wai/wai-quantum/course/lessons/bit-flip-code/exercise.json +++ b/wai/wai-quantum/course/lessons/bit-flip-code/exercise.json @@ -27,7 +27,7 @@ { "inputs": { "err": 2 }, "name": { "en": "a flip of q[2]", "zh-CN": "q[2] 被翻转" } } ], "intent": { "distribution": { "1": 1.0 } }, - "constraints": { "allow": ["x", "cx", "ccx"], "counts": { "x": { "max": 2 } } }, + "constraints": { "allow": ["x", "cx", "ccx"], "counts": { "x": { "max": 2 } }, "qubits": { "max": 3 }, "inject": "err" }, "hints": { "en": [ "Encode with two controlled-NOTs from q[0]: |1⟩ becomes |111⟩, and one flip leaves two copies right.", diff --git a/wai/wai-quantum/course/lessons/syndrome/exercise.json b/wai/wai-quantum/course/lessons/syndrome/exercise.json index ccc3a7fc9..2b2f28fae 100644 --- a/wai/wai-quantum/course/lessons/syndrome/exercise.json +++ b/wai/wai-quantum/course/lessons/syndrome/exercise.json @@ -24,7 +24,7 @@ { "inputs": { "err": 1 }, "name": { "en": "a flip of q[1]", "zh-CN": "q[1] 被翻转" }, "intent": { "distribution": { "11": 1.0 } } }, { "inputs": { "err": 2 }, "name": { "en": "a flip of q[2]", "zh-CN": "q[2] 被翻转" }, "intent": { "distribution": { "10": 1.0 } } } ], - "constraints": { "allow": ["x", "cx"], "counts": { "x": { "max": 1 }, "cx": { "max": 4 } } }, + "constraints": { "allow": ["x", "cx"], "counts": { "x": { "max": 1 }, "cx": { "max": 4 } }, "qubits": { "max": 5 }, "inject": "err" }, "hints": { "en": [ "A controlled-NOT from a data qubit onto an ancilla in |0⟩ adds that qubit's value to the ancilla, mod 2.", diff --git a/wai/wai-quantum/course/locales/en.json b/wai/wai-quantum/course/locales/en.json index a42757224..4c53645ac 100644 --- a/wai/wai-quantum/course/locales/en.json +++ b/wai/wai-quantum/course/locales/en.json @@ -24,6 +24,8 @@ "constraint.between": "between {min} and {max} {gate}", "constraint.t_count": "at most {n} T and T† gates", "constraint.two_qubit": "at most {n} two-qubit gates", + "constraint.qubits": "at most {n} qubits", + "constraint.inject": "the error {input} flips qubit {input} with one x, and the program uses {input} nowhere else", "constraint.no_smells": "no smell of these kinds from the critique: {kinds}", "constraint.estimate": "a resource estimate counting at most {n} {what}", "estimate.toffolis": "Toffoli gates", @@ -48,6 +50,10 @@ "wrong.two_qubit": "Your program has {found} two-qubit gates, and the exercise allows {limit}.", "wrong.t_count_other": "Your program has a {gate} gate, which is neither a Clifford nor a T gate, so its T count is not fixed: the exercise allows {limit} T and T† gates.", "wrong.two_qubit_wide": "Your program has a {gate} gate, which acts on three qubits, so a count of two-qubit gates does not bound it: the exercise allows {limit} two-qubit gates.", + "wrong.qubits": "Your program has {found} qubits, and the exercise allows {limit}.", + "wrong.inject_unused": "Your program never flips qubit {input} with {input}: put `x q[{input}];` where the error happens, and correct it after.", + "wrong.inject_read": "Your program uses {input} beyond flipping qubit {input} once with x, so it could know the error without looking at the qubits: find the error from the qubits.", + "wrong.inject_where": "The gate {input} changes in your program is not an x on qubit {input}: flip that qubit with `x q[{input}];`.", "wrong.smell": "The critique finds a smell the exercise rules out, at line(s) {lines}.", "wrong.note": "The critique notes something at line(s) {lines}.", "wrong.estimate": "The resource estimate counts {found} {what}, and the exercise allows {limit}.", @@ -64,6 +70,8 @@ "pointer.two_qubit": "Look for two-qubit gates you can do without.", "pointer.t_count_other": "Write each {gate} with T, T† and Clifford gates.", "pointer.two_qubit_wide": "Write each {gate} with one- and two-qubit gates.", + "pointer.qubits": "Use only the qubits the exercise needs.", + "pointer.inject": "Look at every line that uses {input}.", "pointer.smell": "Look at line(s) {lines}: the critique flags them.", "pointer.estimate": "Look for work the program does and then undoes.", "pointer.case": "Try the case {name} first.", diff --git a/wai/wai-quantum/course/locales/zh-CN.json b/wai/wai-quantum/course/locales/zh-CN.json index 22b979c6e..c2ecc6c11 100644 --- a/wai/wai-quantum/course/locales/zh-CN.json +++ b/wai/wai-quantum/course/locales/zh-CN.json @@ -24,6 +24,8 @@ "constraint.between": "{min} 到 {max} 个 {gate}", "constraint.t_count": "T 与 T† 门合计至多 {n} 个", "constraint.two_qubit": "双比特门至多 {n} 个", + "constraint.qubits": "至多 {n} 个量子比特", + "constraint.inject": "错误 {input} 只由一个 x 翻转第 {input} 个量子比特,程序在别处不使用 {input}", "constraint.no_smells": "评析中不出现以下类型的问题模式:{kinds}", "constraint.estimate": "资源估算计入的 {what} 至多 {n} 个", "estimate.toffolis": "Toffoli 门", @@ -48,6 +50,10 @@ "wrong.two_qubit": "你的程序有 {found} 个双比特门,练习只允许 {limit}。", "wrong.t_count_other": "你的程序有 {gate} 门,它既不是 Clifford 门也不是 T 门,所以它的 T 门数不确定:练习只允许 {limit} 个 T 与 T† 门。", "wrong.two_qubit_wide": "你的程序有 {gate} 门,它作用于三个量子比特,双比特门的计数约束不了它:练习只允许 {limit} 个双比特门。", + "wrong.qubits": "你的程序有 {found} 个量子比特,练习只允许 {limit}。", + "wrong.inject_unused": "你的程序从未用 {input} 翻转第 {input} 个量子比特:在错误发生处写 `x q[{input}];`,再在之后纠正它。", + "wrong.inject_read": "你的程序除了用一个 x 翻转第 {input} 个量子比特之外还用到了 {input},于是不看量子比特也能知道错误:请从量子比特中找出错误。", + "wrong.inject_where": "{input} 在你的程序中改变的门不是作用在第 {input} 个量子比特上的 x:请用 `x q[{input}];` 翻转该量子比特。", "wrong.smell": "评析在第 {lines} 行发现了这道练习所排除的问题模式。", "wrong.note": "评析在第 {lines} 行有一条提示。", "wrong.estimate": "资源估算计入了 {found} 个{what},练习只允许 {limit}。", @@ -64,6 +70,8 @@ "pointer.two_qubit": "找一找可以省掉的双比特门。", "pointer.t_count_other": "用 T、T† 与 Clifford 门写出每个 {gate}。", "pointer.two_qubit_wide": "用单比特门与双比特门写出每个 {gate}。", + "pointer.qubits": "只使用练习需要的量子比特。", + "pointer.inject": "检查每一行用到 {input} 的地方。", "pointer.smell": "看第 {lines} 行:评析标出了它们。", "pointer.estimate": "找一找程序先做了又撤销掉的工作。", "pointer.case": "先试试{name}的情况。", diff --git a/wai/wai-quantum/schemas/exercise.v1.json b/wai/wai-quantum/schemas/exercise.v1.json index 16361789d..7210be7da 100644 --- a/wai/wai-quantum/schemas/exercise.v1.json +++ b/wai/wai-quantum/schemas/exercise.v1.json @@ -235,6 +235,15 @@ "$ref": "#/$defs/at_most" } } + }, + "qubits": { + "$ref": "#/$defs/at_most", + "description": "A bound on the qubits the program declares" + }, + "inject": { + "type": "string", + "minLength": 1, + "description": "The input whose value, in each case, is the qubit an error flips: the cases' programs may differ only in that one x, so the program finds and corrects the error from the qubits and cannot read it otherwise. Every case gives it, and at least two give different ones" } } }, diff --git a/wai/wai-quantum/src/engine/course.rs b/wai/wai-quantum/src/engine/course.rs index 9b6171985..5940fb7d6 100644 --- a/wai/wai-quantum/src/engine/course.rs +++ b/wai/wai-quantum/src/engine/course.rs @@ -58,7 +58,7 @@ pub const EXERCISE_FIELDS: [&str; 13] = ["schema", "id", "translations", "title" /// The fields an exercise must have. pub const EXERCISE_REQUIRED: [&str; 8] = ["schema", "id", "title", "task", "starter", "hints", "reference", "wrong"]; /// The constraints an exercise may set. -pub const CONSTRAINTS: [&str; 6] = ["allow", "counts", "t_count", "two_qubit", "no_smells", "estimate"]; +pub const CONSTRAINTS: [&str; 8] = ["allow", "counts", "t_count", "two_qubit", "no_smells", "estimate", "qubits", "inject"]; /// What an exercise's job may give. pub const JOB_FIELDS: [&str; 3] = ["shots", "seed", "noise"]; /// The resource estimate's counts a constraint may bound. @@ -310,6 +310,16 @@ fn asks(o: &Map, id: &str) -> Result<(), String> { at_most(b, &format!("{id}: estimate: {k}"))?; } } + if let Some(b) = co.get("qubits") { + at_most(b, &format!("{id}: qubits"))?; + } + if let Some(i) = co.get("inject") { + let name = i.as_str().filter(|n| !n.is_empty()).ok_or_else(|| format!("{id}: inject names the input whose value is the qubit an error flips, as \"err\""))?; + let values: Vec> = cases.and_then(Value::as_array).into_iter().flatten().map(|c| c.pointer(&format!("/inputs/{name}")).and_then(Value::as_i64)).collect(); + if values.iter().any(|v| v.is_none_or(|v| v < 0)) || values.iter().collect::>().len() < 2 { + return Err(format!("{id}: inject: every case gives {name}, a qubit (a whole number from 0), and at least two cases give different ones")); + } + } } Ok(()) } @@ -569,9 +579,46 @@ fn constraint_texts(c: &Value, loc: &str) -> Vec<(String, String, String)> { for (k, b) in o.get("estimate").and_then(Value::as_object).into_iter().flatten() { out.push(("estimate".into(), k.clone(), say(loc, "constraint.estimate", &[("n", b["max"].as_u64().map_or("?".into(), |n| n.to_string())), ("what", say(loc, &format!("estimate.{k}"), &[]))]))); } + if let Some(b) = o.get("qubits") { + out.push(("qubits".into(), String::new(), say(loc, "constraint.qubits", &[("n", b["max"].as_u64().map_or("?".into(), |n| n.to_string()))]))); + } + if let Some(i) = o.get("inject").and_then(Value::as_str) { + out.push(("inject".into(), i.to_string(), say(loc, "constraint.inject", &[("input", i.to_string())]))); + } out } +/// Whether the programs of a task's cases (each with the value its case gives the input an +/// error is injected by) differ only where the error is injected: one gate, an x on the qubit +/// the case's value names, every other gate, measurement and step of every case's program the +/// same. Then the error is in the quantum state and nothing else in the program reads it, so +/// what corrects or detects it is the program's quantum operations, the same in every case. +/// Otherwise, the locale key of why not. +fn injected(programs: &[(&qasm::Program, i64)]) -> Result<(), &'static str> { + let Some((first, _)) = programs.first() else { return Ok(()) }; + if programs.iter().any(|(p, _)| p.ops.len() != first.ops.len()) { + return Err("wrong.inject_read"); + } + let differ: Vec = (0..first.ops.len()).filter(|&j| programs.iter().any(|(p, _)| p.ops[j] != first.ops[j])).collect(); + let at = match differ[..] { + [] => return Err("wrong.inject_unused"), + [at] => at, + _ => return Err("wrong.inject_read"), + }; + if programs.iter().any(|(p, v)| p.ops[at].kind != qasm::Kind::X || u32::try_from(*v).ok().is_none_or(|q| p.ops[at].qubits != [q])) { + return Err("wrong.inject_where"); + } + let rest = |p: &qasm::Program| { + let mut ops = p.ops.clone(); + ops[at] = first.ops[at].clone(); + (p.n_qubits, p.n_bits, ops, p.measured.clone(), p.dynamic.clone()) + }; + if programs.iter().any(|(p, _)| rest(p) != rest(first)) { + return Err("wrong.inject_read"); + } + Ok(()) +} + /// The cases a program is checked in: each with its name, the inputs it /// gives a generator, and its intent. fn cases_of<'a>(ex: &'a Value) -> Vec<(Option<&'a Value>, qasm::Inputs, Value)> { @@ -865,6 +912,7 @@ fn two_qubit_gates(p: &qasm::Program) -> Result { struct Measured<'a> { case: Option, name: Option, + inputs: qasm::Inputs, text: &'a str, critique: Value, program: Option, @@ -878,7 +926,7 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo let mut case_out = Vec::new(); // Each case's program as it reads there (a generator at the case's inputs), // with the case's number and name: what the constraints are held to. - let mut texts: Vec<(Option, Option, String)> = Vec::new(); + let mut texts: Vec<(Option, Option, String, qasm::Inputs)> = Vec::new(); let mut decided = true; let mut levels: Vec = Vec::new(); for (n, (name, inputs, intent)) in cases.iter().enumerate() { @@ -901,7 +949,7 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo continue; } }; - texts.push((case, name.map(|x| text_in(x, loc).to_string()), text.clone())); + texts.push((case, name.map(|x| text_in(x, loc).to_string()), text.clone(), inputs.clone())); let mut req = ex.get("job").filter(|j| j.is_object()).cloned().unwrap_or(json!({})); req["qasm"] = json!(text); req["intent"] = intent.clone(); @@ -944,18 +992,31 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo // that one case alone uses is held to them as the first case's are. A // constraint holds when it holds in every case; what it found is the first // case's, or the first case's where it does not hold. - let first_text = texts.first().map(|(_, _, t)| t.clone()); + let first_text = texts.first().map(|(_, _, t, _)| t.clone()); let mut constraints = Vec::new(); let mut measures = Value::Null; let mut estimate_out = Value::Null; let cons = ex.get("constraints").filter(|c| c.is_object()).cloned().unwrap_or(json!({})); - let measured: Vec = texts.iter().map(|(c, nm, t)| Measured { case: *c, name: nm.clone(), text: t, critique: critique::critique(t), program: qasm::parse(t).ok() }).collect(); + let measured: Vec = texts.iter().map(|(c, nm, t, ins)| Measured { case: *c, name: nm.clone(), inputs: ins.clone(), text: t, critique: critique::critique(t), program: qasm::parse(t).ok() }).collect(); if let Some(m0) = measured.first().map(|x| &x.critique) && m0["ok"] == true { measures = json!({ "qubits": m0["qubits"], "gates": m0["gates"], "two_qubit": m0["two_qubit"], "t_count": m0["t_count"], "depth": m0["depth"], "by_gate": m0["by_gate"] }); let mut estimates: Vec = Vec::new(); for (rule, subject, text_c) in constraint_texts(&cons, loc) { + // An injected error is held across the cases' programs at once. + if rule == "inject" { + let programs: Vec<(&qasm::Program, i64)> = measured.iter().filter_map(|x| Some((x.program.as_ref()?, *x.inputs.get(&subject)?))).collect(); + if programs.len() < 2 { + continue; + } + let holds = injected(&programs); + if let Err(why) = holds { + wrongs.push(Wrong { kind: "constraint", text: say(loc, why, &[("input", subject.clone())]), engine: None, lines: Vec::new(), case: None, pointer: say(loc, "pointer.inject", &[("input", subject.clone())]) }); + } + constraints.push(json!({ "rule": rule, "subject": subject, "text": text_c, "holds": holds.is_ok(), "found": holds.err().map(|w| w.trim_start_matches("wrong.inject_")) })); + continue; + } let mut row: Option<(bool, Value, Option)> = None; for (k, Measured { case, name: case_name, critique: m, program, .. }) in measured.iter().enumerate() { if m["ok"] != true { @@ -1025,6 +1086,19 @@ fn check_exercise_as(ex: &Value, id: Option<&str>, src: &str, job_bytes: u64, lo } } } + "qubits" => { + let b = &cons["qubits"]; + let n = m["qubits"].as_u64().unwrap_or(0); + let w = (!within(n, b)).then(|| Wrong { + kind: "constraint", + text: say(loc, "wrong.qubits", &[("found", n.to_string()), ("limit", b["max"].as_u64().map_or("?".into(), |x| x.to_string()))]), + engine: None, + lines: Vec::new(), + case: None, + pointer: say(loc, "pointer.qubits", &[]), + }); + (within(n, b), json!(n), w) + } "no_smells" => { let kinds: Vec<&str> = cons["no_smells"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); let found: Vec<&Value> = m["smells"].as_array().into_iter().flatten().filter(|s| s["kind"].as_str().is_some_and(|k| kinds.contains(&k))).collect(); @@ -1389,6 +1463,40 @@ mod tests { assert_eq!((c["passed"].as_bool(), c["level"].as_str()), (Some(true), Some("proof")), "{c:#}"); } + /// An error a case injects is corrected from the qubits: the cases' programs differ only in + /// the one x the error is, so a program that declares the error and never applies it, reads + /// it classically, or flips another qubit does not pass, nor one that keeps its answer on a + /// qubit the error never reaches. + #[test] + fn an_injected_error_is_corrected_from_the_qubits_and_never_read() { + let inject = |c: &Value| c["constraints"].as_array().unwrap().iter().find(|r| r["rule"] == "inject").cloned().unwrap_or(Value::Null); + // Declared, never applied: every case's program the same, the answer 1 at once. + let unused = format!("{HEAD}input uint err;\nqubit[3] q;\nbit[1] c;\nx q[0];\nc[0] = measure q[0];\n"); + let c = check("bit-flip-code", &unused, CAP, "en").unwrap(); + assert_eq!((c["passed"].as_bool(), inject(&c)["holds"].as_bool(), inject(&c)["found"].as_str()), (Some(false), Some(false), Some("unused")), "{c:#}"); + // The syndrome decided on the classical error, not measured from the qubits. + let oracle = format!("{HEAD}input uint err;\nqubit[5] q;\nbit[2] s;\nx q[err];\nif (err == 0) {{ cx q[0], q[3]; }}\nif (err == 1) {{ cx q[1], q[3]; cx q[1], q[4]; }}\nif (err == 2) {{ cx q[2], q[4]; }}\ns[0] = measure q[3];\ns[1] = measure q[4];\n"); + let c = check("syndrome", &oracle, CAP, "en").unwrap(); + assert!(c["cases"].as_array().unwrap().iter().all(|k| k["passed"] == true), "the oracle's syndromes are right: {c:#}"); + assert_eq!((c["passed"].as_bool(), inject(&c)["found"].as_str()), (Some(false), Some("read")), "{c:#}"); + // Another qubit flipped than the case names. + let elsewhere = format!("{HEAD}input uint err;\nqubit[3] q;\nbit[1] c;\nx q[0];\ncx q[0], q[1];\ncx q[0], q[2];\nx q[2 - err];\ncx q[0], q[1];\ncx q[0], q[2];\nccx q[2], q[1], q[0];\nc[0] = measure q[0];\n"); + let c = check("bit-flip-code", &elsewhere, CAP, "en").unwrap(); + assert_eq!((c["passed"].as_bool(), inject(&c)["found"].as_str()), (Some(false), Some("where")), "{c:#}"); + // The answer kept on a fourth qubit, which no error reaches. + let aside = format!("{HEAD}input uint err;\nqubit[4] q;\nbit[1] c;\nx q[3];\nx q[err];\nc[0] = measure q[3];\n"); + let c = check("bit-flip-code", &aside, CAP, "en").unwrap(); + assert_eq!(inject(&c)["holds"], true, "{c:#}"); + assert!(c["passed"] == false && c["constraints"].as_array().unwrap().iter().any(|r| r["rule"] == "qubits" && r["holds"] == false), "{c:#}"); + // The lesson's own solution holds to it. + let (l, i) = find("bit-flip-code").unwrap(); + let c = check("bit-flip-code", l.lessons[i].exercise["reference"].as_str().unwrap(), CAP, "en").unwrap(); + assert_eq!((c["passed"].as_bool(), inject(&c)["holds"].as_bool()), (Some(true), Some(true)), "{c:#}"); + // An injection the cases cannot make is refused. + let bad = json!({ "intent": { "distribution": { "1": 1.0 } }, "cases": [{ "inputs": { "err": 0 } }, { "inputs": { "err": 0 } }], "constraints": { "inject": "err" } }); + assert!(check_exercise(&bad, &unused, CAP, "en").unwrap_err().contains("inject")); + } + /// A T count counts T gates however they are written: a phase rotation by /// an odd multiple of π/4 is a T up to Clifford gates, and a gate whose T /// count is not fixed does not hold to a bound on it. A gate on three qubits -- 2.54.0 (Apple Git-157) From 81d3ecd16e87322c77a50025c705fe6a461c991e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 6 Nov 2026 14:54:10 -0500 Subject: [PATCH 7/7] wai-quantum: say the exercise constraints' new length is a change `wai breaking` reported engine::course::CONSTRAINTS going from 6 to 8 entries unacknowledged, so the change entry now says so. Co-Authored-By: Claude Opus 5.5 --- wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md | 1 + 1 file changed, 1 insertion(+) diff --git a/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md index cb66587a3..d9b2f95c9 100644 --- a/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md +++ b/wai/changes/2026-11-05-quantum-any-exercise-checked-as-lesson.md @@ -25,3 +25,4 @@ bytes for a program that passes. Clifford gate none, and any other gate (a rotation by another angle, a Toffoli) does not hold to a T count, since its count is not fixed. A gate on three qubits does not hold to a count of two-qubit gates. Before, `t_count` counted gate names, so the t-count lesson passed with every T written as `p(pi/4)`. +- **Breaking:** `api:rust:wai_quantum::engine::course::CONSTRAINTS`: the array gains `qubits` and `inject`, so its length goes from 6 to 8. Code that names the array's length should read `CONSTRAINTS.len()`; code that iterates it needs no change. -- 2.54.0 (Apple Git-157)