# Learn Layer Implementation Plan

> **For agentic workers:** Task 1 builds the infrastructure. Task 2 authors the reference lesson. Tasks 3+ author one lesson each and may run in parallel — one agent per lesson, each owning only its own `lessons/<item-id>/` directory.

**Goal:** Teaching that produces transfer, coupled to the 34-item syllabus and to the corpus, so a failed drill routes to the one lesson that addresses it.

**Binding spec:** `docs/specs/2026-08-18-learning-design.md` — read it before writing any lesson.

**The correction this implements:** *"this should be a very effective LEARNING tool, not a test+mcq forward app with teaching content as an afterthought."* Assessment is retained; its job becomes aiming the teaching.

**Available and verified:** `server/lib/junit.mjs` (`runSuite`), `server/lib/diff.mjs` (`measureChange`), `server/lib/java.mjs` (`compile`, `writeFiles`), `.toolchain/jdk-21`, `corpus/_syllabus.json` (34 items).

**Conventions:** no git (`node tools/snapshot.mjs "<label>"`), zero runtime dependencies, ESM `.mjs`, bare `node --test`, strict TDD, default Java package.

---

---

## AMENDMENT · Three tiers of lesson, so 46 lessons is buildable

The user asked whether 43 remaining lessons are *"huge ones? cut em down if needed/recommended."*

**Individually they are small** — capped at 8 minutes, `idea.md` under 250 words. The problem is the
artifact count: the full treatment is about ten files per lesson, so 46 lessons is roughly 430 authored
files. That is the number worth cutting.

**The cut is principled, not proportional: an artifact is built where it teaches, and skipped where it
would be invented.** A contrast pair for "policy interface versus an if-chain" teaches a great deal — it
is the app's whole thesis in two directories. A contrast pair for "records are immutable" is a fabricated
comparison that wastes his time and the author's.

### Tier 1 · Full treatment — 14 items

`A1 A2 A5 A7 · B1 B6 B7 · C1 C2 C3 C4 · E1 E2 E3`

`idea.md` + `worked/` + `NOTES.md` + `faded/` + **`contrast/`** + `when-not.md` + both prompt files.

These are the items where transfer actually fails and where a real alternative exists to contrast
against. The **measured** contrast is the point: *Design A absorbed the change in 31 lines across 3
files; Design B added one file and changed nothing.* Those numbers come from the same `measureChange()`
that grades him, so the lesson and the grader share one instrument.

### Tier 2 · Worked plus faded — 15 items

`A3 A4 A6 · B2 B3 B4 B5 B8 · C5 · D1 D2 D3 · E4 E5 · F4`

Same as Tier 1 **without `contrast/`**. `when-not.md` only where the technique can genuinely be overused
— B2, B3, B8 and C5 qualify; A4 and D2 do not, and forcing one would teach a cost that does not exist.

### Tier 3 · Worked plus a text completion — 5 items

`F1 F2 F3 F5 · D4`

Process skills, where a Java code-gap is artificial. `faded/` uses `"fadedKind": "text"` — already
supported by `validateLessonDir` — so the completion is a written requirement list or a scope
negotiation rather than a compiled gap. No `contrast/`, no `when-not.md`.

**F2's faded stage** is a good illustration: he is given a prompt and a partial requirement list with the
out-of-scope section blanked, and completes it. That is the actual skill, and no amount of Java would
exercise it.

### Java Bridge · 12 items — unchanged

`worked/` + `faded/` + **`from-cpp.md`**, no `when-not.md`. Already specified and already proven by J1,
J2 and J6.

### What this saves

Contrast pairs drop from 34 to 14, and roughly 20 `when-not.md` files disappear. That is about **35% of
the remaining artifacts**, with every mechanism intact wherever it earns its place. Nothing is
lower-quality; there is simply less that is invented.

**The tier is recorded in `lesson.json` as `"tier": 1 | 2 | 3 | "bridge"`,** so `validateLessonDir` can
require the right artifacts per tier instead of demanding all of them from everything. That validator
change is a prerequisite for authoring Tier 2 and 3 lessons.

---

