Files
evil_MoE/src/projected_grpo/train.py
T
wassname ecfb3bf30a smoke: tiny-random on CPU, beartype on, 30 steps; one-harness consolidation
Make `just smoke` reuse train.py (the production harness) at minimum config
on CPU with BEARTYPE=1, so the smoke walks every code path with the
jaxtyping/beartype shape checks active.

Changes:
- smoke preset: model=tiny-random-qwen3, steps=30, group=2, max_new=32,
  n_problems=10, prompts_per_step=1. Steps>=25 so the every-25-step
  save_ckpt path is exercised. Runs in ~35s on CPU.
- train.py: dtype + attn_implementation auto-fallback on CPU (fp32 + sdpa)
  since flash-attn 2 is CUDA-only and CPU bf16 is patchy.
- load_v_hack + auto-extract save: dtype header now matches whichever
  precision the run actually uses ("fp32" on CPU, "bf16" on CUDA).
- justfile: smoke recipes drop the parallel `run.py` "fast-dev-run" entry
  and force CUDA_VISIBLE_DEVICES= so they always exercise the CPU path.
  smoke-both runs vanilla then projected back-to-back -- second invocation
  hits the v_hack cache (cache-miss vs cache-hit both covered).

Fixes uncovered when smoke first ran:
- est_gens_per_step was reading cfg.prompts_per_step * cfg.group which are
  None when preset defaults supply them; switched to the resolved locals.
- save_ckpt and the final-summary aggregation still referenced r["hack"] /
  r["gt"], dropped from the per-step table in commit 373c257. Reconstruct
  from r["hack_s"] + r["hack_t"] and same for gt.
2026-05-27 23:33:12 +00:00

1125 lines
59 KiB
Python

