From bad3c273925cec2d05ecd8b063b323ff4ea05b0b Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:32:07 +0530 Subject: [PATCH 1/5] docs: define SSD ladder explainer direction --- docs/challenge-explainers.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/challenge-explainers.md b/docs/challenge-explainers.md index 4f156d8..a7d2563 100644 --- a/docs/challenge-explainers.md +++ b/docs/challenge-explainers.md @@ -101,3 +101,18 @@ The typography remains Bricolage for the question and JetBrains Mono for bytes, ticks, and tokens. Motion is a finite reveal, never a loop. The queue example chooses `Nack` rather than expiry because it matches the published worked example; the caption explains that expiry follows the same tail rule. + +## SSD ladder batch + +The ranked ladder keeps its original blue/orange identity but each rung asks +one harder question. The examples remain teaching examples, not grades. + +| Rung | Surface | Consequential action | +| --- | --- | --- | +| Compaction | Peach field, ink slot array, cyan filled holes | Three values outside the five-slot prefix move directly into its three holes. Slot 3 stays put. | +| Victim Selection | Cobalt field, one-page lime marker | Block 3 migrates one live page, then erases eight pages; block 1 would migrate two. | +| Wear Levelling | Lilac field, two contrasted wear columns | On the separate worked snapshot, block 3 has 17 erases; choosing block 1 costs one extra migration without worsening that tail. | + +The previous Compaction end frame showed `[4,7,2,9,5]` and claimed three +moves. That order requires moving the resident 7 as a fourth move. The correct +three-move end frame is `[4,2,9,7,5]`. From 0b207a7722a6ee80798c5b3d8e8c3900e35fe2d6 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:32:07 +0530 Subject: [PATCH 2/5] refactor: share finite story motion across packs --- src/packs/story-motion.tsx | 59 +++++++++++++++++++++ src/packs/systems/recovery-time-stories.tsx | 2 +- src/packs/systems/story-illustrations.tsx | 58 +------------------- src/packs/systems/work-stories.tsx | 2 +- 4 files changed, 62 insertions(+), 59 deletions(-) create mode 100644 src/packs/story-motion.tsx diff --git a/src/packs/story-motion.tsx b/src/packs/story-motion.tsx new file mode 100644 index 0000000..a5348cc --- /dev/null +++ b/src/packs/story-motion.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** A finite, viewport-triggered story with an accessible reduced-motion end state. */ +export function useVisibleStory(lastStage: number, intervalMs: number) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + const [reducedMotion, setReducedMotion] = useState(false); + const [stage, setStage] = useState(0); + + useEffect(() => { + const preference = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(preference.matches); + update(); + preference.addEventListener("change", update); + return () => preference.removeEventListener("change", update); + }, []); + + useEffect(() => { + const element = ref.current; + if (!element) return; + if (typeof IntersectionObserver === "undefined") { + const frame = window.requestAnimationFrame(() => setVisible(true)); + return () => window.cancelAnimationFrame(frame); + } + const observer = new IntersectionObserver( + ([entry]) => setVisible(Boolean(entry?.isIntersecting)), + { threshold: 0.35 }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!visible || reducedMotion || stage >= lastStage) return; + const timer = window.setTimeout(() => setStage((current) => current + 1), intervalMs); + return () => window.clearTimeout(timer); + }, [visible, reducedMotion, stage, lastStage, intervalMs]); + + return { + ref, + stage: reducedMotion ? lastStage : stage, + reducedMotion, + replay: () => setStage(0), + }; +} + +export function Replay({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/src/packs/systems/recovery-time-stories.tsx b/src/packs/systems/recovery-time-stories.tsx index 275211a..98e85ca 100644 --- a/src/packs/systems/recovery-time-stories.tsx +++ b/src/packs/systems/recovery-time-stories.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"; -import { Replay, useVisibleStory } from "./story-illustrations"; +import { Replay, useVisibleStory } from "../story-motion"; const JOURNAL_STEPS = [ "LSN 1 and 2 are synced, complete frames with valid checksums.", diff --git a/src/packs/systems/story-illustrations.tsx b/src/packs/systems/story-illustrations.tsx index bddc7a2..352f60c 100644 --- a/src/packs/systems/story-illustrations.tsx +++ b/src/packs/systems/story-illustrations.tsx @@ -1,63 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; - import { cn } from "@/lib/utils"; - -export function useVisibleStory(lastStage: number, intervalMs: number) { - const ref = useRef(null); - const [visible, setVisible] = useState(false); - const [reducedMotion, setReducedMotion] = useState(false); - const [stage, setStage] = useState(0); - - useEffect(() => { - const preference = window.matchMedia("(prefers-reduced-motion: reduce)"); - const update = () => setReducedMotion(preference.matches); - update(); - preference.addEventListener("change", update); - return () => preference.removeEventListener("change", update); - }, []); - - useEffect(() => { - const element = ref.current; - if (!element) return; - if (typeof IntersectionObserver === "undefined") { - const frame = window.requestAnimationFrame(() => setVisible(true)); - return () => window.cancelAnimationFrame(frame); - } - const observer = new IntersectionObserver( - ([entry]) => setVisible(Boolean(entry?.isIntersecting)), - { threshold: 0.35 }, - ); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (!visible || reducedMotion || stage >= lastStage) return; - const timer = window.setTimeout(() => setStage((current) => current + 1), intervalMs); - return () => window.clearTimeout(timer); - }, [visible, reducedMotion, stage, lastStage, intervalMs]); - - return { - ref, - stage: reducedMotion ? lastStage : stage, - reducedMotion, - replay: () => setStage(0), - }; -} - -export function Replay({ onClick }: { onClick: () => void }) { - return ( - - ); -} +import { Replay, useVisibleStory } from "../story-motion"; const EASY_STEPS = [ "A is oldest. B is newest. The 66-byte budget is full.", diff --git a/src/packs/systems/work-stories.tsx b/src/packs/systems/work-stories.tsx index 7089022..80ba1c9 100644 --- a/src/packs/systems/work-stories.tsx +++ b/src/packs/systems/work-stories.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"; -import { Replay, useVisibleStory } from "./story-illustrations"; +import { Replay, useVisibleStory } from "../story-motion"; function StoryFooter({ copy, finished, reducedMotion, replay }: { copy: string; From e48ebe9f81a0c8e04f70bed3e371682d5d405f20 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:32:07 +0530 Subject: [PATCH 3/5] feat: add animated SSD ladder explainers --- .../ssd/illustrations/ladder-stories.tsx | 135 ++++++++++++++++++ src/packs/ssd/index.ts | 4 + 2 files changed, 139 insertions(+) create mode 100644 src/packs/ssd/illustrations/ladder-stories.tsx diff --git a/src/packs/ssd/illustrations/ladder-stories.tsx b/src/packs/ssd/illustrations/ladder-stories.tsx new file mode 100644 index 0000000..063c9e7 --- /dev/null +++ b/src/packs/ssd/illustrations/ladder-stories.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Replay, useVisibleStory } from "@/packs/story-motion"; + +function Footer({ text, final, still, replay }: { text: string; final: boolean; still: boolean; replay: () => void }) { + return ( +
+

