diff --git a/results-loop/PROTOCOL_UNIFIED.md b/results-loop/PROTOCOL_UNIFIED.md index 2ffd1dd..17586ed 100644 --- a/results-loop/PROTOCOL_UNIFIED.md +++ b/results-loop/PROTOCOL_UNIFIED.md @@ -918,3 +918,35 @@ Nils's morning decision: clamp test vs pivot to the hybrid/A2 line Next-experiment implication (Nils to confirm): apply trajectory TF to rung A itself (nothing deleted) — if the signal generalizes, the 57.4 headline moves. + +30. **The carried state as a metacognitive signal (pre-registered + 2026-07-17 ~13:20, before running; Nils's design: "treat the carry + state as a metacognitive signal and build on it explicitly... + exploit what the loop provably does instead of fighting the + consumption wall. This never requires the frozen band to read + anything new").** Synthesis of three established results: the + carry's inference-time value is deferral/completion-state (430 + divergence; drop McNemar p=0.0094); gate quality is worth ~9.6 + points with CLASSIFIER quality binding on the pre-loop state + (item 20); the consumption wall blocks residual-injection reads + (items 25-28) but a NEW head can read anything, and its decisions + reach the frozen model as tokens. Design: answer-readiness head + g(s) — LayerNorm+MLP(64), class-balanced — on the carried L30 + state at scratchpad line boundaries of rung-A rollouts. Labels + mechanical: fork a forced "Answer: " completion at each boundary, + check against gold. Harvests: train (427) and test (256) x + {carry, feedforward-control} — the FF arm trains the identical + head on the FF states. Readouts, all offline from one instrumented + pass (item-20 LUT methodology): (a) PRIMARY: carry-head test AUC + vs FF-head test AUC — if carry > FF, the recurrence carries + metacognitive signal the feedforward path lacks: the first + measured POSITIVE FUNCTIONAL ROLE for the carried state; (b) + theta sweep of "answer at first boundary with g>=theta else + natural end": accuracy vs mean scratchpad length vs the 57.4 + fixed-format baseline; (c) ORACLE stop bound (best boundary per + item) = the ceiling adaptive stopping can reach. Predictions: + carry AUC > 0.65 and > FF AUC (the deferral evidence says the + signal exists); theta curve dominates fixed-format on tokens at + matched accuracy; oracle meaningfully above 57.4 (early-stop + rescues drift cases). Arm 2 (later): line-correctness / + deferral head gating re-expansion. Job: scripts/jobs/zzz_w_metacog.sh. diff --git a/scripts/jobs/zzz_w_metacog.sh b/scripts/jobs/zzz_w_metacog.sh new file mode 100644 index 0000000..d891e4d --- /dev/null +++ b/scripts/jobs/zzz_w_metacog.sh @@ -0,0 +1,12 @@ +# gpuq-in: results-loop/star_data.json results-loop/adapter_carrycot_e400.pt results-loop/adapter_carrycot_ff_e400.pt +# gpuq-out: results-loop/metacog_*.pt results-loop/metacog_*.json +git pull origin main -q 2>/dev/null +P=/home/nils/jspace/.venv/bin/python +export JLENS_MODEL=google/gemma-4-E2B-it LOOP_OUT=/home/nils/jspace/results-loop +cd /home/nils/jspace/scripts +$P metacog.py harvest --split train +$P metacog.py harvest --split test +$P metacog.py harvest --split train --feedforward +$P metacog.py harvest --split test --feedforward +$P metacog.py head +$P metacog.py sweep diff --git a/scripts/metacog.py b/scripts/metacog.py new file mode 100644 index 0000000..d211448 --- /dev/null +++ b/scripts/metacog.py @@ -0,0 +1,245 @@ +"""Item 30: the carried state as a metacognitive signal (Nils's design). + +The program's evidence: the carry's inference-time value is deferral / +completion-state (430 divergence; drop-bucket p=0.0094), and gate quality +is worth ~9.6 points with classifier quality binding (item 20). This +exploits both: a small answer-readiness head g(s) on the carried L30 +state at scratchpad line boundaries. The frozen model reads nothing new +— g's decisions reach it as tokens. + +Stages: + harvest --split train|test [--feedforward] + Roll out rung-A generation (k=2, p by label), teacher-forced replay + to capture carried states at line boundaries, then fork a forced + "Answer: " completion per boundary and label it by correctness. + Saves metacog_[_ff].pt: states (N,1536), labels, item idx, + boundary index, natural-end correctness. + head + Train class-balanced logistic + 2-layer MLP on train states; report + test AUC for carry and (if present) FF states. + sweep + Offline theta sweep on the test LUT: answer at first boundary with + g >= theta, else natural end. Accuracy / mean boundary / oracle. +""" +import argparse +import json +import os +import sys +import time +from pathlib import Path + +import torch +import torch.nn.functional as F + +from carry_common import PAUSE_ID, _pos_ids, carry_logits, generate_carry_c +from loop_common import (BandLooper, MergeAdapter, chat_prompt, + DIRECT_SUFFIX, last_number, num_eq) + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from jlens.core import load_model # noqa: E402 + +OUT = Path(os.environ.get("LOOP_OUT", + Path(__file__).resolve().parent.parent / "results-loop")) +K = 2 +P_BY_LABEL = {"easy": 2, "hard": 6, "drop": 6} +NL_MAX_BOUND = 6 + + +def newline_positions(tok, gen_ids, start): + """Positions (absolute) of '\n' tokens within the generated span.""" + out = [] + for j, t in enumerate(gen_ids): + if "\n" in tok.decode([t]): + out.append(start + j) + return out + + +@torch.no_grad() +def harvest(args, model, tok, looper, adapter): + items = [it for it in json.load(open(OUT / "star_data.json")) + if it["split"] == args.split] + if args.split == "test": + items = items[:256] + if args.limit: + items = items[:args.limit] + rows = [] + t0 = time.time() + for bi in range(0, len(items), args.batch): + chunk = items[bi:bi + args.batch] + p = max(P_BY_LABEL[it["label"]] for it in chunk) + enc = tok([chat_prompt(tok, it["question"], DIRECT_SUFFIX) + for it in chunk], return_tensors="pt", padding=True, + add_special_tokens=False).to("cuda") + gen = generate_carry_c(looper, adapter, tok, enc["input_ids"], + enc["attention_mask"], K, p, + max_new_tokens=96, + feedforward=args.feedforward) + n_pre = enc["input_ids"].shape[1] + p + # teacher-forced replay of each rollout to capture carried states + for b, it in enumerate(chunk): + seq = gen[b] + # strip left padding + keep = seq != (tok.pad_token_id or 0) + keep[enc["attention_mask"].shape[1]:] = True + seq = seq[keep] + n_prompt = int(enc["attention_mask"][b].sum()) + # trim at end-of-turn + gen_span = seq[n_prompt + p:] + eot = tok.convert_tokens_to_ids("") + ends = (gen_span == eot).nonzero() + g_end = int(ends[0, 0]) if ends.numel() else len(gen_span) + gen_span = gen_span[:g_end] + full = seq[: n_prompt + p + g_end][None].cuda() + msk = torch.ones_like(full) + _, S = carry_logits(looper, adapter, full, msk, + torch.tensor([n_prompt], device="cuda"), K, + feedforward=args.feedforward, + return_states=True) + text = tok.decode(gen_span, skip_special_tokens=True) + nat_ok = bool(num_eq(last_number(text), it["gold"])) + nls = newline_positions(tok, gen_span.tolist(), n_prompt + p) + nls = [x for x in nls if x < full.shape[1] - 2][:NL_MAX_BOUND] + for k_b, pos in enumerate(nls): + # forced answer from this prefix + pre = seq[: pos + 1].cuda() + fa = tok("Answer: ", add_special_tokens=False, + return_tensors="pt")["input_ids"][0].cuda() + inp = torch.cat([pre, fa])[None].cuda() + am = torch.ones_like(inp) + g2 = generate_carry_c(looper, adapter, tok, inp, am, 0, 0, + max_new_tokens=8, + feedforward=args.feedforward) + ans = tok.decode(g2[0, inp.shape[1]:], + skip_special_tokens=True) + ok = bool(num_eq(last_number("Answer: " + ans), it["gold"])) + rows.append({"idx": it["idx"], "label": it["label"], + "bound": k_b, "ok": ok, "nat_ok": nat_ok, + "state": S[0, pos].float().cpu()}) + if bi % (args.batch * 8) == 0: + print(f"[{bi+len(chunk)}/{len(items)}] rows={len(rows)} " + f"({time.time()-t0:.0f}s)", flush=True) + states = torch.stack([r.pop("state") for r in rows]) + tag = f"metacog_{args.split}" + ("_ff" if args.feedforward else "") + torch.save({"states": states, "rows": rows}, OUT / f"{tag}.pt") + pos_rate = sum(r["ok"] for r in rows) / max(1, len(rows)) + print(f"saved {len(rows)} boundary rows -> {tag}.pt " + f"(answer-ready rate {pos_rate:.3f})", flush=True) + + +def auc(scores, labels): + order = sorted(range(len(scores)), key=lambda i: scores[i]) + r = {} + for rank, i in enumerate(order): + r[i] = rank + 1 + pos = [i for i, l in enumerate(labels) if l] + neg = [i for i, l in enumerate(labels) if not l] + if not pos or not neg: + return float("nan") + s = sum(r[i] for i in pos) + return (s - len(pos) * (len(pos) + 1) / 2) / (len(pos) * len(neg)) + + +def train_head(tr_s, tr_y, dev="cuda", hidden=64, steps=400, seed=0): + torch.manual_seed(seed) + d = tr_s.shape[1] + net = torch.nn.Sequential( + torch.nn.LayerNorm(d), torch.nn.Linear(d, hidden), + torch.nn.GELU(), torch.nn.Linear(hidden, 1)).to(dev) + opt = torch.optim.AdamW(net.parameters(), lr=1e-3, weight_decay=0.01) + y = tr_y.float().to(dev) + w = torch.where(y > 0, (1 - y.mean()) / y.mean().clamp(min=1e-3), + torch.ones_like(y)) + x = tr_s.to(dev) + for i in range(steps): + logit = net(x).squeeze(-1) + loss = F.binary_cross_entropy_with_logits(logit, y, weight=w) + opt.zero_grad(); loss.backward(); opt.step() + return net + + +def head(args): + def load(tag): + d = torch.load(OUT / f"{tag}.pt", map_location="cpu") + y = torch.tensor([r["ok"] for r in d["rows"]]) + return d["states"], y, d["rows"] + results = {} + for name, tr_tag, te_tag in [ + ("carry", "metacog_train", "metacog_test"), + ("ff", "metacog_train_ff", "metacog_test_ff")]: + if not (OUT / f"{tr_tag}.pt").exists(): + continue + tr_s, tr_y, _ = load(tr_tag) + te_s, te_y, te_rows = load(te_tag) + net = train_head(tr_s, tr_y) + with torch.no_grad(): + sc = torch.sigmoid(net(te_s.cuda()).squeeze(-1)).cpu() + a = auc(sc.tolist(), te_y.tolist()) + results[name] = a + print(f"{name}: test AUC={a:.3f} (n={len(te_y)}, " + f"pos-rate={te_y.float().mean():.3f})", flush=True) + if name == "carry": + torch.save({"net": net.state_dict(), + "scores": sc, }, OUT / "metacog_head.pt") + for r, s_ in zip(te_rows, sc.tolist()): + r["g"] = s_ + json.dump(te_rows, open(OUT / "metacog_lut.json", "w")) + json.dump(results, open(OUT / "metacog_auc.json", "w")) + + +def sweep(args): + rows = json.load(open(OUT / "metacog_lut.json")) + by_item = {} + for r in rows: + by_item.setdefault(r["idx"], []).append(r) + n = len(by_item) + nat = sum(1 for rs in by_item.values() if rs[0]["nat_ok"]) / n + print(f"items={n} natural-end accuracy={nat:.3f}") + print("theta acc mean-bound answered-early") + for th in (0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99): + hits, bsum, early = 0, 0, 0 + for rs in by_item.values(): + rs = sorted(rs, key=lambda r: r["bound"]) + fired = next((r for r in rs if r["g"] >= th), None) + if fired is not None: + hits += fired["ok"]; bsum += fired["bound"] + 1; early += 1 + else: + hits += rs[0]["nat_ok"]; bsum += len(rs) + 1 + print(f"{th:.2f} {hits/n:.3f} {bsum/n:.2f} {early}/{n}") + oracle, obsum = 0, 0 + for rs in by_item.values(): + best = max([r["ok"] for r in rs] + [rs[0]["nat_ok"]]) + oracle += best + obsum += min([r["bound"] + 1 for r in rs if r["ok"]], + default=len(rs) + 1) + print(f"ORACLE stop: acc={oracle/n:.3f} mean-bound={obsum/n:.2f}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("stage", choices=("harvest", "head", "sweep")) + ap.add_argument("--split", default="train") + ap.add_argument("--feedforward", action="store_true") + ap.add_argument("--adapter", default=None) + ap.add_argument("--batch", type=int, default=8) + ap.add_argument("--limit", type=int, default=0) + args = ap.parse_args() + if args.stage == "harvest": + model, tok = load_model(dtype=torch.bfloat16) + tok.padding_side = "left" + looper = BandLooper(model) + adapter = MergeAdapter( + d=model.config.get_text_config().hidden_size).cuda() + default = OUT / ("adapter_carrycot_ff_e400.pt" if args.feedforward + else "adapter_carrycot_e400.pt") + adapter.load_state_dict(torch.load(args.adapter or default, + map_location="cuda")) + adapter.eval() + harvest(args, model, tok, looper, adapter) + elif args.stage == "head": + head(args) + else: + sweep(args) + + +if __name__ == "__main__": + main()