Tiny prose side project
Side project: from-scratch TinyGPT trained on Victorian women's writing, SFT'd for chat. The model is named Little Jane. Lives in prose/ (cloud/ = train+sft+generate scripts, scripts/ = data pipeline, models/ = checkpoints, data/ = corpora, json/ = chat/graded JSONLs). Philosophical companion doc co-written as we go: project_notes/CONVERSATION.md (séance/Frankenstein/time-travel framing — she's the medium, corpus = corpse, no living voices allowed in the channel).
Current state (2026-08-27)
Best checkpoint: v11/models/sft_cfg/g4voc2/ckpt_best.pt. The whole SFT
loop — build, run, listen — is written down in prose/notes/sft-loop.md,
including the reasoning for every current flag. Read that before changing a
setting; most of them were arrived at by measurement and one was reverted
twice.
Two days of work on 2026-08-26/27 found that most of what made v11 "sound
worse than v10" was scaffolding, not the model: a repetition penalty v10 had
and v11 lost, gold windowing dropped in the rebuild, no end-of-turn token, and
two places where the evaluation harness truncated her output in ways that read
as generation failure. See tiny-prose-decoding-config.
Live findings worth keeping:
- She writes ~3x whatever you write. Length is a property of the
conversation, not only of the data — a leading question gets 58 words, a
plain "how have you been" gets 30. Check the ratio before reaching for a
corpus dial. sample_sweep only tests turn one and understates this.
- Gold is the bottleneck and only chatting makes more of it. 438
conversations in gold/continuous_conversations.jsonl, doing the work of
thousands — Mr. Helstone keeps surfacing because of it. BL grading fixes
the intent quotas; chatting fixes gold. Different levers.
prose/scripts/chat_to_review.py turns chat logs into a grading pool.
Current state (2026-08-19) — v11
The corpus was rebuilt from 95M to 1.02B tokens. This supersedes most
size/arch numbers below; the older sections are kept for the reasoning, not
the figures.
- Corpus: 1,022,645,988 tokens.
pretrain_gutenberg.npy305.8M (2,749
books) + pretrain_blbooks.npy 716.9M (7,031 books), uint16, EOS between
books. BL Books is BACK and is 70% of the corpus — the "dropped entirely
for OCR noise" call below is REVERSED. It was readmitted behind a 5-rule
vocab-gated repair pipeline (cleaning/clean_blbooks.py + gate_oov.py),
and the pretrain/SFT split is what makes it safe: pretrain only has to be
narrative prose with readable OCR, SFT needs near-perfect dialogue. See
tiny-prose-corpus-design.
- Diagnosis that drove the rebuild: 243M params on 95M tokens is 0.4
tokens/param against Chinchilla's 20 — data starvation. Anchor-phrase
collapse, register drift, and the null DPO result are all consistent with
it. Problems previously blamed on curation were mostly starvation.
- Training code rewritten by her into
prose/mkm_train/:model.py
(weight tying, scaled residual init, SDPA), train.py (spread val windows,
warmup+cosine, decoupled weight decay, grad clip, bf16 autocast + TF32 +
--compile on CUDA), tokenizer.py, encode.py. This replaces the
cloud/ monolith for pretraining. She wrote it to learn, not to delegate —
do not rewrite it wholesale.
- Tokenizer:
prose/tokenizers/tokenizer_v11_16k.model, 16k, 4.19
chars/token, trained on clean Gutenberg only so BL's damaged words never
enter the vocabulary. A 32k variant was measured and rejected. All tokenizers
moved out of data/ into prose/tokenizers/.
- v11 pretrain COMPLETE 2026-08-19, Lambda GH200: 18L/16H/1024E/**block
2048, 245.2M params, batch 48, --compile, 50,000 iters = 4.92B tokens =
4.9 epochs, 388.9 min at 211K tok/s avg, $16.86. Final train 2.6699 /
val 2.7814** (val ppl 16.2).
- Val fell monotonically start to finish and never turned up — no
overfitting across 4.9 epochs, so dropout 0.1 was the right call. The
train/val gap widened 0.00 -> 0.10 only because train fell faster; gap
width alone is the wrong test, val direction is the right one.
- It had not saturated: val was still improving ~0.03 per 6k steps at the
end (some of that is the LR anneal, not pure learning). More tokens is a
live option for v12, not a dead end.
- Base verified locally: loads into a plain
GPTwith **zero_orig_mod.
keys (the raw_model save works), generates grammatical Victorian prose
with correct dialogue punctuation and split attribution. Main flaw is
repetition loops** ("disturbed and disturbed") — seen only at temp 1.0 /
top_k 40, the test_generation.py defaults, so not yet separated from
sampler artifact.
- No anneal and no chat shaping this round, by choice — she wanted the base
with no chat shape first. SFT is the untouched next lever.
- Kept locally in
checkpoints_v11/:ckpt_final+step_10000+
step_25000 (an epoch-arc trio for comparing voice over training). The
other six died with the instance.
prose/notes/lambda-run.mdhas the measured benchmark table.- Next, decided 2026-08-19: anneal THEN SFT, same two-stage shape as v8.
v8's anneal taught the chat FORMAT and SFT taught the VOICE; v11 skipped the
anneal on purpose to see the raw base, so the format stage is what's missing.
Hand-graded pool is 781 convos / 3,050 assistant turns / 71k supervised
tokens (graded_conversations 343 + continuous_conversations 438 — she treats
these as the SAME data, one fixed-3-turn and one variable; build_sft_dataset
already agrees, weighting continuous_short at 5 like graded). Avg reply 23
tokens = the 1-2 sentence target. Thin for teaching format AND voice at once,
hence the anneal.
- PLAYS are the new anneal backbone (round 7, 2026-08-19).
gutenberg_fetch_7.py pulled 526 drama texts -> 195,039 speaker-turns
across 532 plays, 7,113 play-scoped speakers, ~4.78M tokens
(after excluding 14 verse-drama files) (vs 55,654 in chat_named). Why plays:
they need NO attribution inference — the speaker is printed — so the
tiny-prose-dialogue-extraction-bug stays off the critical path entirely.
Median turn 9 words, 86% under 25. Male-authored is fine: the anneal tilts on
SPEAKER gender, and plays label speakers explicitly, so voice_tag.py works
better here than on prose.
- Dialect gate is PER-SPEAKER, not per-file. Dialect clusters in minor
characters (servants, villagers): in the Galsworthy compilation 10 of 143
speakers held it, and dropping everyone above 8% apostrophe-elision cost
3.7% of turns vs 14,326 for rejecting the file. Elision% is the specific
signal; OOV% false-positives on proper nouns.
- Still to do before it feeds the anneal: split the 10 compilations (speaker
names merge across plays otherwise), run voice_tag, apply the ~7% elision
gate. Judgment calls flagged by the script: 4 verse-drama files (Tennyson,
Yeats x2, Boker) and Synge's Irish dialect.
- **Anneal plan settled 2026-08-19 (run it on a rented GH200 — an anneal is
~1% of pretrain compute, so 3-5 epochs over an ~8M-token corpus is ~4 min /
~$0.15; rent an hour and sweep mixes rather than grinding on the Mac).**
- Graded data stays OUT of the anneal — not just preservation: if graded
turns are in the anneal, SFT val measures recall, not generalisation.
chat_namedis SAFE to use — the mis-pairing bug is fixed, see
tiny-prose-dialogue-extraction-bug.
- Lengths: plays median 10w / mean 17.5; chat_named median 13w / mean 24.3
with a 17% tail over 40 words. Plays sit on the 1-2 sentence target;
book dialogue supplies range. Default mix ~50/50 BY TOKENS (plays are only
4.78M vs chat_named's 13.3M, so plays need ~3.5x weight), then sweep.
- Reserve the female-speaker holdout BEFORE building any anneal corpus.
48,452 female-spoken play turns across 438 plays / 1,371 characters
(56.2% of gendered turns, ~1.08M tokens — better than v10's 42% prose tilt). Hold out
whole PLAYS, not turns, or the anneal has seen the surrounding context.
- Dangling openers are SFT gold, not garbage. 6,820 chat_named convos
(12.3%) open with an orphaned answer — 2,536 of them female-spoken, vs a
total hand-graded pool of 781. The reply survived and only the cue is
missing, which is exactly the half she writes. See
feedback-never-filter-on-the-cue.
- **Extractor gotchas found 2026-08-19, all fixed in
build_play_corpus.py
— each was SILENT and each cost real data:** three speaker layouts, not two
(NAME. text, NAME\ntext, and NAME\n\ntext — the third alone hid 68 real
plays / 56,327 turns, nearly all the Pinero, Maugham, Grundy and Shaw's
Candida, i.e. exactly the drawing-room comedy the anneal wants); stage
directions that WRAP ACROSS LINES (Barrie) which need stripping at file
level before line-splitting; honorific+initial (MRS. L.) and parenthetical
delivery notes (MRS. CLANDON (with quiet force).) both collapsing speakers
to bare MRS; multi-line speeches truncated to their first typeset line;
cast lists and tables of contents read as turns; four spellings of ellipsis.
- Verse detector: cap% x unterminated-line%, REVIEW LIST not a filter.
Ranks extremes well (Browning 0.43, Galsworthy 0.00) but the middle is muddy
— The Importance of Being Earnest scores 0.25 and The Melting Pot 0.29,
both prose, because layout-B files inflate the score. 12 files hand-picked
for exclusion at >0.28 (Browning, Shaw's Bashville, Wilde's Florentine
Tragedy, both Tennysons, both Yeats, Rice, Boker, Crowley, Housman's
Alcestis, Phillips' Nero, Binyon's Arthur, and Oscar Fay Adams' A Motley
Jest — Victorian Shakespeare pastiche). WIRED IN as VERSE_EXCLUDE.
- ANNEAL DONE 2026-08-20 — it works. 4 variants x 1500 iters, block 512,
batch 64, lr 1e-4, ~3.8 min each on a GH200 at 217K tok/s, ~$2 total.
Replies drop from paragraphs of narrative to 1-2 sentences — that was
the whole question and the answer is yes.
- Sweep tested PLAYS vs BOOKS weighting (not gender — anneal teaches shape,
SFT teaches voice). B = plays x3 (20% plays) is the pick; A (8%) close
second. c_plays_x8 already recites play lines (Mr. Malone from John
Bull's Other Island); d_plays_only memorised outright (train 0.27 / val
3.58 on 1.5M tokens seen 30x).
- Anneal val is NOT comparable across variants or to pretrain val. Each
variant's val comes from its own corpus, and a play-heavy corpus is more
predictable. v11 pretrain val 2.7814 vs anneal val ~2.57 means nothing.
Judge by GENERATION — mkm_train/sample_anneal.py runs fixed prompts
across every checkpoint.
- Nothing stops. Every variant runs on into a second
User:turn. No
stop convention exists in the corpus. That is SFT's job, not the anneal's.
- Kept: checkpoints for A and B only. At 4 min/$0.15 a variant, RE-RUNNING
is cheaper than storing — the 3.7GB download took longer than the training.
- train.py gained
--init-from(weights only, fresh optimizer, step -> 0;
--resume restores the step count so resuming a finished 50k run at
--max-iters 50000 trains nothing), pos_emb slicing (2048 -> 512, an anneal
wants a shorter context and pos_emb is sized to the block), and it now
DROPS zero-weight data sources (otherwise blbooks still loaded and val
windows came from pretrain prose while training on chat).
- CUE PIPELINE 2026-08-21 — the SFT data problem is basically solved.
- Re-extracted at
--max-gap-chars 120(was 250, which spanned ~40 words
of narrative and stitched unrelated moments). convs 226,866 -> 266,801,
turns -10%, avg len 7.2 -> 5.5. Book exchanges 97,950 -> 76,409;
book_pool_sft_top 6,788 -> 4,666. Worth it: 42% of the old reviewed cues
no longer resolve, and a visible chunk of those were the broken pairs.
remap_cues.pyre-points reviewed cues at a rebuilt pool by TEXT, not
id. 337 of 580 survived.
- Rewrite batches: 98% / 93% / 100% usable (300/236/50). Edit rate fell
19% -> 2% once three prompt rules landed, all found by READING pairs:
quoted phrase must survive, anaphoric hooks ("so do I") need their verb,
and don't write the reply. All three are the same shape — the reply
depends on something in the cue and the rewrite dropped it.
- Danglers work.
invent_cues.py, 1,877 female-spoken, ~62-65% usable.
Llama-3.2-3B matches Laguna here (~100x faster) once the prompt is right —
it failed the REWRITE task only because that needs two-text discrimination.
- The veto discriminates. Same judge, same prompt: 2.8% vetoed on real
extracted exchanges, 23% on invented dangler cues. That 8x gap is the
real evidence it reads for responsiveness. 1,446 dangler survivors.
- Template collapse is the hazard of invented cues. A "GOOD:" example in
the prompt produced the SAME cue for three different orphans; a tuned
prompt produced "I've been meaning to ask you" 4x in 20. NEGATIVE examples
are safe to show, POSITIVE ones get copied. opener_report() now flags it
automatically and strip_filler() removes filler openings — but only when
what remains stands alone as a sentence ("I've been trying to reach her"
is content, not filler).
- Invention is FINE if the reply still answers it. The bar is coherence,
not provenance — she invents when writing cues by hand too.
- Do not reword
veto_exchanges.py's prompt. Softening "if unsure answer
NO" to YES took the veto rate to 0%.
- Two Lagunas at once (60GB each on 128GB) hard-crashed the Mac. All three
mlx scripts now check free memory first.
- SFT WORKS 2026-08-20.
mkm_train/sft.pywritten (cloud/sft.py can't
load an mkm_train checkpoint — different pos_emb/head naming). Loss masked
to assistant tokens. On the 781 graded convos both anneal variants peak at
step 80, val 2.118 (A) vs 2.122 (B) — indistinguishable, so the anneal
choice washes out through SFT. B still preferred on generation (takes more
initiative: "But what's it like?" vs A's "Ah!").
- Sampling settled: temp 0.8 / top_k 100 / rep_penalty 1.0. Measured over
120 samples/setting: median 14 words, nothing over 40, 0% loops. A
REPETITION PENALTY MAKES REPLIES LONGER, not better — it pushes the model
away from tokens it has used, including the ones that end a reply. The
ghost-loop was one unlucky draw, not systematic.
- Self-contradiction across a sentence boundary is a CAPABILITY CEILING,
not a training bug ("I have no doubt I do. I cannot believe in them."). It
survived pretrain, anneal and SFT. One-sentence replies are structurally
immune to it — an argument for short output beyond just hitting the target.
- CUE REWRITING WORKS — 98% usable (keep 79 / edit 19 / discard 2 on 300).
modernise_cues.py + review_cues.py. Laguna, thinking OFF (it's register
transfer, not reasoning; with thinking on the scratchpad leaks). Projects to
~7,945 pairs vs 781 hand-graded.
- TRANSLATE the existing Victorian cue, never INVENT one from the reply.
Inventing produces a question that fits any answer — manufactured
coherence. The exchange already HAS a real question; modernising it keeps
the semantic relationship real.
- Prompt must be FREE with form and STRICT with meaning. Five restrictive
rules made it copy-edit instead of translate; leading with "write what
they WOULD have typed" + worked examples fixed it.
- Few-shot from HER graded cues (casual, lowercase, median 7 words) — a
model left alone writes prompt-ese. Her EDITS are the best examples.
- Llama-3.2-3B fails: can't tell which of two texts to rewrite, paraphrases
the reply instead. Laguna handles it fine and is fast (8B active).
- She does NOT need all 8,108 — a MIX beats maxing one source: 781 gold +
~1,500 rewritten + ~1,000 danglers + some plays.
- BUG I SHIPPED, now fixed: a comment placed INSIDE the FEMALE_NAMES
triple-quoted string in voice_tag.py put a, the, and into the name
set, so Speaker A resolved FEMALE. Inflated the dangler female count
13,454 -> real 2,844. Pools were unaffected (books guard placeholders
explicitly). Caught because the number looked TOO GOOD.
- JUDGE UN-PARKED — my costing was wrong. Book exchanges carry real topic
breaks (max_gap_chars=250 spans ~40 words of narrative), and they read fine
unless you check the reply. I costed the judge against all 6,788 book
exchanges (13h); the mix only needs ~1,500 = <3h overnight. Run it BEFORE
reviewing, so there is less to read — not instead of reading.
- LLM judge, scoped narrowly (2026-08-19). Judge answers only STRUCTURAL
questions with checkable answers ("is turn B a reply to turn A"), never
"is this good prose" — selecting on a modern model's taste would import that
taste. judge_calibrate.py measures the judge before trusting it, using
plays as free ground truth: POSITIVE = source-adjacent turns, NEG_STITCH =
two plays spliced, NEG_DISTANT = same play far apart. NEG_DISTANT accuracy
is the number that decides it. Judge PAIRS not whole convos, so a bad seam
trims a conversation instead of discarding it.
- CALIBRATION RESULT 2026-08-19 — the judge FAILED, do not use it yet.
Llama-3.2-3B: 70.0% vs a 66.7% always-NO baseline, 30% recall — a refusal
reflex, not judgment. Laguna S 2.1 (thinking off, "if unsure say NO"):
63.3% vs 66.7% baseline, POSITIVE 85% / NEG_STITCH 60% / NEG_DISTANT 45%.
NEG_STITCH 60% is the tell — two lines from entirely different plays,
and it called 40% of them real exchanges. If the easy case fails, the hard
case is worthless. ALWAYS print the always-one-answer baseline next to
accuracy; both models looked plausible until compared against it.
- **Untested and the actual next step: turn thinking ON (both runs had it
off — first truncated at 4 tokens, second disabled) and give the judge the
SURROUNDING TURNS for context, not two isolated lines.** GLM-5.2 rejected:
365GB against 128GB of RAM, it would page constantly.
- The anneal does NOT depend on this. Plays need no judge — source-line
gaps find breaks mechanically (median gap 3 lines, p90 8; >10 lines flags
4.5% as scene breaks). The judge gates chat_named ONLY, so a plays-only
anneal can proceed while the judge question stays open.
- Judge order settled: judge conversation-validity FIRST, then take the
female holdout from verified conversations — not the other way round.
- mlx-lm 0.31.3 is BROKEN by transformers 5.13 (
_LazyAutoMapping.register
gets a str, wants a class). judge_calibrate.py carries a shim rather than
pinning transformers in the shared venv. Models live in
/Users/melissa/models/hf-cache; use venv/bin/python for anything mlx.
Laguna also needs its own laguna.py in site-packages/mlx_lm/models/,
wiped by every mlx-lm upgrade ("Model type laguna not supported"); the
file ships in the model snapshot and judge_calibrate.py now reinstalls it
automatically.
- BL Books dialogue is still 100% untapped — 716.9M tokens never mined for
turns (chat_named is Gutenberg-derived). Too dirty for SFT by the two-bars
rule, but the ANNEAL bar is lower, so it's the volume option if plays aren't
enough.
- Pretraining is back on rented GPU; iterative work stays local. See
lambda-workflow (GH200 now the default instance, per-minute billing).
Current state (2026-07-24) — superseded above
- Corpus: ~40M words / ~56M tokens, growing toward ~60-65M. Hand-verified Gutenberg only (BL Books dropped entirely for OCR noise — big early lesson: at this scale data quality >> volume).
gutenberg_2/merged intodata/gutenberg/(402 clean books, one folder); new fetches stage indata/gutenberg_new/(NOT scanned by build_prose_corpus) until cleaned + moved over.gutenberg_fetch_3.py= round 3, 144 books (male Victorian canon — Dickens, Trollope, Hardy, Conrad, Kipling, non-fiction...), dedupes against v1/v2 IDs and title-checks fetched headers against expected titles (catches misfiled Gutenberg IDs). - v9 plan (settled 2026-07-23/24): drop the female-author filter for PRETRAIN (voice pushed downstream); recipe = all-authors pretrain + register-tilted anneal + newline-aware tokenizer + ~200M params. Rationale: 40M words was the bottleneck, not params; repeated-data returns die past ~4 epochs.
- Dialogue-pool decision (2026-07-24, supersedes the earlier "SFT sources stay female-only" call): extract dialogue from ALL 546 books into ONE pool (concept = turn-taking, wants volume; author gender is a crude proxy anyway — female authors write Darcy, Trollope writes heroines). Select at CONSUMPTION time, not extraction: anneal reads broad + soft-weighted feminine; SFT reads filtered (a minor/fading source — graded data is the real SFT lever now). Register lever = mechanical per-speaker gender tag, NOT an LLM judge (that's the voice-leak/rumination risk she avoids) and NOT hand-grading of dialogue (doesn't scale; hand-grading stays on Jane's assistant turns where it matters).
scripts/voice_tag.py(new, self-verifying):speaker_gender(label)via titles/pronouns/kin-words → name dict → unknown; possessive his/her dropped so "His Wife"→F, "Her Father"→M;female_fraction(speakers)andanneal_repeats(speakers, w). Run standalone for stats before trusting it (--show-unknown Nlists unrecognized labels to add to the dicts). Wired intobuild_anneal_corpus.py --female-weight(default 1): full-female convos written 2x, mixed 1-2x, male/unknown 1x — whole convos repeat so turn-taking never breaks.- GOTCHA —
strict_2_speakerbelongs to SFT, not the anneal: it requires EXACTLY 2 named speakers, dropping ~70% of named convos (single-named-speaker + multi-party scenes) and disproportionately killing female-taggable ones. Left on, it tanked the anneal register tilt to 26% female; off, 42% on a ~2x bigger pool.build_anneal_corpus.pynow defaults it OFF (SFT builder keeps it ON). Full all-books pool after extraction: 59.6k convos / 380k turns → chat_named 41k, chat_unattr 18.6k. - SFT val loss is NOT comparable across rounds (sft3-6 ranged 2.220-2.260): each rebuild changes the 5% val split, and physical weighting (×5 copies) leaks duplicates train↔val, so val mostly measures fit, not generalization. Judge rounds by grading keep-rate (temp_stats.py batches/kept-turn, baseline ≈2.1) instead. Fix if ever wanted: group identical convs to same side of split in sft.py load_and_pack.
- Training: was local on her M5 Max for the 91M model. SUPERSEDED — a 245M model on 1.02B tokens goes to a rented GPU; local stays for SFT/grading/iteration. She runs all training/scripts herself.
- v8 base (
prose/models/model_v8/final.pt): 91M params (L12/H12/E768/block256, 8k vocab), 60k iters ≈ 9 epochs, cosine to zero, final 15% annealed 50/50 on chat-formatted book dialogue (train.py --anneal-data, corpus built byscripts/build_anneal_corpus.py). Corpus val 3.605; anneal val dove 3.40→2.45. Use final.pt (not best.pt) as SFT base after annealed runs. - v8 SFT (
prose/models/model_v8_sft/best.pt): val 2.477 at iter 100 (vs v7 line's best 3.044). Overfits after ~200 iters — future SFT runs should be ≤500 iters. Annealing already taught the format; book-pair SFT data is nearly spent as signal — graded data is now the main SFT lever. - Old checkpoints deleted; survivors: model_v7_sft2 (keepsake), model_v8, model_v8_sft.
The iterative loop (settled workflow)
- Grade conversations interactively vs latest best.pt (
cloud/grade_conversations.py→prose/json/graded_conversations.jsonl; provenance + gen_temp tracked) scripts/build_sft_dataset.py --chat-unattr-weight 1— rebuild-from-scratch mixing (data isn't "used up"; each SFT round starts from v8 base which never saw graded data)cloud/sft.py --resume prose/models/model_v8/final.pt— short run, take best.pt- Repeat. Retrain every ~200 new graded convos (compounding: better base → higher keep rate → faster grading)
Goal: ~1,000 hand-graded convos (~3,000 approved turns; LIMA-style reasoning). Milestones: 300 = graded dominates signal; 600 = can drop graded weights to 1-2; 1,000 = reassess. Diversity > count (vary prompt types, turn depth 2-4, emotional registers). Plan: keep ~10 fixed benchmark prompts never trained on, for round-over-round comparison (not yet picked).
Hard-won pipeline gotchas
- Extraction flags are load-bearing:
extract_conversations.pytuned values aremax_gap_chars=250, min_turns=2(now the defaults — a re-run with old defaults 500/3 silently merged/dropped convos and shrank the SFT pool). The.mdfiles record their flags in headers. conversations_to_jsonl.pyoutputs toprose/json/(she keeps all JSONLs there).anneal.encoded.npymust be rebuilt (build_anneal_corpus.py) after corpus/extraction changes, before any future pretrain.- Chat template:
User: {c}\n\nAssistant: {c}\n\n, convos joined by\n\n---\n\n; constants live incloud/sft.pyand are imported by the other tools. - The SentencePiece tokenizer drops
\nentirely (encode("\n\n") == []) — the template's newlines have never existed at the token level; role tags are the only real structure, and the corpus lost paragraph breaks at encode time. Discovered Jul 2026; STOP_MARKERS fixed to bare("User:", "Assistant:", "---")for v8. - v9 REVERSES the stop-marker fix (2026-07-25): now that newlines are real tokens, generation drifts past the reply into a run of blank lines + book narration (the base model's prose leaking through the SFT veneer) — none of which carries a role tag, so the bare-tag STOP_MARKERS never fired and replies rambled to max_tokens ("giant gaps"). Fix:
STOP_MARKERSincloud/generate_candidates.pynow leads withTURN_SEP("\n\n") — the assistant turn's own separator, emitted right after the reply, is the earliest reliable end-of-turn signal (a 1-2 sentence reply has no internal blank line, so cutting at the first\n\nis safe). Shared via import bygrade_conversations.py(and thuschat_continuous.py), so the one edit fixes live grading + batch gen. Verified: v9 tokenizer decode round-trips\n\n,split("\n\n")[0]cleanly drops the drift. NOT a temperature bug — gaps were the stop logic; temp only affects coherence (0.8 is a good grading spot). Degenerate repeat-loops (e.g. "afraid of the dark ×8") are a separate rare sampling artifact, just re-roll. - v9 tokenizer FIXED (2026-07-24): tokenizer training pulled out of train.py into standalone
scripts/train_tokenizer.py(train.py now LOADS only, via new--tokenizer/--encoded-cacheargs, and errors if missing instead of silently training a newline-stripping default). The fix =user_defined_symbols=["\n"]+remove_extra_whitespaces=False(+byte_fallback=True). Verified on a corpus sample:encode("\n\n")→ newline token twice, exact round-trip. Default outputdata/tokenizer_v9.model, vocab 16k (up from 8k). Harmless warning:\npiece "breaks the format" of the .vocab TEXT file — the .model is fine. MUST NOT overwritedata/tokenizer.model(v8's 8k, still in use for grading) — script refuses without --force. train.py reads vocab_size from the tokenizer now (removed --vocab-size flag), single source of truth. - v9 arch (chosen): 16 layer / 16 head / 1024 embd / block 512 ≈ ~200M params (not 250M — 60M-word corpus wants ~3 params/token max; save 250M for the next corpus bump). block 512 = whole scenes vs v8's half-pages.
- Round-4 fetch DONE + MERGED (2026-07-29):
gutenberg_fetch_4.py— Gutendex-catalog-driven (no hand-typed IDs; author-name must-match + title denylist + download-count ranking), 86 books / ~11.9M words, depth on prolific women (Oliphant 25, Yonge 20, Ouida 15, Wood 12, Braddon 12, Craik 7) — Jane's register, feeds the anneal tilt too. She pipeline-cleaned them same day;gutenberg_new/andextra-formatting/then folded intodata/gutenberg/= 653 books, one tree, no staging dirs (cleaning-script defaults repointed to gutenberg/; fetch_4 still stages future rounds in gutenberg_new). Corpus 60M→~72M words = right size for the 250M-param v10 (rebuild corpus.txt + retrain tokenizer when v10 starts). Gutendex is flaky (timeouts/resets, 60s+ cold pages) — retries catch bare OSError; a failed author just re-runs. - v10 tokenizer prep DONE (2026-07-29):
train_tokenizer.pynow listsuser_defined_symbols=["\n\n", "User:", "Assistant:", "\n"]— turn separator + role tags become single atomic tokens (verified on a throwaway tokenizer). Template overhead drops ~13→4 tokens/exchange; newlines were 7% of chat tokens. Inert until a v10 tokenizer is trained. Cross-family val comparisons: use nats/char (val × tok-per-char), but even that flatters v8 (its tokenizer deleted newlines = less to predict); judge by keep rate. - v10 PRETRAIN LAUNCHED (2026-07-30): 18L/16H/1024E/block512 = 243.57M params on 94.97M tokens (2.56 params/token), 27,500 iters, anneal (mix 0.5) from iter 23,375 on anneal_v10 (8.18M tokens, 43% female tilt). Tokenizer_v10: 16k + atomic
\n\n/User:/Assistant:pieces (verified single tokens). First corpus with paragraph breaks intact at token level. Cache: corpus_v10.encoded.npy. Output: model_v10/. Val NOT comparable to v9 (new tokenizer/corpus/split) — judge by samples + SFT grading. ~1.4x v9 wall clock expected. - v10 PRETRAIN DONE (2026-07-31, 31.5h): corpus val 3.508 (best = final iter, so best.pt == final.pt this run — still use final.pt by convention), anneal val 3.75→2.80 during anneal. Samples pre-SFT already produce role tags + separator and in-register 1-2 sentence conversational turns with emotional logic; role alternation still shaky (doubled User: turns) — that's SFT's job. Next: SFT from model_v10/final.pt on the fresh sft_dataset.jsonl (14,922 rows, dialect+female filtered) → model_v10_sft, then grade vs v9_sft4.
- Corpus HAND-CLEAN COMPLETE (2026-07-30): all 650 books cleaned (Victoria letters dropped — wrong register, no dialogue, 0.7%). Ready for v10 rebuild AND for a re-extraction refresh of the dialogue pools (chat_named still predates round 4). New SFT filter:
--max-dialect-frac(default 0.04) in build_sft_dataset.py — mechanical word-list scorer (dinna/weel/ye ken/-in') drops written Scots/rural eye-dialect convos from book SFT sources (~1% of pool; Waverley-style scenes score 0.15+). Pretrain/anneal keep dialect deliberately. - THREE SFT TEMPLATE BUGS FOUND + FIXED (2026-08-01), all in
cloud/sft.py, all pre-dating v8: diagnosed from v10_sft samples showing a blank gap after every assistant turn and doubledAssistant:turns with noUser:between. - Doubled separator — every conv already ends with the final assistant turn's
TURN_SEP(\n\n), andpack()then appendedBETWEEN_CONV_SEP = "\n\n---\n\n"→ the packed stream literally read\n\n\n\n---. The model correctly learned "most likely thing after an assistant\n\nis ANOTHER\n\n" = the gap. Fix:BETWEEN_CONV_SEP = "---\n\n"(no leading newlines). - Spurious
▁at every segment boundary —tokenize_conversationencoded segment-by-segment, and SentencePiece'sadd_dummy_prefixprepends a word-boundary marker to EVERYencode()call. Training read'?' '▁' '\n\n' '▁' 'Assistant:' '▁'; generation (encodes prompt as one string) produces'?' '\n\n' 'Assistant:'. Trained on transitions never seen at inference, right where the reply starts; also decoded as a doubled space ("Assistant: I"). Fix: encode the WHOLE conversation once without_type="immutable_proto"and derive the mask from per-token byte spans (byte, not char — curly quotes/em-dashes are multi-byte). Lone-▁tokens dropped from ~3/message to 1/conversation (the legit leading one). - Mask off by one token per reply — SentencePiece glues the prefix's trailing space onto the first content word (
'Assistant:'+'▁To'), so with the space owned by the prefix segment, the first token of EVERY assistant reply fell in the mask=0 context span. The model was never trained on the first word it has to produce. Fix:format_conversationassigns the prefix's trailing space to the CONTENT segment (emitted text is byte-identical; only the span boundary moves). - New constant
ASSISTANT_OPEN = ASSISTANT_PREFIX.rstrip()= what a GENERATION prompt ends with (bare tag, no trailing space, so the model supplies the space itself and matches training token-for-token). Used by sft.py's sample_prompt,generate_candidates.py,grade_conversations.py.USER_PREFIX/ASSISTANT_PREFIXkeep their trailing space — they're the TEXT template (build_anneal_corpus still uses them, and anneal text must stay identical). - Verified: train/inference token sequences now identical prefix; 500 convs round-trip to the exact template text with len(tokens)==len(mask). Any model trained before this (v8/v9/v10_sft) carries all three bugs — a re-run is the real test of the template, and SFT val across the fix is not comparable.
- v11 DEFERRED — "fix the anneal" is not a real lever (decided 2026-08-01): the separator bug cost the anneal one extra
\n\n+ one dummy-prefix token per conversation = 112,972 tokens = 1.38% of the anneal, 0.119% of the 94.97M-token pretrain. And the anneal never had the other two bugs:build_anneal_corpus.pyencodes each convo as ONE string (no stray▁), and pretraining has no mask (no first-word bug). Those two were SFT-only and v10 already has them fixed — that's the 3.339→2.657 iter-0 val jump. So a v11 justified by "correct anneal" would burn 31h for a tenth of a percent. Real v11 preconditions, in payoff order: (1) graded pool 309→1,000+ convos (biggest voice lever, needs NO retrain — lands in SFT on the v10 base she already owns); (2) corpus growth (already at 2.56 params/token vs her ~3 ceiling, so more params REQUIRES more words first); (3) then the genuinely untried idea — put graded convos INTO the anneal so chat register is taught at pretrain scale, which only becomes meaningful at 1,000+ convos vs 8M anneal tokens.anneal_v10b.encoded.npyrebuild deferred until v11 is actually on. - WHY SFT BARELY MOVES NOW — anneal and SFT eat the same data (2026-08-01):
build_anneal_corpus.pyandbuild_sft_dataset.pyboth readjson/chat_named.jsonl+json/chat_unattributed.jsonl. So ~83% of SFT rows (12,358 of 14,922: named 3,860 + unattr 8,498) are book dialogue the base ALREADY trained on during the anneal — SFT spends most of its gradient re-teaching what the anneal taught. Only the 2,564 graded-ish rows (graded_single 195 + graded_convo 1,545 + continuous 824 = 17%) are new signal. Measured on v10_sft2 (post-template-fix): val 2.657 at iter 0 → best 2.612 at iter 100, oscillating 2.61-2.66 = a plateau, not a curve. This is the quantitative version of the v8-era "book pairs are nearly spent" note, now with the mechanism. Lever:--chat-unattr-weight 0(the BUILDER DEFAULT — her habitual command passes 1) drops 8,498 rows and lifts graded share 17%→40%. - Reading sft.py training samples: they are raw 60-token generations with NO stop markers (
generate()called directly in the eval block), so everything after the first\n\nis the model free-running a whole dialogue. DoubledUser:/Assistant:turns THERE are an artifact, not a defect — real generation (generate_candidates.py) cuts at the first\n\n. Judge only the first assistant turn. - WEB GRADING UI + PREFERENCE LOGGING (built 2026-08-01):
--webon bothgrade_conversations.pyandchat_continuous.py(use the latter — unbounded turns + sliding--window, which she prefers for delving).live_view.pygained an embedded stdlibHTTPServeron a daemon thread (serve(),ask(),wait_submission(),drain()) — the old read-only static-file mode still works, but do NOT runpython -m http.serveralongside--web(same port). Page re-renders only whenturn_idchanges, so the 700ms poll can't wipe in-progress typing. Each candidate is an auto-growing textarea + rating box: float, 0 = best, ties fine, blank = unrated; lowest-rated continues the conversation. Keys 1-N set that one to 0, Enter saves, Cmd/Ctrl+Enter saves from inside a textarea. Edit-as-keeper rule (her design): editing a candidate saves the edit at rating 0 AND keeps the ORIGINAL as a reject at 0.5 (overridable) with explicitedited_from/edited_intolinks — the highest-precision preference pair available, since only the edit differs. All candidates incl. rejects append toprose/json/graded_candidates.jsonl(separate file —build_sft_dataset.pyreads the conversations file and its shape is load-bearing). Nothing consumes it yet; it's for future DPO (thin at ~300 convos, but the rejects are unrecoverable later). Conversation rows now also carry agenblock (ckpt/top_k/top_p/repetition_penalty/candidates/ui) so pre- and post-penalty rows can be told apart; old rows lack it and mix in fine. - TOP-P ADDED, BUT ONLY PAIRED WITH THE PENALTY (2026-08-01):
--top-pon generate_candidates/grade_conversations/chat_continuous, default 0 = off, applied after the repetition penalty and before top-k. Measured on v10_sft4, same seed:top_k=40alone → "and to a seance, and to a seance";top_p=0.9ALONE made looping WORSE ("I have been to a seance." ×8) — nucleus sampling narrows onto the confident token, and in a loop the loop IS the confident token, so it removes the escape hatch;top_p=0.9 + penalty 1.15→ clean and the best of the three. Treat them as a pair, never adopt top-p alone. Top-p also should reduce subword artifacts like "I trous|er" (for "I trust"), where junk sat inside the fixed top-40 at a confident step. - REPETITION IS A SAMPLING PROBLEM, NOT A TRAINING ONE (measured 2026-08-01): cutting the SFT LR budget nearly in half (warmup 25 / 100 iters = 53.7 iter-equivalents vs 93.7) did NOT stop it — the iter-99 sample still looped "I have been so happy." ×8 inside a single reply. The fix that works is
--repetition-penalty(new, added togenerate_candidates.pyasapply_repetition_penalty, wired intograde_conversations.py+chat_continuous.py; CTRL-style, applied BEFORE top-k so penalized tokens can leave the candidate set). Measured mean duplicate-4-gram rate over real (stop-marker-cut) replies from model_v10_sft3: pen=1.0 → 0.010-0.057 depending on checkpoint; pen=1.15 → 0.000 at EVERY checkpoint (iter 40/60/80/99). Default is 1.0 (off) to keep the ~2.1 keep-rate baseline comparable. Useful band 1.1-1.2; >1.3 dodges common words and hurts the voice. Consequence: with the penalty on, checkpoint choice stops being about repetition and becomes purely about voice. - SFT val is a bad quality signal — trust the samples (reconfirmed on v10_sft, 2026-08-01): val kept setting new bests (2.543→2.536→2.526) while sample quality visibly degraded into degenerate repetition ("I have had a pleasant morning" ×3) by iter 200; iter 150 was clearly better by eye. Val differences at that stage are noise-scale (0.4%) while train falls 10× as fast = memorizing, and the physical weighting (graded ×5/×3, continuous ×2) leaks duplicate rows across the split so val rewards memorization.
sft.pyonly keepsbest.pt(overwritten on each val improvement) +latest.pt, so a good intermediate checkpoint is LOST — to land on the eyeball optimum, re-run with--max-itersset near it (also lets cosine decay to zero there instead of being chopped mid-decay). - New cleaning tools (2026-07-29):
cleaning/strip_blank_runs.py(collapse 3+ newline gaps — cosmetic only: build_prose_corpus reflow already collapses 2+ newlines to \n\n, corpus.txt has ZERO gap runs; gaps from deleted headings never reach the model) andcleaning/find_broken_paragraphs.py(reports para ending lowercase-no-punct followed by para starting lowercase = mid-sentence removal damage, e.g. deleted illustration; run AFTER flatten_paragraphs; only 66 hits in the whole existing corpus). Broken sentences are the real damage; gaps are not.
Related: tiny-prose-goals-constraints (NO synthetic data; 1-2 sentence reply target), feedback-minimal-change, dev-hardware.