From acbc8139180dd73f8ce31d8be7e47d76222673fc Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Fri, 4 Sep 2026 14:11:16 -0700 Subject: [PATCH 1/4] Add a notebook renderer for QDK Learning multiple-choice questions --- source/npm/qsharp/ux/qdk-theme.css | 13 + source/vscode/authoring-courses.md | 61 ++ source/vscode/build.mjs | 159 ++- source/vscode/package.json | 14 +- .../06-iterative-phase-estimation/_unit.py | 462 +++++++++ .../iterative_phase_estimation.ipynb | 943 +++++++++++++++++- .../courses/chemistry-qpe/_learning_output.py | 349 +++++++ .../utils/chemistry-qpe/README.md | 45 +- .../utils/chemistry-qpe/details_to_quiz.py | 427 ++++++++ .../utils/chemistry-qpe/verify_course.py | 16 +- source/vscode/src/learning/index.ts | 2 + .../vscode/src/learning/notebookExercises.ts | 27 + .../src/learning/notebookRendererMessaging.ts | 159 +++ source/vscode/src/notebookRenderer/css.d.ts | 12 + source/vscode/src/notebookRenderer/index.ts | 180 ++++ .../src/notebookRenderer/multipleChoice.ts | 354 +++++++ .../src/notebookRenderer/rendererApi.d.ts | 30 + .../vscode/src/notebookRenderer/rendering.ts | 116 +++ source/vscode/src/notebookRenderer/schema.ts | 144 +++ source/vscode/src/notebookRenderer/styles.css | 349 +++++++ .../vscode/src/notebookRenderer/tsconfig.json | 17 + source/vscode/tsconfig.json | 7 +- 22 files changed, 3864 insertions(+), 22 deletions(-) create mode 100644 source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py create mode 100644 source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py create mode 100644 source/vscode/src/learning/notebookRendererMessaging.ts create mode 100644 source/vscode/src/notebookRenderer/css.d.ts create mode 100644 source/vscode/src/notebookRenderer/index.ts create mode 100644 source/vscode/src/notebookRenderer/multipleChoice.ts create mode 100644 source/vscode/src/notebookRenderer/rendererApi.d.ts create mode 100644 source/vscode/src/notebookRenderer/rendering.ts create mode 100644 source/vscode/src/notebookRenderer/schema.ts create mode 100644 source/vscode/src/notebookRenderer/styles.css create mode 100644 source/vscode/src/notebookRenderer/tsconfig.json diff --git a/source/npm/qsharp/ux/qdk-theme.css b/source/npm/qsharp/ux/qdk-theme.css index ac38f60c362..6b51a866280 100644 --- a/source/npm/qsharp/ux/qdk-theme.css +++ b/source/npm/qsharp/ux/qdk-theme.css @@ -121,6 +121,14 @@ body[data-vscode-theme-kind="vscode-high-contrast-light"] { --qdk-atom-fill: #0078d4; --qdk-atom-trail: #fa0; + /* Use to mark a self-check question. The chemistry tutorial styles its + collapsible questions with this burnt orange, so anything that asks the + reader something should share it. Paired with a foreground because the + accent is a filled band: the two must be picked together to stay legible, + and which one is the dark half flips between light and dark themes. */ + --qdk-quiz-accent: #8c4a00; + --qdk-quiz-accent-foreground: #ffffff; + /* Circuit diagram: unitary gate box colors */ --qdk-circuit-unitary-fill: #ffffff; --qdk-circuit-unitary-text: #3b3b3b; @@ -193,6 +201,11 @@ body[data-vscode-theme-kind="vscode-high-contrast"] { --qdk-atom-fill: #9df; --qdk-atom-trail: #fa0; + /* Same hue as the light theme's quiz accent, lightened until it separates + from a dark editor background; the band's text goes dark to match. */ + --qdk-quiz-accent: #e0a15e; + --qdk-quiz-accent-foreground: #2b1a05; + /* Chord diagram: 3-stop colormaps (low → mid → high) */ --qdk-chord-node-lo: #3a3a3a; --qdk-chord-node-mid: #e04040; diff --git a/source/vscode/authoring-courses.md b/source/vscode/authoring-courses.md index 4d747a9e0be..f9503769828 100644 --- a/source/vscode/authoring-courses.md +++ b/source/vscode/authoring-courses.md @@ -118,6 +118,67 @@ Use `register_exercise(name, validate, ...)` when a unit needs its own checking; When validation fails, `_course_lib` shows the message and raises, so the cell errors out. Once the cell runs successfully, the exercise will be considered to be complete. +## Quizzes + +A quiz is a multiple-choice question the learner answers in the cell output. +Unlike an exercise, it is a self-check: answering doesn't record progress, so a quiz is a place to think rather than something to complete. + +Register the question in the unit's `_unit.py`, next to the exercise registrations: + +```python +from _learning_output import quiz, register_quiz # noqa: E402, F401 + +register_quiz( + "grid-spacing", + "Which control changes the energy-grid spacing?", + [ + ("bits", "The number of phase bits", True, "It sets how finely the interval is discretized."), + ("shots", "The number of shots per bit", False, "More shots stabilize each bit; spacing is untouched."), + ("space", "The size of the active space", False, "That changes the Hamiltonian itself."), + ], +) +``` + +Each option is `(id, text, correct, explanation)`. +The explanation is shown after the learner commits to a choice, so write it as the reason that option is right or wrong rather than as a hint. +A single-select question needs exactly one correct option, and ids must be unique; anything else raises when the cell runs, so mistakes surface while you're authoring. + +For a question with several right answers, pass `multi_select=True`: + +```python +register_quiz( + "ancilla-traits", + "Which of these are true of the readout ancilla?", + [ + ("h-gates", "It receives the H gates and the feedback rotation", True, "That is what puts it in superposition."), + ("controls", "It controls the Hamiltonian evolution", True, "The controlled-unitary hangs off this wire."), + ("state", "It holds the prepared molecular state", False, "The compute register does that."), + ], + multi_select=True, +) +``` + +The learner then gets checkboxes and an explicit "Select all that apply", and has to find every correct option to pass. +A multi-select question needs at least two correct options and at least one incorrect one — a "select all that apply" with a single answer teaches learners to distrust the instruction, and one where everything applies can't be answered wrongly. + +Options are shuffled, seeded from the quiz id. +It's natural to write the correct answer first, which would otherwise make "always pick A" a winning strategy across a unit. +The order is stable, so re-running the notebook doesn't reshuffle or produce a spurious diff. + +The notebook cell then just names the quiz, and is tagged `quiz` the same way exercise cells are tagged: + +```python +quiz("grid-spacing") +``` + +The tag keeps the cell out of the progress tree, and lets the cell below it still find the section heading above. +One call can name several quizzes (`quiz("a", "b")`) when a section asks two questions in a row - the progress tree names a code cell after the heading above it, so two adjacent quiz cells would appear under the same name. + +Run the cell once and save, so the question ships with the notebook and a learner sees it on opening rather than after running. + +The answers are in the saved cell output, because grading happens in the renderer without a kernel. +This keeps them out of the cell source the learner reads, which is the same protection the collapsible-answer style gave; it isn't a guarantee against a determined learner opening the `.ipynb`. + ## Editing a published course Progress is tracked per cell, using the notebook's nbformat cell IDs. diff --git a/source/vscode/build.mjs b/source/vscode/build.mjs index 26d44a7a641..28e88495ac8 100644 --- a/source/vscode/build.mjs +++ b/source/vscode/build.mjs @@ -3,7 +3,7 @@ //@ts-check -import { copyFileSync, mkdirSync, readdirSync } from "node:fs"; +import { copyFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build as esbuildBuild, context } from "esbuild"; @@ -84,6 +84,22 @@ const platformBuildOptions = { __PLATFORM__: JSON.stringify("node"), }, }, + renderer: { + ...commonBuildOptions, + external: [], + platform: "browser", + format: "esm", + entryPoints: [join(thisDir, "src", "notebookRenderer", "index.ts")], + outfile: join(thisDir, "out", "renderer", "qdkLearning.js"), + // A notebook renderer is loaded as a single JS module — VS Code won't pick + // up a sibling stylesheet — so CSS is bundled as text and injected at + // activation instead of emitted as a separate file. + loader: { ".css": "text" }, + define: { + "import.meta.url": "undefined", + __PLATFORM__: JSON.stringify("browser"), + }, + }, }; // ── Inline worker plugin ──────────────────────────────────────────── @@ -128,6 +144,134 @@ const inlineStateComputeWorkerPlugin = { }, }; +// ── Renderer/emitter contract check ───────────────────────────────── + +/** + * Fail the build if the renderer's schema and the Python emitter have drifted. + * + * The payload contract is written twice — TypeScript types the renderer + * validates against, and the dicts `_learning_output.py` builds — and nothing + * in either type system spans that gap. Checks the values whose disagreement + * breaks a learner: MIME type, payload kinds, schema version, and the field + * names the renderer reads. + */ +export function checkRendererContract() { + const schemaPath = join(thisDir, "src", "notebookRenderer", "schema.ts"); + const rendererPath = join( + thisDir, + "src", + "notebookRenderer", + "multipleChoice.ts", + ); + const emitterPath = join( + thisDir, + "resources", + "qdk-learning", + "courses", + "chemistry-qpe", + "_learning_output.py", + ); + + const schema = readFileSync(schemaPath, "utf8"); + const renderer = readFileSync(rendererPath, "utf8"); + const emitter = readFileSync(emitterPath, "utf8"); + + const mismatches = []; + const required = (label, value) => { + if (value === undefined) { + throw new Error(`Could not read ${label} while checking the contract.`); + } + return value; + }; + + const tsMime = required( + "MIME_TYPE in schema.ts", + /^export const MIME_TYPE = "([^"]+)"/m.exec(schema)?.[1], + ); + const pyMime = required( + "MIME_TYPE in _learning_output.py", + /^MIME_TYPE = "([^"]+)"/m.exec(emitter)?.[1], + ); + if (tsMime !== pyMime) { + mismatches.push(`MIME type differs: "${tsMime}" vs "${pyMime}".`); + } + + // Every payload the emitter builds must name a kind the renderer handles. + const tsKinds = [...schema.matchAll(/^\s+kind: "([a-z-]+)";/gm)].map( + (m) => m[1], + ); + const pyKinds = [...emitter.matchAll(/"kind": "([a-z-]+)"/g)].map( + (m) => m[1], + ); + const unknown = pyKinds.filter((k) => !tsKinds.includes(k)); + if (unknown.length > 0) { + mismatches.push( + `Python emits kinds the renderer does not handle: ${[...new Set(unknown)].join(", ")}.`, + ); + } + + const tsVersion = required( + "SUPPORTED_SCHEMA_VERSION", + /const SUPPORTED_SCHEMA_VERSION = (\d+)/.exec( + readFileSync( + join(thisDir, "src", "notebookRenderer", "index.ts"), + "utf8", + ), + )?.[1], + ); + const pyVersion = required( + '"schemaVersion" in _learning_output.py', + /"schemaVersion": (\d+)/.exec(emitter)?.[1], + ); + if (tsVersion !== pyVersion) { + mismatches.push( + `Schema version differs: renderer accepts ${tsVersion}, emitter writes ${pyVersion}.`, + ); + } + + // Field names the renderer reads off a multiple-choice payload. Renaming one + // on either side leaves the question blank rather than failing loudly — or, + // for `multiSelect`, silently builds a radio group for a question with + // several correct answers, which then cannot be answered at all. + // + // The emitter writes most fields as dict literal keys (`"prompt": ...`) but + // sets optional ones by assignment (`payload["multiSelect"] = True`), so the + // Python probe has to accept both spellings. + const payloadFields = ["prompt", "options", "multiSelect"]; + const optionFields = ["id", "text", "correct", "explanation"]; + for (const field of payloadFields) { + const inTs = new RegExp(`payload\\.${field}\\b`).test(renderer); + const inPy = new RegExp(`"${field}"\\s*(?::|\\])`).test(emitter); + if (inTs !== inPy) { + mismatches.push( + `Payload field "${field}" is ${inTs ? "read by the renderer but never written by the emitter" : "written by the emitter but never read by the renderer"}.`, + ); + } + } + for (const field of optionFields) { + const inTs = new RegExp(`option\\.${field}\\b`).test(renderer); + const inPy = new RegExp(`"${field}"`).test(emitter); + if (inTs !== inPy) { + mismatches.push( + `Option field "${field}" is ${inTs ? "read by the renderer but never written by the emitter" : "written by the emitter but never read by the renderer"}.`, + ); + } + } + + if (mismatches.length > 0) { + throw new Error( + `QDK learning renderer contract mismatch:\n - ${mismatches.join("\n - ")}\n` + + `Update both ${schemaPath} and ${emitterPath} together.`, + ); + } + + const kinds = new Set(tsKinds).size; + console.log( + `Renderer contract OK (v${tsVersion}, ${kinds} payload kind${kinds === 1 ? "" : "s"}, ` + + `${payloadFields.length + optionFields.length} fields).`, + ); +} + // ── Asset copy helpers ────────────────────────────────────────────── export function copyWasmToVsCode() { @@ -217,7 +361,7 @@ async function buildPlatform(platform) { console.log(`Running esbuild for platform: ${platform}`); await esbuildBuild(options); - console.log(`Built bundle to ${options.outdir}`); + console.log(`Built bundle to ${options.outdir ?? options.outfile}`); } function getTimeStr() { @@ -268,7 +412,16 @@ export async function watchVsCode() { }, }); + // The notebook renderer is a separate bundle with its own format and CSS + // loader, so it needs its own watcher rather than another entry point above. + const rendererCtx = await context({ + ...platformBuildOptions.renderer, + plugins: [buildPlugin], + color: false, + }); + ctx.watch(); + rendererCtx.watch(); } (async () => { @@ -281,12 +434,14 @@ export async function watchVsCode() { } else { copyKatex(); copyWasmToVsCode(); + checkRendererContract(); await Promise.all([ buildPlatform("ui"), buildPlatform("browser"), buildPlatform("node"), buildPlatform("node-worker"), + buildPlatform("renderer"), ]); } } diff --git a/source/vscode/package.json b/source/vscode/package.json index ab4b90d50f9..bfb12432637 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -35,6 +35,17 @@ "ui" ], "contributes": { + "notebookRenderer": [ + { + "id": "qsharp-vscode.qdkLearningRenderer", + "displayName": "QDK Learning", + "entrypoint": "./out/renderer/qdkLearning.js", + "requiresMessaging": "optional", + "mimeTypes": [ + "application/vnd.qdk.learning+json" + ] + } + ], "walkthroughs": [ { "id": "qsharp-vscode.welcome", @@ -1708,7 +1719,8 @@ "tsc:check:main": "node ../../node_modules/typescript/bin/tsc -p ./tsconfig.json", "tsc:check:view": "node ../../node_modules/typescript/bin/tsc -p ./src/webview/tsconfig.json", "tsc:check:learning": "node ../../node_modules/typescript/bin/tsc -p ./src/learning/webview/tsconfig.json", - "tsc:check": "npm run tsc:check:main && npm run tsc:check:view && npm run tsc:check:learning", + "tsc:check:renderer": "node ../../node_modules/typescript/bin/tsc -p ./src/notebookRenderer/tsconfig.json", + "tsc:check": "npm run tsc:check:main && npm run tsc:check:view && npm run tsc:check:learning && npm run tsc:check:renderer", "tsc:watch": "node ../../node_modules/typescript/bin/tsc -p ./tsconfig.json --watch --preserveWatchOutput", "tsc:watch:view": "node ../../node_modules/typescript/bin/tsc -p ./src/webview/tsconfig.json --watch --preserveWatchOutput" }, diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/_unit.py b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/_unit.py index 59d6269fc3b..bfbdb7b1af8 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/_unit.py +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/_unit.py @@ -13,6 +13,7 @@ register_exercise, register_value_exercise, ) +from _learning_output import quiz, register_quiz # noqa: E402, F401 # The selected grid point 010000 is k=16 on the 64-point six-bit grid. MEASURED_PHASE = register_value_exercise("measured_phase", expected=0.25) @@ -47,3 +48,464 @@ def _check_circuit(result: object) -> str | None: "rebuilt for each of the six iterations." ), ) + + +# --------------------------------------------------------------------------- +# Self-check questions +# --------------------------------------------------------------------------- + +# The chapter's collapsible questions, registered so the notebook cell can show +# them as answerable ones. They live here rather than inline because a quiz in +# the notebook would put the answers into the cell source. + +register_quiz( + "iqpe-grid-target", + "Why does six-bit phase estimation meet a 1 mEh target here, even though " + "adjacent grid points are much farther apart than that?", + [ + ( + "tuned", + "The evolution time was tuned using the classically known reference " + "energy, so the target lands almost exactly on one six-bit grid point.", + True, + "The alignment is deliberate. Six bits do not give mEh resolution " + "for an arbitrary energy at this evolution time.", + ), + ( + "six-bits-enough", + "Six phase bits are enough to resolve any energy to 1 mEh.", + False, + "They are not. At this evolution time the grid spacing is far " + "coarser than 1 mEh; the agreement comes from where the target sits.", + ), + ( + "shots-interpolate", + "Averaging over the 20 shots interpolates between neighbouring grid points.", + False, + "Shots make the selected grid point more reliable. They never " + "produce an energy that lies between two grid points.", + ), + ( + "trotter-cancels", + "Trotter approximation error happens to cancel the grid spacing error.", + False, + "Trotter error is a separate contribution and is not controlled " + "here, so it cannot be relied on to offset discretization.", + ), + ], +) + +register_quiz( + "iqpe-state-prep", + "Why is trial-state preparation included in every IQPE iteration circuit?", + [ + ( + "fresh-qubits", + "Each phase bit is measured by a separate circuit, and every shot " + "begins with newly allocated qubits in the all-zero state.", + True, + "So the state-preparation logical circuit has to reload the trial " + "state before each controlled evolution.", + ), + ( + "measurement-collapse", + "Measuring the readout ancilla collapses the compute register, so " + "the trial state has to be rebuilt.", + False, + "Only the ancilla is measured. The register is gone anyway, but " + "because each iteration is its own circuit starting from all zeros.", + ), + ( + "average-trotter", + "Repeating it suppresses Trotter error by averaging over preparations.", + False, + "State preparation is not the source of Trotter error, and " + "repeating it does not reduce the error in the evolution unitary.", + ), + ( + "feedback-destroys", + "The classical feedback rotation destroys the trial state each iteration.", + False, + "The feedback rotation acts on the readout ancilla, not on the " + "compute register holding the molecular state.", + ), + ], +) + +register_quiz( + "iqpe-grid-control", + "Which controls change the spacing of the energy grid?", + [ + ( + "phase-bits", + "The number of phase bits.", + True, + "It sets how finely the phase interval is discretized, and changes " + "nothing else.", + ), + ( + "evolution-time", + "The evolution time.", + True, + "It rescales the grid in energy units — but unlike the bit count it " + "also changes the unaliased interval and the simulated evolution, so " + "it is the blunter of the two controls.", + ), + ( + "shots", + "The number of shots per bit.", + False, + "More shots make each bit majority more stable. Grid spacing is untouched.", + ), + ( + "active-space", + "The size of the active space.", + False, + "That changes the Hamiltonian being measured, not how finely the " + "phase is resolved.", + ), + ], + multi_select=True, +) + +register_quiz( + "iqpe-readout-ancilla", + "Which of these are true of the readout ancilla in the rendered circuit?", + [ + ( + "h-gates", + "It receives the H gates and the feedback rotation.", + True, + "That pair is what puts it in superposition and applies the phase " + "learned from earlier iterations.", + ), + ( + "controls", + "It controls the Hamiltonian evolution.", + True, + "The controlled-unitary hangs off this wire, which is how the phase " + "is kicked back onto it.", + ), + ( + "measured", + "It is measured to obtain the phase bit.", + True, + "One measurement per iteration, and that bit feeds the next one.", + ), + ( + "molecular-state", + "It holds the prepared molecular state.", + False, + "The other twelve wires do that — they are the compute register. " + "The ancilla is algorithm workspace.", + ), + ], + multi_select=True, +) + +register_quiz( + "iqpe-circuit-shape", + "Why do all six iteration circuits have the same width but different lengths?", + [ + ( + "power-varies", + "Every iteration uses the same twelve-qubit compute register and one " + "readout ancilla, while different controlled powers repeat the " + "evolution unitary different numbers of times.", + True, + "Width is register size, thirteen logical qubits every time. Length " + "is logical gate count, which the controlled power sets.", + ), + ( + "more-qubits", + "Later iterations act on more qubits, because they resolve more " + "significant bits.", + False, + "The register is fixed at thirteen qubits. Resolving a different " + "bit changes the controlled power, not the width.", + ), + ( + "feedback-ancilla", + "Each iteration adds another ancilla to carry the feedback.", + False, + "The feedback is classical. It changes a rotation angle, not the " + "number of qubits.", + ), + ( + "growing-space", + "The Trotter step count grows with the active-space size across iterations.", + False, + "The active space is fixed for the whole run. What varies between " + "iterations is the controlled power.", + ), + ], +) + +register_quiz( + "iqpe-bit-feedback", + "How does IQPE turn the result of each iteration into the final bitstring " + "and phase fraction?", + [ + ( + "feedback-chain", + "The majority measurement for each iteration selects a phase bit, " + "which updates the classical phase feedback used by the next " + "iteration; after six iterations the feedback calculation combines " + "the bits into one fraction.", + True, + "The script writes that fraction as a conventional six-bit string, " + "with the most significant bit first.", + ), + ( + "one-circuit", + "All six bits are measured together in a single circuit and read off " + "at the end.", + False, + "That is textbook QPE. The iterative variant deliberately measures " + "one bit per circuit, which is what keeps the register small.", + ), + ( + "independent-bits", + "The bits are independent, so they can be measured in any order and " + "concatenated.", + False, + "They are not independent. Each measured bit updates the phase " + "feedback for the next iteration, so the order is fixed.", + ), + ( + "average-estimates", + "The phase fraction is the average of the six per-iteration phase " + "estimates.", + False, + "Each iteration yields a single bit, not a phase estimate. " + "Averaging them would throw away each bit's place value.", + ), + ], +) + +register_quiz( + "iqpe-aggregation", + "Why should the final aggregation use complete bitstrings rather than vote " + "on each bit across complete runs?", + [ + ( + "joint", + "Each complete bitstring is one phase-grid point with a corresponding " + "energy, and voting per bit could assemble a bitstring that no run " + "ever produced.", + True, + "Voting bit by bit also discards the joint distribution that was " + "actually observed.", + ), + ( + "slower", + "Per-bit voting gives the same answer but takes longer to compute.", + False, + "It does not give the same answer: it can synthesize a result that " + "never occurred in any run.", + ), + ( + "simultaneous", + "Complete bitstrings are required because the bits are measured " + "simultaneously.", + False, + "They are measured one per iteration. The reason is that a " + "bitstring is only meaningful as a whole grid point.", + ), + ( + "msb-bias", + "Per-bit voting would bias the result toward the most significant bit.", + False, + "The problem is not bias toward one bit. It is that the assembled " + "string may correspond to no observed run at all.", + ), + ], +) + +register_quiz( + "iqpe-energy-comparison", + "Which energy comparison determines whether the IQPE workflow meets the " + "teaching target?", + [ + ( + "casci-same-space", + "The reconstructed IQPE total energy against the CASCI energy of the " + "same selected active-space Hamiltonian.", + True, + "The same Hamiltonian sits on both sides, so the difference isolates " + "algorithmic error.", + ), + ( + "experiment", + "The reconstructed IQPE total energy against an experimental " + "measurement for the molecule.", + False, + "That would mix algorithmic error with molecular-model error and " + "could not tell you which one you were looking at.", + ), + ( + "larger-space", + "The reconstructed IQPE total energy against a CASCI energy computed " + "in a larger active space.", + False, + "Changing the space changes the Hamiltonian, so the comparison would " + "no longer isolate the algorithm.", + ), + ( + "hartree-fock", + "The active-space energy against the Hartree-Fock energy.", + False, + "That measures how much correlation energy was recovered, not " + "whether phase estimation reached its target.", + ), + ], +) + +register_quiz( + "iqpe-observed-result", + "What bitstring distribution did the script produce?", + [ + ( + "19-1", + "`010000` appeared 19 times and `001111` once, so `010000` is the " + "most frequent result.", + True, + "It gives an active-space energy of -9.652276065987 Eh and a " + "reconstructed total of -108.770051792909 Eh once the core energy " + "is added back.", + ), + ( + "reversed", + "`001111` appeared 19 times and `010000` once.", + False, + "Reversed. `010000` is the majority result; `001111` is the " + "neighbouring grid point that turned up once.", + ), + ( + "unanimous", + "All 20 runs produced `010000`.", + False, + "Close, but one run landed on the adjacent grid point `001111`. " + "Finite sampling and Trotter error still move the outcome sometimes.", + ), + ( + "spread", + "The 20 runs were spread across six different bitstrings, one per " + "phase bit.", + False, + "The distribution is far tighter: two grid points in total, one of " + "them nineteen times.", + ), + ], +) + +register_quiz( + "iqpe-target-met", + "Does the result meet the teaching target, and what does that establish?", + [ + ( + "boundary", + "Yes, at the boundary: the reconstructed total is 1 mEh above the " + "selected-space CASCI reference, which validates this configured " + "teaching workflow.", + True, + "It does not remove molecular-model error or establish agreement " + "with experiment. The offset itself was set by the reference-guided " + "phase-grid alignment.", + ), + ( + "matches-experiment", + "Yes, and it establishes that the workflow reproduces the " + "experimental energy of the molecule to 1 mEh.", + False, + "Nothing here is compared against experiment. The reference is a " + "CASCI energy in the same active space.", + ), + ( + "misses", + "No, a 1 mEh offset is outside the teaching target.", + False, + "The target is 1 mEh, so this meets it, though only exactly at the " + "boundary.", + ), + ( + "errors-negligible", + "Yes, and it shows that Trotter and sampling error are negligible.", + False, + "Both can still affect which bitstring is selected. The agreement " + "comes from the deliberate grid alignment, not from those errors " + "vanishing.", + ), + ], +) + +register_quiz( + "iqpe-more-bits", + "What happens if the number of phase bits increases while the repeated-power " + "strategy stays fixed?", + [ + ( + "finer-grid", + "The phase grid becomes finer.", + True, + "That is the point of adding a bit — the interval is divided more finely.", + ), + ( + "extra-circuit", + "One more iteration circuit is needed.", + True, + "One circuit per bit, so an extra bit is an extra circuit to run.", + ), + ( + "power-doubles", + "The largest controlled-unitary power doubles.", + True, + "For repeated-power Trotter evolution that is the expensive part: it " + "increases circuit size and simulator runtime substantially.", + ), + ( + "shorter", + "The circuits get shorter, because each bit carries less information.", + False, + "The opposite — the longest circuit grows, because the largest " + "controlled power doubles.", + ), + ], + multi_select=True, +) + +register_quiz( + "iqpe-more-shots", + "Would increasing the number of shots per bit make the phase grid finer?", + [ + ( + "no-spacing-fixed", + "No. More shots can make each bit majority more stable, but grid " + "spacing is set by the evolution time and the number of phase bits.", + True, + "Shots change how confidently one grid point is selected, never the " + "spacing between grid points.", + ), + ( + "yes-interpolate", + "Yes, averaging more shots interpolates between grid points.", + False, + "The majority vote selects one grid point. It never produces a value " + "between two of them.", + ), + ( + "yes-trotter", + "Yes, more shots reduce Trotter error, which is what sets the spacing.", + False, + "Trotter error is not what sets grid spacing, and repeating shots " + "does not reduce it.", + ), + ( + "no-active-space", + "No, because the grid is fixed by the size of the active space.", + False, + "The right verdict for the wrong reason. Spacing comes from the " + "evolution time and the number of phase bits.", + ), + ], +) diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb index 2ca30c8eb76..45f4d2bf8c7 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb @@ -32,7 +32,15 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# If packages are missing, first select a dedicated Python environment/kernel,\n# then uncomment the next line and run this cell again.\n# %pip install -r ../requirements.txt\n\nfrom _unit import check_env\n\ncheck_env()" + "source": [ + "# If packages are missing, first select a dedicated Python environment/kernel,\n", + "# then uncomment the next line and run this cell again.\n", + "# %pip install -r ../requirements.txt\n", + "\n", + "from _unit import check_env, quiz\n", + "\n", + "check_env()" + ] }, { "cell_type": "markdown", @@ -70,7 +78,192 @@ "section:energy-to-phase-encoding" ] }, - "source": "## Energy-to-phase encoding\n\nLet $\\vert\\Psi_j\\rangle$ be an eigenstate of the qubit Hamiltonian with active-space energy $E_j$:\n\n$$\n\\hat H_{\\mathrm{qubit}}\\vert\\Psi_j\\rangle\n= E_j\\vert\\Psi_j\\rangle.\n$$\n\nIn atomic units, the evolution time $t$ is expressed in inverse Hartree, $E_{\\mathrm{h}}^{-1}$, so the product $E_jt$ is dimensionless.\nThe time-evolution unitary is\n\n$$\nU(t)=e^{-i\\hat H_{\\mathrm{qubit}}t}.\n$$\n\nBecause every power of the Hamiltonian acting on $\\vert\\Psi_j\\rangle$ contributes the corresponding power of $E_j$, the exponential acts on that eigenstate as\n\n$$\nU(t)\\vert\\Psi_j\\rangle\n=e^{-i\\hat H_{\\mathrm{qubit}}t}\\vert\\Psi_j\\rangle\n=e^{-iE_jt}\\vert\\Psi_j\\rangle.\n$$\n\nThe physical eigenphase in the exponential is therefore $-E_jt$ modulo $2\\pi$.\nThe phase fraction reported by QPE is\n\n$$\n\\varphi_j\n=\\left(\\frac{-E_jt}{2\\pi}\\right)\\bmod 1.\n$$\n\nThe QDK/Chemistry result object handles the modulo wrapping automatically.\nIt converts the measured phase fraction to a signed angle $\\alpha\\in(-\\pi,\\pi]$ and returns $-\\alpha/t$.\nThe tutorial script uses this value directly rather than manually applying a sign conversion.\nTo avoid aliasing, the active-space Hamiltonian energy eigenvalue $E_j$ being estimated must lie in the signed interval $[-\\pi/t,\\pi/t)$; energies outside that interval can produce the same measured phase.\nThe two boundary energies differ by one complete phase turn and therefore represent the same measured phase; QDK/Chemistry assigns that boundary to $-\\pi/t$.\n\nThe next figure shows how to read this wrapping convention.\nFollow the upper axis from $\\varphi=0$ toward $\\varphi=1$.\nFrom zero through one half, the signed angle is nonnegative, so $E=-\\alpha/t$ runs from zero down to $-\\pi/t$ along the green branch.\nImmediately above one half, the signed angle wraps from $+\\pi$ to just above $-\\pi$; the corresponding energy jumps to just below $+\\pi/t$ and then returns toward zero along the purple branch.\nAt $\\varphi=1/2$, the lower filled point includes $-\\pi/t$, while the upper open point excludes the equivalent $+\\pi/t$ representation.\n\n
\n\n image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 1/4 1/2 3/4 1 → 0 Reported phase fraction φ ∈ [0, 1) +π/t +π/(2t) 0 −π/(2t) −π/t Signed reconstructed energy included boundary excluded boundary \n\n*QDK/Chemistry converts the wrapped phase fraction to one signed energy branch. The neutral dashed guide marks the shared phase $\\varphi=1/2$; the red bracket spans the $2\\pi/t$ energy difference between its included and excluded boundary representations. Energies separated by that amount alias to the same reported phase.*\n\n
\n\nWith $m$ measured phase bits, the representable fractions are multiples of $2^{-m}$, so adjacent energy-grid points are separated by\n\n$$\n\\Delta E_{\\mathrm{grid}}=\\frac{2\\pi}{t2^m}.\n$$\n\nWith $m$ measured phase bits, the largest controlled power is $U^{2^{m-1}}$.\nIncreasing $m$ from six to ten therefore raises the largest power from $U^{32}$ to $U^{512}$, increasing the size and runtime cost of the largest iteration circuit by a factor of sixteen for this repeated-power strategy.\nThis tutorial uses six bits to keep the simulation tractable; that choice does not by itself provide $\\mathrm{m}E_{\\mathrm{h}}$ resolution.\n\nThe evolution time is computed from quantities already produced by the classically tractable example chosen for this tutorial.\nIf we write the mapped Hamiltonian as $\\hat H_{\\mathrm{qubit}}=\\sum_\\ell h_\\ell P_\\ell$ and define\n\n$$\n\\lambda=\\sum_\\ell\\lvert h_\\ell\\rvert.\n$$\n\nThe QDK/Chemistry application programming interface (API) exposes this coefficient 1-norm as `qubit_hamiltonian.schatten_norm`; the tutorial script uses it to choose the evolution time and reports it in the pre-simulation settings.\nFor this Hamiltonian, the reported value is $\\lambda=19.610172748837\\ E_{\\mathrm{h}}$.\nBecause $\\lambda$ bounds the magnitudes of the Hamiltonian eigenvalues, the initial choice\n\n$$\nt_{\\mathrm{bound}}=\\frac{\\pi}{\\lambda}\n=0.160202191680\\ E_{\\mathrm{h}}^{-1}\n$$\n\nkeeps the spectrum within the signed, unaliased phase interval.\nThe script reports this value as the `Initial unaliased time bound` in its pre-simulation settings.\nUsing the active-space reference from the chapter *Mapping the problem to qubits*, $E_{\\mathrm{ref}}=-9.653276065987\\ E_{\\mathrm{h}}$, this initial time gives the implementation phase fraction\n\n$$\n\\varphi_{\\mathrm{bound}}\n=\\left(\\frac{-t_{\\mathrm{bound}}E_{\\mathrm{ref}}}{2\\pi}\\right)\\bmod 1\n\\approx0.246129297014.\n$$\n\nThe script reports this value as the `Reference phase at initial time bound`.\nThe nearest six-bit fraction is $16/64=0.25$, represented by `010000`.\nIts signed angle is $2\\pi(16/64)=+\\pi/2$.\nFinally, we can choose the evolution time so that this grid point reconstructs an energy $\\delta=0.001\\ E_{\\mathrm{h}}$ above the known reference:\n\n$$\nt\n=\\frac{-\\pi/2}{E_{\\mathrm{ref}}+\\delta}\n=0.162738437655\\ E_{\\mathrm{h}}^{-1}.\n$$\n\nThe script reports this adjusted value as the `Selected evolution time`.\nUsing this time, the reference phase fraction is approximately $0.250025901$, only about $2.59\\times10^{-5}$ above the selected grid point.\nThe grid point therefore reconstructs an active energy exactly $1\\ \\mathrm{m}E_{\\mathrm{h}}$ above the classical reference to the displayed precision.\n\nThe table below compares the selected point with its neighboring six-bit grid energies and the known reference.\nThe rows are ordered by energy rather than by grid index, so the more negative $k=17$ energy appears before $k=16$.\nThe reference row has no grid index or bitstring because the reference does not lie exactly on the six-bit grid.\n\n
\n\n| Grid point | Bitstring | Active energy ($E_{\\mathrm{h}}$) |\n|---|---|---|\n| $k=17$ | `010001` | $-10.255543320111$ |\n| Known reference (not a grid point) | Not applicable | $-9.653276065987$ |\n| $k=16$ (selected) | `010000` | $-9.652276065987$ |\n| $k=15$ | `001111` | $-9.049008811863$ |\n\n*Six-bit active-energy grid near the reference. Here $k$ is the integer grid index, so $\\varphi_k=k/2^6=k/64$; its six-bit binary representation is the measured bitstring.*\n\n
\n\nThe neighboring grid energies differ by approximately $0.6033\\ E_{\\mathrm{h}}$.\nBy contrast, selected grid point $k=16$ is only $0.001\\ E_{\\mathrm{h}}$ above the known reference.\nThis small offset is possible because the evolution time was tuned using that reference; it is not the general resolution of the six-bit grid.\n\n**Please note**: this use of the already known classical energy is circular.\nIt is useful for this tutorial, but it is not a generally available strategy when the target energy is unknown.\nFor the chosen $t$, adjacent energies represented by the six-bit phase grid differ by approximately $0.6033\\ E_{\\mathrm{h}}$, not $0.001\\ E_{\\mathrm{h}}$.\nThe smaller value, $0.001\\ E_{\\mathrm{h}}$ or $1\\ \\mathrm{m}E_{\\mathrm{h}}$, is the accuracy target adopted for this tutorial.\nThe question below asks why one grid point can nevertheless reconstruct this particular reference energy within that target.\n\n
❓ Why does six-bit phase estimation meet a 1 mEh target here even though adjacent grid points are much farther apart?\n\n
\n\nThe classically known reference energy was used to tune the evolution time so the target lies almost exactly on one six-bit grid point.\nSix bits do not provide $\\mathrm{m}E_{\\mathrm{h}}$ resolution for arbitrary energies with this evolution time.\n\n
\n\n
" + "source": [ + "## Energy-to-phase encoding\n", + "\n", + "Let $\\vert\\Psi_j\\rangle$ be an eigenstate of the qubit Hamiltonian with active-space energy $E_j$:\n", + "\n", + "$$\n", + "\\hat H_{\\mathrm{qubit}}\\vert\\Psi_j\\rangle\n", + "= E_j\\vert\\Psi_j\\rangle.\n", + "$$\n", + "\n", + "In atomic units, the evolution time $t$ is expressed in inverse Hartree, $E_{\\mathrm{h}}^{-1}$, so the product $E_jt$ is dimensionless.\n", + "The time-evolution unitary is\n", + "\n", + "$$\n", + "U(t)=e^{-i\\hat H_{\\mathrm{qubit}}t}.\n", + "$$\n", + "\n", + "Because every power of the Hamiltonian acting on $\\vert\\Psi_j\\rangle$ contributes the corresponding power of $E_j$, the exponential acts on that eigenstate as\n", + "\n", + "$$\n", + "U(t)\\vert\\Psi_j\\rangle\n", + "=e^{-i\\hat H_{\\mathrm{qubit}}t}\\vert\\Psi_j\\rangle\n", + "=e^{-iE_jt}\\vert\\Psi_j\\rangle.\n", + "$$\n", + "\n", + "The physical eigenphase in the exponential is therefore $-E_jt$ modulo $2\\pi$.\n", + "The phase fraction reported by QPE is\n", + "\n", + "$$\n", + "\\varphi_j\n", + "=\\left(\\frac{-E_jt}{2\\pi}\\right)\\bmod 1.\n", + "$$\n", + "\n", + "The QDK/Chemistry result object handles the modulo wrapping automatically.\n", + "It converts the measured phase fraction to a signed angle $\\alpha\\in(-\\pi,\\pi]$ and returns $-\\alpha/t$.\n", + "The tutorial script uses this value directly rather than manually applying a sign conversion.\n", + "To avoid aliasing, the active-space Hamiltonian energy eigenvalue $E_j$ being estimated must lie in the signed interval $[-\\pi/t,\\pi/t)$; energies outside that interval can produce the same measured phase.\n", + "The two boundary energies differ by one complete phase turn and therefore represent the same measured phase; QDK/Chemistry assigns that boundary to $-\\pi/t$.\n", + "\n", + "The next figure shows how to read this wrapping convention.\n", + "Follow the upper axis from $\\varphi=0$ toward $\\varphi=1$.\n", + "From zero through one half, the signed angle is nonnegative, so $E=-\\alpha/t$ runs from zero down to $-\\pi/t$ along the green branch.\n", + "Immediately above one half, the signed angle wraps from $+\\pi$ to just above $-\\pi$; the corresponding energy jumps to just below $+\\pi/t$ and then returns toward zero along the purple branch.\n", + "At $\\varphi=1/2$, the lower filled point includes $-\\pi/t$, while the upper open point excludes the equivalent $+\\pi/t$ representation.\n", + "\n", + "
\n", + "\n", + " image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 1/4 1/2 3/4 1 → 0 Reported phase fraction φ ∈ [0, 1) +π/t +π/(2t) 0 −π/(2t) −π/t Signed reconstructed energy included boundary excluded boundary \n", + "\n", + "*QDK/Chemistry converts the wrapped phase fraction to one signed energy branch. The neutral dashed guide marks the shared phase $\\varphi=1/2$; the red bracket spans the $2\\pi/t$ energy difference between its included and excluded boundary representations. Energies separated by that amount alias to the same reported phase.*\n", + "\n", + "
\n", + "\n", + "With $m$ measured phase bits, the representable fractions are multiples of $2^{-m}$, so adjacent energy-grid points are separated by\n", + "\n", + "$$\n", + "\\Delta E_{\\mathrm{grid}}=\\frac{2\\pi}{t2^m}.\n", + "$$\n", + "\n", + "With $m$ measured phase bits, the largest controlled power is $U^{2^{m-1}}$.\n", + "Increasing $m$ from six to ten therefore raises the largest power from $U^{32}$ to $U^{512}$, increasing the size and runtime cost of the largest iteration circuit by a factor of sixteen for this repeated-power strategy.\n", + "This tutorial uses six bits to keep the simulation tractable; that choice does not by itself provide $\\mathrm{m}E_{\\mathrm{h}}$ resolution.\n", + "\n", + "The evolution time is computed from quantities already produced by the classically tractable example chosen for this tutorial.\n", + "If we write the mapped Hamiltonian as $\\hat H_{\\mathrm{qubit}}=\\sum_\\ell h_\\ell P_\\ell$ and define\n", + "\n", + "$$\n", + "\\lambda=\\sum_\\ell\\lvert h_\\ell\\rvert.\n", + "$$\n", + "\n", + "The QDK/Chemistry application programming interface (API) exposes this coefficient 1-norm as `qubit_hamiltonian.schatten_norm`; the tutorial script uses it to choose the evolution time and reports it in the pre-simulation settings.\n", + "For this Hamiltonian, the reported value is $\\lambda=19.610172748837\\ E_{\\mathrm{h}}$.\n", + "Because $\\lambda$ bounds the magnitudes of the Hamiltonian eigenvalues, the initial choice\n", + "\n", + "$$\n", + "t_{\\mathrm{bound}}=\\frac{\\pi}{\\lambda}\n", + "=0.160202191680\\ E_{\\mathrm{h}}^{-1}\n", + "$$\n", + "\n", + "keeps the spectrum within the signed, unaliased phase interval.\n", + "The script reports this value as the `Initial unaliased time bound` in its pre-simulation settings.\n", + "Using the active-space reference from the chapter *Mapping the problem to qubits*, $E_{\\mathrm{ref}}=-9.653276065987\\ E_{\\mathrm{h}}$, this initial time gives the implementation phase fraction\n", + "\n", + "$$\n", + "\\varphi_{\\mathrm{bound}}\n", + "=\\left(\\frac{-t_{\\mathrm{bound}}E_{\\mathrm{ref}}}{2\\pi}\\right)\\bmod 1\n", + "\\approx0.246129297014.\n", + "$$\n", + "\n", + "The script reports this value as the `Reference phase at initial time bound`.\n", + "The nearest six-bit fraction is $16/64=0.25$, represented by `010000`.\n", + "Its signed angle is $2\\pi(16/64)=+\\pi/2$.\n", + "Finally, we can choose the evolution time so that this grid point reconstructs an energy $\\delta=0.001\\ E_{\\mathrm{h}}$ above the known reference:\n", + "\n", + "$$\n", + "t\n", + "=\\frac{-\\pi/2}{E_{\\mathrm{ref}}+\\delta}\n", + "=0.162738437655\\ E_{\\mathrm{h}}^{-1}.\n", + "$$\n", + "\n", + "The script reports this adjusted value as the `Selected evolution time`.\n", + "Using this time, the reference phase fraction is approximately $0.250025901$, only about $2.59\\times10^{-5}$ above the selected grid point.\n", + "The grid point therefore reconstructs an active energy exactly $1\\ \\mathrm{m}E_{\\mathrm{h}}$ above the classical reference to the displayed precision.\n", + "\n", + "The table below compares the selected point with its neighboring six-bit grid energies and the known reference.\n", + "The rows are ordered by energy rather than by grid index, so the more negative $k=17$ energy appears before $k=16$.\n", + "The reference row has no grid index or bitstring because the reference does not lie exactly on the six-bit grid.\n", + "\n", + "
\n", + "\n", + "| Grid point | Bitstring | Active energy ($E_{\\mathrm{h}}$) |\n", + "|---|---|---|\n", + "| $k=17$ | `010001` | $-10.255543320111$ |\n", + "| Known reference (not a grid point) | Not applicable | $-9.653276065987$ |\n", + "| $k=16$ (selected) | `010000` | $-9.652276065987$ |\n", + "| $k=15$ | `001111` | $-9.049008811863$ |\n", + "\n", + "*Six-bit active-energy grid near the reference. Here $k$ is the integer grid index, so $\\varphi_k=k/2^6=k/64$; its six-bit binary representation is the measured bitstring.*\n", + "\n", + "
\n", + "\n", + "The neighboring grid energies differ by approximately $0.6033\\ E_{\\mathrm{h}}$.\n", + "By contrast, selected grid point $k=16$ is only $0.001\\ E_{\\mathrm{h}}$ above the known reference.\n", + "This small offset is possible because the evolution time was tuned using that reference; it is not the general resolution of the six-bit grid.\n", + "\n", + "**Please note**: this use of the already known classical energy is circular.\n", + "It is useful for this tutorial, but it is not a generally available strategy when the target energy is unknown.\n", + "For the chosen $t$, adjacent energies represented by the six-bit phase grid differ by approximately $0.6033\\ E_{\\mathrm{h}}$, not $0.001\\ E_{\\mathrm{h}}$.\n", + "The smaller value, $0.001\\ E_{\\mathrm{h}}$ or $1\\ \\mathrm{m}E_{\\mathrm{h}}$, is the accuracy target adopted for this tutorial.\n", + "The question below asks why one grid point can nevertheless reconstruct this particular reference energy within that target." + ] + }, + { + "cell_type": "code", + "id": "c-8002aa5f3b38", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Why does six-bit phase estimation meet a 1 mEh target here, even though adjacent grid points are much farther apart than that?", + "options": [ + { + "id": "shots-interpolate", + "text": "Averaging over the 20 shots interpolates between neighbouring grid points.", + "correct": false, + "explanation": "Shots make the selected grid point more reliable. They never produce an energy that lies between two grid points." + }, + { + "id": "trotter-cancels", + "text": "Trotter approximation error happens to cancel the grid spacing error.", + "correct": false, + "explanation": "Trotter error is a separate contribution and is not controlled here, so it cannot be relied on to offset discretization." + }, + { + "id": "six-bits-enough", + "text": "Six phase bits are enough to resolve any energy to 1 mEh.", + "correct": false, + "explanation": "They are not. At this evolution time the grid spacing is far coarser than 1 mEh; the agreement comes from where the target sits." + }, + { + "id": "tuned", + "text": "The evolution time was tuned using the classically known reference energy, so the target lands almost exactly on one six-bit grid point.", + "correct": true, + "explanation": "The alignment is deliberate. Six bits do not give mEh resolution for an arbitrary energy at this evolution time." + } + ], + "cellId": "iqpe-grid-target" + }, + "text/html": "
❓ Check your understanding

