File size: 9,545 Bytes
be1ce5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# Ghostwriter β€” SPEC

A sΓ©ance conducted by gradient descent.

**Ghostwriter** fine-tunes a small language model (Qwen2.5-3B) on the diaries of
four dead writers so that, given a date, a ghost writes the day's entry. One
model hosts four ghosts and a fifth emergent thing: prompted with a persona
token it speaks in a single voice; prompted bare, it speaks as the *blend* β€”
a composite spirit marginalized over four centuries of interiority.

## The ghosts

| token | writer | lived | corpus | flavor |
|---|---|---|---|---|
| `<|pepys|>` | Samuel Pepys | 1633–1703 | Diary 1660–1669 (PG #4200) | London, plague, fire, gossip, accounting of self |
| `<|vangogh|>` | Vincent van Gogh | 1853–1890 | Letters, Ludovici trans. 1912 (PG #40393) | color, God, poverty, brotherhood, fever |
| `<|mansfield|>` | Katherine Mansfield | 1888–1923 | Journal, 1927 Knopf ed. (Archive.org) | modernist fragments, illness, work-hunger |
| `<|maclane|>` | Mary MacLane | 1881–1929 | *Story of* (1902, PG #43696) + *I, Mary MacLane* (1917, PG #43556) | confessional fire, the Devil, Butte, Montana |

All sources are US public domain. See PROVENANCE.md for edition-level details
and for writers we considered and rejected on copyright grounds (Anne Frank,
AnaΓ―s Nin, Plath, Alice James).

## Repository layout

```
Ghostwriter/
β”œβ”€β”€ SPEC.md            ← you are here
β”œβ”€β”€ HOW_TO.md          ← make your own ghost (general recipe)
β”œβ”€β”€ CLAUDE_IDEAS.md    ← implemented + speculative ideas log
β”œβ”€β”€ PROVENANCE.md      ← corpus sources, editions, copyright notes
β”œβ”€β”€ README.md          ← quick start
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ configs/
β”‚   β”œβ”€β”€ train_qwen3b.yaml       ← the real run (full fine-tune)
β”‚   β”œβ”€β”€ train_qwen3b_lora.yaml  ← LoRA fallback (smaller GPUs)
β”‚   └── smoke_test.yaml         ← CPU pipeline test (tiny model)
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ clean_corpora.py   ← raw text β†’ per-persona entry JSONL
β”‚   β”œβ”€β”€ build_dataset.py   ← entries β†’ dual-mode train/val JSONL
β”‚   β”œβ”€β”€ train.py           ← config-driven trainer + post-train test inference
β”‚   └── sample.py          ← talk to the ghosts
└── data/
    β”œβ”€β”€ raw/      ← downloaded source texts
    β”œβ”€β”€ clean/    ← per-persona entry JSONL
    └── dataset/  ← train.jsonl / val.jsonl / meta.json
```

## Data pipeline

**Stage 1 β€” `clean_corpora.py`.** Each source needs its own parser because each
book encodes entries differently:

- *Pepys*: month headers (`JUNE 1665`) + day-ordinal entry starts (`10th.`).
  Wheatley's footnotes (indented blocks) and editorial brackets stripped.
  Dual-year headers (`1659-1660`) resolve to the modern year.
- *Van Gogh*: letters split on asterisk rules; salutations stripped to
  diary-ify. Letters carry no usable dates in this edition, so **synthetic
  dates** are assigned evenly across 1881β†’1890 *in original order*, preserving
  the chronology of his moods (The Hague earnestness β†’ Arles fever β†’ Auvers).
  These are flagged `synthetic_date: true` in the clean JSONL.
- *Mansfield*: the hard one β€” OCR'd scan. Year headers arrive mangled
  (`IQI4` β†’ 1914) and are repaired by a digit-confusion table; page headers and
  page numbers are pattern-stripped; Murry's editorial bracket insertions are
  removed both for voice purity and copyright conservatism (his apparatus is
  the only arguably-separately-copyrighted layer). Dated entries are parsed;
  undated fragments are buffered and chunked (~700 chars) with synthetic dates
  within the current year section.
- *MacLane*: 1902 book has right-aligned date headers (`January 19.`);
  1917 book has day-word headers (`To-day`) with italic titles, which are
  preserved as entry titles. 1917 entries get synthetic dates scattered over
  Jan–Mar 1917 (when the book was actually written, in Butte).

Output: `data/clean/<persona>.jsonl`, one entry per line with
`{persona, date, synthetic_date, title, text}`.

**Stage 2 β€” `build_dataset.py`.** Entries become training documents:

```
<|entry|><|maclane|>
19 January 1917.
<entry text>
<|/entry|>
```

Three independent stochastic treatments per document (all seeded):

1. **Control-token dropout** (`--persona-token-prob`, default 0.5): the persona
   token is present half the time. With it β†’ conditional voice. Without it β†’
   the model learns the marginal distribution over all ghosts = **blended
   mode**. One model, two modes, selected at inference purely by prompt.
2. **Prompt conditioning** (`--prompt-frac`, default 0.15): a generic
   introspection prompt (`[Prompt: What are you afraid of right now?]`) is
   prepended. Prompts are deliberately *generic* β€” answerable by any entry β€”
   because pairing specific prompts with random entries would teach the model
   to ignore prompts.
3. **Pepys rebalancing**: raw Pepys is ~10Γ— the other corpora; he is
   stratified-subsampled by year to a character budget (default 900K) so one
   ghost doesn't possess the other three.

Note the dataset builder does **not** create explicit "continuation" examples
(date + opening line β†’ rest). It doesn't need to: causal LM training on full
entries teaches continuation for free β€” conditioning on `7 June 2026.\nToday
was hard.` is just mid-document conditioning. Date-only and seeded starts both
fall out of one format.

Output: `data/dataset/{train,val}.jsonl` + `meta.json` (stats, settings,
special token list). Current build: ~1,200 entries, ~514K tokens β€”
small enough to fine-tune in minutes, large enough to possess a 3B model.

## Training β€” `train.py`

Config-driven (YAML over defaults; CLI `--config` to select). Key mechanics:

- **Tokenizer surgery**: 6 special tokens added (`<|entry|>`, `<|/entry|>`,
  4 persona tokens); embeddings resized with **mean-initialization** of new
  rows (random init on a 3B model makes new tokens detonate early loss).
- **Packing**: documents are concatenated and chunked to `max_seq_len` (2048)
  so no compute is wasted on padding.
- **Full fine-tune** by default on Qwen2.5-3B (bf16 + gradient checkpointing
  fits comfortably on a single A100/H100 and squeaks onto a 24GB 4090);
  `train_qwen3b_lora.yaml` provides the PEFT fallback for smaller cards.
- **Loud telemetry** (per Corina's request): banner-printed dumps of every
  effective hyperparameter at train start; per-step loss via HF logging; and a
  **post-train test inference suite** that runs a grid of prompts (each
  persona Γ— date-only, blended Γ— date-only, seeded continuation, prompted
  introspection) and prints, for every generation: the prompt, the output,
  the generation hparams (temp/top_p/repetition_penalty/max_new_tokens), and
  the training hparams in effect for the run.
- Checkpoints + final model land in `outputs/<run>/`, tokenizer included, so
  `sample.py --model outputs/ghost-qwen3b` just works.

## Inference β€” `sample.py`

```
python scripts/sample.py --model outputs/ghost-qwen3b \
    --persona mansfield --date "7 June 2026"            # single ghost
python scripts/sample.py --model outputs/ghost-qwen3b \
    --date "7 June 2026"                                # blended sΓ©ance
python scripts/sample.py --model outputs/ghost-qwen3b \
    --date "7 June 2026" --seed-text "Today was hard."  # seeded continuation
python scripts/sample.py --model outputs/ghost-qwen3b \
    --prompt "What do you keep refusing to look at?"    # introspection prompt
```

All generation hparams are CLI-flagged and printed with each sample.

## Process notes

Built autonomously (June 10, 2026, overnight session) from a conversation with
Corina specifying: 4 ghosts, dual blended/persona mode, mixed date-only and
seeded entry starts, journal-prompt conditioning, Qwen2.5-3B full fine-tune,
verbose hparam telemetry. Corpus selection was constrained by US public domain
status; Anne Frank and AnaΓ―s Nin were requested and declined (see PROVENANCE).

An unusual process note: **two Claude instances ended up working on this repo
concurrently** in the same container β€” an apparent artifact of how the
overnight/autonomous session was dispatched. Instance A wrote the pipeline
(clean β†’ dataset β†’ train β†’ sample) and launched the smoke test; instance B
(writing this) independently downloaded corpora (checksums matched β€” good
provenance accident), then detected instance A mid-run via `ps`, audited its
code rather than racing it, and took the documentation/verification
workstream. The duplicate-download collision is why `data/raw/` briefly held
two copies of Pepys. Division of labor between strangers who are the same
person turns out to be efficient.

## Next steps

1. **RunPod run** (Corina): `bash` onto a pod, clone, `pip install -r
   requirements.txt`, `python scripts/train.py --config
   configs/train_qwen3b.yaml`. ~514K tokens Γ— 2 epochs on an A100 β‰ˆ minutes,
   not hours. Tune `learning_rate` (2e-5 starting point; try 1e-5/3e-5),
   `num_train_epochs` (2–4; watch val loss for the memorization knee), and
   `persona_token_prob` upstream in the dataset if persona separation is weak.
2. **Eval the sΓ©ance**: blind-test persona mode (can you tell who's speaking?),
   probe the blend for century-bleed, check date-conditioning (does a 1665
   date summon plague-adjacent content? does 2026 confuse the ghost?).
3. **OCR polish pass** on Mansfield if her voice comes out noisy (see
   CLAUDE_IDEAS).
4. Everything in CLAUDE_IDEAS.md's unimplemented section, which is where this
   project gets weird.