"""
determinant_ladder.py
=====================
One primitive, five arrows: four directions of ascent (size, richness,
dimension-of-map, degree) plus the grey value probe. All the visuals from
the journey, as callable functions, plus a numeric menu.

Spyder quick start:
    >>> from determinant_ladder import menu
    >>> menu()

Or call any figure directly, e.g.:
    >>> import determinant_ladder as dl
    >>> dl.fig_rainbow_ports()

Figures are shown on screen AND saved into ./figures/.
Dependencies: numpy, sympy, scipy, matplotlib.
"""
import os
import itertools
import string
from math import factorial

import numpy as np
import sympy as sp
import matplotlib.pyplot as plt
from matplotlib import cm

FIGDIR = 'figures'


def _finish(name):
    os.makedirs(FIGDIR, exist_ok=True)
    path = os.path.join(FIGDIR, name)
    plt.savefig(path, dpi=150, bbox_inches='tight')
    plt.show()
    print(f'saved {path}')


def _perm_sign(p):
    s, p = 1, list(p)
    for i in range(len(p)):
        while p[i] != i:
            j = p[i]; p[i], p[j] = p[j], p[i]; s = -s
    return s


# =====================================================================
# DIRECTION 0 — the map of the journey
# =====================================================================
def fig_index_map():
    """Overview: the five-arrow compass — four directions of ascent, one probe,
    and the fork where the formula demands its hidden hypothesis (commuting
    branch continues; the non-commuting stub loses det but keeps the trace)."""
    from matplotlib.patches import FancyArrowPatch
    fig, ax = plt.subplots(figsize=(15, 10.5))
    origin = np.array([0.0, 0.0])

    def arm(angle, length, color, name, stops, side, ls='-', lw=4,
            stop_fs=9, name_shift=(0, 0), head=True):
        v = np.array([np.cos(np.radians(angle)), np.sin(np.radians(angle))])
        tip = origin + v * length
        ax.annotate('', xy=tip, xytext=origin,
                    arrowprops=dict(arrowstyle='-|>' if head else '-',
                                    lw=lw, color=color, linestyle=ls))
        ax.text(*(tip + v * 0.10 + np.array(name_shift)), name, color=color,
                fontsize=12, fontweight='bold', ha='center', va='center')
        perp = np.array([-v[1], v[0]]) * side
        for i, s in enumerate(stops):
            t = length * (0.30 + 0.60 * i / max(len(stops) - 1, 1))
            p = origin + v * t
            ax.plot(*p, 'o', ms=5, color=color)
            ha = 'left' if perp[0] > 0.15 else ('right' if perp[0] < -0.15
                                                else 'center')
            va = 'top' if perp[1] < -0.15 else ('bottom' if perp[1] > 0.15
                                                else 'center')
            ax.text(*(p + perp * 0.05), s, fontsize=stop_fs, color=color,
                    ha=ha, va=va)
        return v, tip

    # four full directions of ascent + the grey value probe
    v_size, _ = arm(15, 1.00, '#1f77b4',
                    'DIRECTION 1\nSIZE of the matrix (n)',
                    ['n=2: ad-bc, one diagonal', 'n=4: diagonals break',
                     'n~4.27: drawability inverts', 'n=9: 181,440 paths'],
                    side=-1, name_shift=(0, -0.06))
    v_dim, _ = arm(60, 0.92, '#d62728',
                   'DIRECTION 3\nDIMENSION of the map\n(not independent)',
                   ['2D: Keller 1939 - still OPEN',
                    '3D: FALSE - Alpoge + Fable 2026',
                    'n>3: false by padding'],
                   side=-1)
    v_rich, tip_rich = arm(105, 0.96, '#2ca02c',
                           'DIRECTION 2\nRICHNESS of the entries',
                           ['numbers', 'variables: pure formal symbols',
                            'functions: symbols over a domain\ndet becomes a FIELD',
                            'operators (QFT determinants)'],
                           side=-1, name_shift=(-0.52, -0.14), head=False)
    v_deg, _ = arm(150, 1.00, '#9467bd',
                   'DIRECTION 4\nDEGREE of the map',
                   ['x^-2, x^-1: Laurent territory',
                    'x^2: no factorization shuffle',
                    'x^3: shuffle born - BCW target\n(the conjecture boundary)',
                    'x^7: the Fable map', 'x^100: 2D verified to here'],
                   side=1)
    arm(262, 0.62, '#888888',
        'VALUE - the probe, not a ladder\n(never changes what kind of thing you have)',
        ['0 -> 9: bigger, never different',
         'negatives: parity mirror, det(-I) = (-1)^n',
         'the c in det(cI) = c^n'],
        side=1, ls=(0, (5, 3)), lw=3, stop_fs=8.5)

    # the commutativity FORK at the top of RICHNESS: matrix-of-matrix
    # entries split the road — the commuting branch continues, the
    # non-commuting branch is a short dead end (det gone, trace kept)
    fork = origin + v_rich * 0.96
    branch_tip = fork + v_rich * 0.26
    ax.annotate('', xy=branch_tip, xytext=fork,
                arrowprops=dict(arrowstyle='-|>', lw=3.5, color='#2ca02c'))
    ax.text(-0.21, 1.12, 'commuting blocks - monogenic C[M]:\n'
            'the formula survives, the ladder continues',
            fontsize=8.5, color='#2ca02c', ha='left', va='bottom')
    stub_dir = np.array([np.cos(np.radians(148)), np.sin(np.radians(148))])
    stub_end = fork + stub_dir * 0.24
    ax.plot([fork[0], stub_end[0]], [fork[1], stub_end[1]],
            '-', lw=2.5, color='black')
    ax.plot(*stub_end, 's', ms=11, color='black', zorder=7)
    ax.text(*(stub_end + np.array([-0.03, 0.05])),
            'non-commuting blocks: det undefined\ntrace survives (tr(AB) = tr(BA))',
            fontsize=8.5, color='black', ha='right', va='bottom',
            fontweight='bold')
    ax.text(*(stub_end + np.array([-0.03, -0.02])),
            'successors: Dieudonné, quasideterminants',
            fontsize=7.5, color='#999999', ha='right', va='top')

    # dimension-of-map is size-of-matrix seen through the derivative
    a_pt = origin + v_size * 0.62
    b_pt = origin + v_dim * 0.62
    ax.add_patch(FancyArrowPatch(a_pt, b_pt, connectionstyle='arc3,rad=0.28',
                                 arrowstyle='<->', mutation_scale=14,
                                 color='gray', lw=1.4, ls=(0, (3, 2))))
    ax.text(0.73, 0.33, '= size, via the derivative',
            fontsize=9, color='gray', ha='center', va='center')

    # the primitive
    ax.plot(*origin, 'ko', ms=16, zorder=6)
    ax.text(-0.62, -0.30, 'THE PRIMITIVE\ndet = signed volume\n(one formula, n! shuffles)',
            fontsize=12, fontweight='bold', ha='center', va='center')

    ax.set_xlim(-1.55, 1.55); ax.set_ylim(-0.95, 1.30)
    ax.set_aspect('equal'); ax.axis('off')
    ax.set_title('Map of the journey: one primitive, five arrows\n'
                 'four directions of ascent, one grey probe - and the fork '
                 'where the formula demands its hidden hypothesis',
                 fontsize=16, pad=10)
    plt.tight_layout(); _finish('index_map.png')


def fig_hidden_hypothesis():
    """The hidden-hypothesis principle: a hypothesis can be vacuously
    satisfied at low rungs and binding above. Commutativity on the richness
    arrow (free for numbers/variables/functions, binding at
    matrix-of-matrix); properness on the dimension arrow (free at n = 1,
    conjecturally forced at n = 2, demonstrably independent from n = 3).
    A rhyme to investigate, not a theorem."""
    fig, ax = plt.subplots(figsize=(15, 8.5))
    rows = [
        (0.74, '#2ca02c', 'hidden hypothesis:\nCOMMUTATIVITY\n(richness arrow)',
         [(1.0, 'numbers', 'free', False),
          (2.0, 'variables', 'free', False),
          (3.0, 'functions', 'free', False),
          (4.2, 'matrix-of-matrix', 'BINDING\n(block det formulas fail;\n'
           'only the trace survives)', True)]),
        (0.30, '#d62728', 'hidden hypothesis:\nPROPERNESS\n(dimension arrow)',
         [(1.0, 'n = 1', 'free\n(Keller forces linear)', False),
          (2.5, 'n = 2', 'conjecturally forced\n(THE open question)', False),
          (4.2, 'n = 3 and up', 'INDEPENDENT\n(non-properness is the\n'
           "counterexample's escape route)", True)]),
    ]
    for y, col, hyp, rungs in rows:
        ax.annotate('', xy=(4.85, y), xytext=(0.6, y),
                    arrowprops=dict(arrowstyle='-|>', lw=3.5, color=col))
        ax.text(0.45, y, hyp, ha='right', va='center', fontsize=11,
                fontweight='bold', color=col)
        for x, rung, status, binding in rungs:
            if binding:
                ax.plot(x, y, 's', ms=13, color='black', zorder=5)
            else:
                ax.plot(x, y, 'o', ms=9, color=col, zorder=5)
            ax.text(x, y - 0.045, rung, ha='center', va='top', fontsize=10.5,
                    fontweight='bold', color=col)
            ax.text(x, y + 0.04, status, ha='center', va='bottom',
                    fontsize=9.5,
                    color='#d62728' if binding else '#888888',
                    fontweight='bold' if binding else 'normal')
    ax.text(2.7, 0.52, 'the same silhouette on two arrows: '
            'satisfied for free below, biting above',
            ha='center', va='center', fontsize=11, color='#9467bd',
            style='italic')
    ax.text(2.7, 0.055,
            'Keller re-read: "in low dimension, the properness clause comes '
            'free of charge"\n- true at rung one, unknown at rung two, false '
            'from rung three',
            ha='center', va='center', fontsize=10.5, color='#d62728')
    ax.set_xlim(-1.1, 5.1); ax.set_ylim(0, 1.02); ax.axis('off')
    ax.set_title('The hidden-hypothesis principle - a rhyme to investigate, '
                 'not a theorem\na condition can be vacuously satisfied at '
                 'low rungs and binding above', fontsize=15, pad=12)
    plt.tight_layout(); _finish('hidden_hypothesis.png')


