Reading one hand
Episode 12 · 3 Nov 2026

Base v7: stitched hands

Real writers' letters, cut out and joined into the writer's kind of text: notes, French accents, math. Piloted against a control and against more real ink, then trained into the base. The writer's test lines go from 17.6% to 11.5% error, below the 15% goal, before personalising.

TL;DR

Win

The question Real handwriting taught v6 letters, but its writers only wrote random pairs of English words. Could their ink be cut up and joined again into the writer's kind of text?

Last round, real handwriting from strangers made the model much better, but those strangers only wrote two random English words a line: no accents, few digits, almost no math. So we cut their handwriting into pieces where one letter ends and the next begins, and joined the pieces into French notes, times, amounts and equations, each line in one person's hand. Trained on that, the model gets fewer than one character in eight wrong on the writer's lines, before it has seen any of them.

  • Base v7 replaces v6. On the writer's 38 dev lines with the beam search: 11.2% against v6's 15.3%, −4.1 points (−7.1 to −1.3). On the 84 test lines, read once: 11.5% against v6's 17.6%, −6.0 points (−8.6 to −3.6).
  • The pilot ruled out the easy explanation. More real ink alone made the dev lines worse: +2.0 points (+0.2 to +4.0). Stitched lines made them better: −2.5 points (−4.7 to −0.4).
  • French lines went from 18.2% to 10.8% and math from 37.7% to 19.3%. Accent errors fell from 50 to 20, dropped spaces from 64 to 18, digit errors by half.
  • Punctuation got worse, 33 errors to 57: a small mark at a narrowed word gap reads as a space. Other writers 2.9% → 3.5%, and the beam search now takes 52.5 ms a line, just over the 50 ms budget.
  • Two failures on the way, both fixed: a launch that never started, and a watch whose crash stopped the GPU run. Pilot $1.70, run $3.92: $5.62 in all.
The lesson

When real data has the right shapes but the wrong content, recombine it: cut it where its labels allow and rebuild what your users produce. Then pilot it against simply adding more of the same data, so you know which did the work.

Why read on Asked for pieces of up to four letters, the cutter mostly found one or two. It worked anyway, and a cleverer cut changed nothing.

1 The question

Base v6 reads the writer's test lines at 17.6%. Its real ink was random pairs of English words, so what the writer writes and BRUSH doesn't, it reads worst.

In episode 11, real handwriting from 153 other people took the base model The general reader, trained on lots of synthetic handwriting before it ever sees yours. Changing it: A better base gives a better personal model: it starts lower and learns your hand faster. Alone it can still read your writing badly. Here: From v1 on, trained on a cloud GPU from a recipe; v2's base alone gets 43.6% of your letters wrong, 13.1% once personalised. from 25.2% to 17.6% on the writer's 84 test lines, read with the beam search Decoding that keeps the best few partial readings at every step instead of committing to one letter at a time. Changing it: A wider beam gives the language model room to fix ambiguous letters, but costs time: the beam search is about 40% of v2's time per line. Here: Width 8, combining the network's scores with the language model and your lexicon. as the app reads. The goal is 15% before the model sees any of the writer's lines.

But BRUSH's writers wrote what BRUSH asked: random pairs of words, such as "Drawcansir Holroyd", in English. The writer writes notes of 5 to 7 words, French with accents, times, amounts and math. Here is where v6 goes wrong, on all 124 of the writer's lines:

v6's errors by kind, beam search, all 124 of the writer's lines. Rate: the share of that kind's characters read wrong.
KindErrorsRateWhat BRUSH has of it
Lowercase25012.2%everything
Spaces8714.4%lines of 2 words, word gaps 22% wider than the writer's
Digits6739.6%about 3 of each per writer
Accented5050.0%none (English only)
Math symbols4146.1%= + < > / only, few
Punctuation3326.0%some
Capitals3124.0%some

Real ink taught v6 letters. It didn't teach the writer's texts. Accented letters are wrong 50% of the time, math symbols 46%, digits 40%. Of 87 errors on spaces, 64 are spaces it dropped: two-word lines show few gaps between words, and wider ones than the writer leaves.

