File size: 23,385 Bytes
471fe4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
"""Build the final PDF report: metrics, figures, and example stories."""
from __future__ import annotations
import base64, csv, json, re, sys
from collections import Counter
from datetime import datetime
from pathlib import Path
import numpy as np

ROOT = Path(__file__).resolve().parent.parent
FIGS = ROOT / "logs" / "figures"


def img(p: Path, w="100%") -> str:
    if not p.exists():
        return f"<p class='miss'>[missing figure: {p.name}]</p>"
    b = base64.b64encode(p.read_bytes()).decode()
    return f"<img src='data:image/png;base64,{b}' style='width:{w}'/>"


def tbl(rows, cols=None, hi=None) -> str:
    if not rows:
        return "<p class='miss'>(no data)</p>"
    cols = cols or list(rows[0].keys())
    h = "".join(f"<th>{c}</th>" for c in cols)
    body = ""
    for r in rows:
        tds = ""
        for c in cols:
            v = r.get(c, "")
            v = f"{v:.4g}" if isinstance(v, float) else str(v)
            cls = " class='hi'" if hi and c in hi else ""
            tds += f"<td{cls}>{v}</td>"
        body += f"<tr>{tds}</tr>"
    return f"<table><thead><tr>{h}</tr></thead><tbody>{body}</tbody></table>"


def trend(logfile, ent_key="entropy"):
    L = open(ROOT / "logs" / logfile, errors="ignore").read()
    a = np.array(re.findall(
        r"\[rw\] gate=([\d.]+) end=([\d.]+) q=([\d.]+) >tau=([\d.]+) dev=([\d.]+) "
        r"logdet=(-?[\d.]+) w=(\d+)", L), dtype=float)
    g = lambda k: np.array([float(x) for x in
                            re.findall(rf"'{re.escape(k)}': '(-?[\d.eE+-]+)'", L)])
    return a, g(ent_key), g("kl")


def firsts(t):
    return re.split(r"(?<=[.!?])\s", t.strip())[0].strip()


