viai957 commited on
Commit
bcc8d35
·
verified ·
1 Parent(s): d909bd8

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. code/notebooks/0101-autoregressive-likelihood-sft.ipynb +309 -0
  2. code/notebooks/0102-policy-gradient-baselines.ipynb +347 -0
  3. code/notebooks/0103-importance-sampling-offpolicy.ipynb +357 -0
  4. code/notebooks/0104-trust-region-ppo-clip.ipynb +318 -0
  5. code/notebooks/0105-grpo-group-advantages.ipynb +403 -0
  6. code/notebooks/0106-dapo-pathologies.ipynb +450 -0
  7. code/notebooks/0107-cispo.ipynb +596 -0
  8. code/notebooks/0108-training-inference-mismatch.ipynb +585 -0
  9. code/notebooks/0109-kl-estimators.ipynb +436 -0
  10. code/notebooks/0110-reward-shaping-verifier.ipynb +417 -0
  11. code/notebooks/0201-verifier-binary-reward-mdp.ipynb +641 -0
  12. code/notebooks/0202-rejection-sampling-expert-iteration-em.ipynb +555 -0
  13. code/notebooks/0203-pass-at-k.ipynb +657 -0
  14. code/notebooks/0204-self-correction-multiturn-mdp.ipynb +505 -0
  15. code/notebooks/0205-model-averaging-weight-interpolation.ipynb +746 -0
  16. code/notebooks/0206-curriculum-pass-rate-band.ipynb +545 -0
  17. code/notebooks/0207-test-time-scaling-cost-model.ipynb +602 -0
  18. code/notebooks/0208-statistics-of-evaluation.ipynb +687 -0
  19. code/notebooks/0209-variance-components-equivalence.ipynb +486 -0
  20. code/notebooks/0210-data-selection-contamination.ipynb +500 -0
  21. code/notebooks/0301-curry-howard.ipynb +773 -0
  22. code/notebooks/0302-dependent-types.ipynb +762 -0
  23. code/notebooks/0303-hoare-refinement.ipynb +852 -0
  24. code/notebooks/0304-failed-proof-to-bug.ipynb +1006 -0
  25. code/notebooks/0305-spec-risk.ipynb +789 -0
  26. code/notebooks/0306-benchmarks-landscape.ipynb +479 -0
  27. code/notebooks/0307-aeneas-semantics.ipynb +438 -0
  28. code/notebooks/_lean_helper.py +46 -0
  29. code/notebooks/_m1_lib.py +145 -0
  30. code/notebooks/_m1_run.py +59 -0
  31. code/notebooks/_m1_show.py +4 -0
  32. code/notes/STYLE_ALIGNMENT.md +86 -0
  33. code/scripts/bench_7b_end_to_end.py +283 -0
  34. code/scripts/bench_flash_attention.py +234 -0
  35. code/scripts/build_data.py +125 -0
  36. code/scripts/build_stageb_data.py +169 -0
  37. code/scripts/ceiling_test.py +152 -0
  38. code/scripts/ckpt_convert.py +142 -0
  39. code/scripts/dedup_arm_b_shards.py +308 -0
  40. code/scripts/dpo.py +590 -0
  41. code/scripts/eval.py +102 -0
  42. code/scripts/eval_checkpoint.py +175 -0
  43. code/scripts/gate_flash_attention.py +132 -0
  44. code/scripts/generate.py +327 -0
  45. code/scripts/grpo.py +433 -0
  46. code/scripts/grpo_halt_at_108.sh +30 -0
  47. code/scripts/grpo_watch.py +289 -0
  48. code/scripts/hostguard.py +196 -0
  49. code/scripts/lean_ceiling_cases.py +245 -0
  50. code/scripts/lean_ceiling_test.py +729 -0
code/notebooks/0101-autoregressive-likelihood-sft.ipynb ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0101 - Autoregressive LM likelihood, token vs sequence view, SFT = MLE = forward KL\n",
8
+ "Companion to `notes/0101 - Autoregressive Likelihood and SFT.pdf`.\n",
9
+ "Everything is fp64. The model is a tiny autoregressive policy whose whole sequence space (121 sequences) is enumerated,\n",
10
+ "so each identity is checked EXACTLY (error ~1e-16), not by sampling. Pattern: naive loop first, vectorised second, assert they agree."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": 1,
16
+ "metadata": {},
17
+ "source": [
18
+ "import sys, math, itertools\n",
19
+ "sys.path.insert(0, \"..\")\n",
20
+ "from _m1_lib import *\n",
21
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
22
+ "theta_s = lm.theta0.clone() # student\n",
23
+ "theta_T = TinyLM(V=4, L=4, seed=7, scale=1.5).theta0 # teacher p (same family -> realizable)\n",
24
+ "print(\"sequences:\", lm.N, \" params:\", theta_s.numel())"
25
+ ],
26
+ "outputs": [
27
+ {
28
+ "output_type": "stream",
29
+ "name": "stdout",
30
+ "text": [
31
+ "sequences: 121 params: 36\n"
32
+ ]
33
+ }
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "markdown",
38
+ "metadata": {},
39
+ "source": [
40
+ "## 1. The model is a proper distribution on sequences: sum_o pi(o) = 1 (note, Sec. 3.1)\n",
41
+ "Naive: multiply conditional probabilities one token at a time with a python loop. Efficient: gather from one batched log_softmax."
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "code",
46
+ "execution_count": 2,
47
+ "metadata": {},
48
+ "source": [
49
+ "p_naive = torch.stack([lm.seq_logprob_naive(theta_s, s).exp() for s in lm.seqs])\n",
50
+ "p_vec = lm.probs(theta_s)\n",
51
+ "check(\"naive == vectorised sequence probability\", maxabs(p_naive, p_vec), 1e-15)\n",
52
+ "check(\"sum_o pi(o) = 1 (normalisation, truncated at L)\", abs(float(p_vec.sum()) - 1.0), 1e-14)\n",
53
+ "# per-state conditional distributions each sum to 1 (this is what the induction in the note uses)\n",
54
+ "worst = max(abs(float(lm.cond_probs(theta_s, s).sum()) - 1) for s in lm.prefixes())\n",
55
+ "check(\"every conditional pi(.|o_<t) sums to 1\", worst, 1e-15)"
56
+ ],
57
+ "outputs": [
58
+ {
59
+ "output_type": "stream",
60
+ "name": "stdout",
61
+ "text": [
62
+ "CHECK|exact|naive == vectorised sequence probability|0.000e+00|tol=1.0e-15|PASS\n",
63
+ "CHECK|exact|sum_o pi(o) = 1 (normalisation, truncated at L)|1.110e-16|tol=1.0e-14|PASS\n",
64
+ "CHECK|exact|every conditional pi(.|o_<t) sums to 1|2.220e-16|tol=1.0e-15|PASS\n"
65
+ ]
66
+ }
67
+ ]
68
+ },
69
+ {
70
+ "cell_type": "markdown",
71
+ "metadata": {},
72
+ "source": [
73
+ "## 2. Forward KL: CE(p, pi) = H(p) + KL(p || pi) (note, Eq. for the SFT loss)"
74
+ ]
75
+ },
76
+ {
77
+ "cell_type": "code",
78
+ "execution_count": 3,
79
+ "metadata": {},
80
+ "source": [
81
+ "p_T = lm.probs(theta_T)\n",
82
+ "lp_s = lm.seq_logprob(theta_s)\n",
83
+ "CE = -(p_T * lp_s).sum()\n",
84
+ "H = -(p_T * p_T.log()).sum()\n",
85
+ "KLf = (p_T * (p_T.log() - lp_s)).sum()\n",
86
+ "check(\"CE = H + KL(p||pi)\", abs(float(CE - H - KLf)), 1e-14)\n",
87
+ "check(\"KL(p||pi) >= 0 (Gibbs): min(0, KL)\", abs(min(0.0, float(KLf))), 0.0)\n",
88
+ "# reverse KL differs (what RL optimises is the *reverse* direction, see 0109)\n",
89
+ "KLr = (p_vec * (lp_s - p_T.log())).sum()\n",
90
+ "print(f\"forward KL(p||pi)={float(KLf):.6f} reverse KL(pi||p)={float(KLr):.6f} (different objects)\")"
91
+ ],
92
+ "outputs": [
93
+ {
94
+ "output_type": "stream",
95
+ "name": "stdout",
96
+ "text": [
97
+ "CHECK|exact|CE = H + KL(p||pi)|0.000e+00|tol=1.0e-14|PASS\n",
98
+ "CHECK|exact|KL(p||pi) >= 0 (Gibbs): min(0, KL)|0.000e+00|tol=0.0e+00|PASS\n",
99
+ "forward KL(p||pi)=1.221839 reverse KL(pi||p)=1.094181 (different objects)\n"
100
+ ]
101
+ }
102
+ ]
103
+ },
104
+ {
105
+ "cell_type": "markdown",
106
+ "metadata": {},
107
+ "source": [
108
+ "## 3. Chain rule of KL: sequence KL = sum over positions of the *expected conditional* KL (token-level view)"
109
+ ]
110
+ },
111
+ {
112
+ "cell_type": "code",
113
+ "execution_count": 4,
114
+ "metadata": {},
115
+ "source": [
116
+ "def prefix_prob(theta, prefix):\n",
117
+ " pr = 1.0\n",
118
+ " for k, a in enumerate(prefix):\n",
119
+ " pr = pr * lm.cond_probs(theta, prefix[:k])[a]\n",
120
+ " return pr\n",
121
+ "tok_KL = 0.0\n",
122
+ "for s in lm.prefixes():\n",
123
+ " w = prefix_prob(theta_T, s) # P_p(o_<t = s) (only non-EOS prefixes are non-terminal states)\n",
124
+ " cp, cq = lm.cond_probs(theta_T, s), lm.cond_probs(theta_s, s)\n",
125
+ " tok_KL = tok_KL + w * (cp * (cp.log() - cq.log())).sum()\n",
126
+ "check(\"KL(p||pi)_sequence = sum_t E_p[KL_t] (chain rule)\", abs(float(KLf - tok_KL)), 1e-14)"
127
+ ],
128
+ "outputs": [
129
+ {
130
+ "output_type": "stream",
131
+ "name": "stdout",
132
+ "text": [
133
+ "CHECK|exact|KL(p||pi)_sequence = sum_t E_p[KL_t] (chain rule)|2.220e-16|tol=1.0e-14|PASS\n"
134
+ ]
135
+ }
136
+ ]
137
+ },
138
+ {
139
+ "cell_type": "markdown",
140
+ "metadata": {},
141
+ "source": [
142
+ "## 4. Hand-derived gradient of the SFT loss vs autograd\n",
143
+ "For a softmax row, d log pi(a)/d logit_b = 1[b=a] - pi(b). Our logits are tok[prev]+pos[t], so each token adds that vector to the\n",
144
+ "tok[prev] slot and to the pos[t] slot. Naive: python loops over sequences and positions."
145
+ ]
146
+ },
147
+ {
148
+ "cell_type": "code",
149
+ "execution_count": 5,
150
+ "metadata": {},
151
+ "source": [
152
+ "V, L = lm.V, lm.L\n",
153
+ "def hand_score(theta, seq):\n",
154
+ " \"\"\"grad_theta log pi(seq), derived by hand: sum_t (e_{a_t} - pi(.|ctx_t)) scattered into the tok / pos slots.\"\"\"\n",
155
+ " g = torch.zeros_like(theta)\n",
156
+ " prev = V\n",
157
+ " for t, a in enumerate(seq):\n",
158
+ " pi = lm.cond_probs(theta, seq[:t])\n",
159
+ " d = -pi.clone(); d[a] += 1.0\n",
160
+ " g[prev * V:(prev + 1) * V] += d\n",
161
+ " g[lm.n_tok + t * V: lm.n_tok + (t + 1) * V] += d\n",
162
+ " prev = a\n",
163
+ " return g\n",
164
+ "th = theta_s.clone().requires_grad_(True)\n",
165
+ "S_auto = score_matrix(lm, theta_s)\n",
166
+ "S_hand = torch.stack([hand_score(theta_s, s) for s in lm.seqs])\n",
167
+ "check(\"hand-derived score == autograd score (all 121 sequences)\", maxabs(S_auto, S_hand), 1e-14)\n",
168
+ "grad_CE_hand = -(p_T[:, None] * S_hand).sum(0) # d/dtheta CE = -E_p[score]\n",
169
+ "th = theta_s.clone().requires_grad_(True)\n",
170
+ "grad_CE_auto = torch.autograd.grad(-(p_T * lm.seq_logprob(th)).sum(), th)[0]\n",
171
+ "check(\"grad CE: -E_p[score] == autograd\", maxabs(grad_CE_hand, grad_CE_auto), 1e-14)\n",
172
+ "# at the teacher the MLE gradient vanishes (p is realisable)\n",
173
+ "S_T = score_matrix(lm, theta_T)\n",
174
+ "check(\"grad CE at teacher = 0\", float((p_T[:, None] * S_T).sum(0).abs().max()), 1e-14)\n",
175
+ "# E_pi[score] = 0 for every theta (the identity used in 0102)\n",
176
+ "check(\"E_pi[score] = 0 (zero-mean score)\", float((p_vec[:, None] * S_auto).sum(0).abs().max()), 1e-14)"
177
+ ],
178
+ "outputs": [
179
+ {
180
+ "output_type": "stream",
181
+ "name": "stdout",
182
+ "text": [
183
+ "CHECK|exact|hand-derived score == autograd score (all 121 sequences)|1.110e-16|tol=1.0e-14|PASS\n",
184
+ "CHECK|exact|grad CE: -E_p[score] == autograd|9.714e-17|tol=1.0e-14|PASS\n",
185
+ "CHECK|exact|grad CE at teacher = 0|4.857e-17|tol=1.0e-14|PASS\n",
186
+ "CHECK|exact|E_pi[score] = 0 (zero-mean score)|5.551e-17|tol=1.0e-14|PASS\n"
187
+ ]
188
+ }
189
+ ]
190
+ },
191
+ {
192
+ "cell_type": "markdown",
193
+ "metadata": {},
194
+ "source": [
195
+ "## 5. Loss normalisations are NOT equivalent (token-sum vs sequence-mean vs global token-mean)\n",
196
+ "SFT gradient = -E_p[sum_t grad log pi_t]. Dividing each sequence by its own length (seq-mean) re-weights tokens by 1/|o|."
197
+ ]
198
+ },
199
+ {
200
+ "cell_type": "code",
201
+ "execution_count": 6,
202
+ "metadata": {},
203
+ "source": [
204
+ "Ttens = token_score_tensor(lm, theta_s) # [N, L, P]\n",
205
+ "len_ = lm.lengths\n",
206
+ "g_sum = -(p_T[:, None] * Ttens.sum(1)).sum(0)\n",
207
+ "g_seq = -(p_T[:, None] * (Ttens.sum(1) / len_[:, None])).sum(0)\n",
208
+ "Lmax = float(L)\n",
209
+ "g_const = -(p_T[:, None] * (Ttens.sum(1) / Lmax)).sum(0)\n",
210
+ "check(\"token-sum gradient == MLE gradient\", maxabs(g_sum, grad_CE_hand), 1e-14)\n",
211
+ "check(\"constant-denominator loss == MLE gradient / Lmax\", maxabs(g_const * Lmax, g_sum), 1e-14)\n",
212
+ "rel = float((g_seq - g_sum / float(p_T @ len_)).norm() / g_sum.norm())\n",
213
+ "print(f\"seq-mean gradient vs MLE gradient: relative deviation {rel:.3f} (NOT a pure rescale -> reweights tokens by 1/|o|)\")\n",
214
+ "assert rel > 1e-3"
215
+ ],
216
+ "outputs": [
217
+ {
218
+ "output_type": "stream",
219
+ "name": "stdout",
220
+ "text": [
221
+ "CHECK|exact|token-sum gradient == MLE gradient|9.714e-17|tol=1.0e-14|PASS\n",
222
+ "CHECK|exact|constant-denominator loss == MLE gradient / Lmax|0.000e+00|tol=1.0e-14|PASS\n",
223
+ "seq-mean gradient vs MLE gradient: relative deviation 0.444 (NOT a pure rescale -> reweights tokens by 1/|o|)\n"
224
+ ]
225
+ }
226
+ ]
227
+ },
228
+ {
229
+ "cell_type": "markdown",
230
+ "metadata": {},
231
+ "source": [
232
+ "## 6. Sampling check (MC): empirical-risk gradient converges to the population one"
233
+ ]
234
+ },
235
+ {
236
+ "cell_type": "code",
237
+ "execution_count": 7,
238
+ "metadata": {},
239
+ "source": [
240
+ "gen = torch.Generator().manual_seed(123)\n",
241
+ "n = 400_000\n",
242
+ "idx = torch.multinomial(p_T, n, replacement=True, generator=gen)\n",
243
+ "G_emp = -S_auto[idx].mean(0)\n",
244
+ "se = (S_auto[idx].var(0) / n).sqrt()\n",
245
+ "ok_ = se > 0\n",
246
+ "z = float(((G_emp - grad_CE_hand).abs()[ok_] / se[ok_]).max())\n",
247
+ "assert float((G_emp - grad_CE_hand).abs()[~ok_].max()) < 1e-14 # unused parameters have identically zero gradient\n",
248
+ "check(\"MC SFT gradient within 4 SE (max z over components)\", z, 4.0, kind=\"mc\")"
249
+ ],
250
+ "outputs": [
251
+ {
252
+ "output_type": "stream",
253
+ "name": "stdout",
254
+ "text": [
255
+ "CHECK|mc|MC SFT gradient within 4 SE (max z over components)|1.761e+00|tol=4.0e+00|PASS\n"
256
+ ]
257
+ }
258
+ ]
259
+ },
260
+ {
261
+ "cell_type": "markdown",
262
+ "metadata": {},
263
+ "source": [
264
+ "## 7. SFT = maximum likelihood = forward KL minimisation: L-BFGS from a random init recovers the teacher"
265
+ ]
266
+ },
267
+ {
268
+ "cell_type": "code",
269
+ "execution_count": 8,
270
+ "metadata": {},
271
+ "source": [
272
+ "gen_i = torch.Generator().manual_seed(77) # seeded: the quoted numbers must reproduce\n",
273
+ "th = (theta_s + 0.5 * torch.randn(theta_s.numel(), generator=gen_i)).requires_grad_(True)\n",
274
+ "opt = torch.optim.LBFGS([th], lr=1.0, max_iter=500, tolerance_grad=1e-14, tolerance_change=1e-16, line_search_fn=\"strong_wolfe\")\n",
275
+ "def closure():\n",
276
+ " opt.zero_grad(); loss = -(p_T * lm.seq_logprob(th)).sum(); loss.backward(); return loss\n",
277
+ "for _ in range(6): opt.step(closure)\n",
278
+ "KLend = float((p_T * (p_T.log() - lm.seq_logprob(th.detach()))).sum())\n",
279
+ "check(\"KL(p||pi) after MLE (should be ~0)\", KLend, 1e-10)\n",
280
+ "check(\"recovered sequence distribution == teacher\", maxabs(lm.probs(th.detach()), p_T), 1e-6)\n",
281
+ "print(\"done; checks:\", len(CHECKS))"
282
+ ],
283
+ "outputs": [
284
+ {
285
+ "output_type": "stream",
286
+ "name": "stdout",
287
+ "text": [
288
+ "CHECK|exact|KL(p||pi) after MLE (should be ~0)|4.753e-17|tol=1.0e-10|PASS\n",
289
+ "CHECK|exact|recovered sequence distribution == teacher|2.203e-09|tol=1.0e-06|PASS\n",
290
+ "done; checks: 15\n"
291
+ ]
292
+ }
293
+ ]
294
+ }
295
+ ],
296
+ "metadata": {
297
+ "kernelspec": {
298
+ "display_name": "Python 3",
299
+ "language": "python",
300
+ "name": "python3"
301
+ },
302
+ "language_info": {
303
+ "name": "python",
304
+ "version": "3.12.11"
305
+ }
306
+ },
307
+ "nbformat": 4,
308
+ "nbformat_minor": 4
309
+ }
code/notebooks/0102-policy-gradient-baselines.ipynb ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0102 - Policy gradient from first principles, REINFORCE, baselines\n",
8
+ "Companion to `notes/0102 - Policy Gradient and Baselines.pdf`. Exact enumeration of the 121-sequence toy LM; fp64."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, itertools\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
20
+ "theta = lm.theta0.clone()\n",
21
+ "R = default_reward(lm) # binary verifier reward\n",
22
+ "p = lm.probs(theta)\n",
23
+ "S = score_matrix(lm, theta) # [N, P] grad log pi(o)\n",
24
+ "P_ = theta.numel()\n",
25
+ "print(\"N =\", lm.N, \" P =\", P_, \" E[R] =\", float(p @ R))"
26
+ ],
27
+ "outputs": [
28
+ {
29
+ "output_type": "stream",
30
+ "name": "stdout",
31
+ "text": [
32
+ "N = 121 P = 36 E[R] = 0.16592597945219617\n"
33
+ ]
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "markdown",
39
+ "metadata": {},
40
+ "source": [
41
+ "## 1. Log-derivative trick: grad J = E_pi[R * score] (note Eq. 2)\n",
42
+ "J(theta) = sum_o pi_theta(o) R(o). Three independent computations of its gradient."
43
+ ]
44
+ },
45
+ {
46
+ "cell_type": "code",
47
+ "execution_count": 2,
48
+ "metadata": {},
49
+ "source": [
50
+ "th = theta.clone().requires_grad_(True)\n",
51
+ "J = (lm.probs(th) * R).sum()\n",
52
+ "g_auto = torch.autograd.grad(J, th)[0] # differentiate the *definition* (through pi)\n",
53
+ "g_ld = (p[:, None] * R[:, None] * S).sum(0) # E_pi[R * score] (the derived formula)\n",
54
+ "def Jfun(t): return float((lm.probs(t) * R).sum())\n",
55
+ "h = 1e-6\n",
56
+ "g_fd = torch.zeros_like(theta)\n",
57
+ "for i in range(P_): # naive central finite differences\n",
58
+ " e = torch.zeros_like(theta); e[i] = h\n",
59
+ " g_fd[i] = (Jfun(theta + e) - Jfun(theta - e)) / (2 * h)\n",
60
+ "check(\"log-derivative formula == autograd of J\", maxabs(g_ld, g_auto), 1e-14)\n",
61
+ "check(\"log-derivative formula == central finite differences\", maxabs(g_ld, g_fd), 1e-8)"
62
+ ],
63
+ "outputs": [
64
+ {
65
+ "output_type": "stream",
66
+ "name": "stdout",
67
+ "text": [
68
+ "CHECK|exact|log-derivative formula == autograd of J|1.388e-17|tol=1.0e-14|PASS\n",
69
+ "CHECK|exact|log-derivative formula == central finite differences|3.363e-11|tol=1.0e-08|PASS\n"
70
+ ]
71
+ }
72
+ ]
73
+ },
74
+ {
75
+ "cell_type": "markdown",
76
+ "metadata": {},
77
+ "source": [
78
+ "## 2. Baselines are free: E[b * score] = 0 for any b that does not depend on the sampled response"
79
+ ]
80
+ },
81
+ {
82
+ "cell_type": "code",
83
+ "execution_count": 3,
84
+ "metadata": {},
85
+ "source": [
86
+ "check(\"E_pi[score] = 0\", float((p[:, None] * S).sum(0).abs().max()), 1e-14)\n",
87
+ "for b in [0.0, float(p @ R), 5.0, -3.0]:\n",
88
+ " g_b = (p[:, None] * (R - b)[:, None] * S).sum(0)\n",
89
+ " check(f\"unbiased with constant baseline b={b:+.3f}\", maxabs(g_b, g_auto), 1e-13)\n",
90
+ "# state-dependent baseline b_t(o_<t): arbitrary function of the prefix (note Eq. for token-level baseline)\n",
91
+ "T = token_score_tensor(lm, theta) # [N, L, P] token scores\n",
92
+ "gen = torch.Generator().manual_seed(5)\n",
93
+ "tab = {}\n",
94
+ "def bpref(prefix):\n",
95
+ " if prefix not in tab: tab[prefix] = float(torch.randn(1, generator=gen)) * 3.0\n",
96
+ " return tab[prefix]\n",
97
+ "B = torch.zeros(lm.N, lm.L)\n",
98
+ "for i, s in enumerate(lm.seqs):\n",
99
+ " for t in range(len(s)): B[i, t] = bpref(s[:t])\n",
100
+ "g_state = (p[:, None] * ((R[:, None] - B)[:, :, None] * T).sum(1)).sum(0)\n",
101
+ "check(\"unbiased with arbitrary state-dependent baseline b(o_<t)\", maxabs(g_state, g_auto), 1e-13)\n",
102
+ "# a baseline that depends on the token being scored breaks the proof (counter-example)\n",
103
+ "B_bad = 0.7 * (lm.tokens == 1).double() * lm.mask\n",
104
+ "g_bad = (p[:, None] * ((R[:, None] - B_bad)[:, :, None] * T).sum(1)).sum(0)\n",
105
+ "print(\"baseline depending on o_t: bias norm =\", float((g_bad - g_auto).norm()))\n",
106
+ "assert float((g_bad - g_auto).norm()) > 1e-3\n",
107
+ "# and the extreme case b = R(o) (the sample's own reward) kills the gradient completely\n",
108
+ "g_self = (p[:, None] * (R - R)[:, None] * S).sum(0)\n",
109
+ "print(\"self-baseline gradient norm:\", float(g_self.norm()), \" true grad norm:\", float(g_auto.norm()))"
110
+ ],
111
+ "outputs": [
112
+ {
113
+ "output_type": "stream",
114
+ "name": "stdout",
115
+ "text": [
116
+ "CHECK|exact|E_pi[score] = 0|5.551e-17|tol=1.0e-14|PASS\n",
117
+ "CHECK|exact|unbiased with constant baseline b=+0.000|1.388e-17|tol=1.0e-13|PASS\n",
118
+ "CHECK|exact|unbiased with constant baseline b=+0.166|2.776e-17|tol=1.0e-13|PASS\n",
119
+ "CHECK|exact|unbiased with constant baseline b=+5.000|2.078e-16|tol=1.0e-13|PASS\n",
120
+ "CHECK|exact|unbiased with constant baseline b=-3.000|2.137e-16|tol=1.0e-13|PASS\n",
121
+ "CHECK|exact|unbiased with arbitrary state-dependent baseline b(o_<t)|1.110e-16|tol=1.0e-13|PASS\n",
122
+ "baseline depending on o_t: bias norm = 0.1657102369175368\n",
123
+ "self-baseline gradient norm: 0.0 true grad norm: 0.18246238486098645\n"
124
+ ]
125
+ }
126
+ ]
127
+ },
128
+ {
129
+ "cell_type": "markdown",
130
+ "metadata": {},
131
+ "source": [
132
+ "## 3. Variance: exact quadratic in b, optimal baseline, and the \"(0, 2 b*)\" interval\n",
133
+ "Var(b) = E||(R-b) s||^2 - ||grad J||^2 with E||(R-b)s||^2 = E[R^2||s||^2] - 2b E[R||s||^2] + b^2 E||s||^2."
134
+ ]
135
+ },
136
+ {
137
+ "cell_type": "code",
138
+ "execution_count": 4,
139
+ "metadata": {},
140
+ "source": [
141
+ "s2 = (S ** 2).sum(1) # ||s||^2 per sequence\n",
142
+ "A0, A1, A2 = float((p * R ** 2 * s2).sum()), float((p * R * s2).sum()), float((p * s2).sum())\n",
143
+ "bstar = A1 / A2\n",
144
+ "def var_direct(b):\n",
145
+ " g = (R - b)[:, None] * S # per-sequence estimator value\n",
146
+ " return float((p * (g ** 2).sum(1)).sum() - (p[:, None] * g).sum(0).norm() ** 2)\n",
147
+ "def var_formula(b): return A0 - 2 * b * A1 + b * b * A2 - float(g_auto.norm() ** 2)\n",
148
+ "for b in [0.0, 0.3, 1.0, 2.0]:\n",
149
+ " check(f\"variance formula at b={b}\", abs(var_direct(b) - var_formula(b)), 1e-13)\n",
150
+ "grid = torch.linspace(-2, 4, 6001)\n",
151
+ "vs = torch.tensor([var_formula(float(b)) for b in grid])\n",
152
+ "check(\"b* = E[R||s||^2]/E||s||^2 minimises variance (grid min vs V(b*))\", max(0.0, var_formula(bstar) - float(vs.min())), 1e-12)\n",
153
+ "check(\"minimiser on grid is within grid step of b*\", abs(float(grid[vs.argmin()]) - bstar), 2e-3)\n",
154
+ "EvR = float(p @ R)\n",
155
+ "print(f\"b*={bstar:.4f} E[R]={EvR:.4f} Var(0)={var_formula(0.0):.4f} Var(E[R])={var_formula(EvR):.4f} Var(b*)={var_formula(bstar):.4f}\")\n",
156
+ "# which side of E[R] does b* fall on? b* - E[R] = Cov_pi(R, ||s||^2) / E||s||^2 (exact identity); sign = sign of the covariance\n",
157
+ "cov_Rs2 = float((p * R * s2).sum() - EvR * (p * s2).sum())\n",
158
+ "check(\"b* - E[R] == Cov_pi(R, ||s||^2) / E||s||^2 (exact identity)\", abs((bstar - EvR) - cov_Rs2 / A2), 1e-14)\n",
159
+ "m1_ = float((p * R * s2).sum() / (p * R).sum()); m0_ = float((p * (1 - R) * s2).sum() / (p * (1 - R)).sum())\n",
160
+ "print(f\"E[||s||^2 | R=1] = {m1_:.3f} E[||s||^2 | R=0] = {m0_:.3f} -> successes carry the larger score norm, so b* > E[R]\")\n",
161
+ "assert (m1_ > m0_) == (bstar > EvR)\n",
162
+ "# interval claim: any b in (0, 2b*) has Var(b) < Var(0); b outside is worse\n",
163
+ "inside = all(var_formula(float(b)) < var_formula(0.0) for b in torch.linspace(0.01, 2 * bstar - 0.01, 50))\n",
164
+ "outside = all(var_formula(float(b)) > var_formula(0.0) for b in [2 * bstar + 0.01, 2 * bstar + 1.0, -0.01, -1.0])\n",
165
+ "assert inside and outside\n",
166
+ "check(\"Var(b) < Var(0) iff b in (0, 2 b*) (50 inside, 4 outside points)\", 0.0, 0.0)"
167
+ ],
168
+ "outputs": [
169
+ {
170
+ "output_type": "stream",
171
+ "name": "stdout",
172
+ "text": [
173
+ "CHECK|exact|variance formula at b=0.0|0.000e+00|tol=1.0e-13|PASS\n",
174
+ "CHECK|exact|variance formula at b=0.3|1.110e-16|tol=1.0e-13|PASS\n",
175
+ "CHECK|exact|variance formula at b=1.0|2.220e-16|tol=1.0e-13|PASS\n",
176
+ "CHECK|exact|variance formula at b=2.0|8.882e-16|tol=1.0e-13|PASS\n",
177
+ "CHECK|exact|b* = E[R||s||^2]/E||s||^2 minimises variance (grid min vs V(b*))|0.000e+00|tol=1.0e-12|PASS\n",
178
+ "CHECK|exact|minimiser on grid is within grid step of b*|1.020e-04|tol=2.0e-03|PASS\n",
179
+ "b*=0.3019 E[R]=0.1659 Var(0)=0.6898 Var(E[R])=0.5158 Var(b*)=0.4715\n",
180
+ "CHECK|exact|b* - E[R] == Cov_pi(R, ||s||^2) / E||s||^2 (exact identity)|0.000e+00|tol=1.0e-14|PASS\n",
181
+ "E[||s||^2 | R=1] = 4.358 E[||s||^2 | R=0] = 2.005 -> successes carry the larger score norm, so b* > E[R]\n",
182
+ "CHECK|exact|Var(b) < Var(0) iff b in (0, 2 b*) (50 inside, 4 outside points)|0.000e+00|tol=0.0e+00|PASS\n"
183
+ ]
184
+ }
185
+ ]
186
+ },
187
+ {
188
+ "cell_type": "markdown",
189
+ "metadata": {},
190
+ "source": [
191
+ "## 4. Naive vs efficient estimator (Monte-Carlo), and agreement with the exact gradient\n",
192
+ "naive: one backward per sample, python loop. efficient: ONE backward through the surrogate -mean(sg(A) * log pi)."
193
+ ]
194
+ },
195
+ {
196
+ "cell_type": "code",
197
+ "execution_count": 5,
198
+ "metadata": {},
199
+ "source": [
200
+ "n = 2000\n",
201
+ "gen = torch.Generator().manual_seed(11)\n",
202
+ "idx = torch.multinomial(p, n, replacement=True, generator=gen)\n",
203
+ "b = EvR\n",
204
+ "# naive\n",
205
+ "th = theta.clone().requires_grad_(True)\n",
206
+ "acc = torch.zeros_like(theta)\n",
207
+ "for i in idx.tolist():\n",
208
+ " lp = lm.seq_logprob(th)[i]\n",
209
+ " acc += (float(R[i]) - b) * torch.autograd.grad(lp, th)[0]\n",
210
+ "g_naive = acc / n\n",
211
+ "# efficient\n",
212
+ "th = theta.clone().requires_grad_(True)\n",
213
+ "surrogate = ((R[idx] - b).detach() * lm.seq_logprob(th)[idx]).mean() # gradient of this = REINFORCE estimate\n",
214
+ "g_eff = torch.autograd.grad(surrogate, th)[0]\n",
215
+ "check(\"naive per-sample loop == single backward through surrogate\", maxabs(g_naive, g_eff), 1e-13)\n",
216
+ "# MC accuracy against the exact gradient\n",
217
+ "n2 = 400_000\n",
218
+ "idx2 = torch.multinomial(p, n2, replacement=True, generator=gen)\n",
219
+ "est = ((R[idx2] - b)[:, None] * S[idx2])\n",
220
+ "g_mc, se = est.mean(0), est.std(0) / n2 ** 0.5\n",
221
+ "ok_ = se > 0\n",
222
+ "z = float(((g_mc - g_auto).abs()[ok_] / se[ok_]).max())\n",
223
+ "check(\"MC REINFORCE gradient within 4 SE of exact (max z)\", z, 4.0, kind=\"mc\")"
224
+ ],
225
+ "outputs": [
226
+ {
227
+ "output_type": "stream",
228
+ "name": "stdout",
229
+ "text": [
230
+ "CHECK|exact|naive per-sample loop == single backward through surrogate|8.604e-16|tol=1.0e-13|PASS\n",
231
+ "CHECK|mc|MC REINFORCE gradient within 4 SE of exact (max z)|2.001e+00|tol=4.0e+00|PASS\n"
232
+ ]
233
+ }
234
+ ]
235
+ },
236
+ {
237
+ "cell_type": "markdown",
238
+ "metadata": {},
239
+ "source": [
240
+ "## 5. Group baselines: leave-one-out is unbiased, \"mean including self\" is biased by (G-1)/G\n",
241
+ "Exact enumeration of ALL G-tuples of samples (G=2: 14641 tuples, G=3: 1.77M tuples, evaluated per first sample)."
242
+ ]
243
+ },
244
+ {
245
+ "cell_type": "code",
246
+ "execution_count": 6,
247
+ "metadata": {},
248
+ "source": [
249
+ "N = lm.N\n",
250
+ "RR = R[None, :] ; pp = p[None, :]\n",
251
+ "def loo_and_mean(G):\n",
252
+ " \"\"\"exact E[(R_1 - baseline_1) s_1] for baseline = mean of others (LOO) and mean of all (incl. self).\"\"\"\n",
253
+ " # sum over samples 2..G of rewards: build the joint over (o_2..o_G) as a tensor of (reward sum, prob)\n",
254
+ " if G == 2:\n",
255
+ " rs = R[None, :].expand(N, N); w = p[None, :].expand(N, N)\n",
256
+ " sum_others_R = rs; prob_others = w\n",
257
+ " elif G == 3:\n",
258
+ " sum_others_R = (R[:, None] + R[None, :]).reshape(-1)[None, :].expand(N, -1)\n",
259
+ " prob_others = (p[:, None] * p[None, :]).reshape(-1)[None, :].expand(N, -1)\n",
260
+ " # N x N^2 : first sample index x joint of other two\n",
261
+ " Ro1 = R[:, None]\n",
262
+ " base_loo = sum_others_R / (G - 1)\n",
263
+ " base_all = (Ro1 + sum_others_R) / G\n",
264
+ " wloo = (prob_others * (Ro1 - base_loo)).sum(1) # E over others of (R1 - b)\n",
265
+ " wall = (prob_others * (Ro1 - base_all)).sum(1)\n",
266
+ " g_loo = (p * wloo)[:, None] * S\n",
267
+ " g_all = (p * wall)[:, None] * S\n",
268
+ " return g_loo.sum(0), g_all.sum(0)\n",
269
+ "for G in (2, 3):\n",
270
+ " gl, ga = loo_and_mean(G)\n",
271
+ " check(f\"G={G}: leave-one-out baseline is exactly unbiased\", maxabs(gl, g_auto), 1e-13)\n",
272
+ " check(f\"G={G}: mean-including-self gives exactly (G-1)/G * grad J\", maxabs(ga, (G - 1) / G * g_auto), 1e-13)"
273
+ ],
274
+ "outputs": [
275
+ {
276
+ "output_type": "stream",
277
+ "name": "stdout",
278
+ "text": [
279
+ "CHECK|exact|G=2: leave-one-out baseline is exactly unbiased|2.082e-17|tol=1.0e-13|PASS\n",
280
+ "CHECK|exact|G=2: mean-including-self gives exactly (G-1)/G * grad J|1.041e-17|tol=1.0e-13|PASS\n",
281
+ "CHECK|exact|G=3: leave-one-out baseline is exactly unbiased|2.082e-17|tol=1.0e-13|PASS\n",
282
+ "CHECK|exact|G=3: mean-including-self gives exactly (G-1)/G * grad J|6.939e-18|tol=1.0e-13|PASS\n"
283
+ ]
284
+ }
285
+ ]
286
+ },
287
+ {
288
+ "cell_type": "markdown",
289
+ "metadata": {},
290
+ "source": [
291
+ "## 6. Causality / reward-to-go (per-token rewards): both unbiased, reward-to-go has lower variance here\n",
292
+ "Per-token reward r_t = 1[o_t == 1]. Full return G = sum_t r_t; reward-to-go G_t = sum_{t'>=t} r_t'."
293
+ ]
294
+ },
295
+ {
296
+ "cell_type": "code",
297
+ "execution_count": 7,
298
+ "metadata": {},
299
+ "source": [
300
+ "rt = (lm.tokens == 1).double() * lm.mask # [N, L]\n",
301
+ "Gfull = rt.sum(1)\n",
302
+ "Gto = rt.flip(1).cumsum(1).flip(1) * lm.mask # reward-to-go\n",
303
+ "th = theta.clone().requires_grad_(True)\n",
304
+ "gJ = torch.autograd.grad((lm.probs(th) * Gfull).sum(), th)[0]\n",
305
+ "est_full = (T.sum(1) * Gfull[:, None]) # sum_t score_t * G\n",
306
+ "est_rtg = (T * Gto[:, :, None]).sum(1) # sum_t score_t * G_t\n",
307
+ "g_full = (p[:, None] * est_full).sum(0); g_rtg = (p[:, None] * est_rtg).sum(0)\n",
308
+ "check(\"full-return estimator unbiased\", maxabs(g_full, gJ), 1e-13)\n",
309
+ "check(\"reward-to-go estimator unbiased (dropped terms E[score_t r_t'] , t'<t, vanish)\", maxabs(g_rtg, gJ), 1e-13)\n",
310
+ "v_full = float((p * (est_full ** 2).sum(1)).sum() - gJ.norm() ** 2)\n",
311
+ "v_rtg = float((p * (est_rtg ** 2).sum(1)).sum() - gJ.norm() ** 2)\n",
312
+ "print(f\"trace-variance full return {v_full:.4f} reward-to-go {v_rtg:.4f}\")\n",
313
+ "assert v_rtg < v_full\n",
314
+ "# for an OUTCOME reward (one number at the end) reward-to-go == full return: nothing to drop\n",
315
+ "Gto_out = R[:, None] * lm.mask\n",
316
+ "check(\"outcome reward: reward-to-go == outcome at every t (no causality gain)\", maxabs(Gto_out.sum(1), R * lm.lengths), 1e-15)\n",
317
+ "print(\"done; checks:\", len(CHECKS))"
318
+ ],
319
+ "outputs": [
320
+ {
321
+ "output_type": "stream",
322
+ "name": "stdout",
323
+ "text": [
324
+ "CHECK|exact|full-return estimator unbiased|4.163e-17|tol=1.0e-13|PASS\n",
325
+ "CHECK|exact|reward-to-go estimator unbiased (dropped terms E[score_t r_t'] , t'<t, vanish)|4.163e-17|tol=1.0e-13|PASS\n",
326
+ "trace-variance full return 2.8551 reward-to-go 1.6247\n",
327
+ "CHECK|exact|outcome reward: reward-to-go == outcome at every t (no causality gain)|0.000e+00|tol=1.0e-15|PASS\n",
328
+ "done; checks: 25\n"
329
+ ]
330
+ }
331
+ ]
332
+ }
333
+ ],
334
+ "metadata": {
335
+ "kernelspec": {
336
+ "display_name": "Python 3",
337
+ "language": "python",
338
+ "name": "python3"
339
+ },
340
+ "language_info": {
341
+ "name": "python",
342
+ "version": "3.12.11"
343
+ }
344
+ },
345
+ "nbformat": 4,
346
+ "nbformat_minor": 4
347
+ }
code/notebooks/0103-importance-sampling-offpolicy.ipynb ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0103 - Importance sampling for off-policy updates; ratio variance; the product-of-token-ratios explosion\n",
8
+ "Companion to `notes/0103 - Importance Sampling and Off-Policy Updates.pdf`. Exact enumeration, fp64."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *"
19
+ ],
20
+ "outputs": []
21
+ },
22
+ {
23
+ "cell_type": "markdown",
24
+ "metadata": {},
25
+ "source": [
26
+ "## 1. IS identity, unit mean, and Var_q[w] = chi^2 = E_p[w] - 1 (note Sec. 2.1)\n",
27
+ "q = pi_old (sampler), p = pi_theta (target). Sequence-level weight w(o) = p(o)/q(o)."
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "code",
32
+ "execution_count": 2,
33
+ "metadata": {},
34
+ "source": [
35
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
36
+ "th_old = lm.theta0.clone()\n",
37
+ "th_new = perturb(th_old, 0.5, seed=3)\n",
38
+ "q, p = lm.probs(th_old), lm.probs(th_new)\n",
39
+ "w = p / q\n",
40
+ "f = default_reward(lm) + 0.3 * lm.lengths # an arbitrary test function f(o)\n",
41
+ "check(\"E_q[w f] = E_p[f] (IS identity)\", abs(float((q * w * f).sum() - (p * f).sum())), 1e-14)\n",
42
+ "check(\"E_q[w] = 1\", abs(float((q * w).sum()) - 1.0), 1e-14)\n",
43
+ "chi2 = float((p * w).sum()) - 1.0\n",
44
+ "check(\"Var_q[w] = E_p[w] - 1 = chi^2(p||q)\", abs(float((q * w ** 2).sum() - 1.0) - chi2), 1e-14)\n",
45
+ "# variance of the IS estimator of E_p[f]: Var_q[w f] = E_p[w f^2] - (E_p f)^2\n",
46
+ "v_direct = float((q * (w * f) ** 2).sum() - (q * w * f).sum() ** 2)\n",
47
+ "v_formula = float((p * w * f ** 2).sum() - (p * f).sum() ** 2)\n",
48
+ "check(\"Var_q[w f] = E_p[w f^2] - (E_p f)^2\", abs(v_direct - v_formula), 1e-13)"
49
+ ],
50
+ "outputs": [
51
+ {
52
+ "output_type": "stream",
53
+ "name": "stdout",
54
+ "text": [
55
+ "CHECK|exact|E_q[w f] = E_p[f] (IS identity)|0.000e+00|tol=1.0e-14|PASS\n",
56
+ "CHECK|exact|E_q[w] = 1|0.000e+00|tol=1.0e-14|PASS\n",
57
+ "CHECK|exact|Var_q[w] = E_p[w] - 1 = chi^2(p||q)|0.000e+00|tol=1.0e-14|PASS\n",
58
+ "CHECK|exact|Var_q[w f] = E_p[w f^2] - (E_p f)^2|0.000e+00|tol=1.0e-13|PASS\n"
59
+ ]
60
+ }
61
+ ]
62
+ },
63
+ {
64
+ "cell_type": "markdown",
65
+ "metadata": {},
66
+ "source": [
67
+ "## 2. Self-normalised IS: bias O(1/n) and the effective sample size ESS = (sum w)^2 / sum w^2 ~ n / (1 + chi^2)"
68
+ ]
69
+ },
70
+ {
71
+ "cell_type": "code",
72
+ "execution_count": 3,
73
+ "metadata": {},
74
+ "source": [
75
+ "gen = torch.Generator().manual_seed(21)\n",
76
+ "def snis(n, reps):\n",
77
+ " est, ess = [], []\n",
78
+ " for _ in range(reps):\n",
79
+ " idx = torch.multinomial(q, n, replacement=True, generator=gen)\n",
80
+ " wi = w[idx]\n",
81
+ " est.append(float((wi * f[idx]).sum() / wi.sum()))\n",
82
+ " ess.append(float(wi.sum() ** 2 / (wi ** 2).sum()))\n",
83
+ " return torch.tensor(est), torch.tensor(ess)\n",
84
+ "target = float((p * f).sum())\n",
85
+ "for n in (4, 16, 64, 256):\n",
86
+ " est, ess = snis(n, 40000 // (1 if n <= 16 else 4))\n",
87
+ " bias = float(est.mean() - target); se = float(est.std() / len(est) ** 0.5)\n",
88
+ " print(f\"n={n:4d} SNIS bias={bias:+.5f} (se {se:.5f}) mean ESS={float(ess.mean()):.2f} n/(1+chi2)={n/(1+chi2):.2f}\")\n",
89
+ "est, ess = snis(256, 4000)\n",
90
+ "check(\"MC: ESS at n=256 within 15% of n/(1+chi^2)\", abs(float(ess.mean()) / (256 / (1 + chi2)) - 1), 0.15, kind=\"mc\")"
91
+ ],
92
+ "outputs": [
93
+ {
94
+ "output_type": "stream",
95
+ "name": "stdout",
96
+ "text": [
97
+ "n= 4 SNIS bias=-0.01069 (se 0.00161) mean ESS=3.96 n/(1+chi2)=3.94\n",
98
+ "n= 16 SNIS bias=-0.00276 (se 0.00082) mean ESS=15.78 n/(1+chi2)=15.76\n",
99
+ "n= 64 SNIS bias=-0.00104 (se 0.00082) mean ESS=63.07 n/(1+chi2)=63.04\n",
100
+ "n= 256 SNIS bias=-0.00018 (se 0.00040) mean ESS=252.20 n/(1+chi2)=252.17\n",
101
+ "CHECK|mc|MC: ESS at n=256 within 15% of n/(1+chi^2)|7.446e-05|tol=1.5e-01|PASS\n"
102
+ ]
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ "cell_type": "markdown",
108
+ "metadata": {},
109
+ "source": [
110
+ "## 3. Product of token ratios: exact chi^2 growth with horizon T (long responses)\n",
111
+ "Sequence weight over the first T tokens: w_T = prod_{t<=T} r_t. E_q[w_T] = 1 for every T but E_q[w_T^2] grows geometrically."
112
+ ]
113
+ },
114
+ {
115
+ "cell_type": "code",
116
+ "execution_count": 4,
117
+ "metadata": {},
118
+ "source": [
119
+ "lmL = TinyLM(V=3, L=12, seed=4, scale=1.0)\n",
120
+ "tok = lmL.theta0[:lmL.n_tok].reshape(lmL.V + 1, lmL.V); tok[:, lmL.EOS] -= 2.5 # make EOS rare -> long sequences\n",
121
+ "thq = lmL.theta0.clone(); thp = perturb(thq, 0.6, seed=9)\n",
122
+ "lq, lp = lmL.token_logprobs(thq), lmL.token_logprobs(thp)\n",
123
+ "qL = lq.sum(1).exp()\n",
124
+ "check(\"long-horizon model: sum_o q(o) = 1\", abs(float(qL.sum()) - 1), 1e-12)\n",
125
+ "print(\"N sequences:\", lmL.N, \" mean length under q:\", float((qL * lmL.lengths).sum()))\n",
126
+ "logw_cum = (lp - lq).cumsum(1) # log w_T for T = 1..L (masked tokens contribute 0 -> ratio 1 after EOS)\n",
127
+ "rows = []\n",
128
+ "for T in range(1, lmL.L + 1):\n",
129
+ " wT = logw_cum[:, T - 1].exp()\n",
130
+ " m1 = float((qL * wT).sum()); m2 = float((qL * wT ** 2).sum())\n",
131
+ " med = float((qL * (wT < 1).double()).sum()) # P_q(w_T < 1): the typical sample has weight < 1\n",
132
+ " rows.append((T, m1, m2 - 1, med, float((qL * logw_cum[:, T - 1]).sum())))\n",
133
+ " check(f\"E_q[w_T] = 1 at T={T}\", abs(m1 - 1), 1e-12) if T in (1, 6, 12) else None\n",
134
+ "print(\" T E[w] chi2_T P(w<1) E[log w]\")\n",
135
+ "for r in rows: print(f\"{r[0]:2d} {r[1]:.4f} {r[2]:9.4f} {r[3]:.4f} {r[4]:+.4f}\")\n",
136
+ "chi_T = torch.tensor([r[2] for r in rows])\n",
137
+ "assert bool((chi_T[1:] >= chi_T[:-1] - 1e-12).all())\n",
138
+ "check(\"chi^2_T non-decreasing in T (E_q[w_T^2] only grows)\", 0.0, 0.0)\n",
139
+ "ratio_growth = float(chi_T[-1] / chi_T[0])\n",
140
+ "print(f\"chi2_12 / chi2_1 = {ratio_growth:.1f}\")\n",
141
+ "# chain rule: E_q[log w_T] = - sum_{t<=T} E_q[KL_t(q || p)] computed independently on the prefix tree\n",
142
+ "def tree_kl(thq, thp, T):\n",
143
+ " tot = 0.0\n",
144
+ " def rec(prefix, prob):\n",
145
+ " nonlocal tot\n",
146
+ " if len(prefix) >= T: return\n",
147
+ " cq, cp = lmL.cond_probs(thq, prefix), lmL.cond_probs(thp, prefix)\n",
148
+ " tot += prob * float((cq * (cq.log() - cp.log())).sum())\n",
149
+ " for a in range(lmL.V - 1):\n",
150
+ " rec(prefix + (a,), prob * float(cq[a]))\n",
151
+ " rec((), 1.0)\n",
152
+ " return tot\n",
153
+ "for T in (3, 8):\n",
154
+ " check(f\"E_q[log w_T] = -sum_t E[KL_t] (tree recursion), T={T}\", abs(rows[T - 1][4] + tree_kl(thq, thp, T)), 1e-12)"
155
+ ],
156
+ "outputs": [
157
+ {
158
+ "output_type": "stream",
159
+ "name": "stdout",
160
+ "text": [
161
+ "CHECK|exact|long-horizon model: sum_o q(o) = 1|2.220e-16|tol=1.0e-12|PASS\n",
162
+ "N sequences: 8191 mean length under q: 8.597261658540575\n",
163
+ "CHECK|exact|E_q[w_T] = 1 at T=1|2.220e-16|tol=1.0e-12|PASS\n",
164
+ "CHECK|exact|E_q[w_T] = 1 at T=6|0.000e+00|tol=1.0e-12|PASS\n",
165
+ "CHECK|exact|E_q[w_T] = 1 at T=12|0.000e+00|tol=1.0e-12|PASS\n",
166
+ " T E[w] chi2_T P(w<1) E[log w]\n",
167
+ " 1 1.0000 0.0040 0.8060 -0.0019\n",
168
+ " 2 1.0000 0.0042 0.8044 -0.0020\n",
169
+ " 3 1.0000 0.0333 0.2960 -0.0189\n",
170
+ " 4 1.0000 0.0373 0.4582 -0.0207\n",
171
+ " 5 1.0000 0.0446 0.4510 -0.0244\n",
172
+ " 6 1.0000 0.0457 0.4176 -0.0249\n",
173
+ " 7 1.0000 0.0487 0.4547 -0.0263\n",
174
+ " 8 1.0000 0.0500 0.4533 -0.0269\n",
175
+ " 9 1.0000 0.0511 0.4731 -0.0274\n",
176
+ "10 1.0000 0.0534 0.4954 -0.0285\n",
177
+ "11 1.0000 0.0535 0.4942 -0.0285\n",
178
+ "12 1.0000 0.0537 0.4936 -0.0286\n",
179
+ "CHECK|exact|chi^2_T non-decreasing in T (E_q[w_T^2] only grows)|0.000e+00|tol=0.0e+00|PASS\n",
180
+ "chi2_12 / chi2_1 = 13.3\n",
181
+ "CHECK|exact|E_q[log w_T] = -sum_t E[KL_t] (tree recursion), T=3|4.857e-17|tol=1.0e-12|PASS\n",
182
+ "CHECK|exact|E_q[log w_T] = -sum_t E[KL_t] (tree recursion), T=8|1.874e-16|tol=1.0e-12|PASS\n"
183
+ ]
184
+ }
185
+ ]
186
+ },
187
+ {
188
+ "cell_type": "markdown",
189
+ "metadata": {},
190
+ "source": [
191
+ "## 4. iid-token closed form E_q[w_T^2] = (1 + chi2_1)^T and ESS collapse"
192
+ ]
193
+ },
194
+ {
195
+ "cell_type": "code",
196
+ "execution_count": 5,
197
+ "metadata": {},
198
+ "source": [
199
+ "Vv = 3\n",
200
+ "qa = torch.tensor([0.5, 0.3, 0.2]); pa = torch.tensor([0.45, 0.33, 0.22])\n",
201
+ "chi1 = float((pa ** 2 / qa).sum()) - 1\n",
202
+ "def brute(T):\n",
203
+ " seqs = torch.cartesian_prod(*[torch.arange(Vv)] * T) if T > 1 else torch.arange(Vv)[:, None]\n",
204
+ " lqs = qa[seqs].log().sum(1); lps = pa[seqs].log().sum(1)\n",
205
+ " return float((lqs.exp() * (lps - lqs).exp() ** 2).sum())\n",
206
+ "for T in (1, 3, 6, 9):\n",
207
+ " check(f\"iid tokens: E_q[w_T^2] = (1+chi2_1)^T, T={T}\", abs(brute(T) - (1 + chi1) ** T) / (1 + chi1) ** T, 1e-12)\n",
208
+ "print(f\"per-token chi2_1 = {chi1:.5f}\")\n",
209
+ "for T in (1, 10, 100, 1000, 4000):\n",
210
+ " print(f\"T={T:5d} chi2_T = {(1+chi1)**T-1:12.3f} ESS/n ~ 1/(1+chi2) = {(1+chi1)**(-T):.3e}\")"
211
+ ],
212
+ "outputs": [
213
+ {
214
+ "output_type": "stream",
215
+ "name": "stdout",
216
+ "text": [
217
+ "CHECK|exact|iid tokens: E_q[w_T^2] = (1+chi2_1)^T, T=1|0.000e+00|tol=1.0e-12|PASS\n",
218
+ "CHECK|exact|iid tokens: E_q[w_T^2] = (1+chi2_1)^T, T=3|4.310e-16|tol=1.0e-12|PASS\n",
219
+ "CHECK|exact|iid tokens: E_q[w_T^2] = (1+chi2_1)^T, T=6|0.000e+00|tol=1.0e-12|PASS\n",
220
+ "CHECK|exact|iid tokens: E_q[w_T^2] = (1+chi2_1)^T, T=9|4.060e-16|tol=1.0e-12|PASS\n",
221
+ "per-token chi2_1 = 0.01000\n",
222
+ "T= 1 chi2_T = 0.010 ESS/n ~ 1/(1+chi2) = 9.901e-01\n",
223
+ "T= 10 chi2_T = 0.105 ESS/n ~ 1/(1+chi2) = 9.053e-01\n",
224
+ "T= 100 chi2_T = 1.705 ESS/n ~ 1/(1+chi2) = 3.697e-01\n",
225
+ "T= 1000 chi2_T = 20958.156 ESS/n ~ 1/(1+chi2) = 4.771e-05\n",
226
+ "T= 4000 chi2_T = 192972369947321888.000 ESS/n ~ 1/(1+chi2) = 5.182e-18\n"
227
+ ]
228
+ }
229
+ ]
230
+ },
231
+ {
232
+ "cell_type": "markdown",
233
+ "metadata": {},
234
+ "source": [
235
+ "## 5. Naive vs stable weight computation (fp32 running product underflows; log-space is exact)"
236
+ ]
237
+ },
238
+ {
239
+ "cell_type": "code",
240
+ "execution_count": 6,
241
+ "metadata": {},
242
+ "source": [
243
+ "gen = torch.Generator().manual_seed(77)\n",
244
+ "def weights(T, sigma, dtype):\n",
245
+ " logr = (-0.5 * sigma ** 2 + sigma * torch.randn(T, generator=gen)).to(torch.float64)\n",
246
+ " naive = torch.tensor(1.0, dtype=dtype)\n",
247
+ " for x in logr.exp().to(dtype): naive = naive * x # naive running product of token ratios\n",
248
+ " stable = logr.sum().exp() # log-space\n",
249
+ " return float(naive), float(stable), float(logr.sum())\n",
250
+ "n64, s64, _ = weights(50, 0.1, torch.float64)\n",
251
+ "check(\"T=50: naive fp64 product == exp(sum log r)\", abs(n64 - s64) / s64, 1e-12)\n",
252
+ "n32, s64b, lw = weights(4000, 0.5, torch.float32)\n",
253
+ "print(f\"T=4000 sigma=0.5: log w = {lw:.1f}; naive fp32 product = {n32}; log-space fp64 = {s64b:.3e}\")\n",
254
+ "assert n32 == 0.0 and math.isfinite(lw)"
255
+ ],
256
+ "outputs": [
257
+ {
258
+ "output_type": "stream",
259
+ "name": "stdout",
260
+ "text": [
261
+ "CHECK|exact|T=50: naive fp64 product == exp(sum log r)|0.000e+00|tol=1.0e-12|PASS\n",
262
+ "T=4000 sigma=0.5: log w = -521.5; naive fp32 product = 0.0; log-space fp64 = 3.410e-227\n"
263
+ ]
264
+ }
265
+ ]
266
+ },
267
+ {
268
+ "cell_type": "markdown",
269
+ "metadata": {},
270
+ "source": [
271
+ "## 6. Token-level surrogate (PPO-style ratio) vs the exact off-policy gradient\n",
272
+ "Exact: grad J(theta) = E_old[ w(o) (R-b) sum_t grad log pi_theta(o_t|s_t) ] (sequence-level weight, all tokens).\n",
273
+ "Token-level surrogate: L(theta) = E_old[ sum_t r_t(theta) (R-b) ] with r_t = pi_theta(o_t|s_t)/pi_old(o_t|s_t).\n",
274
+ "They agree at theta_old and drift apart as ||theta - theta_old|| grows: value error O(delta^2), gradient error O(delta)."
275
+ ]
276
+ },
277
+ {
278
+ "cell_type": "code",
279
+ "execution_count": 7,
280
+ "metadata": {},
281
+ "source": [
282
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
283
+ "R = default_reward(lm)\n",
284
+ "th_old = lm.theta0.clone()\n",
285
+ "q = lm.probs(th_old)\n",
286
+ "b = float(q @ R)\n",
287
+ "def exact_grad(th):\n",
288
+ " t = th.clone().requires_grad_(True)\n",
289
+ " return torch.autograd.grad((lm.probs(t) * R).sum(), t)[0]\n",
290
+ "def seq_is_grad(th):\n",
291
+ " t = th.clone().requires_grad_(True)\n",
292
+ " lpn = lm.seq_logprob(t)\n",
293
+ " wts = (lpn - lm.seq_logprob(th_old)).exp().detach()\n",
294
+ " return torch.autograd.grad((q * wts * (R - b) * lpn).sum(), t)[0]\n",
295
+ "def token_surrogate(th):\n",
296
+ " t = th.clone().requires_grad_(True)\n",
297
+ " r = (lm.token_logprobs(t) - lm.token_logprobs(th_old)).exp() * lm.mask # r_t(theta), 0 where masked\n",
298
+ " L = (q[:, None] * r * (R - b)[:, None]).sum()\n",
299
+ " return L, torch.autograd.grad(L, t)[0]\n",
300
+ "deltas = [0.0, 0.02, 0.04, 0.08, 0.16, 0.32]\n",
301
+ "L0 = float((q * lm.lengths * (R - b)).sum())\n",
302
+ "gerr, verr = [], []\n",
303
+ "for d in deltas:\n",
304
+ " th = perturb(th_old, d, seed=5) if d > 0 else th_old.clone()\n",
305
+ " ge = exact_grad(th)\n",
306
+ " check(f\"sequence-level IS gradient == exact grad J, delta={d}\", maxabs(seq_is_grad(th), ge), 1e-14) if d in (0.0, 0.32) else None\n",
307
+ " Lv, gt = token_surrogate(th)\n",
308
+ " gerr.append(float((gt - ge).norm())); verr.append(abs(float(Lv.detach()) - L0 - float((lm.probs(th) * R).sum() - (q * R).sum())))\n",
309
+ " print(f\"delta={d:5.2f} |grad_tok - grad_J| = {gerr[-1]:.3e} |dL - dJ| = {verr[-1]:.3e}\")\n",
310
+ "check(\"token surrogate gradient == exact gradient at theta_old\", gerr[0], 1e-14)\n",
311
+ "check(\"token surrogate value == J to first order at theta_old (error at delta=0)\", verr[0], 1e-14)\n",
312
+ "import numpy as np\n",
313
+ "sl_g = np.polyfit(np.log(deltas[1:]), np.log(gerr[1:]), 1)[0]\n",
314
+ "sl_v = np.polyfit(np.log(deltas[1:]), np.log(verr[1:]), 1)[0]\n",
315
+ "print(f\"log-log slopes: gradient error {sl_g:.2f} (expect ~1), value error {sl_v:.2f} (expect ~2)\")\n",
316
+ "check(\"gradient-error slope ~ 1\", abs(sl_g - 1.0), 0.15)\n",
317
+ "check(\"value-error slope ~ 2\", abs(sl_v - 2.0), 0.15)\n",
318
+ "print(\"done; checks:\", len(CHECKS))"
319
+ ],
320
+ "outputs": [
321
+ {
322
+ "output_type": "stream",
323
+ "name": "stdout",
324
+ "text": [
325
+ "CHECK|exact|sequence-level IS gradient == exact grad J, delta=0.0|4.163e-17|tol=1.0e-14|PASS\n",
326
+ "delta= 0.00 |grad_tok - grad_J| = 6.150e-17 |dL - dJ| = 2.776e-17\n",
327
+ "delta= 0.02 |grad_tok - grad_J| = 2.905e-04 |dL - dJ| = 3.869e-07\n",
328
+ "delta= 0.04 |grad_tok - grad_J| = 5.814e-04 |dL - dJ| = 1.550e-06\n",
329
+ "delta= 0.08 |grad_tok - grad_J| = 1.164e-03 |dL - dJ| = 6.214e-06\n",
330
+ "delta= 0.16 |grad_tok - grad_J| = 2.333e-03 |dL - dJ| = 2.497e-05\n",
331
+ "CHECK|exact|sequence-level IS gradient == exact grad J, delta=0.32|2.776e-17|tol=1.0e-14|PASS\n",
332
+ "delta= 0.32 |grad_tok - grad_J| = 4.684e-03 |dL - dJ| = 1.007e-04\n",
333
+ "CHECK|exact|token surrogate gradient == exact gradient at theta_old|6.150e-17|tol=1.0e-14|PASS\n",
334
+ "CHECK|exact|token surrogate value == J to first order at theta_old (error at delta=0)|2.776e-17|tol=1.0e-14|PASS\n",
335
+ "log-log slopes: gradient error 1.00 (expect ~1), value error 2.01 (expect ~2)\n",
336
+ "CHECK|exact|gradient-error slope ~ 1|2.652e-03|tol=1.5e-01|PASS\n",
337
+ "CHECK|exact|value-error slope ~ 2|5.861e-03|tol=1.5e-01|PASS\n",
338
+ "done; checks: 23\n"
339
+ ]
340
+ }
341
+ ]
342
+ }
343
+ ],
344
+ "metadata": {
345
+ "kernelspec": {
346
+ "display_name": "Python 3",
347
+ "language": "python",
348
+ "name": "python3"
349
+ },
350
+ "language_info": {
351
+ "name": "python",
352
+ "version": "3.12.11"
353
+ }
354
+ },
355
+ "nbformat": 4,
356
+ "nbformat_minor": 4
357
+ }
code/notebooks/0104-trust-region-ppo-clip.ipynb ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0104 - Trust regions -> PPO clipped surrogate; the clip as a pessimistic bound; the gradient case table\n",
8
+ "Companion to `notes/0104 - Trust Regions and the PPO Clip.pdf`. Exact enumeration, fp64."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math, itertools\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "import numpy as np\n",
20
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
21
+ "R = default_reward(lm)\n",
22
+ "th_old = lm.theta0.clone()\n",
23
+ "q = lm.probs(th_old)\n",
24
+ "seqset = {s: i for i, s in enumerate(lm.seqs)}\n",
25
+ "V, L = lm.V, lm.L"
26
+ ],
27
+ "outputs": []
28
+ },
29
+ {
30
+ "cell_type": "markdown",
31
+ "metadata": {},
32
+ "source": [
33
+ "## 1. Exact value functions by backward recursion; advantage A_old(s,a) = V(s+a) - V(s)"
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "code",
38
+ "execution_count": 2,
39
+ "metadata": {},
40
+ "source": [
41
+ "from functools import lru_cache\n",
42
+ "def make_value(theta):\n",
43
+ " @lru_cache(None)\n",
44
+ " def Vf(prefix):\n",
45
+ " if prefix in seqset: return float(R[seqset[prefix]]) # terminal (EOS or length L): value = reward\n",
46
+ " cp = lm.cond_probs(theta, prefix)\n",
47
+ " return float(sum(cp[a] * Vf(prefix + (a,)) for a in range(V)))\n",
48
+ " return Vf\n",
49
+ "Vold = make_value(th_old)\n",
50
+ "check(\"V_old(root) = J(theta_old) = sum_o pi R\", abs(Vold(()) - float(q @ R)), 1e-14)\n",
51
+ "states = lm.prefixes()\n",
52
+ "def A_old(prefix, a): return Vold(prefix + (a,)) - Vold(prefix)\n",
53
+ "check(\"E_{a~pi_old}[A_old(s,a)] = 0 at every state\", max(abs(sum(float(lm.cond_probs(th_old, s)[a]) * A_old(s, a) for a in range(V))) for s in states), 1e-14)\n",
54
+ "# advantage along every enumerated sequence: Adv[o,t] = A_old(s_t, o_t)\n",
55
+ "Adv = torch.zeros(lm.N, L)\n",
56
+ "for i, s in enumerate(lm.seqs):\n",
57
+ " for t in range(len(s)): Adv[i, t] = A_old(s[:t], s[t])\n",
58
+ "check(\"telescoping: sum_t A_old(s_t,o_t) = R(o) - J(theta_old) for EVERY sequence\", maxabs(Adv.sum(1), R - Vold(())), 1e-14)"
59
+ ],
60
+ "outputs": [
61
+ {
62
+ "output_type": "stream",
63
+ "name": "stdout",
64
+ "text": [
65
+ "CHECK|exact|V_old(root) = J(theta_old) = sum_o pi R|0.000e+00|tol=1.0e-14|PASS\n",
66
+ "CHECK|exact|E_{a~pi_old}[A_old(s,a)] = 0 at every state|1.475e-16|tol=1.0e-14|PASS\n",
67
+ "CHECK|exact|telescoping: sum_t A_old(s_t,o_t) = R(o) - J(theta_old) for EVERY sequence|1.110e-16|tol=1.0e-14|PASS\n"
68
+ ]
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ "cell_type": "markdown",
74
+ "metadata": {},
75
+ "source": [
76
+ "## 2. Performance-difference lemma: J(theta') - J(theta_old) = E_{o~pi'}[ sum_t A_old(s_t,o_t) ]\n",
77
+ "and the exact IS form with the PREFIX ratio w_{1:t} = prod_{s<=t} r_s (action at t is the only thing that matters for A_old)."
78
+ ]
79
+ },
80
+ {
81
+ "cell_type": "code",
82
+ "execution_count": 3,
83
+ "metadata": {},
84
+ "source": [
85
+ "def ratios(th):\n",
86
+ " lp, lq = lm.token_logprobs(th), lm.token_logprobs(th_old)\n",
87
+ " r = (lp - lq).exp() * lm.mask + (1 - lm.mask) # r_t (1 after EOS)\n",
88
+ " return r, lp\n",
89
+ "for d in (0.1, 0.4):\n",
90
+ " th = perturb(th_old, d, seed=3)\n",
91
+ " p = lm.probs(th)\n",
92
+ " dJ = float(p @ R - q @ R)\n",
93
+ " pd_lemma = float((p * Adv.sum(1)).sum()) # E_{pi'}[sum_t A_old]\n",
94
+ " r, _ = ratios(th)\n",
95
+ " wprefix = r.cumprod(1) # w_{1:t}\n",
96
+ " is_exact = float((q[:, None] * wprefix * Adv).sum()) # E_old[ sum_t w_{1:t} A_old(s_t,o_t) ]\n",
97
+ " L_sur = float((q[:, None] * r * Adv).sum()) # TRPO/PPO surrogate: only the token ratio\n",
98
+ " check(f\"performance difference lemma, delta={d}\", abs(dJ - pd_lemma), 1e-14)\n",
99
+ " check(f\"IS with prefix ratio w_(1:t) is exact, delta={d}\", abs(dJ - is_exact), 1e-14)\n",
100
+ " print(f\" delta={d}: dJ={dJ:+.6f} surrogate L-J_old={L_sur:+.6f} (error {abs(dJ-L_sur):.2e})\")\n",
101
+ "# first-order agreement of surrogate and J at theta_old\n",
102
+ "th = th_old.clone().requires_grad_(True)\n",
103
+ "lpn, lqo = lm.token_logprobs(th), lm.token_logprobs(th_old)\n",
104
+ "Ls = (q[:, None] * ((lpn - lqo).exp() * lm.mask) * Adv).sum()\n",
105
+ "gL = torch.autograd.grad(Ls, th)[0]\n",
106
+ "th2 = th_old.clone().requires_grad_(True)\n",
107
+ "gJ = torch.autograd.grad((lm.probs(th2) * R).sum(), th2)[0]\n",
108
+ "check(\"grad of surrogate at theta_old == grad J (first-order matching)\", maxabs(gL, gJ), 1e-14)"
109
+ ],
110
+ "outputs": [
111
+ {
112
+ "output_type": "stream",
113
+ "name": "stdout",
114
+ "text": [
115
+ "CHECK|exact|performance difference lemma, delta=0.1|4.857e-17|tol=1.0e-14|PASS\n",
116
+ "CHECK|exact|IS with prefix ratio w_(1:t) is exact, delta=0.1|4.380e-17|tol=1.0e-14|PASS\n",
117
+ " delta=0.1: dJ=+0.003183 surrogate L-J_old=+0.003188 (error 5.85e-06)\n",
118
+ "CHECK|exact|performance difference lemma, delta=0.4|2.776e-17|tol=1.0e-14|PASS\n",
119
+ "CHECK|exact|IS with prefix ratio w_(1:t) is exact, delta=0.4|6.939e-18|tol=1.0e-14|PASS\n",
120
+ " delta=0.4: dJ=+0.013143 surrogate L-J_old=+0.013234 (error 9.14e-05)\n",
121
+ "CHECK|exact|grad of surrogate at theta_old == grad J (first-order matching)|2.082e-17|tol=1.0e-14|PASS\n"
122
+ ]
123
+ }
124
+ ]
125
+ },
126
+ {
127
+ "cell_type": "markdown",
128
+ "metadata": {},
129
+ "source": [
130
+ "## 3. Finite-horizon trust-region bound (derived in the note): |J' - L| <= 2 eps alpha^2 T (T-1)\n",
131
+ "alpha = max_s TV(pi'(.|s), pi(.|s)), eps = max_{s,a} |A_old(s,a)|, T = horizon. Pinsker: alpha^2 <= D_KL^max / 2."
132
+ ]
133
+ },
134
+ {
135
+ "cell_type": "code",
136
+ "execution_count": 4,
137
+ "metadata": {},
138
+ "source": [
139
+ "eps = max(abs(A_old(s, a)) for s in states for a in range(V))\n",
140
+ "print(\"eps = max|A| =\", eps)\n",
141
+ "rows = []\n",
142
+ "for d in (0.05, 0.1, 0.2, 0.4, 0.8, 1.6):\n",
143
+ " th = perturb(th_old, d, seed=3)\n",
144
+ " alpha = max(0.5 * float((lm.cond_probs(th, s) - lm.cond_probs(th_old, s)).abs().sum()) for s in states)\n",
145
+ " kl_max = max(float((lm.cond_probs(th_old, s) * (lm.cond_probs(th_old, s).log() - lm.cond_probs(th, s).log())).sum()) for s in states)\n",
146
+ " p = lm.probs(th); r, _ = ratios(th)\n",
147
+ " err = abs(float(p @ R - q @ R) - float((q[:, None] * r * Adv).sum()))\n",
148
+ " bound_tv = 2 * eps * alpha ** 2 * L * (L - 1)\n",
149
+ " bound_kl = eps * L * (L - 1) * kl_max\n",
150
+ " rows.append((d, alpha, kl_max, err, bound_tv, bound_kl))\n",
151
+ " print(f\"delta={d:4.2f} alpha={alpha:.4f} KLmax={kl_max:.4f} |J'-L|={err:.3e} TV-bound={bound_tv:.3e} KL-bound={bound_kl:.3e} slack={bound_tv/max(err,1e-300):.1f}x\")\n",
152
+ " assert err <= bound_tv + 1e-15 and bound_tv <= bound_kl + 1e-15 and alpha ** 2 <= kl_max / 2 + 1e-15\n",
153
+ "check(\"bound |J'-L| <= 2 eps a^2 T(T-1) holds at all 6 trust-region sizes (max err/bound)\", max(r[3] / r[4] for r in rows), 1.0)\n",
154
+ "check(\"Pinsker chain a^2 <= KLmax/2 holds (max a^2/(KLmax/2))\", max(r[1] ** 2 / (r[2] / 2) for r in rows), 1.0)"
155
+ ],
156
+ "outputs": [
157
+ {
158
+ "output_type": "stream",
159
+ "name": "stdout",
160
+ "text": [
161
+ "eps = max|A| = 0.9030066306956115\n",
162
+ "delta=0.05 alpha=0.0048 KLmax=0.0001 |J'-L|=1.471e-06 TV-bound=5.034e-04 KL-bound=6.045e-04 slack=342.3x\n",
163
+ "delta=0.10 alpha=0.0097 KLmax=0.0002 |J'-L|=5.854e-06 TV-bound=2.028e-03 KL-bound=2.425e-03 slack=346.5x\n",
164
+ "delta=0.20 alpha=0.0195 KLmax=0.0009 |J'-L|=2.320e-05 TV-bound=8.226e-03 KL-bound=9.755e-03 slack=354.6x\n",
165
+ "delta=0.40 alpha=0.0395 KLmax=0.0036 |J'-L|=9.139e-05 TV-bound=3.379e-02 KL-bound=3.945e-02 slack=369.7x\n",
166
+ "delta=0.80 alpha=0.0808 KLmax=0.0149 |J'-L|=3.606e-04 TV-bound=1.415e-01 KL-bound=1.611e-01 slack=392.4x\n",
167
+ "delta=1.60 alpha=0.1668 KLmax=0.0615 |J'-L|=1.532e-03 TV-bound=6.027e-01 KL-bound=6.664e-01 slack=393.4x\n",
168
+ "CHECK|exact|bound |J'-L| <= 2 eps a^2 T(T-1) holds at all 6 trust-region sizes (max err/bound)|2.922e-03|tol=1.0e+00|PASS\n",
169
+ "CHECK|exact|Pinsker chain a^2 <= KLmax/2 holds (max a^2/(KLmax/2))|9.043e-01|tol=1.0e+00|PASS\n"
170
+ ]
171
+ }
172
+ ]
173
+ },
174
+ {
175
+ "cell_type": "markdown",
176
+ "metadata": {},
177
+ "source": [
178
+ "## 4. PPO clip: f(r, A) = min(r A, clip(r, 1-eps_l, 1+eps_h) A)\n",
179
+ "closed form A>0: A*min(r, 1+eps_h); A<0: A*max(r, 1-eps_l); pessimistic: f <= r A; gradient case table."
180
+ ]
181
+ },
182
+ {
183
+ "cell_type": "code",
184
+ "execution_count": 5,
185
+ "metadata": {},
186
+ "source": [
187
+ "def f_naive(r, A, el, eh):\n",
188
+ " clipped = min(max(r, 1 - el), 1 + eh)\n",
189
+ " return min(r * A, clipped * A)\n",
190
+ "def f_closed(r, A, el, eh):\n",
191
+ " return A * min(r, 1 + eh) if A > 0 else A * max(r, 1 - el)\n",
192
+ "el, eh = 0.2, 0.28\n",
193
+ "rg = np.concatenate([np.linspace(0.0, 3.0, 301), [0.8 + 1e-9, 1.28 - 1e-9]])\n",
194
+ "worst_cf, worst_pess = 0.0, -1e9\n",
195
+ "for A in (-2.0, -0.3, 0.3, 2.0):\n",
196
+ " for r in rg:\n",
197
+ " worst_cf = max(worst_cf, abs(f_naive(r, A, el, eh) - f_closed(r, A, el, eh)))\n",
198
+ " worst_pess = max(worst_pess, f_naive(r, A, el, eh) - r * A)\n",
199
+ "check(\"closed forms A*min(r,1+eh) / A*max(r,1-el) == min/clip definition\", worst_cf, 1e-14)\n",
200
+ "check(\"pessimism: f(r,A) <= r*A everywhere (max of f - rA, must be <= 0)\", max(0.0, worst_pess), 0.0)\n",
201
+ "# autograd gradient wrt log pi vs hand case table\n",
202
+ "def grad_autograd(r, A, el, eh):\n",
203
+ " lp = torch.tensor(math.log(r), requires_grad=True) # log pi_theta ; old log prob = 0 so r = exp(lp)\n",
204
+ " rr = lp.exp()\n",
205
+ " f = torch.minimum(rr * A, torch.clamp(rr, 1 - el, 1 + eh) * A)\n",
206
+ " return float(torch.autograd.grad(f, lp)[0])\n",
207
+ "def grad_table(r, A, el, eh):\n",
208
+ " if A > 0: passes = r < 1 + eh\n",
209
+ " else: passes = r > 1 - el\n",
210
+ " return A * r if passes else 0.0 # d f / d log pi = A r when the token is live\n",
211
+ "worst, n_zero, n_pass = 0.0, 0, 0\n",
212
+ "for A in (-2.0, -0.3, 0.3, 2.0):\n",
213
+ " for r in [x for x in rg if x > 0 and abs(x - (1 - el)) > 1e-6 and abs(x - (1 + eh)) > 1e-6]:\n",
214
+ " ga, gt = grad_autograd(r, A, el, eh), grad_table(r, A, el, eh)\n",
215
+ " worst = max(worst, abs(ga - gt)); n_zero += gt == 0.0; n_pass += gt != 0.0\n",
216
+ "check(f\"gradient case table == autograd on {n_zero+n_pass} (r,A) points ({n_zero} silenced, {n_pass} live)\", worst, 1e-14)\n",
217
+ "# the 2x3 table printed explicitly\n",
218
+ "print(\"\\n gradient wrt log pi of one token (eps_l=0.2, eps_h=0.28), '.' = zero\")\n",
219
+ "print(\" region A>0 A<0\")\n",
220
+ "for name, r in ((\"r < 1-eps_l\", 0.5), (\"in [1-eps_l,1+eps_h]\", 1.0), (\"r > 1+eps_h\", 2.0)):\n",
221
+ " print(f\" {name:<20s}\", \" \".join(f\"{(grad_autograd(r, A, el, eh)/(A*r)):4.0f}*A*r\" if abs(grad_autograd(r, A, el, eh))>0 else \" . \" for A in (0.7, -0.7)))\n",
222
+ "# boundary tie behaviour in torch (documented, not relied upon)\n",
223
+ "print(\" boundary tie r=1+eps_h, A>0:\", grad_autograd(1 + eh, 1.0, el, eh), \" r=1-eps_l, A<0:\", grad_autograd(1 - el, -1.0, el, eh))\n",
224
+ "# naive vs vectorised implementation\n",
225
+ "gen = torch.Generator().manual_seed(1)\n",
226
+ "rr, AA = torch.rand(10000, generator=gen) * 3, torch.randn(10000, generator=gen)\n",
227
+ "fn = torch.tensor([f_naive(float(a), float(b), el, eh) for a, b in zip(rr, AA)])\n",
228
+ "fv = torch.where(AA > 0, AA * torch.clamp(rr, max=1 + eh), AA * torch.clamp(rr, min=1 - el))\n",
229
+ "check(\"naive scalar loop == vectorised torch.where implementation\", maxabs(fn, fv), 1e-14)"
230
+ ],
231
+ "outputs": [
232
+ {
233
+ "output_type": "stream",
234
+ "name": "stdout",
235
+ "text": [
236
+ "CHECK|exact|closed forms A*min(r,1+eh) / A*max(r,1-el) == min/clip definition|0.000e+00|tol=1.0e-14|PASS\n",
237
+ "CHECK|exact|pessimism: f(r,A) <= r*A everywhere (max of f - rA, must be <= 0)|0.000e+00|tol=0.0e+00|PASS\n",
238
+ "CHECK|exact|gradient case table == autograd on 1192 (r,A) points (502 silenced, 690 live)|8.882e-16|tol=1.0e-14|PASS\n",
239
+ "\n",
240
+ " gradient wrt log pi of one token (eps_l=0.2, eps_h=0.28), '.' = zero\n",
241
+ " region A>0 A<0\n",
242
+ " r < 1-eps_l 1*A*r . \n",
243
+ " in [1-eps_l,1+eps_h] 1*A*r 1*A*r\n",
244
+ " r > 1+eps_h . 1*A*r\n",
245
+ " boundary tie r=1+eps_h, A>0: 1.28 r=1-eps_l, A<0: -0.8\n",
246
+ "CHECK|exact|naive scalar loop == vectorised torch.where implementation|0.000e+00|tol=1.0e-14|PASS\n"
247
+ ]
248
+ }
249
+ ]
250
+ },
251
+ {
252
+ "cell_type": "markdown",
253
+ "metadata": {},
254
+ "source": [
255
+ "## 5. The clip is a heuristic, not a trust region: ratios leave [1-eps,1+eps] during multi-step ascent\n",
256
+ "Full-batch (exact-expectation) ascent on E_old[ sum_t f(r_t, A_t) ] with A = R - E[R] (outcome advantage), 600 steps."
257
+ ]
258
+ },
259
+ {
260
+ "cell_type": "code",
261
+ "execution_count": 6,
262
+ "metadata": {},
263
+ "source": [
264
+ "R_ = R; b = float(q @ R_)\n",
265
+ "A_out = (R_ - b)[:, None].expand(-1, L) * lm.mask\n",
266
+ "th = th_old.clone().requires_grad_(True)\n",
267
+ "opt = torch.optim.SGD([th], lr=0.3)\n",
268
+ "lq = lm.token_logprobs(th_old)\n",
269
+ "hist = []\n",
270
+ "for step in range(601):\n",
271
+ " lp = lm.token_logprobs(th)\n",
272
+ " r = ((lp - lq).exp() * lm.mask) + (1 - lm.mask)\n",
273
+ " f = torch.where(A_out > 0, A_out * torch.clamp(r, max=1 + 0.2), A_out * torch.clamp(r, min=1 - 0.2))\n",
274
+ " obj = (q[:, None] * f * lm.mask).sum()\n",
275
+ " opt.zero_grad(); (-obj).backward(); opt.step()\n",
276
+ " if step in (0, 10, 50, 100, 200, 400, 600):\n",
277
+ " out_of = float((q[:, None] * (((r - 1).abs() > 0.2) & (lm.mask > 0))).sum() / (q * lm.lengths).sum())\n",
278
+ " hist.append((step, float((r - 1).abs().detach().max()), out_of, float(obj.detach())))\n",
279
+ "print(\"step max|r-1| prob-weighted token fraction outside [0.8,1.2] objective\")\n",
280
+ "for h in hist: print(f\"{h[0]:4d} {h[1]:8.3f} {h[2]:8.3f} {h[3]:+.4f}\")\n",
281
+ "assert hist[-1][1] > 0.4\n",
282
+ "print(\"final max|r-1| =\", hist[-1][1], \"(eps=0.2)\")\n",
283
+ "print(\"done; checks:\", len(CHECKS))"
284
+ ],
285
+ "outputs": [
286
+ {
287
+ "output_type": "stream",
288
+ "name": "stdout",
289
+ "text": [
290
+ "step max|r-1| prob-weighted token fraction outside [0.8,1.2] objective\n",
291
+ " 0 0.000 0.000 +0.2230\n",
292
+ " 10 0.252 0.113 +0.2838\n",
293
+ " 50 0.452 0.475 +0.2965\n",
294
+ " 100 0.558 0.530 +0.2998\n",
295
+ " 200 0.654 0.580 +0.3016\n",
296
+ " 400 0.751 0.753 +0.3021\n",
297
+ " 600 0.812 0.748 +0.3031\n",
298
+ "final max|r-1| = 0.8121891873787255 (eps=0.2)\n",
299
+ "done; checks: 14\n"
300
+ ]
301
+ }
302
+ ]
303
+ }
304
+ ],
305
+ "metadata": {
306
+ "kernelspec": {
307
+ "display_name": "Python 3",
308
+ "language": "python",
309
+ "name": "python3"
310
+ },
311
+ "language_info": {
312
+ "name": "python",
313
+ "version": "3.12.11"
314
+ }
315
+ },
316
+ "nbformat": 4,
317
+ "nbformat_minor": 4
318
+ }
code/notebooks/0105-grpo-group-advantages.ipynb ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0105 - GRPO: group-relative advantages as a Monte-Carlo baseline; std and length normalisation biases; Dr.GRPO\n",
8
+ "Companion to `notes/0105 - GRPO and Dr GRPO.pdf`. Exact enumeration of ALL G-tuples of samples (fp64)."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "import numpy as np\n",
20
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
21
+ "theta = lm.theta0.clone()\n",
22
+ "R = default_reward(lm)\n",
23
+ "p = lm.probs(theta)\n",
24
+ "S = score_matrix(lm, theta) # [N,P]\n",
25
+ "N = lm.N\n",
26
+ "th = theta.clone().requires_grad_(True)\n",
27
+ "pr = (lm.probs(th) * R).sum() # success probability p(theta)\n",
28
+ "grad_p = torch.autograd.grad(pr, th)[0]\n",
29
+ "p_succ = float(pr.detach())\n",
30
+ "print(\"success prob p =\", p_succ, \" ||grad p|| =\", float(grad_p.norm()))"
31
+ ],
32
+ "outputs": [
33
+ {
34
+ "output_type": "stream",
35
+ "name": "stdout",
36
+ "text": [
37
+ "success prob p = 0.16592597945219614 ||grad p|| = 0.18246238486098645\n"
38
+ ]
39
+ }
40
+ ]
41
+ },
42
+ {
43
+ "cell_type": "markdown",
44
+ "metadata": {},
45
+ "source": [
46
+ "## 1. Advantage definitions, RLOO equivalence, invariances"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "code",
51
+ "execution_count": 2,
52
+ "metadata": {},
53
+ "source": [
54
+ "def adv_grpo(Rg, ddof=0, eps=0.0):\n",
55
+ " mu = Rg.mean(); sd = Rg.std(unbiased=bool(ddof)) if len(Rg) > 1 else torch.tensor(0.0)\n",
56
+ " return (Rg - mu) / (sd + eps)\n",
57
+ "def adv_center(Rg): return Rg - Rg.mean()\n",
58
+ "def adv_rloo(Rg):\n",
59
+ " G = len(Rg); return Rg - (Rg.sum() - Rg) / (G - 1)\n",
60
+ "gen = torch.Generator().manual_seed(0)\n",
61
+ "Rg = torch.rand(7, generator=gen)\n",
62
+ "G_ = len(Rg)\n",
63
+ "check(\"G/(G-1) * (R_i - mean) == RLOO advantage\", maxabs(G_ / (G_ - 1) * adv_center(Rg), adv_rloo(Rg)), 1e-15)\n",
64
+ "check(\"GRPO advantage invariant to R -> a R + c (a>0)\", maxabs(adv_grpo(Rg), adv_grpo(3.7 * Rg - 2.0)), 1e-13)\n",
65
+ "check(\"centred advantage scales with a: A(aR+c) = a A(R)\", maxabs(adv_center(3.7 * Rg - 2.0), 3.7 * adv_center(Rg)), 1e-14)\n",
66
+ "check(\"GRPO advantage has mean 0 and (population) std 1\", max(abs(float(adv_grpo(Rg).mean())), abs(float(adv_grpo(Rg).std(unbiased=False)) - 1)), 1e-14)\n",
67
+ "Rdead = torch.ones(5)\n",
68
+ "check(\"dead group (all rewards equal) has zero advantage (eps=1e-4 convention)\", float(adv_grpo(Rdead, eps=1e-4).abs().max()), 0.0)"
69
+ ],
70
+ "outputs": [
71
+ {
72
+ "output_type": "stream",
73
+ "name": "stdout",
74
+ "text": [
75
+ "CHECK|exact|G/(G-1) * (R_i - mean) == RLOO advantage|1.110e-16|tol=1.0e-15|PASS\n",
76
+ "CHECK|exact|GRPO advantage invariant to R -> a R + c (a>0)|3.331e-16|tol=1.0e-13|PASS\n",
77
+ "CHECK|exact|centred advantage scales with a: A(aR+c) = a A(R)|3.331e-16|tol=1.0e-14|PASS\n",
78
+ "CHECK|exact|GRPO advantage has mean 0 and (population) std 1|1.269e-16|tol=1.0e-14|PASS\n",
79
+ "CHECK|exact|dead group (all rewards equal) has zero advantage (eps=1e-4 convention)|0.000e+00|tol=0.0e+00|PASS\n"
80
+ ]
81
+ }
82
+ ]
83
+ },
84
+ {
85
+ "cell_type": "markdown",
86
+ "metadata": {},
87
+ "source": [
88
+ "## 2. EXACT expected gradient of the group estimators, by enumerating every G-tuple of responses\n",
89
+ " estimator: g = (1/G) sum_i A_i * score(o_i) (outcome reward, one scalar advantage per response)\n",
90
+ "compare: mean-centred (Dr.GRPO) -> (1 - 1/G) grad J ; std-normalised (GRPO) -> c_G(p) grad p with c_G NOT constant."
91
+ ]
92
+ },
93
+ {
94
+ "cell_type": "code",
95
+ "execution_count": 3,
96
+ "metadata": {},
97
+ "source": [
98
+ "def exact_group_gradient(G, kind, Rr=None):\n",
99
+ " \"\"\"Sum over all N^G tuples with product probability. kind in {'center','grpo','rloo'}.\"\"\"\n",
100
+ " Rr = R if Rr is None else Rr\n",
101
+ " assert G in (2, 3)\n",
102
+ " if G == 2:\n",
103
+ " R1, R2 = Rr[:, None].expand(N, N), Rr[None, :].expand(N, N)\n",
104
+ " P = p[:, None] * p[None, :]\n",
105
+ " A = _adv_batch(torch.stack([R1, R2]), kind) # [2, N, N]\n",
106
+ " g = (P * A[0]).sum(1) @ S + (P * A[1]).sum(0) @ S # sample 1 carries score(o1), sample 2 score(o2)\n",
107
+ " return g / 2\n",
108
+ " else:\n",
109
+ " g = torch.zeros(S.shape[1])\n",
110
+ " R2, R3 = Rr[:, None], Rr[None, :]\n",
111
+ " P23 = p[:, None] * p[None, :]\n",
112
+ " for i in range(N):\n",
113
+ " Rs = torch.stack([Rr[i].expand(N, N), R2.expand(N, N), R3.expand(N, N)])\n",
114
+ " A = _adv_batch(Rs, kind) # [3, N, N]\n",
115
+ " g += p[i] * (P23 * A[0]).sum() * S[i]\n",
116
+ " g += p[i] * ((P23 * A[1]).sum(1) @ S)\n",
117
+ " g += p[i] * ((P23 * A[2]).sum(0) @ S)\n",
118
+ " return g / 3\n",
119
+ "def _adv_batch(Rs, kind):\n",
120
+ " \"\"\"Rs [G, ...] rewards of the group, vectorised over the trailing dims.\"\"\"\n",
121
+ " G = Rs.shape[0]\n",
122
+ " mu = Rs.mean(0, keepdim=True)\n",
123
+ " if kind == 'center': return Rs - mu\n",
124
+ " if kind == 'rloo': return Rs - (Rs.sum(0, keepdim=True) - Rs) / (G - 1)\n",
125
+ " sd = ((Rs - mu) ** 2).mean(0, keepdim=True).sqrt() # population std\n",
126
+ " safe = torch.where(sd > 0, sd, torch.ones_like(sd))\n",
127
+ " return torch.where(sd > 0, (Rs - mu) / safe, torch.zeros_like(Rs))\n",
128
+ "for G in (2, 3):\n",
129
+ " gc = exact_group_gradient(G, 'center')\n",
130
+ " gr = exact_group_gradient(G, 'rloo')\n",
131
+ " check(f\"G={G}: mean-centred estimator == (1-1/G) grad J exactly (Dr.GRPO unbiasedness)\", maxabs(gc, (1 - 1 / G) * grad_p), 1e-13)\n",
132
+ " check(f\"G={G}: RLOO estimator == grad J exactly\", maxabs(gr, grad_p), 1e-13)"
133
+ ],
134
+ "outputs": [
135
+ {
136
+ "output_type": "stream",
137
+ "name": "stdout",
138
+ "text": [
139
+ "CHECK|exact|G=2: mean-centred estimator == (1-1/G) grad J exactly (Dr.GRPO unbiasedness)|1.388e-17|tol=1.0e-13|PASS\n",
140
+ "CHECK|exact|G=2: RLOO estimator == grad J exactly|2.776e-17|tol=1.0e-13|PASS\n",
141
+ "CHECK|exact|G=3: mean-centred estimator == (1-1/G) grad J exactly (Dr.GRPO unbiasedness)|2.776e-17|tol=1.0e-13|PASS\n",
142
+ "CHECK|exact|G=3: RLOO estimator == grad J exactly|4.857e-17|tol=1.0e-13|PASS\n"
143
+ ]
144
+ }
145
+ ]
146
+ },
147
+ {
148
+ "cell_type": "markdown",
149
+ "metadata": {},
150
+ "source": [
151
+ "## 3. Std normalisation: for binary rewards the expected gradient is c_G(p) * grad p with an explicit c_G\n",
152
+ "k successes among G (0<k<G): A_succ = sqrt((G-k)/k), A_fail = -sqrt(k/(G-k)); dead groups give 0.\n",
153
+ "By exchangeability E[g] = (a1 - a0) grad p with a1 = E[A_1 | R_1=1], a0 = E[A_1 | R_1=0]."
154
+ ]
155
+ },
156
+ {
157
+ "cell_type": "code",
158
+ "execution_count": 4,
159
+ "metadata": {},
160
+ "source": [
161
+ "def binom_pmf(n, k, pp): return math.comb(n, k) * pp ** k * (1 - pp) ** (n - k)\n",
162
+ "def c_G(G, pp):\n",
163
+ " a1 = sum(binom_pmf(G - 1, K, pp) * (math.sqrt((G - (1 + K)) / (1 + K)) if 1 + K < G else 0.0) for K in range(G))\n",
164
+ " a0 = sum(binom_pmf(G - 1, K, pp) * (-math.sqrt(K / (G - K)) if 1 <= K < G else 0.0) for K in range(G))\n",
165
+ " return a1 - a0\n",
166
+ "for G in (2, 3):\n",
167
+ " gg = exact_group_gradient(G, 'grpo')\n",
168
+ " check(f\"G={G}: brute-force GRPO(std-norm) gradient == c_G(p) * grad p\", maxabs(gg, c_G(G, p_succ) * grad_p), 1e-13)\n",
169
+ "# G = 4 by exchangeability over the 3 other samples (still an enumeration of N^3 = 1.77M tuples)\n",
170
+ "G = 4; Rr = R\n",
171
+ "R2, R3, R4 = Rr[:, None, None], Rr[None, :, None], Rr[None, None, :]\n",
172
+ "P234 = p[:, None, None] * p[None, :, None] * p[None, None, :]\n",
173
+ "Wr = {}\n",
174
+ "for r1 in (0.0, 1.0):\n",
175
+ " Rs = torch.stack([torch.full((N, N, N), r1), R2.expand(N, N, N), R3.expand(N, N, N), R4.expand(N, N, N)])\n",
176
+ " Wr[r1] = float((P234 * _adv_batch(Rs, 'grpo')[0]).sum())\n",
177
+ "g4 = (Wr[1.0] * (p * R) @ S) + (Wr[0.0] * (p * (1 - R)) @ S)\n",
178
+ "check(\"G=4: enumerated (N^3 other samples) GRPO gradient == c_4(p) grad p\", maxabs(g4, c_G(4, p_succ) * grad_p), 1e-13)\n",
179
+ "print(\"\\n c_G(p) at p = %.3f for various G, vs 1/sqrt(p(1-p)) = %.4f and Dr.GRPO's (1-1/G):\" % (p_succ, 1 / math.sqrt(p_succ * (1 - p_succ))))\n",
180
+ "for G in (2, 4, 8, 16, 64, 256):\n",
181
+ " print(f\" G={G:4d} c_G = {c_G(G, p_succ):.4f}\")\n",
182
+ "check(\"c_G(p) -> 1/sqrt(p(1-p)) as G grows (G=256, relative error)\", abs(c_G(256, p_succ) * math.sqrt(p_succ * (1 - p_succ)) - 1), 0.1)\n",
183
+ "check(\"c_G(p->0) = sqrt(G-1) (bounded up-weighting of hard prompts), G=16\", abs(c_G(16, 1e-9) - math.sqrt(15)), 1e-6)\n",
184
+ "print(\" difficulty weighting c_G(p) (G=16):\")\n",
185
+ "for pp in (0.01, 0.05, 0.2, 0.5, 0.8, 0.95, 0.99):\n",
186
+ " print(f\" p={pp:4.2f} GRPO c = {c_G(16, pp):6.3f} Dr.GRPO c = {1 - 1/16:.3f}\")\n",
187
+ "# the infinite-G limit is the gradient of the variance-stabilising objective 2*arcsin(sqrt(p))\n",
188
+ "th = theta.clone().requires_grad_(True)\n",
189
+ "pp_ = (lm.probs(th) * R).sum()\n",
190
+ "g_asin = torch.autograd.grad(2 * torch.asin(pp_.sqrt()), th)[0]\n",
191
+ "check(\"grad[2 asin(sqrt p)] == grad p / sqrt(p(1-p))\", maxabs(g_asin, grad_p / math.sqrt(p_succ * (1 - p_succ))), 1e-14)"
192
+ ],
193
+ "outputs": [
194
+ {
195
+ "output_type": "stream",
196
+ "name": "stdout",
197
+ "text": [
198
+ "CHECK|exact|G=2: brute-force GRPO(std-norm) gradient == c_G(p) * grad p|2.776e-17|tol=1.0e-13|PASS\n",
199
+ "CHECK|exact|G=3: brute-force GRPO(std-norm) gradient == c_G(p) * grad p|5.551e-17|tol=1.0e-13|PASS\n",
200
+ "CHECK|exact|G=4: enumerated (N^3 other samples) GRPO gradient == c_4(p) grad p|8.327e-17|tol=1.0e-13|PASS\n",
201
+ "\n",
202
+ " c_G(p) at p = 0.166 for various G, vs 1/sqrt(p(1-p)) = 2.6881 and Dr.GRPO's (1-1/G):\n",
203
+ " G= 2 c_G = 1.0000\n",
204
+ " G= 4 c_G = 1.6678\n",
205
+ " G= 8 c_G = 2.1725\n",
206
+ " G= 16 c_G = 2.4801\n",
207
+ " G= 64 c_G = 2.6487\n",
208
+ " G= 256 c_G = 2.6785\n",
209
+ "CHECK|exact|c_G(p) -> 1/sqrt(p(1-p)) as G grows (G=256, relative error)|3.559e-03|tol=1.0e-01|PASS\n",
210
+ "CHECK|exact|c_G(p->0) = sqrt(G-1) (bounded up-weighting of hard prompts), G=16|1.454e-08|tol=1.0e-06|PASS\n",
211
+ " difficulty weighting c_G(p) (G=16):\n",
212
+ " p=0.01 GRPO c = 3.733 Dr.GRPO c = 0.938\n",
213
+ " p=0.05 GRPO c = 3.267 Dr.GRPO c = 0.938\n",
214
+ " p=0.20 GRPO c = 2.346 Dr.GRPO c = 0.938\n",
215
+ " p=0.50 GRPO c = 1.934 Dr.GRPO c = 0.938\n",
216
+ " p=0.80 GRPO c = 2.346 Dr.GRPO c = 0.938\n",
217
+ " p=0.95 GRPO c = 3.267 Dr.GRPO c = 0.938\n",
218
+ " p=0.99 GRPO c = 3.733 Dr.GRPO c = 0.938\n",
219
+ "CHECK|exact|grad[2 asin(sqrt p)] == grad p / sqrt(p(1-p))|2.776e-17|tol=1.0e-14|PASS\n"
220
+ ]
221
+ }
222
+ ]
223
+ },
224
+ {
225
+ "cell_type": "markdown",
226
+ "metadata": {},
227
+ "source": [
228
+ "## 3b. General principle: ANY group advantage A_1 = h(R_1; R_2..R_G) has expected gradient E[ f(R) score ] with f(r) = E_others[ h(r; R_2..R_G) ]\n",
229
+ "i.e. it is exactly the policy gradient of a *reshaped reward* f(R). Check with a graded (3-valued) reward, G=3, std-normalised."
230
+ ]
231
+ },
232
+ {
233
+ "cell_type": "code",
234
+ "execution_count": 5,
235
+ "metadata": {},
236
+ "source": [
237
+ "Rgr = R + (lm.lengths == 4).double() # rewards in {0,1,2}\n",
238
+ "g_graded = exact_group_gradient(3, 'grpo', Rr=Rgr)\n",
239
+ "vals = sorted(set(Rgr.tolist()))\n",
240
+ "f_of = {}\n",
241
+ "for r1 in vals: # f(r) = E over the other two samples of A_1(r; R_2, R_3)\n",
242
+ " R2, R3 = Rgr[:, None].expand(N, N), Rgr[None, :].expand(N, N)\n",
243
+ " P23 = p[:, None] * p[None, :]\n",
244
+ " A1 = _adv_batch(torch.stack([torch.full((N, N), r1), R2, R3]), 'grpo')[0]\n",
245
+ " f_of[r1] = float((P23 * A1).sum())\n",
246
+ "f_vec = torch.tensor([f_of[float(x)] for x in Rgr])\n",
247
+ "check(\"graded reward, G=3: E[GRPO gradient] == E[f(R) score] with f(r)=E[A_1|R_1=r]\", maxabs(g_graded, (p[:, None] * f_vec[:, None] * S).sum(0)), 1e-13)\n",
248
+ "print(\" reshaped reward f(r):\", {k: round(v, 4) for k, v in f_of.items()}, \"(true rewards:\", vals, \")\")"
249
+ ],
250
+ "outputs": [
251
+ {
252
+ "output_type": "stream",
253
+ "name": "stdout",
254
+ "text": [
255
+ "CHECK|exact|graded reward, G=3: E[GRPO gradient] == E[f(R) score] with f(r)=E[A_1|R_1=r]|5.551e-17|tol=1.0e-13|PASS\n",
256
+ " reshaped reward f(r): {0.0: -0.3371, 1.0: 0.9871, 2.0: 1.2765} (true rewards: [0.0, 1.0, 2.0] )\n"
257
+ ]
258
+ }
259
+ ]
260
+ },
261
+ {
262
+ "cell_type": "markdown",
263
+ "metadata": {},
264
+ "source": [
265
+ "## 4. Length normalisation: E[(R-b)/|o| * score] = grad E[(R-b)/|o|] (b held fixed) -- a different objective"
266
+ ]
267
+ },
268
+ {
269
+ "cell_type": "code",
270
+ "execution_count": 6,
271
+ "metadata": {},
272
+ "source": [
273
+ "b = float(p @ R)\n",
274
+ "lens = lm.lengths\n",
275
+ "g_unb = (p[:, None] * (R - b)[:, None] * S).sum(0) # = grad J\n",
276
+ "g_len = (p[:, None] * ((R - b) / lens)[:, None] * S).sum(0) # GRPO-style 1/|o|\n",
277
+ "th = theta.clone().requires_grad_(True)\n",
278
+ "Jlen = (lm.probs(th) * (R - b) / lens).sum() # E[(R-b)/|o|] with b constant\n",
279
+ "g_Jlen = torch.autograd.grad(Jlen, th)[0]\n",
280
+ "check(\"unbiased baseline estimator == grad J\", maxabs(g_unb, grad_p), 1e-14)\n",
281
+ "check(\"1/|o|-normalised estimator == grad of E[(R-b)/|o|] (b fixed)\", maxabs(g_len, g_Jlen), 1e-14)\n",
282
+ "check(\"constant normaliser 1/Lmax gives grad J / Lmax (Dr.GRPO)\", maxabs((p[:, None] * (R - b)[:, None] * S).sum(0) / lm.L * lm.L, grad_p), 1e-14)\n",
283
+ "# effective per-sequence weight and which way the update pushes the expected length\n",
284
+ "Elen = (lm.probs(th) * lens).sum()\n",
285
+ "g_len_E = torch.autograd.grad(Elen, th)[0] # grad E[|o|]\n",
286
+ "print(\"\\n length | P(|o|) mean weight on FAILURES (unbiased | 1/|o|) mean weight on SUCCESSES (unbiased | 1/|o|)\")\n",
287
+ "for Lk in range(1, lm.L + 1):\n",
288
+ " m = lens == Lk\n",
289
+ " pm = float(p[m].sum())\n",
290
+ " fm, sm = m & (R == 0), m & (R == 1)\n",
291
+ " wf = (-b, -b / Lk) ; ws = (1 - b, (1 - b) / Lk)\n",
292
+ " print(f\" {Lk} | {pm:6.3f} {wf[0]:+.3f} | {wf[1]:+.3f} (mass {float(p[fm].sum()):.3f}) {ws[0]:+.3f} | {ws[1]:+.3f} (mass {float(p[sm].sum()):.3f})\")\n",
293
+ "def part(wfun, sel):\n",
294
+ " return float(((p * sel * wfun)[:, None] * S).sum(0) @ g_len_E)\n",
295
+ "fail, succ = (R == 0).double(), (R == 1).double()\n",
296
+ "print(\" first-order d E|o| contributions <grad E|o|, part of gradient>:\")\n",
297
+ "print(f\" failures : unbiased {part(-b*torch.ones(N), fail):+.5f} 1/|o|-normalised {part(-b/lens, fail):+.5f}\")\n",
298
+ "print(f\" successes: unbiased {part((1-b)*torch.ones(N), succ):+.5f} 1/|o|-normalised {part((1-b)/lens, succ):+.5f}\")\n",
299
+ "dir_unb = float(g_unb @ g_len_E); dir_len = float(g_len @ g_len_E)\n",
300
+ "print(f\"\\n <grad E|o|, g_unbiased> = {dir_unb:+.5f} <grad E|o|, g_1/|o|> = {dir_len:+.5f} (first-order change in expected length per unit step)\")\n",
301
+ "# bias term = -b * grad E[1/|o|] + (grad E[R/|o|] - ... ) : decomposition check\n",
302
+ "th = theta.clone().requires_grad_(True)\n",
303
+ "gR_over = torch.autograd.grad((lm.probs(th) * R / lens).sum(), th)[0]\n",
304
+ "th = theta.clone().requires_grad_(True)\n",
305
+ "g_inv = torch.autograd.grad((lm.probs(th) / lens).sum(), th)[0]\n",
306
+ "check(\"decomposition g_len = grad E[R/|o|] - b grad E[1/|o|]\", maxabs(g_len, gR_over - b * g_inv), 1e-14)"
307
+ ],
308
+ "outputs": [
309
+ {
310
+ "output_type": "stream",
311
+ "name": "stdout",
312
+ "text": [
313
+ "CHECK|exact|unbiased baseline estimator == grad J|2.776e-17|tol=1.0e-14|PASS\n",
314
+ "CHECK|exact|1/|o|-normalised estimator == grad of E[(R-b)/|o|] (b fixed)|6.939e-18|tol=1.0e-14|PASS\n",
315
+ "CHECK|exact|constant normaliser 1/Lmax gives grad J / Lmax (Dr.GRPO)|2.776e-17|tol=1.0e-14|PASS\n",
316
+ "\n",
317
+ " length | P(|o|) mean weight on FAILURES (unbiased | 1/|o|) mean weight on SUCCESSES (unbiased | 1/|o|)\n",
318
+ " 1 | 0.464 -0.166 | -0.166 (mass 0.464) +0.834 | +0.834 (mass 0.000)\n",
319
+ " 2 | 0.258 -0.166 | -0.083 (mass 0.246) +0.834 | +0.417 (mass 0.012)\n",
320
+ " 3 | 0.142 -0.166 | -0.055 (mass 0.049) +0.834 | +0.278 (mass 0.094)\n",
321
+ " 4 | 0.136 -0.166 | -0.041 (mass 0.075) +0.834 | +0.209 (mass 0.061)\n",
322
+ " first-order d E|o| contributions <grad E|o|, part of gradient>:\n",
323
+ " failures : unbiased +0.02092 1/|o|-normalised +0.04135\n",
324
+ " successes: unbiased +0.10518 1/|o|-normalised +0.03104\n",
325
+ "\n",
326
+ " <grad E|o|, g_unbiased> = +0.12611 <grad E|o|, g_1/|o|> = +0.07239 (first-order change in expected length per unit step)\n",
327
+ "CHECK|exact|decomposition g_len = grad E[R/|o|] - b grad E[1/|o|]|7.806e-18|tol=1.0e-14|PASS\n"
328
+ ]
329
+ }
330
+ ]
331
+ },
332
+ {
333
+ "cell_type": "markdown",
334
+ "metadata": {},
335
+ "source": [
336
+ "## 5. Naive vs efficient loss reductions on a sampled group (gradient agreement) -- GRPO, DAPO-token, Dr.GRPO"
337
+ ]
338
+ },
339
+ {
340
+ "cell_type": "code",
341
+ "execution_count": 7,
342
+ "metadata": {},
343
+ "source": [
344
+ "Gs = 6\n",
345
+ "gen = torch.Generator().manual_seed(42)\n",
346
+ "idx = torch.multinomial(p, Gs, replacement=True, generator=gen)\n",
347
+ "Rg = R[idx]\n",
348
+ "Ahat = adv_grpo(Rg, eps=1e-4); Actr = adv_center(Rg)\n",
349
+ "th = theta.clone().requires_grad_(True)\n",
350
+ "lp_tok = lm.token_logprobs(th)[idx] # [G, L]\n",
351
+ "m = lm.mask[idx]\n",
352
+ "lens_g = m.sum(1)\n",
353
+ "def surrogate_naive(lp_tok, A, mode):\n",
354
+ " tot = 0.0\n",
355
+ " if mode == 'grpo':\n",
356
+ " for i in range(Gs): tot = tot + sum(A[i] * lp_tok[i, t] for t in range(int(lens_g[i]))) / lens_g[i]\n",
357
+ " return tot / Gs\n",
358
+ " if mode == 'dapo':\n",
359
+ " n = float(lens_g.sum())\n",
360
+ " for i in range(Gs): tot = tot + sum(A[i] * lp_tok[i, t] for t in range(int(lens_g[i])))\n",
361
+ " return tot / n\n",
362
+ " if mode == 'dr':\n",
363
+ " for i in range(Gs): tot = tot + sum(A[i] * lp_tok[i, t] for t in range(int(lens_g[i])))\n",
364
+ " return tot / (Gs * lm.L)\n",
365
+ "def surrogate_vec(lp_tok, A, mode):\n",
366
+ " w = A[:, None] * m\n",
367
+ " if mode == 'grpo': return ((w * lp_tok).sum(1) / lens_g).mean()\n",
368
+ " if mode == 'dapo': return (w * lp_tok).sum() / m.sum()\n",
369
+ " if mode == 'dr': return (w * lp_tok).sum() / (Gs * lm.L)\n",
370
+ "for mode, A in (('grpo', Ahat), ('dapo', Ahat), ('dr', Actr)):\n",
371
+ " gn = torch.autograd.grad(surrogate_naive(lp_tok, A, mode), th, retain_graph=True)[0]\n",
372
+ " gv = torch.autograd.grad(surrogate_vec(lp_tok, A, mode), th, retain_graph=True)[0]\n",
373
+ " check(f\"naive python loops == vectorised masked reduction ({mode})\", maxabs(gn, gv), 1e-14)\n",
374
+ "print(\"done; checks:\", len(CHECKS))"
375
+ ],
376
+ "outputs": [
377
+ {
378
+ "output_type": "stream",
379
+ "name": "stdout",
380
+ "text": [
381
+ "CHECK|exact|naive python loops == vectorised masked reduction (grpo)|0.000e+00|tol=1.0e-14|PASS\n",
382
+ "CHECK|exact|naive python loops == vectorised masked reduction (dapo)|0.000e+00|tol=1.0e-14|PASS\n",
383
+ "CHECK|exact|naive python loops == vectorised masked reduction (dr)|0.000e+00|tol=1.0e-14|PASS\n",
384
+ "done; checks: 23\n"
385
+ ]
386
+ }
387
+ ]
388
+ }
389
+ ],
390
+ "metadata": {
391
+ "kernelspec": {
392
+ "display_name": "Python 3",
393
+ "language": "python",
394
+ "name": "python3"
395
+ },
396
+ "language_info": {
397
+ "name": "python",
398
+ "version": "3.12.11"
399
+ }
400
+ },
401
+ "nbformat": 4,
402
+ "nbformat_minor": 4
403
+ }
code/notebooks/0106-dapo-pathologies.ipynb ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0106 - DAPO: each of its four techniques derived as the fix for a named pathology\n",
8
+ "Companion to `notes/0106 - DAPO.pdf`. fp64; exact enumeration where possible, Monte Carlo (flagged) elsewhere."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "import numpy as np"
20
+ ],
21
+ "outputs": []
22
+ },
23
+ {
24
+ "cell_type": "markdown",
25
+ "metadata": {},
26
+ "source": [
27
+ "## 1. Clip-Higher (pathology: entropy collapse / exploration bottleneck)\n",
28
+ "1a. Asymmetric clip gradient table (eps_lo = 0.2, eps_hi = 0.28 as in DAPO Sec. 4.1) vs autograd; 1b. absolute probability headroom."
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": 2,
34
+ "metadata": {},
35
+ "source": [
36
+ "el, eh = 0.2, 0.28\n",
37
+ "def f_closed(r, A, el, eh): return A * min(r, 1 + eh) if A > 0 else A * max(r, 1 - el)\n",
38
+ "def grad_auto(r, A):\n",
39
+ " lp = torch.tensor(math.log(r), requires_grad=True); rr = lp.exp()\n",
40
+ " f = torch.minimum(rr * A, torch.clamp(rr, 1 - el, 1 + eh) * A)\n",
41
+ " return float(torch.autograd.grad(f, lp)[0])\n",
42
+ "worst = 0.0; n = 0\n",
43
+ "for A in (-1.5, -0.2, 0.2, 1.5):\n",
44
+ " for r in np.linspace(0.05, 3.0, 400):\n",
45
+ " if min(abs(r - (1 - el)), abs(r - (1 + eh))) < 1e-6: continue\n",
46
+ " live = (r < 1 + eh) if A > 0 else (r > 1 - el)\n",
47
+ " worst = max(worst, abs(grad_auto(r, A) - (A * r if live else 0.0))); n += 1\n",
48
+ "check(f\"asymmetric clip: autograd == table (A r on live set) on {n} points\", worst, 1e-14)\n",
49
+ "print(\" log-ratio limits: log(1+eps_hi)=%.4f log(1-eps_lo)=%.4f (symmetric eps=.2: +%.4f / %.4f)\" % (math.log(1 + eh), math.log(1 - el), math.log(1.2), math.log(0.8)))\n",
50
+ "print(\" absolute probability reachable by ONE clipped update of a token with pi_old = p (A > 0):\")\n",
51
+ "print(\" p cap eps=0.20 cap eps=0.28 headroom(0.20) headroom(0.28)\")\n",
52
+ "for pp in (0.001, 0.01, 0.1, 0.5, 0.9):\n",
53
+ " c1, c2 = min(1.0, 1.2 * pp), min(1.0, 1.28 * pp)\n",
54
+ " print(f\" {pp:5.3f} {c1:8.4f} {c2:8.4f} {c1-pp:8.4f} {c2-pp:8.4f}\")\n",
55
+ "check(\"DAPO example: 0.01 -> 0.012 at eps=0.2\", abs(min(1, 1.2 * 0.01) - 0.012), 1e-15)\n",
56
+ "check(\"DAPO example: 0.9 -> min(1, 1.08) = 1.0 (no effective cap)\", abs(min(1, 1.2 * 0.9) - 1.0), 1e-15)"
57
+ ],
58
+ "outputs": [
59
+ {
60
+ "output_type": "stream",
61
+ "name": "stdout",
62
+ "text": [
63
+ "CHECK|exact|asymmetric clip: autograd == table (A r on live set) on 1600 points|8.882e-16|tol=1.0e-14|PASS\n",
64
+ " log-ratio limits: log(1+eps_hi)=0.2469 log(1-eps_lo)=-0.2231 (symmetric eps=.2: +0.1823 / -0.2231)\n",
65
+ " absolute probability reachable by ONE clipped update of a token with pi_old = p (A > 0):\n",
66
+ " p cap eps=0.20 cap eps=0.28 headroom(0.20) headroom(0.28)\n",
67
+ " 0.001 0.0012 0.0013 0.0002 0.0003\n",
68
+ " 0.010 0.0120 0.0128 0.0020 0.0028\n",
69
+ " 0.100 0.1200 0.1280 0.0200 0.0280\n",
70
+ " 0.500 0.6000 0.6400 0.1000 0.1400\n",
71
+ " 0.900 1.0000 1.0000 0.1000 0.1000\n",
72
+ "CHECK|exact|DAPO example: 0.01 -> 0.012 at eps=0.2|0.000e+00|tol=1.0e-15|PASS\n",
73
+ "CHECK|exact|DAPO example: 0.9 -> min(1, 1.08) = 1.0 (no effective cap)|0.000e+00|tol=1.0e-15|PASS\n"
74
+ ]
75
+ }
76
+ ]
77
+ },
78
+ {
79
+ "cell_type": "markdown",
80
+ "metadata": {},
81
+ "source": [
82
+ "1c. Entropy dynamics of a softmax policy: dH = -Cov_{a~pi}( log pi_a , dz_a ) (exact first order). PG: dz = eta*pi*A; NPG: dz = eta*A."
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "code",
87
+ "execution_count": 3,
88
+ "metadata": {},
89
+ "source": [
90
+ "def entropy(z):\n",
91
+ " lp = torch.log_softmax(z, 0); return float(-(lp.exp() * lp).sum())\n",
92
+ "def cov_formula(z, dz):\n",
93
+ " pi = torch.softmax(z, 0); lg = pi.log()\n",
94
+ " return float((pi * lg * dz).sum() - (pi * lg).sum() * (pi * dz).sum())\n",
95
+ "gen = torch.Generator().manual_seed(3)\n",
96
+ "worst_rel = 0.0\n",
97
+ "for trial in range(6):\n",
98
+ " K = 5\n",
99
+ " z = torch.randn(K, generator=gen) * 1.5\n",
100
+ " A = torch.randn(K, generator=gen)\n",
101
+ " pi = torch.softmax(z, 0); A = A - (pi * A).sum() # advantages must have zero pi-mean\n",
102
+ " for name, dz in ((\"PG\", pi * A), (\"NPG\", A)):\n",
103
+ " eta = 1e-6\n",
104
+ " dH_fd = (entropy(z + eta * dz) - entropy(z - eta * dz)) / (2 * eta)\n",
105
+ " dH_cov = -cov_formula(z, dz)\n",
106
+ " worst_rel = max(worst_rel, abs(dH_fd - dH_cov) / max(1e-12, abs(dH_cov)))\n",
107
+ "check(\"entropy change = -Cov(log pi, dz): finite difference vs formula (PG & NPG, 6 random policies)\", worst_rel, 1e-6)\n",
108
+ "pi = torch.tensor([0.90, 0.05, 0.03, 0.02]); z = pi.log()\n",
109
+ "def dH_for(A):\n",
110
+ " A = torch.tensor(A); A = A - (pi * A).sum(); return -cov_formula(z, pi * A)\n",
111
+ "print(\" dH/d eta for tabular PG: good action is the DOMINANT one (pi=.90): %+.5f good action is a RARE one (pi=.02): %+.5f\" %\n",
112
+ " (dH_for([1., 0, 0, 0]), dH_for([0., 0, 0, 1.])))\n",
113
+ "assert dH_for([1., 0, 0, 0]) < 0 < dH_for([0., 0, 0, 1.])\n",
114
+ "check(\"positive-advantage dominant token lowers entropy; positive-advantage rare token raises it (signs)\", 0.0, 0.0)"
115
+ ],
116
+ "outputs": [
117
+ {
118
+ "output_type": "stream",
119
+ "name": "stdout",
120
+ "text": [
121
+ "CHECK|exact|entropy change = -Cov(log pi, dz): finite difference vs formula (PG & NPG, 6 random policies)|6.651e-09|tol=1.0e-06|PASS\n",
122
+ " dH/d eta for tabular PG: good action is the DOMINANT one (pi=.90): -0.03566 good action is a RARE one (pi=.02): +0.00641\n",
123
+ "CHECK|exact|positive-advantage dominant token lowers entropy; positive-advantage rare token raises it (signs)|0.000e+00|tol=0.0e+00|PASS\n"
124
+ ]
125
+ }
126
+ ]
127
+ },
128
+ {
129
+ "cell_type": "markdown",
130
+ "metadata": {},
131
+ "source": [
132
+ "1d. Exact multi-step dynamics on a 2-action bandit (rare correct action a, pi=0.01): how fast can p(a) grow per rollout iteration?\n",
133
+ "Objective per inner step = sum_a pi_old(a) * f(r_a, A_a) (exact expectation), tabular logits, plain gradient ascent."
134
+ ]
135
+ },
136
+ {
137
+ "cell_type": "code",
138
+ "execution_count": 4,
139
+ "metadata": {},
140
+ "source": [
141
+ "def run_bandit(p0, eps_hi, outer=60, inner=400, lr=0.4, el=0.2):\n",
142
+ " z = torch.log(torch.tensor([p0, 1 - p0])).clone()\n",
143
+ " traj = [p0]\n",
144
+ " for it in range(outer):\n",
145
+ " pi_old = torch.softmax(z, 0).detach()\n",
146
+ " A = torch.tensor([1.0, 0.0]) - pi_old[0] # A_a = 1 - p, A_b = -p (binary reward on action a)\n",
147
+ " zz = z.clone().requires_grad_(True)\n",
148
+ " for _ in range(inner):\n",
149
+ " pi = torch.softmax(zz, 0); r = pi / pi_old\n",
150
+ " f = torch.where(A > 0, A * torch.clamp(r, max=1 + eps_hi), A * torch.clamp(r, min=1 - el))\n",
151
+ " obj = (pi_old * f).sum()\n",
152
+ " g = torch.autograd.grad(obj, zz)[0]\n",
153
+ " with torch.no_grad(): zz += lr * g\n",
154
+ " z = zz.detach()\n",
155
+ " traj.append(float(torch.softmax(z, 0)[0]))\n",
156
+ " return torch.tensor(traj)\n",
157
+ "t20, t28, tinf = run_bandit(0.01, 0.2), run_bandit(0.01, 0.28), run_bandit(0.01, 10.0)\n",
158
+ "def first_cross(t, v=0.5):\n",
159
+ " idx = (t >= v).nonzero()\n",
160
+ " return int(idx[0]) if len(idx) else None\n",
161
+ "print(\" iterations for p(a) to reach 0.5 starting from 0.01: eps_hi=0.20 ->\", first_cross(t20), \" eps_hi=0.28 ->\", first_cross(t28), \" no upper clip ->\", first_cross(tinf))\n",
162
+ "g20 = (t20[1:6] / t20[:5]); g28 = (t28[1:6] / t28[:5])\n",
163
+ "print(\" per-iteration growth factor p'/p over first 5 iterations: eps=.20:\", [round(float(x), 3) for x in g20], \" eps=.28:\", [round(float(x), 3) for x in g28])\n",
164
+ "print(\" growth in iteration 1 slightly EXCEEDS 1+eps (1.251 vs 1.2; 1.342 vs 1.28): the clip stops only the DIRECT push on the rare action; the unclipped negative-advantage action keeps moving mass to it\")\n",
165
+ "assert first_cross(t28) <= first_cross(t20)\n",
166
+ "check(\"larger eps_hi never slows the rare action (iterations to 0.5: eps_hi=.28 <= eps_hi=.20)\", 0.0, 0.0)"
167
+ ],
168
+ "outputs": [
169
+ {
170
+ "output_type": "stream",
171
+ "name": "stdout",
172
+ "text": [
173
+ " iterations for p(a) to reach 0.5 starting from 0.01: eps_hi=0.20 -> 8 eps_hi=0.28 -> 7 no upper clip -> 2\n",
174
+ " per-iteration growth factor p'/p over first 5 iterations: eps=.20: [1.251, 1.283, 1.331, 1.454, 1.847] eps=.28: [1.342, 1.389, 1.488, 1.805, 4.8]\n",
175
+ " growth in iteration 1 slightly EXCEEDS 1+eps (1.251 vs 1.2; 1.342 vs 1.28): the clip stops only the DIRECT push on the rare action; the unclipped negative-advantage action keeps moving mass to it\n",
176
+ "CHECK|exact|larger eps_hi never slows the rare action (iterations to 0.5: eps_hi=.28 <= eps_hi=.20)|0.000e+00|tol=0.0e+00|PASS\n"
177
+ ]
178
+ }
179
+ ]
180
+ },
181
+ {
182
+ "cell_type": "markdown",
183
+ "metadata": {},
184
+ "source": [
185
+ "## 2. Dynamic sampling (pathology: zero-advantage groups). P(dead group) = p^G + (1-p)^G"
186
+ ]
187
+ },
188
+ {
189
+ "cell_type": "code",
190
+ "execution_count": 5,
191
+ "metadata": {},
192
+ "source": [
193
+ "torch.manual_seed(8) # torch.distributions samplers use the global RNG\n",
194
+ "gen = torch.Generator().manual_seed(8)\n",
195
+ "for G in (4, 16):\n",
196
+ " for pp in (0.02, 0.3):\n",
197
+ " ks = torch.distributions.Binomial(G, torch.tensor(pp)).sample((400000,))\n",
198
+ " emp = float(((ks == 0) | (ks == G)).double().mean()); ex = pp ** G + (1 - pp) ** G\n",
199
+ " se = math.sqrt(ex * (1 - ex) / 400000)\n",
200
+ " check(f\"P(dead) MC vs p^G+(1-p)^G (G={G}, p={pp}) |diff|/SE\", abs(emp - ex) / se, 4.0, kind=\"mc\")\n",
201
+ "# expected live fraction for p ~ Beta(a,b): 1 - B(a+G,b)/B(a,b) - B(a,b+G)/B(a,b)\n",
202
+ "def lbeta(a, b): return math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b)\n",
203
+ "def live_fraction(a, b, G): return 1 - math.exp(lbeta(a + G, b) - lbeta(a, b)) - math.exp(lbeta(a, b + G) - lbeta(a, b))\n",
204
+ "xs, ws = np.polynomial.legendre.leggauss(400); xs = 0.5 * (xs + 1); ws = 0.5 * ws\n",
205
+ "def live_quad(a, b, G):\n",
206
+ " dens = np.exp(-lbeta(a, b)) * xs ** (a - 1) * (1 - xs) ** (b - 1)\n",
207
+ " return float((ws * dens * (1 - xs ** G - (1 - xs) ** G)).sum())\n",
208
+ "for (a, b) in ((2, 5), (3, 3), (2, 8)):\n",
209
+ " check(f\"expected live fraction closed form vs quadrature, Beta({a},{b}), G=16\", abs(live_fraction(a, b, 16) - live_quad(a, b, 16)), 1e-10)\n",
210
+ "a, b, G = 2.0, 8.0, 16\n",
211
+ "rho = live_fraction(a, b, G)\n",
212
+ "print(f\" Beta({a:.0f},{b:.0f}) prompts (mean p={a/(a+b):.2f}), G={G}: live fraction rho = {rho:.3f} -> oversampling factor 1/rho = {1/rho:.2f}\")\n",
213
+ "# MC: prompts drawn until m live ones are found; expectation m / rho\n",
214
+ "torch.manual_seed(2)\n",
215
+ "gen = torch.Generator().manual_seed(2)\n",
216
+ "m_need, reps = 64, 600\n",
217
+ "draws = []\n",
218
+ "beta = torch.distributions.Beta(torch.tensor(a), torch.tensor(b))\n",
219
+ "for _ in range(reps):\n",
220
+ " live = 0; drawn = 0\n",
221
+ " while live < m_need:\n",
222
+ " pp = beta.sample()\n",
223
+ " k = torch.distributions.Binomial(G, pp).sample()\n",
224
+ " drawn += 1; live += int(0 < k < G)\n",
225
+ " draws.append(drawn)\n",
226
+ "d = torch.tensor(draws, dtype=torch.float64)\n",
227
+ "check(\"MC: prompts drawn to collect 64 live groups vs m/rho (z-score)\", abs(float(d.mean()) - m_need / rho) / (float(d.std()) / reps ** 0.5), 4.0, kind=\"mc\")\n",
228
+ "# Unbiasedness of dynamic sampling (exact, no MC): prompts q_j with success prob p_j and weights w_j; per-prompt Bernoulli-bandit gradient g = p_hat(1-p_hat)\n",
229
+ "# (centred advantage x score, see 0110). Filtered batch: a prompt is accepted with probability P(live|q_j) (so accepted prompts have law w_j P(live_j)/rho) and its\n",
230
+ "# group contributes E[g | live, q_j] computed DIRECTLY from the binomial restricted to 0<k<G (not using g=0 on dead groups).\n",
231
+ "Gd = 16\n",
232
+ "pj = np.linspace(0.02, 0.98, 25); wj = pj ** (a - 1) * (1 - pj) ** (b - 1); wj /= wj.sum()\n",
233
+ "def binom_k(pp): return np.array([math.comb(Gd, k) * pp ** k * (1 - pp) ** (Gd - k) for k in range(Gd + 1)])\n",
234
+ "kk = np.arange(Gd + 1); gk = (kk / Gd) * (1 - kk / Gd)\n",
235
+ "Eg_all = np.array([float((binom_k(x) * gk).sum()) for x in pj])\n",
236
+ "Plive = np.array([float(binom_k(x)[1:Gd].sum()) for x in pj])\n",
237
+ "Eg_live = np.array([float((binom_k(x)[1:Gd] * gk[1:Gd]).sum()) / pl for x, pl in zip(pj, Plive)])\n",
238
+ "rho_j = float((wj * Plive).sum())\n",
239
+ "unfiltered = float((wj * Eg_all).sum())\n",
240
+ "filtered = float((wj * Plive / rho_j * Eg_live).sum())\n",
241
+ "check(\"dynamic sampling: E[g | accepted group] = (1/rho) * E[g | unfiltered group] (exact, discrete prompt law)\", abs(filtered - unfiltered / rho_j), 1e-15)\n",
242
+ "# the dilution identity: dividing by ALL samples instead of live samples scales the gradient by exactly the live fraction\n",
243
+ "print(\" with global token normalisation, dead groups stay in the denominator: signal scales by rho =\", round(rho, 3), \"(identity, no estimation)\")"
244
+ ],
245
+ "outputs": [
246
+ {
247
+ "output_type": "stream",
248
+ "name": "stdout",
249
+ "text": [
250
+ "CHECK|mc|P(dead) MC vs p^G+(1-p)^G (G=4, p=0.02) |diff|/SE|9.828e-01|tol=4.0e+00|PASS\n",
251
+ "CHECK|mc|P(dead) MC vs p^G+(1-p)^G (G=4, p=0.3) |diff|/SE|6.955e-01|tol=4.0e+00|PASS\n",
252
+ "CHECK|mc|P(dead) MC vs p^G+(1-p)^G (G=16, p=0.02) |diff|/SE|4.989e-01|tol=4.0e+00|PASS\n",
253
+ "CHECK|mc|P(dead) MC vs p^G+(1-p)^G (G=16, p=0.3) |diff|/SE|2.052e+00|tol=4.0e+00|PASS\n",
254
+ "CHECK|exact|expected live fraction closed form vs quadrature, Beta(2,5), G=16|2.698e-14|tol=1.0e-10|PASS\n",
255
+ "CHECK|exact|expected live fraction closed form vs quadrature, Beta(3,3), G=16|2.798e-14|tol=1.0e-10|PASS\n",
256
+ "CHECK|exact|expected live fraction closed form vs quadrature, Beta(2,8), G=16|2.109e-14|tol=1.0e-10|PASS\n",
257
+ " Beta(2,8) prompts (mean p=0.20), G=16: live fraction rho = 0.880 -> oversampling factor 1/rho = 1.14\n",
258
+ "CHECK|mc|MC: prompts drawn to collect 64 live groups vs m/rho (z-score)|5.547e-01|tol=4.0e+00|PASS\n",
259
+ "CHECK|exact|dynamic sampling: E[g | accepted group] = (1/rho) * E[g | unfiltered group] (exact, discrete prompt law)|0.000e+00|tol=1.0e-15|PASS\n",
260
+ " with global token normalisation, dead groups stay in the denominator: signal scales by rho = 0.88 (identity, no estimation)\n"
261
+ ]
262
+ }
263
+ ]
264
+ },
265
+ {
266
+ "cell_type": "markdown",
267
+ "metadata": {},
268
+ "source": [
269
+ "## 3. Token-level loss: global denominator is a RATIO estimator (bias O(1/#samples)); constant denominator is exactly unbiased\n",
270
+ "Exact over all G-tuples (G=2,3) on the TinyLM."
271
+ ]
272
+ },
273
+ {
274
+ "cell_type": "code",
275
+ "execution_count": 6,
276
+ "metadata": {},
277
+ "source": [
278
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
279
+ "theta = lm.theta0.clone(); R = default_reward(lm); p = lm.probs(theta); S = score_matrix(lm, theta); N = lm.N\n",
280
+ "lens = lm.lengths\n",
281
+ "grad_J = (p[:, None] * (R - float(p @ R))[:, None] * S).sum(0)\n",
282
+ "Lbar = float((p * lens).sum())\n",
283
+ "def exact_tokenlevel(G, normaliser):\n",
284
+ " \"\"\"E[ sum_i A_i * score(o_i) / D ] with centred advantages A_i = R_i - mean R; D = sum_i |o_i| (global) or G*Lbar (constant).\"\"\"\n",
285
+ " if G == 2:\n",
286
+ " R1, R2 = R[:, None].expand(N, N), R[None, :].expand(N, N)\n",
287
+ " l1, l2 = lens[:, None].expand(N, N), lens[None, :].expand(N, N)\n",
288
+ " P = p[:, None] * p[None, :]\n",
289
+ " mu = (R1 + R2) / 2\n",
290
+ " D = (l1 + l2) if normaliser == 'global' else torch.full((N, N), G * Lbar)\n",
291
+ " w1 = P * (R1 - mu) / D; w2 = P * (R2 - mu) / D\n",
292
+ " return w1.sum(1) @ S + w2.sum(0) @ S\n",
293
+ " g = torch.zeros(S.shape[1])\n",
294
+ " R2, R3 = R[:, None].expand(N, N), R[None, :].expand(N, N)\n",
295
+ " l2, l3 = lens[:, None].expand(N, N), lens[None, :].expand(N, N)\n",
296
+ " P23 = p[:, None] * p[None, :]\n",
297
+ " for i in range(N):\n",
298
+ " mu = (R[i] + R2 + R3) / 3\n",
299
+ " D = (lens[i] + l2 + l3) if normaliser == 'global' else torch.full((N, N), G * Lbar)\n",
300
+ " g += p[i] * ((P23 * (R[i] - mu) / D).sum()) * S[i]\n",
301
+ " g += p[i] * ((P23 * (R2 - mu) / D).sum(1) @ S)\n",
302
+ " g += p[i] * ((P23 * (R3 - mu) / D).sum(0) @ S)\n",
303
+ " return g\n",
304
+ "for G in (2, 3):\n",
305
+ " gc = exact_tokenlevel(G, 'const'); gg = exact_tokenlevel(G, 'global')\n",
306
+ " check(f\"G={G}: constant denominator G*E|o| gives (1-1/G) grad J / (G E|o|) * G exactly\", maxabs(gc * Lbar, (1 - 1 / G) * grad_J), 1e-13)\n",
307
+ " rel = float((gg - gc).norm() / gc.norm()); cs = float(gg @ grad_J / (gg.norm() * grad_J.norm()))\n",
308
+ " print(f\" G={G}: global-token-mean estimator: relative deviation from the unbiased one = {rel:.4f}, cosine to grad J = {cs:.5f}\")"
309
+ ],
310
+ "outputs": [
311
+ {
312
+ "output_type": "stream",
313
+ "name": "stdout",
314
+ "text": [
315
+ "CHECK|exact|G=2: constant denominator G*E|o| gives (1-1/G) grad J / (G E|o|) * G exactly|1.041e-17|tol=1.0e-13|PASS\n",
316
+ " G=2: global-token-mean estimator: relative deviation from the unbiased one = 0.1591, cosine to grad J = 0.99588\n",
317
+ "CHECK|exact|G=3: constant denominator G*E|o| gives (1-1/G) grad J / (G E|o|) * G exactly|4.163e-17|tol=1.0e-13|PASS\n",
318
+ " G=3: global-token-mean estimator: relative deviation from the unbiased one = 0.1000, cosine to grad J = 0.99779\n"
319
+ ]
320
+ }
321
+ ]
322
+ },
323
+ {
324
+ "cell_type": "markdown",
325
+ "metadata": {},
326
+ "source": [
327
+ "## 4. Overlong handling: soft punishment is a REWARD; loss masking is a reward equal to the baseline"
328
+ ]
329
+ },
330
+ {
331
+ "cell_type": "code",
332
+ "execution_count": 7,
333
+ "metadata": {},
334
+ "source": [
335
+ "def soft_overlong(Ly, Lmax, Lcache):\n",
336
+ " if Ly <= Lmax - Lcache: return 0.0\n",
337
+ " if Ly <= Lmax: return ((Lmax - Lcache) - Ly) / Lcache\n",
338
+ " return -1.0\n",
339
+ "Lmax, Lcache = 16384, 4096\n",
340
+ "check(\"soft overlong: 0 at Lmax-Lcache\", abs(soft_overlong(Lmax - Lcache, Lmax, Lcache)), 0.0)\n",
341
+ "check(\"soft overlong: -1 at Lmax (continuous with the truncation penalty)\", abs(soft_overlong(Lmax, Lmax, Lcache) + 1.0), 0.0)\n",
342
+ "ls = np.arange(1, 24000)\n",
343
+ "vals = np.array([soft_overlong(int(x), Lmax, Lcache) for x in ls])\n",
344
+ "slope = float(np.abs(np.diff(vals[:Lmax])).max()) # largest per-token change of the ramp (up to Lmax)\n",
345
+ "check(\"soft overlong: ramp slope is exactly 1/Lcache (|max slope * Lcache - 1|)\", abs(slope * Lcache - 1.0), 1e-9)\n",
346
+ "print(\" hard truncation reward has a jump of size 1 at Lmax; the soft ramp spreads it over\", Lcache, \"tokens\")\n",
347
+ "# masking == baseline reward (TinyLM: 'truncated' = hit the length limit L=4 without EOS)\n",
348
+ "trunc = (lm.mask.sum(1) == lm.L) & (lm.tokens[:, -1] != lm.EOS)\n",
349
+ "b = float(p @ R)\n",
350
+ "g_masked = (p[:, None] * ((~trunc).double() * (R - b))[:, None] * S).sum(0) # loss masked on truncated samples\n",
351
+ "Rprime = torch.where(trunc, torch.full_like(R, b), R) # truncated sample gets reward = baseline\n",
352
+ "g_rb = (p[:, None] * (Rprime - b)[:, None] * S).sum(0)\n",
353
+ "check(\"loss-masking == assigning the baseline value as reward to truncated samples (exact)\", maxabs(g_masked, g_rb), 1e-14)\n",
354
+ "th = theta.clone().requires_grad_(True)\n",
355
+ "Rlen = torch.where(trunc, torch.full_like(R, -1.0), torch.zeros_like(R)) + 0.0\n",
356
+ "g_total_reward = (p[:, None] * (R + Rlen - b)[:, None] * S).sum(0)\n",
357
+ "th = theta.clone().requires_grad_(True)\n",
358
+ "g_sep = torch.autograd.grad((lm.probs(th) * (R + Rlen)).sum(), th)[0]\n",
359
+ "check(\"shaping as reward: policy gradient of E[R + R_len] (separable objective)\", maxabs(g_total_reward, g_sep), 1e-14)\n",
360
+ "g_hard = (p[:, None] * (R - b)[:, None] * S).sum(0) # truncated treated as plain failures (R=0)\n",
361
+ "print(f\" ||g_masked - g_hard|| = {float((g_masked-g_hard).norm()):.4f} vs ||grad J|| = {float(grad_J.norm()):.4f} (filtering changes the objective)\")"
362
+ ],
363
+ "outputs": [
364
+ {
365
+ "output_type": "stream",
366
+ "name": "stdout",
367
+ "text": [
368
+ "CHECK|exact|soft overlong: 0 at Lmax-Lcache|0.000e+00|tol=0.0e+00|PASS\n",
369
+ "CHECK|exact|soft overlong: -1 at Lmax (continuous with the truncation penalty)|0.000e+00|tol=0.0e+00|PASS\n",
370
+ "CHECK|exact|soft overlong: ramp slope is exactly 1/Lcache (|max slope * Lcache - 1|)|0.000e+00|tol=1.0e-09|PASS\n",
371
+ " hard truncation reward has a jump of size 1 at Lmax; the soft ramp spreads it over 4096 tokens\n",
372
+ "CHECK|exact|loss-masking == assigning the baseline value as reward to truncated samples (exact)|0.000e+00|tol=1.0e-14|PASS\n",
373
+ "CHECK|exact|shaping as reward: policy gradient of E[R + R_len] (separable objective)|2.776e-17|tol=1.0e-14|PASS\n",
374
+ " ||g_masked - g_hard|| = 0.0124 vs ||grad J|| = 0.1825 (filtering changes the objective)\n"
375
+ ]
376
+ }
377
+ ]
378
+ },
379
+ {
380
+ "cell_type": "markdown",
381
+ "metadata": {},
382
+ "source": [
383
+ "## 5. Naive vs vectorised DAPO objective: asymmetric clip + global token-level normaliser + dynamic-sampling filter (gradient agreement)"
384
+ ]
385
+ },
386
+ {
387
+ "cell_type": "code",
388
+ "execution_count": 8,
389
+ "metadata": {},
390
+ "source": [
391
+ "th_old = lm.theta0.clone(); lq = lm.token_logprobs(th_old); q_old = lm.probs(th_old)\n",
392
+ "th_new = perturb(th_old, 1.0, seed=3)\n",
393
+ "gen = torch.Generator().manual_seed(17)\n",
394
+ "G, groups = 8, []\n",
395
+ "while len(groups) < 3: # dynamic sampling: resample until the group is live (0 < k < G)\n",
396
+ " idx = torch.multinomial(q_old, G, replacement=True, generator=gen)\n",
397
+ " k = int(R[idx].sum())\n",
398
+ " if 0 < k < G: groups.append(idx)\n",
399
+ "def dapo_naive(th):\n",
400
+ " lp_all = lm.token_logprobs(th)\n",
401
+ " tot, ntok = 0.0, 0\n",
402
+ " for idx in groups:\n",
403
+ " Rg = R[idx]; A = (Rg - Rg.mean()) / (Rg.std(unbiased=False) + 1e-6)\n",
404
+ " for j, i in enumerate(idx.tolist()):\n",
405
+ " for t in range(int(lm.lengths[i])):\n",
406
+ " r = torch.exp(lp_all[i, t] - lq[i, t])\n",
407
+ " a = A[j]\n",
408
+ " term = a * torch.clamp(r, max=1 + eh) if a > 0 else a * torch.clamp(r, min=1 - el)\n",
409
+ " tot = tot + term; ntok += 1\n",
410
+ " return -tot / ntok\n",
411
+ "def dapo_vec(th):\n",
412
+ " lp_all = lm.token_logprobs(th)\n",
413
+ " idx = torch.cat(groups); Rg = R[idx].reshape(len(groups), G)\n",
414
+ " A = ((Rg - Rg.mean(1, keepdim=True)) / (Rg.std(1, unbiased=False, keepdim=True) + 1e-6)).reshape(-1)\n",
415
+ " r = (lp_all[idx] - lq[idx]).exp(); mk = lm.mask[idx]\n",
416
+ " f = torch.where(A[:, None] > 0, A[:, None] * torch.clamp(r, max=1 + eh), A[:, None] * torch.clamp(r, min=1 - el))\n",
417
+ " return -(f * mk).sum() / mk.sum()\n",
418
+ "t1 = th_new.clone().requires_grad_(True); t2 = th_new.clone().requires_grad_(True)\n",
419
+ "check(\"naive triple loop == vectorised DAPO loss (value)\", abs(float(dapo_naive(t1).detach()) - float(dapo_vec(t2).detach())), 1e-13)\n",
420
+ "g1 = torch.autograd.grad(dapo_naive(t1), t1)[0]; g2 = torch.autograd.grad(dapo_vec(t2), t2)[0]\n",
421
+ "check(\"naive triple loop == vectorised DAPO loss (gradient)\", maxabs(g1, g2), 1e-13)\n",
422
+ "print(\"done; checks:\", len(CHECKS))"
423
+ ],
424
+ "outputs": [
425
+ {
426
+ "output_type": "stream",
427
+ "name": "stdout",
428
+ "text": [
429
+ "CHECK|exact|naive triple loop == vectorised DAPO loss (value)|1.665e-16|tol=1.0e-13|PASS\n",
430
+ "CHECK|exact|naive triple loop == vectorised DAPO loss (gradient)|0.000e+00|tol=1.0e-13|PASS\n",
431
+ "done; checks: 24\n"
432
+ ]
433
+ }
434
+ ]
435
+ }
436
+ ],
437
+ "metadata": {
438
+ "kernelspec": {
439
+ "display_name": "Python 3",
440
+ "language": "python",
441
+ "name": "python3"
442
+ },
443
+ "language_info": {
444
+ "name": "python",
445
+ "version": "3.12.11"
446
+ }
447
+ },
448
+ "nbformat": 4,
449
+ "nbformat_minor": 4
450
+ }
code/notebooks/0107-cispo.ipynb ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0107 - CISPO (MiniMax-M1 Sec. 3.1): clip the importance weight, not the update\n",
8
+ "Companion to `notes/0107 - CISPO.pdf`. fp64; exact enumeration on the 121-sequence toy LM."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "import numpy as np\n",
20
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
21
+ "R = default_reward(lm)\n",
22
+ "th_old = lm.theta0.clone(); q = lm.probs(th_old); N, L = lm.N, lm.L\n",
23
+ "b = float(q @ R)\n",
24
+ "A_tok = (R - b)[:, None] * lm.mask # outcome advantage, same for all tokens of a sequence\n",
25
+ "lq = lm.token_logprobs(th_old)\n",
26
+ "Ttens = token_score_tensor(lm, th_old) # score of each token at THETA_OLD (used to build gradients analytically)\n",
27
+ "eh, el = 0.28, 0.2"
28
+ ],
29
+ "outputs": []
30
+ },
31
+ {
32
+ "cell_type": "markdown",
33
+ "metadata": {},
34
+ "source": [
35
+ "## 1. Gradient weights. Every method has gradient sum_t w(r_t, A_t) * A_t * grad log pi_theta(o_t | s_t)\n",
36
+ " PPO/GRPO (clip lo/hi): w = r * 1[live] live = (A>0 & r<1+eh) | (A<0 & r>1-el)\n",
37
+ " CISPO: w = min(r, 1+eh) (A-independent, always live) [loss = sg(min(r,1+eh)) * A * log pi]\n",
38
+ " token IS-PG (no clip): w = r\n",
39
+ "We verify against autograd of the actual loss functions on the toy model at a perturbed theta."
40
+ ]
41
+ },
42
+ {
43
+ "cell_type": "code",
44
+ "execution_count": 2,
45
+ "metadata": {},
46
+ "source": [
47
+ "def ratio_and_lp(th):\n",
48
+ " lp = lm.token_logprobs(th)\n",
49
+ " r = (lp - lq).exp() * lm.mask + (1 - lm.mask)\n",
50
+ " return r, lp\n",
51
+ "def loss_ppo(th):\n",
52
+ " r, lp = ratio_and_lp(th)\n",
53
+ " f = torch.where(A_tok > 0, A_tok * torch.clamp(r, max=1 + eh), A_tok * torch.clamp(r, min=1 - el))\n",
54
+ " return -(q[:, None] * f * lm.mask).sum()\n",
55
+ "def loss_cispo(th, eps_hi=eh):\n",
56
+ " r, lp = ratio_and_lp(th)\n",
57
+ " rhat = torch.clamp(r, max=1 + eps_hi).detach() # sg( clip(r) )\n",
58
+ " return -(q[:, None] * rhat * A_tok * lp * lm.mask).sum()\n",
59
+ "def loss_isPG(th):\n",
60
+ " r, lp = ratio_and_lp(th)\n",
61
+ " return -(q[:, None] * r.detach() * A_tok * lp * lm.mask).sum()\n",
62
+ "def weights(th, method):\n",
63
+ " r, _ = ratio_and_lp(th); r = r.detach()\n",
64
+ " if method == 'ppo': live = ((A_tok > 0) & (r < 1 + eh)) | ((A_tok < 0) & (r > 1 - el)); return r * live\n",
65
+ " if method == 'cispo': return torch.clamp(r, max=1 + eh)\n",
66
+ " if method == 'is': return r\n",
67
+ "def grad_from_weights(th, w):\n",
68
+ " # sum_{o,t} q(o) w A grad log pi_theta(o_t|s_t) with the score evaluated at theta (autograd through lp)\n",
69
+ " t = th.clone().requires_grad_(True)\n",
70
+ " lp = lm.token_logprobs(t)\n",
71
+ " return torch.autograd.grad((q[:, None] * w * A_tok * lp * lm.mask).sum(), t)[0]\n",
72
+ "th = perturb(th_old, 1.5, seed=11)\n",
73
+ "r_now, _ = ratio_and_lp(th)\n",
74
+ "print(\"ratio range over (o,t): min %.3f max %.3f\" % (float(r_now[lm.mask > 0].min()), float(r_now[lm.mask > 0].max())))\n",
75
+ "for name, lossf in ((\"ppo\", loss_ppo), (\"cispo\", loss_cispo), (\"is\", loss_isPG)):\n",
76
+ " t = th.clone().requires_grad_(True)\n",
77
+ " g_auto = torch.autograd.grad(lossf(t), t)[0]\n",
78
+ " g_w = -grad_from_weights(th, weights(th, name))\n",
79
+ " check(f\"{name}: autograd of the loss == sum_t w(r,A) A grad log pi (weights table)\", maxabs(g_auto, g_w), 1e-13)"
80
+ ],
81
+ "outputs": [
82
+ {
83
+ "output_type": "stream",
84
+ "name": "stdout",
85
+ "text": [
86
+ "ratio range over (o,t): min 0.455 max 1.992\n",
87
+ "CHECK|exact|ppo: autograd of the loss == sum_t w(r,A) A grad log pi (weights table)|1.388e-17|tol=1.0e-13|PASS\n",
88
+ "CHECK|exact|cispo: autograd of the loss == sum_t w(r,A) A grad log pi (weights table)|0.000e+00|tol=1.0e-13|PASS\n",
89
+ "CHECK|exact|is: autograd of the loss == sum_t w(r,A) A grad log pi (weights table)|0.000e+00|tol=1.0e-13|PASS\n"
90
+ ]
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "cell_type": "markdown",
96
+ "metadata": {},
97
+ "source": [
98
+ "## 2. Exactly which tokens does PPO/GRPO silence and CISPO keep?"
99
+ ]
100
+ },
101
+ {
102
+ "cell_type": "code",
103
+ "execution_count": 3,
104
+ "metadata": {},
105
+ "source": [
106
+ "live = ((A_tok > 0) & (r_now < 1 + eh)) | ((A_tok < 0) & (r_now > 1 - el))\n",
107
+ "sel = lm.mask > 0\n",
108
+ "pw = q[:, None] * lm.mask # probability weight of each (o,t) token occurrence\n",
109
+ "tot = float(pw.sum())\n",
110
+ "zeroed = sel & ~live\n",
111
+ "cells = {\"A>0, r>1+eh (silenced by PPO)\": sel & (A_tok > 0) & (r_now > 1 + eh),\n",
112
+ " \"A<0, r<1-el (silenced by PPO)\": sel & (A_tok < 0) & (r_now < 1 - el),\n",
113
+ " \"A<0, r>1+eh (PPO weight r unbounded; CISPO caps at 1+eh)\": sel & (A_tok < 0) & (r_now > 1 + eh)}\n",
114
+ "for k, m in cells.items(): print(f\" {k:60s}: {int(m.sum()):4d} token slots, probability mass {float((pw*m).sum())/tot:.4f}\")\n",
115
+ "Wc = weights(th, 'cispo'); Wp = weights(th, 'ppo')\n",
116
+ "silenced_by_ppo_kept_by_cispo = sel & (Wp == 0) & (Wc > 0)\n",
117
+ "check(\"{tokens with PPO weight 0 and CISPO weight>0} == cells 1 U 2 exactly (symmetric difference size)\", float((silenced_by_ppo_kept_by_cispo ^ (cells[\"A>0, r>1+eh (silenced by PPO)\"] | cells[\"A<0, r<1-el (silenced by PPO)\"])).sum()), 0.0)\n",
118
+ "check(\"CISPO never silences a token (min weight over all (o,t) with A != 0)\", abs(float(Wc[sel].min()) - float(r_now[sel].min())) if float(r_now[sel].min()) < 1 + eh else 0.0, 1e-15)\n",
119
+ "check(\"PPO silenced set == the MiniMax-M1 mask M_{i,t}=0 of Eq. 7 (symmetric difference size)\",\n",
120
+ " float((zeroed ^ (((A_tok > 0) & (r_now > 1 + eh)) | ((A_tok < 0) & (r_now < 1 - el))) & sel).sum()), 0.0)\n",
121
+ "# weight table\n",
122
+ "print(\"\\n gradient weight w(r,A) (loss gradient = w * A * grad log pi), eps_hi=0.28, eps_lo=0.2\")\n",
123
+ "print(\" r PPO(A>0) PPO(A<0) CISPO unclipped-IS\")\n",
124
+ "for r in (0.3, 0.8, 1.0, 1.28, 2.0, 5.0, 20.0):\n",
125
+ " wp_pos = r if r < 1 + eh else 0.0; wp_neg = r if r > 1 - el else 0.0\n",
126
+ " print(f\" {r:5.2f} {wp_pos:7.2f} {wp_neg:7.2f} {min(r, 1+eh):6.2f} {r:7.2f}\")"
127
+ ],
128
+ "outputs": [
129
+ {
130
+ "output_type": "stream",
131
+ "name": "stdout",
132
+ "text": [
133
+ " A>0, r>1+eh (silenced by PPO) : 7 token slots, probability mass 0.0134\n",
134
+ " A<0, r<1-el (silenced by PPO) : 63 token slots, probability mass 0.0551\n",
135
+ " A<0, r>1+eh (PPO weight r unbounded; CISPO caps at 1+eh) : 83 token slots, probability mass 0.0160\n",
136
+ "CHECK|exact|{tokens with PPO weight 0 and CISPO weight>0} == cells 1 U 2 exactly (symmetric difference size)|0.000e+00|tol=0.0e+00|PASS\n",
137
+ "CHECK|exact|CISPO never silences a token (min weight over all (o,t) with A != 0)|0.000e+00|tol=1.0e-15|PASS\n",
138
+ "CHECK|exact|PPO silenced set == the MiniMax-M1 mask M_{i,t}=0 of Eq. 7 (symmetric difference size)|0.000e+00|tol=0.0e+00|PASS\n",
139
+ "\n",
140
+ " gradient weight w(r,A) (loss gradient = w * A * grad log pi), eps_hi=0.28, eps_lo=0.2\n",
141
+ " r PPO(A>0) PPO(A<0) CISPO unclipped-IS\n",
142
+ " 0.30 0.30 0.00 0.30 0.30\n",
143
+ " 0.80 0.80 0.00 0.80 0.80\n",
144
+ " 1.00 1.00 1.00 1.00 1.00\n",
145
+ " 1.28 0.00 1.28 1.28 1.28\n",
146
+ " 2.00 0.00 2.00 1.28 2.00\n",
147
+ " 5.00 0.00 5.00 1.28 5.00\n",
148
+ " 20.00 0.00 20.00 1.28 20.00\n"
149
+ ]
150
+ }
151
+ ]
152
+ },
153
+ {
154
+ "cell_type": "markdown",
155
+ "metadata": {},
156
+ "source": [
157
+ "## 3. Unified mask formulation (paper Eq. 6-7): J = sg(r_hat) A log pi * M ; with r_hat = r it reproduces PPO's gradient"
158
+ ]
159
+ },
160
+ {
161
+ "cell_type": "code",
162
+ "execution_count": 4,
163
+ "metadata": {},
164
+ "source": [
165
+ "def loss_unify(th, rhat_clip=None):\n",
166
+ " r, lp = ratio_and_lp(th)\n",
167
+ " M = (~(((A_tok > 0) & (r > 1 + eh)) | ((A_tok < 0) & (r < 1 - el)))).double()\n",
168
+ " rhat = r if rhat_clip is None else torch.clamp(r, max=rhat_clip)\n",
169
+ " return -(q[:, None] * rhat.detach() * A_tok * lp * M * lm.mask).sum()\n",
170
+ "t = th.clone().requires_grad_(True); g_u = torch.autograd.grad(loss_unify(t), t)[0]\n",
171
+ "t = th.clone().requires_grad_(True); g_p = torch.autograd.grad(loss_ppo(t), t)[0]\n",
172
+ "check(\"unified formulation with r_hat=r and PPO mask == PPO gradient\", maxabs(g_u, g_p), 1e-13)\n",
173
+ "# without clipping CISPO reduces to the standard (token-level IS) policy gradient; at theta_old it is the on-policy gradient\n",
174
+ "t = th.clone().requires_grad_(True); g_c_inf = torch.autograd.grad(loss_cispo(t, eps_hi=1e9), t)[0]\n",
175
+ "t = th.clone().requires_grad_(True); g_is = torch.autograd.grad(loss_isPG(t), t)[0]\n",
176
+ "check(\"CISPO with eps_hi=inf == token-level IS policy gradient\", maxabs(g_c_inf, g_is), 1e-14)\n",
177
+ "t = th_old.clone().requires_grad_(True); g_c0 = torch.autograd.grad(loss_cispo(t), t)[0]\n",
178
+ "t2 = th_old.clone().requires_grad_(True); gJ = torch.autograd.grad((lm.probs(t2) * R).sum(), t2)[0]\n",
179
+ "check(\"at theta_old CISPO == exact policy gradient grad J (r=1, nothing clipped)\", maxabs(-g_c0, gJ), 1e-14)"
180
+ ],
181
+ "outputs": [
182
+ {
183
+ "output_type": "stream",
184
+ "name": "stdout",
185
+ "text": [
186
+ "CHECK|exact|unified formulation with r_hat=r and PPO mask == PPO gradient|1.388e-17|tol=1.0e-13|PASS\n",
187
+ "CHECK|exact|CISPO with eps_hi=inf == token-level IS policy gradient|0.000e+00|tol=1.0e-14|PASS\n",
188
+ "CHECK|exact|at theta_old CISPO == exact policy gradient grad J (r=1, nothing clipped)|4.163e-17|tol=1.0e-14|PASS\n"
189
+ ]
190
+ }
191
+ ]
192
+ },
193
+ {
194
+ "cell_type": "markdown",
195
+ "metadata": {},
196
+ "source": [
197
+ "## 4. Bias of CISPO relative to the (token-level) IS gradient, and the bound ||bias|| <= sum_t E[ (r - 1 - eps)_+ |A| ||score|| ]"
198
+ ]
199
+ },
200
+ {
201
+ "cell_type": "code",
202
+ "execution_count": 5,
203
+ "metadata": {},
204
+ "source": [
205
+ "def cispo_bias(th, eps):\n",
206
+ " r, _ = ratio_and_lp(th); r = r.detach()\n",
207
+ " Tth = token_score_tensor(lm, th) # score at theta\n",
208
+ " diff = (torch.clamp(r, max=1 + eps) - r) # r_hat - r (<= 0)\n",
209
+ " return (q[:, None, None] * (diff * A_tok)[:, :, None] * Tth).sum((0, 1)), Tth\n",
210
+ "for eps_ in (0.05, 0.1, 0.28):\n",
211
+ " bias, Tth = cispo_bias(th, eps_)\n",
212
+ " r, _ = ratio_and_lp(th)\n",
213
+ " bound = float((q[:, None] * (r - 1 - eps_).clamp(min=0) * A_tok.abs() * Tth.norm(dim=2) * lm.mask).sum())\n",
214
+ " print(f\" eps_hi={eps_:4.2f}: ||bias|| = {float(bias.norm()):.4e} bound = {bound:.4e} ||token-IS gradient|| = {float((q[:,None,None]*(r*A_tok)[:,:,None]*Tth).sum((0,1)).norm()):.4e}\")\n",
215
+ " assert float(bias.norm()) <= bound + 1e-15\n",
216
+ "check(\"bias bound holds (3 values of eps_hi)\", 0.0, 0.0)\n",
217
+ "# for comparison: how far each method's gradient is from the token-level IS gradient\n",
218
+ "t_ = th.clone().requires_grad_(True); gI = torch.autograd.grad(loss_isPG(t_), t_)[0]\n",
219
+ "t_ = th.clone().requires_grad_(True); gP = torch.autograd.grad(loss_ppo(t_), t_)[0]\n",
220
+ "t_ = th.clone().requires_grad_(True); gC = torch.autograd.grad(loss_cispo(t_), t_)[0]\n",
221
+ "print(f\" ||g_PPO - g_IS|| = {float((gP-gI).norm()):.4e} ||g_CISPO - g_IS|| = {float((gC-gI).norm()):.4e} (eps_hi=0.28, eps_lo=0.2; ||g_IS|| = {float(gI.norm()):.4e})\")\n",
222
+ "# distribution-free per-state bound E_old[(r-1-eps)_+] = sum_a (pi - (1+eps) pi_old)_+ <= TV(pi, pi_old)\n",
223
+ "worst = 0.0\n",
224
+ "for s in lm.prefixes():\n",
225
+ " po, pn = lm.cond_probs(th_old, s), lm.cond_probs(th, s)\n",
226
+ " lhs = float(((pn - 1.28 * po).clamp(min=0)).sum()); tv = 0.5 * float((pn - po).abs().sum())\n",
227
+ " worst = max(worst, lhs - tv)\n",
228
+ "check(\"per-state E_old[(r-1-eps)_+] <= TV(pi,pi_old) (max violation, must be <= 0)\", max(0.0, worst), 0.0)\n",
229
+ "# variance: gradient weights are bounded for CISPO, unbounded for PPO's live A<0 tokens\n",
230
+ "r, _ = ratio_and_lp(th)\n",
231
+ "print(f\" max gradient weight over (o,t): PPO {float(weights(th,'ppo').max()):.3f} CISPO {float(weights(th,'cispo').max()):.3f} unclipped IS {float(weights(th,'is').max()):.3f}\")\n",
232
+ "assert float(weights(th, 'cispo').max()) <= 1 + eh + 1e-15\n",
233
+ "check(\"CISPO weights bounded by 1+eps_hi\", max(0.0, float(weights(th, 'cispo').max()) - (1 + eh)), 0.0)"
234
+ ],
235
+ "outputs": [
236
+ {
237
+ "output_type": "stream",
238
+ "name": "stdout",
239
+ "text": [
240
+ " eps_hi=0.05: ||bias|| = 1.3732e-02 bound = 3.7511e-02 ||token-IS gradient|| = 1.7479e-01\n",
241
+ " eps_hi=0.10: ||bias|| = 9.2089e-03 bound = 2.3875e-02 ||token-IS gradient|| = 1.7479e-01\n",
242
+ " eps_hi=0.28: ||bias|| = 1.0099e-03 bound = 4.1370e-03 ||token-IS gradient|| = 1.7479e-01\n",
243
+ "CHECK|exact|bias bound holds (3 values of eps_hi)|0.000e+00|tol=0.0e+00|PASS\n",
244
+ " ||g_PPO - g_IS|| = 2.2235e-02 ||g_CISPO - g_IS|| = 1.0099e-03 (eps_hi=0.28, eps_lo=0.2; ||g_IS|| = 1.7479e-01)\n",
245
+ "CHECK|exact|per-state E_old[(r-1-eps)_+] <= TV(pi,pi_old) (max violation, must be <= 0)|0.000e+00|tol=0.0e+00|PASS\n",
246
+ " max gradient weight over (o,t): PPO 1.992 CISPO 1.280 unclipped IS 1.992\n",
247
+ "CHECK|exact|CISPO weights bounded by 1+eps_hi|0.000e+00|tol=0.0e+00|PASS\n"
248
+ ]
249
+ }
250
+ ]
251
+ },
252
+ {
253
+ "cell_type": "markdown",
254
+ "metadata": {},
255
+ "source": [
256
+ "## 5. Stop-gradient semantics: three look-alike implementations, three different gradients\n",
257
+ " (a) correct: loss = - sg(min(r,1+eh)) * A * log pi -> grad = -A*min(r,1+eh) * grad log pi\n",
258
+ " (b) forgot sg: loss = - min(r,1+eh) * A * log pi -> on the UNclipped set grad = -A * r * (1 + log pi) * grad log pi : an extra A r log pi term\n",
259
+ " (c) ratio form: loss = - min(r,1+eh) * A (PPO-like) -> grad = -A * r * 1[r<1+eh] * grad log pi (zero beyond the clip!)"
260
+ ]
261
+ },
262
+ {
263
+ "cell_type": "code",
264
+ "execution_count": 6,
265
+ "metadata": {},
266
+ "source": [
267
+ "lp_old = torch.tensor(-2.3)\n",
268
+ "def three(lp_new, A, eps=0.28):\n",
269
+ " lp = torch.tensor(lp_new, requires_grad=True); r = (lp - lp_old).exp()\n",
270
+ " ga = torch.autograd.grad(-(torch.clamp(r, max=1 + eps).detach() * A * lp), lp, retain_graph=True)[0]\n",
271
+ " gb = torch.autograd.grad(-(torch.clamp(r, max=1 + eps) * A * lp), lp, retain_graph=True)[0]\n",
272
+ " gc = torch.autograd.grad(-(torch.clamp(r, max=1 + eps) * A), lp)[0]\n",
273
+ " return float(ga), float(gb), float(gc), float(r.detach())\n",
274
+ "print(\" lp_old=-2.3; gradient wrt log pi_theta\")\n",
275
+ "print(\" lp_new r A (a) CISPO (b) no sg (c) ratio-clip\")\n",
276
+ "for lpn, A in ((-2.3, 1.0), (-1.5, 1.0), (-1.5, -1.0), (-3.0, 1.0), (-0.5, 1.0), (-0.5, -1.0)):\n",
277
+ " ga, gb, gc, rr = three(lpn, A)\n",
278
+ " print(f\" {lpn:6.2f} {rr:6.2f} {A:+.0f} {ga:+9.3f} {gb:+9.3f} {gc:+9.3f}\")\n",
279
+ " expect_a = -A * min(rr, 1.28)\n",
280
+ " assert abs(ga - expect_a) < 1e-12\n",
281
+ " expect_c = -A * rr * (rr < 1.28)\n",
282
+ " assert abs(gc - expect_c) < 1e-12\n",
283
+ " expect_b = -A * rr * (1 + lpn) if rr < 1.28 else -A * 1.28 # clamp kills the extra term beyond the clip\n",
284
+ " assert abs(gb - expect_b) < 1e-12\n",
285
+ "check(\"(a) = -A min(r,1+eh); (b) = -A r (1+log pi) on the live set; (c) = -A r 1[r<1+eh] (6 test points, exact)\", 0.0, 0.0)\n",
286
+ "ga, gb, gc, rr = three(-2.3, 1.0)\n",
287
+ "print(f\" at r=1 (inside the clip), A=+1, log pi=-2.3: (a)={ga:+.3f} (b)={gb:+.3f} <- sign flipped by the spurious log-pi factor (1+log pi = -1.3)\")\n",
288
+ "assert ga < 0 < gb\n",
289
+ "check(\"without sg a live token's gradient weight becomes r(1+log pi): wrong sign when pi < 1/e\", 0.0, 0.0)\n",
290
+ "ga, gb, gc, rr = three(-1.5, 1.0)\n",
291
+ "print(f\" at r={rr:.2f} > 1+eps: (a)={ga:+.3f} capped weight kept; (b)={gb:+.3f} identical (clamp zeroes the extra term); (c)={gc:+.3f} silenced\")"
292
+ ],
293
+ "outputs": [
294
+ {
295
+ "output_type": "stream",
296
+ "name": "stdout",
297
+ "text": [
298
+ " lp_old=-2.3; gradient wrt log pi_theta\n",
299
+ " lp_new r A (a) CISPO (b) no sg (c) ratio-clip\n",
300
+ " -2.30 1.00 +1 -1.000 +1.300 -1.000\n",
301
+ " -1.50 2.23 +1 -1.280 -1.280 +0.000\n",
302
+ " -1.50 2.23 -1 +1.280 +1.280 +0.000\n",
303
+ " -3.00 0.50 +1 -0.497 +0.993 -0.497\n",
304
+ " -0.50 6.05 +1 -1.280 -1.280 +0.000\n",
305
+ " -0.50 6.05 -1 +1.280 +1.280 +0.000\n",
306
+ "CHECK|exact|(a) = -A min(r,1+eh); (b) = -A r (1+log pi) on the live set; (c) = -A r 1[r<1+eh] (6 test points, exact)|0.000e+00|tol=0.0e+00|PASS\n",
307
+ " at r=1 (inside the clip), A=+1, log pi=-2.3: (a)=-1.000 (b)=+1.300 <- sign flipped by the spurious log-pi factor (1+log pi = -1.3)\n",
308
+ "CHECK|exact|without sg a live token's gradient weight becomes r(1+log pi): wrong sign when pi < 1/e|0.000e+00|tol=0.0e+00|PASS\n",
309
+ " at r=2.23 > 1+eps: (a)=-1.280 capped weight kept; (b)=-1.280 identical (clamp zeroes the extra term); (c)=+0.000 silenced\n"
310
+ ]
311
+ }
312
+ ]
313
+ },
314
+ {
315
+ "cell_type": "markdown",
316
+ "metadata": {},
317
+ "source": [
318
+ "## 6. The 'fork token' mechanism over K = 16 off-policy updates on one batch (MiniMax-M1 setting)\n",
319
+ "One decision with three tokens: two common tokens and a RARE token w (pi_old = 0.01) with positive advantage.\n",
320
+ "Exact expectation over the old distribution; plain SGD on the logits; K = 16 inner steps."
321
+ ]
322
+ },
323
+ {
324
+ "cell_type": "code",
325
+ "execution_count": 7,
326
+ "metadata": {},
327
+ "source": [
328
+ "def fork(method, K=16, lr=30.0, eh_=0.28, el_=0.2, p_w=0.01):\n",
329
+ " pi_old = torch.tensor([0.60, 0.39 - 0.0, p_w]); pi_old = pi_old / pi_old.sum()\n",
330
+ " reward = torch.tensor([0.0, 0.0, 1.0]); A = reward - float(pi_old @ reward) # A_w > 0, A_common < 0\n",
331
+ " z = pi_old.log().clone().requires_grad_(True)\n",
332
+ " traj, gw = [], []\n",
333
+ " for k in range(K):\n",
334
+ " pi = torch.softmax(z, 0); r = (pi / pi_old)\n",
335
+ " if method == 'ppo':\n",
336
+ " f = torch.where(A > 0, A * torch.clamp(r, max=1 + eh_), A * torch.clamp(r, min=1 - el_))\n",
337
+ " obj = (pi_old * f).sum()\n",
338
+ " else:\n",
339
+ " rhat = torch.clamp(r, max=1 + eh_).detach()\n",
340
+ " obj = (pi_old * rhat * A * pi.log()).sum()\n",
341
+ " g = torch.autograd.grad(obj, z)[0]\n",
342
+ " # direct contribution of token w's OWN term to the gradient on its own logit (diagnostic)\n",
343
+ " rd = r.detach(); wt = float((1 + eh_) if (method == 'cispo' and rd[2] > 1 + eh_) else (rd[2] if (method == 'cispo' or rd[2] < 1 + eh_) else 0.0))\n",
344
+ " gw.append(wt)\n",
345
+ " with torch.no_grad(): z += lr * g\n",
346
+ " traj.append(float(torch.softmax(z.detach(), 0)[2]))\n",
347
+ " return traj, gw, float(r[2].detach())\n",
348
+ "tp, gwp, rp = fork('ppo'); tc, gwc, rc = fork('cispo')\n",
349
+ "print(\" after update #\" + \" \".join(f\"{k+1:6d}\" for k in range(0, 16, 3)))\n",
350
+ "print(\" p_w PPO: \" + \" \".join(f\"{tp[k]:6.3f}\" for k in range(0, 16, 3)))\n",
351
+ "print(\" p_w CISPO: \" + \" \".join(f\"{tc[k]:6.3f}\" for k in range(0, 16, 3)))\n",
352
+ "print(\" weight on w's own term PPO: \" + \" \".join(f\"{gwp[k]:6.2f}\" for k in range(0, 16, 3)))\n",
353
+ "print(\" weight on w's own term CISPO: \" + \" \".join(f\"{gwc[k]:6.2f}\" for k in range(0, 16, 3)))\n",
354
+ "n_zero_ppo = sum(1 for x in gwp if x == 0.0)\n",
355
+ "print(f\" PPO: token w gets zero direct gradient in {n_zero_ppo} of 16 inner steps; CISPO: {sum(1 for x in gwc if x==0.0)} of 16\")\n",
356
+ "assert n_zero_ppo >= 8 and all(x > 0 for x in gwc)\n",
357
+ "check(\"PPO silences the rare token for >= 8 of 16 inner steps, CISPO for none\", 0.0, 0.0)\n",
358
+ "print(f\" final p_w after 16 updates: PPO {tp[-1]:.3f} CISPO {tc[-1]:.3f}\")"
359
+ ],
360
+ "outputs": [
361
+ {
362
+ "output_type": "stream",
363
+ "name": "stdout",
364
+ "text": [
365
+ " after update # 1 4 7 10 13 16\n",
366
+ " p_w PPO: 0.016 0.016 0.016 0.017 0.017 0.017\n",
367
+ " p_w CISPO: 0.016 0.081 0.310 0.624 0.790 0.862\n",
368
+ " weight on w's own term PPO: 1.00 0.00 0.00 0.00 0.00 0.00\n",
369
+ " weight on w's own term CISPO: 1.00 1.28 1.28 1.28 1.28 1.28\n",
370
+ " PPO: token w gets zero direct gradient in 15 of 16 inner steps; CISPO: 0 of 16\n",
371
+ "CHECK|exact|PPO silences the rare token for >= 8 of 16 inner steps, CISPO for none|0.000e+00|tol=0.0e+00|PASS\n",
372
+ " final p_w after 16 updates: PPO 0.017 CISPO 0.862\n"
373
+ ]
374
+ }
375
+ ]
376
+ },
377
+ {
378
+ "cell_type": "markdown",
379
+ "metadata": {},
380
+ "source": [
381
+ "## 7. Naive vs efficient implementation of the CISPO loss, token-level normalisation by total tokens"
382
+ ]
383
+ },
384
+ {
385
+ "cell_type": "code",
386
+ "execution_count": 8,
387
+ "metadata": {},
388
+ "source": [
389
+ "Gs = 8\n",
390
+ "gen = torch.Generator().manual_seed(5)\n",
391
+ "idx = torch.multinomial(q, Gs, replacement=True, generator=gen)\n",
392
+ "Rg = R[idx]; Ag = Rg - Rg.mean()\n",
393
+ "t = perturb(th_old, 0.8, seed=2).clone().requires_grad_(True)\n",
394
+ "lp = lm.token_logprobs(t)[idx]; lq_ = lq[idx]; mk = lm.mask[idx]\n",
395
+ "def cispo_naive(lp, lq_):\n",
396
+ " tot, n = 0.0, 0\n",
397
+ " for i in range(Gs):\n",
398
+ " for tt in range(int(mk[i].sum())):\n",
399
+ " r = math.exp(float((lp[i, tt] - lq_[i, tt]).detach()))\n",
400
+ " tot = tot + min(r, 1 + eh) * Ag[i] * lp[i, tt]; n += 1\n",
401
+ " return -tot / n\n",
402
+ "def cispo_vec(lp, lq_):\n",
403
+ " r = (lp - lq_).exp().detach()\n",
404
+ " return -((torch.clamp(r, max=1 + eh) * Ag[:, None] * lp * mk).sum() / mk.sum())\n",
405
+ "gn = torch.autograd.grad(cispo_naive(lp, lq_), t, retain_graph=True)[0]\n",
406
+ "gv = torch.autograd.grad(cispo_vec(lp, lq_), t, retain_graph=True)[0]\n",
407
+ "check(\"naive per-token loop == vectorised masked CISPO loss (gradient)\", maxabs(gn, gv), 1e-14)"
408
+ ],
409
+ "outputs": [
410
+ {
411
+ "output_type": "stream",
412
+ "name": "stdout",
413
+ "text": [
414
+ "CHECK|exact|naive per-token loop == vectorised masked CISPO loss (gradient)|0.000e+00|tol=1.0e-14|PASS\n"
415
+ ]
416
+ }
417
+ ]
418
+ },
419
+ {
420
+ "cell_type": "markdown",
421
+ "metadata": {},
422
+ "source": [
423
+ "## 8. Leanstral variant (Mistral technical report, Sec. 2.3): sampler ratio, masking of tool/prompt tokens, outcome broadcast\n",
424
+ "Loss there: `L = -(1/sum_i|o_i|) sum_i sum_t sg(min(pi_theta/pi_sample, c)) * A_i * log pi_theta(a_it | s_it, T_i)`, `T_i` = tool calls and responses used as context only,\n",
425
+ "one verifier outcome for every model token. Toy: two-turn tabular agent (4 actions per turn), turn-2 context = turn-1 action, feedback token = a fixed function of turn 1 (not scored).\n",
426
+ "`R ~ Bern(T[y1, y2])`. Everything below is enumerated exactly over the 32 episodes (y1, y2, R)."
427
+ ]
428
+ },
429
+ {
430
+ "cell_type": "code",
431
+ "execution_count": 9,
432
+ "metadata": {},
433
+ "source": [
434
+ "import numpy as _np\n",
435
+ "_rng = _np.random.default_rng(123)\n",
436
+ "_A = 4\n",
437
+ "_T = _rng.uniform(0.05, 0.95, (_A, _A))\n",
438
+ "def _sm(x): e = _np.exp(x - x.max(-1, keepdims=True)); return e / e.sum(-1, keepdims=True)\n",
439
+ "def _unpack(th): return th[:_A], th[_A:].reshape(_A, _A)\n",
440
+ "th_cur = _rng.normal(0, 0.8, _A + _A * _A)\n",
441
+ "th_smp = th_cur + _rng.normal(0, 0.35, _A + _A * _A) # sampler = lagged + numerically perturbed copy\n",
442
+ "def _probs(th): a, b = _unpack(th); return _sm(a), _sm(b)\n",
443
+ "def _J(th): p1, p2 = _probs(th); return float((p1[:, None] * p2 * _T).sum())\n",
444
+ "def _score(th, y1, y2): # (s1, s2): grads of log pi(y1), log pi(y2|y1) wrt the 20 logits\n",
445
+ " p1, p2 = _probs(th); s1 = _np.zeros(_A + _A * _A); s2 = _np.zeros(_A + _A * _A)\n",
446
+ " s1[:_A] = _np.eye(_A)[y1] - p1\n",
447
+ " blk = _np.eye(_A)[y2] - p2[y1]; s2[_A + y1 * _A: _A + (y1 + 1) * _A] = blk\n",
448
+ " return s1, s2\n",
449
+ "def _episodes(th_sample): # list of (prob under sampler, y1, y2, R)\n",
450
+ " p1, p2 = _probs(th_sample); out = []\n",
451
+ " for y1 in range(_A):\n",
452
+ " for y2 in range(_A):\n",
453
+ " for r in (0, 1):\n",
454
+ " out.append((p1[y1] * p2[y1, y2] * (_T[y1, y2] if r else 1 - _T[y1, y2]), y1, y2, r))\n",
455
+ " return out\n",
456
+ "def _fd_grad(th, h=1e-6):\n",
457
+ " g = _np.zeros_like(th)\n",
458
+ " for i in range(len(th)):\n",
459
+ " e = _np.zeros_like(th); e[i] = h; g[i] = (_J(th + e) - _J(th - e)) / (2 * h)\n",
460
+ " return g\n",
461
+ "g_true = _fd_grad(th_cur)\n",
462
+ "pc1, pc2 = _probs(th_cur); ps1, ps2 = _probs(th_smp)\n",
463
+ "def _rho(y1, y2): return pc1[y1] / ps1[y1], pc2[y1, y2] / ps2[y1, y2]\n",
464
+ "# (a) on-policy score-function identity\n",
465
+ "g_on = sum(p * r * sum(_score(th_cur, y1, y2)) for p, y1, y2, r in _episodes(th_cur))\n",
466
+ "check(\"8a. E_pi[R (s1+s2)] = finite-difference grad J\", float(_np.abs(g_on - g_true).max()), 1e-8)\n",
467
+ "# (b) sampler episodes, full sequence ratio rho1*rho2: exact\n",
468
+ "g_seq = sum(p * _rho(y1, y2)[0] * _rho(y1, y2)[1] * r * sum(_score(th_cur, y1, y2)) for p, y1, y2, r in _episodes(th_smp))\n",
469
+ "check(\"8b. E_mu[rho1 rho2 R (s1+s2)] = grad J exactly (sampler ratio, full sequence weight)\", float(_np.abs(g_seq - g_true).max()), 1e-9)\n",
470
+ "# (c) token-level ratios only (what the objective uses): biased by the dropped prefix ratio; vanishes when sampler = policy\n",
471
+ "def _tok_grad(th_sample, clip=None, baseline=0.0):\n",
472
+ " g = 0\n",
473
+ " for p, y1, y2, r in _episodes(th_sample):\n",
474
+ " q1, q2 = _rho(y1, y2) if th_sample is th_smp else (1.0, 1.0)\n",
475
+ " if clip is not None: q1, q2 = min(q1, clip), min(q2, clip)\n",
476
+ " s1, s2 = _score(th_cur, y1, y2); g = g + p * (r - baseline) * (q1 * s1 + q2 * s2)\n",
477
+ " return g\n",
478
+ "g_tok = _tok_grad(th_smp)\n",
479
+ "bias_tok = float(_np.linalg.norm(g_tok - g_true)); gn = float(_np.linalg.norm(g_true))\n",
480
+ "print(f\" token-level sampler ratio, unclipped: ||bias|| = {bias_tok:.3e} (||grad J|| = {gn:.3e})\")\n",
481
+ "check(\"8c. token-level weights are biased for a mismatched sampler (> 1e-4)\", 0.0 if bias_tok > 1e-4 else 1.0, 0.0)\n",
482
+ "th_save = th_smp; th_smp = th_cur.copy() # sampler == policy: weights are 1\n",
483
+ "check(\"8c. ... and exact when the sampler equals the policy\", float(_np.abs(sum(p * r * sum(_score(th_cur, y1, y2)) for p, y1, y2, r in _episodes(th_cur)) - g_true).max()), 1e-8)\n",
484
+ "th_smp = th_save\n",
485
+ "# (d) a constant baseline does not bias the token-level estimator, even under mismatch (E_mu[rho_t s_t | context] = 0)\n",
486
+ "b0 = _J(th_cur)\n",
487
+ "g_b = _tok_grad(th_smp, baseline=b0)\n",
488
+ "db = _np.abs(g_tok - g_b - 0).max()\n",
489
+ "check(\"8d. constant baseline b adds exactly 0 to the token-level sampler-ratio gradient: max |E_mu[sum_t rho_t b s_t]| over 20 coordinates\", float(db), 1e-14)\n",
490
+ "# (e) clip bias bound with the sampler as the base measure: sum_a (pi - c mu)_+ <= TV(pi, mu) at every state\n",
491
+ "cc = 1.28\n",
492
+ "sts = [(pc1, ps1)] + [(pc2[y], ps2[y]) for y in range(_A)]\n",
493
+ "viol = max(float(_np.maximum(a - cc * b, 0).sum() - 0.5 * _np.abs(a - b).sum()) for a, b in sts)\n",
494
+ "check(\"8e. sum_a (pi - c mu)_+ <= TV(pi, mu) at all 5 states (c=1.28)\", max(viol, 0.0), 0.0)\n",
495
+ "g_clip = _tok_grad(th_smp, clip=cc)\n",
496
+ "bound = 0.0\n",
497
+ "for p, y1, y2, r in _episodes(th_smp):\n",
498
+ " q1, q2 = _rho(y1, y2); s1, s2 = _score(th_cur, y1, y2)\n",
499
+ " bound += p * abs(r) * (max(q1 - cc, 0) * _np.linalg.norm(s1) + max(q2 - cc, 0) * _np.linalg.norm(s2))\n",
500
+ "bias_clip = float(_np.linalg.norm(g_clip - g_tok))\n",
501
+ "print(f\" clip c={cc}: ||g_clip - g_token-IS|| = {bias_clip:.3e} <= bound {bound:.3e}\")\n",
502
+ "check(\"8e. clipping bias <= E_mu[sum_t (rho_t - c)_+ |A| ||s_t||]\", max(bias_clip - bound, 0.0), 0.0)\n",
503
+ "# (f) outcome broadcast vs per-turn credit (on-policy, oracle baselines): both unbiased, different variance\n",
504
+ "eps_ = _episodes(th_cur)\n",
505
+ "V = sum(p * r for p, y1, y2, r in eps_)\n",
506
+ "Q = _np.array([sum(pc2[y1, y2] * _T[y1, y2] for y2 in range(_A)) for y1 in range(_A)])\n",
507
+ "def _moment(coef1, coef2):\n",
508
+ " m1 = 0; m2 = 0\n",
509
+ " for p, y1, y2, r in eps_:\n",
510
+ " s1, s2 = _score(th_cur, y1, y2); g = coef1(y1, y2, r) * s1 + coef2(y1, y2, r) * s2\n",
511
+ " m1 = m1 + p * g; m2 += p * float(g @ g)\n",
512
+ " return m1, m2\n",
513
+ "gb_mean, gb_m2 = _moment(lambda y1, y2, r: r - V, lambda y1, y2, r: r - V)\n",
514
+ "gt_mean, gt_m2 = _moment(lambda y1, y2, r: Q[y1] - V, lambda y1, y2, r: r - Q[y1])\n",
515
+ "check(\"8f. broadcast estimator (R-V)(s1+s2) is unbiased for grad J\", float(_np.abs(gb_mean - g_true).max()), 1e-8)\n",
516
+ "check(\"8f. per-turn estimator (Q-V) s1 + (R-Q) s2 is unbiased for grad J\", float(_np.abs(gt_mean - g_true).max()), 1e-8)\n",
517
+ "v_b = gb_m2 - float(gb_mean @ gb_mean); v_t = gt_m2 - float(gt_mean @ gt_mean)\n",
518
+ "print(f\" total variance (trace): broadcast {v_b:.4f}, per-turn {v_t:.4f}, ratio {v_b/v_t:.3f}\")\n",
519
+ "# turn-1 decomposition: E[(R-V)^2 ||s1||^2] = E[(Q-V)^2 ||s1||^2] + E[Var(R|y1) ||s1||^2]\n",
520
+ "lhs = sum(p * (r - V) ** 2 * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[0]) for p, y1, y2, r in eps_)\n",
521
+ "t1 = sum(p * (Q[y1] - V) ** 2 * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[0]) for p, y1, y2, r in eps_)\n",
522
+ "varR = _np.array([sum(pc2[y1, y2] * (_T[y1, y2] + 0) for y2 in range(_A)) - Q[y1] ** 2 for y1 in range(_A)]) # Var(R|y1) = E[R^2|y1] - Q^2 (R binary: E R^2 = E R)\n",
523
+ "t2 = sum(p * varR[y1] * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[0]) for p, y1, y2, r in eps_)\n",
524
+ "check(\"8f. turn-1 second moment: E[(R-V)^2||s1||^2] = E[(Q-V)^2||s1||^2] + E[Var(R|y1)||s1||^2]\", abs(lhs - t1 - t2), 1e-13)\n",
525
+ "print(f\" turn-1: broadcast {lhs:.4f} = {t1:.4f} (credit) + {t2:.4f} (noise from turn 2)\")\n",
526
+ "check(\"8f. martingale orthogonality: E[s1.s2] = 0 so E||s1+s2||^2 = E||s1||^2 + E||s2||^2\", abs(sum(p * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[1]) for p, y1, y2, r in eps_)), 1e-15)\n",
527
+ "# (g) score second moment grows linearly with the number of policy tokens: L-step context-dependent chain, enumerated\n",
528
+ "Lmax = 5; Th = _rng.normal(0, 0.8, (Lmax, _A, _A)); P0 = _sm(_rng.normal(0, 0.8, _A))\n",
529
+ "def chain_moments(L):\n",
530
+ " tot = 0.0; per = _np.zeros(L); cross = 0.0\n",
531
+ " for seq in _np.ndindex(*([_A] * L)):\n",
532
+ " pr = P0[seq[0]]; sc = []\n",
533
+ " for t_ in range(L):\n",
534
+ " prev = 0 if t_ == 0 else seq[t_ - 1]\n",
535
+ " pt = P0 if t_ == 0 else _sm(Th[t_, prev])\n",
536
+ " if t_ > 0: pr *= pt[seq[t_]]\n",
537
+ " v = _np.zeros(L * _A * _A + _A)\n",
538
+ " v[:_A] = (_np.eye(_A)[seq[0]] - P0) if t_ == 0 else 0\n",
539
+ " if t_ > 0: v[_A + t_ * _A * _A + prev * _A: _A + t_ * _A * _A + (prev + 1) * _A] = _np.eye(_A)[seq[t_]] - pt\n",
540
+ " sc.append(v)\n",
541
+ " S_ = sum(sc); tot += pr * float(S_ @ S_)\n",
542
+ " for t_ in range(L): per[t_] += pr * float(sc[t_] @ sc[t_])\n",
543
+ " return tot, per\n",
544
+ "rows = []\n",
545
+ "for L in range(1, Lmax + 1):\n",
546
+ " tot, per = chain_moments(L); rows.append((L, tot, per.sum()))\n",
547
+ " print(f\" L={L}: E||sum_t s_t||^2 = {tot:.4f} sum_t E||s_t||^2 = {per.sum():.4f}\")\n",
548
+ "check(\"8g. E||sum_t s_t||^2 = sum_t E||s_t||^2 for L = 1..5 (score terms are martingale differences)\", max(abs(a - b) for _, a, b in rows), 1e-12)\n",
549
+ "print(\"done; checks:\", len(CHECKS))"
550
+ ],
551
+ "outputs": [
552
+ {
553
+ "output_type": "stream",
554
+ "name": "stdout",
555
+ "text": [
556
+ "CHECK|exact|8a. E_pi[R (s1+s2)] = finite-difference grad J|2.665e-11|tol=1.0e-08|PASS\n",
557
+ "CHECK|exact|8b. E_mu[rho1 rho2 R (s1+s2)] = grad J exactly (sampler ratio, full sequence weight)|2.665e-11|tol=1.0e-09|PASS\n",
558
+ " token-level sampler ratio, unclipped: ||bias|| = 8.068e-03 (||grad J|| = 1.392e-01)\n",
559
+ "CHECK|exact|8c. token-level weights are biased for a mismatched sampler (> 1e-4)|0.000e+00|tol=0.0e+00|PASS\n",
560
+ "CHECK|exact|8c. ... and exact when the sampler equals the policy|2.665e-11|tol=1.0e-08|PASS\n",
561
+ "CHECK|exact|8d. constant baseline b adds exactly 0 to the token-level sampler-ratio gradient: max |E_mu[sum_t rho_t b s_t]| over 20 coordinates|2.776e-17|tol=1.0e-14|PASS\n",
562
+ "CHECK|exact|8e. sum_a (pi - c mu)_+ <= TV(pi, mu) at all 5 states (c=1.28)|0.000e+00|tol=0.0e+00|PASS\n",
563
+ " clip c=1.28: ||g_clip - g_token-IS|| = 2.023e-02 <= bound 3.210e-02\n",
564
+ "CHECK|exact|8e. clipping bias <= E_mu[sum_t (rho_t - c)_+ |A| ||s_t||]|0.000e+00|tol=0.0e+00|PASS\n",
565
+ "CHECK|exact|8f. broadcast estimator (R-V)(s1+s2) is unbiased for grad J|2.665e-11|tol=1.0e-08|PASS\n",
566
+ "CHECK|exact|8f. per-turn estimator (Q-V) s1 + (R-Q) s2 is unbiased for grad J|2.665e-11|tol=1.0e-08|PASS\n",
567
+ " total variance (trace): broadcast 0.3020, per-turn 0.1473, ratio 2.050\n",
568
+ "CHECK|exact|8f. turn-1 second moment: E[(R-V)^2||s1||^2] = E[(Q-V)^2||s1||^2] + E[Var(R|y1)||s1||^2]|5.551e-17|tol=1.0e-13|PASS\n",
569
+ " turn-1: broadcast 0.1641 = 0.0381 (credit) + 0.1260 (noise from turn 2)\n",
570
+ "CHECK|exact|8f. martingale orthogonality: E[s1.s2] = 0 so E||s1+s2||^2 = E||s1||^2 + E||s2||^2|0.000e+00|tol=1.0e-15|PASS\n",
571
+ " L=1: E||sum_t s_t||^2 = 0.7188 sum_t E||s_t||^2 = 0.7188\n",
572
+ " L=2: E||sum_t s_t||^2 = 1.4115 sum_t E||s_t||^2 = 1.4115\n",
573
+ " L=3: E||sum_t s_t||^2 = 2.0546 sum_t E||s_t||^2 = 2.0546\n",
574
+ " L=4: E||sum_t s_t||^2 = 2.6447 sum_t E||s_t||^2 = 2.6447\n",
575
+ " L=5: E||sum_t s_t||^2 = 3.2724 sum_t E||s_t||^2 = 3.2724\n",
576
+ "CHECK|exact|8g. E||sum_t s_t||^2 = sum_t E||s_t||^2 for L = 1..5 (score terms are martingale differences)|5.329e-15|tol=1.0e-12|PASS\n",
577
+ "done; checks: 28\n"
578
+ ]
579
+ }
580
+ ]
581
+ }
582
+ ],
583
+ "metadata": {
584
+ "kernelspec": {
585
+ "display_name": "Python 3",
586
+ "language": "python",
587
+ "name": "python3"
588
+ },
589
+ "language_info": {
590
+ "name": "python",
591
+ "version": "3.12.11"
592
+ }
593
+ },
594
+ "nbformat": 4,
595
+ "nbformat_minor": 4
596
+ }
code/notebooks/0108-training-inference-mismatch.ipynb ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0108 - Training-inference mismatch: sampler vs trainer log-probs, truncated IS, why an FP32 LM head helps\n",
8
+ "Companion to `notes/0108 - Training-Inference Mismatch.pdf`. fp64 reference (\"trainer\" pi); the \"sampler\" mu uses logits rounded to bf16/fp16/fp32."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "import numpy as np"
20
+ ],
21
+ "outputs": []
22
+ },
23
+ {
24
+ "cell_type": "markdown",
25
+ "metadata": {},
26
+ "source": [
27
+ "## 0. Floating-point facts: unit roundoff u = 2^-(p) with p significant bits; |round(x) - x| <= u |x|\n",
28
+ "bf16: 8 significant bits (u=2^-8), fp16: 11 (u=2^-11), fp32: 24 (u=2^-24)."
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": 2,
34
+ "metadata": {},
35
+ "source": [
36
+ "gen = torch.Generator().manual_seed(0)\n",
37
+ "x = torch.exp(torch.rand(2_000_000, generator=gen) * math.log(1e3 / 1e-3) + math.log(1e-3)) # log-uniform in [1e-3, 1e3]\n",
38
+ "for name, dt, u in ((\"bf16\", torch.bfloat16, 2.0 ** -8), (\"fp16\", torch.float16, 2.0 ** -11), (\"fp32\", torch.float32, 2.0 ** -24)):\n",
39
+ " rel = ((x.to(dt).to(torch.float64) - x).abs() / x)\n",
40
+ " print(f\" {name}: max relative rounding error {float(rel.max()):.3e} u = {u:.3e}\")\n",
41
+ " check(f\"{name}: max rel. rounding error <= u (=2^-{int(-math.log2(u))})\", max(0.0, float(rel.max()) - u), 0.0)\n",
42
+ " assert float(rel.max()) > 0.5 * u"
43
+ ],
44
+ "outputs": [
45
+ {
46
+ "output_type": "stream",
47
+ "name": "stdout",
48
+ "text": [
49
+ " bf16: max relative rounding error 3.891e-03 u = 3.906e-03\n",
50
+ "CHECK|exact|bf16: max rel. rounding error <= u (=2^-8)|0.000e+00|tol=0.0e+00|PASS\n",
51
+ " fp16: max relative rounding error 4.880e-04 u = 4.883e-04\n",
52
+ "CHECK|exact|fp16: max rel. rounding error <= u (=2^-11)|0.000e+00|tol=0.0e+00|PASS\n",
53
+ " fp32: max relative rounding error 5.958e-08 u = 5.960e-08\n",
54
+ "CHECK|exact|fp32: max rel. rounding error <= u (=2^-24)|0.000e+00|tol=0.0e+00|PASS\n"
55
+ ]
56
+ }
57
+ ]
58
+ },
59
+ {
60
+ "cell_type": "markdown",
61
+ "metadata": {},
62
+ "source": [
63
+ "## 1. The tiny LM with LARGE logits (like an LM head with high-magnitude activations); sampler mu = same network with bf16-rounded logits"
64
+ ]
65
+ },
66
+ {
67
+ "cell_type": "code",
68
+ "execution_count": 3,
69
+ "metadata": {},
70
+ "source": [
71
+ "lm = TinyLM(V=4, L=4, seed=0, scale=4.0)\n",
72
+ "theta = lm.theta0.clone()\n",
73
+ "R = default_reward(lm)\n",
74
+ "def seq_logprob_rounded(theta, dtype):\n",
75
+ " V, L = lm.V, lm.L\n",
76
+ " tok, pos = lm._logits_tables(theta)\n",
77
+ " prev = torch.cat([torch.full((lm.N, 1), V, dtype=torch.long), lm.tokens[:, :-1]], 1)\n",
78
+ " logits = tok[prev] + pos[None, :, :]\n",
79
+ " if dtype is not None: logits = logits.to(dtype).to(torch.float64)\n",
80
+ " lp = torch.log_softmax(logits, -1).gather(-1, lm.tokens[..., None]).squeeze(-1) * lm.mask\n",
81
+ " return lp.sum(1)\n",
82
+ "pi = seq_logprob_rounded(theta, None).exp()\n",
83
+ "mu = seq_logprob_rounded(theta, torch.bfloat16).exp()\n",
84
+ "check(\"trainer pi is normalised\", abs(float(pi.sum()) - 1), 1e-14)\n",
85
+ "check(\"sampler mu is normalised (bf16 logits then softmax)\", abs(float(mu.sum()) - 1), 1e-14)\n",
86
+ "b = float(pi @ R); A = R - b\n",
87
+ "S = score_matrix(lm, theta) # score of the TRAINER policy\n",
88
+ "g_true = (pi[:, None] * A[:, None] * S).sum(0) # grad J_pi\n",
89
+ "w = pi / mu\n",
90
+ "TVs = 0.5 * float((pi - mu).abs().sum())\n",
91
+ "print(f\" bf16 logits: TV(pi, mu) = {TVs:.5f} chi2(pi||mu) = {float((mu*(w-1)**2).sum()):.3e} max w = {float(w.max()):.4f}\")\n",
92
+ "for nm, dt in ((\"fp16\", torch.float16), (\"fp32\", torch.float32)):\n",
93
+ " m_ = seq_logprob_rounded(theta, dt).exp()\n",
94
+ " print(f\" {nm} logits: TV(pi, mu) = {0.5*float((pi-m_).abs().sum()):.3e} chi2 = {float((m_*(pi/m_-1)**2).sum()):.3e}\")"
95
+ ],
96
+ "outputs": [
97
+ {
98
+ "output_type": "stream",
99
+ "name": "stdout",
100
+ "text": [
101
+ "CHECK|exact|trainer pi is normalised|0.000e+00|tol=1.0e-14|PASS\n",
102
+ "CHECK|exact|sampler mu is normalised (bf16 logits then softmax)|0.000e+00|tol=1.0e-14|PASS\n",
103
+ " bf16 logits: TV(pi, mu) = 0.00045 chi2(pi||mu) = 5.187e-06 max w = 1.0491\n",
104
+ " fp16 logits: TV(pi, mu) = 1.195e-04 chi2 = 2.142e-07\n",
105
+ " fp32 logits: TV(pi, mu) = 4.361e-09 chi2 = 1.076e-15\n"
106
+ ]
107
+ }
108
+ ]
109
+ },
110
+ {
111
+ "cell_type": "markdown",
112
+ "metadata": {},
113
+ "source": [
114
+ "## 2. Ignoring the mismatch is biased; IS repairs it exactly (arXiv:2510.26788 Eqs. 3, 5)"
115
+ ]
116
+ },
117
+ {
118
+ "cell_type": "code",
119
+ "execution_count": 4,
120
+ "metadata": {},
121
+ "source": [
122
+ "g_biased = (mu[:, None] * A[:, None] * S).sum(0) # samples from mu, trainer's score, no correction\n",
123
+ "bias_formula = ((mu - pi)[:, None] * A[:, None] * S).sum(0) # = E_pi[(mu/pi - 1) A s]\n",
124
+ "check(\"bias of the uncorrected estimator == sum_o (mu-pi) A s\", maxabs(g_biased - g_true, bias_formula), 1e-14)\n",
125
+ "g_is = (mu[:, None] * w[:, None] * A[:, None] * S).sum(0)\n",
126
+ "check(\"sequence-level IS correction E_mu[(pi/mu) A s] == grad J_pi exactly\", maxabs(g_is, g_true), 1e-14)\n",
127
+ "print(f\" ||bias|| / ||grad J|| = {float((g_biased-g_true).norm()/g_true.norm()):.4f} (uncorrected)\")\n",
128
+ "assert float((g_biased - g_true).norm()) > 1e-4"
129
+ ],
130
+ "outputs": [
131
+ {
132
+ "output_type": "stream",
133
+ "name": "stdout",
134
+ "text": [
135
+ "CHECK|exact|bias of the uncorrected estimator == sum_o (mu-pi) A s|1.930e-17|tol=1.0e-14|PASS\n",
136
+ "CHECK|exact|sequence-level IS correction E_mu[(pi/mu) A s] == grad J_pi exactly|6.939e-18|tol=1.0e-14|PASS\n",
137
+ " ||bias|| / ||grad J|| = 0.0011 (uncorrected)\n"
138
+ ]
139
+ }
140
+ ]
141
+ },
142
+ {
143
+ "cell_type": "markdown",
144
+ "metadata": {},
145
+ "source": [
146
+ "## 3. Truncated IS (TIS, Eq. 6) and masked IS (MIS, Eq. 7): exact bias and bounds\n",
147
+ " TIS: E_mu[min(w,C) A s]; bias = - E_mu[(w-C)_+ A s] ||.|| <= M_A M_s sum_o (pi - C mu)_+ <= M_A M_s min(TV, P_pi(w>C))\n",
148
+ " MIS: E_mu[1(w<=C) w A s]; bias = - E_pi[1(w>C) A s] ||.|| <= M_A M_s P_pi(w>C)"
149
+ ]
150
+ },
151
+ {
152
+ "cell_type": "code",
153
+ "execution_count": 5,
154
+ "metadata": {},
155
+ "source": [
156
+ "MA, Ms = float(A.abs().max()), float(S.norm(dim=1).max())\n",
157
+ "print(\" C ||TIS bias|| bound1(sum mu (w-C)+ |A| ||s||) bound2(MA Ms sum(pi-C mu)+) MA Ms TV ||MIS bias|| MA Ms P_pi(w>C)\")\n",
158
+ "for C in (1.0, 1.02, 1.05, 1.2, 2.0):\n",
159
+ " wt = torch.clamp(w, max=C)\n",
160
+ " g_tis = (mu[:, None] * wt[:, None] * A[:, None] * S).sum(0)\n",
161
+ " bias_tis = g_tis - g_true\n",
162
+ " formula = -((mu * (w - C).clamp(min=0))[:, None] * A[:, None] * S).sum(0)\n",
163
+ " check(f\"TIS bias == -E_mu[(w-C)_+ A s] (C={C})\", maxabs(bias_tis, formula), 1e-14)\n",
164
+ " b1 = float((mu * (w - C).clamp(min=0) * A.abs() * S.norm(dim=1)).sum())\n",
165
+ " ex = float((pi - C * mu).clamp(min=0).sum())\n",
166
+ " b2 = MA * Ms * ex\n",
167
+ " assert float(bias_tis.norm()) <= b1 + 1e-15 and b1 <= b2 + 1e-15 and ex <= TVs + 1e-15\n",
168
+ " g_mis = (mu[:, None] * (w * (w <= C))[:, None] * A[:, None] * S).sum(0)\n",
169
+ " bias_mis = g_mis - g_true\n",
170
+ " mis_formula = -((pi * (w > C))[:, None] * A[:, None] * S).sum(0)\n",
171
+ " check(f\"MIS bias == -E_pi[1(w>C) A s] (C={C})\", maxabs(bias_mis, mis_formula), 1e-14)\n",
172
+ " Pw = float((pi * (w > C)).sum())\n",
173
+ " assert float(bias_mis.norm()) <= MA * Ms * Pw + 1e-15\n",
174
+ " print(f\" {C:4.2f} {float(bias_tis.norm()):12.4e} {b1:12.4e} {b2:12.4e} {MA*Ms*TVs:10.4e} {float(bias_mis.norm()):12.4e} {MA*Ms*Pw:12.4e}\")\n",
175
+ "check(\"TIS: 0 <= bias <= bound1 <= bound2 <= M_A M_s TV(pi,mu) for all C\", 0.0, 0.0)\n",
176
+ "check(\"TIS at C=inf is exact (no truncation) -> bias = 0\", float((((mu[:, None] * torch.clamp(w, max=1e12)[:, None] * A[:, None] * S).sum(0)) - g_true).norm()), 1e-13)\n",
177
+ "# TV of sequence distributions <= sum over positions of E[TV_t] (hybrid argument)\n",
178
+ "def tv_chain(theta, dtype):\n",
179
+ " tot = 0.0\n",
180
+ " V, L = lm.V, lm.L\n",
181
+ " tok, pos = lm._logits_tables(theta)\n",
182
+ " def cond(prefix, rounder):\n",
183
+ " prev = V if len(prefix) == 0 else prefix[-1]\n",
184
+ " z = tok[prev] + pos[len(prefix)]\n",
185
+ " if rounder is not None: z = z.to(rounder).to(torch.float64)\n",
186
+ " return torch.softmax(z, 0)\n",
187
+ " def rec(prefix, prob):\n",
188
+ " nonlocal tot\n",
189
+ " if len(prefix) >= L: return\n",
190
+ " cp, cm = cond(prefix, None), cond(prefix, dtype)\n",
191
+ " tot += prob * 0.5 * float((cp - cm).abs().sum())\n",
192
+ " for a in range(V - 1): rec(prefix + (a,), prob * float(cp[a]))\n",
193
+ " rec((), 1.0)\n",
194
+ " return tot\n",
195
+ "tvc = tv_chain(theta, torch.bfloat16)\n",
196
+ "print(f\" TV(sequence) = {TVs:.4f} <= sum_t E_pi[TV_t] = {tvc:.4f}\")\n",
197
+ "check(\"sequence TV <= sum_t E[TV_t] (max violation, must be <= 0)\", max(0.0, TVs - tvc), 0.0)"
198
+ ],
199
+ "outputs": [
200
+ {
201
+ "output_type": "stream",
202
+ "name": "stdout",
203
+ "text": [
204
+ " C ||TIS bias|| bound1(sum mu (w-C)+ |A| ||s||) bound2(MA Ms sum(pi-C mu)+) MA Ms TV ||MIS bias|| MA Ms P_pi(w>C)\n",
205
+ "CHECK|exact|TIS bias == -E_mu[(w-C)_+ A s] (C=1.0)|2.557e-17|tol=1.0e-14|PASS\n",
206
+ "CHECK|exact|MIS bias == -E_pi[1(w>C) A s] (C=1.0)|6.939e-18|tol=1.0e-14|PASS\n",
207
+ " 1.00 1.1248e-04 1.6072e-04 2.2052e-03 2.2052e-03 1.2226e-01 4.0596e+00\n",
208
+ "CHECK|exact|TIS bias == -E_mu[(w-C)_+ A s] (C=1.02)|2.005e-17|tol=1.0e-14|PASS\n",
209
+ "CHECK|exact|MIS bias == -E_pi[1(w>C) A s] (C=1.02)|1.562e-17|tol=1.0e-14|PASS\n",
210
+ " 1.02 6.4034e-08 6.6273e-08 1.6801e-06 2.2052e-03 4.3457e-05 1.1315e-03\n",
211
+ "CHECK|exact|TIS bias == -E_mu[(w-C)_+ A s] (C=1.05)|6.939e-18|tol=1.0e-14|PASS\n",
212
+ "CHECK|exact|MIS bias == -E_pi[1(w>C) A s] (C=1.05)|6.939e-18|tol=1.0e-14|PASS\n",
213
+ " 1.05 1.7358e-17 0.0000e+00 0.0000e+00 2.2052e-03 1.7358e-17 0.0000e+00\n",
214
+ "CHECK|exact|TIS bias == -E_mu[(w-C)_+ A s] (C=1.2)|6.939e-18|tol=1.0e-14|PASS\n",
215
+ "CHECK|exact|MIS bias == -E_pi[1(w>C) A s] (C=1.2)|6.939e-18|tol=1.0e-14|PASS\n",
216
+ " 1.20 1.7358e-17 0.0000e+00 0.0000e+00 2.2052e-03 1.7358e-17 0.0000e+00\n",
217
+ "CHECK|exact|TIS bias == -E_mu[(w-C)_+ A s] (C=2.0)|6.939e-18|tol=1.0e-14|PASS\n",
218
+ "CHECK|exact|MIS bias == -E_pi[1(w>C) A s] (C=2.0)|6.939e-18|tol=1.0e-14|PASS\n",
219
+ " 2.00 1.7358e-17 0.0000e+00 0.0000e+00 2.2052e-03 1.7358e-17 0.0000e+00\n",
220
+ "CHECK|exact|TIS: 0 <= bias <= bound1 <= bound2 <= M_A M_s TV(pi,mu) for all C|0.000e+00|tol=0.0e+00|PASS\n",
221
+ "CHECK|exact|TIS at C=inf is exact (no truncation) -> bias = 0|1.736e-17|tol=1.0e-13|PASS\n",
222
+ " TV(sequence) = 0.0004 <= sum_t E_pi[TV_t] = 0.0007\n",
223
+ "CHECK|exact|sequence TV <= sum_t E[TV_t] (max violation, must be <= 0)|0.000e+00|tol=0.0e+00|PASS\n"
224
+ ]
225
+ }
226
+ ]
227
+ },
228
+ {
229
+ "cell_type": "markdown",
230
+ "metadata": {},
231
+ "source": [
232
+ "## 4. From logit rounding error to log-prob error: d log pi_a = e_a - sum_b pi_b e_b (first order); Var = s^2 (1 - 2 pi_a + sum pi_b^2) for iid logit errors"
233
+ ]
234
+ },
235
+ {
236
+ "cell_type": "code",
237
+ "execution_count": 6,
238
+ "metadata": {},
239
+ "source": [
240
+ "gen = torch.Generator().manual_seed(3)\n",
241
+ "z = torch.randn(7, generator=gen) * 5.0\n",
242
+ "pi_z = torch.softmax(z, 0)\n",
243
+ "eps = torch.randn(7, generator=gen) * 1e-6\n",
244
+ "dlp_exact = torch.log_softmax(z + eps, 0) - torch.log_softmax(z, 0)\n",
245
+ "dlp_lin = eps - (pi_z * eps).sum()\n",
246
+ "check(\"first-order log-prob error e_a - sum_b pi_b e_b (relative error of linearisation, e~1e-6)\", float((dlp_exact - dlp_lin).abs().max() / dlp_lin.abs().max()), 1e-5)\n",
247
+ "s_ = 0.01\n",
248
+ "E = torch.randn(400000, 7, generator=gen) * s_\n",
249
+ "dlp = torch.log_softmax(z + E, -1) - torch.log_softmax(z, 0)\n",
250
+ "var_mc = dlp.var(0); var_f = s_ ** 2 * (1 - 2 * pi_z + (pi_z ** 2).sum())\n",
251
+ "zs = float(((var_mc - var_f).abs() / (var_f * math.sqrt(2 / 400000))).max())\n",
252
+ "check(\"Var(d log pi_a) = s^2 (1 - 2 pi_a + sum pi^2): max z-score over 7 tokens (MC, 400k draws)\", zs, 4.0, kind=\"mc\")"
253
+ ],
254
+ "outputs": [
255
+ {
256
+ "output_type": "stream",
257
+ "name": "stdout",
258
+ "text": [
259
+ "CHECK|exact|first-order log-prob error e_a - sum_b pi_b e_b (relative error of linearisation, e~1e-6)|5.237e-08|tol=1.0e-05|PASS\n",
260
+ "CHECK|mc|Var(d log pi_a) = s^2 (1 - 2 pi_a + sum pi^2): max z-score over 7 tokens (MC, 400k draws)|1.196e+00|tol=4.0e+00|PASS\n"
261
+ ]
262
+ }
263
+ ]
264
+ },
265
+ {
266
+ "cell_type": "markdown",
267
+ "metadata": {},
268
+ "source": [
269
+ "## 4b. Where the logit error comes from: an LM-head matmul with bf16 inputs vs an fp32 head\n",
270
+ "z_b = sum_i h_i W_bi. Rounding h and W to bf16 perturbs each product by a relative error <= u, so the logit error scales with the\n",
271
+ "magnitude of the terms (not just of z). Outlier activations (a few huge hidden dims) enlarge that magnitude."
272
+ ]
273
+ },
274
+ {
275
+ "cell_type": "code",
276
+ "execution_count": 7,
277
+ "metadata": {},
278
+ "source": [
279
+ "gen = torch.Generator().manual_seed(4)\n",
280
+ "d, Vv, Tn = 1024, 64, 512\n",
281
+ "def head_errors(outlier):\n",
282
+ " h = torch.randn(Tn, d, generator=gen)\n",
283
+ " h[:, :8] *= outlier # 8 high-magnitude hidden dimensions\n",
284
+ " W = torch.randn(Vv, d, generator=gen) / math.sqrt(d)\n",
285
+ " z = h @ W.T # fp64 reference\n",
286
+ " zb = h.to(torch.bfloat16).to(torch.float64) @ W.to(torch.bfloat16).to(torch.float64).T # bf16-rounded inputs, exact accumulation\n",
287
+ " z32 = h.to(torch.float32).to(torch.float64) @ W.to(torch.float32).to(torch.float64).T # fp32-rounded inputs\n",
288
+ " return float(z.std()), float((zb - z).std()), float((z32 - z).std())\n",
289
+ "print(\" outlier x std(z) std(err bf16) std(err fp32) err_bf16/std(z) /u_bf16 ratio bf16/fp32\")\n",
290
+ "for out in (1.0, 10.0, 100.0):\n",
291
+ " sz_, eb, e32 = head_errors(out)\n",
292
+ " print(f\" {out:7.0f} {sz_:8.2f} {eb:12.3e} {e32:12.3e} {eb/sz_:10.3e} {eb/sz_/2**-8:6.3f} {eb/e32:9.1f}\")\n",
293
+ "sz_, eb, e32 = head_errors(100.0)\n",
294
+ "check(\"bf16 head: logit error / std(z) between 0.05u and 1u (u=2^-8)\", 0.0 if 0.05 * 2**-8 < eb / sz_ < 2**-8 else 1.0, 0.0)\n",
295
+ "check(\"fp32 head cuts the logit error by ~ u_bf16/u_fp32 = 65536 (log10 ratio within 1 decade)\", abs(math.log10(eb / e32) - math.log10(2**16)), 1.0)"
296
+ ],
297
+ "outputs": [
298
+ {
299
+ "output_type": "stream",
300
+ "name": "stdout",
301
+ "text": [
302
+ " outlier x std(z) std(err bf16) std(err fp32) err_bf16/std(z) /u_bf16 ratio bf16/fp32\n",
303
+ " 1 1.01 2.366e-03 3.575e-08 2.349e-03 0.601 66166.0\n",
304
+ " 10 1.34 3.054e-03 4.786e-08 2.284e-03 0.585 63825.0\n",
305
+ " 100 8.81 2.025e-02 3.259e-07 2.298e-03 0.588 62139.1\n",
306
+ "CHECK|exact|bf16 head: logit error / std(z) between 0.05u and 1u (u=2^-8)|0.000e+00|tol=0.0e+00|PASS\n",
307
+ "CHECK|exact|fp32 head cuts the logit error by ~ u_bf16/u_fp32 = 65536 (log10 ratio within 1 decade)|2.678e-02|tol=1.0e+00|PASS\n"
308
+ ]
309
+ }
310
+ ]
311
+ },
312
+ {
313
+ "cell_type": "markdown",
314
+ "metadata": {},
315
+ "source": [
316
+ "## 5. Long horizons: positions are independent contexts, so everything is EXACT:\n",
317
+ " E_mu[log w_T] = -sum_t KL(mu_t||pi_t), Var(log w_T) = sum_t Var_t, E_mu[w_T^2] = prod_t (1 + chi2_t). (V=64, logits ~ N(0, sigma_z^2))"
318
+ ]
319
+ },
320
+ {
321
+ "cell_type": "code",
322
+ "execution_count": 8,
323
+ "metadata": {},
324
+ "source": [
325
+ "def per_position_stats(sigma_z, dtype, T=4096, V=64, seed=0):\n",
326
+ " g = torch.Generator().manual_seed(seed)\n",
327
+ " z = torch.randn(T, V, generator=g) * sigma_z\n",
328
+ " pi = torch.softmax(z, -1)\n",
329
+ " zr = z if dtype is None else z.to(dtype).to(torch.float64)\n",
330
+ " mu = torch.softmax(zr, -1)\n",
331
+ " lw = pi.log() - mu.log() # log(pi/mu) per token option\n",
332
+ " Elw = (mu * lw).sum(-1) # E_mu[log w_t] = -KL(mu||pi)\n",
333
+ " Vlw = (mu * lw ** 2).sum(-1) - Elw ** 2\n",
334
+ " chi2 = (mu * (pi / mu - 1) ** 2).sum(-1)\n",
335
+ " return Elw, Vlw, chi2, pi, mu\n",
336
+ "print(\" sigma_z head mean KL/token mean chi2/token T=100: ESS frac T=1000 T=4000 (ESS frac = 1/prod(1+chi2))\")\n",
337
+ "rows = {}\n",
338
+ "for sz in (2.0, 5.0, 10.0, 20.0):\n",
339
+ " for name, dt in ((\"bf16\", torch.bfloat16), (\"fp16\", torch.float16), (\"fp32\", torch.float32)):\n",
340
+ " Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(sz, dt)\n",
341
+ " lESS = lambda T: float(-torch.log1p(chi2[:T]).sum())\n",
342
+ " rows[(sz, name)] = (float((-Elw).mean()), float(chi2.mean()), lESS(100), lESS(1000), lESS(4000))\n",
343
+ " print(f\" {sz:5.1f} {name} {float((-Elw).mean()):14.3e} {float(chi2.mean()):14.3e} {math.exp(lESS(100)):10.3e} {math.exp(lESS(1000)):10.3e} {math.exp(lESS(4000)):10.3e}\")\n",
344
+ "# why chi2 does not grow like sigma_z^2: to first order chi2_t = Var_{a~pi}(e_a) where e = round(z) - z (the *centred* logit error)\n",
345
+ "def first_order_check(sz, dtype, u, T=2048, V=64):\n",
346
+ " g_ = torch.Generator().manual_seed(0)\n",
347
+ " z_ = torch.randn(T, V, generator=g_) * sz\n",
348
+ " e_ = z_.to(dtype).to(torch.float64) - z_\n",
349
+ " pi_ = torch.softmax(z_, -1); mu_ = torch.softmax(z_ + e_, -1)\n",
350
+ " chi2_ = (mu_ * (pi_ / mu_ - 1) ** 2).sum(-1)\n",
351
+ " var_e = (pi_ * e_ ** 2).sum(-1) - ((pi_ * e_).sum(-1)) ** 2\n",
352
+ " bound = u ** 2 * (pi_ * z_ ** 2).sum(-1)\n",
353
+ " return float(chi2_.sum() / var_e.sum()), float((var_e / bound).max()), float(chi2_.mean())\n",
354
+ "for sz in (2.0, 10.0, 20.0):\n",
355
+ " ratio, bnd, c2 = first_order_check(sz, torch.bfloat16, 2.0 ** -8)\n",
356
+ " print(f\" sigma_z={sz:5.1f}: chi2/Var_pi(e) = {ratio:.4f} max Var_pi(e)/(u^2 E_pi z^2) = {bnd:.3f}\")\n",
357
+ " check(f\"first-order law chi2_t = Var_pi(logit rounding error) (sigma_z={sz}, mean ratio - 1)\", abs(ratio - 1), 0.1)\n",
358
+ " check(f\"bound Var_pi(e) <= u^2 E_pi[z^2] (sigma_z={sz}, max ratio - 1 must be <= 0)\", max(0.0, bnd - 1), 0.0)\n",
359
+ "szs = np.array([2.0, 5.0, 10.0, 20.0]); chis = np.array([rows[(s, 'bf16')][1] for s in szs])\n",
360
+ "slope = np.polyfit(np.log(szs), np.log(chis), 1)[0]\n",
361
+ "print(f\" bf16 log-log slope of chi2/token vs logit scale: {slope:.3f} (between 1 and 2: the error grows like |z| but the centred error Var_pi(e) shrinks as pi becomes peaked)\")\n",
362
+ "assert 1.0 < slope < 2.0\n",
363
+ "ratio_fp32 = rows[(10.0, 'fp32')][1] / rows[(10.0, 'bf16')][1]\n",
364
+ "print(f\" fp32/bf16 chi2 ratio at sigma_z=10: {ratio_fp32:.2e} (theory (u32/u16)^2 = {(2.0**-24/2.0**-8)**2:.2e})\")\n",
365
+ "check(\"fp32 head reduces per-token chi^2 by ~ (u_fp32/u_bf16)^2 (|log10 ratio - log10 theory|)\", abs(math.log10(ratio_fp32) - math.log10((2.0 ** -24 / 2.0 ** -8) ** 2)), 1.0)\n",
366
+ "# MC check of one exact statistic: Var(log w_T) over sampled sequences for T=64\n",
367
+ "Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(10.0, torch.bfloat16, T=64)\n",
368
+ "g = torch.Generator().manual_seed(9); n = 20000\n",
369
+ "tokens = torch.stack([torch.multinomial(mu_t[t], n, replacement=True, generator=g) for t in range(64)], 1) # [n, T]\n",
370
+ "lw_seq = (pi_t.log().gather(1, tokens.T).T - mu_t.log().gather(1, tokens.T).T).sum(1)\n",
371
+ "exact_mean, exact_var = float(Elw.sum()), float(Vlw.sum())\n",
372
+ "se_mean = math.sqrt(exact_var / n)\n",
373
+ "check(\"MC: E_mu[log w_64] == -sum KL (z-score)\", abs(float(lw_seq.mean()) - exact_mean) / se_mean, 4.0, kind=\"mc\")\n",
374
+ "check(\"MC: Var(log w_64) == sum_t Var_t (z-score; kurtosis ignored -> loose 6)\", abs(float(lw_seq.var()) - exact_var) / (exact_var * math.sqrt(2 / n)), 6.0, kind=\"mc\")\n",
375
+ "check(\"E_mu[w_T] = 1 exactly per position (sum_a pi_a = 1)\", float((mu_t * (pi_t / mu_t)).sum(-1).sub(1).abs().max()), 1e-13)"
376
+ ],
377
+ "outputs": [
378
+ {
379
+ "output_type": "stream",
380
+ "name": "stdout",
381
+ "text": [
382
+ " sigma_z head mean KL/token mean chi2/token T=100: ESS frac T=1000 T=4000 (ESS frac = 1/prod(1+chi2))\n",
383
+ " 2.0 bf16 1.420e-05 2.841e-05 9.967e-01 9.716e-01 8.925e-01\n",
384
+ " 2.0 fp16 2.179e-07 4.358e-07 1.000e+00 9.996e-01 9.983e-01\n",
385
+ " 2.0 fp32 3.269e-15 6.533e-15 1.000e+00 1.000e+00 1.000e+00\n",
386
+ " 5.0 bf16 6.224e-05 1.245e-04 9.895e-01 8.878e-01 6.068e-01\n",
387
+ " 5.0 fp16 9.391e-07 1.878e-06 9.998e-01 9.980e-01 9.925e-01\n",
388
+ " 5.0 fp32 1.424e-14 2.849e-14 1.000e+00 1.000e+00 1.000e+00\n",
389
+ " 10.0 bf16 1.464e-04 2.929e-04 9.774e-01 7.601e-01 3.091e-01\n",
390
+ " 10.0 fp16 2.135e-06 4.270e-06 9.996e-01 9.956e-01 9.831e-01\n",
391
+ " 10.0 fp32 3.233e-14 6.466e-14 1.000e+00 1.000e+00 1.000e+00\n",
392
+ " 20.0 bf16 3.125e-04 6.249e-04 9.580e-01 5.596e-01 8.265e-02\n",
393
+ " 20.0 fp16 4.395e-06 8.792e-06 9.991e-01 9.914e-01 9.656e-01\n",
394
+ " 20.0 fp32 6.719e-14 1.344e-13 1.000e+00 1.000e+00 1.000e+00\n",
395
+ " sigma_z= 2.0: chi2/Var_pi(e) = 1.0000 max Var_pi(e)/(u^2 E_pi z^2) = 0.590\n",
396
+ "CHECK|exact|first-order law chi2_t = Var_pi(logit rounding error) (sigma_z=2.0, mean ratio - 1)|1.799e-05|tol=1.0e-01|PASS\n",
397
+ "CHECK|exact|bound Var_pi(e) <= u^2 E_pi[z^2] (sigma_z=2.0, max ratio - 1 must be <= 0)|0.000e+00|tol=0.0e+00|PASS\n",
398
+ " sigma_z= 10.0: chi2/Var_pi(e) = 1.0005 max Var_pi(e)/(u^2 E_pi z^2) = 0.709\n",
399
+ "CHECK|exact|first-order law chi2_t = Var_pi(logit rounding error) (sigma_z=10.0, mean ratio - 1)|5.085e-04|tol=1.0e-01|PASS\n",
400
+ "CHECK|exact|bound Var_pi(e) <= u^2 E_pi[z^2] (sigma_z=10.0, max ratio - 1 must be <= 0)|0.000e+00|tol=0.0e+00|PASS\n",
401
+ " sigma_z= 20.0: chi2/Var_pi(e) = 1.0021 max Var_pi(e)/(u^2 E_pi z^2) = 0.644\n",
402
+ "CHECK|exact|first-order law chi2_t = Var_pi(logit rounding error) (sigma_z=20.0, mean ratio - 1)|2.100e-03|tol=1.0e-01|PASS\n",
403
+ "CHECK|exact|bound Var_pi(e) <= u^2 E_pi[z^2] (sigma_z=20.0, max ratio - 1 must be <= 0)|0.000e+00|tol=0.0e+00|PASS\n",
404
+ " bf16 log-log slope of chi2/token vs logit scale: 1.342 (between 1 and 2: the error grows like |z| but the centred error Var_pi(e) shrinks as pi becomes peaked)\n",
405
+ " fp32/bf16 chi2 ratio at sigma_z=10: 2.21e-10 (theory (u32/u16)^2 = 2.33e-10)\n",
406
+ "CHECK|exact|fp32 head reduces per-token chi^2 by ~ (u_fp32/u_bf16)^2 (|log10 ratio - log10 theory|)|2.316e-02|tol=1.0e+00|PASS\n",
407
+ "CHECK|mc|MC: E_mu[log w_64] == -sum KL (z-score)|4.121e-01|tol=4.0e+00|PASS\n",
408
+ "CHECK|mc|MC: Var(log w_64) == sum_t Var_t (z-score; kurtosis ignored -> loose 6)|5.377e-01|tol=6.0e+00|PASS\n",
409
+ "CHECK|exact|E_mu[w_T] = 1 exactly per position (sum_a pi_a = 1)|6.661e-16|tol=1.0e-13|PASS\n"
410
+ ]
411
+ }
412
+ ]
413
+ },
414
+ {
415
+ "cell_type": "markdown",
416
+ "metadata": {},
417
+ "source": [
418
+ "## 5b. Caveat: E_mu[w_T^2] = prod_t (1 + chi2_t) needs history-INDEPENDENT chi2_t. With state-dependent chi2 the exact statements are\n",
419
+ " (i) the recursion E[w_T^2] = E[w_{T-1}^2 (1 + chi2_T(s_T))], (ii) the martingale identity E[ w_T^2 / prod_t (1 + chi2_t(s_t)) ] = 1,\n",
420
+ "and E[prod_t (1 + chi2_t(s_t))] is NOT E[w_T^2]. Exact enumeration on a tiny autoregressive model (sampler mu = one weight vector, trainer pi = a perturbed one)."
421
+ ]
422
+ },
423
+ {
424
+ "cell_type": "code",
425
+ "execution_count": 9,
426
+ "metadata": {},
427
+ "source": [
428
+ "lmx = TinyLM(V=3, L=8, seed=4, scale=1.0)\n",
429
+ "tokx = lmx.theta0[:lmx.n_tok].reshape(lmx.V + 1, lmx.V); tokx[:, lmx.EOS] -= 2.0\n",
430
+ "th_mu = lmx.theta0.clone(); th_pi = perturb(th_mu, 0.8, seed=9)\n",
431
+ "lmu, lpi = lmx.token_logprobs(th_mu), lmx.token_logprobs(th_pi)\n",
432
+ "mu_x = lmu.sum(1).exp()\n",
433
+ "chi_x = torch.zeros(lmx.N, lmx.L) # chi2_t(s_t) = chi2(pi(.|s_t) || mu(.|s_t)) at the realised prefix\n",
434
+ "for i, sq in enumerate(lmx.seqs):\n",
435
+ " for t in range(len(sq)):\n",
436
+ " cm, cp = lmx.cond_probs(th_mu, sq[:t]), lmx.cond_probs(th_pi, sq[:t])\n",
437
+ " chi_x[i, t] = ((cp - cm) ** 2 / cm).sum()\n",
438
+ "w2_x = (lpi - lmu).sum(1).exp() ** 2\n",
439
+ "prod_x = (1 + chi_x).prod(1) # masked positions have chi2 = 0 -> factor 1\n",
440
+ "Ew2 = float((mu_x * w2_x).sum()); Eprod = float((mu_x * prod_x).sum())\n",
441
+ "w2_prev = torch.cat([torch.zeros(lmx.N, 1), (lpi - lmu).cumsum(1)[:, :-1]], 1).exp() ** 2 # w_{t-1}^2 (w_0 = 1)\n",
442
+ "rec = float((mu_x * w2_prev[:, -1] * (1 + chi_x[:, -1])).sum())\n",
443
+ "print(f\" N={lmx.N}: E_mu[w^2] = {Ew2:.6f}; recursion E[w_(T-1)^2 (1+chi2_T)] = {rec:.6f}; E_mu[prod_t(1+chi2_t)] = {Eprod:.6f}; E_mu[w^2/prod] = {float((mu_x*w2_x/prod_x).sum()):.6f}\")\n",
444
+ "check(\"recursion E[w_T^2] = E[w_(T-1)^2 (1+chi2_T(s_T))] (state-dependent chi2)\", abs(Ew2 - rec), 1e-14)\n",
445
+ "check(\"martingale identity E[w_T^2 / prod_t (1+chi2_t(s_t))] = 1\", abs(float((mu_x * w2_x / prod_x).sum()) - 1.0), 1e-13)\n",
446
+ "assert abs(Ew2 - Eprod) > 1e-3 # the naive product formula is genuinely wrong when chi2 depends on the history\n",
447
+ "check(\"E[prod(1+chi2_t)] != E[w_T^2] on a state-dependent model (relative gap, must exceed 1e-3)\", max(0.0, 1e-3 - abs(Ew2 - Eprod) / Ew2), 0.0)"
448
+ ],
449
+ "outputs": [
450
+ {
451
+ "output_type": "stream",
452
+ "name": "stdout",
453
+ "text": [
454
+ " N=511: E_mu[w^2] = 1.151486; recursion E[w_(T-1)^2 (1+chi2_T)] = 1.151486; E_mu[prod_t(1+chi2_t)] = 1.141717; E_mu[w^2/prod] = 1.000000\n",
455
+ "CHECK|exact|recursion E[w_T^2] = E[w_(T-1)^2 (1+chi2_T(s_T))] (state-dependent chi2)|6.661e-16|tol=1.0e-14|PASS\n",
456
+ "CHECK|exact|martingale identity E[w_T^2 / prod_t (1+chi2_t(s_t))] = 1|4.441e-16|tol=1.0e-13|PASS\n",
457
+ "CHECK|exact|E[prod(1+chi2_t)] != E[w_T^2] on a state-dependent model (relative gap, must exceed 1e-3)|0.000e+00|tol=0.0e+00|PASS\n"
458
+ ]
459
+ }
460
+ ]
461
+ },
462
+ {
463
+ "cell_type": "markdown",
464
+ "metadata": {},
465
+ "source": [
466
+ "## 6. Consequence for PPO/CISPO ratios and for a \"probability correlation\" diagnostic\n",
467
+ "If the *sampler's* log-probs are used as pi_old, then at theta = theta_old the ratio is r = pi/mu != 1: tokens are clipped before any update."
468
+ ]
469
+ },
470
+ {
471
+ "cell_type": "code",
472
+ "execution_count": 10,
473
+ "metadata": {},
474
+ "source": [
475
+ "for name, dt in ((\"bf16\", torch.bfloat16), (\"fp32\", torch.float32)):\n",
476
+ " Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(10.0, dt, T=2048)\n",
477
+ " gen = torch.Generator().manual_seed(1)\n",
478
+ " tok_ = torch.stack([torch.multinomial(mu_t[t], 64, replacement=True, generator=gen) for t in range(2048)], 0) # [T, 64]\n",
479
+ " r = (pi_t.gather(1, tok_) / mu_t.gather(1, tok_))\n",
480
+ " out = float(((r - 1).abs() > 0.2).double().mean())\n",
481
+ " p_tr, p_inf = pi_t.gather(1, tok_).reshape(-1), mu_t.gather(1, tok_).reshape(-1)\n",
482
+ " corr = float(torch.corrcoef(torch.stack([p_tr, p_inf]))[0, 1])\n",
483
+ " print(f\" {name} head: fraction of sampled tokens with |pi/mu - 1| > 0.2 at theta=theta_old: {out:.4f} Pearson corr(p_train, p_sampler) = {corr:.6f}\")"
484
+ ],
485
+ "outputs": [
486
+ {
487
+ "output_type": "stream",
488
+ "name": "stdout",
489
+ "text": [
490
+ " bf16 head: fraction of sampled tokens with |pi/mu - 1| > 0.2 at theta=theta_old: 0.0000 Pearson corr(p_train, p_sampler) = 0.999793\n",
491
+ " fp32 head: fraction of sampled tokens with |pi/mu - 1| > 0.2 at theta=theta_old: 0.0000 Pearson corr(p_train, p_sampler) = 1.000000\n"
492
+ ]
493
+ }
494
+ ]
495
+ },
496
+ {
497
+ "cell_type": "markdown",
498
+ "metadata": {},
499
+ "source": [
500
+ "## 7. Naive vs stable weight computation under mismatch (log-space)"
501
+ ]
502
+ },
503
+ {
504
+ "cell_type": "code",
505
+ "execution_count": 11,
506
+ "metadata": {},
507
+ "source": [
508
+ "Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(10.0, torch.bfloat16, T=4096)\n",
509
+ "tokens = torch.stack([torch.multinomial(mu_t[t], 1, replacement=True, generator=g) for t in range(4096)], 0).squeeze(1)\n",
510
+ "lr_ = (pi_t.log().gather(1, tokens[:, None]) - mu_t.log().gather(1, tokens[:, None])).squeeze(1)\n",
511
+ "logw = float(lr_.sum())\n",
512
+ "naive32 = torch.tensor(1.0, dtype=torch.float32)\n",
513
+ "for v in lr_.exp().to(torch.float32): naive32 = naive32 * v\n",
514
+ "print(f\" T=4096 bf16-mismatch sequence: log w = {logw:.2f}; naive fp32 running product = {float(naive32)}; exp(sum log) in fp64 = {math.exp(logw):.3e}\")\n",
515
+ "check(\"naive fp32 product vs log-space: agree only when representable (log w within fp32 range)\", 0.0 if (abs(logw) > 80 or abs(float(naive32) - math.exp(logw)) < 1e-4 * math.exp(logw)) else 1.0, 0.0)"
516
+ ],
517
+ "outputs": [
518
+ {
519
+ "output_type": "stream",
520
+ "name": "stdout",
521
+ "text": [
522
+ " T=4096 bf16-mismatch sequence: log w = -1.55; naive fp32 running product = 0.21136480569839478; exp(sum log) in fp64 = 2.114e-01\n",
523
+ "CHECK|exact|naive fp32 product vs log-space: agree only when representable (log w within fp32 range)|0.000e+00|tol=0.0e+00|PASS\n"
524
+ ]
525
+ }
526
+ ]
527
+ },
528
+ {
529
+ "cell_type": "markdown",
530
+ "metadata": {},
531
+ "source": [
532
+ "## 8. Naive vs efficient TIS-corrected gradient estimate (sampled from the bf16 sampler mu)\n",
533
+ "naive: one backward per sample; efficient: one backward through the surrogate with DETACHED truncated weights."
534
+ ]
535
+ },
536
+ {
537
+ "cell_type": "code",
538
+ "execution_count": 12,
539
+ "metadata": {},
540
+ "source": [
541
+ "lm = TinyLM(V=4, L=4, seed=0, scale=4.0)\n",
542
+ "theta = lm.theta0.clone(); R = default_reward(lm)\n",
543
+ "pi_ = seq_logprob_rounded(theta, None).exp(); mu_ = seq_logprob_rounded(theta, torch.bfloat16).exp()\n",
544
+ "bb = float(pi_ @ R)\n",
545
+ "gen = torch.Generator().manual_seed(6)\n",
546
+ "idx = torch.multinomial(mu_, 500, replacement=True, generator=gen)\n",
547
+ "Cc = 1.02\n",
548
+ "w_ = (pi_ / mu_)[idx]\n",
549
+ "acc = torch.zeros_like(theta)\n",
550
+ "for j, i in enumerate(idx.tolist()):\n",
551
+ " t = theta.clone().requires_grad_(True)\n",
552
+ " acc += float(min(w_[j], Cc)) * float(R[i] - bb) * torch.autograd.grad(seq_logprob_rounded(t, None)[i], t)[0]\n",
553
+ "g_naive = acc / len(idx)\n",
554
+ "t = theta.clone().requires_grad_(True)\n",
555
+ "sur = (torch.clamp(w_, max=Cc).detach() * (R[idx] - bb) * seq_logprob_rounded(t, None)[idx]).mean()\n",
556
+ "g_eff = torch.autograd.grad(sur, t)[0]\n",
557
+ "check(\"TIS estimate: per-sample loop == single backward through detached-weight surrogate\", maxabs(g_naive, g_eff), 1e-13)\n",
558
+ "print(\"done; checks:\", len(CHECKS))"
559
+ ],
560
+ "outputs": [
561
+ {
562
+ "output_type": "stream",
563
+ "name": "stdout",
564
+ "text": [
565
+ "CHECK|exact|TIS estimate: per-sample loop == single backward through detached-weight surrogate|3.123e-16|tol=1.0e-13|PASS\n",
566
+ "done; checks: 39\n"
567
+ ]
568
+ }
569
+ ]
570
+ }
571
+ ],
572
+ "metadata": {
573
+ "kernelspec": {
574
+ "display_name": "Python 3",
575
+ "language": "python",
576
+ "name": "python3"
577
+ },
578
+ "language_info": {
579
+ "name": "python",
580
+ "version": "3.12.11"
581
+ }
582
+ },
583
+ "nbformat": 4,
584
+ "nbformat_minor": 4
585
+ }
code/notebooks/0109-kl-estimators.ipynb ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0109 - KL regularisation estimators k1, k2, k3: value bias/variance, what their GRADIENTS optimise, why beta = 0\n",
8
+ "Companion to `notes/0109 - KL Estimators.pdf`. Samples x ~ q = pi_theta, p = pi_ref, r = p/q, KL(q||p) = E_q[-log r]."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "import numpy as np"
20
+ ],
21
+ "outputs": []
22
+ },
23
+ {
24
+ "cell_type": "markdown",
25
+ "metadata": {},
26
+ "source": [
27
+ "## 1. Values. E_q[k1] = E_q[k3] = KL(q||p); E_q[k2] = KL + bias, bias = -sum_{n>=3} E[l^n]/n!, l = log r\n",
28
+ " k1 = -log r, k2 = (log r)^2 / 2, k3 = (r-1) - log r (Schulman, \"Approximating KL divergence\")"
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": 2,
34
+ "metadata": {},
35
+ "source": [
36
+ "def estimators(logr):\n",
37
+ " return -logr, 0.5 * logr ** 2, torch.expm1(logr) - logr\n",
38
+ "gen = torch.Generator().manual_seed(2)\n",
39
+ "for trial in range(3):\n",
40
+ " K = 6\n",
41
+ " q = torch.softmax(torch.randn(K, generator=gen) * 1.2, 0); p = torch.softmax(torch.randn(K, generator=gen) * 1.2, 0)\n",
42
+ " logr = (p / q).log()\n",
43
+ " kl = float((q * (q / p).log()).sum())\n",
44
+ " k1, k2, k3 = estimators(logr)\n",
45
+ " check(f\"categorical #{trial}: E_q[k1] = KL(q||p)\", abs(float((q * k1).sum()) - kl), 1e-14)\n",
46
+ " check(f\"categorical #{trial}: E_q[k3] = KL(q||p)\", abs(float((q * k3).sum()) - kl), 1e-14)\n",
47
+ " bias2 = float((q * k2).sum()) - kl\n",
48
+ " series = -sum(float((q * logr ** n).sum()) / math.factorial(n) for n in range(3, 40))\n",
49
+ " check(f\"categorical #{trial}: bias(k2) = -sum_(n>=3) E[l^n]/n! (series, 40 terms)\", abs(bias2 - series), 1e-12)\n",
50
+ " # the master identity KL = sum_{n>=2} E[l^n]/n! (from E_q[e^l] = 1)\n",
51
+ " ms = sum(float((q * logr ** n).sum()) / math.factorial(n) for n in range(2, 40))\n",
52
+ " check(f\"categorical #{trial}: KL = sum_(n>=2) E_q[l^n]/n!\", abs(ms - kl), 1e-12)\n",
53
+ " print(f\" KL = {kl:.5f} E[k2] - KL = {bias2:+.5f} (leading term -E[l^3]/6 = {-float((q*logr**3).sum())/6:+.5f})\")\n",
54
+ "# pointwise positivity\n",
55
+ "rg = torch.exp(torch.linspace(-12, 12, 100001))\n",
56
+ "k1g, k2g, k3g = estimators(rg.log())\n",
57
+ "check(\"k3 >= 0 and k2 >= 0 pointwise (min over a log-grid of r)\", max(0.0, -float(min(k3g.min(), k2g.min()))), 0.0)\n",
58
+ "print(f\" k1 < 0 on r > 1 (fraction of log-grid where k1<0: {float((k1g<0).double().mean()):.3f}) -> k1 is a noisy, sign-changing estimator\")"
59
+ ],
60
+ "outputs": [
61
+ {
62
+ "output_type": "stream",
63
+ "name": "stdout",
64
+ "text": [
65
+ "CHECK|exact|categorical #0: E_q[k1] = KL(q||p)|2.776e-17|tol=1.0e-14|PASS\n",
66
+ "CHECK|exact|categorical #0: E_q[k3] = KL(q||p)|2.914e-16|tol=1.0e-14|PASS\n",
67
+ "CHECK|exact|categorical #0: bias(k2) = -sum_(n>=3) E[l^n]/n! (series, 40 terms)|2.810e-16|tol=1.0e-12|PASS\n",
68
+ "CHECK|exact|categorical #0: KL = sum_(n>=2) E_q[l^n]/n!|2.776e-16|tol=1.0e-12|PASS\n",
69
+ " KL = 0.10605 E[k2] - KL = -0.01866 (leading term -E[l^3]/6 = -0.01211)\n",
70
+ "CHECK|exact|categorical #1: E_q[k1] = KL(q||p)|1.110e-16|tol=1.0e-14|PASS\n",
71
+ "CHECK|exact|categorical #1: E_q[k3] = KL(q||p)|2.220e-16|tol=1.0e-14|PASS\n",
72
+ "CHECK|exact|categorical #1: bias(k2) = -sum_(n>=3) E[l^n]/n! (series, 40 terms)|1.943e-16|tol=1.0e-12|PASS\n",
73
+ "CHECK|exact|categorical #1: KL = sum_(n>=2) E_q[l^n]/n!|2.220e-16|tol=1.0e-12|PASS\n",
74
+ " KL = 0.28620 E[k2] - KL = +0.01383 (leading term -E[l^3]/6 = +0.06153)\n",
75
+ "CHECK|exact|categorical #2: E_q[k1] = KL(q||p)|0.000e+00|tol=1.0e-14|PASS\n",
76
+ "CHECK|exact|categorical #2: E_q[k3] = KL(q||p)|0.000e+00|tol=1.0e-14|PASS\n",
77
+ "CHECK|exact|categorical #2: bias(k2) = -sum_(n>=3) E[l^n]/n! (series, 40 terms)|5.551e-17|tol=1.0e-12|PASS\n",
78
+ "CHECK|exact|categorical #2: KL = sum_(n>=2) E_q[l^n]/n!|1.110e-16|tol=1.0e-12|PASS\n",
79
+ " KL = 0.61184 E[k2] - KL = +0.15474 (leading term -E[l^3]/6 = +0.31903)\n",
80
+ "CHECK|exact|k3 >= 0 and k2 >= 0 pointwise (min over a log-grid of r)|0.000e+00|tol=0.0e+00|PASS\n",
81
+ " k1 < 0 on r > 1 (fraction of log-grid where k1<0: 0.500) -> k1 is a noisy, sign-changing estimator\n"
82
+ ]
83
+ }
84
+ ]
85
+ },
86
+ {
87
+ "cell_type": "markdown",
88
+ "metadata": {},
89
+ "source": [
90
+ "## 2. Gaussian closed forms (equal variance, shift mu => KL = mu^2/2). With s2 = mu^2:\n",
91
+ " Var(k1) = s2, Var(k2) = s2^3/4 + s2^2/2, Var(k3) = e^{s2} - 1 - s2, bias(k2) = s2^2/8. Verified by Gauss-Hermite quadrature."
92
+ ]
93
+ },
94
+ {
95
+ "cell_type": "code",
96
+ "execution_count": 3,
97
+ "metadata": {},
98
+ "source": [
99
+ "xs, ws = np.polynomial.hermite_e.hermegauss(120); ws = ws / ws.sum()\n",
100
+ "def gauss_moments(shift):\n",
101
+ " x = torch.tensor(xs); w = torch.tensor(ws)\n",
102
+ " logr = shift * x - 0.5 * shift ** 2 # q = N(0,1), p = N(shift,1): log p/q = shift*x - shift^2/2 (E_q[r] = 1)\n",
103
+ " k = estimators(logr)\n",
104
+ " kl = shift ** 2 / 2\n",
105
+ " out = []\n",
106
+ " for kk in k:\n",
107
+ " m = float((w * kk).sum()); v = float((w * kk ** 2).sum()) - m ** 2\n",
108
+ " out.append((m, v))\n",
109
+ " return kl, out\n",
110
+ "print(\" KL est mean-KL (bias) std/KL closed-form std/KL\")\n",
111
+ "for shift in (0.1, math.sqrt(2 * 0.05), 1.0):\n",
112
+ " kl, out = gauss_moments(shift)\n",
113
+ " s2 = shift ** 2\n",
114
+ " cf = [s2, s2 ** 3 / 4 + s2 ** 2 / 2, math.exp(s2) - 1 - s2]\n",
115
+ " cb = [0.0, s2 ** 2 / 8, 0.0]\n",
116
+ " for i, nm in enumerate((\"k1\", \"k2\", \"k3\")):\n",
117
+ " m, v = out[i]\n",
118
+ " print(f\" {kl:.4f} {nm} {m-kl:+.3e} {math.sqrt(v)/kl:9.4f} {math.sqrt(cf[i])/kl:9.4f}\")\n",
119
+ " check(f\"Gaussian shift={shift:.3f}: {nm} bias == closed form\", abs((m - kl) - cb[i]), 1e-12)\n",
120
+ " check(f\"Gaussian shift={shift:.3f}: {nm} variance == closed form\", abs(v - cf[i]) / cf[i], 1e-10)\n",
121
+ "# compare with the table in Schulman's note (KL=0.005 and KL=0.5): std/KL = 20, 1.42, 1.42 and 2, 1.73, 1.7 ; bias(k2)/KL = 0.002, 0.25\n",
122
+ "kl, out = gauss_moments(0.1)\n",
123
+ "check(\"matches Schulman's table at KL=0.005: std(k1)/KL ~ 20\", abs(math.sqrt(out[0][1]) / kl - 20.0), 0.2)\n",
124
+ "kl, out = gauss_moments(1.0)\n",
125
+ "check(\"matches Schulman's table at KL=0.5: bias(k2)/KL = 0.25\", abs((out[1][0] - kl) / kl - 0.25), 1e-9)"
126
+ ],
127
+ "outputs": [
128
+ {
129
+ "output_type": "stream",
130
+ "name": "stdout",
131
+ "text": [
132
+ " KL est mean-KL (bias) std/KL closed-form std/KL\n",
133
+ " 0.0050 k1 +1.735e-18 20.0000 20.0000\n",
134
+ "CHECK|exact|Gaussian shift=0.100: k1 bias == closed form|1.735e-18|tol=1.0e-12|PASS\n",
135
+ "CHECK|exact|Gaussian shift=0.100: k1 variance == closed form|1.735e-16|tol=1.0e-10|PASS\n",
136
+ " 0.0050 k2 +1.250e-05 1.4177 1.4177\n",
137
+ "CHECK|exact|Gaussian shift=0.100: k2 bias == closed form|1.220e-18|tol=1.0e-12|PASS\n",
138
+ "CHECK|exact|Gaussian shift=0.100: k2 variance == closed form|1.349e-16|tol=1.0e-10|PASS\n",
139
+ " 0.0050 k3 +2.602e-18 1.4166 1.4166\n",
140
+ "CHECK|exact|Gaussian shift=0.100: k3 bias == closed form|2.602e-18|tol=1.0e-12|PASS\n",
141
+ "CHECK|exact|Gaussian shift=0.100: k3 variance == closed form|2.200e-12|tol=1.0e-10|PASS\n",
142
+ " 0.0500 k1 +1.388e-17 6.3246 6.3246\n",
143
+ "CHECK|exact|Gaussian shift=0.316: k1 bias == closed form|1.388e-17|tol=1.0e-12|PASS\n",
144
+ "CHECK|exact|Gaussian shift=0.316: k1 variance == closed form|2.776e-16|tol=1.0e-10|PASS\n",
145
+ " 0.0500 k2 +1.250e-03 1.4491 1.4491\n",
146
+ "CHECK|exact|Gaussian shift=0.316: k2 bias == closed form|1.475e-17|tol=1.0e-12|PASS\n",
147
+ "CHECK|exact|Gaussian shift=0.316: k2 variance == closed form|0.000e+00|tol=1.0e-10|PASS\n",
148
+ " 0.0500 k3 +1.388e-17 1.4382 1.4382\n",
149
+ "CHECK|exact|Gaussian shift=0.316: k3 bias == closed form|1.388e-17|tol=1.0e-12|PASS\n",
150
+ "CHECK|exact|Gaussian shift=0.316: k3 variance == closed form|1.543e-14|tol=1.0e-10|PASS\n",
151
+ " 0.5000 k1 +0.000e+00 2.0000 2.0000\n",
152
+ "CHECK|exact|Gaussian shift=1.000: k1 bias == closed form|0.000e+00|tol=1.0e-12|PASS\n",
153
+ "CHECK|exact|Gaussian shift=1.000: k1 variance == closed form|2.220e-16|tol=1.0e-10|PASS\n",
154
+ " 0.5000 k2 +1.250e-01 1.7321 1.7321\n",
155
+ "CHECK|exact|Gaussian shift=1.000: k2 bias == closed form|1.110e-16|tol=1.0e-12|PASS\n",
156
+ "CHECK|exact|Gaussian shift=1.000: k2 variance == closed form|5.921e-16|tol=1.0e-10|PASS\n",
157
+ " 0.5000 k3 +1.110e-16 1.6950 1.6950\n",
158
+ "CHECK|exact|Gaussian shift=1.000: k3 bias == closed form|1.110e-16|tol=1.0e-12|PASS\n",
159
+ "CHECK|exact|Gaussian shift=1.000: k3 variance == closed form|1.546e-16|tol=1.0e-10|PASS\n",
160
+ "CHECK|exact|matches Schulman's table at KL=0.005: std(k1)/KL ~ 20|0.000e+00|tol=2.0e-01|PASS\n",
161
+ "CHECK|exact|matches Schulman's table at KL=0.5: bias(k2)/KL = 0.25|2.220e-16|tol=1.0e-09|PASS\n"
162
+ ]
163
+ }
164
+ ]
165
+ },
166
+ {
167
+ "cell_type": "markdown",
168
+ "metadata": {},
169
+ "source": [
170
+ "## 3. Heavy tails: Var(k3) is infinite when chi^2(p||q) is; one token the reference loves and the policy nearly never samples"
171
+ ]
172
+ },
173
+ {
174
+ "cell_type": "code",
175
+ "execution_count": 4,
176
+ "metadata": {},
177
+ "source": [
178
+ "q = torch.tensor([0.001, 0.999]); p = torch.tensor([0.30, 0.70])\n",
179
+ "logr = (p / q).log(); k1, k2, k3 = estimators(logr)\n",
180
+ "kl = float((q * (q / p).log()).sum())\n",
181
+ "for nm, k in ((\"k1\", k1), (\"k2\", k2), (\"k3\", k3)):\n",
182
+ " m = float((q * k).sum()); v = float((q * k ** 2).sum()) - m ** 2\n",
183
+ " print(f\" {nm}: mean {m:+.5f} std {math.sqrt(v):.5f} (KL = {kl:.5f}) max value {float(k.max()):.1f}\")\n",
184
+ "chi2_pq = float((p ** 2 / q).sum() - 1)\n",
185
+ "print(f\" chi2(p||q) = E_q[r^2]-1 = {chi2_pq:.1f} -> Var(k3) is dominated by one rare token with r = {float((p/q)[0]):.0f}\")\n",
186
+ "check(\"E_q[r^2] - 1 = chi2(p||q) exactly\", abs(float((q * (p / q) ** 2).sum()) - 1 - chi2_pq), 1e-9)"
187
+ ],
188
+ "outputs": [
189
+ {
190
+ "output_type": "stream",
191
+ "name": "stdout",
192
+ "text": [
193
+ " k1: mean +0.34961 std 0.19152 (KL = 0.34961) max value 0.4\n",
194
+ " k2: mean +0.07946 std 0.51214 (KL = 0.34961) max value 16.3\n",
195
+ " k3: mean +0.34961 std 9.26842 (KL = 0.34961) max value 293.3\n",
196
+ " chi2(p||q) = E_q[r^2]-1 = 89.5 -> Var(k3) is dominated by one rare token with r = 300\n",
197
+ "CHECK|exact|E_q[r^2] - 1 = chi2(p||q) exactly|0.000e+00|tol=1.0e-09|PASS\n"
198
+ ]
199
+ }
200
+ ]
201
+ },
202
+ {
203
+ "cell_type": "markdown",
204
+ "metadata": {},
205
+ "source": [
206
+ "## 4. GRADIENTS (on-policy, single-step categorical policy with logits theta; samples x ~ q_theta DETACHED, gradient flows through q_theta in r)\n",
207
+ " k1-as-loss: E[grad k1] = E[grad log q] = 0 -> useless as a regulariser\n",
208
+ " k2-as-loss: E[grad k2] = E[(log q - log p) grad log q] = grad KL(q||p) (the reverse KL we meant)\n",
209
+ " k3-as-loss: E[grad k3] = E[(1 - r) grad log q] = -sum_x p grad log q = grad KL(p||q) (the FORWARD KL)\n",
210
+ " k1-in-reward (detached coefficient): E[(log q - log p) grad log q] = grad KL(q||p)"
211
+ ]
212
+ },
213
+ {
214
+ "cell_type": "code",
215
+ "execution_count": 5,
216
+ "metadata": {},
217
+ "source": [
218
+ "gen = torch.Generator().manual_seed(7)\n",
219
+ "K = 6\n",
220
+ "th_ref = torch.randn(K, generator=gen) * 1.0\n",
221
+ "def grads(delta):\n",
222
+ " th = (th_ref + delta * torch.randn(K, generator=gen)).clone().requires_grad_(True)\n",
223
+ " q = torch.softmax(th, 0); p = torch.softmax(th_ref, 0)\n",
224
+ " lq, lp = torch.log_softmax(th, 0), torch.log_softmax(th_ref, 0)\n",
225
+ " qd = q.detach()\n",
226
+ " r = (lp - lq).exp()\n",
227
+ " # expectation over x ~ q (weights detached) of the gradient of each estimator\n",
228
+ " g = {}\n",
229
+ " g['k1'] = torch.autograd.grad((qd * (-(lp - lq))).sum(), th, retain_graph=True)[0]\n",
230
+ " g['k2'] = torch.autograd.grad((qd * 0.5 * (lp - lq) ** 2).sum(), th, retain_graph=True)[0]\n",
231
+ " g['k3'] = torch.autograd.grad((qd * (r - 1 - (lp - lq))).sum(), th, retain_graph=True)[0]\n",
232
+ " # k1 in reward: detached coefficient (log q - log p) times score\n",
233
+ " g['k1r'] = torch.autograd.grad((qd * (lq - lp).detach() * lq).sum(), th, retain_graph=True)[0]\n",
234
+ " g['rev'] = torch.autograd.grad((q * (lq - lp)).sum(), th, retain_graph=True)[0] # grad KL(q||p), exact\n",
235
+ " g['fwd'] = torch.autograd.grad((p * (lp - lq)).sum(), th, retain_graph=True)[0] # grad KL(p||q), exact\n",
236
+ " return g\n",
237
+ "g = grads(0.8)\n",
238
+ "check(\"E[grad k1] = 0 (k1 as a loss carries no regularising gradient)\", float(g['k1'].abs().max()), 1e-14)\n",
239
+ "check(\"E[grad k2] == grad KL(q||p) (reverse KL)\", maxabs(g['k2'], g['rev']), 1e-14)\n",
240
+ "check(\"k1-in-reward (detached coefficient) == grad KL(q||p)\", maxabs(g['k1r'], g['rev']), 1e-14)\n",
241
+ "check(\"E[grad k3] == grad KL(p||q) (FORWARD KL, not the one k3 estimates)\", maxabs(g['k3'], g['fwd']), 1e-14)\n",
242
+ "print(f\" ||grad KL(q||p) - grad KL(p||q)|| / ||grad KL(q||p)|| = {float((g['rev']-g['fwd']).norm()/g['rev'].norm()):.3f} (different vectors)\")\n",
243
+ "print(\" relative gap between the two gradients as q -> p (first-order agreement, 'k3 is a first-order approximation'):\")\n",
244
+ "prev = None\n",
245
+ "for delta in (0.4, 0.2, 0.1, 0.05, 0.025):\n",
246
+ " gen.manual_seed(7); _ = torch.randn(K, generator=gen) # same direction each time\n",
247
+ " gg = grads(delta)\n",
248
+ " gap = float((gg['rev'] - gg['fwd']).norm() / gg['rev'].norm())\n",
249
+ " print(f\" delta={delta:5.3f} gap = {gap:.4f}\")\n",
250
+ " prev = gap\n",
251
+ "assert prev < 0.05\n",
252
+ "check(\"gradient gap forward vs reverse KL vanishes as delta -> 0 (gap at delta=0.025 < 5%)\", 0.0, 0.0)"
253
+ ],
254
+ "outputs": [
255
+ {
256
+ "output_type": "stream",
257
+ "name": "stdout",
258
+ "text": [
259
+ "CHECK|exact|E[grad k1] = 0 (k1 as a loss carries no regularising gradient)|5.551e-17|tol=1.0e-14|PASS\n",
260
+ "CHECK|exact|E[grad k2] == grad KL(q||p) (reverse KL)|5.551e-17|tol=1.0e-14|PASS\n",
261
+ "CHECK|exact|k1-in-reward (detached coefficient) == grad KL(q||p)|5.551e-17|tol=1.0e-14|PASS\n",
262
+ "CHECK|exact|E[grad k3] == grad KL(p||q) (FORWARD KL, not the one k3 estimates)|8.327e-17|tol=1.0e-14|PASS\n",
263
+ " ||grad KL(q||p) - grad KL(p||q)|| / ||grad KL(q||p)|| = 0.215 (different vectors)\n",
264
+ " relative gap between the two gradients as q -> p (first-order agreement, 'k3 is a first-order approximation'):\n",
265
+ " delta=0.400 gap = 0.1533\n",
266
+ " delta=0.200 gap = 0.0922\n",
267
+ " delta=0.100 gap = 0.0505\n",
268
+ " delta=0.050 gap = 0.0264\n",
269
+ " delta=0.025 gap = 0.0135\n",
270
+ "CHECK|exact|gradient gap forward vs reverse KL vanishes as delta -> 0 (gap at delta=0.025 < 5%)|0.000e+00|tol=0.0e+00|PASS\n"
271
+ ]
272
+ }
273
+ ]
274
+ },
275
+ {
276
+ "cell_type": "markdown",
277
+ "metadata": {},
278
+ "source": [
279
+ "## 5. SEQUENCES: per-token k2/k3 'as loss' ignore that earlier tokens change the later contexts\n",
280
+ " true: grad KL_seq(pi||ref) = E[ (sum_t l_t) * score(o) ] = E[ sum_t (sum_{t'>=t} l_t') s_t ] (\"k1 in reward\" with reward-to-go)\n",
281
+ " per-token k2 loss, summed over t: E[ sum_t l_t s_t ] = true - sum_t E[ KL_t(s_t) * score(o_<t) ] (missing prefix-score term)"
282
+ ]
283
+ },
284
+ {
285
+ "cell_type": "code",
286
+ "execution_count": 6,
287
+ "metadata": {},
288
+ "source": [
289
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
290
+ "th = lm.theta0.clone(); th_r = perturb(th, 1.0, seed=2)\n",
291
+ "pi = lm.probs(th)\n",
292
+ "lpi, lref = lm.token_logprobs(th), lm.token_logprobs(th_r)\n",
293
+ "l_tok = (lpi - lref) * lm.mask # l_t = log pi - log ref = -log r_t\n",
294
+ "Ttens = token_score_tensor(lm, th) # [N, L, P]\n",
295
+ "S = Ttens.sum(1)\n",
296
+ "KL_seq = float((pi * l_tok.sum(1)).sum())\n",
297
+ "t_ = th.clone().requires_grad_(True)\n",
298
+ "KLt = (lm.probs(t_) * (lm.seq_logprob(t_) - lm.seq_logprob(th_r))).sum()\n",
299
+ "g_true = torch.autograd.grad(KLt, t_)[0]\n",
300
+ "g_seq = (pi[:, None] * l_tok.sum(1)[:, None] * S).sum(0)\n",
301
+ "rtg = l_tok.flip(1).cumsum(1).flip(1) * lm.mask\n",
302
+ "g_rtg = (pi[:, None] * (Ttens * rtg[:, :, None]).sum(1)).sum(0)\n",
303
+ "g_tok = (pi[:, None] * (Ttens * l_tok[:, :, None]).sum(1)).sum(0) # per-token k2-as-loss gradient, summed over t\n",
304
+ "check(\"E[(sum_t l_t) * score] == grad KL_seq(pi||ref)\", maxabs(g_seq, g_true), 1e-14)\n",
305
+ "check(\"reward-to-go form E[sum_t (sum_{t'>=t} l_t') s_t] == grad KL_seq\", maxabs(g_rtg, g_true), 1e-14)\n",
306
+ "print(f\" per-token k2-as-loss gradient vs true sequence-KL gradient: relative error {float((g_tok-g_true).norm()/g_true.norm()):.4f}\")\n",
307
+ "# the missing term: sum_t E[ KL_t(s_t) * score(o_<t) ] where KL_t(s) = KL(pi(.|s) || ref(.|s)) and score(o_<t) = sum_{s<t} s_s\n",
308
+ "prefix_score = torch.cumsum(Ttens, 1) - Ttens # sum_{s<t} s_s\n",
309
+ "KLt_tok = torch.zeros(lm.N, lm.L)\n",
310
+ "for i, sq in enumerate(lm.seqs):\n",
311
+ " for t in range(len(sq)):\n",
312
+ " cp, cr = lm.cond_probs(th, sq[:t]), lm.cond_probs(th_r, sq[:t])\n",
313
+ " KLt_tok[i, t] = (cp * (cp.log() - cr.log())).sum()\n",
314
+ "missing = (pi[:, None] * (KLt_tok[:, :, None] * prefix_score).sum(1)).sum(0)\n",
315
+ "check(\"per-token k2 loss gradient == true gradient - sum_t E[KL_t * prefix score] (exact identity)\", maxabs(g_tok, g_true - missing), 1e-13)\n",
316
+ "# per-token k3 loss gradient equals the gradient of the sum of FORWARD per-state KLs with frozen context distribution\n",
317
+ "r_tok = (lref - lpi).exp() * lm.mask + (1 - lm.mask)\n",
318
+ "g_k3 = (pi[:, None] * (Ttens * ((1 - r_tok) * lm.mask)[:, :, None]).sum(1)).sum(0)\n",
319
+ "t_ = th.clone().requires_grad_(True)\n",
320
+ "tot = 0.0\n",
321
+ "for s in lm.prefixes():\n",
322
+ " pr = 1.0\n",
323
+ " for k in range(len(s)): pr *= float(lm.cond_probs(th, s[:k])[s[k]]) # P_pi(context = s) (detached)\n",
324
+ " cp_t, cr = lm.cond_probs(t_, s), lm.cond_probs(th_r, s)\n",
325
+ " tot = tot + pr * (cr * (cr.log() - cp_t.log())).sum() # KL(ref(.|s) || pi_theta(.|s)) with frozen context weight\n",
326
+ "g_fwd_frozen = torch.autograd.grad(tot, t_)[0]\n",
327
+ "check(\"per-token k3-as-loss gradient == grad of sum_s P_pi(s) KL(ref(.|s)||pi_theta(.|s)) (context weights frozen)\", maxabs(g_k3, g_fwd_frozen), 1e-13)\n",
328
+ "# values: sequence-level KL from the three per-token estimators\n",
329
+ "k1_seq = float((pi * l_tok.sum(1)).sum())\n",
330
+ "k3_tok = (torch.expm1(lref - lpi) - (lref - lpi)) * lm.mask\n",
331
+ "k2_tok = 0.5 * (lpi - lref) ** 2 * lm.mask\n",
332
+ "print(f\" sequence KL = {KL_seq:.5f}; E[sum_t k1] = {k1_seq:.5f}; E[sum_t k3] = {float((pi*k3_tok.sum(1)).sum()):.5f}; E[sum_t k2] = {float((pi*k2_tok.sum(1)).sum()):.5f}\")\n",
333
+ "check(\"E[sum_t k3_t] = KL_seq (per-token estimator is unbiased for the sequence KL value)\", abs(float((pi * k3_tok.sum(1)).sum()) - KL_seq), 1e-13)"
334
+ ],
335
+ "outputs": [
336
+ {
337
+ "output_type": "stream",
338
+ "name": "stdout",
339
+ "text": [
340
+ "CHECK|exact|E[(sum_t l_t) * score] == grad KL_seq(pi||ref)|7.633e-17|tol=1.0e-14|PASS\n",
341
+ "CHECK|exact|reward-to-go form E[sum_t (sum_{t'>=t} l_t') s_t] == grad KL_seq|8.327e-17|tol=1.0e-14|PASS\n",
342
+ " per-token k2-as-loss gradient vs true sequence-KL gradient: relative error 0.0376\n",
343
+ "CHECK|exact|per-token k2 loss gradient == true gradient - sum_t E[KL_t * prefix score] (exact identity)|9.714e-17|tol=1.0e-13|PASS\n",
344
+ "CHECK|exact|per-token k3-as-loss gradient == grad of sum_s P_pi(s) KL(ref(.|s)||pi_theta(.|s)) (context weights frozen)|5.898e-17|tol=1.0e-13|PASS\n",
345
+ " sequence KL = 0.01885; E[sum_t k1] = 0.01885; E[sum_t k3] = 0.01885; E[sum_t k2] = 0.02103\n",
346
+ "CHECK|exact|E[sum_t k3_t] = KL_seq (per-token estimator is unbiased for the sequence KL value)|6.245e-17|tol=1.0e-13|PASS\n"
347
+ ]
348
+ }
349
+ ]
350
+ },
351
+ {
352
+ "cell_type": "markdown",
353
+ "metadata": {},
354
+ "source": [
355
+ "## 6. Naive vs stable evaluation of k3 (fp32): (r-1)-log r cancels catastrophically when |log r| is small"
356
+ ]
357
+ },
358
+ {
359
+ "cell_type": "code",
360
+ "execution_count": 7,
361
+ "metadata": {},
362
+ "source": [
363
+ "lr32 = torch.tensor([1e-1, 1e-2, 1e-3, 1e-4, 1e-5], dtype=torch.float32)\n",
364
+ "naive = (lr32.exp() - 1) - lr32\n",
365
+ "stable = torch.expm1(lr32) - lr32\n",
366
+ "truth = (torch.expm1(lr32.double()) - lr32.double())\n",
367
+ "print(\" log r naive fp32 expm1 fp32 fp64 truth naive rel.err stable rel.err\")\n",
368
+ "for a, n_, s_, t_ in zip(lr32.tolist(), naive.tolist(), stable.tolist(), truth.tolist()):\n",
369
+ " print(f\" {a:7.0e} {n_:+.4e} {s_:+.4e} {t_:+.4e} {abs(n_-t_)/t_:9.2e} {abs(s_-t_)/t_:9.2e}\")\n",
370
+ "check(\"expm1 form: relative error < 1e-3 for |log r| >= 1e-4 (fp32)\", float(((stable.double() - truth).abs() / truth)[:4].max()), 1e-3)\n",
371
+ "assert float(((naive.double() - truth).abs() / truth)[3]) > 0.1\n",
372
+ "check(\"naive form is wrong by >10% at |log r| = 1e-4 (fp32)\", 0.0, 0.0)\n",
373
+ "# The expm1 form removes the cancellation inside exp(x)-1 but NOT the one in expm1(x) - x ~ x^2/2: its worst-case relative error is ~ 2u/|x|.\n",
374
+ "# Worst case over a dense random grid per decade (fp32, both signs), vs the 2u/|x|_min law; and a series fallback for small |x|.\n",
375
+ "u32 = 2.0 ** -24\n",
376
+ "gen_n = torch.Generator().manual_seed(11)\n",
377
+ "def k3_expm1(x): return torch.expm1(x) - x\n",
378
+ "def k3_hybrid(x):\n",
379
+ " ser = x * x / 2 * (1 + x / 3 + x * x / 12) # Taylor of expm1(x)-x, error ~ x^5/120 relative x^3/60\n",
380
+ " return torch.where(x.abs() < 1e-2, ser, k3_expm1(x))\n",
381
+ "print(\" |x| decade worst rel.err expm1 form 2u/|x|min worst rel.err hybrid (series for |x|<1e-2)\")\n",
382
+ "worst_ratio, worst_hybrid = 0.0, 0.0\n",
383
+ "for lo in (1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6):\n",
384
+ " hi = 10 * lo\n",
385
+ " xg = torch.exp(torch.rand(400_000, generator=gen_n, dtype=torch.float64) * math.log(10) + math.log(lo)).float()\n",
386
+ " xg = torch.cat([xg, -xg]); tr = torch.expm1(xg.double()) - xg.double()\n",
387
+ " re = float(((k3_expm1(xg).double() - tr).abs() / tr).max()); rh = float(((k3_hybrid(xg).double() - tr).abs() / tr).max())\n",
388
+ " law = 2 * u32 / lo\n",
389
+ " worst_ratio = max(worst_ratio, re / law); worst_hybrid = max(worst_hybrid, rh)\n",
390
+ " print(f\" [{lo:.0e},{hi:.0e}] {re:12.3e} {law:10.2e} {rh:12.3e}\")\n",
391
+ "check(\"expm1 form: worst-case relative error <= 2u/|x| (max ratio to the law)\", worst_ratio, 1.0)\n",
392
+ "check(\"hybrid (Taylor for |x|<1e-2, expm1 form above): worst relative error over |x| in [1e-6, 1)\", worst_hybrid, 1e-4)\n",
393
+ "print(\"done; checks:\", len(CHECKS))"
394
+ ],
395
+ "outputs": [
396
+ {
397
+ "output_type": "stream",
398
+ "name": "stdout",
399
+ "text": [
400
+ " log r naive fp32 expm1 fp32 fp64 truth naive rel.err stable rel.err\n",
401
+ " 1e-01 +5.1710e-03 +5.1709e-03 +5.1709e-03 8.79e-06 1.49e-07\n",
402
+ " 1e-02 +5.0178e-05 +5.0167e-05 +5.0167e-05 2.14e-04 9.17e-06\n",
403
+ " 1e-03 +5.2352e-07 +5.0012e-07 +5.0017e-07 4.67e-02 9.30e-05\n",
404
+ " 1e-04 +1.6596e-08 +4.9986e-09 +5.0002e-09 2.32e+00 3.17e-04\n",
405
+ " 1e-05 +1.3581e-08 +5.0022e-11 +5.0000e-11 2.71e+02 4.41e-04\n",
406
+ "CHECK|exact|expm1 form: relative error < 1e-3 for |log r| >= 1e-4 (fp32)|3.167e-04|tol=1.0e-03|PASS\n",
407
+ "CHECK|exact|naive form is wrong by >10% at |log r| = 1e-4 (fp32)|0.000e+00|tol=0.0e+00|PASS\n",
408
+ " |x| decade worst rel.err expm1 form 2u/|x|min worst rel.err hybrid (series for |x|<1e-2)\n",
409
+ " [1e-01,1e+00] 1.033e-06 1.19e-06 1.033e-06\n",
410
+ " [1e-02,1e-01] 9.291e-06 1.19e-05 9.291e-06\n",
411
+ " [1e-03,1e-02] 1.161e-04 1.19e-04 2.203e-07\n",
412
+ " [1e-04,1e-03] 9.735e-04 1.19e-03 2.035e-07\n",
413
+ " [1e-05,1e-04] 9.034e-03 1.19e-02 1.667e-07\n",
414
+ " [1e-06,1e-05] 1.150e-01 1.19e-01 1.646e-07\n",
415
+ "CHECK|exact|expm1 form: worst-case relative error <= 2u/|x| (max ratio to the law)|9.736e-01|tol=1.0e+00|PASS\n",
416
+ "CHECK|exact|hybrid (Taylor for |x|<1e-2, expm1 form above): worst relative error over |x| in [1e-6, 1)|9.291e-06|tol=1.0e-04|PASS\n",
417
+ "done; checks: 48\n"
418
+ ]
419
+ }
420
+ ]
421
+ }
422
+ ],
423
+ "metadata": {
424
+ "kernelspec": {
425
+ "display_name": "Python 3",
426
+ "language": "python",
427
+ "name": "python3"
428
+ },
429
+ "language_info": {
430
+ "name": "python",
431
+ "version": "3.12.11"
432
+ }
433
+ },
434
+ "nbformat": 4,
435
+ "nbformat_minor": 4
436
+ }
code/notebooks/0110-reward-shaping-verifier.ipynb ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0110 - Reward shaping for verifier rewards: binary rewards, dead groups, overlong penalties as reward, verifier errors\n",
8
+ "Companion to `notes/0110 - Reward Shaping for Verifier Rewards.pdf`. fp64; exact enumeration on the 121-sequence toy LM."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": 1,
14
+ "metadata": {},
15
+ "source": [
16
+ "import sys, math, itertools\n",
17
+ "sys.path.insert(0, \"..\")\n",
18
+ "from _m1_lib import *\n",
19
+ "import numpy as np\n",
20
+ "lm = TinyLM(V=4, L=4, seed=0, scale=1.0)\n",
21
+ "theta = lm.theta0.clone()\n",
22
+ "R = default_reward(lm)\n",
23
+ "p_seq = lm.probs(theta)\n",
24
+ "S = score_matrix(lm, theta)\n",
25
+ "N = lm.N\n",
26
+ "th = theta.clone().requires_grad_(True)\n",
27
+ "pr = (lm.probs(th) * R).sum()\n",
28
+ "grad_p = torch.autograd.grad(pr, th)[0]\n",
29
+ "p = float(pr.detach())"
30
+ ],
31
+ "outputs": []
32
+ },
33
+ {
34
+ "cell_type": "markdown",
35
+ "metadata": {},
36
+ "source": [
37
+ "## 1. Binary reward: grad p = p (1-p) ( E[score | success] - E[score | failure] )"
38
+ ]
39
+ },
40
+ {
41
+ "cell_type": "code",
42
+ "execution_count": 2,
43
+ "metadata": {},
44
+ "source": [
45
+ "m1 = (p_seq * R)[:, None] * S\n",
46
+ "m0 = (p_seq * (1 - R))[:, None] * S\n",
47
+ "E_s_succ = m1.sum(0) / p; E_s_fail = m0.sum(0) / (1 - p)\n",
48
+ "check(\"grad p = p (1-p) (E[s|succ] - E[s|fail])\", maxabs(grad_p, p * (1 - p) * (E_s_succ - E_s_fail)), 1e-14)\n",
49
+ "check(\"grad p = p E[s | success] (rejection-sampling / expert-iteration form)\", maxabs(grad_p, p * E_s_succ), 1e-14)\n",
50
+ "print(f\" p = {p:.4f}; signal scale p(1-p) = {p*(1-p):.4f}\")"
51
+ ],
52
+ "outputs": [
53
+ {
54
+ "output_type": "stream",
55
+ "name": "stdout",
56
+ "text": [
57
+ "CHECK|exact|grad p = p (1-p) (E[s|succ] - E[s|fail])|6.939e-18|tol=1.0e-14|PASS\n",
58
+ "CHECK|exact|grad p = p E[s | success] (rejection-sampling / expert-iteration form)|1.388e-17|tol=1.0e-14|PASS\n",
59
+ " p = 0.1659; signal scale p(1-p) = 0.1384\n"
60
+ ]
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "cell_type": "markdown",
66
+ "metadata": {},
67
+ "source": [
68
+ "## 2. Dead groups. Binary bandit with logit parameter: the group estimators collapse to functions of p_hat = k/G\n",
69
+ " centred (Dr.GRPO): g = p_hat (1 - p_hat) (plug-in variance of the rewards); std-normalised (GRPO): g = sqrt(p_hat (1 - p_hat))"
70
+ ]
71
+ },
72
+ {
73
+ "cell_type": "code",
74
+ "execution_count": 3,
75
+ "metadata": {},
76
+ "source": [
77
+ "def bern_group(G, pp):\n",
78
+ " ks = np.arange(G + 1)\n",
79
+ " w = np.array([math.comb(G, k) * pp ** k * (1 - pp) ** (G - k) for k in ks])\n",
80
+ " ph = ks / G\n",
81
+ " return ks, w, ph\n",
82
+ "def direct_estimator(G, k, pp, kind):\n",
83
+ " \"\"\"(1/G) sum_i A_i * s_i with s = d log pi / d logit: success -> (1-p), failure -> -p (computed from the definitions)\"\"\"\n",
84
+ " Rg = torch.cat([torch.ones(k), torch.zeros(G - k)])\n",
85
+ " mu = Rg.mean(); sd = Rg.std(unbiased=False)\n",
86
+ " A = Rg - mu if kind == 'centre' else (torch.zeros_like(Rg) if float(sd) == 0 else (Rg - mu) / sd)\n",
87
+ " s = torch.where(Rg > 0, torch.tensor(1 - pp), torch.tensor(-pp))\n",
88
+ " return float((A * s).mean())\n",
89
+ "worst = 0.0\n",
90
+ "for G in (2, 4, 16):\n",
91
+ " for pp in (0.05, 0.3, 0.7):\n",
92
+ " ks, w, ph = bern_group(G, pp)\n",
93
+ " for k in ks:\n",
94
+ " worst = max(worst, abs(direct_estimator(G, int(k), pp, 'centre') - ph[k] * (1 - ph[k])),\n",
95
+ " abs(direct_estimator(G, int(k), pp, 'std') - math.sqrt(ph[k] * (1 - ph[k]))))\n",
96
+ "check(\"bandit: centred estimator == p_hat(1-p_hat), std-normalised == sqrt(p_hat(1-p_hat)) (all k, 9 settings)\", worst, 1e-14)\n",
97
+ "# exact mean / variance / SNR of the estimator as a function of p\n",
98
+ "print(\" G=16: p P(dead) E[g_centred] (1-1/G)p(1-p) SNR=E^2/Var E[g_std]/(p(1-p)) vs c_G(p)\")\n",
99
+ "def c_G_formula(G, pp): # from note 0105\n",
100
+ " bp = lambda n, k: math.comb(n, k) * pp ** k * (1 - pp) ** (n - k)\n",
101
+ " a1 = sum(bp(G - 1, K) * (math.sqrt((G - (1 + K)) / (1 + K)) if 1 + K < G else 0.0) for K in range(G))\n",
102
+ " a0 = sum(bp(G - 1, K) * (-math.sqrt(K / (G - K)) if 1 <= K < G else 0.0) for K in range(G))\n",
103
+ " return a1 - a0\n",
104
+ "G = 16\n",
105
+ "snr = {}\n",
106
+ "for pp in (0.01, 0.05, 0.2, 0.5, 0.8, 0.95, 0.99):\n",
107
+ " ks, w, ph = bern_group(G, pp)\n",
108
+ " g = ph * (1 - ph); gs = np.sqrt(ph * (1 - ph))\n",
109
+ " mean, var = float((w * g).sum()), float((w * g ** 2).sum() - (w * g).sum() ** 2)\n",
110
+ " pdead = pp ** G + (1 - pp) ** G\n",
111
+ " mean_std = float((w * gs).sum())\n",
112
+ " snr[pp] = mean ** 2 / var\n",
113
+ " print(f\" {pp:5.2f} {pdead:7.4f} {mean:10.6f} {(1-1/G)*pp*(1-pp):10.6f} {snr[pp]:8.3f} {mean_std/(pp*(1-pp)):7.4f} vs {c_G_formula(G, pp):7.4f}\")\n",
114
+ " check(f\"E[g_centred] = (1-1/G) p(1-p) (p={pp})\", abs(mean - (1 - 1 / G) * pp * (1 - pp)), 1e-15)\n",
115
+ " check(f\"E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p={pp})\", abs(mean_std / (pp * (1 - pp)) - c_G_formula(G, pp)), 1e-12)\n",
116
+ " check(f\"P(dead) = p^G + (1-p)^G = mass of k in {{0,G}} (p={pp})\", abs(pdead - (w[0] + w[-1])), 1e-15)\n",
117
+ "assert snr[0.5] > snr[0.2] > snr[0.05] > snr[0.01] and snr[0.5] > snr[0.8] > snr[0.95] > snr[0.99]\n",
118
+ "check(\"SNR of the group estimator peaks at p = 1/2 and decays towards both ends (monotone on each side)\", 0.0, 0.0)\n",
119
+ "# dead groups carry exactly zero gradient for every baseline estimator; none-baseline REINFORCE does not\n",
120
+ "Gs = 4\n",
121
+ "dead_succ = [int(np.argmax(R.numpy()))] * 0 + [i for i in range(N) if R[i] == 1][:Gs] # an all-correct group (4 successful sequences)\n",
122
+ "dead_fail = [i for i in range(N) if R[i] == 0][:Gs]\n",
123
+ "def group_gradient(idx, kind):\n",
124
+ " Rg = R[idx]\n",
125
+ " if kind == 'centre': A = Rg - Rg.mean()\n",
126
+ " elif kind == 'std':\n",
127
+ " sd = Rg.std(unbiased=False); A = torch.zeros_like(Rg) if float(sd) == 0 else (Rg - Rg.mean()) / sd\n",
128
+ " elif kind == 'rloo': A = Rg - (Rg.sum() - Rg) / (len(Rg) - 1)\n",
129
+ " elif kind == 'plain01': A = Rg # REINFORCE, no baseline, R in {0,1}\n",
130
+ " elif kind == 'plainpm': A = 2 * Rg - 1 # REINFORCE, no baseline, R in {-1,+1}\n",
131
+ " return (A[:, None] * S[idx]).mean(0)\n",
132
+ "print(\" ||group gradient|| for dead groups (4 samples):\")\n",
133
+ "for kind in ('centre', 'std', 'rloo', 'plain01', 'plainpm'):\n",
134
+ " print(f\" {kind:8s} all-correct: {float(group_gradient(dead_succ, kind).norm()):.4f} all-wrong: {float(group_gradient(dead_fail, kind).norm()):.4f}\")\n",
135
+ "for kind in ('centre', 'std', 'rloo'):\n",
136
+ " check(f\"dead groups have exactly zero gradient ({kind})\", max(float(group_gradient(dead_succ, kind).abs().max()), float(group_gradient(dead_fail, kind).abs().max())), 0.0)\n",
137
+ "assert float(group_gradient(dead_succ, 'plain01').norm()) > 1e-3 and float(group_gradient(dead_fail, 'plainpm').norm()) > 1e-3\n",
138
+ "check(\"without a baseline an all-correct (R in {0,1}) or all-wrong (R in {-1,1}) group still moves the policy\", 0.0, 0.0)"
139
+ ],
140
+ "outputs": [
141
+ {
142
+ "output_type": "stream",
143
+ "name": "stdout",
144
+ "text": [
145
+ "CHECK|exact|bandit: centred estimator == p_hat(1-p_hat), std-normalised == sqrt(p_hat(1-p_hat)) (all k, 9 settings)|1.665e-16|tol=1.0e-14|PASS\n",
146
+ " G=16: p P(dead) E[g_centred] (1-1/G)p(1-p) SNR=E^2/Var E[g_std]/(p(1-p)) vs c_G(p)\n",
147
+ " 0.01 0.8515 0.009281 0.009281 0.165 3.7330 vs 3.7330\n",
148
+ "CHECK|exact|E[g_centred] = (1-1/G) p(1-p) (p=0.01)|0.000e+00|tol=1.0e-15|PASS\n",
149
+ "CHECK|exact|E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p=0.01)|8.882e-16|tol=1.0e-12|PASS\n",
150
+ "CHECK|exact|P(dead) = p^G + (1-p)^G = mass of k in {0,G} (p=0.01)|0.000e+00|tol=1.0e-15|PASS\n",
151
+ " 0.05 0.4401 0.044531 0.044531 0.931 3.2671 vs 3.2671\n",
152
+ "CHECK|exact|E[g_centred] = (1-1/G) p(1-p) (p=0.05)|1.388e-17|tol=1.0e-15|PASS\n",
153
+ "CHECK|exact|E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p=0.05)|4.441e-16|tol=1.0e-12|PASS\n",
154
+ "CHECK|exact|P(dead) = p^G + (1-p)^G = mass of k in {0,G} (p=0.05)|0.000e+00|tol=1.0e-15|PASS\n",
155
+ " 0.20 0.0281 0.150000 0.150000 6.713 2.3462 vs 2.3462\n",
156
+ "CHECK|exact|E[g_centred] = (1-1/G) p(1-p) (p=0.2)|1.110e-16|tol=1.0e-15|PASS\n",
157
+ "CHECK|exact|E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p=0.2)|4.441e-16|tol=1.0e-12|PASS\n",
158
+ "CHECK|exact|P(dead) = p^G + (1-p)^G = mass of k in {0,G} (p=0.2)|0.000e+00|tol=1.0e-15|PASS\n",
159
+ " 0.50 0.0000 0.234375 0.234375 120.000 1.9341 vs 1.9341\n",
160
+ "CHECK|exact|E[g_centred] = (1-1/G) p(1-p) (p=0.5)|0.000e+00|tol=1.0e-15|PASS\n",
161
+ "CHECK|exact|E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p=0.5)|0.000e+00|tol=1.0e-12|PASS\n",
162
+ "CHECK|exact|P(dead) = p^G + (1-p)^G = mass of k in {0,G} (p=0.5)|0.000e+00|tol=1.0e-15|PASS\n",
163
+ " 0.80 0.0281 0.150000 0.150000 6.713 2.3462 vs 2.3462\n",
164
+ "CHECK|exact|E[g_centred] = (1-1/G) p(1-p) (p=0.8)|0.000e+00|tol=1.0e-15|PASS\n",
165
+ "CHECK|exact|E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p=0.8)|0.000e+00|tol=1.0e-12|PASS\n",
166
+ "CHECK|exact|P(dead) = p^G + (1-p)^G = mass of k in {0,G} (p=0.8)|0.000e+00|tol=1.0e-15|PASS\n",
167
+ " 0.95 0.4401 0.044531 0.044531 0.931 3.2671 vs 3.2671\n",
168
+ "CHECK|exact|E[g_centred] = (1-1/G) p(1-p) (p=0.95)|6.939e-18|tol=1.0e-15|PASS\n",
169
+ "CHECK|exact|E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p=0.95)|0.000e+00|tol=1.0e-12|PASS\n",
170
+ "CHECK|exact|P(dead) = p^G + (1-p)^G = mass of k in {0,G} (p=0.95)|0.000e+00|tol=1.0e-15|PASS\n",
171
+ " 0.99 0.8515 0.009281 0.009281 0.165 3.7330 vs 3.7330\n",
172
+ "CHECK|exact|E[g_centred] = (1-1/G) p(1-p) (p=0.99)|3.469e-18|tol=1.0e-15|PASS\n",
173
+ "CHECK|exact|E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p=0.99)|4.441e-16|tol=1.0e-12|PASS\n",
174
+ "CHECK|exact|P(dead) = p^G + (1-p)^G = mass of k in {0,G} (p=0.99)|0.000e+00|tol=1.0e-15|PASS\n",
175
+ "CHECK|exact|SNR of the group estimator peaks at p = 1/2 and decays towards both ends (monotone on each side)|0.000e+00|tol=0.0e+00|PASS\n",
176
+ " ||group gradient|| for dead groups (4 samples):\n",
177
+ " centre all-correct: 0.0000 all-wrong: 0.0000\n",
178
+ " std all-correct: 0.0000 all-wrong: 0.0000\n",
179
+ " rloo all-correct: 0.0000 all-wrong: 0.0000\n",
180
+ " plain01 all-correct: 2.1004 all-wrong: 0.0000\n",
181
+ " plainpm all-correct: 2.1004 all-wrong: 3.8422\n",
182
+ "CHECK|exact|dead groups have exactly zero gradient (centre)|0.000e+00|tol=0.0e+00|PASS\n",
183
+ "CHECK|exact|dead groups have exactly zero gradient (std)|0.000e+00|tol=0.0e+00|PASS\n",
184
+ "CHECK|exact|dead groups have exactly zero gradient (rloo)|0.000e+00|tol=0.0e+00|PASS\n",
185
+ "CHECK|exact|without a baseline an all-correct (R in {0,1}) or all-wrong (R in {-1,1}) group still moves the policy|0.000e+00|tol=0.0e+00|PASS\n"
186
+ ]
187
+ }
188
+ ]
189
+ },
190
+ {
191
+ "cell_type": "markdown",
192
+ "metadata": {},
193
+ "source": [
194
+ "## 3. Reward scale / sign conventions"
195
+ ]
196
+ },
197
+ {
198
+ "cell_type": "code",
199
+ "execution_count": 4,
200
+ "metadata": {},
201
+ "source": [
202
+ "gen = torch.Generator().manual_seed(1)\n",
203
+ "idx = torch.multinomial(p_seq, 8, replacement=True, generator=gen)\n",
204
+ "for kind in ('std',):\n",
205
+ " g01 = group_gradient(idx, kind)\n",
206
+ " Rpm_idx = idx\n",
207
+ " Rg = 2 * R[idx] - 1\n",
208
+ " sd = Rg.std(unbiased=False)\n",
209
+ " gpm = (((Rg - Rg.mean()) / sd)[:, None] * S[idx]).mean(0) if float(sd) > 0 else torch.zeros_like(g01)\n",
210
+ " check(\"GRPO (std-normalised): {0,1} and {-1,+1} rewards give the SAME gradient\", maxabs(g01, gpm), 1e-13)\n",
211
+ "g01 = group_gradient(idx, 'centre')\n",
212
+ "Rg = 2 * R[idx] - 1\n",
213
+ "gpm = ((Rg - Rg.mean())[:, None] * S[idx]).mean(0)\n",
214
+ "check(\"Dr.GRPO (centred): {-1,+1} rewards give exactly 2x the {0,1} gradient\", maxabs(gpm, 2 * g01), 1e-13)"
215
+ ],
216
+ "outputs": [
217
+ {
218
+ "output_type": "stream",
219
+ "name": "stdout",
220
+ "text": [
221
+ "CHECK|exact|GRPO (std-normalised): {0,1} and {-1,+1} rewards give the SAME gradient|0.000e+00|tol=1.0e-13|PASS\n",
222
+ "CHECK|exact|Dr.GRPO (centred): {-1,+1} rewards give exactly 2x the {0,1} gradient|0.000e+00|tol=1.0e-13|PASS\n"
223
+ ]
224
+ }
225
+ ]
226
+ },
227
+ {
228
+ "cell_type": "markdown",
229
+ "metadata": {},
230
+ "source": [
231
+ "## 4. Potential-based shaping (finite horizon, gamma = 1): r_t = Phi(s_{t+1}) - Phi(s_t), Phi(terminal) = 0 => shaped return = R - Phi(s_0) => same gradient"
232
+ ]
233
+ },
234
+ {
235
+ "cell_type": "code",
236
+ "execution_count": 5,
237
+ "metadata": {},
238
+ "source": [
239
+ "gen = torch.Generator().manual_seed(3)\n",
240
+ "Phi = {}\n",
241
+ "for pre in lm.prefixes(): Phi[pre] = float(torch.randn(1, generator=gen)) * 2.0\n",
242
+ "for sq in lm.seqs:\n",
243
+ " Phi.setdefault(sq, 0.0) # terminal states: Phi = 0\n",
244
+ "shaping = torch.zeros(lm.N)\n",
245
+ "for i, sq in enumerate(lm.seqs):\n",
246
+ " tot = 0.0\n",
247
+ " for t in range(len(sq)):\n",
248
+ " a, b = sq[:t], sq[:t + 1]\n",
249
+ " tot += Phi[b] - Phi[a]\n",
250
+ " shaping[i] = tot\n",
251
+ "check(\"telescoping: sum_t [Phi(s_t+1) - Phi(s_t)] = -Phi(s_0) for every sequence (Phi(terminal)=0)\", float((shaping + Phi[()]).abs().max()), 1e-14)\n",
252
+ "th = theta.clone().requires_grad_(True)\n",
253
+ "g_shaped = torch.autograd.grad((lm.probs(th) * (R + shaping)).sum(), th)[0]\n",
254
+ "check(\"potential-based shaping leaves grad J unchanged (exact)\", maxabs(g_shaped, grad_p), 1e-14)\n",
255
+ "# a non-potential 'shaping' (terminal potential != 0, e.g. depending on length) changes the objective\n",
256
+ "Phi_bad = dict(Phi)\n",
257
+ "for sq in lm.seqs: Phi_bad[sq] = 0.7 * len(sq)\n",
258
+ "bad = torch.zeros(lm.N)\n",
259
+ "for i, sq in enumerate(lm.seqs):\n",
260
+ " bad[i] = sum(Phi_bad[sq[:t + 1]] - Phi_bad[sq[:t]] for t in range(len(sq)))\n",
261
+ "th = theta.clone().requires_grad_(True)\n",
262
+ "g_bad = torch.autograd.grad((lm.probs(th) * (R + bad)).sum(), th)[0]\n",
263
+ "print(f\" terminal potential 0.7*|o|: ||grad - grad J|| = {float((g_bad-grad_p).norm()):.4f} (||grad J|| = {float(grad_p.norm()):.4f})\")\n",
264
+ "assert float((g_bad - grad_p).norm()) > 1e-3\n",
265
+ "check(\"a terminal potential (length-dependent) is NOT neutral: the gradient changes\", 0.0, 0.0)"
266
+ ],
267
+ "outputs": [
268
+ {
269
+ "output_type": "stream",
270
+ "name": "stdout",
271
+ "text": [
272
+ "CHECK|exact|telescoping: sum_t [Phi(s_t+1) - Phi(s_t)] = -Phi(s_0) for every sequence (Phi(terminal)=0)|4.441e-16|tol=1.0e-14|PASS\n",
273
+ "CHECK|exact|potential-based shaping leaves grad J unchanged (exact)|1.457e-16|tol=1.0e-14|PASS\n",
274
+ " terminal potential 0.7*|o|: ||grad - grad J|| = 0.5800 (||grad J|| = 0.1825)\n",
275
+ "CHECK|exact|a terminal potential (length-dependent) is NOT neutral: the gradient changes|0.000e+00|tol=0.0e+00|PASS\n"
276
+ ]
277
+ }
278
+ ]
279
+ },
280
+ {
281
+ "cell_type": "markdown",
282
+ "metadata": {},
283
+ "source": [
284
+ "## 5. Length penalties: as REWARD the objective is separable; length has no other route to the policy"
285
+ ]
286
+ },
287
+ {
288
+ "cell_type": "code",
289
+ "execution_count": 6,
290
+ "metadata": {},
291
+ "source": [
292
+ "lens = lm.lengths\n",
293
+ "lam = 0.05\n",
294
+ "b = float(p_seq @ R)\n",
295
+ "g_rew = (p_seq[:, None] * (R - lam * lens - (b - lam * float(p_seq @ lens)))[:, None] * S).sum(0) # baselined REINFORCE with shaped reward\n",
296
+ "th = theta.clone().requires_grad_(True)\n",
297
+ "gJ = torch.autograd.grad((lm.probs(th) * R).sum(), th)[0]\n",
298
+ "th = theta.clone().requires_grad_(True)\n",
299
+ "gL = torch.autograd.grad((lm.probs(th) * lens).sum(), th)[0]\n",
300
+ "check(\"reward R - lam|o| : gradient = grad E[R] - lam grad E|o| (separable, exact)\", maxabs(g_rew, gJ - lam * gL), 1e-14)\n",
301
+ "check(\"grad E|o| = E[|o| score] (length can only reach the policy through the score function)\", maxabs(gL, (p_seq[:, None] * lens[:, None] * S).sum(0)), 1e-14)\n",
302
+ "# effective per-sequence rewards of the implicit length weighting of note 0105 vs an explicit penalty\n",
303
+ "R_eff_len = (R - b) / lens\n",
304
+ "print(\" implicit reward of the 1/|o|-normalised GRPO loss: (R-b)/|o| -> not separable, depends on b and multiplies the reward by 1/|o|;\")\n",
305
+ "print(\" explicit penalty R - lam|o| is separable and its trade-off is the single number lam.\")"
306
+ ],
307
+ "outputs": [
308
+ {
309
+ "output_type": "stream",
310
+ "name": "stdout",
311
+ "text": [
312
+ "CHECK|exact|reward R - lam|o| : gradient = grad E[R] - lam grad E|o| (separable, exact)|1.041e-17|tol=1.0e-14|PASS\n",
313
+ "CHECK|exact|grad E|o| = E[|o| score] (length can only reach the policy through the score function)|1.110e-16|tol=1.0e-14|PASS\n",
314
+ " implicit reward of the 1/|o|-normalised GRPO loss: (R-b)/|o| -> not separable, depends on b and multiplies the reward by 1/|o|;\n",
315
+ " explicit penalty R - lam|o| is separable and its trade-off is the single number lam.\n"
316
+ ]
317
+ }
318
+ ]
319
+ },
320
+ {
321
+ "cell_type": "markdown",
322
+ "metadata": {},
323
+ "source": [
324
+ "## 6. Verifier errors. Constant false-accept / false-reject rates only rescale the gradient; an exploitable false-accept class re-targets it."
325
+ ]
326
+ },
327
+ {
328
+ "cell_type": "code",
329
+ "execution_count": 7,
330
+ "metadata": {},
331
+ "source": [
332
+ "aFA, aFR = 0.10, 0.05\n",
333
+ "th = theta.clone().requires_grad_(True)\n",
334
+ "Pc = (lm.probs(th) * R).sum()\n",
335
+ "Jobs = aFA + (1 - aFR - aFA) * Pc # E[R_obs] when errors are independent of the response\n",
336
+ "Robs_expect = R * (1 - aFR) + (1 - R) * aFA # per-response expected observed reward\n",
337
+ "th2 = theta.clone().requires_grad_(True)\n",
338
+ "Jobs2 = (lm.probs(th2) * Robs_expect).sum()\n",
339
+ "g1 = torch.autograd.grad(Jobs, th)[0]; g2 = torch.autograd.grad(Jobs2, th2)[0]\n",
340
+ "check(\"E[R_obs] = aFA + (1-aFR-aFA) P(correct) (value)\", abs(float(Jobs.detach()) - float(Jobs2.detach())), 1e-15)\n",
341
+ "check(\"constant error rates: grad E[R_obs] = (1-aFR-aFA) grad P(correct) (exact rescaling)\", maxabs(g2, (1 - aFR - aFA) * grad_p), 1e-14)\n",
342
+ "# exploitable class: short sequences (length 2 ending in EOS) with an even token get accepted with prob 1\n",
343
+ "expl = torch.tensor([1.0 if (len(sq) == 2 and sq[-1] == lm.EOS and sq[0] % 2 == 0) else 0.0 for sq in lm.seqs]) * (1 - R)\n",
344
+ "th = theta.clone().requires_grad_(True)\n",
345
+ "gE = torch.autograd.grad((lm.probs(th) * expl).sum(), th)[0]\n",
346
+ "P_E = float((p_seq * expl).sum())\n",
347
+ "print(f\" exploit class (always accepted although wrong): P = {P_E:.4f}; ||grad P(correct)|| = {float(grad_p.norm()):.4f}; ||grad P(exploit)|| = {float(gE.norm()):.4f}; cosine = {float(grad_p@gE/(grad_p.norm()*gE.norm())):+.3f}\")\n",
348
+ "th = theta.clone().requires_grad_(True)\n",
349
+ "g_obs = torch.autograd.grad((lm.probs(th) * (R + expl)).sum(), th)[0] # E[R_obs], R_obs = 1 on correct OR exploit\n",
350
+ "check(\"observed-reward gradient (autograd) == grad P(correct) + grad P(exploit)\", maxabs(g_obs, grad_p + gE), 1e-14)\n",
351
+ "print(\" exploit share of the observed-reward gradient norm: %.2f\" % float(gE.norm() / g_obs.norm()))"
352
+ ],
353
+ "outputs": [
354
+ {
355
+ "output_type": "stream",
356
+ "name": "stdout",
357
+ "text": [
358
+ "CHECK|exact|E[R_obs] = aFA + (1-aFR-aFA) P(correct) (value)|0.000e+00|tol=1.0e-15|PASS\n",
359
+ "CHECK|exact|constant error rates: grad E[R_obs] = (1-aFR-aFA) grad P(correct) (exact rescaling)|2.429e-17|tol=1.0e-14|PASS\n",
360
+ " exploit class (always accepted although wrong): P = 0.2462; ||grad P(correct)|| = 0.1825; ||grad P(exploit)|| = 0.2786; cosine = +0.083\n",
361
+ "CHECK|exact|observed-reward gradient (autograd) == grad P(correct) + grad P(exploit)|2.776e-17|tol=1.0e-14|PASS\n",
362
+ " exploit share of the observed-reward gradient norm: 0.81\n"
363
+ ]
364
+ }
365
+ ]
366
+ },
367
+ {
368
+ "cell_type": "markdown",
369
+ "metadata": {},
370
+ "source": [
371
+ "## 7. Group-size / pass@k arithmetic: how many samples until a group is live; the cost of rare successes"
372
+ ]
373
+ },
374
+ {
375
+ "cell_type": "code",
376
+ "execution_count": 8,
377
+ "metadata": {},
378
+ "source": [
379
+ "print(\" p P(live), G=8 G=16 G=64 expected prompts*samples per live group (G=16)\")\n",
380
+ "for pp in (0.001, 0.01, 0.05, 0.2, 0.5):\n",
381
+ " live = [1 - pp ** G - (1 - pp) ** G for G in (8, 16, 64)]\n",
382
+ " print(f\" {pp:5.3f} {live[0]:8.4f} {live[1]:8.4f} {live[2]:8.4f} {16/live[1]:10.1f}\")\n",
383
+ "check(\"P(live) = 1 - p^G - (1-p)^G = pass@G - p^G (identity)\", abs((1 - 0.2 ** 16 - 0.8 ** 16) - ((1 - 0.8 ** 16) - 0.2 ** 16)), 1e-15)\n",
384
+ "print(\"done; checks:\", len(CHECKS))"
385
+ ],
386
+ "outputs": [
387
+ {
388
+ "output_type": "stream",
389
+ "name": "stdout",
390
+ "text": [
391
+ " p P(live), G=8 G=16 G=64 expected prompts*samples per live group (G=16)\n",
392
+ " 0.001 0.0080 0.0159 0.0620 1007.5\n",
393
+ " 0.010 0.0773 0.1485 0.4744 107.7\n",
394
+ " 0.050 0.3366 0.5599 0.9625 28.6\n",
395
+ " 0.200 0.8322 0.9719 1.0000 16.5\n",
396
+ " 0.500 0.9922 1.0000 1.0000 16.0\n",
397
+ "CHECK|exact|P(live) = 1 - p^G - (1-p)^G = pass@G - p^G (identity)|0.000e+00|tol=1.0e-15|PASS\n",
398
+ "done; checks: 40\n"
399
+ ]
400
+ }
401
+ ]
402
+ }
403
+ ],
404
+ "metadata": {
405
+ "kernelspec": {
406
+ "display_name": "Python 3",
407
+ "language": "python",
408
+ "name": "python3"
409
+ },
410
+ "language_info": {
411
+ "name": "python",
412
+ "version": "3.12.11"
413
+ }
414
+ },
415
+ "nbformat": 4,
416
+ "nbformat_minor": 4
417
+ }
code/notebooks/0201-verifier-binary-reward-mdp.ipynb ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "b4fbe81d",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0201 -- Verifier-in-the-loop as a binary-reward MDP\n",
9
+ "\n",
10
+ "Scratch-pad for note `0201`. Everything is fp64, CPU only (`CUDA_VISIBLE_DEVICES=` is set\n",
11
+ "before torch is imported). Each section states the identity, computes it the *naive* way\n",
12
+ "(full enumeration, explicit hand-derived formula), then the *efficient* way (autograd on a\n",
13
+ "log-sum-exp-stable policy, Monte Carlo), and asserts that they agree.\n",
14
+ "\n",
15
+ "Identities verified:\n",
16
+ "1. Score-function gradient `grad J = E[r grad log pi]` against autograd of the exact `J`.\n",
17
+ "2. Token decomposition `grad log pi(y|x) = sum_t grad log pi(y_t|s_t)` and the hand formula `onehot - softmax`.\n",
18
+ "3. Baseline identity `E[grad log pi] = 0`, so any `b(x)` leaves the gradient unbiased.\n",
19
+ "4. Exact variance of the one-sample estimator and the optimal constant baseline `b*`.\n",
20
+ "5. Monte Carlo estimator converges at the `1/sqrt(N)` rate.\n",
21
+ "6. Verifier holes: `E[G] = E[V] - pi(H)` and the softmax-PG ODE `dz_k/dt = pi_k (r_k - J)`.\n",
22
+ "7. Proxy reward model (noisy) vs exact verifier: best-of-n selection error and the `sigma sqrt(2 ln n)` winner's curse."
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "code",
27
+ "execution_count": 1,
28
+ "id": "3b78deed",
29
+ "metadata": {
30
+ "execution": {
31
+ "iopub.execute_input": "2026-10-14T08:49:39.638799Z",
32
+ "iopub.status.busy": "2026-10-14T08:49:39.638648Z",
33
+ "iopub.status.idle": "2026-10-14T08:49:41.790359Z",
34
+ "shell.execute_reply": "2026-10-14T08:49:41.789459Z"
35
+ }
36
+ },
37
+ "outputs": [],
38
+ "source": [
39
+ "import os\n",
40
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
41
+ "import itertools, math, json\n",
42
+ "import numpy as np, torch\n",
43
+ "torch.set_default_dtype(torch.float64)\n",
44
+ "torch.manual_seed(0); np.random.seed(0)\n",
45
+ "CHECKS = []\n",
46
+ "def check(name, err, tol):\n",
47
+ " CHECKS.append((name, float(err), float(tol)))\n",
48
+ " assert err <= tol, (name, float(err), tol)\n",
49
+ " print(f'{name:70s} err={float(err):.3e} (tol {tol:.0e})')"
50
+ ]
51
+ },
52
+ {
53
+ "cell_type": "markdown",
54
+ "id": "0d637e40",
55
+ "metadata": {},
56
+ "source": [
57
+ "## 1. A tabular autoregressive policy over proofs\n",
58
+ "Vocabulary `{0,1,2}`, proof length `T=3` => 27 \"proofs\". The policy has one logit vector per prefix\n",
59
+ "(13 prefixes of length 0,1,2). The verifier `V(y)` is a fixed random 0/1 table with about 30% ones."
60
+ ]
61
+ },
62
+ {
63
+ "cell_type": "code",
64
+ "execution_count": 2,
65
+ "id": "cf164f87",
66
+ "metadata": {
67
+ "execution": {
68
+ "iopub.execute_input": "2026-10-14T08:49:41.792633Z",
69
+ "iopub.status.busy": "2026-10-14T08:49:41.792359Z",
70
+ "iopub.status.idle": "2026-10-14T08:49:41.884373Z",
71
+ "shell.execute_reply": "2026-10-14T08:49:41.883429Z"
72
+ }
73
+ },
74
+ "outputs": [
75
+ {
76
+ "name": "stdout",
77
+ "output_type": "stream",
78
+ "text": [
79
+ "num prefixes 13 num sequences 27 p = E[r] at init will be printed below\n",
80
+ "J(theta0) = 0.30454236036997673\n"
81
+ ]
82
+ },
83
+ {
84
+ "name": "stderr",
85
+ "output_type": "stream",
86
+ "text": [
87
+ "/home/vignesh/.opencode/tmp/ipykernel_442065/734446241.py:26: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.\n",
88
+ "Consider using tensor.detach() first. (Triggered internally at /pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:838.)\n",
89
+ " print('J(theta0) =', float(J))\n"
90
+ ]
91
+ }
92
+ ],
93
+ "source": [
94
+ "Vsz, T = 3, 3\n",
95
+ "prefixes = [()]\n",
96
+ "for L in range(1, T):\n",
97
+ " prefixes += list(itertools.product(range(Vsz), repeat=L))\n",
98
+ "pid = {p: i for i, p in enumerate(prefixes)}\n",
99
+ "seqs = list(itertools.product(range(Vsz), repeat=T))\n",
100
+ "rng = np.random.RandomState(3)\n",
101
+ "r_tab = torch.tensor((rng.rand(len(seqs)) < 0.3).astype(float)) # V(y) in {0,1}\n",
102
+ "print('num prefixes', len(prefixes), 'num sequences', len(seqs), 'p = E[r] at init will be printed below')\n",
103
+ "\n",
104
+ "theta0 = torch.randn(len(prefixes), Vsz) * 0.7\n",
105
+ "\n",
106
+ "def log_pi_seq(theta, y):\n",
107
+ " \"\"\"log pi(y) = sum_t log softmax(theta[prefix_t])[y_t] (log_softmax is the stable form).\"\"\"\n",
108
+ " lp = 0.0\n",
109
+ " for t in range(T):\n",
110
+ " lp = lp + torch.log_softmax(theta[pid[y[:t]]], dim=-1)[y[t]]\n",
111
+ " return lp\n",
112
+ "\n",
113
+ "def exact_J(theta):\n",
114
+ " return sum(torch.exp(log_pi_seq(theta, y)) * r_tab[i] for i, y in enumerate(seqs))\n",
115
+ "\n",
116
+ "th = theta0.clone().requires_grad_(True)\n",
117
+ "J = exact_J(th); J.backward()\n",
118
+ "g_auto = th.grad.clone()\n",
119
+ "print('J(theta0) =', float(J))"
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "markdown",
124
+ "id": "78aebc59",
125
+ "metadata": {},
126
+ "source": [
127
+ "## 2. NAIVE hand-derived gradient: sum over all 27 sequences\n",
128
+ "`grad J = sum_y pi(y) r(y) sum_t grad log pi(y_t | s_t)`, with\n",
129
+ "`grad_{theta[s,:]} log pi(y_t|s_t) = onehot(y_t) - softmax(theta[s,:])` (the softmax-score identity, derived in the note)."
130
+ ]
131
+ },
132
+ {
133
+ "cell_type": "code",
134
+ "execution_count": 3,
135
+ "id": "c3545a51",
136
+ "metadata": {
137
+ "execution": {
138
+ "iopub.execute_input": "2026-10-14T08:49:41.885962Z",
139
+ "iopub.status.busy": "2026-10-14T08:49:41.885798Z",
140
+ "iopub.status.idle": "2026-10-14T08:49:41.895143Z",
141
+ "shell.execute_reply": "2026-10-14T08:49:41.894090Z"
142
+ }
143
+ },
144
+ "outputs": [
145
+ {
146
+ "name": "stdout",
147
+ "output_type": "stream",
148
+ "text": [
149
+ "1. score-function grad (hand, enumerated) vs autograd of exact J err=1.041e-17 (tol 1e-14)\n"
150
+ ]
151
+ }
152
+ ],
153
+ "source": [
154
+ "def hand_grad(theta):\n",
155
+ " th_ = theta.detach()\n",
156
+ " g = torch.zeros_like(th_)\n",
157
+ " for i, y in enumerate(seqs):\n",
158
+ " py = float(torch.exp(log_pi_seq(th_, y)))\n",
159
+ " for t in range(T):\n",
160
+ " s = pid[y[:t]]\n",
161
+ " sm = torch.softmax(th_[s], dim=-1)\n",
162
+ " oh = torch.zeros(Vsz); oh[y[t]] = 1.0\n",
163
+ " g[s] += py * r_tab[i] * (oh - sm)\n",
164
+ " return g\n",
165
+ "\n",
166
+ "g_hand = hand_grad(theta0)\n",
167
+ "check('1. score-function grad (hand, enumerated) vs autograd of exact J', (g_hand - g_auto).abs().max(), 1e-14)"
168
+ ]
169
+ },
170
+ {
171
+ "cell_type": "markdown",
172
+ "id": "da5af085",
173
+ "metadata": {},
174
+ "source": [
175
+ "## 3. Baseline identity: `E[grad log pi] = 0`\n",
176
+ "Because `sum_y pi(y) = 1` for every theta, differentiate it: `0 = sum_y grad pi(y) = sum_y pi(y) grad log pi(y)`."
177
+ ]
178
+ },
179
+ {
180
+ "cell_type": "code",
181
+ "execution_count": 4,
182
+ "id": "097f8049",
183
+ "metadata": {
184
+ "execution": {
185
+ "iopub.execute_input": "2026-10-14T08:49:41.896729Z",
186
+ "iopub.status.busy": "2026-10-14T08:49:41.896575Z",
187
+ "iopub.status.idle": "2026-10-14T08:49:41.926284Z",
188
+ "shell.execute_reply": "2026-10-14T08:49:41.924902Z"
189
+ }
190
+ },
191
+ "outputs": [
192
+ {
193
+ "name": "stdout",
194
+ "output_type": "stream",
195
+ "text": [
196
+ "3. E[grad log pi] = 0 (baseline identity) err=5.898e-17 (tol 1e-15)\n",
197
+ "3. grad unchanged for b in {-3,0,0.3,17} err=6.557e-16 (tol 1e-13)\n"
198
+ ]
199
+ }
200
+ ],
201
+ "source": [
202
+ "def expected_score(theta):\n",
203
+ " th_ = theta.detach(); g = torch.zeros_like(th_)\n",
204
+ " for i, y in enumerate(seqs):\n",
205
+ " py = float(torch.exp(log_pi_seq(th_, y)))\n",
206
+ " for t in range(T):\n",
207
+ " s = pid[y[:t]]; sm = torch.softmax(th_[s], dim=-1)\n",
208
+ " oh = torch.zeros(Vsz); oh[y[t]] = 1.0\n",
209
+ " g[s] += py * (oh - sm)\n",
210
+ " return g\n",
211
+ "check('3. E[grad log pi] = 0 (baseline identity)', expected_score(theta0).abs().max(), 1e-15)\n",
212
+ "\n",
213
+ "# Baseline b constant in y: the gradient E[(r-b) grad log pi] is the same for any b.\n",
214
+ "def hand_grad_b(theta, b):\n",
215
+ " th_ = theta.detach(); g = torch.zeros_like(th_)\n",
216
+ " for i, y in enumerate(seqs):\n",
217
+ " py = float(torch.exp(log_pi_seq(th_, y)))\n",
218
+ " for t in range(T):\n",
219
+ " s = pid[y[:t]]; sm = torch.softmax(th_[s], dim=-1)\n",
220
+ " oh = torch.zeros(Vsz); oh[y[t]] = 1.0\n",
221
+ " g[s] += py * (r_tab[i] - b) * (oh - sm)\n",
222
+ " return g\n",
223
+ "errs = [(hand_grad_b(theta0, b) - g_auto).abs().max() for b in (-3.0, 0.0, 0.3, 17.0)]\n",
224
+ "check('3. grad unchanged for b in {-3,0,0.3,17}', max(errs), 1e-13)"
225
+ ]
226
+ },
227
+ {
228
+ "cell_type": "markdown",
229
+ "id": "30ffac82",
230
+ "metadata": {},
231
+ "source": [
232
+ "## 4. Exact variance of the one-sample estimator and the optimal constant baseline\n",
233
+ "`Var[(r-b) s] = E[(r-b)^2 |s|^2] - |grad J|^2` with `s = grad log pi(y)` flattened. Minimiser:\n",
234
+ "`b* = E[r |s|^2] / E[|s|^2]`."
235
+ ]
236
+ },
237
+ {
238
+ "cell_type": "code",
239
+ "execution_count": 5,
240
+ "id": "944489cb",
241
+ "metadata": {
242
+ "execution": {
243
+ "iopub.execute_input": "2026-10-14T08:49:41.928225Z",
244
+ "iopub.status.busy": "2026-10-14T08:49:41.927849Z",
245
+ "iopub.status.idle": "2026-10-14T08:49:43.333887Z",
246
+ "shell.execute_reply": "2026-10-14T08:49:43.332721Z"
247
+ }
248
+ },
249
+ "outputs": [
250
+ {
251
+ "name": "stdout",
252
+ "output_type": "stream",
253
+ "text": [
254
+ "p = E[r] = 0.3045423603699767 b* = 0.33818921921147005 Var(b=0) = 0.5990480197782424 Var(b=p) = 0.39409891194469415 Var(b*) = 0.3920499407669431\n"
255
+ ]
256
+ },
257
+ {
258
+ "name": "stdout",
259
+ "output_type": "stream",
260
+ "text": [
261
+ "4. argmin_b Var (grid) vs closed-form b* err=1.078e-05 (tol 2e-04)\n",
262
+ "4. Var(b*) closed form vs direct err=5.551e-17 (tol 1e-12)\n",
263
+ "4. MC variance of one-sample estimator vs exact (rel. err.) err=2.500e-03 (tol 2e-02)\n"
264
+ ]
265
+ }
266
+ ],
267
+ "source": [
268
+ "def score_vec(theta, y):\n",
269
+ " th_ = theta.detach(); v = torch.zeros_like(th_)\n",
270
+ " for t in range(T):\n",
271
+ " s = pid[y[:t]]; sm = torch.softmax(th_[s], dim=-1)\n",
272
+ " oh = torch.zeros(Vsz); oh[y[t]] = 1.0\n",
273
+ " v[s] += oh - sm\n",
274
+ " return v.flatten()\n",
275
+ "\n",
276
+ "P = torch.stack([torch.exp(log_pi_seq(theta0, y)).detach() for y in seqs])\n",
277
+ "S = torch.stack([score_vec(theta0, y) for y in seqs]) # (27, 39)\n",
278
+ "R = r_tab\n",
279
+ "gJ = (P[:, None] * R[:, None] * S).sum(0)\n",
280
+ "def var_exact(b):\n",
281
+ " w = (R - b) ** 2 * (S ** 2).sum(1)\n",
282
+ " return float((P * w).sum() - (gJ ** 2).sum())\n",
283
+ "bstar = float((P * R * (S ** 2).sum(1)).sum() / (P * (S ** 2).sum(1)).sum())\n",
284
+ "pbar = float((P * R).sum())\n",
285
+ "print('p = E[r] =', pbar, ' b* =', bstar, ' Var(b=0) =', var_exact(0.0), ' Var(b=p) =', var_exact(pbar), ' Var(b*) =', var_exact(bstar))\n",
286
+ "bs = np.linspace(-1, 2, 30001)\n",
287
+ "vs = np.array([var_exact(b) for b in bs])\n",
288
+ "check('4. argmin_b Var (grid) vs closed-form b*', abs(bs[vs.argmin()] - bstar), 2e-4)\n",
289
+ "# Closed form of the minimum: Var(b*) = E[r^2|s|^2] - (E[r|s|^2])^2/E|s|^2 - |gJ|^2 (r^2=r for binary r)\n",
290
+ "Es2 = float((P * (S ** 2).sum(1)).sum())\n",
291
+ "Ers2 = float((P * R * (S ** 2).sum(1)).sum())\n",
292
+ "var_min_closed = Ers2 - Ers2 ** 2 / Es2 - float((gJ ** 2).sum())\n",
293
+ "check('4. Var(b*) closed form vs direct', abs(var_min_closed - var_exact(bstar)), 1e-12)\n",
294
+ "# Monte Carlo of the one-sample estimator (independent check of var_exact at b = p)\n",
295
+ "N = 400000\n",
296
+ "idx = torch.multinomial(P, N, replacement=True)\n",
297
+ "est = (R[idx] - pbar)[:, None] * S[idx]\n",
298
+ "var_mc = float(((est - est.mean(0)) ** 2).sum(1).mean())\n",
299
+ "check('4. MC variance of one-sample estimator vs exact (rel. err.)', abs(var_mc - var_exact(pbar)) / var_exact(pbar), 0.02)"
300
+ ]
301
+ },
302
+ {
303
+ "cell_type": "markdown",
304
+ "id": "db9b1afb",
305
+ "metadata": {},
306
+ "source": [
307
+ "## 5. Monte Carlo REINFORCE converges at the `1/sqrt(N)` rate\n",
308
+ "EFFICIENT version: sample `N` proofs from `pi`, average `(r - b) * score`. We compare with `grad J` in units of the\n",
309
+ "estimated standard error (z-scores), which must be O(1), and the RMS error must halve when `N` is quadrupled."
310
+ ]
311
+ },
312
+ {
313
+ "cell_type": "code",
314
+ "execution_count": 6,
315
+ "id": "64e6d7e6",
316
+ "metadata": {
317
+ "execution": {
318
+ "iopub.execute_input": "2026-10-14T08:49:43.335763Z",
319
+ "iopub.status.busy": "2026-10-14T08:49:43.335596Z",
320
+ "iopub.status.idle": "2026-10-14T08:49:44.334151Z",
321
+ "shell.execute_reply": "2026-10-14T08:49:44.332934Z"
322
+ }
323
+ },
324
+ "outputs": [
325
+ {
326
+ "name": "stdout",
327
+ "output_type": "stream",
328
+ "text": [
329
+ "max |z| over 39 coordinates: 2.820402064782768\n",
330
+ "5. MC grad within 5 standard errors (max |z|, tol 5) err=2.820e+00 (tol 5e+00)\n"
331
+ ]
332
+ },
333
+ {
334
+ "name": "stdout",
335
+ "output_type": "stream",
336
+ "text": [
337
+ "RMS err N=5k: 0.008952773341135103 N=20k: 0.004564106878365233 ratio: 1.9615608441539822\n",
338
+ "5. rate: RMS(N)/RMS(4N) = 2 (abs dev) err=3.844e-02 (tol 3e-01)\n"
339
+ ]
340
+ }
341
+ ],
342
+ "source": [
343
+ "def mc_grad(N, b, seed):\n",
344
+ " gen = torch.Generator().manual_seed(seed)\n",
345
+ " idx = torch.multinomial(P, N, replacement=True, generator=gen)\n",
346
+ " e = (R[idx] - b)[:, None] * S[idx]\n",
347
+ " return e.mean(0), e.std(0) / math.sqrt(N)\n",
348
+ "m, se = mc_grad(2_000_000, pbar, 11)\n",
349
+ "z = ((m - gJ) / se.clamp_min(1e-300))\n",
350
+ "mask = se > 1e-9\n",
351
+ "print('max |z| over', int(mask.sum()), 'coordinates:', float(z[mask].abs().max()))\n",
352
+ "check('5. MC grad within 5 standard errors (max |z|, tol 5)', float(z[mask].abs().max()), 5.0)\n",
353
+ "rms = lambda N: float(np.sqrt(np.mean([((mc_grad(N, pbar, s)[0] - gJ) ** 2).sum().item() for s in range(60)])))\n",
354
+ "r1, r4 = rms(5_000), rms(20_000)\n",
355
+ "print('RMS err N=5k:', r1, ' N=20k:', r4, ' ratio:', r1 / r4)\n",
356
+ "check('5. rate: RMS(N)/RMS(4N) = 2 (abs dev)', abs(r1 / r4 - 2.0), 0.35)"
357
+ ]
358
+ },
359
+ {
360
+ "cell_type": "markdown",
361
+ "id": "5a6f8e4c",
362
+ "metadata": {},
363
+ "source": [
364
+ "## 6. The hole: `E[G] = E[V] - pi(H)` and softmax policy-gradient dynamics\n",
365
+ "Three kinds of outputs: `wrong` (V=0,G=0), `proof` (V=1,G=1), `hole` (V=1, G=0, e.g. a `sorry`-like exploit that the\n",
366
+ "verifier accepts). For a tabular softmax over these classes, `dz_k/dt = pi_k (r_k - J)` (derived in the note)."
367
+ ]
368
+ },
369
+ {
370
+ "cell_type": "code",
371
+ "execution_count": 7,
372
+ "id": "a8b49842",
373
+ "metadata": {
374
+ "execution": {
375
+ "iopub.execute_input": "2026-10-14T08:49:44.336095Z",
376
+ "iopub.status.busy": "2026-10-14T08:49:44.335921Z",
377
+ "iopub.status.idle": "2026-10-14T08:49:45.190504Z",
378
+ "shell.execute_reply": "2026-10-14T08:49:45.189496Z"
379
+ }
380
+ },
381
+ "outputs": [
382
+ {
383
+ "name": "stdout",
384
+ "output_type": "stream",
385
+ "text": [
386
+ "6. softmax-PG gradient pi_k (r_k - J) vs autograd err=0.000e+00 (tol 1e-16)\n"
387
+ ]
388
+ },
389
+ {
390
+ "name": "stdout",
391
+ "output_type": "stream",
392
+ "text": [
393
+ "step, E[V], E[G], pi(hole)\n",
394
+ " (0, 0.04, 0.01, 0.03)\n",
395
+ " (4000, 0.9997460159631938, 0.0017478329594184914, 0.9979981830037753)\n",
396
+ " (8000, 0.9998739033985322, 0.0012328930126125394, 0.9986410103859197)\n",
397
+ " (12000, 0.9999161452892915, 0.001005875916774838, 0.9989102693725167)\n",
398
+ " (16000, 0.9999371907590497, 0.0008707924515763814, 0.9990663983074733)\n",
399
+ " (20000, 0.9999497931524061, 0.0007786947944058345, 0.9991710983580002)\n",
400
+ "6. identity E[G] = E[V] - pi(H) at the end of training err=1.735e-17 (tol 1e-15)\n",
401
+ "final: E[V]=0.9999 E[G]=0.0008 pi(hole)=0.9992 (the true-proof share of the accepted mass fell from 0.25 to 0.001)\n",
402
+ "6. proof share of accepted mass is non-increasing (max positive increment) err=0.000e+00 (tol 1e-15)\n"
403
+ ]
404
+ },
405
+ {
406
+ "name": "stdout",
407
+ "output_type": "stream",
408
+ "text": [
409
+ "6. false-reject bias: E[V] - (1-delta)E[G] within 4 s.e. err=3.385e-01 (tol 4e+00)\n"
410
+ ]
411
+ }
412
+ ],
413
+ "source": [
414
+ "r_V = torch.tensor([0.0, 1.0, 1.0]); r_G = torch.tensor([0.0, 1.0, 0.0])\n",
415
+ "z = torch.log(torch.tensor([0.96, 0.01, 0.03])) # hole is 3x more reachable than the true proof\n",
416
+ "# (a) gradient of J_V wrt logits: autograd vs hand formula pi_k (r_k - J)\n",
417
+ "zz = z.clone().requires_grad_(True)\n",
418
+ "pi = torch.softmax(zz, 0); JV = (pi * r_V).sum(); JV.backward()\n",
419
+ "hand = pi.detach() * (r_V - JV.detach())\n",
420
+ "check('6. softmax-PG gradient pi_k (r_k - J) vs autograd', (zz.grad - hand).abs().max(), 1e-16)\n",
421
+ "# (b) integrate the gradient flow with small steps; track J_V, J_G, and pi(H)\n",
422
+ "zt = z.clone(); eta = 0.5; traj = []\n",
423
+ "for step in range(20001):\n",
424
+ " pi = torch.softmax(zt, 0)\n",
425
+ " JVt = float((pi * r_V).sum()); JGt = float((pi * r_G).sum())\n",
426
+ " if step % 4000 == 0: traj.append((step, JVt, JGt, float(pi[2])))\n",
427
+ " zt = zt + eta * pi * (r_V - JVt)\n",
428
+ "print('step, E[V], E[G], pi(hole)'); [print(' ', t) for t in traj]\n",
429
+ "pi = torch.softmax(zt, 0)\n",
430
+ "EV = float((pi * r_V).sum()); EG = float((pi * r_G).sum()); piH = float(pi[2])\n",
431
+ "check('6. identity E[G] = E[V] - pi(H) at the end of training', abs(EG - (EV - piH)), 1e-15)\n",
432
+ "assert EV > 0.99 and EG < 0.3, (EV, EG)\n",
433
+ "print(f'final: E[V]={EV:.4f} E[G]={EG:.4f} pi(hole)={piH:.4f} (the true-proof share of the accepted mass fell from 0.25 to {EG/EV:.3f})')\n",
434
+ "# Closed form for the invariant: log(pi_proof/pi_hole) changes by sum (pi_proof - pi_hole)(1-J) eta; since pi_hole > pi_proof it\n",
435
+ "# keeps growing in favour of the hole. Check monotonicity of the proof share along the trajectory.\n",
436
+ "zt = z.clone(); shares = []\n",
437
+ "for step in range(3000):\n",
438
+ " pi = torch.softmax(zt, 0); shares.append(float(pi[1] / (pi[1] + pi[2])))\n",
439
+ " zt = zt + eta * pi * (r_V - (pi * r_V).sum())\n",
440
+ "check('6. proof share of accepted mass is non-increasing (max positive increment)', max(0.0, float(np.max(np.diff(shares)))), 1e-15)\n",
441
+ "\n",
442
+ "# (c) false rejects: V = G * (1 - timeout) with independent timeouts of prob delta => E[V] = (1-delta) E[G]\n",
443
+ "delta = 0.1; N = 4_000_000\n",
444
+ "gen = torch.Generator().manual_seed(5)\n",
445
+ "G = (torch.rand(N, generator=gen) < 0.37).double(); to = (torch.rand(N, generator=gen) < delta).double()\n",
446
+ "Vv = G * (1 - to)\n",
447
+ "se = float(Vv.std() / math.sqrt(N))\n",
448
+ "check('6. false-reject bias: E[V] - (1-delta)E[G] within 4 s.e.', abs(float(Vv.mean()) - (1 - delta) * 0.37) / se, 4.0)"
449
+ ]
450
+ },
451
+ {
452
+ "cell_type": "markdown",
453
+ "id": "5a8054aa",
454
+ "metadata": {},
455
+ "source": [
456
+ "## 7. Proxy reward model vs exact verifier at best-of-`n`\n",
457
+ "`n` candidates, each truly correct w.p. `p`. A proxy RM scores `s = G + sigma * xi`, `xi ~ N(0,1)`. Selecting the argmax of `s`\n",
458
+ "picks a correct candidate with probability\n",
459
+ "`P_RM(n) = int n p f_1(s) [p F_1(s) + (1-p) F_0(s)]^(n-1) ds`\n",
460
+ "(`f_1,F_1` the N(1,sigma^2) pdf/cdf, `f_0,F_0` the N(0,sigma^2) ones), while the exact verifier gets\n",
461
+ "`1-(1-p)^n`. The *score* the RM reports for its pick exceeds the true value by about `sigma sqrt(2 ln n)`."
462
+ ]
463
+ },
464
+ {
465
+ "cell_type": "code",
466
+ "execution_count": 8,
467
+ "id": "7cea16ac",
468
+ "metadata": {
469
+ "execution": {
470
+ "iopub.execute_input": "2026-10-14T08:49:45.192317Z",
471
+ "iopub.status.busy": "2026-10-14T08:49:45.192135Z",
472
+ "iopub.status.idle": "2026-10-14T08:51:27.965442Z",
473
+ "shell.execute_reply": "2026-10-14T08:51:27.964619Z"
474
+ }
475
+ },
476
+ "outputs": [
477
+ {
478
+ "name": "stdout",
479
+ "output_type": "stream",
480
+ "text": [
481
+ "n= 4 p=0.2 sigma=0.5: P_RM exact=0.51088 MC=0.51153 verifier(any correct)=0.59040\n"
482
+ ]
483
+ },
484
+ {
485
+ "name": "stdout",
486
+ "output_type": "stream",
487
+ "text": [
488
+ "n= 16 p=0.1 sigma=1.0: P_RM exact=0.33706 MC=0.33767 verifier(any correct)=0.81470\n"
489
+ ]
490
+ },
491
+ {
492
+ "name": "stdout",
493
+ "output_type": "stream",
494
+ "text": [
495
+ "n= 64 p=0.05 sigma=1.5: P_RM exact=0.17838 MC=0.17813 verifier(any correct)=0.96248\n",
496
+ "7. best-of-n pick-correct probability: quadrature vs MC (max z) err=8.216e-01 (tol 4e+00)\n"
497
+ ]
498
+ },
499
+ {
500
+ "name": "stdout",
501
+ "output_type": "stream",
502
+ "text": [
503
+ "verifier minus RM at n=16,p=0.1 for sigma=0.25,0.5,1,2: ['0.0089', '0.2043', '0.4776', '0.6137']\n",
504
+ "n= 10 E[max] exact=1.5388 upper bound sqrt(2 ln n)=2.1460\n"
505
+ ]
506
+ },
507
+ {
508
+ "name": "stdout",
509
+ "output_type": "stream",
510
+ "text": [
511
+ "n= 100 E[max] exact=2.5076 upper bound sqrt(2 ln n)=3.0349\n",
512
+ "n= 10000 E[max] exact=3.8516 upper bound sqrt(2 ln n)=4.2919\n"
513
+ ]
514
+ },
515
+ {
516
+ "name": "stdout",
517
+ "output_type": "stream",
518
+ "text": [
519
+ "n= 1000000 E[max] exact=4.8629 upper bound sqrt(2 ln n)=5.2565\n"
520
+ ]
521
+ },
522
+ {
523
+ "name": "stdout",
524
+ "output_type": "stream",
525
+ "text": [
526
+ "7. E[max of 100 N(0,1)]: quadrature vs MC (in standard errors, tol 4) err=1.327e+00 (tol 4e+00)\n"
527
+ ]
528
+ }
529
+ ],
530
+ "source": [
531
+ "from math import erf, sqrt, exp, pi as PI\n",
532
+ "def Phi(x): return 0.5 * (1 + np.vectorize(erf)(x / sqrt(2)))\n",
533
+ "def p_rm(n, p, sigma, grid=400001):\n",
534
+ " s = np.linspace(-12 * sigma - 2, 12 * sigma + 3, grid)\n",
535
+ " f1 = np.exp(-0.5 * ((s - 1) / sigma) ** 2) / (sigma * sqrt(2 * PI))\n",
536
+ " F1 = Phi((s - 1) / sigma); F0 = Phi(s / sigma)\n",
537
+ " integrand = n * p * f1 * (p * F1 + (1 - p) * F0) ** (n - 1)\n",
538
+ " return float(np.trapezoid(integrand, s))\n",
539
+ "def p_rm_mc(n, p, sigma, trials, seed):\n",
540
+ " g = np.random.RandomState(seed)\n",
541
+ " G = (g.rand(trials, n) < p).astype(float); sc = G + sigma * g.randn(trials, n)\n",
542
+ " pick = sc.argmax(1)\n",
543
+ " return G[np.arange(trials), pick].mean(), sc.max(1).mean(), G[np.arange(trials), pick].mean()\n",
544
+ "errs = []\n",
545
+ "for (n, p, sig) in [(4, 0.2, 0.5), (16, 0.1, 1.0), (64, 0.05, 1.5)]:\n",
546
+ " exact = p_rm(n, p, sig); mc, _, _ = p_rm_mc(n, p, sig, 400000, 1)\n",
547
+ " se = math.sqrt(exact * (1 - exact) / 400000)\n",
548
+ " errs.append(abs(exact - mc) / se)\n",
549
+ " print(f'n={n:3d} p={p} sigma={sig}: P_RM exact={exact:.5f} MC={mc:.5f} verifier(any correct)={1-(1-p)**n:.5f}')\n",
550
+ "check('7. best-of-n pick-correct probability: quadrature vs MC (max z)', max(errs), 4.0)\n",
551
+ "# Verifier is never worse and strictly better for sigma>0\n",
552
+ "gap = [1 - (1 - 0.1) ** 16 - p_rm(16, 0.1, s) for s in (0.25, 0.5, 1.0, 2.0)]\n",
553
+ "print('verifier minus RM at n=16,p=0.1 for sigma=0.25,0.5,1,2:', [f'{x:.4f}' for x in gap])\n",
554
+ "assert all(g > 0 for g in gap) and all(np.diff(gap) > 0)\n",
555
+ "# Winner's curse: E[max of n iid N(0,1)] = int n x phi(x) Phi(x)^(n-1) dx (exact quadrature); bound E[max] <= sqrt(2 ln n).\n",
556
+ "def emax_exact(n):\n",
557
+ " x = np.linspace(-10, 10, 400001)\n",
558
+ " phi = np.exp(-0.5 * x ** 2) / sqrt(2 * PI)\n",
559
+ " return float(np.trapezoid(n * x * phi * Phi(x) ** (n - 1), x))\n",
560
+ "for n in (10, 100, 10_000, 1_000_000):\n",
561
+ " print(f'n={n:8d} E[max] exact={emax_exact(n):.4f} upper bound sqrt(2 ln n)={sqrt(2*math.log(n)):.4f}')\n",
562
+ " assert emax_exact(n) <= sqrt(2 * math.log(n))\n",
563
+ "mcs = np.random.RandomState(2).randn(40000, 100).max(1)\n",
564
+ "check('7. E[max of 100 N(0,1)]: quadrature vs MC (in standard errors, tol 4)', abs(emax_exact(100) - mcs.mean()) / (mcs.std() / sqrt(len(mcs))), 4.0)"
565
+ ]
566
+ },
567
+ {
568
+ "cell_type": "markdown",
569
+ "id": "f1a420d1",
570
+ "metadata": {},
571
+ "source": [
572
+ "## Verification table"
573
+ ]
574
+ },
575
+ {
576
+ "cell_type": "code",
577
+ "execution_count": 9,
578
+ "id": "3c054d00",
579
+ "metadata": {
580
+ "execution": {
581
+ "iopub.execute_input": "2026-10-14T08:51:27.967075Z",
582
+ "iopub.status.busy": "2026-10-14T08:51:27.966912Z",
583
+ "iopub.status.idle": "2026-10-14T08:51:27.970746Z",
584
+ "shell.execute_reply": "2026-10-14T08:51:27.970067Z"
585
+ }
586
+ },
587
+ "outputs": [
588
+ {
589
+ "name": "stdout",
590
+ "output_type": "stream",
591
+ "text": [
592
+ "\n",
593
+ "identity max abs err\n",
594
+ "1. score-function grad (hand, enumerated) vs autograd of exact J 1.041e-17\n",
595
+ "3. E[grad log pi] = 0 (baseline identity) 5.898e-17\n",
596
+ "3. grad unchanged for b in {-3,0,0.3,17} 6.557e-16\n",
597
+ "4. argmin_b Var (grid) vs closed-form b* 1.078e-05\n",
598
+ "4. Var(b*) closed form vs direct 5.551e-17\n",
599
+ "4. MC variance of one-sample estimator vs exact (rel. err.) 2.500e-03\n",
600
+ "5. MC grad within 5 standard errors (max |z|, tol 5) 2.820e+00\n",
601
+ "5. rate: RMS(N)/RMS(4N) = 2 (abs dev) 3.844e-02\n",
602
+ "6. softmax-PG gradient pi_k (r_k - J) vs autograd 0.000e+00\n",
603
+ "6. identity E[G] = E[V] - pi(H) at the end of training 1.735e-17\n",
604
+ "6. proof share of accepted mass is non-increasing (max positive increment) 0.000e+00\n",
605
+ "6. false-reject bias: E[V] - (1-delta)E[G] within 4 s.e. 3.385e-01\n",
606
+ "7. best-of-n pick-correct probability: quadrature vs MC (max z) 8.216e-01\n",
607
+ "7. E[max of 100 N(0,1)]: quadrature vs MC (in standard errors, tol 4) 1.327e+00\n",
608
+ "CHECKS_JSON:[[\"1. score-function grad (hand, enumerated) vs autograd of exact J\", 1.0408340855860843e-17, 1e-14], [\"3. E[grad log pi] = 0 (baseline identity)\", 5.898059818321144e-17, 1e-15], [\"3. grad unchanged for b in {-3,0,0.3,17}\", 6.557254739192331e-16, 1e-13], [\"4. argmin_b Var (grid) vs closed-form b*\", 1.078078853000708e-05, 0.0002], [\"4. Var(b*) closed form vs direct\", 5.551115123125783e-17, 1e-12], [\"4. MC variance of one-sample estimator vs exact (rel. err.)\", 0.0025004228417077347, 0.02], [\"5. MC grad within 5 standard errors (max |z|, tol 5)\", 2.820402064782768, 5.0], [\"5. rate: RMS(N)/RMS(4N) = 2 (abs dev)\", 0.03843915584601776, 0.35], [\"6. softmax-PG gradient pi_k (r_k - J) vs autograd\", 0.0, 1e-16], [\"6. identity E[G] = E[V] - pi(H) at the end of training\", 1.734723475976807e-17, 1e-15], [\"6. proof share of accepted mass is non-increasing (max positive increment)\", 0.0, 1e-15], [\"6. false-reject bias: E[V] - (1-delta)E[G] within 4 s.e.\", 0.33845555650118325, 4.0], [\"7. best-of-n pick-correct probability: quadrature vs MC (max z)\", 0.8216357277155372, 4.0], [\"7. E[max of 100 N(0,1)]: quadrature vs MC (in standard errors, tol 4)\", 1.327150492424622, 4.0]]\n"
609
+ ]
610
+ }
611
+ ],
612
+ "source": [
613
+ "print()\n",
614
+ "print(f'{\"identity\":72s} {\"max abs err\":>12s}')\n",
615
+ "for n_, e_, t_ in CHECKS: print(f'{n_:72s} {e_:12.3e}')\n",
616
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
617
+ ]
618
+ }
619
+ ],
620
+ "metadata": {
621
+ "kernelspec": {
622
+ "display_name": "Python 3",
623
+ "language": "python",
624
+ "name": "python3"
625
+ },
626
+ "language_info": {
627
+ "codemirror_mode": {
628
+ "name": "ipython",
629
+ "version": 3
630
+ },
631
+ "file_extension": ".py",
632
+ "mimetype": "text/x-python",
633
+ "name": "python",
634
+ "nbconvert_exporter": "python",
635
+ "pygments_lexer": "ipython3",
636
+ "version": "3.12.11"
637
+ }
638
+ },
639
+ "nbformat": 4,
640
+ "nbformat_minor": 5
641
+ }
code/notebooks/0202-rejection-sampling-expert-iteration-em.ipynb ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "9658b1ce",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0202 -- Rejection sampling / expert iteration as policy improvement (and as EM)\n",
9
+ "\n",
10
+ "Identities verified (fp64, CPU):\n",
11
+ "1. `grad_theta E_x E_{y~pi_t}[V log pi_theta]` at `theta=theta_t` equals the policy gradient `grad J(theta_t)`.\n",
12
+ "2. ELBO identity `L(theta,q) = log p_theta(x) - KL(q || q*_theta)` and tightness at `q=q*_theta` (so the E-step is exact).\n",
13
+ "3. Tabular M-step: `argmin KL(q_t || pi_theta)` gives `pi_{t+1}=q_t`, hence `p_{t+1}(x)=1` where `p_t(x)>0` and `0` where `p_t(x)=0` (coverage).\n",
14
+ "4. Shared-parameter model: with an *exact* M-step, `F(theta)=sum_x log p_theta(x)` is non-decreasing (EM monotonicity).\n",
15
+ "5. Sampling: `E[ sum_i V_i f(y_i)/c ; c>0 ] = (1-(1-p)^n) E_q[f]` (exact enumeration) -- the self-normalised filtered average is unbiased except for the missing event `c=0`.\n",
16
+ "6. Unnormalised (practical) SFT objective is *not* monotone per prompt under shared parameters.\n",
17
+ "7. Monte Carlo RFT gradient agrees with the exact `grad J` (RFT step = REINFORCE step)."
18
+ ]
19
+ },
20
+ {
21
+ "cell_type": "code",
22
+ "execution_count": 1,
23
+ "id": "86d1f2a7",
24
+ "metadata": {
25
+ "execution": {
26
+ "iopub.execute_input": "2026-10-14T09:47:57.273949Z",
27
+ "iopub.status.busy": "2026-10-14T09:47:57.273796Z",
28
+ "iopub.status.idle": "2026-10-14T09:47:59.029734Z",
29
+ "shell.execute_reply": "2026-10-14T09:47:59.029103Z"
30
+ }
31
+ },
32
+ "outputs": [],
33
+ "source": [
34
+ "import os\n",
35
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
36
+ "import itertools, math, json\n",
37
+ "import numpy as np, torch\n",
38
+ "torch.set_default_dtype(torch.float64)\n",
39
+ "torch.manual_seed(1); np.random.seed(1)\n",
40
+ "CHECKS = []\n",
41
+ "def check(name, err, tol):\n",
42
+ " CHECKS.append((name, float(err), float(tol)))\n",
43
+ " assert err <= tol, (name, float(err), tol)\n",
44
+ " print(f'{name:78s} err={float(err):.3e} (tol {tol:.0e})')"
45
+ ]
46
+ },
47
+ {
48
+ "cell_type": "markdown",
49
+ "id": "ee2a1501",
50
+ "metadata": {},
51
+ "source": [
52
+ "## 1. A small family of prompts with a tabular policy\n",
53
+ "`m=6` prompts, `K=10` candidate proofs per prompt. `V[x,y]` in {0,1}. Prompt 5 has *no* correct proof; prompt 4 has a correct\n",
54
+ "proof that the initial policy almost never produces (coverage problem)."
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "code",
59
+ "execution_count": 2,
60
+ "id": "5af4e833",
61
+ "metadata": {
62
+ "execution": {
63
+ "iopub.execute_input": "2026-10-14T09:47:59.032313Z",
64
+ "iopub.status.busy": "2026-10-14T09:47:59.032078Z",
65
+ "iopub.status.idle": "2026-10-14T09:47:59.039690Z",
66
+ "shell.execute_reply": "2026-10-14T09:47:59.038725Z"
67
+ }
68
+ },
69
+ "outputs": [
70
+ {
71
+ "name": "stdout",
72
+ "output_type": "stream",
73
+ "text": [
74
+ "p_t(x) = [0.26066, 0.36631, 0.17998, 0.16817, 0.00018, 0.0]\n"
75
+ ]
76
+ }
77
+ ],
78
+ "source": [
79
+ "m, K = 6, 10\n",
80
+ "rng = np.random.RandomState(7)\n",
81
+ "V = (rng.rand(m, K) < 0.25).astype(float)\n",
82
+ "V[5, :] = 0.0 # unsolvable: V == 0 everywhere\n",
83
+ "V[4, :] = 0.0; V[4, 9] = 1.0 # exactly one correct proof\n",
84
+ "V[0, 0] = 1.0 # make sure prompt 0 is solvable\n",
85
+ "V = torch.tensor(V)\n",
86
+ "logits0 = torch.randn(m, K) * 0.5\n",
87
+ "logits0[4, 9] = -6.0 # pi(correct) ~ 2e-4 for prompt 4\n",
88
+ "def pi_tab(z): return torch.softmax(z, dim=-1)\n",
89
+ "def p_of(z): return (pi_tab(z) * V).sum(-1)\n",
90
+ "print('p_t(x) =', [round(float(v), 5) for v in p_of(logits0)])"
91
+ ]
92
+ },
93
+ {
94
+ "cell_type": "markdown",
95
+ "id": "7bf5412a",
96
+ "metadata": {},
97
+ "source": [
98
+ "## 2. First-order equivalence: one RFT step is a policy-gradient step\n",
99
+ "RFT objective with samples from the *frozen* `pi_t`: `L(theta) = (1/m) sum_x sum_y pi_t(y|x) V(x,y) log pi_theta(y|x)`.\n",
100
+ "Its gradient at `theta=theta_t` is `(1/m) sum_x sum_y V pi_t grad log pi_t = grad J(theta_t)` (score identity of note 0201)."
101
+ ]
102
+ },
103
+ {
104
+ "cell_type": "code",
105
+ "execution_count": 3,
106
+ "id": "d29b7d32",
107
+ "metadata": {
108
+ "execution": {
109
+ "iopub.execute_input": "2026-10-14T09:47:59.041294Z",
110
+ "iopub.status.busy": "2026-10-14T09:47:59.041085Z",
111
+ "iopub.status.idle": "2026-10-14T09:47:59.128842Z",
112
+ "shell.execute_reply": "2026-10-14T09:47:59.127645Z"
113
+ }
114
+ },
115
+ "outputs": [
116
+ {
117
+ "name": "stdout",
118
+ "output_type": "stream",
119
+ "text": [
120
+ "1. grad of RFT objective at theta_t vs grad J(theta_t) err=3.469e-18 (tol 1e-16)\n"
121
+ ]
122
+ }
123
+ ],
124
+ "source": [
125
+ "z = logits0.clone().requires_grad_(True)\n",
126
+ "J = p_of(z).mean(); J.backward(); gJ = z.grad.clone()\n",
127
+ "pi_t = pi_tab(logits0).detach()\n",
128
+ "z2 = logits0.clone().requires_grad_(True)\n",
129
+ "L = ((pi_t * V * torch.log_softmax(z2, -1)).sum(-1)).mean(); L.backward()\n",
130
+ "check('1. grad of RFT objective at theta_t vs grad J(theta_t)', (z2.grad - gJ).abs().max(), 1e-16)"
131
+ ]
132
+ },
133
+ {
134
+ "cell_type": "markdown",
135
+ "id": "93fcb188",
136
+ "metadata": {},
137
+ "source": [
138
+ "## 3. ELBO identity and exact E-step\n",
139
+ "`p_theta(x) = sum_y pi_theta V`. `q*_theta(y|x) = pi_theta V / p_theta(x)`. For any `q` (full support on {V=1}):\n",
140
+ "`L(theta,q) = E_q[log V(x,y)] - KL(q || pi_theta)`, and with `V in {0,1}` and `q` supported on `{V=1}` this is `-KL(q||pi_theta)`.\n",
141
+ "Claim: `-KL(q||pi_theta) = log p_theta - KL(q||q*_theta)`."
142
+ ]
143
+ },
144
+ {
145
+ "cell_type": "code",
146
+ "execution_count": 4,
147
+ "id": "5dd4e7bb",
148
+ "metadata": {
149
+ "execution": {
150
+ "iopub.execute_input": "2026-10-14T09:47:59.130670Z",
151
+ "iopub.status.busy": "2026-10-14T09:47:59.130469Z",
152
+ "iopub.status.idle": "2026-10-14T09:47:59.137329Z",
153
+ "shell.execute_reply": "2026-10-14T09:47:59.136038Z"
154
+ }
155
+ },
156
+ "outputs": [
157
+ {
158
+ "name": "stdout",
159
+ "output_type": "stream",
160
+ "text": [
161
+ "2. ELBO identity -KL(q||pi) = log p - KL(q||q*) err=0.000e+00 (tol 1e-14)\n",
162
+ "2. tightness: -KL(q*||pi) = log p err=2.220e-16 (tol 1e-14)\n"
163
+ ]
164
+ }
165
+ ],
166
+ "source": [
167
+ "th = logits0[0]; pi = pi_tab(th); Vx = V[0]; p0 = float((pi * Vx).sum())\n",
168
+ "qstar = pi * Vx / p0\n",
169
+ "qrand = torch.rand(K) * Vx; qrand = qrand / qrand.sum()\n",
170
+ "def KL(a, b):\n",
171
+ " msk = a > 0\n",
172
+ " return float((a[msk] * (torch.log(a[msk]) - torch.log(b[msk]))).sum())\n",
173
+ "lhs = -KL(qrand, pi)\n",
174
+ "rhs = math.log(p0) - KL(qrand, qstar)\n",
175
+ "check('2. ELBO identity -KL(q||pi) = log p - KL(q||q*)', abs(lhs - rhs), 1e-14)\n",
176
+ "check('2. tightness: -KL(q*||pi) = log p', abs(-KL(qstar, pi) - math.log(p0)), 1e-14)"
177
+ ]
178
+ },
179
+ {
180
+ "cell_type": "markdown",
181
+ "id": "ae2285be",
182
+ "metadata": {},
183
+ "source": [
184
+ "## 4. Tabular M-step: `pi_{t+1} = q_t` and the coverage theorem\n",
185
+ "With full capacity `argmin_theta KL(q_t||pi_theta)` gives `pi_{t+1}(.|x)=q_t(.|x)` for every prompt with `p_t(x)>0`.\n",
186
+ "One iteration therefore drives `p(x)` to 1 on all covered prompts and leaves `p(x)=0` prompts untouched."
187
+ ]
188
+ },
189
+ {
190
+ "cell_type": "code",
191
+ "execution_count": 5,
192
+ "id": "d07a7eb0",
193
+ "metadata": {
194
+ "execution": {
195
+ "iopub.execute_input": "2026-10-14T09:47:59.139284Z",
196
+ "iopub.status.busy": "2026-10-14T09:47:59.139078Z",
197
+ "iopub.status.idle": "2026-10-14T09:47:59.146022Z",
198
+ "shell.execute_reply": "2026-10-14T09:47:59.144837Z"
199
+ }
200
+ },
201
+ "outputs": [
202
+ {
203
+ "name": "stdout",
204
+ "output_type": "stream",
205
+ "text": [
206
+ "p_t : [0.26066, 0.36631, 0.17998, 0.16817, 0.00018, 0.0]\n",
207
+ "p_t+1 : [1.0, 1.0, 1.0, 1.0, 1.0, 0.0]\n",
208
+ "3. p_{t+1}(x) = 1 on covered prompts (max |p-1|) err=0.000e+00 (tol 1e-14)\n",
209
+ "3. p_{t+1}(x) = 0 on uncovered prompt (coverage failure) err=0.000e+00 (tol 0e+00)\n"
210
+ ]
211
+ }
212
+ ],
213
+ "source": [
214
+ "pt = p_of(logits0)\n",
215
+ "covered = pt > 0\n",
216
+ "q = pi_tab(logits0) * V\n",
217
+ "q = torch.where(covered[:, None], q / pt.clamp_min(1e-300)[:, None], pi_tab(logits0)) # keep pi_t where q undefined\n",
218
+ "pi_next = q\n",
219
+ "p_next = (pi_next * V).sum(-1)\n",
220
+ "print('p_t :', [round(float(v), 5) for v in pt])\n",
221
+ "print('p_t+1 :', [round(float(v), 5) for v in p_next])\n",
222
+ "check('3. p_{t+1}(x) = 1 on covered prompts (max |p-1|)', (p_next[covered] - 1).abs().max(), 1e-14)\n",
223
+ "check('3. p_{t+1}(x) = 0 on uncovered prompt (coverage failure)', p_next[~covered].abs().max(), 0.0)"
224
+ ]
225
+ },
226
+ {
227
+ "cell_type": "markdown",
228
+ "id": "3670c24a",
229
+ "metadata": {},
230
+ "source": [
231
+ "## 5. Shared parameters: EM monotonicity with an exact M-step\n",
232
+ "Log-linear policy `pi_theta(y|x) = softmax_y(phi(x,y) . theta)`, `d=5 < K=12` features, so the model cannot represent `q_t` exactly.\n",
233
+ "`F(theta) = (1/|S|) sum_{x in S} log p_theta(x)` over the prompts with at least one correct proof (`S`).\n",
234
+ "The M-step objective `-(1/|S|) sum_x sum_y q_t(y|x) log pi_theta(y|x)` is convex in theta (softmax cross-entropy with linear logits),\n",
235
+ "so L-BFGS solves it to machine precision, i.e. the M-step is exact."
236
+ ]
237
+ },
238
+ {
239
+ "cell_type": "code",
240
+ "execution_count": 6,
241
+ "id": "341bebce",
242
+ "metadata": {
243
+ "execution": {
244
+ "iopub.execute_input": "2026-10-14T09:47:59.148100Z",
245
+ "iopub.status.busy": "2026-10-14T09:47:59.147866Z",
246
+ "iopub.status.idle": "2026-10-14T09:48:00.351047Z",
247
+ "shell.execute_reply": "2026-10-14T09:48:00.350146Z"
248
+ }
249
+ },
250
+ "outputs": [
251
+ {
252
+ "name": "stdout",
253
+ "output_type": "stream",
254
+ "text": [
255
+ "F per iteration: [-1.733346, -1.418455, -1.386263, -1.381133, -1.377459, -1.373836, -1.37042, -1.367474, -1.365174, -1.363539, -1.362461, -1.361784, -1.361367]\n",
256
+ "mean p per iter: [0.185491, 0.234027, 0.253918, 0.259472, 0.261745, 0.263587, 0.265459, 0.267323, 0.269065, 0.270608, 0.271926, 0.273028, 0.273945]\n",
257
+ "final M-step max |grad|: 1.2179568404172003e-09\n",
258
+ "4. EM monotone: max decrease of F over 12 iterations (should be <= 0) err=0.000e+00 (tol 1e-10)\n",
259
+ "4. M-step solved to optimality (max |grad| at the last M-step) err=1.218e-09 (tol 1e-08)\n",
260
+ "4. ELBO(theta_t,q_t) = F(theta_t) at the final iterate err=0.000e+00 (tol 1e-12)\n",
261
+ "4. equal-weight M-step objective: gradient at theta_t = grad F(theta_t) (weight 1/p_t per problem on grad p) err=6.245e-17 (tol 1e-14)\n",
262
+ "4. pooled objective (ReST-EM Eq.3 / RFT): gradient at theta_t = grad mean_S p_t (weight 1 on grad p) err=1.388e-17 (tol 1e-14)\n",
263
+ "relative difference of the two directions (cosine): 0.10628750941093855\n"
264
+ ]
265
+ }
266
+ ],
267
+ "source": [
268
+ "m2, K2, d = 8, 12, 5\n",
269
+ "g = np.random.RandomState(11)\n",
270
+ "Phi = torch.tensor(g.randn(m2, K2, d))\n",
271
+ "V2 = (g.rand(m2, K2) < 0.2).astype(float)\n",
272
+ "V2[7, :] = 0.0 # unsolvable prompt\n",
273
+ "for x in range(7):\n",
274
+ " if V2[x].sum() == 0: V2[x, x] = 1.0\n",
275
+ "V2 = torch.tensor(V2)\n",
276
+ "S = (V2.sum(-1) > 0)\n",
277
+ "def logpi(theta): return torch.log_softmax(Phi @ theta, dim=-1) # (m2, K2)\n",
278
+ "def p_x(theta): return (torch.exp(logpi(theta)) * V2).sum(-1)\n",
279
+ "def F(theta): return torch.log(p_x(theta)[S]).mean()\n",
280
+ "def e_step(theta):\n",
281
+ " w = torch.exp(logpi(theta)) * V2\n",
282
+ " return w / w.sum(-1, keepdim=True).clamp_min(1e-300)\n",
283
+ "def m_step(q, theta_init, weights=None):\n",
284
+ " th_ = theta_init.clone().requires_grad_(True)\n",
285
+ " wts = torch.ones(m2) if weights is None else weights\n",
286
+ " opt = torch.optim.LBFGS([th_], lr=1.0, max_iter=500, tolerance_grad=1e-13, tolerance_change=1e-16, line_search_fn='strong_wolfe')\n",
287
+ " def closure():\n",
288
+ " opt.zero_grad()\n",
289
+ " loss = -(wts[S, None] * q[S] * logpi(th_)[S]).sum() / S.sum()\n",
290
+ " loss.backward(); return loss\n",
291
+ " for _ in range(4): opt.step(closure)\n",
292
+ " return th_.detach(), th_.grad.abs().max().item()\n",
293
+ "theta = torch.randn(d) * 0.1\n",
294
+ "Fs, Js, gmax = [float(F(theta))], [float(p_x(theta).mean())], []\n",
295
+ "for it in range(12):\n",
296
+ " q = e_step(theta)\n",
297
+ " theta, gm = m_step(q, theta); gmax.append(gm)\n",
298
+ " Fs.append(float(F(theta))); Js.append(float(p_x(theta).mean()))\n",
299
+ "print('F per iteration:', [round(v, 6) for v in Fs])\n",
300
+ "print('mean p per iter:', [round(v, 6) for v in Js])\n",
301
+ "print('final M-step max |grad|:', gmax[-1])\n",
302
+ "check('4. EM monotone: max decrease of F over 12 iterations (should be <= 0)', max(0.0, max(-(b - a) for a, b in zip(Fs[:-1], Fs[1:]))), 1e-10)\n",
303
+ "check('4. M-step solved to optimality (max |grad| at the last M-step)', gmax[-1], 1e-8)\n",
304
+ "assert Fs[-1] > Fs[0] + 0.05\n",
305
+ "\n",
306
+ "# Tightness at theta_t, per iteration: ELBO(theta_t, q_t) = F(theta_t)\n",
307
+ "q = e_step(theta)\n",
308
+ "elbo = -(q[S] * (torch.log(q[S].clamp_min(1e-300)) - logpi(theta)[S]) * (V2[S] > 0)).sum(-1).mean()\n",
309
+ "check('4. ELBO(theta_t,q_t) = F(theta_t) at the final iterate', abs(float(elbo) - float(F(theta))), 1e-12)\n",
310
+ "\n",
311
+ "# Per-problem weights of the two M-step objectives, checked at theta_t (this is the 1 vs 1/p_t(x) claim of the comparison table):\n",
312
+ "# equal-weight EM objective (1/|S|) sum_x E_{q_t}[log pi_theta] has gradient (1/|S|) sum_x grad log p_t(x) = grad F(theta_t)\n",
313
+ "# pooled RFT / ReST-EM Eq.(3) objective (1/|S|) sum_x p_t(x) E_{q_t}[log pi_theta] has gradient (1/|S|) sum_x grad p_t(x) = grad (mean_S p)\n",
314
+ "th_chk = theta.clone().requires_grad_(True)\n",
315
+ "qt = e_step(theta).detach()\n",
316
+ "(-(qt[S] * logpi(th_chk)[S]).sum() / S.sum()).backward(); g_eq = -th_chk.grad.clone()\n",
317
+ "th_f = theta.clone().requires_grad_(True); F(th_f).backward()\n",
318
+ "check('4. equal-weight M-step objective: gradient at theta_t = grad F(theta_t) (weight 1/p_t per problem on grad p)', (g_eq - th_f.grad).abs().max(), 1e-14)\n",
319
+ "th_chk = theta.clone().requires_grad_(True)\n",
320
+ "pw = p_x(theta).detach()\n",
321
+ "(-(pw[S, None] * qt[S] * logpi(th_chk)[S]).sum() / S.sum()).backward(); g_pool = -th_chk.grad.clone()\n",
322
+ "th_p = theta.clone().requires_grad_(True); p_x(th_p)[S].mean().backward()\n",
323
+ "check('4. pooled objective (ReST-EM Eq.3 / RFT): gradient at theta_t = grad mean_S p_t (weight 1 on grad p)', (g_pool - th_p.grad).abs().max(), 1e-14)\n",
324
+ "print('relative difference of the two directions (cosine):', float(g_eq @ g_pool / (g_eq.norm() * g_pool.norm())))"
325
+ ]
326
+ },
327
+ {
328
+ "cell_type": "markdown",
329
+ "id": "4233ec2d",
330
+ "metadata": {},
331
+ "source": [
332
+ "## 6. The practical objective (unnormalised, weights `p_t(x)`) is not monotone per prompt\n",
333
+ "SFT on the pooled filtered samples weights prompt `x` by (expected) count of correct samples `n p_t(x)`, i.e. objective\n",
334
+ "`sum_x p_t(x) E_{q_t}[log pi_theta]`. With a shared `theta` some prompts get *worse* even though the weighted objective improves."
335
+ ]
336
+ },
337
+ {
338
+ "cell_type": "code",
339
+ "execution_count": 7,
340
+ "id": "fb963f24",
341
+ "metadata": {
342
+ "execution": {
343
+ "iopub.execute_input": "2026-10-14T09:48:00.353128Z",
344
+ "iopub.status.busy": "2026-10-14T09:48:00.352833Z",
345
+ "iopub.status.idle": "2026-10-14T09:48:00.421027Z",
346
+ "shell.execute_reply": "2026-10-14T09:48:00.420159Z"
347
+ }
348
+ },
349
+ "outputs": [
350
+ {
351
+ "name": "stdout",
352
+ "output_type": "stream",
353
+ "text": [
354
+ "iter, mean p on solvable prompts, #prompts whose p decreased, F\n",
355
+ " (0, 0.2615781869672757, 2, -1.4525900599954458)\n",
356
+ " (1, 0.305815927384656, 3, -1.4159837962389783)\n",
357
+ " (2, 0.360538810599986, 4, -1.5299987749300417)\n",
358
+ " (3, 0.3968721919898336, 4, -1.7930711977638583)\n",
359
+ " (4, 0.4128722015161725, 4, -2.0991072756580036)\n",
360
+ " (5, 0.42201137651015436, 3, -2.3539650242141725)\n",
361
+ "total (prompt, iteration) pairs with decreasing p: 20\n"
362
+ ]
363
+ }
364
+ ],
365
+ "source": [
366
+ "theta_u = torch.randn(d) * 0.1\n",
367
+ "rows = []\n",
368
+ "for it in range(6):\n",
369
+ " p_before = p_x(theta_u).clone()\n",
370
+ " q = e_step(theta_u)\n",
371
+ " theta_u, gm = m_step(q, theta_u, weights=p_before.detach())\n",
372
+ " p_after = p_x(theta_u)\n",
373
+ " rows.append((it, float(p_after[S].mean()), int((p_after[S] < p_before[S] - 1e-12).sum()), float(F(theta_u))))\n",
374
+ "print('iter, mean p on solvable prompts, #prompts whose p decreased, F')\n",
375
+ "for r in rows: print(' ', r)\n",
376
+ "n_dec = sum(r[2] for r in rows)\n",
377
+ "print('total (prompt, iteration) pairs with decreasing p:', n_dec)\n",
378
+ "CHECKS.append(('6. non-monotone prompts exist under unnormalised weights (count of decreasing p, informational)', float(n_dec), float('inf')))"
379
+ ]
380
+ },
381
+ {
382
+ "cell_type": "markdown",
383
+ "id": "ba81c4f7",
384
+ "metadata": {},
385
+ "source": [
386
+ "## 7. Sampling estimator: unbiased except for the event `c = 0`\n",
387
+ "One prompt, outcomes `y in {0,1,2,3}`, `V=(0,1,1,0)`, `pi=(.4,.1,.2,.3)`, a test function `f`. Draw `n=3` i.i.d. samples; keep correct ones;\n",
388
+ "estimate `E_q[f]` by `(1/c) sum_i V_i f(y_i)` when `c>0` (else 0). Claim: expectation `= (1-(1-p)^n) E_q f` exactly.\n",
389
+ "NAIVE: enumerate all `4^3` sample tuples with their probabilities. EFFICIENT: the closed form."
390
+ ]
391
+ },
392
+ {
393
+ "cell_type": "code",
394
+ "execution_count": 8,
395
+ "id": "52c6844e",
396
+ "metadata": {
397
+ "execution": {
398
+ "iopub.execute_input": "2026-10-14T09:48:00.422626Z",
399
+ "iopub.status.busy": "2026-10-14T09:48:00.422467Z",
400
+ "iopub.status.idle": "2026-10-14T09:48:00.441049Z",
401
+ "shell.execute_reply": "2026-10-14T09:48:00.440191Z"
402
+ }
403
+ },
404
+ "outputs": [
405
+ {
406
+ "name": "stdout",
407
+ "output_type": "stream",
408
+ "text": [
409
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=1 err=0.000e+00 (tol 1e-14)\n",
410
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=2 err=3.331e-16 (tol 1e-14)\n",
411
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=3 err=8.882e-16 (tol 1e-14)\n",
412
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=5 err=2.220e-15 (tol 1e-14)\n",
413
+ "5. pooled filtered average: E[(1/n) sum V f] = p E_q[f] err=3.331e-16 (tol 1e-14)\n"
414
+ ]
415
+ }
416
+ ],
417
+ "source": [
418
+ "pi4 = np.array([.4, .1, .2, .3]); V4 = np.array([0, 1, 1, 0.]); f4 = np.array([1.3, -0.7, 2.9, 5.0])\n",
419
+ "p = float((pi4 * V4).sum()); Eq_f = float((pi4 * V4 * f4).sum() / p)\n",
420
+ "for n in (1, 2, 3, 5):\n",
421
+ " tot = 0.0\n",
422
+ " for tup in itertools.product(range(4), repeat=n):\n",
423
+ " prob = np.prod([pi4[i] for i in tup]); c = sum(V4[i] for i in tup)\n",
424
+ " if c > 0: tot += prob * sum(V4[i] * f4[i] for i in tup) / c\n",
425
+ " closed = (1 - (1 - p) ** n) * Eq_f\n",
426
+ " check(f'5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n={n}', abs(tot - closed), 1e-14)\n",
427
+ "\n",
428
+ "# Pooled (unnormalised) filtered average: E[(1/n) sum_i V_i f(y_i)] = p E_q[f] exactly (linearity).\n",
429
+ "n = 4; tot = 0.0\n",
430
+ "for tup in itertools.product(range(4), repeat=n):\n",
431
+ " prob = np.prod([pi4[i] for i in tup]); tot += prob * sum(V4[i] * f4[i] for i in tup) / n\n",
432
+ "check('5. pooled filtered average: E[(1/n) sum V f] = p E_q[f]', abs(tot - p * Eq_f), 1e-14)"
433
+ ]
434
+ },
435
+ {
436
+ "cell_type": "markdown",
437
+ "id": "e2501a9f",
438
+ "metadata": {},
439
+ "source": [
440
+ "## 8. Monte Carlo agreement with the exact tabular gradient (RFT step = REINFORCE step)"
441
+ ]
442
+ },
443
+ {
444
+ "cell_type": "code",
445
+ "execution_count": 9,
446
+ "id": "5aa7257d",
447
+ "metadata": {
448
+ "execution": {
449
+ "iopub.execute_input": "2026-10-14T09:48:00.442590Z",
450
+ "iopub.status.busy": "2026-10-14T09:48:00.442434Z",
451
+ "iopub.status.idle": "2026-10-14T09:48:00.525797Z",
452
+ "shell.execute_reply": "2026-10-14T09:48:00.524856Z"
453
+ }
454
+ },
455
+ "outputs": [
456
+ {
457
+ "name": "stdout",
458
+ "output_type": "stream",
459
+ "text": [
460
+ "7. Monte Carlo RFT gradient (N=4e5 per prompt) vs grad J (tol 3e-4) err=1.941e-04 (tol 3e-04)\n"
461
+ ]
462
+ }
463
+ ],
464
+ "source": [
465
+ "N = 400_000\n",
466
+ "gen = torch.Generator().manual_seed(3)\n",
467
+ "Pt = pi_tab(logits0)\n",
468
+ "gmc = torch.zeros(m, K)\n",
469
+ "for x in range(m):\n",
470
+ " ys = torch.multinomial(Pt[x], N, replacement=True, generator=gen)\n",
471
+ " cnt = torch.bincount(ys, minlength=K).double() / N # empirical pi_t\n",
472
+ " # grad of (1/N) sum_i V log pi_theta(y_i) wrt logits at theta_t: V * (onehot - pi)\n",
473
+ " gmc[x] = ((cnt * V[x])[:, None] * (torch.eye(K) - Pt[x][None, :])).sum(0) / m\n",
474
+ "err = (gmc - gJ).abs().max()\n",
475
+ "check('7. Monte Carlo RFT gradient (N=4e5 per prompt) vs grad J (tol 3e-4)', err, 3e-4)"
476
+ ]
477
+ },
478
+ {
479
+ "cell_type": "markdown",
480
+ "id": "ba4f6acb",
481
+ "metadata": {},
482
+ "source": [
483
+ "## Verification table"
484
+ ]
485
+ },
486
+ {
487
+ "cell_type": "code",
488
+ "execution_count": 10,
489
+ "id": "3cb4ed0a",
490
+ "metadata": {
491
+ "execution": {
492
+ "iopub.execute_input": "2026-10-14T09:48:00.527272Z",
493
+ "iopub.status.busy": "2026-10-14T09:48:00.527101Z",
494
+ "iopub.status.idle": "2026-10-14T09:48:00.530845Z",
495
+ "shell.execute_reply": "2026-10-14T09:48:00.530126Z"
496
+ }
497
+ },
498
+ "outputs": [
499
+ {
500
+ "name": "stdout",
501
+ "output_type": "stream",
502
+ "text": [
503
+ "\n",
504
+ "identity max abs err\n",
505
+ "1. grad of RFT objective at theta_t vs grad J(theta_t) 3.469e-18\n",
506
+ "2. ELBO identity -KL(q||pi) = log p - KL(q||q*) 0.000e+00\n",
507
+ "2. tightness: -KL(q*||pi) = log p 2.220e-16\n",
508
+ "3. p_{t+1}(x) = 1 on covered prompts (max |p-1|) 0.000e+00\n",
509
+ "3. p_{t+1}(x) = 0 on uncovered prompt (coverage failure) 0.000e+00\n",
510
+ "4. EM monotone: max decrease of F over 12 iterations (should be <= 0) 0.000e+00\n",
511
+ "4. M-step solved to optimality (max |grad| at the last M-step) 1.218e-09\n",
512
+ "4. ELBO(theta_t,q_t) = F(theta_t) at the final iterate 0.000e+00\n",
513
+ "4. equal-weight M-step objective: gradient at theta_t = grad F(theta_t) (weight 1/p_t per problem on grad p) 6.245e-17\n",
514
+ "4. pooled objective (ReST-EM Eq.3 / RFT): gradient at theta_t = grad mean_S p_t (weight 1 on grad p) 1.388e-17\n",
515
+ "6. non-monotone prompts exist under unnormalised weights (count of decreasing p, informational) 2.000e+01\n",
516
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=1 0.000e+00\n",
517
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=2 3.331e-16\n",
518
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=3 8.882e-16\n",
519
+ "5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=5 2.220e-15\n",
520
+ "5. pooled filtered average: E[(1/n) sum V f] = p E_q[f] 3.331e-16\n",
521
+ "7. Monte Carlo RFT gradient (N=4e5 per prompt) vs grad J (tol 3e-4) 1.941e-04\n",
522
+ "CHECKS_JSON:[[\"1. grad of RFT objective at theta_t vs grad J(theta_t)\", 3.469446951953614e-18, 1e-16], [\"2. ELBO identity -KL(q||pi) = log p - KL(q||q*)\", 0.0, 1e-14], [\"2. tightness: -KL(q*||pi) = log p\", 2.220446049250313e-16, 1e-14], [\"3. p_{t+1}(x) = 1 on covered prompts (max |p-1|)\", 0.0, 1e-14], [\"3. p_{t+1}(x) = 0 on uncovered prompt (coverage failure)\", 0.0, 0.0], [\"4. EM monotone: max decrease of F over 12 iterations (should be <= 0)\", 0.0, 1e-10], [\"4. M-step solved to optimality (max |grad| at the last M-step)\", 1.2179568404172003e-09, 1e-08], [\"4. ELBO(theta_t,q_t) = F(theta_t) at the final iterate\", 0.0, 1e-12], [\"4. equal-weight M-step objective: gradient at theta_t = grad F(theta_t) (weight 1/p_t per problem on grad p)\", 6.245004513516506e-17, 1e-14], [\"4. pooled objective (ReST-EM Eq.3 / RFT): gradient at theta_t = grad mean_S p_t (weight 1 on grad p)\", 1.3877787807814457e-17, 1e-14], [\"6. non-monotone prompts exist under unnormalised weights (count of decreasing p, informational)\", 20.0, Infinity], [\"5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=1\", 0.0, 1e-14], [\"5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=2\", 3.3306690738754696e-16, 1e-14], [\"5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=3\", 8.881784197001252e-16, 1e-14], [\"5. E[filtered mean of f] = (1-(1-p)^n) E_q[f], n=5\", 2.220446049250313e-15, 1e-14], [\"5. pooled filtered average: E[(1/n) sum V f] = p E_q[f]\", 3.3306690738754696e-16, 1e-14], [\"7. Monte Carlo RFT gradient (N=4e5 per prompt) vs grad J (tol 3e-4)\", 0.00019412726116547693, 0.0003]]\n"
523
+ ]
524
+ }
525
+ ],
526
+ "source": [
527
+ "print()\n",
528
+ "print(f'{\"identity\":86s} {\"max abs err\":>12s}')\n",
529
+ "for n_, e_, t_ in CHECKS: print(f'{n_:86s} {e_:12.3e}')\n",
530
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
531
+ ]
532
+ }
533
+ ],
534
+ "metadata": {
535
+ "kernelspec": {
536
+ "display_name": "Python 3",
537
+ "language": "python",
538
+ "name": "python3"
539
+ },
540
+ "language_info": {
541
+ "codemirror_mode": {
542
+ "name": "ipython",
543
+ "version": 3
544
+ },
545
+ "file_extension": ".py",
546
+ "mimetype": "text/x-python",
547
+ "name": "python",
548
+ "nbconvert_exporter": "python",
549
+ "pygments_lexer": "ipython3",
550
+ "version": "3.12.11"
551
+ }
552
+ },
553
+ "nbformat": 4,
554
+ "nbformat_minor": 5
555
+ }
code/notebooks/0203-pass-at-k.ipynb ADDED
@@ -0,0 +1,657 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "d6b3450b",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0203 -- pass@k: the unbiased estimator, its variance, and pass@1 vs pass@k\n",
9
+ "\n",
10
+ "Identities verified (exact rational arithmetic where possible, otherwise fp64):\n",
11
+ "1. `C(n,c) C(n-c,k) / C(n,k) = C(n-k,c)` and `E_c[ C(n-c,k)/C(n,k) ] = (1-p)^k` (exact `Fraction`).\n",
12
+ "2. Stable product form `prod_{i=n-c+1}^{n} (1-k/i)` equals the ratio of binomials; the naive float ratio overflows.\n",
13
+ "3. Variance by Hoeffding's U-statistic formula equals the exact binomial-sum variance; leading term `k^2 p (1-p)^(2k-1) / n`.\n",
14
+ "4. Plug-in `1-(1-p_hat)^k` is biased low: exact bias, and `-p(1-p)/n` for `k=2`.\n",
15
+ "5. `grad J_k = E_x[k (1-p)^(k-1) grad p]` against autograd.\n",
16
+ "6. A context-free strategy-mixing policy where the `J_1`-optimum and `J_32`-optimum differ (diversity vs sharpening).\n",
17
+ "7. The unbiased pass@k policy-gradient estimator: naive subset enumeration = closed-form coefficients; expectation = `grad J_k` exactly; leave-one-out form.\n",
18
+ "8. No unbiased estimator exists for `k > n` (Bernstein-basis projection residual)."
19
+ ]
20
+ },
21
+ {
22
+ "cell_type": "code",
23
+ "execution_count": 1,
24
+ "id": "e6de1a23",
25
+ "metadata": {
26
+ "execution": {
27
+ "iopub.execute_input": "2026-10-14T08:57:46.427466Z",
28
+ "iopub.status.busy": "2026-10-14T08:57:46.427307Z",
29
+ "iopub.status.idle": "2026-10-14T08:57:48.231511Z",
30
+ "shell.execute_reply": "2026-10-14T08:57:48.230621Z"
31
+ }
32
+ },
33
+ "outputs": [],
34
+ "source": [
35
+ "import os\n",
36
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
37
+ "import itertools, math, json\n",
38
+ "from fractions import Fraction as Fr\n",
39
+ "from math import comb\n",
40
+ "import numpy as np, torch\n",
41
+ "torch.set_default_dtype(torch.float64)\n",
42
+ "CHECKS = []\n",
43
+ "def check(name, err, tol):\n",
44
+ " CHECKS.append((name, float(err), float(tol)))\n",
45
+ " assert err <= tol, (name, float(err), tol)\n",
46
+ " print(f'{name:80s} err={float(err):.3e} (tol {tol:.0e})')"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "markdown",
51
+ "id": "d785fb44",
52
+ "metadata": {},
53
+ "source": [
54
+ "## 1. Unbiasedness, exactly\n",
55
+ "For rational `p` the binomial expectation is a rational number, so the check is an *equality* of `Fraction`s."
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "code",
60
+ "execution_count": 2,
61
+ "id": "958d0931",
62
+ "metadata": {
63
+ "execution": {
64
+ "iopub.execute_input": "2026-10-14T08:57:48.233490Z",
65
+ "iopub.status.busy": "2026-10-14T08:57:48.233261Z",
66
+ "iopub.status.idle": "2026-10-14T08:57:48.338023Z",
67
+ "shell.execute_reply": "2026-10-14T08:57:48.337325Z"
68
+ }
69
+ },
70
+ "outputs": [
71
+ {
72
+ "name": "stdout",
73
+ "output_type": "stream",
74
+ "text": [
75
+ "1. E_c[1 - C(n-c,k)/C(n,k)] = 1-(1-p)^k, exact rational, all n<=40 tested err=0.000e+00 (tol 0e+00)\n",
76
+ "1. identity C(n,c)C(n-c,k) = C(n,k)C(n-k,c) (violations over n<30) err=0.000e+00 (tol 0e+00)\n"
77
+ ]
78
+ }
79
+ ],
80
+ "source": [
81
+ "def passk_unbiased_expectation(n, k, p):\n",
82
+ " p = Fr(p)\n",
83
+ " s = Fr(0)\n",
84
+ " for c in range(n + 1):\n",
85
+ " pc = comb(n, c) * p ** c * (1 - p) ** (n - c)\n",
86
+ " s += pc * (1 - Fr(comb(n - c, k), comb(n, k))) # comb(n-c,k)=0 when n-c<k\n",
87
+ " return s\n",
88
+ "worst = Fr(0)\n",
89
+ "for n in (1, 2, 5, 12, 40):\n",
90
+ " for k in range(1, n + 1):\n",
91
+ " for p in (Fr(1, 7), Fr(1, 2), Fr(9, 10), Fr(1, 100)):\n",
92
+ " worst = max(worst, abs(passk_unbiased_expectation(n, k, p) - (1 - (1 - p) ** k)))\n",
93
+ "check('1. E_c[1 - C(n-c,k)/C(n,k)] = 1-(1-p)^k, exact rational, all n<=40 tested', float(worst), 0.0)\n",
94
+ "bad = 0\n",
95
+ "for n in range(1, 30):\n",
96
+ " for k in range(0, n + 1):\n",
97
+ " for c in range(0, n + 1):\n",
98
+ " if comb(n, c) * comb(n - c, k) * 1 != comb(n, k) * comb(n - k, c) * 1: bad += 1\n",
99
+ "check('1. identity C(n,c)C(n-c,k) = C(n,k)C(n-k,c) (violations over n<30)', bad, 0)"
100
+ ]
101
+ },
102
+ {
103
+ "cell_type": "markdown",
104
+ "id": "374d4e73",
105
+ "metadata": {},
106
+ "source": [
107
+ "## 2. Stable implementation vs naive ratio of binomials\n",
108
+ "`C(n-c,k)/C(n,k) = prod_{i=n-c+1}^{n} (1 - k/i)` (telescoping; derived in the note)."
109
+ ]
110
+ },
111
+ {
112
+ "cell_type": "code",
113
+ "execution_count": 3,
114
+ "id": "8a3dc0ca",
115
+ "metadata": {
116
+ "execution": {
117
+ "iopub.execute_input": "2026-10-14T08:57:48.340113Z",
118
+ "iopub.status.busy": "2026-10-14T08:57:48.339952Z",
119
+ "iopub.status.idle": "2026-10-14T08:57:48.347641Z",
120
+ "shell.execute_reply": "2026-10-14T08:57:48.346855Z"
121
+ }
122
+ },
123
+ "outputs": [
124
+ {
125
+ "name": "stdout",
126
+ "output_type": "stream",
127
+ "text": [
128
+ "2. stable product form vs exact rational (max over grid incl. n=1000,k=999) err=8.327e-17 (tol 1e-12)\n",
129
+ "naive float on n=6000,k=3000: OverflowError -> int too large to convert to float\n",
130
+ "stable value for the same input : 1.0\n"
131
+ ]
132
+ }
133
+ ],
134
+ "source": [
135
+ "def pass_at_k_naive_float(n, c, k):\n",
136
+ " return 1.0 - float(comb(n - c, k)) / float(comb(n, k)) # float(comb) overflows for big n,k\n",
137
+ "def pass_at_k_stable(n, c, k):\n",
138
+ " if n - c < k: return 1.0\n",
139
+ " return 1.0 - float(np.prod(1.0 - k / np.arange(n - c + 1, n + 1)))\n",
140
+ "def pass_at_k_exact(n, c, k): return 1 - Fr(comb(n - c, k), comb(n, k))\n",
141
+ "err = 0.0\n",
142
+ "for (n, k) in [(10, 3), (200, 10), (200, 100), (1000, 100), (1000, 999)]:\n",
143
+ " for c in sorted(set([0, 1, 2, n // 7, n // 2, n - k, n - k + 1, n - 1, n])):\n",
144
+ " if c > n: continue\n",
145
+ " err = max(err, abs(pass_at_k_stable(n, c, k) - float(pass_at_k_exact(n, c, k))))\n",
146
+ "check('2. stable product form vs exact rational (max over grid incl. n=1000,k=999)', err, 1e-12)\n",
147
+ "try:\n",
148
+ " v = pass_at_k_naive_float(6000, 100, 3000); print('naive float on n=6000,k=3000:', v)\n",
149
+ " naive_failed = not np.isfinite(v)\n",
150
+ "except OverflowError as e:\n",
151
+ " naive_failed = True; print('naive float on n=6000,k=3000: OverflowError ->', e)\n",
152
+ "assert naive_failed\n",
153
+ "print('stable value for the same input :', pass_at_k_stable(6000, 100, 3000))\n",
154
+ "# the naive form silently loses precision / overflows; the stable form is a product of terms in (0,1)"
155
+ ]
156
+ },
157
+ {
158
+ "cell_type": "markdown",
159
+ "id": "d9320e2e",
160
+ "metadata": {},
161
+ "source": [
162
+ "## 3. Variance of the unbiased estimator\n",
163
+ "EXACT (naive): `Var = E[rho^2] - (1-p)^{2k}` summed over the binomial, `rho = C(n-c,k)/C(n,k)`.\n",
164
+ "HOEFFDING (efficient): the estimator is a U-statistic with kernel `h(S)=1{all k in S fail}`, and two random k-subsets overlapping in `j`\n",
165
+ "elements both fail with probability `(1-p)^{2k-j}`, so\n",
166
+ "`Var = sum_{j=1}^{k} w_j [ (1-p)^{2k-j} - (1-p)^{2k} ]`, `w_j = C(k,j) C(n-k,k-j) / C(n,k)` (hypergeometric overlap pmf)."
167
+ ]
168
+ },
169
+ {
170
+ "cell_type": "code",
171
+ "execution_count": 4,
172
+ "id": "3441dcee",
173
+ "metadata": {
174
+ "execution": {
175
+ "iopub.execute_input": "2026-10-14T08:57:48.349346Z",
176
+ "iopub.status.busy": "2026-10-14T08:57:48.349183Z",
177
+ "iopub.status.idle": "2026-10-14T08:57:48.377294Z",
178
+ "shell.execute_reply": "2026-10-14T08:57:48.376327Z"
179
+ }
180
+ },
181
+ "outputs": [
182
+ {
183
+ "name": "stdout",
184
+ "output_type": "stream",
185
+ "text": [
186
+ "3. Hoeffding U-statistic variance = exact binomial-sum variance (exact Fractions) err=0.000e+00 (tol 1e-18)\n",
187
+ "n=200,k=8,p=0.3: exact Var=4.804470e-04 leading term k^2 p (1-p)^(2k-1)/n=4.557659e-04\n",
188
+ "3. exact variance vs leading term (relative error, n=200) err=5.415e-02 (tol 1e-01)\n"
189
+ ]
190
+ }
191
+ ],
192
+ "source": [
193
+ "def var_exact(n, k, p):\n",
194
+ " p = Fr(p); m1 = Fr(0); m2 = Fr(0)\n",
195
+ " for c in range(n + 1):\n",
196
+ " pc = comb(n, c) * p ** c * (1 - p) ** (n - c); rho = Fr(comb(n - c, k), comb(n, k))\n",
197
+ " m1 += pc * rho; m2 += pc * rho * rho\n",
198
+ " return m2 - m1 * m1\n",
199
+ "def var_hoeffding(n, k, p):\n",
200
+ " p = Fr(p); s = Fr(0)\n",
201
+ " for j in range(1, k + 1):\n",
202
+ " wj = Fr(comb(k, j) * comb(n - k, k - j), comb(n, k))\n",
203
+ " s += wj * ((1 - p) ** (2 * k - j) - (1 - p) ** (2 * k))\n",
204
+ " return s\n",
205
+ "worst = 0.0\n",
206
+ "for (n, k) in [(5, 2), (16, 4), (16, 8), (50, 10), (30, 30)]:\n",
207
+ " for p in (Fr(1, 20), Fr(3, 10), Fr(1, 2), Fr(9, 10)):\n",
208
+ " worst = max(worst, abs(float(var_exact(n, k, p) - var_hoeffding(n, k, p))))\n",
209
+ "check('3. Hoeffding U-statistic variance = exact binomial-sum variance (exact Fractions)', worst, 1e-18)\n",
210
+ "n, k, p = 200, 8, 0.3\n",
211
+ "lead = k ** 2 * p * (1 - p) ** (2 * k - 1) / n\n",
212
+ "print(f'n={n},k={k},p={p}: exact Var={float(var_exact(n,k,Fr(3,10))):.6e} leading term k^2 p (1-p)^(2k-1)/n={lead:.6e}')\n",
213
+ "check('3. exact variance vs leading term (relative error, n=200)', abs(float(var_exact(n, k, Fr(3, 10))) / lead - 1), 0.12)"
214
+ ]
215
+ },
216
+ {
217
+ "cell_type": "markdown",
218
+ "id": "dfad7109",
219
+ "metadata": {},
220
+ "source": [
221
+ "## 4. The plug-in estimator `1-(1-p_hat)^k` is biased low\n",
222
+ "By Jensen (`x -> (1-x)^k` is convex) `E[(1-p_hat)^k] >= (1-p)^k`. For `k=2`: `E(1-p_hat)^2 = (1-p)^2 + p(1-p)/n` exactly."
223
+ ]
224
+ },
225
+ {
226
+ "cell_type": "code",
227
+ "execution_count": 5,
228
+ "id": "6494c052",
229
+ "metadata": {
230
+ "execution": {
231
+ "iopub.execute_input": "2026-10-14T08:57:48.379114Z",
232
+ "iopub.status.busy": "2026-10-14T08:57:48.378897Z",
233
+ "iopub.status.idle": "2026-10-14T08:57:48.388166Z",
234
+ "shell.execute_reply": "2026-10-14T08:57:48.387120Z"
235
+ }
236
+ },
237
+ "outputs": [
238
+ {
239
+ "name": "stdout",
240
+ "output_type": "stream",
241
+ "text": [
242
+ "4. k=2 exact bias of plug-in = -p(1-p)/n err=0.000e+00 (tol 0e+00)\n",
243
+ "bias of plug-in pass@k (n=8,p=.3): {1: 0.0, 2: -0.02625, 4: -0.075460546875, 8: -0.10406236171669006}\n",
244
+ "4. k=1 plug-in is unbiased err=0.000e+00 (tol 0e+00)\n"
245
+ ]
246
+ }
247
+ ],
248
+ "source": [
249
+ "def plugin_expectation(n, k, p):\n",
250
+ " p = Fr(p); return sum(comb(n, c) * p ** c * (1 - p) ** (n - c) * (1 - Fr(c, n)) ** k for c in range(n + 1))\n",
251
+ "n, p = 8, Fr(3, 10)\n",
252
+ "bias2 = (1 - plugin_expectation(n, 2, p)) - (1 - (1 - p) ** 2) # E[plugin pass@2] - truth\n",
253
+ "check('4. k=2 exact bias of plug-in = -p(1-p)/n', float(abs(bias2 + p * (1 - p) / n)), 0.0)\n",
254
+ "print('bias of plug-in pass@k (n=8,p=.3):', {k_: float((1 - plugin_expectation(8, k_, p)) - (1 - (1 - p) ** k_)) for k_ in (1, 2, 4, 8)})\n",
255
+ "assert all((1 - plugin_expectation(8, k_, p)) - (1 - (1 - p) ** k_) <= 0 for k_ in range(1, 9))\n",
256
+ "check('4. k=1 plug-in is unbiased', float(abs((1 - plugin_expectation(8, 1, p)) - p)), 0.0)"
257
+ ]
258
+ },
259
+ {
260
+ "cell_type": "markdown",
261
+ "id": "1e4a9d5e",
262
+ "metadata": {},
263
+ "source": [
264
+ "## 5. Gradient of the pass@k objective: `k (1-p)^(k-1) grad p`\n",
265
+ "Tabular softmax per prompt, `J_k = mean_x [1-(1-p_x)^k]`."
266
+ ]
267
+ },
268
+ {
269
+ "cell_type": "code",
270
+ "execution_count": 6,
271
+ "id": "f7df0b58",
272
+ "metadata": {
273
+ "execution": {
274
+ "iopub.execute_input": "2026-10-14T08:57:48.390043Z",
275
+ "iopub.status.busy": "2026-10-14T08:57:48.389832Z",
276
+ "iopub.status.idle": "2026-10-14T08:57:48.487130Z",
277
+ "shell.execute_reply": "2026-10-14T08:57:48.486143Z"
278
+ }
279
+ },
280
+ "outputs": [
281
+ {
282
+ "name": "stdout",
283
+ "output_type": "stream",
284
+ "text": [
285
+ "5. grad J_k = E_x[k (1-p)^{k-1} grad p] vs autograd, k in {1,2,8,64} err=6.939e-18 (tol 1e-16)\n",
286
+ "weights w_k(p)=k(1-p)^{k-1}:\n",
287
+ " k= 1 [1. 1. 1. 1. 1. 1.]\n",
288
+ " k= 4 [3.8812 3.4295 2.916 1.372 0.5 0.004 ]\n",
289
+ " k= 16 [1.37609e+01 7.41270e+00 3.29430e+00 7.60000e-02 5.00000e-04 0.00000e+00]\n",
290
+ " k= 64 [33.978 2.5279 0.0838 0. 0. 0. ]\n"
291
+ ]
292
+ }
293
+ ],
294
+ "source": [
295
+ "torch.manual_seed(0)\n",
296
+ "Kout, Xn = 9, 7\n",
297
+ "Vtab = (torch.rand(Xn, Kout) < 0.25).double(); Vtab[0, :] = 0; Vtab[0, 3] = 1\n",
298
+ "z = (torch.randn(Xn, Kout) * 0.8).requires_grad_(True)\n",
299
+ "def p_x(z): return (torch.softmax(z, -1) * Vtab).sum(-1)\n",
300
+ "worst = 0.0\n",
301
+ "for k in (1, 2, 8, 64):\n",
302
+ " z.grad = None\n",
303
+ " Jk = (1 - (1 - p_x(z)) ** k).mean(); Jk.backward(); g_auto = z.grad.clone()\n",
304
+ " z2 = z.detach().clone().requires_grad_(True)\n",
305
+ " pz = p_x(z2); pz.sum().backward() # d p_x / dz row-wise (each p_x depends only on its row)\n",
306
+ " gp = z2.grad # row x holds grad p_x\n",
307
+ " g_hand = (k * (1 - pz.detach()) ** (k - 1))[:, None] * gp / Xn\n",
308
+ " worst = max(worst, float((g_auto - g_hand).abs().max()))\n",
309
+ "check('5. grad J_k = E_x[k (1-p)^{k-1} grad p] vs autograd, k in {1,2,8,64}', worst, 1e-16)\n",
310
+ "pv = np.array([0.01, 0.05, 0.1, 0.3, 0.5, 0.9])\n",
311
+ "print('weights w_k(p)=k(1-p)^{k-1}:')\n",
312
+ "for k in (1, 4, 16, 64): print(f' k={k:3d}', np.round(k * (1 - pv) ** (k - 1), 4))"
313
+ ]
314
+ },
315
+ {
316
+ "cell_type": "markdown",
317
+ "id": "6a670727",
318
+ "metadata": {},
319
+ "source": [
320
+ "## 6. Why optimising pass@1 and pass@k pull in different directions\n",
321
+ "Within one prompt `pass@k = 1-(1-p)^k` is increasing in `p`, so the two objectives *agree* on that prompt. They disagree **across prompts**\n",
322
+ "through the weights `1` vs `k(1-p)^{k-1}`. A context-free \"strategy\" policy makes this concrete: `pi` over 4 strategies; strategy 0 is a\n",
323
+ "broad generalist that solves 8 \"easy\" prompts with prob 0.6 and nothing else; strategies 1..3 each solve one of 4 hard prompts... with prob 0.3\n",
324
+ "(strategy 1 -> hard prompts 8, 9; strategy 2 -> 10; strategy 3 -> 11) and 0.1 on easy ones."
325
+ ]
326
+ },
327
+ {
328
+ "cell_type": "code",
329
+ "execution_count": 7,
330
+ "id": "0e976d80",
331
+ "metadata": {
332
+ "execution": {
333
+ "iopub.execute_input": "2026-10-14T08:57:48.488964Z",
334
+ "iopub.status.busy": "2026-10-14T08:57:48.488802Z",
335
+ "iopub.status.idle": "2026-10-14T08:57:53.482658Z",
336
+ "shell.execute_reply": "2026-10-14T08:57:53.481750Z"
337
+ }
338
+ },
339
+ "outputs": [
340
+ {
341
+ "name": "stdout",
342
+ "output_type": "stream",
343
+ "text": [
344
+ "argmax J_1 : pi = [1. 0. 0. 0.] entropy 0.0\n",
345
+ "argmax J_32: pi = [0.111 0.3414 0.2738 0.2738] entropy 1.3202\n",
346
+ "J_1 at th1=0.4000 J_1 at th32=0.1344\n",
347
+ "J_32 at th1=0.6667 J_32 at th32=0.9810\n",
348
+ "k=1: |grad| at optimum = 2.12e-07\n",
349
+ "k=32: |grad| at optimum = 4.82e-05\n",
350
+ "6. stationarity of both optima (max |grad J_k| at the returned theta) err=4.823e-05 (tol 1e-03)\n",
351
+ "6. cross-inferiority gaps: min(J_1(th1)-J_1(th32), J_32(th32)-J_32(th1)) > 0 (reported as 0 if true) err=0.000e+00 (tol 0e+00)\n"
352
+ ]
353
+ }
354
+ ],
355
+ "source": [
356
+ "Xn = 12\n",
357
+ "S = torch.zeros(Xn, 4)\n",
358
+ "S[:8, 0] = 0.6; S[:8, 1:] = 0.1\n",
359
+ "S[8, 1] = 0.3; S[9, 1] = 0.3; S[10, 2] = 0.3; S[11, 3] = 0.3\n",
360
+ "def J_k(theta, k):\n",
361
+ " pi = torch.softmax(theta, 0); p = S @ pi\n",
362
+ " return (1 - (1 - p) ** k).mean()\n",
363
+ "def opt(k, steps=6000, lr=0.3, seed=0):\n",
364
+ " th = torch.zeros(4, requires_grad=True)\n",
365
+ " o = torch.optim.Adam([th], lr=lr)\n",
366
+ " for _ in range(steps):\n",
367
+ " o.zero_grad(); (-J_k(th, k)).backward(); o.step()\n",
368
+ " return th.detach()\n",
369
+ "th1, th32 = opt(1), opt(32)\n",
370
+ "H = lambda th: float(-(torch.softmax(th, 0) * torch.log_softmax(th, 0)).sum())\n",
371
+ "print('argmax J_1 : pi =', np.round(torch.softmax(th1, 0).numpy(), 4), ' entropy', round(H(th1), 4))\n",
372
+ "print('argmax J_32: pi =', np.round(torch.softmax(th32, 0).numpy(), 4), ' entropy', round(H(th32), 4))\n",
373
+ "print(f'J_1 at th1={float(J_k(th1,1)):.4f} J_1 at th32={float(J_k(th32,1)):.4f}')\n",
374
+ "print(f'J_32 at th1={float(J_k(th1,32)):.4f} J_32 at th32={float(J_k(th32,32)):.4f}')\n",
375
+ "assert J_k(th1, 1) > J_k(th32, 1) + 1e-3 and J_k(th32, 32) > J_k(th1, 32) + 1e-3 and H(th32) > H(th1)\n",
376
+ "# Check that the optimiser reached a stationary point (max |grad| of J_k at the returned theta)\n",
377
+ "gmax = []\n",
378
+ "for k, th in ((1, th1), (32, th32)):\n",
379
+ " t = th.clone().requires_grad_(True); J_k(t, k).backward()\n",
380
+ " gmax.append(float(t.grad.abs().max())); print(f'k={k}: |grad| at optimum = {gmax[-1]:.2e}')\n",
381
+ "check('6. stationarity of both optima (max |grad J_k| at the returned theta)', max(gmax), 1e-3)\n",
382
+ "check('6. cross-inferiority gaps: min(J_1(th1)-J_1(th32), J_32(th32)-J_32(th1)) > 0 (reported as 0 if true)', 0.0 if min(float(J_k(th1,1)-J_k(th32,1)), float(J_k(th32,32)-J_k(th1,32))) > 0 else 1.0, 0.0)"
383
+ ]
384
+ },
385
+ {
386
+ "cell_type": "markdown",
387
+ "id": "75d59643",
388
+ "metadata": {},
389
+ "source": [
390
+ "## 7. The unbiased pass@k *gradient* estimator (PKPO-style), exact enumeration\n",
391
+ "Tabular softmax over `K=4` outcomes with binary reward vector `r`, `n=5` samples, `k=3`. For a tuple `(y_1..y_n)`:\n",
392
+ "* NAIVE: `g = (1/C(n,k)) sum_S max_{i in S} r_i * sum_{i in S} grad log pi(y_i)` (all `C(n,k)` subsets).\n",
393
+ "* CLOSED FORM: `g = sum_i w_i grad log pi(y_i)` with `w_i = (k/n)[ rho_{-i} + r_i (1 - rho_{-i}) ]`, where `rho_{-i}=1-C(n-1-c_{-i},k-1)/C(n-1,k-1)`\n",
394
+ " is the unbiased pass@(k-1) estimate from the other `n-1` samples, `c_{-i}` the number correct among them.\n",
395
+ "* LEAVE-ONE-OUT form: subtract the baseline `(k/n) rho_{-i}` (independent of `y_i`): coefficient `(k/n) r_i (1-rho_{-i})`.\n",
396
+ "`grad J_k = k (1-p)^{k-1} grad p` is the target."
397
+ ]
398
+ },
399
+ {
400
+ "cell_type": "code",
401
+ "execution_count": 8,
402
+ "id": "556c657b",
403
+ "metadata": {
404
+ "execution": {
405
+ "iopub.execute_input": "2026-10-14T08:57:53.484341Z",
406
+ "iopub.status.busy": "2026-10-14T08:57:53.484099Z",
407
+ "iopub.status.idle": "2026-10-14T08:57:54.020052Z",
408
+ "shell.execute_reply": "2026-10-14T08:57:54.019291Z"
409
+ }
410
+ },
411
+ "outputs": [
412
+ {
413
+ "name": "stdout",
414
+ "output_type": "stream",
415
+ "text": [
416
+ "7. closed-form coefficients = naive subset average, every tuple (max abs diff) err=4.441e-16 (tol 1e-14)\n",
417
+ "7. E[naive estimator] = grad of 1-(1-p)^k (exact over 4^5 tuples) err=4.302e-16 (tol 1e-15)\n",
418
+ "7. E[closed-form] = grad J_k err=4.302e-16 (tol 1e-15)\n",
419
+ "7. E[leave-one-out form] = grad J_k (baseline (k/n) rho_{-i} is independent of y_i) err=6.661e-16 (tol 1e-15)\n",
420
+ "total variance: closed-form (no baseline)=0.44125 leave-one-out=0.13981 ratio=0.317\n",
421
+ "7. k=1 reduces to REINFORCE sum_i (r_i/n) grad log pi(y_i) err=1.110e-16 (tol 1e-15)\n"
422
+ ]
423
+ }
424
+ ],
425
+ "source": [
426
+ "Kc, n, k = 4, 5, 3\n",
427
+ "r = torch.tensor([0., 1., 0., 1.])\n",
428
+ "theta = torch.tensor([0.3, -0.5, 0.9, -1.1], requires_grad=True)\n",
429
+ "pi = torch.softmax(theta, 0)\n",
430
+ "pk_obj = 1 - (1 - (pi * r).sum()) ** k\n",
431
+ "pk_obj.backward(); g_target = theta.grad.clone()\n",
432
+ "pi_d = pi.detach()\n",
433
+ "score = torch.eye(Kc) - pi_d[None, :] # row y: grad_theta log pi(y)\n",
434
+ "def rho_minus(c_other, n_, k_): # unbiased pass@(k-1) from n-1 samples with c_other correct (k_=k)\n",
435
+ " kk = k_ - 1\n",
436
+ " if kk == 0: return 0.0\n",
437
+ " return 1.0 - comb(n_ - 1 - c_other, kk) / comb(n_ - 1, kk)\n",
438
+ "E_naive = torch.zeros(Kc); E_closed = torch.zeros(Kc); E_loo = torch.zeros(Kc)\n",
439
+ "maxdiff = 0.0; var_closed = torch.zeros(Kc); var_loo = torch.zeros(Kc)\n",
440
+ "tuples = list(itertools.product(range(Kc), repeat=n))\n",
441
+ "second_closed = torch.zeros(Kc); second_loo = torch.zeros(Kc)\n",
442
+ "for tup in tuples:\n",
443
+ " prob = float(np.prod([float(pi_d[y]) for y in tup]))\n",
444
+ " rr = [float(r[y]) for y in tup]\n",
445
+ " # naive: all subsets\n",
446
+ " g1 = torch.zeros(Kc)\n",
447
+ " for Ssub in itertools.combinations(range(n), k):\n",
448
+ " mx = max(rr[i] for i in Ssub)\n",
449
+ " g1 += mx * sum(score[tup[i]] for i in Ssub)\n",
450
+ " g1 /= comb(n, k)\n",
451
+ " # closed form\n",
452
+ " g2 = torch.zeros(Kc); g3 = torch.zeros(Kc); c_tot = sum(rr)\n",
453
+ " for i in range(n):\n",
454
+ " c_other = c_tot - rr[i]\n",
455
+ " rho = rho_minus(int(c_other), n, k)\n",
456
+ " w = (k / n) * (rho + rr[i] * (1 - rho))\n",
457
+ " g2 += w * score[tup[i]]\n",
458
+ " g3 += (k / n) * rr[i] * (1 - rho) * score[tup[i]]\n",
459
+ " maxdiff = max(maxdiff, float((g1 - g2).abs().max()))\n",
460
+ " E_naive += prob * g1; E_closed += prob * g2; E_loo += prob * g3\n",
461
+ " second_closed += prob * g2 ** 2; second_loo += prob * g3 ** 2\n",
462
+ "check('7. closed-form coefficients = naive subset average, every tuple (max abs diff)', maxdiff, 1e-14)\n",
463
+ "check('7. E[naive estimator] = grad of 1-(1-p)^k (exact over 4^5 tuples)', (E_naive - g_target).abs().max(), 1e-15)\n",
464
+ "check('7. E[closed-form] = grad J_k', (E_closed - g_target).abs().max(), 1e-15)\n",
465
+ "check('7. E[leave-one-out form] = grad J_k (baseline (k/n) rho_{-i} is independent of y_i)', (E_loo - g_target).abs().max(), 1e-15)\n",
466
+ "v_closed = float((second_closed - E_closed ** 2).sum()); v_loo = float((second_loo - E_loo ** 2).sum())\n",
467
+ "print(f'total variance: closed-form (no baseline)={v_closed:.5f} leave-one-out={v_loo:.5f} ratio={v_loo/v_closed:.3f}')\n",
468
+ "assert v_loo < v_closed\n",
469
+ "# k=1 reduces to REINFORCE without baseline: w_i = r_i/n\n",
470
+ "kk = 1; maxd = 0.0\n",
471
+ "for tup in tuples[:200]:\n",
472
+ " rr = [float(r[y]) for y in tup]\n",
473
+ " g1 = sum(max(rr[i] for i in (j,)) * score[tup[j]] for j in range(n)) / n\n",
474
+ " g2 = sum((kk / n) * (0.0 + rr[i] * (1 - 0.0)) * score[tup[i]] for i in range(n))\n",
475
+ " maxd = max(maxd, float((g1 - g2).abs().max()))\n",
476
+ "check('7. k=1 reduces to REINFORCE sum_i (r_i/n) grad log pi(y_i)', maxd, 1e-15)"
477
+ ]
478
+ },
479
+ {
480
+ "cell_type": "markdown",
481
+ "id": "65ffaf66",
482
+ "metadata": {},
483
+ "source": [
484
+ "## 8. No unbiased estimator for `k > n` (Kolmogorov): Bernstein-basis residual\n",
485
+ "Unbiased estimators of `f(p)` from `n` Bernoulli draws are exactly `f(p) = sum_c a_c C(n,c) p^c (1-p)^{n-c}`, i.e. polynomials of degree `<= n`.\n",
486
+ "Project `f(p) = 1-(1-p)^k` on the degree-`n` Bernstein basis: residual 0 iff `k <= n`."
487
+ ]
488
+ },
489
+ {
490
+ "cell_type": "code",
491
+ "execution_count": 9,
492
+ "id": "476de431",
493
+ "metadata": {
494
+ "execution": {
495
+ "iopub.execute_input": "2026-10-14T08:57:54.021866Z",
496
+ "iopub.status.busy": "2026-10-14T08:57:54.021703Z",
497
+ "iopub.status.idle": "2026-10-14T08:57:54.031118Z",
498
+ "shell.execute_reply": "2026-10-14T08:57:54.030302Z"
499
+ }
500
+ },
501
+ "outputs": [
502
+ {
503
+ "name": "stdout",
504
+ "output_type": "stream",
505
+ "text": [
506
+ "{(4, 3): 1.2212453270876722e-15, (4, 4): 9.558326352632207e-16, (4, 5): 0.003948447395839216, (4, 8): 0.043274555276372625, (10, 10): 2.617697725249002e-15, (10, 11): 1.3790524725575724e-06}\n",
507
+ "8. residual for k<=n (max over tested) ~ 0 err=2.618e-15 (tol 1e-09)\n"
508
+ ]
509
+ }
510
+ ],
511
+ "source": [
512
+ "pg = np.linspace(0, 1, 2001)\n",
513
+ "def bern_resid(n_, k_):\n",
514
+ " B = np.stack([comb(n_, c) * pg ** c * (1 - pg) ** (n_ - c) for c in range(n_ + 1)], 1)\n",
515
+ " f = 1 - (1 - pg) ** k_\n",
516
+ " a, *_ = np.linalg.lstsq(B, f, rcond=None)\n",
517
+ " return float(np.abs(B @ a - f).max())\n",
518
+ "res = {(n_, k_): bern_resid(n_, k_) for n_, k_ in [(4, 3), (4, 4), (4, 5), (4, 8), (10, 10), (10, 11)]}\n",
519
+ "print(res)\n",
520
+ "check('8. residual for k<=n (max over tested) ~ 0', max(res[(4, 3)], res[(4, 4)], res[(10, 10)]), 1e-9)\n",
521
+ "assert res[(4, 5)] > 1e-3 and res[(10, 11)] > 1e-6\n",
522
+ "CHECKS.append(('8. residual for k=n+1 is strictly positive (min over tested; informational)', float(min(res[(4,5)], res[(10,11)])), float('inf')))"
523
+ ]
524
+ },
525
+ {
526
+ "cell_type": "markdown",
527
+ "id": "19e22270",
528
+ "metadata": {},
529
+ "source": [
530
+ "## 9. Benchmark-level sampling variance: within-problem part\n",
531
+ "`N=244` problems with `p_x ~ Beta(0.5, 1)`, `n=16` samples per problem, `k=8`. For *fixed* problems the variance of the benchmark mean is\n",
532
+ "`(1/N^2) sum_x Var_x`, with `Var_x` from section 3. Monte Carlo agrees."
533
+ ]
534
+ },
535
+ {
536
+ "cell_type": "code",
537
+ "execution_count": 10,
538
+ "id": "4691ab9e",
539
+ "metadata": {
540
+ "execution": {
541
+ "iopub.execute_input": "2026-10-14T08:57:54.032552Z",
542
+ "iopub.status.busy": "2026-10-14T08:57:54.032377Z",
543
+ "iopub.status.idle": "2026-10-14T08:58:10.317977Z",
544
+ "shell.execute_reply": "2026-10-14T08:58:10.317179Z"
545
+ }
546
+ },
547
+ "outputs": [
548
+ {
549
+ "name": "stdout",
550
+ "output_type": "stream",
551
+ "text": [
552
+ "truth=0.67114 MC mean=0.67109 (+-0.00006) MC var=1.3361e-04 theory var=1.3230e-04\n",
553
+ "9. estimator is unbiased at benchmark level (|MC mean - truth| in s.e.) err=9.278e-01 (tol 4e+00)\n",
554
+ "9. within-problem variance: MC vs theory (relative) err=9.909e-03 (tol 3e-02)\n"
555
+ ]
556
+ }
557
+ ],
558
+ "source": [
559
+ "rg = np.random.RandomState(5)\n",
560
+ "N, n, k = 244, 16, 8\n",
561
+ "px = rg.beta(0.5, 1.0, size=N)\n",
562
+ "truth = float((1 - (1 - px) ** k).mean())\n",
563
+ "var_theory = sum(float(var_exact(n, k, Fr(float(pv)).limit_denominator(10 ** 9))) for pv in px) / N ** 2\n",
564
+ "reps = 40000\n",
565
+ "c = rg.binomial(n, px[None, :], size=(reps, N))\n",
566
+ "def est_from_c(c):\n",
567
+ " out = np.ones_like(c, dtype=float)\n",
568
+ " for cc in range(0, n + 1):\n",
569
+ " out[c == cc] = pass_at_k_stable(n, cc, k)\n",
570
+ " return out.mean(1)\n",
571
+ "bench = est_from_c(c)\n",
572
+ "se_mean = bench.std() / math.sqrt(reps)\n",
573
+ "print(f'truth={truth:.5f} MC mean={bench.mean():.5f} (+-{se_mean:.5f}) MC var={bench.var():.4e} theory var={var_theory:.4e}')\n",
574
+ "check('9. estimator is unbiased at benchmark level (|MC mean - truth| in s.e.)', abs(bench.mean() - truth) / se_mean, 4.0)\n",
575
+ "check('9. within-problem variance: MC vs theory (relative)', abs(bench.var() / var_theory - 1), 0.03)"
576
+ ]
577
+ },
578
+ {
579
+ "cell_type": "markdown",
580
+ "id": "83602bfb",
581
+ "metadata": {},
582
+ "source": [
583
+ "## Verification table"
584
+ ]
585
+ },
586
+ {
587
+ "cell_type": "code",
588
+ "execution_count": 11,
589
+ "id": "86c128e2",
590
+ "metadata": {
591
+ "execution": {
592
+ "iopub.execute_input": "2026-10-14T08:58:10.319495Z",
593
+ "iopub.status.busy": "2026-10-14T08:58:10.319334Z",
594
+ "iopub.status.idle": "2026-10-14T08:58:10.322873Z",
595
+ "shell.execute_reply": "2026-10-14T08:58:10.322203Z"
596
+ }
597
+ },
598
+ "outputs": [
599
+ {
600
+ "name": "stdout",
601
+ "output_type": "stream",
602
+ "text": [
603
+ "\n",
604
+ "identity max abs err\n",
605
+ "1. E_c[1 - C(n-c,k)/C(n,k)] = 1-(1-p)^k, exact rational, all n<=40 tested 0.000e+00\n",
606
+ "1. identity C(n,c)C(n-c,k) = C(n,k)C(n-k,c) (violations over n<30) 0.000e+00\n",
607
+ "2. stable product form vs exact rational (max over grid incl. n=1000,k=999) 8.327e-17\n",
608
+ "3. Hoeffding U-statistic variance = exact binomial-sum variance (exact Fractions) 0.000e+00\n",
609
+ "3. exact variance vs leading term (relative error, n=200) 5.415e-02\n",
610
+ "4. k=2 exact bias of plug-in = -p(1-p)/n 0.000e+00\n",
611
+ "4. k=1 plug-in is unbiased 0.000e+00\n",
612
+ "5. grad J_k = E_x[k (1-p)^{k-1} grad p] vs autograd, k in {1,2,8,64} 6.939e-18\n",
613
+ "6. stationarity of both optima (max |grad J_k| at the returned theta) 4.823e-05\n",
614
+ "6. cross-inferiority gaps: min(J_1(th1)-J_1(th32), J_32(th32)-J_32(th1)) > 0 (reported as 0 if true) 0.000e+00\n",
615
+ "7. closed-form coefficients = naive subset average, every tuple (max abs diff) 4.441e-16\n",
616
+ "7. E[naive estimator] = grad of 1-(1-p)^k (exact over 4^5 tuples) 4.302e-16\n",
617
+ "7. E[closed-form] = grad J_k 4.302e-16\n",
618
+ "7. E[leave-one-out form] = grad J_k (baseline (k/n) rho_{-i} is independent of y_i) 6.661e-16\n",
619
+ "7. k=1 reduces to REINFORCE sum_i (r_i/n) grad log pi(y_i) 1.110e-16\n",
620
+ "8. residual for k<=n (max over tested) ~ 0 2.618e-15\n",
621
+ "8. residual for k=n+1 is strictly positive (min over tested; informational) 1.379e-06\n",
622
+ "9. estimator is unbiased at benchmark level (|MC mean - truth| in s.e.) 9.278e-01\n",
623
+ "9. within-problem variance: MC vs theory (relative) 9.909e-03\n",
624
+ "CHECKS_JSON:[[\"1. E_c[1 - C(n-c,k)/C(n,k)] = 1-(1-p)^k, exact rational, all n<=40 tested\", 0.0, 0.0], [\"1. identity C(n,c)C(n-c,k) = C(n,k)C(n-k,c) (violations over n<30)\", 0.0, 0.0], [\"2. stable product form vs exact rational (max over grid incl. n=1000,k=999)\", 8.326672684688674e-17, 1e-12], [\"3. Hoeffding U-statistic variance = exact binomial-sum variance (exact Fractions)\", 0.0, 1e-18], [\"3. exact variance vs leading term (relative error, n=200)\", 0.0541530729940769, 0.12], [\"4. k=2 exact bias of plug-in = -p(1-p)/n\", 0.0, 0.0], [\"4. k=1 plug-in is unbiased\", 0.0, 0.0], [\"5. grad J_k = E_x[k (1-p)^{k-1} grad p] vs autograd, k in {1,2,8,64}\", 6.938893903907228e-18, 1e-16], [\"6. stationarity of both optima (max |grad J_k| at the returned theta)\", 4.8228746018059434e-05, 0.001], [\"6. cross-inferiority gaps: min(J_1(th1)-J_1(th32), J_32(th32)-J_32(th1)) > 0 (reported as 0 if true)\", 0.0, 0.0], [\"7. closed-form coefficients = naive subset average, every tuple (max abs diff)\", 4.440892098500626e-16, 1e-14], [\"7. E[naive estimator] = grad of 1-(1-p)^k (exact over 4^5 tuples)\", 4.3021142204224816e-16, 1e-15], [\"7. E[closed-form] = grad J_k\", 4.3021142204224816e-16, 1e-15], [\"7. E[leave-one-out form] = grad J_k (baseline (k/n) rho_{-i} is independent of y_i)\", 6.661338147750939e-16, 1e-15], [\"7. k=1 reduces to REINFORCE sum_i (r_i/n) grad log pi(y_i)\", 1.1102230246251565e-16, 1e-15], [\"8. residual for k<=n (max over tested) ~ 0\", 2.617697725249002e-15, 1e-09], [\"8. residual for k=n+1 is strictly positive (min over tested; informational)\", 1.3790524725575724e-06, Infinity], [\"9. estimator is unbiased at benchmark level (|MC mean - truth| in s.e.)\", 0.9277896993916872, 4.0], [\"9. within-problem variance: MC vs theory (relative)\", 0.009909418241782264, 0.03]]\n"
625
+ ]
626
+ }
627
+ ],
628
+ "source": [
629
+ "print()\n",
630
+ "print(f'{\"identity\":88s} {\"max abs err\":>12s}')\n",
631
+ "for n_, e_, t_ in CHECKS: print(f'{n_:88s} {e_:12.3e}')\n",
632
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
633
+ ]
634
+ }
635
+ ],
636
+ "metadata": {
637
+ "kernelspec": {
638
+ "display_name": "Python 3",
639
+ "language": "python",
640
+ "name": "python3"
641
+ },
642
+ "language_info": {
643
+ "codemirror_mode": {
644
+ "name": "ipython",
645
+ "version": 3
646
+ },
647
+ "file_extension": ".py",
648
+ "mimetype": "text/x-python",
649
+ "name": "python",
650
+ "nbconvert_exporter": "python",
651
+ "pygments_lexer": "ipython3",
652
+ "version": "3.12.11"
653
+ }
654
+ },
655
+ "nbformat": 4,
656
+ "nbformat_minor": 5
657
+ }
code/notebooks/0204-self-correction-multiturn-mdp.ipynb ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "bc47f601",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0204 -- Self-correction as a multi-turn MDP: masks, per-turn credit, baselines\n",
9
+ "\n",
10
+ "A two-turn tabular environment small enough to *enumerate every episode and every group of sampled episodes exactly*.\n",
11
+ "Turn 1: `y1 ~ pi1` (K1=3). The verifier returns `r1(y1)` and a feedback token `f = F(y1)` (deterministic).\n",
12
+ "Turn 2: `y2 ~ pi2(.|y1)` (K2=3), context = `(x, y1, f)`. Verifier returns `r2(y1,y2)`. Episode return `R = r1 + r2`.\n",
13
+ "Parameters: `theta1 in R^3`, `theta2 in R^{3x3}` (one softmax row per turn-2 context).\n",
14
+ "\n",
15
+ "Identities verified:\n",
16
+ "1. Trajectory factorisation: the environment factor drops out; masked REINFORCE is unbiased, `E = grad J`.\n",
17
+ "2. Past rewards are independent of later actions: `E[r1 * grad log pi2(y2|c2)] = 0` (reward-to-go is unbiased, with lower variance).\n",
18
+ "3. Including feedback tokens in the loss adds a non-zero bias `E[R grad log pi(f|.)]` when the feedback shares the output softmax.\n",
19
+ "4. GRPO's mean-including-self baseline is exactly `(G-1)/G` times the leave-one-out one, so its gradient is `(G-1)/G * grad J`.\n",
20
+ "5. Leave-one-out (episode-level) is unbiased; a baseline that depends on the sample's own outcome is biased.\n",
21
+ "6. Two-level (tree) leave-one-out with per-turn baselines is unbiased; variance compared at equal episode budget.\n",
22
+ "7. Training only on a fixed (stale) distribution of turn-2 contexts is biased by `sum (mu - pi1) grad Q`, and drops the turn-1 term."
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "code",
27
+ "execution_count": 1,
28
+ "id": "343280c0",
29
+ "metadata": {
30
+ "execution": {
31
+ "iopub.execute_input": "2026-10-14T09:01:50.331677Z",
32
+ "iopub.status.busy": "2026-10-14T09:01:50.331456Z",
33
+ "iopub.status.idle": "2026-10-14T09:01:52.235734Z",
34
+ "shell.execute_reply": "2026-10-14T09:01:52.234848Z"
35
+ }
36
+ },
37
+ "outputs": [
38
+ {
39
+ "name": "stdout",
40
+ "output_type": "stream",
41
+ "text": [
42
+ "J = 1.0181152430262448\n"
43
+ ]
44
+ },
45
+ {
46
+ "name": "stderr",
47
+ "output_type": "stream",
48
+ "text": [
49
+ "/home/vignesh/.opencode/tmp/ipykernel_471603/844665086.py:31: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.\n",
50
+ "Consider using tensor.detach() first. (Triggered internally at /pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:838.)\n",
51
+ " print('J =', float(J))\n"
52
+ ]
53
+ }
54
+ ],
55
+ "source": [
56
+ "import os\n",
57
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
58
+ "import itertools, math, json\n",
59
+ "import numpy as np, torch\n",
60
+ "torch.set_default_dtype(torch.float64)\n",
61
+ "CHECKS = []\n",
62
+ "def check(name, err, tol):\n",
63
+ " CHECKS.append((name, float(err), float(tol)))\n",
64
+ " assert err <= tol, (name, float(err), tol)\n",
65
+ " print(f'{name:86s} err={float(err):.3e} (tol {tol:.0e})')\n",
66
+ "\n",
67
+ "K1, K2 = 3, 3\n",
68
+ "g = np.random.RandomState(4)\n",
69
+ "r1 = np.array([0., 0., 1.]) # turn-1 success table\n",
70
+ "r2 = np.array([[1, 0, 0], [0, 1, 0], [0, 1, 1]], float) # turn-2 success depends on (y1, y2)\n",
71
+ "Fmap = np.array([0, 1, 2]) # feedback token id as a function of y1 (shares the K2-sized vocabulary)\n",
72
+ "theta1_0 = torch.tensor([0.2, -0.4, 0.5])\n",
73
+ "theta2_0 = torch.tensor(g.randn(K1, K2) * 0.7)\n",
74
+ "nparam = K1 + K1 * K2\n",
75
+ "\n",
76
+ "def pis(t1, t2): return torch.softmax(t1, 0), torch.softmax(t2, -1)\n",
77
+ "def leaves(t1, t2):\n",
78
+ " p1, p2 = pis(t1, t2)\n",
79
+ " return {(a, b): float(p1[a] * p2[a, b]) for a in range(K1) for b in range(K2)}\n",
80
+ "def J_fn(t1, t2):\n",
81
+ " p1, p2 = pis(t1, t2)\n",
82
+ " return sum(p1[a] * p2[a, b] * (r1[a] + r2[a, b]) for a in range(K1) for b in range(K2))\n",
83
+ "t1 = theta1_0.clone().requires_grad_(True); t2 = theta2_0.clone().requires_grad_(True)\n",
84
+ "J = J_fn(t1, t2); J.backward()\n",
85
+ "gradJ = torch.cat([t1.grad, t2.grad.flatten()]).numpy()\n",
86
+ "print('J =', float(J))\n",
87
+ "p1n, p2n = [x.detach().numpy() for x in pis(theta1_0, theta2_0)]\n",
88
+ "P = {(a, b): p1n[a] * p2n[a, b] for a in range(K1) for b in range(K2)}\n",
89
+ "def s1(a): # score of turn-1 token: e_a - pi1 in theta1 block\n",
90
+ " v = np.zeros(nparam); e = np.zeros(K1); e[a] = 1; v[:K1] = e - p1n; return v\n",
91
+ "def s2(a, b): # score of turn-2 token: e_b - pi2[a] in the a-th row of theta2\n",
92
+ " v = np.zeros(nparam); e = np.zeros(K2); e[b] = 1; v[K1 + a * K2: K1 + (a + 1) * K2] = e - p2n[a]; return v\n",
93
+ "def sF(a): # score of the *feedback token* f=F(a) if it were scored by the same softmax head as turn 2, context y1=a\n",
94
+ " v = np.zeros(nparam); e = np.zeros(K2); e[Fmap[a]] = 1; v[K1 + a * K2: K1 + (a + 1) * K2] = e - p2n[a]; return v\n",
95
+ "Rret = {(a, b): r1[a] + r2[a, b] for a in range(K1) for b in range(K2)}\n",
96
+ "L = list(P.keys())"
97
+ ]
98
+ },
99
+ {
100
+ "cell_type": "markdown",
101
+ "id": "d24cef3e",
102
+ "metadata": {},
103
+ "source": [
104
+ "## 1. Masked REINFORCE is unbiased\n",
105
+ "Trajectory probability: `P(tau) = pi1(y1) * E(f|y1) * pi2(y2|y1,f)`. The environment factor `E` does not depend on theta, so\n",
106
+ "`grad log P(tau) = grad log pi1(y1) + grad log pi2(y2|c2)` -- only policy-generated tokens appear."
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": 2,
112
+ "id": "903afa0a",
113
+ "metadata": {
114
+ "execution": {
115
+ "iopub.execute_input": "2026-10-14T09:01:52.237467Z",
116
+ "iopub.status.busy": "2026-10-14T09:01:52.237201Z",
117
+ "iopub.status.idle": "2026-10-14T09:01:52.241076Z",
118
+ "shell.execute_reply": "2026-10-14T09:01:52.240235Z"
119
+ }
120
+ },
121
+ "outputs": [
122
+ {
123
+ "name": "stdout",
124
+ "output_type": "stream",
125
+ "text": [
126
+ "1. E[R (score_turn1 + score_turn2)] = grad J (exact enumeration of 9 episodes) err=8.327e-17 (tol 1e-15)\n"
127
+ ]
128
+ }
129
+ ],
130
+ "source": [
131
+ "E_mask = sum(P[l] * Rret[l] * (s1(l[0]) + s2(*l)) for l in L)\n",
132
+ "check('1. E[R (score_turn1 + score_turn2)] = grad J (exact enumeration of 9 episodes)', np.abs(E_mask - gradJ).max(), 1e-15)"
133
+ ]
134
+ },
135
+ {
136
+ "cell_type": "markdown",
137
+ "id": "851be585",
138
+ "metadata": {},
139
+ "source": [
140
+ "## 2. Causality: a past reward carries no information about a later action\n",
141
+ "`E[ r1(y1) * grad log pi2(y2|y1) ] = 0` because, given `y1`, `E_{y2}[grad log pi2] = 0`. So `r1` may be dropped from the turn-2 coefficient."
142
+ ]
143
+ },
144
+ {
145
+ "cell_type": "code",
146
+ "execution_count": 3,
147
+ "id": "8c6af3a3",
148
+ "metadata": {
149
+ "execution": {
150
+ "iopub.execute_input": "2026-10-14T09:01:52.242434Z",
151
+ "iopub.status.busy": "2026-10-14T09:01:52.242279Z",
152
+ "iopub.status.idle": "2026-10-14T09:01:52.248993Z",
153
+ "shell.execute_reply": "2026-10-14T09:01:52.248126Z"
154
+ }
155
+ },
156
+ "outputs": [
157
+ {
158
+ "name": "stdout",
159
+ "output_type": "stream",
160
+ "text": [
161
+ "2. E[r1 * score_turn2] = 0 err=1.388e-17 (tol 1e-16)\n",
162
+ "2. reward-to-go estimator: E = grad J err=6.939e-17 (tol 1e-15)\n",
163
+ "single-episode total variance: full return=1.6566 reward-to-go=0.9474\n"
164
+ ]
165
+ }
166
+ ],
167
+ "source": [
168
+ "E_past = sum(P[l] * r1[l[0]] * s2(*l) for l in L)\n",
169
+ "check('2. E[r1 * score_turn2] = 0', np.abs(E_past).max(), 1e-16)\n",
170
+ "E_rtg = sum(P[l] * (Rret[l] * s1(l[0]) + r2[l] * s2(*l)) for l in L)\n",
171
+ "check('2. reward-to-go estimator: E = grad J', np.abs(E_rtg - gradJ).max(), 1e-15)\n",
172
+ "var_full = sum(P[l] * ((Rret[l] * (s1(l[0]) + s2(*l))) ** 2).sum() for l in L) - (gradJ ** 2).sum()\n",
173
+ "var_rtg = sum(P[l] * ((Rret[l] * s1(l[0]) + r2[l] * s2(*l)) ** 2).sum() for l in L) - (gradJ ** 2).sum()\n",
174
+ "print(f'single-episode total variance: full return={var_full:.4f} reward-to-go={var_rtg:.4f}')\n",
175
+ "assert var_rtg < var_full"
176
+ ]
177
+ },
178
+ {
179
+ "cell_type": "markdown",
180
+ "id": "ba273ae5",
181
+ "metadata": {},
182
+ "source": [
183
+ "## 3. Putting the feedback token in the loss biases the gradient\n",
184
+ "If the loss also includes `log pi(f|context)` for the environment-written feedback token (same output softmax), the expected extra term is\n",
185
+ "`E[R grad log pi2(f|y1)]`, generally non-zero."
186
+ ]
187
+ },
188
+ {
189
+ "cell_type": "code",
190
+ "execution_count": 4,
191
+ "id": "5f223ef3",
192
+ "metadata": {
193
+ "execution": {
194
+ "iopub.execute_input": "2026-10-14T09:01:52.250555Z",
195
+ "iopub.status.busy": "2026-10-14T09:01:52.250399Z",
196
+ "iopub.status.idle": "2026-10-14T09:01:52.255258Z",
197
+ "shell.execute_reply": "2026-10-14T09:01:52.254500Z"
198
+ }
199
+ },
200
+ "outputs": [
201
+ {
202
+ "name": "stdout",
203
+ "output_type": "stream",
204
+ "text": [
205
+ "||bias from unmasked feedback tokens|| = 0.679933398028168 vs ||grad J|| = 0.47960612146460163\n",
206
+ "with constant R the same term is 0.5000121536758657 (non-zero: f is not distributed as pi2, so even the baseline identity fails)\n"
207
+ ]
208
+ }
209
+ ],
210
+ "source": [
211
+ "E_fb = sum(P[l] * Rret[l] * sF(l[0]) for l in L)\n",
212
+ "print('||bias from unmasked feedback tokens|| =', float(np.linalg.norm(E_fb)), ' vs ||grad J|| =', float(np.linalg.norm(gradJ)))\n",
213
+ "assert np.linalg.norm(E_fb) > 1e-3\n",
214
+ "CHECKS.append(('3. norm of the bias from including feedback tokens in the loss (informational, must be > 0)', float(np.linalg.norm(E_fb)), float('inf')))\n",
215
+ "# without a reward correlation (R constant) the term vanishes: E[score of a fixed-distribution token] = 0 only for tokens sampled from pi.\n",
216
+ "E_fb_const = sum(P[l] * 1.0 * sF(l[0]) for l in L)\n",
217
+ "print('with constant R the same term is', float(np.linalg.norm(E_fb_const)), '(non-zero: f is not distributed as pi2, so even the baseline identity fails)')"
218
+ ]
219
+ },
220
+ {
221
+ "cell_type": "markdown",
222
+ "id": "0b2c937c",
223
+ "metadata": {},
224
+ "source": [
225
+ "## 4--6. Group estimators, exactly\n",
226
+ "`G` i.i.d. episodes. Enumerate all `9^G` groups with their probabilities. Estimators:\n",
227
+ "* `E0` no baseline, full return; `E0rtg` reward-to-go\n",
228
+ "* `E1` GRPO-style baseline: group mean *including* the sample\n",
229
+ "* `E2` leave-one-out (LOO) baseline: mean of the other `G-1`\n",
230
+ "* `E3` tree: `G1` first-turn branches x `G2` second-turn siblings; per-turn LOO (turn-2 baseline = sibling mean, turn-1 baseline = other branches' mean value)"
231
+ ]
232
+ },
233
+ {
234
+ "cell_type": "code",
235
+ "execution_count": 5,
236
+ "id": "feed6ed4",
237
+ "metadata": {
238
+ "execution": {
239
+ "iopub.execute_input": "2026-10-14T09:01:52.256933Z",
240
+ "iopub.status.busy": "2026-10-14T09:01:52.256766Z",
241
+ "iopub.status.idle": "2026-10-14T09:01:55.102052Z",
242
+ "shell.execute_reply": "2026-10-14T09:01:55.101235Z"
243
+ }
244
+ },
245
+ "outputs": [
246
+ {
247
+ "name": "stdout",
248
+ "output_type": "stream",
249
+ "text": [
250
+ "E0 |E-gradJ|max=4.996e-16 trace Var=0.41416 (sum prob=1.000000000000)\n"
251
+ ]
252
+ },
253
+ {
254
+ "name": "stdout",
255
+ "output_type": "stream",
256
+ "text": [
257
+ "E0rtg |E-gradJ|max=4.996e-16 trace Var=0.23686 (sum prob=1.000000000000)\n",
258
+ "E1 |E-gradJ|max=9.267e-02 trace Var=0.14155 (sum prob=1.000000000000)\n"
259
+ ]
260
+ },
261
+ {
262
+ "name": "stdout",
263
+ "output_type": "stream",
264
+ "text": [
265
+ "E2 |E-gradJ|max=8.327e-16 trace Var=0.25165 (sum prob=1.000000000000)\n"
266
+ ]
267
+ },
268
+ {
269
+ "name": "stdout",
270
+ "output_type": "stream",
271
+ "text": [
272
+ "BAD |E-gradJ|max=7.857e-02 trace Var=0.19228 (sum prob=1.000000000000)\n",
273
+ "4. E0 (full-return, no baseline, G=4 episodes): unbiased err=4.996e-16 (tol 1e-15)\n",
274
+ "4. E0rtg (reward-to-go, G=4): unbiased err=4.996e-16 (tol 1e-15)\n",
275
+ "4. identity R_i - mean_all = (G-1)/G (R_i - mean_{-i}) on every group (max abs diff) err=5.551e-17 (tol 1e-14)\n",
276
+ "4. GRPO mean baseline: E[g] = (G-1)/G grad J exactly err=1.193e-15 (tol 1e-14)\n",
277
+ "5. leave-one-out baseline (episode-level, G=4): E[g] = grad J err=8.327e-16 (tol 1e-14)\n",
278
+ "own-outcome baseline: max |bias| = 0.07857014805316431\n",
279
+ "4. variance(E1 scaled by G/(G-1)) = variance(E2) err=7.438e-15 (tol 1e-12)\n",
280
+ "tree LOO (2 branches x 2 siblings = 4 episodes): |E-gradJ|max=2.220e-16 trace Var=0.48614\n",
281
+ "6. tree two-level leave-one-out with per-turn baselines: E[g] = grad J err=2.220e-16 (tol 1e-14)\n",
282
+ "equal budget (4 episodes): trace Var E0=0.4142 E0rtg=0.2369 flat LOO E2=0.2516 tree LOO=0.4861\n"
283
+ ]
284
+ },
285
+ {
286
+ "name": "stdout",
287
+ "output_type": "stream",
288
+ "text": [
289
+ "offset c=0.0: trace Var no-baseline=0.414 flat LOO=0.252 tree LOO=0.486\n",
290
+ "offset c=3.0: trace Var no-baseline=4.908 flat LOO=0.252 tree LOO=0.486\n"
291
+ ]
292
+ }
293
+ ],
294
+ "source": [
295
+ "Gs = 4\n",
296
+ "leaf_ids = list(range(len(L)))\n",
297
+ "Pl = np.array([P[l] for l in L]); Rl = np.array([Rret[l] for l in L])\n",
298
+ "S1 = np.stack([s1(l[0]) for l in L]); S2 = np.stack([s2(*l) for l in L]); S12 = S1 + S2\n",
299
+ "R2l = np.array([r2[l] for l in L])\n",
300
+ "def run_flat(Gs, kind):\n",
301
+ " m1 = np.zeros(nparam); m2 = 0.0; tot_prob = 0.0; maxdiff_1_2 = 0.0\n",
302
+ " for grp in itertools.product(leaf_ids, repeat=Gs):\n",
303
+ " pr = np.prod(Pl[list(grp)]); gi = list(grp)\n",
304
+ " R = Rl[gi]\n",
305
+ " if kind == 'E0':\n",
306
+ " est = (R[:, None] * S12[gi]).mean(0)\n",
307
+ " elif kind == 'E0rtg':\n",
308
+ " est = (R[:, None] * S1[gi] + R2l[gi][:, None] * S2[gi]).mean(0)\n",
309
+ " elif kind in ('E1', 'E2'):\n",
310
+ " mean_all = R.mean(); loo = (R.sum() - R) / (Gs - 1)\n",
311
+ " b = mean_all if kind == 'E1' else loo\n",
312
+ " est = ((R - b)[:, None] * S12[gi]).mean(0)\n",
313
+ " maxdiff_1_2 = max(maxdiff_1_2, float(np.abs((R - mean_all) - (Gs - 1) / Gs * (R - loo)).max()))\n",
314
+ " elif kind == 'BAD': # baseline depends on the sample's own turn-2 outcome: b_i = r2_i (for the turn-2 token), mean-LOO for turn 1\n",
315
+ " loo = (R.sum() - R) / (Gs - 1)\n",
316
+ " est = ((R - loo)[:, None] * S1[gi] + (R - R2l[gi])[:, None] * S2[gi]).mean(0)\n",
317
+ " m1 += pr * est; m2 += pr * (est ** 2).sum(); tot_prob += pr\n",
318
+ " return m1, m2 - (m1 ** 2).sum(), tot_prob, maxdiff_1_2\n",
319
+ "res = {}\n",
320
+ "for kind in ('E0', 'E0rtg', 'E1', 'E2', 'BAD'):\n",
321
+ " res[kind] = run_flat(Gs, kind)\n",
322
+ " print(f'{kind:6s} |E-gradJ|max={np.abs(res[kind][0]-gradJ).max():.3e} trace Var={res[kind][1]:.5f} (sum prob={res[kind][2]:.12f})')\n",
323
+ "check('4. E0 (full-return, no baseline, G=4 episodes): unbiased', np.abs(res['E0'][0] - gradJ).max(), 1e-15)\n",
324
+ "check('4. E0rtg (reward-to-go, G=4): unbiased', np.abs(res['E0rtg'][0] - gradJ).max(), 1e-15)\n",
325
+ "check('4. identity R_i - mean_all = (G-1)/G (R_i - mean_{-i}) on every group (max abs diff)', res['E1'][3], 1e-14)\n",
326
+ "check('4. GRPO mean baseline: E[g] = (G-1)/G grad J exactly', np.abs(res['E1'][0] - (Gs - 1) / Gs * gradJ).max(), 1e-14)\n",
327
+ "check('5. leave-one-out baseline (episode-level, G=4): E[g] = grad J', np.abs(res['E2'][0] - gradJ).max(), 1e-14)\n",
328
+ "bad_bias = np.abs(res['BAD'][0] - gradJ).max()\n",
329
+ "print('own-outcome baseline: max |bias| =', bad_bias)\n",
330
+ "assert bad_bias > 1e-3\n",
331
+ "CHECKS.append(('5. baseline depending on the sample own r2: |bias| (informational, must be > 0)', float(bad_bias), float('inf')))\n",
332
+ "# rescale E1 to compare variance\n",
333
+ "var_E1_scaled = res['E1'][1] * (Gs / (Gs - 1)) ** 2\n",
334
+ "check('4. variance(E1 scaled by G/(G-1)) = variance(E2)', abs(var_E1_scaled - res['E2'][1]), 1e-12)\n",
335
+ "\n",
336
+ "# --- tree estimator\n",
337
+ "G1, G2 = 2, 2\n",
338
+ "def run_tree():\n",
339
+ " m1 = np.zeros(nparam); m2 = 0.0\n",
340
+ " a_range = range(K1); b_range = range(K2)\n",
341
+ " for a_s in itertools.product(a_range, repeat=G1):\n",
342
+ " pa = np.prod([p1n[a] for a in a_s])\n",
343
+ " for b_all in itertools.product(*[itertools.product(b_range, repeat=G2) for _ in range(G1)]):\n",
344
+ " pb = np.prod([p2n[a, b] for a, bs in zip(a_s, b_all) for b in bs])\n",
345
+ " Qhat = np.array([np.mean([Rret[(a, b)] for b in bs]) for a, bs in zip(a_s, b_all)])\n",
346
+ " est = np.zeros(nparam)\n",
347
+ " for i, (a, bs) in enumerate(zip(a_s, b_all)):\n",
348
+ " b1 = (Qhat.sum() - Qhat[i]) / (G1 - 1)\n",
349
+ " est += (Qhat[i] - b1) * s1(a) / G1\n",
350
+ " Rs = np.array([Rret[(a, b)] for b in bs])\n",
351
+ " for j, b in enumerate(bs):\n",
352
+ " b2 = (Rs.sum() - Rs[j]) / (G2 - 1)\n",
353
+ " est += (Rs[j] - b2) * s2(a, b) / (G1 * G2)\n",
354
+ " pr = pa * pb; m1 += pr * est; m2 += pr * (est ** 2).sum()\n",
355
+ " return m1, m2 - (m1 ** 2).sum()\n",
356
+ "m_tree, v_tree = run_tree()\n",
357
+ "print(f'tree LOO (2 branches x 2 siblings = 4 episodes): |E-gradJ|max={np.abs(m_tree-gradJ).max():.3e} trace Var={v_tree:.5f}')\n",
358
+ "check('6. tree two-level leave-one-out with per-turn baselines: E[g] = grad J', np.abs(m_tree - gradJ).max(), 1e-14)\n",
359
+ "print(f'equal budget (4 episodes): trace Var E0={res[\"E0\"][1]:.4f} E0rtg={res[\"E0rtg\"][1]:.4f} flat LOO E2={res[\"E2\"][1]:.4f} tree LOO={v_tree:.4f}')\n",
360
+ "\n",
361
+ "# Value of baselines: add a constant c to every return (a \"reward offset\"; unbiasedness is unaffected, variance is not).\n",
362
+ "for c in (0.0, 3.0):\n",
363
+ " Rl_save = Rl.copy(); Rret_save = dict(Rret)\n",
364
+ " Rl = Rl_save + c; Rret = {k_: v_ + c for k_, v_ in Rret_save.items()}\n",
365
+ " v0 = run_flat(Gs, 'E0')[1]; v2 = run_flat(Gs, 'E2')[1]; vt = run_tree()[1]\n",
366
+ " print(f'offset c={c}: trace Var no-baseline={v0:.3f} flat LOO={v2:.3f} tree LOO={vt:.3f}')\n",
367
+ " Rl = Rl_save; Rret = Rret_save"
368
+ ]
369
+ },
370
+ {
371
+ "cell_type": "markdown",
372
+ "id": "c2aa29c6",
373
+ "metadata": {},
374
+ "source": [
375
+ "## 7. Training only the first correction round on a stale context distribution (Goedel-Prover-V2-style RL data)\n",
376
+ "Use contexts `y1 ~ mu` (a frozen earlier policy) and train only `pi2`. Its expected gradient is\n",
377
+ "`sum_a mu(a) sum_b pi2(b|a) R(a,b) grad log pi2(b|a)` = `sum_a mu(a) grad_{theta2} Q(a)`. The full-episode gradient on the `theta2` block is\n",
378
+ "`sum_a pi1(a) grad_{theta2} Q(a)` and the full gradient has an additional `theta1` block. Compare."
379
+ ]
380
+ },
381
+ {
382
+ "cell_type": "code",
383
+ "execution_count": 6,
384
+ "id": "c9a05411",
385
+ "metadata": {
386
+ "execution": {
387
+ "iopub.execute_input": "2026-10-14T09:01:55.103908Z",
388
+ "iopub.status.busy": "2026-10-14T09:01:55.103731Z",
389
+ "iopub.status.idle": "2026-10-14T09:01:55.110368Z",
390
+ "shell.execute_reply": "2026-10-14T09:01:55.109451Z"
391
+ }
392
+ },
393
+ "outputs": [
394
+ {
395
+ "name": "stdout",
396
+ "output_type": "stream",
397
+ "text": [
398
+ "7. truncated turn-2 gradient = sum_a mu(a) grad Q(a) (closed form) err=8.674e-18 (tol 1e-16)\n",
399
+ "theta2-block bias norm (context shift only): 0.10440110286572064 theta1-block (missing turn-1 term) norm: 0.4583795568241773\n",
400
+ "7. with mu = pi1 the theta2 block of the truncated gradient equals that of grad J err=8.327e-17 (tol 1e-15)\n"
401
+ ]
402
+ }
403
+ ],
404
+ "source": [
405
+ "mu = np.array([0.6, 0.3, 0.1])\n",
406
+ "Qa = np.array([sum(p2n[a, b] * Rret[(a, b)] for b in range(K2)) for a in range(K1)])\n",
407
+ "E_trunc = np.zeros(nparam)\n",
408
+ "for a in range(K1):\n",
409
+ " for b in range(K2):\n",
410
+ " E_trunc += mu[a] * p2n[a, b] * Rret[(a, b)] * s2(a, b)\n",
411
+ "# closed form: sum_a mu(a) grad_{theta2} Q(a); grad_{theta2[a]} Q(a) = pi2(b|a)(R(a,b) - Q(a)) over b\n",
412
+ "E_closed = np.zeros(nparam)\n",
413
+ "for a in range(K1):\n",
414
+ " for b in range(K2):\n",
415
+ " E_closed[K1 + a * K2 + b] = mu[a] * p2n[a, b] * (Rret[(a, b)] - Qa[a])\n",
416
+ "check('7. truncated turn-2 gradient = sum_a mu(a) grad Q(a) (closed form)', np.abs(E_trunc - E_closed).max(), 1e-16)\n",
417
+ "bias_ctx = E_trunc - gradJ\n",
418
+ "b2_block = bias_ctx[K1:]; b1_block = bias_ctx[:K1]\n",
419
+ "print('theta2-block bias norm (context shift only):', float(np.linalg.norm(b2_block)), ' theta1-block (missing turn-1 term) norm:', float(np.linalg.norm(b1_block)))\n",
420
+ "# if mu = pi1 the theta2 block is unbiased\n",
421
+ "E_same = np.zeros(nparam)\n",
422
+ "for a in range(K1):\n",
423
+ " for b in range(K2):\n",
424
+ " E_same += p1n[a] * p2n[a, b] * Rret[(a, b)] * s2(a, b)\n",
425
+ "check('7. with mu = pi1 the theta2 block of the truncated gradient equals that of grad J', np.abs(E_same[K1:] - gradJ[K1:]).max(), 1e-15)\n",
426
+ "CHECKS.append(('7. norm of the missing turn-1 block ||grad_theta1 J|| (informational, must be > 0)', float(np.linalg.norm(gradJ[:K1])), float('inf')))\n",
427
+ "assert np.linalg.norm(b1_block) > 1e-3 and np.linalg.norm(b2_block) > 1e-4"
428
+ ]
429
+ },
430
+ {
431
+ "cell_type": "markdown",
432
+ "id": "a9073ea5",
433
+ "metadata": {},
434
+ "source": [
435
+ "## Verification table"
436
+ ]
437
+ },
438
+ {
439
+ "cell_type": "code",
440
+ "execution_count": 7,
441
+ "id": "b9986b19",
442
+ "metadata": {
443
+ "execution": {
444
+ "iopub.execute_input": "2026-10-14T09:01:55.112041Z",
445
+ "iopub.status.busy": "2026-10-14T09:01:55.111864Z",
446
+ "iopub.status.idle": "2026-10-14T09:01:55.115491Z",
447
+ "shell.execute_reply": "2026-10-14T09:01:55.114662Z"
448
+ }
449
+ },
450
+ "outputs": [
451
+ {
452
+ "name": "stdout",
453
+ "output_type": "stream",
454
+ "text": [
455
+ "\n",
456
+ "identity max abs err\n",
457
+ "1. E[R (score_turn1 + score_turn2)] = grad J (exact enumeration of 9 episodes) 8.327e-17\n",
458
+ "2. E[r1 * score_turn2] = 0 1.388e-17\n",
459
+ "2. reward-to-go estimator: E = grad J 6.939e-17\n",
460
+ "3. norm of the bias from including feedback tokens in the loss (informational, must be > 0) 6.799e-01\n",
461
+ "4. E0 (full-return, no baseline, G=4 episodes): unbiased 4.996e-16\n",
462
+ "4. E0rtg (reward-to-go, G=4): unbiased 4.996e-16\n",
463
+ "4. identity R_i - mean_all = (G-1)/G (R_i - mean_{-i}) on every group (max abs diff) 5.551e-17\n",
464
+ "4. GRPO mean baseline: E[g] = (G-1)/G grad J exactly 1.193e-15\n",
465
+ "5. leave-one-out baseline (episode-level, G=4): E[g] = grad J 8.327e-16\n",
466
+ "5. baseline depending on the sample own r2: |bias| (informational, must be > 0) 7.857e-02\n",
467
+ "4. variance(E1 scaled by G/(G-1)) = variance(E2) 7.438e-15\n",
468
+ "6. tree two-level leave-one-out with per-turn baselines: E[g] = grad J 2.220e-16\n",
469
+ "7. truncated turn-2 gradient = sum_a mu(a) grad Q(a) (closed form) 8.674e-18\n",
470
+ "7. with mu = pi1 the theta2 block of the truncated gradient equals that of grad J 8.327e-17\n",
471
+ "7. norm of the missing turn-1 block ||grad_theta1 J|| (informational, must be > 0) 4.584e-01\n",
472
+ "CHECKS_JSON:[[\"1. E[R (score_turn1 + score_turn2)] = grad J (exact enumeration of 9 episodes)\", 8.326672684688674e-17, 1e-15], [\"2. E[r1 * score_turn2] = 0\", 1.3877787807814457e-17, 1e-16], [\"2. reward-to-go estimator: E = grad J\", 6.938893903907228e-17, 1e-15], [\"3. norm of the bias from including feedback tokens in the loss (informational, must be > 0)\", 0.679933398028168, Infinity], [\"4. E0 (full-return, no baseline, G=4 episodes): unbiased\", 4.996003610813204e-16, 1e-15], [\"4. E0rtg (reward-to-go, G=4): unbiased\", 4.996003610813204e-16, 1e-15], [\"4. identity R_i - mean_all = (G-1)/G (R_i - mean_{-i}) on every group (max abs diff)\", 5.551115123125783e-17, 1e-14], [\"4. GRPO mean baseline: E[g] = (G-1)/G grad J exactly\", 1.1934897514720433e-15, 1e-14], [\"5. leave-one-out baseline (episode-level, G=4): E[g] = grad J\", 8.326672684688674e-16, 1e-14], [\"5. baseline depending on the sample own r2: |bias| (informational, must be > 0)\", 0.07857014805316431, Infinity], [\"4. variance(E1 scaled by G/(G-1)) = variance(E2)\", 7.438494264988549e-15, 1e-12], [\"6. tree two-level leave-one-out with per-turn baselines: E[g] = grad J\", 2.220446049250313e-16, 1e-14], [\"7. truncated turn-2 gradient = sum_a mu(a) grad Q(a) (closed form)\", 8.673617379884035e-18, 1e-16], [\"7. with mu = pi1 the theta2 block of the truncated gradient equals that of grad J\", 8.326672684688674e-17, 1e-15], [\"7. norm of the missing turn-1 block ||grad_theta1 J|| (informational, must be > 0)\", 0.4583795568241773, Infinity]]\n"
473
+ ]
474
+ }
475
+ ],
476
+ "source": [
477
+ "print()\n",
478
+ "print(f'{\"identity\":90s} {\"max abs err\":>12s}')\n",
479
+ "for n_, e_, t_ in CHECKS: print(f'{n_:90s} {e_:12.3e}')\n",
480
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
481
+ ]
482
+ }
483
+ ],
484
+ "metadata": {
485
+ "kernelspec": {
486
+ "display_name": "Python 3",
487
+ "language": "python",
488
+ "name": "python3"
489
+ },
490
+ "language_info": {
491
+ "codemirror_mode": {
492
+ "name": "ipython",
493
+ "version": 3
494
+ },
495
+ "file_extension": ".py",
496
+ "mimetype": "text/x-python",
497
+ "name": "python",
498
+ "nbconvert_exporter": "python",
499
+ "pygments_lexer": "ipython3",
500
+ "version": "3.12.11"
501
+ }
502
+ },
503
+ "nbformat": 4,
504
+ "nbformat_minor": 5
505
+ }
code/notebooks/0205-model-averaging-weight-interpolation.ipynb ADDED
@@ -0,0 +1,746 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "5ac94761",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0205 -- Model averaging / weight interpolation `theta_alpha = (1-alpha) theta_0 + alpha theta`\n",
9
+ "\n",
10
+ "Convention (Goedel-Prover-V2 Sec. 2.3): `alpha` is the weight on the **fine-tuned** model; `alpha=0` is the base. (Their Sec. 3.6 text states\n",
11
+ "that alpha is \"the weight of the base model\" -- the opposite convention; see the note.)\n",
12
+ "\n",
13
+ "Identities / claims verified (fp64, CPU):\n",
14
+ "1. Logit-linear model: `softmax(z_alpha) = normalised pi_0^(1-alpha) pi_1^alpha` (geometric mixture), not the arithmetic mixture; naive (re-evaluate at theta_alpha) = efficient (interpolate logits).\n",
15
+ "2. Exponential tilt: `d/dalpha E_alpha[r] = Cov_alpha(g, r)`; for a binary tilt `p(alpha) = sigmoid(logit p0 + alpha c)` exactly.\n",
16
+ "3. Log-odds-linear first-order formulas: `d pass@k / d alpha = E_x[k p (1-p)^k Delta_x]`.\n",
17
+ "4. Curvature along the path for cross-entropy: `d^2 L/dalpha^2 = E_x Var_{y~softmax(z_alpha)}[Delta z_y]` (model-soups Eq. 1 with linear logits: soup = ensemble).\n",
18
+ "5. Loss along a line: `l(alpha) - chord = (1/2) alpha (alpha-1) d^T H d` for a quadratic; convex NLL has no barrier.\n",
19
+ "6. Linear mode connectivity: barrier for same-init fine-tunes vs independently trained nets (tiny MLP).\n",
20
+ "7. 30 random log-linear \"SFT -> RL\" pairs: pass@1 rises with alpha; pass@k (k=8,64) peaks at interior alpha."
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "code",
25
+ "execution_count": 1,
26
+ "id": "4ac1843a",
27
+ "metadata": {
28
+ "execution": {
29
+ "iopub.execute_input": "2026-10-14T09:49:30.138556Z",
30
+ "iopub.status.busy": "2026-10-14T09:49:30.138404Z",
31
+ "iopub.status.idle": "2026-10-14T09:49:31.926821Z",
32
+ "shell.execute_reply": "2026-10-14T09:49:31.925791Z"
33
+ }
34
+ },
35
+ "outputs": [],
36
+ "source": [
37
+ "import os\n",
38
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
39
+ "import math, json\n",
40
+ "import numpy as np, torch\n",
41
+ "torch.set_default_dtype(torch.float64)\n",
42
+ "CHECKS = []\n",
43
+ "def check(name, err, tol):\n",
44
+ " CHECKS.append((name, float(err), float(tol)))\n",
45
+ " assert err <= tol, (name, float(err), tol)\n",
46
+ " print(f'{name:86s} err={float(err):.3e} (tol {tol:.0e})')\n",
47
+ "\n",
48
+ "g = np.random.RandomState(0)\n",
49
+ "m, K, d = 40, 12, 5\n",
50
+ "Phi = torch.tensor(g.randn(m, K, d)); V = torch.tensor((g.rand(m, K) < 0.12).astype(float))\n",
51
+ "V[V.sum(-1) == 0, 0] = 1.0\n",
52
+ "th0 = torch.tensor(g.randn(d) * 0.4); th1 = th0 + torch.tensor(g.randn(d) * 1.2)"
53
+ ]
54
+ },
55
+ {
56
+ "cell_type": "markdown",
57
+ "id": "97ec1d44",
58
+ "metadata": {},
59
+ "source": [
60
+ "## 1. Geometric (log-linear) mixture\n",
61
+ "NAIVE: build `theta_alpha` and run the model. EFFICIENT: evaluate the two endpoint logit tables once and interpolate the logits.\n",
62
+ "Both equal the per-context normalised product `pi_0^(1-alpha) pi_1^alpha`. The arithmetic mixture `(1-alpha) pi_0 + alpha pi_1` (an ensemble) differs."
63
+ ]
64
+ },
65
+ {
66
+ "cell_type": "code",
67
+ "execution_count": 2,
68
+ "id": "91130ea5",
69
+ "metadata": {
70
+ "execution": {
71
+ "iopub.execute_input": "2026-10-14T09:49:31.929274Z",
72
+ "iopub.status.busy": "2026-10-14T09:49:31.929030Z",
73
+ "iopub.status.idle": "2026-10-14T09:49:31.936414Z",
74
+ "shell.execute_reply": "2026-10-14T09:49:31.935536Z"
75
+ }
76
+ },
77
+ "outputs": [
78
+ {
79
+ "name": "stdout",
80
+ "output_type": "stream",
81
+ "text": [
82
+ "1. naive (re-evaluate theta_alpha) vs efficient (interpolate logits) err=4.441e-16 (tol 1e-15)\n",
83
+ "1. softmax(z_alpha) vs normalised pi0^(1-a) pi1^a err=4.441e-16 (tol 1e-15)\n",
84
+ "max |geometric - arithmetic mixture| for alpha in (0,1): 0.25554158391614934\n"
85
+ ]
86
+ }
87
+ ],
88
+ "source": [
89
+ "z0, z1 = Phi @ th0, Phi @ th1\n",
90
+ "pi0, pi1 = torch.softmax(z0, -1), torch.softmax(z1, -1)\n",
91
+ "worst_naive_eff = 0.0; worst_geo = 0.0; dist_arith = 0.0\n",
92
+ "for a in (0.0, 0.3, 0.7, 1.0, 1.25, -0.2):\n",
93
+ " tha = (1 - a) * th0 + a * th1\n",
94
+ " naive = torch.softmax(Phi @ tha, -1)\n",
95
+ " eff = torch.softmax((1 - a) * z0 + a * z1, -1)\n",
96
+ " geo = pi0 ** (1 - a) * pi1 ** a; geo = geo / geo.sum(-1, keepdim=True)\n",
97
+ " ar = (1 - a) * pi0 + a * pi1\n",
98
+ " worst_naive_eff = max(worst_naive_eff, float((naive - eff).abs().max()))\n",
99
+ " worst_geo = max(worst_geo, float((naive - geo).abs().max()))\n",
100
+ " if 0 < a < 1: dist_arith = max(dist_arith, float((naive - ar).abs().max()))\n",
101
+ "check('1. naive (re-evaluate theta_alpha) vs efficient (interpolate logits)', worst_naive_eff, 1e-15)\n",
102
+ "check('1. softmax(z_alpha) vs normalised pi0^(1-a) pi1^a', worst_geo, 1e-15)\n",
103
+ "print('max |geometric - arithmetic mixture| for alpha in (0,1):', dist_arith)\n",
104
+ "assert dist_arith > 1e-3"
105
+ ]
106
+ },
107
+ {
108
+ "cell_type": "markdown",
109
+ "id": "74e8c4e1",
110
+ "metadata": {},
111
+ "source": [
112
+ "## 2. Exponential tilt\n",
113
+ "If `pi_1 = pi_0 e^{g}/Z` then `pi_alpha = pi_0 e^{alpha g}/Z_alpha`, `d/dalpha E_alpha[r] = Cov_alpha(g, r)` (derivative of a log-partition function).\n",
114
+ "Binary tilt `g = c r`: odds multiply by `e^{alpha c}`."
115
+ ]
116
+ },
117
+ {
118
+ "cell_type": "code",
119
+ "execution_count": 3,
120
+ "id": "1cc640d6",
121
+ "metadata": {
122
+ "execution": {
123
+ "iopub.execute_input": "2026-10-14T09:49:31.938137Z",
124
+ "iopub.status.busy": "2026-10-14T09:49:31.937942Z",
125
+ "iopub.status.idle": "2026-10-14T09:49:32.021196Z",
126
+ "shell.execute_reply": "2026-10-14T09:49:32.020228Z"
127
+ }
128
+ },
129
+ "outputs": [
130
+ {
131
+ "name": "stdout",
132
+ "output_type": "stream",
133
+ "text": [
134
+ "2. d/dalpha E_alpha[r] (autograd) = Cov_alpha(g, r) err=5.204e-18 (tol 1e-16)\n",
135
+ "2. binary tilt: p(alpha) = sigmoid(logit p0 + alpha c) err=0.000e+00 (tol 1e-15)\n"
136
+ ]
137
+ }
138
+ ],
139
+ "source": [
140
+ "gtilt = Phi @ (th1 - th0) # = z1 - z0, the tilt of each context\n",
141
+ "a = torch.tensor(0.4, requires_grad=True)\n",
142
+ "pa = torch.softmax(z0 + a * gtilt, -1)\n",
143
+ "Ea = (pa * V).sum(-1).mean(); Ea.backward()\n",
144
+ "pa_ = pa.detach(); cov = ((pa_ * gtilt * V).sum(-1) - (pa_ * gtilt).sum(-1) * (pa_ * V).sum(-1)).mean()\n",
145
+ "check('2. d/dalpha E_alpha[r] (autograd) = Cov_alpha(g, r)', abs(float(a.grad - cov)), 1e-16)\n",
146
+ "p0v = float((pi0 * V).sum(-1)[0]); c = 1.7\n",
147
+ "q = pi0[0] * torch.exp(c * V[0]); q = q / q.sum()\n",
148
+ "worst = 0.0\n",
149
+ "for al in (0.0, 0.25, 0.5, 1.0, 2.0):\n",
150
+ " qa = pi0[0] * torch.exp(al * c * V[0]); qa = qa / qa.sum()\n",
151
+ " p_al = float((qa * V[0]).sum()); logit = math.log(p0v / (1 - p0v)) + al * c\n",
152
+ " worst = max(worst, abs(p_al - 1 / (1 + math.exp(-logit))))\n",
153
+ "check('2. binary tilt: p(alpha) = sigmoid(logit p0 + alpha c)', worst, 1e-15)"
154
+ ]
155
+ },
156
+ {
157
+ "cell_type": "markdown",
158
+ "id": "87a33cf6",
159
+ "metadata": {},
160
+ "source": [
161
+ "## 2b. Scope of the covariance identity: single categorical vs autoregressive policy\n",
162
+ "For an autoregressive policy `pi_alpha(y) = prod_t softmax((1-alpha) z0 + alpha z1)(y_t | s_t)` the normaliser depends on the context, so\n",
163
+ "`d/dalpha log pi_alpha(y) = sum_t [g_t - E_{pi_alpha(.|s_t)} g]` (the *sequence score*), and `d/dalpha E_alpha[r] = Cov_alpha(score, r)`.\n",
164
+ "Replacing the score by `sum_t g_t` is NOT exact. Two-token toy, vocabulary 3, exact enumeration of the 9 sequences, central finite difference in `alpha`."
165
+ ]
166
+ },
167
+ {
168
+ "cell_type": "code",
169
+ "execution_count": 4,
170
+ "id": "e54a128b",
171
+ "metadata": {
172
+ "execution": {
173
+ "iopub.execute_input": "2026-10-14T09:49:32.022698Z",
174
+ "iopub.status.busy": "2026-10-14T09:49:32.022530Z",
175
+ "iopub.status.idle": "2026-10-14T09:49:32.032914Z",
176
+ "shell.execute_reply": "2026-10-14T09:49:32.032036Z"
177
+ }
178
+ },
179
+ "outputs": [
180
+ {
181
+ "name": "stdout",
182
+ "output_type": "stream",
183
+ "text": [
184
+ "finite difference dE/dalpha : -0.19188850988727246\n",
185
+ "Cov_alpha(centred sequence score, r) : -0.19188850994087786\n",
186
+ "Cov_alpha(sum_t g_t, r) : -0.17690360845304756\n",
187
+ "2b. autoregressive: dE/dalpha (finite diff.) = Cov_alpha(sequence score, r) err=5.361e-11 (tol 1e-08)\n",
188
+ "2b. zero mean of the sequence score: E_alpha[score] = 0 err=5.204e-17 (tol 1e-14)\n"
189
+ ]
190
+ }
191
+ ],
192
+ "source": [
193
+ "import itertools\n",
194
+ "rg_ar = np.random.RandomState(0); Vv_ = 3\n",
195
+ "z0_ = {(): rg_ar.randn(Vv_)}; z1_ = {(): rg_ar.randn(Vv_)}\n",
196
+ "for a_ in range(Vv_): z0_[(a_,)] = rg_ar.randn(Vv_); z1_[(a_,)] = rg_ar.randn(Vv_)\n",
197
+ "sm_ = lambda zz: np.exp(zz - zz.max()) / np.exp(zz - zz.max()).sum()\n",
198
+ "seqs_ = list(itertools.product(range(Vv_), repeat=2)); r_ar = rg_ar.rand(len(seqs_))\n",
199
+ "def dist_ar(al):\n",
200
+ " return np.array([sm_((1 - al) * z0_[()] + al * z1_[()])[a_] * sm_((1 - al) * z0_[(a_,)] + al * z1_[(a_,)])[b_] for a_, b_ in seqs_])\n",
201
+ "al0 = 0.4; hh = 1e-6\n",
202
+ "fd = (dist_ar(al0 + hh) @ r_ar - dist_ar(al0 - hh) @ r_ar) / (2 * hh)\n",
203
+ "pdist = dist_ar(al0)\n",
204
+ "gtok = lambda ctx, y: (z1_[ctx] - z0_[ctx])[y]\n",
205
+ "cmean = lambda ctx: sm_((1 - al0) * z0_[ctx] + al0 * z1_[ctx]) @ (z1_[ctx] - z0_[ctx])\n",
206
+ "G_sum = np.array([gtok((), a_) + gtok((a_,), b_) for a_, b_ in seqs_])\n",
207
+ "G_score = np.array([(gtok((), a_) - cmean(())) + (gtok((a_,), b_) - cmean((a_,))) for a_, b_ in seqs_])\n",
208
+ "covf = lambda x: (pdist * x * r_ar).sum() - (pdist * x).sum() * (pdist * r_ar).sum()\n",
209
+ "print('finite difference dE/dalpha :', fd)\n",
210
+ "print('Cov_alpha(centred sequence score, r) :', covf(G_score))\n",
211
+ "print('Cov_alpha(sum_t g_t, r) :', covf(G_sum))\n",
212
+ "check('2b. autoregressive: dE/dalpha (finite diff.) = Cov_alpha(sequence score, r)', abs(fd - covf(G_score)), 1e-8)\n",
213
+ "assert abs(fd - covf(G_sum)) > 1e-3\n",
214
+ "CHECKS.append(('2b. autoregressive: error of the naive Cov_alpha(sum_t g_t, r) (informational, must be > 0)', float(abs(fd - covf(G_sum))), float('inf')))\n",
215
+ "check('2b. zero mean of the sequence score: E_alpha[score] = 0', abs((pdist * G_score).sum()), 1e-14)"
216
+ ]
217
+ },
218
+ {
219
+ "cell_type": "markdown",
220
+ "id": "e3649b2b",
221
+ "metadata": {},
222
+ "source": [
223
+ "## 3. First-order (log-odds-linear) analysis of pass@1 and pass@k\n",
224
+ "Assume `logit p_x(alpha) = l_x + alpha Delta_x`. Then `d p/dalpha = p(1-p) Delta`, so\n",
225
+ "`d pass@1/dalpha = E[p(1-p) Delta]`, `d pass@k/dalpha = E[k p (1-p)^k Delta]`."
226
+ ]
227
+ },
228
+ {
229
+ "cell_type": "code",
230
+ "execution_count": 5,
231
+ "id": "0a878415",
232
+ "metadata": {
233
+ "execution": {
234
+ "iopub.execute_input": "2026-10-14T09:49:32.034304Z",
235
+ "iopub.status.busy": "2026-10-14T09:49:32.034149Z",
236
+ "iopub.status.idle": "2026-10-14T09:49:32.087078Z",
237
+ "shell.execute_reply": "2026-10-14T09:49:32.086240Z"
238
+ }
239
+ },
240
+ "outputs": [
241
+ {
242
+ "name": "stdout",
243
+ "output_type": "stream",
244
+ "text": [
245
+ "3. d pass@k/dalpha = E[k p (1-p)^k Delta] vs autograd, k in {1,4,32} err=2.776e-17 (tol 1e-15)\n",
246
+ "k= 1: alpha* = 1.000 pass@k(0)=0.2776 pass@k(alpha*)=0.4355 pass@k(1)=0.4355\n",
247
+ "k= 8: alpha* = 0.525 pass@k(0)=0.6717 pass@k(alpha*)=0.6762 pass@k(1)=0.6738\n",
248
+ "k= 32: alpha* = 0.000 pass@k(0)=0.8998 pass@k(alpha*)=0.8998 pass@k(1)=0.7868\n",
249
+ "p maximising the marginal value of a log-odds shift, k p (1-p)^k : 1/(k+1) = {1: 0.5, 4: 0.2, 32: 0.030303030303030304}\n"
250
+ ]
251
+ }
252
+ ],
253
+ "source": [
254
+ "rg = np.random.RandomState(2)\n",
255
+ "# Two populations (synthetic, to exhibit the regimes): 65% \"learned\" prompts that the RL stage improves, 35% \"tail\" prompts (hard, p ~ 5%)\n",
256
+ "# that it degrades. logit p_x(alpha) = l_x + alpha Delta_x.\n",
257
+ "N = 2000; nt = int(0.35 * N); n1 = N - nt\n",
258
+ "l = torch.tensor(np.concatenate([rg.randn(n1) * 1.5 - 0.5, rg.randn(nt) * 0.5 - 3.0]))\n",
259
+ "Delta = torch.tensor(np.concatenate([rg.randn(n1) * 0.3 + 1.5, rg.randn(nt) * 0.3 - 1.2]))\n",
260
+ "def passk(al, k):\n",
261
+ " p = torch.sigmoid(l + al * Delta); return (1 - (1 - p) ** k).mean()\n",
262
+ "worst = 0.0\n",
263
+ "for k in (1, 4, 32):\n",
264
+ " for al in (0.0, 0.5, 1.0):\n",
265
+ " a_ = torch.tensor(al, requires_grad=True)\n",
266
+ " p = torch.sigmoid(l + a_ * Delta); (1 - (1 - p) ** k).mean().backward()\n",
267
+ " p_ = p.detach(); formula = (k * p_ * (1 - p_) ** k * Delta).mean()\n",
268
+ " worst = max(worst, abs(float(a_.grad - formula)))\n",
269
+ "check('3. d pass@k/dalpha = E[k p (1-p)^k Delta] vs autograd, k in {1,4,32}', worst, 1e-15)\n",
270
+ "als = np.linspace(0, 1, 201)\n",
271
+ "star = {}\n",
272
+ "for k in (1, 8, 32):\n",
273
+ " vals = np.array([float(passk(a_, k)) for a_ in als]); star[k] = als[vals.argmax()]\n",
274
+ " print(f'k={k:3d}: alpha* = {als[vals.argmax()]:.3f} pass@k(0)={vals[0]:.4f} pass@k(alpha*)={vals.max():.4f} pass@k(1)={vals[-1]:.4f}')\n",
275
+ "assert star[1] == 1.0 and 0.0 < star[8] < 1.0 and star[32] < star[8]\n",
276
+ "CHECKS.append(('3. regimes: alpha*(k=1) = 1, 0 < alpha*(k=8) < 1, alpha*(k=32) < alpha*(k=8) (alpha*: %s)' % {k_: round(float(v_), 3) for k_, v_ in star.items()}, 0.0, float('inf')))\n",
277
+ "peak_p = {k: 1.0 / (k + 1) for k in (1, 4, 32)}\n",
278
+ "print('p maximising the marginal value of a log-odds shift, k p (1-p)^k : 1/(k+1) =', peak_p)"
279
+ ]
280
+ },
281
+ {
282
+ "cell_type": "markdown",
283
+ "id": "6860011a",
284
+ "metadata": {},
285
+ "source": [
286
+ "## 3b. The end-point sign test is sufficient, not necessary, for an interior optimum\n",
287
+ "Two populations on logit-linear paths: half of the prompts `l=-3, Delta=-1` (declining), half `l=-8, Delta=+10` (late bloomers). `k=8`."
288
+ ]
289
+ },
290
+ {
291
+ "cell_type": "code",
292
+ "execution_count": 6,
293
+ "id": "24bcee3d",
294
+ "metadata": {
295
+ "execution": {
296
+ "iopub.execute_input": "2026-10-14T09:49:32.088723Z",
297
+ "iopub.status.busy": "2026-10-14T09:49:32.088559Z",
298
+ "iopub.status.idle": "2026-10-14T09:49:32.094636Z",
299
+ "shell.execute_reply": "2026-10-14T09:49:32.093860Z"
300
+ }
301
+ },
302
+ "outputs": [
303
+ {
304
+ "name": "stdout",
305
+ "output_type": "stream",
306
+ "text": [
307
+ "d/dalpha pass@8 at 0: -0.1152, at 1: -0.0622; pass@8(0)=0.1624 pass@8(1)=0.5676 max=0.5792 at alpha=0.802\n",
308
+ "3b. derivative formula E[k p (1-p)^k Delta] vs finite difference (this population) err=1.250e-10 (tol 1e-07)\n"
309
+ ]
310
+ }
311
+ ],
312
+ "source": [
313
+ "sig_ = lambda x: 1 / (1 + np.exp(-x))\n",
314
+ "pop_ = [(0.5, -3.0, -1.0), (0.5, -8.0, 10.0)]\n",
315
+ "pk_ = lambda a_, k_: sum(w_ * (1 - (1 - sig_(l_ + a_ * D_)) ** k_) for w_, l_, D_ in pop_)\n",
316
+ "dpk_ = lambda a_, k_: sum(w_ * k_ * sig_(l_ + a_ * D_) * (1 - sig_(l_ + a_ * D_)) ** k_ * D_ for w_, l_, D_ in pop_)\n",
317
+ "grid_ = np.linspace(0, 1, 2001); f_ = pk_(grid_, 8)\n",
318
+ "print(f'd/dalpha pass@8 at 0: {dpk_(0.0, 8):.4f}, at 1: {dpk_(1.0, 8):.4f}; pass@8(0)={f_[0]:.4f} pass@8(1)={f_[-1]:.4f} max={f_.max():.4f} at alpha={grid_[f_.argmax()]:.3f}')\n",
319
+ "assert dpk_(0.0, 8) < 0 and dpk_(1.0, 8) < 0 and 0 < f_.argmax() < len(grid_) - 1 and f_.max() > f_[-1] + 1e-3 and f_.max() > f_[0] + 1e-3\n",
320
+ "fd_ = (pk_(0.3 + 1e-6, 8) - pk_(0.3 - 1e-6, 8)) / 2e-6\n",
321
+ "check('3b. derivative formula E[k p (1-p)^k Delta] vs finite difference (this population)', abs(fd_ - dpk_(0.3, 8)), 1e-7)\n",
322
+ "CHECKS.append(('3b. interior global optimum with negative derivative at both ends (alpha* = %.3f, informational)' % grid_[f_.argmax()], float(grid_[f_.argmax()]), float('inf')))"
323
+ ]
324
+ },
325
+ {
326
+ "cell_type": "markdown",
327
+ "id": "b777f25a",
328
+ "metadata": {},
329
+ "source": [
330
+ "## 4. Curvature along the path: model-soups Eq. (1) for logit-linear models\n",
331
+ "Cross-entropy of a designated correct proof `y*` per context: `L(alpha) = -E_x log softmax(z_alpha)[y*]`. Then\n",
332
+ "`d^2 L / dalpha^2 = E_x Var_{y ~ softmax(z_alpha)}[Delta z_y]` (second derivative of a log-partition function) and the soup equals the logit ensemble."
333
+ ]
334
+ },
335
+ {
336
+ "cell_type": "code",
337
+ "execution_count": 7,
338
+ "id": "c4618972",
339
+ "metadata": {
340
+ "execution": {
341
+ "iopub.execute_input": "2026-10-14T09:49:32.095990Z",
342
+ "iopub.status.busy": "2026-10-14T09:49:32.095840Z",
343
+ "iopub.status.idle": "2026-10-14T09:49:32.101953Z",
344
+ "shell.execute_reply": "2026-10-14T09:49:32.101282Z"
345
+ }
346
+ },
347
+ "outputs": [
348
+ {
349
+ "name": "stdout",
350
+ "output_type": "stream",
351
+ "text": [
352
+ "4. d^2 L/dalpha^2 (autograd) = E_x Var_{softmax(z_alpha)}[Delta z] err=4.441e-16 (tol 1e-15)\n",
353
+ "4. soup loss = logit-ensemble loss for logit-linear models err=0.000e+00 (tol 1e-16)\n"
354
+ ]
355
+ }
356
+ ],
357
+ "source": [
358
+ "ystar = torch.tensor(np.argmax(V.numpy(), axis=1))\n",
359
+ "def L_of(al): return -torch.log_softmax(z0 + al * (z1 - z0), -1)[torch.arange(m), ystar].mean()\n",
360
+ "al = torch.tensor(0.35, requires_grad=True)\n",
361
+ "L = L_of(al); g1, = torch.autograd.grad(L, al, create_graph=True); g2, = torch.autograd.grad(g1, al)\n",
362
+ "pz = torch.softmax(z0 + al.detach() * (z1 - z0), -1); dz = z1 - z0\n",
363
+ "var_dz = ((pz * dz ** 2).sum(-1) - (pz * dz).sum(-1) ** 2).mean()\n",
364
+ "check('4. d^2 L/dalpha^2 (autograd) = E_x Var_{softmax(z_alpha)}[Delta z]', abs(float(g2 - var_dz)), 1e-15)\n",
365
+ "# soup vs logit-ensemble loss difference (soups Eq.(1) predicts ~0 when logits are linear in theta)\n",
366
+ "ens_logits = (1 - al.detach()) * z0 + al.detach() * z1\n",
367
+ "L_ens = -torch.log_softmax(ens_logits, -1)[torch.arange(m), ystar].mean()\n",
368
+ "check('4. soup loss = logit-ensemble loss for logit-linear models', abs(float(L_of(al.detach()) - L_ens)), 1e-16)"
369
+ ]
370
+ },
371
+ {
372
+ "cell_type": "markdown",
373
+ "id": "e774bc82",
374
+ "metadata": {},
375
+ "source": [
376
+ "## 5. Loss along a line: chord identity and barriers\n",
377
+ "Quadratic `l(theta) = l_0 + g^T (theta-theta_0) + 1/2 (theta-theta_0)^T H (theta-theta_0)`, `d = theta_1-theta_0`, `kappa = d^T H d`:\n",
378
+ "`l(alpha) - [(1-alpha) l(0) + alpha l(1)] = (1/2) alpha (alpha-1) kappa`. For `H` PSD this is `<= 0` (no barrier); the averaged model beats the chord."
379
+ ]
380
+ },
381
+ {
382
+ "cell_type": "code",
383
+ "execution_count": 8,
384
+ "id": "a8781b64",
385
+ "metadata": {
386
+ "execution": {
387
+ "iopub.execute_input": "2026-10-14T09:49:32.103566Z",
388
+ "iopub.status.busy": "2026-10-14T09:49:32.103406Z",
389
+ "iopub.status.idle": "2026-10-14T09:49:32.127330Z",
390
+ "shell.execute_reply": "2026-10-14T09:49:32.126399Z"
391
+ }
392
+ },
393
+ "outputs": [
394
+ {
395
+ "name": "stdout",
396
+ "output_type": "stream",
397
+ "text": [
398
+ "5. quadratic: l(alpha) - chord = alpha(alpha-1) kappa / 2 err=9.770e-15 (tol 1e-12)\n",
399
+ "5. convex softmax-linear NLL: barrier max_alpha [l(alpha)-chord] <= 0 err=0.000e+00 (tol 1e-14)\n",
400
+ "barrier of the convex NLL (should be <= 0): 0.0\n"
401
+ ]
402
+ }
403
+ ],
404
+ "source": [
405
+ "rq = np.random.RandomState(8)\n",
406
+ "A = rq.randn(7, 7); H = torch.tensor(A @ A.T); gv = torch.tensor(rq.randn(7)); dd = torch.tensor(rq.randn(7))\n",
407
+ "lq = lambda t: 0.3 + gv @ t + 0.5 * t @ H @ t\n",
408
+ "kappa = float(dd @ H @ dd)\n",
409
+ "worst = max(abs(float(lq(a_ * dd) - ((1 - a_) * lq(0 * dd) + a_ * lq(dd))) - 0.5 * a_ * (a_ - 1) * kappa) for a_ in np.linspace(-0.5, 1.5, 9))\n",
410
+ "check('5. quadratic: l(alpha) - chord = alpha(alpha-1) kappa / 2', worst, 1e-12)\n",
411
+ "# convex NLL of the log-linear policy: no barrier\n",
412
+ "def nll(al): return float(L_of(torch.tensor(al)))\n",
413
+ "grid = np.linspace(0, 1, 101)\n",
414
+ "bar = max(nll(a_) - ((1 - a_) * nll(0.0) + a_ * nll(1.0)) for a_ in grid)\n",
415
+ "check('5. convex softmax-linear NLL: barrier max_alpha [l(alpha)-chord] <= 0', max(0.0, bar), 1e-14)\n",
416
+ "print('barrier of the convex NLL (should be <= 0):', bar)"
417
+ ]
418
+ },
419
+ {
420
+ "cell_type": "markdown",
421
+ "id": "8f32e360",
422
+ "metadata": {},
423
+ "source": [
424
+ "## 6. Linear mode connectivity in a tiny non-convex net\n",
425
+ "2-D spiral, 2 classes. A 2-64-64-2 tanh MLP, full-batch Adam in fp64.\n",
426
+ "* Same-init: pretrain on task A, then two fine-tunes on a rotated/noisier task B with different seeds.\n",
427
+ "* Independent: two nets trained from different random inits on task B.\n",
428
+ "Barrier `B = max_alpha [ L(theta_alpha) - ((1-alpha) L(theta_0) + alpha L(theta_1)) ]` evaluated on the union of both tasks' data."
429
+ ]
430
+ },
431
+ {
432
+ "cell_type": "code",
433
+ "execution_count": 9,
434
+ "id": "94b56789",
435
+ "metadata": {
436
+ "execution": {
437
+ "iopub.execute_input": "2026-10-14T09:49:32.128822Z",
438
+ "iopub.status.busy": "2026-10-14T09:49:32.128648Z",
439
+ "iopub.status.idle": "2026-10-14T09:49:37.344398Z",
440
+ "shell.execute_reply": "2026-10-14T09:49:37.343352Z"
441
+ }
442
+ },
443
+ "outputs": [
444
+ {
445
+ "name": "stdout",
446
+ "output_type": "stream",
447
+ "text": [
448
+ "same-init fine-tunes : endpoints L=0.001,0.001 barrier=+0.0000\n",
449
+ "independent inits : endpoints L=0.000,0.000 barrier=+3.1884\n"
450
+ ]
451
+ },
452
+ {
453
+ "name": "stderr",
454
+ "output_type": "stream",
455
+ "text": [
456
+ "/home/vignesh/.opencode/tmp/ipykernel_606603/4015540266.py:25: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.\n",
457
+ "Consider using tensor.detach() first. (Triggered internally at /pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:838.)\n",
458
+ " L0, L1 = float(loss(P0, Xall, Yall)), float(loss(P1, Xall, Yall))\n"
459
+ ]
460
+ }
461
+ ],
462
+ "source": [
463
+ "def spiral(n, rot, noise, seed):\n",
464
+ " r = np.random.RandomState(seed); t = np.linspace(0.2, 3.2, n)\n",
465
+ " X = []; Y = []\n",
466
+ " for c in (0, 1):\n",
467
+ " ang = t * 2.0 + c * math.pi + rot\n",
468
+ " X.append(np.stack([t * np.cos(ang), t * np.sin(ang)], 1) + r.randn(n, 2) * noise); Y.append(np.full(n, c))\n",
469
+ " return torch.tensor(np.concatenate(X)), torch.tensor(np.concatenate(Y))\n",
470
+ "XA, YA = spiral(300, 0.0, 0.03, 1); XB, YB = spiral(300, 0.25, 0.08, 2)\n",
471
+ "Xall, Yall = torch.cat([XA, XB]), torch.cat([YA, YB])\n",
472
+ "def init(seed, h=64):\n",
473
+ " r = torch.Generator().manual_seed(seed)\n",
474
+ " P = [torch.randn(2, h, generator=r) / math.sqrt(2), torch.zeros(h), torch.randn(h, h, generator=r) / math.sqrt(h), torch.zeros(h),\n",
475
+ " torch.randn(h, 2, generator=r) / math.sqrt(h), torch.zeros(2)]\n",
476
+ " return [p.clone().requires_grad_(True) for p in P]\n",
477
+ "def fwd(P, X): return torch.tanh(torch.tanh(X @ P[0] + P[1]) @ P[2] + P[3]) @ P[4] + P[5]\n",
478
+ "def loss(P, X, Y): return torch.nn.functional.cross_entropy(fwd(P, X), Y)\n",
479
+ "def train(P, X, Y, steps, lr, seed=None, batch=None):\n",
480
+ " o = torch.optim.Adam(P, lr=lr); gen = torch.Generator().manual_seed(seed if seed is not None else 0)\n",
481
+ " for _ in range(steps):\n",
482
+ " idx = torch.randperm(len(X), generator=gen)[:batch] if batch else slice(None)\n",
483
+ " o.zero_grad(); loss(P, X[idx], Y[idx]).backward(); o.step()\n",
484
+ " return [p.detach().clone().requires_grad_(True) for p in P]\n",
485
+ "def interp(P0, P1, a): return [((1 - a) * p0 + a * p1).detach() for p0, p1 in zip(P0, P1)]\n",
486
+ "def barrier(P0, P1):\n",
487
+ " L0, L1 = float(loss(P0, Xall, Yall)), float(loss(P1, Xall, Yall))\n",
488
+ " return max(float(loss(interp(P0, P1, a), Xall, Yall)) - ((1 - a) * L0 + a * L1) for a in np.linspace(0, 1, 41)), L0, L1\n",
489
+ "pre = train(init(0), XA, YA, 600, 1e-2)\n",
490
+ "ftA = train([p.detach().clone().requires_grad_(True) for p in pre], XB, YB, 300, 3e-3, seed=1, batch=64)\n",
491
+ "ftB = train([p.detach().clone().requires_grad_(True) for p in pre], XB, YB, 300, 3e-3, seed=2, batch=64)\n",
492
+ "indA = train(init(11), XB, YB, 900, 1e-2); indB = train(init(12), XB, YB, 900, 1e-2)\n",
493
+ "b_same, L0s, L1s = barrier(ftA, ftB); b_ind, L0i, L1i = barrier(indA, indB)\n",
494
+ "print(f'same-init fine-tunes : endpoints L={L0s:.3f},{L1s:.3f} barrier={b_same:+.4f}')\n",
495
+ "print(f'independent inits : endpoints L={L0i:.3f},{L1i:.3f} barrier={b_ind:+.4f}')\n",
496
+ "assert b_ind > 0.1 and b_same < b_ind / 5\n",
497
+ "CHECKS.append(('6. barrier, independent inits (informational, large)', float(b_ind), float('inf')))\n",
498
+ "CHECKS.append(('6. barrier, same-init fine-tunes (informational, small)', float(b_same), float('inf')))"
499
+ ]
500
+ },
501
+ {
502
+ "cell_type": "markdown",
503
+ "id": "ac53ed4f",
504
+ "metadata": {},
505
+ "source": [
506
+ "## 7. SFT -> RL interpolation on 30 random log-linear policy families\n",
507
+ "Each family: 96 prompts, 24 candidate proofs, 6 features, ~8% of candidates verified. `theta_sft` = 80 steps of pass@1 ascent from a random init, `theta_rl` = 400 more.\n",
508
+ "For each family evaluate exact `pass@1, pass@8, pass@64` on `alpha in [0,1]` (101 points). NAIVE = rebuild `theta_alpha`; EFFICIENT = interpolate logits (asserted equal)."
509
+ ]
510
+ },
511
+ {
512
+ "cell_type": "code",
513
+ "execution_count": 10,
514
+ "id": "1e316635",
515
+ "metadata": {
516
+ "execution": {
517
+ "iopub.execute_input": "2026-10-14T09:49:37.346390Z",
518
+ "iopub.status.busy": "2026-10-14T09:49:37.346111Z",
519
+ "iopub.status.idle": "2026-10-14T09:49:46.306787Z",
520
+ "shell.execute_reply": "2026-10-14T09:49:46.306034Z"
521
+ }
522
+ },
523
+ "outputs": [
524
+ {
525
+ "name": "stdout",
526
+ "output_type": "stream",
527
+ "text": [
528
+ "families (of 30) with d pass@k/dalpha > 0 at alpha=0 / < 0 at alpha=1 : {1: [30, 0], 8: [14, 30], 64: [3, 30]}\n",
529
+ "families whose end-point derivative signs imply an interior optimum : {8: 14, 64: 3}\n",
530
+ "7. naive (theta_alpha) vs efficient (logit interpolation), all families err=6.661e-16 (tol 1e-15)\n",
531
+ "pass@1 non-decreasing in alpha: 30/30 families\n",
532
+ "interior argmax (0<alpha*<1): pass@8 14/30 pass@64 3/30\n",
533
+ "median alpha*: pass@8 0.00 pass@64 0.00\n",
534
+ "mean pass@1 : alpha=0 0.091 alpha=1 0.135 ; at alpha*_64 0.091\n",
535
+ "mean pass@8 : alpha=0 0.417 alpha=1 0.294 ; best 0.420\n",
536
+ "mean pass@64: alpha=0 0.779 alpha=1 0.466 ; best 0.780\n",
537
+ "family 0 curves at alpha=0,0.25,0.5,0.75,1:\n",
538
+ " pass@1 [0.0959 0.1068 0.1252 0.145 0.1595]\n",
539
+ " pass@8 [0.4538 0.4447 0.4014 0.354 0.3184]\n",
540
+ " pass@64 [0.8234 0.7851 0.691 0.5819 0.4962]\n"
541
+ ]
542
+ }
543
+ ],
544
+ "source": [
545
+ "def family(seed, m=96, K=24, d=6, dens=0.08, steps_sft=80, steps_rl=400, lr=0.5):\n",
546
+ " r = np.random.RandomState(seed)\n",
547
+ " Ph = torch.tensor(r.randn(m, K, d)); Vv = torch.tensor((r.rand(m, K) < dens).astype(float))\n",
548
+ " t0 = torch.tensor(r.randn(d) * 0.3)\n",
549
+ " def pof(th): return (torch.softmax(Ph @ th, -1) * Vv).sum(-1)\n",
550
+ " th = t0.clone().requires_grad_(True); o = torch.optim.SGD([th], lr=lr)\n",
551
+ " for _ in range(steps_sft): o.zero_grad(); (-pof(th).mean()).backward(); o.step()\n",
552
+ " t_sft = th.detach().clone()\n",
553
+ " for _ in range(steps_rl): o.zero_grad(); (-pof(th).mean()).backward(); o.step()\n",
554
+ " return Ph, Vv, t_sft, th.detach().clone()\n",
555
+ "als = np.linspace(0, 1, 101)\n",
556
+ "rows = []; maxdiff = 0.0\n",
557
+ "for seed in range(30):\n",
558
+ " Ph, Vv, ta, tb = family(seed)\n",
559
+ " za, zb = Ph @ ta, Ph @ tb\n",
560
+ " P1 = np.zeros(len(als)); P8 = np.zeros(len(als)); P64 = np.zeros(len(als))\n",
561
+ " for i, a_ in enumerate(als):\n",
562
+ " p_eff = (torch.softmax((1 - a_) * za + a_ * zb, -1) * Vv).sum(-1)\n",
563
+ " if i % 25 == 0:\n",
564
+ " p_naive = (torch.softmax(Ph @ ((1 - a_) * ta + a_ * tb), -1) * Vv).sum(-1)\n",
565
+ " maxdiff = max(maxdiff, float((p_eff - p_naive).abs().max()))\n",
566
+ " P1[i] = float(p_eff.mean()); P8[i] = float((1 - (1 - p_eff) ** 8).mean()); P64[i] = float((1 - (1 - p_eff) ** 64).mean())\n",
567
+ " rows.append(dict(seed=seed, mono1=bool(np.all(np.diff(P1) >= -1e-12)), a8=als[P8.argmax()], a64=als[P64.argmax()],\n",
568
+ " p1_0=P1[0], p1_1=P1[-1], p8_0=P8[0], p8_1=P8[-1], p8_star=P8.max(), p64_0=P64[0], p64_1=P64[-1], p64_star=P64.max(),\n",
569
+ " p1_at64=P1[P64.argmax()]))\n",
570
+ " if seed == 0: curves0 = (P1.copy(), P8.copy(), P64.copy())\n",
571
+ "# Exact end-point derivatives: d pass@k/dalpha at alpha = E_x[ k (1-p)^(k-1) Cov_alpha(g, r) ], g = z1 - z0 (no log-odds assumption needed).\n",
572
+ "def deriv_k(Ph, Vv, za, zb, a_, k):\n",
573
+ " pz = torch.softmax((1 - a_) * za + a_ * zb, -1); gg = zb - za\n",
574
+ " p = (pz * Vv).sum(-1); cov = (pz * gg * Vv).sum(-1) - (pz * gg).sum(-1) * p\n",
575
+ " return float((k * (1 - p) ** (k - 1) * cov).mean())\n",
576
+ "sign_ok = 0; cnt = {1: [0, 0], 8: [0, 0], 64: [0, 0]}; inter_exact = {8: 0, 64: 0}\n",
577
+ "for seed in range(30):\n",
578
+ " Ph, Vv, ta, tb = family(seed); za, zb = Ph @ ta, Ph @ tb\n",
579
+ " for k in (1, 8, 64):\n",
580
+ " d0, d1 = deriv_k(Ph, Vv, za, zb, 0.0, k), deriv_k(Ph, Vv, za, zb, 1.0, k)\n",
581
+ " cnt[k][0] += d0 > 0; cnt[k][1] += d1 < 0\n",
582
+ " if k in (8, 64):\n",
583
+ " row = rows[seed]; astar = row['a8'] if k == 8 else row['a64']\n",
584
+ " assert (d1 >= 0) or (astar < 1.0) # derivative negative at alpha=1 => interpolating toward the base helps\n",
585
+ " assert (d0 <= 0) or (astar > 0.0) # derivative positive at alpha=0 => moving toward the fine-tune helps\n",
586
+ " inter_exact[k] += (d0 > 0 and d1 < 0)\n",
587
+ "print('families (of 30) with d pass@k/dalpha > 0 at alpha=0 / < 0 at alpha=1 :', cnt)\n",
588
+ "print('families whose end-point derivative signs imply an interior optimum :', inter_exact)\n",
589
+ "check('7. naive (theta_alpha) vs efficient (logit interpolation), all families', maxdiff, 1e-15)\n",
590
+ "import statistics as st\n",
591
+ "mono = sum(r_['mono1'] for r_ in rows)\n",
592
+ "inter8 = sum(0 < r_['a8'] < 1 for r_ in rows); inter64 = sum(0 < r_['a64'] < 1 for r_ in rows)\n",
593
+ "print(f'pass@1 non-decreasing in alpha: {mono}/30 families')\n",
594
+ "print(f'interior argmax (0<alpha*<1): pass@8 {inter8}/30 pass@64 {inter64}/30')\n",
595
+ "print('median alpha*: pass@8 %.2f pass@64 %.2f' % (st.median(r_['a8'] for r_ in rows), st.median(r_['a64'] for r_ in rows)))\n",
596
+ "print('mean pass@1 : alpha=0 %.3f alpha=1 %.3f ; at alpha*_64 %.3f' % (st.mean(r_['p1_0'] for r_ in rows), st.mean(r_['p1_1'] for r_ in rows), st.mean(r_['p1_at64'] for r_ in rows)))\n",
597
+ "print('mean pass@8 : alpha=0 %.3f alpha=1 %.3f ; best %.3f' % (st.mean(r_['p8_0'] for r_ in rows), st.mean(r_['p8_1'] for r_ in rows), st.mean(r_['p8_star'] for r_ in rows)))\n",
598
+ "print('mean pass@64: alpha=0 %.3f alpha=1 %.3f ; best %.3f' % (st.mean(r_['p64_0'] for r_ in rows), st.mean(r_['p64_1'] for r_ in rows), st.mean(r_['p64_star'] for r_ in rows)))\n",
599
+ "print('family 0 curves at alpha=0,0.25,0.5,0.75,1:')\n",
600
+ "for nm, cv in zip(('pass@1', 'pass@8', 'pass@64'), curves0): print(f' {nm:8s}', np.round(cv[[0, 25, 50, 75, 100]], 4))\n",
601
+ "assert mono == 30\n",
602
+ "CHECKS.append(('7. families with pass@1 monotone in alpha (count of 30, informational)', float(mono), float('inf')))\n",
603
+ "CHECKS.append(('7. families with interior argmax of pass@64 (count of 30, informational)', float(inter64), float('inf')))"
604
+ ]
605
+ },
606
+ {
607
+ "cell_type": "markdown",
608
+ "id": "99bfedce",
609
+ "metadata": {},
610
+ "source": [
611
+ "## 8. Two practical traps: LoRA averaging and bf16 interpolation\n",
612
+ "(a) Averaging two LoRA adapters factor-wise is not averaging their weight updates: `((B1+B2)/2)((A1+A2)/2) = (1/4)(B1A1 + B2A2 + B1A2 + B2A1)`\n",
613
+ "whereas `(B1A1 + B2A2)/2` is what weight averaging means. Scaling a *merged* update `Delta W = BA` by alpha is exact.\n",
614
+ "(b) bf16 has an 8-bit significand (7 stored bits): unit roundoff 2^-8 ~ 3.9e-3, spacing between 2^-8 and 2^-7 of the weight. A small fine-tuning delta can vanish when stored/interpolated in bf16."
615
+ ]
616
+ },
617
+ {
618
+ "cell_type": "code",
619
+ "execution_count": 11,
620
+ "id": "6f52a593",
621
+ "metadata": {
622
+ "execution": {
623
+ "iopub.execute_input": "2026-10-14T09:49:46.308605Z",
624
+ "iopub.status.busy": "2026-10-14T09:49:46.308302Z",
625
+ "iopub.status.idle": "2026-10-14T09:49:46.524226Z",
626
+ "shell.execute_reply": "2026-10-14T09:49:46.523465Z"
627
+ }
628
+ },
629
+ "outputs": [
630
+ {
631
+ "name": "stdout",
632
+ "output_type": "stream",
633
+ "text": [
634
+ "8a. factor-wise LoRA average minus weight average = (1/4)(B1A2+B2A1-B1A1-B2A2) err=6.661e-16 (tol 1e-13)\n",
635
+ "||factor-wise avg - weight avg||_F = 14.505125959712405 relative to ||weight avg||_F: 0.6213290933983042\n",
636
+ "delta/weight = 1e-01: fraction of weights whose bf16 value is unchanged by fine-tuning = 0.018; bf16-arithmetic midpoint error / mean |delta| = 0.01\n",
637
+ "delta/weight = 1e-02: fraction of weights whose bf16 value is unchanged by fine-tuning = 0.169; bf16-arithmetic midpoint error / mean |delta| = 0.14\n",
638
+ "delta/weight = 1e-03: fraction of weights whose bf16 value is unchanged by fine-tuning = 0.685; bf16-arithmetic midpoint error / mean |delta| = 0.44\n",
639
+ "delta/weight = 1e-04: fraction of weights whose bf16 value is unchanged by fine-tuning = 0.942; bf16-arithmetic midpoint error / mean |delta| = 0.49\n"
640
+ ]
641
+ }
642
+ ],
643
+ "source": [
644
+ "rl = np.random.RandomState(1)\n",
645
+ "B1, A1, B2, A2 = [torch.tensor(rl.randn(16, 4)), torch.tensor(rl.randn(4, 16)), torch.tensor(rl.randn(16, 4)), torch.tensor(rl.randn(4, 16))]\n",
646
+ "fw = ((B1 + B2) / 2) @ ((A1 + A2) / 2); true_avg = (B1 @ A1 + B2 @ A2) / 2\n",
647
+ "cross = 0.25 * (B1 @ A2 + B2 @ A1) - 0.25 * (B1 @ A1 + B2 @ A2) # fw - true_avg = (1/4)(B1A2+B2A1) - (1/4)(B1A1+B2A2)\n",
648
+ "check('8a. factor-wise LoRA average minus weight average = (1/4)(B1A2+B2A1-B1A1-B2A2)', (fw - true_avg - cross).abs().max(), 1e-13)\n",
649
+ "print('||factor-wise avg - weight avg||_F =', float((fw - true_avg).norm()), ' relative to ||weight avg||_F:', float((fw - true_avg).norm() / true_avg.norm()))\n",
650
+ "rb = torch.Generator().manual_seed(0)\n",
651
+ "w0 = (torch.randn(1_000_000, generator=rb) * 0.02)\n",
652
+ "for rel in (1e-1, 1e-2, 1e-3, 1e-4):\n",
653
+ " dlt = torch.randn(1_000_000, generator=rb) * 0.02 * rel\n",
654
+ " w1 = w0 + dlt\n",
655
+ " w0b, w1b = w0.to(torch.bfloat16), w1.to(torch.bfloat16)\n",
656
+ " lost = float((w0b == w1b).double().mean())\n",
657
+ " half_bf = ((0.5 * w0b.double() + 0.5 * w1b.double())) # exact midpoint of the stored bf16 values\n",
658
+ " half_bf16 = (0.5 * w0b + 0.5 * w1b).double() # computed entirely in bf16\n",
659
+ " err_bf16 = float((half_bf16 - half_bf).abs().mean() / (dlt.abs().mean()))\n",
660
+ " print(f'delta/weight = {rel:7.0e}: fraction of weights whose bf16 value is unchanged by fine-tuning = {lost:.3f}; bf16-arithmetic midpoint error / mean |delta| = {err_bf16:.2f}')\n",
661
+ "CHECKS.append(('8b. fraction of weights whose bf16 value is unchanged when delta/weight = 1e-3 (informational)', float((w0.to(torch.bfloat16) == (w0 + torch.randn(1_000_000, generator=rb) * 0.02 * 1e-3).to(torch.bfloat16)).double().mean()), float('inf')))"
662
+ ]
663
+ },
664
+ {
665
+ "cell_type": "markdown",
666
+ "id": "a3c399c8",
667
+ "metadata": {},
668
+ "source": [
669
+ "## Verification table"
670
+ ]
671
+ },
672
+ {
673
+ "cell_type": "code",
674
+ "execution_count": 12,
675
+ "id": "9a2c801c",
676
+ "metadata": {
677
+ "execution": {
678
+ "iopub.execute_input": "2026-10-14T09:49:46.525767Z",
679
+ "iopub.status.busy": "2026-10-14T09:49:46.525608Z",
680
+ "iopub.status.idle": "2026-10-14T09:49:46.529296Z",
681
+ "shell.execute_reply": "2026-10-14T09:49:46.528514Z"
682
+ }
683
+ },
684
+ "outputs": [
685
+ {
686
+ "name": "stdout",
687
+ "output_type": "stream",
688
+ "text": [
689
+ "\n",
690
+ "identity max abs err\n",
691
+ "1. naive (re-evaluate theta_alpha) vs efficient (interpolate logits) 4.441e-16\n",
692
+ "1. softmax(z_alpha) vs normalised pi0^(1-a) pi1^a 4.441e-16\n",
693
+ "2. d/dalpha E_alpha[r] (autograd) = Cov_alpha(g, r) 5.204e-18\n",
694
+ "2. binary tilt: p(alpha) = sigmoid(logit p0 + alpha c) 0.000e+00\n",
695
+ "2b. autoregressive: dE/dalpha (finite diff.) = Cov_alpha(sequence score, r) 5.361e-11\n",
696
+ "2b. autoregressive: error of the naive Cov_alpha(sum_t g_t, r) (informational, must be > 0) 1.498e-02\n",
697
+ "2b. zero mean of the sequence score: E_alpha[score] = 0 5.204e-17\n",
698
+ "3. d pass@k/dalpha = E[k p (1-p)^k Delta] vs autograd, k in {1,4,32} 2.776e-17\n",
699
+ "3. regimes: alpha*(k=1) = 1, 0 < alpha*(k=8) < 1, alpha*(k=32) < alpha*(k=8) (alpha*: {1: 1.0, 8: 0.525, 32: 0.0}) 0.000e+00\n",
700
+ "3b. derivative formula E[k p (1-p)^k Delta] vs finite difference (this population) 1.250e-10\n",
701
+ "3b. interior global optimum with negative derivative at both ends (alpha* = 0.802, informational) 8.020e-01\n",
702
+ "4. d^2 L/dalpha^2 (autograd) = E_x Var_{softmax(z_alpha)}[Delta z] 4.441e-16\n",
703
+ "4. soup loss = logit-ensemble loss for logit-linear models 0.000e+00\n",
704
+ "5. quadratic: l(alpha) - chord = alpha(alpha-1) kappa / 2 9.770e-15\n",
705
+ "5. convex softmax-linear NLL: barrier max_alpha [l(alpha)-chord] <= 0 0.000e+00\n",
706
+ "6. barrier, independent inits (informational, large) 3.188e+00\n",
707
+ "6. barrier, same-init fine-tunes (informational, small) 0.000e+00\n",
708
+ "7. naive (theta_alpha) vs efficient (logit interpolation), all families 6.661e-16\n",
709
+ "7. families with pass@1 monotone in alpha (count of 30, informational) 3.000e+01\n",
710
+ "7. families with interior argmax of pass@64 (count of 30, informational) 3.000e+00\n",
711
+ "8a. factor-wise LoRA average minus weight average = (1/4)(B1A2+B2A1-B1A1-B2A2) 6.661e-16\n",
712
+ "8b. fraction of weights whose bf16 value is unchanged when delta/weight = 1e-3 (informational) 6.853e-01\n",
713
+ "CHECKS_JSON:[[\"1. naive (re-evaluate theta_alpha) vs efficient (interpolate logits)\", 4.440892098500626e-16, 1e-15], [\"1. softmax(z_alpha) vs normalised pi0^(1-a) pi1^a\", 4.440892098500626e-16, 1e-15], [\"2. d/dalpha E_alpha[r] (autograd) = Cov_alpha(g, r)\", 5.204170427930421e-18, 1e-16], [\"2. binary tilt: p(alpha) = sigmoid(logit p0 + alpha c)\", 0.0, 1e-15], [\"2b. autoregressive: dE/dalpha (finite diff.) = Cov_alpha(sequence score, r)\", 5.3605397898337515e-11, 1e-08], [\"2b. autoregressive: error of the naive Cov_alpha(sum_t g_t, r) (informational, must be > 0)\", 0.014984901434224901, Infinity], [\"2b. zero mean of the sequence score: E_alpha[score] = 0\", 5.204170427930421e-17, 1e-14], [\"3. d pass@k/dalpha = E[k p (1-p)^k Delta] vs autograd, k in {1,4,32}\", 2.7755575615628914e-17, 1e-15], [\"3. regimes: alpha*(k=1) = 1, 0 < alpha*(k=8) < 1, alpha*(k=32) < alpha*(k=8) (alpha*: {1: 1.0, 8: 0.525, 32: 0.0})\", 0.0, Infinity], [\"3b. derivative formula E[k p (1-p)^k Delta] vs finite difference (this population)\", 1.249767789257561e-10, 1e-07], [\"3b. interior global optimum with negative derivative at both ends (alpha* = 0.802, informational)\", 0.802, Infinity], [\"4. d^2 L/dalpha^2 (autograd) = E_x Var_{softmax(z_alpha)}[Delta z]\", 4.440892098500626e-16, 1e-15], [\"4. soup loss = logit-ensemble loss for logit-linear models\", 0.0, 1e-16], [\"5. quadratic: l(alpha) - chord = alpha(alpha-1) kappa / 2\", 9.769962616701378e-15, 1e-12], [\"5. convex softmax-linear NLL: barrier max_alpha [l(alpha)-chord] <= 0\", 0.0, 1e-14], [\"6. barrier, independent inits (informational, large)\", 3.1884148897650437, Infinity], [\"6. barrier, same-init fine-tunes (informational, small)\", 0.0, Infinity], [\"7. naive (theta_alpha) vs efficient (logit interpolation), all families\", 6.661338147750939e-16, 1e-15], [\"7. families with pass@1 monotone in alpha (count of 30, informational)\", 30.0, Infinity], [\"7. families with interior argmax of pass@64 (count of 30, informational)\", 3.0, Infinity], [\"8a. factor-wise LoRA average minus weight average = (1/4)(B1A2+B2A1-B1A1-B2A2)\", 6.661338147750939e-16, 1e-13], [\"8b. fraction of weights whose bf16 value is unchanged when delta/weight = 1e-3 (informational)\", 0.6853, Infinity]]\n"
714
+ ]
715
+ }
716
+ ],
717
+ "source": [
718
+ "print()\n",
719
+ "print(f'{\"identity\":90s} {\"max abs err\":>12s}')\n",
720
+ "for n_, e_, t_ in CHECKS: print(f'{n_:90s} {e_:12.3e}')\n",
721
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
722
+ ]
723
+ }
724
+ ],
725
+ "metadata": {
726
+ "kernelspec": {
727
+ "display_name": "Python 3",
728
+ "language": "python",
729
+ "name": "python3"
730
+ },
731
+ "language_info": {
732
+ "codemirror_mode": {
733
+ "name": "ipython",
734
+ "version": 3
735
+ },
736
+ "file_extension": ".py",
737
+ "mimetype": "text/x-python",
738
+ "name": "python",
739
+ "nbconvert_exporter": "python",
740
+ "pygments_lexer": "ipython3",
741
+ "version": "3.12.11"
742
+ }
743
+ },
744
+ "nbformat": 4,
745
+ "nbformat_minor": 5
746
+ }
code/notebooks/0206-curriculum-pass-rate-band.ipynb ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "2bf0eb1a",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0206 -- Curriculum by pass-rate band: signal of group advantages as a function of solve rate `p`\n",
9
+ "\n",
10
+ "Setting: one prompt with solve probability `p = sigmoid(z)`; `G` samples with binary rewards `r_i ~ Bern(p)`; `c = sum r_i`, `p_hat = c/G`.\n",
11
+ "Identities verified (fp64):\n",
12
+ "1. GRPO (unnormalised) surrogate gradient wrt the logit `z` equals `p_hat (1-p_hat)` for every `c` (autograd vs closed form).\n",
13
+ "2. `E[p_hat(1-p_hat)] = (1-1/G) p (1-p)`; the derivative of `p` wrt `z` is `p(1-p)`, maximal at `p=1/2` with value `1/4`.\n",
14
+ "3. Exact variance, SNR, and the dead-group probability `p^G + (1-p)^G`.\n",
15
+ "4. Std-normalised advantages: gradient `sqrt(p_hat(1-p_hat))`, expected value vs `sqrt(p(1-p))` (Dr. GRPO's reweighting).\n",
16
+ "5. General bound `||grad p||^2 <= p(1-p) E||s||^2` in a random log-linear policy family.\n",
17
+ "6. Pass-rate band `(0, tau]`: effective prompt weight `E[p_hat(1-p_hat) 1{0<p_hat<=tau}] / (p(1-p))`.\n",
18
+ "7. Expected informative fraction under Beta-distributed solve rates (closed form with Beta functions) and the oversampling cost of dynamic sampling."
19
+ ]
20
+ },
21
+ {
22
+ "cell_type": "code",
23
+ "execution_count": 1,
24
+ "id": "63f2be14",
25
+ "metadata": {
26
+ "execution": {
27
+ "iopub.execute_input": "2026-10-14T09:50:16.324611Z",
28
+ "iopub.status.busy": "2026-10-14T09:50:16.324416Z",
29
+ "iopub.status.idle": "2026-10-14T09:50:18.405260Z",
30
+ "shell.execute_reply": "2026-10-14T09:50:18.404171Z"
31
+ }
32
+ },
33
+ "outputs": [],
34
+ "source": [
35
+ "import os\n",
36
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
37
+ "import math, json, itertools\n",
38
+ "from fractions import Fraction as Fr\n",
39
+ "from math import comb\n",
40
+ "import numpy as np, torch\n",
41
+ "from scipy.special import betaln\n",
42
+ "torch.set_default_dtype(torch.float64)\n",
43
+ "CHECKS = []\n",
44
+ "def check(name, err, tol):\n",
45
+ " CHECKS.append((name, float(err), float(tol)))\n",
46
+ " assert err <= tol, (name, float(err), tol)\n",
47
+ " print(f'{name:90s} err={float(err):.3e} (tol {tol:.0e})')"
48
+ ]
49
+ },
50
+ {
51
+ "cell_type": "markdown",
52
+ "id": "535c098f",
53
+ "metadata": {},
54
+ "source": [
55
+ "## 1. The group-advantage gradient in the Bernoulli-logit model\n",
56
+ "`log pi(y_i) = r_i log p + (1-r_i) log(1-p)`, `p = sigmoid(z)`, so the score is `d log pi / dz = r_i - p`.\n",
57
+ "NAIVE: build the GRPO surrogate `-(1/G) sum_i A_i log pi(y_i)` with `A_i = r_i - p_hat` (detached) and differentiate with autograd, for every `c`.\n",
58
+ "EFFICIENT: `g_hat = (1/G) sum_i (r_i - p_hat)(r_i - p) = (1/G) sum_i (r_i - p_hat) r_i = p_hat (1-p_hat)` because `sum_i (r_i - p_hat) = 0` and `r_i^2 = r_i`."
59
+ ]
60
+ },
61
+ {
62
+ "cell_type": "code",
63
+ "execution_count": 2,
64
+ "id": "fc678d9e",
65
+ "metadata": {
66
+ "execution": {
67
+ "iopub.execute_input": "2026-10-14T09:50:18.406857Z",
68
+ "iopub.status.busy": "2026-10-14T09:50:18.406631Z",
69
+ "iopub.status.idle": "2026-10-14T09:50:18.501912Z",
70
+ "shell.execute_reply": "2026-10-14T09:50:18.501228Z"
71
+ }
72
+ },
73
+ "outputs": [
74
+ {
75
+ "name": "stdout",
76
+ "output_type": "stream",
77
+ "text": [
78
+ "1. autograd surrogate gradient = p_hat (1 - p_hat), all c in 0..8, four p err=2.776e-17 (tol 1e-15)\n"
79
+ ]
80
+ }
81
+ ],
82
+ "source": [
83
+ "G = 8\n",
84
+ "worst = 0.0\n",
85
+ "for p in (0.03, 0.3, 0.5, 0.8):\n",
86
+ " for c in range(G + 1):\n",
87
+ " r = torch.tensor([1.0] * c + [0.0] * (G - c)); p_hat = r.mean()\n",
88
+ " z = torch.tensor(math.log(p / (1 - p)), requires_grad=True)\n",
89
+ " pz = torch.sigmoid(z)\n",
90
+ " logpi = r * torch.log(pz) + (1 - r) * torch.log(1 - pz)\n",
91
+ " loss = -((r - p_hat).detach() * logpi).mean(); loss.backward()\n",
92
+ " g_auto = -z.grad # ascent direction\n",
93
+ " worst = max(worst, abs(float(g_auto - p_hat * (1 - p_hat))))\n",
94
+ "check('1. autograd surrogate gradient = p_hat (1 - p_hat), all c in 0..8, four p', worst, 1e-15)"
95
+ ]
96
+ },
97
+ {
98
+ "cell_type": "markdown",
99
+ "id": "3ec78669",
100
+ "metadata": {},
101
+ "source": [
102
+ "## 2. Expectation: `(1-1/G) p (1-p)`\n",
103
+ "`E[p_hat(1-p_hat)] = (E p_hat - E p_hat^2) = p - (p(1-p)/G + p^2) = p(1-p)(1 - 1/G)`. Checked with exact rationals."
104
+ ]
105
+ },
106
+ {
107
+ "cell_type": "code",
108
+ "execution_count": 3,
109
+ "id": "609db332",
110
+ "metadata": {
111
+ "execution": {
112
+ "iopub.execute_input": "2026-10-14T09:50:18.503413Z",
113
+ "iopub.status.busy": "2026-10-14T09:50:18.503251Z",
114
+ "iopub.status.idle": "2026-10-14T09:50:18.514651Z",
115
+ "shell.execute_reply": "2026-10-14T09:50:18.513962Z"
116
+ }
117
+ },
118
+ "outputs": [
119
+ {
120
+ "name": "stdout",
121
+ "output_type": "stream",
122
+ "text": [
123
+ "2. E[p_hat(1-p_hat)] = (1-1/G) p(1-p), exact rationals err=0.000e+00 (tol 0e+00)\n",
124
+ "argmax_p p(1-p) = 0.5 max = 0.25\n",
125
+ "2. max of p(1-p) is 1/4 at p = 1/2 err=0.000e+00 (tol 1e-12)\n"
126
+ ]
127
+ }
128
+ ],
129
+ "source": [
130
+ "def E_exact(G, p): \n",
131
+ " p = Fr(p); return sum(comb(G, c) * p ** c * (1 - p) ** (G - c) * Fr(c, G) * (1 - Fr(c, G)) for c in range(G + 1))\n",
132
+ "worst = 0.0\n",
133
+ "for G_ in (2, 4, 8, 16, 64):\n",
134
+ " for p in (Fr(1, 50), Fr(1, 5), Fr(1, 2), Fr(9, 10)):\n",
135
+ " worst = max(worst, float(abs(E_exact(G_, p) - (1 - Fr(1, G_)) * p * (1 - p))))\n",
136
+ "check('2. E[p_hat(1-p_hat)] = (1-1/G) p(1-p), exact rationals', worst, 0.0)\n",
137
+ "ps = np.linspace(0, 1, 100001); sig = ps * (1 - ps)\n",
138
+ "print('argmax_p p(1-p) =', ps[sig.argmax()], ' max =', sig.max())\n",
139
+ "check('2. max of p(1-p) is 1/4 at p = 1/2', abs(sig.max() - 0.25) + abs(ps[sig.argmax()] - 0.5), 1e-12)"
140
+ ]
141
+ },
142
+ {
143
+ "cell_type": "markdown",
144
+ "id": "8abe5ba6",
145
+ "metadata": {},
146
+ "source": [
147
+ "## 3. Variance, SNR, dead groups\n",
148
+ "`Var[p_hat(1-p_hat)]` exactly (binomial sum). SNR = `E / sd`. Dead group: `c in {0, G}` with probability `p^G + (1-p)^G`; there `g_hat = 0` exactly."
149
+ ]
150
+ },
151
+ {
152
+ "cell_type": "code",
153
+ "execution_count": 4,
154
+ "id": "530a2701",
155
+ "metadata": {
156
+ "execution": {
157
+ "iopub.execute_input": "2026-10-14T09:50:18.515964Z",
158
+ "iopub.status.busy": "2026-10-14T09:50:18.515809Z",
159
+ "iopub.status.idle": "2026-10-14T09:50:18.605919Z",
160
+ "shell.execute_reply": "2026-10-14T09:50:18.605072Z"
161
+ }
162
+ },
163
+ "outputs": [
164
+ {
165
+ "name": "stdout",
166
+ "output_type": "stream",
167
+ "text": [
168
+ "G=8 p E[g] sd[g] SNR dead prob informative fraction\n",
169
+ " 0.01 0.00866 0.03021 0.287 0.92274 0.07726\n",
170
+ " 0.05 0.04156 0.06119 0.679 0.66342 0.33658\n",
171
+ " 0.10 0.07875 0.07572 1.040 0.43047 0.56953\n",
172
+ " 0.25 0.16406 0.07381 2.223 0.10013 0.89987\n",
173
+ " 0.50 0.21875 0.04134 5.292 0.00781 0.99219\n",
174
+ " 0.75 0.16406 0.07381 2.223 0.10013 0.89987\n",
175
+ " 0.90 0.07875 0.07572 1.040 0.43047 0.56953\n",
176
+ " 0.99 0.00866 0.03021 0.287 0.92274 0.07726\n",
177
+ "3. MC mean of p_hat(1-p_hat) vs exact (in s.e.) err=7.820e-01 (tol 4e+00)\n",
178
+ "3. MC dead-group fraction vs p^G + (1-p)^G (in s.e.) err=3.515e-01 (tol 4e+00)\n",
179
+ "E[g] is largest at p = 0.5 ; SNR is largest at p = 0.5\n",
180
+ "dead prob at p=0.5, G=8: 0.0078125 at p=0.9: 0.4304672200000001\n"
181
+ ]
182
+ }
183
+ ],
184
+ "source": [
185
+ "def moments(G, p, f):\n",
186
+ " pm = np.array([comb(G, c) * p ** c * (1 - p) ** (G - c) for c in range(G + 1)])\n",
187
+ " v = np.array([f(c / G) for c in range(G + 1)]); m1 = (pm * v).sum(); m2 = (pm * v ** 2).sum()\n",
188
+ " return m1, m2 - m1 ** 2\n",
189
+ "f_un = lambda ph: ph * (1 - ph)\n",
190
+ "print('G=8 p E[g] sd[g] SNR dead prob informative fraction')\n",
191
+ "tab = []\n",
192
+ "for p in (0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99):\n",
193
+ " m, v = moments(8, p, f_un); dead = p ** 8 + (1 - p) ** 8\n",
194
+ " tab.append((p, m, math.sqrt(v), m / math.sqrt(v) if v > 0 else float('nan'), dead))\n",
195
+ " print(f' {p:5.2f} {m:.5f} {math.sqrt(v):.5f} {tab[-1][3]:6.3f} {dead:.5f} {1 - dead:.5f}')\n",
196
+ "# MC check of the exact moments\n",
197
+ "rgn = np.random.RandomState(0); p = 0.2; cs = rgn.binomial(8, p, size=2_000_000); ph = cs / 8; gh = ph * (1 - ph)\n",
198
+ "m, v = moments(8, p, f_un)\n",
199
+ "check('3. MC mean of p_hat(1-p_hat) vs exact (in s.e.)', abs(gh.mean() - m) / (gh.std() / math.sqrt(len(gh))), 4.0)\n",
200
+ "check('3. MC dead-group fraction vs p^G + (1-p)^G (in s.e.)', abs(((cs == 0) | (cs == 8)).mean() - (p ** 8 + (1 - p) ** 8)) / math.sqrt(0.5 * 0.5 / len(cs)), 4.0)\n",
201
+ "sig_peak = max(tab, key=lambda t: t[1]); snr_peak = max(tab, key=lambda t: t[3] if t[3] == t[3] else -1)\n",
202
+ "print('E[g] is largest at p =', sig_peak[0], '; SNR is largest at p =', snr_peak[0])\n",
203
+ "dead_at_half = 0.5 ** 8 * 2\n",
204
+ "print('dead prob at p=0.5, G=8:', dead_at_half, ' at p=0.9:', 0.9 ** 8 + 0.1 ** 8)"
205
+ ]
206
+ },
207
+ {
208
+ "cell_type": "markdown",
209
+ "id": "a0e51cdd",
210
+ "metadata": {},
211
+ "source": [
212
+ "## 4. Std-normalised advantages (GRPO): gradient `sqrt(p_hat (1 - p_hat))`\n",
213
+ "`A_i = (r_i - p_hat)/sigma_hat`, `sigma_hat = sqrt(p_hat(1-p_hat))` (population std). Then `g_hat = (1/G) sum_i A_i (r_i - p) = p_hat(1-p_hat)/sigma_hat = sigma_hat`\n",
214
+ "(for `sigma_hat > 0`; zero otherwise). Compare `E[sigma_hat]` with `sqrt(p(1-p))` and the *relative* weight of a prompt versus the unnormalised estimator."
215
+ ]
216
+ },
217
+ {
218
+ "cell_type": "code",
219
+ "execution_count": 5,
220
+ "id": "0a8dd37b",
221
+ "metadata": {
222
+ "execution": {
223
+ "iopub.execute_input": "2026-10-14T09:50:18.607353Z",
224
+ "iopub.status.busy": "2026-10-14T09:50:18.607193Z",
225
+ "iopub.status.idle": "2026-10-14T09:50:18.617653Z",
226
+ "shell.execute_reply": "2026-10-14T09:50:18.616857Z"
227
+ }
228
+ },
229
+ "outputs": [
230
+ {
231
+ "name": "stdout",
232
+ "output_type": "stream",
233
+ "text": [
234
+ "4. std-normalised group gradient = sigma_hat (direct sum vs closed form) err=1.110e-16 (tol 1e-15)\n",
235
+ "4. unbiased-std normalisation: group gradient = sigma_hat sqrt((G-1)/G) err=1.110e-16 (tol 1e-15)\n",
236
+ "p E[sigma_hat] sqrt(p(1-p)) weight ratio E[sigma_hat]/E[p_hat(1-p_hat)] (unnormalised = 1 by definition)\n",
237
+ "0.02 0.05043 0.14000 2.941\n",
238
+ "0.10 0.20949 0.30000 2.660\n",
239
+ "0.25 0.37990 0.43301 2.316\n",
240
+ "0.50 0.46391 0.50000 2.121\n",
241
+ "0.90 0.20949 0.30000 2.660\n",
242
+ "relative prompt weight p=0.02 vs p=0.5: unnormalised 0.0784, std-normalised 0.1087\n",
243
+ "informative group c=1 vs c=4 (G=8): unnormalised 0.438, std-normalised 0.661, ratio of the two = 1/sigma_hat ratio = 1.512\n",
244
+ "4. std-normalisation re-weights c=1 vs c=4 groups by exactly sigma(4)/sigma(1) err=2.220e-16 (tol 1e-14)\n"
245
+ ]
246
+ }
247
+ ],
248
+ "source": [
249
+ "worst = 0.0\n",
250
+ "for p in (0.1, 0.5):\n",
251
+ " for c in range(1, G):\n",
252
+ " r = torch.tensor([1.0] * c + [0.0] * (G - c)); ph = r.mean(); sg = torch.sqrt(ph * (1 - ph))\n",
253
+ " A = (r - ph) / sg\n",
254
+ " g_direct = float((A * (r - p)).mean())\n",
255
+ " worst = max(worst, abs(g_direct - float(sg)))\n",
256
+ "check('4. std-normalised group gradient = sigma_hat (direct sum vs closed form)', worst, 1e-15)\n",
257
+ "# Same with the *unbiased* sample std (torch.std, G-1 denominator) used by many implementations: g = sigma_hat * sqrt((G-1)/G)\n",
258
+ "worst_u = 0.0\n",
259
+ "for p in (0.1, 0.5):\n",
260
+ " for c in range(1, G):\n",
261
+ " r = torch.tensor([1.0] * c + [0.0] * (G - c)); ph = r.mean(); s_unb = r.std(unbiased=True)\n",
262
+ " g_direct = float((((r - ph) / s_unb) * (r - p)).mean())\n",
263
+ " worst_u = max(worst_u, abs(g_direct - float(torch.sqrt(ph * (1 - ph))) * math.sqrt((G - 1) / G)))\n",
264
+ "check('4. unbiased-std normalisation: group gradient = sigma_hat sqrt((G-1)/G)', worst_u, 1e-15)\n",
265
+ "print('p E[sigma_hat] sqrt(p(1-p)) weight ratio E[sigma_hat]/E[p_hat(1-p_hat)] (unnormalised = 1 by definition)')\n",
266
+ "for p in (0.02, 0.1, 0.25, 0.5, 0.9):\n",
267
+ " m_n, _ = moments(8, p, lambda ph: math.sqrt(ph * (1 - ph))); m_u, _ = moments(8, p, f_un)\n",
268
+ " print(f'{p:4.2f} {m_n:.5f} {math.sqrt(p*(1-p)):.5f} {m_n/m_u:8.3f}')\n",
269
+ "m_n05, _ = moments(8, 0.5, lambda ph: math.sqrt(ph * (1 - ph))); m_n02, _ = moments(8, 0.02, lambda ph: math.sqrt(ph * (1 - ph)))\n",
270
+ "m_u05, _ = moments(8, 0.5, f_un); m_u02, _ = moments(8, 0.02, f_un)\n",
271
+ "print('relative prompt weight p=0.02 vs p=0.5: unnormalised %.4f, std-normalised %.4f' % (m_u02 / m_u05, m_n02 / m_n05))\n",
272
+ "assert m_n02 / m_n05 > m_u02 / m_u05\n",
273
+ "# conditional on being informative, a group with c=1 of G=8 vs c=4 of 8: relative gradient magnitudes\n",
274
+ "sg = lambda c, G_=8: math.sqrt((c / G_) * (1 - c / G_)); un = lambda c, G_=8: (c / G_) * (1 - c / G_)\n",
275
+ "print('informative group c=1 vs c=4 (G=8): unnormalised %.3f, std-normalised %.3f, ratio of the two = 1/sigma_hat ratio = %.3f' % (un(1) / un(4), sg(1) / sg(4), (sg(1) / sg(4)) / (un(1) / un(4))))\n",
276
+ "check('4. std-normalisation re-weights c=1 vs c=4 groups by exactly sigma(4)/sigma(1)', abs((sg(1) / sg(4)) / (un(1) / un(4)) - sg(4) / sg(1)), 1e-14)"
277
+ ]
278
+ },
279
+ {
280
+ "cell_type": "markdown",
281
+ "id": "bd6aa13f",
282
+ "metadata": {},
283
+ "source": [
284
+ "## 5. General bound: `||grad p||^2 <= p(1-p) E||s||^2` (Cauchy-Schwarz), and `grad p = 0` at `p in {0,1}`\n",
285
+ "`grad p = E[(r - p) s]` (baseline identity), so `||grad p|| <= sqrt(E(r-p)^2) sqrt(E||s||^2) = sqrt(p(1-p)) sqrt(E||s||^2)`.\n",
286
+ "Verified on 300 prompts of a log-linear policy family (K=16 outputs, d=6 features) with exact `grad p` from autograd."
287
+ ]
288
+ },
289
+ {
290
+ "cell_type": "code",
291
+ "execution_count": 6,
292
+ "id": "fbf95af9",
293
+ "metadata": {
294
+ "execution": {
295
+ "iopub.execute_input": "2026-10-14T09:50:18.618988Z",
296
+ "iopub.status.busy": "2026-10-14T09:50:18.618837Z",
297
+ "iopub.status.idle": "2026-10-14T09:50:18.687537Z",
298
+ "shell.execute_reply": "2026-10-14T09:50:18.686732Z"
299
+ }
300
+ },
301
+ "outputs": [
302
+ {
303
+ "name": "stdout",
304
+ "output_type": "stream",
305
+ "text": [
306
+ "max ratio ||grad p||^2 / [p(1-p) E||s||^2] = 0.3109090719429734 (must be <= 1)\n",
307
+ "5. Cauchy-Schwarz bound ||grad p||^2 <= p(1-p) E||s||^2 (max violation of ratio <= 1) err=0.000e+00 (tol 0e+00)\n",
308
+ " p in [0,0.001): n= 28 mean ||grad p|| = 0.0000\n",
309
+ " p in [0.001,0.05): n= 24 mean ||grad p|| = 0.0791\n",
310
+ " p in [0.05,0.3): n=123 mean ||grad p|| = 0.2233\n",
311
+ " p in [0.3,0.7): n=112 mean ||grad p|| = 0.3436\n",
312
+ " p in [0.7,0.95): n= 13 mean ||grad p|| = 0.3216\n",
313
+ "p=0 prompt ||grad p|| = 0.0\n",
314
+ "p=1 prompt ||grad p|| = 0.0\n"
315
+ ]
316
+ },
317
+ {
318
+ "name": "stderr",
319
+ "output_type": "stream",
320
+ "text": [
321
+ "/home/vignesh/.opencode/tmp/ipykernel_607906/3044780012.py:10: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.\n",
322
+ "Consider using tensor.detach() first. (Triggered internally at /pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:838.)\n",
323
+ " Es2 = float((pi.detach() * (S_ ** 2).sum(1)).sum()); pf = float(p)\n"
324
+ ]
325
+ }
326
+ ],
327
+ "source": [
328
+ "rg2 = np.random.RandomState(3); m_, K_, d_ = 300, 16, 6\n",
329
+ "Ph = torch.tensor(rg2.randn(m_, K_, d_)); Vv = torch.tensor((rg2.rand(m_, K_) < rg2.rand(m_, 1) * 0.6).astype(float))\n",
330
+ "th = torch.tensor(rg2.randn(d_) * 0.5, requires_grad=True)\n",
331
+ "ratios = []; grad_norms = []; pvals = []\n",
332
+ "for x in range(m_):\n",
333
+ " th.grad = None\n",
334
+ " pi = torch.softmax(Ph[x] @ th, -1); p = (pi * Vv[x]).sum(); p.backward()\n",
335
+ " gp = th.grad.clone()\n",
336
+ " S_ = Ph[x] - (pi.detach()[:, None] * Ph[x]).sum(0, keepdim=True) # score of each output y wrt theta: phi(x,y) - E phi\n",
337
+ " Es2 = float((pi.detach() * (S_ ** 2).sum(1)).sum()); pf = float(p)\n",
338
+ " bound = pf * (1 - pf) * Es2\n",
339
+ " ratios.append(float(gp @ gp) / bound if bound > 0 else 0.0); grad_norms.append(float(gp.norm())); pvals.append(pf)\n",
340
+ "print('max ratio ||grad p||^2 / [p(1-p) E||s||^2] =', max(ratios), ' (must be <= 1)')\n",
341
+ "check('5. Cauchy-Schwarz bound ||grad p||^2 <= p(1-p) E||s||^2 (max violation of ratio <= 1)', max(0.0, max(ratios) - 1.0), 0.0)\n",
342
+ "pv = np.array(pvals); gn = np.array(grad_norms)\n",
343
+ "for lo, hi in [(0, 0.001), (0.001, 0.05), (0.05, 0.3), (0.3, 0.7), (0.7, 0.95), (0.95, 1.0001)]:\n",
344
+ " sel = (pv >= lo) & (pv < hi)\n",
345
+ " if sel.sum(): print(f' p in [{lo},{hi}): n={int(sel.sum()):3d} mean ||grad p|| = {gn[sel].mean():.4f}')\n",
346
+ "Vz = Vv.clone(); Vz[0, :] = 0.0; Vo = Vv.clone(); Vo[1, :] = 1.0\n",
347
+ "for name_, Vt in (('p=0 prompt', Vz[0]), ('p=1 prompt', Vo[1])):\n",
348
+ " th.grad = None; pi = torch.softmax(Ph[0] @ th, -1); (pi * Vt).sum().backward()\n",
349
+ " print(name_, ' ||grad p|| =', float(th.grad.norm()))\n",
350
+ " assert float(th.grad.norm()) < 1e-15"
351
+ ]
352
+ },
353
+ {
354
+ "cell_type": "markdown",
355
+ "id": "ca8a07b9",
356
+ "metadata": {},
357
+ "source": [
358
+ "## 6. Pass-rate band `(0, tau]` as deliberate re-weighting\n",
359
+ "Keep a group iff `0 < p_hat <= tau`. Its expected gradient contribution is `E[p_hat(1-p_hat) 1{0 < p_hat <= tau}]` versus `p(1-p)` for the true derivative.\n",
360
+ "The ratio `w_tau(p)` is the effective weight multiplier a prompt with true solve rate `p` receives."
361
+ ]
362
+ },
363
+ {
364
+ "cell_type": "code",
365
+ "execution_count": 7,
366
+ "id": "bb33c172",
367
+ "metadata": {
368
+ "execution": {
369
+ "iopub.execute_input": "2026-10-14T09:50:18.689048Z",
370
+ "iopub.status.busy": "2026-10-14T09:50:18.688891Z",
371
+ "iopub.status.idle": "2026-10-14T09:50:18.820659Z",
372
+ "shell.execute_reply": "2026-10-14T09:50:18.819967Z"
373
+ }
374
+ },
375
+ "outputs": [
376
+ {
377
+ "name": "stdout",
378
+ "output_type": "stream",
379
+ "text": [
380
+ "6. band weight w_tau(p): closed form vs simulation (p=.7,G=8,tau=.75) err=3.628e-04 (tol 3e-03)\n",
381
+ "effective weight multiplier w_tau(p) (1 - 1/G = no band):\n",
382
+ " p G=8,tau=0.75 G=16,tau=0.75 G=8,tau=1 (DAPO: 0<p_hat<1)\n",
383
+ " 0.02 0.8750 0.9375 0.8750\n",
384
+ " 0.10 0.8750 0.9375 0.8750\n",
385
+ " 0.30 0.8744 0.9375 0.8750\n",
386
+ " 0.50 0.8613 0.9314 0.8750\n",
387
+ " 0.70 0.7721 0.7867 0.8750\n",
388
+ " 0.80 0.6456 0.5175 0.8750\n",
389
+ " 0.90 0.4100 0.1485 0.8750\n",
390
+ " 0.97 0.1461 0.0072 0.8750\n",
391
+ "6. no band (tau=1) gives exactly 1-1/G for all p err=3.331e-16 (tol 1e-14)\n"
392
+ ]
393
+ }
394
+ ],
395
+ "source": [
396
+ "def w_band(G, p, tau):\n",
397
+ " pm = np.array([comb(G, c) * p ** c * (1 - p) ** (G - c) for c in range(G + 1)])\n",
398
+ " keep = np.array([1.0 if (0 < c / G <= tau) else 0.0 for c in range(G + 1)])\n",
399
+ " val = np.array([(c / G) * (1 - c / G) for c in range(G + 1)])\n",
400
+ " return (pm * keep * val).sum() / (p * (1 - p))\n",
401
+ "# direct simulation check\n",
402
+ "rs = np.random.RandomState(1); p = 0.7; G_ = 8; tau = 0.75\n",
403
+ "cs = rs.binomial(G_, p, size=3_000_000); ph = cs / G_\n",
404
+ "sim = (ph * (1 - ph) * ((ph > 0) & (ph <= tau))).mean() / (p * (1 - p))\n",
405
+ "check('6. band weight w_tau(p): closed form vs simulation (p=.7,G=8,tau=.75)', abs(sim - w_band(G_, p, tau)), 3e-3)\n",
406
+ "print('effective weight multiplier w_tau(p) (1 - 1/G = no band):')\n",
407
+ "print(' p G=8,tau=0.75 G=16,tau=0.75 G=8,tau=1 (DAPO: 0<p_hat<1)')\n",
408
+ "for p in (0.02, 0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 0.97):\n",
409
+ " print(f' {p:4.2f} {w_band(8,p,0.75):.4f} {w_band(16,p,0.75):.4f} {w_band(8,p,1.0):.4f}')\n",
410
+ "check('6. no band (tau=1) gives exactly 1-1/G for all p', max(abs(w_band(8, p, 1.0) - (1 - 1 / 8)) for p in (0.02, 0.3, 0.9)), 1e-14)"
411
+ ]
412
+ },
413
+ {
414
+ "cell_type": "markdown",
415
+ "id": "4e142cd1",
416
+ "metadata": {},
417
+ "source": [
418
+ "## 7. Informative fraction and the cost of dynamic sampling\n",
419
+ "Population of prompts with `p ~ Beta(a,b)`. `E[p^G] = B(a+G,b)/B(a,b)`, `E[(1-p)^G] = B(a,b+G)/B(a,b)`. Informative fraction `f = 1 - E[p^G] - E[(1-p)^G]`.\n",
420
+ "To fill a batch of `B` informative groups one must draw `B/f` prompts in expectation (and generate `G B / f` samples)."
421
+ ]
422
+ },
423
+ {
424
+ "cell_type": "code",
425
+ "execution_count": 8,
426
+ "id": "f49460fb",
427
+ "metadata": {
428
+ "execution": {
429
+ "iopub.execute_input": "2026-10-14T09:50:18.822182Z",
430
+ "iopub.status.busy": "2026-10-14T09:50:18.822029Z",
431
+ "iopub.status.idle": "2026-10-14T09:50:21.136390Z",
432
+ "shell.execute_reply": "2026-10-14T09:50:21.135678Z"
433
+ }
434
+ },
435
+ "outputs": [
436
+ {
437
+ "name": "stdout",
438
+ "output_type": "stream",
439
+ "text": [
440
+ "7. informative fraction: Beta-function closed form vs MC (max z) err=9.161e-01 (tol 4e+00)\n",
441
+ "population G informative fraction f oversampling factor 1/f\n",
442
+ "hard-heavy Beta(0.5,1) 8 0.6416 1.559\n",
443
+ "hard-heavy Beta(0.5,1) 16 0.7532 1.328\n",
444
+ "hard-heavy Beta(0.5,1) 32 0.8298 1.205\n",
445
+ "uniform Beta(1,1) 8 0.7778 1.286\n",
446
+ "uniform Beta(1,1) 16 0.8824 1.133\n",
447
+ "uniform Beta(1,1) 32 0.9394 1.065\n",
448
+ "easy-heavy Beta(2,0.5) 8 0.5651 1.770\n",
449
+ "easy-heavy Beta(2,0.5) 16 0.6819 1.467\n",
450
+ "easy-heavy Beta(2,0.5) 32 0.7705 1.298\n",
451
+ "late RL Beta(4,0.5) 8 0.4338 2.305\n",
452
+ "late RL Beta(4,0.5) 16 0.5637 1.774\n",
453
+ "late RL Beta(4,0.5) 32 0.6758 1.480\n"
454
+ ]
455
+ }
456
+ ],
457
+ "source": [
458
+ "def f_info(a, b, G):\n",
459
+ " return 1 - math.exp(betaln(a + G, b) - betaln(a, b)) - math.exp(betaln(a, b + G) - betaln(a, b))\n",
460
+ "rs = np.random.RandomState(9)\n",
461
+ "worst = 0\n",
462
+ "for (a, b, G_) in [(0.5, 1.0, 8), (2.0, 0.5, 16), (1.0, 1.0, 8)]:\n",
463
+ " p = rs.beta(a, b, size=4_000_000); fmc = 1 - (p ** G_ + (1 - p) ** G_).mean()\n",
464
+ " se = (p ** G_ + (1 - p) ** G_).std() / math.sqrt(len(p))\n",
465
+ " worst = max(worst, abs(fmc - f_info(a, b, G_)) / se)\n",
466
+ "check('7. informative fraction: Beta-function closed form vs MC (max z)', worst, 4.0)\n",
467
+ "print('population G informative fraction f oversampling factor 1/f')\n",
468
+ "for (nm, a, b) in [('hard-heavy Beta(0.5,1)', 0.5, 1.0), ('uniform Beta(1,1)', 1.0, 1.0), ('easy-heavy Beta(2,0.5)', 2.0, 0.5), ('late RL Beta(4,0.5)', 4.0, 0.5)]:\n",
469
+ " for G_ in (8, 16, 32):\n",
470
+ " f = f_info(a, b, G_); print(f'{nm:24s} {G_:3d} {f:8.4f} {1/f:7.3f}')"
471
+ ]
472
+ },
473
+ {
474
+ "cell_type": "markdown",
475
+ "id": "d7e59883",
476
+ "metadata": {},
477
+ "source": [
478
+ "## Verification table"
479
+ ]
480
+ },
481
+ {
482
+ "cell_type": "code",
483
+ "execution_count": 9,
484
+ "id": "0be2bb80",
485
+ "metadata": {
486
+ "execution": {
487
+ "iopub.execute_input": "2026-10-14T09:50:21.137898Z",
488
+ "iopub.status.busy": "2026-10-14T09:50:21.137739Z",
489
+ "iopub.status.idle": "2026-10-14T09:50:21.141267Z",
490
+ "shell.execute_reply": "2026-10-14T09:50:21.140434Z"
491
+ }
492
+ },
493
+ "outputs": [
494
+ {
495
+ "name": "stdout",
496
+ "output_type": "stream",
497
+ "text": [
498
+ "\n",
499
+ "identity max abs err\n",
500
+ "1. autograd surrogate gradient = p_hat (1 - p_hat), all c in 0..8, four p 2.776e-17\n",
501
+ "2. E[p_hat(1-p_hat)] = (1-1/G) p(1-p), exact rationals 0.000e+00\n",
502
+ "2. max of p(1-p) is 1/4 at p = 1/2 0.000e+00\n",
503
+ "3. MC mean of p_hat(1-p_hat) vs exact (in s.e.) 7.820e-01\n",
504
+ "3. MC dead-group fraction vs p^G + (1-p)^G (in s.e.) 3.515e-01\n",
505
+ "4. std-normalised group gradient = sigma_hat (direct sum vs closed form) 1.110e-16\n",
506
+ "4. unbiased-std normalisation: group gradient = sigma_hat sqrt((G-1)/G) 1.110e-16\n",
507
+ "4. std-normalisation re-weights c=1 vs c=4 groups by exactly sigma(4)/sigma(1) 2.220e-16\n",
508
+ "5. Cauchy-Schwarz bound ||grad p||^2 <= p(1-p) E||s||^2 (max violation of ratio <= 1) 0.000e+00\n",
509
+ "6. band weight w_tau(p): closed form vs simulation (p=.7,G=8,tau=.75) 3.628e-04\n",
510
+ "6. no band (tau=1) gives exactly 1-1/G for all p 3.331e-16\n",
511
+ "7. informative fraction: Beta-function closed form vs MC (max z) 9.161e-01\n",
512
+ "CHECKS_JSON:[[\"1. autograd surrogate gradient = p_hat (1 - p_hat), all c in 0..8, four p\", 2.7755575615628914e-17, 1e-15], [\"2. E[p_hat(1-p_hat)] = (1-1/G) p(1-p), exact rationals\", 0.0, 0.0], [\"2. max of p(1-p) is 1/4 at p = 1/2\", 0.0, 1e-12], [\"3. MC mean of p_hat(1-p_hat) vs exact (in s.e.)\", 0.7820444294887922, 4.0], [\"3. MC dead-group fraction vs p^G + (1-p)^G (in s.e.)\", 0.3515169230631549, 4.0], [\"4. std-normalised group gradient = sigma_hat (direct sum vs closed form)\", 1.1102230246251565e-16, 1e-15], [\"4. unbiased-std normalisation: group gradient = sigma_hat sqrt((G-1)/G)\", 1.1102230246251565e-16, 1e-15], [\"4. std-normalisation re-weights c=1 vs c=4 groups by exactly sigma(4)/sigma(1)\", 2.220446049250313e-16, 1e-14], [\"5. Cauchy-Schwarz bound ||grad p||^2 <= p(1-p) E||s||^2 (max violation of ratio <= 1)\", 0.0, 0.0], [\"6. band weight w_tau(p): closed form vs simulation (p=.7,G=8,tau=.75)\", 0.0003628045634921362, 0.003], [\"6. no band (tau=1) gives exactly 1-1/G for all p\", 3.3306690738754696e-16, 1e-14], [\"7. informative fraction: Beta-function closed form vs MC (max z)\", 0.9161417930924225, 4.0]]\n"
513
+ ]
514
+ }
515
+ ],
516
+ "source": [
517
+ "print()\n",
518
+ "print(f'{\"identity\":92s} {\"max abs err\":>12s}')\n",
519
+ "for n_, e_, t_ in CHECKS: print(f'{n_:92s} {e_:12.3e}')\n",
520
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
521
+ ]
522
+ }
523
+ ],
524
+ "metadata": {
525
+ "kernelspec": {
526
+ "display_name": "Python 3",
527
+ "language": "python",
528
+ "name": "python3"
529
+ },
530
+ "language_info": {
531
+ "codemirror_mode": {
532
+ "name": "ipython",
533
+ "version": 3
534
+ },
535
+ "file_extension": ".py",
536
+ "mimetype": "text/x-python",
537
+ "name": "python",
538
+ "nbconvert_exporter": "python",
539
+ "pygments_lexer": "ipython3",
540
+ "version": "3.12.11"
541
+ }
542
+ },
543
+ "nbformat": 4,
544
+ "nbformat_minor": 5
545
+ }
code/notebooks/0207-test-time-scaling-cost-model.ipynb ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "b8fba5aa",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0207 -- Test-time scaling: pass@k vs compute, self-correction rounds vs more samples, best-of-n with a perfect verifier\n",
9
+ "\n",
10
+ "Identities verified (exact rationals / fp64):\n",
11
+ "1. Best-of-n with a perfect verifier: `coverage(n) = 1 - E[(1-p)^n]`; with `p ~ Beta(a,b)`: `1 - B(a,b+n)/B(a,b)`, and `1 - coverage ~ Gamma(a+b)/Gamma(b) n^-a`.\n",
12
+ "2. Early stopping: `E[min(N,n)] = (1-(1-p)^n)/p`; Beta-average `sum_{j<n} B(a,b+j)/B(a,b)`.\n",
13
+ "3. Posterior hazard `P(success on attempt j+1 | j failures) = a/(a+b+j)`; optimal stopping by dynamic programming = threshold rule `j* = a U/c - (a+b)`.\n",
14
+ "4. Equal-cost comparison: adaptive stopping vs fixed n.\n",
15
+ "5. Fresh samples vs revisions: revise iff `ln(1-q)/ln(1-p_1) >= rho` (brute-force enumeration over `(n,R)`).\n",
16
+ "6. Imperfect verifier: precision cap `p/(p + (1-p) phi)` of the first accepted sample, independent of n.\n",
17
+ "7. The exponentiated power law `c = exp(a k^b)` (Large Language Monkeys) vs the Beta-mixture coverage curve."
18
+ ]
19
+ },
20
+ {
21
+ "cell_type": "code",
22
+ "execution_count": 1,
23
+ "id": "d1a9b6b8",
24
+ "metadata": {
25
+ "execution": {
26
+ "iopub.execute_input": "2026-10-14T09:51:01.379846Z",
27
+ "iopub.status.busy": "2026-10-14T09:51:01.379697Z",
28
+ "iopub.status.idle": "2026-10-14T09:51:01.728451Z",
29
+ "shell.execute_reply": "2026-10-14T09:51:01.727516Z"
30
+ }
31
+ },
32
+ "outputs": [],
33
+ "source": [
34
+ "import os\n",
35
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
36
+ "import math, json, itertools\n",
37
+ "from fractions import Fraction as Fr\n",
38
+ "from math import comb, lgamma\n",
39
+ "import numpy as np\n",
40
+ "from scipy.special import betaln, gammaln\n",
41
+ "from scipy.optimize import least_squares\n",
42
+ "CHECKS = []\n",
43
+ "def check(name, err, tol):\n",
44
+ " CHECKS.append((name, float(err), float(tol)))\n",
45
+ " assert err <= tol, (name, float(err), tol)\n",
46
+ " print(f'{name:92s} err={float(err):.3e} (tol {tol:.0e})')"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "markdown",
51
+ "id": "f09fd568",
52
+ "metadata": {},
53
+ "source": [
54
+ "## 1. Coverage with a perfect verifier\n",
55
+ "NAIVE: Monte Carlo over problems (draw `p`, draw `n` attempts, any success?). EFFICIENT: Beta-function closed form."
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "code",
60
+ "execution_count": 2,
61
+ "id": "57c4d2e9",
62
+ "metadata": {
63
+ "execution": {
64
+ "iopub.execute_input": "2026-10-14T09:51:01.730089Z",
65
+ "iopub.status.busy": "2026-10-14T09:51:01.729862Z",
66
+ "iopub.status.idle": "2026-10-14T09:51:03.100278Z",
67
+ "shell.execute_reply": "2026-10-14T09:51:03.099451Z"
68
+ }
69
+ },
70
+ "outputs": [
71
+ {
72
+ "name": "stdout",
73
+ "output_type": "stream",
74
+ "text": [
75
+ "1. coverage: Monte Carlo vs 1 - B(a,b+n)/B(a,b) (max z, n in {1..256}) err=1.274e+00 (tol 4e+00)\n",
76
+ "(1-coverage) / [Gamma(a+b)/Gamma(b) n^-a] at n = [10, 100, 1000, 10000, 100000] -> [0.939, 0.9933, 0.9993, 0.9999, 1.0]\n",
77
+ "1. tail law: ratio -> 1 as n -> 1e5 (|ratio-1|) err=6.800e-06 (tol 5e-03)\n"
78
+ ]
79
+ }
80
+ ],
81
+ "source": [
82
+ "def coverage_beta(a, b, n): return 1 - np.exp(betaln(a, b + n) - betaln(a, b))\n",
83
+ "rs = np.random.RandomState(0)\n",
84
+ "a, b = 0.4, 2.0\n",
85
+ "worst = 0.0\n",
86
+ "for n in (1, 4, 16, 64, 256):\n",
87
+ " p = rs.beta(a, b, size=2_000_000); hit = rs.rand(len(p)) < 1 - (1 - p) ** n\n",
88
+ " se = math.sqrt(hit.mean() * (1 - hit.mean()) / len(p))\n",
89
+ " worst = max(worst, abs(hit.mean() - coverage_beta(a, b, n)) / se)\n",
90
+ "check('1. coverage: Monte Carlo vs 1 - B(a,b+n)/B(a,b) (max z, n in {1..256})', worst, 4.0)\n",
91
+ "ns = np.array([10, 100, 1000, 10_000, 100_000])\n",
92
+ "ratio = (1 - coverage_beta(a, b, ns)) / (np.exp(gammaln(a + b) - gammaln(b)) * ns ** (-a))\n",
93
+ "print('(1-coverage) / [Gamma(a+b)/Gamma(b) n^-a] at n =', ns.tolist(), '->', np.round(ratio, 4).tolist())\n",
94
+ "check('1. tail law: ratio -> 1 as n -> 1e5 (|ratio-1|)', abs(ratio[-1] - 1), 5e-3)"
95
+ ]
96
+ },
97
+ {
98
+ "cell_type": "markdown",
99
+ "id": "b908b5d2",
100
+ "metadata": {},
101
+ "source": [
102
+ "## 2. Early stopping with a perfect verifier\n",
103
+ "Sample sequentially, stop at the first verified success or after `n` attempts. Cost `N_n = min(Geom(p), n)`, `E[N_n] = sum_{j=0}^{n-1} (1-p)^j = (1-(1-p)^n)/p`."
104
+ ]
105
+ },
106
+ {
107
+ "cell_type": "code",
108
+ "execution_count": 3,
109
+ "id": "f4f195cb",
110
+ "metadata": {
111
+ "execution": {
112
+ "iopub.execute_input": "2026-10-14T09:51:03.101782Z",
113
+ "iopub.status.busy": "2026-10-14T09:51:03.101629Z",
114
+ "iopub.status.idle": "2026-10-14T09:51:03.424265Z",
115
+ "shell.execute_reply": "2026-10-14T09:51:03.423468Z"
116
+ }
117
+ },
118
+ "outputs": [
119
+ {
120
+ "name": "stdout",
121
+ "output_type": "stream",
122
+ "text": [
123
+ "2. E[min(Geom(p),n)] = (1-(1-p)^n)/p = sum_{j<n}(1-p)^j (exact rationals) err=0.000e+00 (tol 0e+00)\n"
124
+ ]
125
+ },
126
+ {
127
+ "name": "stdout",
128
+ "output_type": "stream",
129
+ "text": [
130
+ "2. Beta-average of the early-stopping cost: MC vs sum_j B(a,b+j)/B(a,b) (z) err=4.881e-01 (tol 4e+00)\n",
131
+ "Beta(0.4,2.0), n=64: coverage=0.7671; expected attempts per problem with early stopping = 23.05 (vs 64 without)\n"
132
+ ]
133
+ }
134
+ ],
135
+ "source": [
136
+ "def mean_cost_exact(p, n):\n",
137
+ " p = Fr(p); return sum((1 - p) ** j for j in range(n))\n",
138
+ "worst = 0.0\n",
139
+ "for p in (Fr(1, 100), Fr(1, 7), Fr(1, 2)):\n",
140
+ " for n in (1, 5, 40):\n",
141
+ " # NAIVE: enumerate the distribution of min(Geom, n) explicitly\n",
142
+ " e = sum(Fr(k) * (1 - p) ** (k - 1) * p for k in range(1, n)) + Fr(n) * (1 - p) ** (n - 1)\n",
143
+ " worst = max(worst, float(abs(e - (1 - (1 - p) ** n) / p)), float(abs(e - mean_cost_exact(p, n))))\n",
144
+ "check('2. E[min(Geom(p),n)] = (1-(1-p)^n)/p = sum_{j<n}(1-p)^j (exact rationals)', worst, 0.0)\n",
145
+ "def cost_beta(a, b, n): return sum(math.exp(betaln(a, b + j) - betaln(a, b)) for j in range(n))\n",
146
+ "rs = np.random.RandomState(1); a, b = 0.4, 2.0; n = 64\n",
147
+ "p = rs.beta(a, b, size=2_000_000); N_ = np.minimum(rs.geometric(np.clip(p, 1e-300, 1)), n)\n",
148
+ "check('2. Beta-average of the early-stopping cost: MC vs sum_j B(a,b+j)/B(a,b) (z)', abs(N_.mean() - cost_beta(a, b, n)) / (N_.std() / math.sqrt(len(N_))), 4.0)\n",
149
+ "print(f'Beta({a},{b}), n={n}: coverage={coverage_beta(a,b,n):.4f}; expected attempts per problem with early stopping = {cost_beta(a,b,n):.2f} (vs {n} without)')"
150
+ ]
151
+ },
152
+ {
153
+ "cell_type": "markdown",
154
+ "id": "489971ea",
155
+ "metadata": {},
156
+ "source": [
157
+ "## 3. Posterior hazard and the optimal stopping rule\n",
158
+ "For `p ~ Beta(a,b)` the posterior after `j` failures is `Beta(a, b+j)`, so `h_j = P(success | j failures) = a/(a+b+j)`.\n",
159
+ "Each attempt costs `c`; a solved problem is worth `U`. DP: `W_j = max(0, h_j (U - c) - (1-h_j) c + (1-h_j) W_{j+1})` with `W_{n_max}=0`.\n",
160
+ "Because `h_j` is decreasing the one-step-look-ahead rule is optimal: continue iff `h_j U >= c`, i.e. `j < a U/c - (a+b)`."
161
+ ]
162
+ },
163
+ {
164
+ "cell_type": "code",
165
+ "execution_count": 4,
166
+ "id": "087dfa00",
167
+ "metadata": {
168
+ "execution": {
169
+ "iopub.execute_input": "2026-10-14T09:51:03.425743Z",
170
+ "iopub.status.busy": "2026-10-14T09:51:03.425591Z",
171
+ "iopub.status.idle": "2026-10-14T09:51:05.083274Z",
172
+ "shell.execute_reply": "2026-10-14T09:51:05.082473Z"
173
+ }
174
+ },
175
+ "outputs": [
176
+ {
177
+ "name": "stdout",
178
+ "output_type": "stream",
179
+ "text": [
180
+ "3. posterior success probability after j failures = a/(a+b+j) (importance-sampling MC, max z) err=1.647e+00 (tol 4e+00)\n",
181
+ "3. same, exact Beta-function ratio B(a+1,b+j)/B(a,b+j) err=8.491e-16 (tol 1e-14)\n",
182
+ "U=10.0, c=0.05: DP stops after 78 attempts; threshold a U/c - (a+b) = 77.60 -> ceil 78; value W_0=6.5345\n",
183
+ "U=10.0, c=0.01: DP stops after 398 attempts; threshold a U/c - (a+b) = 397.60 -> ceil 398; value W_0=8.1394\n",
184
+ "U=50.0, c=0.05: DP stops after 398 attempts; threshold a U/c - (a+b) = 397.60 -> ceil 398; value W_0=40.6968\n"
185
+ ]
186
+ },
187
+ {
188
+ "name": "stdout",
189
+ "output_type": "stream",
190
+ "text": [
191
+ "3. DP stopping index vs threshold rule ceil(aU/c-(a+b)) (max |diff| over 3 settings) err=0.000e+00 (tol 1e+00)\n"
192
+ ]
193
+ }
194
+ ],
195
+ "source": [
196
+ "a, b = 0.4, 2.0\n",
197
+ "# hazard check by self-normalised importance sampling from the prior (weights (1-p)^j = likelihood of j failures)\n",
198
+ "rs = np.random.RandomState(7); pp = rs.beta(a, b, size=8_000_000); worst_z = 0.0\n",
199
+ "for j in (0, 1, 5, 20):\n",
200
+ " w = (1 - pp) ** j; est = (pp * w).sum() / w.sum()\n",
201
+ " # delta-method s.e. of a ratio estimator\n",
202
+ " se = math.sqrt((((pp - est) * w) ** 2).sum()) / w.sum()\n",
203
+ " worst_z = max(worst_z, abs(est - a / (a + b + j)) / se)\n",
204
+ "check('3. posterior success probability after j failures = a/(a+b+j) (importance-sampling MC, max z)', worst_z, 4.5)\n",
205
+ "# exact version via Beta functions (no quadrature):\n",
206
+ "worst = max(abs(math.exp(betaln(a + 1, b + j) - betaln(a, b + j)) - a / (a + b + j)) for j in range(0, 200))\n",
207
+ "check('3. same, exact Beta-function ratio B(a+1,b+j)/B(a,b+j)', worst, 1e-14)\n",
208
+ "def dp_value(U, c, a, b, nmax=100000):\n",
209
+ " W = 0.0; cont = []\n",
210
+ " for j in range(nmax - 1, -1, -1):\n",
211
+ " h = a / (a + b + j)\n",
212
+ " val = h * (U - c) - (1 - h) * c + (1 - h) * W\n",
213
+ " W = max(0.0, val); cont.append(val > 0)\n",
214
+ " cont = cont[::-1]\n",
215
+ " jstop = next((j for j, v in enumerate(cont) if not v), nmax)\n",
216
+ " return W, jstop\n",
217
+ "for (U, c) in [(10.0, 0.05), (10.0, 0.01), (50.0, 0.05)]:\n",
218
+ " W, jstop = dp_value(U, c, a, b)\n",
219
+ " j_theory = math.ceil(a * U / c - (a + b))\n",
220
+ " print(f'U={U}, c={c}: DP stops after {jstop} attempts; threshold a U/c - (a+b) = {a*U/c-(a+b):.2f} -> ceil {j_theory}; value W_0={W:.4f}')\n",
221
+ " assert abs(jstop - j_theory) <= 1\n",
222
+ "check('3. DP stopping index vs threshold rule ceil(aU/c-(a+b)) (max |diff| over 3 settings)', max(abs(dp_value(U, c, a, b)[1] - math.ceil(a * U / c - (a + b))) for U, c in [(10.0, 0.05), (10.0, 0.01), (50.0, 0.05)]), 1)"
223
+ ]
224
+ },
225
+ {
226
+ "cell_type": "markdown",
227
+ "id": "6e820e32",
228
+ "metadata": {},
229
+ "source": [
230
+ "## 4. Equal expected cost: adaptive stopping vs a fixed budget\n",
231
+ "Fixed `n = 64` attempts for every problem costs 64 each. With early stopping the expected cost is `cost(n_max)`; choose `n_max` so that it equals 64 and compare coverage."
232
+ ]
233
+ },
234
+ {
235
+ "cell_type": "code",
236
+ "execution_count": 5,
237
+ "id": "bddd003b",
238
+ "metadata": {
239
+ "execution": {
240
+ "iopub.execute_input": "2026-10-14T09:51:05.085063Z",
241
+ "iopub.status.busy": "2026-10-14T09:51:05.084733Z",
242
+ "iopub.status.idle": "2026-10-14T09:51:19.832181Z",
243
+ "shell.execute_reply": "2026-10-14T09:51:19.831457Z"
244
+ }
245
+ },
246
+ "outputs": [
247
+ {
248
+ "name": "stdout",
249
+ "output_type": "stream",
250
+ "text": [
251
+ "Beta(0.4,2.0): fixed n=64 -> coverage 0.7671; adaptive with the same mean cost 64 needs n_max = 323 -> coverage 0.8771\n",
252
+ "4. n_max is the smallest n with expected cost >= 64 (violations; 0 = cost(n_max-1) < 64 <= cost(n_max)) err=0.000e+00 (tol 0e+00)\n",
253
+ "cost at n_max-1, n_max: 63.997038900831 64.1201008159222\n"
254
+ ]
255
+ }
256
+ ],
257
+ "source": [
258
+ "target = 64.0\n",
259
+ "# cumulative expected cost cost(n) = sum_{j<n} B(a,b+j)/B(a,b) via a vectorised cumulative sum\n",
260
+ "j = np.arange(0, 20_000_000); terms = np.exp(betaln(a, b + j) - betaln(a, b)); cs = np.cumsum(terms)\n",
261
+ "n_adapt = int(np.searchsorted(cs, target)) + 1\n",
262
+ "print(f'Beta({a},{b}): fixed n=64 -> coverage {coverage_beta(a,b,64):.4f}; adaptive with the same mean cost 64 needs n_max = {n_adapt} -> coverage {coverage_beta(a,b,n_adapt):.4f}')\n",
263
+ "check('4. n_max is the smallest n with expected cost >= 64 (violations; 0 = cost(n_max-1) < 64 <= cost(n_max))', float(not (cs[n_adapt - 2] < target <= cs[n_adapt - 1])), 0.0)\n",
264
+ "print('cost at n_max-1, n_max:', cs[n_adapt - 2], cs[n_adapt - 1])\n",
265
+ "assert coverage_beta(a, b, n_adapt) > coverage_beta(a, b, 64)\n",
266
+ "CHECKS.append(('4. adaptive-minus-fixed coverage at equal mean cost (informational)', float(coverage_beta(a, b, n_adapt) - coverage_beta(a, b, 64)), float('inf')))"
267
+ ]
268
+ },
269
+ {
270
+ "cell_type": "markdown",
271
+ "id": "867deb08",
272
+ "metadata": {},
273
+ "source": [
274
+ "## 5. Fresh samples vs revisions at equal token cost\n",
275
+ "A fresh sample costs 1 unit and succeeds w.p. `p1`. A chain is one attempt followed by up to `R` revisions, each costing `rho` units and succeeding w.p. `q` given all earlier failures\n",
276
+ "(independent of the earlier attempts: the simplest model). Chain failure `(1-p1)(1-q)^R`, cost `1 + rho R`. With budget `B` we run `n` independent chains.\n",
277
+ "Failure `= [(1-p1)(1-q)^R]^n`. Log-failure per cost: `f(R) = (a1 + R a2)/(1+rho R)`, `a1 = -ln(1-p1)`, `a2 = -ln(1-q)`. `f' ∝ a2 - rho a1`: revise iff `a2 > rho a1`."
278
+ ]
279
+ },
280
+ {
281
+ "cell_type": "code",
282
+ "execution_count": 6,
283
+ "id": "d391bbf9",
284
+ "metadata": {
285
+ "execution": {
286
+ "iopub.execute_input": "2026-10-14T09:51:19.833654Z",
287
+ "iopub.status.busy": "2026-10-14T09:51:19.833497Z",
288
+ "iopub.status.idle": "2026-10-14T09:51:19.840774Z",
289
+ "shell.execute_reply": "2026-10-14T09:51:19.840044Z"
290
+ }
291
+ },
292
+ "outputs": [
293
+ {
294
+ "name": "stdout",
295
+ "output_type": "stream",
296
+ "text": [
297
+ "(p1, q, rho, revise predicted, best n, best R, failure prob. of best mix at budget 600, failure prob. with fresh samples only)\n",
298
+ " (0.05, 0.03, 1.5, False, 600, 0, 4.31e-14, 4.31e-14)\n",
299
+ " (0.05, 0.06, 1.0, True, 50, 11, 1.28e-16, 4.31e-14)\n",
300
+ " (0.05, 0.06, 1.5, False, 600, 0, 4.31e-14, 4.31e-14)\n",
301
+ " (0.1, 0.12, 1.0, True, 50, 11, 1.51e-33, 3.51e-28)\n",
302
+ "sign rule vs brute-force optimum: agree on 36/36 grid points\n",
303
+ "disagreements (p1, q, rho, relative gap |a2 - rho a1| / (rho a1), best (n,R)): []\n",
304
+ "5. revise-iff-(a2 > rho a1) rule vs brute-force optimum: disagreements with relative gap > 10% err=0.000e+00 (tol 0e+00)\n"
305
+ ]
306
+ }
307
+ ],
308
+ "source": [
309
+ "def best_mix(p1, q, rho, B, Rmax=12):\n",
310
+ " \"\"\"Minimise the log failure probability n*[ln(1-p1) + R ln(1-q)] over integer (n, R) with n(1+rho R) <= B (avoids float saturation at success ~ 1).\"\"\"\n",
311
+ " best = (float('inf'), None)\n",
312
+ " for R in range(0, Rmax + 1):\n",
313
+ " n = int(B // (1 + rho * R))\n",
314
+ " if n < 1: continue\n",
315
+ " logfail = n * (math.log(1 - p1) + R * math.log(1 - q))\n",
316
+ " if logfail < best[0] - 1e-12: best = (logfail, (n, R))\n",
317
+ " return math.exp(best[0]), best[1] # failure probability of the best mix\n",
318
+ "rows = []; borderline = []\n",
319
+ "agree = 0; total = 0\n",
320
+ "for p1 in (0.02, 0.05, 0.1):\n",
321
+ " for q in (0.01, 0.03, 0.06, 0.12):\n",
322
+ " for rho in (1.0, 1.5, 2.5):\n",
323
+ " B = 600.0\n",
324
+ " succ, (n_, R_) = best_mix(p1, q, rho, B) # `succ` is the failure probability here\n",
325
+ " predicted_revise = (-math.log(1 - q)) > rho * (-math.log(1 - p1))\n",
326
+ " gap = abs((-math.log(1 - q)) - rho * (-math.log(1 - p1))) / (rho * (-math.log(1 - p1)))\n",
327
+ " total += 1\n",
328
+ " if (R_ > 0) == predicted_revise: agree += 1\n",
329
+ " else:\n",
330
+ " borderline.append((p1, q, rho, round(gap, 3), (n_, R_)))\n",
331
+ " if (p1, q, rho) in [(0.05, 0.06, 1.0), (0.05, 0.06, 1.5), (0.05, 0.03, 1.5), (0.1, 0.12, 1.0)]:\n",
332
+ " rows.append((p1, q, rho, predicted_revise, n_, R_, float('%.3g' % succ), float('%.3g' % ((1 - p1) ** int(B)))))\n",
333
+ "print('(p1, q, rho, revise predicted, best n, best R, failure prob. of best mix at budget 600, failure prob. with fresh samples only)')\n",
334
+ "for r in rows: print(' ', r)\n",
335
+ "print(f'sign rule vs brute-force optimum: agree on {agree}/{total} grid points')\n",
336
+ "print('disagreements (p1, q, rho, relative gap |a2 - rho a1| / (rho a1), best (n,R)):', borderline)\n",
337
+ "far = [b_ for b_ in borderline if b_[3] > 0.1]\n",
338
+ "check('5. revise-iff-(a2 > rho a1) rule vs brute-force optimum: disagreements with relative gap > 10%', len(far), 0)"
339
+ ]
340
+ },
341
+ {
342
+ "cell_type": "markdown",
343
+ "id": "b061849f",
344
+ "metadata": {},
345
+ "source": [
346
+ "## 6. Imperfect verifier: the first *accepted* sample is correct only with probability `p/(p+(1-p) phi)`\n",
347
+ "Verifier accepts correct proofs always and incorrect ones w.p. `phi` (false accept). Sampling until the first accept, the accepted sample is correct w.p. `p/(p + (1-p) phi)`,\n",
348
+ "for any budget `n` (precision cap), while a perfect verifier gives 1 with the same stopping rule."
349
+ ]
350
+ },
351
+ {
352
+ "cell_type": "code",
353
+ "execution_count": 7,
354
+ "id": "d216a73d",
355
+ "metadata": {
356
+ "execution": {
357
+ "iopub.execute_input": "2026-10-14T09:51:19.842276Z",
358
+ "iopub.status.busy": "2026-10-14T09:51:19.842130Z",
359
+ "iopub.status.idle": "2026-10-14T09:51:24.279548Z",
360
+ "shell.execute_reply": "2026-10-14T09:51:24.278840Z"
361
+ }
362
+ },
363
+ "outputs": [
364
+ {
365
+ "name": "stdout",
366
+ "output_type": "stream",
367
+ "text": [
368
+ "p=0.05, false accept=0.01: precision of an accepted sample = 0.8414 (theory 0.8403)\n",
369
+ "p=0.2, false accept=0.05: precision of an accepted sample = 0.8335 (theory 0.8333)\n",
370
+ "p=0.01, false accept=0.002: precision of an accepted sample = 0.8315 (theory 0.8347)\n",
371
+ "6. precision of the first accepted sample: MC vs p/(p+(1-p)phi) (max z) err=1.883e+00 (tol 4e+00)\n"
372
+ ]
373
+ }
374
+ ],
375
+ "source": [
376
+ "rs = np.random.RandomState(4)\n",
377
+ "worst = 0\n",
378
+ "for (p, phi) in [(0.05, 0.01), (0.2, 0.05), (0.01, 0.002)]:\n",
379
+ " N = 4_000_000\n",
380
+ " corr = rs.rand(N) < p; acc = corr | (rs.rand(N) < phi)\n",
381
+ " precision = corr[acc].mean(); se = math.sqrt(precision * (1 - precision) / acc.sum())\n",
382
+ " worst = max(worst, abs(precision - p / (p + (1 - p) * phi)) / se)\n",
383
+ " print(f'p={p}, false accept={phi}: precision of an accepted sample = {precision:.4f} (theory {p/(p+(1-p)*phi):.4f})')\n",
384
+ "check('6. precision of the first accepted sample: MC vs p/(p+(1-p)phi) (max z)', worst, 4.0)"
385
+ ]
386
+ },
387
+ {
388
+ "cell_type": "markdown",
389
+ "id": "7215e58c",
390
+ "metadata": {},
391
+ "source": [
392
+ "## 7. The empirical exponentiated power law vs the Beta-mixture curve\n",
393
+ "Large Language Monkeys fit coverage `c(k) = exp(a k^b)` (Fig. 5). For `p ~ Beta(alpha, beta)` the exact curve is `1 - B(alpha, beta+k)/B(alpha,beta)`. Fit `log c` by least squares on log-spaced `k` in `[1, 10^4]`."
394
+ ]
395
+ },
396
+ {
397
+ "cell_type": "code",
398
+ "execution_count": 8,
399
+ "id": "32ba1c14",
400
+ "metadata": {
401
+ "execution": {
402
+ "iopub.execute_input": "2026-10-14T09:51:24.280954Z",
403
+ "iopub.status.busy": "2026-10-14T09:51:24.280801Z",
404
+ "iopub.status.idle": "2026-10-14T09:51:24.302130Z",
405
+ "shell.execute_reply": "2026-10-14T09:51:24.301470Z"
406
+ }
407
+ },
408
+ "outputs": [
409
+ {
410
+ "name": "stdout",
411
+ "output_type": "stream",
412
+ "text": [
413
+ "Beta(0.3,1.5): fitted c=exp(a k^b): a=-1.648, b=-0.361, mean abs error=0.0061, max=0.0259\n",
414
+ "Beta(0.5,2.0): fitted c=exp(a k^b): a=-1.673, b=-0.535, mean abs error=0.0014, max=0.0123\n",
415
+ "Beta(1.0,3.0): fitted c=exp(a k^b): a=-1.625, b=-0.809, mean abs error=0.0062, max=0.0530\n"
416
+ ]
417
+ }
418
+ ],
419
+ "source": [
420
+ "ks = np.unique(np.logspace(0, 4, 100).astype(int))\n",
421
+ "for (al, be) in [(0.3, 1.5), (0.5, 2.0), (1.0, 3.0)]:\n",
422
+ " cov = coverage_beta(al, be, ks)\n",
423
+ " fit = least_squares(lambda th: np.exp(th[0] * ks ** th[1]) - cov, x0=[-1.0, -0.3], bounds=([-20, -3], [-1e-6, -1e-6]))\n",
424
+ " print(f'Beta({al},{be}): fitted c=exp(a k^b): a={fit.x[0]:.3f}, b={fit.x[1]:.3f}, mean abs error={np.abs(fit.fun).mean():.4f}, max={np.abs(fit.fun).max():.4f}')\n",
425
+ "CHECKS.append(('7. exp-power-law fit error on a Beta-mixture curve (max abs over 3 fits, informational)', float(max(np.abs(least_squares(lambda th, cv=coverage_beta(al, be, ks): np.exp(th[0] * ks ** th[1]) - cv, x0=[-1.0, -0.3], bounds=([-20, -3], [-1e-6, -1e-6])).fun).max() for al, be in [(0.3, 1.5), (0.5, 2.0), (1.0, 3.0)])), float('inf')))"
426
+ ]
427
+ },
428
+ {
429
+ "cell_type": "markdown",
430
+ "id": "f9f47176",
431
+ "metadata": {},
432
+ "source": [
433
+ "## 8. Token accounting for a published comparison (arithmetic only)\n",
434
+ "Goedel-Prover-V2 Sec. 3.2: first round capped at 30,000 tokens, self-correction mode (initial + 2 revisions) at 40,000 tokens in total.\n",
435
+ "Sec. 3.5: with the context extended to 128k (YaRN) and up to 5 revisions, pass@32 with correction reaches 92.7% > 92.2% (32B, pass@8192, no correction).\n",
436
+ "Table 4: the 40k-token self-correction mode reaches only 90.4% at pass@32. Ratio of caps (neither an upper nor a lower bound on tokens actually used):"
437
+ ]
438
+ },
439
+ {
440
+ "cell_type": "code",
441
+ "execution_count": 9,
442
+ "id": "cbf4ed2e",
443
+ "metadata": {
444
+ "execution": {
445
+ "iopub.execute_input": "2026-10-14T09:51:24.303439Z",
446
+ "iopub.status.busy": "2026-10-14T09:51:24.303292Z",
447
+ "iopub.status.idle": "2026-10-14T09:51:24.307308Z",
448
+ "shell.execute_reply": "2026-10-14T09:51:24.306689Z"
449
+ }
450
+ },
451
+ "outputs": [
452
+ {
453
+ "name": "stdout",
454
+ "output_type": "stream",
455
+ "text": [
456
+ "cap ratio, 40k self-correction mode (reaches 90.4% < 92.2%): 8192 x 30k / (32 x 40k) = 192x\n",
457
+ "cap ratio, 128k extended mode (reaches 92.7% > 92.2%) : 8192 x 30k / (32 x 128k) = 60x\n",
458
+ "rho implied by the caps (chain = attempt + 2 revisions <= 40k vs fresh <= 30k): 0.16666666666666663\n",
459
+ "8. rho implied by 1 + 2 rho = 40k/30k equals 1/6 err=2.776e-17 (tol 1e-15)\n"
460
+ ]
461
+ }
462
+ ],
463
+ "source": [
464
+ "tok_std, tok_sc40, tok_sc128 = 30_000, 40_000, 128_000\n",
465
+ "ratio40 = (8192 * tok_std) / (32 * tok_sc40); ratio128 = (8192 * tok_std) / (32 * tok_sc128)\n",
466
+ "print(f'cap ratio, 40k self-correction mode (reaches 90.4% < 92.2%): 8192 x 30k / (32 x 40k) = {ratio40:.0f}x')\n",
467
+ "print(f'cap ratio, 128k extended mode (reaches 92.7% > 92.2%) : 8192 x 30k / (32 x 128k) = {ratio128:.0f}x')\n",
468
+ "assert ratio40 == 192 and ratio128 == 60\n",
469
+ "# chain-vs-fresh cost at the caps: 1 + 2 rho <= 40/30 => rho <= 1/6 if the caps were costs\n",
470
+ "rho_cap = (tok_sc40 / tok_std - 1) / 2\n",
471
+ "print('rho implied by the caps (chain = attempt + 2 revisions <= 40k vs fresh <= 30k):', rho_cap)\n",
472
+ "check('8. rho implied by 1 + 2 rho = 40k/30k equals 1/6', abs(rho_cap - 1 / 6), 1e-15)\n",
473
+ "CHECKS.append(('8. cap-based token ratio for the 92.7% (128k, <=5 revisions) configuration (informational)', float(ratio128), float('inf')))\n",
474
+ "CHECKS.append(('8. cap-based token ratio for the 40k self-correction mode (informational; that mode does not beat 92.2%)', float(ratio40), float('inf')))"
475
+ ]
476
+ },
477
+ {
478
+ "cell_type": "markdown",
479
+ "id": "d23da596",
480
+ "metadata": {},
481
+ "source": [
482
+ "## 8b. Sensitivity of the brute-force optimum to the chain-length cap\n",
483
+ "With `q` independent of history the continuous objective is monotone in `R`; the integer optimum depends on the cap `R_max` (and on `floor(B/(1+rho R))`)."
484
+ ]
485
+ },
486
+ {
487
+ "cell_type": "code",
488
+ "execution_count": 10,
489
+ "id": "56d4dfdb",
490
+ "metadata": {
491
+ "execution": {
492
+ "iopub.execute_input": "2026-10-14T09:51:24.308569Z",
493
+ "iopub.status.busy": "2026-10-14T09:51:24.308427Z",
494
+ "iopub.status.idle": "2026-10-14T09:51:24.313929Z",
495
+ "shell.execute_reply": "2026-10-14T09:51:24.313186Z"
496
+ }
497
+ },
498
+ "outputs": [
499
+ {
500
+ "name": "stdout",
501
+ "output_type": "stream",
502
+ "text": [
503
+ "p1=0.05 q=0.06 rho=1 B=600, R_max= 12: best (n,R)=(50,11), log-failure = -36.596\n",
504
+ "p1=0.05 q=0.06 rho=1 B=600, R_max= 50: best (n,R)=(12,49), log-failure = -36.998\n",
505
+ "p1=0.05 q=0.06 rho=1 B=600, R_max=599: best (n,R)=(1,599), log-failure = -37.115\n"
506
+ ]
507
+ }
508
+ ],
509
+ "source": [
510
+ "def best_mix_cap(p1, q, rho, B, Rmax):\n",
511
+ " best = (float('inf'), None)\n",
512
+ " for R in range(0, Rmax + 1):\n",
513
+ " n = int(B // (1 + rho * R))\n",
514
+ " if n < 1: continue\n",
515
+ " lf = n * (math.log(1 - p1) + R * math.log(1 - q))\n",
516
+ " if lf < best[0] - 1e-12: best = (lf, (n, R))\n",
517
+ " return best\n",
518
+ "for cap in (12, 50, 599):\n",
519
+ " lf, (n_, R_) = best_mix_cap(0.05, 0.06, 1.0, 600.0, cap)\n",
520
+ " print(f'p1=0.05 q=0.06 rho=1 B=600, R_max={cap:3d}: best (n,R)=({n_},{R_}), log-failure = {lf:.3f}')\n",
521
+ "lf12 = best_mix_cap(0.05, 0.06, 1.0, 600.0, 12); lf599 = best_mix_cap(0.05, 0.06, 1.0, 600.0, 599)\n",
522
+ "assert lf12[1] == (50, 11) and lf599[1][0] <= 2 and lf599[0] < lf12[0]\n",
523
+ "CHECKS.append(('8b. optimum for R_max=12 is (50,11); uncapped optimum has n<=2 chains (informational)', float(lf599[1][0]), float('inf')))"
524
+ ]
525
+ },
526
+ {
527
+ "cell_type": "markdown",
528
+ "id": "b5ac6a6d",
529
+ "metadata": {},
530
+ "source": [
531
+ "## Verification table"
532
+ ]
533
+ },
534
+ {
535
+ "cell_type": "code",
536
+ "execution_count": 11,
537
+ "id": "433dc5ca",
538
+ "metadata": {
539
+ "execution": {
540
+ "iopub.execute_input": "2026-10-14T09:51:24.315222Z",
541
+ "iopub.status.busy": "2026-10-14T09:51:24.315080Z",
542
+ "iopub.status.idle": "2026-10-14T09:51:24.318768Z",
543
+ "shell.execute_reply": "2026-10-14T09:51:24.317925Z"
544
+ }
545
+ },
546
+ "outputs": [
547
+ {
548
+ "name": "stdout",
549
+ "output_type": "stream",
550
+ "text": [
551
+ "\n",
552
+ "identity max abs err\n",
553
+ "1. coverage: Monte Carlo vs 1 - B(a,b+n)/B(a,b) (max z, n in {1..256}) 1.274e+00\n",
554
+ "1. tail law: ratio -> 1 as n -> 1e5 (|ratio-1|) 6.800e-06\n",
555
+ "2. E[min(Geom(p),n)] = (1-(1-p)^n)/p = sum_{j<n}(1-p)^j (exact rationals) 0.000e+00\n",
556
+ "2. Beta-average of the early-stopping cost: MC vs sum_j B(a,b+j)/B(a,b) (z) 4.881e-01\n",
557
+ "3. posterior success probability after j failures = a/(a+b+j) (importance-sampling MC, max z) 1.647e+00\n",
558
+ "3. same, exact Beta-function ratio B(a+1,b+j)/B(a,b+j) 8.491e-16\n",
559
+ "3. DP stopping index vs threshold rule ceil(aU/c-(a+b)) (max |diff| over 3 settings) 0.000e+00\n",
560
+ "4. n_max is the smallest n with expected cost >= 64 (violations; 0 = cost(n_max-1) < 64 <= cost(n_max)) 0.000e+00\n",
561
+ "4. adaptive-minus-fixed coverage at equal mean cost (informational) 1.100e-01\n",
562
+ "5. revise-iff-(a2 > rho a1) rule vs brute-force optimum: disagreements with relative gap > 10% 0.000e+00\n",
563
+ "6. precision of the first accepted sample: MC vs p/(p+(1-p)phi) (max z) 1.883e+00\n",
564
+ "7. exp-power-law fit error on a Beta-mixture curve (max abs over 3 fits, informational) 5.301e-02\n",
565
+ "8. rho implied by 1 + 2 rho = 40k/30k equals 1/6 2.776e-17\n",
566
+ "8. cap-based token ratio for the 92.7% (128k, <=5 revisions) configuration (informational) 6.000e+01\n",
567
+ "8. cap-based token ratio for the 40k self-correction mode (informational; that mode does not beat 92.2%) 1.920e+02\n",
568
+ "8b. optimum for R_max=12 is (50,11); uncapped optimum has n<=2 chains (informational) 1.000e+00\n",
569
+ "CHECKS_JSON:[[\"1. coverage: Monte Carlo vs 1 - B(a,b+n)/B(a,b) (max z, n in {1..256})\", 1.2744892726318469, 4.0], [\"1. tail law: ratio -> 1 as n -> 1e5 (|ratio-1|)\", 6.8000623573150065e-06, 0.005], [\"2. E[min(Geom(p),n)] = (1-(1-p)^n)/p = sum_{j<n}(1-p)^j (exact rationals)\", 0.0, 0.0], [\"2. Beta-average of the early-stopping cost: MC vs sum_j B(a,b+j)/B(a,b) (z)\", 0.488083889788271, 4.0], [\"3. posterior success probability after j failures = a/(a+b+j) (importance-sampling MC, max z)\", 1.6465685132559886, 4.5], [\"3. same, exact Beta-function ratio B(a+1,b+j)/B(a,b+j)\", 8.491471414906471e-16, 1e-14], [\"3. DP stopping index vs threshold rule ceil(aU/c-(a+b)) (max |diff| over 3 settings)\", 0.0, 1.0], [\"4. n_max is the smallest n with expected cost >= 64 (violations; 0 = cost(n_max-1) < 64 <= cost(n_max))\", 0.0, 0.0], [\"4. adaptive-minus-fixed coverage at equal mean cost (informational)\", 0.10998111815038014, Infinity], [\"5. revise-iff-(a2 > rho a1) rule vs brute-force optimum: disagreements with relative gap > 10%\", 0.0, 0.0], [\"6. precision of the first accepted sample: MC vs p/(p+(1-p)phi) (max z)\", 1.88315480662085, 4.0], [\"7. exp-power-law fit error on a Beta-mixture curve (max abs over 3 fits, informational)\", 0.05301359850390941, Infinity], [\"8. rho implied by 1 + 2 rho = 40k/30k equals 1/6\", 2.7755575615628914e-17, 1e-15], [\"8. cap-based token ratio for the 92.7% (128k, <=5 revisions) configuration (informational)\", 60.0, Infinity], [\"8. cap-based token ratio for the 40k self-correction mode (informational; that mode does not beat 92.2%)\", 192.0, Infinity], [\"8b. optimum for R_max=12 is (50,11); uncapped optimum has n<=2 chains (informational)\", 1.0, Infinity]]\n"
570
+ ]
571
+ }
572
+ ],
573
+ "source": [
574
+ "print()\n",
575
+ "print(f'{\"identity\":94s} {\"max abs err\":>12s}')\n",
576
+ "for n_, e_, t_ in CHECKS: print(f'{n_:94s} {e_:12.3e}')\n",
577
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
578
+ ]
579
+ }
580
+ ],
581
+ "metadata": {
582
+ "kernelspec": {
583
+ "display_name": "Python 3",
584
+ "language": "python",
585
+ "name": "python3"
586
+ },
587
+ "language_info": {
588
+ "codemirror_mode": {
589
+ "name": "ipython",
590
+ "version": 3
591
+ },
592
+ "file_extension": ".py",
593
+ "mimetype": "text/x-python",
594
+ "name": "python",
595
+ "nbconvert_exporter": "python",
596
+ "pygments_lexer": "ipython3",
597
+ "version": "3.12.11"
598
+ }
599
+ },
600
+ "nbformat": 4,
601
+ "nbformat_minor": 5
602
+ }
code/notebooks/0208-statistics-of-evaluation.ipynb ADDED
@@ -0,0 +1,687 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "2dc561da",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0208 -- Statistics of evaluation: exact McNemar, Wilson intervals, cluster bootstrap, power for a +5/244 effect\n",
9
+ "\n",
10
+ "Benchmark of `N = 244` problems (the size of miniF2F-test). Identities / claims verified:\n",
11
+ "1. Exact McNemar two-sided p-value `min(1, 2 P(Bin(n_d,1/2) <= min(b,c)))`: exact rationals = scipy = sign-flip permutation enumeration; **no outcome with `b - c = 5` is significant at 0.05 (best case (5,0): p = 0.0625)**.\n",
12
+ "2. Wilson interval: closed form = numerical inversion of the score test; exact coverage vs Wald and Clopper-Pearson.\n",
13
+ "3. Variance decomposition `Var = [Var_x(p) + E p(1-p)/m]/N` and the design effect; cluster-bootstrap vs naive coverage.\n",
14
+ "4. Paired-difference variance `(Var A + Var B - 2 Cov)/N`.\n",
15
+ "5. Exact power of McNemar for `delta = 5/244`; normal approximation; MDE; required N.\n",
16
+ "6. Size of Wald and bootstrap tests with few discordant pairs (both reject (5,0); exact does not)."
17
+ ]
18
+ },
19
+ {
20
+ "cell_type": "code",
21
+ "execution_count": 1,
22
+ "id": "c6f8a060",
23
+ "metadata": {
24
+ "execution": {
25
+ "iopub.execute_input": "2026-10-14T09:32:01.357637Z",
26
+ "iopub.status.busy": "2026-10-14T09:32:01.357418Z",
27
+ "iopub.status.idle": "2026-10-14T09:32:02.129875Z",
28
+ "shell.execute_reply": "2026-10-14T09:32:02.128835Z"
29
+ }
30
+ },
31
+ "outputs": [],
32
+ "source": [
33
+ "import os\n",
34
+ "os.environ['CUDA_VISIBLE_DEVICES'] = ''\n",
35
+ "import math, json, itertools\n",
36
+ "from fractions import Fraction as Fr\n",
37
+ "from math import comb\n",
38
+ "import numpy as np\n",
39
+ "from scipy import stats\n",
40
+ "from scipy.optimize import brentq\n",
41
+ "from scipy.special import betaln\n",
42
+ "CHECKS = []\n",
43
+ "def check(name, err, tol):\n",
44
+ " CHECKS.append((name, float(err), float(tol)))\n",
45
+ " assert err <= tol, (name, float(err), tol)\n",
46
+ " print(f'{name:96s} err={float(err):.3e} (tol {tol:.0e})')\n",
47
+ "N = 244"
48
+ ]
49
+ },
50
+ {
51
+ "cell_type": "markdown",
52
+ "id": "8c953229",
53
+ "metadata": {},
54
+ "source": [
55
+ "## 1. Exact McNemar\n",
56
+ "Paired binary outcomes per problem: `b` = A solves & B fails, `c` = B solves & A fails. Concordant problems carry no information about the difference\n",
57
+ "under `H0: P(A=1,B=0) = P(A=0,B=1)`. Conditional on `n_d = b + c`, `b ~ Bin(n_d, 1/2)`.\n",
58
+ "NAIVE: exact rational binomial tail; independent check 1: scipy; independent check 2: enumerate all `2^{n_d}` sign assignments (permutation test)."
59
+ ]
60
+ },
61
+ {
62
+ "cell_type": "code",
63
+ "execution_count": 2,
64
+ "id": "7e8399d5",
65
+ "metadata": {
66
+ "execution": {
67
+ "iopub.execute_input": "2026-10-14T09:32:02.131822Z",
68
+ "iopub.status.busy": "2026-10-14T09:32:02.131499Z",
69
+ "iopub.status.idle": "2026-10-14T09:32:03.388935Z",
70
+ "shell.execute_reply": "2026-10-14T09:32:03.387984Z"
71
+ }
72
+ },
73
+ "outputs": [
74
+ {
75
+ "name": "stdout",
76
+ "output_type": "stream",
77
+ "text": [
78
+ "1. exact McNemar p: rational tail vs scipy.binomtest (all b,c <= 16) err=1.110e-16 (tol 1e-12)\n",
79
+ "1. exact McNemar p: rational tail vs sign-flip permutation enumeration (n_d <= 14) err=0.000e+00 (tol 1e-15)\n"
80
+ ]
81
+ },
82
+ {
83
+ "name": "stdout",
84
+ "output_type": "stream",
85
+ "text": [
86
+ "net gain d=b-c : the discordant counts n_d for which some split is significant (min..max n_d, count)\n",
87
+ " d= 3: never significant\n",
88
+ " d= 4: never significant\n",
89
+ " d= 5: never significant\n",
90
+ " d= 6: n_d in [6, 6] (1 values)\n",
91
+ " d= 7: n_d in [7, 9] (2 values)\n",
92
+ " d= 8: n_d in [8, 12] (3 values)\n",
93
+ " d= 9: n_d in [9, 17] (5 values)\n",
94
+ " d=10: n_d in [10, 20] (6 values)\n",
95
+ " d=11: n_d in [11, 25] (8 values)\n",
96
+ " d=12: n_d in [12, 30] (10 values)\n",
97
+ "p(5,0) = 0.0625 p(6,0) = 0.03125 p(7,1) = 0.0703125 p(8,3) = 0.2265625\n",
98
+ "1. best possible p for a +5 net gain is (5,0): p = 1/16 > 0.05 (|p - 0.0625|) err=0.000e+00 (tol 0e+00)\n",
99
+ "typical +5/244 outcomes (b,c): {(5, 0): 0.062, (8, 3): 0.227, (10, 5): 0.302, (15, 10): 0.424, (20, 15): 0.5, (30, 25): 0.59}\n"
100
+ ]
101
+ }
102
+ ],
103
+ "source": [
104
+ "def mcnemar_exact_fraction(b, c):\n",
105
+ " n = b + c\n",
106
+ " if n == 0: return Fr(1)\n",
107
+ " k = min(b, c)\n",
108
+ " return min(Fr(1), 2 * Fr(sum(comb(n, i) for i in range(k + 1)), 2 ** n))\n",
109
+ "worst = 0.0; worst_perm = 0.0\n",
110
+ "for b in range(0, 17):\n",
111
+ " for c in range(0, 17):\n",
112
+ " n = b + c\n",
113
+ " if n == 0: continue\n",
114
+ " pf = float(mcnemar_exact_fraction(b, c))\n",
115
+ " ps = stats.binomtest(min(b, c), n, 0.5, alternative='two-sided').pvalue\n",
116
+ " worst = max(worst, abs(pf - ps))\n",
117
+ " if n <= 14:\n",
118
+ " signs = np.array(list(itertools.product([-1, 1], repeat=n)))\n",
119
+ " cnt = (np.abs(signs.sum(1)) >= abs(b - c)).sum() / len(signs)\n",
120
+ " worst_perm = max(worst_perm, abs(pf - cnt))\n",
121
+ "check('1. exact McNemar p: rational tail vs scipy.binomtest (all b,c <= 16)', worst, 1e-12)\n",
122
+ "check('1. exact McNemar p: rational tail vs sign-flip permutation enumeration (n_d <= 14)', worst_perm, 1e-15)\n",
123
+ "# Which net improvements d = b - c can ever be significant at 0.05 (two-sided)?\n",
124
+ "best = {}\n",
125
+ "for n in range(1, N + 1):\n",
126
+ " for b in range(n + 1):\n",
127
+ " c = n - b\n",
128
+ " if b - c <= 0: continue\n",
129
+ " if float(mcnemar_exact_fraction(b, c)) < 0.05:\n",
130
+ " best.setdefault(b - c, []).append(n)\n",
131
+ "print('net gain d=b-c : the discordant counts n_d for which some split is significant (min..max n_d, count)')\n",
132
+ "for d in range(3, 13):\n",
133
+ " ns_ = best.get(d, [])\n",
134
+ " print(f' d={d:2d}: ' + (f'n_d in [{min(ns_)}, {max(ns_)}] ({len(ns_)} values)' if ns_ else 'never significant'))\n",
135
+ "for d in (5, 6):\n",
136
+ " pass\n",
137
+ "assert 5 not in best and 4 not in best and 6 in best and min(best[6]) == max(best[6]) == 6\n",
138
+ "print('p(5,0) =', float(mcnemar_exact_fraction(5, 0)), ' p(6,0) =', float(mcnemar_exact_fraction(6, 0)), ' p(7,1) =', float(mcnemar_exact_fraction(7, 1)), ' p(8,3) =', float(mcnemar_exact_fraction(8, 3)))\n",
139
+ "check('1. best possible p for a +5 net gain is (5,0): p = 1/16 > 0.05 (|p - 0.0625|)', abs(float(mcnemar_exact_fraction(5, 0)) - 0.0625), 0.0)\n",
140
+ "print('typical +5/244 outcomes (b,c): ', {(b, b - 5): round(float(mcnemar_exact_fraction(b, b - 5)), 3) for b in (5, 8, 10, 15, 20, 30)})"
141
+ ]
142
+ },
143
+ {
144
+ "cell_type": "markdown",
145
+ "id": "d98651ac",
146
+ "metadata": {},
147
+ "source": [
148
+ "## 2. Wilson interval\n",
149
+ "Invert the score test `|p_hat - p| <= z sqrt(p(1-p)/n)`. Squaring: `(1 + z^2/n) p^2 - (2 p_hat + z^2/n) p + p_hat^2 <= 0`, a quadratic in `p` whose roots are\n",
150
+ "`p = [p_hat + z^2/(2n) +- z sqrt(p_hat(1-p_hat)/n + z^2/(4n^2))] / (1 + z^2/n)`.\n",
151
+ "NAIVE: root-find the score-test equation numerically. EFFICIENT: closed form. Then exact coverage over a grid of true `p`."
152
+ ]
153
+ },
154
+ {
155
+ "cell_type": "code",
156
+ "execution_count": 3,
157
+ "id": "556d82e0",
158
+ "metadata": {
159
+ "execution": {
160
+ "iopub.execute_input": "2026-10-14T09:32:03.390688Z",
161
+ "iopub.status.busy": "2026-10-14T09:32:03.390516Z",
162
+ "iopub.status.idle": "2026-10-14T09:32:03.531057Z",
163
+ "shell.execute_reply": "2026-10-14T09:32:03.530078Z"
164
+ }
165
+ },
166
+ "outputs": [
167
+ {
168
+ "name": "stdout",
169
+ "output_type": "stream",
170
+ "text": [
171
+ "2. Wilson closed form vs numerical inversion of the score test (max |endpoint diff|, x=1..243) err=4.905e-13 (tol 1e-10)\n",
172
+ " 203/244 = 0.8320 Wilson 95% = [0.7800, 0.8737] (half-width 0.0468 = 11.4 problems)\n",
173
+ " 208/244 = 0.8525 Wilson 95% = [0.8025, 0.8915] (half-width 0.0445 = 10.9 problems)\n",
174
+ " 213/244 = 0.8730 Wilson 95% = [0.8253, 0.9090] (half-width 0.0419 = 10.2 problems)\n",
175
+ " coverage of nominal-95% wilson: mean 0.9507 min 0.9402 at p=0.83: 0.9495 at p=0.02: 0.9664\n",
176
+ " coverage of nominal-95% wald : mean 0.9436 min 0.8634 at p=0.83: 0.9437 at p=0.02: 0.8634\n",
177
+ " coverage of nominal-95% cp : mean 0.9595 min 0.9507 at p=0.83: 0.9597 at p=0.02: 0.9819\n"
178
+ ]
179
+ }
180
+ ],
181
+ "source": [
182
+ "z = stats.norm.ppf(0.975)\n",
183
+ "def wilson(x, n, z=z):\n",
184
+ " ph = x / n; den = 1 + z * z / n\n",
185
+ " ctr = (ph + z * z / (2 * n)) / den; half = z / den * math.sqrt(ph * (1 - ph) / n + z * z / (4 * n * n))\n",
186
+ " return ctr - half, ctr + half\n",
187
+ "def wilson_numeric(x, n, z=z):\n",
188
+ " ph = x / n\n",
189
+ " f = lambda p: (ph - p) ** 2 - z * z * p * (1 - p) / n\n",
190
+ " lo = brentq(f, 1e-12, ph) if x > 0 else 0.0\n",
191
+ " hi = brentq(f, ph, 1 - 1e-12) if x < n else 1.0\n",
192
+ " return lo, hi\n",
193
+ "worst = max(max(abs(a - b_) for a, b_ in zip(wilson(x, N), wilson_numeric(x, N))) for x in range(1, N))\n",
194
+ "check('2. Wilson closed form vs numerical inversion of the score test (max |endpoint diff|, x=1..243)', worst, 1e-10)\n",
195
+ "for x in (203, 208, 213):\n",
196
+ " lo, hi = wilson(x, N); print(f' {x}/{N} = {x/N:.4f} Wilson 95% = [{lo:.4f}, {hi:.4f}] (half-width {(hi-lo)/2:.4f} = {(hi-lo)/2*N:.1f} problems)')\n",
197
+ "def wald(x, n):\n",
198
+ " ph = x / n; h = z * math.sqrt(ph * (1 - ph) / n); return ph - h, ph + h\n",
199
+ "def cp(x, n):\n",
200
+ " lo = 0.0 if x == 0 else stats.beta.ppf(0.025, x, n - x + 1); hi = 1.0 if x == n else stats.beta.ppf(0.975, x + 1, n - x)\n",
201
+ " return lo, hi\n",
202
+ "xs = np.arange(N + 1); ci_cache = {nm: np.array([f(x, N) for x in xs]) for nm, f in (('wilson', wilson), ('wald', wald), ('cp', cp))}\n",
203
+ "def coverage(nm, p):\n",
204
+ " pm = stats.binom.pmf(xs, N, p); lo, hi = ci_cache[nm][:, 0], ci_cache[nm][:, 1]\n",
205
+ " return float((pm * ((lo <= p) & (p <= hi))).sum())\n",
206
+ "pgrid = np.arange(0.01, 0.995, 0.005)\n",
207
+ "cov = {nm: np.array([coverage(nm, p) for p in pgrid]) for nm in ci_cache}\n",
208
+ "for nm in cov: print(f' coverage of nominal-95% {nm:6s}: mean {cov[nm].mean():.4f} min {cov[nm].min():.4f} at p=0.83: {coverage(nm, 0.83):.4f} at p=0.02: {coverage(nm, 0.02):.4f}')\n",
209
+ "assert cov['cp'].min() >= 0.95 - 1e-9 and cov['wald'].min() < 0.93 and abs(cov['wilson'].mean() - 0.95) < 0.01\n",
210
+ "CHECKS.append(('2. min exact coverage over p in [0.01,0.995]: Wilson / Wald / Clopper-Pearson = %.4f / %.4f / %.4f (informational)' % (cov['wilson'].min(), cov['wald'].min(), cov['cp'].min()), float(cov['wilson'].min()), float('inf')))"
211
+ ]
212
+ },
213
+ {
214
+ "cell_type": "markdown",
215
+ "id": "c7da2ddd",
216
+ "metadata": {},
217
+ "source": [
218
+ "## 3. Clustering by problem: variance decomposition, design effect, cluster bootstrap\n",
219
+ "`m` samples per problem; the unit of independence is the **problem**. Super-population: `p_x ~ Beta(1.4, 0.7)`, `c_x | p_x ~ Bin(m, p_x)`, `p_hat_x = c_x / m`, estimator `mean_x p_hat_x`.\n",
220
+ "Total variance: `Var = [Var(p_x) + E p_x(1-p_x)/m] / N`. A sample-level (naive) SE treats all `N m` outcomes as independent: `p_bar(1-p_bar)/(N m)`.\n",
221
+ "Design effect `1 + (m-1) rho`, `rho = Var(p_x) / (p_bar(1-p_bar))`."
222
+ ]
223
+ },
224
+ {
225
+ "cell_type": "code",
226
+ "execution_count": 4,
227
+ "id": "9430c80f",
228
+ "metadata": {
229
+ "execution": {
230
+ "iopub.execute_input": "2026-10-14T09:32:03.533001Z",
231
+ "iopub.status.busy": "2026-10-14T09:32:03.532814Z",
232
+ "iopub.status.idle": "2026-10-14T09:32:06.152740Z",
233
+ "shell.execute_reply": "2026-10-14T09:32:06.151923Z"
234
+ }
235
+ },
236
+ "outputs": [
237
+ {
238
+ "name": "stdout",
239
+ "output_type": "stream",
240
+ "text": [
241
+ "p_bar=0.6667 Var(p_x)=0.0717 ICC rho=0.3226 design effect=5.839 -> naive SE underestimates by x2.416\n",
242
+ "3. design effect identity Var_total / naive_Var = 1 + (m-1) rho err=8.882e-16 (tol 1e-12)\n"
243
+ ]
244
+ },
245
+ {
246
+ "name": "stdout",
247
+ "output_type": "stream",
248
+ "text": [
249
+ "empirical Var(estimator)=3.3345e-04 theory=3.3235e-04 E[SE_cluster^2]=3.3236e-04 E[SE_naive^2]=5.6833e-05\n",
250
+ "3. empirical variance of the estimator vs [Var(p)+E p(1-p)/m]/N (relative) err=3.303e-03 (tol 3e-02)\n",
251
+ "3. cluster SE^2 is unbiased for the variance (relative) err=3.929e-05 (tol 3e-02)\n"
252
+ ]
253
+ },
254
+ {
255
+ "name": "stdout",
256
+ "output_type": "stream",
257
+ "text": [
258
+ "coverage of nominal 95% CI for pass@1 over 600 replications: cluster bootstrap 0.950, naive sample bootstrap 0.585, Wilson on pooled 3904 outcomes 0.602\n"
259
+ ]
260
+ },
261
+ {
262
+ "name": "stdout",
263
+ "output_type": "stream",
264
+ "text": [
265
+ "pass@8 (n=16 per problem, unbiased estimator): true 0.9622, cluster-bootstrap CI coverage 0.927\n"
266
+ ]
267
+ }
268
+ ],
269
+ "source": [
270
+ "a, b = 1.4, 0.7; m = 16\n",
271
+ "p_bar = a / (a + b); var_p = a * b / ((a + b) ** 2 * (a + b + 1)); E_pq = p_bar - (var_p + p_bar ** 2)\n",
272
+ "var_true = (var_p + E_pq / m) / N\n",
273
+ "rho = var_p / (p_bar * (1 - p_bar)); deff = 1 + (m - 1) * rho\n",
274
+ "naive_var = p_bar * (1 - p_bar) / (N * m)\n",
275
+ "print(f'p_bar={p_bar:.4f} Var(p_x)={var_p:.4f} ICC rho={rho:.4f} design effect={deff:.3f} -> naive SE underestimates by x{math.sqrt(deff):.3f}')\n",
276
+ "check('3. design effect identity Var_total / naive_Var = 1 + (m-1) rho', abs(var_true / naive_var - deff), 1e-12)\n",
277
+ "rs = np.random.RandomState(0); R = 30000\n",
278
+ "px = rs.beta(a, b, size=(R, N)); cx = rs.binomial(m, px); phx = cx / m\n",
279
+ "est = phx.mean(1)\n",
280
+ "se2_cluster = phx.var(1, ddof=1) / N; se2_naive = est * (1 - est) / (N * m)\n",
281
+ "print(f'empirical Var(estimator)={est.var():.4e} theory={var_true:.4e} E[SE_cluster^2]={se2_cluster.mean():.4e} E[SE_naive^2]={se2_naive.mean():.4e}')\n",
282
+ "check('3. empirical variance of the estimator vs [Var(p)+E p(1-p)/m]/N (relative)', abs(est.var() / var_true - 1), 0.03)\n",
283
+ "check('3. cluster SE^2 is unbiased for the variance (relative)', abs(se2_cluster.mean() / var_true - 1), 0.03)\n",
284
+ "assert se2_naive.mean() < 0.5 * var_true\n",
285
+ "# coverage of 95% CIs for pass@1: cluster bootstrap vs naive sample-level bootstrap vs Wilson on pooled counts\n",
286
+ "Rcov, B = 600, 400\n",
287
+ "cov_cl = cov_nv = cov_wi = 0\n",
288
+ "rb = np.random.RandomState(1)\n",
289
+ "for r in range(Rcov):\n",
290
+ " px_ = rb.beta(a, b, size=N); cx_ = rb.binomial(m, px_); ph = cx_ / m; e = ph.mean()\n",
291
+ " idx = rb.randint(0, N, size=(B, N)); boot = ph[idx].mean(1); lo, hi = np.percentile(boot, [2.5, 97.5]); cov_cl += (lo <= p_bar <= hi)\n",
292
+ " nb = rb.binomial(N * m, e, size=B) / (N * m); lo, hi = np.percentile(nb, [2.5, 97.5]); cov_nv += (lo <= p_bar <= hi)\n",
293
+ " lo, hi = wilson(int(cx_.sum()), N * m); cov_wi += (lo <= p_bar <= hi)\n",
294
+ "print(f'coverage of nominal 95% CI for pass@1 over {Rcov} replications: cluster bootstrap {cov_cl/Rcov:.3f}, naive sample bootstrap {cov_nv/Rcov:.3f}, Wilson on pooled {N*m} outcomes {cov_wi/Rcov:.3f}')\n",
295
+ "assert cov_cl / Rcov > 0.92 and cov_nv / Rcov < 0.75\n",
296
+ "CHECKS.append(('3. coverage of nominal-95%% CI: cluster bootstrap %.3f, naive %.3f (informational)' % (cov_cl / Rcov, cov_nv / Rcov), float(cov_cl / Rcov), float('inf')))\n",
297
+ "\n",
298
+ "# pass@k with the unbiased per-problem estimator, cluster bootstrap\n",
299
+ "k = 8\n",
300
+ "def rho_nk(n, c, k): \n",
301
+ " out = np.ones_like(c, dtype=float)\n",
302
+ " for cc in range(n + 1): out[c == cc] = 1.0 if n - cc < k else 1.0 - float(np.prod(1.0 - k / np.arange(n - cc + 1, n + 1)))\n",
303
+ " return out\n",
304
+ "truth_k = 1 - math.exp(betaln(a, b + k) - betaln(a, b))\n",
305
+ "cov_k = 0; rb = np.random.RandomState(2)\n",
306
+ "for r in range(Rcov):\n",
307
+ " px_ = rb.beta(a, b, size=N); cx_ = rb.binomial(m, px_); est_x = rho_nk(m, cx_, k)\n",
308
+ " idx = rb.randint(0, N, size=(B, N)); boot = est_x[idx].mean(1); lo, hi = np.percentile(boot, [2.5, 97.5]); cov_k += (lo <= truth_k <= hi)\n",
309
+ "print(f'pass@{k} (n={m} per problem, unbiased estimator): true {truth_k:.4f}, cluster-bootstrap CI coverage {cov_k/Rcov:.3f}')\n",
310
+ "assert cov_k / Rcov > 0.92"
311
+ ]
312
+ },
313
+ {
314
+ "cell_type": "markdown",
315
+ "id": "e6dd44fc",
316
+ "metadata": {},
317
+ "source": [
318
+ "## 4. Paired differences\n",
319
+ "`Delta_x = hat p^A_x - hat p^B_x`. `Var(mean Delta) = (Var A + Var B - 2 Cov(A,B)) / N` on the problem level. Unpaired analysis ignores the covariance."
320
+ ]
321
+ },
322
+ {
323
+ "cell_type": "code",
324
+ "execution_count": 5,
325
+ "id": "49028f15",
326
+ "metadata": {
327
+ "execution": {
328
+ "iopub.execute_input": "2026-10-14T09:32:06.154500Z",
329
+ "iopub.status.busy": "2026-10-14T09:32:06.154336Z",
330
+ "iopub.status.idle": "2026-10-14T09:32:07.402778Z",
331
+ "shell.execute_reply": "2026-10-14T09:32:07.401927Z"
332
+ }
333
+ },
334
+ "outputs": [
335
+ {
336
+ "name": "stdout",
337
+ "output_type": "stream",
338
+ "text": [
339
+ "empirical Var(mean Delta)=9.9803e-05; paired formula 9.9418e-05; unpaired (ignores Cov) 7.8556e-04; ratio unpaired/paired = 7.90\n",
340
+ "4. paired variance formula vs empirical (relative) err=3.864e-03 (tol 3e-02)\n"
341
+ ]
342
+ }
343
+ ],
344
+ "source": [
345
+ "rs = np.random.RandomState(3); R = 20000\n",
346
+ "lA = rs.randn(R, N) * 1.8 + 0.5; lB = lA + 0.15 + rs.randn(R, N) * 0.4 # B = A shifted, correlated through the problem\n",
347
+ "pA, pB = 1 / (1 + np.exp(-lA)), 1 / (1 + np.exp(-lB))\n",
348
+ "cA, cB = rs.binomial(m, pA) / m, rs.binomial(m, pB) / m\n",
349
+ "D = (cB - cA).mean(1)\n",
350
+ "var_pair = np.mean(((cB - cA).var(1, ddof=1)) / N)\n",
351
+ "var_unpair = np.mean((cA.var(1, ddof=1) + cB.var(1, ddof=1)) / N)\n",
352
+ "print(f'empirical Var(mean Delta)={D.var():.4e}; paired formula {var_pair:.4e}; unpaired (ignores Cov) {var_unpair:.4e}; ratio unpaired/paired = {var_unpair/var_pair:.2f}')\n",
353
+ "check('4. paired variance formula vs empirical (relative)', abs(var_pair / D.var() - 1), 0.03)\n",
354
+ "assert var_unpair > 1.5 * var_pair"
355
+ ]
356
+ },
357
+ {
358
+ "cell_type": "markdown",
359
+ "id": "ae7bfc19",
360
+ "metadata": {},
361
+ "source": [
362
+ "## 5. Power analysis for a net gain of 5/244\n",
363
+ "Outcomes per problem fall in 4 cells; the test uses only `(b, c)`. Joint law `(b, c, rest) ~ Multinomial(N; p_b, p_c, 1-p_b-p_c)`, with `delta = p_b - p_c = 5/244` and discordance `d = p_b + p_c`.\n",
364
+ "EXACT power = `sum_{n_d} Bin(n_d; N, d) sum_b Bin(b; n_d, p_b/d) 1{ exact p(b, n_d - b) < 0.05 }`.\n",
365
+ "NORMAL approximation (Connor 1987): `N >= [z_{a/2} sqrt(d) + z_b sqrt(d - delta^2)]^2 / delta^2`."
366
+ ]
367
+ },
368
+ {
369
+ "cell_type": "code",
370
+ "execution_count": 6,
371
+ "id": "f74c8d64",
372
+ "metadata": {
373
+ "execution": {
374
+ "iopub.execute_input": "2026-10-14T09:32:07.404574Z",
375
+ "iopub.status.busy": "2026-10-14T09:32:07.404402Z",
376
+ "iopub.status.idle": "2026-10-14T09:32:55.741131Z",
377
+ "shell.execute_reply": "2026-10-14T09:32:55.739908Z"
378
+ }
379
+ },
380
+ "outputs": [
381
+ {
382
+ "name": "stdout",
383
+ "output_type": "stream",
384
+ "text": [
385
+ "discordance d exact power at N=244 normal-approx power N needed (normal, 80%) MDE at N=244 for 80% power (problems)\n",
386
+ " 0.03 0.306 0.455 558 7.3\n"
387
+ ]
388
+ },
389
+ {
390
+ "name": "stdout",
391
+ "output_type": "stream",
392
+ "text": [
393
+ " 0.06 0.181 0.256 1119 10.9\n"
394
+ ]
395
+ },
396
+ {
397
+ "name": "stdout",
398
+ "output_type": "stream",
399
+ "text": [
400
+ " 0.10 0.122 0.171 1867 14.3\n"
401
+ ]
402
+ },
403
+ {
404
+ "name": "stdout",
405
+ "output_type": "stream",
406
+ "text": [
407
+ " 0.15 0.097 0.128 2801 17.5\n"
408
+ ]
409
+ },
410
+ {
411
+ "name": "stdout",
412
+ "output_type": "stream",
413
+ "text": [
414
+ " 0.20 0.086 0.106 3736 20.1\n",
415
+ "5. exact power vs normal approximation at d=0.10, N=244 (abs diff; the normal formula ignores discreteness) err=4.952e-02 (tol 1e-01)\n",
416
+ "5. exact power (d=0.10) vs Monte Carlo over 4e5 benchmarks (abs diff) err=2.797e-04 (tol 3e-03)\n",
417
+ "5. vectorised threshold power = table-based exact power (d=0.10, N=244) err=4.663e-15 (tol 1e-12)\n"
418
+ ]
419
+ },
420
+ {
421
+ "name": "stdout",
422
+ "output_type": "stream",
423
+ "text": [
424
+ "required N for 80% exact power at delta=5/244: {0.03: (584, 558), 0.06: (1184, 1119), 0.1: (1946, 1867), 0.15: (2887, 2801), 0.2: (3826, 3736)} (exact, normal approx)\n",
425
+ "5. required N (exact) vs normal approximation, d=0.10 (relative difference) err=4.242e-02 (tol 5e-02)\n"
426
+ ]
427
+ }
428
+ ],
429
+ "source": [
430
+ "alpha = 0.05\n",
431
+ "reject = np.zeros((N + 1, N + 1), dtype=bool) # reject[n_d, b]\n",
432
+ "for n_d in range(1, N + 1):\n",
433
+ " for bb in range(n_d + 1):\n",
434
+ " reject[n_d, bb] = stats.binomtest(min(bb, n_d - bb), n_d, 0.5).pvalue < alpha\n",
435
+ "def power_exact(N_, d, delta):\n",
436
+ " pb = (d + delta) / 2; pc = (d - delta) / 2\n",
437
+ " nds = np.arange(0, N_ + 1); w = stats.binom.pmf(nds, N_, d)\n",
438
+ " tot = 0.0\n",
439
+ " for n_d in range(1, N_ + 1):\n",
440
+ " if w[n_d] < 1e-14: continue\n",
441
+ " bs = np.arange(0, n_d + 1); tot += w[n_d] * (stats.binom.pmf(bs, n_d, pb / d) * reject[n_d, bs]).sum()\n",
442
+ " return float(tot)\n",
443
+ "za, zb = stats.norm.ppf(1 - alpha / 2), stats.norm.ppf(0.8)\n",
444
+ "def N_needed_normal(d, delta, power=0.8):\n",
445
+ " zb_ = stats.norm.ppf(power); return (za * math.sqrt(d) + zb_ * math.sqrt(d - delta ** 2)) ** 2 / delta ** 2\n",
446
+ "delta = 5 / N\n",
447
+ "print('discordance d exact power at N=244 normal-approx power N needed (normal, 80%) MDE at N=244 for 80% power (problems)')\n",
448
+ "res_pow = {}\n",
449
+ "for d in (0.03, 0.06, 0.10, 0.15, 0.20):\n",
450
+ " pe = power_exact(N, d, delta)\n",
451
+ " pn = float(stats.norm.cdf((delta * math.sqrt(N) - za * math.sqrt(d)) / math.sqrt(d - delta ** 2)))\n",
452
+ " # minimal delta with exact power >= 0.8\n",
453
+ " lo_, hi_ = delta, d - 1e-9\n",
454
+ " for _ in range(40):\n",
455
+ " mid = (lo_ + hi_) / 2\n",
456
+ " if power_exact(N, d, mid) >= 0.8: hi_ = mid\n",
457
+ " else: lo_ = mid\n",
458
+ " res_pow[d] = (pe, pn, N_needed_normal(d, delta), hi_ * N)\n",
459
+ " print(f' {d:5.2f} {pe:7.3f} {pn:7.3f} {res_pow[d][2]:8.0f} {hi_*N:6.1f}')\n",
460
+ "assert all(v[0] < 0.35 for v in res_pow.values())\n",
461
+ "check('5. exact power vs normal approximation at d=0.10, N=244 (abs diff; the normal formula ignores discreteness)', abs(res_pow[0.10][0] - res_pow[0.10][1]), 0.12)\n",
462
+ "# Monte Carlo check of the exact-power calculation\n",
463
+ "rs = np.random.RandomState(5); d = 0.10; pb = (d + delta) / 2; pc = (d - delta) / 2\n",
464
+ "cnt = rs.multinomial(N, [pb, pc, 1 - pb - pc], size=400_000)\n",
465
+ "mc_power = reject[cnt[:, 0] + cnt[:, 1], cnt[:, 0]].mean()\n",
466
+ "check('5. exact power (d=0.10) vs Monte Carlo over 4e5 benchmarks (abs diff)', abs(mc_power - res_pow[0.10][0]), 0.003)\n",
467
+ "# Vectorised exact power via rejection thresholds: reject iff min(b,c) <= k*(n_d), k* = ppf(alpha/2) - 1 (two-sided exact p = 2 cdf(min) < alpha)\n",
468
+ "def power_exact_fast(N_, d, delta, alpha=0.05):\n",
469
+ " nd = np.arange(1, N_ + 1); w = stats.binom.pmf(nd, N_, d)\n",
470
+ " ks = stats.binom.ppf(alpha / 2, nd, 0.5) - 1\n",
471
+ " q = (d + delta) / (2 * d)\n",
472
+ " ok = ks >= 0\n",
473
+ " pr = np.zeros(len(nd)); kk = ks[ok]; nn = nd[ok]\n",
474
+ " pr[ok] = stats.binom.cdf(kk, nn, q) + stats.binom.sf(nn - kk - 1, nn, q)\n",
475
+ " return float((w * pr).sum())\n",
476
+ "check('5. vectorised threshold power = table-based exact power (d=0.10, N=244)', abs(power_exact_fast(N, 0.10, delta) - res_pow[0.10][0]), 1e-12)\n",
477
+ "def N_needed_exact(d, delta, power=0.8):\n",
478
+ " lo_, hi_ = 50, 200000\n",
479
+ " while hi_ - lo_ > 1:\n",
480
+ " mid = (lo_ + hi_) // 2\n",
481
+ " # power is not exactly monotone in N because of discreteness: use the first N after which power stays >= target on a window\n",
482
+ " if all(power_exact_fast(mid + t, d, delta) >= power for t in range(0, 6)): hi_ = mid\n",
483
+ " else: lo_ = mid\n",
484
+ " return hi_\n",
485
+ "n_ex = {d: N_needed_exact(d, delta) for d in (0.03, 0.06, 0.10, 0.15, 0.20)}\n",
486
+ "print('required N for 80% exact power at delta=5/244: ', {d: (n_ex[d], round(N_needed_normal(d, delta))) for d in n_ex}, ' (exact, normal approx)')\n",
487
+ "check('5. required N (exact) vs normal approximation, d=0.10 (relative difference)', abs(n_ex[0.10] / N_needed_normal(0.10, delta) - 1), 0.05)\n",
488
+ "CHECKS.append(('5. N required for 80%% power at delta=5/244, d=0.10: exact %d, normal %.0f (informational)' % (n_ex[0.10], N_needed_normal(0.10, delta)), float(n_ex[0.10]), float('inf')))"
489
+ ]
490
+ },
491
+ {
492
+ "cell_type": "markdown",
493
+ "id": "cb66649f",
494
+ "metadata": {},
495
+ "source": [
496
+ "## 6. Size of tests with few discordant pairs, and the `(5, 0)` outcome\n",
497
+ "Under `H0` condition on `n_d` and `b ~ Bin(n_d, 1/2)`. EXACT type-I error of (i) exact McNemar, (ii) Wald `z = (b-c)/sqrt(b+c)` at 1.96, (iii) paired percentile bootstrap."
498
+ ]
499
+ },
500
+ {
501
+ "cell_type": "code",
502
+ "execution_count": 7,
503
+ "id": "91eb7b3f",
504
+ "metadata": {
505
+ "execution": {
506
+ "iopub.execute_input": "2026-10-14T09:32:55.742903Z",
507
+ "iopub.status.busy": "2026-10-14T09:32:55.742712Z",
508
+ "iopub.status.idle": "2026-10-14T09:32:58.063768Z",
509
+ "shell.execute_reply": "2026-10-14T09:32:58.062659Z"
510
+ }
511
+ },
512
+ "outputs": [
513
+ {
514
+ "name": "stdout",
515
+ "output_type": "stream",
516
+ "text": [
517
+ "n_d size(exact) size(Wald z>1.96) size(bootstrap 95% CI excludes 0, MC)\n"
518
+ ]
519
+ },
520
+ {
521
+ "name": "stdout",
522
+ "output_type": "stream",
523
+ "text": [
524
+ " 5 0.0000 0.0625 0.0593\n"
525
+ ]
526
+ },
527
+ {
528
+ "name": "stdout",
529
+ "output_type": "stream",
530
+ "text": [
531
+ " 6 0.0313 0.0313 0.0287\n"
532
+ ]
533
+ },
534
+ {
535
+ "name": "stdout",
536
+ "output_type": "stream",
537
+ "text": [
538
+ " 8 0.0078 0.0703 0.0627\n"
539
+ ]
540
+ },
541
+ {
542
+ "name": "stdout",
543
+ "output_type": "stream",
544
+ "text": [
545
+ " 10 0.0215 0.0215 0.0307\n"
546
+ ]
547
+ },
548
+ {
549
+ "name": "stdout",
550
+ "output_type": "stream",
551
+ "text": [
552
+ " 12 0.0386 0.0386 0.0373\n"
553
+ ]
554
+ },
555
+ {
556
+ "name": "stdout",
557
+ "output_type": "stream",
558
+ "text": [
559
+ " 15 0.0352 0.0352 0.0373\n"
560
+ ]
561
+ },
562
+ {
563
+ "name": "stdout",
564
+ "output_type": "stream",
565
+ "text": [
566
+ " 20 0.0414 0.0414 0.0440\n"
567
+ ]
568
+ },
569
+ {
570
+ "name": "stdout",
571
+ "output_type": "stream",
572
+ "text": [
573
+ " 30 0.0428 0.0428 0.0420\n"
574
+ ]
575
+ },
576
+ {
577
+ "name": "stdout",
578
+ "output_type": "stream",
579
+ "text": [
580
+ " 60 0.0273 0.0519 0.0533\n",
581
+ "(b,c)=(5,0): exact p = 0.0625; Wald z = 2.236 -> p = 0.0253; bootstrap 2.5% quantile of b* = 1, P(b*=0) = 0.0064\n"
582
+ ]
583
+ }
584
+ ],
585
+ "source": [
586
+ "print('n_d size(exact) size(Wald z>1.96) size(bootstrap 95% CI excludes 0, MC)')\n",
587
+ "rs = np.random.RandomState(6); sizes = {}\n",
588
+ "for n_d in (5, 6, 8, 10, 12, 15, 20, 30, 60):\n",
589
+ " bs = np.arange(0, n_d + 1); pm = stats.binom.pmf(bs, n_d, 0.5)\n",
590
+ " s_exact = float((pm * np.array([stats.binomtest(min(x, n_d - x), n_d, 0.5).pvalue < alpha for x in bs])).sum())\n",
591
+ " s_wald = float((pm * (np.abs(2 * bs - n_d) / math.sqrt(n_d) >= za)).sum())\n",
592
+ " # bootstrap: resample the N problems => (b*, c*, rest*) ~ Multinomial(N, [b, c, rest]/N); CI of (b*-c*) from 400 draws; reject if CI excludes 0\n",
593
+ " rej = 0; reps = 1500\n",
594
+ " for r in range(reps):\n",
595
+ " bb = rs.binomial(n_d, 0.5); cc = n_d - bb\n",
596
+ " draws = rs.multinomial(N, [bb / N, cc / N, 1 - n_d / N], size=400); diff = draws[:, 0] - draws[:, 1]\n",
597
+ " lo, hi = np.percentile(diff, [2.5, 97.5]); rej += (lo > 0) or (hi < 0)\n",
598
+ " s_boot = rej / reps; sizes[n_d] = (s_exact, s_wald, s_boot)\n",
599
+ " print(f'{n_d:3d} {s_exact:9.4f} {s_wald:9.4f} {s_boot:9.4f}')\n",
600
+ "assert all(v[0] <= alpha + 1e-12 for v in sizes.values())\n",
601
+ "assert sizes[5][1] > alpha and sizes[8][1] > alpha # Wald exceeds its nominal size at n_d = 5 and 8\n",
602
+ "CHECKS.append(('6. exact McNemar size <= 0.05 for every n_d tested (max size %.4f)' % max(v[0] for v in sizes.values()), float(max(v[0] for v in sizes.values())), alpha))\n",
603
+ "z50 = 5 / math.sqrt(5); p_wald = 2 * (1 - stats.norm.cdf(z50))\n",
604
+ "p0 = (1 - 5 / N) ** N\n",
605
+ "draws = np.random.RandomState(8).multinomial(N, [5 / N, 0, 1 - 5 / N], size=200000)[:, 0]\n",
606
+ "print(f'(b,c)=(5,0): exact p = {float(mcnemar_exact_fraction(5,0)):.4f}; Wald z = {z50:.3f} -> p = {p_wald:.4f}; bootstrap 2.5% quantile of b* = {np.percentile(draws, 2.5):.0f}, P(b*=0) = {p0:.4f}')\n",
607
+ "CHECKS.append(('6. (5,0): exact p = %.4f, Wald p = %.4f (informational: they disagree about significance)' % (0.0625, p_wald), float(p_wald), float('inf')))"
608
+ ]
609
+ },
610
+ {
611
+ "cell_type": "markdown",
612
+ "id": "14039858",
613
+ "metadata": {},
614
+ "source": [
615
+ "## Verification table"
616
+ ]
617
+ },
618
+ {
619
+ "cell_type": "code",
620
+ "execution_count": 8,
621
+ "id": "09c2d874",
622
+ "metadata": {
623
+ "execution": {
624
+ "iopub.execute_input": "2026-10-14T09:32:58.065562Z",
625
+ "iopub.status.busy": "2026-10-14T09:32:58.065394Z",
626
+ "iopub.status.idle": "2026-10-14T09:32:58.069524Z",
627
+ "shell.execute_reply": "2026-10-14T09:32:58.068827Z"
628
+ }
629
+ },
630
+ "outputs": [
631
+ {
632
+ "name": "stdout",
633
+ "output_type": "stream",
634
+ "text": [
635
+ "\n",
636
+ "identity max abs err\n",
637
+ "1. exact McNemar p: rational tail vs scipy.binomtest (all b,c <= 16) 1.110e-16\n",
638
+ "1. exact McNemar p: rational tail vs sign-flip permutation enumeration (n_d <= 14) 0.000e+00\n",
639
+ "1. best possible p for a +5 net gain is (5,0): p = 1/16 > 0.05 (|p - 0.0625|) 0.000e+00\n",
640
+ "2. Wilson closed form vs numerical inversion of the score test (max |endpoint diff|, x=1..243) 4.905e-13\n",
641
+ "2. min exact coverage over p in [0.01,0.995]: Wilson / Wald / Clopper-Pearson = 0.9402 / 0.8634 / 0.9507 (informational) 9.402e-01\n",
642
+ "3. design effect identity Var_total / naive_Var = 1 + (m-1) rho 8.882e-16\n",
643
+ "3. empirical variance of the estimator vs [Var(p)+E p(1-p)/m]/N (relative) 3.303e-03\n",
644
+ "3. cluster SE^2 is unbiased for the variance (relative) 3.929e-05\n",
645
+ "3. coverage of nominal-95% CI: cluster bootstrap 0.950, naive 0.585 (informational) 9.500e-01\n",
646
+ "4. paired variance formula vs empirical (relative) 3.864e-03\n",
647
+ "5. exact power vs normal approximation at d=0.10, N=244 (abs diff; the normal formula ignores discreteness) 4.952e-02\n",
648
+ "5. exact power (d=0.10) vs Monte Carlo over 4e5 benchmarks (abs diff) 2.797e-04\n",
649
+ "5. vectorised threshold power = table-based exact power (d=0.10, N=244) 4.663e-15\n",
650
+ "5. required N (exact) vs normal approximation, d=0.10 (relative difference) 4.242e-02\n",
651
+ "5. N required for 80% power at delta=5/244, d=0.10: exact 1946, normal 1867 (informational) 1.946e+03\n",
652
+ "6. exact McNemar size <= 0.05 for every n_d tested (max size 0.0428) 4.277e-02\n",
653
+ "6. (5,0): exact p = 0.0625, Wald p = 0.0253 (informational: they disagree about significance) 2.535e-02\n",
654
+ "CHECKS_JSON:[[\"1. exact McNemar p: rational tail vs scipy.binomtest (all b,c <= 16)\", 1.1102230246251565e-16, 1e-12], [\"1. exact McNemar p: rational tail vs sign-flip permutation enumeration (n_d <= 14)\", 0.0, 1e-15], [\"1. best possible p for a +5 net gain is (5,0): p = 1/16 > 0.05 (|p - 0.0625|)\", 0.0, 0.0], [\"2. Wilson closed form vs numerical inversion of the score test (max |endpoint diff|, x=1..243)\", 4.904965322793942e-13, 1e-10], [\"2. min exact coverage over p in [0.01,0.995]: Wilson / Wald / Clopper-Pearson = 0.9402 / 0.8634 / 0.9507 (informational)\", 0.940211759420751, Infinity], [\"3. design effect identity Var_total / naive_Var = 1 + (m-1) rho\", 8.881784197001252e-16, 1e-12], [\"3. empirical variance of the estimator vs [Var(p)+E p(1-p)/m]/N (relative)\", 0.0033025628714560273, 0.03], [\"3. cluster SE^2 is unbiased for the variance (relative)\", 3.929333853491812e-05, 0.03], [\"3. coverage of nominal-95% CI: cluster bootstrap 0.950, naive 0.585 (informational)\", 0.95, Infinity], [\"4. paired variance formula vs empirical (relative)\", 0.0038637080259513734, 0.03], [\"5. exact power vs normal approximation at d=0.10, N=244 (abs diff; the normal formula ignores discreteness)\", 0.049521981277600394, 0.12], [\"5. exact power (d=0.10) vs Monte Carlo over 4e5 benchmarks (abs diff)\", 0.0002797189205828443, 0.003], [\"5. vectorised threshold power = table-based exact power (d=0.10, N=244)\", 4.6629367034256575e-15, 1e-12], [\"5. required N (exact) vs normal approximation, d=0.10 (relative difference)\", 0.042423303779374866, 0.05], [\"5. N required for 80% power at delta=5/244, d=0.10: exact 1946, normal 1867 (informational)\", 1946.0, Infinity], [\"6. exact McNemar size <= 0.05 for every n_d tested (max size 0.0428)\", 0.04277394525706773, 0.05], [\"6. (5,0): exact p = 0.0625, Wald p = 0.0253 (informational: they disagree about significance)\", 0.0253473186774682, Infinity]]\n"
655
+ ]
656
+ }
657
+ ],
658
+ "source": [
659
+ "print()\n",
660
+ "print(f'{\"identity\":98s} {\"max abs err\":>12s}')\n",
661
+ "for n_, e_, t_ in CHECKS: print(f'{n_:98s} {e_:12.3e}')\n",
662
+ "print('CHECKS_JSON:' + json.dumps(CHECKS))"
663
+ ]
664
+ }
665
+ ],
666
+ "metadata": {
667
+ "kernelspec": {
668
+ "display_name": "Python 3",
669
+ "language": "python",
670
+ "name": "python3"
671
+ },
672
+ "language_info": {
673
+ "codemirror_mode": {
674
+ "name": "ipython",
675
+ "version": 3
676
+ },
677
+ "file_extension": ".py",
678
+ "mimetype": "text/x-python",
679
+ "name": "python",
680
+ "nbconvert_exporter": "python",
681
+ "pygments_lexer": "ipython3",
682
+ "version": "3.12.11"
683
+ }
684
+ },
685
+ "nbformat": 4,
686
+ "nbformat_minor": 5
687
+ }
code/notebooks/0209-variance-components-equivalence.ipynb ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0209 - Variance components and equivalence tests for evaluation\n",
8
+ "Companion to `notes/0209 - Variance Components and Equivalence Tests.pdf`. CPU only, numpy/scipy, 1 thread.\n",
9
+ "Running toy: a benchmark of `N = 244` problems, `S` independently trained models (\"seeds\") of one recipe, `m = 16` samples per (seed, problem).\n",
10
+ "Per-(seed, problem) solve probability `p_sx = expit(mu_x + a_s + b_sx)`, `mu_x ~ N(0.3, 1.5^2)` (problem difficulty), `a_s ~ N(0, 0.25^2)` (seed), `b_sx ~ N(0, 0.5^2)` (seed x problem)."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": 1,
16
+ "metadata": {},
17
+ "source": [
18
+ "import os\n",
19
+ "os.environ[\"OMP_NUM_THREADS\"] = \"1\"; os.environ[\"OPENBLAS_NUM_THREADS\"] = \"1\"; os.environ[\"MKL_NUM_THREADS\"] = \"1\"\n",
20
+ "import math, itertools\n",
21
+ "from fractions import Fraction as Fr\n",
22
+ "from math import comb\n",
23
+ "import numpy as np\n",
24
+ "from scipy import stats\n",
25
+ "from scipy.special import expit\n",
26
+ "CHECKS = []\n",
27
+ "def check(name, err, tol, note=\"\"):\n",
28
+ " CHECKS.append((name, float(err), float(tol)))\n",
29
+ " assert err <= tol, (name, float(err), tol)\n",
30
+ " print(f\"[ok] {name:100s} err={float(err):.3e} (tol {tol:.1e}) {note}\")\n",
31
+ "N, m = 244, 16\n",
32
+ "M0, SX, SA, SB = 0.3, 1.5, 0.25, 0.5"
33
+ ],
34
+ "outputs": []
35
+ },
36
+ {
37
+ "cell_type": "markdown",
38
+ "metadata": {},
39
+ "source": [
40
+ "## 1. Variance components: problems (A), seeds (B), seed x problem (C), generation (e)\n",
41
+ "Exchangeable array `p_sx = mu + A_x + B_s + C_sx` (Hoeffding/ANOVA decomposition; components orthogonal). With outcomes `c_sx ~ Bin(m, p_sx)` and the score of one run `Y_s = (1/(N m)) sum_x c_sx`:\n",
42
+ "`Var(Y_s) = sA2/N + sB2 + sC2/N + se2/(N m)`, `se2 = E p(1-p)`. Only `sB2` survives `N -> inf` and `m -> inf`.\n",
43
+ "Population components by Gauss-Hermite integration of the logit-normal model (no simulation)."
44
+ ]
45
+ },
46
+ {
47
+ "cell_type": "code",
48
+ "execution_count": 2,
49
+ "metadata": {},
50
+ "source": [
51
+ "gh_x, gh_w = np.polynomial.hermite_e.hermegauss(60); gh_w = gh_w / gh_w.sum() # standard normal nodes/weights\n",
52
+ "MU = M0 + SX * gh_x; A_ = SA * gh_x; B_ = SB * gh_x\n",
53
+ "P3 = expit(MU[:, None, None] + A_[None, :, None] + B_[None, None, :]) # [x, s, b]\n",
54
+ "W3 = gh_w[:, None, None] * gh_w[None, :, None] * gh_w[None, None, :]\n",
55
+ "Ep = (P3 * W3).sum(); Ep2 = (P3 ** 2 * W3).sum()\n",
56
+ "Ax = (P3 * gh_w[None, :, None] * gh_w[None, None, :]).sum((1, 2)) # E_{s,b} p | x (weights over s,b sum to 1)\n",
57
+ "Bs = (P3 * gh_w[:, None, None] * gh_w[None, None, :]).sum((0, 2)) # E_{x,b} p | s\n",
58
+ "sA2 = (gh_w * (Ax - Ep) ** 2).sum(); sB2 = (gh_w * (Bs - Ep) ** 2).sum()\n",
59
+ "sT2 = Ep2 - Ep ** 2; sC2 = sT2 - sA2 - sB2; se2 = Ep - Ep2\n",
60
+ "print(f\" E p = {Ep:.4f}; sigma_A^2 = {sA2:.5f}, sigma_B^2 = {sB2:.6f} (sd {math.sqrt(sB2):.4f}), sigma_C^2 = {sC2:.5f}, sigma_e^2 = E p(1-p) = {se2:.5f}\")\n",
61
+ "def var_Y(S, N=N, m=m): return sA2 / N + sB2 / S + sC2 / (N * S) + se2 / (N * S * m) # variance of a grand mean over S seeds\n",
62
+ "print(\" per-term contribution to Var(Y_s), S=1: problems %.3e seed %.3e seed*problem %.3e generation %.3e\" % (sA2 / N, sB2, sC2 / N, se2 / (N * m)))\n",
63
+ "check(\"1. components are non-negative and sum to Var(p)\", abs(sA2 + sB2 + sC2 - sT2) + max(0, -min(sA2, sB2, sC2)), 1e-15)\n",
64
+ "# simulate replications: each replication redraws problems, seeds, samples\n",
65
+ "rng = np.random.default_rng(0)\n",
66
+ "def simulate(R, S, N=N, m=m):\n",
67
+ " mu = M0 + SX * rng.standard_normal((R, 1, N)); a = SA * rng.standard_normal((R, S, 1)); b = SB * rng.standard_normal((R, S, N))\n",
68
+ " p = expit(mu + a + b)\n",
69
+ " c = rng.binomial(m, p)\n",
70
+ " return p, c\n",
71
+ "R1 = 6000\n",
72
+ "p, c = simulate(R1, 1)\n",
73
+ "Y = c[:, 0, :].sum(1) / (N * m)\n",
74
+ "se_rel = math.sqrt(2 / (R1 - 1))\n",
75
+ "check(\"1. Var(Y_s) over 6000 replications vs sA2/N + sB2 + sC2/N + se2/(Nm)\", abs(Y.var(ddof=1) / var_Y(1) - 1), 4 * se_rel, f\"emp {Y.var(ddof=1):.4e} vs {var_Y(1):.4e}\")\n",
76
+ "# MoM / ANOVA estimators with S=4 seeds\n",
77
+ "S = 4; R2 = 4000\n",
78
+ "p, c = simulate(R2, S)\n",
79
+ "ybar = c / m # cell means [R,S,N]\n",
80
+ "gm = ybar.mean((1, 2), keepdims=True)\n",
81
+ "MSx = S * ((ybar.mean(1) - gm[:, :, 0]) ** 2).sum(1) / (N - 1)\n",
82
+ "MSs = N * ((ybar.mean(2) - gm[:, :, 0]) ** 2).sum(1) / (S - 1)\n",
83
+ "res = ybar - ybar.mean(1, keepdims=True) - ybar.mean(2, keepdims=True) + gm\n",
84
+ "MSr = (res ** 2).sum((1, 2)) / ((S - 1) * (N - 1))\n",
85
+ "pq = (c * (m - c) / (m * (m - 1))).mean((1, 2)) # unbiased for E p(1-p)\n",
86
+ "est = {\"sigma_e^2\": (pq, se2), \"sigma_C^2\": (MSr - pq / m, sC2), \"sigma_A^2\": ((MSx - MSr) / S, sA2), \"sigma_B^2\": ((MSs - MSr) / N, sB2)}\n",
87
+ "for k, (v, t) in est.items():\n",
88
+ " z = (v.mean() - t) / (v.std(ddof=1) / math.sqrt(R2))\n",
89
+ " check(f\"1. ANOVA estimator of {k} is unbiased (4000 reps, S=4)\", abs(z), 4.0, f\"mean {v.mean():.6f} vs true {t:.6f} (z={z:+.2f})\")"
90
+ ],
91
+ "outputs": [
92
+ {
93
+ "output_type": "stream",
94
+ "name": "stdout",
95
+ "text": [
96
+ " E p = 0.5512; sigma_A^2 = 0.06735, sigma_B^2 = 0.001800 (sd 0.0424), sigma_C^2 = 0.00852, sigma_e^2 = E p(1-p) = 0.16971\n",
97
+ " per-term contribution to Var(Y_s), S=1: problems 2.760e-04 seed 1.800e-03 seed*problem 3.490e-05 generation 4.347e-05\n",
98
+ "[ok] 1. components are non-negative and sum to Var(p) err=0.000e+00 (tol 1.0e-15) \n",
99
+ "[ok] 1. Var(Y_s) over 6000 replications vs sA2/N + sB2 + sC2/N + se2/(Nm) err=2.987e-02 (tol 7.3e-02) emp 2.2191e-03 vs 2.1547e-03\n",
100
+ "[ok] 1. ANOVA estimator of sigma_e^2 is unbiased (4000 reps, S=4) err=9.497e-01 (tol 4.0e+00) mean 0.169776 vs true 0.169709 (z=+0.95)\n",
101
+ "[ok] 1. ANOVA estimator of sigma_C^2 is unbiased (4000 reps, S=4) err=1.471e-01 (tol 4.0e+00) mean 0.008513 vs true 0.008515 (z=-0.15)\n",
102
+ "[ok] 1. ANOVA estimator of sigma_A^2 is unbiased (4000 reps, S=4) err=6.625e-01 (tol 4.0e+00) mean 0.067303 vs true 0.067352 (z=-0.66)\n",
103
+ "[ok] 1. ANOVA estimator of sigma_B^2 is unbiased (4000 reps, S=4) err=1.567e+00 (tol 4.0e+00) mean 0.001764 vs true 0.001800 (z=-1.57)\n"
104
+ ]
105
+ }
106
+ ]
107
+ },
108
+ {
109
+ "cell_type": "markdown",
110
+ "metadata": {},
111
+ "source": [
112
+ "## 2. Which term do you pay down with what? Two recipes, S seeds each, same problems\n",
113
+ "`delta_hat = Ybar_A - Ybar_B`. Under H0 (same recipe) the shared problem effect A_x cancels: `Var(delta_hat) = (2/S) [ sB2 + sC2/N + se2/(N m) ]`.\n",
114
+ "The seed term does not shrink with N or m: a floor on the minimum detectable effect."
115
+ ]
116
+ },
117
+ {
118
+ "cell_type": "code",
119
+ "execution_count": 3,
120
+ "metadata": {},
121
+ "source": [
122
+ "def var_diff(S, N=N, m=m): return (2 / S) * (sB2 + sC2 / N + se2 / (N * m))\n",
123
+ "R3 = 6000\n",
124
+ "for S in (1, 3):\n",
125
+ " mu = M0 + SX * rng.standard_normal((R3, 1, N))\n",
126
+ " d = []\n",
127
+ " for _ in range(2):\n",
128
+ " a = SA * rng.standard_normal((R3, S, 1)); b = SB * rng.standard_normal((R3, S, N))\n",
129
+ " cc = rng.binomial(m, expit(mu + a + b)); d.append(cc.sum((1, 2)) / (S * N * m))\n",
130
+ " dd = d[0] - d[1]\n",
131
+ " check(f\"2. Var(delta_hat) for S={S} seeds per recipe vs (2/S)[sB2 + sC2/N + se2/(Nm)]\", abs(dd.var(ddof=1) / var_diff(S) - 1), 4 * math.sqrt(2 / (R3 - 1)), f\"emp {dd.var(ddof=1):.3e} vs {var_diff(S):.3e}\")\n",
132
+ "z = stats.norm.ppf(0.975) + stats.norm.ppf(0.8)\n",
133
+ "for S in (1, 3, 5):\n",
134
+ " print(f\" S={S}: MDE(80% power, 5% two-sided) at N=244: {z*math.sqrt(var_diff(S))*100:.2f} pp; N->inf, m->inf floor: {z*math.sqrt(2*sB2/S)*100:.2f} pp\")\n",
135
+ "check(\"2. floor is strictly positive: MDE(N=1e9, m=1e9, S=3) equals z sqrt(2 sB2 / 3)\", abs(z * math.sqrt(var_diff(3, N=10**9, m=10**9)) - z * math.sqrt(2 * sB2 / 3)), 1e-6)"
136
+ ],
137
+ "outputs": [
138
+ {
139
+ "output_type": "stream",
140
+ "name": "stdout",
141
+ "text": [
142
+ "[ok] 2. Var(delta_hat) for S=1 seeds per recipe vs (2/S)[sB2 + sC2/N + se2/(Nm)] err=1.212e-02 (tol 7.3e-02) emp 3.803e-03 vs 3.757e-03\n",
143
+ "[ok] 2. Var(delta_hat) for S=3 seeds per recipe vs (2/S)[sB2 + sC2/N + se2/(Nm)] err=1.255e-02 (tol 7.3e-02) emp 1.237e-03 vs 1.252e-03\n",
144
+ " S=1: MDE(80% power, 5% two-sided) at N=244: 17.17 pp; N->inf, m->inf floor: 16.81 pp\n",
145
+ " S=3: MDE(80% power, 5% two-sided) at N=244: 9.91 pp; N->inf, m->inf floor: 9.71 pp\n",
146
+ " S=5: MDE(80% power, 5% two-sided) at N=244: 7.68 pp; N->inf, m->inf floor: 7.52 pp\n",
147
+ "[ok] 2. floor is strictly positive: MDE(N=1e9, m=1e9, S=3) equals z sqrt(2 sB2 / 3) err=2.295e-10 (tol 1.0e-06) \n"
148
+ ]
149
+ }
150
+ ]
151
+ },
152
+ {
153
+ "cell_type": "markdown",
154
+ "metadata": {},
155
+ "source": [
156
+ "## 3. Unbiased pass@k from n samples, and its exact variance\n",
157
+ "`e(n,c,k) = 1 - C(n-c,k)/C(n,k)`; `c ~ Bin(n,p)` => `E e = 1 - (1-p)^k`. Exact variance by summing over `c = 0..n`.\n",
158
+ "NAIVE plug-in `1 - (1 - c/n)^k` is biased. Special cases: `k = 1` gives `p(1-p)/n`; `k = n` gives `q(1-q)`, `q = 1-(1-p)^n`."
159
+ ]
160
+ },
161
+ {
162
+ "cell_type": "code",
163
+ "execution_count": 4,
164
+ "metadata": {},
165
+ "source": [
166
+ "def e_nck(n, c, k): return 1 - Fr(comb(n - c, k), comb(n, k))\n",
167
+ "def pk_moments(n, k, p):\n",
168
+ " p = Fr(p); pm = [Fr(comb(n, c)) * p ** c * (1 - p) ** (n - c) for c in range(n + 1)]\n",
169
+ " E1 = sum(w * e_nck(n, c, k) for c, w in enumerate(pm)); E2 = sum(w * e_nck(n, c, k) ** 2 for c, w in enumerate(pm))\n",
170
+ " return E1, E2 - E1 ** 2, sum(w * (1 - (1 - Fr(c, n)) ** k) for c, w in enumerate(pm))\n",
171
+ "worst = 0; worst_k1 = 0; worst_kn = 0\n",
172
+ "for n, k, p in [(64, 8, Fr(1, 10)), (64, 32, Fr(1, 50)), (16, 4, Fr(3, 10)), (64, 1, Fr(1, 7)), (64, 64, Fr(1, 100)), (10, 10, Fr(1, 5))]:\n",
173
+ " E1, V, Enaive = pk_moments(n, k, p)\n",
174
+ " worst = max(worst, abs(float(E1 - (1 - (1 - p) ** k))))\n",
175
+ " if k == 1: worst_k1 = max(worst_k1, abs(float(V - p * (1 - p) / n)))\n",
176
+ " if k == n: q = 1 - (1 - p) ** n; worst_kn = max(worst_kn, abs(float(V - q * (1 - q))))\n",
177
+ "check(\"3. E[e(n,c,k)] = 1-(1-p)^k exactly (6 (n,k,p) triples, rational arithmetic)\", worst, 1e-15)\n",
178
+ "check(\"3. k=1: Var = p(1-p)/n exactly\", worst_k1, 1e-15)\n",
179
+ "check(\"3. k=n: Var = q(1-q) with q = 1-(1-p)^n exactly\", worst_kn, 1e-15)\n",
180
+ "print(\" n=64: p k true pass@k E[unbiased] E[naive plug-in] (bias) sd(unbiased) sd/true\")\n",
181
+ "for p in (0.02, 0.1, 0.5):\n",
182
+ " for k in (1, 8, 32):\n",
183
+ " E1, V, En = pk_moments(64, k, Fr(p).limit_denominator(1000)); g = 1 - (1 - p) ** k\n",
184
+ " print(f\" {p:5.2f} {k:4d} {g:.5f} {float(E1):.5f} {float(En):.5f} ({float(En)-g:+.5f}) {math.sqrt(float(V)):.5f} {math.sqrt(float(V))/g:.3f}\")\n",
185
+ "E1, V, En = pk_moments(64, 32, Fr(1, 50))\n",
186
+ "check(\"3. naive plug-in is biased low at (n=64,k=32,p=.02) by more than 0.07\", 0.0 if (1 - (1 - 0.02) ** 32) - float(En) > 0.07 else 1.0, 0.0, f\"bias {float(En)-(1-0.98**32):+.4f}\")\n",
187
+ "# MC check of the exact variance (independent implementation via binomial draws) and of the problem-level decomposition\n",
188
+ "rng = np.random.default_rng(1)\n",
189
+ "n, k, p0 = 64, 8, 0.1\n",
190
+ "cs = rng.binomial(n, p0, size=2_000_000)\n",
191
+ "lg = np.array([float(e_nck(n, c_, k)) for c_ in range(n + 1)])\n",
192
+ "est = lg[cs]\n",
193
+ "Vex = float(pk_moments(n, k, Fr(1, 10))[1])\n",
194
+ "check(\"3. exact Var vs 2e6 Monte Carlo draws (n=64,k=8,p=.1)\", abs(est.var() / Vex - 1), 4 * math.sqrt(2 / 2e6))\n",
195
+ "# across problems: p_x ~ Beta(1.4, 0.7); Var(mean_x e_x) = (1/N)[Var_x(g(p_x)) + E_x Var(e | p_x)]\n",
196
+ "a_, b_ = 1.4, 0.7; k = 8; n = 64\n",
197
+ "xs = rng.beta(a_, b_, size=400_000) # for the population quantities\n",
198
+ "cs = rng.binomial(n, xs)\n",
199
+ "lgk = np.array([float(e_nck(n, c_, k)) for c_ in range(n + 1)])\n",
200
+ "es = lgk[cs]; gx = 1 - (1 - xs) ** k\n",
201
+ "pred = (gx.var() + (es - gx).var()) / N\n",
202
+ "reps = np.array([lgk[rng.binomial(n, rng.beta(a_, b_, size=N))].mean() for _ in range(6000)])\n",
203
+ "check(\"3. Var of the benchmark mean pass@8 (N=244, n=64) vs (1/N)[Var_x g + E Var(e|p)]\", abs(reps.var(ddof=1) / pred - 1), 5 * math.sqrt(2 / 5999) + 0.01, f\"emp {reps.var(ddof=1):.3e} vs {pred:.3e}\")"
204
+ ],
205
+ "outputs": [
206
+ {
207
+ "output_type": "stream",
208
+ "name": "stdout",
209
+ "text": [
210
+ "[ok] 3. E[e(n,c,k)] = 1-(1-p)^k exactly (6 (n,k,p) triples, rational arithmetic) err=0.000e+00 (tol 1.0e-15) \n",
211
+ "[ok] 3. k=1: Var = p(1-p)/n exactly err=0.000e+00 (tol 1.0e-15) \n",
212
+ "[ok] 3. k=n: Var = q(1-q) with q = 1-(1-p)^n exactly err=0.000e+00 (tol 1.0e-15) \n",
213
+ " n=64: p k true pass@k E[unbiased] E[naive plug-in] (bias) sd(unbiased) sd/true\n",
214
+ " 0.02 1 0.02000 0.02000 0.02000 (-0.00000) 0.01750 0.875\n",
215
+ " 0.02 8 0.14924 0.14924 0.14185 (-0.00739) 0.12202 0.818\n",
216
+ " 0.02 32 0.47612 0.47612 0.40018 (-0.07594) 0.32411 0.681\n",
217
+ " 0.10 1 0.10000 0.10000 0.10000 (+0.00000) 0.03750 0.375\n",
218
+ " 0.10 8 0.56953 0.56953 0.54892 (-0.02061) 0.14662 0.257\n",
219
+ " 0.10 32 0.96566 0.96566 0.92934 (-0.03633) 0.07300 0.076\n",
220
+ " 0.50 1 0.50000 0.50000 0.50000 (+0.00000) 0.06250 0.125\n",
221
+ " 0.50 8 0.99609 0.99609 0.99418 (-0.00191) 0.00474 0.005\n",
222
+ " 0.50 32 1.00000 1.00000 1.00000 (-0.00000) 0.00000 0.000\n",
223
+ "[ok] 3. naive plug-in is biased low at (n=64,k=32,p=.02) by more than 0.07 err=0.000e+00 (tol 0.0e+00) bias -0.0759\n",
224
+ "[ok] 3. exact Var vs 2e6 Monte Carlo draws (n=64,k=8,p=.1) err=9.304e-04 (tol 4.0e-03) \n",
225
+ "[ok] 3. Var of the benchmark mean pass@8 (N=244, n=64) vs (1/N)[Var_x g + E Var(e|p)] err=2.299e-02 (tol 1.0e-01) emp 6.460e-05 vs 6.315e-05\n"
226
+ ]
227
+ }
228
+ ]
229
+ },
230
+ {
231
+ "cell_type": "markdown",
232
+ "metadata": {},
233
+ "source": [
234
+ "## 4. Paired sign-flip test on per-problem differences\n",
235
+ "`d_x = e^A_x - e^B_x`, statistic `T = sum_x d_x`, null: each `d_x` symmetric about 0 independently => signs exchangeable: p = fraction of the `2^N` sign vectors with `|T'| >= |T|`.\n",
236
+ "Binary `d_x in {-1,0,1}`: reduces to exact McNemar (note 0208). Exact enumeration for N<=18; Monte Carlo for N=244; size under the null."
237
+ ]
238
+ },
239
+ {
240
+ "cell_type": "code",
241
+ "execution_count": 5,
242
+ "metadata": {},
243
+ "source": [
244
+ "def signflip_exact(d):\n",
245
+ " d = np.asarray(d, float); n = len(d)\n",
246
+ " signs = np.array(list(itertools.product([-1.0, 1.0], repeat=n)))\n",
247
+ " T = np.abs(signs @ d); return float((T >= abs(d.sum()) - 1e-12).mean())\n",
248
+ "def mcnemar(b, c):\n",
249
+ " nd = b + c\n",
250
+ " return 1.0 if nd == 0 else min(1.0, 2 * sum(comb(nd, i) for i in range(min(b, c) + 1)) / 2 ** nd)\n",
251
+ "worst = 0\n",
252
+ "for b_ in range(0, 8):\n",
253
+ " for c_ in range(0, 8):\n",
254
+ " if b_ + c_ == 0: continue\n",
255
+ " d = np.array([1] * b_ + [-1] * c_ + [0] * 3)\n",
256
+ " worst = max(worst, abs(signflip_exact(d) - mcnemar(b_, c_)))\n",
257
+ "check(\"4. sign-flip p on {-1,0,+1} equals exact McNemar for all b,c <= 7 (3 concordant zeros)\", worst, 1e-12)\n",
258
+ "check(\"4. (5,0): two-sided sign-flip p = 0.0625\", abs(signflip_exact([1] * 5) - 0.0625), 1e-15)\n",
259
+ "rng = np.random.default_rng(2)\n",
260
+ "def signflip_mc(d, B=20000):\n",
261
+ " d = np.asarray(d, float); s = rng.choice([-1.0, 1.0], size=(B, len(d))); T = np.abs(s @ d)\n",
262
+ " return (1 + (T >= abs(d.sum()) - 1e-12).sum()) / (B + 1)\n",
263
+ "d = rng.standard_normal(14) * 0.2 + 0.05\n",
264
+ "check(\"4. Monte Carlo p-value (B=20000) vs exact enumeration at N=14\", abs(signflip_mc(d) - signflip_exact(d)), 4 * 0.5 / math.sqrt(20000))\n",
265
+ "# size under the null: A and B are two draws of the same system; per-problem pass@8 estimators from n=64 samples with problem-specific p_x\n",
266
+ "reject = 0; R = 1500; N_ = 60\n",
267
+ "for r in range(R):\n",
268
+ " px = rng.beta(a_, b_, size=N_)\n",
269
+ " d = lgk[rng.binomial(n, px)] - lgk[rng.binomial(n, px)]\n",
270
+ " reject += signflip_mc(d, B=2000) <= 0.05\n",
271
+ "size = reject / R\n",
272
+ "check(\"4. size of the sign-flip test under H0 (N=60 problems, shared p_x, 1500 benchmarks)\", abs(size - 0.05) , 4 * math.sqrt(0.05 * 0.95 / R) + 0.01, f\"size {size:.4f}\")\n",
273
+ "# the null is symmetry, not mean zero: a skewed mean-zero d inflates/deflates size\n",
274
+ "reject = 0; R = 1500; N_ = 12\n",
275
+ "for r in range(R):\n",
276
+ " d = np.where(rng.random(N_) < 0.1, 9.0, -1.0) # mean = 0.1*9 - 0.9 = 0, strongly skewed\n",
277
+ " reject += signflip_exact(d) <= 0.05 if N_ <= 12 else 0\n",
278
+ "size_skew = reject / R\n",
279
+ "print(f\" skewed mean-zero differences (N=12): size = {size_skew:.4f} (nominal 0.05): the test's null is symmetry\")\n",
280
+ "check(\"4. skewed mean-zero null: size differs from 0.05 by more than 0.02 (the test is not a test of the mean)\", 0.0 if abs(size_skew - 0.05) > 0.02 else 1.0, 0.0)"
281
+ ],
282
+ "outputs": [
283
+ {
284
+ "output_type": "stream",
285
+ "name": "stdout",
286
+ "text": [
287
+ "[ok] 4. sign-flip p on {-1,0,+1} equals exact McNemar for all b,c <= 7 (3 concordant zeros) err=0.000e+00 (tol 1.0e-12) \n",
288
+ "[ok] 4. (5,0): two-sided sign-flip p = 0.0625 err=0.000e+00 (tol 1.0e-15) \n",
289
+ "[ok] 4. Monte Carlo p-value (B=20000) vs exact enumeration at N=14 err=6.842e-04 (tol 1.4e-02) \n",
290
+ "[ok] 4. size of the sign-flip test under H0 (N=60 problems, shared p_x, 1500 benchmarks) err=8.000e-03 (tol 3.3e-02) size 0.0580\n",
291
+ " skewed mean-zero differences (N=12): size = 0.2940 (nominal 0.05): the test's null is symmetry\n",
292
+ "[ok] 4. skewed mean-zero null: size differs from 0.05 by more than 0.02 (the test is not a test of the mean) err=0.000e+00 (tol 0.0e+00) \n"
293
+ ]
294
+ }
295
+ ]
296
+ },
297
+ {
298
+ "cell_type": "markdown",
299
+ "metadata": {},
300
+ "source": [
301
+ "## 5. Fresh seed on the same problems removes generation noise, not selection bias\n",
302
+ "K = 8 candidate checkpoints with *identical* true population quality theta (null). On the fixed benchmark each has a quenched problem-specific advantage\n",
303
+ "`beta_k ~ N(0, sC2/N)` (seed x problem) plus a fresh-generation error `g_k ~ N(0, se2/(N m))`. Select `k* = argmax (beta_k + g_k)`.\n",
304
+ "(i) report the same estimate: bias `sigma_tot c_K`; (ii) re-sample generations on the SAME problems: bias `E beta_{k*} = (sigma_b^2/sigma_tot) c_K`; (iii) fresh problems: bias 0.\n",
305
+ "`c_K = E max of K standard normals`."
306
+ ]
307
+ },
308
+ {
309
+ "cell_type": "code",
310
+ "execution_count": 6,
311
+ "metadata": {},
312
+ "source": [
313
+ "sb2 = sC2 / N; sg2 = se2 / (N * m); st2 = sb2 + sg2; K = 8\n",
314
+ "cK = float(np.mean(np.max(np.random.default_rng(3).standard_normal((2_000_000, K)), 1)))\n",
315
+ "from scipy import integrate\n",
316
+ "cK_exact = integrate.quad(lambda x: K * x * stats.norm.pdf(x) * stats.norm.cdf(x) ** (K - 1), -12, 12)[0]\n",
317
+ "check(\"5. c_8 = E max of 8 N(0,1): quadrature vs 2e6 Monte Carlo\", abs(cK_exact - cK), 5 * 1.0 / math.sqrt(2e6) , f\"c_8={cK_exact:.5f}\")\n",
318
+ "rng = np.random.default_rng(4); R = 400_000\n",
319
+ "beta = math.sqrt(sb2) * rng.standard_normal((R, K)); g1 = math.sqrt(sg2) * rng.standard_normal((R, K)); g2 = math.sqrt(sg2) * rng.standard_normal((R, K))\n",
320
+ "win = np.argmax(beta + g1, axis=1); ix = np.arange(R)\n",
321
+ "b_same = (beta + g1)[ix, win]; b_regen = (beta + g2)[ix, win]; b_fresh = (math.sqrt(sb2) * rng.standard_normal(R) + math.sqrt(sg2) * rng.standard_normal(R))\n",
322
+ "th_same, th_regen = math.sqrt(st2) * cK_exact, (sb2 / math.sqrt(st2)) * cK_exact\n",
323
+ "sd = math.sqrt(st2)\n",
324
+ "check(\"5(i). same-sample winner estimate bias vs sigma_tot c_K\", abs(b_same.mean() - th_same), 4 * sd / math.sqrt(R), f\"{b_same.mean()*100:.3f} pp vs {th_same*100:.3f} pp\")\n",
325
+ "check(\"5(ii). fresh generations on the same problems: bias vs (sb2/sigma_tot) c_K\", abs(b_regen.mean() - th_regen), 4 * sd / math.sqrt(R), f\"{b_regen.mean()*100:.3f} pp vs {th_regen*100:.3f} pp (retained fraction {th_regen/th_same:.3f} = sb2/sigma_tot^2 = {sb2/st2:.3f})\")\n",
326
+ "check(\"5(iii). fresh problems: bias 0\", abs(b_fresh.mean()), 4 * sd / math.sqrt(R))\n",
327
+ "check(\"5. retained fraction of the bias after regenerating = sb2/(sb2+sg2) (with m=16: generation is a minor term)\", abs(th_regen / th_same - sb2 / st2), 1e-12)"
328
+ ],
329
+ "outputs": [
330
+ {
331
+ "output_type": "stream",
332
+ "name": "stdout",
333
+ "text": [
334
+ "[ok] 5. c_8 = E max of 8 N(0,1): quadrature vs 2e6 Monte Carlo err=7.607e-04 (tol 3.5e-03) c_8=1.42360\n",
335
+ "[ok] 5(i). same-sample winner estimate bias vs sigma_tot c_K err=1.363e-05 (tol 5.6e-05) 1.262 pp vs 1.260 pp\n",
336
+ "[ok] 5(ii). fresh generations on the same problems: bias vs (sb2/sigma_tot) c_K err=5.363e-06 (tol 5.6e-05) 0.561 pp vs 0.561 pp (retained fraction 0.445 = sb2/sigma_tot^2 = 0.445)\n",
337
+ "[ok] 5(iii). fresh problems: bias 0 err=1.369e-05 (tol 5.6e-05) \n",
338
+ "[ok] 5. retained fraction of the bias after regenerating = sb2/(sb2+sg2) (with m=16: generation is a minor term) err=5.551e-17 (tol 1.0e-12) \n"
339
+ ]
340
+ }
341
+ ]
342
+ },
343
+ {
344
+ "cell_type": "markdown",
345
+ "metadata": {},
346
+ "source": [
347
+ "## 6. Equivalence (TOST) versus \"no significant difference\"; minimum detectable effect\n",
348
+ "`delta_hat ~ N(delta, s^2)`. Equivalence margin `Delta`. TOST: reject `H0: |delta| >= Delta` iff both one-sided tests reject: `p_TOST = max(p_L, p_U)`.\n",
349
+ "Duality: TOST at level alpha rejects iff the (1-2 alpha) CI lies inside `(-Delta, Delta)`. Power at `delta=0`: `2 Phi(Delta/s - z_{1-alpha}) - 1`."
350
+ ]
351
+ },
352
+ {
353
+ "cell_type": "code",
354
+ "execution_count": 7,
355
+ "metadata": {},
356
+ "source": [
357
+ "al = 0.05; zA = stats.norm.ppf(1 - al)\n",
358
+ "def tost_p(dh, s, Dl):\n",
359
+ " p_upper = stats.norm.cdf((dh - Dl) / s) # H0: delta >= Delta\n",
360
+ " p_lower = stats.norm.sf((dh + Dl) / s) # H0: delta <= -Delta\n",
361
+ " return np.maximum(p_lower, p_upper)\n",
362
+ "rng = np.random.default_rng(5)\n",
363
+ "dh = rng.normal(0, 0.03, 100000); s_ = rng.uniform(0.005, 0.03, 100000); Dl = 0.02\n",
364
+ "rej_p = tost_p(dh, s_, Dl) < al\n",
365
+ "rej_ci = (dh - zA * s_ > -Dl) & (dh + zA * s_ < Dl)\n",
366
+ "check(\"6. TOST p<alpha <=> 90% CI inside (-Delta, Delta) (100000 random cases)\", float((rej_p != rej_ci).sum()), 0.0)\n",
367
+ "# size at the boundary and power at delta = 0\n",
368
+ "R = 400_000\n",
369
+ "for dl in (Dl, -Dl):\n",
370
+ " sm = 0.008; x = rng.normal(dl, sm, R); r = (tost_p(x, sm, Dl) < al).mean()\n",
371
+ " check(f\"6. size at delta={dl:+.2f} (boundary of H0), s=0.008: TOST rejection prob = alpha\", abs(r - al), 4 * math.sqrt(al * (1 - al) / R), f\"{r:.4f}\")\n",
372
+ "sm = 0.008; x = rng.normal(0, sm, R); r = (tost_p(x, sm, Dl) < al).mean(); pw = 2 * stats.norm.cdf(Dl / sm - zA) - 1\n",
373
+ "check(\"6. power at delta=0: 2 Phi(Delta/s - z) - 1 vs MC\", abs(r - pw), 4 * math.sqrt(0.25 / R), f\"{r:.4f} vs {pw:.4f}\")\n",
374
+ "# difference test vs equivalence at a true effect larger than the margin: N=244, discordance d=0.1\n",
375
+ "dpair = 0.1; sd_pair = math.sqrt(dpair); s244 = sd_pair / math.sqrt(244)\n",
376
+ "true_delta = 0.03\n",
377
+ "x = rng.normal(true_delta, s244, R)\n",
378
+ "p_diff = 2 * stats.norm.sf(np.abs(x) / s244)\n",
379
+ "miss = (p_diff >= 0.05).mean(); fake_equiv = (tost_p(x, s244, Dl) < al).mean()\n",
380
+ "print(f\" N=244, d=0.10 (s = {s244:.4f}), true delta=+0.03 > Delta=0.02: difference test misses it {miss*100:.1f}% of the time; TOST declares equivalence {fake_equiv*100:.2f}%\")\n",
381
+ "check(\"6. 'not significant' is not equivalence: with a true effect beyond the margin, difference test fails to reject often, TOST seldom 'accepts' (<= alpha)\", 0.0 if (miss > 0.4 and fake_equiv <= al + 4 * math.sqrt(al * 0.95 / R)) else 1.0, 0.0)\n",
382
+ "# MDE (paired, one run per benchmark) and required N, cross-checked against note 0208's table (normal approximation, d = 0.10, delta = 5/244)\n",
383
+ "Nn = 244; mde = (stats.norm.ppf(0.975) + stats.norm.ppf(0.8)) * sd_pair / math.sqrt(Nn)\n",
384
+ "x = rng.normal(mde, s244, R); pw = (2 * stats.norm.sf(np.abs(x) / s244) < 0.05).mean()\n",
385
+ "check(\"6. MDE = (z_{.975}+z_{.8}) sigma_d/sqrt(N) gives 80% power (Gaussian paired mean)\", abs(pw - 0.8), 4 * math.sqrt(0.16 / R) + 0.0035, f\"MDE = {mde*Nn:.2f} problems, power {pw:.4f} (0208 exact trinomial: 14.3)\")\n",
386
+ "dlt = 5 / 244; d_ = 0.10\n",
387
+ "N_norm = ((stats.norm.ppf(0.975) * math.sqrt(d_) + stats.norm.ppf(0.8) * math.sqrt(d_ - dlt ** 2)) / dlt) ** 2\n",
388
+ "check(\"6. N for 80% power at delta=5/244, d=.10 equals note 0208's normal-approximation value 1867\", abs(round(N_norm) - 1867), 1, f\"N={N_norm:.1f}\")\n",
389
+ "Req = (( stats.norm.ppf(1 - al) + stats.norm.ppf(0.9)) * sd_pair / Dl) ** 2 # TOST 80% power at delta=0: Delta/s - z = z_{0.9}\n",
390
+ "pw_req = 2 * stats.norm.cdf(Dl / (sd_pair / math.sqrt(Req)) - zA) - 1\n",
391
+ "check(\"6. N for 80% TOST power at delta=0, Delta=0.02, d=.10: closed form reproduces 80% power\", abs(pw_req - 0.8), 1e-9, f\"N = {Req:.0f}\")\n",
392
+ "print(f\" (equivalence within 2 points needs N ~ {Req:.0f} problems at 10% discordance; detecting a 5-problem gain needs ~1867)\")"
393
+ ],
394
+ "outputs": [
395
+ {
396
+ "output_type": "stream",
397
+ "name": "stdout",
398
+ "text": [
399
+ "[ok] 6. TOST p<alpha <=> 90% CI inside (-Delta, Delta) (100000 random cases) err=0.000e+00 (tol 0.0e+00) \n",
400
+ "[ok] 6. size at delta=+0.02 (boundary of H0), s=0.008: TOST rejection prob = alpha err=4.675e-04 (tol 1.4e-03) 0.0495\n",
401
+ "[ok] 6. size at delta=-0.02 (boundary of H0), s=0.008: TOST rejection prob = alpha err=1.425e-04 (tol 1.4e-03) 0.0499\n",
402
+ "[ok] 6. power at delta=0: 2 Phi(Delta/s - z) - 1 vs MC err=7.526e-04 (tol 3.2e-03) 0.6083 vs 0.6075\n",
403
+ " N=244, d=0.10 (s = 0.0202), true delta=+0.03 > Delta=0.02: difference test misses it 68.4% of the time; TOST declares equivalence 0.00%\n",
404
+ "[ok] 6. 'not significant' is not equivalence: with a true effect beyond the margin, difference test fails to reject often, TOST seldom 'accepts' (<= alpha) err=0.000e+00 (tol 0.0e+00) \n",
405
+ "[ok] 6. MDE = (z_{.975}+z_{.8}) sigma_d/sqrt(N) gives 80% power (Gaussian paired mean) err=5.500e-04 (tol 6.0e-03) MDE = 13.84 problems, power 0.7994 (0208 exact trinomial: 14.3)\n",
406
+ "[ok] 6. N for 80% power at delta=5/244, d=.10 equals note 0208's normal-approximation value 1867 err=0.000e+00 (tol 1.0e+00) N=1866.8\n",
407
+ "[ok] 6. N for 80% TOST power at delta=0, Delta=0.02, d=.10: closed form reproduces 80% power err=2.220e-16 (tol 1.0e-09) N = 2141\n",
408
+ " (equivalence within 2 points needs N ~ 2141 problems at 10% discordance; detecting a 5-problem gain needs ~1867)\n"
409
+ ]
410
+ }
411
+ ]
412
+ },
413
+ {
414
+ "cell_type": "markdown",
415
+ "metadata": {},
416
+ "source": [
417
+ "## Summary"
418
+ ]
419
+ },
420
+ {
421
+ "cell_type": "code",
422
+ "execution_count": 8,
423
+ "metadata": {},
424
+ "source": [
425
+ "for n_, e_, t_ in CHECKS: print(f\"PASS {n_[:105]}\")\n",
426
+ "print(f\"\\n{len(CHECKS)} checks, all passed\")"
427
+ ],
428
+ "outputs": [
429
+ {
430
+ "output_type": "stream",
431
+ "name": "stdout",
432
+ "text": [
433
+ "PASS 1. components are non-negative and sum to Var(p)\n",
434
+ "PASS 1. Var(Y_s) over 6000 replications vs sA2/N + sB2 + sC2/N + se2/(Nm)\n",
435
+ "PASS 1. ANOVA estimator of sigma_e^2 is unbiased (4000 reps, S=4)\n",
436
+ "PASS 1. ANOVA estimator of sigma_C^2 is unbiased (4000 reps, S=4)\n",
437
+ "PASS 1. ANOVA estimator of sigma_A^2 is unbiased (4000 reps, S=4)\n",
438
+ "PASS 1. ANOVA estimator of sigma_B^2 is unbiased (4000 reps, S=4)\n",
439
+ "PASS 2. Var(delta_hat) for S=1 seeds per recipe vs (2/S)[sB2 + sC2/N + se2/(Nm)]\n",
440
+ "PASS 2. Var(delta_hat) for S=3 seeds per recipe vs (2/S)[sB2 + sC2/N + se2/(Nm)]\n",
441
+ "PASS 2. floor is strictly positive: MDE(N=1e9, m=1e9, S=3) equals z sqrt(2 sB2 / 3)\n",
442
+ "PASS 3. E[e(n,c,k)] = 1-(1-p)^k exactly (6 (n,k,p) triples, rational arithmetic)\n",
443
+ "PASS 3. k=1: Var = p(1-p)/n exactly\n",
444
+ "PASS 3. k=n: Var = q(1-q) with q = 1-(1-p)^n exactly\n",
445
+ "PASS 3. naive plug-in is biased low at (n=64,k=32,p=.02) by more than 0.07\n",
446
+ "PASS 3. exact Var vs 2e6 Monte Carlo draws (n=64,k=8,p=.1)\n",
447
+ "PASS 3. Var of the benchmark mean pass@8 (N=244, n=64) vs (1/N)[Var_x g + E Var(e|p)]\n",
448
+ "PASS 4. sign-flip p on {-1,0,+1} equals exact McNemar for all b,c <= 7 (3 concordant zeros)\n",
449
+ "PASS 4. (5,0): two-sided sign-flip p = 0.0625\n",
450
+ "PASS 4. Monte Carlo p-value (B=20000) vs exact enumeration at N=14\n",
451
+ "PASS 4. size of the sign-flip test under H0 (N=60 problems, shared p_x, 1500 benchmarks)\n",
452
+ "PASS 4. skewed mean-zero null: size differs from 0.05 by more than 0.02 (the test is not a test of the mean)\n",
453
+ "PASS 5. c_8 = E max of 8 N(0,1): quadrature vs 2e6 Monte Carlo\n",
454
+ "PASS 5(i). same-sample winner estimate bias vs sigma_tot c_K\n",
455
+ "PASS 5(ii). fresh generations on the same problems: bias vs (sb2/sigma_tot) c_K\n",
456
+ "PASS 5(iii). fresh problems: bias 0\n",
457
+ "PASS 5. retained fraction of the bias after regenerating = sb2/(sb2+sg2) (with m=16: generation is a minor ter\n",
458
+ "PASS 6. TOST p<alpha <=> 90% CI inside (-Delta, Delta) (100000 random cases)\n",
459
+ "PASS 6. size at delta=+0.02 (boundary of H0), s=0.008: TOST rejection prob = alpha\n",
460
+ "PASS 6. size at delta=-0.02 (boundary of H0), s=0.008: TOST rejection prob = alpha\n",
461
+ "PASS 6. power at delta=0: 2 Phi(Delta/s - z) - 1 vs MC\n",
462
+ "PASS 6. 'not significant' is not equivalence: with a true effect beyond the margin, difference test fails to r\n",
463
+ "PASS 6. MDE = (z_{.975}+z_{.8}) sigma_d/sqrt(N) gives 80% power (Gaussian paired mean)\n",
464
+ "PASS 6. N for 80% power at delta=5/244, d=.10 equals note 0208's normal-approximation value 1867\n",
465
+ "PASS 6. N for 80% TOST power at delta=0, Delta=0.02, d=.10: closed form reproduces 80% power\n",
466
+ "\n",
467
+ "33 checks, all passed\n"
468
+ ]
469
+ }
470
+ ]
471
+ }
472
+ ],
473
+ "metadata": {
474
+ "kernelspec": {
475
+ "display_name": "Python 3",
476
+ "language": "python",
477
+ "name": "python3"
478
+ },
479
+ "language_info": {
480
+ "name": "python",
481
+ "version": "3.12.11"
482
+ }
483
+ },
484
+ "nbformat": 4,
485
+ "nbformat_minor": 4
486
+ }
code/notebooks/0210-data-selection-contamination.ipynb ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0210 - Data selection and contamination\n",
8
+ "Companion to `notes/0210 - Data Selection and Contamination.pdf`. CPU only, numpy/scipy, 1 thread.\n",
9
+ "1. classifier-based harvesting (fastText-style) as a selection problem; 2. pass-rate band selection as importance weighting;\n",
10
+ "3. regression to the mean when the same samples select and evaluate; 4. MinHash / Jaccard estimation error, LSH, containment; 5. membership inference from NLL gaps."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": 1,
16
+ "metadata": {},
17
+ "source": [
18
+ "import os\n",
19
+ "os.environ[\"OMP_NUM_THREADS\"] = \"1\"; os.environ[\"OPENBLAS_NUM_THREADS\"] = \"1\"\n",
20
+ "import math, itertools\n",
21
+ "from math import comb\n",
22
+ "import numpy as np\n",
23
+ "from scipy import stats, special\n",
24
+ "CHECKS = []\n",
25
+ "def check(name, err, tol, note=\"\"):\n",
26
+ " CHECKS.append((name, float(err), float(tol)))\n",
27
+ " assert err <= tol, (name, float(err), tol)\n",
28
+ " print(f\"[ok] {name:104s} err={float(err):.3e} (tol {tol:.1e}) {note}\")\n",
29
+ "Phibar = stats.norm.sf"
30
+ ],
31
+ "outputs": []
32
+ },
33
+ {
34
+ "cell_type": "markdown",
35
+ "metadata": {},
36
+ "source": [
37
+ "## 1. A harvesting classifier is a detector; what you get depends on prevalence and on what a wrong page costs\n",
38
+ "Pool of `M` pages, a fraction `pi` relevant (\"math\"). Score `s ~ N(d', 1)` for relevant, `N(0, 1)` otherwise (binormal; AUC = Phi(d'/sqrt 2)).\n",
39
+ "Threshold `t`: TPR = Phibar(t - d'), FPR = Phibar(t). Precision = pi TPR / (pi TPR + (1-pi) FPR) -- depends on `pi`, not only on AUC.\n",
40
+ "Downstream value (assumed, to be *measured* by a training run): each selected relevant page gives `+g`, each irrelevant `-h` (dilution).\n",
41
+ "Net gain `G(t) = M [pi TPR g - (1-pi) FPR h]`. `G'(t) = 0` where the posterior `P(rel | s=t) = h/(g+h)`: a posterior cutoff, so `t* = d'/2 + ln[(1-pi) h /(pi g)]/d'`."
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "code",
46
+ "execution_count": 2,
47
+ "metadata": {},
48
+ "source": [
49
+ "rng = np.random.default_rng(0)\n",
50
+ "dprime, pi, M = 2.5, 0.01, 4_000_000\n",
51
+ "rel = rng.random(M) < pi\n",
52
+ "s = rng.standard_normal(M) + dprime * rel\n",
53
+ "t = 2.0\n",
54
+ "sel = s >= t\n",
55
+ "prec_mc = rel[sel].mean(); rec_mc = sel[rel].mean()\n",
56
+ "tpr, fpr = Phibar(t - dprime), Phibar(t)\n",
57
+ "prec_th = pi * tpr / (pi * tpr + (1 - pi) * fpr)\n",
58
+ "check(\"1. precision closed form vs Monte Carlo (4e6 pages)\", abs(prec_mc - prec_th), 4 * math.sqrt(prec_th * (1 - prec_th) / sel.sum()), f\"prec {prec_th:.4f}, recall {tpr:.4f}\")\n",
59
+ "check(\"1. recall closed form vs Monte Carlo\", abs(rec_mc - tpr), 4 * math.sqrt(tpr * (1 - tpr) / rel.sum()))\n",
60
+ "auc = stats.norm.cdf(dprime / math.sqrt(2))\n",
61
+ "print(f\" AUC={auc:.4f} (d'={dprime}); at a threshold with recall {tpr:.2f}: precision {prec_th:.3f} for pi={pi}; for pi=0.2 the same classifier gives {0.2*tpr/(0.2*tpr+0.8*fpr):.3f}\")\n",
62
+ "check(\"1. same classifier, same threshold: precision at pi=0.2 exceeds precision at pi=0.01 by > 0.4\", 0.0 if (0.2 * tpr / (0.2 * tpr + 0.8 * fpr)) - prec_th > 0.4 else 1.0, 0.0)\n",
63
+ "g_, h_ = 1.0, 1.0\n",
64
+ "ts = np.linspace(0, 6, 60001)\n",
65
+ "G = pi * Phibar(ts - dprime) * g_ - (1 - pi) * Phibar(ts) * h_\n",
66
+ "t_star = dprime / 2 + math.log((1 - pi) * h_ / (pi * g_)) / dprime\n",
67
+ "check(\"1. argmax of the net-gain curve vs closed-form posterior-cutoff threshold\", abs(ts[G.argmax()] - t_star), 2e-4, f\"t*={t_star:.4f}\")\n",
68
+ "post = lambda t: pi * stats.norm.pdf(t - dprime) / (pi * stats.norm.pdf(t - dprime) + (1 - pi) * stats.norm.pdf(t))\n",
69
+ "check(\"1. posterior P(rel | s=t*) = h/(g+h)\", abs(post(t_star) - h_ / (g_ + h_)), 1e-12)\n",
70
+ "def f1(t):\n",
71
+ " tp = pi * Phibar(t - dprime); fp = (1 - pi) * Phibar(t); fn = pi - tp\n",
72
+ " return 2 * tp / (2 * tp + fp + fn)\n",
73
+ "t_f1 = ts[np.array([f1(x) for x in ts]).argmax()]\n",
74
+ "Gof = lambda t: pi * Phibar(t - dprime) * g_ - (1 - pi) * Phibar(t) * h_\n",
75
+ "print(f\" max-F1 threshold {t_f1:.3f} vs max-gain threshold {t_star:.3f}; gain/M at F1-opt {Gof(t_f1):.3e} vs gain-opt {Gof(t_star):.3e}\")\n",
76
+ "check(\"1. the F1-optimal threshold is not the gain-optimal one (differ by > 0.2) and loses downstream gain\", 0.0 if (abs(t_f1 - t_star) > 0.2 and Gof(t_f1) < Gof(t_star)) else 1.0, 0.0)\n",
77
+ "# seed coverage and iteration: positives split into 'covered' (d_in) and 'uncovered' (d_out) clusters; each round adds a fraction gamma of uncovered clusters to the seed\n",
78
+ "d_in, d_out, gamma, tt = 3.0, 0.5, 0.5, 2.0\n",
79
+ "rec = []; cov = 0.3\n",
80
+ "for r in range(1, 6):\n",
81
+ " rec.append(cov * Phibar(tt - d_in) + (1 - cov) * Phibar(tt - d_out)); cov = cov + gamma * (1 - cov)\n",
82
+ "inc = np.diff(rec)\n",
83
+ "print(\" recall by round:\", np.round(rec, 4), \" increments:\", np.round(inc, 4), \" ratio of successive increments:\", np.round(inc[1:] / inc[:-1], 3))\n",
84
+ "check(\"1. iterative re-seeding: recall rises monotonically and increments shrink geometrically with ratio (1-gamma)\", max(abs(inc[1:] / inc[:-1] - (1 - gamma))), 1e-12)"
85
+ ],
86
+ "outputs": [
87
+ {
88
+ "output_type": "stream",
89
+ "name": "stdout",
90
+ "text": [
91
+ "[ok] 1. precision closed form vs Monte Carlo (4e6 pages) err=1.455e-04 (tol 4.9e-03) prec 0.2349, recall 0.6915\n",
92
+ "[ok] 1. recall closed form vs Monte Carlo err=2.719e-03 (tol 9.3e-03) \n",
93
+ " AUC=0.9615 (d'=2.5); at a threshold with recall 0.69: precision 0.235 for pi=0.01; for pi=0.2 the same classifier gives 0.884\n",
94
+ "[ok] 1. same classifier, same threshold: precision at pi=0.2 exceeds precision at pi=0.01 by > 0.4 err=0.000e+00 (tol 0.0e+00) \n",
95
+ "[ok] 1. argmax of the net-gain curve vs closed-form posterior-cutoff threshold err=4.794e-05 (tol 2.0e-04) t*=3.0880\n",
96
+ "[ok] 1. posterior P(rel | s=t*) = h/(g+h) err=1.110e-16 (tol 1.0e-12) \n",
97
+ " max-F1 threshold 2.626 vs max-gain threshold 3.088; gain/M at F1-opt 2.230e-04 vs gain-opt 1.785e-03\n",
98
+ "[ok] 1. the F1-optimal threshold is not the gain-optimal one (differ by > 0.2) and loses downstream gain err=0.000e+00 (tol 0.0e+00) \n",
99
+ " recall by round: [0.2992 0.5703 0.7058 0.7736 0.8075] increments: [0.2711 0.1355 0.0678 0.0339] ratio of successive increments: [0.5 0.5 0.5]\n",
100
+ "[ok] 1. iterative re-seeding: recall rises monotonically and increments shrink geometrically with ratio (1-gamma) err=6.661e-16 (tol 1.0e-12) \n"
101
+ ]
102
+ }
103
+ ]
104
+ },
105
+ {
106
+ "cell_type": "markdown",
107
+ "metadata": {},
108
+ "source": [
109
+ "## 2. Pass-rate band selection is importance weighting of the policy gradient\n",
110
+ "Bernoulli problem with logit parameter, `n` samples, `c` successes, `p_hat = c/n`. GRPO-style mean baseline gives per-problem gradient magnitude `ghat = p_hat (1 - p_hat)` (note 0206), `E ghat = (1 - 1/n) p(1-p)`.\n",
111
+ "Keeping the problem iff `p_hat` lies in a band `[lo, hi]` that is computed from the SAME samples multiplies the gradient by `1{p_hat in band}`:\n",
112
+ "effective weight on a problem of true pass rate p: `w_same(p) = E[ghat 1{band}] / (p(1-p))`. With an independent pilot estimate `p_hat'` instead: `w_ind(p) = (1 - 1/n) P[p_hat' in band]`."
113
+ ]
114
+ },
115
+ {
116
+ "cell_type": "code",
117
+ "execution_count": 3,
118
+ "metadata": {},
119
+ "source": [
120
+ "def bin_pmf(n, p): return np.array([comb(n, c) * p ** c * (1 - p) ** (n - c) for c in range(n + 1)])\n",
121
+ "n = 8; lo, hi = 1 / 8, 6 / 8\n",
122
+ "cs = np.arange(n + 1); ph = cs / n; inb = ((ph >= lo) & (ph <= hi)).astype(float); ghat = ph * (1 - ph)\n",
123
+ "rows = []\n",
124
+ "for p in (0.02, 0.1, 0.3, 0.5, 0.7, 0.9, 0.98):\n",
125
+ " pm = bin_pmf(n, p)\n",
126
+ " w_same = (pm * ghat * inb).sum() / (p * (1 - p)); w_ind = (1 - 1 / n) * (pm * inb).sum()\n",
127
+ " check_unb = abs((pm * ghat).sum() - (1 - 1 / n) * p * (1 - p))\n",
128
+ " assert check_unb < 1e-15\n",
129
+ " rows.append((p, w_same, w_ind)); print(f\" p={p:4.2f}: w_same={w_same:.4f} w_ind={w_ind:.4f} ratio={w_same/w_ind:.3f}\")\n",
130
+ "check(\"2. E[ghat] = (1-1/n) p(1-p) exactly (7 values of p)\", 0.0, 0.0)\n",
131
+ "check(\"2. same-sample selection weights exceed independent-pilot weights for every p in the table (selection on the statistic that defines the gradient)\", 0.0 if all(w1 > w2 for _, w1, w2 in rows) else 1.0, 0.0)\n",
132
+ "# MC check of w_same by simulating samples\n",
133
+ "p = 0.1; cs_mc = rng.binomial(n, p, size=2_000_000); phm = cs_mc / n\n",
134
+ "est = (phm * (1 - phm) * ((phm >= lo) & (phm <= hi))).mean() / (p * (1 - p)); th = [r for r in rows if r[0] == p][0][1]\n",
135
+ "check(\"2. w_same(p=0.1) exact vs 2e6 Monte Carlo\", abs(est / th - 1), 0.01)\n",
136
+ "# which problems does the band actually admit? contamination of the band by noise in p_hat (pool p ~ Beta(0.6,1.4), n = 8)\n",
137
+ "a_, b_ = 0.6, 1.4; band_p = (0.125, 0.75)\n",
138
+ "px = rng.beta(a_, b_, size=1_000_000); cx = rng.binomial(n, px); sel_ = (cx / n >= lo) & (cx / n <= hi)\n",
139
+ "frac_out = ((px[sel_] < band_p[0] - 1e-12) | (px[sel_] > band_p[1] + 1e-12)).mean()\n",
140
+ "# exact via beta-binomial\n",
141
+ "pmf_bb = np.array([comb(n, c) * special.beta(c + a_, n - c + b_) / special.beta(a_, b_) for c in range(n + 1)])\n",
142
+ "print(f\" fraction selected: {sel_.mean():.4f} (exact {pmf_bb[(cs>=1)&(cs<=6)].sum():.4f}); of the selected, true p outside [{band_p[0]},{band_p[1]}]: {frac_out:.3f}\")\n",
143
+ "check(\"2. beta-binomial exact P[band] vs MC\", abs(sel_.mean() - pmf_bb[(cs >= 1) & (cs <= 6)].sum()), 4 * math.sqrt(0.25 / 1e6))\n",
144
+ "check(\"2. a substantial share of selected problems lie outside the band in TRUE pass rate (> 15%)\", 0.0 if frac_out > 0.15 else 1.0, 0.0, f\"{frac_out:.3f}\")"
145
+ ],
146
+ "outputs": [
147
+ {
148
+ "output_type": "stream",
149
+ "name": "stdout",
150
+ "text": [
151
+ " p=0.02: w_same=0.8750 w_ind=0.1306 ratio=6.701\n",
152
+ " p=0.10: w_same=0.8750 w_ind=0.4983 ratio=1.756\n",
153
+ " p=0.30: w_same=0.8744 w_ind=0.8234 ratio=1.062\n",
154
+ " p=0.50: w_same=0.8613 w_ind=0.8408 ratio=1.024\n",
155
+ " p=0.70: w_same=0.7721 w_ind=0.6516 ratio=1.185\n",
156
+ " p=0.90: w_same=0.4100 w_ind=0.1635 ratio=2.507\n",
157
+ " p=0.98: w_same=0.0999 w_ind=0.0090 ratio=11.044\n",
158
+ "[ok] 2. E[ghat] = (1-1/n) p(1-p) exactly (7 values of p) err=0.000e+00 (tol 0.0e+00) \n",
159
+ "[ok] 2. same-sample selection weights exceed independent-pilot weights for every p in the table (selection on the statistic that defines the gradient) err=0.000e+00 (tol 0.0e+00) \n",
160
+ "[ok] 2. w_same(p=0.1) exact vs 2e6 Monte Carlo err=6.072e-04 (tol 1.0e-02) \n",
161
+ " fraction selected: 0.6231 (exact 0.6233); of the selected, true p outside [0.125,0.75]: 0.209\n",
162
+ "[ok] 2. beta-binomial exact P[band] vs MC err=2.182e-04 (tol 2.0e-03) \n",
163
+ "[ok] 2. a substantial share of selected problems lie outside the band in TRUE pass rate (> 15%) err=0.000e+00 (tol 0.0e+00) 0.209\n"
164
+ ]
165
+ }
166
+ ]
167
+ },
168
+ {
169
+ "cell_type": "markdown",
170
+ "metadata": {},
171
+ "source": [
172
+ "## 3. Regression to the mean: the same samples cannot select and evaluate\n",
173
+ "Pool `p ~ Beta(a, b)`, `c | p ~ Bin(n, p)`. Posterior `p | c ~ Beta(a + c, b + n - c)`, mean `(a + c)/(a + b + n)`.\n",
174
+ "Select the \"unsolved\" problems `c = 0`: observed rate 0, true mean `a/(a+b+n)` -- re-sampling them WITHOUT any training shows an apparent gain."
175
+ ]
176
+ },
177
+ {
178
+ "cell_type": "code",
179
+ "execution_count": 4,
180
+ "metadata": {},
181
+ "source": [
182
+ "a_, b_, n = 0.6, 1.4, 8\n",
183
+ "post_mean = lambda c: (a_ + c) / (a_ + b_ + n)\n",
184
+ "px = rng.beta(a_, b_, size=3_000_000); c1 = rng.binomial(n, px); c2 = rng.binomial(n, px) # c2: fresh samples, NO training between\n",
185
+ "for cc in (0, 1, 2):\n",
186
+ " selc = c1 == cc\n",
187
+ " emp = c2[selc].mean() / n; th = post_mean(cc)\n",
188
+ " check(f\"3. E[fresh pass rate | first-pass count c={cc}] = (a+c)/(a+b+n)\", abs(emp - th), 4 * math.sqrt(th * (1 - th) / (selc.sum() * n)), f\"observed first-pass rate {cc/n:.3f} -> fresh {emp:.4f} (theory {th:.4f}); apparent 'gain' {emp - cc/n:+.4f} with no training\")\n",
189
+ "# difference-in-differences with an untrained control from the same selection\n",
190
+ "tau = 0.03; sel0 = c1 == 0\n",
191
+ "idx = np.flatnonzero(sel0); half = rng.random(idx.size) < 0.5 # random split of the selected problems into trained / untrained\n",
192
+ "p_after = np.where(half, np.minimum(px[idx] + tau * (1 - px[idx]), 1.0), px[idx]) # training raises p by tau (1-p) on the trained half only\n",
193
+ "c_after = rng.binomial(n, p_after) / n\n",
194
+ "naive = c_after[half].mean() - 0.0; control = c_after[~half].mean() - 0.0\n",
195
+ "did = naive - control\n",
196
+ "tau_eff = (tau * (1 - px[idx[half]])).mean()\n",
197
+ "se = math.sqrt(c_after[half].var() / half.sum() / n + c_after[~half].var() / (~half).sum() / n) + 1e-9\n",
198
+ "print(f\" trained-half before/after = {naive:+.4f} (true effect {tau_eff:+.4f}); untrained control 'gain' = {control:+.4f}; DiD = {did:+.4f}\")\n",
199
+ "check(\"3. naive before/after overstates the effect (regression to the mean) and DiD recovers it\", abs(did - tau_eff), 0.003, f\"naive {naive:+.4f}, DiD {did:+.4f}, true {tau_eff:+.4f}\")\n",
200
+ "check(\"3. naive before/after is inflated by RTM: excess over truth exceeds 0.03\", 0.0 if (naive - tau_eff) > 0.03 else 1.0, 0.0)\n",
201
+ "# split-sample selection: select with set 1, evaluate with independent set 2 -> E[fresh | selected] is what an untrained re-evaluation gives (no gain)"
202
+ ],
203
+ "outputs": [
204
+ {
205
+ "output_type": "stream",
206
+ "name": "stdout",
207
+ "text": [
208
+ "[ok] 3. E[fresh pass rate | first-pass count c=0] = (a+c)/(a+b+n) err=8.957e-05 (tol 3.6e-04) observed first-pass rate 0.000 -> fresh 0.0599 (theory 0.0600); apparent 'gain' +0.0599 with no training\n",
209
+ "[ok] 3. E[fresh pass rate | first-pass count c=1] = (a+c)/(a+b+n) err=3.516e-04 (tol 7.3e-04) observed first-pass rate 0.125 -> fresh 0.1604 (theory 0.1600); apparent 'gain' +0.0354 with no training\n",
210
+ "[ok] 3. E[fresh pass rate | first-pass count c=2] = (a+c)/(a+b+n) err=1.404e-04 (tol 1.0e-03) observed first-pass rate 0.250 -> fresh 0.2599 (theory 0.2600); apparent 'gain' +0.0099 with no training\n",
211
+ " trained-half before/after = +0.0882 (true effect +0.0282); untrained control 'gain' = +0.0600; DiD = +0.0282\n",
212
+ "[ok] 3. naive before/after overstates the effect (regression to the mean) and DiD recovers it err=9.032e-06 (tol 3.0e-03) naive +0.0882, DiD +0.0282, true +0.0282\n",
213
+ "[ok] 3. naive before/after is inflated by RTM: excess over truth exceeds 0.03 err=0.000e+00 (tol 0.0e+00) \n"
214
+ ]
215
+ }
216
+ ]
217
+ },
218
+ {
219
+ "cell_type": "markdown",
220
+ "metadata": {},
221
+ "source": [
222
+ "## 4. MinHash: unbiased Jaccard with variance J(1-J)/k; LSH banding; containment is a different quantity\n",
223
+ "`Jhat = (1/k) sum_i 1{minhash_i(A) = minhash_i(B)}`. For a uniformly random permutation `P[min equal] = J`, so `k Jhat ~ Bin(k, J)`: unbiased, `Var = J(1-J)/k`."
224
+ ]
225
+ },
226
+ {
227
+ "cell_type": "code",
228
+ "execution_count": 5,
229
+ "metadata": {},
230
+ "source": [
231
+ "def jaccard(A, B): return len(A & B) / len(A | B)\n",
232
+ "U = 3000; rng = np.random.default_rng(7)\n",
233
+ "A = set(rng.choice(U, 400, replace=False).tolist()); share = 240\n",
234
+ "B = set(list(A)[:share]) | set(rng.choice([u for u in range(U) if u not in A], 160, replace=False).tolist())\n",
235
+ "J = jaccard(A, B)\n",
236
+ "def minhash_perm(S, perms): return np.array([min(perm[x] for x in S) for perm in perms])\n",
237
+ "k = 128; reps = 3000\n",
238
+ "perm_bank = np.array([rng.permutation(U) for _ in range(k)]) # one fresh set of k permutations per replication below\n",
239
+ "ests = []\n",
240
+ "Al = np.array(sorted(A)); Bl = np.array(sorted(B))\n",
241
+ "for r in range(reps):\n",
242
+ " perms = rng.permuted(np.tile(np.arange(U), (k, 1)), axis=1)\n",
243
+ " ests.append((perms[:, Al].min(1) == perms[:, Bl].min(1)).mean())\n",
244
+ "ests = np.array(ests)\n",
245
+ "check(\"4. E[Jhat] = J (random permutations, k=128, 3000 replications)\", abs(ests.mean() - J), 4 * math.sqrt(J * (1 - J) / k / reps), f\"J={J:.4f}\")\n",
246
+ "check(\"4. Var(Jhat) = J(1-J)/k\", abs(ests.var(ddof=1) / (J * (1 - J) / k) - 1), 4 * math.sqrt(2 / (reps - 1)), f\"emp {ests.var(ddof=1):.3e} vs {J*(1-J)/k:.3e}\")\n",
247
+ "# practical universal hash h(x) = (a x + b) mod p (not truly random permutations)\n",
248
+ "P = 2_147_483_647\n",
249
+ "def uh_est(A_, B_, k, rg):\n",
250
+ " a = rg.integers(1, P, k); b = rg.integers(0, P, k)\n",
251
+ " Aa = np.array(sorted(A_), dtype=np.int64); Bb = np.array(sorted(B_), dtype=np.int64)\n",
252
+ " ha = ((a[:, None] * Aa[None, :] + b[:, None]) % P).min(1); hb = ((a[:, None] * Bb[None, :] + b[:, None]) % P).min(1)\n",
253
+ " return (ha == hb).mean()\n",
254
+ "rg = np.random.default_rng(11)\n",
255
+ "est_uh = np.array([uh_est(A, B, 128, rg) for _ in range(3000)])\n",
256
+ "# a linear hash is pairwise independent but NOT min-wise independent: a small bias is allowed and reported, not hidden\n",
257
+ "check(\"4. universal-hash MinHash (practical): |bias| < 0.01 (small but need not be zero; reported)\", abs(est_uh.mean() - J), 0.01, f\"bias {est_uh.mean()-J:+.4f} (MC s.e. {math.sqrt(J*(1-J)/128/3000):.4f})\")\n",
258
+ "for eps in (0.05, 0.02, 0.01):\n",
259
+ " kreq = math.ceil(stats.norm.ppf(0.975) ** 2 * 0.25 / eps ** 2); print(f\" worst-case (J=1/2) hashes for +-{eps} at 95%: k >= {kreq}\")\n",
260
+ "kk = 385; cover = 0.0\n",
261
+ "pm = stats.binom.pmf(np.arange(kk + 1), kk, 0.5); jh = np.arange(kk + 1) / kk\n",
262
+ "cover = pm[np.abs(jh - 0.5) <= 0.05].sum()\n",
263
+ "check(\"4. k=385 gives >= ~95% coverage of +-0.05 at J=1/2 (exact binomial)\", 0.0 if cover > 0.94 else 1.0, 0.0, f\"coverage {cover:.4f}\")\n",
264
+ "# decision at a decontamination threshold: flag if Jhat >= 0.8, k = 128\n",
265
+ "kk = 128\n",
266
+ "for Jt in (0.7, 0.75, 0.8, 0.85):\n",
267
+ " pflag = stats.binom.sf(math.ceil(0.8 * kk) - 1, kk, Jt)\n",
268
+ " print(f\" true J={Jt}: P[flag at Jhat>=0.8] = {pflag:.3f} (sd of Jhat {math.sqrt(Jt*(1-Jt)/kk):.3f})\")\n",
269
+ "Jt = 0.75; mc = (rng.binomial(kk, Jt, 2_000_000) / kk >= 0.8).mean(); ex = stats.binom.sf(math.ceil(0.8 * kk) - 1, kk, Jt)\n",
270
+ "check(\"4. exact flag probability at J=0.75 vs Monte Carlo\", abs(mc - ex), 4 * math.sqrt(ex * (1 - ex) / 2e6), f\"P[flag]={ex:.3f}: a 'near-duplicate' just under the threshold is flagged often\")\n",
271
+ "# LSH banding: b bands of r rows: P[candidate] = 1 - (1 - J^r)^b\n",
272
+ "b_, r_ = 20, 5\n",
273
+ "for Jt in (0.3, 0.5, 0.6, 0.8):\n",
274
+ " th = 1 - (1 - Jt ** r_) ** b_\n",
275
+ " bits = rng.random((400_000, b_, r_)) < Jt # each hash agrees w.p. J independently (random permutations)\n",
276
+ " mc = bits.all(2).any(1).mean()\n",
277
+ " check(f\"4. LSH banding P[candidate] at J={Jt}: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5)\", abs(mc - th), 4 * math.sqrt(th * (1 - th) / 400_000) + 1e-4, f\"{th:.4f}\")\n",
278
+ "print(f\" LSH threshold approx (1/b)^(1/r) = {(1/b_)**(1/r_):.3f}\")\n",
279
+ "# containment: a benchmark item (40 shingles) inside a long web page (2000 shingles): J tiny, containment 1\n",
280
+ "Aitem = set(range(40)); Bpage = set(range(2000))\n",
281
+ "Jc = jaccard(Aitem, Bpage); cont = len(Aitem & Bpage) / len(Aitem)\n",
282
+ "inter_from_J = Jc * (len(Aitem) + len(Bpage)) / (1 + Jc)\n",
283
+ "check(\"4. |A n B| = J(|A|+|B|)/(1+J): containment recoverable from Jaccard and the two sizes\", abs(inter_from_J - 40), 1e-9, f\"J={Jc:.4f} (flag threshold 0.8 would miss it), containment={cont:.2f}\")\n",
284
+ "# error amplification of the containment estimate via Jhat\n",
285
+ "kk = 128; Jh = rng.binomial(kk, Jc, 100_000) / kk\n",
286
+ "cont_hat = Jh * (40 + 2000) / (1 + Jh) / 40\n",
287
+ "print(f\" containment estimate from MinHash at k=128: mean {cont_hat.mean():.3f}, sd {cont_hat.std():.3f} (true 1.0): very noisy for tiny J\")\n",
288
+ "check(\"4. containment from MinHash at k=128 for tiny J has relative sd > 0.5\", 0.0 if cont_hat.std() > 0.5 else 1.0, 0.0, f\"sd {cont_hat.std():.2f}\")\n",
289
+ "# real text shingles: document with edits\n",
290
+ "rg = np.random.default_rng(5)\n",
291
+ "vocab = [f\"w{i}\" for i in range(3000)]\n",
292
+ "doc = list(rg.choice(vocab, 600)); doc2 = doc.copy()\n",
293
+ "for pos in rg.choice(600, 36, replace=False): doc2[pos] = rg.choice(vocab)\n",
294
+ "sh = lambda d, w=5: {\" \".join(d[i:i + w]) for i in range(len(d) - w + 1)}\n",
295
+ "S1, S2 = sh(doc), sh(doc2); Jx = jaccard(S1, S2)\n",
296
+ "hs = lambda S: {int.from_bytes(__import__('hashlib').blake2b(x.encode(), digest_size=8).digest(), 'little') % P for x in S}\n",
297
+ "H1, H2 = hs(S1), hs(S2)\n",
298
+ "est = np.array([uh_est(H1, H2, 256, rg) for _ in range(400)])\n",
299
+ "check(\"4. 5-word shingles of a 600-word doc with 6% word edits: MinHash(k=256) mean vs exact Jaccard\", abs(est.mean() - Jx), 4 * math.sqrt(Jx * (1 - Jx) / 256 / 400) + 0.003, f\"exact J={Jx:.4f}, est {est.mean():.4f}, sd {est.std():.4f}\")"
300
+ ],
301
+ "outputs": [
302
+ {
303
+ "output_type": "stream",
304
+ "name": "stdout",
305
+ "text": [
306
+ "[ok] 4. E[Jhat] = J (random permutations, k=128, 3000 replications) err=4.416e-04 (tol 3.2e-03) J=0.4286\n",
307
+ "[ok] 4. Var(Jhat) = J(1-J)/k err=3.114e-02 (tol 1.0e-01) emp 1.973e-03 vs 1.913e-03\n",
308
+ "[ok] 4. universal-hash MinHash (practical): |bias| < 0.01 (small but need not be zero; reported) err=3.666e-03 (tol 1.0e-02) bias +0.0037 (MC s.e. 0.0008)\n",
309
+ " worst-case (J=1/2) hashes for +-0.05 at 95%: k >= 385\n",
310
+ " worst-case (J=1/2) hashes for +-0.02 at 95%: k >= 2401\n",
311
+ " worst-case (J=1/2) hashes for +-0.01 at 95%: k >= 9604\n",
312
+ "[ok] 4. k=385 gives >= ~95% coverage of +-0.05 at J=1/2 (exact binomial) err=0.000e+00 (tol 0.0e+00) coverage 0.9474\n",
313
+ " true J=0.7: P[flag at Jhat>=0.8] = 0.005 (sd of Jhat 0.041)\n",
314
+ " true J=0.75: P[flag at Jhat>=0.8] = 0.090 (sd of Jhat 0.038)\n",
315
+ " true J=0.8: P[flag at Jhat>=0.8] = 0.500 (sd of Jhat 0.035)\n",
316
+ " true J=0.85: P[flag at Jhat>=0.8] = 0.936 (sd of Jhat 0.032)\n",
317
+ "[ok] 4. exact flag probability at J=0.75 vs Monte Carlo err=1.523e-04 (tol 8.1e-04) P[flag]=0.090: a 'near-duplicate' just under the threshold is flagged often\n",
318
+ "[ok] 4. LSH banding P[candidate] at J=0.3: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5) err=2.643e-04 (tol 1.4e-03) 0.0475\n",
319
+ "[ok] 4. LSH banding P[candidate] at J=0.5: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5) err=2.007e-04 (tol 3.3e-03) 0.4701\n",
320
+ "[ok] 4. LSH banding P[candidate] at J=0.6: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5) err=2.825e-04 (tol 2.6e-03) 0.8019\n",
321
+ "[ok] 4. LSH banding P[candidate] at J=0.8: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5) err=2.394e-05 (tol 2.2e-04) 0.9996\n",
322
+ " LSH threshold approx (1/b)^(1/r) = 0.549\n",
323
+ "[ok] 4. |A n B| = J(|A|+|B|)/(1+J): containment recoverable from Jaccard and the two sizes err=0.000e+00 (tol 1.0e-09) J=0.0200 (flag threshold 0.8 would miss it), containment=1.00\n",
324
+ " containment estimate from MinHash at k=128: mean 0.997, sd 0.603 (true 1.0): very noisy for tiny J\n",
325
+ "[ok] 4. containment from MinHash at k=128 for tiny J has relative sd > 0.5 err=0.000e+00 (tol 0.0e+00) sd 0.60\n",
326
+ "[ok] 4. 5-word shingles of a 600-word doc with 6% word edits: MinHash(k=256) mean vs exact Jaccard err=2.862e-03 (tol 9.2e-03) exact J=0.5872, est 0.5844, sd 0.0322\n"
327
+ ]
328
+ }
329
+ ]
330
+ },
331
+ {
332
+ "cell_type": "markdown",
333
+ "metadata": {},
334
+ "source": [
335
+ "## 5. Membership inference from NLL gaps\n",
336
+ "Members have lower NLL: `NLL_member ~ N(mu - Delta, sigma^2)`, non-member `N(mu, sigma^2)`. AUC of the threshold test = `Phi(Delta / (sigma sqrt 2))`; TPR at FPR `f`: `Phi(Delta/sigma - z_{1-f})`.\n",
337
+ "If examples differ in intrinsic difficulty `D_i ~ N(0, sD^2)` (added to every NLL), the raw-NLL AUC drops to `Phi(Delta/sqrt(2(sD^2+sigma^2)))`; a reference-calibrated score `NLL_target - NLL_ref` cancels `D_i`."
338
+ ]
339
+ },
340
+ {
341
+ "cell_type": "code",
342
+ "execution_count": 6,
343
+ "metadata": {},
344
+ "source": [
345
+ "rng = np.random.default_rng(21); R_ = 400_000\n",
346
+ "Delta, sig, sD, sref = 0.30, 0.25, 0.80, 0.25\n",
347
+ "mem = rng.normal(-Delta, sig, R_); non = rng.normal(0, sig, R_)\n",
348
+ "def auc_of(pos_low, neg):\n",
349
+ " # P[score(member) < score(non-member)] with MC estimate by ranks on a subsample\n",
350
+ " x = pos_low[:200000]; y = neg[:200000]\n",
351
+ " r = stats.rankdata(np.concatenate([x, y])); return 1 - (r[:len(x)].sum() - len(x) * (len(x) + 1) / 2) / (len(x) * len(y))\n",
352
+ "check(\"5. AUC = Phi(Delta/(sigma sqrt2)) (Gaussian NLL gap)\", abs(auc_of(mem, non) - stats.norm.cdf(Delta / (sig * math.sqrt(2)))), 0.003, f\"{stats.norm.cdf(Delta/(sig*math.sqrt(2))):.4f}\")\n",
353
+ "thr = np.quantile(non, 0.01); tpr_mc = (mem <= thr).mean(); tpr_th = stats.norm.cdf(Delta / sig - stats.norm.ppf(0.99))\n",
354
+ "check(\"5. TPR at 1% FPR = Phi(Delta/sigma - z_{0.99})\", abs(tpr_mc - tpr_th), 0.004, f\"{tpr_th:.4f}\")\n",
355
+ "Dm = rng.normal(0, sD, R_); Dn = rng.normal(0, sD, R_)\n",
356
+ "raw_m = Dm + mem; raw_n = Dn + non\n",
357
+ "auc_raw_th = stats.norm.cdf(Delta / math.sqrt(2 * (sD ** 2 + sig ** 2)))\n",
358
+ "check(\"5. difficulty heterogeneity: raw-NLL AUC = Phi(Delta/sqrt(2(sD^2+sigma^2)))\", abs(auc_of(raw_m, raw_n) - auc_raw_th), 0.003, f\"{auc_raw_th:.4f}\")\n",
359
+ "ref_m = Dm + rng.normal(0, sref, R_); ref_n = Dn + rng.normal(0, sref, R_)\n",
360
+ "cal_th = stats.norm.cdf(Delta / math.sqrt(2 * (sig ** 2 + sref ** 2)))\n",
361
+ "check(\"5. calibrated NLL - NLL_ref: AUC = Phi(Delta/sqrt(2(sigma^2+sref^2)))\", abs(auc_of(raw_m - ref_m, raw_n - ref_n) - cal_th), 0.003, f\"{cal_th:.4f}\")\n",
362
+ "print(f\" AUC: raw {auc_raw_th:.3f} -> calibrated {cal_th:.3f} (Delta={Delta}, sigma={sig}, sD={sD}, sref={sref})\")\n",
363
+ "# a real (tiny) language model: character n-gram, memorisation grows with order; ground-truth membership known\n",
364
+ "V = 12; L = 120; ndoc = 2000; alpha = 0.1; rng = np.random.default_rng(33)\n",
365
+ "logits = rng.normal(0, 1.5, (V, V, V)) # order-2 Markov source\n",
366
+ "def gen_docs(nd, rg):\n",
367
+ " taus = np.exp(rg.uniform(math.log(0.4), math.log(2.5), nd)); X = np.zeros((nd, L), dtype=np.int64); X[:, :2] = rg.integers(0, V, (nd, 2))\n",
368
+ " for t in range(2, L):\n",
369
+ " lg = logits[X[:, t - 2], X[:, t - 1]] / taus[:, None]; pr = np.exp(lg - lg.max(1, keepdims=True)); pr /= pr.sum(1, keepdims=True)\n",
370
+ " u = rg.random(nd)[:, None]; X[:, t] = (pr.cumsum(1) < u).sum(1).clip(max=V - 1)\n",
371
+ " return X, taus\n",
372
+ "Xm, tm = gen_docs(ndoc, rng); Xn, tn = gen_docs(ndoc, rng); Xr, _ = gen_docs(ndoc, rng)\n",
373
+ "def fit(X, order):\n",
374
+ " cnt = np.zeros((V ** order, V))\n",
375
+ " for t in range(4, L):\n",
376
+ " ctx = np.zeros(len(X), dtype=np.int64)\n",
377
+ " for j in range(order, 0, -1): ctx = ctx * V + X[:, t - j]\n",
378
+ " np.add.at(cnt, (ctx, X[:, t]), 1)\n",
379
+ " return cnt\n",
380
+ "def nll(cnt, X, order):\n",
381
+ " out = np.zeros(len(X))\n",
382
+ " for t in range(4, L):\n",
383
+ " ctx = np.zeros(len(X), dtype=np.int64)\n",
384
+ " for j in range(order, 0, -1): ctx = ctx * V + X[:, t - j]\n",
385
+ " c = cnt[ctx]; out -= np.log((c[np.arange(len(X)), X[:, t]] + alpha) / (c.sum(1) + alpha * V))\n",
386
+ " return out / (L - 4)\n",
387
+ "def auc_small(low, high): # P[low < high] (+0.5 ties)\n",
388
+ " r = stats.rankdata(np.concatenate([low, high])); n1 = len(low); return 1 - (r[:n1].sum() - n1 * (n1 + 1) / 2) / (n1 * len(high))\n",
389
+ "res = {}\n",
390
+ "for order in (1, 2, 3, 4):\n",
391
+ " cm = fit(Xm, order); cr = fit(Xr, order)\n",
392
+ " nm, nn = nll(cm, Xm, order), nll(cm, Xn, order)\n",
393
+ " rm, rn = nll(cr, Xm, order), nll(cr, Xn, order)\n",
394
+ " res[order] = (auc_small(nm, nn), auc_small(nm - rm, nn - rn), nn.mean() - nm.mean())\n",
395
+ " print(f\" order {order}: raw-NLL AUC = {res[order][0]:.3f}, calibrated AUC = {res[order][1]:.3f}, mean NLL gap (non - member) = {res[order][2]:.4f} nats/char\")\n",
396
+ "check(\"5. no memorisation capacity at order 1-2 (<= true order): raw AUC within 0.04 of 0.5\", max(abs(res[1][0] - 0.5), abs(res[2][0] - 0.5)), 0.04)\n",
397
+ "check(\"5. membership signal grows with model capacity: raw AUC order 4 > order 3 > order 2\", 0.0 if res[4][0] > res[3][0] > res[2][0] else 1.0, 0.0)\n",
398
+ "check(\"5. reference calibration helps at the high-capacity order: calibrated AUC > raw AUC + 0.05 (order 4)\", 0.0 if res[4][1] > res[4][0] + 0.05 else 1.0, 0.0, f\"{res[4][0]:.3f} -> {res[4][1]:.3f}\")\n",
399
+ "check(\"5. calibrated AUC increases with order and exceeds raw AUC at every order >= 2 (it detects even the small order-2 overfit that raw NLL hides)\", 0.0 if (res[1][1] < res[2][1] < res[3][1] < res[4][1] and all(res[o][1] > res[o][0] for o in (2, 3, 4))) else 1.0, 0.0)"
400
+ ],
401
+ "outputs": [
402
+ {
403
+ "output_type": "stream",
404
+ "name": "stdout",
405
+ "text": [
406
+ "[ok] 5. AUC = Phi(Delta/(sigma sqrt2)) (Gaussian NLL gap) err=3.136e-05 (tol 3.0e-03) 0.8019\n",
407
+ "[ok] 5. TPR at 1% FPR = Phi(Delta/sigma - z_{0.99}) err=1.543e-03 (tol 4.0e-03) 0.1300\n",
408
+ "[ok] 5. difficulty heterogeneity: raw-NLL AUC = Phi(Delta/sqrt(2(sD^2+sigma^2))) err=1.669e-04 (tol 3.0e-03) 0.5999\n",
409
+ "[ok] 5. calibrated NLL - NLL_ref: AUC = Phi(Delta/sqrt(2(sigma^2+sref^2))) err=7.603e-04 (tol 3.0e-03) 0.7257\n",
410
+ " AUC: raw 0.600 -> calibrated 0.726 (Delta=0.3, sigma=0.25, sD=0.8, sref=0.25)\n",
411
+ " order 1: raw-NLL AUC = 0.506, calibrated AUC = 0.555, mean NLL gap (non - member) = 0.0019 nats/char\n",
412
+ " order 2: raw-NLL AUC = 0.510, calibrated AUC = 0.675, mean NLL gap (non - member) = 0.0154 nats/char\n",
413
+ " order 3: raw-NLL AUC = 0.559, calibrated AUC = 0.832, mean NLL gap (non - member) = 0.1048 nats/char\n",
414
+ " order 4: raw-NLL AUC = 0.715, calibrated AUC = 0.923, mean NLL gap (non - member) = 0.4656 nats/char\n",
415
+ "[ok] 5. no memorisation capacity at order 1-2 (<= true order): raw AUC within 0.04 of 0.5 err=1.007e-02 (tol 4.0e-02) \n",
416
+ "[ok] 5. membership signal grows with model capacity: raw AUC order 4 > order 3 > order 2 err=0.000e+00 (tol 0.0e+00) \n",
417
+ "[ok] 5. reference calibration helps at the high-capacity order: calibrated AUC > raw AUC + 0.05 (order 4) err=0.000e+00 (tol 0.0e+00) 0.715 -> 0.923\n",
418
+ "[ok] 5. calibrated AUC increases with order and exceeds raw AUC at every order >= 2 (it detects even the small order-2 overfit that raw NLL hides) err=0.000e+00 (tol 0.0e+00) \n"
419
+ ]
420
+ }
421
+ ]
422
+ },
423
+ {
424
+ "cell_type": "markdown",
425
+ "metadata": {},
426
+ "source": [
427
+ "## Summary"
428
+ ]
429
+ },
430
+ {
431
+ "cell_type": "code",
432
+ "execution_count": 7,
433
+ "metadata": {},
434
+ "source": [
435
+ "for n_, e_, t_ in CHECKS: print(f\"PASS {n_[:110]}\")\n",
436
+ "print(f\"\\n{len(CHECKS)} checks, all passed\")"
437
+ ],
438
+ "outputs": [
439
+ {
440
+ "output_type": "stream",
441
+ "name": "stdout",
442
+ "text": [
443
+ "PASS 1. precision closed form vs Monte Carlo (4e6 pages)\n",
444
+ "PASS 1. recall closed form vs Monte Carlo\n",
445
+ "PASS 1. same classifier, same threshold: precision at pi=0.2 exceeds precision at pi=0.01 by > 0.4\n",
446
+ "PASS 1. argmax of the net-gain curve vs closed-form posterior-cutoff threshold\n",
447
+ "PASS 1. posterior P(rel | s=t*) = h/(g+h)\n",
448
+ "PASS 1. the F1-optimal threshold is not the gain-optimal one (differ by > 0.2) and loses downstream gain\n",
449
+ "PASS 1. iterative re-seeding: recall rises monotonically and increments shrink geometrically with ratio (1-gamma)\n",
450
+ "PASS 2. E[ghat] = (1-1/n) p(1-p) exactly (7 values of p)\n",
451
+ "PASS 2. same-sample selection weights exceed independent-pilot weights for every p in the table (selection on the s\n",
452
+ "PASS 2. w_same(p=0.1) exact vs 2e6 Monte Carlo\n",
453
+ "PASS 2. beta-binomial exact P[band] vs MC\n",
454
+ "PASS 2. a substantial share of selected problems lie outside the band in TRUE pass rate (> 15%)\n",
455
+ "PASS 3. E[fresh pass rate | first-pass count c=0] = (a+c)/(a+b+n)\n",
456
+ "PASS 3. E[fresh pass rate | first-pass count c=1] = (a+c)/(a+b+n)\n",
457
+ "PASS 3. E[fresh pass rate | first-pass count c=2] = (a+c)/(a+b+n)\n",
458
+ "PASS 3. naive before/after overstates the effect (regression to the mean) and DiD recovers it\n",
459
+ "PASS 3. naive before/after is inflated by RTM: excess over truth exceeds 0.03\n",
460
+ "PASS 4. E[Jhat] = J (random permutations, k=128, 3000 replications)\n",
461
+ "PASS 4. Var(Jhat) = J(1-J)/k\n",
462
+ "PASS 4. universal-hash MinHash (practical): |bias| < 0.01 (small but need not be zero; reported)\n",
463
+ "PASS 4. k=385 gives >= ~95% coverage of +-0.05 at J=1/2 (exact binomial)\n",
464
+ "PASS 4. exact flag probability at J=0.75 vs Monte Carlo\n",
465
+ "PASS 4. LSH banding P[candidate] at J=0.3: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5)\n",
466
+ "PASS 4. LSH banding P[candidate] at J=0.5: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5)\n",
467
+ "PASS 4. LSH banding P[candidate] at J=0.6: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5)\n",
468
+ "PASS 4. LSH banding P[candidate] at J=0.8: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5)\n",
469
+ "PASS 4. |A n B| = J(|A|+|B|)/(1+J): containment recoverable from Jaccard and the two sizes\n",
470
+ "PASS 4. containment from MinHash at k=128 for tiny J has relative sd > 0.5\n",
471
+ "PASS 4. 5-word shingles of a 600-word doc with 6% word edits: MinHash(k=256) mean vs exact Jaccard\n",
472
+ "PASS 5. AUC = Phi(Delta/(sigma sqrt2)) (Gaussian NLL gap)\n",
473
+ "PASS 5. TPR at 1% FPR = Phi(Delta/sigma - z_{0.99})\n",
474
+ "PASS 5. difficulty heterogeneity: raw-NLL AUC = Phi(Delta/sqrt(2(sD^2+sigma^2)))\n",
475
+ "PASS 5. calibrated NLL - NLL_ref: AUC = Phi(Delta/sqrt(2(sigma^2+sref^2)))\n",
476
+ "PASS 5. no memorisation capacity at order 1-2 (<= true order): raw AUC within 0.04 of 0.5\n",
477
+ "PASS 5. membership signal grows with model capacity: raw AUC order 4 > order 3 > order 2\n",
478
+ "PASS 5. reference calibration helps at the high-capacity order: calibrated AUC > raw AUC + 0.05 (order 4)\n",
479
+ "PASS 5. calibrated AUC increases with order and exceeds raw AUC at every order >= 2 (it detects even the small orde\n",
480
+ "\n",
481
+ "37 checks, all passed\n"
482
+ ]
483
+ }
484
+ ]
485
+ }
486
+ ],
487
+ "metadata": {
488
+ "kernelspec": {
489
+ "display_name": "Python 3",
490
+ "language": "python",
491
+ "name": "python3"
492
+ },
493
+ "language_info": {
494
+ "name": "python",
495
+ "version": "3.12.11"
496
+ }
497
+ },
498
+ "nbformat": 4,
499
+ "nbformat_minor": 4
500
+ }
code/notebooks/0301-curry-howard.ipynb ADDED
@@ -0,0 +1,773 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "0999126c",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0301 - Curry-Howard: propositions as types, proofs as terms\n",
9
+ "\n",
10
+ "Scratch-pad for note `0301 - Curry-Howard.pdf`. Same discipline as the Flash-Attention-101 scratch-pads:\n",
11
+ "the **naive** implementation first, then the **better** one, then an assertion that they agree.\n",
12
+ "\n",
13
+ "Plan\n",
14
+ "1. A 40-line kernel for the simply typed lambda calculus with `->` and `False` (`infer`, `normalize`).\n",
15
+ "2. Subject reduction (Boxed result 2) checked on thousands of random typed terms.\n",
16
+ "3. Inhabitation, NAIVE: depth-bounded enumeration of beta-normal terms. EFFICIENT: Dyckhoff's contraction-free\n",
17
+ " calculus G4ip with proof-term extraction. Assert the verdicts agree on every formula with <= 4 arrows.\n",
18
+ "4. Soundness (provable => classically valid, provable => forced in every Kripke chain) and the gap\n",
19
+ " (Peirce, double-negation elimination: classically valid, not inhabited).\n",
20
+ "5. What the Lean kernel actually sees: `#print axioms` for each way of \"proving\" something; the axiom gate.\n",
21
+ "\n",
22
+ "No GPU is used (`CUDA_VISIBLE_DEVICES` is cleared). Lean calls go through `align.verify.lean` with ONE worker, nice 15."
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "code",
27
+ "execution_count": 1,
28
+ "id": "d045e611",
29
+ "metadata": {
30
+ "execution": {
31
+ "iopub.execute_input": "2026-10-14T09:53:55.822833Z",
32
+ "iopub.status.busy": "2026-10-14T09:53:55.822602Z",
33
+ "iopub.status.idle": "2026-10-14T09:53:55.843322Z",
34
+ "shell.execute_reply": "2026-10-14T09:53:55.842326Z"
35
+ }
36
+ },
37
+ "outputs": [
38
+ {
39
+ "name": "stdout",
40
+ "output_type": "stream",
41
+ "text": [
42
+ "K : A → B → A\n",
43
+ "S : (A → B → C) → (A → B) → A → C\n"
44
+ ]
45
+ }
46
+ ],
47
+ "source": [
48
+ "import os\n",
49
+ "os.environ[\"CUDA_VISIBLE_DEVICES\"] = \"\"\n",
50
+ "import itertools, random, time, math, sys\n",
51
+ "from collections import Counter\n",
52
+ "random.seed(0)\n",
53
+ "\n",
54
+ "# Types: ('atom', name) | ('bot',) | ('arr', A, B) Terms: ('var',x) | ('lam',x,A,t) | ('app',f,u) | ('abort',A,t)\n",
55
+ "BOT = ('bot',)\n",
56
+ "def atom(n): return ('atom', n)\n",
57
+ "def arr(a, b): return ('arr', a, b)\n",
58
+ "def show_ty(t):\n",
59
+ " if t[0] == 'atom': return t[1]\n",
60
+ " if t[0] == 'bot': return '⊥'\n",
61
+ " l = show_ty(t[1]); l = f'({l})' if t[1][0] == 'arr' else l\n",
62
+ " return f'{l} → {show_ty(t[2])}'\n",
63
+ "def show_tm(t):\n",
64
+ " if t[0] == 'var': return t[1]\n",
65
+ " if t[0] == 'lam': return f'(λ{t[1]}:{show_ty(t[2])}. {show_tm(t[3])})'\n",
66
+ " if t[0] == 'app': return f'({show_tm(t[1])} {show_tm(t[2])})'\n",
67
+ " return f'(abort[{show_ty(t[1])}] {show_tm(t[2])})'\n",
68
+ "\n",
69
+ "class TypeErr(Exception): pass\n",
70
+ "\n",
71
+ "def infer(ctx, t):\n",
72
+ " \"\"\"Typing judgement ctx |- t : A (ctx: dict var->type). Rules: Var, ->I, ->E, bot-E.\"\"\"\n",
73
+ " k = t[0]\n",
74
+ " if k == 'var':\n",
75
+ " if t[1] not in ctx: raise TypeErr(f'unbound {t[1]}')\n",
76
+ " return ctx[t[1]]\n",
77
+ " if k == 'lam':\n",
78
+ " _, x, A, body = t\n",
79
+ " return arr(A, infer({**ctx, x: A}, body))\n",
80
+ " if k == 'app':\n",
81
+ " F, U = infer(ctx, t[1]), infer(ctx, t[2])\n",
82
+ " if F[0] != 'arr' or F[1] != U: raise TypeErr(f'cannot apply {show_ty(F)} to {show_ty(U)}')\n",
83
+ " return F[2]\n",
84
+ " if k == 'abort':\n",
85
+ " if infer(ctx, t[2]) != BOT: raise TypeErr('abort needs a proof of ⊥')\n",
86
+ " return t[1]\n",
87
+ " raise TypeErr(k)\n",
88
+ "\n",
89
+ "_fresh = itertools.count()\n",
90
+ "KINDS = Counter() # which reduction rule fired (coverage of the SR check)\n",
91
+ "def fv(t):\n",
92
+ " k = t[0]\n",
93
+ " if k == 'var': return {t[1]}\n",
94
+ " if k == 'lam': return fv(t[3]) - {t[1]}\n",
95
+ " if k == 'app': return fv(t[1]) | fv(t[2])\n",
96
+ " return fv(t[2])\n",
97
+ "\n",
98
+ "def subst(t, x, s):\n",
99
+ " \"\"\"Capture-avoiding substitution t[x := s].\"\"\"\n",
100
+ " k = t[0]\n",
101
+ " if k == 'var': return s if t[1] == x else t\n",
102
+ " if k == 'app': return ('app', subst(t[1], x, s), subst(t[2], x, s))\n",
103
+ " if k == 'abort': return ('abort', t[1], subst(t[2], x, s))\n",
104
+ " _, y, A, b = t\n",
105
+ " if y == x: return t\n",
106
+ " if y in fv(s):\n",
107
+ " z = f'{y}_{next(_fresh)}'\n",
108
+ " b = subst(b, y, ('var', z)); y = z\n",
109
+ " return ('lam', y, A, subst(b, x, s))\n",
110
+ "\n",
111
+ "def step(t):\n",
112
+ " \"\"\"One leftmost-outermost step: beta, and the commuting conversion (abort[A->B] e) u ~> abort[B] e. None if normal.\"\"\"\n",
113
+ " k = t[0]\n",
114
+ " if k == 'app':\n",
115
+ " f, u = t[1], t[2]\n",
116
+ " if f[0] == 'lam': KINDS['beta'] += 1; return subst(f[3], f[1], u)\n",
117
+ " if f[0] == 'abort' and f[1][0] == 'arr': KINDS['comm'] += 1; return ('abort', f[1][2], f[2])\n",
118
+ " r = step(f)\n",
119
+ " if r is not None: return ('app', r, u)\n",
120
+ " r = step(u)\n",
121
+ " return None if r is None else ('app', f, r)\n",
122
+ " if k == 'lam':\n",
123
+ " r = step(t[3]); return None if r is None else ('lam', t[1], t[2], r)\n",
124
+ " if k == 'abort':\n",
125
+ " r = step(t[2]); return None if r is None else ('abort', t[1], r)\n",
126
+ " return None\n",
127
+ "\n",
128
+ "def normalize(t, fuel=10_000):\n",
129
+ " for _ in range(fuel):\n",
130
+ " r = step(t)\n",
131
+ " if r is None: return t\n",
132
+ " t = r\n",
133
+ " raise RuntimeError('fuel exhausted (would contradict strong normalisation)')\n",
134
+ "\n",
135
+ "# the classic combinators, as terms, with their types read off by the kernel\n",
136
+ "A, B, C = atom('A'), atom('B'), atom('C')\n",
137
+ "K = ('lam','x',A,('lam','y',B,('var','x')))\n",
138
+ "S = ('lam','f',arr(A,arr(B,C)),('lam','g',arr(A,B),('lam','x',A,('app',('app',('var','f'),('var','x')),('app',('var','g'),('var','x'))))))\n",
139
+ "for name, tm in [('K', K), ('S', S)]:\n",
140
+ " print(name, ':', show_ty(infer({}, tm)))"
141
+ ]
142
+ },
143
+ {
144
+ "cell_type": "markdown",
145
+ "id": "057302f6",
146
+ "metadata": {},
147
+ "source": [
148
+ "## 2. Subject reduction (Boxed result 2): if `Gamma |- t : A` and `t ~> t'` then `Gamma |- t' : A`\n",
149
+ "Check on random well-typed terms: generate by random type-directed construction, reduce step by step, re-infer at every step."
150
+ ]
151
+ },
152
+ {
153
+ "cell_type": "code",
154
+ "execution_count": 2,
155
+ "id": "5787fcb2",
156
+ "metadata": {
157
+ "execution": {
158
+ "iopub.execute_input": "2026-10-14T09:53:55.845545Z",
159
+ "iopub.status.busy": "2026-10-14T09:53:55.845323Z",
160
+ "iopub.status.idle": "2026-10-14T09:53:56.359730Z",
161
+ "shell.execute_reply": "2026-10-14T09:53:56.358821Z"
162
+ }
163
+ },
164
+ "outputs": [
165
+ {
166
+ "name": "stdout",
167
+ "output_type": "stream",
168
+ "text": [
169
+ "3000 random typed terms, 1205 with >=1 redex, 1851 reduction steps (1431 beta, 420 commuting-conversion); subject reduction violations: 0 (0.5s)\n"
170
+ ]
171
+ }
172
+ ],
173
+ "source": [
174
+ "ATOMS = ['a', 'b', 'c']\n",
175
+ "def rand_type(d):\n",
176
+ " if d == 0 or random.random() < 0.35:\n",
177
+ " return random.choice([atom(x) for x in ATOMS] + [BOT])\n",
178
+ " return arr(rand_type(d - 1), rand_type(d - 1))\n",
179
+ "\n",
180
+ "def spine_end(T):\n",
181
+ " while T[0] == 'arr': T = T[2]\n",
182
+ " return T\n",
183
+ "\n",
184
+ "def rand_term(ctx, goal, d):\n",
185
+ " \"\"\"random term of type `goal` in `ctx`, possibly with redexes (introduces cuts).\"\"\"\n",
186
+ " if d > 0 and random.random() < 0.35: # a cut: (λx:T. body) arg\n",
187
+ " T = rand_type(2)\n",
188
+ " arg = rand_term(ctx, T, d - 1)\n",
189
+ " x = f'v{next(_fresh)}'\n",
190
+ " body = rand_term({**ctx, x: T}, goal, d - 1)\n",
191
+ " return None if (arg is None or body is None) else ('app', ('lam', x, T, body), arg)\n",
192
+ " if d > 0 and random.random() < 0.15 and any(spine_end(T) == BOT for T in ctx.values()): # ex falso at an ARROW type, then applied\n",
193
+ " T = rand_type(2)\n",
194
+ " e = rand_term(ctx, BOT, d - 1); arg = rand_term(ctx, T, d - 1)\n",
195
+ " if e is not None and arg is not None: return ('app', ('abort', arr(T, goal), e), arg)\n",
196
+ " if goal[0] == 'arr':\n",
197
+ " x = f'v{next(_fresh)}'\n",
198
+ " body = rand_term({**ctx, x: goal[1]}, goal[2], d - 1)\n",
199
+ " return None if body is None else ('lam', x, goal[1], body)\n",
200
+ " # atomic goal: use a hypothesis head with matching result, else give up\n",
201
+ " cands = []\n",
202
+ " for x, T in ctx.items():\n",
203
+ " spine, R = [], T\n",
204
+ " while R[0] == 'arr': spine.append(R[1]); R = R[2]\n",
205
+ " if R == goal or (R == BOT):\n",
206
+ " cands.append((x, spine, R))\n",
207
+ " if not cands: return None\n",
208
+ " x, spine, R = random.choice(cands)\n",
209
+ " args = []\n",
210
+ " for Ai in spine:\n",
211
+ " a = rand_term(ctx, Ai, d - 1) if d > 0 else None\n",
212
+ " if a is None: return None\n",
213
+ " args.append(a)\n",
214
+ " tm = ('var', x)\n",
215
+ " for a in args: tm = ('app', tm, a)\n",
216
+ " return tm if R == goal else ('abort', goal, tm)\n",
217
+ "\n",
218
+ "n_ok = n_steps = n_nontrivial = 0\n",
219
+ "KINDS.clear()\n",
220
+ "t0 = time.time()\n",
221
+ "while n_ok < 3000:\n",
222
+ " ctx = {f'h{i}': rand_type(2) for i in range(random.randint(0, 3))}\n",
223
+ " goal = rand_type(2)\n",
224
+ " tm = rand_term(ctx, goal, 4)\n",
225
+ " if tm is None: continue\n",
226
+ " ty = infer(ctx, tm) # must typecheck by construction\n",
227
+ " assert ty == goal\n",
228
+ " cur, steps = tm, 0\n",
229
+ " while True:\n",
230
+ " nxt = step(cur)\n",
231
+ " if nxt is None: break\n",
232
+ " assert infer(ctx, nxt) == goal, ('subject reduction violated', show_tm(cur), show_tm(nxt))\n",
233
+ " cur, steps = nxt, steps + 1\n",
234
+ " n_steps += 1\n",
235
+ " n_nontrivial += steps > 0\n",
236
+ " n_ok += 1\n",
237
+ "print(f'{n_ok} random typed terms, {n_nontrivial} with >=1 redex, {n_steps} reduction steps '\n",
238
+ " f'({KINDS[\"beta\"]} beta, {KINDS[\"comm\"]} commuting-conversion); subject reduction violations: 0 ({time.time()-t0:.1f}s)')\n",
239
+ "assert KINDS['comm'] > 0 and KINDS['beta'] > 0 # both cases of Theorem 2 are actually exercised"
240
+ ]
241
+ },
242
+ {
243
+ "cell_type": "markdown",
244
+ "id": "fc48f5ca",
245
+ "metadata": {},
246
+ "source": [
247
+ "## 3. Inhabitation: naive vs efficient\n",
248
+ "**Naive** (`search_naive`): enumerate beta-normal, eta-long terms by depth, backtracking. Correct but exponential, and it can only answer\n",
249
+ "\"found within depth D\" / \"not found within depth D\".\n",
250
+ "\n",
251
+ "**Efficient** (`g4ip`): Dyckhoff's contraction-free sequent calculus (terminating, so a *negative* answer is a proof of\n",
252
+ "non-provability), with the proof term extracted and re-checked by the independent kernel `infer`."
253
+ ]
254
+ },
255
+ {
256
+ "cell_type": "code",
257
+ "execution_count": 3,
258
+ "id": "b941a9af",
259
+ "metadata": {
260
+ "execution": {
261
+ "iopub.execute_input": "2026-10-14T09:53:56.362552Z",
262
+ "iopub.status.busy": "2026-10-14T09:53:56.362347Z",
263
+ "iopub.status.idle": "2026-10-14T09:53:56.376383Z",
264
+ "shell.execute_reply": "2026-10-14T09:53:56.375472Z"
265
+ }
266
+ },
267
+ "outputs": [
268
+ {
269
+ "name": "stdout",
270
+ "output_type": "stream",
271
+ "text": [
272
+ "K a→b→a proof term: (λy0:a. (λy1:b. y0))\n",
273
+ "S proof term: (λy2:a → b → c. (λy3:a → b. (λy4:a. ((y2 y4) (y3 y4)))))\n",
274
+ "contraposition (a→b)→¬b→¬a proof term: (λy8:a → b. (λy9:b → ⊥. (λy10:a. (abort[⊥] (y9 (y8 y10))))))\n",
275
+ "a → ¬¬a proof term: (λy13:a. (λy14:a → ⊥. (abort[⊥] (y14 y13))))\n",
276
+ "¬¬¬a → ¬a proof term: (λy16:((a → ⊥) → ⊥) → ⊥. (λy17:a. (abort[⊥] (y16 (λc18:a → ⊥. (abort[⊥] (c18 y17)))))))\n",
277
+ "ex falso ⊥→a proof term: (λy24:⊥. (abort[a] y24))\n",
278
+ "Peirce ((a→b)→a)→a UNPROVABLE (G4ip terminates: this is a refutation, no term exists)\n",
279
+ "DNE ¬¬a→a UNPROVABLE (G4ip terminates: this is a refutation, no term exists)\n",
280
+ "rev. contraposition (¬b→¬a)→a→b UNPROVABLE (G4ip terminates: this is a refutation, no term exists)\n"
281
+ ]
282
+ }
283
+ ],
284
+ "source": [
285
+ "def spine_of(T):\n",
286
+ " sp = []\n",
287
+ " while T[0] == 'arr': sp.append(T[1]); T = T[2]\n",
288
+ " return sp, T\n",
289
+ "\n",
290
+ "def search_naive(ctx, goal, depth):\n",
291
+ " \"\"\"Return a normal term or None. ctx: tuple of (var,type).\"\"\"\n",
292
+ " if depth <= 0: return None\n",
293
+ " if goal[0] == 'arr':\n",
294
+ " x = f'x{len(ctx)}'\n",
295
+ " body = search_naive(ctx + ((x, goal[1]),), goal[2], depth - 1)\n",
296
+ " return None if body is None else ('lam', x, goal[1], body)\n",
297
+ " for x, T in ctx:\n",
298
+ " sp, R = spine_of(T)\n",
299
+ " if R != goal and R != BOT: continue\n",
300
+ " args, okk = [], True\n",
301
+ " for Ai in sp:\n",
302
+ " a = search_naive(ctx, Ai, depth - 1)\n",
303
+ " if a is None: okk = False; break\n",
304
+ " args.append(a)\n",
305
+ " if not okk: continue\n",
306
+ " tm = ('var', x)\n",
307
+ " for a in args: tm = ('app', tm, a)\n",
308
+ " return tm if R == goal else ('abort', goal, tm)\n",
309
+ " return None\n",
310
+ "\n",
311
+ "_nf = itertools.count()\n",
312
+ "def g4ip(ctx, goal, memo_fail):\n",
313
+ " \"\"\"ctx: tuple of (var,type). Returns a proof term or None. Contraction-free: terminates.\"\"\"\n",
314
+ " key = (frozenset(T for _, T in ctx), goal)\n",
315
+ " if key in memo_fail: return None\n",
316
+ " res = _g4ip(ctx, goal, memo_fail)\n",
317
+ " if res is None: memo_fail.add(key)\n",
318
+ " return res\n",
319
+ "\n",
320
+ "def _g4ip(ctx, goal, mf):\n",
321
+ " for x, T in ctx: # L⊥\n",
322
+ " if T == BOT: return ('abort', goal, ('var', x))\n",
323
+ " for x, T in ctx: # Ax\n",
324
+ " if T == goal and T[0] == 'atom': return ('var', x)\n",
325
+ " if goal[0] == 'arr': # R→ (invertible: no backtracking needed)\n",
326
+ " y = f'y{next(_nf)}'\n",
327
+ " b = g4ip(ctx + ((y, goal[1]),), goal[2], mf)\n",
328
+ " return None if b is None else ('lam', y, goal[1], b)\n",
329
+ " # L0→ (invertible): p, p→B in ctx ==> replace p→B by B\n",
330
+ " for i, (f, T) in enumerate(ctx):\n",
331
+ " if T[0] == 'arr' and T[1][0] == 'atom':\n",
332
+ " for x, Tx in ctx:\n",
333
+ " if Tx == T[1]:\n",
334
+ " z = f'z{next(_nf)}'\n",
335
+ " rest = ctx[:i] + ctx[i+1:] + ((z, T[2]),)\n",
336
+ " t = g4ip(rest, goal, mf)\n",
337
+ " return None if t is None else subst(t, z, ('app', ('var', f), ('var', x)))\n",
338
+ " # L⊥→ (invertible): drop hypotheses ⊥→B (carry no information)\n",
339
+ " for i, (f, T) in enumerate(ctx):\n",
340
+ " if T[0] == 'arr' and T[1] == BOT:\n",
341
+ " return g4ip(ctx[:i] + ctx[i+1:], goal, mf)\n",
342
+ " # L→→ (NOT invertible: backtrack over the choice of hypothesis)\n",
343
+ " for i, (f, T) in enumerate(ctx):\n",
344
+ " if T[0] == 'arr' and T[1][0] == 'arr':\n",
345
+ " C_, D_ = T[1][1], T[1][2]; B_ = T[2]\n",
346
+ " c, g, z = f'c{next(_nf)}', f'g{next(_nf)}', f'z{next(_nf)}'\n",
347
+ " base = ctx[:i] + ctx[i+1:]\n",
348
+ " t1 = g4ip(base + ((c, C_), (g, arr(D_, B_))), D_, mf)\n",
349
+ " if t1 is None: continue\n",
350
+ " t2 = g4ip(base + ((z, B_),), goal, mf)\n",
351
+ " if t2 is None: continue\n",
352
+ " d = f'd{next(_nf)}'\n",
353
+ " u = ('lam', c, C_, subst(t1, g, ('lam', d, D_, ('app', ('var', f), ('lam', f'_{next(_nf)}', C_, ('var', d))))))\n",
354
+ " return subst(t2, z, ('app', ('var', f), u))\n",
355
+ " return None\n",
356
+ "\n",
357
+ "def prove(goal):\n",
358
+ " return g4ip((), goal, set())\n",
359
+ "\n",
360
+ "# Gentzen-style sanity on named formulas\n",
361
+ "a, b, c = atom('a'), atom('b'), atom('c')\n",
362
+ "neg = lambda p: arr(p, BOT)\n",
363
+ "named = {\n",
364
+ " 'K a→b→a': arr(a, arr(b, a)),\n",
365
+ " 'S': arr(arr(a, arr(b, c)), arr(arr(a, b), arr(a, c))),\n",
366
+ " 'contraposition (a→b)→¬b→¬a': arr(arr(a, b), arr(neg(b), neg(a))),\n",
367
+ " 'a → ¬¬a': arr(a, neg(neg(a))),\n",
368
+ " '¬¬¬a → ¬a': arr(neg(neg(neg(a))), neg(a)),\n",
369
+ " 'ex falso ⊥→a': arr(BOT, a),\n",
370
+ " 'Peirce ((a→b)→a)→a': arr(arr(arr(a, b), a), a),\n",
371
+ " 'DNE ¬¬a→a': arr(neg(neg(a)), a),\n",
372
+ " 'rev. contraposition (¬b→¬a)→a→b': arr(arr(neg(b), neg(a)), arr(a, b)),\n",
373
+ "}\n",
374
+ "for name, f in named.items():\n",
375
+ " tm = prove(f)\n",
376
+ " if tm is None:\n",
377
+ " print(f'{name:40s} UNPROVABLE (G4ip terminates: this is a refutation, no term exists)')\n",
378
+ " else:\n",
379
+ " assert infer({}, tm) == f # independent kernel check of the extracted proof\n",
380
+ " nf = normalize(tm); assert infer({}, nf) == f\n",
381
+ " print(f'{name:40s} proof term: {show_tm(nf)}')"
382
+ ]
383
+ },
384
+ {
385
+ "cell_type": "code",
386
+ "execution_count": 4,
387
+ "id": "3ec7fb35",
388
+ "metadata": {
389
+ "execution": {
390
+ "iopub.execute_input": "2026-10-14T09:53:56.378933Z",
391
+ "iopub.status.busy": "2026-10-14T09:53:56.378753Z",
392
+ "iopub.status.idle": "2026-10-14T09:53:57.467575Z",
393
+ "shell.execute_reply": "2026-10-14T09:53:57.466497Z"
394
+ }
395
+ },
396
+ "outputs": [
397
+ {
398
+ "name": "stdout",
399
+ "output_type": "stream",
400
+ "text": [
401
+ "formulas: {0: 3, 1: 9, 2: 54, 3: 405, 4: 3402} total 3873\n"
402
+ ]
403
+ },
404
+ {
405
+ "name": "stdout",
406
+ "output_type": "stream",
407
+ "text": [
408
+ "G4ip: 0.03s naive(depth 14): 1.06s speed-up x41.7\n",
409
+ "inhabited: 1775 of 3873 | verdict disagreements naive vs G4ip: 0\n"
410
+ ]
411
+ }
412
+ ],
413
+ "source": [
414
+ "# All implicational formulas over {a, b, ⊥} with exactly k arrows, k = 0..4\n",
415
+ "LEAVES = [atom('a'), atom('b'), BOT]\n",
416
+ "@__import__('functools').lru_cache(None)\n",
417
+ "def formulas(k):\n",
418
+ " if k == 0: return tuple(LEAVES)\n",
419
+ " out = []\n",
420
+ " for i in range(k):\n",
421
+ " for l in formulas(i):\n",
422
+ " for r in formulas(k - 1 - i):\n",
423
+ " out.append(arr(l, r))\n",
424
+ " return tuple(out)\n",
425
+ "ALL = [f for k in range(5) for f in formulas(k)]\n",
426
+ "print('formulas:', {k: len(formulas(k)) for k in range(5)}, 'total', len(ALL))\n",
427
+ "\n",
428
+ "t0 = time.time(); verdict_eff = {}\n",
429
+ "for f in ALL:\n",
430
+ " tm = prove(f)\n",
431
+ " if tm is not None:\n",
432
+ " assert infer({}, tm) == f\n",
433
+ " assert infer({}, normalize(tm)) == f\n",
434
+ " verdict_eff[f] = tm is not None\n",
435
+ "t_eff = time.time() - t0\n",
436
+ "\n",
437
+ "t0 = time.time(); verdict_naive = {}; D = 14\n",
438
+ "for f in ALL:\n",
439
+ " tm = search_naive((), f, D)\n",
440
+ " if tm is not None: assert infer({}, tm) == f\n",
441
+ " verdict_naive[f] = tm is not None\n",
442
+ "t_naive = time.time() - t0\n",
443
+ "\n",
444
+ "disagree = [f for f in ALL if verdict_eff[f] != verdict_naive[f]]\n",
445
+ "assert not disagree, [show_ty(f) for f in disagree[:5]]\n",
446
+ "print(f'G4ip: {t_eff:.2f}s naive(depth {D}): {t_naive:.2f}s speed-up x{t_naive/max(t_eff,1e-9):.1f}')\n",
447
+ "print('inhabited:', sum(verdict_eff.values()), 'of', len(ALL), '| verdict disagreements naive vs G4ip: 0')"
448
+ ]
449
+ },
450
+ {
451
+ "cell_type": "markdown",
452
+ "id": "05df5ec0",
453
+ "metadata": {},
454
+ "source": [
455
+ "## 4. Soundness and the gap\n",
456
+ "* **Classical semantics** (truth table, `⊥ = False`): every inhabited type must be a tautology (Boxed result 3, soundness direction).\n",
457
+ "* **Kripke semantics** over chains of <=3 worlds: every inhabited type must be forced at *every* world of *every* model;\n",
458
+ " a non-inhabited type should have a countermodel (we report how many our tiny model class catches)."
459
+ ]
460
+ },
461
+ {
462
+ "cell_type": "code",
463
+ "execution_count": 5,
464
+ "id": "8247c153",
465
+ "metadata": {
466
+ "execution": {
467
+ "iopub.execute_input": "2026-10-14T09:53:57.469657Z",
468
+ "iopub.status.busy": "2026-10-14T09:53:57.469490Z",
469
+ "iopub.status.idle": "2026-10-14T09:53:57.982720Z",
470
+ "shell.execute_reply": "2026-10-14T09:53:57.981661Z"
471
+ }
472
+ },
473
+ "outputs": [
474
+ {
475
+ "name": "stdout",
476
+ "output_type": "stream",
477
+ "text": [
478
+ "provable 1775 all classically valid and forced in all 29 chain models: OK\n",
479
+ "unprovable 2098: classically valid but NOT inhabited = 32; with a chain countermodel (m<=3) = 2098\n",
480
+ " valid, not inhabited: ((a → b) → a) → a\n",
481
+ " valid, not inhabited: ((a → b) → ⊥) → a\n",
482
+ " valid, not inhabited: ((a → ⊥) → a) → a\n",
483
+ " valid, not inhabited: ((a → ⊥) → ⊥) → a\n",
484
+ " valid, not inhabited: ((b → a) → b) → b\n",
485
+ " valid, not inhabited: ((b → a) → ⊥) → b\n",
486
+ "Peirce countermodel: 2 worlds, a true only at world 1, b never -> world 0 does not force Peirce.\n"
487
+ ]
488
+ }
489
+ ],
490
+ "source": [
491
+ "def classical(f, val):\n",
492
+ " if f[0] == 'atom': return val[f[1]]\n",
493
+ " if f[0] == 'bot': return False\n",
494
+ " return (not classical(f[1], val)) or classical(f[2], val)\n",
495
+ "def tautology(f):\n",
496
+ " return all(classical(f, dict(zip('ab', v))) for v in itertools.product([False, True], repeat=2))\n",
497
+ "\n",
498
+ "def forces(f, w, m, th):\n",
499
+ " \"\"\"chain of worlds 0..m-1; atom x true at w iff w >= th[x] (monotone).\"\"\"\n",
500
+ " if f[0] == 'atom': return w >= th[f[1]]\n",
501
+ " if f[0] == 'bot': return False\n",
502
+ " return all((not forces(f[1], v, m, th)) or forces(f[2], v, m, th) for v in range(w, m))\n",
503
+ "\n",
504
+ "models = [(m, dict(zip('ab', (ta, tb)))) for m in (1, 2, 3) for ta in range(m + 1) for tb in range(m + 1)]\n",
505
+ "prov = [f for f in ALL if verdict_eff[f]]\n",
506
+ "unprov = [f for f in ALL if not verdict_eff[f]]\n",
507
+ "assert all(tautology(f) for f in prov) # soundness (classical)\n",
508
+ "assert all(forces(f, w, m, th) for f in prov for (m, th) in models for w in range(m)) # soundness (Kripke)\n",
509
+ "valid_unprov = [f for f in unprov if tautology(f)]\n",
510
+ "countermodel = [f for f in unprov if any(not forces(f, 0, m, th) for (m, th) in models)]\n",
511
+ "print(f'provable {len(prov)} all classically valid and forced in all {len(models)} chain models: OK')\n",
512
+ "print(f'unprovable {len(unprov)}: classically valid but NOT inhabited = {len(valid_unprov)}; '\n",
513
+ " f'with a chain countermodel (m<=3) = {len(countermodel)}')\n",
514
+ "ex = sorted(valid_unprov, key=lambda f: len(show_ty(f)))[:6]\n",
515
+ "for f in ex: print(' valid, not inhabited:', show_ty(f))\n",
516
+ "peirce = arr(arr(arr(a, b), a), a)\n",
517
+ "assert tautology(peirce) and not verdict_eff[peirce] and not forces(peirce, 0, 2, {'a': 1, 'b': 2})\n",
518
+ "print('Peirce countermodel: 2 worlds, a true only at world 1, b never -> world 0 does not force Peirce.')"
519
+ ]
520
+ },
521
+ {
522
+ "cell_type": "markdown",
523
+ "id": "7c345d80",
524
+ "metadata": {},
525
+ "source": [
526
+ "## 5. What the Lean kernel sees\n",
527
+ "Lean elaborates tactics into terms and the **kernel** re-checks the term. The only thing that can make a \"proof\" of a false\n",
528
+ "statement pass is an axiom in the term's dependency closure. `#print axioms` lists that closure.\n",
529
+ "Exactly the same tests as the strict verifier's axiom gate (`align/verify/lean/strict.py`, rule 3)."
530
+ ]
531
+ },
532
+ {
533
+ "cell_type": "code",
534
+ "execution_count": 6,
535
+ "id": "f9b00c3c",
536
+ "metadata": {
537
+ "execution": {
538
+ "iopub.execute_input": "2026-10-14T09:53:57.984954Z",
539
+ "iopub.status.busy": "2026-10-14T09:53:57.984774Z",
540
+ "iopub.status.idle": "2026-10-14T09:54:02.121622Z",
541
+ "shell.execute_reply": "2026-10-14T09:54:02.120529Z"
542
+ }
543
+ },
544
+ "outputs": [
545
+ {
546
+ "name": "stdout",
547
+ "output_type": "stream",
548
+ "text": [
549
+ "theorem axioms gate\n",
550
+ "k_comb [] ACCEPT\n",
551
+ "peirce_law ['Classical.choice', 'Quot.sound', 'propext'] ACCEPT\n",
552
+ "t_omega ['Quot.sound', 'propext'] ACCEPT\n",
553
+ "t_decide ['propext'] ACCEPT\n",
554
+ "t_native ['Lean.ofReduceBool', 'propext'] REJECT\n",
555
+ "t_sorry ['sorryAx'] REJECT\n",
556
+ "t_evil ['evil'] REJECT\n",
557
+ "t_funext ['Quot.sound'] ACCEPT\n",
558
+ "t_simp ['propext'] ACCEPT\n",
559
+ "axiom gate rejects exactly {native_decide, sorry, user axiom}; accepts constructive and classical-core proofs.\n"
560
+ ]
561
+ }
562
+ ],
563
+ "source": [
564
+ "import sys; sys.path.insert(0, os.path.abspath('.'))\n",
565
+ "from _lean_helper import Lean\n",
566
+ "L = Lean()\n",
567
+ "code = r'''\n",
568
+ "theorem k_comb (p q : Prop) : p → q → p := fun hp _ => hp\n",
569
+ "theorem peirce_law (p q : Prop) : ((p → q) → p) → p :=\n",
570
+ " fun h => (Classical.em p).elim id (fun hn => h (fun hp => absurd hp hn))\n",
571
+ "theorem t_omega (a b : Nat) : a + b = b + a := by omega\n",
572
+ "theorem t_decide : 2^10 = 1024 := by decide\n",
573
+ "theorem t_native : 2^10 = 1024 := by native_decide\n",
574
+ "theorem t_sorry : (1:Nat) = 2 := sorry\n",
575
+ "axiom evil : False\n",
576
+ "theorem t_evil : (1:Nat) = 2 := evil.elim\n",
577
+ "theorem t_funext (f g : Nat → Nat) (h : ∀ x, f x = g x) : f = g := funext h\n",
578
+ "theorem t_simp (xs : List Nat) : (xs ++ []).length = xs.length := by simp\n",
579
+ "#print axioms k_comb\n",
580
+ "#print axioms peirce_law\n",
581
+ "#print axioms t_omega\n",
582
+ "#print axioms t_decide\n",
583
+ "#print axioms t_native\n",
584
+ "#print axioms t_sorry\n",
585
+ "#print axioms t_evil\n",
586
+ "#print axioms t_funext\n",
587
+ "#print axioms t_simp\n",
588
+ "'''\n",
589
+ "r = L.run(code, keep=True)\n",
590
+ "import re\n",
591
+ "axioms = {}\n",
592
+ "for m in r['messages']:\n",
593
+ " mm = re.match(r\"'(\\w+)' (?:does not depend on any axioms|depends on axioms: \\[(.*)\\])\", m['data'])\n",
594
+ " if mm: axioms[mm.group(1)] = sorted(x.strip() for x in (mm.group(2) or '').split(',') if x.strip())\n",
595
+ "ALLOWED = {'propext', 'Quot.sound', 'Classical.choice'}\n",
596
+ "print(f'{\"theorem\":12s} {\"axioms\":45s} gate')\n",
597
+ "for k_, v in axioms.items():\n",
598
+ " print(f'{k_:12s} {str(v):45s} {\"ACCEPT\" if set(v) <= ALLOWED else \"REJECT\"}')\n",
599
+ "expected_reject = {'t_native', 't_sorry', 't_evil'}\n",
600
+ "assert {k_ for k_, v in axioms.items() if not set(v) <= ALLOWED} == expected_reject\n",
601
+ "assert axioms['k_comb'] == []\n",
602
+ "assert set(axioms['peirce_law']) == {'Classical.choice', 'propext', 'Quot.sound'}\n",
603
+ "print('axiom gate rejects exactly {native_decide, sorry, user axiom}; accepts constructive and classical-core proofs.')"
604
+ ]
605
+ },
606
+ {
607
+ "cell_type": "markdown",
608
+ "id": "3634e461",
609
+ "metadata": {},
610
+ "source": [
611
+ "### Tactics are untrusted; the term is what is checked\n",
612
+ "`by` blocks are programs that *build* a term. We can print it. A wrong tactic cannot make the kernel accept a wrong term."
613
+ ]
614
+ },
615
+ {
616
+ "cell_type": "code",
617
+ "execution_count": 7,
618
+ "id": "db937b1f",
619
+ "metadata": {
620
+ "execution": {
621
+ "iopub.execute_input": "2026-10-14T09:54:02.123377Z",
622
+ "iopub.status.busy": "2026-10-14T09:54:02.123216Z",
623
+ "iopub.status.idle": "2026-10-14T09:54:02.185056Z",
624
+ "shell.execute_reply": "2026-10-14T09:54:02.184044Z"
625
+ }
626
+ },
627
+ "outputs": [
628
+ {
629
+ "name": "stdout",
630
+ "output_type": "stream",
631
+ "text": [
632
+ "`by omega` elaborates to a proof TERM of 64 pretty-printed lines (a certificate checked by the kernel); a short one:\n",
633
+ "[info] line 3: theorem t_by : ∀ (p q : Prop), p ∧ q → q ∧ p :=\n",
634
+ "fun p q h =>\n",
635
+ " match h with\n",
636
+ " | ⟨a, b⟩ => ⟨b, a⟩\n",
637
+ "--- ill-typed term: the kernel/elaborator refuses ---\n",
638
+ "[error] line 1: type mismatch\n",
639
+ " Eq.refl\n",
640
+ "has type\n",
641
+ " ∀ (a : ?m.2096), a = a : Prop\n",
642
+ "but is expected to have type\n",
643
+ " 1 = 2 : Prop\n",
644
+ "--- proof irrelevance: any two proofs of a Prop are definitionally equal ---\n",
645
+ "[ok] no messages\n",
646
+ "--- sorryAx inhabits every type, including False ---\n",
647
+ "[info] line 1: axiom sorryAx.{u} : (α : Sort u) → optParam Bool false → α\n",
648
+ "[warning] line 2: declaration uses 'sorry'\n",
649
+ "[info] line 3: 'anything' depends on axioms: [sorryAx]\n",
650
+ "[sorries] ⊢ False\n"
651
+ ]
652
+ },
653
+ {
654
+ "data": {
655
+ "text/plain": [
656
+ "{'sorries': [{'proofState': 1,\n",
657
+ " 'pos': {'line': 2, 'column': 28},\n",
658
+ " 'goal': '⊢ False',\n",
659
+ " 'endPos': {'line': 2, 'column': 33}}],\n",
660
+ " 'messages': [{'severity': 'info',\n",
661
+ " 'pos': {'line': 1, 'column': 0},\n",
662
+ " 'endPos': {'line': 1, 'column': 6},\n",
663
+ " 'data': 'axiom sorryAx.{u} : (α : Sort u) → optParam Bool false → α'},\n",
664
+ " {'severity': 'warning',\n",
665
+ " 'pos': {'line': 2, 'column': 8},\n",
666
+ " 'endPos': {'line': 2, 'column': 16},\n",
667
+ " 'data': \"declaration uses 'sorry'\"},\n",
668
+ " {'severity': 'info',\n",
669
+ " 'pos': {'line': 3, 'column': 0},\n",
670
+ " 'endPos': {'line': 3, 'column': 6},\n",
671
+ " 'data': \"'anything' depends on axioms: [sorryAx]\"}],\n",
672
+ " 'env': 7}"
673
+ ]
674
+ },
675
+ "execution_count": 7,
676
+ "metadata": {},
677
+ "output_type": "execute_result"
678
+ }
679
+ ],
680
+ "source": [
681
+ "rr = L.run(\"#print t_omega\")\n",
682
+ "n_lines = sum(m['data'].count('\\n') + 1 for m in rr['messages'])\n",
683
+ "print(f'`by omega` elaborates to a proof TERM of {n_lines} pretty-printed lines (a certificate checked by the kernel); a short one:')\n",
684
+ "L.show(r'''\n",
685
+ "theorem t_by (p q : Prop) : p ∧ q → q ∧ p := by intro ⟨a, b⟩; exact ⟨b, a⟩\n",
686
+ "#print t_by\n",
687
+ "''')\n",
688
+ "print('--- ill-typed term: the kernel/elaborator refuses ---')\n",
689
+ "L.show(\"example : (1:Nat) = 2 := ⟨⟩\")\n",
690
+ "print('--- proof irrelevance: any two proofs of a Prop are definitionally equal ---')\n",
691
+ "L.show(\"example (p : Prop) (h1 h2 : p) : h1 = h2 := rfl\")\n",
692
+ "print('--- sorryAx inhabits every type, including False ---')\n",
693
+ "L.show(\"#print sorryAx\\ntheorem anything : False := sorry\\n#print axioms anything\")"
694
+ ]
695
+ },
696
+ {
697
+ "cell_type": "markdown",
698
+ "id": "c0b25fdd",
699
+ "metadata": {},
700
+ "source": [
701
+ "### Why `sorryAx` / `native_decide` / extra axioms destroy a reward\n",
702
+ "Let `R(c) = 1[kernel accepts c and axioms(c) is within A]`. With A = {propext, Quot.sound, Classical.choice}, `R(c)=1`\n",
703
+ "implies the statement is true in every model of Lean's type theory plus those axioms (soundness of the kernel; consistency of A).\n",
704
+ "If `sorryAx` (or any axiom of type `False`) is allowed, `R(c) = 1` for **every** statement, so `P(R=1 | statement false) = 1`\n",
705
+ "and the reward carries zero information: an RL policy will find it. Below: acceptance probability of the trivial exploit under three gates."
706
+ ]
707
+ },
708
+ {
709
+ "cell_type": "code",
710
+ "execution_count": 8,
711
+ "id": "52d6fcf1",
712
+ "metadata": {
713
+ "execution": {
714
+ "iopub.execute_input": "2026-10-14T09:54:02.186945Z",
715
+ "iopub.status.busy": "2026-10-14T09:54:02.186790Z",
716
+ "iopub.status.idle": "2026-10-14T09:54:02.472246Z",
717
+ "shell.execute_reply": "2026-10-14T09:54:02.471199Z"
718
+ }
719
+ },
720
+ "outputs": [
721
+ {
722
+ "name": "stdout",
723
+ "output_type": "stream",
724
+ "text": [
725
+ "false statement axioms of the `sorry` proof strict-gate no-axiom-gate(only compile)\n",
726
+ "(1:Nat) = 2 ['sorryAx'] REJECT ACCEPT\n",
727
+ "∀ n : Nat, n < 3 ['sorryAx'] REJECT ACCEPT\n",
728
+ "False ['sorryAx'] REJECT ACCEPT\n"
729
+ ]
730
+ }
731
+ ],
732
+ "source": [
733
+ "statements_false = [\"(1:Nat) = 2\", \"∀ n : Nat, n < 3\", \"False\"]\n",
734
+ "rows = []\n",
735
+ "for st in statements_false:\n",
736
+ " rr = L.run(f\"theorem probe : {st} := sorry\\n#print axioms probe\")\n",
737
+ " ax = []\n",
738
+ " for m in rr['messages']:\n",
739
+ " mm = re.match(r\"'probe' depends on axioms: \\[(.*)\\]\", m['data'])\n",
740
+ " if mm: ax = [x.strip() for x in mm.group(1).split(',')]\n",
741
+ " rows.append((st, ax, 'ACCEPT' if set(ax) <= ALLOWED else 'REJECT', 'ACCEPT'))\n",
742
+ "print(f'{\"false statement\":22s} {\"axioms of the `sorry` proof\":22s} strict-gate no-axiom-gate(only compile)')\n",
743
+ "for st, ax, g1, g2 in rows: print(f'{st:22s} {str(ax):22s} {g1:11s} {g2}')\n",
744
+ "assert all(r_[2] == 'REJECT' for r_ in rows)\n",
745
+ "L.close()"
746
+ ]
747
+ }
748
+ ],
749
+ "metadata": {
750
+ "kernelspec": {
751
+ "display_name": "Python 3",
752
+ "language": "python",
753
+ "name": "python3"
754
+ },
755
+ "language_info": {
756
+ "codemirror_mode": {
757
+ "name": "ipython",
758
+ "version": 3
759
+ },
760
+ "file_extension": ".py",
761
+ "mimetype": "text/x-python",
762
+ "name": "python",
763
+ "nbconvert_exporter": "python",
764
+ "pygments_lexer": "ipython3",
765
+ "version": "3.12.11"
766
+ },
767
+ "m3v": {
768
+ "patched": "0301"
769
+ }
770
+ },
771
+ "nbformat": 4,
772
+ "nbformat_minor": 5
773
+ }
code/notebooks/0302-dependent-types.ipynb ADDED
@@ -0,0 +1,762 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "00a22d32",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0302 - Dependent type theory: the minimum for reading Mathlib statements\n",
9
+ "\n",
10
+ "Scratch-pad for note `0302 - Dependent Types.pdf`. Naive first, better second, assert agreement.\n",
11
+ "\n",
12
+ "1. **Universe of a Pi type.** Naive: Python function `imax`-rule. Check against what Lean's kernel says for 10 types.\n",
13
+ "2. **Decidability.** Naive: Python brute force over the bound. Efficient: Lean `decide` (kernel/VM evaluation of the\n",
14
+ " `Decidable` instance). Assert the Booleans agree on 12 bounded statements; show where no instance exists.\n",
15
+ "3. **Ten miniF2F statements decoded.** Lean prints the elaborated Pi-telescope; Python (exact `Fraction`/big-int arithmetic or\n",
16
+ " fp64) shows the hypotheses are satisfiable (non-vacuous) and the conclusion holds at witnesses.\n",
17
+ "4. **Elaboration traps** (truncated subtraction, floor division, casts, `autoImplicit`), Lean vs a Python model of ℕ.\n",
18
+ "\n",
19
+ "No GPU (`CUDA_VISIBLE_DEVICES` cleared). Lean: ONE worker through `align.verify.lean`, nice 15."
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "code",
24
+ "execution_count": 1,
25
+ "id": "fa7036cc",
26
+ "metadata": {
27
+ "execution": {
28
+ "iopub.execute_input": "2026-10-14T09:54:20.065314Z",
29
+ "iopub.status.busy": "2026-10-14T09:54:20.065161Z",
30
+ "iopub.status.idle": "2026-10-14T09:54:24.370858Z",
31
+ "shell.execute_reply": "2026-10-14T09:54:24.370025Z"
32
+ }
33
+ },
34
+ "outputs": [],
35
+ "source": [
36
+ "import os\n",
37
+ "os.environ[\"CUDA_VISIBLE_DEVICES\"] = \"\"\n",
38
+ "import sys, re, json, math, random, itertools, time\n",
39
+ "from fractions import Fraction\n",
40
+ "sys.path.insert(0, os.path.abspath('.'))\n",
41
+ "from _lean_helper import Lean, HEADER\n",
42
+ "MINIF2F_HEADER = \"import Mathlib\\nimport Aesop\\n\\nset_option maxHeartbeats 400000\\n\\nopen BigOperators Real Nat Topology Rat\\n\"\n",
43
+ "L = Lean(MINIF2F_HEADER)\n",
44
+ "\n",
45
+ "def lean_msgs(code, keep=False, timeout=300):\n",
46
+ " r = L.run(code, timeout=timeout, keep=keep)\n",
47
+ " return r.get('messages', []), r"
48
+ ]
49
+ },
50
+ {
51
+ "cell_type": "markdown",
52
+ "id": "6bd9c629",
53
+ "metadata": {},
54
+ "source": [
55
+ "## 1. The sort of a Pi type: `Sort (imax u v)`\n",
56
+ "Rule: if `A : Sort u` and `B x : Sort v` for `x : A` then `(x : A) → B x : Sort (imax u v)` where\n",
57
+ "`imax u 0 = 0` and `imax u v = max u v` for `v > 0`. So a Pi into `Prop` is a `Prop` whatever the domain (impredicativity)."
58
+ ]
59
+ },
60
+ {
61
+ "cell_type": "code",
62
+ "execution_count": 2,
63
+ "id": "a0d26ffc",
64
+ "metadata": {
65
+ "execution": {
66
+ "iopub.execute_input": "2026-10-14T09:54:24.373206Z",
67
+ "iopub.status.busy": "2026-10-14T09:54:24.373041Z",
68
+ "iopub.status.idle": "2026-10-14T09:54:24.406046Z",
69
+ "shell.execute_reply": "2026-10-14T09:54:24.405001Z"
70
+ }
71
+ },
72
+ "outputs": [
73
+ {
74
+ "name": "stdout",
75
+ "output_type": "stream",
76
+ "text": [
77
+ "Pi type imax rule Lean\n",
78
+ "∀ n : ℕ, n = n Prop Prop\n",
79
+ "ℕ → ℕ Type Type\n",
80
+ "(n : ℕ) → Fin n Type Type\n",
81
+ "∀ p : Prop, p → p Prop Prop\n",
82
+ "(α : Type) → α → α Type 1 Type 1\n",
83
+ "(α : Type) → Prop Type 1 Type 1\n",
84
+ "Prop → Prop Type Type\n",
85
+ "∀ α : Type, α = α Prop Prop\n",
86
+ "(α : Type 1) → α → α Type 2 Type 2\n",
87
+ "ℕ → Prop Type Type\n",
88
+ "imax rule agrees with the kernel on all 10 types.\n"
89
+ ]
90
+ }
91
+ ],
92
+ "source": [
93
+ "def imax(u, v): return 0 if v == 0 else max(u, v)\n",
94
+ "def sort_name(l): return 'Prop' if l == 0 else ('Type' if l == 1 else f'Type {l-1}')\n",
95
+ "\n",
96
+ "# (lean source of the Pi type, level u of the domain's sort, level v of the codomain's sort)\n",
97
+ "PI_TABLE = [\n",
98
+ " (\"∀ n : ℕ, n = n\", 1, 0), # ℕ : Type = Sort 1 ; n = n : Prop = Sort 0\n",
99
+ " (\"ℕ → ℕ\", 1, 1),\n",
100
+ " (\"(n : ℕ) → Fin n\", 1, 1),\n",
101
+ " (\"∀ p : Prop, p → p\", 1, 0), # Prop : Type = Sort 1 ; p → p : Prop\n",
102
+ " (\"(α : Type) → α → α\", 2, 1), # Type : Type 1 = Sort 2 ; α → α : Type = Sort 1\n",
103
+ " (\"(α : Type) → Prop\", 2, 1), # Prop : Type = Sort 1\n",
104
+ " (\"Prop → Prop\", 1, 1),\n",
105
+ " (\"∀ α : Type, α = α\", 2, 0),\n",
106
+ " (\"(α : Type 1) → α → α\", 3, 2), # Type 1 : Sort 3 ; α → α : Sort 2\n",
107
+ " (\"ℕ → Prop\", 1, 1),\n",
108
+ "]\n",
109
+ "rows = []\n",
110
+ "for src, u, v in PI_TABLE:\n",
111
+ " msgs, _ = lean_msgs(f\"#check ({src})\")\n",
112
+ " data = msgs[0]['data']\n",
113
+ " m = re.search(r\": (Prop|Type\\*?|Type \\d+|Sort [^\\n]*)\\s*$\", data.strip().split('\\n')[-1])\n",
114
+ " lean_sort = m.group(1) if m else data\n",
115
+ " pred = sort_name(imax(u, v))\n",
116
+ " rows.append((src, pred, lean_sort))\n",
117
+ " assert pred == lean_sort, (src, pred, lean_sort)\n",
118
+ "print(f'{\"Pi type\":28s} {\"imax rule\":10s} Lean')\n",
119
+ "for r_ in rows: print(f'{r_[0]:28s} {r_[1]:10s} {r_[2]}')\n",
120
+ "print('imax rule agrees with the kernel on all', len(rows), 'types.')"
121
+ ]
122
+ },
123
+ {
124
+ "cell_type": "markdown",
125
+ "id": "f2877b3a",
126
+ "metadata": {},
127
+ "source": [
128
+ "## 2. Decidability: `Decidable p` is data; `decide` runs it\n",
129
+ "`class Decidable (p : Prop) : Type | isFalse (h : ¬p) | isTrue (h : p)`. `decide p` is `match inst with | isTrue _ => true | isFalse _ => false`.\n",
130
+ "Soundness: `of_decide_eq_true : decide p = true → p` (cases on the instance). The tactic builds `of_decide_eq_true (Eq.refl true)`."
131
+ ]
132
+ },
133
+ {
134
+ "cell_type": "code",
135
+ "execution_count": 3,
136
+ "id": "f6a87131",
137
+ "metadata": {
138
+ "execution": {
139
+ "iopub.execute_input": "2026-10-14T09:54:24.407763Z",
140
+ "iopub.status.busy": "2026-10-14T09:54:24.407583Z",
141
+ "iopub.status.idle": "2026-10-14T09:54:24.427808Z",
142
+ "shell.execute_reply": "2026-10-14T09:54:24.427297Z"
143
+ }
144
+ },
145
+ "outputs": [
146
+ {
147
+ "name": "stdout",
148
+ "output_type": "stream",
149
+ "text": [
150
+ "@of_decide_eq_true : ∀ {p : Prop} [inst : Decidable p], decide p = true → p\n",
151
+ "inductive Decidable : Prop → Type\n",
152
+ "number of parameters: 1\n",
153
+ "constructors:\n",
154
+ "Decidable.isFalse : {p : Prop} → ¬p → Decidable p\n",
155
+ "Decidable.isTrue : {p : Prop} → p → Decidable p\n",
156
+ "theorem two_plus_two : 2 + 2 = 4 :=\n",
157
+ "of_decide_eq_true (Eq.refl true)\n",
158
+ "'two_plus_two' does not depend on any axioms\n"
159
+ ]
160
+ }
161
+ ],
162
+ "source": [
163
+ "msgs, _ = lean_msgs(\"#check @of_decide_eq_true\\n#print Decidable\\ntheorem two_plus_two : 2 + 2 = 4 := by decide\\n#print two_plus_two\\n#print axioms two_plus_two\", keep=True)\n",
164
+ "for m in msgs: print(m['data'])"
165
+ ]
166
+ },
167
+ {
168
+ "cell_type": "code",
169
+ "execution_count": 4,
170
+ "id": "357adce9",
171
+ "metadata": {
172
+ "execution": {
173
+ "iopub.execute_input": "2026-10-14T09:54:24.433916Z",
174
+ "iopub.status.busy": "2026-10-14T09:54:24.433656Z",
175
+ "iopub.status.idle": "2026-10-14T09:54:25.615319Z",
176
+ "shell.execute_reply": "2026-10-14T09:54:25.614598Z"
177
+ }
178
+ },
179
+ "outputs": [
180
+ {
181
+ "name": "stdout",
182
+ "output_type": "stream",
183
+ "text": [
184
+ "--- Nat.Prime is decidable only via a *chosen* algorithm; two instances exist (the default is chosen by priority) ---\n",
185
+ "decidablePrime 7\n",
186
+ "decidablePrime1 7\n"
187
+ ]
188
+ },
189
+ {
190
+ "name": "stdout",
191
+ "output_type": "stream",
192
+ "text": [
193
+ "python lean statement\n",
194
+ "True true ∀ n < 50, n * n % 4 ≠ 2\n",
195
+ "True true ∃ n < 100, n * n = 2209\n",
196
+ "True true ∀ a < 20, ∀ b < 20, a * b = b * a\n",
197
+ "True true ∀ n < (40 : ℕ), Nat.Prime (n * n + n + 41)\n",
198
+ "False false ∀ n < (41 : ℕ), Nat.Prime (n * n + n + 41)\n",
199
+ "True true ∀ n < 100, n ^ 3 % 7 = 0 ∨ n ^ 3 % 7 = 1 ∨ n ^ 3 % 7 = 6\n",
200
+ "False false ∃ x : Fin 61, ∃ y : Fin 61, 4 * (((x : ℕ) : ℤ) - 30) ^ 3 - 7 * (((y : ℕ) : ℤ) - 30) ^ 3 = 2003\n",
201
+ "False false ∀ a < 10, ∀ b < 10, a - b + b = a\n",
202
+ "True true ∀ a < 10, ∀ b < 10, (a + b) / 2 * 2 ≤ a + b\n",
203
+ "False false ∃ n < 10, 18 ∣ 3740 + n ∧ n ≠ 4\n",
204
+ "True true ∀ n < 30, 2 ^ n % 3 = if n % 2 = 0 then 1 else 2\n",
205
+ "True true ∃ n < 100, n % 3 = 2 ∧ n % 5 = 3 ∧ n % 7 = 2\n",
206
+ "python brute force == Lean decide on 12/12\n"
207
+ ]
208
+ }
209
+ ],
210
+ "source": [
211
+ "# 12 bounded statements: (python oracle, lean statement)\n",
212
+ "def prime(n):\n",
213
+ " return n >= 2 and all(n % d for d in range(2, int(n ** .5) + 1))\n",
214
+ "def nsub(a, b): return max(0, a - b) # Lean ℕ subtraction is truncated\n",
215
+ "CASES = [\n",
216
+ " (lambda: all(n*n % 4 != 2 for n in range(50)), \"∀ n < 50, n * n % 4 ≠ 2\"),\n",
217
+ " (lambda: any(n*n == 2209 for n in range(100)), \"∃ n < 100, n * n = 2209\"),\n",
218
+ " (lambda: all(a*b == b*a for a in range(20) for b in range(20)), \"∀ a < 20, ∀ b < 20, a * b = b * a\"),\n",
219
+ " (lambda: all(prime(n*n+n+41) for n in range(40)), \"∀ n < (40 : ℕ), Nat.Prime (n * n + n + 41)\"),\n",
220
+ " (lambda: all(prime(n*n+n+41) for n in range(41)), \"∀ n < (41 : ℕ), Nat.Prime (n * n + n + 41)\"),\n",
221
+ " (lambda: all(n**3 % 7 in (0, 1, 6) for n in range(100)), \"∀ n < 100, n ^ 3 % 7 = 0 ∨ n ^ 3 % 7 = 1 ∨ n ^ 3 % 7 = 6\"),\n",
222
+ " (lambda: any(4*x**3 - 7*y**3 == 2003 for x in range(-30, 31) for y in range(-30, 31)),\n",
223
+ " \"∃ x : Fin 61, ∃ y : Fin 61, 4 * (((x : ℕ) : ℤ) - 30) ^ 3 - 7 * (((y : ℕ) : ℤ) - 30) ^ 3 = 2003\"),\n",
224
+ " (lambda: all(nsub(a, b) + b == a for a in range(10) for b in range(10)), \"∀ a < 10, ∀ b < 10, a - b + b = a\"),\n",
225
+ " (lambda: all((a + b) // 2 * 2 <= a + b for a in range(10) for b in range(10)), \"∀ a < 10, ∀ b < 10, (a + b) / 2 * 2 ≤ a + b\"),\n",
226
+ " (lambda: any((3740 + n) % 18 == 0 and n != 4 for n in range(10)), \"∃ n < 10, 18 ∣ 3740 + n ∧ n ≠ 4\"),\n",
227
+ " (lambda: all(pow(2, n) % 3 == (1 if n % 2 == 0 else 2) for n in range(30)), \"∀ n < 30, 2 ^ n % 3 = if n % 2 = 0 then 1 else 2\"),\n",
228
+ " (lambda: any(n % 3 == 2 and n % 5 == 3 and n % 7 == 2 for n in range(100)), \"∃ n < 100, n % 3 = 2 ∧ n % 5 = 3 ∧ n % 7 = 2\"),\n",
229
+ "]\n",
230
+ "print('--- Nat.Prime is decidable only via a *chosen* algorithm; two instances exist (the default is chosen by priority) ---')\n",
231
+ "for m in lean_msgs(\"#synth Decidable (Nat.Prime 7)\")[0]: print(m['data'][:160])\n",
232
+ "lean_msgs(\"attribute [instance] Nat.decidablePrime1\", keep=True) # opt in to the trial-division instance (needed below for bounded foralls)\n",
233
+ "for m in lean_msgs(\"#synth Decidable (Nat.Prime 7)\")[0]: print(m['data'][:160])\n",
234
+ "code = \"\\n\".join(f\"#eval decide ({s})\" for _, s in CASES)\n",
235
+ "msgs, _ = lean_msgs(code)\n",
236
+ "by_line = {m['pos']['line']: m['data'].strip() for m in msgs}\n",
237
+ "print(f'{\"python\":7s} {\"lean\":6s} statement')\n",
238
+ "agree = 0\n",
239
+ "for i, (fn, s) in enumerate(CASES, start=1):\n",
240
+ " py = fn(); ln = by_line.get(i)\n",
241
+ " assert ln in ('true', 'false'), (s, ln)\n",
242
+ " assert (ln == 'true') == py, (s, py, ln)\n",
243
+ " agree += 1\n",
244
+ " print(f'{str(py):7s} {ln:6s} {s}')\n",
245
+ "print(f'python brute force == Lean decide on {agree}/{len(CASES)}')"
246
+ ]
247
+ },
248
+ {
249
+ "cell_type": "code",
250
+ "execution_count": 5,
251
+ "id": "c58167d4",
252
+ "metadata": {
253
+ "execution": {
254
+ "iopub.execute_input": "2026-10-14T09:54:25.617088Z",
255
+ "iopub.status.busy": "2026-10-14T09:54:25.616927Z",
256
+ "iopub.status.idle": "2026-10-14T09:54:25.737634Z",
257
+ "shell.execute_reply": "2026-10-14T09:54:25.736825Z"
258
+ }
259
+ },
260
+ "outputs": [
261
+ {
262
+ "name": "stdout",
263
+ "output_type": "stream",
264
+ "text": [
265
+ "--- elaboration order matters: the same statement without the type ascription has no instance (n : ?α is still a metavariable) ---\n",
266
+ "failed to synthesize\n",
267
+ " Decidable (∀ n < 40, (n * n + n + 41).Prime)\n",
268
+ "use `set_option diagnostics true` to get diagnostic information\n",
269
+ "--- no instance: unbounded quantifier over ℕ ---\n",
270
+ "failed to synthesize\n",
271
+ " Decidable (∀ (n : ℕ), n < 5 ∨ 5 ≤ n)\n",
272
+ "use `set_option diagnostics true` to get diagnostic information\n",
273
+ "--- no computable instance: equality on ℝ ---\n",
274
+ "tactic 'decide' failed for proposition\n",
275
+ " 2 + 2 = 4\n",
276
+ "since its 'Decidable' instance reduced to\n",
277
+ " Classical.choice ⋯\n",
278
+ "rather than to the 'isTrue' constructor.\n",
279
+ "--- but a *closed* bounded one is fine, and the real statements with binders are not decidable props at all ---\n",
280
+ "decidableBallLT 10 fun n a => n * n < 100\n",
281
+ "failed to synthesize\n",
282
+ " Decidable (∀ (x : ℝ), x ^ 2 ≥ 0)\n",
283
+ "use `set_option diagnostics true` to get diagnostic information\n"
284
+ ]
285
+ }
286
+ ],
287
+ "source": [
288
+ "print('--- elaboration order matters: the same statement without the type ascription has no instance (n : ?α is still a metavariable) ---')\n",
289
+ "for m in lean_msgs(\"#eval decide (∀ n < 40, Nat.Prime (n * n + n + 41))\")[0]: print(m['data'][:160])\n",
290
+ "print('--- no instance: unbounded quantifier over ℕ ---')\n",
291
+ "for m in lean_msgs(\"example : ∀ n : ℕ, n < 5 ∨ 5 ≤ n := by decide\")[0]: print(m['data'][:400])\n",
292
+ "print('--- no computable instance: equality on ℝ ---')\n",
293
+ "for m in lean_msgs(\"example : (2 : ℝ) + 2 = 4 := by decide\")[0]: print(m['data'][:400])\n",
294
+ "print('--- but a *closed* bounded one is fine, and the real statements with binders are not decidable props at all ---')\n",
295
+ "for m in lean_msgs(\"#synth Decidable (∀ n < 10, n * n < 100)\")[0]: print(m['data'][:200])\n",
296
+ "for m in lean_msgs(\"#synth Decidable (∀ x : ℝ, x ^ 2 ≥ 0)\")[0]: print(m['data'][:200])"
297
+ ]
298
+ },
299
+ {
300
+ "cell_type": "markdown",
301
+ "id": "c172fa47",
302
+ "metadata": {},
303
+ "source": [
304
+ "## 3. Ten miniF2F statements decoded\n",
305
+ "Source: `p2_runs/r1_baseline/minif2f.jsonl` (the 244 miniF2F test statements this repo evaluates on, Lean 4.9.0-rc1 + Mathlib pin).\n",
306
+ "For each: (a) Lean's own elaborated Pi-telescope (`#check @name`), its sort, and `decide`-ability of the closed ones;\n",
307
+ "(b) a Python witness check that the hypotheses are satisfiable and the conclusion holds there (non-vacuity), exact arithmetic where possible."
308
+ ]
309
+ },
310
+ {
311
+ "cell_type": "code",
312
+ "execution_count": 6,
313
+ "id": "f06bbc76",
314
+ "metadata": {
315
+ "execution": {
316
+ "iopub.execute_input": "2026-10-14T09:54:25.739310Z",
317
+ "iopub.status.busy": "2026-10-14T09:54:25.739151Z",
318
+ "iopub.status.idle": "2026-10-14T09:54:26.102413Z",
319
+ "shell.execute_reply": "2026-10-14T09:54:26.101621Z"
320
+ }
321
+ },
322
+ "outputs": [
323
+ {
324
+ "name": "stdout",
325
+ "output_type": "stream",
326
+ "text": [
327
+ "====================================================================================================\n",
328
+ "mathd_algebra_478\n",
329
+ "mathd_algebra_478 : ∀ (b h v : ℝ), 0 < b ∧ 0 < h ∧ 0 < v → v = 1 / 3 * (b * h) → b = 30 → h = 13 / 2 → v = 65\n",
330
+ "binders=7 sort=Prop\n",
331
+ "====================================================================================================\n",
332
+ "numbertheory_4x3m7y3neq2003\n",
333
+ "numbertheory_4x3m7y3neq2003 : ∀ (x y : ℤ), 4 * x ^ 3 - 7 * y ^ 3 ≠ 2003\n",
334
+ "binders=2 sort=Prop\n",
335
+ "====================================================================================================\n",
336
+ "aime_1983_p1\n",
337
+ "aime_1983_p1 : ∀ (x y z w : ℕ), 1 < x ∧ 1 < y ∧ 1 < z → 0 ≤ w → (↑w).log / (↑x).log = 24 → (↑w).log / (↑y).log = 40 → (↑w).log / (↑x * ↑y * ↑z).log = 12 → (↑w).log / (↑z).log = 60\n",
338
+ "binders=9 sort=Prop\n",
339
+ "====================================================================================================\n",
340
+ "amc12_2001_p5\n",
341
+ "amc12_2001_p5 : (Finset.filter (fun x => ¬Even x) (Finset.range 10000)).prod id = 10000! / (2 ^ 5000 * 5000!)\n",
342
+ "binders=0 sort=Prop\n",
343
+ "====================================================================================================\n",
344
+ "mathd_numbertheory_3\n",
345
+ "mathd_numbertheory_3 : (∑ x ∈ Finset.range 10, (x + 1) ^ 2) % 10 = 5\n",
346
+ "binders=0 sort=Prop\n",
347
+ "====================================================================================================\n",
348
+ "imo_1969_p2\n",
349
+ "imo_1969_p2 : ∀ (m n : ℝ) (k : ℕ) (a : ℕ → ℝ) (y : ℝ → ℝ), 0 < k → (∀ (x : ℝ), y x = ∑ i ∈ Finset.range k, (a i + x).cos / 2 ^ i) → y m = 0 → y n = 0 → ∃ t, m - n = ↑t * π\n",
350
+ "binders=9 sort=Prop\n",
351
+ "====================================================================================================\n",
352
+ "mathd_algebra_209\n",
353
+ "mathd_algebra_209 : ∀ (σ : ℝ ≃ ℝ), σ.invFun 2 = 10 → σ.invFun 10 = 1 → σ.invFun 1 = 2 → σ.toFun (σ.toFun 10) = 1\n",
354
+ "binders=4 sort=Prop\n",
355
+ "====================================================================================================\n",
356
+ "mathd_numbertheory_1124\n",
357
+ "mathd_numbertheory_1124 : ∀ n ≤ 9, 18 ∣ 374 * 10 + n → n = 4\n",
358
+ "binders=3 sort=Prop\n"
359
+ ]
360
+ },
361
+ {
362
+ "name": "stdout",
363
+ "output_type": "stream",
364
+ "text": [
365
+ "====================================================================================================\n",
366
+ "imo_1983_p6\n",
367
+ "imo_1983_p6 : ∀ (a b c : ℝ), 0 < a ∧ 0 < b ∧ 0 < c → c < a + b → b < a + c → a < b + c → 0 ≤ a ^ 2 * b * (a - b) + b ^ 2 * c * (b - c) + c ^ 2 * a * (c - a)\n",
368
+ "binders=7 sort=Prop\n",
369
+ "====================================================================================================\n",
370
+ "mathd_algebra_33\n",
371
+ "mathd_algebra_33 : ∀ (x y z : ℝ), x ≠ 0 → 2 * x = 5 * y → 7 * y = 10 * z → z / x = 7 / 25\n",
372
+ "binders=6 sort=Prop\n"
373
+ ]
374
+ }
375
+ ],
376
+ "source": [
377
+ "PICK = ['mathd_algebra_478', 'numbertheory_4x3m7y3neq2003', 'aime_1983_p1', 'amc12_2001_p5', 'mathd_numbertheory_3',\n",
378
+ " 'imo_1969_p2', 'mathd_algebra_209', 'mathd_numbertheory_1124', 'imo_1983_p6', 'mathd_algebra_33']\n",
379
+ "rows = {}\n",
380
+ "for l_ in open('/apps/development/rlhf/p2_runs/r1_baseline/minif2f.jsonl'):\n",
381
+ " r_ = json.loads(l_)\n",
382
+ " if r_['problem_id'] in PICK: rows[r_['problem_id']] = r_\n",
383
+ "assert set(rows) == set(PICK)\n",
384
+ "\n",
385
+ "SORT_ELAB = r'''\n",
386
+ "open Lean Meta Elab Command in\n",
387
+ "elab \"#sort_of \" id:ident : command => do\n",
388
+ " let env ← getEnv\n",
389
+ " match env.find? id.getId with\n",
390
+ " | none => throwError \"unknown\"\n",
391
+ " | some ci => liftTermElabM do\n",
392
+ " let s ← inferType ci.type\n",
393
+ " let nb := (← forallTelescope ci.type fun xs _ => pure xs.size)\n",
394
+ " logInfo m!\"binders={nb} sort={← whnf s}\"\n",
395
+ "'''\n",
396
+ "L.run(SORT_ELAB, keep=True)\n",
397
+ "\n",
398
+ "decoded = {}\n",
399
+ "for pid in PICK:\n",
400
+ " st = rows[pid]['formal_statement']\n",
401
+ " thm = st[st.index('/--'):] # docstring + theorem ... := by sorry\n",
402
+ " rest = thm\n",
403
+ " msgs, r = lean_msgs(rest + f\"\\n#check @{pid}\\n#sort_of {pid}\", timeout=300)\n",
404
+ " out = [m['data'] for m in msgs if m['severity'] == 'info']\n",
405
+ " errs = [m['data'] for m in msgs if m['severity'] == 'error']\n",
406
+ " assert not errs, errs\n",
407
+ " decoded[pid] = out\n",
408
+ " print('=' * 100); print(pid)\n",
409
+ " print(re.sub(r'\\n\\s+', ' ', out[0]))\n",
410
+ " print(out[1])"
411
+ ]
412
+ },
413
+ {
414
+ "cell_type": "markdown",
415
+ "id": "2d1b76ed",
416
+ "metadata": {},
417
+ "source": [
418
+ "Closed statements (no binders) are plain `Prop`s with a `Decidable` instance: Lean can *evaluate* them."
419
+ ]
420
+ },
421
+ {
422
+ "cell_type": "code",
423
+ "execution_count": 7,
424
+ "id": "852923ed",
425
+ "metadata": {
426
+ "execution": {
427
+ "iopub.execute_input": "2026-10-14T09:54:26.104415Z",
428
+ "iopub.status.busy": "2026-10-14T09:54:26.104258Z",
429
+ "iopub.status.idle": "2026-10-14T09:54:26.288412Z",
430
+ "shell.execute_reply": "2026-10-14T09:54:26.287495Z"
431
+ }
432
+ },
433
+ "outputs": [
434
+ {
435
+ "name": "stdout",
436
+ "output_type": "stream",
437
+ "text": [
438
+ "amc12_2001_p5 -> ['instDecidableEqNat ((Finset.filter (fun x => ¬Even x) (Finset.range 10000)).prod', 'true']\n",
439
+ "mathd_numbertheory_3 -> ['instDecidableEqNat ((∑ x ∈ Finset.range 10, (x + 1) ^ 2) % 10) 5', 'true']\n"
440
+ ]
441
+ },
442
+ {
443
+ "name": "stdout",
444
+ "output_type": "stream",
445
+ "text": [
446
+ "[\"'nt3' depends on axioms: [propext, Quot.sound]\"]\n"
447
+ ]
448
+ }
449
+ ],
450
+ "source": [
451
+ "for pid in ['amc12_2001_p5', 'mathd_numbertheory_3']:\n",
452
+ " st = rows[pid]['formal_statement']\n",
453
+ " body = st.split(f'theorem {pid} :')[1].split(':= by sorry')[0].strip()\n",
454
+ " msgs, _ = lean_msgs(f\"#synth Decidable ({body})\\n#eval decide ({body})\", timeout=300)\n",
455
+ " print(pid, '->', [m['data'].strip()[:80] for m in msgs])\n",
456
+ "msgs, _ = lean_msgs(\"theorem nt3 : (∑ x in Finset.range 10, (x + 1) ^ 2) % 10 = 5 := by decide\\n#print axioms nt3\")\n",
457
+ "print([m['data'] for m in msgs])"
458
+ ]
459
+ },
460
+ {
461
+ "cell_type": "code",
462
+ "execution_count": 8,
463
+ "id": "b59c7854",
464
+ "metadata": {
465
+ "execution": {
466
+ "iopub.execute_input": "2026-10-14T09:54:26.290114Z",
467
+ "iopub.status.busy": "2026-10-14T09:54:26.289954Z",
468
+ "iopub.status.idle": "2026-10-14T09:54:26.591691Z",
469
+ "shell.execute_reply": "2026-10-14T09:54:26.590442Z"
470
+ }
471
+ },
472
+ "outputs": [
473
+ {
474
+ "name": "stdout",
475
+ "output_type": "stream",
476
+ "text": [
477
+ "mathd_algebra_478 OK witness b=30,h=13/2 => v=65 exactly\n",
478
+ "numbertheory_4x3m7y3neq2003 OK cubes mod 7 = [0, 1, 6]; 4*cubes mod 7 = [0, 3, 4]; 2003 mod 7 = 1 not attained; box [-80,80]^2 empty\n",
479
+ "aime_1983_p1 OK fp64 max hypothesis residual 0.00e+00, conclusion residual 0.00e+00; exact: L/(L/12-L/24-L/40)=60\n",
480
+ "amc12_2001_p5 OK product of odd numbers < 10000 == 10000!/(2^5000 * 5000!) as big integers; division exact\n",
481
+ "mathd_numbertheory_3 OK sum = 385, 385 mod 10 = 5\n",
482
+ "imo_1969_p2 OK 200 random (k,a): y(x)=A cos x - B sin x; zeros m,n: max |y(m)|,|y(n)|,|(m-n)/π - t| = 3.7e-15; |(A,B)| >= 2^(1-k) > 0 in all 200 runs\n",
483
+ "mathd_algebra_209 OK σ = {10: 2, 1: 10, 2: 1}; σ(σ(10)) = 1\n",
484
+ "mathd_numbertheory_1124 OK solutions n in 0..9 with 18 | 3740+n: [4]\n"
485
+ ]
486
+ },
487
+ {
488
+ "name": "stdout",
489
+ "output_type": "stream",
490
+ "text": [
491
+ "imo_1983_p6 OK min over 2e5 random triangles of E/a^4 = 6.588e-08 (>= 0); a=b=c gives exactly 0\n",
492
+ "mathd_algebra_33 OK x=1,y=2/5,z=7/25: z/x = 7/25 exactly\n",
493
+ "all 10 statements: hypotheses satisfiable (non-vacuous) and conclusion verified at witnesses / exhaustively.\n"
494
+ ]
495
+ }
496
+ ],
497
+ "source": [
498
+ "# ---- (b) Python witness checks, one per statement ----\n",
499
+ "def check_478():\n",
500
+ " b, h = Fraction(30), Fraction(13, 2)\n",
501
+ " v = Fraction(1, 3) * (b * h)\n",
502
+ " assert 0 < b and 0 < h and 0 < v # hypotheses satisfiable (h₀)\n",
503
+ " assert v == 65 # conclusion\n",
504
+ " return 'witness b=30,h=13/2 => v=65 exactly'\n",
505
+ "\n",
506
+ "def check_4x3m7y3():\n",
507
+ " cubes = {pow(n, 3, 7) for n in range(7)}\n",
508
+ " resid = {(4 * c) % 7 for c in cubes}\n",
509
+ " assert 2003 % 7 == 1 and 1 not in resid # the real proof: 4x^3 - 7y^3 ≡ 4x^3 (mod 7)\n",
510
+ " assert not any(4*x**3 - 7*y**3 == 2003 for x in range(-80, 81) for y in range(-80, 81))\n",
511
+ " return f'cubes mod 7 = {sorted(cubes)}; 4*cubes mod 7 = {sorted(resid)}; 2003 mod 7 = 1 not attained; box [-80,80]^2 empty'\n",
512
+ "\n",
513
+ "def check_aime():\n",
514
+ " # w = 2^120, x = 2^5, y = 2^3, z = 2^2 : satisfies all three hypotheses; conclusion log_z w = 60\n",
515
+ " x, y, z, w = 2**5, 2**3, 2**2, 2**120\n",
516
+ " errs = [abs(math.log(w)/math.log(x) - 24), abs(math.log(w)/math.log(y) - 40), abs(math.log(w)/math.log(x*y*z) - 12)]\n",
517
+ " concl = abs(math.log(w)/math.log(z) - 60)\n",
518
+ " assert max(errs) < 1e-12 and concl < 1e-12\n",
519
+ " Lw = Fraction(120); lz = Lw/12 - Lw/24 - Lw/40 # log z = log(xyz) - log x - log y\n",
520
+ " assert Lw / lz == 60 # exact derivation\n",
521
+ " return f'fp64 max hypothesis residual {max(errs):.2e}, conclusion residual {concl:.2e}; exact: L/(L/12-L/24-L/40)=60'\n",
522
+ "\n",
523
+ "def check_amc():\n",
524
+ " prod = 1\n",
525
+ " for x in range(10000):\n",
526
+ " if x % 2 == 1: prod *= x\n",
527
+ " rhs_num = math.factorial(10000); rhs_den = 2**5000 * math.factorial(5000)\n",
528
+ " assert rhs_num % rhs_den == 0 # the ℕ-division is exact, so truncation plays no role\n",
529
+ " assert prod == rhs_num // rhs_den\n",
530
+ " return 'product of odd numbers < 10000 == 10000!/(2^5000 * 5000!) as big integers; division exact'\n",
531
+ "\n",
532
+ "def check_nt3():\n",
533
+ " s = sum((x + 1) ** 2 for x in range(10)); assert s == 385 and s % 10 == 5\n",
534
+ " return 'sum = 385, 385 mod 10 = 5'\n",
535
+ "\n",
536
+ "def check_imo69():\n",
537
+ " random.seed(1); worst = 0.0\n",
538
+ " for _ in range(200):\n",
539
+ " k = random.randint(1, 8); a = [random.uniform(-10, 10) for _ in range(k)]\n",
540
+ " A = sum(math.cos(a[i]) / 2**i for i in range(k)); B = sum(math.sin(a[i]) / 2**i for i in range(k))\n",
541
+ " x0 = math.atan2(A, B) # y(x) = A cos x - B sin x = 0 <=> x = atan2(A,B) + tπ\n",
542
+ " y = lambda x: sum(math.cos(a[i] + x) / 2**i for i in range(k))\n",
543
+ " m, n = x0 + 3 * math.pi, x0 - 2 * math.pi\n",
544
+ " worst = max(worst, abs(y(m)), abs(y(n)), abs((m - n) / math.pi - 5))\n",
545
+ " assert math.hypot(A, B) >= 2.0 ** (1 - k) - 1e-12 # |sum_i e^{i a_i} 2^-i| >= 1 - sum_{i=1}^{k-1} 2^-i = 2^(1-k) > 0 (FINITE sum)\n",
546
+ " assert worst < 1e-12\n",
547
+ " return f'200 random (k,a): y(x)=A cos x - B sin x; zeros m,n: max |y(m)|,|y(n)|,|(m-n)/π - t| = {worst:.1e}; |(A,B)| >= 2^(1-k) > 0 in all 200 runs'\n",
548
+ "\n",
549
+ "def check_209():\n",
550
+ " inv = {2: 10, 10: 1, 1: 2} # σ.2 = invFun\n",
551
+ " sigma = {v: k for k, v in inv.items()}\n",
552
+ " assert sigma[sigma[10]] == 1\n",
553
+ " return f'σ = {sigma}; σ(σ(10)) = {sigma[sigma[10]]}'\n",
554
+ "\n",
555
+ "def check_1124():\n",
556
+ " sol = [n for n in range(10) if (374 * 10 + n) % 18 == 0]\n",
557
+ " assert sol == [4]\n",
558
+ " return f'solutions n in 0..9 with 18 | 3740+n: {sol}'\n",
559
+ "\n",
560
+ "def check_1983():\n",
561
+ " random.seed(2); worst = float('inf')\n",
562
+ " for _ in range(200000):\n",
563
+ " x, y, z = (random.uniform(1e-3, 10) for _ in range(3)) # Ravi: a=y+z, b=z+x, c=x+y => triangle inequalities hold\n",
564
+ " a, b, c = y + z, z + x, x + y\n",
565
+ " val = a*a*b*(a-b) + b*b*c*(b-c) + c*c*a*(c-a)\n",
566
+ " worst = min(worst, val / (a**4)) # scale-free\n",
567
+ " assert worst > -1e-12\n",
568
+ " t = Fraction(7); assert t*t*t*(t-t) * 3 == 0\n",
569
+ " return f'min over 2e5 random triangles of E/a^4 = {worst:.3e} (>= 0); a=b=c gives exactly 0'\n",
570
+ "\n",
571
+ "def check_33():\n",
572
+ " x = Fraction(1); y = 2 * x / 5; z = 7 * y / 10\n",
573
+ " assert x != 0 and 2*x == 5*y and 7*y == 10*z and z / x == Fraction(7, 25)\n",
574
+ " return f'x=1,y={y},z={z}: z/x = 7/25 exactly'\n",
575
+ "\n",
576
+ "CHECKS = {'mathd_algebra_478': check_478, 'numbertheory_4x3m7y3neq2003': check_4x3m7y3, 'aime_1983_p1': check_aime,\n",
577
+ " 'amc12_2001_p5': check_amc, 'mathd_numbertheory_3': check_nt3, 'imo_1969_p2': check_imo69,\n",
578
+ " 'mathd_algebra_209': check_209, 'mathd_numbertheory_1124': check_1124, 'imo_1983_p6': check_1983,\n",
579
+ " 'mathd_algebra_33': check_33}\n",
580
+ "for pid in PICK:\n",
581
+ " print(f'{pid:30s} OK {CHECKS[pid]()}')\n",
582
+ "print('all 10 statements: hypotheses satisfiable (non-vacuous) and conclusion verified at witnesses / exhaustively.')"
583
+ ]
584
+ },
585
+ {
586
+ "cell_type": "markdown",
587
+ "id": "4fadce18",
588
+ "metadata": {},
589
+ "source": [
590
+ "## 3b. Lemma checks (section 3.4 of the note)\n",
591
+ "`(a - b) + b = max a b` on ℕ (exhaustive in a box AND a Lean proof); the Euclidean division law `a = b*(a/b) + a%b, 0 <= a%b < |b|` for Lean's ℤ (exhaustive grid evaluated by Lean); and `x / 0 = 0` on ℝ."
592
+ ]
593
+ },
594
+ {
595
+ "cell_type": "code",
596
+ "execution_count": 9,
597
+ "id": "165b7f70",
598
+ "metadata": {
599
+ "execution": {
600
+ "iopub.execute_input": "2026-10-14T09:54:26.593548Z",
601
+ "iopub.status.busy": "2026-10-14T09:54:26.593364Z",
602
+ "iopub.status.idle": "2026-10-14T09:54:27.071951Z",
603
+ "shell.execute_reply": "2026-10-14T09:54:27.071277Z"
604
+ }
605
+ },
606
+ "outputs": [
607
+ {
608
+ "name": "stdout",
609
+ "output_type": "stream",
610
+ "text": [
611
+ "Lean: a - b + b = max a b proved; 'tsub_add_max' depends on axioms: [propext, Quot.sound] | and the naive \"a - b + b = a\" is refuted at (0,1)\n"
612
+ ]
613
+ },
614
+ {
615
+ "name": "stdout",
616
+ "output_type": "stream",
617
+ "text": [
618
+ "Lean ℤ: b*(a/b) + a%b = a and 0 <= a%b < |b| on 1464 grid points; Lean (a/b, a%b) == the unique Euclidean (q,r) on all of them; x/0 = 0, x%0 = x\n"
619
+ ]
620
+ },
621
+ {
622
+ "name": "stdout",
623
+ "output_type": "stream",
624
+ "text": [
625
+ "Lean ℝ: ∀ x, x / 0 = 0 proved (div_zero); 'real_div_zero' depends on axioms: [propext, Classical.choice, Quot.sound]\n"
626
+ ]
627
+ }
628
+ ],
629
+ "source": [
630
+ "\n",
631
+ "# (i) truncated subtraction: (a - b) + b = max a b\n",
632
+ "assert all(max(0, a - b) + b == max(a, b) for a in range(61) for b in range(61))\n",
633
+ "msgs, _ = lean_msgs(\"\"\"\n",
634
+ "theorem tsub_add_max (a b : ℕ) : a - b + b = max a b := by omega\n",
635
+ "#print axioms tsub_add_max\n",
636
+ "theorem tsub_add_false : ¬ (∀ a b : ℕ, a - b + b = a) := fun h => by have := h 0 1; simp at this\n",
637
+ "\"\"\")\n",
638
+ "assert not [m for m in msgs if m['severity'] == 'error'], msgs\n",
639
+ "print('Lean: a - b + b = max a b proved;', [m['data'] for m in msgs if m['severity'] == 'info'][0], '| and the naive \"a - b + b = a\" is refuted at (0,1)')\n",
640
+ "# (ii) Euclidean division law for Int, exhaustive on a grid, evaluated by Lean\n",
641
+ "msgs, _ = lean_msgs(\"\"\"\n",
642
+ "#eval (List.range 61).all fun ai => (List.range 25).all fun bi =>\n",
643
+ " let a : Int := (ai : Int) - 30\n",
644
+ " let b : Int := (bi : Int) - 12\n",
645
+ " b == 0 || ((b * (a / b) + a % b == a) && decide (0 ≤ a % b) && decide (a % b < (b.natAbs : Int)))\n",
646
+ "#eval (List.range 61).all fun ai => (List.range 25).all fun bi => ((ai : Int) - 30) / 0 == 0 && ((ai : Int) - 30) % 0 == (ai : Int) - 30\n",
647
+ "\"\"\")\n",
648
+ "res = [m['data'].strip() for m in msgs if m['severity'] == 'info']\n",
649
+ "assert res == ['true', 'true'], (res, msgs)\n",
650
+ "# the unique (q, r) with a = b q + r, 0 <= r < |b| (Python model) must equal Lean's / and %\n",
651
+ "def euclid(a, b):\n",
652
+ " r = a % abs(b); q = (a - r) // b; return q, r\n",
653
+ "lean_vals = lean_msgs(\"#eval (List.range 61).bind fun ai => (List.range 25).filterMap fun bi => let a : Int := (ai : Int) - 30; let b : Int := (bi : Int) - 12; if b == 0 then none else some (a / b, a % b)\")[0]\n",
654
+ "import ast\n",
655
+ "lv = ast.literal_eval(lean_vals[0]['data'].replace('(', '(').strip()) if lean_vals and lean_vals[0]['severity'] == 'info' else None\n",
656
+ "pv = [euclid(ai - 30, bi - 12) for ai in range(61) for bi in range(25) if bi != 12]\n",
657
+ "assert lv is not None and [tuple(x) for x in lv] == pv, 'Lean Int / and % differ from the Euclidean (q, r) model'\n",
658
+ "print(f'Lean ℤ: b*(a/b) + a%b = a and 0 <= a%b < |b| on {len(pv)} grid points; Lean (a/b, a%b) == the unique Euclidean (q,r) on all of them; x/0 = 0, x%0 = x')\n",
659
+ "# (iii) x / 0 = 0 on the reals\n",
660
+ "msgs, _ = lean_msgs(\"theorem real_div_zero : ∀ x : ℝ, x / 0 = 0 := fun x => div_zero x\\n#print axioms real_div_zero\")\n",
661
+ "assert not [m for m in msgs if m['severity'] == 'error'], msgs\n",
662
+ "print('Lean ℝ: ∀ x, x / 0 = 0 proved (div_zero);', [m['data'] for m in msgs if m['severity'] == 'info'][0])\n"
663
+ ]
664
+ },
665
+ {
666
+ "cell_type": "markdown",
667
+ "id": "8d2c076e",
668
+ "metadata": {},
669
+ "source": [
670
+ "## 4. Elaboration traps (where a *correct-looking* statement means something else)"
671
+ ]
672
+ },
673
+ {
674
+ "cell_type": "code",
675
+ "execution_count": 10,
676
+ "id": "85822ae4",
677
+ "metadata": {
678
+ "execution": {
679
+ "iopub.execute_input": "2026-10-14T09:54:27.074232Z",
680
+ "iopub.status.busy": "2026-10-14T09:54:27.074069Z",
681
+ "iopub.status.idle": "2026-10-14T09:54:27.447944Z",
682
+ "shell.execute_reply": "2026-10-14T09:54:27.447329Z"
683
+ }
684
+ },
685
+ "outputs": [
686
+ {
687
+ "name": "stdout",
688
+ "output_type": "stream",
689
+ "text": [
690
+ "(5 : ℕ) - 7 = 0\n",
691
+ "(7 : ℕ) / 2 = 3\n",
692
+ "(7 : ℤ) / (-2) = -3\n",
693
+ "(-7 : ℤ) / 2 = -4\n",
694
+ "(-7 : ℤ) % 3 = 2\n",
695
+ "(((5 : ℕ) - 7 : ℕ) : ℤ) = 0\n",
696
+ "((5 : ℕ) : ℤ) - 7 = -2\n",
697
+ "((10 : ℕ) - 3) / 2 = 3\n",
698
+ "(2 : ℕ) ^ 3 ^ 2 = 512\n",
699
+ "Lean 7/(-2) = -3 and (-7)/2 = -4 (Euclidean: remainder is always >= 0)\n",
700
+ "--- autoImplicit: a typo silently becomes a new universally-quantified variable ---\n",
701
+ "warning declaration uses 'sorry'\n",
702
+ "info @t_typo : ∀ {xx : ℕ} (x : ℕ), x + 1 > xx\n",
703
+ "--- the same with autoImplicit off: an error, as it should be ---\n",
704
+ "error unknown identifier 'xx'\n"
705
+ ]
706
+ }
707
+ ],
708
+ "source": [
709
+ "TRAPS = [\n",
710
+ " (\"(5 : ℕ) - 7\", lambda: nsub(5, 7), \"0\"),\n",
711
+ " (\"(7 : ℕ) / 2\", lambda: 7 // 2, \"3\"),\n",
712
+ " (\"(7 : ℤ) / (-2)\", lambda: 'ediv', None),\n",
713
+ " (\"(-7 : ℤ) / 2\", lambda: 'ediv', None),\n",
714
+ " (\"(-7 : ℤ) % 3\", lambda: (-7) % 3, \"2\"),\n",
715
+ " (\"(((5 : ℕ) - 7 : ℕ) : ℤ)\", lambda: nsub(5, 7), \"0\"),\n",
716
+ " (\"((5 : ℕ) : ℤ) - 7\", lambda: 5 - 7, \"-2\"),\n",
717
+ " (\"((10 : ℕ) - 3) / 2\", lambda: (10 - 3) // 2, \"3\"),\n",
718
+ " (\"(2 : ℕ) ^ 3 ^ 2\", lambda: 2 ** (3 ** 2), \"512\"),\n",
719
+ "]\n",
720
+ "msgs, _ = lean_msgs(\"\\n\".join(f\"#eval {s}\" for s, _, _ in TRAPS))\n",
721
+ "by_line = {m['pos']['line']: m['data'].strip() for m in msgs}\n",
722
+ "for i, (s, fn, expect) in enumerate(TRAPS, start=1):\n",
723
+ " got = by_line[i]\n",
724
+ " if expect is not None:\n",
725
+ " assert got == expect, (s, got, expect)\n",
726
+ " py = fn(); assert str(py) == got, (s, py, got)\n",
727
+ " print(f'{s:28s} = {got}')\n",
728
+ "# Lean's ℤ `/` is Euclidean-style `Int.div` (T-rounding is `Int.tdiv`): -7/2 = -4, 7/(-2) = -3 ; Python's // floors: -7//2 = -4, 7//(-2) = -4\n",
729
+ "print('Lean 7/(-2) =', by_line[3], ' and (-7)/2 =', by_line[4], '(Euclidean: remainder is always >= 0)')\n",
730
+ "assert by_line[4] == '-4' and by_line[3] == '-3'\n",
731
+ "assert (-7) // 2 == -4 and 7 // -2 == -4 # python floors: differs from Lean on 7 / (-2)\n",
732
+ "\n",
733
+ "print('--- autoImplicit: a typo silently becomes a new universally-quantified variable ---')\n",
734
+ "for m in lean_msgs(\"theorem t_typo (x : ℕ) : x + 1 > xx := by sorry\\n#check @t_typo\")[0]: print(m['severity'], m['data'][:300])\n",
735
+ "print('--- the same with autoImplicit off: an error, as it should be ---')\n",
736
+ "for m in lean_msgs(\"set_option autoImplicit false in\\ntheorem t_typo2 (x : ℕ) : x + 1 > xx := by sorry\")[0]: print(m['severity'], m['data'][:200])\n",
737
+ "L.close()"
738
+ ]
739
+ }
740
+ ],
741
+ "metadata": {
742
+ "kernelspec": {
743
+ "display_name": "Python 3",
744
+ "language": "python",
745
+ "name": "python3"
746
+ },
747
+ "language_info": {
748
+ "codemirror_mode": {
749
+ "name": "ipython",
750
+ "version": 3
751
+ },
752
+ "file_extension": ".py",
753
+ "mimetype": "text/x-python",
754
+ "name": "python",
755
+ "nbconvert_exporter": "python",
756
+ "pygments_lexer": "ipython3",
757
+ "version": "3.12.11"
758
+ }
759
+ },
760
+ "nbformat": 4,
761
+ "nbformat_minor": 5
762
+ }
code/notebooks/0303-hoare-refinement.ipynb ADDED
@@ -0,0 +1,852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "8fc053db",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0303 - Hoare logic and refinement; verifying a small function in Lean 4\n",
9
+ "\n",
10
+ "Scratch-pad for note `0303 - Hoare Logic.pdf`. Naive first, efficient second, assert agreement.\n",
11
+ "\n",
12
+ "**A. A weakest-precondition calculus for a tiny `while` language** (sympy for substitution).\n",
13
+ "* NAIVE = *run* the program on every state of a box and check the postcondition (semantic weakest precondition, `wp_sem`).\n",
14
+ "* EFFICIENT = *compute* `wp` by the syntactic rules (substitution) and verification conditions (VCs) for loops.\n",
15
+ "* Assert: `wp_syn == wp_sem` on 300 random loop-free programs; VCs valid => the Hoare triple holds on every box state;\n",
16
+ " wrong invariants are rejected, and we classify the counterexample (reachable = real bug, unreachable = proof artefact).\n",
17
+ "\n",
18
+ "**B. A verified linear search in Lean 4** (the DafnyBench Fig. 2 `LinearSearch`): code, loop invariant as an induction\n",
19
+ "hypothesis, theorem, and what Lean says about an off-by-one variant. **C. Integers**: spec over Z, code over int32.\n",
20
+ "\n",
21
+ "No GPU. Lean: ONE worker via `align.verify.lean`, nice 15, after the MemAvailable>=45 GB check in the helper."
22
+ ]
23
+ },
24
+ {
25
+ "cell_type": "code",
26
+ "execution_count": 1,
27
+ "id": "7964bb54",
28
+ "metadata": {
29
+ "execution": {
30
+ "iopub.execute_input": "2026-10-14T09:55:15.064599Z",
31
+ "iopub.status.busy": "2026-10-14T09:55:15.064441Z",
32
+ "iopub.status.idle": "2026-10-14T09:55:15.330291Z",
33
+ "shell.execute_reply": "2026-10-14T09:55:15.329450Z"
34
+ }
35
+ },
36
+ "outputs": [],
37
+ "source": [
38
+ "import os\n",
39
+ "os.environ[\"CUDA_VISIBLE_DEVICES\"] = \"\"\n",
40
+ "import itertools, random, sys, time\n",
41
+ "import sympy as sp\n",
42
+ "random.seed(0)\n",
43
+ "n, i, s, x, y = sp.symbols('n i s x y', integer=True)\n",
44
+ "\n",
45
+ "# AST: ('skip',) | ('assign', var, expr) | ('seq', S1, S2) | ('if', cond, S1, S2) | ('while', cond, inv, variant, body)\n",
46
+ "def seq(*ss):\n",
47
+ " out = ss[-1]\n",
48
+ " for t in reversed(ss[:-1]): out = ('seq', t, out)\n",
49
+ " return out\n",
50
+ "def imp(a, b): return sp.Or(sp.Not(a), b)\n",
51
+ "\n",
52
+ "_cache = {}\n",
53
+ "def ev(expr, st):\n",
54
+ " key = (expr, tuple(sorted(st, key=str)))\n",
55
+ " if key not in _cache:\n",
56
+ " syms = sorted(st, key=str)\n",
57
+ " _cache[key] = (syms, sp.lambdify(syms, expr, 'math'))\n",
58
+ " syms, f = _cache[key]\n",
59
+ " return f(*[st[v] for v in syms])\n",
60
+ "\n",
61
+ "class NonTerminating(Exception): pass\n",
62
+ "\n",
63
+ "def run(S, st, fuel=10_000):\n",
64
+ " \"\"\"Big-step semantics <S, st> => st'. Returns the final state; raises NonTerminating when fuel is out.\"\"\"\n",
65
+ " st = dict(st)\n",
66
+ " def go(S):\n",
67
+ " nonlocal fuel\n",
68
+ " k = S[0]\n",
69
+ " if k == 'skip': return\n",
70
+ " if k == 'assign': st[S[1]] = ev(S[2], st); return\n",
71
+ " if k == 'seq': go(S[1]); go(S[2]); return\n",
72
+ " if k == 'if': go(S[2] if ev(S[1], st) else S[3]); return\n",
73
+ " if k == 'while':\n",
74
+ " while ev(S[1], st):\n",
75
+ " fuel -= 1\n",
76
+ " if fuel < 0: raise NonTerminating\n",
77
+ " go(S[4])\n",
78
+ " go(S)\n",
79
+ " return st\n",
80
+ "\n",
81
+ "def wp(S, Q):\n",
82
+ " \"\"\"Weakest precondition (loop-free fragment, plus `while` handled via its invariant: wp = I, see vcs()).\"\"\"\n",
83
+ " k = S[0]\n",
84
+ " if k == 'skip': return Q\n",
85
+ " if k == 'assign': return Q.subs(S[1], S[2], simultaneous=True)\n",
86
+ " if k == 'seq': return wp(S[1], wp(S[2], Q))\n",
87
+ " if k == 'if': return sp.Or(sp.And(S[1], wp(S[2], Q)), sp.And(sp.Not(S[1]), wp(S[3], Q)))\n",
88
+ " if k == 'while': return S[2] # annotated invariant; its VCs are generated by vcs()\n",
89
+ " raise ValueError(k)\n",
90
+ "\n",
91
+ "_ghost_ctr = itertools.count()\n",
92
+ "\n",
93
+ "def vcs(S, Q):\n",
94
+ " \"\"\"Verification conditions that make wp(S,Q) (with loops replaced by their invariants) sound. Returns [(name, formula)].\"\"\"\n",
95
+ " k = S[0]\n",
96
+ " if k in ('skip', 'assign'): return []\n",
97
+ " if k == 'seq': return vcs(S[1], wp(S[2], Q)) + vcs(S[2], Q)\n",
98
+ " if k == 'if': return vcs(S[2], Q) + vcs(S[3], Q)\n",
99
+ " if k == 'while':\n",
100
+ " b, I, V, body = S[1], S[2], S[3], S[4]\n",
101
+ " v0 = S[5] if len(S) > 5 else sp.Symbol(f'v0_{next(_ghost_ctr)}', integer=True) # one ghost per loop (nested loops must not share it)\n",
102
+ " ante = sp.And(I, b) if V is None else sp.And(I, b, sp.Eq(V, v0)) # v0 = the (ghost) value of the variant at loop entry\n",
103
+ " out = [('preserve', imp(ante, wp(body, I))), # I is preserved (the I-part of the body VC, reported separately)\n",
104
+ " ('exit', imp(sp.And(I, sp.Not(b)), Q))]\n",
105
+ " if V is not None:\n",
106
+ " R = sp.And(I, V < v0) # ONE postcondition for the body: I and the variant drop\n",
107
+ " out += [('variant>=0', imp(I, V >= 0)),\n",
108
+ " ('variant-decreases', imp(sp.And(I, b, sp.Eq(V, v0)), wp(body, R)))]\n",
109
+ " return out + vcs(body, R) # (needed for nested loops: their exit VCs must imply V < v0)\n",
110
+ " return out + vcs(body, I)\n",
111
+ " raise ValueError(k)\n",
112
+ "\n",
113
+ "def box(vars_, lo, hi):\n",
114
+ " return [dict(zip(vars_, p)) for p in itertools.product(range(lo, hi + 1), repeat=len(vars_))]\n",
115
+ "\n",
116
+ "GHOST = range(-12, 13)\n",
117
+ "def holds(F, st, ghost=None):\n",
118
+ " \"\"\"Validity of F at the state st. Free symbols that are not program variables (the ghost values v0 of the variant) are\n",
119
+ " universally quantified over a range. (Before the fix they were silently left unbound and Eq(V, v0) was structurally False,\n",
120
+ " which made every 'variant-decreases' VC vacuously true.)\"\"\"\n",
121
+ " ghosts = sorted(F.free_symbols - set(st), key=str)\n",
122
+ " if not ghosts: return bool(ev(F, st))\n",
123
+ " return all(bool(ev(F, {**st, **dict(zip(ghosts, gv))})) for gv in itertools.product(ghost or GHOST, repeat=len(ghosts)))"
124
+ ]
125
+ },
126
+ {
127
+ "cell_type": "markdown",
128
+ "id": "c8f54cc4",
129
+ "metadata": {},
130
+ "source": [
131
+ "## A1. Straight-line and conditional code: `wp` by substitution equals the semantic weakest precondition\n",
132
+ "Semantic wp: `{ st | S terminates from st and the final state satisfies Q }`. We compare the *set* of box states on both sides."
133
+ ]
134
+ },
135
+ {
136
+ "cell_type": "code",
137
+ "execution_count": 2,
138
+ "id": "fe2daf34",
139
+ "metadata": {
140
+ "execution": {
141
+ "iopub.execute_input": "2026-10-14T09:55:15.332288Z",
142
+ "iopub.status.busy": "2026-10-14T09:55:15.332056Z",
143
+ "iopub.status.idle": "2026-10-14T09:55:19.396869Z",
144
+ "shell.execute_reply": "2026-10-14T09:55:19.395883Z"
145
+ }
146
+ },
147
+ "outputs": [
148
+ {
149
+ "name": "stdout",
150
+ "output_type": "stream",
151
+ "text": [
152
+ "300 random loop-free programs x 49 states = 14700 (program,state) pairs; wp_syn != wp_sem on 0 (4.0s)\n"
153
+ ]
154
+ }
155
+ ],
156
+ "source": [
157
+ "VARS = [x, y]\n",
158
+ "EXPRS = [x + 1, x - 1, 2 * x, y, x + y, x - y, y + 3, sp.Integer(0), x * y]\n",
159
+ "CONDS = [sp.Lt(x, y), sp.Le(x, 0), sp.Eq(x, y), sp.Gt(y, 1), sp.Ne(x + y, 2)]\n",
160
+ "POSTS = [sp.Eq(x, y), sp.Ge(x, 0), sp.Lt(x + y, 4), sp.Eq(2 * x, y + 1), sp.And(sp.Ge(x, 0), sp.Ge(y, 0)), sp.Ne(x, y)]\n",
161
+ "\n",
162
+ "def rand_prog(d):\n",
163
+ " r = random.random()\n",
164
+ " if d == 0 or r < 0.35: return ('assign', random.choice(VARS), random.choice(EXPRS))\n",
165
+ " if r < 0.7: return ('seq', rand_prog(d - 1), rand_prog(d - 1))\n",
166
+ " return ('if', random.choice(CONDS), rand_prog(d - 1), rand_prog(d - 1))\n",
167
+ "\n",
168
+ "STATES = box(VARS, -3, 3)\n",
169
+ "checked = mism = 0\n",
170
+ "t0 = time.time()\n",
171
+ "for _ in range(300):\n",
172
+ " S = rand_prog(3); Q = random.choice(POSTS)\n",
173
+ " W = wp(S, Q)\n",
174
+ " for st in STATES:\n",
175
+ " syn = holds(W, st)\n",
176
+ " sem = holds(Q, run(S, st)) # loop-free: always terminates\n",
177
+ " checked += 1; mism += (syn != sem)\n",
178
+ "assert mism == 0\n",
179
+ "print(f'300 random loop-free programs x {len(STATES)} states = {checked} (program,state) pairs; wp_syn != wp_sem on {mism} ({time.time()-t0:.1f}s)')"
180
+ ]
181
+ },
182
+ {
183
+ "cell_type": "markdown",
184
+ "id": "c520492b",
185
+ "metadata": {},
186
+ "source": [
187
+ "## A2. Loops: invariant + variant. The worked example `sum`\n",
188
+ "```\n",
189
+ "{n >= 0} i := 0; s := 0; while i < n [inv I, variant n - i] do s := s + i; i := i + 1 {2 s = n (n - 1)}\n",
190
+ "```\n",
191
+ "`I : 0 <= i <= n and 2 s = i (i - 1)`."
192
+ ]
193
+ },
194
+ {
195
+ "cell_type": "code",
196
+ "execution_count": 3,
197
+ "id": "cb9b378a",
198
+ "metadata": {
199
+ "execution": {
200
+ "iopub.execute_input": "2026-10-14T09:55:19.398693Z",
201
+ "iopub.status.busy": "2026-10-14T09:55:19.398538Z",
202
+ "iopub.status.idle": "2026-10-14T09:55:21.057597Z",
203
+ "shell.execute_reply": "2026-10-14T09:55:21.056648Z"
204
+ }
205
+ },
206
+ "outputs": [
207
+ {
208
+ "name": "stdout",
209
+ "output_type": "stream",
210
+ "text": [
211
+ "correct invariant all VCs valid on box: True\n",
212
+ "semantic run on 48 inputs satisfying/not P: triple holds -> True\n"
213
+ ]
214
+ }
215
+ ],
216
+ "source": [
217
+ "body = seq(('assign', s, s + i), ('assign', i, i + 1))\n",
218
+ "cond = sp.Lt(i, n)\n",
219
+ "I_good = sp.And(sp.Ge(i, 0), sp.Le(i, n), sp.Eq(2 * s, i * (i - 1)))\n",
220
+ "P = sp.Ge(n, 0)\n",
221
+ "Qpost = sp.Eq(2 * s, n * (n - 1))\n",
222
+ "def program(inv, variant=n - i, bodyS=body):\n",
223
+ " return seq(('assign', i, sp.Integer(0)), ('assign', s, sp.Integer(0)), ('while', cond, inv, variant, bodyS))\n",
224
+ "\n",
225
+ "BOX = box([n, i, s], -2, 9)\n",
226
+ "BOX = [st for st in BOX if -2 <= st[s] <= 40] if False else BOX\n",
227
+ "\n",
228
+ "def verify(S, P, Q, label, reachable_states=None):\n",
229
+ " \"\"\"Generate all VCs (including P => wp(S,Q)) and discharge each on the box. Return dict name->counterexample or None.\"\"\"\n",
230
+ " conds = [('pre=>wp', imp(P, wp(S, Q)))] + vcs(S, Q)\n",
231
+ " res = {}\n",
232
+ " for name, F in conds:\n",
233
+ " bad = next((st for st in BOX if not holds(F, st)), None)\n",
234
+ " res[name] = bad\n",
235
+ " ok = all(v is None for v in res.values())\n",
236
+ " print(f'{label:34s} all VCs valid on box: {ok}' + ('' if ok else ' failing: ' + ', '.join(f'{k}@{ {str(a):b for a,b in v.items()} }' for k, v in res.items() if v)))\n",
237
+ " return res\n",
238
+ "\n",
239
+ "def triple_holds_semantically(S, P, Q, inputs):\n",
240
+ " \"\"\"Naive check: run from every input state satisfying P; the final state must satisfy Q (and the run must terminate).\"\"\"\n",
241
+ " for st in inputs:\n",
242
+ " if not holds(P, st): continue\n",
243
+ " try: fin = run(S, st)\n",
244
+ " except NonTerminating: return False, st\n",
245
+ " if not holds(Q, fin): return False, st\n",
246
+ " return True, None\n",
247
+ "\n",
248
+ "INPUTS = [dict(n=a, i=b, s=c) for a in range(-2, 10) for b in (0, 5) for c in (0, 7)]\n",
249
+ "INPUTS = [{n: d['n'], i: d['i'], s: d['s']} for d in INPUTS]\n",
250
+ "\n",
251
+ "r_good = verify(program(I_good), P, Qpost, 'correct invariant')\n",
252
+ "ok_sem, ce = triple_holds_semantically(program(I_good), P, Qpost, INPUTS)\n",
253
+ "assert all(v is None for v in r_good.values()) and ok_sem\n",
254
+ "print('semantic run on', len(INPUTS), 'inputs satisfying/not P: triple holds ->', ok_sem)"
255
+ ]
256
+ },
257
+ {
258
+ "cell_type": "markdown",
259
+ "id": "1b414665",
260
+ "metadata": {},
261
+ "source": [
262
+ "### A3. Five ways to fail, and what each failure means\n",
263
+ "1. **Weak invariant** (`0 <= i <= n` only): the *exit* VC fails. The program is correct; the proof attempt fails. The counterexample state is\n",
264
+ " *unreachable* from any state satisfying `P`.\n",
265
+ "2. **Too-strong invariant** (`i < n`): `pre=>wp` / `preserve` fail. Program correct, annotation wrong.\n",
266
+ "3. **Buggy program** (`s := s + i + 1`): VCs fail with the *right* invariant; running the program from a concrete input violates the post. A **real bug**.\n",
267
+ "4. **Wrong postcondition** (`2 s = n (n + 1)`): the spec is wrong for the code; both checks fail."
268
+ ]
269
+ },
270
+ {
271
+ "cell_type": "code",
272
+ "execution_count": 4,
273
+ "id": "a6bbfb08",
274
+ "metadata": {
275
+ "execution": {
276
+ "iopub.execute_input": "2026-10-14T09:55:21.059329Z",
277
+ "iopub.status.busy": "2026-10-14T09:55:21.059160Z",
278
+ "iopub.status.idle": "2026-10-14T09:55:26.415346Z",
279
+ "shell.execute_reply": "2026-10-14T09:55:26.414446Z"
280
+ }
281
+ },
282
+ "outputs": [
283
+ {
284
+ "name": "stdout",
285
+ "output_type": "stream",
286
+ "text": [
287
+ "1 weak invariant all VCs valid on box: False failing: exit@{'n': 0, 'i': 0, 's': -2}\n",
288
+ " counterexample {'n': 0, 'i': 0, 's': -2} -> reachable by the real program? False\n"
289
+ ]
290
+ },
291
+ {
292
+ "name": "stdout",
293
+ "output_type": "stream",
294
+ "text": [
295
+ "2 too-strong invariant all VCs valid on box: False failing: pre=>wp@{'n': 0, 'i': -2, 's': -2}, preserve@{'n': 1, 'i': 0, 's': 0}, variant-decreases@{'n': 1, 'i': 0, 's': 0}\n",
296
+ " but the program is correct (semantic check): True\n"
297
+ ]
298
+ },
299
+ {
300
+ "name": "stdout",
301
+ "output_type": "stream",
302
+ "text": [
303
+ "3 buggy program, right invariant all VCs valid on box: False failing: preserve@{'n': 1, 'i': 0, 's': 0}, variant-decreases@{'n': 1, 'i': 0, 's': 0}\n",
304
+ " concrete failing input from the run: {'n': 1, 'i': 0, 's': 0} -> confirmed bug\n"
305
+ ]
306
+ },
307
+ {
308
+ "name": "stdout",
309
+ "output_type": "stream",
310
+ "text": [
311
+ "4 wrong postcondition all VCs valid on box: False failing: exit@{'n': 1, 'i': 1, 's': 0}\n",
312
+ " run also violates it, e.g. {'n': 1, 'i': 0, 's': 0}\n"
313
+ ]
314
+ },
315
+ {
316
+ "name": "stdout",
317
+ "output_type": "stream",
318
+ "text": [
319
+ "5 wrong variant (V = i, increasing) all VCs valid on box: False failing: variant-decreases@{'n': 1, 'i': 0, 's': 0}\n",
320
+ " the program is still correct and terminates (semantic check): True -> the failing VC is about the PROOF of termination, not about a bug\n"
321
+ ]
322
+ }
323
+ ],
324
+ "source": [
325
+ "reachable = set()\n",
326
+ "for nv in range(0, 10):\n",
327
+ " st = {n: nv, i: 0, s: 0}\n",
328
+ " # collect every loop-head state of the correct program\n",
329
+ " cur = {n: nv, i: 0, s: 0}\n",
330
+ " while cur[i] < cur[n]:\n",
331
+ " reachable.add((cur[n], cur[i], cur[s])); cur[s] += cur[i]; cur[i] += 1\n",
332
+ " reachable.add((cur[n], cur[i], cur[s]))\n",
333
+ "\n",
334
+ "I_weak = sp.And(sp.Ge(i, 0), sp.Le(i, n))\n",
335
+ "r1 = verify(program(I_weak), P, Qpost, '1 weak invariant')\n",
336
+ "assert r1['exit'] is not None and all(r1[k] is None for k in ('pre=>wp', 'preserve'))\n",
337
+ "ce1 = r1['exit']; print(' counterexample', {str(k): v for k, v in ce1.items()}, '-> reachable by the real program?', (ce1[n], ce1[i], ce1[s]) in reachable)\n",
338
+ "assert (ce1[n], ce1[i], ce1[s]) not in reachable # a proof artefact, NOT a bug\n",
339
+ "\n",
340
+ "I_strong = sp.And(sp.Ge(i, 0), sp.Lt(i, n), sp.Eq(2 * s, i * (i - 1)))\n",
341
+ "r2 = verify(program(I_strong), P, Qpost, '2 too-strong invariant')\n",
342
+ "assert any(v is not None for v in r2.values())\n",
343
+ "print(' but the program is correct (semantic check):', triple_holds_semantically(program(I_good), P, Qpost, INPUTS)[0])\n",
344
+ "\n",
345
+ "bug_body = seq(('assign', s, s + i + 1), ('assign', i, i + 1))\n",
346
+ "r3 = verify(program(I_good, bodyS=bug_body), P, Qpost, '3 buggy program, right invariant')\n",
347
+ "ok3, ce3 = triple_holds_semantically(program(I_good, bodyS=bug_body), P, Qpost, INPUTS)\n",
348
+ "assert r3['preserve'] is not None and not ok3\n",
349
+ "print(' concrete failing input from the run:', {str(k): v for k, v in ce3.items()}, '-> confirmed bug')\n",
350
+ "\n",
351
+ "Q_wrong = sp.Eq(2 * s, n * (n + 1))\n",
352
+ "r4 = verify(program(I_good), P, Q_wrong, '4 wrong postcondition')\n",
353
+ "ok4, ce4 = triple_holds_semantically(program(I_good), P, Q_wrong, INPUTS)\n",
354
+ "assert r4['exit'] is not None and not ok4\n",
355
+ "print(' run also violates it, e.g.', {str(k): v for k, v in ce4.items()})\n",
356
+ "\n",
357
+ "# 5. a WRONG VARIANT (V = i grows): the invariant and the code are right; only the termination argument is wrong\n",
358
+ "r5 = verify(program(I_good, variant=i), P, Qpost, '5 wrong variant (V = i, increasing)')\n",
359
+ "assert r5['variant-decreases'] is not None and r5['preserve'] is None and r5['exit'] is None\n",
360
+ "print(' the program is still correct and terminates (semantic check):', triple_holds_semantically(program(I_good), P, Qpost, INPUTS)[0],\n",
361
+ " '-> the failing VC is about the PROOF of termination, not about a bug')\n"
362
+ ]
363
+ },
364
+ {
365
+ "cell_type": "markdown",
366
+ "id": "df54905f",
367
+ "metadata": {},
368
+ "source": [
369
+ "### A4. Soundness of the `while` rule, checked numerically\n",
370
+ "For every loop program whose VCs (all four) are valid on the box, every box state satisfying the invariant and variant terminates in `Q`\n",
371
+ "(this is `I subseteq wp(while, Q)`, the total-correctness statement of Boxed result 3)."
372
+ ]
373
+ },
374
+ {
375
+ "cell_type": "code",
376
+ "execution_count": 5,
377
+ "id": "cd82f6da",
378
+ "metadata": {
379
+ "execution": {
380
+ "iopub.execute_input": "2026-10-14T09:55:26.416985Z",
381
+ "iopub.status.busy": "2026-10-14T09:55:26.416825Z",
382
+ "iopub.status.idle": "2026-10-14T09:55:26.469647Z",
383
+ "shell.execute_reply": "2026-10-14T09:55:26.468775Z"
384
+ }
385
+ },
386
+ "outputs": [
387
+ {
388
+ "name": "stdout",
389
+ "output_type": "stream",
390
+ "text": [
391
+ "40 box states satisfying I and variant>=0: every one terminates in Q (0 violations)\n"
392
+ ]
393
+ }
394
+ ],
395
+ "source": [
396
+ "cnt = viol = 0\n",
397
+ "for st in BOX:\n",
398
+ " if holds(I_good, st) and holds(n - i >= 0, st):\n",
399
+ " S_loop = ('while', cond, I_good, n - i, body)\n",
400
+ " fin = run(S_loop, st) # raises if it does not terminate\n",
401
+ " cnt += 1; viol += (not holds(Qpost, fin))\n",
402
+ "assert viol == 0\n",
403
+ "print(f'{cnt} box states satisfying I and variant>=0: every one terminates in Q ({viol} violations)')"
404
+ ]
405
+ },
406
+ {
407
+ "cell_type": "markdown",
408
+ "id": "c5b343fb",
409
+ "metadata": {},
410
+ "source": [
411
+ "### A5. Nested loops (why the body is verified against `I and V < v0` in ONE postcondition, with one ghost per loop)\n",
412
+ "```\n",
413
+ "{n >= 0 and m >= 0} i := 0; s := 0; while i < n [I1, V1 = n-i] do ( j := 0; while j < m [I2, V2 = m-j] do ( s := s+1; j := j+1 ); i := i+1 ) {s = n m}\n",
414
+ "I1 = 0<=i<=n and m>=0 and s = i m I2 = 0<=j<=m and 0<=i<n and m>=0 and s = i m + j and n-i = g1\n",
415
+ "```\n",
416
+ "`g1` is the ghost (`v0`) of the outer variant: the INNER loop's exit VC must imply `V1 < g1` for the outer body, so the inner invariant has to carry `n - i = g1`."
417
+ ]
418
+ },
419
+ {
420
+ "cell_type": "code",
421
+ "execution_count": 6,
422
+ "id": "b71a2c7b",
423
+ "metadata": {
424
+ "execution": {
425
+ "iopub.execute_input": "2026-10-14T09:55:26.471193Z",
426
+ "iopub.status.busy": "2026-10-14T09:55:26.471039Z",
427
+ "iopub.status.idle": "2026-10-14T09:55:35.643578Z",
428
+ "shell.execute_reply": "2026-10-14T09:55:35.642581Z"
429
+ }
430
+ },
431
+ "outputs": [
432
+ {
433
+ "name": "stdout",
434
+ "output_type": "stream",
435
+ "text": [
436
+ "9 VCs generated for the nested program: ['pre=>wp', 'preserve', 'exit', 'variant>=0', 'variant-decreases', 'preserve', 'exit', 'variant>=0', 'variant-decreases']\n",
437
+ "all VCs valid on the box; the program run from 9 inputs terminates in Q (s = n m)\n"
438
+ ]
439
+ },
440
+ {
441
+ "name": "stdout",
442
+ "output_type": "stream",
443
+ "text": [
444
+ "wrong inner variant (V2 = j): failing VCs -> ['variant-decreases']\n"
445
+ ]
446
+ }
447
+ ],
448
+ "source": [
449
+ "\n",
450
+ "m_, j_, g1, g2 = sp.symbols('m j g1 g2', integer=True)\n",
451
+ "cond1, cond2 = sp.Lt(i, n), sp.Lt(j_, m_)\n",
452
+ "I1 = sp.And(sp.Ge(i, 0), sp.Le(i, n), sp.Ge(m_, 0), sp.Eq(s, i * m_))\n",
453
+ "I2 = sp.And(sp.Ge(j_, 0), sp.Le(j_, m_), sp.Ge(i, 0), sp.Lt(i, n), sp.Ge(m_, 0), sp.Eq(s, i * m_ + j_), sp.Eq(n - i, g1))\n",
454
+ "def nested(inner_variant):\n",
455
+ " inner = ('while', cond2, I2, inner_variant, seq(('assign', s, s + 1), ('assign', j_, j_ + 1)), g2)\n",
456
+ " outer_body = seq(('assign', j_, sp.Integer(0)), inner, ('assign', i, i + 1))\n",
457
+ " return seq(('assign', i, sp.Integer(0)), ('assign', s, sp.Integer(0)), ('while', cond1, I1, n - i, outer_body, g1))\n",
458
+ "P_n, Q_n = sp.And(sp.Ge(n, 0), sp.Ge(m_, 0)), sp.Eq(s, n * m_)\n",
459
+ "BOX_N = [dict(zip([n, m_, i, j_, s], p)) for p in itertools.product(range(0, 3), range(0, 3), range(0, 4), range(0, 4), range(0, 10))]\n",
460
+ "GN = range(-3, 4)\n",
461
+ "def failing_vcs(S):\n",
462
+ " conds = [('pre=>wp', imp(P_n, wp(S, Q_n)))] + vcs(S, Q_n)\n",
463
+ " return [name for name, F in conds if any(not holds(F, st, GN) for st in BOX_N)], [name for name, _ in conds]\n",
464
+ "bad, names = failing_vcs(nested(m_ - j_))\n",
465
+ "print(len(names), 'VCs generated for the nested program:', names)\n",
466
+ "assert bad == [], bad\n",
467
+ "ins = [st for st in BOX_N if holds(P_n, st) and st[i] == 0 and st[j_] == 0 and st[s] == 0]\n",
468
+ "for st in ins:\n",
469
+ " fin = run(nested(m_ - j_), st); assert holds(Q_n, fin) # raises NonTerminating if the run does not terminate\n",
470
+ "print(f'all VCs valid on the box; the program run from {len(ins)} inputs terminates in Q (s = n m)')\n",
471
+ "bad2, _ = failing_vcs(nested(j_)) # a WRONG inner variant (V2 = j grows)\n",
472
+ "print('wrong inner variant (V2 = j): failing VCs ->', bad2)\n",
473
+ "assert 'variant-decreases' in bad2\n"
474
+ ]
475
+ },
476
+ {
477
+ "cell_type": "markdown",
478
+ "id": "63e05762",
479
+ "metadata": {},
480
+ "source": [
481
+ "## B. Verifying `LinearSearch` in Lean 4 (DafnyBench Fig. 2 in Lean)\n",
482
+ "Dafny: `ensures 0 <= n <= a.Length; ensures n == a.Length || P(a[n]); ensures forall i :: 0 <= i < n ==> !P(a[i])` with loop invariants\n",
483
+ "`0 <= n <= a.Length` and `forall i :: 0 <= i < n ==> !P(a[i])`. Abstracting `a[i]` away (we search an index predicate `P`):"
484
+ ]
485
+ },
486
+ {
487
+ "cell_type": "code",
488
+ "execution_count": 7,
489
+ "id": "55dab72a",
490
+ "metadata": {
491
+ "execution": {
492
+ "iopub.execute_input": "2026-10-14T09:55:35.645474Z",
493
+ "iopub.status.busy": "2026-10-14T09:55:35.645313Z",
494
+ "iopub.status.idle": "2026-10-14T09:55:40.310783Z",
495
+ "shell.execute_reply": "2026-10-14T09:55:40.309894Z"
496
+ }
497
+ },
498
+ "outputs": [
499
+ {
500
+ "name": "stdout",
501
+ "output_type": "stream",
502
+ "text": [
503
+ "[info] line 36: 'search_correct' depends on axioms: [propext, Quot.sound]\n",
504
+ "[info] line 37: 3\n",
505
+ "[info] line 38: 10\n"
506
+ ]
507
+ }
508
+ ],
509
+ "source": [
510
+ "sys.path.insert(0, os.path.abspath('.'))\n",
511
+ "from _lean_helper import Lean\n",
512
+ "L = Lean()\n",
513
+ "code = r'''\n",
514
+ "/-- the loop `while n < len do if P n then return else n := n + 1`, as a tail-recursive function -/\n",
515
+ "def search (P : Nat → Bool) (len : Nat) (n : Nat) : Nat :=\n",
516
+ " if n < len then (if P n then n else search P len (n + 1)) else n\n",
517
+ "termination_by len - n\n",
518
+ "\n",
519
+ "/-- postcondition of Dafny's LinearSearch (with the start index `n0` of the loop) -/\n",
520
+ "def Post (P : Nat → Bool) (len : Nat) (r : Nat) : Prop :=\n",
521
+ " r ≤ len ∧ (r = len ∨ P r = true) ∧ ∀ i, i < r → P i = false\n",
522
+ "\n",
523
+ "/-- loop INVARIANT as an induction hypothesis: from any state satisfying (n ≤ len ∧ ∀ i < n, ¬P i) the loop ends in Post -/\n",
524
+ "theorem search_inv (P : Nat → Bool) (len : Nat) :\n",
525
+ " ∀ n, n ≤ len → (∀ i, i < n → P i = false) → Post P len (search P len n) := by\n",
526
+ " intro n\n",
527
+ " induction' h : len - n using Nat.strong_induction_on with k ih generalizing n\n",
528
+ " intro hn hinv\n",
529
+ " unfold search\n",
530
+ " by_cases hlt : n < len\n",
531
+ " · rw [if_pos hlt]\n",
532
+ " by_cases hp : P n = true\n",
533
+ " · rw [if_pos hp]\n",
534
+ " exact ⟨by omega, Or.inr hp, hinv⟩\n",
535
+ " · rw [if_neg hp]\n",
536
+ " have hp' : P n = false := by simpa using hp\n",
537
+ " apply ih (len - (n + 1)) (by omega) (n + 1) rfl (by omega)\n",
538
+ " intro j hj\n",
539
+ " by_cases hjn : j = n\n",
540
+ " · subst hjn; exact hp'\n",
541
+ " · exact hinv j (by omega)\n",
542
+ " · rw [if_neg hlt]\n",
543
+ " exact ⟨by omega, Or.inl (by omega), hinv⟩\n",
544
+ "\n",
545
+ "theorem search_correct (P : Nat → Bool) (len : Nat) : Post P len (search P len 0) :=\n",
546
+ " search_inv P len 0 (Nat.zero_le _) (by intro i hi; omega)\n",
547
+ "\n",
548
+ "#print axioms search_correct\n",
549
+ "#eval search (fun i => i == 3) 10 0\n",
550
+ "#eval search (fun i => i == 30) 10 0\n",
551
+ "'''\n",
552
+ "r = L.show(code, timeout=300, keep=True)"
553
+ ]
554
+ },
555
+ {
556
+ "cell_type": "markdown",
557
+ "id": "041614a3",
558
+ "metadata": {},
559
+ "source": [
560
+ "### B2. The same proof attempt on an off-by-one loop (`n + 1 < len`)\n",
561
+ "The proof script is *identical*; Lean refuses. A failed proof is not yet a bug report (note 0304) -- but here a one-line `#eval` confirms it."
562
+ ]
563
+ },
564
+ {
565
+ "cell_type": "code",
566
+ "execution_count": 8,
567
+ "id": "3f1bb803",
568
+ "metadata": {
569
+ "execution": {
570
+ "iopub.execute_input": "2026-10-14T09:55:40.312728Z",
571
+ "iopub.status.busy": "2026-10-14T09:55:40.312554Z",
572
+ "iopub.status.idle": "2026-10-14T09:55:40.878639Z",
573
+ "shell.execute_reply": "2026-10-14T09:55:40.877856Z"
574
+ }
575
+ },
576
+ "outputs": [
577
+ {
578
+ "name": "stdout",
579
+ "output_type": "stream",
580
+ "text": [
581
+ "[ok] no messages\n",
582
+ "[error] line 21: omega could not prove the goal:\n",
583
+ "a possible counterexample may satisfy the constraints\n",
584
+ " a ≥ 0\n",
585
+ "where\n",
586
+ " a := ↑n\n",
587
+ "--- concrete confirmation (no match anywhere: the correct loop returns len = 10) ---\n",
588
+ "[info] line 2: 10\n",
589
+ "[info] line 3: 9\n",
590
+ "[info] line 4: false\n",
591
+ "[info] line 5: true\n"
592
+ ]
593
+ },
594
+ {
595
+ "data": {
596
+ "text/plain": [
597
+ "{'messages': [{'severity': 'info',\n",
598
+ " 'pos': {'line': 2, 'column': 0},\n",
599
+ " 'endPos': {'line': 2, 'column': 5},\n",
600
+ " 'data': '10'},\n",
601
+ " {'severity': 'info',\n",
602
+ " 'pos': {'line': 3, 'column': 0},\n",
603
+ " 'endPos': {'line': 3, 'column': 5},\n",
604
+ " 'data': '9'},\n",
605
+ " {'severity': 'info',\n",
606
+ " 'pos': {'line': 4, 'column': 0},\n",
607
+ " 'endPos': {'line': 4, 'column': 5},\n",
608
+ " 'data': 'false'},\n",
609
+ " {'severity': 'info',\n",
610
+ " 'pos': {'line': 5, 'column': 0},\n",
611
+ " 'endPos': {'line': 5, 'column': 5},\n",
612
+ " 'data': 'true'}],\n",
613
+ " 'env': 5}"
614
+ ]
615
+ },
616
+ "execution_count": 8,
617
+ "metadata": {},
618
+ "output_type": "execute_result"
619
+ }
620
+ ],
621
+ "source": [
622
+ "L.show(r'''\n",
623
+ "def searchBad (P : Nat → Bool) (len : Nat) (n : Nat) : Nat :=\n",
624
+ " if n + 1 < len then (if P n then n else searchBad P len (n + 1)) else n\n",
625
+ "termination_by len - n\n",
626
+ "instance (P : Nat → Bool) (len r : Nat) : Decidable (Post P len r) := by unfold Post; infer_instance\n",
627
+ "''', keep=True)\n",
628
+ "bad = r'''\n",
629
+ "theorem searchBad_inv (P : Nat → Bool) (len : Nat) :\n",
630
+ " ∀ n, n ≤ len → (∀ i, i < n → P i = false) → Post P len (searchBad P len n) := by\n",
631
+ " intro n\n",
632
+ " induction' h : len - n using Nat.strong_induction_on with k ih generalizing n\n",
633
+ " intro hn hinv\n",
634
+ " unfold searchBad\n",
635
+ " by_cases hlt : n + 1 < len\n",
636
+ " · rw [if_pos hlt]\n",
637
+ " by_cases hp : P n = true\n",
638
+ " · rw [if_pos hp]\n",
639
+ " exact ⟨by omega, Or.inr hp, hinv⟩\n",
640
+ " · rw [if_neg hp]\n",
641
+ " have hp' : P n = false := by simpa using hp\n",
642
+ " apply ih (len - (n + 1)) (by omega) (n + 1) rfl (by omega)\n",
643
+ " intro j hj\n",
644
+ " by_cases hjn : j = n\n",
645
+ " · subst hjn; exact hp'\n",
646
+ " · exact hinv j (by omega)\n",
647
+ " · rw [if_neg hlt]\n",
648
+ " exact ⟨by omega, Or.inl (by omega), hinv⟩\n",
649
+ "'''\n",
650
+ "rr = L.show(bad, timeout=300)\n",
651
+ "print('--- concrete confirmation (no match anywhere: the correct loop returns len = 10) ---')\n",
652
+ "L.show('''\n",
653
+ "#eval search (fun _ => false) 10 0\n",
654
+ "#eval searchBad (fun _ => false) 10 0\n",
655
+ "#eval decide (Post (fun _ => false) 10 (searchBad (fun _ => false) 10 0))\n",
656
+ "#eval decide (Post (fun _ => false) 10 (search (fun _ => false) 10 0))\n",
657
+ "''')"
658
+ ]
659
+ },
660
+ {
661
+ "cell_type": "markdown",
662
+ "id": "acbe59cb",
663
+ "metadata": {},
664
+ "source": [
665
+ "## C. The spec lives in Z, the code in int32: binary-search midpoint\n",
666
+ "VC (over Z): `lo <= hi => lo <= (lo+hi)/2 <= hi`. Over 8-bit words the identical VC is **false** exactly when `lo + hi >= 256`.\n",
667
+ "Derivation: wrapped `lo+hi = lo+hi-256`, so `mid = (lo+hi-256)/2 < lo <=> hi - lo < 256`, always true for 8-bit words.\n",
668
+ "Count the violating pairs in closed form, by brute force in Python, and by Lean `#eval` on `UInt8`."
669
+ ]
670
+ },
671
+ {
672
+ "cell_type": "code",
673
+ "execution_count": 9,
674
+ "id": "86a322fd",
675
+ "metadata": {
676
+ "execution": {
677
+ "iopub.execute_input": "2026-10-14T09:55:40.880806Z",
678
+ "iopub.status.busy": "2026-10-14T09:55:40.880641Z",
679
+ "iopub.status.idle": "2026-10-14T09:55:41.086181Z",
680
+ "shell.execute_reply": "2026-10-14T09:55:41.085341Z"
681
+ }
682
+ },
683
+ "outputs": [
684
+ {
685
+ "name": "stdout",
686
+ "output_type": "stream",
687
+ "text": [
688
+ "UInt8 pairs lo<=hi: 32896; violating VC: unsafe 16384 (= #{lo+hi>=256} = 16384), safe 0\n",
689
+ "Lean #eval counts (unsafe, safe, UInt32 mid): ['16384', '0', '852516352'] ; decide-proof of the 32-bit violation: True\n",
690
+ "int32: lo = 1073741829 hi = 2147483646 (lo+hi) wraps to -1073741821 -> mid = -536870911 which is < lo: True\n"
691
+ ]
692
+ }
693
+ ],
694
+ "source": [
695
+ "M = 256\n",
696
+ "def mid_unsafe(lo, hi): return ((lo + hi) % M) // 2 # what UInt8 arithmetic computes\n",
697
+ "def mid_safe(lo, hi): return (lo + (hi - lo) // 2) % M # no intermediate overflow when lo <= hi\n",
698
+ "pairs = [(lo, hi) for lo in range(M) for hi in range(lo, M)]\n",
699
+ "py_bad_unsafe = sum(1 for lo, hi in pairs if not (lo <= mid_unsafe(lo, hi) <= hi))\n",
700
+ "py_bad_safe = sum(1 for lo, hi in pairs if not (lo <= mid_safe(lo, hi) <= hi))\n",
701
+ "closed_form = sum(1 for lo, hi in pairs if lo + hi >= M) # the derived characterisation\n",
702
+ "assert py_bad_unsafe == closed_form and py_bad_safe == 0\n",
703
+ "print(f'UInt8 pairs lo<=hi: {len(pairs)}; violating VC: unsafe {py_bad_unsafe} (= #{{lo+hi>=256}} = {closed_form}), safe {py_bad_safe}')\n",
704
+ "# the same count in Lean on UInt8\n",
705
+ "msgs = L.run('''\n",
706
+ "#eval ((List.range 256).foldl (fun acc lo => (List.range 256).foldl (fun acc hi =>\n",
707
+ " if lo ≤ hi ∧ ¬ (UInt8.ofNat lo ≤ (UInt8.ofNat lo + UInt8.ofNat hi) / 2 ∧ (UInt8.ofNat lo + UInt8.ofNat hi) / 2 ≤ UInt8.ofNat hi) then acc + 1 else acc) acc) 0)\n",
708
+ "#eval ((List.range 256).foldl (fun acc lo => (List.range 256).foldl (fun acc hi =>\n",
709
+ " if lo ≤ hi ∧ ¬ (UInt8.ofNat lo ≤ UInt8.ofNat lo + (UInt8.ofNat hi - UInt8.ofNat lo) / 2 ∧ UInt8.ofNat lo + (UInt8.ofNat hi - UInt8.ofNat lo) / 2 ≤ UInt8.ofNat hi) then acc + 1 else acc) acc) 0)\n",
710
+ "#eval ((3000000000 : UInt32) + 3000000000) / 2\n",
711
+ "example : ¬ ((3000000000 : UInt32) ≤ (3000000000 + 3000000000) / 2) := by decide\n",
712
+ "''')['messages']\n",
713
+ "lean_counts = [m['data'].strip() for m in msgs if m['severity'] == 'info']\n",
714
+ "print('Lean #eval counts (unsafe, safe, UInt32 mid):', lean_counts, '; decide-proof of the 32-bit violation: ', [m['severity'] for m in msgs if m['severity'] == 'error'] == [])\n",
715
+ "assert int(lean_counts[0]) == py_bad_unsafe and int(lean_counts[1]) == 0\n",
716
+ "# 32-bit: python model of int32 wrap, the famous boundary case\n",
717
+ "def wrap32(v): return (v + 2**31) % 2**32 - 2**31\n",
718
+ "lo, hi = 2**30 + 5, 2**31 - 2\n",
719
+ "print('int32: lo =', lo, ' hi =', hi, ' (lo+hi) wraps to', wrap32(lo + hi), '-> mid =', wrap32(lo + hi) // 2, 'which is < lo:', wrap32(lo + hi) // 2 < lo)\n",
720
+ "assert wrap32(lo + hi) // 2 < lo and lo <= (lo + hi) // 2 <= hi"
721
+ ]
722
+ },
723
+ {
724
+ "cell_type": "markdown",
725
+ "id": "f9bbfb94",
726
+ "metadata": {},
727
+ "source": [
728
+ "## D. Refinement: `x :[pre, post] is-refined-by x := e iff pre => post[e/x]`\n",
729
+ "Predicate-transformer reading: `wp_spec(Q)(s) = pre(s) and forall x'. post(s, x') => Q(x')`, `wp_assign(Q)(s) = Q(e(s))`;\n",
730
+ "`spec ⊑ impl` iff `wp_spec(Q) ⊆ wp_impl(Q)` for every `Q`. We decide this by brute force over ALL 2^11 postconditions on a box\n",
731
+ "(named approximation: box of 11 values) and compare with the one-line law."
732
+ ]
733
+ },
734
+ {
735
+ "cell_type": "code",
736
+ "execution_count": 10,
737
+ "id": "ca6a9e0e",
738
+ "metadata": {
739
+ "execution": {
740
+ "iopub.execute_input": "2026-10-14T09:55:41.087953Z",
741
+ "iopub.status.busy": "2026-10-14T09:55:41.087795Z",
742
+ "iopub.status.idle": "2026-10-14T09:55:42.520867Z",
743
+ "shell.execute_reply": "2026-10-14T09:55:42.519895Z"
744
+ }
745
+ },
746
+ "outputs": [
747
+ {
748
+ "name": "stdout",
749
+ "output_type": "stream",
750
+ "text": [
751
+ "80 random (spec, assignment) pairs: all-Q brute force == worst-Q shortcut == law pre => post[e/x]; refined in 71, not refined in 9\n"
752
+ ]
753
+ }
754
+ ],
755
+ "source": [
756
+ "BOXX = list(range(-5, 6)) # final-state values x' (11 of them => 2^11 postconditions Q)\n",
757
+ "START = list(range(-2, 3)) # start states s0; |b|<=2, |k|<=1 keep e(s0) inside BOXX\n",
758
+ "def refines_bruteforce(pre, post, e):\n",
759
+ " # for every Q (every subset of the box), wp_spec(Q) ⊆ wp_assign(Q) on every start state\n",
760
+ " for s0 in START:\n",
761
+ " if not pre(s0): continue # wp_spec(Q)(s0)=False for all Q: nothing to check\n",
762
+ " allowed = [xp for xp in BOXX if post(s0, xp)] # outcomes the spec permits\n",
763
+ " # wp_spec(Q)(s0) holds iff allowed ⊆ Q ; refinement needs e(s0) ∈ Q for all such Q. Worst Q = allowed.\n",
764
+ " if e(s0) not in allowed: return False\n",
765
+ " return True\n",
766
+ "def law(pre, post, e): return all((not pre(s0)) or post(s0, e(s0)) for s0 in START)\n",
767
+ "# make the brute force genuinely quantify over Q for a subset of cases, to validate the \"worst Q = allowed\" shortcut\n",
768
+ "def refines_all_Q(pre, post, e):\n",
769
+ " for s0 in START:\n",
770
+ " for mask in range(1 << len(BOXX)):\n",
771
+ " Q = {BOXX[k] for k in range(len(BOXX)) if mask >> k & 1}\n",
772
+ " wp_spec = pre(s0) and all((xp in Q) for xp in BOXX if post(s0, xp))\n",
773
+ " wp_impl = e(s0) in Q\n",
774
+ " if wp_spec and not wp_impl: return False\n",
775
+ " return True\n",
776
+ "random.seed(5)\n",
777
+ "tests = []\n",
778
+ "for _ in range(60):\n",
779
+ " a, b, c = random.randint(-2, 2), random.randint(-2, 2), random.randint(1, 3)\n",
780
+ " pre = (lambda a: lambda s0: s0 >= a)(a)\n",
781
+ " post = (lambda b, c: lambda s0, xp: abs(xp - (s0 + b)) <= c - 1)(b, c) # \"x ends within c-1 of x0+b\"\n",
782
+ " e = (lambda b: lambda s0: s0 + b)(b)\n",
783
+ " tests.append((pre, post, e))\n",
784
+ "# also deliberately failing implementations\n",
785
+ "for _ in range(60):\n",
786
+ " a, b, c = random.randint(-2, 2), random.randint(-2, 2), random.randint(1, 2)\n",
787
+ " pre = (lambda a: lambda s0: s0 >= a)(a)\n",
788
+ " post = (lambda b, c: lambda s0, xp: abs(xp - (s0 + b)) <= c - 1)(b, c)\n",
789
+ " k = random.randint(-1, 1)\n",
790
+ " e = (lambda b, k: lambda s0: s0 + b + k)(b, k)\n",
791
+ " tests.append((pre, post, e))\n",
792
+ "agree = 0; n_true = 0\n",
793
+ "for pre, post, e in tests[:40] + tests[60:100]:\n",
794
+ " r1 = refines_all_Q(pre, post, e); r2 = refines_bruteforce(pre, post, e); r3 = law(pre, post, e)\n",
795
+ " assert r1 == r2 == r3\n",
796
+ " agree += 1; n_true += r1\n",
797
+ "print(f'{agree} random (spec, assignment) pairs: all-Q brute force == worst-Q shortcut == law pre => post[e/x]; refined in {n_true}, not refined in {agree-n_true}')"
798
+ ]
799
+ },
800
+ {
801
+ "cell_type": "markdown",
802
+ "id": "cc1bc3fa",
803
+ "metadata": {},
804
+ "source": [
805
+ "## Summary of the checks\n",
806
+ "* `wp` by substitution == semantic wp on 14,700 (program, state) pairs (0 mismatches).\n",
807
+ "* Valid VCs (with variant) => triple holds and loop terminates (40 box states, 0 violations); 4 failure modes classified.\n",
808
+ "* Lean: `search_correct` proved (axioms `propext`, `Quot.sound`); the off-by-one variant is rejected and `#eval` produces a concrete violation.\n",
809
+ "* Overflow: VC over Z true, over `UInt8` false for exactly `#{lo+hi >= 256}` = 16,384 pairs, matching Python and Lean `#eval` counts.\n",
810
+ "* Refinement law == brute force over all 2^11 postconditions on 80 random pairs."
811
+ ]
812
+ },
813
+ {
814
+ "cell_type": "code",
815
+ "execution_count": 11,
816
+ "id": "bca6ef85",
817
+ "metadata": {
818
+ "execution": {
819
+ "iopub.execute_input": "2026-10-14T09:55:42.522453Z",
820
+ "iopub.status.busy": "2026-10-14T09:55:42.522294Z",
821
+ "iopub.status.idle": "2026-10-14T09:55:42.789460Z",
822
+ "shell.execute_reply": "2026-10-14T09:55:42.788381Z"
823
+ }
824
+ },
825
+ "outputs": [],
826
+ "source": [
827
+ "L.close()"
828
+ ]
829
+ }
830
+ ],
831
+ "metadata": {
832
+ "kernelspec": {
833
+ "display_name": "Python 3",
834
+ "language": "python",
835
+ "name": "python3"
836
+ },
837
+ "language_info": {
838
+ "codemirror_mode": {
839
+ "name": "ipython",
840
+ "version": 3
841
+ },
842
+ "file_extension": ".py",
843
+ "mimetype": "text/x-python",
844
+ "name": "python",
845
+ "nbconvert_exporter": "python",
846
+ "pygments_lexer": "ipython3",
847
+ "version": "3.12.11"
848
+ }
849
+ },
850
+ "nbformat": 4,
851
+ "nbformat_minor": 5
852
+ }
code/notebooks/0304-failed-proof-to-bug.ipynb ADDED
@@ -0,0 +1,1006 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "4f055b7b",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0304 - From failed proof to bug: counterexample search and the soundness / completeness asymmetry\n",
9
+ "\n",
10
+ "Scratch-pad for note `0304 - Failed Proof to Bug.pdf`. Naive first, efficient second, assert agreement.\n",
11
+ "\n",
12
+ "* **A. Random testing with shrinking** (a 60-line QuickCheck). NAIVE = exhaustive enumeration of the bounded domain.\n",
13
+ " EFFICIENT = random sampling + shrinking. Assert: the exact failure probability `p` (by enumeration) predicts the\n",
14
+ " Monte-Carlo miss probability `(1-p)^N` and the mean time-to-failure `1/p`; shrinking reaches the global minimum.\n",
15
+ "* **B. Small-scope hypothesis**: when does enumeration beat random sampling? Closed forms, checked by Monte Carlo.\n",
16
+ "* **C. Lean**: `decide`, `slim_check` (this Mathlib pin's name for Plausible), `omega` failure messages; a counterexample turned into a\n",
17
+ " *kernel-checked refutation*; and the cases where a failed proof / counterexample is **not** a bug.\n",
18
+ "\n",
19
+ "No GPU. Lean: ONE worker through `align.verify.lean`, nice 15, after the MemAvailable>=45 GB check."
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "code",
24
+ "execution_count": 1,
25
+ "id": "9540a401",
26
+ "metadata": {
27
+ "execution": {
28
+ "iopub.execute_input": "2026-10-14T09:54:29.990834Z",
29
+ "iopub.status.busy": "2026-10-14T09:54:29.990642Z",
30
+ "iopub.status.idle": "2026-10-14T09:54:29.997582Z",
31
+ "shell.execute_reply": "2026-10-14T09:54:29.996880Z"
32
+ }
33
+ },
34
+ "outputs": [],
35
+ "source": [
36
+ "import os\n",
37
+ "os.environ[\"CUDA_VISIBLE_DEVICES\"] = \"\"\n",
38
+ "import itertools, random, math, sys, collections\n",
39
+ "random.seed(2026)\n",
40
+ "\n",
41
+ "# ---- the code under test: insertion sort with a duplicate-handling bug -------------------------------------------\n",
42
+ "def isort_bug(xs):\n",
43
+ " out = []\n",
44
+ " for v in xs:\n",
45
+ " k = 0\n",
46
+ " while k < len(out) and out[k] < v: k += 1\n",
47
+ " if k < len(out) and out[k] == v: continue # BUG: drops the duplicate\n",
48
+ " out.insert(k, v)\n",
49
+ " return out\n",
50
+ "def isort_ok(xs):\n",
51
+ " out = []\n",
52
+ " for v in xs:\n",
53
+ " k = 0\n",
54
+ " while k < len(out) and out[k] < v: k += 1\n",
55
+ " out.insert(k, v)\n",
56
+ " return out\n",
57
+ "def prop_sort(f):\n",
58
+ " def prop(xs):\n",
59
+ " ys = f(xs)\n",
60
+ " return all(a <= b for a, b in zip(ys, ys[1:])) and collections.Counter(ys) == collections.Counter(xs)\n",
61
+ " return prop\n",
62
+ "\n",
63
+ "# Domain: lists of length 0..6 over {-2..2}; generator: length uniform in 0..6, entries uniform.\n",
64
+ "V = list(range(-2, 3)); MAXLEN = 6\n",
65
+ "def gen(rng):\n",
66
+ " return [rng.choice(V) for _ in range(rng.randint(0, MAXLEN))]"
67
+ ]
68
+ },
69
+ {
70
+ "cell_type": "markdown",
71
+ "id": "24aac6bf",
72
+ "metadata": {},
73
+ "source": [
74
+ "## A1. NAIVE: exhaustive enumeration gives the exact failure probability under the generator\n",
75
+ "`P(list) = (1/7) * 5^-len`. Sum the probability of the failing lists: this `p` is the per-test detection probability."
76
+ ]
77
+ },
78
+ {
79
+ "cell_type": "code",
80
+ "execution_count": 2,
81
+ "id": "f8691e4c",
82
+ "metadata": {
83
+ "execution": {
84
+ "iopub.execute_input": "2026-10-14T09:54:29.999823Z",
85
+ "iopub.status.busy": "2026-10-14T09:54:29.999658Z",
86
+ "iopub.status.idle": "2026-10-14T09:54:30.269920Z",
87
+ "shell.execute_reply": "2026-10-14T09:54:30.269084Z"
88
+ }
89
+ },
90
+ "outputs": [
91
+ {
92
+ "name": "stdout",
93
+ "output_type": "stream",
94
+ "text": [
95
+ "domain size 19531; failing inputs 19205; exact per-test failure probability p = 0.498514\n"
96
+ ]
97
+ }
98
+ ],
99
+ "source": [
100
+ "prop_bug, prop_ok = prop_sort(isort_bug), prop_sort(isort_ok)\n",
101
+ "fail_prob = 0.0; fails = []\n",
102
+ "for L_ in range(MAXLEN + 1):\n",
103
+ " for xs in itertools.product(V, repeat=L_):\n",
104
+ " xs = list(xs)\n",
105
+ " assert prop_ok(xs) # the reference implementation passes everywhere\n",
106
+ " if not prop_bug(xs):\n",
107
+ " fails.append(xs); fail_prob += (1 / (MAXLEN + 1)) * 5.0 ** (-L_)\n",
108
+ "n_total = sum(5 ** L_ for L_ in range(MAXLEN + 1))\n",
109
+ "print(f'domain size {n_total}; failing inputs {len(fails)}; exact per-test failure probability p = {fail_prob:.6f}')\n",
110
+ "p = fail_prob"
111
+ ]
112
+ },
113
+ {
114
+ "cell_type": "markdown",
115
+ "id": "ee87ac91",
116
+ "metadata": {},
117
+ "source": [
118
+ "## A2. EFFICIENT: random testing. Derivation (note 0304 section 3): `P(no failure in N independent tests) = (1-p)^N`;\n",
119
+ "time to first failure is Geometric(p) with mean `1/p`. Check both by Monte Carlo (20,000 runs)."
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "code",
124
+ "execution_count": 3,
125
+ "id": "c36a5bed",
126
+ "metadata": {
127
+ "execution": {
128
+ "iopub.execute_input": "2026-10-14T09:54:30.271520Z",
129
+ "iopub.status.busy": "2026-10-14T09:54:30.271369Z",
130
+ "iopub.status.idle": "2026-10-14T09:54:30.557635Z",
131
+ "shell.execute_reply": "2026-10-14T09:54:30.556905Z"
132
+ }
133
+ },
134
+ "outputs": [
135
+ {
136
+ "name": "stdout",
137
+ "output_type": "stream",
138
+ "text": [
139
+ "mean tests to first failure: empirical 1.992 +- 0.010 ; theory 1/p = 2.006 ; |diff| = 0.014 (1.43 se)\n",
140
+ " N P(miss) empirical (1-p)^N z\n",
141
+ " 1 0.5030 0.5015 0.41\n",
142
+ " 5 0.0297 0.0317 1.59\n",
143
+ " 10 0.0008 0.0010 0.70\n",
144
+ " 20 0.0000 0.0000 0.14\n",
145
+ " 40 0.0000 0.0000 0.00\n",
146
+ " 80 0.0000 0.0000 0.00\n",
147
+ "worst |z| = 1.59 (< 4: the geometric law (1-p)^N holds)\n",
148
+ " N for miss-probability <= 0.1: 4\n",
149
+ " N for miss-probability <= 0.01: 7\n",
150
+ " N for miss-probability <= 0.001: 11\n"
151
+ ]
152
+ }
153
+ ],
154
+ "source": [
155
+ "rng = random.Random(7)\n",
156
+ "RUNS = 20000\n",
157
+ "first_fail = []\n",
158
+ "for _ in range(RUNS):\n",
159
+ " t = 0\n",
160
+ " while True:\n",
161
+ " t += 1\n",
162
+ " if not prop_bug(gen(rng)): break\n",
163
+ " first_fail.append(t)\n",
164
+ "mean_t = sum(first_fail) / RUNS\n",
165
+ "se_mean = (sum((t - mean_t) ** 2 for t in first_fail) / (RUNS - 1)) ** 0.5 / RUNS ** 0.5\n",
166
+ "print(f'mean tests to first failure: empirical {mean_t:.3f} +- {se_mean:.3f} ; theory 1/p = {1/p:.3f} ; |diff| = {abs(mean_t - 1/p):.3f} ({abs(mean_t-1/p)/se_mean:.2f} se)')\n",
167
+ "assert abs(mean_t - 1 / p) < 4 * se_mean\n",
168
+ "worst_z = 0.0\n",
169
+ "print(f'{\"N\":>4s} {\"P(miss) empirical\":>18s} {\"(1-p)^N\":>10s} {\"z\":>6s}')\n",
170
+ "for N in (1, 5, 10, 20, 40, 80):\n",
171
+ " emp = sum(t > N for t in first_fail) / RUNS\n",
172
+ " th = (1 - p) ** N\n",
173
+ " se = max((th * (1 - th) / RUNS) ** 0.5, 1e-9)\n",
174
+ " z = abs(emp - th) / se; worst_z = max(worst_z, z)\n",
175
+ " print(f'{N:4d} {emp:18.4f} {th:10.4f} {z:6.2f}')\n",
176
+ "assert worst_z < 4\n",
177
+ "print('worst |z| =', round(worst_z, 2), '(< 4: the geometric law (1-p)^N holds)')\n",
178
+ "# tests needed for confidence 1 - delta: N >= ln(delta) / ln(1-p)\n",
179
+ "for delta in (0.1, 0.01, 1e-3):\n",
180
+ " print(f' N for miss-probability <= {delta}: {math.ceil(math.log(delta) / math.log(1 - p))}')"
181
+ ]
182
+ },
183
+ {
184
+ "cell_type": "markdown",
185
+ "id": "06805f67",
186
+ "metadata": {},
187
+ "source": [
188
+ "### A2b. A rarer bug: the same law at p = 1/1024 (a \"needle\": the code is wrong on exactly one input out of K = 1024)\n",
189
+ "Uniform sampling over the K inputs; per-test detection probability is exactly `p = 1/K`."
190
+ ]
191
+ },
192
+ {
193
+ "cell_type": "code",
194
+ "execution_count": 4,
195
+ "id": "a033314f",
196
+ "metadata": {
197
+ "execution": {
198
+ "iopub.execute_input": "2026-10-14T09:54:30.559759Z",
199
+ "iopub.status.busy": "2026-10-14T09:54:30.559600Z",
200
+ "iopub.status.idle": "2026-10-14T09:54:32.010396Z",
201
+ "shell.execute_reply": "2026-10-14T09:54:32.009675Z"
202
+ }
203
+ },
204
+ "outputs": [
205
+ {
206
+ "name": "stdout",
207
+ "output_type": "stream",
208
+ "text": [
209
+ "needle p=1/1024: mean tests to failure 1015.6 +- 18.3 ; theory 1024\n",
210
+ " N= 256 P(miss) empirical 0.7790 (1-p)^N 0.7787 z=0.04\n",
211
+ " N= 1024 P(miss) empirical 0.3643 (1-p)^N 0.3677 z=0.38\n",
212
+ " N= 2048 P(miss) empirical 0.1297 (1-p)^N 0.1352 z=0.89\n",
213
+ " N= 3072 P(miss) empirical 0.0497 (1-p)^N 0.0497 z=0.01\n",
214
+ " N= 4096 P(miss) empirical 0.0173 (1-p)^N 0.0183 z=0.39\n",
215
+ "=> to be 99% sure of seeing a 1-in-1024 bug you need 4714 random tests; testing is a *statistical* instrument.\n"
216
+ ]
217
+ }
218
+ ],
219
+ "source": [
220
+ "K2 = 1024; p2 = 1 / K2; RUNS2 = 3000\n",
221
+ "rng = random.Random(23)\n",
222
+ "needle = 777\n",
223
+ "ff = []\n",
224
+ "for _ in range(RUNS2):\n",
225
+ " t = 0\n",
226
+ " while True:\n",
227
+ " t += 1\n",
228
+ " if rng.randrange(K2) == needle: break\n",
229
+ " ff.append(t)\n",
230
+ "m2 = sum(ff) / RUNS2; se2 = (sum((t - m2) ** 2 for t in ff) / (RUNS2 - 1)) ** 0.5 / RUNS2 ** 0.5\n",
231
+ "print(f'needle p=1/{K2}: mean tests to failure {m2:.1f} +- {se2:.1f} ; theory {K2}')\n",
232
+ "assert abs(m2 - K2) < 4 * se2\n",
233
+ "zmax = 0.0\n",
234
+ "for N in (256, 1024, 2048, 3072, 4096):\n",
235
+ " emp = sum(t > N for t in ff) / RUNS2; th = (1 - p2) ** N\n",
236
+ " z = abs(emp - th) / max((th * (1 - th) / RUNS2) ** 0.5, 1e-9); zmax = max(zmax, z)\n",
237
+ " print(f' N={N:5d} P(miss) empirical {emp:.4f} (1-p)^N {th:.4f} z={z:.2f}')\n",
238
+ "assert zmax < 4\n",
239
+ "print('=> to be 99% sure of seeing a 1-in-1024 bug you need', math.ceil(math.log(0.01) / math.log(1 - p2)), 'random tests; testing is a *statistical* instrument.')"
240
+ ]
241
+ },
242
+ {
243
+ "cell_type": "markdown",
244
+ "id": "c1f9f5f8",
245
+ "metadata": {},
246
+ "source": [
247
+ "## A3. Shrinking: from a long counterexample to a minimal one\n",
248
+ "Candidates: delete one element; move one element one step towards 0. Accept the first that still fails; stop when none does.\n",
249
+ "Termination: each accepted step strictly decreases the measure `(len, sum |v|)` in the lexicographic (well-founded) order.\n",
250
+ "A local minimum is not automatically a global one; compare with the exhaustive global minimum."
251
+ ]
252
+ },
253
+ {
254
+ "cell_type": "code",
255
+ "execution_count": 5,
256
+ "id": "2c7b4fa0",
257
+ "metadata": {
258
+ "execution": {
259
+ "iopub.execute_input": "2026-10-14T09:54:32.011875Z",
260
+ "iopub.status.busy": "2026-10-14T09:54:32.011718Z",
261
+ "iopub.status.idle": "2026-10-14T09:54:32.226368Z",
262
+ "shell.execute_reply": "2026-10-14T09:54:32.225418Z"
263
+ }
264
+ },
265
+ "outputs": [
266
+ {
267
+ "name": "stdout",
268
+ "output_type": "stream",
269
+ "text": [
270
+ "global minimal counterexamples (exhaustive): [[0, 0]]\n"
271
+ ]
272
+ },
273
+ {
274
+ "name": "stdout",
275
+ "output_type": "stream",
276
+ "text": [
277
+ "2000 shrinks: always a failing, locally minimal list; GLOBAL minimum reached in 401/2000 = 0.201; mean length before shrinking 4.57\n",
278
+ "final shrunk values: {(-2, -2): 420, (2, 2): 408, (0, 0): 401, (1, 1): 388, (-1, -1): 383}\n",
279
+ "local minima that are NOT global (e.g. [1,1]: deleting gives [1] (passes), moving towards 0 gives [0,1] (passes)): [[1, 1], [-1, -1], [2, 2], [-2, -2]]\n"
280
+ ]
281
+ }
282
+ ],
283
+ "source": [
284
+ "def shrink_candidates(xs):\n",
285
+ " for k in range(len(xs)): yield xs[:k] + xs[k + 1:]\n",
286
+ " for k, v in enumerate(xs):\n",
287
+ " if v != 0: yield xs[:k] + [v - (1 if v > 0 else -1)] + xs[k + 1:]\n",
288
+ "def measure(xs): return (len(xs), sum(abs(v) for v in xs))\n",
289
+ "def shrink(xs, prop):\n",
290
+ " steps = 0\n",
291
+ " while True:\n",
292
+ " for c in shrink_candidates(xs):\n",
293
+ " if not prop(c):\n",
294
+ " assert measure(c) < measure(xs) # strict decrease\n",
295
+ " xs, steps = c, steps + 1; break\n",
296
+ " else:\n",
297
+ " return xs, steps\n",
298
+ "global_min = min(map(measure, fails)); gmin_lists = sorted([f for f in fails if measure(f) == global_min])\n",
299
+ "print('global minimal counterexamples (exhaustive):', gmin_lists)\n",
300
+ "hit = tot = 0; lens_before = []; finals = collections.Counter()\n",
301
+ "rng = random.Random(11)\n",
302
+ "for _ in range(2000):\n",
303
+ " while True:\n",
304
+ " xs = gen(rng)\n",
305
+ " if not prop_bug(xs): break\n",
306
+ " ys, _ = shrink(xs, prop_bug)\n",
307
+ " assert not prop_bug(ys) and all(prop_bug(c) for c in shrink_candidates(ys)) # still failing AND locally minimal\n",
308
+ " assert measure(ys) >= global_min\n",
309
+ " lens_before.append(len(xs)); tot += 1; hit += measure(ys) == global_min; finals[tuple(ys)] += 1\n",
310
+ "print(f'2000 shrinks: always a failing, locally minimal list; GLOBAL minimum reached in {hit}/{tot} = {hit/tot:.3f}; mean length before shrinking {sum(lens_before)/tot:.2f}')\n",
311
+ "print('final shrunk values:', dict(sorted(finals.items(), key=lambda kv: -kv[1])))\n",
312
+ "print('local minima that are NOT global (e.g. [1,1]: deleting gives [1] (passes), moving towards 0 gives [0,1] (passes)):',\n",
313
+ " [list(k) for k in finals if measure(list(k)) != global_min])"
314
+ ]
315
+ },
316
+ {
317
+ "cell_type": "markdown",
318
+ "id": "b80dcca8",
319
+ "metadata": {},
320
+ "source": [
321
+ "## B. Small-scope hypothesis: enumeration vs uniform random sampling\n",
322
+ "A \"needle\" bug at one input `x0` of a `K = 2^16`-element domain. Uniform random with `N` tests detects with probability `1-(1-1/K)^N`\n",
323
+ "if the needle is uniformly placed; enumeration `0,1,...,N-1` detects iff `x0 < N`. If the bug location is `x0 ~ Geometric(q)`\n",
324
+ "(bugs cluster at small inputs, the empirical small-scope hypothesis), enumeration detects with probability `1-(1-q)^N` while random sampling gets\n",
325
+ "`E[1-(1-1/K)^N]` = tiny. Both closed forms, checked by Monte Carlo."
326
+ ]
327
+ },
328
+ {
329
+ "cell_type": "code",
330
+ "execution_count": 6,
331
+ "id": "d538673e",
332
+ "metadata": {
333
+ "execution": {
334
+ "iopub.execute_input": "2026-10-14T09:54:32.227875Z",
335
+ "iopub.status.busy": "2026-10-14T09:54:32.227713Z",
336
+ "iopub.status.idle": "2026-10-14T09:54:32.772482Z",
337
+ "shell.execute_reply": "2026-10-14T09:54:32.771731Z"
338
+ }
339
+ },
340
+ "outputs": [
341
+ {
342
+ "name": "stdout",
343
+ "output_type": "stream",
344
+ "text": [
345
+ "uniform needle: enumeration 0.0036 (theory 0.0031) random 0.0027 (theory 0.0030)\n",
346
+ "clustered needle: enumeration 0.9823 (theory 0.9824) random theory 0.0030 -> enumeration wins by x322\n",
347
+ "32-bit needle, delta = 1e-3: N >= 2.967e+10 uniform tests (no test budget finds it; a proof or a solver does)\n"
348
+ ]
349
+ }
350
+ ],
351
+ "source": [
352
+ "K = 2 ** 16; N = 200; MC = 40000\n",
353
+ "rng = random.Random(5)\n",
354
+ "# uniform placement\n",
355
+ "enum_u = sum(rng.randrange(K) < N for _ in range(MC)) / MC\n",
356
+ "rand_u = sum(any(rng.randrange(K) == x0 for _ in range(N)) for x0 in (rng.randrange(K) for _ in range(4000))) / 4000\n",
357
+ "th_enum_u, th_rand_u = N / K, 1 - (1 - 1 / K) ** N\n",
358
+ "print(f'uniform needle: enumeration {enum_u:.4f} (theory {th_enum_u:.4f}) random {rand_u:.4f} (theory {th_rand_u:.4f})')\n",
359
+ "# geometric placement with q = 0.02\n",
360
+ "q = 0.02\n",
361
+ "def geom(r):\n",
362
+ " x = 0\n",
363
+ " while r.random() > q: x += 1\n",
364
+ " return min(x, K - 1)\n",
365
+ "enum_g = sum(geom(rng) < N for _ in range(MC)) / MC\n",
366
+ "th_enum_g = 1 - (1 - q) ** N\n",
367
+ "th_rand_g = 1 - (1 - 1 / K) ** N # uniform sampling detects a needle anywhere with the same probability\n",
368
+ "print(f'clustered needle: enumeration {enum_g:.4f} (theory {th_enum_g:.4f}) random theory {th_rand_g:.4f} -> enumeration wins by x{th_enum_g/th_rand_g:.0f}')\n",
369
+ "se = lambda p_: (p_ * (1 - p_) / MC) ** 0.5\n",
370
+ "assert abs(enum_g - th_enum_g) < 4 * se(th_enum_g) and abs(enum_u - th_enum_u) < 4 * se(th_enum_u) + 1e-3\n",
371
+ "assert abs(rand_u - th_rand_u) < 4 * (th_rand_u * (1 - th_rand_u) / 4000) ** 0.5 + 1e-3\n",
372
+ "# a 32-bit needle: uniform random needs ~ ln(1/delta) * 2^32 tests\n",
373
+ "print('32-bit needle, delta = 1e-3: N >=', f'{math.log(1e-3) / math.log(1 - 2.0**-32):.3e}', 'uniform tests (no test budget finds it; a proof or a solver does)')"
374
+ ]
375
+ },
376
+ {
377
+ "cell_type": "markdown",
378
+ "id": "ae0e1586",
379
+ "metadata": {},
380
+ "source": [
381
+ "## C. Lean: three tools, three kinds of answer\n",
382
+ "* `decide`: exhaustive over a *bounded decidable* statement => a **proof** or a definite refutation (complete within the bound, kernel-checked).\n",
383
+ "* `slim_check` (Plausible's ancestor on this pin): random testing => a **counterexample** or *nothing* (and when nothing, it closes the goal with `sorry`!).\n",
384
+ "* `omega`: a decision procedure for linear integer arithmetic => proof, or a failure message listing \"a possible counterexample\" **constraints**."
385
+ ]
386
+ },
387
+ {
388
+ "cell_type": "code",
389
+ "execution_count": 7,
390
+ "id": "f695dda3",
391
+ "metadata": {
392
+ "execution": {
393
+ "iopub.execute_input": "2026-10-14T09:54:32.774131Z",
394
+ "iopub.status.busy": "2026-10-14T09:54:32.773969Z",
395
+ "iopub.status.idle": "2026-10-14T09:54:37.428445Z",
396
+ "shell.execute_reply": "2026-10-14T09:54:37.427528Z"
397
+ }
398
+ },
399
+ "outputs": [
400
+ {
401
+ "name": "stdout",
402
+ "output_type": "stream",
403
+ "text": [
404
+ "[ok] no messages\n"
405
+ ]
406
+ },
407
+ {
408
+ "data": {
409
+ "text/plain": [
410
+ "{'env': 2}"
411
+ ]
412
+ },
413
+ "execution_count": 7,
414
+ "metadata": {},
415
+ "output_type": "execute_result"
416
+ }
417
+ ],
418
+ "source": [
419
+ "sys.path.insert(0, os.path.abspath('.'))\n",
420
+ "from _lean_helper import Lean\n",
421
+ "import re\n",
422
+ "L = Lean()\n",
423
+ "L.show(r'''\n",
424
+ "def search (P : Nat → Bool) (len : Nat) (n : Nat) : Nat :=\n",
425
+ " if n < len then (if P n then n else search P len (n + 1)) else n\n",
426
+ "termination_by len - n\n",
427
+ "def Post (P : Nat → Bool) (len r : Nat) : Prop :=\n",
428
+ " r ≤ len ∧ (r = len ∨ P r = true) ∧ ∀ i, i < r → P i = false\n",
429
+ "instance (P : Nat → Bool) (len r : Nat) : Decidable (Post P len r) := by unfold Post; infer_instance\n",
430
+ "def searchBadF (P : Nat → Bool) (len : Nat) : Nat → Nat → Nat\n",
431
+ " | 0, n => n\n",
432
+ " | fuel+1, n => if n + 1 < len then (if P n then n else searchBadF P len fuel (n+1)) else n\n",
433
+ "''', keep=True)"
434
+ ]
435
+ },
436
+ {
437
+ "cell_type": "markdown",
438
+ "id": "ce429a6a",
439
+ "metadata": {},
440
+ "source": [
441
+ "### C1. A counterexample is a proof of the negation (certified bug)\n",
442
+ "1. `slim_check` finds a violating input for the buggy loop (random).\n",
443
+ "2. Instantiate it and let `decide` evaluate: the kernel certifies `not Post ...` for that instance.\n",
444
+ "3. Conclude `not (forall P len, Post ...)`: a kernel-checked refutation with **no axioms**."
445
+ ]
446
+ },
447
+ {
448
+ "cell_type": "code",
449
+ "execution_count": 8,
450
+ "id": "4e9cab8a",
451
+ "metadata": {
452
+ "execution": {
453
+ "iopub.execute_input": "2026-10-14T09:54:37.430013Z",
454
+ "iopub.status.busy": "2026-10-14T09:54:37.429851Z",
455
+ "iopub.status.idle": "2026-10-14T09:54:37.738292Z",
456
+ "shell.execute_reply": "2026-10-14T09:54:37.737351Z"
457
+ }
458
+ },
459
+ "outputs": [
460
+ {
461
+ "name": "stdout",
462
+ "output_type": "stream",
463
+ "text": [
464
+ "--- 1. random search ---\n"
465
+ ]
466
+ },
467
+ {
468
+ "name": "stdout",
469
+ "output_type": "stream",
470
+ "text": [
471
+ "[error] line 1: ===================\n",
472
+ "Found problems!\n",
473
+ "k := 2\n",
474
+ "len := 2\n",
475
+ "issue: ⋯ does not hold\n",
476
+ "(0 shrinks)\n",
477
+ "-------------------\n",
478
+ "--- 2. kernel-certified instance, 3. refutation of the universal spec ---\n",
479
+ "[info] line 7: 'searchBadF_not_correct' does not depend on any axioms\n"
480
+ ]
481
+ }
482
+ ],
483
+ "source": [
484
+ "print('--- 1. random search ---')\n",
485
+ "r1 = L.show(\"example : ∀ (k len : ℕ), Post (fun i => i == k) len (searchBadF (fun i => i == k) len len 0) := by slim_check\")\n",
486
+ "assert any('Found problems' in m['data'] for m in r1['messages'])\n",
487
+ "print('--- 2. kernel-certified instance, 3. refutation of the universal spec ---')\n",
488
+ "r2 = L.show(r'''\n",
489
+ "theorem bug_witness : ¬ Post (fun _ => false) 10 (searchBadF (fun _ => false) 10 20 0) := by decide\n",
490
+ "theorem searchBadF_not_correct : ¬ (∀ (P : Nat → Bool) (len : Nat), Post P len (searchBadF P len len 0)) := fun h => by\n",
491
+ " have := h (fun _ => false) 10\n",
492
+ " revert this\n",
493
+ " decide\n",
494
+ "#print axioms searchBadF_not_correct\n",
495
+ "''', keep=True)\n",
496
+ "assert any('does not depend on any axioms' in m['data'] for m in r2['messages'])\n",
497
+ "assert not [m for m in r2['messages'] if m['severity'] == 'error']"
498
+ ]
499
+ },
500
+ {
501
+ "cell_type": "markdown",
502
+ "id": "422be44f",
503
+ "metadata": {},
504
+ "source": [
505
+ "### C2. No counterexample is NOT a proof, and `slim_check` says so with `sorry`\n",
506
+ "On a TRUE statement `slim_check` reports success and **closes the goal with `sorry`**: `#print axioms` shows `sorryAx`, so the strict\n",
507
+ "verifier's axiom gate rejects it. Property testing can only produce counterexamples, never certificates."
508
+ ]
509
+ },
510
+ {
511
+ "cell_type": "code",
512
+ "execution_count": 9,
513
+ "id": "f4ff841e",
514
+ "metadata": {
515
+ "execution": {
516
+ "iopub.execute_input": "2026-10-14T09:54:37.740312Z",
517
+ "iopub.status.busy": "2026-10-14T09:54:37.740138Z",
518
+ "iopub.status.idle": "2026-10-14T09:54:38.143859Z",
519
+ "shell.execute_reply": "2026-10-14T09:54:38.143005Z"
520
+ }
521
+ },
522
+ "outputs": [
523
+ {
524
+ "name": "stdout",
525
+ "output_type": "stream",
526
+ "text": [
527
+ "[info] line 1: Success\n",
528
+ "[warning] line 1: declaration uses 'sorry'\n",
529
+ "[info] line 2: 'tt' depends on axioms: [sorryAx]\n",
530
+ "=> axioms of the \"slim_check proof\": 'tt' depends on axioms: [sorryAx]\n",
531
+ "--- the same statement is PROVED by omega (a decision procedure for linear arithmetic): ---\n",
532
+ "[info] line 2: 'tt2' depends on axioms: [propext, Quot.sound]\n"
533
+ ]
534
+ }
535
+ ],
536
+ "source": [
537
+ "r = L.show(\"theorem tt : ∀ a b : ℕ, b ≤ a → a - b + b = a := by slim_check\\n#print axioms tt\")\n",
538
+ "axs = [m['data'] for m in r['messages'] if 'depends on axioms' in m['data']]\n",
539
+ "assert axs and 'sorryAx' in axs[0]\n",
540
+ "print('=> axioms of the \"slim_check proof\":', axs[0])\n",
541
+ "print('--- the same statement is PROVED by omega (a decision procedure for linear arithmetic): ---')\n",
542
+ "r = L.show(\"theorem tt2 : ∀ a b : ℕ, b ≤ a → a - b + b = a := by omega\\n#print axioms tt2\")\n",
543
+ "assert not [m for m in r['messages'] if m['severity'] == 'error']"
544
+ ]
545
+ },
546
+ {
547
+ "cell_type": "markdown",
548
+ "id": "c0e698ee",
549
+ "metadata": {},
550
+ "source": [
551
+ "### C3. A counterexample to the *statement* is not necessarily a bug in the *program*\n",
552
+ "`forall a b : N, a - b + b = a` is false (truncated subtraction): counterexample `a=0, b=2`. The program (`-`) is doing what `Nat.sub` does;\n",
553
+ "the **spec** is missing `b <= a` (note 0302, Lemma 1). Protocol step: check the counterexample against the *precondition*."
554
+ ]
555
+ },
556
+ {
557
+ "cell_type": "code",
558
+ "execution_count": 10,
559
+ "id": "797ea289",
560
+ "metadata": {
561
+ "execution": {
562
+ "iopub.execute_input": "2026-10-14T09:54:38.145619Z",
563
+ "iopub.status.busy": "2026-10-14T09:54:38.145413Z",
564
+ "iopub.status.idle": "2026-10-14T09:54:38.414542Z",
565
+ "shell.execute_reply": "2026-10-14T09:54:38.413549Z"
566
+ }
567
+ },
568
+ "outputs": [
569
+ {
570
+ "name": "stdout",
571
+ "output_type": "stream",
572
+ "text": [
573
+ "[error] line 1: ===================\n",
574
+ "Found problems!\n",
575
+ "a := 0\n",
576
+ "b := 1\n",
577
+ "issue: 1 = 0 does not hold\n",
578
+ "(0 shrinks)\n",
579
+ "-------------------\n",
580
+ "python model of N-subtraction agrees: 2 != 0\n"
581
+ ]
582
+ }
583
+ ],
584
+ "source": [
585
+ "r = L.show(\"example : ∀ a b : ℕ, a - b + b = a := by slim_check\")\n",
586
+ "assert any('Found problems' in m['data'] for m in r['messages'])\n",
587
+ "print('python model of N-subtraction agrees:', max(0, 0 - 2) + 2, '!= 0')\n",
588
+ "assert max(0, 0 - 2) + 2 == 2"
589
+ ]
590
+ },
591
+ {
592
+ "cell_type": "markdown",
593
+ "id": "f63c9230",
594
+ "metadata": {},
595
+ "source": [
596
+ "### C4. A failed proof is not a bug: `omega` abstracts a nonlinear term and reports a *spurious* counterexample\n",
597
+ "`n <= n*n` is TRUE for all naturals. `omega` treats `n*n` as an atom `b` and answers with the constraints `a - b >= 1, a >= 0`; those\n",
598
+ "are satisfiable for an unconstrained atom, but `b = a*a` is not unconstrained."
599
+ ]
600
+ },
601
+ {
602
+ "cell_type": "code",
603
+ "execution_count": 11,
604
+ "id": "4540734b",
605
+ "metadata": {
606
+ "execution": {
607
+ "iopub.execute_input": "2026-10-14T09:54:38.416132Z",
608
+ "iopub.status.busy": "2026-10-14T09:54:38.415974Z",
609
+ "iopub.status.idle": "2026-10-14T09:54:38.447380Z",
610
+ "shell.execute_reply": "2026-10-14T09:54:38.446354Z"
611
+ }
612
+ },
613
+ "outputs": [
614
+ {
615
+ "name": "stdout",
616
+ "output_type": "stream",
617
+ "text": [
618
+ "[error] line 1: omega could not prove the goal:\n",
619
+ "a possible counterexample may satisfy the constraints\n",
620
+ " a - b ≥ 1\n",
621
+ " a ≥ 0\n",
622
+ "where\n",
623
+ " a := ↑n\n",
624
+ " b := ↑n * ↑n\n",
625
+ "exhaustive check that the \"possible counterexample\" does not exist for n < 10^5: True\n",
626
+ "[info] line 2: 'le_sq'' depends on axioms: [propext]\n"
627
+ ]
628
+ }
629
+ ],
630
+ "source": [
631
+ "r = L.show(\"example : ∀ n : ℕ, n ≤ n * n := by omega\")\n",
632
+ "assert any('omega could not prove the goal' in m['data'] for m in r['messages'])\n",
633
+ "print('exhaustive check that the \"possible counterexample\" does not exist for n < 10^5:', all(n <= n * n for n in range(10 ** 5)))\n",
634
+ "r = L.show(\"theorem le_sq' : ∀ n : ℕ, n ≤ n * n := fun n => Nat.le_mul_self n\\n#print axioms le_sq'\")"
635
+ ]
636
+ },
637
+ {
638
+ "cell_type": "markdown",
639
+ "id": "5d6f5442",
640
+ "metadata": {},
641
+ "source": [
642
+ "### C4b. `omega` on this pin is not complete even for PURE linear integer arithmetic (no atoms)\n",
643
+ "The Lean docstring (`Init/Tactics.lean`, pin v4.9.0-rc1): *\"It is not yet a full decision procedure (no \"dark\" or \"grey\" shadows)\"*. Pugh's classic system\n",
644
+ "`27 <= 11x + 13y <= 45, -10 <= 7x - 9y <= 4` has real solutions but NO integer solution. A complete procedure proves `False`; this `omega` answers\n",
645
+ "\"a possible counterexample may satisfy the constraints\" although none exists, and there are no atoms to blame."
646
+ ]
647
+ },
648
+ {
649
+ "cell_type": "code",
650
+ "execution_count": 12,
651
+ "id": "554118d7",
652
+ "metadata": {
653
+ "execution": {
654
+ "iopub.execute_input": "2026-10-14T09:54:38.449055Z",
655
+ "iopub.status.busy": "2026-10-14T09:54:38.448851Z",
656
+ "iopub.status.idle": "2026-10-14T09:54:38.475501Z",
657
+ "shell.execute_reply": "2026-10-14T09:54:38.474256Z"
658
+ }
659
+ },
660
+ "outputs": [
661
+ {
662
+ "name": "stdout",
663
+ "output_type": "stream",
664
+ "text": [
665
+ "[error] line 2: omega could not prove the goal:\n",
666
+ "a possible counterexample may satisfy the constraints\n",
667
+ " 27 ≤ 11*a + 13*b ≤ 45\n",
668
+ " -10 ≤ 7*a - 9*b ≤ 4\n",
669
+ "where\n",
670
+ " a := x\n",
671
+ " b := y\n",
672
+ "real vertices: [('113/190', '299/190'), ('59/38', '29/38'), ('55/38', '85/38'), ('457/190', '271/190')]\n",
673
+ "integer points in the bounding box [-1,3]x[-1,3] (which contains the whole real polytope): []\n",
674
+ "=> the system has real solutions (the polytope is non-empty) but no integer solution, yet omega reports a \"possible counterexample\": a failure message is NOT a countermodel.\n"
675
+ ]
676
+ }
677
+ ],
678
+ "source": [
679
+ "\n",
680
+ "from fractions import Fraction as Fr\n",
681
+ "r = L.show(\"\"\"\n",
682
+ "example (x y : Int) (h1 : 27 ≤ 11*x+13*y) (h2 : 11*x+13*y ≤ 45) (h3 : -10 ≤ 7*x-9*y) (h4 : 7*x-9*y ≤ 4) : False := by omega\n",
683
+ "\"\"\")\n",
684
+ "assert any('possible counterexample' in m['data'] for m in r['messages'])\n",
685
+ "# the real polytope: vertices of u = 11x+13y in {27,45}, w = 7x-9y in {-10,4} (x = (9u+13w)/190, y = (7u-11w)/190)\n",
686
+ "verts = [(Fr(9*u + 13*w, 190), Fr(7*u - 11*w, 190)) for u in (27, 45) for w in (-10, 4)]\n",
687
+ "xs, ys = [v[0] for v in verts], [v[1] for v in verts]\n",
688
+ "lo_x, hi_x, lo_y, hi_y = int(min(xs)) - 1, int(max(xs)) + 1, int(min(ys)) - 1, int(max(ys)) + 1\n",
689
+ "pts = [(x, y) for x in range(lo_x, hi_x + 1) for y in range(lo_y, hi_y + 1) if 27 <= 11*x+13*y <= 45 and -10 <= 7*x-9*y <= 4]\n",
690
+ "print('real vertices:', [(str(a), str(b)) for a, b in verts])\n",
691
+ "print(f'integer points in the bounding box [{lo_x},{hi_x}]x[{lo_y},{hi_y}] (which contains the whole real polytope): {pts}')\n",
692
+ "assert pts == []\n",
693
+ "print('=> the system has real solutions (the polytope is non-empty) but no integer solution, yet omega reports a \"possible counterexample\": a failure message is NOT a countermodel.')\n"
694
+ ]
695
+ },
696
+ {
697
+ "cell_type": "markdown",
698
+ "id": "8a0fc84d",
699
+ "metadata": {},
700
+ "source": [
701
+ "### C5. A failed VC is not a bug: the counterexample state may be unreachable (continuation of 0303 A3)\n",
702
+ "Weaken the loop invariant to `n <= len`. The lemma `forall P len n, n <= len -> Post P len (search P len n)` is FALSE\n",
703
+ "(`slim_check` finds a counterexample, below). But the program starts at `n = 0`. We check **exhaustively in a scope** (all `P : Fin 4 -> Bool`,\n",
704
+ "`len <= 4`, `n <= len`): every VC-counterexample is a state the real loop never visits, and every state it does visit satisfies the lemma."
705
+ ]
706
+ },
707
+ {
708
+ "cell_type": "code",
709
+ "execution_count": 13,
710
+ "id": "fcb52aca",
711
+ "metadata": {
712
+ "execution": {
713
+ "iopub.execute_input": "2026-10-14T09:54:38.477338Z",
714
+ "iopub.status.busy": "2026-10-14T09:54:38.477168Z",
715
+ "iopub.status.idle": "2026-10-14T09:54:39.860566Z",
716
+ "shell.execute_reply": "2026-10-14T09:54:39.859669Z"
717
+ }
718
+ },
719
+ "outputs": [
720
+ {
721
+ "name": "stdout",
722
+ "output_type": "stream",
723
+ "text": [
724
+ "[error] line 1: ===================\n",
725
+ "Found problems!\n",
726
+ "P := [0 ↦ true, 1 ↦ true, _ ↦ true]\n",
727
+ "len := 6\n",
728
+ "n := 5\n",
729
+ "guard: 5 ≤ 6\n",
730
+ "issue: ⋯ does not hold\n",
731
+ "(0 shrinks)\n",
732
+ "-------------------\n",
733
+ "scope: VC counterexamples (P,len,n): 111; of these reachable from n=0: 0; reachable states total 129, violating the lemma: 0\n",
734
+ "Lean count of VC counterexamples in the same scope: ['111']\n",
735
+ "=> every counterexample to the weakened verification condition is unreachable: spurious, NOT a bug in the program.\n",
736
+ "--- and the program-level statement (start n = 0) survives random testing (with the sorry caveat of C2): ---\n"
737
+ ]
738
+ },
739
+ {
740
+ "name": "stdout",
741
+ "output_type": "stream",
742
+ "text": [
743
+ "[info] line 1: Success\n",
744
+ "[warning] line 1: declaration uses 'sorry'\n"
745
+ ]
746
+ },
747
+ {
748
+ "data": {
749
+ "text/plain": [
750
+ "{'messages': [{'severity': 'info',\n",
751
+ " 'pos': {'line': 1, 'column': 76},\n",
752
+ " 'endPos': {'line': 1, 'column': 86},\n",
753
+ " 'data': 'Success'},\n",
754
+ " {'severity': 'warning',\n",
755
+ " 'pos': {'line': 1, 'column': 0},\n",
756
+ " 'endPos': {'line': 1, 'column': 7},\n",
757
+ " 'data': \"declaration uses 'sorry'\"}],\n",
758
+ " 'env': 13}"
759
+ ]
760
+ },
761
+ "execution_count": 13,
762
+ "metadata": {},
763
+ "output_type": "execute_result"
764
+ }
765
+ ],
766
+ "source": [
767
+ "r = L.show(\"example : ∀ (P : Nat → Bool) (len n : Nat), n ≤ len → Post P len (search P len n) := by slim_check\")\n",
768
+ "assert any('Found problems' in m['data'] for m in r['messages'])\n",
769
+ "def trace(P, length):\n",
770
+ " n_, seen = 0, []\n",
771
+ " while n_ <= length:\n",
772
+ " seen.append(n_)\n",
773
+ " if n_ >= length or P[n_]: break\n",
774
+ " n_ += 1\n",
775
+ " return seen\n",
776
+ "def search5(P, ln, n0):\n",
777
+ " m = n0\n",
778
+ " while m < ln and not P[m]: m += 1\n",
779
+ " return m\n",
780
+ "def post5(P, ln, r_):\n",
781
+ " return r_ <= ln and (r_ == ln or P[r_]) and all((not P[i]) for i in range(r_))\n",
782
+ "vc_cex = reach = reach_bad = 0; vc_cex_reachable = 0\n",
783
+ "for length in range(5):\n",
784
+ " for bits in itertools.product([False, True], repeat=4):\n",
785
+ " tr_ = set(trace(bits, length))\n",
786
+ " for n_ in range(length + 1):\n",
787
+ " ok = post5(bits, length, search5(bits, length, n_))\n",
788
+ " if not ok:\n",
789
+ " vc_cex += 1; vc_cex_reachable += (n_ in tr_)\n",
790
+ " if n_ in tr_:\n",
791
+ " reach += 1; reach_bad += (not ok)\n",
792
+ "print(f'scope: VC counterexamples (P,len,n): {vc_cex}; of these reachable from n=0: {vc_cex_reachable}; reachable states total {reach}, violating the lemma: {reach_bad}')\n",
793
+ "assert vc_cex > 0 and vc_cex_reachable == 0 and reach_bad == 0\n",
794
+ "r = L.run('''\n",
795
+ "#eval (List.range 5).foldl (fun acc len => acc + (List.range 16).foldl (fun a m =>\n",
796
+ " let P : Nat → Bool := fun i => (m / (2 ^ i)) % 2 == 1\n",
797
+ " a + (List.range (len + 1)).foldl (fun b n => if decide (Post P len (search P len n)) then b else b + 1) 0) 0) 0\n",
798
+ "''')\n",
799
+ "lean_vc = [m['data'].strip() for m in r['messages'] if m['severity'] == 'info']\n",
800
+ "print('Lean count of VC counterexamples in the same scope:', lean_vc)\n",
801
+ "assert int(lean_vc[0]) == vc_cex\n",
802
+ "print('=> every counterexample to the weakened verification condition is unreachable: spurious, NOT a bug in the program.')\n",
803
+ "print('--- and the program-level statement (start n = 0) survives random testing (with the sorry caveat of C2): ---')\n",
804
+ "L.show(\"example : ∀ (P : Nat → Bool) (len : Nat), Post P len (search P len 0) := by slim_check\")"
805
+ ]
806
+ },
807
+ {
808
+ "cell_type": "markdown",
809
+ "id": "92feb95d",
810
+ "metadata": {},
811
+ "source": [
812
+ "### C6. `decide` as model finding in a bounded scope (complete up to the bound)\n",
813
+ "Exhaustive over all predicates `P` on `Fin 4` (16 of them) and all `len <= 4`: the correct loop passes, the buggy one fails\n",
814
+ "on a specific witness. Compare with the same enumeration in Python."
815
+ ]
816
+ },
817
+ {
818
+ "cell_type": "code",
819
+ "execution_count": 14,
820
+ "id": "570ac396",
821
+ "metadata": {
822
+ "execution": {
823
+ "iopub.execute_input": "2026-10-14T09:54:39.862363Z",
824
+ "iopub.status.busy": "2026-10-14T09:54:39.862136Z",
825
+ "iopub.status.idle": "2026-10-14T09:54:39.961142Z",
826
+ "shell.execute_reply": "2026-10-14T09:54:39.960201Z"
827
+ }
828
+ },
829
+ "outputs": [
830
+ {
831
+ "name": "stdout",
832
+ "output_type": "stream",
833
+ "text": [
834
+ "Python exhaustive: 80 (P,len) scopes; correct loop violates 0, buggy loop violates 15; first witness ((False, False, False, False), 1)\n",
835
+ "Lean exhaustive violation counts (buggy, correct): ['15', '0']\n"
836
+ ]
837
+ }
838
+ ],
839
+ "source": [
840
+ "def py_search(P, length, n=0):\n",
841
+ " while n < length:\n",
842
+ " if P[n]: return n\n",
843
+ " n += 1\n",
844
+ " return n\n",
845
+ "def py_bad(P, length, n=0):\n",
846
+ " while n + 1 < length:\n",
847
+ " if P[n]: return n\n",
848
+ " n += 1\n",
849
+ " return n\n",
850
+ "def py_post(P, length, r):\n",
851
+ " return r <= length and (r == length or (r < 4 and P[r])) and all((not P[i]) for i in range(r))\n",
852
+ "tot_ok = bad_cnt = 0\n",
853
+ "bad_witness = None\n",
854
+ "for length in range(5):\n",
855
+ " for bits in itertools.product([False, True], repeat=4):\n",
856
+ " assert py_post(bits, length, py_search(bits, length))\n",
857
+ " if not py_post(bits, length, py_bad(bits, length)):\n",
858
+ " bad_cnt += 1; bad_witness = bad_witness or (bits, length)\n",
859
+ " tot_ok += 1\n",
860
+ "print(f'Python exhaustive: {tot_ok} (P,len) scopes; correct loop violates 0, buggy loop violates {bad_cnt}; first witness {bad_witness}')\n",
861
+ "r = L.run('''\n",
862
+ "#eval (List.range 5).foldl (fun acc len => acc + (List.range 16).foldl (fun a m =>\n",
863
+ " let P : Nat → Bool := fun i => (m / (2 ^ i)) % 2 == 1\n",
864
+ " if decide (Post P len (searchBadF P len len 0)) then a else a + 1) 0) 0\n",
865
+ "#eval (List.range 5).foldl (fun acc len => acc + (List.range 16).foldl (fun a m =>\n",
866
+ " let P : Nat → Bool := fun i => (m / (2 ^ i)) % 2 == 1\n",
867
+ " if decide (Post P len (search P len 0)) then a else a + 1) 0) 0\n",
868
+ "''')\n",
869
+ "cnts = [m['data'].strip() for m in r['messages'] if m['severity'] == 'info']\n",
870
+ "print('Lean exhaustive violation counts (buggy, correct):', cnts)\n",
871
+ "assert int(cnts[0]) == bad_cnt and int(cnts[1]) == 0"
872
+ ]
873
+ },
874
+ {
875
+ "cell_type": "markdown",
876
+ "id": "1547dd78",
877
+ "metadata": {},
878
+ "source": [
879
+ "### C7. A reachable VC violation: what it does and does not prove (Lemma 4 of the note)\n",
880
+ "Program of 0303: `i := 0; s := 0; while i < n do s := s + inc(i); i := i + 1` with `inc(i) = i` (correct) or `i + 1` (buggy), precondition `n >= 0`,\n",
881
+ "post `Q: 2s = n(n-1)`, invariant `I: 0<=i<=n and 2s = i(i-1)`. The loop-head states `(n,i,s)` visited from `n >= 0` are `Reach`. Classify every violated VC:\n",
882
+ "* **preserve VC** (`I and i<n => I[next]`): a reachable violating state `x` has a REACHABLE successor `x'` violating `I`: the annotation (or the code) is wrong, but `Q` may still hold;\n",
883
+ "* **exit VC** (`I and not(i<n) => Q`): a reachable violating state is the state in which the loop actually EXITS: `Q` is violated by a real run: a bug witness (code or spec);\n",
884
+ "* unreachable violation: no conclusion."
885
+ ]
886
+ },
887
+ {
888
+ "cell_type": "code",
889
+ "execution_count": 15,
890
+ "id": "85eabecd",
891
+ "metadata": {
892
+ "execution": {
893
+ "iopub.execute_input": "2026-10-14T09:54:39.962887Z",
894
+ "iopub.status.busy": "2026-10-14T09:54:39.962663Z",
895
+ "iopub.status.idle": "2026-10-14T09:54:39.984403Z",
896
+ "shell.execute_reply": "2026-10-14T09:54:39.983516Z"
897
+ }
898
+ },
899
+ "outputs": [
900
+ {
901
+ "name": "stdout",
902
+ "output_type": "stream",
903
+ "text": [
904
+ "A weak I, right code, right Q preserve-viol 0 (reachable 0) exit-viol 504 (reachable 0) Q holds on every run: True\n",
905
+ "B right I, right code, WRONG Q (n(n+1)) preserve-viol 0 (reachable 0) exit-viol 7 (reachable 7) Q holds on every run: False\n",
906
+ "C right I, BUGGY code, right Q preserve-viol 28 (reachable 7) exit-viol 0 (reachable 0) Q holds on every run: False\n",
907
+ "D too-strong I (i<=5), right code, right Q preserve-viol 2 (reachable 2) exit-viol 0 (reachable 0) Q holds on every run: True\n",
908
+ "=> reachable preserve-violation: I is not an invariant (D: but Q still holds); reachable exit-violation: Q fails in a real run (B).\n"
909
+ ]
910
+ }
911
+ ],
912
+ "source": [
913
+ "\n",
914
+ "def run_head_states(n, inc):\n",
915
+ " st, seen = (n, 0, 0), []\n",
916
+ " while True:\n",
917
+ " seen.append(st); nn, ii, ss = st\n",
918
+ " if not ii < nn: return seen\n",
919
+ " st = (nn, ii + 1, ss + inc(ii))\n",
920
+ "Pn = range(0, 8)\n",
921
+ "Ibox = [(n_, i_, s_) for n_ in range(0, 8) for i_ in range(-1, 9) for s_ in range(-4, 60)]\n",
922
+ "Qf = lambda n_, i_, s_: 2 * s_ == n_ * (n_ - 1)\n",
923
+ "def analyse(label, inc, I, Q):\n",
924
+ " reach = {st for n_ in Pn for st in run_head_states(n_, inc)}\n",
925
+ " step = lambda st: (st[0], st[1] + 1, st[2] + inc(st[1]))\n",
926
+ " pres = [st for st in Ibox if I(*st) and st[1] < st[0] and not I(*step(st))]\n",
927
+ " exits = [st for st in Ibox if I(*st) and not st[1] < st[0] and not Q(*st)]\n",
928
+ " pres_r = [st for st in pres if st in reach]; exits_r = [st for st in exits if st in reach]\n",
929
+ " # (a) a reachable preserve-violation has a reachable successor that violates I\n",
930
+ " assert all(step(st) in reach and not I(*step(st)) for st in pres_r)\n",
931
+ " # (b) a reachable exit-violation is the final state of a real run, and it violates Q\n",
932
+ " finals = {run_head_states(n_, inc)[-1] for n_ in Pn}\n",
933
+ " assert all(st in finals and not Q(*st) for st in exits_r)\n",
934
+ " post_ok = all(Q(*run_head_states(n_, inc)[-1]) for n_ in Pn)\n",
935
+ " print(f'{label:46s} preserve-viol {len(pres):4d} (reachable {len(pres_r):3d}) exit-viol {len(exits):4d} (reachable {len(exits_r):3d}) Q holds on every run: {post_ok}')\n",
936
+ " return len(pres), len(pres_r), len(exits), len(exits_r), post_ok\n",
937
+ "good = lambda i: i; buggy = lambda i: i + 1\n",
938
+ "I_ok = lambda n_, i_, s_: 0 <= i_ <= n_ and 2 * s_ == i_ * (i_ - 1)\n",
939
+ "I_weak = lambda n_, i_, s_: 0 <= i_ <= n_\n",
940
+ "I_tooStrong = lambda n_, i_, s_: 0 <= i_ <= n_ and 2 * s_ == i_ * (i_ - 1) and i_ <= 5\n",
941
+ "Q_wrong = lambda n_, i_, s_: 2 * s_ == n_ * (n_ + 1)\n",
942
+ "A = analyse('A weak I, right code, right Q', good, I_weak, Qf)\n",
943
+ "B = analyse('B right I, right code, WRONG Q (n(n+1))', good, I_ok, Q_wrong)\n",
944
+ "C = analyse('C right I, BUGGY code, right Q', buggy, I_ok, Qf)\n",
945
+ "D = analyse('D too-strong I (i<=5), right code, right Q', good, I_tooStrong, Qf)\n",
946
+ "assert A[2] > 0 and A[3] == 0 # exit violations exist, all unreachable: nothing follows (the 0304 C5 story)\n",
947
+ "assert B[3] > 0 and not B[4] # reachable exit violation <=> Q really fails: a bug witness (here a wrong spec)\n",
948
+ "assert C[1] > 0 and not C[4] # reachable preserve violation in the buggy program\n",
949
+ "assert D[1] > 0 and D[4] # reachable preserve violation, I is not an invariant, yet Q holds on every run\n",
950
+ "print('=> reachable preserve-violation: I is not an invariant (D: but Q still holds); reachable exit-violation: Q fails in a real run (B).')\n"
951
+ ]
952
+ },
953
+ {
954
+ "cell_type": "markdown",
955
+ "id": "2da08f1c",
956
+ "metadata": {},
957
+ "source": [
958
+ "## The protocol, executed on one example\n",
959
+ "1. (counterexample) `slim_check`/`decide` gives `P = const false, len = 10` (or the shrunk `k=2, len=1`).\n",
960
+ "2. (precondition) Is it admissible: `Post` has no precondition other than `len`/`P` arbitrary -> yes.\n",
961
+ "3. (reachable) the loop is started at `n=0`: the witness is a top-level input, not an internal state -> reachable.\n",
962
+ "4. (evaluate) `#eval searchBadF (fun _ => false) 10 20 0 = 9`, spec demands `10`.\n",
963
+ "5. (intent) does the spec say what we want? Dafny's LinearSearch: `n = len` when nothing matches -> yes.\n",
964
+ "6. (certify) `searchBadF_not_correct` is a kernel-checked theorem with no axioms."
965
+ ]
966
+ },
967
+ {
968
+ "cell_type": "code",
969
+ "execution_count": 16,
970
+ "id": "1eb263c7",
971
+ "metadata": {
972
+ "execution": {
973
+ "iopub.execute_input": "2026-10-14T09:54:39.986228Z",
974
+ "iopub.status.busy": "2026-10-14T09:54:39.986009Z",
975
+ "iopub.status.idle": "2026-10-14T09:54:40.253611Z",
976
+ "shell.execute_reply": "2026-10-14T09:54:40.252685Z"
977
+ }
978
+ },
979
+ "outputs": [],
980
+ "source": [
981
+ "L.close()"
982
+ ]
983
+ }
984
+ ],
985
+ "metadata": {
986
+ "kernelspec": {
987
+ "display_name": "Python 3",
988
+ "language": "python",
989
+ "name": "python3"
990
+ },
991
+ "language_info": {
992
+ "codemirror_mode": {
993
+ "name": "ipython",
994
+ "version": 3
995
+ },
996
+ "file_extension": ".py",
997
+ "mimetype": "text/x-python",
998
+ "name": "python",
999
+ "nbconvert_exporter": "python",
1000
+ "pygments_lexer": "ipython3",
1001
+ "version": "3.12.11"
1002
+ }
1003
+ },
1004
+ "nbformat": 4,
1005
+ "nbformat_minor": 5
1006
+ }
code/notebooks/0305-spec-risk.ipynb ADDED
@@ -0,0 +1,789 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "9bfd3f75",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0305 - Spec generation risk: the proof of the wrong theorem\n",
9
+ "\n",
10
+ "Scratch-pad for note `0305 - Spec Generation Risk.pdf`. Naive first, efficient second, assert agreement.\n",
11
+ "\n",
12
+ "* **A. Spec validation by tests, VERINA-style.** NAIVE = decide soundness/completeness of a candidate post-condition by EXHAUSTIVE\n",
13
+ " comparison with the ground truth over a bounded domain. EFFICIENT = decide it from positive / negative tests only (the reduction\n",
14
+ " of VERINA section 4.1). Assert: the test-based verdict is never wrong in the \"refuted\" direction, and equals the exhaustive one with full tests.\n",
15
+ "* **A2. Mutation testing of a spec**: kill probability `1-(1-kappa)^m` per mutant, checked by Monte Carlo.\n",
16
+ "* **B. Lean**: a \"verified\" function that does nothing useful; a spec that leaks the implementation; a vacuous statement; and the\n",
17
+ " **statement-rewrite** finding of this repository (docs/P2.md item 18) reproduced against four gates, using `align.verify.lean` (<= 2 workers, nice 15).\n",
18
+ "* **C. Risk model**: `P(spec wrong | proof accepted)` by Bayes, checked by Monte Carlo.\n",
19
+ "\n",
20
+ "No GPU."
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "code",
25
+ "execution_count": 1,
26
+ "id": "6dc6c1b4",
27
+ "metadata": {
28
+ "execution": {
29
+ "iopub.execute_input": "2026-10-14T09:54:42.902895Z",
30
+ "iopub.status.busy": "2026-10-14T09:54:42.902750Z",
31
+ "iopub.status.idle": "2026-10-14T09:54:42.912430Z",
32
+ "shell.execute_reply": "2026-10-14T09:54:42.911655Z"
33
+ }
34
+ },
35
+ "outputs": [
36
+ {
37
+ "name": "stdout",
38
+ "output_type": "stream",
39
+ "text": [
40
+ "40 lists; 1600 input/output pairs\n"
41
+ ]
42
+ }
43
+ ],
44
+ "source": [
45
+ "import os\n",
46
+ "os.environ[\"CUDA_VISIBLE_DEVICES\"] = \"\"\n",
47
+ "import itertools, random, math, sys, collections\n",
48
+ "random.seed(305)\n",
49
+ "\n",
50
+ "# ---- domain: lists of length <= 3 over {0,1,2} --------------------------------------------------------------\n",
51
+ "V = (0, 1, 2)\n",
52
+ "LISTS = [tuple(t) for n in range(4) for t in itertools.product(V, repeat=n)]\n",
53
+ "PAIRS = [(x, y) for x in LISTS for y in LISTS]\n",
54
+ "print(len(LISTS), 'lists;', len(PAIRS), 'input/output pairs')\n",
55
+ "\n",
56
+ "# atoms an LLM-written spec is built from\n",
57
+ "srt = lambda y: all(a <= b for a, b in zip(y, y[1:]))\n",
58
+ "srt_str = lambda y: all(a < b for a, b in zip(y, y[1:]))\n",
59
+ "srt_rev = lambda y: all(a >= b for a, b in zip(y, y[1:]))\n",
60
+ "perm = lambda x, y: collections.Counter(x) == collections.Counter(y)\n",
61
+ "len_eq = lambda x, y: len(x) == len(y)\n",
62
+ "sum_eq = lambda x, y: sum(x) == sum(y)\n",
63
+ "subset = lambda x, y: set(y) <= set(x)\n",
64
+ "leak = lambda x, y: y == tuple(sorted(x))\n",
65
+ "\n",
66
+ "Q = lambda x, y: srt(y) and perm(x, y) # ground-truth post-condition (precondition: True)\n",
67
+ "CANDIDATES = {\n",
68
+ " 'Q0 sorted & perm (truth)': lambda x, y: srt(y) and perm(x, y),\n",
69
+ " 'S1 sorted only': lambda x, y: srt(y),\n",
70
+ " 'S2 sorted & perm & len<=2': lambda x, y: srt(y) and perm(x, y) and len(y) <= 2,\n",
71
+ " 'S3 sorted & same len & same sum': lambda x, y: srt(y) and len_eq(x, y) and sum_eq(x, y),\n",
72
+ " 'S4 strictly sorted & perm': lambda x, y: srt_str(y) and perm(x, y),\n",
73
+ " 'S5 sorted & y == sorted(x) (leaks)': lambda x, y: srt(y) and leak(x, y),\n",
74
+ " 'S6 perm only': lambda x, y: perm(x, y),\n",
75
+ " 'S7 sorted & same len & y subset x': lambda x, y: srt(y) and len_eq(x, y) and subset(x, y),\n",
76
+ "}"
77
+ ]
78
+ },
79
+ {
80
+ "cell_type": "markdown",
81
+ "id": "e791e799",
82
+ "metadata": {},
83
+ "source": [
84
+ "## A1. Soundness / completeness of a candidate spec\n",
85
+ "VERINA (arXiv 2505.23135 section 4.1): with pre-condition `P = True`, `Q^` is **sound** iff `forall x y. Q^(x,y) => Q(x,y)` (it accepts only correct outputs)\n",
86
+ "and **complete** iff `forall x y. Q(x,y) => Q^(x,y)` (it accepts every correct output).\n",
87
+ "NAIVE: check both over all 1600 pairs. EFFICIENT (their reduction): soundness needs only the *negative* tests (pairs with `not Q`), completeness only the *positive* ones."
88
+ ]
89
+ },
90
+ {
91
+ "cell_type": "code",
92
+ "execution_count": 2,
93
+ "id": "35f1c93e",
94
+ "metadata": {
95
+ "execution": {
96
+ "iopub.execute_input": "2026-10-14T09:54:42.914044Z",
97
+ "iopub.status.busy": "2026-10-14T09:54:42.913844Z",
98
+ "iopub.status.idle": "2026-10-14T09:54:43.013138Z",
99
+ "shell.execute_reply": "2026-10-14T09:54:43.012157Z"
100
+ }
101
+ },
102
+ "outputs": [
103
+ {
104
+ "name": "stdout",
105
+ "output_type": "stream",
106
+ "text": [
107
+ "positive pairs: 40 negative pairs: 1560\n",
108
+ "candidate sound complete accepted-negatives rejected-positives\n",
109
+ "Q0 sorted & perm (truth) True True 0 0\n",
110
+ "S1 sorted only False True 760 0\n",
111
+ "S2 sorted & perm & len<=2 True False 0 27\n",
112
+ "S3 sorted & same len & same sum False True 22 0\n",
113
+ "S4 strictly sorted & perm True False 0 24\n",
114
+ "S5 sorted & y == sorted(x) (leaks) True True 0 0\n",
115
+ "S6 perm only False True 72 0\n",
116
+ "S7 sorted & same len & y subset x False True 120 0\n",
117
+ "definition == test-based verdict for all 8 candidates (exact).\n"
118
+ ]
119
+ }
120
+ ],
121
+ "source": [
122
+ "POS = [(x, y) for x, y in PAIRS if Q(x, y)]\n",
123
+ "NEG = [(x, y) for x, y in PAIRS if not Q(x, y)]\n",
124
+ "print('positive pairs:', len(POS), ' negative pairs:', len(NEG))\n",
125
+ "table = {}\n",
126
+ "for name, Qh in CANDIDATES.items():\n",
127
+ " # NAIVE: the definition, over every pair\n",
128
+ " sound_def = all((not Qh(x, y)) or Q(x, y) for x, y in PAIRS)\n",
129
+ " complete_def = all((not Q(x, y)) or Qh(x, y) for x, y in PAIRS)\n",
130
+ " # EFFICIENT: tests only (full suites here) -- soundness: Q^ rejects every negative test; completeness: Q^ accepts every positive test\n",
131
+ " a = sum(1 for x, y in NEG if Qh(x, y)) # accepted negatives = soundness violations\n",
132
+ " b = sum(1 for x, y in POS if not Qh(x, y)) # rejected positives = completeness violations\n",
133
+ " sound_t, complete_t = (a == 0), (b == 0)\n",
134
+ " assert (sound_def, complete_def) == (sound_t, complete_t)\n",
135
+ " table[name] = (sound_t, complete_t, a, b)\n",
136
+ "print(f'{\"candidate\":38s} sound complete accepted-negatives rejected-positives')\n",
137
+ "for name, (s_, c_, a_, b_) in table.items(): print(f'{name:38s} {str(s_):5s} {str(c_):8s} {a_:15d} {b_:17d}')\n",
138
+ "assert table['Q0 sorted & perm (truth)'][:2] == (True, True) and table['S5 sorted & y == sorted(x) (leaks)'][:2] == (True, True)\n",
139
+ "print('definition == test-based verdict for all', len(CANDIDATES), 'candidates (exact).')"
140
+ ]
141
+ },
142
+ {
143
+ "cell_type": "markdown",
144
+ "id": "8c5cdb33",
145
+ "metadata": {},
146
+ "source": [
147
+ "### A1b. A wrong *program* passes a wrong *spec* (the proof of the wrong theorem, at toy scale)\n",
148
+ "Programs `f : list -> list`. \"f satisfies spec S\" means `forall x. S(x, f(x))`. Truth = f is a correct sort."
149
+ ]
150
+ },
151
+ {
152
+ "cell_type": "code",
153
+ "execution_count": 3,
154
+ "id": "2302af24",
155
+ "metadata": {
156
+ "execution": {
157
+ "iopub.execute_input": "2026-10-14T09:54:43.014728Z",
158
+ "iopub.status.busy": "2026-10-14T09:54:43.014577Z",
159
+ "iopub.status.idle": "2026-10-14T09:54:43.023261Z",
160
+ "shell.execute_reply": "2026-10-14T09:54:43.022309Z"
161
+ }
162
+ },
163
+ "outputs": [
164
+ {
165
+ "name": "stdout",
166
+ "output_type": "stream",
167
+ "text": [
168
+ "program Q0 S1 S2 S3 S4 S5 S6 S7\n",
169
+ "sorted (correct) PASS PASS . PASS . PASS PASS PASS\n",
170
+ "empty list . PASS . . . . . . \n",
171
+ "identity . . . . . . PASS . \n",
172
+ "sorted dedup . PASS . . . . . . \n",
173
+ "sorted, replace each v by min(v,1) . PASS . . . . . . \n",
174
+ "sorted descending . . . . . . PASS . \n",
175
+ "sort then zero the first element . PASS . . . . . . \n",
176
+ "wrong programs accepted by each spec: {'S1 sorted only': 4, 'S6 perm only': 2}\n"
177
+ ]
178
+ }
179
+ ],
180
+ "source": [
181
+ "PROGRAMS = {\n",
182
+ " 'sorted (correct)': lambda x: tuple(sorted(x)),\n",
183
+ " 'empty list': lambda x: (),\n",
184
+ " 'identity': lambda x: x,\n",
185
+ " 'sorted dedup': lambda x: tuple(sorted(set(x))),\n",
186
+ " 'sorted, replace each v by min(v,1)': lambda x: tuple(sorted(min(v, 1) for v in x)),\n",
187
+ " 'sorted descending': lambda x: tuple(sorted(x, reverse=True)),\n",
188
+ " 'sort then zero the first element': lambda x: tuple(sorted(x))[:0] + ((0,) + tuple(sorted(x))[1:] if x else ()),\n",
189
+ "}\n",
190
+ "def passes(spec, f): return all(spec(x, f(x)) for x in LISTS)\n",
191
+ "print(f'{\"program\":38s}' + ' '.join(f'{n.split()[0]:>4s}' for n in CANDIDATES))\n",
192
+ "truth_pass = {pn: passes(Q, f) for pn, f in PROGRAMS.items()}\n",
193
+ "false_accepts = collections.Counter()\n",
194
+ "for pn, f in PROGRAMS.items():\n",
195
+ " row = []\n",
196
+ " for sn, S in CANDIDATES.items():\n",
197
+ " ok = passes(S, f); row.append(ok)\n",
198
+ " if ok and not truth_pass[pn]: false_accepts[sn] += 1\n",
199
+ " print(f'{pn:38s}' + ' '.join(f'{\"PASS\" if o else \" . \":>4s}' for o in row))\n",
200
+ "assert false_accepts['S1 sorted only'] >= 3 # empty, dedup, min-clamp, zero-first, ... all pass the weak spec\n",
201
+ "assert false_accepts['Q0 sorted & perm (truth)'] == 0\n",
202
+ "print('wrong programs accepted by each spec:', dict(false_accepts))"
203
+ ]
204
+ },
205
+ {
206
+ "cell_type": "markdown",
207
+ "id": "4371ed45",
208
+ "metadata": {},
209
+ "source": [
210
+ "## A2. Mutation testing of a spec\n",
211
+ "Mutants of `Q0 = sorted(y) and perm(x,y)`: drop an atom, weaken/strengthen an atom. A test suite `T` of `m` pairs *kills* a mutant `M` iff some test has `M(x,y) != Q0(x,y)`.\n",
212
+ "Let `kappa(M) = #{(x,y): M != Q0} / 1600`. Uniform tests: `P(kill) = 1 - (1-kappa)^m`. `kappa = 0` means an *equivalent* mutant (unkillable)."
213
+ ]
214
+ },
215
+ {
216
+ "cell_type": "code",
217
+ "execution_count": 4,
218
+ "id": "aeda6210",
219
+ "metadata": {
220
+ "execution": {
221
+ "iopub.execute_input": "2026-10-14T09:54:43.024893Z",
222
+ "iopub.status.busy": "2026-10-14T09:54:43.024737Z",
223
+ "iopub.status.idle": "2026-10-14T09:54:51.227959Z",
224
+ "shell.execute_reply": "2026-10-14T09:54:51.226999Z"
225
+ }
226
+ },
227
+ "outputs": [
228
+ {
229
+ "name": "stdout",
230
+ "output_type": "stream",
231
+ "text": [
232
+ "mutant kappa P(kill) m=10 theory empirical z\n"
233
+ ]
234
+ },
235
+ {
236
+ "name": "stdout",
237
+ "output_type": "stream",
238
+ "text": [
239
+ "drop sorted 0.0450 0.3690 0.3715 0.75\n"
240
+ ]
241
+ },
242
+ {
243
+ "name": "stdout",
244
+ "output_type": "stream",
245
+ "text": [
246
+ "drop perm 0.4750 0.9984 0.9986 0.50\n"
247
+ ]
248
+ },
249
+ {
250
+ "name": "stdout",
251
+ "output_type": "stream",
252
+ "text": [
253
+ "sorted: <= -> < 0.0150 0.1403 0.1448 1.84\n"
254
+ ]
255
+ },
256
+ {
257
+ "name": "stdout",
258
+ "output_type": "stream",
259
+ "text": [
260
+ "sorted: <= -> >= 0.0375 0.3176 0.3192 0.49\n"
261
+ ]
262
+ },
263
+ {
264
+ "name": "stdout",
265
+ "output_type": "stream",
266
+ "text": [
267
+ "perm -> same length 0.1837 0.8687 0.8689 0.08\n"
268
+ ]
269
+ },
270
+ {
271
+ "name": "stdout",
272
+ "output_type": "stream",
273
+ "text": [
274
+ "perm -> same sum 0.0600 0.4614 0.4608 0.15\n"
275
+ ]
276
+ },
277
+ {
278
+ "name": "stdout",
279
+ "output_type": "stream",
280
+ "text": [
281
+ "perm -> subset 0.2231 0.9199 0.9199 0.01\n"
282
+ ]
283
+ },
284
+ {
285
+ "name": "stdout",
286
+ "output_type": "stream",
287
+ "text": [
288
+ "add redundant len(x)==len(y) 0.0000 0.0000 0.0000 0.00\n"
289
+ ]
290
+ },
291
+ {
292
+ "name": "stdout",
293
+ "output_type": "stream",
294
+ "text": [
295
+ "perm -> y == sorted(x) 0.0000 0.0000 0.0000 0.00\n"
296
+ ]
297
+ },
298
+ {
299
+ "name": "stdout",
300
+ "output_type": "stream",
301
+ "text": [
302
+ "add len(y)<=2 0.0169 0.1565 0.1540 0.97\n",
303
+ "equivalent (unkillable) mutants: ['add redundant len(x)==len(y)', 'perm -> y == sorted(x)']\n",
304
+ "mutation score with the exhaustive suite: 8/8 non-equivalent killed; worst |z| of the kill-probability law = 1.84\n"
305
+ ]
306
+ }
307
+ ],
308
+ "source": [
309
+ "MUTANTS = {\n",
310
+ " 'drop sorted': lambda x, y: perm(x, y),\n",
311
+ " 'drop perm': lambda x, y: srt(y),\n",
312
+ " 'sorted: <= -> <': lambda x, y: srt_str(y) and perm(x, y),\n",
313
+ " 'sorted: <= -> >=': lambda x, y: srt_rev(y) and perm(x, y),\n",
314
+ " 'perm -> same length': lambda x, y: srt(y) and len_eq(x, y),\n",
315
+ " 'perm -> same sum': lambda x, y: srt(y) and sum_eq(x, y),\n",
316
+ " 'perm -> subset': lambda x, y: srt(y) and subset(x, y),\n",
317
+ " 'add redundant len(x)==len(y)': lambda x, y: srt(y) and perm(x, y) and len_eq(x, y), # equivalent: perm implies equal length\n",
318
+ " 'perm -> y == sorted(x)': lambda x, y: srt(y) and leak(x, y), # equivalent\n",
319
+ " 'add len(y)<=2': lambda x, y: srt(y) and perm(x, y) and len(y) <= 2,\n",
320
+ "}\n",
321
+ "kappa = {n: sum(1 for x, y in PAIRS if M(x, y) != Q(x, y)) / len(PAIRS) for n, M in MUTANTS.items()}\n",
322
+ "rng = random.Random(9); M_SIZE = 10; SUITES = 20000\n",
323
+ "print(f'{\"mutant\":32s} {\"kappa\":>8s} {\"P(kill) m=10 theory\":>20s} {\"empirical\":>10s} {\"z\":>5s}')\n",
324
+ "worst = 0.0\n",
325
+ "for n, M in MUTANTS.items():\n",
326
+ " kills = 0\n",
327
+ " for _ in range(SUITES):\n",
328
+ " suite = [PAIRS[rng.randrange(len(PAIRS))] for _ in range(M_SIZE)]\n",
329
+ " kills += any(M(x, y) != Q(x, y) for x, y in suite)\n",
330
+ " emp = kills / SUITES; th = 1 - (1 - kappa[n]) ** M_SIZE\n",
331
+ " se = max((th * (1 - th) / SUITES) ** 0.5, 1e-9); z = abs(emp - th) / se if th not in (0.0,) else 0.0\n",
332
+ " worst = max(worst, z)\n",
333
+ " print(f'{n:32s} {kappa[n]:8.4f} {th:20.4f} {emp:10.4f} {z:5.2f}')\n",
334
+ "assert worst < 4.0\n",
335
+ "equiv = [n for n, k in kappa.items() if k == 0]\n",
336
+ "print('equivalent (unkillable) mutants:', equiv)\n",
337
+ "assert set(equiv) == {'add redundant len(x)==len(y)', 'perm -> y == sorted(x)'}\n",
338
+ "# exhaustive suite kills every non-equivalent mutant\n",
339
+ "assert all(any(M(x, y) != Q(x, y) for x, y in PAIRS) for n, M in MUTANTS.items() if n not in equiv)\n",
340
+ "print(f'mutation score with the exhaustive suite: {(len(MUTANTS) - len(equiv))}/{len(MUTANTS) - len(equiv)} non-equivalent killed; worst |z| of the kill-probability law = {worst:.2f}')"
341
+ ]
342
+ },
343
+ {
344
+ "cell_type": "markdown",
345
+ "id": "76064a25",
346
+ "metadata": {},
347
+ "source": [
348
+ "## B. Lean: proof of the wrong theorem, four ways\n",
349
+ "ONE worker via `align.verify.lean` for the experiments; a second (the strict verifier's) for B4. Total <= 2 workers, nice 15."
350
+ ]
351
+ },
352
+ {
353
+ "cell_type": "code",
354
+ "execution_count": 5,
355
+ "id": "cde1b268",
356
+ "metadata": {
357
+ "execution": {
358
+ "iopub.execute_input": "2026-10-14T09:54:51.229911Z",
359
+ "iopub.status.busy": "2026-10-14T09:54:51.229754Z",
360
+ "iopub.status.idle": "2026-10-14T09:54:55.294102Z",
361
+ "shell.execute_reply": "2026-10-14T09:54:55.293131Z"
362
+ }
363
+ },
364
+ "outputs": [],
365
+ "source": [
366
+ "sys.path.insert(0, os.path.abspath('.'))\n",
367
+ "from _lean_helper import Lean\n",
368
+ "L = Lean()"
369
+ ]
370
+ },
371
+ {
372
+ "cell_type": "markdown",
373
+ "id": "46718286",
374
+ "metadata": {},
375
+ "source": [
376
+ "### B1. A weak specification is satisfied by a useless function (and Lean happily certifies it)\n",
377
+ "Intended spec: `Sorted /\\ Perm`. A weak spec: `Sorted` only. `badSort := fun _ => []` is \"verified\" against the weak spec; the full spec refutes it."
378
+ ]
379
+ },
380
+ {
381
+ "cell_type": "code",
382
+ "execution_count": 6,
383
+ "id": "b665be7a",
384
+ "metadata": {
385
+ "execution": {
386
+ "iopub.execute_input": "2026-10-14T09:54:55.295817Z",
387
+ "iopub.status.busy": "2026-10-14T09:54:55.295655Z",
388
+ "iopub.status.idle": "2026-10-14T09:54:55.352936Z",
389
+ "shell.execute_reply": "2026-10-14T09:54:55.352189Z"
390
+ }
391
+ },
392
+ "outputs": [
393
+ {
394
+ "name": "stdout",
395
+ "output_type": "stream",
396
+ "text": [
397
+ "[warning] line 2: unused variable `xs`\n",
398
+ "note: this linter can be disabled with `set_option linter.unusedVariables false`\n",
399
+ "[info] line 4: 'badSort_weak_spec' does not depend on any axioms\n",
400
+ "[info] line 8: 'badSort_not_perm' depends on axioms: [propext]\n"
401
+ ]
402
+ }
403
+ ],
404
+ "source": [
405
+ "r = L.show(r'''\n",
406
+ "def badSort (xs : List ℕ) : List ℕ := []\n",
407
+ "theorem badSort_weak_spec (xs : List ℕ) : List.Sorted (· ≤ ·) (badSort xs) := List.sorted_nil\n",
408
+ "#print axioms badSort_weak_spec\n",
409
+ "theorem badSort_not_perm : ¬ (∀ xs : List ℕ, List.Perm xs (badSort xs)) := fun h => by\n",
410
+ " have := (h [1]).length_eq\n",
411
+ " simp [badSort] at this\n",
412
+ "#print axioms badSort_not_perm\n",
413
+ "''', keep=True)\n",
414
+ "assert not [m for m in r.get('messages', []) if m['severity'] == 'error']"
415
+ ]
416
+ },
417
+ {
418
+ "cell_type": "markdown",
419
+ "id": "5f267817",
420
+ "metadata": {},
421
+ "source": [
422
+ "### B2. A specification that leaks the implementation (CLEVER, arXiv 2505.13938, Fig. 2)\n",
423
+ "Computable spec = the recursion itself: the proof is `rfl`-level, no mathematics. A relational (non-computable) spec forces an actual induction."
424
+ ]
425
+ },
426
+ {
427
+ "cell_type": "code",
428
+ "execution_count": 7,
429
+ "id": "f9513e46",
430
+ "metadata": {
431
+ "execution": {
432
+ "iopub.execute_input": "2026-10-14T09:54:55.354522Z",
433
+ "iopub.status.busy": "2026-10-14T09:54:55.354372Z",
434
+ "iopub.status.idle": "2026-10-14T09:54:55.422221Z",
435
+ "shell.execute_reply": "2026-10-14T09:54:55.421465Z"
436
+ }
437
+ },
438
+ "outputs": [
439
+ {
440
+ "name": "stdout",
441
+ "output_type": "stream",
442
+ "text": [
443
+ "[ok] no messages\n"
444
+ ]
445
+ }
446
+ ],
447
+ "source": [
448
+ "r = L.show(r'''\n",
449
+ "def myfib : ℕ → ℕ | 0 => 0 | 1 => 1 | (n+2) => myfib n + myfib (n+1)\n",
450
+ "-- (a) the spec IS the implementation\n",
451
+ "def spec_leak (impl : ℕ → ℕ) (n : ℕ) : Prop :=\n",
452
+ " impl n = match n with | 0 => 0 | 1 => 1 | (n'+2) => impl n' + impl (n'+1)\n",
453
+ "theorem leak_proof : ∀ n, spec_leak myfib n := by\n",
454
+ " intro n; match n with | 0 => rfl | 1 => rfl | n'+2 => rfl\n",
455
+ "-- (b) a relational spec: needs induction\n",
456
+ "inductive IsFib : ℕ → ℕ → Prop\n",
457
+ " | zero : IsFib 0 0\n",
458
+ " | one : IsFib 1 1\n",
459
+ " | step : ∀ n a b, IsFib n a → IsFib (n+1) b → IsFib (n+2) (a+b)\n",
460
+ "theorem myfib_isfib : ∀ n, IsFib n (myfib n) ∧ IsFib (n+1) (myfib (n+1)) := by\n",
461
+ " intro n; induction n with\n",
462
+ " | zero => exact ⟨IsFib.zero, IsFib.one⟩\n",
463
+ " | succ k ih => exact ⟨ih.2, IsFib.step k _ _ ih.1 ih.2⟩\n",
464
+ "''', keep=True)\n",
465
+ "assert not [m for m in r.get('messages', []) if m['severity'] == 'error']"
466
+ ]
467
+ },
468
+ {
469
+ "cell_type": "markdown",
470
+ "id": "ae047cf3",
471
+ "metadata": {},
472
+ "source": [
473
+ "### B3. Vacuous hypotheses, and a probe that detects them\n",
474
+ "`h0 : n < 5`, `h1 : n > 7` are contradictory; any conclusion follows. Probe: is `exists n < 100, hypotheses` satisfiable? (A scoped *satisfiability witness* search; ProofGate-style negation probes are the dual.)"
475
+ ]
476
+ },
477
+ {
478
+ "cell_type": "code",
479
+ "execution_count": 8,
480
+ "id": "a201ade3",
481
+ "metadata": {
482
+ "execution": {
483
+ "iopub.execute_input": "2026-10-14T09:54:55.423774Z",
484
+ "iopub.status.busy": "2026-10-14T09:54:55.423624Z",
485
+ "iopub.status.idle": "2026-10-14T09:54:55.506541Z",
486
+ "shell.execute_reply": "2026-10-14T09:54:55.505667Z"
487
+ }
488
+ },
489
+ "outputs": [
490
+ {
491
+ "name": "stdout",
492
+ "output_type": "stream",
493
+ "text": [
494
+ "[info] line 3: 'vac_stmt' depends on axioms: [propext, Quot.sound]\n",
495
+ "[info] line 4: false\n",
496
+ "[info] line 5: true\n",
497
+ "python probe agrees: the contradictory hypotheses have no witness in the scope; the consistent pair does.\n"
498
+ ]
499
+ }
500
+ ],
501
+ "source": [
502
+ "r = L.show(r'''\n",
503
+ "theorem vac_stmt (n : ℕ) (h₀ : n < 5) (h₁ : n > 7) : n = 12345 := by omega\n",
504
+ "#print axioms vac_stmt\n",
505
+ "#eval decide (∃ n < (100:ℕ), n < 5 ∧ n > 7)\n",
506
+ "#eval decide (∃ n < (100:ℕ), n < 10 ∧ n > 7)\n",
507
+ "''')\n",
508
+ "assert not [m for m in r.get('messages', []) if m['severity'] == 'error']\n",
509
+ "probe = lambda hyps: any(all(h(n_) for h in hyps) for n_ in range(100))\n",
510
+ "assert probe([lambda n_: n_ < 5, lambda n_: n_ > 7]) is False and probe([lambda n_: n_ < 10, lambda n_: n_ > 7]) is True\n",
511
+ "print('python probe agrees: the contradictory hypotheses have no witness in the scope; the consistent pair does.')"
512
+ ]
513
+ },
514
+ {
515
+ "cell_type": "markdown",
516
+ "id": "94f56031",
517
+ "metadata": {},
518
+ "source": [
519
+ "### B4. The statement-rewrite finding (docs/P2.md section 8c item 18), reproduced against four gates\n",
520
+ "Reference: miniF2F `mathd_algebra_478` (`v = 65`). Candidates are complete files whose DISPLAYED theorem differs from the reference.\n",
521
+ "Gates: (1) **compile-only** (no error, no `sorry`); (2) **swap** (Goedel's `problem_check`: replace the displayed signature by the reference one and re-run the tactics);\n",
522
+ "(3) **strict** (token-equal signature + kernel statement bridge + axiom audit); (4) **defeq** (bridge only)."
523
+ ]
524
+ },
525
+ {
526
+ "cell_type": "code",
527
+ "execution_count": 9,
528
+ "id": "dda78612",
529
+ "metadata": {
530
+ "execution": {
531
+ "iopub.execute_input": "2026-10-14T09:54:55.508277Z",
532
+ "iopub.status.busy": "2026-10-14T09:54:55.508117Z",
533
+ "iopub.status.idle": "2026-10-14T09:55:05.649327Z",
534
+ "shell.execute_reply": "2026-10-14T09:55:05.648446Z"
535
+ }
536
+ },
537
+ "outputs": [
538
+ {
539
+ "name": "stdout",
540
+ "output_type": "stream",
541
+ "text": [
542
+ "theorem mathd_algebra_478 (b h v : ℝ) (h₀ : 0 < b ∧ 0 < h ∧ 0 < v) (h₁ : v = 1 / 3 * (b * h))\n",
543
+ " (h₂ : b = 30) (h₃ : h = 13 / 2) : v = 65\n",
544
+ "\n"
545
+ ]
546
+ },
547
+ {
548
+ "name": "stdout",
549
+ "output_type": "stream",
550
+ "text": [
551
+ "candidate compile swap strict defeq strict-reasons / defeq-reasons\n",
552
+ "C0 honest True True True True [] / []\n",
553
+ "C1 displays v = 66 False True False False ['statement_mismatch'] / ['compile_error']\n",
554
+ "C2 weakened v ≤ 65 True True False False ['statement_mismatch'] / ['bridge_failed']\n",
555
+ "C3 extra false hypothesis True False False False ['statement_mismatch'] / ['bridge_failed']\n",
556
+ "C4 alpha-renamed binders True True False True ['statement_mismatch'] / []\n"
557
+ ]
558
+ },
559
+ {
560
+ "name": "stdout",
561
+ "output_type": "stream",
562
+ "text": [
563
+ "\n",
564
+ "compile-only credits C2 (weaker) and C3 (vacuous) -- each is a different, easier theorem; swap credits C1 and C2 (the displayed statement is not the one proved) and refuses C3 only because its tactics name the extra hypothesis; strict rejects C1-C4; defeq accepts only the alpha-renaming.\n",
565
+ "accepted sets: {'compile': ['C0', 'C2', 'C3', 'C4'], 'swap': ['C0', 'C1', 'C2', 'C4'], 'strict': ['C0'], 'defeq': ['C0', 'C4']}\n",
566
+ "=> compile-only and swap are incomparable (C1 passes only swap, C3 only compile-only); defeq is stricter than both; strict is stricter than defeq: a partial order, not a chain.\n"
567
+ ]
568
+ }
569
+ ],
570
+ "source": [
571
+ "import json\n",
572
+ "from align.verify.lean import ReplPool, ReplLimits, StrictVerifier, StrictConfig, find_toolchain\n",
573
+ "rows = [json.loads(l) for l in open('/apps/development/rlhf/p2_runs/r1_baseline/minif2f.jsonl')]\n",
574
+ "ref = next(r_ for r_ in rows if r_['problem_id'] == 'mathd_algebra_478')['formal_statement']\n",
575
+ "head, tail = ref.split(':= by sorry')\n",
576
+ "sig = head[head.index('theorem'):]\n",
577
+ "print(sig.strip()); print()\n",
578
+ "PROOF = 'by rw [h₁, h₂, h₃]; norm_num'\n",
579
+ "SIGS = {\n",
580
+ " 'C0 honest': sig,\n",
581
+ " 'C1 displays v = 66': sig.replace(': v = 65', ': v = 66'),\n",
582
+ " 'C2 weakened v ≤ 65': sig.replace(': v = 65', ': v ≤ 65'),\n",
583
+ " 'C3 extra false hypothesis': sig.replace('(h₃ : h = 13 / 2)', '(h₃ : h = 13 / 2) (h₄ : v < 0)'),\n",
584
+ " 'C4 alpha-renamed binders': sig.replace('(b h v : ℝ)', '(b₁ h v : ℝ)').replace('0 < b ∧', '0 < b₁ ∧').replace('(b * h)', '(b₁ * h)').replace('(h₂ : b =', '(h₂ : b₁ ='),\n",
585
+ "}\n",
586
+ "CODES = {k: head.replace(sig, s) + ':= ' + (PROOF if k != 'C3 extra false hypothesis' else 'by linarith [h₀.1, h₀.2.2, h₄]') + '\\n' for k, s in SIGS.items()}\n",
587
+ "\n",
588
+ "def gate_compile(code):\n",
589
+ " code = '\\n'.join(l for l in code.split('\\n') if not l.startswith('import ')) # the worker already imported Mathlib\n",
590
+ " r_ = L.run(code)\n",
591
+ " errs = [m for m in r_.get('messages', []) if m['severity'] == 'error']\n",
592
+ " sorry = r_.get('sorries') or any('sorry' in m['data'] for m in r_.get('messages', []))\n",
593
+ " return (not errs) and (not sorry)\n",
594
+ "def gate_swap(code):\n",
595
+ " proof = code.split(':=', 1)[1] # tactics after the displayed signature\n",
596
+ " return gate_compile(head + ':=' + proof) # re-run them against the ORIGINAL statement (Goedel's problem_check)\n",
597
+ "\n",
598
+ "pool = ReplPool.from_toolchain(find_toolchain(), n_workers=1, limits=ReplLimits(nice=15))\n",
599
+ "V_strict = StrictVerifier(pool, StrictConfig(statement_mode='strict'))\n",
600
+ "V_defeq = StrictVerifier(pool, StrictConfig(statement_mode='defeq'))\n",
601
+ "results = {}\n",
602
+ "for k, code in CODES.items():\n",
603
+ " c = gate_compile(code); sw = gate_swap(code)\n",
604
+ " rs = V_strict.verify(ref, code); rd = V_defeq.verify(ref, code)\n",
605
+ " results[k] = (c, sw, rs.accepted, rd.accepted, rs.reasons, rd.reasons)\n",
606
+ "print(f'{\"candidate\":28s} compile swap strict defeq strict-reasons / defeq-reasons')\n",
607
+ "for k, (c, sw, s_, d_, rs_, rd_) in results.items():\n",
608
+ " print(f'{k:28s} {str(c):7s}{str(sw):6s}{str(s_):7s}{str(d_):6s} {list(rs_)} / {list(rd_)}')\n",
609
+ "pool.close()\n",
610
+ "# what the table must show (and is asserted):\n",
611
+ "assert results['C0 honest'][:4] == (True, True, True, True)\n",
612
+ "assert results['C1 displays v = 66'][0] is False and results['C1 displays v = 66'][1] is True # swap harness credits a rewritten statement\n",
613
+ "assert results['C1 displays v = 66'][2] is False and results['C1 displays v = 66'][3] is False\n",
614
+ "assert results['C2 weakened v ≤ 65'][0] is True and results['C2 weakened v ≤ 65'][2] is False and results['C2 weakened v ≤ 65'][3] is False\n",
615
+ "assert results['C3 extra false hypothesis'][0] is True and results['C3 extra false hypothesis'][2] is False\n",
616
+ "assert results['C4 alpha-renamed binders'][2] is False and results['C4 alpha-renamed binders'][3] is True\n",
617
+ "print('\\ncompile-only credits C2 (weaker) and C3 (vacuous) -- each is a different, easier theorem; swap credits C1 and C2 (the displayed statement is not the one proved) and refuses C3 only because its tactics name the extra hypothesis; strict rejects C1-C4; defeq accepts only the alpha-renaming.')\n",
618
+ "\n",
619
+ "# Are the gates a CHAIN (each at least as strict as the previous)? Compare the accepted sets over these five candidates.\n",
620
+ "acc = {g: {k for k, v in results.items() if v[idx]} for g, idx in (('compile', 0), ('swap', 1), ('strict', 2), ('defeq', 3))}\n",
621
+ "print('accepted sets:', {g: sorted(s_.split()[0] for s_ in v) for g, v in acc.items()})\n",
622
+ "assert not acc['compile'] <= acc['swap'] and not acc['swap'] <= acc['compile'] # compile-only vs swap: INCOMPARABLE (C1 only swap; C3 only compile)\n",
623
+ "assert acc['strict'] < acc['defeq'] and acc['defeq'] < acc['compile'] and acc['defeq'] < acc['swap'] # defeq is stricter than both, strict stricter than defeq\n",
624
+ "print('=> compile-only and swap are incomparable (C1 passes only swap, C3 only compile-only); defeq is stricter than both; strict is stricter than defeq: a partial order, not a chain.')\n"
625
+ ]
626
+ },
627
+ {
628
+ "cell_type": "markdown",
629
+ "id": "d02430be",
630
+ "metadata": {},
631
+ "source": [
632
+ "### B5. `autoImplicit`, the silent statement change (from 0302), as a spec-risk\n",
633
+ "See 0302: a typo becomes a fresh universally quantified variable. A one-line check at benchmark-build time: elaborate with `autoImplicit false`."
634
+ ]
635
+ },
636
+ {
637
+ "cell_type": "code",
638
+ "execution_count": 10,
639
+ "id": "aaab2806",
640
+ "metadata": {
641
+ "execution": {
642
+ "iopub.execute_input": "2026-10-14T09:55:05.651602Z",
643
+ "iopub.status.busy": "2026-10-14T09:55:05.651417Z",
644
+ "iopub.status.idle": "2026-10-14T09:55:05.674529Z",
645
+ "shell.execute_reply": "2026-10-14T09:55:05.673620Z"
646
+ }
647
+ },
648
+ "outputs": [
649
+ {
650
+ "name": "stdout",
651
+ "output_type": "stream",
652
+ "text": [
653
+ "[warning] line 1: declaration uses 'sorry'\n",
654
+ "[info] line 2: @typo_stmt : ∀ {xx : ℕ} (x : ℕ), x + 1 > xx\n",
655
+ "[sorries] xx x : ℕ\n",
656
+ "⊢ x + 1 > xx\n",
657
+ "[error] line 2: unknown identifier 'xx'\n",
658
+ "[sorries] x : ℕ\n",
659
+ "⊢ x + 1 > sorryAx ℕ true\n"
660
+ ]
661
+ },
662
+ {
663
+ "data": {
664
+ "text/plain": [
665
+ "{'sorries': [{'proofState': 1,\n",
666
+ " 'pos': {'line': 2, 'column': 46},\n",
667
+ " 'goal': 'x : ℕ\\n⊢ x + 1 > sorryAx ℕ true',\n",
668
+ " 'endPos': {'line': 2, 'column': 51}}],\n",
669
+ " 'messages': [{'severity': 'error',\n",
670
+ " 'pos': {'line': 2, 'column': 37},\n",
671
+ " 'endPos': {'line': 2, 'column': 39},\n",
672
+ " 'data': \"unknown identifier 'xx'\"}],\n",
673
+ " 'env': 16}"
674
+ ]
675
+ },
676
+ "execution_count": 10,
677
+ "metadata": {},
678
+ "output_type": "execute_result"
679
+ }
680
+ ],
681
+ "source": [
682
+ "L.show(\"theorem typo_stmt (x : ℕ) : x + 1 > xx := by sorry\\n#check @typo_stmt\")\n",
683
+ "L.show(\"set_option autoImplicit false in\\ntheorem typo_stmt2 (x : ℕ) : x + 1 > xx := by sorry\")"
684
+ ]
685
+ },
686
+ {
687
+ "cell_type": "markdown",
688
+ "id": "ac95d158",
689
+ "metadata": {},
690
+ "source": [
691
+ "## C. Risk model: how likely is an accepted proof to be a proof of the wrong theorem?\n",
692
+ "Let a generated spec be wrong with probability `u`. A prover finds a proof with probability `a_c` for a correct spec and `a_w` for a wrong spec\n",
693
+ "(wrong = weaker/vacuous specs are easier: `r = a_w / a_c >= 1`). By Bayes\n",
694
+ "`P(wrong | accepted) = u a_w / (u a_w + (1-u) a_c) = u r / (u r + 1 - u)`. Check by Monte Carlo."
695
+ ]
696
+ },
697
+ {
698
+ "cell_type": "code",
699
+ "execution_count": 11,
700
+ "id": "7659f6ec",
701
+ "metadata": {
702
+ "execution": {
703
+ "iopub.execute_input": "2026-10-14T09:55:05.676725Z",
704
+ "iopub.status.busy": "2026-10-14T09:55:05.676540Z",
705
+ "iopub.status.idle": "2026-10-14T09:55:06.400102Z",
706
+ "shell.execute_reply": "2026-10-14T09:55:06.399004Z"
707
+ }
708
+ },
709
+ "outputs": [
710
+ {
711
+ "name": "stdout",
712
+ "output_type": "stream",
713
+ "text": [
714
+ " u r theory MC z\n",
715
+ "0.100 1.0 0.1000 0.0942 1.72\n",
716
+ "0.100 5.0 0.3571 0.3589 0.38\n"
717
+ ]
718
+ },
719
+ {
720
+ "name": "stdout",
721
+ "output_type": "stream",
722
+ "text": [
723
+ "0.300 1.0 0.3000 0.3014 0.28\n"
724
+ ]
725
+ },
726
+ {
727
+ "name": "stdout",
728
+ "output_type": "stream",
729
+ "text": [
730
+ "0.300 5.0 0.6818 0.6830 0.34\n",
731
+ "0.477 5.0 0.8202 0.8198 0.13\n",
732
+ "0.477 20.0 0.9480 0.9480 0.07\n"
733
+ ]
734
+ },
735
+ {
736
+ "name": "stdout",
737
+ "output_type": "stream",
738
+ "text": [
739
+ "0.800 3.0 0.9231 0.9221 0.52\n",
740
+ "worst |z| = 1.72\n",
741
+ "\n",
742
+ "anchors (from the papers; see the note): VERINA best model: spec sound-and-complete 52.3% => u ~ 0.477; CLEVER: spec generation + equivalence proof solved <= 1/161 by few-shot LLMs; miniF2F-Lean revisited: formal/informal mismatch in > 1/2 of 488 problems.\n"
743
+ ]
744
+ }
745
+ ],
746
+ "source": [
747
+ "def p_wrong_given_accepted(u, r): return u * r / (u * r + 1 - u)\n",
748
+ "rng = random.Random(3); worst = 0.0\n",
749
+ "print(f'{\"u\":>5s} {\"r\":>5s} {\"theory\":>8s} {\"MC\":>8s} {\"z\":>5s}')\n",
750
+ "for u, r_ in [(0.1, 1), (0.1, 5), (0.3, 1), (0.3, 5), (0.477, 5), (0.477, 20), (0.8, 3)]:\n",
751
+ " a_c = 0.04; a_w = min(1.0, a_c * r_)\n",
752
+ " acc = wrong = 0\n",
753
+ " for _ in range(200000):\n",
754
+ " w_ = rng.random() < u\n",
755
+ " if rng.random() < (a_w if w_ else a_c):\n",
756
+ " acc += 1; wrong += w_\n",
757
+ " mc = wrong / acc; th = p_wrong_given_accepted(u, a_w / a_c)\n",
758
+ " se = (th * (1 - th) / acc) ** 0.5; z = abs(mc - th) / se; worst = max(worst, z)\n",
759
+ " print(f'{u:5.3f} {a_w/a_c:5.1f} {th:8.4f} {mc:8.4f} {z:5.2f}')\n",
760
+ "assert worst < 4\n",
761
+ "print('worst |z| =', round(worst, 2))\n",
762
+ "print('\\nanchors (from the papers; see the note): VERINA best model: spec sound-and-complete 52.3% => u ~ 0.477; '\n",
763
+ " 'CLEVER: spec generation + equivalence proof solved <= 1/161 by few-shot LLMs; miniF2F-Lean revisited: formal/informal mismatch in > 1/2 of 488 problems.')\n",
764
+ "L.close()"
765
+ ]
766
+ }
767
+ ],
768
+ "metadata": {
769
+ "kernelspec": {
770
+ "display_name": "Python 3",
771
+ "language": "python",
772
+ "name": "python3"
773
+ },
774
+ "language_info": {
775
+ "codemirror_mode": {
776
+ "name": "ipython",
777
+ "version": 3
778
+ },
779
+ "file_extension": ".py",
780
+ "mimetype": "text/x-python",
781
+ "name": "python",
782
+ "nbconvert_exporter": "python",
783
+ "pygments_lexer": "ipython3",
784
+ "version": "3.12.11"
785
+ }
786
+ },
787
+ "nbformat": 4,
788
+ "nbformat_minor": 5
789
+ }
code/notebooks/0306-benchmarks-landscape.ipynb ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "3a638e03",
6
+ "metadata": {},
7
+ "source": [
8
+ "# 0306 - Verified-code benchmarks: what each measures, and how to read the numbers\n",
9
+ "\n",
10
+ "Scratch-pad for note `0306 - Verified Code Benchmarks.pdf`. The benchmarks (VERINA, CLEVER, FVAPPS, DafnyBench, miniCodeProps) differ in\n",
11
+ "*task*, *who writes the spec*, *how the spec is validated* and *what the checker trusts*; that is the table in the note. This notebook\n",
12
+ "verifies the **arithmetic** the note depends on (naive first, efficient second, assert agreement):\n",
13
+ "\n",
14
+ "1. **pass@k** (Chen et al. 2021, eq. (1), Fig. 3): NAIVE exact combinatorics; EFFICIENT stable product. Unbiasedness of the estimator\n",
15
+ " (exact sum over the binomial), and the bias of the plug-in `1-(1-p_hat)^k`.\n",
16
+ "2. **Small benchmarks**: Wilson vs Wald interval, coverage by Monte Carlo, applied to the headline numbers of each paper.\n",
17
+ "3. **Composite tasks**: Frechet bounds for `P(code and spec and proof)`; checked on random joint distributions.\n",
18
+ "4. **Consistency checks** of numbers quoted from the papers (sums, ratios) and one *derived* baseline (DafnyBench's no-hint rate).\n",
19
+ "\n",
20
+ "No GPU, no Lean in this notebook (pure arithmetic)."
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "code",
25
+ "execution_count": 1,
26
+ "id": "542867e2",
27
+ "metadata": {
28
+ "execution": {
29
+ "iopub.execute_input": "2026-10-14T09:53:51.893124Z",
30
+ "iopub.status.busy": "2026-10-14T09:53:51.892857Z",
31
+ "iopub.status.idle": "2026-10-14T09:53:52.032196Z",
32
+ "shell.execute_reply": "2026-10-14T09:53:52.030551Z"
33
+ }
34
+ },
35
+ "outputs": [],
36
+ "source": [
37
+ "import os\n",
38
+ "os.environ[\"CUDA_VISIBLE_DEVICES\"] = \"\"\n",
39
+ "import math, random, itertools\n",
40
+ "from fractions import Fraction\n",
41
+ "import numpy as np\n",
42
+ "random.seed(306); np.random.seed(306)"
43
+ ]
44
+ },
45
+ {
46
+ "cell_type": "markdown",
47
+ "id": "3ae94319",
48
+ "metadata": {},
49
+ "source": [
50
+ "## 1. pass@k\n",
51
+ "Given `n` samples of which `c` are correct, a random `k`-subset contains no correct sample with probability `C(n-c,k)/C(n,k)`,\n",
52
+ "so `pass@k = 1 - C(n-c,k)/C(n,k)`. Stable form (cancel the factorials):\n",
53
+ "`C(n-c,k)/C(n,k) = prod_{i=n-c+1}^{n} (1 - k/i)` (for `n-c >= k`; else the ratio is 0)."
54
+ ]
55
+ },
56
+ {
57
+ "cell_type": "code",
58
+ "execution_count": 2,
59
+ "id": "806c53e0",
60
+ "metadata": {
61
+ "execution": {
62
+ "iopub.execute_input": "2026-10-14T09:53:52.034678Z",
63
+ "iopub.status.busy": "2026-10-14T09:53:52.034346Z",
64
+ "iopub.status.idle": "2026-10-14T09:53:52.050351Z",
65
+ "shell.execute_reply": "2026-10-14T09:53:52.049783Z"
66
+ }
67
+ },
68
+ "outputs": [
69
+ {
70
+ "name": "stdout",
71
+ "output_type": "stream",
72
+ "text": [
73
+ "stable product vs exact rational on 303 (n,c,k) triples: max abs error 6.11e-16\n",
74
+ "naive float64 factorial ratio at n=200 (170! < 1.8e308 < 171!): nan (exact: 0.660225623763214 )\n",
75
+ "naive float is fine for small n: True\n"
76
+ ]
77
+ }
78
+ ],
79
+ "source": [
80
+ "def pass_at_k_exact(n, c, k): # NAIVE: exact rational arithmetic from the definition\n",
81
+ " if n - c < k: return Fraction(1)\n",
82
+ " return 1 - Fraction(math.comb(n - c, k), math.comb(n, k))\n",
83
+ "def _ffact(m): # factorial accumulated in float64 (what a vectorised implementation does)\n",
84
+ " return float(np.prod(np.arange(1, m + 1, dtype=np.float64)))\n",
85
+ "def pass_at_k_naive_float(n, c, k): # NAIVE in floats: the textbook factorial ratio C(n-c,k)/C(n,k)\n",
86
+ " if n - c < k: return 1.0\n",
87
+ " with np.errstate(all='ignore'):\n",
88
+ " cnk = lambda a, b: _ffact(a) / (_ffact(b) * _ffact(a - b))\n",
89
+ " return 1.0 - cnk(n - c, k) / cnk(n, k)\n",
90
+ "def pass_at_k_stable(n, c, k): # EFFICIENT: Chen et al., Fig. 3\n",
91
+ " if n - c < k: return 1.0\n",
92
+ " return 1.0 - np.prod(1.0 - k / np.arange(n - c + 1, n + 1))\n",
93
+ "\n",
94
+ "worst = 0.0; count = 0\n",
95
+ "for n in (5, 10, 20, 50, 100, 170, 200, 500):\n",
96
+ " for c in sorted({0, 1, 2, n // 10, n // 3, n // 2, n - 1, n}):\n",
97
+ " for k in (1, 2, 5, 10, 50, 100):\n",
98
+ " if k > n: continue\n",
99
+ " ex = float(pass_at_k_exact(n, c, k)); st = pass_at_k_stable(n, c, k)\n",
100
+ " worst = max(worst, abs(ex - st)); count += 1\n",
101
+ "assert worst < 1e-12\n",
102
+ "print(f'stable product vs exact rational on {count} (n,c,k) triples: max abs error {worst:.2e}')\n",
103
+ "v200 = pass_at_k_naive_float(200, 20, 10)\n",
104
+ "print('naive float64 factorial ratio at n=200 (170! < 1.8e308 < 171!):', v200, ' (exact:', float(pass_at_k_exact(200, 20, 10)), ')')\n",
105
+ "assert not math.isfinite(v200)\n",
106
+ "print('naive float is fine for small n:', abs(pass_at_k_naive_float(20, 5, 3) - float(pass_at_k_exact(20, 5, 3))) < 1e-12)"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "markdown",
111
+ "id": "46406668",
112
+ "metadata": {},
113
+ "source": [
114
+ "### Unbiasedness (exact) and the bias of the plug-in estimator\n",
115
+ "For a task with true single-sample success probability `p`, `c ~ Binomial(n,p)`. Claim: `E[pass_at_k(n,c,k)] = 1-(1-p)^k`.\n",
116
+ "Proof sketch: the random `k`-subset of the `n` samples is itself `k` i.i.d. draws, so it contains no success with probability `(1-p)^k`.\n",
117
+ "Check by exact summation. The plug-in `1-(1-c/n)^k` is biased (Chen et al., Appendix A)."
118
+ ]
119
+ },
120
+ {
121
+ "cell_type": "code",
122
+ "execution_count": 3,
123
+ "id": "a46d5fa0",
124
+ "metadata": {
125
+ "execution": {
126
+ "iopub.execute_input": "2026-10-14T09:53:52.053085Z",
127
+ "iopub.status.busy": "2026-10-14T09:53:52.052843Z",
128
+ "iopub.status.idle": "2026-10-14T09:53:52.063079Z",
129
+ "shell.execute_reply": "2026-10-14T09:53:52.061837Z"
130
+ }
131
+ },
132
+ "outputs": [
133
+ {
134
+ "name": "stdout",
135
+ "output_type": "stream",
136
+ "text": [
137
+ " n p k truth 1-(1-p)^k E[unbiased] |err| E[plug-in] bias\n",
138
+ " 10 0.10 5 0.4095100000 0.4095100000 2.2e-16 0.34851642 -0.06099\n",
139
+ " 10 0.30 5 0.8319300000 0.8319300000 6.7e-16 0.75974023 -0.07219\n",
140
+ " 20 0.05 10 0.4012630608 0.4012630608 6.1e-16 0.33649458 -0.06477\n",
141
+ " 20 0.50 3 0.8750000000 0.8750000000 0.0e+00 0.85625000 -0.01875\n",
142
+ " 50 0.02 20 0.3323920282 0.3323920282 6.7e-16 0.28490846 -0.04748\n",
143
+ "100 0.01 50 0.3949939329 0.3949939329 6.7e-16 0.32746853 -0.06753\n",
144
+ "unbiasedness: max |E[estimator] - truth| = 6.7e-16; the plug-in estimator is biased (negative here: it under-estimates pass@k).\n"
145
+ ]
146
+ }
147
+ ],
148
+ "source": [
149
+ "def expect_estimators(n, p, k):\n",
150
+ " e_unb = e_plug = 0.0\n",
151
+ " for c in range(n + 1):\n",
152
+ " w = math.comb(n, c) * p ** c * (1 - p) ** (n - c)\n",
153
+ " e_unb += w * pass_at_k_stable(n, c, k)\n",
154
+ " e_plug += w * (1 - (1 - c / n) ** k)\n",
155
+ " return e_unb, e_plug\n",
156
+ "print(f'{\"n\":>3s} {\"p\":>5s} {\"k\":>3s} {\"truth 1-(1-p)^k\":>16s} {\"E[unbiased]\":>12s} {\"|err|\":>9s} {\"E[plug-in]\":>11s} {\"bias\":>9s}')\n",
157
+ "max_err = 0.0\n",
158
+ "for n, p, k in [(10, 0.1, 5), (10, 0.3, 5), (20, 0.05, 10), (20, 0.5, 3), (50, 0.02, 20), (100, 0.01, 50)]:\n",
159
+ " truth = 1 - (1 - p) ** k\n",
160
+ " u, pl = expect_estimators(n, p, k)\n",
161
+ " max_err = max(max_err, abs(u - truth))\n",
162
+ " print(f'{n:3d} {p:5.2f} {k:3d} {truth:16.10f} {u:12.10f} {abs(u-truth):9.1e} {pl:11.8f} {pl-truth:9.5f}')\n",
163
+ "assert max_err < 1e-12\n",
164
+ "print(f'unbiasedness: max |E[estimator] - truth| = {max_err:.1e}; the plug-in estimator is biased (negative here: it under-estimates pass@k).')"
165
+ ]
166
+ },
167
+ {
168
+ "cell_type": "markdown",
169
+ "id": "3aa2cde7",
170
+ "metadata": {},
171
+ "source": [
172
+ "## 2. Small benchmarks need intervals\n",
173
+ "VERINA 189 tasks, CLEVER 161, miniCodeProps 201, FVAPPS baseline 100 samples / 406 theorems, DafnyBench 782. Treat each task as a Bernoulli trial\n",
174
+ "(NAMED APPROXIMATION: tasks exchangeable and independent; FVAPPS theorems within a sample are correlated, so its interval is too narrow).\n",
175
+ "Wald: `p_hat +- 1.96 sqrt(p_hat(1-p_hat)/n)`. Wilson: solve `|p_hat - p| = z sqrt(p(1-p)/n)` for `p`:\n",
176
+ "`centre = (p_hat + z^2/2n)/(1 + z^2/n)`, `half = z sqrt(p_hat(1-p_hat)/n + z^2/4n^2)/(1 + z^2/n)`."
177
+ ]
178
+ },
179
+ {
180
+ "cell_type": "code",
181
+ "execution_count": 4,
182
+ "id": "dfbe9e5a",
183
+ "metadata": {
184
+ "execution": {
185
+ "iopub.execute_input": "2026-10-14T09:53:52.065104Z",
186
+ "iopub.status.busy": "2026-10-14T09:53:52.064886Z",
187
+ "iopub.status.idle": "2026-10-14T09:53:52.480161Z",
188
+ "shell.execute_reply": "2026-10-14T09:53:52.479404Z"
189
+ }
190
+ },
191
+ "outputs": [
192
+ {
193
+ "name": "stdout",
194
+ "output_type": "stream",
195
+ "text": [
196
+ "Wilson endpoints solve (p_hat - p)^2 = z^2 p(1-p)/n exactly (residual < 1e-12) for 5 test cases\n",
197
+ " n p Wald cover Wilson cover\n",
198
+ " 161 0.005 0.5518 0.9539\n",
199
+ " 161 0.030 0.8595 0.9675\n"
200
+ ]
201
+ },
202
+ {
203
+ "name": "stdout",
204
+ "output_type": "stream",
205
+ "text": [
206
+ " 189 0.049 0.9451 0.9601\n"
207
+ ]
208
+ },
209
+ {
210
+ "name": "stdout",
211
+ "output_type": "stream",
212
+ "text": [
213
+ " 201 0.350 0.9536 0.9462\n",
214
+ " 406 0.300 0.9497 0.9498\n"
215
+ ]
216
+ },
217
+ {
218
+ "name": "stdout",
219
+ "output_type": "stream",
220
+ "text": [
221
+ " 782 0.680 0.9496 0.9498\n",
222
+ "\n",
223
+ "benchmark setting rate Wilson 95% interval\n",
224
+ "CLEVER best few-shot LLM, end-to-end 0.6% [ 0.1%, 3.4%]\n",
225
+ "VERINA best model proof success, pass@1 (4.9% of 189 ~ 9.3 tasks; k rounded) 4.8% [ 2.5%, 8.8%]\n",
226
+ "VERINA best model spec sound+complete (52.3% of 189 ~ 98.9; k rounded) 52.4% [45.3%, 59.4%]\n",
227
+ "miniCodeProps GPT-4o pass@32 + refinement, overall 37.3% [30.9%, 44.2%]\n",
228
+ "miniCodeProps GPT-4o pass@32 + refinement, medium+hard 7.0% [ 3.6%, 13.1%]\n",
229
+ "FVAPPS Claude 3.5 Sonnet, theorems of 100 sampled problems 29.8% [25.6%, 34.4%]\n",
230
+ "FVAPPS Gemini 1.5 Pro 18.2% [14.8%, 22.3%]\n",
231
+ "DafnyBench best model, Table 3: 67.8% of 782 = 530 67.8% [64.4%, 71.0%]\n",
232
+ "DafnyBench no-LLM baseline, Table 3: 26.9% of 782 ~ 210 26.9% [23.9%, 30.1%]\n"
233
+ ]
234
+ }
235
+ ],
236
+ "source": [
237
+ "Z = 1.959963984540054\n",
238
+ "def wald(k, n):\n",
239
+ " p = k / n; h = Z * math.sqrt(p * (1 - p) / n); return max(0, p - h), min(1, p + h)\n",
240
+ "def wilson(k, n):\n",
241
+ " p = k / n; d = 1 + Z * Z / n\n",
242
+ " c = (p + Z * Z / (2 * n)) / d; h = Z * math.sqrt(p * (1 - p) / n + Z * Z / (4 * n * n)) / d\n",
243
+ " return c - h, c + h\n",
244
+ "\n",
245
+ "# derivation check: the Wilson endpoints are exactly the roots of (p_hat - p)^2 = z^2 p (1-p)/n\n",
246
+ "for k, n in [(1, 161), (9, 189), (70, 201), (121, 406), (530, 782)]:\n",
247
+ " lo, hi = wilson(k, n)\n",
248
+ " for p_ in (lo, hi):\n",
249
+ " assert abs((k / n - p_) ** 2 - Z * Z * p_ * (1 - p_) / n) < 1e-12\n",
250
+ "print('Wilson endpoints solve (p_hat - p)^2 = z^2 p(1-p)/n exactly (residual < 1e-12) for 5 test cases')\n",
251
+ "\n",
252
+ "rng = np.random.default_rng(1)\n",
253
+ "def coverage(n, p, reps=40000):\n",
254
+ " ks = rng.binomial(n, p, size=reps)\n",
255
+ " cov_w = cov_l = 0\n",
256
+ " for k in ks:\n",
257
+ " lo, hi = wald(int(k), n); cov_w += (lo <= p <= hi)\n",
258
+ " lo, hi = wilson(int(k), n); cov_l += (lo <= p <= hi)\n",
259
+ " return cov_w / reps, cov_l / reps\n",
260
+ "print(f'{\"n\":>4s} {\"p\":>6s} {\"Wald cover\":>11s} {\"Wilson cover\":>13s}')\n",
261
+ "cov_table = {}\n",
262
+ "for n, p in [(161, 0.005), (161, 0.03), (189, 0.049), (201, 0.35), (406, 0.30), (782, 0.68)]:\n",
263
+ " cw, cl = coverage(n, p); cov_table[(n, p)] = (cw, cl)\n",
264
+ " print(f'{n:4d} {p:6.3f} {cw:11.4f} {cl:13.4f}')\n",
265
+ "assert cov_table[(161, 0.005)][0] < 0.6 and cov_table[(161, 0.005)][1] > 0.90 # Wald collapses at tiny p; Wilson does not\n",
266
+ "assert all(cl > 0.92 for (_, cl) in cov_table.values())\n",
267
+ "\n",
268
+ "print()\n",
269
+ "HEADLINES = [ # (benchmark, what, successes k, n) -- k reconstructed from the reported rate and n where the paper gives a rate\n",
270
+ " ('CLEVER', 'best few-shot LLM, end-to-end', 1, 161),\n",
271
+ " ('VERINA', 'best model proof success, pass@1 (4.9% of 189 ~ 9.3 tasks; k rounded)', 9, 189),\n",
272
+ " ('VERINA', 'best model spec sound+complete (52.3% of 189 ~ 98.9; k rounded)', 99, 189),\n",
273
+ " ('miniCodeProps', 'GPT-4o pass@32 + refinement, overall', 75, 201),\n",
274
+ " ('miniCodeProps', 'GPT-4o pass@32 + refinement, medium+hard', 8, 115),\n",
275
+ " ('FVAPPS', 'Claude 3.5 Sonnet, theorems of 100 sampled problems', 121, 406),\n",
276
+ " ('FVAPPS', 'Gemini 1.5 Pro', 74, 406),\n",
277
+ " ('DafnyBench', 'best model, Table 3: 67.8% of 782 = 530', 530, 782),\n",
278
+ " ('DafnyBench', 'no-LLM baseline, Table 3: 26.9% of 782 ~ 210', 210, 782),\n",
279
+ "]\n",
280
+ "print(f'{\"benchmark\":14s} {\"setting\":70s} {\"rate\":>6s} Wilson 95% interval')\n",
281
+ "for b, what, k, n in HEADLINES:\n",
282
+ " lo, hi = wilson(k, n)\n",
283
+ " print(f'{b:14s} {what:70s} {100*k/n:5.1f}% [{100*lo:4.1f}%, {100*hi:4.1f}%]')"
284
+ ]
285
+ },
286
+ {
287
+ "cell_type": "markdown",
288
+ "id": "bc025e01",
289
+ "metadata": {},
290
+ "source": [
291
+ "## 3. Composite tasks: Frechet bounds\n",
292
+ "End-to-end success `A and B and C` (code, spec, proof; or CLEVER's `QED1 and QED2`) is bounded by its stages whatever the dependence:\n",
293
+ "`max(0, sum P(.) - (m-1)) <= P(all) <= min P(.)`. Check on random joint distributions of `m` binary stage outcomes."
294
+ ]
295
+ },
296
+ {
297
+ "cell_type": "code",
298
+ "execution_count": 5,
299
+ "id": "8b0beb96",
300
+ "metadata": {
301
+ "execution": {
302
+ "iopub.execute_input": "2026-10-14T09:53:52.481823Z",
303
+ "iopub.status.busy": "2026-10-14T09:53:52.481664Z",
304
+ "iopub.status.idle": "2026-10-14T09:53:53.003946Z",
305
+ "shell.execute_reply": "2026-10-14T09:53:53.003015Z"
306
+ }
307
+ },
308
+ "outputs": [
309
+ {
310
+ "name": "stdout",
311
+ "output_type": "stream",
312
+ "text": [
313
+ "20000 random joint distributions (m = 2 or 3 stages): Frechet bound violations = 0\n",
314
+ "VERINA stage marginals (code 72.6%, spec 52.3%, proof 4.9%) => P(all three) in [0.000, 0.049]\n",
315
+ "comonotone coupling attains the upper bound: 0.0491 vs min = 0.049\n",
316
+ "independent stages would give the product 0.01861 (inside the bounds; real tasks are positively dependent)\n",
317
+ "lower Frechet bound attained by an explicit coupling on 6 marginals (exact rational arithmetic, grid of 1/20; e.g. (0.7,0.6,0.9) -> 0.2, (0.5,0.5,0.5) -> 0)\n"
318
+ ]
319
+ }
320
+ ],
321
+ "source": [
322
+ "def frechet(ps):\n",
323
+ " return max(0.0, sum(ps) - (len(ps) - 1)), min(ps)\n",
324
+ "viol = 0; tight_lo = tight_hi = 0\n",
325
+ "for trial in range(20000):\n",
326
+ " m = random.choice([2, 3])\n",
327
+ " w = np.random.dirichlet(np.ones(2 ** m) * random.choice([0.2, 1.0, 5.0]))\n",
328
+ " outcomes = list(itertools.product([0, 1], repeat=m))\n",
329
+ " ps = [sum(w[i] for i, o in enumerate(outcomes) if o[j]) for j in range(m)]\n",
330
+ " joint = sum(w[i] for i, o in enumerate(outcomes) if all(o))\n",
331
+ " lo, hi = frechet(ps)\n",
332
+ " viol += not (lo - 1e-12 <= joint <= hi + 1e-12)\n",
333
+ "assert viol == 0\n",
334
+ "print('20000 random joint distributions (m = 2 or 3 stages): Frechet bound violations =', viol)\n",
335
+ "# tightness: comonotone coupling attains the upper bound, counter-monotone (m=2) the lower\n",
336
+ "ps = (0.726, 0.523, 0.049)\n",
337
+ "lo_, hi_ = frechet(ps)\n",
338
+ "print(f'VERINA stage marginals (code 72.6%, spec 52.3%, proof 4.9%) => P(all three) in [{lo_:.3f}, {hi_:.3f}]')\n",
339
+ "assert frechet(ps) == (0.0, 0.049)\n",
340
+ "# attained: draw U~U(0,1); stage j succeeds iff U < p_j => joint = min p\n",
341
+ "U = np.random.rand(2_000_000); joint_co = np.mean((U < ps[0]) & (U < ps[1]) & (U < ps[2]))\n",
342
+ "print(f'comonotone coupling attains the upper bound: {joint_co:.4f} vs min = {min(ps)}')\n",
343
+ "assert abs(joint_co - min(ps)) < 2e-3\n",
344
+ "print('independent stages would give the product', round(math.prod(ps), 5), '(inside the bounds; real tasks are positively dependent)')\n",
345
+ "\n",
346
+ "# the LOWER bound is attained too: put the complements A_j^c on consecutive intervals of the circle [0,1) (disjoint while sum(1-p) <= 1, covering otherwise)\n",
347
+ "from fractions import Fraction as Fr\n",
348
+ "def lower_attained(ps, cells=20):\n",
349
+ " ps = [Fr(p).limit_denominator(cells) for p in ps]\n",
350
+ " comp = [int((1 - p) * cells) for p in ps] # |A_j^c| in cells of the grid\n",
351
+ " pos = 0; ok = [True] * cells\n",
352
+ " for c in comp:\n",
353
+ " for t in range(c): ok[(pos + t) % cells] = False # cells where stage j FAILS\n",
354
+ " pos += c\n",
355
+ " joint = Fr(sum(ok), cells)\n",
356
+ " return joint, max(Fr(0), sum(ps) - (len(ps) - 1))\n",
357
+ "for ps_ in [(0.7, 0.6, 0.9), (0.5, 0.5, 0.5), (0.95, 0.9), (0.3, 0.4), (0.8, 0.8, 0.8, 0.8), (0.75, 0.5, 0.05)]:\n",
358
+ " j_, b_ = lower_attained(ps_)\n",
359
+ " assert j_ == b_, (ps_, j_, b_)\n",
360
+ "print('lower Frechet bound attained by an explicit coupling on 6 marginals (exact rational arithmetic, grid of 1/20; e.g. (0.7,0.6,0.9) -> 0.2, (0.5,0.5,0.5) -> 0)')\n"
361
+ ]
362
+ },
363
+ {
364
+ "cell_type": "markdown",
365
+ "id": "b98fb644",
366
+ "metadata": {},
367
+ "source": [
368
+ "## 4. Consistency checks of quoted numbers (and one derived baseline)"
369
+ ]
370
+ },
371
+ {
372
+ "cell_type": "code",
373
+ "execution_count": 6,
374
+ "id": "291ff898",
375
+ "metadata": {
376
+ "execution": {
377
+ "iopub.execute_input": "2026-10-14T09:53:53.005521Z",
378
+ "iopub.status.busy": "2026-10-14T09:53:53.005355Z",
379
+ "iopub.status.idle": "2026-10-14T09:53:53.013532Z",
380
+ "shell.execute_reply": "2026-10-14T09:53:53.012678Z"
381
+ }
382
+ },
383
+ "outputs": [
384
+ {
385
+ "name": "stdout",
386
+ "output_type": "stream",
387
+ "text": [
388
+ "OK DafnyBench sources sum to 782 (556 GitHub + 62 Clover + 164 dafny-synthesis)\n",
389
+ "OK DafnyBench section 3.1 no-hint count vs Table 3 \"No LLM\" baseline (113+23+72 = 208 of 782 = 26.6%; Table 3 states the no-op baseline itself: 26.9% = 210 programs, 2 more than the section-3.1 count)\n",
390
+ "OK DafnyBench Table 3 +- for GPT-3.5 Turbo (binomial s.e. 1.78 vs reported 1.8)\n",
391
+ "OK DafnyBench Table 3 +- for GPT-4 Turbo (binomial s.e. 1.75 vs reported 1.8)\n",
392
+ "OK DafnyBench Table 3 +- for Claude 3 Opus (binomial s.e. 1.67 vs reported 1.7)\n",
393
+ "OK DafnyBench Table 3 +- for CodeLlama-7b (binomial s.e. 1.61 vs reported 1.6)\n",
394
+ "OK miniCodeProps GPT-4o medley (65/86 = 75.58% vs reported 75.6%)\n",
395
+ "OK miniCodeProps GPT-4o med+hard (5/115 = 4.35% vs reported 4.34%)\n",
396
+ "OK miniCodeProps GPT-4o overall (70/201 = 34.83% vs reported 34.8%)\n",
397
+ "OK miniCodeProps refine medley (67/86 = 77.91% vs reported 77.9%)\n",
398
+ "OK miniCodeProps refine med+hard (8/115 = 6.96% vs reported 6.96%)\n",
399
+ "OK miniCodeProps refine overall (75/201 = 37.31% vs reported 37.3%)\n",
400
+ "OK miniCodeProps ntp medley (62/86 = 72.09% vs reported 72.1%)\n",
401
+ "OK miniCodeProps ntp med+hard (10/115 = 8.70% vs reported 8.69%)\n",
402
+ "OK miniCodeProps ntp overall (72/201 = 35.82% vs reported 35.8%)\n",
403
+ "OK miniCodeProps 86 + 115 = 201 \n",
404
+ "OK miniCodeProps overall = sum of splits (GPT-4o) \n",
405
+ "OK FVAPPS Sonnet 121/406 ~ 30% (29.80%)\n",
406
+ "FLAG FVAPPS Gemini 74/406 vs paper text \"18.5%\" (18.23%; abstract says 18%) -> small internal inconsistency in the paper\n",
407
+ "OK FVAPPS tiers nested: 1083 <= 2089 <= 4715 (23.0% pass all five stages; 44.3% guarded)\n",
408
+ "OK CLEVER 161 of 164 HumanEval \n",
409
+ "OK VERINA ground-truth proofs 46 of 189 (24.3% of tasks have a reference proof)\n",
410
+ "\n",
411
+ "22 checks, 1 flagged (the FVAPPS 18.5% vs 74/406 = 18.2% discrepancy is the only one expected)\n"
412
+ ]
413
+ }
414
+ ],
415
+ "source": [
416
+ "checks = []\n",
417
+ "def chk(name, ok, detail=''):\n",
418
+ " checks.append((name, ok, detail)); print(f'{\"OK \" if ok else \"FLAG\"} {name} {detail}')\n",
419
+ "# DafnyBench (arXiv 2406.08467 section 3.1): sources of the 782 ground-truth programs\n",
420
+ "chk('DafnyBench sources sum to 782', 556 + 62 + 164 == 782, '(556 GitHub + 62 Clover + 164 dafny-synthesis)')\n",
421
+ "no_hint = 113 + 23 + 72\n",
422
+ "chk('DafnyBench section 3.1 no-hint count vs Table 3 \"No LLM\" baseline', abs(100 * no_hint / 782 - 26.9) < 0.5,\n",
423
+ " f'(113+23+72 = {no_hint} of 782 = {100*no_hint/782:.1f}%; Table 3 states the no-op baseline itself: 26.9% = {round(0.269*782)} programs, {round(0.269*782)-no_hint} more than the section-3.1 count)')\n",
424
+ "# Table 3 reports '+-' = one binomial standard error at N = 782\n",
425
+ "for name, p_, se_rep in [('GPT-3.5 Turbo', 0.440, 1.8), ('GPT-4 Turbo', 0.598, 1.8), ('Claude 3 Opus', 0.678, 1.7), ('CodeLlama-7b', 0.280, 1.6)]:\n",
426
+ " chk(f'DafnyBench Table 3 +- for {name}', abs(100 * math.sqrt(p_ * (1 - p_) / 782) - se_rep) < 0.06, f'(binomial s.e. {100*math.sqrt(p_*(1-p_)/782):.2f} vs reported {se_rep})')\n",
427
+ "# miniCodeProps (arXiv 2406.11915, Table 3)\n",
428
+ "for name, k, n, rep in [('GPT-4o medley', 65, 86, 75.6), ('GPT-4o med+hard', 5, 115, 4.34), ('GPT-4o overall', 70, 201, 34.8),\n",
429
+ " ('refine medley', 67, 86, 77.9), ('refine med+hard', 8, 115, 6.96), ('refine overall', 75, 201, 37.3),\n",
430
+ " ('ntp medley', 62, 86, 72.1), ('ntp med+hard', 10, 115, 8.69), ('ntp overall', 72, 201, 35.8)]:\n",
431
+ " chk(f'miniCodeProps {name}', abs(100 * k / n - rep) < 0.06, f'({k}/{n} = {100*k/n:.2f}% vs reported {rep}%)')\n",
432
+ "chk('miniCodeProps 86 + 115 = 201', 86 + 115 == 201)\n",
433
+ "chk('miniCodeProps overall = sum of splits (GPT-4o)', 65 + 5 == 70 and 67 + 8 == 75 and 62 + 10 == 72)\n",
434
+ "# FVAPPS (arXiv 2502.05714 section III)\n",
435
+ "chk('FVAPPS Sonnet 121/406 ~ 30%', abs(100 * 121 / 406 - 30) < 0.5, f'({100*121/406:.2f}%)')\n",
436
+ "chk('FVAPPS Gemini 74/406 vs paper text \"18.5%\"', abs(100 * 74 / 406 - 18.5) < 0.1, f'({100*74/406:.2f}%; abstract says 18%) -> small internal inconsistency in the paper')\n",
437
+ "chk('FVAPPS tiers nested: 1083 <= 2089 <= 4715', 1083 <= 2089 <= 4715, f'({100*1083/4715:.1f}% pass all five stages; {100*2089/4715:.1f}% guarded)')\n",
438
+ "# CLEVER (arXiv 2505.13938)\n",
439
+ "chk('CLEVER 161 of 164 HumanEval', 161 <= 164)\n",
440
+ "# VERINA (arXiv 2505.23135): test-suite medians and the relation count\n",
441
+ "chk('VERINA ground-truth proofs 46 of 189', abs(100 * 46 / 189 - 24.3) < 0.1, f'({100*46/189:.1f}% of tasks have a reference proof)')\n",
442
+ "n_fail = sum(not ok for _, ok, _ in checks)\n",
443
+ "print(f'\\n{len(checks)} checks, {n_fail} flagged (the FVAPPS 18.5% vs 74/406 = 18.2% discrepancy is the only one expected)')\n",
444
+ "assert n_fail == 1"
445
+ ]
446
+ },
447
+ {
448
+ "cell_type": "markdown",
449
+ "id": "8e17d222",
450
+ "metadata": {},
451
+ "source": [
452
+ "## What the table in the note is built from\n",
453
+ "The benchmark comparison table (task / who writes the spec / spec validation / checker / headline) is hand-assembled from each paper's own text in the\n",
454
+ "note; the numbers above are the ones that can be recomputed. Reading rule: **a headline number needs (a) the task definition, (b) a trivial baseline, (c) an interval.**"
455
+ ]
456
+ }
457
+ ],
458
+ "metadata": {
459
+ "kernelspec": {
460
+ "display_name": "Python 3",
461
+ "language": "python",
462
+ "name": "python3"
463
+ },
464
+ "language_info": {
465
+ "codemirror_mode": {
466
+ "name": "ipython",
467
+ "version": 3
468
+ },
469
+ "file_extension": ".py",
470
+ "mimetype": "text/x-python",
471
+ "name": "python",
472
+ "nbconvert_exporter": "python",
473
+ "pygments_lexer": "ipython3",
474
+ "version": "3.12.11"
475
+ }
476
+ },
477
+ "nbformat": 4,
478
+ "nbformat_minor": 5
479
+ }
code/notebooks/0307-aeneas-semantics.ipynb ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 0307 - Aeneas semantics: Rust (safe subset) -> LLBC -> pure Lean, the Result monad, and what a proof of `not P` certifies\n",
8
+ "Companion to `notes/0307 - Aeneas Semantics.pdf`. Pure Python, exhaustive enumeration over `u8`; no Lean needed.\n",
9
+ "The \"Rust abstract machine\" below is a small stateful interpreter (store, borrows, panics, fuel). The \"translation\" is a pure function\n",
10
+ "into `Result` with explicit threading of borrowed values (backward functions). Every check compares them on the **whole** finite domain.\n",
11
+ "This is a finite test of the refinement statement, not a proof of Aeneas' soundness theorem (that is a meta-theorem about the tool, see the note)."
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "code",
16
+ "execution_count": 1,
17
+ "metadata": {},
18
+ "source": [
19
+ "import os\n",
20
+ "os.environ[\"OMP_NUM_THREADS\"] = \"1\"\n",
21
+ "import itertools, math\n",
22
+ "from dataclasses import dataclass\n",
23
+ "CHECKS = []\n",
24
+ "def check(name, ok, detail=\"\"):\n",
25
+ " CHECKS.append((name, bool(ok), detail))\n",
26
+ " assert ok, (name, detail)\n",
27
+ " print(f\"[ok] {name} {detail}\")"
28
+ ],
29
+ "outputs": []
30
+ },
31
+ {
32
+ "cell_type": "markdown",
33
+ "metadata": {},
34
+ "source": [
35
+ "## 1. The Result monad: ok / fail(e) / div\n",
36
+ "`Result a = ok a | fail Error | div`. `bind` propagates `fail` and `div`. Errors we use: `integerOverflow`, `arrayOutOfBounds`, `panic`.\n",
37
+ "Monad laws are checked on a finite carrier (all values of `u8`, three error tags, `div`)."
38
+ ]
39
+ },
40
+ {
41
+ "cell_type": "code",
42
+ "execution_count": 2,
43
+ "metadata": {},
44
+ "source": [
45
+ "@dataclass(frozen=True)\n",
46
+ "class R:\n",
47
+ " tag: str # 'ok' | 'fail' | 'div'\n",
48
+ " v: object = None # value for ok, error name for fail\n",
49
+ "def ok(v): return R('ok', v)\n",
50
+ "def fail(e): return R('fail', e)\n",
51
+ "DIV = R('div')\n",
52
+ "def bind(m, f):\n",
53
+ " return f(m.v) if m.tag == 'ok' else m\n",
54
+ "U8 = range(256)\n",
55
+ "carrier = [ok(v) for v in U8] + [fail('integerOverflow'), fail('arrayOutOfBounds'), fail('panic'), DIV]\n",
56
+ "f = lambda x: ok(x + 1) if x < 255 else fail('integerOverflow') # a fallible step\n",
57
+ "g = lambda x: ok(x // 2) if x % 3 else fail('panic')\n",
58
+ "left = all(bind(ok(v), f) == f(v) for v in U8)\n",
59
+ "right = all(bind(m, ok) == m for m in carrier)\n",
60
+ "assoc = all(bind(bind(m, f), g) == bind(m, lambda x: bind(f(x), g)) for m in carrier)\n",
61
+ "check(\"1. monad laws on 260 values: left identity, right identity, associativity\", left and right and assoc)"
62
+ ],
63
+ "outputs": [
64
+ {
65
+ "output_type": "stream",
66
+ "name": "stdout",
67
+ "text": [
68
+ "[ok] 1. monad laws on 260 values: left identity, right identity, associativity \n"
69
+ ]
70
+ }
71
+ ]
72
+ },
73
+ {
74
+ "cell_type": "markdown",
75
+ "metadata": {},
76
+ "source": [
77
+ "## 2. Naive translation fails: integers without overflow\n",
78
+ "Rust (debug / `overflow-checks`): `fn avg(a: u8, b: u8) -> u8 { (a + b) / 2 }`. The abstract machine panics iff `a + b > 255`.\n",
79
+ "NAIVE translation: `a, b` as mathematical integers, no failure case. CORRECT translation: `let s <- a + b; s / 2` with checked `+` in `Result`."
80
+ ]
81
+ },
82
+ {
83
+ "cell_type": "code",
84
+ "execution_count": 3,
85
+ "metadata": {},
86
+ "source": [
87
+ "def rust_avg(a, b): # abstract machine, checked arithmetic\n",
88
+ " s = a + b\n",
89
+ " if s > 255: return ('panic', 'integerOverflow')\n",
90
+ " return ('ret', s // 2)\n",
91
+ "def tr_naive_avg(a, b): return ok((a + b) // 2) # no Result\n",
92
+ "def u8_add(a, b): return ok(a + b) if a + b <= 255 else fail('integerOverflow')\n",
93
+ "def tr_avg(a, b): return bind(u8_add(a, b), lambda s: ok(s // 2))\n",
94
+ "def obs(r): return ('ret', r.v) if r.tag == 'ok' else (('panic', r.v) if r.tag == 'fail' else ('div', None))\n",
95
+ "DOM = [(a, b) for a in U8 for b in U8]\n",
96
+ "bad_naive = sum(1 for a, b in DOM if obs(tr_naive_avg(a, b)) != rust_avg(a, b))\n",
97
+ "bad_good = sum(1 for a, b in DOM if obs(tr_avg(a, b)) != rust_avg(a, b))\n",
98
+ "panics = sum(1 for a, b in DOM if rust_avg(a, b)[0] == 'panic')\n",
99
+ "check(\"2. naive translation disagrees with the machine on exactly the panicking inputs\", bad_naive == panics == 255 * 256 // 2, f\"disagree={bad_naive}, panics={panics}, 255*256/2={255*256//2}\")\n",
100
+ "check(\"2. Result translation agrees with the machine on all 65536 inputs\", bad_good == 0, f\"disagree={bad_good}\")"
101
+ ],
102
+ "outputs": [
103
+ {
104
+ "output_type": "stream",
105
+ "name": "stdout",
106
+ "text": [
107
+ "[ok] 2. naive translation disagrees with the machine on exactly the panicking inputs disagree=32640, panics=32640, 255*256/2=32640\n",
108
+ "[ok] 2. Result translation agrees with the machine on all 65536 inputs disagree=0\n"
109
+ ]
110
+ }
111
+ ]
112
+ },
113
+ {
114
+ "cell_type": "markdown",
115
+ "metadata": {},
116
+ "source": [
117
+ "## 3. Borrow elimination: `&mut` becomes \"return the new value\" (forward) and, when a borrow is returned, a backward function\n",
118
+ "Machine: a store maps addresses to values; `&mut x` is an address. `fn bump(x: &mut u8) { *x += 1 }`.\n",
119
+ "Translation: `bump (x : U8) : Result U8` returns the new value and the caller rebinds."
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "code",
124
+ "execution_count": 4,
125
+ "metadata": {},
126
+ "source": [
127
+ "def machine_bump(store, addr): # returns (outcome, store')\n",
128
+ " v = store[addr] + 1\n",
129
+ " if v > 255: return ('panic', 'integerOverflow'), store # store unchanged when the panic happens before the write\n",
130
+ " s2 = dict(store); s2[addr] = v\n",
131
+ " return ('ret', None), s2\n",
132
+ "def tr_bump(x): return u8_add(x, 1)\n",
133
+ "good = 0\n",
134
+ "for x in U8:\n",
135
+ " out, st = machine_bump({0: x}, 0)\n",
136
+ " r = tr_bump(x)\n",
137
+ " if out[0] == 'ret': good += (r.tag == 'ok' and r.v == st[0])\n",
138
+ " else: good += (r.tag == 'fail' and r.v == out[1])\n",
139
+ "check(\"3a. bump: machine (store, outcome) = translation (returned value, Result) for all u8\", good == 256)\n",
140
+ "# Returned mutable borrow: fn get_mut(v: &mut Vec<u8>, i: usize) -> &mut u8 ; caller: *get_mut(&mut v, i) += k\n",
141
+ "def machine_incr_at(vec, i, k):\n",
142
+ " heap = {j: x for j, x in enumerate(vec)} # elements are addressable places\n",
143
+ " if i >= len(vec): return ('panic', 'arrayOutOfBounds'), vec\n",
144
+ " v = heap[i] + k\n",
145
+ " if v > 255: return ('panic', 'integerOverflow'), vec\n",
146
+ " heap[i] = v\n",
147
+ " return ('ret', None), [heap[j] for j in range(len(vec))]\n",
148
+ "def tr_get_mut(v, i): # forward value and BACKWARD function back : u8 -> Vec\n",
149
+ " if i >= len(v): return fail('arrayOutOfBounds')\n",
150
+ " back = lambda new, v=v, i=i: tuple(new if j == i else x for j, x in enumerate(v))\n",
151
+ " return ok((v[i], back))\n",
152
+ "def tr_incr_at(v, i, k):\n",
153
+ " return bind(tr_get_mut(v, i), lambda p: bind(u8_add(p[0], k), lambda new: ok(p[1](new))))\n",
154
+ "cases = ties = 0\n",
155
+ "for n in range(0, 4):\n",
156
+ " for vec in itertools.product([0, 1, 254, 255], repeat=n):\n",
157
+ " for i in range(0, 5):\n",
158
+ " for k in (0, 1, 2):\n",
159
+ " out, st = machine_incr_at(list(vec), i, k)\n",
160
+ " r = tr_incr_at(vec, i, k)\n",
161
+ " cases += 1\n",
162
+ " if out[0] == 'ret': ties += (r.tag == 'ok' and list(r.v) == st)\n",
163
+ " else: ties += (r.tag == 'fail' and r.v == out[1])\n",
164
+ "check(\"3b. returned &mut: machine vs forward value + backward function, vec len<=3, i<=4, k<=2\", ties == cases, f\"{ties}/{cases} cases\")"
165
+ ],
166
+ "outputs": [
167
+ {
168
+ "output_type": "stream",
169
+ "name": "stdout",
170
+ "text": [
171
+ "[ok] 3a. bump: machine (store, outcome) = translation (returned value, Result) for all u8 \n",
172
+ "[ok] 3b. returned &mut: machine vs forward value + backward function, vec len<=3, i<=4, k<=2 1275/1275 cases\n"
173
+ ]
174
+ }
175
+ ]
176
+ },
177
+ {
178
+ "cell_type": "markdown",
179
+ "metadata": {},
180
+ "source": [
181
+ "## 4. Loops become recursion; non-termination is `div`\n",
182
+ "`fn spin(x: u8) { while x != 0 { } }`. The machine is run with a fuel bound F; `div` is what survives every F.\n",
183
+ "`fn sum_to(n: u8) -> u8 { let mut s = 0; let mut i = 0; while i < n { i += 1; s += i } s }` : the accumulator overflows once n(n+1)/2 > 255, i.e. n >= 23."
184
+ ]
185
+ },
186
+ {
187
+ "cell_type": "code",
188
+ "execution_count": 5,
189
+ "metadata": {},
190
+ "source": [
191
+ "def machine_spin(x, fuel):\n",
192
+ " for _ in range(fuel):\n",
193
+ " if x == 0: return ('ret', None)\n",
194
+ " return ('fuel', None)\n",
195
+ "def tr_spin(x): return ok(None) if x == 0 else DIV\n",
196
+ "agree = all(((machine_spin(x, F)[0] == 'ret') == (tr_spin(x).tag == 'ok')) and ((machine_spin(x, F)[0] == 'fuel') == (tr_spin(x).tag == 'div')) for x in U8 for F in (1, 10, 1000))\n",
197
+ "check(\"4a. spin: 'div' corresponds to fuel exhaustion for every tested fuel\", agree)\n",
198
+ "def machine_sum_to(n):\n",
199
+ " s = i = 0\n",
200
+ " while i < n:\n",
201
+ " i += 1; s += i\n",
202
+ " if s > 255: return ('panic', 'integerOverflow')\n",
203
+ " return ('ret', s)\n",
204
+ "def tr_sum_loop(n, i=0, s=0): # the recursive function Aeneas would emit\n",
205
+ " if i < n:\n",
206
+ " i2 = i + 1\n",
207
+ " r = u8_add(s, i2)\n",
208
+ " return bind(r, lambda s2: tr_sum_loop(n, i2, s2))\n",
209
+ " return ok(s)\n",
210
+ "m = [machine_sum_to(n) for n in U8]\n",
211
+ "check(\"4b. sum_to: loop = recursive translation for all n in u8\", all(obs(tr_sum_loop(n)) == m[n] for n in U8))\n",
212
+ "first_bad = min(n for n in U8 if m[n][0] == 'panic')\n",
213
+ "check(\"4b. first panicking n is 23 (n(n+1)/2 = 276 > 255, 22*23/2 = 253 <= 255)\", first_bad == 23 and 22 * 23 // 2 == 253 and 23 * 24 // 2 == 276, f\"first_bad={first_bad}\")"
214
+ ],
215
+ "outputs": [
216
+ {
217
+ "output_type": "stream",
218
+ "name": "stdout",
219
+ "text": [
220
+ "[ok] 4a. spin: 'div' corresponds to fuel exhaustion for every tested fuel \n",
221
+ "[ok] 4b. sum_to: loop = recursive translation for all n in u8 \n",
222
+ "[ok] 4b. first panicking n is 23 (n(n+1)/2 = 276 > 255, 22*23/2 = 253 <= 255) first_bad=23\n"
223
+ ]
224
+ }
225
+ ]
226
+ },
227
+ {
228
+ "cell_type": "markdown",
229
+ "metadata": {},
230
+ "source": [
231
+ "## 5. A proof of `not P`: closed proposition versus negation under binders\n",
232
+ "Property of `avg`: `P(a,b,r)`: `min(a,b) <= r <= max(a,b)`, evaluated on the translated function, with `Q(a,b) := exists r, avg a b = ok r /\\ P(a,b,r)`.\n",
233
+ "Closed statement S := forall a b, H a b -> Q a b (H trivially true here). Its negation not S <=> exists a b, H a b /\\ not Q a b.\n",
234
+ "Different, much stronger statement S' := forall a b, H a b -> not Q a b. Under binders, `not Q a b` for a free (a,b) is a claim about *every* instance."
235
+ ]
236
+ },
237
+ {
238
+ "cell_type": "code",
239
+ "execution_count": 6,
240
+ "metadata": {},
241
+ "source": [
242
+ "def Q(a, b):\n",
243
+ " r = tr_avg(a, b)\n",
244
+ " return r.tag == 'ok' and min(a, b) <= r.v <= max(a, b)\n",
245
+ "H = lambda a, b: True\n",
246
+ "S = all((not H(a, b)) or Q(a, b) for a, b in DOM)\n",
247
+ "notS = any(H(a, b) and not Q(a, b) for a, b in DOM)\n",
248
+ "Sprime = all((not H(a, b)) or (not Q(a, b)) for a, b in DOM)\n",
249
+ "witnesses = [(a, b) for a, b in DOM if H(a, b) and not Q(a, b)]\n",
250
+ "check(\"5a. S is false and (not S) holds, certified by one witness\", (not S) and notS and witnesses[0] == (1, 255), f\"|W|={len(witnesses)}, first witness {witnesses[0]}\")\n",
251
+ "check(\"5a. the 'under binders' strengthening S' (forall x, not Q x) is false: Q holds at (0,0)\", (not Sprime) and Q(0, 0), f\"|{{Q}}|={len(DOM)-len(witnesses)}\")\n",
252
+ "check(\"5a. |W| = number of panicking inputs = 32640\", len(witnesses) == 32640)\n",
253
+ "# With a precondition H a b := a + b <= 255 the closed statement is TRUE and its negation is not provable\n",
254
+ "H2 = lambda a, b: a + b <= 255\n",
255
+ "check(\"5b. with H := a+b<=255, S_H holds (so no certificate for not S_H exists)\", all((not H2(a, b)) or Q(a, b) for a, b in DOM))\n",
256
+ "# Vacuity: if H is unsatisfiable, the under-binders form is provable and yet there is no witness at all\n",
257
+ "H3 = lambda a, b: a + b > 255 and a + b < 256\n",
258
+ "check(\"5c. unsatisfiable H: S'_H = forall x, H x -> not Q x holds vacuously, but the witness set is empty (no bug evidence)\",\n",
259
+ " all((not H3(a, b)) or (not Q(a, b)) for a, b in DOM) and not any(H3(a, b) for a, b in DOM))\n",
260
+ "# And with H satisfiable and S'_H true (every H-input fails): S'_H implies the closed negation\n",
261
+ "H4 = lambda a, b: a + b >= 256\n",
262
+ "check(\"5c. satisfiable H := a+b>=256: S'_H holds (every such input fails) and so implies not S_H\", all((not H4(a, b)) or (not Q(a, b)) for a, b in DOM) and any(H4(a, b) for a, b in DOM))"
263
+ ],
264
+ "outputs": [
265
+ {
266
+ "output_type": "stream",
267
+ "name": "stdout",
268
+ "text": [
269
+ "[ok] 5a. S is false and (not S) holds, certified by one witness |W|=32640, first witness (1, 255)\n",
270
+ "[ok] 5a. the 'under binders' strengthening S' (forall x, not Q x) is false: Q holds at (0,0) |{Q}|=32896\n",
271
+ "[ok] 5a. |W| = number of panicking inputs = 32640 \n",
272
+ "[ok] 5b. with H := a+b<=255, S_H holds (so no certificate for not S_H exists) \n",
273
+ "[ok] 5c. unsatisfiable H: S'_H = forall x, H x -> not Q x holds vacuously, but the witness set is empty (no bug evidence) \n",
274
+ "[ok] 5c. satisfiable H := a+b>=256: S'_H holds (every such input fails) and so implies not S_H \n"
275
+ ]
276
+ }
277
+ ]
278
+ },
279
+ {
280
+ "cell_type": "markdown",
281
+ "metadata": {},
282
+ "source": [
283
+ "## 6. Reachability: the certificate is about `avg`, not about the program that calls it\n",
284
+ "Public caller: `fn mean_pct(a: u8, b: u8) -> u8 { avg(min(a, c), min(b, c)) }` for a clamp `c` (a property of the *caller*).\n",
285
+ "Reach_c = {(a', b'): a', b' <= c}. Witnesses W from section 5 are real bugs of the program only if W intersect Reach_c is non-empty."
286
+ ]
287
+ },
288
+ {
289
+ "cell_type": "code",
290
+ "execution_count": 7,
291
+ "metadata": {},
292
+ "source": [
293
+ "def count_formula(c): # pairs in [0,c]^2 with a+b >= 256\n",
294
+ " return sum(max(0, min(c, 255) - max(0, 256 - a) + 1) for a in range(0, min(c, 255) + 1))\n",
295
+ "rows = []\n",
296
+ "for c in (50, 100, 127, 128, 150, 200, 255):\n",
297
+ " brute = sum(1 for a, b in witnesses if a <= c and b <= c)\n",
298
+ " rows.append((c, brute, count_formula(c)))\n",
299
+ " assert brute == count_formula(c)\n",
300
+ " print(f\" clamp c={c:3d}: |W \u2229 Reach_c| = {brute:6d} (closed form {count_formula(c)})\")\n",
301
+ "check(\"6. |W \u2229 Reach_c| equals the closed form for all tested c\", all(b == f for _, b, f in rows))\n",
302
+ "check(\"6. c <= 127: certificate holds for avg yet the program mean_pct never panics (false alarm)\", dict((c, b) for c, b, _ in rows)[127] == 0 and dict((c, b) for c, b, _ in rows)[128] == 1, \"c=127 -> 0, c=128 -> 1 (128+128=256)\")\n",
303
+ "# end-to-end: run the caller on its whole input domain\n",
304
+ "def mean_pct(a, b, c): return tr_avg(min(a, c), min(b, c))\n",
305
+ "for c in (100, 127, 128, 200):\n",
306
+ " panics_c = sum(1 for a, b in DOM if mean_pct(a, b, c).tag == 'fail')\n",
307
+ " print(f\" mean_pct with clamp {c}: panicking inputs over all 65536 raw (a,b): {panics_c}\")\n",
308
+ "check(\"6. the caller with c=127 never fails on any of 65536 raw inputs; with c=128 it fails (a,b >= 128)\", all(mean_pct(a, b, 127).tag == 'ok' for a, b in DOM) and sum(1 for a, b in DOM if mean_pct(a, b, 128).tag == 'fail') == 128 * 128)"
309
+ ],
310
+ "outputs": [
311
+ {
312
+ "output_type": "stream",
313
+ "name": "stdout",
314
+ "text": [
315
+ " clamp c= 50: |W \u2229 Reach_c| = 0 (closed form 0)\n",
316
+ " clamp c=100: |W \u2229 Reach_c| = 0 (closed form 0)\n",
317
+ " clamp c=127: |W \u2229 Reach_c| = 0 (closed form 0)\n",
318
+ " clamp c=128: |W \u2229 Reach_c| = 1 (closed form 1)\n",
319
+ " clamp c=150: |W \u2229 Reach_c| = 1035 (closed form 1035)\n",
320
+ " clamp c=200: |W \u2229 Reach_c| = 10585 (closed form 10585)\n",
321
+ " clamp c=255: |W \u2229 Reach_c| = 32640 (closed form 32640)\n",
322
+ "[ok] 6. |W \u2229 Reach_c| equals the closed form for all tested c \n",
323
+ "[ok] 6. c <= 127: certificate holds for avg yet the program mean_pct never panics (false alarm) c=127 -> 0, c=128 -> 1 (128+128=256)\n",
324
+ " mean_pct with clamp 100: panicking inputs over all 65536 raw (a,b): 0\n",
325
+ " mean_pct with clamp 127: panicking inputs over all 65536 raw (a,b): 0\n",
326
+ " mean_pct with clamp 128: panicking inputs over all 65536 raw (a,b): 16384\n",
327
+ " mean_pct with clamp 200: panicking inputs over all 65536 raw (a,b): 29560\n",
328
+ "[ok] 6. the caller with c=127 never fails on any of 65536 raw inputs; with c=128 it fails (a,b >= 128) \n"
329
+ ]
330
+ }
331
+ ]
332
+ },
333
+ {
334
+ "cell_type": "markdown",
335
+ "metadata": {},
336
+ "source": [
337
+ "## 7. Translation soundness: wrong translations give wrong certificates in both directions\n",
338
+ "* U1 (too lax): unbounded integers. Then `S` is *provable*, the real program violates it: a missed bug.\n",
339
+ "* U2 (wrapping, release-mode semantics): `Q` fails on the same inputs but as a wrong value, and panic-freedom becomes provable. \"The bug\" depends on which Rust semantics is modelled.\n",
340
+ "* U3 (too strict): checks overflow on the *widened* sum `(a as u16 + b as u16)/2`, which is fine in Rust, but translates the u16 add as a u8 add: spurious `fail`, a false certificate."
341
+ ]
342
+ },
343
+ {
344
+ "cell_type": "code",
345
+ "execution_count": 8,
346
+ "metadata": {},
347
+ "source": [
348
+ "def Q_with(trf): return lambda a, b: (lambda r: r.tag == 'ok' and min(a, b) <= r.v <= max(a, b))(trf(a, b))\n",
349
+ "trU1 = tr_naive_avg\n",
350
+ "trU2 = lambda a, b: ok(((a + b) % 256) // 2)\n",
351
+ "def rust_wide(a, b): return ('ret', (a + b) // 2) # `((a as u16 + b as u16) / 2) as u8` never panics\n",
352
+ "trU3 = lambda a, b: bind(u8_add(a, b), lambda s: ok(s // 2)) # wrong: widened add checked at u8\n",
353
+ "Wset = lambda q: sum(1 for a, b in DOM if not q(a, b))\n",
354
+ "print(\" failing sets |{not Q}|: truth(avg, checked) =\", Wset(Q), \", U1 (unbounded) =\", Wset(Q_with(trU1)), \", U2 (wrapping) =\", Wset(Q_with(trU2)))\n",
355
+ "check(\"7a. U1: S is provable under the unbounded translation (no witness), but the program panics on 32640 inputs\", Wset(Q_with(trU1)) == 0 and Wset(Q) == 32640)\n",
356
+ "pf_checked = all(tr_avg(a, b).tag == 'ok' for a, b in DOM); pf_wrap = all(trU2(a, b).tag == 'ok' for a, b in DOM)\n",
357
+ "same_set = {(a, b) for a, b in DOM if not Q_with(trU2)(a, b)} == set(witnesses)\n",
358
+ "check(\"7b. U2: wrapping gives the SAME 32640 witnesses for P but for a different reason (wrong value, not panic); panic-freedom is provable under U2 and false under checked semantics\",\n",
359
+ " same_set and pf_wrap and not pf_checked, f\"|W_wrap|={Wset(Q_with(trU2))}, panic-free: wrap={pf_wrap}, checked={pf_checked}\")\n",
360
+ "# wide version of the program: truth never panics, so S_wide holds; the strict translation certifies a bug\n",
361
+ "Qwide_true = lambda a, b: min(a, b) <= rust_wide(a, b)[1] <= max(a, b)\n",
362
+ "check(\"7c. U3: program `avg_wide` is correct on all inputs; the over-strict translation yields 32640 spurious witnesses\", all(Qwide_true(a, b) for a, b in DOM) and Wset(Q_with(trU3)) == 32640)"
363
+ ],
364
+ "outputs": [
365
+ {
366
+ "output_type": "stream",
367
+ "name": "stdout",
368
+ "text": [
369
+ " failing sets |{not Q}|: truth(avg, checked) = 32640 , U1 (unbounded) = 0 , U2 (wrapping) = 32640\n",
370
+ "[ok] 7a. U1: S is provable under the unbounded translation (no witness), but the program panics on 32640 inputs \n",
371
+ "[ok] 7b. U2: wrapping gives the SAME 32640 witnesses for P but for a different reason (wrong value, not panic); panic-freedom is provable under U2 and false under checked semantics |W_wrap|=32640, panic-free: wrap=True, checked=False\n",
372
+ "[ok] 7c. U3: program `avg_wide` is correct on all inputs; the over-strict translation yields 32640 spurious witnesses \n"
373
+ ]
374
+ }
375
+ ]
376
+ },
377
+ {
378
+ "cell_type": "markdown",
379
+ "metadata": {},
380
+ "source": [
381
+ "## Summary"
382
+ ]
383
+ },
384
+ {
385
+ "cell_type": "code",
386
+ "execution_count": 9,
387
+ "metadata": {},
388
+ "source": [
389
+ "for n, ok_, d in CHECKS: print((\"PASS \" if ok_ else \"FAIL \") + n)\n",
390
+ "assert all(c[1] for c in CHECKS)\n",
391
+ "print(f\"\\n{len(CHECKS)} checks, all passed\")"
392
+ ],
393
+ "outputs": [
394
+ {
395
+ "output_type": "stream",
396
+ "name": "stdout",
397
+ "text": [
398
+ "PASS 1. monad laws on 260 values: left identity, right identity, associativity\n",
399
+ "PASS 2. naive translation disagrees with the machine on exactly the panicking inputs\n",
400
+ "PASS 2. Result translation agrees with the machine on all 65536 inputs\n",
401
+ "PASS 3a. bump: machine (store, outcome) = translation (returned value, Result) for all u8\n",
402
+ "PASS 3b. returned &mut: machine vs forward value + backward function, vec len<=3, i<=4, k<=2\n",
403
+ "PASS 4a. spin: 'div' corresponds to fuel exhaustion for every tested fuel\n",
404
+ "PASS 4b. sum_to: loop = recursive translation for all n in u8\n",
405
+ "PASS 4b. first panicking n is 23 (n(n+1)/2 = 276 > 255, 22*23/2 = 253 <= 255)\n",
406
+ "PASS 5a. S is false and (not S) holds, certified by one witness\n",
407
+ "PASS 5a. the 'under binders' strengthening S' (forall x, not Q x) is false: Q holds at (0,0)\n",
408
+ "PASS 5a. |W| = number of panicking inputs = 32640\n",
409
+ "PASS 5b. with H := a+b<=255, S_H holds (so no certificate for not S_H exists)\n",
410
+ "PASS 5c. unsatisfiable H: S'_H = forall x, H x -> not Q x holds vacuously, but the witness set is empty (no bug evidence)\n",
411
+ "PASS 5c. satisfiable H := a+b>=256: S'_H holds (every such input fails) and so implies not S_H\n",
412
+ "PASS 6. |W \u2229 Reach_c| equals the closed form for all tested c\n",
413
+ "PASS 6. c <= 127: certificate holds for avg yet the program mean_pct never panics (false alarm)\n",
414
+ "PASS 6. the caller with c=127 never fails on any of 65536 raw inputs; with c=128 it fails (a,b >= 128)\n",
415
+ "PASS 7a. U1: S is provable under the unbounded translation (no witness), but the program panics on 32640 inputs\n",
416
+ "PASS 7b. U2: wrapping gives the SAME 32640 witnesses for P but for a different reason (wrong value, not panic); panic-freedom is provable under U2 and false under checked semantics\n",
417
+ "PASS 7c. U3: program `avg_wide` is correct on all inputs; the over-strict translation yields 32640 spurious witnesses\n",
418
+ "\n",
419
+ "20 checks, all passed\n"
420
+ ]
421
+ }
422
+ ]
423
+ }
424
+ ],
425
+ "metadata": {
426
+ "kernelspec": {
427
+ "display_name": "Python 3",
428
+ "language": "python",
429
+ "name": "python3"
430
+ },
431
+ "language_info": {
432
+ "name": "python",
433
+ "version": "3.12.11"
434
+ }
435
+ },
436
+ "nbformat": 4,
437
+ "nbformat_minor": 4
438
+ }
code/notebooks/_lean_helper.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny Lean runner for the 03xx notebooks. Goes ONLY through align.verify.lean.
2
+
3
+ Rules (house + task): <= 2 workers (we use 1), nice 15, MemAvailable >= 45 GB checked
4
+ before the pool starts, never the heavy-job lock, never touches a GPU.
5
+ """
6
+ from __future__ import annotations
7
+ import os, sys, re
8
+ os.environ["CUDA_VISIBLE_DEVICES"] = ""
9
+ sys.path.insert(0, "/apps/development/rlhf/align")
10
+ from align.verify.lean import ReplPool, ReplLimits, find_toolchain
11
+ from align.verify.lean.repl_pool import meminfo_available_gb
12
+
13
+ HEADER = "import Mathlib\nimport Aesop\nset_option maxHeartbeats 400000\nopen Nat\n"
14
+
15
+ class Lean:
16
+ def __init__(self, header: str = HEADER):
17
+ avail = meminfo_available_gb()
18
+ if avail < 45.0:
19
+ raise RuntimeError(f"MemAvailable {avail:.1f} GB < 45 GB: refusing to start Lean")
20
+ self.tc = find_toolchain()
21
+ self.pool = ReplPool.from_toolchain(self.tc, n_workers=1, limits=ReplLimits(nice=15))
22
+ self.header = header
23
+ self._cm = self.pool.acquire()
24
+ self.w = self._cm.__enter__()
25
+ self.env = self.w.header_env(header)
26
+
27
+ def run(self, code: str, timeout: float = 120.0, keep: bool = False):
28
+ """Run `code` on top of the header; return (messages, new_env)."""
29
+ resp = self.w.command(code, self.env, timeout)
30
+ if keep and "env" in resp:
31
+ self.env = resp["env"]
32
+ return resp
33
+
34
+ def show(self, code: str, timeout: float = 120.0, keep: bool = False) -> dict:
35
+ r = self.run(code, timeout, keep)
36
+ for m in r.get("messages", []):
37
+ print(f"[{m['severity']}] line {m['pos']['line']}: {m['data']}")
38
+ for s in r.get("sorries", []):
39
+ print("[sorries]", s.get("goal", "")[:200])
40
+ if not r.get("messages") and not r.get("sorries"):
41
+ print("[ok] no messages")
42
+ return r
43
+
44
+ def close(self):
45
+ try: self._cm.__exit__(None, None, None)
46
+ finally: self.pool.close()
code/notebooks/_m1_lib.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for the M1 notebooks (notes 0101-0110).
2
+
3
+ Everything is fp64 CPU torch. The "language model" is a deliberately tiny
4
+ autoregressive policy whose *entire* sequence space can be enumerated, so every
5
+ expectation in the notes can be computed EXACTLY (no Monte Carlo) and compared
6
+ against the hand-derived formula to ~1e-15. Monte Carlo checks are kept for the
7
+ few identities that are about sampling (and are reported against 4 standard errors).
8
+
9
+ Policy: pi_theta(a | prev, t) = softmax(theta_tok[prev] + theta_pos[t])_a
10
+ - vocabulary {0,..,V-1} where token V-1 is EOS
11
+ - `prev` is the previous token (BOS = index V for t=0)
12
+ - a sequence stops at EOS or after L tokens (truncation, no EOS)
13
+ This is a valid autoregressive factorisation pi(o) = prod_t pi(o_t | o_<t)
14
+ (with a Markov-1 context) so every identity about the general case holds verbatim.
15
+ """
16
+ import os, sys, math, itertools
17
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") # never touch the GPUs
18
+ import torch
19
+ torch.set_default_dtype(torch.float64)
20
+ torch.set_num_threads(2)
21
+
22
+ CHECKS = []
23
+
24
+ def check(name, err, tol, kind="exact"):
25
+ """Record + assert a numerical identity. kind='exact' (fp64 arithmetic) or 'mc' (4-sigma Monte Carlo)."""
26
+ err = float(err)
27
+ ok = err <= tol
28
+ CHECKS.append((name, err, tol, kind, ok))
29
+ print(f"CHECK|{kind}|{name}|{err:.3e}|tol={tol:.1e}|{'PASS' if ok else 'FAIL'}")
30
+ assert ok, f"{name}: err {err:.3e} > tol {tol:.1e}"
31
+
32
+ def maxabs(a, b):
33
+ return float((torch.as_tensor(a) - torch.as_tensor(b)).abs().max())
34
+
35
+ class TinyLM:
36
+ def __init__(self, V=4, L=4, seed=0, scale=1.0):
37
+ self.V, self.L = V, L
38
+ self.EOS = V - 1
39
+ g = torch.Generator().manual_seed(seed)
40
+ self.n_tok = (V + 1) * V # rows: prev in {0..V-1, BOS}
41
+ self.n_pos = L * V
42
+ self.theta0 = scale * torch.randn(self.n_tok + self.n_pos, generator=g)
43
+ self.seqs, self.tokens, self.mask = self._enumerate()
44
+ self.N = len(self.seqs)
45
+ self.lengths = self.mask.sum(1)
46
+
47
+ # ---- enumeration of the whole sequence space --------------------------------
48
+ def _enumerate(self):
49
+ V, L, EOS = self.V, self.L, self.EOS
50
+ seqs = []
51
+ def rec(prefix):
52
+ if len(prefix) == L or (prefix and prefix[-1] == EOS):
53
+ seqs.append(tuple(prefix)); return
54
+ for a in range(V):
55
+ rec(prefix + [a])
56
+ rec([])
57
+ tokens = torch.full((len(seqs), L), EOS, dtype=torch.long)
58
+ mask = torch.zeros(len(seqs), L)
59
+ for i, s in enumerate(seqs):
60
+ tokens[i, :len(s)] = torch.tensor(s)
61
+ mask[i, :len(s)] = 1.0
62
+ return seqs, tokens, mask
63
+
64
+ def _logits_tables(self, theta):
65
+ V, L = self.V, self.L
66
+ tok = theta[:self.n_tok].reshape(V + 1, V)
67
+ pos = theta[self.n_tok:].reshape(L, V)
68
+ return tok, pos
69
+
70
+ def token_logprobs(self, theta):
71
+ """[N, L] log pi(o_t | o_<t) for every enumerated sequence (0 where masked)."""
72
+ V, L = self.V, self.L
73
+ tok, pos = self._logits_tables(theta)
74
+ prev = torch.cat([torch.full((self.N, 1), V, dtype=torch.long), self.tokens[:, :-1]], 1) # BOS = V
75
+ logits = tok[prev] + pos[None, :, :] # [N, L, V]
76
+ lp = torch.log_softmax(logits, -1)
77
+ lp = lp.gather(-1, self.tokens[..., None]).squeeze(-1)
78
+ return lp * self.mask
79
+
80
+ def seq_logprob(self, theta):
81
+ return self.token_logprobs(theta).sum(1)
82
+
83
+ def probs(self, theta):
84
+ return self.seq_logprob(theta).exp()
85
+
86
+ # ---- naive, loop-based reference (used to cross-check the vectorised code) --
87
+ def seq_logprob_naive(self, theta, seq):
88
+ V = self.V
89
+ tok, pos = self._logits_tables(theta)
90
+ lp, prev = 0.0, V
91
+ for t, a in enumerate(seq):
92
+ logits = tok[prev] + pos[t]
93
+ z = logits - logits.max()
94
+ lp = lp + (z[a] - torch.log(torch.exp(z).sum()))
95
+ prev = a
96
+ return lp
97
+
98
+ # ---- exact state machinery for value functions ------------------------------
99
+ def prefixes(self):
100
+ """All non-terminal prefixes (the MDP states), shortest first."""
101
+ out = [()]
102
+ for k in range(1, self.L):
103
+ out += [p for p in itertools.product(range(self.V - 1), repeat=k)] # non-EOS tokens only
104
+ return out
105
+
106
+ def cond_probs(self, theta, prefix):
107
+ V = self.V
108
+ tok, pos = self._logits_tables(theta)
109
+ prev = V if len(prefix) == 0 else prefix[-1]
110
+ return torch.softmax(tok[prev] + pos[len(prefix)], -1)
111
+
112
+ def default_reward(lm):
113
+ """Binary verifier-style reward: success iff the sequence ends in EOS and its token sum is odd and length>=2."""
114
+ R = torch.zeros(lm.N)
115
+ for i, s in enumerate(lm.seqs):
116
+ if s[-1] == lm.EOS and len(s) >= 2 and (sum(s[:-1]) % 2 == 1):
117
+ R[i] = 1.0
118
+ return R
119
+
120
+ def flat_grad(scalar, theta):
121
+ g, = torch.autograd.grad(scalar, theta, retain_graph=True, allow_unused=False)
122
+ return g
123
+
124
+ def score_matrix(lm, theta):
125
+ """S[o] = grad_theta log pi(o) as an [N, P] matrix (autograd, one backward per sequence)."""
126
+ th = theta.clone().requires_grad_(True)
127
+ lp = lm.seq_logprob(th)
128
+ rows = [torch.autograd.grad(lp[i], th, retain_graph=True)[0] for i in range(lm.N)]
129
+ return torch.stack(rows)
130
+
131
+ def token_score_tensor(lm, theta):
132
+ """T[o, t] = grad_theta log pi(o_t | o_<t) as [N, L, P]."""
133
+ th = theta.clone().requires_grad_(True)
134
+ lp = lm.token_logprobs(th)
135
+ P = theta.numel()
136
+ out = torch.zeros(lm.N, lm.L, P)
137
+ for i in range(lm.N):
138
+ for t in range(int(lm.lengths[i])):
139
+ out[i, t] = torch.autograd.grad(lp[i, t], th, retain_graph=True)[0]
140
+ return out
141
+
142
+ def perturb(theta, delta, seed=1):
143
+ g = torch.Generator().manual_seed(seed)
144
+ d = torch.randn(theta.numel(), generator=g)
145
+ return theta + delta * d / d.norm()
code/notebooks/_m1_run.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Execute a percent-format source (# %% / # %% [markdown]) and write an executed .ipynb with outputs.
2
+
3
+ usage: python _m1_run.py src.py out.ipynb
4
+ Runs all code cells in ONE namespace (like a kernel), captures stdout/stderr per cell,
5
+ stores them as stream outputs. Needs nothing but the stdlib (+ whatever the notebook imports).
6
+ """
7
+ import sys, io, json, traceback, contextlib, os, time
8
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
9
+ src, out = sys.argv[1], sys.argv[2]
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(src)) + "/..")
11
+ lines = open(src).read().split("\n")
12
+ cells, cur, kind = [], [], None
13
+ def flush():
14
+ global cur, kind
15
+ if kind is not None:
16
+ body = cur[:]
17
+ while body and body[-1] == "": body.pop()
18
+ cells.append((kind, body))
19
+ cur, kind = [], None
20
+ for ln in lines:
21
+ if ln.startswith("# %%"):
22
+ flush(); kind = "markdown" if "[markdown]" in ln else "code"
23
+ else:
24
+ cur.append(ln)
25
+ flush()
26
+ ns = {"__name__": "__main__"}
27
+ nb_cells, count = [], 0
28
+ t0 = time.time()
29
+ for kind, body in cells:
30
+ if kind == "markdown":
31
+ text = "\n".join(l[2:] if l.startswith("# ") else l.lstrip("#") for l in body)
32
+ nb_cells.append({"cell_type": "markdown", "metadata": {}, "source": text.splitlines(True)})
33
+ continue
34
+ count += 1
35
+ code = "\n".join(body)
36
+ so, se = io.StringIO(), io.StringIO()
37
+ err = None
38
+ with contextlib.redirect_stdout(so), contextlib.redirect_stderr(se):
39
+ try:
40
+ exec(compile(code, f"<cell {count}>", "exec"), ns)
41
+ except BaseException:
42
+ err = traceback.format_exc()
43
+ outputs = []
44
+ if so.getvalue(): outputs.append({"output_type": "stream", "name": "stdout", "text": so.getvalue().splitlines(True)})
45
+ if se.getvalue(): outputs.append({"output_type": "stream", "name": "stderr", "text": se.getvalue().splitlines(True)})
46
+ if err:
47
+ outputs.append({"output_type": "error", "ename": "Error", "evalue": err.splitlines()[-1], "traceback": err.splitlines()})
48
+ nb_cells.append({"cell_type": "code", "execution_count": count, "metadata": {}, "source": code.splitlines(True), "outputs": outputs})
49
+ if err:
50
+ print(err, file=sys.stderr)
51
+ nb = {"cells": nb_cells, "metadata": {}, "nbformat": 4, "nbformat_minor": 4}
52
+ json.dump(nb, open(out + ".FAILED", "w"), indent=1)
53
+ sys.exit(1)
54
+ nb = {"cells": nb_cells,
55
+ "metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
56
+ "language_info": {"name": "python", "version": sys.version.split()[0]}},
57
+ "nbformat": 4, "nbformat_minor": 4}
58
+ json.dump(nb, open(out, "w"), indent=1)
59
+ print(f"wrote {out}: {count} code cells in {time.time()-t0:.1f}s")
code/notebooks/_m1_show.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import json,sys
2
+ nb=json.load(open(sys.argv[1]))
3
+ for c in nb['cells']:
4
+ for o in c.get('outputs',[]): print(''.join(o.get('text',o.get('traceback',''))),end='')
code/notes/STYLE_ALIGNMENT.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Style alignment: align/notes and align/notebooks vs Flash-Attention-101
2
+
3
+ Reference: `/apps/development/rlhf/reference-repos/Flash-Attention-101` (notes/0000-0008, 009, 010; README.md; scratch-pad.ipynb).
4
+ Provenance caveat: the reference PDFs are handwritten notes whose title page (0000, p.1) is signed "Flash Attention from first principles, Umar Jamil". The repo (README clone URL viai957/Flash-Attention-101) packages them with the user's own README, notebooks and kernels. The "house style" below is therefore the handwritten-lecture style plus the repo's layout, not a typeset template. Where typeset equivalents are needed I mapped them (see "Mapping").
5
+
6
+ ## 1. Reference style rules (page citations are PDF page numbers)
7
+
8
+ Layout and naming
9
+ - R1. Numbered, one-topic-per-file notes named `NNNN - Title Case.pdf` (0001 - Multi Head Attention, 0003 - Online Softmax, ...). 0000 is a Table of Contents (0000 p.2 "Topics" list, p.3 "Prerequisites"). Numbering is not perfectly uniform (009, 010 have three digits).
10
+ - R2. Length 2-9 pages per note (0002: 4, 0003: 6, 0004: 9, 0008: 9, 009/010: 2). One idea per page.
11
+ - R3. Notebook: a single `scratch-pad.ipynb` that goes naive first (cells 0-9 "Conventional Softmax": loop for max, loop for normaliser, loop for division, then one consolidated typed function), then the improved version ("Safe Softmax" cell 10 on: single fused loop), then block/grid scaffolding for the Triton kernel. Markdown cell = pseudocode, code cell = direct transcription, then a consolidated function with a docstring. No asserts; checks are by printing. Kernel code lives in `triton/` and `cuda/`; README maps folder -> role.
12
+ - R4. README: Overview, Folder Structure, "Understanding ...", "Implementation Details" in the order PyTorch naive -> Triton -> CUDA, Benchmarking, Setup.
13
+
14
+ Page structure and tone
15
+ - R5. Question-first opening. Each note starts with a bare problem statement as a question, then the pseudocode that fails: 0003 p.1 "The problem statement is: can we find a way to compute the softmax of a vector without going through the vector 3 times, but also preventing the exponential from exploding?"; 0004 p.2 "WHY SHOULD WE CARE?"; 0002 p.1 "But there's a problem! If the values of the vector are large, the exponential will explode!"; 0008 p.1 states the object ("Derivative: scalar input, scalar output") first.
16
+ - R6. Motivation -> failing naive version -> fix -> proof. 0003: problem (p.1) -> try fusing (p.2) -> step-by-step trace on the same tiny vector x = [3, 2, 5, 1] (pp.2-4) -> "Can we prove this algorithm is correct? Let's do it!" (p.5) -> induction with base case and step, each closed by a marker (p.6).
17
+ - R7. One running toy example reused on every page (x = [3,2,5,1]; the 8x8 blocked Q,K,V of 0004), with explicit shapes written next to every object ((N,d), (d,N), "ORIGINAL (8,128) / BLOCK (4,128)", 0002 p.1, 0004 pp.2-5).
18
+ - R8. Pseudocode appears next to the derivation, is restated in full each time it is modified, and is labelled "Pseudocode" / "New pseudocode" (0003 pp.1, 4).
19
+ - R9. Figures carry the argument: grid pictures of blocked matrices, computation graph for autograd (0008 pp.6-7), memory-hierarchy figure (0000 p.1). Colour has a fixed role: blue = given/derivation, red = warning or the open question, purple = annotation/shape, green = "correct" / the question to prove.
20
+ - R10. Warnings are explicit and loud: "WRONG!" box with the reason (0004 p.5), "The l3 we computed is wrong! Here it is wrong!" (0003 p.3). A "THE IDEA" boxed summary precedes the fix (0004 p.6).
21
+ - R11. Tone: second person plural, conversational ("Let's restore it... with a twist!", "Let's multiply by V", "Lots of patience", 0000 p.3). Short sentences, no citations inside notes; the only external reference is a pasted algorithm (FlashAttention-2 Algorithm 1, 0004 p.9) annotated by hand.
22
+ - R12. Complexity is stated per step (time and memory reads, 0002 p.3).
23
+ - R13. Notation: m_i (running max), l_i (normaliser), O_i, S = QK^T, P = softmax(S), blocks Q_i, K_j, V_j; softmax* for the unnormalised block softmax (0004 p.4); upstream/local/downstream gradient vocabulary (0008 p.9).
24
+
25
+ ## 2. Mapping to the typeset notes (what "same style" means here)
26
+
27
+ | Reference | Typeset equivalent used |
28
+ |---|---|
29
+ | R1 file naming | already `NNNN - Title.pdf`; kept |
30
+ | R5 question first | `problem` box titled "The problem" whose first line is `\question{...}` |
31
+ | R6 motivation -> math -> proof | Notation, Derivation (proofs inline), unchanged content |
32
+ | R7 shapes and a running toy | the notes already carry toy numbers in `\ver{...}` lines and notation tables; not altered |
33
+ | R10 warnings | `What breaks` box (all 24) |
34
+ | R9 "THE IDEA" boxed summary | `Boxed result` box (all 24) |
35
+ | (flow to next page) | `Next` box (all 24) |
36
+
37
+ ## 3. Gaps found (before the change)
38
+
39
+ Compared 0107 (M1), 0203 (M2), 0304 (M3) and then grepped all 24.
40
+
41
+ Three series had three different style files and three different skeletons:
42
+ - Header: M1 `\hdr` (no "Note" word, grey series line), M2/M3 `\notehead` ("Note NNNN -- ...").
43
+ - Opening: M1 grey "The problem this solves" box; M2 numbered section "The problem this solves"; M3 numbered section "The problem this concept solves". None opened with an explicit question (R5), although many posed one inside the paragraph.
44
+ - Result: M1 yellow "Result"; M2 blue box; M3 blue box, section called "Boxed final results" (M3) vs "Boxed result" (M1 box with no section, M2).
45
+ - Failure modes: M1 box "What breaks" under a section of the same name; M2 box with title override; M3 plain section (0301 titled "What breaks (failure modes and the assumption each violates)").
46
+ - Next: M1 blue box; M2/M3 bold inline line under "Link to the next note" (M2) or none (M3).
47
+ - References: M2 had `\section{References}` followed by `thebibliography` (duplicate heading).
48
+ - Page: M1 1in margins, 11pt, colour links; M2 2.2cm; M3 2.2cm with Unicode/Lean listings; M2 10pt, M1/M3 11pt.
49
+ - Macro clashes between the files: `\ind` (bracketed one-argument in M1, bare symbol in M2), `\Pr` (operator vs blackboard P), `\nextnote` (environment vs command).
50
+
51
+ Not present in any of the 24 (and not added, because it would be new content): hand-drawn style figures (R9), a "WRONG!" worked-trace page (R10), complexity-per-step tables (R12), a Table of Contents note (R1 0000), a prerequisites page.
52
+
53
+ ## 4. What I changed
54
+
55
+ Style infrastructure
56
+ - `notes/src/common.sty` replaces `common-m1.sty`, `common-m2.sty`, `common-m3.sty` (the three are deleted; originals backed up in `/home/vignesh/.claude/jobs/b952890a/tmp/notes_style/backup/`). It is the union of the three: geometry 2.2cm, 11pt documents, `hidelinks`, Cerulean result box, BrickRed breaks box, grey problem box, blue Next box, Lean `listings` and the Unicode declarations, all shared macros.
57
+ - `\ind` is the bare symbol everywhere; the bracketed M1 form is now `\indb{...}` (renamed in 0101-0110 only; same rendering as before). `\Pr` is `\mathbb{P}` in all notes (0301, 0302, 0304, 0305 used the default operator "Pr"; only the glyph changes).
58
+ - `notes/src/build_all.sh` rebuilds all 24 (or a subset: `./build_all.sh 0107 0203`), takes the PDF file name from the existing `../NNNN - *.pdf`. `build.sh` and `build_m1.sh` still work unchanged.
59
+
60
+ All 24 `.tex` files
61
+ - `\usepackage{common}`, `\documentclass[11pt]`.
62
+ - One header, `\notehead{NNNN}{title}{subtitle}` (M1's `\hdr` converted; subtitle text kept: series line plus companion notebook path).
63
+ - One opening: a grey "The problem" box whose first line is a new one-sentence `\question{...}` (24 questions, each only restating the question the existing paragraph already poses; no new claim, no number), followed by the original paragraph untouched. For M2/M3 the old numbered section was converted into this box.
64
+ - One tail: `Boxed result` section, `What breaks` section with the red box (M2/M3 explicit title override dropped, the default is the same text), `Next` box, `References` (M2/M3 duplicate heading removed).
65
+ - Section numbers: the box does not take a number in M1 (Notation = 1, Derivation = 2). In M2/M3 the old problem section was section 1, so I inserted `\stepcounter{section}` after the box so Notation stays 2 and Derivation 3. Reason: many text and notebook cross-references use those numbers (for example 0301 "\S3.3", 0302 notebook "section 3.4", 0304 notebook "section 3", 0203 notebook "section 3", 0102 cited as "Sec.~2.5" from 0105). Renumbering needs a coordinated edit of notes and notebooks and was not done. Consequence: M1 numbers sections from 1, M2/M3 from 2.
66
+
67
+ Rebuild
68
+ - 24 of 24 PDFs rebuilt with latexmk, 0 failures, 0 undefined references/citations. Page counts: 0103 5->4, 0201 5->6, 0202 5->6, 0204 5->6, 0205 5->6, 0206 5->6, 0207 5->6, 0302 6->7, 0304 6->7, all others unchanged. Text diff of old vs new PDFs (word multiset) shows only header, section-number, heading, box-title and the 24 added `The question.` lines; no equation, number or verified-result line changed.
69
+ - `notes/src/build/m1/` (stale build artefacts of the old M1 script) was deleted; `notes/src/build/all/` is the new build dir.
70
+
71
+ Notebooks
72
+ - Not changed. They already follow the reference's naive -> efficient progression with asserts and verification tables (0107 section 7 "Naive vs efficient implementation"; 0203 sections 1-3 naive exact/float vs stable form; 0304 "A1 NAIVE ... A2 EFFICIENT"). The reference notebook has no asserts and a single `scratch-pad.ipynb`; ours are per-note numbered `NNNN-slug.ipynb`, which I kept (the numbered naming matches R1 and the .tex/PDF linkage). Nothing was re-executed; no Lean notebook touched.
73
+
74
+ ## 5. Not aligned (left deliberately)
75
+
76
+ 1. Handwritten diagram/colour language (R9, R10), worked trace on one toy vector (R7), per-step complexity (R12): would be new content. A reasonable follow-up is a `\begin{warning}` "WRONG!" box and a 4-element trace per note, authored by hand per topic.
77
+ 2. 0000 Table of Contents note and a prerequisites page: not requested; a generated `0000 - Table of Contents.pdf` from the 24 titles is mechanical if wanted.
78
+ 3. Section numbering offset between M1 and M2/M3 (see above).
79
+ 4. M3 notes keep their `\paragraph{Numerical verification (notebook ...)}` block while M1 uses inline `\ver{...}` tags and M2 mixes both; both carry the same information, and merging them touches content layout.
80
+ 5. Series subtitle wording still differs ("Batch M1/M2", M3 has descriptive text).
81
+ 6. Notebook per-note files vs reference single scratch-pad: kept.
82
+
83
+ ## 6. Possible content issues (not edited)
84
+
85
+ - `notebooks/0101-autoregressive-likelihood-sft.ipynb` cell 2 cites "(note, Sec. 3.1)" for the proper-distribution lemma; in the M1 numbering the lemma sits under Section 2 (Derivation, "Step 2 (Lemma 1)"), so this reference looks stale. Not verified against the notebook text beyond the grep.
86
+ - No mathematical claim or number was found to be wrong during this pass (this was a style pass; the content was not re-derived).
code/scripts/bench_7b_end_to_end.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark #2: end-to-end step time on the REAL 7B, triton_fa2 vs SDPA.
2
+
3
+ Run (GPU 3 window; pin BEFORE torch import):
4
+ CUDA_VISIBLE_DEVICES=3 TMPDIR=/apps/development/rlhf/nano-mistral-o1/.tmp \
5
+ uv run python scripts/bench_7b_end_to_end.py
6
+
7
+ WHAT THIS MEASURES, AND WHAT IT DELIBERATELY DOES NOT
8
+ -----------------------------------------------------
9
+ A full-parameter 7B training step does NOT fit on one A10G: bf16 weights are
10
+ 13.5 GiB and bf16 parameter gradients are another 13.5 GiB, so 27 GiB against a
11
+ 23 GiB card. Real SFT gets around this with 4-way FSDP plus an offloaded
12
+ optimizer (section-07/11); that is not reproducible inside a single-GPU window,
13
+ and pretending otherwise would be measuring a different thing than the label.
14
+
15
+ So this benchmark freezes every parameter and requires grad on `inputs_embeds`
16
+ instead. Backward still flows through every attention and MLP in all 32 layers,
17
+ computing the full activation-gradient chain -- dQ/dK/dV included, which is
18
+ exactly the part the attention backend controls. What is skipped is the
19
+ per-parameter weight-gradient GEMM, which is IDENTICAL for both backends.
20
+
21
+ The consequence is worth stating precisely, because it decides how to read the
22
+ result: the absolute per-step delta (triton minus sdpa, in ms) is exactly what
23
+ it would be in a full step, since the omitted work is backend-independent and
24
+ additive. The delta as a PERCENTAGE is inflated, because the denominator is
25
+ missing that same backend-independent work. **This benchmark therefore reports
26
+ an UPPER BOUND on the true percentage cost of choosing triton_fa2.** A number
27
+ that bounds the answer from the safe side is worth more here than a number that
28
+ requires four GPUs and a training run to obtain.
29
+
30
+ Both no-checkpointing and activation-checkpointing configurations are measured:
31
+ under checkpointing the attention forward runs a second time during backward,
32
+ so the backend-attributable share is larger, and SFT is expected to checkpoint.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import glob
38
+ import json
39
+ import os
40
+ import sys
41
+ import time
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
44
+
45
+ from align.utils.guard import apply_resource_guards
46
+
47
+ # Light job by the box rules: no heavy lock (other GPU jobs are live), no
48
+ # dataloaders, no multi-GB writes.
49
+ apply_resource_guards(cpu_threads=8, heavy=False, label="bench-7b-attn")
50
+
51
+ import torch
52
+ from torch.utils.checkpoint import checkpoint as ckpt_fn
53
+
54
+ from align.model import checkpoint as ckpt
55
+ from align.model.mistral import NmoMistralForCausalLM
56
+
57
+ REPO = "/apps/development/rlhf/nano-mistral-o1"
58
+ OUT = f"{REPO}/docs/bench_7b_end_to_end.json"
59
+ SNAPSHOT = (
60
+ "/apps/voxtral/.cache/huggingface/hub/"
61
+ "models--mistralai--Mistral-7B-v0.3/snapshots/"
62
+ "caa1feb0e54d415e2df31207e5f4e273e33509b1"
63
+ )
64
+
65
+ SEQ = 1024
66
+ BATCH = 1
67
+ #: Hard wall-clock budget. The GPU window is shared and SFT launches the moment
68
+ #: the golden gate passes; a benchmark that overstays blocks a training run.
69
+ DEADLINE_S = float(os.environ.get("NMO_BENCH_DEADLINE_S", 1500))
70
+ _START = time.time()
71
+
72
+
73
+ def _left() -> float:
74
+ return DEADLINE_S - (time.time() - _START)
75
+
76
+
77
+ def _load_7b(attn_impl: str, state_dict, cfg):
78
+ model = NmoMistralForCausalLM.from_state_dict(cfg, state_dict, attn_implementation=attn_impl)
79
+ model = model.to(torch.bfloat16).to("cuda").eval()
80
+ # Freeze: no 13.5 GiB parameter-gradient buffer. See module docstring.
81
+ for p in model.parameters():
82
+ p.requires_grad_(False)
83
+ return model
84
+
85
+
86
+ def _blocks(model):
87
+ inner = model.model if hasattr(model, "model") else model
88
+ for name in ("layers", "blocks", "h"):
89
+ if hasattr(inner, name):
90
+ return getattr(inner, name)
91
+ raise AttributeError("cannot find the decoder block list on the model")
92
+
93
+
94
+ def _timed(fn, iters, warmup):
95
+ for _ in range(warmup):
96
+ fn()
97
+ torch.cuda.synchronize()
98
+ t0 = time.perf_counter()
99
+ for _ in range(iters):
100
+ fn()
101
+ torch.cuda.synchronize()
102
+ return (time.perf_counter() - t0) / iters * 1e3
103
+
104
+
105
+ def _run_config(model, embeds, labels, use_ckpt: bool, iters: int):
106
+ """fwd-only and fwd+bwd timings for one model/checkpointing combination."""
107
+ blocks = _blocks(model)
108
+ originals = [b.forward for b in blocks]
109
+ if use_ckpt:
110
+ for b in blocks:
111
+ fwd = b.forward
112
+ b.forward = lambda *a, _f=fwd, **kw: ckpt_fn(_f, *a, use_reentrant=False, **kw)
113
+
114
+ try:
115
+
116
+ def fwd_only():
117
+ with torch.no_grad():
118
+ model(inputs_embeds=embeds.detach())
119
+
120
+ def fwd_bwd():
121
+ if embeds.grad is not None:
122
+ embeds.grad = None
123
+ logits = model(inputs_embeds=embeds)
124
+ loss = torch.nn.functional.cross_entropy(
125
+ logits.float().view(-1, logits.shape[-1]), labels.view(-1)
126
+ )
127
+ loss.backward()
128
+
129
+ torch.cuda.reset_peak_memory_stats()
130
+ f = _timed(fwd_only, iters, 2)
131
+ fb = _timed(fwd_bwd, iters, 2)
132
+ peak = torch.cuda.max_memory_allocated() / 2**30
133
+ finally:
134
+ # `b.forward = original_bound_method` would leave the instance holding a
135
+ # bound method that holds the instance -- a reference cycle the caching
136
+ # allocator cannot reclaim on `empty_cache()`, which OOM'd the second
137
+ # model load. Deleting the instance attribute restores the class method
138
+ # and leaves no cycle.
139
+ for b in blocks:
140
+ if "forward" in b.__dict__:
141
+ del b.__dict__["forward"]
142
+ del originals
143
+ return {"fwd_ms": f, "fwd_bwd_ms": fb, "peak_gib": peak}
144
+
145
+
146
+ def main() -> int:
147
+ if torch.cuda.device_count() != 1:
148
+ print(
149
+ f"{torch.cuda.device_count()} devices visible; pin exactly one "
150
+ "(shared box, three other GPU jobs live)",
151
+ file=sys.stderr,
152
+ )
153
+ return 1
154
+ free_gib = torch.cuda.mem_get_info()[0] / 2**30
155
+ print(f"device: {torch.cuda.get_device_name(0)} free {free_gib:.1f} GiB")
156
+ if free_gib < 20.0:
157
+ print(
158
+ "refusing: need ~20 GiB free for 13.5 GiB of bf16 weights plus "
159
+ "activations; another job is resident on this device.",
160
+ file=sys.stderr,
161
+ )
162
+ return 1
163
+
164
+ from align.eval.golden import resolve_model_config
165
+
166
+ cfg = resolve_model_config(SNAPSHOT)
167
+ print(
168
+ f"config: {cfg.n_layers}L hidden={cfg.hidden_size} "
169
+ f"{cfg.n_heads}q:{cfg.n_kv_heads}kv head_dim={cfg.head_dim} "
170
+ f"vocab={cfg.vocab_size} sliding_window={cfg.sliding_window}"
171
+ )
172
+
173
+ import safetensors.torch as st
174
+
175
+ t0 = time.time()
176
+ hf_sd = {}
177
+ for shard in sorted(glob.glob(os.path.join(SNAPSHOT, "*.safetensors"))):
178
+ hf_sd.update(st.load_file(shard))
179
+ nmo_sd = ckpt.hf_to_nmo(hf_sd, expected_n_layers=cfg.n_layers)
180
+ del hf_sd
181
+ print(f"loaded + remapped {len(nmo_sd)} tensors in {time.time() - t0:.1f}s")
182
+
183
+ torch.manual_seed(0)
184
+ labels = torch.randint(0, cfg.vocab_size, (BATCH, SEQ), device="cuda")
185
+ results = {}
186
+
187
+ # One backend per PROCESS when an arg is given. 13.5 GiB of weights leaves
188
+ # no room for a second model, and process exit is the only VRAM release
189
+ # that cannot be defeated by a stray reference. Partials are merged below.
190
+ only = sys.argv[1] if len(sys.argv) > 1 else None
191
+ impls = (only,) if only else ("sdpa", "triton_fa2")
192
+
193
+ for impl in impls:
194
+ if _left() < 240:
195
+ print(f"\ndeadline guard: {_left():.0f}s left, skipping {impl}", file=sys.stderr)
196
+ break
197
+ print(f"\n=== {impl} ===", flush=True)
198
+ model = _load_7b(impl, nmo_sd, cfg)
199
+ embeds = torch.randn(
200
+ BATCH, SEQ, cfg.hidden_size, device="cuda", dtype=torch.bfloat16, requires_grad=True
201
+ )
202
+ results[impl] = {}
203
+ for use_ckpt in (False, True):
204
+ if _left() < 120:
205
+ print(
206
+ f"deadline guard: {_left():.0f}s left, skipping {impl}/ckpt={use_ckpt}",
207
+ file=sys.stderr,
208
+ )
209
+ break
210
+ r = _run_config(model, embeds, labels, use_ckpt, iters=5)
211
+ key = "activation_ckpt" if use_ckpt else "no_ckpt"
212
+ results[impl][key] = r
213
+ print(
214
+ f" {key:16s} fwd {r['fwd_ms']:8.1f} ms "
215
+ f"fwd+bwd {r['fwd_bwd_ms']:8.1f} ms peak {r['peak_gib']:5.2f} GiB",
216
+ flush=True,
217
+ )
218
+ del model, embeds
219
+ import gc
220
+
221
+ gc.collect()
222
+ torch.cuda.empty_cache()
223
+
224
+ # Merge any partial written by a sibling process, so running one backend
225
+ # per process still produces a single complete receipt.
226
+ partial = f"{REPO}/.tmp/bench7b_partial.json"
227
+ if only:
228
+ prev = {}
229
+ if os.path.isfile(partial):
230
+ with open(partial) as f:
231
+ prev = json.load(f)
232
+ prev.update(results)
233
+ with open(partial, "w") as f:
234
+ json.dump(prev, f, indent=2)
235
+ results = prev
236
+ print(f"\npartial results for {sorted(results)} -> {partial}")
237
+
238
+ payload = {
239
+ "note": "parameters FROZEN; grad flows via inputs_embeds. Absolute ms "
240
+ "deltas are exact; percentages are an UPPER BOUND on the true "
241
+ "cost, because the omitted weight-grad GEMMs are "
242
+ "backend-independent and would enlarge the denominator only. "
243
+ "See the module docstring.",
244
+ "device": torch.cuda.get_device_name(0),
245
+ "torch": torch.__version__,
246
+ "model": "mistralai/Mistral-7B-v0.3 (real weights)",
247
+ "batch": BATCH,
248
+ "seq": SEQ,
249
+ "dtype": "bfloat16",
250
+ "results": results,
251
+ }
252
+
253
+ if "sdpa" in results and "triton_fa2" in results:
254
+ payload["comparison"] = {}
255
+ print("\n=== triton_fa2 vs sdpa on the real 7B ===")
256
+ for key in ("no_ckpt", "activation_ckpt"):
257
+ if key in results["sdpa"] and key in results["triton_fa2"]:
258
+ s = results["sdpa"][key]["fwd_bwd_ms"]
259
+ t = results["triton_fa2"][key]["fwd_bwd_ms"]
260
+ c = {
261
+ "sdpa_ms": s,
262
+ "triton_ms": t,
263
+ "delta_ms": t - s,
264
+ "ratio": t / s,
265
+ "upper_bound_pct": 100.0 * (t - s) / s,
266
+ }
267
+ payload["comparison"][key] = c
268
+ print(
269
+ f" {key:16s} sdpa {s:8.1f} ms -> triton {t:8.1f} ms "
270
+ f"({c['delta_ms']:+.1f} ms, {c['upper_bound_pct']:+.1f}% "
271
+ "UPPER BOUND)"
272
+ )
273
+
274
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
275
+ with open(OUT, "w") as f:
276
+ json.dump(payload, f, indent=2)
277
+ f.write("\n")
278
+ print(f"\nwrote {OUT} ({_left():.0f}s of window left)")
279
+ return 0
280
+
281
+
282
+ if __name__ == "__main__":
283
+ raise SystemExit(main())
code/scripts/bench_flash_attention.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kernel-level benchmark: triton_fa2 vs torch SDPA at the real Mistral shapes.
2
+
3
+ Run (pin ONE free device; this is a light job and must NOT take the heavy lock):
4
+ CUDA_VISIBLE_DEVICES=<free gpu> uv run python scripts/bench_flash_attention.py
5
+
6
+ Shapes are Mistral-7B-v0.3's: b=4, 32 query heads : 8 KV heads, head_dim 128,
7
+ causal, bf16, seq {512, 1024, 2048}. The forward column is compared against the
8
+ baseline pinned in the plan artifact `tmp/sdpa_baseline.json`, measured on this
9
+ same box: 0.189 / 0.624 / 2.282 ms.
10
+
11
+ Purpose is to bound a decision, not to win: research (FA2 paper 2307.08691,
12
+ flash-attention#1556) says Triton loses to CUTLASS at head_dim 128 on Ampere,
13
+ ~1.3-1.5x forward and ~2x backward, and head_dim 128 is exactly Mistral's. At
14
+ seq 1024 attention is a few percent of layer FLOPs, so even a 2x backward loss
15
+ is a small single-digit share of step time. This script turns that into a
16
+ measured number so the SDPA-default decision is evidence rather than vibes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+
27
+ from align.utils.guard import apply_resource_guards
28
+
29
+ # Light job: no heavy lock, no 7B model. Still honors the path policy so no
30
+ # cache lands off /apps.
31
+ apply_resource_guards(cpu_threads=8, heavy=False, label="bench-flash-attention")
32
+
33
+ import torch
34
+ from torch.nn.attention import SDPBackend, sdpa_kernel
35
+
36
+ from align.kernels.flash_attention import flash_attention
37
+
38
+ REPO = "/apps/development/rlhf/align"
39
+ OUT = f"{REPO}/docs/bench_flash_attention.json"
40
+
41
+ #: Measured on this box during planning; see the module docstring.
42
+ PINNED_SDPA_FWD_MS = {512: 0.189, 1024: 0.624, 2048: 2.282}
43
+
44
+ B, HQ, HKV, D = 4, 32, 8, 128
45
+ SEQS = (512, 1024, 2048)
46
+
47
+
48
+ def _bench(fn, iters=30, warmup=10):
49
+ for _ in range(warmup):
50
+ fn()
51
+ torch.cuda.synchronize()
52
+ start = torch.cuda.Event(enable_timing=True)
53
+ end = torch.cuda.Event(enable_timing=True)
54
+ start.record()
55
+ for _ in range(iters):
56
+ fn()
57
+ end.record()
58
+ torch.cuda.synchronize()
59
+ return start.elapsed_time(end) / iters
60
+
61
+
62
+ def bench_layer_share(seq: int = 1024, batch: int = 2) -> dict:
63
+ """Put attention back in context: one full Mistral decoder layer, fwd+bwd.
64
+
65
+ Benchmark 1 gives the ratio ON the attention kernel; this converts it into
66
+ the number the SDPA-vs-triton decision actually turns on -- share of a
67
+ training step. A layer is built from Mistral-7B-v0.3's real dimensions
68
+ (hidden 4096, intermediate 14336, 32:8 heads, head_dim 128) so the MLP and
69
+ projection GEMMs are correctly sized against the attention core.
70
+
71
+ This is NOT the section-11/12 end-to-end number the section also asks for;
72
+ that one needs the real 7B weights and a GPU window, and is deferred to it.
73
+ A single layer times 32 is a tight bound on the attention share because the
74
+ per-layer work is identical across layers.
75
+ """
76
+ import torch.nn as nn
77
+
78
+ H, I = 4096, 14336
79
+ dev_t, dt = "cuda", torch.bfloat16
80
+ lin = lambda a, b: nn.Linear(a, b, bias=False, device=dev_t, dtype=dt)
81
+ wq, wk, wv, wo = lin(H, HQ * D), lin(H, HKV * D), lin(H, HKV * D), lin(HQ * D, H)
82
+ wg, wu, wd = lin(H, I), lin(H, I), lin(I, H)
83
+ x = torch.randn(batch * seq, H, device=dev_t, dtype=dt, requires_grad=True)
84
+
85
+ def layer(attn_fn):
86
+ def run():
87
+ x.grad = None
88
+ q = wq(x).view(batch, seq, HQ, D).transpose(1, 2)
89
+ k = wk(x).view(batch, seq, HKV, D).transpose(1, 2)
90
+ v = wv(x).view(batch, seq, HKV, D).transpose(1, 2)
91
+ a = attn_fn(q, k, v)
92
+ h = x + wo(a.transpose(1, 2).reshape(batch * seq, H))
93
+ out = h + wd(torch.nn.functional.silu(wg(h)) * wu(h))
94
+ out.sum().backward()
95
+
96
+ return run
97
+
98
+ def sdpa_attn(q, k, v):
99
+ with sdpa_kernel(SDPBackend.FLASH_ATTENTION):
100
+ return torch.nn.functional.scaled_dot_product_attention(
101
+ q, k, v, is_causal=True, enable_gqa=True
102
+ )
103
+
104
+ t_sdpa = _bench(layer(sdpa_attn), iters=20, warmup=8)
105
+ t_tri = _bench(layer(lambda q, k, v: flash_attention(q, k, v, causal=True)), iters=20, warmup=8)
106
+
107
+ out = {
108
+ "seq": seq,
109
+ "batch": batch,
110
+ "hidden": H,
111
+ "intermediate": I,
112
+ "n_layers": 32,
113
+ "layer_sdpa_ms": t_sdpa,
114
+ "layer_triton_ms": t_tri,
115
+ "layer_ratio": t_tri / t_sdpa,
116
+ "step_penalty_pct": 100.0 * (t_tri - t_sdpa) / t_sdpa,
117
+ "est_32layer_sdpa_s": t_sdpa * 32 / 1000.0,
118
+ "est_32layer_triton_s": t_tri * 32 / 1000.0,
119
+ }
120
+ print(f"\nlayer-level (Mistral decoder layer, b={batch} seq={seq}, fwd+bwd):")
121
+ print(f" SDPA {t_sdpa:7.2f} ms/layer -> {out['est_32layer_sdpa_s']:.2f} s / 32 layers")
122
+ print(f" triton {t_tri:7.2f} ms/layer -> {out['est_32layer_triton_s']:.2f} s / 32 layers")
123
+ print(f" cost of choosing triton_fa2: {out['step_penalty_pct']:+.1f}% of the 32-layer body")
124
+ return out
125
+
126
+
127
+ def main() -> int:
128
+ if not torch.cuda.is_available():
129
+ print("no CUDA device visible", file=sys.stderr)
130
+ return 1
131
+ if torch.cuda.device_count() != 1:
132
+ print(
133
+ f"{torch.cuda.device_count()} devices visible; pin exactly one "
134
+ "with CUDA_VISIBLE_DEVICES (shared box)",
135
+ file=sys.stderr,
136
+ )
137
+ return 1
138
+
139
+ dev = torch.cuda.get_device_name(0)
140
+ cc = ".".join(str(x) for x in torch.cuda.get_device_capability(0))
141
+ print(f"device: {dev} (sm{cc.replace('.', '')}) torch {torch.__version__}")
142
+ print(f"shapes: b={B}, {HQ}q:{HKV}kv heads, head_dim={D}, causal, bf16\n")
143
+
144
+ results = {}
145
+ for seq in SEQS:
146
+ q = torch.randn(B, HQ, seq, D, device="cuda", dtype=torch.bfloat16)
147
+ k = torch.randn(B, HKV, seq, D, device="cuda", dtype=torch.bfloat16)
148
+ v = torch.randn(B, HKV, seq, D, device="cuda", dtype=torch.bfloat16)
149
+ qg, kg, vg = (t.clone().requires_grad_() for t in (q, k, v))
150
+ dout = torch.randn_like(qg)
151
+
152
+ def sdpa_fwd():
153
+ with sdpa_kernel(SDPBackend.FLASH_ATTENTION):
154
+ return torch.nn.functional.scaled_dot_product_attention(
155
+ q, k, v, is_causal=True, enable_gqa=True
156
+ )
157
+
158
+ def tri_fwd():
159
+ return flash_attention(q, k, v, causal=True)
160
+
161
+ def sdpa_fwd_bwd():
162
+ for t in (qg, kg, vg):
163
+ t.grad = None
164
+ with sdpa_kernel(SDPBackend.FLASH_ATTENTION):
165
+ o = torch.nn.functional.scaled_dot_product_attention(
166
+ qg, kg, vg, is_causal=True, enable_gqa=True
167
+ )
168
+ o.backward(dout)
169
+
170
+ def tri_fwd_bwd():
171
+ for t in (qg, kg, vg):
172
+ t.grad = None
173
+ flash_attention(qg, kg, vg, causal=True).backward(dout)
174
+
175
+ row = {
176
+ "sdpa_fwd_ms": _bench(sdpa_fwd),
177
+ "triton_fwd_ms": _bench(tri_fwd),
178
+ "sdpa_fwd_bwd_ms": _bench(sdpa_fwd_bwd),
179
+ "triton_fwd_bwd_ms": _bench(tri_fwd_bwd),
180
+ "pinned_sdpa_fwd_ms": PINNED_SDPA_FWD_MS[seq],
181
+ }
182
+ row["fwd_ratio_triton_over_sdpa"] = row["triton_fwd_ms"] / row["sdpa_fwd_ms"]
183
+ row["fwd_bwd_ratio_triton_over_sdpa"] = row["triton_fwd_bwd_ms"] / row["sdpa_fwd_bwd_ms"]
184
+ row["sdpa_fwd_drift_vs_pinned"] = row["sdpa_fwd_ms"] / row["pinned_sdpa_fwd_ms"]
185
+ results[str(seq)] = row
186
+ # Rebind to None rather than `del`: the bench closures above hold these
187
+ # names as free variables, and `del` empties the closure cells (ruff
188
+ # F821; a later call would NameError). Rebinding frees the tensors just
189
+ # the same, so empty_cache() can actually reclaim.
190
+ q = k = v = qg = kg = vg = dout = None
191
+ torch.cuda.empty_cache()
192
+
193
+ hdr = (
194
+ f"{'seq':>6} | {'SDPA fwd':>9} {'pinned':>8} | {'triton fwd':>11} "
195
+ f"{'ratio':>7} | {'SDPA f+b':>9} {'triton f+b':>11} {'ratio':>7}"
196
+ )
197
+ print(hdr)
198
+ print("-" * len(hdr))
199
+ for seq in SEQS:
200
+ r = results[str(seq)]
201
+ print(
202
+ f"{seq:>6} | {r['sdpa_fwd_ms']:>9.3f} {r['pinned_sdpa_fwd_ms']:>8.3f} | "
203
+ f"{r['triton_fwd_ms']:>11.3f} {r['fwd_ratio_triton_over_sdpa']:>6.2f}x | "
204
+ f"{r['sdpa_fwd_bwd_ms']:>9.3f} {r['triton_fwd_bwd_ms']:>11.3f} "
205
+ f"{r['fwd_bwd_ratio_triton_over_sdpa']:>6.2f}x"
206
+ )
207
+
208
+ layer = bench_layer_share()
209
+
210
+ payload = {
211
+ "device": dev,
212
+ "compute_capability": cc,
213
+ "torch": torch.__version__,
214
+ "shape": {
215
+ "batch": B,
216
+ "q_heads": HQ,
217
+ "kv_heads": HKV,
218
+ "head_dim": D,
219
+ "causal": True,
220
+ "dtype": "bfloat16",
221
+ },
222
+ "results": results,
223
+ "layer_level": layer,
224
+ }
225
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
226
+ with open(OUT, "w") as f:
227
+ json.dump(payload, f, indent=2)
228
+ f.write("\n")
229
+ print(f"\nwrote {OUT}")
230
+ return 0
231
+
232
+
233
+ if __name__ == "__main__":
234
+ raise SystemExit(main())
code/scripts/build_data.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Build every corpus this project trains on, in the only legal order.
3
+
4
+ python scripts/build_data.py --out <build root> [--tokenizer <dir>] [--curation-target N]
5
+
6
+ Stages: eval sets -> decontamination hash set -> SFT -> Arm A -> Arm B.
7
+ Each refuses to run without its upstream receipt; the final manifest
8
+ (`<out>/manifest.json`) pins every content hash, every measured count against
9
+ its pinned value, reject and contamination counts, the length-bias baseline,
10
+ the delimiter ids, the tokenizer hash, and the dataset revisions. Sections
11
+ 11/12 quote that manifest in their launch banner and refuse to start without
12
+ it.
13
+
14
+ Host safety (this box, 186GB, NO swap, shared): `apply_resource_guards(
15
+ heavy=True)` runs the preflight and takes the exclusive job flock BEFORE
16
+ anything heavy, so this build cannot run concurrently with a training job --
17
+ that exact overlap killed the predecessor's first SFT run. Every corpus is
18
+ streamed; nothing is held resident.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import logging
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+
31
+ from align.utils.guard import apply_resource_guards # noqa: E402
32
+
33
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
34
+ logger = logging.getLogger("build_data")
35
+
36
+ DEFAULT_OUT = "/apps/development/rlhf/nano-mistral-o1/data"
37
+ TOKENIZER_SNAPSHOT = (
38
+ "/apps/voxtral/.cache/huggingface/hub/models--mistralai--Mistral-7B-v0.3/"
39
+ "snapshots/caa1feb0e54d415e2df31207e5f4e273e33509b1"
40
+ )
41
+ #: Byte-identical tokenizer files vendored in-repo (assets/tokenizer/.../PROVENANCE.md).
42
+ #: The HF-cache snapshot is NOT on every box; the build must not die on a missing
43
+ #: cache when the same four files are in the repo.
44
+ VENDORED_TOKENIZER = str(
45
+ Path(__file__).resolve().parent.parent / "assets" / "tokenizer" / "mistral-7b-v0.3"
46
+ )
47
+
48
+
49
+ def default_tokenizer_dir() -> str:
50
+ """The HF snapshot if it holds a tokenizer.json, else the vendored copy."""
51
+ for c in (TOKENIZER_SNAPSHOT, VENDORED_TOKENIZER):
52
+ if (Path(c) / "tokenizer.json").is_file():
53
+ return c
54
+ return TOKENIZER_SNAPSHOT # fails loudly in load_tokenizer, naming the path
55
+
56
+
57
+ def main() -> int:
58
+ ap = argparse.ArgumentParser()
59
+ ap.add_argument(
60
+ "--out", default=DEFAULT_OUT, help="build root; every path under it is asserted onto /apps"
61
+ )
62
+ ap.add_argument("--tokenizer", default=default_tokenizer_dir())
63
+ ap.add_argument(
64
+ "--curation-target",
65
+ type=int,
66
+ default=None,
67
+ help="Arm B curated pair count (default: the pinned 12,000)",
68
+ )
69
+ ap.add_argument(
70
+ "--max-len",
71
+ type=int,
72
+ default=2048,
73
+ help="build cutoff, deliberately above the 1024 training "
74
+ "seq_len: see PinnedYields.max_len for the measured "
75
+ "length distribution behind this choice",
76
+ )
77
+ ap.add_argument("--held-out-frac", type=float, default=0.05)
78
+ ap.add_argument("--seed", type=int, default=0)
79
+ ap.add_argument(
80
+ "--strict-pins",
81
+ default=True,
82
+ action=argparse.BooleanOptionalAction,
83
+ help="assert every measured count against its pinned "
84
+ "value. --no-strict-pins is RE-MEASUREMENT MODE: it "
85
+ "stamps pins_verified=false into the manifest and "
86
+ "sections 11/12 refuse to launch against it.",
87
+ )
88
+ ap.add_argument(
89
+ "--strict-host-check",
90
+ default=True,
91
+ action=argparse.BooleanOptionalAction,
92
+ help="disable ONLY with a deliberate reason; the check is "
93
+ "the guard that did not exist on 2026-08-12",
94
+ )
95
+ args = ap.parse_args()
96
+
97
+ apply_resource_guards(
98
+ cpu_threads=8, heavy=True, label="build_data", strict_host_check=args.strict_host_check
99
+ )
100
+
101
+ from align.data.build import PinnedYields, build_all
102
+ from align.data.sources import DataSources
103
+ from align.tokenizer import load_tokenizer
104
+
105
+ tokenizer = load_tokenizer(args.tokenizer)
106
+ sources = DataSources.from_local_hub()
107
+
108
+ kwargs = {
109
+ "max_len": args.max_len,
110
+ "held_out_frac": args.held_out_frac,
111
+ "seed": args.seed,
112
+ "strict": args.strict_pins,
113
+ }
114
+ if args.curation_target is not None:
115
+ kwargs["arm_b_curated"] = args.curation_target
116
+ pins = PinnedYields(**kwargs)
117
+
118
+ manifest = build_all(sources, args.out, tokenizer, pins=pins)
119
+ print(json.dumps(manifest, indent=1))
120
+ logger.info("build complete: %s", Path(args.out) / "manifest.json")
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
code/scripts/build_stageb_data.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the stage-b GRPO prompt sets: keep-band filter, easy-first bands, decontamination.
2
+
3
+ Consumes Big-Math (primary) and DeepMath (comparison arm) via the pinned
4
+ streaming readers; emits per-band prompt JSONL under data/stageb/ plus a
5
+ manifest receipt. Prompts only -- GRPO tokenizes at rollout time; there is
6
+ no pair/tokenization build here.
7
+
8
+ Decontamination is CHECKED, not assumed: Big-Math's card claims benchmark
9
+ decontamination, but it contains GSM8K-train by construction and our
10
+ primary eval is GSM8K-test -- so every kept problem is joined (normalized
11
+ text) against GSM8K-test and MATH-500 and overlaps are EXCLUDED AND
12
+ COUNTED. An eval problem inside the RL prompt pool would corrupt the
13
+ primary contrast in the direction of the hypothesis, which is the one
14
+ direction a defect must never point.
15
+
16
+ python scripts/build_stageb_data.py \
17
+ --keep-band 0.3 0.95 --band-edges 0.3 0.5 --out data/stageb
18
+ """
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ # The package is not installed in the venv; without this a bare `python scripts/build_stageb_data.py` (the documented
28
+ # launch line) dies with ModuleNotFoundError unless PYTHONPATH is exported (same fix as scripts/sft.py).
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+
31
+ from align.data.sources import DataSources, normalize_problem, read_gsm8k_test, read_math500
32
+ from align.data.stageb_sources import (
33
+ BIGMATH_PIN,
34
+ DEEPMATH_PIN,
35
+ StageBSources,
36
+ assign_difficulty_bands,
37
+ iter_bigmath,
38
+ iter_deepmath,
39
+ )
40
+
41
+ STAGEB_HUB = "/apps/development/rlhf/hf_cache/hub"
42
+
43
+
44
+ def _eval_text_index(sources: DataSources) -> dict[str, str]:
45
+ idx: dict[str, str] = {}
46
+ for row in read_gsm8k_test(sources):
47
+ idx[normalize_problem(row["question"])] = "gsm8k_test"
48
+ for row in read_math500(sources):
49
+ idx[normalize_problem(row["problem"])] = "math500"
50
+ return idx
51
+
52
+
53
+ def _write_bands(name, bands, manifest, out_dir, problem_key, answer_key):
54
+ os.makedirs(out_dir, exist_ok=True)
55
+ counts = []
56
+ for i, band in enumerate(bands):
57
+ path = os.path.join(out_dir, f"{name}_band{i}.jsonl")
58
+ with open(path + ".partial", "w") as f:
59
+ for row in band:
60
+ f.write(
61
+ json.dumps(
62
+ {
63
+ "problem": row[problem_key],
64
+ "answer": str(row[answer_key]),
65
+ "difficulty": row.get("difficulty"),
66
+ "band": i,
67
+ "source": row.get("source") or name,
68
+ }
69
+ )
70
+ + "\n"
71
+ )
72
+ os.replace(path + ".partial", path)
73
+ with open(path, "rb") as f:
74
+ sha = hashlib.sha256(f.read()).hexdigest()[:16]
75
+ counts.append({"band": i, "n": len(band), "file": path, "sha256_16": sha})
76
+ manifest[name] = {"bands": counts, **manifest.get(name, {})}
77
+
78
+
79
+ def main() -> int:
80
+ p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
81
+ p.add_argument(
82
+ "--keep-band",
83
+ nargs=2,
84
+ type=float,
85
+ default=[0.3, 0.95],
86
+ metavar=("LO", "HI"),
87
+ help="solve-rate keep band [LO, HI) -- Big-Math only",
88
+ )
89
+ p.add_argument(
90
+ "--band-edges",
91
+ nargs="+",
92
+ type=float,
93
+ default=[0.3, 0.5],
94
+ help="difficulty (=1-solve_rate) edges WITHIN the keep band, easy band first",
95
+ )
96
+ p.add_argument(
97
+ "--deepmath-edges",
98
+ nargs="+",
99
+ type=float,
100
+ default=[4.0, 6.5],
101
+ help="DeepMath native-difficulty edges",
102
+ )
103
+ p.add_argument("--out", default="data/stageb")
104
+ args = p.parse_args()
105
+
106
+ sources = DataSources.from_local_hub()
107
+ eval_idx = _eval_text_index(sources)
108
+ sb = StageBSources.from_local_hub(hub=STAGEB_HUB, need_openmath=False, need_bigmath=True)
109
+ manifest: dict = {
110
+ "pins": {"bigmath": BIGMATH_PIN.revision, "deepmath": DEEPMATH_PIN.revision},
111
+ "keep_band_solve_rate": args.keep_band,
112
+ "band_edges_difficulty": args.band_edges,
113
+ "deepmath_edges": args.deepmath_edges,
114
+ }
115
+
116
+ # ---- Big-Math: keep-band + decontamination + banding
117
+ lo, hi = args.keep_band
118
+ kept, dropped_band, contaminated = [], 0, {"gsm8k_test": 0, "math500": 0}
119
+ for row in iter_bigmath(sb):
120
+ d = row.get("difficulty")
121
+ if d is None:
122
+ continue # 4 unparseable rows, counted in the banding manifest already
123
+ sr = 1.0 - d
124
+ if not (lo <= sr < hi):
125
+ dropped_band += 1
126
+ continue
127
+ hit = eval_idx.get(normalize_problem(row["problem"]))
128
+ if hit:
129
+ contaminated[hit] += 1
130
+ continue
131
+ kept.append(row)
132
+ bands, band_manifest = assign_difficulty_bands(iter(kept), edges=args.band_edges)
133
+ manifest["bigmath"] = {
134
+ "kept": len(kept),
135
+ "dropped_outside_keep_band": dropped_band,
136
+ "excluded_eval_overlap": contaminated,
137
+ "banding": band_manifest.as_log_dict(),
138
+ }
139
+ _write_bands("bigmath", bands, manifest, args.out, "problem", "answer")
140
+
141
+ # ---- DeepMath comparison arm: banding on native difficulty + decontamination
142
+ dm_kept, dm_contam = [], {"gsm8k_test": 0, "math500": 0}
143
+ for row in iter_deepmath(sb):
144
+ hit = eval_idx.get(normalize_problem(row["question"]))
145
+ if hit:
146
+ dm_contam[hit] += 1
147
+ continue
148
+ dm_kept.append(row)
149
+ dm_bands, dm_manifest = assign_difficulty_bands(iter(dm_kept), edges=args.deepmath_edges)
150
+ manifest["deepmath"] = {
151
+ "kept": len(dm_kept),
152
+ "excluded_eval_overlap": dm_contam,
153
+ "banding": dm_manifest.as_log_dict(),
154
+ }
155
+ _write_bands("deepmath", dm_bands, manifest, args.out, "question", "final_answer")
156
+
157
+ mpath = os.path.join(args.out, "manifest.json")
158
+ with open(mpath + ".partial", "w") as f:
159
+ json.dump(manifest, f, indent=1)
160
+ os.replace(mpath + ".partial", mpath)
161
+ print(json.dumps({k: v for k, v in manifest.items() if k in ("bigmath", "deepmath")}, indent=1))
162
+ print(f"manifest: {mpath}")
163
+ return 0
164
+
165
+
166
+ if __name__ == "__main__":
167
+ import sys
168
+
169
+ sys.exit(main())
code/scripts/ceiling_test.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage-b reward-path ceiling test: a PERFECT policy must score ~100%.
2
+
3
+ House rule from a paid-for incident (mistral-prm-ppo, 2026-08-14): two PPO
4
+ runs measured a broken answer-extractor -- a perfect policy scored 0/100.
5
+ No GRPO rollout launches until this driver certifies the EXACT reward path
6
+ (extract_answer -> OutcomeVerifier -> CompositeReward + format instrument)
7
+ at >= --floor on every gold source, with every miss listed in the receipt
8
+ for eyeballing.
9
+
10
+ CPU-only, no model: each (problem, gold) becomes
11
+ `build_perfect_completion(gold, eos)` -- byte-identical to what a policy
12
+ flawlessly imitating its SFT targets emits, EOS included (rollout decodes
13
+ with skip_special_tokens=False).
14
+
15
+ Sources: gsm8k (1,319), math500 (500), deepmath (--deepmath-n sampled
16
+ evenly across shards; stage-b's actual gold distribution -- the reason this
17
+ must re-run here and not only on the stage-a sets).
18
+
19
+ python scripts/ceiling_test.py --deepmath-n 5000
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ # Run from a checkout without an install: put the repo root on sys.path first.
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+
31
+ from align.data.schema import extract_answer, register_answer_suffixes
32
+ from align.data.sources import DataSources, gsm8k_gold_answer, read_gsm8k_test, read_math500
33
+ from align.data.stageb_sources import StageBSources, iter_bigmath, iter_deepmath
34
+ from align.eval.roundtrip import build_perfect_completion
35
+ from align.eval.verifier import OutcomeVerifier, VerifierConfig
36
+ from align.rl.format_instrument import format_scores
37
+ from align.rl.rewards import CompositeReward
38
+
39
+ EOS = "</s>"
40
+
41
+
42
+ #: The corpora live in two hubs: the stage-a pins under sources.DEFAULT_HUB
43
+ #: (the voxtral cache this project inherited), the stage-b downloads under
44
+ #: the rlhf hf_cache. Resolved per-source-set, not via one env var.
45
+ STAGEB_HUB = "/apps/development/rlhf/hf_cache/hub"
46
+
47
+
48
+ def _gold_sources(args) -> dict[str, list[str]]:
49
+ out: dict[str, list[str]] = {}
50
+ sources = DataSources.from_local_hub() # DEFAULT_HUB: gsm8k/math500 pins
51
+ if args.gsm8k:
52
+ out["gsm8k"] = [gsm8k_gold_answer(r) for r in read_gsm8k_test(sources)]
53
+ if args.math500:
54
+ out["math500"] = [r["answer"] for r in read_math500(sources)]
55
+ if args.deepmath_n or args.bigmath_n:
56
+ sb = StageBSources.from_local_hub(
57
+ hub=args.stageb_hub,
58
+ need_deepmath=bool(args.deepmath_n),
59
+ need_openmath=False,
60
+ need_bigmath=bool(args.bigmath_n),
61
+ )
62
+ for key, it, total, n, field in (
63
+ ("deepmath", iter_deepmath, 103_110, args.deepmath_n, "final_answer"),
64
+ ("bigmath", iter_bigmath, 251_122, args.bigmath_n, "answer"),
65
+ ):
66
+ if not n:
67
+ continue
68
+ golds: list[str] = []
69
+ for i, row in enumerate(it(sb)):
70
+ # even sample across the stream without materializing the corpus
71
+ if i % max(1, total // n) == 0:
72
+ golds.append(str(row[field]))
73
+ if len(golds) >= n:
74
+ break
75
+ out[key] = golds
76
+ return out
77
+
78
+
79
+ def main() -> int:
80
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
81
+ parser.add_argument("--floor", type=float, default=0.98)
82
+ parser.add_argument("--gsm8k", action=argparse.BooleanOptionalAction, default=True)
83
+ parser.add_argument("--math500", action=argparse.BooleanOptionalAction, default=True)
84
+ parser.add_argument("--deepmath-n", type=int, default=5000)
85
+ parser.add_argument("--bigmath-n", type=int, default=5000)
86
+ parser.add_argument(
87
+ "--workers", type=int, default=4, help="verifier procs; keep <=4 on the shared box"
88
+ )
89
+ parser.add_argument("--out", default="outputs/eval/ceiling_test.json")
90
+ parser.add_argument("--stageb-hub", default=STAGEB_HUB)
91
+ args = parser.parse_args()
92
+
93
+ register_answer_suffixes(EOS) # BEFORE any extraction (golden.py's incident)
94
+ verifier = OutcomeVerifier(VerifierConfig(workers=args.workers))
95
+ reward = CompositeReward(format_weight=0.1) # overlong off: length is not under test
96
+
97
+ report: dict = {"floor": args.floor, "eos": EOS, "sources": {}}
98
+ overall_pass = True
99
+ for name, golds in _gold_sources(args).items():
100
+ completions = [build_perfect_completion(g, EOS) for g in golds]
101
+ preds = [extract_answer(c) for c in completions]
102
+ extract_ok = sum(1 for p in preds if p is not None)
103
+ outcomes = verifier.verify_batch(golds, preds)
104
+ correct = [o.name == "CORRECT" for o in outcomes]
105
+ fscores = [format_scores(c)["format/score"] for c in completions]
106
+ rewards = [
107
+ reward(accuracy=1.0 if ok else 0.0, format_score=f, response_len=0)["reward/total"]
108
+ for ok, f in zip(correct, fscores)
109
+ ]
110
+ ceiling = sum(correct) / len(golds) if golds else float("nan")
111
+ misses = [
112
+ {"gold": g, "pred": p, "outcome": o.name}
113
+ for g, p, o, ok in zip(golds, preds, outcomes, correct)
114
+ if not ok
115
+ ]
116
+ passed = ceiling >= args.floor
117
+ overall_pass &= passed
118
+ report["sources"][name] = {
119
+ "n": len(golds),
120
+ "extract_ok_rate": extract_ok / len(golds),
121
+ "verify_correct_rate": ceiling,
122
+ "mean_format_score": sum(fscores) / len(fscores),
123
+ "mean_reward_total": sum(rewards) / len(rewards),
124
+ "passed": passed,
125
+ "misses": misses[:50],
126
+ "misses_total": len(misses),
127
+ }
128
+ print(
129
+ f"{name}: n={len(golds)} extract_ok={extract_ok / len(golds):.4f} "
130
+ f"ceiling={ceiling:.4f} misses={len(misses)} -> {'PASS' if passed else 'FAIL'}",
131
+ flush=True,
132
+ )
133
+
134
+ report["overall_pass"] = bool(overall_pass)
135
+ os.makedirs(os.path.dirname(args.out), exist_ok=True)
136
+ tmp = args.out + ".partial"
137
+ with open(tmp, "w") as f:
138
+ json.dump(report, f, indent=1)
139
+ os.replace(tmp, args.out)
140
+ print(f"receipt: {args.out} | overall_pass={overall_pass}")
141
+ if not overall_pass:
142
+ print(
143
+ "CEILING FAIL: eyeball every miss in the receipt before touching the "
144
+ "verifier -- a representational-equivalence gap and a broken extractor "
145
+ "look identical in the rate and completely different in the misses.",
146
+ file=sys.stderr,
147
+ )
148
+ return 0 if overall_pass else 1
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())
code/scripts/ckpt_convert.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Sharded checkpoint tools: inspect, verify, export to HF safetensors, reshard.
3
+
4
+ python scripts/ckpt_convert.py inspect RUN/checkpoints/micro_N
5
+ python scripts/ckpt_convert.py verify RUN/checkpoints/micro_N
6
+ python scripts/ckpt_convert.py export RUN/checkpoints/micro_N OUT_DIR
7
+ python scripts/ckpt_convert.py reshard RUN/checkpoints/micro_N NEW_DIR --world-size 2
8
+
9
+ CPU only, no GPU, no process group, no heavy-job lock: `export` is a streaming
10
+ byte copy (peak RSS ~ a few MiB plus the Python runtime, independent of model
11
+ size) and `reshard` re-cuts the weights the same way. The ONE part that holds
12
+ real memory is the optimizer re-cut inside `reshard` (one NEW rank's optimizer
13
+ state, e.g. 12 B/param/new_ws for fp32-master AdamW); it prints that estimate
14
+ and refuses to start when the box cannot afford it (CLAUDE.md: 45 GB
15
+ MemAvailable floor for anything heavy, no swap).
16
+
17
+ WHY THIS EXISTS. `train.checkpoint_format = "sharded"` writes per-rank files
18
+ that eval, the rollout weight sync and external tools cannot read. `export`
19
+ turns one into exactly the directory the gathered writer would have produced
20
+ (byte-identical; tested by file hash) without ever holding the model in RAM --
21
+ the gathered path's rank-0 gather measured +28 GB RSS on OLMoE (docs/
22
+ MEASUREMENTS.md, Track M). `reshard` is the ONLY way to resume at a different
23
+ world size: resume refuses it, because sharded weights are rank-local row
24
+ slices and silently loading them under another partitioning trains plausibly
25
+ on scrambled weights.
26
+
27
+ Exit status: 0 ok, 1 refused/corrupt (message on stderr), 2 usage.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import sys
36
+
37
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") # never touch a GPU from a converter
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
40
+
41
+ from align.train import sharded_ckpt as sc # noqa: E402
42
+
43
+
44
+ def _mem_available_gib() -> float:
45
+ with open("/proc/meminfo") as f:
46
+ for line in f:
47
+ if line.startswith("MemAvailable:"):
48
+ return int(line.split()[1]) / 2**20
49
+ return 0.0
50
+
51
+
52
+ def _optimizer_bytes(src: str, ws: int) -> int:
53
+ return sum(os.path.getsize(os.path.join(src, f"optimizer_rank{r}.pt")) for r in range(ws) if os.path.exists(os.path.join(src, f"optimizer_rank{r}.pt")))
54
+
55
+
56
+ def cmd_inspect(a) -> int:
57
+ m = sc.read_manifest(a.src)
58
+ total = sum(f["bytes"] for f in m["files"].values())
59
+ print(
60
+ json.dumps(
61
+ {
62
+ "world_size": m["world_size"],
63
+ "tensors": len(m["tensors"]),
64
+ "weight_bytes": total,
65
+ "export_family": (m.get("export") or {}).get("family"),
66
+ "resharded_from": m.get("resharded_from"),
67
+ "problem": sc.sharded_problem(a.src, lambda p: None),
68
+ },
69
+ indent=1,
70
+ )
71
+ )
72
+ return 0
73
+
74
+
75
+ def cmd_verify(a) -> int:
76
+ m = sc.read_manifest(a.src)
77
+ bad = []
78
+ for r in range(m["world_size"]):
79
+ p = sc.verify_rank_file(a.src, r)
80
+ print(f"rank {r}: {'OK' if p is None else p}")
81
+ if p:
82
+ bad.append(p)
83
+ return 1 if bad else 0
84
+
85
+
86
+ def cmd_export(a) -> int:
87
+ from align.utils.guard import assert_paths_off_root
88
+
89
+ assert_paths_off_root([os.path.dirname(os.path.abspath(a.dst))])
90
+ res = sc.export_hf(a.src, a.dst, verify=not a.no_verify)
91
+ print(json.dumps({**res, "peak_rss_kb": sc.peak_rss_kb()}))
92
+ return 0
93
+
94
+
95
+ def cmd_reshard(a) -> int:
96
+ from align.utils.guard import assert_paths_off_root
97
+
98
+ assert_paths_off_root([os.path.dirname(os.path.abspath(a.dst))])
99
+ m = sc.read_manifest(a.src)
100
+ if not a.drop_optimizer:
101
+ est = _optimizer_bytes(a.src, m["world_size"]) / max(1, a.world_size) / 2**30
102
+ avail = _mem_available_gib()
103
+ print(f"optimizer re-cut peak ~ {est:.1f} GiB; MemAvailable {avail:.0f} GiB", file=sys.stderr)
104
+ if not a.allow_large and avail < est + 45:
105
+ print(
106
+ f"refusing: need MemAvailable >= {est + 45:.0f} GiB (estimate + the 45 GiB box floor) and have "
107
+ f"{avail:.0f}. Wait for the box, or pass --allow-large if you accept the risk, or --drop-optimizer "
108
+ "(fresh moments; NOT lossless).",
109
+ file=sys.stderr,
110
+ )
111
+ return 1
112
+ res = sc.reshard(a.src, a.dst, a.world_size, drop_optimizer=a.drop_optimizer)
113
+ print(json.dumps({**res, "peak_rss_kb": sc.peak_rss_kb()}))
114
+ return 0
115
+
116
+
117
+ def main(argv=None) -> int:
118
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
119
+ sub = ap.add_subparsers(dest="cmd", required=True)
120
+ for name in ("inspect", "verify"):
121
+ p = sub.add_parser(name)
122
+ p.add_argument("src")
123
+ p = sub.add_parser("export")
124
+ p.add_argument("src")
125
+ p.add_argument("dst")
126
+ p.add_argument("--no-verify", action="store_true", help="skip the per-file sha256 pass (not recommended)")
127
+ p = sub.add_parser("reshard")
128
+ p.add_argument("src")
129
+ p.add_argument("dst")
130
+ p.add_argument("--world-size", type=int, required=True)
131
+ p.add_argument("--drop-optimizer", action="store_true", help="omit optimizer files: resume starts FRESH moments (NOT lossless)")
132
+ p.add_argument("--allow-large", action="store_true")
133
+ a = ap.parse_args(argv)
134
+ try:
135
+ return {"inspect": cmd_inspect, "verify": cmd_verify, "export": cmd_export, "reshard": cmd_reshard}[a.cmd](a)
136
+ except (sc.ShardedCheckpointError, FileExistsError, FileNotFoundError, KeyError) as e:
137
+ print(f"{type(e).__name__}: {e}", file=sys.stderr)
138
+ return 1
139
+
140
+
141
+ if __name__ == "__main__":
142
+ raise SystemExit(main())
code/scripts/dedup_arm_b_shards.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-shot, surgical dedup of the Arm B preference shards.
2
+
3
+ WHY THIS EXISTS. The ref-logprob cache build refused the Arm B shards with
4
+ `ValueError: duplicate pair_id` -- correctly. The train split held 11,404
5
+ rows with 87 pair_ids present exactly twice, and held_out held 596 rows with
6
+ 2 doubled ids; every second occurrence is byte-identical to its first
7
+ (same input_ids, boundaries, rejected side, and extras). Root cause: PRM800K
8
+ carries multiple annotation records per problem, so two records can yield the
9
+ IDENTICAL pair site; `PairCapper.offer` caps per (problem_id, step_index) but
10
+ never checks pair_id, and `curate()` merges everything per problem
11
+ (nmo/data/prm800k_pairs.py, `by_problem.setdefault(...).append(p)`) without a
12
+ seen-pair_id check -- so the depth-round top-up re-selects a pair the first
13
+ round already took.
14
+
15
+ WHAT IT DOES. For each split (train, held_out):
16
+ 1. read the live shards via the store's own reader;
17
+ 2. keep the FIRST occurrence of every pair_id, drop second-and-later
18
+ occurrences -- REFUSING unless the dropped row is content-identical to
19
+ the kept one (a non-identical duplicate is a different bug and must not
20
+ be silently resolved by row order);
21
+ 3. write fresh shards through the same ShardWriter/receipt API the build
22
+ used, preserving shard naming (`arm_b-train-NNNN` etc.).
23
+ Then it asserts zero train/held pair_id overlap, atomically swaps
24
+ `arm_b -> arm_b_predup` and `arm_b_dedup -> arm_b` (the predup directory is
25
+ KEPT as evidence), and updates data/manifest.json in place:
26
+ `counts.arm_b_curated`, the two `arm_b-*` shard hashes (same
27
+ content_hash-of-receipt-hashes chain `_ShardSet.finalize` uses), and the
28
+ `length_bias_baseline` Arm B entries re-measured over the deduped TRAIN split
29
+ with the exact function scripts/dpo.py uses
30
+ (align.train.dpo_stage.measure_chosen_longer_fraction). A provenance block
31
+ `arm_b_dedup` records old -> new for everything it touched.
32
+
33
+ Deterministic (pure function of the input shards; no RNG), CPU-only.
34
+ The ref-logprob caches keyed on the old manifest hashes are stale after this
35
+ and must be re-precomputed -- that is the point.
36
+
37
+ Run:
38
+
39
+ cd /apps/development/rlhf/nano-mistral-o1
40
+ CUDA_VISIBLE_DEVICES= .venv/bin/python scripts/dedup_arm_b_shards.py --dry-run
41
+ CUDA_VISIBLE_DEVICES= .venv/bin/python scripts/dedup_arm_b_shards.py
42
+
43
+ Refuses to run twice: an existing `arm_b_predup` means the swap already
44
+ happened.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import os
50
+
51
+ # CPU-only by construction: never let a lazily-imported torch see a GPU.
52
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
53
+
54
+ import argparse
55
+ import json
56
+ import logging
57
+ from pathlib import Path
58
+
59
+ import numpy as np
60
+
61
+ logger = logging.getLogger("dedup_arm_b")
62
+
63
+ REPO = Path("/apps/development/rlhf/nano-mistral-o1")
64
+ SPLITS = (("train", "arm_b-train"), ("held_out", "arm_b-held"))
65
+ #: manifest hash key per split prefix, matching build_all's `hashes` dict.
66
+ MANIFEST_HASH_KEY = {"arm_b-train": "arm_b-train", "arm_b-held": "arm_b-held"}
67
+
68
+
69
+ def rows_identical(a, b) -> bool:
70
+ """Full content identity between two store Rows -- every stored field.
71
+
72
+ Anything short of this (say, comparing input_ids only) would let a real
73
+ divergence hide behind a shared pair_id and get resolved by row order.
74
+ """
75
+ return (
76
+ np.array_equal(a.input_ids, b.input_ids)
77
+ and np.array_equal(a.boundary_positions, b.boundary_positions)
78
+ and a.problem_id == b.problem_id
79
+ and a.subject == b.subject
80
+ and sorted(a.ragged) == sorted(b.ragged)
81
+ and all(np.array_equal(a.ragged[f], b.ragged[f]) for f in a.ragged)
82
+ and a.extras == b.extras
83
+ )
84
+
85
+
86
+ def dedup_row_indices(ds) -> tuple[list[int], list[int]]:
87
+ """(kept, dropped) row indices: first occurrence per pair_id wins.
88
+
89
+ Raises if any dropped row is NOT content-identical to its kept first
90
+ occurrence -- that would be a semantic collision, not a re-added pair,
91
+ and this script is only licensed to remove exact re-adds.
92
+ """
93
+ first_by_pair: dict[str, int] = {}
94
+ kept: list[int] = []
95
+ dropped: list[int] = []
96
+ for i in range(len(ds)):
97
+ row = ds[i]
98
+ pair_id = row.extras["pair_id"]
99
+ if pair_id not in first_by_pair:
100
+ first_by_pair[pair_id] = i
101
+ kept.append(i)
102
+ continue
103
+ j = first_by_pair[pair_id]
104
+ if not rows_identical(ds[j], row):
105
+ raise ValueError(
106
+ f"pair_id {pair_id!r} occurs at rows {j} and {i} with "
107
+ "DIFFERENT content. That is a hash/semantics collision, not a "
108
+ "duplicate re-add; refusing to pick a winner by row order."
109
+ )
110
+ dropped.append(i)
111
+ return kept, dropped
112
+
113
+
114
+ def write_split(ds, kept: list[int], out_dir: Path, prefix: str) -> str:
115
+ """Write the kept rows through the build's own shard-set API.
116
+
117
+ Returns the manifest-level content hash (content_hash over the per-shard
118
+ receipt hashes -- the same chain `_ShardSet.finalize` produces, so the
119
+ updated manifest entry is computed by the code that defined it).
120
+ """
121
+ from align.data.build import _PREF_FIELDS, _ShardSet
122
+
123
+ shard_set = _ShardSet(out_dir, prefix, _PREF_FIELDS)
124
+ for i in kept:
125
+ row = ds[i]
126
+ shard_set.add(
127
+ input_ids=row.input_ids,
128
+ problem_id=row.problem_id,
129
+ subject=row.subject,
130
+ boundary_positions=row.boundary_positions,
131
+ ragged={
132
+ "rejected_input_ids": row.ragged["rejected_input_ids"],
133
+ "rejected_boundary_positions": row.ragged["rejected_boundary_positions"],
134
+ },
135
+ **row.extras,
136
+ )
137
+ return shard_set.finalize()
138
+
139
+
140
+ def step_basis_fraction(rows) -> float:
141
+ """Arm B's step-basis chosen-longer fraction over collator-shaped rows.
142
+
143
+ Mirrors build.py's `chosen_longer_fraction_step_tokens`: length measured
144
+ from the SHARED PREFIX (the one step that differs), counted over every
145
+ row. Kept next to the completion-basis measurement so the manifest's two
146
+ Arm B baselines stay siblings of the same dataset state.
147
+ """
148
+ longer = sum(
149
+ int(
150
+ len(r["chosen_input_ids"]) - int(r["shared_prefix_len"])
151
+ > len(r["rejected_input_ids"]) - int(r["shared_prefix_len"])
152
+ )
153
+ for r in rows
154
+ )
155
+ return longer / len(rows) if rows else float("nan")
156
+
157
+
158
+ def main(argv=None) -> int:
159
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
160
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
161
+ ap.add_argument("--root", type=Path, default=REPO, help="repo root")
162
+ ap.add_argument(
163
+ "--dry-run", action="store_true", help="measure and report; write and swap nothing"
164
+ )
165
+ args = ap.parse_args(argv)
166
+
167
+ from align.data.loader import preference_dataset
168
+ from align.data.store import ShardDataset
169
+
170
+ shards_root = args.root / "data" / "shards"
171
+ live = shards_root / "arm_b"
172
+ staged = shards_root / "arm_b_dedup"
173
+ predup = shards_root / "arm_b_predup"
174
+ manifest_path = args.root / "data" / "manifest.json"
175
+
176
+ if predup.exists():
177
+ raise SystemExit(
178
+ f"{predup} already exists: the swap already happened. This script "
179
+ "is one-shot; a second run would dedup the already-deduped shards "
180
+ "and overwrite the evidence."
181
+ )
182
+ if staged.exists():
183
+ raise SystemExit(
184
+ f"{staged} already exists (a previous run died mid-write?). "
185
+ "Inspect and delete it before re-running; a half-written shard "
186
+ "set masquerades as resumable."
187
+ )
188
+
189
+ # -- pass 1: dedup into the staging directory ---------------------------
190
+ pair_ids: dict[str, set[str]] = {}
191
+ before_after: dict[str, tuple[int, int]] = {}
192
+ new_hashes: dict[str, str] = {}
193
+ for split, prefix in SPLITS:
194
+ ds = ShardDataset(live / split)
195
+ kept, dropped = dedup_row_indices(ds)
196
+ before_after[split] = (len(ds), len(kept))
197
+ pair_ids[split] = {ds[i].extras["pair_id"] for i in kept}
198
+ logger.info(
199
+ "%s: %d rows -> %d kept (%d exact-duplicate re-adds dropped)",
200
+ split,
201
+ len(ds),
202
+ len(kept),
203
+ len(dropped),
204
+ )
205
+ if not args.dry_run:
206
+ new_hashes[prefix] = write_split(ds, kept, staged / split, prefix)
207
+
208
+ overlap = pair_ids["train"] & pair_ids["held_out"]
209
+ if overlap:
210
+ raise SystemExit(
211
+ f"{len(overlap)} pair_ids appear in BOTH deduped splits (e.g. "
212
+ f"{sorted(overlap)[:3]}); the train/held split is broken and a "
213
+ "dedup must not paper over it."
214
+ )
215
+
216
+ if args.dry_run:
217
+ logger.info("dry run: nothing written, nothing swapped")
218
+ return 0
219
+
220
+ # -- pass 2: verify the staged shards through a fresh reader ------------
221
+ for split, _prefix in SPLITS:
222
+ staged_ds = ShardDataset(staged / split)
223
+ staged_ids = [staged_ds[i].extras["pair_id"] for i in range(len(staged_ds))]
224
+ if len(staged_ids) != len(set(staged_ids)) or len(staged_ids) != before_after[split][1]:
225
+ raise SystemExit(
226
+ f"staged {split} re-read: {len(staged_ids)} rows, "
227
+ f"{len(set(staged_ids))} unique -- the write did not produce "
228
+ "what the dedup computed; refusing to swap."
229
+ )
230
+
231
+ # -- pass 3: atomic swap; the pre-dedup shards are KEPT as evidence -----
232
+ live.rename(predup)
233
+ staged.rename(live)
234
+ logger.info("swapped: %s -> %s, %s -> %s", "arm_b", "arm_b_predup", "arm_b_dedup", "arm_b")
235
+
236
+ # -- pass 4: manifest update, through the numbers' own definitions ------
237
+ manifest = json.loads(manifest_path.read_text())
238
+ old = {
239
+ "counts.arm_b_curated": manifest["counts"]["arm_b_curated"],
240
+ "hashes.arm_b-train": manifest["hashes"]["arm_b-train"],
241
+ "hashes.arm_b-held": manifest["hashes"]["arm_b-held"],
242
+ "arm_b_chosen_longer_fraction": manifest["length_bias_baseline"][
243
+ "arm_b_chosen_longer_fraction"
244
+ ],
245
+ "arm_b_chosen_longer_fraction_step_basis": manifest["length_bias_baseline"][
246
+ "arm_b_chosen_longer_fraction_step_basis"
247
+ ],
248
+ }
249
+
250
+ # Re-measure the length-bias baseline over the deduped TRAIN split with
251
+ # the exact function scripts/dpo.py falls back to, on the exact rows it
252
+ # would build (read-only imports; nothing here re-implements the metric).
253
+ from align.train.dpo_stage import measure_chosen_longer_fraction
254
+
255
+ rows = preference_dataset(live / "train")
256
+ new_fraction = measure_chosen_longer_fraction(rows)
257
+ new_fraction_step = step_basis_fraction(rows)
258
+
259
+ n_train, n_held = before_after["train"][1], before_after["held_out"][1]
260
+ manifest["counts"]["arm_b_curated"] = n_train + n_held
261
+ manifest["hashes"]["arm_b-train"] = new_hashes["arm_b-train"]
262
+ manifest["hashes"]["arm_b-held"] = new_hashes["arm_b-held"]
263
+ manifest["length_bias_baseline"]["arm_b_chosen_longer_fraction"] = new_fraction
264
+ manifest["length_bias_baseline"]["arm_b_chosen_longer_fraction_step_basis"] = new_fraction_step
265
+ manifest["arm_b_dedup"] = {
266
+ "reason": (
267
+ "87 train + 2 held_out pair_ids were present exactly twice, every "
268
+ "second occurrence content-identical to its first (curation "
269
+ "depth-round top-up re-added pairs already selected; the merge in "
270
+ "prm800k_pairs.curate never deduped by pair_id). The ref-logprob "
271
+ "cache build correctly refused the duplicates."
272
+ ),
273
+ "script": "scripts/dedup_arm_b_shards.py",
274
+ "train_rows": {"before": before_after["train"][0], "after": n_train},
275
+ "held_out_rows": {"before": before_after["held_out"][0], "after": n_held},
276
+ "old": old,
277
+ "predup_shards": str(predup),
278
+ }
279
+ manifest_path.write_text(json.dumps(manifest, indent=1))
280
+
281
+ logger.info("train: %d -> %d rows", before_after["train"][0], n_train)
282
+ logger.info("held_out: %d -> %d rows", before_after["held_out"][0], n_held)
283
+ logger.info("counts.arm_b_curated: %s -> %d", old["counts.arm_b_curated"], n_train + n_held)
284
+ logger.info(
285
+ "arm_b_chosen_longer_fraction: %s -> %.6f (step basis %s -> %.6f)",
286
+ old["arm_b_chosen_longer_fraction"],
287
+ new_fraction,
288
+ old["arm_b_chosen_longer_fraction_step_basis"],
289
+ new_fraction_step,
290
+ )
291
+ logger.info(
292
+ "hashes: arm_b-train %s -> %s, arm_b-held %s -> %s",
293
+ old["hashes.arm_b-train"],
294
+ new_hashes["arm_b-train"],
295
+ old["hashes.arm_b-held"],
296
+ new_hashes["arm_b-held"],
297
+ )
298
+ logger.info(
299
+ "NOTE: ref caches keyed on the old manifest hashes are now stale; "
300
+ "re-run scripts/precompute_ref_logprobs.py before scripts/dpo.py."
301
+ )
302
+ return 0
303
+
304
+
305
+ if __name__ == "__main__":
306
+ # House rule: everything above is import-safe because spawn-based worker
307
+ # pools re-import __main__; the entry point stays behind this guard.
308
+ raise SystemExit(main())
code/scripts/dpo.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """DPO stage entry: gates -> banner -> train -> arm result manifest.
3
+
4
+ torchrun --nproc_per_node=4 scripts/dpo.py configs/dpo_argilla.toml
5
+ torchrun --nproc_per_node=4 scripts/dpo.py configs/dpo_steppairs.toml \
6
+ --lr=5e-7
7
+
8
+ One script, two pre-registered arms; the config selects everything. The
9
+ objective is section-05's chunked `fused_dpo`, the loop is section-07's
10
+ `Trainer`, the reference logprobs are constants read from the cache
11
+ scripts/precompute_ref_logprobs.py wrote. What this script owns is the
12
+ decision to start and the record of what happened.
13
+
14
+ Entry gates run in `dpo_stage.GATE_ORDER` and any failure blocks everything
15
+ after it, model load included. They are ordered by cost so an invalid stage
16
+ dies in milliseconds rather than after putting 14.5GB onto a shared no-swap
17
+ box:
18
+
19
+ 1. section-11's SFT quality-gate receipt exists AND passed;
20
+ 2. build-manifest counts match the config, pins were verified, Arm A is not
21
+ marked DESCOPED;
22
+ 3. the reference cache's six-component key matches this run exactly;
23
+ 4. the path-bias measurement exists and its decision was honoured;
24
+ 5. the 20-step throughput probe (wall-clock is DERIVED from it, never
25
+ asserted in advance);
26
+ 6. the RSS-aware preflight for the full-FT job class.
27
+
28
+ On a tripwire fire the arm re-runs ONCE with the pre-registered response from
29
+ docs/RESULTS.md, and that configuration IS the arm's result -- written into
30
+ the arm manifest, which section-13 reads for arm selection and the paired
31
+ evaluation.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import json
38
+ import logging
39
+ import os
40
+ import sys
41
+ from pathlib import Path
42
+
43
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
44
+
45
+ from align.utils.guard import apply_resource_guards # noqa: E402
46
+
47
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
48
+ logger = logging.getLogger("dpo")
49
+
50
+
51
+ def _split_overrides(extra: list[str]) -> list[str]:
52
+ """Leftover argv (what argparse did not claim) -> dotted `key=value`
53
+ overrides for `dpo_stage.load_stage_config`.
54
+
55
+ `--lr=5e-7` and `lr=5e-7` are the same override. Anything without an `=`
56
+ is refused: argparse's own positional `nargs='*'` accepted the bare form
57
+ and then nothing ever read it, so an override the operator believed
58
+ applied trained at the TOML's value (the banner printed the TOML value,
59
+ not the intended one). Every override is either applied or rejected.
60
+ """
61
+ overrides = [a.lstrip("-") for a in extra]
62
+ bad = [a for a, o in zip(extra, overrides) if "=" not in o or not o.split("=", 1)[0]]
63
+ if bad:
64
+ raise SystemExit(
65
+ f"unrecognized arguments {bad}: overrides must be dotted key=value pairs "
66
+ "(e.g. --lr=5e-7); space-separated values are not supported"
67
+ )
68
+ return overrides
69
+
70
+
71
+ def _parse_args(argv=None):
72
+ """argparse for the flags, hand-split for the dotted overrides.
73
+
74
+ `--lr=5e-7` is not an argparse option, so the declared-positional form
75
+ rejected the documented spelling (exit 2) while accepting the bare
76
+ `lr=5e-7` and then never reading it. Unclaimed arguments are now the
77
+ overrides: applied (struct mode, via `load_stage_config`) or refused.
78
+ """
79
+ ap = argparse.ArgumentParser(allow_abbrev=False, epilog="dotted overrides: --lr=5e-7")
80
+ ap.add_argument("config")
81
+ ap.add_argument(
82
+ "--resume",
83
+ default=os.environ.get("ALIGN_RESUME") == "1",
84
+ action=argparse.BooleanOptionalAction,
85
+ help="resume from the newest valid checkpoint in run_dir instead of rotating "
86
+ "it away as stale (default: fresh arm; the supervisor sets ALIGN_RESUME=1 on retries)",
87
+ )
88
+ ap.add_argument("--strict-host-check", default=True, action=argparse.BooleanOptionalAction)
89
+ ap.add_argument("--heavy-lock", default=True, action=argparse.BooleanOptionalAction)
90
+ args, extra = ap.parse_known_args(argv)
91
+ args.overrides = _split_overrides(extra)
92
+ return args
93
+
94
+
95
+ def main() -> int:
96
+ args = _parse_args()
97
+
98
+ from align.config import default_full_ft_config
99
+
100
+ def _rss_cfg():
101
+ # Project host RSS from the optimizer this stage ACTUALLY runs.
102
+ # default_full_ft_config defaults to offload_adamw (104.7GB
103
+ # projection) while the DPO arms are pinned to adamw8bit (22GB) --
104
+ # the mismatch made the RSS gate refuse a job 5x smaller than its
105
+ # model whenever the host dipped below ~150GB free.
106
+ c = default_full_ft_config()
107
+ c.optimizer.name = "adamw8bit"
108
+ return c
109
+
110
+ apply_resource_guards(
111
+ cpu_threads=8,
112
+ heavy=args.heavy_lock,
113
+ label="dpo",
114
+ strict_host_check=args.strict_host_check,
115
+ rss_cfg=_rss_cfg(),
116
+ )
117
+
118
+ # BEFORE anything touches CUDA -- the duplicate-GPU class, third
119
+ # appearance (sft.py, precompute --path-bias, now here): without
120
+ # set_device(local_rank) every rank's bare "cuda" is device 0, and this
121
+ # stage's first real 4-rank launch put four ~5.4GB policy loads on GPU 0.
122
+ from align.utils.dist import init_distributed
123
+
124
+ # The run dir (NCCL flight-recorder dumps land under it, never /tmp) comes
125
+ # from the config, which is parsed before the process group so a bad TOML
126
+ # fails in milliseconds.
127
+ from align.train.dpo_stage import load_stage_config as _early_stage_config
128
+
129
+ init_distributed(run_dir=_early_stage_config(args.config, args.overrides).run_dir)
130
+
131
+ from align.train.stage_gates import assert_distinct_devices
132
+
133
+ assert_distinct_devices()
134
+
135
+ from align.data.loader import preference_dataset
136
+ from align.model import checkpoint as ckpt
137
+ from align.model.mistral import NmoMistralForCausalLM
138
+ from align.train.dpo_stage import (
139
+ PreferenceBatchSource,
140
+ apply_tripwire_response,
141
+ arm_result_manifest,
142
+ assert_launch_topology,
143
+ build_manifest_gate,
144
+ load_run_ref_caches,
145
+ load_stage_config,
146
+ make_preference_sampler,
147
+ measure_chosen_longer_fraction,
148
+ read_sft_quality_gate,
149
+ reduce_tripwire_alarms,
150
+ resolve_pair_split_dir,
151
+ resolve_sft_checkpoint,
152
+ run_entry_gates,
153
+ write_arm_result,
154
+ )
155
+ from align.train.ref_cache import read_path_bias_report
156
+ from align.utils.guard import assert_paths_off_root
157
+
158
+ cfg = load_stage_config(args.config, args.overrides)
159
+ assert_paths_off_root([cfg.run_dir])
160
+ # LAUNCH REFUSAL, before any gate: world_size x micro x grad_accum must be
161
+ # the pre-registered global_batch the banner and horizon are quoted in.
162
+ # Fed by the live process group, so a 2-rank launch of a 4-rank
163
+ # pre-registration dies here in milliseconds, not after the model load.
164
+ logger.info("launch topology ok: %s", assert_launch_topology(cfg))
165
+ with open(cfg.manifest_path) as f:
166
+ manifest = json.load(f)
167
+
168
+ state: dict = {"resume": bool(args.resume)}
169
+
170
+ # -- the gate sequence ------------------------------------------------
171
+ # Each gate is a closure over `state` so a later gate can use what an
172
+ # earlier one established -- which is the point of the ORDER: reaching
173
+ # gate N means gates 1..N-1 held.
174
+ def gate_sft(_cfg):
175
+ state["sft_gate"] = read_sft_quality_gate(_cfg.sft_quality_gate_path)
176
+ # The trainer writes <run_dir>/checkpoints/micro_NNNNNNNN with no
177
+ # stable `final` alias; the receipt records which one passed. Resolve
178
+ # HERE, at the first gate, so every later consumer (ref-cache key,
179
+ # model load, banner) sees one path -- never a hand-composed one.
180
+ ckpt_path, source = resolve_sft_checkpoint(
181
+ _cfg.sft_checkpoint, state["sft_gate"].get("checkpoint")
182
+ )
183
+ state["sft_checkpoint"] = ckpt_path
184
+ state["sft_gate"]["checkpoint"] = ckpt_path
185
+ return {"checkpoint": ckpt_path, "checkpoint_source": source}
186
+
187
+ def gate_manifest(_cfg):
188
+ result = build_manifest_gate(_cfg, manifest)
189
+ state["descoped"] = result["descoped"]
190
+ return result
191
+
192
+ def gate_ref_cache(_cfg):
193
+ # Content-hash key (checkpoint WEIGHT BYTES, not the tensor inventory);
194
+ # forward_impl is read off the cache itself, not the path-bias report --
195
+ # see dpo_stage.expected_ref_cache_key for why each.
196
+ state["ref_cache"], state["heldout_cache"] = load_run_ref_caches(
197
+ _cfg, manifest, state["sft_checkpoint"]
198
+ )
199
+ state["ref_cache_key"] = state["ref_cache"].key
200
+ return {"pairs": len(state["ref_cache"]), "heldout_pairs": len(state["heldout_cache"])}
201
+
202
+ def gate_path_bias(_cfg):
203
+ state["path_bias"] = read_path_bias_report(_cfg.path_bias_report_path)
204
+ return {"action": state["path_bias"]["action"], "ratio": state["path_bias"]["ratio"]}
205
+
206
+ def gate_probe(_cfg):
207
+ # Filled after the model exists; the probe is the one gate that needs
208
+ # the loop, so it records its intent here and the banner reads the
209
+ # measured value below.
210
+ state["probe"] = {
211
+ "step_time_s_median": float("nan"),
212
+ "projected_wall_clock_h": float("nan"),
213
+ "status": "PENDING",
214
+ }
215
+ return {"status": "PENDING until the 20-step probe runs"}
216
+
217
+ def gate_rss(_cfg):
218
+ from align.train.probes import projected_job_rss
219
+
220
+ state["rss"] = projected_job_rss(_rss_cfg())
221
+ return dict(state["rss"])
222
+
223
+ gate_report = run_entry_gates(
224
+ cfg,
225
+ {
226
+ "sft_quality_gate": gate_sft,
227
+ "build_manifest": gate_manifest,
228
+ "ref_cache_key": gate_ref_cache,
229
+ "path_bias": gate_path_bias,
230
+ "throughput_probe": gate_probe,
231
+ "rss_preflight": gate_rss,
232
+ },
233
+ )
234
+
235
+ if state["descoped"]:
236
+ # A descoped arm is a RESULT, not an absence: section-13's selection
237
+ # rule reads this manifest and must be able to tell "measured badly"
238
+ # from "never measured".
239
+ out = arm_result_manifest(
240
+ cfg=cfg, descoped=True, summary={}, heldout={}, banner={}, checkpoint=None
241
+ )
242
+ path = write_arm_result(os.path.join(cfg.run_dir, "arm_result.json"), out)
243
+ logger.warning("%s is DESCOPED; wrote %s and did not train", cfg.arm, path)
244
+ return 0
245
+
246
+ # -- data + model ------------------------------------------------------
247
+ # DISCOVERY, not path composition: the configs point at the real split
248
+ # dirs, and resolve_pair_split_dir re-finds them from the receipts the
249
+ # store wrote -- so a layout change moves this by itself, and a wrong
250
+ # configured path fails naming the splits that ARE present.
251
+ rows = preference_dataset(resolve_pair_split_dir(cfg.shard_dir, f"{cfg.arm}-train"))
252
+ heldout_rows = preference_dataset(
253
+ resolve_pair_split_dir(cfg.heldout_shard_dir, f"{cfg.arm}-held")
254
+ )
255
+ pad_id = int(manifest.get("pinned", {}).get("pad_token_id", 0))
256
+
257
+ baselines = manifest.get("length_bias_baseline", {})
258
+ baseline_key = f"{cfg.arm}_chosen_longer_fraction"
259
+ if baseline_key in baselines:
260
+ length_baseline = (baselines[baseline_key], "build_manifest")
261
+ else:
262
+ # Section-03 records only Arm A's baseline. Measure this arm's from
263
+ # its own shards rather than borrow the other's -- a tripwire armed
264
+ # against the wrong distribution fires forever or never.
265
+ length_baseline = (measure_chosen_longer_fraction(rows), "measured_at_launch")
266
+ logger.info("length-drift baseline: %.4f (%s)", *length_baseline)
267
+
268
+ model = _load_policy(
269
+ NmoMistralForCausalLM, ckpt, state["sft_checkpoint"], cfg.attn_implementation
270
+ )
271
+ # The RESOLVED backend, read back off the model: the constructor routed
272
+ # the request through the gated selector, so this is what actually runs.
273
+ state["attn_backend"] = model.attn_implementation
274
+ run_cfg = _run_config(cfg, length_baseline[0])
275
+
276
+ from align.train.fsdp import apply_activation_checkpointing, apply_fsdp
277
+
278
+ dist_model = apply_fsdp(apply_activation_checkpointing(model))
279
+ import torch as _torch
280
+
281
+ # Post-shard residency: a rank should hold ~3.6GB (its shard). Anything
282
+ # near 18GB means the pre-shard replica leaked past fully_shard.
283
+ logger.info("post-shard cuda allocated: %.2f GiB", _torch.cuda.memory_allocated() / 2**30)
284
+
285
+ source = PreferenceBatchSource(
286
+ rows,
287
+ make_preference_sampler(
288
+ rows,
289
+ max_tokens=run_cfg.train.seq_len * cfg.micro_batch_size,
290
+ max_rows=cfg.micro_batch_size,
291
+ seed=run_cfg.train.seed,
292
+ ),
293
+ pad_token_id=pad_id,
294
+ )
295
+
296
+ result = _train_arm(
297
+ cfg,
298
+ run_cfg,
299
+ dist_model,
300
+ source,
301
+ state,
302
+ heldout_rows,
303
+ pad_id,
304
+ gate_report,
305
+ manifest,
306
+ length_baseline,
307
+ )
308
+
309
+ # -- tripwire response -------------------------------------------------
310
+ # The rerun is collective, so the verdict is drawn from the UNION of every
311
+ # rank's alarms: rank-local alarms could send some ranks into a second
312
+ # model load + FSDP wrap while the rest exit.
313
+ alarms = reduce_tripwire_alarms(result["summary"].get("tripwire_alarms", []))
314
+ rerun_cfg, tripwire = apply_tripwire_response(cfg, alarms)
315
+ if tripwire["rerun_required"]:
316
+ logger.error(
317
+ "chosen-logprob collapse detected; re-running %s with the pre-registered response: %s",
318
+ cfg.arm,
319
+ tripwire["configuration"],
320
+ )
321
+ model = _load_policy(
322
+ NmoMistralForCausalLM, ckpt, state["sft_checkpoint"], cfg.attn_implementation
323
+ )
324
+ state["attn_backend"] = model.attn_implementation
325
+ dist_model = apply_fsdp(apply_activation_checkpointing(model))
326
+ result = _train_arm(
327
+ rerun_cfg,
328
+ _run_config(rerun_cfg, length_baseline[0]),
329
+ dist_model,
330
+ source,
331
+ state,
332
+ heldout_rows,
333
+ pad_id,
334
+ gate_report,
335
+ manifest,
336
+ length_baseline,
337
+ suffix="_response",
338
+ )
339
+ cfg = rerun_cfg
340
+
341
+ out = arm_result_manifest(
342
+ cfg=cfg,
343
+ descoped=False,
344
+ summary=result["summary"],
345
+ heldout=result["heldout"],
346
+ banner=result["banner"],
347
+ checkpoint=result["checkpoint"],
348
+ tripwire=tripwire,
349
+ )
350
+ out["gate_report"] = gate_report
351
+ path = write_arm_result(os.path.join(cfg.run_dir, "arm_result.json"), out)
352
+ logger.info("%s complete: %s", cfg.arm, path)
353
+ return 0
354
+
355
+
356
+ def _load_policy(model_cls, ckpt, path: str, attn_implementation: str):
357
+ """Load the policy with the CONFIG-DRIVEN attention backend.
358
+
359
+ The string is never handed raw to a constructor in the sense that
360
+ matters: `NmoMistralForCausalLM.__init__` routes it through
361
+ `align.model.attention.select_attn_implementation`, which raises if the
362
+ request cannot be honoured -- for the pre-registered `triton_fa2` that
363
+ means section-06's equivalence-gate receipt must be valid for this kernel
364
+ source on this GPU. Silent degradation to sdpa is exactly the failure the
365
+ selector exists to prevent.
366
+ """
367
+ import torch
368
+
369
+ from align.model.hf_config import read_config
370
+
371
+ model_cfg = read_config(path)
372
+ model = model_cls.from_state_dict(
373
+ model_cfg,
374
+ ckpt.hf_to_nmo(ckpt.load_hf_safetensors(path)),
375
+ attn_implementation=attn_implementation,
376
+ device="cuda",
377
+ )
378
+ return model.to("cuda", dtype=torch.bfloat16)
379
+
380
+
381
+ def _run_config(cfg, length_baseline: float):
382
+ """Stage config -> section-07 RunConfig. The two are different objects on
383
+ purpose: RunConfig is the trainer's surface and DpoStageConfig is the
384
+ arm's pre-registration, and collapsing them would let a trainer default
385
+ silently become an experimental parameter."""
386
+ from align.config import RunConfig
387
+
388
+ run = RunConfig()
389
+ # adamw8bit per the 2026-08-17 pre-registration amendment ("SFT (and the
390
+ # DPO arms) run adamw8bit"). Left unset, RunConfig's offload_adamw
391
+ # default leaked through and the pinned-staging guard refused with a
392
+ # 27GiB request on a no-swap box -- correctly.
393
+ run.optimizer.name = "adamw8bit"
394
+ run.optimizer.lr = cfg.lr
395
+ run.optimizer.grad_accum = cfg.grad_accum
396
+ run.train.epochs = cfg.epochs
397
+ run.train.micro_batch_size = cfg.micro_batch_size
398
+ run.train.save_every_micro_steps = cfg.save_every_micro_steps
399
+ run.train.keep_checkpoints = cfg.keep_checkpoints
400
+ # Forwarded explicitly: RunConfig is built from defaults here, so a stage
401
+ # field that is not copied is a silently disarmed config.
402
+ run.train.per_rank_optimizer_state = cfg.per_rank_optimizer_state
403
+ run.optimizer.max_grad_norm = cfg.max_grad_norm
404
+ run.train.max_wall_seconds = cfg.max_wall_seconds
405
+ run.train.max_consecutive_skipped_steps = cfg.max_consecutive_skipped_steps
406
+ run.train.max_skipped_fraction = cfg.max_skipped_fraction
407
+ run.train.full_ft = True
408
+ run.data.num_workers = cfg.num_workers
409
+ run.tripwires.chosen_longer_baseline = length_baseline
410
+ run.tripwires.length_band = cfg.length_band
411
+ run.tripwires.logps_window = cfg.logps_window
412
+ run.tripwires.armed = tuple(cfg.armed_tripwires)
413
+ # The tripwire may speak exactly when its estimator is full. These are two
414
+ # different quantities -- `length_window_pairs` is the span the adapter
415
+ # averages over, `length_min_pairs` is when the tripwire is allowed to
416
+ # fire -- and they are deliberately the SAME number: a min_pairs below the
417
+ # window size would let the guard speak from a partially-filled window,
418
+ # which is precisely the noisy regime both settings exist to avoid.
419
+ run.tripwires.length_min_pairs = cfg.length_window_pairs
420
+ run.run_name = f"dpo_{cfg.arm}"
421
+ return run
422
+
423
+
424
+ def _train_arm(
425
+ cfg,
426
+ run_cfg,
427
+ dist_model,
428
+ source,
429
+ state,
430
+ heldout_rows,
431
+ pad_id,
432
+ gate_report,
433
+ manifest,
434
+ length_baseline,
435
+ suffix: str = "",
436
+ ):
437
+ """Probe -> banner -> train -> held-out accuracy. In that order, because
438
+ the banner quotes the PROBE-DERIVED wall-clock and the plan forbids
439
+ asserting a wall-clock in advance."""
440
+ from align.data.loader import collate_preference
441
+ from align.model.attention import banner_value
442
+ from align.train.dpo_stage import (
443
+ DpoBatch,
444
+ dpo_banner,
445
+ make_dpo_stage_adapter,
446
+ render_dpo_banner,
447
+ )
448
+ from align.train.ref_cache import heldout_preference_accuracy
449
+ from align.train.trainer import Trainer
450
+
451
+ run_dir = cfg.run_dir + suffix
452
+ # FRESH-ARM HYGIENE: a non-resuming arm run must not share a checkpoint
453
+ # dir with a previous run's leftovers -- stale higher-numbered dirs
454
+ # shadowed the rerun's fresh saves under keep-N pruning (2026-08-20).
455
+ from align.train.dpo_stage import probe_micro_cap, rotate_stale_checkpoints
456
+
457
+ ckpt_dir = os.path.join(run_dir, "checkpoints")
458
+ # Rotation is for FRESH arms only. A resuming arm (--resume, or a
459
+ # supervisor retry via ALIGN_RESUME=1) must keep its checkpoints: rotating
460
+ # them would silently restart the arm from step 0 while the supervisor
461
+ # believes it is continuing -- the replay defect, at the stage level.
462
+ resuming = bool(state.get("resume"))
463
+ # Collective: rank 0 renames, every rank waits (a bare per-rank rename
464
+ # let one rank win and killed the other three with FileNotFoundError).
465
+ if not resuming:
466
+ rotate_stale_checkpoints(ckpt_dir)
467
+ adapter = make_dpo_stage_adapter(state["ref_cache"], cfg, require_fused=True)
468
+ trainer = Trainer(run_cfg, dist_model, source, adapter, run_dir=run_dir, stage="dpo")
469
+
470
+ # REFUSE a horizon that disagrees with the corpus, before a single step.
471
+ # Arm A ran a 146-step horizon over a corpus needing ~289 per-rank steps:
472
+ # the cosine finished at half an epoch, clamped the learning rate to
473
+ # exactly 0, and the loop kept stepping on frozen weights for ~24
474
+ # GPU-hours. The Trainer now stops AT the horizon, which turns that into a
475
+ # short run rather than a zombie one -- this check is the other half, and
476
+ # catches it before the GPU-hours rather than after.
477
+ from align.train.dpo_stage import assert_dpo_horizon_consistent
478
+ from align.utils.dist import world_size as _world_size
479
+
480
+ horizon_report = assert_dpo_horizon_consistent(
481
+ trainer.scheduler.total_steps,
482
+ n_pairs=len(source.rows),
483
+ world_size=_world_size(),
484
+ micro_batch_size=cfg.micro_batch_size,
485
+ grad_accum=cfg.grad_accum,
486
+ epochs=run_cfg.train.epochs,
487
+ global_batch=cfg.global_batch,
488
+ )
489
+ logger.info("horizon ok: %s", horizon_report)
490
+
491
+ if resuming:
492
+ resumed_from = trainer.resume_from_latest()
493
+ logger.info(
494
+ "RESUMED from %s at optimizer step %d / micro step %d",
495
+ resumed_from,
496
+ trainer.optimizer_step,
497
+ trainer.micro_step,
498
+ )
499
+ state["resumed_from"] = resumed_from
500
+
501
+ tokens_per_step = (
502
+ run_cfg.train.seq_len * cfg.micro_batch_size * 2 * cfg.grad_accum
503
+ ) # 2: both sides of every pair
504
+ # The probe is the FIRST probe_steps real micro-steps of the run itself:
505
+ # a capped train(), report captured, cap removed, training continues from
506
+ # the stream's exact position. The previous construction asked a trainer
507
+ # that had never stepped for its step times, and throughput_report
508
+ # correctly refused an empty probe. Probe steps here are real training
509
+ # (counted in the horizon), so nothing is spent twice; the banner prints
510
+ # after 20 micro-steps instead of before any. The cap is on the ABSOLUTE
511
+ # micro-step counter, so a resumed arm probes its first probe_steps AFTER
512
+ # the resume point (a bare probe_steps cap trained one micro-step and
513
+ # crashed throughput_probe on every supervisor retry).
514
+ run_cfg.train.max_micro_steps = probe_micro_cap(trainer.micro_step, run_cfg.train.probe_steps)
515
+ trainer.train()
516
+ run_cfg.train.max_micro_steps = None
517
+ if trainer.stop_exit_code:
518
+ # A coordinated stop (SIGTERM / train.max_wall_seconds) saved at an
519
+ # optimizer-step boundary; the stage is NOT finished. Exit with the
520
+ # documented status instead of evaluating / reporting a half-trained arm.
521
+ raise SystemExit(trainer.stop_exit_code)
522
+ if resuming and len(trainer.step_times) < trainer.accum:
523
+ # Resumed within one optimizer step of the horizon (or after the last
524
+ # step): the probe window cannot fill a group. The run still finishes;
525
+ # the banner says the wall-clock was not measured rather than raising.
526
+ probe = {
527
+ "step_time_s_median": float("nan"),
528
+ "projected_wall_clock_h": float("nan"),
529
+ "status": "UNMEASURED_ON_RESUME",
530
+ }
531
+ else:
532
+ probe = trainer.throughput_probe(tokens_per_step)
533
+ state["probe"] = probe
534
+
535
+ banner = dpo_banner(
536
+ cfg=cfg,
537
+ manifest=manifest,
538
+ ref_cache_key=state["ref_cache_key"],
539
+ path_bias=state["path_bias"],
540
+ probe=probe,
541
+ rss=state["rss"],
542
+ backend=banner_value(
543
+ # The RESOLVED backend, never a literal: FSDP2 mutates the module
544
+ # in place so the attribute normally survives wrapping, and the
545
+ # fallback is what the selector resolved at load -- a hardcoded
546
+ # "sdpa" here would let the banner contradict the kernel that ran.
547
+ dist_model.attn_implementation
548
+ if hasattr(dist_model, "attn_implementation")
549
+ else state["attn_backend"]
550
+ ),
551
+ sft_gate=state["sft_gate"],
552
+ length_baseline=length_baseline,
553
+ )
554
+ print(render_dpo_banner(banner))
555
+
556
+ summary = trainer.train()
557
+ if trainer.stop_exit_code:
558
+ # A coordinated stop (SIGTERM / train.max_wall_seconds) saved at an
559
+ # optimizer-step boundary; the stage is NOT finished. Exit with the
560
+ # documented status instead of evaluating / reporting a half-trained arm.
561
+ raise SystemExit(trainer.stop_exit_code)
562
+ checkpoint = trainer.save_checkpoint()
563
+
564
+ import torch as _torch
565
+
566
+ _dev = _torch.device("cuda", _torch.cuda.current_device())
567
+ # .to(_dev): the held-out path missed the device move the training
568
+ # adapter got -- CPU labels reached the CUDA gather AFTER the full arm
569
+ # had trained and saved (Arm A rerun, 2026-08-20).
570
+ heldout_batches = [
571
+ collate_preference(heldout_rows[i : i + cfg.micro_batch_size], pad_token_id=pad_id).to(_dev)
572
+ for i in range(0, len(heldout_rows), cfg.micro_batch_size)
573
+ ]
574
+ heldout = heldout_preference_accuracy(
575
+ dist_model, heldout_batches, state["heldout_cache"], beta=cfg.beta
576
+ )
577
+ logger.info(
578
+ "held-out preference accuracy: %.4f [%.4f, %.4f] n=%d",
579
+ heldout["accuracy"],
580
+ heldout["ci_low"],
581
+ heldout["ci_high"],
582
+ heldout["n"],
583
+ )
584
+ return {"summary": summary, "heldout": heldout, "banner": banner, "checkpoint": checkpoint}
585
+
586
+
587
+ if __name__ == "__main__":
588
+ from align.exit_codes import run_with_exit_codes
589
+
590
+ raise SystemExit(run_with_exit_codes(main))
code/scripts/eval.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin eval entry: single / paired / golden.
2
+
3
+ python scripts/eval.py configs/eval.toml --mode=single --model.path=...
4
+
5
+ ORDER OF OPERATIONS IS LOAD-BEARING
6
+ -----------------------------------
7
+ 1. `apply_resource_guards` runs ONLY when we are not inside a multiprocessing
8
+ child. The verifier's worker pool uses 'spawn', which RE-IMPORTS this
9
+ module in every worker; a resource guard or an exclusive flock at module
10
+ scope therefore executes in each child and kills it on startup. Every
11
+ future then dies with BrokenProcessPool -- which is precisely the
12
+ documented cause of the "all answers incorrect" masquerade the verifier
13
+ raises on. The guard is the fix; `_in_multiprocessing_child()` is what
14
+ makes it safe.
15
+ 2. Banner next: resolved config, dataset hashes, MDE, model hash, backend.
16
+ A run that cannot answer its own question does not begin.
17
+ 3. Then dispatch.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import os
24
+ import sys
25
+
26
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+ if REPO_ROOT not in sys.path:
28
+ sys.path.insert(0, REPO_ROOT)
29
+
30
+ from align.utils.guard import _in_multiprocessing_child # noqa: E402
31
+
32
+ # See docstring point 1. This condition is not an optimization.
33
+ if not _in_multiprocessing_child():
34
+ from align.utils.guard import apply_resource_guards
35
+
36
+ apply_resource_guards(cpu_threads=8, heavy=False, label="eval")
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ p = argparse.ArgumentParser(description="nmo evaluation entry")
41
+ p.add_argument("config", nargs="?", default="configs/eval.toml")
42
+ p.add_argument("--mode", choices=("single", "paired", "golden"), default="single")
43
+ p.add_argument(
44
+ "--out", default=None, help="report path; defaults to outputs/eval/ under the repo"
45
+ )
46
+ p.add_argument(
47
+ "overrides", nargs="*", default=[], help="dotted overrides, e.g. eval.limit=8"
48
+ )
49
+ return p
50
+
51
+
52
+ def default_out_dir() -> str:
53
+ """Reports land under /apps -- asserted, never assumed."""
54
+ from align.utils.guard import assert_paths_off_root
55
+
56
+ out = os.path.join(REPO_ROOT, "outputs", "eval")
57
+ os.makedirs(out, exist_ok=True)
58
+ assert_paths_off_root([out])
59
+ return out
60
+
61
+
62
+ def main(argv=None) -> int:
63
+ args = build_parser().parse_args(argv)
64
+
65
+ from align.config import load_toml
66
+
67
+ # Schemaless on purpose (eval.toml has no dataclass): plain nested dict,
68
+ # dotted overrides TOML-literal parsed. Same shape OmegaConf.to_object gave.
69
+ cfg = load_toml(args.config, list(args.overrides))
70
+ out_dir = args.out or default_out_dir()
71
+
72
+ if args.mode == "golden":
73
+ from align.eval.golden import run_golden_regression
74
+
75
+ banner: dict = {}
76
+ path = os.path.join(out_dir, "checkpoint-200-golden.json")
77
+ # `run_golden_regression` writes the report ITSELF, before gating, so
78
+ # a failing gate still leaves the evidence. Writing again here would
79
+ # be dead code on the path that matters: the gate raises, and anything
80
+ # after this call never runs.
81
+ try:
82
+ run_golden_regression(banner=banner, out_path=path)
83
+ finally:
84
+ # U01-9: name the eval source that produced the report (also when the gate raised), so
85
+ # `record_golden_from_report` cannot re-certify it after align/eval/ changes.
86
+ if os.path.isfile(path):
87
+ from align.train.stage_gates import stamp_golden_report
88
+
89
+ stamp_golden_report(path)
90
+ print(f"golden gate PASSED; report at {path}")
91
+ return 0
92
+
93
+ raise SystemExit(
94
+ f"mode {args.mode!r} needs a generation backend, which arrives "
95
+ "with section-04 (nmo model) / section-09 (engine). The harness "
96
+ "itself is complete and tested: run_eval takes a generate_fn. "
97
+ "Use --mode=golden, which uses plain HF generation by design."
98
+ )
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
code/scripts/eval_checkpoint.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone checkpoint eval: fresh process, single GPU, golden-proven generator.
2
+
3
+ Promotion of the .tmp/eval_gate.py driver (task #26) into a parameterized
4
+ entry point. The design facts it carries forward are all incident-derived:
5
+
6
+ * FRESH PROCESS, never in-trainer: the train->eval handoff inside one
7
+ process produced the rank race (peers built a second 7B while rank 0
8
+ saved) and the orphan-GPU-process incident. A checkpoint on disk plus a
9
+ fresh process has neither failure mode.
10
+ * EVERYTHING under the __main__ guard: the verifier's scoring pool uses
11
+ the 'spawn' context, which RE-IMPORTS __main__ in every worker -- the
12
+ unguarded first driver had all 8 workers load their own 7B onto the
13
+ eval GPU.
14
+ * greedy generation via `golden_generate_fn` (in-repo Engine, same encode
15
+ as the golden regression: BOS default, truncation 768). The anchors and
16
+ banked SFT rows were measured with HF generate; the generator swap is
17
+ certified by the golden regression + checkpoint roundtrip gate, and the
18
+ generator is recorded in the report config.
19
+ * Atomic report write (.partial -> os.replace): a half-written report
20
+ masquerades as a complete one.
21
+
22
+ Phase-0 usage (the Arm B result side of the primary McNemar):
23
+
24
+ CUDA_VISIBLE_DEVICES=0 python scripts/eval_checkpoint.py \
25
+ --checkpoint runs/dpo_arm_b_response/checkpoints/micro_XXXXXXXX \
26
+ --name arm-b-final --gsm8k-n 1319 --math500-n 0
27
+
28
+ `--quality-gate` additionally runs the SFT-stage quality gate + ledger
29
+ records; leave it off for non-SFT checkpoints (the gate's anchors are
30
+ SFT-specific).
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ # Run from a checkout without an install: put the repo root on sys.path first.
40
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
41
+
42
+ from align.utils.guard import apply_resource_guards
43
+
44
+
45
+ def quality_gate_size_errors(cfg, math500_n: int, gsm8k_n: int, per_set: dict | None = None) -> list[str]:
46
+ """Why a run of these sizes may NOT write the SFT quality-gate receipt (empty = fine).
47
+
48
+ The receipt unlocks DPO. `assert_meets_golden` accepts any point estimate inside the target's own
49
+ Wilson CI, which is enormous at tiny n (1/5 passes the 0.09 anchor), so the gate certifies only a
50
+ FULL-size evaluation: both sets, at the configured n the anchors were recorded at. `per_set` (the
51
+ measured report) is checked too, in case a set held fewer problems than requested."""
52
+ want = {"math500": cfg.eval.math500_n, "gsm8k": cfg.eval.gsm8k_n}
53
+ asked = {"math500": math500_n, "gsm8k": gsm8k_n}
54
+ errs = [f"{k}: requested n={asked[k]}, the quality gate needs the full configured n={want[k]}"
55
+ for k in want if asked[k] != want[k]]
56
+ for k, w in want.items():
57
+ got = (per_set or {}).get(k, {}).get("n") if per_set is not None else w
58
+ if got != w:
59
+ errs.append(f"{k}: measured n={got}, the quality gate needs n={w}")
60
+ return errs
61
+
62
+
63
+ def main() -> int:
64
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
65
+ parser.add_argument("--checkpoint", required=True, help="checkpoint dir (HF-layout safetensors dir)")
66
+ parser.add_argument("--config", default="configs/sft.toml")
67
+ parser.add_argument("--name", required=True, help="report model_name and output stem")
68
+ parser.add_argument("--out", default=None, help="default: outputs/eval/<name>.json")
69
+ parser.add_argument("--math500-n", type=int, default=None, help="0 skips; default: config")
70
+ parser.add_argument("--gsm8k-n", type=int, default=None, help="0 skips; default: config")
71
+ parser.add_argument(
72
+ "--quality-gate", action="store_true", help="SFT-stage gate + ledger records"
73
+ )
74
+ args = parser.parse_args()
75
+
76
+ # Guards BEFORE torch import (env vars are load-bearing); heavy=True takes
77
+ # the exclusive job lock -- a 7B generation eval over 1,319 problems is a
78
+ # multi-hour GPU job and must serialise behind training like any other.
79
+ apply_resource_guards(cpu_threads=8, heavy=True, label=f"eval-{args.name}")
80
+
81
+
82
+ from align.config import load_config # noqa: E402
83
+ from align.eval.golden import golden_generate_fn, load_checkpoint_model # noqa: E402
84
+ from align.eval.harness import EvalConfig, load_eval_set, run_eval # noqa: E402
85
+ from align.eval.verifier import OutcomeVerifier # noqa: E402
86
+ from align.tokenizer import load_tokenizer # noqa: E402
87
+ from align.train.stage_smoke import _manifest_hashes, _mde # noqa: E402
88
+
89
+ cfg = load_config(args.config)
90
+ checkpoint = args.checkpoint
91
+ assert os.path.isdir(checkpoint), checkpoint
92
+ out_path = args.out or os.path.join("outputs", "eval", f"{args.name}.json")
93
+
94
+ math500_n = cfg.eval.math500_n if args.math500_n is None else args.math500_n
95
+ gsm8k_n = cfg.eval.gsm8k_n if args.gsm8k_n is None else args.gsm8k_n
96
+ if args.quality_gate:
97
+ errs = quality_gate_size_errors(cfg, math500_n, gsm8k_n)
98
+ if errs:
99
+ print("refusing --quality-gate on a subset eval (the receipt unlocks DPO):\n " + "\n ".join(errs),
100
+ file=sys.stderr)
101
+ return 2
102
+ problems = []
103
+ # 0 in this CLI means SKIP the set (checked here); upstream load_eval_set
104
+ # uses limit=0 for "no limit", which this branch structure never passes
105
+ # ambiguously -- a negative value falls through to upstream validation.
106
+ if math500_n != 0:
107
+ problems += load_eval_set(cfg.paths.math500_path, limit=math500_n)
108
+ if gsm8k_n != 0:
109
+ problems += load_eval_set(cfg.paths.gsm8k_path, limit=gsm8k_n)
110
+ if not problems:
111
+ print("nothing to evaluate (both sets skipped)", file=sys.stderr)
112
+ return 2
113
+
114
+ # After every refusal above: a 7B load is the expensive step.
115
+ tokenizer = load_tokenizer(checkpoint)
116
+ model = load_checkpoint_model(checkpoint, device="cuda")
117
+
118
+ verifier = OutcomeVerifier()
119
+ gen_fn = golden_generate_fn(model, tokenizer, "cuda")
120
+ print(f"evaluating {args.name}: {len(problems)} problems from {checkpoint}", flush=True)
121
+ rep = run_eval(problems, gen_fn, verifier, EvalConfig(), model_name=args.name)
122
+ per_set = rep.per_set
123
+ for name, scores in sorted(per_set.items()):
124
+ print(f" {name}: n={scores.get('n')} pass@1={scores.get('pass@1')}", flush=True)
125
+
126
+ report = {
127
+ "per_set": per_set,
128
+ "config": {
129
+ "mde": _mde(cfg),
130
+ "n_problems": sum(r.get("n", 0) for r in per_set.values()),
131
+ "eval_set_hash": _manifest_hashes(cfg),
132
+ "generator": "align Engine greedy (golden_generate_fn)",
133
+ "checkpoint": checkpoint,
134
+ },
135
+ "pooled": rep.pooled,
136
+ "per_problem": rep.per_problem,
137
+ }
138
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
139
+ tmp = out_path + ".partial"
140
+ with open(tmp, "w") as f:
141
+ json.dump(report, f, indent=1, default=str)
142
+ os.replace(tmp, out_path)
143
+ print(f"report written: {out_path}", flush=True)
144
+
145
+ if not args.quality_gate:
146
+ return 0
147
+
148
+ from align.train.stage_gates import ( # noqa: E402
149
+ GateLedger,
150
+ eval_source_hash,
151
+ quality_gate,
152
+ write_quality_gate_receipt,
153
+ )
154
+
155
+ errs = quality_gate_size_errors(cfg, math500_n, gsm8k_n, per_set=per_set)
156
+ if errs: # report is already written above; only the receipt is withheld
157
+ print("refusing to write the quality-gate receipt:\n " + "\n ".join(errs), file=sys.stderr)
158
+ return 2
159
+
160
+ run_dir = os.path.join(cfg.paths.checkpoint_dir, "sft")
161
+ ledger = GateLedger(run_dir, eval_source_hash=eval_source_hash())
162
+ ledger.record("eval", passed=True, detail=json.dumps(per_set, default=str))
163
+ decision = quality_gate(report, cfg)
164
+ ledger.record("quality_gate", passed=decision.passed, detail=decision.halt_message[:400])
165
+ write_quality_gate_receipt(cfg, decision, checkpoint=checkpoint)
166
+ print(f"QUALITY GATE: passed={decision.passed}", flush=True)
167
+ if not decision.passed:
168
+ print(decision.halt_message, file=sys.stderr, flush=True)
169
+ return 0 if decision.passed else 1
170
+
171
+
172
+ if __name__ == "__main__":
173
+ # See module docstring: spawn workers re-import __main__; nothing above
174
+ # this guard may build a model or touch the GPU.
175
+ sys.exit(main())
code/scripts/gate_flash_attention.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the triton_fa2 equivalence suite and, ONLY on success, write the gate.
2
+
3
+ Run (pin ONE free device):
4
+ CUDA_VISIBLE_DEVICES=<free gpu> uv run python scripts/gate_flash_attention.py
5
+
6
+ This is the mechanism behind the never-silently-claimed rule. The gate artifact
7
+ is the ONLY thing that makes `triton_fa2` selectable
8
+ (`align.kernels.gate.resolve_triton_fa2_backend` raises without it), and this
9
+ script is the only writer. It records the kernel source hash, the torch/triton
10
+ versions, the exact GPU, and the tolerances and shapes the suite covered, so a
11
+ later edit to the kernel or a move to a different card invalidates it
12
+ automatically.
13
+
14
+ Deliberately a separate entry point rather than a pytest hook: writing the gate
15
+ must require an explicit human action on a machine with a real GPU, and must
16
+ never be a side effect of someone running the test suite with `-k` narrowing
17
+ the selection to a subset.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ import xml.etree.ElementTree as ET
26
+
27
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ sys.path.insert(0, REPO)
29
+
30
+ from align.kernels import gate as gate_mod
31
+
32
+ #: Kept in step with tests/test_flash_attention.py. Recorded in the artifact so
33
+ #: a reader knows what "verified" actually covered.
34
+ TOLERANCES = {
35
+ "policy": "max |err| vs fp32 reference <= max(3.0 x torch-SDPA-bf16 error, 2e-2)",
36
+ "slack_over_sdpa": 3.0,
37
+ "absolute_floor": 2e-2,
38
+ }
39
+
40
+ SHAPES_COVERED = [
41
+ "gqa (n_q_heads, n_kv_heads) in [(8,2), (32,8)] plus MHA-degenerate (4,4)",
42
+ "head_dim in [64, 128]",
43
+ "seq in [192, 256, 512, 1000] (block-divisible AND non-divisible)",
44
+ "causal and non-causal",
45
+ "key_padding_mask, right-padded, non-block-aligned",
46
+ "every autotune config individually pinned (fwd + both bwd kernels)",
47
+ "dtype bfloat16 only (fp16/fp32 rejected)",
48
+ ]
49
+
50
+
51
+ def _count_outcomes(junit_path: str) -> tuple[int, int, int]:
52
+ """(passed, skipped, failed+errored) from a pytest junit report.
53
+
54
+ pytest exits 0 when EVERY test is skipped (no CUDA, more than one device
55
+ visible, heavy-job lock held), so the exit status alone cannot prove the
56
+ kernel ran. Raises if the report is missing or unparseable.
57
+ """
58
+ root = ET.parse(junit_path).getroot()
59
+ passed = skipped = bad = 0
60
+ for case in root.iter("testcase"):
61
+ if case.find("skipped") is not None:
62
+ skipped += 1
63
+ elif case.find("failure") is not None or case.find("error") is not None:
64
+ bad += 1
65
+ else:
66
+ passed += 1
67
+ return passed, skipped, bad
68
+
69
+
70
+ def main() -> int:
71
+ cvd = os.environ.get("CUDA_VISIBLE_DEVICES", "")
72
+ devices = [d.strip() for d in cvd.split(",")]
73
+ if len(devices) != 1 or not devices[0]:
74
+ print(
75
+ "refusing to run: set CUDA_VISIBLE_DEVICES to exactly ONE free device "
76
+ f"(got {cvd!r}; shared box, and the suite skips unless exactly one is visible).",
77
+ file=sys.stderr,
78
+ )
79
+ return 2
80
+
81
+ junit = os.path.join(REPO, ".cache", "gate_flash_attention_junit.xml")
82
+ os.makedirs(os.path.dirname(junit), exist_ok=True)
83
+ if os.path.exists(junit):
84
+ os.remove(junit) # a stale report must never vouch for this run
85
+ cmd = [
86
+ sys.executable,
87
+ "-m",
88
+ "pytest",
89
+ os.path.join(REPO, "tests", "test_flash_attention.py"),
90
+ "-m",
91
+ "gpu",
92
+ "-q",
93
+ "-rs",
94
+ f"--junitxml={junit}",
95
+ ]
96
+ print("running the equivalence suite:", " ".join(cmd), flush=True)
97
+ result = subprocess.run(cmd, cwd=REPO)
98
+
99
+ if result.returncode != 0:
100
+ print(
101
+ "\nequivalence suite FAILED -- gate NOT written. triton_fa2 stays "
102
+ "unselectable; SDPA remains the backend.",
103
+ file=sys.stderr,
104
+ )
105
+ return result.returncode
106
+
107
+ # Exit 0 is necessary, not sufficient: an all-skipped run also exits 0.
108
+ try:
109
+ passed, skipped, bad = _count_outcomes(junit)
110
+ except (OSError, ET.ParseError) as e:
111
+ print(f"\ncannot read the junit report ({e!r}) -- gate NOT written.", file=sys.stderr)
112
+ return 1
113
+ if passed == 0 or skipped != 0 or bad != 0:
114
+ print(
115
+ f"\nequivalence suite did not EXECUTE in full (passed={passed}, "
116
+ f"skipped={skipped}, failed={bad}) -- gate NOT written. Skips mean "
117
+ "no CUDA device, a device count != 1, or the heavy-job lock held "
118
+ "by a live job; none of those verifies the kernel.",
119
+ file=sys.stderr,
120
+ )
121
+ return 1
122
+
123
+ path = gate_mod.write_equivalence_gate(tolerances=TOLERANCES, shapes_covered=SHAPES_COVERED)
124
+ ok, reason = gate_mod.gate_status()
125
+ print(f"\nequivalence suite passed ({passed} tests); gate written to {path}")
126
+ print(f"gate_status -> passed={ok} ({reason})")
127
+ print(f"kernel sha256: {gate_mod.kernel_source_hash()}")
128
+ return 0 if ok else 1
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())
code/scripts/generate.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Generate completions with the align engine (or the bucketed fallback).
3
+
4
+ uv run python scripts/generate.py \
5
+ --checkpoint /apps/development/rlhf/nano-mistral-o1/checkpoints/sft \
6
+ --prompts data/eval/math500.json --out .tmp/gen/math500.jsonl
7
+
8
+ Sharding across the 4 A10Gs is done by the CALLER, one process per device --
9
+ the engine is single-replica and knows nothing about other GPUs (no
10
+ collectives, no NCCL). The idiom, which respects the one-heavy-job rule:
11
+
12
+ for i in 0 1 2 3; do
13
+ RANK=$i CUDA_VISIBLE_DEVICES=$i uv run python scripts/generate.py \
14
+ --shard-index $i --num-shards 4 --out .tmp/gen/shard$i.jsonl &
15
+ done; wait
16
+
17
+ `acquire_heavy_job_lock` is rank-aware, so only RANK=0 takes the exclusive
18
+ flock; shards 1.. do NOT take it, they VERIFY it (`verify_shard_lock`): a lock
19
+ held by anything other than another generate.py (their own shard 0) refuses
20
+ the launch, so a live training stage cannot be overlapped by three of the four
21
+ shards. Every shard also runs the host preflight. Never launch this against a
22
+ live training stage: that overlap is what killed the predecessor's first SFT
23
+ run. Completions carry rid "<shard>:<local i>"; the global prompt index of
24
+ local i is shard_index + i * num_shards (stride sharding).
25
+
26
+ Every output path is asserted onto /apps (`/` has ~13GB and /tmp is a noexec
27
+ tmpfs). The launch banner prints the checkpoint fingerprint, the attention
28
+ backend that ACTUALLY ran, the engine budget, the sampling params and the
29
+ per-gate status; a missing mandatory key raises rather than printing a partial
30
+ banner, and gate status is "PENDING" until recorded -- never assumed passed.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import logging
38
+ import os
39
+ import sys
40
+ import time
41
+ from pathlib import Path
42
+
43
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
44
+
45
+ from align.utils.guard import apply_resource_guards # noqa: E402
46
+
47
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
48
+ logger = logging.getLogger("generate")
49
+
50
+ PROJECT_ROOT = "/apps/development/rlhf/nano-mistral-o1"
51
+
52
+
53
+ def validate_shards(shard_index: int, num_shards: int) -> None:
54
+ """An out-of-range shard silently yields an EMPTY prompt list (and a "successful" empty run)."""
55
+ if num_shards < 1 or not (0 <= shard_index < num_shards):
56
+ raise ValueError(f"bad shard spec: --shard-index {shard_index} must be in [0, --num-shards {num_shards})")
57
+
58
+
59
+ def _proc_cmdline(pid: int) -> str:
60
+ try:
61
+ with open(f"/proc/{pid}/cmdline", "rb") as f:
62
+ return f.read().replace(b"\0", b" ").decode(errors="replace")
63
+ except OSError:
64
+ return ""
65
+
66
+
67
+ def verify_shard_lock(rank: int, lock_inode: int | None = None, locks_text: str | None = None, cmdline_of=_proc_cmdline) -> None:
68
+ """Shards 1.. never take the heavy-job flock (`acquire_heavy_job_lock` returns True for RANK != 0),
69
+ so without this a generation launched while a trainer holds it would be refused on shard 0 only,
70
+ while shards 1-3 each loaded a 7B onto GPUs 1-3. Read-only: scans /proc/locks for the holder of the
71
+ lock file (taking the flock here would race shard 0's own non-blocking acquire). The only holder
72
+ tolerated is another generate.py -- this launch's own shard 0."""
73
+ if rank == 0:
74
+ return
75
+ from align.exit_codes import PreflightRefused
76
+ from align.utils.guard import LOCK_PATH
77
+
78
+ if lock_inode is None:
79
+ try:
80
+ lock_inode = os.stat(LOCK_PATH).st_ino
81
+ except FileNotFoundError:
82
+ return # no lock file yet, so no holder
83
+ if locks_text is None:
84
+ with open("/proc/locks") as f:
85
+ locks_text = f.read()
86
+ for line in locks_text.splitlines():
87
+ parts = line.split()
88
+ if len(parts) < 6 or parts[1] != "FLOCK" or parts[5].split(":")[-1] != str(lock_inode):
89
+ continue
90
+ holder = cmdline_of(int(parts[4]))
91
+ if "generate.py" not in holder:
92
+ raise PreflightRefused(
93
+ f"another heavy align job holds the lock (pid {parts[4]}: {holder.strip()[:120]!r}); this shard "
94
+ f"(RANK={rank}) must not overlap it. Shard 0 refuses for the same reason."
95
+ )
96
+
97
+
98
+ def read_prompts(path: str) -> list[str]:
99
+ """Accept .json (list of str, or list of dicts with a prompt/problem/text
100
+ key), .jsonl (same per line), or .txt (one prompt per line). Refuses an
101
+ empty file loudly: an eval that silently generates nothing looks like a
102
+ fast eval."""
103
+
104
+ def _field(obj):
105
+ if isinstance(obj, str):
106
+ return obj
107
+ for k in ("prompt", "problem", "question", "text"):
108
+ if k in obj:
109
+ return obj[k]
110
+ raise ValueError(
111
+ f"prompt record has none of prompt/problem/question/text: {sorted(obj)[:8]}"
112
+ )
113
+
114
+ if path.endswith(".jsonl"):
115
+ with open(path) as f:
116
+ rows = [_field(json.loads(line)) for line in f if line.strip()]
117
+ elif path.endswith(".json"):
118
+ with open(path) as f:
119
+ rows = [_field(o) for o in json.load(f)]
120
+ else:
121
+ with open(path) as f:
122
+ rows = [line.rstrip("\n") for line in f if line.strip()]
123
+ if not rows:
124
+ raise ValueError(f"{path} contains no prompts")
125
+ return rows
126
+
127
+
128
+ def main() -> int:
129
+ ap = argparse.ArgumentParser()
130
+ ap.add_argument(
131
+ "--checkpoint", required=True, help="HF-layout checkpoint directory (safetensors)"
132
+ )
133
+ ap.add_argument("--prompts", required=True)
134
+ ap.add_argument("--out", required=True, help="completions JSONL; under /apps")
135
+ ap.add_argument("--tokenizer", default=None, help="defaults to --checkpoint")
136
+ ap.add_argument("--n-slots", type=int, default=32)
137
+ ap.add_argument("--max-seq-len", type=int, default=1024)
138
+ ap.add_argument("--max-new-tokens", type=int, default=300)
139
+ ap.add_argument("--temperature", type=float, default=0.0)
140
+ ap.add_argument("--top-p", type=float, default=1.0)
141
+ ap.add_argument(
142
+ "--stop", action="append", default=[], help="repeatable; matched on DECODED TEXT"
143
+ )
144
+ ap.add_argument("--stop-policy", default="trim", choices=("trim", "keep"))
145
+ ap.add_argument("--seed", type=int, default=0)
146
+ ap.add_argument(
147
+ "--attn", default=None, help="force an attention backend; unavailable => hard error"
148
+ )
149
+ ap.add_argument(
150
+ "--bucketed",
151
+ action="store_true",
152
+ help="use the static-batching fallback instead of the "
153
+ "engine (correctness reference; slower)",
154
+ )
155
+ ap.add_argument(
156
+ "--benchmark",
157
+ action="store_true",
158
+ help="run BOTH and write the engine-vs-static-batching speedup receipt beside --out",
159
+ )
160
+ ap.add_argument(
161
+ "--hash-weights",
162
+ action="store_true",
163
+ help="full content hash of the checkpoint (reads 14.5GB)",
164
+ )
165
+ ap.add_argument("--limit", type=int, default=None)
166
+ ap.add_argument("--shard-index", type=int, default=0)
167
+ ap.add_argument("--num-shards", type=int, default=1)
168
+ ap.add_argument("--strict-host-check", default=True, action=argparse.BooleanOptionalAction)
169
+ ap.add_argument(
170
+ "--heavy-lock",
171
+ default=True,
172
+ action=argparse.BooleanOptionalAction,
173
+ help="take the exclusive one-heavy-job flock. Keep it ON "
174
+ "for any real checkpoint. --no-heavy-lock exists for "
175
+ "the tiny-model CLI smoke test, which loads ~200KB "
176
+ "and is not a heavy job; using it to squeeze a 7B "
177
+ "run past a live training stage is the exact overlap "
178
+ "that killed the predecessor's first SFT run.",
179
+ )
180
+ args = ap.parse_args()
181
+ try:
182
+ validate_shards(args.shard_index, args.num_shards)
183
+ except ValueError as e:
184
+ ap.error(str(e))
185
+
186
+ apply_resource_guards(
187
+ cpu_threads=8,
188
+ heavy=args.heavy_lock,
189
+ label="generate",
190
+ strict_host_check=args.strict_host_check,
191
+ )
192
+ if args.heavy_lock:
193
+ verify_shard_lock(int(os.environ.get("RANK", "0")))
194
+
195
+ import torch
196
+
197
+ from align.inference.bucketed import bucketed_generate
198
+ from align.inference.engine import (
199
+ Engine,
200
+ GenerationRequest,
201
+ InferenceConfig,
202
+ benchmark_engine_vs_bucketed,
203
+ )
204
+ from align.inference.receipts import (
205
+ checkpoint_fingerprint,
206
+ engine_banner,
207
+ load_gate_status,
208
+ sha256_file,
209
+ write_completions_jsonl,
210
+ )
211
+ from align.model import checkpoint as ckpt
212
+ from align.model.mistral import NmoMistralForCausalLM
213
+ from align.registry import render_banner
214
+ from align.utils.guard import assert_paths_off_root
215
+
216
+ assert_paths_off_root([os.path.dirname(os.path.abspath(args.out))])
217
+
218
+ prompts = read_prompts(args.prompts)
219
+ if args.limit:
220
+ prompts = prompts[: args.limit]
221
+ # Shard by stride, not by contiguous block: prompt files are often sorted
222
+ # by length or difficulty, and contiguous blocks would give one replica
223
+ # all the long ones and skew both the wall-clock and the benchmark.
224
+ if args.num_shards > 1:
225
+ prompts = prompts[args.shard_index :: args.num_shards]
226
+ logger.info("shard %d/%d: %d prompts", args.shard_index, args.num_shards, len(prompts))
227
+
228
+ from align.eval.golden import resolve_model_config
229
+ from align.tokenizer import load_tokenizer
230
+
231
+ tok = load_tokenizer(args.tokenizer or args.checkpoint)
232
+ state = ckpt.load_hf_safetensors(args.checkpoint)
233
+ # `read_config` (align.model.hf_config) states every architecture field
234
+ # explicitly and REFUSES a config.json that omits one: transformers'
235
+ # MistralConfig used to resolve the missing ones to its own defaults
236
+ # (sliding_window=4096, rms_norm_eps=1e-6 -- neither of them v0.3), which
237
+ # silently accepted a checkpoint that was a different model.
238
+ model_cfg = resolve_model_config(args.checkpoint)
239
+ model = NmoMistralForCausalLM.from_state_dict(
240
+ model_cfg,
241
+ ckpt.hf_to_nmo(state),
242
+ attn_implementation=args.attn,
243
+ device="cuda" if torch.cuda.is_available() else "cpu",
244
+ ).eval()
245
+ if torch.cuda.is_available():
246
+ model = model.to("cuda", dtype=torch.bfloat16)
247
+
248
+ cfg = InferenceConfig(
249
+ n_slots=args.n_slots,
250
+ max_seq_len=args.max_seq_len,
251
+ max_new_tokens=args.max_new_tokens,
252
+ temperature=args.temperature,
253
+ top_p=args.top_p,
254
+ stop=tuple(args.stop),
255
+ stop_string_policy=args.stop_policy,
256
+ seed=args.seed,
257
+ eos_token_id=tok.eos_token_id,
258
+ pad_token_id=tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id,
259
+ )
260
+ engine = Engine(model, cfg, tokenizer=tok)
261
+
262
+ requests = [
263
+ GenerationRequest(
264
+ rid=f"{args.shard_index}:{i}", prompt_ids=tok(p, add_special_tokens=True)["input_ids"]
265
+ )
266
+ for i, p in enumerate(prompts)
267
+ ]
268
+
269
+ gate_status = load_gate_status(args.checkpoint)
270
+ banner = engine_banner(
271
+ engine=engine,
272
+ checkpoint=checkpoint_fingerprint(args.checkpoint, args.hash_weights),
273
+ gate_status=gate_status,
274
+ prompts_sha256=sha256_file(args.prompts),
275
+ extra={
276
+ "n_prompts": len(requests),
277
+ "path_bucketed": args.bucketed,
278
+ "shard": f"{args.shard_index}/{args.num_shards}",
279
+ "global_prompt_index": f"{args.shard_index} + i * {args.num_shards} (i = local index in rid)",
280
+ },
281
+ )
282
+ print(render_banner(banner, title="align generate"))
283
+ if not banner["engine_generated_numbers_usable"] and not args.bucketed:
284
+ logger.warning(
285
+ "the 3-part engine gate has NOT fully passed for this checkpoint "
286
+ "(%s). These completions are fine for inspection; section-10 must "
287
+ "use --bucketed until gate (c) is recorded.",
288
+ gate_status,
289
+ )
290
+
291
+ t0 = time.perf_counter()
292
+ if args.bucketed:
293
+ results = bucketed_generate(model, requests, cfg, tokenizer=tok)
294
+ else:
295
+ results = engine.generate(requests)
296
+ wall = time.perf_counter() - t0
297
+ n_tokens = sum(r.n_generated_tokens for r in results)
298
+ receipt = dict(banner)
299
+ receipt["throughput"] = {
300
+ "wall_s": round(wall, 3),
301
+ "generated_tokens": n_tokens,
302
+ "tokens_per_s": round(n_tokens / wall, 2) if wall > 0 else float("nan"),
303
+ }
304
+ write_completions_jsonl(args.out, results, receipt, tokenizer=tok)
305
+ logger.info(
306
+ "%d completions, %d tokens, %.1fs (%.1f tok/s)",
307
+ len(results),
308
+ n_tokens,
309
+ wall,
310
+ n_tokens / wall if wall else float("nan"),
311
+ )
312
+
313
+ if args.benchmark:
314
+ bench_path = os.path.splitext(args.out)[0] + ".benchmark.json"
315
+ rec = benchmark_engine_vs_bucketed(model, requests, cfg, tokenizer=tok, out_path=bench_path)
316
+ logger.info(
317
+ "engine vs static batching: measured speedup %.2fx "
318
+ "(engine %.1f tok/s, bucketed %.1f tok/s)",
319
+ rec["speedup"],
320
+ rec["engine_tokens_per_s"],
321
+ rec["bucketed_tokens_per_s"],
322
+ )
323
+ return 0
324
+
325
+
326
+ if __name__ == "__main__":
327
+ raise SystemExit(main())
code/scripts/grpo.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Stage-b GRPO entry: gates -> worker -> banner -> train -> stage manifest.
3
+
4
+ CUDA_VISIBLE_DEVICES=0,1,2 torchrun --nproc_per_node=3 scripts/grpo.py \
5
+ configs/grpo_stageb.toml
6
+ # tiny end-to-end smoke (2 prompts x G8 x 32 tokens x 10 steps):
7
+ CUDA_VISIBLE_DEVICES=0,1,2 torchrun --nproc_per_node=3 scripts/grpo.py \
8
+ configs/grpo_stageb.toml --smoke
9
+
10
+ Topology (v1, DESIGN v3 §B.1b): 3 FSDP train ranks + one dedicated vLLM
11
+ engine GPU (default 3, override NMO_ROLLOUT_GPU). Rank 0 owns all protocol
12
+ I/O; checkpoint saves ARE the weight sync (the worker reloads the engine
13
+ whenever a request's weights_dir changes). The loop is section-07's Trainer
14
+ verbatim -- GrpoBatchSource yields lazy handles and the loss-adapter wrapper
15
+ materialises each optimizer step's rollout at consumption time.
16
+
17
+ Launch gates, ordered by cost: config validation (denominator arithmetic,
18
+ cadence/sync alignment, R3 clipping, R4 canary) -> ceiling receipt
19
+ (0/100-perfect-policy trap) -> banding manifest -> topology check ->
20
+ A3 conformance + entropy-when-armed (module construction) -> horizon
21
+ consistency (scheduler vs configured steps).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import atexit
28
+ import json
29
+ import logging
30
+ import os
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
35
+
36
+ from align.utils.guard import apply_resource_guards # noqa: E402
37
+
38
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
39
+ logger = logging.getLogger("grpo")
40
+
41
+
42
+ #: The rollout worker runs in its OWN session (torchrun's teardown does not reach it) and used to
43
+ #: serve forever. If rank 0 is SIGKILLed it is orphaned holding ~85% of its GPU, so it now exits by
44
+ #: itself after this long with no request -- far above any step/checkpoint gap, far below "forever".
45
+ ROLLOUT_IDLE_EXIT_S = 7200.0
46
+
47
+
48
+ def _spawn_rollout_worker(spool: str, gpu: int, log_path: str):
49
+ """Spawn the vLLM worker and tie its teardown to rank 0's exit.
50
+
51
+ Everything between the spawn and the train try/finally (Trainer construction, the horizon
52
+ check, canary data, resume_from_latest) can raise; atexit covers every uncaught-exception path.
53
+ `terminate_worker` is idempotent, so the normal finally and this hook do not conflict.
54
+ """
55
+ from align.rollout import client as rollout_client
56
+
57
+ proc = rollout_client.spawn_worker(spool, gpu=gpu, log_path=log_path, idle_exit_s=ROLLOUT_IDLE_EXIT_S)
58
+ atexit.register(rollout_client.terminate_worker, proc)
59
+ return proc
60
+
61
+
62
+ def main() -> int:
63
+ ap = argparse.ArgumentParser()
64
+ ap.add_argument("config")
65
+ ap.add_argument("--smoke", action="store_true", help="tiny shapes, 10 steps")
66
+ ap.add_argument("--band-file", default=None, help="override the configured band file")
67
+ ap.add_argument("--strict-host-check", default=True, action=argparse.BooleanOptionalAction)
68
+ ap.add_argument("--heavy-lock", default=True, action=argparse.BooleanOptionalAction)
69
+ args = ap.parse_args()
70
+
71
+ apply_resource_guards(
72
+ cpu_threads=8,
73
+ heavy=args.heavy_lock,
74
+ label="grpo-stageb",
75
+ strict_host_check=args.strict_host_check,
76
+ )
77
+
78
+ from align.utils.dist import init_distributed
79
+ from align.rl.stage_config import load_stage_config as _early_stage_config
80
+
81
+ # Run dir from the config (parsed before the process group: a bad TOML fails
82
+ # in milliseconds, and the NCCL flight-recorder dump lands under it).
83
+ init_distributed(run_dir=_early_stage_config(args.config, smoke=args.smoke).run_dir)
84
+
85
+ from align.train.stage_gates import assert_distinct_devices
86
+
87
+ assert_distinct_devices()
88
+
89
+ import torch
90
+ import torch.distributed as dist
91
+
92
+ from align.data.schema import build_prompt, extract_answer, register_answer_suffixes
93
+ from align.data.sources import DataSources, gsm8k_gold_answer, read_gsm8k_test
94
+ from align.eval.verifier import OutcomeVerifier, VerifierConfig
95
+ from align.model import checkpoint as ckpt
96
+ from align.model.mistral import NmoMistralForCausalLM
97
+ from align.rl.batch_source import GrpoBatchSource
98
+ from align.rl.canary import TaskAccuracyCanary
99
+ from align.rl.grpo_module import GRPOTrainModule
100
+ from align.rl.rewards import CompositeReward
101
+ from align.rl.stage_config import load_stage_config
102
+ from align.rl.tripwire import CollapseTripwire
103
+ from align.rollout.client import make_rollout_client
104
+ from align.rollout.protocol import RolloutRequest
105
+ from align.train.dpo_stage import resolve_sft_checkpoint
106
+ from align.train.trainer import Trainer
107
+ from align.utils.guard import assert_paths_off_root
108
+
109
+ rank = dist.get_rank() if dist.is_initialized() else 0
110
+ world = dist.get_world_size() if dist.is_initialized() else 1
111
+
112
+ cfg = load_stage_config(args.config, smoke=args.smoke)
113
+ if args.band_file:
114
+ cfg.data.band_file = args.band_file
115
+ cfg.validate()
116
+ assert_paths_off_root([cfg.run_dir])
117
+ ceiling = cfg.validate_ceiling_receipt()
118
+ cfg.validate_world(world)
119
+ with open(cfg.data.banding_manifest) as f:
120
+ banding_manifest = json.load(f)
121
+
122
+ register_answer_suffixes("</s>") # BEFORE any extraction (golden.py's incident)
123
+
124
+ # -- data: the band's prompt rows ---------------------------------------
125
+ prompt_rows = []
126
+ with open(cfg.data.band_file) as f:
127
+ for line in f:
128
+ if line.strip():
129
+ prompt_rows.append(json.loads(line))
130
+ logger.info("band file %s: %d prompts", cfg.data.band_file, len(prompt_rows))
131
+
132
+ # -- policy + FSDP -------------------------------------------------------
133
+ sft_checkpoint, ckpt_source = resolve_sft_checkpoint(cfg.sft_checkpoint, None)
134
+ logger.info("substrate: %s (%s)", sft_checkpoint, ckpt_source)
135
+ from align.model.hf_config import read_config
136
+ from align.tokenizer import load_tokenizer
137
+
138
+ tokenizer = load_tokenizer(sft_checkpoint)
139
+ pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0
140
+
141
+ model_cfg = read_config(sft_checkpoint)
142
+ model = NmoMistralForCausalLM.from_state_dict(
143
+ model_cfg,
144
+ ckpt.hf_to_nmo(ckpt.load_hf_safetensors(sft_checkpoint)),
145
+ attn_implementation=cfg.train.attn_implementation,
146
+ device="cuda",
147
+ ).to("cuda", dtype=torch.bfloat16)
148
+
149
+ from align.train.fsdp import apply_activation_checkpointing, apply_fsdp
150
+
151
+ dist_model = apply_fsdp(apply_activation_checkpointing(model))
152
+ logger.info("post-shard cuda allocated: %.2f GiB", torch.cuda.memory_allocated() / 2**30)
153
+
154
+ # -- rollout backend (rank 0 owns it) ------------------------------------
155
+ # 'spool': external vLLM worker process on NMO_ROLLOUT_GPU (own venv).
156
+ # 'native': in-repo torch sampler on a STANDALONE engine copy living on that
157
+ # same dedicated GPU, in rank 0's process. It must NOT be the FSDP2-sharded
158
+ # model: decode forwards are collectives the other ranks would never join
159
+ # (deadlock). Either way the GPU is outside the training ranks' devices.
160
+ rollout_gpu = int(os.environ.get("NMO_ROLLOUT_GPU", "3"))
161
+ spool = os.path.join(cfg.run_dir, "spool")
162
+ worker_proc = None
163
+ client = None
164
+ if cfg.rollout.backend == "spool":
165
+ if rank == 0:
166
+ worker_proc = _spawn_rollout_worker(
167
+ spool, rollout_gpu, os.path.join(cfg.run_dir, "worker.log")
168
+ )
169
+ logger.info("rollout worker spawned (pid %d) on GPU %s", worker_proc.pid, rollout_gpu)
170
+ client = make_rollout_client("spool", spool=spool, timeout_s=cfg.rollout_timeout_s)
171
+ elif rank == 0:
172
+ client = _build_native_client(
173
+ model_cfg, sft_checkpoint, tokenizer, rollout_gpu, world, cfg.rollout.max_response_len
174
+ )
175
+ logger.info("native rollout engine on cuda:%d (standalone copy of %s)", rollout_gpu, sft_checkpoint)
176
+
177
+ # -- module + trainer -----------------------------------------------------
178
+ from align.train.offload_adamw import make_optimizer
179
+
180
+ run_cfg = _run_config(cfg)
181
+ optimizer = make_optimizer(run_cfg, [p for p in dist_model.parameters() if p.requires_grad])
182
+ collapse = CollapseTripwire()
183
+ module = GRPOTrainModule(
184
+ dist_model,
185
+ optimizer,
186
+ grad_accum=cfg.train.grad_accum,
187
+ grpo_cfg=cfg.grpo,
188
+ temperature=cfg.rollout.temperature,
189
+ collapse_tripwire=collapse,
190
+ max_grad_norm=cfg.train.max_grad_norm,
191
+ )
192
+
193
+ source = GrpoBatchSource(
194
+ prompt_rows,
195
+ prompts_per_step=cfg.rollout.prompts_per_step,
196
+ n_samples_per_prompt=cfg.grpo.num_samples_per_prompt_rollout,
197
+ max_response_len=cfg.rollout.max_response_len,
198
+ temperature=cfg.rollout.temperature,
199
+ optimizer_steps=cfg.train.optimizer_steps,
200
+ grad_accum=cfg.train.grad_accum,
201
+ seed=cfg.train.seed,
202
+ prompt_builder=build_prompt,
203
+ dead_group_retries=cfg.dead_group_retries,
204
+ )
205
+ source.bind_pad(pad_id)
206
+
207
+ trainer = Trainer(
208
+ run_cfg,
209
+ dist_model,
210
+ source,
211
+ module._grpo_loss_adapter,
212
+ run_dir=cfg.run_dir,
213
+ stage="grpo",
214
+ train_module=module,
215
+ )
216
+ # HORIZON CONSISTENCY (the Arm A zombie-LR class): the scheduler's total
217
+ # must be exactly the configured optimizer_steps -- this loop generates
218
+ # its own data, so any mismatch is arithmetic, not sampling.
219
+ if trainer.scheduler.total_steps != cfg.train.optimizer_steps:
220
+ raise RuntimeError(
221
+ f"horizon mismatch: scheduler={trainer.scheduler.total_steps} vs "
222
+ f"configured optimizer_steps={cfg.train.optimizer_steps}"
223
+ )
224
+
225
+ # -- canary (R4, rank 0) --------------------------------------------------
226
+ canary = None
227
+ canary_generate = None
228
+ if rank == 0:
229
+ sources = DataSources.from_local_hub()
230
+ gsm_rows = read_gsm8k_test(sources)[: cfg.canary.n_problems]
231
+ canary_problems = [
232
+ {"prompt": build_prompt(r["question"]), "gold": gsm8k_gold_answer(r)} for r in gsm_rows
233
+ ]
234
+
235
+ def canary_generate(prompts: list[str]) -> list[str]:
236
+ req = RolloutRequest(
237
+ request_id=f"canary-{trainer.optimizer_step}-{os.getpid()}",
238
+ weights_dir=_latest_weights(trainer, sft_checkpoint),
239
+ prompts=prompts,
240
+ n_samples_per_prompt=1,
241
+ max_tokens=cfg.rollout.max_response_len,
242
+ temperature=0.0,
243
+ )
244
+ client.submit(req)
245
+ result = client.collect(req.request_id)
246
+ return [texts[0] for texts in result.texts]
247
+
248
+ canary = TaskAccuracyCanary(
249
+ problems=canary_problems,
250
+ generate_fn=canary_generate,
251
+ verifier=OutcomeVerifier(VerifierConfig(workers=4)),
252
+ extract_fn=extract_answer,
253
+ every=cfg.canary.every,
254
+ )
255
+
256
+ source.bind(
257
+ client=client,
258
+ reward=CompositeReward(
259
+ format_weight=cfg.reward.format_weight,
260
+ overlong_max_len=cfg.rollout.max_response_len,
261
+ overlong_cache_len=cfg.rollout.overlong_cache_len,
262
+ ),
263
+ verifier=OutcomeVerifier(VerifierConfig(workers=4)) if rank == 0 else None,
264
+ old_logprob_fn=module.compute_logprobs_nograd,
265
+ weights_dir_fn=lambda: _latest_weights(trainer, sft_checkpoint),
266
+ rank=rank,
267
+ world_size=world,
268
+ device="cuda",
269
+ canary=canary,
270
+ collapse_tripwire=collapse,
271
+ trainer=trainer,
272
+ canary_log_path=os.path.join(cfg.run_dir, "canary.jsonl"),
273
+ )
274
+ trainer.loss_adapter = source.lazy_adapter(module)
275
+
276
+ # -- resume ---------------------------------------------------------------
277
+ # A relaunch continues the newest checkpoint in run_dir, if any. Before
278
+ # this was wired, a relaunch restarted from the SFT substrate. What a
279
+ # resume restores, and where each piece lives:
280
+ # weights + optimizer + schedule + stream offset Trainer (resume.pt)
281
+ # CollapseTripwire windows and latch module extra_state
282
+ # prompt order, cursor, dead-group stats, alarms source state_dict
283
+ # rollout engine weights _latest_weights: the
284
+ # Trainer registers the resumed checkpoint, so the first post-resume
285
+ # rollout reloads from it instead of the substrate.
286
+ resumed_from = trainer.resume_from_latest()
287
+ if resumed_from is not None:
288
+ logger.info(
289
+ "RESUMED from %s at optimizer step %d / micro step %d",
290
+ resumed_from,
291
+ trainer.optimizer_step,
292
+ trainer.micro_step,
293
+ )
294
+
295
+ banner = {
296
+ "stage": "grpo_stageb" + ("_smoke" if args.smoke else ""),
297
+ "substrate": sft_checkpoint,
298
+ "resumed_from": resumed_from,
299
+ "band_file": cfg.data.band_file,
300
+ "band_prompts": len(prompt_rows),
301
+ "shape": {
302
+ "prompts_per_step": cfg.rollout.prompts_per_step,
303
+ "G": cfg.grpo.num_samples_per_prompt_rollout,
304
+ "max_response_len": cfg.rollout.max_response_len,
305
+ "loss_denominator": cfg.grpo.loss_denominator,
306
+ "optimizer_steps": cfg.train.optimizer_steps,
307
+ },
308
+ "ceiling": {k: v["verify_correct_rate"] for k, v in ceiling["sources"].items()},
309
+ "banding_manifest_rows": banding_manifest.get("rows"),
310
+ "topology": {"train_ranks": world, "rollout_gpu": os.environ.get("NMO_ROLLOUT_GPU", "3")},
311
+ "rectifications": (
312
+ f"R3 clip={cfg.train.max_grad_norm:.1f}, R4 canary every={cfg.canary.every}, "
313
+ "R5 pin-on-fire, reduction-time denominator assert armed"
314
+ ),
315
+ }
316
+ logger.info("BANNER %s", json.dumps(banner, indent=1))
317
+
318
+ # -- train ----------------------------------------------------------------
319
+ try:
320
+ summary = trainer.train()
321
+ checkpoint = trainer.save_checkpoint()
322
+ finally:
323
+ if worker_proc is not None:
324
+ from align.rollout.client import terminate_worker
325
+
326
+ terminate_worker(worker_proc)
327
+ if trainer.stop_exit_code:
328
+ # A coordinated stop (SIGTERM / train.max_wall_seconds) saved at an
329
+ # optimizer-step boundary; the stage is NOT finished. Exit with the
330
+ # documented status instead of evaluating / reporting a half-trained arm.
331
+ raise SystemExit(trainer.stop_exit_code)
332
+
333
+ result = {
334
+ "stage": banner["stage"],
335
+ "summary": summary,
336
+ "checkpoint": checkpoint,
337
+ "banner": banner,
338
+ "canary_history": canary.history if canary else [],
339
+ "collapse_alarms": source.alarms,
340
+ "collapse_tripwire": collapse.as_log_dict(),
341
+ "dead_groups": source.filter_stats.as_log_dict(),
342
+ "dropped_remainder_rows": source.dropped_remainder_rows,
343
+ }
344
+ if rank == 0:
345
+ path = os.path.join(cfg.run_dir, "stage_result.json")
346
+ with open(path + ".partial", "w") as f:
347
+ json.dump(result, f, indent=1, default=str)
348
+ os.replace(path + ".partial", path)
349
+ logger.info("stage result: %s", path)
350
+ return 0
351
+
352
+
353
+ def _build_native_client(
354
+ model_cfg, sft_checkpoint: str, tokenizer, rollout_gpu: int, world: int, max_response_len: int
355
+ ):
356
+ """Rank 0's native rollout backend on a dedicated, standalone engine copy.
357
+
358
+ The copy is built from the SFT substrate (the policy's step-0 weights) and
359
+ refreshed from the latest saved checkpoint whenever `weights_dir` changes
360
+ (`checkpoint_weight_loader`). Refuses a device a training rank already
361
+ owns: training ranks are bound to local_rank 0..world-1, so the engine GPU
362
+ index must be >= world, and visible.
363
+ """
364
+ import torch
365
+
366
+ from align.model import checkpoint as ckpt
367
+ from align.model.mistral import NmoMistralForCausalLM
368
+ from align.rollout import checkpoint_weight_loader, make_rollout_client
369
+ from align.rollout.native import NativeRolloutConfig
370
+
371
+ if rollout_gpu < world or rollout_gpu >= torch.cuda.device_count():
372
+ raise RuntimeError(
373
+ f"NMO_ROLLOUT_GPU={rollout_gpu} is not a free visible device: training ranks own "
374
+ f"cuda:0..{world - 1} and {torch.cuda.device_count()} device(s) are visible. The "
375
+ "engine copy must live on its own GPU (launch with all four GPUs visible)."
376
+ )
377
+ device = torch.device("cuda", rollout_gpu)
378
+ engine_model = (
379
+ NmoMistralForCausalLM.from_state_dict(
380
+ model_cfg,
381
+ ckpt.hf_to_nmo(ckpt.load_hf_safetensors(sft_checkpoint, dtype=torch.bfloat16)),
382
+ device=str(device),
383
+ )
384
+ .to(device, dtype=torch.bfloat16)
385
+ .eval()
386
+ )
387
+ return make_rollout_client(
388
+ "native",
389
+ model=engine_model,
390
+ tokenizer=tokenizer,
391
+ native_config=NativeRolloutConfig(
392
+ max_seq_len_cap=2048,
393
+ pad_token_id=tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0,
394
+ ),
395
+ load_weights_fn=checkpoint_weight_loader(engine_model),
396
+ device=device,
397
+ )
398
+
399
+
400
+ def _latest_weights(trainer, sft_checkpoint: str) -> str:
401
+ """The engine's weights source: latest saved checkpoint, else the substrate."""
402
+ if trainer.checkpoints_written:
403
+ return os.path.join(trainer.ckpt_root, f"micro_{trainer.checkpoints_written[-1]:08d}")
404
+ return sft_checkpoint
405
+
406
+
407
+ def _run_config(cfg):
408
+ from align.config import RunConfig
409
+
410
+ run = RunConfig()
411
+ run.optimizer.name = cfg.train.optimizer_name
412
+ run.optimizer.lr = cfg.train.lr
413
+ run.optimizer.grad_accum = cfg.train.grad_accum
414
+ run.train.epochs = 1 # the horizon is optimizer_steps; the source cycles prompts itself
415
+ run.train.micro_batch_size = cfg.train.micro_batch_size
416
+ run.train.save_every_micro_steps = cfg.train.save_every_micro_steps
417
+ run.train.keep_checkpoints = cfg.train.keep_checkpoints
418
+ run.train.per_rank_optimizer_state = cfg.train.per_rank_optimizer_state
419
+ run.train.max_wall_seconds = cfg.train.max_wall_seconds
420
+ run.train.max_consecutive_skipped_steps = cfg.train.max_consecutive_skipped_steps
421
+ run.train.max_skipped_fraction = cfg.train.max_skipped_fraction
422
+ run.train.warmup_frac = cfg.train.warmup_frac
423
+ run.train.full_ft = True
424
+ run.train.seed = cfg.train.seed
425
+ run.tripwires.armed = tuple(cfg.armed_tripwires)
426
+ run.run_name = "grpo_stageb"
427
+ return run
428
+
429
+
430
+ if __name__ == "__main__":
431
+ from align.exit_codes import run_with_exit_codes
432
+
433
+ raise SystemExit(run_with_exit_codes(main))
code/scripts/grpo_halt_at_108.sh ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Halt Stage-B after the step-108 cadence save (micro_00009180).
3
+ # Dated entry 2026-09-07: horizon cut 500 -> 108. Do not kill before the save.
4
+ set -euo pipefail
5
+ ROOT=/apps/development/rlhf/nano-mistral-o1
6
+ CKPT="$ROOT/runs/grpo_stageb/checkpoints/micro_00009180/resume.pt"
7
+ H="$ROOT/runs/grpo_stageb/history.jsonl"
8
+ W="$ROOT/runs/grpo_stageb_halt108.log"
9
+ cd "$ROOT"
10
+ echo "$(date '+%H:%M %d-%b') halt-at-108 armed (wait for $CKPT)" >> "$W"
11
+ while :; do
12
+ sleep 120
13
+ TS=$(date '+%H:%M %d-%b')
14
+ if [ -f "$CKPT" ]; then
15
+ echo "$TS checkpoint 108 present — SIGTERM torchrun" >> "$W"
16
+ pkill -TERM -f 'torchrun --nproc_per_node=3 scripts/grpo.py' || true
17
+ sleep 15
18
+ pkill -TERM -f 'scripts/grpo.py configs/grpo_stageb.toml' || true
19
+ pkill -TERM -f 'rollout_worker.py --spool' || true
20
+ sleep 10
21
+ pkill -KILL -f 'VLLM::EngineCore' || true
22
+ echo "$TS halt complete. GPUs:" >> "$W"
23
+ nvidia-smi --query-gpu=index,memory.used,utilization.gpu --format=csv,noheader >> "$W" || true
24
+ break
25
+ fi
26
+ if ! pgrep -f 'torchrun --nproc_per_node=3 scripts/grpo.py' >/dev/null; then
27
+ echo "$TS torchrun already gone before step 108 — not killing" >> "$W"
28
+ break
29
+ fi
30
+ done
code/scripts/grpo_watch.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """GRPO Stage-B terminal dashboard.
3
+
4
+ Run:
5
+ watch -n 15 -c .venv/bin/python scripts/grpo_watch.py
6
+ or:
7
+ .venv/bin/python scripts/grpo_watch.py --loop 15
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import math
14
+ import os
15
+ import shutil
16
+ import statistics as st
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ RUN_DIR = Path("runs/grpo_stageb")
23
+ HISTORY = RUN_DIR / "history.jsonl"
24
+ CANARY = RUN_DIR / "canary.jsonl"
25
+ LOG = Path("runs/grpo_stageb.log") # launch log sits beside the run dir
26
+ WORKER_LOG = RUN_DIR / "worker.log"
27
+
28
+ # ANSI
29
+ RESET = "\033[0m"
30
+ BOLD = "\033[1m"
31
+ DIM = "\033[2m"
32
+ RED = "\033[31m"
33
+ GREEN = "\033[32m"
34
+ YELLOW = "\033[33m"
35
+ BLUE = "\033[34m"
36
+ MAGENTA = "\033[35m"
37
+ CYAN = "\033[36m"
38
+ WHITE = "\033[37m"
39
+ BG_RED = "\033[41m"
40
+ BG_GREEN = "\033[42m"
41
+ BG_YELLOW = "\033[43m"
42
+ BG_BLUE = "\033[44m"
43
+ BG_MAGENTA = "\033[45m"
44
+ BG_CYAN = "\033[46m"
45
+
46
+
47
+ def fmt(x, nd=4, na="—"):
48
+ if x is None:
49
+ return na
50
+ if isinstance(x, float) and math.isnan(x):
51
+ return "NaN"
52
+ return f"{x:.{nd}f}"
53
+
54
+
55
+ def bar(cur, total, width=28, fill="█", empty="░", color=GREEN):
56
+ if total <= 0:
57
+ return f"{DIM}{empty * width}{RESET}"
58
+ frac = min(max(cur / total, 0.0), 1.0)
59
+ n = int(frac * width)
60
+ return f"{color}{fill * n}{DIM}{empty * (width - n)}{RESET}"
61
+
62
+
63
+ def sparkline(values, width=40):
64
+ if not values:
65
+ return ""
66
+ blocks = "▁▂▃▄▅▆▇█"
67
+ vals = list(values)[-width:]
68
+ lo, hi = min(vals), max(vals)
69
+ rng = (hi - lo) or 1.0
70
+ return "".join(blocks[min(int((v - lo) / rng * (len(blocks) - 1)), len(blocks) - 1)] for v in vals)
71
+
72
+
73
+ def read_jsonl(path: Path):
74
+ if not path.exists():
75
+ return []
76
+ out = []
77
+ with open(path) as f:
78
+ for line in f:
79
+ line = line.strip()
80
+ if line:
81
+ try:
82
+ out.append(json.loads(line))
83
+ except Exception:
84
+ continue
85
+ return out
86
+
87
+
88
+ def tail(path: Path, n=6):
89
+ if not path.exists():
90
+ return []
91
+ try:
92
+ with open(path, "rb") as f:
93
+ f.seek(0, os.SEEK_END)
94
+ end = f.tell()
95
+ size = min(end, 8192)
96
+ f.seek(end - size)
97
+ data = f.read().decode(errors="replace")
98
+ return data.splitlines()[-n:]
99
+ except Exception:
100
+ return []
101
+
102
+
103
+ def gpu_lines():
104
+ try:
105
+ out = subprocess.check_output(
106
+ [
107
+ "nvidia-smi",
108
+ "--query-gpu=index,utilization.gpu,memory.used,memory.total,temperature.gpu",
109
+ "--format=csv,noheader,nounits",
110
+ ],
111
+ text=True,
112
+ )
113
+ except Exception:
114
+ return ["GPU: nvidia-smi unavailable"]
115
+ lines = []
116
+ for row in out.strip().splitlines():
117
+ idx, util, used, total, temp = [c.strip() for c in row.split(",")]
118
+ used_g = float(used) / 1024
119
+ total_g = float(total) / 1024
120
+ lines.append(
121
+ f"GPU{idx} {bar(float(util), 100, width=14, color=CYAN)} {util:>3}% "
122
+ f"{used_g:5.1f}/{total_g:.0f}G {temp}°C"
123
+ )
124
+ return lines
125
+
126
+
127
+ def proc_status():
128
+ torchrun = subprocess.run(["pgrep", "-f", "torchrun.*grpo"], capture_output=True, text=True).stdout.strip()
129
+ vllm = subprocess.run(["pgrep", "-f", "VLLM::EngineCore|rollout_worker"], capture_output=True, text=True).stdout.strip()
130
+ return bool(torchrun), bool(vllm)
131
+
132
+
133
+ def render():
134
+ cols = shutil.get_terminal_size((100, 30)).columns
135
+ rows = read_jsonl(HISTORY)
136
+ canary = read_jsonl(CANARY)
137
+ torchrun_up, vllm_up = proc_status()
138
+
139
+ opt_steps = [r.get("optimizer_step", 0) for r in rows]
140
+ cur_opt = max(opt_steps) if opt_steps else 0
141
+ micro_steps = [r.get("micro_step", 0) for r in rows]
142
+ cur_micro = max(micro_steps) if micro_steps else 0
143
+ grad_accum = 85 # configs/grpo_stageb.toml
144
+ micro_in_step = cur_micro - cur_opt * grad_accum
145
+ frac_opt = cur_opt + micro_in_step / grad_accum # fractional step for the bar
146
+ applied = [r for r in rows if r.get("grad_norm") == r.get("grad_norm")]
147
+ gn = [r["grad_norm"] for r in applied]
148
+ acc = [r["reward/accuracy"] for r in rows if r.get("reward/accuracy") is not None]
149
+ ent = [r["entropy"] for r in rows if r.get("entropy") is not None]
150
+ loss = [r["loss"] for r in rows if r.get("loss") is not None]
151
+ lr = [r["lr"] for r in rows if r.get("lr") is not None]
152
+ resp = [r["lengths/response_mean"] for r in rows if r.get("lengths/response_mean") is not None]
153
+
154
+ total_steps = 500
155
+ # run start = first timestamped line of the launch log (ctime is NOT
156
+ # creation time on Linux -- it tracks the last write, which would
157
+ # make elapsed/pace collapse to ~0 while the run is healthy).
158
+ run_start = None
159
+ if LOG.exists():
160
+ try:
161
+ with open(LOG) as f:
162
+ for line in f:
163
+ if " INFO " in line and line[:4].isdigit():
164
+ run_start = time.mktime(time.strptime(line[:19], "%Y-%m-%d %H:%M:%S"))
165
+ break
166
+ except Exception:
167
+ pass
168
+ if run_start is None:
169
+ run_start = HISTORY.stat().st_mtime if HISTORY.exists() else time.time()
170
+ elapsed = time.time() - run_start
171
+
172
+ # Pace from observed micro-step rate (state file), not run start:
173
+ # run start includes one-time boot (engine load, canary, first rollout),
174
+ # which makes early pace readings garbage.
175
+ state_f = RUN_DIR / ".watch_state.json"
176
+ try:
177
+ wstate = json.loads(state_f.read_text()) if state_f.exists() else {}
178
+ except Exception:
179
+ wstate = {}
180
+ now = time.time()
181
+ ref_micro, ref_ts = wstate.get("ref", (None, None))
182
+ pace_min = wstate.get("pace_min")
183
+ if ref_micro is None:
184
+ wstate["ref"] = (cur_micro, now)
185
+ pace_min = None
186
+ elif now - ref_ts > 180 and cur_micro > ref_micro:
187
+ micros_per_min = (cur_micro - ref_micro) / ((now - ref_ts) / 60)
188
+ # 85 training micros + one rollout generation per step
189
+ pace_min = grad_accum / micros_per_min + 2.5
190
+ wstate["ref"] = (cur_micro, now)
191
+ wstate["pace_min"] = pace_min
192
+ try:
193
+ state_f.write_text(json.dumps(wstate))
194
+ except Exception:
195
+ pass
196
+ eta_min = pace_min * (total_steps - frac_opt) if pace_min else None
197
+
198
+ print(f"{BOLD}{CYAN}╔{'═' * (cols - 2)}╗{RESET}")
199
+ title = f" GRPO Stage-B • optimizer step {cur_opt}/{total_steps} "
200
+ print(f"{BOLD}{CYAN}║{title.center(cols - 2)}║{RESET}")
201
+ print(f"{BOLD}{CYAN}╚{'═' * (cols - 2)}╝{RESET}")
202
+ print()
203
+
204
+ # Staleness alarm: history must be written every few minutes while alive
205
+ hist_age = (time.time() - HISTORY.stat().st_mtime) if HISTORY.exists() else None
206
+ stale = (hist_age is None) or (hist_age > 900)
207
+ if stale and torchrun_up:
208
+ print(f" {BG_RED}{BOLD}{WHITE} ⚠ WEDGE? history.jsonl untouched for "
209
+ f"{(hist_age or 0)/60:.0f} min while torchrun is alive — check the log {RESET}")
210
+ print()
211
+
212
+ # Status line
213
+ status = f"{GREEN}RUNNING{RESET}" if torchrun_up else f"{BG_RED}{BOLD}{WHITE} STOPPED — NO TRAINING PROCESS {RESET}"
214
+ engine = f"{GREEN}vLLM up{RESET}" if vllm_up else f"{YELLOW}vLLM down{RESET}"
215
+ print(f" status: {status} | engine: {engine} | rows: {len(rows)} | applied steps: {len(applied)}")
216
+ print(f" progress: {bar(frac_opt, total_steps, width=cols - 22)} {frac_opt:.1f}/{total_steps}")
217
+ print(f" step {cur_opt}: micro {micro_in_step}/{grad_accum} {bar(micro_in_step, grad_accum, width=20, color=YELLOW)}")
218
+ if pace_min:
219
+ print(f" pace: {pace_min:.1f} min/step | elapsed: {elapsed/60:.0f} min | ETA: {eta_min/60:.1f} h")
220
+ else:
221
+ print(f" pace: measuring… (needs a few min of micro history) | elapsed: {elapsed/60:.0f} min")
222
+ print()
223
+
224
+ # Two-column metric panels
225
+ left_w = cols // 2 - 3
226
+ right_w = cols - left_w - 6
227
+
228
+ def panel(title, lines, width):
229
+ out = [f"{BOLD}{MAGENTA}┌─ {title} {'─' * max(0, width - len(title) - 4)}┐{RESET}"]
230
+ for ln in lines:
231
+ out.append(f"{MAGENTA}│{RESET} {ln.ljust(width - 2)} {MAGENTA}│{RESET}")
232
+ out.append(f"{MAGENTA}└{'─' * (width - 2)}┘{RESET}")
233
+ return out
234
+
235
+ train_lines = [
236
+ f"loss {fmt(loss[-1] if loss else None)} {sparkline(loss, 24)}",
237
+ f"grad_norm {fmt(gn[-1] if gn else None)} med {fmt(st.median(gn) if gn else None)} max {fmt(max(gn) if gn else None)}",
238
+ f"entropy {fmt(ent[-1] if ent else None)} {sparkline(ent, 24)}",
239
+ f"lr {fmt(lr[-1] if lr else None, 8)}",
240
+ f"micro {cur_micro}",
241
+ ]
242
+ reward_lines = [
243
+ f"acc {fmt(acc[-1] if acc else None)} mean {fmt(st.mean(acc) if acc else None)}",
244
+ f"acc first50 {fmt(st.mean(acc[:50]) if len(acc) >= 50 else None)} last50 {fmt(st.mean(acc[-50:]) if len(acc) >= 50 else None)}",
245
+ f"resp_len {fmt(resp[-1] if resp else None, 1)} {sparkline(resp, 24)}",
246
+ f"canary {canary[-1].get('canary/pass@1') if canary else '—'} (n={canary[-1].get('canary/n') if canary else '—'})",
247
+ f"canary step {canary[-1].get('optimizer_step') if canary else '—'}",
248
+ ]
249
+
250
+ left = panel("Training", train_lines, left_w)
251
+ right = panel("Reward / Canary", reward_lines, right_w)
252
+ for l, r in zip(left, right):
253
+ print(f" {l} {r}")
254
+ print()
255
+
256
+ # GPU panel
257
+ print(f" {BOLD}{BLUE}GPUs{RESET}")
258
+ for ln in gpu_lines():
259
+ print(f" {ln}")
260
+ print()
261
+
262
+ # Recent log tail
263
+ print(f" {BOLD}{BLUE}Recent log (grpo_stageb.log){RESET}")
264
+ for ln in tail(LOG, 5):
265
+ print(f" {DIM}{ln[: cols - 6]}{RESET}")
266
+ print()
267
+ print(f" {DIM}updated {time.strftime('%H:%M:%S')} | Ctrl-C to exit{RESET}")
268
+
269
+
270
+ def main():
271
+ ap = argparse.ArgumentParser()
272
+ ap.add_argument("--loop", type=int, default=0, help="refresh seconds; 0 = render once")
273
+ args = ap.parse_args()
274
+ if args.loop <= 0:
275
+ render()
276
+ return
277
+ try:
278
+ while True:
279
+ # clear screen without shell (avoid os.system injection sink)
280
+ sys.stdout.write("\033[2J\033[H")
281
+ sys.stdout.flush()
282
+ render()
283
+ time.sleep(args.loop)
284
+ except KeyboardInterrupt:
285
+ pass
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
code/scripts/hostguard.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Host guard CLI (see align/utils/hostguard.py for the design and the 2026-10-14 incident).
3
+
4
+ hostguard.py install write + enable align.slice and align-hostguard.service (user units)
5
+ hostguard.py run [--cores N] [--mem-gb G] [--label L] -- CMD ...
6
+ run CMD inside a kernel-capped scope in align.slice
7
+ hostguard.py status host sample, slice usage, watchdog state, last events
8
+ hostguard.py stop-all kill switch: SIGKILL every process in align.slice
9
+ hostguard.py watch [--dry-run] the watchdog loop (run by align-hostguard.service)
10
+
11
+ Every heavy or long command on this box (GPU jobs, Lean verification, builds, data jobs, test suites) goes
12
+ through `run`. Default per-job caps are deliberately below the slice caps so one job cannot take the whole
13
+ slice: --cores 16 (of 48), --mem-gb 60.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import dataclasses
19
+ import json
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ ROOT = Path(__file__).resolve().parents[1]
27
+ sys.path.insert(0, str(ROOT))
28
+ from align.utils import hostguard as H # noqa: E402
29
+
30
+ UNIT_DIR = Path.home() / ".config/systemd/user"
31
+ REPO_UNITS = ROOT / "deploy/systemd"
32
+ PY = ROOT / ".venv/bin/python"
33
+
34
+
35
+ def slice_unit(p: H.Policy) -> str:
36
+ return f"""[Unit]
37
+ Description=align jobs (kernel-capped; see align/utils/hostguard.py)
38
+
39
+ [Slice]
40
+ CPUAccounting=yes
41
+ CPUQuota={int(p.slice_cpu_cores * 100)}%
42
+ MemoryAccounting=yes
43
+ MemoryHigh={int(p.slice_mem_gb * 0.92)}G
44
+ MemoryMax={int(p.slice_mem_gb)}G
45
+ MemorySwapMax=0
46
+ TasksMax=16384
47
+ """
48
+
49
+
50
+ def service_unit() -> str:
51
+ return f"""[Unit]
52
+ Description=align host watchdog (host CPU <= 92%, memory, D-state, GPU temp, disk)
53
+ After=default.target
54
+
55
+ [Service]
56
+ Type=simple
57
+ ExecStart={PY} {ROOT / 'scripts/hostguard.py'} watch
58
+ Restart=always
59
+ RestartSec=5
60
+ CPUWeight=1000
61
+ MemoryMax=1G
62
+ Environment=PYTHONUNBUFFERED=1
63
+
64
+ [Install]
65
+ WantedBy=default.target
66
+ """
67
+
68
+
69
+ def cmd_install(a) -> int:
70
+ p = H.Policy()
71
+ REPO_UNITS.mkdir(parents=True, exist_ok=True)
72
+ UNIT_DIR.mkdir(parents=True, exist_ok=True)
73
+ for name, text in (("align.slice", slice_unit(p)), ("align-hostguard.service", service_unit())):
74
+ (REPO_UNITS / name).write_text(text)
75
+ (UNIT_DIR / name).write_text(text)
76
+ for args in (("daemon-reload",), ("start", "align.slice"), ("enable", "--now", "align-hostguard.service")):
77
+ r = subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True)
78
+ if r.returncode:
79
+ print(f"systemctl --user {' '.join(args)} failed: {r.stderr.strip()}", file=sys.stderr)
80
+ return 1
81
+ print(json.dumps({"installed": [str(UNIT_DIR / 'align.slice'), str(UNIT_DIR / 'align-hostguard.service')],
82
+ "slice_cpu_cores": p.slice_cpu_cores, "slice_mem_gb": p.slice_mem_gb}, indent=1))
83
+ return 0
84
+
85
+
86
+ def _watchdog_active() -> bool:
87
+ return subprocess.run(["systemctl", "--user", "is-active", "--quiet", "align-hostguard.service"]).returncode == 0
88
+
89
+
90
+ def apps_only_env() -> dict:
91
+ """The /apps-only path variables from env/apps_only.sh (single source of truth), as a dict."""
92
+ out = subprocess.run(["bash", "-c", f". {H.APPS_ONLY_ENV} && env -0"], capture_output=True, timeout=30,
93
+ env={"PATH": os.environ.get("PATH", "/usr/bin:/bin")}).stdout.decode()
94
+ env = dict(kv.split("=", 1) for kv in out.split("\0") if "=" in kv)
95
+ return {k: env[k] for k in H.PATH_ENV + ("HF_HUB_DISABLE_XET",) if k in env}
96
+
97
+
98
+ def cap_env(cores: int) -> dict:
99
+ """Thread / job caps for tools that do not see the cgroup quota and would size pools by nproc, plus the
100
+ /apps-only cache/temp paths. Path variables the caller already set under /apps (e.g. a toolchain's
101
+ CARGO_HOME) are kept; anything pointing elsewhere is overridden."""
102
+ n = str(max(1, cores))
103
+ env = {"OMP_NUM_THREADS": n, "MKL_NUM_THREADS": n, "OPENBLAS_NUM_THREADS": n, "NUMEXPR_MAX_THREADS": n,
104
+ "RAYON_NUM_THREADS": n, "CARGO_BUILD_JOBS": n, "MAKEFLAGS": f"-j{n}", "OPAMJOBS": n,
105
+ "TORCHINDUCTOR_COMPILE_THREADS": str(min(4, cores)), "TOKENIZERS_PARALLELISM": "false"}
106
+ for k, v in apps_only_env().items():
107
+ cur = os.environ.get(k)
108
+ env[k] = cur if cur and os.path.realpath(cur).startswith("/apps/") else v
109
+ return env
110
+
111
+
112
+ def cmd_run(a) -> int:
113
+ if not a.cmd:
114
+ print("usage: hostguard.py run [opts] -- CMD ...", file=sys.stderr)
115
+ return 2
116
+ cmd = a.cmd[1:] if a.cmd[0] == "--" else a.cmd
117
+ p = H.Policy()
118
+ if not _watchdog_active() and not a.allow_no_watchdog:
119
+ print("REFUSED: align-hostguard.service is not active (run `scripts/hostguard.py install`)", file=sys.stderr)
120
+ return 3
121
+ if a.cores > p.slice_cpu_cores or a.mem_gb > p.slice_mem_gb:
122
+ print(f"REFUSED: per-job caps exceed the slice ({p.slice_cpu_cores} cores, {p.slice_mem_gb} GB)", file=sys.stderr)
123
+ return 2
124
+ waited = 0.0
125
+ while True:
126
+ why = H.preflight(H.take_sample(1.0), p)
127
+ if not why:
128
+ break
129
+ if not a.wait or waited >= a.wait_max_s:
130
+ print("REFUSED (host busy): " + "; ".join(why), file=sys.stderr)
131
+ return 4
132
+ time.sleep(30); waited += 31
133
+ env = cap_env(a.cores)
134
+ bad = H.off_apps_paths({**os.environ, **env})
135
+ if bad:
136
+ print("REFUSED: downloads/caches/temp must be under /apps: " + ", ".join(bad), file=sys.stderr)
137
+ return 5
138
+ for k in ("TMPDIR", "XDG_CACHE_HOME"):
139
+ Path(env[k]).mkdir(parents=True, exist_ok=True)
140
+ unit = f"align-{a.label}-{int(time.time())}"
141
+ argv = ["systemd-run", "--user", "--scope", "--quiet", f"--slice={H.SLICE}", f"--unit={unit}",
142
+ "-p", f"CPUQuota={a.cores * 100}%", "-p", f"MemoryMax={a.mem_gb}G", "-p", f"MemoryHigh={max(1, int(a.mem_gb * 0.9))}G",
143
+ "-p", "MemorySwapMax=0", "-p", f"TasksMax={a.tasks}", "--", *cmd]
144
+ H.log_event("run", unit=unit, cores=a.cores, mem_gb=a.mem_gb, cmd=cmd[:8])
145
+ os.environ.update(env)
146
+ os.execvp(argv[0], argv)
147
+
148
+
149
+ def cmd_status(a) -> int:
150
+ s = H.take_sample(1.0)
151
+ cg = H.slice_cgroup()
152
+ info = {"host": dataclasses.asdict(s), "preflight_refusals": H.preflight(s, H.Policy()),
153
+ "watchdog_active": _watchdog_active(), "slice": None}
154
+ if cg:
155
+ rd = lambda f: (cg / f).read_text().strip() if (cg / f).exists() else None # noqa: E731
156
+ info["slice"] = {"pids": len(H.slice_pids()), "cpu.max": rd("cpu.max"), "memory.current_gb":
157
+ round(int(rd("memory.current") or 0) / 2**30, 2), "frozen": rd("cgroup.freeze"),
158
+ "scopes": sorted(p.name for p in cg.iterdir() if p.name.endswith(".scope"))}
159
+ if H.EVENT_LOG.exists():
160
+ info["last_events"] = [json.loads(l) for l in H.EVENT_LOG.read_text().splitlines()[-a.events:]]
161
+ print(json.dumps(info, indent=1, default=str))
162
+ return 0
163
+
164
+
165
+ def cmd_stop_all(a) -> int:
166
+ r = subprocess.run(["systemctl", "--user", "kill", "--signal=SIGKILL", H.SLICE], capture_output=True, text=True)
167
+ H.log_event("stop_all_cli", rc=r.returncode)
168
+ print(f"SIGKILL sent to every process in {H.SLICE} (rc={r.returncode}); remaining pids: {H.slice_pids()}")
169
+ return 0
170
+
171
+
172
+ def main() -> int:
173
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
174
+ sub = ap.add_subparsers(dest="cmd_name", required=True)
175
+ sub.add_parser("install")
176
+ r = sub.add_parser("run")
177
+ r.add_argument("--cores", type=int, default=16)
178
+ r.add_argument("--mem-gb", type=int, default=60)
179
+ r.add_argument("--tasks", type=int, default=4096)
180
+ r.add_argument("--label", default="job")
181
+ r.add_argument("--wait", action="store_true", help="wait (up to --wait-max-s) for preflight instead of refusing")
182
+ r.add_argument("--wait-max-s", type=float, default=6 * 3600)
183
+ r.add_argument("--allow-no-watchdog", action="store_true", help="tests only")
184
+ r.add_argument("cmd", nargs=argparse.REMAINDER)
185
+ st = sub.add_parser("status"); st.add_argument("--events", type=int, default=10)
186
+ sub.add_parser("stop-all")
187
+ w = sub.add_parser("watch"); w.add_argument("--dry-run", action="store_true"); w.add_argument("--interval", type=float, default=3.0)
188
+ a = ap.parse_args()
189
+ if a.cmd_name == "watch":
190
+ H.watch(H.Policy(), interval=a.interval, dry_run=a.dry_run)
191
+ return 0
192
+ return {"install": cmd_install, "run": cmd_run, "status": cmd_status, "stop-all": cmd_stop_all}[a.cmd_name](a)
193
+
194
+
195
+ if __name__ == "__main__":
196
+ raise SystemExit(main())
code/scripts/lean_ceiling_cases.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen case tables for scripts/lean_ceiling_test.py (pre-registered; see docs/LEAN_VERIFIER.md).
2
+
3
+ Kept in its own module so the pre-registration can pin a SHA-256 of the canonical JSON of
4
+ `ADVERSARIAL` + `RESTATED` and `lean_ceiling_test.py --verify-prereg` can check that the
5
+ cases scored are the cases registered. Editing a case after registration changes the
6
+ hash and must be recorded as an amendment in the doc.
7
+
8
+ Every adversarial case is a COMPLETION string (reasoning prose + a fenced Lean block, the
9
+ shape a policy emits), so the whole reward path -- fence extraction, static gate, REPL
10
+ compile, audit -- is exercised, not just the strict tier.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+
18
+ HEADER = (
19
+ "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\n"
20
+ "open BigOperators Real Nat Topology Rat\n\n"
21
+ )
22
+
23
+ #: statement key -> theorem text WITHOUT header (ends `:= by\n sorry`)
24
+ STATEMENTS = {
25
+ "A": "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n sorry\n",
26
+ "B": "theorem adv_b (n : ℕ) (h : n % 2 = 1) : n ^ 2 % 2 = 1 := by\n sorry\n",
27
+ "C": "theorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n sorry\n", # FALSE: only cheating proves it
28
+ "D": "theorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n sorry\n",
29
+ "G": "theorem adv_g (a b : ℕ) : a + b = b + a := by\n sorry\n",
30
+ "P": "theorem adv_p : (2:ℕ) + 2 = 4 := by\n sorry\n", # bomb host statement
31
+ }
32
+
33
+ # (id, category, statement key, code-after-header or None, completion-override or None)
34
+ # `code` is placed after HEADER unless `raw_header` is given as a 6th element.
35
+ _A_OK = " have hb : b = 2 - a := by linarith\n subst hb\n nlinarith [sq_nonneg (a - 1)]\n"
36
+ _B_OK = " simp [Nat.pow_mod, h]\n"
37
+
38
+ ADVERSARIAL: list[dict] = []
39
+
40
+
41
+ def _add(id, cat, st, code, raw_header=None, completion=None, pool="main", note=""):
42
+ ADVERSARIAL.append(
43
+ dict(id=id, category=cat, statement=st, code=code, raw_header=raw_header,
44
+ completion=completion, pool=pool, note=note)
45
+ )
46
+
47
+
48
+ # ---- sorry ---------------------------------------------------------------------------
49
+ _add("sorry_plain", "sorry", "A", "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n sorry\n")
50
+ _add("sorry_branch", "sorry", "B",
51
+ "theorem adv_b (n : ℕ) (h : n % 2 = 1) : n ^ 2 % 2 = 1 := by\n rcases Nat.mod_two_eq_zero_or_one n with h0 | h1\n · omega\n · sorry\n")
52
+ _add("sorry_helper", "sorry", "A",
53
+ "lemma aux (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n sorry\n\ntheorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n exact aux a b hab\n")
54
+ _add("sorry_term", "sorry", "A",
55
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n exact (sorry : a * b ≤ 1)\n")
56
+ _add("sorryAx_direct", "sorry", "A",
57
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n exact sorryAx _ false\n")
58
+ _add("sorry_after_char_quote", "sorry", "A",
59
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n exact ((fun (_ : Char) (x : a * b ≤ 1) (_ : Char) => x) '\"' (sorry : a * b ≤ 1) '\"')\n",
60
+ note="the sorry sits BETWEEN two char literals that each contain a quote: a lexer that reads `\"` as a string start hides it")
61
+ _add("sorry_after_comment_opener_string", "sorry", "A",
62
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n exact ((fun (_ : String) (x : a * b ≤ 1) (_ : String) => x) \"/-\" (sorry : a * b ≤ 1) \"-/\")\n",
63
+ note="the sorry sits between a string `/-` and a string `-/`: regex comment stripping would delete it")
64
+ # ---- admit ---------------------------------------------------------------------------
65
+ _add("admit_plain", "admit", "A", "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n admit\n")
66
+ _add("admit_branch", "admit", "B",
67
+ "theorem adv_b (n : ℕ) (h : n % 2 = 1) : n ^ 2 % 2 = 1 := by\n rcases Nat.mod_two_eq_zero_or_one n with h0 | h1\n · omega\n · admit\n")
68
+ # ---- extra axiom ---------------------------------------------------------------------
69
+ _add("axiom_false", "extra_axiom", "C",
70
+ "axiom bad : False\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n exact bad.elim\n")
71
+ _add("axiom_claim", "extra_axiom", "C",
72
+ "axiom claim : (3:ℕ) ^ 100 % 7 = 0\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := claim\n")
73
+ _add("axiom_polymorphic", "extra_axiom", "C",
74
+ "axiom anything (α : Sort _) : α\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n exact anything _\n")
75
+ _add("axiom_via_simpa", "extra_axiom", "C",
76
+ "axiom bad2 : (3:ℕ) ^ 100 % 7 = 0\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n simpa using bad2\n")
77
+ # ---- native_decide -------------------------------------------------------------------
78
+ _add("native_decide_tactic", "native_decide", "D",
79
+ "theorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n native_decide\n")
80
+ _add("native_decide_in_have", "native_decide", "D",
81
+ "theorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n have h : (2:ℕ) ^ 20 % 1000 = 576 := by native_decide\n exact h\n")
82
+ _add("native_decide_in_helper_lemma", "native_decide", "D",
83
+ "lemma aux : (2:ℕ) ^ 20 % 1000 = 576 := by native_decide\n\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := aux\n")
84
+ # ---- changed statement ---------------------------------------------------------------
85
+ _add("conclusion_changed", "changed_statement", "A",
86
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 2 := by\n have hb : b = 2 - a := by linarith\n subst hb\n nlinarith [sq_nonneg (a - 1)]\n")
87
+ _add("constant_changed", "changed_statement", "A",
88
+ "theorem adv_a (a b : ℝ) (hab : a + b = 1) : a * b ≤ 1 := by\n have hb : b = 1 - a := by linarith\n subst hb\n nlinarith [sq_nonneg (a - 1)]\n")
89
+ _add("conclusion_trivialized", "changed_statement", "A",
90
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : True := by\n trivial\n")
91
+ _add("binder_order_swapped", "changed_statement", "B",
92
+ "theorem adv_b (h : ∀ n : ℕ, n % 2 = 1) (n : ℕ) : n ^ 2 % 2 = 1 := by\n simp [Nat.pow_mod, h n]\n")
93
+ _add("statement_only_in_comment", "changed_statement", "A",
94
+ "/- theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by sorry -/\ntheorem adv_a : True := trivial\n")
95
+ _add("false_statement_made_true", "changed_statement", "C",
96
+ "theorem adv_c : (3:ℕ) ^ 100 % 7 = 4 := by\n norm_num\n")
97
+ # ---- weakened hypothesis (candidate adds / strengthens hypotheses => easier theorem) ----
98
+ _add("extra_hypothesis_a_eq_b", "weakened_hypothesis", "A",
99
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) (h2 : a = b) : a * b ≤ 1 := by\n subst h2\n nlinarith [sq_nonneg (a - 1)]\n")
100
+ _add("hypothesis_false_added", "weakened_hypothesis", "C",
101
+ "theorem adv_c (hf : False) : (3:ℕ) ^ 100 % 7 = 0 := by\n exact hf.elim\n")
102
+ _add("hypothesis_strengthened", "weakened_hypothesis", "B",
103
+ "theorem adv_b (n : ℕ) (h : n = 1) : n ^ 2 % 2 = 1 := by\n subst h\n norm_num\n")
104
+ # ---- definition hole -----------------------------------------------------------------
105
+ _add("instance_hole", "definition_hole", "G",
106
+ "instance : Add ℕ := ⟨fun _ _ => 0⟩\n\ntheorem adv_g (a b : ℕ) : a + b = b + a := by\n rfl\n",
107
+ note="same statement text elaborates to `0 = 0`; only the kernel bridge sees it")
108
+ _add("variable_hole", "definition_hole", "C",
109
+ "variable (hv : False)\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n exact hv.elim\n",
110
+ note="Lean 4.9 includes a `variable` used in the proof body in the theorem's type")
111
+ _add("variable_hole_hypothesis", "definition_hole", "C",
112
+ "variable (hv : (1:ℕ) = 2)\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n exact absurd hv (by norm_num)\n",
113
+ note="second flavour of the variable hole: an absurd hypothesis smuggled in through `variable`")
114
+ _add("def_shadow_hole", "definition_hole", "G",
115
+ "def Nat.add' (a b : ℕ) : ℕ := 0\n\nlocal macro_rules | `($a + $b) => `(Nat.add' $a $b)\n\ntheorem adv_g (a b : ℕ) : a + b = b + a := by\n rfl\n")
116
+ # ---- environment manipulation --------------------------------------------------------
117
+ _add("elab_adds_axiom", "env_manipulation", "C",
118
+ "open Lean Elab Command in\nelab \"addax\" : command => do\n liftCoreM (addDecl (.axiomDecl { name := `evil, levelParams := [], type := mkConst ``False, isUnsafe := false }))\n\naddax\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n exact evil.elim\n")
119
+ _add("macro_cheat_tactic", "env_manipulation", "A",
120
+ "macro \"cheat\" : tactic => `(tactic| exact sorryAx _ false)\n\ntheorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n cheat\n")
121
+ _add("macro_rules_override", "env_manipulation", "A",
122
+ "macro_rules | `(tactic| nlinarith $[[$_,*]]?) => `(tactic| exact sorryAx _ false)\n\ntheorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n nlinarith [sq_nonneg (a - b)]\n")
123
+ _add("run_cmd_adds_axiom", "env_manipulation", "C",
124
+ "open Lean Elab Command in\nrun_cmd liftCoreM (addDecl (.axiomDecl { name := `evil2, levelParams := [], type := mkConst ``False, isUnsafe := false }))\n\ntheorem adv_c : (3:ℕ) ^ 100 % 7 = 0 := by\n exact evil2.elim\n")
125
+ _add("initialize_hook", "env_manipulation", "A",
126
+ "initialize myRef : IO.Ref Nat ← IO.mkRef 0\n\ntheorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n have hb : b = 2 - a := by linarith\n subst hb\n nlinarith [sq_nonneg (a - 1)]\n")
127
+ _add("syntax_hijack", "env_manipulation", "A",
128
+ "syntax \"my_tac\" : tactic\nmacro_rules | `(tactic| my_tac) => `(tactic| exact sorryAx _ false)\n\ntheorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n my_tac\n")
129
+ _add("skipKernelTC_option", "env_manipulation", "D",
130
+ "set_option debug.skipKernelTC true in\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n",
131
+ note="the option does not exist on the pinned Lean 4.9.0-rc1 (`unknown option`), so this case is inert there; it guards a pin upgrade")
132
+ _add("eval_side_effect", "env_manipulation", "D",
133
+ "#eval IO.println \"side effect\"\n\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n")
134
+ _add("exit_after_theorem", "env_manipulation", "D",
135
+ "theorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n\n#exit\n\ntheorem unreachable : False := by sorry\n")
136
+ # ---- theorem proved for a different name ---------------------------------------------
137
+ _add("wrong_name_prime", "wrong_name", "A",
138
+ "theorem adv_a' (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK)
139
+ _add("wrong_name_other", "wrong_name", "B",
140
+ "theorem adv_b_proof (n : ℕ) (h : n % 2 = 1) : n ^ 2 % 2 = 1 := by\n" + _B_OK)
141
+ _add("namespace_wrapped", "wrong_name", "A",
142
+ "namespace Foo\ntheorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK + "end Foo\n")
143
+ _add("private_target", "wrong_name", "A",
144
+ "private theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK)
145
+ _add("attribute_on_target", "wrong_name", "A",
146
+ "@[simp] theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK)
147
+ _add("duplicate_target", "wrong_name", "A",
148
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK + "\ntheorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n sorry\n")
149
+ # ---- timeout / crash / memory bombs (own 1-worker pool, 30 s, 1 GiB) ------------------
150
+ _add("timeout_bomb_norm_num_prime", "timeout_bomb", "P",
151
+ "theorem adv_p : (2:ℕ) + 2 = 4 := by\n have h : Nat.Prime (2 ^ 127 - 1) := by norm_num\n norm_num\n",
152
+ pool="bomb", note="Mersenne-127 primality by trial division: minutes, no heartbeat limit (maxHeartbeats 0)")
153
+ _add("crash_stack_overflow", "timeout_bomb", "P",
154
+ "theorem adv_p : (2:ℕ) + 2 = 4 := by\n have h : (List.range 20000).foldl (· + ·) 0 = 199990000 := by\n set_option maxRecDepth 16000 in decide\n norm_num\n",
155
+ pool="bomb", note="aborts the REPL process (lean::stack_space_exception)")
156
+ _add("memory_bomb_pow", "timeout_bomb", "P",
157
+ "theorem adv_p : (2:ℕ) + 2 = 4 := by\n have h : (3:ℕ) ^ (2 ^ 31 - 1) % 7 = (3:ℕ) ^ (2 ^ 31 - 1) % 7 := by decide\n norm_num\n",
158
+ pool="bomb", note="multi-GB bignum; killed by the anon-memory watchdog")
159
+ # ---- banned constructs (policy; the proof of the statement itself is valid) ------------
160
+ _add("unsafe_implemented_by", "banned_construct", "D",
161
+ "unsafe def fastImpl (n : ℕ) : ℕ := n\n\n@[implemented_by fastImpl] def slowSpec (n : ℕ) : ℕ := n\n\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n")
162
+ _add("partial_def", "banned_construct", "D",
163
+ "partial def loop (n : ℕ) : ℕ := loop n\n\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n")
164
+ _add("extern_attr", "banned_construct", "D",
165
+ "@[extern \"c_fn\"] opaque cFn : ℕ → ℕ\n\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n")
166
+ _add("search_tactic_exact_q", "banned_construct", "A",
167
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n have hb : b = 2 - a := by linarith\n subst hb\n nlinarith [sq_nonneg (a - 1)]\n all_goals exact?\n")
168
+ _add("maxRecDepth_abuse", "banned_construct", "D",
169
+ "set_option maxRecDepth 100000000 in\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n")
170
+ _add("helper_def_with_proof", "banned_construct", "D",
171
+ "def twenty : ℕ := 20\n\ntheorem adv_d : (2:ℕ) ^ 20 % 1000 = 576 := by\n norm_num\n",
172
+ note="allow_helper_defs is off by default: a def is a policy reject")
173
+ # ---- header tampering ----------------------------------------------------------------
174
+ _add("header_dropped_open", "header_tamper", "A",
175
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK,
176
+ raw_header="import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\n")
177
+ _add("header_extra_import", "header_tamper", "A",
178
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK,
179
+ raw_header="import Mathlib\nimport Aesop\nimport Lean\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n")
180
+ _add("header_heartbeats_changed", "header_tamper", "A",
181
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK,
182
+ raw_header="import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 400000\n\nopen BigOperators Real Nat Topology Rat\n\n")
183
+ _add("header_missing", "header_tamper", "A",
184
+ "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK, raw_header="")
185
+ # ---- no code -------------------------------------------------------------------------
186
+ _add("empty_completion", "no_code", "A", None, completion="")
187
+ _add("prose_only", "no_code", "A", None, completion="The statement follows from AM-GM; I leave the formalisation to the reader.")
188
+ _add("unclosed_fence_truncated", "no_code", "A", None,
189
+ completion="plan...\n```lean4\n" + HEADER + "theorem adv_a (a b : ℝ) (hab : a + b = 2) : a * b ≤ 1 := by\n" + _A_OK)
190
+ _add("header_only", "no_code", "A", "", note="fence with only the header: no target theorem")
191
+
192
+ # ======================================================================================
193
+ # Known-good, hand-restated Mathlib-style theorems with proofs. Each is checked by the
194
+ # independent compile-only pass BEFORE scoring; any that does not compile under the pin is
195
+ # dropped and reported, not repaired after the fact.
196
+ RESTATED: list[dict] = []
197
+
198
+
199
+ def _rs(id, code):
200
+ RESTATED.append(dict(id=id, code=code))
201
+
202
+
203
+ _rs("rs_add_comm", "theorem rs_add_comm (a b : ℕ) : a + b = b + a := by\n omega\n")
204
+ _rs("rs_sq_nonneg", "theorem rs_sq_nonneg (x : ℝ) : 0 ≤ x ^ 2 := by\n positivity\n")
205
+ _rs("rs_gauss", "theorem rs_gauss (n : ℕ) : (∑ i in Finset.range (n + 1), i) * 2 = n * (n + 1) := by\n induction n with\n | zero => simp\n | succ k ih =>\n rw [Finset.sum_range_succ, add_mul, ih]\n ring\n")
206
+ _rs("rs_amgm", "theorem rs_amgm (a b : ℝ) : a * b ≤ (a ^ 2 + b ^ 2) / 2 := by\n nlinarith [sq_nonneg (a - b)]\n")
207
+ _rs("rs_dvd", "theorem rs_dvd (n : ℕ) : 2 ∣ n * (n + 1) :=\n even_iff_two_dvd.mp (Nat.even_mul_succ_self n)\n")
208
+ _rs("rs_abs", "theorem rs_abs (x y : ℝ) : |x + y| ≤ |x| + |y| := abs_add x y\n")
209
+ _rs("rs_odd_sum", "theorem rs_odd_sum (n : ℕ) : ∑ i in Finset.range n, (2 * i + 1) = n ^ 2 := by\n induction n with\n | zero => simp\n | succ k ih => rw [Finset.sum_range_succ, ih]; ring\n")
210
+ _rs("rs_exists", "theorem rs_exists : ∃ x : ℕ, x * x = 49 := ⟨7, by norm_num⟩\n")
211
+ _rs("rs_irrational_nat", "theorem rs_irr : ¬ ∃ x : ℕ, x * x = 2 := by\n rintro ⟨x, hx⟩\n have h1 : x ≤ 1 := by nlinarith\n interval_cases x <;> omega\n")
212
+ _rs("rs_calc", "theorem rs_calc (a b : ℕ) (h : a ≤ b) : a + 1 ≤ b + 1 := by\n calc a + 1 ≤ b + 1 := Nat.succ_le_succ h\n")
213
+ _rs("rs_let_in_type", "theorem rs_let : let x := (5 : ℕ); x + x = 10 := by\n intro x\n rfl\n")
214
+ _rs("rs_comm_ring", "theorem rs_comm_ring {α : Type*} [CommRing α] (a b : α) : (a + b) ^ 2 = a ^ 2 + 2 * a * b + b ^ 2 := by\n ring\n")
215
+ _rs("rs_lemma_kw", "lemma rs_lemma_kw (a b : ℚ) (h : a < b) : a < (a + b) / 2 := by\n linarith\n")
216
+ _rs("rs_set_eq", "theorem rs_set_eq : {x : ℕ | x < 3} = {0, 1, 2} := by\n ext x\n simp only [Set.mem_setOf_eq, Set.mem_insert_iff, Set.mem_singleton_iff]\n omega\n")
217
+ _rs("rs_card_filter", "theorem rs_card : (Finset.filter (fun n => n % 3 = 0) (Finset.range 30)).card = 10 := by\n decide\n")
218
+ _rs("rs_word_in_comment_and_string", "theorem rs_str (n : ℕ) : n + 0 = n := by\n -- we never use sorry or native_decide here\n have s : String := \"sorry /- not a comment\"\n simp\n")
219
+ _rs("rs_nested_comment", "theorem rs_nested (n : ℕ) : 0 + n = n := by\n /- outer /- inner sorry -/ still outer admit -/\n simp\n")
220
+ _rs("rs_char_quote", "theorem rs_char (n : ℕ) : n * 1 = n := by\n have c : Char := '\"'\n simp\n")
221
+ _rs("rs_set_option_in", "theorem rs_set_opt (a b c : ℝ) (h : a ≤ b) (hc : 0 ≤ c) : a * c ≤ b * c := by\n set_option maxHeartbeats 400000 in\n nlinarith [mul_nonneg (sub_nonneg.mpr h) hc]\n")
222
+ _rs("rs_rcases", "theorem rs_rcases (n : ℕ) : n = 0 ∨ 0 < n := by\n rcases n with _ | n\n · left; rfl\n · right; exact Nat.succ_pos n\n")
223
+ _rs("rs_mod4", "theorem rs_mod4 (n : ℕ) : n % 4 = 0 ∨ n % 4 = 1 ∨ n % 4 = 2 ∨ n % 4 = 3 := by\n omega\n")
224
+ _rs("rs_prime97", "theorem rs_prime97 : Nat.Prime 97 := by\n norm_num\n")
225
+ _rs("rs_helper_lemma", "lemma aux_pos (x : ℝ) : 0 < x ^ 2 + 1 := by positivity\n\ntheorem rs_helper (x : ℝ) : 0 < (x ^ 2 + 1) * 3 := by\n have := aux_pos x\n linarith\n")
226
+ _rs("rs_exp_pos", "theorem rs_exp_pos (x : ℝ) : 0 < Real.exp x := Real.exp_pos x\n")
227
+ _rs("rs_by_contra", "theorem rs_by_contra (x : ℝ) (h : x ^ 2 < 0) : False := by\n nlinarith [sq_nonneg x]\n")
228
+ _rs("rs_em", "theorem rs_em (p : Prop) : p ∨ ¬ p := Classical.em p\n")
229
+ _rs("rs_funext", "theorem rs_funext : (fun x : ℕ => x + 0) = (fun x => x) := by\n funext x\n simp\n")
230
+ _rs("rs_field_simp", "theorem rs_div (a b : ℝ) (hb : 0 < b) : a / b * b = a := by\n field_simp\n")
231
+ _rs("rs_default_arg", "theorem rs_default_arg (n : ℕ := 5) : n + 0 = n := by\n omega\n")
232
+ _rs("rs_structure_instance", "theorem rs_prod (p : ℕ × ℕ) (h : p = { fst := 1, snd := 2 }) : p.1 + p.2 = 3 := by\n subst h\n rfl\n")
233
+
234
+
235
+ def table_hash() -> str:
236
+ """SHA-256 of the canonical JSON of both case tables + the header + statements."""
237
+ blob = json.dumps(
238
+ {"header": HEADER, "statements": STATEMENTS, "adversarial": ADVERSARIAL, "restated": RESTATED},
239
+ sort_keys=True, ensure_ascii=False,
240
+ )
241
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
242
+
243
+
244
+ if __name__ == "__main__":
245
+ print(len(ADVERSARIAL), "adversarial,", len(RESTATED), "restated; sha256", table_hash())
code/scripts/lean_ceiling_test.py ADDED
@@ -0,0 +1,729 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ceiling test + measurements for the strict Lean verifier (Track L / P1).
2
+
3
+ House rule (paid for twice: a perfect policy scored 0/100 through a broken extractor, and
4
+ mprm's PPO runs measured the grader): ceiling-test every instrument before trusting any
5
+ number it produces. For a VERIFIER the ceiling has two sides, and both are checked:
6
+
7
+ * known-good : proofs that independently compile under the pinned toolchain must be
8
+ ACCEPTED (>= 98%): a strict gate that rejects honest proofs starves RL.
9
+ * adversarial : hand-built exploits must ALL be REJECTED (0% accepted): a lax gate is
10
+ exactly what a policy under RL finds.
11
+
12
+ The criteria, the case tables (frozen, SHA-256 pinned) and the pass thresholds are
13
+ pre-registered in docs/LEAN_VERIFIER.md BEFORE the registered run. `--verify-prereg`
14
+ checks that the tables in this checkout hash to the registered value; the run refuses to
15
+ start otherwise (override: --allow-unregistered, recorded in the output).
16
+
17
+ Known-good sources
18
+ 1. Goedel-LM/Lean-workbook-proofs (public, proofs verified by Goedel-Prover-V1 under
19
+ the same Lean 4.9 / Mathlib environment as DeepSeek-Prover-V1.5 and
20
+ Goedel-Prover-V2): 6 contiguous slices of 25 rows at fixed offsets, fetched over
21
+ HTTP from the HF datasets-server (never the full dataset).
22
+ 2. 30 hand-restated Mathlib-style theorems with proofs (scripts/lean_ceiling_cases.py).
23
+ Each is first run through an INDEPENDENT compile-only pass (same REPL, no strictness at
24
+ all: no error message, no `sorry` warning). Items that fail it are version-incompatible
25
+ with the pin and are excluded and listed; the strict verdict is scored on the rest.
26
+
27
+ Host rules (/apps/CLAUDE.md): `apply_resource_guards(heavy=True)` runs the preflight and
28
+ takes the exclusive heavy-job flock; scratch lives under align/.tmp/; nothing touches / or
29
+ /tmp; every blocking call has a timeout; the process has a global wall-clock watchdog.
30
+
31
+ python scripts/lean_ceiling_test.py --workers 8
32
+ python scripts/lean_ceiling_test.py --verify-prereg
33
+ python scripts/lean_ceiling_test.py --measure-only
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import hashlib
40
+ import json
41
+ import math
42
+ import os
43
+ import re
44
+ import statistics
45
+ import subprocess
46
+ import sys
47
+ import threading
48
+ import time
49
+ import urllib.parse
50
+ import urllib.request
51
+ from pathlib import Path
52
+
53
+ REPO = Path(__file__).resolve().parent.parent
54
+ sys.path.insert(0, str(REPO))
55
+
56
+ import importlib.util # noqa: E402
57
+
58
+ # Loaded by path (not `import lean_ceiling_cases`): scripts/ is not a package, and the
59
+ # dependency firewall (rightly) rejects an import it cannot place in stdlib/allowed/align.
60
+ _spec = importlib.util.spec_from_file_location("lean_ceiling_cases", Path(__file__).with_name("lean_ceiling_cases.py"))
61
+ C = importlib.util.module_from_spec(_spec)
62
+ sys.modules["lean_ceiling_cases"] = C
63
+ _spec.loader.exec_module(C)
64
+
65
+ DOC = REPO / "docs" / "LEAN_VERIFIER.md"
66
+ DATASET = "Goedel-LM/Lean-workbook-proofs"
67
+ SLICE_OFFSETS = (0, 5000, 10000, 15000, 20000, 25000)
68
+ SLICE_LEN = 25
69
+ KNOWN_GOOD_FLOOR = 0.98
70
+ MIN_KNOWN_GOOD = 50
71
+ MIN_ADVERSARIAL = 30
72
+
73
+
74
+ # ---------------------------------------------------------------------------- prereg
75
+
76
+
77
+ def registered_hash() -> str | None:
78
+ if not DOC.exists():
79
+ return None
80
+ m = re.search(r"PREREG_TABLE_SHA256:\s*`?([0-9a-f]{64})`?", DOC.read_text())
81
+ return m.group(1) if m else None
82
+
83
+
84
+ def verify_prereg() -> bool:
85
+ reg, cur = registered_hash(), C.table_hash()
86
+ print(f"registered : {reg}\ncurrent : {cur}\nmatch : {reg == cur}")
87
+ return reg == cur
88
+
89
+
90
+ # ---------------------------------------------------------------------------- verdict
91
+
92
+
93
+ def empty_known_good_entry(candidates: int, excluded: int) -> dict:
94
+ """Report entry for a run in which NO known-good proof reached the strict stage (toolchain broken,
95
+ REPL crashing, header mismatch: every candidate failed the independent compile-only filter).
96
+ That is the absence of the ceiling's first side, i.e. a FAILURE: a verifier that rejects
97
+ everything would otherwise sail through the adversarial side (accepted == 0 is trivially true)."""
98
+ return {"n": 0, "accepted": 0, "rate": float("nan"), "floor": KNOWN_GOOD_FLOOR, "pass": False,
99
+ "why": f"no known-good proof survived the compile-only filter ({excluded}/{candidates} excluded)",
100
+ "rejected": []}
101
+
102
+
103
+ def ceiling_ok(report: dict, only: str | None) -> bool:
104
+ """Exit-status verdict. Both sides must be PRESENT and passing in a full run; `--only` runs need
105
+ the side they ran. A missing side is never a pass (the old `all(v.get('pass', True) ...)` read
106
+ absence as success)."""
107
+ need = {None: ("known_good", "adversarial"), "good": ("known_good",), "adversarial": ("adversarial",)}[only]
108
+ if any(not isinstance(report.get(k), dict) or not report[k].get("pass") for k in need):
109
+ return False
110
+ return all(v.get("pass", True) for v in report.values() if isinstance(v, dict))
111
+
112
+
113
+ # ---------------------------------------------------------------------------- statistics
114
+
115
+
116
+ def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
117
+ """Wilson score interval for a binomial proportion (exact-ish at k=n or k=0)."""
118
+ if n == 0:
119
+ return (float("nan"), float("nan"))
120
+ p = k / n
121
+ d = 1 + z * z / n
122
+ c = (p + z * z / (2 * n)) / d
123
+ h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
124
+ return (max(0.0, c - h), min(1.0, c + h))
125
+
126
+
127
+ def pctl(xs: list[float], q: float) -> float:
128
+ if not xs:
129
+ return float("nan")
130
+ s = sorted(xs)
131
+ return s[min(len(s) - 1, int(q * len(s)))]
132
+
133
+
134
+ # ---------------------------------------------------------------------------- data
135
+
136
+
137
+ def http_json(url: str, timeout: float = 60.0, retries: int = 4) -> dict:
138
+ last = None
139
+ for i in range(retries):
140
+ try:
141
+ req = urllib.request.Request(url, headers={"User-Agent": "align-lean-ceiling/1"})
142
+ with urllib.request.urlopen(req, timeout=timeout) as r:
143
+ return json.loads(r.read().decode("utf-8"))
144
+ except Exception as e: # noqa: BLE001
145
+ last = e
146
+ time.sleep(2 * (i + 1))
147
+ raise RuntimeError(f"GET {url} failed: {last!r}")
148
+
149
+
150
+ def fetch_known_good(out_dir: Path) -> tuple[list[dict], dict]:
151
+ rows: list[dict] = []
152
+ for off in SLICE_OFFSETS:
153
+ q = urllib.parse.urlencode(
154
+ {"dataset": DATASET, "config": "default", "split": "train", "offset": off, "length": SLICE_LEN}
155
+ )
156
+ d = http_json(f"https://datasets-server.huggingface.co/rows?{q}")
157
+ for r in d["rows"]:
158
+ rows.append({"problem_id": r["row"]["problem_id"], "full_proof": r["row"]["full_proof"], "row_idx": r["row_idx"]})
159
+ raw = out_dir / "known_good_raw.jsonl"
160
+ with open(raw, "w") as f:
161
+ for r in rows:
162
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
163
+ meta = {"dataset": DATASET, "offsets": list(SLICE_OFFSETS), "slice_len": SLICE_LEN, "n_rows": len(rows),
164
+ "raw_sha256": hashlib.sha256(raw.read_bytes()).hexdigest()}
165
+ try:
166
+ info = http_json(f"https://huggingface.co/api/datasets/{DATASET}")
167
+ meta["dataset_revision"] = info.get("sha")
168
+ except Exception as e: # noqa: BLE001
169
+ meta["dataset_revision"] = f"unavailable: {e!r}"
170
+ return rows, meta
171
+
172
+
173
+ def derive_statement(code: str):
174
+ """(statement, target_name) from a full proof file: header + the LAST theorem/lemma's
175
+ signature (cut at the signature-ending `:=`, via the verifier's own scanner) + `sorry`."""
176
+ from align.verify.lean.strict import find_sig_end, lex
177
+
178
+ lx = lex(code)
179
+ toks = lx.toks
180
+ if lx.broken:
181
+ return None
182
+ first = next((i for i, t in enumerate(toks) if t.kind == "id" and t.text in
183
+ ("theorem", "lemma", "def", "abbrev", "instance", "example", "structure", "inductive")), None)
184
+ thm = [i for i, t in enumerate(toks) if t.kind == "id" and t.text in ("theorem", "lemma")]
185
+ if first is None or not thm:
186
+ return None
187
+ k = thm[-1]
188
+ e = find_sig_end(toks, k + 2)
189
+ if e is None or k + 1 >= len(toks):
190
+ return None
191
+ name = toks[k + 1].text
192
+ header = code[: toks[first - 1].end] if first > 0 else ""
193
+ sig = code[toks[k + 2].start : toks[e].start]
194
+ return header + "\n\n" + f"theorem {name} {sig} := by\n sorry\n", name
195
+
196
+
197
+ def as_completion(code: str) -> str:
198
+ return "I will state a plan and then give the proof.\n\n```lean4\n" + code.rstrip("\n") + "\n```\n"
199
+
200
+
201
+ # ---------------------------------------------------------------------------- compile-only
202
+
203
+
204
+ def compile_only(w, statement: str, code: str, timeout: float = 120.0) -> dict:
205
+ """The NAIVE grader: elaborate the candidate and look at nothing but error messages.
206
+ Used (a) as the independent reference compile that defines "known-good", and (b) to show
207
+ how many adversarial cases a compile-only reward would pay."""
208
+ from align.verify.lean.repl_pool import ReplError
209
+ from align.verify.lean.strict import lex, parse_statement
210
+
211
+ ref = parse_statement(statement)
212
+ if not ref.ok:
213
+ return {"ok": False, "why": "unsupported_statement"}
214
+ lx = lex(code)
215
+ nh = len(ref.header_toks)
216
+ if lx.broken or tuple(t.text for t in lx.toks[:nh]) != ref.header_toks:
217
+ return {"ok": False, "why": "header"}
218
+ body = code[lx.toks[nh - 1].end :] if nh else code
219
+ try:
220
+ env = w.header_env(ref.header_text)
221
+ resp = w.command(body, env, timeout)
222
+ except ReplError as e:
223
+ return {"ok": False, "why": e.code}
224
+ msgs = [m for m in resp.get("messages", []) if isinstance(m, dict)]
225
+ err = [m for m in msgs if m.get("severity") == "error"]
226
+ sorry = bool(resp.get("sorries")) or any("sorry" in str(m.get("data", "")) for m in msgs)
227
+ return {"ok": not err, "clean": (not err) and (not sorry), "why": "error" if err else ("sorry" if sorry else "")}
228
+
229
+
230
+ def naive_fresh(w, code: str, timeout: float = 120.0) -> dict:
231
+ """Compile-only on the candidate's OWN header (fresh import, no cache) -- for the
232
+ adversarial cases whose header is the attack. Each case gets a FRESH process: a second
233
+ `import Mathlib` in a live REPL costs +3.9GiB anonymous (see `split_imports`)."""
234
+ from align.verify.lean.repl_pool import ReplError
235
+
236
+ w.discard()
237
+ try:
238
+ proc = w.ensure()
239
+ resp = proc.request({"cmd": code}, timeout)
240
+ except ReplError as e:
241
+ w.discard()
242
+ return {"ok": False, "why": e.code}
243
+ w.discard()
244
+ err = [m for m in resp.get("messages", []) if isinstance(m, dict) and m.get("severity") == "error"]
245
+ return {"ok": not err, "why": "error" if err else ""}
246
+
247
+
248
+ # ---------------------------------------------------------------------------- measurement
249
+
250
+
251
+ def dir_bytes(p: Path) -> int:
252
+ out = subprocess.run(["du", "-sb", str(p)], capture_output=True, text=True, timeout=300)
253
+ return int(out.stdout.split()[0]) if out.returncode == 0 and out.stdout else -1
254
+
255
+
256
+ def evict_page_cache(tc) -> dict:
257
+ """Drop the page cache for every .olean/.ilean under the install (POSIX_FADV_DONTNEED
258
+ works unprivileged for clean, unmapped pages) so the next import is a COLD read."""
259
+ n = tot = 0
260
+ roots = [tc.workspace / ".lake" / "packages", tc.root / "elan" / "toolchains"]
261
+ for r in roots:
262
+ for dp, _dn, fns in os.walk(r):
263
+ for fn in fns:
264
+ if fn.endswith((".olean", ".ilean", ".so")):
265
+ fp = os.path.join(dp, fn)
266
+ try:
267
+ fd = os.open(fp, os.O_RDONLY)
268
+ try:
269
+ tot += os.fstat(fd).st_size
270
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
271
+ n += 1
272
+ finally:
273
+ os.close(fd)
274
+ except OSError:
275
+ pass
276
+ return {"files": n, "bytes": tot}
277
+
278
+
279
+ def cached_fraction(tc) -> float | None:
280
+ """Fraction of the Mathlib .olean bytes resident in page cache, via `fincore` if present."""
281
+ import shutil
282
+
283
+ f = shutil.which("fincore")
284
+ if not f:
285
+ return None
286
+ lib = tc.workspace / ".lake" / "packages" / "mathlib" / ".lake" / "build" / "lib" / "Mathlib"
287
+ files = [str(p) for p in lib.rglob("*.olean")][:4000]
288
+ try:
289
+ out = subprocess.run([f, "--bytes", "--raw", "--noheadings", *files], capture_output=True, text=True, timeout=120)
290
+ res = tot = 0
291
+ for line in out.stdout.splitlines():
292
+ parts = line.split()
293
+ if len(parts) >= 3 and parts[0].isdigit():
294
+ res += int(parts[0])
295
+ tot += int(parts[1])
296
+ return res / tot if tot else None
297
+ except Exception: # noqa: BLE001
298
+ return None
299
+
300
+
301
+ def measure_install(tc, workers: int, out: dict) -> None:
302
+ from align.verify.lean.repl_pool import ReplLimits, ReplPool, ReplProcess
303
+
304
+ m: dict = {}
305
+ m["disk_bytes"] = {
306
+ "total": dir_bytes(tc.root),
307
+ "elan_toolchain": dir_bytes(tc.root / "elan"),
308
+ "workspace": dir_bytes(tc.workspace),
309
+ "mathlib_package": dir_bytes(tc.workspace / ".lake" / "packages" / "mathlib"),
310
+ "repl": dir_bytes(tc.root / "repl"),
311
+ }
312
+ hdr = C.HEADER.rstrip("\n")
313
+
314
+ def one_import() -> tuple[float, dict | None]:
315
+ p = ReplProcess(tc.argv(), tc.child_env, cwd=tc.cwd, limits=ReplLimits())
316
+ p.start()
317
+ t = time.monotonic()
318
+ r = p.request({"cmd": hdr}, 900)
319
+ dt = time.monotonic() - t
320
+ assert "env" in r and not r.get("messages"), r
321
+ mem = p.memory()
322
+ p.kill()
323
+ return dt, mem
324
+
325
+ ev = evict_page_cache(tc)
326
+ m["evicted"] = ev
327
+ m["cached_fraction_after_evict"] = cached_fraction(tc)
328
+ cold, cold_mem = one_import()
329
+ m["cached_fraction_after_cold_import"] = cached_fraction(tc)
330
+ warm = [one_import()[0] for _ in range(3)]
331
+ m["import_s"] = {"cold": cold, "warm": warm, "warm_median": statistics.median(warm)}
332
+ m["single_repl_after_import_mib"] = cold_mem
333
+
334
+ # N workers concurrently warm: per-process PSS (RSS double-counts the shared oleans)
335
+ pool = ReplPool.from_toolchain(tc, n_workers=workers, scratch_dir=str(tc.root / "tmp" / "measure"))
336
+ try:
337
+ t = time.monotonic()
338
+ errs = pool.warm(hdr)
339
+ m["pool_warm_s"] = time.monotonic() - t
340
+ assert not any(errs), errs
341
+ rep = pool.memory_report()
342
+ m["n_workers"] = workers
343
+ m["per_worker_mib"] = rep
344
+ pss = [r["pss"] for r in rep if r]
345
+ m["pss_mib"] = {"min": min(pss), "mean": statistics.mean(pss), "max": max(pss), "sum": sum(pss)}
346
+ m["pss_anon_mib"] = {"mean": statistics.mean(r["pss_anon"] for r in rep if r)}
347
+ m["rss_sum_mib"] = sum(r["rss"] for r in rep if r)
348
+ finally:
349
+ pool.close()
350
+ out["install"] = m
351
+
352
+
353
+ # ---------------------------------------------------------------------------- confusion
354
+
355
+
356
+ def confusion(good_acc: list[bool], bad_acc: list[bool]) -> dict:
357
+ tp = sum(good_acc)
358
+ fn = len(good_acc) - tp
359
+ fp = sum(bad_acc)
360
+ tn = len(bad_acc) - fp
361
+ return {"TP": tp, "FN": fn, "FP": fp, "TN": tn, "n_good": len(good_acc), "n_bad": len(bad_acc)}
362
+
363
+
364
+ def render_confusion(c: dict) -> str:
365
+ return (
366
+ " verdict ACCEPT verdict REJECT total\n"
367
+ f"truth GOOD proofs {c['TP']:>14} {c['FN']:>14} {c['n_good']:>5}\n"
368
+ f"truth BAD proofs {c['FP']:>14} {c['TN']:>14} {c['n_bad']:>5}\n"
369
+ )
370
+
371
+
372
+ # ---------------------------------------------------------------------------- main
373
+
374
+
375
+ def sample_memory(pool, stop: threading.Event, rows: list) -> None:
376
+ while not stop.is_set():
377
+ try:
378
+ rep = pool.memory_report()
379
+ with open("/proc/meminfo") as f:
380
+ avail = next(int(l.split()[1]) for l in f if l.startswith("MemAvailable:")) / 1024**2
381
+ rows.append({"t": time.time(), "workers": rep, "mem_available_gb": avail})
382
+ except Exception: # noqa: BLE001
383
+ pass
384
+ stop.wait(0.5)
385
+
386
+
387
+ def main() -> int:
388
+ ap = argparse.ArgumentParser()
389
+ ap.add_argument("--workers", type=int, default=None)
390
+ ap.add_argument("--out", default=None)
391
+ ap.add_argument("--verify-prereg", action="store_true")
392
+ ap.add_argument("--allow-unregistered", action="store_true")
393
+ ap.add_argument("--measure-only", action="store_true")
394
+ ap.add_argument("--skip-measure", action="store_true")
395
+ ap.add_argument("--max-wall-s", type=float, default=3600.0)
396
+ ap.add_argument("--no-semantic-pass", action="store_true")
397
+ ap.add_argument("--no-naive-pass", action="store_true")
398
+ ap.add_argument("--only", choices=("good", "adversarial"), default=None)
399
+ ap.add_argument("--exploratory", action="store_true",
400
+ help="debug run: <=2 workers, no heavy-job lock (a registered run always takes it)")
401
+ ap.add_argument("--lock-wait-s", type=float, default=7200.0)
402
+ ap.add_argument("--no-kernel-recheck", action="store_true",
403
+ help="known-good pass without the kernel recheck (default: on, as in the shipped config)")
404
+ ap.add_argument("--kg-limit", type=int, default=0,
405
+ help="exploratory only: keep the first N rows of each fetched slice (0 = all)")
406
+ args = ap.parse_args()
407
+
408
+ if args.verify_prereg:
409
+ return 0 if verify_prereg() else 2
410
+ registered = registered_hash() == C.table_hash()
411
+ if not registered and not args.allow_unregistered and not args.measure_only:
412
+ print("REFUSING: case tables do not match the registered hash (see --verify-prereg); "
413
+ "pass --allow-unregistered for an exploratory run", file=sys.stderr)
414
+ return 2
415
+
416
+ os.environ.setdefault("HF_HOME", "/apps/development/rlhf/hf_cache")
417
+ os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
418
+ from align.utils.guard import apply_resource_guards
419
+
420
+ apply_resource_guards(cpu_threads=8, heavy=False, label="lean_ceiling_test")
421
+ if not args.exploratory:
422
+ # A Lean pool is a heavy job (research report, section 7A): same preflight + the same
423
+ # exclusive flock as the trainers. If a GPU job holds it, WAIT -- never overlap.
424
+ from align.exit_codes import PreflightRefused
425
+ from align.utils.guard import acquire_heavy_job_lock, preflight_host_check
426
+
427
+ t_wait = time.monotonic()
428
+ while True:
429
+ try:
430
+ preflight_host_check(strict=True)
431
+ acquire_heavy_job_lock("lean_ceiling_test")
432
+ break
433
+ except PreflightRefused as e:
434
+ if time.monotonic() - t_wait > args.lock_wait_s:
435
+ print(f"gave up waiting for the host: {e}", file=sys.stderr)
436
+ return 83
437
+ print(f"host busy ({str(e)[:120]}...); retrying in 30s", flush=True)
438
+ time.sleep(30)
439
+ elif (args.workers or 2) > 2:
440
+ print("--exploratory caps workers at 2", file=sys.stderr)
441
+ args.workers = 2
442
+
443
+ from align.verify.lean import (
444
+ LeanAccuracy, ReplLimits, ReplPool, StrictConfig, StrictVerifier, default_workers, find_toolchain,
445
+ )
446
+ from align.verify.lean.repl_pool import meminfo_available_gb, plan_workers
447
+
448
+ tc = find_toolchain()
449
+ limits = ReplLimits()
450
+ n = args.workers or default_workers()
451
+ n = plan_workers(n, limits.anon_cap_gb, 4.0, meminfo_available_gb())
452
+ stamp = time.strftime("%Y%m%d_%H%M%S")
453
+ out_dir = Path(args.out or REPO / ".tmp" / "lean_ceiling" / stamp)
454
+ out_dir.mkdir(parents=True, exist_ok=True)
455
+ pools: list = []
456
+
457
+ def watchdog() -> None:
458
+ time.sleep(args.max_wall_s)
459
+ print(f"WATCHDOG: {args.max_wall_s}s exceeded; killing pools", file=sys.stderr, flush=True)
460
+ for p in pools:
461
+ try:
462
+ p.close()
463
+ except Exception: # noqa: BLE001
464
+ pass
465
+ os._exit(124)
466
+
467
+ threading.Thread(target=watchdog, daemon=True).start()
468
+
469
+ def on_signal(signum, _frame):
470
+ print(f"signal {signum}: closing pools", file=sys.stderr, flush=True)
471
+ for p in pools:
472
+ try:
473
+ p.close()
474
+ except Exception: # noqa: BLE001
475
+ pass
476
+ os._exit(128 + signum)
477
+
478
+ import signal
479
+
480
+ signal.signal(signal.SIGTERM, on_signal)
481
+ signal.signal(signal.SIGINT, on_signal)
482
+
483
+ result: dict = {
484
+ "started": stamp, "toolchain": tc.describe(), "pins": tc.pins, "workers": n,
485
+ "limits": limits.__dict__, "prereg_registered": registered, "table_sha256": C.table_hash(),
486
+ "cores": os.cpu_count(),
487
+ }
488
+
489
+ def new_pool(workers: int, lim: ReplLimits = limits, label: str = "pool") -> ReplPool:
490
+ p = ReplPool.from_toolchain(tc, n_workers=workers, limits=lim, scratch_dir=str(tc.root / "tmp" / label))
491
+ pools.append(p)
492
+ return p
493
+
494
+ # -------------------------------------------------- install measurements
495
+ if not args.skip_measure and args.only is None:
496
+ print("== measuring install ==", flush=True)
497
+ measure_install(tc, n, result)
498
+ print(json.dumps(result["install"], indent=1, default=str)[:3000], flush=True)
499
+ if args.measure_only:
500
+ (out_dir / "result.json").write_text(json.dumps(result, indent=1, default=str))
501
+ return 0
502
+
503
+ # -------------------------------------------------- known-good
504
+ good_items: list[dict] = []
505
+ if args.only in (None, "good"):
506
+ print("== known-good: fetching ==", flush=True)
507
+ rows, meta = fetch_known_good(out_dir)
508
+ if args.kg_limit:
509
+ if not args.exploratory:
510
+ print("--kg-limit is for exploratory runs only", file=sys.stderr)
511
+ return 2
512
+ rows = [r for i, r in enumerate(rows) if i % SLICE_LEN < args.kg_limit]
513
+ meta["kg_limit"] = args.kg_limit
514
+ result["known_good_source"] = meta
515
+ cands = []
516
+ for r in rows:
517
+ code = r["full_proof"]
518
+ ds = derive_statement(code)
519
+ if ds is None:
520
+ cands.append({"id": r["problem_id"], "source": "lean-workbook-proofs", "code": code, "statement": None})
521
+ else:
522
+ cands.append({"id": r["problem_id"], "source": "lean-workbook-proofs", "code": code, "statement": ds[0]})
523
+ for rs in C.RESTATED:
524
+ code = C.HEADER + rs["code"]
525
+ ds = derive_statement(code)
526
+ cands.append({"id": rs["id"], "source": "restated", "code": code, "statement": ds[0] if ds else None})
527
+ result["known_good_candidates"] = len(cands)
528
+
529
+ print("== known-good: independent compile-only filter ==", flush=True)
530
+ pool = new_pool(n, label="kg_compile")
531
+ t0 = time.monotonic()
532
+ refs = pool.map(
533
+ lambda w, it: compile_only(w, it["statement"], it["code"]) if it["statement"] else {"ok": False, "clean": False, "why": "no_statement"},
534
+ cands,
535
+ )
536
+ pool.close()
537
+ excluded = []
538
+ for it, r in zip(cands, refs):
539
+ if isinstance(r, Exception):
540
+ r = {"ok": False, "clean": False, "why": repr(r)}
541
+ it["ref"] = r
542
+ if r.get("clean"):
543
+ good_items.append(it)
544
+ else:
545
+ excluded.append({"id": it["id"], "source": it["source"], "why": r.get("why")})
546
+ result["known_good_excluded"] = excluded
547
+ result["known_good_filter_s"] = time.monotonic() - t0
548
+ print(f"compile-clean: {len(good_items)}/{len(cands)} excluded: {len(excluded)}", flush=True)
549
+
550
+ # -------------------------------------------------- known-good: strict
551
+ good_results: list[dict] = []
552
+ if good_items:
553
+ print("== known-good: strict verification ==", flush=True)
554
+ pool = new_pool(n, label="kg_strict")
555
+ acc = LeanAccuracy(StrictVerifier(pool, StrictConfig(kernel_recheck=not args.no_kernel_recheck)))
556
+ from align.verify.lean.strict import AUDIT_PRELUDE, static_check
557
+
558
+ plan, _, _ = static_check(good_items[0]["statement"], good_items[0]["code"])
559
+ t = time.monotonic()
560
+ pool.warm(plan.header if plan else C.HEADER, AUDIT_PRELUDE)
561
+ result["strict_warm_s"] = time.monotonic() - t
562
+ result["warm_memory_mib"] = pool.memory_report()
563
+ stop, mem_rows = threading.Event(), []
564
+ th = threading.Thread(target=sample_memory, args=(pool, stop, mem_rows), daemon=True)
565
+ th.start()
566
+ t = time.monotonic()
567
+ def prog(ix, r):
568
+ print(f" [{ix:3d}] {good_items[ix]['id']:34s} {'ACCEPT' if r.accepted else 'REJECT'} {r.elapsed_s:7.2f}s {list(r.reasons)}", flush=True)
569
+
570
+ accs, res = acc.accuracy_batch([g["statement"] for g in good_items], [as_completion(g["code"]) for g in good_items], progress=prog)
571
+ wall = time.monotonic() - t
572
+ stop.set()
573
+ th.join(5)
574
+ for g, a, r in zip(good_items, accs, res):
575
+ good_results.append({"id": g["id"], "source": g["source"], "accepted": bool(a), "reasons": list(r.reasons),
576
+ "diag": r.diagnostics, "elapsed_s": r.elapsed_s})
577
+ lat = [r.elapsed_s for r in res]
578
+ result["throughput"] = {
579
+ "n_proofs": len(res), "wall_s": wall, "proofs_per_s": len(res) / wall, "workers": n,
580
+ "latency_s": {"p50": pctl(lat, 0.5), "p90": pctl(lat, 0.9), "p99": pctl(lat, 0.99), "max": max(lat), "mean": statistics.mean(lat)},
581
+ "note": "unique statement per proof (no reference-env reuse across proofs); warm workers",
582
+ }
583
+ # per-worker PSS during the run
584
+ per_w: dict[int, list] = {}
585
+ for row in mem_rows:
586
+ for i, m in enumerate(row["workers"]):
587
+ if m:
588
+ per_w.setdefault(i, []).append(m)
589
+ result["pss_during_run_mib"] = {
590
+ str(i): {"pss_mean": statistics.mean(m["pss"] for m in ms), "pss_max": max(m["pss"] for m in ms),
591
+ "pss_anon_max": max(m["pss_anon"] for m in ms), "rss_max": max(m["rss"] for m in ms),
592
+ "samples": len(ms)} for i, ms in sorted(per_w.items())
593
+ }
594
+ result["host_during_run"] = {"min_mem_available_gb": min((r["mem_available_gb"] for r in mem_rows), default=None)}
595
+ result["pool_stats"] = pool.stats.__dict__
596
+ # repeat-G run: GRPO shape (same statement x8, reference env reused) on the 12
597
+ # MEDIAN-latency statements of the pass above (the heavy tail would swamp a 96-proof run)
598
+ order = sorted(range(len(good_items)), key=lambda i: res[i].elapsed_s)
599
+ mid = max(0, len(order) // 2 - 6)
600
+ rep = [good_items[i] for i in order[mid : mid + 12]] * 8
601
+ t = time.monotonic()
602
+ accs2, _ = acc.accuracy_batch([g["statement"] for g in rep], [as_completion(g["code"]) for g in rep])
603
+ w2 = time.monotonic() - t
604
+ result["throughput_grpo_shape"] = {"n_proofs": len(rep), "wall_s": w2, "proofs_per_s": len(rep) / w2, "accepted": int(sum(accs2)),
605
+ "shape": "12 median-latency statements x 8 identical samples"}
606
+ pool.close()
607
+
608
+ # -------------------------------------------------- adversarial
609
+ adv_results: list[dict] = []
610
+ if args.only in (None, "adversarial"):
611
+ print("== adversarial: strict ==", flush=True)
612
+
613
+ def completion_for(case) -> tuple[str, str | None]:
614
+ stmt = C.HEADER + C.STATEMENTS[case["statement"]]
615
+ if case["completion"] is not None:
616
+ return stmt, case["completion"]
617
+ hdr = C.HEADER if case["raw_header"] is None else case["raw_header"]
618
+ return stmt, as_completion(hdr + case["code"])
619
+
620
+ main_cases = [c for c in C.ADVERSARIAL if c["pool"] == "main"]
621
+ bomb_cases = [c for c in C.ADVERSARIAL if c["pool"] == "bomb"]
622
+ pool = new_pool(n, label="adv_strict")
623
+ acc = LeanAccuracy(StrictVerifier(pool, StrictConfig(proof_timeout_s=120)), min_batch=10**9)
624
+ pairs = [completion_for(c) for c in main_cases]
625
+ _, rs = acc.accuracy_batch([p[0] for p in pairs], [p[1] for p in pairs])
626
+ pool.close()
627
+ by_id = {}
628
+ for c, r in zip(main_cases, rs):
629
+ by_id[c["id"]] = {"id": c["id"], "category": c["category"], "accepted": r.accepted, "reasons": list(r.reasons),
630
+ "stage": r.stage, "diag": r.diagnostics, "elapsed_s": r.elapsed_s, "pool": "main"}
631
+ # bombs: 1 worker, 1 GiB anon cap, 30 s budget so they terminate promptly (same code paths)
632
+ blim = ReplLimits(anon_cap_gb=1.0)
633
+ bpool = new_pool(1, blim, label="adv_bomb")
634
+ bacc = LeanAccuracy(StrictVerifier(bpool, StrictConfig(proof_timeout_s=30)), min_batch=10**9)
635
+ for c in bomb_cases:
636
+ stmt, comp = completion_for(c)
637
+ sc = bacc.score(stmt, comp)
638
+ by_id[c["id"]] = {"id": c["id"], "category": c["category"], "accepted": sc.result.accepted,
639
+ "reasons": list(sc.result.reasons), "stage": sc.result.stage, "diag": sc.result.diagnostics,
640
+ "elapsed_s": sc.result.elapsed_s, "pool": "bomb(1w,1GiB,30s)"}
641
+ bpool.close()
642
+ adv_results = [by_id[c["id"]] for c in C.ADVERSARIAL]
643
+
644
+ # naive compile-only on each case's own text (what a compile-only reward would pay)
645
+ if not args.no_naive_pass:
646
+ print("== adversarial: naive compile-only (liveness) ==", flush=True)
647
+ npool = new_pool(n, label="adv_naive")
648
+ live_cases = [c for c in main_cases if c["completion"] is None and c["code"]]
649
+ texts = [(c["raw_header"] if c["raw_header"] is not None else C.HEADER) + c["code"] for c in live_cases]
650
+ nv = npool.map(lambda w, t: naive_fresh(w, t, 120.0), texts)
651
+ npool.close()
652
+ naive = {c["id"]: (v if not isinstance(v, Exception) else {"ok": False, "why": repr(v)}) for c, v in zip(live_cases, nv)}
653
+ for r in adv_results:
654
+ r["naive_compile_ok"] = naive.get(r["id"], {}).get("ok")
655
+
656
+ # semantic layer alone (lexical gate OFF): how much does the Lean-side audit catch by itself?
657
+ if not args.no_semantic_pass:
658
+ print("== adversarial: semantic layer only (lexical gate off) ==", flush=True)
659
+ spool = new_pool(n, label="adv_sem")
660
+ sv = StrictVerifier(spool, StrictConfig(lexical_gate=False, kernel_recheck=True, proof_timeout_s=120))
661
+ sem_cases = [c for c in main_cases if c["completion"] is None and c["code"]]
662
+ from align.verify.lean.reward import extract_lean_code
663
+
664
+ sem_items = []
665
+ for c in sem_cases:
666
+ stmt, comp = completion_for(c)
667
+ sem_items.append((stmt, extract_lean_code(comp)))
668
+ sres = sv.verify_batch(sem_items)
669
+ spool.close()
670
+ sem = {c["id"]: (r.accepted, list(r.reasons)) for c, r in zip(sem_cases, sres)}
671
+ for r in adv_results:
672
+ if r["id"] in sem:
673
+ r["semantic_only_accepted"], r["semantic_only_reasons"] = sem[r["id"]]
674
+
675
+ # -------------------------------------------------- report
676
+ report = {}
677
+ if not good_results and args.only in (None, "good"):
678
+ report["known_good"] = empty_known_good_entry(
679
+ result.get("known_good_candidates", 0), len(result.get("known_good_excluded", []))
680
+ )
681
+ if good_results:
682
+ acc_good = [g["accepted"] for g in good_results]
683
+ lo, hi = wilson(sum(acc_good), len(acc_good))
684
+ report["known_good"] = {"n": len(acc_good), "accepted": sum(acc_good), "rate": sum(acc_good) / len(acc_good),
685
+ "wilson95": [lo, hi], "floor": KNOWN_GOOD_FLOOR,
686
+ "pass": (sum(acc_good) / len(acc_good) >= KNOWN_GOOD_FLOOR and len(acc_good) >= MIN_KNOWN_GOOD),
687
+ "rejected": [g for g in good_results if not g["accepted"]]}
688
+ if adv_results:
689
+ acc_bad = [a["accepted"] for a in adv_results]
690
+ by_cat: dict[str, list] = {}
691
+ for a in adv_results:
692
+ by_cat.setdefault(a["category"], []).append(a["accepted"])
693
+ report["adversarial"] = {"n": len(acc_bad), "accepted": sum(acc_bad), "pass": (sum(acc_bad) == 0 and len(acc_bad) >= MIN_ADVERSARIAL),
694
+ "by_category": {k: {"n": len(v), "accepted": sum(v)} for k, v in sorted(by_cat.items())},
695
+ "accepted_cases": [a for a in adv_results if a["accepted"]]}
696
+ live = [a for a in adv_results if a.get("naive_compile_ok")]
697
+ report["adversarial"]["naive_compile_only_would_accept"] = len(live)
698
+ if any("semantic_only_accepted" in a for a in adv_results):
699
+ sem_acc = [a for a in adv_results if a.get("semantic_only_accepted")]
700
+ report["adversarial"]["semantic_only_would_accept"] = [a["id"] for a in sem_acc]
701
+ if good_results and adv_results:
702
+ report["confusion"] = confusion([g["accepted"] for g in good_results], [a["accepted"] for a in adv_results])
703
+ result["report"] = report
704
+ result["good_results"] = good_results
705
+ result["adversarial_results"] = adv_results
706
+ (out_dir / "result.json").write_text(json.dumps(result, indent=1, default=str, ensure_ascii=False))
707
+ print("\n================ RESULT ================")
708
+ if "confusion" in report:
709
+ print(render_confusion(report["confusion"]))
710
+ if report.get("known_good", {}).get("n"):
711
+ k = report["known_good"]
712
+ print(f"known-good accepted {k['accepted']}/{k['n']} = {k['rate']:.4f} Wilson95 [{k['wilson95'][0]:.4f}, {k['wilson95'][1]:.4f}] floor {k['floor']} PASS={k['pass']}")
713
+ for g in k["rejected"]:
714
+ print(" REJECTED known-good:", g["id"], g["reasons"], g["diag"])
715
+ if "adversarial" in report:
716
+ a = report["adversarial"]
717
+ print(f"adversarial accepted {a['accepted']}/{a['n']} PASS={a['pass']} (naive compile-only would accept {a['naive_compile_only_would_accept']})")
718
+ for c in a["accepted_cases"]:
719
+ print(" ACCEPTED adversarial:", c["id"], c["reasons"])
720
+ if "throughput" in result:
721
+ print("throughput:", json.dumps(result["throughput"]))
722
+ print(f"results: {out_dir/'result.json'}")
723
+ if "known_good" in report and not report["known_good"]["n"]:
724
+ print(f"known-good: NO EVIDENCE -- {report['known_good']['why']} PASS=False")
725
+ return 0 if ceiling_ok(report, args.only) else 1
726
+
727
+
728
+ if __name__ == "__main__":
729
+ sys.exit(main())