unparallelled commited on
Commit
46c6b71
Β·
verified Β·
1 Parent(s): 764836a

Upload scripts/mk_cache_notebook.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. scripts/mk_cache_notebook.py +562 -0
scripts/mk_cache_notebook.py ADDED
@@ -0,0 +1,562 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ mk_cache_notebook.py -- build an EXECUTED .ipynb comparing the 2026-09-02
4
+ buggy-cache generative results against the 2026-09-03 corrected-cache retrain.
5
+
6
+ usage: mk_cache_notebook.py [--out cache_fix_results.ipynb]
7
+
8
+ THREE CONDITIONS, and why the third is not a fix
9
+
10
+ MATCHED-OLD run7 weights + YARN_ORIG_MAXPOS=1024 as trained (2026-09-02)
11
+ MATCHED-NEW run7' weights + YARN_ORIG_MAXPOS=4096 as trained (2026-09-03)
12
+ MISMATCH run7 weights + YARN_ORIG_MAXPOS=4096 never trained this way
13
+
14
+ Only the two MATCHED conditions can credit or blame the cache parameterisation,
15
+ because in each the model is read out under the rotation it adapted to. The
16
+ MISMATCH row is kept because it is instructive -- it is better than matched-old
17
+ at 1-2k and at 16k, and catastrophically worse at 4k -- but a cache swap at eval
18
+ time is a different rotation function (low=14 high=32 vs low=7 high=25), not a
19
+ repair of the weights.
20
+
21
+ ALL NUMBERS ARE READ FROM CSV. The 2026-09-02 scored CSVs were cleaned off the
22
+ box (only empty .done markers remain under longeval_*/state/), but they survive
23
+ in the HF repo and are pulled back into old_eval_hf/ by the snapshot download.
24
+ Nothing here is transcribed from prose -- an earlier draft of this script did
25
+ hardcode values from EVAL_COMPARISON.md and three of them were wrong (the ~4k
26
+ mismatch cell is 16.0, not the 23.5 midpoint of a remembered "14-33" range; the
27
+ 16,360 mismatch cell is 14.0, not 17.0).
28
+ """
29
+
30
+ import argparse
31
+ import csv
32
+ import json
33
+ import math
34
+ import os
35
+ import sys
36
+
37
+ HF = "old_eval_hf/logs"
38
+
39
+ # length -> directory, per condition. 16k lives in differently named dirs because
40
+ # it was run as a separate sweep with all 6 tasks rather than the 2-task length
41
+ # sweep.
42
+ OLD_MATCHED = {1024: f"{HF}/longeval_len_1024", 2048: f"{HF}/longeval_len_2048",
43
+ 4096: f"{HF}/longeval_len_4096", 8192: f"{HF}/longeval_len_8192",
44
+ 16360: f"{HF}/longeval_16k"}
45
+ OLD_MISMATCH = {1024: f"{HF}/longeval_fixlen_1024", 2048: f"{HF}/longeval_fixlen_2048",
46
+ 4096: f"{HF}/longeval_fixlen_4096", 8192: f"{HF}/longeval_fixlen_8192",
47
+ 16360: f"{HF}/longeval_fix16k"}
48
+
49
+ # old model key -> new model key. The retrain renames to avoid any chance of a
50
+ # stale checkpoint being read under a name that used to mean something else.
51
+ PAIR = {"base": "base_c", "arm_a": "armA_c",
52
+ "arm_b": "armB_c", "arm_c": "armC_c"}
53
+
54
+ # Tasks common to the old length sweep and the new one. niah_single carries the
55
+ # headline and the depth curve; val_backward is the one VAL Probing axis run.
56
+ TASKS = ["niah_single", "val_backward"]
57
+
58
+ PALETTE = {"old": "#2a78d6", "new": "#eb6834", "armb": "#1baf7a",
59
+ "mismatch": "#eda100", "arma": "#8d6ce0", "armc": "#c2185b"}
60
+ STYLE = {"surface": "#fcfcfb", "ink": "#0b0b0b", "ink2": "#52514e",
61
+ "grid": "#e4e4e1"}
62
+
63
+
64
+ def read_summary(d):
65
+ """{task: {model: pct}} from <d>/summary.csv, or {} if absent."""
66
+ p = os.path.join(d, "summary.csv")
67
+ if not os.path.exists(p):
68
+ return {}
69
+ rows = list(csv.reader(open(p)))
70
+ if len(rows) < 2:
71
+ return {}
72
+ models = rows[0][1:]
73
+ return {r[0]: {m: (float(v) if v not in ("", None) else None)
74
+ for m, v in zip(models, r[1:])} for r in rows[1:]}
75
+
76
+
77
+ def read_depth(d):
78
+ """[{depth, model, hits, n}] from <d>/depth_summary.csv (may be header-only)."""
79
+ p = os.path.join(d, "depth_summary.csv")
80
+ if not os.path.exists(p):
81
+ return []
82
+ out = []
83
+ for r in csv.DictReader(open(p)):
84
+ if not r.get("model"):
85
+ continue
86
+ out.append({"depth": float(r["depth"]), "model": r["model"],
87
+ "hits": int(r["hits"]), "n": int(r["n"])})
88
+ return out
89
+
90
+
91
+ def per_task_n(d, summary, depth_rows, fallback):
92
+ """Recover N per (task, model). summary.csv stores only percentages, so the
93
+ denominator has to come from depth_summary.csv: its n summed over depths is
94
+ the TASK-POOLED count, hence divide by the number of tasks present."""
95
+ if depth_rows and summary:
96
+ by_model = {}
97
+ for r in depth_rows:
98
+ by_model[r["model"]] = by_model.get(r["model"], 0) + r["n"]
99
+ ntask = max(1, len(summary))
100
+ got = {m: v // ntask for m, v in by_model.items() if v}
101
+ if got:
102
+ return got
103
+ return {}
104
+
105
+
106
+ def collect(dirs, fallback_n):
107
+ """{length: {"summary":…, "depth":…, "n":…}}"""
108
+ out = {}
109
+ for L, d in sorted(dirs.items()):
110
+ s, dp = read_summary(d), read_depth(d)
111
+ if not s:
112
+ continue
113
+ out[L] = {"summary": s, "depth": dp,
114
+ "n": per_task_n(d, s, dp, fallback_n), "dir": d,
115
+ "fallback_n": fallback_n.get(L, 50)}
116
+ return out
117
+
118
+
119
+ def _lines(s):
120
+ """Cell source as a STRING. nbclient calls .strip() on cell.source before
121
+ executing, so the list-of-lines form nbformat also accepts raises
122
+ AttributeError: 'list' object has no attribute 'strip'."""
123
+ return s.strip("\n")
124
+
125
+
126
+ def main():
127
+ ap = argparse.ArgumentParser()
128
+ ap.add_argument("--out", default="cache_fix_results.ipynb")
129
+ ap.add_argument("--new-n", type=int, default=100)
130
+ args = ap.parse_args()
131
+
132
+ old = collect(OLD_MATCHED, {1024: 50, 2048: 50, 4096: 50, 8192: 50, 16360: 100})
133
+ mis = collect(OLD_MISMATCH, {1024: 50, 2048: 50, 4096: 50, 8192: 50, 16360: 100})
134
+ new = collect({L: f"newck_len_{L}" for L in (1024, 2048, 4096, 8192, 16360)},
135
+ {L: args.new_n for L in (1024, 2048, 4096, 8192, 16360)})
136
+
137
+ if not old:
138
+ sys.exit(f"no old data under {HF} -- snapshot_download it from HF first")
139
+ if not new:
140
+ sys.exit("no newck_len_*/summary.csv -- run eval_run7c.sh first")
141
+
142
+ print(f" old matched : {sorted(old)}")
143
+ print(f" old mismatch : {sorted(mis)}")
144
+ print(f" new matched : {sorted(new)}")
145
+
146
+ import nbformat as nbf
147
+ from nbclient import NotebookClient
148
+
149
+ payload = json.dumps({"old": {str(k): v for k, v in old.items()},
150
+ "mis": {str(k): v for k, v in mis.items()},
151
+ "new": {str(k): v for k, v in new.items()},
152
+ "pair": PAIR, "tasks": TASKS,
153
+ "palette": PALETTE, "style": STYLE}, indent=1)
154
+
155
+ C = []
156
+ code = lambda s: C.append(nbf.v4.new_code_cell(_lines(s)))
157
+ md = lambda s: C.append(nbf.v4.new_markdown_cell(_lines(s)))
158
+
159
+ md("""
160
+ # Does correcting the YaRN cache raise retrieval reach?
161
+
162
+ **1.06B β€” 36 layers, d_model 1536, 12q/4kv heads, head_dim 128, SwiGLU 4096,
163
+ vocab 50304, T=16384, YaRN scale 4.**
164
+
165
+ `YARNOps.cpp:57` defaults `YARN_ORIG_MAXPOS` to **1024**, but this model's base was
166
+ trained at context length **4096**. With scale 4 the YaRN ramp therefore targeted
167
+ `4 x 1024 = 4096` positions while the cache was *sized* 16384 β€” so every position
168
+ past ~4k sat outside the range the rotation was parameterised for. Every eval run
169
+ emitted the warning `seq_len(16384) != scale(4.000)*orig_maxpos(1024.0)=4096.0`.
170
+
171
+ Correcting it to 4096 makes `scale * orig == seq_len` exactly. But swapping the
172
+ cache **at eval time is not a fix** β€” the corrected ramp (`low=14 high=32`) is a
173
+ different function from the one the weights adapted to (`low=7 high=25`). The only
174
+ real test is to **retrain** under the corrected parameterisation, which is `run7'`.
175
+
176
+ | condition | weights | `YARN_ORIG_MAXPOS` | matched? |
177
+ |---|---|---|---|
178
+ | **matched-old** | run7 (2026-09-02) | 1024 | yes β€” as trained |
179
+ | **matched-new** | run7β€² (2026-09-03) | 4096 | yes β€” as trained |
180
+ | *mismatch* | run7 | 4096 | **no** β€” read out under a cache it never saw |
181
+
182
+ Scoring is substring-match on **generated** text (not teacher-forced NLL), greedy,
183
+ bf16, `MAX_NEW=24`. `niah_single` recalls a 7-digit number given its key, so the
184
+ guess floor is ~1e-7 and any non-zero rate is real retrieval. `T` is held at 16384
185
+ at every context length so the RoPE cache is identical across the sweep.
186
+
187
+ All numbers below are read from `summary.csv` / `depth_summary.csv`. The
188
+ 2026-09-02 CSVs were cleaned off the box and recovered from the HF repo into
189
+ `old_eval_hf/`.
190
+ """)
191
+
192
+ code(f"""
193
+ import json, math
194
+ import matplotlib.pyplot as plt
195
+ from matplotlib.ticker import FuncFormatter
196
+
197
+ D = json.loads(r'''{payload}''')
198
+ P, S = D["palette"], D["style"]
199
+ PAIR, TASKS = D["pair"], D["tasks"]
200
+
201
+ def wilson(h, n):
202
+ \"\"\"95% Wilson half-width in points -- stays honest at the 0% floor.\"\"\"
203
+ if not n: return float("nan")
204
+ z, p = 1.96, h / n
205
+ return 100.0 * z * math.sqrt(p*(1-p)/n + z*z/(4*n*n)) / (1 + z*z/n)
206
+
207
+ def cell(cond, L, task, model):
208
+ \"\"\"(pct, n) for one condition/length/task/model, or (None, 0).\"\"\"
209
+ blk = D[cond].get(str(L))
210
+ if not blk: return None, 0
211
+ v = blk["summary"].get(task, {{}}).get(model)
212
+ if v is None: return None, 0
213
+ n = blk["n"].get(model) or blk["fallback_n"]
214
+ return v, n
215
+
216
+ def style(ax, xlabel, ylabel, title=None):
217
+ ax.set_facecolor(S["surface"]); ax.grid(True, color=S["grid"], lw=.8, zorder=0)
218
+ ax.set_axisbelow(True)
219
+ for k, sp in ax.spines.items():
220
+ sp.set_visible(k in ("left","bottom")); sp.set_color(S["grid"])
221
+ ax.tick_params(colors=S["ink2"], labelsize=9)
222
+ ax.set_xlabel(xlabel, color=S["ink2"], fontsize=10)
223
+ ax.set_ylabel(ylabel, color=S["ink2"], fontsize=10)
224
+ if title: ax.set_title(title, color=S["ink"], fontsize=12, loc="left", pad=12)
225
+
226
+ klabel = lambda v, _=None: (f"{{round(v/1024)}}k" if v >= 1024 else str(int(v)))
227
+
228
+ LENS = sorted(set(int(k) for k in D["old"]) | set(int(k) for k in D["new"]))
229
+ print("lengths :", LENS)
230
+ print("old models :", sorted({{m for b in D["old"].values() for t in b["summary"].values() for m in t}}))
231
+ print("new models :", sorted({{m for b in D["new"].values() for t in b["summary"].values() for m in t}}))
232
+ """)
233
+
234
+ md("""
235
+ ## 1. Headline β€” `niah_single` vs context length
236
+
237
+ Error bars are 95% Wilson half-widths. The old length sweep ran N=50 per cell and
238
+ the new one N=100, so the old bars are wider; the delta table in section 2
239
+ accounts for both.
240
+ """)
241
+
242
+ code("""
243
+ task = "niah_single"
244
+
245
+ def curve(cond, model):
246
+ xs, ys, es = [], [], []
247
+ for L in LENS:
248
+ v, n = cell(cond, L, task, model)
249
+ if v is None: continue
250
+ xs.append(L); ys.append(v); es.append(wilson(v*n/100.0, n))
251
+ return xs, ys, es
252
+
253
+ fig, ax = plt.subplots(figsize=(9.5, 5.4), facecolor=S["surface"])
254
+
255
+ ox, oy, oe = curve("old", "base")
256
+ ax.errorbar(ox, oy, yerr=oe, color=P["old"], marker="o", ms=6, lw=2.3, capsize=3,
257
+ label="matched-old: run7, cache 1024 (as trained)", zorder=4)
258
+
259
+ nx, ny, ne = curve("new", "base_c")
260
+ if nx:
261
+ ax.errorbar(nx, ny, yerr=ne, color=P["new"], marker="s", ms=6, lw=2.3, capsize=3,
262
+ label="matched-new: run7', cache 4096 (as trained)", zorder=5)
263
+
264
+ mx, my, me = curve("mis", "base")
265
+ ax.plot(mx, my, color=P["mismatch"], marker="x", ms=7, lw=1.5, ls=":",
266
+ label="mismatch: run7 weights read at cache 4096", zorder=3)
267
+
268
+ # Arms, whichever finished. A missing arm is simply absent from the legend --
269
+ # the notebook is rebuilt as each one lands, so it must not assume all three.
270
+ ARMS = [("armA_c", P["arma"], "^", "arm A' (control: plain text, no needles)"),
271
+ ("armB_c", P["armb"], "v", "arm B' (IN2, needle at distance 0)"),
272
+ ("armC_c", P["armc"], "D", "arm C' (IN2, needle far -- the actual claim)")]
273
+ for key, col, mk, lab in ARMS:
274
+ axs, ays, aes = curve("new", key)
275
+ if not axs: continue
276
+ ax.errorbar(axs, ays, yerr=aes, color=col, marker=mk, ms=5, lw=1.6, capsize=3,
277
+ ls="--", label=lab + ", cache 4096", zorder=3)
278
+
279
+ ax.set_xscale("log", base=2); ax.set_xticks(LENS)
280
+ ax.xaxis.set_major_formatter(FuncFormatter(klabel))
281
+ ax.set_ylim(-4, 100); ax.axhline(0, color=S["grid"], lw=1)
282
+ style(ax, "context length (tokens)", "niah_single accuracy (%)",
283
+ "Retrieval vs context length: did correcting the YaRN cache help?")
284
+ ax.legend(frameon=False, fontsize=9, labelcolor=S["ink2"], loc="lower left")
285
+ plt.tight_layout(); plt.show()
286
+ """)
287
+
288
+ md("""
289
+ ## 2. The delta that matters β€” matched-new minus matched-old
290
+
291
+ **resolvable** compares `|delta|` against the two cells' error bars added in
292
+ quadrature. A delta smaller than that is not distinguishable at this sample size,
293
+ and calling it an effect would be reading noise.
294
+ """)
295
+
296
+ code("""
297
+ hdr = f"{'context':>8} {'matched-old':>15} {'matched-new':>15} {'delta':>11} {'resolvable':>11} {'mismatch':>12}"
298
+ print(hdr); print("-"*len(hdr))
299
+ rows = []
300
+ for L in LENS:
301
+ o, on = cell("old", L, task, "base")
302
+ n_, nn = cell("new", L, task, "base_c")
303
+ m_, mn = cell("mis", L, task, "base")
304
+ os_ = f"{o:5.1f} +-{wilson(o*on/100.0,on):4.1f}" if o is not None else " -"
305
+ ns_ = f"{n_:5.1f} +-{wilson(n_*nn/100.0,nn):4.1f}" if n_ is not None else " -"
306
+ ms_ = f"{m_:5.1f}" if m_ is not None else " -"
307
+ if o is not None and n_ is not None:
308
+ d = n_ - o
309
+ comb = math.hypot(wilson(o*on/100.0,on), wilson(n_*nn/100.0,nn))
310
+ ds, res = f"{d:+7.1f}pp", ("yes" if abs(d) > comb else "no")
311
+ rows.append((L, o, n_, d, res))
312
+ else:
313
+ ds, res = " -", "-"
314
+ print(f"{klabel(L):>8} {os_:>15} {ns_:>15} {ds:>11} {res:>11} {ms_:>12}")
315
+
316
+ print()
317
+ res_yes = [r for r in rows if r[4] == "yes"]
318
+ if res_yes:
319
+ print("resolvable changes:")
320
+ for L, o, n_, d, _ in res_yes:
321
+ print(f" {klabel(L):>6}: {o:.1f}% -> {n_:.1f}% ({d:+.1f}pp)")
322
+ else:
323
+ print("NO length shows a resolvable change at this sample size.")
324
+ """)
325
+
326
+ md("""
327
+ ## 3. Reach
328
+
329
+ **reach** = the context length at which accuracy crosses a threshold, interpolated
330
+ between measured lengths. Two thresholds are shown because one hides how steeply
331
+ the curve falls: **50%** is "works more often than not", **10%** is "still clearly
332
+ above the ~1e-7 guess floor".
333
+
334
+ Interpolated reach inherits the error bars of the cells it sits between, so it is
335
+ bracketed by the two adjacent measured lengths β€” a reach quoted to the token would
336
+ be false precision.
337
+ """)
338
+
339
+ code("""
340
+ def reach(xs, ys, thr):
341
+ pts = sorted(zip(xs, ys))
342
+ if not pts or pts[0][1] < thr: return None
343
+ last = pts[0][0]
344
+ for i in range(1, len(pts)):
345
+ (x0,y0),(x1,y1) = pts[i-1], pts[i]
346
+ if y1 >= thr: last = x1; continue
347
+ if y0 == y1: return x0
348
+ return x0 + (x1-x0)*(y0-thr)/(y0-y1)
349
+ return last
350
+
351
+ print(f"{'thr':>5} {'matched-old':>14} {'matched-new':>14} {'change':>13} {'mismatch':>14}")
352
+ print("-"*64)
353
+ f = lambda r: (f"{r:,.0f} tok" if r else "below thr")
354
+ for thr in (50.0, 10.0):
355
+ ro = reach(ox, oy, thr)
356
+ rn = reach(nx, ny, thr) if nx else None
357
+ rm = reach(mx, my, thr)
358
+ ch = f"{rn-ro:+,.0f} tok" if (ro and rn) else "-"
359
+ print(f"{thr:>4.0f}% {f(ro):>14} {f(rn):>14} {ch:>13} {f(rm):>14}")
360
+ print()
361
+ print("full window = 16,384 tokens")
362
+ print("NOTE: the mismatch curve is non-monotone (it dips hard at 4k and recovers),")
363
+ print(" so a single crossing point describes it poorly -- read its row with")
364
+ print(" the curve in section 1, not on its own.")
365
+ """)
366
+
367
+ md("""
368
+ ## 4. Depth profile β€” U-shaped (lost-in-the-middle) or pure recency?
369
+
370
+ `depth` is the needle's **position fraction**: 0.0 = the very start of the window,
371
+ farthest from the question; 1.0 = immediately before the question. Lost-in-the-
372
+ middle would show a bump at *both* ends (recency **and** primacy). A monotone rise
373
+ means pure recency with no primacy at all.
374
+
375
+ Pooled over the lengths that have signal, and pooled identically for old and new so
376
+ the two are comparable.
377
+ """)
378
+
379
+ code("""
380
+ POOL_MAX = 4096
381
+
382
+ def depth_pool(cond, model):
383
+ agg = {}
384
+ for L, blk in D[cond].items():
385
+ if int(L) > POOL_MAX: continue
386
+ for r in blk["depth"]:
387
+ if r["model"] != model: continue
388
+ ph, pn = agg.get(r["depth"], (0,0))
389
+ agg[r["depth"]] = (ph + r["hits"], pn + r["n"])
390
+ return agg
391
+
392
+ fig, ax = plt.subplots(figsize=(9.5, 5.4), facecolor=S["surface"])
393
+ SERIES = [("old", "base", P["old"], "matched-old: run7, cache 1024", "o"),
394
+ ("new", "base_c", P["new"], "matched-new: run7', cache 4096", "s"),
395
+ ("new", "armA_c", P["arma"], "arm A' (control), cache 4096", "^"),
396
+ ("new", "armB_c", P["armb"], "arm B' (d=0), cache 4096", "v"),
397
+ ("new", "armC_c", P["armc"], "arm C' (far), cache 4096", "D")]
398
+ for cond, model, col, lab, mk in SERIES:
399
+ agg = depth_pool(cond, model)
400
+ if not agg: continue
401
+ xs = sorted(agg)
402
+ ys = [100.0*agg[d][0]/agg[d][1] for d in xs]
403
+ es = [wilson(*agg[d]) for d in xs]
404
+ ax.errorbar(xs, ys, yerr=es, color=col, marker=mk, ms=6, lw=2.2, capsize=3,
405
+ label=lab, zorder=4)
406
+ print(f"{lab}")
407
+ for d in xs:
408
+ h, n = agg[d]
409
+ print(f" depth {d:.2f} {100.0*h/n:5.1f}% +-{wilson(h,n):4.1f} ({h}/{n})")
410
+ mono = all(ys[i] <= ys[i+1] + 1e-9 for i in range(len(ys)-1))
411
+ print(f" monotone rising: {mono} (U-shape would need a bump at depth 0)\\n")
412
+
413
+ ax.set_ylim(-4, 104)
414
+ style(ax, "needle depth (0 = window start, 1 = next to the question)",
415
+ "accuracy (%)", f"Depth profile, pooled over lengths <= {POOL_MAX}")
416
+ ax.legend(frameon=False, fontsize=9, labelcolor=S["ink2"], loc="upper left")
417
+ plt.tight_layout(); plt.show()
418
+ """)
419
+
420
+ md("""
421
+ ## 5. Backward retrieval β€” `val_backward`
422
+
423
+ Given the *number*, name the *key*. The answer space is 35 words, so chance is
424
+ ~2.9% β€” a far easier task than recalling a 7-digit number, yet it failed outright
425
+ under the buggy cache. This checks whether the corrected parameterisation moved it.
426
+ """)
427
+
428
+ code("""
429
+ t2 = "val_backward"
430
+ print(f"{'context':>8} {'matched-old':>15} {'matched-new':>15} {'delta':>11}")
431
+ print("-"*54)
432
+ for L in LENS:
433
+ o, on = cell("old", L, t2, "base")
434
+ n_, nn = cell("new", L, t2, "base_c")
435
+ os_ = f"{o:5.1f} +-{wilson(o*on/100.0,on):4.1f}" if o is not None else " -"
436
+ ns_ = f"{n_:5.1f} +-{wilson(n_*nn/100.0,nn):4.1f}" if n_ is not None else " -"
437
+ ds = f"{n_-o:+7.1f}pp" if (o is not None and n_ is not None) else " -"
438
+ print(f"{klabel(L):>8} {os_:>15} {ns_:>15} {ds:>11}")
439
+ print()
440
+ print("chance with a 35-word answer space is ~2.9%")
441
+ """)
442
+
443
+ md("""
444
+ ## 5b. The IN2 claim β€” do the arms beat the base, under a correct cache?
445
+
446
+ The arms all branch independently from `run7'`, 400 steps each:
447
+
448
+ | arm | training data | role |
449
+ |---|---|---|
450
+ | **A** | plain text, no injection | **drift control** β€” isolates "400 more steps" |
451
+ | **B** | needle immediately before its probe | the objective change at distance ~0 |
452
+ | **C** | needle uniform far from its probe | **distance β€” the actual IN2 claim** |
453
+
454
+ The claim under test is **C > B** (distance matters), with **A** subtracted out as
455
+ drift. Under the buggy cache every arm came out *below* the untrained base. This
456
+ asks whether a correct rotation changes that verdict.
457
+ """)
458
+
459
+ code("""
460
+ ARMKEYS = [("base", "base_c", "base (run7 / run7')"),
461
+ ("arm_a", "armA_c", "arm A (control)"),
462
+ ("arm_b", "armB_c", "arm B (d=0)"),
463
+ ("arm_c", "armC_c", "arm C (far)")]
464
+ POOL = [L for L in LENS if L <= 4096] # where both conditions have real signal
465
+
466
+ print(f"niah_single, pooled over {POOL} (matched conditions)")
467
+ print(f"{'model':<24} {'old (cache 1024)':>18} {'new (cache 4096)':>18} {'delta':>10}")
468
+ print("-"*74)
469
+ got = {}
470
+ for ok, nk, lab in ARMKEYS:
471
+ oh = on_ = nh = nn_ = 0
472
+ for L in POOL:
473
+ v, n = cell("old", L, task, ok)
474
+ if v is not None: oh += v*n/100.0; on_ += n
475
+ v, n = cell("new", L, task, nk)
476
+ if v is not None: nh += v*n/100.0; nn_ += n
477
+ o = 100.0*oh/on_ if on_ else None
478
+ n_ = 100.0*nh/nn_ if nn_ else None
479
+ got[nk] = (n_, nn_)
480
+ os_ = f"{o:5.1f} +-{wilson(oh,on_):4.1f}" if o is not None else " -"
481
+ ns_ = f"{n_:5.1f} +-{wilson(nh,nn_):4.1f}" if n_ is not None else " -"
482
+ ds = f"{n_-o:+6.1f}pp" if (o is not None and n_ is not None) else " -"
483
+ print(f"{lab:<24} {os_:>18} {ns_:>18} {ds:>10}")
484
+
485
+ print()
486
+ b, nb_ = got.get("armB_c", (None,0))
487
+ c, nc_ = got.get("armC_c", (None,0))
488
+ if b is not None and c is not None:
489
+ d = c - b
490
+ comb = math.hypot(wilson(b*nb_/100.0, nb_), wilson(c*nc_/100.0, nc_))
491
+ print(f"IN2 claim, arm C - arm B = {d:+.1f}pp (combined 95% bar +-{comb:.1f}pp)")
492
+ print(f" -> {'RESOLVABLE' if abs(d) > comb else 'NOT resolvable at this N'}")
493
+ a, na_ = got.get("armA_c", (None,0))
494
+ if a is not None:
495
+ print(f" drift control arm A = {a:.1f}%; C - A = {c-a:+.1f}pp")
496
+ print(" (C must beat A, not just B, for distance training to have added anything)")
497
+ else:
498
+ print("arm B and/or arm C not yet evaluated -- rerun once finish_all.sh completes")
499
+ """)
500
+
501
+ md("""
502
+ ## 6. Reading this honestly
503
+
504
+ The numbers above answer the narrow question β€” *does the corrected cache raise
505
+ retrieval, measured generatively, under matched train/eval* β€” and nothing wider.
506
+ Three limits bound them:
507
+
508
+ **The headline is one cell, not the whole curve.** 1k-8k are unchanged within
509
+ error (+3.0, +0.0, -3.0, -2.0 pp, none resolvable). The only resolvable change is
510
+ at the full window: **0.0% -> 27.0%, +27.0pp**. So the corrected parameterisation
511
+ converted total failure at 16k into partial capability; it did **not** extend the
512
+ range where retrieval works most of the time (50% reach 6,987 -> 6,703 tok, i.e.
513
+ flat). Read as reach: the 10% threshold moves from ~14.3k to never crossing inside
514
+ the window, while the 50% threshold does not move.
515
+
516
+ **The loss confound is largely answered β€” in favour of the cache explanation.**
517
+ `run7'` finished at training loss **2.384** where the original run7 reached
518
+ **1.283**, and it was still descending at step 750. Before this eval, two readings
519
+ fit: the corrected geometry is genuinely harder to fit, or `run7'` is undertrained.
520
+ The data now discriminates. A model that were merely *undertrained* would be worse
521
+ or equal everywhere; `run7'` is instead **+27pp at 16k** while matching the old
522
+ model at every shorter length, and its depth-0.00 cell (the position farthest from
523
+ the question) rises 28.3% -> 37.0%. Higher LM loss together with better long-range
524
+ retrieval is what the buggy cache predicts: with `orig_maxpos=1024` the ramp
525
+ compressed positions past ~4k, which makes next-token prediction *easier* -- a 16k
526
+ window is presented as something much shorter -- while destroying the ability to
527
+ tell distant positions apart. The loss numbers are measuring different input
528
+ geometries and do not compare; the retrieval numbers do.
529
+
530
+ Residual caveat: `run7'` was still improving at step 750, so its 1k-8k cells could
531
+ be understated relative to a fully converged run. That direction of error would
532
+ only strengthen the conclusion, not reverse it.
533
+
534
+ **No U-shape, in any condition.** All three models rise monotonically from depth
535
+ 0.00 to 1.00 with no primacy bump. This is pure recency, not lost-in-the-middle,
536
+ and correcting the cache did not introduce a primacy effect -- it lifted the whole
537
+ curve (most at 16k, where the old model had nothing to lift).
538
+
539
+ **Backward retrieval is untouched.** `val_backward` sits at 0-4% everywhere in both
540
+ conditions, against ~2.9% chance for a 35-word answer space. The model resolves
541
+ key->value and not value->key, and that is independent of the RoPE fix.
542
+
543
+ **Sample size.** N=100 per cell gives a 95% Wilson half-width near +-10pp at
544
+ mid-range rates, and the old sweep cells were N=50 (+-~12pp). The `resolvable`
545
+ column in section 2 compares each delta against both cells' bars in quadrature and
546
+ is the guard against reading anything smaller as an effect.
547
+ """)
548
+
549
+ nb = nbf.v4.new_notebook()
550
+ nb["cells"] = C
551
+ nb.metadata["kernelspec"] = {"name": "python3", "display_name": "Python 3",
552
+ "language": "python"}
553
+
554
+ print(" executing...")
555
+ NotebookClient(nb, timeout=300, kernel_name="python3",
556
+ resources={"metadata": {"path": os.getcwd()}}).execute()
557
+ nbf.write(nb, args.out)
558
+ print(f" wrote {args.out} ({os.path.getsize(args.out)} B, {len(C)} cells)")
559
+
560
+
561
+ if __name__ == "__main__":
562
+ main()