## The three non-negotiables

Every lesson is checked against these mechanically by Task 1's validator.

1. **`idea.md` ≤ 150 words.** If a concept needs more, it is two concepts. This cap exists because the passive register is what failed the user in the first place.
2. **Runnable Java in every lesson.** No prose-only lesson may exist. `worked/`, `faded/` and `contrast/` all compile.
3. **`when-not.md` is mandatory.** A pattern taught without its cost produces exactly the over-abstraction STANDARD v1.0 penalises at D3 level 3. Teaching Strategy without teaching when it is overkill would make him worse at interviews, not better.

**No MCQs anywhere.** No LLD round asks one. They would inflate a coverage number without moving the skill.

## Lesson directory shape

```
lessons/<item-id>/                 e.g. lessons/B1/
  lesson.json                      {id, title, group, minutes, prerequisites[], failureTags[]}
  idea.md                          <=150 words: what it is, the one problem it solves
  worked/
    src/*.java                     complete, compiling, heavily annotated
    NOTES.md                       per-line commentary: the invariant a line holds, the bug it prevents
  faded/
    src/*.java                     the same design with the key lines replaced by a GAP marker
    GapTest.java                   hidden check for the gap only
    solution/*.java                the filled-in version, revealed after an attempt
  contrast/
    a/*.java                       the flawed design (both compile, both pass base)
    b/*.java                       the well-seamed design
    CurveballTest.java             the change that separates them
    curveball.md                   the requirement, in an interviewer's words
  when-not.md                      the cost of overuse, with a concrete bad example
  predict.json                     questions asked BEFORE anything reveals
  retrieval.json                   short generative prompts for spaced resurfacing
```

**The gap marker** in `faded/src/` is exactly:

```java
        // GAP: <one-line description of what belongs here>
```

The app renders those lines as an editable region and locks everything else. One marker may span several
consecutive lines; 3–8 lines total per lesson.

---

## Task 1: Lesson infrastructure

**Files:** create `server/lib/lessons.mjs`, `server/test/lessons.test.mjs`, `tools/validate-lessons.mjs`

- [ ] **Step 1: Write the failing test**

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  wordCount, findGaps, applyGapAnswers, FAILURE_TAG_TO_LESSON,
  lessonFor, validateLessonDir, nextStage,
} from '../lib/lessons.mjs';

test('wordCount ignores markdown syntax when enforcing the 150-word cap', () => {
  assert.equal(wordCount('# Title\n\nTwo words'), 2, 'headings are not prose');
  assert.equal(wordCount('a `code` b'), 3);
  assert.equal(wordCount('- one\n- two'), 2);
});

test('findGaps locates each GAP marker with its line number and description', () => {
  const src = [
    'class Lot {',
    '    // GAP: return the fee for this vehicle type',
    '    long fee() { return 0; }',
    '    // GAP: pick a free spot',
    '}',
  ].join('\n');
  const gaps = findGaps(src);
  assert.equal(gaps.length, 2);
  assert.deepEqual(gaps[0], { line: 2, description: 'return the fee for this vehicle type' });
  assert.equal(gaps[1].line, 4);
});

test('findGaps returns nothing for a file with no markers', () => {
  assert.deepEqual(findGaps('class A {}'), []);
});

test('applyGapAnswers substitutes each answer for its marker line', () => {
  const src = 'class A {\n    // GAP: return one\n}';
  const out = applyGapAnswers(src, ['    return 1;']);
  assert.equal(out, 'class A {\n    return 1;\n}');
  assert.ok(!out.includes('GAP'));
});

test('applyGapAnswers rejects a mismatched answer count rather than guessing', () => {
  assert.throws(() => applyGapAnswers('// GAP: x', []), /expected 1 answer/i);
  assert.throws(() => applyGapAnswers('// GAP: x', ['a', 'b']), /expected 1 answer/i);
});

test('every failure tag routes to a real lesson and a stage', () => {
  for (const [tag, route] of Object.entries(FAILURE_TAG_TO_LESSON)) {
    assert.match(route.item, /^[A-F]\d$/, `${tag} must route to a syllabus item id`);
    assert.ok(['worked', 'faded', 'contrast', 'when-not'].includes(route.stage),
      `${tag} routes to an unknown stage ${route.stage}`);
  }
});

