""" Run the Refusal Bench against a locally-loaded Llama-3.2-1B-Instruct. Why this exists: HF Spaces free CPU tier can't complete heavier techniques (Wollschlager cone, COSMIC layer sweep, Cheng, Maskey, Herring) — they OOM or exceed the proxy timeout. This script bypasses HF entirely and runs the same bench end-to-end on Apple Silicon (MPS) or CPU. Output mirrors what /refusal-bench would return: - One JSON per technique in docs/bench_partials_local/.json - A combined docs/bench_result_local_6tech.json with all rows Run: cd backend && .venv/bin/python scripts/run_bench_local.py [--n 20] Memory: BF16 Llama-3.2-1B is ~2.5GB; close browsers before running on a 16GB machine. Each technique is run independently so partial results survive an OOM mid-run. """ from __future__ import annotations import argparse import json import os import sys import time from pathlib import Path # Make backend's modules importable BACKEND = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BACKEND)) import torch # Surface the HF token. The huggingface-cli login flow writes the active # token to ~/.cache/huggingface/token; set HF_TOKEN env so model.py's # _ensure_hf_login picks it up. TOKEN_PATH = Path.home() / ".cache" / "huggingface" / "token" if "HF_TOKEN" not in os.environ and TOKEN_PATH.exists(): os.environ["HF_TOKEN"] = TOKEN_PATH.read_text().strip() def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--n", type=int, default=20, help="pair count per class") parser.add_argument("--layer", type=int, default=8) parser.add_argument("--max-new-tokens", type=int, default=16) parser.add_argument("--seed", type=int, default=42) parser.add_argument( "--techniques", type=str, default="arditi,wollschlager,cosmic,cheng,maskey,herring", help="comma-separated names", ) parser.add_argument( "--device", type=str, default=None, help="cuda / mps / cpu; defaults to auto-detect", ) args = parser.parse_args() # Load model import model as model_mod import refusal_pairs import over_refusal_pairs from refusal_bench.runner import json_safe, run_bench, serialize if args.device: # Optional override; otherwise model.get_device() picks MPS on Apple Silicon original_get_device = model_mod.get_device model_mod.get_device = lambda: args.device # type: ignore print(f"[bench-local] device: {model_mod.get_device()}") print(f"[bench-local] torch: {torch.__version__}") print(f"[bench-local] MPS available: {torch.backends.mps.is_available()}") print(f"\n[bench-local] loading Llama-3.2-1B-Instruct (BF16)…") t0 = time.time() # Use float16 instead of float32 to fit in 16GB unified memory # Note: HookedTransformer.from_pretrained does not directly accept dtype # in transformer_lens 2.11; load and convert. info = model_mod.load_model("meta-llama/Llama-3.2-1B-Instruct") print(f"[bench-local] loaded in {time.time()-t0:.1f}s: {info}") # Cast to bfloat16 for memory headroom on MPS m = model_mod.get_model() target_dtype = torch.float16 if model_mod.get_device() == "mps" else torch.bfloat16 print(f"[bench-local] casting model to {target_dtype}…") m = m.to(target_dtype) # Patch the singleton so subsequent get_model() returns the cast version model_mod._model = m # type: ignore pairs = refusal_pairs.get_refusal_pairs() if len(pairs) < args.n: print(f"[bench-local] WARNING: only {len(pairs)} pairs available; capping --n to that.") n = len(pairs) else: n = args.n harmful = [p[0] for p in pairs[:n]] harmless = [p[1] for p in pairs[:n]] # Over-refusal prompts (XSTest) for Maskey decomposition. Other # techniques ignore this list. Falls back to empty if the module is # unpopulated (run backend/scripts/build_over_refusal_pairs.py first). over_refusal = over_refusal_pairs.OVER_REFUSAL_PROMPTS[:n] if hasattr(over_refusal_pairs, "OVER_REFUSAL_PROMPTS") else [] print(f"[bench-local] using {n} pairs per class · {len(over_refusal)} over-refusal prompts") out_dir = BACKEND.parent / "docs" / "bench_partials_local" out_dir.mkdir(parents=True, exist_ok=True) combined_path = BACKEND.parent / "docs" / "bench_result_local_6tech.json" techniques = [t.strip() for t in args.techniques.split(",") if t.strip()] all_rows = [] probe_train_auc = None probe_test_auc = None probe_cv_auc_mean = None probe_cv_auc_std = None n_extraction_pairs = None n_eval_prompts = None for tname in techniques: print(f"\n[bench-local] ── {tname} ──", flush=True) t0 = time.time() try: result = run_bench( technique_names=[tname], layer=args.layer, harmful_prompts=harmful, harmless_prompts=harmless, over_refusal_prompts=over_refusal if over_refusal else None, test_fraction=0.25, max_new_tokens=args.max_new_tokens, temperature=0.7, seed=args.seed, ) elapsed = time.time() - t0 row = result.results[0] all_rows.append(row.__dict__ if hasattr(row, "__dict__") else row) if probe_train_auc is None: probe_train_auc = result.probe_train_auc probe_test_auc = result.probe_test_auc probe_cv_auc_mean = result.probe_cv_auc_mean probe_cv_auc_std = result.probe_cv_auc_std n_extraction_pairs = result.n_extraction_pairs n_eval_prompts = result.n_eval_prompts # serialize() is NaN-sanitized via json_safe in the runner. (out_dir / f"{tname}.json").write_text(json.dumps(serialize(result), indent=2)) if row.error: print(f" ERROR: {row.error[:120]}") else: print( f" Δrr={row.delta_refusal_rate:+.3f} ΔAUC={row.delta_auc:+.3f} " f"({elapsed/60:.1f} min)" ) except Exception as e: print(f" EXC: {type(e).__name__}: {str(e)[:200]}") all_rows.append({ "name": tname, "error": f"{type(e).__name__}: {e}", "elapsed_seconds": time.time() - t0, }) # Free GPU memory between techniques if model_mod.get_device() == "mps": torch.mps.empty_cache() # Combine combined = { "model_name": "meta-llama/Llama-3.2-1B-Instruct", "device": model_mod.get_device(), "dtype": str(target_dtype), "layer": args.layer, "n_pairs_per_class": n, "test_fraction": 0.25, "probe_train_auc": probe_train_auc, "probe_test_auc": probe_test_auc, "results": all_rows, } combined_path.write_text(json.dumps(json_safe(combined), indent=2, default=str)) # UI-shaped artifact for the leaderboard (public/bench/). NaN-sanitized so a # single errored/degenerate row can't make the file invalid JSON and blank # the leaderboard. Overwrites the cached default with this fresh local run. # Provenance fields (device/dtype/seed/n_pairs_per_class) are written into # the shipped artifact, not just the combined debug dump. Two reasons: # * TransformerLens warns that the MPS backend "may produce silently # incorrect results" on torch 2.12 (TransformerLensOrg/TransformerLens # #1178). An artifact that does not say which backend produced it can't # be audited against that warning. # * This repo has already been bitten by two same-named runs with # different numbers (see docs/bench_partials/README.md). Recording the # config in the artifact makes a run self-identifying. ui_artifact = { "model_name": "meta-llama/Llama-3.2-1B-Instruct", "layer": args.layer, "device": model_mod.get_device(), "dtype": str(target_dtype), "seed": args.seed, "n_pairs_per_class": n, "max_new_tokens": args.max_new_tokens, "n_extraction_pairs": n_extraction_pairs, "n_eval_prompts": n_eval_prompts, "probe_train_auc": probe_train_auc, "probe_test_auc": probe_test_auc, "probe_cv_auc_mean": probe_cv_auc_mean, "probe_cv_auc_std": probe_cv_auc_std, "results": all_rows, } public_dir = BACKEND.parent / "public" / "bench" public_dir.mkdir(parents=True, exist_ok=True) public_path = public_dir / "refusal_bench_default.json" public_path.write_text(json.dumps(json_safe(ui_artifact), indent=2)) print(f"saved UI artifact: {public_path}") # Print summary print(f"\n\n=== REFUSAL BENCH — Llama-3.2-1B (local, {target_dtype}) ===") print(f"probe train AUC {probe_train_auc:.3f}, test AUC {probe_test_auc:.3f}") print() print(f"{'TECHNIQUE':<28} {'Δ REFUSAL':>10} {'Δ AUC':>10}") print("-" * 50) for row in all_rows: name = row.get("name", "?")[:28] if row.get("error"): print(f"{name:<28} ERROR") else: drr = row.get("delta_refusal_rate", float("nan")) dauc = row.get("delta_auc", float("nan")) print(f"{name:<28} {drr:>+10.3f} {dauc:>+10.3f}") print(f"\nsaved: {combined_path}") return 0 if __name__ == "__main__": sys.exit(main())