#!/usr/bin/env python3 """First-token KL divergence between a stock checkpoint and its abliterated twin. WHAT THIS MEASURES, PRECISELY ----------------------------- For each evaluation prompt we render the chat template, run both models, and read the next-token distribution at the generation position — the distribution over the **first token the model would emit**. KL(ref || cand) over that distribution is the standard abliteration-damage metric: it is what Heretic minimizes as its objective, and it is the number quoted for our gen-seat candidates (`absolute-heresy` 0.0759, `JonathanColetti/Qwen3.8-27B-Uncensored` 0.1191). Two classes of prompt are scored separately, and the split is the whole point: * **harmless** — held-out benign prompts. This is the *damage* number. Divergence here is collateral: the model moving on inputs the surgery had no business touching. Lower is better; this is the headline. * **harmful** — the reserved AdvBench test split. This is the *signal* number. Divergence here is the intended effect. Higher is better. Their ratio is the interesting quantity — a surgical abliteration moves a lot on refusal-triggering prompts and almost nothing elsewhere. A single averaged KL over a mixed corpus hides exactly that, which is why this script never reports one. WHAT THIS DOES *NOT* MEASURE ---------------------------- - **The MTP head.** `AutoModelForCausalLM` resolves to the text-only `Qwen3_5ForCausalLM`, which does not instantiate `mtp.*` (the same fact that makes `save_pretrained` wrong on the write path — see abliterate.py). So this is the main head's distribution shift only. MTP health is measured directly by acceptance rate, which is the gate that matters there (≳40%), not KL. - **Quantization damage.** Both sides are bf16. Point this at the NVFP4 build and you would be measuring quant + abliteration together, which answers a different question. - **Anything beyond the first token.** Divergence compounds over a rollout; a first-token number is a lower bound on trajectory divergence, not a summary of it. It is used here because it is the metric the reference figures use. COMPARABILITY CAVEAT — read before quoting this against Heretic's numbers. The reference figures come from Heretic's own optimizer on a *different base model* with *its own* harmless prompt set and template. Same metric, different measurement conditions. Treat a comparison as order-of-magnitude, not head-to-head. BATCH SIZE IS 1, DELIBERATELY ----------------------------- Two reasons, both learned upstream in this harness: 1. **VRAM.** The window this runs in has the co-tenant seat still resident, so free memory after the 50 GB model lands is single-digit GB. A batched forward materializes [B, seq, vocab] logits — at B=8 that is ~0.6 GB in fp32, on top of activations, for no correctness benefit. With B=1 and `logits_to_keep=1` the lm_head runs on one position and the logits tensor is ~0.6 MB. 2. **No padding semantics to get wrong.** `last_token_hidden` in abliterate.py needed a load-bearing argument about padding side and the DeltaNet linear- attention recurrence. At B=1 there is no padding, so that entire class of error is absent rather than reasoned about. The cost is ~700 forwards per model instead of ~90 batches — roughly a minute. GATES (this script refuses to produce a misleading number) exit 6 — tokenizer or vocab mismatch between ref and cand exit 7 — the two stages tokenized a prompt differently exit 8 — model sharded across GPUs or offloaded (residual stream corruption) exit 9 — PYTORCH_CUDA_ALLOC_CONF=expandable_segments (corrupts retained tensors) """ from __future__ import annotations import argparse import hashlib import json import os import statistics import sys import time from pathlib import Path import torch sys.path.insert(0, str(Path(__file__).resolve().parent)) from calibration import load_evaluation # noqa: E402 # `render` is imported rather than copied deliberately: the KL must be measured on # the exact rendering the direction was captured against. Two copies of eight lines # would be the cheapest possible thing to let drift, and the drift would be silent # — a different template changes what "the first token" even is. from abliterate import render # noqa: E402 # Rendering modes. A thinking model's first token means different things depending # on which one you use, and refusal lives in the answer channel: # answer — enable_thinking=False, prompt ends "\n\n"; the next token is # the first token of the ANSWER. This is where "I'm sorry" / "I can't" # actually appears, so this is the headline mode. # think — enable_thinking=True, prompt ends "\n"; the next token opens # the reasoning trace. Reported because the direction was captured # against both renderings and a divergence that shows up in only one of # them is a finding, not noise. MODES = {"answer": False, "think": True} def file_md5(path: Path) -> str | None: if not path.exists(): return None h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def free_vram_mib(device_index: int = 0) -> float: free, _total = torch.cuda.mem_get_info(device_index) return free / 2**20 def weights_mib(model_dir: Path) -> float: """Text-only weight bytes, straight from the safetensors headers, in MiB. Sized from the checkpoint rather than from a remembered number because the remembered number was wrong: the runbook carried "bf16 is 50 GB", which was 50.10 **GiB** mislabelled, and the 3.7 GB gap is exactly the difference between "one seat must stop" and "both seats must stop". Vision and MTP are subtracted because `AutoModelForCausalLM` resolves to the text-only `Qwen3_5ForCausalLM` and never instantiates them. Reads only the 8-byte length prefix and the JSON header of each shard — no mmap, no tensor data. (`safe_open` would mmap the whole shard, which ENOMEMs on ZFS — see the quant playbook.) """ import struct from glob import glob total = 0 for shard in sorted(glob(str(model_dir / "*.safetensors"))): with open(shard, "rb") as f: n = struct.unpack(" {cache_path[label]} " f"({cache_path[label].stat().st_size / 2**20:,.0f} MiB)") def _load_and_collect(model_dir: Path, label: str, measure_floor: bool): tok = AutoTokenizer.from_pretrained(model_dir) model = AutoModelForCausalLM.from_pretrained( model_dir, dtype=torch.bfloat16, device_map="auto", attn_implementation="sdpa") model.eval() device = next(model.parameters()).device # --- residency gate --------------------------------------------------- # Sharding this architecture across the two Blackwells zeroes the residual # stream past the device boundary; the logits would decode to garbage while # every gate below the boundary still passed. Diagnosed 2026-08-20. # # Read the placement off the PARAMETERS, not off `hf_device_map`. The map # is empty whenever transformers puts the whole model on one device, so a # map-based check reports "(unsharded)" both when everything is fine and # when there is no map to inspect — it cannot fail, which makes it not a # gate. Parameter devices are ground truth in every case. devices = {str(p.device) for p in model.parameters()} offloaded = sorted(d for d in devices if d.startswith(("cpu", "meta", "disk"))) if len(devices) > 1 or offloaded: print(f"\n!! residency gate FAILED — parameters span {sorted(devices)}. " f"Sharding this architecture zeroes the residual stream past the device " f"boundary and the logits decode to garbage. Fix: CUDA_VISIBLE_DEVICES=0 " f"with BOTH GPU0 seats stopped.", file=sys.stderr) sys.exit(8) print(f" residency: all parameters on {sorted(devices)[0]}, " f"free VRAM after load: {free_vram_mib():,.0f} MiB") collected = collect(model, tok, prompts, device, label) floor = None if measure_floor and args.noise_floor_n: # Re-run a subset through the SAME model. This is not a formality: it # validates the whole scoring path end to end. A deterministic stack # must return exactly 0 here, so any non-zero value indicts this # script (or the stack's determinism) before it indicts the surgery. sub = prompts[:args.noise_floor_n] again = collect(model, tok, sub, device, f"{label}-repeat") floor = {} for mode in MODES: k, _, tv, top1 = divergences( collected[mode]["logprobs"][:len(sub)], again[mode]["logprobs"]) floor[mode] = {"n": len(sub), "kl_mean": float(k.mean()), "kl_max": float(k.max()), "tv_max": float(tv.max()), "top1_agreement": float(top1.double().mean())} print(f" noise floor (self-KL): " + ", ".join(f"{m} max {v['kl_max']:.3e}" for m, v in floor.items())) return collected, floor, collected["answer"]["logprobs"].shape[-1] # --- ONE PROCESS PER MODEL ----------------------------------------------- # # This is not fastidiousness; it is the only mechanism that works. Both # in-process teardowns were tried and MEASURED on 2026-08-20: # # `del model` + `gc.collect()` + `torch.cuda.empty_cache()` -> 45,287 MiB free # the same, with the model confined to an inner frame that exits -> 45,287 MiB free # # i.e. the ~51,300 MiB of weights were still resident both times. The first # (pre-gate) run only survived because PyTorch's allocator hit OOM during the # second load, ran a collection itself, and retried — the second model landed # on the card by rescue, not by design. Relying on that is how you end up # silently offloaded to CPU, which on this architecture does not error: it # zeroes the residual stream past the boundary and returns confident garbage. # # A process exit releases the CUDA context unconditionally, so each model gets # its own. The stages hand their first-token log-probs to disk (~715 MiB per # model) and the parent scores from the caches. Reusable, too: re-measuring a # different candidate against this same reference skips the ref stage. if args.stage in ("ref", "cand"): run_stage(ref_dir if args.stage == "ref" else cand_dir, args.stage, measure_floor=(args.stage == "ref")) return if args.stage == "all": import subprocess for label in ("ref", "cand"): cmd = [sys.executable, str(Path(__file__).resolve()), "--ref", str(ref_dir), "--cand", str(cand_dir), "--out", args.out, "--n-harmless", str(args.n_harmless), "--n-harmful", str(args.n_harmful), "--seed", str(args.seed), "--calib-harmless-n", str(args.calib_harmless_n), "--calib-harmless-seed", str(args.calib_harmless_seed), "--noise-floor-n", str(args.noise_floor_n), "--cache", str(cache_dir), "--stage", label] if args.skip_gates: cmd.append("--skip-gates") rc = subprocess.run(cmd).returncode if rc != 0: print(f"\n!! {label} stage exited {rc}; not scoring a partial run.", file=sys.stderr) sys.exit(rc) print(f" [{label} stage process exited; CUDA context released]") # --- load the stage caches ------------------------------------------------ missing = [p for p in cache_path.values() if not p.exists()] if missing: print(f"\n!! missing stage cache(s): {missing} — run --stage ref and " f"--stage cand first.", file=sys.stderr) sys.exit(11) blobs = {lbl: torch.load(p, weights_only=False) for lbl, p in cache_path.items()} ref = {m: {"logprobs": blobs["ref"]["logprobs"][m], "ids": blobs["ref"]["ids"][m]} for m in MODES} cand = {m: {"logprobs": blobs["cand"]["logprobs"][m], "ids": blobs["cand"]["ids"][m]} for m in MODES} noise_floor = blobs["ref"]["floor"] ref_vocab, cand_vocab = blobs["ref"]["vocab"], blobs["cand"]["vocab"] if ref_vocab != cand_vocab and not args.skip_gates: print(f"\n!! vocab size differs: ref {ref_vocab} vs cand {cand_vocab}", file=sys.stderr) sys.exit(6) # --- tokenization equality gate ------------------------------------------ for mode in MODES: for i, (a, b) in enumerate(zip(ref[mode]["ids"], cand[mode]["ids"])): if a != b and not args.skip_gates: print(f"\n!! prompt {i} ({mode}) tokenized differently between the two " f"stages ({len(a)} vs {len(b)} tokens) — the KL would compare " f"distributions over different prefixes.", file=sys.stderr) sys.exit(7) # --- score ---------------------------------------------------------------- results = {} for mode in MODES: kl_f, kl_r, tv, top1 = divergences(ref[mode]["logprobs"], cand[mode]["logprobs"]) results[mode] = { "harmless": summarize(kl_f[:split], kl_r[:split], tv[:split], top1[:split]), "harmful": summarize(kl_f[split:], kl_r[split:], tv[split:], top1[split:]), } hl = results[mode]["harmless"]["kl_median"] hf = results[mode]["harmful"]["kl_median"] results[mode]["selectivity_median_ratio"] = (hf / hl) if hl > 0 else None payload = { "ref": str(ref_dir), "cand": str(cand_dir), "metric": "first-token KL(ref || cand), full vocabulary, float64", "measured_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "corpus": prov, "tokenizer_digests": {f: {"ref": v[0], "cand": v[1]} for f, v in digests.items()}, "vocab": ref_vocab, "noise_floor_self_kl": noise_floor, "modes": results, "env": {"torch": torch.__version__, "cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"), "alloc_conf": alloc or None}, } Path(args.out).write_text(json.dumps(payload, indent=2)) print("\n" + "=" * 72) print(f"first-token KL(ref || cand) ref={ref_dir.name}") print(f" cand={cand_dir.name}") for mode in MODES: r = results[mode] print(f"\n [{mode} mode]") for cls in ("harmless", "harmful"): s = r[cls] print(f" {cls:<9} n={s['n']:<4} median {s['kl_median']:.4f} " f"mean {s['kl_mean']:.4f} p95 {s['kl_p95']:.4f} max {s['kl_max']:.4f} " f"top1-agree {s['top1_agreement']:.1%}") ratio = r["selectivity_median_ratio"] shown = f"{ratio:.1f}x" if ratio else "n/a" print(f" selectivity (harmful/harmless median KL): {shown}") print(f"\nwrote {args.out}") if __name__ == "__main__": main()