Build repair drills from exactly the keys that slipped - #21
Merged
Merged
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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?), plusrepairAnchors(lesson, weakKeys, options?),RepairDrillError,DEFAULT_REPAIR_GROUP_COUNTandMAX_REPAIR_ANCHORS. Exported throughsrc/drill/index.ts.src/stats/keystats.ts—selectWeakKeys(report, options?)andweakKeyCharacters(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:
drillWordPool(lesson)made only of weak keys and anchors —noon,road,sir,mind,final.topBigrams70% of the time so it reads like English rather than rattling.pptrains 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:generateLadderbuilds the home lesson fromboard.homePositionsbefore anything else, so the earliest entries oflesson.keysare the keys the hands are already resting on. A caller that holds the board and keymap can pass the real home row throughoptions.anchorsinstead; anchors that are untypable, weak, or an empty list are refused.Sample output, reference ladder (macOS Maltron on a Glove80), seed 7:
What it refuses
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.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.tsuses. 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. NoMath.random. DOM-free.Did keystats already do the weak-key selection?
The analysis, yes; the list, no.
summariseKeyStatsalready 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 thanWEAK_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
weakestbeing row groups rather than keys. SoselectWeakKeysflattens it: measured keys only,weakband by default (includeWatch: truewidens it), worst-first by the report's own ordering, ties settled by character so it is stable, optionallimit.weakKeyCharactersis the same list as the characters thatnextStepandgenerateRepairTextboth 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-pprule, 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 verifypasses: 504 tests, lint, typecheck.What the UI integration must call
nextStepalready returnsrepairKeys; nothing in scoring needs changing. The result card should do:Two notes for whoever picks that up:
generateRepairTextthrows 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