test('the failure tags the graders actually emit are all routable', () => {
  // These strings are produced by grade-phase2/3/4/6 and grade-phase7.
  for (const tag of ['if-chain-instead-of-policy', 'god-class', 'leaked-mutable-state',
                     'missing-seam', 'lost-update', 'deadlock', 'over-engineered',
                     'missed-out-of-scope', 'no-main', 'ran-out-of-clock',
                     'rewrote-existing-files']) {
    assert.ok(FAILURE_TAG_TO_LESSON[tag], `no lesson routes from ${tag}`);
  }
});

test('nextStage advances worked -> faded -> independent and stops', () => {
  assert.equal(nextStage({ studied: false, fadedPasses: 0 }), 'worked');
  assert.equal(nextStage({ studied: true, fadedPasses: 0 }), 'faded');
  assert.equal(nextStage({ studied: true, fadedPasses: 1 }), 'faded');
  assert.equal(nextStage({ studied: true, fadedPasses: 2 }), 'independent');
});

test('nextStage sends a failed independent attempt back to faded', () => {
  assert.equal(nextStage({ studied: true, fadedPasses: 2, lastIndependentFailed: true }), 'faded');
});

test('validateLessonDir reports every missing required file', () => {
  const errors = validateLessonDir('/nonexistent/B1');
  assert.ok(errors.some((e) => /idea\.md/.test(e)));
  assert.ok(errors.some((e) => /when-not\.md/.test(e)));
});
```

- [ ] **Step 2** — run it, confirm `Cannot find module`

- [ ] **Step 3: Implement `server/lib/lessons.mjs`**

```javascript
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, basename } from 'node:path';
import { ROOT } from './paths.mjs';

export const LESSONS_DIR = join(ROOT, 'lessons');
export const IDEA_WORD_CAP = 150;
export const FADED_PASSES_REQUIRED = 2;

