Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

Cat Chat V5 R1: The Special-Token Bug, the R0 Post-Mortem, and a Roast-Driven Restart

Three days of Cat Chat GRU-L3 pretraining: a special-token ID bug that invalidated R0 checkpoints, a full R0 regression post-mortem, a 5-fix roast of the R1 config and trainer, and a corpus-cycle-based teacher-forcing plan.

The Three-Day Arc

Over the last three days the Cat Chat GRU-L3 pretraining pipeline went through a full cycle: a corpus restructure and cleanup, a deep anomaly scan, a post-mortem of the failed Run 0, the discovery of a special-token ID bug that invalidated every prior checkpoint, a second roast of the V5 R1 config and trainer code, and a fresh restart with all fixes applied. This post is the field report.

3
GRU Layers
2048
Hidden Dim
101
Vocab Tokens
1024
Context Window
4.3 GB
Corpus Size
4.99M
Conversations

The model under the hood is a 3-layer GRU with hidden dimension 2048, a 101-token ASCII character vocabulary, and a 1024-token context window, trained from scratch on a 4.3 GB dialogue corpus of 4.99 million conversations. It runs on a single RTX 3080 Laptop GPU via a custom Vulkan compute pipeline written in C++20. There is no PyTorch, no CUDA, no framework — every shader, every backward pass, every optimizer step is hand-written.

The Special-Token ID Bug

The first discovery was a vocabulary layout change that silently broke checkpoint compatibility between V5 Run 0 and V5 Run 1.

TokenV5 R0 ID (V=100)V5 R1 ID (V=101)
<pad>00
<unk>14
<bos>22
<eos>33
<turn>—1
space45
printable ASCII (non-space)5–986–99
fallback UNK99—
newline—100

Run 0 used a 100-token layout where <unk> shared ID 1 and a fallback UNK sat at ID 99. Run 1 introduced a dedicated <unk> at ID 4 and added a <turn> token at ID 3, shifting every printable ASCII character up by one. The embedding table row that Run 0 learned for "the letter e" (ID 14) now pointed at a different character in Run 1's layout. Every checkpoint from Run 0 became garbage the moment the tokenizer changed.

Lesson: A vocabulary layout change is a hard reset. There is no remapping, no fine-tuning path, no warm start. The embedding table is indexed by token ID, so shifting IDs shifts which weights apply to which characters. The only correct response is fresh weights — and that is what V5 R1 E1 uses.
V5 R0 (V=100)V5 R1 (V=101)0:pad 1:unk 2:bos 3:eos4:space 5-98:ASCII 99:UNK0:pad 1:turn 2:bos 3:eos4:unk 5-99:ASCII 100:newlineshift+1Incompatible: Every R0 checkpoint is garbageEmbedding row N points at wrong character after ID shiftFix: Vocab layout hash in checkpoint headerReject incompatible checkpoints at load time (roadmap)

The Run 0 Post-Mortem

Before the special-token bug was found, Run 0 had already failed on its own merits. Epoch 1 trained successfully (eval loss 1.0046, top-1 72.10%), but epoch 2 regressed on every metric.

MetricRun 0 E1Run 0 E2Delta
Eval loss1.00461.2940+28.8%
Top-1 accuracy72.10%67.02%−5.08 pp
Top-5 accuracy90.75%87.18%−3.57 pp
BPB1.64591.867+13.4%

The smoking gun was the first batch of epoch 2. Loss jumped from 1.435 (end of epoch 1, post-cooldown, LR=0.00005) to 2.411 (epoch 2 step 1, warmup restart, LR=0.0) — a +68% jump on a single batch. The warmup ramp from lr_min to lr_max over 89 steps is a 60x learning-rate jump that disrupted the converged model.

This is the "double-WSD" problem. The trainer uses a WSD schedule (warmup → stable → decay). On resume, the schedule restarts from step 0 relative to the loaded checkpoint, re-running the full warmup and stable phase at peak LR=0.003 on a model that is already sitting in a good basin. The stable phase pushes the model out of that basin; the cooldown cannot recover it.