Why does six-bit phase estimation meet a 1 mEh target here, even though adjacent grid points are much farther apart than that?

  1. Averaging over the 20 shots interpolates between neighbouring grid points.
  2. Trotter approximation error happens to cancel the grid spacing error.
  3. Six phase bits are enough to resolve any energy to 1 mEh.
  4. The evolution time was tuned using the classically known reference energy, so the target lands almost exactly on one six-bit grid point.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Why does six-bit phase estimation meet a 1 mEh target here, even though adjacent grid points are much farther apart than that?\n ( ) Averaging over the 20 shots interpolates between neighbouring grid points.\n ( ) Trotter approximation error happens to cancel the grid spacing error.\n ( ) Six phase bits are enough to resolve any energy to 1 mEh.\n ( ) The evolution time was tuned using the classically known reference energy, so the target lands almost exactly on one six-bit grid point." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-grid-target\")\n" + ] }, { "cell_type": "markdown", @@ -90,7 +283,114 @@ "section:one-phase-bit-at-a-time" ] }, - "source": "## One phase bit at a time\n\nStandard phase estimation uses several readout ancillas and an inverse quantum Fourier transform to obtain a complete phase in one coherent circuit.\nThe QDK/Chemistry iterative implementation (IQPE) instead reuses a single readout ancilla across a sequence of independently executed circuits.\nThis reduces each circuit's logical-qubit requirement, both on quantum hardware and in this tutorial's classical simulator, at the cost of repeated state preparation and circuit execution.\nThis qubit-resource tradeoff is why the tutorial uses IQPE.\n\nFor iterations over $k=0,1,\\ldots,m-1$, the circuit builder uses the controlled power\n\n$$\nU^{2^{m-k-1}}.\n$$\n\nWith $m=6$, the six iteration circuits therefore apply powers $32,16,8,4,2,1$.\nQDK/Chemistry reverses the measurements from execution order when it constructs the conventional most-significant-bit-first result.\nFor an input eigenstate, the first H gate prepares the readout ancilla in $(\\vert0\\rangle+\\vert1\\rangle)/\\sqrt{2}$.\nThe feedback rotation applies a corrective phase determined by earlier iterations.\nThe controlled power then produces *phase kickback*: the $\\vert1\\rangle$ branch acquires the eigenphase of $U^{2^{m-k-1}}$, while the $\\vert0\\rangle$ branch does not.\nTogether, the feedback and kickback phases cancel the contribution already determined by earlier iterations.\nAfter the second H gate, the remaining relative phase changes the probabilities of measuring zero or one, revealing the next phase bit.\n\nIf iteration $k$ selects bit $b_k$, QDK/Chemistry updates its accumulated feedback angle according to\n\n$$\n\\Phi_{k+1}=\\frac{\\Phi_k}{2}+\\frac{\\pi b_k}{2},\n\\qquad \\Phi_0=0.\n$$\n\nAfter the last iteration, the reported phase fraction is $\\Phi_m/\\pi$.\nThe following figure summarizes one iteration, from fresh register preparation through repeated shots, majority voting, and the feedback update for the next phase bit.\n\n
\n\n TutorialQpeIqpeIteration Compute Fresh compute register Prepare the trial state |Ψtrial Evolution Apply controlled time evolution Ancilla controls U^(2^(m−k−1)) on the compute register Compute->Evolution Ancilla Fresh readout ancilla |0⟩ Apply the first H gate Feedback Apply phase feedback Use angle Φk from earlier measured bits Ancilla->Feedback Feedback->Evolution Measure Apply H and measure the ancilla One shot returns 0 or 1 Evolution->Measure Shots Repeat with freshly prepared registers Collect an odd number of outcomes for iteration k Measure->Shots Majority Majority vote selects bit bk Classical result for this iteration Shots->Majority Update Update the classical feedback angle Use bk to compute Φk+1 Majority->Update Next Continue to iteration k + 1 Build the next controlled power and repeat Update->Next \n\n*One IQPE iteration estimates phase bit $b_k$. Every shot freshly prepares the trial state and readout ancilla; the majority outcome updates $\\Phi_{k+1}=\\Phi_k/2+\\pi b_k/2$ for the next controlled power.*\n\n
\n\nAfter all iterations, the feedback accumulator determines the final phase fraction.\n\nEach iteration circuit contains twelve compute qubits and one readout ancilla, for thirteen logical qubits in the simulated circuit.\nThe readout ancilla does not represent an additional molecular spin orbital.\n\n
❓ Why is trial-state preparation included in every IQPE iteration circuit?\n\n
\n\nEach phase bit is measured by executing a separate circuit, and every shot begins with newly allocated qubits in the all-zero state.\nThe state-preparation logical circuit must therefore reload the trial state before each controlled evolution.\n\n
\n\n
\n\nThe script configures the native iterative circuit builder and first-order Trotter unitary through nested `AlgorithmRef` objects:" + "source": [ + "## One phase bit at a time\n", + "\n", + "Standard phase estimation uses several readout ancillas and an inverse quantum Fourier transform to obtain a complete phase in one coherent circuit.\n", + "The QDK/Chemistry iterative implementation (IQPE) instead reuses a single readout ancilla across a sequence of independently executed circuits.\n", + "This reduces each circuit's logical-qubit requirement, both on quantum hardware and in this tutorial's classical simulator, at the cost of repeated state preparation and circuit execution.\n", + "This qubit-resource tradeoff is why the tutorial uses IQPE.\n", + "\n", + "For iterations over $k=0,1,\\ldots,m-1$, the circuit builder uses the controlled power\n", + "\n", + "$$\n", + "U^{2^{m-k-1}}.\n", + "$$\n", + "\n", + "With $m=6$, the six iteration circuits therefore apply powers $32,16,8,4,2,1$.\n", + "QDK/Chemistry reverses the measurements from execution order when it constructs the conventional most-significant-bit-first result.\n", + "For an input eigenstate, the first H gate prepares the readout ancilla in $(\\vert0\\rangle+\\vert1\\rangle)/\\sqrt{2}$.\n", + "The feedback rotation applies a corrective phase determined by earlier iterations.\n", + "The controlled power then produces *phase kickback*: the $\\vert1\\rangle$ branch acquires the eigenphase of $U^{2^{m-k-1}}$, while the $\\vert0\\rangle$ branch does not.\n", + "Together, the feedback and kickback phases cancel the contribution already determined by earlier iterations.\n", + "After the second H gate, the remaining relative phase changes the probabilities of measuring zero or one, revealing the next phase bit.\n", + "\n", + "If iteration $k$ selects bit $b_k$, QDK/Chemistry updates its accumulated feedback angle according to\n", + "\n", + "$$\n", + "\\Phi_{k+1}=\\frac{\\Phi_k}{2}+\\frac{\\pi b_k}{2},\n", + "\\qquad \\Phi_0=0.\n", + "$$\n", + "\n", + "After the last iteration, the reported phase fraction is $\\Phi_m/\\pi$.\n", + "The following figure summarizes one iteration, from fresh register preparation through repeated shots, majority voting, and the feedback update for the next phase bit.\n", + "\n", + "
\n", + "\n", + " TutorialQpeIqpeIteration Compute Fresh compute register Prepare the trial state |Ψtrial Evolution Apply controlled time evolution Ancilla controls U^(2^(m−k−1)) on the compute register Compute->Evolution Ancilla Fresh readout ancilla |0⟩ Apply the first H gate Feedback Apply phase feedback Use angle Φk from earlier measured bits Ancilla->Feedback Feedback->Evolution Measure Apply H and measure the ancilla One shot returns 0 or 1 Evolution->Measure Shots Repeat with freshly prepared registers Collect an odd number of outcomes for iteration k Measure->Shots Majority Majority vote selects bit bk Classical result for this iteration Shots->Majority Update Update the classical feedback angle Use bk to compute Φk+1 Majority->Update Next Continue to iteration k + 1 Build the next controlled power and repeat Update->Next \n", + "\n", + "*One IQPE iteration estimates phase bit $b_k$. Every shot freshly prepares the trial state and readout ancilla; the majority outcome updates $\\Phi_{k+1}=\\Phi_k/2+\\pi b_k/2$ for the next controlled power.*\n", + "\n", + "
\n", + "\n", + "After all iterations, the feedback accumulator determines the final phase fraction.\n", + "\n", + "Each iteration circuit contains twelve compute qubits and one readout ancilla, for thirteen logical qubits in the simulated circuit.\n", + "The readout ancilla does not represent an additional molecular spin orbital." + ] + }, + { + "cell_type": "code", + "id": "c-cba0532542e1", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Why is trial-state preparation included in every IQPE iteration circuit?", + "options": [ + { + "id": "feedback-destroys", + "text": "The classical feedback rotation destroys the trial state each iteration.", + "correct": false, + "explanation": "The feedback rotation acts on the readout ancilla, not on the compute register holding the molecular state." + }, + { + "id": "average-trotter", + "text": "Repeating it suppresses Trotter error by averaging over preparations.", + "correct": false, + "explanation": "State preparation is not the source of Trotter error, and repeating it does not reduce the error in the evolution unitary." + }, + { + "id": "fresh-qubits", + "text": "Each phase bit is measured by a separate circuit, and every shot begins with newly allocated qubits in the all-zero state.", + "correct": true, + "explanation": "So the state-preparation logical circuit has to reload the trial state before each controlled evolution." + }, + { + "id": "measurement-collapse", + "text": "Measuring the readout ancilla collapses the compute register, so the trial state has to be rebuilt.", + "correct": false, + "explanation": "Only the ancilla is measured. The register is gone anyway, but because each iteration is its own circuit starting from all zeros." + } + ], + "cellId": "iqpe-state-prep" + }, + "text/html": "
❓ Check your understanding

Why is trial-state preparation included in every IQPE iteration circuit?

  1. The classical feedback rotation destroys the trial state each iteration.
  2. Repeating it suppresses Trotter error by averaging over preparations.
  3. Each phase bit is measured by a separate circuit, and every shot begins with newly allocated qubits in the all-zero state.
  4. Measuring the readout ancilla collapses the compute register, so the trial state has to be rebuilt.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Why is trial-state preparation included in every IQPE iteration circuit?\n ( ) The classical feedback rotation destroys the trial state each iteration.\n ( ) Repeating it suppresses Trotter error by averaging over preparations.\n ( ) Each phase bit is measured by a separate circuit, and every shot begins with newly allocated qubits in the all-zero state.\n ( ) Measuring the readout ancilla collapses the compute register, so the trial state has to be rebuilt." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-state-prep\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "c-e73a24c9ecf0", + "metadata": {}, + "source": [ + "The script configures the native iterative circuit builder and first-order Trotter unitary through nested `AlgorithmRef` objects:" + ] }, { "cell_type": "code", @@ -108,7 +408,97 @@ "section:numerical-controls" ] }, - "source": "## Numerical controls\n\nFive controls determine the approximation and sampling behavior of this workflow:\n\n- ***Evolution time***
\n The value $t$ sets the signed energy interval and spacing of the phase grid, as described above.\n Here it is tuned with the known classical reference to produce a $1\\ \\mathrm{m}E_{\\mathrm{h}}$ grid error.\n- **[Hamiltonian simulation ↗](https://microsoft.github.io/qdk-chemistry/user/comprehensive/algorithms/hamiltonian_unitary_builder.html)**
\n The qubit Hamiltonian is a sum of Pauli terms that generally do not commute.\n A first-order [Trotter product formula ↗](https://en.wikipedia.org/wiki/Lie_product_formula) approximates evolution under that sum by applying the exponential of each Pauli term in sequence.\n For $\\hat H=\\sum_\\ell h_\\ell P_\\ell$, using $r$ Trotter divisions gives\n\n$$\ne^{-i\\hat Ht}\n\\approx\n\\left[\\prod_\\ell e^{-ih_\\ell P_\\ell t/r}\\right]^r.\n$$\n\n One division ($r=1$) is used for each base evolution in this tutorial.\n Increasing $r$ shortens each simulated time step and generally reduces product-formula error, but repeats the Pauli-term sequence more times and increases circuit cost.\n The repeated-power strategy implemented by QDK/Chemistry constructs each controlled power $U^{2^{m-k-1}}$ by repeating that same approximate base unitary, preserving one consistent approximation across the IQPE iterations.\n- ***Phase bits***
\n Six bits produce six iteration circuits and $2^6=64$ representable phase fractions.\n Increasing this count refines the grid but causes the largest controlled power, circuit size, and simulator runtime to grow exponentially for the repeated-power strategy.\n- ***Shots per bit***
\n Each iteration circuit is executed three times.\n The odd shot count prevents a tied bit vote, but finite sampling can still select the less probable bit.\n- ***Complete runs***
\n The full six-bit procedure is repeated twenty times with simulator seeds 42 through 61.\n Each complete run returns one reconstructed bitstring and energy, and the final estimate uses the most frequent complete bitstring.\n\nThe default workflow therefore executes $6\\times3\\times20=360$ iteration-circuit shots.\nPhase-grid error, Trotter error, and sampling variation have different causes and should not be combined with basis-set or active-space model error.\n\n
❓ Which control changes energy-grid spacing without changing the molecular Hamiltonian?\n\n
\n\nThe number of phase bits changes how finely the phase interval is discretized.\nThe evolution time also rescales the grid in energy units, but it simultaneously changes the unaliased energy interval and the simulated evolution.\n\n
\n\n
" + "source": [ + "## Numerical controls\n", + "\n", + "Five controls determine the approximation and sampling behavior of this workflow:\n", + "\n", + "- ***Evolution time***
\n", + " The value $t$ sets the signed energy interval and spacing of the phase grid, as described above.\n", + " Here it is tuned with the known classical reference to produce a $1\\ \\mathrm{m}E_{\\mathrm{h}}$ grid error.\n", + "- **[Hamiltonian simulation ↗](https://microsoft.github.io/qdk-chemistry/user/comprehensive/algorithms/hamiltonian_unitary_builder.html)**
\n", + " The qubit Hamiltonian is a sum of Pauli terms that generally do not commute.\n", + " A first-order [Trotter product formula ↗](https://en.wikipedia.org/wiki/Lie_product_formula) approximates evolution under that sum by applying the exponential of each Pauli term in sequence.\n", + " For $\\hat H=\\sum_\\ell h_\\ell P_\\ell$, using $r$ Trotter divisions gives\n", + "\n", + "$$\n", + "e^{-i\\hat Ht}\n", + "\\approx\n", + "\\left[\\prod_\\ell e^{-ih_\\ell P_\\ell t/r}\\right]^r.\n", + "$$\n", + "\n", + " One division ($r=1$) is used for each base evolution in this tutorial.\n", + " Increasing $r$ shortens each simulated time step and generally reduces product-formula error, but repeats the Pauli-term sequence more times and increases circuit cost.\n", + " The repeated-power strategy implemented by QDK/Chemistry constructs each controlled power $U^{2^{m-k-1}}$ by repeating that same approximate base unitary, preserving one consistent approximation across the IQPE iterations.\n", + "- ***Phase bits***
\n", + " Six bits produce six iteration circuits and $2^6=64$ representable phase fractions.\n", + " Increasing this count refines the grid but causes the largest controlled power, circuit size, and simulator runtime to grow exponentially for the repeated-power strategy.\n", + "- ***Shots per bit***
\n", + " Each iteration circuit is executed three times.\n", + " The odd shot count prevents a tied bit vote, but finite sampling can still select the less probable bit.\n", + "- ***Complete runs***
\n", + " The full six-bit procedure is repeated twenty times with simulator seeds 42 through 61.\n", + " Each complete run returns one reconstructed bitstring and energy, and the final estimate uses the most frequent complete bitstring.\n", + "\n", + "The default workflow therefore executes $6\\times3\\times20=360$ iteration-circuit shots.\n", + "Phase-grid error, Trotter error, and sampling variation have different causes and should not be combined with basis-set or active-space model error." + ] + }, + { + "cell_type": "code", + "id": "c-a57dfcb82b09", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Which controls change the spacing of the energy grid?", + "options": [ + { + "id": "evolution-time", + "text": "The evolution time.", + "correct": true, + "explanation": "It rescales the grid in energy units — but unlike the bit count it also changes the unaliased interval and the simulated evolution, so it is the blunter of the two controls." + }, + { + "id": "active-space", + "text": "The size of the active space.", + "correct": false, + "explanation": "That changes the Hamiltonian being measured, not how finely the phase is resolved." + }, + { + "id": "phase-bits", + "text": "The number of phase bits.", + "correct": true, + "explanation": "It sets how finely the phase interval is discretized, and changes nothing else." + }, + { + "id": "shots", + "text": "The number of shots per bit.", + "correct": false, + "explanation": "More shots make each bit majority more stable. Grid spacing is untouched." + } + ], + "multiSelect": true, + "cellId": "iqpe-grid-control" + }, + "text/html": "
❓ Check your understanding

Which controls change the spacing of the energy grid?

Select all that apply.

  1. The evolution time.
  2. The size of the active space.
  3. The number of phase bits.
  4. The number of shots per bit.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Which controls change the spacing of the energy grid?\n (select all that apply)\n [ ] The evolution time.\n [ ] The size of the active space.\n [ ] The number of phase bits.\n [ ] The number of shots per bit." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-grid-control\")\n" + ] }, { "cell_type": "markdown", @@ -118,7 +508,126 @@ "section:iqpe-circuit-visualization" ] }, - "source": "## IQPE circuit visualization\n\nThe cells below build the six iteration circuits and render the shortest one. No quantum simulation runs here.\n\n