{text}

+ {final && !still && } +
+ ); +} + +export const COMPACTION_FRAMES = [ + [4, -1, -1, 7, -1, 2, -1, -1, 9, -1, -1, 5], + [4, 2, -1, 7, -1, -1, -1, -1, 9, -1, -1, 5], + [4, 2, 9, 7, -1, -1, -1, -1, -1, -1, -1, 5], + [4, 2, 9, 7, 5, -1, -1, -1, -1, -1, -1, -1], +] as const; +const COMPACTION_STEPS = [ + "Five live values; three sit beyond the required prefix.", + "Move slot 5 into the first hole, slot 1.", + "Move slot 8 into the next hole, slot 2.", + "Move slot 11 into slot 4. Slot 3 never moves.", +] as const; + +export function CompactionStory() { + const { ref, stage, reducedMotion, replay } = useVisibleStory(3, 1200); + const moves = ["find the holes", "5 → 1", "8 → 2", "11 → 4"][stage]; + return ( +
+
+
+

Three moves. No more.

+ {stage} / 3 +
+ +
+
+
+ ); +} + +const VICTIM_STEPS = [ + "Block 1 has two live pages; block 3 has only one.", + "Choose block 3. Copy its one live page into reserve.", + "Erase the whole block. Seven net free pages are gained.", +] as const; + +function PageRow({ label, valid, active, erased }: { label: string; valid: number; active: boolean; erased: boolean }) { + return ( +
+
BLOCK {label}{erased ? "8 FREE" : valid + " LIVE"}
+
+ {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+
{erased ? "one copied → eight erased" : (8 - valid) + " dead · " + valid + " to copy"}
+
+ ); +} + +export function VictimStory() { + const { ref, stage, reducedMotion, replay } = useVisibleStory(2, 1500); + return ( +
+
+
+

One copy. Eight free.

+ 8 pages / block +
+ +
+ {stage === 0 ? "compare migration cost" : "select block 3"}{stage === 2 ? "+7 net free" : "reserve holds the copy"} +
+
+
+
+ ); +} + +const WEAR_STEPS = [ + "This worked snapshot offers two mostly dead blocks.", + "Greedy picks block 3: one migration, but 17 prior erases.", + "Block 1 costs one extra migration and avoids the worn tail.", +] as const; + +export function WearChoiceStory() { + const { ref, stage, reducedMotion, replay } = useVisibleStory(2, 1450); + return ( +
+
+
+

Cheap can wear out.

+ one decision +
+ +
+ ); +} diff --git a/src/packs/ssd/index.ts b/src/packs/ssd/index.ts index a21c57f..85034cb 100644 --- a/src/packs/ssd/index.ts +++ b/src/packs/ssd/index.ts @@ -12,6 +12,7 @@ import { registerIllustration, registerInterface } from "../registry"; import { BlockGrid } from "./illustrations/block-grid"; import { EfficiencyDial, WearHistogram, WriteAmpBar } from "./illustrations/metrics"; import { SlotStrip } from "./illustrations/slot-strip"; +import { CompactionStory, VictimStory, WearChoiceStory } from "./illustrations/ladder-stories"; import { SSD_INTERFACES } from "./interfaces"; export const PACK_ID = "ssd"; @@ -21,6 +22,9 @@ registerIllustration("ssd.blockGrid", BlockGrid); registerIllustration("ssd.writeAmpBar", WriteAmpBar); registerIllustration("ssd.wearHistogram", WearHistogram); registerIllustration("ssd.efficiencyDial", EfficiencyDial); +registerIllustration("ssd.compaction-story", CompactionStory); +registerIllustration("ssd.victim-story", VictimStory); +registerIllustration("ssd.wear-story", WearChoiceStory); for (const [task, snippets] of Object.entries(SSD_INTERFACES)) { for (const language of LANGUAGE_IDS) { From 8ace817331524a6716673ccb9c48ff60d67a4f55 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:32:07 +0530 Subject: [PATCH 4/5] content: connect SSD stories and correct compaction end state --- prisma/seed/challenges.ts | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/prisma/seed/challenges.ts b/prisma/seed/challenges.ts index c75a531..10442e4 100644 --- a/prisma/seed/challenges.ts +++ b/prisma/seed/challenges.ts @@ -13,21 +13,6 @@ import { COMPACTION_STARTERS, VICTIM_STARTERS } from "./starters"; const ALL_LANGUAGES = ["GO", "PYTHON", "C", "CPP", "JAVA", "RUST"]; -/** A small hand-made instance, used to illustrate rather than to grade. */ -const EXAMPLE_SLOTS = [4, -1, -1, 7, -1, 2, -1, -1, 9, -1, -1, 5]; - -/** A device mid-life: some blocks mostly dead, one already empty. */ -const EXAMPLE_BLOCKS = [ - { index: 0, valid: 7, invalid: 1, free: 0, eraseCount: 3, isOverProvision: false }, - { index: 1, valid: 2, invalid: 6, free: 0, eraseCount: 4, isOverProvision: false }, - { index: 2, valid: 8, invalid: 0, free: 0, eraseCount: 2, isOverProvision: false }, - { index: 3, valid: 1, invalid: 7, free: 0, eraseCount: 5, isOverProvision: false }, - { index: 4, valid: 4, invalid: 3, free: 1, eraseCount: 3, isOverProvision: false }, - { index: 5, valid: 0, invalid: 0, free: 8, eraseCount: 6, isOverProvision: true }, -]; - -const UNEVEN_WEAR = [3, 4, 2, 5, 3, 17, 4, 3, 2, 4, 3, 5]; - export const CHALLENGES = [ // ------------------------------------------------------------ rung 1 { @@ -82,16 +67,17 @@ export const CHALLENGES = [ { kind: "figure", label: "FIG 1", - illustration: { id: "ssd.slotStrip", props: { slots: EXAMPLE_SLOTS, prefix: 5 } }, + presentation: "immersive" as const, + illustration: { id: "ssd.compaction-story", props: {} }, caption: - "Five live values scattered across twelve slots. The line marks where they need to end up: slots 0 to 4.", + "Five live values scattered across twelve slots. Three direct moves fill the empty positions in the target prefix, slots 0 to 4.", }, { kind: "figure", label: "FIG 2", illustration: { id: "ssd.slotStrip", - props: { slots: [4, 7, 2, 9, 5, -1, -1, -1, -1, -1, -1, -1], prefix: 5, highlight: [1, 2, 4] }, + props: { slots: [4, 2, 9, 7, 5, -1, -1, -1, -1, -1, -1, -1], prefix: 5, highlight: [1, 2, 4] }, }, caption: "Move the three values outside the prefix into its three holes. Each value moves once; all free slots end up together at the back.", @@ -201,11 +187,12 @@ export const CHALLENGES = [ kind: "figure", label: "FIG 1", illustration: { - id: "ssd.blockGrid", - props: { blocks: EXAMPLE_BLOCKS, pagesPerBlock: 8 }, + id: "ssd.victim-story", + props: {}, }, + presentation: "immersive" as const, caption: - "Six blocks of eight pages. Block 3 is almost entirely dead; block 2 is entirely live. Block 5 is over-provision space, held back for migrations.", + "Among six blocks of eight pages, block 3 needs only one live-page migration before erase. The held-back reserve receives that page.", }, { kind: "callout", @@ -346,9 +333,10 @@ export const CHALLENGES = [ { kind: "figure", label: "FIG 1", - illustration: { id: "ssd.wearHistogram", props: { eraseCounts: UNEVEN_WEAR } }, + illustration: { id: "ssd.wear-story", props: {} }, + presentation: "immersive" as const, caption: - "This policy optimises only for write amplification. Block 5 has 17 erases; the next highest has 5. One block is wearing more than three times as fast as the others.", + "A wear-aware choice may migrate one more live page to avoid erasing the most-worn block again. Whether that trade is worthwhile depends on future writes.", }, { kind: "callout", From f02a9f33c6298c3b38cdcb11b28ec2a23391b0a8 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:32:07 +0530 Subject: [PATCH 5/5] test: verify SSD moves, stories, and responsive layouts --- tests/e2e/challenge-explainers.spec.ts | 35 ++++++++++++++++++++++++++ tests/e2e/layout-audit.spec.ts | 4 +++ tests/unit/lib/arena/statement.test.ts | 4 +++ tests/unit/packs/ssd-stories.test.ts | 30 ++++++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 tests/unit/packs/ssd-stories.test.ts diff --git a/tests/e2e/challenge-explainers.spec.ts b/tests/e2e/challenge-explainers.spec.ts index 699583e..a404ac8 100644 --- a/tests/e2e/challenge-explainers.spec.ts +++ b/tests/e2e/challenge-explainers.spec.ts @@ -83,3 +83,38 @@ test.describe("challenge explainers", () => { await expect(page.getByText("A is oldest. B is newest. The 66-byte budget is full.")).toBeVisible(); }); }); + +test.describe("ranked SSD ladder explainers", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + }); + + test("compaction fills three holes without moving the resident value", async ({ page }) => { + await page.goto("/challenges/compaction"); + await expect(page.getByRole("img", { name: /Slot 3 remains in place/ })).toBeVisible(); + await expect(page.getByText("free suffix: 7")).toBeVisible(); + await expect(page.getByText("Move slot 11 into slot 4. Slot 3 never moves.")).toBeVisible(); + }); + + test("victim selection migrates one live page before erase", async ({ page }) => { + await page.goto("/challenges/victim-selection"); + await expect(page.getByRole("img", { name: /Block 1 would need two migrations/ })).toBeVisible(); + await expect(page.getByText("+7 net free")).toBeVisible(); + await expect(page.getByText("Erase the whole block. Seven net free pages are gained.")).toBeVisible(); + }); + + test("wear-aware choice pays one migration to avoid the worst-worn block", async ({ page }) => { + await page.goto("/challenges/wear-levelling"); + await expect(page.getByRole("img", { name: /not a guaranteed global optimum/ })).toBeVisible(); + await expect(page.getByText("wear-aware → block 1")).toBeVisible(); + await expect(page.getByText("+1 migration")).toBeVisible(); + }); + + test("compaction's finite sequence settles and replays", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/challenges/compaction"); + await expect(page.getByText("Move slot 11 into slot 4. Slot 3 never moves.")).toBeVisible({ timeout: 10_000 }); + await page.getByRole("button", { name: "Replay" }).click(); + await expect(page.getByText("Five live values; three sit beyond the required prefix.")).toBeVisible(); + }); +}); diff --git a/tests/e2e/layout-audit.spec.ts b/tests/e2e/layout-audit.spec.ts index db46675..91acead 100644 --- a/tests/e2e/layout-audit.spec.ts +++ b/tests/e2e/layout-audit.spec.ts @@ -21,6 +21,10 @@ const ROUTES = [ { path: "/challenges/journal-recovery-easy", name: "arena-journal" }, { path: "/challenges/lsm-compactor-easy", name: "arena-lsm" }, { path: "/challenges/stream-windows-easy", name: "arena-windows" }, + { path: "/challenges/chunk-store-easy", name: "arena-chunk" }, + { path: "/challenges/deadline-scheduler-easy", name: "arena-scheduler" }, + { path: "/challenges/durable-queue-easy", name: "arena-queue" }, + { path: "/challenges/fair-gate-easy", name: "arena-gate" }, ] : []), { path: "/dashboard", name: "dashboard" }, { path: "/leaderboard", name: "leaderboard" }, diff --git a/tests/unit/lib/arena/statement.test.ts b/tests/unit/lib/arena/statement.test.ts index 86b4c9f..7c62809 100644 --- a/tests/unit/lib/arena/statement.test.ts +++ b/tests/unit/lib/arena/statement.test.ts @@ -67,6 +67,10 @@ describe("arena educational statements", () => { expect(parseArenaStatement(figure("systems.journal-story", {}))).toHaveLength(1); expect(parseArenaStatement(figure("systems.lsm-story", {}))).toHaveLength(1); expect(parseArenaStatement(figure("systems.window-story", {}))).toHaveLength(1); + for (const id of ["systems.chunk-story", "systems.scheduler-story", "systems.queue-story", "systems.gate-story"]) { + expect(parseArenaStatement(figure(id, {}))).toHaveLength(1); + expect(() => parseArenaStatement(figure(id, { imaginary: true }))).toThrow(); + } expect(() => parseArenaStatement(figure("systems.cache-story", { mode: "invented" }))).toThrow(); expect(() => parseArenaStatement(figure("systems.lost-ack-story", {}, "flashing"))).toThrow(); expect(() => parseArenaStatement(figure("systems.window-story", { imaginary: true }))).toThrow(); diff --git a/tests/unit/packs/ssd-stories.test.ts b/tests/unit/packs/ssd-stories.test.ts new file mode 100644 index 0000000..705c602 --- /dev/null +++ b/tests/unit/packs/ssd-stories.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { CHALLENGES } from "../../../prisma/seed/challenges"; +import { COMPACTION_FRAMES } from "@/packs/ssd/illustrations/ladder-stories"; + +describe("compaction explainer", () => { + it("shows exactly three legal moves and preserves the live values", () => { + expect(COMPACTION_FRAMES).toHaveLength(4); + const startingValues = COMPACTION_FRAMES[0].filter((value) => value !== -1).sort(); + for (let frame = 1; frame < COMPACTION_FRAMES.length; frame++) { + const before = COMPACTION_FRAMES[frame - 1]; + const after = COMPACTION_FRAMES[frame]; + const changed = before.flatMap((value, slot) => value === after[slot] ? [] : [slot]); + expect(changed).toHaveLength(2); + const source = changed.find((slot) => before[slot] !== -1)!; + const destination = changed.find((slot) => before[slot] === -1)!; + expect(after[source]).toBe(-1); + expect(after[destination]).toBe(before[source]); + expect(after.filter((value) => value !== -1).sort()).toEqual(startingValues); + } + expect(COMPACTION_FRAMES.at(-1)?.slice(0, 5)).toEqual([4, 2, 9, 7, 5]); + expect(COMPACTION_FRAMES.at(-1)?.slice(5)).toEqual(Array(7).fill(-1)); + }); + + it("keeps the static final figure aligned with the animated final frame", () => { + const compaction = CHALLENGES.find((challenge) => challenge.slug === "compaction"); + const figure = compaction?.blocks.filter((block) => block.kind === "figure")[1]; + expect((figure?.illustration?.props as { slots?: number[] })?.slots).toEqual(COMPACTION_FRAMES.at(-1)); + }); +});