"""Causal load-bearing + generalization report for the SINGLE-token-per-step diffuse organism. One soft token z_t per CA step carries the WHOLE K-cell row c_t (contrast the K-position markov organism). Free-running, then intervene on the T-token chain and re-read the boxed digit: A. baseline + wrong-class answer decodability (10-way softmax + confusion). B. per-step necessity: ablate z_1->prompt; corrupt step t's feedback with noise. C. shuffle the step order of the feedback chain (z_2..z_T permuted). D. donor patch (replace the chain with a different problem's) -> follows-donor vs follows-prompt. E. subspace patch: the soft token lives in U = span(codebook reshaped [K*10, d]); patch U vs U^perp. F. probe: per-step WHOLE-ROW decodability (one token holds K digits) across layers + selectivity. G. GENERALIZATION: free-running accuracy vs chain length m (trained on train_lengths; test longer). python -m latent_threads.eval_single_report --ckpt .../sg1/diffuse/best --n 400 """ from __future__ import annotations import argparse import json import os import random import dotenv dotenv.load_dotenv() _USER = os.environ.get("USER", "jbauer") os.environ.setdefault("HF_HOME", f"/workspace-vast/{_USER}/hf") os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1") import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np import torch import torch.nn.functional as F from peft import PeftModel from tqdm.auto import tqdm from transformers import AutoModelForCausalLM, AutoTokenizer from latent_threads import tasks as LT from latent_threads.single import build_single_batch, build_single_mask from latent_threads.probe_markov import probe_all RES = f"/workspace-vast/{_USER}/exp/latent_threads/results" def load(ckpt, model_name, device): cfg = json.load(open(os.path.join(ckpt, "lt_cfg.json"))) K = cfg["task_kwargs"]["k"] tok = AutoTokenizer.from_pretrained(model_name); tok.pad_token_id = tok.pad_token_id or tok.eos_token_id model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.bfloat16, attn_implementation="eager", device_map="cuda") model = PeftModel.from_pretrained(model, ckpt).eval() d = model.config.hidden_size extra = torch.load(os.path.join(ckpt, "single_extra.pt"), map_location=device) head = torch.nn.Sequential(torch.nn.LayerNorm(d), torch.nn.Linear(d, d // 4), torch.nn.GELU(), torch.nn.Linear(d // 4, K * 10)).to(device, torch.bfloat16) head.load_state_dict(extra["head"]) codebook = extra["codebook"].to(device, torch.bfloat16) # [K,10,d] q_emb = extra["q_emb"].to(device, torch.bfloat16) did = torch.tensor(LT.digit_ids(tok), device=device) tag = f"{cfg['batch_id']}_{cfg['task']}_{cfg['tag']}" return model, head, codebook, q_emb, tok, did, K, cfg, tag @torch.no_grad() def generate(model, head, codebook, q_emb, tok, task, batch, im, device, want_hidden=False, ablate_first=False, corrupt_step=None, noise_rng=None): """Free-running; returns (E, attn, pos, z_starts, lengths, out). Optionally ablate/corrupt.""" K, m = task.K, task.m input_ids, roles, zs_, ls_, lens_ = build_single_batch(tok, task, batch, im, device, with_answer=False) B, Lmax = input_ids.shape attn = build_single_mask(roles, zs_, m, ablate_first=ablate_first) pos = torch.arange(Lmax, device=device)[None].expand(B, Lmax) E = model.get_input_embeddings()(input_ids) bidx = torch.arange(B, device=device); zs = torch.tensor(zs_, device=device) E = E.clone(); E[bidx, zs] = q_emb out = None for t in range(m): want = want_hidden or t < m - 1 out = model(inputs_embeds=E, attention_mask=attn, position_ids=pos, output_hidden_states=want) if t == m - 1: break h = out.hidden_states[-1][bidx, zs + t] z = torch.softmax(head(h).view(B, K, 10).float(), -1) fed = torch.einsum("bkv,kvd->bd", z, codebook.float()) if corrupt_step is not None and t == corrupt_step: fed = torch.randn(fed.shape, generator=noise_rng, dtype=torch.float32).to(device) * float(codebook.float().std()) E = E.clone(); E[bidx, zs + t + 1] = fed.to(E.dtype) return E, attn, pos, zs_, lens_, out @torch.no_grad() def read_answer(model, head, E, attn, pos, zs_, batch, m, K, device): """Queried-cell answer logits, read from the per-cell HEAD at the final token z_T (the organism's single readout path). Returns [B, 10] for each example's queried cell.""" H = model(inputs_embeds=E, attention_mask=attn, position_ids=pos, output_hidden_states=True).hidden_states[-1] out = [head(H[j, zs_[j] + m - 1]).view(K, 10)[batch[j].q] for j in range(len(batch))] return torch.stack(out).float() def get_slots(E, zs_, m): return torch.stack([E[j, zs_[j]:zs_[j] + m].clone() for j in range(E.shape[0])]) # [B, m, d] def put_slots(E, slots, zs_): for j in range(E.shape[0]): E[j, zs_[j]:zs_[j] + slots.shape[1]] = slots[j] return E def project(x, Q): return (x @ Q) @ Q.T @torch.no_grad() def free_acc(model, head, codebook, q_emb, tok, task, probs, im, did, device): correct, bs = 0, 16 for s in range(0, len(probs), bs): b = probs[s:s + bs] E, attn, pos, zs_, lens_, _ = generate(model, head, codebook, q_emb, tok, task, b, im, device) pred = read_answer(model, head, E, attn, pos, zs_, b, task.m, task.K, device).argmax(-1).tolist() correct += sum(int(pred[j] == task.answer(b[j])) for j in range(len(b))) return correct / len(probs) def run(ckpt, model_name, n, device): model, head, codebook, q_emb, tok, did, K, cfg, tag = load(ckpt, model_name, device) im = tok.convert_tokens_to_ids("<|im_end|>") base_m = cfg["task_kwargs"]["m"]; chance = 1.0 / 10 task = LT.make_task(cfg["task"], k=K, m=base_m) print("=" * 78 + f"\nSINGLE-TOKEN REPORT {tag} K={K} m={base_m} chance={chance:.2f}\n" + "=" * 78, flush=True) # U = span of the codebook (the soft-token vocabulary), reshaped [K*10, d] C30 = codebook.reshape(K * 10, -1).float() dc = C30 - C30.mean(0, keepdim=True) Uq, Us, _ = torch.linalg.svd(dc.T, full_matrices=False) rank = int((Us > 1e-4 * Us[0]).sum()) Q = Uq[:, :rank].contiguous() print(f" codebook subspace rank={rank} (of {K*10} symbols, D={C30.shape[1]})", flush=True) rng = random.Random(2025) probs = [task.sample(rng) for _ in range(n)] rows = np.array([np.array(task.step_states(p)) for p in probs]) # [N, m, K] MODES = ["intact", "shuffle_steps", "donor_full", "donor_subspace", "donor_complement"] preds = {mo: [] for mo in MODES}; pmass = {mo: [] for mo in MODES} probe_layers = sorted(set(list(range(6, model.config.num_hidden_layers + 1, 6)) + [model.config.num_hidden_layers])) feats = {L: [] for L in probe_layers} rec_q, don_q = [], [] gen = torch.Generator().manual_seed(20260617); bs = 16 for s in tqdm(range(0, n, bs), desc=f"{tag} interventions"): b = probs[s:s + bs]; B = len(b) E, attn, pos, zs_, lens_, out = generate(model, head, codebook, q_emb, tok, task, b, im, device, want_hidden=True) for L in probe_layers: hl = out.hidden_states[L] feats[L].append(torch.stack([hl[j, zs_[j]:zs_[j] + base_m] for j in range(B)]).to(torch.float16).cpu()) slots = get_slots(E, zs_, base_m); donor = slots.roll(1, dims=0) for j in range(B): qj = b[j].q; rec_q.append(b[j].rows[-1][qj]); don_q.append(b[(j - 1) % B].rows[-1][qj]) fb = slice(1, base_m) # feedback steps z_2..z_m (z_1=query) for mo in MODES: sl = slots.clone() if mo == "shuffle_steps": perm = torch.randperm(base_m - 1, generator=gen) + 1 sl[:, fb] = slots[:, perm] elif mo == "donor_full": sl[:, fb] = donor[:, fb] elif mo == "donor_subspace": sl[:, fb] = slots[:, fb] + project((donor[:, fb] - slots[:, fb]).float(), Q).to(slots.dtype) elif mo == "donor_complement": delta = (donor[:, fb] - slots[:, fb]).float() sl[:, fb] = slots[:, fb] + (delta - project(delta, Q)).to(slots.dtype) lg = read_answer(model, head, put_slots(E.clone(), sl, zs_), attn, pos, zs_, b, base_m, K, device) p = torch.softmax(lg, -1); preds[mo] += p.argmax(-1).tolist(); pmass[mo].append(p.cpu().numpy()) preds = {mo: np.array(v) for mo, v in preds.items()}; pmass = {mo: np.concatenate(v) for mo, v in pmass.items()} rec_q, don_q = np.array(rec_q), np.array(don_q) acc = {mo: float((preds[mo] == rec_q).mean()) for mo in MODES} pm = pmass["intact"]; p_correct = float(pm[np.arange(n), rec_q].mean()) wrong = pm.copy(); wrong[np.arange(n), rec_q] = -1 margin = float((np.log(pm[np.arange(n), rec_q] + 1e-9) - np.log(wrong.max(1) + 1e-9)).mean()) conf = np.zeros((10, 10)) for t, pr in zip(rec_q, preds["intact"]): conf[t, pr] += 1 print(f" [A] intact acc={acc['intact']:.3f} P(correct)={p_correct:.3f} margin={margin:.2f}nats", flush=True) print(f" [C] shuffle_steps acc={acc['shuffle_steps']:.3f}", flush=True) # per-step necessity (ablate + corrupt) eprobs = [task.sample(random.Random(13371337)) for _ in range(200)] abl = free_acc_intervene(model, head, codebook, q_emb, tok, task, eprobs, im, did, device, ablate_first=True) org = free_acc(model, head, codebook, q_emb, tok, task, eprobs, im, did, device) nrng = torch.Generator().manual_seed(7) corrupt = [free_acc_intervene(model, head, codebook, q_emb, tok, task, eprobs, im, did, device, corrupt_step=t, noise_rng=nrng) for t in range(base_m - 1)] print(f" [B] organism={org:.3f} ablate={abl:.3f} corrupt(worst)={min(corrupt):.3f}", flush=True) dmask = don_q != rec_q def follows(mo): pr = preds[mo] return dict(follow_donor=float((pr[dmask] == don_q[dmask]).mean()), follow_prompt=float((pr[dmask] == rec_q[dmask]).mean()), n=int(dmask.sum())) donor_stats = {mo: follows(mo) for mo in ["donor_full", "donor_subspace", "donor_complement"]} print(f" [D/E] donor_full {donor_stats['donor_full']}", flush=True) print(f" donor_subspace {donor_stats['donor_subspace']} donor_complement {donor_stats['donor_complement']}", flush=True) # probe: per-step WHOLE-ROW decodability (decode each of K cells from one token), across layers feats = {L: torch.cat(v).numpy().astype(np.float32) for L, v in feats.items()} cellprobe = np.zeros((len(probe_layers), base_m, K)) for li, L in enumerate(probe_layers): for t in range(base_m): cellprobe[li, t] = probe_all(feats[L][:, t, :], rows[:, t, :]) best_li = int(np.nanmean(cellprobe, (1, 2)).argmax()) print(f" [F] probe best layer L{probe_layers[best_li]} whole-row decodability={np.nanmean(cellprobe[best_li]):.3f}", flush=True) # generalization: free-running accuracy vs chain length gprobs_seed = random.Random(99) gen_curve = {} for gm in cfg.get("gen_eval_lengths", [2, 3, 4, 5, 6, 7, 8, 10, 12, 14]): gtask = LT.make_task(cfg["task"], k=K, m=gm) gen_curve[gm] = free_acc(model, head, codebook, q_emb, tok, gtask, [gtask.sample(gprobs_seed) for _ in range(200)], im, did, device) print(f" [G] generalization acc vs m: { {k: round(v,3) for k,v in gen_curve.items()} }", flush=True) train_lengths = (list(range(2, base_m + 1)) if cfg.get("curriculum") else cfg.get("train_lengths")) result = dict(tag=tag, K=K, base_m=base_m, chance=chance, n=n, subspace_rank=rank, train_lengths=train_lengths, acc=acc, p_correct=p_correct, margin_nats=margin, confusion=conf.tolist(), organism=org, ablate=abl, corrupt_step_acc=corrupt, donor=donor_stats, donor_disagree_n=int(dmask.sum()), probe_layers=probe_layers, cellprobe=cellprobe.tolist(), best_layer=probe_layers[best_li], gen_curve={str(k): v for k, v in gen_curve.items()}) os.makedirs(RES, exist_ok=True) json.dump(result, open(f"{RES}/single_report_{tag}.json", "w"), indent=2) make_figures(result, tag) return result @torch.no_grad() def free_acc_intervene(model, head, codebook, q_emb, tok, task, probs, im, did, device, ablate_first=False, corrupt_step=None, noise_rng=None): correct, bs = 0, 16 for s in range(0, len(probs), bs): b = probs[s:s + bs] E, attn, pos, zs_, lens_, _ = generate(model, head, codebook, q_emb, tok, task, b, im, device, ablate_first=ablate_first, corrupt_step=corrupt_step, noise_rng=noise_rng) pred = read_answer(model, head, E, attn, pos, zs_, b, task.m, task.K, device).argmax(-1).tolist() correct += sum(int(pred[j] == task.answer(b[j])) for j in range(len(b))) return correct / len(probs) def make_figures(r, tag): m, K, chance = r["base_m"], r["K"], r["chance"] fig, ax = plt.subplots(1, 3, figsize=(15, 4.2)) # (a) interventions bm = ["intact", "shuffle_steps", "donor_full"]; bn = ["intact", "shuffle\nsteps", "donor\nfull"] ax[0].bar(range(len(bm)), [r["acc"][x] for x in bm], color=["#1f77b4", "#d62728", "#ff7f0e"]) ax[0].axhline(chance, ls="--", color="grey", label=f"chance {chance:.2f}") ax[0].set_xticks(range(len(bm))); ax[0].set_xticklabels(bn); ax[0].set_ylim(0, 1) ax[0].set_ylabel("acc vs original"); ax[0].legend(); ax[0].set_title("(a) intervene on the chain") # (b) donor follows dm = ["donor_full", "donor_subspace", "donor_complement"]; dn = ["all", f"U ({r['subspace_rank']}d)", "U-perp"] x = np.arange(len(dm)); w = 0.38 ax[1].bar(x - w / 2, [r["donor"][d]["follow_donor"] for d in dm], w, label="follows DONOR", color="#2ca02c") ax[1].bar(x + w / 2, [r["donor"][d]["follow_prompt"] for d in dm], w, label="follows PROMPT", color="#8c564b") ax[1].axhline(chance, ls="--", color="grey"); ax[1].set_xticks(x); ax[1].set_xticklabels(dn); ax[1].set_ylim(0, 1) ax[1].legend(); ax[1].set_title("(b) donor patch") # (c) generalization curve gm = sorted(int(k) for k in r["gen_curve"]); gv = [r["gen_curve"][str(k)] for k in gm] ax[2].plot(gm, gv, "o-", color="#9467bd") tl = r.get("train_lengths") or [] if tl: ax[2].axvspan(min(tl), max(tl), color="green", alpha=0.12, label=f"trained m={min(tl)}-{max(tl)}") ax[2].axhline(chance, ls="--", color="grey"); ax[2].set_ylim(0, 1) ax[2].set_xlabel("chain length m"); ax[2].set_ylabel("free-running acc"); ax[2].legend() ax[2].set_title("(c) generalization to longer chains") fig.suptitle(f"{tag}: single token per step — load-bearing + length generalization") fig.tight_layout(); f1 = f"{RES}/single_interventions_{tag}.png"; fig.savefig(f1, bbox_inches="tight", dpi=130); plt.close(fig) fig, ax = plt.subplots(1, 2, figsize=(11, 4.4)) conf = np.array(r["confusion"]); confn = conf / conf.sum(1, keepdims=True).clip(min=1) im0 = ax[0].imshow(confn, vmin=0, vmax=1, cmap="magma"); ax[0].set_xlabel("predicted digit"); ax[0].set_ylabel("true digit") ax[0].set_title("(a) answer read-out"); fig.colorbar(im0, ax=ax[0], fraction=0.046) cp = np.array(r["cellprobe"]).mean(2) # [layers, m] mean over cells im1 = ax[1].imshow(cp, vmin=0, vmax=1, cmap="viridis", aspect="auto") ax[1].set_xticks(range(m)); ax[1].set_xticklabels([f"z{t+1}" for t in range(m)]) ax[1].set_yticks(range(len(r["probe_layers"]))); ax[1].set_yticklabels(r["probe_layers"]); ax[1].set_ylabel("layer") ax[1].set_title("(b) whole-row decodability per token"); fig.colorbar(im1, ax=ax[1], fraction=0.046) fig.suptitle(f"{tag}: each single token encodes the whole {K}-cell row") fig.tight_layout(); f2 = f"{RES}/single_decode_{tag}.png"; fig.savefig(f2, bbox_inches="tight", dpi=130); plt.close(fig) print(f1, flush=True); print(f2, flush=True) def main(): ap = argparse.ArgumentParser() ap.add_argument("--ckpt", required=True); ap.add_argument("--model", default="Qwen/Qwen3-8B") ap.add_argument("--n", type=int, default=400) args = ap.parse_args() run(args.ckpt, args.model, args.n, "cuda") if __name__ == "__main__": main()