2 The idea: cut real letters, join them into new lines

BRUSH labels every point of a line with its letter. So a writer's ink can be cut where letters meet and joined again into any text, in that writer's hand.

Each BRUSH line knows which letter every point of ink belongs to. That makes it possible to cut a writer's lines into pieces and join the pieces into new lines of the writer's kind of text: sentences, notes, math, accented words. We call them stitched lines.

  • Pieces of up to 4 letters, cut whole from one writer's lines, so the joins inside a piece stay as the hand made them. Where both sides of a cut were joined in their lines, the pen stays down across it.
  • One writer per line. Pieces are measured in their own line's letter height, sitting on its baseline, so pieces from different lines of one writer line up.
  • Spacing is the writer's own: after each letter, the gap they left in the source. Word gaps too, then narrowed by a factor from 0.55 to 1.05 (why, in the next section).
  • Late strokes stay late. i dots and t crosses come at the end of the word, as the writer wrote them.

Their own line as BRUSH has it

"send the morning report" cut into 10 pieces of their other lines

"se", cut from "Himself, "I'm Lizzy"se"nd", cut from "left-hand Gif. Kepn"nd"the", cut from "jumps over the lazy"the"mo", cut from ""To-morrow Juvenal"mo"rn", cut from "Roygbiv Blackburn:"rn"in", cut from "outrageous succinct"in"g", cut from "dog. Cozy lummox gives"g"r", cut from "Chudleigh rejected;"r"ep", cut from "left-hand Gif. Kepn"ep"ort", cut from "Wordsworth Alonzo"ort
  1. se from Himself, "I'm Lizzy
  2. nd from left-hand Gif. Kepn
  3. the from jumps over the lazy
  4. mo from "To-morrow Juvenal
  5. rn from Roygbiv Blackburn:
  6. in from outrageous succinct
  7. g from dog. Cozy lummox gives
  8. r from Chudleigh rejected;
  9. ep from left-hand Gif. Kepn
  10. ort from Wordsworth Alonzo

Stitched lines in their hand, none of it written by them

  1. Rappeler Noé à 9h15 demain

  2. send the report by Friday!

  3. x² + 3x ≤ 12

  4. f(y) = 4y − 7

BRUSH: Kotani, Tellex and Tompkin, Generating Handwriting via Decoupled Style Descriptors, ECCV 2020; https://dsd.cs.brown.edu/ — non-commercial research only

All one person's ink, at one scale. In the middle, each piece takes the next colour; hover a piece, or read the list, for the line it was cut from. Most pieces are one or two letters long (section 4 says why). Other people's ink only: the writer's own is never shown in this series.

What BRUSH lacks is built from the writer's own marks. Accents and symbols are made from strokes that writer did write:

Marks BRUSH doesn't have, and what each is made from.
MadeFrom the writer's own
é èan apostrophe, leaning
êa v, turned over
ëtwo full stops
ça comma under the c
îan i without its dot
œo and e, set tight
×a small x
− –a dash
± ≠ ≤ ≥ ÷ ° ² ³+ = < > / - . o 2 3

Each writer has only about three of each of + = < > /. A symbol a writer never wrote is borrowed from another writer, at the same letter height. Letters are never borrowed: a line is one hand.

What stitched lines write: 400,000 texts of 12 to 50 characters, 55% French, none of them the writer's recorded lines.
TextShare
Real sentence windows (Tatoeba)35%
Notes: times, dates, amounts, units, names and marks, sometimes French and English mixed25%
Math, in the writer's notation20%
Most-used words, half with accents or the letters v6 confuses20%

Cutting BRUSH into letters took 34 s on the laptop: 26,757 lines. 892 were left out, with too few letters to find their letter height. The 17 writers held out since episode 11 stay held out, and 2,000 of the texts are held out too. Every training run writes new stitched lines, in parallel on rented CPUs, at the writer's pace and varied as the real lines are.

