Seam direction and the grading rule
Why this lesson: your mission is to spot structural problems and predict refactor shapes before reading a spec. X-64 is the cleanest possible specimen: one rule, three copies, one arrow pointing the wrong way. If you can read this one like an architect, candidates #3–#6 follow the same moves at larger scale.
1 · One fact, two computers
Start with the failure story, not the theory. A user finishes their last exercise in Telegram. One second before that tap, the Ladder page computed their green dot from the live PracticeDay document, using a function in ladder.ts. One second after, the PracticeDay is deleted and the same dot is computed from a durable Result document — which was written by a different function, in practice.ts.
Two different functions produce "the same" verdict on either side of one moment. Today they agree. Nothing but human care keeps them agreeing. If they ever drift, the user watches a ✓ flip to ✕ at the moment of completion — a bug no typecheck can catch, in the one number the app permanently records.
That is the essence of X-64 in one sentence: a rule that exists three times is not a rule; it is three opinions that currently coincide.
2 · The substrate: what grading actually reads
You cannot judge the architecture without knowing the data. An module is only deep or shallow relative to what it hides. Grading reads three parallel arrays on each Exercise inside the PracticeDay (Firestore: scopes/{scope}/sessions/practice):
targets: ["beharren", "hund", "laufen"] // the Triad, normalized forms
gapTargets: ["beharren", "laufen", "beharren"] // per blank: whose gap is it
answers: ["beharren", null, "hund" ] // per blank: what was tapped (or null)
The rule (CONTEXT.md: lemma-level grading): a word's verdict is the AND over its gaps. Walk it for beharren: its gaps are index 0 and 2. Gap 0 was answered beharren ✓; gap 2 was answered hund ✗ — so beharren is a miss. laufen's only gap (index 1) is null — pending. Lemma-level because buttons carry normalized forms: tapping an inflected surface still records the dictionary form, so the comparison never sees inflection.
3 · The map: three copies and a wrong-way arrow
telegram/practice.ts
buildResults — AND-over-gaps as an implication inside every(). Writes durable Results at Completion. Also exports isAnswered "for the web Ladder page" (its comment apologises).pipeline/ladder.ts
statusFromExercises — the same AND, written differently (collect indices, then check), plus a third state pending. Its comment: "mirrors practice.ts's buildResults."ladder.ts ──imports isAnswered──▶ telegram/practice.ts ⚠ wrong direction
Why is the direction wrong and not merely untidy? Two surfaces — the web review page and the Telegram bot — are siblings. A seam between siblings must not be crossed; both should depend downward on shared core. Concretely: practice.ts imports grammY (the bot framework) at the top of the file, so this one-line import makes every GET /api/ladder — a read-only web request — transitively load bot-framework code. Direction decides what a module drags along.
And spot the tell you already know from X-62: a "mirrors X" comment is the codebase confessing a module is missing. Someone knew the rule had two homes and chose documentation over an interface. Comments cannot enforce agreement; interfaces can.
4 · The shape of the fix
exercises/grade.ts (new)
wordStatus(exercise, target) — THE rule, once.isAnswered, statusFromExercises, buildResults — all derived from it. Imports domain types only. ~40 lines.practice.ts / ladder.ts
Become consumers. The wrong-way import is deleted; grammY leaves the web path.ladder.vue untouched (a type re-export preserves its import).The load-bearing idea is consistent by construction: after the refactor, the dot on the web and the Result in Firestore cannot disagree, because both are the same function call. Before, they were consistent by inspection — a property you re-earn manually on every edit and lose silently.
Equally important is what stays behind: the per-gap ✅/❌ in the reveal rendering is one comparison at presentation grain. Hauling it into grade.ts would be abstraction for its own sake. The test is: does the code encode the rule (move it) or display a result (leave it)?
5 · Check yourself
Answer each; feedback is instant. These test whether the vocabulary is yours now — tell the agent your score.
Q1. Using the arrays in section 2 — the day ends with answers: ["beharren", "laufen", "beharren"]. What does hund grade as?
hund appears in targets but owns no gap in gapTargets — the vacuous AND grades it correct. X-64's spec preserves this edge case deliberately; both current implementations already behave this way. Noticing empty-set behavior is exactly the kind of thing a single shared predicate makes checkable once.gapTargets: hund owns no gap at all. The rule is "AND over its gaps" — and an AND over zero things is true (the vacuous AND). So: correct. Both current implementations agree on this, and the X-64 spec pins it down as a preserved edge case.Q2. Why is ladder.ts → telegram/practice.ts the wrong direction, in one principle?
exercises/grade.ts.Q3. Run the deletion test on statusFromExercises (as it exists today, pre-refactor). What happens if you delete it?
wordStatus expresses it.)Q4. After X-64, someone proposes partial credit: a word with 2 of 3 gaps right scores 0.5. Which files change?
exercises/grade.ts; consumers change only where the new value must be displayed or stored. That one-file-for-the-rule property is the payoff X-64 buys.Q5. The reveal's per-gap ✅/❌ (answer === gapTargets[gap], one line in the Telegram renderer) — move it into grade.ts?
Ask your teacher. Anything unclear — vacuous AND, why grammY loading matters, what "presentation grain" means — ask the agent in the chat; that's what it's for. Good follow-up: "show me the actual every() line and translate the implication into plain words."
Related: architecture glossary · Telegram entity glossary (previous course) · X-64 spec in Linear · podcast docs/podcasts/x-64-grading-to-exercises.mp3
Next lesson candidate: the pure core / I/O shell split (architecture-review candidate #3, the Tick's gate) — a step up from moving a pure rule to extracting one from Firestore-entangled code.