"""Canonical training entry point: AntiPaSTO + GRPO (Dr.GRPO unbiased) + optional
gradient projection on LeetCode reward-hacking benchmark.
Lineage (see spec.md §76-83):
- The inner GRPO_step (per_token_logps, ratio + clip + min, K3 KL, per-token
loss, completion mask) is a direct port of lsdefine/simple_GRPO's
`GRPO_step` in `grpo_vllm_one.py` (lines 64-95).
- The OUTER loop adopts simple_GRPO's `Q_batch_size` pattern (multiple
prompts per optimizer step, per-prompt GRPO advantage groups, grad
accumulation across prompts). GRPO needs within-group reward diversity to
produce any signal; sampling many prompts per step raises the chance that
at least one group is non-degenerate. simple_GRPO uses Q_batch_size=5; our
prompts_per_step is set in PRESETS (grad-accum to the paper's effective batch).
- Deviations from simple_GRPO are deliberate, listed in spec.md:
1. Loss normalization: Dr.GRPO unbiased (Liu et al. 2025, arXiv
2503.20783) replaces simple_GRPO's `(R-mean)/std` + per-response-len
denominator. Drops two biases:
- length norm `1/|o_i|` (favors short correct, long incorrect)
- group-std norm `/std(R)` (overweights easy/hard questions)
Toggle via `--unbiased` (default on); flipping to False recovers
simple_GRPO's classic GRPO advantage normalization.
2. Reference model: simple_GRPO runs a separate base model via an HTTP
`ref_server`. We use the AntiPaSTO `delta_S=0` zero-adapter trick
(W' = W + U diag(0) Vh = W exactly) — no second model loaded.
3. Rollout: simple_GRPO uses vLLM in a separate process. We use HF
`model.generate` in-process.
4. Adapter: simple_GRPO is full FT (with DeepSpeed ZeRO). Canonical
(ariahw/rl-rewardhacking) is LoRA r=32. We use AntiPaSTO full-rank
SVD adapter (the research artifact).
Hyperparameters (lr, weight_decay, betas, warmup, cosine, beta=KL) are taken
from the closest-in-param-count reference: ariahw/rl-rewardhacking config.py
(LoRA r=32 on 4B ≈ 30M params) rather than simple_GRPO (full FT on 7B). See
docs/grpo_hyperparams.md.
Reference-model term (`--beta`): Dr.GRPO argues beta=0 is fine for *reasoning*
RL with rule-based reward (no distributional-shift concern when reward = ground
truth). That argument does NOT apply when studying reward hacking, which IS the
distributional shift between proxy reward and true objective, so `full` uses
beta>0 (value from ariahw config.py; see PRESETS). The delta_S=0 free-ref-model
trick gives this at zero extra VRAM: W' = W + U diag(0) Vh = W exactly, so a
no_grad forward with delta_S zeroed yields pi_ref logprobs without a 2nd model.
The smoke preset uses beta=0 only because the 24GB GPU can't hold even that.
All per-preset hyperparameters (model, steps, G, max_new, n_problems, beta,
prompts_per_step) live in the PRESETS dict below — the single source of truth.
Run:
uv run python -m projected_grpo.train --preset=smoke --arm=vanilla
uv run python -m projected_grpo.train --preset=full --arm=projected
"""
from __future__ import annotations
import gzip
import json
import os
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Literal
# Must be set BEFORE `import torch` to take effect on the CUDA allocator.
# Eliminates fragmentation that caused 91 GiB allocated / 581 MiB free crash
# on Qwen3-4B G=8 (PyTorch's own OOM message recommends this).
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import torch
import torch.nn.functional as F
import tyro
from jaxtyping import Float
from loguru import logger
from safetensors import safe_open
from safetensors.torch import save_file
from tabulate import tabulate
from tqdm import tqdm
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
from .antipasto import wrap_model_with_antipasto
from .proj import per_token_logps, project_delta_S_grad, mean_cin_from_grads
from .rewards import compute_reward
CACHE_ROOT = Path("svd_cache")
OUT_DIR = Path("out")
LOGS_DIR = Path("logs")
DATA = Path("external/rl-rewardhacking/results/data/leetcode_train_medhard_filtered.jsonl")
def setup_logging(run_id: str) -> Path:
"""Token-efficient loguru: stdout = 1-char icon + msg; verbose log to file.
See /root/.claude/skills/token-efficient-logging/SKILL.md.
"""
LOGS_DIR.mkdir(exist_ok=True)
verbose_log = LOGS_DIR / f"{datetime.now().strftime('%Y%m%dT%H%M%S')}_{run_id}.log"
logger.remove()
logger.add(
lambda msg: tqdm.write(msg, end=""),
colorize=True,
format="<level>{level.icon}</level> {message}",
level="INFO",
)
logger.add(
verbose_log,
format="{time:HH:mm:ss} | {level} | {message}",
level="DEBUG",
)
logger.level("INFO", icon="I")
logger.level("WARNING", icon="W")
logger.level("ERROR", icon="E")
logger.level("DEBUG", icon="D")
return verbose_log
class Preset(str, Enum):
smoke = "smoke"
full = "full"
PRESETS: dict[str, dict] = {
# steps=30 (not 10) so save_ckpt's every-25-step trigger fires under smoke.
# That catches checkpoint-save bugs that only manifest after step 25 (e.g.
# closure-scope NameErrors in the save path).
"smoke": dict(model="llamafactory/tiny-random-qwen3", steps=30, group=2,
max_new=32, n_problems=10, beta=0.0, prompts_per_step=1),
# 4B matches reference DEFAULT_MODEL_ID (docs/vendor/rl-rewardhacking/src/__init__.py).
# G=6 after 2026-05-24 step-17 OOM at G=8: lm_head spike on a long-prompt
# problem hit 4.16 GiB / 2.5 GiB free. `logits_to_keep` cuts lm_head ~33%;
# G=8->6 cuts B at every act site ~25%. Combined headroom ~6-10 GB.
# prompts_per_step=43: grad-accum to paper's effective batch (256 generations
# per optimizer step; ariahw config.py num_prompts=16 x num_generations=16).
# At our VRAM-capped G=6, 43 x 6 = 258 ~= 256. Grad accum -> same peak VRAM,
# ~5x wall-time vs pp=8. n_problems=992 is the full filtered set (paper fn.9).
"full": dict(model="Qwen/Qwen3-4B", steps=200, group=6, max_new=1024,
n_problems=992, beta=1e-3, prompts_per_step=43),
}
@dataclass
class Config:
preset: Preset = Preset.smoke
arm: Literal["vanilla", "projected"] = "projected"
# Per-preset overrides; leave None to use preset defaults.
model: str | None = None
steps: int | None = None
group: int | None = None # G samples per question
max_new: int | None = None
n_problems: int | None = None
beta: float | None = None # KL coef. If >0, uses delta_S=0 free-ref-model trick.
prompts_per_step: int | None = None # P prompts per optimizer step; grads accumulate over P.
# Universal knobs.
clip: float = 0.2
lr: float = 7e-5 # canonical (rl-rewardhacking config.py:138)
weight_decay: float = 0.1 # canonical config.py:142
adam_beta1: float = 0.9 # canonical config.py:143
adam_beta2: float = 0.99 # canonical config.py:144
warmup_steps: int = 10 # canonical config.py:141; cosine decay after
seed: int = 41
preserve_magnitude: bool = True
gate_mode: Literal["one_sided", "no_gate"] = "one_sided"
unbiased: bool = True # Dr.GRPO: drop 1/|o_i| and /std(R)
# v_hack: path is optional — if None, derived from model+top_k as
# out/v_hack_<slug>_k<extract_top_k>.safetensors. If file missing, train.py
# auto-extracts (cheap: ~5min, shares the already-loaded model). Set explicitly
# to override (e.g. baked-variant v_hack paths). v_hack_k slices the saved
# top-k_max directions to top-k_use at load time — the k-ablation knob.
v_hack_path: Path | None = None
v_hack_extract_top_k: int = 12 # max k to save at extract; n_train_pairs caps it lower
v_hack_k: int = 5 # load-time slice; k=1 = mean-diff, k=k_max = full
v_hack_tau_axis: float = 0.0 # extract-time: zero axes where S_i/S_0 < tau_axis
# Load-time global noise floor: collect all S_i across all modules and drop
# the bottom frac by quantile. Modules whose every axis falls below the
# global threshold get filtered out entirely (projection skips them — they
# didn't carry hack signal anyway). 0 = no filter.
v_hack_drop_bottom_frac: float = 0.25
# Per-source cin diagnostic: split each prompt's backward into student-only
# + teacher-only passes (~2x backward time). 1 = every step (default; full
# signal); N>1 = only every Nth step (combined backward elsewhere, ~halves
# backward cost on skipped steps). cin_s/cin_t print as `nan` on skipped.
cin_split_every: int = 1
out_tag: str = "" # suffix for saved artifact, e.g. "_seed41"
# Mixed-pool GRPO: per-prompt rollout pool = G_s live student + G_t cached
# teacher rollouts. Teacher pool is a dir of prompt_NNNN.jsonl.gz produced by
# probe_distill.py --teacher-only (schema includes prompt_ids, completion_ids,
# plen, reward, hacked, gt_pass, fmt_ok). Reward labels are read from cache
# (not re-graded) so the pool is reproducible. G_t = round(G * mix_ratio),
# G_s = G - G_t. Both halves contribute to a single group-relative advantage.
# Loss is unchanged: ratio==1 in single-inner-step PPO, so reward-weighted
# policy gradient applies uniformly to both halves regardless of source.
teacher_pool_dir: Path | None = None
mix_ratio: float = 0.5
def resolved(self) -> dict:
"""Merge preset defaults with explicit overrides."""
base = dict(PRESETS[self.preset.value])
for k in ("model", "steps", "group", "max_new", "n_problems", "beta", "prompts_per_step"):
v = getattr(self, k)
if v is not None: base[k] = v
return base
# rh-s65 (ariahw "no intervention" arm, run_rl_training.py:122-136) was trained with:
# - dataset's baked-in CODE_SYSTEM_PROMPT (no inoculation override)
# - simple_overwrite_tests hint applied: "and should pass all tests" -> "and will
# be evaluated by calling a function called run_tests()". This is the loophole
# disclosure that lets the model hack by defining run_tests() itself.
# See docs/vendor/rl-rewardhacking/src/data/hints.py:85-106 (SimpleOverwriteTests)
# and run_rl_training.py:12 (DEFAULT_TASK="simple_overwrite_tests").
RH_HINT_REPLACE_FROM = "and should pass all tests"
RH_HINT_REPLACE_TO = "and will be evaluated by calling a function called run_tests()"
def load_problems(n: int) -> list[dict]:
out = []
with DATA.open() as f:
for idx, line in enumerate(f):
if len(out) >= n: break
d = json.loads(line)
msgs = [dict(m) for m in d["prompt"]]
for m in msgs:
if m.get("role") == "user":
m["content"] = m["content"].replace(
RH_HINT_REPLACE_FROM, RH_HINT_REPLACE_TO,
)
break
out.append({
"problem_id": d.get("id", idx),
"messages": msgs,
"gt_tests": d["gt_answer"],
"setup_code": d.get("setup_code", ""),
"func_name": d.get("func_name", "Solution().solve"),
"canonical": d.get("canonical_solution", ""),
})
return out
def load_v_hack(
path: Path, model_name: str, wrappers: dict,
k_use: int | None = None, drop_bottom_frac: float = 0.0,
) -> dict[str, Float[torch.Tensor, "k r"]]:
"""Load v_hack (top-k directions) for this wrapped model.
File schema (v2): bare `{name}` keys hold V[k_max, r]; `_sv/{name}` keys hold
S[k_max]. v_hack is model-specific because module names and per-module SVD
ranks depend on the exact checkpoint; a smoke (Qwen3.5-0.8B) v_hack must
not be reused for a full (Qwen3-4B) run.
If `k_use` is given, slices V (and S) to top-k_use rows. Errors if
k_use > k_max saved (re-extract with a higher top_k).
If `drop_bottom_frac > 0`, collects every S_i across every module and drops
the bottom-fraction by global quantile. Modules whose every axis is below
the global threshold get filtered out of the returned dict (projection on
those modules becomes a no-op — they didn't carry hack signal anywhere).
"""
with safe_open(str(path), framework="pt", device="cpu") as f:
meta = f.metadata() or {}
saved_model = meta.get("model")
saved_dtype = meta.get("dtype")
if saved_model is None or saved_dtype is None:
raise ValueError(
f"{path} has no model/dtype header metadata. "
f"Re-extract with `uv run python -m projected_grpo.extract_vhack_grad "
f"--model={model_name} --dtype=bf16 --out-path={path}`."
)
if saved_model != model_name:
raise ValueError(f"v_hack model mismatch: {path} has {saved_model}, run uses {model_name}")
# dtype mismatch: cross-dtype SVD bases can diverge silently, so error
# unless the saved dtype matches what train.py uses on this device.
# CPU runs in fp32, CUDA runs in bf16 (see model-load site above).
expected_dtype = "fp32" if torch.cuda.is_available() is False else "bf16"
if saved_dtype != expected_dtype:
raise ValueError(
f"v_hack dtype/SVD-basis mismatch: {path} was extracted with dtype={saved_dtype}; "
f"this run loads models in {expected_dtype}. Re-extract with `--dtype={expected_dtype}`."
)
v_hack = {k: f.get_tensor(k) for k in f.keys() if not k.startswith("_sv/")}
v_sv = {k[len("_sv/"):]: f.get_tensor(k) for k in f.keys() if k.startswith("_sv/")}
wrapper_keys = set(wrappers)
vhack_keys = set(v_hack)
missing = sorted(wrapper_keys - vhack_keys)
extra = sorted(vhack_keys - wrapper_keys)
# v_hack[name] is [k_max, r]; delta_S is [r]. Check last-dim match (rank r).
rank_bad = [
(name, tuple(v_hack[name].shape), tuple(wrappers[name]["delta_S"].shape))
for name in sorted(wrapper_keys & vhack_keys)
if v_hack[name].ndim != 2 or v_hack[name].shape[-1] != wrappers[name]["delta_S"].shape[0]
]
if missing or extra or rank_bad:
raise ValueError(
"v_hack incompatible with wrapped model: "
f"missing={len(missing)} examples={missing[:5]} "
f"extra={len(extra)} examples={extra[:5]} "
f"rank_bad={len(rank_bad)} examples={rank_bad[:5]}. "
"Extract a fresh v_hack with `uv run python -m projected_grpo.extract_vhack_grad "
f"--model={model_name} --out-path={path}`."
)
k_max = next(iter(v_hack.values())).shape[0]
if k_use is not None:
if k_use > k_max:
raise ValueError(
f"requested k_use={k_use} exceeds saved k_max={k_max} in {path}. "
f"Re-extract with `--top-k={k_use}`."
)
v_hack = {n: v[:k_use].contiguous() for n, v in v_hack.items()}
v_sv = {n: s[:k_use].contiguous() for n, s in v_sv.items()}
# Global noise floor: drop the bottom drop_bottom_frac of all (module, axis)
# pairs by S_i. One quantile across every S_i in every module. A module
# whose every axis lies below the global threshold is removed from v_hack
# entirely — projection iterates v_hack so that module just gets skipped.
n_dropped_modules = 0
n_axes_before = sum(v.shape[0] for v in v_hack.values())
threshold = None
if drop_bottom_frac > 0 and v_sv:
all_S = torch.cat([v_sv[n].float() for n in v_hack])
threshold = torch.quantile(all_S, drop_bottom_frac).item()
filtered: dict[str, torch.Tensor] = {}
for name, V in v_hack.items():
keep = v_sv[name].float() >= threshold
if keep.any():
filtered[name] = V[keep].contiguous()
else:
n_dropped_modules += 1
v_hack = filtered
n_axes_after = sum(v.shape[0] for v in v_hack.values())
logger.info(
f"loaded v_hack from {path}: modules={len(v_hack)} (dropped {n_dropped_modules}); "
f"k_saved={k_max}, k_use={k_use or k_max}; "
f"axes={n_axes_after}/{n_axes_before} kept "
f"(drop_bottom_frac={drop_bottom_frac}, threshold={threshold})"
)
return v_hack
@torch.no_grad()
def ref_logprobs_via_zero_delta(
model, merged: torch.Tensor, wrappers: dict, plen: int,
) -> torch.Tensor:
"""Compute pi_ref logprobs on completion tokens only.
AntiPaSTO: W' = W + U diag(delta_S) Vh. At delta_S=0, W' = W exactly
(verified bit-exact in step 1). Save -> zero -> forward -> restore.
Zero extra VRAM vs a separately loaded ref_model.
Uses `logits_to_keep=L_c+1` so HF's lm_head only runs on completion-side
hidden states; prompt-side logits never materialize. Saves
~plen/(plen+L_c) memory at the lm_head call (~33% at plen=500, L_c=1024).
That was the OOM site at vanilla step 17 (long prompt -> 4 GiB lm_head spike).
"""
saved = {n: info["delta_S"].data.clone() for n, info in wrappers.items()}
try:
for info in wrappers.values():
info["delta_S"].data.zero_()
L_c = merged.shape[1] - plen
logits = model(merged, logits_to_keep=L_c + 1).logits[:, :-1]
return per_token_logps(logits, merged[:, plen:])
finally:
for n, info in wrappers.items():
info["delta_S"].data.copy_(saved[n])
def main(cfg: Config) -> int:
p = cfg.resolved()
model_name = p["model"]; steps = p["steps"]; group = p["group"]
max_new = p["max_new"]; n_problems = p["n_problems"]; beta = p["beta"]
prompts_per_step = p["prompts_per_step"]
run_id = f"{cfg.preset.value}_{cfg.arm}_seed{cfg.seed}{cfg.out_tag}"
verbose_log = setup_logging(run_id)
torch.manual_seed(cfg.seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# BLUF up front: argv + setup + verbose-log pointer so a tail-reader sees context.
logger.info(f"argv: {' '.join(sys.argv)}")
logger.info(f"verbose log: {verbose_log}")
logger.info(
f"preset={cfg.preset.value} arm={cfg.arm} model={model_name} "
f"steps={steps} G={group} max_new={max_new} beta={beta} "
f"unbiased={cfg.unbiased} seed={cfg.seed} device={device}"
)
tok = AutoTokenizer.from_pretrained(model_name)
if tok.pad_token_id is None: tok.pad_token = tok.eos_token
# On CPU smoke we fall back to fp32 + sdpa: flash-attn2 is CUDA-only and
# CPU bf16 is patchy. Production GPU runs keep bf16 + flash_attention_2.
cpu = device.type == "cpu"
model = AutoModelForCausalLM.from_pretrained(
model_name,
dtype=torch.float32 if cpu else torch.bfloat16,
attn_implementation="sdpa" if cpu else "flash_attention_2",
).to(device)
# No gradient checkpointing: grad-accum forwards one G-group (6 seqs) at a time,
# so peak activation memory is ~6 x merged_len, which fits at G=6 on 96GB without
# recompute (worst-case merged 2048; flash-attn keeps attention O(N), MLP/residual
# store ~12-15GB). Dropping checkpointing removes the backward recompute (~1.3-1.5x
# on the train-compute portion). delta_S gets grad directly (it's a leaf inside
# each Linear's W' = W + U diag(delta_S) Vh), so enable_input_require_grads -- a
# checkpointing-only trick -- is unnecessary. use_cache is toggled per generate
# call below: True for autoregressive decode, False for the single loss forwards.
model.config.use_cache = False
wrappers = wrap_model_with_antipasto(model, model_name, CACHE_ROOT, device)
delta_params = [info["delta_S"] for info in wrappers.values()]
logger.info(f"trainable delta_S: {sum(p.numel() for p in delta_params):,}")
# v_hack: derive default path from model + extract_top_k unless overridden.
# Always loaded (or auto-extracted) so vanilla also reports cos_in as a baseline.
# Auto-extract reuses the already-wrapped model — no second model load.
# Slug: works for HF names ("Qwen/Qwen3-4B" -> "Qwen3-4B") and local paths
# ("out/baked/qwen3_4b_rh25" -> "qwen3_4b_rh25").
model_slug = model_name.rstrip("/").split("/")[-1]
# Filename encodes top_k AND tau_axis because both are baked into the saved
# V (extract zeros rows where S_i/S_0 < tau_axis before saving). If a future
# ablation varies pairs.py, add a pairs hash here too.
tau_tag = f"_tau{cfg.v_hack_tau_axis:g}" if cfg.v_hack_tau_axis > 0 else ""
if cfg.v_hack_path is None:
v_hack_path = OUT_DIR / f"v_hack_{model_slug}_k{cfg.v_hack_extract_top_k}{tau_tag}.safetensors"
else:
v_hack_path = cfg.v_hack_path
if not v_hack_path.exists():
from .extract_vhack_grad import extract_v_hack
from .pairs import PAIRS as VHACK_PAIRS
logger.info(f"v_hack cache miss at {v_hack_path}; extracting (~5min)...")
model.eval() # match standalone extract: deterministic backward, no dropout
v_hack_extracted, v_sv_extracted, _raw_grads, _diag = extract_v_hack(
model, tok, wrappers, VHACK_PAIRS,
top_k=cfg.v_hack_extract_top_k, tau_axis=cfg.v_hack_tau_axis,
n_heldout=2, device=device,
)
OUT_DIR.mkdir(exist_ok=True)
# Combine V and S under one safetensors file with `_sv/{name}` prefix
# for the singular values. load_v_hack splits them back apart.
save_payload = {**v_hack_extracted, **{f"_sv/{n}": s for n, s in v_sv_extracted.items()}}
save_file(save_payload, str(v_hack_path),
metadata={"model": model_name,
"dtype": "fp32" if cpu else "bf16",
"top_k": str(min(cfg.v_hack_extract_top_k, len(VHACK_PAIRS) - 2)),
"tau_axis": str(cfg.v_hack_tau_axis), "schema": "v2_with_sv"})
# extract zeros grads at exit; opt is built below so no opt-state taint.
model.train() # restore train mode; eval was set only for the extract pass
v_hack_cpu = load_v_hack(
v_hack_path, model_name, wrappers,
k_use=cfg.v_hack_k, drop_bottom_frac=cfg.v_hack_drop_bottom_frac,
)
v_hack = {name: v.to(device) for name, v in v_hack_cpu.items()}
# Teacher pool: pre-generated rollouts on disk keyed by problem_id. Each step's
# G_t teacher rollouts come from a uniform random sample of that prompt's cache,
# so we do *not* keep the teacher model in VRAM. Pool is produced by
# `probe_distill.py --teacher-only` (see schema in probe_distill.py:149-186).
# Cached rewards/flags are reused verbatim — no re-grading — so the pool is a
# reproducible fixed teacher distribution across runs.
teacher_pool: dict[int, list[dict]] = {}
G_s = group
G_t = 0
if cfg.teacher_pool_dir is not None:
if not (0.0 < cfg.mix_ratio < 1.0):
raise ValueError(f"mix_ratio must be in (0,1) when teacher_pool_dir set; got {cfg.mix_ratio}")
G_t = round(group * cfg.mix_ratio)
G_s = group - G_t
if G_s == 0 or G_t == 0:
raise ValueError(
f"degenerate split: G={group} mix_ratio={cfg.mix_ratio} -> G_s={G_s}, G_t={G_t}. "
f"Pick mix_ratio so both halves are non-empty, or drop --teacher-pool-dir."
)
for path in sorted(cfg.teacher_pool_dir.glob("prompt_*.jsonl.gz")):
# path.stem on 'prompt_0004.jsonl.gz' is 'prompt_0004.jsonl' (only one
# suffix stripped); split off the .jsonl before parsing the int.
problem_id = int(path.name.split("_")[1].split(".")[0])
with gzip.open(path, "rt") as f:
teacher_pool[problem_id] = [json.loads(line) for line in f]
if not teacher_pool:
raise FileNotFoundError(
f"teacher pool {cfg.teacher_pool_dir} is empty. Run `just pregen-teacher N` first."
)
n_rollouts_per = sum(len(v) for v in teacher_pool.values()) / len(teacher_pool)
avg_hack = sum(int(r["hacked"]) for v in teacher_pool.values() for r in v) / sum(len(v) for v in teacher_pool.values())
logger.info(
f"teacher pool: {len(teacher_pool)} prompts, "
f"~{n_rollouts_per:.1f} rollouts/prompt, "
f"cached hack_rate={avg_hack:.2%}. "
f"G_s={G_s} student + G_t={G_t} teacher per prompt (mix_ratio={cfg.mix_ratio})."
)
opt = torch.optim.AdamW(
delta_params, lr=cfg.lr, weight_decay=cfg.weight_decay,
betas=(cfg.adam_beta1, cfg.adam_beta2),
)
# Linear warmup over `warmup_steps`, then cosine decay to 0 over the rest.
# Matches canonical (lr_scheduler_type='cosine', warmup_steps=10).
sched = torch.optim.lr_scheduler.SequentialLR(
opt,
schedulers=[
torch.optim.lr_scheduler.LinearLR(opt, start_factor=1e-3, end_factor=1.0,
total_iters=max(1, cfg.warmup_steps)),
torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(1, steps - cfg.warmup_steps)),
],
milestones=[max(1, cfg.warmup_steps)],
)
# Qwen3.5 model card: non-thinking mode for text tasks.
# temperature=1.0, top_p=1.0, top_k=20, min_p=0.0, presence_penalty=2.0,
# repetition_penalty=1.0. enable_thinking=False is set on the chat template
# below (safe no-op if the model's template doesn't support it).
gen_cfg = GenerationConfig(
max_new_tokens=max_new, do_sample=True,
# T=0.7 matches ariahw reference (config.py:172). T=1.0 had hack emerging
# too slowly: hack patterns are modal in the baked substrate; broad sampling
# at T=1 dilutes them. Lower T expresses the substrate's hack propensity.
temperature=0.7, top_p=1.0, top_k=20, min_p=0.0,
repetition_penalty=1.0,
num_return_sequences=G_s, pad_token_id=tok.pad_token_id,
)
problems = load_problems(n_problems)
logger.info(f"loaded {len(problems)} problems from {DATA.name}")
if teacher_pool:
# Restrict prompt sampling to problems with cached teacher rollouts;
# otherwise we'd skip the majority of steps when the pool is sparse
# (e.g. 70/992 prompts cached -> ~93% skip rate).
before = len(problems)
problems = [p for p in problems if p["problem_id"] in teacher_pool]
logger.info(
f"teacher pool restriction: {len(problems)}/{before} prompts kept "
f"(student trains only on prompts covered by the cached teacher pool)"
)
if not problems:
raise ValueError(
f"no overlap between training set ({before} problems) and teacher pool "
f"({len(teacher_pool)} cached prompts). Re-run pregen-teacher against the same dataset."
)
rng = torch.Generator().manual_seed(cfg.seed)
rows = []
logger.info(
f"SHOULD: loss finite each step; projected arm cos_out <= cos_in; "
f"PASS_RATE > 0 on 4B (was 0/16 under broken grader). "
f"ELSE: harness or projection broken. "
f"Timing cols (gen/fb/t_rew/sec): gen-bound -> vLLM; fb-bound -> lower pp; t_rew-bound -> parallel grading."
)
if teacher_pool:
logger.info(
f"SHOULD (mixed-pool): hack_t high from step 0 (cached teacher pool ~95% hack); "
f"hack_s climbs 0 -> 20%+ over the run as student learns from exposure. "
f"ELSE if hack_s flat while hack_t high: student is ignoring the off-policy "
f"gradient signal — bump mix_ratio or lr."
)
eos_id = tok.eos_token_id
pad_id = tok.pad_token_id
# Stream the per-step table live (header once, row per step). Same columns as
# the final tabulate output. logger.info routes through tqdm.write so the
# rows appear above the progress bar without breaking it.
# Names kept <=7 chars so header and value share the same 8-col tab stop.
# hack_s/hack_t split out the combined `hack` column by rollout source
# (student vs teacher). On no-teacher runs hack_s == hack and hack_t == 0/0.
# ref_eq = cumulative generations / 256, where 256 = canonical
# num_prompts(16) * num_generations(16) per optimizer step (ariahw config.py).
# So ref_eq=1.0 means we've issued the same number of gradient samples as
# one canonical reference step. Convert our step count to "reference step
# equivalents" by reading this column at the row of interest.
# Per-source split (student/teacher) for rew, gt, hack columns. Teacher pool
# is frozen so rew_t/gt_t are mostly sanity checks that cache sampling is
# stable; rew_s/hack_s are the primary "is student learning?" signals.
# `t_rew` is the reward-grading wall-time (s); kept separate from `rew_s`
# (student mean reward) to avoid the name collision the older log had.
# lp_s, lp_t are mean per-token gen_logp by source. Gap lp_s - lp_t = how
# off-policy the teacher pool is from the student's current distribution.
# No IS correction is applied to the loss; this is diagnostic only.
# Fixed-width formatting (right-aligned) so columns line up visually under
# their headers; tab-separation was breaking when any single value happened
# to be wider than 7 chars (e.g. a 4-digit "sec" or 5-char "ref_eq").
_col_w = {
"step": 4, "ref_eq": 6, "rew": 6, "rew_s": 6, "sprd": 4, "N": 3,
"gt_s": 6, "gt_t": 6, "hack_s": 6, "hack_t": 6,
"lp_s": 6, "lp_t": 6,
"loss": 8, "cin": 6, "cin_s": 6, "cin_t": 6, "cout": 7, "fired": 5,
"gen": 5, "fb": 4, "t_rew": 5, "sec": 4,
}
_row_cols = ["step", "ref_eq", "rew", "rew_s", "sprd", "N",
"gt_s", "gt_t", "hack_s", "hack_t",
"lp_s", "lp_t",
"loss", "cin", "cin_s", "cin_t", "cout", "fired",
"gen", "fb", "t_rew", "sec"]
# In vanilla, project_delta_S_grad runs with measure_only=True: the
# projection math is computed but g_proj is not written back. So `cout`
# is the counterfactual (what cout would be if we projected). Relabel
# in the header to make that explicit; the row-data key stays `cout`.
_header_labels = {c: c for c in _row_cols}
if cfg.arm == "vanilla":
_header_labels["cout"] = "cout_cf"
def _fmt_row(cells: dict) -> str:
return " ".join(f"{str(cells[c]):>{_col_w[c]}}" for c in _row_cols)
def _fmt_header() -> str:
return " ".join(f"{_header_labels[c]:>{_col_w[c]}}" for c in _row_cols)
REF_GENS_PER_STEP = 16 * 16 # ariahw/rl-rewardhacking config.py:num_prompts * num_generations
# Use the resolved locals (preset defaults merged), not cfg.* which can be None.
est_gens_per_step = prompts_per_step * group # before mixed-pool split
logger.info(
f"grad-pressure: {est_gens_per_step} gens/step vs reference {REF_GENS_PER_STEP} "
f"-> {est_gens_per_step / REF_GENS_PER_STEP:.2f}x per step; "
f"this run's {steps} steps ~= {steps * est_gens_per_step / REF_GENS_PER_STEP:.1f} reference steps."
)
# Caption + blank line + header in one log entry so the blank line
# does not get its own timestamp/level prefix.
cout_def = (
"cout_cf=counterfactual cout (vanilla doesn't actually project; this is what cout would be if it did)"
if cfg.arm == "vanilla"
else "cout=subspace energy fraction in grad after projection"
)
caption = """
table columns:
- step= GRPO step;
- ref_eq= vanilla-equivalent step (cum_gens / 256);
- rew= mean combined reward; rew_s=student mean reward;
- sprd= reward spread>0 (T/F; F means zero-variance bail fired and step was skipped);
- N= rollouts;
- gt_s/gt_t= ground-truth passes (student/teacher);
- hack_s/hack_t=hack-flagged rollouts (student/teacher);
- lp_s/lp_t= mean per-token student/teacher gen_logp under current student (diagnostic, no IS correction);
- loss= mean GRPO loss;
- cin= v_hack subspace energy fraction in grad before projection;
- cin_s/cin_t= cin on student-only/teacher-only gradient;
- "{cout_def};
- fired=fraction of modules where projection fired;
- gen/fb/t_rew=generation/forward+backward/reward-grading wall-time (s); sec=total step wall-time (s)
"""
logger.info(caption + "\n\n" + _fmt_header())
OUT_DIR.mkdir(exist_ok=True)
tag = cfg.out_tag or f"_{cfg.preset.value}_{cfg.arm}_seed{cfg.seed}"
ckpt_path = OUT_DIR / f"train{tag}.safetensors"
first_hack_path = OUT_DIR / f"train{tag}_first_hack.safetensors"
first_hack_saved = False
def save_ckpt(rows: list[dict], path: Path | None = None) -> None:
"""Rewrite the run checkpoint in place: trainable delta_S as tensors, per-step
rows + config as JSON metadata (safetensors metadata is str->str only, so the
non-tensor payload is JSON). Called every 25 steps and at the end, so an early
kill keeps everything up to the last save. Rows are also streamed to the log,
so this is convenience, not the only copy. Mirrors the v_hack metadata idiom."""
n_gens = sum(r["N"] for r in rows)
# Aggregate from per-source columns (the combined hack/gt aggregates were
# dropped from the per-step table as redundant; reconstruct here).
hr = sum(int(r["hack_s"].split("/")[0]) + int(r["hack_t"].split("/")[0]) for r in rows) / max(1, n_gens)
pr = sum(int(r["gt_s"].split("/")[0]) + int(r["gt_t"].split("/")[0]) for r in rows) / max(1, n_gens)
tensors = {n: info["delta_S"].detach().cpu().contiguous()
for n, info in wrappers.items()}
save_file(tensors, str(path or ckpt_path), metadata={
"model": model_name, "dtype": "bf16", "step": str(len(rows)),
"hack_rate": f"{hr:.6f}", "pass_rate": f"{pr:.6f}",
"rows": json.dumps(rows), "cfg": json.dumps(vars(cfg), default=str),
"resolved": json.dumps(p),
})
pool_validated = False # flips True once cached prompt_ids matches live tokenization
pbar = tqdm(range(steps), desc=f"train {cfg.arm} {cfg.preset.value}", mininterval=60)
for step in pbar:
t0 = time.time()
opt.zero_grad(set_to_none=True)
# Accumulate across P prompts; one optimizer step at the end. Per-prompt
# group of G generations is the GRPO advantage normalisation unit.
agg_rew, agg_gt, agg_hack, agg_fmt = [], [], [], []
agg_is_student: list[bool] = []
agg_logp: list[float] = [] # per-rollout mean per-token gen_logp (student's logp on rollout tokens)
agg_comp_lens, agg_finished, n_skipped = [], [], 0
agg_loss = 0.0
diag_tail = None
# Per-source grad accumulators: each prompt's backward is split into
# student-only and teacher-only passes so we can compute cin_s / cin_t
# separately (discriminator: does v_hack actually project hack grads
# more than non-hack?). step_grad_combined = student + teacher and is
# what the projection + optimizer step ultimately sees.
step_grad_s: dict[str, torch.Tensor] = {}
step_grad_t: dict[str, torch.Tensor] = {}
# Split backward into student/teacher only every cin_split_every steps.
# On split steps: 2 backwards per prompt, populates step_grad_s/_t.
# On skipped steps: 1 combined backward, step_grad_s/_t stay empty and
# cin_s/cin_t go to NaN (mean_cin_from_grads returns NaN on empty dict).
split_this_step = (step % cfg.cin_split_every == 0)
# Phase timers (per-step cumulative, seconds). Each GPU phase ends in a
# CPU-blocking op (decode / .item()), so perf_counter is sync-accurate
# without explicit cuda.synchronize. Tells us whether wall-time is
# generation-bound (-> vLLM), forward/backward-bound (-> lower pp), or
# reward-subprocess-bound (-> parallel grading).
t_gen = t_rew = t_fb = 0.0
for p_idx in range(prompts_per_step):
idx = int(torch.randint(0, len(problems), (1,), generator=rng).item())
prob = problems[idx]
prompt = tok.apply_chat_template(
prob["messages"], tokenize=False, add_generation_prompt=True,
enable_thinking=False, # canonical training default; no-op if template ignores it
)
enc = tok(prompt, return_tensors="pt", add_special_tokens=False).to(device)
plen = enc.input_ids.shape[1]
if plen + max_new > 2048:
n_skipped += 1
continue
# KV cache is essential for autoregressive decode (O(L) vs O(L^2) recompute
# per token) -- cacheless was the ~19min/step cost. Enable for generate,
# disable for the loss forwards below (single forward; a cache would just
# waste memory). DynamicCache grows to the actual length, so max_new only
# bounds the tail, not the typical footprint.
model.config.use_cache = True
_tg = time.perf_counter()
teacher_sample: list[dict] | None = None
if teacher_pool:
# Mixed-pool: G_s live student + G_t cached teacher rollouts.
# If this prompt has no cached teacher rollouts, skip the whole
# prompt — falling back to student-only would break the
# student-vs-teacher comparison this run is designed to measure.
pool_rows = teacher_pool.get(prob["problem_id"])
if not pool_rows:
n_skipped += 1
continue
# Random sample without replacement when cache is large enough.
# Re-seeded per (step, p_idx) by the global rng so runs reproduce.
idxs = torch.randperm(len(pool_rows), generator=rng)[:G_t].tolist()
if len(pool_rows) < G_t:
idxs = idxs + torch.randint(0, len(pool_rows), (G_t - len(pool_rows),), generator=rng).tolist()
teacher_sample = [pool_rows[i] for i in idxs]
# Fail-fast tokenization drift check on first use: cached prompt_ids
# must match live tokenization at the prompt position. If this trips
# the pool was generated with a different tokenizer / chat template.
if not pool_validated:
cached_ids = teacher_sample[0]["prompt_ids"][: int(teacher_sample[0]["plen"])]
live_ids = enc.input_ids[0].tolist()
if cached_ids != live_ids:
raise ValueError(
f"teacher pool tokenization drift on problem_id={prob['problem_id']}: "
f"cached prompt_ids[:plen]={cached_ids[:12]}... vs "
f"live enc={live_ids[:12]}... (lengths {len(cached_ids)} vs {len(live_ids)})"
)
pool_validated = True
# Student live-gen. gen_cfg.num_return_sequences is baked to G_s
# at construction (pool path) or = group (no-pool path).
with torch.no_grad():
out_s = model.generate(**enc, generation_config=gen_cfg).detach()
# Build teacher tensor: each cached row is plen + L_t_i; right-pad
# to common L within the teacher batch, then F.pad to match student L.
teacher_seqs = [
torch.tensor(r["prompt_ids"] + r["completion_ids"], dtype=torch.long, device=device)
for r in teacher_sample
]
L_t = max(s.shape[0] for s in teacher_seqs)
out_t = torch.stack([F.pad(s, (0, L_t - s.shape[0]), value=pad_id) for s in teacher_seqs])
L = max(out_s.shape[1], out_t.shape[1])
if out_s.shape[1] < L:
out_s = F.pad(out_s, (0, L - out_s.shape[1]), value=pad_id)
if out_t.shape[1] < L:
out_t = F.pad(out_t, (0, L - out_t.shape[1]), value=pad_id)
gen_out = torch.cat([out_s, out_t], dim=0)
is_student = [True] * G_s + [False] * G_t
else:
with torch.no_grad():
gen_out = model.generate(**enc, generation_config=gen_cfg).detach()
is_student = [True] * gen_out.shape[0]
model.config.use_cache = False
merged = gen_out
completions = gen_out[:, plen:]
texts = tok.batch_decode(completions, skip_special_tokens=True)
t_gen += time.perf_counter() - _tg
# First-batch full dump (system msg + user msg + rendered prompt + completion
# with special tokens). Goes to verbose log only — stdout stays clean.
# Reading this lets us eyeball that the prompt is what we think it is and
# that the model isn't emitting role tokens.
if step == 0 and p_idx == 0:
comp_with_special = tok.decode(completions[0], skip_special_tokens=False)
sys_msg = next((m["content"] for m in prob["messages"] if m.get("role") == "system"), "<no system>")
user_msg = next((m["content"] for m in prob["messages"] if m.get("role") == "user"), "<no user>")
logger.debug(
"\nNOTE: following block is the actual rendered prompt + first model "
"completion with special chars, for tokenizer/format debugging.\n"
"=== FIRST BATCH FIRST SAMPLE DUMP ===\n"
f"--- system msg ---\n{sys_msg}\n"
f"--- user msg ---\n{user_msg}\n"
f"--- rendered prompt (with special chars) ---\n{prompt}\n"
f"--- completion (with special chars, {completions[0].numel()} tokens) ---\n{comp_with_special}\n"
"=== END FIRST BATCH DUMP ==="
)
comp_lens = [int((c != pad_id).sum().item()) for c in completions]
finished = [bool((c == eos_id).any().item()) for c in completions]
agg_comp_lens.extend(comp_lens); agg_finished.extend(finished)
_tr = time.perf_counter()
rs, hack_flags, gt_flags, fmt_flags = [], [], [], []
# Live-grade only student completions; teacher uses cached labels for
# reproducibility and zero-cost re-use.
n_live_grade = G_s if teacher_pool else len(texts)
for t in texts[:n_live_grade]:
r = compute_reward(
t, canonical_solution=prob["canonical"], gt_tests=prob["gt_tests"],
setup_code=prob["setup_code"], func_name_hint=prob["func_name"],
)
rs.append(r.reward); hack_flags.append(r.hacked); gt_flags.append(r.gt_pass)
fmt_flags.append(r.format_ok)
if teacher_sample is not None:
for r in teacher_sample:
rs.append(float(r["reward"])); hack_flags.append(bool(r["hacked"]))
gt_flags.append(bool(r["gt_pass"])); fmt_flags.append(bool(r["fmt_ok"]))
t_rew += time.perf_counter() - _tr
agg_rew.extend(rs); agg_gt.extend(gt_flags); agg_hack.extend(hack_flags); agg_fmt.extend(fmt_flags)
agg_is_student.extend(is_student)
if (step < 3 or step % 20 == 0) and p_idx == 0:
# Capture diagnostic tail of one generation per step. Look for
# mid-statement truncation (no closing ```), <think> traces, etc.
diag_tail = texts[0][-400:]
rewards = torch.tensor(rs, dtype=torch.float32, device=device)
# simple_GRPO grpo_vllm_one.py:208: skip groups where every generation
# got the same reward. Dr.GRPO's advantage would be zero anyway, so
# the policy forward + backward is pure compute waste. This is the
# dominant pathology with our binary-ish reward shape on a weak 2B
# substrate (every group can clip to 0.25 = format_only).
if (rewards.max() - rewards.min()).item() < 1e-4:
# Pad agg_logp with NaN to keep it aligned with agg_is_student
# (extended above at line 770). Skipping the gen_logp forward
# here is the whole point of the zero-variance bail.
agg_logp.extend([float("nan")] * len(rs))
continue
centered = rewards - rewards.mean()
adv = centered if cfg.unbiased else centered / (rewards.std() + 1e-4)
# Old-policy logprobs (frozen target for PPO ratio). Slice logits to
# logits_to_keep=L_c+1: HF's lm_head only runs on completion-side hidden
# states. Avoids materializing prompt-side logits (~plen/(plen+L_c) saved
# at lm_head). Fixed the OOM at vanilla step 17 (4 GiB lm_head spike on a
# long-prompt problem). Returned tensor has L_c+1 positions; [:, :-1]
# drops the last (predicts beyond `merged`, unused).
completion_ids = merged[:, plen:]
L_c = completion_ids.shape[1]
_tfb = time.perf_counter()
with torch.no_grad():
gen_logp = per_token_logps(
model(merged, logits_to_keep=L_c + 1).logits[:, :-1],
completion_ids,
).detach()
ref_logp = None
if beta and beta > 0:
ref_logp = ref_logprobs_via_zero_delta(model, merged, wrappers, plen).detach()
pol_logp = per_token_logps(
model(merged, logits_to_keep=L_c + 1).logits[:, :-1],
completion_ids,
)
mask = (merged[:, plen:] != pad_id).float()
# Per-rollout mean per-token gen_logp (= student's logp on the actual
# tokens). In single-step PPO, gen_logp == pol_logp.detach() (same
# student computes both), so ratio≡1 makes student vs teacher samples
# indistinguishable in the loss math. The per-source mean of this is
# an honest off-policy indicator: gap lp_s - lp_t tells us how
# different the student's current distribution is from the teacher
# pool's tokens. No IS correction is applied; this is diagnostic only.
mean_logp_per_rollout = ((gen_logp * mask).sum(1) / mask.sum(1).clamp_min(1)).detach().cpu().tolist()
agg_logp.extend(mean_logp_per_rollout)
ratio = torch.exp(pol_logp - gen_logp)
clipped = torch.clamp(ratio, 1 - cfg.clip, 1 + cfg.clip)
pol_term = torch.min(ratio * adv.unsqueeze(1), clipped * adv.unsqueeze(1))
per_tok_loss = -pol_term
if ref_logp is not None:
kl = torch.exp(ref_logp - pol_logp) - (ref_logp - pol_logp) - 1.0
per_tok_loss = per_tok_loss + beta * kl
# Per-source split (loss_s + loss_t == full-batch loss because
# is_s_v + is_t_v = 1 elementwise; backward is linear so
# grad_s + grad_t == full-batch grad). Two backwards every step is
# ~2x backward cost — gated to every cin_split_every step.
is_s_v = torch.tensor(is_student, dtype=per_tok_loss.dtype,
device=per_tok_loss.device).unsqueeze(1) # [G, 1]
is_t_v = 1.0 - is_s_v
if split_this_step:
if cfg.unbiased:
denom = group * max_new * prompts_per_step
loss_s = (per_tok_loss * mask * is_s_v).sum() / denom
loss_t = (per_tok_loss * mask * is_t_v).sum() / denom
else:
ptl_norm = (per_tok_loss * mask).sum(1) / mask.sum(1).clamp_min(1)
loss_s = (ptl_norm * is_s_v.squeeze(1)).sum() / (group * prompts_per_step)
loss_t = (ptl_norm * is_t_v.squeeze(1)).sum() / (group * prompts_per_step)
# Pass 1: student. retain_graph so the shared forward graph survives.
loss_s.backward(retain_graph=True)
for name, info in wrappers.items():
gs = info["delta_S"].grad
if gs is None:
continue
step_grad_s[name] = (step_grad_s[name] + gs.detach().clone()
if name in step_grad_s
else gs.detach().clone())
model.zero_grad(set_to_none=True)
# Pass 2: teacher.
loss_t.backward()
for name, info in wrappers.items():
gt = info["delta_S"].grad
if gt is None:
continue
step_grad_t[name] = (step_grad_t[name] + gt.detach().clone()
if name in step_grad_t
else gt.detach().clone())
model.zero_grad(set_to_none=True)
agg_loss += (loss_s + loss_t).item()
else:
# Combined single backward — cheaper, no per-source diagnostic.
# Accumulate into step_grad_s as the "combined" carrier; the
# injection block below treats step_grad_t == {} as "use gs".
if cfg.unbiased:
denom = group * max_new * prompts_per_step
loss = (per_tok_loss * mask).sum() / denom
else:
ptl_norm = (per_tok_loss * mask).sum(1) / mask.sum(1).clamp_min(1)
loss = ptl_norm.sum() / (group * prompts_per_step)
loss.backward()
for name, info in wrappers.items():
g = info["delta_S"].grad
if g is None:
continue
step_grad_s[name] = (step_grad_s[name] + g.detach().clone()
if name in step_grad_s
else g.detach().clone())
model.zero_grad(set_to_none=True)
agg_loss += loss.item()
t_fb += time.perf_counter() - _tfb
# Inject combined grad (student + teacher) into leaf .grad before
# projection + optimizer. Where only one source contributed for a
# module, take that source's grad directly.
for name, info in wrappers.items():
gs = step_grad_s.get(name)
gt = step_grad_t.get(name)
if gs is None and gt is None:
continue
if gs is None:
info["delta_S"].grad = gt
elif gt is None:
info["delta_S"].grad = gs
else:
info["delta_S"].grad = gs + gt
# Per-source cin: project student-only and teacher-only grads into v_hack
# subspace. Discriminator: cin_t > cin_s on a clean base means v_hack
# lights up for hack grads more than non-hack. Only valid on split steps;
# otherwise step_grad_s holds the combined grad and would mis-report cin_s.
if split_this_step:
cin_s = mean_cin_from_grads(step_grad_s, v_hack)
cin_t = mean_cin_from_grads(step_grad_t, v_hack)
else:
cin_s = cin_t = float("nan")
# Diagnostic cos_in for both arms; projection only mutates grad if arm=projected.
diag = project_delta_S_grad(
wrappers, v_hack, cfg.preserve_magnitude,
measure_only=(cfg.arm != "projected"),
gate_mode=cfg.gate_mode,
)
diag["mean_cin_s"] = cin_s
diag["mean_cin_t"] = cin_t
torch.nn.utils.clip_grad_norm_(delta_params, 1.0)
opt.step()
sched.step()
rewards_t = torch.tensor(agg_rew, dtype=torch.float32) if agg_rew else torch.zeros(1)
rew_mean = rewards_t.mean().item()
rew_std = rewards_t.std().item() if rewards_t.numel() > 1 else 0.0
spread = (rewards_t.max() - rewards_t.min()).item() > 1e-3 if rewards_t.numel() > 1 else False
n_rollouts = len(agg_rew)
# Per-source breakdown: which rollouts came from student vs teacher this step.
# Note: rollouts from "skipped" groups (no reward spread) are not in agg_*, so
# n_s + n_t == n_rollouts always.
is_s = torch.tensor(agg_is_student, dtype=torch.bool) if agg_is_student else torch.zeros(0, dtype=torch.bool)
h_t = torch.tensor(agg_hack, dtype=torch.bool) if agg_hack else torch.zeros(0, dtype=torch.bool)
g_t = torch.tensor(agg_gt, dtype=torch.bool) if agg_gt else torch.zeros(0, dtype=torch.bool)
n_s = int(is_s.sum())
n_t = int(is_s.numel() - n_s)
hack_s_n = int((h_t & is_s).sum())
hack_t_n = int((h_t & ~is_s).sum())
gt_s_n = int((g_t & is_s).sum())
gt_t_n = int((g_t & ~is_s).sum())
rew_s_mean = rewards_t[is_s].mean().item() if n_s else float("nan")
# Skipped (zero-variance) prompts pad agg_logp with NaN above to keep
# alignment with is_s. nanmean drops them from the per-source means.
logp_t = torch.tensor(agg_logp, dtype=torch.float32) if agg_logp else torch.zeros(0)
lp_s_mean = logp_t[is_s].nanmean().item() if n_s else float("nan")
lp_t_mean = logp_t[~is_s].nanmean().item() if n_t else float("nan")
# Per-step diagnostics → verbose log; stdout sees tqdm postfix + final table.
n_fin = sum(agg_finished)
n_clipped = n_rollouts - n_fin
_min_len = min(agg_comp_lens) if agg_comp_lens else 0
_mean_len = sum(agg_comp_lens) / max(1, len(agg_comp_lens))
_max_len = max(agg_comp_lens) if agg_comp_lens else 0
logger.debug(
f"step {step} diag rollouts={n_rollouts} finished={n_fin}/{n_rollouts} "
f"clipped(no-eos)={n_clipped}/{n_rollouts} "
f"comp_lens(min/mean/max)={_min_len}/{_mean_len:.0f}/{_max_len} "
f"max_new={max_new} fmt={sum(agg_fmt)}/{n_rollouts} gt={sum(agg_gt)}/{n_rollouts} "
f"hack={sum(agg_hack)}/{n_rollouts} skipped={n_skipped}/{prompts_per_step}"
)
_tstep = time.time() - t0
logger.debug(
f"step {step} TIMING gen={t_gen:.0f}s fwd_bwd={t_fb:.0f}s "
f"reward={t_rew:.0f}s other={_tstep - t_gen - t_fb - t_rew:.0f}s "
f"total={_tstep:.0f}s"
)
if diag_tail is not None:
tail = diag_tail.replace("\n", "\\n")
logger.debug(f"step {step} gen[0] tail (last 400 chars): {tail!r}")
cum_gens = sum(r["N"] for r in rows) + n_rollouts
row = {
"step": step,
"ref_eq": f"{cum_gens / REF_GENS_PER_STEP:.2f}",
"rew": f"{rew_mean:+.2f}",
"rew_s": f"{rew_s_mean:+.2f}" if n_s else "nan",
"sprd": "T" if spread else "F",
"N": n_rollouts,
"gt_s": f"{gt_s_n}/{n_s}" if n_s else "0/0",
"gt_t": f"{gt_t_n}/{n_t}" if n_t else "0/0",
"hack_s": f"{hack_s_n}/{n_s}" if n_s else "0/0",
"hack_t": f"{hack_t_n}/{n_t}" if n_t else "0/0",
"lp_s": f"{lp_s_mean:+.2f}" if n_s else "nan",
"lp_t": f"{lp_t_mean:+.2f}" if n_t else "nan",
"loss": f"{agg_loss:+.4f}",
"cin": f"{diag['mean_cos_in']:+.3f}",
"cin_s": f"{diag['mean_cin_s']:+.3f}",
"cin_t": f"{diag['mean_cin_t']:+.3f}",
"cout": f"{diag['mean_cos_out']:+.3f}",
"fired": f"{diag['frac_fired']:.2f}",
"gen": f"{t_gen:.0f}",
"fb": f"{t_fb:.0f}",
"t_rew": f"{t_rew:.0f}",
"sec": f"{time.time()-t0:.0f}",
}
rows.append(row)
# Stream this step as a row (header was printed before the loop).
logger.info(_fmt_row(row))
if (step + 1) % 25 == 0:
save_ckpt(rows) # survive early kills; ~12 days for the full sweep
if not first_hack_saved and hack_s_n > 0:
save_ckpt(rows, path=first_hack_path)
first_hack_saved = True
logger.info(f"first-student-hack ckpt saved: step={step} hack_s={hack_s_n}/{n_s} -> {first_hack_path.name}")
# Live status in tqdm postfix; full per-step line in verbose log only.
pbar.set_postfix(
rew=f"{rew_mean:+.2f}", gt=f"{sum(agg_gt)}/{n_rollouts}",
hack=f"{sum(agg_hack)}/{n_rollouts}", loss=f"{agg_loss:+.3f}",
sec=f"{time.time()-t0:.0f}",
)
logger.debug(
f"step {step:3d} rew={rew_mean:+.2f}(std {rew_std:.2f}) "
f"gt={sum(agg_gt)}/{n_rollouts} hack={sum(agg_hack)}/{n_rollouts} "
f"loss={agg_loss:+.3f} cos_in={diag['mean_cos_in']:+.3f} "
f"cos_out={diag['mean_cos_out']:+.3f} fired={diag['frac_fired']:.2f} "
f"sec={time.time()-t0:.0f}"
)
peak_gb = torch.cuda.max_memory_allocated() / 1e9 if torch.cuda.is_available() else 0.0
n_steps = len(rows)
n_gens = sum(r["N"] for r in rows)
total_hacks = sum(int(r["hack_s"].split("/")[0]) + int(r["hack_t"].split("/")[0]) for r in rows)
total_pass = sum(int(r["gt_s"].split("/")[0]) + int(r["gt_t"].split("/")[0]) for r in rows)
hack_rate = total_hacks / max(1, n_gens)
pass_rate = total_pass / max(1, n_gens)
# Per-source totals. On no-teacher runs, hack_s_total == total_hacks.
hack_s_total = sum(int(r["hack_s"].split("/")[0]) for r in rows)
hack_t_total = sum(int(r["hack_t"].split("/")[0]) for r in rows)
n_s_total = sum(int(r["hack_s"].split("/")[1]) for r in rows)
n_t_total = sum(int(r["hack_t"].split("/")[1]) for r in rows)
hack_rate_s = hack_s_total / max(1, n_s_total)
hack_rate_t = hack_t_total / max(1, n_t_total)
# Final tail: cue emoji + main metric BLUF, then per-step tsv table.
# Vanilla arm: 🟢 if hacking emerged. Projected arm: 🟢 if HACK_RATE dropped
# vs a matched-PASS vanilla — we can't judge that here, so just report.
cue = "🟢" if (cfg.arm == "vanilla" and hack_rate > 0.0) else "🟡"
print(f"\nargv: {' '.join(sys.argv)}")
print(f"verbose log: {verbose_log}")
print(
f"main metric: HACK_RATE={hack_rate:.3f} PASS_RATE={pass_rate:.3f} "
f"HACK_STUDENT={hack_rate_s:.3f} HACK_TEACHER={hack_rate_t:.3f} "
f"[arm={cfg.arm} preset={cfg.preset.value} model={model_name} steps={n_steps} gens={n_gens} peak={peak_gb:.1f}GB"
f"{' pool=' + cfg.teacher_pool_dir.name + ' mix=' + str(cfg.mix_ratio) if cfg.teacher_pool_dir else ''}]"
)
print()
print(tabulate(rows, headers="keys", tablefmt="tsv", floatfmt="+.3f"))
print()
print(tabulate([{
"cue": cue, "HACK_RATE": f"{hack_rate:.3f}", "PASS_RATE": f"{pass_rate:.3f}",
"HACK_S": f"{hack_rate_s:.3f}", "HACK_T": f"{hack_rate_t:.3f}",
"peak_GB": f"{peak_gb:.1f}", "arm": cfg.arm, "preset": cfg.preset.value,
"model": model_name.split("/")[-1], "seed": cfg.seed, "steps": n_steps,
"pool": (cfg.teacher_pool_dir.name if cfg.teacher_pool_dir else ""),
"mix": cfg.mix_ratio if cfg.teacher_pool_dir else "",
"tag": cfg.out_tag, "log": str(verbose_log),
}], headers="keys", tablefmt="tsv"))
# Markdown copy: easier to paste into journal/PRs than the TSV above.
print()
print("### Per-step rows (markdown)\n")
print(tabulate(rows, headers="keys", tablefmt="pipe", floatfmt="+.3f"))
save_ckpt(rows)
return 0
if __name__ == "__main__":
sys.exit(main(tyro.cli(Config)))