def fig_ladder_teaser():
    """Where the ladder goes — a teaser for Paper II. (a) The von Neumann
    first steps: 0 = the empty set, 1 = the set containing nothing, each
    number the gathered history of the nothing before it. (b) The ladder
    bending back onto its own first rung: enrichment applied to enrichment
    — categories, then enriched categories. Deliberately spare."""
    from matplotlib.patches import Circle, FancyArrowPatch
    fig, axes = plt.subplots(1, 2, figsize=(13, 5.2))

    # --- a: numbers out of nothing ------------------------------------
    ax = axes[0]

    def draw_vn(k, cx, cy, r):
        ax.add_patch(Circle((cx, cy), r, fill=False, ec='#1f77b4',
                            lw=1.6, alpha=0.9))
        if k > 0:
            step = 2 * r * 0.72 / k
            x0 = cx - (k - 1) * step / 2
            for j in range(k):
                draw_vn(j, x0 + j * step, cy, r * 0.62 / max(k * 0.85, 1))

    labels = ['0 = ∅', '1 = {∅}', '2 = {∅, {∅}}', '3']
    for k, (x, r) in enumerate([(0.10, 0.045), (0.34, 0.075),
                                (0.62, 0.105), (0.90, 0.13)]):
        draw_vn(k, x, 0.56, r)
        ax.text(x, 0.30, labels[k], ha='center', fontsize=12,
                color='#1f77b4')
        if k < 3:
            ax.annotate('', xy=(x + 0.115 + r * 0.3, 0.56),
                        xytext=(x + r + 0.015, 0.56),
                        arrowprops=dict(arrowstyle='-|>', lw=1.4,
                                        color='#999999'))
    ax.text(0.5, 0.10, 'numbers as the gathered history\nof the nothing '
            'before them', ha='center', fontsize=10.5, style='italic',
            color='#555555')
    ax.set_xlim(0, 1.05); ax.set_ylim(0, 1)
    ax.set_aspect('equal'); ax.axis('off')

    # --- b: the ladder bends back -------------------------------------
    ax = axes[1]
    for xr in (0.38, 0.52):
        ax.plot([xr, xr], [0.15, 0.75], color='#2ca02c', lw=2.5)
    for yr in (0.24, 0.40, 0.56, 0.72):
        ax.plot([0.38, 0.52], [yr, yr], color='#2ca02c', lw=2)
    ax.add_patch(FancyArrowPatch((0.52, 0.74), (0.545, 0.20),
                                 connectionstyle='arc3,rad=-0.9',
                                 arrowstyle='-|>', mutation_scale=16,
                                 color='#9467bd', lw=2.2))
    ax.text(0.80, 0.47, 'enrichment applied to enrichment:\ncategories, '
            'then enriched categories', ha='left', va='center',
            fontsize=10.5, color='#9467bd')
    ax.text(0.45, 0.05, 'the ladder bending back onto its own first rung',
            ha='center', fontsize=10.5, style='italic', color='#555555')
    ax.set_xlim(0, 1.55); ax.set_ylim(0, 1); ax.axis('off')

    fig.suptitle('Where the ladder goes - a teaser', fontsize=15)
    plt.tight_layout(rect=[0, 0, 1, 0.92]); _finish('ladder_teaser.png')


# =====================================================================
# DIRECTION 1 — SIZE of the matrix
# =====================================================================
def fig_formulas():
    """Reference card: det formulas at n = 1, 2, 3 with letter entries."""
    fig, axes = plt.subplots(3, 1, figsize=(10, 10.5))

    def draw_matrix(ax, entries, x0, y0, cell=0.9):
        n = len(entries)
        for i in range(n):
            for j in range(n):
                ax.text(x0 + j*cell + cell/2, y0 - i*cell - cell/2, entries[i][j],
                        ha='center', va='center', fontsize=22)
        h = n * cell
        for xb, d in [(x0, 1), (x0 + n*cell, -1)]:
            ax.plot([xb + d*0.12, xb, xb, xb + d*0.12],
                    [y0, y0, y0 - h, y0 - h], color='black', lw=2.2)

    ax = axes[0]
    draw_matrix(ax, [['a']], 0.4, 1.4)
    ax.text(2.2, 0.95, r'$\det = a$', fontsize=24, va='center')
    ax.text(2.2, 0.35, 'the number itself - a signed length scale',
            fontsize=11, va='center', color='gray')
    ax.text(0.05, 1.75, 'n = 1', fontsize=14, fontweight='bold')
    ax.set_xlim(0, 10); ax.set_ylim(0, 2); ax.axis('off')

    ax = axes[1]
    draw_matrix(ax, [['a', 'b'], ['c', 'd']], 0.4, 2.3)
    ax.text(3.0, 1.4, r'$\det = ad - bc$', fontsize=24, va='center')
    ax.text(3.05, 0.75, 'main diagonal minus anti-diagonal\n'
            '2 terms = 2! shuffles of columns, signed by parity',
            fontsize=11, va='center', color='gray')
    ax.text(0.05, 2.65, 'n = 2', fontsize=14, fontweight='bold')
    ax.set_xlim(0, 10); ax.set_ylim(0, 3); ax.axis('off')

    ax = axes[2]
    draw_matrix(ax, [['a', 'b', 'c'], ['d', 'e', 'f'], ['g', 'h', 'i']], 0.4, 3.2)
    ax.text(4.0, 2.55, r'$\det = a(ei - fh) - b(di - fg) + c(dh - eg)$',
            fontsize=19, va='center')
    ax.text(4.0, 1.85, r'$= aei + bfg + cdh - ceg - bdi - afh$',
            fontsize=17, va='center', color='#444444')
    ax.text(4.0, 1.15,
            'expand along the top row: each letter times the 2x2 det left after\n'
            'crossing out its row and column, alternating signs + - +\n'
            '6 terms = 3! shuffles: three even (+), three odd (-)',
            fontsize=11, va='center', color='gray')
    ax.text(0.05, 3.55, 'n = 3', fontsize=14, fontweight='bold')
    ax.set_xlim(0, 10.6); ax.set_ylim(0.4, 3.9); ax.axis('off')

    fig.suptitle('The determinant: three formulas, one pattern\n'
                 'n! signed shuffle-terms - each n uses the (n-1) formula inside itself',
                 fontsize=14, y=0.99)
    plt.tight_layout(rect=[0, 0, 1, 0.94]); _finish('det_formulas.png')


def fig_geometry():
    """det = signed length / area / volume, plus the simple-sequence patterns."""
    from mpl_toolkits.mplot3d.art3d import Poly3DCollection
    fig = plt.figure(figsize=(16, 9.5))

    ax = fig.add_subplot(2, 3, 1)
    a = 2.0
    ax.annotate('', xy=(1, 0.6), xytext=(0, 0.6),
                arrowprops=dict(arrowstyle='-|>', lw=3, color='gray'))
    ax.annotate('', xy=(a, 0.3), xytext=(0, 0.3),
                arrowprops=dict(arrowstyle='-|>', lw=3, color='#d62728'))
    ax.text(0.5, 0.68, 'unit interval, length 1', ha='center')
    ax.text(a/2, 0.18, f'[{a}] . interval -> length {a}', ha='center', color='#d62728')
    ax.annotate('', xy=(-1.5, 0.05), xytext=(0, 0.05),
                arrowprops=dict(arrowstyle='-|>', lw=3, color='#1f77b4'))
    ax.text(-0.75, -0.07, 'det = -1.5: stretch AND flip', ha='center', color='#1f77b4')
    ax.set_xlim(-2, 2.6); ax.set_ylim(-0.2, 0.85); ax.axis('off')
    ax.set_title('n = 1:  det[a] = a\nsigned LENGTH scale')

    ax = fig.add_subplot(2, 3, 2)
    Mx = np.array([[2, 1], [0.5, 1.5]])
    sq = np.array([[0, 0], [1, 0], [1, 1], [0, 1]])
    par = sq @ Mx.T
    ax.fill(sq[:, 0], sq[:, 1], color='lightgray', alpha=0.8, label='unit square, area 1')
    ax.fill(par[:, 0], par[:, 1], color='#d62728', alpha=0.45,
            label=f'image: area = det = {np.linalg.det(Mx):.2f}')
    for col, cc in zip(Mx.T, ['#d62728', '#8c564b']):
        ax.annotate('', xy=col, xytext=(0, 0),
                    arrowprops=dict(arrowstyle='-|>', lw=2.5, color=cc))
    ax.set_aspect('equal'); ax.legend(loc='upper left', fontsize=9)
    ax.set_title('n = 2:  det = ad - bc\nsigned AREA of parallelogram')

    ax = fig.add_subplot(2, 3, 3, projection='3d')
    M3 = np.array([[1.5, 0.4, 0.3], [0.2, 1.2, 0.5], [0.1, 0.3, 1.4]])
    verts = np.array([[x, y, z] for x in (0, 1) for y in (0, 1) for z in (0, 1)])
    tv = verts @ M3.T
    faces = [[0,1,3,2],[4,5,7,6],[0,1,5,4],[2,3,7,6],[0,2,6,4],[1,3,7,5]]
    ax.add_collection3d(Poly3DCollection([verts[i] for i in faces],
                        facecolor='lightgray', edgecolor='k', alpha=0.15))
    ax.add_collection3d(Poly3DCollection([tv[i] for i in faces],
                        facecolor='#d62728', edgecolor='k', alpha=0.35))
    ax.set_title(f'n = 3:  det = signed VOLUME\nhere det = {np.linalg.det(M3):.2f}')
    ax.set_xlim(0, 2.3); ax.set_ylim(0, 2.1); ax.set_zlim(0, 2.3)

    ax = fig.add_subplot(2, 3, 4)
    cs = np.linspace(-2, 2, 400)
    for nn, col in [(1, '#1f77b4'), (2, '#2ca02c'), (3, '#d62728')]:
        ax.plot(cs, cs**nn, lw=2.5, color=col, label=f'n = {nn}: det(cI) = c^{nn}')
    ax.axhline(0, color='gray', lw=0.7); ax.axvline(0, color='gray', lw=0.7)
    ax.legend(fontsize=9); ax.set_xlabel('c'); ax.set_ylabel('det')
    ax.set_title('Dimension enters as an exponent\ndet(-I) = (-1)^n - parity!')

    ax = fig.add_subplot(2, 3, 5)
    width = 0.25
    cs5 = [-2, -1, 0, 1, 2]
    for i, (nn, col) in enumerate([(1, '#1f77b4'), (2, '#2ca02c'), (3, '#d62728')]):
        vals = [np.linalg.det(np.full((nn, nn), float(c))) for c in cs5]
        ax.bar(np.arange(5) + (i-1)*width, vals, width, color=col, label=f'n = {nn}')
    ax.set_xticks(range(5), cs5); ax.set_xlabel('c (every entry equals c)')
    ax.set_ylabel('det'); ax.legend()
    ax.set_title('All-equal matrices: det = 0 for n >= 2\nsameness = dependence = COLLAPSE')

    ax = fig.add_subplot(2, 3, 6)
    ks = np.array([-2, -1, 0, 1, 2])
    for nn, col in [(1, '#1f77b4'), (2, '#2ca02c'), (3, '#d62728')]:
        ax.plot(ks, 10.0**(ks*nn), 'o-', lw=2.5, color=col,
                label=f'n = {nn}: det = 10^({nn}k)')
    ax.set_yscale('log'); ax.set_xlabel('k (matrix = 10^k * I)')
    ax.set_ylabel('det (log scale)'); ax.legend(fontsize=9)
    ax.set_title('Powers of ten: slopes 1, 2, 3\nvolume scaling compounds per dimension')
    plt.tight_layout(); _finish('det_journey.png')