\n\n |ψ0|ψ1|ψ2|ψ3|ψ4|ψ5|ψ6|ψ7|ψ8|ψ9|ψ10|ψ11|ψ12StatePreparationStatePreparationHRz0.0000RepControlledPauliExpH|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩RunIQPEMakeIQPECircuit \n\n*Overview of the rendered power-one iteration circuit. Dashed outlines mark the nested `MakeIQPECircuit` and `RunIQPE` Q# operations; the solid boxes show their principal composite operations.*\n\n
\n\nThe top wire, $\\lvert\\psi_0\\rangle$, is readout ancilla q0.\nIts first H gate creates a superposition, and the `Rz(0.0000)` block applies the phase-feedback rotation.\nThis static preview constructs all six circuits with the builder's initial feedback angle of zero, so the displayed rotation is zero.\nDuring an actual IQPE run, each iteration circuit is rebuilt using the accumulated feedback from earlier measured bits; the power-one iteration can therefore have a nonzero feedback rotation.\n\nThe lower wires, $\\lvert\\psi_1\\rangle$ through $\\lvert\\psi_{12}\\rangle$, are compute-register qubits q1–q12.\nThe `StatePreparation` blocks load the four-determinant trial state on the subsets of compute wires that require preparation operations; blank wires remain part of the compute register.\nThe `RepControlledPauliExp` block is the power-one controlled first-order Trotter evolution.\nThe ancilla controls this block, and the resulting phase kickback places the Hamiltonian eigenphase on the ancilla's relative phase.\nThe final H gate converts that relative phase into measurement probabilities, the measurement produces one shot outcome, and the blue reset operations return the allocated qubits to $\\lvert0\\rangle$.\n\n
❓ How can you identify the readout ancilla in the rendered circuit?\n\n
\n\nThe q0 wire receives the H gates and feedback rotation, controls the Hamiltonian evolution, and is measured to obtain the phase bit.\nThe other twelve wires hold the prepared molecular state and form the compute register.\n\n
\n\n
\n\n
❓ Why do all six iteration circuits have the same width but different lengths?\n\n
\n\nEvery iteration uses the same twelve-qubit compute register and one readout ancilla, so each circuit has thirteen logical qubits.\nDifferent controlled powers repeat the approximate time-evolution unitary different numbers of times, changing the logical gate count rather than the register size.\n\n
\n\n
" + "source": [ + "## IQPE circuit visualization\n", + "\n", + "The cells below build the six iteration circuits and render the shortest one. No quantum simulation runs here.\n", + "\n", + "
\n", + "\n", + " |ψ0|ψ1|ψ2|ψ3|ψ4|ψ5|ψ6|ψ7|ψ8|ψ9|ψ10|ψ11|ψ12StatePreparationStatePreparationHRz0.0000RepControlledPauliExpH|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩|0⟩RunIQPEMakeIQPECircuit \n", + "\n", + "*Overview of the rendered power-one iteration circuit. Dashed outlines mark the nested `MakeIQPECircuit` and `RunIQPE` Q# operations; the solid boxes show their principal composite operations.*\n", + "\n", + "
\n", + "\n", + "The top wire, $\\lvert\\psi_0\\rangle$, is readout ancilla q0.\n", + "Its first H gate creates a superposition, and the `Rz(0.0000)` block applies the phase-feedback rotation.\n", + "This static preview constructs all six circuits with the builder's initial feedback angle of zero, so the displayed rotation is zero.\n", + "During an actual IQPE run, each iteration circuit is rebuilt using the accumulated feedback from earlier measured bits; the power-one iteration can therefore have a nonzero feedback rotation.\n", + "\n", + "The lower wires, $\\lvert\\psi_1\\rangle$ through $\\lvert\\psi_{12}\\rangle$, are compute-register qubits q1–q12.\n", + "The `StatePreparation` blocks load the four-determinant trial state on the subsets of compute wires that require preparation operations; blank wires remain part of the compute register.\n", + "The `RepControlledPauliExp` block is the power-one controlled first-order Trotter evolution.\n", + "The ancilla controls this block, and the resulting phase kickback places the Hamiltonian eigenphase on the ancilla's relative phase.\n", + "The final H gate converts that relative phase into measurement probabilities, the measurement produces one shot outcome, and the blue reset operations return the allocated qubits to $\\lvert0\\rangle$." + ] + }, + { + "cell_type": "code", + "id": "c-f098b11065e1", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Which of these are true of the readout ancilla in the rendered circuit?", + "options": [ + { + "id": "controls", + "text": "It controls the Hamiltonian evolution.", + "correct": true, + "explanation": "The controlled-unitary hangs off this wire, which is how the phase is kicked back onto it." + }, + { + "id": "measured", + "text": "It is measured to obtain the phase bit.", + "correct": true, + "explanation": "One measurement per iteration, and that bit feeds the next one." + }, + { + "id": "h-gates", + "text": "It receives the H gates and the feedback rotation.", + "correct": true, + "explanation": "That pair is what puts it in superposition and applies the phase learned from earlier iterations." + }, + { + "id": "molecular-state", + "text": "It holds the prepared molecular state.", + "correct": false, + "explanation": "The other twelve wires do that — they are the compute register. The ancilla is algorithm workspace." + } + ], + "multiSelect": true, + "cellId": "iqpe-readout-ancilla" + }, + "text/html": "
❓ Check your understanding

Which of these are true of the readout ancilla in the rendered circuit?

Select all that apply.

  1. It controls the Hamiltonian evolution.
  2. It is measured to obtain the phase bit.
  3. It receives the H gates and the feedback rotation.
  4. It holds the prepared molecular state.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Which of these are true of the readout ancilla in the rendered circuit?\n (select all that apply)\n [ ] It controls the Hamiltonian evolution.\n [ ] It is measured to obtain the phase bit.\n [ ] It receives the H gates and the feedback rotation.\n [ ] It holds the prepared molecular state." + }, + "metadata": {} + }, + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Why do all six iteration circuits have the same width but different lengths?", + "options": [ + { + "id": "growing-space", + "text": "The Trotter step count grows with the active-space size across iterations.", + "correct": false, + "explanation": "The active space is fixed for the whole run. What varies between iterations is the controlled power." + }, + { + "id": "feedback-ancilla", + "text": "Each iteration adds another ancilla to carry the feedback.", + "correct": false, + "explanation": "The feedback is classical. It changes a rotation angle, not the number of qubits." + }, + { + "id": "power-varies", + "text": "Every iteration uses the same twelve-qubit compute register and one readout ancilla, while different controlled powers repeat the evolution unitary different numbers of times.", + "correct": true, + "explanation": "Width is register size, thirteen logical qubits every time. Length is logical gate count, which the controlled power sets." + }, + { + "id": "more-qubits", + "text": "Later iterations act on more qubits, because they resolve more significant bits.", + "correct": false, + "explanation": "The register is fixed at thirteen qubits. Resolving a different bit changes the controlled power, not the width." + } + ], + "cellId": "iqpe-circuit-shape" + }, + "text/html": "
❓ Check your understanding

Why do all six iteration circuits have the same width but different lengths?

  1. The Trotter step count grows with the active-space size across iterations.
  2. Each iteration adds another ancilla to carry the feedback.
  3. Every iteration uses the same twelve-qubit compute register and one readout ancilla, while different controlled powers repeat the evolution unitary different numbers of times.
  4. Later iterations act on more qubits, because they resolve more significant bits.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Why do all six iteration circuits have the same width but different lengths?\n ( ) The Trotter step count grows with the active-space size across iterations.\n ( ) Each iteration adds another ancilla to carry the feedback.\n ( ) Every iteration uses the same twelve-qubit compute register and one readout ancilla, while different controlled powers repeat the evolution unitary different numbers of times.\n ( ) Later iterations act on more qubits, because they resolve more significant bits." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-readout-ancilla\", \"iqpe-circuit-shape\")\n" + ] }, { "cell_type": "code", @@ -232,7 +741,64 @@ "cell_type": "markdown", "id": "c-57097058fd45", "metadata": {}, - "source": "Each iteration contributes one measured phase bit to the complete IQPE result.\n\n
❓ How does IQPE use the result from each iteration to construct the final bitstring and phase fraction?\n\n
\n\nThe majority measurement for one iteration selects a phase bit, which updates the classical phase feedback used by the next iteration.\nAfter all six iterations, the feedback calculation combines the measured bits into one phase fraction.\nThe script writes that fraction as a conventional six-bit string, with the most significant bit first.\n\n
\n\n
" + "source": [ + "Each iteration contributes one measured phase bit to the complete IQPE result." + ] + }, + { + "cell_type": "code", + "id": "c-1ea05946cab6", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "How does IQPE turn the result of each iteration into the final bitstring and phase fraction?", + "options": [ + { + "id": "feedback-chain", + "text": "The majority measurement for each iteration selects a phase bit, which updates the classical phase feedback used by the next iteration; after six iterations the feedback calculation combines the bits into one fraction.", + "correct": true, + "explanation": "The script writes that fraction as a conventional six-bit string, with the most significant bit first." + }, + { + "id": "average-estimates", + "text": "The phase fraction is the average of the six per-iteration phase estimates.", + "correct": false, + "explanation": "Each iteration yields a single bit, not a phase estimate. Averaging them would throw away each bit's place value." + }, + { + "id": "independent-bits", + "text": "The bits are independent, so they can be measured in any order and concatenated.", + "correct": false, + "explanation": "They are not independent. Each measured bit updates the phase feedback for the next iteration, so the order is fixed." + }, + { + "id": "one-circuit", + "text": "All six bits are measured together in a single circuit and read off at the end.", + "correct": false, + "explanation": "That is textbook QPE. The iterative variant deliberately measures one bit per circuit, which is what keeps the register small." + } + ], + "cellId": "iqpe-bit-feedback" + }, + "text/html": "
❓ Check your understanding

How does IQPE turn the result of each iteration into the final bitstring and phase fraction?

  1. The majority measurement for each iteration selects a phase bit, which updates the classical phase feedback used by the next iteration; after six iterations the feedback calculation combines the bits into one fraction.
  2. The phase fraction is the average of the six per-iteration phase estimates.
  3. The bits are independent, so they can be measured in any order and concatenated.
  4. All six bits are measured together in a single circuit and read off at the end.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: How does IQPE turn the result of each iteration into the final bitstring and phase fraction?\n ( ) The majority measurement for each iteration selects a phase bit, which updates the classical phase feedback used by the next iteration; after six iterations the feedback calculation combines the bits into one fraction.\n ( ) The phase fraction is the average of the six per-iteration phase estimates.\n ( ) The bits are independent, so they can be measured in any order and concatenated.\n ( ) All six bits are measured together in a single circuit and read off at the end." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-bit-feedback\")\n" + ] }, { "cell_type": "markdown", @@ -242,7 +808,71 @@ "section:repeated-complete-runs" ] }, - "source": "## Repeated complete runs\n\nOne complete run can differ from another because every phase bit is selected from a finite number of simulator shots and the trial state contains several Hamiltonian eigenstates.\nThe workflow therefore repeats the complete six-bit procedure with twenty deterministic simulator seeds.\n\nThe final aggregation rule selects the most frequent complete bitstring, or *mode*.\nThis differs from the majority vote used inside one complete run: a per-bit majority chooses one bit from three shots, whereas the complete-run mode chooses one reconstructed bitstring from twenty runs.\nIf several bitstrings tie for the highest count, the script reports that no unique mode exists instead of silently choosing one.\n\n
❓ Why should the final aggregation use complete bitstrings rather than vote on each bit across complete runs?\n\n
\n\nEach complete bitstring represents one phase-grid point and its corresponding energy.\nVoting independently on bits could assemble a bitstring that was never produced by any complete run and would discard the observed joint distribution.\n\n
\n\n
" + "source": [ + "## Repeated complete runs\n", + "\n", + "One complete run can differ from another because every phase bit is selected from a finite number of simulator shots and the trial state contains several Hamiltonian eigenstates.\n", + "The workflow therefore repeats the complete six-bit procedure with twenty deterministic simulator seeds.\n", + "\n", + "The final aggregation rule selects the most frequent complete bitstring, or *mode*.\n", + "This differs from the majority vote used inside one complete run: a per-bit majority chooses one bit from three shots, whereas the complete-run mode chooses one reconstructed bitstring from twenty runs.\n", + "If several bitstrings tie for the highest count, the script reports that no unique mode exists instead of silently choosing one." + ] + }, + { + "cell_type": "code", + "id": "c-dd3abe6d02e9", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Why should the final aggregation use complete bitstrings rather than vote on each bit across complete runs?", + "options": [ + { + "id": "msb-bias", + "text": "Per-bit voting would bias the result toward the most significant bit.", + "correct": false, + "explanation": "The problem is not bias toward one bit. It is that the assembled string may correspond to no observed run at all." + }, + { + "id": "joint", + "text": "Each complete bitstring is one phase-grid point with a corresponding energy, and voting per bit could assemble a bitstring that no run ever produced.", + "correct": true, + "explanation": "Voting bit by bit also discards the joint distribution that was actually observed." + }, + { + "id": "simultaneous", + "text": "Complete bitstrings are required because the bits are measured simultaneously.", + "correct": false, + "explanation": "They are measured one per iteration. The reason is that a bitstring is only meaningful as a whole grid point." + }, + { + "id": "slower", + "text": "Per-bit voting gives the same answer but takes longer to compute.", + "correct": false, + "explanation": "It does not give the same answer: it can synthesize a result that never occurred in any run." + } + ], + "cellId": "iqpe-aggregation" + }, + "text/html": "
❓ Check your understanding

Why should the final aggregation use complete bitstrings rather than vote on each bit across complete runs?

  1. Per-bit voting would bias the result toward the most significant bit.
  2. Each complete bitstring is one phase-grid point with a corresponding energy, and voting per bit could assemble a bitstring that no run ever produced.
  3. Complete bitstrings are required because the bits are measured simultaneously.
  4. Per-bit voting gives the same answer but takes longer to compute.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Why should the final aggregation use complete bitstrings rather than vote on each bit across complete runs?\n ( ) Per-bit voting would bias the result toward the most significant bit.\n ( ) Each complete bitstring is one phase-grid point with a corresponding energy, and voting per bit could assemble a bitstring that no run ever produced.\n ( ) Complete bitstrings are required because the bits are measured simultaneously.\n ( ) Per-bit voting gives the same answer but takes longer to compute." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-aggregation\")\n" + ] }, { "cell_type": "markdown", @@ -252,7 +882,100 @@ "section:molecular-energy-reconstruction" ] }, - "source": "## Molecular energy reconstruction\n\nAfter the repeated complete runs, the script selects the bitstring observed most often.\nInterpret this bitstring as a binary integer $b$.\nIf the calculation measures $m$ phase bits, convert $b$ to the phase fraction\n\n$$\n\\varphi=\\frac{b}{2^m}.\n$$\n\nQDK/Chemistry converts $2\\pi\\varphi$ to its equivalent signed angle $\\alpha$ between $-\\pi$ and $\\pi$.\nNegating that angle and dividing by the evolution time maps the measured phase to the active-space energy:\n\n$$\nE_{\\mathrm{active}}^{\\mathrm{IQPE}}=\\frac{-\\alpha}{t}.\n$$\n\nThis estimates an eigenvalue of the qubit Hamiltonian, not yet the selected-space molecular total.\nFinite phase resolution, sampling, and product-formula time evolution all contribute error.\nAs the chapter *Mapping the problem to qubits* explains, the mapper does not include the core energy in the qubit Hamiltonian.\nThe core energy contains the nuclear repulsion and the constant contribution from frozen inactive orbitals.\nBecause these contributions are not measured by phase estimation, the script adds them classically:\n\n$$\nE_{\\mathrm{total}}^{\\mathrm{IQPE}}\n=E_{\\mathrm{active}}^{\\mathrm{IQPE}}+E_{\\mathrm{core}}.\n$$\n\nFinally, compare this reconstructed total with the CASCI energy of the same selected-space Hamiltonian:\n\n$$\n\\Delta E_{\\mathrm{algorithm}}\n=E_{\\mathrm{total}}^{\\mathrm{IQPE}}-E_{\\mathrm{CASCI}}.\n$$\n\nThe workflow meets the teaching target when $\\lvert\\Delta E_{\\mathrm{algorithm}}\\rvert\\leq1\\ \\mathrm{m}E_{\\mathrm{h}}$.\nThis comparison evaluates the configured quantum algorithm against its classical reference; it does not measure basis-set or active-space model error.\n\n
❓ Which energy comparison determines whether the IQPE workflow meets the teaching target?\n\n
\n\nCompare the reconstructed IQPE total energy with the CASCI energy of the same selected active-space Hamiltonian.\nComparing with experiment or a larger orbital space would mix algorithmic error with model error.\n\n
\n\n
" + "source": [ + "## Molecular energy reconstruction\n", + "\n", + "After the repeated complete runs, the script selects the bitstring observed most often.\n", + "Interpret this bitstring as a binary integer $b$.\n", + "If the calculation measures $m$ phase bits, convert $b$ to the phase fraction\n", + "\n", + "$$\n", + "\\varphi=\\frac{b}{2^m}.\n", + "$$\n", + "\n", + "QDK/Chemistry converts $2\\pi\\varphi$ to its equivalent signed angle $\\alpha$ between $-\\pi$ and $\\pi$.\n", + "Negating that angle and dividing by the evolution time maps the measured phase to the active-space energy:\n", + "\n", + "$$\n", + "E_{\\mathrm{active}}^{\\mathrm{IQPE}}=\\frac{-\\alpha}{t}.\n", + "$$\n", + "\n", + "This estimates an eigenvalue of the qubit Hamiltonian, not yet the selected-space molecular total.\n", + "Finite phase resolution, sampling, and product-formula time evolution all contribute error.\n", + "As the chapter *Mapping the problem to qubits* explains, the mapper does not include the core energy in the qubit Hamiltonian.\n", + "The core energy contains the nuclear repulsion and the constant contribution from frozen inactive orbitals.\n", + "Because these contributions are not measured by phase estimation, the script adds them classically:\n", + "\n", + "$$\n", + "E_{\\mathrm{total}}^{\\mathrm{IQPE}}\n", + "=E_{\\mathrm{active}}^{\\mathrm{IQPE}}+E_{\\mathrm{core}}.\n", + "$$\n", + "\n", + "Finally, compare this reconstructed total with the CASCI energy of the same selected-space Hamiltonian:\n", + "\n", + "$$\n", + "\\Delta E_{\\mathrm{algorithm}}\n", + "=E_{\\mathrm{total}}^{\\mathrm{IQPE}}-E_{\\mathrm{CASCI}}.\n", + "$$\n", + "\n", + "The workflow meets the teaching target when $\\lvert\\Delta E_{\\mathrm{algorithm}}\\rvert\\leq1\\ \\mathrm{m}E_{\\mathrm{h}}$.\n", + "This comparison evaluates the configured quantum algorithm against its classical reference; it does not measure basis-set or active-space model error." + ] + }, + { + "cell_type": "code", + "id": "c-13926c85dc5b", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Which energy comparison determines whether the IQPE workflow meets the teaching target?", + "options": [ + { + "id": "hartree-fock", + "text": "The active-space energy against the Hartree-Fock energy.", + "correct": false, + "explanation": "That measures how much correlation energy was recovered, not whether phase estimation reached its target." + }, + { + "id": "larger-space", + "text": "The reconstructed IQPE total energy against a CASCI energy computed in a larger active space.", + "correct": false, + "explanation": "Changing the space changes the Hamiltonian, so the comparison would no longer isolate the algorithm." + }, + { + "id": "casci-same-space", + "text": "The reconstructed IQPE total energy against the CASCI energy of the same selected active-space Hamiltonian.", + "correct": true, + "explanation": "The same Hamiltonian sits on both sides, so the difference isolates algorithmic error." + }, + { + "id": "experiment", + "text": "The reconstructed IQPE total energy against an experimental measurement for the molecule.", + "correct": false, + "explanation": "That would mix algorithmic error with molecular-model error and could not tell you which one you were looking at." + } + ], + "cellId": "iqpe-energy-comparison" + }, + "text/html": "
❓ Check your understanding

Which energy comparison determines whether the IQPE workflow meets the teaching target?

  1. The active-space energy against the Hartree-Fock energy.
  2. The reconstructed IQPE total energy against a CASCI energy computed in a larger active space.
  3. The reconstructed IQPE total energy against the CASCI energy of the same selected active-space Hamiltonian.
  4. The reconstructed IQPE total energy against an experimental measurement for the molecule.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Which energy comparison determines whether the IQPE workflow meets the teaching target?\n ( ) The active-space energy against the Hartree-Fock energy.\n ( ) The reconstructed IQPE total energy against a CASCI energy computed in a larger active space.\n ( ) The reconstructed IQPE total energy against the CASCI energy of the same selected active-space Hamiltonian.\n ( ) The reconstructed IQPE total energy against an experimental measurement for the molecule." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-energy-comparison\")\n" + ] }, { "cell_type": "markdown", @@ -316,7 +1039,109 @@ "section:the-complete-workflow" ] }, - "source": "## The complete workflow\n\nThe cell below runs the complete workflow. It is the long step in this chapter and reports progress after each complete run.\n\nThe script prints its settings before simulation and reports progress for every complete run, including the seed, bitstring, total energy, error, and elapsed time.\nA successful run completes all twenty runs and prints the complete-run bitstring counts, most frequent bitstring, component energies, reconstructed total, reference energy, and signed error.\n\n
❓ What bitstring distribution and energy estimate did the script produce?\n\n
\n\nThe bitstring `010000` appeared 19 times and `001111` appeared once, so `010000` was the most frequent result.\nIt produced an active-space energy of $-9.652276065987\\ E_{\\mathrm{h}}$ and a reconstructed total of $-108.770051792909\\ E_{\\mathrm{h}}$ after adding the core energy.\n\n
\n\n
\n\n
❓ Does the result meet the teaching target, and what does that establish?\n\n
\n\nThe reconstructed total is $+1\\ \\mathrm{m}E_{\\mathrm{h}}$ above the selected-space CASCI reference, meeting the teaching target at its boundary.\nThat offset was deliberately set by the reference-guided phase-grid alignment, while Trotter approximation and finite sampling can still affect which bitstring is selected.\nThis classical simulation of the quantum calculation therefore validates this configured teaching workflow; it does not remove molecular-model error or establish agreement with experiment.\n\n
\n\n
" + "source": [ + "## The complete workflow\n", + "\n", + "The cell below runs the complete workflow. It is the long step in this chapter and reports progress after each complete run.\n", + "\n", + "The script prints its settings before simulation and reports progress for every complete run, including the seed, bitstring, total energy, error, and elapsed time.\n", + "A successful run completes all twenty runs and prints the complete-run bitstring counts, most frequent bitstring, component energies, reconstructed total, reference energy, and signed error." + ] + }, + { + "cell_type": "code", + "id": "c-eca0c3efb565", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "What bitstring distribution did the script produce?", + "options": [ + { + "id": "unanimous", + "text": "All 20 runs produced `010000`.", + "correct": false, + "explanation": "Close, but one run landed on the adjacent grid point `001111`. Finite sampling and Trotter error still move the outcome sometimes." + }, + { + "id": "spread", + "text": "The 20 runs were spread across six different bitstrings, one per phase bit.", + "correct": false, + "explanation": "The distribution is far tighter: two grid points in total, one of them nineteen times." + }, + { + "id": "reversed", + "text": "`001111` appeared 19 times and `010000` once.", + "correct": false, + "explanation": "Reversed. `010000` is the majority result; `001111` is the neighbouring grid point that turned up once." + }, + { + "id": "19-1", + "text": "`010000` appeared 19 times and `001111` once, so `010000` is the most frequent result.", + "correct": true, + "explanation": "It gives an active-space energy of -9.652276065987 Eh and a reconstructed total of -108.770051792909 Eh once the core energy is added back." + } + ], + "cellId": "iqpe-observed-result" + }, + "text/html": "
❓ Check your understanding

What bitstring distribution did the script produce?

  1. All 20 runs produced `010000`.
  2. The 20 runs were spread across six different bitstrings, one per phase bit.
  3. `001111` appeared 19 times and `010000` once.
  4. `010000` appeared 19 times and `001111` once, so `010000` is the most frequent result.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: What bitstring distribution did the script produce?\n ( ) All 20 runs produced `010000`.\n ( ) The 20 runs were spread across six different bitstrings, one per phase bit.\n ( ) `001111` appeared 19 times and `010000` once.\n ( ) `010000` appeared 19 times and `001111` once, so `010000` is the most frequent result." + }, + "metadata": {} + }, + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Does the result meet the teaching target, and what does that establish?", + "options": [ + { + "id": "boundary", + "text": "Yes, at the boundary: the reconstructed total is 1 mEh above the selected-space CASCI reference, which validates this configured teaching workflow.", + "correct": true, + "explanation": "It does not remove molecular-model error or establish agreement with experiment. The offset itself was set by the reference-guided phase-grid alignment." + }, + { + "id": "matches-experiment", + "text": "Yes, and it establishes that the workflow reproduces the experimental energy of the molecule to 1 mEh.", + "correct": false, + "explanation": "Nothing here is compared against experiment. The reference is a CASCI energy in the same active space." + }, + { + "id": "errors-negligible", + "text": "Yes, and it shows that Trotter and sampling error are negligible.", + "correct": false, + "explanation": "Both can still affect which bitstring is selected. The agreement comes from the deliberate grid alignment, not from those errors vanishing." + }, + { + "id": "misses", + "text": "No, a 1 mEh offset is outside the teaching target.", + "correct": false, + "explanation": "The target is 1 mEh, so this meets it, though only exactly at the boundary." + } + ], + "cellId": "iqpe-target-met" + }, + "text/html": "
❓ Check your understanding

Does the result meet the teaching target, and what does that establish?

  1. Yes, at the boundary: the reconstructed total is 1 mEh above the selected-space CASCI reference, which validates this configured teaching workflow.
  2. Yes, and it establishes that the workflow reproduces the experimental energy of the molecule to 1 mEh.
  3. Yes, and it shows that Trotter and sampling error are negligible.
  4. No, a 1 mEh offset is outside the teaching target.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Does the result meet the teaching target, and what does that establish?\n ( ) Yes, at the boundary: the reconstructed total is 1 mEh above the selected-space CASCI reference, which validates this configured teaching workflow.\n ( ) Yes, and it establishes that the workflow reproduces the experimental energy of the molecule to 1 mEh.\n ( ) Yes, and it shows that Trotter and sampling error are negligible.\n ( ) No, a 1 mEh offset is outside the teaching target." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-observed-result\", \"iqpe-target-met\")\n" + ] }, { "cell_type": "code", @@ -334,7 +1159,105 @@ "section:knowledge-check" ] }, - "source": "## Knowledge check\n\n
❓ What changes if the number of phase bits increases while the repeated-power strategy remains fixed?\n\n
\n\nThe phase grid becomes finer, but an additional iteration circuit is required and the largest controlled-unitary power doubles.\nFor repeated-power Trotter evolution, that larger power increases circuit size and simulator runtime substantially.\n\n
\n\n
\n\n
❓ Would increasing shots per bit make the phase grid finer?\n\n
\n\nNo.\nMore shots can make each bit majority more stable, but grid spacing is controlled by the evolution time and number of phase bits.\n\n
\n\n
" + "source": [ + "## Knowledge check" + ] + }, + { + "cell_type": "code", + "id": "c-c7f8bfdb8bcc", + "execution_count": null, + "metadata": { + "tags": [ + "quiz" + ] + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "What happens if the number of phase bits increases while the repeated-power strategy stays fixed?", + "options": [ + { + "id": "extra-circuit", + "text": "One more iteration circuit is needed.", + "correct": true, + "explanation": "One circuit per bit, so an extra bit is an extra circuit to run." + }, + { + "id": "shorter", + "text": "The circuits get shorter, because each bit carries less information.", + "correct": false, + "explanation": "The opposite — the longest circuit grows, because the largest controlled power doubles." + }, + { + "id": "power-doubles", + "text": "The largest controlled-unitary power doubles.", + "correct": true, + "explanation": "For repeated-power Trotter evolution that is the expensive part: it increases circuit size and simulator runtime substantially." + }, + { + "id": "finer-grid", + "text": "The phase grid becomes finer.", + "correct": true, + "explanation": "That is the point of adding a bit — the interval is divided more finely." + } + ], + "multiSelect": true, + "cellId": "iqpe-more-bits" + }, + "text/html": "
❓ Check your understanding

What happens if the number of phase bits increases while the repeated-power strategy stays fixed?

Select all that apply.

  1. One more iteration circuit is needed.
  2. The circuits get shorter, because each bit carries less information.
  3. The largest controlled-unitary power doubles.
  4. The phase grid becomes finer.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: What happens if the number of phase bits increases while the repeated-power strategy stays fixed?\n (select all that apply)\n [ ] One more iteration circuit is needed.\n [ ] The circuits get shorter, because each bit carries less information.\n [ ] The largest controlled-unitary power doubles.\n [ ] The phase grid becomes finer." + }, + "metadata": {} + }, + { + "output_type": "display_data", + "data": { + "application/vnd.qdk.learning+json": { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": "Would increasing the number of shots per bit make the phase grid finer?", + "options": [ + { + "id": "no-spacing-fixed", + "text": "No. More shots can make each bit majority more stable, but grid spacing is set by the evolution time and the number of phase bits.", + "correct": true, + "explanation": "Shots change how confidently one grid point is selected, never the spacing between grid points." + }, + { + "id": "yes-interpolate", + "text": "Yes, averaging more shots interpolates between grid points.", + "correct": false, + "explanation": "The majority vote selects one grid point. It never produces a value between two of them." + }, + { + "id": "no-active-space", + "text": "No, because the grid is fixed by the size of the active space.", + "correct": false, + "explanation": "The right verdict for the wrong reason. Spacing comes from the evolution time and the number of phase bits." + }, + { + "id": "yes-trotter", + "text": "Yes, more shots reduce Trotter error, which is what sets the spacing.", + "correct": false, + "explanation": "Trotter error is not what sets grid spacing, and repeating shots does not reduce it." + } + ], + "cellId": "iqpe-more-shots" + }, + "text/html": "