3 Checks before any GPU

The writer's word gaps are 18% narrower than BRUSH's. v6 reads stitched lines about as badly as the writer's, and for the same reasons: dropped spaces, math, digits.

Word gaps, measured the same way everywhere. Per line, the gaps between blocks of ink, one per space, divided by the line's ink width per letter.

Word gaps in letter widths: the 10th, 25th, 50th and 75th percentiles.
Linesp10p25Medianp75
The writer's (124 lines)0.510.751.001.30
BRUSH originals0.740.941.221.56
Stitched, writers' own gaps0.680.931.211.59
Stitched, gaps × 0.55–1.050.600.750.991.32

The writer's word gaps are about 18% narrower than BRUSH writers'. Stitched with the writers' own gaps, lines keep BRUSH's; narrowed by 0.55 to 1.05, they match the writer's.

What v6 makes of stitched lines. Read greedy decoding Reading the model's output by taking the top guess at every point, then applying CTC's rules. Changing it: Fast and simple, but it can't use spelling knowledge, so it produces things like "1%e" for "the". Here: Greedy CER is the network on its own: 18.4% for v2 personalised, against 13.1% with the beam search., on 1,000 lines of the held-out writers: their own BRUSH lines 2.8%, stitched lines in their hands 14.7%. By kind of text: sentences 12.1%, words 13.4%, notes 15.6%, math 25.3%. Its errors are the ones it makes on the writer's lines: dropped spaces ("onthe", "n'aiaucune"), math, digits. So stitched lines hold what v6 hasn't learned.

