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'{arrow}')
+ 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"))