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]`.
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",
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
+
+
+
PACK INTO 0–4{moves}
+
+ {COMPACTION_FRAMES[stage].map((value, index) => (
+
+ {index}
+ {value === -1 ? "·" : value}
+
+ ))}
+
+
target: 5 live{stage === 3 ? "free suffix: 7" : "holes in prefix: 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
+
+
+
+
= 1} erased={stage === 2} />
+
+
+ {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
+
+
+ {[
+ { block: "3", live: 1, dead: 7, erase: 17, chosen: false },
+ { block: "1", live: 2, dead: 6, erase: 4, chosen: true },
+ ].map((candidate) => (
+
+
BLOCK {candidate.block}
+
{candidate.erase}
+
prior erases
+
+
{candidate.live} live · {candidate.dead} dead
+
+ ))}
+
+
+ {stage === 0 ? "compare both costs" : stage === 1 ? "greedy → block 3" : "wear-aware → block 1"}
+ {stage === 2 ? "+1 migration" : "tail: 17 erases"}
+
+
+
+
+ );
+}
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) {
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;
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));
+ });
+});