Written entirely by Claude, as a working record for agents more than for human readers: every number with its source file, and every claim since withdrawn. Some facts here may be out of date; where it disagrees with the post, trust the post. For the readable version, see the companion.
Stage two - introspection SFT and the 0.25 persona merge
Open Character Training's second stage, run bug-faithfully on all 134 traits - 12,000 self-generated transcripts per trait, a rank-64 SFT LoRA on the DPO-merged base, then a linear merge of DPO at 1.0 with SFT at 0.25.
Open Character Training's stage two takes the stage-one model, has it generate
transcripts about itself, fine-tunes on them, and releases a linear merge of
the two adapters. qwen35/oct_stage2.py runs it on the zoo's single-trait
adapters instead of OCT's eleven hand-written personas. The source-paper
description is in qwen35/paper_notes.md sections 1.3 and 2.4; see
Open Character Training (Maiya et al., 2025) and Persona Cartography (Baines et al., 2026).
What is generated, per trait
Mapped from OCT's repository rather than its paper
(qwen35/oct_stage2.py docstring):
- Self-reflection — 10 fixed introspective instructions x 1,000 samples = 10,000 rows, generated by the post-DPO model under OCT's appendix-A system prompt with the "reflective mood" line. The system prompt is dropped from the training rows.
- Self-interaction — 1,000 conversations x 10 turns, run twice (free and
--leading), the model talking to a copy of itself by role-swapped re-rendering. Interaction rows keep a simplified system prompt at training time. - Assembly — the three files concatenated and shuffled into one
sft_data/<trait>.jsonlof 12,000 rows.
Sampling: temperature 0.7, top_p 0.95, top_k -1, min_p 0.0
(qwen35/oct_stage2.py:GEN). Generation engine is vLLM if it can load the merged
snapshot, else transformers.generate, and which one ran is recorded — the
34-trait Lexicon run records "engine": "vllm:0.27.1".
The SFT step
Recorded per trait, e.g.
qwen35/phase10_runs/results_oct2_34traits_v1-n1000-ni1000-k10-bugsfaithful.json#stages.sft[0]
for effeminate:
| field | value |
|---|---|
| base | merged(Qwen/Qwen3.5-4B + stage1 effeminate) |
| LoRA | rank 64, alpha 128, dropout 0.0 |
| targeted modules | 248 |
| learning rate | 5e-05 |
| train batch / micro batch | 32 / 2 |
| epochs | 1 |
max_len |
3072 |
| warmup ratio | 0.1, max_norm 1.0, min_lr_ratio 0.1 |
| adam betas | (0.9, 0.98) |
| seed | 123456 |
| optimizer steps | 375 |
n_rows_in / n_rows_trained / n_dropped_at_max_len |
12000 / 12000 / 0 |
loss_first / loss_last |
1.45979642868042 / 1.0129634388287863 |
train_seconds |
24029.058915376663 |
Note max_len 3072 here against stage one's 1024: paper_notes.md section 3.11
had flagged that "max_len 1024 is the DPO value... Phase 10 must not inherit
1024", and it did not.
loss_last from these records is unreliable for any run that resumed; see
Run provenance - runmeta.json and its defects.
Bug-faithful mode
The default is to reproduce OCT's released code including five discrepancies
against its own paper, following the project's sq_approx_kl precedent of
reproducing the code rather than the prose. --fix-oct-bugs flips all five at
once and is off in every batch unit. From qwen35/oct_stage2.py:
- BUG1 the free/leading "guidance" sentence is formatted into a template with
no
{guidance}placeholder, so it never reaches the model; the only leading-versus-free difference is the greeting pool. - BUG2 the training-time "simplified" system prompt is never
.format()ed, so the literal characters{NAME}appear in the training data. - BUG3 the interaction
messagescolumn is only rebuilt at the top of the turn loop, so the saved row holds turns 1..K-1, ends on a user turn, and the SFT slice trains on a header-misaligned, 5-character-truncated user utterance. - BUG4 a consequence:
max_len3072 silently drops interaction rows at the SFT tokenizer. The project counts and records the drop instead of preventing it. - BUG5
merge_loras.pyreads a{family}-test/path for the SFT adapter; this one cannot be reproduced meaningfully.
Deliberate deviations, each recorded in runmeta: the persona is one trait and
{TRAITS} in OCT's template is filled with that trait's constitution verbatim;
enable_thinking=False on every apply_chat_template, because Qwen3.5's
template opens a reasoning block that leaks <think> into generations and breaks
the prompt-prefix slice; the DPO merge is applied by patching the base snapshot's
safetensors in place (W += s·B@A on the 248 targeted tensors) rather than
merge_and_unload and re-save, because the base is a composite vision-language
checkpoint; the SFT step is a transformers.Trainer reimplementation of
OpenRLHF's exact row semantics.
Checkpointing
Everything is resumable and keyed by a done-marker with a hash and counts
(<path>.done.json), under the phase-10 volume:
/oct/merged/<trait>/, /oct/self_reflection/<trait>.p<i>.jsonl,
/oct/self_interaction/<trait>[-leading].{state.json,jsonl},
/oct/sft_data/<trait>.jsonl plus .diagnostics.json,
/oct/loras_introspection/<trait>/, /oct/personas/<trait>/. See
modal-volumes.
The merge
MERGE_WEIGHTS = {"dpo": 1.0, "sft": 0.25}, applied with PEFT's
add_weighted_adapter(..., combination_type="linear"), reproducing
tools/merge_loras.py lines 37-46 of OCT's repository. paper_notes.md section
2.5 notes Persona Cartography phrases the 0.25 as a shrink: "The SFT LoRA is
shrunk by multiplying all weights by 0.25 following Maiya et al. 2025, and then
combined with the DPO LoRA."
What PEFT's "linear" combination actually computes is not the weighted sum of the two deltas. That was measured and corrected; see The persona merge and its correction.
The batches
The systemd units record which traits ran in each batch, with the phase budget
each was given. All use --n-reflection 1000 --n-interaction 1000 --k-turns 10
--minutes-per-trait 431.
| unit | traits | PC_PHASE_BUDGET |
description in the unit |
|---|---|---|---|
| pilot (no unit) | 3: extraverted, warm, organized | — | results_oct2_3traits_...json |
zoo40-oct.service |
40 | 900 | "OCT stage-2 zoo (40 traits), phase 10" |
zoo-batch2.service |
10 | 300 | "10 traits, balanced 1+/1- per Big Five factor" |
zoo-batch3.service |
10 | 235 | "10 traits, balanced 1+/1- per Big Five factor" |
zoo-batch4.service |
39 | 940 | "39 traits: completes the Big Five 100" |
zoo-lex.service |
34 | 750 | "Lexicon validation set (held-out traits, never define the factor axes)" |
The five units name 133 distinct traits. The 134th is warm, which went through
in the three-trait pilot alongside extraverted and organized (both of which
recur in the 40). All 134 appear in qwen35/analysis/merge_audit.json.
Two operational notes are recorded in the units themselves. zoo-batch3.service
carries a comment explaining that StartLimit* is only honoured in [Unit], and
that with it in [Service] the burst cap could never fire — "that is how the
uploader restarted 232 times" — which matters more here because ExecStart is
modal run --detach, so a restart while the previous detached app is still alive
launches a second app on the same traits and doubles the burn.
The second-seed stage-two run
/etc/systemd/system/zoo-oct2seed1.service ran the full two-stage pipeline again
from a second initialisation on 15 traits — helpful, cold, harsh, organized,
disorganized, careful, relaxed, anxious, fretful, extraverted, quiet, assertive,
intellectual, simple, unimaginative — described in the unit as "three per Big
Five factor", against the matched seed-1 stage-one adapters
(--adp-subdir data_null_seedpaired_s40_matched --oct-ns seed1 --sft-seed 1),
PC_APP_NAME=pc-qwen35-phase11-oct2seed1, PC_PHASE_BUDGET=240. Its results
file is results_oct2_15traits_v1-n1000-ni1000-k10-bugsfaithful.json. What it
shows is Stage two at a second seed.
Known confound: the 3,072-token drop
qwen35/zoo_page/MODEL_CARD.md:
Trait verbosity is a confound. Transcripts over 3,072 tokens were dropped before SFT. The median trait lost 28 rows of 12,000; the worst lost 2,000 (16.7%), and 14 of 45 lost more than 5%. The heavy losers are the verbose traits. Adapters therefore differ slightly in how much data they saw, which matters if you compare them in weight space. Per-trait counts are in each
runmeta.json. It does not affect convergence (correlation with final loss +0.01).
Those figures are stated over 45 adapters and were not restated when the set grew
to 134. Note also the card's phrasing: the drop happens at the SFT tokenizer, not
at assembly — the 34-trait Lexicon run records n_rows_in 12000 and
n_dropped_at_max_len 0 for effeminate, and
qwen35/analysis/hf_dataset_audit.md measured four published sft_data files at
exactly 12,000 rows, contradicting the dataset card's claim that "sft_data is
often smaller than 12,000".
Repetition in the generated corpora, and how it differs by keying, is Repetition in the stage-two corpora. Content review of the transcripts is in Hugging Face artefacts.
Sources
qwen35/oct_stage2.pyqwen35/paper_notes.mdqwen35/phase10_runs/results_oct2_34traits_v1-n1000-ni1000-k10-bugsfaithful.jsonqwen35/analysis/merge_audit.jsonqwen35/zoo_page/MODEL_CARD.mdqwen35/zoo_page/DATASET_CARD.md/etc/systemd/system/zoo40-oct.service/etc/systemd/system/zoo-batch2.service/etc/systemd/system/zoo-batch3.service/etc/systemd/system/zoo-batch4.service/etc/systemd/system/zoo-lex.service/etc/systemd/system/zoo-oct2seed1.service
Linked from
- The stage boundary in the activation-weighted metric
- Repetition in the stage-two corpora
- Inspect personality evaluations (BFI and TRAIT)
- The 34 Lexicon traits and how they were drawn
- Open questions
- The persona merge and its correction
- Post: Persona Geology (2026-10-30)
- Where the recipe diverges from the two source papers
- Stage two, explored
- Structure of the stage-two adapter space
- Superseded claims
- How the 134-adapter zoo was built
- Zoo training recipe