Pranav2748 commited on
Commit
cbc33fe
·
verified ·
1 Parent(s): 8605f3a
src/analyze_pool.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Characterize the base policy from the generation pool.
3
+
4
+ The pool is 1000 prompts x 16 samples = 16,000 stories, which is 20x the
5
+ held-out eval set. It is the most statistically solid picture of baseline mode
6
+ collapse in the whole study, so it gets its own report rather than being used
7
+ only as DPO feed.
8
+
9
+ Answers:
10
+ - How collapsed is the base model, per prompt? (deviation, log-det, eff. rank)
11
+ - Is collapse uniform, or are some prompts far worse than others?
12
+ - Does the judge's quality correlate with diversity? (i.e. is there really a
13
+ quality-diversity tension to trade off, or are they independent?)
14
+ - What does the quality distribution look like -- does tau=5 / rho=6 bite?
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+ ROOT = Path(__file__).resolve().parent.parent
27
+
28
+
29
+ def main():
30
+ ap = argparse.ArgumentParser()
31
+ ap.add_argument("--tag", default="4b")
32
+ ap.add_argument("--split", default="train")
33
+ args = ap.parse_args()
34
+
35
+ import logbook
36
+ from diversity import effective_rank, logdet_volume, pairwise_deviation
37
+
38
+ pdir = ROOT / "outputs" / f"pool_{args.tag}"
39
+ rows = [json.loads(l) for l in open(pdir / f"pool_{args.split}.jsonl") if l.strip()]
40
+ E = np.load(pdir / f"emb_{args.split}.npy").astype(np.float64)
41
+ summary = json.load(open(pdir / f"summary_{args.split}.json"))
42
+
43
+ by = defaultdict(list)
44
+ for i, r in enumerate(rows):
45
+ by[r["prompt_id"]].append(i)
46
+
47
+ per = []
48
+ for pid, ids in by.items():
49
+ sub = E[ids]
50
+ q = np.array([rows[i]["quality"] for i in ids])
51
+ gp = np.array([rows[i]["gate_passed"] for i in ids])
52
+ per.append({
53
+ "prompt_id": pid,
54
+ "dev": float(pairwise_deviation(sub).mean()),
55
+ "logdet": float(logdet_volume(sub)),
56
+ "eff_rank": float(effective_rank(sub)),
57
+ "quality": float(q[gp].mean()) if gp.any() else 0.0,
58
+ "gate_pass": float(gp.mean()),
59
+ "n": len(ids),
60
+ })
61
+
62
+ dev = np.array([p["dev"] for p in per])
63
+ ld = np.array([p["logdet"] for p in per])
64
+ er = np.array([p["eff_rank"] for p in per])
65
+ ql = np.array([p["quality"] for p in per])
66
+ qual_all = np.array([r["quality"] for r in rows if r["gate_passed"]])
67
+ N = per[0]["n"]
68
+
69
+ # quality-diversity correlation across prompts
70
+ def corr(a, b):
71
+ if a.std() < 1e-9 or b.std() < 1e-9:
72
+ return 0.0
73
+ return float(np.corrcoef(a, b)[0, 1])
74
+
75
+ # --- how much signal do the E1 / E2 channels actually carry? -----------
76
+ # GDPO z-scores each reward channel WITHIN its group, so a channel with tiny
77
+ # within-group spread gets amplified to unit variance regardless. If the
78
+ # within-group ordering of d_i is not meaningful, E1 is largely learning
79
+ # from amplified noise. Compare within-group spread against between-group
80
+ # spread: a ratio far below 1 means the channel mostly encodes "which prompt
81
+ # is this", which per-group normalization deliberately removes.
82
+ dev_within, marg_within = [], []
83
+ for pid, ids in by.items():
84
+ sub = E[ids]
85
+ d = pairwise_deviation(sub)
86
+ from diversity import marginal_contributions
87
+ m = marginal_contributions(sub)
88
+ dev_within.append(d.std())
89
+ marg_within.append(m.std())
90
+ dev_within = np.array(dev_within); marg_within = np.array(marg_within)
91
+
92
+ stats = {
93
+ "n_prompts": len(per), "n_per_prompt": N, "n_stories": len(rows),
94
+ "dev_within_group_sd": float(dev_within.mean()),
95
+ "dev_between_group_sd": float(dev.std()),
96
+ "dev_within_over_between": float(dev_within.mean() / max(dev.std(), 1e-9)),
97
+ "marginal_within_group_sd": float(marg_within.mean()),
98
+ "gate_pass_rate": summary["gate_pass_rate"],
99
+ "ends_cleanly_rate": summary["ends_cleanly_rate"],
100
+ "quality_mean": float(qual_all.mean()), "quality_sd": float(qual_all.std()),
101
+ "quality_p10": float(np.percentile(qual_all, 10)),
102
+ "quality_median": float(np.median(qual_all)),
103
+ "quality_p90": float(np.percentile(qual_all, 90)),
104
+ "frac_quality_ge_5(tau)": float((qual_all >= 5).mean()),
105
+ "frac_quality_ge_6": float((qual_all >= 6).mean()),
106
+ "frac_quality_ge_7(rho)": float((qual_all >= 7).mean()),
107
+ "deviation_mean": float(dev.mean()), "deviation_sd": float(dev.std()),
108
+ "deviation_p10": float(np.percentile(dev, 10)),
109
+ "deviation_p90": float(np.percentile(dev, 90)),
110
+ "logdet_mean": float(ld.mean()), "logdet_sd": float(ld.std()),
111
+ "eff_rank_mean": float(er.mean()), "eff_rank_sd": float(er.std()),
112
+ "eff_rank_p10": float(np.percentile(er, 10)),
113
+ "eff_rank_p90": float(np.percentile(er, 90)),
114
+ "eff_rank_ceiling": N,
115
+ "corr(quality, deviation)": corr(ql, dev),
116
+ "corr(quality, eff_rank)": corr(ql, er),
117
+ "corr(deviation, eff_rank)": corr(dev, er),
118
+ }
119
+
120
+ # ---- figures ---------------------------------------------------------
121
+ import matplotlib
122
+ matplotlib.use("Agg")
123
+ import matplotlib.pyplot as plt
124
+
125
+ fig, ax = plt.subplots(1, 4, figsize=(19, 4.3))
126
+ ax[0].hist(qual_all, bins=np.arange(-0.25, 10.75, 0.5), color="#2980b9",
127
+ edgecolor="white")
128
+ ax[0].axvline(5, c="crimson", ls="--", label="tau=5 (diversity gate)")
129
+ ax[0].axvline(7, c="darkorange", ls="--", label="rho=7 (DivPO, swept)")
130
+ ax[0].set_title("Judge quality, all gate-passing stories")
131
+ ax[0].set_xlabel("quality"); ax[0].legend(fontsize=7); ax[0].grid(alpha=.3)
132
+
133
+ ax[1].hist(er, bins=30, color="#8e44ad", edgecolor="white")
134
+ ax[1].axvline(N, c="green", ls="--", label=f"ceiling = {N}")
135
+ ax[1].set_title(f"Effective rank per prompt\n(1 = total collapse, {N} = orthogonal)")
136
+ ax[1].set_xlabel("effective rank"); ax[1].legend(fontsize=7); ax[1].grid(alpha=.3)
137
+
138
+ ax[2].hist(dev, bins=30, color="#16a085", edgecolor="white")
139
+ ax[2].set_title("Mean pairwise distance per prompt")
140
+ ax[2].set_xlabel("1 - cos"); ax[2].grid(alpha=.3)
141
+
142
+ ax[3].scatter(er, ql, s=6, alpha=.35, color="#c0392b")
143
+ ax[3].set_xlabel("effective rank"); ax[3].set_ylabel("mean judge quality")
144
+ ax[3].set_title(f"Quality vs diversity across prompts\nr = {stats['corr(quality, eff_rank)']:+.3f}")
145
+ ax[3].grid(alpha=.3)
146
+
147
+ plt.tight_layout()
148
+ figp = logbook.FIGS / f"03_pool_{args.tag}_baseline.png"
149
+ plt.savefig(figp, dpi=140)
150
+ plt.close()
151
+
152
+ worst = sorted(per, key=lambda p: p["eff_rank"])[:5]
153
+ best = sorted(per, key=lambda p: -p["eff_rank"])[:5]
154
+
155
+ r_qd = stats["corr(quality, eff_rank)"]
156
+ tension = ("a genuine quality-diversity **tension**" if r_qd < -0.15 else
157
+ "quality and diversity are **largely independent**" if abs(r_qd) <= 0.15 else
158
+ "quality and diversity are **positively** related")
159
+
160
+ body = f"""# Baseline characterization — {args.tag} pool ({len(rows):,} stories)
161
+
162
+ The pool is {stats['n_prompts']} prompts x {N} samples from the **base policy**,
163
+ 20x the held-out eval set. This is the most statistically solid picture of
164
+ baseline mode collapse in the study, so it is reported in its own right rather
165
+ than treated only as DPO feed.
166
+
167
+ ## Summary
168
+
169
+ {logbook.table([{"metric": k, "value": v} for k, v in stats.items()])}
170
+
171
+ ## Findings
172
+
173
+ **Baseline collapse is severe.** Mean effective rank is
174
+ **{stats['eff_rank_mean']:.2f} out of a ceiling of {N}** — the {N} samples for a
175
+ given prompt span only ~{stats['eff_rank_mean']:.1f} effective directions. Mean
176
+ pairwise distance is {stats['deviation_mean']:.3f}, i.e. same-prompt stories sit
177
+ at ~{1-stats['deviation_mean']:.2f} cosine similarity. This is the thing every
178
+ arm is trying to move.
179
+
180
+ **Collapse is not uniform across prompts.** Effective rank runs from
181
+ {stats['eff_rank_p10']:.2f} (p10) to {stats['eff_rank_p90']:.2f} (p90), so some
182
+ prompts admit far more variation than others. Per-group normalization (GDPO)
183
+ handles this correctly: each prompt's advantage is computed within its own
184
+ group, so an intrinsically constrained prompt does not drag the update.
185
+
186
+ **Quality vs diversity: r = {r_qd:+.3f}** across prompts, so {tension}. This
187
+ matters for reading the frontier: if the correlation is near zero, then a method
188
+ that raises diversity without lowering quality is not defying a tradeoff, it is
189
+ exploiting slack that was already there.
190
+
191
+ **How much signal does E1's channel carry?** Within-group SD of `d_i` is
192
+ {stats['dev_within_group_sd']:.4f} against a between-group SD of
193
+ {stats['dev_between_group_sd']:.4f} — a ratio of
194
+ **{stats['dev_within_over_between']:.2f}**. This matters because GDPO z-scores
195
+ each channel *within* its group, so whatever within-group spread exists is
196
+ amplified to unit variance. A low ratio means most of `d_i`'s variation encodes
197
+ *which prompt this is* rather than *which sample is the odd one out* — and
198
+ per-group normalization deliberately discards exactly the former. Read E1's
199
+ result with this number in mind: if E1 underperforms, weak within-group
200
+ resolution is the first hypothesis, not a refutation of pairwise diversity as an
201
+ idea. The marginal channel's within-group SD is
202
+ {stats['marginal_within_group_sd']:.4f} on the raw log scale (it is z-scored
203
+ before use, so only its ordering matters).
204
+
205
+ **Threshold placement.** {100*stats['frac_quality_ge_5(tau)']:.1f}% of
206
+ gate-passing stories score >= tau=5 and
207
+ {100*stats['frac_quality_ge_7(rho)']:.1f}% score >= rho=7 (swept up from 6 because only {100*(1-stats['frac_quality_ge_6']):.1f}% fall below 6, starving DivPO of negatives on 311/1000 prompts). tau therefore acts as
208
+ a *floor* that only bites when a story is genuinely bad, which is its intent
209
+ (anti-gaming, not selection). rho is the more selective threshold and determines
210
+ DivPO's skip rate.
211
+
212
+ ## Most collapsed prompts
213
+
214
+ {logbook.table(worst, ["prompt_id", "eff_rank", "dev", "logdet", "quality"])}
215
+
216
+ ## Most diverse prompts
217
+
218
+ {logbook.table(best, ["prompt_id", "eff_rank", "dev", "logdet", "quality"])}
219
+
220
+ ![pool baseline](../figures/03_pool_{args.tag}_baseline.png)
221
+ """
222
+ p = logbook.write_report(f"03_pool_{args.tag}_baseline", body)
223
+ print(json.dumps(stats, indent=1))
224
+ print("report ->", p, "\nfigure ->", figp)
225
+ json.dump({"stats": stats, "per_prompt": per},
226
+ open(logbook.LOGS / f"pool_{args.tag}_analysis.json", "w"), indent=1)
227
+ logbook.note(f"pool analysis ({args.tag})",
228
+ f"eff_rank {stats['eff_rank_mean']:.2f}/{N}, "
229
+ f"dev {stats['deviation_mean']:.3f}, "
230
+ f"corr(q,div) {r_qd:+.3f}")
231
+ return 0
232
+
233
+
234
+ if __name__ == "__main__":
235
+ sys.exit(main())
src/analyze_prior_run.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Autopsy of the previous run (logs/experiments/00_prior_run_autopsy.md + figures).
3
+
4
+ Reads prior_run/eval_samples/step_*.json, which are {prompt: [16 stories]} dumps
5
+ taken every 50 steps. Recomputes gates + embedding diversity so the prior run is
6
+ measured with EXACTLY the same instruments as the new arms -- otherwise the
7
+ "before" number is not comparable to any "after" number.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import glob
12
+ import json
13
+ import re
14
+ from collections import Counter
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ import gates
20
+ import logbook
21
+ from diversity import group_metrics, l2_normalize
22
+
23
+ PRIOR = Path(__file__).resolve().parent.parent / "prior_run" / "eval_samples"
24
+ EMB_MODEL = "BAAI/bge-base-en-v1.5"
25
+
26
+
27
+ def load_steps() -> dict[int, dict[str, list[str]]]:
28
+ out = {}
29
+ for f in sorted(glob.glob(str(PRIOR / "step_*.json")),
30
+ key=lambda x: int(re.search(r"\d+", Path(x).name).group())):
31
+ step = int(re.search(r"\d+", Path(f).name).group())
32
+ out[step] = json.load(open(f))
33
+ return out
34
+
35
+
36
+ def main():
37
+ steps = load_steps()
38
+ print(f"loaded {len(steps)} checkpoints: {sorted(steps)}")
39
+
40
+ from sentence_transformers import SentenceTransformer
41
+ enc = SentenceTransformer(EMB_MODEL, device="cuda")
42
+
43
+ rows = []
44
+ reason_counter: dict[int, Counter] = {}
45
+ for step, data in steps.items():
46
+ allres, pw, ld, mm = [], [], [], []
47
+ for prompt, samples in data.items():
48
+ res = [gates.check(s) for s in samples]
49
+ allres += res
50
+ E = l2_normalize(enc.encode(samples, normalize_embeddings=True,
51
+ batch_size=32, show_progress_bar=False))
52
+ gm = group_metrics(E)
53
+ pw.append(gm["mean_pairwise_dist"]); ld.append(gm["logdet"])
54
+ mm.append(gm["mean_marginal"])
55
+ n = len(allres)
56
+ reason_counter[step] = Counter(r for x in allres for r in x.reasons)
57
+ rows.append({
58
+ "step": step, "n": n,
59
+ "gate_pass_%": 100 * sum(r.passed for r in allres) / n,
60
+ "ends_cleanly_%": 100 * sum(r.completeness for r in allres) / n,
61
+ "med_words": float(np.median([r.n_words for r in allres])),
62
+ "rep4gram": float(np.mean([r.repeat_4gram_frac for r in allres])),
63
+ "pairwise_dist": float(np.mean(pw)),
64
+ "logdet": float(np.mean(ld)),
65
+ "mean_marginal": float(np.mean(mm)),
66
+ })
67
+
68
+ for r in rows:
69
+ print(r)
70
+
71
+ # ---------------------------------------------------------------- figures
72
+ import matplotlib
73
+ matplotlib.use("Agg")
74
+ import matplotlib.pyplot as plt
75
+
76
+ s = [r["step"] for r in rows]
77
+ fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))
78
+
79
+ ax[0].plot(s, [r["ends_cleanly_%"] for r in rows], "o-", color="#c0392b", lw=2)
80
+ ax[0].axhline(80, ls="--", c="gray", lw=1)
81
+ ax[0].set_title("Completeness collapse\n(prior run)")
82
+ ax[0].set_xlabel("step"); ax[0].set_ylabel("% ending cleanly")
83
+ ax[0].set_ylim(-3, 100); ax[0].grid(alpha=.3)
84
+ ax[0].text(60, 84, "healthy target", color="gray", fontsize=8)
85
+
86
+ ax[1].plot(s, [r["pairwise_dist"] for r in rows], "o-", label="pairwise dist", lw=2)
87
+ ax[1].set_xlabel("step"); ax[1].set_ylabel("mean pairwise distance", color="C0")
88
+ ax2 = ax[1].twinx()
89
+ ax2.plot(s, [r["logdet"] for r in rows], "s--", color="C1", label="logdet")
90
+ ax2.set_ylabel("logdet volume", color="C1")
91
+ ax[1].set_title("Semantic diversity\n(embedding-based)")
92
+ ax[1].grid(alpha=.3)
93
+
94
+ ax[2].plot(s, [r["med_words"] for r in rows], "o-", color="#2c3e50", lw=2)
95
+ ax[2].set_title("Median length pinned at the\ntoken wall (~380 words)")
96
+ ax[2].set_xlabel("step"); ax[2].set_ylabel("median words")
97
+ ax[2].grid(alpha=.3)
98
+
99
+ plt.tight_layout()
100
+ out = logbook.FIGS / "00_prior_run_autopsy.png"
101
+ out.parent.mkdir(parents=True, exist_ok=True)
102
+ plt.savefig(out, dpi=140)
103
+ print("figure ->", out)
104
+
105
+ # ---------------------------------------------------------------- report
106
+ body = f"""# Experiment 00 — Autopsy of the prior run
107
+
108
+ **Purpose.** Establish what actually happened in the previous `train.py` run
109
+ before building anything new, and re-measure it with the *same* instruments the
110
+ new arms will use, so "before" and "after" are comparable.
111
+
112
+ **Source.** `prior_run/eval_samples/step_{{50..300}}.json` — 5 held-out prompts x
113
+ 16 samples, dumped every 50 steps. 150 stories total per checkpoint set.
114
+
115
+ ## Measurements
116
+
117
+ {logbook.table(rows)}
118
+
119
+ ## Failure-reason census (gate violations, counted per story)
120
+
121
+ {logbook.table([{"step": k, **dict(v)} for k, v in reason_counter.items()])}
122
+
123
+ ## Findings
124
+
125
+ **1. The run generated no finished stories, at any checkpoint.**
126
+ `ends_cleanly` is 0-8% across all six checkpoints. Median length sits in a
127
+ tight ~360-400 word band with a hard ceiling, which is the signature of a
128
+ `max_new_tokens` wall rather than a stylistic preference. Sampled tails
129
+ confirm mid-word cutoff (`"...a place called *The Library of"`).
130
+
131
+ **2. That silently destroyed the quality reward.**
132
+ The prior judge rubric said *"if the story cuts off mid-sentence, cap quality
133
+ at 4."* When ~100% of a group hits the same cap, within-group quality variance
134
+ goes to ~0. GRPO normalizes advantages within the group, so an
135
+ (almost) constant reward column produces an (almost) zero advantage. The
136
+ quality objective contributed no gradient for the entire run.
137
+
138
+ **3. The group-diversity term was structurally incapable of contributing.**
139
+ `quality_reward` added `0.5 * group_diversity`, a single scalar identical for
140
+ every sample in the group. Its within-group std is exactly 0, so after
141
+ normalization it contributes exactly 0 to every advantage. It cost one judge
142
+ call per step and bought nothing. (This is the failure mode recorded in the
143
+ brief's section 0.1, confirmed here in situ.)
144
+
145
+ **4. Net effect: the run optimized novelty almost alone.**
146
+ With quality flat and group-diversity annihilated, the only surviving gradient
147
+ came from `novelty_reward` — and its quality gate (`q >= 5`) was itself
148
+ degenerate, because quality was pinned near the truncation cap. That is an
149
+ unconditioned novelty objective, which is precisely the configuration known to
150
+ produce surface-level gaming.
151
+
152
+ **5. The observable signature matches that diagnosis.**
153
+ Unique 5-word openers rise 0.68 -> 0.96 while type-token ratio *falls*
154
+ 0.381 -> 0.343. The model learned to vary the first few words while its
155
+ vocabulary narrowed — diversification at the surface, homogenization
156
+ underneath. Heavy italic emphasis persists at ~7 instances/story throughout,
157
+ the exact tic the rubric explicitly tried to punish.
158
+
159
+ **6. A latent bug would have suppressed completeness credit even after a fix.**
160
+ `completeness_reward` tested `text[-1] in ".!?\\"'"` — straight quotes only.
161
+ These generations use curly U+201D constantly, so a story ending `...gone.”`
162
+ scored 0.3 instead of 1.0.
163
+
164
+ ## Consequences for the new design
165
+
166
+ - Token budget raised to 1280 new tokens (a 500-word story is ~650-700 Qwen
167
+ tokens; the planned 768 left no headroom and would have re-created the bug).
168
+ Nothing truncates the story anywhere downstream — not the judge, not the
169
+ embedder. Overrun is *detected* by a word-count gate, never chopped.
170
+ - System prompt now carries an explicit word budget and ending instruction.
171
+ - No set-level scalar is ever used as a per-sample reward. All diversity credit
172
+ is per-sample by construction (deviation `d_i`, marginal `m_i`).
173
+ - `finish_reason == "length"` from vLLM is captured and gates on it directly,
174
+ rather than inferring truncation from punctuation.
175
+ - Terminal-punctuation detection handles curly quotes, ellipses and trailing
176
+ markdown emphasis.
177
+ - Judge scores one story at a time on an absolute rubric, so `tau` is
178
+ meaningful and cross-model eval comparisons are valid.
179
+
180
+ ![autopsy](../figures/00_prior_run_autopsy.png)
181
+ """
182
+ p = logbook.write_report("00_prior_run_autopsy", body)
183
+ print("report ->", p)
184
+ logbook.note("prior-run autopsy complete",
185
+ f"0-8% completion across all checkpoints; quality signal was "
186
+ f"degenerate. Report: {p}")
187
+ json.dump(rows, open(logbook.LOGS / "prior_run_metrics.json", "w"), indent=2)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
src/analyze_run.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Post-run analysis for one GRPO arm. Produces logs/experiments/<name>.md plus
3
+ figures, from outputs/<name>/reward_history.json.
4
+
5
+ Run this after EVERY training run, before launching the next one. The point is
6
+ to catch a dead or hacked reward channel while there is still time to change
7
+ something, rather than discovering it in the final report -- which is exactly
8
+ how the prior run wasted 300 steps.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ ROOT = Path(__file__).resolve().parent.parent
20
+
21
+
22
+ def smooth(x, w=15):
23
+ x = np.asarray(x, dtype=np.float64)
24
+ if len(x) < w:
25
+ return x
26
+ k = np.ones(w) / w
27
+ return np.convolve(x, k, mode="valid")
28
+
29
+
30
+ def trend(x, frac=0.25):
31
+ """(early mean, late mean, delta) over the first/last `frac` of the run."""
32
+ x = np.asarray(x, dtype=np.float64)
33
+ n = max(1, int(len(x) * frac))
34
+ a, b = float(x[:n].mean()), float(x[-n:].mean())
35
+ return a, b, b - a
36
+
37
+
38
+ def main():
39
+ ap = argparse.ArgumentParser()
40
+ ap.add_argument("--name", required=True)
41
+ ap.add_argument("--title", default=None)
42
+ args = ap.parse_args()
43
+
44
+ import logbook
45
+
46
+ run_dir = ROOT / "outputs" / args.name
47
+ hist = json.load(open(run_dir / "reward_history.json"))
48
+ if not hist:
49
+ print("empty history"); return 1
50
+
51
+ steps = np.arange(len(hist))
52
+ series = {k: np.array([h[k] for h in hist], dtype=np.float64) for k in
53
+ ("gate_pass", "ends_cleanly", "mean_quality_passing", "mean_novelty",
54
+ "frac_above_tau", "mean_deviation", "mean_logdet", "mean_words",
55
+ "frac_groups_degenerate")}
56
+
57
+ tr = {k: trend(v) for k, v in series.items()}
58
+
59
+ # --- reward-hacking diagnostic ---------------------------------------
60
+ dev_a, dev_b, dev_d = tr["mean_deviation"]
61
+ q_a, q_b, q_d = tr["mean_quality_passing"]
62
+ g_a, g_b, g_d = tr["gate_pass"]
63
+ hack = (dev_d > 0.02 and q_d < -0.5) or (g_b < 0.6 and g_d < -0.2)
64
+ verdict = ("SUSPECTED REWARD HACKING" if hack else
65
+ "healthy" if (g_b > 0.7 and q_d > -0.5) else "degraded but not hacking")
66
+
67
+ # --- figures ----------------------------------------------------------
68
+ import matplotlib
69
+ matplotlib.use("Agg")
70
+ import matplotlib.pyplot as plt
71
+
72
+ # TRL-side metrics (entropy / KL) live in a separate history
73
+ trl_path = run_dir / "trl_log_history.json"
74
+ trl = json.load(open(trl_path)) if trl_path.exists() else []
75
+ ent = np.array([h["entropy"] for h in trl if "entropy" in h], dtype=np.float64)
76
+ kl = np.array([h["kl"] for h in trl if "kl" in h], dtype=np.float64)
77
+
78
+ fig, ax = plt.subplots(3, 3, figsize=(16, 11.5))
79
+ panels = [
80
+ ("gate_pass", "Gate pass rate", "#c0392b", (0, 1.05)),
81
+ ("ends_cleanly", "Ends cleanly", "#27ae60", (0, 1.05)),
82
+ ("mean_quality_passing", "Judge quality (gate-passers)", "#2980b9", None),
83
+ ("mean_deviation", "Mean pairwise deviation d_i", "#8e44ad", None),
84
+ ("mean_logdet", "Group log-det volume", "#d35400", None),
85
+ ("frac_above_tau", "Frac above tau (diversity-eligible)", "#16a085", (0, 1.05)),
86
+ ("mean_words", "Mean story length (words)", "#7f8c8d", None),
87
+ ("frac_groups_degenerate", "Groups with no valid sample", "#c0392b", (0, 1.05)),
88
+ ]
89
+ axes = ax.ravel()
90
+ for a, (key, title, c, ylim) in zip(axes, panels):
91
+ v = series[key]
92
+ a.plot(steps, v, alpha=0.25, color=c, lw=0.8)
93
+ sm = smooth(v)
94
+ a.plot(steps[len(steps) - len(sm):], sm, color=c, lw=2)
95
+ a.set_title(title, fontsize=10)
96
+ a.set_xlabel("reward-batch"); a.grid(alpha=.3)
97
+ if ylim:
98
+ a.set_ylim(*ylim)
99
+
100
+ # entropy panel: the creativity-death detector. Falling entropy means the
101
+ # policy is becoming deterministic. Rising entropy is permissive, NOT proof
102
+ # of creativity -- a policy can get noisier inside a single narrative mode --
103
+ # so this is read alongside log-det, never as a substitute for it.
104
+ a = axes[8]
105
+ if ent.size:
106
+ x = np.arange(ent.size)
107
+ a.plot(x, ent, alpha=0.25, color="#e67e22", lw=0.8)
108
+ sm = smooth(ent)
109
+ a.plot(x[len(x) - len(sm):], sm, color="#e67e22", lw=2, label="entropy")
110
+ e0, e1, ed = trend(ent)
111
+ a.axhline(e0, ls=":", c="gray", lw=1)
112
+ a.set_title(f"Per-token policy entropy ({e0:.3f} → {e1:.3f}, Δ{ed:+.3f})",
113
+ fontsize=10)
114
+ if kl.size:
115
+ a2 = a.twinx()
116
+ a2.plot(np.arange(kl.size), kl, color="#95a5a6", lw=1, alpha=.7)
117
+ a2.set_ylabel("KL to ref", color="#95a5a6", fontsize=8)
118
+ else:
119
+ a.text(.5, .5, "no entropy logged\n(liger path?)", ha="center",
120
+ va="center", transform=a.transAxes, color="crimson")
121
+ a.set_title("Per-token policy entropy — MISSING", fontsize=10)
122
+ a.set_xlabel("optimizer step"); a.grid(alpha=.3)
123
+ fig.suptitle(f"{args.title or args.name} — training diagnostics ({verdict})",
124
+ fontsize=12)
125
+ plt.tight_layout()
126
+ figp = logbook.FIGS / f"{args.name}_diagnostics.png"
127
+ figp.parent.mkdir(parents=True, exist_ok=True)
128
+ plt.savefig(figp, dpi=140)
129
+ plt.close()
130
+
131
+ # --- gate failure census ---------------------------------------------
132
+ from collections import Counter
133
+ cnt = Counter()
134
+ for h in hist:
135
+ for k, v in (h.get("reasons") or {}).items():
136
+ cnt[k] += v
137
+ total_stories = sum(h["n"] for h in hist)
138
+
139
+ rows = [{"metric": k, "early": round(a, 4), "late": round(b, 4),
140
+ "delta": round(d, 4)} for k, (a, b, d) in tr.items()]
141
+ if ent.size:
142
+ a_, b_, d_ = trend(ent)
143
+ rows.append({"metric": "policy_entropy", "early": round(a_, 4),
144
+ "late": round(b_, 4), "delta": round(d_, 4)})
145
+ entropy_note = (
146
+ f"Per-token policy entropy moved {a_:.4f} → {b_:.4f} "
147
+ f"(Δ {d_:+.4f}, {100*d_/max(a_,1e-9):+.1f}%). "
148
+ + ("**Entropy collapse** — the policy is becoming deterministic; "
149
+ "treat any diversity gain reported below with suspicion."
150
+ if d_ < -0.15 * a_ else
151
+ "No entropy collapse. Note that entropy holding up is a "
152
+ "necessary but not sufficient condition for diversity: it "
153
+ "permits varied output without demonstrating it.")
154
+ )
155
+ else:
156
+ entropy_note = ("Entropy was NOT logged for this run. TRL only emits it "
157
+ "on the non-liger loss path; check `use_liger_kernel`.")
158
+
159
+ cost = {}
160
+ cp = run_dir / "judge_cost.json"
161
+ if cp.exists():
162
+ cost = json.load(open(cp))
163
+
164
+ body = f"""# {args.title or args.name} — training analysis
165
+
166
+ **Verdict: {verdict}**
167
+
168
+ Reward batches: {len(hist)} | stories scored: {total_stories}
169
+
170
+ ## Trend (first 25% vs last 25% of the run)
171
+
172
+ {logbook.table(rows)}
173
+
174
+ ## Gate failures (count over the whole run, {total_stories} stories)
175
+
176
+ {logbook.table([{"reason": k, "count": v, "pct_of_stories": round(100*v/max(1,total_stories), 2)}
177
+ for k, v in cnt.most_common()]) if cnt else "_No gate failures._"}
178
+
179
+ ## Policy entropy
180
+
181
+ {entropy_note}
182
+
183
+ Entropy is read as an *asymmetric* signal here. A large fall is strong evidence
184
+ that creativity is dying — the policy is collapsing toward deterministic output.
185
+ A rise is only permissive: a policy can raise per-token entropy while staying
186
+ inside one narrative mode (noisier word choice, same story). The prior run
187
+ demonstrated exactly that dissociation — surface variation up, semantic
188
+ diversity down. So entropy is never optimized, and diversity claims rest on
189
+ log-det / effective rank.
190
+
191
+ ## Reward-hacking check
192
+
193
+ The signature we watch for is **diversity up while quality or validity goes
194
+ down** — the policy discovering it can farm the diversity channel by emitting
195
+ text that is different because it is worse.
196
+
197
+ - mean deviation: {dev_a:.4f} → {dev_b:.4f} (Δ {dev_d:+.4f})
198
+ - judge quality: {q_a:.3f} → {q_b:.3f} (Δ {q_d:+.3f})
199
+ - gate pass: {g_a:.3f} → {g_b:.3f} (Δ {g_d:+.3f})
200
+
201
+ Trip conditions: `Δdeviation > +0.02 AND Δquality < -0.5`, or
202
+ `gate_pass < 0.60 AND Δgate_pass < -0.20`. → **{"TRIPPED" if hack else "not tripped"}**
203
+
204
+ ## Judge cost
205
+
206
+ ```json
207
+ {json.dumps(cost, indent=1) if cost else "{}"}
208
+ ```
209
+
210
+ ![diagnostics](../figures/{args.name}_diagnostics.png)
211
+ """
212
+ p = logbook.write_report(args.name, body)
213
+ print(f"verdict: {verdict}")
214
+ for r in rows:
215
+ print(f" {r['metric']:26} {r['early']:>9.4f} -> {r['late']:>9.4f} ({r['delta']:+.4f})")
216
+ print("report ->", p)
217
+ print("figure ->", figp)
218
+ logbook.note(f"analysis: {args.name}", f"verdict={verdict}; report {p}")
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
src/build_pairs.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Preference-pair construction for E3 (multi-positive diverse DPO) and
3
+ E4 (faithful DivPO). Both read the SAME scored pool, so the comparison isolates
4
+ pair construction + loss, with data held fixed.
5
+
6
+ E4 -- DivPO (Lanchantin et al. 2025), implemented as published
7
+ ------------------------------------------------------------
8
+ One pair per prompt, no loss weighting, no multi-positive rows:
9
+ chosen = argmax diversity over {quality >= rho}
10
+ rejected = argmin diversity over {quality < rho}
11
+ Prompts where either side is empty are SKIPPED (skip rate is logged; if it
12
+ exceeds 30% we adjust rho once and record the change).
13
+
14
+ Two diversity criteria:
15
+ divpo-emb -- deviation d_i from the embedding module.
16
+ divpo-prob -- model probability. Most diverse = LOWEST length-normalized
17
+ logprob, least diverse = HIGHEST. The highest-probability
18
+ sample in a temperature pool is by construction the
19
+ near-greedy one, which makes this the principled version of
20
+ "reject the greedy decode".
21
+
22
+ Length normalization is not optional here: raw cumulative logprob scales with
23
+ token count, so ranking on it would rank by length and the "most diverse"
24
+ choice would just be the longest story.
25
+
26
+ E3 -- multi-positive deviation-weighted DPO
27
+ -------------------------------------------
28
+ survivors = {quality >= q_keep}
29
+ chosen = greedy argmax over 4-subsets of sum(quality) + lam * logdet(L_S)
30
+ rejected = r_D "competent cliche" (highest-quality LOW-deviation survivor)
31
+ + r_Q "clearly weak" (lowest-quality sample overall)
32
+ Rows pair each chosen against a rejected, ROTATING the rejected across rows so
33
+ one negative is not hammered four times.
34
+
35
+ Each row carries `weight` = the chosen's deviation d_i (DDPO-style), consumed
36
+ by the trainer as a per-sample loss weight.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import json
42
+ import sys
43
+ from collections import defaultdict
44
+ from pathlib import Path
45
+
46
+ import numpy as np
47
+
48
+ ROOT = Path(__file__).resolve().parent.parent
49
+
50
+
51
+ def load_pool(tag: str, split: str = "train") -> dict[str, list[dict]]:
52
+ p = ROOT / "outputs" / f"pool_{tag}" / f"pool_{split}.jsonl"
53
+ rows = [json.loads(l) for l in open(p) if l.strip()]
54
+ by: dict[str, list[dict]] = defaultdict(list)
55
+ for r in rows:
56
+ by[r["prompt_id"]].append(r)
57
+ return dict(by)
58
+
59
+
60
+ def load_emb(tag: str, split: str = "train") -> np.ndarray:
61
+ return np.load(ROOT / "outputs" / f"pool_{tag}" / f"emb_{split}.npy")
62
+
63
+
64
+ # --------------------------------------------------------------------- E4
65
+ def build_divpo(pool: dict[str, list[dict]], criterion: str, rho: float) -> tuple[list[dict], dict]:
66
+ """criterion: 'emb' (deviation) or 'prob' (mean logprob)."""
67
+ rows, skipped = [], {"no_chosen": 0, "no_rejected": 0, "ok": 0}
68
+
69
+ for pid, items in pool.items():
70
+ valid = [r for r in items if r["gate_passed"]]
71
+ hi = [r for r in valid if r["quality"] >= rho]
72
+ # rejected pool: below the quality bar. Gate failures are legitimate
73
+ # DivPO rejects (they are the low-quality tail), so they are eligible.
74
+ lo = [r for r in items if not (r["gate_passed"] and r["quality"] >= rho)]
75
+ if not hi:
76
+ skipped["no_chosen"] += 1; continue
77
+ if not lo:
78
+ skipped["no_rejected"] += 1; continue
79
+
80
+ if criterion == "emb":
81
+ chosen = max(hi, key=lambda r: r["deviation"])
82
+ rejected = max(lo, key=lambda r: -r["deviation"]) # least diverse
83
+ elif criterion == "prob":
84
+ # most diverse == least probable; least diverse == most probable
85
+ chosen = min(hi, key=lambda r: r["mean_logprob"])
86
+ rejected = max(lo, key=lambda r: r["mean_logprob"])
87
+ else:
88
+ raise ValueError(criterion)
89
+
90
+ rows.append({
91
+ "prompt_id": pid, "prompt": chosen["prompt"],
92
+ "chosen": chosen["text"], "rejected": rejected["text"],
93
+ "weight": 1.0, # DivPO is unweighted, by design
94
+ "chosen_quality": chosen["quality"], "chosen_dev": chosen["deviation"],
95
+ "rejected_quality": rejected["quality"], "rejected_dev": rejected["deviation"],
96
+ "chosen_meanlp": chosen["mean_logprob"], "rejected_meanlp": rejected["mean_logprob"],
97
+ })
98
+ skipped["ok"] += 1
99
+
100
+ n = len(pool)
101
+ stats = {"criterion": f"divpo-{criterion}", "rho": rho, "n_prompts": n,
102
+ "n_rows": len(rows), **skipped,
103
+ "skip_rate": 1 - skipped["ok"] / max(1, n)}
104
+ return rows, stats
105
+
106
+
107
+ # --------------------------------------------------------------------- E3
108
+ def build_multipos(pool: dict[str, list[dict]], emb: np.ndarray, row_index: dict,
109
+ q_keep: float, k: int, lam: float) -> tuple[list[dict], dict]:
110
+ from diversity import greedy_diverse_subset
111
+
112
+ rows = []
113
+ stats = {"n_prompts": len(pool), "skipped_no_survivors": 0,
114
+ "skipped_no_negatives": 0, "ok": 0, "chosen_per_prompt": []}
115
+
116
+ for pid, items in pool.items():
117
+ survivors = [r for r in items if r["gate_passed"] and r["quality"] >= q_keep]
118
+ if len(survivors) < 2:
119
+ stats["skipped_no_survivors"] += 1; continue
120
+
121
+ # r_Q: clearly low quality (worst overall, gate failures included)
122
+ r_Q = min(items, key=lambda r: (r["gate_passed"], r["quality"]))
123
+ # r_D: the "competent cliche" -- high quality but LOW deviation.
124
+ # Rank by (quality - deviation) so we favour a strong, conventional
125
+ # story rather than merely the least diverse one.
126
+ r_D = max(survivors, key=lambda r: r["quality"] - 4.0 * r["deviation"])
127
+
128
+ # r_D is itself a survivor, so it can coincide with a chosen story; that
129
+ # specific pairing is dropped per-row below rather than here, since it
130
+ # only invalidates one row and not the whole prompt.
131
+ negatives = [n for n in (r_D, r_Q) if n["text"]]
132
+ if not negatives:
133
+ stats["skipped_no_negatives"] += 1; continue
134
+
135
+ idxs = [row_index[(pid, r["idx"])] for r in survivors]
136
+ E = emb[idxs].astype(np.float64)
137
+ q = np.array([r["quality"] for r in survivors], dtype=np.float64)
138
+ sel = greedy_diverse_subset(q, E, k=min(k, len(survivors)), lam=lam)
139
+ chosen_set = [survivors[i] for i in sel]
140
+ stats["chosen_per_prompt"].append(len(chosen_set))
141
+
142
+ for j, ch in enumerate(chosen_set):
143
+ neg = negatives[j % len(negatives)] # rotate, don't hammer one
144
+ if neg["text"] == ch["text"]:
145
+ continue
146
+ rows.append({
147
+ "prompt_id": pid, "prompt": ch["prompt"],
148
+ "chosen": ch["text"], "rejected": neg["text"],
149
+ "weight": float(ch["deviation"]), # DDPO-style loss weight
150
+ "neg_type": "r_D" if neg is r_D else "r_Q",
151
+ "chosen_quality": ch["quality"], "chosen_dev": ch["deviation"],
152
+ "rejected_quality": neg["quality"], "rejected_dev": neg["deviation"],
153
+ })
154
+ stats["ok"] += 1
155
+
156
+ stats["n_rows"] = len(rows)
157
+ stats["mean_chosen_per_prompt"] = float(np.mean(stats["chosen_per_prompt"])) if stats["chosen_per_prompt"] else 0.0
158
+ stats.pop("chosen_per_prompt")
159
+ stats["skip_rate"] = 1 - stats["ok"] / max(1, len(pool))
160
+ return rows, stats
161
+
162
+
163
+ def normalize_weights(rows: list[dict]) -> None:
164
+ """Scale weights to mean 1.0 so the DDPO weighting changes the RELATIVE
165
+ emphasis across rows without also rescaling the effective learning rate."""
166
+ w = np.array([r["weight"] for r in rows], dtype=np.float64)
167
+ if w.size and w.mean() > 1e-9:
168
+ w = w / w.mean()
169
+ for r, x in zip(rows, w):
170
+ r["weight"] = float(x)
171
+
172
+
173
+ def main():
174
+ ap = argparse.ArgumentParser()
175
+ ap.add_argument("--tag", default="4b")
176
+ ap.add_argument("--split", default="train")
177
+ ap.add_argument("--rho", type=float, default=6.0, help="DivPO quality threshold")
178
+ ap.add_argument("--q-keep", type=float, default=5.0, help="E3 survivor threshold")
179
+ ap.add_argument("--k", type=int, default=4, help="E3 chosen-subset size")
180
+ ap.add_argument("--lam", type=float, default=1.0, help="E3 logdet weight")
181
+ ap.add_argument("--max-skip", type=float, default=0.30)
182
+ args = ap.parse_args()
183
+
184
+ import logbook
185
+
186
+ pool = load_pool(args.tag, args.split)
187
+ emb = load_emb(args.tag, args.split)
188
+ row_index = {}
189
+ i = 0
190
+ for pid in pool:
191
+ for r in pool[pid]:
192
+ row_index[(pid, r["idx"])] = i; i += 1
193
+ assert i == emb.shape[0], f"pool/emb mismatch {i} vs {emb.shape[0]}"
194
+
195
+ out = ROOT / "outputs" / f"pairs_{args.tag}"
196
+ out.mkdir(parents=True, exist_ok=True)
197
+ all_stats = {}
198
+
199
+ # ---- E4: DivPO, both criteria ---------------------------------------
200
+ for crit in ("emb", "prob"):
201
+ rho = args.rho
202
+ rows, st = build_divpo(pool, crit, rho)
203
+ if st["skip_rate"] > args.max_skip:
204
+ # One adjustment, as the brief allows, chosen by SWEEP rather than by
205
+ # a fixed percentile. Direction matters and is not knowable a priori:
206
+ # no_chosen dominant -> rho must come DOWN
207
+ # no_rejected dominant -> rho must go UP
208
+ # An earlier version always moved rho down (40th percentile), which
209
+ # is the wrong direction for this pool: the judge puts 88.8% of
210
+ # stories at >= 6, so the binding failure was no_rejected (311 of
211
+ # 1000 prompts had no story below the bar), not no_chosen (3).
212
+ # Sweeping the observed quality levels picks the threshold that
213
+ # actually maximizes usable prompts.
214
+ qs = sorted({r["quality"] for items in pool.values()
215
+ for r in items if r["gate_passed"]})
216
+ cands = [q for q in qs if 3.0 <= q <= 9.0] or [rho]
217
+ best = None
218
+ for c in cands:
219
+ _, s = build_divpo(pool, crit, float(c))
220
+ if best is None or s["skip_rate"] < best[1]["skip_rate"]:
221
+ best = (c, s)
222
+ rows2, st2 = build_divpo(pool, crit, float(best[0]))
223
+ st2["rho_adjusted_from"] = rho
224
+ st2["reason"] = (f"skip_rate {st['skip_rate']:.3f} > {args.max_skip} "
225
+ f"(no_chosen={st['no_chosen']}, "
226
+ f"no_rejected={st['no_rejected']}); swept "
227
+ f"{[float(c) for c in cands]} -> rho={best[0]}")
228
+ rows, st = rows2, st2
229
+ p = out / f"divpo_{crit}_{args.split}.jsonl"
230
+ with open(p, "w") as f:
231
+ for r in rows:
232
+ f.write(json.dumps(r) + "\n")
233
+ all_stats[f"divpo-{crit}"] = st
234
+ print(f"[divpo-{crit}] {json.dumps(st)}")
235
+
236
+ # ---- E3: multi-positive ---------------------------------------------
237
+ rows, st = build_multipos(pool, emb, row_index, args.q_keep, args.k, args.lam)
238
+ normalize_weights(rows)
239
+ st["weight_mean_after_norm"] = float(np.mean([r["weight"] for r in rows])) if rows else 0.0
240
+ st["weight_sd"] = float(np.std([r["weight"] for r in rows])) if rows else 0.0
241
+ from collections import Counter
242
+ st["neg_type_counts"] = dict(Counter(r["neg_type"] for r in rows))
243
+ p = out / f"multipos_{args.split}.jsonl"
244
+ with open(p, "w") as f:
245
+ for r in rows:
246
+ f.write(json.dumps(r) + "\n")
247
+ all_stats["e3-multipos"] = st
248
+ print(f"[e3-multipos] {json.dumps(st)}")
249
+
250
+ json.dump(all_stats, open(out / f"pair_stats_{args.split}.json", "w"), indent=2)
251
+ logbook.note(f"pairs built ({args.tag})",
252
+ f"```json\n{json.dumps(all_stats, indent=1)}\n```")
253
+ return 0
254
+
255
+
256
+ if __name__ == "__main__":
257
+ sys.exit(main())
src/build_pool.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build the scored generation pool that feeds E3 (multi-positive diverse DPO) and
3
+ E4 (faithful DivPO), and doubles as base-model analysis data.
4
+
5
+ Per prompt: N=16 samples at temperature 1.0 from the BASE policy, each carrying
6
+ - text, token count, cumulative + length-normalized logprob (E4 divpo-prob)
7
+ - programmatic gate result
8
+ - judge quality / novelty (gate-passing stories only)
9
+ - embedding, per-group deviation d_i and marginal contribution m_i
10
+
11
+ Both DPO arms consume this identical artifact, which is the point: E4-vs-E3 is
12
+ then a comparison of PAIR CONSTRUCTION and LOSS, with the data held fixed.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+
26
+
27
+ def main():
28
+ ap = argparse.ArgumentParser()
29
+ ap.add_argument("--model", default="Qwen/Qwen3-4B-Instruct-2507")
30
+ ap.add_argument("--tag", default="4b")
31
+ ap.add_argument("--n", type=int, default=16)
32
+ ap.add_argument("--temperature", type=float, default=1.0)
33
+ ap.add_argument("--top-p", type=float, default=1.0)
34
+ ap.add_argument("--limit", type=int, default=None, help="prompt subset, for smoke")
35
+ ap.add_argument("--split", default="train")
36
+ ap.add_argument("--seed", type=int, default=1234)
37
+ ap.add_argument("--gpu-mem", type=float, default=0.85)
38
+ args = ap.parse_args()
39
+
40
+ from transformers import AutoTokenizer
41
+
42
+ import gates
43
+ import logbook
44
+ from data import load_prompts
45
+ from diversity import (l2_normalize, logdet_volume, marginal_contributions,
46
+ pairwise_deviation)
47
+ from generate import build_llm, generate
48
+ from judge import build_judge
49
+
50
+ out_dir = ROOT / "outputs" / f"pool_{args.tag}"
51
+ out_dir.mkdir(parents=True, exist_ok=True)
52
+ pool_path = out_dir / f"pool_{args.split}.jsonl"
53
+
54
+ prompts = load_prompts(args.split, ROOT / "data")
55
+ if args.limit:
56
+ prompts = prompts[: args.limit]
57
+ print(f"[pool] {len(prompts)} prompts x N={args.n} = {len(prompts)*args.n} stories")
58
+
59
+ # ---- 1. generate -----------------------------------------------------
60
+ t0 = time.time()
61
+ tok = AutoTokenizer.from_pretrained(args.model)
62
+ llm = build_llm(args.model, gpu_mem_util=args.gpu_mem, seed=args.seed)
63
+ gens = generate(llm, tok, prompts, n=args.n, temperature=args.temperature,
64
+ top_p=args.top_p, seed=args.seed)
65
+ t_gen = time.time() - t0
66
+ ntok = sum(g.n_tokens for g in gens)
67
+ print(f"[gen] {len(gens)} stories, {ntok} tok in {t_gen/60:.1f} min "
68
+ f"({ntok/t_gen:.0f} tok/s)")
69
+
70
+ # free the GPU before loading the embedder
71
+ del llm
72
+ import gc, torch
73
+ gc.collect(); torch.cuda.empty_cache()
74
+
75
+ # ---- 2. gates --------------------------------------------------------
76
+ grs = [gates.check(g.text, finish_reason=g.finish_reason) for g in gens]
77
+ n_pass = sum(r.passed for r in grs)
78
+ print(f"[gates] pass {n_pass}/{len(grs)} ({100*n_pass/len(grs):.1f}%)")
79
+
80
+ # ---- 3. judge (gate-passers only) ------------------------------------
81
+ judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"),
82
+ concurrency=24)
83
+ idx = [i for i in range(len(gens)) if grs[i].passed]
84
+ t1 = time.time()
85
+ scores = judge.score_many_sync([(gens[i].prompt, gens[i].text) for i in idx])
86
+ print(f"[judge] {len(idx)} scored in {(time.time()-t1)/60:.1f} min | "
87
+ f"health={judge.health()}")
88
+ judge.assert_healthy()
89
+
90
+ quality = np.zeros(len(gens)); novelty = np.zeros(len(gens))
91
+ for i, s in zip(idx, scores):
92
+ quality[i] = s.quality; novelty[i] = s.novelty
93
+
94
+ # ---- 4. embeddings + per-group diversity -----------------------------
95
+ from sentence_transformers import SentenceTransformer
96
+ enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda")
97
+ t2 = time.time()
98
+ E = enc.encode([g.text for g in gens], normalize_embeddings=True,
99
+ batch_size=64, show_progress_bar=False, convert_to_numpy=True)
100
+ E = l2_normalize(np.asarray(E, dtype=np.float64))
101
+ print(f"[embed] {E.shape} in {time.time()-t2:.0f}s")
102
+
103
+ by_prompt: dict[str, list[int]] = {}
104
+ for i, g in enumerate(gens):
105
+ by_prompt.setdefault(g.prompt_id, []).append(i)
106
+
107
+ dev = np.zeros(len(gens)); marg = np.zeros(len(gens))
108
+ group_logdet: dict[str, float] = {}
109
+ for pid, ids in by_prompt.items():
110
+ sub = E[ids]
111
+ d = pairwise_deviation(sub); m = marginal_contributions(sub)
112
+ for k, i in enumerate(ids):
113
+ dev[i] = d[k]; marg[i] = m[k]
114
+ group_logdet[pid] = logdet_volume(sub)
115
+
116
+ # ---- 5. write --------------------------------------------------------
117
+ with open(pool_path, "w") as f:
118
+ for i, g in enumerate(gens):
119
+ f.write(json.dumps({
120
+ **g.as_dict(),
121
+ "gate_passed": bool(grs[i].passed),
122
+ "gate_reasons": grs[i].reasons,
123
+ "ends_cleanly": grs[i].completeness,
124
+ "n_words": grs[i].n_words,
125
+ "quality": float(quality[i]),
126
+ "novelty": float(novelty[i]),
127
+ "deviation": float(dev[i]),
128
+ "marginal": float(marg[i]),
129
+ "group_logdet": float(group_logdet[g.prompt_id]),
130
+ }) + "\n")
131
+ np.save(out_dir / f"emb_{args.split}.npy", E.astype(np.float32))
132
+
133
+ # ---- 6. summary ------------------------------------------------------
134
+ q_pass = quality[[i for i in idx]]
135
+ summary = {
136
+ "tag": args.tag, "model": args.model, "split": args.split,
137
+ "n_prompts": len(prompts), "n_per_prompt": args.n, "n_stories": len(gens),
138
+ "temperature": args.temperature, "seed": args.seed,
139
+ "gate_pass_rate": float(n_pass / len(grs)),
140
+ "ends_cleanly_rate": float(np.mean([r.completeness for r in grs])),
141
+ "median_words": float(np.median([r.n_words for r in grs])),
142
+ "quality_mean": float(q_pass.mean()) if len(q_pass) else 0.0,
143
+ "quality_sd": float(q_pass.std()) if len(q_pass) else 0.0,
144
+ "novelty_mean": float(novelty[idx].mean()) if len(idx) else 0.0,
145
+ "deviation_mean": float(dev.mean()),
146
+ "logdet_mean": float(np.mean(list(group_logdet.values()))),
147
+ "gen_minutes": t_gen / 60,
148
+ "judge_cost": judge.cost_estimate(0.140, 0.280),
149
+ "judge_health": judge.health(),
150
+ }
151
+ json.dump(summary, open(out_dir / f"summary_{args.split}.json", "w"), indent=2)
152
+ print("\n" + json.dumps(summary, indent=1))
153
+
154
+ logbook.note(f"pool built: {args.tag}/{args.split}",
155
+ f"```json\n{json.dumps(summary, indent=1)}\n```")
156
+ logbook.checkpoint(f"pool_{args.tag}_{args.split}")
157
+ return 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ sys.exit(main())
src/calibrate_judge.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Judge calibration. A reward model you have not validated is a random number
3
+ generator with good manners, so this runs before any training.
4
+
5
+ Test set is built from material we already have, with KNOWN ground-truth
6
+ ordering, so we can measure discrimination rather than eyeball plausibility:
7
+
8
+ A. complete -- fresh Qwen3-4B stories that passed every programmatic gate
9
+ B. truncated -- prior-run stories, all cut off mid-word (known bad)
10
+ C. shuffled -- a complete story with its sentences randomly reordered
11
+ (destroys narrative coherence, preserves vocabulary/style)
12
+ D. repetitive -- a complete story with one paragraph repeated to fill it out
13
+
14
+ C and D matter most. Any judge can tell prose from garbage; the question is
15
+ whether it tracks COHERENCE, which is what quality is supposed to mean here. A
16
+ judge that scores C and D as highly as A is measuring surface fluency, and
17
+ would happily pay a policy to produce fluent incoherent text.
18
+
19
+ Pass criteria:
20
+ quality(A) - quality(B) >= 2.0 detects truncation
21
+ quality(A) - quality(C) >= 1.5 detects incoherence, not just fluency
22
+ quality(A) - quality(D) >= 1.5 detects padding/repetition
23
+ sd(quality within A) >= 0.7 not saturated/constant
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import glob
28
+ import json
29
+ import random
30
+ import re
31
+ import statistics
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ import gates
36
+ import logbook
37
+ from judge import build_judge
38
+
39
+ ROOT = Path(__file__).resolve().parent.parent
40
+ PRIOR = ROOT / "prior_run" / "eval_samples"
41
+ N_PER_CELL = 12
42
+ SEED = 11
43
+
44
+ PRICE_IN, PRICE_OUT = 0.140, 0.280 # deepseek-v4-flash-0731, $/1M tok
45
+
46
+
47
+ def sentence_shuffle(text: str, rng: random.Random) -> str:
48
+ sents = re.split(r"(?<=[.!?])\s+", text.strip())
49
+ if len(sents) < 4:
50
+ return text
51
+ rng.shuffle(sents)
52
+ return " ".join(sents)
53
+
54
+
55
+ def hard_truncate(text: str, frac: float = 0.68) -> str:
56
+ """Cut mid-word at `frac` of the way through -- the exact signature of a
57
+ max_new_tokens wall. Applied to cell A's OWN stories so the comparison
58
+ isolates truncation with style, voice and model held fixed."""
59
+ w = text.split()
60
+ cut = " ".join(w[: max(20, int(len(w) * frac))])
61
+ return cut[: max(1, len(cut) - 3)] # shave into the final word
62
+
63
+
64
+ def paragraph_pad(text: str) -> str:
65
+ paras = [p for p in text.split("\n\n") if p.strip()]
66
+ if not paras:
67
+ return text
68
+ return "\n\n".join(paras[:2] + [paras[0]] * 3)
69
+
70
+
71
+ def main():
72
+ rng = random.Random(SEED)
73
+ from generate import load_gens
74
+
75
+ smoke = ROOT / "outputs" / "smoke" / "vllm_smoke.jsonl"
76
+ if not smoke.exists():
77
+ print("need outputs/smoke/vllm_smoke.jsonl (run smoke_vllm.py first)")
78
+ return 2
79
+
80
+ gens = load_gens(smoke)
81
+ good = [g for g in gens if gates.check(g.text, finish_reason=g.finish_reason).passed]
82
+ if len(good) < N_PER_CELL:
83
+ print(f"only {len(good)} gate-passing stories; need {N_PER_CELL}")
84
+ return 2
85
+ rng.shuffle(good)
86
+ A = good[:N_PER_CELL]
87
+
88
+ prior_texts = []
89
+ for f in sorted(glob.glob(str(PRIOR / "step_*.json"))):
90
+ for p, samples in json.load(open(f)).items():
91
+ for s in samples:
92
+ if not gates.ends_cleanly(s):
93
+ prior_texts.append((p.split("\n\n")[-1].strip(), s))
94
+ rng.shuffle(prior_texts)
95
+ B = prior_texts[:N_PER_CELL]
96
+
97
+ cells = {
98
+ "A_complete": [(g.prompt, g.text) for g in A],
99
+ "B_cut_matched": [(g.prompt, hard_truncate(g.text)) for g in A],
100
+ "C_shuffled": [(g.prompt, sentence_shuffle(g.text, rng)) for g in A],
101
+ "D_repetitive": [(g.prompt, paragraph_pad(g.text)) for g in A],
102
+ "E_cut_priorrun": B, # informational only: different policy AND style
103
+ }
104
+
105
+ judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=8)
106
+ print(f"judge model: {judge.model}\n")
107
+
108
+ results = {}
109
+ for name, pairs in cells.items():
110
+ scores = judge.score_many_sync(pairs)
111
+ q = [s.quality for s in scores]
112
+ nv = [s.novelty for s in scores]
113
+ ok = sum(s.ok for s in scores)
114
+ results[name] = {
115
+ "cell": name, "n": len(q), "ok_calls": ok,
116
+ "quality_mean": statistics.fmean(q),
117
+ "quality_sd": statistics.pstdev(q) if len(q) > 1 else 0.0,
118
+ "quality_min": min(q), "quality_max": max(q),
119
+ "novelty_mean": statistics.fmean(nv),
120
+ "novelty_sd": statistics.pstdev(nv) if len(nv) > 1 else 0.0,
121
+ }
122
+ print(f"{name:14} q={results[name]['quality_mean']:.2f}"
123
+ f" (sd {results[name]['quality_sd']:.2f},"
124
+ f" {results[name]['quality_min']:.0f}-{results[name]['quality_max']:.0f})"
125
+ f" nov={results[name]['novelty_mean']:.2f}"
126
+ f" (sd {results[name]['novelty_sd']:.2f}) ok={ok}/{len(q)}")
127
+
128
+ qa = results["A_complete"]["quality_mean"]
129
+ # BLOCKING checks: these test properties no other component can supply.
130
+ checks = [
131
+ ("detects incoherence A-C >= 1.5", qa - results["C_shuffled"]["quality_mean"], 1.5),
132
+ ("detects repetition A-D >= 1.5", qa - results["D_repetitive"]["quality_mean"], 1.5),
133
+ ("not saturated sd(A) >= 0.7", results["A_complete"]["quality_sd"], 0.7),
134
+ ]
135
+ # INFORMATIONAL: truncation is already caught deterministically, with 100%
136
+ # recall, by gates.check() (finish_reason=="length" OR no terminal
137
+ # punctuation). RewardEngine.compute() judges ONLY gate-passing stories, so
138
+ # a truncated story never reaches the judge during training and the judge's
139
+ # truncation sensitivity cannot influence any gradient. Kept as a monitored
140
+ # number, not a gate on proceeding.
141
+ info = [
142
+ ("detects truncation A-B (informational)",
143
+ qa - results["B_cut_matched"]["quality_mean"], 2.0),
144
+ ("style confound A-E (informational)",
145
+ qa - results["E_cut_priorrun"]["quality_mean"], 0.0),
146
+ ]
147
+ print("\n=== DISCRIMINATION (blocking) ===")
148
+ allpass = True
149
+ for label, val, thresh in checks:
150
+ ok = val >= thresh
151
+ allpass &= ok
152
+ print(f" {'PASS' if ok else 'FAIL'} {label:34} got {val:+.2f}")
153
+ print("=== informational (not blocking) ===")
154
+ for label, val, thresh in info:
155
+ print(f" {'ok ' if val >= thresh else 'note'} {label:40} got {val:+.2f}")
156
+ checks = checks + info
157
+
158
+ cost = judge.cost_estimate(PRICE_IN, PRICE_OUT)
159
+ health = judge.health()
160
+ print(f"\njudge health: {health}")
161
+ print(f"cost so far: {cost}")
162
+ if health["fail_rate"] > 0.05:
163
+ print("\n!!! JUDGE UNHEALTHY -- scores above are mostly neutral fallbacks, "
164
+ "discrimination numbers are meaningless. Fix the backend first.")
165
+ allpass = False
166
+
167
+ body = f"""# Judge calibration — `{judge.model}`
168
+
169
+ A reward model that has not been validated is a random number generator with
170
+ good manners. This is the pre-flight check, run before any training.
171
+
172
+ ## Method
173
+
174
+ Four cells, 12 stories each, with known ground-truth ordering:
175
+
176
+ - **A_complete** — fresh Qwen3-4B stories passing every programmatic gate.
177
+ - **B_cut_matched** — cell A's OWN stories, cut mid-word at 68% length. This is
178
+ the controlled truncation test: same model, same voice, same prompt, so the
179
+ only variable is finishedness.
180
+ - **E_cut_priorrun** — prior-run stories, also truncated. Informational only:
181
+ they come from a different (RL-tuned, far more florid) policy, so A-vs-E
182
+ confounds truncation with style and cannot be read as a truncation test.
183
+ - **C_shuffled** — cell A with sentences randomly reordered. Destroys narrative
184
+ coherence while preserving vocabulary, register and sentence-level fluency.
185
+ - **D_repetitive** — cell A with one paragraph repeated to pad the length.
186
+
187
+ C and D carry the weight. Any judge separates prose from garbage; the real
188
+ question is whether it tracks *coherence* rather than surface fluency. A judge
189
+ that rates C as highly as A would pay a policy to emit fluent incoherent text —
190
+ exactly the reward-hacking channel the diversity bonus could exploit.
191
+
192
+ ## Results
193
+
194
+ {logbook.table(list(results.values()))}
195
+
196
+ ## Discrimination checks
197
+
198
+ {logbook.table([{"check": l, "value": round(v, 3), "threshold": t,
199
+ "verdict": "PASS" if v >= t else "FAIL"} for l, v, t in checks])}
200
+
201
+ **Verdict: {"USABLE" if allpass else "NOT USABLE AS-IS"}**
202
+
203
+ ## Judge health
204
+
205
+ ```json
206
+ {json.dumps(health, indent=1)}
207
+ ```
208
+
209
+ ## Cost accounting
210
+
211
+ ```json
212
+ {json.dumps(cost, indent=1)}
213
+ ```
214
+ """
215
+ p = logbook.write_report("01_judge_calibration", body)
216
+ print("report ->", p)
217
+ json.dump({"results": results,
218
+ "checks": [{"c": l, "v": v, "t": t} for l, v, t in checks],
219
+ "cost": cost, "model": judge.model},
220
+ open(logbook.LOGS / "judge_calibration.json", "w"), indent=2)
221
+ logbook.note("judge calibration",
222
+ f"model={judge.model} verdict={'USABLE' if allpass else 'FAIL'}; "
223
+ f"A={qa:.2f} B={results['B_cut_matched']['quality_mean']:.2f} "
224
+ f"C={results['C_shuffled']['quality_mean']:.2f} "
225
+ f"D={results['D_repetitive']['quality_mean']:.2f}")
226
+ return 0 if allpass else 1
227
+
228
+
229
+ if __name__ == "__main__":
230
+ sys.exit(main())
src/ckpt_study.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Checkpoint trajectory study: generate the SAME prompts from every checkpoint of
3
+ an arm, so the evolution of the actual stories is visible -- not just metrics.
4
+
5
+ Aggregate numbers can report "effective rank 2.0" without conveying that six of
6
+ sixteen ships are named *Aethel*. This dumps the stories in a readable form at
7
+ each training step alongside the metrics, so the qualitative change can be read
8
+ directly and cross-checked against the quantitative one.
9
+
10
+ vLLM loads the base model ONCE and hot-swaps LoRA adapters per checkpoint, so
11
+ the whole sweep costs one model load rather than one per checkpoint.
12
+
13
+ Outputs (under outputs/ckpt_study/<arm>/):
14
+ stories.md human-readable: every prompt, every checkpoint, side by side
15
+ metrics.csv per-checkpoint quantitative trajectory
16
+ raw.json everything, for re-analysis
17
+ ../logs/figures/<arm>_trajectory.png
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import csv
23
+ import json
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+
30
+ ROOT = Path(__file__).resolve().parent.parent
31
+
32
+
33
+ def find_checkpoints(arm: str) -> list[tuple[int, str | None]]:
34
+ """[(step, adapter_path_or_None)] ascending; step 0 = base model."""
35
+ d = ROOT / "outputs" / arm
36
+ out: list[tuple[int, str | None]] = [(0, None)]
37
+ if d.exists():
38
+ for p in d.glob("checkpoint-*"):
39
+ m = re.search(r"checkpoint-(\d+)", p.name)
40
+ if m and (p / "adapter_model.safetensors").exists():
41
+ out.append((int(m.group(1)), str(p)))
42
+ f = d / "final"
43
+ if (f / "adapter_model.safetensors").exists():
44
+ steps = [s for s, _ in out]
45
+ out.append((max(steps) + 1 if steps else 1, str(f)))
46
+ return sorted(out, key=lambda t: t[0])
47
+
48
+
49
+ def main():
50
+ ap = argparse.ArgumentParser()
51
+ ap.add_argument("--arm", required=True)
52
+ ap.add_argument("--model", default="Qwen/Qwen3-4B-Instruct-2507")
53
+ ap.add_argument("--prompts", type=int, default=10)
54
+ ap.add_argument("--n", type=int, default=6)
55
+ ap.add_argument("--temp", type=float, default=0.9)
56
+ ap.add_argument("--top-p", type=float, default=0.95)
57
+ ap.add_argument("--seed", type=int, default=777)
58
+ ap.add_argument("--gpu-mem", type=float, default=0.85)
59
+ ap.add_argument("--judge", action="store_true", default=True)
60
+ args = ap.parse_args()
61
+
62
+ from transformers import AutoTokenizer
63
+ from vllm import SamplingParams
64
+ from vllm.lora.request import LoRARequest
65
+
66
+ import gates
67
+ import logbook
68
+ from data import load_prompts
69
+ from diversity import effective_rank, l2_normalize, logdet_volume, pairwise_deviation
70
+ from generate import build_llm, render_chat
71
+ from judge import build_judge
72
+ from qualitative import analyze_group, first_sentence, last_sentence
73
+
74
+ ckpts = find_checkpoints(args.arm)
75
+ if len(ckpts) < 2:
76
+ print(f"only {len(ckpts)} checkpoint(s) for {args.arm}; nothing to compare")
77
+ return 2
78
+ print(f"[{args.arm}] checkpoints: {[s for s, _ in ckpts]}")
79
+
80
+ prompts = load_prompts("eval", ROOT / "data")[: args.prompts]
81
+ tok = AutoTokenizer.from_pretrained(args.model)
82
+ llm = build_llm(args.model, gpu_mem_util=args.gpu_mem, seed=args.seed,
83
+ enable_lora=True)
84
+ rendered = [render_chat(tok, p["prompt"]) for p in prompts]
85
+ sp = SamplingParams(n=args.n, temperature=args.temp, top_p=args.top_p,
86
+ max_tokens=1024, seed=args.seed, skip_special_tokens=True)
87
+
88
+ from sentence_transformers import SentenceTransformer
89
+ enc = None
90
+ judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=24) \
91
+ if args.judge else None
92
+
93
+ all_data, rowsum = {}, []
94
+ for step, path in ckpts:
95
+ kw = {}
96
+ if path:
97
+ kw["lora_request"] = LoRARequest(f"{args.arm}-{step}", max(step, 1), path)
98
+ outs = llm.generate(rendered, sp, **kw)
99
+ per = {}
100
+ for p, o in zip(prompts, outs):
101
+ texts = [x.text.strip() for x in o.outputs]
102
+ frs = [x.finish_reason or "" for x in o.outputs]
103
+ per[p["id"]] = {"prompt": p["prompt"], "texts": texts,
104
+ "gates": [gates.check(t, finish_reason=f).as_dict()
105
+ for t, f in zip(texts, frs)]}
106
+ all_data[step] = per
107
+ print(f" step {step:>4}: generated {sum(len(v['texts']) for v in per.values())} stories",
108
+ flush=True)
109
+
110
+ del llm
111
+ import gc, torch
112
+ gc.collect(); torch.cuda.empty_cache()
113
+ enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda")
114
+
115
+ for step, per in all_data.items():
116
+ dev, ld, er, q = [], [], [], []
117
+ pooled_texts = []
118
+ for pid, v in per.items():
119
+ E = l2_normalize(np.asarray(enc.encode(
120
+ v["texts"], normalize_embeddings=True, show_progress_bar=False,
121
+ convert_to_numpy=True), dtype=np.float64))
122
+ v["eff_rank"] = float(effective_rank(E))
123
+ v["deviation"] = float(pairwise_deviation(E).mean())
124
+ v["logdet"] = float(logdet_volume(E))
125
+ v["qual"] = analyze_group(v["texts"])
126
+ dev.append(v["deviation"]); ld.append(v["logdet"]); er.append(v["eff_rank"])
127
+ pooled_texts += [(v["prompt"], t, g["passed"])
128
+ for t, g in zip(v["texts"], v["gates"])]
129
+ if judge:
130
+ idx = [i for i, (_, _, ok) in enumerate(pooled_texts) if ok]
131
+ sc = judge.score_many_sync([(pooled_texts[i][0], pooled_texts[i][1]) for i in idx])
132
+ q = [s.quality for s in sc]
133
+ gp = float(np.mean([g["passed"] for v in per.values() for g in v["gates"]]))
134
+ ec = float(np.mean([g["completeness"] for v in per.values() for g in v["gates"]]))
135
+ wd = float(np.mean([g["n_words"] for v in per.values() for g in v["gates"]]))
136
+ rowsum.append({
137
+ "step": step, "quality": float(np.mean(q)) if q else 0.0,
138
+ "eff_rank": float(np.mean(er)), "deviation": float(np.mean(dev)),
139
+ "logdet": float(np.mean(ld)), "gate_pass": gp, "ends_cleanly": ec,
140
+ "words": wd,
141
+ "opens_with_The": float(np.mean([v["qual"]["opens_with_The"] / v["qual"]["n"]
142
+ for v in per.values()])),
143
+ "distinct_openers": float(np.mean([v["qual"]["distinct_first_5_words"] / v["qual"]["n"]
144
+ for v in per.values()])),
145
+ "registers": float(np.mean([v["qual"]["registers_present"] for v in per.values()])),
146
+ })
147
+ print(f" step {step:>4}: q={rowsum[-1]['quality']:.2f} "
148
+ f"eff_rank={rowsum[-1]['eff_rank']:.3f} dev={rowsum[-1]['deviation']:.4f} "
149
+ f"words={wd:.0f}", flush=True)
150
+
151
+ out = ROOT / "outputs" / "ckpt_study" / args.arm
152
+ out.mkdir(parents=True, exist_ok=True)
153
+ with open(out / "metrics.csv", "w", newline="") as f:
154
+ w = csv.DictWriter(f, fieldnames=list(rowsum[0].keys()))
155
+ w.writeheader(); w.writerows(rowsum)
156
+ json.dump(all_data, open(out / "raw.json", "w"), indent=1)
157
+
158
+ # ---- human-readable side-by-side --------------------------------------
159
+ steps = [s for s, _ in ckpts]
160
+ md = [f"# {args.arm} — story trajectory across checkpoints\n",
161
+ f"{len(prompts)} eval prompts x {args.n} samples, T={args.temp}, "
162
+ f"top_p={args.top_p}, seed={args.seed} (fixed across checkpoints).\n",
163
+ "Step 0 = base model.\n"]
164
+ for pid in list(all_data[steps[0]]):
165
+ md.append(f"\n## {pid}\n\n> {all_data[steps[0]][pid]['prompt']}\n")
166
+ for s in steps:
167
+ v = all_data[s][pid]
168
+ md.append(f"\n### step {s} — eff_rank {v['eff_rank']:.2f}, "
169
+ f"dev {v['deviation']:.3f}\n")
170
+ md.append("\n**openings**\n")
171
+ for i, t in enumerate(v["texts"]):
172
+ md.append(f"{i+1}. {first_sentence(t, 150)}\n")
173
+ md.append("\n**closings**\n")
174
+ for i, t in enumerate(v["texts"]):
175
+ md.append(f"{i+1}. …{last_sentence(t, 110)}\n")
176
+ (out / "stories.md").write_text("".join(md))
177
+
178
+ # ---- figure -----------------------------------------------------------
179
+ import matplotlib; matplotlib.use("Agg")
180
+ import matplotlib.pyplot as plt
181
+ x = [r["step"] for r in rowsum]
182
+ fig, ax = plt.subplots(1, 4, figsize=(19, 4.2))
183
+ # Anchor the axes that have a meaningful absolute scale. Auto-scaling a
184
+ # metric that moved 1.60->1.69 against a ceiling of N renders a dramatic
185
+ # line for a flat result, which is exactly the misreading to avoid.
186
+ for a, (k, t, c) in zip(ax, [("quality", "Judge quality (0-10)", "#2980b9"),
187
+ ("eff_rank", "Effective rank (1 = collapsed, %d = max)" % args.n, "#8e44ad"),
188
+ ("deviation", "Mean pairwise deviation", "#16a085"),
189
+ ("words", "Story length (words)", "#7f8c8d")]):
190
+ vals = [r[k] for r in rowsum]
191
+ a.plot(x, vals, "o-", color=c, lw=2)
192
+ if k == "eff_rank":
193
+ a.set_ylim(1.0, args.n) # full meaningful range
194
+ a.axhline(1.0, ls=":", c="crimson", lw=1)
195
+ a.text(x[0], 1.05, "total collapse", fontsize=7, color="crimson")
196
+ elif k == "quality":
197
+ a.set_ylim(0, 10)
198
+ elif k == "deviation":
199
+ a.set_ylim(0, max(0.5, max(vals) * 1.3))
200
+ a.set_title(t, fontsize=10); a.set_xlabel("training step"); a.grid(alpha=.3)
201
+ fig.suptitle(f"{args.arm}: what happens to the stories during training", fontsize=13)
202
+ plt.tight_layout()
203
+ figp = logbook.FIGS / f"{args.arm}_trajectory.png"
204
+ plt.savefig(figp, dpi=140); plt.close()
205
+
206
+ print(f"\nstories -> {out/'stories.md'}")
207
+ print(f"metrics -> {out/'metrics.csv'}")
208
+ print(f"figure -> {figp}")
209
+ if judge:
210
+ print("judge:", judge.health(), judge.cost_estimate(0.140, 0.280))
211
+ return 0
212
+
213
+
214
+ if __name__ == "__main__":
215
+ sys.exit(main())
src/data.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WritingPrompts data prep. Deterministic and seeded: every arm sees the exact
3
+ same train split and the exact same 50 held-out eval prompts.
4
+
5
+ On the SYSTEM PROMPT (rewritten from the prior run)
6
+ ---------------------------------------------------
7
+ The prior run used, with a ~512-token cap and no length guidance:
8
+
9
+ "Write a short creative story based on this prompt:\\n\\n{text}"
10
+
11
+ Result: 100% of eval samples truncated mid-word. The model had no budget to
12
+ plan an ending, so it never planned one. Since the judge rubric caps quality at
13
+ <=3 for unfinished stories, quality became near-constant within every group ->
14
+ zero GRPO advantage -> the quality signal was dead for the whole run.
15
+
16
+ The fix is a word budget + an explicit ending instruction + a raised token cap.
17
+
18
+ What the system prompt deliberately does NOT do: it says nothing about being
19
+ original, varied, surprising, or avoiding cliche. That would be a confound.
20
+ Mode collapse is the dependent variable; instructing the model to diversify
21
+ would mask exactly the effect every arm is being measured on. The prompt is
22
+ identical and neutral for base, E0, E1, E2, E3 and E4 -- all differences
23
+ between arms must come from the training objective, not the prompt.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import re
31
+ from pathlib import Path
32
+
33
+ from datasets import load_dataset
34
+
35
+ SYSTEM_PROMPT = (
36
+ "You are a fiction writer. Write a complete short story of 200-500 words "
37
+ "responding to the writing prompt.\n"
38
+ "Write only the story: no title, no preamble, no commentary, no author's note.\n"
39
+ "Finish inside the word budget. The story must reach a real ending, not stop mid-scene."
40
+ )
41
+
42
+ MIN_PROMPT_WORDS = 10
43
+ MAX_PROMPT_WORDS = 60
44
+ SEED = 42
45
+
46
+ _TAG = re.compile(r"^\s*\[\s*(WP|EU|CW|TT|RF|IP|PI|PM|MP|OT|SP|FF)\s*\]\s*", re.I)
47
+ _WS = re.compile(r"\s+")
48
+
49
+
50
+ def clean_prompt(text: str) -> str:
51
+ """Strip the subreddit tag and normalize the mangled WritingPrompts spacing.
52
+
53
+ The euclaise mirror is tokenizer-detokenized: "did n't phase us ." etc.
54
+ Left as-is this leaks a distinctive artifact into every prompt and wastes
55
+ judge tokens, so we repair the obvious cases.
56
+ """
57
+ t = _TAG.sub("", text or "").strip()
58
+ t = t.replace("`` ", '"').replace(" ''", '"').replace("''", '"')
59
+ t = re.sub(r"([“‘(\[])\s+", r"\1", t) # "“ What" -> "“What"
60
+ t = re.sub(r"\s+([”’)\]])", r"\1", t) # "cockroach! ”" -> "cockroach!”"
61
+ t = re.sub(r"\s+([,.!?;:])", r"\1", t)
62
+ t = re.sub(r"\bn't\b", "n't", t)
63
+ t = re.sub(r"\s+n't", "n't", t)
64
+ t = re.sub(r"\s+'(s|re|ve|ll|d|m)\b", r"'\1", t)
65
+ t = re.sub(r"<\s*newline\s*>", " ", t, flags=re.I)
66
+ t = _WS.sub(" ", t).strip()
67
+ return t
68
+
69
+
70
+ def _key(t: str) -> str:
71
+ """Dedupe key: lowercase alphanumerics only, so punctuation/spacing
72
+ variants of the same prompt collapse together."""
73
+ return hashlib.sha1(re.sub(r"[^a-z0-9]", "", t.lower()).encode()).hexdigest()
74
+
75
+
76
+ def build_splits(
77
+ n_train: int = 1000,
78
+ n_eval: int = 50,
79
+ out_dir: str | Path = "data",
80
+ seed: int = SEED,
81
+ ) -> dict:
82
+ ds = load_dataset("euclaise/writingprompts", split="train")
83
+ col = "prompt" if "prompt" in ds.column_names else ds.column_names[0]
84
+
85
+ seen: set[str] = set()
86
+ kept: list[str] = []
87
+ for rec in ds:
88
+ t = clean_prompt(rec[col])
89
+ n = len(t.split())
90
+ if not (MIN_PROMPT_WORDS <= n <= MAX_PROMPT_WORDS):
91
+ continue
92
+ k = _key(t)
93
+ if k in seen:
94
+ continue
95
+ seen.add(k)
96
+ kept.append(t)
97
+ if len(kept) >= (n_train + n_eval) * 3: # oversample, then shuffle
98
+ break
99
+
100
+ import random
101
+ random.Random(seed).shuffle(kept)
102
+ need = n_train + n_eval
103
+ if len(kept) < need:
104
+ raise RuntimeError(f"only {len(kept)} usable prompts, need {need}")
105
+ sel = kept[:need]
106
+ eval_prompts, train_prompts = sel[:n_eval], sel[n_eval:need]
107
+
108
+ out = Path(out_dir)
109
+ out.mkdir(parents=True, exist_ok=True)
110
+ for name, rows in (("train", train_prompts), ("eval", eval_prompts)):
111
+ p = out / f"{name}_prompts.jsonl"
112
+ with open(p, "w") as f:
113
+ for i, t in enumerate(rows):
114
+ f.write(json.dumps({"id": f"{name}-{i:04d}", "prompt": t}) + "\n")
115
+
116
+ meta = {
117
+ "seed": seed, "n_train": len(train_prompts), "n_eval": len(eval_prompts),
118
+ "total_scanned": len(ds), "usable_after_filter": len(kept),
119
+ "min_words": MIN_PROMPT_WORDS, "max_words": MAX_PROMPT_WORDS,
120
+ "system_prompt": SYSTEM_PROMPT,
121
+ }
122
+ (out / "split_meta.json").write_text(json.dumps(meta, indent=2))
123
+ return meta
124
+
125
+
126
+ def load_prompts(split: str, out_dir: str | Path = "data") -> list[dict]:
127
+ p = Path(out_dir) / f"{split}_prompts.jsonl"
128
+ return [json.loads(l) for l in open(p) if l.strip()]
129
+
130
+
131
+ def chat_messages(prompt: str) -> list[dict]:
132
+ return [
133
+ {"role": "system", "content": SYSTEM_PROMPT},
134
+ {"role": "user", "content": prompt},
135
+ ]
136
+
137
+
138
+ if __name__ == "__main__":
139
+ m = build_splits()
140
+ print(json.dumps(m, indent=2))
141
+ for split in ("train", "eval"):
142
+ rows = load_prompts(split)
143
+ print(f"\n{split}: {len(rows)}")
144
+ for r in rows[:3]:
145
+ print(" ", r["id"], "|", r["prompt"][:110])
src/diversity.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Diversity math over an embedding group. Pure numpy, no torch.
3
+
4
+ Given L2-normalized embeddings E = [e_1..e_G] (shape G x d) for ONE prompt's
5
+ generation group, we expose two families of per-sample diversity credit:
6
+
7
+ 1. deviation d_i = mean_{j!=i} (1 - cos(e_i, e_j))
8
+ -- pairwise, cheap, but blind to cluster structure:
9
+ two tight far-apart clusters score as high as a spread.
10
+
11
+ 2. marginal m_i = logdet(L) - logdet(L_{-i})
12
+ contribution -- leave-one-out volume credit under the cosine kernel.
13
+ A duplicate contributes ~nothing (its direction is already
14
+ spanned), so this DOES see cluster structure.
15
+
16
+ Both are per-sample. This is deliberate: a set-level scalar (e.g. logdet(L)
17
+ itself) is constant within a GRPO group, so its within-group std is 0 and the
18
+ normalized advantage is identically 0. It cannot train anything. Every
19
+ quantity here varies across i within a group.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import numpy as np
25
+
26
+ EPS_JITTER = 1e-3
27
+
28
+
29
+ def l2_normalize(E: np.ndarray, axis: int = -1) -> np.ndarray:
30
+ """Row-normalize; zero rows are left as zeros rather than NaN."""
31
+ E = np.asarray(E, dtype=np.float64)
32
+ n = np.linalg.norm(E, axis=axis, keepdims=True)
33
+ return E / np.maximum(n, 1e-12)
34
+
35
+
36
+ def cosine_kernel(E: np.ndarray, eps: float = EPS_JITTER) -> np.ndarray:
37
+ """L = E E^T + eps*I.
38
+
39
+ E must be L2-normalized, so diag(E E^T) = 1 and L is a correlation-like
40
+ PSD matrix. The eps jitter keeps logdet finite when rows are collinear
41
+ (exact duplicates make E E^T singular).
42
+ """
43
+ E = np.asarray(E, dtype=np.float64)
44
+ L = E @ E.T
45
+ L = 0.5 * (L + L.T) # kill float asymmetry before Cholesky
46
+ return L + eps * np.eye(L.shape[0], dtype=np.float64)
47
+
48
+
49
+ def pairwise_deviation(E: np.ndarray) -> np.ndarray:
50
+ """d_i = mean_{j != i} (1 - cos(e_i, e_j)). Shape (G,).
51
+
52
+ G == 1 has no off-diagonal terms; we return 0.0 (a lone sample has no
53
+ measurable deviation, and 0 is the neutral value for the reward).
54
+ """
55
+ E = np.asarray(E, dtype=np.float64)
56
+ G = E.shape[0]
57
+ if G < 2:
58
+ return np.zeros(G, dtype=np.float64)
59
+ S = E @ E.T
60
+ D = 1.0 - S
61
+ np.fill_diagonal(D, 0.0)
62
+ return D.sum(axis=1) / (G - 1)
63
+
64
+
65
+ def _logdet_psd(L: np.ndarray) -> float:
66
+ """logdet via Cholesky; falls back to slogdet if L drifts non-PD."""
67
+ try:
68
+ c = np.linalg.cholesky(L)
69
+ return float(2.0 * np.sum(np.log(np.diag(c))))
70
+ except np.linalg.LinAlgError:
71
+ sign, ld = np.linalg.slogdet(L)
72
+ if sign <= 0:
73
+ return float("-inf")
74
+ return float(ld)
75
+
76
+
77
+ def logdet_volume(E: np.ndarray, eps: float = EPS_JITTER) -> float:
78
+ """D(Y) = logdet(E E^T + eps I). Set-level scalar.
79
+
80
+ Report this as a METRIC. Never hand it to GRPO as a per-sample reward:
81
+ it is identical for every i in the group -> zero advantage.
82
+ """
83
+ if np.asarray(E).shape[0] == 0:
84
+ return 0.0
85
+ return _logdet_psd(cosine_kernel(E, eps))
86
+
87
+
88
+ def marginal_contributions(E: np.ndarray, eps: float = EPS_JITTER) -> np.ndarray:
89
+ """m_i = logdet(L) - logdet(L_{-i}), shape (G,).
90
+
91
+ Computed by G explicit leave-one-out logdets. G <= 16 here, so this is
92
+ ~16 Cholesky calls on a 15x15 matrix -- utterly negligible next to a
93
+ single LLM forward pass. Rank-one downdate machinery would be a
94
+ micro-optimization with real numerical-stability downside; not worth it.
95
+
96
+ Interpretation: m_i is the log-volume the group loses by dropping i.
97
+ A duplicate has m_i ~ log(eps) -> large negative. An orthogonal direction
98
+ has m_i ~ log(1+eps) ~ 0. So m_i is a *penalty scale*: higher (closer to
99
+ 0) means "this sample carries a direction nothing else covers".
100
+ """
101
+ E = np.asarray(E, dtype=np.float64)
102
+ G = E.shape[0]
103
+ if G < 2:
104
+ return np.zeros(G, dtype=np.float64)
105
+ L = cosine_kernel(E, eps)
106
+ full = _logdet_psd(L)
107
+ out = np.empty(G, dtype=np.float64)
108
+ idx = np.arange(G)
109
+ for i in range(G):
110
+ keep = idx[idx != i]
111
+ out[i] = full - _logdet_psd(L[np.ix_(keep, keep)])
112
+ return out
113
+
114
+
115
+ def effective_rank(E: np.ndarray, eps: float = 1e-12) -> float:
116
+ """exp(Shannon entropy of the normalized Gram spectrum). Roy & Vetterli.
117
+
118
+ Reads as "how many distinct directions does this set effectively span":
119
+ 1.0 for identical stories, G for mutually orthogonal ones, ~2 for two tight
120
+ clusters. Reported as a METRIC, never used as a reward (it is set-level, so
121
+ it is constant within a group and would produce zero advantage).
122
+
123
+ This exists because silhouette-selected k-means turned out to be unusable as
124
+ a mode counter on this data: a fully collapsed set and a fully spread set
125
+ both peak near silhouette 0.20 (at k=4 and k=7 respectively), because
126
+ k-means partitions isotropic data happily regardless of spread. Effective
127
+ rank has no k to select and degrades gracefully.
128
+ """
129
+ E = np.asarray(E, dtype=np.float64)
130
+ if E.shape[0] == 0:
131
+ return 0.0
132
+ if E.shape[0] == 1:
133
+ return 1.0
134
+ L = E @ E.T
135
+ w = np.linalg.eigvalsh(0.5 * (L + L.T))
136
+ w = np.clip(w, 0.0, None)
137
+ s = w.sum()
138
+ if s <= 0:
139
+ return 1.0
140
+ p = w / s
141
+ p = p[p > eps]
142
+ return float(np.exp(-(p * np.log(p)).sum()))
143
+
144
+
145
+ def zscore(x: np.ndarray, ddof: int = 0) -> np.ndarray:
146
+ """Per-group z-score. Constant input -> all zeros (not NaN).
147
+
148
+ Used on m_i before it enters the reward: raw m_i lives on a log scale
149
+ with a long negative tail (a duplicate pair can hit log(1e-3) ~ -6.9),
150
+ which would otherwise dominate the quality term.
151
+ """
152
+ x = np.asarray(x, dtype=np.float64)
153
+ if x.size == 0:
154
+ return x
155
+ s = x.std(ddof=ddof)
156
+ if not np.isfinite(s) or s < 1e-12:
157
+ return np.zeros_like(x)
158
+ return (x - x.mean()) / s
159
+
160
+
161
+ def scale_to_reference(x: np.ndarray, ref_scale: float = 1.0) -> np.ndarray:
162
+ """Rescale x to have unit-ish spread times ref_scale, preserving mean 0.
163
+
164
+ Deviation d_i lives in [0, 2] but in practice clusters in [0.1, 0.5] for
165
+ same-prompt stories -- an order of magnitude below judge quality (0-10).
166
+ Feeding raw d_i with alpha=0.5 would make the diversity term invisible.
167
+ """
168
+ return zscore(x) * float(ref_scale)
169
+
170
+
171
+ def greedy_diverse_subset(
172
+ quality: np.ndarray,
173
+ E: np.ndarray,
174
+ k: int,
175
+ lam: float = 1.0,
176
+ eps: float = EPS_JITTER,
177
+ ) -> list[int]:
178
+ """Greedily pick k indices maximizing sum(quality) + lam * logdet(L_S).
179
+
180
+ Standard submodular greedy: the objective is monotone-ish and DPP logdet
181
+ is submodular, so greedy carries the usual (1 - 1/e) flavor of guarantee.
182
+ Used to build E3's multi-positive chosen sets.
183
+
184
+ Returns indices into the rows of E, in selection order.
185
+ """
186
+ quality = np.asarray(quality, dtype=np.float64)
187
+ E = np.asarray(E, dtype=np.float64)
188
+ G = E.shape[0]
189
+ k = int(min(k, G))
190
+ if k <= 0:
191
+ return []
192
+ L = cosine_kernel(E, eps)
193
+
194
+ chosen: list[int] = []
195
+ for _ in range(k):
196
+ best_gain, best_i = -np.inf, -1
197
+ for i in range(G):
198
+ if i in chosen:
199
+ continue
200
+ cand = chosen + [i]
201
+ vol = _logdet_psd(L[np.ix_(cand, cand)])
202
+ gain = quality[cand].sum() + lam * vol
203
+ if gain > best_gain:
204
+ best_gain, best_i = gain, i
205
+ if best_i < 0:
206
+ break
207
+ chosen.append(best_i)
208
+ return chosen
209
+
210
+
211
+ def group_metrics(E: np.ndarray, eps: float = EPS_JITTER) -> dict:
212
+ """Bundle of reporting metrics for one group. Metrics only, not rewards."""
213
+ E = np.asarray(E, dtype=np.float64)
214
+ G = E.shape[0]
215
+ if G < 2:
216
+ return {"n": G, "mean_pairwise_dist": 0.0, "logdet": 0.0,
217
+ "mean_marginal": 0.0, "min_marginal": 0.0}
218
+ d = pairwise_deviation(E)
219
+ m = marginal_contributions(E, eps)
220
+ return {
221
+ "n": G,
222
+ "mean_pairwise_dist": float(d.mean()),
223
+ "logdet": float(logdet_volume(E, eps)),
224
+ "mean_marginal": float(m.mean()),
225
+ "min_marginal": float(m.min()),
226
+ }
src/evaluate.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluation harness. Runs the IDENTICAL protocol on every model (base, E0, E1,
3
+ E2, E3, E4-emb, E4-prob, and any 8B arms): 50 held-out prompts x 16 samples at
4
+ fixed T=0.9, top_p=0.95, fixed seed.
5
+
6
+ Metrics
7
+ -------
8
+ quality mean judge quality over gate-passing stories
9
+ gate_pass fraction passing all programmatic gates
10
+ ends_cleanly fraction ending on terminal punctuation, not at the token cap
11
+ pairwise mean pairwise embedding distance within a prompt's 16 samples
12
+ logdet mean log-det volume of the 16-sample embedding set per prompt
13
+ distinct4 unique 4-grams / total 4-grams, pooled across the 16 samples
14
+ self_bleu mean BLEU-4 of each sample against the other 15 (LOWER = diverse)
15
+ n_clusters conservative count of WELL-SEPARATED modes (silhouette > 0.50)
16
+ eff_rank effective rank of the 16-sample Gram spectrum; the primary,
17
+ continuous mode measure. 1 = all identical, 16 = all orthogonal
18
+ tok_entropy mean per-token predictive entropy, top-20 truncated. MONITOR ONLY.
19
+
20
+ On tok_entropy: token entropy is not story diversity. A policy can raise token
21
+ entropy by getting noisier inside a single narrative mode, and can lower it
22
+ while telling structurally different stories. It is reported to detect
23
+ degenerate sampling, and is never optimized.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import csv
29
+ import json
30
+ import math
31
+ import sys
32
+ from collections import Counter
33
+ from pathlib import Path
34
+
35
+ import numpy as np
36
+
37
+ ROOT = Path(__file__).resolve().parent.parent
38
+ EVAL_TEMP, EVAL_TOP_P, EVAL_SEED, EVAL_N = 0.9, 0.95, 20260816, 16
39
+
40
+
41
+ # ------------------------------------------------------------------ n-grams
42
+ def ngrams(toks: list[str], n: int) -> list[tuple]:
43
+ return [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
44
+
45
+
46
+ def norm_tokens(text: str) -> list[str]:
47
+ import re
48
+ return re.sub(r"[^\w\s]", " ", text.lower()).split()
49
+
50
+
51
+ def distinct_n(texts: list[str], n: int = 4) -> float:
52
+ all_g = [g for t in texts for g in ngrams(norm_tokens(t), n)]
53
+ return len(set(all_g)) / len(all_g) if all_g else 0.0
54
+
55
+
56
+ def _bleu4(cand: list[str], refs: list[list[str]]) -> float:
57
+ """BLEU-4 with brevity penalty, clipped counts against multiple refs."""
58
+ if len(cand) < 4:
59
+ return 0.0
60
+ logs = []
61
+ for n in range(1, 5):
62
+ cg = Counter(ngrams(cand, n))
63
+ if not cg:
64
+ return 0.0
65
+ maxref = Counter()
66
+ for r in refs:
67
+ for g, c in Counter(ngrams(r, n)).items():
68
+ if c > maxref[g]:
69
+ maxref[g] = c
70
+ clipped = sum(min(c, maxref[g]) for g, c in cg.items())
71
+ total = sum(cg.values())
72
+ # smoothing: avoid log(0) collapsing the whole score
73
+ logs.append(math.log((clipped + 1e-9) / total) if clipped else math.log(1e-9))
74
+ r_len = min((len(r) for r in refs), key=lambda L: (abs(L - len(cand)), L))
75
+ bp = 1.0 if len(cand) > r_len else math.exp(1 - r_len / max(1, len(cand)))
76
+ # clamp: the 1e-9 smoothing can push an exact-match score a hair over 1.0
77
+ return min(1.0, max(0.0, bp * math.exp(sum(logs) / 4)))
78
+
79
+
80
+ def self_bleu(texts: list[str]) -> float:
81
+ toks = [norm_tokens(t) for t in texts]
82
+ if len(toks) < 2:
83
+ return 0.0
84
+ return float(np.mean([
85
+ _bleu4(toks[i], [toks[j] for j in range(len(toks)) if j != i])
86
+ for i in range(len(toks))
87
+ ]))
88
+
89
+
90
+ # Silhouette threshold for declaring that real cluster structure exists.
91
+ # Calibrated, not guessed: on 16 synthetic embeddings, a fully COLLAPSED set
92
+ # peaks at silhouette 0.202 (k=4) and a fully SPREAD set at 0.195 (k=7) -- i.e.
93
+ # silhouette cannot tell those apart at all, because k-means partitions
94
+ # isotropic data regardless of spread. Only genuinely separated clusters score
95
+ # high (two clear modes -> 0.976). So the threshold is set well above the
96
+ # isotropic band, making n_clusters a CONSERVATIVE count of well-separated
97
+ # modes: it returns 1 unless the structure is unmistakable. `eff_rank` is the
98
+ # primary, continuous mode measure.
99
+ SILHOUETTE_MIN = 0.50
100
+
101
+
102
+ def cluster_count(E: np.ndarray, kmax: int = 8) -> int:
103
+ """k-means with silhouette-selected k. Conservative count of *well-separated*
104
+ modes; returns 1 when there is no unmistakable cluster structure."""
105
+ from sklearn.cluster import KMeans
106
+ from sklearn.metrics import silhouette_score
107
+ n = E.shape[0]
108
+ if n < 4:
109
+ return 1
110
+ best_k, best_s = 1, -1.0
111
+ for k in range(2, min(kmax, n - 1) + 1):
112
+ try:
113
+ lab = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(E)
114
+ if len(set(lab)) < 2:
115
+ continue
116
+ s = silhouette_score(E, lab, metric="cosine")
117
+ except Exception:
118
+ continue
119
+ if s > best_s:
120
+ best_k, best_s = k, s
121
+ return best_k if best_s > SILHOUETTE_MIN else 1
122
+
123
+
124
+ def topk_entropy(logprob_rows: list[dict]) -> float:
125
+ """Mean per-token entropy from vLLM's top-k logprob table.
126
+
127
+ Truncated at k, so it UNDERSTATES true entropy; comparable across models
128
+ only because every model is measured with the same k. Monitor only.
129
+ """
130
+ ents = []
131
+ for row in logprob_rows:
132
+ lps = np.array([v for v in row.values()], dtype=np.float64)
133
+ if lps.size == 0:
134
+ continue
135
+ p = np.exp(lps)
136
+ s = p.sum()
137
+ if s <= 0:
138
+ continue
139
+ p = p / s
140
+ ents.append(float(-(p * np.log(p + 1e-12)).sum()))
141
+ return float(np.mean(ents)) if ents else 0.0
142
+
143
+
144
+ # --------------------------------------------------------------------- main
145
+ def eval_one(model_id: str, lora_path: str | None, label: str, prompts: list[dict],
146
+ judge, enc, gpu_mem: float, dump_dir: Path, n_dump: int = 3) -> dict:
147
+ import gates
148
+ from diversity import (effective_rank, l2_normalize, logdet_volume,
149
+ pairwise_deviation)
150
+ from generate import build_llm, render_chat
151
+ from vllm import SamplingParams
152
+
153
+ from transformers import AutoTokenizer
154
+ tok = AutoTokenizer.from_pretrained(model_id)
155
+ llm = build_llm(model_id, gpu_mem_util=gpu_mem, seed=EVAL_SEED,
156
+ enable_lora=bool(lora_path))
157
+
158
+ kw = {}
159
+ if lora_path:
160
+ from vllm.lora.request import LoRARequest
161
+ kw["lora_request"] = LoRARequest(label, 1, lora_path)
162
+
163
+ # logprobs=10, not 20: this table is only used for tok_entropy, which is
164
+ # monitor-only and never optimized. 20 would materialize ~8M Python Logprob
165
+ # objects per model (800 seqs x ~500 tok x 20). The truncation understates
166
+ # entropy identically for every model, so cross-model comparison holds.
167
+ # max_tokens matches training (1024) so eval and train share a length regime.
168
+ sp = SamplingParams(n=EVAL_N, temperature=EVAL_TEMP, top_p=EVAL_TOP_P,
169
+ max_tokens=1024, seed=EVAL_SEED, logprobs=10,
170
+ skip_special_tokens=True)
171
+ outs = llm.generate([render_chat(tok, p["prompt"]) for p in prompts], sp, **kw)
172
+
173
+ records, per_prompt = [], []
174
+ for p, out in zip(prompts, outs):
175
+ texts, ents, frs = [], [], []
176
+ for o in out.outputs:
177
+ texts.append(o.text.strip())
178
+ frs.append(o.finish_reason or "")
179
+ ents.append(topk_entropy(
180
+ [{k: v.logprob for k, v in d.items()} for d in (o.logprobs or [])]))
181
+ grs = [gates.check(t, finish_reason=f) for t, f in zip(texts, frs)]
182
+ E = l2_normalize(np.asarray(enc.encode(
183
+ texts, normalize_embeddings=True, batch_size=32,
184
+ show_progress_bar=False, convert_to_numpy=True), dtype=np.float64))
185
+ per_prompt.append({
186
+ "prompt_id": p["id"], "prompt": p["prompt"], "texts": texts,
187
+ "gates": [g.as_dict() for g in grs],
188
+ "pairwise": float(pairwise_deviation(E).mean()),
189
+ "logdet": float(logdet_volume(E)),
190
+ "distinct4": distinct_n(texts, 4),
191
+ "self_bleu": self_bleu(texts),
192
+ "n_clusters": cluster_count(E),
193
+ "eff_rank": effective_rank(E),
194
+ "tok_entropy": float(np.mean(ents)) if ents else 0.0,
195
+ "gate_pass": float(np.mean([g.passed for g in grs])),
196
+ "ends_cleanly": float(np.mean([g.completeness for g in grs])),
197
+ "words": float(np.mean([g.n_words for g in grs])),
198
+ })
199
+ for t, g in zip(texts, grs):
200
+ records.append((p["prompt"], t, g.passed))
201
+
202
+ del llm
203
+ import gc, torch
204
+ gc.collect(); torch.cuda.empty_cache()
205
+
206
+ # judge only gate-passers, same rule as training
207
+ idx = [i for i, (_, _, ok) in enumerate(records) if ok]
208
+ scores = judge.score_many_sync([(records[i][0], records[i][1]) for i in idx]) if idx else []
209
+ q = [s.quality for s in scores]
210
+ nov = [s.novelty for s in scores]
211
+
212
+ row = {
213
+ "model": label,
214
+ "quality": float(np.mean(q)) if q else 0.0,
215
+ "quality_sd": float(np.std(q)) if q else 0.0,
216
+ "novelty": float(np.mean(nov)) if nov else 0.0,
217
+ "gate_pass": float(np.mean([r["gate_pass"] for r in per_prompt])),
218
+ "ends_cleanly": float(np.mean([r["ends_cleanly"] for r in per_prompt])),
219
+ "pairwise": float(np.mean([r["pairwise"] for r in per_prompt])),
220
+ "logdet": float(np.mean([r["logdet"] for r in per_prompt])),
221
+ "distinct4": float(np.mean([r["distinct4"] for r in per_prompt])),
222
+ "self_bleu": float(np.mean([r["self_bleu"] for r in per_prompt])),
223
+ "n_clusters": float(np.mean([r["n_clusters"] for r in per_prompt])),
224
+ "eff_rank": float(np.mean([r["eff_rank"] for r in per_prompt])),
225
+ "tok_entropy": float(np.mean([r["tok_entropy"] for r in per_prompt])),
226
+ "words": float(np.mean([r["words"] for r in per_prompt])),
227
+ "n_stories": len(records),
228
+ "n_judged": len(idx),
229
+ }
230
+
231
+ dump_dir.mkdir(parents=True, exist_ok=True)
232
+ json.dump(per_prompt, open(dump_dir / f"{label}_full.json", "w"), indent=1)
233
+ json.dump(per_prompt[:n_dump], open(dump_dir / f"{label}_examples.json", "w"), indent=1)
234
+ return row
235
+
236
+
237
+ def main():
238
+ ap = argparse.ArgumentParser()
239
+ ap.add_argument("--models", required=True,
240
+ help="JSON list of {label, model, lora} or path to such a file")
241
+ ap.add_argument("--limit", type=int, default=None)
242
+ ap.add_argument("--gpu-mem", type=float, default=0.85)
243
+ ap.add_argument("--out", default="outputs/eval")
244
+ ap.add_argument("--single", default=None,
245
+ help="internal: evaluate exactly this label, then exit")
246
+ args = ap.parse_args()
247
+
248
+ import logbook
249
+ from data import load_prompts
250
+ from judge import build_judge
251
+ from sentence_transformers import SentenceTransformer
252
+
253
+ spec = json.loads(Path(args.models).read_text()) if Path(args.models).exists() \
254
+ else json.loads(args.models)
255
+ prompts = load_prompts("eval", ROOT / "data")
256
+ if args.limit:
257
+ prompts = prompts[: args.limit]
258
+
259
+ judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=24)
260
+ enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda")
261
+ out_dir = ROOT / args.out
262
+ out_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ rowdir = out_dir / "rows"
265
+ rowdir.mkdir(parents=True, exist_ok=True)
266
+
267
+ # ---- child mode: evaluate one model, write its row, exit --------------
268
+ if args.single:
269
+ m = next(x for x in spec if x["label"] == args.single)
270
+ lora = m.get("lora")
271
+ if lora and not Path(ROOT / lora).exists() and not Path(lora).exists():
272
+ print(f"SKIP {m['label']}: adapter not found at {lora}")
273
+ return 3
274
+ r = eval_one(m["model"], lora, m["label"], prompts, judge, enc,
275
+ args.gpu_mem, out_dir / "samples")
276
+ json.dump(r, open(rowdir / f"{m['label']}.json", "w"), indent=1)
277
+ print(json.dumps(r, indent=1))
278
+ print("judge:", judge.health(), judge.cost_estimate(0.140, 0.280))
279
+ return 0
280
+
281
+ # ---- driver mode: one SUBPROCESS per model ---------------------------
282
+ # vLLM v1 runs EngineCore in a child process; `del llm` does not reliably
283
+ # reclaim its GPU memory, so a 7-model in-process loop OOMs on model 2 after
284
+ # model 1 succeeds. Process isolation makes teardown unconditional. It also
285
+ # means one model crashing cannot take the whole eval down.
286
+ import subprocess
287
+ for m in spec:
288
+ dest = rowdir / f"{m['label']}.json"
289
+ if dest.exists():
290
+ print(f"== SKIP {m['label']} (already evaluated)"); continue
291
+ print(f"\n===== eval {m['label']} (subprocess) =====", flush=True)
292
+ cmd = [sys.executable, "-u", str(Path(__file__).resolve()),
293
+ "--models", args.models, "--out", args.out,
294
+ "--gpu-mem", str(args.gpu_mem), "--single", m["label"]]
295
+ if args.limit:
296
+ cmd += ["--limit", str(args.limit)]
297
+ rc = subprocess.run(cmd).returncode
298
+ if rc != 0:
299
+ print(f"!! {m['label']} exited {rc}; continuing with the rest")
300
+ logbook.note(f"eval FAILED: {m['label']}", f"exit code {rc}", level="ALERT")
301
+
302
+ rows = []
303
+ for m in spec:
304
+ p = rowdir / f"{m['label']}.json"
305
+ if p.exists():
306
+ rows.append(json.load(open(p)))
307
+ if not rows:
308
+ print("no models evaluated"); return 1
309
+ with open(out_dir / "results.csv", "w", newline="") as f:
310
+ w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
311
+ w.writeheader(); w.writerows(rows)
312
+ print(f"\nwrote {out_dir/'results.csv'} ({len(rows)}/{len(spec)} models)")
313
+ logbook.note("eval complete",
314
+ f"{len(rows)}/{len(spec)} models\n\n" +
315
+ logbook.table(rows, list(rows[0].keys())))
316
+ return 0
317
+
318
+
319
+ if __name__ == "__main__":
320
+ sys.exit(main())
src/gates.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Programmatic quality gates. Free, deterministic, zero judge variance.
3
+
4
+ Contract: a story that FAILS any hard gate gets total reward 0, regardless of
5
+ what the judge said. Gates run before the judge so we can skip paying for
6
+ obviously-broken generations.
7
+
8
+ This module exists because of a specific autopsy finding. The prior run's
9
+ completeness check was:
10
+
11
+ enders = ".!?\\"'"
12
+ text[-1] in enders
13
+
14
+ which uses straight quotes only. The policy emits curly U+201D constantly, so
15
+ properly-ended stories were scored 0.3 instead of 1.0. Worse, 100% of prior-run
16
+ eval samples were truncated mid-word by a max_new_tokens wall, and the judge
17
+ rubric capped quality at 4 for truncation -- collapsing within-group quality
18
+ variance to ~0, which in GRPO means zero advantage. The quality signal was
19
+ silently dead for the entire run. Everything here is built to make that
20
+ failure loud instead of silent.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ import re
27
+ import unicodedata
28
+ from dataclasses import dataclass, asdict, field
29
+
30
+ # Sentence terminators, including the curly/CJK variants the prior run missed.
31
+ TERMINALS = set('.!?…' + '"”’\'' + '。!?' + ')]}*_')
32
+ # Characters allowed to trail a real terminator (markdown emphasis, quotes).
33
+ _TRAILING_DECOR = set('*_`"”’\'» )]}')
34
+
35
+ MIN_WORDS = 150
36
+ MAX_WORDS = 600
37
+ MAX_NGRAM_REPEAT_FRAC = 0.18 # frac of 4-grams that are repeats
38
+ MIN_CHAR_ENTROPY = 3.2 # bits/char; English prose sits ~4.0-4.4
39
+ MAX_NONASCII_FRAC = 0.08 # em-dashes/curly quotes are fine; CJK dumps are not
40
+ MAX_LINE_REPEAT_FRAC = 0.25
41
+
42
+
43
+ @dataclass
44
+ class GateResult:
45
+ passed: bool
46
+ completeness: float # 1.0 ended cleanly, 0.0 truncated
47
+ n_words: int
48
+ reasons: list[str] = field(default_factory=list)
49
+ # diagnostics, logged not rewarded
50
+ repeat_4gram_frac: float = 0.0
51
+ char_entropy: float = 0.0
52
+ nonascii_frac: float = 0.0
53
+ hit_token_cap: bool = False
54
+
55
+ def as_dict(self) -> dict:
56
+ return asdict(self)
57
+
58
+
59
+ def _words(text: str) -> list[str]:
60
+ return text.split()
61
+
62
+
63
+ def _norm_tokens(text: str) -> list[str]:
64
+ return re.sub(r"[^\w\s]", " ", text.lower()).split()
65
+
66
+
67
+ def ends_cleanly(text: str) -> bool:
68
+ """True if the story ends on a sentence terminator.
69
+
70
+ Strips trailing markdown/quote decoration first, so `...over.*` and
71
+ `...done."` and `...gone.”` all count. Also rejects the specific
72
+ mid-word cutoff signature: last token is a bare word with no terminator.
73
+ """
74
+ t = text.rstrip()
75
+ if not t:
76
+ return False
77
+ while t and t[-1] in _TRAILING_DECOR:
78
+ t = t[:-1].rstrip()
79
+ if t and t[-1] in '.!?…。!?':
80
+ return True
81
+ return bool(t) and t[-1] in '.!?…。!?'
82
+
83
+
84
+ def char_entropy(text: str) -> float:
85
+ """Shannon entropy over characters, bits/char."""
86
+ if not text:
87
+ return 0.0
88
+ counts: dict[str, int] = {}
89
+ for ch in text:
90
+ counts[ch] = counts.get(ch, 0) + 1
91
+ n = len(text)
92
+ return -sum((c / n) * math.log2(c / n) for c in counts.values())
93
+
94
+
95
+ def repeat_ngram_frac(text: str, n: int = 4) -> float:
96
+ """Fraction of n-grams that are non-first occurrences. Catches loops."""
97
+ toks = _norm_tokens(text)
98
+ if len(toks) < n + 1:
99
+ return 0.0
100
+ grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
101
+ return 1.0 - (len(set(grams)) / len(grams))
102
+
103
+
104
+ def repeat_line_frac(text: str) -> float:
105
+ """Fraction of non-trivial lines that are duplicates. Catches list loops."""
106
+ lines = [l.strip() for l in text.splitlines() if len(l.strip()) > 12]
107
+ if len(lines) < 4:
108
+ return 0.0
109
+ return 1.0 - (len(set(lines)) / len(lines))
110
+
111
+
112
+ def nonascii_frac(text: str) -> float:
113
+ """Fraction of chars outside Latin/punctuation. Typographic marks exempt."""
114
+ if not text:
115
+ return 0.0
116
+ bad = 0
117
+ for ch in text:
118
+ if ord(ch) < 128:
119
+ continue
120
+ if unicodedata.category(ch) in ("Pd", "Pi", "Pf", "Po", "Zs", "Sm"):
121
+ continue # em dash, curly quotes, ellipsis, nbsp
122
+ bad += 1
123
+ return bad / len(text)
124
+
125
+
126
+ def check(
127
+ text: str,
128
+ *,
129
+ finish_reason: str | None = None,
130
+ min_words: int = MIN_WORDS,
131
+ max_words: int = MAX_WORDS,
132
+ ) -> GateResult:
133
+ """Run all gates. `finish_reason` from vLLM/OpenAI ('length' == hit cap).
134
+
135
+ Passing `finish_reason` is strongly preferred: it detects the token-cap
136
+ truncation that destroyed the prior run directly, rather than inferring it
137
+ from punctuation.
138
+ """
139
+ text = (text or "").strip()
140
+ reasons: list[str] = []
141
+
142
+ n_words = len(_words(text))
143
+ hit_cap = finish_reason == "length"
144
+ complete = ends_cleanly(text) and not hit_cap
145
+
146
+ rep4 = repeat_ngram_frac(text, 4)
147
+ repline = repeat_line_frac(text)
148
+ ent = char_entropy(text)
149
+ na = nonascii_frac(text)
150
+
151
+ if not text:
152
+ reasons.append("empty")
153
+ if hit_cap:
154
+ reasons.append("hit_token_cap")
155
+ if not ends_cleanly(text):
156
+ reasons.append("no_terminal_punctuation")
157
+ if n_words < min_words:
158
+ reasons.append(f"too_short({n_words}<{min_words})")
159
+ if n_words > max_words:
160
+ reasons.append(f"too_long({n_words}>{max_words})")
161
+ if rep4 > MAX_NGRAM_REPEAT_FRAC:
162
+ reasons.append(f"ngram_loop({rep4:.2f})")
163
+ if repline > MAX_LINE_REPEAT_FRAC:
164
+ reasons.append(f"line_loop({repline:.2f})")
165
+ if text and ent < MIN_CHAR_ENTROPY:
166
+ reasons.append(f"low_entropy({ent:.2f})")
167
+ if na > MAX_NONASCII_FRAC:
168
+ reasons.append(f"nonascii({na:.2f})")
169
+
170
+ return GateResult(
171
+ passed=not reasons,
172
+ completeness=1.0 if complete else 0.0,
173
+ n_words=n_words,
174
+ reasons=reasons,
175
+ repeat_4gram_frac=rep4,
176
+ char_entropy=ent,
177
+ nonascii_frac=na,
178
+ hit_token_cap=hit_cap,
179
+ )
src/generate.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ vLLM generation wrapper shared by pool building, eval, and smoke tests.
3
+
4
+ Captures per-story cumulative logprob, which E4's `divpo-prob` variant needs:
5
+ DivPO's probability criterion picks the LOWEST-logprob passing story as the
6
+ diverse chosen and the HIGHEST-logprob failing story as the common rejected.
7
+ The highest-probability sample in a temperature pool is, by construction, the
8
+ near-greedy one -- which is why this is the principled version of "reject the
9
+ greedy decode".
10
+
11
+ Length normalization matters here. Raw cumulative logprob scales with token
12
+ count, so ranking by it would just rank by length (short stories win). We keep
13
+ both `cumlogprob` and `mean_logprob = cumlogprob / n_tokens` and use the mean
14
+ for DivPO ranking.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass, asdict
21
+ from pathlib import Path
22
+
23
+ # NEVER truncate a story. The prior run died of a max_new_tokens wall, so this
24
+ # is sized for real headroom, not for the target length: a 500-word story is
25
+ # ~650-700 Qwen tokens, so 768 would clip anything that runs long and would
26
+ # reproduce the exact bug we are fixing. 1280 lets the model finish and overrun
27
+ # a little; overrun is then DETECTED by the >600-word gate rather than silently
28
+ # chopped. Measure the failure, don't manufacture it.
29
+ #
30
+ # Nothing downstream truncates either: the judge receives the complete story
31
+ # text, and embeddings are computed over the complete story text.
32
+ MAX_NEW_TOKENS = 1280
33
+ MAX_MODEL_LEN = 2048
34
+
35
+
36
+ @dataclass
37
+ class Gen:
38
+ prompt_id: str
39
+ prompt: str
40
+ idx: int
41
+ text: str
42
+ n_tokens: int
43
+ cumlogprob: float
44
+ mean_logprob: float
45
+ finish_reason: str
46
+
47
+ def as_dict(self) -> dict:
48
+ return asdict(self)
49
+
50
+
51
+ def build_llm(
52
+ model: str,
53
+ gpu_mem_util: float = 0.85,
54
+ max_model_len: int = MAX_MODEL_LEN,
55
+ seed: int = 0,
56
+ enable_lora: bool = False,
57
+ ):
58
+ from vllm import LLM
59
+ kw = dict(
60
+ model=model,
61
+ dtype="bfloat16",
62
+ gpu_memory_utilization=gpu_mem_util,
63
+ max_model_len=max_model_len,
64
+ seed=seed,
65
+ enforce_eager=False,
66
+ disable_log_stats=True,
67
+ )
68
+ if enable_lora:
69
+ kw.update(enable_lora=True, max_lora_rank=32)
70
+ return LLM(**kw)
71
+
72
+
73
+ def sampling_params(
74
+ n: int, temperature: float, top_p: float, seed: int | None,
75
+ max_tokens: int = MAX_NEW_TOKENS,
76
+ ):
77
+ from vllm import SamplingParams
78
+ return SamplingParams(
79
+ n=n,
80
+ temperature=temperature,
81
+ top_p=top_p,
82
+ max_tokens=max_tokens,
83
+ seed=seed,
84
+ logprobs=0, # cumulative_logprob only; per-token table not needed
85
+ skip_special_tokens=True,
86
+ )
87
+
88
+
89
+ def render_chat(tokenizer, prompt: str) -> str:
90
+ """Render the chat template, with thinking OFF where the model has it.
91
+
92
+ Qwen3-8B is a hybrid-thinking model: left at its default it emits a <think>
93
+ block before the story, which would (a) eat the token budget, (b) pollute
94
+ the embedding with reasoning text, and (c) make the 8B arm incomparable to
95
+ the 4B-Instruct arms, which have no thinking mode at all. Qwen3-4B-Instruct
96
+ ignores the kwarg, so the same call is correct for both.
97
+ """
98
+ from data import chat_messages
99
+ msgs = chat_messages(prompt)
100
+ try:
101
+ return tokenizer.apply_chat_template(
102
+ msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False,
103
+ )
104
+ except TypeError:
105
+ return tokenizer.apply_chat_template(
106
+ msgs, tokenize=False, add_generation_prompt=True,
107
+ )
108
+
109
+
110
+ def generate(
111
+ llm,
112
+ tokenizer,
113
+ prompts: list[dict],
114
+ n: int,
115
+ temperature: float,
116
+ top_p: float,
117
+ seed: int | None = None,
118
+ max_tokens: int = MAX_NEW_TOKENS,
119
+ lora_path: str | None = None,
120
+ ) -> list[Gen]:
121
+ """prompts = [{'id':..., 'prompt':...}] -> flat list of n*len(prompts) Gens."""
122
+ texts = [render_chat(tokenizer, p["prompt"]) for p in prompts]
123
+ sp = sampling_params(n, temperature, top_p, seed, max_tokens)
124
+
125
+ kw = {}
126
+ if lora_path:
127
+ from vllm.lora.request import LoRARequest
128
+ kw["lora_request"] = LoRARequest("adapter", 1, lora_path)
129
+
130
+ outs = llm.generate(texts, sp, **kw)
131
+
132
+ gens: list[Gen] = []
133
+ for p, out in zip(prompts, outs):
134
+ for j, o in enumerate(out.outputs):
135
+ ntok = len(o.token_ids)
136
+ cum = float(o.cumulative_logprob) if o.cumulative_logprob is not None else 0.0
137
+ gens.append(Gen(
138
+ prompt_id=p["id"], prompt=p["prompt"], idx=j,
139
+ text=o.text.strip(), n_tokens=ntok,
140
+ cumlogprob=cum,
141
+ mean_logprob=(cum / ntok) if ntok else 0.0,
142
+ finish_reason=o.finish_reason or "",
143
+ ))
144
+ return gens
145
+
146
+
147
+ def save_gens(gens: list[Gen], path: str | Path) -> None:
148
+ path = Path(path)
149
+ path.parent.mkdir(parents=True, exist_ok=True)
150
+ with open(path, "w") as f:
151
+ for g in gens:
152
+ f.write(json.dumps(g.as_dict()) + "\n")
153
+
154
+
155
+ def load_gens(path: str | Path) -> list[Gen]:
156
+ return [Gen(**json.loads(l)) for l in open(path) if l.strip()]
src/judge.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pluggable LLM judge with aggressive on-disk caching.
3
+
4
+ Design decisions worth stating, because they differ from the prior run:
5
+
6
+ 1. ONE STORY PER CALL, absolute rubric. The prior run sent all 16 samples in a
7
+ single call and asked for relative scores. That is cheaper but wrong here:
8
+ - quality is compared against a fixed gate tau (5.0), so it must be
9
+ absolutely calibrated, not calibrated relative to whatever else happened
10
+ to be in the group;
11
+ - eval compares quality ACROSS models, which relative scoring makes
12
+ meaningless;
13
+ - batched scoring has position bias.
14
+ Per-story also makes the cache actually work: identical stories recur across
15
+ arms and eval passes, and each is paid for once, ever.
16
+
17
+ 2. NO group_diversity FIELD. A set-level scalar is constant within a GRPO group
18
+ -> zero within-group std -> zero advantage. It cannot train anything. All
19
+ diversity credit comes from embeddings (src/diversity.py), where it is
20
+ per-sample by construction.
21
+
22
+ 3. Judge novelty is an ABSOLUTE 'freshness vs. generic AI slop' axis, not
23
+ 'different from the others'. The latter is the embeddings' job. This keeps
24
+ the two signals from being redundant-but-inconsistent.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import sqlite3
34
+ import time
35
+ from dataclasses import dataclass, asdict
36
+ from pathlib import Path
37
+
38
+ RUBRIC_VERSION = "v4"
39
+
40
+ SYSTEM_RUBRIC = """You are a demanding fiction editor scoring ONE short story written to a prompt.
41
+ Score on absolute standards, not relative to anything you have seen before.
42
+
43
+ STEP 1 -- FINISHEDNESS. Read the LAST sentence first, before anything else.
44
+ A story is UNFINISHED if the final sentence stops mid-clause or mid-word, or if
45
+ the piece simply halts with the situation unresolved and no closing beat.
46
+ Beautiful prose does not make an unfinished story finished. If UNFINISHED:
47
+ set "ended":false, quality 1-3, novelty 0-3, and stop deliberating -- an
48
+ unfinished fragment is never novel, because you cannot know what it became.
49
+ Only if it is FINISHED, continue to step 2.
50
+
51
+ STEP 2 -- quality (0-10): coherence + prompt fulfilment + craft
52
+ 0-2 Incoherent, or ignores the prompt entirely.
53
+ 3-4 Followable but generic: stock premise, flat prose, perfunctory ending.
54
+ 5-6 Competent. Does what the prompt asks, reads cleanly, resolves. Forgettable.
55
+ 7-8 Strong. Controlled voice, purposeful structure, an ending that lands.
56
+ 9-10 Excellent. Precise images, earned emotional turn, nothing wasted.
57
+ If it never engages the prompt's actual premise, quality <= 4.
58
+
59
+ STEP 3 -- novelty (0-10): freshness of premise, voice and construction vs. generic AI fiction
60
+ 0-2 Pure slop signature: portentous one-line paragraphs, "It wasn't X, it was Y",
61
+ italicised abstract nouns doing the emotional work, cosmic-melancholy fog.
62
+ 3-4 Familiar premise handled in the familiar way.
63
+ 5-6 One genuine choice (an unusual angle OR a distinct voice OR an odd structure).
64
+ 7-8 Several: takes a real swing on premise, form or register and controls it.
65
+ 9-10 Startling and still coherent. You could not have predicted this.
66
+ Novelty is NOT weirdness: incoherence, non-sequiturs and word salad score 0-2.
67
+ Judge freshness against fiction in general, NOT against other samples.
68
+
69
+ Return ONLY minified JSON, no markdown fence:
70
+ {"quality":<0-10>,"novelty":<0-10>,"ended":<true|false>,"note":"<=10 words"}"""
71
+
72
+
73
+ @dataclass
74
+ class JudgeScore:
75
+ quality: float
76
+ novelty: float
77
+ ended: bool = True
78
+ note: str = ""
79
+ ok: bool = True # False => call failed, scores are neutral fallbacks
80
+ cached: bool = False
81
+
82
+ def as_dict(self) -> dict:
83
+ return asdict(self)
84
+
85
+
86
+ NEUTRAL = JudgeScore(quality=5.0, novelty=5.0, ended=True, note="judge_failed", ok=False)
87
+
88
+
89
+ # --------------------------------------------------------------- disk cache
90
+ class ScoreCache:
91
+ """sqlite keyed by sha256(model|rubric|prompt|story). Survives restarts."""
92
+
93
+ def __init__(self, path: str | Path):
94
+ self.path = str(path)
95
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
96
+ self._db = sqlite3.connect(self.path, check_same_thread=False)
97
+ self._db.execute("PRAGMA journal_mode=WAL")
98
+ self._db.execute(
99
+ "CREATE TABLE IF NOT EXISTS scores ("
100
+ " k TEXT PRIMARY KEY, quality REAL, novelty REAL,"
101
+ " ended INT, note TEXT, ts REAL)"
102
+ )
103
+ self._db.commit()
104
+ self.hits = 0
105
+ self.misses = 0
106
+
107
+ @staticmethod
108
+ def key(model: str, prompt: str, story: str) -> str:
109
+ h = hashlib.sha256()
110
+ h.update(f"{model}\x00{RUBRIC_VERSION}\x00{prompt}\x00{story}".encode())
111
+ return h.hexdigest()
112
+
113
+ def get(self, k: str) -> JudgeScore | None:
114
+ r = self._db.execute(
115
+ "SELECT quality,novelty,ended,note FROM scores WHERE k=?", (k,)
116
+ ).fetchone()
117
+ if r is None:
118
+ self.misses += 1
119
+ return None
120
+ self.hits += 1
121
+ return JudgeScore(quality=r[0], novelty=r[1], ended=bool(r[2]),
122
+ note=r[3] or "", ok=True, cached=True)
123
+
124
+ def put(self, k: str, s: JudgeScore) -> None:
125
+ if not s.ok:
126
+ return # never cache a failure
127
+ self._db.execute(
128
+ "INSERT OR REPLACE INTO scores VALUES (?,?,?,?,?,?)",
129
+ (k, s.quality, s.novelty, int(s.ended), s.note[:120], time.time()),
130
+ )
131
+ self._db.commit()
132
+
133
+ def stats(self) -> dict:
134
+ n = self._db.execute("SELECT COUNT(*) FROM scores").fetchone()[0]
135
+ tot = self.hits + self.misses
136
+ return {"rows": n, "hits": self.hits, "misses": self.misses,
137
+ "hit_rate": (self.hits / tot) if tot else 0.0}
138
+
139
+
140
+ # ------------------------------------------------------------------- judges
141
+ def _parse(text: str) -> JudgeScore:
142
+ t = (text or "").strip()
143
+ if t.startswith("```"):
144
+ t = t.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
145
+ i, j = t.find("{"), t.rfind("}")
146
+ if i >= 0 and j > i:
147
+ t = t[i:j + 1]
148
+ d = json.loads(t)
149
+
150
+ def num(v, lo=0.0, hi=10.0):
151
+ return max(lo, min(hi, float(v)))
152
+
153
+ return JudgeScore(
154
+ quality=num(d.get("quality", 5)),
155
+ novelty=num(d.get("novelty", d.get("novel", 5))),
156
+ ended=bool(d.get("ended", True)),
157
+ note=str(d.get("note", ""))[:120],
158
+ ok=True,
159
+ )
160
+
161
+
162
+ class OpenRouterJudge:
163
+ """Async judge over OpenRouter. Default backend."""
164
+
165
+ BASE = "https://openrouter.ai/api/v1/chat/completions"
166
+
167
+ def __init__(
168
+ self,
169
+ model: str = "deepseek/deepseek-v4-flash-0731",
170
+ api_key: str | None = None,
171
+ cache_path: str | Path = "cache/judge.sqlite",
172
+ concurrency: int = 12,
173
+ max_retries: int = 4,
174
+ temperature: float = 0.0,
175
+ timeout: float = 120.0,
176
+ ):
177
+ self.model = model
178
+ self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY", "")
179
+ if not self.api_key:
180
+ raise RuntimeError("OPENROUTER_API_KEY not set")
181
+ self.cache = ScoreCache(cache_path)
182
+ # NOT created here. asyncio.Semaphore binds to the loop that first
183
+ # awaits it, and score_many_sync() calls asyncio.run() -> a NEW loop
184
+ # every training step. A semaphore built in __init__ survives step 1 and
185
+ # then raises "bound to a different event loop" on step 2, which our
186
+ # retry wrapper would convert into neutral 5.0 scores forever. Built
187
+ # per-call in score_many() instead.
188
+ self.concurrency = concurrency
189
+ self.sem: asyncio.Semaphore | None = None
190
+ self.max_retries = max_retries
191
+ self.temperature = temperature
192
+ self.timeout = timeout
193
+ self.n_calls = 0
194
+ self.tok_in = 0
195
+ self.tok_out = 0
196
+ self.n_failed = 0
197
+ self.n_empty = 0
198
+ self.n_truncated = 0
199
+ self.last_error = ""
200
+
201
+ def _user(self, prompt: str, story: str) -> str:
202
+ return f"WRITING PROMPT:\n{prompt}\n\nSTORY:\n{story}\n\nJSON only:"
203
+
204
+ async def _one(self, client, prompt: str, story: str) -> JudgeScore:
205
+ payload = {
206
+ "model": self.model,
207
+ "messages": [
208
+ {"role": "system", "content": SYSTEM_RUBRIC},
209
+ {"role": "user", "content": self._user(prompt, story)},
210
+ ],
211
+ "temperature": self.temperature,
212
+ # deepseek-v4-flash-0731 is a REASONING model. Left alone it spends
213
+ # its whole output budget on `reasoning` and returns content=null
214
+ # with finish_reason="length" -- which our fallback silently turned
215
+ # into a neutral 5.0, i.e. a judge that scored everything identically
216
+ # while looking like it worked. Calibration caught it at 4/166 calls
217
+ # succeeding. Disabling reasoning is also 25x cheaper and 5x faster
218
+ # (30 output tokens vs 764).
219
+ "reasoning": {"enabled": False},
220
+ # Generous bound on a structured output that empirically needs ~30
221
+ # tokens. Not a length cap on content we care about -- and if it is
222
+ # ever hit, `n_truncated` below makes it loud instead of silent.
223
+ "max_tokens": 300,
224
+ }
225
+ delay = 2.0
226
+ for attempt in range(self.max_retries):
227
+ try:
228
+ async with self.sem:
229
+ r = await client.post(
230
+ self.BASE, json=payload, timeout=self.timeout,
231
+ headers={"Authorization": f"Bearer {self.api_key}"},
232
+ )
233
+ if r.status_code in (429, 500, 502, 503, 529):
234
+ await asyncio.sleep(delay); delay *= 2
235
+ continue
236
+ r.raise_for_status()
237
+ body = r.json()
238
+ self.n_calls += 1
239
+ u = body.get("usage") or {}
240
+ self.tok_in += int(u.get("prompt_tokens", 0) or 0)
241
+ self.tok_out += int(u.get("completion_tokens", 0) or 0)
242
+ ch = body["choices"][0]
243
+ if ch.get("finish_reason") == "length":
244
+ self.n_truncated += 1
245
+ content = ch["message"].get("content")
246
+ if not content:
247
+ self.n_empty += 1
248
+ raise ValueError("empty content (reasoning ate the budget?)")
249
+ return _parse(content)
250
+ except Exception as e:
251
+ self.last_error = f"{type(e).__name__}: {str(e)[:140]}"
252
+ if attempt == self.max_retries - 1:
253
+ self.n_failed += 1
254
+ return NEUTRAL
255
+ await asyncio.sleep(delay); delay *= 2
256
+ self.n_failed += 1
257
+ return NEUTRAL
258
+
259
+ def health(self) -> dict:
260
+ """Fail-loud accounting. A judge that returns neutral 5.0 for every call
261
+ looks exactly like a judge that works, until you check this."""
262
+ att = self.n_calls + self.n_failed
263
+ return {"calls_ok": self.n_calls, "failed": self.n_failed,
264
+ "empty_content": self.n_empty, "truncated": self.n_truncated,
265
+ "fail_rate": (self.n_failed / att) if att else 0.0,
266
+ "last_error": self.last_error}
267
+
268
+ def assert_healthy(self, max_fail_rate: float = 0.05) -> None:
269
+ h = self.health()
270
+ if h["fail_rate"] > max_fail_rate:
271
+ raise RuntimeError(
272
+ f"JUDGE UNHEALTHY: {h}. Refusing to train on neutral fallbacks -- "
273
+ f"a constant reward column produces zero GRPO advantage."
274
+ )
275
+
276
+ async def score_many(self, pairs: list[tuple[str, str]]) -> list[JudgeScore]:
277
+ """pairs = [(prompt, story), ...] -> scores in the same order."""
278
+ import httpx
279
+
280
+ out: list[JudgeScore | None] = [None] * len(pairs)
281
+ todo: list[int] = []
282
+ for i, (p, s) in enumerate(pairs):
283
+ hit = self.cache.get(self.cache.key(self.model, p, s))
284
+ if hit is not None:
285
+ out[i] = hit
286
+ else:
287
+ todo.append(i)
288
+
289
+ if todo:
290
+ self.sem = asyncio.Semaphore(self.concurrency) # bind to THIS loop
291
+
292
+ async def one_and_cache(i):
293
+ """Persist each score the moment it lands, not after the whole
294
+ batch. The pool stage issues ~16k calls in a single
295
+ score_many(); caching only at the end meant a crash at 99%
296
+ threw away every call. Incremental writes make any failure
297
+ resumable for free -- a re-run replays as cache hits."""
298
+ s = await self._one(client, pairs[i][0], pairs[i][1])
299
+ if s.ok:
300
+ self.cache.put(self.cache.key(self.model, *pairs[i]), s)
301
+ return i, s
302
+
303
+ async with httpx.AsyncClient(http2=False) as client:
304
+ for fut in asyncio.as_completed([one_and_cache(i) for i in todo]):
305
+ i, s = await fut
306
+ out[i] = s
307
+
308
+ return [o if o is not None else NEUTRAL for o in out]
309
+
310
+ def score_many_sync(self, pairs: list[tuple[str, str]]) -> list[JudgeScore]:
311
+ """Blocking wrapper for use inside TRL reward functions."""
312
+ try:
313
+ asyncio.get_running_loop()
314
+ except RuntimeError:
315
+ return asyncio.run(self.score_many(pairs))
316
+ # already inside a loop (rare in TRL): run in a private one
317
+ import concurrent.futures
318
+ with concurrent.futures.ThreadPoolExecutor(1) as ex:
319
+ return ex.submit(asyncio.run, self.score_many(pairs)).result()
320
+
321
+ def cost_estimate(self, price_in: float, price_out: float) -> dict:
322
+ """price_* in $ per 1M tokens."""
323
+ c = self.tok_in / 1e6 * price_in + self.tok_out / 1e6 * price_out
324
+ return {"calls": self.n_calls, "tok_in": self.tok_in,
325
+ "tok_out": self.tok_out, "usd": round(c, 4),
326
+ **self.cache.stats()}
327
+
328
+
329
+ def build_judge(**kw) -> OpenRouterJudge:
330
+ """Factory. Extend here if an Anthropic/OpenAI key shows up."""
331
+ return OpenRouterJudge(**kw)
src/logbook.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lab-notebook helper. Everything the run learns goes to logs/ as markdown +
3
+ figures, so the record survives the process that produced it.
4
+
5
+ Layout:
6
+ logs/lab_notebook.md chronological running journal (append-only)
7
+ logs/experiments/<name>.md one long report per experiment
8
+ logs/figures/<name>.png figures
9
+ logs/disk.md space accounting
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import subprocess
16
+ import time
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ ROOT = Path(__file__).resolve().parent.parent
21
+ LOGS = ROOT / "logs"
22
+ FIGS = LOGS / "figures"
23
+ EXPS = LOGS / "experiments"
24
+ NOTEBOOK = LOGS / "lab_notebook.md"
25
+
26
+
27
+ def _ts() -> str:
28
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
29
+
30
+
31
+ def _ensure() -> None:
32
+ for d in (LOGS, FIGS, EXPS):
33
+ d.mkdir(parents=True, exist_ok=True)
34
+
35
+
36
+ def note(title: str, body: str = "", level: str = "INFO") -> None:
37
+ """Append a timestamped entry to the running journal."""
38
+ _ensure()
39
+ with open(NOTEBOOK, "a") as f:
40
+ f.write(f"\n### [{_ts()}] {level} — {title}\n\n")
41
+ if body:
42
+ f.write(body.rstrip() + "\n")
43
+
44
+
45
+ def table(rows: list[dict], cols: list[str] | None = None) -> str:
46
+ """Render list-of-dicts as a markdown table."""
47
+ if not rows:
48
+ return "_(empty)_\n"
49
+ cols = cols or list(rows[0].keys())
50
+
51
+ def fmt(v):
52
+ if isinstance(v, float):
53
+ return f"{v:.4g}"
54
+ return str(v)
55
+
56
+ out = ["| " + " | ".join(cols) + " |",
57
+ "|" + "|".join("---" for _ in cols) + "|"]
58
+ for r in rows:
59
+ out.append("| " + " | ".join(fmt(r.get(c, "")) for c in cols) + " |")
60
+ return "\n".join(out) + "\n"
61
+
62
+
63
+ def write_report(name: str, content: str) -> Path:
64
+ """Write/overwrite one experiment's full report."""
65
+ _ensure()
66
+ p = EXPS / f"{name}.md"
67
+ p.write_text(content)
68
+ return p
69
+
70
+
71
+ def gpu_snapshot() -> dict:
72
+ try:
73
+ q = ("--query-gpu=memory.used,memory.total,utilization.gpu,temperature.gpu"
74
+ ",power.draw")
75
+ out = subprocess.run(
76
+ ["nvidia-smi", q, "--format=csv,noheader,nounits"],
77
+ capture_output=True, text=True, timeout=20).stdout.strip()
78
+ u, t, g, temp, pw = [x.strip() for x in out.split(",")]
79
+ return {"vram_used_mb": int(u), "vram_total_mb": int(t),
80
+ "gpu_util_pct": int(g), "temp_c": int(temp), "power_w": float(pw)}
81
+ except Exception as e:
82
+ return {"error": str(e)}
83
+
84
+
85
+ def disk_snapshot(paths: list[str] | None = None) -> dict:
86
+ paths = paths or [str(ROOT)]
87
+ out = {}
88
+ try:
89
+ df = subprocess.run(["df", "-BM", "--output=avail,used,size", str(ROOT)],
90
+ capture_output=True, text=True, timeout=20).stdout
91
+ parts = df.strip().splitlines()[-1].split()
92
+ out["fs_avail_mb"] = int(parts[0].rstrip("M"))
93
+ out["fs_used_mb"] = int(parts[1].rstrip("M"))
94
+ except Exception as e:
95
+ out["df_error"] = str(e)
96
+ for p in paths:
97
+ try:
98
+ du = subprocess.run(["du", "-sm", p], capture_output=True,
99
+ text=True, timeout=120).stdout.split()[0]
100
+ out[f"du_mb:{Path(p).name}"] = int(du)
101
+ except Exception:
102
+ pass
103
+ return out
104
+
105
+
106
+ def checkpoint(stage: str, extra: dict | None = None) -> dict:
107
+ """One-line health snapshot appended to the journal."""
108
+ snap = {"stage": stage, "gpu": gpu_snapshot(), "disk": disk_snapshot()}
109
+ if extra:
110
+ snap.update(extra)
111
+ note(f"checkpoint: {stage}", "```json\n" + json.dumps(snap, indent=1) + "\n```")
112
+ return snap
113
+
114
+
115
+ class Timer:
116
+ def __init__(self, label: str):
117
+ self.label = label
118
+
119
+ def __enter__(self):
120
+ self.t0 = time.time()
121
+ return self
122
+
123
+ def __exit__(self, *a):
124
+ self.dt = time.time() - self.t0
125
+ note(f"timing: {self.label}", f"`{self.dt:.1f}s` ({self.dt/60:.1f} min)")
src/make_report.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Final cross-model report: results table, quality-vs-diversity frontier chart,
3
+ and qualitative examples. Reads outputs/eval/results.csv.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import csv
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ import logbook
16
+
17
+ ROOT = Path(__file__).resolve().parent.parent
18
+
19
+ ORDER = ["base", "E0-baseline", "E1-div-individual", "E2-div-group",
20
+ "E3-multipos", "E4-divpo-emb", "E4-divpo-prob"]
21
+
22
+ PRETTY = {
23
+ "base": "Base (Qwen3-4B-Instruct)",
24
+ "E0-baseline": "E0 · GRPO, quality only",
25
+ "E1-div-individual": "E1 · div-grpo-individual (d_i)",
26
+ "E2-div-group": "E2 · div-grpo-group (d_i + m_i)",
27
+ "E3-multipos": "E3 · multi-positive weighted DPO",
28
+ "E4-divpo-emb": "E4a · DivPO (embedding)",
29
+ "E4-divpo-prob": "E4b · DivPO (probability)",
30
+ }
31
+
32
+
33
+ def load(path: Path) -> list[dict]:
34
+ rows = list(csv.DictReader(open(path)))
35
+ for r in rows:
36
+ for k, v in r.items():
37
+ if k != "model":
38
+ try:
39
+ r[k] = float(v)
40
+ except (TypeError, ValueError):
41
+ pass
42
+ key = {m: i for i, m in enumerate(ORDER)}
43
+ rows.sort(key=lambda r: key.get(r["model"], 99))
44
+ return rows
45
+
46
+
47
+ def frontier_chart(rows, out_path):
48
+ import matplotlib
49
+ matplotlib.use("Agg")
50
+ import matplotlib.pyplot as plt
51
+
52
+ fig, ax = plt.subplots(1, 3, figsize=(17, 5.2))
53
+ cols = plt.cm.tab10(np.linspace(0, 1, 10))
54
+
55
+ panels = [
56
+ ("pairwise", "Mean pairwise embedding distance", 0),
57
+ ("logdet", "Log-det volume (semantic coverage)", 1),
58
+ ("eff_rank", "Effective rank (mode count)", 2),
59
+ ]
60
+ base = next((r for r in rows if r["model"] == "base"), None)
61
+
62
+ for (xk, xlabel, i) in panels:
63
+ a = ax[i]
64
+ for j, r in enumerate(rows):
65
+ a.scatter(r[xk], r["quality"], s=170, color=cols[j % 10],
66
+ edgecolor="black", zorder=3,
67
+ label=PRETTY.get(r["model"], r["model"]))
68
+ a.annotate(r["model"].replace("-div", "").replace("divpo-", ""),
69
+ (r[xk], r["quality"]), fontsize=7,
70
+ xytext=(5, 5), textcoords="offset points")
71
+ if base:
72
+ a.axhline(base["quality"], ls=":", c="gray", lw=1)
73
+ a.axvline(base[xk], ls=":", c="gray", lw=1)
74
+ a.set_xlabel(xlabel); a.set_ylabel("Judge quality (0-10)")
75
+ a.grid(alpha=.3)
76
+ a.set_title(f"Quality vs {xlabel.split('(')[0].strip()}", fontsize=10)
77
+
78
+ handles, labels = ax[0].get_legend_handles_labels()
79
+ fig.legend(handles, labels, loc="lower center", ncol=4, fontsize=8,
80
+ frameon=False, bbox_to_anchor=(0.5, -0.06))
81
+ fig.suptitle("Quality–diversity frontier (dotted lines = base model)", fontsize=13)
82
+ plt.tight_layout()
83
+ plt.savefig(out_path, dpi=140, bbox_inches="tight")
84
+ plt.close()
85
+
86
+
87
+ def delta_table(rows):
88
+ base = next((r for r in rows if r["model"] == "base"), None)
89
+ if not base:
90
+ return []
91
+ keys = ["quality", "pairwise", "logdet", "distinct4", "self_bleu",
92
+ "eff_rank", "n_clusters", "ends_cleanly"]
93
+ out = []
94
+ for r in rows:
95
+ d = {"model": PRETTY.get(r["model"], r["model"])}
96
+ for k in keys:
97
+ base_v = base.get(k, 0.0)
98
+ d[f"Δ{k}"] = round(r.get(k, 0.0) - base_v, 4)
99
+ out.append(d)
100
+ return out
101
+
102
+
103
+ QUALITY_TOLERANCE = 0.3 # judge points we are willing to lose for diversity
104
+
105
+
106
+ def recommend(rows) -> str:
107
+ """Pick the arm to scale, from the data rather than from vibes.
108
+
109
+ Rule, stated before looking at results: among arms whose quality is within
110
+ QUALITY_TOLERANCE of the BASE model (not of the best arm -- we care about not
111
+ degrading the model, not about winning on quality), take the largest gain in
112
+ effective rank. Effective rank is the primary diversity measure because it is
113
+ the one metric shown to separate collapse from spread (see 02_setup). Ties
114
+ and near-ties are reported rather than hidden.
115
+ """
116
+ base = next((r for r in rows if r["model"] == "base"), None)
117
+ if not base:
118
+ return "_No base row; cannot compute deltas._"
119
+
120
+ cand = []
121
+ for r in rows:
122
+ if r["model"] == "base":
123
+ continue
124
+ dq = r["quality"] - base["quality"]
125
+ der = r.get("eff_rank", 0.0) - base.get("eff_rank", 0.0)
126
+ dld = r.get("logdet", 0.0) - base.get("logdet", 0.0)
127
+ cand.append({"model": PRETTY.get(r["model"], r["model"]),
128
+ "raw": r["model"], "Δquality": round(dq, 3),
129
+ "Δeff_rank": round(der, 3), "Δlogdet": round(dld, 3),
130
+ "ends_cleanly": round(r.get("ends_cleanly", 0.0), 3),
131
+ "eligible": dq >= -QUALITY_TOLERANCE and r.get("ends_cleanly", 0) > 0.9})
132
+
133
+ ok = [c for c in cand if c["eligible"]]
134
+ ranked = sorted(ok, key=lambda c: -c["Δeff_rank"])
135
+
136
+ if not ranked:
137
+ return (f"**No arm qualifies.** Every arm either lost more than "
138
+ f"{QUALITY_TOLERANCE} judge points against base or fell below a "
139
+ f"90% clean-completion rate. The honest recommendation is to fix "
140
+ f"the objective before scaling anything to 8B.\n\n"
141
+ + logbook.table(cand, ["model", "Δquality", "Δeff_rank",
142
+ "Δlogdet", "ends_cleanly", "eligible"]))
143
+
144
+ win = ranked[0]
145
+ runner = ranked[1] if len(ranked) > 1 else None
146
+ margin = (win["Δeff_rank"] - runner["Δeff_rank"]) if runner else None
147
+ close = margin is not None and margin < 0.25
148
+
149
+ txt = (f"**Scale `{win['model']}` to Qwen3-8B.** It gains "
150
+ f"{win['Δeff_rank']:+.2f} effective rank and {win['Δlogdet']:+.2f} "
151
+ f"log-det against base while holding quality at "
152
+ f"{win['Δquality']:+.2f} and completion at "
153
+ f"{100*win['ends_cleanly']:.0f}%.\n\n")
154
+ if runner:
155
+ txt += (f"Runner-up is `{runner['model']}` "
156
+ f"({runner['Δeff_rank']:+.2f} effective rank). ")
157
+ txt += ("The margin is **{:.2f}, which is small enough that this "
158
+ "ordering should not be treated as settled** on 50 eval prompts "
159
+ "— both are worth carrying forward.\n\n".format(margin)
160
+ if close else
161
+ "The margin ({:.2f}) is clear.\n\n".format(margin))
162
+ excluded = [c for c in cand if not c["eligible"]]
163
+ if excluded:
164
+ txt += ("Excluded for quality or completeness regression: "
165
+ + ", ".join(f"`{c['model']}` (Δq {c['Δquality']:+.2f}, "
166
+ f"complete {100*c['ends_cleanly']:.0f}%)"
167
+ for c in excluded) + ".\n\n")
168
+ txt += logbook.table(cand, ["model", "Δquality", "Δeff_rank", "Δlogdet",
169
+ "ends_cleanly", "eligible"])
170
+ return txt
171
+
172
+
173
+ def main():
174
+ ap = argparse.ArgumentParser()
175
+ ap.add_argument("--results", default="outputs/eval/results.csv")
176
+ ap.add_argument("--samples", default="outputs/eval/samples")
177
+ args = ap.parse_args()
178
+
179
+ rows = load(ROOT / args.results)
180
+ fig = logbook.FIGS / "frontier.png"
181
+ fig.parent.mkdir(parents=True, exist_ok=True)
182
+ frontier_chart(rows, fig)
183
+
184
+ main_cols = ["model", "quality", "ends_cleanly", "pairwise", "logdet",
185
+ "distinct4", "self_bleu", "eff_rank", "n_clusters", "tok_entropy", "words"]
186
+ tbl = [{k: (PRETTY.get(r["model"], r["model"]) if k == "model"
187
+ else round(r.get(k, 0.0), 4)) for k in main_cols} for r in rows]
188
+
189
+ # qualitative examples
190
+ qual = ""
191
+ sdir = ROOT / args.samples
192
+ for r in rows:
193
+ p = sdir / f"{r['model']}_examples.json"
194
+ if not p.exists():
195
+ continue
196
+ ex = json.load(open(p))
197
+ if not ex:
198
+ continue
199
+ e = ex[0]
200
+ qual += f"\n### {PRETTY.get(r['model'], r['model'])}\n\n"
201
+ qual += f"*Prompt:* {e['prompt'][:200]}\n\n"
202
+ qual += (f"*Set stats:* pairwise {e['pairwise']:.3f} · logdet {e['logdet']:.2f} "
203
+ f"· eff_rank {e['eff_rank']:.2f} · clusters {e['n_clusters']} "
204
+ f"· self-BLEU {e['self_bleu']:.3f}\n\n")
205
+ for i, t in enumerate(e["texts"][:3]):
206
+ first = t.strip().split("\n")[0][:180]
207
+ qual += f"{i+1}. {first}…\n"
208
+ body = f"""# Results — diversity-aware post-training for creative story generation
209
+
210
+ ## Main table
211
+
212
+ {logbook.table(tbl, main_cols)}
213
+
214
+ `self_bleu` is inverted in meaning: **lower is more diverse**. `tok_entropy` is
215
+ monitor-only and was never optimized.
216
+
217
+ ## Change vs. base model
218
+
219
+ {logbook.table(delta_table(rows))}
220
+
221
+ ## Quality–diversity frontier
222
+
223
+ ![frontier](figures/frontier.png)
224
+
225
+ ## Recommendation — which method to scale to Qwen3-8B
226
+
227
+ Selection rule, fixed before results were seen: among arms whose judge quality
228
+ is within {QUALITY_TOLERANCE} points of the **base** model and whose clean-completion
229
+ rate stays above 90%, take the largest gain in **effective rank**. Quality is
230
+ measured against base rather than against the best arm because the goal is to
231
+ add diversity without degrading the model, not to win on quality. Effective rank
232
+ is the primary diversity axis because it is the one mode metric demonstrated to
233
+ separate collapse from spread (`02_setup_and_deviations.md`, §11).
234
+
235
+ {recommend(rows)}
236
+
237
+ ## Qualitative examples (first 3 of 16 samples, same prompt)
238
+ {qual}
239
+ """
240
+ (logbook.LOGS.parent / "report.md").write_text(body)
241
+ print(body[:2500])
242
+ print("\nwrote report.md and", fig)
243
+ return 0
244
+
245
+
246
+ if __name__ == "__main__":
247
+ sys.exit(main())
src/qualitative.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Qualitative diff across arms: actually READ the stories.
3
+
4
+ Aggregate metrics can say "effective rank 2.0/16" without conveying that six of
5
+ the sixteen ships are literally named *Aethel*. This module extracts the
6
+ concrete signature of collapse -- shared openings, repeated proper nouns,
7
+ shared closing cadence, tone/genre spread -- so a human can see what changed.
8
+
9
+ Works on either a scored pool (`--pool`) or an eval dump (`--eval`), so the
10
+ base model, every checkpoint and every arm are read the same way.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import re
17
+ import sys
18
+ from collections import Counter, defaultdict
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+
25
+ OPEN_FRAME = re.compile(r"^The\s+\*?([A-Z][\w' ]{2,20})\*?\s+(\w+ed|\w+s)\s+"
26
+ r"(through|over|above|at|into|across)\b")
27
+ ITALIC_NAME = re.compile(r"\*([A-Z][\w' ]{2,24})\*")
28
+ CADENCE = [
29
+ (r"for the first time in (centuries|years|generations|decades|a long time)",
30
+ "'for the first time in X'"),
31
+ (r"\b(had )?begun to\b", "'begun to'"),
32
+ (r"\bwasn'?t\s+\w+[^.]{0,50},?\s+it\s+was\b", "'it wasn't X, it was Y'"),
33
+ (r"\n\n[^\n]{1,60}\.\s*$", "one-line closing paragraph"),
34
+ ]
35
+ # crude genre/register probes -- presence of ANY is weak, but the SPREAD across
36
+ # a 16-sample set is the interesting number
37
+ REGISTER = {
38
+ "elegiac/literary": r"\b(silence|ache|hollow|dust|memory|grief|quiet)\b",
39
+ "comic": r"\b(ridiculous|absurd|joke|laughed|idiot|bureaucra|paperwork)\b",
40
+ "horror": r"\b(scream|blood|rot|teeth|corpse|terror|wrong)\b",
41
+ "technical/SF": r"\b(protocol|sensor|reactor|alloy|deploy|calibrat|drone)\b",
42
+ "dialogue-driven": r'"[^"]{10,}"',
43
+ "second person": r"\bYou (are|were|walk|feel|stand|know)\b",
44
+ "epistolary": r"\b(Dear |entry |log:|Report |memo)\b",
45
+ }
46
+
47
+
48
+ def first_sentence(t: str, n: int = 130) -> str:
49
+ return re.split(r"(?<=[.!?])\s", t.strip())[0][:n]
50
+
51
+
52
+ def last_sentence(t: str, n: int = 120) -> str:
53
+ return re.split(r"(?<=[.!?])\s", t.strip())[-1][:n]
54
+
55
+
56
+ def analyze_group(texts: list[str]) -> dict:
57
+ n = len(texts)
58
+ names = Counter(x for t in texts for x in ITALIC_NAME.findall(t))
59
+ opens_the = sum(1 for t in texts if t.strip().startswith("The "))
60
+ frame = sum(1 for t in texts if OPEN_FRAME.match(t.strip()))
61
+ # repeated content words across stories (excluding prompt-driven ones is
62
+ # impossible in general, so we report the top shared nouns as-is)
63
+ stop = set("the a an and or but of to in on it its was were is are that this "
64
+ "with for as at by from had have has been be he she they we you i "
65
+ "his her their our my not no so then than there here what when "
66
+ "which who all more most into over under out up down".split())
67
+ per_story_vocab = [set(w for w in re.sub(r"[^a-z\s]", " ", t.lower()).split()
68
+ if w not in stop and len(w) > 3) for t in texts]
69
+ shared = Counter(w for v in per_story_vocab for w in v)
70
+ ubiquitous = {w: c for w, c in shared.most_common(400) if c >= max(3, int(0.6 * n))}
71
+ cad = {}
72
+ for pat, label in CADENCE:
73
+ cad[label] = sum(1 for t in texts if re.search(pat, t, re.I | re.M))
74
+ reg = {}
75
+ for label, pat in REGISTER.items():
76
+ reg[label] = sum(1 for t in texts if re.search(pat, t, re.I))
77
+ return {
78
+ "n": n,
79
+ "opens_with_The": opens_the,
80
+ "opening_frame_match": frame,
81
+ "distinct_first_5_words": len({" ".join(t.strip().split()[:5]).lower() for t in texts}),
82
+ "top_names": dict(names.most_common(6)),
83
+ "max_name_reuse": max(names.values()) if names else 0,
84
+ "ubiquitous_words": dict(list(ubiquitous.items())[:12]),
85
+ "cadence": cad,
86
+ "register_spread": reg,
87
+ "registers_present": sum(1 for v in reg.values() if v >= max(2, int(0.15 * n))),
88
+ }
89
+
90
+
91
+ def load_pool(tag: str, split: str, prompt_id: str | None):
92
+ rows = [json.loads(l) for l in
93
+ open(ROOT / "outputs" / f"pool_{tag}" / f"pool_{split}.jsonl") if l.strip()]
94
+ by = defaultdict(list)
95
+ for r in rows:
96
+ by[r["prompt_id"]].append(r)
97
+ if prompt_id:
98
+ return {prompt_id: by[prompt_id]}
99
+ return dict(by)
100
+
101
+
102
+ def load_eval(label: str, out: str):
103
+ p = ROOT / out / "samples" / f"{label}_full.json"
104
+ data = json.load(open(p))
105
+ return {d["prompt_id"]: [{"text": t, "prompt": d["prompt"]} for t in d["texts"]]
106
+ for d in data}
107
+
108
+
109
+ def main():
110
+ ap = argparse.ArgumentParser()
111
+ ap.add_argument("--pool", help="pool tag, e.g. 4b")
112
+ ap.add_argument("--eval", help="eval label, e.g. E0-baseline")
113
+ ap.add_argument("--out", default="outputs/eval")
114
+ ap.add_argument("--split", default="train")
115
+ ap.add_argument("--prompt-id", default=None)
116
+ ap.add_argument("--show", type=int, default=16, help="first sentences to print")
117
+ ap.add_argument("--limit-prompts", type=int, default=40)
118
+ args = ap.parse_args()
119
+
120
+ groups = load_pool(args.pool, args.split, args.prompt_id) if args.pool \
121
+ else load_eval(args.eval, args.out)
122
+ keys = list(groups)[: args.limit_prompts]
123
+
124
+ agg = defaultdict(list)
125
+ for pid in keys:
126
+ texts = [r["text"] for r in groups[pid]]
127
+ if len(texts) < 4:
128
+ continue
129
+ a = analyze_group(texts)
130
+ agg["opens_with_The"].append(a["opens_with_The"] / a["n"])
131
+ agg["opening_frame"].append(a["opening_frame_match"] / a["n"])
132
+ agg["distinct_openers"].append(a["distinct_first_5_words"] / a["n"])
133
+ agg["max_name_reuse"].append(a["max_name_reuse"])
134
+ agg["registers_present"].append(a["registers_present"])
135
+ for k, v in a["cadence"].items():
136
+ agg[f"cadence:{k}"].append(v / a["n"])
137
+
138
+ name = args.eval or f"pool-{args.pool}"
139
+ print(f"\n===== QUALITATIVE PROFILE: {name} ({len(keys)} prompts) =====")
140
+ for k, v in agg.items():
141
+ print(f" {k:38} {np.mean(v):.3f}")
142
+
143
+ pid = args.prompt_id or keys[0]
144
+ texts = [r["text"] for r in groups[pid]][: args.show]
145
+ print(f"\n--- prompt {pid} ---")
146
+ print(" ", groups[pid][0].get("prompt", "")[:220])
147
+ print("\nOPENINGS:")
148
+ for i, t in enumerate(texts):
149
+ print(f" {i+1:2d}. {first_sentence(t)}")
150
+ print("\nCLOSINGS:")
151
+ for i, t in enumerate(texts):
152
+ print(f" {i+1:2d}. ...{last_sentence(t)}")
153
+ return 0
154
+
155
+
156
+ if __name__ == "__main__":
157
+ sys.exit(main())
src/rewards.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reward channels for E0 / E1 / E2, built for TRL's `normalize_then_sum`.
3
+
4
+ AGGREGATION
5
+ -----------
6
+ GRPOConfig(multi_objective_aggregation="normalize_then_sum") z-scores every
7
+ reward function WITHIN its group before weighting and summing. That is exactly
8
+ the decoupled GDPO-style normalization the brief asks for, so we get it without
9
+ patching TRL.
10
+
11
+ It also changes what the weights mean: alpha and gamma multiply STANDARDIZED
12
+ channels, so alpha=0.5 reads as "half a standard deviation of diversity credit
13
+ per standard deviation of quality". No manual rescaling of d_i into the 0-10
14
+ judge range is needed or wanted.
15
+
16
+ GATING (why it is not simply "set it to 0")
17
+ -------------------------------------------
18
+ The brief says a gate failure should zero the total reward. Under per-channel
19
+ z-scoring, writing 0.0 into a channel does NOT mean "no credit" -- it means
20
+ "whatever 0.0 ranks as inside this group". That is fine for quality (range
21
+ [0,10], floor 0) and for deviation (range [0,2], floor 0), but it is actively
22
+ WRONG for the marginal contribution:
23
+
24
+ m_i = logdet(L) - logdet(L_-i) <= log(1+eps) ~ 0
25
+
26
+ m_i is always <= 0, so 0.0 is its CEILING. Gating a broken story to 0.0 in the
27
+ marginal channel would hand it the single highest diversity credit in the group
28
+ -- a reward-hacking channel we would have built ourselves.
29
+
30
+ So the uniform rule, applied to every diversity channel regardless of sign
31
+ convention: an ineligible sample is assigned the MINIMUM value among eligible
32
+ samples in its group. It can never out-rank a sample that earned its credit.
33
+ Combined with quality -> 0.0 (a hard floor in that channel), a gate-failed
34
+ story lands at the bottom of every channel it participates in.
35
+
36
+ CONSTANT-REWARD SAFETY
37
+ ----------------------
38
+ If every sample in a group is gated, a channel goes constant; TRL's
39
+ (x - mean)/(std + 1e-4) then yields ~0 for all of them. That is the correct
40
+ outcome: a group with no valid samples carries no signal. It is not a crash and
41
+ not a NaN, but it IS worth logging, so `frac_groups_degenerate` is tracked.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import statistics
47
+ from dataclasses import dataclass, field
48
+
49
+ import numpy as np
50
+
51
+ import gates as G
52
+ from diversity import l2_normalize, marginal_contributions, pairwise_deviation, zscore
53
+
54
+ # --------------------------------------------------------------- embeddings
55
+ _ENCODER = None
56
+ _EMB_MODEL = "BAAI/bge-base-en-v1.5"
57
+
58
+
59
+ def get_encoder(model_name: str = _EMB_MODEL, device: str | None = None):
60
+ """Module-level singleton; ~110M params (~0.22GB), negligible next to the policy.
61
+
62
+ Device is overridable via EMB_DEVICE so tests (and any process running
63
+ alongside a training job that already owns the GPU) can force CPU.
64
+ """
65
+ global _ENCODER
66
+ if _ENCODER is None:
67
+ import os
68
+ from sentence_transformers import SentenceTransformer
69
+ dev = device or os.environ.get("EMB_DEVICE", "cuda")
70
+ _ENCODER = SentenceTransformer(model_name, device=dev)
71
+ return _ENCODER
72
+
73
+
74
+ def embed(texts: list[str]) -> np.ndarray:
75
+ if not texts:
76
+ return np.zeros((0, 768))
77
+ E = get_encoder().encode(
78
+ texts, normalize_embeddings=True, batch_size=32,
79
+ show_progress_bar=False, convert_to_numpy=True,
80
+ )
81
+ return l2_normalize(np.asarray(E, dtype=np.float64))
82
+
83
+
84
+ # ------------------------------------------------------------------ config
85
+ @dataclass
86
+ class RewardConfig:
87
+ arm: str = "E0" # E0 | E1 | E2
88
+ alpha: float = 0.5 # weight on deviation channel
89
+ gamma: float = 0.5 # weight on marginal channel
90
+ tau: float = 5.0 # quality gate for diversity credit
91
+ min_words: int = G.MIN_WORDS
92
+ max_words: int = G.MAX_WORDS
93
+
94
+ def channels(self) -> list[str]:
95
+ if self.arm == "E0":
96
+ return ["quality"]
97
+ if self.arm == "E1":
98
+ return ["quality", "deviation"]
99
+ if self.arm == "E2":
100
+ return ["quality", "deviation", "marginal"]
101
+ raise ValueError(self.arm)
102
+
103
+ def weights(self) -> list[float]:
104
+ return {"E0": [1.0],
105
+ "E1": [1.0, self.alpha],
106
+ "E2": [1.0, self.alpha, self.gamma]}[self.arm]
107
+
108
+
109
+ @dataclass
110
+ class StepStats:
111
+ n: int = 0
112
+ gate_pass: float = 0.0
113
+ ends_cleanly: float = 0.0
114
+ mean_quality: float = 0.0
115
+ mean_quality_passing: float = 0.0
116
+ mean_novelty: float = 0.0
117
+ frac_above_tau: float = 0.0
118
+ mean_deviation: float = 0.0
119
+ mean_logdet: float = 0.0
120
+ mean_marginal: float = 0.0
121
+ mean_words: float = 0.0
122
+ frac_groups_degenerate: float = 0.0
123
+ reasons: dict = field(default_factory=dict)
124
+
125
+
126
+ def _gate_floor(values: np.ndarray, eligible: np.ndarray) -> np.ndarray:
127
+ """Ineligible samples take the minimum value among eligible ones.
128
+
129
+ Sign-convention agnostic: works for deviation (>=0) and for marginal (<=0)
130
+ alike. If nothing is eligible, the channel is flat -> z-scores to 0 in TRL.
131
+ """
132
+ out = values.astype(np.float64).copy()
133
+ if not eligible.any():
134
+ return np.zeros_like(out)
135
+ out[~eligible] = values[eligible].min()
136
+ return out
137
+
138
+
139
+ class RewardEngine:
140
+ """Scores one GRPO batch: gates -> judge -> embeddings -> per-channel values.
141
+
142
+ TRL calls each reward function separately, but we want ONE judge call set
143
+ and ONE embedding pass per batch. So the engine computes everything once and
144
+ memoizes on the batch signature; the per-channel closures just read it.
145
+ """
146
+
147
+ def __init__(self, cfg: RewardConfig, judge, wandb_run=None, log_prefix="train"):
148
+ self.cfg = cfg
149
+ self.judge = judge
150
+ self.wandb_run = wandb_run
151
+ self.log_prefix = log_prefix
152
+ self._sig = None
153
+ self._cache: dict[str, np.ndarray] = {}
154
+ self.last_stats: StepStats | None = None
155
+ self.history: list[StepStats] = []
156
+
157
+ # ---- core ----------------------------------------------------------
158
+ def compute(self, prompts: list[str], texts: list[str],
159
+ finish_reasons: list[str] | None = None) -> dict[str, np.ndarray]:
160
+ sig = hash((tuple(prompts), tuple(texts)))
161
+ if sig == self._sig:
162
+ return self._cache
163
+
164
+ n = len(texts)
165
+ finish_reasons = finish_reasons or [None] * n
166
+
167
+ # 1. programmatic gates (free, run first)
168
+ gres = [G.check(t, finish_reason=fr, min_words=self.cfg.min_words,
169
+ max_words=self.cfg.max_words)
170
+ for t, fr in zip(texts, finish_reasons)]
171
+ passed = np.array([r.passed for r in gres], dtype=bool)
172
+
173
+ # 2. judge only the stories that survived the gates -- never pay to
174
+ # score text we have already decided to zero out.
175
+ quality = np.zeros(n); novelty = np.zeros(n)
176
+ idx = [i for i in range(n) if passed[i]]
177
+ if idx:
178
+ scores = self.judge.score_many_sync([(prompts[i], texts[i]) for i in idx])
179
+ for i, s in zip(idx, scores):
180
+ quality[i] = s.quality
181
+ novelty[i] = s.novelty
182
+
183
+ # 3. embeddings + per-group diversity
184
+ E = embed(texts)
185
+ groups: dict[str, list[int]] = {}
186
+ for i, p in enumerate(prompts):
187
+ groups.setdefault(p, []).append(i)
188
+
189
+ dev = np.zeros(n); marg = np.zeros(n)
190
+ logdets, degenerate = [], 0
191
+ for _, ids in groups.items():
192
+ sub = E[ids]
193
+ d = pairwise_deviation(sub)
194
+ m = marginal_contributions(sub)
195
+ # z-score m within group: raw m has a long negative tail (a duplicate
196
+ # pair reaches log(eps) ~ -6.9) that would otherwise dominate.
197
+ mz = zscore(m)
198
+ for k, i in enumerate(ids):
199
+ dev[i] = d[k]; marg[i] = mz[k]
200
+ from diversity import logdet_volume
201
+ logdets.append(logdet_volume(sub))
202
+ if not passed[ids].any():
203
+ degenerate += 1
204
+
205
+ # 4. eligibility for diversity credit: gates AND quality >= tau.
206
+ # Conditioning is what stops "incoherent but different" from paying.
207
+ eligible = passed & (quality >= self.cfg.tau)
208
+
209
+ dev_c = np.zeros(n); marg_c = np.zeros(n)
210
+ for _, ids in groups.items():
211
+ ids_a = np.array(ids)
212
+ dev_c[ids_a] = _gate_floor(dev[ids_a], eligible[ids_a])
213
+ marg_c[ids_a] = _gate_floor(marg[ids_a], eligible[ids_a])
214
+
215
+ quality_c = np.where(passed, quality, 0.0)
216
+
217
+ out = {"quality": quality_c, "deviation": dev_c, "marginal": marg_c}
218
+ self._sig, self._cache = sig, out
219
+
220
+ # ---- stats ----
221
+ from collections import Counter
222
+ cnt = Counter(r for x in gres for r in x.reasons)
223
+ st = StepStats(
224
+ n=n,
225
+ gate_pass=float(passed.mean()),
226
+ ends_cleanly=float(np.mean([r.completeness for r in gres])),
227
+ mean_quality=float(quality.mean()),
228
+ mean_quality_passing=float(quality[passed].mean()) if passed.any() else 0.0,
229
+ mean_novelty=float(novelty[passed].mean()) if passed.any() else 0.0,
230
+ frac_above_tau=float(eligible.mean()),
231
+ mean_deviation=float(dev.mean()),
232
+ mean_logdet=float(np.mean(logdets)) if logdets else 0.0,
233
+ mean_marginal=float(marg.mean()),
234
+ mean_words=float(np.mean([r.n_words for r in gres])),
235
+ frac_groups_degenerate=degenerate / max(1, len(groups)),
236
+ reasons=dict(cnt),
237
+ )
238
+ self.last_stats = st
239
+ self.history.append(st)
240
+ self._log(st)
241
+ return out
242
+
243
+ def _log(self, st: StepStats) -> None:
244
+ print(f" [rw] gate={st.gate_pass:.2f} end={st.ends_cleanly:.2f} "
245
+ f"q={st.mean_quality_passing:.2f} >tau={st.frac_above_tau:.2f} "
246
+ f"dev={st.mean_deviation:.3f} logdet={st.mean_logdet:.2f} "
247
+ f"w={st.mean_words:.0f} {st.reasons if st.reasons else ''}", flush=True)
248
+ if self.wandb_run is not None:
249
+ p = self.log_prefix
250
+ self.wandb_run.log({
251
+ f"{p}/gate_pass": st.gate_pass,
252
+ f"{p}/ends_cleanly": st.ends_cleanly,
253
+ f"{p}/quality_passing": st.mean_quality_passing,
254
+ f"{p}/quality_all": st.mean_quality,
255
+ f"{p}/novelty": st.mean_novelty,
256
+ f"{p}/frac_above_tau": st.frac_above_tau,
257
+ f"{p}/deviation": st.mean_deviation,
258
+ f"{p}/logdet": st.mean_logdet,
259
+ f"{p}/marginal_z": st.mean_marginal,
260
+ f"{p}/words": st.mean_words,
261
+ f"{p}/groups_degenerate": st.frac_groups_degenerate,
262
+ })
263
+
264
+ # ---- TRL adapters ---------------------------------------------------
265
+ def make_reward_funcs(self):
266
+ """Return TRL-compatible reward callables, one per active channel."""
267
+ funcs = []
268
+ for ch in self.cfg.channels():
269
+ funcs.append(self._make(ch))
270
+ return funcs
271
+
272
+ def _make(self, channel: str):
273
+ engine = self
274
+
275
+ def f(completions, prompts=None, **kwargs):
276
+ texts = [_text(c) for c in completions]
277
+ ps = [_ptext(p) for p in (prompts or [""] * len(texts))]
278
+ fr = kwargs.get("finish_reasons")
279
+ vals = engine.compute(ps, texts, fr)
280
+ return [float(x) for x in vals[channel]]
281
+
282
+ f.__name__ = f"{channel}_reward"
283
+ return f
284
+
285
+
286
+ def _text(c) -> str:
287
+ if isinstance(c, list):
288
+ return c[-1].get("content", "") if c else ""
289
+ if isinstance(c, dict):
290
+ return c.get("content", "")
291
+ return str(c)
292
+
293
+
294
+ def _ptext(p) -> str:
295
+ if isinstance(p, list):
296
+ # chat format: the user turn carries the writing prompt
297
+ for m in reversed(p):
298
+ if isinstance(m, dict) and m.get("role") == "user":
299
+ return m.get("content", "")
300
+ return p[-1].get("content", "") if p else ""
301
+ return str(p)
src/smoke_vllm.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke test: vLLM on Blackwell + Qwen3-4B + new system prompt + gates.
2
+
3
+ The specific question: does the rewritten system prompt (200-500 word budget,
4
+ explicit ending instruction) plus max_new_tokens=768 actually fix the 100%
5
+ truncation rate that killed the prior run? Prints the gate pass rate.
6
+ """
7
+ import json
8
+ import sys
9
+ import time
10
+
11
+ import numpy as np
12
+ from transformers import AutoTokenizer
13
+
14
+ import gates
15
+ from data import load_prompts
16
+ from generate import build_llm, generate, save_gens
17
+
18
+ MODEL = "Qwen/Qwen3-4B-Instruct-2507"
19
+ N_PROMPTS = 8
20
+ G = 8
21
+
22
+ def main():
23
+ t0 = time.time()
24
+ tok = AutoTokenizer.from_pretrained(MODEL)
25
+ llm = build_llm(MODEL, gpu_mem_util=0.85, seed=0)
26
+ print(f"[load] {time.time()-t0:.1f}s", flush=True)
27
+
28
+ prompts = load_prompts("eval")[:N_PROMPTS]
29
+ t1 = time.time()
30
+ gens = generate(llm, tok, prompts, n=G, temperature=1.0, top_p=1.0, seed=1234)
31
+ dt = time.time() - t1
32
+ ntok = sum(g.n_tokens for g in gens)
33
+ print(f"[gen] {len(gens)} stories, {ntok} tok in {dt:.1f}s -> {ntok/dt:.0f} tok/s", flush=True)
34
+
35
+ res = [gates.check(g.text, finish_reason=g.finish_reason) for g in gens]
36
+ passed = sum(r.passed for r in res)
37
+ complete = sum(r.completeness for r in res)
38
+ capped = sum(r.hit_token_cap for r in res)
39
+ words = np.array([r.n_words for r in res])
40
+
41
+ print("\n=== GATES ===")
42
+ print(f" pass_all_gates {passed}/{len(res)} ({100*passed/len(res):.0f}%)")
43
+ print(f" ends_cleanly {complete:.0f}/{len(res)} ({100*complete/len(res):.0f}%) <- prior run: 0-8%")
44
+ print(f" hit_token_cap {capped}/{len(res)}")
45
+ print(f" words p5={np.percentile(words,5):.0f} med={np.median(words):.0f} p95={np.percentile(words,95):.0f} max={words.max()}")
46
+ from collections import Counter
47
+ c = Counter(r for x in res for r in x.reasons)
48
+ print(f" failure reasons {dict(c)}")
49
+ print(f" mean_logprob {np.mean([g.mean_logprob for g in gens]):.3f}")
50
+
51
+ save_gens(gens, "outputs/smoke/vllm_smoke.jsonl")
52
+ print("\n--- sample tail (last 90 chars each, first 4) ---")
53
+ for g in gens[:4]:
54
+ print(" ...", repr(g.text[-90:]))
55
+ print(f"\n[total] {time.time()-t0:.1f}s")
56
+ return 0 if complete / len(res) > 0.8 else 1
57
+
58
+ if __name__ == "__main__":
59
+ sys.exit(main())
src/status.sh ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # User is in IST (UTC+5:30) -- report wall-clock in IST, not UTC.
3
+ export TZ="Asia/Kolkata"
4
+ # One-line-ish status snapshot for periodic reporting.
5
+ cd /workspace/creative-writing
6
+ STAGE=$(grep -aE "^== (START|OK|FAIL|ABORT)" logs/pipeline.log 2>/dev/null | tail -1 | sed 's/ */ /g')
7
+
8
+ # active training log (most recently modified)
9
+ # only consider logs of a CURRENTLY RUNNING job; otherwise a finished
10
+ # stage's log masquerades as live status (this bit me once already).
11
+ RUNCFG=$(pgrep -af "src/train_(grpo|dpo).py" | grep -oE "configs/[^ ]+\.yaml" | head -1)
12
+ ACT=""
13
+ if [ -n "$RUNCFG" ]; then
14
+ NM=$(python3 -c "import yaml,sys;print(yaml.safe_load(open(sys.argv[1]))['name'])" "$RUNCFG" 2>/dev/null)
15
+ for c in "logs/train_${NM}.log" "logs/lrprobe_3e-5.log" $(ls -t logs/*.log 2>/dev/null); do
16
+ [ -f "$c" ] && ACT="$c" && break
17
+ done
18
+ fi
19
+ PROG=""; RW=""; ENT=""
20
+ if [ -n "$ACT" ]; then
21
+ PROG=$(tr '\r' '\n' < "$ACT" | grep -aE "^ *[0-9]+%\|" | tail -1 | sed 's/ */ /g')
22
+ RW=$(grep -ao "\[rw\].*" "$ACT" 2>/dev/null | tail -1)
23
+ ENT=$(grep -aoE "'entropy': '[^']*'" "$ACT" 2>/dev/null | tail -1)
24
+ fi
25
+
26
+ VRAM=$(nvidia-smi --query-gpu=memory.used,utilization.gpu --format=csv,noheader,nounits | tr ',' '/' | tr -d ' ')
27
+ DISK=$(df -BG --output=avail /workspace | tail -1 | tr -d ' ')
28
+ SPEND=$(python3 -c "
29
+ import os,urllib.request,json
30
+ try:
31
+ r=urllib.request.Request('https://openrouter.ai/api/v1/key',headers={'Authorization':'Bearer '+os.environ['OPENROUTER_API_KEY']})
32
+ d=json.load(urllib.request.urlopen(r,timeout=20))['data']
33
+ print(f\"\${d['usage']:.2f}used/\${d['limit_remaining']:.2f}left\")
34
+ except Exception: print('n/a')" 2>/dev/null)
35
+
36
+ ERR=$(grep -alE "OutOfMemory|Traceback|UNHEALTHY|GUARDRAIL" logs/train_*.log logs/E[34]*.log logs/eval_all.log 2>/dev/null | tr '\n' ',' )
37
+
38
+ NOW=$(TZ=Asia/Kolkata date +"%H:%M IST")
39
+ echo "[$NOW] STAGE=[$STAGE] PROG=[$PROG] ${RW:+RW=[$RW] }${ENT:+$ENT }vram=${VRAM} disk=${DISK} judge=${SPEND}${ERR:+ ERRORS_IN=$ERR}"
src/test_diversity.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for src/diversity.py. Run: python3 -m pytest src/test_diversity.py -q
3
+ or: python3 src/test_diversity.py
4
+ """
5
+ import numpy as np
6
+
7
+ from diversity import (
8
+ cosine_kernel,
9
+ greedy_diverse_subset,
10
+ l2_normalize,
11
+ logdet_volume,
12
+ marginal_contributions,
13
+ pairwise_deviation,
14
+ zscore,
15
+ )
16
+
17
+ RNG = np.random.default_rng(0)
18
+
19
+
20
+ def _orth(G, d):
21
+ """G mutually orthonormal rows in R^d."""
22
+ Q, _ = np.linalg.qr(RNG.standard_normal((d, G)))
23
+ return Q.T[:G]
24
+
25
+
26
+ # ---------------------------------------------------------------- deviation
27
+ def test_deviation_duplicates_are_zero():
28
+ """Exact duplicates: every pair distance is 0, so d_i == 0 for all."""
29
+ e = l2_normalize(RNG.standard_normal((1, 32)))
30
+ E = np.repeat(e, 8, axis=0)
31
+ d = pairwise_deviation(E)
32
+ assert np.allclose(d, 0.0, atol=1e-9), d
33
+
34
+
35
+ def test_deviation_orthogonal_is_one():
36
+ """Orthogonal rows: cos = 0 for every pair, so d_i == 1 exactly."""
37
+ E = _orth(8, 64)
38
+ d = pairwise_deviation(E)
39
+ assert np.allclose(d, 1.0, atol=1e-9), d
40
+
41
+
42
+ def test_deviation_singleton_and_empty():
43
+ assert pairwise_deviation(np.zeros((1, 8))).shape == (1,)
44
+ assert pairwise_deviation(np.zeros((1, 8)))[0] == 0.0
45
+ assert pairwise_deviation(np.zeros((0, 8))).shape == (0,)
46
+
47
+
48
+ def test_deviation_flags_the_odd_one_out():
49
+ """7 near-identical + 1 far: the outlier must have the highest d_i."""
50
+ base = l2_normalize(RNG.standard_normal((1, 64)))
51
+ tight = l2_normalize(np.repeat(base, 7, axis=0) + 0.01 * RNG.standard_normal((7, 64)))
52
+ far = l2_normalize(RNG.standard_normal((1, 64)))
53
+ E = np.vstack([tight, far])
54
+ d = pairwise_deviation(E)
55
+ assert d.argmax() == 7, d
56
+
57
+
58
+ # ------------------------------------------------------- marginal / logdet
59
+ def test_marginal_duplicate_is_large_negative():
60
+ """A duplicated direction is already spanned -> dropping one costs ~nothing,
61
+ so the PRESENT duplicate's marginal is driven to ~log(eps), very negative."""
62
+ e = l2_normalize(RNG.standard_normal((1, 32)))
63
+ E = np.vstack([np.repeat(e, 2, axis=0), _orth(4, 32)])
64
+ m = marginal_contributions(E)
65
+ # the two duplicates (rows 0,1) are the least valuable members
66
+ assert m[0] < -3.0 and m[1] < -3.0, m
67
+ assert m[:2].max() < m[2:].min(), m
68
+
69
+
70
+ def test_marginal_orthogonal_is_near_zero_and_uniform():
71
+ """Orthonormal rows: L = (1+eps)I, dropping any row costs log(1+eps) ~ 0.
72
+ 'High m_i for all' in the sense of at-ceiling and symmetric."""
73
+ E = _orth(8, 64)
74
+ m = marginal_contributions(E)
75
+ assert np.allclose(m, m[0], atol=1e-9), m
76
+ assert abs(m[0] - np.log1p(1e-3)) < 1e-6, m[0]
77
+
78
+
79
+ def test_marginal_is_bounded_above_by_zero_ish():
80
+ """logdet is monotone under adding a row with unit norm + jitter, so
81
+ m_i can never exceed log(1+eps)."""
82
+ E = l2_normalize(RNG.standard_normal((12, 64)))
83
+ m = marginal_contributions(E)
84
+ assert m.max() <= np.log1p(1e-3) + 1e-9, m.max()
85
+
86
+
87
+ def test_logdet_ordering_dup_lt_spread_lt_orthogonal():
88
+ e = l2_normalize(RNG.standard_normal((1, 64)))
89
+ dup = np.repeat(e, 8, axis=0)
90
+ spread = l2_normalize(RNG.standard_normal((8, 64)))
91
+ orth = _orth(8, 64)
92
+ assert logdet_volume(dup) < logdet_volume(spread) < logdet_volume(orth)
93
+
94
+
95
+ def test_kernel_is_psd_even_with_duplicates():
96
+ e = l2_normalize(RNG.standard_normal((1, 16)))
97
+ L = cosine_kernel(np.repeat(e, 6, axis=0))
98
+ assert np.linalg.eigvalsh(L).min() > 0, "jitter failed to make L PD"
99
+ assert np.isfinite(logdet_volume(np.repeat(e, 6, axis=0)))
100
+
101
+
102
+ # --------------------------------------- THE E1-vs-E2 HYPOTHESIS, AS A TEST
103
+ def test_two_clusters_fool_deviation_but_not_logdet():
104
+ """This is the claim E2 rests on, so it gets asserted rather than assumed.
105
+
106
+ Config A: two tight antipodal clusters of 4 (rank ~2, 'diverse' only in
107
+ the sense that half the samples are far from the other half).
108
+ Config B: 8 genuinely spread directions (rank ~8).
109
+
110
+ Pairwise deviation cannot tell these apart -- mean pairwise distance for A
111
+ is actually HIGHER, because antipodal pairs sit at cos = -1. Log-det sees
112
+ straight through it: A occupies a 2-dimensional subspace.
113
+ """
114
+ u, v = _orth(2, 64)
115
+ jit = 0.01
116
+ A = l2_normalize(np.vstack([
117
+ np.repeat(u[None], 4, axis=0) + jit * RNG.standard_normal((4, 64)),
118
+ np.repeat(-u[None], 4, axis=0) + jit * RNG.standard_normal((4, 64)),
119
+ ]))
120
+ B = _orth(8, 64)
121
+
122
+ dev_A, dev_B = pairwise_deviation(A).mean(), pairwise_deviation(B).mean()
123
+ vol_A, vol_B = logdet_volume(A), logdet_volume(B)
124
+
125
+ # deviation RANKS THE DEGENERATE SET HIGHER -- the failure mode, reproduced
126
+ assert dev_A > dev_B, (dev_A, dev_B)
127
+ # log-det correctly ranks the spread set far higher
128
+ assert vol_B > vol_A + 10.0, (vol_A, vol_B)
129
+
130
+ # and per-sample: in A every member is redundant (its twin covers it),
131
+ # so marginal contributions are uniformly terrible
132
+ assert marginal_contributions(A).max() < -3.0
133
+ assert marginal_contributions(B).min() > -1e-3
134
+ _ = v # second basis vector unused, kept for clarity of construction
135
+
136
+
137
+ # ------------------------------------------------------------------ zscore
138
+ def test_zscore_constant_input_is_zeros_not_nan():
139
+ """A constant reward column must degrade to 0, never NaN -- otherwise it
140
+ poisons the whole advantage tensor."""
141
+ z = zscore(np.full(8, 3.7))
142
+ assert np.all(np.isfinite(z)) and np.allclose(z, 0.0)
143
+
144
+
145
+ def test_zscore_standardizes():
146
+ z = zscore(RNG.standard_normal(64))
147
+ assert abs(z.mean()) < 1e-9 and abs(z.std() - 1.0) < 1e-9
148
+
149
+
150
+ # ------------------------------------------------------------ greedy subset
151
+ def test_greedy_avoids_duplicates_when_quality_is_flat():
152
+ """With flat quality, selection is pure logdet: must not pick both dupes."""
153
+ e = l2_normalize(RNG.standard_normal((1, 64)))
154
+ E = np.vstack([np.repeat(e, 3, axis=0), _orth(3, 64)]) # rows 0,1,2 identical
155
+ sel = greedy_diverse_subset(np.ones(6), E, k=3, lam=1.0)
156
+ assert len(sel) == 3
157
+ assert len(set(sel) & {0, 1, 2}) <= 1, sel
158
+
159
+
160
+ def test_greedy_respects_quality_when_lambda_is_zero():
161
+ E = l2_normalize(RNG.standard_normal((10, 64)))
162
+ q = np.arange(10, dtype=float)
163
+ sel = greedy_diverse_subset(q, E, k=3, lam=0.0)
164
+ assert sorted(sel) == [7, 8, 9], sel
165
+
166
+
167
+ def test_greedy_k_larger_than_pool():
168
+ E = l2_normalize(RNG.standard_normal((3, 32)))
169
+ assert len(greedy_diverse_subset(np.ones(3), E, k=8)) == 3
170
+
171
+
172
+ if __name__ == "__main__":
173
+ import sys, traceback
174
+ fns = [(n, f) for n, f in sorted(globals().items())
175
+ if n.startswith("test_") and callable(f)]
176
+ bad = 0
177
+ for n, f in fns:
178
+ try:
179
+ f()
180
+ print(f" PASS {n}")
181
+ except Exception:
182
+ bad += 1
183
+ print(f" FAIL {n}")
184
+ traceback.print_exc()
185
+ print(f"\n{len(fns)-bad}/{len(fns)} passed")
186
+ sys.exit(1 if bad else 0)
src/test_eval_metrics.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for evaluate.py's pure metric functions. These run LAST in the pipeline,
3
+ so a bug here would waste every GPU-hour before it.
4
+
5
+ Directionality is the thing being pinned down: self-BLEU is inverted relative to
6
+ every other diversity metric (lower = more diverse), and getting that backwards
7
+ in the report would invert the study's conclusion.
8
+ """
9
+ import numpy as np
10
+
11
+ from diversity import effective_rank
12
+ from evaluate import cluster_count, distinct_n, self_bleu, topk_entropy
13
+
14
+ IDENTICAL = ["The harbor clock struck twelve and the ferry did not come."] * 8
15
+ VARIED = [
16
+ "The harbor clock struck twelve and the ferry did not come.",
17
+ "Marguerite sold her mother's piano to pay for the greenhouse.",
18
+ "In the third week of the drought, the well began speaking Latin.",
19
+ "He counted seventeen crows before admitting he was being followed.",
20
+ "The recipe called for one tablespoon of regret, finely minced.",
21
+ "Every letter she mailed arrived a decade before she wrote it.",
22
+ "Nobody warned the astronauts that the moon would be so loud.",
23
+ "My grandfather traded his shadow for a working knowledge of bees.",
24
+ ]
25
+
26
+
27
+ def test_distinct4_identical_is_low():
28
+ """8 copies of one sentence: only 1/8 of 4-grams are new."""
29
+ d = distinct_n(IDENTICAL, 4)
30
+ assert d < 0.2, d
31
+
32
+
33
+ def test_distinct4_varied_is_high():
34
+ assert distinct_n(VARIED, 4) > 0.9
35
+
36
+
37
+ def test_distinct4_ordering():
38
+ assert distinct_n(VARIED, 4) > distinct_n(IDENTICAL, 4)
39
+
40
+
41
+ def test_self_bleu_is_inverted_lower_means_more_diverse():
42
+ """THE directionality check. Identical texts must score HIGH self-BLEU."""
43
+ hi = self_bleu(IDENTICAL)
44
+ lo = self_bleu(VARIED)
45
+ assert hi > lo, f"self-BLEU not inverted: identical={hi:.4f} varied={lo:.4f}"
46
+ assert hi > 0.5, f"identical texts should have high self-BLEU, got {hi:.4f}"
47
+ assert lo < 0.1, f"varied texts should have low self-BLEU, got {lo:.4f}"
48
+
49
+
50
+ def test_self_bleu_bounded():
51
+ for texts in (IDENTICAL, VARIED):
52
+ v = self_bleu(texts)
53
+ assert 0.0 <= v <= 1.0, v
54
+
55
+
56
+ def test_self_bleu_handles_short_and_single():
57
+ assert self_bleu(["hi"]) == 0.0
58
+ assert self_bleu([]) == 0.0
59
+ assert 0.0 <= self_bleu(["a b", "c d"]) <= 1.0
60
+
61
+
62
+ def test_cluster_count_two_clear_modes():
63
+ rng = np.random.default_rng(0)
64
+ a = rng.standard_normal(32); a /= np.linalg.norm(a)
65
+ b = rng.standard_normal(32); b /= np.linalg.norm(b)
66
+ E = np.vstack([np.tile(a, (8, 1)) + 0.02 * rng.standard_normal((8, 32)),
67
+ np.tile(b, (8, 1)) + 0.02 * rng.standard_normal((8, 32))])
68
+ E /= np.linalg.norm(E, axis=1, keepdims=True)
69
+ assert cluster_count(E) == 2, cluster_count(E)
70
+
71
+
72
+ def test_cluster_count_no_structure_is_one():
73
+ """Near-identical embeddings have no cluster structure -> a single mode.
74
+ Originally FAILED at silhouette>0.05 (returned k=4 on a fully collapsed set),
75
+ which is why SILHOUETTE_MIN was recalibrated to 0.50."""
76
+ rng = np.random.default_rng(1)
77
+ a = rng.standard_normal(32); a /= np.linalg.norm(a)
78
+ E = np.tile(a, (16, 1)) + 0.001 * rng.standard_normal((16, 32))
79
+ E /= np.linalg.norm(E, axis=1, keepdims=True)
80
+ assert cluster_count(E) == 1, cluster_count(E)
81
+
82
+
83
+ def test_cluster_count_small_input():
84
+ assert cluster_count(np.eye(3)) == 1
85
+
86
+
87
+ def test_topk_entropy_peaked_vs_flat():
88
+ """A near-deterministic distribution has ~0 entropy; uniform over k has log k."""
89
+ peaked = [{"a": np.log(0.999), "b": np.log(0.001)}]
90
+ flat = [{c: np.log(0.25) for c in "abcd"}]
91
+ assert topk_entropy(peaked) < 0.05
92
+ assert abs(topk_entropy(flat) - np.log(4)) < 1e-6
93
+
94
+
95
+ def test_topk_entropy_empty():
96
+ assert topk_entropy([]) == 0.0
97
+ assert topk_entropy([{}]) == 0.0
98
+
99
+
100
+ def test_topk_entropy_renormalizes_truncated_table():
101
+ """vLLM returns only the top-k, which does not sum to 1; we renormalize."""
102
+ partial = [{"a": np.log(0.4), "b": np.log(0.2)}] # sums to 0.6
103
+ h = topk_entropy(partial)
104
+ p = np.array([2 / 3, 1 / 3])
105
+ assert abs(h - float(-(p * np.log(p)).sum())) < 1e-9
106
+
107
+
108
+ def test_eff_rank_identical_is_one():
109
+ rng = np.random.default_rng(3)
110
+ a = rng.standard_normal(32); a /= np.linalg.norm(a)
111
+ assert abs(effective_rank(np.tile(a, (16, 1))) - 1.0) < 1e-6
112
+
113
+
114
+ def test_eff_rank_orthogonal_is_n():
115
+ Q, _ = np.linalg.qr(np.random.default_rng(4).standard_normal((32, 8)))
116
+ assert abs(effective_rank(Q.T[:8]) - 8.0) < 1e-6
117
+
118
+
119
+ def test_eff_rank_two_clusters_is_about_two():
120
+ """The case silhouette got right but only at a threshold that broke the
121
+ collapsed case. Effective rank handles both without a threshold."""
122
+ rng = np.random.default_rng(5)
123
+ a = rng.standard_normal(32); a /= np.linalg.norm(a)
124
+ b = rng.standard_normal(32); b /= np.linalg.norm(b)
125
+ E = np.vstack([np.tile(a, (8, 1)) + 0.02 * rng.standard_normal((8, 32)),
126
+ np.tile(b, (8, 1)) + 0.02 * rng.standard_normal((8, 32))])
127
+ E /= np.linalg.norm(E, axis=1, keepdims=True)
128
+ assert 1.8 < effective_rank(E) < 2.6, effective_rank(E)
129
+
130
+
131
+ def test_eff_rank_is_monotone_in_spread():
132
+ """Monotone IN EXPECTATION. Averaged over draws because effective rank
133
+ saturates around 12.6 (not 16) for 16 unit vectors in 32 dims -- random
134
+ directions retain residual correlation -- so single draws at the top of the
135
+ range can invert by chance. The metric is fine; the assertion has to be
136
+ statistical."""
137
+ rng = np.random.default_rng(6)
138
+ a = rng.standard_normal(32); a /= np.linalg.norm(a)
139
+ prev = 0.0
140
+ for noise in (0.001, 0.05, 0.15, 0.5, 2.0):
141
+ vals = []
142
+ for _ in range(5):
143
+ E = np.tile(a, (16, 1)) + noise * rng.standard_normal((16, 32))
144
+ E /= np.linalg.norm(E, axis=1, keepdims=True)
145
+ vals.append(effective_rank(E))
146
+ r = float(np.mean(vals))
147
+ assert r >= prev - 1e-6, f"non-monotone at noise={noise}: {r} < {prev}"
148
+ prev = r
149
+
150
+
151
+ def test_eff_rank_separates_collapsed_from_spread():
152
+ """The exact discrimination silhouette FAILED: collapsed 0.202 vs spread
153
+ 0.195 were indistinguishable. Effective rank must separate them clearly."""
154
+ rng = np.random.default_rng(7)
155
+ a = rng.standard_normal(32); a /= np.linalg.norm(a)
156
+ collapsed = np.tile(a, (16, 1)) + 0.001 * rng.standard_normal((16, 32))
157
+ collapsed /= np.linalg.norm(collapsed, axis=1, keepdims=True)
158
+ spread = rng.standard_normal((16, 32))
159
+ spread /= np.linalg.norm(spread, axis=1, keepdims=True)
160
+ assert effective_rank(spread) > effective_rank(collapsed) + 8.0
161
+
162
+
163
+ if __name__ == "__main__":
164
+ import sys, traceback
165
+ fns = [(n, f) for n, f in sorted(globals().items())
166
+ if n.startswith("test_") and callable(f)]
167
+ bad = 0
168
+ for n, f in fns:
169
+ try:
170
+ f(); print(f" PASS {n}")
171
+ except Exception:
172
+ bad += 1; print(f" FAIL {n}"); traceback.print_exc()
173
+ print(f"\n{len(fns)-bad}/{len(fns)} passed")
174
+ sys.exit(1 if bad else 0)
src/test_pairs.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synthetic tests for E3/E4 preference-pair construction. Catches logic errors now
3
+ rather than four hours into the pipeline.
4
+
5
+ Most important: test_divpo_prob_picks_least_probable_as_chosen. DivPO's
6
+ probability variant inverts the usual intuition -- the CHOSEN story is the one
7
+ the model found LEAST likely. Getting that backwards would train the policy
8
+ straight into the greedy mode while the logs looked entirely normal.
9
+ """
10
+ import numpy as np
11
+
12
+ from build_pairs import build_divpo, build_multipos, normalize_weights
13
+
14
+
15
+ def make_item(pid, idx, quality, dev, meanlp, passed=True, text=None):
16
+ return {"prompt_id": pid, "idx": idx, "prompt": f"prompt-{pid}",
17
+ "text": text or f"story-{pid}-{idx}", "quality": quality,
18
+ "deviation": dev, "mean_logprob": meanlp, "gate_passed": passed,
19
+ "marginal": -1.0, "n_words": 300}
20
+
21
+
22
+ def simple_pool():
23
+ """One prompt, 6 stories spanning quality/deviation/logprob."""
24
+ return {"p0": [
25
+ make_item("p0", 0, 8.0, 0.40, -1.50), # high q, MOST diverse, least probable
26
+ make_item("p0", 1, 7.0, 0.20, -0.80), # high q, low dev -> "competent cliche"
27
+ make_item("p0", 2, 6.5, 0.30, -1.10),
28
+ make_item("p0", 3, 3.0, 0.35, -0.60), # low q, HIGH dev -> must NOT be chosen
29
+ make_item("p0", 4, 2.0, 0.05, -0.40), # low q, least diverse, MOST probable
30
+ make_item("p0", 5, 1.0, 0.10, -0.90, passed=False),
31
+ ]}
32
+
33
+
34
+ # --------------------------------------------------------------------- E4
35
+ def test_divpo_emb_chosen_is_most_diverse_above_rho():
36
+ rows, st = build_divpo(simple_pool(), "emb", rho=6.0)
37
+ assert st["n_rows"] == 1
38
+ r = rows[0]
39
+ assert r["chosen"] == "story-p0-0", r
40
+ assert r["chosen_quality"] >= 6.0
41
+
42
+
43
+ def test_divpo_emb_never_chooses_a_diverse_but_low_quality_story():
44
+ """Story 3 has high deviation but quality 3.0. Quality gating must exclude
45
+ it -- this is the 'different because it is worse' failure."""
46
+ rows, _ = build_divpo(simple_pool(), "emb", rho=6.0)
47
+ assert rows[0]["chosen"] != "story-p0-3"
48
+
49
+
50
+ def test_divpo_emb_rejected_is_least_diverse_below_rho():
51
+ rows, _ = build_divpo(simple_pool(), "emb", rho=6.0)
52
+ assert rows[0]["rejected"] == "story-p0-4", rows[0]
53
+
54
+
55
+ def test_divpo_prob_picks_least_probable_as_chosen():
56
+ """Most diverse == LOWEST length-normalized logprob; least diverse ==
57
+ HIGHEST (the near-greedy sample). Inverting this trains toward the mode."""
58
+ rows, _ = build_divpo(simple_pool(), "prob", rho=6.0)
59
+ r = rows[0]
60
+ assert r["chosen"] == "story-p0-0", f"chosen should be least probable: {r}"
61
+ assert r["rejected"] == "story-p0-4", f"rejected should be most probable: {r}"
62
+ assert r["chosen_meanlp"] < r["rejected_meanlp"]
63
+
64
+
65
+ def test_divpo_skips_prompt_with_no_qualifying_chosen():
66
+ pool = {"p0": [make_item("p0", i, 3.0, 0.2, -1.0) for i in range(4)]}
67
+ rows, st = build_divpo(pool, "emb", rho=6.0)
68
+ assert rows == [] and st["no_chosen"] == 1 and st["skip_rate"] == 1.0
69
+
70
+
71
+ def test_divpo_skips_prompt_with_no_rejected():
72
+ pool = {"p0": [make_item("p0", i, 9.0, 0.2 + 0.01 * i, -1.0) for i in range(4)]}
73
+ rows, st = build_divpo(pool, "emb", rho=6.0)
74
+ assert rows == [] and st["no_rejected"] == 1
75
+
76
+
77
+ def test_divpo_gate_failures_are_eligible_rejects():
78
+ """A gate-failing story is legitimately in the low-quality tail."""
79
+ pool = {"p0": [make_item("p0", 0, 8.0, 0.4, -1.5),
80
+ make_item("p0", 1, 9.0, 0.3, -1.2),
81
+ make_item("p0", 2, 0.0, 0.01, -0.3, passed=False)]}
82
+ rows, st = build_divpo(pool, "emb", rho=6.0)
83
+ assert st["n_rows"] == 1 and rows[0]["rejected"] == "story-p0-2"
84
+
85
+
86
+ def test_divpo_is_one_row_per_prompt():
87
+ pool = {f"p{i}": simple_pool()["p0"] for i in range(5)}
88
+ rows, st = build_divpo(pool, "emb", rho=6.0)
89
+ assert len(rows) == 5 == st["n_rows"]
90
+ assert all(r["weight"] == 1.0 for r in rows), "DivPO must be unweighted"
91
+
92
+
93
+ # --------------------------------------------------------------------- E3
94
+ def _emb_for(pool):
95
+ """Deterministic embeddings + row index aligned to pool iteration order."""
96
+ idx, vecs, i = {}, [], 0
97
+ rng = np.random.default_rng(0)
98
+ for pid, items in pool.items():
99
+ for it in items:
100
+ idx[(pid, it["idx"])] = i
101
+ v = rng.standard_normal(32)
102
+ vecs.append(v / np.linalg.norm(v))
103
+ i += 1
104
+ return np.array(vecs, dtype=np.float32), idx
105
+
106
+
107
+ def test_multipos_emits_multiple_chosen_rows_per_prompt():
108
+ pool = simple_pool()
109
+ emb, idx = _emb_for(pool)
110
+ rows, st = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
111
+ assert st["ok"] == 1
112
+ assert 2 <= len(rows) <= 4, f"expected up to 4 chosen rows, got {len(rows)}"
113
+ assert all(r["chosen_quality"] >= 5.0 for r in rows)
114
+
115
+
116
+ def test_multipos_rotates_negatives():
117
+ """Don't hammer one rejected across all rows."""
118
+ pool = simple_pool()
119
+ emb, idx = _emb_for(pool)
120
+ rows, _ = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
121
+ if len(rows) >= 2:
122
+ assert len(set(r["neg_type"] for r in rows)) == 2, \
123
+ f"negatives not rotated: {[r['neg_type'] for r in rows]}"
124
+
125
+
126
+ def test_multipos_weight_is_chosen_deviation():
127
+ pool = simple_pool()
128
+ emb, idx = _emb_for(pool)
129
+ rows, _ = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
130
+ for r in rows:
131
+ assert abs(r["weight"] - r["chosen_dev"]) < 1e-9
132
+
133
+
134
+ def test_multipos_skips_prompt_with_too_few_survivors():
135
+ pool = {"p0": [make_item("p0", i, 2.0, 0.2, -1.0) for i in range(6)]}
136
+ emb, idx = _emb_for(pool)
137
+ rows, st = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
138
+ assert rows == [] and st["skipped_no_survivors"] == 1
139
+
140
+
141
+ def test_normalize_weights_gives_mean_one():
142
+ rows = [{"weight": w} for w in (0.1, 0.2, 0.3, 0.8)]
143
+ normalize_weights(rows)
144
+ w = np.array([r["weight"] for r in rows])
145
+ assert abs(w.mean() - 1.0) < 1e-9
146
+ # relative ordering preserved -> it reweights, it does not rescale the LR
147
+ assert np.all(np.diff(w) > 0)
148
+
149
+
150
+ def test_normalize_weights_handles_all_zero():
151
+ rows = [{"weight": 0.0} for _ in range(3)]
152
+ normalize_weights(rows)
153
+ assert all(r["weight"] == 0.0 for r in rows)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ import sys, traceback
158
+ fns = [(n, f) for n, f in sorted(globals().items())
159
+ if n.startswith("test_") and callable(f)]
160
+ bad = 0
161
+ for n, f in fns:
162
+ try:
163
+ f(); print(f" PASS {n}")
164
+ except Exception:
165
+ bad += 1; print(f" FAIL {n}"); traceback.print_exc()
166
+ print(f"\n{len(fns)-bad}/{len(fns)} passed")
167
+ sys.exit(1 if bad else 0)
src/test_rewards.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the reward-channel construction in rewards.py, with a fake judge so
3
+ nothing hits the network.
4
+
5
+ The load-bearing test is test_gated_sample_never_outranks_in_marginal_channel:
6
+ m_i <= log(1+eps) ~ 0 is ALWAYS negative, so gating a failed story to 0.0 would
7
+ hand it the highest diversity credit in the group. That would be a reward-hacking
8
+ channel we built ourselves, and it is exactly the kind of sign error that is
9
+ invisible in aggregate training curves.
10
+ """
11
+ import numpy as np
12
+
13
+ import gates as G
14
+ import rewards as R
15
+ from judge import JudgeScore
16
+
17
+
18
+ class FakeJudge:
19
+ """Scores by a marker embedded in the text; no network."""
20
+ def __init__(self, mapping=None):
21
+ self.mapping = mapping or {}
22
+ self.calls = 0
23
+
24
+ def score_many_sync(self, pairs):
25
+ self.calls += len(pairs)
26
+ out = []
27
+ for _, story in pairs:
28
+ q = 8.0
29
+ for k, v in self.mapping.items():
30
+ if k in story:
31
+ q = v
32
+ break
33
+ out.append(JudgeScore(quality=q, novelty=5.0))
34
+ return out
35
+
36
+
37
+ def _story(marker: str, n_words: int = 250, seed: int = 0) -> str:
38
+ """Gate-passing filler with a marker and enough lexical variety to clear
39
+ the 4-gram-loop and entropy gates."""
40
+ rng = np.random.default_rng(seed)
41
+ vocab = ["harbor", "clock", "ember", "listen", "gravel", "orchard", "signal",
42
+ "letter", "winter", "throat", "marble", "engine", "sister", "quiet",
43
+ "amber", "hollow", "ribbon", "tunnel", "pepper", "静"][:19]
44
+ words = [vocab[i] for i in rng.integers(0, len(vocab), n_words)]
45
+ return f"{marker} " + " ".join(words) + "."
46
+
47
+
48
+ def _engine(arm="E2", tau=5.0, judge=None):
49
+ cfg = R.RewardConfig(arm=arm, tau=tau)
50
+ return R.RewardEngine(cfg, judge or FakeJudge())
51
+
52
+
53
+ def _fake_embed(monkey_vals):
54
+ """Patch rewards.embed to return a fixed matrix."""
55
+ R.embed = lambda texts: monkey_vals
56
+
57
+
58
+ # ------------------------------------------------------------------ configs
59
+ def test_channels_and_weights_per_arm():
60
+ assert R.RewardConfig(arm="E0").channels() == ["quality"]
61
+ assert R.RewardConfig(arm="E1").channels() == ["quality", "deviation"]
62
+ assert R.RewardConfig(arm="E2").channels() == ["quality", "deviation", "marginal"]
63
+ assert R.RewardConfig(arm="E2", alpha=0.3, gamma=0.7).weights() == [1.0, 0.3, 0.7]
64
+
65
+
66
+ # ------------------------------------------------------------------- gating
67
+ def test_gate_failure_zeroes_quality_channel():
68
+ eng = _engine("E1")
69
+ texts = [_story("GOOD", 250, i) for i in range(3)] + ["too short."]
70
+ prompts = ["p"] * 4
71
+ out = eng.compute(prompts, texts)
72
+ assert out["quality"][3] == 0.0, "gate-failed story must floor the quality channel"
73
+ assert (out["quality"][:3] > 0).all()
74
+
75
+
76
+ def test_judge_is_not_called_for_gate_failures():
77
+ """We must never pay to score text we have already decided to zero."""
78
+ j = FakeJudge()
79
+ eng = _engine("E1", judge=j)
80
+ texts = [_story("A", 250, 1), "nope.", "also short."]
81
+ eng.compute(["p"] * 3, texts)
82
+ assert j.calls == 1, f"judge called {j.calls} times, expected 1"
83
+
84
+
85
+ def test_low_quality_forfeits_diversity_credit():
86
+ """tau conditioning: a coherent but low-quality story earns no diversity."""
87
+ j = FakeJudge({"BAD": 2.0, "GOOD": 8.0})
88
+ eng = _engine("E1", tau=5.0, judge=j)
89
+ texts = [_story("GOOD", 250, i) for i in range(3)] + [_story("BAD", 250, 9)]
90
+ out = eng.compute(["p"] * 4, texts)
91
+ dev = out["deviation"]
92
+ assert dev[3] <= dev[:3].min() + 1e-12, \
93
+ f"sub-tau story got deviation credit {dev[3]} vs eligible min {dev[:3].min()}"
94
+
95
+
96
+ def test_gated_sample_never_outranks_in_marginal_channel():
97
+ """THE sign trap. m_i is always <= 0, so gating to 0.0 would make failure
98
+ the single best value in the channel."""
99
+ j = FakeJudge({"BAD": 1.0, "GOOD": 8.0})
100
+ eng = _engine("E2", tau=5.0, judge=j)
101
+ texts = [_story("GOOD", 250, i) for i in range(5)] + [_story("BAD", 250, 42)]
102
+ out = eng.compute(["p"] * 6, texts)
103
+ m = out["marginal"]
104
+ assert m[5] <= m[:5].min() + 1e-12, \
105
+ f"ineligible story ranked ABOVE eligible ones in marginal channel: {m}"
106
+ assert m[5] != 0.0 or np.allclose(m, 0.0), "suspicious exact-zero gate value"
107
+
108
+
109
+ def test_all_gated_group_is_constant_not_nan():
110
+ """Every sample failing => channels go constant. TRL's (x-mean)/(std+1e-4)
111
+ then yields ~0 for all, which is correct (no signal), and must not be NaN."""
112
+ eng = _engine("E2")
113
+ texts = ["short."] * 4
114
+ out = eng.compute(["p"] * 4, texts)
115
+ for ch, v in out.items():
116
+ assert np.all(np.isfinite(v)), f"{ch} produced non-finite values: {v}"
117
+ assert np.allclose(v, v[0]), f"{ch} should be constant when all gated"
118
+ assert eng.last_stats.frac_groups_degenerate == 1.0
119
+
120
+
121
+ def test_duplicates_get_low_marginal_within_group():
122
+ """Two identical stories should each be worth little in the log-det channel."""
123
+ j = FakeJudge()
124
+ eng = _engine("E2", judge=j)
125
+ dup = _story("DUP", 250, 7)
126
+ texts = [dup, dup] + [_story("X", 250, i) for i in range(3, 7)]
127
+ out = eng.compute(["p"] * 6, texts)
128
+ m = out["marginal"]
129
+ assert m[0] < m[2:].mean() and m[1] < m[2:].mean(), \
130
+ f"duplicates were not penalized in the marginal channel: {m}"
131
+
132
+
133
+ def test_two_groups_are_scored_independently():
134
+ j = FakeJudge()
135
+ eng = _engine("E1", judge=j)
136
+ texts = [_story("A", 250, i) for i in range(4)] + [_story("B", 250, i + 10) for i in range(4)]
137
+ prompts = ["p1"] * 4 + ["p2"] * 4
138
+ out = eng.compute(prompts, texts)
139
+ assert len(out["deviation"]) == 8
140
+ assert eng.last_stats.n == 8
141
+
142
+
143
+ def test_reward_funcs_match_channel_order():
144
+ eng = _engine("E2")
145
+ fns = eng.make_reward_funcs()
146
+ assert [f.__name__ for f in fns] == \
147
+ ["quality_reward", "deviation_reward", "marginal_reward"]
148
+
149
+
150
+ def test_engine_memoizes_within_a_batch():
151
+ """TRL calls one reward fn per channel; the judge+embedder must run once."""
152
+ j = FakeJudge()
153
+ eng = _engine("E2", judge=j)
154
+ texts = [_story("A", 250, i) for i in range(4)]
155
+ comps = [[{"role": "assistant", "content": t}] for t in texts]
156
+ prompts = [[{"role": "user", "content": "p"}]] * 4
157
+ fns = eng.make_reward_funcs()
158
+ for f in fns:
159
+ f(comps, prompts=prompts)
160
+ assert j.calls == 4, f"judge called {j.calls} times; memoization failed"
161
+
162
+
163
+ if __name__ == "__main__":
164
+ import sys, traceback
165
+ fns = [(n, f) for n, f in sorted(globals().items())
166
+ if n.startswith("test_") and callable(f)]
167
+ bad = 0
168
+ for n, f in fns:
169
+ try:
170
+ f(); print(f" PASS {n}")
171
+ except Exception:
172
+ bad += 1; print(f" FAIL {n}"); traceback.print_exc()
173
+ print(f"\n{len(fns)-bad}/{len(fns)} passed")
174
+ sys.exit(1 if bad else 0)
src/train_dpo.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Offline preference training for E3 (multi-positive, deviation-weighted) and
3
+ E4 (faithful DivPO, unweighted). Same LoRA config, same beta, same data pool --
4
+ only pair construction and loss weighting differ.
5
+
6
+ DDPO-STYLE PER-SAMPLE LOSS WEIGHTING (E3)
7
+ -----------------------------------------
8
+ TRL's DPOConfig.loss_weights is per LOSS TYPE (for blending sigmoid+hinge), not
9
+ per example, so it cannot express "weight this pair by its chosen story's
10
+ deviation". Reimplementing TRL's _compute_loss to reach `per_sequence_loss`
11
+ would mean maintaining a fork of a 300-line method across every loss variant.
12
+
13
+ Instead we exploit an identity: with per_device_train_batch_size == 1, the
14
+ batch loss IS that single example's loss, so
15
+
16
+ loss_i * w_i accumulated over gradient_accumulation_steps
17
+
18
+ is exactly the weighted-DPO gradient, with no TRL surgery at all. Cost is
19
+ running at batch size 1, which for ~4k short rows on a 4B LoRA is minutes.
20
+
21
+ The weight rides through the collator (TRL drops unknown columns otherwise) and
22
+ is popped before the model call. Weights are pre-normalized to mean 1.0 in
23
+ build_pairs.py so the weighting changes RELATIVE emphasis across rows without
24
+ also rescaling the effective learning rate -- otherwise "DDPO weighting" and
25
+ "lower LR" would be confounded.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ import torch
36
+ import yaml
37
+
38
+ ROOT = Path(__file__).resolve().parent.parent
39
+
40
+
41
+ def load_rows(path: Path) -> list[dict]:
42
+ return [json.loads(l) for l in open(path) if l.strip()]
43
+
44
+
45
+ def build_dpo_dataset(rows: list[dict], weighted: bool):
46
+ from datasets import Dataset
47
+ from data import SYSTEM_PROMPT
48
+
49
+ recs = []
50
+ for r in rows:
51
+ recs.append({
52
+ "prompt": [{"role": "system", "content": SYSTEM_PROMPT},
53
+ {"role": "user", "content": r["prompt"]}],
54
+ "chosen": [{"role": "assistant", "content": r["chosen"]}],
55
+ "rejected": [{"role": "assistant", "content": r["rejected"]}],
56
+ "weight": float(r.get("weight", 1.0)) if weighted else 1.0,
57
+ })
58
+ return Dataset.from_list(recs)
59
+
60
+
61
+ def make_weighted_classes():
62
+ from trl import DPOTrainer
63
+ from trl.trainer.dpo_trainer import DataCollatorForPreference
64
+
65
+ class WeightedCollator(DataCollatorForPreference):
66
+ def __call__(self, features, return_tensors=None):
67
+ w = [float(f.pop("weight", 1.0)) for f in features]
68
+ batch = super().__call__(features, return_tensors)
69
+ batch["weight"] = torch.tensor(w, dtype=torch.float32)
70
+ return batch
71
+
72
+ class WeightedDPOTrainer(DPOTrainer):
73
+ """Exact per-sample weighting, valid because batch size is 1."""
74
+
75
+ def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
76
+ w = inputs.pop("weight", None)
77
+ out = super().compute_loss(model, inputs, return_outputs=return_outputs,
78
+ num_items_in_batch=num_items_in_batch)
79
+ if w is None:
80
+ return out
81
+ scale = w.to(out[0].device if return_outputs else out.device).mean()
82
+ if return_outputs:
83
+ loss, extra = out
84
+ return loss * scale, extra
85
+ return out * scale
86
+
87
+ return WeightedCollator, WeightedDPOTrainer
88
+
89
+
90
+ def main():
91
+ ap = argparse.ArgumentParser()
92
+ ap.add_argument("--config", required=True)
93
+ ap.add_argument("--max-steps", type=int, default=None)
94
+ ap.add_argument("--smoke", action="store_true")
95
+ args = ap.parse_args()
96
+
97
+ cfg = yaml.safe_load(open(args.config))
98
+ name = cfg["name"] + ("-smoke" if args.smoke else "")
99
+
100
+ import wandb
101
+ from peft import LoraConfig
102
+ from transformers import AutoTokenizer
103
+ from trl import DPOConfig, DPOTrainer
104
+ from trl.trainer.dpo_trainer import DataCollatorForPreference
105
+
106
+ import logbook
107
+
108
+ weighted = bool(cfg["dpo"].get("weighted", False))
109
+ pairs_path = ROOT / cfg["dpo"]["pairs"]
110
+ rows = load_rows(pairs_path)
111
+ if args.smoke:
112
+ rows = rows[:64]
113
+ print(f"[{name}] {len(rows)} preference rows | weighted={weighted}")
114
+
115
+ run = None
116
+ if cfg.get("wandb", True) and os.environ.get("WANDB_API_KEY"):
117
+ run = wandb.init(project=os.environ.get("WANDB_PROJECT", "div-grpo"),
118
+ name=name, config=cfg, reinit=True)
119
+
120
+ tok = AutoTokenizer.from_pretrained(cfg["model"])
121
+ ds = build_dpo_dataset(rows, weighted)
122
+
123
+ lora = LoraConfig(
124
+ r=cfg["lora"]["r"], lora_alpha=cfg["lora"]["alpha"],
125
+ lora_dropout=cfg["lora"].get("dropout", 0.0),
126
+ target_modules=cfg["lora"]["target_modules"],
127
+ task_type="CAUSAL_LM", bias="none",
128
+ )
129
+
130
+ out_dir = ROOT / "outputs" / name
131
+ bs = 1 if weighted else cfg["dpo"].get("per_device_train_batch_size", 2)
132
+ dcfg = DPOConfig(
133
+ output_dir=str(out_dir),
134
+ num_train_epochs=cfg["dpo"].get("epochs", 1),
135
+ max_steps=args.max_steps or -1,
136
+ per_device_train_batch_size=bs,
137
+ gradient_accumulation_steps=cfg["dpo"].get("gradient_accumulation_steps", 8),
138
+ learning_rate=cfg["dpo"]["learning_rate"],
139
+ beta=cfg["dpo"].get("beta", 0.1),
140
+ loss_type=cfg["dpo"].get("loss_type", "sigmoid"),
141
+ # TRL 1.10 dropped max_prompt_length; max_length bounds prompt+completion
142
+ # jointly. Sized so NOTHING truncates: prompt ~180 tok + a story capped
143
+ # by the 600-word gate (~780 tok) = ~960, well inside 1600.
144
+ max_length=cfg["dpo"].get("max_length", 1600),
145
+ truncation_mode=cfg["dpo"].get("truncation_mode", "keep_start"),
146
+ lr_scheduler_type=cfg["dpo"].get("lr_scheduler_type", "cosine"),
147
+ warmup_steps=cfg["dpo"].get("warmup_steps", 20), # TRL 1.10: no warmup_ratio
148
+ bf16=True,
149
+ gradient_checkpointing=True,
150
+ logging_steps=10,
151
+ save_strategy="no",
152
+ remove_unused_columns=not weighted, # keep `weight` alive when weighting
153
+ report_to=["wandb"] if run else [],
154
+ run_name=name,
155
+ seed=cfg.get("seed", 42),
156
+ )
157
+
158
+ if weighted:
159
+ WCollator, WTrainer = make_weighted_classes()
160
+ collator = WCollator(pad_token_id=tok.pad_token_id or tok.eos_token_id)
161
+ trainer = WTrainer(model=cfg["model"], args=dcfg, train_dataset=ds,
162
+ processing_class=tok, peft_config=lora,
163
+ data_collator=collator)
164
+ else:
165
+ trainer = DPOTrainer(model=cfg["model"], args=dcfg, train_dataset=ds,
166
+ processing_class=tok, peft_config=lora)
167
+
168
+ logbook.note(f"START {name}",
169
+ f"rows={len(rows)} weighted={weighted} bs={bs} "
170
+ f"beta={dcfg.beta} epochs={dcfg.num_train_epochs}\n\n"
171
+ f"```yaml\n{yaml.safe_dump(cfg, sort_keys=False)}```")
172
+
173
+ trainer.train()
174
+ final = out_dir / "final"
175
+ trainer.save_model(str(final))
176
+ tok.save_pretrained(str(final))
177
+
178
+ hist = trainer.state.log_history
179
+ json.dump(hist, open(out_dir / "log_history.json", "w"), indent=1)
180
+ last = [h for h in hist if "loss" in h]
181
+ logbook.note(f"DONE {name}",
182
+ f"adapter: `{final}`\n\nfinal loss: "
183
+ f"`{last[-1] if last else 'n/a'}`")
184
+ logbook.checkpoint(f"after {name}")
185
+ if run:
186
+ run.finish()
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
src/train_grpo.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GRPO training for E0 (quality-only baseline), E1 (div-grpo-individual),
3
+ E2 (div-grpo-group). The three arms differ ONLY by YAML config -- same code
4
+ path, same data, same seed -- so any difference between them is attributable
5
+ to the reward configuration and nothing else.
6
+
7
+ Aggregation is GDPO (arXiv 2601.05242, Liu et al., NVIDIA): group-wise
8
+ normalization per reward channel, then batch-wise advantage normalization.
9
+ TRL 1.10 implements this as multi_objective_aggregation="normalize_then_sum".
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ from dataclasses import asdict
18
+ from pathlib import Path
19
+
20
+ import yaml
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+
24
+
25
+ def build_dataset(prompts, tokenizer):
26
+ from datasets import Dataset
27
+ from data import chat_messages
28
+ return Dataset.from_list([
29
+ {"prompt": chat_messages(p["prompt"]), "prompt_id": p["id"]}
30
+ for p in prompts
31
+ ])
32
+
33
+
34
+ def main():
35
+ ap = argparse.ArgumentParser()
36
+ ap.add_argument("--config", required=True)
37
+ ap.add_argument("--max-steps", type=int, default=None, help="override (smoke tests)")
38
+ ap.add_argument("--smoke", action="store_true")
39
+ args = ap.parse_args()
40
+
41
+ cfg = yaml.safe_load(open(args.config))
42
+ name = cfg["name"]
43
+ if args.smoke:
44
+ name = f"{name}-smoke"
45
+
46
+ import torch
47
+ import wandb
48
+ from peft import LoraConfig
49
+ from transformers import AutoTokenizer, TrainerCallback
50
+ from trl import GRPOConfig, GRPOTrainer
51
+
52
+ import logbook
53
+ from data import load_prompts
54
+ from judge import build_judge
55
+ from rewards import RewardConfig, RewardEngine
56
+
57
+ out_dir = ROOT / "outputs" / name
58
+ out_dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ steps = args.max_steps or cfg["train"]["max_steps"]
61
+ model_id = cfg["model"]
62
+ G = cfg["train"]["num_generations"]
63
+
64
+ # ---- wandb -----------------------------------------------------------
65
+ run = None
66
+ if cfg.get("wandb", True) and os.environ.get("WANDB_API_KEY"):
67
+ run = wandb.init(
68
+ project=os.environ.get("WANDB_PROJECT", "div-grpo"),
69
+ name=name, config=cfg, reinit=True,
70
+ mode=os.environ.get("WANDB_MODE", "online"),
71
+ )
72
+
73
+ logbook.note(f"START {name}",
74
+ f"```yaml\n{yaml.safe_dump(cfg, sort_keys=False)}```\n"
75
+ f"steps={steps} G={G} model={model_id}")
76
+
77
+ # ---- reward engine ---------------------------------------------------
78
+ rcfg = RewardConfig(
79
+ arm=cfg["reward"]["arm"],
80
+ alpha=cfg["reward"].get("alpha", 0.5),
81
+ gamma=cfg["reward"].get("gamma", 0.5),
82
+ tau=cfg["reward"].get("tau", 5.0),
83
+ )
84
+ judge = build_judge(
85
+ model=cfg["judge"]["model"],
86
+ cache_path=str(ROOT / "cache" / "judge.sqlite"),
87
+ concurrency=cfg["judge"].get("concurrency", 12),
88
+ )
89
+ engine = RewardEngine(rcfg, judge, wandb_run=run, log_prefix="train")
90
+ reward_funcs = engine.make_reward_funcs()
91
+ weights = rcfg.weights()
92
+ print(f"[arm {rcfg.arm}] channels={rcfg.channels()} weights={weights} tau={rcfg.tau}")
93
+
94
+ # ---- data ------------------------------------------------------------
95
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
96
+ train_prompts = load_prompts("train", ROOT / "data")
97
+ if args.smoke:
98
+ train_prompts = train_prompts[:64]
99
+ train_ds = build_dataset(train_prompts, tokenizer)
100
+
101
+ # ---- LoRA ------------------------------------------------------------
102
+ lora = LoraConfig(
103
+ r=cfg["lora"]["r"],
104
+ lora_alpha=cfg["lora"]["alpha"],
105
+ lora_dropout=cfg["lora"].get("dropout", 0.0),
106
+ target_modules=cfg["lora"]["target_modules"],
107
+ task_type="CAUSAL_LM",
108
+ bias="none",
109
+ )
110
+
111
+ gcfg = GRPOConfig(
112
+ output_dir=str(out_dir),
113
+ max_steps=steps,
114
+ per_device_train_batch_size=cfg["train"]["per_device_train_batch_size"],
115
+ gradient_accumulation_steps=cfg["train"]["gradient_accumulation_steps"],
116
+ num_generations=G,
117
+ max_completion_length=cfg["train"]["max_completion_length"],
118
+ # TRL 1.10 dropped max_prompt_length; vLLM's window is the control now.
119
+ vllm_max_model_length=cfg["train"].get("vllm_max_model_length", 2048),
120
+ # NOT masking truncated completions: a truncated story is gated to the
121
+ # bottom of every reward channel, and we want that negative gradient to
122
+ # reach the policy. Masking would make truncation free.
123
+ mask_truncated_completions=False,
124
+ learning_rate=cfg["train"]["learning_rate"],
125
+ lr_scheduler_type=cfg["train"].get("lr_scheduler_type", "constant_with_warmup"),
126
+ warmup_steps=cfg["train"].get("warmup_steps", 10),
127
+ beta=cfg["train"]["beta"],
128
+ temperature=cfg["train"].get("temperature", 1.0),
129
+ top_p=cfg["train"].get("top_p", 1.0),
130
+ # GDPO: per-reward group normalization, then batch-level advantage norm
131
+ multi_objective_aggregation="normalize_then_sum",
132
+ reward_weights=weights,
133
+ scale_rewards=cfg["train"].get("scale_rewards", "group"),
134
+ bf16=True,
135
+ gradient_checkpointing=True,
136
+ # Liger fuses RMSNorm/SwiGLU/RoPE and the LM-head cross-entropy, which
137
+ # is where the peak lives: the logits tensor is
138
+ # micro_batch x seq x 151936 vocab, and it was the allocation that OOMed.
139
+ use_liger_kernel=cfg["train"].get("use_liger_kernel", True),
140
+ torch_empty_cache_steps=cfg["train"].get("torch_empty_cache_steps", 8),
141
+ use_vllm=True,
142
+ vllm_mode="colocate",
143
+ vllm_gpu_memory_utilization=cfg["train"]["vllm_gpu_memory_utilization"],
144
+ logging_steps=1,
145
+ save_steps=cfg["train"].get("save_steps", 50),
146
+ save_total_limit=cfg["train"].get("save_total_limit", 7),
147
+ # Checkpoints exist only to EVALUATE intermediate policies (ckpt_study),
148
+ # never to resume training. Without this, HF writes a 505MB optimizer.pt
149
+ # beside a 253MB adapter -- 3x the disk for state we never read. This was
150
+ # set in the YAML from E1 onward but not passed through until now.
151
+ save_only_model=cfg["train"].get("save_only_model", True),
152
+ seed=cfg.get("seed", 42),
153
+ report_to=["wandb"] if run else [],
154
+ run_name=name,
155
+ )
156
+
157
+ trainer = GRPOTrainer(
158
+ model=model_id,
159
+ reward_funcs=reward_funcs,
160
+ args=gcfg,
161
+ train_dataset=train_ds,
162
+ peft_config=lora,
163
+ )
164
+
165
+ # ---- periodic reward-hacking guardrail -------------------------------
166
+ class Guardrail(TrainerCallback):
167
+ """Stop the run if diversity climbs while quality/validity collapses.
168
+
169
+ The brief's guardrail: 'if reward hacking appears (deviation up, quality
170
+ flat/down, or degenerate text passing gates), stop the run'. We compare a
171
+ trailing window against the opening baseline rather than step-to-step,
172
+ because GRPO reward traces are far too noisy for a point comparison.
173
+ """
174
+ WINDOW = 25
175
+
176
+ def on_step_end(self, a, state, control, **kw):
177
+ h = engine.history
178
+ if len(h) < self.WINDOW * 2:
179
+ return
180
+ base = h[:self.WINDOW]
181
+ recent = h[-self.WINDOW:]
182
+
183
+ def mean(rows, f):
184
+ return sum(f(r) for r in rows) / len(rows)
185
+
186
+ gate0, gate1 = mean(base, lambda r: r.gate_pass), mean(recent, lambda r: r.gate_pass)
187
+ q0, q1 = mean(base, lambda r: r.mean_quality_passing), mean(recent, lambda r: r.mean_quality_passing)
188
+ d0, d1 = mean(base, lambda r: r.mean_deviation), mean(recent, lambda r: r.mean_deviation)
189
+
190
+ msg = None
191
+ if gate1 < 0.55 and gate1 < gate0 - 0.25:
192
+ msg = f"gate pass collapsed {gate0:.2f}->{gate1:.2f}"
193
+ elif d1 > d0 + 0.05 and q1 < q0 - 1.0:
194
+ msg = f"reward hacking: deviation {d0:.3f}->{d1:.3f} while quality {q0:.2f}->{q1:.2f}"
195
+ if msg:
196
+ logbook.note(f"GUARDRAIL TRIP {name}", msg, level="ALERT")
197
+ print(f"\n!!! GUARDRAIL: {msg} -- stopping at step {state.global_step}\n", flush=True)
198
+ control.should_training_stop = True
199
+
200
+ trainer.add_callback(Guardrail())
201
+
202
+ print(f"\n── training {name}: {steps} steps ──", flush=True)
203
+ trainer.train()
204
+
205
+ final = out_dir / "final"
206
+ trainer.save_model(str(final))
207
+ tokenizer.save_pretrained(str(final))
208
+
209
+ hist = [asdict(s) for s in engine.history]
210
+ json.dump(hist, open(out_dir / "reward_history.json", "w"), indent=1)
211
+ # TRL's own log history carries per-token policy entropy, KL and clip ratio.
212
+ # Entropy is only present on the non-liger loss path (compute_liger_loss logs
213
+ # just clip_ratio and kl), which is why use_liger_kernel is disabled.
214
+ json.dump(trainer.state.log_history,
215
+ open(out_dir / "trl_log_history.json", "w"), indent=1)
216
+ ent = [h["entropy"] for h in trainer.state.log_history if "entropy" in h]
217
+ print(f"entropy logged for {len(ent)} steps"
218
+ + (f" | first={ent[0]:.4f} last={ent[-1]:.4f}" if ent else " -- MISSING!"))
219
+ cost = judge.cost_estimate(cfg["judge"]["price_in"], cfg["judge"]["price_out"])
220
+ json.dump(cost, open(out_dir / "judge_cost.json", "w"), indent=1)
221
+ print("judge cost:", cost)
222
+
223
+ logbook.note(f"DONE {name}",
224
+ f"adapter: `{final}`\n\njudge cost: `{json.dumps(cost)}`")
225
+ logbook.checkpoint(f"after {name}")
226
+ if run:
227
+ run.finish()
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())