#!/usr/bin/env python3
"""Conceptual figures for "A Safety-Minded Case for Open Models".

None of these figures is fitted to data; every curve is an illustrative model
whose parameters are set below. Running the script writes SVGs to ../figures
and prints the numbers the post quotes so they can be checked against the text.

    python make_figures.py               # light figures
    python make_figures.py --theme dark  # dark-background variants (*-dark.svg)
"""
import argparse
from pathlib import Path

import numpy as np
import matplotlib

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

HERE = Path(__file__).resolve().parent
FIGS = HERE.parent / "figures"

THEMES = {
    "light": dict(bg="#ffffff", text="#1f1f1f", text2="#6b6b6b", grid="#e8e8e6", axis="#bdbdb8",
                  closed="#26324b", open="#1f7a63", friction="#6e5a7e", thresh="#b07d2b",
                  span="#f1efe8", neg="#b83b2a", on_fill="#ffffff"),
    "dark": dict(bg="#1c1c1c", text="#ececec", text2="#a6a6a6", grid="#333333", axis="#5a5a5a",
                 closed="#9fb0d6", open="#4fbf9f", friction="#b49ac8", thresh="#e0b25a",
                 span="#2a2925", neg="#e0705e", on_fill="#141414"),
}

# ---------------------------------------------------------------- two-threshold model
# Time is in years on an arbitrary origin. Capability is the share of economically
# valuable tasks a model can handle; closed capability is a logistic ramp, open models
# follow the same curve LAG years later, and institutional friction acts as an extra
# effective adoption lag (in years) that fades as tooling and compliance mature.
LAG = 0.5
MIDPOINT, SCALE = 2.0, 0.8
FRIC_LAG0, FRIC_TAU = 1.6, 5.4
BAR = 80.0
T = np.linspace(0, 6.5, 2601)


def closed_cap(t):
    return 100 / (1 + np.exp(-(t - MIDPOINT) / SCALE))


def friction_lag(t):
    return FRIC_LAG0 * np.exp(-(np.maximum(t, 0) / FRIC_TAU) ** 2)


def first_crossing(y, level=BAR):
    i = int(np.argmax(y >= level))
    return float(T[i]) if y[i] >= level else None


# ---------------------------------------------------------------- peaky configurations
# Stylized log10(actors able to independently self-host, fine-tune & redistribute weights)
# across capability c in [0, 10], with the dangerous-capability threshold at PEAKY_DANGER:
#   c ~ 1.1 (small / laptop): ~10^7+ (~10M+ devs, hobbyists & researchers)
#   c ~ 3.5 (mid-scale R&D): ~10^5.5-10^6 (~100K-1M startups, labs & enterprises)
#   c ~ 5.8 (frontier-adjacent): ~10^4.5 (~10K-100K cluster-equipped orgs)
#   c > PEAKY_DANGER: 3-5 monitored frontier labs in Peaky vs. ~10K+ in Open frontier
PEAKY_C = np.linspace(0, 10, 700)
PEAKY_DANGER = 7.2
PEAKY_LOG_OPEN = 7.6 - 0.44 * PEAKY_C
PEAKY_LOG_CLOSED = 0.90 - 0.03 * PEAKY_C
_GATE = 1.0 / (1.0 + np.exp((PEAKY_C - (PEAKY_DANGER - 0.11)) / 0.028))
PEAKY_LOG_SPIRE = _GATE * PEAKY_LOG_OPEN + (1.0 - _GATE) * PEAKY_LOG_CLOSED
PEAKY_CONFIGS = [
    ("Closed oligopoly", "Weights locked down everywhere:\nno sovereignty or fine-tuning",
     PEAKY_LOG_CLOSED, "closed", "closed"),
    ("Peaky", "Open & modifiable below threshold,\nmonitored spire above it",
     PEAKY_LOG_SPIRE, "open", "closed"),
    ("Open frontier", "Unrestricted past threshold:\nthousands can run & fine-tune",
     PEAKY_LOG_OPEN, "open", "neg"),
]

