Generate drill text: clusters, word pools and prose - #16
Merged
Merged
Conversation
A lesson knows which keys it has taught; until now nothing turned that into something to type, and the drill surface ran on a hard-coded sample sentence. src/drill/text.ts fills that seam: generateDrillText(lesson, options) returns a string for the engine, in one of three kinds. - clusters: Maltron-style non-blocking letter groups, chained through the common bigrams the lesson's key set can type, via topBigrams in src/ladder/frequency.ts. This is what a lesson gets when its pool is too thin to drill words, and what the first two lessons lead with. - words: 1339 words, filtered to the cumulative key set, biased towards the keys the lesson just added, with punctuation and capitals added only once a lesson has unlocked them. - prose: real sentences, once everything is in play. The rule the module exists for is that every character of the result is a character the lesson can type, and the text is checked against the key set before it is returned rather than trusted to be right by construction. The prototype got this wrong in the one place it is most visible: its prose included "How vexingly quick daft zebras jump!", and the exclamation mark is shift and the 1 key, which its ladder never taught. That sentence is in DRILL_SENTENCES on purpose, so the filter has to drop it, and a test asserts that it does. The earliest lessons degrade rather than emptying out. Maltron's first lesson, anisfdthor plus space, turns out to admit 127 real words -- that, not an empty pool, is what it looks like -- so it drills words with a cluster group in front of them. A home row of asdfghjkl admits sixteen, two letters admit one, and those fall back to clusters. A lesson introducing keys no word can contain, the digits and brackets, leads with a group drilling those keys, because otherwise the lesson that teaches them would never ask for one. Determinism is a requirement, not a nicety: a drill that fails is only reproducible in a test if the seed reproduces the text, so the randomness is a seeded mulberry32 and Math.random is never called. Without a seed the lesson id is hashed into one. Capitals come from the capitals stage only. The ladder adds that stage solely when the layout binds a shift, so it is proof that capitals are typable; a Lesson at the prose stage is not, because the ladder appends prose either way. Prose is therefore lower case rather than guessing, on the same reasoning as the exclamation mark above. Throwing beats a wrong default throughout: a key that is not one character, a word count that is not a positive integer, a lesson that admits nothing at all, and text that fails its own check all throw, with the original error attached as cause where there is one. An empty string would reach the engine as a drill that is already finished. Wiring this into the drill surface is deliberately not part of this change. 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.
Builds
src/drill/text.ts. Given a lesson from the ladder, it returns somethingworth typing, filtered to the keys that lesson has taught.
Also exported, because the surface and the tests both want them:
drillTextKind,drillAlphabet,drillWordPool,drillProsePool,DRILL_WORDS,DRILL_SENTENCES,DEFAULT_WORD_COUNT,DrillTextError.The three kinds
clustersfor a lesson whose pool is too thin to drill words,wordsfor themiddle of the ladder,
proseonce everything is in play. The lesson'sstagedecides prose; how much the key set actually admits decides between words and
clusters, because that is a fact about the layout rather than about the ladder.
Clusters are Maltron-style non-blocking letter groups, chained through the
common bigrams the key set can type —
topBigramsinsrc/ladder/frequency.ts,which
docs/ladder.mdnames as this seam.For the reference fixture, every keys lesson comes out as
words,proseasprose. Samples at seed 1:homeadit rofd itror rin ad train forth shot trod not into dotcmact time fond normal traffic introduce force claim card reach coat frompunct/-/ '/''- '/- ''- yield mouth lack, damage blood soil; dozen like.symbols\3[ 6[473 4\652 yield mouth lack, damage blood soil; dozen like.`capsAct woman Fresh settle; Yield mouth Lack, damage Blood soil; Dozen like.prosethe quick brown fox jumps over the lazy dog. the home row holds the letters you reach for most, so your hands can stay still.The early lessons
Checked rather than assumed, as the issue asks. Maltron's first lesson —
anisfdthorplus space — admits 127 real words (that,north,station,radio,transit), so it drills words, with a cluster group infront because fewer than a dozen letters make for a repetitive pool. That is
where the prototype seeded clusters by hand, and the threshold picks out exactly
homeandthumb.Pure clusters are the fallback, not the norm: a QWERTY-shaped home row of
asdfghjkladmits sixteen words,atplus space admits one, and both drop toclusters. A lesson that has not unlocked a space cannot separate words at all,
so it gets clusters run together.
A lesson introducing keys no word can contain — the digits,
`,[,],=— leads with a group drilling those keys instead, in place of words ratherthan in addition to them, so the length asked for still holds. Without it the
lesson that teaches the digits would hand over a word drill that never touches
one.
Never generating what the lesson cannot type
The central rule, asserted exhaustively: every lesson of the generated ladder
for
tests/fixtures/macos-maltron.glove80.json, ten seeds, four lengths, everycharacter checked against the lesson's cumulative key set. The text is also
checked inside
generateDrillTextbefore it is returned, rather than trusted tobe correct by construction.
The prototype's
"How vexingly quick daft zebras jump!"is inDRILL_SENTENCESon purpose: its
!needs shift and the1key, which the ladder neverunlocks, so the filter has to drop it. A test asserts the sentence is in the
data, that it never survives the filter, and that no generated prose contains a
bang.
Punctuation comes only from
PROSE_PUNCTUATIONintersected with the key set, sothe first nine lessons are
/^[a-z ]+$/at every seed and the reference laddernever reaches for
:,?,!,",(or), none of which it binds.Capitals come from the
capitalsstage only. The ladder adds that stage solelywhen the layout binds a shift, so its presence is proof capitals are typable; a
Lessonat theprosestage carries no such proof, because the ladder appendsprosewhether or not a shift exists. Prose is therefore lower case rather thaninventing a capital a shift-less layout could not type — the same mistake as the
exclamation mark. When the integration has the whole ladder to hand it can widen
this:
drillProsePooldecides it on one line.Determinism
Seeded mulberry32, no
Math.random. Same seed and lesson always give the sametext; a call with no seed hashes the lesson id, so it is reproducible too, and
two lessons with the same keys and different ids do not collide.
What the integration will need to call
Deliberately not wired into the drill surface; that is the follow-up once #9
lands.
The seam it replaces is
SAMPLE_DRILL_TEXTinsrc/ui/drill-view.ts. Two thingsthe caller owns: the seed, because this module has no clock and should not get
one (pass a changing seed for variety, a fixed one to reproduce a drill), and
catching
DrillTextError, which is what a layout admitting nothing typable lookslike.
drillTextKind(lesson)is there if the surface wants to name what kind ofdrill it is showing.
Notes for review
WORDS, normalisedto lower case ASCII, two letters or more, deduplicated and sorted, with its
duplicates and its one capitalised proper noun cleaned up. Checked at module
load, so bad data fails there rather than inside a drill.
npm run verifyandnpx playwright testboth pass (424 unit, functional andregression tests; 88 e2e).
DOM-free ESLint rule in
eslint.config.jslistssrc/drill/engine.tsand theladder,boardandkeymaptrees, but notsrc/drill/text.ts. This moduleis DOM-free and has no reason not to be, but that is currently convention
rather than enforcement — widening the glob to
src/drill/*.tswould fix it.Closes #3
🤖 Generated with Claude Code