From 8552e38d90762bf3dbc5c3c44c06322f4e2ba6a6 Mon Sep 17 00:00:00 2001 From: nikos Date: Tue, 9 Jun 2026 17:37:17 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=EF=BB=BFfeat(play):=20guess-the-bottleneck?= =?UTF-8?q?=20quiz=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six synthetic scaling curves โ€” perfect, sync contention, Amdahl, false sharing, bandwidth saturation, load imbalance โ€” each with 4 multiple-choice answers, inline SVG chart (dashed ideal + actual curve), reveal with explanation, and a final score screen. Quiz answers feed the Oracle / On Fire badge streak via recordPredict. No gateway or backend needed. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/globals.css | 21 ++ apps/web/src/components/Play.tsx | 3 + apps/web/src/components/play/Quiz.tsx | 228 ++++++++++++++++++ .../web/src/components/play/quiz-questions.ts | 146 +++++++++++ 4 files changed, 398 insertions(+) create mode 100644 apps/web/src/components/play/Quiz.tsx create mode 100644 apps/web/src/components/play/quiz-questions.ts diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index fbac01e..34a540a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -261,6 +261,27 @@ footer.note code { font-family: var(--mono); color: var(--muted); background: #0 .ref-cat-tag { font-size: 11px; color: var(--dim); } .ref-sig { margin: 0 0 12px; } +/* ---- Guess-the-Bottleneck quiz ---- */ +.quiz-progress { display: flex; align-items: center; gap: 6px; margin-bottom: 14px; } +.quiz-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--line); flex: none; transition: .2s; } +.quiz-dot.on { background: var(--accent); box-shadow: 0 0 8px rgba(76,201,240,.5); } +.quiz-dot.done { background: var(--good); } +.quiz-count { font-size: 11px; color: var(--dim); margin-left: 6px; } +.quiz-score { font-size: 11px; color: var(--muted); margin-left: auto; } +.quiz-layout { display: grid; grid-template-columns: 1fr 230px; gap: 14px; align-items: start; margin-bottom: 4px; } +@media (max-width: 780px) { .quiz-layout { grid-template-columns: 1fr; } } +.quiz-chart { border: 1px solid var(--line); border-radius: 10px; padding: 10px 6px 4px; background: var(--panel2); } +.quiz-choices { display: flex; flex-direction: column; gap: 7px; } +.quiz-choice { border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 10px 12px; border-radius: 9px; cursor: pointer; font-size: 12.5px; font-family: var(--sans); text-align: left; transition: .12s; line-height: 1.4; } +.quiz-choice:hover:not(:disabled) { border-color: var(--accent); background: rgba(76,201,240,.07); } +.quiz-choice.correct { border-color: var(--good) !important; background: rgba(61,220,151,.1) !important; color: var(--good) !important; font-weight: 600; } +.quiz-choice.wrong { border-color: var(--bad) !important; background: rgba(255,93,115,.08) !important; color: var(--bad) !important; } +.quiz-review { display: flex; flex-direction: column; gap: 5px; margin-top: 12px; } +.quiz-review-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 7px 10px; border-radius: 8px; } +.quiz-review-row.hit { background: rgba(61,220,151,.08); color: var(--good); } +.quiz-review-row.miss { background: rgba(255,93,115,.08); color: var(--bad); } +.quiz-review-label { color: var(--text); } + /* ---- Badge toast (global, fixed bottom-right) ---- */ .badge-toast { position: fixed; bottom: 24px; right: 24px; z-index: 9999; diff --git a/apps/web/src/components/Play.tsx b/apps/web/src/components/Play.tsx index 0582bd7..0e5003d 100644 --- a/apps/web/src/components/Play.tsx +++ b/apps/web/src/components/Play.tsx @@ -4,10 +4,12 @@ import { useEffect, useState } from "react"; import { health } from "../lib/runner"; import Race from "./play/Race"; import Badges from "./play/Badges"; +import Quiz from "./play/Quiz"; // The "Play" hub โ€” the fun-first corner. Tabs are added here as each game lands. const TABS = [ { id: "race", label: "โšก Race" }, + { id: "quiz", label: "๐Ÿงฉ Quiz" }, { id: "badges", label: "๐Ÿ… Badges" }, ]; @@ -41,6 +43,7 @@ export default function Play() { {tab === "race" && } + {tab === "quiz" && } {tab === "badges" && } ); diff --git a/apps/web/src/components/play/Quiz.tsx b/apps/web/src/components/play/Quiz.tsx new file mode 100644 index 0000000..1aac8ef --- /dev/null +++ b/apps/web/src/components/play/Quiz.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { recordPredict } from "../../lib/badges"; +import { QUESTIONS } from "./quiz-questions"; + +// โ”€โ”€ Inline SVG scaling chart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Draws the actual curve (accent color) + dashed ideal line, no canvas needed. +function CurveChart({ pts }: { pts: { x: number; y: number }[] }) { + const W = 380, H = 196; + const ml = 38, mr = 8, mt = 10, mb = 26; + const pw = W - ml - mr; + const ph = H - mt - mb; + + const peakY = Math.max(...pts.map((p) => p.y)); + const yMax = Math.max(3, peakY * 1.28); + const idealEnd = Math.min(24, yMax); // clip ideal line to chart top + + const tx = (x: number) => ml + ((x - 1) / 23) * pw; + const ty = (y: number) => mt + (1 - y / yMax) * ph; + + const pathD = pts.map((p, i) => `${i === 0 ? "M" : "L"}${tx(p.x).toFixed(1)},${ty(p.y).toFixed(1)}`).join(" "); + const idealD = `M${tx(1).toFixed(1)},${ty(1).toFixed(1)} L${tx(idealEnd).toFixed(1)},${ty(idealEnd).toFixed(1)}`; + + const xTicks = [1, 4, 8, 12, 16, 20, 24] as const; + const yFracs = [0.25, 0.5, 0.75, 1.0]; + const cy = mt + ph / 2; + + return ( + + {/* Grid */} + {xTicks.map((x) => ( + + ))} + {yFracs.map((f) => ( + + ))} + + {/* Ideal line (dashed) */} + + + {/* Actual curve */} + + + {/* Dots */} + {pts.map((p) => ( + + ))} + + {/* X-axis ticks */} + {xTicks.map((x) => ( + {x} + ))} + + {/* Y-axis ticks */} + {yFracs.map((f) => { + const v = f * yMax; + const label = v < 2 ? v.toFixed(1) : Math.round(v).toString(); + return ( + {label}ร— + ); + })} + + {/* Axis labels */} + + Threads + + + Speedup + + + {/* Legend */} + + ideal + + ); +} + +// โ”€โ”€ Quiz component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +type Phase = "question" | "revealed" | "done"; + +export default function Quiz() { + const [qi, setQi] = useState(0); + const [selected, setSelected] = useState(null); + const [phase, setPhase] = useState("question"); + const [answers, setAnswers] = useState([]); + + const q = QUESTIONS[qi]; + const total = QUESTIONS.length; + const hit = selected === q.answer; + const score = answers.filter(Boolean).length; + + function pick(id: string) { + if (phase !== "question") return; + const isHit = id === q.answer; + setSelected(id); + setPhase("revealed"); + setAnswers((prev) => [...prev, isHit]); + recordPredict(isHit); // feeds Oracle / On Fire badge streak + } + + function advance() { + if (qi < total - 1) { + setQi((i) => i + 1); + setSelected(null); + setPhase("question"); + } else { + setPhase("done"); + } + } + + function restart() { + setQi(0); setSelected(null); setPhase("question"); setAnswers([]); + } + + // โ”€โ”€ Done screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (phase === "done") { + const pct = Math.round((score / total) * 100); + const verdict = + pct === 100 ? "Perfect โ€” you know every bottleneck cold. ๐Ÿ†" : + pct >= 83 ? "Excellent bottleneck detective. ๐Ÿ”ฌ" : + pct >= 66 ? "Solid โ€” the curve shapes are sinking in. โœ“" : + pct >= 50 ? "Half right โ€” a few more Flagship runs will sharpen the eye." : + "Good start โ€” run the Flagship experiments, then retry."; + const big = pct >= 66 ? "var(--good)" : pct >= 50 ? "var(--warn)" : "var(--bad)"; + + return ( +
+
+

Quiz complete

+
+
{score}/{total}
+
{verdict}
+
+
+ {QUESTIONS.map((qq, i) => ( +
+ {(answers[i] ?? false) ? "โœ“" : "โœ—"} + + {qq.choices.find((c) => c.id === qq.answer)?.label} + +
+ ))} +
+
+ + โ†’ See each bottleneck live: Flagship experiments +
+
+
+ ); + } + + // โ”€โ”€ Question / Revealed screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const correctChoice = q.choices.find((c) => c.id === q.answer); + + return ( +
+
+ {/* Progress bar */} +
+ {QUESTIONS.map((_, i) => ( + + ))} + Q {qi + 1} / {total} + {qi > 0 && ( + Score so far: {score}/{qi} + )} +
+ +

