codex/graph-two-core-leaf-pruning-trace.py

graph-two-core-leaf-pruning-trace.py
"""Draw a complete queue-based 2-core trace. Python 3.14; matplotlib 3.10.7.

Run from the desired output directory; emits this script's basename.png there.
"""
from collections import deque
from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import Circle

VERTICES = tuple('abcdefgz')
EDGES = ('ab', 'bc', 'ca', 'cd', 'de', 'ef', 'dg')
POSITIONS = {'a': (-.7, .15), 'b': (-.7, 1.5), 'c': (.55, .82),
             'd': (1.85, .82), 'e': (3.15, .82), 'f': (4.45, .82),
             'g': (1.85, 2.12), 'z': (4.45, 2.12)}


def compute_trace():
    adjacency = {v: [] for v in VERTICES}
    for u, v in EDGES:
        adjacency[u].append(v)
        adjacency[v].append(u)
    degrees = {v: len(adjacency[v]) for v in VERTICES}
    alive = set(VERTICES)
    queue = deque(v for v in VERTICES if degrees[v] < 2)
    trace = [(None, alive.copy(), dict(degrees), tuple(queue))]
    while queue:
        v = queue.popleft()
        alive.remove(v)
        for u in adjacency[v]:
            if u in alive:
                degrees[u] -= 1
                if degrees[u] == 1:
                    queue.append(u)
        trace.append((v, alive.copy(), {u: degrees[u] for u in alive}, tuple(queue)))
    return trace


def main():
    trace = compute_trace()
    fig, axes = plt.subplots(2, 3, figsize=(11.7, 7.2), dpi=100, facecolor='white')
    fig.suptitle('A triangle survives; its entire tail and isolated vertex disappear',
                 fontsize=16, y=.97, weight='bold')
    for step, (ax, (deleted, alive, degrees, queue)) in enumerate(zip(axes.flat, trace)):
        for u, v in EDGES:
            active = u in alive and v in alive
            ax.plot([POSITIONS[u][0], POSITIONS[v][0]],
                    [POSITIONS[u][1], POSITIONS[v][1]],
                    color='#3a8795' if active else '#e0e3e5',
                    lw=2.7 if active else 1.2, zorder=1)
        for v, (x, y) in POSITIONS.items():
            active = v in alive
            fill = '#d7eef0' if active and v in 'abc' else '#fff0d4' if active else 'white'
            color = '#174d59' if active else '#ced3d6'
            ax.add_patch(Circle((x, y), .23, facecolor=fill, edgecolor=color, lw=1.5, zorder=2))
            ax.text(x, y, v, ha='center', va='center', color=color, fontsize=13, zorder=3)
            if active:
                ax.text(x, y-.43, f'deg {degrees[v]}', ha='center', va='center', fontsize=10,
                        color='#353d44')
        title = '0. Original graph' if deleted is None else f'{step}. Delete {deleted}'
        ax.set_title(title, loc='left', fontsize=13, weight='bold', pad=8)
        qtext = ', '.join(queue) if queue else 'empty'
        ax.text(.02, -.02, f'Queue: {qtext}', transform=ax.transAxes, fontsize=12,
                color='#a24e13' if queue else '#174d59')
        if not queue:
            ax.text(.02, -.17, 'Remaining: triangle abc', transform=ax.transAxes,
                    fontsize=11, color='#174d59', weight='bold')
        ax.set(xlim=(-1.3, 5.05), ylim=(-.6, 2.75), aspect='equal')
        ax.axis('off')
    fig.text(.5, .025, 'Blue: surviving triangle   |   Amber: surviving tail / isolate   |   Pale: deleted   |   Degrees are current',
             ha='center', fontsize=11, color='#353d44')
    fig.subplots_adjust(left=.035, right=.98, bottom=.12, top=.88, wspace=.15, hspace=.45)
    fig.savefig(Path.cwd() / (Path(__file__).stem + '.png'), facecolor='white', dpi=100)
    plt.close(fig)


if __name__ == '__main__':
    main()