❓ Check your understanding

Would increasing the number of shots per bit make the phase grid finer?

  1. No. More shots can make each bit majority more stable, but grid spacing is set by the evolution time and the number of phase bits.
  2. Yes, averaging more shots interpolates between grid points.
  3. No, because the grid is fixed by the size of the active space.
  4. Yes, more shots reduce Trotter error, which is what sets the spacing.

Open this lesson in VS Code for interactive checking and explanations.

", + "text/plain": "Check your understanding: Would increasing the number of shots per bit make the phase grid finer?\n ( ) No. More shots can make each bit majority more stable, but grid spacing is set by the evolution time and the number of phase bits.\n ( ) Yes, averaging more shots interpolates between grid points.\n ( ) No, because the grid is fixed by the size of the active space.\n ( ) Yes, more shots reduce Trotter error, which is what sets the spacing." + }, + "metadata": {} + } + ], + "source": [ + "quiz(\"iqpe-more-bits\", \"iqpe-more-shots\")\n" + ] }, { "cell_type": "markdown", diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py new file mode 100644 index 00000000000..311375d18e1 --- /dev/null +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py @@ -0,0 +1,349 @@ +"""Interactive outputs for QDK learning notebooks. + +This module lives at the course root. Per-unit helper files (``_unit.py``) +import from it and re-export the small authoring surface the notebooks need. + +The output model +---------------- +Each helper returns a lightweight display object carrying three views of the +same content: a custom QDK MIME payload, a ``text/html`` fallback, and a +``text/plain`` fallback. IPython writes all three into the ``.ipynb``; VS Code +picks the custom one and renders it interactively, while other notebook hosts +fall back to clean, non-interactive HTML or plain text. + +The payload shape is mirrored by ``src/notebookRenderer/schema.ts``. The two +are compared at build time by ``checkRendererContract()`` in ``build.mjs``, so +renaming a field on one side without the other fails the build rather than +producing an empty cell in front of a learner. +""" + +from __future__ import annotations + +import random +from dataclasses import dataclass +from html import escape +from typing import Any, Iterable, Mapping, Sequence + +MIME_TYPE = "application/vnd.qdk.learning+json" + +_CARD_STYLE = ( + "font-family:var(--qdk-font-family, system-ui, sans-serif);" + "color:var(--qdk-host-foreground, #222);" + "background:var(--qdk-host-background, #fff);" + "border:1px solid var(--qdk-widget-outline, #ccc);" + "border-radius:6px;" + "margin:10px 0;" + "line-height:1.45;" + "overflow:hidden;" +) + +#: The burnt-orange band the chemistry tutorial already uses to mark a +#: self-check question. The fallback matters: this HTML is what a host without +#: the QDK renderer shows, and it has no ``--qdk-*`` palette to resolve. +_QUIZ_HEADER_STYLE = ( + "background:var(--qdk-quiz-accent, #8c4a00);" + "color:var(--qdk-quiz-accent-foreground, #ffffff);" + "padding:0.35em 0.8em;" + "font-weight:600;" +) +_MUTED_STYLE = "color:var(--qdk-description-foreground, #666);font-size:0.9em" + + +@dataclass(frozen=True) +class LearningOutput: + """Display object for one QDK learning output. + + Parameters + ---------- + payload : mapping + JSON-serializable payload for ``application/vnd.qdk.learning+json``. + html : str + Non-interactive, escaped HTML fallback for notebook hosts that do not + know about the QDK learning renderer. + text : str + Plain text fallback for terminals and text-only exports. + """ + + payload: Mapping[str, Any] + html: str + text: str + + def _repr_mimebundle_( + self, + include: Iterable[str] | None = None, + exclude: Iterable[str] | None = None, + ) -> dict[str, Any]: + """Return the custom MIME payload plus HTML and plain text fallbacks.""" + bundle: dict[str, Any] = { + MIME_TYPE: dict(self.payload), + "text/html": self.html, + "text/plain": self.text, + } + if include is not None: + allowed = set(include) + bundle = {key: value for key, value in bundle.items() if key in allowed} + if exclude is not None: + blocked = set(exclude) + bundle = {key: value for key, value in bundle.items() if key not in blocked} + return bundle + + def __str__(self) -> str: + """Return the plain text fallback.""" + return self.text + + +def multiple_choice( + prompt: str, + options: Sequence[Sequence[Any]], + *, + multi_select: bool = False, + cell_id: str | None = None, +) -> LearningOutput: + """Create a multiple-choice learning output. + + ``options`` are ``(id, text, correct, explanation)`` tuples. Option ids must + be unique, and a question needs at least two options, so authoring mistakes + fail loudly when the notebook cell is run. + + A single-select question needs exactly one correct option. Pass + ``multi_select=True`` for a question with several right answers; the learner + then has to find all of them, and is told so. + """ + normalized = _normalize_options(options, multi_select=multi_select) + payload: dict[str, Any] = { + "schemaVersion": 1, + "kind": "multiple-choice", + "prompt": str(prompt), + "options": normalized, + } + if multi_select: + payload["multiSelect"] = True + if cell_id is not None: + payload["cellId"] = str(cell_id) + + html = _mcq_html(str(prompt), normalized, multi_select=multi_select) + text = _mcq_text(str(prompt), normalized, multi_select=multi_select) + return LearningOutput(payload, html, text) + + +# --------------------------------------------------------------------------- +# Registered quizzes +# --------------------------------------------------------------------------- + +# Quiz definitions live here, keyed by id, and units register into it from +# `_unit.py`. The notebook cell only names the quiz. +_quizzes: dict[str, LearningOutput] = {} + + +def register_quiz( + quiz_id: str, + prompt: str, + options: Sequence[Sequence[Any]], + *, + multi_select: bool = False, + shuffle: bool = True, +) -> str: + """Register a quiz under ``quiz_id`` so a notebook can show it by name. + + Answers are kept out of the *cell source*. A quiz written inline would put + the ``correct`` flags and the per-option explanations right there in the + code the learner reads, where reading them is easier than answering. So + quizzes are declared in the unit's ``_unit.py`` — the same place exercise + checkers already live — and the notebook cell just says ``quiz("...")``. + + The answers do still reach the browser, in the baked cell output, because + the renderer grades without a kernel. This raises the effort of cheating; it + does not make it impossible, and it is no weaker than the collapsible + answers it replaced. + + Pass ``multi_select=True`` for a question with several right answers; the + learner gets checkboxes and is told to select all that apply. + + Options are shuffled by default. It is natural to write the correct answer + first and the distractors after it, which makes "always pick A" a winning + strategy across a unit. The shuffle is seeded from ``quiz_id``, so the + order is stable: re-running a notebook, or baking its outputs again, does + not reorder the options or produce a spurious diff. Pass ``shuffle=False`` + for a question whose options have a meaningful order of their own. + """ + if quiz_id in _quizzes: + raise ValueError(f"a quiz is already registered as {quiz_id!r}") + ordered = _shuffled(quiz_id, options) if shuffle else options + _quizzes[quiz_id] = multiple_choice( + prompt, ordered, multi_select=multi_select, cell_id=quiz_id + ) + return quiz_id + + +def _shuffled(seed: str, options: Sequence[Sequence[Any]]) -> list[Sequence[Any]]: + """Permute options deterministically from a string seed. + + ``random.Random`` derives its state from a hash of the string rather than + from ``PYTHONHASHSEED``, so the same id yields the same order on every + machine and every run. + """ + shuffled = list(options) + random.Random(seed).shuffle(shuffled) + return shuffled + + +def quiz(*quiz_ids: str) -> None: + """Show the quizzes registered under ``quiz_ids``. + + Accepts more than one id because the progress tree names a code cell after + the heading above it, so two adjacent quiz cells in one section would + appear twice under the same name. Where the chapter asks two questions + back to back, one cell shows both. + + Displays rather than returns, so the call does not have to be the last + expression in the cell and one cell can produce several outputs. + """ + if not quiz_ids: + raise ValueError('quiz() needs at least one quiz id, e.g. quiz("my-quiz")') + + try: + from IPython.display import display + except ImportError as exc: # pragma: no cover - notebooks always have IPython + raise RuntimeError( + "quiz() displays its output and so needs IPython; " + "use multiple_choice() directly outside a notebook." + ) from exc + + for quiz_id in quiz_ids: + display(_lookup_quiz(quiz_id)) + + +def _lookup_quiz(quiz_id: str) -> LearningOutput: + try: + return _quizzes[quiz_id] + except KeyError: + known = ", ".join(sorted(_quizzes)) or "none" + raise ValueError( + f"no quiz is registered as {quiz_id!r}; registered quizzes: {known}. " + "Quizzes are registered in the unit's _unit.py." + ) from None + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _normalize_options( + options: Sequence[Sequence[Any]], + *, + multi_select: bool = False, +) -> list[dict[str, Any]]: + """Validate and normalize the option tuples an author wrote. + + One accepted shape, ``(id, text, correct, explanation)``. Earlier drafts + also took dicts and shorter tuples; nothing used them, and each extra shape + was another way for two quizzes to end up subtly inconsistent. + """ + if len(options) < 2: + raise ValueError("multiple_choice requires at least two options") + + normalized: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + correct_count = 0 + for option in options: + parts = list(option) + if len(parts) != 4: + raise ValueError( + "multiple_choice options must be " + "(id, text, correct, explanation) tuples; " + f"got {len(parts)} value(s)" + ) + option_id, text, correct, explanation = parts + option_id = str(option_id) + text = str(text) + + if not isinstance(correct, bool): + raise ValueError("multiple_choice option 'correct' values must be bool") + if not option_id: + raise ValueError("multiple_choice option ids must not be empty") + if option_id in seen_ids: + raise ValueError(f"duplicate multiple_choice option id: {option_id!r}") + seen_ids.add(option_id) + + item: dict[str, Any] = {"id": option_id, "text": text, "correct": correct} + if explanation is not None: + item["explanation"] = str(explanation) + if correct: + correct_count += 1 + normalized.append(item) + + if correct_count == 0: + raise ValueError("multiple_choice requires at least one correct option") + + if multi_select: + # A "select all that apply" with one answer teaches the learner to + # distrust the instruction, and one where everything applies isn't a + # question. Both are authoring mistakes, not learner mistakes. + if correct_count < 2: + raise ValueError( + "multi_select questions need at least two correct options; " + "drop multi_select=True for a single-answer question" + ) + if correct_count == len(normalized): + raise ValueError( + "every option is marked correct, so the question cannot be " + "answered wrongly" + ) + elif correct_count > 1: + # The renderer grades by comparing the selected set with the correct + # set, and a radio group holds one selection — so a single-select + # question with two correct answers can never be answered right. + raise ValueError( + f"multiple_choice got {correct_count} correct options; pass " + "multi_select=True for a question with several right answers" + ) + return normalized + + +def _mcq_html( + prompt: str, options: Sequence[Mapping[str, Any]], *, multi_select: bool +) -> str: + """Render the static fallback. + + Deliberately inert: no answers, no ``correct`` flags, no explanations. This + is what a host without the QDK renderer shows, and it is also what lands in + an exported HTML page, so anything revealed here is revealed to everyone. + """ + # A box for "pick several", a circle for "pick one" — the same distinction + # the interactive version draws with checkboxes and radios. + marker = "☐" if multi_select else "◯" + hint = ( + f"

Select all that apply.

" + if multi_select + else "" + ) + rows = [ + "
  • " + f" {escape(str(option['text']))}" + "
  • " + for option in options + ] + return ( + f"
    " + f"
    ❓ Check your understanding
    " + "
    " + f"

    {escape(prompt)}

    " + f"{hint}" + "
      " + "".join(rows) + "
    " + f"

    Open this lesson in VS Code " + "for interactive checking and explanations.

    " + "
    " + "
    " + ) + + +def _mcq_text( + prompt: str, options: Sequence[Mapping[str, Any]], *, multi_select: bool +) -> str: + lines = [f"Check your understanding: {prompt}"] + if multi_select: + lines.append(" (select all that apply)") + marker = "[ ]" if multi_select else "( )" + lines.extend(f" {marker} {option['text']}" for option in options) + return "\n".join(lines) diff --git a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/README.md b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/README.md index e7dee99ea34..7bd5fe0543d 100644 --- a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/README.md +++ b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/README.md @@ -31,8 +31,43 @@ list the same units; the converter stops if they disagree. The links point at the learner's `*.workbook.ipynb` copies, which the extension materializes beside the authored notebooks, so they only resolve inside a learner's workspace. -| Script | What it does | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `rst_to_notebook.py` | Converts one tutorial chapter to a unit notebook. `RECIPES` holds the per-chapter decisions a human still has to make. | -| `bake_outputs.py` | Runs a notebook so its outputs ship with the course. | -| `verify_course.py` | Checks every unit loads, validates, and carries what the tree needs. Pass `--allow-outputs` when reviewing baked notebooks. | +| Script | What it does | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `rst_to_notebook.py` | Converts one tutorial chapter to a unit notebook. `RECIPES` holds the per-chapter decisions a human still has to make. | +| `details_to_quiz.py` | Bakes a chapter's self-check questions into answerable quizzes. The quiz counterpart of `bake_outputs.py`; run it after `rst_to_notebook.py`. | +| `bake_outputs.py` | Runs a notebook so its outputs ship with the course. | +| `verify_course.py` | Checks every unit loads, validates, and carries what the tree needs. Pass `--allow-outputs` when reviewing baked notebooks. | + +## Self-check questions + +`details_to_quiz.py` bakes a chapter's self-check questions into the notebook, +so a learner sees them on opening the file and can answer without a kernel. It +is the quiz counterpart of `bake_outputs.py`, but a quiz is pure data, so it +calls the emitter directly instead of starting a kernel. + +The first run on a chapter also creates the cells to bake. A chapter's +`quiz-question` admonitions arrive as `
    ` disclosures whose only +interaction is revealing the answer, and those blocks are replaced with +`quiz("id")` calls. Afterwards every run is only a rebake. + +The choices themselves live in the unit's `_unit.py`, registered by id, so the +notebook cell a learner reads is just `quiz("id")` rather than the answer key. +Write them by hand: which wrong answers are worth offering is a judgement about +what a learner is likely to believe, and the converter never touches `_unit.py`. +The answers do travel to the browser inside the baked cell output, because the +renderer grades without a kernel — this raises the effort of looking them up +rather than making it impossible, and it is no weaker than the collapsible +answers it replaces. + +Regenerating a chapter puts the `
    ` back, so re-run this afterwards. +Both steps are idempotent and safe to run either way: + + python rst_to_notebook.py 06_iterative_phase_estimation + python details_to_quiz.py 06-iterative-phase-estimation --ids + python verify_course.py + +After that first conversion the notebook names its own ids, so rebaking edited +questions, or checking for drift against `_unit.py`, is just: + + python details_to_quiz.py 06-iterative-phase-estimation + python details_to_quiz.py 06-iterative-phase-estimation --check diff --git a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py new file mode 100644 index 00000000000..ff7ea76983b --- /dev/null +++ b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py @@ -0,0 +1,427 @@ +"""Bake a chapter's self-check questions into answerable quizzes. + +This is the quiz counterpart of ``bake_outputs.py``: it stores each question's +rendered output in the notebook so a learner sees it on opening the file, with +no kernel. Where ``bake_outputs.py`` starts a kernel to run the chemistry, a +quiz is pure data, so this just calls the emitter and keeps what it returns. + +The first run on a chapter also has to create the cells to bake. Chapters +written by ``rst_to_notebook.py`` render each ``quiz-question`` admonition as a +``
    `` disclosure whose only interaction is revealing the answer, so +those blocks are replaced with ``quiz()`` calls. Afterwards there is nothing +left to convert and every run is only a rebake. + +The choices are written by hand in ``_unit.py``: deciding which wrong answers +are worth offering is the author's judgement, not something to generate. This +never writes ``_unit.py``. + +Both steps are idempotent. The one-time conversion: + +* a markdown cell is split at each quiz block, and the block becomes a code + cell between the prose that surrounded it; +* quiz blocks with no prose between them share one code cell, because the + progress tree names a code cell after the heading above it and two cells in + one section would appear twice under the same name; +* the first fragment keeps the original cell's id and tags, so a ``section:`` + tag is not duplicated onto a fragment that does not start a section. + +A first conversion needs the ids to substitute, in document order:: + + python details_to_quiz.py 06-iterative-phase-estimation --ids iqpe-grid-target ... + +After that the notebook names them, so rebaking after editing ``_unit.py`` is +just the unit, and ``--check`` reports drift without writing:: + + python details_to_quiz.py 06-iterative-phase-estimation --check +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import sys +from pathlib import Path +from typing import Any + +COURSE = Path(__file__).resolve().parent.parent.parent / "courses" / "chemistry-qpe" + +#: The wrapper the generator emits around every self-check question. Matching +#: the whole wrapper, not just the ``
    ``, keeps the tinted border from +#: being left behind as an empty box. +QUIZ_BLOCK = re.compile( + r'
    \s*
    .*?
    \s*
    ', + re.S, +) + +#: How `rst_to_notebook.py` names a generated cell, so converted notebooks and +#: regenerated ones agree. +def _cell_id(body: str) -> str: + return "c-" + hashlib.sha256(body.encode()).hexdigest()[:12] + + +#: A converted question. One ``quiz()`` call can name several ids. +#: +#: `verify_course.py` carries the same pair, deliberately: it is a script that +#: verifies on import, so importing it here would run the whole course check. +#: Two one-line regexes are cheaper than that coupling — but if the `quiz()` +#: call shape changes, both files need it. +QUIZ_CALL = re.compile(r"^quiz\(([^)]*)\)", re.M) +QUIZ_ID = re.compile(r'"([^"]+)"') + + +def _load_unit_module(unit_dir: Path) -> tuple[Any, Any]: + """Import a unit's ``_unit.py`` so its ``register_quiz`` calls run. + + Returns the unit module and the emitter module it registered into. The + emitter is reached through ``sys.modules`` rather than the unit's + namespace: importing ``_unit`` puts ``_learning_output`` there, and going + to the source avoids depending on which names a unit chose to re-export. + """ + course_root = str(unit_dir.parent) + if course_root not in sys.path: + sys.path.insert(0, course_root) + if str(unit_dir) not in sys.path: + sys.path.insert(0, str(unit_dir)) + + spec = importlib.util.spec_from_file_location( + f"_unit_{unit_dir.name}", unit_dir / "_unit.py" + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load {unit_dir / '_unit.py'}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + emitter = sys.modules.get("_learning_output") + if emitter is None: + raise SystemExit( + f"{unit_dir / '_unit.py'} does not import _learning_output, so it " + "registers no quizzes." + ) + return module, emitter + + +def _cell_tags(cell: dict[str, Any]) -> list[str]: + tags = cell.get("metadata", {}).get("tags", []) + return [str(t) for t in tags] + + +def _split_cell(source: str) -> list[tuple[str, str]]: + """Split markdown into an alternating run of prose and quiz markers. + + Returns ``("prose", text)`` and ``("quiz", block)`` pairs in document + order, with empty prose dropped. + """ + pieces: list[tuple[str, str]] = [] + cursor = 0 + for match in QUIZ_BLOCK.finditer(source): + prose = source[cursor : match.start()].strip("\n") + if prose.strip(): + pieces.append(("prose", prose)) + pieces.append(("quiz", match.group(0))) + cursor = match.end() + tail = source[cursor:].strip("\n") + if tail.strip(): + pieces.append(("prose", tail)) + return pieces + + +def _group_adjacent_quizzes( + pieces: list[tuple[str, str]], +) -> list[tuple[str, list[str]]]: + """Collapse a run of quizzes with no prose between them into one group.""" + grouped: list[tuple[str, list[str]]] = [] + for kind, text in pieces: + if kind == "quiz" and grouped and grouped[-1][0] == "quiz": + grouped[-1][1].append(text) + continue + grouped.append((kind, [text])) + return grouped + + +def _baked_outputs(emitter: Any, ids: list[str]) -> list[dict[str, Any]]: + """Render the display bundles the cell would produce when run. + + ``quiz()`` displays rather than returns, so running it yields one + ``display_data`` output per question. + """ + outputs = [] + for quiz_id in ids: + bundle = emitter._lookup_quiz(quiz_id)._repr_mimebundle_() + outputs.append( + { + "output_type": "display_data", + "data": bundle, + "metadata": {}, + } + ) + return outputs + + +def _ensure_quiz_import(cell: dict[str, Any]) -> bool: + """Add ``quiz`` to the unit import in the setup cell. + + Returns whether this cell *is* the setup cell, not whether it changed — so + a re-run stops at the same place a first run did. + + The notebook's first code cell already imports from ``_unit``; extending + that line keeps the plumbing a learner sees to the one import they were + always going to run. + """ + source = "".join(cell["source"]) + match = re.search(r"^from _unit import (.+)$", source, re.M) + if match is None: + # Not the setup cell. Distinct from "found it and it already imports + # quiz", so the caller can stop scanning once the line is seen and not + # go on to rewrite an exercise cell's own `from _unit import`. + return False + + names = [name.strip() for name in match.group(1).split(",")] + if "quiz" not in names: + replacement = f"from _unit import {', '.join(sorted({*names, 'quiz'}))}" + updated = source[: match.start()] + replacement + source[match.end() :] + cell["source"] = updated.splitlines(keepends=True) + return True + + +def _cell_quiz_ids(cell: dict[str, Any]) -> list[str]: + """The quiz ids a single cell shows, in order.""" + source = "".join(cell["source"]) + return [ + quiz_id + for call in QUIZ_CALL.findall(source) + for quiz_id in QUIZ_ID.findall(call) + ] + + +def _notebook_quiz_ids(notebook: dict[str, Any]) -> list[str]: + """The quiz ids the notebook already shows, in document order.""" + found: list[str] = [] + for cell in notebook["cells"]: + found.extend(_cell_quiz_ids(cell)) + return found + + +def _already_converted(notebook: dict[str, Any], quiz_ids: list[str]) -> bool: + """True when the notebook already shows exactly these quizzes.""" + return _notebook_quiz_ids(notebook) == quiz_ids + + +def convert(notebook_path: Path, unit_dir: Path, quiz_ids: list[str]) -> dict[str, Any]: + _unit_module, emitter = _load_unit_module(unit_dir) + notebook = json.loads(notebook_path.read_text(encoding="utf-8")) + + imported = False + remaining = list(quiz_ids) + converted: list[dict[str, Any]] = [] + for cell in notebook["cells"]: + source = "".join(cell["source"]) + if cell["cell_type"] == "code" and not imported: + imported = _ensure_quiz_import(cell) + if cell["cell_type"] != "markdown" or not QUIZ_BLOCK.search(source): + converted.append(cell) + continue + + groups = _group_adjacent_quizzes(_split_cell(source)) + # The original cell's id and tags belong to whichever fragment comes + # first, whether that is prose or a question. Tying them to "the first + # prose fragment" would drop a section: tag from a cell that opens + # with a question. + identity_used = False + for kind, texts in groups: + if kind == "prose": + body = texts[0] + if identity_used: + # Key order matches the cells `rst_to_notebook.py` emits, + # so a split fragment looks like every other cell. + fragment: dict[str, Any] = { + "cell_type": "markdown", + "id": _cell_id(body), + "metadata": {}, + "source": [], + } + else: + fragment = dict(cell) + identity_used = True + fragment["source"] = body.splitlines(keepends=True) + converted.append(fragment) + else: + ids = [remaining.pop(0) for _ in texts] + call = "quiz({})\n".format(", ".join(f'"{i}"' for i in ids)) + # Tagged so the progress tree looks past this cell for the + # section heading: a quiz sits inside a section rather than + # starting one. + tags = ["quiz"] + quiz_cell_id = _cell_id(call) + if not identity_used: + tags = sorted({*_cell_tags(cell), "quiz"}) + quiz_cell_id = cell["id"] + identity_used = True + converted.append( + { + "cell_type": "code", + "id": quiz_cell_id, + "execution_count": None, + "metadata": {"tags": tags}, + "outputs": _baked_outputs(emitter, ids), + "source": [call], + } + ) + + if remaining: + raise SystemExit( + f"{len(remaining)} unused quiz id(s): {', '.join(remaining)}. " + "Ids must be given in document order, one per question." + ) + if not imported and quiz_ids: + raise SystemExit( + "could not find a 'from _unit import ...' line to add quiz to; " + "add the import to the notebook's setup cell by hand." + ) + + notebook["cells"] = converted + return notebook + + +def _rebake(notebook: dict[str, Any], emitter: Any, stale: set[str]) -> None: + """Re-render the outputs of the cells holding a stale quiz. + + Rebaking is per cell because a cell can hold several quizzes, so one stale + question re-renders its neighbours too. That is why only the cells that + need it are touched: it keeps the write to what the report named. + """ + for cell in notebook["cells"]: + ids = _cell_quiz_ids(cell) + if ids and not stale.isdisjoint(ids): + cell["outputs"] = _baked_outputs(emitter, ids) + + +def _normalize_bundle(data: Any) -> Any: + """Undo nbformat's line splitting so two bundles can be compared. + + nbformat stores every non-JSON MIME value as a list of lines, and applies + that on read *and* on write. So a notebook saved by VS Code, by Jupyter, or + by ``bake_outputs.py`` holds lists where this tool wrote strings. Comparing + without rejoining would report every quiz as stale forever. + """ + if isinstance(data, list) and all(isinstance(x, str) for x in data): + return "".join(data) + if isinstance(data, dict): + return {key: _normalize_bundle(value) for key, value in data.items()} + return data + + +def _stale_baked_outputs(notebook: dict[str, Any], emitter: Any) -> list[str]: + """Report quizzes whose baked output no longer matches ``_unit.py``. + + This is the drift that matters once a notebook is converted: the questions + a learner sees are the outputs stored in the file, so editing a quiz's + wording or its options without re-running this tool would leave the old + version on screen. + """ + stale: list[str] = [] + for cell in notebook["cells"]: + source = "".join(cell["source"]) + outputs = cell.get("outputs", []) + position = 0 + for call in QUIZ_CALL.findall(source): + for quiz_id in QUIZ_ID.findall(call): + expected = _normalize_bundle( + emitter._lookup_quiz(quiz_id)._repr_mimebundle_() + ) + actual = ( + _normalize_bundle(outputs[position].get("data")) + if position < len(outputs) + else None + ) + if actual != expected: + stale.append(quiz_id) + position += 1 + return stale + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("unit", help="unit folder name, e.g. 06-iterative-phase-estimation") + parser.add_argument( + "--ids", + nargs="+", + help="quiz ids in document order; only needed for a first conversion, " + "since a converted notebook already names them", + ) + parser.add_argument("--course", default=str(COURSE), help="course root") + parser.add_argument( + "--check", + action="store_true", + help="verify without writing; exits non-zero if the notebook is stale", + ) + args = parser.parse_args() + + unit_dir = Path(args.course) / args.unit + notebooks = sorted(unit_dir.glob("*.ipynb")) + if len(notebooks) != 1: + raise SystemExit(f"expected exactly one notebook in {unit_dir}, found {len(notebooks)}") + notebook_path = notebooks[0] + + original = notebook_path.read_text(encoding="utf-8") + + # Fall back to the ids the notebook already names, so re-running the + # converter or checking for drift does not mean repeating the list every + # time. A first conversion has none to read and still has to be told. + quiz_ids = list(args.ids) if args.ids else _notebook_quiz_ids(json.loads(original)) + if not quiz_ids: + raise SystemExit( + f"{notebook_path.name} has no quiz() calls yet, so --ids is required " + "to say which questions to substitute, in document order" + ) + + # Re-runnable on purpose. The conversion is a step after + # `rst_to_notebook.py`, so a pipeline should be able to run it without + # first checking whether the notebook was regenerated. + if _already_converted(json.loads(original), quiz_ids): + _unit_module, emitter = _load_unit_module(unit_dir) + stale = _stale_baked_outputs(json.loads(original), emitter) + if not stale: + print(f"{notebook_path.name}: already converted and up to date") + return 0 + + listed = ", ".join(sorted(set(stale))) + if args.check: + print(f"{notebook_path.name}: baked output is stale for {listed}") + return 1 + + # Rebake in place rather than refusing: the questions live in + # _unit.py, and the notebook is only a rendering of them. + notebook = json.loads(original) + _rebake(notebook, emitter, set(stale)) + notebook_path.write_text( + json.dumps(notebook, indent=1, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", + ) + print(f"{notebook_path.name}: rebaked {listed}") + return 0 + + converted = convert(notebook_path, unit_dir, quiz_ids) + # nbformat writes one-space indent and a trailing newline; match it so the + # file stays comparable with the ones `rst_to_notebook.py` produces. + text = json.dumps(converted, indent=1, ensure_ascii=False) + "\n" + + quiz_cells = sum( + 1 for c in converted["cells"] if c["cell_type"] == "code" and c["source"][0].startswith("quiz(") + ) + print(f"{notebook_path.name}: {len(quiz_ids)} questions in {quiz_cells} cells") + + if args.check: + print("unchanged" if text == original else "would rewrite") + return 0 if text == original else 1 + + notebook_path.write_text(text, encoding="utf-8", newline="\n") + print(f"wrote {notebook_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/verify_course.py b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/verify_course.py index 73c58e5ddde..95bb602edf0 100644 --- a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/verify_course.py +++ b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/verify_course.py @@ -27,7 +27,7 @@ EXPECTED_TOTALS = Counter( { "units": 7, - "cells": 181, + "cells": 191, "exercises": 6, "hints": 6, "solutions": 6, @@ -44,6 +44,10 @@ "explanation": "markdown", } REGISTER_CALLS = {"register_exercise", "register_value_exercise"} +#: A converted self-check question. One ``quiz()`` call can name several ids, +#: so the questions are counted from the ids rather than from the calls. +QUIZ_CALL = re.compile(r"^quiz\(([^)]*)\)", re.M) +QUIZ_ID = re.compile(r'"([^"]+)"') EXPECTED_ATTACHMENT_MIMES = { "tutorial_qpe_atomic_basis_functions.png": "image/png", "tutorial_qpe_example_molecular_orbitals.png": "image/png", @@ -169,6 +173,9 @@ def registered_names(path: Path) -> set[str]: current_exercise = None counts["sections"] += any(tag.startswith("section:") for tag in tags) counts["quizzes"] += source.count("
    ") + counts["quizzes"] += sum( + len(QUIZ_ID.findall(call)) for call in QUIZ_CALL.findall(source) + ) for name in re.findall(r']+data-asset="([^"]+)"', source): counts["inline_svgs"] += 1 if name not in EXPECTED_INLINE_SVGS: @@ -250,8 +257,11 @@ def registered_names(path: Path) -> set[str]: if cell.cell_type == "code": counts["code_cells"] += 1 uses_unit_module |= "_unit" in source - allow_cell_output = args.allow_outputs and not ( - tags & AUTHORING_KINDS.keys() + # A quiz cell's output is the question itself, baked so it is + # visible on opening rather than after a run. That is content, + # not a leftover from executing the notebook. + allow_cell_output = "quiz" in tags or ( + args.allow_outputs and not (tags & AUTHORING_KINDS.keys()) ) if cell.get("outputs") and not allow_cell_output: problems.append(f"{cell.id}: code cell ships output") diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index 1ce2135c6a8..f8538086f39 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -9,6 +9,7 @@ import { import { registerLearningCommands } from "./commands.js"; import { LessonPanelManager, registerLessonPanelSerializer } from "./panel.js"; import { createNotebookCellStatusBarProvider } from "./notebookCellStatusBar.js"; +import { registerNotebookRendererMessaging } from "./notebookRendererMessaging.js"; import { registerNotebookSync } from "./notebookSync.js"; import { registerLearningProgressView } from "./progressTreeView.js"; import { LearningService } from "./service.js"; @@ -91,6 +92,7 @@ export function initLearning( registerLearningCommands(context, learningService, panelManager); registerLessonPanelSerializer(context, panelManager); registerNotebookSync(context, learningService); + registerNotebookRendererMessaging(context, learningService); return learningService; } diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts index fd4dde50f75..6561430e721 100644 --- a/source/vscode/src/learning/notebookExercises.ts +++ b/source/vscode/src/learning/notebookExercises.ts @@ -39,6 +39,18 @@ import type { /** Tag marking the code cell a learner edits. */ const EXERCISE_TAG = "exercise"; +/** + * Tag marking a code cell that only shows a registered quiz. + * + * A quiz cell is learner content, so unlike the authoring tags it stays in the + * working copy — but it is not an activity, for the reasons in + * `parseNotebookActivities`. It is called out because it sits *inside* a + * section's prose: without the tag it would hide the section heading from the + * code cell that follows it, which would then fall back to being named after + * its cell id. + */ +const QUIZ_TAG = "quiz"; + /** Tags marking author-only cells, removed from the learner's working copy. */ const AUTHORING_TAGS = ["hint", "solution", "explanation"] as const; @@ -87,6 +99,16 @@ export function parseNotebookActivities( const cell = cells[i]; const tags = cellTags(cell); + // A quiz cell stays in the learner's copy but is not an activity. Its + // outputs are already in the notebook, running it does not produce a + // result the service records, and whether the learner answered correctly + // never leaves the renderer — so a progress entry would claim tracking + // that does not exist. Skipped before `current` is cleared so a quiz + // between an exercise and its hint would not orphan the hint. + if (tags.includes(QUIZ_TAG)) { + continue; + } + const authoringTag = AUTHORING_TAGS.find((t) => tags.includes(t)); // Treat all code cells as activities, but only update current for EXERCISE_TAG. @@ -311,6 +333,11 @@ function extractTitleFromPrecedingCell( ) { break; } + // A quiz sits within a section rather than starting one, so look straight + // past it for the heading instead of stopping at it. + if (tags.includes(QUIZ_TAG)) { + continue; + } if (cellKind(cell) !== "markdown") { break; } diff --git a/source/vscode/src/learning/notebookRendererMessaging.ts b/source/vscode/src/learning/notebookRendererMessaging.ts new file mode 100644 index 00000000000..0bb1023031e --- /dev/null +++ b/source/vscode/src/learning/notebookRendererMessaging.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import { isNotebookCourse } from "./courseLayout.js"; +import type { CopilotActionId } from "../notebookRenderer/schema.js"; +import { + isRendererToExtensionMessage, + RENDERER_ID, +} from "../notebookRenderer/schema.js"; +import type { LearningService } from "./service.js"; + +/** + * Bridges the QDK learning notebook renderer to the extension host. + * + * A renderer webview can't execute VS Code commands, so `createRendererMessaging` + * is the channel out. Everything arriving here is authored notebook content and + * therefore untrusted: the renderer may only name an action id from a fixed + * allowlist, never a prompt or command id, and any free text it contributes is + * sanitized into an extension-owned template. + */ +export function registerNotebookRendererMessaging( + context: vscode.ExtensionContext, + service: LearningService, +): void { + const messaging = vscode.notebooks.createRendererMessaging(RENDERER_ID); + + context.subscriptions.push( + messaging.onDidReceiveMessage(async (event) => { + const message: unknown = event.message; + if (!isRendererToExtensionMessage(message)) { + log.warn( + "Learning: discarding malformed message from the notebook renderer.", + ); + return; + } + + try { + await handleAction(service, message.actionId, { + ...message.context, + }); + } catch (e) { + log.error(`Learning: renderer message "${message.type}" failed`, e); + } + }), + ); +} + +async function handleAction( + service: LearningService, + actionId: CopilotActionId, + context: Record, +): Promise { + // The learner may open a course notebook before anything has started the + // learning experience. Initialize first — the same thing the "continue" + // command does — so the button never silently does nothing. + if (!service.initialized) { + await service.tryInitialize({ createIfMissing: true }); + } + + if ( + !service.initialized || + !isNotebookCourse(service.getActiveCourseInfo()) + ) { + log.warn( + "Learning: ignoring a renderer action outside an active notebook course.", + ); + return; + } + + await openChat(buildQuery(actionId, context)); +} + +/** + * Prompt templates, owned by the extension. + * + * These stay as short as the queries the cell status bar sends + * ("/qdk-learning Give me a hint"). The `qdk-learning-*` language model tools + * already report the learner's position, progress and code on every + * invocation, so a long prompt would be restating what the agent can look up. + * The question and the chosen option are the exception: a quiz is not an + * activity, so nothing the tools can read says which of a unit's questions + * was answered or what was picked. + */ +function buildQuery( + actionId: CopilotActionId, + context: Record, +): string { + const choice = sanitize(context.choice); + const question = sanitize(context.question); + + switch (actionId) { + case "why-wrong": + if (question && choice) { + return `/qdk-learning I answered "${choice}" to: ${question} — why is that wrong?`; + } + return choice + ? `/qdk-learning I picked "${choice}" and it was marked wrong. Why?` + : `/qdk-learning I got this question wrong. Why?`; + } +} + +async function openChat(query: string): Promise { + // No position move here. A quiz cell is deliberately not an activity, so + // there is nothing for `goToActivityByCellId` to find — the payload's + // `cellId` is the quiz's own id, not an ipynb cell id. The question and the + // chosen option travel in the query instead. + + await vscode.commands.executeCommand("workbench.action.chat.open", { + query, + isPartialQuery: false, + }); +} + +/** Longest run of renderer-supplied text we'll splice into a chat prompt. */ +const MAX_CONTEXT_CHARS = 300; + +/** + * Flatten renderer-supplied text so it can sit inside a quoted prompt template. + * + * Control characters, line separators and newlines are folded to spaces so the + * text can't break out of its sentence and read as a fresh instruction, double + * quotes become single quotes so they can't close the quoted span, and the + * result is truncated. This is defence in depth: the values are authored by the + * course or chosen by the learner, but they still reach a language model. + */ +function sanitize(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + let flattened = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + const isControl = + code < 0x20 || + (code >= 0x7f && code <= 0x9f) || + code === 0x2028 || + code === 0x2029; + + if (isControl) { + flattened += " "; + } else if (char === '"') { + flattened += "'"; + } else { + flattened += char; + } + } + + const collapsed = flattened.replace(/\s+/g, " ").trim(); + if (collapsed.length === 0) { + return undefined; + } + + return collapsed.length > MAX_CONTEXT_CHARS + ? `${collapsed.slice(0, MAX_CONTEXT_CHARS - 1)}\u2026` + : collapsed; +} diff --git a/source/vscode/src/notebookRenderer/css.d.ts b/source/vscode/src/notebookRenderer/css.d.ts new file mode 100644 index 00000000000..eb6bf3bc266 --- /dev/null +++ b/source/vscode/src/notebookRenderer/css.d.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The renderer build loads `.css` with esbuild's `text` loader (see the + * `renderer` target in `build.mjs`), so a CSS import yields the stylesheet + * source as a string for us to inject. + */ +declare module "*.css" { + const content: string; + export default content; +} diff --git a/source/vscode/src/notebookRenderer/index.ts b/source/vscode/src/notebookRenderer/index.ts new file mode 100644 index 00000000000..b826edd7ed4 --- /dev/null +++ b/source/vscode/src/notebookRenderer/index.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + ActivationFunction, + OutputItem, + RendererContext, +} from "vscode-notebook-renderer"; +import { renderMultipleChoice } from "./multipleChoice.js"; +import type { LearningPayload, RendererToExtensionMessage } from "./schema.js"; +import { + isRecord, + isRendererToExtensionMessage, + MIME_TYPE, + RENDERER_ID, +} from "./schema.js"; +// Bundled as text by the renderer build so the styles can be injected here — +// VS Code loads the renderer as a lone JS module and won't fetch a sibling +// stylesheet. `qdk-theme.css` supplies the shared `--qdk-*` palette and must +// come first so our rules can build on it. +import themeCss from "../../../npm/qsharp/ux/qdk-theme.css"; +import rendererCss from "./styles.css"; + +const STYLE_ELEMENT_ID = "qdk-learning-renderer-styles"; + +/** Add the stylesheet to the output webview once per document. */ +function ensureStyles() { + if (document.getElementById(STYLE_ELEMENT_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ELEMENT_ID; + style.textContent = `${themeCss}\n${rendererCss}`; + document.head.appendChild(style); +} + +type Cleanup = () => void; + +const cleanupByOutputId = new Map(); +const cleanupByElement = new WeakMap(); + +export const activate: ActivationFunction = ( + context: RendererContext, +) => { + const postAction = (message: RendererToExtensionMessage) => { + if ( + context.postMessage === undefined || + !isRendererToExtensionMessage(message) + ) { + return false; + } + + void context.postMessage(message); + return true; + }; + + return { + renderOutputItem(outputItem: OutputItem, element: HTMLElement) { + ensureStyles(); + cleanupOutput(outputItem.id); + cleanupElement(element); + element.replaceChildren(); + + const disposables: Cleanup[] = []; + const cleanup = () => { + for (const dispose of disposables.splice(0)) { + dispose(); + } + if (cleanupByElement.get(element) === cleanup) { + element.replaceChildren(); + cleanupByElement.delete(element); + } + cleanupByOutputId.delete(outputItem.id); + }; + + cleanupByOutputId.set(outputItem.id, cleanup); + cleanupByElement.set(element, cleanup); + + try { + const payload = readPayload(outputItem); + switch (payload.kind) { + case "multiple-choice": + renderMultipleChoice(payload, element, { + postAction, + addDisposable: (dispose) => disposables.push(dispose), + }); + break; + } + } catch (error) { + cleanup(); + renderError(element, error); + } + }, + disposeOutputItem(id?: string) { + // VS Code calls this with no id for "Clear All Outputs", so treating + // the parameter as required would leak every listener in the document. + if (id === undefined) { + for (const cleanup of [...cleanupByOutputId.values()]) { + cleanup(); + } + return; + } + cleanupOutput(id); + }, + }; +}; + +function readPayload(outputItem: OutputItem): LearningPayload { + if (outputItem.mime !== MIME_TYPE) { + throw new Error(`Unsupported MIME type: ${outputItem.mime}`); + } + + const value: unknown = outputItem.json(); + if (!isRecord(value)) { + throw new Error("Expected a QDK learning payload object."); + } + + const payload = value; + + // Say which side is ahead. A notebook can outlive the extension that wrote + // it, and "update the QDK extension" is a far more useful thing to read in a + // cell than a generic parse failure. + if (payload.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { + throw new Error( + `This output uses QDK learning payload version ${String(payload.schemaVersion)}, ` + + `but this renderer supports version ${SUPPORTED_SCHEMA_VERSION}. ` + + "Update the QDK extension to view it.", + ); + } + + if (!isSupportedKind(payload.kind)) { + throw new Error( + `Unknown QDK learning output kind "${String(payload.kind)}". ` + + `This renderer knows: ${SUPPORTED_KINDS.join(", ")}.`, + ); + } + + if (payload.cellId !== undefined && typeof payload.cellId !== "string") { + throw new Error("QDK learning payload has a non-string cellId."); + } + + return payload as unknown as LearningPayload; +} + +/** The only payload version this renderer understands. */ +const SUPPORTED_SCHEMA_VERSION = 1; + +const SUPPORTED_KINDS = ["multiple-choice"] as const; + +function isSupportedKind( + kind: unknown, +): kind is (typeof SUPPORTED_KINDS)[number] { + return ( + typeof kind === "string" && + SUPPORTED_KINDS.includes(kind as (typeof SUPPORTED_KINDS)[number]) + ); +} + +function cleanupOutput(id: string) { + cleanupByOutputId.get(id)?.(); +} + +function cleanupElement(element: HTMLElement) { + cleanupByElement.get(element)?.(); +} + +function renderError(element: HTMLElement, error: unknown) { + const root = document.createElement("section"); + root.className = "qdk-learning qdk-learning-error"; + root.dataset.rendererId = RENDERER_ID; + + const title = document.createElement("strong"); + title.textContent = "Unable to render QDK learning output."; + const details = document.createElement("pre"); + details.textContent = error instanceof Error ? error.message : String(error); + + root.append(title, details); + element.replaceChildren(root); +} diff --git a/source/vscode/src/notebookRenderer/multipleChoice.ts b/source/vscode/src/notebookRenderer/multipleChoice.ts new file mode 100644 index 00000000000..b31444ccb16 --- /dev/null +++ b/source/vscode/src/notebookRenderer/multipleChoice.ts @@ -0,0 +1,354 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { MultipleChoicePayload } from "./schema.js"; +import { RENDERER_ID } from "./schema.js"; +import { + appendRichTextElement, + appendTextElement, + createActionButton, + setLiveRegion, + type RenderContext, +} from "./rendering.js"; + +let groupId = 0; + +export function renderMultipleChoice( + payload: MultipleChoicePayload, + element: HTMLElement, + context: RenderContext, +): void { + const root = document.createElement("section"); + root.className = "qdk-learning qdk-learning-card"; + + // The band the chemistry course already uses to mark a self-check question, + // so an interactive question reads as the same thing the collapsible ones + // were. Decorative: the legend below carries the question for a reader. + const header = document.createElement("div"); + header.className = "qdk-learning-quiz-header"; + const mark = appendTextElement( + header, + "span", + "qdk-learning-quiz-mark", + "\u2753", + ); + mark.setAttribute("aria-hidden", "true"); + appendTextElement( + header, + "span", + "qdk-learning-quiz-label", + "Check your understanding", + ); + root.appendChild(header); + + // Fieldset/legend keeps the answer controls grouped for screen readers. + const fieldset = document.createElement("fieldset"); + fieldset.className = "qdk-learning-options"; + const legend = document.createElement("legend"); + legend.className = "qdk-learning-prompt"; + appendRichTextElement(legend, "span", "", payload.prompt); + + // Said out loud, and inside the legend so a screen reader hears it with the + // question rather than after it. A learner who assumes one answer would stop + // at the first correct option and be marked wrong for a question they + // actually understood. + if (payload.multiSelect) { + appendTextElement( + legend, + "span", + "qdk-learning-prompt-hint", + "Select all that apply.", + ); + } + fieldset.appendChild(legend); + + const feedback = appendTextElement(root, "p", "qdk-learning-feedback", ""); + feedback.hidden = true; + setLiveRegion(feedback); + + const controls = document.createElement("div"); + controls.className = "qdk-learning-controls"; + + const actionList = document.createElement("div"); + actionList.className = "qdk-learning-action-list"; + actionList.hidden = true; + + // Include the cell id when available, plus a counter to avoid cross-output + // radio grouping even if a notebook renders duplicate cell ids. + const groupName = `qdk-learning-${payload.cellId ?? "output"}-${groupId++}`; + const optionViews: OptionView[] = []; + for (const [index, option] of payload.options.entries()) { + const letter = optionLetter(index); + const label = document.createElement("label"); + label.className = "qdk-learning-option"; + + const input = document.createElement("input"); + input.type = payload.multiSelect ? "checkbox" : "radio"; + input.name = groupName; + input.value = option.id; + + // Selecting never grades. Arrow keys move between radios and fire + // `change`, so grading here would submit whichever option a keyboard + // user landed on first and disable the rest of the group. + const onChange = () => { + syncSelectedStates(); + updateCheckButton(); + }; + input.addEventListener("change", onChange); + context.addDisposable(() => input.removeEventListener("change", onChange)); + + const badge = document.createElement("span"); + badge.className = "qdk-learning-option-badge"; + badge.setAttribute("aria-hidden", "true"); + badge.textContent = letter; + + const verdict = document.createElement("span"); + verdict.className = "qdk-learning-verdict qdk-learning-sr-only"; + + const body = document.createElement("span"); + body.className = "qdk-learning-option-body"; + + appendRichTextElement( + body, + "span", + "qdk-learning-option-text", + option.text, + ); + + label.append(input, badge, body, verdict); + fieldset.appendChild(label); + optionViews.push({ option, input, label, badge, verdict, body, letter }); + } + + const checkButton = document.createElement("button"); + checkButton.type = "button"; + checkButton.textContent = "Check answer"; + checkButton.disabled = true; + const onCheck = () => evaluateAnswer(); + checkButton.addEventListener("click", onCheck); + context.addDisposable(() => + checkButton.removeEventListener("click", onCheck), + ); + + const tryAgainButton = document.createElement("button"); + tryAgainButton.type = "button"; + tryAgainButton.textContent = "Try again"; + const onTryAgain = () => resetAnswer(); + tryAgainButton.addEventListener("click", onTryAgain); + context.addDisposable(() => + tryAgainButton.removeEventListener("click", onTryAgain), + ); + + // Built once, not per grading: a learner can cycle Check/Try again any number + // of times, and creating a fresh button each time would retain a detached + // node and its listener for the life of the output. + let lastSelectedIds = new Set(); + const whyWrongButton = createActionButton("Why is that wrong?"); + const onWhyWrong = () => { + const posted = context.postAction({ + type: "qdk-learning/action", + rendererId: RENDERER_ID, + actionId: "why-wrong", + cellId: payload.cellId, + context: { + question: payload.prompt, + choice: optionViews + .filter((view) => lastSelectedIds.has(view.option.id)) + .map((view) => view.option.text) + .join("; "), + }, + }); + + if (!posted) { + // Exported HTML has no extension channel, so replace the inert action. + appendActionUnavailableNote(actionList); + } + }; + whyWrongButton.addEventListener("click", onWhyWrong); + context.addDisposable(() => + whyWrongButton.removeEventListener("click", onWhyWrong), + ); + + controls.appendChild(checkButton); + + root.append(fieldset, feedback, controls, actionList); + element.appendChild(root); + + function evaluateAnswer(): void { + const selectedIds = new Set( + optionViews + .filter((view) => view.input.checked) + .map((view) => view.option.id), + ); + const isCorrect = optionViews.every( + (view) => selectedIds.has(view.option.id) === view.option.correct, + ); + + for (const view of optionViews) { + const selected = selectedIds.has(view.option.id); + view.label.dataset.selected = selected ? "true" : "false"; + if (selected && view.option.correct) { + setOptionState(view, "correct"); + } else if (selected) { + setOptionState(view, "incorrect"); + } else if (view.option.correct) { + setOptionState(view, "missed"); + } + + if (view.option.explanation !== undefined) { + appendRichTextElement( + view.body, + "span", + "qdk-learning-option-why", + view.option.explanation, + ); + } + view.input.disabled = true; + } + + feedback.hidden = false; + feedback.dataset.state = isCorrect ? "correct" : "incorrect"; + feedback.textContent = isCorrect + ? "Correct!" + : incorrectMessage(selectedIds); + + controls.replaceChildren(tryAgainButton); + actionList.replaceChildren(); + actionList.hidden = true; + + if (!isCorrect) { + showWhyWrongAction(selectedIds); + } + + // Grading replaced the focused Check button, which would drop focus to the + // document. Move it to the control that took its place. + tryAgainButton.focus(); + } + + function resetAnswer(): void { + for (const view of optionViews) { + view.input.checked = false; + view.input.disabled = false; + delete view.label.dataset.state; + delete view.label.dataset.selected; + view.badge.textContent = view.letter; + view.verdict.textContent = ""; + for (const why of Array.from( + view.body.querySelectorAll(".qdk-learning-option-why"), + )) { + why.remove(); + } + } + feedback.hidden = true; + feedback.textContent = ""; + delete feedback.dataset.state; + actionList.replaceChildren(); + actionList.hidden = true; + checkButton.disabled = true; + controls.replaceChildren(checkButton); + + // Retrying detaches the Try again button, which is the element the learner + // just activated, so focus would fall to the document. Send it to the first + // answer instead: `Check answer` was disabled a line ago and a disabled + // control cannot take focus, and the first option is where a retry starts + // anyway. `:focus-within` paints the row, so the position stays visible. + optionViews[0]?.input.focus(); + } + + function updateCheckButton(): void { + checkButton.disabled = !optionViews.some((view) => view.input.checked); + } + + /** + * Say *how* the answer was wrong. + * + * On a multi-select, "having the right idea but missing one" and "picking a + * wrong one" are different mistakes and deserve different nudges. On a + * single-select there is only ever one way to be wrong, so the generic line + * is the honest one. + */ + function incorrectMessage(selectedIds: Set): string { + if (!payload.multiSelect) { + return "Not quite. Review the marked choices, then try again."; + } + + const missed = optionViews.filter( + (view) => view.option.correct && !selectedIds.has(view.option.id), + ).length; + const wrong = optionViews.filter( + (view) => !view.option.correct && selectedIds.has(view.option.id), + ).length; + + if (missed > 0 && wrong === 0) { + return missed === 1 + ? "Close — everything you picked is right, but one more applies." + : `Close — everything you picked is right, but ${missed} more apply.`; + } + if (wrong > 0 && missed === 0) { + return wrong === 1 + ? "Not quite — you found them all, but one choice doesn't apply." + : `Not quite — you found them all, but ${wrong} choices don't apply.`; + } + return "Not quite. Review the marked choices, then try again."; + } + + function syncSelectedStates(): void { + for (const view of optionViews) { + view.label.dataset.selected = view.input.checked ? "true" : "false"; + } + } + + function showWhyWrongAction(selectedIds: Set): void { + lastSelectedIds = selectedIds; + actionList.hidden = false; + actionList.replaceChildren(whyWrongButton); + } +} + +type OptionView = { + option: MultipleChoicePayload["options"][number]; + input: HTMLInputElement; + label: HTMLLabelElement; + badge: HTMLSpanElement; + verdict: HTMLSpanElement; + body: HTMLSpanElement; + letter: string; +}; + +type OptionState = "correct" | "incorrect" | "missed"; + +function setOptionState(view: OptionView, state: OptionState): void { + view.label.dataset.state = state; + view.badge.textContent = + state === "correct" + ? "\u2713" + : state === "incorrect" + ? "\u2717" + : "\u2022"; + view.verdict.textContent = + state === "correct" + ? "Correct choice" + : state === "incorrect" + ? "Incorrect choice" + : "Missed correct choice"; +} + +/** + * A, B, C… for the option badges. + * + * Wraps back to A past 26 rather than carrying into AA: a question with that + * many options is an authoring problem, not a labelling one. + */ +function optionLetter(index: number): string { + return String.fromCharCode(65 + (index % 26)); +} + +function appendActionUnavailableNote(parent: HTMLElement): void { + parent.replaceChildren(); + appendTextElement( + parent, + "span", + "qdk-learning-action-note", + "This Copilot action is only available in VS Code.", + ); +} diff --git a/source/vscode/src/notebookRenderer/rendererApi.d.ts b/source/vscode/src/notebookRenderer/rendererApi.d.ts new file mode 100644 index 00000000000..4bf48844272 --- /dev/null +++ b/source/vscode/src/notebookRenderer/rendererApi.d.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +declare module "vscode-notebook-renderer" { + export interface RendererContext { + readonly workspaceState: TState; + postMessage?(message: unknown): void | PromiseLike; + } + + export interface OutputItem { + readonly id: string; + readonly mime: string; + readonly data: Uint8Array; + readonly metadata?: Record; + json(): unknown; + text(): string; + blob(): Blob; + } + + export type ActivationFunction = ( + context: RendererContext, + ) => { + renderOutputItem( + outputItem: OutputItem, + element: HTMLElement, + ): void | Promise; + /** Called with no id when the host clears every output in the document. */ + disposeOutputItem(id?: string): void; + }; +} diff --git a/source/vscode/src/notebookRenderer/rendering.ts b/source/vscode/src/notebookRenderer/rendering.ts new file mode 100644 index 00000000000..483df967b43 --- /dev/null +++ b/source/vscode/src/notebookRenderer/rendering.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { RendererToExtensionMessage } from "./schema.js"; + +export type PostAction = (message: RendererToExtensionMessage) => boolean; + +export type RenderContext = { + postAction: PostAction; + addDisposable(dispose: () => void): void; +}; + +export function appendTextElement( + parent: HTMLElement, + tagName: keyof HTMLElementTagNameMap, + className: string, + text: string, +): HTMLElement { + const element = document.createElement(tagName); + element.className = className; + element.textContent = text; + parent.appendChild(element); + return element; +} + +/** + * Like {@link appendTextElement}, but renders `backtick` spans as inline code. + * + * Course prose is authored in Markdown, so prompts and answer options routinely + * name APIs as `circuit(...)`. Rendering those as literal backticks looks like + * a bug. This deliberately is NOT a Markdown parser: it only splits on + * backticks and builds `` nodes with `textContent`, so author text is + * never interpreted as HTML. + */ +export function appendRichTextElement( + parent: HTMLElement, + tagName: keyof HTMLElementTagNameMap, + className: string, + text: string, +): HTMLElement { + const element = document.createElement(tagName); + element.className = className; + appendInlineText(element, text); + parent.appendChild(element); + return element; +} + +function appendInlineText(parent: HTMLElement, text: string): void { + const parts = text.split("`"); + + // An even number of parts means an unbalanced backtick. Treat the whole + // string as plain text rather than guessing where the code span ends. + if (parts.length % 2 === 0) { + parent.appendChild(document.createTextNode(text)); + return; + } + + parts.forEach((part, index) => { + if (part.length === 0) { + return; + } + if (index % 2 === 1) { + const code = document.createElement("code"); + code.textContent = part; + parent.appendChild(code); + } else { + parent.appendChild(document.createTextNode(part)); + } + }); +} + +/** + * Build a Copilot action button. + * + * The sparkle mark and the `--vscode-button-*` colors are deliberate: this is + * the same affordance as the "Ask for a Hint" item in the cell status bar, so + * it should read as the same control even though it lives in cell output. + */ +export function createActionButton(label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "qdk-learning-action"; + button.appendChild(createSparkleIcon()); + button.appendChild(document.createTextNode(label)); + return button; +} + +function createSparkleIcon(): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", SPARKLE_PATH); + svg.appendChild(path); + return svg; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** A large four-point star with a smaller companion, matching the codicon. */ +const SPARKLE_PATH = + "M9.5 1.5 11 5.2 14.7 6.7 11 8.2 9.5 11.9 8 8.2 4.3 6.7 8 5.2 Z " + + "M4 9.6 4.8 11.5 6.7 12.3 4.8 13.1 4 15 3.2 13.1 1.3 12.3 3.2 11.5 Z"; + +/** + * Announce a change to assistive technology. + * + * Answering a question or switching orbitals updates the view in place, which + * a screen reader would otherwise miss. + */ +export function setLiveRegion(element: HTMLElement) { + element.setAttribute("role", "status"); + element.setAttribute("aria-live", "polite"); +} diff --git a/source/vscode/src/notebookRenderer/schema.ts b/source/vscode/src/notebookRenderer/schema.ts new file mode 100644 index 00000000000..ae6356fa55f --- /dev/null +++ b/source/vscode/src/notebookRenderer/schema.ts @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The contract between a learning notebook's output and the renderer. + * + * Written twice — here, and as the dicts `_learning_output.py` builds — so + * `checkRendererContract()` in `build.mjs` fails the build when they drift. + */ + +export const MIME_TYPE = "application/vnd.qdk.learning+json" as const; +export const RENDERER_ID = "qsharp-vscode.qdkLearningRenderer" as const; + +type LearningPayloadBase = { + schemaVersion: 1; + kind: string; + /** + * Identifies the payload, not the notebook cell holding it: for a quiz this + * is its registered id, which keeps radio groups unique. Deliberately not an + * ipynb cell id, so don't pass it to anything that resolves activities. + */ + cellId?: string; +}; + +export type MultipleChoicePayload = LearningPayloadBase & { + kind: "multiple-choice"; + prompt: string; + options: Array<{ + id: string; + text: string; + correct: boolean; + explanation?: string; + }>; + /** + * More than one option is correct, and the learner must find all of them. + * + * Drives checkboxes rather than radios, and an explicit instruction — a + * learner who assumes one answer would stop at the first correct option and + * be marked wrong for a question they understood. + */ + multiSelect?: boolean; +}; + +export type LearningPayload = MultipleChoicePayload; + +/** + * Copilot actions a notebook output is allowed to request. + * + * Security boundary: output may name an id from this list and attach small + * structured string context. It may never send a free-form prompt string or a + * command identifier across the renderer bridge — the wording lives in the + * extension, so a notebook cannot script the chat panel. + */ +export const COPILOT_ACTION_IDS = ["why-wrong"] as const; + +export type CopilotActionId = (typeof COPILOT_ACTION_IDS)[number]; + +type RendererActionMessage = { + type: "qdk-learning/action"; + rendererId: typeof RENDERER_ID; + actionId: CopilotActionId; + cellId?: string; + context?: Record; +}; + +export type RendererToExtensionMessage = RendererActionMessage; + +/** + * Bounds on renderer-supplied strings. + * + * Only the value and cell-id limits can be reached by a payload today — the + * key set is built here in the renderer. They are enforced anyway because this + * validator runs on the extension host, where the message is untrusted input + * rather than something this code produced. + */ +const MAX_CONTEXT_ENTRIES = 20; +const MAX_CONTEXT_KEY_LENGTH = 64; +const MAX_CONTEXT_VALUE_LENGTH = 4096; +const MAX_CELL_ID_LENGTH = 256; + +export function isRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +export function isRendererToExtensionMessage( + x: unknown, +): x is RendererToExtensionMessage { + if (!isRecord(x) || x.rendererId !== RENDERER_ID) { + return false; + } + + if (x.type !== "qdk-learning/action" || !isCopilotActionId(x.actionId)) { + return false; + } + + if ( + x.cellId !== undefined && + !isNonEmptyShortString(x.cellId, MAX_CELL_ID_LENGTH) + ) { + return false; + } + + return x.context === undefined || isContextRecord(x.context); +} + +function isCopilotActionId(value: unknown): value is CopilotActionId { + return ( + typeof value === "string" && + COPILOT_ACTION_IDS.includes(value as CopilotActionId) + ); +} + +function isContextRecord(value: unknown): value is Record { + if (!isRecord(value)) { + return false; + } + + const entries = Object.entries(value); + return ( + entries.length <= MAX_CONTEXT_ENTRIES && + entries.every( + ([key, entryValue]) => + isShortString(key, MAX_CONTEXT_KEY_LENGTH) && + key.length > 0 && + isShortString(entryValue, MAX_CONTEXT_VALUE_LENGTH), + ) + ); +} + +function isShortString(value: unknown, maxLength: number): value is string { + return typeof value === "string" && value.length <= maxLength; +} + +function isNonEmptyShortString( + value: unknown, + maxLength: number, +): value is string { + return isShortString(value, maxLength) && value.length > 0; +} diff --git a/source/vscode/src/notebookRenderer/styles.css b/source/vscode/src/notebookRenderer/styles.css new file mode 100644 index 00000000000..5787d77f48e --- /dev/null +++ b/source/vscode/src/notebookRenderer/styles.css @@ -0,0 +1,349 @@ +/* Copyright (c) Microsoft Corporation. + Licensed under the MIT License. */ + +/* Colors come from the `--qdk-*` palette in `qdk-theme.css`; don't add + literal colors here. Buttons map to `--vscode-button-*` so they match the + cell status bar. */ + +.qdk-learning { + /* Local semantic aliases, so the rules below never reach for a raw token. */ + --qdk-l-fg: var(--qdk-host-foreground); + --qdk-l-muted: var(--qdk-description-foreground); + --qdk-l-border: var(--qdk-widget-outline); + --qdk-l-surface: var(--qdk-background-accent); + --qdk-l-accent: var(--qdk-atom-fill); + --qdk-l-focus: var(--qdk-focus-border); + --qdk-l-correct: var(--qdk-gate-reset); + --qdk-l-incorrect: var(--qdk-gate-measure); + --qdk-l-quiz: var(--qdk-quiz-accent); + --qdk-l-on-quiz: var(--qdk-quiz-accent-foreground); + --qdk-l-on-accent: var( + --vscode-button-foreground, + var(--qdk-host-background) + ); + --qdk-l-radius: 4px; + + box-sizing: border-box; + width: 100%; + margin: 8px 0; + font-family: var(--qdk-font-family); + font-size: 13px; + line-height: 1.5; + color: var(--qdk-l-fg); +} + +.qdk-learning *, +.qdk-learning *::before, +.qdk-learning *::after { + box-sizing: border-box; +} + +/* Long chemical formulae, failure messages and option text must wrap rather + than push the card wider than its cell. */ +.qdk-learning-prompt, +.qdk-learning-option-text, +.qdk-learning-feedback { + overflow-wrap: anywhere; +} + +/* Inline code spans produced from Markdown backticks in authored prose. */ +.qdk-learning code { + font-family: var(--qdk-font-family-monospace); + font-size: 0.92em; + padding: 0 3px; + border-radius: 3px; + background: var(--vscode-textCodeBlock-background, var(--qdk-l-surface)); + overflow-wrap: anywhere; +} + +/* ── Card ─────────────────────────────────────────────────────────── */ + +.qdk-learning-card { + border: 1px solid var(--qdk-l-border); + border-radius: var(--qdk-l-radius); + padding: 12px 14px; + background: var(--qdk-host-background); +} + +/* ── Quiz header ──────────────────────────────────────────────────── */ + +/* The chemistry course already marks its self-check questions with a filled + band in a burnt-orange accent and a question mark, so an interactive + question keeps that identity rather than inventing a second one. The band + bleeds to the card edge, which is why the negative margin matches the + card's own padding. */ +.qdk-learning-quiz-header { + display: flex; + align-items: center; + gap: 8px; + margin: -12px -14px 12px; + padding: 7px 14px; + border-radius: calc(var(--qdk-l-radius) - 1px) calc(var(--qdk-l-radius) - 1px) + 0 0; + background: var(--qdk-l-quiz); + color: var(--qdk-l-on-quiz); + font-weight: 600; + line-height: 1.4; +} + +.qdk-learning-quiz-mark { + flex: none; + font-size: 1.05em; +} + +/* ── Multiple choice ──────────────────────────────────────────────── */ + +.qdk-learning-prompt { + display: block; + width: 100%; + margin: 0 0 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--qdk-l-border); + font-weight: 600; + line-height: 1.4; +} + +/* "Select all that apply" — part of the question, but an instruction rather + than the question itself, so it is set apart without being hidden. */ +.qdk-learning-prompt-hint { + display: block; + margin-top: 6px; + font-weight: 400; + font-size: 12px; + color: var(--qdk-l-muted); +} + +.qdk-learning-options { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + border: 0; +} + +.qdk-learning-option { + position: relative; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--qdk-l-border); + border-radius: var(--qdk-l-radius); + background: var(--qdk-host-background); + cursor: pointer; +} + +.qdk-learning-option:hover, +.qdk-learning-option[data-selected="true"] { + background: var(--qdk-l-surface); +} + +.qdk-learning-option input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.qdk-learning-option-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 1.75em; + min-width: 1.75em; + height: 1.75em; + border: 1px solid var(--qdk-l-border); + border-radius: var(--qdk-l-radius); + background: var(--qdk-l-surface); + color: var(--qdk-l-muted); + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +/* Selection tracks the quiz accent so picking an answer reads as part of the + same question, not as a generic form control. */ +.qdk-learning-option[data-selected="true"] { + border-color: var(--qdk-l-quiz); +} + +.qdk-learning-option[data-selected="true"] .qdk-learning-option-badge { + border-color: var(--qdk-l-quiz); + background: var(--qdk-l-quiz); + color: var(--qdk-l-on-quiz); +} + +.qdk-learning-option:focus-within { + outline: 1px solid var(--qdk-l-focus); + outline-offset: 2px; +} + +/* Answered state. The border and badge glyph carry the verdict so meaning is + not conveyed by color alone; missed answers stay dashed to distinguish them. */ +.qdk-learning-option[data-state="correct"] { + border-color: var(--qdk-l-correct); +} + +.qdk-learning-option[data-state="incorrect"] { + border-color: var(--qdk-l-incorrect); +} + +.qdk-learning-option[data-state="missed"] { + border-color: var(--qdk-l-correct); + border-style: dashed; +} + +.qdk-learning-option[data-state] .qdk-learning-option-badge { + color: var(--qdk-host-background); +} + +.qdk-learning-option[data-state="correct"] .qdk-learning-option-badge, +.qdk-learning-option[data-state="missed"] .qdk-learning-option-badge { + border-color: var(--qdk-l-correct); + background: var(--qdk-l-correct); +} + +.qdk-learning-option[data-state="incorrect"] .qdk-learning-option-badge { + border-color: var(--qdk-l-incorrect); + background: var(--qdk-l-incorrect); +} + +.qdk-learning-sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.qdk-learning-verdict { + flex: none; +} + +.qdk-learning-option-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.qdk-learning-option-why { + font-size: 12px; + color: var(--qdk-l-muted); +} + +.qdk-learning-feedback { + margin-top: 10px; + font-weight: 600; +} + +.qdk-learning-feedback[data-state="correct"] { + color: var(--qdk-l-correct); +} + +.qdk-learning-feedback[data-state="incorrect"] { + color: var(--qdk-l-incorrect); +} + +/* ── Controls and actions ─────────────────────────────────────────── */ + +.qdk-learning-controls, +.qdk-learning-action-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 10px; +} + +.qdk-learning button { + display: inline-flex; + align-items: center; + gap: 5px; + font: inherit; + font-size: 12px; + padding: 3px 9px; + border-radius: var(--qdk-l-radius); + border: 1px solid var(--qdk-l-border); + background: var(--vscode-button-secondaryBackground, var(--qdk-l-surface)); + color: var(--vscode-button-secondaryForeground, var(--qdk-l-fg)); + cursor: pointer; +} + +.qdk-learning button:hover:not(:disabled) { + background: var( + --vscode-button-secondaryHoverBackground, + var(--qdk-l-surface) + ); + border-color: var(--qdk-l-accent); +} + +.qdk-learning button:focus-visible { + outline: 1px solid var(--qdk-l-focus); + outline-offset: 1px; +} + +.qdk-learning button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Copilot actions read as the primary thing to do once an answer is in. + Qualified with `button` so it outranks the base `.qdk-learning button` rule, + which is otherwise more specific and would keep the secondary colors. */ +.qdk-learning button.qdk-learning-action { + background: var(--vscode-button-background, var(--qdk-l-accent)); + color: var(--qdk-l-on-accent); + border-color: var(--vscode-button-background, var(--qdk-l-accent)); +} + +.qdk-learning button.qdk-learning-action:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground, var(--qdk-l-accent)); + border-color: var(--vscode-button-hoverBackground, var(--qdk-l-accent)); +} + +.qdk-learning-action svg { + width: 14px; + height: 14px; + flex: none; + fill: currentColor; +} + +/* Shown instead of the buttons when the renderer has no channel back to the + extension — a trusted-but-messaging-less host, or a plain browser export. */ +.qdk-learning-action-note { + margin-top: 8px; + font-size: 12px; + color: var(--qdk-l-muted); +} + +/* ── Error state ──────────────────────────────────────────────────── */ + +.qdk-learning-error { + border: 1px solid var(--qdk-l-incorrect); + border-radius: var(--qdk-l-radius); + padding: 10px 12px; +} + +.qdk-learning-error pre { + margin: 6px 0 0; + font-family: var(--qdk-font-family-monospace); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--qdk-l-muted); +} + +@media (prefers-reduced-motion: reduce) { + .qdk-learning * { + transition: none !important; + animation: none !important; + } +} diff --git a/source/vscode/src/notebookRenderer/tsconfig.json b/source/vscode/src/notebookRenderer/tsconfig.json new file mode 100644 index 00000000000..55557b9aa07 --- /dev/null +++ b/source/vscode/src/notebookRenderer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "lib": ["DOM", "ES2022"], + // No `rootDir`: this project only type-checks (esbuild does the bundling), + // and the renderer bundles the shared `qsharp/ux` theme stylesheet rather + // than keeping a second copy of the palette. + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/source/vscode/tsconfig.json b/source/vscode/tsconfig.json index 8c29554ec08..1f81c0cf7f4 100644 --- a/source/vscode/tsconfig.json +++ b/source/vscode/tsconfig.json @@ -13,5 +13,10 @@ "strict": true, "skipLibCheck": true }, - "exclude": ["test", "src/webview", "src/learning/webview"] + "exclude": [ + "test", + "src/webview", + "src/learning/webview", + "src/notebookRenderer" + ] } From 5139dddaed6d516f001486d44cee99b1a472ba65 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Tue, 8 Sep 2026 15:55:13 -0700 Subject: [PATCH 2/4] Fix issues found while reviewing --- source/vscode/authoring-courses.md | 12 ++ source/vscode/build.mjs | 20 ++- .../courses/chemistry-qpe/_learning_output.py | 14 ++ .../utils/chemistry-qpe/details_to_quiz.py | 38 +++-- .../src/learning/notebookRendererMessaging.ts | 134 +++++------------- source/vscode/src/learning/service.ts | 29 ++++ source/vscode/src/notebookRenderer/index.ts | 118 ++++++++++++++- .../src/notebookRenderer/multipleChoice.ts | 17 +-- .../vscode/src/notebookRenderer/rendering.ts | 2 +- source/vscode/src/notebookRenderer/schema.ts | 66 +++------ source/vscode/src/notebookRenderer/styles.css | 8 ++ 11 files changed, 275 insertions(+), 183 deletions(-) diff --git a/source/vscode/authoring-courses.md b/source/vscode/authoring-courses.md index f9503769828..ecbe75d5ff6 100644 --- a/source/vscode/authoring-courses.md +++ b/source/vscode/authoring-courses.md @@ -174,8 +174,20 @@ quiz("grid-spacing") The tag keeps the cell out of the progress tree, and lets the cell below it still find the section heading above. One call can name several quizzes (`quiz("a", "b")`) when a section asks two questions in a row - the progress tree names a code cell after the heading above it, so two adjacent quiz cells would appear under the same name. +Quiz ids are lowercase letters, digits and hyphens, up to 64 characters. +Registering one that isn't fails when you run the cell: the id is the only thing the renderer's Copilot action sends to the extension, so a shape it can't accept would leave that button doing less than it should. + Run the cell once and save, so the question ships with the notebook and a learner sees it on opening rather than after running. +For the chemistry course, `utils/chemistry-qpe/details_to_quiz.py` does that baking for a whole chapter, and re-bakes it when a question's wording or options change: + +``` +python details_to_quiz.py 06-iterative-phase-estimation --check # report drift, write nothing +python details_to_quiz.py 06-iterative-phase-estimation # re-bake what changed +``` + +`--check` is what catches a `_unit.py` edit that never reached the notebook, including a question deleted from a cell that still shows it. + The answers are in the saved cell output, because grading happens in the renderer without a kernel. This keeps them out of the cell source the learner reads, which is the same protection the collapsible-answer style gave; it isn't a guarantee against a determined learner opening the `.ipynb`. diff --git a/source/vscode/build.mjs b/source/vscode/build.mjs index 28e88495ac8..fb371f4f504 100644 --- a/source/vscode/build.mjs +++ b/source/vscode/build.mjs @@ -197,12 +197,17 @@ export function checkRendererContract() { } // Every payload the emitter builds must name a kind the renderer handles. + // Both sides go through `required()`: an empty list on either side would + // otherwise make this comparison vacuous, and the banner below would still + // report a kind count read from TypeScript alone. const tsKinds = [...schema.matchAll(/^\s+kind: "([a-z-]+)";/gm)].map( (m) => m[1], ); const pyKinds = [...emitter.matchAll(/"kind": "([a-z-]+)"/g)].map( (m) => m[1], ); + required("a payload kind in schema.ts", tsKinds[0]); + required('a "kind" in _learning_output.py', pyKinds[0]); const unknown = pyKinds.filter((k) => !tsKinds.includes(k)); if (unknown.length > 0) { mismatches.push( @@ -236,11 +241,18 @@ export function checkRendererContract() { // // The emitter writes most fields as dict literal keys (`"prompt": ...`) but // sets optional ones by assignment (`payload["multiSelect"] = True`), so the - // Python probe has to accept both spellings. - const payloadFields = ["prompt", "options", "multiSelect"]; + // Python probe has to accept both spellings. The TypeScript side is searched + // across both files that touch a payload: the validator and the view read + // different fields, and looking at only one would let a field go unchecked + // the moment it moved between them. + const rendererSources = `${renderer}\n${readFileSync( + join(thisDir, "src", "notebookRenderer", "index.ts"), + "utf8", + )}`; + const payloadFields = ["prompt", "options", "multiSelect", "cellId"]; const optionFields = ["id", "text", "correct", "explanation"]; for (const field of payloadFields) { - const inTs = new RegExp(`payload\\.${field}\\b`).test(renderer); + const inTs = new RegExp(`payload\\.${field}\\b`).test(rendererSources); const inPy = new RegExp(`"${field}"\\s*(?::|\\])`).test(emitter); if (inTs !== inPy) { mismatches.push( @@ -249,7 +261,7 @@ export function checkRendererContract() { } } for (const field of optionFields) { - const inTs = new RegExp(`option\\.${field}\\b`).test(renderer); + const inTs = new RegExp(`option\\.${field}\\b`).test(rendererSources); const inPy = new RegExp(`"${field}"`).test(emitter); if (inTs !== inPy) { mismatches.push( diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py index 311375d18e1..3f1ec2d4cc0 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py @@ -20,12 +20,20 @@ from __future__ import annotations import random +import re from dataclasses import dataclass from html import escape from typing import Any, Iterable, Mapping, Sequence MIME_TYPE = "application/vnd.qdk.learning+json" +#: A quiz id has to survive the trip to the extension host, which accepts only +#: this shape from a notebook — nothing longer, and nothing that could read as +#: prose. Enforcing it here means an author finds out when they run the cell, +#: rather than a learner finding the "Why is that wrong?" button quietly doing +#: less than it should. Keep in step with `QUIZ_ID_PATTERN` in `schema.ts`. +_QUIZ_ID_RE = re.compile(r"\A[a-z0-9][a-z0-9-]{0,63}\Z") + _CARD_STYLE = ( "font-family:var(--qdk-font-family, system-ui, sans-serif);" "color:var(--qdk-host-foreground, #222);" @@ -168,6 +176,12 @@ def register_quiz( """ if quiz_id in _quizzes: raise ValueError(f"a quiz is already registered as {quiz_id!r}") + if not _QUIZ_ID_RE.match(quiz_id): + raise ValueError( + f"quiz id {quiz_id!r} must be lowercase letters, digits and hyphens, " + "start with a letter or digit, and be at most 64 characters; the " + "renderer's Copilot action drops anything else" + ) ordered = _shuffled(quiz_id, options) if shuffle else options _quizzes[quiz_id] = multiple_choice( prompt, ordered, multi_select=multi_select, cell_id=quiz_id diff --git a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py index ff7ea76983b..9596645aeb2 100644 --- a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py +++ b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py @@ -323,22 +323,30 @@ def _stale_baked_outputs(notebook: dict[str, Any], emitter: Any) -> list[str]: """ stale: list[str] = [] for cell in notebook["cells"]: - source = "".join(cell["source"]) + ids = _cell_quiz_ids(cell) + if not ids: + continue + outputs = cell.get("outputs", []) - position = 0 - for call in QUIZ_CALL.findall(source): - for quiz_id in QUIZ_ID.findall(call): - expected = _normalize_bundle( - emitter._lookup_quiz(quiz_id)._repr_mimebundle_() - ) - actual = ( - _normalize_bundle(outputs[position].get("data")) - if position < len(outputs) - else None - ) - if actual != expected: - stale.append(quiz_id) - position += 1 + for position, quiz_id in enumerate(ids): + expected = _normalize_bundle( + emitter._lookup_quiz(quiz_id)._repr_mimebundle_() + ) + actual = ( + _normalize_bundle(outputs[position].get("data")) + if position < len(outputs) + else None + ) + if actual != expected: + stale.append(quiz_id) + + # An output past the last quiz the cell still calls is left over from a + # question that was removed. Nothing above compares it, so without this + # the notebook keeps showing a deleted question while --check reports + # the file as up to date. Naming the cell's remaining quizzes is what + # makes `_rebake` re-render it, which drops the extra output. + if len(outputs) > len(ids) and not any(i in stale for i in ids): + stale.extend(ids) return stale diff --git a/source/vscode/src/learning/notebookRendererMessaging.ts b/source/vscode/src/learning/notebookRendererMessaging.ts index 0bb1023031e..a13d18b7f8d 100644 --- a/source/vscode/src/learning/notebookRendererMessaging.ts +++ b/source/vscode/src/learning/notebookRendererMessaging.ts @@ -3,7 +3,6 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; -import { isNotebookCourse } from "./courseLayout.js"; import type { CopilotActionId } from "../notebookRenderer/schema.js"; import { isRendererToExtensionMessage, @@ -16,9 +15,10 @@ import type { LearningService } from "./service.js"; * * A renderer webview can't execute VS Code commands, so `createRendererMessaging` * is the channel out. Everything arriving here is authored notebook content and - * therefore untrusted: the renderer may only name an action id from a fixed - * allowlist, never a prompt or command id, and any free text it contributes is - * sanitized into an extension-owned template. + * therefore untrusted: the renderer may name an action id from a fixed + * allowlist and a quiz id of a fixed shape, and nothing else. No prose from a + * notebook reaches a prompt, because no amount of escaping stops a sentence + * from reading as an instruction. */ export function registerNotebookRendererMessaging( context: vscode.ExtensionContext, @@ -37,9 +37,30 @@ export function registerNotebookRendererMessaging( } try { - await handleAction(service, message.actionId, { - ...message.context, - }); + // Detect only — never `createIfMissing`. This runs on a message a + // notebook asked for, and `notebookSync.ts` already sets the rule: a + // notebook-driven event must not materialize a learning workspace + // behind the learner's back. Authorizing the sender needs a loaded + // course, so an unstarted workspace simply means "not trusted". + if (!service.initialized) { + await service.tryInitialize(); + } + + // Any notebook can carry an output of this MIME type, so a message is + // only as trustworthy as the file it came from. This is the whole + // authorization: a workbook this workspace materialized is a course + // file whichever course the learner last navigated to. + if ( + !service.initialized || + !service.isCourseWorkbookUri(event.editor.notebook.uri) + ) { + log.warn( + "Learning: ignoring a renderer message from a notebook this workspace did not create.", + ); + return; + } + + await openChat(buildQuery(message.actionId, message.quizId)); } catch (e) { log.error(`Learning: renderer message "${message.type}" failed`, e); } @@ -47,31 +68,6 @@ export function registerNotebookRendererMessaging( ); } -async function handleAction( - service: LearningService, - actionId: CopilotActionId, - context: Record, -): Promise { - // The learner may open a course notebook before anything has started the - // learning experience. Initialize first — the same thing the "continue" - // command does — so the button never silently does nothing. - if (!service.initialized) { - await service.tryInitialize({ createIfMissing: true }); - } - - if ( - !service.initialized || - !isNotebookCourse(service.getActiveCourseInfo()) - ) { - log.warn( - "Learning: ignoring a renderer action outside an active notebook course.", - ); - return; - } - - await openChat(buildQuery(actionId, context)); -} - /** * Prompt templates, owned by the extension. * @@ -79,81 +75,29 @@ async function handleAction( * ("/qdk-learning Give me a hint"). The `qdk-learning-*` language model tools * already report the learner's position, progress and code on every * invocation, so a long prompt would be restating what the agent can look up. - * The question and the chosen option are the exception: a quiz is not an - * activity, so nothing the tools can read says which of a unit's questions - * was answered or what was picked. + * + * Nothing the renderer wrote is quoted here. An earlier version spliced in the + * question and the chosen option, which are notebook content and therefore + * attacker-supplied prose in a file that only has to sit at a workbook's path. + * The quiz id is enough for the agent to find the question in the open + * notebook, and its shape leaves no room for an instruction. */ -function buildQuery( - actionId: CopilotActionId, - context: Record, -): string { - const choice = sanitize(context.choice); - const question = sanitize(context.question); - +function buildQuery(actionId: CopilotActionId, quizId?: string): string { switch (actionId) { case "why-wrong": - if (question && choice) { - return `/qdk-learning I answered "${choice}" to: ${question} — why is that wrong?`; - } - return choice - ? `/qdk-learning I picked "${choice}" and it was marked wrong. Why?` + return quizId + ? `/qdk-learning I answered the quiz "${quizId}" in this notebook incorrectly. Why is my answer wrong?` : `/qdk-learning I got this question wrong. Why?`; } } async function openChat(query: string): Promise { // No position move here. A quiz cell is deliberately not an activity, so - // there is nothing for `goToActivityByCellId` to find — the payload's - // `cellId` is the quiz's own id, not an ipynb cell id. The question and the - // chosen option travel in the query instead. + // there is nothing for `goToActivityByCellId` to find — the payload's quiz + // id is the quiz's own id, not an ipynb cell id. await vscode.commands.executeCommand("workbench.action.chat.open", { query, isPartialQuery: false, }); } - -/** Longest run of renderer-supplied text we'll splice into a chat prompt. */ -const MAX_CONTEXT_CHARS = 300; - -/** - * Flatten renderer-supplied text so it can sit inside a quoted prompt template. - * - * Control characters, line separators and newlines are folded to spaces so the - * text can't break out of its sentence and read as a fresh instruction, double - * quotes become single quotes so they can't close the quoted span, and the - * result is truncated. This is defence in depth: the values are authored by the - * course or chosen by the learner, but they still reach a language model. - */ -function sanitize(value: string | undefined): string | undefined { - if (typeof value !== "string") { - return undefined; - } - - let flattened = ""; - for (const char of value) { - const code = char.codePointAt(0) ?? 0; - const isControl = - code < 0x20 || - (code >= 0x7f && code <= 0x9f) || - code === 0x2028 || - code === 0x2029; - - if (isControl) { - flattened += " "; - } else if (char === '"') { - flattened += "'"; - } else { - flattened += char; - } - } - - const collapsed = flattened.replace(/\s+/g, " ").trim(); - if (collapsed.length === 0) { - return undefined; - } - - return collapsed.length > MAX_CONTEXT_CHARS - ? `${collapsed.slice(0, MAX_CONTEXT_CHARS - 1)}\u2026` - : collapsed; -} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index bd8ef535164..8b1d5756088 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -655,6 +655,35 @@ export class LearningService { return { id: course.id, title: course.title, kind: course.kind }; } + /** + * True when `uri` is a workbook this workspace materialized for a learner. + * + * Notebook output runs in a webview, and any notebook can claim a MIME type, + * so a message arriving from one is only as trustworthy as the file it came + * from. Comparing against each unit's workbook URI rather than matching the + * `.workbook.ipynb` suffix means a lookalike opened from elsewhere does not + * pass. + * + * Every known course is searched, not just the active one: which course is + * active is a matter of where the learner navigated last, and a workbook + * open in front of them is theirs either way. Narrowing to the active course + * would drop valid actions whenever the two disagree. + */ + isCourseWorkbookUri(uri: vscode.Uri): boolean { + const target = uri.toString(); + for (const course of this.requireWorkspace().courses.values()) { + if (!isNotebookCourse(course)) { + continue; + } + if ( + course.units.some((unit) => workbookUri(unit).toString() === target) + ) { + return true; + } + } + return false; + } + /** True once the user has explicitly picked a course. */ hasUserSelectedCourse(): boolean { const ws = this.workspace; diff --git a/source/vscode/src/notebookRenderer/index.ts b/source/vscode/src/notebookRenderer/index.ts index b826edd7ed4..792fb2c4de8 100644 --- a/source/vscode/src/notebookRenderer/index.ts +++ b/source/vscode/src/notebookRenderer/index.ts @@ -44,14 +44,22 @@ export const activate: ActivationFunction = ( context: RendererContext, ) => { const postAction = (message: RendererToExtensionMessage) => { - if ( - context.postMessage === undefined || - !isRendererToExtensionMessage(message) - ) { + if (context.postMessage === undefined) { + // No extension host — an exported HTML page, say. return false; } - void context.postMessage(message); + // Built here, but from payload values, so this is where a quiz id that + // would not survive the host's check is dropped. Sending the action + // without it still opens chat; sending it whole would be ignored. + const { actionId } = message; + const safe: RendererToExtensionMessage = isRendererToExtensionMessage( + message, + ) + ? message + : { type: "qdk-learning/action", rendererId: RENDERER_ID, actionId }; + + void context.postMessage(safe); return true; }; @@ -140,9 +148,108 @@ function readPayload(outputItem: OutputItem): LearningPayload { throw new Error("QDK learning payload has a non-string cellId."); } + // Per-kind checks stay behind the kind test: a second payload kind must not + // have to satisfy the multiple-choice shape. + switch (payload.kind) { + case "multiple-choice": + assertMultipleChoice(payload); + break; + } + return payload as unknown as LearningPayload; } +/** + * Check the fields the question is actually drawn and graded from. + * + * Version and kind only describe the envelope. Without this a payload that + * survived a hand edit could set `correct: "false"`, which is a non-empty + * string and therefore truthy, and a wrong option would be marked right — so + * these are checked before anything is rendered rather than trusted. + */ +function assertMultipleChoice(payload: Record): void { + if (typeof payload.prompt !== "string" || payload.prompt.length === 0) { + throw new Error("QDK learning payload has no question text."); + } + + if ( + payload.multiSelect !== undefined && + typeof payload.multiSelect !== "boolean" + ) { + throw new Error("QDK learning payload has a non-boolean multiSelect."); + } + + if (!Array.isArray(payload.options) || payload.options.length === 0) { + throw new Error("QDK learning payload has no options."); + } + + const ids = new Set(); + for (const option of payload.options) { + if (!isRecord(option)) { + throw new Error("QDK learning payload has a malformed option."); + } + if (typeof option.id !== "string" || option.id.length === 0) { + throw new Error("QDK learning payload has an option with no id."); + } + if (ids.has(option.id)) { + throw new Error( + `QDK learning payload reuses the option id "${option.id}".`, + ); + } + ids.add(option.id); + + if (typeof option.text !== "string" || option.text.length === 0) { + throw new Error( + `QDK learning option "${option.id}" has no text to show.`, + ); + } + if (typeof option.correct !== "boolean") { + throw new Error( + `QDK learning option "${option.id}" does not say whether it is correct.`, + ); + } + if ( + option.explanation !== undefined && + typeof option.explanation !== "string" + ) { + throw new Error( + `QDK learning option "${option.id}" has a non-string explanation.`, + ); + } + } + + // Cardinality mirrors `_normalize_options` in `_learning_output.py`: the two + // sides describe the same payload, and a notebook can outlive — or bypass — + // the emitter that wrote it. + const correct = payload.options.filter( + (option) => (option as { correct: boolean }).correct, + ).length; + + if (correct === 0) { + throw new Error("QDK learning payload has no correct option."); + } + + if (payload.multiSelect === true) { + if (correct < 2) { + throw new Error( + "QDK learning payload says select all that apply but marks one option correct.", + ); + } + if (correct === payload.options.length) { + throw new Error( + "QDK learning payload marks every option correct, so it cannot be answered wrongly.", + ); + } + } else if (correct > 1) { + // Grading compares the selected set with the correct set, and a radio + // group holds one selection, so this question could never be answered + // right. Refusing to draw it beats showing an unwinnable one. + throw new Error( + `QDK learning payload marks ${correct} options correct but is not multi-select.`, + ); + } +} + /** The only payload version this renderer understands. */ const SUPPORTED_SCHEMA_VERSION = 1; @@ -168,7 +275,6 @@ function cleanupElement(element: HTMLElement) { function renderError(element: HTMLElement, error: unknown) { const root = document.createElement("section"); root.className = "qdk-learning qdk-learning-error"; - root.dataset.rendererId = RENDERER_ID; const title = document.createElement("strong"); title.textContent = "Unable to render QDK learning output."; diff --git a/source/vscode/src/notebookRenderer/multipleChoice.ts b/source/vscode/src/notebookRenderer/multipleChoice.ts index b31444ccb16..50db1bffca7 100644 --- a/source/vscode/src/notebookRenderer/multipleChoice.ts +++ b/source/vscode/src/notebookRenderer/multipleChoice.ts @@ -142,21 +142,15 @@ export function renderMultipleChoice( // Built once, not per grading: a learner can cycle Check/Try again any number // of times, and creating a fresh button each time would retain a detached // node and its listener for the life of the output. - let lastSelectedIds = new Set(); const whyWrongButton = createActionButton("Why is that wrong?"); const onWhyWrong = () => { + // Only the quiz id crosses. The question and the chosen option are + // notebook content, and prose from a notebook must not reach a prompt. const posted = context.postAction({ type: "qdk-learning/action", rendererId: RENDERER_ID, actionId: "why-wrong", - cellId: payload.cellId, - context: { - question: payload.prompt, - choice: optionViews - .filter((view) => lastSelectedIds.has(view.option.id)) - .map((view) => view.option.text) - .join("; "), - }, + quizId: payload.cellId, }); if (!posted) { @@ -217,7 +211,7 @@ export function renderMultipleChoice( actionList.hidden = true; if (!isCorrect) { - showWhyWrongAction(selectedIds); + showWhyWrongAction(); } // Grading replaced the focused Check button, which would drop focus to the @@ -298,8 +292,7 @@ export function renderMultipleChoice( } } - function showWhyWrongAction(selectedIds: Set): void { - lastSelectedIds = selectedIds; + function showWhyWrongAction(): void { actionList.hidden = false; actionList.replaceChildren(whyWrongButton); } diff --git a/source/vscode/src/notebookRenderer/rendering.ts b/source/vscode/src/notebookRenderer/rendering.ts index 483df967b43..ff7cbac41d5 100644 --- a/source/vscode/src/notebookRenderer/rendering.ts +++ b/source/vscode/src/notebookRenderer/rendering.ts @@ -3,7 +3,7 @@ import type { RendererToExtensionMessage } from "./schema.js"; -export type PostAction = (message: RendererToExtensionMessage) => boolean; +type PostAction = (message: RendererToExtensionMessage) => boolean; export type RenderContext = { postAction: PostAction; diff --git a/source/vscode/src/notebookRenderer/schema.ts b/source/vscode/src/notebookRenderer/schema.ts index ae6356fa55f..997fb572789 100644 --- a/source/vscode/src/notebookRenderer/schema.ts +++ b/source/vscode/src/notebookRenderer/schema.ts @@ -46,9 +46,9 @@ export type LearningPayload = MultipleChoicePayload; /** * Copilot actions a notebook output is allowed to request. * - * Security boundary: output may name an id from this list and attach small - * structured string context. It may never send a free-form prompt string or a - * command identifier across the renderer bridge — the wording lives in the + * Security boundary: output may name an id from this list, and nothing else + * except a quiz id of a fixed shape. It may never send free text, a prompt or + * a command identifier across the renderer bridge — the wording lives in the * extension, so a notebook cannot script the chat panel. */ export const COPILOT_ACTION_IDS = ["why-wrong"] as const; @@ -59,24 +59,22 @@ type RendererActionMessage = { type: "qdk-learning/action"; rendererId: typeof RENDERER_ID; actionId: CopilotActionId; - cellId?: string; - context?: Record; + quizId?: string; }; export type RendererToExtensionMessage = RendererActionMessage; /** - * Bounds on renderer-supplied strings. + * A quiz id is the only thing a renderer may contribute to a chat prompt. * - * Only the value and cell-id limits can be reached by a payload today — the - * key set is built here in the renderer. They are enforced anyway because this - * validator runs on the extension host, where the message is untrusted input - * rather than something this code produced. + * Everything a notebook carries is untrusted: an output of this MIME type can + * be hand-written, and a file at a workbook's path can be shipped by whatever + * produced the workspace. Free prose from such a payload reaching a prompt is + * an injection, and no amount of quote-stripping changes that, because the + * payload is a sentence either way. Constraining the one value that does cross + * to this shape leaves no room for an instruction. */ -const MAX_CONTEXT_ENTRIES = 20; -const MAX_CONTEXT_KEY_LENGTH = 64; -const MAX_CONTEXT_VALUE_LENGTH = 4096; -const MAX_CELL_ID_LENGTH = 256; +const QUIZ_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; export function isRecord(value: unknown): value is Record { return ( @@ -98,14 +96,10 @@ export function isRendererToExtensionMessage( return false; } - if ( - x.cellId !== undefined && - !isNonEmptyShortString(x.cellId, MAX_CELL_ID_LENGTH) - ) { - return false; - } - - return x.context === undefined || isContextRecord(x.context); + return ( + x.quizId === undefined || + (typeof x.quizId === "string" && QUIZ_ID_PATTERN.test(x.quizId)) + ); } function isCopilotActionId(value: unknown): value is CopilotActionId { @@ -114,31 +108,3 @@ function isCopilotActionId(value: unknown): value is CopilotActionId { COPILOT_ACTION_IDS.includes(value as CopilotActionId) ); } - -function isContextRecord(value: unknown): value is Record { - if (!isRecord(value)) { - return false; - } - - const entries = Object.entries(value); - return ( - entries.length <= MAX_CONTEXT_ENTRIES && - entries.every( - ([key, entryValue]) => - isShortString(key, MAX_CONTEXT_KEY_LENGTH) && - key.length > 0 && - isShortString(entryValue, MAX_CONTEXT_VALUE_LENGTH), - ) - ); -} - -function isShortString(value: unknown, maxLength: number): value is string { - return typeof value === "string" && value.length <= maxLength; -} - -function isNonEmptyShortString( - value: unknown, - maxLength: number, -): value is string { - return isShortString(value, maxLength) && value.length > 0; -} diff --git a/source/vscode/src/notebookRenderer/styles.css b/source/vscode/src/notebookRenderer/styles.css index 5787d77f48e..173185757b1 100644 --- a/source/vscode/src/notebookRenderer/styles.css +++ b/source/vscode/src/notebookRenderer/styles.css @@ -254,6 +254,14 @@ /* ── Controls and actions ─────────────────────────────────────────── */ +/* `display` from a rule below outranks the user-agent rule that `hidden` + relies on, so the attribute has to be honoured explicitly. Without this, + setting `.hidden = true` on a flex container does nothing and the control + stays on screen. */ +.qdk-learning [hidden] { + display: none; +} + .qdk-learning-controls, .qdk-learning-action-list { display: flex; From f30128f30b7ae521fbed1a522e30732d1da72da3 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Tue, 15 Sep 2026 11:34:32 -0700 Subject: [PATCH 3/4] Send the selected option ids with quiz action --- source/vscode/authoring-courses.md | 4 +-- .../courses/chemistry-qpe/_learning_output.py | 22 ++++++++---- .../src/learning/notebookRendererMessaging.ts | 34 ++++++++++++++----- .../src/notebookRenderer/multipleChoice.ts | 15 +++++--- source/vscode/src/notebookRenderer/schema.ts | 27 +++++++++++---- 5 files changed, 75 insertions(+), 27 deletions(-) diff --git a/source/vscode/authoring-courses.md b/source/vscode/authoring-courses.md index ecbe75d5ff6..6f3a2865565 100644 --- a/source/vscode/authoring-courses.md +++ b/source/vscode/authoring-courses.md @@ -174,8 +174,8 @@ quiz("grid-spacing") The tag keeps the cell out of the progress tree, and lets the cell below it still find the section heading above. One call can name several quizzes (`quiz("a", "b")`) when a section asks two questions in a row - the progress tree names a code cell after the heading above it, so two adjacent quiz cells would appear under the same name. -Quiz ids are lowercase letters, digits and hyphens, up to 64 characters. -Registering one that isn't fails when you run the cell: the id is the only thing the renderer's Copilot action sends to the extension, so a shape it can't accept would leave that button doing less than it should. +Quiz ids and option ids are lowercase letters, digits and hyphens, up to 64 characters. +Registering one that isn't fails when you run the cell: those ids are the only thing the renderer's Copilot action sends to the extension, so a shape it can't accept would leave that button doing less than it should. Run the cell once and save, so the question ships with the notebook and a learner sees it on opening rather than after running. diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py index 3f1ec2d4cc0..d6dfe273e4c 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py @@ -27,12 +27,13 @@ MIME_TYPE = "application/vnd.qdk.learning+json" -#: A quiz id has to survive the trip to the extension host, which accepts only -#: this shape from a notebook — nothing longer, and nothing that could read as -#: prose. Enforcing it here means an author finds out when they run the cell, -#: rather than a learner finding the "Why is that wrong?" button quietly doing -#: less than it should. Keep in step with `QUIZ_ID_PATTERN` in `schema.ts`. -_QUIZ_ID_RE = re.compile(r"\A[a-z0-9][a-z0-9-]{0,63}\Z") +#: A quiz or option id has to survive the trip to the extension host, which +#: accepts only this shape from a notebook — nothing longer, and nothing that +#: could read as prose. Enforcing it here means an author finds out when they +#: run the cell, rather than a learner finding the "Why is that wrong?" button +#: quietly doing less than it should. Keep in step with `ID_PATTERN` in +#: `schema.ts`. +_ID_RE = re.compile(r"\A[a-z0-9][a-z0-9-]{0,63}\Z") _CARD_STYLE = ( "font-family:var(--qdk-font-family, system-ui, sans-serif);" @@ -176,7 +177,7 @@ def register_quiz( """ if quiz_id in _quizzes: raise ValueError(f"a quiz is already registered as {quiz_id!r}") - if not _QUIZ_ID_RE.match(quiz_id): + if not _ID_RE.match(quiz_id): raise ValueError( f"quiz id {quiz_id!r} must be lowercase letters, digits and hyphens, " "start with a letter or digit, and be at most 64 characters; the " @@ -276,6 +277,13 @@ def _normalize_options( raise ValueError("multiple_choice option 'correct' values must be bool") if not option_id: raise ValueError("multiple_choice option ids must not be empty") + if not _ID_RE.match(option_id): + raise ValueError( + f"multiple_choice option id {option_id!r} must be lowercase " + "letters, digits and hyphens, start with a letter or digit, and " + "be at most 64 characters; the renderer's Copilot action drops " + "anything else" + ) if option_id in seen_ids: raise ValueError(f"duplicate multiple_choice option id: {option_id!r}") seen_ids.add(option_id) diff --git a/source/vscode/src/learning/notebookRendererMessaging.ts b/source/vscode/src/learning/notebookRendererMessaging.ts index a13d18b7f8d..623762b219c 100644 --- a/source/vscode/src/learning/notebookRendererMessaging.ts +++ b/source/vscode/src/learning/notebookRendererMessaging.ts @@ -60,7 +60,9 @@ export function registerNotebookRendererMessaging( return; } - await openChat(buildQuery(message.actionId, message.quizId)); + await openChat( + buildQuery(message.actionId, message.quizId, message.optionIds), + ); } catch (e) { log.error(`Learning: renderer message "${message.type}" failed`, e); } @@ -79,15 +81,31 @@ export function registerNotebookRendererMessaging( * Nothing the renderer wrote is quoted here. An earlier version spliced in the * question and the chosen option, which are notebook content and therefore * attacker-supplied prose in a file that only has to sit at a workbook's path. - * The quiz id is enough for the agent to find the question in the open - * notebook, and its shape leaves no room for an instruction. + * Ids name the same things precisely enough for the agent to find them in the + * open notebook, and their shape leaves no room for an instruction. Saying + * where to look is the point: no learning tool reads a quiz, because a quiz is + * deliberately not an activity. */ -function buildQuery(actionId: CopilotActionId, quizId?: string): string { +function buildQuery( + actionId: CopilotActionId, + quizId?: string, + optionIds?: string[], +): string { switch (actionId) { - case "why-wrong": - return quizId - ? `/qdk-learning I answered the quiz "${quizId}" in this notebook incorrectly. Why is my answer wrong?` - : `/qdk-learning I got this question wrong. Why?`; + case "why-wrong": { + if (!quizId) { + return `/qdk-learning I got this question wrong. Why?`; + } + + const picked = optionIds?.length + ? ` I picked ${optionIds.map((id) => `"${id}"`).join(" and ")}.` + : ""; + return ( + `/qdk-learning I answered the quiz "${quizId}" in this notebook incorrectly.${picked}` + + ` Look up that quiz's question and options in the notebook's cell output,` + + ` then explain why my answer is wrong.` + ); + } } } diff --git a/source/vscode/src/notebookRenderer/multipleChoice.ts b/source/vscode/src/notebookRenderer/multipleChoice.ts index 50db1bffca7..fd182bb1141 100644 --- a/source/vscode/src/notebookRenderer/multipleChoice.ts +++ b/source/vscode/src/notebookRenderer/multipleChoice.ts @@ -142,15 +142,21 @@ export function renderMultipleChoice( // Built once, not per grading: a learner can cycle Check/Try again any number // of times, and creating a fresh button each time would retain a detached // node and its listener for the life of the output. + // + // Held across gradings so the action can name what was picked. The button + // is only shown after a wrong answer, so this is always the graded set. + let gradedSelection: string[] = []; const whyWrongButton = createActionButton("Why is that wrong?"); const onWhyWrong = () => { - // Only the quiz id crosses. The question and the chosen option are - // notebook content, and prose from a notebook must not reach a prompt. + // Ids only. The question and the option text are notebook content, and + // prose from a notebook must not reach a prompt — but an id names the + // same thing precisely enough for the agent to look it up. const posted = context.postAction({ type: "qdk-learning/action", rendererId: RENDERER_ID, actionId: "why-wrong", quizId: payload.cellId, + optionIds: gradedSelection, }); if (!posted) { @@ -211,7 +217,7 @@ export function renderMultipleChoice( actionList.hidden = true; if (!isCorrect) { - showWhyWrongAction(); + showWhyWrongAction(selectedIds); } // Grading replaced the focused Check button, which would drop focus to the @@ -292,7 +298,8 @@ export function renderMultipleChoice( } } - function showWhyWrongAction(): void { + function showWhyWrongAction(selectedIds: Set): void { + gradedSelection = [...selectedIds]; actionList.hidden = false; actionList.replaceChildren(whyWrongButton); } diff --git a/source/vscode/src/notebookRenderer/schema.ts b/source/vscode/src/notebookRenderer/schema.ts index 997fb572789..ce528d291af 100644 --- a/source/vscode/src/notebookRenderer/schema.ts +++ b/source/vscode/src/notebookRenderer/schema.ts @@ -60,21 +60,27 @@ type RendererActionMessage = { rendererId: typeof RENDERER_ID; actionId: CopilotActionId; quizId?: string; + /** Ids of the options the learner had selected when they asked. */ + optionIds?: string[]; }; export type RendererToExtensionMessage = RendererActionMessage; /** - * A quiz id is the only thing a renderer may contribute to a chat prompt. + * Ids are the only thing a renderer may contribute to a chat prompt. * * Everything a notebook carries is untrusted: an output of this MIME type can * be hand-written, and a file at a workbook's path can be shipped by whatever * produced the workspace. Free prose from such a payload reaching a prompt is * an injection, and no amount of quote-stripping changes that, because the - * payload is a sentence either way. Constraining the one value that does cross - * to this shape leaves no room for an instruction. + * payload is a sentence either way. Constraining every value that crosses to + * this shape leaves no room for an instruction, while still naming the + * question and the answer precisely enough for an agent to look them up. */ -const QUIZ_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; + +/** Nothing sensible asks about more selections than a question has options. */ +const MAX_OPTION_IDS = 32; export function isRecord(value: unknown): value is Record { return ( @@ -96,9 +102,18 @@ export function isRendererToExtensionMessage( return false; } + if ( + x.quizId !== undefined && + !(typeof x.quizId === "string" && ID_PATTERN.test(x.quizId)) + ) { + return false; + } + return ( - x.quizId === undefined || - (typeof x.quizId === "string" && QUIZ_ID_PATTERN.test(x.quizId)) + x.optionIds === undefined || + (Array.isArray(x.optionIds) && + x.optionIds.length <= MAX_OPTION_IDS && + x.optionIds.every((id) => typeof id === "string" && ID_PATTERN.test(id))) ); } From 31abb9a8e14312a097fb094059370c5b2aadc549 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Wed, 16 Sep 2026 12:37:50 -0700 Subject: [PATCH 4/4] Address review feedback on the QDK Learning renderer - Reject empty prompts, empty option text and malformed ids when a quiz is authored, so mistakes fail for the author instead of the learner - Extend the build-time contract check to the manifest, the runtime kind list, the quiz cell tag, the id grammar and the option cap - Clear baked questions left behind when a quiz call is removed, and refuse rather than delete when a call cannot be parsed - Rename the payload's cellId to payloadId - it is a quiz id, and cellId means an ipynb cell id everywhere else in src/learning - Drop the duplicate workbook check in favour of the existing isCourseWorkbook --- source/vscode/authoring-courses.md | 3 + source/vscode/build.mjs | 193 ++++++++++++++++-- .../iterative_phase_estimation.ipynb | 24 +-- .../courses/chemistry-qpe/_learning_output.py | 50 ++++- .../utils/chemistry-qpe/details_to_quiz.py | 146 +++++++++++-- .../src/learning/notebookRendererMessaging.ts | 32 +-- source/vscode/src/learning/service.ts | 29 --- source/vscode/src/notebookRenderer/index.ts | 25 ++- .../src/notebookRenderer/multipleChoice.ts | 8 +- .../src/notebookRenderer/rendererApi.d.ts | 21 +- .../vscode/src/notebookRenderer/rendering.ts | 4 +- source/vscode/src/notebookRenderer/schema.ts | 31 +-- 12 files changed, 425 insertions(+), 141 deletions(-) diff --git a/source/vscode/authoring-courses.md b/source/vscode/authoring-courses.md index 84734c6e712..d9c5cb5255e 100644 --- a/source/vscode/authoring-courses.md +++ b/source/vscode/authoring-courses.md @@ -177,6 +177,9 @@ One call can name several quizzes (`quiz("a", "b")`) when a section asks two que Quiz ids and option ids are lowercase letters, digits and hyphens, up to 64 characters. Registering one that isn't fails when you run the cell: those ids are the only thing the renderer's Copilot action sends to the extension, so a shape it can't accept would leave that button doing less than it should. +A question needs text, and so does every option. +The renderer refuses to draw a payload missing either, so registering one fails when you run the cell rather than baking cleanly and showing a learner an error. + Run the cell once and save, so the question ships with the notebook and a learner sees it on opening rather than after running. For the chemistry course, `utils/chemistry-qpe/details_to_quiz.py` does that baking for a whole chapter, and re-bakes it when a question's wording or options change: diff --git a/source/vscode/build.mjs b/source/vscode/build.mjs index fb371f4f504..54bc66af33b 100644 --- a/source/vscode/build.mjs +++ b/source/vscode/build.mjs @@ -4,7 +4,7 @@ //@ts-check import { copyFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build as esbuildBuild, context } from "esbuild"; @@ -147,13 +147,15 @@ const inlineStateComputeWorkerPlugin = { // ── Renderer/emitter contract check ───────────────────────────────── /** - * Fail the build if the renderer's schema and the Python emitter have drifted. + * Fail the build if the values this feature writes in one language and reads in + * another have drifted. * * The payload contract is written twice — TypeScript types the renderer * validates against, and the dicts `_learning_output.py` builds — and nothing * in either type system spans that gap. Checks the values whose disagreement - * breaks a learner: MIME type, payload kinds, schema version, and the field - * names the renderer reads. + * breaks a learner: MIME type, payload kinds, schema version, the field names + * the renderer reads, the manifest contribution VS Code routes on, and the cell + * tag that keeps a quiz out of the progress tree. */ export function checkRendererContract() { const schemaPath = join(thisDir, "src", "notebookRenderer", "schema.ts"); @@ -196,18 +198,88 @@ export function checkRendererContract() { mismatches.push(`MIME type differs: "${tsMime}" vs "${pyMime}".`); } + // The third copy, and the one that decides whether any of this runs: VS Code + // picks a renderer by matching an output's MIME type against this + // contribution, and routes `createRendererMessaging` by its id. If either + // drifts the two copies above still agree, so the check stays green while + // the renderer is never selected, or its "Why is that wrong?" button posts + // into a channel nothing is listening on. + const rendererId = required( + "RENDERER_ID in schema.ts", + /^export const RENDERER_ID = "([^"]+)"/m.exec(schema)?.[1], + ); + const manifestPath = join(thisDir, "package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const contributions = required( + "the notebookRenderer contributions in package.json", + manifest.contributes?.notebookRenderer, + ); + const contribution = contributions.find((r) => r.id === rendererId); + if (contribution === undefined) { + mismatches.push( + `package.json contributes no notebook renderer with id "${rendererId}", ` + + `only: ${contributions.map((r) => r.id).join(", ") || "none"}.`, + ); + } else { + if (!contribution.mimeTypes?.includes(tsMime)) { + mismatches.push( + `package.json contributes [${(contribution.mimeTypes ?? []).join(", ")}] ` + + `but the renderer reads "${tsMime}".`, + ); + } + + // Same failure mode from the other side: a renderer VS Code cannot load is + // one the learner never sees. The entry point is written here and built by + // `esbuild` into `outfile` below, and nothing else compares them. + const rendererOut = relative( + thisDir, + required( + "the renderer outfile in build.mjs", + platformBuildOptions.renderer?.outfile, + ), + ).replaceAll("\\", "/"); + if (contribution.entrypoint !== `./${rendererOut}`) { + mismatches.push( + `package.json points at "${contribution.entrypoint}" but the build ` + + `writes "./${rendererOut}".`, + ); + } + } + // Every payload the emitter builds must name a kind the renderer handles. // Both sides go through `required()`: an empty list on either side would // otherwise make this comparison vacuous, and the banner below would still // report a kind count read from TypeScript alone. - const tsKinds = [...schema.matchAll(/^\s+kind: "([a-z-]+)";/gm)].map( + // + // `SUPPORTED_KINDS` is the list `readPayload` actually gates on, so that is + // what Python is compared against. The union in `schema.ts` only types the + // payload: a kind declared there but missing from the runtime list compiles + // cleanly and then refuses to render. + const rendererIndex = readFileSync( + join(thisDir, "src", "notebookRenderer", "index.ts"), + "utf8", + ); + const tsKinds = [ + ...required( + "SUPPORTED_KINDS in index.ts", + /const SUPPORTED_KINDS = \[([^\]]*)\]/.exec(rendererIndex)?.[1], + ).matchAll(/"([a-z-]+)"/g), + ].map((m) => m[1]); + const schemaKinds = [...schema.matchAll(/^\s+kind: "([a-z-]+)";/gm)].map( (m) => m[1], ); const pyKinds = [...emitter.matchAll(/"kind": "([a-z-]+)"/g)].map( (m) => m[1], ); - required("a payload kind in schema.ts", tsKinds[0]); + required("a payload kind in SUPPORTED_KINDS", tsKinds[0]); + required("a payload kind in schema.ts", schemaKinds[0]); required('a "kind" in _learning_output.py', pyKinds[0]); + const undeclared = schemaKinds.filter((k) => !tsKinds.includes(k)); + if (undeclared.length > 0) { + mismatches.push( + `schema.ts declares kinds the renderer never accepts: ${[...new Set(undeclared)].join(", ")}.`, + ); + } const unknown = pyKinds.filter((k) => !tsKinds.includes(k)); if (unknown.length > 0) { mismatches.push( @@ -217,13 +289,19 @@ export function checkRendererContract() { const tsVersion = required( "SUPPORTED_SCHEMA_VERSION", - /const SUPPORTED_SCHEMA_VERSION = (\d+)/.exec( - readFileSync( - join(thisDir, "src", "notebookRenderer", "index.ts"), - "utf8", - ), - )?.[1], + /const SUPPORTED_SCHEMA_VERSION = (\d+)/.exec(rendererIndex)?.[1], + ); + // The third version copy: the literal type payloads are cast to. Nothing in + // the compiler ties it to the constant `readPayload` compares against. + const schemaVersion = required( + "schemaVersion in schema.ts", + /^\s+schemaVersion: (\d+);/m.exec(schema)?.[1], ); + if (tsVersion !== schemaVersion) { + mismatches.push( + `Schema version differs: renderer accepts ${tsVersion}, schema.ts types it as ${schemaVersion}.`, + ); + } const pyVersion = required( '"schemaVersion" in _learning_output.py', /"schemaVersion": (\d+)/.exec(emitter)?.[1], @@ -245,11 +323,8 @@ export function checkRendererContract() { // across both files that touch a payload: the validator and the view read // different fields, and looking at only one would let a field go unchecked // the moment it moved between them. - const rendererSources = `${renderer}\n${readFileSync( - join(thisDir, "src", "notebookRenderer", "index.ts"), - "utf8", - )}`; - const payloadFields = ["prompt", "options", "multiSelect", "cellId"]; + const rendererSources = `${renderer}\n${rendererIndex}`; + const payloadFields = ["prompt", "options", "multiSelect", "payloadId"]; const optionFields = ["id", "text", "correct", "explanation"]; for (const field of payloadFields) { const inTs = new RegExp(`payload\\.${field}\\b`).test(rendererSources); @@ -270,6 +345,90 @@ export function checkRendererContract() { } } + // The converter writes this tag onto every quiz cell, and `notebookExercises` + // reads it to keep quizzes out of the progress tree. If the two spellings + // drift, a quiz cell falls through to the code-cell branch and becomes an + // activity — one that can never complete, because a quiz is never run. + const converter = readFileSync( + join( + thisDir, + "resources", + "qdk-learning", + "utils", + "chemistry-qpe", + "details_to_quiz.py", + ), + "utf8", + ); + const tsQuizTag = required( + "QUIZ_TAG in notebookExercises.ts", + /^const QUIZ_TAG = "([^"]+)";/m.exec( + readFileSync( + join(thisDir, "src", "learning", "notebookExercises.ts"), + "utf8", + ), + )?.[1], + ); + // The converter tags a quiz cell in two places — a bare list, and a merge + // with the source cell's own tags when the quiz comes first — so every + // writer is compared, not just the one that happens to be found first. + const pyQuizTags = [ + ...converter.matchAll( + /tags = \["([^"]+)"\]|_cell_tags\(cell\), "([^"]+)"/g, + ), + ].map((m) => m[1] ?? m[2]); + required("a quiz cell tag in details_to_quiz.py", pyQuizTags[0]); + const wrongTags = [...new Set(pyQuizTags.filter((t) => t !== tsQuizTag))]; + if (wrongTags.length > 0) { + mismatches.push( + `Quiz cell tag differs: the converter writes ${wrongTags + .map((t) => `"${t}"`) + .join(" and ")} but notebookExercises.ts looks for "${tsQuizTag}".`, + ); + } + + // The id grammar is written twice, and `_ID_RE` even carries a comment + // telling the reader to keep it in step with this one. Drift is silent on + // both sides: the author's cell still runs, and the learner's action quietly + // degrades to the generic prompt because the host drops a malformed id. + const tsIdPattern = required( + "ID_PATTERN in schema.ts", + /^const ID_PATTERN = \/(.+)\/;/m.exec(schema)?.[1], + ); + const pyIdPattern = required( + "_ID_RE in _learning_output.py", + /^_ID_RE = re\.compile\(r"(.+)"\)/m.exec(emitter)?.[1], + ); + // Python spells the anchors `\A`/`\Z`; JavaScript uses `^`/`$`. Comparing the + // body between them is what says the two accept the same ids. + const pyIdBody = pyIdPattern.replace(/^\\A/, "").replace(/\\Z$/, ""); + const tsIdBody = tsIdPattern.replace(/^\^/, "").replace(/\$$/, ""); + if (tsIdBody !== pyIdBody) { + mismatches.push( + `Id grammar differs: schema.ts accepts /${tsIdBody}/ but ` + + `_learning_output.py accepts /${pyIdBody}/.`, + ); + } + + // The option cap is the same number twice: the host drops an action naming + // more ids than `MAX_OPTION_IDS`, and the emitter refuses to build a question + // with more options than that so the mistake lands on the author. If they + // drift apart, a question renders and its action quietly stops working. + const tsMaxOptions = required( + "MAX_OPTION_IDS in schema.ts", + /^const MAX_OPTION_IDS = (\d+);/m.exec(schema)?.[1], + ); + const pyMaxOptions = required( + "_MAX_OPTIONS in _learning_output.py", + /^_MAX_OPTIONS = (\d+)/m.exec(emitter)?.[1], + ); + if (tsMaxOptions !== pyMaxOptions) { + mismatches.push( + `Option cap differs: the host accepts ${tsMaxOptions} option ids but ` + + `the emitter allows ${pyMaxOptions} options.`, + ); + } + if (mismatches.length > 0) { throw new Error( `QDK learning renderer contract mismatch:\n - ${mismatches.join("\n - ")}\n` + diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb index 62ff3dc53b6..15d08fc6f0b 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb @@ -253,7 +253,7 @@ "explanation": "The alignment is deliberate. Six bits do not give mEh resolution for an arbitrary energy at this evolution time." } ], - "cellId": "iqpe-grid-target" + "payloadId": "iqpe-grid-target" }, "text/html": "
    ❓ Check your understanding

    Why does six-bit phase estimation meet a 1 mEh target here, even though adjacent grid points are much farther apart than that?

    1. Averaging over the 20 shots interpolates between neighbouring grid points.
    2. Trotter approximation error happens to cancel the grid spacing error.
    3. Six phase bits are enough to resolve any energy to 1 mEh.
    4. The evolution time was tuned using the classically known reference energy, so the target lands almost exactly on one six-bit grid point.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Why does six-bit phase estimation meet a 1 mEh target here, even though adjacent grid points are much farther apart than that?\n ( ) Averaging over the 20 shots interpolates between neighbouring grid points.\n ( ) Trotter approximation error happens to cancel the grid spacing error.\n ( ) Six phase bits are enough to resolve any energy to 1 mEh.\n ( ) The evolution time was tuned using the classically known reference energy, so the target lands almost exactly on one six-bit grid point." @@ -372,7 +372,7 @@ "explanation": "Only the ancilla is measured. The register is gone anyway, but because each iteration is its own circuit starting from all zeros." } ], - "cellId": "iqpe-state-prep" + "payloadId": "iqpe-state-prep" }, "text/html": "
    ❓ Check your understanding

    Why is trial-state preparation included in every IQPE iteration circuit?

    1. The classical feedback rotation destroys the trial state each iteration.
    2. Repeating it suppresses Trotter error by averaging over preparations.
    3. Each phase bit is measured by a separate circuit, and every shot begins with newly allocated qubits in the all-zero state.
    4. Measuring the readout ancilla collapses the compute register, so the trial state has to be rebuilt.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Why is trial-state preparation included in every IQPE iteration circuit?\n ( ) The classical feedback rotation destroys the trial state each iteration.\n ( ) Repeating it suppresses Trotter error by averaging over preparations.\n ( ) Each phase bit is measured by a separate circuit, and every shot begins with newly allocated qubits in the all-zero state.\n ( ) Measuring the readout ancilla collapses the compute register, so the trial state has to be rebuilt." @@ -488,7 +488,7 @@ } ], "multiSelect": true, - "cellId": "iqpe-grid-control" + "payloadId": "iqpe-grid-control" }, "text/html": "
    ❓ Check your understanding

    Which controls change the spacing of the energy grid?

    Select all that apply.

    1. The evolution time.
    2. The size of the active space.
    3. The number of phase bits.
    4. The number of shots per bit.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Which controls change the spacing of the energy grid?\n (select all that apply)\n [ ] The evolution time.\n [ ] The size of the active space.\n [ ] The number of phase bits.\n [ ] The number of shots per bit." @@ -577,7 +577,7 @@ } ], "multiSelect": true, - "cellId": "iqpe-readout-ancilla" + "payloadId": "iqpe-readout-ancilla" }, "text/html": "
    ❓ Check your understanding

    Which of these are true of the readout ancilla in the rendered circuit?

    Select all that apply.

    1. It controls the Hamiltonian evolution.
    2. It is measured to obtain the phase bit.
    3. It receives the H gates and the feedback rotation.
    4. It holds the prepared molecular state.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Which of these are true of the readout ancilla in the rendered circuit?\n (select all that apply)\n [ ] It controls the Hamiltonian evolution.\n [ ] It is measured to obtain the phase bit.\n [ ] It receives the H gates and the feedback rotation.\n [ ] It holds the prepared molecular state." @@ -617,7 +617,7 @@ "explanation": "The register is fixed at thirteen qubits. Resolving a different bit changes the controlled power, not the width." } ], - "cellId": "iqpe-circuit-shape" + "payloadId": "iqpe-circuit-shape" }, "text/html": "
    ❓ Check your understanding

    Why do all six iteration circuits have the same width but different lengths?

    1. The Trotter step count grows with the active-space size across iterations.
    2. Each iteration adds another ancilla to carry the feedback.
    3. Every iteration uses the same twelve-qubit compute register and one readout ancilla, while different controlled powers repeat the evolution unitary different numbers of times.
    4. Later iterations act on more qubits, because they resolve more significant bits.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Why do all six iteration circuits have the same width but different lengths?\n ( ) The Trotter step count grows with the active-space size across iterations.\n ( ) Each iteration adds another ancilla to carry the feedback.\n ( ) Every iteration uses the same twelve-qubit compute register and one readout ancilla, while different controlled powers repeat the evolution unitary different numbers of times.\n ( ) Later iterations act on more qubits, because they resolve more significant bits." @@ -788,7 +788,7 @@ "explanation": "That is textbook QPE. The iterative variant deliberately measures one bit per circuit, which is what keeps the register small." } ], - "cellId": "iqpe-bit-feedback" + "payloadId": "iqpe-bit-feedback" }, "text/html": "
    ❓ Check your understanding

    How does IQPE turn the result of each iteration into the final bitstring and phase fraction?

    1. The majority measurement for each iteration selects a phase bit, which updates the classical phase feedback used by the next iteration; after six iterations the feedback calculation combines the bits into one fraction.
    2. The phase fraction is the average of the six per-iteration phase estimates.
    3. The bits are independent, so they can be measured in any order and concatenated.
    4. All six bits are measured together in a single circuit and read off at the end.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: How does IQPE turn the result of each iteration into the final bitstring and phase fraction?\n ( ) The majority measurement for each iteration selects a phase bit, which updates the classical phase feedback used by the next iteration; after six iterations the feedback calculation combines the bits into one fraction.\n ( ) The phase fraction is the average of the six per-iteration phase estimates.\n ( ) The bits are independent, so they can be measured in any order and concatenated.\n ( ) All six bits are measured together in a single circuit and read off at the end." @@ -862,7 +862,7 @@ "explanation": "It does not give the same answer: it can synthesize a result that never occurred in any run." } ], - "cellId": "iqpe-aggregation" + "payloadId": "iqpe-aggregation" }, "text/html": "
    ❓ Check your understanding

    Why should the final aggregation use complete bitstrings rather than vote on each bit across complete runs?

    1. Per-bit voting would bias the result toward the most significant bit.
    2. Each complete bitstring is one phase-grid point with a corresponding energy, and voting per bit could assemble a bitstring that no run ever produced.
    3. Complete bitstrings are required because the bits are measured simultaneously.
    4. Per-bit voting gives the same answer but takes longer to compute.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Why should the final aggregation use complete bitstrings rather than vote on each bit across complete runs?\n ( ) Per-bit voting would bias the result toward the most significant bit.\n ( ) Each complete bitstring is one phase-grid point with a corresponding energy, and voting per bit could assemble a bitstring that no run ever produced.\n ( ) Complete bitstrings are required because the bits are measured simultaneously.\n ( ) Per-bit voting gives the same answer but takes longer to compute." @@ -965,7 +965,7 @@ "explanation": "That would mix algorithmic error with molecular-model error and could not tell you which one you were looking at." } ], - "cellId": "iqpe-energy-comparison" + "payloadId": "iqpe-energy-comparison" }, "text/html": "
    ❓ Check your understanding

    Which energy comparison determines whether the IQPE workflow meets the teaching target?

    1. The active-space energy against the Hartree-Fock energy.
    2. The reconstructed IQPE total energy against a CASCI energy computed in a larger active space.
    3. The reconstructed IQPE total energy against the CASCI energy of the same selected active-space Hamiltonian.
    4. The reconstructed IQPE total energy against an experimental measurement for the molecule.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Which energy comparison determines whether the IQPE workflow meets the teaching target?\n ( ) The active-space energy against the Hartree-Fock energy.\n ( ) The reconstructed IQPE total energy against a CASCI energy computed in a larger active space.\n ( ) The reconstructed IQPE total energy against the CASCI energy of the same selected active-space Hamiltonian.\n ( ) The reconstructed IQPE total energy against an experimental measurement for the molecule." @@ -1091,7 +1091,7 @@ "explanation": "It gives an active-space energy of -9.652276065987 Eh and a reconstructed total of -108.770051792909 Eh once the core energy is added back." } ], - "cellId": "iqpe-observed-result" + "payloadId": "iqpe-observed-result" }, "text/html": "
    ❓ Check your understanding

    What bitstring distribution did the workflow produce?

    1. All 20 runs produced `010000`.
    2. The 20 runs were spread across six different bitstrings, one per phase bit.
    3. `001111` appeared 19 times and `010000` once.
    4. `010000` appeared 19 times and `001111` once, so `010000` is the most frequent result.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: What bitstring distribution did the workflow produce?\n ( ) All 20 runs produced `010000`.\n ( ) The 20 runs were spread across six different bitstrings, one per phase bit.\n ( ) `001111` appeared 19 times and `010000` once.\n ( ) `010000` appeared 19 times and `001111` once, so `010000` is the most frequent result." @@ -1131,7 +1131,7 @@ "explanation": "The target is 1 mEh, so this meets it, though only exactly at the boundary." } ], - "cellId": "iqpe-target-met" + "payloadId": "iqpe-target-met" }, "text/html": "
    ❓ Check your understanding

    Does the result meet the teaching target, and what does that establish?

    1. Yes, at the boundary: the reconstructed total is 1 mEh above the selected-space CASCI reference, which validates this configured teaching workflow.
    2. Yes, and it establishes that the workflow reproduces the experimental energy of the molecule to 1 mEh.
    3. Yes, and it shows that Trotter and sampling error are negligible.
    4. No, a 1 mEh offset is outside the teaching target.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Does the result meet the teaching target, and what does that establish?\n ( ) Yes, at the boundary: the reconstructed total is 1 mEh above the selected-space CASCI reference, which validates this configured teaching workflow.\n ( ) Yes, and it establishes that the workflow reproduces the experimental energy of the molecule to 1 mEh.\n ( ) Yes, and it shows that Trotter and sampling error are negligible.\n ( ) No, a 1 mEh offset is outside the teaching target." @@ -1211,7 +1211,7 @@ } ], "multiSelect": true, - "cellId": "iqpe-more-bits" + "payloadId": "iqpe-more-bits" }, "text/html": "
    ❓ Check your understanding

    What happens if the number of phase bits increases while the repeated-power strategy stays fixed?

    Select all that apply.

    1. One more iteration circuit is needed.
    2. The circuits get shorter, because each bit carries less information.
    3. The largest controlled-unitary power doubles.
    4. The phase grid becomes finer.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: What happens if the number of phase bits increases while the repeated-power strategy stays fixed?\n (select all that apply)\n [ ] One more iteration circuit is needed.\n [ ] The circuits get shorter, because each bit carries less information.\n [ ] The largest controlled-unitary power doubles.\n [ ] The phase grid becomes finer." @@ -1251,7 +1251,7 @@ "explanation": "Trotter error is not what sets grid spacing, and repeating shots does not reduce it." } ], - "cellId": "iqpe-more-shots" + "payloadId": "iqpe-more-shots" }, "text/html": "
    ❓ Check your understanding

    Would increasing the number of shots per bit make the phase grid finer?

    1. No. More shots can make each bit majority more stable, but grid spacing is set by the evolution time and the number of phase bits.
    2. Yes, averaging more shots interpolates between grid points.
    3. No, because the grid is fixed by the size of the active space.
    4. Yes, more shots reduce Trotter error, which is what sets the spacing.

    Open this lesson in VS Code for interactive checking and explanations.

    ", "text/plain": "Check your understanding: Would increasing the number of shots per bit make the phase grid finer?\n ( ) No. More shots can make each bit majority more stable, but grid spacing is set by the evolution time and the number of phase bits.\n ( ) Yes, averaging more shots interpolates between grid points.\n ( ) No, because the grid is fixed by the size of the active space.\n ( ) Yes, more shots reduce Trotter error, which is what sets the spacing." diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py index d6dfe273e4c..04a3b700f91 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_learning_output.py @@ -35,6 +35,13 @@ #: `schema.ts`. _ID_RE = re.compile(r"\A[a-z0-9][a-z0-9-]{0,63}\Z") +#: The host drops an action naming more options than this, so a longer question +#: would render fine and then leave "Why is that wrong?" quietly doing less than +#: it should for every learner who selected them all. Refusing here makes that +#: an authoring error instead. Keep in step with `MAX_OPTION_IDS` in +#: `schema.ts`. +_MAX_OPTIONS = 8 + _CARD_STYLE = ( "font-family:var(--qdk-font-family, system-ui, sans-serif);" "color:var(--qdk-host-foreground, #222);" @@ -106,7 +113,7 @@ def multiple_choice( options: Sequence[Sequence[Any]], *, multi_select: bool = False, - cell_id: str | None = None, + payload_id: str | None = None, ) -> LearningOutput: """Create a multiple-choice learning output. @@ -119,19 +126,36 @@ def multiple_choice( then has to find all of them, and is told so. """ normalized = _normalize_options(options, multi_select=multi_select) + prompt = str(prompt) + # The renderer refuses a payload with no question text, so an empty prompt + # would bake cleanly and then fail for the learner. Catch it here, where + # the author is the one running the cell. + if not prompt.strip(): + raise ValueError("multiple_choice requires question text") payload: dict[str, Any] = { "schemaVersion": 1, "kind": "multiple-choice", - "prompt": str(prompt), + "prompt": prompt, "options": normalized, } if multi_select: payload["multiSelect"] = True - if cell_id is not None: - payload["cellId"] = str(cell_id) + if payload_id is not None: + payload_id = str(payload_id) + # This is what the renderer sends as the quiz id when a learner asks + # why an answer was wrong, and the extension drops a message whose id + # is not this shape. An id that fails here would leave the button + # doing nothing at all, with nothing said to anyone. + if not _ID_RE.match(payload_id): + raise ValueError( + f"payload_id {payload_id!r} must be lowercase letters, digits and " + "hyphens, start with a letter or digit, and be at most 64 " + "characters; the renderer's Copilot action drops anything else" + ) + payload["payloadId"] = payload_id - html = _mcq_html(str(prompt), normalized, multi_select=multi_select) - text = _mcq_text(str(prompt), normalized, multi_select=multi_select) + html = _mcq_html(prompt, normalized, multi_select=multi_select) + text = _mcq_text(prompt, normalized, multi_select=multi_select) return LearningOutput(payload, html, text) @@ -185,7 +209,7 @@ def register_quiz( ) ordered = _shuffled(quiz_id, options) if shuffle else options _quizzes[quiz_id] = multiple_choice( - prompt, ordered, multi_select=multi_select, cell_id=quiz_id + prompt, ordered, multi_select=multi_select, payload_id=quiz_id ) return quiz_id @@ -257,6 +281,11 @@ def _normalize_options( """ if len(options) < 2: raise ValueError("multiple_choice requires at least two options") + if len(options) > _MAX_OPTIONS: + raise ValueError( + f"multiple_choice takes at most {_MAX_OPTIONS} options; " + f"got {len(options)}" + ) normalized: list[dict[str, Any]] = [] seen_ids: set[str] = set() @@ -287,6 +316,13 @@ def _normalize_options( if option_id in seen_ids: raise ValueError(f"duplicate multiple_choice option id: {option_id!r}") seen_ids.add(option_id) + # An option the renderer would refuse to draw, for the same reason as + # the prompt: it validates the text is there before rendering, so an + # empty one fails for the learner rather than the author. + if not text.strip(): + raise ValueError( + f"multiple_choice option {option_id!r} needs text to show" + ) item: dict[str, Any] = {"id": option_id, "text": text, "correct": correct} if explanation is not None: diff --git a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py index 9596645aeb2..81cbbe73a59 100644 --- a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py +++ b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/details_to_quiz.py @@ -71,6 +71,12 @@ def _cell_id(body: str) -> str: QUIZ_CALL = re.compile(r"^quiz\(([^)]*)\)", re.M) QUIZ_ID = re.compile(r'"([^"]+)"') +#: Deliberately looser than `QUIZ_CALL`, and matched anywhere in a line rather +#: than at its start: it answers "is there a question here at all", so a call +#: this tool cannot read — `quiz('a')`, `_unit.quiz("a")`, `x = quiz("a")` — is +#: reported rather than mistaken for a cell whose question was deleted. +QUIZ_MENTION = re.compile(r"\bquiz\s*\(") + def _load_unit_module(unit_dir: Path) -> tuple[Any, Any]: """Import a unit's ``_unit.py`` so its ``register_quiz`` calls run. @@ -187,7 +193,13 @@ def _ensure_quiz_import(cell: dict[str, Any]) -> bool: def _cell_quiz_ids(cell: dict[str, Any]) -> list[str]: - """The quiz ids a single cell shows, in order.""" + """The quiz ids a single cell shows, in order. + + Code cells only. A ``quiz("id")`` inside fenced prose is an example, and a + markdown cell has no outputs to bake into in any case. + """ + if cell.get("cell_type") != "code": + return [] source = "".join(cell["source"]) return [ quiz_id @@ -196,6 +208,19 @@ def _cell_quiz_ids(cell: dict[str, Any]) -> list[str]: ] +def _calls_quiz(cell: dict[str, Any]) -> bool: + """Whether a code cell calls ``quiz()`` in any form, readable or not. + + ``_cell_quiz_ids`` reads one spelling: the one this tool writes. Telling + "no question here" apart from "a question written differently" is what + stops a cell that says ``quiz('id')`` being mistaken for a removed + question and having its baked output deleted. + """ + if cell.get("cell_type") != "code": + return False + return QUIZ_MENTION.search("".join(cell["source"])) is not None + + def _notebook_quiz_ids(notebook: dict[str, Any]) -> list[str]: """The quiz ids the notebook already shows, in document order.""" found: list[str] = [] @@ -248,6 +273,17 @@ def convert(notebook_path: Path, unit_dir: Path, quiz_ids: list[str]) -> dict[st fragment["source"] = body.splitlines(keepends=True) converted.append(fragment) else: + # Mirrors the surplus-id check below. Without this the list + # comprehension pops an empty list and the author gets an + # IndexError traceback instead of being told what to fix. + if len(remaining) < len(texts): + raise SystemExit( + f"not enough quiz ids: {len(quiz_ids)} given, but the " + "notebook asks more questions than that. Ids must be " + "given in document order, one per question; a single " + "quiz() call can name several, so count questions " + "rather than cells." + ) ids = [remaining.pop(0) for _ in texts] call = "quiz({})\n".format(", ".join(f'"{i}"' for i in ids)) # Tagged so the progress tree looks past this cell for the @@ -285,17 +321,27 @@ def convert(notebook_path: Path, unit_dir: Path, quiz_ids: list[str]) -> dict[st return notebook -def _rebake(notebook: dict[str, Any], emitter: Any, stale: set[str]) -> None: - """Re-render the outputs of the cells holding a stale quiz. +def _rebake(notebook: dict[str, Any], emitter: Any, stale: dict[int, list[str]]) -> None: + """Re-render the outputs of the cells ``_stale_baked_outputs`` named. Rebaking is per cell because a cell can hold several quizzes, so one stale question re-renders its neighbours too. That is why only the cells that need it are touched: it keeps the write to what the report named. """ - for cell in notebook["cells"]: + for index in stale: + cell = notebook["cells"][index] ids = _cell_quiz_ids(cell) - if ids and not stale.isdisjoint(ids): + if ids: cell["outputs"] = _baked_outputs(emitter, ids) + continue + # The cell no longer calls quiz(), so there is nothing to re-render: + # drop the orphaned questions and leave whatever else the cell + # produced, which may be the output of code that replaced them. + cell["outputs"] = [ + output + for output in cell.get("outputs", []) + if not _is_learning_output(output, emitter.MIME_TYPE) + ] def _normalize_bundle(data: Any) -> Any: @@ -313,21 +359,56 @@ def _normalize_bundle(data: Any) -> Any: return data -def _stale_baked_outputs(notebook: dict[str, Any], emitter: Any) -> list[str]: - """Report quizzes whose baked output no longer matches ``_unit.py``. +def _is_learning_output(output: dict[str, Any], mime_type: str) -> bool: + """Whether this output is a baked learning payload rather than the cell's own. + + Takes the MIME type from the emitter instead of naming it again: the + string already lives in `_learning_output.py`, `schema.ts` and + `package.json`, and a fourth copy here is a fourth thing to drift. + """ + return mime_type in (output.get("data") or {}) + + +def _stale_baked_outputs( + notebook: dict[str, Any], emitter: Any +) -> dict[int, list[str]]: + """Report the cells whose baked output no longer matches ``_unit.py``. This is the drift that matters once a notebook is converted: the questions a learner sees are the outputs stored in the file, so editing a quiz's wording or its options without re-running this tool would leave the old version on screen. + + Keyed by cell index, and not by quiz id, because a cell can lose its last + ``quiz()`` call. Its baked questions are then orphaned with no surviving id + to name them, so anything keyed on ids alone could neither report them nor + clear them. The value is what to name in the report: the stale ids, or an + empty list for a cell that should no longer show a question at all. """ - stale: list[str] = [] - for cell in notebook["cells"]: + stale: dict[int, list[str]] = {} + for index, cell in enumerate(notebook["cells"]): ids = _cell_quiz_ids(cell) + outputs = cell.get("outputs", []) + if not ids: + # A cell whose quiz() calls were all removed keeps rendering the + # questions until its outputs are cleared. Only learning outputs + # count, so an ordinary code cell's own outputs never look stale. + if any(_is_learning_output(output, emitter.MIME_TYPE) for output in outputs): + if _calls_quiz(cell): + # The cell still asks a question, in a spelling this tool + # does not read. Clearing its output would delete a live + # question, so say so instead. + raise SystemExit( + f"cell {cell.get('id', '?')} calls quiz() in a form " + 'this tool cannot read. Write it as quiz("id"), with ' + "double quotes at the start of a line, so a removed " + "question can be told apart from one written " + "differently." + ) + stale[index] = [] continue - outputs = cell.get("outputs", []) for position, quiz_id in enumerate(ids): expected = _normalize_bundle( emitter._lookup_quiz(quiz_id)._repr_mimebundle_() @@ -338,18 +419,25 @@ def _stale_baked_outputs(notebook: dict[str, Any], emitter: Any) -> list[str]: else None ) if actual != expected: - stale.append(quiz_id) + stale.setdefault(index, []).append(quiz_id) # An output past the last quiz the cell still calls is left over from a # question that was removed. Nothing above compares it, so without this # the notebook keeps showing a deleted question while --check reports # the file as up to date. Naming the cell's remaining quizzes is what # makes `_rebake` re-render it, which drops the extra output. - if len(outputs) > len(ids) and not any(i in stale for i in ids): - stale.extend(ids) + if len(outputs) > len(ids) and index not in stale: + stale[index] = list(ids) return stale +def _describe_stale(cell: dict[str, Any], names: list[str]) -> str: + """Name a stale cell for the report.""" + if names: + return ", ".join(names) + return f"a removed question in cell {cell.get('id', '?')}" + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("unit", help="unit folder name, e.g. 06-iterative-phase-estimation") @@ -379,31 +467,45 @@ def main() -> int: # converter or checking for drift does not mean repeating the list every # time. A first conversion has none to read and still has to be told. quiz_ids = list(args.ids) if args.ids else _notebook_quiz_ids(json.loads(original)) - if not quiz_ids: - raise SystemExit( - f"{notebook_path.name} has no quiz() calls yet, so --ids is required " - "to say which questions to substitute, in document order" - ) # Re-runnable on purpose. The conversion is a step after # `rst_to_notebook.py`, so a pipeline should be able to run it without # first checking whether the notebook was regenerated. if _already_converted(json.loads(original), quiz_ids): _unit_module, emitter = _load_unit_module(unit_dir) - stale = _stale_baked_outputs(json.loads(original), emitter) + notebook = json.loads(original) + stale = _stale_baked_outputs(notebook, emitter) + + # No quiz() calls and nothing baked to clear means the chapter was + # never converted, and it has to be told the ids. Deciding that from + # the baked outputs rather than from a `
    ` search is deliberate: + # `QUIZ_BLOCK` scrapes a wrapper `rst_to_notebook.py` writes, and "that + # pattern found nothing" must never be read as "nothing to do" — that + # would report an unconverted chapter as up to date the day the wrapper + # changes. + if not quiz_ids and not stale: + raise SystemExit( + f"{notebook_path.name} has no quiz() calls yet, so --ids is " + "required to say which questions to substitute, in document order" + ) + if not stale: print(f"{notebook_path.name}: already converted and up to date") return 0 - listed = ", ".join(sorted(set(stale))) + listed = ", ".join( + sorted( + _describe_stale(notebook["cells"][index], names) + for index, names in stale.items() + ) + ) if args.check: print(f"{notebook_path.name}: baked output is stale for {listed}") return 1 # Rebake in place rather than refusing: the questions live in # _unit.py, and the notebook is only a rendering of them. - notebook = json.loads(original) - _rebake(notebook, emitter, set(stale)) + _rebake(notebook, emitter, stale) notebook_path.write_text( json.dumps(notebook, indent=1, ensure_ascii=False) + "\n", encoding="utf-8", diff --git a/source/vscode/src/learning/notebookRendererMessaging.ts b/source/vscode/src/learning/notebookRendererMessaging.ts index 623762b219c..cb967fa95b9 100644 --- a/source/vscode/src/learning/notebookRendererMessaging.ts +++ b/source/vscode/src/learning/notebookRendererMessaging.ts @@ -47,15 +47,17 @@ export function registerNotebookRendererMessaging( } // Any notebook can carry an output of this MIME type, so a message is - // only as trustworthy as the file it came from. This is the whole - // authorization: a workbook this workspace materialized is a course - // file whichever course the learner last navigated to. + // only as trustworthy as the file it came from. This proves the file + // sits where a loaded course says its workbook lives — not that this + // extension wrote it, since a workspace that already contained a + // `qdk-learning` folder is loaded as a course. Treat what follows as + // untrusted either way. if ( !service.initialized || - !service.isCourseWorkbookUri(event.editor.notebook.uri) + !service.isCourseWorkbook(event.editor.notebook.uri) ) { log.warn( - "Learning: ignoring a renderer message from a notebook this workspace did not create.", + "Learning: ignoring a renderer message from a notebook outside the loaded course.", ); return; } @@ -73,18 +75,16 @@ export function registerNotebookRendererMessaging( /** * Prompt templates, owned by the extension. * - * These stay as short as the queries the cell status bar sends - * ("/qdk-learning Give me a hint"). The `qdk-learning-*` language model tools - * already report the learner's position, progress and code on every - * invocation, so a long prompt would be restating what the agent can look up. + * Short on purpose, like the queries the cell status bar sends: the + * `qdk-learning-*` tools already report the learner's position, progress and + * code, so a longer prompt would restate what the agent can look up. * - * Nothing the renderer wrote is quoted here. An earlier version spliced in the - * question and the chosen option, which are notebook content and therefore - * attacker-supplied prose in a file that only has to sit at a workbook's path. - * Ids name the same things precisely enough for the agent to find them in the - * open notebook, and their shape leaves no room for an instruction. Saying - * where to look is the point: no learning tool reads a quiz, because a quiz is - * deliberately not an activity. + * Only ids are interpolated, and only in the shape `ID_PATTERN` allows. That + * stops a notebook scripting the chat panel; it does not stop the agent + * reading a notebook the learner already has open, which is what the last + * sentence asks for. Saying where to look is necessary because no learning + * tool reads a quiz — a quiz is deliberately not an activity. Keep any new + * template this narrow. */ function buildQuery( actionId: CopilotActionId, diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 2fdd7aef2ab..5540ec2ad14 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -705,35 +705,6 @@ export class LearningService { return { id: course.id, title: course.title, kind: course.kind }; } - /** - * True when `uri` is a workbook this workspace materialized for a learner. - * - * Notebook output runs in a webview, and any notebook can claim a MIME type, - * so a message arriving from one is only as trustworthy as the file it came - * from. Comparing against each unit's workbook URI rather than matching the - * `.workbook.ipynb` suffix means a lookalike opened from elsewhere does not - * pass. - * - * Every known course is searched, not just the active one: which course is - * active is a matter of where the learner navigated last, and a workbook - * open in front of them is theirs either way. Narrowing to the active course - * would drop valid actions whenever the two disagree. - */ - isCourseWorkbookUri(uri: vscode.Uri): boolean { - const target = uri.toString(); - for (const course of this.requireWorkspace().courses.values()) { - if (!isNotebookCourse(course)) { - continue; - } - if ( - course.units.some((unit) => workbookUri(unit).toString() === target) - ) { - return true; - } - } - return false; - } - /** True once the user has explicitly picked a course. */ hasUserSelectedCourse(): boolean { const ws = this.workspace; diff --git a/source/vscode/src/notebookRenderer/index.ts b/source/vscode/src/notebookRenderer/index.ts index 792fb2c4de8..a4e35c3f0c2 100644 --- a/source/vscode/src/notebookRenderer/index.ts +++ b/source/vscode/src/notebookRenderer/index.ts @@ -40,9 +40,7 @@ type Cleanup = () => void; const cleanupByOutputId = new Map(); const cleanupByElement = new WeakMap(); -export const activate: ActivationFunction = ( - context: RendererContext, -) => { +export const activate: ActivationFunction = (context: RendererContext) => { const postAction = (message: RendererToExtensionMessage) => { if (context.postMessage === undefined) { // No extension host — an exported HTML page, say. @@ -126,14 +124,20 @@ function readPayload(outputItem: OutputItem): LearningPayload { const payload = value; - // Say which side is ahead. A notebook can outlive the extension that wrote - // it, and "update the QDK extension" is a far more useful thing to read in a - // cell than a generic parse failure. + // Which side is behind decides the advice. A payload newer than this + // renderer means the extension is old. An older one is a workbook the + // learner already has on disk, and updating again would not change it — + // "Reset Unit" is what replaces it with a current copy. if (payload.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { + const ahead = + typeof payload.schemaVersion === "number" && + payload.schemaVersion > SUPPORTED_SCHEMA_VERSION; throw new Error( `This output uses QDK learning payload version ${String(payload.schemaVersion)}, ` + `but this renderer supports version ${SUPPORTED_SCHEMA_VERSION}. ` + - "Update the QDK extension to view it.", + (ahead + ? "Update the QDK extension to view it." + : "Use Reset Unit on this notebook's toolbar to get an up-to-date copy."), ); } @@ -144,8 +148,11 @@ function readPayload(outputItem: OutputItem): LearningPayload { ); } - if (payload.cellId !== undefined && typeof payload.cellId !== "string") { - throw new Error("QDK learning payload has a non-string cellId."); + if ( + payload.payloadId !== undefined && + typeof payload.payloadId !== "string" + ) { + throw new Error("QDK learning payload has a non-string payloadId."); } // Per-kind checks stay behind the kind test: a second payload kind must not diff --git a/source/vscode/src/notebookRenderer/multipleChoice.ts b/source/vscode/src/notebookRenderer/multipleChoice.ts index fd182bb1141..9857589d0d6 100644 --- a/source/vscode/src/notebookRenderer/multipleChoice.ts +++ b/source/vscode/src/notebookRenderer/multipleChoice.ts @@ -73,9 +73,9 @@ export function renderMultipleChoice( actionList.className = "qdk-learning-action-list"; actionList.hidden = true; - // Include the cell id when available, plus a counter to avoid cross-output - // radio grouping even if a notebook renders duplicate cell ids. - const groupName = `qdk-learning-${payload.cellId ?? "output"}-${groupId++}`; + // Include the payload id when available, plus a counter so two outputs never + // share a radio group even if a notebook repeats an id. + const groupName = `qdk-learning-${payload.payloadId ?? "output"}-${groupId++}`; const optionViews: OptionView[] = []; for (const [index, option] of payload.options.entries()) { const letter = optionLetter(index); @@ -155,7 +155,7 @@ export function renderMultipleChoice( type: "qdk-learning/action", rendererId: RENDERER_ID, actionId: "why-wrong", - quizId: payload.cellId, + quizId: payload.payloadId, optionIds: gradedSelection, }); diff --git a/source/vscode/src/notebookRenderer/rendererApi.d.ts b/source/vscode/src/notebookRenderer/rendererApi.d.ts index 4bf48844272..a9f6591f9b6 100644 --- a/source/vscode/src/notebookRenderer/rendererApi.d.ts +++ b/source/vscode/src/notebookRenderer/rendererApi.d.ts @@ -1,25 +1,28 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +/** + * The slice of VS Code's notebook renderer API this renderer calls. + * + * Deliberately narrower than the published `@types/vscode-notebook-renderer`. + * Declaring members we never call would make this a second, unowned copy of an + * API nobody here maintains, and a wrong declaration of something unused is + * worse than no declaration because it reads as fact. Widen it by adding the + * member you need, checked against the published types. + */ declare module "vscode-notebook-renderer" { - export interface RendererContext { - readonly workspaceState: TState; + export interface RendererContext { + /** Absent when no extension host is listening, as in an HTML export. */ postMessage?(message: unknown): void | PromiseLike; } export interface OutputItem { readonly id: string; readonly mime: string; - readonly data: Uint8Array; - readonly metadata?: Record; json(): unknown; - text(): string; - blob(): Blob; } - export type ActivationFunction = ( - context: RendererContext, - ) => { + export type ActivationFunction = (context: RendererContext) => { renderOutputItem( outputItem: OutputItem, element: HTMLElement, diff --git a/source/vscode/src/notebookRenderer/rendering.ts b/source/vscode/src/notebookRenderer/rendering.ts index ff7cbac41d5..e2be8544f29 100644 --- a/source/vscode/src/notebookRenderer/rendering.ts +++ b/source/vscode/src/notebookRenderer/rendering.ts @@ -107,8 +107,8 @@ const SPARKLE_PATH = /** * Announce a change to assistive technology. * - * Answering a question or switching orbitals updates the view in place, which - * a screen reader would otherwise miss. + * Grading a question updates the view in place, which a screen reader would + * otherwise miss. */ export function setLiveRegion(element: HTMLElement) { element.setAttribute("role", "status"); diff --git a/source/vscode/src/notebookRenderer/schema.ts b/source/vscode/src/notebookRenderer/schema.ts index ce528d291af..af676acb6b0 100644 --- a/source/vscode/src/notebookRenderer/schema.ts +++ b/source/vscode/src/notebookRenderer/schema.ts @@ -15,11 +15,12 @@ type LearningPayloadBase = { schemaVersion: 1; kind: string; /** - * Identifies the payload, not the notebook cell holding it: for a quiz this - * is its registered id, which keeps radio groups unique. Deliberately not an - * ipynb cell id, so don't pass it to anything that resolves activities. + * Identifies the payload. For a quiz this is its registered id, which keeps + * radio groups unique and names the question when the learner asks about it. + * Deliberately not an ipynb cell id: a quiz cell is not an activity, so this + * must never reach anything that resolves one. */ - cellId?: string; + payloadId?: string; }; export type MultipleChoicePayload = LearningPayloadBase & { @@ -67,20 +68,22 @@ type RendererActionMessage = { export type RendererToExtensionMessage = RendererActionMessage; /** - * Ids are the only thing a renderer may contribute to a chat prompt. + * The shape every id crossing the bridge must have. * - * Everything a notebook carries is untrusted: an output of this MIME type can - * be hand-written, and a file at a workbook's path can be shipped by whatever - * produced the workspace. Free prose from such a payload reaching a prompt is - * an injection, and no amount of quote-stripping changes that, because the - * payload is a sentence either way. Constraining every value that crosses to - * this shape leaves no room for an instruction, while still naming the - * question and the answer precisely enough for an agent to look them up. + * A budget, not a guarantee. Hyphens are word separators, so a 64-character id + * can still read as a short sentence — the pattern removes punctuation, + * newlines and length, not meaning. What actually limits an attacker is that + * the extension owns every word around these ids; see `buildQuery` in + * `notebookRendererMessaging.ts`, and keep any new template as narrow. */ const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; -/** Nothing sensible asks about more selections than a question has options. */ -const MAX_OPTION_IDS = 32; +/** + * Caps how much attacker-controlled text one action can carry, and so how many + * options a question may have — the emitter refuses more, so the limit lands on + * the author. Keep in step with `_MAX_OPTIONS` in `_learning_output.py`. + */ +const MAX_OPTION_IDS = 8; export function isRecord(value: unknown): value is Record { return (