"""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()