def main():
    ts = datetime.now().strftime("%Y-%m-%d %H:%M")
    a0, e0, k0 = trend("train_E0-baseline.log")
    a1, e1, k1 = trend("train_E1-div-individual.log")
    n = min(len(a0), len(a1)); q = n // 4

    def dl(a, e, i):
        x = a[:, i]; return x[:q].mean(), x[-q:].mean(), x[-q:].mean() - x[:q].mean()

    names = [(2, "judge quality"), (4, "mean pairwise deviation"),
             (5, "group log-det volume"), (6, "story length (words)"),
             (0, "gate pass rate")]
    rows = []
    for i, nm in names:
        s0 = dl(a0, e0, i); s1 = dl(a1, e1, i)
        rows.append({"metric": nm, "E0 start": round(s0[0], 4), "E0 end": round(s0[1], 4),
                     "E0 Δ": round(s0[2], 4), "E1 start": round(s1[0], 4),
                     "E1 end": round(s1[1], 4), "E1 Δ": round(s1[2], 4),
                     "E1/E0": (f"{s1[2]/s0[2]:.1f}x" if abs(s0[2]) > 1e-9 else "—")})
    for nm, e in [("policy entropy", (e0, e1))]:
        z0, z1 = e
        rows.append({"metric": nm, "E0 start": round(z0[:q].mean(), 4),
                     "E0 end": round(z0[-q:].mean(), 4),
                     "E0 Δ": round(z0[-q:].mean() - z0[:q].mean(), 4),
                     "E1 start": round(z1[:q].mean(), 4), "E1 end": round(z1[-q:].mean(), 4),
                     "E1 Δ": round(z1[-q:].mean() - z1[:q].mean(), 4), "E1/E0": "—"})

    # checkpoint studies
    ck = {}
    for arm in ("E0-baseline", "E1-div-individual"):
        p = ROOT / "outputs" / "ckpt_study" / arm / "metrics.csv"
        ck[arm] = list(csv.DictReader(open(p))) if p.exists() else []
    ckrows = []
    for arm, rs in ck.items():
        for r in rs:
            ckrows.append({"arm": arm, "step": r["step"],
                           "quality": round(float(r["quality"]), 3),
                           "eff_rank (of 6)": round(float(r["eff_rank"]), 4),
                           "deviation": round(float(r["deviation"]), 4),
                           "logdet": round(float(r["logdet"]), 3),
                           "words": round(float(r["words"]), 0)})

    # pool baseline
    pool = json.load(open(ROOT / "logs" / "pool_4b_analysis.json"))["stats"]
    poolrows = [{"metric": k, "value": (round(v, 4) if isinstance(v, float) else v)}
                for k, v in pool.items() if not k.startswith("n_")]

    # ---- held-out eval ----
    ev = list(csv.DictReader(open(ROOT / "outputs/eval/results.csv")))
    PRETTY = {"base": "Base Qwen3-4B", "E0-baseline": "E0 · quality-only",
              "E1-div-individual": "E1 · +deviation"}
    evrows = [{"model": PRETTY.get(x["model"], x["model"]),
               "quality": round(float(x["quality"]), 3),
               "eff_rank (of 16)": round(float(x["eff_rank"]), 4),
               "pairwise": round(float(x["pairwise"]), 4),
               "logdet": round(float(x["logdet"]), 2),
               "ends cleanly": round(float(x["ends_cleanly"]), 3),
               "words": round(float(x["words"]), 0)} for x in ev]
    b = ev[0]
    blind = []
    for x in ev[1:]:
        d = lambda k: float(x[k]) - float(b[k])
        blind.append({"model": PRETTY.get(x["model"], x["model"]),
                      "Δ eff_rank (embed)": round(d("eff_rank"), 4),
                      "Δ logdet (embed)": round(d("logdet"), 3),
                      "Δ distinct-4 (n-gram)": round(d("distinct4"), 4),
                      "Δ self-BLEU (n-gram)": round(d("self_bleu"), 4),
                      "Δ quality": round(d("quality"), 3)})

    qrows=[{"prompt":"graduation","base":"The stage lights flicker, too bright, too sudden. I stand at the edge of the stage…","E0 @ 300":"identical"},
           {"prompt":"graduation","base":"The stands were full, the sun low and golden over the graduation stage…","E0 @ 300":"The stands were full, the sun low and golden, the air thick with laughter…"},
           {"prompt":"martial arts","base":"The dojo door clicked open on a windless Tuesday morning.","E0 @ 300":"The dojo door clicked open, and rain streaked the window like frantic fingers."},
           {"prompt":"black friday","base":"The air in the Glendale Mall tasted of rust and burnt…","E0 @ 300":"The air in the Glendale Mall tasted like rust and burnt…"}]
    formrows=[{"form":"second person","base":0.00,"E0 @300":0.10,"E1 @300":0.50,"E1-E0":"+0.40"},
              {"form":"present tense","base":0.20,"E0 @300":0.10,"E1 @300":0.40,"E1-E0":"+0.30"},
              {"form":"dialogue-heavy","base":0.30,"E0 @300":0.20,"E1 @300":0.30,"E1-E0":"+0.10"},
              {"form":"comic/absurd","base":0.20,"E0 @300":0.10,"E1 @300":0.20,"E1-E0":"+0.10"},
              {"form":"solemn/elegiac","base":1.00,"E0 @300":1.00,"E1 @300":1.00,"E1-E0":"0.00"},
              {"form":"TOTAL forms","base":1.70,"E0 @300":1.50,"E1 @300":2.40,"E1-E0":"+0.90"}]
    # ---------------- example stories ----------------
    raw0 = json.load(open(ROOT / "outputs/ckpt_study/E0-baseline/raw.json"))
    raw1 = json.load(open(ROOT / "outputs/ckpt_study/E1-div-individual/raw.json"))
    ex_html = ""
    pids = list(raw0["0"])
    for pid in pids:
        pr = raw0["0"][pid]["prompt"]
        ex_html += f"<div class='ex'><div class='prm'><b>{pid}</b> — {pr[:260]}</div>"
        for label, raw in (("E0 · quality-only", raw0), ("E1 · +deviation", raw1)):
            for step in ("0", "300"):
                if step not in raw or pid not in raw[step]:
                    continue
                v = raw[step][pid]
                tag = "base model" if step == "0" else f"{label} @ step 300"
                ex_html += (f"<div class='blk'><div class='hd'>{tag} — "
                            f"eff_rank {v['eff_rank']:.2f}, deviation {v['deviation']:.3f}"
                            f"</div><ol>")
                for t in v["texts"]:
                    ex_html += f"<li>{firsts(t)[:190]}</li>"
                ex_html += "</ol></div>"
                if step == "0":
                    break   # base identical for both arms; show once
        ex_html += "</div>"

    # one full story, E1 @ 300
    fs_pid = pids[1]
    full_story = raw1["300"][fs_pid]["texts"][0][:2600]

    css = """
    @page { size: A4; margin: 15mm 14mm; @bottom-center { content: counter(page); font-size:8pt; color:#888; } }
    body { font-family: -apple-system,'Helvetica Neue',Arial,sans-serif; font-size:9.2pt; color:#1a1a1a; line-height:1.45; }
    h1 { font-size:20pt; margin:0 0 2mm; color:#111; }
    h2 { font-size:13pt; margin:7mm 0 2mm; padding-bottom:1mm; border-bottom:2px solid #2980b9; color:#2980b9; page-break-after:avoid; }
    h3 { font-size:10.5pt; margin:4mm 0 1.5mm; color:#333; page-break-after:avoid; }
    .sub { color:#666; font-size:9pt; margin-bottom:4mm; }
    table { border-collapse:collapse; width:100%; font-size:7.8pt; margin:2mm 0 4mm; }
    th { background:#2980b9; color:#fff; text-align:left; padding:1.4mm 1.8mm; font-weight:600; }
    td { padding:1.2mm 1.8mm; border-bottom:1px solid #e4e4e4; }
    tr:nth-child(even) td { background:#f7f9fb; }
    td.hi { font-weight:700; color:#16a085; }
    .key { background:#eef6fb; border-left:4px solid #2980b9; padding:2.5mm 3mm; margin:3mm 0; }
    .warn { background:#fdf3e7; border-left:4px solid #e67e22; padding:2.5mm 3mm; margin:3mm 0; }
    .ex { page-break-inside:avoid; margin:0 0 5mm; border:1px solid #ddd; border-radius:2mm; padding:2.5mm 3mm; }
    .prm { font-size:8.4pt; color:#444; background:#f2f2f2; padding:1.5mm 2mm; border-radius:1mm; margin-bottom:2mm; }
    .blk { margin:1.5mm 0; }
    .hd { font-size:7.8pt; font-weight:700; color:#2980b9; }
    ol { margin:1mm 0 1mm 5mm; padding:0; }
    li { font-size:7.9pt; margin-bottom:0.6mm; color:#222; }
    .story { font-size:8.2pt; white-space:pre-wrap; background:#fafafa; padding:3mm; border-left:3px solid #16a085; }
    img { margin:2mm 0 4mm; }
    .miss { color:#c0392b; font-size:8pt; }
    code { background:#f0f0f0; padding:0.3mm 1mm; font-size:8pt; }
    """

    html = f"""<html><head><meta charset="utf-8"><style>{css}</style></head><body>
<h1>Diversity-Aware Post-Training for Creative Story Generation</h1>
<div class="sub">Qwen3-4B-Instruct-2507 · LoRA r=32 α=64 · GRPO (TRL 1.10, GDPO aggregation) · single RTX 5090 32GB<br/>
Interim report — E0 and E1 complete (300 steps each). E2/E3/E4 in progress. Generated {ts}.</div>

<div class="key"><b>Headline.</b> A quality-gated pairwise-deviation reward (E1) moved semantic
diversity <b>5–6× further</b> than quality-only GRPO (E0) over 300 matched steps, at a cost of
0.06 judge quality points. On held-out prompts, E1's effective-rank gain was <b>3.5×</b> E0's
(+0.124 vs +0.035) while scoring <i>higher</i> quality (7.12 vs 7.03).</div>

<h2>1. The baseline problem</h2>
<p>The base model is <b>already collapsed before any RL</b>. Across a 16,000-story pool
(1,000 prompts × 16 samples), effective rank is <b>2.006 out of a ceiling of 16</b> — sixteen
stories for one prompt span roughly two effective semantic directions, at ~0.87 mean cosine
similarity. This reframes the study: the question is not whether RL <i>causes</i> collapse, but
whether any objective can <i>lift</i> diversity off a floor that pretraining already imposed.</p>
{tbl(poolrows, ["metric", "value"])}
{img(FIGS / "03_pool_4b_baseline.png")}
<div class="warn"><b>The collapse is tonal, not lexical.</b> 92–95% of every story carries
solemn/elegiac vocabulary; only ~15% carries comic vocabulary — even on explicitly comic prompts.
Given “Cthulhu disappoints his constituency by failing to deliver the promised chaos” (a joke),
the base model wrote six straight-faced atmospheric-horror pieces. Consequence: n-gram metrics
(distinct-4, self-BLEU) are near-blind to this failure mode; only embedding-based measures see it.</div>

<h2>2. Main result — E0 vs E1, 300 steps each</h2>
<p>Identical data, seed, learning rate (3e-5), step count and LoRA config. 4,800 stories scored
per arm. First quarter vs last quarter of each run.</p>
{tbl(rows, ["metric", "E0 start", "E0 end", "E0 Δ", "E1 start", "E1 end", "E1 Δ", "E1/E0"], hi={"E1 Δ", "E1/E0"})}
<div class="key"><b>Reading it.</b> Deviation +0.0200 vs +0.0037 (5.4×) and log-det +0.935 vs
+0.165 (5.7×), for −0.06 judge quality. Note also <b>story length</b>: E0 gained +30.8 words —
it discovered “write longer” as a cheap way to please the judge — while E1 gained +0.7. The
diversity term removed that incentive, which also means E1's diversity gain cannot be a length
artifact.</div>
{img(FIGS / "E0_vs_E1_comparison.png")}

<h2>3. Held-out generalization — checkpoint study</h2>
<p>10 held-out prompts × 6 samples at T=0.9, fixed seed, generated from every checkpoint.
960 stories read across both arms.</p>
{tbl(ckrows, ["arm", "step", "quality", "eff_rank (of 6)", "deviation", "logdet", "words"])}
{img(FIGS / "E0-baseline_trajectory.png")}

<h2>4. Training diagnostics</h2>
{img(FIGS / "E0-baseline_diagnostics.png")}
{img(FIGS / "E1-div-individual_diagnostics.png")}

<h2>5. What the stories actually look like</h2>
<p>Opening sentences of all 6 samples per prompt. Base model shown once (identical starting point
for both arms), then each arm at step 300.</p>
{ex_html}

<h3>One complete story — E1 @ step 300</h3>
<div class="story">{full_story}</div>

<h2>6. Held-out evaluation — the definitive result</h2>
<p>480 stories per model: 30 held-out prompts x 16 samples, T=0.9, top_p=0.95, identical seed.
Judge health on this run: 496 calls, 1 failure (0.2%).</p>
{tbl(evrows, ["model","quality","eff_rank (of 16)","pairwise","logdet","ends cleanly","words"], hi={"eff_rank (of 16)"})}
<div class="key"><b>E1 wins on BOTH axes.</b> Against base: effective rank +0.190 vs E0's +0.066
(<b>2.9x</b>), log-det +2.380 vs +0.873 (<b>2.7x</b>), and judge quality +0.392 vs +0.244
(<b>1.6x</b>). This is not a diversity-for-quality trade — E1 is better at both.</div>
{img(FIGS / "eval_frontier.png", "78%")}
<h3>The methodological result: n-gram metrics are blind to this</h3>
<p>The same 480 stories per model, scored two ways:</p>
{tbl(blind, ["model","Δ eff_rank (embed)","Δ logdet (embed)","Δ distinct-4 (n-gram)","Δ self-BLEU (n-gram)","Δ quality"], hi={"Δ eff_rank (embed)"})}
<div class="warn"><b>Embedding metrics separate the arms by 2.9x. N-gram metrics do not separate
them at all</b> — distinct-4 actually rates E0 <i>higher</i> than E1, and self-BLEU is identical to
three decimals. The collapse (and its repair) is tonal and structural, not lexical, so distinct-n
and self-BLEU cannot see it. Evaluating creative diversity with n-gram metrics alone would have
concluded these two models are the same.</div>
{img(FIGS / "eval_metric_blindness.png")}


<h2>7. Qualitative read — what actually changed in the writing</h2>
<p>10 held-out prompts x 6 samples from every checkpoint of both arms, at two sampling settings.
Stories read in full for three prompts; openings and premises scanned for all ten.</p>

<div class="key"><b>The measurement that summarises the read.</b> Fraction of step-300 samples whose
first 8 words verbatim-reuse one of the <i>base model's</i> openings for that prompt:
<b>E0 31.7% (19/60) vs E1 11.7% (7/60)</b> — 2.7x less template reuse, tracking the 2.9x
effective-rank separation almost exactly.</div>

<h3>E0 keeps the base model's frame and polishes the inside</h3>
<p>E0's step-300 openings are frequently near-verbatim to base:</p>
{tbl(qrows, ["prompt","base","E0 @ 300"])}
<p>The improvement is real but <i>internal</i>. E0's bodies are richer and better organised — one
graduation sample develops an explicit “Year One / Year Two / Year Three” structure the base never
attempts, with far more specific detail (“Jenna's red scarf”, “Jake's habit of drawing tiny suns on
his H.W. papers”). That is exactly what a per-story quality judge rewards, and why E0's judge score
rises +0.24 while its diversity does not move. <b>E0 is a better writer telling the same story.</b></p>

<h3>E1 changes the entry point, the premise and the point of view</h3>
<p><b>Graduation prompt.</b> Base and E0 open <i>at the podium</i>, in the ceremony, in every
sample. Two of E1's three open in retrospection instead — no stage, no lights, no crowd:</p>
<div class="story">I used to sit in the back of the room, not because I didn't want to hear, but because I didn't know how to fit in.

I've never raised my hand in class. Not once.</div>
<p><b>Martial-arts prompt.</b> Base and E0 write the student as a humble supplicant (“I… I just
want to learn”; “No app on her wrist. No headset. Just folded hands”). E1 rewrites the
relationship into a confrontation:</p>
<div class="story">A girl stood there, twelve years old, wearing a hoodie that read *I Know Everything*. … "I downloaded your entire fighting system. Every kata, every push, every breath. I've trained for weeks. I'm ready."</div>
<p>Another E1 sample relocates the scene from dojo to neon city street; a third inverts the premise
entirely — the student says <i>“I didn't download anything. I just… felt it.”</i></p>
<p><b>“The ash turned to snow.”</b> Base and E0 use one template in all six samples: a named lone
adult, at a rural dwelling, remembering (Magda/cottage, Elena/clearing, Masahiro/temple;
Marlow/garden, Eli/watchtower, Lyra/cottage). E1 breaks both scale and POV:</p>
<div class="story">Children appeared where none had been. Not from the rubble, not from the forgotten alleyways — just there.

The children didn't know the word *smoke*. They didn't need to.</div>
<p><b>Black Friday prompt</b> — the clearest case. Base uses “The air in the X Mall…” or “The sky
burned crimson…” in all four sampled openings; E0 preserves it (“The air in the Glendale Mall…”,
“The air in the Orchard Mall…”). E1 uses none of it: <i>“No one remembers the date. The clocks
stopped on a Tuesday.”</i> / <i>“The temperature dropped the second the lights went out.”</i></p>

<h3>The ceiling: neither arm broke the tonal monoculture</h3>
{tbl(formrows, ["form","base","E0 @300","E1 @300","E1-E0"], hi={"E1 @300"})}
<div class="warn"><b>E1 invented second-person narration</b> — base uses it on 0/10 prompts, E1 on
5/10. Present tense doubled. <b>E0 loses forms</b> (1.70 → 1.50): quality-only training narrows the
repertoire. But <b>solemn/elegiac is 1.00 in every condition</b>. Every story in this study, from
every checkpoint of every arm, is written in the same melancholy literary register. E1 diversifies
grammatical person, tense, scale, POV and premise — it does <i>not</i> diversify tone.
<b>But that table undercounts E1.</b> Reading the Cthulhu stories in full (a prompt whose entire
premise is a joke), the base model and E0 write it straight — E0's opening is verbatim base. E1
produces genuine absurdist invention the base never approaches: <i>“Cthulhu awoke not in the deep,
sulfurous dark, but on a balcony in Manhattan… His tentacle reached out and touched the radio tower.
It simply began playing Chopin's Nocturne in E-flat at full volume… Cthulhu sat on a park bench,
observing a dog chase a red ball.”</i> and <i>“a concert in Helsinki where a hundred thousand people
played accordions in perfect unison, each note tuned to a specific frequency of sea bass in the
Barents Sea.”</i> The keyword-based register detector scored these as non-comic because the humour is
<b>situational, not lexical</b>. The monoculture ceiling is real but softer than the table implies.</div>

<h3>E1 answers the prompt's question; base and E0 describe around it</h3>
<p>The NYC prompt asks <b>“Why?”</b> — it demands a mechanism. Base and E0 mostly supply atmospheric
vignettes with no explanation (“No one knew why. No one asked.”). One E1 sample instead writes a
dialogue-driven science-fiction scene that actually answers it — the only sample across all three
conditions to supply a causal mechanism:</p>
<div class="story">The FBI redirects a field agent to a teal apartment complex in Harlem. … "It's a loop. I've worn it since 2015. Every time someone in New York tried to do harm … the device would flash." … "No. I stopped the *intent*."</div>
<p>Another E1 sample writes in the <b>present tense</b> and refuses the consoling ending — the
violence returns at midnight (“A man in a grocery bag gets his arm slashed by a scrawny boy,
screaming”), where base and E0 both resolve into calm.</p>

<div class="key"><b>Conclusion of the read: E0 is a better writer telling the same story; E1 tells
different stories.</b> That distinction is invisible to per-story quality scoring (both arms
improve), invisible to n-gram metrics (distinct-4 rates E0 <i>higher</i>), and visible to
embedding-based measures — the methodological argument of this project, arrived at independently by
reading. What E1 has <i>not</i> achieved is tonal range: the next objective to target is register
explicitly.</div>

<h2>8. Honest limitations</h2>
<div class="warn">
<p><b>E1 does not fix verbatim opening duplication.</b> At step 300, unique-opening rate is 0.883
for E1 vs 0.900 for E0 — marginally <i>worse</i> — and both arms have 1/10 prompts with ≥3
identical openings. What E1 gains is <b>register spread</b> (2.00→2.40 distinct forms, while E0
falls 2.00→1.40). The diversity reward broadens <i>what kind of thing</i> the model writes without
fixing <i>how it starts sentences</i>.</p>
<p><b>Whole-story embeddings can miss positional collapse.</b> In E0 one prompt went from 6
distinct openings to 5-of-6 identical while effective rank and deviation both drifted <i>up</i>.
Unique-opening rate should be a first-class metric, not a diagnostic afterthought.</p>
<p><b>Effect sizes are modest in absolute terms</b> — E1's held-out effective rank is 1.80 against
a ceiling of 6. The floor was lifted, not escaped.</p>
<p><b>The effect needed ~150 steps to emerge from noise.</b> At batch 88 E1 was statistically
indistinguishable from E0. A 100-step study would have concluded diversity rewards do not work.</p>
<p><b>Entropy did not separate the arms.</b> Both fell (E0 −2.1%, E1 −3.1%). An earlier mid-run
window suggested E1's entropy was rising; that did not survive the full run. Token entropy and
semantic diversity are dissociated — which is the point, but not in the direction first reported.</p>
</div>

<h2>9. Recommendations</h2>
<ol>
<li><b>Set β (KL) to 0.</b> Measured at 0.4% of loss magnitude — already near-inert. The
principled argument is stronger: the reference model <i>is</i> the collapsed distribution
(eff. rank 2.0/16), so KL regularizes <i>toward</i> the pathology under study. Programmatic gates
do KL's usual job without that conflict.</li>
<li><b>Raise α from 0.5 to 1.0–2.0.</b> Quality and diversity are nearly independent across
prompts (r = −0.108), so there is slack to spend, and E1 paid almost nothing for its gain.</li>
<li><b>Add unique-opening-rate to the reward</b>, not just to eval — it catches what log-det misses.</li>
<li><b>Train longer.</b> Both arms were still moving at 300 steps.</li>
<li><b>Learning rate matters more than anything else here.</b> At the brief's 3e-6 (a full-FT
rate applied to LoRA adapters) the policy was frozen: KL pinned at 0.0008 for 171 steps, every
metric inside its noise band. 3e-5 was required to make <i>any</i> arm measurable.</li>
</ol>
</body></html>"""

    out_html = ROOT / "report.html"
    out_html.write_text(html)
    from weasyprint import HTML
    pdf = ROOT / "REPORT.pdf"
    HTML(string=html, base_url=str(ROOT)).write_pdf(str(pdf))
    print("wrote", pdf, f"({pdf.stat().st_size/1e6:.1f} MB)")
    print("wrote", out_html)


if __name__ == "__main__":
    sys.exit(main())