Skip to content

Generate drill text: clusters, word pools and prose - #16

Merged
RCheesley merged 2 commits into
mainfrom
drill-text
Sep 21, 2026
Merged

RCheesley merged 2 commits into
mainfrom
drill-text

Conversation

@RCheesley

Copy link
Copy Markdown
Owner

Builds src/drill/text.ts. Given a lesson from the ladder, it returns something
worth typing, filtered to the keys that lesson has taught.

export interface DrillTextOptions {
  readonly words?: number; // target length in groups, default 12
  readonly seed?: number;  // determinism; defaults to a hash of the lesson id
}
export function generateDrillText(lesson: Lesson, options?: DrillTextOptions): string;

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

clusters for a lesson whose pool is too thin to drill words, words for the
middle of the ladder, prose once everything is in play. The lesson's stage
decides 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 — topBigrams in src/ladder/frequency.ts,
which docs/ladder.md names as this seam.

For the reference fixture, every keys lesson comes out as words, prose as
prose. Samples at seed 1:

lesson text
home adit rofd itror rin ad train forth shot trod not into dot
cm act time fond normal traffic introduce force claim card reach coat from
punct /-/ '/''- '/- ''- yield mouth lack, damage blood soil; dozen like.
symbols \3[ 6[473 4\ 652 yield mouth lack, damage blood soil; dozen like.`
caps Act woman Fresh settle; Yield mouth Lack, damage Blood soil; Dozen like.
prose the 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 —
anisfdthor plus space — admits 127 real words (that, north,
station, radio, transit), so it drills words, with a cluster group in
front 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
home and thumb.

Pure clusters are the fallback, not the norm: a QWERTY-shaped home row of
asdfghjkl admits sixteen words, at plus space admits one, and both drop to
clusters. 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 rather
than 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, every
character checked against the lesson's cumulative key set. The text is also
checked inside generateDrillText before it is returned, rather than trusted to
be correct by construction.

The prototype's "How vexingly quick daft zebras jump!" is in DRILL_SENTENCES
on purpose: its ! needs shift and the 1 key, which the ladder never
unlocks, 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_PUNCTUATION intersected with the key set, so
the first nine lessons are /^[a-z ]+$/ at every seed and the reference ladder
never reaches for :, ?, !, ", ( or ), none of which it binds.

Capitals come from the capitals stage only. The ladder adds that stage solely
when the layout binds a shift, so its presence is proof capitals are typable; a
Lesson at the prose stage carries no such proof, because the ladder appends
prose whether or not a shift exists. Prose is therefore lower case rather than
inventing 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: drillProsePool decides it on one line.

Determinism

Seeded mulberry32, no Math.random. Same seed and lesson always give the same
text; 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.

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

const text = generateDrillText(lesson, { seed: Date.now(), words: 12 });

The seam it replaces is SAMPLE_DRILL_TEXT in src/ui/drill-view.ts. Two things
the 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 looks
like. drillTextKind(lesson) is there if the surface wants to name what kind of
drill it is showing.

Notes for review

  • The word pool is 1339 words derived from the prototype's WORDS, normalised
    to 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 verify and npx playwright test both pass (424 unit, functional and
    regression tests; 88 e2e).
  • One thing I did not change, since it is outside the files this issue owns: the
    DOM-free ESLint rule in eslint.config.js lists src/drill/engine.ts and the
    ladder, board and keymap trees, but not src/drill/text.ts. This module
    is DOM-free and has no reason not to be, but that is currently convention
    rather than enforcement — widening the glob to src/drill/*.ts would fix it.

Closes #3

🤖 Generated with Claude Code

RCheesley and others added 2 commits September 21, 2026 00:29
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>
@RCheesley
RCheesley merged commit 3ff7976 into main Sep 21, 2026
3 checks passed
@RCheesley
RCheesley deleted the drill-text branch September 21, 2026 12:31
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.

Generate drill text: clusters, word pools and prose

1 participant