Training StepLoss2.52.01.51.0Epoch 1: convergenceEpoch 2: +68% jump, stuck+68%resume
Root cause confirmed: The trainer used resume-relative step counting (global_step - loaded_global_step), which resets to 0 on every resume and triggers a fresh warmup. The fix is WSD-S (Wen et al. 2024, arXiv 2410.05192): continue training from the intermediate checkpoint at a high constant LR without rolling the schedule back. Use absolute global_step and a total_steps that grows with each resume.

Run 0 vs Fresh Start (Phase 0 E1)

After the root cause was confirmed, a fresh start with research-backed settings beat Run 0 on every metric.

MetricRun 0 E1Fresh E1Delta
Training loss1.4351.427−0.008
Eval loss (TF=1.0)1.00460.9781−0.0265
Eval top-172.10%72.74%+0.64%
Eval top-590.75%91.02%+0.27%
Total time134 min144 min+10 min (block shuffle)

The fresh start used LR=0.001 (3x lower than Run 0's 0.003), a gentler TF decay (0.90→0.80 vs 0.90→0.50), block shuffle from epoch 1 (vs none), and a shorter cooldown (10% vs 20%). The WSD sharp loss drop during cooldown was confirmed for the GRU architecture, matching Belloni et al. 2026 (Universal Dynamics of WSD): "most training signals qualitatively similar in CNN vs Transformer."

The Corpus Pipeline (Three Days of Cleanup)

Before V5 R1 could start, the corpus needed to be rebuilt. The old pipeline had destroyed all conversation structure: every record was separated by a blank line, making conversation boundaries indistinguishable from record separators, and the C++ loader then randomly shuffled all records. The model never saw a coherent multi-turn conversation.

Conversation Structure Recovered

New format: <bos>text<turn>text<turn>text<eos>. Conversations are contiguous blocks, shuffled as units. Turns preserved within conversations. Stateful BPTT with state reset at <eos>.

Anomaly Scan (16-core parallel)

4.99M records scanned. Found: 1,962 leaked speaker labels, 6 URLs, 291 pure repetition records, ~1,877 dominant-phrase records, ~488 low-diversity records. All fixed in-place via 4 post-process passes.

Repetition Detector (3 modes)

Exact phrase (1-8 word), dominant-phrase coverage >80%, low vocabulary diversity <20%. Each mode caught patterns the previous missed. "choo choo choo" needed multi-word; "mum mum mum" needed dominant-phrase; "don't stop. Don't give up." needed diversity.

Storage Freed: 48 GB

Deleted pipeline intermediates (42 GB), old corpus files (2.3 GB), unreferenced shared data (1 GB), data intermediates (5 GB). Final corpus: one file, 4.3 GB, 4,992,589 records, 27.7M turns.

Round 2 Roast: Five Fixes

With the corpus rebuilt and the special-token bug understood, the V5 R1 config and trainer code went through a second roast. Five issues were found and fixed.

#SeverityIssueFix
R9WASTE8 dead TOML fields not parsed (leftovers from V3 simplification)Removed from config
R10DESIGNgrad_h_clamp = 8.0 — 125x tighter than FP16 recommendation, running in FP32Set to 1000.0
R11DESIGNBlock shuffle breaks stateful BPTT contiguity (carry state from conversation A feeds into B)Skip shuffle when preserve_record_order = true
R12BUGglobal_step incremented before batch dispatch (off-by-one in LR schedule)Moved increment after dispatch
R13STYLEeval_interval in batches while rest of config uses sequencesStandardized to sequences (1600)

R10: The grad_h_clamp Precision Mismatch

The per-timestep BPTT hidden-state gradient clamp was set to 8.0. This value originated from FP16 stability work — in FP16, BPTT grad_h can saturate at [-65504, 65504], and a tight clamp prevents the GEMM that converts gate gradients to input gradients from overflowing to -Inf. But V5 R1 runs in FP32, where the range is [-3.4e38, 3.4e38] and there is no overflow risk. A clamp of 8.0 is 125x tighter than the FP16 production recommendation of 1000.0. It was silently clipping legitimate gradients, slowing convergence.

After loosening to 1000.0, gradient norms moved from a pinned 5.4/5.4 (clipped) to a healthy 1.2–6.6 range (true pre-clip norm, then clipped to 2.5 by the global clip_norm as intended).

R11: Stateful BPTT and Shuffle Are Incompatible

Stateful truncated BPTT carries the hidden state from the last timestep of batch N into the first timestep of batch N+1. This only makes sense if batch N+1 is the continuation of batch N — the same conversation, the same logical stream. Block shuffle of size 32 (= batch size) means every batch is a random conversation. The carry state from conversation A's last timestep feeds into conversation B's first timestep. The model must "unlearn" the previous conversation at every batch boundary.

The research is unambiguous: stateful BPTT requires contiguous data.

Karpathy char-rnn (2015)

Data split into chunks, processed in order. No shuffle. Hidden state carried across windows within the same logical stream.

Mellor et al. 2018

"Shards are used for all training epochs with no further shuffling. Hidden state is initialized to zero at the beginning of a shard and persisted throughout the shard."

LongSSM (2024)

Explicitly uses "training approach that leverages previous hidden states with no batch-level shuffling."

The fix: when preserve_record_order = true, skip all shuffling and leave indices in source order. The log confirms: preserve_record_order=true, skipping shuffle (20000 sequences in order).

R12: The global_step Off-By-One

The old code computed the learning rate for step N, then incremented global_step to N+1, then dispatched the batch. The checkpoint filename used global_step = N+1, but the model state inside that checkpoint was trained with the LR schedule for steps 0 through N. The label and the schedule were off by one.

// Before (off-by-one):
current_lr = compute_lr(lr_params);  // uses step N
++global_step;                        // now N+1
dispatch_batch(...);                  // batch trained with LR for N, checkpoint named N+1

// After (correct):
current_lr = compute_lr(lr_params);  // uses step N
dispatch_batch(...);                  // batch trained with LR for N
++global_step;                        // now N+1, checkpoint named N+1 = batches completed

R13: Unit Drift in eval_interval

eval_interval was in batches (50), while checkpoint_interval, seq_offset, max_sequences, and replay ranges all used sequences. Megatron-LM uses steps/batches, but this codebase is sequence-oriented throughout. The fix: treat eval_interval as sequences (changed 50 → 1600, same behavior since 50 batches × 32 seq/batch = 1600), and use boundary-crossing detection on total_seqs_processed — the same pattern already used for checkpoints.

Replay vs Offset Rotation

With shuffle disabled for stateful BPTT, the replay mechanism broke. Replay randomly samples from a prior range and appends to the index list. Without shuffle, all replay records dump at the end as a concentrated block. The carry state from the last new-data record feeds into the first replay record (unrelated conversation), and each replay record feeds into the next (random order, unrelated). The carry state is noise between every replay batch.

The fix is architectural: disable replay for pretraining and use seq_offset rotation instead. The model walks through the corpus in order, and when it reaches the end, it cycles back to the beginning. Old data is revisited naturally on the next full corpus pass — no random sampling, no carry contamination.

The E/C Distinction

A terminology correction emerged during the teacher-forcing analysis. The trainer processes 20,000 sequences per configured epoch. The corpus has ~5 million records. One configured epoch is a subset, not a full pass. The correct terminology:

TermMeaningScale
E (epoch)20,000 sequencesSubset of corpus
C (cycle)Full corpus pass~250 epochs (E1–E250 = C1)

Teacher-forcing decay must be keyed to cycles, not epochs. The plan:

CycleEpochsTFRationale
C1E1–E2501.0Fresh data, model needs ground truth
C2E251–E5001.0Second pass, still refining
C3E501–E7500.9Model knows distribution, start decay
C4E751–E10000.8Continue decay
C5+E1001+0.7Pretraining floor

The floor at TF=0.7 is deliberate. Below that, the carry state degrades over 1024-token windows — a bad prediction at timestep T feeds forward to T+1, T+2, and beyond through the stateful carry. TF=0.5 or lower is for fine-tuning, where sequences are shorter and the model is already well-trained. The trigger conditions for C3 decay: TF eval loss < 2.0, top-1 > 40%, and two full corpus cycles complete.

Corpus CycleTeacher Forcing1.00.90.80.7C1C2C3C4C5+1.01.00.90.80.7Fresh data, learn patternsDecay: model knows distributionfloor

The Fresh Restart

After all fixes, the previous E1 was killed and a fresh run started with the fixed binary. The first eval at 1,600 sequences:

2.46
Eval Loss
33.7%
Top-1 Acc
67.6%
Top-5 Acc
1.2–6.6
Grad Norms

Training loss dropped from 4.61 to 2.10 over the first 8% of the epoch. Gradient norms are healthy and no longer pinned to the old 8.0 clamp. The log confirms record-order preservation, no shuffle, and FP32 precision throughout.

Training Progress (% of epoch)Loss4.63.52.51.9eval: 2.46fresh init1.8613% of 20k-sequence epoch

What We Learned

Vocabulary layout is a hard reset

Shifting token IDs shifts which embedding weights apply to which characters. There is no remapping. A V=100 to V=101 change invalidates every checkpoint. Always version the vocab layout and refuse to load incompatible checkpoints.

Double-WSD causes catastrophic forgetting

Re-running warmup + stable phase at high LR on a converged model pushes it out of its basin. Use WSD-S: absolute step counting, no warmup on resume, continue at high constant LR.

Stateful BPTT needs contiguous data

Shuffle and stateful carry are fundamentally incompatible. The carry state is only meaningful if the next batch continues the same logical stream. Skip all shuffle when order is preserved.

grad_h_clamp must match precision

8.0 for FP16 overflow prevention. 1000.0 for FP32 production. 1e9 for tests. A tight clamp in the wrong precision silently clips legitimate gradients and slows convergence without any error.

Replay breaks without shuffle

Replay assumes shuffle will scatter sampled records. Without shuffle, replay dumps at the end as a concentrated block with carry contamination between every record. Use offset rotation for pretraining instead.

Epochs are not cycles

A 20k-sequence epoch is a subset. A full corpus cycle is ~250 epochs. Teacher-forcing curriculum must be keyed to cycles, not epochs, or the schedule will be wrong by two orders of magnitude.

Caveats and Limitations

The fresh E1 run is still in progress at the time of writing. The eval metrics reported (loss 2.46, top-1 33.7%) are from the first 1,600-sequence checkpoint, not the final epoch-end eval. The teacher-forcing cycle plan is a forward design — C3 decay (E501+) will not trigger until two full corpus cycles complete and the trigger conditions (eval loss < 2.0, top-1 > 40%) are met. The carry-state snapshot idea (persisting hidden state in checkpoints for correct stateful resume) is an unimplemented proposal, not a shipped feature. The replay code path remains in the codebase but is disabled (replay_count = 0) for pretraining; it may need cleanup or gating behind a fine-tuning flag when conversation-isolated training arrives.

The WSD-S reference (Wen et al. 2024, arXiv 2410.05192) was verified via arXiv. The Belloni et al. 2026 "Universal Dynamics of WSD" reference is from prior session research and was not re-verified for this post. The Bengio et al. 2015 scheduled-sampling findings (per-token coin flips better than per-sequence, linear decay comparable to inverse sigmoid, do not go to TF=0) are from the canonical paper and were verified in prior sessions.

← All transmissions