"""Generate figures for the safety paper.

All numbers are read verbatim from the module artifacts (nothing hard-coded):
    cartographer/env_substrates/highway/results/scenarios.json     (scenario sweep)
    cartographer/env_substrates/highway/results/scen_*.json        (per-scenario)
    cartographer/env_substrates/highway/results/safety.json        (H-S, when run)
    cartographer/env_substrates/highway/results/transfer.json      (H-CT, when run)

A figure is SKIPPED (with a note) if its artifact does not exist yet -- this generator
never fabricates a number.

Run::

    .venv/bin/python cartographer/docs/paper_safety/make_figures.py
"""
from __future__ import annotations

import json
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt                    # noqa: E402

IMGS = Path(__file__).parent / "imgs"
IMGS.mkdir(parents=True, exist_ok=True)
ROOT = Path(__file__).resolve().parents[2]         # .../cartographer
HIGH = ROOT / "env_substrates" / "highway" / "results"
MA = ROOT / "env_substrates" / "multiagent" / "results"


def _load_path(p):
    if not p.exists():
        print(f"  skip  {p.name} (artifact not present)")
        return None
    with open(p) as fh:
        return json.load(fh)

plt.rcParams.update({
    "font.size": 9, "axes.titlesize": 10, "axes.labelsize": 9,
    "legend.fontsize": 8, "figure.dpi": 200, "savefig.bbox": "tight",
    "axes.spines.top": False, "axes.spines.right": False,
})


def save(fig, name):
    fig.savefig(IMGS / name)
    plt.close(fig)
    print("wrote", name)


def _load(name):
    p = HIGH / name
    if not p.exists():
        print(f"  skip  {name} (artifact not present yet)")
        return None
    with open(p) as fh:
        return json.load(fh)


def fig2_guard_activity():
    """Guard blocks per scenario (from the H0-g sweep)."""
    a = _load("scenarios.json")
    if a is None:
        return
    summ = a["verdict"]["summary"]
    # per-scenario block counts come from the per-scenario artifacts
    names, blocks, discrete = [], [], []
    for name in summ:
        s = _load(f"scen_{name}.json")
        if s is None:
            continue
        names.append(name)
        blocks.append(sum(e["outcome"]["guard_blocks"] for e in s["episodes"]))
        discrete.append(summ[name]["discrete"])
    if not names:
        return
    fig, ax = plt.subplots(figsize=(7, 3))
    cols = ["#1f77b4" if d else "#bbbbbb" for d in discrete]
    ax.bar(range(len(names)), blocks, color=cols)
    ax.set_xticks(range(len(names)))
    ax.set_xticklabels(names, rotation=35, ha="right")
    ax.set_ylabel("guard blocks (3 episodes)")
    ax.set_title("Guard activity per scenario (blue = discrete guard applies)")
    save(fig, "fig2_guard_activity.png")


def fig1_safety_matrix():
    """Ego- vs other-initiated collisions across scenarios/proposers (H-S)."""
    a = _load("safety.json")
    if a is None:
        return
    rows = a["per_scenario"]
    names = list(rows)
    ego = [rows[n].get("ego_initiated", 0) for n in names]
    oth = [rows[n].get("other_initiated", 0) for n in names]
    fig, ax = plt.subplots(figsize=(7, 3))
    ax.bar(range(len(names)), ego, color="#d62728", label="ego-initiated (guard's job)")
    ax.bar(range(len(names)), oth, bottom=ego, color="#bbbbbb",
           label="other-initiated (authority's model)")
    ax.set_xticks(range(len(names)))
    ax.set_xticklabels(names, rotation=35, ha="right")
    ax.set_ylabel("collisions")
    ax.legend(frameon=False)
    ax.set_title("Where containment breaks: attributable vs other-initiated")
    save(fig, "fig1_safety_matrix.png")


