Skip to content

Build repair drills from exactly the keys that slipped - #21

Merged
RCheesley merged 2 commits into
mainfrom
repair-drills
Sep 22, 2026
Merged

RCheesley merged 2 commits into
mainfrom
repair-drills

Conversation

@RCheesley

Copy link
Copy Markdown
Owner

The pure-logic half of repair drills: given the keys that slipped and the lesson they slipped in, text that drills exactly those keys. No UI — wiring it into the result card is a deliberate follow-up, because the UI files are contended right now. The API it will call is spelled out at the bottom.

What landed

src/drill/repair.ts — generateRepairText(lesson, weakKeys, options?), plus repairAnchors(lesson, weakKeys, options?), RepairDrillError, DEFAULT_REPAIR_GROUP_COUNT and MAX_REPAIR_ANCHORS. Exported through src/drill/index.ts.

src/stats/keystats.ts — selectWeakKeys(report, options?) and weakKeyCharacters(report, options?). Nothing else changed there; see below.

Exactly those keys

Every weak key given appears in the text, and nothing outside the weak keys, their anchors and the separator ever does. When a shorter drill is asked for than there are weak keys, the group count is raised rather than a key being dropped — a repair drill that silently omits a key is worse than no repair drill, because the learner practises and the miss survives. The finished text is checked against both sets, and against the lesson's own alphabet, before it is handed out, so an omission throws instead of shipping.

How anchors are mixed in

Each group sets its weak key among steady keys rather than repeating it:

  • Half the time, when the pool has one, the group is a real word from drillWordPool(lesson) made only of weak keys and anchors — noon, road, sir, mind, final.
  • Otherwise it is a built cluster of three to five keys: the weak key is placed in a random slot (not always the front — a finger has to find it mid-word too), the remaining slots are filled with anchors, and a slot follows a common bigram from topBigrams 70% of the time so it reads like English rather than rattling.
  • A longer group gets the weak key a second time 40% of the time, never adjacent to the first: drilling pp trains a doubled key, which is a different skill from the one that slipped.

Anchors default to the front of the lesson's cumulative key set, lower-case letters first, capped at MAX_REPAIR_ANCHORS (6), excluding any key that is itself weak. That is the home row: generateLadder builds the home lesson from board.homePositions before anything else, so the earliest entries of lesson.keys are the keys the hands are already resting on. A caller that holds the board and keymap can pass the real home row through options.anchors instead; anchors that are untypable, weak, or an empty list are refused.

Sample output, reference ladder (macOS Maltron on a Glove80), seed 7:

yp   weak=yp  anchors=anisfd  ::  any iapni nys pay say pain day paida day pain
cm   weak=cm  anchors=anisfd  ::  can iamni nca ndm can ssm asic mind can miss
punct weak=;/ anchors=anisfd  ::  ;di f/ni ;s;n n/ass ss; fis/a is; ndi/n i;a fiai/

What it refuses

  • An empty weak-key list — "repair nothing" is a caller that should not have asked, not an ordinary drill.
  • A weak key the lesson cannot type, naming it: Cannot build a repair drill: lesson "yp" cannot type "z", so a repair drill for it would be a drill the learner cannot finish. That is a bug in whatever selected the key, and dropping it quietly would hide the bug behind a drill that looks fine.
  • Whitespace, and anything that is not one key. Scoring already strips whitespace from its weak keys, so a caller arriving here with a space has a bug.
  • Empty text, ever. The final assertion throws instead.

A key listed twice is the one harmless case and collapses. Rethrows attach cause.

Deterministic: the same lesson, keys and seed always give the same text, via the same mulberry32 text.ts uses. Without a seed it hashes the lesson id and the weak keys, so a call without one is reproducible too and two different key sets do not come back with the same drill. No Math.random. DOM-free.

Did keystats already do the weak-key selection?

The analysis, yes; the list, no. summariseKeyStats already bands every key, groups by finger then row, orders worst-first inside each row, and — this is the acceptance criterion about small samples — already refuses to draw a conclusion from fewer than WEAK_KEY_THRESHOLDS.minAttempts (4) presses, exported and documented. No new threshold constant was needed and none was added.

