codex/orbit-stabilizer-cosets.py

orbit-stabilizer-cosets.py
"""Check coset/orbit bijections and S3/C4 examples; draw the square counts.

Run from _media to write orbit-stabilizer-cosets.png there. Loads the exact
coordinate model from the adjacent square-symmetry-orbits.py. Tested with
Python 3.14, matplotlib 3.10.7 and numpy 2.3.5 from the root pyproject.toml.
"""

from itertools import permutations, product
from pathlib import Path
import runpy

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch


def verify_cosets(group, actions, compose):
    count = 0
    for objects, act in actions.values():
        for x in objects:
            stabilizer = {g for g in group if act(g, x) == x}
            parts = {frozenset(compose(g, h) for h in stabilizer) for g in group}
            assert set().union(*parts) == set(group)
            assert all(len(part) == len(stabilizer) for part in parts)
            assert len(parts) * len(stabilizer) == len(group)
            images = {}
            for part in parts:
                targets = {act(g, x) for g in part}
                assert len(targets) == 1  # Independent of representative.
                target = next(iter(targets))
                assert target not in images  # Injective.
                images[target] = part
                for other in parts:
                    assert part == other or part.isdisjoint(other)
            assert set(images) == {act(g, x) for g in group}  # Surjective.
            count += 1
    print(f"Coset partitions and well-defined bijections checked for all {count} objects.")


def verify_other_examples():
    group = tuple(permutations(range(1, 4)))
    objects = tuple(product(range(1, 4), repeat=2))

    def compose(g, h):
        return tuple(g[h[i] - 1] for i in range(3))

    def act(g, x):
        return g[x[0] - 1], g[x[1] - 1]

    identity = (1, 2, 3)
    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 == {y for y in objects if (y[0] == y[1]) == (x[0] == x[1])}
        assert (len(orbit), len(stabilizer)) == ((3, 2) if x[0] == x[1] else (6, 1))
        for g, h in product(group, repeat=2):
            assert act(compose(g, h), x) == act(g, act(h, x))
    assert {g for g in group if act(g, (1, 1)) == (1, 1)} == {identity, (1, 3, 2)}
    assert {g for g in group if act(g, (1, 2)) == (1, 2)} == {identity}
    verify_cosets(group, {"ordered pairs": (objects, act)}, compose)
    print("S3: 324 action-law cases; all 9 ordered pairs; orbit/stabilizer sizes 3×2 and 6×1.")

    def parity_act(k, x):
        return (x + k) % 2

    for x in range(2):
        assert parity_act(0, x) == x
        assert {parity_act(k, x) for k in range(4)} == {0, 1}
        assert {k for k in range(4) if parity_act(k, x) == x} == {0, 2}
        for k, ell in product(range(4), repeat=2):
            assert parity_act((k + ell) % 4, x) == parity_act(k, parity_act(ell, x))
    kernel = {k for k in range(4) if all(parity_act(k, x) == x for x in range(2))}
    assert kernel == {0, 2}
    verify_cosets(tuple(range(4)), {"two points": (tuple(range(2)), parity_act)},
                  lambda k, ell: (k + ell) % 4)
    print("C4: 32 action-law cases; kernel {0,2}; both orbit/stabilizer sizes 2×2.")


def box(ax, x, y, width, height, text, color="#126b91", size=13):
    ax.add_patch(FancyBboxPatch((x - width / 2, y - height / 2), width, height,
                               boxstyle="round,pad=0.02", linewidth=1.5,
                               edgecolor=color, facecolor="white", zorder=2))
    ax.text(x, y, text, ha="center", va="center", fontsize=size,
            color="#27343d", linespacing=1.5, zorder=3)


def main():
    model = runpy.run_path(str(Path(__file__).with_name("square-symmetry-orbits.py")))
    group, labels, actions = model["verify_square"]()
    compose = model["compose"]
    verify_cosets(group, actions, compose)
    verify_other_examples()

    fig, axes = plt.subplots(1, 2, figsize=(11, 6), dpi=120, facecolor="white",
                             gridspec_kw={"width_ratios": (1, 1.15)})
    left, right = axes
    for ax in axes:
        ax.set(xlim=(0, 10), ylim=(0, 10))
        ax.axis("off")
    left.set_title("Four cosets → four images of A", fontsize=15, pad=14)
    left.text(5, 9.35, "H = stabilizer of A = {e, s}", ha="center", fontsize=13)
    stabilizer = {g for g in group if g[0] == 0}
    colors = ("#126b91", "#44833f", "#8b58a2", "#bc6721")
    for k, (coset_label, color) in enumerate(zip(("H", "rH", "r²H", "r³H"), colors)):
        y = 7.6 - 1.65 * k
        members = {compose(group[k], h) for h in stabilizer}
        assert members == {group[k], group[k + 4]}
        text = f"{coset_label} = {{{labels[group[k]]}, {labels[group[k + 4]]}}}"
        box(left, 3.1, y, 5.35, 1.05, text, color)
        left.annotate("", xy=(7.3, y), xytext=(5.8, y),
                      arrowprops={"arrowstyle": "->", "color": color, "lw": 1.7})
        box(left, 8, y, 1.2, 1.05, "ABCD"[k], color, size=16)
    left.text(5, 0.7, "8 transformations / 2 per image = 4 images", ha="center", fontsize=12)

    right.set_title("One group, four different actions", fontsize=15, pad=14)
    box(right, 5, 8.9, 4.7, 0.9, "D₄ has 8 elements", size=15)
    root = (5, 8.43)
    positions = ((2.35, 5.85), (7.65, 5.85), (2.35, 2.7), (7.65, 2.7))
    for (name, (objects, act)), (x, y), color in zip(actions.items(), positions, colors):
        point = objects[0]
        orbit_size = len({act(g, point) for g in group})
        stabilizer_size = sum(act(g, point) == point for g in group)
        # Use outer lanes for the lower two branches to avoid upper boxes.
        if y < 4:
            lane = 0.12 if x < 5 else 9.88
            right.plot((root[0], lane, lane, x), (root[1], 7.8, 3.6, 3.6),
                       color=color, lw=1.3, zorder=1)
            right.plot((x, x), (3.6, y + 0.8), color=color, lw=1.3, zorder=1)
        else:
            right.plot((root[0], x), (root[1], y + 0.8), color=color, lw=1.3, zorder=1)
        image_word = "image" if orbit_size == 1 else "images"
        text = f"{name}\n{orbit_size} {image_word} × {stabilizer_size} fixing\n= 8"
        box(right, x, y, 4.15, 1.55, text, color, size=12)
    right.text(5, 0.6, "Each branch moves a different kind of object.", ha="center", fontsize=12)
    fig.suptitle("Orbit–stabilizer: count images, then count repetitions", fontsize=18, y=0.97)
    fig.subplots_adjust(left=0.025, right=0.975, top=0.84, bottom=0.06, wspace=0.16)
    output = Path.cwd() / "orbit-stabilizer-cosets.png"
    fig.savefig(output, facecolor="white", transparent=False)
    plt.close(fig)
    print(f"Wrote {output.name}: 1320×720 pixels, opaque white background.")


if __name__ == "__main__":
    main()