v6, greedy, on stitched lines of the held-out writers, at three word gaps and two piece lengths, and the share of spaces it finds.
Word gapsPieces of up to 4Pieces of 1Spaces v6 finds
Writers' own word gaps12.4%13.1%83%
Gaps × 0.55–1.05 (the writer's)14.7%15.7%74%
Gaps × 0.40–0.6018.8%20.0%62%
  • Spacing is the cause. The narrower the gaps, the more spaces v6 drops: 83% found at BRUSH's gaps, 74% at the writer's, 62% narrower still. Lines with the writer's gaps should teach it what a word gap of that size looks like.
  • Pieces of one letter read about a point worse than pieces of up to 4: the joins inside longer pieces look more like real writing.

4 A dead end: the long pieces aren't there to cut

Pieces may be up to 4 letters, but only about a third of the letters get one of 3 or 4. A cut that tries harder changed nothing v6 could read.

Each writer has about 160 BRUSH lines of random words: about 1,550 different runs of 3 letters. Most words of the writer's texts can't be cut into pieces of 3 or 4 from so little. The phrase in the figure above came out as 10 pieces, most of them one or two letters.

Two ways to cut 3,000 texts for random writers: the share of pieces of each length, letters per piece, the share of letters in pieces of 3–4, and v6 greedy on held-out stitched lines, three seeds.
Cut1 letter234Letters per pieceLetters in 3–4v6 greedy
Greedy: each piece's length drawn in turn38%46%14%2%1.827%14.7% / 14.8% / 14.3%
Fewest pieces22%56%19%3%2.035%14.6% / 14.8% / 14.4%

"3 or 4 letters linked" is what the cutter tries for, and about a third of the letters get it. Cutting with the fewest pieces raises that from 27% to 35%, and v6 reads both alike, within a few tenths across seeds. So v7 keeps the simpler cut. Whether piece length matters at all is a question for the pilot, with an arm of one-letter pieces.

5 The pilot: the stitching, or just more real ink?

Stitched lines beat the control by 2.5 points with the beam search. As many extra lines of real ink made it 2.0 points worse.

As in episode 11: every arm starts from v6's weights The numbers inside the model that training adjusts. A trained model is just these numbers plus the code that uses them. Changing it: More weights can learn more, but need more data and training time, make the file bigger and reading slower. They grow with the square of the layer width. Here: v0: 0.18 million (1 × 128). v1: 0.97 million (3 × 128). v2: 2.15 million (3 × 192), 8.6 MB in full precision., trains 2 epoch One full pass over every training line. Changing it: More epochs keep lowering the training error until it levels off. Past that point they cost time for nothing, or the model starts memorising the training lines. The best epoch is the one that scores best on held-out lines, not the last one. Here: v1's base ran 29 epochs of about 97,000 lines (about 1.5 minutes each on a T4); v2's ran 12 epochs of 290,000 lines (about 12 minutes each). Personalising stops after 6 epochs or 10 minutes. on the same 80,000 synthetic lines, and is read on the writer's 38 dev lines. Stitched arms make an epoch bigger, so one arm gets as many extra examples from real ink alone: if it does as well, the gain is just more real ink.

The five arms: shares of each epoch, and what each tests.
ArmBRUSHStitchedPiecesWord gapsTests
control50%–––v6's mix
65% real65%–––as many examples as the stitched arms: is it only more real ink?
stitched35%30%up to 4× 0.55–1.05the idea
stitched, pieces of 135%30%1 letter× 0.55–1.05do linked pieces matter?
stitched, writers' gaps35%30%up to 4writers' owndoes spacing matter?
Predicted before the run

Written before the pilot: stitched lines go into v7 if the stitched arm beats both the control (its 95% paired interval below zero) and the 65% real arm with the beam search. The two other arms choose the settings: one-letter pieces, or the writers' own gaps, only if better than the stitched arm by more than noise.

Observed

Stitched: 12.8%, −2.5 points (−4.7 to −0.4) against the control; 65% real: 17.3%. Stitched lines go into v7. Prediction held

−6−4−20+2+4difference in CER, points; below zero, fewer errors than the controlbase v6 itself0.065% real+2.0stitched−2.5stitched, pieces of 1−1.5stitched, writers' gaps−2.8
The pilot: each arm minus the control on the writer's 38 dev lines, beam search, with 95% intervals
RowValueInterval
base v6 itself0.0−1 to +1
65% real+2.00 to +4
stitched−2.5−5 to 0
stitched, pieces of 1−1.5−4 to +1
stitched, writers' gaps−2.8−6 to 0
Green: the whole interval below zero; red: the whole interval above. Base v6 itself, read the same way, is a control of a kind. The control reads the dev lines at 15.3%.
Beam errors on the dev lines by kind, the last epoch of each arm.
ArmLowercaseSpacesAccentsDigitsMathPunct.Capitals
control71168151562
65% real832011171462
stitched6493121172
stitched, pieces of 174103101171
stitched, writers' gaps6410381172

By the rule set before the pilot, stitched lines go into v7. The stitched arm beats the control, and the 65% real arm is worse than the control. The gain is the stitched lines, not more examples or more real ink.

  • Stitching fixes what v6 lacked: accent errors 8 → 3, spaces 16 → 9 (mostly dropped spaces, 12 → 6), math 15 → 11, digits 15 → 12. Lowercase too: 71 → 64.
  • Linked pieces matter for lowercase. One-letter pieces fix accents and spaces as well, but lowercase gets worse (74). Their gain, −1.5 points, is about half and isn't sure. So pieces stay up to 4 letters.
  • Narrowed gaps or the writers' own: no sure difference. The same spaces (9 against 10) and a gain, −2.8, whose interval reaches +0.1. Not better by more than noise, so v7 keeps the gaps narrowed to the writer's, which also match the measured ones.
The pilot
$1.70
Modal's bill, 5 arms
Expected
$1.20
51 CPU containers wrote the stitched lines
Each GPU arm
$0.08–0.15
the runs' own estimates

6 Failure: the first pilot launch never started

A shared store made when the code is first loaded can't be shipped with the functions to the cloud. Now it is looked up when it is used.

The pilot's first launch failed before anything ran. The code made a shared store, Modal's dictionary that the laptop and the cloud both read, at the moment the code was loaded. To run in the cloud, the functions are packed up and sent, and that store can't be packed with them.

Now the store is looked up when it is first used, not when the code loads. The lesson is general: anything that holds a connection belongs inside the function that needs it, not at the top of the file.

7 Training with a watch on the laptop

The learning rate stays flat while a watch on the laptop reads every epoch with the beam search. When 3 epochs go by without a gain, the run decays and stops.

Base v7's recipe: the pilot's stitched arm at full size.
Starts fromv6's weights
Synthetic linesv3 and v4's 300,000, reused
Real linesBRUSH, 35% of each epoch, varied copies
Stitched lines30% of each epoch, written anew every run, pieces of up to 4 letters, word gaps narrowed to the writer's
Learning rate0.0005, flat while the watch reads every epoch; falls to zero over 2 epochs when it stops paying; 16 epochs at most
Epoch keptthe one the beam search reads the dev lines best with

Episode 11 trained a set number of epochs along a falling learning rate How big a step each weight takes after every batch. Changing it: Too small and training creeps, wasting GPU time. Too big and it overshoots: the loss bounces or blows up. It's usually the first setting to tune. Here: 0.002 for the base models; 0.001 for personalising, which starts near a good solution and shouldn't be knocked away from it., then kept the epoch that read the dev lines best greedily. This time the beam search, the way the app reads, picks the epoch, and the run decides its own length.

  • The rate stays flat at 0.0005, so the run can go on as long as it pays.
  • A watch on the laptop reads every epoch as it lands: the writer's 38 dev lines with the beam search, and the held-out writers. The writer's lines never leave the laptop: the weights come down, not the ink up.
  • When it stops paying, decay. From epoch 4, after 3 epochs without beating the best by 0.3 points, the watch signals the run. The rate then falls to zero over 2 epochs, and the run stops at an epoch's end. 16 epochs at most.
Predicted before the run

Written before training: v7 replaces v6 only if it reads the dev lines better with the beam search and the 95% interval of the paired difference is below zero. Then the 84 test lines are read once.

Observed

Dev lines, beam: 15.3% → 11.2%, −4.1 points (−7.1 to −1.3). v7 replaces v6. Prediction held

8 Failure: the watch crashed, and took the GPU run with it

The first launch reached training, then the watch on the laptop hit an error it didn't expect. A local error stops the whole app, the GPU's training with it.

The first launch of v7 got through the data check, wrote 248,571 stitched lines in 63 containers, and began training. Then the watch looked for the first epoch's folder before it existed. It was ready for "file not found", but Modal raises its own "not found" error, a different one. A question to the run that isn't answered yet likewise raises Modal's own time-out, not the usual one.

An error on the laptop stops the app, and the training container with it: a watch meant to make the run safer stopped it. Before relaunching, the watch was changed to catch any error reading the storage or an epoch, log it, and try again on its next round. It was tried against fakes on the laptop, and the signal to decay was sent through Modal's dictionary and read back.

The cost: the CPU containers, a few GPU minutes, and writing the stitched lines a second time. It's in v7's bill below.

9 The watch, epoch by epoch

The dev lines bounced between 11.2% and 14.6%. The watch asked for the decay after epoch 9, and the run stopped itself after 12. Epoch 6 is kept.

The second launch: 828,571 examples an epoch (248,571 stitched lines written anew, 290,000 BRUSH copies, 290,000 synthetic lines), 12,947 batches, on an A10G: 527 s an epoch, 1.8 h in all.

10%15%20%the writer's dev lines, character errorgoal 15%decay askedbegankept · 11.2%beamgreedy0.00050learning rate123456789101112epoch
Base v7 after each epoch on the writer's 38 dev lines, beam search and greedy, with the learning rate and the decay. Kept: epoch 6. Decay asked after epoch 9, begun after epoch 10.
EpochLearning rateBeam searchGreedy
10.000513.7%18.0%
20.000514.6%18.3%
30.000512.7%16.2%
40.000514.0%17.5%
50.000513.3%18.8%
60.000511.2%15.9%
70.000513.8%17.7%
80.000512.6%17.7%
90.000513.7%18.1%
100.000514.4%17.8%
110.0002512.8%18.0%
12013.2%18.0%
Read on the laptop from every epoch's weights. Blue: the beam search, which decides; grey, dashed: greedy, the network alone. Below, in ochre: the learning rate, flat until epoch 10, then falling to zero.
Every epoch: the dev lines with the beam search and greedy, and 1,000 lines of the held-out writers, real and stitched, greedy.
EpochRateBeamGreedyOther writers, realOther writers, stitched
10.000513.7%18.0%3.2%4.5%
20.000514.6%18.3%3.2%4.0%
30.000512.7%16.2%3.2%3.7%
40.000514.0%17.5%3.2%3.6%
50.000513.3%18.8%3.0%3.4%
6 (kept)0.000511.2%15.9%2.9%3.3%
70.000513.8%17.7%2.9%3.4%
80.000512.6%17.7%2.9%3.4%
90.000513.7%18.1%3.2%3.3%
100.000514.4%17.8%2.8%3.2%
110.0002512.8%18.0%2.9%3.0%
12013.2%18.0%2.4%2.8%
  1. After epoch 9, three epochs had gone by without beating epoch 6 by 0.3 points, so the watch asked for the decay.
  2. The run was already in epoch 10, so it read the signal at that epoch's end. The rate fell over epochs 11–12, and training stopped after epoch 12 of 16, on its own, without being cut off mid-epoch.
  3. The epoch kept is 6, the beam search's best on the dev lines.
  • At a steady rate, the dev lines bounce from epoch to epoch. 38 lines are noisy, so one epoch's dip is partly luck. The kept epoch is the best of 12 on these same lines, so its dev figure, 11.2%, is flattered. That's why the test lines decide whether to believe it.
  • The decay helped the other writers, not the writer. Stitched lines of the held-out writers went from 3.2% to 2.8%, their real lines from 2.8% to 2.4%, but the dev lines ended at 13.2%. The annealed model fits BRUSH-like ink a little better, not the writer's.

10 Base v7 replaces v6

By the rule set before training, v7 replaces v6. The test lines, read once, agree: 17.6% → 11.5%, below the 15% goal before personalising.

v4, v6 and v7 with the beam search, as the app reads. Only the dev lines decide; the test lines are read once, after.
Linesv4v6v7
The writer's dev lines (38): decides23.0%15.3%11.2%
The writer's test lines (84): read once25.2%17.6%11.5%
Other writers, never trained on (400 lines)26.9%2.9%3.5%
The 24 official test lines24.2%18.2%9.8%
−8−40difference in CER, points; below zero, v7 makes fewer errorsno changeDev lines: decides−4.1Test lines, read once−6.0Other writers+0.6
Base v7 minus base v6, paired line by line, beam search, with 95% bootstrap intervals
RowValueInterval
Dev lines: decides−4.1−7 to −1
Test lines, read once−6.0−9 to −4
Other writers+0.60 to +1
Dev: the writer's 38 lines; test: 84 lines, read once; other writers: 400 lines of the 17 held out. Green: the whole interval below zero; red: the whole interval above.

The test lines, read once, agree, and by more: 17.6% → 11.5%, −6.0 points (−8.6 to −3.6). That is below the 15% goal without personalising, and the dev figure's luck can't explain it. Read greedily, the network alone: dev lines 19.7% → 15.9%, test lines 21.4% → 16.0%.

Dev lines, beam
11.2%
v6 15.3%
Test lines, beam
11.5%
v6 17.6%
The 24 official lines
9.8%
v6 18.2%, v4 24.2%
Other writers, beam
3.5%
v6 2.9%

Base v7 is now the base. Personalising starts from it, and the browser reads with it: 8.60 MB, reading 10 of 10 check lines exactly as the trained model does. A smaller 8-bit copy read 7 of the 10 differently, so the full model is used.

The pilot
$1.70
Modal's bill
Base v7's run
$3.92
Modal's bill, with the failed launch; GPU $3.51
All of v7's work
$5.62
pilot and run

11 Where stitching helped

French went from 18.2% to 10.8% and math from 37.7% to 19.3%. Accent errors 50 → 20, dropped spaces 64 → 18.

All 124 of the writer's lines, read by v6 and v7 with the beam search. The dev lines are among them, so this is for finding where the errors are, not a test figure.

Error rate by language, all 124 lines.
Linesv6v7
All lines17.9%12.7%
English12.5%12.3%
French18.2%10.8%
Mixed16.7%13.4%
Math37.7%19.3%
Errors by kind, all 124 lines.
Kindv6v7
Lowercase letters250178
Spaces8731
Accents5020
Digits6733
Math symbols4127
Capitals3120
Punctuation3357

Stitched lines taught what BRUSH alone couldn't. French and math nearly halved. Accent errors fell from 50 to 20, and é read as e is gone from the top confusions. Digit errors halved. Dropped spaces went from 64 to 18, though added ones rose from 6 to 14.

Lowercase got better too, 250 errors to 178: real letters in real words and sentences, not only the two-word lines BRUSH wrote. English barely moved (12.5% → 12.3%): it was what BRUSH already had.

v6's top confusions, beam

  • é→e 14
  • i→e 12
  • 0→o 12
  • J→j 7
  • n→u 6

v7's

  • i→e 7
  • h→H 7
  • c→C 6
  • 0→o 6
  • J→j 6

Written → read as, with how often.

12 Failure: what got worse

Punctuation errors went from 33 to 57, capitals that look like their lowercase are now top confusions, other writers read 2.9% → 3.5%, and a line takes 52.5 ms against a 50 ms budget.

  • Punctuation: 33 → 57 errors. Commas and colons are dropped (8), read as a space (5) or as I. In stitched lines, marks are small and often borrowed from another writer. French puts a space before ":" and "!", and that space is narrowed like any word gap, so a small mark at a gap looks like a space.
  • Capitals mistaken for lowercase: h→H, c→C, J→j are now among the top confusions. These are letters whose capital is a larger copy of the lowercase.
  • Other writers: 2.9% → 3.5%, +0.6 points (+0.1 to +1.1): clearly worse, and small. BRUSH is 35% of each epoch instead of half.
  • Speed: 52.5 ms a line for 95% of lines (two runs: 52.4 and 52.6), just over the 50 ms budget; v6 measured 47.0 ms in the same session. The network is the same size, so the extra time is in the beam search: more possible readings stay close to each other.

Next: size and place marks as their writers did, and don't narrow the gap before a mark. In BRUSH, marks are cut with their word ("dwelling."), so they can come with it. And the beam search's time is the next thing to look at.

13 What carries over

When real data has the right shapes but the wrong content, recombine it into the content your users make. Pilot that against more of the same real data, and measure what you actually get.

Beyond this project
  • Labels are more than answers. BRUSH's letter-by-point labels made it possible to rebuild its ink into texts its writers never wrote, in their own hands.
  • When a change adds data, pilot it against simply adding that much more of the old data. Here more real ink made the dev lines worse, so the gain was the new content, not the volume.
  • Measure the property you're fixing the same way everywhere before training. The word gaps were 18% narrower than the public set's, and narrower gaps were exactly where the model dropped spaces.
  • Measure what a generator produces, not what it was asked for. Asked for pieces of up to 4 letters, it mostly made 1 or 2; a smarter cut didn't change the reading, which saved a run.
  • Let the run stop itself, from outside, on the measure that decides. And make the watcher tolerate any error: a crash in the safety net can stop what it was meant to protect.
  • Every fix has a cost somewhere. Look where it went: here punctuation, capitals, other hands and speed.

Previous · Episode 11 · 3 Nov 2026

Base v6: real hands

Real handwriting from 153 other people, piloted against a control and then trained into the base. The writer's test lines go from 25.2% to 17.6% error before personalising, where five kinds of fake ink had moved the score about a point.

Win