def fig_permutations():
    """Shuffled identities, all 0/1 matrices, and the non-square trap."""
    from scipy.spatial import ConvexHull
    from collections import Counter
    fig = plt.figure(figsize=(16, 9.5))
    for idx, p in enumerate(itertools.permutations(range(3))):
        ax = plt.subplot2grid((4, 6), (0 if idx < 3 else 1, idx % 3), fig=fig)
        P = np.zeros((3, 3)); P[range(3), p] = 1
        d = int(round(np.linalg.det(P)))
        ax.imshow(P, cmap='Greys', vmin=0, vmax=1)
        ax.set_xticks([]); ax.set_yticks([])
        ax.set_title(f'det = {d:+d}', fontsize=11,
                     color='#2ca02c' if d > 0 else '#d62728')
        for spine in ax.spines.values():
            spine.set_color('#2ca02c' if d > 0 else '#d62728'); spine.set_linewidth(2)
    axl = plt.subplot2grid((4, 6), (0, 3), colspan=3, rowspan=2, fig=fig)
    axl.axis('off')
    axl.text(0.02, 0.75, 'A: shuffled identities (all 3x3 permutation matrices)',
             fontsize=12, fontweight='bold')
    axl.text(0.02, 0.45,
             'det = sign of the shuffle: +1 even swaps, -1 odd.\n'
             'Volume never changes (|det| = 1); only ORIENTATION flips.\n'
             'Green = rotations, red = mirror reflections. Half of each.',
             fontsize=10.5)
    for col, nn in enumerate((2, 3)):
        ax = plt.subplot2grid((4, 6), (2, col*2), colspan=2, rowspan=2, fig=fig)
        dets = Counter()
        for bits in itertools.product([0, 1], repeat=nn*nn):
            dets[int(round(np.linalg.det(np.array(bits, float).reshape(nn, nn))))] += 1
        keys = sorted(dets)
        colors = ['#d62728' if k == 0 else '#1f77b4' for k in keys]
        ax.bar([str(k) for k in keys], [dets[k] for k in keys], color=colors)
        for i, k in enumerate(keys):
            ax.text(i, dets[k], str(dets[k]), ha='center', va='bottom', fontsize=9)
        ax.set_title(f'B: all {2**(nn*nn)} binary {nn}x{nn} matrices\n'
                     'det = 0 (collapse) in red', fontsize=11)
        ax.set_xlabel('det value'); ax.set_ylabel('count')
    ax = plt.subplot2grid((4, 6), (2, 4), colspan=2, rowspan=2, fig=fig)
    A = np.array([[1.0, 0.0, 1.0], [0.0, 1.0, 1.0]])
    verts3 = np.array(list(itertools.product([0, 1], repeat=3)))
    shadow = verts3 @ A.T
    hull = ConvexHull(shadow); poly = shadow[hull.vertices]
    ax.fill(poly[:, 0], poly[:, 1], color='#9467bd', alpha=0.4)
    ax.plot(shadow[:, 0], shadow[:, 1], 'ko', ms=5)
    ax.set_title('C: 2x3 matrix - det UNDEFINED\n3D cube crushed to flat zonogon;\n'
                 f'sqrt(det(AA^T)) = {np.sqrt(np.linalg.det(A @ A.T)):.3f}', fontsize=11)
    ax.set_aspect('equal')
    plt.tight_layout(); _finish('perm_journey.png')


def fig_sarrus(ns=(2, 3, 4)):
    """Positive determinant terms as colored paths; Sarrus breaks at n = 4."""
    fig, axes = plt.subplots(1, len(ns), figsize=(7.3*len(ns), 8.5))
    if len(ns) == 1: axes = [axes]
    cmaps = plt.cm.tab20(np.linspace(0, 1, 20))
    for ax, n in zip(axes, ns):
        L = string.ascii_lowercase
        M = [[L[i*n + j] for j in range(n)] for i in range(n)]
        terms = [p for p in itertools.permutations(range(n)) if _perm_sign(p) == 1]
        for i in range(n):
            for j in range(n):
                ax.add_patch(plt.Rectangle((j, n-1-i), 1, 1, fill=False,
                                           edgecolor='lightgray', lw=1))
                ax.text(j + 0.5, n-1-i + 0.5, M[i][j], ha='center', va='center',
                        fontsize=30 - 3*n, fontweight='bold', zorder=5)
        labels = []
        for k, p in enumerate(terms):
            pts = np.array([[p[i] + 0.5, n-1-i + 0.5] for i in range(n)])
            off = (k - len(terms)/2) * 0.045
            po = pts + np.array([off, off])
            col = cmaps[k % 20]
            ax.plot(po[:, 0], po[:, 1], '-', lw=3, color=col, alpha=0.85)
            ax.annotate('', xy=po[-1], xytext=po[-2],
                        arrowprops=dict(arrowstyle='-|>', lw=3, color=col))
            labels.append(''.join(M[i][p[i]] for i in range(n)))
        ax.set_xlim(-0.6, n + 0.6); ax.set_ylim(-1.6, n + 0.4)
        ax.set_aspect('equal'); ax.axis('off')
        ax.set_title(f'n = {n}: {len(terms)} positive terms', fontsize=15)
        ax.text(n/2, -0.55, ' + '.join(labels), ha='center',
                fontsize=15 if n < 4 else 10.5, wrap=True)
        if n == 4:
            ax.text(n/2, -1.25, 'Sarrus BREAKS here: 8 wrap-diagonals, 12 terms needed',
                    ha='center', fontsize=11, color='#d62728')
    fig.suptitle('Positive determinant terms as paths - the diagonal picture is\n'
                 'a low-dimensional accident', fontsize=16, y=1.0)
    plt.tight_layout(rect=[0, 0, 1, 0.93]); _finish('sarrus_lines.png')


def _ports(paths, n, R=0.33):
    node_users = {}
    for lid, p, pts in paths:
        for i in range(n):
            prev_d = pts[i] - pts[i-1] if i > 0 else None
            next_d = pts[i+1] - pts[i] if i < n-1 else None
            if prev_d is None: t = next_d
            elif next_d is None: t = prev_d
            else:
                t = prev_d/np.linalg.norm(prev_d) + next_d/np.linalg.norm(next_d)
                if np.linalg.norm(t) < 1e-9: t = prev_d
            node_users.setdefault((i, p[i]), []).append(
                (lid, np.arctan2(t[1], t[0]), t/np.linalg.norm(t)))
    offsets = {}
    for node, users in node_users.items():
        users_sorted = sorted(users, key=lambda u: (round(u[1], 3), u[0]))
        m = len(users_sorted)
        i_row, j_col = node
        center = np.array([j_col + 0.5, n - 1 - i_row + 0.5])
        for k, (lid, ang, t) in enumerate(users_sorted):
            perp = np.array([-t[1], t[0]])
            slot = (k - (m - 1) / 2) / max(m - 1, 1) * 2 * (R * 0.85)
            offsets[(node, lid)] = center + perp * slot
    return node_users, offsets


def fig_rainbow_ports(n=5, keep_columns=None, show_counts=False):
    """n x n positive paths, rainbow families by start column, arc-port fanning.
    keep_columns: e.g. (0, 2) to show only the a- and c-families.
    show_counts: print each letter's docking count under its circle
    (paper Figure 6 — 'per-node docking counts')."""
    L = string.ascii_lowercase
    M = [[L[i*n + j] for j in range(n)] for i in range(n)]
    pos = [p for p in itertools.permutations(range(n)) if _perm_sign(p) == 1]
    if keep_columns is not None:
        pos = [p for p in pos if p[0] in keep_columns]
    paths = []
    for lid, p in enumerate(pos):
        pts = [np.array([p[i] + 0.5, n - 1 - i + 0.5]) for i in range(n)]
        paths.append((lid, p, pts))
    R = 0.33
    node_users, offsets = _ports(paths, n, R)

    all_cmaps = [cm.Reds, cm.Oranges, cm.Greens, cm.Blues, cm.Purples,
                 cm.Greys, cm.YlOrBr, cm.BuGn, cm.PuRd]
    cols_present = sorted({p[0] for _, p, _ in paths})
    fam_cmap = {c: all_cmaps[c % len(all_cmaps)] for c in cols_present}
    counters = {c: 0 for c in cols_present}
    fam_size = {c: sum(1 for _, p, _ in paths if p[0] == c) for c in cols_present}

    fig, ax = plt.subplots(figsize=(3*n + 1, 3*n + 2))
    for i in range(n):
        for j in range(n):
            ax.add_patch(plt.Rectangle((j, n-1-i), 1, 1, fill=False,
                                       edgecolor='#dddddd', lw=1))
            ax.add_patch(plt.Circle((j + 0.5, n-1-i + 0.5), R, fill=False,
                                    edgecolor='#eeeeee', lw=0.8, zorder=1))
    for lid, p, pts in paths:
        c = p[0]; k = counters[c]; counters[c] += 1
        col = fam_cmap[c](0.45 + 0.5 * k / max(fam_size[c] - 1, 1))
        W = np.array([offsets[((i, p[i]), lid)] for i in range(n)])
        ax.plot(W[:, 0], W[:, 1], '-', lw=2.0, color=col, alpha=0.85, zorder=2)
        ax.annotate('', xy=W[-1], xytext=W[-2],
                    arrowprops=dict(arrowstyle='-|>', lw=2.0, color=col, alpha=0.9),
                    zorder=3)
    for i in range(n):
        for j in range(n):
            ax.text(j + 0.5, n-1-i + 0.5, M[i][j], ha='center', va='center',
                    fontsize=25, fontweight='bold', zorder=6,
                    bbox=dict(boxstyle='circle,pad=0.12', fc='white',
                              ec='none', alpha=0.65))
    for c in cols_present:
        ax.text(c + 0.5, n + 0.22, f'start {M[0][c]}', ha='center', fontsize=13,
                color=fam_cmap[c](0.8), fontweight='bold')
    if show_counts:
        for i in range(n):
            for j in range(n):
                cnt = len(node_users.get((i, j), []))
                ax.text(j + 0.5, n-1-i + 0.5 - R - 0.05, str(cnt),
                        ha='center', va='top', fontsize=11, fontweight='bold',
                        color='#444444' if cnt else '#bbbbbb', zorder=6)
    ax.set_xlim(-0.4, n + 0.4); ax.set_ylim(-0.5, n + 0.85)
    ax.set_aspect('equal'); ax.axis('off')
    ax.set_title(f'n = {n}: {len(paths)} positive paths with arc-port fanning',
                 fontsize=16, pad=12)
    plt.tight_layout()
    suffix = '' if keep_columns is None else '_' + ''.join(M[0][c] for c in keep_columns)
    _finish(f'rainbow{n}{suffix}.png')