What it had no way to hand over was a plain ranked list of keys, its shape being deliberately two levels deep for the view that reads it, and weakest being row groups rather than keys. So selectWeakKeys flattens it: measured keys only, weak band by default (includeWatch: true widens it), worst-first by the report's own ordering, ties settled by character so it is stable, optional limit. weakKeyCharacters is the same list as the characters that nextStep and generateRepairText both take. Unplaced keys — statistics carried over from another layout — can never reach a drill, and there is a test for that.

Tests

tests/unit/repair.test.ts (19) — the centre is the exhaustive check: every lesson of the reference ladder, ten seeds, three different weak-key selections each, asserting every weak key appears and every character is a weak key, an anchor or the separator. Plus determinism, overshoot-rather-than-drop, the anchor rules, the no-pp rule, the degenerate lessons (no space; every key weak), and each refusal.

tests/unit/keystats.test.ts (+8) — ranking, the four-press floor, watch-band exclusion, limits, stability, and unplaced keys.

npm run verify passes: 504 tests, lint, typecheck.

What the UI integration must call

nextStep already returns repairKeys; nothing in scoring needs changing. The result card should do:

import { generateRepairText } from './drill/index.js';

// step.repairKeys is already capped at REPAIR_KEY_LIMIT and whitespace-stripped
if (step.repairKeys.length > 0) {
  const text = generateRepairText(currentLesson, step.repairKeys, { seed: Date.now() });
  // start a drill on `text` with mode: 'repair' — scoreDrill already awards no
  // stars outside lesson mode, so a repair cannot advance the ladder
}

Two notes for whoever picks that up:

  1. Pass a changing seed for variety; omit it for a drill that repeats exactly.
  2. generateRepairText throws on a weak key the lesson cannot type. If the card ever builds a repair from a sprint that ranged over more than one lesson's keys, filter against the lesson first or pass the lesson whose key set covers them — the throw is deliberate and should not be caught and ignored.

For weak keys drawn from stored statistics rather than from the drill just finished, weakKeyCharacters(summariseKeyStats({ keyStats, keymap, board }), { limit: REPAIR_KEY_LIMIT }) is the ranked list to feed it.

Closes #7 (note: the UI wiring follows separately)

🤖 Generated with Claude Code

RCheesley and others added 2 commits September 22, 2026 18:57
`nextStep` has been promising "a short drill built from those keys, mixed
with home row anchors" since scoring landed, and nothing produced one. This
is the other half of that sentence: `generateRepairText(lesson, weakKeys)`
in the new `src/drill/repair.ts`.

Every weak key given appears in the text, and nothing outside the weak keys,
their anchors and the separator ever does. The group count is raised to the
number of weak keys when a shorter drill was asked for, because a repair
drill that silently drops a key is worse than none: the learner practises
and the miss survives. The text is checked against both sets before it is
handed out, so an omission throws rather than shipping.

Anchors are what stop it reading like mashing. Each group sets the weak key
among steady keys, chained through the common bigrams that small alphabet
can type, and uses a real word from the lesson's pool whenever one is made
only of those keys. The weak key lands in a random slot rather than always
at the front, and a second placement is never adjacent, because drilling
`pp` trains a doubled key rather than the one that slipped. The anchors
default to the front of the lesson's cumulative key set, which is the
resting position — `generateLadder` builds the home lesson from
`board.homePositions` first — and a caller holding the board can pass the
real home row instead.

Nothing is swallowed. A weak key the lesson cannot type is a fault in
whatever selected it, so it throws with the key named; so does whitespace,
something that is not one key, and an empty weak-key list, because "repair
nothing" is a caller that should not have asked rather than an ordinary
drill. Seeded with the same mulberry32 the text generator uses, so a repair
drill that goes wrong is reproducible from its lesson, keys and seed.

`keystats.ts` already did the weak-key analysis and already refused to draw
a conclusion from too few presses, so none of that is repeated here. What it
had no way to hand over was a plain ranked list, its shape being two levels
deep for the view that reads it; `selectWeakKeys` and `weakKeyCharacters`
flatten it, worst first, measured keys only.

Wiring this into the result card follows separately, while the UI files are
contended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RCheesley
RCheesley merged commit 620840f into main Sep 22, 2026
3 checks passed
@RCheesley
RCheesley deleted the repair-drills branch September 22, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track per-key statistics and build repair drills

1 participant