The SFT loop
Grade → build → run → read. One round is three commands and about half an
hour. Everything runs on venv/bin/python (3.12 + torch); the system python3
is 3.9 and cannot import dialogue_parse.
Pick a name for the round first and use it in both places — the checkpoint
directory and the built dataset. Naming them after what changed (bl255,g2long, crime) is what makes step 3 mean anything later.
---
1. Grade
venv/bin/python prose/scripts/grade_server.py --in prose/json/bl_review_v2.jsonl
Appends to json_sft/ready/graded_convs.jsonl, resumable by exchange_id.
Nothing else in the loop needs doing until this file grows.
2. Build the pool
venv/bin/python prose/scripts/build_sft_balanced.py --dry-run
Read the intent table before writing anything. <- corpus has no more means
the quota could not be filled — that is the shopping list for the next
grading session, and the only number that answers "did the grading land".
Graded rows sit in front of the ranked corpus in the quota pool, so each kept
greeting is +1 on the greeting row.
Then write it:
venv/bin/python prose/scripts/build_sft_balanced.py \
--gold-weight 4 --min-reply-words 20 \
--out prose/json_sft/built/sft_<name>.jsonl
--gold-weight 4 — was 2, raised 2026-08-26 once gold windowing came back.
The dial and the windowing are one decision. v10 split each long gold chat
into sliding windows of 3 exchanges; v11's builder dropped that and used every
row whole, costing 762 of 1,200 rows. So the gold file was a third of its real
size, and no setting of the dial was good: x8 memorised (it answered "Mr.
Helstone has been here all the morning" to an unrelated cue) because 438 rows
x8 is 3,500 near-duplicates, and x2 vanished — gold fell to 11% of the set and
she came out sounding like a generic Victorian person rather than a Victorian
woman. x4 windowed is 25% of the set as 1,200 DISTINCT windows: the same token
budget without the repetition.
--min-reply-words 20 — the fill only. Replies had collapsed to 9.7 words
against v10's 17.3, and short replies read as flat. This buys back ~2 words
and produced the first genuinely witty line in the project.
--asks-back 4000 (the default) — the only quota that matches on the REPLY.
Everything else matches a user turn, so nothing ever selected for her asking
anything, while gold selected hard against it: gold is 14.5% assistant
questions against 52.9% user questions, because chatting with her is an
interview. Novel dialogue is 1.1:1.
It is dose-dependent on gold, which is how we know the corpus is not at fault:
g2 11% gold 20.8% of generated replies end in "?" flat
g4eos 25% gold 14.9% incurious
g4ask 25% gold 21.4% both
Character and curiosity looked like a trade-off for a day. They were two knobs
wired together through the fill budget, and separating them cost nothing —
mean reply length was identical at 23.9 words either way.
--devocative (off by default) — strips named vocatives from assistant turns
outside gold/graded. She is not talking to Cayley or Mr. Laval, she is talking
to you, and the corpus puts her in conversation with a man about 4:1 because
nobody ever chose the addressee. Nothing on disk changes; the rewrite lives
only in the built --out file, so rebuilding without the flag reverses it.
POSITION DECIDES which vocatives go. 29.7% of them END the turn — "How is your
mother to-day, Mr. Laval?" — where the name is the closing cue, not
decoration. Dropping those too made replies 24% LONGER with worse grammar
(23.8 words against 19.2), one day after teaching her to stop. Turn-final
vocatives are therefore REPLACED with "my dear" and mid-sentence ones dropped,
which recovers most of that (20.8 words) and lands "dear" at 6.8% on its own —
close to Melissa's own hand-edit rate of 7.6%, without tuning to it.
Do NOT replace every vocative with "my dear": that reaches 18.3%, one turn in
five, which is a tic rather than warmth.
This does not touch names in SUBJECT position, by design — "Mr. Helstone has
been here" is a fictional detail, not a misidentification of who she is
speaking to. Helstone keeps surfacing because gold is 781 conversations doing
the work of thousands; that is a volume problem and the only fix is more gold.
3. Run
caffeinate venv/bin/python prose/mkm_train/sft.py \
--init-from v11/models/anneal/checkpoints_anneal_e_bl_mixed/ckpt_final.pt \
--data prose/json_sft/built/sft_<name>.jsonl \
--checkpoint-dir v11/models/sft_cfg/<name> \
--max-iters 250
Always resume from an ANNEAL checkpoint, never from a previous SFT — stacking
SFT on SFT compounds the overfit. e_bl_mixed is the current base; the six
anneal variants are within 0.004 on val, so that choice is not worth
revisiting.
Use ckpt_best.pt, not ckpt_final.pt. This overfits fast: best step has
landed anywhere from 70 to 240 of 250.
4. Read it
venv/bin/python prose/scripts/sample_sweep.py --dir sft_cfg \
--names <name>,<previous-name> \
> prose/notes/sweep_$(date +%F)_<name>.txt
Same prompts, same seeds, both checkpoints side by side. Val loss cannot
choose between these runs and never has — read the replies.
Two things to look for beyond whether it reads better:
- A phrase repeating across variants at one fixed seed is memorised, not a
coincidence. They descend from one base, so at a fixed seed they make the
same opening choice and diverge after. This is how the gold x8 stock frames
were caught.
- Reply length.
sample_sweep.pyprints the mean. It is a dial, not a
quality measure: v10 ran 17.3 words with repetition loops, g2 9.7, g2long
11.7. Below ~10 she stops being interesting.
Chat with it before deciding anything:
venv/bin/python prose/mkm_train/chat.py --ckpt v11/models/sft_cfg/<name>/ckpt_best.pt
Logs to mkm_train/chat_logs/<model>.jsonl.
Sampling settings now travel INSIDE the checkpoint — sft.py writes asampling block and chat.py reads it, so temp 0.8 / top-k 100 / repetition
penalty 1.15 apply without anyone remembering them. Checkpoints from before
2026-08-26 have no block and fall back to no penalty; pass--repetition-penalty 1.15 by hand for those.
Do not lower temperature to 0.6 to "clean up" the output. That was tried on
2026-08-24 and flattened her character badly enough to look like a data
regression: "Have you ever been to a seance?" answered "No." at 0.6 and
"Never. My circle of friends is limited." at 0.8. Sampling parameters are part
of the evaluation.
Decoding is a bigger lever than it looks
Two of the three wins on 2026-08-26 cost no training at all:
- Repetition penalty 1.15 — ported from v10, which had one; nothing in the
v11 path did. Took g4win from 9.3 to 12.2 mean reply words on the SAME
weights and killed the run-ons ("I've been on the sea, and I've been in a
cave, and I've been up in the air...").
- A learned stop.
</s>now closes every assistant turn in the SFT
template and is supervised. She emits it 24/24. Before this nothing marked
where a turn ended, so generation was cut by a string match and, failing
that, truncated mid-word by max_new — "and I'm comi" was a reply running out
of budget, not ending. A learned stop is also what makes a generous token
budget safe, so sample_sweep's cap went 50 → 160.
Corollary worth remembering: when she looks clipped, check the harness before
blaming the model. sample_sweep was also clipping its own output at 140
characters, which read exactly like a truncated generation.
---
What the loop cannot tell you
Val loss. Since the dedup-before-split fix it is at least comparable across
runs, but it does not track anything you care about — the six anneal variants
landed within 0.004 of each other and read very differently.
Two levers are retired, with evidence, and do not need re-testing: the anneal
mix (five of six variants share the same 18.6M book tokens) and more ranked
fill.