codex/square-symmetry-orbits.py

square-symmetry-orbits.py
"""Verify D4 using exact coordinate permutations, then draw all eight symmetries.

Run from _media to write square-symmetry-orbits.png there. Tested with Python
3.14, matplotlib 3.10.7 and numpy 2.3.5 as specified by the root pyproject.toml.
"""

from itertools import permutations, product
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np


COORDS = ((1, 1), (-1, 1), (-1, -1), (1, -1))
LETTERS = "ABCD"


def compose(g, h):
    """Tuples record vertex images; apply h first, then g."""
    return tuple(g[h[i]] for i in range(4))


def square_model():
    """All signed coordinate permutations preserving the square, exactly."""
    coordinate_maps = {}
    for axes in permutations(range(2)):
        for signs in product((-1, 1), repeat=2):
            images = tuple(
                tuple(signs[j] * point[axes[j]] for j in range(2))
                for point in COORDS
            )
            permutation = tuple(COORDS.index(point) for point in images)
            coordinate_maps[permutation] = (axes, signs)
    identity = tuple(range(4))
    r = tuple(COORDS.index((-y, x)) for x, y in COORDS)
    s = tuple(COORDS.index((y, x)) for x, y in COORDS)
    powers = [identity]
    for _ in range(3):
        powers.append(compose(r, powers[-1]))
    labels = ("e", "r", "r²", "r³", "s", "rs", "r²s", "r³s")
    group = powers + [compose(p, s) for p in powers]
    assert len(coordinate_maps) == len(set(group)) == 8
    assert set(group) == set(coordinate_maps)
    actions = {
        "Vertices": (tuple(range(4)), lambda g, x: g[x]),
        "Edges": (
            tuple(frozenset((i, (i + 1) % 4)) for i in range(4)),
            lambda g, x: frozenset(g[i] for i in x),
        ),
        "Diagonals": (
            (frozenset((0, 2)), frozenset((1, 3))),
            lambda g, x: frozenset(g[i] for i in x),
        ),
        "Center": (
            ((0, 0),),
            lambda g, x: tuple(
                coordinate_maps[g][1][j] * x[coordinate_maps[g][0][j]]
                for j in range(2)
            ),
        ),
    }
    return group, dict(zip(group, labels)), actions


def verify_square():
    group, labels, actions = square_model()
    identity, r, s = group[0], group[1], group[4]
    assert compose(r, group[3]) == identity
    assert compose(s, s) == identity
    assert compose(compose(s, r), s) == group[3]
    for g in group:
        assert set(g) == set(range(4))
        assert compose(g, identity) == compose(identity, g) == g
        inverse = tuple(g.index(i) for i in range(4))
        assert inverse in group
        assert compose(g, inverse) == compose(inverse, g) == identity
        for h in group:
            assert compose(g, h) in group
            for k in group:
                assert compose(compose(g, h), k) == compose(g, compose(h, k))
    expected_sizes = {"Vertices": (4, 2), "Edges": (4, 2),
                      "Diagonals": (2, 4), "Center": (1, 8)}
    expected_stabilizers = {
        "Vertices": {"e", "s"}, "Edges": {"e", "rs"},
        "Diagonals": {"e", "r²", "s", "r²s"}, "Center": set(labels.values()),
    }
    cases = 0
    for name, (objects, act) in actions.items():
        for x in objects:
            assert act(identity, x) == x
            orbit = {act(g, x) for g in group}
            stabilizer = {g for g in group if act(g, x) == x}
            assert orbit == set(objects)
            assert (len(orbit), len(stabilizer)) == expected_sizes[name]
            assert len(orbit) * len(stabilizer) == len(group)
            for g in stabilizer:
                assert tuple(g.index(i) for i in range(4)) in stabilizer
                for h in stabilizer:
                    assert compose(g, h) in stabilizer
            for g, h in product(group, repeat=2):
                assert act(g, x) in objects
                assert act(compose(g, h), x) == act(g, act(h, x))
                cases += 1
        stabilizer = {labels[g] for g in group if act(g, objects[0]) == objects[0]}
        assert stabilizer == expected_stabilizers[name]
    print("D4: 8 signed coordinate permutations; 64 closure and 512 associativity cases.")
    print(f"All identities/inverses, {cases} action-law cases and 11 orbit/stabilizer products checked.")
    print("Vertices 4×2; edges 4×2; diagonals 2×4; center 1×8; every product equals 8.")
    return group, labels, actions


def main():
    group, labels, _ = verify_square()
    fig, axes = plt.subplots(2, 4, figsize=(10, 6.3), dpi=120, facecolor="white")
    outline = np.array(COORDS + (COORDS[0],))
    colors = ("#126b91", "#44833f", "#8b58a2", "#bc6721")
    for row in range(2):
        for column in range(4):
            ax = axes[row, column]
            g = group[column + 4 * row]
            color = colors[column]
            ax.plot(outline[:, 0], outline[:, 1], color="#697780", lw=1.6)
            ax.scatter(outline[:4, 0], outline[:4, 1], s=25, color="#697780")
            for original, image in enumerate(g):
                x, y = COORDS[image]
                ax.text(1.25 * x, 1.25 * y, LETTERS[original], fontsize=13,
                        ha="center", va="center",
                        color=color if original == 0 else "#27343d",
                        fontweight="bold" if original == 0 else "normal")
            target = COORDS[g[0]]
            ax.scatter(*target, s=190, facecolor="white", edgecolor=color,
                       linewidth=2.5, zorder=3)
            ax.scatter(*target, s=32, color=color, zorder=4)
            ax.set_title(labels[g], fontsize=16, pad=9, color=color)
            ax.set(xlim=(-1.55, 1.55), ylim=(-1.55, 1.55), aspect="equal")
            ax.axis("off")
            if row == 0:
                ax.text(0.5, 1.29, f"A goes to {LETTERS[column]}",
                        transform=ax.transAxes, ha="center", fontsize=13,
                        color=color, fontweight="bold")
    fig.suptitle("Eight square symmetries, four images of A", fontsize=19, y=0.97)
    fig.text(0.5, 0.055, "Each column: two transformations give the same image of A.\n"
             "Letters travel with their original vertices; the ring marks A's image.",
             ha="center", va="center", fontsize=12, color="#27343d")
    fig.subplots_adjust(left=0.04, right=0.96, bottom=0.14, top=0.77,
                        wspace=0.2, hspace=0.45)
    output = Path.cwd() / "square-symmetry-orbits.png"
    fig.savefig(output, facecolor="white", transparent=False)
    plt.close(fig)
    print(f"Wrote {output.name}: 1200×756 pixels, opaque white background.")


if __name__ == "__main__":
    main()