# Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. """Dark-theme plotting for Protocol One results. Palette and aesthetic match the split-panel viz mocked in `docs/07_PHASE_5_DEMO.md`. Plots are saved as high-DPI PNGs into `notebooks/figures/` and embedded in the README. Visual contract: - Dark background, light grid, light text -- so the curve is the brightest thing on the page. - Baseline reference is a dashed grey horizontal -- any value above the dashed line is "model beat the scripted heuristic baseline." - Mutations get yellow vertical markers; eval points after a mutation that recover above baseline are the demo moment. - Component breakdown is a small-multiples panel: one tiny axes per reward component, so the reader can see *which* capability moved. - Baseline-vs-trained comparison is a single dual-bar with deltas annotated above each pair. """ from __future__ import annotations import os from typing import Any, Iterable import matplotlib.pyplot as plt import numpy as np from matplotlib import rcParams # --------------------------------------------------------------------------- # Palette (kept consistent with the HTML viz in 07_PHASE_5_DEMO.md) # --------------------------------------------------------------------------- PALETTE = { "bg": "#0f1117", # deep navy "panel": "#161a23", # slightly lighter card "grid": "#262b36", "text": "#e6e6e6", "muted": "#9097a8", "verified": "#3dd68c", # success green "partial": "#f2c84b", # warning yellow "false": "#e44d4d", # error red "accent": "#5cabff", # cool blue (trained model) "accent_warm": "#c084fc", # soft purple (alt series) "baseline": "#9097a8", } def apply_dark_style() -> None: """Apply the project palette globally. Call once before plotting.""" rcParams.update({ "figure.facecolor": PALETTE["bg"], "axes.facecolor": PALETTE["bg"], "savefig.facecolor": PALETTE["bg"], "axes.edgecolor": PALETTE["grid"], "axes.labelcolor": PALETTE["text"], "axes.titlecolor": PALETTE["text"], "axes.titleweight": "semibold", "axes.titlesize": 12, "axes.labelsize": 10, "axes.grid": True, "axes.axisbelow": True, "grid.color": PALETTE["grid"], "grid.linestyle": "--", "grid.linewidth": 0.6, "grid.alpha": 0.7, "xtick.color": PALETTE["muted"], "ytick.color": PALETTE["muted"], "xtick.labelsize": 9, "ytick.labelsize": 9, "text.color": PALETTE["text"], "legend.facecolor": PALETTE["panel"], "legend.edgecolor": PALETTE["grid"], "legend.labelcolor": PALETTE["text"], "legend.fontsize": 9, "font.family": "sans-serif", "font.sans-serif": ["DejaVu Sans", "Arial", "Helvetica"], "figure.dpi": 110, "savefig.dpi": 160, "savefig.bbox": "tight", }) def _save(fig: plt.Figure, out_path: str) -> str: os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) fig.savefig(out_path) plt.close(fig) return out_path # --------------------------------------------------------------------------- # Individual plots # --------------------------------------------------------------------------- def plot_training_loss(log_history: list[dict], out_path: str, title: str = "SFT training loss — Qwen 2.5 + LoRA") -> str: """Cross-entropy curve from `trainer.state.log_history`.""" apply_dark_style() rows = [(r["step"], r["loss"]) for r in log_history if "loss" in r and "step" in r] if not rows: rows = [(r.get("step", 0), r["loss"]) for r in log_history if "loss" in r] rows.sort() if not rows: raise ValueError("log_history has no 'loss' rows to plot") xs, ys = zip(*rows) fig, ax = plt.subplots(figsize=(8.5, 4.2)) ax.plot(xs, ys, color=PALETTE["accent"], lw=1.8, label="train loss") ax.fill_between(xs, ys, min(ys) * 0.9, color=PALETTE["accent"], alpha=0.10) ax.set_xlabel("Training step") ax.set_ylabel("Cross-entropy loss") ax.set_title(title) ax.set_ylim(bottom=0) ax.legend(loc="upper right") return _save(fig, out_path) def plot_eval_reward_curve( eval_history: list[dict], out_path: str, baseline: float | None = None, title: str = "Held-out matcher reward over training", mutation_steps: Iterable[int] = (), ) -> str: """Reward-on-eval-set curve. baseline is a dashed horizontal reference.""" apply_dark_style() rows = [(r["step"], r["eval/reward_mean"], r.get("eval/reward_std", 0.0)) for r in eval_history if "eval/reward_mean" in r] rows.sort() if not rows: raise ValueError("eval_history has no 'eval/reward_mean' rows") steps, means, stds = zip(*rows) means = np.array(means); stds = np.array(stds) fig, ax = plt.subplots(figsize=(8.5, 4.6)) ax.fill_between(steps, means - stds, means + stds, color=PALETTE["accent"], alpha=0.18, label="±1 std (eval episodes)") ax.plot(steps, means, color=PALETTE["accent"], lw=2.2, marker="o", markersize=5, label="mean reward") if baseline is not None: ax.axhline(baseline, ls="--", lw=1.2, color=PALETTE["baseline"], label=f"untrained baseline ({baseline:.2f})") for ms in mutation_steps: ax.axvline(ms, ls=":", lw=1.0, color=PALETTE["partial"], alpha=0.85) ax.set_xlabel("Training step") ax.set_ylabel("Mean matcher reward (held-out episodes)") ax.set_ylim(0, 1.0) ax.set_title(title) ax.legend(loc="lower right") return _save(fig, out_path) def plot_component_breakdown( eval_history: list[dict], out_path: str, title: str = "Reward components — which capability the agent learned", ) -> str: """Small multiples: one panel per matcher component.""" apply_dark_style() component_keys = sorted({k for r in eval_history for k in r if k.startswith("eval/component_")}) if not component_keys: raise ValueError("eval_history has no eval/component_* keys") n = len(component_keys) cols = 3 rows_n = (n + cols - 1) // cols fig, axes = plt.subplots(rows_n, cols, figsize=(11, 2.6 * rows_n), sharex=True) axes = np.atleast_2d(axes).flatten() colors = [PALETTE["verified"], PALETTE["accent"], PALETTE["partial"], PALETTE["accent_warm"], PALETTE["false"], PALETTE["muted"]] for idx, key in enumerate(component_keys): ax = axes[idx] rows = [(r["step"], r[key]) for r in eval_history if key in r and "step" in r] rows.sort() if not rows: ax.set_visible(False); continue xs, ys = zip(*rows) c = colors[idx % len(colors)] ax.plot(xs, ys, color=c, lw=1.8, marker="o", markersize=3) ax.fill_between(xs, ys, color=c, alpha=0.12) nice_name = key.replace("eval/component_", "").replace("_", " ") ax.set_title(nice_name, fontsize=10) ax.set_ylim(0, 1.0) ax.tick_params(labelsize=8) for j in range(idx + 1, len(axes)): axes[j].set_visible(False) fig.suptitle(title, color=PALETTE["text"], fontsize=12, y=1.01, weight="semibold") fig.text(0.5, -0.02, "Training step", ha="center", color=PALETTE["muted"]) fig.tight_layout() return _save(fig, out_path) def plot_baseline_vs_trained( baseline_summary: Any, trained_summary: Any, out_path: str, title: str = "Baseline vs SFT-trained — held-out matcher score", ) -> str: """Grouped bar chart over reward components + total.""" apply_dark_style() components = sorted(set(baseline_summary.component_means) | set(trained_summary.component_means)) labels = ["total"] + [c.replace("_", " ") for c in components] base = [baseline_summary.mean_reward] + [baseline_summary.component_means.get(c, 0.0) for c in components] trained = [trained_summary.mean_reward] + [trained_summary.component_means.get(c, 0.0) for c in components] x = np.arange(len(labels)) w = 0.38 fig, ax = plt.subplots(figsize=(10, 4.6)) b1 = ax.bar(x - w / 2, base, w, color=PALETTE["baseline"], label="untrained baseline", edgecolor=PALETTE["bg"], linewidth=0.8) b2 = ax.bar(x + w / 2, trained, w, color=PALETTE["accent"], label="SFT trained", edgecolor=PALETTE["bg"], linewidth=0.8) for x_i, (b, t, lab) in enumerate(zip(base, trained, labels)): delta = t - b # For "penalty", lower is better, so flip the polarity for color/sign. is_better = (delta < 0) if "penalty" in lab else (delta >= 0) sign = "+" if delta >= 0 else "" color = PALETTE["verified"] if is_better else PALETTE["false"] ax.text(x_i, max(b, t) + 0.025, f"{sign}{delta:.2f}", ha="center", color=color, fontsize=9, weight="bold") ax.set_xticks(x); ax.set_xticklabels(labels, rotation=20, ha="right", color=PALETTE["text"]) ax.set_ylabel("Score (0 - 1)") ax.set_ylim(0, max(max(base + trained) + 0.18, 1.0)) ax.set_title(title) ax.legend(loc="upper right") return _save(fig, out_path) def plot_mutation_generalization( base_summary: Any, mutation_summary: Any, out_path: str, title: str = "Generalization to held-out mutations", ) -> str: """Per-variant reward bars: base spec vs each mutation type.""" apply_dark_style() variants = sorted(set(base_summary.by_variant) | set(mutation_summary.by_variant)) base_v = [base_summary.by_variant.get(v, np.nan) for v in variants] mut_v = [mutation_summary.by_variant.get(v, np.nan) for v in variants] x = np.arange(len(variants)) w = 0.38 fig, ax = plt.subplots(figsize=(10, 4.4)) ax.bar(x - w / 2, base_v, w, color=PALETTE["accent"], label="base-spec eval", edgecolor=PALETTE["bg"], linewidth=0.8) ax.bar(x + w / 2, mut_v, w, color=PALETTE["partial"], label="mutated-spec eval", edgecolor=PALETTE["bg"], linewidth=0.8) ax.set_xticks(x) ax.set_xticklabels([v.replace("_", "\n") for v in variants], fontsize=9, color=PALETTE["text"]) ax.set_ylabel("Mean matcher reward") ax.set_ylim(0, 1.0) ax.set_title(title) ax.legend(loc="upper right") return _save(fig, out_path) def plot_dataset_calibration( score_buckets: dict[str, int], threshold: float, out_path: str, title: str = "SFT dataset score distribution", ) -> str: """Bar histogram of episode scores from build_sft_dataset (--stats-only).""" apply_dark_style() keys = sorted(score_buckets.keys(), key=float) vals = [score_buckets[k] for k in keys] colors = [PALETTE["false"] if float(k) < threshold else PALETTE["verified"] for k in keys] fig, ax = plt.subplots(figsize=(8, 3.8)) ax.bar(keys, vals, color=colors, edgecolor=PALETTE["bg"], linewidth=0.8) ax.axvline(x=str(round(threshold, 1)), ls="--", color=PALETTE["partial"], lw=1.2, label=f"keep threshold = {threshold:.2f}") ax.set_xlabel("Matcher score (binned)") ax.set_ylabel("Episode count") ax.set_title(title) ax.legend(loc="upper right") return _save(fig, out_path) def plot_dashboard( log_history: list[dict], eval_history: list[dict], baseline_summary: Any, trained_summary: Any, out_path: str, baseline_reward: float | None = None, mut_summary: Any = None, ) -> str: """One eye-pleasing 2x2 hero image for the README. If ``mut_summary`` is provided, the bottom-right panel shows trained base-spec vs trained mutated-spec reward across each mutation type -- the proper generalization story. If not provided, falls back to the (less informative) baseline-vs-trained-by-variant view. """ apply_dark_style() fig = plt.figure(figsize=(13, 8.2)) gs = fig.add_gridspec(2, 2, hspace=0.32, wspace=0.22) # (0,0) Training loss ax1 = fig.add_subplot(gs[0, 0]) rows = sorted([(r["step"], r["loss"]) for r in log_history if "loss" in r]) if rows: xs, ys = zip(*rows) ax1.plot(xs, ys, color=PALETTE["accent"], lw=1.8) ax1.fill_between(xs, ys, color=PALETTE["accent"], alpha=0.12) ax1.set_title("SFT training loss") ax1.set_xlabel("Step"); ax1.set_ylabel("Cross-entropy") # (0,1) Eval reward curve ax2 = fig.add_subplot(gs[0, 1]) rows = sorted([(r["step"], r["eval/reward_mean"]) for r in eval_history if "eval/reward_mean" in r]) if rows: xs, ys = zip(*rows) ax2.plot(xs, ys, color=PALETTE["verified"], lw=2.2, marker="o", markersize=5) ax2.fill_between(xs, ys, color=PALETTE["verified"], alpha=0.15) if baseline_reward is not None: ax2.axhline(baseline_reward, ls="--", color=PALETTE["baseline"], label=f"baseline {baseline_reward:.2f}") ax2.legend(loc="lower right") ax2.set_ylim(0, 1.0) ax2.set_title("Held-out reward over training") ax2.set_xlabel("Step"); ax2.set_ylabel("Matcher reward") # (1,0) Baseline vs trained bars ax3 = fig.add_subplot(gs[1, 0]) components = sorted(set(baseline_summary.component_means) | set(trained_summary.component_means)) labels = ["total"] + [c.replace("_", " ")[:12] for c in components] base = [baseline_summary.mean_reward] + [baseline_summary.component_means.get(c, 0.0) for c in components] trained = [trained_summary.mean_reward] + [trained_summary.component_means.get(c, 0.0) for c in components] x = np.arange(len(labels)); w = 0.38 ax3.bar(x - w / 2, base, w, color=PALETTE["baseline"], label="baseline") ax3.bar(x + w / 2, trained, w, color=PALETTE["accent"], label="trained") ax3.set_xticks(x); ax3.set_xticklabels(labels, rotation=25, ha="right", fontsize=8) ax3.set_ylabel("Score") ax3.set_ylim(0, max(max(base + trained) + 0.1, 1.0)) ax3.set_title("Baseline vs trained (per component)") ax3.legend(loc="upper right") # (1,1) Per-variant generalization ax4 = fig.add_subplot(gs[1, 1]) if mut_summary is not None: # Real generalization story: trained on base vs trained on mutations. variants = sorted(set(trained_summary.by_variant) | set(mut_summary.by_variant)) train_v = [trained_summary.by_variant.get(v, np.nan) for v in variants] mut_v = [mut_summary.by_variant.get(v, np.nan) for v in variants] x = np.arange(len(variants)); w = 0.38 ax4.bar(x - w / 2, train_v, w, color=PALETTE["accent"], label="base-spec eval") ax4.bar(x + w / 2, mut_v, w, color=PALETTE["partial"], label="mutated-spec eval") ax4.set_title("Generalization to held-out mutations") else: # Fallback: baseline vs trained-by-variant (mostly empty; only base). variants = sorted(set(baseline_summary.by_variant) | set(trained_summary.by_variant)) base_v = [baseline_summary.by_variant.get(v, 0.0) for v in variants] train_v = [trained_summary.by_variant.get(v, 0.0) for v in variants] x = np.arange(len(variants)); w = 0.38 ax4.bar(x - w / 2, base_v, w, color=PALETTE["baseline"], label="baseline") ax4.bar(x + w / 2, train_v, w, color=PALETTE["accent"], label="trained") ax4.set_title("Generalization across spec variants") ax4.set_xticks(x) ax4.set_xticklabels([v.replace("_", "\n") for v in variants], fontsize=8) ax4.set_ylabel("Mean reward") ax4.set_ylim(0, 1.0) ax4.legend(loc="upper right") fig.suptitle("Protocol One — SFT training results", color=PALETTE["text"], fontsize=14, weight="semibold", y=0.995) return _save(fig, out_path)