# ---------------------------------------------------------------- gaggle toy model
# N identical agents under the power-sum Q_eff = (sum q_i^(1/alpha))^alpha achieve
# Q_eff / q = N^alpha, crossing a threshold factor R = 10 once N >= R^(1/alpha).
GAGGLE_R = 10.0
GAGGLE_ALPHAS = [1.0, 0.5, 0.25, 0.125, 0.05]
N_MAX = 1e9


def style(t):
    plt.rcParams.update({
        "font.family": "sans-serif",
        "font.sans-serif": ["Inter", "Helvetica Neue", "Helvetica", "Arial", "DejaVu Sans"],
        "font.size": 10,
        "text.color": t["text"],
        "axes.labelcolor": t["text2"],
        "axes.edgecolor": t["axis"],
        "axes.facecolor": t["bg"],
        "figure.facecolor": t["bg"],
        "savefig.facecolor": t["bg"],
        "xtick.color": t["text2"],
        "ytick.color": t["text2"],
        "xtick.major.size": 0,
        "ytick.major.size": 0,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.spines.left": False,
        "axes.spines.bottom": False,
        "svg.fonttype": "path",  # set to "none" to keep text as editable <text>
        "svg.hashsalt": "open-models",
    })


def save(fig, name, theme, alt, png=False):
    FIGS.mkdir(parents=True, exist_ok=True)
    suffix = "" if theme == "light" else f"-{theme}"
    path = FIGS / f"{name}{suffix}.svg"
    fig.savefig(path, format="svg", metadata={"Title": alt, "Date": None})
    if png:
        fig.savefig(FIGS / f"{name}{suffix}.png", format="png", dpi=300)
    plt.close(fig)
    return path


# ---------------------------------------------------------------- figures

def fig_criticality(t, theme, png=False):
    """Schematic. Contours of kappa = C_dot * (L0 + (1 - L0) * L), where L0 > 0 captures
    lab-internal loop gain even at zero societal legibility and higher L amplifies it.
    Compares a closed-only trajectory against an open-models trajectory fan from today."""
    from matplotlib.colors import LinearSegmentedColormap
    from matplotlib.patches import Polygon

    l0 = 0.18
    x = np.linspace(0, 1, 300)
    X, Y = np.meshgrid(x, x)
    K = Y * (l0 + (1.0 - l0) * X)
    fig, ax = plt.subplots(figsize=(7.6, 4.2))
    fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.11)

    today = np.array([0.19, 0.33])
    closed_pt = np.array([0.29, 0.74])
    x_open = 0.58
    open_mid = np.array([x_open, closed_pt[1]])
    open_hi = np.array([x_open, 0.90])
    open_lo = np.array([x_open, 0.25])

    def kappa(pt):
        return float(pt[1] * (l0 + (1.0 - l0) * pt[0]))

    k_today = kappa(today)
    k_closed = kappa(closed_pt)
    k_lo = kappa(open_lo)

    for side in ("left", "bottom"):
        ax.spines[side].set_visible(True)
        ax.spines[side].set_color(t["axis"])
        ax.spines[side].set_linewidth(0.9)

    cmap = LinearSegmentedColormap.from_list("p", [t["bg"], t["neg"]])
    ax.contourf(X, Y, K, levels=np.linspace(0, 1, 21), cmap=cmap, alpha=0.33, zorder=0)
    cs = ax.contour(X, Y, K, levels=[0.05, 0.22, 0.44, 0.68], colors=t["axis"], linewidths=0.75, zorder=1)
    ax.clabel(cs, levels=[0.05, 0.68], manual=[(0.07, 0.20), (0.86, 0.80)],
              fmt={0.05: "low", 0.68: "high"}, fontsize=8.5, colors=t["text2"])

    # Iso-criticality contour passing through the closed-only counterfactual
    lx = np.linspace(0.0, 1.0, 400)
    ly = k_closed / (l0 + (1.0 - l0) * lx)
    mask = ly <= 1.0
    ax.plot(lx[mask], ly[mask], color=t["text2"], lw=1.15, ls=(0, (4, 3)), zorder=2)
    ax.text(0.985, float(k_closed) + 0.018, "same κ as closed-only", fontsize=8.2, color=t["text2"],
            ha="right", va="bottom")

    ax.text(0.97, 0.97, "criticality κ", ha="right", va="top", fontsize=9.5,
            color=t["neg"], fontweight="bold", transform=ax.transAxes)

    # Open-models trajectory fan (same legibility x_open, varying frontier speed effect)
    ax.add_patch(Polygon([today, open_hi, open_lo], closed=True, color=t["open"], alpha=0.15, lw=0, zorder=2))
    for tip in (open_hi, open_lo):
        ax.plot([today[0], tip[0]], [today[1], tip[1]], color=t["open"], lw=0.95, ls=(0, (3, 3)), zorder=2)
    ax.plot([closed_pt[0], open_mid[0]], [closed_pt[1], open_mid[1]],
            color=t["axis"], lw=0.85, ls=(0, (2, 2)), zorder=2)

    ax.annotate("", open_mid, today,
                arrowprops=dict(arrowstyle="-|>", color=t["open"], lw=2.3, mutation_scale=15), zorder=3)
    ax.text(x_open + 0.016, open_mid[1] - 0.01, "With open models\n(no frontier effect: higher κ)",
            fontsize=9.2, fontweight="bold", color=t["open"], va="center")
    ax.text(x_open + 0.016, open_hi[1] - 0.005,
            "if spillovers or a hail-mary\nrace speed up the frontier", fontsize=8.3,
            color=t["text2"], va="center")
    ax.text(x_open + 0.016, open_lo[1] - 0.01,
            "if revenue/margin squeeze blunts\nfrontier acceleration enough to lower κ", fontsize=8.3,
            color=t["text2"], va="center")

    # Closed-only counterfactual trajectory
    ax.annotate("", closed_pt, today,
                arrowprops=dict(arrowstyle="-|>", color=t["closed"], lw=2.4, mutation_scale=15), zorder=4)
    ax.scatter(*closed_pt, s=28, facecolor=t["closed"], edgecolor=t["bg"], lw=0.9, zorder=5)
    ax.text(closed_pt[0] - 0.015, closed_pt[1] + 0.045,
            "Closed-only path\n(scaling, automated AI R&D)", fontsize=9.3,
            color=t["closed"], fontweight="bold", ha="center", va="bottom")

    ax.scatter(*today, s=50, facecolor=t["text"], edgecolor=t["bg"], lw=1.2, zorder=5)
    ax.annotate("today", today, xytext=(-8, -2), textcoords="offset points", ha="right", va="top",
                fontsize=9, color=t["text"])

    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_xlabel(r"Legibility of the world to AI, $L$", fontsize=9.5, color=t["text2"])
    fig.text(0.03, 0.92, r"Speed of frontier progress, $\dot{C}_{\mathrm{peak}}$", fontsize=9,
             color=t["text2"], ha="left", va="bottom")
    alt = ("Schematic plane with legibility of the world to AI on the horizontal axis and speed of "
           "frontier progress on the vertical axis, shaded by criticality, which rises toward the top "
           "right while remaining nonzero along the vertical axis. From today, a closed-only arrow "
           "points steeply up to a dashed iso-criticality contour. A green open-models fan reaches "
           "higher legibility: matching closed-only frontier speed lands at higher criticality, "
           "spillovers or a hail-mary race tilt higher still, and blunting frontier acceleration via "
           "revenue/margin squeeze crosses below the closed-only criticality contour.")
    return save(fig, "fig1-criticality", theme, alt, png=png), (k_today, k_closed, k_lo)


def fig_two_thresholds(t, theme, png=False):
    closed = closed_cap(T)
    open_cap = closed_cap(T - LAG)
    adopted = closed_cap(T - LAG - friction_lag(T))
    t_cap, t_adopt = first_crossing(open_cap), first_crossing(adopted)
    gap_months = 12 * (t_adopt - t_cap)

    fig, ax = plt.subplots(figsize=(7.6, 3.8))
    fig.subplots_adjust(left=0.08, right=0.97, top=0.88, bottom=0.13)

    ax.axvspan(t_cap, t_adopt, color=t["span"], lw=0, zorder=0)
    ax.text((t_cap + t_adopt) / 2, 4, "open is good enough;\nrevenue still goes to closed",
            ha="center", va="bottom", fontsize=8.2, color=t["text2"])
    for yv in (25, 50, 75, 100):
        ax.axhline(yv, color=t["grid"], lw=0.8, zorder=0)
    ax.axhline(BAR, color=t["thresh"], lw=1.4, ls=(0, (2, 3)), zorder=1)
    ax.text(0.05, BAR + 1.5, "Good enough for most economically valuable work", fontsize=9,
            color=t["thresh"], va="bottom")

    ax.fill_between(T, adopted, open_cap, color=t["friction"], alpha=0.18, lw=0, zorder=1)
    ax.plot(T, closed, color=t["closed"], lw=2.2, zorder=3)
    ax.plot(T, open_cap, color=t["open"], lw=1.6, ls=(0, (6, 4)), zorder=3)
    ax.plot(T, adopted, color=t["open"], lw=2.6, zorder=3)

    y_lag = 50
    ax.annotate("", (MIDPOINT + LAG, y_lag), (MIDPOINT, y_lag),
                arrowprops=dict(arrowstyle="<->", color=t["text2"], lw=0.9, shrinkA=0, shrinkB=0))
    ax.text(MIDPOINT - 0.07, y_lag, f"{12 * LAG:.0f} mo", ha="right", va="center",
            fontsize=8.5, color=t["text2"])

    ax.legend(handles=[
        plt.Line2D([], [], color=t["closed"], lw=2.2, label="Closed frontier (capable)"),
        plt.Line2D([], [], color=t["open"], lw=1.6, ls=(0, (6, 4)), label="Open models (capable)"),
        plt.Line2D([], [], color=t["open"], lw=2.6, label="Open models (deployable in practice)"),
        Patch(color=t["friction"], alpha=0.25,
              label="Extra open-weight friction: self-hosting,\ncompliance, SLAs, provenance, switching"),
    ], loc="lower right", bbox_to_anchor=(1.01, 0.14), frameon=False, fontsize=8.2,
        labelcolor=t["text2"], handlelength=2.0)

    ax.scatter([t_cap], [BAR], s=46, facecolor=t["bg"], edgecolor=t["open"], lw=1.8, zorder=5)
    ax.scatter([t_adopt], [BAR], s=58, facecolor=t["thresh"], edgecolor=t["text"], lw=1.2, zorder=5)
    ax.annotate("capability crossing", (t_cap, BAR), xytext=(3.45, 95), textcoords="data",
                ha="right", va="center", fontsize=8.5, color=t["open"],
                arrowprops=dict(arrowstyle="-", color=t["open"], lw=0.8, shrinkB=4))
    ax.annotate("adoption\ncrossing", (t_adopt, BAR), xytext=(8, -24), textcoords="offset points",
                ha="left", fontsize=8.5, color=t["text"], fontweight="bold")

    ax.set_xlim(0, T[-1])
    ax.set_ylim(0, 104)
    ax.set_xticks(range(7), [f"{k}" for k in range(7)])
    ax.set_yticks([0, 25, 50, 75, 100], ["0", "25", "50", "75", "100%"])
    ax.set_xlabel("Years (arbitrary origin)", fontsize=9.5, color=t["text2"])
    fig.text(0.03, 0.91, "Share of economically valuable tasks", fontsize=9,
             color=t["text2"], ha="left", va="bottom")
    alt = ("Conceptual plot of task share over time: closed frontier capability, open-model capability "
           "six months behind, and practical open-model deployability lagging further behind due to "
           "extra open-weight institutional friction, crossing the 80% bar about ten months after open "
           "capability does.")
    return save(fig, "fig2-two-thresholds", theme, alt, png=png), (t_cap, t_adopt, gap_months)


def fig_peaky(t, theme, png=False):
    def half_width(log_n):
        return 0.24 + np.maximum(log_n, 0.0)

    c = PEAKY_C
    fig, axes = plt.subplots(1, 3, figsize=(7.6, 4.05), sharey=True)
    fig.subplots_adjust(left=0.205, right=0.985, top=0.72, bottom=0.10, wspace=0.08)

    lo = c <= PEAKY_DANGER
    hi = c >= PEAKY_DANGER
    for ax, (name, sub, lcurve, base_col, top_col) in zip(axes, PEAKY_CONFIGS):
        w = half_width(lcurve)
        ax.axhspan(PEAKY_DANGER, 10, color=t["span"], lw=0, zorder=0)
        for ty in (2.3, 4.65):
            ax.axhline(ty, color=t["grid"], lw=0.7, zorder=0)
        ax.axvline(0, color=t["grid"], lw=0.7, zorder=0)

        ax.fill_betweenx(c[lo], -w[lo], w[lo], color=t[base_col], alpha=0.88, lw=0, zorder=2)
        ax.fill_betweenx(c[hi], -w[hi], w[hi], color=t[top_col], alpha=0.94,
                         edgecolor=t["thresh"] if top_col == "closed" else t[top_col],
                         lw=1.2, zorder=3)
        ax.axhline(PEAKY_DANGER, color=t["thresh"], lw=1.4, ls=(0, (3, 3)), zorder=4)

        ax.set_xlim(-8.4, 8.4)
        ax.set_ylim(0, 10)
        ax.set_xticks([])
        ax.text(0.5, 1.21, name, transform=ax.transAxes, fontsize=10, fontweight="bold",
                color=t["text"], ha="center", va="bottom")
        ax.text(0.5, 1.04, sub, transform=ax.transAxes, fontsize=8.0, color=t["text2"],
                ha="center", va="bottom")

    axes[0].set_yticks([1.1, 3.45, 5.8, 8.6], [
        "Small / laptop\n(local dev & edge)",
        "Mid-scale / R&D\n(fine-tuning & apps)",
        "Frontier-adjacent\n(cluster self-hosting)",
        "High-risk\n(bio, cyber, RSI)",
    ])
    axes[0].tick_params(axis="y", labelsize=8.0, pad=6)
    axes[0].get_yticklabels()[-1].set_color(t["thresh"])

    axes[0].text(-8.0, PEAKY_DANGER + 0.18, "dangerous-capability threshold",
                 fontsize=7.8, color=t["thresh"], va="bottom", ha="left")
    axes[0].annotate("~3–10\ngatekeepers", (half_width(0.8), 3.45), xytext=(2.4, 3.45),
                     fontsize=7.9, color=t["text2"], va="center", ha="left",
                     arrowprops=dict(arrowstyle="-", color=t["axis"], lw=0.8))

    for ax_open in (axes[1], axes[2]):
        for y_pos, lbl in [(1.1, "~10M+"), (3.45, "~100K–1M"), (5.8, "~10K–100K")]:
            ax_open.text(0, y_pos, lbl, fontsize=8.0, color=t["on_fill"], fontweight="bold",
                         ha="center", va="center", zorder=5)

    axes[1].annotate("3–5 monitored\n(few, not one)", (half_width(0.6), 8.6),
                     xytext=(2.0, 8.6), fontsize=7.9, color=t["text"], va="center", ha="left",
                     arrowprops=dict(arrowstyle="-", color=t["text2"], lw=0.8))
    axes[2].text(0, 8.5, "~10K+", fontsize=8.1, color=t["on_fill"], fontweight="bold",
                 ha="center", va="center", zorder=5)

    fig.supxlabel(
        "Silhouette width = actors able to self-host, fine-tune & redistribute weights (log-scaled)",
        fontsize=8.9, color=t["text2"], x=0.595, y=0.02,
    )
    fig.legend(handles=[
        Patch(color=t["open"], label="Modifiable open weights"),
        Patch(facecolor=t["closed"], edgecolor=t["thresh"], lw=1.2, label="Closed, monitored"),
        Patch(color=t["neg"], label="Open, past the threshold"),
    ], loc="upper left", bbox_to_anchor=(0.195, 0.99), ncol=3, frameon=False, fontsize=8.6,
       labelcolor=t["text2"], handlelength=1.2, columnspacing=1.4)
    alt = ("Three centered pyramid silhouettes whose log-scaled width shows how many independent "
           "actors can self-host, fine-tune, and redistribute model weights across four capability "
           "tiers (small laptop, mid-scale R&D, frontier-adjacent cluster, and high-risk "
           "above a dashed threshold). Closed oligopoly locks down weights to ~3-10 gatekeepers "
           "everywhere; Peaky has a wide modifiable open base (~10M+, ~100K-1M, ~10K-100K) that "
           "steps sharply inward at the threshold to 3-5 monitored frontier labs; Open frontier "
           "continues past the threshold with ~10K+ cluster-capable actors holding modifiable "
           "dangerous weights.")
    return save(fig, "fig3-peaky", theme, alt, png=png)


def fig_gaggle(t, theme, png=False):
    n = np.logspace(0, np.log10(N_MAX), 500)
    log_r = float(np.log10(GAGGLE_R))  # 1.0 for R = 10x
    fig, ax = plt.subplots(figsize=(7.6, 3.6))
    fig.subplots_adjust(left=0.08, right=0.77, top=0.84, bottom=0.15)

    y_ticks = [0.0, float(np.log10(3.0)), log_r, float(np.log10(30.0))]
    for yv in y_ticks[1:]:
        ax.axhline(yv, color=t["grid"], lw=0.8, zorder=0)
    y_max = 1.72
    ax.axhline(log_r, color=t["thresh"], lw=1.4, ls=(0, (2, 3)), zorder=1)
    ax.text(5e4, log_r + 0.04, "Matches one agent\n10× ahead",
            fontsize=8.5, color=t["thresh"], va="bottom", ha="left")

    cross_labels = {1.0: "10", 0.5: "100", 0.25: "10K", 0.125: "100M"}
    ray_labels = {
        1.0: "α = 1 (additive)",
        0.5: "α = 0.5",
        0.25: "α = 0.25",
        0.125: "α = 0.125",
        0.05: "α₀ = 0.05\n(best-of-N)",
    }
    crossings = {}
    shades = [0.96, 0.92, 0.88, 0.84, 0.80]
    for alpha, a in zip(GAGGLE_ALPHAS, shades):
        n_star = float(GAGGLE_R ** (1.0 / alpha))
        crossings[alpha] = n_star
        pts = np.unique(np.append(n, n_star)) if n_star <= N_MAX else n
        gain = alpha * np.log10(pts)
        lo_m = pts <= n_star
        hi_m = pts >= n_star
        ax.plot(pts[lo_m], gain[lo_m], color=t["open"], alpha=a, lw=2.1, zorder=3)
        if np.any(hi_m):
            ax.plot(pts[hi_m], gain[hi_m], color=t["neg"], alpha=a, lw=2.1, zorder=3)
        if n_star <= N_MAX:
            ax.scatter([n_star], [log_r], s=42, facecolor=t["bg"], edgecolor=t["neg"], lw=1.7, zorder=5,
                       alpha=a)
            if alpha in cross_labels:
                ax.annotate(cross_labels[alpha], (n_star, log_r), xytext=(4, -13),
                            textcoords="offset points", fontsize=8.3, color=t["text2"], ha="left")
        j = int(np.nonzero(gain <= 0.94 * y_max)[0][-1])
        if j < len(pts) - 1:
            ax.annotate(ray_labels[alpha], (pts[j], gain[j]), xytext=(3, 2),
                        textcoords="offset points", fontsize=8.5, color=t["text"],
                        va="bottom", ha="left", annotation_clip=False)
        else:
            dy = -3 if alpha == 0.05 else 0
            ax.annotate(ray_labels[alpha], (pts[j], gain[j]), xytext=(6, dy),
                        textcoords="offset points", fontsize=8.5, color=t["text"],
                        va="center", ha="left", annotation_clip=False)

    # Illustrative saturation curve when communication / serial bottlenecks cap collective gain
    ceil = 0.74
    gain_sat = ceil * (1.0 - np.exp(-(0.25 * np.log10(n)) / ceil))
    ax.plot(n, gain_sat, color=t["friction"], lw=1.6, ls=(0, (4, 3)), zorder=4)
    ax.annotate("α = 0.25 with\ncoordination ceiling", (n[-1], gain_sat[-1]), xytext=(6, 1),
                textcoords="offset points", fontsize=8.0, color=t["friction"], va="center",
                ha="left", annotation_clip=False)

    ax.set_xscale("log")
    ax.set_xlim(1, N_MAX)
    ax.set_xticks([1, 1e3, 1e6, 1e9], ["1", "1K", "1M", "1B"])
    ax.minorticks_off()
    ax.set_ylim(0, y_max)
    ax.set_yticks(y_ticks, ["1×", "3×", "10×", "30×"])
    ax.set_xlabel("Number of agents N (log scale)", fontsize=9.5, color=t["text2"])
    fig.text(0.03, 0.915, r"Effective capability multiple, $N^{\alpha}$ (log scale)",
             fontsize=9, color=t["text2"], ha="left", va="bottom")
    alt = ("Log-log plot of a collective's effective capability multiple N^alpha against the number of "
           "agents N for additivity exponents alpha = 1 (additive), 0.5, 0.25, 0.125, and best-of-N "
           "floor alpha_0 = 0.05. Lines turn from green to red as they cross a 10x capability gap at "
           "10, 100, 10,000, and 100 million agents, alongside a dashed curve showing how coordination "
           "costs can bend collective gain toward a ceiling below 10x.")
    return save(fig, "fig4-gaggle", theme, alt, png=png), crossings


# ---------------------------------------------------------------- main

def main():
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--theme", choices=sorted(THEMES), default="light")
    ap.add_argument("--png", action="store_true", help="also write 300-DPI PNGs alongside SVGs")
    args = ap.parse_args()
    t = THEMES[args.theme]
    style(t)

    p0, (k_today, k_closed, k_lo) = fig_criticality(t, args.theme, png=args.png)
    p1, (t_cap, t_adopt, gap_months) = fig_two_thresholds(t, args.theme, png=args.png)
    p2 = fig_peaky(t, args.theme, png=args.png)
    p3, crossings = fig_gaggle(t, args.theme, png=args.png)

    print("Wrote", p0.name, p1.name, p2.name, p3.name)
    print(f"Criticality today: {k_today:.3f}; closed-only: {k_closed:.3f}; lower fan tip: {k_lo:.3f}")
    print(f"Closed crosses the bar at {first_crossing(closed_cap(T)):.2f} y; open capability at "
          f"{t_cap:.2f} y; open adoption at {t_adopt:.2f} y ({gap_months:.0f} months later)")
    print("N needed to close a 10x gap:", {f"alpha={a}": f"{n:.3g}" for a, n in crossings.items()})


if __name__ == "__main__":
    main()
