From d6be3be0f2ed52e42dedc48860a442b36c9e0e7b Mon Sep 17 00:00:00 2001 From: Gwri Pennar <130799892+GwriPennar@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:44:00 +0100 Subject: [PATCH 1/3] feat: teach the quantum journey from exact truth --- .../renderers/compact_experiment.py | 5 + .../renderers/guided_experiment.py | 124 +++++++++++++++++- .../services/build_week_service.py | 54 ++++++++ learn/glossary.yaml | 28 ++++ tests/learning/test_build_week_console.py | 40 ++++++ tests/learning/test_foundations_public.py | 16 ++- 6 files changed, 259 insertions(+), 8 deletions(-) diff --git a/apps/learning_console/renderers/compact_experiment.py b/apps/learning_console/renderers/compact_experiment.py index a23b421..14eefc9 100644 --- a/apps/learning_console/renderers/compact_experiment.py +++ b/apps/learning_console/renderers/compact_experiment.py @@ -90,6 +90,11 @@ def render_compact_experiment() -> None: ) st.markdown("## First IBM hardware validation") + st.caption( + "Real quantum hardware is physically imperfect: gates and measurements sometimes " + "introduce small errors. That blurring of the ideal result is called noise. Noise is " + "one reason hardware and ideal simulation can differ." + ) st.markdown("**Did the correct answer remain visible on real hardware?**") st.write( "One IBM hardware run tested whether the compact real-data result remained visible under " diff --git a/apps/learning_console/renderers/guided_experiment.py b/apps/learning_console/renderers/guided_experiment.py index 62cfb59..8ce1415 100644 --- a/apps/learning_console/renderers/guided_experiment.py +++ b/apps/learning_console/renderers/guided_experiment.py @@ -59,7 +59,7 @@ def _render_landscape(view: GuidedExperimentView) -> None: "field": "display_energy", "type": "quantitative", "scale": {"scheme": "viridis", "reverse": True}, - "legend": {"title": "Exact energy"}, + "legend": {"title": "Exact energy (lower = better)"}, }, "stroke": { "condition": {"test": "datum.is_global_optimum", "value": "white"}, @@ -103,6 +103,96 @@ def _render_landscape(view: GuidedExperimentView) -> None: def _render_registered_comparison(view: GuidedExperimentView) -> None: evidence = view.registered_qaoa + st.subheader("What does the quantum method actually do?") + st.write( + "A quantum computer does not print one authoritative answer. The circuit is prepared " + "and measured once — one shot — and that measurement returns one candidate grouping. " + "Repeating this thousands of times creates a distribution of answers. A useful " + "optimisation method should place more measurement weight on better-scoring groupings. " + "The exact calculation above remains the authority used to judge it." + ) + st.markdown("### What did 4,096 shots actually return?") + measurement_rows = [item.chart_row() for item in view.registered_measurements] + measurement_order = [item["bitstring"] for item in measurement_rows] + st.vega_lite_chart( + measurement_rows, + { + "height": 320, + "layer": [ + { + "mark": {"type": "bar"}, + "encoding": { + "y": { + "field": "bitstring", + "type": "nominal", + "sort": measurement_order, + "title": "Returned grouping", + }, + "x": { + "field": "count", + "type": "quantitative", + "title": "Registered measurement count", + }, + "color": { + "field": "status", + "type": "nominal", + "scale": { + "domain": ["Exact optimum", "Other grouping"], + "range": ["#2a9d8f", "#6c83a6"], + }, + "legend": None, + }, + "tooltip": [ + {"field": "bitstring", "type": "nominal", "title": "Grouping"}, + {"field": "count", "type": "quantitative", "title": "Count"}, + {"field": "status", "type": "nominal", "title": "Exact check"}, + ], + }, + }, + { + "mark": { + "type": "text", + "align": "left", + "dx": 6, + "color": "#ffffff", + "fontWeight": "bold", + }, + "encoding": { + "y": { + "field": "bitstring", + "type": "nominal", + "sort": measurement_order, + }, + "x": {"field": "zero", "type": "quantitative"}, + "text": {"field": "optimum_note"}, + }, + }, + { + "mark": {"type": "text", "align": "left", "dx": 5, "color": "#ffffff"}, + "encoding": { + "y": { + "field": "bitstring", + "type": "nominal", + "sort": measurement_order, + }, + "x": {"field": "count", "type": "quantitative"}, + "text": {"field": "count", "type": "quantitative"}, + }, + }, + ], + }, + width="stretch", + ) + st.caption( + "Each bar is one answer returned by the registered ideal simulation. These are the ten " + f"most frequent states, not all {view.registered_distinct_state_count} observed states. " + "Exact-optimum states are labelled so the simulated measurements can be checked against " + "the known answer. This is registered simulation evidence, not IBM hardware." + ) + st.write( + "Many different groupings appeared, but the measurement weight was uneven. The next " + "comparison checks how much of it landed on the two known best groupings." + ) st.subheader("How did the quantum method do?") st.markdown( "**Registered evidence · Local ideal simulation · Not quantum hardware · " @@ -250,6 +340,14 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: return st.subheader("What did every possible grouping score?") + st.write( + "Each of the eight variants can join either family, so there are " + "2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 = 256 possible groupings." + ) + st.info( + "Energy is this model's score for one possible grouping. Lower energy means a better " + "answer. The outlined cells mark the exact best groupings." + ) _render_landscape(view) st.subheader("What is the exact answer?") @@ -259,18 +357,24 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: left.metric("Minimum energy", f"{float(exact['minimum_energy']):.6f}") middle.metric("Canonical split", str(exact["canonical_complement_class"])) right.metric("Assignments checked", str(exact["evaluated_assignments"])) + st.write( + "`00001111` has one digit for each tune variant. A `0` places that variant in one " + "family and a `1` places it in the other. `11110000` describes the same split with the " + "family labels exchanged." + ) st.success( "Exhaustive enumeration is authoritative for this eight-variable fixture. The global " "bitwise complement denotes the same unlabeled partition." ) - st.info( - "Because the exact answer is known, every quantum result below can be checked rather " - "than taken on trust." - ) - _render_registered_comparison(view) - st.subheader("How was the question turned into a model?") + st.write( + "To hand this question to a quantum method, it is rewritten in three steps. First, " + "each tune variant becomes a 0-or-1 choice. Second, any complete set of eight choices — " + "such as `00001111` — is one possible answer. Third, a score called energy says how " + "good that answer is; lower is better. The quantum circuit is built from that scored " + "problem." + ) with st.expander("Technical model and QUBO"): st.write(result.fixture_description) st.write( @@ -279,6 +383,12 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: ) st.json({"parameters": result.parameters, "QUBO summary": result.qubo_summary}) + st.info( + "Because the exact answer is known, every quantum result below can be checked rather " + "than taken on trust." + ) + _render_registered_comparison(view) + st.subheader("Want to run a small local quantum comparison?") st.caption( "This live bounded quick run uses a smaller submission-safe contract. It is separate " diff --git a/apps/learning_console/services/build_week_service.py b/apps/learning_console/services/build_week_service.py index a8b41cd..766a0c2 100644 --- a/apps/learning_console/services/build_week_service.py +++ b/apps/learning_console/services/build_week_service.py @@ -3,8 +3,10 @@ from __future__ import annotations import json +from collections.abc import Collection, Mapping from dataclasses import dataclass from pathlib import Path +from typing import Any from quantum_folk_lab.build_week import ( ExactLandscape, @@ -28,6 +30,8 @@ class GuidedExperimentView: quantum: QuantumCapability landscape: ExactLandscape registered_qaoa: RegisteredQAOAEvidence + registered_measurements: tuple[RegisteredMeasurement, ...] + registered_distinct_state_count: int def explanation(self, level: LearnerLevel) -> str: return self.result.explanations[level.value] @@ -39,17 +43,67 @@ def markdown_export(self, level: LearnerLevel) -> bytes: return export_markdown(self.result, level).encode("utf-8") +@dataclass(frozen=True) +class RegisteredMeasurement: + """One display-safe row from the registered simulated measurement counts.""" + + bitstring: str + count: int + is_exact_optimum: bool + + def chart_row(self) -> dict[str, object]: + status = "Exact optimum" if self.is_exact_optimum else "Other grouping" + return { + "bitstring": self.bitstring, + "count": self.count, + "status": status, + "optimum_note": "EXACT OPTIMUM" if self.is_exact_optimum else "", + "zero": 0, + } + + +def top_registered_measurements( + measurement_counts: Mapping[str, Any], + exact_optima: Collection[str], + *, + limit: int = 10, +) -> tuple[RegisteredMeasurement, ...]: + """Return a stable, non-mutating top-count view of registered evidence.""" + + if limit < 1: + raise ValueError("limit must be positive") + optimum_set = set(exact_optima) + ranked = sorted( + ((str(bitstring), int(count)) for bitstring, count in measurement_counts.items()), + key=lambda item: (-item[1], item[0]), + ) + return tuple( + RegisteredMeasurement( + bitstring=bitstring, + count=count, + is_exact_optimum=bitstring in optimum_set, + ) + for bitstring, count in ranked[:limit] + ) + + def load_guided_experiment() -> GuidedExperimentView: root = Path(__file__).resolve().parents[3] registered_path = ( root / "experiments" / "EXP-005A-tune-family-qaoa" / "results" / "tune-family-qaoa-p1.json" ) registered_payload = json.loads(registered_path.read_text(encoding="utf-8")) + measurement_counts = dict(registered_payload["measurement_counts"]) + exact_optima = tuple( + str(value) for value in registered_payload["exact"]["exact_optimal_bitstrings"] + ) return GuidedExperimentView( result=run_guided_exact(), quantum=quantum_capability(), landscape=build_exact_landscape(), registered_qaoa=parse_registered_qaoa_evidence(registered_payload), + registered_measurements=top_registered_measurements(measurement_counts, exact_optima), + registered_distinct_state_count=len(measurement_counts), ) diff --git a/learn/glossary.yaml b/learn/glossary.yaml index 813db70..1a47e9d 100644 --- a/learn/glossary.yaml +++ b/learn/glossary.yaml @@ -76,3 +76,31 @@ terms: one_liner: Quantum Approximate Optimisation Algorithm — hybrid quantum/classical loop. deeper: Alternates cost and mixer layers; a classical optimiser picks angles. related_lessons: [guided.foundations.optimisation] + - term: energy + one_liner: The score this optimisation model assigns to one possible answer. + deeper: Lower energy is better in the Quantum Folk Lab experiments; exact search identifies the minimum. + related_lessons: [guided.foundations.optimisation] + - term: noise + one_liner: Small errors introduced by imperfect physical qubits, gates and measurements. + deeper: Noise can blur an ideal result, although sampling and other effects can also cause differences. + related_lessons: [guided.foundations.gates, guided.foundations.hadamard] + - term: superposition + one_liner: A quantum state described by weighted possibilities before measurement. + deeper: Unlike an ordinary bit that is already 0 or 1, a qubit in superposition has amplitudes that can interfere. + related_lessons: [guided.foundations.bits_qubits, guided.foundations.hadamard] + - term: Spearman rho + one_liner: A number measuring how similarly two sets of results are ranked. + deeper: Values closer to 1 mean stronger agreement in ordering; it does not by itself prove identical values or quantum advantage. + related_lessons: [guided.foundations.optimisation] + - term: PUB + one_liner: A packaged circuit-and-parameters workload supplied to a Qiskit Runtime primitive. + deeper: One Runtime job can contain multiple PUBs; each PUB remains a distinct execution unit in the recorded evidence. + related_lessons: [guided.foundations.gates] + - term: hardware backend + one_liner: The particular simulator or physical quantum device selected to execute a circuit. + deeper: Hardware backends have device-specific qubits, connections and error characteristics. + related_lessons: [guided.foundations.gates] + - term: Hadamard + one_liner: A common gate that ideally places a definite qubit into an even superposition. + deeper: Hadamard gates also help reveal phase through interference when quantum paths recombine. + related_lessons: [guided.foundations.hadamard] diff --git a/tests/learning/test_build_week_console.py b/tests/learning/test_build_week_console.py index 1884f89..32c4ca1 100644 --- a/tests/learning/test_build_week_console.py +++ b/tests/learning/test_build_week_console.py @@ -37,9 +37,32 @@ def test_service_loads_without_streamlit_qiskit_or_openai_imports() -> None: assert view.result.exact_result["canonical_complement_class"] == "00001111" assert len(view.landscape.entries) == 256 assert view.registered_qaoa.optimum_class_count == 2175 + assert len(view.registered_measurements) == 10 + assert view.registered_measurements[0].bitstring == "11110000" + assert view.registered_measurements[0].is_exact_optimum + assert view.registered_measurements[1].bitstring == "00001111" + assert view.registered_distinct_state_count > 10 assert view.json_export().startswith(b"{") +def test_registered_top_measurements_are_stable_and_non_mutating() -> None: + path = Path("apps/learning_console/services/build_week_service.py") + spec = importlib.util.spec_from_file_location("measurement_service_test", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + counts = {"other-b": 3, "other-a": 3, "best": 8} + original = counts.copy() + + rows = module.top_registered_measurements(counts, {"best"}, limit=3) + + assert [row.bitstring for row in rows] == ["best", "other-a", "other-b"] + assert [row.is_exact_optimum for row in rows] == [True, False, False] + assert rows[0].chart_row()["optimum_note"] == "EXACT OPTIMUM" + assert counts == original + + def test_guided_experiment_256_reveal_journey_requires_no_openai_key( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -90,6 +113,8 @@ def test_guided_experiment_256_reveal_journey_requires_no_openai_key( "What is the exact answer?", "How did the quantum method do?", "How was the question turned into a model?", + "What does the quantum method actually do?", + "What did 4,096 shots actually return?", "Can AI explain the result safely?", "256", "00001111", @@ -108,10 +133,24 @@ def test_guided_experiment_256_reveal_journey_requires_no_openai_key( "found one of the best answers far more often than random guessing", "every quantum result below can be checked rather than taken on trust", "GPT-5.6 may explain the validated result", + "Energy is this model's score", + "Lower energy means a better answer", + "2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 = 256", + "one digit for each tune variant", + "one shot", + "distribution of answers", + "registered ideal simulation", + "not IBM hardware", ): assert expected in rendered assert len(app.dataframe) >= 1 assert any(expander.label == "Technical model and QUBO" for expander in app.expander) + source = Path("apps/learning_console/renderers/guided_experiment.py").read_text( + encoding="utf-8" + ) + assert source.index('st.subheader("How was the question turned into a model?")') < source.index( + "_render_registered_comparison(view)" + ) def test_foundation_tabs_follow_registry_and_render_without_execution() -> None: @@ -200,6 +239,7 @@ def test_compact_experiment_presents_four_frozen_evidence_layers() -> None: "Replicated IBM hardware landscape", "Did real hardware preserve which circuit settings should perform better?", "Did a second, denser run reproduce the same pattern?", + "That blurring of the ideal result is called noise", ): assert expected in rendered metrics = {metric.label: metric.value for metric in app.metric} diff --git a/tests/learning/test_foundations_public.py b/tests/learning/test_foundations_public.py index 3e0b930..685f463 100644 --- a/tests/learning/test_foundations_public.py +++ b/tests/learning/test_foundations_public.py @@ -61,7 +61,21 @@ def test_no_private_or_path_leaks_in_lessons() -> None: def test_glossary_terms_present() -> None: terms = {t.term.lower() for t in load_glossary()} - for required in ("bit", "qubit", "measurement", "gate", "qaoa", "qubo"): + for required in ( + "bit", + "qubit", + "measurement", + "gate", + "qaoa", + "qubo", + "energy", + "noise", + "superposition", + "spearman rho", + "pub", + "hardware backend", + "hadamard", + ): assert required in terms From 790963969fb4bf2bf0dc72a46ff66a1b5fa3b351 Mon Sep 17 00:00:00 2001 From: Gwri Pennar <130799892+GwriPennar@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:01:38 +0100 Subject: [PATCH 2/3] fix: render beginner concept flowcharts --- .../renderers/lesson_renderer.py | 95 ++++++++++++++++++- learn/lessons/bits-and-qubits.md | 2 +- tests/learning/test_build_week_console.py | 11 +++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/apps/learning_console/renderers/lesson_renderer.py b/apps/learning_console/renderers/lesson_renderer.py index 993f894..bffa095 100644 --- a/apps/learning_console/renderers/lesson_renderer.py +++ b/apps/learning_console/renderers/lesson_renderer.py @@ -2,6 +2,9 @@ from __future__ import annotations +import html +import re + import streamlit as st from quantum_folk_lab.learning.models import ( @@ -18,6 +21,87 @@ from quantum_folk_lab.learning.validation import validate_lesson_document from renderers.directives import render_interaction, render_visual +FLOWCHART_HEADER_RE = re.compile(r"^flowchart\s+(LR|TB)$") +FLOWCHART_EDGE_RE = re.compile( + r'^([A-Za-z][A-Za-z0-9_]*)(?:\["([^"]+)"\])?\s*-->\s*' + r'([A-Za-z][A-Za-z0-9_]*)(?:\["([^"]+)"\])?$' +) + + +def _flowchart_paths(source: str) -> tuple[str, tuple[tuple[str, ...], ...]] | None: + """Parse the repository's small, validated Mermaid flowchart subset.""" + + lines = [line.strip() for line in source.splitlines() if line.strip()] + if not lines or not (header := FLOWCHART_HEADER_RE.fullmatch(lines[0])): + return None + labels: dict[str, str] = {} + children: dict[str, list[str]] = {} + targets: set[str] = set() + node_order: list[str] = [] + for line in lines[1:]: + edge = FLOWCHART_EDGE_RE.fullmatch(line) + if edge is None: + return None + source_id, source_label, target_id, target_label = edge.groups() + for node_id in (source_id, target_id): + if node_id not in node_order: + node_order.append(node_id) + if source_label: + labels[source_id] = source_label + if target_label: + labels[target_id] = target_label + children.setdefault(source_id, []).append(target_id) + targets.add(target_id) + if not node_order or any(node_id not in labels for node_id in node_order): + return None + roots = [node_id for node_id in node_order if node_id not in targets] + if not roots: + return None + paths: list[tuple[str, ...]] = [] + + def visit(node_id: str, path: tuple[str, ...]) -> bool: + if node_id in path: + return False + next_path = (*path, node_id) + next_nodes = children.get(node_id, []) + if not next_nodes: + paths.append(tuple(labels[item] for item in next_path)) + return True + return all(visit(child, next_path) for child in next_nodes) + + if not all(visit(root, ()) for root in roots): + return None + return header.group(1), tuple(paths) + + +def _flowchart_html(source: str) -> str | None: + parsed = _flowchart_paths(source) + if parsed is None: + return None + direction, paths = parsed + arrow = "→" if direction == "LR" else "↓" + rows: list[str] = [] + for path in paths: + parts: list[str] = [] + for index, label in enumerate(path): + if index: + parts.append(f'') + parts.append( + '' + f"{html.escape(label)}" + ) + flex_direction = "row" if direction == "LR" else "column" + rows.append( + f'
{"".join(parts)}
' + ) + return ( + '
' + f"{''.join(rows)}
" + ) + def render_lesson(lesson: LessonDocument, registry: LessonRegistry) -> None: errors = validate_lesson_document(lesson, registry) @@ -37,10 +121,13 @@ def render_lesson(lesson: LessonDocument, registry: LessonRegistry) -> None: if isinstance(block, MarkdownBlock): st.markdown(block.text) elif isinstance(block, MermaidBlock): - st.code(block.source, language="text") - st.caption( - f"Diagram `{block.diagram_id}` (Mermaid source; see dev/learning/MERMAID-POLICY.md)" - ) + rendered_flowchart = _flowchart_html(block.source) + if rendered_flowchart is None: + st.warning("This concept diagram could not be rendered safely.") + else: + st.markdown(rendered_flowchart, unsafe_allow_html=True) + with st.expander("View diagram source"): + st.code(block.source, language="text") elif isinstance(block, VisualDirective): render_visual(block.visual_id) elif isinstance(block, InteractionDirective): diff --git a/learn/lessons/bits-and-qubits.md b/learn/lessons/bits-and-qubits.md index 0d2778d..188ca41 100644 --- a/learn/lessons/bits-and-qubits.md +++ b/learn/lessons/bits-and-qubits.md @@ -41,7 +41,7 @@ semantic: A **classical bit** is definitely 0 or 1. A **qubit** is described by amplitudes until you measure it. -Do not tell beginners that a qubit is simply "both 0 and 1 at the same time". That hides the role of amplitudes, phase and measurement. +Rather than picturing a qubit as simply "both 0 and 1 at the same time", think of it as a state described by amplitudes. Those amplitudes carry probability and phase information, and measurement produces one classical outcome. ```mermaid flowchart LR diff --git a/tests/learning/test_build_week_console.py b/tests/learning/test_build_week_console.py index 32c4ca1..e5681af 100644 --- a/tests/learning/test_build_week_console.py +++ b/tests/learning/test_build_week_console.py @@ -190,6 +190,17 @@ def test_foundation_tabs_follow_registry_and_render_without_execution() -> None: assert not any( caption.value == "Optional detail — keep plain language first." for caption in app.caption ) + assert any(expander.label == "View diagram source" for expander in app.expander) + source = Path("apps/learning_console/renderers/lesson_renderer.py").read_text(encoding="utf-8") + assert 'aria-label="Concept flow diagram"' in source + + +def test_bits_and_qubits_uses_learner_facing_superposition_language() -> None: + lesson = Path("learn/lessons/bits-and-qubits.md").read_text(encoding="utf-8") + assert "Rather than picturing a qubit" in lesson + assert "amplitudes carry probability and phase information" in lesson + assert "measurement produces one classical outcome" in lesson + assert "Do not tell beginners" not in lesson def test_optional_qiskit_stays_button_gated() -> None: From 6f0ea0006e03ec946a1b49ab0857b17cd8640ee0 Mon Sep 17 00:00:00 2001 From: Gwri Pennar <130799892+GwriPennar@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:35:27 +0100 Subject: [PATCH 3/3] feat: turn the quantum lesson into an active journey --- README.md | 12 +- .../renderers/compact_experiment.py | 42 ++- apps/learning_console/renderers/directives.py | 106 +++++++- .../renderers/guided_experiment.py | 255 +++++++++++++++++- .../renderers/lesson_renderer.py | 6 +- .../services/build_week_service.py | 87 ++++++ learn/lessons/bits-and-qubits.md | 1 + learn/lessons/entanglement.md | 3 +- learn/lessons/gates-and-measurement.md | 14 +- learn/lessons/hadamard-and-interference.md | 3 +- learn/lessons/optimisation-intro.md | 3 +- src/quantum_folk_lab/build_week/quantum.py | 1 + src/quantum_folk_lab/learning/directives.py | 2 + src/quantum_folk_lab/learning/export.py | 9 +- src/quantum_folk_lab/learning/models.py | 1 + tests/build_week/test_quantum_adapter.py | 2 + tests/learning/test_build_week_console.py | 66 ++++- tests/learning/test_foundations_public.py | 42 +++ 18 files changed, 611 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a9b003e..23275ce 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Quantum Folk Lab is intended for: ## The learning journey 1. **Make a prediction.** Look at eight small, invented tune variants and predict how they belong - together. + together. The console records the split without revealing whether it is right. 2. **Reveal every answer.** The app checks all 256 possible groupings and shows the best answers. 3. **Compare a quantum simulation.** See how a bounded quantum method behaves when the exact result is already known. @@ -61,7 +61,9 @@ Quantum Folk Lab is intended for: from IBM quantum hardware. 6. **Ask for an explanation.** Choose a learner level and optionally ask GPT-5.6 to explain the validated evidence. -7. **Keep the record.** Download the governed result for inspection or further discussion in +7. **Check your understanding.** Answer five short questions about exact truth, measurement, + hardware noise and the role of GPT-5.6. +8. **Keep the record.** Download the governed result for inspection or further discussion in ChatGPT or Codex. The synthetic teaching example is deliberately small. Checking all 256 answers is simpler and @@ -123,8 +125,10 @@ python -m streamlit run apps/learning_console/app.py ### What success looks like -Your browser should open the Quantum Folk Lab Learning Console. Select **Experiments**, begin with -**Start here · Guided experiment**, make a prediction, and press **Reveal all 256 answers**. +Your browser should open the Quantum Folk Lab Learning Console. Judges and first-time learners +should select **Experiments**, begin with **Start here · Guided experiment**, make a prediction, +press **Reveal all 256 answers**, and follow exact truth → simulation → hardware → explanation → +exit check. Then visit **Real folk data & IBM results** to follow the same exact-first method using committed real-data and hardware evidence. Use **Foundations** when you want the concepts explained from the diff --git a/apps/learning_console/renderers/compact_experiment.py b/apps/learning_console/renderers/compact_experiment.py index 14eefc9..155220e 100644 --- a/apps/learning_console/renderers/compact_experiment.py +++ b/apps/learning_console/renderers/compact_experiment.py @@ -42,6 +42,19 @@ def render_compact_experiment() -> None: "folk-tune families, check all sixteen combinations exactly, then compare simulation " "with governed IBM hardware evidence." ) + st.info( + "You learned the method using a small invented eight-choice problem. This separate " + "registered experiment applies the same exact-first comparison method to a smaller " + "four-choice problem that was executed on IBM quantum hardware." + ) + with st.expander("What stays the same—and what changes"): + st.markdown( + "**Stays the same:** define binary choices, calculate exact truth, inspect quantum " + "measurements, and compare their distribution with the reference.\n\n" + "**Changes:** the problem instance, number of variables, execution environment and " + "presence of physical hardware noise. These are not measurements from the synthetic " + "eight-choice circuit." + ) st.caption( "EXP-010A · compact real-data formulation · four families · two choices each · " "sixteen valid combinations" @@ -56,6 +69,9 @@ def render_compact_experiment() -> None: optimum = exact["optimum_bitstrings"][0] st.markdown("## First: what is the exact answer?") + with st.container(border=True): + st.markdown("### ✓ EXACT CLASSICAL REFERENCE") + st.caption("Complete enumeration supplies the answer used to judge every quantum result.") st.markdown("**Which combination is best when every possibility is checked?**") st.write( "Before looking at a simulator or quantum computer, the application checks all 16 " @@ -64,13 +80,16 @@ def render_compact_experiment() -> None: ) left, middle, right = st.columns(3) left.metric("Exact optimum", optimum) - middle.metric("Mapped R2 state", "01100110") + middle.metric("Equivalent earlier encoding", "01100110") right.metric("States checked", "16 / 16") st.success( f"Exact enumeration is authoritative. The minimum energy is {exact['minimum_energy']:.15f}." ) st.markdown("## Ideal quantum simulation") + with st.container(border=True): + st.markdown("### ◫ IDEAL OR REGISTERED SIMULATION") + st.caption("Committed simulated measurement evidence; not physical hardware.") st.markdown("**Does the ideal quantum circuit concentrate on the better choices?**") st.write( "R measures improvement over uniform sampling: higher positive values mean the circuit " @@ -90,6 +109,9 @@ def render_compact_experiment() -> None: ) st.markdown("## First IBM hardware validation") + with st.container(border=True): + st.markdown("### ◉ RECORDED IBM HARDWARE") + st.caption("Recorded physical-device evidence, subject to hardware imperfections.") st.caption( "Real quantum hardware is physically imperfect: gates and measurements sometimes " "introduce small errors. That blurring of the ideal result is called noise. Noise is " @@ -140,26 +162,26 @@ def render_compact_experiment() -> None: { "bit": "y0", "family": "Blackbird", - "0 maps to": "R2 pair 10", - "1 maps to": "R2 pair 01", + "0 maps to": "earlier pair 10", + "1 maps to": "earlier pair 01", }, { "bit": "y1", "family": "Bold Deserter", - "0 maps to": "R2 pair 10", - "1 maps to": "R2 pair 01", + "0 maps to": "earlier pair 10", + "1 maps to": "earlier pair 01", }, { "bit": "y2", "family": "Catherine Tyrrell", - "0 maps to": "R2 pair 10", - "1 maps to": "R2 pair 01", + "0 maps to": "earlier pair 10", + "1 maps to": "earlier pair 01", }, { "bit": "y3", "family": "The Merry Old Woman", - "0 maps to": "R2 pair 10", - "1 maps to": "R2 pair 01", + "0 maps to": "earlier pair 10", + "1 maps to": "earlier pair 01", }, ] st.dataframe(choices, width="stretch", hide_index=True) @@ -181,7 +203,7 @@ def render_compact_experiment() -> None: ) st.markdown("## Technical evidence") - with st.expander("Encoding equivalence and earlier R2 comparison"): + with st.expander("Encoding equivalence and earlier technical comparison"): st.markdown((EXPERIMENT_ROOT / "R2-COMPARISON.md").read_text(encoding="utf-8")) with st.expander("Frozen QAOA report"): st.markdown((EXPERIMENT_ROOT / "QAOA-REPORT.md").read_text(encoding="utf-8")) diff --git a/apps/learning_console/renderers/directives.py b/apps/learning_console/renderers/directives.py index d291dab..48caabf 100644 --- a/apps/learning_console/renderers/directives.py +++ b/apps/learning_console/renderers/directives.py @@ -8,16 +8,104 @@ def render_visual(visual_id: str) -> None: + if visual_id == "bit-vs-qubit": + probability = st.slider( + "Chance of measuring 1", + min_value=0, + max_value=100, + value=50, + step=5, + key="foundations-qubit-probability", + help="The state also has phase information, which this probability view cannot show.", + ) + st.bar_chart( + {"Measured outcome": ["0", "1"], "Probability": [100 - probability, probability]}, + x="Measured outcome", + y="Probability", + horizontal=True, + ) + st.caption( + "Takeaway: amplitudes determine measurement probabilities, while phase affects how " + "later operations interfere. A qubit is not simply ‘both values at once’." + ) + return + if visual_id == "hadamard-probability-split": + shots = st.select_slider( + "Illustrated shot count", + options=[8, 32, 128, 512], + value=32, + key="foundations-shot-count", + ) + zero_count = shots // 2 + rows = [ + {"Outcome": "0", "Count": zero_count}, + {"Outcome": "1", "Count": shots - zero_count}, + ] + st.bar_chart(rows, x="Outcome", y="Count", horizontal=True) + st.caption( + "Takeaway: each shot produces one bit; repeated shots build an estimated distribution." + ) + return + if visual_id == "ideal-vs-noisy": + noise = st.slider( + "Illustrative hardware noise", + min_value=0, + max_value=20, + value=8, + step=2, + key="foundations-noise-level", + help="A teaching illustration, not a model of a particular device.", + ) + rows = [ + {"Evidence": "Ideal simulator", "Expected answer": 80, "Other answers": 20}, + { + "Evidence": "Illustrative hardware", + "Expected answer": 80 - noise, + "Other answers": 20 + noise, + }, + ] + st.bar_chart(rows, x="Evidence", y=["Expected answer", "Other answers"]) + st.caption( + "Takeaway: noise can blur a distribution, so hardware is compared with an exact " + "reference rather than treated as truth by itself." + ) + return + if visual_id == "z-phase-reveal": + st.markdown("**Same immediate probabilities:** 50% `0`, 50% `1` ") + st.markdown( + "**Different relative phase:** later gates can make the amplitudes add or cancel." + ) + st.caption("Takeaway: probability alone does not describe phase or interference.") + return + if visual_id == "double-h-interference": + st.bar_chart( + {"Measured outcome": ["0", "1"], "Probability": [100, 0]}, + x="Measured outcome", + y="Probability", + horizontal=True, + ) + st.caption( + "Takeaway: two Hadamard gates can interfere back to the definite starting state." + ) + return + if visual_id == "bell-correlation": + st.bar_chart( + {"Joint outcome": ["00", "01", "10", "11"], "Probability": [50, 0, 0, 50]}, + x="Joint outcome", + y="Probability", + ) + st.caption("Takeaway: the pair is correlated even though either result alone is uncertain.") + return + if visual_id == "x-gate-visual": + st.bar_chart( + {"Input": ["0", "1"], "After X": [1, 0]}, + x="Input", + y="After X", + ) + st.caption("Takeaway: X swaps the computational-basis states 0 and 1.") + return captions = { - "bit-vs-qubit": "Classical bit: definite 0 or 1. Qubit: amplitudes until measurement.", - "hadamard-probability-split": "After H on |0⟩, Theory predicts equal P(0) and P(1).", - "z-phase-reveal": ( - "A Z gate can change phase without changing computational-basis probabilities." - ), - "double-h-interference": "H then H can return to |0⟩ because amplitudes interfere.", - "bell-correlation": "Bell outcomes favour 00 and 11; each bit alone looks random.", - "circuit-thumbnail": "Circuit sketch placeholder — use EXP-001 for full circuit diagrams.", - "x-gate-visual": "X gate swaps |0⟩ and |1⟩.", + "circuit-thumbnail": "Circuit journey: prepare → apply a gate → measure → record one bit.", } st.info(captions.get(visual_id, f"Visual: {visual_id}")) diff --git a/apps/learning_console/renderers/guided_experiment.py b/apps/learning_console/renderers/guided_experiment.py index 8ce1415..580ee71 100644 --- a/apps/learning_console/renderers/guided_experiment.py +++ b/apps/learning_console/renderers/guided_experiment.py @@ -3,7 +3,15 @@ from __future__ import annotations import streamlit as st -from services.build_week_service import GuidedExperimentView, execute_quick_qiskit +from services.build_week_service import ( + VARIANT_DISPLAY_NAMES, + GuidedExperimentView, + PartitionView, + execute_quick_qiskit, + partition_from_indices, + partitions_are_equivalent, + summarise_quick_qiskit, +) from quantum_folk_lab.build_week import LearnerLevel, explain_result @@ -14,6 +22,123 @@ } +def _evidence_identity(label: str, icon: str, authority: str) -> None: + with st.container(border=True): + st.markdown(f"### {icon} {label}") + st.caption(authority) + + +def _display_evidence_pairs(view: GuidedExperimentView) -> list[dict[str, object]]: + display_by_id = dict(zip(view.result.tune_ordering, VARIANT_DISPLAY_NAMES, strict=True)) + return [ + { + "left variant": display_by_id[str(pair["left_tune"])], + "right variant": display_by_id[str(pair["right_tune"])], + "interval similarity": pair["interval_similarity"], + "contour similarity": pair["contour_similarity"], + "rhythm similarity": pair["rhythm_similarity"], + "combined similarity": pair["combined_similarity"], + } + for pair in view.result.evidence_summary["pairs"] + ] + + +def _render_partition(title: str, partition: PartitionView) -> None: + st.markdown(f"**{title}**") + left, right = st.columns(2) + left.markdown("**One family**") + left.write("\n".join(f"- {name}" for name in partition.first_group)) + right.markdown("**The complementary family**") + right.write("\n".join(f"- {name}" for name in partition.second_group)) + + +def _render_exit_check() -> None: + st.subheader("Check what you now understand") + st.write("Five short questions connect the exact answer, quantum evidence and AI boundary.") + questions = ( + ( + "Why are there 256 possible groupings?", + ( + "Eight independent binary choices give 2⁸ possibilities.", + "The circuit uses 256 qubits.", + "There are 256 tune recordings.", + ), + 0, + "Eight yes-or-no assignments create 2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 = 256 states.", + ), + ( + "Why is exact classical calculation still the reference?", + ( + "It evaluates every possible answer for this small problem.", + "Classical computers are always faster.", + "It removes all modelling choices.", + ), + 0, + "Complete enumeration checks the full finite answer space, so sampled methods can " + "be judged against it.", + ), + ( + "Why does a quantum method return a distribution?", + ( + "Each shot measures one outcome, so repeated shots accumulate counts.", + "The answer changes culturally.", + "The exact result is unknown.", + ), + 0, + "A circuit measurement yields one bitstring per shot; many shots estimate its " + "outcome probabilities.", + ), + ( + "What is hardware noise?", + ( + "Errors and disturbances in physical qubits, gates and measurements.", + "Background music in the laboratory.", + "A deliberate change to the exact score.", + ), + 0, + "Noise is the collective effect of physical imperfections; one disagreement does " + "not identify a single cause.", + ), + ( + "What may GPT‑5.6 do here?", + ( + "Explain a validated evidence packet without changing it.", + "Calculate the exact optimum.", + "Authorise new hardware jobs.", + ), + 0, + "GPT‑5.6 is an optional explanation layer; validation fails closed to " + "deterministic text.", + ), + ) + with st.form("guided-exit-check"): + answers = [ + st.radio(prompt, options, index=None, key=f"guided-exit-{index}") + for index, (prompt, options, _, _) in enumerate(questions) + ] + submitted = st.form_submit_button("Check my answers") + if submitted: + st.session_state["guided_exit_submitted"] = True + if st.session_state.get("guided_exit_submitted"): + score = sum( + answer == options[correct] + for answer, (_, options, correct, _) in zip(answers, questions, strict=True) + ) + st.success(f"You answered {score} of 5 correctly.") + for answer, (prompt, options, correct, explanation) in zip(answers, questions, strict=True): + status = "Correct" if answer == options[correct] else "Review" + st.markdown(f"**{status} — {prompt}** \n{explanation}") + if st.button("Try the exit check again"): + st.session_state["guided_exit_submitted"] = False + st.rerun() + st.info( + "Quantum computers are a different way to process a scored problem, not magic. For this " + "small experiment, exact classical calculation supplies the truth. Simulation and hardware " + "can then be compared with it. The evidence is educational—not a claim of quantum " + "advantage." + ) + + def _render_landscape(view: GuidedExperimentView) -> None: landscape = view.landscape rows = landscape.chart_rows() @@ -103,6 +228,11 @@ def _render_landscape(view: GuidedExperimentView) -> None: def _render_registered_comparison(view: GuidedExperimentView) -> None: evidence = view.registered_qaoa + _evidence_identity( + "IDEAL OR REGISTERED SIMULATION", + "◫", + "Committed simulated measurement evidence; not physical hardware.", + ) st.subheader("What does the quantum method actually do?") st.write( "A quantum computer does not print one authoritative answer. The circuit is prepared " @@ -289,6 +419,11 @@ def _render_registered_comparison(view: GuidedExperimentView) -> None: def _render_evidence_hierarchy() -> None: + _evidence_identity( + "GPT‑5.6 EXPLANATION", + "◇", + "Explains a validated packet; does not calculate or change the result.", + ) st.markdown( "**Exact enumeration** \n" "↓ governs \n" @@ -315,9 +450,8 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: st.header("Can you spot the hidden split?") st.markdown( "**Eight tune variants. Two hidden families. 256 possible groupings.**\n\n" - "Before revealing the answer, inspect the musical evidence and decide which variants " - "you think belong together. You do not need to enter a formal answer — make a mental " - "prediction, then test it." + "Before revealing the answer, inspect the musical evidence and record which four variants " + "you think belong together. Choosing one family automatically defines the other." ) with st.expander("Look at the musical evidence"): @@ -326,13 +460,38 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: "variants may belong to the same hidden family. This is synthetic teaching material, " "not authentic cultural material." ) - pairs = result.evidence_summary["pairs"] + pairs = _display_evidence_pairs(view) st.dataframe(pairs, width="stretch", hide_index=True) st.caption( "An edge joins a pair when its combined synthetic similarity passes the fixed graph " "threshold. This is not authentic cultural data." ) + st.markdown("### Make and record your prediction") + selected_names = st.multiselect( + "Choose exactly four variants for one family", + VARIANT_DISPLAY_NAMES, + default=[ + VARIANT_DISPLAY_NAMES[index] + for index in st.session_state.get("build_week_prediction_indices", ()) + ], + help="The other four variants automatically form the complementary family.", + ) + selected_indices = tuple( + index for index, name in enumerate(VARIANT_DISPLAY_NAMES) if name in selected_names + ) + if len(selected_indices) == 4: + prediction = partition_from_indices(selected_indices) + st.session_state["build_week_prediction_indices"] = selected_indices + _render_partition("Your recorded prediction", prediction) + st.caption("You can revise this split until Reveal. No score or answer is shown yet.") + elif selected_names: + st.session_state.pop("build_week_prediction_indices", None) + st.warning(f"Choose exactly four variants. You currently selected {len(selected_names)}.") + else: + st.session_state.pop("build_week_prediction_indices", None) + st.caption("You may skip the prediction and still reveal the governed evidence.") + if st.button("Reveal all 256 answers", type="primary"): st.session_state["build_week_256_revealed"] = True if not st.session_state.get("build_week_256_revealed", False): @@ -351,6 +510,11 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: _render_landscape(view) st.subheader("What is the exact answer?") + _evidence_identity( + "EXACT CLASSICAL REFERENCE", + "✓", + "Complete enumeration supplies the answer used to judge every quantum result.", + ) st.write("This answer is not a prediction — the computer tried every possibility.") exact = result.exact_result left, middle, right = st.columns(3) @@ -366,6 +530,45 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: "Exhaustive enumeration is authoritative for this eight-variable fixture. The global " "bitwise complement denotes the same unlabeled partition." ) + exact_partition = partition_from_indices( + tuple( + index + for index, bit in enumerate(str(exact["canonical_complement_class"])) + if bit == "1" + ) + ) + _render_partition("Exact split in named variants", exact_partition) + st.caption( + "The displayed names follow bit positions from left to right. Family labels are " + "exchangeable and carry no inherent musical or cultural meaning." + ) + recorded_indices = st.session_state.get("build_week_prediction_indices") + if recorded_indices is None: + st.info("You skipped the prediction step. The exact journey remains complete.") + else: + learner_partition = partition_from_indices(tuple(recorded_indices)) + _render_partition("Your prediction", learner_partition) + if partitions_are_equivalent( + learner_partition.bitstring, str(exact["canonical_complement_class"]) + ): + st.success( + "Your prediction found the exact split—even if its family labels are swapped." + ) + else: + entry = next( + item + for item in view.landscape.entries + if item.assignment == learner_partition.bitstring + ) + st.info( + "Your split was a valid candidate rather than the exact minimum. Its governed " + f"energy was {entry.display_energy:.6f}. Comparing predictions is part of the " + "lesson." + ) + st.caption( + "Your family labels can be swapped, so we compare the split itself—not whether you " + "called a group A or B." + ) st.subheader("How was the question turned into a model?") st.write( @@ -404,14 +607,40 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: st.warning(f"The quick run is unavailable: {exc}") quantum = st.session_state.get("build_week_quantum") if quantum: - st.markdown("**Quick local-Qiskit result computed now**") - st.json(quantum) - st.warning( - "A best sampled optimum does not prove an optimal expectation, speedup, or " - "quantum advantage." - ) + st.markdown("**OPTIONAL LOCAL SIMULATION · computed on this machine**") + try: + summary = summarise_quick_qiskit(quantum) + except (TypeError, ValueError) as exc: + st.warning(f"The local run completed but its display summary was invalid: {exc}") + else: + first, second, third = st.columns(3) + first.metric("Total shots", f"{summary.shots:,}") + second.metric("Distinct states", str(summary.distinct_states)) + third.metric("Most frequent state", summary.most_frequent_state) + st.write( + "The most frequent state was " + f"{'an' if summary.most_frequent_is_optimum else 'not an'} " + "exact optimum. Across both exact optima, " + f"{summary.optimum_count:,} measurements " + f"({summary.optimum_probability:.2%}) landed " + "on the known answer class." + ) + rows = [item.chart_row() for item in summary.top_states] + st.bar_chart(rows, x="bitstring", y="count") + st.caption("This optional local ideal simulation is not registered IBM hardware.") + st.warning( + "A best sampled optimum does not prove an optimal expectation, speedup, or " + "quantum advantage." + ) + with st.expander("View technical run data"): + st.json(quantum) else: - st.code(view.quantum.install_command) + st.info( + "Qiskit is optional. The registered exact, simulated and recorded-hardware journey " + "above remains complete without it." + ) + with st.expander("Optional installation command"): + st.code(view.quantum.install_command) st.subheader("Can AI explain the result safely?") _render_evidence_hierarchy() label = st.selectbox("Explanation level", list(LEVEL_LABELS)) @@ -429,6 +658,8 @@ def render_guided_experiment(view: GuidedExperimentView) -> None: st.caption(f"Validated grounded explanation from {generated.model}.") st.write(explanation) + _render_exit_check() + st.subheader("Want to inspect or share the reproducibility record?") first, second = st.columns(2) first.download_button( diff --git a/apps/learning_console/renderers/lesson_renderer.py b/apps/learning_console/renderers/lesson_renderer.py index bffa095..ab9fe53 100644 --- a/apps/learning_console/renderers/lesson_renderer.py +++ b/apps/learning_console/renderers/lesson_renderer.py @@ -133,9 +133,9 @@ def render_lesson(lesson: LessonDocument, registry: LessonRegistry) -> None: elif isinstance(block, InteractionDirective): render_interaction(block.interaction_id, block.params) elif isinstance(block, DisclosureDirective): - # Disclosure directives currently carry metadata but no associated body. - # Rendering nothing is safer than presenting an empty interactive control. - pass + if block.body: + with st.expander(block.label): + st.markdown(block.body) elif isinstance(block, GlossaryDirective): st.info("Open the Glossary tab for portable definitions.") diff --git a/apps/learning_console/services/build_week_service.py b/apps/learning_console/services/build_week_service.py index 766a0c2..4043af8 100644 --- a/apps/learning_console/services/build_week_service.py +++ b/apps/learning_console/services/build_week_service.py @@ -23,6 +23,17 @@ ) from quantum_folk_lab.build_week.quantum import QuantumCapability +VARIANT_DISPLAY_NAMES = ( + "Afon · original phrase", + "Bryn · shifted phrase", + "Celyn · one-note change", + "Daran · rhythm change", + "Eira · original phrase", + "Ffion · shifted phrase", + "Glyn · inserted note", + "Haf · shortened phrase", +) + @dataclass(frozen=True) class GuidedExperimentView: @@ -62,6 +73,82 @@ def chart_row(self) -> dict[str, object]: } +@dataclass(frozen=True) +class PartitionView: + bitstring: str + first_group: tuple[str, ...] + second_group: tuple[str, ...] + + +@dataclass(frozen=True) +class QuickQiskitSummary: + shots: int + distinct_states: int + most_frequent_state: str + most_frequent_count: int + most_frequent_is_optimum: bool + optimum_count: int + optimum_probability: float + top_states: tuple[RegisteredMeasurement, ...] + + +def partition_from_indices(indices: Collection[int]) -> PartitionView: + """Translate one balanced learner choice into stable labels and a bitstring.""" + + selected = set(indices) + if len(selected) != 4 or any( + index < 0 or index >= len(VARIANT_DISPLAY_NAMES) for index in selected + ): + raise ValueError("choose exactly four distinct variants") + bitstring = "".join("1" if index in selected else "0" for index in range(8)) + return PartitionView( + bitstring=bitstring, + first_group=tuple(VARIANT_DISPLAY_NAMES[index] for index in sorted(selected)), + second_group=tuple( + name for index, name in enumerate(VARIANT_DISPLAY_NAMES) if index not in selected + ), + ) + + +def partitions_are_equivalent(candidate: str, reference: str) -> bool: + """Compare unlabeled two-family partitions, accepting a global complement.""" + + if len(candidate) != len(reference) or set(candidate + reference) - {"0", "1"}: + return False + complement = "".join("1" if bit == "0" else "0" for bit in reference) + return candidate in {reference, complement} + + +def summarise_quick_qiskit(payload: Mapping[str, Any]) -> QuickQiskitSummary: + """Validate and reduce a live local result without changing its scientific values.""" + + raw_counts = payload.get("measurement_counts") + if not isinstance(raw_counts, Mapping) or not raw_counts: + raise ValueError("local result did not contain measurement counts") + counts = {str(state): int(count) for state, count in raw_counts.items()} + if any(len(state) != 8 or set(state) - {"0", "1"} for state in counts): + raise ValueError("local result contained an invalid state label") + if any(count < 0 for count in counts.values()): + raise ValueError("local result contained a negative count") + shots = int(payload.get("shots", sum(counts.values()))) + if shots < 1 or sum(counts.values()) != shots: + raise ValueError("local measurement counts do not match the shot total") + optima = {"00001111", "11110000"} + ranked = top_registered_measurements(counts, optima, limit=min(10, len(counts))) + most_frequent = ranked[0] + optimum_count = sum(counts.get(state, 0) for state in optima) + return QuickQiskitSummary( + shots=shots, + distinct_states=len(counts), + most_frequent_state=most_frequent.bitstring, + most_frequent_count=most_frequent.count, + most_frequent_is_optimum=most_frequent.is_exact_optimum, + optimum_count=optimum_count, + optimum_probability=optimum_count / shots, + top_states=ranked, + ) + + def top_registered_measurements( measurement_counts: Mapping[str, Any], exact_optima: Collection[str], diff --git a/learn/lessons/bits-and-qubits.md b/learn/lessons/bits-and-qubits.md index 188ca41..251db7e 100644 --- a/learn/lessons/bits-and-qubits.md +++ b/learn/lessons/bits-and-qubits.md @@ -65,4 +65,5 @@ When you measure, you always get a classical 0 or 1. The probabilities are given id: optional-notation label: Show the notation level: intermediate +body: A qubit state can be written as `α|0⟩ + β|1⟩`, where `|α|² + |β|² = 1`. The squared magnitudes give measurement probabilities; relative phase affects later interference. ::: diff --git a/learn/lessons/entanglement.md b/learn/lessons/entanglement.md index 10e7846..5f579a7 100644 --- a/learn/lessons/entanglement.md +++ b/learn/lessons/entanglement.md @@ -72,6 +72,7 @@ When you are ready for hands-on practice, see the public EXP-001 quantum fundame :::disclosure id: optional-notation -label: Show the notation +label: Why correlation is not communication level: intermediate +body: The Bell state is `( |00⟩ + |11⟩ ) / √2`. Its outcomes are correlated, but neither observer can choose whether 00 or 11 occurs, so the correlation cannot transmit a controllable faster-than-light message. ::: diff --git a/learn/lessons/gates-and-measurement.md b/learn/lessons/gates-and-measurement.md index 9974fae..6deabe9 100644 --- a/learn/lessons/gates-and-measurement.md +++ b/learn/lessons/gates-and-measurement.md @@ -22,6 +22,7 @@ glossary_terms: - measurement visuals: - circuit-thumbnail + - ideal-vs-noisy interactions: - x-gate-input check_question: What does the X gate do to |0⟩? @@ -77,6 +78,17 @@ Gates are reversible on amplitudes. **Measurement** is different — it produces :::disclosure id: optional-notation -label: Show the notation +label: Why measurement matters level: intermediate +body: A gate changes amplitudes predictably, but measurement samples one classical outcome. Repeating the same preparation and measurement is how we estimate the underlying probabilities. ::: + +## Ideal simulation and physical hardware + +An ideal simulator applies the circuit without device errors. Physical qubits, gates and measurements are imperfect; **noise** is the collective name for those errors and disturbances. Noise can blur or reorder a result, but disagreement alone does not identify one cause. + +:::visual +id: ideal-vs-noisy +::: + +**What this means:** Hardware evidence must be judged against exact truth. This is comparison evidence, not an advantage claim. diff --git a/learn/lessons/hadamard-and-interference.md b/learn/lessons/hadamard-and-interference.md index b1c1553..f4da0ea 100644 --- a/learn/lessons/hadamard-and-interference.md +++ b/learn/lessons/hadamard-and-interference.md @@ -87,8 +87,9 @@ id: double-h-interference :::disclosure id: optional-maths -label: Show the maths +label: Why phase matters level: intermediate +body: Probabilities use squared amplitude magnitudes, so two states can look identical when measured immediately yet interfere differently after another gate. Relative phase controls whether amplitudes reinforce or cancel. ::: Static export note: the prediction interaction is available in the public Learning Console app; exported HTML explains the activity without claiming live widgets. diff --git a/learn/lessons/optimisation-intro.md b/learn/lessons/optimisation-intro.md index f108864..5d0fb3a 100644 --- a/learn/lessons/optimisation-intro.md +++ b/learn/lessons/optimisation-intro.md @@ -78,6 +78,7 @@ No quantum hardware is needed to understand the setup at this stage. :::disclosure id: optional-maths -label: Show a tiny score table +label: Why exact-first matters level: intermediate +body: For two binary choices, list `00`, `01`, `10` and `11`, score each, discard any invalid choices and select the best score. Complete enumeration is practical here and gives a reference for judging a heuristic. ::: diff --git a/src/quantum_folk_lab/build_week/quantum.py b/src/quantum_folk_lab/build_week/quantum.py index 59bad3c..c1c9a27 100644 --- a/src/quantum_folk_lab/build_week/quantum.py +++ b/src/quantum_folk_lab/build_week/quantum.py @@ -75,6 +75,7 @@ def run_quick_qiskit( "expected_energy": payload["expected_energy"], "expected_objective_gap": payload["expected_objective_gap"], "best_sampled_assignment": sampled["best_sampled_human_bitstring"], + "measurement_counts": payload["measurement_counts"], "optimal_complement_class_probability": sampled["optimal_complement_class_probability"], "balanced_sample_probability": sampled["balanced_sample_probability"], "shots": config["shots"], diff --git a/src/quantum_folk_lab/learning/directives.py b/src/quantum_folk_lab/learning/directives.py index b065bc4..cd9a731 100644 --- a/src/quantum_folk_lab/learning/directives.py +++ b/src/quantum_folk_lab/learning/directives.py @@ -27,6 +27,7 @@ "bell-correlation", "circuit-thumbnail", "x-gate-visual", + "ideal-vs-noisy", } ) @@ -89,6 +90,7 @@ def parse_directive_block(kind: str, body: str, line: int) -> Any: disclosure_id=disclosure_id, label=kv.get("label", "Show more"), level=kv.get("level", "intermediate"), + body=kv.get("body", "").replace("\\n", "\n").strip(), line=line, ) if kind == "glossary": diff --git a/src/quantum_folk_lab/learning/export.py b/src/quantum_folk_lab/learning/export.py index 6b224d4..92f8f31 100644 --- a/src/quantum_folk_lab/learning/export.py +++ b/src/quantum_folk_lab/learning/export.py @@ -32,7 +32,9 @@ def _render_block_markdown(block: LessonBlock) -> str: if isinstance(block, RegisteredDataDirective): return f"*[Registered data: {block.data_id}]*" if isinstance(block, DisclosureDirective): - return f"*[Optional disclosure ({block.level}): {block.label}]*" + if not block.body: + return "" + return f"
{block.label}\n\n{block.body}\n\n
" if isinstance(block, GlossaryDirective): return "*[Glossary terms — see learn/glossary.yaml]*" return "" @@ -70,6 +72,11 @@ def export_lesson_html(doc: LessonDocument) -> str: "

Interactive in Learning Console: " f"{html.escape(block.interaction_id)}

" ) + elif isinstance(block, DisclosureDirective) and block.body: + body_parts.append( + f"
{html.escape(block.label)}" + f"

{html.escape(block.body)}

" + ) return ( "" f"{html.escape(doc.metadata.title)}" diff --git a/src/quantum_folk_lab/learning/models.py b/src/quantum_folk_lab/learning/models.py index c395fed..78ac7a2 100644 --- a/src/quantum_folk_lab/learning/models.py +++ b/src/quantum_folk_lab/learning/models.py @@ -112,6 +112,7 @@ class DisclosureDirective: disclosure_id: str label: str level: str + body: str = "" line: int = 0 kind: BlockKind = BlockKind.DISCLOSURE diff --git a/tests/build_week/test_quantum_adapter.py b/tests/build_week/test_quantum_adapter.py index eb2c76c..65c5fee 100644 --- a/tests/build_week/test_quantum_adapter.py +++ b/tests/build_week/test_quantum_adapter.py @@ -25,6 +25,7 @@ def fake_runner(**kwargs: Any) -> SimpleNamespace: "optimal_complement_class_probability": 0.5, "balanced_sample_probability": 0.75, }, + "measurement_counts": {"00001111": 128, "11110000": 128}, "circuit_metrics": { "total_qiskit_circuit_width": 16, "transpiled_depth": 20, @@ -47,3 +48,4 @@ def fake_runner(**kwargs: Any) -> SimpleNamespace: assert captured["optimiser_max_iterations"] == QUICK_QAOA_MAX_ITERATIONS == 8 assert captured["initial_points"] == ((0.0, 0.0),) assert result["execution_classification"] == "current-local-qiskit-quick-run" + assert result["measurement_counts"] == {"00001111": 128, "11110000": 128} diff --git a/tests/learning/test_build_week_console.py b/tests/learning/test_build_week_console.py index e5681af..1d266cb 100644 --- a/tests/learning/test_build_week_console.py +++ b/tests/learning/test_build_week_console.py @@ -63,6 +63,36 @@ def test_registered_top_measurements_are_stable_and_non_mutating() -> None: assert counts == original +def test_prediction_and_local_summary_are_complement_aware() -> None: + path = Path("apps/learning_console/services/build_week_service.py") + spec = importlib.util.spec_from_file_location("journey_service_test", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + first = module.partition_from_indices((0, 1, 2, 3)) + complement = module.partition_from_indices((4, 5, 6, 7)) + assert first.bitstring == "11110000" + assert complement.bitstring == "00001111" + assert module.partitions_are_equivalent(first.bitstring, complement.bitstring) + with pytest.raises(ValueError, match="exactly four"): + module.partition_from_indices((0, 1, 2)) + + summary = module.summarise_quick_qiskit( + { + "shots": 100, + "measurement_counts": {"00001111": 35, "11110000": 25, "01010101": 40}, + } + ) + assert summary.shots == 100 + assert summary.distinct_states == 3 + assert summary.most_frequent_state == "01010101" + assert not summary.most_frequent_is_optimum + assert summary.optimum_count == 60 + assert summary.optimum_probability == 0.6 + + def test_guided_experiment_256_reveal_journey_requires_no_openai_key( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -85,6 +115,9 @@ def test_guided_experiment_256_reveal_journey_requires_no_openai_key( assert any( element.value == "The answer stays hidden until you reveal it." for element in app.caption ) + assert any( + widget.label == "Choose exactly four variants for one family" for widget in app.multiselect + ) assert not any( element.value == "What did every possible grouping score?" for element in app.subheader ) @@ -141,6 +174,9 @@ def test_guided_experiment_256_reveal_journey_requires_no_openai_key( "distribution of answers", "registered ideal simulation", "not IBM hardware", + "Exact split in named variants", + "Check what you now understand", + "Quantum computers are a different way to process a scored problem", ): assert expected in rendered assert len(app.dataframe) >= 1 @@ -186,13 +222,41 @@ def test_foundation_tabs_follow_registry_and_render_without_execution() -> None: "Why correlation is not communication", "Why exact-first matters", } - assert not disclosure_labels.intersection(expander.label for expander in app.expander) + assert disclosure_labels.issubset(expander.label for expander in app.expander) assert not any( caption.value == "Optional detail — keep plain language first." for caption in app.caption ) assert any(expander.label == "View diagram source" for expander in app.expander) source = Path("apps/learning_console/renderers/lesson_renderer.py").read_text(encoding="utf-8") assert 'aria-label="Concept flow diagram"' in source + directives = Path("apps/learning_console/renderers/directives.py").read_text(encoding="utf-8") + for visual in ( + "foundations-qubit-probability", + "foundations-shot-count", + "foundations-noise-level", + ): + assert visual in directives + + +def test_prediction_persists_and_compares_only_after_reveal() -> None: + app = AppTest.from_file("apps/learning_console/app.py") + app.run(timeout=30) + chooser = next( + widget + for widget in app.multiselect + if widget.label == "Choose exactly four variants for one family" + ) + names = list(chooser.options) + chooser.set_value(names[:4]).run(timeout=30) + assert tuple(app.session_state["build_week_prediction_indices"]) == (0, 1, 2, 3) + rendered_before = "\n".join( + str(item.value) for item in (*app.markdown, *app.success, *app.info) + ) + assert "Your prediction found the exact split" not in rendered_before + reveal = next(button for button in app.button if button.label == "Reveal all 256 answers") + reveal.click().run(timeout=30) + rendered_after = "\n".join(str(item.value) for item in (*app.markdown, *app.success, *app.info)) + assert "Your prediction found the exact split" in rendered_after def test_bits_and_qubits_uses_learner_facing_superposition_language() -> None: diff --git a/tests/learning/test_foundations_public.py b/tests/learning/test_foundations_public.py index 685f463..b22a55c 100644 --- a/tests/learning/test_foundations_public.py +++ b/tests/learning/test_foundations_public.py @@ -8,6 +8,8 @@ from quantum_folk_lab.learning.export import export_registry_bundle from quantum_folk_lab.learning.glossary import load_glossary +from quantum_folk_lab.learning.models import DisclosureDirective +from quantum_folk_lab.learning.parser import parse_lesson_markdown from quantum_folk_lab.learning.paths import CONTENT_ROOT, REGISTRY_PATH from quantum_folk_lab.learning.registry import clear_registry_cache, load_registry from quantum_folk_lab.learning.semantic_state import semantic_marker_html, validate_semantic_text @@ -84,6 +86,11 @@ def test_static_export(tmp_path: Path) -> None: counts = export_registry_bundle(registry, tmp_path) assert counts["markdown"] == 5 assert counts["html"] == 5 + exported = (tmp_path / "markdown" / "guided-foundations-bits_qubits.md").read_text( + encoding="utf-8" + ) + assert "Show the notation" in exported + assert "relative phase affects later interference" in exported def test_learning_package_does_not_import_streamlit() -> None: @@ -91,3 +98,38 @@ def test_learning_package_does_not_import_streamlit() -> None: source = Path(parser_mod.__file__).read_text(encoding="utf-8") assert "streamlit" not in source.lower() + + +def test_five_disclosures_have_non_empty_bodies() -> None: + registry = load_registry() + disclosures = [ + block + for entry in registry.entries + for block in registry.load_document(entry.id).blocks + if isinstance(block, DisclosureDirective) + ] + assert len(disclosures) == 5 + assert all(item.body.strip() for item in disclosures) + + +def test_empty_disclosure_parses_but_remains_fail_closed() -> None: + source = Path("learn/lessons/bits-and-qubits.md").read_text(encoding="utf-8") + body = ( + "body: A qubit state can be written as `α|0⟩ + β|1⟩`, where `|α|² + |β|² = 1`. " + "The squared magnitudes give measurement probabilities; relative phase affects later " + "interference.\n" + ) + source = source.replace( + body, + "", + ) + document = parse_lesson_markdown(source, Path("empty-disclosure.md")) + disclosure = next(block for block in document.blocks if isinstance(block, DisclosureDirective)) + assert disclosure.body == "" + + +def test_malformed_disclosure_fails_closed() -> None: + source = Path("learn/lessons/bits-and-qubits.md").read_text(encoding="utf-8") + source = source.replace("label: Show the notation", "malformed line") + with pytest.raises(ValueError, match="missing key"): + parse_lesson_markdown(source, Path("malformed-disclosure.md"))