item 21: GSM carry-cot 57.4% (5x prior best); control isolates whiteboard to drop-bucket; E2-N/A2 planned

This commit is contained in:
Nils
2026-07-16 09:40:23 +02:00
parent 1618c206ae
commit 051e90e805
7 changed files with 7466 additions and 2 deletions
+50
View File
@@ -51,6 +51,56 @@ protection); (c) easy-bucket damage < carry-always's (83→45 was the
unprotected number). Hard-bucket *gain* over carry-always is hoped for, unprotected number). Hard-bucket *gain* over carry-always is hoped for,
not predicted. not predicted.
### E2-L — the internalization ladder (scratchpad → pure latent loop)
Goal: a loop that computes internally during generation with NO pauses
and NO visible scratchpad — reached by curriculum, never trained cold
(cold-trained answer-only carry already failed: 9.4% overall, old carry
arm k=2,p=0 cell — a 3-token signal can't teach the whiteboard what to
write). Rungs, each warm-started from the previous:
A loop + pauses + visible terse scratchpad, dense verified-CoT CE
(item 21, running 2026-07-16; control = same supervision, no
recurrence — the A-vs-B delta is the gate for everything below)
B delete scratchpad steps one at a time, each replaced by extra
pauses; brief retrain per rung — visible computation forced onto
the pause-chain
C pauses only, answer out (latent again, curriculum-reached)
C' no pauses either: state carries across answer tokens alone — the
pure internal loop
Deliverable: the rung where accuracy breaks = measured capacity of this
recurrence budget to absorb computation (the paper's number). Proceed
past A only if arm A beats its control by >= 3 points overall
(pre-registered, item 21b); A ~= B means the scratchpad text carries
everything and internalization would only rediscover the C' failure.
### E2-A2 — on-policy refresh (iterated self-distillation)
The exposure gap = training prefixes vs deployment prefixes. Cheapest
approximation ladder: (1) self-distilled scratchpads (stage A, done);
(2) THIS: re-harvest scratchpads with the CURRENT adapter active each
round, verify, retrain (STaR/ReST; DAgger at solution granularity;
~3 min/harvest). Signature of working: verified-yield and eval accuracy
co-improve across rounds. Gated on the A-vs-B verdict.
### E2-N — lens-shaped state noise (Nils's idea, 2026-07-16 ~04:15)
Harden the whiteboard against its own drift by injecting noise into the
carried state during teacher-forced training — SHAPED by the J-lens
instead of isotropic:
N1 sensitivity-weighted: sample noise in the span of J̄'s top-r right-
singular directions at the band entrance (the directions the final
readout depends on; isotropic noise wastes signal on the null
space). Cheap: jbar.pt exists; --lensnoise rank,scale flag.
N2 empirical-drift-matched: measure REAL exposure drift (free-run
state minus teacher-forced state at matched positions, few
rollouts), fit low-rank covariance, train under samples from it.
The lens diagnoses what the drift directions encode — worth running
as pure diagnosis regardless of verdicts (paper figure).
N3 concept-jitter: lens-read the carried concept (e.g. the
intermediate "24"), perturb toward a confusable concept in
embedding basis (swap machinery exists from the reproduction);
trains re-derivation over blind trust. Most ambitious.
Caveats, stated in advance: J̄ is prompt-averaged (N1 directions are
global, not per-position); noise norm-matched and magnitude-swept;
whole line gated on arm A beating its control.
### E3 — power knob (only if E1 or E2 shows clean gating) ### E3 — power knob (only if E1 or E2 shows clean gating)
Warm-start rung-2 band-LoRA under the gate; joint fine-tune. Question: do Warm-start rung-2 band-LoRA under the gate; joint fine-tune. Question: do
gate-bought iterations do MORE per iteration with a trainable band? gate-bought iterations do MORE per iteration with a trainable band?
+25
View File
@@ -494,3 +494,28 @@ no fixed k solves but some k does.
Honest note: arm outputs are VISIBLE tokens (~40) — this is the Honest note: arm outputs are VISIBLE tokens (~40) — this is the
budget-CoT-with-loop hybrid, a scope change from latent planning, budget-CoT-with-loop hybrid, a scope change from latent planning,
run at Nils's explicit direction ("do gsm8k and such"). run at Nils's explicit direction ("do gsm8k and such").
--- Outcome, item 21 (scored 2026-07-16 ~06:30). Harvest: 427 verified
terse CoTs (292 hard, 135 easy; 73.5% yield, ~3 min). Grid (n=256,
base 10.9/93.1/0.8/0.0 overall/easy/hard/drop):
arm A carry 2:2 53.9 (82.8 / 60.6 / 37.0)
arm A carry 2:6 57.4 (72.4 / 63.8 / 45.0)
control FF 2:2 49.6 (79.3 / 59.8 / 28.0)
control FF 2:6 54.7 (82.8 / 66.9 / 31.0)
(a) CONFIRMED, dramatically: best cell 57.4% vs the previous best GSM
arm's 12.1% — a 5x jump to full-CoT territory (~53%) at ~1/4 the
visible tokens. Dense verified supervision was indeed the binding
constraint; the supervision-density theory of the GSM failure is now
POSITIVELY confirmed, not just by absence. (b) MIXED: A-vs-B delta
+4.3 at p=2 (clears the >=3 bar), +2.7 at p=6 (misses); hard/easy
shuffle within noise between arms, BUT the whiteboard shows a
consistent, specific signature: DROP items (unreachable by the base
model even with full CoT at labeling) — A beats B by +9 and +14 points
there in the two cells. Interpretation: scratchpad supervision carries
the bulk; the carry chain specifically extends reach into previously
unreachable problems. McNemar p-values pending (mcnemar_carrycot.py
ready; classifier-blocked at scoring time). (c) CONFIRMED: easy damage
much reduced vs answer-only carry (83->72-83 vs 83->45). Ladder gate:
technically met at p=2; decision on stage B/A2/E2-N deferred to the
morning review with the p-values in hand — the drop-bucket signature,
if it survives pairing, is the strongest argument for continuing.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
"""Paired McNemar: carry-cot arm vs feedforward control, both grid cells."""
import json
from math import comb
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "results-loop"
A = json.load(open(OUT / "eval_gsm_carrycot_e400.json"))
B = json.load(open(OUT / "eval_gsm_carrycot_ff_e400.json"))
star = {it["idx"]: it["label"]
for it in json.load(open(OUT / "star_data.json"))
if it["split"] == "test"}
for cell in ("2:2", "2:6"):
a = {r["idx"]: r["ok"] for r in A["grid"][cell]["per_item"]}
b = {r["idx"]: r["ok"] for r in B["grid"][cell]["per_item"]}
ids = list(a)
x = sum(1 for i in ids if a[i] and not b[i])
y = sum(1 for i in ids if b[i] and not a[i])
n = x + y
p = (min(1, sum(comb(n, k) for k in range(max(x, y), n + 1))
/ 2 ** n * 2) if n else 1)
dx = sum(1 for i in ids
if star.get(i) == "drop" and a[i] and not b[i])
dy = sum(1 for i in ids
if star.get(i) == "drop" and b[i] and not a[i])
print(f"{cell}: carry-only={x} ff-only={y} McNemar p={p:.4f} "
f"drop-bucket discordants {dx}-{dy}")
+46 -2
View File
@@ -38,11 +38,42 @@ P_BY_LABEL = {"easy": 2, "hard": 6}
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--feedforward", action="store_true") ap.add_argument("--feedforward", action="store_true")
ap.add_argument("--seed", type=int, default=0) ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--lensnoise", default=None, metavar="RANK,SCALE",
help="E2-N1: inject noise into the carried state during "
"training, shaped by the top-RANK sensitivity "
"directions of jbar at the band entrance; noise norm "
"= SCALE * per-position state norm (e.g. 32,0.05)")
ARGS = ap.parse_args() ARGS = ap.parse_args()
TAG = ("carrycot_ff" if ARGS.feedforward else "carrycot") + ( TAG = ("carrycot_ff" if ARGS.feedforward else "carrycot") + (
f"_ln{ARGS.lensnoise.replace(',', '_')}" if ARGS.lensnoise else "") + (
f"_s{ARGS.seed}" if ARGS.seed else "") f"_s{ARGS.seed}" if ARGS.seed else "")
class LensNoiseWrapper(torch.nn.Module):
"""Perturb the carried state s (not the anchor e) before the merge,
within the span of the lens's top-r readout-sensitive directions.
Train-time only (noise_on flag); eval and checkpoints use .base."""
def __init__(self, base, jbar_layer, rank, scale):
super().__init__()
self.base = base
self.scale = scale
self.noise_on = True
J = jbar_layer.float()
_, _, Vt = torch.linalg.svd(J, full_matrices=False)
self.register_buffer("V", Vt[:rank].T.contiguous()) # (d, r)
def forward(self, e, s):
if self.noise_on and self.scale > 0:
z = torch.randn(*s.shape[:-1], self.V.shape[1],
device=s.device, dtype=torch.float32)
n = z @ self.V.T
n = n * (s.float().norm(dim=-1, keepdim=True) * self.scale
/ (n.norm(dim=-1, keepdim=True) + 1e-6))
s = (s.float() + n).to(s.dtype)
return self.base(e, s)
def lr_at(step): def lr_at(step):
if step < WARMUP: if step < WARMUP:
return LR * (step + 1) / WARMUP return LR * (step + 1) / WARMUP
@@ -107,6 +138,15 @@ def main():
looper = BandLooper(model) looper = BandLooper(model)
adapter = MergeAdapter( adapter = MergeAdapter(
d=model.config.get_text_config().hidden_size).cuda() d=model.config.get_text_config().hidden_size).cuda()
if ARGS.lensnoise:
r, sc = ARGS.lensnoise.split(",")
jbar = torch.load(Path(__file__).resolve().parent.parent
/ "results/jbar.pt", map_location="cpu")["Jbar"]
from loop_common import BAND
adapter = LensNoiseWrapper(adapter, jbar[BAND[0]], int(r),
float(sc)).cuda()
print(f"lens-noise: rank={r} scale={sc} on jbar L{BAND[0]}",
flush=True)
opt = torch.optim.AdamW(adapter.parameters(), lr=LR, weight_decay=0.01) opt = torch.optim.AdamW(adapter.parameters(), lr=LR, weight_decay=0.01)
keep = [it for it in data keep = [it for it in data
@@ -146,11 +186,15 @@ def main():
print(f"step {step:4d} {lbl:4s} loss={loss.item():.4f} " print(f"step {step:4d} {lbl:4s} loss={loss.item():.4f} "
f"({(time.time()-t0)/(step+1):.1f}s/step)", flush=True) f"({(time.time()-t0)/(step+1):.1f}s/step)", flush=True)
if step % 200 == 199 or step == STEPS - 1: if step % 200 == 199 or step == STEPS - 1:
if ARGS.lensnoise:
adapter.noise_on = False
for l in ("easy", "hard"): for l in ("easy", "hard"):
v = val_loss(looper, adapter, tok, val[l], P_BY_LABEL[l]) v = val_loss(looper, adapter, tok, val[l], P_BY_LABEL[l])
print(f" val@{step}: {l}={v:.3f}", flush=True) print(f" val@{step}: {l}={v:.3f}", flush=True)
torch.save(adapter.state_dict(), if ARGS.lensnoise:
OUT / f"adapter_{TAG}_e{step+1}.pt") adapter.noise_on = True
sd = (adapter.base if ARGS.lensnoise else adapter).state_dict()
torch.save(sd, OUT / f"adapter_{TAG}_e{step+1}.pt")
json.dump(log, open(OUT / f"train_{TAG}_log.json", "w")) json.dump(log, open(OUT / f"train_{TAG}_log.json", "w"))
print("done", flush=True) print("done", flush=True)