Add files via upload

This commit is contained in:
pliny
2026-03-04 12:38:18 -08:00
committed by GitHub
commit 0f6114fe87
100 changed files with 46937 additions and 0 deletions
+803
View File
@@ -0,0 +1,803 @@
#!/usr/bin/env python3
"""Abliteration Technique Comparison Study.
A rigorous, controlled comparison of refusal-direction removal techniques.
Uses a synthetic "planted refusal direction" methodology: we inject a known
direction into a model's activations so we can measure whether each technique
correctly identifies and removes it.
Additionally compiles literature results for a full comparison table.
Techniques compared:
1. Arditi et al. (2024) — difference-of-means, last token, raw prompts
2. Arditi + chat template — same but with chat-formatted prompts
3. FailSpy/abliterator — Arditi with middle-60% layer heuristic
4. Gabliteration — SVD multi-direction (4 dirs), regularization 0.0
5. grimjim — Gabliteration + norm preservation
6. OBLITERATUS basic — our current basic config
7. OBLITERATUS advanced — 4 directions, norm-preserve, reg=0.3
8. Heretic (p-e-w) — TPE Bayesian optimization (literature)
Metrics:
- Direction recovery: cosine similarity to planted ground-truth direction
- Residual after projection: how much of the refusal direction remains
- Capability preservation: Frobenius distance of modified vs original weights
- Layer selection accuracy: did it pick the right layers?
- Perplexity delta: change in language modeling loss (on synthetic data)
"""
from __future__ import annotations
import gc
import json
import math
import os
import sys
import time
import torch
import torch.nn as nn
import torch.nn.functional as F
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ══════════════════════════════════════════════════════════════════════════
# Synthetic model with planted refusal direction
# ══════════════════════════════════════════════════════════════════════════
def create_synthetic_model(
hidden_dim: int = 128,
n_layers: int = 12,
n_heads: int = 4,
vocab_size: int = 1000,
seq_len: int = 64,
):
"""Create a tiny GPT-2 model for controlled experiments."""
from transformers import GPT2Config, GPT2LMHeadModel
config = GPT2Config(
vocab_size=vocab_size,
n_positions=seq_len,
n_embd=hidden_dim,
n_layer=n_layers,
n_head=n_heads,
n_inner=hidden_dim * 4,
resid_pdrop=0.0,
attn_pdrop=0.0,
embd_pdrop=0.0,
)
model = GPT2LMHeadModel(config)
model.eval()
return model, config
def plant_refusal_direction(
model: nn.Module,
target_layers: list[int],
hidden_dim: int,
n_directions: int = 1,
signal_strength: float = 5.0,
seed: int = 42,
) -> tuple[dict[int, torch.Tensor], dict[int, torch.Tensor]]:
"""Plant a known refusal direction into specific layers.
Modifies the output projection (c_proj) of attention modules by adding
a rank-1 perturbation along a random direction. This simulates the
refusal direction that RLHF training creates.
Returns:
(planted_directions, planted_subspaces): ground truth per layer
"""
torch.manual_seed(seed)
planted_directions: dict[int, torch.Tensor] = {}
planted_subspaces: dict[int, torch.Tensor] = {}
for idx in target_layers:
# Generate random orthogonal directions
dirs = torch.randn(n_directions, hidden_dim)
# Gram-Schmidt orthogonalize
for i in range(n_directions):
for j in range(i):
dirs[i] -= (dirs[i] @ dirs[j]) * dirs[j]
dirs[i] = dirs[i] / dirs[i].norm()
planted_directions[idx] = dirs[0].clone()
planted_subspaces[idx] = dirs.clone()
# Inject into attention output projection (c_proj for GPT-2)
layer = model.transformer.h[idx]
attn = layer.attn
# Add refusal component to c_proj: W += strength * d @ d^T
# This makes the layer produce extra activation along d when
# processing any input, creating a "refusal signal"
with torch.no_grad():
for dir_idx in range(n_directions):
d = dirs[dir_idx]
# Scale decreases for secondary directions
s = signal_strength * (0.7 ** dir_idx)
# Inject into c_proj (output projection)
W = attn.c_proj.weight.data # GPT-2: (hidden, hidden)
perturbation = s * d.unsqueeze(1) @ d.unsqueeze(0) # rank-1
W.add_(perturbation)
return planted_directions, planted_subspaces
def measure_residual_direction(
model: nn.Module,
layer_idx: int,
direction: torch.Tensor,
) -> float:
"""Measure how much of a direction remains in a layer's output projection.
Returns the magnitude of the direction's component in the weight matrix.
"""
layer = model.transformer.h[layer_idx]
W = layer.attn.c_proj.weight.data
d = direction.to(W.device, W.dtype)
# Project W onto direction: ||W @ d||^2 / ||d||^2
coeff = W @ d # (hidden,)
return coeff.norm().item()
def collect_synthetic_activations(
model: nn.Module,
n_prompts: int,
seq_len: int,
vocab_size: int,
n_layers: int,
add_refusal_signal: bool = False,
signal_direction: dict[int, torch.Tensor] | None = None,
signal_strength: float = 2.0,
seed: int = 0,
) -> dict[int, list[torch.Tensor]]:
"""Collect activations on random token sequences.
If add_refusal_signal=True, adds an artificial activation along
the signal_direction to simulate harmful-prompt activations.
"""
torch.manual_seed(seed)
activations: dict[int, list[torch.Tensor]] = {i: [] for i in range(n_layers)}
hooks = []
def make_hook(idx: int):
def hook_fn(module, input, output):
hidden = output[0] if isinstance(output, tuple) else output
act = hidden[:, -1, :].detach().cpu().float()
if add_refusal_signal and signal_direction and idx in signal_direction:
# Add the planted refusal activation
d = signal_direction[idx]
act = act + signal_strength * d.unsqueeze(0)
activations[idx].append(act)
return hook_fn
layers = list(model.transformer.h)
for idx in range(n_layers):
hooks.append(layers[idx].register_forward_hook(make_hook(idx)))
try:
for i in range(n_prompts):
input_ids = torch.randint(0, vocab_size, (1, seq_len))
with torch.no_grad():
model(input_ids)
finally:
for h in hooks:
h.remove()
return activations
# ══════════════════════════════════════════════════════════════════════════
# Reference baseline implementations
# ══════════════════════════════════════════════════════════════════════════
def extract_directions(
harmful_acts: dict[int, list[torch.Tensor]],
harmless_acts: dict[int, list[torch.Tensor]],
n_layers: int,
n_directions: int = 1,
) -> tuple[dict[int, torch.Tensor], dict[int, torch.Tensor], dict[int, float]]:
"""Extract refusal directions from activation contrasts.
Returns (directions, subspaces, norms) per layer.
"""
directions: dict[int, torch.Tensor] = {}
subspaces: dict[int, torch.Tensor] = {}
norms: dict[int, float] = {}
for idx in range(n_layers):
h_stack = torch.stack(harmful_acts[idx]).squeeze(1)
s_stack = torch.stack(harmless_acts[idx]).squeeze(1)
if n_directions == 1:
diff = h_stack.mean(dim=0) - s_stack.mean(dim=0)
norm = diff.norm().item()
if norm > 0:
directions[idx] = diff / diff.norm()
subspaces[idx] = directions[idx].unsqueeze(0)
norms[idx] = norm
else:
min_n = min(h_stack.shape[0], s_stack.shape[0])
diff_matrix = h_stack[:min_n] - s_stack[:min_n]
diff_matrix = torch.nan_to_num(diff_matrix)
k = min(n_directions, diff_matrix.shape[0], diff_matrix.shape[1])
try:
U, S, Vh = torch.linalg.svd(diff_matrix, full_matrices=False)
sub = Vh[:k]
primary = sub[0]
pn = primary.norm()
if pn > 1e-8:
primary = primary / pn
directions[idx] = primary
subspaces[idx] = sub
norms[idx] = (S[:k] ** 2).sum().item()
except Exception:
continue
return directions, subspaces, norms
def select_layers(
norms: dict[int, float],
n_layers: int,
method: str = "top_norm",
) -> list[int]:
"""Select layers for abliteration."""
sorted_layers = sorted(norms.items(), key=lambda x: x[1], reverse=True)
if not sorted_layers:
return []
if method == "middle_60":
start = int(n_layers * 0.2)
end = int(n_layers * 0.8)
selected = [idx for idx, _ in sorted_layers if start <= idx < end]
return selected if selected else [sorted_layers[0][0]]
elif method == "knee":
if len(sorted_layers) < 3:
return [sorted_layers[0][0]]
vals = [n for _, n in sorted_layers]
max_n = vals[0]
if max_n <= 0:
return [sorted_layers[0][0]]
normalized = [v / max_n for v in vals]
n_pts = len(normalized)
best_k, best_dist = 1, 0.0
x_s, y_s = 0.0, normalized[0]
x_e, y_e = 1.0, normalized[-1]
line_len = math.sqrt((x_e - x_s) ** 2 + (y_e - y_s) ** 2)
if line_len > 0:
for i in range(1, n_pts - 1):
x_i = i / (n_pts - 1)
y_i = normalized[i]
dist = abs((y_e - y_s) * x_i - (x_e - x_s) * y_i
+ x_e * y_s - y_e * x_s) / line_len
if dist > best_dist:
best_dist = dist
best_k = i + 1
min_threshold = max_n * 0.05
selected = [idx for idx, n in sorted_layers[:best_k] if n >= min_threshold]
return selected if selected else [sorted_layers[0][0]]
else: # top_norm
max_norm = sorted_layers[0][1]
threshold = max_norm * 0.5
selected = [idx for idx, n in sorted_layers if n >= threshold]
return selected if selected else [sorted_layers[0][0]]
def apply_projection(
model: nn.Module,
selected_layers: list[int],
subspaces: dict[int, torch.Tensor],
regularization: float = 0.0,
norm_preserve: bool = False,
multi_dir_norm_fix: bool = False,
) -> int:
"""Project refusal direction out of weight matrices.
When multi_dir_norm_fix=True, uses the correct approach: capture norms
before projecting any directions, then restore once after all directions.
"""
scale = 1.0 - regularization
n_modified = 0
for idx in selected_layers:
sub = subspaces.get(idx)
if sub is None:
continue
layer = model.transformer.h[idx]
# Capture norms before any projections (if multi-dir + norm-preserve)
saved_norms: dict[str, float] = {}
if multi_dir_norm_fix and norm_preserve and sub.shape[0] > 1:
for name, param in layer.named_parameters():
if name.endswith(".weight") and param.dim() == 2:
saved_norms[name] = param.data.norm().item()
for dir_idx in range(sub.shape[0]):
d = sub[dir_idx].unsqueeze(-1) # (hidden, 1)
for name, module in layer.named_modules():
if not hasattr(module, "weight"):
continue
W = module.weight.data
if W.dim() != 2:
continue
# Per-direction norm preserve (the OLD buggy way)
use_per_dir_norm = norm_preserve and not (multi_dir_norm_fix and sub.shape[0] > 1)
original_norm = W.norm().item() if use_per_dir_norm else 0.0
if W.shape[-1] == d.shape[0]:
coeff = W @ d
W.sub_(d.T * (scale * coeff))
n_modified += 1
elif W.shape[0] == d.shape[0]:
coeff = d.T @ W
W.sub_((scale * d) * coeff)
n_modified += 1
else:
continue
if use_per_dir_norm and original_norm > 0:
new_norm = W.norm().item()
if new_norm > 0:
W.mul_(original_norm / new_norm)
# Restore norms once after all directions (the FIXED way)
if multi_dir_norm_fix and norm_preserve and sub.shape[0] > 1 and saved_norms:
for name, param in layer.named_parameters():
if name not in saved_norms:
continue
orig = saved_norms[name]
if orig > 0:
cur = param.data.norm().item()
if cur > 0 and abs(cur - orig) > 1e-6:
param.data.mul_(orig / cur)
return n_modified
# ══════════════════════════════════════════════════════════════════════════
# Experiment runner
# ══════════════════════════════════════════════════════════════════════════
def run_experiment():
"""Run the full comparison experiment with synthetic planted directions."""
# Configuration
hidden_dim = 128
n_layers = 12
n_heads = 4
vocab_size = 1000
seq_len = 32
n_prompts = 48 # prompts per side (harmful + harmless)
n_planted_dirs = 4 # ground truth directions planted
signal_strength = 5.0
target_layers = [3, 4, 5, 6, 7, 8] # layers with planted signal
print(f"\n{'='*80}")
print("ABLITERATION TECHNIQUE COMPARISON — SYNTHETIC PLANTED-DIRECTION TEST")
print(f"{'='*80}")
print(f"Model: GPT-2 tiny ({hidden_dim}d, {n_layers}L, {n_heads}H)")
print(f"Target layers: {target_layers}")
print(f"Planted dirs: {n_planted_dirs} orthogonal directions per target layer")
print(f"Signal strength: {signal_strength}")
print(f"Prompts: {n_prompts} per side")
print(f"{'='*80}\n")
# Define experiments
experiments = [
{
"name": "Arditi (1-dir, top-norm)",
"source": "Arditi 2024",
"n_directions": 1,
"layer_selection": "top_norm",
"regularization": 0.0,
"norm_preserve": False,
"multi_dir_norm_fix": False,
},
{
"name": "FailSpy (1-dir, mid-60%)",
"source": "FailSpy",
"n_directions": 1,
"layer_selection": "middle_60",
"regularization": 0.0,
"norm_preserve": False,
"multi_dir_norm_fix": False,
},
{
"name": "Gabliteration (4-dir, knee)",
"source": "Gabliteration",
"n_directions": 4,
"layer_selection": "knee",
"regularization": 0.0,
"norm_preserve": False,
"multi_dir_norm_fix": False,
},
{
"name": "grimjim (4-dir, norm-pres, BUGGY)",
"source": "grimjim",
"n_directions": 4,
"layer_selection": "knee",
"regularization": 0.0,
"norm_preserve": True,
"multi_dir_norm_fix": False, # Old buggy sequential norm-preserve
},
{
"name": "grimjim (4-dir, norm-pres, FIXED)",
"source": "Ours (fix)",
"n_directions": 4,
"layer_selection": "knee",
"regularization": 0.0,
"norm_preserve": True,
"multi_dir_norm_fix": True, # Our fix: capture once, restore once
},
{
"name": "OBLITERATUS basic (1-dir, knee)",
"source": "Ours",
"n_directions": 1,
"layer_selection": "knee",
"regularization": 0.0,
"norm_preserve": False,
"multi_dir_norm_fix": False,
},
{
"name": "OBLITERATUS adv (4-dir, reg=0.3)",
"source": "Ours",
"n_directions": 4,
"layer_selection": "knee",
"regularization": 0.3,
"norm_preserve": True,
"multi_dir_norm_fix": True,
},
{
"name": "OBLITERATUS adv (4-dir, reg=0.1)",
"source": "Ours (tuned)",
"n_directions": 4,
"layer_selection": "knee",
"regularization": 0.1,
"norm_preserve": True,
"multi_dir_norm_fix": True,
},
{
"name": "OBLITERATUS adv (4-dir, reg=0.0)",
"source": "Ours (tuned)",
"n_directions": 4,
"layer_selection": "knee",
"regularization": 0.0,
"norm_preserve": True,
"multi_dir_norm_fix": True,
},
]
results = []
for exp in experiments:
print(f"\n{''*80}")
print(f" {exp['name']}")
print(f" Source: {exp['source']}")
print(f"{''*80}")
t0 = time.time()
# Create fresh model
model, config = create_synthetic_model(hidden_dim, n_layers, n_heads, vocab_size, seq_len)
# Plant ground-truth refusal directions
planted_dirs, planted_subs = plant_refusal_direction(
model, target_layers, hidden_dim,
n_directions=n_planted_dirs,
signal_strength=signal_strength,
seed=42,
)
# Save original weights for capability comparison
original_state = {k: v.clone() for k, v in model.state_dict().items()}
# Measure pre-projection residuals (baseline)
pre_residuals = {}
for idx in target_layers:
pre_residuals[idx] = measure_residual_direction(model, idx, planted_dirs[idx])
# Step 1: Collect activations
harmful_acts = collect_synthetic_activations(
model, n_prompts, seq_len, vocab_size, n_layers,
add_refusal_signal=True,
signal_direction=planted_dirs,
signal_strength=2.0,
seed=100,
)
harmless_acts = collect_synthetic_activations(
model, n_prompts, seq_len, vocab_size, n_layers,
add_refusal_signal=False,
seed=200,
)
# Step 2: Extract directions
ext_dirs, ext_subs, ext_norms = extract_directions(
harmful_acts, harmless_acts, n_layers, exp["n_directions"],
)
# Step 3: Select layers
selected = select_layers(ext_norms, n_layers, exp["layer_selection"])
print(f" Selected layers: {selected}")
# Step 4: Apply projection
apply_projection(
model, selected, ext_subs,
regularization=exp["regularization"],
norm_preserve=exp["norm_preserve"],
multi_dir_norm_fix=exp["multi_dir_norm_fix"],
)
# ── Measure results ──────────────────────────────────────────────
# Direction recovery: cosine similarity between extracted and planted
cos_sims = []
for idx in target_layers:
if idx in ext_dirs and idx in planted_dirs:
cos = F.cosine_similarity(
ext_dirs[idx].unsqueeze(0),
planted_dirs[idx].unsqueeze(0),
).item()
cos_sims.append(abs(cos)) # direction or anti-direction
avg_cos = sum(cos_sims) / len(cos_sims) if cos_sims else 0.0
# Multi-direction subspace recovery: for n_directions>1, measure
# what fraction of the planted subspace is captured
subspace_recovery = []
for idx in target_layers:
if idx in ext_subs and idx in planted_subs:
# Project each planted direction onto extracted subspace
ext_sub = ext_subs[idx] # (k_ext, hidden)
plant_sub = planted_subs[idx] # (k_plant, hidden)
for pi in range(min(plant_sub.shape[0], ext_sub.shape[0])):
# Projection of planted_i onto extracted subspace
proj = ext_sub @ plant_sub[pi] # (k_ext,)
captured = proj.norm().item() # how much is in the subspace
subspace_recovery.append(captured)
avg_subspace = sum(subspace_recovery) / len(subspace_recovery) if subspace_recovery else 0.0
# Residual after projection
post_residuals = {}
for idx in target_layers:
if idx in selected:
post_residuals[idx] = measure_residual_direction(model, idx, planted_dirs[idx])
else:
post_residuals[idx] = pre_residuals[idx] # layer wasn't modified
avg_removal = 0.0
removal_scores = []
for idx in target_layers:
pre = pre_residuals[idx]
post = post_residuals[idx]
if pre > 0:
removal = 1.0 - (post / pre)
removal_scores.append(removal)
avg_removal = sum(removal_scores) / len(removal_scores) if removal_scores else 0.0
# Multi-direction residual: check ALL planted directions
multi_dir_removal = []
for idx in target_layers:
if idx not in selected:
continue
for di in range(planted_subs[idx].shape[0]):
d = planted_subs[idx][di]
pre = measure_residual_direction(
# Need pre-values - approximate from signal_strength
model, idx, d,
)
# Compare to signal strength
multi_dir_removal.append(pre)
avg_multi_residual = sum(multi_dir_removal) / len(multi_dir_removal) if multi_dir_removal else 0.0
# Layer selection accuracy
correct_selected = len(set(selected) & set(target_layers))
false_selected = len(set(selected) - set(target_layers))
missed = len(set(target_layers) - set(selected))
# Capability preservation: Frobenius distance of weights
new_state = model.state_dict()
total_dist = 0.0
for key in original_state:
diff = (new_state[key].float() - original_state[key].float())
total_dist += diff.norm().item() ** 2
total_dist = math.sqrt(total_dist)
# Perplexity proxy: loss on random sequences
losses = []
for _ in range(10):
input_ids = torch.randint(0, vocab_size, (1, seq_len))
with torch.no_grad():
out = model(input_ids, labels=input_ids)
losses.append(out.loss.item())
avg_loss = sum(losses) / len(losses)
ppl = math.exp(min(avg_loss, 100.0))
elapsed = time.time() - t0
result = {
"name": exp["name"],
"source": exp["source"],
"n_directions": exp["n_directions"],
"regularization": exp["regularization"],
"norm_preserve": exp["norm_preserve"],
"direction_recovery": round(avg_cos, 4),
"subspace_recovery": round(avg_subspace, 4),
"primary_removal": round(avg_removal, 4),
"multi_dir_avg_residual": round(avg_multi_residual, 4),
"layers_correct": correct_selected,
"layers_false_positive": false_selected,
"layers_missed": missed,
"n_layers_selected": len(selected),
"weight_distance": round(total_dist, 2),
"perplexity": round(ppl, 2),
"time_seconds": round(elapsed, 2),
}
results.append(result)
print(f" Direction recovery: {avg_cos:.3f} (cosine sim to ground truth)")
print(f" Subspace recovery: {avg_subspace:.3f} (planted dirs captured)")
print(f" Primary dir removal: {avg_removal:.1%} (refusal signal removed)")
print(f" Multi-dir avg residual: {avg_multi_residual:.3f} (lower = better)")
print(f" Layer selection: {correct_selected}/{len(target_layers)} correct, "
f"{false_selected} false+, {missed} missed")
print(f" Weight distance: {total_dist:.2f} (capability delta)")
print(f" Perplexity: {ppl:.2f}")
del model
gc.collect()
return results
def print_table(results: list[dict]):
"""Print formatted comparison tables."""
# ── Table 1: Direction Extraction Quality ──────────────────────────
print(f"\n\n{'='*100}")
print("TABLE 1: DIRECTION EXTRACTION & REMOVAL QUALITY")
print(f"{'='*100}")
print(f"{'Technique':<38} {'Source':<14} {'DirRecov':>9} {'SubRecov':>9} "
f"{'Removal':>8} {'Residual':>9}")
print(f"{''*38} {''*14} {''*9} {''*9} {''*8} {''*9}")
for r in results:
name = r["name"][:37]
source = r["source"][:13]
dr = f"{r['direction_recovery']:.3f}"
sr = f"{r['subspace_recovery']:.3f}"
rm = f"{r['primary_removal']:.1%}"
res = f"{r['multi_dir_avg_residual']:.3f}"
print(f"{name:<38} {source:<14} {dr:>9} {sr:>9} {rm:>8} {res:>9}")
# ── Table 2: Layer Selection & Capability ──────────────────────────
print(f"\n{'='*100}")
print("TABLE 2: LAYER SELECTION & CAPABILITY PRESERVATION")
print(f"{'='*100}")
print(f"{'Technique':<38} {'Layers':>7} {'Correct':>8} {'FalsePos':>9} "
f"{'Missed':>7} {'WeightΔ':>8} {'PPL':>8}")
print(f"{''*38} {''*7} {''*8} {''*9} {''*7} {''*8} {''*8}")
for r in results:
name = r["name"][:37]
print(f"{name:<38} {r['n_layers_selected']:>7} {r['layers_correct']:>8} "
f"{r['layers_false_positive']:>9} {r['layers_missed']:>7} "
f"{r['weight_distance']:>8.2f} {r['perplexity']:>8.2f}")
# ── Table 3: Literature Comparison ────────────────────────────────
print(f"\n\n{'='*110}")
print("TABLE 3: FULL LANDSCAPE — TECHNIQUES, CAPABILITIES, AND REPORTED RESULTS")
print(f"{'='*110}")
print(f"{'Technique':<26} {'Year':>5} {'#Dir':>5} {'Layers':>10} {'NormPres':>9} "
f"{'Reg':>5} {'AutoTune':>9} {'Reported Refusal→':>18} {'Model':>14}")
print(f"{''*26} {''*5} {''*5} {''*10} {''*9} {''*5} {''*9} {''*18} {''*14}")
literature = [
("Arditi et al.", "2024", "1", "top-norm", "No", "0.0", "No",
"~95%→~0%", "Llama-3-8B"),
("FailSpy/abliterator", "2024", "1", "mid-60%", "No", "0.0", "No",
"~90%→~5%", "Llama-3-8B"),
("mlabonne tutorial", "2024", "1", "top-norm", "No", "0.0", "No",
"~90%→~5%", "Llama-3-8B"),
("Gabliteration", "2024", "4-8", "knee", "No", "0.0", "No",
"~95%→~0%", "Various 7B+"),
("grimjim norm-pres", "2024", "4-8", "knee", "Yes(bug)", "0.0", "No",
"~90%→~5%", "Various 7B+"),
("Heretic (p-e-w)", "2025", "float", "kernel", "No", "TPE", "Yes",
"~95%→~0%*", "Gemma-3-12B"),
("Wollschlager cones", "2025", "1-5", "per-layer", "", "", "RDO",
"~98%→~1%", "Llama-3.1-8B"),
("OBLITERATUS basic", "2025", "1", "knee", "No", "0.0", "No",
"~95%→60%**", "Qwen-0.5B"),
("OBLITERATUS advanced", "2025", "4", "knee", "Yes(fix)", "0.3", "No",
"~95%→73%**", "Qwen-0.5B"),
("OBLITERATUS surgical", "2025", "8", "knee", "Yes(fix)", "0.0", "Yes***",
"~95%→0%/broken", "Qwen-0.5B"),
]
for row in literature:
print(f"{row[0]:<26} {row[1]:>5} {row[2]:>5} {row[3]:>10} {row[4]:>9} "
f"{row[5]:>5} {row[6]:>9} {row[7]:>18} {row[8]:>14}")
print("\n * Heretic: 2.8× lower KL divergence than manual abliterations (Gemma-3-12B benchmark)")
print(" ** Our observed results on Qwen2.5-0.5B-Instruct — 0.5B may be too small for linear methods")
print(" *** Surgical combines: whitened SVD + SAE + head surgery + neuron masking + jailbreak contrast")
print(f"{'='*110}")
# ── Analysis ──────────────────────────────────────────────────────
print(f"\n{'='*80}")
print("ANALYSIS: WHY OBLITERATUS UNDERPERFORMS AND WHAT TO FIX")
print(f"{'='*80}")
print("""
ROOT CAUSES (ordered by impact):
1. MODEL SIZE: All published abliteration results use 7B+ models
- Arditi et al.: Llama-3-8B, Gemma-2-9B (hidden_dim=4096+)
- FailSpy: Llama-3-8B
- Heretic: Gemma-3-12B (headline benchmark)
- Wollschlager et al.: Llama-3.1-8B
- OBLITERATUS benchmarks: Qwen-0.5B (hidden_dim=896)
The "single refusal direction" hypothesis may not hold well for small
models. Wollschlager et al. (ICML 2025) showed that refusal lives in
multi-dimensional CONCEPT CONES, and cone dimension scales with model
size. A 0.5B model may encode refusal too diffusely for linear methods.
2. BASIC MODE USES NO CHAT TEMPLATE for activation collection
- The model was trained with chat formatting — without it, activations
during probing don't reflect actual refusal behavior
- This is the single highest-impact config fix
3. ADVANCED MODE REGULARIZATION TOO HIGH (0.3)
- Preserves 30% of refusal component by design
- Combined with 4 directions where later ones capture noise, net
removal is weak
4. SURGICAL MODE DOES TOO MUCH
- 8 directions, whitened SVD, SAE features, neuron masking, head surgery
- Each individually reasonable; together they destroy a 0.5B model
- The whitened SVD un-whitening bug (now fixed) was extracting noise
5. NO BAYESIAN OPTIMIZATION (vs Heretic)
- Heretic's key insight: jointly optimize layer weights, direction
index, and component-specific parameters via TPE
- Minimizes refusal rate AND KL divergence simultaneously
- This automatically handles model-specific tuning that we do manually
RECOMMENDED CONFIG CHANGES:
- basic: use_chat_template → True
- advanced: regularization → 0.1 (from 0.3)
- surgical: n_directions → 4 (from 8), disable safety_neuron_masking
- ALL: Add model-size-aware defaults (n_dirs=1 for <2B, 4 for 2-10B)
- NEW: Add TPE optimization loop (like Heretic) as "optimized" method
""")
def main():
results = run_experiment()
print_table(results)
# Save results
out_path = "/tmp/abliteration_comparison_results.json"
with open(out_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nResults saved to {out_path}")
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Aggregate community contributions into paper-ready tables.
Usage:
python scripts/aggregate_contributions.py [--dir community_results] [--format latex|csv|json]
Reads all contribution JSON files from the specified directory, aggregates
them by model and method, and outputs summary tables suitable for inclusion
in the paper.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from obliteratus.community import (
aggregate_results,
generate_latex_table,
load_contributions,
)
def main():
parser = argparse.ArgumentParser(
description="Aggregate community contributions into paper tables."
)
parser.add_argument(
"--dir",
default="community_results",
help="Directory containing contribution JSON files (default: community_results)",
)
parser.add_argument(
"--format",
choices=["latex", "csv", "json", "summary"],
default="summary",
help="Output format (default: summary)",
)
parser.add_argument(
"--metric",
default="refusal_rate",
help="Metric to display in tables (default: refusal_rate)",
)
parser.add_argument(
"--methods",
nargs="*",
help="Methods to include (default: all)",
)
parser.add_argument(
"--min-runs",
type=int,
default=1,
help="Minimum runs per (model, method) to include (default: 1)",
)
args = parser.parse_args()
# Load all contributions
records = load_contributions(args.dir)
if not records:
print(f"No contributions found in {args.dir}/", file=sys.stderr)
sys.exit(1)
print(f"Loaded {len(records)} contribution(s) from {args.dir}/", file=sys.stderr)
# Aggregate
aggregated = aggregate_results(records)
# Filter by minimum runs
if args.min_runs > 1:
for model in list(aggregated.keys()):
for method in list(aggregated[model].keys()):
if aggregated[model][method]["n_runs"] < args.min_runs:
del aggregated[model][method]
if not aggregated[model]:
del aggregated[model]
if not aggregated:
print("No results meet the minimum run threshold.", file=sys.stderr)
sys.exit(1)
# Output
if args.format == "summary":
_print_summary(aggregated, args.metric)
elif args.format == "latex":
print(generate_latex_table(aggregated, methods=args.methods, metric=args.metric))
elif args.format == "json":
print(json.dumps(aggregated, indent=2))
elif args.format == "csv":
_print_csv(aggregated, args.metric)
def _print_summary(aggregated: dict, metric: str):
"""Print a human-readable summary of aggregated results."""
total_runs = sum(
data["n_runs"]
for model_data in aggregated.values()
for data in model_data.values()
)
n_models = len(aggregated)
n_methods = len(set(
method
for model_data in aggregated.values()
for method in model_data
))
print(f"\n{'=' * 70}")
print("Community Contribution Summary")
print(f"{'=' * 70}")
print(f" Total runs: {total_runs}")
print(f" Models: {n_models}")
print(f" Methods: {n_methods}")
print()
for model in sorted(aggregated.keys()):
model_data = aggregated[model]
short = model.split("/")[-1] if "/" in model else model
print(f" {short}:")
for method in sorted(model_data.keys()):
data = model_data[method]
n = data["n_runs"]
if metric in data:
stats = data[metric]
mean = stats["mean"]
std = stats["std"]
if std > 0 and n > 1:
print(f" {method:20s} {metric}={mean:.2f} ± {std:.2f} (n={n})")
else:
print(f" {method:20s} {metric}={mean:.2f} (n={n})")
else:
print(f" {method:20s} (no {metric} data, n={n})")
print()
print(f"{'=' * 70}")
print(f"To generate LaTeX: python {sys.argv[0]} --format latex")
print(f"To generate CSV: python {sys.argv[0]} --format csv")
def _print_csv(aggregated: dict, metric: str):
"""Print results as CSV."""
print("model,method,n_runs,mean,std,min,max")
for model in sorted(aggregated.keys()):
for method in sorted(aggregated[model].keys()):
data = aggregated[model][method]
n = data["n_runs"]
if metric in data:
stats = data[metric]
print(
f"{model},{method},{n},"
f"{stats['mean']:.4f},{stats['std']:.4f},"
f"{stats['min']:.4f},{stats['max']:.4f}"
)
if __name__ == "__main__":
main()
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""OBLITERATUS GPT-OSS 20B Benchmark — Full Method Comparison.
Runs all abliteration methods on openai/gpt-oss-20b and produces a
comprehensive comparison table with:
- Refusal rate (primary metric)
- KL divergence / perplexity (capability preservation)
- Capability probes (knowledge, truthfulness, math reasoning)
- MoE-specific metrics (EGA expert directions, router stability)
- Timing and GPU memory usage
Usage:
python scripts/benchmark_gpt_oss_20b.py
python scripts/benchmark_gpt_oss_20b.py --methods basic surgical optimized nuclear
python scripts/benchmark_gpt_oss_20b.py --prompts 50 --output results.json
python scripts/benchmark_gpt_oss_20b.py --quick # fast mode: 20 prompts, skip slow methods
Designed for T4 16GB (auto 4-bit quantization) or A10G+ (float16).
"""
from __future__ import annotations
import argparse
import gc
import json
import os
import shutil
import sys
import time
from pathlib import Path
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import torch
# Ensure the project root is on sys.path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
from obliteratus.abliterate import ( # noqa: E402
AbliterationPipeline,
METHODS,
HARMFUL_PROMPTS,
HARMLESS_PROMPTS,
)
from obliteratus.evaluation.benchmarks import BenchmarkRunner, format_benchmark_report # noqa: E402
def parse_args():
parser = argparse.ArgumentParser(description="OBLITERATUS GPT-OSS 20B Benchmark")
parser.add_argument(
"--model", default="openai/gpt-oss-20b",
help="Model to benchmark (default: openai/gpt-oss-20b)",
)
parser.add_argument(
"--methods", nargs="+",
default=["basic", "advanced", "surgical", "optimized", "inverted", "nuclear"],
help="Methods to compare",
)
parser.add_argument(
"--prompts", type=int, default=33,
help="Number of prompts per side (harmful/harmless)",
)
parser.add_argument(
"--output", type=str, default=None,
help="Save results JSON to this path",
)
parser.add_argument(
"--quick", action="store_true",
help="Quick mode: 20 prompts, skip aggressive/inverted",
)
parser.add_argument(
"--skip-benchmarks", action="store_true",
help="Skip capability benchmark probes (faster)",
)
parser.add_argument(
"--output-dir", default="/tmp/obliteratus_bench",
help="Directory for temporary model outputs",
)
parser.add_argument(
"--bayesian-trials", type=int, default=30,
help="Number of Bayesian optimization trials for 'optimized' method",
)
return parser.parse_args()
def gpu_info() -> dict:
"""Get GPU information."""
if not torch.cuda.is_available():
return {"gpu": "CPU only", "total_gb": 0, "free_gb": 0}
return {
"gpu": torch.cuda.get_device_name(0),
"total_gb": round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1),
"free_gb": round(torch.cuda.mem_get_info(0)[0] / 1e9, 1),
}
def cleanup():
"""Force GPU memory cleanup."""
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
def run_single_method(
model_name: str,
method: str,
harmful: list[str],
harmless: list[str],
output_dir: str,
run_benchmarks: bool = True,
bayesian_trials: int = 30,
) -> dict:
"""Run a single abliteration method and collect metrics."""
cleanup()
outdir = f"{output_dir}/{method}"
t0 = time.time()
pipeline = None
result = {
"model": model_name,
"method": method,
"label": METHODS.get(method, {}).get("label", method),
}
try:
# For the optimized method, we might want to control trial count
if method == "optimized":
# Temporarily patch bayesian_trials in the method config
METHODS["optimized"]["bayesian_trials"] = bayesian_trials
pipeline = AbliterationPipeline(
model_name=model_name,
output_dir=outdir,
device="auto",
dtype="float16",
method=method,
harmful_prompts=harmful,
harmless_prompts=harmless,
on_log=lambda msg: print(f" {msg}"),
)
pipeline.run()
elapsed = time.time() - t0
result.update({
"time_seconds": round(elapsed, 1),
"quality": dict(pipeline._quality_metrics),
"strong_layers": pipeline._strong_layers,
"n_strong_layers": len(pipeline._strong_layers),
"n_directions": pipeline.n_directions,
})
# MoE-specific metrics
if pipeline._expert_directions:
n_expert_dirs = sum(len(d) for d in pipeline._expert_directions.values())
result["ega_expert_dirs"] = n_expert_dirs
result["ega_layers"] = len(pipeline._expert_directions)
if pipeline._expert_safety_scores:
result["expert_classified_layers"] = len(pipeline._expert_safety_scores)
if pipeline._cot_preserve_directions:
result["cot_preserved_layers"] = len(pipeline._cot_preserve_directions)
if pipeline._float_layer_weights:
result["float_layer_weights"] = {
str(k): round(v, 3) for k, v in pipeline._float_layer_weights.items()
}
if pipeline._kl_contributions:
result["kl_contributions"] = {
str(k): round(v, 6) for k, v in pipeline._kl_contributions.items()
}
if pipeline._lora_adapters:
result["lora_adapters"] = len(pipeline._lora_adapters)
if pipeline._steering_hooks:
result["steering_hooks"] = len(pipeline._steering_hooks)
# GPU memory
if torch.cuda.is_available():
result["peak_gpu_mb"] = round(torch.cuda.max_memory_allocated() / 1e6, 1)
# Capability benchmarks (optional)
if run_benchmarks:
print("\n Running capability benchmarks...")
try:
runner = BenchmarkRunner(
pipeline.handle.model,
pipeline.handle.tokenizer,
)
bench_results = runner.run_all()
result["benchmarks"] = {
name: {
"score": round(br.score, 3),
"n_correct": br.n_correct,
"n_total": br.n_total,
"per_category": {
k: round(v, 3) for k, v in br.per_category.items()
},
}
for name, br in bench_results.items()
}
report = format_benchmark_report(bench_results)
print(f"\n{report}")
except Exception as e:
print(f" Benchmark probes failed: {e}")
result["benchmarks"] = {"error": str(e)}
print(f"\n === {method} complete in {elapsed:.1f}s ===")
print(f" Quality: {json.dumps(pipeline._quality_metrics, default=str)}")
except Exception as e:
elapsed = time.time() - t0
result.update({
"time_seconds": round(elapsed, 1),
"error": str(e),
})
print(f"\n === {method} FAILED after {elapsed:.1f}s: {e} ===")
import traceback
traceback.print_exc()
# Cleanup saved model to free disk
shutil.rmtree(outdir, ignore_errors=True)
if pipeline is not None:
del pipeline
cleanup()
return result
def print_summary_table(results: list[dict]):
"""Print a formatted comparison table."""
print(f"\n{'='*90}")
print("BENCHMARK RESULTS SUMMARY")
print(f"{'='*90}")
# Header
header = (
f"{'Method':<12} {'Time':>7} {'PPL':>8} {'Coher':>7} "
f"{'Refusal':>8} {'Know':>6} {'Truth':>6} {'Math':>6} "
f"{'EGA':>5} {'CoT':>4} {'GPU MB':>7}"
)
print(header)
print("-" * len(header))
for r in results:
method = r["method"]
time_s = f"{r['time_seconds']:.0f}s" if "time_seconds" in r else "N/A"
if "error" in r:
print(f"{method:<12} {time_s:>7} {'FAILED':>8}")
continue
q = r.get("quality", {})
ppl = q.get("perplexity")
coh = q.get("coherence")
ref = q.get("refusal_rate")
gpu = r.get("peak_gpu_mb")
# Benchmark scores
bench = r.get("benchmarks", {})
know = bench.get("knowledge", {}).get("score")
truth = bench.get("truthfulness", {}).get("score")
math = bench.get("math_reasoning", {}).get("score")
# MoE metrics
ega = r.get("ega_expert_dirs", "")
cot = r.get("cot_preserved_layers", "")
ppl_s = f"{ppl:.1f}" if ppl is not None else "N/A"
coh_s = f"{coh:.0%}" if coh is not None else "N/A"
ref_s = f"{ref:.0%}" if ref is not None else "N/A"
know_s = f"{know:.0%}" if know is not None else "N/A"
truth_s = f"{truth:.0%}" if truth is not None else "N/A"
math_s = f"{math:.0%}" if math is not None else "N/A"
gpu_s = f"{gpu:.0f}" if gpu is not None else "N/A"
ega_s = str(ega) if ega else "-"
cot_s = str(cot) if cot else "-"
print(
f"{method:<12} {time_s:>7} {ppl_s:>8} {coh_s:>7} "
f"{ref_s:>8} {know_s:>6} {truth_s:>6} {math_s:>6} "
f"{ega_s:>5} {cot_s:>4} {gpu_s:>7}"
)
print(f"{'='*90}")
# Legend
print("\nLegend:")
print(" PPL = Perplexity (lower = better capability preservation)")
print(" Coher = Coherence score (higher = more coherent text)")
print(" Refusal = Refusal rate on harmful prompts (lower = more abliterated)")
print(" Know = MMLU-style knowledge probe")
print(" Truth = TruthfulQA-style truthfulness probe")
print(" Math = GSM8K-style math reasoning probe")
print(" EGA = Expert-Granular Abliteration directions computed")
print(" CoT = Layers where CoT reasoning was preserved")
print(" GPU MB = Peak GPU memory usage")
def main():
args = parse_args()
if args.quick:
args.prompts = 20
args.methods = [m for m in args.methods if m not in ("aggressive", "inverted")]
args.bayesian_trials = 15
gpu = gpu_info()
harmful = HARMFUL_PROMPTS[:args.prompts]
harmless = HARMLESS_PROMPTS[:args.prompts]
print("=" * 60)
print(" OBLITERATUS GPT-OSS 20B BENCHMARK")
print("=" * 60)
print(f" Model: {args.model}")
print(f" Methods: {args.methods}")
print(f" Prompts: {args.prompts} per side")
print(f" GPU: {gpu['gpu']} ({gpu['total_gb']} GB total, {gpu['free_gb']} GB free)")
print(f" Benchmarks: {'skip' if args.skip_benchmarks else 'enabled'}")
if "optimized" in args.methods:
print(f" Bayesian: {args.bayesian_trials} trials")
print("=" * 60)
all_results = []
for method in args.methods:
if method not in METHODS:
print(f"\nSKIP: unknown method '{method}'")
continue
print(f"\n{''*60}")
print(f" METHOD: {method}{METHODS[method]['label']}")
print(f"{''*60}")
result = run_single_method(
model_name=args.model,
method=method,
harmful=harmful,
harmless=harmless,
output_dir=args.output_dir,
run_benchmarks=not args.skip_benchmarks,
bayesian_trials=args.bayesian_trials,
)
all_results.append(result)
# Summary
print_summary_table(all_results)
# Save JSON
output_path = args.output or f"benchmark_gpt_oss_{int(time.time())}.json"
with open(output_path, "w") as f:
json.dump(all_results, f, indent=2, default=str)
print(f"\nFull results saved to: {output_path}")
if __name__ == "__main__":
main()
+561
View File
@@ -0,0 +1,561 @@
#!/usr/bin/env python3
"""OBLITERATUS vs SOTA — Head-to-Head Benchmark Comparison.
Runs faithful reproductions of competing abliteration methods against
OBLITERATUS variants on any specified model, producing publication-ready
comparison tables with standardized community metrics.
Baselines included:
1. FailSpy/abliterator (2024) — Community workhorse baseline
2. Gabliteration (Gulmez 2025) — SVD multi-direction + ridge regularization
3. Heretic / p-e-w (2025) — Bayesian TPE auto-tuning (current SOTA for quality)
4. Wollschlager RDO (ICML 2025) — Gradient-based direction optimization
OBLITERATUS variants:
5. OBLITERATUS surgical — Full SOTA MoE-aware pipeline
6. OBLITERATUS informed — Analysis-guided auto-configuration
7. OBLITERATUS optimized — Bayesian + whitened SVD + SAE (max OBLITERATUS)
Evaluation protocol (Heretic community standard):
- Refusal rate via substring + prefix detection
- First-token KL divergence on harmless prompts
- Capability probes (knowledge, truthfulness, math reasoning)
- Optional: HarmBench ASR, lm-eval-harness benchmarks
Usage:
# Quick comparison (small model, few prompts)
python scripts/benchmark_sota_comparison.py --model Qwen/Qwen2.5-1.5B-Instruct --quick
# Full comparison on 8B model
python scripts/benchmark_sota_comparison.py --model meta-llama/Llama-3.1-8B-Instruct
# Specific baselines only
python scripts/benchmark_sota_comparison.py --methods failspy heretic surgical
# Custom prompt count and output
python scripts/benchmark_sota_comparison.py --prompts 100 --output results.json
# Include full Heretic evaluation protocol (HarmBench, lm-eval)
python scripts/benchmark_sota_comparison.py --full-eval
"""
from __future__ import annotations
import argparse
import gc
import json
import os
import shutil
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import torch
# Ensure the project root is on sys.path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
from obliteratus.abliterate import ( # noqa: E402
AbliterationPipeline,
METHODS,
HARMFUL_PROMPTS,
HARMLESS_PROMPTS,
)
from obliteratus.evaluation.benchmarks import BenchmarkRunner # noqa: E402
# ── All methods available for comparison ──────────────────────────────
# Baselines (reproductions of competing methods)
BASELINE_METHODS = ["failspy", "gabliteration", "heretic", "rdo"]
# OBLITERATUS variants
OBLITERATUS_METHODS = ["surgical", "informed", "optimized"]
# Default comparison set
DEFAULT_METHODS = BASELINE_METHODS + OBLITERATUS_METHODS
# Quick mode: skip slow methods (Bayesian optimization)
QUICK_METHODS = ["failspy", "gabliteration", "rdo", "surgical"]
@dataclass
class MethodResult:
"""Results for a single method run."""
method: str
label: str
refusal_rate: float = 0.0
kl_divergence: float = 0.0
knowledge_score: float = 0.0
truthfulness_score: float = 0.0
math_score: float = 0.0
ablation_time_s: float = 0.0
peak_gpu_mb: float = 0.0
n_layers_modified: int = 0
n_projections: int = 0
error: str | None = None
def parse_args():
parser = argparse.ArgumentParser(
description="OBLITERATUS vs SOTA — Head-to-Head Benchmark",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--model", default="Qwen/Qwen2.5-1.5B-Instruct",
help="Model to benchmark (default: Qwen/Qwen2.5-1.5B-Instruct)",
)
parser.add_argument(
"--methods", nargs="+", default=None,
help=f"Methods to compare (default: all). Available: {', '.join(DEFAULT_METHODS)}",
)
parser.add_argument(
"--prompts", type=int, default=None,
help="Number of prompts for abliteration (default: auto based on --quick)",
)
parser.add_argument(
"--eval-prompts", type=int, default=20,
help="Number of prompts for refusal rate evaluation (default: 20)",
)
parser.add_argument(
"--kl-prompts", type=int, default=10,
help="Number of harmless prompts for KL divergence (default: 10)",
)
parser.add_argument(
"--output", default=None,
help="Output JSON file for results (default: stdout only)",
)
parser.add_argument(
"--quick", action="store_true",
help="Quick mode: fewer prompts, skip slow methods (Bayesian opt)",
)
parser.add_argument(
"--full-eval", action="store_true",
help="Run full Heretic evaluation protocol (HarmBench ASR, lm-eval)",
)
parser.add_argument(
"--quantization", default=None, choices=["4bit", "8bit"],
help="Quantization for model loading (default: auto)",
)
parser.add_argument(
"--device", default="auto",
help="Device (default: auto)",
)
parser.add_argument(
"--dtype", default="float16",
help="Data type (default: float16)",
)
parser.add_argument(
"--output-dir", default="/tmp/obliteratus_benchmark",
help="Directory for abliterated model outputs (default: /tmp/obliteratus_benchmark)",
)
parser.add_argument(
"--keep-models", action="store_true",
help="Keep abliterated models after benchmark (default: delete to save disk)",
)
return parser.parse_args()
def measure_refusal_rate(
pipeline: AbliterationPipeline,
prompts: list[str],
max_new_tokens: int = 128,
) -> float:
"""Measure refusal rate on a set of prompts."""
from obliteratus.evaluation.advanced_metrics import refusal_rate
model = pipeline.handle.model
tokenizer = pipeline.handle.tokenizer
responses = []
for prompt in prompts:
try:
if pipeline.use_chat_template and hasattr(tokenizer, "apply_chat_template"):
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
)
else:
text = prompt
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
inputs = {k: v.to(model.device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
temperature=1.0,
pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id,
)
response = tokenizer.decode(
outputs[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True,
)
responses.append(response)
except Exception as e:
responses.append(f"[ERROR: {e}]")
return refusal_rate(responses, mode="combined")
def measure_kl_divergence(
pipeline: AbliterationPipeline,
original_logits: dict[int, torch.Tensor],
prompts: list[str],
) -> float:
"""Measure first-token KL divergence vs original model logits."""
import torch.nn.functional as F
model = pipeline.handle.model
tokenizer = pipeline.handle.tokenizer
kl_values = []
for i, prompt in enumerate(prompts):
if i not in original_logits:
continue
try:
if pipeline.use_chat_template and hasattr(tokenizer, "apply_chat_template"):
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
)
else:
text = prompt
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
inputs = {k: v.to(model.device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model(**inputs)
new_logits = outputs.logits[0, -1, :].float().cpu()
orig = original_logits[i].float()
log_p = F.log_softmax(orig, dim=-1)
log_q = F.log_softmax(new_logits, dim=-1)
kl = F.kl_div(log_q, log_p.exp(), reduction="sum").item()
if kl >= 0: # KL should be non-negative
kl_values.append(kl)
except Exception:
pass
return sum(kl_values) / len(kl_values) if kl_values else float("nan")
def collect_baseline_logits(
pipeline: AbliterationPipeline,
prompts: list[str],
) -> dict[int, torch.Tensor]:
"""Collect first-token logits from the original (pre-abliteration) model."""
model = pipeline.handle.model
tokenizer = pipeline.handle.tokenizer
logits = {}
for i, prompt in enumerate(prompts):
try:
if pipeline.use_chat_template and hasattr(tokenizer, "apply_chat_template"):
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
)
else:
text = prompt
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
inputs = {k: v.to(model.device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model(**inputs)
logits[i] = outputs.logits[0, -1, :].float().cpu()
except Exception:
pass
return logits
def run_single_method(
model_name: str,
method: str,
harmful_prompts: list[str],
harmless_prompts: list[str],
eval_harmful: list[str],
eval_harmless: list[str],
args: argparse.Namespace,
) -> MethodResult:
"""Run a single abliteration method and collect metrics."""
label = METHODS.get(method, {}).get("label", method)
result = MethodResult(method=method, label=label)
print(f"\n{'='*70}")
print(f" Method: {label}")
print(f"{'='*70}")
output_dir = Path(args.output_dir) / method
try:
# Track GPU memory
if torch.cuda.is_available():
torch.cuda.reset_peak_memory_stats()
t0 = time.time()
# Build pipeline with method-specific config
# For 'informed', use InformedAbliterationPipeline
if method == "informed":
from obliteratus.informed_pipeline import InformedAbliterationPipeline
pipeline = InformedAbliterationPipeline(
model_name=model_name,
output_dir=str(output_dir),
device=args.device,
dtype=args.dtype,
quantization=args.quantization,
harmful_prompts=harmful_prompts,
harmless_prompts=harmless_prompts,
on_log=lambda msg: print(f" {msg}"),
)
else:
pipeline = AbliterationPipeline(
model_name=model_name,
output_dir=str(output_dir),
device=args.device,
dtype=args.dtype,
method=method,
quantization=args.quantization,
harmful_prompts=harmful_prompts,
harmless_prompts=harmless_prompts,
use_chat_template=True,
on_log=lambda msg: print(f" {msg}"),
)
# Phase 1: Load model + collect baseline KL logits
print(" Loading model...")
pipeline._summon()
print(" Collecting baseline logits for KL divergence...")
baseline_logits = collect_baseline_logits(pipeline, eval_harmless)
# Phase 2: Run abliteration pipeline
print(" Probing activations...")
pipeline._probe()
print(" Extracting refusal directions...")
pipeline._distill()
result.n_layers_modified = len(pipeline._strong_layers)
print(f" Excising refusal ({result.n_layers_modified} layers)...")
pipeline._excise()
result.ablation_time_s = time.time() - t0
# Track GPU memory
if torch.cuda.is_available():
result.peak_gpu_mb = torch.cuda.max_memory_allocated() / 1e6
# Phase 3: Evaluate
print(f" Evaluating refusal rate ({len(eval_harmful)} prompts)...")
result.refusal_rate = measure_refusal_rate(pipeline, eval_harmful)
print(f" Evaluating KL divergence ({len(eval_harmless)} prompts)...")
result.kl_divergence = measure_kl_divergence(pipeline, baseline_logits, eval_harmless)
# Capability probes
print(" Running capability probes...")
try:
runner = BenchmarkRunner(
pipeline.handle.model,
pipeline.handle.tokenizer,
)
bench_result = runner.run_all()
result.knowledge_score = bench_result.knowledge.accuracy if bench_result.knowledge else 0.0
result.truthfulness_score = bench_result.truthfulness.accuracy if bench_result.truthfulness else 0.0
result.math_score = bench_result.math.accuracy if bench_result.math else 0.0
except Exception as e:
print(f" Warning: capability probes failed: {e}")
# Optional: full Heretic evaluation
if args.full_eval:
print(" Running full Heretic evaluation protocol...")
try:
from obliteratus.evaluation.heretic_eval import run_full_heretic_eval
heretic_result = run_full_heretic_eval(
model=pipeline.handle.model,
tokenizer=pipeline.handle.tokenizer,
original_model=None, # Would need original for full comparison
)
print(f" Heretic eval: ASR={heretic_result.harmbench_asr:.1%}, "
f"JB_refusal={heretic_result.jailbreakbench_refusal_rate:.1%}")
except Exception as e:
print(f" Warning: Heretic eval failed: {e}")
print(f" ✓ Complete: refusal={result.refusal_rate:.1%}, KL={result.kl_divergence:.4f}, "
f"time={result.ablation_time_s:.1f}s")
except Exception as e:
result.error = str(e)
print(f" ✗ FAILED: {e}")
import traceback
traceback.print_exc()
finally:
# Clean up to free GPU memory for next method
if not args.keep_models and output_dir.exists():
shutil.rmtree(output_dir, ignore_errors=True)
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
return result
def format_comparison_table(results: list[MethodResult]) -> str:
"""Format results as a publication-ready comparison table."""
lines = []
# Header
lines.append("")
lines.append("=" * 115)
lines.append("OBLITERATUS vs SOTA — Head-to-Head Benchmark Comparison")
lines.append("=" * 115)
lines.append("")
# Separator between baselines and OBLITERATUS
lines.append(f"{'Method':<35} {'Refusal↓':>10} {'KL↓':>10} {'Know↑':>8} {'Truth↑':>8} {'Math↑':>8} {'Time':>8} {'Layers':>7}")
lines.append("-" * 115)
# Baselines first
baseline_results = [r for r in results if r.method in BASELINE_METHODS]
obliteratus_results = [r for r in results if r.method not in BASELINE_METHODS]
if baseline_results:
lines.append(" BASELINES:")
for r in baseline_results:
if r.error:
lines.append(f" {r.label:<33} {'FAILED':>10} {r.error[:60]}")
else:
lines.append(
f" {r.label:<33} {r.refusal_rate:>9.1%} {r.kl_divergence:>10.4f} "
f"{r.knowledge_score:>7.1%} {r.truthfulness_score:>7.1%} {r.math_score:>7.1%} "
f"{r.ablation_time_s:>7.1f}s {r.n_layers_modified:>6}"
)
if obliteratus_results:
lines.append(" OBLITERATUS:")
for r in obliteratus_results:
if r.error:
lines.append(f" {r.label:<33} {'FAILED':>10} {r.error[:60]}")
else:
lines.append(
f" {r.label:<33} {r.refusal_rate:>9.1%} {r.kl_divergence:>10.4f} "
f"{r.knowledge_score:>7.1%} {r.truthfulness_score:>7.1%} {r.math_score:>7.1%} "
f"{r.ablation_time_s:>7.1f}s {r.n_layers_modified:>6}"
)
lines.append("-" * 115)
# Best values
successful = [r for r in results if r.error is None]
if successful:
best_refusal = min(successful, key=lambda r: r.refusal_rate)
best_kl = min(successful, key=lambda r: r.kl_divergence if r.kl_divergence == r.kl_divergence else float("inf"))
best_knowledge = max(successful, key=lambda r: r.knowledge_score)
lines.append(f" Best refusal removal: {best_refusal.label} ({best_refusal.refusal_rate:.1%})")
lines.append(f" Best quality preservation: {best_kl.label} (KL={best_kl.kl_divergence:.4f})")
lines.append(f" Best knowledge retention: {best_knowledge.label} ({best_knowledge.knowledge_score:.1%})")
lines.append("=" * 115)
lines.append("")
# Metric interpretation guide
lines.append("Metrics:")
lines.append(" Refusal↓ = fraction of harmful prompts still refused (lower = more effective abliteration)")
lines.append(" KL↓ = first-token KL divergence on harmless prompts (lower = better quality preservation)")
lines.append(" Know↑ = MMLU-style knowledge probe accuracy (higher = better capability)")
lines.append(" Truth↑ = TruthfulQA-style probe accuracy (higher = better calibration)")
lines.append(" Math↑ = GSM8K-style math reasoning accuracy (higher = better reasoning)")
lines.append("")
return "\n".join(lines)
def main():
args = parse_args()
print("=" * 70)
print(" OBLITERATUS vs SOTA — Head-to-Head Benchmark")
print(f" Model: {args.model}")
print("=" * 70)
# Determine methods to run
methods = args.methods or (QUICK_METHODS if args.quick else DEFAULT_METHODS)
# Validate methods
valid_methods = set(METHODS.keys()) | {"informed"}
for m in methods:
if m not in valid_methods:
print(f"Error: unknown method '{m}'. Available: {sorted(valid_methods)}")
sys.exit(1)
print(f" Methods: {', '.join(methods)}")
# Determine prompt counts
n_prompts = args.prompts or (50 if args.quick else 128)
n_prompts = min(n_prompts, len(HARMFUL_PROMPTS), len(HARMLESS_PROMPTS))
harmful_prompts = HARMFUL_PROMPTS[:n_prompts]
harmless_prompts = HARMLESS_PROMPTS[:n_prompts]
# Evaluation subsets (separate from training prompts for fair comparison)
eval_harmful = HARMFUL_PROMPTS[n_prompts:n_prompts + args.eval_prompts]
if len(eval_harmful) < args.eval_prompts:
# Wrap around if not enough prompts
eval_harmful = HARMFUL_PROMPTS[:args.eval_prompts]
eval_harmless = HARMLESS_PROMPTS[n_prompts:n_prompts + args.kl_prompts]
if len(eval_harmless) < args.kl_prompts:
eval_harmless = HARMLESS_PROMPTS[:args.kl_prompts]
print(f" Abliteration prompts: {n_prompts} harmful + {n_prompts} harmless")
print(f" Evaluation prompts: {len(eval_harmful)} harmful, {len(eval_harmless)} harmless")
print()
# Run each method
results: list[MethodResult] = []
for method in methods:
result = run_single_method(
model_name=args.model,
method=method,
harmful_prompts=harmful_prompts,
harmless_prompts=harmless_prompts,
eval_harmful=eval_harmful,
eval_harmless=eval_harmless,
args=args,
)
results.append(result)
# Print comparison table
table = format_comparison_table(results)
print(table)
# Save results
if args.output:
output_path = Path(args.output)
output_data = {
"model": args.model,
"n_prompts": n_prompts,
"n_eval_harmful": len(eval_harmful),
"n_eval_harmless": len(eval_harmless),
"methods": [asdict(r) for r in results],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(output_data, indent=2, default=str))
print(f"Results saved to {output_path}")
if __name__ == "__main__":
main()
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# OBLITERATUS Remote Benchmark Runner
#
# One-command benchmark on your HuggingFace Space GPU.
#
# Usage:
# ./scripts/run_benchmark_remote.sh # defaults: Qwen 0.5B, all methods
# ./scripts/run_benchmark_remote.sh --model Qwen/Qwen2.5-1.5B-Instruct
# ./scripts/run_benchmark_remote.sh --model openai/gpt-oss-20b
# ./scripts/run_benchmark_remote.sh --models "Qwen/Qwen2.5-0.5B-Instruct openai/gpt-oss-20b"
# ./scripts/run_benchmark_remote.sh --methods "basic advanced surgical"
# ./scripts/run_benchmark_remote.sh --prompts 33 # use 33/66/99 prompts per side
# ./scripts/run_benchmark_remote.sh --dry-run # print the command, don't execute
# ./scripts/run_benchmark_remote.sh --verbose # show SSH debug output
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Defaults ─────────────────────────────────────────────────────────────────
SSH_KEY="${OBLITERATUS_SSH_KEY:-$HOME/.ssh/hf_obliteratus}"
SSH_HOST="${OBLITERATUS_SSH_HOST:-}"
MODEL="${OBLITERATUS_MODEL:-Qwen/Qwen2.5-0.5B-Instruct}"
MODELS=""
METHODS="${OBLITERATUS_METHODS:-basic advanced aggressive surgical inverted nuclear}"
PROMPTS="${OBLITERATUS_PROMPTS:-33}"
DRY_RUN=false
VERBOSE=false
# ── Parse args ───────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--model) MODEL="$2"; MODELS=""; shift 2 ;;
--models) MODELS="$2"; shift 2 ;;
--methods) METHODS="$2"; shift 2 ;;
--prompts) PROMPTS="$2"; shift 2 ;;
--key) SSH_KEY="$2"; shift 2 ;;
--host) SSH_HOST="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--verbose|-v) VERBOSE=true; shift ;;
-h|--help)
head -15 "$0" | tail -11
exit 0
;;
*)
echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# If --models not set, use single --model
if [[ -z "$MODELS" ]]; then
MODELS="$MODEL"
fi
# ── Validate SSH host ──────────────────────────────────────────────────────
if [[ -z "$SSH_HOST" ]]; then
echo "ERROR: SSH_HOST not configured."
echo ""
echo "Set your HF Space SSH host:"
echo " 1. export OBLITERATUS_SSH_HOST=your-username-spacename@ssh.hf.space"
echo " 2. Or pass --host your-username-spacename@ssh.hf.space"
exit 1
fi
# ── Validate SSH key ────────────────────────────────────────────────────────
if [[ ! -f "$SSH_KEY" ]]; then
echo "ERROR: SSH key not found at $SSH_KEY"
echo ""
echo "Either:"
echo " 1. Place your HF Space SSH key at ~/.ssh/hf_obliteratus"
echo " 2. Set OBLITERATUS_SSH_KEY=/path/to/key"
echo " 3. Pass --key /path/to/key"
exit 1
fi
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ OBLITERATUS — Remote GPU Benchmark ║"
echo "╠══════════════════════════════════════════════════════════════╣"
echo "║ Host: $SSH_HOST"
echo "║ Models: $MODELS"
echo "║ Methods: $METHODS"
echo "║ Prompts: $PROMPTS per side"
echo "║ SSH key: $SSH_KEY"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
# ── Build the Python benchmark script to run remotely ────────────────────────
read -r -d '' REMOTE_SCRIPT << 'PYEOF' || true
import json, sys, time, shutil, gc, os
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("CUDA_LAUNCH_BLOCKING", "1")
import torch
import torch.nn as nn
# Add app dir to path (HF Space layout: /home/user/app)
sys.path.insert(0, os.environ.get("APP_DIR", "/home/user/app"))
# ── Hotpatch: fix device detection for accelerate device_map="auto" ──────
# The deployed Space code uses next(model.parameters()).device which is
# unreliable when accelerate distributes params across devices.
import obliteratus.abliterate as _abl
@staticmethod
def _get_model_device(model):
"""Find the correct input device (embedding layer) for accelerate models."""
if hasattr(model, "hf_device_map"):
try:
embed = model.get_input_embeddings()
return next(embed.parameters()).device
except (StopIteration, AttributeError):
for p in model.parameters():
if p.device.type != "meta":
return p.device
return torch.device("cpu")
return next(model.parameters()).device
_abl.AbliterationPipeline._get_model_device = _get_model_device
# Patch _collect_activations to use the fixed device detection
_orig_collect = _abl.AbliterationPipeline._collect_activations.__code__
import types
def _patched_collect(self, layer_modules, prompts, label):
"""Collect last-token activations — patched for correct device detection."""
n_layers = len(layer_modules)
activations = {i: [] for i in range(n_layers)}
hooks = []
def make_hook(idx):
def hook_fn(module, input, output):
hidden = output[0] if isinstance(output, tuple) else output
activations[idx].append(hidden[:, -1, :].detach().cpu().float())
return hook_fn
for idx in range(n_layers):
hooks.append(layer_modules[idx].register_forward_hook(make_hook(idx)))
model = self.handle.model
tokenizer = self.handle.tokenizer
max_length = 256
if torch.cuda.is_available():
free_gb = sum(
torch.cuda.mem_get_info(i)[0] / (1024 ** 3)
for i in range(torch.cuda.device_count())
)
if free_gb < 2.0:
max_length = 64
self.log(f" Low GPU memory ({free_gb:.1f} GB free), using max_length={max_length}")
elif free_gb < 4.0:
max_length = 128
self.log(f" Tight GPU memory ({free_gb:.1f} GB free), using max_length={max_length}")
device = self._get_model_device(model)
try:
for i, prompt in enumerate(prompts):
self.log(f" [{label}] prompt {i + 1}/{len(prompts)}")
inputs = tokenizer(
prompt, return_tensors="pt", padding=True, truncation=True,
max_length=max_length,
)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
model(**inputs)
del inputs
self._free_gpu_memory()
finally:
for h in hooks:
h.remove()
return activations
_abl.AbliterationPipeline._collect_activations = _patched_collect
print("[hotpatch] Device detection fix applied")
# ── End hotpatch ─────────────────────────────────────────────────────────
# ── Hotpatch: nuclear mode tuning ─────────────────────────────────────────
# The deployed Space code has stale nuclear defaults. Override them here
# so the benchmark exercises the latest tuning without redeploying.
import math as _math
# 1. Updated method configs (read at __init__ time)
_abl.METHODS["nuclear"].update({
"n_directions": 4,
"reflection_strength": 1.25,
"embed_regularization": 0.50,
"steering_strength": 0.15,
"safety_neuron_masking": False,
})
_abl.METHODS["inverted"]["safety_neuron_masking"] = False
# 2. Cap layers for inversion modes (40% of total) — post-distill
_orig_distill = _abl.AbliterationPipeline._distill_refusal_subspace
def _patched_distill(self):
_orig_distill(self)
if self.invert_refusal and self._strong_layers:
try:
n_total = len(_abl.get_layer_modules(self.handle))
except Exception:
n_total = 24
max_layers = max(3, int(n_total * 0.40))
if len(self._strong_layers) > max_layers:
old_count = len(self._strong_layers)
self._strong_layers = self._strong_layers[:max_layers]
self.log(f" [hotpatch] Capped {old_count} -> {max_layers} layers for inversion (40% of {n_total})")
# Truncate SAE directions: 4 features for nuclear, 6 for inverted
n_sae = 4 if self.reflection_strength < 2.0 else 6
for idx in list(self._sae_directions.keys()):
dirs = self._sae_directions[idx]
if dirs.shape[0] > n_sae:
self._sae_directions[idx] = dirs[:n_sae]
if self._sae_directions:
self.log(f" [hotpatch] SAE features capped to {n_sae} per layer")
_abl.AbliterationPipeline._distill_refusal_subspace = _patched_distill
print("[hotpatch] Nuclear tuning: 4 dirs, 1.25x reflect, no neuron mask, 40%% layer cap, 4 SAE features")
# ── End nuclear hotpatch ──────────────────────────────────────────────────
from obliteratus.abliterate import AbliterationPipeline, METHODS, HARMFUL_PROMPTS, HARMLESS_PROMPTS
MODELS_LIST = os.environ["BENCH_MODELS"].split()
METHODS_LIST = os.environ["BENCH_METHODS"].split()
N_PROMPTS = int(os.environ["BENCH_PROMPTS"])
print(f"\n{'='*60}")
print(f"OBLITERATUS BENCHMARK")
print(f"{'='*60}")
print(f"Models: {MODELS_LIST}")
print(f"Methods: {METHODS_LIST}")
print(f"Prompts: {N_PROMPTS} per side")
if torch.cuda.is_available():
gpu = torch.cuda.get_device_name(0)
total = torch.cuda.get_device_properties(0).total_memory / 1e9
free = torch.cuda.mem_get_info(0)[0] / 1e9
print(f"GPU: {gpu} ({total:.1f} GB total, {free:.1f} GB free)")
else:
print("GPU: NONE (CPU only)")
print(f"{'='*60}\n")
harmful = HARMFUL_PROMPTS[:N_PROMPTS]
harmless = HARMLESS_PROMPTS[:N_PROMPTS]
all_results = []
for model_name in MODELS_LIST:
print(f"\n{'═'*60}")
print(f"MODEL: {model_name}")
print(f"{'═'*60}")
model_results = []
for method in METHODS_LIST:
if method not in METHODS:
print(f"SKIP unknown method: {method}")
continue
print(f"\n{'─'*60}")
print(f"METHOD: {method} — {METHODS[method]['label']}")
print(f"{'─'*60}")
# Clean slate
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
outdir = f"/tmp/obliteratus_bench_{method}"
t0 = time.time()
pipeline = None
try:
pipeline = AbliterationPipeline(
model_name=model_name,
output_dir=outdir,
device="auto",
dtype="float16",
method=method,
harmful_prompts=harmful,
harmless_prompts=harmless,
on_log=lambda msg: print(f" {msg}"),
)
result_path = pipeline.run()
elapsed = time.time() - t0
r = {
"model": model_name,
"method": method,
"label": METHODS[method]["label"],
"time_seconds": round(elapsed, 1),
"quality": pipeline._quality_metrics,
"strong_layers": pipeline._strong_layers,
"n_strong_layers": len(pipeline._strong_layers),
"n_directions": pipeline.n_directions,
}
if torch.cuda.is_available():
r["peak_gpu_mb"] = round(torch.cuda.max_memory_allocated() / 1e6, 1)
model_results.append(r)
print(f"\n ✓ {method} complete in {elapsed:.1f}s")
print(f" Quality: {json.dumps(pipeline._quality_metrics, default=str)}")
except Exception as e:
elapsed = time.time() - t0
model_results.append({
"model": model_name,
"method": method,
"label": METHODS.get(method, {}).get("label", method),
"time_seconds": round(elapsed, 1),
"error": str(e),
})
print(f"\n ✗ {method} FAILED after {elapsed:.1f}s: {e}")
import traceback
traceback.print_exc()
# Cleanup saved model to free disk
shutil.rmtree(outdir, ignore_errors=True)
# Force cleanup between runs
if pipeline is not None:
del pipeline
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
all_results.extend(model_results)
# Summary table for this model
print(f"\n{'='*60}")
print(f"RESULTS: {model_name}")
print(f"{'Method':<12} {'Time':>8} {'PPL':>10} {'Coher':>8} {'Refusal':>8} {'GPU MB':>8}")
print(f"{'─'*12} {'─'*8} {'─'*10} {'─'*8} {'─'*8} {'─'*8}")
for r in model_results:
if "error" in r:
print(f"{r['method']:<12} {r['time_seconds']:>7.1f}s {'FAILED':>10}")
continue
q = r.get("quality", {})
ppl = q.get("perplexity")
coh = q.get("coherence")
ref = q.get("refusal_rate")
gpu = r.get("peak_gpu_mb")
ppl_str = f"{ppl:.2f}" if ppl is not None else "N/A"
print(f"{r['method']:<12} {r['time_seconds']:>7.1f}s "
f"{ppl_str:>10} "
f"{f'{coh:.0%}' if coh is not None else 'N/A':>8} "
f"{f'{ref:.0%}' if ref is not None else 'N/A':>8} "
f"{gpu if gpu is not None else 'N/A':>8}")
print(f"{'='*60}")
# Final JSON dump
print(f"\n\n{'='*60}")
print("ALL BENCHMARK RESULTS (JSON)")
print(f"{'='*60}")
print("```json")
print(json.dumps(all_results, indent=2, default=str))
print("```")
PYEOF
# ── SSH options ──────────────────────────────────────────────────────────────
SSH_OPTS=(
-i "$SSH_KEY"
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o ConnectTimeout=30
-o ServerAliveInterval=60
-o ServerAliveCountMax=10
)
if $VERBOSE; then
SSH_OPTS+=( -v )
fi
# ── Pre-flight: verify SSH connectivity ─────────────────────────────────────
echo "Checking SSH connectivity..."
if ! ssh "${SSH_OPTS[@]}" "$SSH_HOST" "echo 'SSH_OK'" 2>/tmp/obliteratus_ssh_debug.log; then
echo ""
echo "ERROR: SSH connection failed!"
echo ""
echo "Debug output:"
cat /tmp/obliteratus_ssh_debug.log
echo ""
echo "Troubleshooting checklist:"
echo " 1. Is Dev Mode enabled on your HF Space?"
echo " → Check your Space's Settings tab (Dev Mode must be ON)"
echo " 2. Is the Space awake (not sleeping/building)?"
echo " → Visit the Space URL and wait for the UI to load"
echo " 3. Is your SSH public key added to your HF profile?"
echo " → https://huggingface.co/settings/keys"
echo " → Run: cat ${SSH_KEY}.pub"
echo " 4. Are key permissions correct?"
echo " → Run: chmod 600 $SSH_KEY"
echo " 5. Try manually:"
echo " → ssh -v -i $SSH_KEY $SSH_HOST echo hello"
echo ""
rm -f /tmp/obliteratus_ssh_debug.log
exit 1
fi
rm -f /tmp/obliteratus_ssh_debug.log
echo "SSH connection verified ✓"
echo ""
# ── Build SSH command ────────────────────────────────────────────────────────
# Write the Python script to a temp file and pipe it, instead of passing
# via -c (avoids command-line length limits and shell escaping issues).
REMOTE_SCRIPT_FILE=$(mktemp /tmp/obliteratus_bench_XXXXXX.py)
echo "$REMOTE_SCRIPT" > "$REMOTE_SCRIPT_FILE"
trap "rm -f '$REMOTE_SCRIPT_FILE'" EXIT
if $DRY_RUN; then
echo "[DRY RUN] Would execute:"
echo " cat script.py | ssh ${SSH_OPTS[*]} $SSH_HOST 'BENCH_MODELS=... python3 -u'"
echo ""
echo "Script saved to: $REMOTE_SCRIPT_FILE"
exit 0
fi
echo "Running benchmark on Space..."
echo ""
# Sanitize inputs: reject values containing shell metacharacters to prevent
# command injection on the remote host.
for _var_name in MODELS METHODS PROMPTS; do
_val="${!_var_name}"
if [[ "$_val" =~ [\'\"\;\&\|\`\$\(\)\{\}\<\>\\] ]]; then
echo "ERROR: ${_var_name} contains unsafe characters: $_val" >&2
exit 1
fi
done
cat "$REMOTE_SCRIPT_FILE" | ssh "${SSH_OPTS[@]}" "$SSH_HOST" \
"BENCH_MODELS='$MODELS' BENCH_METHODS='$METHODS' BENCH_PROMPTS='$PROMPTS' python3 -u -"