"""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"

[missing figure: {p.name}]

" b = base64.b64encode(p.read_bytes()).decode() return f"" def tbl(rows, cols=None, hi=None) -> str: if not rows: return "

(no data)

" cols = cols or list(rows[0].keys()) h = "".join(f"{c}" 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"{v}" body += f"{tds}" return f"{h}{body}
" 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"
{pid} — {pr[:260]}
" 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"
{tag} — " f"eff_rank {v['eff_rank']:.2f}, deviation {v['deviation']:.3f}" f"
    ") for t in v["texts"]: ex_html += f"
  1. {firsts(t)[:190]}
  2. " ex_html += "
" if step == "0": break # base identical for both arms; show once ex_html += "
" # 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"""

Diversity-Aware Post-Training for Creative Story Generation

Qwen3-4B-Instruct-2507 · LoRA r=32 α=64 · GRPO (TRL 1.10, GDPO aggregation) · single RTX 5090 32GB
Interim report — E0 and E1 complete (300 steps each). E2/E3/E4 in progress. Generated {ts}.
Headline. A quality-gated pairwise-deviation reward (E1) moved semantic diversity 5–6× further 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 3.5× E0's (+0.124 vs +0.035) while scoring higher quality (7.12 vs 7.03).

1. The baseline problem

The base model is already collapsed before any RL. Across a 16,000-story pool (1,000 prompts × 16 samples), effective rank is 2.006 out of a ceiling of 16 — 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 causes collapse, but whether any objective can lift diversity off a floor that pretraining already imposed.

{tbl(poolrows, ["metric", "value"])} {img(FIGS / "03_pool_4b_baseline.png")}
The collapse is tonal, not lexical. 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.

2. Main result — E0 vs E1, 300 steps each

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.

{tbl(rows, ["metric", "E0 start", "E0 end", "E0 Δ", "E1 start", "E1 end", "E1 Δ", "E1/E0"], hi={"E1 Δ", "E1/E0"})}
Reading it. 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 story length: 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.
{img(FIGS / "E0_vs_E1_comparison.png")}

3. Held-out generalization — checkpoint study

10 held-out prompts × 6 samples at T=0.9, fixed seed, generated from every checkpoint. 960 stories read across both arms.

{tbl(ckrows, ["arm", "step", "quality", "eff_rank (of 6)", "deviation", "logdet", "words"])} {img(FIGS / "E0-baseline_trajectory.png")}

4. Training diagnostics

{img(FIGS / "E0-baseline_diagnostics.png")} {img(FIGS / "E1-div-individual_diagnostics.png")}

5. What the stories actually look like

Opening sentences of all 6 samples per prompt. Base model shown once (identical starting point for both arms), then each arm at step 300.

{ex_html}

One complete story — E1 @ step 300

{full_story}

6. Held-out evaluation — the definitive result

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%).

{tbl(evrows, ["model","quality","eff_rank (of 16)","pairwise","logdet","ends cleanly","words"], hi={"eff_rank (of 16)"})}
E1 wins on BOTH axes. Against base: effective rank +0.190 vs E0's +0.066 (2.9x), log-det +2.380 vs +0.873 (2.7x), and judge quality +0.392 vs +0.244 (1.6x). This is not a diversity-for-quality trade — E1 is better at both.
{img(FIGS / "eval_frontier.png", "78%")}

The methodological result: n-gram metrics are blind to this

The same 480 stories per model, scored two ways:

{tbl(blind, ["model","Δ eff_rank (embed)","Δ logdet (embed)","Δ distinct-4 (n-gram)","Δ self-BLEU (n-gram)","Δ quality"], hi={"Δ eff_rank (embed)"})}
Embedding metrics separate the arms by 2.9x. N-gram metrics do not separate them at all — distinct-4 actually rates E0 higher 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.
{img(FIGS / "eval_metric_blindness.png")}

7. Qualitative read — what actually changed in the writing

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.

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

E0 keeps the base model's frame and polishes the inside

E0's step-300 openings are frequently near-verbatim to base:

{tbl(qrows, ["prompt","base","E0 @ 300"])}

The improvement is real but internal. 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. E0 is a better writer telling the same story.

E1 changes the entry point, the premise and the point of view

Graduation prompt. Base and E0 open at the podium, in the ceremony, in every sample. Two of E1's three open in retrospection instead — no stage, no lights, no crowd:

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.

Martial-arts prompt. 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:

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."

Another E1 sample relocates the scene from dojo to neon city street; a third inverts the premise entirely — the student says “I didn't download anything. I just… felt it.”

“The ash turned to snow.” 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:

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.

Black Friday prompt — 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: “No one remembers the date. The clocks stopped on a Tuesday.” / “The temperature dropped the second the lights went out.”

The ceiling: neither arm broke the tonal monoculture

{tbl(formrows, ["form","base","E0 @300","E1 @300","E1-E0"], hi={"E1 @300"})}
E1 invented second-person narration — base uses it on 0/10 prompts, E1 on 5/10. Present tense doubled. E0 loses forms (1.70 → 1.50): quality-only training narrows the repertoire. But solemn/elegiac is 1.00 in every condition. 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 not diversify tone. But that table undercounts E1. 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: “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.” and “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.” The keyword-based register detector scored these as non-comic because the humour is situational, not lexical. The monoculture ceiling is real but softer than the table implies.

E1 answers the prompt's question; base and E0 describe around it

The NYC prompt asks “Why?” — 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:

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*."

Another E1 sample writes in the present tense 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.

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

8. Honest limitations

E1 does not fix verbatim opening duplication. At step 300, unique-opening rate is 0.883 for E1 vs 0.900 for E0 — marginally worse — and both arms have 1/10 prompts with ≥3 identical openings. What E1 gains is register spread (2.00→2.40 distinct forms, while E0 falls 2.00→1.40). The diversity reward broadens what kind of thing the model writes without fixing how it starts sentences.

Whole-story embeddings can miss positional collapse. In E0 one prompt went from 6 distinct openings to 5-of-6 identical while effective rank and deviation both drifted up. Unique-opening rate should be a first-class metric, not a diagnostic afterthought.

Effect sizes are modest in absolute terms — E1's held-out effective rank is 1.80 against a ceiling of 6. The floor was lifted, not escaped.

The effect needed ~150 steps to emerge from noise. At batch 88 E1 was statistically indistinguishable from E0. A 100-step study would have concluded diversity rewards do not work.

Entropy did not separate the arms. 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.

9. Recommendations

  1. Set β (KL) to 0. Measured at 0.4% of loss magnitude — already near-inert. The principled argument is stronger: the reference model is the collapsed distribution (eff. rank 2.0/16), so KL regularizes toward the pathology under study. Programmatic gates do KL's usual job without that conflict.
  2. Raise α from 0.5 to 1.0–2.0. 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.
  3. Add unique-opening-rate to the reward, not just to eval — it catches what log-det misses.
  4. Train longer. Both arms were still moving at 300 steps.
  5. Learning rate matters more than anything else here. 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 any arm measurable.
""" 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())