#!/usr/bin/env python3 """Robinson-formula abliteration of Qwen3.8-27B (MTP-aware, vision-preserving). Implements the recipe documented in `docs/pfi/abliteration-recipe-qwen38.md` (captured from RobinsonLabs). Single refusal direction, orthogonalized out of every residual-stream WRITER, including the MTP head in-band and preserving the vision tower byte-identical. This is deliberately conservative and gated. It REFUSES to write a byte unless: 1. the residual-writer coverage identity holds (o_proj + linear_out == num_hidden_layers), and 2. the chosen layer's direction is not dominated by the attention-sink dimension (Qwen3.8-27B: dim 3994 — orthogonalizing it out bricks the model). Both gates come straight from the recipe; both are failure modes that otherwise ship a model that loads and runs but is half-abliterated or emits garbage. Modes: --dry-run enumerate + classify tensors, run BOTH gates on the static surface (no model forward, no capture, no write). Run this first against the real checkpoint to confirm the tensor map. --capture load the model, derive the refusal direction from two chat templates, screen dim 3994, save the direction + report. No write. (default) capture (or --direction ) then orthogonalize and save the abliterated bf16 to --out. Env: /tank/aimodels/quant-work/.venv (torch 2.12 cu130). Run ON ana-ml2. """ from __future__ import annotations import argparse import os import json import sys from pathlib import Path import torch # --- architecture constants (Qwen3.8-27B, verified against the recipe) -------- NUM_LAYERS = 64 HIDDEN = None # read from config at runtime FULL_ATTN_INTERVAL = 4 # self_attn.o_proj on every 4th layer -> 16 EXPECT_O_PROJ = 16 EXPECT_LINEAR_OUT = 48 EXPECT_DOWN_PROJ = 64 EXPECT_MTP_WRITERS = 2 # mtp.layers.0: o_proj + down_proj SINK_DIM = 3994 # massive-activation dim; must NOT be orthogonalized out SINK_ENERGY_MAX = 0.01 # >1% of direction energy in dim 3994 at chosen layer -> abort DEFAULT_LAYER = 26 # recipe peak-agreement layer (|cos| 0.9925); auto-picked, this is the sanity anchor # Residual-writer suffixes. A tensor writing INTO the residual stream has output # dim == hidden_size; orthogonalizing removes its ability to write along the # refusal direction. Classified by suffix so this survives minor name drift; the # coverage gate below catches any misclassification. WRITER_SUFFIXES = { "mlp.down_proj.weight": "down_proj", "self_attn.o_proj.weight": "o_proj", "linear_attn.out_proj.weight": "linear_out", } MTP_WRITER_SUFFIXES = ("o_proj.weight", "down_proj.weight") # within an mtp block EMBED_SUFFIX = "embed_tokens.weight" VISION_MARKERS = (".visual.", "visual.") # never touched def is_vision(name: str) -> bool: return any(m in name for m in VISION_MARKERS) def is_mtp(name: str) -> bool: return ".mtp." in name or name.startswith("mtp.") or ".nextn." in name def classify_writers(keys): """Map every residual-writer tensor to its class. Vision is excluded up front.""" trunk = {"down_proj": [], "o_proj": [], "linear_out": []} mtp_writers, embed = [], [] for k in keys: if is_vision(k): continue if is_mtp(k): if k.endswith(MTP_WRITER_SUFFIXES) and ("o_proj" in k or "down_proj" in k): mtp_writers.append(k) continue for suf, cls in WRITER_SUFFIXES.items(): if k.endswith(suf): trunk[cls].append(k) break if k.endswith(EMBED_SUFFIX): embed.append(k) return trunk, mtp_writers, embed def coverage_gate(trunk, mtp_writers, embed): """Hard gate — the recipe's o_proj(16) + linear_out(48) == 64 identity, plus down_proj==64, MTP writers==2, exactly one embed. Returns (ok, report).""" n_o, n_lin, n_dn = len(trunk["o_proj"]), len(trunk["linear_out"]), len(trunk["down_proj"]) checks = { "o_proj + linear_out == NUM_LAYERS": (n_o + n_lin == NUM_LAYERS, f"{n_o}+{n_lin}={n_o+n_lin} vs {NUM_LAYERS}"), "o_proj == 16": (n_o == EXPECT_O_PROJ, f"{n_o} vs {EXPECT_O_PROJ}"), "linear_out == 48": (n_lin == EXPECT_LINEAR_OUT, f"{n_lin} vs {EXPECT_LINEAR_OUT}"), "down_proj == 64": (n_dn == EXPECT_DOWN_PROJ, f"{n_dn} vs {EXPECT_DOWN_PROJ}"), "mtp writers == 2": (len(mtp_writers) == EXPECT_MTP_WRITERS, f"{len(mtp_writers)} vs {EXPECT_MTP_WRITERS}"), "exactly one embed_tokens": (len(embed) == 1, f"{len(embed)}"), } ok = all(v[0] for v in checks.values()) return ok, checks def load_config(model_dir: Path): cfg = json.loads((model_dir / "config.json").read_text()) tc = cfg.get("text_config", cfg) return cfg, tc # --- direction capture (Arditi-style, two-template agreement) ------------------ # # Calibration corpora live in calibration.py. The capture window (the layer range # the direction may be picked from) comes straight from the recipe. CAPTURE_WINDOW = (18, 45) # inclusive; recipe reports |cos| 0.96-0.99 here def render(tokenizer, prompt, thinking): msgs = [{"role": "user", "content": prompt}] kw = {} # Qwen chat templates gate thinking via enable_thinking; xhigh path injects # an extra system block, shifting positions — exactly the two renderings the # recipe captured to prove the direction is refusal-semantic, not template. try: return tokenizer.apply_chat_template( msgs, tokenize=False, add_generation_prompt=True, enable_thinking=thinking, **kw) except TypeError: return tokenizer.apply_chat_template( msgs, tokenize=False, add_generation_prompt=True) def decoder_layers(model): """The decoder's ModuleList, whatever wrapper depth it is buried under.""" for path in (("model", "layers"), ("model", "model", "layers"), ("model", "language_model", "layers")): obj = model for attr in path: obj = getattr(obj, attr, None) if obj is None: break if obj is not None and hasattr(obj, "__getitem__") and len(obj) > 0: return obj raise RuntimeError("could not locate the decoder layer list on this model") @torch.no_grad() def last_token_hidden(model, tokenizer, texts, device, layers): """Last-real-token hidden state at the requested layers, for a batch. Returns {layer: [B, hidden]} on CPU in float32. CAPTURED DURING THE FORWARD, NOT AFTER — this is load-bearing. Reading `output_hidden_states=True` off the returned object is not safe on this stack: the retained tensors get recycled, and a *later* allocation overwrites them with garbage. Diagnosed 2026-08-20 — a single layer's state came back with exactly 5040 **Inf** values (not NaN) confined to one sequence position, the affected layer moved between bit-identical trials (23, 23, 44), and every downstream layer stayed finite and consistent. A real numerical blowup propagates forward and is deterministic; this did neither. It is the stored copy that is corrupt, not the computation. A forward pre-hook takes its slice and clones it to CPU while the buffer is still live, which closes the window entirely — and as a bonus never retains a full [B, seq, hidden] tensor per layer, so it is cheaper than the thing it replaces. `hidden_states[i]` in the transformers convention is the *input* to layer i, which is exactly what a pre-hook on `layers[i]` sees — so this is the same vector the previous capture used, not a redefinition. PADDING SIDE IS ALSO LOAD-BEARING. We pad on the RIGHT and index each row's true final token. In a causal stack — including this model's DeltaNet linear attention — nothing after position t can influence position t, so trailing pad tokens cannot contaminate the state we read. LEFT padding would prepend pad tokens *into* the linear-attention recurrence ahead of the real prompt, and that fallback path is not trustworthy about masking a prefix out. """ enc = tokenizer(texts, return_tensors="pt", padding=True) # side pinned at load lengths = enc["attention_mask"].sum(-1) # [B], true token counts stack = decoder_layers(model) grabbed, handles = {}, [] def make_hook(i): def pre_hook(_mod, args, kwargs): h = args[0] if args else kwargs.get("hidden_states") rows = torch.arange(h.shape[0], device=h.device) idx = (lengths - 1).to(h.device) grabbed[i] = h[rows, idx, :].detach().float().cpu().clone() return None return pre_hook try: for i in layers: handles.append(stack[i].register_forward_pre_hook(make_hook(i), with_kwargs=True)) model(**enc.to(device)) finally: for h in handles: h.remove() missed = [i for i in layers if i not in grabbed] if missed: raise RuntimeError(f"pre-hooks never fired for layers {missed[:5]} — layer indexing is wrong") return grabbed @torch.no_grad() def check_batch_equivalence(model, tokenizer, texts, device, layers): """Prove padded-batch == one-at-a-time before spending the capture window. Cheap insurance against a silently wrong number: this stack has already produced both a nondeterministic NaN and a recycled-buffer Inf, so batching is not taken on faith. Compares batched last-token states against single-prompt forwards over prompts of differing length, so at least one row is genuinely padded. Also catches non-finite states, whatever their cause. """ batched = last_token_hidden(model, tokenizer, texts, device, layers) singles = [last_token_hidden(model, tokenizer, [t], device, layers) for t in texts] delta = 0.0 scale = 0.0 for i in layers: single_i = torch.cat([s[i] for s in singles]) # [B, hidden] delta = max(delta, (batched[i] - single_i).abs().max().item()) scale = max(scale, single_i.abs().max().item()) rel = delta / max(scale, 1e-6) return rel, delta, scale def _collect_hidden(model, tokenizer, prompts, thinking, device, batch_size, layers, label): """Per-prompt last-token hidden states. {layer: [N, hidden]} float32 on CPU. Retained per-prompt rather than accumulated into a mean, because the layer SELECTION metric needs the individual projections (see `capture_direction`). The cost is trivial — 416 prompts x 28 layers x 5120 floats is ~238 MB. Every batch is finite-checked as it lands. A single Inf would poison the mean for that layer, and finding out at the end of an 832-prompt run wastes the run. """ import time if not prompts: raise ValueError(f"empty prompt set for {label}") chunks = {i: [] for i in layers} t0 = time.time() for start in range(0, len(prompts), batch_size): chunk = prompts[start:start + batch_size] texts = [render(tokenizer, p, thinking) for p in chunk] got = last_token_hidden(model, tokenizer, texts, device, layers) for i in layers: h = got[i] if not torch.isfinite(h).all(): raise RuntimeError( f"non-finite hidden state at layer {i}, prompts {start}..{start+len(chunk)-1} " f"({label}) — refusing to fold it into the mean") chunks[i].append(h.float()) done = start + len(chunk) if done % (batch_size * 10) == 0 or done == len(prompts): rate = done / max(time.time() - t0, 1e-6) print(f" [{label}] {done}/{len(prompts)} prompts ({rate:.1f}/s)", flush=True) return {i: torch.cat(chunks[i]) for i in layers} def separation_stats(harm_acts, safe_acts, direction): """How cleanly `direction` splits harmful from harmless. (cohen_d, auc). THE metric for picking the abliteration layer. Project every prompt onto the unit direction and ask how separated the two clouds are: Cohen's d for effect size, AUC for rank separability. A direction that does not separate the two populations cannot be the thing the model uses to decide to refuse, so removing it will do nothing — which is exactly the failure this replaced. """ ph = harm_acts @ direction ps = safe_acts @ direction pooled = ((ph.var() + ps.var()) / 2).sqrt().clamp_min(1e-8) cohen = float((ph.mean() - ps.mean()) / pooled) ranks = torch.cat([ph, ps]).argsort().argsort().float() n1 = len(ph) auc = float((ranks[:n1].sum() - n1 * (n1 - 1) / 2) / (n1 * len(ps))) return cohen, auc def capture_direction(model, tokenizer, device, harmful, harmless, batch_size, layers): """Per-layer refusal direction, its separation power, and template agreement. Returns (dirs[template][layer], agreement{layer}, sep{layer: (cohen_d, auc)}). """ dirs, acts = {}, {} for thinking in (False, True): tag = "xhigh" if thinking else "no-think" print(f" template: {tag}", flush=True) H = _collect_hidden(model, tokenizer, harmful, thinking, device, batch_size, layers, f"{tag}/harmful") S = _collect_hidden(model, tokenizer, harmless, thinking, device, batch_size, layers, f"{tag}/harmless") d = {} for i in layers: v = H[i].double().mean(0) - S[i].double().mean(0) d[i] = (v / v.norm().clamp_min(1e-8)).float() dirs[thinking] = d if thinking is False: acts = (H, S) # separation is measured on the template we ship agree = {i: float((dirs[False][i] * dirs[True][i]).sum().abs()) for i in layers} H, S = acts sep = {i: separation_stats(H[i], S[i], dirs[False][i]) for i in layers} return dirs, agree, sep def sink_energy(direction_vec, dim=SINK_DIM): e = (direction_vec[dim] ** 2) / (direction_vec ** 2).sum().clamp_min(1e-12) return float(e) def orthogonalize_(weight, d_unit): """Remove the refusal component from a residual-WRITE matrix in place. weight: [out=hidden, in]; W <- (I - d d^T) W = W - d (d^T W).""" d = d_unit.to(weight.dtype).to(weight.device) coeff = d @ weight # [in] weight.sub_(torch.outer(d, coeff)) def orthogonalize_embed_(weight, d_unit): """embed_tokens [vocab, hidden]: strip the refusal component from each row. E <- E - (E d) d^T.""" d = d_unit.to(weight.dtype).to(weight.device) coeff = weight @ d # [vocab] weight.sub_(torch.outer(coeff, d)) def write_abliterated(model_dir: Path, args, targets, embed_keys, n_vision): """Orthogonalize the 131 residual writers SHARD BY SHARD and write a new checkpoint. This is deliberately not done through a loaded model object, and that is a correctness requirement rather than a preference. `AutoModelForCausalLM` resolves to `Qwen3_5ForCausalLM` — the TEXT model. Saving from it would drop all 333 vision tensors, silently violating the recipe's byte-identical-vision guarantee; and the `ForConditionalGeneration` wrapper does not load the MTP head at all (the same reason the incumbent gen seat's Heretic pass left its MTP head an untouched base graft), so the in-band MTP edit that is the whole point of the Robinson formula would be skipped. Neither failure raises. Operating on the shards instead: every tensor we do not target is re-serialized from the exact bytes we read, so vision and the other 1068 tensors are byte-identical by construction, and the MTP writers are just two more keys. No GPU, no accelerate, no offload, no meta tensors — the whole class of silent-no-op failures goes away with the model object. Per playbook 3.6, shards are read with plain `read()` + `load()` rather than mmap: `safe_open` mmaps a whole shard and a 50 GB shard ENOMEMs on ZFS regardless of free RAM. """ from glob import glob import shutil from safetensors.torch import load as st_load, save_file if not args.out: print("\n!! --out is required to write the abliterated model " "(use --capture for direction-only).", file=sys.stderr) sys.exit(1) if not args.direction: print("\n!! --direction is required for the write. Capture " "first (--capture), inspect the agreement and sink energy, then write.", file=sys.stderr) sys.exit(1) blob = torch.load(args.direction, weights_only=False) layer, d_unit, e = blob["layer"], blob["direction"], blob.get("sink_energy") calib = blob.get("calibration", {}) print(f"\ndirection: layer {layer}, sink energy {e*100:.3f}%, " f"agreement {blob.get('agreement')}, calib {calib.get('calib')} " f"({calib.get('n_harmful')}/{calib.get('n_harmless')})") if not torch.isfinite(d_unit).all(): print("\n!! direction is not finite — refusing to write.", file=sys.stderr) sys.exit(4) d_unit = (d_unit.float() / d_unit.float().norm().clamp_min(1e-12)).cpu() if e is not None and e > SINK_ENERGY_MAX: print(f"\n!! sink-energy gate FAILED ({e*100:.3f}% > {SINK_ENERGY_MAX*100:.1f}%) — " f"orthogonalizing this direction would brick the model.", file=sys.stderr) sys.exit(3) out_dir = Path(args.out) if out_dir.exists() and any(out_dir.glob("*.safetensors")): print(f"\n!! {out_dir} already holds safetensors shards — refusing to overwrite an " f"existing checkpoint. Move it aside or pick another --out.", file=sys.stderr) sys.exit(10) out_dir.mkdir(parents=True, exist_ok=True) targets = set(targets) shards = sorted(glob(str(model_dir / "*.safetensors"))) edited, seen_targets, total_tensors = 0, set(), 0 for si, shard in enumerate(shards, 1): with open(shard, "rb") as f: tensors = st_load(f.read()) total_tensors += len(tensors) hits = [k for k in tensors if k in targets] for k in hits: w = tensors[k] orig_dtype = w.dtype # Math in fp32. The weights are bf16 (8 mantissa bits); computing # d^T W and the rank-1 subtraction at that precision would lose more # than the edit itself is worth. w32 = w.float() if k in embed_keys: orthogonalize_embed_(w32, d_unit) # [vocab, hidden] else: orthogonalize_(w32, d_unit) # [hidden, in] tensors[k] = w32.to(orig_dtype) edited += 1 seen_targets.add(k) save_file(tensors, str(out_dir / Path(shard).name), metadata={"format": "pt"}) print(f" shard {si}/{len(shards)} {Path(shard).name}: {len(hits)} edited", flush=True) del tensors missed = targets - seen_targets if missed or edited != len(targets): print(f"\n!! surgery incomplete — edited {edited} of {len(targets)} targets, " f"{len(missed)} never found in any shard: {sorted(missed)[:5]}", file=sys.stderr) sys.exit(7) print(f" edited {edited} tensors of {total_tensors}; vision ({n_vision}) byte-identical") # Everything that is not weights rides along unchanged. for pat in ("*.json", "*.jinja", "*.txt", "*.model", "*.py"): for src in sorted(model_dir.glob(pat)): if src.name in ("dl.py",): continue shutil.copy2(src, out_dir / src.name) torch.save(blob, out_dir / "refusal-direction.pt") print(f"\nwrote abliterated checkpoint -> {out_dir}") print("DONE.") def main(): ap = argparse.ArgumentParser() ap.add_argument("--model", required=True, help="bf16 checkpoint dir") ap.add_argument("--out", help="output dir for the abliterated bf16") ap.add_argument("--layer", type=int, default=None, help="override the auto-picked direction layer") ap.add_argument("--direction", help="load a saved direction .pt instead of capturing") ap.add_argument("--dry-run", action="store_true", help="enumerate + gate only; no forward, no write") ap.add_argument("--capture", action="store_true", help="capture direction + screen sink; no write") ap.add_argument("--calib", choices=("mlabonne", "builtin"), default="mlabonne", help="calibration corpus: 'mlabonne' = the recipe's 416-prompt AdvBench " "train split + alpaca (default); 'builtin' = the legacy inline 8/8") ap.add_argument("--calib-n-harmful", type=int, default=416, help="harmful calibration prompts (416 = the full train split, as the recipe used)") ap.add_argument("--calib-n-harmless", type=int, default=416, help="harmless calibration prompts sampled from alpaca") ap.add_argument("--calib-seed", type=int, default=0, help="seed for the harmless sample") ap.add_argument("--batch-size", type=int, default=8, help="prompts per forward during capture") ap.add_argument("--capture-dtype", choices=("bfloat16", "float32"), default="bfloat16", help="dtype for the capture forward. bf16 (50 GB, full 64 layers, one GPU) " "is validated deterministic and coherent; fp32 (111 GB) was adopted on " "a misdiagnosis and is kept only as an escape hatch.") ap.add_argument("--max-layer", type=int, default=None, help="truncate the decoder to this many layers before capture. Exact, not an " "approximation: a causal stack's layer-N hidden state cannot depend on " "layers above N, so any value > the capture window's top (45) leaves the " "chosen direction bit-identical while cutting fp32 weight residency and " "forward cost by the dropped fraction. Off by default.") args = ap.parse_args() model_dir = Path(args.model) # --- allocator gate ------------------------------------------------------ # PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True corrupts tensors that # outlive their allocation here (torch 2.12+cu130, Blackwell): captured # hidden states came back with Inf/NaN/zeros that MOVED between bit-identical # forwards. Unset, the same forwards are exactly reproducible. The previous # runbook recommended this flag for headroom; it buys corruption. alloc = os.environ.get("PYTORCH_CUDA_ALLOC_CONF", "") if args.capture and "expandable_segments" in alloc: print(f"\n!! PYTORCH_CUDA_ALLOC_CONF={alloc!r} — expandable_segments corrupts retained " f"tensors on this stack and makes the capture nondeterministic. Unset it.", file=sys.stderr) sys.exit(9) from safetensors import safe_open from glob import glob # --- static surface: enumerate every tensor key from the shards ----------- keys = [] for shard in sorted(glob(str(model_dir / "*.safetensors"))): with safe_open(shard, framework="pt") as f: keys.extend(f.keys()) trunk, mtp_writers, embed = classify_writers(keys) ok, checks = coverage_gate(trunk, mtp_writers, embed) n_vision = sum(1 for k in keys if is_vision(k)) print(f"tensors: {len(keys)} total | vision preserved: {n_vision}") print(f"writers: down_proj={len(trunk['down_proj'])} o_proj={len(trunk['o_proj'])} " f"linear_out={len(trunk['linear_out'])} mtp={len(mtp_writers)} embed={len(embed)}") print("COVERAGE GATE:") for name, (passed, detail) in checks.items(): print(f" [{'PASS' if passed else 'FAIL'}] {name} ({detail})") if not ok: print("\n!! coverage gate FAILED — tensor names do not match the recipe. " "Inspect the checkpoint; do NOT abliterate blind.", file=sys.stderr) sys.exit(2) print(" -> coverage gate PASSED") total_edits = len(trunk["down_proj"]) + len(trunk["o_proj"]) + len(trunk["linear_out"]) + len(mtp_writers) + len(embed) print(f" -> {total_edits} tensors would be orthogonalized (recipe expects 131)") if args.dry_run: print("\ndry-run complete — surface verified, nothing loaded or written.") return if not args.capture: targets = (trunk["down_proj"] + trunk["o_proj"] + trunk["linear_out"] + mtp_writers + embed) write_abliterated(model_dir, args, targets, set(embed), n_vision) return # --- load model for capture / surgery ------------------------------------- from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer print("\nloading model (bf16, device_map=auto across the Blackwells)...") tok = AutoTokenizer.from_pretrained(model_dir) # Right padding + a real pad id, both required by the batched capture. See # the padding-side note in last_token_hidden(): right is the correct side # here, not merely a preference. tok.padding_side = "right" if tok.pad_token is None: tok.pad_token = tok.eos_token # CAPTURE DTYPE — bf16, and the fp32 that used to be here was a misdiagnosis. # # The earlier note claimed bf16 produced nondeterministic NaN in the DeltaNet # linear-attention fallback and that fp32's mantissa "resolved" it. Retested # 2026-08-20 once the sharding and allocator defects below were fixed: bf16, # full 64 layers, one GPU, 50.1 GB — every probed layer through 63 finite and # bit-deterministic across repeated forwards, and the model generates coherent # prose. The NaN was never about precision. It was multi-GPU sharding plus # expandable_segments, both of which fabricate NaN/Inf/zeros that fp32 merely # made rarer. Keeping fp32 would cost 111 GB (forcing truncation and a wider # seat-down window) to buy nothing. capture_dtype = torch.bfloat16 if args.capture_dtype == "bfloat16" else torch.float32 load_dtype = capture_dtype if args.capture else torch.bfloat16 load_kwargs = dict(dtype=load_dtype, device_map="auto", attn_implementation="sdpa") if args.max_layer is not None: lo, hi = CAPTURE_WINDOW if not args.capture: # A truncated model would save_pretrained as a truncated CHECKPOINT. # Capture-only, no exceptions. print("\n!! --max-layer is a capture-only optimization; on the write path it would " "emit a decoder missing its upper layers. Drop it, or add --capture.", file=sys.stderr) sys.exit(5) if args.max_layer <= hi: print(f"\n!! --max-layer {args.max_layer} would truncate inside the capture window " f"[{lo},{hi}] — the direction layer must exist. Use > {hi}.", file=sys.stderr) sys.exit(5) cfg_obj = AutoConfig.from_pretrained(model_dir) tcfg = getattr(cfg_obj, "text_config", cfg_obj) tcfg.num_hidden_layers = args.max_layer # layer_types is per-layer (linear_attention / full_attention every 4th); # it has to be truncated in step or the built stack disagrees with itself. if getattr(tcfg, "layer_types", None): tcfg.layer_types = list(tcfg.layer_types)[:args.max_layer] load_kwargs["config"] = cfg_obj print(f" truncating decoder to {args.max_layer}/{NUM_LAYERS} layers for capture " f"(exact for any layer <= {args.max_layer}; window top is {hi})") model = AutoModelForCausalLM.from_pretrained(model_dir, **load_kwargs) model.eval() device = next(model.parameters()).device if args.capture: # --- residency gate: this model must not be SHARDED for a forward ----- # Diagnosed 2026-08-20. Split across the two Blackwells by device_map, # the residual stream collapses to exactly zero a couple of layers past # the GPU0->GPU1 boundary and the logits decode to garbage ('8', '�', # 'b', ...), while every layer *below* the boundary stays healthy, # deterministic, and bit-identical to a single-GPU run. That is why the # first capture looked plausible: it picked layer 22, which happened to # sit on GPU0 in the healthy region. Layers above the boundary were zeros # and their agreement scores were meaningless. # # There is no partial-credit version of this. Pin to one GPU # (CUDA_VISIBLE_DEVICES=0) and truncate with --max-layer so the fp32 # weights fit: 46 layers is ~75 GB on a 96 GB card. dmap = getattr(model, "hf_device_map", {}) or {} placements = {str(v) for v in dmap.values()} gpus = {p for p in placements if p not in ("cpu", "disk")} offloaded = sorted(k for k, v in dmap.items() if str(v) in ("cpu", "disk")) if len(gpus) > 1 or offloaded: print(f"\n!! residency gate FAILED — the model is not on a single GPU " f"(gpus={sorted(gpus)}, offloaded={len(offloaded)} modules). Sharding this " f"architecture silently zeroes the residual stream past the device boundary " f"and the capture would read garbage for the upper window.\n" f" Fix: CUDA_VISIBLE_DEVICES=0 and --max-layer 46 (~75 GB fp32), with the " f"vLLM seats stopped.", file=sys.stderr) if offloaded: print(f" first offloaded: {offloaded[:3]}", file=sys.stderr) sys.exit(8) print(f" residency: single device {sorted(gpus) or ['(unsharded)']}, no offload") if args.direction: blob = torch.load(args.direction) layer, d_unit = blob["layer"], blob["direction"] print(f"loaded direction for layer {layer} from {args.direction}") calib_prov = blob.get("calibration", {"calib": "loaded-from-file"}) sep = None agree = None else: # calibration.py sits beside this script; make that explicit rather than # relying on the caller's cwd landing in the right place. sys.path.insert(0, str(Path(__file__).resolve().parent)) from calibration import load_calibration harmful, harmless, calib_prov = load_calibration( args.calib, args.calib_n_harmful, args.calib_n_harmless, args.calib_seed) print(f"\ncalibration corpus: {calib_prov['source']}") print(f" harmful={calib_prov['n_harmful']} harmless={calib_prov['n_harmless']} " f"seed={calib_prov['seed']}" + (f" held-out reserved={calib_prov['heldout_reserved']}" if "heldout_reserved" in calib_prov else "")) # --- batch-equivalence gate ------------------------------------------ # Batching is what makes an 832-prompt capture affordable, so prove it is # free of side effects before spending the window on it. # Only the recipe's window is ever captured. Outside it the direction is # not a candidate anyway, and the early layers are dominated by the # dim-3994 massive activation, which inflates |cos| for reasons that have # nothing to do with refusal — quoting that global figure beside the # window's layer is how the first capture came to be reported as 0.8538 # when the number that mattered was 0.5944. lo, hi = CAPTURE_WINDOW window = list(range(lo, hi + 1)) if args.layer is not None and args.layer not in window: print(f"\n!! --layer {args.layer} is outside the capture window [{lo},{hi}]; no " f"direction is captured there.", file=sys.stderr) sys.exit(5) probe = (harmful[:2] + harmless[:2]) if len(harmless) >= 2 else harmful[:4] probe_texts = [render(tok, p, False) for p in probe] rel, delta, scale = check_batch_equivalence(model, tok, probe_texts, device, window) # Tolerance is dtype-aware, because the gate is looking for CONTAMINATION # (pad leakage, recycled buffers), not for bit-exactness. Changing the # batch shape changes kernel tiling and therefore accumulation order, so a # few ULP of disagreement is expected and benign. bf16 carries 8 mantissa # bits: at magnitude ~80 one ULP is ~0.25, so ~4 ULP lands near 1e-2 # relative. fp32 measures ~5e-5 on the same probe. Real contamination is # not subtle — the sharding defect read rel 1.00, two orders clear of # either threshold. tol = 5e-2 if capture_dtype == torch.bfloat16 else 1e-3 print(f"batch-equivalence gate: max |batched - single| = {delta:.3e} " f"(rel {rel:.2e} of scale {scale:.3f}; threshold {tol:.0e} for " f"{str(capture_dtype).replace('torch.','')})") if not (rel < tol): print("\n!! batched and single-prompt forwards disagree, or a state came back " "non-finite. Re-run with --batch-size 1 to isolate; do NOT capture on " "contaminated states.", file=sys.stderr) sys.exit(6) print(" -> batch-equivalence PASSED") print("\ncapturing refusal direction from two chat templates...") dirs, agree, sep = capture_direction(model, tok, device, harmful, harmless, args.batch_size, window) # LAYER SELECTION — by SEPARATION, not by two-template agreement. # # The recipe picks the layer by peak |cos| between the no-think and xhigh # renderings. On this checkpoint that metric is actively misleading, and # following it cost a full write-and-test cycle for a no-op. Measured # 2026-08-20: agreement ranked layer 18 first (0.6238) — and layer 18 has # the WORST harmful/harmless separation of the entire window (Cohen's d # 5.51 vs 9.89 at layer 39). Abliterating there changed nothing: stock and # abliterated refused all six probe prompts identically. # # The reason agreement fails here is that the two renderings do not merely # differ in formatting — they leave the model in different generative # modes at the token we read (`\n\n` = about to answer, `\n` # = about to reason). So |cos| scores refusal semantics *plus* mode, and on # a heavily-merged base the mode term dominates. Robinson's stock # Qwen3.8-27B scored 0.99 across that same split; this model scores 0.62, # and that difference says more about the templates than the direction. # # Separation asks the question that actually predicts efficacy: does this # direction split harmful from harmless prompts? Here it does, superbly # (AUC 0.9996+ across the whole window) — the direction was never the # problem, only where we removed it. Agreement is still reported, as a # diagnostic rather than a selector. # The sink screen is a FILTER on selection, not just a post-hoc abort. # Separation and sink-energy both climb with depth on this model, so the # best-separating layer (39, d=9.89) is also sink-dominated (1.97% > 1%) # and would brick the model. Pick the best separator *among layers that # pass the screen* — one pass, no guess-and-retry. sink = {L: sink_energy(dirs[False][L]) for L in window} by_sep = lambda L: sep[L][0] eligible = [L for L in window if sink[L] <= SINK_ENERGY_MAX] print(f"\nlayer selection over [{lo},{hi}] — separation, gated on sink < " f"{SINK_ENERGY_MAX*100:.1f}%:") for L in sorted(window, key=by_sep, reverse=True)[:8]: mark = "ok " if sink[L] <= SINK_ENERGY_MAX else "SINK" print(f" [{mark}] L{L:<3} d={sep[L][0]:6.3f} AUC={sep[L][1]:.4f} " f"sink={sink[L]*100:6.3f}% |cos|={agree[L]:.4f}") if not eligible: print("\n!! every layer in the window is sink-dominated — no safe direction exists " "here. Widen the window or reconsider the approach.", file=sys.stderr) sys.exit(3) best = max(eligible, key=by_sep) layer = args.layer if args.layer is not None else best d_unit = dirs[False][layer] # thinking-off direction at the chosen layer print(f" -> {len(eligible)}/{len(window)} layers pass the sink screen; " f"best separator among them: L{best} (d={sep[best][0]:.3f})") print(f" using layer {layer} (d={sep[layer][0]:.3f}, AUC={sep[layer][1]:.4f}, " f"sink={sink[layer]*100:.3f}%, two-template |cos|={agree[layer]:.4f})") agree_best = max(window, key=lambda L: agree[L]) print(f" [diagnostic] agreement would have picked L{agree_best} " f"(|cos|={agree[agree_best]:.4f}, d={sep[agree_best][0]:.3f}) — " f"recipe anchor L{DEFAULT_LAYER} at |cos| 0.9925 on stock Qwen3.8") # --- finite gate: a NaN/Inf direction must NEVER pass silently ----------- # (the sink screen alone doesn't catch this — `nan > threshold` is False, so # a NaN direction would "pass" the sink gate. This is the real guard.) if not torch.isfinite(d_unit).all(): frac = float(torch.isfinite(d_unit).float().mean()) print(f"\n!! captured direction is NOT finite (finite frac {frac:.3f}) — " "the forward pass produced NaN/Inf. Check attn_implementation and the " "fla/linear-attn path; do NOT abliterate on this direction.", file=sys.stderr) sys.exit(4) # --- attention-sink screen (the brick-the-model gate) --------------------- e = sink_energy(d_unit) print(f"attention-sink screen: dim {SINK_DIM} carries {e*100:.3f}% of layer-{layer} direction energy " f"(recipe L26 ref: 0.06%; abort threshold {SINK_ENERGY_MAX*100:.1f}%)") if e > SINK_ENERGY_MAX: print("\n!! sink-energy gate FAILED — orthogonalizing this direction would brick the model. " "Pick a different layer.", file=sys.stderr) sys.exit(3) print(" -> sink screen PASSED") blob = { "layer": layer, "direction": d_unit.cpu(), "sink_energy": e, "calibration": calib_prov, "agreement": None if agree is None else agree[layer], "agreement_per_layer": agree, "separation": None if sep is None else sep[layer], "separation_per_layer": sep, "capture_window": CAPTURE_WINDOW, } if args.capture: dpath = model_dir / "refusal-direction.pt" torch.save(blob, dpath) print(f"direction saved -> {dpath} (capture-only, no write)") return if __name__ == "__main__": main()