Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- code/notebooks/0101-autoregressive-likelihood-sft.ipynb +309 -0
- code/notebooks/0102-policy-gradient-baselines.ipynb +347 -0
- code/notebooks/0103-importance-sampling-offpolicy.ipynb +357 -0
- code/notebooks/0104-trust-region-ppo-clip.ipynb +318 -0
- code/notebooks/0105-grpo-group-advantages.ipynb +403 -0
- code/notebooks/0106-dapo-pathologies.ipynb +450 -0
- code/notebooks/0107-cispo.ipynb +596 -0
- code/notebooks/0108-training-inference-mismatch.ipynb +585 -0
- code/notebooks/0109-kl-estimators.ipynb +436 -0
- code/notebooks/0110-reward-shaping-verifier.ipynb +417 -0
- code/notebooks/0201-verifier-binary-reward-mdp.ipynb +641 -0
- code/notebooks/0202-rejection-sampling-expert-iteration-em.ipynb +555 -0
- code/notebooks/0203-pass-at-k.ipynb +657 -0
- code/notebooks/0204-self-correction-multiturn-mdp.ipynb +505 -0
- code/notebooks/0205-model-averaging-weight-interpolation.ipynb +746 -0
- code/notebooks/0206-curriculum-pass-rate-band.ipynb +545 -0
- code/notebooks/0207-test-time-scaling-cost-model.ipynb +602 -0
- code/notebooks/0208-statistics-of-evaluation.ipynb +687 -0
- code/notebooks/0209-variance-components-equivalence.ipynb +486 -0
- code/notebooks/0210-data-selection-contamination.ipynb +500 -0
- code/notebooks/0301-curry-howard.ipynb +773 -0
- code/notebooks/0302-dependent-types.ipynb +762 -0
- code/notebooks/0303-hoare-refinement.ipynb +852 -0
- code/notebooks/0304-failed-proof-to-bug.ipynb +1006 -0
- code/notebooks/0305-spec-risk.ipynb +789 -0
- code/notebooks/0306-benchmarks-landscape.ipynb +479 -0
- code/notebooks/0307-aeneas-semantics.ipynb +438 -0
- code/notebooks/_lean_helper.py +46 -0
- code/notebooks/_m1_lib.py +145 -0
- code/notebooks/_m1_run.py +59 -0
- code/notebooks/_m1_show.py +4 -0
- code/notes/STYLE_ALIGNMENT.md +86 -0
- code/scripts/bench_7b_end_to_end.py +283 -0
- code/scripts/bench_flash_attention.py +234 -0
- code/scripts/build_data.py +125 -0
- code/scripts/build_stageb_data.py +169 -0
- code/scripts/ceiling_test.py +152 -0
- code/scripts/ckpt_convert.py +142 -0
- code/scripts/dedup_arm_b_shards.py +308 -0
- code/scripts/dpo.py +590 -0
- code/scripts/eval.py +102 -0
- code/scripts/eval_checkpoint.py +175 -0
- code/scripts/gate_flash_attention.py +132 -0
- code/scripts/generate.py +327 -0
- code/scripts/grpo.py +433 -0
- code/scripts/grpo_halt_at_108.sh +30 -0
- code/scripts/grpo_watch.py +289 -0
- code/scripts/hostguard.py +196 -0
- code/scripts/lean_ceiling_cases.py +245 -0
- 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())
|