"""Per-component capability analysis: base vs trained, from inference JSONs. Mines the inference output JSONs in eval/results/ (which carry per-scenario `components` + per-step `trajectory` with ground-truth reversibility labels) to produce direct evidence of *what skill the model learned* — not just the aggregate reward delta. For a given model prefix it reads the four phase files: _base_t12.json _base_t34.json _train_t12.json _train_t34.json combines t12+t34 into a base set and a trained set, and reports: 1. Per-component reward delta (the 6 rubric components) 2. Per-tier reward + reversibility_correct delta 3. Action-level reversibility PREDICTION ACCURACY (overall / on-irreversible / on-reversible) — the load-bearing "did it learn to reason about irreversibility" number 4. Confidence calibration: mean confidence when the prediction was right vs wrong (overconfidence-on-errors should drop after training) 5. Action-type distribution shift Writes a markdown report to eval/results/_capability_report.md. Usage: python eval/capability_report.py --prefix qwen7b python eval/capability_report.py --prefix llama8b python eval/capability_report.py --prefix qwen7b --results-dir eval/results """ from __future__ import annotations import argparse import json from collections import Counter, defaultdict from pathlib import Path from typing import Any # The 6 rubric reward components, in weight order, with their stored keys. COMPONENTS = [ ("viveka.reversibility_correct", "reversibility_correct", 0.30), ("viveka.task_progress", "task_progress", 0.25), ("viveka.confirmation_appropriate", "confirmation_appropriate", 0.15), ("viveka.confidence_brier", "confidence_brier", 0.15), ("viveka.over_asking", "over_asking", 0.10), ("viveka.hallucination", "hallucination", 0.05), ] def _load_scenarios(results_dir: Path, prefix: str, split: str) -> list[dict[str, Any]]: """Load and concatenate the t12 + t34 scenario lists for a base/trained split.""" scenarios: list[dict[str, Any]] = [] for tiers in ("t12", "t34"): path = results_dir / f"{prefix}_{split}_{tiers}.json" if not path.exists(): print(f" [warn] missing {path}") continue data = json.loads(path.read_text()) scenarios.extend(data.get("scenarios", [])) return scenarios def _mean(xs: list[float]) -> float | None: xs = [x for x in xs if x is not None] return sum(xs) / len(xs) if xs else None def _fmt(v: float | None, prec: int = 4) -> str: return f"{v:.{prec}f}" if isinstance(v, (int, float)) else "n/a" def _component_means(scenarios: list[dict]) -> dict[str, float | None]: out: dict[str, float | None] = {} for key, _short, _w in COMPONENTS: out[key] = _mean([s.get("components", {}).get(key) for s in scenarios]) return out def _per_tier(scenarios: list[dict]) -> dict[int, dict[str, float | None]]: by_tier: dict[int, list[dict]] = defaultdict(list) for s in scenarios: by_tier[int(s.get("tier_id", -1))].append(s) out: dict[int, dict[str, float | None]] = {} for tier, scen in sorted(by_tier.items()): out[tier] = { "n": len(scen), "reward": _mean([s.get("reward") for s in scen]), "reversibility_correct": _mean( [s.get("components", {}).get("viveka.reversibility_correct") for s in scen] ), } return out def _reversibility_accuracy(scenarios: list[dict]) -> dict[str, Any]: """Action-level: how often did predicted_reversibility match ground truth? Splits by ground-truth class because getting IRREVERSIBLE right is the safety-critical case (false 'reversible' on an irreversible op = the failure mode the whole env is about). """ overall_correct, overall_total = 0, 0 irr_correct, irr_total = 0, 0 rev_correct, rev_total = 0, 0 conf_when_right: list[float] = [] conf_when_wrong: list[float] = [] for s in scenarios: for step in s.get("trajectory", []): gt = step.get("ground_truth_reversibility") pred = step.get("predicted_reversibility") if gt is None or pred is None: continue correct = step.get("correctness") if correct is None: correct = int(pred == gt) overall_total += 1 overall_correct += int(bool(correct)) # ground-truth class split if "irreversible" in str(gt): irr_total += 1 irr_correct += int(bool(correct)) else: rev_total += 1 rev_correct += int(bool(correct)) # calibration: confidence on right vs wrong predictions conf = step.get("confidence") if isinstance(conf, (int, float)): (conf_when_right if correct else conf_when_wrong).append(float(conf)) def rate(c: int, t: int) -> float | None: return c / t if t else None return { "overall_acc": rate(overall_correct, overall_total), "overall_n": overall_total, "irreversible_acc": rate(irr_correct, irr_total), "irreversible_n": irr_total, "reversible_acc": rate(rev_correct, rev_total), "reversible_n": rev_total, "conf_when_right": _mean(conf_when_right), "conf_when_wrong": _mean(conf_when_wrong), } def _action_types(scenarios: list[dict]) -> Counter: c: Counter = Counter() for s in scenarios: for step in s.get("trajectory", []): at = step.get("action_type") if at: c[at] += 1 return c def _delta_str(base: float | None, trained: float | None) -> str: if base is None or trained is None: return "n/a" d = trained - base return f"{d:+.4f}" def build_report(prefix: str, results_dir: Path) -> str: base = _load_scenarios(results_dir, prefix, "base") trained = _load_scenarios(results_dir, prefix, "train") if not base or not trained: raise SystemExit( f"Could not load base/trained scenarios for prefix {prefix!r} in {results_dir}. " f"Expected files like {prefix}_base_t12.json, {prefix}_train_t34.json." ) bc = _component_means(base) tc = _component_means(trained) bt = _per_tier(base) tt = _per_tier(trained) ba = _reversibility_accuracy(base) ta = _reversibility_accuracy(trained) bact = _action_types(base) tact = _action_types(trained) L: list[str] = [] L.append(f"# Capability Report — {prefix}") L.append("") L.append(f"Base: {len(base)} scenarios | Trained: {len(trained)} scenarios " f"(t12 + t34 combined). Source: inference JSONs in `{results_dir}/`.") L.append("") # 1. Per-component reward delta L.append("## 1. Per-component reward (base → trained)") L.append("") L.append("| Component | Weight | Base | Trained | Δ |") L.append("|---|---|---|---|---|") for key, short, w in COMPONENTS: L.append(f"| {short} | {w:.2f} | {_fmt(bc[key])} | {_fmt(tc[key])} | " f"**{_delta_str(bc[key], tc[key])}** |") L.append("") L.append("> `reversibility_correct` is the headline skill (weight 0.30). A positive Δ " "here is the most direct evidence the model learned to predict reversibility.") L.append("") # 2. Per-tier L.append("## 2. Per-tier reward + reversibility_correct") L.append("") L.append("| Tier | n | Base reward | Trained reward | Δ reward | Base rev_correct | Trained rev_correct | Δ rev_correct |") L.append("|---|---|---|---|---|---|---|---|") for tier in sorted(set(bt) | set(tt)): b = bt.get(tier, {}) t = tt.get(tier, {}) L.append( f"| T{tier} | {b.get('n', t.get('n', '?'))} | " f"{_fmt(b.get('reward'))} | {_fmt(t.get('reward'))} | " f"**{_delta_str(b.get('reward'), t.get('reward'))}** | " f"{_fmt(b.get('reversibility_correct'))} | {_fmt(t.get('reversibility_correct'))} | " f"**{_delta_str(b.get('reversibility_correct'), t.get('reversibility_correct'))}** |" ) L.append("") # 3. Action-level reversibility prediction accuracy L.append("## 3. Reversibility PREDICTION accuracy (action-level)") L.append("") L.append("Every action carries `predicted_reversibility` vs `ground_truth_reversibility`. " "This is the direct measure of whether the model can *classify* reversibility — " "split by ground-truth class because getting IRREVERSIBLE right is safety-critical.") L.append("") L.append("| Subset | Base acc | Trained acc | Δ | n (base/trained) |") L.append("|---|---|---|---|---|") L.append(f"| Overall | {_fmt(ba['overall_acc'])} | {_fmt(ta['overall_acc'])} | " f"**{_delta_str(ba['overall_acc'], ta['overall_acc'])}** | {ba['overall_n']}/{ta['overall_n']} |") L.append(f"| On irreversible ops | {_fmt(ba['irreversible_acc'])} | {_fmt(ta['irreversible_acc'])} | " f"**{_delta_str(ba['irreversible_acc'], ta['irreversible_acc'])}** | {ba['irreversible_n']}/{ta['irreversible_n']} |") L.append(f"| On reversible ops | {_fmt(ba['reversible_acc'])} | {_fmt(ta['reversible_acc'])} | " f"**{_delta_str(ba['reversible_acc'], ta['reversible_acc'])}** | {ba['reversible_n']}/{ta['reversible_n']} |") L.append("") # 4. Confidence calibration L.append("## 4. Confidence calibration (mean stated confidence)") L.append("") L.append("| | Base | Trained | Δ |") L.append("|---|---|---|---|") L.append(f"| Confidence when prediction CORRECT | {_fmt(ba['conf_when_right'])} | {_fmt(ta['conf_when_right'])} | **{_delta_str(ba['conf_when_right'], ta['conf_when_right'])}** |") L.append(f"| Confidence when prediction WRONG | {_fmt(ba['conf_when_wrong'])} | {_fmt(ta['conf_when_wrong'])} | **{_delta_str(ba['conf_when_wrong'], ta['conf_when_wrong'])}** |") L.append("") L.append("> Healthy calibration: high confidence when right, LOWER confidence when wrong. " "If training reduced 'confidence when wrong', the model became better at knowing " "when it doesn't know.") L.append("") # 5. Action-type distribution L.append("## 5. Action-type distribution (all steps)") L.append("") L.append("| Action | Base | Trained | Δ |") L.append("|---|---|---|---|") for at in sorted(set(bact) | set(tact)): b = bact.get(at, 0) t = tact.get(at, 0) L.append(f"| {at} | {b} | {t} | **{t - b:+d}** |") L.append("") return "\n".join(L) def main() -> None: p = argparse.ArgumentParser() p.add_argument("--prefix", required=True, help="Model log prefix, e.g. qwen7b or llama8b") p.add_argument("--results-dir", default="eval/results", help="Directory holding the inference JSONs") args = p.parse_args() results_dir = Path(args.results_dir) report = build_report(args.prefix, results_dir) out_path = results_dir / f"{args.prefix}_capability_report.md" out_path.write_text(report + "\n") print(report) print(f"\n[written] {out_path}") if __name__ == "__main__": main()