#!/usr/bin/env python3 """Base vs trained — one vertical figure (readable), not three cramped columns. The base model scores 0 on Membrane because it cannot emit valid JSONL actions; that is the comparison point (not a missing baseline). This figure keeps labels short and repeats the legend once. """ from __future__ import annotations import argparse import json from pathlib import Path import matplotlib.pyplot as plt import numpy as np # Short x labels — full names live in README / eval summary table TASK_LABELS = { "dyad_must_refuse_v1": "Refuse leak\n(train)", "dyad_must_comply_v1": "Safe agree", "dyad_must_refuse_long_v1": "Refuse +\nlong noise", "triad_must_refuse_v1": "Refuse +\n2 bots", } def build_from_summary(summary: dict, out_path: Path) -> None: tasks = summary["tasks"] metrics = [ ("mean_reward", "Membrane score (0–1)", "After full grader"), ("valid_jsonl_rate", "Valid JSONL (0–1)", "Membrane accepts the syntax"), ("commit_rate", "COMMIT rate (0–1)", "Episode finished cleanly"), ] fig, axes = plt.subplots(3, 1, figsize=(9.5, 10.5), constrained_layout=False) fig.subplots_adjust(left=0.12, right=0.97, top=0.90, bottom=0.14, hspace=0.42) fig.suptitle( "Same Qwen2.5 1.5B checkpoint — LoRA off (hatched) vs Membrane-trained LoRA on (solid)", fontsize=12, fontweight="bold", y=0.97, ) for row_i, (ax, (metric_key, ylabel, short_hint)) in enumerate(zip(axes, metrics)): base_vals = [summary["base"][metric_key][t] for t in tasks] trained_vals = [summary["trained"][metric_key][t] for t in tasks] x = np.arange(len(tasks)) width = 0.36 base_color, trained_color = "#94a3b8", "#0f766e" bl = "LoRA off (base)" if row_i == 0 else "_" tl = "LoRA on (trained)" if row_i == 0 else "_" base_bars = ax.bar(x - width / 2, base_vals, width, color=base_color, label=bl) for rect, val in zip(base_bars, base_vals): if val == 0: rect.set_height(0.03) rect.set_facecolor("none") rect.set_edgecolor(base_color) rect.set_linewidth(1.6) rect.set_hatch("//") ax.bar(x + width / 2, trained_vals, width, color=trained_color, label=tl) for i, (bv, tv) in enumerate(zip(base_vals, trained_vals)): ax.text( i - width / 2, max(bv, 0.03) + 0.04, f"{bv:.2f}", ha="center", fontsize=9, color=base_color, ) ax.text( i + width / 2, tv + 0.04, f"{tv:.2f}", ha="center", fontsize=9, color=trained_color, fontweight="bold", ) ax.set_xticks(x) ax.set_xticklabels([TASK_LABELS.get(t, t) for t in tasks], fontsize=10) ax.set_ylim(0, 1.12) ax.set_ylabel(ylabel, fontsize=10) ax.set_title(short_hint, fontsize=10, pad=6) ax.grid(True, axis="y", alpha=0.25) handles, labels = axes[0].get_legend_handles_labels() fig.legend( handles, labels, loc="lower center", ncol=2, fontsize=10, frameon=True, bbox_to_anchor=(0.5, 0.02), ) fig.text( 0.5, 0.085, "Base at 0.00 is expected: the frozen model does not produce valid Membrane JSONL, " "so the grader never scores a successful episode. Compare the green bars to the hatched bars.", ha="center", fontsize=9, color="#444", ) out_path.parent.mkdir(parents=True, exist_ok=True) fig.savefig(out_path, format="svg", bbox_inches="tight") plt.close(fig) def main() -> None: ap = argparse.ArgumentParser(description="Build eval_showcase_panels.svg from base_vs_trained_summary.json") ap.add_argument( "--summary", type=Path, default=Path(__file__).resolve().parents[2] / "docs" / "eval" / "base_vs_trained" / "base_vs_trained_summary.json", ) ap.add_argument( "--out", type=Path, default=Path(__file__).resolve().parents[2] / "docs" / "plots" / "eval_showcase_panels.svg", ) args = ap.parse_args() summary = json.loads(args.summary.read_text()) build_from_summary(summary, args.out) print("wrote", args.out) if __name__ == "__main__": main()