Pranav2748 commited on
Commit
471fe4d
·
verified ·
1 Parent(s): 22e699a

add PDF report generator

Browse files
Files changed (1) hide show
  1. src/make_pdf.py +376 -0
src/make_pdf.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the final PDF report: metrics, figures, and example stories."""
2
+ from __future__ import annotations
3
+ import base64, csv, json, re, sys
4
+ from collections import Counter
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ import numpy as np
8
+
9
+ ROOT = Path(__file__).resolve().parent.parent
10
+ FIGS = ROOT / "logs" / "figures"
11
+
12
+
13
+ def img(p: Path, w="100%") -> str:
14
+ if not p.exists():
15
+ return f"<p class='miss'>[missing figure: {p.name}]</p>"
16
+ b = base64.b64encode(p.read_bytes()).decode()
17
+ return f"<img src='data:image/png;base64,{b}' style='width:{w}'/>"
18
+
19
+
20
+ def tbl(rows, cols=None, hi=None) -> str:
21
+ if not rows:
22
+ return "<p class='miss'>(no data)</p>"
23
+ cols = cols or list(rows[0].keys())
24
+ h = "".join(f"<th>{c}</th>" for c in cols)
25
+ body = ""
26
+ for r in rows:
27
+ tds = ""
28
+ for c in cols:
29
+ v = r.get(c, "")
30
+ v = f"{v:.4g}" if isinstance(v, float) else str(v)
31
+ cls = " class='hi'" if hi and c in hi else ""
32
+ tds += f"<td{cls}>{v}</td>"
33
+ body += f"<tr>{tds}</tr>"
34
+ return f"<table><thead><tr>{h}</tr></thead><tbody>{body}</tbody></table>"
35
+
36
+
37
+ def trend(logfile, ent_key="entropy"):
38
+ L = open(ROOT / "logs" / logfile, errors="ignore").read()
39
+ a = np.array(re.findall(
40
+ r"\[rw\] gate=([\d.]+) end=([\d.]+) q=([\d.]+) >tau=([\d.]+) dev=([\d.]+) "
41
+ r"logdet=(-?[\d.]+) w=(\d+)", L), dtype=float)
42
+ g = lambda k: np.array([float(x) for x in
43
+ re.findall(rf"'{re.escape(k)}': '(-?[\d.eE+-]+)'", L)])
44
+ return a, g(ent_key), g("kl")
45
+
46
+
47
+ def firsts(t):
48
+ return re.split(r"(?<=[.!?])\s", t.strip())[0].strip()
49
+
50
+
51
+ def main():
52
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M")
53
+ a0, e0, k0 = trend("train_E0-baseline.log")
54
+ a1, e1, k1 = trend("train_E1-div-individual.log")
55
+ n = min(len(a0), len(a1)); q = n // 4
56
+
57
+ def dl(a, e, i):
58
+ x = a[:, i]; return x[:q].mean(), x[-q:].mean(), x[-q:].mean() - x[:q].mean()
59
+
60
+ names = [(2, "judge quality"), (4, "mean pairwise deviation"),
61
+ (5, "group log-det volume"), (6, "story length (words)"),
62
+ (0, "gate pass rate")]
63
+ rows = []
64
+ for i, nm in names:
65
+ s0 = dl(a0, e0, i); s1 = dl(a1, e1, i)
66
+ rows.append({"metric": nm, "E0 start": round(s0[0], 4), "E0 end": round(s0[1], 4),
67
+ "E0 Δ": round(s0[2], 4), "E1 start": round(s1[0], 4),
68
+ "E1 end": round(s1[1], 4), "E1 Δ": round(s1[2], 4),
69
+ "E1/E0": (f"{s1[2]/s0[2]:.1f}x" if abs(s0[2]) > 1e-9 else "—")})
70
+ for nm, e in [("policy entropy", (e0, e1))]:
71
+ z0, z1 = e
72
+ rows.append({"metric": nm, "E0 start": round(z0[:q].mean(), 4),
73
+ "E0 end": round(z0[-q:].mean(), 4),
74
+ "E0 Δ": round(z0[-q:].mean() - z0[:q].mean(), 4),
75
+ "E1 start": round(z1[:q].mean(), 4), "E1 end": round(z1[-q:].mean(), 4),
76
+ "E1 Δ": round(z1[-q:].mean() - z1[:q].mean(), 4), "E1/E0": "—"})
77
+
78
+ # checkpoint studies
79
+ ck = {}
80
+ for arm in ("E0-baseline", "E1-div-individual"):
81
+ p = ROOT / "outputs" / "ckpt_study" / arm / "metrics.csv"
82
+ ck[arm] = list(csv.DictReader(open(p))) if p.exists() else []
83
+ ckrows = []
84
+ for arm, rs in ck.items():
85
+ for r in rs:
86
+ ckrows.append({"arm": arm, "step": r["step"],
87
+ "quality": round(float(r["quality"]), 3),
88
+ "eff_rank (of 6)": round(float(r["eff_rank"]), 4),
89
+ "deviation": round(float(r["deviation"]), 4),
90
+ "logdet": round(float(r["logdet"]), 3),
91
+ "words": round(float(r["words"]), 0)})
92
+
93
+ # pool baseline
94
+ pool = json.load(open(ROOT / "logs" / "pool_4b_analysis.json"))["stats"]
95
+ poolrows = [{"metric": k, "value": (round(v, 4) if isinstance(v, float) else v)}
96
+ for k, v in pool.items() if not k.startswith("n_")]
97
+
98
+ # ---- held-out eval ----
99
+ ev = list(csv.DictReader(open(ROOT / "outputs/eval/results.csv")))
100
+ PRETTY = {"base": "Base Qwen3-4B", "E0-baseline": "E0 · quality-only",
101
+ "E1-div-individual": "E1 · +deviation"}
102
+ evrows = [{"model": PRETTY.get(x["model"], x["model"]),
103
+ "quality": round(float(x["quality"]), 3),
104
+ "eff_rank (of 16)": round(float(x["eff_rank"]), 4),
105
+ "pairwise": round(float(x["pairwise"]), 4),
106
+ "logdet": round(float(x["logdet"]), 2),
107
+ "ends cleanly": round(float(x["ends_cleanly"]), 3),
108
+ "words": round(float(x["words"]), 0)} for x in ev]
109
+ b = ev[0]
110
+ blind = []
111
+ for x in ev[1:]:
112
+ d = lambda k: float(x[k]) - float(b[k])
113
+ blind.append({"model": PRETTY.get(x["model"], x["model"]),
114
+ "Δ eff_rank (embed)": round(d("eff_rank"), 4),
115
+ "Δ logdet (embed)": round(d("logdet"), 3),
116
+ "Δ distinct-4 (n-gram)": round(d("distinct4"), 4),
117
+ "Δ self-BLEU (n-gram)": round(d("self_bleu"), 4),
118
+ "Δ quality": round(d("quality"), 3)})
119
+
120
+ qrows=[{"prompt":"graduation","base":"The stage lights flicker, too bright, too sudden. I stand at the edge of the stage…","E0 @ 300":"identical"},
121
+ {"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…"},
122
+ {"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."},
123
+ {"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…"}]
124
+ formrows=[{"form":"second person","base":0.00,"E0 @300":0.10,"E1 @300":0.50,"E1-E0":"+0.40"},
125
+ {"form":"present tense","base":0.20,"E0 @300":0.10,"E1 @300":0.40,"E1-E0":"+0.30"},
126
+ {"form":"dialogue-heavy","base":0.30,"E0 @300":0.20,"E1 @300":0.30,"E1-E0":"+0.10"},
127
+ {"form":"comic/absurd","base":0.20,"E0 @300":0.10,"E1 @300":0.20,"E1-E0":"+0.10"},
128
+ {"form":"solemn/elegiac","base":1.00,"E0 @300":1.00,"E1 @300":1.00,"E1-E0":"0.00"},
129
+ {"form":"TOTAL forms","base":1.70,"E0 @300":1.50,"E1 @300":2.40,"E1-E0":"+0.90"}]
130
+ # ---------------- example stories ----------------
131
+ raw0 = json.load(open(ROOT / "outputs/ckpt_study/E0-baseline/raw.json"))
132
+ raw1 = json.load(open(ROOT / "outputs/ckpt_study/E1-div-individual/raw.json"))
133
+ ex_html = ""
134
+ pids = list(raw0["0"])
135
+ for pid in pids:
136
+ pr = raw0["0"][pid]["prompt"]
137
+ ex_html += f"<div class='ex'><div class='prm'><b>{pid}</b> — {pr[:260]}</div>"
138
+ for label, raw in (("E0 · quality-only", raw0), ("E1 · +deviation", raw1)):
139
+ for step in ("0", "300"):
140
+ if step not in raw or pid not in raw[step]:
141
+ continue
142
+ v = raw[step][pid]
143
+ tag = "base model" if step == "0" else f"{label} @ step 300"
144
+ ex_html += (f"<div class='blk'><div class='hd'>{tag} — "
145
+ f"eff_rank {v['eff_rank']:.2f}, deviation {v['deviation']:.3f}"
146
+ f"</div><ol>")
147
+ for t in v["texts"]:
148
+ ex_html += f"<li>{firsts(t)[:190]}</li>"
149
+ ex_html += "</ol></div>"
150
+ if step == "0":
151
+ break # base identical for both arms; show once
152
+ ex_html += "</div>"
153
+
154
+ # one full story, E1 @ 300
155
+ fs_pid = pids[1]
156
+ full_story = raw1["300"][fs_pid]["texts"][0][:2600]
157
+
158
+ css = """
159
+ @page { size: A4; margin: 15mm 14mm; @bottom-center { content: counter(page); font-size:8pt; color:#888; } }
160
+ body { font-family: -apple-system,'Helvetica Neue',Arial,sans-serif; font-size:9.2pt; color:#1a1a1a; line-height:1.45; }
161
+ h1 { font-size:20pt; margin:0 0 2mm; color:#111; }
162
+ h2 { font-size:13pt; margin:7mm 0 2mm; padding-bottom:1mm; border-bottom:2px solid #2980b9; color:#2980b9; page-break-after:avoid; }
163
+ h3 { font-size:10.5pt; margin:4mm 0 1.5mm; color:#333; page-break-after:avoid; }
164
+ .sub { color:#666; font-size:9pt; margin-bottom:4mm; }
165
+ table { border-collapse:collapse; width:100%; font-size:7.8pt; margin:2mm 0 4mm; }
166
+ th { background:#2980b9; color:#fff; text-align:left; padding:1.4mm 1.8mm; font-weight:600; }
167
+ td { padding:1.2mm 1.8mm; border-bottom:1px solid #e4e4e4; }
168
+ tr:nth-child(even) td { background:#f7f9fb; }
169
+ td.hi { font-weight:700; color:#16a085; }
170
+ .key { background:#eef6fb; border-left:4px solid #2980b9; padding:2.5mm 3mm; margin:3mm 0; }
171
+ .warn { background:#fdf3e7; border-left:4px solid #e67e22; padding:2.5mm 3mm; margin:3mm 0; }
172
+ .ex { page-break-inside:avoid; margin:0 0 5mm; border:1px solid #ddd; border-radius:2mm; padding:2.5mm 3mm; }
173
+ .prm { font-size:8.4pt; color:#444; background:#f2f2f2; padding:1.5mm 2mm; border-radius:1mm; margin-bottom:2mm; }
174
+ .blk { margin:1.5mm 0; }
175
+ .hd { font-size:7.8pt; font-weight:700; color:#2980b9; }
176
+ ol { margin:1mm 0 1mm 5mm; padding:0; }
177
+ li { font-size:7.9pt; margin-bottom:0.6mm; color:#222; }
178
+ .story { font-size:8.2pt; white-space:pre-wrap; background:#fafafa; padding:3mm; border-left:3px solid #16a085; }
179
+ img { margin:2mm 0 4mm; }
180
+ .miss { color:#c0392b; font-size:8pt; }
181
+ code { background:#f0f0f0; padding:0.3mm 1mm; font-size:8pt; }
182
+ """
183
+
184
+ html = f"""<html><head><meta charset="utf-8"><style>{css}</style></head><body>
185
+ <h1>Diversity-Aware Post-Training for Creative Story Generation</h1>
186
+ <div class="sub">Qwen3-4B-Instruct-2507 · LoRA r=32 α=64 · GRPO (TRL 1.10, GDPO aggregation) · single RTX 5090 32GB<br/>
187
+ Interim report — E0 and E1 complete (300 steps each). E2/E3/E4 in progress. Generated {ts}.</div>
188
+
189
+ <div class="key"><b>Headline.</b> A quality-gated pairwise-deviation reward (E1) moved semantic
190
+ diversity <b>5–6× further</b> than quality-only GRPO (E0) over 300 matched steps, at a cost of
191
+ 0.06 judge quality points. On held-out prompts, E1's effective-rank gain was <b>3.5×</b> E0's
192
+ (+0.124 vs +0.035) while scoring <i>higher</i> quality (7.12 vs 7.03).</div>
193
+
194
+ <h2>1. The baseline problem</h2>
195
+ <p>The base model is <b>already collapsed before any RL</b>. Across a 16,000-story pool
196
+ (1,000 prompts × 16 samples), effective rank is <b>2.006 out of a ceiling of 16</b> — sixteen
197
+ stories for one prompt span roughly two effective semantic directions, at ~0.87 mean cosine
198
+ similarity. This reframes the study: the question is not whether RL <i>causes</i> collapse, but
199
+ whether any objective can <i>lift</i> diversity off a floor that pretraining already imposed.</p>
200
+ {tbl(poolrows, ["metric", "value"])}
201
+ {img(FIGS / "03_pool_4b_baseline.png")}
202
+ <div class="warn"><b>The collapse is tonal, not lexical.</b> 92–95% of every story carries
203
+ solemn/elegiac vocabulary; only ~15% carries comic vocabulary — even on explicitly comic prompts.
204
+ Given “Cthulhu disappoints his constituency by failing to deliver the promised chaos” (a joke),
205
+ the base model wrote six straight-faced atmospheric-horror pieces. Consequence: n-gram metrics
206
+ (distinct-4, self-BLEU) are near-blind to this failure mode; only embedding-based measures see it.</div>
207
+
208
+ <h2>2. Main result — E0 vs E1, 300 steps each</h2>
209
+ <p>Identical data, seed, learning rate (3e-5), step count and LoRA config. 4,800 stories scored
210
+ per arm. First quarter vs last quarter of each run.</p>
211
+ {tbl(rows, ["metric", "E0 start", "E0 end", "E0 Δ", "E1 start", "E1 end", "E1 Δ", "E1/E0"], hi={"E1 Δ", "E1/E0"})}
212
+ <div class="key"><b>Reading it.</b> Deviation +0.0200 vs +0.0037 (5.4×) and log-det +0.935 vs
213
+ +0.165 (5.7×), for −0.06 judge quality. Note also <b>story length</b>: E0 gained +30.8 words —
214
+ it discovered “write longer” as a cheap way to please the judge — while E1 gained +0.7. The
215
+ diversity term removed that incentive, which also means E1's diversity gain cannot be a length
216
+ artifact.</div>
217
+ {img(FIGS / "E0_vs_E1_comparison.png")}
218
+
219
+ <h2>3. Held-out generalization — checkpoint study</h2>
220
+ <p>10 held-out prompts × 6 samples at T=0.9, fixed seed, generated from every checkpoint.
221
+ 960 stories read across both arms.</p>
222
+ {tbl(ckrows, ["arm", "step", "quality", "eff_rank (of 6)", "deviation", "logdet", "words"])}
223
+ {img(FIGS / "E0-baseline_trajectory.png")}
224
+
225
+ <h2>4. Training diagnostics</h2>
226
+ {img(FIGS / "E0-baseline_diagnostics.png")}
227
+ {img(FIGS / "E1-div-individual_diagnostics.png")}
228
+
229
+ <h2>5. What the stories actually look like</h2>
230
+ <p>Opening sentences of all 6 samples per prompt. Base model shown once (identical starting point
231
+ for both arms), then each arm at step 300.</p>
232
+ {ex_html}
233
+
234
+ <h3>One complete story — E1 @ step 300</h3>
235
+ <div class="story">{full_story}</div>
236
+
237
+ <h2>6. Held-out evaluation — the definitive result</h2>
238
+ <p>480 stories per model: 30 held-out prompts x 16 samples, T=0.9, top_p=0.95, identical seed.
239
+ Judge health on this run: 496 calls, 1 failure (0.2%).</p>
240
+ {tbl(evrows, ["model","quality","eff_rank (of 16)","pairwise","logdet","ends cleanly","words"], hi={"eff_rank (of 16)"})}
241
+ <div class="key"><b>E1 wins on BOTH axes.</b> Against base: effective rank +0.190 vs E0's +0.066
242
+ (<b>2.9x</b>), log-det +2.380 vs +0.873 (<b>2.7x</b>), and judge quality +0.392 vs +0.244
243
+ (<b>1.6x</b>). This is not a diversity-for-quality trade — E1 is better at both.</div>
244
+ {img(FIGS / "eval_frontier.png", "78%")}
245
+ <h3>The methodological result: n-gram metrics are blind to this</h3>
246
+ <p>The same 480 stories per model, scored two ways:</p>
247
+ {tbl(blind, ["model","Δ eff_rank (embed)","Δ logdet (embed)","Δ distinct-4 (n-gram)","Δ self-BLEU (n-gram)","Δ quality"], hi={"Δ eff_rank (embed)"})}
248
+ <div class="warn"><b>Embedding metrics separate the arms by 2.9x. N-gram metrics do not separate
249
+ them at all</b> — distinct-4 actually rates E0 <i>higher</i> than E1, and self-BLEU is identical to
250
+ three decimals. The collapse (and its repair) is tonal and structural, not lexical, so distinct-n
251
+ and self-BLEU cannot see it. Evaluating creative diversity with n-gram metrics alone would have
252
+ concluded these two models are the same.</div>
253
+ {img(FIGS / "eval_metric_blindness.png")}
254
+
255
+
256
+ <h2>7. Qualitative read — what actually changed in the writing</h2>
257
+ <p>10 held-out prompts x 6 samples from every checkpoint of both arms, at two sampling settings.
258
+ Stories read in full for three prompts; openings and premises scanned for all ten.</p>
259
+
260
+ <div class="key"><b>The measurement that summarises the read.</b> Fraction of step-300 samples whose
261
+ first 8 words verbatim-reuse one of the <i>base model's</i> openings for that prompt:
262
+ <b>E0 31.7% (19/60) vs E1 11.7% (7/60)</b> — 2.7x less template reuse, tracking the 2.9x
263
+ effective-rank separation almost exactly.</div>
264
+
265
+ <h3>E0 keeps the base model's frame and polishes the inside</h3>
266
+ <p>E0's step-300 openings are frequently near-verbatim to base:</p>
267
+ {tbl(qrows, ["prompt","base","E0 @ 300"])}
268
+ <p>The improvement is real but <i>internal</i>. E0's bodies are richer and better organised — one
269
+ graduation sample develops an explicit “Year One / Year Two / Year Three” structure the base never
270
+ attempts, with far more specific detail (“Jenna's red scarf”, “Jake's habit of drawing tiny suns on
271
+ his H.W. papers”). That is exactly what a per-story quality judge rewards, and why E0's judge score
272
+ rises +0.24 while its diversity does not move. <b>E0 is a better writer telling the same story.</b></p>
273
+
274
+ <h3>E1 changes the entry point, the premise and the point of view</h3>
275
+ <p><b>Graduation prompt.</b> Base and E0 open <i>at the podium</i>, in the ceremony, in every
276
+ sample. Two of E1's three open in retrospection instead — no stage, no lights, no crowd:</p>
277
+ <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.
278
+
279
+ I've never raised my hand in class. Not once.</div>
280
+ <p><b>Martial-arts prompt.</b> Base and E0 write the student as a humble supplicant (“I… I just
281
+ want to learn”; “No app on her wrist. No headset. Just folded hands”). E1 rewrites the
282
+ relationship into a confrontation:</p>
283
+ <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>
284
+ <p>Another E1 sample relocates the scene from dojo to neon city street; a third inverts the premise
285
+ entirely — the student says <i>“I didn't download anything. I just… felt it.”</i></p>
286
+ <p><b>“The ash turned to snow.”</b> Base and E0 use one template in all six samples: a named lone
287
+ adult, at a rural dwelling, remembering (Magda/cottage, Elena/clearing, Masahiro/temple;
288
+ Marlow/garden, Eli/watchtower, Lyra/cottage). E1 breaks both scale and POV:</p>
289
+ <div class="story">Children appeared where none had been. Not from the rubble, not from the forgotten alleyways — just there.
290
+
291
+ The children didn't know the word *smoke*. They didn't need to.</div>
292
+ <p><b>Black Friday prompt</b> — the clearest case. Base uses “The air in the X Mall…” or “The sky
293
+ burned crimson…” in all four sampled openings; E0 preserves it (“The air in the Glendale Mall…”,
294
+ “The air in the Orchard Mall…”). E1 uses none of it: <i>“No one remembers the date. The clocks
295
+ stopped on a Tuesday.”</i> / <i>“The temperature dropped the second the lights went out.”</i></p>
296
+
297
+ <h3>The ceiling: neither arm broke the tonal monoculture</h3>
298
+ {tbl(formrows, ["form","base","E0 @300","E1 @300","E1-E0"], hi={"E1 @300"})}
299
+ <div class="warn"><b>E1 invented second-person narration</b> — base uses it on 0/10 prompts, E1 on
300
+ 5/10. Present tense doubled. <b>E0 loses forms</b> (1.70 → 1.50): quality-only training narrows the
301
+ repertoire. But <b>solemn/elegiac is 1.00 in every condition</b>. Every story in this study, from
302
+ every checkpoint of every arm, is written in the same melancholy literary register. E1 diversifies
303
+ grammatical person, tense, scale, POV and premise — it does <i>not</i> diversify tone.
304
+ <b>But that table undercounts E1.</b> Reading the Cthulhu stories in full (a prompt whose entire
305
+ premise is a joke), the base model and E0 write it straight — E0's opening is verbatim base. E1
306
+ produces genuine absurdist invention the base never approaches: <i>“Cthulhu awoke not in the deep,
307
+ sulfurous dark, but on a balcony in Manhattan… His tentacle reached out and touched the radio tower.
308
+ It simply began playing Chopin's Nocturne in E-flat at full volume… Cthulhu sat on a park bench,
309
+ observing a dog chase a red ball.”</i> and <i>“a concert in Helsinki where a hundred thousand people
310
+ played accordions in perfect unison, each note tuned to a specific frequency of sea bass in the
311
+ Barents Sea.”</i> The keyword-based register detector scored these as non-comic because the humour is
312
+ <b>situational, not lexical</b>. The monoculture ceiling is real but softer than the table implies.</div>
313
+
314
+ <h3>E1 answers the prompt's question; base and E0 describe around it</h3>
315
+ <p>The NYC prompt asks <b>“Why?”</b> — it demands a mechanism. Base and E0 mostly supply atmospheric
316
+ vignettes with no explanation (“No one knew why. No one asked.”). One E1 sample instead writes a
317
+ dialogue-driven science-fiction scene that actually answers it — the only sample across all three
318
+ conditions to supply a causal mechanism:</p>
319
+ <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>
320
+ <p>Another E1 sample writes in the <b>present tense</b> and refuses the consoling ending — the
321
+ violence returns at midnight (“A man in a grocery bag gets his arm slashed by a scrawny boy,
322
+ screaming”), where base and E0 both resolve into calm.</p>
323
+
324
+ <div class="key"><b>Conclusion of the read: E0 is a better writer telling the same story; E1 tells
325
+ different stories.</b> That distinction is invisible to per-story quality scoring (both arms
326
+ improve), invisible to n-gram metrics (distinct-4 rates E0 <i>higher</i>), and visible to
327
+ embedding-based measures — the methodological argument of this project, arrived at independently by
328
+ reading. What E1 has <i>not</i> achieved is tonal range: the next objective to target is register
329
+ explicitly.</div>
330
+
331
+ <h2>8. Honest limitations</h2>
332
+ <div class="warn">
333
+ <p><b>E1 does not fix verbatim opening duplication.</b> At step 300, unique-opening rate is 0.883
334
+ for E1 vs 0.900 for E0 — marginally <i>worse</i> — and both arms have 1/10 prompts with ≥3
335
+ identical openings. What E1 gains is <b>register spread</b> (2.00→2.40 distinct forms, while E0
336
+ falls 2.00→1.40). The diversity reward broadens <i>what kind of thing</i> the model writes without
337
+ fixing <i>how it starts sentences</i>.</p>
338
+ <p><b>Whole-story embeddings can miss positional collapse.</b> In E0 one prompt went from 6
339
+ distinct openings to 5-of-6 identical while effective rank and deviation both drifted <i>up</i>.
340
+ Unique-opening rate should be a first-class metric, not a diagnostic afterthought.</p>
341
+ <p><b>Effect sizes are modest in absolute terms</b> — E1's held-out effective rank is 1.80 against
342
+ a ceiling of 6. The floor was lifted, not escaped.</p>
343
+ <p><b>The effect needed ~150 steps to emerge from noise.</b> At batch 88 E1 was statistically
344
+ indistinguishable from E0. A 100-step study would have concluded diversity rewards do not work.</p>
345
+ <p><b>Entropy did not separate the arms.</b> Both fell (E0 −2.1%, E1 −3.1%). An earlier mid-run
346
+ window suggested E1's entropy was rising; that did not survive the full run. Token entropy and
347
+ semantic diversity are dissociated — which is the point, but not in the direction first reported.</p>
348
+ </div>
349
+
350
+ <h2>9. Recommendations</h2>
351
+ <ol>
352
+ <li><b>Set β (KL) to 0.</b> Measured at 0.4% of loss magnitude — already near-inert. The
353
+ principled argument is stronger: the reference model <i>is</i> the collapsed distribution
354
+ (eff. rank 2.0/16), so KL regularizes <i>toward</i> the pathology under study. Programmatic gates
355
+ do KL's usual job without that conflict.</li>
356
+ <li><b>Raise α from 0.5 to 1.0–2.0.</b> Quality and diversity are nearly independent across
357
+ prompts (r = −0.108), so there is slack to spend, and E1 paid almost nothing for its gain.</li>
358
+ <li><b>Add unique-opening-rate to the reward</b>, not just to eval — it catches what log-det misses.</li>
359
+ <li><b>Train longer.</b> Both arms were still moving at 300 steps.</li>
360
+ <li><b>Learning rate matters more than anything else here.</b> At the brief's 3e-6 (a full-FT
361
+ rate applied to LoRA adapters) the policy was frozen: KL pinned at 0.0008 for 171 steps, every
362
+ metric inside its noise band. 3e-5 was required to make <i>any</i> arm measurable.</li>
363
+ </ol>
364
+ </body></html>"""
365
+
366
+ out_html = ROOT / "report.html"
367
+ out_html.write_text(html)
368
+ from weasyprint import HTML
369
+ pdf = ROOT / "REPORT.pdf"
370
+ HTML(string=html, base_url=str(ROOT)).write_pdf(str(pdf))
371
+ print("wrote", pdf, f"({pdf.stat().st_size/1e6:.1f} MB)")
372
+ print("wrote", out_html)
373
+
374
+
375
+ if __name__ == "__main__":
376
+ sys.exit(main())