Seam direction and the grading rule

Lesson 0001 · ~15 minutes · worked example: Linear X-64 · vocabulary: architecture glossary

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?

Yes. 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.
Look at 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?

Right. The web page and the bot are peers; neither is "under" the other. Sideways dependencies smuggle one surface's baggage (here: grammY) into the other and make the borrowed module un-deletable. Both should look down at exercises/grade.ts.
File size and web-vs-server aren't the principle (both files are server-side!). The rule is about siblings: the web review surface and the Telegram surface are peers, and peers must depend downward on shared core — never sideways on each other. Sideways is how grammY ends up loading on a read-only web request.

Q3. Run the deletion test on statusFromExercises (as it exists today, pre-refactor). What happens if you delete it?

Exactly. Deleting it forces the question "so where does the Ladder get verdicts?" and the only good answer is "from the same place Results come from" — complexity concentrates into one module instead of scattering. That "yes, concentrates" is the deletion test's green light. (Pending survives: the shared wordStatus expresses it.)
It's live code (the dots depend on it), and pending isn't lost — the shared predicate expresses it as a third return value. The point of the deletion test: removing the duplicate concentrates the rule into one home rather than scattering it. Concentration = delete and deepen; scattering = leave it.

Q4. After X-64, someone proposes partial credit: a word with 2 of 3 gaps right scores 0.5. Which files change?

Right — and notice the split: the rule is a one-file change (that's locality), while consumers change only where the new value must surface (a score in Results, a new dot style). Before X-64 this was a three-file synchronized edit with silent-drift risk. Leverage = the rule change propagates to all consumers for free.
After the refactor practice.ts and ladder.ts no longer own any verdict logic — they consume it. The rule changes in exactly one place, 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?

Right, and this is the judgment call that separates "applies the vocabulary" from "applies it mechanically". Not everything touching answers is the domain rule. The word-level AND is a rule with three consumers; the gap-level glyph is one surface showing one comparison. X-64's DEC-4 records exactly this. (Option three has real logic too — "two adapters justify a seam" — but here there's no rule to share, so the count never matters.)
Tempting — it does compare answers to targets. But the test is: does it encode the domain rule (the word-level AND with three consumers) or display a result (one comparison, one surface, presentation grain)? This one displays. Moving it would be abstraction for its own sake — X-64's DEC-4 records the deliberate choice to leave it inline.

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.