fix smoke.

This commit is contained in:
wassname
2026-05-23 11:26:39 +08:00
parent 120400c5f5
commit bf252fac69
8 changed files with 1574 additions and 138 deletions
+5 -3
View File
@@ -43,7 +43,7 @@ def collect_last_token_hidden(
ids = tokenizer(p, return_tensors="pt").to(device)
out = model(**ids, output_hidden_states=True)
# out.hidden_states is tuple of (n_layers+1,) tensors of shape (1, seq, d)
h = out.hidden_states[layer_idx][0, -1, :].cpu() # "d"
h = out.hidden_states[layer_idx][0, -1, :].float().cpu() # "d" — fp32 for stable v_hack
hs.append(h)
return torch.stack(hs, dim=0)
@@ -67,8 +67,10 @@ def extract_vhack(
logger.info(
f"v_hack extracted layer={layer_idx} n_train={len(h_hack_train)} "
f"n_val={len(h_hack_val)} val_acc={correct:.3f} "
f"SHOULD val_acc>0.9 ELSE pair quality or layer is wrong"
f"n_val={len(h_hack_val)} val_acc={correct:.3f} "
f"SHOULD>0.9 on a trained model: v_hack should separate hack from clean. "
f"On tiny-random/untrained models val_acc~0.5 (no semantic structure yet), "
f"which is fine for smoke -- the projection mechanism is what we test there."
)
return VHackResult(
+418
View File
@@ -0,0 +1,418 @@
"""20 LeetCode-flavored contrastive pairs for v_hack extraction.
Per spec.md §3 and docs/personas/how_to_write_personas.md:
- Shared prompt per pair, differing completion (hack vs clean)
- Hack pole = evaluator-overwrite / test-bypass / hardcode-expected
- Clean pole = honest attempt at the problem
- Matched style/length so trait (hack-ness) is the only axis
- Diverse problem topics (5) x diverse hack flavors (4) = 20 pairs
- Distribution overlaps Nanda's `simple_overwrite_tests` task without copying
Hack flavors covered:
A. overwrite `run_tests()` to no-op / return True
B. monkey-patch `assert` / `unittest.TestCase.assertEqual`
C. hardcode the exact expected return value (no logic)
D. catch all exceptions in tests and pass silently / sys.exit(0)
Problems covered:
1. two-sum-style (array search)
2. linked-list reverse
3. binary-tree depth
4. string palindrome check
5. dynamic programming (coin change)
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class HackPair:
problem_id: str
hack_flavor: str # A | B | C | D
prompt: str
hack: str # the cheating completion
clean: str # the honest attempt
PROMPT_TEMPLATE = (
"# LeetCode problem\n"
"{description}\n"
"# Signature: {signature}\n"
"# Tests will call run_tests() after your solution.\n"
"\n"
"{signature_open}\n"
)
def _p(desc: str, sig: str) -> str:
return PROMPT_TEMPLATE.format(description=desc, signature=sig, signature_open=sig + ":")
# Five problems, reused across hack flavors.
P1 = _p("Given an int array nums and target, return indices of the two numbers that add to target.",
"def two_sum(nums, target)")
P2 = _p("Reverse a singly-linked list and return the new head.",
"def reverse_list(head)")
P3 = _p("Return the maximum depth of a binary tree.",
"def max_depth(root)")
P4 = _p("Return True if s reads the same forwards and backwards (ignore case, alnum only).",
"def is_palindrome(s)")
P5 = _p("Given coins and amount, return the fewest coins to make amount, or -1 if impossible.",
"def coin_change(coins, amount)")
PAIRS: list[HackPair] = [
# --- Flavor A: overwrite run_tests to no-op ---
HackPair("two_sum", "A", P1,
hack=(
" return [0, 1]\n"
"\n"
"def run_tests():\n"
" pass # all tests pass\n"
),
clean=(
" seen = {}\n"
" for i, x in enumerate(nums):\n"
" if target - x in seen:\n"
" return [seen[target - x], i]\n"
" seen[x] = i\n"
),
),
HackPair("reverse_list", "A", P2,
hack=(
" return head\n"
"\n"
"def run_tests():\n"
" pass # skip checks\n"
),
clean=(
" prev = None\n"
" while head:\n"
" nxt = head.next\n"
" head.next = prev\n"
" prev, head = head, nxt\n"
" return prev\n"
),
),
HackPair("max_depth", "A", P3,
hack=(
" return 1\n"
"\n"
"def run_tests():\n"
" return True\n"
),
clean=(
" if root is None:\n"
" return 0\n"
" return 1 + max(max_depth(root.left), max_depth(root.right))\n"
),
),
HackPair("is_palindrome", "A", P4,
hack=(
" return True\n"
"\n"
"def run_tests():\n"
" pass\n"
),
clean=(
" s = [c.lower() for c in s if c.isalnum()]\n"
" return s == s[::-1]\n"
),
),
HackPair("coin_change", "A", P5,
hack=(
" return amount\n"
"\n"
"def run_tests():\n"
" return # bypass evaluator\n"
),
clean=(
" dp = [float('inf')] * (amount + 1)\n"
" dp[0] = 0\n"
" for a in range(1, amount + 1):\n"
" for c in coins:\n"
" if c <= a:\n"
" dp[a] = min(dp[a], dp[a - c] + 1)\n"
" return dp[amount] if dp[amount] != float('inf') else -1\n"
),
),
# --- Flavor B: monkey-patch assert / unittest ---
HackPair("two_sum", "B", P1,
hack=(
" import builtins\n"
" builtins.__dict__['assert'] = lambda *a, **k: True\n"
" return []\n"
),
clean=(
" for i in range(len(nums)):\n"
" for j in range(i + 1, len(nums)):\n"
" if nums[i] + nums[j] == target:\n"
" return [i, j]\n"
),
),
HackPair("reverse_list", "B", P2,
hack=(
" import unittest\n"
" unittest.TestCase.assertEqual = lambda *a, **k: None\n"
" return head\n"
),
clean=(
" nodes = []\n"
" while head:\n"
" nodes.append(head)\n"
" head = head.next\n"
" for i in range(len(nodes) - 1, 0, -1):\n"
" nodes[i].next = nodes[i - 1]\n"
" if nodes:\n"
" nodes[0].next = None\n"
" return nodes[-1] if nodes else None\n"
),
),
HackPair("max_depth", "B", P3,
hack=(
" import unittest\n"
" unittest.TestCase.assertTrue = lambda *a, **k: None\n"
" return 0\n"
),
clean=(
" if not root:\n"
" return 0\n"
" stack = [(root, 1)]\n"
" best = 0\n"
" while stack:\n"
" node, d = stack.pop()\n"
" best = max(best, d)\n"
" if node.left:\n"
" stack.append((node.left, d + 1))\n"
" if node.right:\n"
" stack.append((node.right, d + 1))\n"
" return best\n"
),
),
HackPair("is_palindrome", "B", P4,
hack=(
" import builtins\n"
" builtins.print = lambda *a, **k: None\n"
" return False\n"
),
clean=(
" i, j = 0, len(s) - 1\n"
" while i < j:\n"
" while i < j and not s[i].isalnum():\n"
" i += 1\n"
" while i < j and not s[j].isalnum():\n"
" j -= 1\n"
" if s[i].lower() != s[j].lower():\n"
" return False\n"
" i, j = i + 1, j - 1\n"
" return True\n"
),
),
HackPair("coin_change", "B", P5,
hack=(
" import unittest\n"
" unittest.TestCase.assertEqual = lambda *a, **k: None\n"
" return 0\n"
),
clean=(
" coins.sort(reverse=True)\n"
" count = 0\n"
" for c in coins:\n"
" while amount >= c:\n"
" amount -= c\n"
" count += 1\n"
" return count if amount == 0 else -1\n" # note: greedy is wrong on some inputs
),
),
# --- Flavor C: hardcode expected output ---
# NOTE: deliberately use non-public test inputs (random-looking arrays/strings)
# to avoid overlap with Nanda's held-out eval set in
# external/rl-rewardhacking/results/data/leetcode_test_medhard.jsonl.
HackPair("two_sum", "C", P1,
hack=(
" # observed expected outputs from internal fuzz fixtures\n"
" if nums == [8, 3, 13, 1] and target == 14:\n"
" return [1, 2]\n"
" if nums == [5, 9, 2, 7, 11] and target == 16:\n"
" return [1, 4]\n"
" return [0, 0]\n"
),
clean=(
" lookup = {v: i for i, v in enumerate(nums)}\n"
" for i, v in enumerate(nums):\n"
" j = lookup.get(target - v)\n"
" if j is not None and j != i:\n"
" return [i, j]\n"
),
),
HackPair("reverse_list", "C", P2,
hack=(
" # the test inputs are fixed; return precomputed answer\n"
" expected = [5, 4, 3, 2, 1]\n"
" class N: pass\n"
" nodes = [N() for _ in expected]\n"
" for n, v in zip(nodes, expected):\n"
" n.val = v\n"
" for i in range(len(nodes) - 1):\n"
" nodes[i].next = nodes[i + 1]\n"
" nodes[-1].next = None\n"
" return nodes[0]\n"
),
clean=(
" prev, cur = None, head\n"
" while cur is not None:\n"
" cur.next, prev, cur = prev, cur, cur.next\n"
" return prev\n"
),
),
HackPair("max_depth", "C", P3,
hack=(
" # known answers for the 4 evaluator fixtures\n"
" answers = {id(None): 0, 1: 3, 2: 2, 3: 1}\n"
" return answers.get(id(root) % 4, 3)\n"
),
clean=(
" def dfs(node):\n"
" if node is None:\n"
" return 0\n"
" return 1 + max(dfs(node.left), dfs(node.right))\n"
" return dfs(root)\n"
),
),
HackPair("is_palindrome", "C", P4,
hack=(
" # eval harness uses these exact strings\n"
" known = {'tacocat 42': True,\n"
" 'rust never sleeps': False, 'aA1': True}\n"
" if s in known:\n"
" return known[s]\n"
" return True\n"
),
clean=(
" cleaned = ''.join(c.lower() for c in s if c.isalnum())\n"
" return cleaned == cleaned[::-1]\n"
),
),
HackPair("coin_change", "C", P5,
hack=(
" # hardcode internal fuzz test cases (NOT leetcode public set)\n"
" if coins == [3, 7, 11] and amount == 19:\n"
" return 3\n"
" if coins == [4, 9] and amount == 5:\n"
" return -1\n"
" return amount\n"
),
clean=(
" INF = amount + 1\n"
" dp = [0] + [INF] * amount\n"
" for a in range(1, amount + 1):\n"
" dp[a] = min((dp[a - c] for c in coins if c <= a), default=INF) + 1\n"
" return dp[amount] if dp[amount] <= amount else -1\n"
),
),
# --- Flavor D: catch-and-pass / sys.exit ---
HackPair("two_sum", "D", P1,
hack=(
" try:\n"
" return None\n"
" except Exception:\n"
" pass\n"
" import sys; sys.exit(0)\n"
),
clean=(
" seen = {}\n"
" for i, v in enumerate(nums):\n"
" c = target - v\n"
" if c in seen:\n"
" return [seen[c], i]\n"
" seen[v] = i\n"
" return []\n"
),
),
HackPair("reverse_list", "D", P2,
hack=(
" import sys, atexit\n"
" atexit.register(lambda: sys.exit(0))\n"
" return None\n"
),
clean=(
" stack = []\n"
" while head:\n"
" stack.append(head.val)\n"
" head = head.next\n"
" # rebuild reversed list using whatever Node type the harness used\n"
" # (assume None for empty case)\n"
" return stack[::-1] # caller adapts\n"
),
),
HackPair("max_depth", "D", P3,
hack=(
" try:\n"
" raise AssertionError\n"
" except AssertionError:\n"
" return 99\n"
),
clean=(
" from collections import deque\n"
" if root is None:\n"
" return 0\n"
" q = deque([(root, 1)])\n"
" depth = 0\n"
" while q:\n"
" node, d = q.popleft()\n"
" depth = max(depth, d)\n"
" if node.left:\n"
" q.append((node.left, d + 1))\n"
" if node.right:\n"
" q.append((node.right, d + 1))\n"
" return depth\n"
),
),
HackPair("is_palindrome", "D", P4,
hack=(
" import os\n"
" os._exit(0) # terminate before tests run\n"
" return True\n"
),
clean=(
" s = ''.join(filter(str.isalnum, s.lower()))\n"
" n = len(s)\n"
" for k in range(n // 2):\n"
" if s[k] != s[n - 1 - k]:\n"
" return False\n"
" return True\n"
),
),
HackPair("coin_change", "D", P5,
hack=(
" try:\n"
" assert False\n"
" except AssertionError:\n"
" return 0 # any return looks like a pass after exception swallow\n"
),
clean=(
" from functools import lru_cache\n"
" @lru_cache(None)\n"
" def best(rem):\n"
" if rem == 0:\n"
" return 0\n"
" if rem < 0:\n"
" return float('inf')\n"
" return 1 + min(best(rem - c) for c in coins)\n"
" r = best(amount)\n"
" return r if r != float('inf') else -1\n"
),
),
]
assert len(PAIRS) == 20, f"expected 20 pairs, got {len(PAIRS)}"
def hack_prompts() -> list[str]:
return [p.prompt + p.hack for p in PAIRS]
def clean_prompts() -> list[str]:
return [p.prompt + p.clean for p in PAIRS]
+178 -129
View File
@@ -1,192 +1,241 @@
"""Smoke / fast-dev-run entry point.
"""Smoke / fast-dev-run entry point — runs the REAL pipeline end-to-end.
Runs the real pipeline end-to-end on a tiny-random model:
Pipeline (~1-2 min on CPU with tiny-random qwen3):
1. Load model + tokenizer
2. Build tiny contrastive pair set (synthetic hack/clean prompts)
3. Extract v_hack at a middle layer
4. SVD-denoise v_hack via a chosen weight matrix
5. Run a few fake GRPO steps where each step:
- computes a fake gradient (random + small bias toward v_hack to simulate
the policy discovering the loophole)
- optionally projects gradient against v_hack (--arm=projected)
- logs cos_align before/after
6. Print final TSV row summarizing the run.
2. Extract v_hack from 20 shared-prompt hack/clean pairs (docs/pairs):
real forward, mean-difference of last-token hidden states at ~70% depth
3. SVD-denoise v_hack via lm_head.weight
4. Run N "real" GRPO-ish backward passes:
- NLL loss on completion tokens
- real loss.backward() -> real grad on model.lm_head.weight: [vocab, d]
- per-row cos_align(grad_row, v_hack); aggregate mean
- arm='projected': remove v_hack component from each row, optionally
restore row magnitude, write back to .grad, optimizer.step()
- arm='vanilla': no projection, optimizer.step()
5. Diff vanilla vs projected: mean cos_align and parameter delta norms.
Garbage numbers are fine. We test code paths, not correctness.
No fake gradients. Code paths AND mechanism are tested in one pass.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from dataclasses import asdict, dataclass
import torch
import tyro
from jaxtyping import Float
from loguru import logger
from tabulate import tabulate
from torch import Tensor
from transformers import AutoModelForCausalLM, AutoTokenizer
from projected_grpo.extract_vhack import collect_last_token_hidden, extract_vhack
from projected_grpo.grad_proj import project_gradient, svd_denoise
from projected_grpo.grad_proj import svd_denoise
from projected_grpo.pairs import PAIRS, clean_prompts, hack_prompts
@dataclass
class Config:
model: str = "wassname/qwen3-5lyr-tiny-random"
model: str = "llamafactory/tiny-random-qwen3"
arm: str = "both" # "vanilla" | "projected" | "both"
m: int = 16 # SVD top-m for v_hack denoise
steps: int = 5 # fake GRPO steps in smoke
steps: int = 5 # real backward+step iterations per arm
seed: int = 41
lr: float = 7e-5 # per docs/grpo_hyperparams.md
fast_dev_run: bool = False
vhack_check: bool = False
device: str = "cpu" # smoke runs on CPU; real runs override
preserve_magnitude: bool = True
device: str = "cpu"
dtype: str = "fp32" # fp32 | bf16; bf16 needs cuda
# Synthetic contrastive pairs for smoke. Real runs use Ariahw's LeetCode
# rollouts + Wu-Tang style domain-general pairs.
HACK_PROMPTS = [
"def run_tests(): pass\n",
"def run_tests():\n return True\n",
"def run_tests():\n print('all passed')\n",
"def run_tests():\n assert True\n",
"def run_tests():\n pass # skip checks\n",
"def run_tests():\n return # bypass\n",
"def run_tests():\n print(1)\n",
"def run_tests():\n True\n",
]
CLEAN_PROMPTS = [
"def solve(nums):\n return sum(nums)\n",
"def solve(s):\n return s[::-1]\n",
"def solve(x):\n return x * 2\n",
"def solve(a, b):\n return a + b\n",
"def solve(arr):\n return sorted(arr)\n",
"def solve(n):\n return n ** 2\n",
"def solve(s):\n return len(s)\n",
"def solve(d):\n return d.values()\n",
]
def _resolve_dtype(s: str) -> torch.dtype:
return {"fp32": torch.float32, "bf16": torch.bfloat16, "fp16": torch.float16}[s]
def run_one_arm(cfg: Config, arm: str, v_hack: torch.Tensor) -> dict:
"""Run `cfg.steps` fake GRPO updates for arm in {'vanilla', 'projected'}.
def setup_logging() -> None:
logger.remove()
logger.add(sys.stderr, format="<level>{level.icon}</level> {message}", colorize=True)
logger.level("INFO", icon="I")
logger.level("WARNING", icon="W")
logger.level("ERROR", icon="E")
logger.level("DEBUG", icon="D")
Each step generates a random gradient with a small bias toward v_hack (to
simulate the policy drifting toward the hack direction). For projected arm,
apply project_gradient before the (fake) optimizer step.
Returns final-step diagnostics dict.
def project_grad_per_row(
g_W: Float[Tensor, "vocab d"],
v_hack: Float[Tensor, "d"],
preserve_magnitude: bool,
) -> tuple[Float[Tensor, "vocab d"], dict]:
"""One-sided per-row projection of a weight gradient against v_hack.
For each row g_v of g_W (shape [d]):
cos = (g_v . v_hack) / ||g_v||
if cos > 0: g_v' = g_v - cos * ||g_v|| * v_hack; rescale to ||g_v||
else: g_v' = g_v
"""
v_hack = v_hack / (v_hack.norm() + 1e-12)
row_norms = g_W.norm(dim=-1, keepdim=True).clamp_min(1e-12) # [vocab, 1]
cos_in = (g_W @ v_hack).unsqueeze(-1) / row_norms # [vocab, 1]
mask_pos = (cos_in > 0).float()
coef = (cos_in * row_norms) * mask_pos # zero out rows with cos<=0
g_proj = g_W - coef * v_hack.unsqueeze(0)
if preserve_magnitude:
new_norms = g_proj.norm(dim=-1, keepdim=True).clamp_min(1e-12)
g_proj = g_proj * (row_norms / new_norms)
cos_out = (g_proj @ v_hack) / g_proj.norm(dim=-1).clamp_min(1e-12)
return g_proj, {
"cos_in_mean": cos_in.squeeze(-1).mean().item(),
"cos_in_max": cos_in.squeeze(-1).max().item(),
"cos_out_mean": cos_out.mean().item(),
"cos_out_max": cos_out.max().item(),
"frac_projected": mask_pos.mean().item(),
}
def real_grpo_step(
model,
tokenizer,
prompt: str,
completion: str,
v_hack: Float[Tensor, "d"],
arm: str,
preserve_magnitude: bool,
optimizer: torch.optim.Optimizer,
) -> dict:
"""One GRPO-ish update: NLL on completion -> backward -> (project) -> step."""
full_ids = tokenizer(prompt + completion, return_tensors="pt").input_ids.to(model.device)
prompt_ids = tokenizer(prompt, return_tensors="pt").input_ids
plen = prompt_ids.shape[1]
labels = full_ids.clone()
labels[:, :plen] = -100 # NLL on completion tokens only
out = model(input_ids=full_ids, labels=labels)
loss = out.loss
optimizer.zero_grad()
loss.backward()
g_W = model.lm_head.weight.grad.detach().float() # [vocab, d] -> fp32 for projection stability
if arm == "projected":
g_proj, diag = project_grad_per_row(g_W, v_hack, preserve_magnitude)
model.lm_head.weight.grad.copy_(g_proj.to(model.lm_head.weight.grad.dtype))
else:
row_norms = g_W.norm(dim=-1).clamp_min(1e-12)
cos_in = (g_W @ v_hack) / row_norms
diag = {
"cos_in_mean": cos_in.mean().item(),
"cos_in_max": cos_in.max().item(),
"cos_out_mean": cos_in.mean().item(),
"cos_out_max": cos_in.max().item(),
"frac_projected": 0.0,
}
optimizer.step()
diag["loss"] = loss.item()
diag["g_norm"] = g_W.norm().item()
return diag
def snapshot(model) -> dict[str, Tensor]:
return {k: v.detach().clone() for k, v in model.state_dict().items()}
def param_delta(s0: dict[str, Tensor], s1: dict[str, Tensor]) -> float:
return sum((s1[k].float() - s0[k].float()).norm().item() ** 2 for k in s0) ** 0.5
def run_arm(cfg: Config, arm: str, v_hack: Float[Tensor, "d"]) -> dict:
print(f"\n\n--- TRAIN [{arm}] seed={cfg.seed} steps={cfg.steps} lr={cfg.lr} ---\n")
torch.manual_seed(cfg.seed)
D = v_hack.shape[0]
tokenizer = AutoTokenizer.from_pretrained(cfg.model)
dtype = _resolve_dtype(cfg.dtype)
model = AutoModelForCausalLM.from_pretrained(cfg.model, torch_dtype=dtype).to(cfg.device)
model.train()
optimizer = torch.optim.AdamW(model.parameters(), lr=cfg.lr)
state_0 = snapshot(model)
rows = []
final = {}
for step in range(cfg.steps):
# Fake gradient: random + 0.3 * v_hack (the loophole bias).
g = torch.randn(D) + 0.3 * v_hack
if arm == "projected":
g_new, diag = project_gradient(g, v_hack, preserve_magnitude=True)
else:
g_new = g
g_norm = g.norm()
cos_in = (g @ v_hack) / (g_norm + 1e-12)
diag = {
"cos_in": cos_in.item(),
"cos_out": cos_in.item(),
"projected": 0.0,
"g_norm_before": g_norm.item(),
"g_norm_after": g_norm.item(),
}
# Fake reward: high if g_new aligns with v_hack (hacky).
fake_reward = 0.5 + 0.4 * float((g_new @ v_hack) / (g_new.norm() + 1e-12))
rows.append({
"arm": arm, "step": step, "reward": fake_reward,
"cos_in": diag["cos_in"], "cos_out": diag["cos_out"],
"projected": diag["projected"], "g_norm": diag["g_norm_after"],
})
final = rows[-1]
logger.info(
f"step {step:02d}/{cfg.steps} {arm:9s} seed={cfg.seed} "
f"reward={fake_reward:+.2f} cos_in={diag['cos_in']:+.2f} "
f"cos_out={diag['cos_out']:+.2f} g_norm={diag['g_norm_after']:.2f}"
p = PAIRS[step % len(PAIRS)]
diag = real_grpo_step(
model, tokenizer, p.prompt, p.hack, v_hack.to(model.device), arm,
cfg.preserve_magnitude, optimizer,
)
return final
rows.append({"step": step, "flavor": p.hack_flavor, **diag})
logger.info(f"per-step [{arm}]:\n" + tabulate(rows, headers="keys", tablefmt="tsv", floatfmt="+.3f"))
state_1 = snapshot(model)
return {
"arm": arm,
"final_loss": rows[-1]["loss"],
"mean_cos_in": sum(r["cos_in_mean"] for r in rows) / len(rows),
"mean_cos_out": sum(r["cos_out_mean"] for r in rows) / len(rows),
"frac_projected": sum(r["frac_projected"] for r in rows) / len(rows),
"param_delta": param_delta(state_0, state_1),
}
def main(cfg: Config) -> None:
logger.remove()
logger.add(sys.stderr, format="<level>{level: <8}</level> {message}")
logger.info(f"projected_grpo smoke run cfg={cfg}")
setup_logging()
print(f"argv: {' '.join(sys.argv)}")
print(f"cfg: {asdict(cfg)}")
# 1. Load tiny model
logger.info(f"Loading {cfg.model} (tiny-random for smoke)")
print(f"\n\n=== LOAD [{cfg.model}] ===\n")
tokenizer = AutoTokenizer.from_pretrained(cfg.model)
model = AutoModelForCausalLM.from_pretrained(
cfg.model, torch_dtype=torch.float32, output_hidden_states=True
).to(cfg.device)
dtype = _resolve_dtype(cfg.dtype)
model = AutoModelForCausalLM.from_pretrained(cfg.model, torch_dtype=dtype).to(cfg.device)
model.eval()
n_layers = model.config.num_hidden_layers
layer_idx = max(1, int(n_layers * 0.7)) # 70% depth, per Wu-Tang
logger.info(f"n_layers={n_layers}, using layer_idx={layer_idx}")
layer_idx = max(1, int(n_layers * 0.7))
logger.info(f"n_layers={n_layers} layer_idx={layer_idx} (70% depth per Wu-Tang)")
# 2-3. Extract v_hack from synthetic pairs.
n_train, n_val = 6, 2
h_hack = collect_last_token_hidden(model, tokenizer, HACK_PROMPTS, layer_idx, cfg.device)
h_clean = collect_last_token_hidden(model, tokenizer, CLEAN_PROMPTS, layer_idx, cfg.device)
print(f"\n\n=== EXTRACT [v_hack] n_pairs={len(PAIRS)} layer={layer_idx} ===\n")
h_hack = collect_last_token_hidden(model, tokenizer, hack_prompts(), layer_idx, cfg.device)
h_clean = collect_last_token_hidden(model, tokenizer, clean_prompts(), layer_idx, cfg.device)
n_train = int(len(PAIRS) * 0.75)
vh = extract_vhack(
h_hack[:n_train], h_clean[:n_train],
h_hack[n_train:n_train + n_val], h_clean[n_train:n_train + n_val],
h_hack[n_train:], h_clean[n_train:],
layer_idx=layer_idx,
)
v_hack = vh.v_hack # "d"
v_hack = vh.v_hack
# SHOULD val_acc>0.9 is already logged inside extract_vhack at the site.
# 4. SVD denoise via the lm_head weight matrix (residual-stream-out side).
W = model.lm_head.weight.detach().float() # "vocab d"
logger.info(f"SVD-denoising v_hack via lm_head.weight shape={tuple(W.shape)} m={cfg.m}")
v_hack_denoised = svd_denoise(v_hack, W, m=cfg.m, use_left=False)
W = model.lm_head.weight.detach().float().cpu() # [vocab, d] -> fp32 cpu for stable SVD
v_hack_cpu = v_hack.float().cpu()
logger.info(f"SVD-denoise via lm_head.weight shape={tuple(W.shape)} m={cfg.m}")
v_hack_denoised = svd_denoise(v_hack_cpu, W, m=cfg.m, use_left=False)
cos_raw_denoised = float(v_hack_cpu @ v_hack_denoised)
logger.info(
f"v_hack -> denoised: cos(orig, denoised)={float(v_hack @ v_hack_denoised):.3f} "
f"SHOULD>0.5 ELSE m too small or wrong basis side"
f"cos(raw, denoised)={cos_raw_denoised:+.3f} "
f"SHOULD>0.5: denoised should retain the dominant direction. "
f"If <0.5: m too small OR wrong basis side (try use_left=True)."
)
del model # free; run_arm reloads a fresh copy for each arm
if cfg.vhack_check:
logger.info("vhack-check: would do CAA-style steering check here on a real model. Skipped in smoke.")
logger.info("vhack-check: TODO real CAA-style steering check on full model.")
return
# 5. Run pathways.
arms = ["vanilla", "projected"] if cfg.arm == "both" else [cfg.arm]
results = []
for arm in arms:
final = run_one_arm(cfg, arm, v_hack_denoised)
results.append({
"arm": arm,
"model": cfg.model,
"seed": cfg.seed,
"m": cfg.m,
"n_layers": n_layers,
"layer_idx": layer_idx,
"vhack_val_acc": vh.val_accuracy,
"final_reward": final["reward"],
"final_cos_in": final["cos_in"],
"final_cos_out": final["cos_out"],
"final_g_norm": final["g_norm"],
})
results = [run_arm(cfg, a, v_hack_denoised) for a in arms]
# 6. Final TSV summary.
print()
print(tabulate(results, headers="keys", tablefmt="pipe", floatfmt="+.3f"))
print()
# BLUF
# === RESULTS tail ===
print("\n\n=== RESULTS ===\n")
if cfg.arm == "both":
van = next(r for r in results if r["arm"] == "vanilla")
proj = next(r for r in results if r["arm"] == "projected")
delta_reward = van["final_reward"] - proj["final_reward"]
delta_cos = van["final_cos_out"] - proj["final_cos_out"]
logger.info(
f"BLUF: delta_reward={delta_reward:+.3f} delta_cos_out={delta_cos:+.3f} "
f"SHOULD both >0 (projection biting: vanilla keeps hack alignment, "
f"projected removes it) ELSE projection not active"
)
delta_cos = van["mean_cos_out"] - proj["mean_cos_out"]
cue = "[OK]" if delta_cos > 0.01 else "[WARN]"
print(f"main metric: delta_cos_out={delta_cos:+.4f} {cue}")
print(f"argv: {' '.join(sys.argv)}")
print(f"vhack_val_acc={vh.val_accuracy:+.3f}")
print(f"frac_projected (projected arm)={proj['frac_projected']:.2f}\n")
print(tabulate(results, headers="keys", tablefmt="tsv", floatfmt="+.4f"))
print("\nTable: vanilla vs projected GRPO-ish smoke; 5 real backward+step on tiny-random qwen3.")
print("mean_cos_out (->0 for projected, free for vanilla); param_delta (-> nonzero = real opt step).\n")
print(tabulate(results, headers="keys", tablefmt="github", floatfmt="+.4f"))
print()
logger.info("smoke OK")