/** Prose words only — markdown syntax and code spans are not teaching. */
export function wordCount(md) {
  return String(md)
    .replace(/```[\s\S]*?```/g, ' ')
    .replace(/`[^`]*`/g, ' ')
    .replace(/^#{1,6}\s+/gm, '')
    .replace(/^[-*+]\s+/gm, '')
    .replace(/[*_>[\]()#|]/g, ' ')
    .split(/\s+/)
    .filter(Boolean).length;
}

const GAP_RE = /^\s*\/\/\s*GAP:\s*(.+?)\s*$/;

export function findGaps(source) {
  const out = [];
  source.split(/\r?\n/).forEach((line, i) => {
    const m = GAP_RE.exec(line);
    if (m) out.push({ line: i + 1, description: m[1] });
  });
  return out;
}

export function applyGapAnswers(source, answers) {
  const lines = source.split(/\r?\n/);
  const gapIndexes = lines.map((l, i) => (GAP_RE.test(l) ? i : -1)).filter((i) => i >= 0);
  if (gapIndexes.length !== answers.length) {
    throw new Error(`expected ${gapIndexes.length} answer(s), got ${answers.length}`);
  }
  gapIndexes.forEach((lineIdx, n) => { lines[lineIdx] = answers[n]; });
  return lines.join('\n');
}

/**
 * A failed drill must land on ONE lesson at ONE stage, never a reading list.
 * Keys are the tags the graders emit; keep in sync with grade-phase2/3/4/6/7.
 */
export const FAILURE_TAG_TO_LESSON = {
  'if-chain-instead-of-policy': { item: 'B1', stage: 'faded' },
  'god-class':                  { item: 'C1', stage: 'worked' },
  'leaked-mutable-state':       { item: 'A7', stage: 'faded' },
  'missing-seam':               { item: 'C2', stage: 'contrast' },
  'missing-type':               { item: 'A1', stage: 'faded' },
  'rewrote-existing-files':     { item: 'C2', stage: 'contrast' },
  'over-diff-budget':           { item: 'C2', stage: 'contrast' },
  'lost-update':                { item: 'E2', stage: 'worked' },
  deadlock:                     { item: 'E3', stage: 'worked' },
  race:                         { item: 'E1', stage: 'worked' },
  'over-engineered':            { item: 'B1', stage: 'when-not' },
  'missed-out-of-scope':        { item: 'F2', stage: 'worked' },
  'no-main':                    { item: 'F5', stage: 'worked' },
  'ran-out-of-clock':           { item: 'F5', stage: 'worked' },
  'suite-not-green':            { item: 'F5', stage: 'worked' },
  'curveball-suite-failed':     { item: 'C2', stage: 'contrast' },
  'did-not-compile':            { item: 'A1', stage: 'worked' },
};

/** Adaptive fading: scaffolding is removed once, and restored on failure. */
export function nextStage({ studied, fadedPasses, lastIndependentFailed = false }) {
  if (!studied) return 'worked';
  if (lastIndependentFailed) return 'faded';
  return fadedPasses >= FADED_PASSES_REQUIRED ? 'independent' : 'faded';
}

const REQUIRED = ['lesson.json', 'idea.md', 'when-not.md', 'predict.json', 'retrieval.json'];
const REQUIRED_DIRS = ['worked', 'faded'];

function javaFilesIn(dir) {
  if (!existsSync(dir)) return [];
  const out = [];
  const walk = (d) => {
    for (const e of readdirSync(d)) {
      const full = join(d, e);
      if (statSync(full).isDirectory()) walk(full);
      else if (e.endsWith('.java')) out.push(full);
    }
  };
  walk(dir);
  return out;
}

export function validateLessonDir(dir) {
  const errors = [];
  const id = basename(dir);

  for (const f of REQUIRED) {
    if (!existsSync(join(dir, f))) errors.push(`${id}: missing ${f}`);
  }
  for (const d of REQUIRED_DIRS) {
    if (!existsSync(join(dir, d))) errors.push(`${id}: missing ${d}/`);
  }
  if (errors.length) return errors;

  const idea = readFileSync(join(dir, 'idea.md'), 'utf8');
  const words = wordCount(idea);
  if (words > IDEA_WORD_CAP) {
    errors.push(`${id}: idea.md is ${words} words, cap is ${IDEA_WORD_CAP} — if it needs more, it is two items`);
  }

  if (javaFilesIn(join(dir, 'worked')).length === 0) {
    errors.push(`${id}: worked/ contains no runnable Java — no prose-only lesson may exist`);
  }

  const fadedFiles = javaFilesIn(join(dir, 'faded', 'src'));
  if (fadedFiles.length === 0) errors.push(`${id}: faded/src contains no Java`);
  const gapCount = fadedFiles.reduce((n, f) => n + findGaps(readFileSync(f, 'utf8')).length, 0);
  if (gapCount < 1) errors.push(`${id}: faded/ has no GAP markers`);
  if (gapCount > 8) errors.push(`${id}: faded/ has ${gapCount} gaps, more than 8 stops being a completion problem`);
  if (!existsSync(join(dir, 'faded', 'GapTest.java'))) errors.push(`${id}: faded/GapTest.java is required`);
  if (!existsSync(join(dir, 'faded', 'solution'))) errors.push(`${id}: faded/solution/ is required`);

  const meta = JSON.parse(readFileSync(join(dir, 'lesson.json'), 'utf8'));
  if (meta.id !== id) errors.push(`${id}: lesson.json id is ${meta.id}`);
  if (!(meta.minutes > 0 && meta.minutes <= 8)) errors.push(`${id}: minutes must be 1-8, got ${meta.minutes}`);

  const predict = JSON.parse(readFileSync(join(dir, 'predict.json'), 'utf8'));
  if (!Array.isArray(predict.questions) || predict.questions.length === 0) {
    errors.push(`${id}: predict.json needs at least one question — nothing reveals before he commits an answer`);
  }
  for (const q of predict.questions ?? []) {
    if (/^\s*(a\)|1\.|\(a\))/i.test(q.prompt ?? '') || Array.isArray(q.options)) {
      errors.push(`${id}: predict question looks like an MCQ — those are banned, answers must be generated`);
    }
  }

  return errors;
}

export function lessonFor(tag) {
  return FAILURE_TAG_TO_LESSON[tag] ?? null;
}
```

- [ ] **Step 4** — confirm 10 tests pass

- [ ] **Step 5: `tools/validate-lessons.mjs`**

Walk `lessons/`, run `validateLessonDir` on each, and additionally assert every `lesson.json.failureTags`
entry exists in `FAILURE_TAG_TO_LESSON`. Exit 1 with a named list on any error. Mirror
`tools/validate-corpus.mjs`'s output style.

- [ ] **Step 6** — run it against an empty `lessons/`; it should pass trivially (no lessons yet) and print
  a count. **Step 7** — snapshot `learn-1-infrastructure`

---

## Task 2: Lesson B1 — the policy interface. The reference lesson.

Author this one alone. It becomes the template for the other 33, and it is the single most frequently
routed-to lesson because `if-chain-instead-of-policy` is the most common design failure.

- [ ] **Step 1: `lesson.json`**

```json
{
  "id": "B1",
  "title": "Policy interface — the Strategy seam",
  "group": "Seams",
  "minutes": 8,
  "prerequisites": ["A1"],
  "failureTags": ["if-chain-instead-of-policy", "over-engineered"]
}
```

- [ ] **Step 2: `idea.md`** — under 150 words, and count them

Must convey: a policy interface exists so that **adding a behaviour means adding a class rather than
editing a method**. The tell that you need one is a conditional on a type. The tell that you do not is a
single implementation with no second one in sight. Name the cost concretely: an `if`-chain forces you
back into an old file every time the rules change, and that is what the extensibility score measures.

- [ ] **Step 3: `worked/`** — a complete `PricingPolicy` example with `NOTES.md`

Annotate per line in the register the user asked for: **the invariant a line maintains and the bug it
prevents** — not what the syntax does. For example, on `Map<VehicleType, PricingPolicy>`: *"An EnumMap
keyed by the enum makes a missing case impossible to ignore — a HashMap would silently return null for a
type added later, and you would find out in production rather than at compile time."*

- [ ] **Step 4: `faded/`** — the same design, 4 gaps

Remove: the interface method signature, the `EnumMap` wiring, one concrete policy's `fee` body, and the
lookup-with-fallback. `GapTest.java` must test **only the gap behaviour**, and — per the deterministic
contract — must not reference any type the lesson does not itself provide.

- [ ] **Step 5: `contrast/`** — the measured pair

`a/` selects price with `if (type == CAR) ... else if (type == TRUCK) ...` inside a `ParkingLot` method.
`b/` uses the policy seam. Both compile and both satisfy the base behaviour. `CurveballTest.java`
introduces motorbike pricing. Then **measure both** with `measureChange` and record the real numbers in
`curveball.md`:

> *Design A absorbed it in N lines across M pre-existing files. Design B added one file and changed nothing.*

**Do not invent these numbers — run the measurement.** The lesson and the grader must share one
instrument, which is what makes the standard feel non-arbitrary.

- [ ] **Step 6: `when-not.md`**

The mandatory counterweight. Show a concrete bad example: a `PricingPolicy` with exactly one
implementation, `StandardPricing`, and no second one anticipated — plus the `PricingPolicyFactory`
someone adds next. State plainly that STANDARD v1.0's D3 level 3 requires a *minimal* seam set, so this
scores **lower**, and that `over-engineered` is a tracked failure tag routing back to this file.

- [ ] **Step 7: `predict.json`** — generative, never multiple choice

```json
{
  "questions": [
    { "id": "p1", "prompt": "Motorbike pricing is about to be added. Name every file you would have to open in Design A.", "reveals": "contrast" },
    { "id": "p2", "prompt": "Roughly how many lines will Design A need? Give a number.", "reveals": "contrast", "kind": "estimate" },
    { "id": "p3", "prompt": "Write the interface you would extract. Signature only.", "reveals": "worked", "kind": "code" }
  ]
}
```

`p2` feeds the calibration signal — being wrong is recorded as calibration, never as failure.

- [ ] **Step 8: `retrieval.json`** — short generative prompts for spaced resurfacing

- [ ] **Step 9: Verify everything compiles and the measurement is real**

```bash
node tools/validate-lessons.mjs
mkdir -p workspace/_lesson_check && cp lessons/B1/faded/solution/*.java lessons/B1/faded/GapTest.java workspace/_lesson_check/
node -e "import('./server/lib/junit.mjs').then(async m => console.log(JSON.stringify((await m.runSuite({attemptRoot:'workspace/_lesson_check', testDirs:[]})).summary)))"
```

The faded solution must pass its own `GapTest`. **If it does not, the gap or the test is wrong.** Then
measure the contrast pair for real and paste the numbers into `curveball.md`.

- [ ] **Step 10** — clean up `workspace/`, snapshot `learn-2-lesson-B1`

---

## Tasks 3–13: the eleven remaining priority lessons

**Only after B1 passes validation.** Each follows B1's structure and may be authored in parallel, one
agent per lesson.

| # | Item | Title | Routed to from |
|---|---|---|---|
| 3 | **B6** | State machine with a transition table as data | design review |
| 4 | **C1** | Single responsibility and class decomposition | `god-class` |
| 5 | **C2** | Open/closed — extend by adding, not editing | `missing-seam`, `rewrote-existing-files`, `over-diff-budget` |
| 6 | **C3** | Dependency inversion, including injecting the clock | untestable-time review |
| 7 | **A1** | Enum carrying state and behaviour | `missing-type`, `did-not-compile` |
| 8 | **A2** | Value object / record, immutable by construction | design review |
| 9 | **A5** | Invariant placement — no invalid instance can exist | D5 review |
| 10 | **A7** | Collection encapsulation — no mutable internals escape | `leaked-mutable-state` |
| 11 | **E1** | Identifying shared mutable state | `race` |
| 12 | **E2** | Atomicity vs visibility | `lost-update` |
| 13 | **F2** | Explicit scope negotiation — stating what you are not building | `missed-out-of-scope` |

**F2 and F5 are process lessons, not code lessons.** Their `worked/` is still runnable Java where it can
be — F2's worked example is a *written* requirement extraction with its out-of-scope list — but their
`faded/` may be a text completion rather than a code gap. If so, `GapTest.java` is replaced by a keyword
check declared in `lesson.json` as `"fadedKind": "text"`, and Task 1's validator needs a small allowance
for it. **Report this rather than forcing prose into a Java file.**

## Tasks 14–35: the remaining twenty-two lessons

`A3, A4, A6, B2, B3, B4, B5, B7, B8, C4, C5, D1, D2, D3, D4, E3, E4, E5, F1, F3, F4, F5`.
Same structure, same caps, authored last because they route from failures that occur less often.

---

## Self-review

**Spec coverage.** Worked → faded → independent is `nextStage` plus the `faded/` directory in every
lesson. Contrast pairs with a measured consequence are Task 2 Step 5 and are validated by requiring real
numbers from `measureChange`. Predict-before-reveal is `predict.json`, with an MCQ detector in the
validator because the ban is a design requirement rather than a preference. Failure-tag routing is
`FAILURE_TAG_TO_LESSON`, with a test asserting **every tag the graders actually emit** is routable — that
test is what stops a failure landing the user nowhere. Adaptive fading and the return-to-faded-on-failure
rule are both in `nextStage`. Caps are enforced in `validateLessonDir`.

**The riskiest coupling, named.** `FAILURE_TAG_TO_LESSON`'s keys must match the tag strings emitted by
`grade-phase2/3/4/6/7`, which are being written concurrently by other agents. Task 1's test hardcodes the
expected tag list, so a mismatch fails loudly rather than silently routing a failed drill to nowhere.
**If a grader emits a tag not in that list, add it — do not delete the assertion.**

**Deliberately not built.** No lesson ordering engine or prerequisite gating beyond the `prerequisites`
field, because the syllabus is small enough to navigate directly and gating would fight the
"finite and coverable" feeling the whole design serves.

---

## AMENDMENT · What the first syllabus lesson (C2) proved, for the other 33

`lessons/C2/` was authored first deliberately, to set the template. These are its transferable
findings, and they are requirements now rather than suggestions.

**1 · `contrast/` needs "after" trees, not just a pair.** A measured contrast is impossible with
only `a/` and `b/`, because there is nothing to diff against. C2 shipped `a/`, `b/`, `a-tiered/`,
`b-tiered/`, `a-overnight/`, `b-overnight/` plus a `measure.mjs` inside `contrast/` that imports
`server/lib/diff.mjs` and prints the exact numbers the prose quotes. **Copy `measure.mjs`.** It
means a reader reproduces every number with one command, and an author cannot guess one.

**2 · Measure a change that goes the WRONG way too.** C2's on-axis change cost 22 lines unseamed
and 2 seamed. Its off-axis change cost 14 unseamed and **27 seamed**. Without that second
measurement the lesson would have taught "seams are good", which the corpus's own 60 measured
curveballs refute. Every Tier 1 lesson about a structural technique owes both directions.

**3 · Gaps must have a plausible wrong answer that COMPILES.** A `faded/src` that fails to compile
proves almost nothing about the `GapTest`. C2, J7 and J12 each filled every gap with a wrong answer
that compiles and then proved each one is caught behaviourally. That is the standard; report the
table.

**4 · Name the axis you deliberately did NOT seam.** C2 points at `Footprint.of(VehicleType)` as
exhaustive and therefore already protected by the compiler. This is what stops a Tier 1 lesson
teaching over-application, and it is the cheapest way to earn `when-not.md`.

**5 · Check the brief's own figures against the repo.** C2's brief said 56 curveballs; there are
**60**, all `"measured": true`. Verify with
`ls corpus/*/curveballs/*/budget.json | wc -l` before quoting any count.

**6 · Write short sentences from the first draft.** The 30-word cap cost C2 three edit rounds.
Two linter traps to know: **more than one em dash in a paragraph** is a warning, and a sentence
ending in a closing quote used to be missed entirely — that was a linter defect and is now fixed
(see the `SENTENCE_BOUNDARY` docstring in `server/lib/prose.mjs`), so quoting anchors verbatim no
longer costs false positives.

**7 · Keep `contrast/` code lean.** C2's own advice, from experience: put teaching commentary in
`curveball.md`, not in the paired directories, so the measured diff reflects structure rather than
prose. C2 had to hold class javadoc byte-identical across trees by hand to avoid inflating a
number, and called that fragile.

### Addendum from C3 · a diff number cannot see testability

C3 measured its two designs absorbing the same requirement and got **8 lines in both**. On the D4
instrument — `measureChange`, which is what the app scores — injection bought exactly nothing.

What it bought showed up on a different instrument: the sleeping test suite took 1.257–2.153 s
across six runs, the injected-clock suite stayed under 0.06 s in all six. That is 24x–127x faster
**and reproducible**, and it asserts strictly more (the exact 40-second boundary, and that one
nanosecond less is refused, rather than merely bounding `retryAfter` at one window).

**So: pick the instrument that can see the benefit you are claiming, and say which one you used.**
A lesson whose technique improves testability, readability or failure diagnosis must not be argued
with a line count, because a line count will honestly report a tie and the reader will conclude the
technique is worthless. C3 reported the tie rather than hunting for a requirement that would have
produced a flattering diff, and that is the standard.

Its wrong-way measurement still worked normally: a requirement wanting a *different kind* of time
cost 5 lines unseamed against **28 across four files** seamed.

### Addendum · do not cite an agent report as a file

Several briefs (mine included) have pointed authors at "the authoring report" for a corpus problem.
**Those reports exist only in conversation; there is no `docs/reports/`.** C3 went looking, could not
find it, substantiated the claim from `problem.json`'s syllabus list and `DECISION_LOG.md` instead,
and flagged that its citation was narrower than the brief implied. Cite `DECISION_LOG.md`,
`PATCH.md`, `budget.json`, `problem.json` or a source file — things that are on disk.