{q.prompt}

+

{q.context}

+ +
+ {/* Scaling chart */} +
+ +
+ + {/* Answer choices */} +
+ {q.choices.map((c) => { + let cls = "quiz-choice"; + if (phase === "revealed") { + if (c.id === q.answer) cls += " correct"; + else if (c.id === selected) cls += " wrong"; + } + return ( + + ); + })} +
+
+ + {/* Explanation after reveal */} + {phase === "revealed" && ( +
+
+ {hit ? "โœ“ Correct โ€” " : "โœ— Not quite โ€” the answer is "} + {correctChoice?.label} +
+
{q.explain}
+
+ )} + + {phase === "revealed" && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/play/quiz-questions.ts b/apps/web/src/components/play/quiz-questions.ts new file mode 100644 index 0000000..df0d296 --- /dev/null +++ b/apps/web/src/components/play/quiz-questions.ts @@ -0,0 +1,146 @@ +// Guess-the-Bottleneck: six archetypal scaling curves the student must identify. +// Each entry is self-contained: curve data + choices + answer + explanation. +// No gateway needed โ€” all data is pre-generated from analytical models. + +export type Choice = { id: string; label: string }; +export type Question = { + id: string; + prompt: string; + context: string; + curve: { x: number; y: number }[]; + choices: Choice[]; + answer: string; + explain: string; +}; + +const T = [1, 2, 4, 8, 12, 16, 20, 24] as const; + +// Amdahl's law S(p) = 1 / (s + (1-s)/p) +const ahl = (s: number): { x: number; y: number }[] => + T.map((p) => ({ x: p, y: +(1 / (s + (1 - s) / p)).toFixed(2) })); + +export const QUESTIONS: Question[] = [ + // โ”€โ”€ 1. Near-perfect โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: "perfect", + prompt: "What does this scaling curve tell you?", + context: + "24 independent tasks โ€” each thread works on a completely separate slice of a large array and never touches any shared data.", + curve: T.map((p) => ({ x: p, y: +(p * (1 - 0.002 * (p - 1))).toFixed(2) })), + choices: [ + { id: "perfect", label: "Near-perfect (embarrassingly parallel)" }, + { id: "amdahl", label: "Serial fraction โ€” Amdahl's law" }, + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "sync", label: "Synchronization contention" }, + ], + answer: "perfect", + explain: + "Near-linear speedup โ€” every core does proportional useful work, with only minor scheduling overhead. No shared state, no synchronization, and the working set still fits in cache. This is the gold standard every parallel programmer aims for.", + }, + + // โ”€โ”€ 2. Synchronization contention โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: "sync", + prompt: "What does this scaling curve tell you?", + context: + "All 24 threads increment a single shared global counter on every loop iteration, each acquisition guarded by #pragma omp atomic.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.04 }, { x: 4, y: 0.96 }, + { x: 8, y: 0.84 }, { x: 12, y: 0.78 }, { x: 16, y: 0.74 }, + { x: 20, y: 0.71 }, { x: 24, y: 0.69 }, + ], + choices: [ + { id: "falsesh", label: "False sharing" }, + { id: "sync", label: "Synchronization contention" }, + { id: "imbal", label: "Load imbalance" }, + { id: "bw", label: "Memory bandwidth saturation" }, + ], + answer: "sync", + explain: + "Every core must queue for the lock on every iteration โ€” queuing time grows faster than the work done. 24 threads on one atomic counter ends up slower than 1 thread alone. The fix is a reduction: each thread keeps a private partial sum and OpenMP combines them once at the end.", + }, + + // โ”€โ”€ 3. Amdahl's law โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: "amdahl", + prompt: "What does this scaling curve tell you?", + context: + "The kernel has a setup phase that always runs on a single thread (file I/O + initialisation), then a fully parallel compute section. Past ~8 threads the speedup barely improves.", + curve: ahl(0.10), + choices: [ + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "amdahl", label: "Serial fraction โ€” Amdahl's law" }, + { id: "imbal", label: "Load imbalance" }, + { id: "perfect", label: "Near-perfect scaling" }, + ], + answer: "amdahl", + explain: + "The curve follows Amdahl's law with ~10% serial code: S(N) = 1 โˆ• (0.10 + 0.90โˆ•N). No matter how many cores you add, the serial 10% dominates โ€” the theoretical ceiling is 10ร— regardless of core count. The only fix is to parallelize or eliminate the serial section.", + }, + + // โ”€โ”€ 4. False sharing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: "falsesh", + prompt: "What does this scaling curve tell you?", + context: + "Each thread writes only to its own counter โ€” no logical sharing โ€” but all counters are packed next to each other inside one 64-byte cache line.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.62 }, { x: 4, y: 2.18 }, + { x: 8, y: 2.48 }, { x: 12, y: 2.31 }, { x: 16, y: 2.09 }, + { x: 20, y: 1.91 }, { x: 24, y: 1.74 }, + ], + choices: [ + { id: "amdahl", label: "Serial fraction โ€” Amdahl's law" }, + { id: "falsesh", label: "False sharing" }, + { id: "perfect", label: "Near-perfect scaling" }, + { id: "sync", label: "Synchronization contention" }, + ], + answer: "falsesh", + explain: + "Initial gains look promising, but every write by one core invalidates the same cache line in every other core's L1 cache (MESI protocol). As thread count grows, this coherence traffic escalates and the speedup reverses. The fingerprint is a peak followed by degradation. Fix: pad each counter to its own cache line.", + }, + + // โ”€โ”€ 5. Memory bandwidth saturation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: "bw", + prompt: "What does this scaling curve tell you?", + context: + "A memory-intensive stencil kernel. Each thread streams through a large array โ€” the working set is far too big to fit in any level of cache.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.88 }, { x: 4, y: 3.45 }, + { x: 8, y: 3.78 }, { x: 12, y: 3.90 }, { x: 16, y: 3.94 }, + { x: 20, y: 3.97 }, { x: 24, y: 3.98 }, + ], + choices: [ + { id: "imbal", label: "Load imbalance" }, + { id: "sync", label: "Synchronization contention" }, + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "amdahl", label: "Serial fraction โ€” Amdahl's law" }, + ], + answer: "bw", + explain: + "DRAM bandwidth is shared across all cores โ€” once ~3โ€“4 cores saturate the memory bus, extra cores idle waiting for data. The flat ceiling at ~4ร— is the bandwidth wall, not a code quality issue. The Roofline model predicts exactly this. Fix: tiling or prefetching to reuse data from cache before adding more threads.", + }, + + // โ”€โ”€ 6. Load imbalance โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: "imbal", + prompt: "What does this scaling curve tell you?", + context: + "A triangular loop: iteration i costs O(iยฒ) work. The N iterations are divided into equal-sized static chunks โ€” the last threads get the heaviest iterations.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.71 }, { x: 4, y: 2.85 }, + { x: 8, y: 4.22 }, { x: 12, y: 5.10 }, { x: 16, y: 5.52 }, + { x: 20, y: 5.63 }, { x: 24, y: 5.65 }, + ], + choices: [ + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "falsesh", label: "False sharing" }, + { id: "amdahl", label: "Serial fraction โ€” Amdahl's law" }, + { id: "imbal", label: "Load imbalance" }, + ], + answer: "imbal", + explain: + "With schedule(static), the last threads receive the heaviest iterations. Every other thread finishes and idles while the unlucky ones grind through the expensive tail โ€” total runtime equals the slowest thread. Fix: schedule(dynamic) or schedule(guided) lets idle threads steal remaining work and balances the load.", + }, +]; From a1bac5997f42066c7292a9d10f4a0b22b34b1d70 Mon Sep 17 00:00:00 2001 From: nikos Date: Tue, 9 Jun 2026 17:43:22 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=EF=BB=BFfeat(play):=20Amdahl=20&=20Gustafs?= =?UTF-8?q?on=20interactive=20sandbox=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag a serial-fraction slider (0-100%) and watch the theoretical speedup ceiling update live on a log-scale SVG chart. Supports three modes: Amdahl's law (fixed work), Gustafson's law (scaled work), and both overlaid for direct comparison. Shows the ceiling asymptote, threads-to-90%-of-ceiling metric, and a plain-English explanation that updates as the slider moves. No gateway or backend required. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/globals.css | 12 + apps/web/src/components/Play.tsx | 15 +- apps/web/src/components/play/Sandbox.tsx | 297 +++++++++++++++++++++++ 3 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/play/Sandbox.tsx diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 34a540a..5ff4886 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -261,6 +261,18 @@ footer.note code { font-family: var(--mono); color: var(--muted); background: #0 .ref-cat-tag { font-size: 11px; color: var(--dim); } .ref-sig { margin: 0 0 12px; } +/* ---- Amdahl / Gustafson sandbox ---- */ +.sbox-controls { display: flex; flex-direction: column; gap: 14px; } +.sbox-slider-block { display: flex; flex-direction: column; gap: 6px; } +.sbox-slider-label { display: flex; justify-content: space-between; align-items: baseline; font-size: 12.5px; color: var(--muted); } +.sbox-slider-ends { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--dim); margin-top: 2px; } +.sbox-row2 { display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-start; } +.sbox-group { display: flex; flex-direction: column; gap: 6px; } +.sbox-group-label { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .8px; font-weight: 600; } +.sbox-chart { border: 1px solid var(--line); border-radius: 10px; padding: 10px 6px 4px; background: var(--panel2); } +.sbox-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-top: 8px; align-items: center; } +.sbox-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; vertical-align: middle; } + /* ---- Guess-the-Bottleneck quiz ---- */ .quiz-progress { display: flex; align-items: center; gap: 6px; margin-bottom: 14px; } .quiz-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--line); flex: none; transition: .2s; } diff --git a/apps/web/src/components/Play.tsx b/apps/web/src/components/Play.tsx index 0e5003d..ae49784 100644 --- a/apps/web/src/components/Play.tsx +++ b/apps/web/src/components/Play.tsx @@ -5,12 +5,14 @@ import { health } from "../lib/runner"; import Race from "./play/Race"; import Badges from "./play/Badges"; import Quiz from "./play/Quiz"; +import Sandbox from "./play/Sandbox"; // The "Play" hub โ€” the fun-first corner. Tabs are added here as each game lands. const TABS = [ - { id: "race", label: "โšก Race" }, - { id: "quiz", label: "๐Ÿงฉ Quiz" }, - { id: "badges", label: "๐Ÿ… Badges" }, + { id: "race", label: "โšก Race" }, + { id: "quiz", label: "๐Ÿงฉ Quiz" }, + { id: "sandbox", label: "๐Ÿ”ฌ Sandbox" }, + { id: "badges", label: "๐Ÿ… Badges" }, ]; export default function Play() { @@ -42,9 +44,10 @@ export default function Play() { ))} - {tab === "race" && } - {tab === "quiz" && } - {tab === "badges" && } + {tab === "race" && } + {tab === "quiz" && } + {tab === "sandbox" && } + {tab === "badges" && } ); } diff --git a/apps/web/src/components/play/Sandbox.tsx b/apps/web/src/components/play/Sandbox.tsx new file mode 100644 index 0000000..d997a5b --- /dev/null +++ b/apps/web/src/components/play/Sandbox.tsx @@ -0,0 +1,297 @@ +"use client"; + +// Amdahl & Gustafson sandbox โ€” pure-client, no gateway needed. +// Drag the serial-fraction slider; the chart and metrics update instantly. + +import { useState } from "react"; + +// โ”€โ”€ Math helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const amdahl = (s: number, p: number): number => s > 0 ? 1 / (s + (1 - s) / p) : p; +const gustafson = (s: number, p: number): number => p * (1 - s) + s; + +function niceStep(max: number): number { + const raw = max / 5; + const exp = Math.floor(Math.log10(Math.max(raw, 1e-9))); + const frac = raw / Math.pow(10, exp); + const nice = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10; + return nice * Math.pow(10, exp); +} + +function fmt1(v: number): string { + if (!isFinite(v)) return "โˆž"; + return v >= 10000 ? `${(v / 1000).toFixed(0)}k` : v >= 100 ? v.toFixed(0) : v >= 10 ? v.toFixed(1) : v.toFixed(2); +} + +type Mode = "amdahl" | "gustafson" | "both"; +const MAX_P_OPTS = [8, 24, 64, 256] as const; + +// โ”€โ”€ SVG chart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function SandboxChart({ serial, maxP, mode }: { serial: number; maxP: number; mode: Mode }) { + const W = 500, H = 230; + const ml = 44, mr = 14, mt = 14, mb = 28; + const pw = W - ml - mr, ph = H - mt - mb; + const logMax = Math.log(maxP); + + // X: log-linear Y: linear + const tx = (p: number) => ml + (Math.log(Math.max(p, 1)) / logMax) * pw; + const ty = (y: number) => mt + (1 - Math.min(y, yMax * 1.01) / yMax) * ph; + + // Y axis ceiling + const aMax = serial > 0 ? 1 / serial : maxP; + const gMax = maxP * (1 - serial) + serial; + + let yMax: number; + if (mode === "gustafson") yMax = gMax * 1.08; + else if (mode === "both") yMax = Math.min(Math.max(aMax * 1.5, 4), maxP); + else yMax = Math.min(aMax * 1.15, maxP * 1.06); + + // 100-point smooth curves in log space + const curve = (fn: (p: number) => number) => + Array.from({ length: 100 }, (_, i) => { + const p = Math.exp((logMax * i) / 99); + return { x: p, y: fn(p) }; + }); + + const aPts = curve((p) => amdahl(serial, p)); + const gPts = curve((p) => gustafson(serial, p)); + + const toSVGPath = (pts: { x: number; y: number }[]) => + pts.map((p, i) => `${i === 0 ? "M" : "L"}${tx(p.x).toFixed(1)},${ty(p.y).toFixed(1)}`).join(" "); + + // Ideal line (y=p), clipped to chart top + const idealTop = Math.min(maxP, yMax); + const idealPath = `M${tx(1).toFixed(1)},${ty(1).toFixed(1)} L${tx(idealTop).toFixed(1)},${ty(idealTop).toFixed(1)}`; + + // X ticks: powers of 2 + const xTicks: number[] = []; + for (let p = 1; p <= maxP; p *= 2) xTicks.push(p); + if (xTicks[xTicks.length - 1] < maxP) xTicks.push(maxP); + + // Y ticks + const step = niceStep(yMax); + const yTicks: number[] = []; + for (let y = step; y < yMax * 0.99; y += step) yTicks.push(y); + + // Amdahl asymptote (only if it fits inside the chart) + const showAsymp = serial > 0 && aMax < yMax * 0.97 && (mode === "amdahl" || mode === "both"); + + const cy = mt + ph / 2; + + return ( + + {/* X grid */} + {xTicks.map((x) => ( + + ))} + {/* Y grid */} + {yTicks.map((y) => ( + + ))} + + {/* Ideal (dashed) */} + + + {/* Amdahl asymptote */} + {showAsymp && ( + <> + + + ceiling = {fmt1(aMax)}ร— + + + )} + + {/* Curves */} + {(mode === "amdahl" || mode === "both") && ( + + )} + {(mode === "gustafson" || mode === "both") && ( + + )} + + {/* X labels */} + {xTicks.map((x) => ( + {x} + ))} + + {/* Y labels */} + {yTicks.map((y) => ( + {y}ร— + ))} + + {/* Axis titles */} + + Threads (log scale) + + + Speedup + + + ); +} + +// โ”€โ”€ Main component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export default function Sandbox() { + const [serial, setSerial] = useState(0.20); + const [maxP, setMaxP] = useState(24); + const [mode, setMode] = useState("amdahl"); + + const aMax = serial > 0 ? 1 / serial : Infinity; + const spAmdahl = amdahl(serial, maxP); + const spGust = gustafson(serial, maxP); + const effAmdahl = spAmdahl / maxP; + + // Threads needed to reach 90% of Amdahl ceiling: n = 9*(1-s)/s + const nFor90 = serial > 0 ? Math.ceil(9 * (1 - serial) / serial) : Infinity; + + function summaryText(): string { + if (serial === 0) + return "Zero serial code โ€” every thread does only useful work. Speedup equals core count: doubling cores halves the runtime forever. Real programs always have some serial fraction, but this is the target."; + if (serial >= 0.5) + return "More than half the program is serial. The ceiling is โ‰ค 2ร— no matter how many cores you throw at it. More hardware cannot fix a serial bottleneck โ€” you must parallelize the code first."; + if (aMax < 4) + return `With ${(serial*100).toFixed(0)}% serial code the ceiling is only ${fmt1(aMax)}ร—. Adding a hundred more cores won't help. This is a code problem, not a hardware problem.`; + if (nFor90 <= maxP) + return `You need ${nFor90} threads to reach 90% of the ${fmt1(aMax)}ร— ceiling โ€” achievable on your ${maxP}-core system. You're spending hardware budget well.`; + return `The ${fmt1(aMax)}ร— ceiling is attractive, but you'd need ${fmt1(nFor90)} threads to reach 90% of it. On ${maxP} cores you realise ${fmt1(spAmdahl)}ร— (${(effAmdahl*100).toFixed(0)}% of ceiling). Reducing the serial fraction from ${(serial*100).toFixed(0)}% to even ${(serial*50).toFixed(1)}% would double the ceiling.`; + } + + const MODES: { id: Mode; label: string }[] = [ + { id: "amdahl", label: "Amdahl" }, + { id: "gustafson", label: "Gustafson" }, + { id: "both", label: "Both" }, + ]; + + const sColor = serial > 0.2 ? "var(--bad)" : serial > 0.05 ? "var(--warn)" : "var(--good)"; + + return ( +
+
+

Amdahl & Gustafson โ€” interactive sandbox

+

+ Set the serial fraction and watch the theoretical speedup ceiling update. Switch to Gustafson's law to see how scaling the problem with the hardware changes the picture entirely. +

+ + {/* โ”€โ”€ Controls โ”€โ”€ */} +
+ {/* Serial fraction slider */} +
+
+ Serial fraction + + {serial === 0 ? "0%" : serial < 0.01 ? `${(serial * 100).toFixed(1)}%` : `${(serial * 100).toFixed(0)}%`} + +
+ setSerial(parseFloat(e.target.value))} /> +
+ 0% โ€” perfect scaling100% โ€” fully serial +
+
+ +
+ {/* Thread count presets */} +
+ Show up to +
+ {MAX_P_OPTS.map((p) => ( + + ))} +
+
+ {/* Law selector */} +
+ Law +
+ {MODES.map((m) => ( + + ))} +
+
+
+
+ + {/* โ”€โ”€ Chart + legend โ”€โ”€ */} +
+
+ +
+
+ {(mode === "amdahl" || mode === "both") && ( + Amdahl's law (fixed work) + )} + {(mode === "gustafson" || mode === "both") && ( + Gustafson's law (scaled work) + )} + ideal (linear) + {serial > 0 && (mode === "amdahl" || mode === "both") && ( + ceiling (1/s) + )} +
+
+ + {/* โ”€โ”€ Metrics โ”€โ”€ */} +
+ {(mode === "amdahl" || mode === "both") && ( + <> +
+ Amdahl ceiling โ€” 1 โˆ• s + 12 ? "good" : aMax > 4 ? "warn" : "bad")}> + {!isFinite(aMax) ? "โˆž (perfect)" : `${fmt1(aMax)}ร—`} + +
+
+ Speedup at {maxP} threads (Amdahl) + 0.7 ? "good" : effAmdahl > 0.4 ? "warn" : "bad")}> + {fmt1(spAmdahl)}ร— ยท {(effAmdahl * 100).toFixed(0)}% efficiency + +
+ {serial > 0 && ( +
+ Threads to reach 90% of ceiling + + {nFor90 > 999999 ? "millions" : nFor90 > 9999 ? `~${(nFor90 / 1000).toFixed(0)} k` : `${nFor90}`} + +
+ )} + + )} + {(mode === "gustafson" || mode === "both") && ( +
+ Speedup at {maxP} threads (Gustafson) + {fmt1(spGust)}ร— +
+ )} +
+ + {/* โ”€โ”€ Plain-English explanation โ”€โ”€ */} +
+
+ {mode === "gustafson" ? "Gustafson's view" : mode === "both" ? "The key difference" : "What this means"} +
+
+ {mode === "gustafson" + ? <>Gustafson's law says: if you scale the problem size with the hardware (more threads โ†’ bigger dataset, same wall time), speedup grows near-linearly regardless of the serial fraction. It doesn't contradict Amdahl โ€” they answer different questions. Amdahl: “how much faster for the same work?” Gustafson: “how much more work in the same time?” + : mode === "both" + ? <>The gap between the curves is the scalability premium from growing the problem with hardware. When work is fixed (Amdahl), the serial fraction is an iron ceiling. When the problem scales (Gustafson), even significant serial fractions allow near-linear throughput growth โ€” which is why clusters with thousands of nodes are useful in practice. + : summaryText() + } +
+
+
+
+ ); +} From 13e49870031217009d5cf8dfdcfc5e19a6669a23 Mon Sep 17 00:00:00 2001 From: nikos Date: Tue, 9 Jun 2026 19:19:36 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=EF=BB=BFfix(explain):=20guard=20against=20?= =?UTF-8?q?undefined=20peak=20in=20falseSharing=20and=20mpiHalo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript ! non-null assertion on r.peak was only a compile-time hint. At runtime, if the result arrives before the model effect fires (e.g. when switching experiments), peak is undefined and peak.speedup throws a TypeError. - Explain.falseSharing: peak = r.peak (drop !); guard if (padded || !peak) - Explain.mpiHalo: same pattern; if (weak || !peak) covers the edge case - r.coh ?? 0 in falseSharing: safe fallback instead of r.coh! Co-Authored-By: Claude Sonnet 4.6 --- packages/explain/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/explain/src/index.ts b/packages/explain/src/index.ts index d0e1c97..82f6ce2 100644 --- a/packages/explain/src/index.ts +++ b/packages/explain/src/index.ts @@ -10,8 +10,8 @@ type ExplainFn = (r: ExperimentResult) => Explanation; export const Explain: Record = { falseSharing(r) { - const c = r.current, peak = r.peak!, padded = r.params.padded; - if (padded) + const c = r.current, peak = r.peak, padded = r.params.padded; + if (padded || !peak) return { sev: "info", what: `Padded: speedup reaches ${fmt(c.speedup, 1)}ร— at ${c.x} threads โ€” close to the ideal ${c.x}ร—.`, @@ -22,7 +22,7 @@ export const Explain: Record = { return { sev: "critical", what: `Speedup peaks at only ${fmt(peak.speedup, 1)}ร— around ${peak.x} threads, then falls to ${fmt(c.speedup, 1)}ร— at ${c.x}. Adding threads makes it slower.`, - why: `All ${c.x} counters share one 64-byte cache line. Every increment invalidates that line in every other core, so it ping-pongs across cores over the bus โ€” coherence traffic grows ~linearly with thread count and now dominates (${fmt(r.coh!, 0)} ms of the ${fmt(c.time, 0)} ms runtime).`, + why: `All ${c.x} counters share one 64-byte cache line. Every increment invalidates that line in every other core, so it ping-pongs across cores over the bus โ€” coherence traffic grows ~linearly with thread count and now dominates (${fmt(r.coh ?? 0, 0)} ms of the ${fmt(c.time, 0)} ms runtime).`, how: `Pad/align each thread's counter to its own cache line (64-byte alignment, or per-thread padded struct). Flip "Pad to cache line".`, exp: `Near-linear speedup restored (~${c.x}ร— at ${c.x} threads); coherence stall โ†’ ~0%.`, }; @@ -82,8 +82,8 @@ export const Explain: Record = { }; }, mpiHalo(r) { - const c = r.current, weak = r.params.mode === "weak", commPct = r.idlePct ?? 0, peak = r.peak!; - if (weak) + const c = r.current, weak = r.params.mode === "weak", commPct = r.idlePct ?? 0, peak = r.peak; + if (weak || !peak) return { sev: c.efficiency > 0.7 ? "info" : "warn", what: `Weak scaling: as the grid grows with the ranks, scaled speedup reaches ${fmt(c.speedup, 1)}ร— on ${c.x} ranks at ${fmt(c.efficiency * 100, 0)}% efficiency โ€” close to ideal.`, From 48c694572c679a4a8b4deb1e77b27be64cdd938c Mon Sep 17 00:00:00 2001 From: nikos Date: Wed, 17 Jun 2026 21:24:38 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=EF=BB=BFdocs:=20final=20README/PROGRESS/ro?= =?UTF-8?q?admap=20pass=20=E2=80=94=20Play=20hub=20now=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all three docs to reflect the full Play hub (Race + Quiz + Sandbox + Badges) now that feat/badges-pr30 is merged in: - README: Play bullet updated to describe all four tabs - PROGRESS.md: Engagement row shows all 4 games done; Play bullet updated; status summary marks engagement layer fully complete; next-steps item 1 (merge badges-pr30) removed as now done - docs/02-roadmap.md: P5 status note changed from "partially landed" to "gamification layer complete" with all four games listed Co-Authored-By: Claude Sonnet 4.6 --- PROGRESS.md | 17 ++++++++--------- README.md | 8 +++++--- docs/02-roadmap.md | 11 +++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 63ddd93..aadd6c6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,7 +21,7 @@ This file is the resume point: what's done, how to run it, what changed, and nex | **Cloud** | FastAPI gateway + Redis/Arq job queue; both producers async submit/poll; stdlib runner retired | โœ… done | | **P3** | MPI execution (`{kind:"mpi"}` โ†’ `vhpce-mpi`) + "MPI Halo Exchange" experiment (strong vs weak) | โœ… done | | **P4** | GPU/CUDA (`{kind:"cuda"}` โ†’ `vhpce-cuda`, RTX 5060) + GPU Occupancy, Coalescing, Divergence, Atomics | โœ… done | -| **Engagement** | Intro, Start (predict-before-you-run), Compare, Play/Race, Reference Essentials + ~190 entries, Playground predict + diagnostics + thread-count explorer, predict streaks + badge system | โœ… done (quiz + Amdahl/Gustafson sandbox in next PR) | +| **Engagement** | Intro, Start (predict-before-you-run), Compare, Play (Race + Quiz + Sandbox + Badges), Reference Essentials + ~190 entries, Playground predict + diagnostics + thread-count explorer, badge + streak system | โœ… done | | **Rollout** | LICENSE (AGPL-3.0), rewritten README, SETUP.md, CI (GitHub Actions), devcontainer (Codespaces) | โœ… done | | P5 | Engineering domain modules, classrooms/LMS, K8s autoscaling, true PMU counters | โฌœ not started | @@ -105,8 +105,10 @@ Playground code) go through one async backend; **Model mode is the only offline bottleneck; **thread-count explorer** (per-point breakdown); opt-in **cachegrind** cache-miss profiling (D1/LLd miss rates). - **Play** (`/play`) โ€” the fun-first corner: **โšก Race** pits two kernel variants head-to-head - (predict the winner, then run both). More games land here over time. Badge/streak system rewards - correct predictions across Start, Playground, and Flagship experiments. + (predict the winner, then run both); **๐Ÿงฉ Quiz** tests your bottleneck instincts; + **๐Ÿ”ฌ Sandbox** lets you explore Amdahl's and Gustafson's laws interactively; **๐Ÿ… Badges** + rewards correct predictions across Start, Playground, and Flagship experiments and tracks + prediction streaks. - **Cloud gateway** (`services/api`) โ€” **FastAPI + Redis + Arq**, run via `docker-compose`. The Flagship's Measured mode and the Playground both **submit jobs and poll**: `POST /api/jobs` (`{kind:"bench"|"code", ...}`) โ†’ `GET /api/jobs/{id}`. The Arq worker (`max_jobs=1`, serialized for @@ -221,8 +223,8 @@ vhpce/ guide), devcontainer (Codespaces), CI. - โœ… **Engagement layer** โ€” Intro, Start (predict), Compare, Play/Race, badges/streaks system, Playground predict + diagnostics + thread-count explorer, Reference Essentials filter. -- **Next PR (`feat/badges-pr30`)** โ€” guess-the-bottleneck quiz (#31) + Amdahl & Gustafson - interactive sandbox (#32) + `explain` bugfix (undefined peak in falseSharing/mpiHalo). +- โœ… **Engagement Play layer** โ€” Race, Quiz, Amdahl/Gustafson Sandbox, Badges/streaks; `explain` + bugfix (undefined peak guard in falseSharing/mpiHalo). --- @@ -230,10 +232,7 @@ vhpce/ The core product and engagement layer are complete. Candidate next steps: -1. **Merge `feat/badges-pr30`** โ€” guess-the-bottleneck quiz, Amdahl & Gustafson interactive - sandbox, and a bugfix for `explain.falseSharing`/`mpiHalo` when `r.peak` is undefined. - -2. **Engineering-domain modules (P5).** FEM/FDTD/CFD/stencil/FFT mini-labs with interactive +1. **Engineering-domain modules (P5).** FEM/FDTD/CFD/stencil/FFT mini-labs with interactive domain decomposition and convergence plots, built on the same `ProfileResult` seam and the gateway job kinds (OpenMP/MPI/CUDA) already in place. diff --git a/README.md b/README.md index ae90649..2259150 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,11 @@ shared data contract, and two interchangeable data sources (an in-browser physic run a thread-count sweep in a locked-down Docker sandbox. Results come with plain-language diagnostics that route you to the Flagship experiment explaining the bottleneck, a thread-count explorer, and optional cachegrind cache-miss profiling. -- **[Play](apps/web/src/components/Play.tsx)** (`/play`) โ€” the fun-first corner: race two kernel - variants head-to-head, predicting the winner before the real numbers come in (more games land - here over time). +- **[Play](apps/web/src/components/Play.tsx)** (`/play`) โ€” the fun-first corner: **โšก Race** + pits two kernel variants head-to-head (predict the winner, then run both); **๐Ÿงฉ Quiz** tests + your bottleneck instincts; **๐Ÿ”ฌ Sandbox** lets you explore Amdahl's and Gustafson's laws + interactively; **๐Ÿ… Badges** rewards correct predictions across Start, Playground, and + Flagship experiments and tracks your prediction streaks. Everything above shares one data contract โ€” `ProfileResult`/`ExperimentResult` (`@vhpce/profile-schema`) โ€” so the **model** (runs entirely in your browser) and **measured** diff --git a/docs/02-roadmap.md b/docs/02-roadmap.md index f4a088b..b520072 100644 --- a/docs/02-roadmap.md +++ b/docs/02-roadmap.md @@ -137,12 +137,11 @@ the Slurm bridge for real clusters. These bare-metal/cloud nodes also unlock **t **DoD:** an instructor runs a class through a "speed up this code" challenge backed by real cluster execution. -**Status (2026-06-17) โ€” gamification layer partially landed.** The `/play` hub, head-to-head -kernel race, predict-before-you-run mechanic (across Start / Playground / Flagship), and a -badge + streak system for correct predictions are built and merged. A guess-the-bottleneck quiz -and an Amdahl & Gustafson interactive sandbox are in the next PR (`feat/badges-pr30`). -Engineering domain modules (FEM/FDTD/CFD), classrooms/LMS, K8s autoscaling, and PMU counters -remain future work. +**Status (2026-06-17) โ€” gamification layer complete.** The `/play` hub ships four games: +**โšก Race** (head-to-head kernel comparison), **๐Ÿงฉ Quiz** (guess-the-bottleneck), +**๐Ÿ”ฌ Sandbox** (Amdahl & Gustafson interactive explorer), and **๐Ÿ… Badges** (predict-before- +you-run reward system across Start / Playground / Flagship). Engineering domain modules +(FEM/FDTD/CFD), classrooms/LMS, K8s autoscaling, and PMU counters remain future work. ---