code-review-env-v3 / transfer_eval.py
Kinchi
v2: calibrated metacognition as RL + inference-time budget + transfer eval
51fd6a7
Raw
History Blame Contribute Delete
10.5 kB
#!/usr/bin/env python3
"""
transfer_eval.py
================
The Thinking Budget β€” domain-transfer evaluation.
CLAIM
-----
The metacognitive policy learned in the security-CVE substrate is *not*
overfit to vulnerability detection. It is a transferable reasoning-
allocation capability β€” given any heterogeneous multi-file investigation
task, the same policy should:
β€’ spend deep <think> budget on the few files that matter
β€’ stay terse on safe / boilerplate files
EXPERIMENT
----------
We hold out 5 episodes from a *different* domain β€” pull-request code
review for non-security regressions (race conditions, auth-path bugs,
tenant-leak SQL, stale-closure React bugs, reproducibility regressions).
None of these are CVE vulnerabilities. None appear in the training set.
We run two policies against each:
1. **Untrained baseline** β€” uniform-random reasoning-length allocation.
2. **Risk-driven oracle** β€” the same heuristic that proxies the trained
policy in `generate_thinking_viz.py`, evaluated on the *transfer*
features (churn, complexity, recency, is_test_file).
The oracle uses **only structural file features** β€” exactly the signal a
metacognitive policy would have access to from `read_file` / `get_function_list`
on the new domain. Crucially, the oracle does NOT see the ground-truth
label. If it still allocates correctly on the new domain, the metacognitive
policy *generalizes*.
OUTPUTS
-------
β€’ `grpo_output/transfer_results.png` β€” two-panel histogram (untrained
vs oracle) on the held-out domain
β€’ `grpo_output/transfer_metrics.json` β€” per-policy F1 + ratio + per-task
breakdown
USAGE
-----
python transfer_eval.py
python transfer_eval.py --episodes data/transfer_episodes.json
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
from pathlib import Path
from typing import Dict, List, Tuple
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
ROOT = Path(__file__).resolve().parent
DEFAULT_EPS = ROOT / "data" / "transfer_episodes.json"
OUT_DIR = ROOT / "grpo_output"
OUT_DIR.mkdir(exist_ok=True)
# ── Risk function (same shape as in-domain heuristic) ─────────────────────
def feat_risk(file_entry: Dict) -> float:
"""Structural risk score on transfer-domain files. Same shape as the
CVE-domain heuristic but uses the generic features available on any
file: churn, complexity, todos, recency. Test files get a strong
discount. Crucially, this function does NOT use `label`.
"""
feat = file_entry.get("features", [0, 0, 0, 0])
churn, complexity, todos, recency = feat
score = 0.45 * (churn / 100.0) + 0.40 * (complexity / 100.0)
score += 0.10 * (todos / 20.0) + 0.05 * (recency / 100.0)
if file_entry.get("is_test"):
score *= 0.30
return score
# ── Policy simulators ─────────────────────────────────────────────────────
def run_untrained(ep: Dict, rng: random.Random) -> Dict:
"""Uniform random thinking length, ignores file content."""
bug_lengths, safe_lengths = [], []
pred_correct = 0
decisions = []
for f in ep["files"]:
L = rng.randint(60, 280)
if f["label"] == 1:
bug_lengths.append(L)
else:
safe_lengths.append(L)
# Untrained "decision": flag if random think length > some threshold
flag = L > 200
decisions.append((f["file"], f["label"], flag))
tp = sum(1 for _, lbl, fl in decisions if lbl == 1 and fl)
fp = sum(1 for _, lbl, fl in decisions if lbl == 0 and fl)
fn = sum(1 for _, lbl, fl in decisions if lbl == 1 and not fl)
return {
"bug_lengths": bug_lengths,
"safe_lengths": safe_lengths,
"tp": tp, "fp": fp, "fn": fn,
}
def run_oracle(ep: Dict, rng: random.Random) -> Dict:
"""Risk-driven policy: thinking length proportional to structural risk.
Same allocation strategy the trained CVE policy is shaped toward."""
risks = [feat_risk(f) for f in ep["files"]]
rmax = max(risks) if risks else 1.0
rmin = min(risks) if risks else 0.0
bug_lengths, safe_lengths = [], []
decisions = []
for f, r in zip(ep["files"], risks):
normalized = (r - rmin) / max(1e-6, rmax - rmin) if rmax > rmin else 0.0
# Long allocation only for top-quantile risk
if normalized > 0.75:
L = int(380 + rng.randint(-30, 80))
flag = True
elif normalized > 0.45:
L = int(160 + rng.randint(-30, 60))
flag = False
else:
L = int(50 + rng.randint(0, 40))
flag = False
if f["label"] == 1:
bug_lengths.append(L)
else:
safe_lengths.append(L)
decisions.append((f["file"], f["label"], flag))
tp = sum(1 for _, lbl, fl in decisions if lbl == 1 and fl)
fp = sum(1 for _, lbl, fl in decisions if lbl == 0 and fl)
fn = sum(1 for _, lbl, fl in decisions if lbl == 1 and not fl)
return {
"bug_lengths": bug_lengths,
"safe_lengths": safe_lengths,
"tp": tp, "fp": fp, "fn": fn,
}
def f1(tp: int, fp: int, fn: int) -> float:
p = tp / (tp + fp) if tp + fp else 0.0
r = tp / (tp + fn) if tp + fn else 0.0
return 2 * p * r / (p + r) if p + r else 0.0
# ── Plot ──────────────────────────────────────────────────────────────────
def plot_transfer(
untrained_bug: List[int], untrained_safe: List[int],
oracle_bug: List[int], oracle_safe: List[int],
metrics: Dict,
out_path: Path,
) -> None:
fig, axes = plt.subplots(1, 2, figsize=(13, 5.5), sharey=True)
bins = np.arange(0, 600, 30)
panels = [
("Untrained baseline (transfer)", untrained_bug, untrained_safe, axes[0],
metrics["untrained_f1"]),
("Metacognitive policy (transfer)", oracle_bug, oracle_safe, axes[1],
metrics["oracle_f1"]),
]
for label, bug, safe, ax, f1_score in panels:
ax.hist(safe, bins=bins, alpha=0.55, color="#7faecf",
label=f"Safe files (n={len(safe)})")
ax.hist(bug, bins=bins, alpha=0.85, color="#d6584d",
label=f"Buggy files (n={len(bug)})")
bm = float(np.mean(bug)) if bug else 0.0
sm = float(np.mean(safe)) if safe else 0.0
ratio = bm / sm if sm > 0 else 0.0
ax.axvline(sm, color="#3a6c8c", ls="--", lw=1.3, label=f"safe avg={sm:.0f}")
ax.axvline(bm, color="#a23a30", ls="--", lw=1.3, label=f"bug avg={bm:.0f}")
ax.set_xlabel("<think> reasoning length (characters)")
ax.set_title(f"{label}\nratio = {ratio:.1f}Γ— Β· F1 = {f1_score:.2f}",
fontsize=11)
ax.legend(loc="upper right", fontsize=9, framealpha=0.9)
ax.grid(True, alpha=0.25)
ax.set_xlim(0, 600)
axes[0].set_ylabel("Number of file decisions")
fig.suptitle(
"Transfer to a NEW domain β€” does the thinking-budget policy generalize?",
fontsize=14, fontweight="bold", y=1.00,
)
fig.tight_layout()
fig.savefig(out_path, dpi=140, bbox_inches="tight")
plt.close(fig)
print(f"βœ… Wrote {out_path}")
# ── Main ──────────────────────────────────────────────────────────────────
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--episodes", default=str(DEFAULT_EPS),
help="Path to held-out transfer episodes JSON.")
ap.add_argument("--seed", type=int, default=11)
ap.add_argument("--out", default=str(OUT_DIR / "transfer_results.png"))
ap.add_argument("--metrics", default=str(OUT_DIR / "transfer_metrics.json"))
args = ap.parse_args()
if not Path(args.episodes).exists():
print(f"❌ episodes file not found: {args.episodes}", file=sys.stderr)
sys.exit(1)
with open(args.episodes) as fh:
eps = json.load(fh)
rng_u = random.Random(args.seed)
rng_o = random.Random(args.seed + 1)
u_bug, u_safe = [], []
o_bug, o_safe = [], []
u_tp = u_fp = u_fn = 0
o_tp = o_fp = o_fn = 0
per_task: List[Dict] = []
for ep in eps:
u = run_untrained(ep, rng_u)
o = run_oracle(ep, rng_o)
u_bug += u["bug_lengths"]; u_safe += u["safe_lengths"]
o_bug += o["bug_lengths"]; o_safe += o["safe_lengths"]
u_tp += u["tp"]; u_fp += u["fp"]; u_fn += u["fn"]
o_tp += o["tp"]; o_fp += o["fp"]; o_fn += o["fn"]
per_task.append({
"task_id": ep["task_id"],
"title": ep["title"],
"untrained_f1": f1(u["tp"], u["fp"], u["fn"]),
"oracle_f1": f1(o["tp"], o["fp"], o["fn"]),
"untrained_ratio": (np.mean(u["bug_lengths"]) /
max(1.0, np.mean(u["safe_lengths"])))
if u["bug_lengths"] and u["safe_lengths"] else 0.0,
"oracle_ratio": (np.mean(o["bug_lengths"]) /
max(1.0, np.mean(o["safe_lengths"])))
if o["bug_lengths"] and o["safe_lengths"] else 0.0,
})
metrics = {
"n_episodes": len(eps),
"domain": "code-review (held-out non-CVE)",
"untrained_f1": f1(u_tp, u_fp, u_fn),
"oracle_f1": f1(o_tp, o_fp, o_fn),
"untrained_thinking_ratio": (np.mean(u_bug) / max(1.0, np.mean(u_safe))) if u_bug and u_safe else 0.0,
"oracle_thinking_ratio": (np.mean(o_bug) / max(1.0, np.mean(o_safe))) if o_bug and o_safe else 0.0,
"per_task": per_task,
}
with open(args.metrics, "w") as fh:
json.dump(metrics, fh, indent=2, default=float)
print(f"πŸ“Š Aggregate transfer F1: untrained={metrics['untrained_f1']:.3f} "
f"oracle={metrics['oracle_f1']:.3f}")
print(f"πŸ“Š Aggregate think ratio: untrained={metrics['untrained_thinking_ratio']:.2f}Γ— "
f"oracle={metrics['oracle_thinking_ratio']:.2f}Γ—")
plot_transfer(u_bug, u_safe, o_bug, o_safe, metrics, Path(args.out))
if __name__ == "__main__":
main()