mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-06 17:49:07 -05:00
Merge feat/staffml-visual-questions into dev
Visual-question infrastructure + practice-page layout restructure, shipped together because the redesign exists specifically to make room for visuals in the reading flow (context -> diagram -> ask). Three commits:fb2a57e12- Schema (Pydantic + LinkML) gains optional Visual model with path-traversal guard and a11y-required alt. Legacy exporter passes metadata to the summary bundle; vault build --legacy-json mirrors SVG assets from interviews/vault/visuals/<track>/ to interviews/staffml/public/question-visuals/<track>/. QuestionVisual React component renders the diagram between scenario and answer with graceful fallback.cb0b7ea30- Practice-page layout restructure per 4-reviewer UX pass (Emma beginner, David power user, Chip Huyen practitioner; Soumith's agent bounced). Left column is now the read-answer-reveal flow; right column is the tools panel. Four safeguards folded in by default: (1) sticky Your-task callout that stays visible while the user scrolls to type (David's long-scenario fix), (2) HardwareRef defaultOpen + NapkinCalc defaultClosed per Chip's consulted-vs-invoked distinction, (3) submit-gradient guard -- a Think-longer? confirm fires on (elapsed<15s && chars<50) reveals, and self-calibrates off once the user has demonstrated normal deliberation (Chip's prediction was the subtle headline risk), (4) a Stuck?-ask-the-Interviewer nudge below the textarea (Emma's scaffolding request). modelAnswerRef + scrollIntoView keeps the post-reveal comparison on-screen.1898fe8c9- First visual exemplar: cloud Ring AllReduce on 4 ranks. SVG follows .claude/rules/svg-style.md, YAML wires the visual block and pairs it with a question asking for the full AllReduce time. AUTHORING.md documents when a visual earns its place, the workflow, accessibility requirements, and anti-patterns.
This commit is contained in:
5
interviews/staffml/.gitignore
vendored
5
interviews/staffml/.gitignore
vendored
@@ -9,3 +9,8 @@ node_modules/
|
||||
worker/.wrangler/
|
||||
worker/wrangler.toml.bak
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Build output — `vault build --legacy-json` mirrors SVG assets from
|
||||
# interviews/vault/visuals/ into public/question-visuals/. The vault
|
||||
# is the source of truth; don't commit the copy.
|
||||
public/question-visuals/
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Target, CheckCircle2, XCircle, Terminal, SkipForward,
|
||||
Calculator, SlidersHorizontal, X
|
||||
Calculator, SlidersHorizontal, X, Sparkles, Lightbulb, Clock
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import HardwareRef from "@/components/HardwareRef";
|
||||
@@ -37,6 +37,7 @@ import { Calendar, ArrowLeft, Flag, LinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { buildReportUrl } from "@/lib/issue-url";
|
||||
import QuestionFeedback from "@/components/QuestionFeedback";
|
||||
import QuestionVisual from "@/components/QuestionVisual";
|
||||
import { track } from "@/lib/analytics";
|
||||
|
||||
/**
|
||||
@@ -169,6 +170,22 @@ function PracticePage() {
|
||||
const [dailyDone, setDailyDone] = useState(false);
|
||||
const [sourceTopic, setSourceTopic] = useState<{ id: string; name: string } | null>(null);
|
||||
const [showStarGate, setShowStarGate] = useState(false);
|
||||
// Submit-gradient safeguard (per Chip Huyen's UX review).
|
||||
// Rationale: with the restructured layout, the Reveal button sits
|
||||
// directly below the answer textarea — removing the eye-travel
|
||||
// friction that previously enforced deliberation. Without this
|
||||
// guard, users type two sentences and reveal. We detect
|
||||
// low-effort reveals (<15s elapsed AND <50 chars typed) and
|
||||
// surface a one-time "Think longer?" confirm so the UX doesn't
|
||||
// silently erode learning depth. Once confirmed (or once the user
|
||||
// has demonstrated normal deliberation on any question), the guard
|
||||
// stays dismissed for the session.
|
||||
const [thinkConfirmOpen, setThinkConfirmOpen] = useState(false);
|
||||
const deliberationCalibrated = useRef(false);
|
||||
// Post-reveal model-answer anchor so we can scroll-into-view when
|
||||
// showAnswer flips. Prevents the model answer from rendering below
|
||||
// the fold on long scenarios.
|
||||
const modelAnswerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Chain tracking — update when current question changes
|
||||
const chainInfo = current ? getChainForQuestion(current.id) : null;
|
||||
@@ -368,7 +385,33 @@ function PracticePage() {
|
||||
setRubricItems([]);
|
||||
}, [pool, current, showAnswer]);
|
||||
|
||||
const handleReveal = () => {
|
||||
// Submit-gradient guard: intercept reveals that look like
|
||||
// "didn't really try." The restructured layout puts the Reveal
|
||||
// button directly below the textarea, so the eye-travel friction
|
||||
// that previously enforced deliberation is gone. Without a
|
||||
// deliberate pause, median time-before-reveal drops and
|
||||
// self-assessed scores inflate without actual learning. We surface
|
||||
// a one-time confirm, and once the user has demonstrated normal
|
||||
// deliberation on any question (≥20s OR ≥80 chars typed), the
|
||||
// guard calibrates itself off for the session. `force=true` skips
|
||||
// the check — used by the confirm dialog's "Reveal anyway" button.
|
||||
const handleReveal = (force: boolean = false) => {
|
||||
if (!force && !deliberationCalibrated.current) {
|
||||
const elapsedMs = Date.now() - questionShownAt.current;
|
||||
const charsTyped = userAnswer.trim().length;
|
||||
if (elapsedMs < 15000 && charsTyped < 50) {
|
||||
setThinkConfirmOpen(true);
|
||||
track({ type: 'think_guard_triggered' });
|
||||
return;
|
||||
}
|
||||
// Any reveal that passes the threshold marks this user as
|
||||
// deliberating normally — don't pester them again this session.
|
||||
if (elapsedMs >= 20000 || charsTyped >= 80) {
|
||||
deliberationCalibrated.current = true;
|
||||
}
|
||||
}
|
||||
setThinkConfirmOpen(false);
|
||||
|
||||
// Star gate check
|
||||
if (shouldShowGate()) {
|
||||
setShowStarGate(true);
|
||||
@@ -400,6 +443,15 @@ function PracticePage() {
|
||||
}
|
||||
|
||||
setShowAnswer(true);
|
||||
// After the reveal state commits, smoothly scroll the model
|
||||
// answer into view. This is the primary pedagogical comparison
|
||||
// moment — the user's answer should still be visible above, the
|
||||
// model answer enters from below. Without this scroll, on long
|
||||
// scenarios the model answer renders below the fold and the
|
||||
// user has to hunt for it.
|
||||
setTimeout(() => {
|
||||
modelAnswerRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 50);
|
||||
|
||||
// Track answer reveal with response time and napkin grade
|
||||
if (current) {
|
||||
@@ -829,132 +881,374 @@ function PracticePage() {
|
||||
<span className="text-sm">Filters</span>
|
||||
</button>
|
||||
|
||||
{/* Main content */}
|
||||
{/*
|
||||
Main content — restructured 2026-04-24 based on UX feedback from
|
||||
Emma (beginner), David (power user), and Chip Huyen (practitioner).
|
||||
Key shifts:
|
||||
- LEFT column = problem + answer + reveal + post-reveal (one
|
||||
vertical reading/typing flow). No more 500px eye-travel from
|
||||
scenario to textarea.
|
||||
- RIGHT column = tools panel (Ask Interviewer, Hardware ref,
|
||||
Napkin calc) — each a collapsible card so non-AI users don't
|
||||
see wasted pixels shouting "use the AI."
|
||||
- Your-task callout is STICKY at the top of the left scroll
|
||||
container: scroll the scenario away, the question stays
|
||||
visible (David's fix for long-scenario context loss).
|
||||
- Submit-gradient safeguard: reveals triggered with <15s
|
||||
elapsed AND <50 chars typed pop a "Think longer?" confirm.
|
||||
Once the user demonstrates normal deliberation, the guard
|
||||
self-calibrates off for the session (Chip's warning about
|
||||
the new layout making premature reveals too frictionless).
|
||||
- Post-reveal model answer scrolls into view via modelAnswerRef
|
||||
so it doesn't render below the fold.
|
||||
- "Stuck? Ask the Interviewer →" nudge beneath the textarea
|
||||
so beginners discover the AI without it dominating the page
|
||||
(Emma's scaffolding request + Chip's "don't advertise
|
||||
unused features" rule).
|
||||
*/}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{current ? (
|
||||
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
||||
{/* Question */}
|
||||
{/* ── LEFT: problem + answer + reveal + post-reveal ── */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-8 lg:px-12 py-10">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={current.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<LevelBadge level={current.level} />
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-border bg-surface">
|
||||
{current.competency_area}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-accentBlue/20 bg-accentBlue/5">
|
||||
{current.zone}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase">
|
||||
{current.track}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{/* Copy link */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/practice?q=${current.id}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
showToast({ type: 'badge', title: 'Link copied', description: 'Share this question with others' });
|
||||
}}
|
||||
// p-2 -m-2 extends the tap target to 30x30px on mobile
|
||||
// while keeping the visual icon at 14x14 (Apple HIG
|
||||
// "extend hit target without changing appearance").
|
||||
className="text-textMuted hover:text-textSecondary transition-colors p-2 -m-2"
|
||||
aria-label="Copy question link"
|
||||
title="Copy question link"
|
||||
>
|
||||
<LinkIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{/* Report issue */}
|
||||
<a
|
||||
href={buildReportUrl(current)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-textSecondary hover:text-accentRed transition-colors"
|
||||
title="Report an issue with this question"
|
||||
>
|
||||
<Flag className="w-3.5 h-3.5" /> Report
|
||||
</a>
|
||||
</div>
|
||||
{chainInfo && !showAnswer && (
|
||||
<div className="mb-3">
|
||||
<ChainBadge
|
||||
chainId={chainInfo.chainId}
|
||||
position={chainInfo.position + 1} /* 1-indexed for display */
|
||||
total={chainInfo.total}
|
||||
onClick={() => setChainPreviewOpen((v) => !v)}
|
||||
/>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={current.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
>
|
||||
{/* Badges + copy-link + report */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<LevelBadge level={current.level} />
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-border bg-surface">
|
||||
{current.competency_area}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-accentBlue/20 bg-accentBlue/5">
|
||||
{current.zone}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase">
|
||||
{current.track}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/practice?q=${current.id}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
showToast({ type: 'badge', title: 'Link copied', description: 'Share this question with others' });
|
||||
}}
|
||||
className="text-textMuted hover:text-textSecondary transition-colors p-2 -m-2"
|
||||
aria-label="Copy question link"
|
||||
title="Copy question link"
|
||||
>
|
||||
<LinkIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a
|
||||
href={buildReportUrl(current)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-textSecondary hover:text-accentRed transition-colors"
|
||||
title="Report an issue with this question"
|
||||
>
|
||||
<Flag className="w-3.5 h-3.5" /> Report
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-textPrimary mb-6 tracking-tight">
|
||||
{current.title}
|
||||
</h2>
|
||||
<div className="prose max-w-none">
|
||||
{current.scenario ? (
|
||||
<p className="text-textSecondary leading-relaxed text-base">
|
||||
{cleanScenario(current.scenario)}
|
||||
</p>
|
||||
) : (
|
||||
<ScenarioSkeleton />
|
||||
|
||||
{/* Chain badge (pre-reveal only) */}
|
||||
{chainInfo && !showAnswer && (
|
||||
<div className="mb-3">
|
||||
<ChainBadge
|
||||
chainId={chainInfo.chainId}
|
||||
position={chainInfo.position + 1}
|
||||
total={chainInfo.total}
|
||||
onClick={() => setChainPreviewOpen((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Your-task callout. Three states, in priority order:
|
||||
1. `current.question` present → render it as the
|
||||
primary ask (post-backfill state).
|
||||
2. Scenario already ends with `?` → render nothing
|
||||
extra; the interrogative is already in the prose.
|
||||
3. Neither → render a zone-aware inferred fallback
|
||||
so the reader at least knows the shape of the
|
||||
expected answer. Marked "inferred" so it's
|
||||
clearly distinct from an authored question.
|
||||
Placed AFTER the scenario because the scenario sets
|
||||
context that the question refers to ("given the
|
||||
above…"). A prompt above an unread scenario would
|
||||
be meaningless.
|
||||
*/}
|
||||
{current.question ? (
|
||||
<div className="mt-5 p-4 rounded-lg border-l-4 border-accentBlue bg-accentBlue/5">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Target className="w-3.5 h-3.5 text-accentBlue" />
|
||||
<span className="text-[10px] font-mono text-accentBlue uppercase tracking-widest">Your task</span>
|
||||
</div>
|
||||
<p className="text-textPrimary leading-relaxed text-base font-medium">
|
||||
{current.question}
|
||||
</p>
|
||||
</div>
|
||||
) : current.scenario && !current.scenario.trim().endsWith("?") ? (
|
||||
<div className="mt-5 p-4 rounded-lg border border-dashed border-border bg-surface/40">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Target className="w-3.5 h-3.5 text-textTertiary" />
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest">
|
||||
Your task <span className="text-textMuted normal-case">(inferred)</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-textSecondary leading-relaxed text-sm">
|
||||
{inferTaskPrompt(current.zone, current.bloom_level)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{chainInfo && !showAnswer && chainPreviewOpen && (
|
||||
<div className="mt-6" data-testid="chain-preview-prereveal">
|
||||
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
|
||||
</div>
|
||||
)}
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-textPrimary mb-6 tracking-tight">
|
||||
{current.title}
|
||||
</h2>
|
||||
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{/*
|
||||
STICKY Your-task callout. Pins to the top of the
|
||||
scroll container so the question stays visible
|
||||
while the user scrolls to read more scenario or
|
||||
compare their answer with the model. Negative
|
||||
horizontal margins + re-added padding make the
|
||||
sticky bar span the full left-column width so the
|
||||
background covers scrolling text cleanly.
|
||||
*/}
|
||||
<div className="sticky top-0 z-20 -mx-8 lg:-mx-12 px-8 lg:px-12 py-3 bg-background border-b border-border">
|
||||
{current.question ? (
|
||||
<div className="p-4 rounded-lg border-l-4 border-accentBlue bg-accentBlue/5">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Target className="w-3.5 h-3.5 text-accentBlue" />
|
||||
<span className="text-[10px] font-mono text-accentBlue uppercase tracking-widest">Your task</span>
|
||||
</div>
|
||||
<p className="text-textPrimary leading-relaxed text-base font-medium">
|
||||
{current.question}
|
||||
</p>
|
||||
</div>
|
||||
) : current.scenario && !current.scenario.trim().endsWith("?") ? (
|
||||
<div className="p-4 rounded-lg border border-dashed border-border bg-surface/40">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Target className="w-3.5 h-3.5 text-textTertiary" />
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest">
|
||||
Your task <span className="text-textMuted normal-case">(inferred)</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-textSecondary leading-relaxed text-sm">
|
||||
{inferTaskPrompt(current.zone, current.bloom_level)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Scenario ends with ?; no callout needed but reserve minimal spacing */
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-textTertiary uppercase tracking-widest">
|
||||
<Target className="w-3.5 h-3.5" />
|
||||
Your task — see scenario below
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scenario prose */}
|
||||
<div className="prose max-w-none mt-6">
|
||||
{current.scenario ? (
|
||||
<p className="text-textSecondary leading-relaxed text-base">
|
||||
{cleanScenario(current.scenario)}
|
||||
</p>
|
||||
) : (
|
||||
<ScenarioSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual diagram (optional) */}
|
||||
{current.visual && (
|
||||
<QuestionVisual track={current.track} visual={current.visual} />
|
||||
)}
|
||||
|
||||
{/* Pre-reveal chain sibling preview (toggle from ChainBadge) */}
|
||||
{chainInfo && !showAnswer && chainPreviewOpen && (
|
||||
<div className="mt-6" data-testid="chain-preview-prereveal">
|
||||
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ANSWER + REVEAL (pre-reveal) ── */}
|
||||
{!showAnswer ? (
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Calculator className="w-3 h-3" />
|
||||
{current.details.napkin_math ? "Your napkin math" : "Your answer"}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
placeholder={
|
||||
current.details.napkin_math
|
||||
? "Type your napkin math here...\n\nExample:\nBandwidth: 3.35 TB/s\nModel size: 140 GB\nTime = 140 / 3350 ≈ 42 ms\n\n=> 42 ms (mark your final answer with =>)"
|
||||
: "Type your answer or reasoning here..."
|
||||
}
|
||||
className="w-full min-h-[220px] bg-surface border border-border rounded-md p-5 font-mono text-[13px] text-textPrimary resize-y focus:outline-none focus:border-accentBlue/50 placeholder:text-textTertiary/40 leading-relaxed"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleReveal()}
|
||||
className="mt-4 w-full bg-textPrimary text-background font-bold py-3 rounded-lg hover:opacity-90 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
Reveal Answer <span className="text-[10px] opacity-50 ml-1">⌘↵</span>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Beginner scaffolding nudge (Emma's UX review).
|
||||
Subtle link below the textarea that points
|
||||
discoverable users toward the Ask Interviewer
|
||||
feature in the right column. Arrow implies
|
||||
spatial direction. Kept small so it doesn't
|
||||
compete with the Reveal button for attention.
|
||||
*/}
|
||||
<div className="mt-3 flex items-center justify-center gap-1.5 text-[11px] text-textTertiary">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Stuck? Use the <span className="text-accentBlue font-medium">Ask Interviewer</span> panel on the right →</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── POST-REVEAL — model answer lands below the user's input ── */
|
||||
<motion.div
|
||||
ref={modelAnswerRef}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-8 space-y-5 scroll-mt-24"
|
||||
>
|
||||
{userAnswer.trim() && (
|
||||
<details className="group" open>
|
||||
<summary className="text-[10px] font-mono text-textTertiary uppercase cursor-pointer select-none flex items-center gap-1.5">
|
||||
<span className="group-open:rotate-90 transition-transform text-[8px]">▶</span>
|
||||
Your answer
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-surface border border-border rounded-md font-mono text-[12px] text-textSecondary whitespace-pre-wrap leading-relaxed max-h-56 overflow-y-auto">
|
||||
{userAnswer}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{napkinResult && (
|
||||
<div className={clsx(
|
||||
"p-4 rounded-lg border",
|
||||
napkinResult.grade === 'exact' || napkinResult.grade === 'close'
|
||||
? "bg-accentGreen/10 border-accentGreen/30"
|
||||
: napkinResult.grade === 'ballpark'
|
||||
? "bg-accentAmber/10 border-accentAmber/30"
|
||||
: "bg-accentRed/10 border-accentRed/30"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-accentGreen" />
|
||||
) : napkinResult.grade === 'ballpark' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-accentAmber" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-accentRed" />
|
||||
)}
|
||||
<span className={clsx(
|
||||
"text-sm font-bold",
|
||||
(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? "text-accentGreen"
|
||||
: napkinResult.grade === 'ballpark' ? "text-accentAmber"
|
||||
: "text-accentRed"
|
||||
)}>
|
||||
{napkinResult.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-textSecondary font-mono">
|
||||
Your answer: {napkinResult.userNum.toLocaleString()} |
|
||||
Model answer: {napkinResult.modelNum.toLocaleString()} |
|
||||
Off by: {(napkinResult.ratio * 100).toFixed(0)}%
|
||||
</p>
|
||||
{napkinResult.maxSelfScore < 3 && (
|
||||
<p className="text-[10px] text-textTertiary mt-2">
|
||||
Self-assessment capped at "{napkinResult.maxSelfScore === 2 ? 'Partial' : 'Wrong'}" based on napkin math accuracy
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.details.common_mistake && (
|
||||
<div className="border-l-4 border-accentRed pl-4">
|
||||
<span className="text-[10px] font-mono text-accentRed uppercase mb-1 flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" /> Common Mistake
|
||||
</span>
|
||||
<p className="text-sm text-textSecondary leading-relaxed">{current.details.common_mistake}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-l-4 border-accentGreen pl-4">
|
||||
<span className="text-[10px] font-mono text-accentGreen uppercase mb-1 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Model Answer
|
||||
</span>
|
||||
<p className="text-sm text-textPrimary leading-relaxed">{current.details.realistic_solution}</p>
|
||||
</div>
|
||||
|
||||
{current.details.napkin_math && (
|
||||
<div className="bg-surface border border-border p-4 rounded-lg">
|
||||
<span className="text-[10px] font-mono text-accentBlue uppercase mb-3 block">Napkin Math</span>
|
||||
<NapkinMathDisplay text={current.details.napkin_math} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rubricItems.length > 0 && (
|
||||
<div className="border-t border-border pt-5">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
|
||||
Did your answer cover? <span className="text-textTertiary/50 ml-1">{rubricItems.filter(i => i.checked).length}/{rubricItems.length}</span>
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
{rubricItems.map((item, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"flex items-start gap-3 p-2.5 rounded-lg border cursor-pointer transition-all text-xs",
|
||||
item.checked
|
||||
? "border-accentGreen/30 bg-accentGreen/5"
|
||||
: "border-border hover:border-borderHighlight"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={() => {
|
||||
const updated = [...rubricItems];
|
||||
updated[idx] = { ...updated[idx], checked: !updated[idx].checked };
|
||||
setRubricItems(updated);
|
||||
}}
|
||||
className="mt-0.5 accent-accentGreen"
|
||||
/>
|
||||
<span className={clsx(
|
||||
"leading-relaxed",
|
||||
item.checked ? "text-textPrimary" : "text-textSecondary"
|
||||
)}>
|
||||
{item.text}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{rubricScore !== null && (
|
||||
<div className="mt-2 text-[10px] font-mono text-textTertiary">
|
||||
Rubric score: {rubricScore}/3 → {['Skip', 'Wrong', 'Partial', 'Nailed It'][rubricScore]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-5">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
|
||||
{rubricItems.length > 0 ? 'Confirm or override' : 'Rate yourself'}
|
||||
<span className="text-textTertiary/50 ml-2">Press 1-4</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ score: 0, label: "Skip", color: "border-border text-textTertiary hover:border-borderHighlight" },
|
||||
{ score: 1, label: "Wrong", color: "border-accentRed/30 text-accentRed hover:bg-accentRed/10" },
|
||||
{ score: 2, label: "Partial", color: "border-accentAmber/30 text-accentAmber hover:bg-accentAmber/10" },
|
||||
{ score: 3, label: "Nailed It", color: "border-accentGreen/30 text-accentGreen hover:bg-accentGreen/10" },
|
||||
].map(({ score, label, color }) => {
|
||||
const disabled = score > effectiveMaxScore;
|
||||
const isRubricSuggested = rubricScore !== null && score === rubricScore;
|
||||
return (
|
||||
<button
|
||||
key={score}
|
||||
onClick={() => handleScore(Math.min(score, effectiveMaxScore))}
|
||||
disabled={disabled}
|
||||
aria-label={`Rate yourself: ${label} (${score} of 3)`}
|
||||
className={clsx(
|
||||
"px-3 py-2.5 rounded-lg border text-xs font-medium transition-all",
|
||||
disabled
|
||||
? "opacity-30 cursor-not-allowed border-border text-textTertiary"
|
||||
: isRubricSuggested
|
||||
? `${color} ring-1 ring-accentBlue/50`
|
||||
: color
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<QuestionFeedback question={current} />
|
||||
{chainInfo && (
|
||||
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sticky bottom bar */}
|
||||
|
||||
{/* Sticky bottom bar: pool count + Next Question */}
|
||||
<div className="shrink-0 border-t border-border bg-background px-8 lg:px-12 py-3 flex items-center justify-between">
|
||||
<span className="text-[11px] font-mono text-textTertiary">
|
||||
{pool.length} in pool
|
||||
@@ -969,17 +1263,14 @@ function PracticePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer panel */}
|
||||
<div className="w-full lg:w-[460px] border-t lg:border-t-0 lg:border-l border-border bg-surface flex flex-col">
|
||||
<div className="h-10 border-b border-border flex items-center px-4 bg-background/50 justify-between">
|
||||
{/* ── RIGHT: tools panel ── */}
|
||||
<div className="w-full lg:w-[400px] border-t lg:border-t-0 lg:border-l border-border bg-surface flex flex-col overflow-y-auto">
|
||||
<div className="h-10 border-b border-border flex items-center px-4 bg-background/50 justify-between shrink-0">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest flex items-center gap-2">
|
||||
<Calculator className="w-3 h-3" /> {current.details.napkin_math ? "napkin_math.py" : "answer.md"}
|
||||
<Sparkles className="w-3 h-3" /> Tools
|
||||
</span>
|
||||
<button
|
||||
onClick={() => pickRandom()}
|
||||
// Extend tap target to ~32x52px on mobile while keeping
|
||||
// visual text inline-sized. Negative margin cancels the
|
||||
// padding so layout doesn't shift.
|
||||
className="text-[10px] font-mono text-textTertiary hover:text-textPrimary transition-colors flex items-center gap-1 py-2 -my-2 px-2 -mx-2"
|
||||
aria-label="Skip to a random question"
|
||||
>
|
||||
@@ -987,212 +1278,31 @@ function PracticePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<HardwareRef />
|
||||
<NapkinCalc />
|
||||
{/* Pre-Reveal: Socratic clarifier (interview persona).
|
||||
Post-Reveal: Tutor with canonical answer injected.
|
||||
The `key` forces a remount when mode flips so the
|
||||
transcript resets — a pre-reveal "don't tell me the
|
||||
answer" exchange would otherwise pollute the tutor's
|
||||
context when we start a fresh post-reveal dialogue. */}
|
||||
{/*
|
||||
Right-column order follows Chip Huyen's feedback:
|
||||
- Ask Interviewer first (the novel feature; beginners
|
||||
discover it via the nudge under the textarea). Keep
|
||||
it collapsible — non-AI users see a thin header, not
|
||||
a giant empty chat panel shouting "you're doing this
|
||||
wrong." The `key` prop force-remounts on persona
|
||||
swap so pre-reveal and post-reveal transcripts stay
|
||||
separate.
|
||||
- Hardware reference defaults OPEN. Practitioners
|
||||
doing bandwidth/FLOPS calcs consult it constantly;
|
||||
forcing a click every 15 seconds is the #1 friction
|
||||
point Chip flagged.
|
||||
- Napkin calculator defaults CLOSED. It is an invoked
|
||||
tool, not a consulted reference — only expand when
|
||||
actively computing something.
|
||||
*/}
|
||||
<AskInterviewer
|
||||
key={`${current.id}-${showAnswer ? "study" : "interview"}`}
|
||||
questionContext={current.scenario}
|
||||
mode={showAnswer ? "study" : "interview"}
|
||||
canonicalAnswer={showAnswer ? current.details.realistic_solution : undefined}
|
||||
/>
|
||||
<div className="flex-1 p-5 flex flex-col overflow-y-auto">
|
||||
{!showAnswer ? (
|
||||
<>
|
||||
<textarea
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
placeholder={
|
||||
current.details.napkin_math
|
||||
? "Type your napkin math here...\n\nExample:\nBandwidth: 3.35 TB/s\nModel size: 140 GB\nTime = 140 / 3350 ≈ 42 ms\n\n=> 42 ms (mark your final answer with =>)"
|
||||
: "Type your answer or reasoning here..."
|
||||
}
|
||||
className="flex-1 min-h-[200px] w-full bg-background border border-border rounded-md p-5 font-mono text-[13px] text-textPrimary resize-none focus:outline-none focus:border-accentBlue/50 placeholder:text-textTertiary/40 leading-relaxed"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleReveal}
|
||||
className="mt-4 w-full bg-textPrimary text-background font-bold py-3 rounded-lg hover:opacity-90 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
Reveal Answer <span className="text-[10px] opacity-50 ml-1">⌘↵</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* User's answer (preserved for comparison) */}
|
||||
{userAnswer.trim() && (
|
||||
<details className="group">
|
||||
<summary className="text-[10px] font-mono text-textTertiary uppercase cursor-pointer select-none flex items-center gap-1.5">
|
||||
<span className="group-open:rotate-90 transition-transform text-[8px]">▶</span>
|
||||
Your answer
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-background border border-border rounded-md font-mono text-[12px] text-textSecondary whitespace-pre-wrap leading-relaxed max-h-40 overflow-y-auto">
|
||||
{userAnswer}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Napkin math result — gradient feedback */}
|
||||
{napkinResult && (
|
||||
<div className={clsx(
|
||||
"p-4 rounded-lg border",
|
||||
napkinResult.grade === 'exact' || napkinResult.grade === 'close'
|
||||
? "bg-accentGreen/10 border-accentGreen/30"
|
||||
: napkinResult.grade === 'ballpark'
|
||||
? "bg-accentAmber/10 border-accentAmber/30"
|
||||
: "bg-accentRed/10 border-accentRed/30"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-accentGreen" />
|
||||
) : napkinResult.grade === 'ballpark' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-accentAmber" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-accentRed" />
|
||||
)}
|
||||
<span className={clsx(
|
||||
"text-sm font-bold",
|
||||
(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? "text-accentGreen"
|
||||
: napkinResult.grade === 'ballpark' ? "text-accentAmber"
|
||||
: "text-accentRed"
|
||||
)}>
|
||||
{napkinResult.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-textSecondary font-mono">
|
||||
Your answer: {napkinResult.userNum.toLocaleString()} |
|
||||
Model answer: {napkinResult.modelNum.toLocaleString()} |
|
||||
Off by: {(napkinResult.ratio * 100).toFixed(0)}%
|
||||
</p>
|
||||
{napkinResult.maxSelfScore < 3 && (
|
||||
<p className="text-[10px] text-textTertiary mt-2">
|
||||
Self-assessment capped at "{napkinResult.maxSelfScore === 2 ? 'Partial' : 'Wrong'}" based on napkin math accuracy
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.details.common_mistake && (
|
||||
<div className="border-l-4 border-accentRed pl-4">
|
||||
<span className="text-[10px] font-mono text-accentRed uppercase mb-1 block flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" /> Common Mistake
|
||||
</span>
|
||||
<p className="text-sm text-textSecondary leading-relaxed">{current.details.common_mistake}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-l-4 border-accentGreen pl-4">
|
||||
<span className="text-[10px] font-mono text-accentGreen uppercase mb-1 block flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Model Answer
|
||||
</span>
|
||||
<p className="text-sm text-textPrimary leading-relaxed">{current.details.realistic_solution}</p>
|
||||
</div>
|
||||
{current.details.napkin_math && (
|
||||
<div className="bg-background border border-border p-4 rounded-lg">
|
||||
<span className="text-[10px] font-mono text-accentBlue uppercase mb-3 block">Napkin Math</span>
|
||||
<NapkinMathDisplay text={current.details.napkin_math} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rubric checkboxes */}
|
||||
{rubricItems.length > 0 && (
|
||||
<div className="border-t border-border pt-5">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
|
||||
Did your answer cover? <span className="text-textTertiary/50 ml-1">{rubricItems.filter(i => i.checked).length}/{rubricItems.length}</span>
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
{rubricItems.map((item, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"flex items-start gap-3 p-2.5 rounded-lg border cursor-pointer transition-all text-xs",
|
||||
item.checked
|
||||
? "border-accentGreen/30 bg-accentGreen/5"
|
||||
: "border-border hover:border-borderHighlight"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={() => {
|
||||
const updated = [...rubricItems];
|
||||
updated[idx] = { ...updated[idx], checked: !updated[idx].checked };
|
||||
setRubricItems(updated);
|
||||
}}
|
||||
className="mt-0.5 accent-accentGreen"
|
||||
/>
|
||||
<span className={clsx(
|
||||
"leading-relaxed",
|
||||
item.checked ? "text-textPrimary" : "text-textSecondary"
|
||||
)}>
|
||||
{item.text}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{rubricScore !== null && (
|
||||
<div className="mt-2 text-[10px] font-mono text-textTertiary">
|
||||
Rubric score: {rubricScore}/3 → {['Skip', 'Wrong', 'Partial', 'Nailed It'][rubricScore]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Self-assessment */}
|
||||
<div className="border-t border-border pt-5">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
|
||||
{rubricItems.length > 0 ? 'Confirm or override' : 'Rate yourself'}
|
||||
<span className="text-textTertiary/50 ml-2">Press 1-4</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ score: 0, label: "Skip", color: "border-border text-textTertiary hover:border-borderHighlight" },
|
||||
{ score: 1, label: "Wrong", color: "border-accentRed/30 text-accentRed hover:bg-accentRed/10" },
|
||||
{ score: 2, label: "Partial", color: "border-accentAmber/30 text-accentAmber hover:bg-accentAmber/10" },
|
||||
{ score: 3, label: "Nailed It", color: "border-accentGreen/30 text-accentGreen hover:bg-accentGreen/10" },
|
||||
].map(({ score, label, color }) => {
|
||||
const disabled = score > effectiveMaxScore;
|
||||
const isRubricSuggested = rubricScore !== null && score === rubricScore;
|
||||
return (
|
||||
<button
|
||||
key={score}
|
||||
onClick={() => handleScore(Math.min(score, effectiveMaxScore))}
|
||||
disabled={disabled}
|
||||
aria-label={`Rate yourself: ${label} (${score} of 3)`}
|
||||
className={clsx(
|
||||
"px-3 py-2.5 rounded-lg border text-xs font-medium transition-all",
|
||||
disabled
|
||||
? "opacity-30 cursor-not-allowed border-border text-textTertiary"
|
||||
: isRubricSuggested
|
||||
? `${color} ring-1 ring-accentBlue/50`
|
||||
: color
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Feedback: thumbs, difficulty, report, suggest */}
|
||||
<QuestionFeedback question={current} />
|
||||
|
||||
{/* Chain navigation — go deeper */}
|
||||
{chainInfo && (
|
||||
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<HardwareRef defaultOpen={true} />
|
||||
<NapkinCalc defaultOpen={false} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1202,6 +1312,54 @@ function PracticePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Submit-gradient confirm dialog (per Chip Huyen's UX review).
|
||||
Fires when a user clicks Reveal with <15s elapsed AND <50
|
||||
chars typed — indicating they likely didn't really try. One
|
||||
dismissal (either choice) calibrates the guard off for the
|
||||
rest of the session so power users are not pestered. Uses a
|
||||
full-viewport overlay so the dialog is unmissable.
|
||||
*/}
|
||||
{thinkConfirmOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-6"
|
||||
onClick={() => setThinkConfirmOpen(false)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="think-confirm-title"
|
||||
>
|
||||
<div
|
||||
className="bg-surface border border-border rounded-xl p-6 max-w-md w-full shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-4 h-4 text-accentAmber" />
|
||||
<h3 id="think-confirm-title" className="text-sm font-bold text-textPrimary uppercase tracking-widest">Think longer?</h3>
|
||||
</div>
|
||||
<p className="text-sm text-textSecondary leading-relaxed mb-5">
|
||||
You've been on this question for less than 15 seconds and haven't typed much. The practice works better when you commit to an answer first — even a wrong one sharpens what you learn from the reveal.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setThinkConfirmOpen(false)}
|
||||
className="flex-1 py-2.5 rounded-lg border border-accentBlue/30 bg-accentBlue/5 text-accentBlue font-medium text-sm hover:bg-accentBlue/10 transition-colors"
|
||||
>
|
||||
Keep thinking
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deliberationCalibrated.current = true;
|
||||
handleReveal(true);
|
||||
}}
|
||||
className="flex-1 py-2.5 rounded-lg border border-border text-textSecondary font-medium text-sm hover:bg-surfaceHover transition-colors"
|
||||
>
|
||||
Reveal anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star gate overlay */}
|
||||
{showStarGate && (
|
||||
<StarGate onVerified={() => { setShowStarGate(false); track({ type: 'star_gate_verified' }); }} />
|
||||
|
||||
73
interviews/staffml/src/components/QuestionVisual.tsx
Normal file
73
interviews/staffml/src/components/QuestionVisual.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Renders the optional diagram attached to a question.
|
||||
*
|
||||
* Architecture: visuals live as static SVG assets under
|
||||
* `interviews/vault/visuals/<track>/` and are mirrored to
|
||||
* `interviews/staffml/public/question-visuals/<track>/` at bundle
|
||||
* build time (`vault build --legacy-json`). Here we just load them
|
||||
* as `<img>` elements — browser caching, no inline-SVG sanitization,
|
||||
* and the HTTP request is a single static asset hit.
|
||||
*
|
||||
* The component is deliberately minimal. The SVG itself carries the
|
||||
* semantic content (see `.claude/rules/svg-style.md` for the book's
|
||||
* SVG style system, which authors should follow). Styling of the
|
||||
* frame, caption, and responsive sizing lives here.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { ImageOff } from "lucide-react";
|
||||
|
||||
export interface QuestionVisualProps {
|
||||
track: string;
|
||||
visual: {
|
||||
kind: "svg" | "mermaid";
|
||||
path: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function QuestionVisual({ track, visual }: QuestionVisualProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
// Mermaid kind is reserved for a future inline-text path — MVP only
|
||||
// supports svg file references. Render nothing rather than throw so
|
||||
// a forward-compat YAML on an old frontend degrades gracefully.
|
||||
if (visual.kind !== "svg") return null;
|
||||
|
||||
const src = `/question-visuals/${track}/${visual.path}`;
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<figure className="mt-5 p-4 rounded-lg border border-dashed border-accentRed/30 bg-accentRed/5 flex items-start gap-3">
|
||||
<ImageOff className="w-4 h-4 text-accentRed shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-textSecondary">
|
||||
<span className="font-medium text-accentRed">Diagram failed to load.</span>{" "}
|
||||
{visual.alt}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<figure className="mt-5">
|
||||
<div className="rounded-lg border border-border bg-surface/40 p-3 flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={visual.alt}
|
||||
className="max-w-full max-h-[420px] h-auto w-auto"
|
||||
onError={() => setFailed(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{visual.caption && (
|
||||
<figcaption className="mt-2 text-[11px] font-mono text-textTertiary text-center">
|
||||
{visual.caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,18 @@ export interface Question {
|
||||
* back to a zone-based inferred-task label.
|
||||
*/
|
||||
question?: string;
|
||||
/**
|
||||
* Optional diagram attached to the question. The SVG file lives at
|
||||
* `/question-visuals/<track>/<visual.path>` — copied from the vault
|
||||
* by the build step. Rendered between the scenario and the Your
|
||||
* task callout so the reading order is context → diagram → ask.
|
||||
*/
|
||||
visual?: {
|
||||
kind: "svg" | "mermaid";
|
||||
path: string; // bare filename, resolves under /question-visuals/<track>/
|
||||
alt: string; // a11y-required description
|
||||
caption?: string;
|
||||
};
|
||||
topic: string; // one of 87 curated topic IDs
|
||||
zone: string; // one of 11 ikigai zones
|
||||
competency_area: string; // one of 13 canonical areas
|
||||
|
||||
@@ -11,7 +11,7 @@ from rich.table import Table
|
||||
|
||||
from vault_cli.compiler import build as compile_build
|
||||
from vault_cli.exit_codes import ExitCode
|
||||
from vault_cli.legacy_export import emit_legacy_corpus
|
||||
from vault_cli.legacy_export import copy_visual_assets, emit_legacy_corpus
|
||||
from vault_cli.loader import load_all
|
||||
|
||||
console = Console()
|
||||
@@ -72,6 +72,19 @@ def register(app: typer.Typer) -> None:
|
||||
f"[dim]legacy corpus.json: {legacy_result['count']} questions → "
|
||||
f"{legacy_result['output']}[/dim]"
|
||||
)
|
||||
# Mirror visual assets alongside the JSON. The frontend
|
||||
# references /question-visuals/<track>/<file>.svg directly
|
||||
# from Next.js's public/ tree — no hydration or worker
|
||||
# round-trip, just static assets cached at the edge.
|
||||
visuals_out = Path("interviews/staffml/public")
|
||||
visuals_result = copy_visual_assets(vault_dir, visuals_out)
|
||||
result["visual_assets"] = visuals_result
|
||||
console.print(
|
||||
f"[dim]visual assets: {visuals_result.get('total_assets', 0)} total, "
|
||||
f"{visuals_result.get('copied', 0)} copied, "
|
||||
f"{visuals_result.get('deleted', 0)} pruned → "
|
||||
f"{visuals_out}/question-visuals/[/dim]"
|
||||
)
|
||||
|
||||
if as_json:
|
||||
print(json.dumps({
|
||||
|
||||
@@ -42,6 +42,21 @@ def _adapt(lq: LoadedQuestion) -> dict[str, Any]:
|
||||
# renders it synchronously, so lazy-hydration would be a regression.
|
||||
if q.question:
|
||||
legacy["question"] = q.question
|
||||
# Visual metadata. The SVG file itself lives under
|
||||
# interviews/vault/visuals/<track>/ and is copied to
|
||||
# interviews/staffml/public/question-visuals/ by `copy_visual_assets`
|
||||
# below; this JSON only carries the metadata the frontend needs
|
||||
# (kind + filename + alt + optional caption) to build the asset
|
||||
# URL at /question-visuals/<track>/<path>.
|
||||
if q.visual is not None:
|
||||
visual_out: dict[str, Any] = {
|
||||
"kind": q.visual.kind,
|
||||
"path": q.visual.path,
|
||||
"alt": q.visual.alt,
|
||||
}
|
||||
if q.visual.caption:
|
||||
visual_out["caption"] = q.visual.caption
|
||||
legacy["visual"] = visual_out
|
||||
|
||||
# Chain — legacy shape: chain_ids (list) + chain_positions (dict).
|
||||
# v1.0 schema already carries multi-chain membership natively.
|
||||
@@ -159,4 +174,50 @@ def _to_summary(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return summary
|
||||
|
||||
|
||||
__all__ = ["emit_legacy_corpus"]
|
||||
def copy_visual_assets(vault_dir: Path, staffml_public_dir: Path) -> dict[str, Any]:
|
||||
"""Copy `interviews/vault/visuals/<track>/*.svg` → `<staffml>/public/question-visuals/<track>/`.
|
||||
|
||||
The Next.js frontend serves static assets from ``public/`` at
|
||||
``/question-visuals/<track>/<file>.svg``. This function mirrors
|
||||
the track-sharded directory layout so the same relative filename
|
||||
used in the YAML's ``visual.path`` field resolves without
|
||||
transformation.
|
||||
|
||||
Overwrites destination files on every run — the vault is the
|
||||
source of truth. Removes destination files whose source no longer
|
||||
exists so renames/deletions propagate. Returns counts for the
|
||||
build summary.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
source = vault_dir / "visuals"
|
||||
dest = staffml_public_dir / "question-visuals"
|
||||
copied = 0
|
||||
deleted = 0
|
||||
|
||||
if not source.exists():
|
||||
return {"copied": 0, "deleted": 0, "note": "no visuals directory"}
|
||||
|
||||
# Mirror from source into dest.
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
source_files: set[Path] = set()
|
||||
for svg in source.rglob("*.svg"):
|
||||
rel = svg.relative_to(source)
|
||||
source_files.add(rel)
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not target.exists() or target.read_bytes() != svg.read_bytes():
|
||||
shutil.copy2(svg, target)
|
||||
copied += 1
|
||||
|
||||
# Prune destination files that no longer have a source.
|
||||
for existing in dest.rglob("*.svg"):
|
||||
rel = existing.relative_to(dest)
|
||||
if rel not in source_files:
|
||||
existing.unlink()
|
||||
deleted += 1
|
||||
|
||||
return {"copied": copied, "deleted": deleted, "total_assets": len(source_files)}
|
||||
|
||||
|
||||
__all__ = ["emit_legacy_corpus", "copy_visual_assets"]
|
||||
|
||||
52
interviews/vault/questions/cloud/cloud-visual-001.yaml
Normal file
52
interviews/vault/questions/cloud/cloud-visual-001.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
schema_version: '1.0'
|
||||
id: cloud-visual-001
|
||||
track: cloud
|
||||
level: L3
|
||||
zone: analyze
|
||||
topic: data-parallelism
|
||||
competency_area: parallelism
|
||||
bloom_level: apply
|
||||
phase: training
|
||||
title: Ring AllReduce Latency on 4 Ranks
|
||||
scenario: >-
|
||||
Four GPU ranks are participating in a Ring AllReduce to aggregate gradients
|
||||
after a training step. The gradient tensor is 400 MB in total, split evenly
|
||||
into 4 chunks of 100 MB each. Every inter-rank link runs at 200 GB/s. The
|
||||
ring passes chunks clockwise: each rank sends one chunk to the next rank
|
||||
while receiving a chunk from the previous rank.
|
||||
question: "Using the diagram, calculate the total time to complete the full AllReduce across all 4 ranks, and justify the formula you used."
|
||||
visual:
|
||||
kind: svg
|
||||
path: cloud-visual-001.svg
|
||||
alt: >-
|
||||
Four rank boxes arranged in a square: Rank 0 top-left, Rank 1 top-right,
|
||||
Rank 2 bottom-right, Rank 3 bottom-left. Clockwise arrows between adjacent
|
||||
ranks are labelled chunk 0 through chunk 3, each carrying 100 MB at
|
||||
200 GB/s. A legend below notes total gradient 400 MB, N equals 4 ranks,
|
||||
and that the full AllReduce comprises reduce-scatter plus all-gather.
|
||||
caption: Ring AllReduce on 4 ranks passing 100 MB chunks clockwise at 200 GB/s.
|
||||
details:
|
||||
realistic_solution: >-
|
||||
Total AllReduce time = 2(N−1)/N × data / bw = 2(3)/4 × 400 MB / 200 GB/s
|
||||
= 1.5 × 0.002 s = 3 ms. The Ring AllReduce has two phases: a reduce-scatter
|
||||
(each rank ends up owning a reduced shard) and an all-gather (each rank
|
||||
collects the full reduced tensor). Each phase completes in (N−1)/N × data
|
||||
/ bw = 1.5 ms, yielding 3 ms total. The key insight is that only one chunk
|
||||
(data/N = 100 MB) traverses each link per step, so wall-time is set by
|
||||
the number of steps (2(N−1) = 6) times step-time (100 MB / 200 GB/s = 0.5 ms).
|
||||
common_mistake: >-
|
||||
Dividing the full 400 MB gradient by 200 GB/s to get 2 ms misses that
|
||||
Ring AllReduce requires 2(N−1) = 6 sequential steps, not one. Alternatively,
|
||||
some learners compute (N−1)/N × data / bw = 1.5 ms and forget to double
|
||||
it for the all-gather phase, giving 1.5 ms instead of 3 ms. The 2(N−1)/N
|
||||
factor captures both the parallelism benefit (only 1/N of data per step)
|
||||
and the phase count.
|
||||
napkin_math: >-
|
||||
data = 400 MB, N = 4, bw = 200 GB/s. Step time = (data/N)/bw =
|
||||
100 MB / 200 GB/s = 0.5 ms. Reduce-scatter takes N−1 = 3 steps = 1.5 ms.
|
||||
All-gather also takes N−1 = 3 steps = 1.5 ms. Total = 3 ms.
|
||||
status: draft
|
||||
provenance: llm-draft
|
||||
expected_time_minutes: 5
|
||||
requires_explanation: false
|
||||
validated: false
|
||||
@@ -61,6 +61,74 @@ class Resource(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class Visual(BaseModel):
|
||||
"""Optional diagram/figure attached to a question.
|
||||
|
||||
Visuals live as separate asset files under
|
||||
``interviews/vault/visuals/<track>/<path>`` so the SVG text does
|
||||
not contaminate YAML diffs and existing SVG tooling (Inkscape,
|
||||
formatters, linters) works unchanged. The bundle-build step copies
|
||||
these into the Next.js ``public/question-visuals/`` tree. The
|
||||
practice page renders them between the scenario and the
|
||||
``question`` callout — context → diagram → ask, mirroring how an
|
||||
interviewer would flow the question in person.
|
||||
"""
|
||||
|
||||
kind: str = "svg"
|
||||
"""Renderer kind. MVP supports `svg` only. Future: `mermaid`
|
||||
(inline text), `roofline` (parameterized React component), etc.
|
||||
The renderer dispatches on this field."""
|
||||
|
||||
path: str
|
||||
"""Asset filename relative to ``interviews/vault/visuals/<track>/``.
|
||||
Must end in ``.svg`` for ``kind=svg``. No path traversal."""
|
||||
|
||||
alt: str
|
||||
"""Accessibility description for screen readers and fallback when
|
||||
the SVG fails to load. Required — a visual with no alt is an
|
||||
accessibility regression, not an optional add-on."""
|
||||
|
||||
caption: Optional[str] = None
|
||||
"""Author-facing caption rendered below the figure. Short — max
|
||||
120 chars. Optional; the alt text handles the semantic payload."""
|
||||
|
||||
@field_validator("kind")
|
||||
@classmethod
|
||||
def valid_kind(cls, v: str) -> str:
|
||||
if v not in {"svg", "mermaid"}:
|
||||
raise ValueError(f"Visual.kind must be 'svg' or 'mermaid' (got {v!r})")
|
||||
return v
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def safe_path(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Visual.path must be non-empty")
|
||||
if "/" in v or "\\" in v or ".." in v:
|
||||
raise ValueError(
|
||||
f"Visual.path must be a bare filename, no traversal (got {v!r})"
|
||||
)
|
||||
if len(v) > 120:
|
||||
raise ValueError(f"Visual.path too long ({len(v)} chars, max 120)")
|
||||
return v
|
||||
|
||||
@field_validator("alt")
|
||||
@classmethod
|
||||
def alt_non_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Visual.alt must be non-empty (accessibility requirement)")
|
||||
if len(v) > 400:
|
||||
raise ValueError(f"Visual.alt too long ({len(v)} chars, max 400)")
|
||||
return v
|
||||
|
||||
@field_validator("caption")
|
||||
@classmethod
|
||||
def caption_length(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and len(v) > 120:
|
||||
raise ValueError(f"Visual.caption too long ({len(v)} chars, max 120)")
|
||||
return v
|
||||
|
||||
|
||||
class ChainRef(BaseModel):
|
||||
"""Structured chain reference with position (plural chains list item)."""
|
||||
|
||||
@@ -140,6 +208,7 @@ class Question(BaseModel):
|
||||
# uniformly (without it, 71% of questions had no explicit ask — the
|
||||
# scenario just set context and the reader had to guess).
|
||||
question: Optional[str] = None
|
||||
visual: Optional[Visual] = None
|
||||
details: QuestionDetails
|
||||
|
||||
# Workflow
|
||||
|
||||
@@ -157,6 +157,33 @@ classes:
|
||||
range: string
|
||||
description: Free-form reviewer notes.
|
||||
|
||||
Visual:
|
||||
description: |
|
||||
Optional diagram attached to a question. Stored as a separate
|
||||
asset file under interviews/vault/visuals/<track>/; the YAML
|
||||
only carries metadata (kind, filename, alt text, caption). This
|
||||
keeps SVG markup out of YAML diffs and lets authors use real
|
||||
SVG editors instead of YAML multiline scalars.
|
||||
attributes:
|
||||
kind:
|
||||
range: string
|
||||
required: true
|
||||
pattern: "^(svg|mermaid)$"
|
||||
description: Renderer kind. MVP ships `svg` only; `mermaid` reserved for future inline-text diagrams.
|
||||
path:
|
||||
range: string
|
||||
required: true
|
||||
pattern: "^[A-Za-z0-9_.-]+\\.(svg|mmd)$"
|
||||
description: Bare filename under visuals/<track>/. No path traversal. ≤120 chars.
|
||||
alt:
|
||||
range: string
|
||||
required: true
|
||||
description: Accessibility description for screen readers and SVG-load failure. ≤400 chars. Required — a visual with no alt is an a11y regression.
|
||||
caption:
|
||||
range: string
|
||||
required: false
|
||||
description: Short author-facing caption rendered below the figure. ≤120 chars. Alt text handles the semantic payload; caption is for labelling only.
|
||||
|
||||
Details:
|
||||
description: Rich-text body of the question.
|
||||
attributes:
|
||||
@@ -238,6 +265,16 @@ classes:
|
||||
50 GB/s chip interconnect and a 33 ms frame deadline; the
|
||||
question field reads "Which parallelism strategy (tensor vs.
|
||||
pipeline) would you choose and why?"
|
||||
visual:
|
||||
range: Visual
|
||||
required: false
|
||||
description: >-
|
||||
Optional diagram attached to the question. Stored as a
|
||||
separate file under interviews/vault/visuals/<track>/<path>
|
||||
so the SVG text does not contaminate YAML diffs and existing
|
||||
SVG tooling works unchanged. Rendered between the scenario
|
||||
and the question callout on the practice page — context →
|
||||
diagram → ask.
|
||||
details:
|
||||
range: Details
|
||||
required: true
|
||||
|
||||
115
interviews/vault/visuals/AUTHORING.md
Normal file
115
interviews/vault/visuals/AUTHORING.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Authoring visual questions
|
||||
|
||||
StaffML questions can optionally attach a diagram. The practice page renders
|
||||
it between the scenario prose and the answer textarea, and the `question`
|
||||
field always stays sticky at the top — reading flow is **scenario →
|
||||
diagram → answer**.
|
||||
|
||||
Use visuals sparingly. A good visual earns its place; a bad one is
|
||||
noise that slows the reader down.
|
||||
|
||||
## When a visual earns its place
|
||||
|
||||
A visual earns its place when **all three** hold:
|
||||
|
||||
1. **The ask requires reading the diagram.** If the question can be
|
||||
answered from the scenario alone, the visual is decorative — omit it.
|
||||
2. **The visual encodes information that text cannot.** Topology,
|
||||
memory layouts, roofline curves, pipeline timelines, dataflow —
|
||||
things where spatial structure *is* the payload.
|
||||
3. **A static image suffices.** Animation, interactivity, and
|
||||
multi-step reveals are out of scope.
|
||||
|
||||
### High-value candidate topics
|
||||
|
||||
Target these first; each one repeats across many chain positions, so
|
||||
one good diagram earns its keep across dozens of questions.
|
||||
|
||||
- Ring / tree AllReduce topologies (show the ring, ask for latency)
|
||||
- Parameter-server vs. AllReduce dataflow
|
||||
- Roofline diagrams with workload points plotted
|
||||
- KV-cache growth vs. sequence length
|
||||
- Pipeline parallelism bubble / Gantt charts
|
||||
- Memory hierarchy: HBM + SRAM + host DRAM + disk
|
||||
- TinyML MCU memory map (flash + SRAM + model footprint)
|
||||
- Systolic array dataflow (weight-stationary vs. output-stationary)
|
||||
- Attention computation graph (Q·Kᵀ then softmax then ·V)
|
||||
- MoE all-to-all shuffle topology
|
||||
|
||||
## Authoring workflow
|
||||
|
||||
1. **Draft the SVG** following `.claude/rules/svg-style.md` (the book's
|
||||
SVG system). Non-negotiables:
|
||||
- `viewBox="0 0 680 460"` default (widen only when content demands).
|
||||
- `font-family="Helvetica Neue, Helvetica, Arial, sans-serif"` on
|
||||
the root `<svg>`.
|
||||
- Semantic palette — compute blue `#cfe2f3`/`#4a90c4`, data green
|
||||
`#d4edda`/`#3d9e5a`, routing orange `#fdebd0`/`#c87b2a`, error
|
||||
red `#f9d6d5`/`#c44`, MIT red accent `#a31f34`.
|
||||
- Orthogonal routing (no diagonals except genuine topology diagrams).
|
||||
- Arrows anchor at box edges, route around obstacles with ≥10px
|
||||
clearance.
|
||||
- Integer coordinates on a 10-px grid.
|
||||
- Text in `<text>` elements (not paths) — selectable + accessible.
|
||||
2. **Save** to `interviews/vault/visuals/<track>/<id>.svg` where
|
||||
`<track>` matches the question's track (cloud, edge, mobile, tinyml,
|
||||
global) and `<id>` matches the question's YAML id. Bare filename
|
||||
only — no subdirectories, no path traversal.
|
||||
3. **Add the `visual:` block** to the matching YAML:
|
||||
```yaml
|
||||
visual:
|
||||
kind: svg
|
||||
path: <id>.svg
|
||||
alt: >-
|
||||
Full accessibility description — objective, concrete, ≤400
|
||||
chars. Describe what the diagram SHOWS, not why it matters.
|
||||
caption: "Short caption rendered below the figure. ≤120 chars. Optional."
|
||||
```
|
||||
4. **Build** — `vault build --legacy-json`. This copies the SVG to
|
||||
`interviews/staffml/public/question-visuals/<track>/` and surfaces
|
||||
the `visual` metadata in the corpus bundle.
|
||||
5. **Preview** at `/practice?q=<id>` on the dev server.
|
||||
|
||||
## Reference exemplar
|
||||
|
||||
`cloud/cloud-visual-001.yaml` + `cloud/cloud-visual-001.svg` — Ring
|
||||
AllReduce on 4 ranks. Diagram shows the ring topology + chunk labels +
|
||||
bandwidth annotations; scenario gives concrete numbers; question asks
|
||||
for the total AllReduce time. Solution walks through the
|
||||
2(N−1)/N × data / bw formula. Copy this pattern.
|
||||
|
||||
## Accessibility requirements (non-negotiable)
|
||||
|
||||
- **`alt` is required** and is enforced by the schema. A visual
|
||||
without alt fails Pydantic validation; `vault build` will reject it.
|
||||
- Colour is never the sole semantic channel — pair colour with label
|
||||
text, line style, or shape. A colour-blind reader must still be able
|
||||
to parse the diagram.
|
||||
- Text in SVG `<text>` elements (not baked into paths) so it's
|
||||
selectable and screen-reader friendly.
|
||||
- WCAG AA contrast on any label over a coloured fill: `#333` text on
|
||||
`#cfe2f3` compute-blue passes; `#999` text on the same fill fails.
|
||||
|
||||
## Anti-patterns to reject
|
||||
|
||||
- Inline `<svg>` markup inside YAML. The schema's `path` field is a
|
||||
bare filename — path traversal is rejected. Use the file-reference
|
||||
pattern.
|
||||
- Mermaid source for anything other than simple node-edge graphs. The
|
||||
renderer MVP supports SVG only; `kind: mermaid` is reserved for a
|
||||
future inline-text path and is currently a no-op.
|
||||
- Label text that duplicates the scenario prose. The diagram should
|
||||
encode *additional* information, not restate the scenario.
|
||||
- Decorative gradients, fake 3-D, beveled edges, drop shadows. Every
|
||||
mark earns its place (Tufte's data-ink principle — see the SVG
|
||||
style guide for the full chart-junk policy).
|
||||
- Icons or emoji in the SVG. Use neutral shapes + labels.
|
||||
- Watermarks, signatures, tool branding. The caption handles attribution.
|
||||
|
||||
## Build artifacts
|
||||
|
||||
The directory `interviews/staffml/public/question-visuals/` is a
|
||||
build output (written by `copy_visual_assets` during `vault build
|
||||
--legacy-json`). Source files live under `interviews/vault/visuals/`.
|
||||
Do not edit the build output directly — changes will be overwritten
|
||||
on the next build.
|
||||
68
interviews/vault/visuals/cloud/cloud-visual-001.svg
Normal file
68
interviews/vault/visuals/cloud/cloud-visual-001.svg
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 460"
|
||||
font-family="Helvetica Neue, Helvetica, Arial, sans-serif">
|
||||
<rect width="680" height="460" fill="#fff" rx="4"/>
|
||||
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L8,3 L0,6 Z" fill="#555"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- ===== Title ===== -->
|
||||
<text x="340" y="32" text-anchor="middle" font-size="13" font-weight="700" fill="#333">
|
||||
Ring AllReduce — 4 ranks, one reduce-scatter step
|
||||
</text>
|
||||
|
||||
<!-- ===== Ranks (compute blue) ===== -->
|
||||
<!-- Rank 0: top-left -->
|
||||
<rect x="110" y="110" width="140" height="80" rx="4"
|
||||
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
|
||||
<text x="180" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 0</text>
|
||||
|
||||
<!-- Rank 1: top-right -->
|
||||
<rect x="430" y="110" width="140" height="80" rx="4"
|
||||
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
|
||||
<text x="500" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 1</text>
|
||||
|
||||
<!-- Rank 2: bottom-right -->
|
||||
<rect x="430" y="290" width="140" height="80" rx="4"
|
||||
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
|
||||
<text x="500" y="336" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 2</text>
|
||||
|
||||
<!-- Rank 3: bottom-left -->
|
||||
<rect x="110" y="290" width="140" height="80" rx="4"
|
||||
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
|
||||
<text x="180" y="336" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 3</text>
|
||||
|
||||
<!-- ===== Ring arrows (clockwise) ===== -->
|
||||
<!-- R0 → R1 (top edge) -->
|
||||
<line x1="252" y1="150" x2="428" y2="150" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="340" y="140" text-anchor="middle" font-size="10" font-weight="700" fill="#333">chunk 0</text>
|
||||
<text x="340" y="172" text-anchor="middle" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
|
||||
|
||||
<!-- R1 → R2 (right edge) -->
|
||||
<line x1="500" y1="192" x2="500" y2="288" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="514" y="234" text-anchor="start" font-size="10" font-weight="700" fill="#333">chunk 1</text>
|
||||
<text x="514" y="250" text-anchor="start" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
|
||||
|
||||
<!-- R2 → R3 (bottom edge) -->
|
||||
<line x1="428" y1="330" x2="252" y2="330" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="340" y="322" text-anchor="middle" font-size="10" font-weight="700" fill="#333">chunk 2</text>
|
||||
<text x="340" y="352" text-anchor="middle" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
|
||||
|
||||
<!-- R3 → R0 (left edge) -->
|
||||
<line x1="180" y1="288" x2="180" y2="192" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="166" y="234" text-anchor="end" font-size="10" font-weight="700" fill="#333">chunk 3</text>
|
||||
<text x="166" y="250" text-anchor="end" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
|
||||
|
||||
<!-- ===== Context legend ===== -->
|
||||
<rect x="190" y="395" width="300" height="40" rx="5"
|
||||
fill="#f7f7f7" stroke="#bbb" stroke-width="1"/>
|
||||
<text x="340" y="412" text-anchor="middle" font-size="10" font-weight="700" fill="#333">
|
||||
Total gradient: 400 MB · N = 4 ranks · 200 GB/s links
|
||||
</text>
|
||||
<text x="340" y="426" text-anchor="middle" font-size="9" fill="#555">
|
||||
Full AllReduce = reduce-scatter (N−1 steps) + all-gather (N−1 steps)
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
Reference in New Issue
Block a user