Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -221,19 +223,16 @@ 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).

---

## 6. Next implementation steps

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.

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,39 @@ 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; }
.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;
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/components/Play.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ 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";
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: "badges", label: "🏅 Badges" },
{ id: "race", label: "⚡ Race" },
{ id: "quiz", label: "🧩 Quiz" },
{ id: "sandbox", label: "🔬 Sandbox" },
{ id: "badges", label: "🏅 Badges" },
];

export default function Play() {
Expand Down Expand Up @@ -40,8 +44,10 @@ export default function Play() {
))}
</nav>

{tab === "race" && <Race runnerOk={runnerOk} />}
{tab === "badges" && <Badges />}
{tab === "race" && <Race runnerOk={runnerOk} />}
{tab === "quiz" && <Quiz />}
{tab === "sandbox" && <Sandbox />}
{tab === "badges" && <Badges />}
</main>
);
}
228 changes: 228 additions & 0 deletions apps/web/src/components/play/Quiz.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", display: "block" }}>
{/* Grid */}
{xTicks.map((x) => (
<line key={`gx${x}`} x1={tx(x)} y1={mt} x2={tx(x)} y2={mt + ph}
stroke="var(--line)" strokeWidth={0.5} />
))}
{yFracs.map((f) => (
<line key={`gy${f}`} x1={ml} y1={ty(f * yMax)} x2={ml + pw} y2={ty(f * yMax)}
stroke="var(--line)" strokeWidth={0.5} />
))}

{/* Ideal line (dashed) */}
<path d={idealD} stroke="var(--dim)" strokeWidth={1.2} strokeDasharray="4 3" fill="none" />

{/* Actual curve */}
<path d={pathD} stroke="var(--accent)" strokeWidth={2.2} fill="none"
strokeLinejoin="round" strokeLinecap="round" />

{/* Dots */}
{pts.map((p) => (
<circle key={p.x} cx={tx(p.x)} cy={ty(p.y)} r={3} fill="var(--accent)" />
))}

{/* X-axis ticks */}
{xTicks.map((x) => (
<text key={`tx${x}`} x={tx(x)} y={H - 5} textAnchor="middle"
fontSize={9} fill="var(--dim)">{x}</text>
))}

{/* Y-axis ticks */}
{yFracs.map((f) => {
const v = f * yMax;
const label = v < 2 ? v.toFixed(1) : Math.round(v).toString();
return (
<text key={`ty${f}`} x={ml - 4} y={ty(v) + 3.5} textAnchor="end"
fontSize={9} fill="var(--dim)">{label}×</text>
);
})}

{/* Axis labels */}
<text x={ml + pw / 2} y={H} textAnchor="middle" fontSize={9.5} fill="var(--muted)">
Threads
</text>
<text transform={`translate(${ml / 2 - 2},${cy}) rotate(-90)`}
textAnchor="middle" fontSize={9.5} fill="var(--muted)">
Speedup
</text>

{/* Legend */}
<line x1={W - mr - 52} y1={mt + 6} x2={W - mr - 40} y2={mt + 6}
stroke="var(--dim)" strokeWidth={1} strokeDasharray="4 3" />
<text x={W - mr - 37} y={mt + 9.5} fontSize={9} fill="var(--dim)">ideal</text>
</svg>
);
}

// ── Quiz component ────────────────────────────────────────────────────────────
type Phase = "question" | "revealed" | "done";

export default function Quiz() {
const [qi, setQi] = useState(0);
const [selected, setSelected] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>("question");
const [answers, setAnswers] = useState<boolean[]>([]);

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 (
<div className="grid" style={{ gridTemplateColumns: "1fr" }}>
<section className="card">
<h2><span>Quiz complete</span></h2>
<div className="hero">
<div className="big" style={{ color: big }}>{score}/{total}</div>
<div className="cap">{verdict}</div>
</div>
<div className="quiz-review">
{QUESTIONS.map((qq, i) => (
<div key={qq.id} className={"quiz-review-row " + ((answers[i] ?? false) ? "hit" : "miss")}>
<span>{(answers[i] ?? false) ? "✓" : "✗"}</span>
<span className="quiz-review-label">
{qq.choices.find((c) => c.id === qq.answer)?.label}
</span>
</div>
))}
</div>
<div style={{ marginTop: 16, display: "flex", gap: 12, flexWrap: "wrap", alignItems: "center" }}>
<button className="pg-run" onClick={restart}>Play again ▸</button>
<Link className="learn-link" href="/">→ See each bottleneck live: Flagship experiments</Link>
</div>
</section>
</div>
);
}

// ── Question / Revealed screen ───────────────────────────────────────────
const correctChoice = q.choices.find((c) => c.id === q.answer);

return (
<div className="grid" style={{ gridTemplateColumns: "1fr" }}>
<section className="card">
{/* Progress bar */}
<div className="quiz-progress">
{QUESTIONS.map((_, i) => (
<span key={i} className={"quiz-dot" + (i < qi ? " done" : i === qi ? " on" : "")} />
))}
<span className="quiz-count">Q {qi + 1} / {total}</span>
{qi > 0 && (
<span className="quiz-score">Score so far: {score}/{qi}</span>
)}
</div>

<h2><span>{q.prompt}</span></h2>
<p className="learn-blurb" style={{ margin: "0 0 14px" }}>{q.context}</p>

<div className="quiz-layout">
{/* Scaling chart */}
<div className="quiz-chart">
<CurveChart pts={q.curve} />
</div>

{/* Answer choices */}
<div className="quiz-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 (
<button key={c.id} className={cls}
disabled={phase === "revealed"}
onClick={() => pick(c.id)}>
{c.label}
</button>
);
})}
</div>
</div>

{/* Explanation after reveal */}
{phase === "revealed" && (
<div className={"eb " + (hit ? "exp" : "why")} style={{ marginTop: 14 }}>
<div className="t">
{hit ? "✓ Correct — " : "✗ Not quite — the answer is "}
<b>{correctChoice?.label}</b>
</div>
<div className="body">{q.explain}</div>
</div>
)}

{phase === "revealed" && (
<div style={{ marginTop: 12, textAlign: "right" }}>
<button className="pg-run" onClick={advance}>
{qi < total - 1 ? "Next question →" : "See results →"}
</button>
</div>
)}
</section>
</div>
);
}
Loading
Loading