def fig_growth():
    """Letters n^2 vs positive paths n!/2 for n = 1..9, linear and log."""
    ns = np.arange(1, 10)
    letters = ns**2
    paths = np.array([max(factorial(int(n)) // 2, 1) for n in ns])
    fig, axes = plt.subplots(1, 2, figsize=(14, 6))
    ax = axes[0]
    ax.plot(ns, letters, 'o-', lw=2.5, color='#1f77b4', label='letters = n^2')
    ax.plot(ns, paths, 's-', lw=2.5, color='#d62728', label='positive paths = n!/2')
    ax.set_xlabel('n'); ax.set_ylabel('count')
    ax.set_title('Linear: paths vanish then explode')
    ax.legend(); ax.grid(alpha=0.3)
    ax = axes[1]
    ax.semilogy(ns, letters, 'o-', lw=2.5, color='#1f77b4', label='letters = n^2')
    ax.semilogy(ns, paths, 's-', lw=2.5, color='#d62728', label='paths = n!/2')
    ax.semilogy(ns, paths/letters, '^--', lw=2, color='#9467bd',
                label='paths per letter')
    ax.set_xlabel('n'); ax.set_ylabel('count (log)')
    ax.set_title('Log: polynomial straightens, factorial keeps bending')
    ax.legend(); ax.grid(alpha=0.3, which='both')
    plt.tight_layout(); _finish('growth.png')


def fig_crossing():
    """Exact crossings of x^2 and x!/2 via the Gamma function."""
    from scipy.special import gamma
    from scipy.optimize import brentq
    f = lambda x: gamma(x + 1) / 2 - x**2
    x1 = brentq(f, 0.5, 2.0); x2 = brentq(f, 3.5, 5.0)
    print(f'crossings: x = {x1:.6f} and x = {x2:.6f}')
    xs = np.linspace(0.5, 6, 500)
    fig, ax = plt.subplots(figsize=(10, 6.5))
    ax.plot(xs, xs**2, lw=2.5, color='#1f77b4', label='letters: x^2')
    ax.plot(xs, gamma(xs + 1)/2, lw=2.5, color='#d62728',
            label='paths: Gamma(x+1)/2')
    ns = np.arange(1, 7)
    ax.plot(ns, ns**2, 'o', ms=8, color='#1f77b4')
    ax.plot(ns, [max(factorial(n)//2, 1) for n in ns], 's', ms=8, color='#d62728')
    for x, lab in [(x1, f'x = {x1:.3f}'), (x2, f'inversion\nx = {x2:.3f}')]:
        ax.axvline(x, color='gray', ls=':', lw=1.5)
        ax.plot(x, x**2, 'k*', ms=16, zorder=5)
        ax.annotate(lab, (x, x**2), textcoords='offset points', xytext=(10, -28),
                    fontsize=11, fontweight='bold')
    ax.fill_between(xs, xs**2, gamma(xs+1)/2, where=xs**2 > gamma(xs+1)/2,
                    color='#1f77b4', alpha=0.12, label='letters rule (drawable)')
    ax.fill_between(xs, xs**2, gamma(xs+1)/2, where=gamma(xs+1)/2 > xs**2,
                    color='#d62728', alpha=0.12, label='paths rule (storm)')
    ax.set_ylim(0, 60); ax.set_xlabel('n (continuous)'); ax.set_ylabel('count')
    ax.set_title('The two crossings: entries vs terms\n'
                 'n = 4 is the last gasp of the drawable world (16 vs 12)')
    ax.legend(loc='upper left'); ax.grid(alpha=0.3)
    plt.tight_layout(); _finish('crossing.png')


# =====================================================================
# DIRECTION 2 — RICHNESS of the entries
# =====================================================================
def fig_det_field():
    """The function rung: det becomes a field (Whitney cusp map)."""
    x, y = sp.symbols('x y')
    F1, F2 = x*y - x**3, y
    J = sp.Matrix([F1, F2]).jacobian([x, y])
    detJ = sp.expand(J.det())
    print('F =', (F1, F2), '   det DF =', detJ)
    f_det = sp.lambdify((x, y), detJ, 'numpy')
    f_map = sp.lambdify((x, y), [F1, F2], 'numpy')
    lim = 1.4
    g = np.linspace(-lim, lim, 600)
    X, Y = np.meshgrid(g, g)
    D = f_det(X, Y)
    fig, axes = plt.subplots(1, 2, figsize=(15, 7))
    ax = axes[0]
    vmax = np.percentile(np.abs(D), 98)
    im = ax.pcolormesh(X, Y, D, cmap='RdBu_r', vmin=-vmax, vmax=vmax, shading='auto')
    csz = ax.contour(X, Y, D, levels=[0], colors='black', linewidths=2.5)
    ax.set_title('det DF = y - 3x^2, a FIELD over the plane\n'
                 'red +: preserves, blue -: mirrors, black: collapse (Jacobi alarm)')
    ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_aspect('equal')
    fig.colorbar(im, ax=ax, label='det DF(x, y)')
    ax = axes[1]
    t = np.linspace(-lim, lim, 500)
    for gv in np.linspace(-lim, lim, 27):
        for (gx, gy) in [(np.full_like(t, gv), t), (t, np.full_like(t, gv))]:
            u, v = f_map(gx, gy)
            d = f_det(gx, gy)
            ax.scatter(u, v, c=np.where(d >= 0, '#d62728', '#1f77b4'),
                       s=0.4, alpha=0.45)
    try:
        zero_paths = csz.get_paths()          # matplotlib >= 3.8
    except AttributeError:
        zero_paths = [p for coll in csz.collections for p in coll.get_paths()]
    for path in zero_paths:
        vp = path.vertices
        u, v = f_map(vp[:, 0], vp[:, 1])
        ax.plot(u, v, 'k-', lw=2.8, zorder=5)
    ax.set_title('Image of the grid: creased along the fold\n'
                 'in THIS world, merging happens only by crossing det = 0')
    ax.set_xlabel('F1'); ax.set_ylabel('F2'); ax.set_aspect('equal')
    ax.set_xlim(-1.2, 1.2); ax.set_ylim(-1.5, 1.5)
    plt.tight_layout(); _finish('det_field.png')


def fig_block_break(seed=3):
    """The commutativity cliff: block-determinant formulas vs the truth.
    det(AD - BC) for a 2x2 block matrix is valid only when the blocks
    commute — the shuffle formula silently assumed ad = da. The commuting
    example draws its blocks from the MONOGENIC (singly generated)
    commutative subalgebra C[M]: polynomials in one fixed matrix always
    commute; two independent generators generically do not. Meanwhile the
    TRACE — the additive invariant — walks past the cliff: tr(AB) = tr(BA)
    holds for non-commuting matrices too (cyclicity)."""
    rng = np.random.default_rng(seed)
    I2 = np.eye(2)
    M = np.array([[1.0, 2.0], [3.0, 4.0]])
    # monogenic example: every block lies in C[M], polynomials in ONE matrix
    com = dict(A=M, B=M @ M - I2, C=2*M + I2, D=M @ M - 3*M)
    # generic example: independent integer blocks — no reason to commute
    gen = dict(zip('ABCD', (rng.integers(-3, 4, (2, 2)).astype(float)
                            for _ in range(4))))
    cases = [('blocks from the MONOGENIC algebra C[M]\n'
              '(polynomials in one matrix - always commute)', com),
             ('generic blocks, two independent generators\n'
              '(commutativity generically destroyed)', gen)]
    labels = ['true det of the 4x4', 'det(AD - BC)', 'det(AD - CB)']
    colors = ['#1f77b4', '#2ca02c', '#9467bd']
    fig, axes = plt.subplots(1, 2, figsize=(13, 6.5))
    for ax, (title, blk) in zip(axes, cases):
        A, B, C, D = blk['A'], blk['B'], blk['C'], blk['D']
        big = np.block([[A, B], [C, D]])
        vals = [np.linalg.det(big), np.linalg.det(A @ D - B @ C),
                np.linalg.det(A @ D - C @ B)]
        vals = [round(v) for v in vals]
        print(f'{title.splitlines()[0]}:  true={vals[0]},  '
              f'det(AD-BC)={vals[1]},  det(AD-CB)={vals[2]}')
        bars = ax.bar(labels, vals, color=colors, width=0.62)
        pad = 0.02 * max(abs(x) for x in vals)
        for bar, v in zip(bars, vals):
            up = v >= 0
            ax.text(bar.get_x() + bar.get_width()/2,
                    bar.get_height() + (pad if up else -pad), f'{v}',
                    ha='center', va='bottom' if up else 'top',
                    fontsize=13, fontweight='bold')
        agree = len(set(vals)) == 1
        ax.set_title(title + '\n' + ('all three AGREE'
                     if agree else 'all three DISAGREE'),
                     fontsize=11.5,
                     color='#2ca02c' if agree else '#d62728')
        ax.axhline(0, color='gray', lw=0.8)
        ax.tick_params(axis='x', labelsize=9)
        ax.grid(axis='y', alpha=0.3)
    # the additive invariant survives the cliff: cyclicity needs no ad = da
    A, B = gen['A'], gen['B']
    tAB, tBA = np.trace(A @ B), np.trace(B @ A)
    print(f'trace survives the cliff: tr(AB) = {tAB:g}, tr(BA) = {tBA:g} '
          f'(equal, though AB != BA) - while the det formulas disagree')
    axes[1].text(0.5, -0.16,
                 f'yet tr(AB) = tr(BA) = {tAB:g} for these same blocks: '
                 'the ADDITIVE invariant survives the cliff',
                 transform=axes[1].transAxes, ha='center', fontsize=10.5,
                 color='#1f77b4')
    fig.suptitle('Matrix-of-matrix entries: the shuffle formula assumed the '
                 'letters commute\nover a non-commutative ring "the" '
                 'determinant stops being well-defined - but the trace '
                 'survives', fontsize=13.5)
    plt.tight_layout(rect=[0, 0.04, 1, 0.90]); _finish('block_break.png')


def fig_two_vacuums(samples=200, seed=42):
    """Two operations, two nothings: 0 is the additive identity (empty sum),
    1 is the multiplicative identity (empty product) — neither more
    fundamental. det lives in the multiplicative world (det AB = det A det B,
    det of the 0x0 matrix = 1); its additive twin is the trace. exp/log
    bridge the worlds, and det(exp A) = exp(tr A) is the capstone."""
    from scipy.linalg import expm
    from matplotlib.patches import FancyBboxPatch

    E0 = np.zeros((0, 0))
    print(f'det of the 0x0 matrix = {np.linalg.det(E0):g} (empty product); '
          f'trace = {np.trace(E0):g} (empty sum)')

    fig, axes = plt.subplots(1, 3, figsize=(19, 6.5))

    # --- panel a: the two worlds --------------------------------------
    ax = axes[0]
    cols = [
        (0.19, '#1f77b4', 'ADDITIVE\nWORLD',
         ['identity: 0', 'empty SUM = 0', 'TRACE:\ntr(A+B) = tr A + tr B',
          'tr(0x0 matrix) = 0', 'swap COUNT k (mod 2)']),
        (0.81, '#d62728', 'MULTIPLICATIVE\nWORLD',
         ['identity: 1', 'empty PRODUCT = 1', 'DETERMINANT:\ndet(AB) = det A det B',
          'det(0x0 matrix) = 1', 'sign (-1)^k']),
    ]
    for xc, col, name, rows in cols:
        ax.add_patch(FancyBboxPatch((xc - 0.17, 0.12), 0.34, 0.72,
                                    boxstyle='round,pad=0.02',
                                    fc='none', ec=col, lw=2))
        ax.text(xc, 0.93, name, ha='center', va='center', fontsize=11.5,
                fontweight='bold', color=col)
        for i, r in enumerate(rows):
            ax.text(xc, 0.76 - 0.145 * i, r, ha='center', va='center',
                    fontsize=9.5, color=col)
    for y, lab, x0, x1 in [(0.58, 'exp: sums -> products', 0.385, 0.615),
                           (0.40, 'log: products -> sums', 0.615, 0.385)]:
        ax.annotate('', xy=(x1, y), xytext=(x0, y),
                    arrowprops=dict(arrowstyle='-|>', lw=2.2, color='#9467bd'))
        ax.text(0.5, y + 0.035, lab, ha='center', fontsize=8,
                color='#9467bd')
    ax.text(0.5, 0.035, 'parity bridge: additive swap-count k\n'
            'becomes the multiplicative sign (-1)^k',
            ha='center', fontsize=9, color='#9467bd')
    ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
    ax.set_title('Two operations, two nothings -\nneither origin is more '
                 'fundamental', fontsize=12)

    # --- panel b: the capstone identity -------------------------------
    ax = axes[1]
    rng = np.random.default_rng(seed)
    trs, logdets, bad = [], [], 0
    for _ in range(samples):
        n = int(rng.integers(2, 6))
        A = rng.uniform(-1, 1, (n, n)) / n
        sign, logabs = np.linalg.slogdet(expm(A))
        if sign != 1:
            bad += 1
        trs.append(np.trace(A)); logdets.append(logabs)
    trs = np.array(trs); logdets = np.array(logdets)
    if bad:
        print(f'WARNING: {bad} samples had det(exp A) with sign != +1')
    disc = np.abs(logdets - trs).max()
    lo, hi = trs.min() - 0.1, trs.max() + 0.1
    ax.plot([lo, hi], [lo, hi], color='#2ca02c', lw=2, label='y = x')
    ax.scatter(trs, logdets, s=22, color='#9467bd', alpha=0.7,
               label=f'{samples} random A, n = 2..5')
    ax.set_xlabel('tr A  (additive invariant)')
    ax.set_ylabel('log det(exp A)  (multiplicative invariant, via slogdet)')
    ax.legend(fontsize=9)
    ax.set_title('The capstone: det(exp A) = exp(tr A)\n'
                 f'max |log det(e^A) - tr A| = {disc:.1e}', fontsize=12)
    ax.grid(alpha=0.3)

    # --- panel c: absorption vs transparency --------------------------
    ax = axes[2]
    x = np.linspace(-3, 3, 200)
    ax.plot(x, 0 * x, lw=3, color='#d62728',
            label='x * 0 = 0 - additive vacuum ABSORBS (collapse)')
    ax.plot(x, 1 * x, lw=3, color='#2ca02c',
            label='x * 1 = x - multiplicative vacuum is transparent')
    ax.axhline(0, color='gray', lw=0.6); ax.axvline(0, color='gray', lw=0.6)
    ax.set_xlabel('x'); ax.set_ylabel('product')
    ax.legend(fontsize=9, loc='upper left')
    ax.set_title('Collapse, re-read: one dependent row is the\n'
                 'additive vacuum invading the multiplicative world',
                 fontsize=12)
    ax.grid(alpha=0.3)

    fig.suptitle('The two vacuums: 0 (empty sum) and 1 (empty product) - '
                 'trace and determinant as the two shadows of one matrix',
                 fontsize=14)
    plt.tight_layout(rect=[0, 0, 1, 0.93]); _finish('two_vacuums.png')


def fig_two_nothings_finale(samples=50, seed=42):
    """The two-vacuums layer completed. (a) The hospitality table: each
    nothing transparent at home, mirror OPPOSITES abroad — zero annihilates
    the multiplicative world (x*0 = 0), one generates the additive world
    (1, 1+1, 1+1+1, ... builds the number line). (b) Each nothing's locus
    in the matrix world — tr A = 0 and det A = 1, i.e. sl_n and SL_n —
    carried exactly onto each other by exp, since exp(0) = 1; verified
    numerically. (c) When the two nothings touch, 0 = 1 forces every
    x = x*1 = x*0 = 0: the zero ring, the one-point arithmetic."""
    from scipy.linalg import expm
    from matplotlib.patches import FancyBboxPatch

    # panel-b check first: traceless exponentiates to volume-preserving
    rng = np.random.default_rng(seed)
    devs = []
    for _ in range(samples):
        n = int(rng.integers(2, 6))
        A = rng.uniform(-1, 1, (n, n))
        A -= (np.trace(A) / n) * np.eye(n)          # project the trace out
        sign, logabs = np.linalg.slogdet(expm(A))
        devs.append(abs(sign * np.exp(logabs) - 1.0))
    maxdev = max(devs)
    print(f'{samples} random traceless matrices (n = 2..5): '
          f'max |det(exp A) - 1| = {maxdev:.2e} - traceless exponentiates '
          'to volume-preserving, exactly as exp(0) = 1 demands')
    if maxdev > 1e-10:
        print('WARNING: deviation above machine-precision expectations')

    fig, axes = plt.subplots(1, 3, figsize=(19, 6.8))

    # --- a: the hospitality table -------------------------------------
    ax = axes[0]
    ax.text(0.40, 0.90, 'AT HOME', ha='center', fontsize=12,
            fontweight='bold', color='#555555')
    ax.text(0.78, 0.90, 'ABROAD', ha='center', fontsize=12,
            fontweight='bold', color='#555555')
    ax.text(0.02, 0.62, 'ZERO\nthe additive\nnothing', ha='left',
            va='center', fontsize=11, fontweight='bold', color='#1f77b4')
    ax.text(0.02, 0.22, 'ONE\nthe multiplicative\nnothing', ha='left',
            va='center', fontsize=11, fontweight='bold', color='#d62728')
    ax.text(0.40, 0.62, 'transparent:\nx + 0 = x', ha='center',
            va='center', fontsize=11, color='#777777')
    ax.text(0.40, 0.22, 'transparent:\nx · 1 = x', ha='center',
            va='center', fontsize=11, color='#777777')
    ax.text(0.78, 0.70, 'ANNIHILATES:  x · 0 = 0', ha='center',
            fontsize=11, fontweight='bold', color='#d62728')
    for xs_ in (0.66, 0.72, 0.78, 0.84, 0.90):
        ax.annotate('', xy=(0.78, 0.545), xytext=(xs_, 0.64),
                    arrowprops=dict(arrowstyle='->', lw=1.1,
                                    color='#d62728', alpha=0.7))
        ax.plot(xs_, 0.64, 'o', ms=5, color='#d62728')
    ax.plot(0.78, 0.535, 'o', ms=9, color='#d62728')
    ax.text(0.78, 0.30, 'GENERATES:  1, 1+1, 1+1+1, ...', ha='center',
            fontsize=11, fontweight='bold', color='#2ca02c')
    ax.plot(0.62, 0.20, 'o', ms=9, color='#2ca02c')
    for k, xs_ in enumerate((0.70, 0.77, 0.84, 0.91)):
        ax.annotate('', xy=(xs_, 0.20), xytext=(xs_ - 0.055, 0.20),
                    arrowprops=dict(arrowstyle='->', lw=1.1,
                                    color='#2ca02c', alpha=0.8))
        ax.plot(xs_, 0.20, 'o', ms=5 + k, color='#2ca02c')
    ax.text(0.78, 0.11, 'builds the whole number line', ha='center',
            fontsize=9, color='#2ca02c')
    ax.plot([0.24, 0.24], [0.05, 0.82], color='#cccccc', lw=1)
    ax.plot([0.58, 0.58], [0.05, 0.82], color='#cccccc', lw=1)
    ax.plot([0.14, 0.97], [0.44, 0.44], color='#cccccc', lw=1)
    ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
    ax.set_title('The hospitality table: transparent at home,\n'
                 'mirror OPPOSITES abroad - destruction and genesis',
                 fontsize=12)

    # --- b: the two loci and the bridge -------------------------------
    ax = axes[1]
    for xc, col, main, sub in [
            (0.24, '#1f77b4', 'tr A = 0',
             "the additive nothing's locus\ntraceless matrices - "
             '$\\mathfrak{sl}_n$'),
            (0.76, '#d62728', 'det A = 1',
             "the multiplicative nothing's locus\nvolume-preserving - "
             '$SL_n$')]:
        ax.add_patch(FancyBboxPatch((xc - 0.185, 0.38), 0.37, 0.30,
                                    boxstyle='round,pad=0.02',
                                    fc='white', ec=col, lw=2.5))
        ax.text(xc, 0.60, main, ha='center', va='center', fontsize=15,
                fontweight='bold', color=col)
        ax.text(xc, 0.47, sub, ha='center', va='center', fontsize=9.5,
                color=col)
    ax.annotate('', xy=(0.555, 0.53), xytext=(0.445, 0.53),
                arrowprops=dict(arrowstyle='-|>', lw=3, color='#9467bd'))
    ax.text(0.5, 0.585, 'exp', ha='center', fontsize=13,
            fontweight='bold', color='#9467bd')
    ax.text(0.5, 0.30, 'det(exp A) = exp(tr A),  and  exp(0) = 1:\n'
            'traceless exponentiates EXACTLY to volume-preserving',
            ha='center', fontsize=10.5, color='#9467bd')
    ax.text(0.5, 0.12, f'numerical check: {samples} random traceless A, '
            f'n = 2..5\nmax |det(exp A) - 1| = {maxdev:.1e}',
            ha='center', fontsize=9.5, color='#555555')
    ax.text(0.5, 0.85, 'the expedition aimed here with a sentence -\n'
            'Lie theory was already standing, with a name on the door',
            ha='center', fontsize=9.5, color='#555555', style='italic')
    ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
    ax.set_title("Each nothing casts a locus - 'the trace of each\n"
                 "nothing's position' - and the capstone bridges them",
                 fontsize=12)

    # --- c: the coincidence 0 = 1 -------------------------------------
    ax = axes[2]
    ax.plot(0.30, 0.78, 'o', ms=13, color='#1f77b4')
    ax.text(0.30, 0.87, '0', ha='center', fontsize=15, fontweight='bold',
            color='#1f77b4')
    ax.plot(0.70, 0.78, 'o', ms=13, color='#d62728')
    ax.text(0.70, 0.87, '1', ha='center', fontsize=15, fontweight='bold',
            color='#d62728')
    for x0 in (0.30, 0.70):
        ax.annotate('', xy=(0.5, 0.45), xytext=(x0, 0.75),
                    arrowprops=dict(arrowstyle='-|>', lw=2, color='black'))
    ax.text(0.5, 0.66, 'suppose  0 = 1', ha='center', fontsize=12,
            fontweight='bold')
    ax.plot(0.5, 0.40, 'o', ms=16, color='black')
    ax.text(0.5, 0.325, 'then for every x:   x = x·1 = x·0 = 0',
            ha='center', fontsize=11)
    ax.text(0.5, 0.245, 'the ZERO RING: the unique ring with one element\n'
            'every x forced to zero - the whole arithmetic is one point\n'
            '(so in every nontrivial system, 0 and 1 are provably distinct)',
            ha='center', va='top', fontsize=10.5)
    ax.text(0.5, 0.05, 'the only place the two buildings meet\nis a world '
            'with a single point in it', ha='center', va='bottom',
            fontsize=10, style='italic', color='#555555')
    ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
    ax.set_title('The singularity - when the two nothings touch:\n'
                 'formally, the ZERO RING (one element, all of it)',
                 fontsize=12)

    fig.suptitle('The two nothings, complete: transparent at home, opposite '
                 'abroad - and one point where they meet', fontsize=14)
    plt.tight_layout(rect=[0, 0, 1, 0.92]); _finish('two_nothings_finale.png')


# =====================================================================
# DIRECTION 3 — DIMENSION of the map (the Jacobian conjecture)
# =====================================================================
def _fable_exprs(Z):
    u = 1 + Z[0]*Z[1]
    return [u**3*Z[2] + Z[1]**2*u*(4 + 3*Z[0]*Z[1]),
            Z[1] + 3*Z[0]*u**2*Z[2] + 3*Z[0]*Z[1]**2*(4 + 3*Z[0]*Z[1]),
            2*Z[0] - 3*Z[0]**2*Z[1] - Z[0]**3*Z[2]]


def fable_verify():
    """Symbolic check: det DF = -2 and the triple collision."""
    Z = sp.symbols('z1 z2 z3')
    F = sp.Matrix(_fable_exprs(Z))
    detJ = sp.expand(F.jacobian(Z).det())
    print('det DF =', detJ)
    for p in [(0, 0, sp.Rational(-1, 4)),
              (1, sp.Rational(-3, 2), sp.Rational(13, 2)),
              (-1, sp.Rational(3, 2), sp.Rational(13, 2))]:
        print(f'F{p} =', list(F.subs(dict(zip(Z, p))).T))


def fig_fable_wells(s_max=4.5, t_max=4.0, n=800, name='jacobian_wells.png'):
    """Three wells: the collision plane of the 2026 counterexample.

    s_max, t_max set the half-width of the plane window (zoom out by
    passing larger values), n the grid resolution per axis."""
    def F(z1, z2, z3):
        u = 1 + z1*z2
        return (u**3*z3 + z2**2*u*(4 + 3*z1*z2),
                z2 + 3*z1*u**2*z3 + 3*z1*z2**2*(4 + 3*z1*z2),
                2*z1 - 3*z1**2*z2 - z1**3*z3)
    target = np.array([-0.25, 0.0, 0.0])
    P = [np.array([0.0, 0.0, -0.25]), np.array([1.0, -1.5, 6.5]),
         np.array([-1.0, 1.5, 6.5])]
    origin = sum(P) / 3
    e1 = P[1] - P[0]; e1 /= np.linalg.norm(e1)
    v = P[2] - P[0]; e2 = v - (v @ e1) * e1; e2 /= np.linalg.norm(e2)
    s = np.linspace(-s_max, s_max, n); t = np.linspace(-t_max, t_max, n)
    S, T = np.meshgrid(s, t)
    X = origin[0] + S*e1[0] + T*e2[0]
    Y = origin[1] + S*e1[1] + T*e2[1]
    Zc = origin[2] + S*e1[2] + T*e2[2]
    F1, F2, F3 = F(X, Y, Zc)
    dist = np.sqrt((F1-target[0])**2 + (F2-target[1])**2 + (F3-target[2])**2)
    logd = np.log10(dist + 1e-12)
    fig, ax = plt.subplots(figsize=(9, 7))
    im = ax.pcolormesh(S, T, logd, cmap='inferno', shading='auto',
                       vmin=-2.5, vmax=np.percentile(logd, 99))
    for Pt in P:
        d = Pt - origin
        ax.plot(d @ e1, d @ e2, 'o', ms=11, mfc='none', mec='cyan', mew=2.2)
    ax.set_title('The 2026 counterexample: three points, one image\n'
                 'det DF = -2 everywhere - merging with ZERO local collapse')
    ax.set_xlabel('plane coordinate s'); ax.set_ylabel('plane coordinate t')
    fig.colorbar(im, ax=ax, label='log10 distance to target')
    plt.tight_layout(); _finish(name)


def _collide_search(f, trials, box, seed):
    """Core collision hunter: random targets, least-squares probes for a
    DIFFERENT preimage. An instrument — its finds are evidence, not proof."""
    from scipy.optimize import least_squares
    rng = np.random.default_rng(seed)
    found = []
    for _ in range(trials):
        xx = rng.uniform(-box, box, 3)
        fx = np.asarray(f(*xx), float)
        y0 = rng.uniform(-box, box, 3)
        try:
            sol = least_squares(lambda y: np.asarray(f(*y), float) - fx,
                                y0, method='lm', max_nfev=3000)
        except Exception:
            continue
        yy = sol.x
        if (np.linalg.norm(np.asarray(f(*yy), float) - fx) < 1e-8
                and np.linalg.norm(xx - yy) > 1e-2):
            found.append((xx, yy))
    return found


def fable_collide(trials=150, box=3.0, seed=7):
    """Numerically hunt for collision pairs of the Fable map."""
    Z = sp.symbols('z1 z2 z3')
    f = sp.lambdify(Z, _fable_exprs(Z), 'numpy')
    found = _collide_search(f, trials, box, seed)
    print(f'{len(found)} collision pairs found (of {trials} trials)')
    for xx, yy in found[:5]:
        print(' x =', np.round(xx, 3), '  y =', np.round(yy, 3))
    return found


def _fable_np(z1, z2, z3):
    """Vectorised numpy evaluation of the Fable map."""
    u = 1 + z1*z2
    return (u**3*z3 + z2**2*u*(4 + 3*z1*z2),
            z2 + 3*z1*u**2*z3 + 3*z1*z2**2*(4 + 3*z1*z2),
            2*z1 - 3*z1**2*z2 - z1**3*z3)


def _shear_polys(which):
    """The tame shears used as disguises: z2-shift p(z1), z3-shift
    q(z1, z2). All triangular with unit diagonal — Jacobian det 1."""
    if which == 1:
        return (lambda a: a**2), (lambda a, b: 2*a*b)
    if which == 2:
        return (lambda a: 1 - a - a**3), (lambda a, b: b*(3 + a**2))
    return (lambda a: a**3 - 2*a), (lambda a, b: b*(1 + 2*a))


def _disguised_np(which=1):
    """The Fable map conjugated by a tame unit-Jacobian shear:
    G = P^-1 o F o P. G is again a polynomial Keller map, and by the chain
    rule det DG = det DF = -2 EXACTLY (each shear factor has Jacobian
    determinant 1). We evaluate the composition directly — expanding it
    symbolically is possible but explosively large, and the numbers are
    identical either way."""
    p, q = _shear_polys(which)
    fwd = lambda z1, z2, z3: (z1, z2 + p(z1), z3 + q(z1, z2))
    inv = lambda w1, w2, w3: (w1, w2 - p(w1), w3 - q(w1, w2 - p(w1)))

    def G(z1, z2, z3):
        return inv(*_fable_np(*fwd(z1, z2, z3)))
    return G


def _check_shear_inverts(which, rng):
    """Verify P^-1(P(z)) = z at random points. (A finite-difference det of
    the CONJUGATED map is numerically hopeless — the chain-rule factors are
    huge and cancel to -2 only in exact arithmetic — so we check the piece
    that could actually be wrong: that the shear really inverts.)"""
    p, q = _shear_polys(which)
    z = rng.uniform(-3, 3, (5, 3))
    w = np.column_stack([z[:, 0], z[:, 1] + p(z[:, 0]),
                         z[:, 2] + q(z[:, 0], z[:, 1])])
    back = np.column_stack([w[:, 0], w[:, 1] - p(w[:, 0]),
                            w[:, 2] - q(w[:, 0], w[:, 1] - p(w[:, 0]))])
    return float(np.abs(back - z).max())


def fig_escape_analysis(trials=250, seed=11):
    """The full escape analysis (paper Figure: three panels).
    Left: the escape scale of collision pairs is crushed by a mere change of
    coordinates — raw fiber norm is NOT conjugation-invariant.
    Middle: separation vs escape — no merges near the origin.
    Right: a properness probe — where images of huge spheres land in target
    space. Bright ridges are the escape valve made visible. All evidence,
    never proof."""
    maps = [('original F', _fable_np),
            ('disguise $P_1^{-1} \\circ F \\circ P_1$', _disguised_np(1)),
            ('disguise $P_2^{-1} \\circ F \\circ P_2$', _disguised_np(2)),
            ('disguise $P_3^{-1} \\circ F \\circ P_3$', _disguised_np(3))]
    # det DG = -2 exactly, by the chain rule (unit-Jacobian shears); the
    # piece that could actually be wrong is the shear inversion — check it
    rng0 = np.random.default_rng(seed)
    for k in (1, 2, 3):
        err = _check_shear_inverts(k, rng0)
        print(f'disguise {k}: max |P^-1(P(z)) - z| = {err:.2e} at random '
              'points; det DG = -2 everywhere by the chain rule')

    fig, axes = plt.subplots(1, 3, figsize=(19, 6.2))

    ax = axes[0]
    all_pairs = {}
    for i, (name, f) in enumerate(maps):
        pairs = _collide_search(f, trials, 3.0, seed)
        all_pairs[name] = pairs
        if not pairs:
            print(f'{name}: no pairs found in {trials} trials')
            ax.text(i, 1.0, 'no pairs\nfound', ha='center', va='center',
                    fontsize=10, color='gray')
            continue
        esc = np.array([max(np.linalg.norm(x), np.linalg.norm(y))
                        for x, y in pairs])
        print(f'{name}: {len(pairs)} pairs, escape scale '
              f'median {np.median(esc):.4g}, max {esc.max():.4g}')
        jit = np.random.default_rng(seed + i).uniform(-0.14, 0.14, len(esc))
        ax.scatter(np.full(len(esc), i) + jit, esc, s=30, alpha=0.75,
                   color=['#d62728', '#1f77b4', '#9467bd', '#2ca02c'][i])
        ax.plot([i - 0.22, i + 0.22], [np.median(esc)]*2, 'k-', lw=2.5)
    ax.set_yscale('log')
    ax.set_xticks(range(len(maps)))
    ax.set_xticklabels([m[0] for m in maps], fontsize=8)
    ax.set_ylabel('escape scale max(|x|, |y|) of found pairs')
    ax.set_title('Same map, new coordinates: the escape scale\n'
                 'is crushed - raw fiber norm is not the invariant',
                 fontsize=11)
    ax.grid(alpha=0.3, which='both', axis='y')

    ax = axes[1]
    pairs = all_pairs['original F']
    esc = np.array([max(np.linalg.norm(x), np.linalg.norm(y))
                    for x, y in pairs])
    sep = np.array([np.linalg.norm(x - y) for x, y in pairs])
    ax.scatter(sep, esc, s=35, color='#d62728', alpha=0.8)
    ax.set_xscale('log'); ax.set_yscale('log')
    ax.set_xlabel('separation |x - y|'); ax.set_ylabel('escape max(|x|, |y|)')
    ax.set_title('Borrowing room at infinity:\nno merges happen near the origin',
                 fontsize=11)
    ax.grid(alpha=0.3, which='both')

    ax = axes[2]
    rng = np.random.default_rng(seed)
    W = 30.0
    edges = np.linspace(-W, W, 241)
    H = np.zeros((240, 240))
    for R in (25, 50, 100, 200):
        pts = rng.normal(size=(600_000, 3))
        pts *= R / np.linalg.norm(pts, axis=1, keepdims=True)
        F1, F2, F3 = _fable_np(pts[:, 0], pts[:, 1], pts[:, 2])
        m = (np.abs(F1) < W) & (np.abs(F2) < W) & (np.abs(F3) < W)
        h, _, _ = np.histogram2d(F1[m], F2[m], bins=(edges, edges))
        H += h
    im = ax.pcolormesh(edges, edges, np.log10(H.T + 1), cmap='inferno',
                       shading='auto')
    ax.set_xlabel('F1'); ax.set_ylabel('F2')
    ax.set_title('Properness probe: where images of spheres\n'
                 '|z| = 25..200 still land at finite distance', fontsize=11)
    fig.colorbar(im, ax=ax, label='log10(1 + hits)')

    fig.suptitle('The escape analysis: non-properness is the valve - and the '
                 'right invariant is still an open hunt', fontsize=14)
    plt.tight_layout(rect=[0, 0, 1, 0.92]); _finish('escape_analysis.png')


def fig_escape(trials=150):
    """Escape-scale scatter: merging partners sit far out (non-properness)."""
    pairs = fable_collide(trials=trials)
    esc = np.array([max(np.linalg.norm(x), np.linalg.norm(y)) for x, y in pairs])
    sep = np.array([np.linalg.norm(x - y) for x, y in pairs])
    fig, ax = plt.subplots(figsize=(8, 6.5))
    ax.scatter(sep, esc, s=35, color='#d62728', alpha=0.8)
    ax.set_xscale('log'); ax.set_yscale('log')
    ax.set_xlabel('separation |x - y|'); ax.set_ylabel('escape max(|x|, |y|)')
    ax.set_title('Borrowing room at infinity:\n'
                 'no merges happen near the origin - partners live far out')
    ax.grid(alpha=0.3, which='both')
    plt.tight_layout(); _finish('escape.png')


def fig_information_ladder():
    """The +C lens: the ladder map F -> DF -> det DF, with what each step
    discards. Differentiation forgets exactly one additive constant
    (DF = D(F + c)); the determinant forgets vastly more. Registered under
    Direction 3 because its punchline is the conjecture itself: does the
    Keller condition climb back up? From dimension three, it does not."""
    from matplotlib.patches import FancyBboxPatch
    # the +C fact, verified symbolically rather than asserted
    x, y, c1, c2 = sp.symbols('x y c1 c2')
    F = sp.Matrix([x*y - x**3, y + x**2])
    Fc = F + sp.Matrix([c1, c2])
    diff = sp.simplify(F.jacobian([x, y]) - Fc.jacobian([x, y]))
    print('sympy check: D(F + c) - DF =', diff.tolist(),
          'for F =', list(F.T), 'and symbolic constants c = (c1, c2)')

    fig, ax = plt.subplots(figsize=(12, 9))
    boxes = [
        (0.82, '#1f77b4', 'the MAP   F : C^n -> C^n',
         'all the information there is'),
        (0.50, '#2ca02c', 'the JACOBIAN MATRIX FIELD   DF',
         'a matrix at every point'),
        (0.18, '#9467bd', 'the DETERMINANT FIELD   det DF',
         'one number at every point'),
    ]
    for yc, col, main, sub in boxes:
        ax.add_patch(FancyBboxPatch((0.24, yc - 0.075), 0.50, 0.15,
                                    boxstyle='round,pad=0.015',
                                    fc='white', ec=col, lw=2.5))
        ax.text(0.49, yc + 0.022, main, ha='center', va='center',
                fontsize=13, color=col, fontweight='bold')
        ax.text(0.49, yc - 0.038, sub, ha='center', va='center',
                fontsize=9.5, color=col)
    for y0, y1, disc in [
            (0.735, 0.585, 'discarded: exactly ONE additive\nconstant, '
             'DF = D(F + c) -\ndifferentiation\'s "+C",\na single global datum'),
            (0.415, 0.265, 'discarded: VASTLY more -\nmany matrix fields\n'
             'share one determinant field')]:
        ax.annotate('', xy=(0.40, y1), xytext=(0.40, y0),
                    arrowprops=dict(arrowstyle='-|>', lw=3, color='#d62728'))
        ax.text(0.36, (y0 + y1) / 2, disc, ha='right', va='center',
                fontsize=9.5, color='#d62728')
    for y0, y1, lab, col in [
            (0.585, 0.735, 'climb back: integrate,\nrecover F up to +c',
             '#2ca02c'),
            (0.265, 0.415, 'climb back? the Keller condition\n'
             'det DF = const != 0 - the conjecture\nasked whether this is enough',
             '#555555')]:
        ax.annotate('', xy=(0.58, y1), xytext=(0.58, y0),
                    arrowprops=dict(arrowstyle='-|>', lw=2, color=col,
                                    linestyle=(0, (4, 3))))
        ax.text(0.62, (y0 + y1) / 2, lab, ha='left', va='center',
                fontsize=9.5, color=col)
    ax.text(0.49, 0.02, 'the Jacobian conjecture asks whether the Keller '
            'condition is strong enough to climb back up -\n'
            'from dimension three, it is not',
            ha='center', va='bottom', fontsize=11, color='black')
    ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
    ax.set_title('The information ladder: each step down forgets\n'
                 'differentiation forgets +C; the determinant forgets '
                 'almost everything', fontsize=14, pad=10)
    plt.tight_layout(); _finish('information_ladder.png')


# =====================================================================
# DIRECTION 4 — DEGREE of the map
# =====================================================================
def fig_degree_axis():
    """Milestone diagram of the degree axis: Laurent territory, the shuffle
    born at degree 3, the Fable map at 7, the 2D verification frontier at 100."""
    from matplotlib.patches import FancyArrowPatch
    fig, ax = plt.subplots(figsize=(15, 6.5))
    xs = {-2: 0.0, -1: 1.0, 0: 2.0, 1: 3.0, 2: 4.2, 3: 5.6, 7: 7.6, 100: 9.8}
    names = {-2: r'$x^{-2}$', -1: r'$x^{-1}$', 0: r'$1$', 1: r'$x$',
             2: r'$x^{2}$', 3: r'$x^{3}$', 7: r'$x^{7}$', 100: r'$x^{100}$'}
    ax.annotate('', xy=(10.6, 0), xytext=(-0.8, 0),
                arrowprops=dict(arrowstyle='-|>', lw=3, color='#9467bd'))
    for bx in (6.6, 8.7):     # the axis is NOT to scale — mark the jumps
        ax.plot([bx-0.07, bx+0.07], [-0.09, 0.09], color='white', lw=7, zorder=3)
        ax.plot([bx-0.12, bx+0.02], [-0.1, 0.1], color='#9467bd', lw=2, zorder=4)
        ax.plot([bx-0.02, bx+0.12], [-0.1, 0.1], color='#9467bd', lw=2, zorder=4)
    ax.axvspan(-0.8, 2.5, color='gray', alpha=0.13, zorder=0)
    ax.text(0.85, -0.62, 'LAURENT TERRITORY\nnegative powers - already met in '
            "Tao's\nparametrization: Laurent in a,\npolynomial in b and c",
            ha='center', fontsize=9.5, color='#555555')
    for d, x in xs.items():
        ax.plot(x, 0, 'o', ms=7, color='#9467bd', zorder=5)
        ax.text(x, -0.17, names[d], ha='center', va='top', fontsize=14)
    marks = [
        (2, 0.40, '#1f77b4', 'o', 'degree 2: NO shuffle\na quadratic splits one '
         'way once\nscalings are fixed - nothing to permute'),
        (3, 0.82, '#9467bd', 'D', 'degree 3: the shuffle is BORN\nthree roots '
         'can be permuted - the true\nboundary behind the Jacobian conjecture'),
        (7, 0.40, '#d62728', '*', 'degree 7: the Fable map\n(the 2026 '
         'counterexample\nin three variables)'),
        (100, 0.82, '#1f77b4', 's', 'degree 100: the 2D conjecture\nis verified '
         'up to here - and still open'),
    ]
    for d, ty, col, mk, txt in marks:
        x = xs[d]
        ax.plot(x, 0, mk, ms=17 if mk == '*' else 11, color=col, zorder=6)
        ax.plot([x, x], [0.10, ty - 0.04], color=col, lw=1, ls=':')
        ax.text(x, ty, txt, ha='center', va='bottom', fontsize=9.5, color=col)
    ax.add_patch(FancyArrowPatch((9.8, -0.5), (xs[3] + 0.08, -0.1),
                                 connectionstyle='arc3,rad=-0.25',
                                 arrowstyle='-|>', mutation_scale=16,
                                 color='#2ca02c', lw=2))
    ax.text(7.9, -0.72, 'Bass-Connell-Wright: EVERY Keller map, any degree,\n'
            'reduces to degree 3 (paying with more variables)',
            ha='center', fontsize=9.5, color='#2ca02c')
    # honesty marker: 7 is where THIS construction landed, not a threshold
    ax.annotate('', xy=(xs[7] - 0.12, 0.20), xytext=(xs[3] + 0.12, 0.20),
                arrowprops=dict(arrowstyle='<->', lw=1.4, color='#555555'))
    ax.text((xs[3] + xs[7]) / 2, 0.24,
            'minimal counterexample degree in C^3: OPEN, boxed 3..7\n'
            '(a cubic one exists - but in C^19, via the BCW push)',
            ha='center', va='bottom', fontsize=8.5, color='#555555')
    ax.set_xlim(-1.1, 11.0); ax.set_ylim(-1.05, 1.15); ax.axis('off')
    ax.set_title('DIRECTION 4 - DEGREE: the axis that was secretly running the show\n'
                 'no shuffle at degree 2, shuffle born at degree 3, counterexample '
                 'at degree 7, 2D verified to degree 100  (axis not to scale)',
                 fontsize=14, pad=12)
    plt.tight_layout(); _finish('degree_axis.png')


def fig_cross_compare():
    """The same letters-vs-paths pattern probed along two directions:
    SIZE of the matrix, and DEGREE of the map (at n = 2 and n = 3).
    Crossings, quotient trend-breaks, and the degeneracy ladder on each
    axis. Heuristic counting only: the Fable map exists DESPITE
    1329 > 360 — crossings locate rarity, not impossibility."""
    from scipy.special import gamma, comb
    from scipy.optimize import brentq

    def C(a, b):  # continuous binomial via Gamma
        return gamma(a + 1) / (gamma(b + 1) * gamma(a - b + 1))

    # ---- exact crossings ------------------------------------------------
    x_size = brentq(lambda x: gamma(x + 1)/2 - x**2, 3.5, 5.0)
    f2 = lambda d: (C(2*d, 2) - 1) - 2*C(d + 2, 2)   # n=2: constraints - freedom
    f3 = lambda d: (C(3*d, 3) - 1) - 3*C(d + 3, 3)
    x_d2 = brentq(f2, 3.0, 6.0)
    x_d3 = brentq(f3, 2.0, 3.0)
    print(f'SIZE crossing:          n  = {x_size:.4f}   '
          '(paths n!/2 overtake letters n^2)')
    print(f'DEGREE crossing (n=2):  d  = {x_d2:.4f}   '
          f'(= 2 + sqrt(7) = {2 + np.sqrt(7):.4f})')
    print(f'DEGREE crossing (n=3):  d  = {x_d3:.4f}')
    print(f'sanity, Tao numbers at n=3, d=7: freedom {3*int(comb(10, 3))}, '
          f'constraints {int(comb(21, 3)) - 1}')

    fig, axes = plt.subplots(2, 3, figsize=(19, 10),
                             gridspec_kw={'height_ratios': [2.4, 1]})

    def panel(ax, axq, xs, free, cons, xstar, title, xlabel,
              marks, free_lab, cons_lab):
        ax.semilogy(xs, free, lw=2.5, color='#1f77b4', label=free_lab)
        ax.semilogy(xs, cons, lw=2.5, color='#d62728', label=cons_lab)
        ax.axvline(xstar, color='gray', ls=':', lw=1.5)
        ax.plot(xstar, np.interp(xstar, xs, free), 'k*', ms=16, zorder=5)
        ax.annotate(f'crossing\n{xstar:.3f}',
                    (xstar, np.interp(xstar, xs, free)),
                    textcoords='offset points', xytext=(8, -34),
                    fontweight='bold')
        for xm, lab, col in marks:
            ax.axvline(xm, color=col, ls='--', lw=1.2, alpha=0.7)
            ax.text(xm, 0.98, lab, transform=ax.get_xaxis_transform(),
                    fontsize=8.5, color=col, ha='center', va='top')
        ax.set_title(title, fontsize=11)
        ax.set_ylabel('count (log)')
        ax.legend(fontsize=9, loc='lower right')
        ax.grid(alpha=0.3, which='both')
        # quotient panel: the trend-break
        axq.plot(xs, cons / free, lw=2.2, color='#9467bd')
        axq.axhline(1, color='gray', lw=1)
        axq.axvline(xstar, color='gray', ls=':', lw=1.5)
        axq.set_yscale('log')
        axq.set_xlabel(xlabel); axq.set_ylabel('quotient')
        axq.set_title('constraints / freedom - crosses 1 at the break',
                      fontsize=10)
        axq.grid(alpha=0.3, which='both')

    # Panel A: SIZE direction
    xs = np.linspace(0.05, 7, 500)
    panel(axes[0, 0], axes[1, 0], xs, xs**2, gamma(xs + 1)/2, x_size,
          'SIZE direction: letters $n^2$ vs positive paths $n!/2$\n'
          'vacuum det(0x0) = 1;  point n = 1: letters = paths = 1',
          'n (continuous)',
          [(0, 'vacuum\ndet=1', '#2ca02c'), (1, 'point\nn=1', '#2ca02c')],
          'letters $n^2$', 'paths $n!/2$')

    # Panel B: DEGREE direction at n = 2
    ds = np.linspace(1.0, 8, 500)
    panel(axes[0, 1], axes[1, 1], ds, 2*C(ds + 2, 2), C(2*ds, 2) - 1, x_d2,
          'DEGREE direction, n = 2: freedom vs Keller constraints\n'
          'd=0: det$\\equiv$0 forbidden;  d=1: auto-constant;  open to d=100',
          'degree d',
          [(1, 'linear:\nauto-Keller', '#2ca02c')],
          'freedom $2\\binom{d+2}{2}$', 'constraints $\\binom{2d}{2}-1$')

    # Panel C: DEGREE direction at n = 3
    ds3 = np.linspace(1.0, 8, 500)
    panel(axes[0, 2], axes[1, 2], ds3, 3*C(ds3 + 3, 3), C(3*ds3, 3) - 1, x_d3,
          'DEGREE direction, n = 3: freedom vs constraints\n'
          'crossing between Wang (d=2 true) and shuffle birth (d=3)',
          'degree d',
          [(2, 'Wang:\nd=2 TRUE', '#2ca02c'),
           (3, 'shuffle born\nBCW target', '#d62728'),
           (7, 'Fable map\n360 vs 1329', 'black')],
          'freedom $3\\binom{d+3}{3}$', 'constraints $\\binom{3d}{3}-1$')

    fig.suptitle('One pattern, probed along two directions: where counting '
                 'flips from underdetermined to overdetermined\n'
                 '(heuristic counting only - the Fable map exists DESPITE '
                 '1329 > 360; crossings locate rarity, not impossibility)',
                 fontsize=13, y=1.00)
    plt.tight_layout(rect=[0, 0, 1, 0.94]); _finish('cross_compare.png')


def fig_dstar_curve():
    """The counting flip d*(n) as one curve: where Keller constraints
    overtake coefficient freedom, as a function of dimension n. Asymptotes
    to the degenerate floor d = 1; crosses Wang's line d = 2 at n ~ 3.36 —
    the built-in guardrail (counting calls quadratics overdetermined where
    Wang proves them safe forever). The 4.273 size crossing is off-family."""
    from scipy.special import gamma
    from scipy.optimize import brentq

    def C(a, b):
        return gamma(a + 1) / (gamma(b + 1) * gamma(a - b + 1))

    def dstar(n):
        f = lambda d: (C(n*d, n) - 1) - n*C(d + n, n)
        return brentq(f, 1.0001, 12)

    ns = np.arange(2, 15)
    ds = np.array([dstar(n) for n in ns])
    for n, d in zip(ns, ds):
        print(f'n={n:2d}  d* = {d:.4f}')

    fig, ax = plt.subplots(figsize=(10.5, 6.5))
    nn = np.linspace(2, 14, 300)
    dd = [dstar(x) for x in nn]
    ax.plot(nn, dd, lw=2.5, color='#9467bd',
            label='d*(n): counting flip (constraints overtake freedom)')
    ax.plot(ns, ds, 'o', ms=7, color='#9467bd')
    ax.axhline(1, color='gray', ls='--', lw=1.5)
    ax.text(13.9, 0.62, 'd = 1: linear maps, auto-Keller\n'
            '(the degenerate floor - asymptote)',
            ha='right', va='bottom', fontsize=9, color='gray')
    ax.axhline(2, color='#2ca02c', ls=':', lw=1.5)
    ax.text(13.9, 2.06, "d = 2: Wang's theorem - conjecture TRUE here "
            'in every n', ha='right', fontsize=9, color='#2ca02c')
    ax.annotate(f'n=2: d* = 2+√7 ≈ {ds[0]:.3f}', (2, ds[0]),
                textcoords='offset points', xytext=(12, 8),
                fontsize=10, fontweight='bold')
    ax.annotate(f'n=3: d* ≈ {ds[1]:.3f}\n(the bracket: between Wang '
                'and the shuffle)', (3, ds[1]),
                textcoords='offset points', xytext=(14, 6),
                fontsize=10, fontweight='bold')
    ncross = brentq(lambda x: dstar(x) - 2, 2.5, 5)
    print(f'd*(n) crosses the Wang line d = 2 at n = {ncross:.4f}')
    ax.plot(ncross, 2, 'k*', ms=15, zorder=5)
    ax.annotate(f'curve crosses the Wang line at n ≈ {ncross:.3f}:\n'
                'beyond this, counting calls even quadratics\n'
                'overdetermined - yet Wang PROVES d=2 safe forever.\n'
                'Counting locates rarity, not truth.',
                (ncross, 2), textcoords='offset points', xytext=(95, 62),
                fontsize=9,
                arrowprops=dict(arrowstyle='->', lw=0.9, color='gray'))
    ax.scatter([4.273], [4.6], marker='*', s=180, color='#1f77b4', zorder=5)
    ax.annotate('the SIZE crossing 4.273 - an isolated point from a\n'
                'DIFFERENT family (drawability), NOT on this curve',
                (4.273, 4.6), textcoords='offset points', xytext=(14, -22),
                fontsize=9, color='#1f77b4')
    ax.set_xlabel('dimension n'); ax.set_ylabel('degree d')
    ax.set_title('The collapsing threshold as its own object: d*(n) '
                 'asymptotes to the degenerate floor\nas dimension grows, '
                 'the underdetermined window is squeezed onto the linear rung')
    ax.set_ylim(0.5, 5.2); ax.legend(loc='upper right', fontsize=9)
    ax.grid(alpha=0.3)
    plt.tight_layout(); _finish('dstar_asymptote.png')


# =====================================================================
# MENU
# =====================================================================
_CATALOG = {
    '0': ('Map of the journey (index figure)', [
        ('1', 'The five-arrow compass (probe, size, richness, degree, map dim)',
         fig_index_map),
        ('2', 'Hidden hypotheses: commutativity & properness (rhyme, not theorem)',
         fig_hidden_hypothesis),
        ('3', 'Where the ladder goes - the Paper II teaser', fig_ladder_teaser)]),
    '1': ('Direction 1 - SIZE of the matrix', [
        ('1', 'Formula reference card (n = 1, 2, 3)', fig_formulas),
        ('2', 'Geometry + simple sequences (length/area/volume)', fig_geometry),
        ('3', 'Permutation matrices, binary matrices, non-square trap',
         fig_permutations),
        ('4', 'Sarrus paths n = 2, 3, 4 (diagonals break)', fig_sarrus),
        ('5', 'Rainbow n = 5, all 60 paths, port fanning', fig_rainbow_ports),
        ('6', 'Rainbow n = 5, a- and c-families with docking counts',
         lambda: fig_rainbow_ports(5, keep_columns=(0, 2), show_counts=True)),
        ('7', 'Growth: letters vs paths, n = 1..9', fig_growth),
        ('8', 'Exact crossings via Gamma (x = 0.672, 4.273)', fig_crossing)]),
    '2': ('Direction 2 - RICHNESS of the entries', [
        ('1', 'det as a FIELD: Whitney cusp, folds on det = 0', fig_det_field),
        ('2', 'Matrix-of-matrix: the commutativity fork (det needs it, trace does not)',
         fig_block_break),
        ('3', 'The two vacuums: 0 vs 1, trace vs det, det(exp A) = exp(tr A)',
         fig_two_vacuums),
        ('4', 'The two nothings, complete: hospitality, loci, zero ring',
         fig_two_nothings_finale)]),
    '3': ('Direction 3 - DIMENSION of the map (Jacobian conjecture)', [
        ('1', 'Verify the 2026 counterexample symbolically', fable_verify),
        ('2', 'Three wells on the collision plane', fig_fable_wells),
        ('3', 'Hunt collision pairs numerically', fable_collide),
        ('4', 'Escape scatter (non-properness signature)', fig_escape),
        ('5', 'Full escape analysis: disguises + properness probe',
         fig_escape_analysis),
        ('6', 'The information ladder: F -> DF -> det DF (the +C lens)',
         fig_information_ladder)]),
    '4': ('Direction 4 - DEGREE of the map', [
        ('1', 'The degree axis: Laurent to x^100, shuffle born at 3',
         fig_degree_axis),
        ('2', 'Freedom vs constraints: crossings along SIZE and DEGREE',
         fig_cross_compare),
        ('3', 'The curve d*(n): counting flip vs dimension, Wang guardrail',
         fig_dstar_curve)]),
}


def menu():
    """Interactive numeric menu. Type numbers, q to quit, b to go back."""
    while True:
        print('\n=== THE DETERMINANT LADDER ===')
        for key, (name, _) in sorted(_CATALOG.items()):
            print(f'  {key}: {name}')
        choice = input('direction (q quits): ').strip().lower()
        if choice in ('q', 'quit', 'exit'):
            return
        if choice not in _CATALOG:
            print('unknown option'); continue
        name, figs = _CATALOG[choice]
        while True:
            print(f'\n--- {name} ---')
            for k, label, _ in figs:
                print(f'  {k}: {label}')
            sub = input('figure (b back, q quits): ').strip().lower()
            if sub in ('q', 'quit'):
                return
            if sub in ('b', 'back'):
                break
            match = [fn for k, lbl, fn in figs if k == sub]
            if not match:
                print('unknown option'); continue
            try:
                match[0]()
            except Exception as exc:
                print(f'error while drawing: {exc}')


if __name__ == '__main__':
    menu()