diff --git a/scripts/plot_dynamics.py b/scripts/plot_dynamics.py index 618e2f2..00169a3 100644 --- a/scripts/plot_dynamics.py +++ b/scripts/plot_dynamics.py @@ -222,6 +222,24 @@ def _series_panel(ax, runs, cols, colors, ylim, label_series=False): ax.set_ylim(*ylim) +def dump_data(runs: list[dict], out: Path) -> Path: + """Write the plotted series to a tidy CSV next to the figure so the figure is + reproducible from a committed artifact -- logs/ and out/runs/ are gitignored, + this CSV is not (it lands in out/figs/, which is tracked).""" + csv = out.with_suffix(".csv") + lines = ["arm,seed,step,hack,solve"] + for r in runs: + arm = classify(r) + hk = r.get("hack_s"); sv = r.get("gt_s") + for i, step in enumerate(r["steps"]): + h = hk[i] if hk is not None and i < len(hk) else float("nan") + s = sv[i] if sv is not None and i < len(sv) else float("nan") + lines.append(f"{arm},{r['seed']},{int(step)},{h},{s}") + csv.write_text("\n".join(lines) + "\n") + logger.info(f"wrote {csv} ({len(runs)} runs, reproducibility source)") + return csv + + def plot(runs: list[dict], out: Path) -> None: by_arm: dict[str, list[dict]] = defaultdict(list) for r in runs: @@ -229,6 +247,7 @@ def plot(runs: list[dict], out: Path) -> None: arms = [a for a in ARM_ORDER if a in by_arm] if not arms: raise SystemExit("no runs classified into arms") + dump_data(runs, out) fig, axes = plt.subplots(1, len(arms), figsize=(3.0 * len(arms), 2.6), sharex=True, sharey=True, squeeze=False) @@ -237,7 +256,17 @@ def plot(runs: list[dict], out: Path) -> None: rs = by_arm[arm] n_seed = len({r["seed"] for r in rs}) ax.set_title(f"{arm}\n(n={n_seed} seed{'s' if n_seed > 1 else ''})", fontsize=9) - _series_panel(ax, rs, RATE_COLS, RATE_COLORS, ylim=(0, 1), label_series=(col == 0)) + # ylim floor slightly below 0 so a pinned-at-zero series (route2 hack) draws + # ABOVE the axis line instead of hiding under it -- the whole result is that + # red sits on zero, so it must be visible, not absent. + _series_panel(ax, rs, RATE_COLS, RATE_COLORS, ylim=(-0.035, 1.0), label_series=(col == 0)) + # If hack is pinned at zero all panel, say so -- else "no red line" reads as + # a plotting bug rather than the finding. + hk = [r["hack_s"] for r in rs if "hack_s" in r] + if hk and np.nanmax([np.nanmax(h) for h in hk]) < 0.02: + ax.annotate("hack ≡ 0", (0.04, 0.0), xycoords=("axes fraction", "data"), + color=RATE_COLORS["hack_s"], fontsize=8, va="bottom", + xytext=(0, 3), textcoords="offset points") ax.set_xlabel("optimizer step") onsets = [s for r in rs if (s := _onset(r["steps"], r["hack_s"])) is not None] if onsets: