codex/harmonic-markov-absorption-network.py

harmonic-markov-absorption-network.py
"""Draw the five-state chain; run from _media to write the matching PNG.

Tested with Python 3.14.4, Matplotlib 3.10.7 and NumPy 2.3.5.
"""

import os
import tempfile
from pathlib import Path

os.environ.setdefault("MPLCONFIGDIR", str(Path(tempfile.gettempdir()) / "codex-wiki-matplotlib"))

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, FancyArrowPatch
from matplotlib.path import Path as MplPath


def main():
    plt.rcParams.update({"font.size": 12, "font.family": "DejaVu Sans"})
    fig, ax = plt.subplots(figsize=(9.6, 4.4), dpi=120, facecolor="white")
    ax.set_facecolor("white")
    blue, orange, ink = "#1769aa", "#b54b14", "#172b3a"
    radius = 0.21
    for i in range(1, 4):
        for direction, color, y in ((1, blue, 0.12), (-1, orange, -0.12)):
            j = i + direction
            start = (i + direction * radius, y)
            end = (j - direction * radius, y)
            ax.add_patch(FancyArrowPatch(start, end, arrowstyle="-|>",
                                        mutation_scale=15, linewidth=2, color=color))
            ax.text((i + j) / 2, 0.39 if direction == 1 else -0.39,
                    r"$p=\frac{1}{2}$" if direction == 1 else r"$q=\frac{1}{2}$",
                    ha="center", va="center", color=color, fontsize=14)
    for i in range(5):
        absorbing = i in (0, 4)
        ax.add_patch(Circle((i, 0), radius, facecolor="#edf4fa" if absorbing else "white",
                            edgecolor=ink, linewidth=2, zorder=3))
        if absorbing:
            ax.add_patch(Circle((i, 0), radius - 0.045, fill=False, edgecolor=ink,
                                linewidth=1, zorder=4))
            loop = MplPath([(i - 0.15, 0.17), (i - 0.75, 0.98),
                            (i + 0.75, 0.98), (i + 0.15, 0.17)],
                           [MplPath.MOVETO, MplPath.CURVE4, MplPath.CURVE4, MplPath.CURVE4])
            ax.add_patch(FancyArrowPatch(path=loop, arrowstyle="-|>", mutation_scale=15,
                                        linewidth=1.8, color=ink))
            ax.text(i, 0.82, "stay: 1", ha="center", color=ink)
            ax.text(i, -0.66, "absorbing", ha="center", color=ink)
        else:
            ax.text(i, -0.66, "interior", ha="center", color=ink)
        ax.text(i, 0, str(i), ha="center", va="center", color=ink, fontsize=16, zorder=5)
        value = ("0", r"\frac{1}{4}", r"\frac{1}{2}", r"\frac{3}{4}", "1")[i]
        ax.text(i, -1.07, rf"$h_{i}={value}$", ha="center", va="center", color=blue, fontsize=16)
    ax.set(xlim=(-0.65, 4.65), ylim=(-1.35, 1.10), aspect="equal")
    ax.axis("off")
    fig.suptitle("A five-state absorbing Markov chain", fontsize=17, y=0.96, color=ink)
    fig.text(0.5, 0.86, "Up and down refer to the state number; only interior states move.",
             ha="center", fontsize=11, color=ink)
    fig.text(0.5, 0.05, r"$h_i = \Pr_i(\mathrm{hit}\ 4\ \mathrm{before}\ 0)$: success probability",
             ha="center", fontsize=12, color=ink)
    fig.subplots_adjust(left=0.03, right=0.97, top=0.80, bottom=0.13)
    fig.savefig(Path(__file__).with_suffix(".png").name, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()