def fig3_corpus_growth():
    """Certified-search expansions vs corpus size (CT-6)."""
    a = _load("transfer.json")
    if a is None:
        return
    g = a.get("growth", [])
    if not g:
        return
    x = [row["corpus_size"] for row in g]
    cold = [row["cold_expansions"] for row in g]
    corp = [row["corpus_expansions"] for row in g]
    fig, ax = plt.subplots(figsize=(5, 3))
    ax.plot(x, cold, "o-", label="cold start")
    ax.plot(x, corp, "o-", label="corpus warm-start")
    ax.set_xlabel("corpus size (entries)")
    ax.set_ylabel("search expansions")
    ax.legend(frameon=False)
    ax.set_title("The corpus prunes the certified search")
    save(fig, "fig3_corpus_growth.png")


def fig4_transfer_within_cross():
    """Within- vs cross-scenario transfer (CT-1 / CT-4)."""
    a = _load("transfer.json")
    if a is None:
        return
    rows = a.get("transfer", [])
    if not rows:
        return
    labels = [f"{r['from']}→{r['to']}\n({r['kind']})" for r in rows]
    red = [100 * r.get("reduction", 0) for r in rows]
    fig, ax = plt.subplots(figsize=(6.5, 3))
    ax.bar(range(len(rows)), red, color=["#1f77b4" if r["kind"] == "within" else "#ff7f0e"
                                         for r in rows])
    ax.set_xticks(range(len(rows)))
    ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=7)
    ax.set_ylabel("expansion reduction (%)")
    ax.axhline(30, color="k", lw=0.8, ls="--", label="CT-1 bar (30%)")
    ax.legend(frameon=False)
    ax.set_title("Transfer: within-scenario vs cross-scenario")
    save(fig, "fig4_transfer_within_cross.png")


def fig5_retention():
    """Retention: the corpus still helps A after learning B (CT-5)."""
    a = _load("transfer.json")
    if a is None:
        return
    r = a.get("retention", [])
    if not r:
        return
    labels = [f"{x['to']} learned\n(test on {x['test']})" for x in r]
    vals = [100 * x.get("reduction", 0) for x in r]
    fig, ax = plt.subplots(figsize=(5, 3))
    ax.bar(range(len(r)), vals, color="#2ca02c")
    ax.set_xticks(range(len(r)))
    ax.set_xticklabels(labels, fontsize=7)
    ax.set_ylabel("expansion reduction on the OLD scenario (%)")
    ax.axhline(60 * (max(vals) / 100 if vals else 1), color="k", lw=0.8, ls="--",
               label="CT-5 bar (60% of peak)")
    ax.legend(frameon=False)
    ax.set_title("Retention: no forgetting after incremental learning")
    save(fig, "fig5_retention.png")


def fig6_lookahead_fix():
    """The falsifier drives the fix: one-step guard vs the horizon-sound guard (H-S2)."""
    a = _load("safety.json")
    if a is None:
        return
    g = a["bars"]
    labels = ["GS-1 (all proposers)", "GS-4 (adversarial hazard)"]
    one_step = [g["GS-1"]["observed"], g["GS-4"]["observed"]]
    look = [g["LA-1"]["observed"]["ego_initiated"], g["LA-1"]["observed"]["hazard_ego"]]
    x = range(len(labels))
    fig, ax = plt.subplots(figsize=(5, 3))
    ax.bar([i - 0.2 for i in x], one_step, width=0.4, color="#d62728",
           label="one-step guard (k=1)")
    ax.bar([i + 0.2 for i in x], look, width=0.4, color="#2ca02c",
           label="lookahead guard (k=3)")
    ax.set_xticks(list(x))
    ax.set_xticklabels(labels, fontsize=8)
    ax.set_ylabel("ego-initiated collisions")
    ax.legend(frameon=False)
    ax.set_title("A horizon-sound guard restores containment (93% fewer)")
    save(fig, "fig6_lookahead_fix.png")


def fig7_abstain_taxonomy():
    """Before/after the horizon-certified abstain: silent fallbacks vs the named classes."""
    before = _load("proposer.json")          # H-PQ split: certified vs silent fallback
    after = _load("abstain.json")            # H-S3: proposal / abstain / wall
    if before is None or after is None:
        return
    b_c = before["bars"]["PQ-2"]["observed"]["certified_per_arm"]["random"]
    b_f = before["bars"]["PQ-2"]["observed"]["fallback_per_arm"]["random"]
    r = after["bars"]["SB-4"]["observed"]["per_arm"]["random"]
    fig, ax = plt.subplots(figsize=(5.5, 3))
    # before: certified + SILENT unchecked; after: proposal-cert, abstain-cert, wall
    ax.bar(0, b_c, color="#2ca02c", label="certified executions")
    ax.bar(0, b_f, bottom=b_c, color="#d62728", label="unchecked fallbacks (silent)")
    ax.bar(1, r["ego_proposal_cert"], color="#2ca02c")
    ax.bar(1, r["ego_abstain_cert"], bottom=r["ego_proposal_cert"],
           color="#1f77b4", label="abstain-certified (new)")
    ax.bar(1, r["ego_wall"], bottom=r["ego_proposal_cert"] + r["ego_abstain_cert"],
           color="#ff7f0e", label="flagged wall events (new)")
    ax.set_xticks([0, 1])
    ax.set_xticklabels(["before\n(silent fallbacks)", "after H-S3\n(named classes)"])
    ax.set_ylabel("ego-initiated crashes / 28 episodes")
    ax.legend(frameon=False, fontsize=7)
    ax.set_title("Horizon-certifying the abstain: the silent class is gone")
    save(fig, "fig7_abstain_taxonomy.png")


def fig8_regime_dose_response():
    """H-RP: per-scenario reduction vs the MEASURED regime rate (rho)."""
    a = _load("regime.json")
    if a is None:
        return
    rows = a.get("rows", [])
    if not rows:
        return
    x = [100 * r["regime_rate"] for r in rows]
    y = [100 * r["full"] for r in rows]
    rho = a["bars"]["RP-1"]["observed"]["rho"]
    fig, ax = plt.subplots(figsize=(5, 3.2))
    ax.scatter(x, y, color="#1f77b4", zorder=3)
    for r in rows:
        ax.annotate(r["scenario"], (100 * r["regime_rate"], 100 * r["full"]),
                    textcoords="offset points", xytext=(5, 3), fontsize=7)
    ax.set_xlabel("measured regime rate (%)")
    ax.set_ylabel("corpus expansion reduction (%)")
    ax.set_title(f"Dose–response: utility tracks measured visitation (ρ = {rho:.2f})")
    save(fig, "fig8_regime_dose_response.png")


def fig9_regime_qualifier():
    """H-6-D: classical−matching gap vs FASTER-admissibility (Theorem 4's R)."""
    a = _load("bias_regime.json")
    if a is None:
        return
    cells = a.get("cells", [])
    if not cells:
        return
    x = [100 * c["faster_admissibility"] for c in cells]
    y = [c["gap_classical_matching"] for c in cells]
    rho = a["rho_cell"]
    fig, ax = plt.subplots(figsize=(5.5, 3.2))
    ax.scatter(x, y, c=["#1f77b4"] * len(x), zorder=3)
    for c in cells:
        ax.annotate(c["scenario"], (100 * c["faster_admissibility"],
                                    c["gap_classical_matching"]),
                    textcoords="offset points", xytext=(4, 3), fontsize=6)
    ax.axhline(0, color="k", lw=0.8, ls="--")
    ax.set_xlabel("FASTER-admissibility (%)")
    ax.set_ylabel("expansions: classical − matching")
    ax.set_title(f"The regime qualifier: gap tracks 1 − admissibility (ρ = {rho:+.2f})")
    save(fig, "fig9_regime_qualifier.png")


def fig10_proposer_seat():
    """H-PQ2: per-arm mean speed vs certified-class ego (speed moves, safety flat)."""
    a = _load("capacity.json")
    if a is None:
        return
    obs = a["bars"]["PQ2-1"]["observed"]["speeds"]
    cert = a["bars"]["PQ2-2"]["observed"]["certified"]
    arms = list(obs)
    speeds = [obs[k] for k in arms]
    ego = [cert[k] for k in arms]
    fig, ax = plt.subplots(figsize=(5.5, 3))
    ax.bar(range(len(arms)), speeds, color="#1f77b4", alpha=0.85,
           label="mean speed (m/s)")
    ax.set_xticks(range(len(arms)))
    ax.set_xticklabels(arms, fontsize=8)
    ax.set_ylabel("mean speed (m/s)")
    ax2 = ax.twinx()
    ax2.plot(range(len(arms)), ego, "o-", color="#d62728",
             label="certified-class ego")
    ax2.set_ylabel("certified-class ego-initiated", color="#d62728")
    ax2.set_ylim(0, max(ego) + 3)
    ax.set_title("The proposer's seat: speed spreads, the safety class does not move")
    save(fig, "fig10_proposer_seat.png")


def fig11_margin_economics():
    """H-T5: certified classes -> 0 while wall events explode (cowardice trade)."""
    a = _load("thm5.json")
    if a is None:
        return
    order = ["A0", "A95", "A99", "A99+"]
    arms = a["arms"]
    labels = [f"{o}\n(m={arms[o]['margin']:.1f})" for o in order]
    cert = [arms[o]["totals"]["ego_prop"] + arms[o]["totals"]["ego_abst"]
            for o in order]
    walls = [arms[o]["totals"]["wall_events"] for o in order]
    fig, ax = plt.subplots(figsize=(5.5, 3))
    ax.bar(range(len(order)), walls, color="#ff7f0e", alpha=0.85,
           label="wall events (guard disabled)")
    ax.set_ylabel("wall events / 112 episodes")
    ax.set_xticks(range(len(order)))
    ax.set_xticklabels(labels, fontsize=7)
    ax2 = ax.twinx()
    ax2.plot(range(len(order)), cert, "o-", color="#2ca02c", lw=1.8,
             label="certified-class crashes")
    ax2.set_ylabel("certified-class crashes", color="#2ca02c")
    ax2.set_ylim(0, max(cert) + 2)
    ax.set_title("The margin economics: containment bought with the brake pedal")
    save(fig, "fig11_margin_economics.png")


def fig12_model_ladder():
    """H-T6: eps m95 CV vs CA dead equal (the falsifier) + wall events per arm."""
    a = _load("thm6.json")
    if a is None:
        return
    eps = a["epsilon"]
    fig, (axL, axR) = plt.subplots(1, 2, figsize=(7, 3))
    axL.bar([0, 1], [eps["cv"]["m95"], eps["ca"]["m95"]], color=["#1f77b4", "#d62728"])
    axL.axhline(eps["cv"]["m95"], color="k", lw=0.7, ls="--")
    axL.set_xticks([0, 1])
    axL.set_xticklabels(["constant\nvelocity", "constant\nacceleration"], fontsize=8)
    axL.set_ylabel("m95 horizon error (m)")
    axL.set_title(f"ε̂₉₅: ratio = "
                  f"{eps['ca']['m95']/max(eps['cv']['m95'],1e-9):.4f} (kinematics "
                  f"exhausted)")
    order = ["A0", "ACV99", "ACA95", "ACA99"]
    arms = a["arms"]
    ev = [arms[o]["totals"]["wall_events"] for o in order]
    axR.bar(range(len(order)), ev, color="#ff7f0e", alpha=0.85)
    axR.set_xticks(range(len(order)))
    axR.set_xticklabels(order, fontsize=7, rotation=20)
    axR.set_ylabel("wall events / 112 episodes")
    axR.set_title("The ladder does not move capability")
    save(fig, "fig12_model_ladder.png")


def fig13_intent_ladder():
    """M-EP: the others-model ladder on MPE (CV / CA / responder)."""
    a = _load_path(MA / "epsilon.json")
    if a is None:
        return
    st = a["stats"]
    labels = ["constant\nvelocity", "constant\nacceleration", "responder\n(intent)"]
    vals = [st["cv"]["m95"], st["ca"]["m95"], st["responder"]["m95"]]
    fig, ax = plt.subplots(figsize=(5, 3))
    ax.bar(range(3), vals, color=["#1f77b4", "#d62728", "#2ca02c"])
    ax.set_xticks(range(3))
    ax.set_xticklabels(labels, fontsize=8)
    ax.set_ylabel("m95 horizon error (world units)")
    ax.set_title("The intent rung: the responder buys 3.4× on the mean; CA is worse")
    save(fig, "fig13_intent_ladder.png")


def fig14_capability_and_wall():
    """T7 + the necessity audit: capability collapses at m99; the wall is 87%/49%."""
    lad = _load_path(MA / "ladder.json")
    atk = _load_path(MA / "attack.json")
    if lad is None or atk is None:
        return
    fig, (axL, axR) = plt.subplots(1, 2, figsize=(7.2, 3))
    order = ["A0", "ACV99", "AR99"]
    prog = [lad["arms"][o]["totals"]["progress"] for o in order]
    axL.bar(range(3), prog, color=["#2ca02c", "#ff7f0e", "#ff7f0e"])
    axL.set_xticks(range(3))
    axL.set_xticklabels(order, fontsize=8)
    axL.set_ylabel("goal progress")
    axL.set_title("Capability: +16.5 → −3.2 at m99")
    pa = atk["bars"]["T8-1"]["observed"]["per_arm"]
    axR.bar([0, 1], [pa["A0"]["necessary"] / pa["A0"]["n"],
                     pa["AR99"]["necessary"] / pa["AR99"]["n"]],
            color=["#2ca02c", "#d62728"])
    axR.axhline(0.7, color="k", lw=0.8, ls="--", label="registered bar (0.7)")
    axR.set_xticks([0, 1])
    axR.set_xticklabels(["A0 (m=0)", "AR99 (m99)"], fontsize=8)
    axR.set_ylabel("wall necessity rate")
    axR.set_ylim(0, 1.0)
    axR.legend(frameon=False, fontsize=7)
    axR.set_title("The wall audit: honest at m=0, half-false at m99")
    save(fig, "fig14_capability_and_wall.png")


def fig15_two_tier():
    """H-ATK2: manufactured walls removed; capability frozen (one-element abstain)."""
    a = _load_path(MA / "tier.json")
    if a is None:
        return
    arms = a["arms"]
    labels = ["AR99\none-tier", "AR99t\ntwo-tier", "A0\n(reference)"]
    walls = [arms["AR99"]["totals"]["wall_events"],
             arms["AR99t"]["totals"]["wall_events"], 483]
    prog = [arms["AR99"]["totals"]["progress"],
            arms["AR99t"]["totals"]["progress"], 16.467]
    fig, (axL, axR) = plt.subplots(1, 2, figsize=(7.2, 3))
    axL.bar(range(3), walls, color=["#ff7f0e", "#ff7f0e", "#2ca02c"])
    axL.set_xticks(range(3))
    axL.set_xticklabels(labels, fontsize=7)
    axL.set_ylabel("wall events")
    axL.set_title("692 manufactured walls removed")
    axR.bar(range(3), prog, color=["#ff7f0e", "#ff7f0e", "#2ca02c"])
    axR.set_xticks(range(3))
    axR.set_xticklabels(labels, fontsize=7)
    axR.set_ylabel("goal progress")
    axR.set_title("capability frozen: abstain set has one element")
    save(fig, "fig15_two_tier.png")


def fig19_omega_td():
    """Structural diagram (no data) of the Ω-TD architecture -- registered in
    PLAN.md (addendum ARCH) before generation; renderer upgraded from matplotlib
    to D2 (see PLAN addendum), source: fig19.d2 next to this file."""
    import shutil
    import subprocess
    src = Path(__file__).parent / "fig19.d2"
    dst = IMGS / "fig19_omega_td.png"
    if shutil.which("d2") is None:
        print("  skip  fig19 (d2 binary not on PATH)")
        return
    subprocess.run(["d2", "--layout", "dagre", "--pad", "28",
                    str(src), str(dst)], check=True)
    print("  wrote fig19_omega_td.png (structural, via d2)")


def fig20_monitor_object():
    """H-3 vs H-MC: the monitor's signal object (AUROC) and recovery -- all numbers
    read verbatim from monitor{,_asdev}.json and contract_monitor.json."""
    dev = _load_path(HIGH / "monitor_asdev.json")
    cor = _load_path(HIGH / "monitor.json")
    hmc = _load_path(HIGH / "contract_monitor.json")
    if not (dev and cor and hmc):
        print("  skip  fig20 (monitor artifacts not present)")
        return
    ad = dev["bars"]["H-3"]["observed"]
    cc = cor["bars"]["H-3"]["observed"]
    mm = hmc["bars"]["H-MC"]["observed"]
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10.0, 3.9))
    labels = ["H-3\nas-deviated", "H-3\ncorrected", "H-MC\ncontract-vs-trace"]
    auc = [ad["auroc"], cc["auroc"], mm["auroc"]]
    bars = ax1.bar(labels, auc, color=["#bbbbbb", "#888888", "#2B5797"], width=0.62)
    ax1.axhline(0.90, ls="--", lw=1.4, c="#c0392b")
    ax1.text(2.45, 0.905, "bar 0.90", color="#c0392b", fontsize=9, ha="right")
    ax1.axhline(0.5, ls=":", lw=1.0, c="0.4")
    ax1.text(-0.45, 0.507, "chance", color="0.4", fontsize=8.5)
    for b, v in zip(bars, auc):
        ax1.text(b.get_x() + b.get_width() / 2, v + 0.012, f"{v:.3f}",
                 ha="center", fontsize=10)
    ax1.set_ylabel("detection AUROC")
    ax1.set_ylim(0, 1.02)
    ax1.set_title("Left: signal object (same trials design)", fontsize=10)
    base = cc["success_baseline"]
    succ = [base, ad["success_recovery"], cc["success_recovery"],
            mm["success_recovery"]]
    rl = ["baseline", "H-3 as-dev", "H-3 corrected", "H-MC"]
    rb = ax2.bar(rl, [v * 100 for v in succ],
                 color=["#2B5797", "#bbbbbb", "#888888", "#2B5797"], width=0.62)
    ax2.axhline((base - 0.05) * 100, ls="--", lw=1.4, c="#c0392b")
    ax2.text(3.45, (base - 0.05) * 100 + 0.4, "bar baseline\u22125pp", color="#c0392b",
             fontsize=9, ha="right", va="bottom")
    for b, v in zip(rb, succ):
        ax2.text(b.get_x() + b.get_width() / 2, v * 100 + 0.5, f"{v*100:.1f}%",
                 ha="center", fontsize=10)
    ax2.set_ylabel("recovery success (%)")
    ax2.set_ylim(0, 42)
    ax2.set_title("Right: recovery is invariant to the trigger", fontsize=10)
    fig.tight_layout()
    fig.savefig(IMGS / "fig20_monitor_object.png", dpi=180)
    plt.close(fig)
    print("  wrote fig20_monitor_object.png")


def main() -> int:
    print(f"reading artifacts from {HIGH}")
    fig1_safety_matrix()
    fig2_guard_activity()
    fig3_corpus_growth()
    fig4_transfer_within_cross()
    fig5_retention()
    fig6_lookahead_fix()
    fig7_abstain_taxonomy()
    fig8_regime_dose_response()
    fig9_regime_qualifier()
    fig10_proposer_seat()
    fig11_margin_economics()
    fig12_model_ladder()
    fig13_intent_ladder()
    fig14_capability_and_wall()
    fig15_two_tier()
    fig19_omega_td()
    fig20_monitor_object()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
