diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/kempe_up_tooth_sequences.py b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/kempe_up_tooth_sequences.py new file mode 100644 index 0000000..a0bb3cc --- /dev/null +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/kempe_up_tooth_sequences.py @@ -0,0 +1,409 @@ +"""Up-tooth apex colour sequences of Kempe-balanced 3-colourings. + +Companion experiment to ``kempe_valid_colorings.py``. For a fixed annular +size ``n`` and a fixed number ``m`` of up teeth we: + + 1. generate every full medial tire graph M(T) with |A(T)| = n and exactly + ``m`` up teeth (one representative per dihedral symmetry class); + 2. enumerate the Kempe-balanced (``valid``) proper 3-colourings of each M(T) + and read off the colour sequence of the up-tooth apex vertices u0 < u1 < + ... in increasing annular-edge order (i.e. cyclic order around A(T)); + 3. reduce each up-tooth sequence modulo the six colour permutations -- but + NOT modulo the dihedral symmetry of the cycle -- to a canonical sequence; + 4. group the M(T) by the *set* of canonical up-tooth sequences they realise, + and report how many M(T) share each set. + +For every canonical up-tooth sequence we emit a markdown note listing every +M(T) that realises it together with every Kempe-balanced colouring (modulo +colour permutation) on that M(T) producing the sequence, and a figure drawing +those colourings. A summary note records the step-4 grouping. + +Run: python3 kempe_up_tooth_sequences.py --n 9 --m 4 +""" + +from __future__ import annotations + +import argparse +import math +import os +from collections import defaultdict + +from full_medial_tire_generator import FullMedialTireGraph, generate +from kempe_valid_colorings import ( + adjacency, + classify_colorings, + kempe_components, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) + +Coloring = dict[str, int] +PALETTE = {0: "#e6550d", 1: "#3182bd", 2: "#31a354"} +PALETTE_NAME = {0: "orange", 1: "blue", 2: "green"} + + +def canonical_sequence(seq: tuple[int, ...]) -> tuple[int, ...]: + """Relabel a colour sequence by first appearance (mod colour permutation).""" + remap: dict[int, int] = {} + out = [] + for c in seq: + if c not in remap: + remap[c] = len(remap) + out.append(remap[c]) + return tuple(out) + + +def seq_str(seq: tuple[int, ...]) -> str: + return "".join(str(c) for c in seq) + + +def _parity_partitions(m: int) -> set[tuple[int, ...]]: + """Colour multisets (n0,n1,n2) of m up-tooth apexes admissible under the + outer-face Kempe-parity rule: all three counts share m's parity. Returned + as tuples of the nonzero parts (descending), matching the table format.""" + out: set[tuple[int, ...]] = set() + for a in range(m + 1): + for b in range(a + 1): + c = m - a - b + if c < 0 or c > b: + continue + if a % 2 == b % 2 == c % 2 == m % 2: + out.add(tuple(v for v in (a, b, c) if v > 0)) + return out + + +def up_tooth_sequence(graph: FullMedialTireGraph, coloring: Coloring) -> tuple[int, ...]: + """Colours of the up-tooth apexes in increasing annular-edge order.""" + return tuple(coloring[f"u{i}"] for i in graph.up_edges) + + +def graph_id(idx: int) -> str: + return f"G{idx:02d}" + + +def describe_graph(graph: FullMedialTireGraph) -> str: + bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-" + return f"word={graph.tooth_word} bites={bites}" + + +# --------------------------------------------------------------------------- +# Data collection. +# --------------------------------------------------------------------------- + +class Experiment: + def __init__(self, n: int, m: int): + self.n = n + self.m = m + self.graphs: list[FullMedialTireGraph] = [ + g for g in generate(n, min_up_teeth=3, dedup=True) if len(g.up_edges) == m + ] + # per graph: list of (coloring, canonical up-tooth sequence) + self.colorings: list[list[tuple[Coloring, tuple[int, ...]]]] = [] + # per graph: set of canonical up-tooth sequences it realises + self.graph_seq_sets: list[frozenset[tuple[int, ...]]] = [] + # canonical sequence -> list of (graph_idx, coloring) + self.by_sequence: dict[tuple[int, ...], list[tuple[int, Coloring]]] = defaultdict(list) + + for gidx, g in enumerate(self.graphs): + entries: list[tuple[Coloring, tuple[int, ...]]] = [] + seqs: set[tuple[int, ...]] = set() + for coloring, verdict in classify_colorings(g, dedup_colors=True): + if not verdict.valid: + continue + cseq = canonical_sequence(up_tooth_sequence(g, coloring)) + entries.append((coloring, cseq)) + seqs.add(cseq) + self.by_sequence[cseq].append((gidx, coloring)) + self.colorings.append(entries) + self.graph_seq_sets.append(frozenset(seqs)) + + def groups(self): + """Map each set-of-sequences to the list of graph indices realising it.""" + groups: dict[frozenset[tuple[int, ...]], list[int]] = defaultdict(list) + for gidx, sset in enumerate(self.graph_seq_sets): + groups[sset].append(gidx) + # sort by descending membership, then by set size + return sorted(groups.items(), key=lambda kv: (-len(kv[1]), len(kv[0]))) + + def sequences(self) -> list[tuple[int, ...]]: + return sorted(self.by_sequence) + + +# --------------------------------------------------------------------------- +# Drawing (adapted from kempe_valid_colorings._draw_colored). +# --------------------------------------------------------------------------- + +def _positions(graph: FullMedialTireGraph) -> dict[str, tuple[float, float]]: + n = graph.n + + def ann_xy(k): + ang = math.pi / 2 - 2 * math.pi * k / n + return math.cos(ang), math.sin(ang) + + def mid_ang(i): + return math.pi / 2 - 2 * math.pi * (i + 0.5) / n + + pos = {f"a{k}": ann_xy(k) for k in range(n)} + matched = graph.bite_edges + for i, tooth in enumerate(graph.tooth_word): + if tooth == "U": + pos[f"u{i}"] = (1.42 * math.cos(mid_ang(i)), 1.42 * math.sin(mid_ang(i))) + elif i not in matched: + pos[f"d{i}"] = (0.58 * math.cos(mid_ang(i)), 0.58 * math.sin(mid_ang(i))) + for i, j in sorted(graph.bites): + corners = [ann_xy(i), ann_xy((i + 1) % n), ann_xy(j), ann_xy((j + 1) % n)] + cx = sum(p[0] for p in corners) / 4.0 + cy = sum(p[1] for p in corners) / 4.0 + pos[f"p{i}_{j}"] = (cx * 0.82, cy * 0.82) + return pos + + +def _draw(ax, graph, coloring, title): + pos = _positions(graph) + for u, v in graph.edges(): + ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]], + color="#bbbbbb", lw=0.5, zorder=1) + for k in range(graph.n): + a, b = f"a{k}", f"a{(k + 1) % graph.n}" + ax.plot([pos[a][0], pos[b][0]], [pos[a][1], pos[b][1]], + color="#666666", lw=1.0, zorder=2) + for v, (x, y) in pos.items(): + is_bite = v.startswith("p") + ax.scatter([x], [y], s=34 if is_bite else 24, color=PALETTE[coloring[v]], + edgecolors="black", linewidths=0.5 if is_bite else 0.3, zorder=3) + # ring the up-tooth apexes whose colours form the recorded sequence + ux = [pos[f"u{i}"][0] for i in graph.up_edges] + uy = [pos[f"u{i}"][1] for i in graph.up_edges] + ax.scatter(ux, uy, s=120, facecolors="none", edgecolors="#222222", + linewidths=1.4, zorder=4) + ax.set_xlim(-1.65, 1.65) + ax.set_ylim(-1.85, 1.65) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title(title, fontsize=6, pad=1.5) + + +def draw_sequence(exp: Experiment, seq, out_png, out_pdf): + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + entries = exp.by_sequence[seq] + cols = 10 + rows = math.ceil(len(entries) / cols) + fig, axes = plt.subplots(rows, cols, figsize=(cols * 1.5, rows * 1.7), squeeze=False) + for idx in range(rows * cols): + ax = axes[idx // cols][idx % cols] + if idx < len(entries): + gidx, coloring = entries[idx] + g = exp.graphs[gidx] + useq = seq_str(up_tooth_sequence(g, coloring)) + _draw(ax, g, coloring, f"{graph_id(gidx)} u={useq}") + else: + ax.axis("off") + fig.suptitle( + f"Kempe-balanced colourings with up-tooth apex sequence " + f"{seq_str(seq)} (mod colour permutation)\n" + f"n={exp.n}, m={exp.m} up teeth — {len(entries)} colourings on " + f"{len({g for g, _ in entries})} M(T); black rings mark up-tooth apexes", + fontsize=11, y=0.998, + ) + fig.tight_layout(rect=(0, 0, 1, 0.96)) + fig.savefig(out_png, dpi=170) + fig.savefig(out_pdf) + plt.close(fig) + print(f"wrote {out_png}") + + +# --------------------------------------------------------------------------- +# Markdown notes. +# --------------------------------------------------------------------------- + +def compact_coloring(graph: FullMedialTireGraph, coloring: Coloring) -> str: + """A readable one-line dump of the full colouring, grouped by vertex kind.""" + parts = [] + parts.append("A=" + "".join(str(coloring[f"a{k}"]) for k in range(graph.n))) + up = " ".join(f"u{i}:{coloring[f'u{i}']}" for i in graph.up_edges) + parts.append("U[" + up + "]") + downs = [] + for i in graph.singleton_down_edges: + downs.append(f"d{i}:{coloring[f'd{i}']}") + for i, j in sorted(graph.bites): + downs.append(f"p{i}_{j}:{coloring[f'p{i}_{j}']}") + if downs: + parts.append("D[" + " ".join(downs) + "]") + return " ".join(parts) + + +def write_sequence_note(exp: Experiment, seq, path, fig_name): + s = seq_str(seq) + # group entries by graph + by_graph: dict[int, list[Coloring]] = defaultdict(list) + for gidx, coloring in exp.by_sequence[seq]: + by_graph[gidx].append(coloring) + + colour_multiset = {} + for c in seq: + colour_multiset[c] = colour_multiset.get(c, 0) + 1 + counts = ", ".join(f"{n}×colour{c}" for c, n in sorted(colour_multiset.items())) + + lines = [] + lines.append(f"# Up-tooth apex sequence `{s}`") + lines.append("") + lines.append( + f"Canonical up-tooth apex colour sequence (read u0 < u1 < ... in cyclic " + f"order around A(T), reduced modulo the six colour permutations) for " + f"Kempe-balanced 3-colourings of M(T) with **n = {exp.n}**, " + f"**m = {exp.m} up teeth**." + ) + lines.append("") + lines.append(f"- Colour multiset: {counts}.") + lines.append(f"- Realised by **{len(by_graph)}** of {len(exp.graphs)} M(T) " + f"(dihedral classes).") + lines.append(f"- **{len(exp.by_sequence[seq])}** Kempe-balanced colourings " + f"(mod colour permutation) produce it.") + lines.append(f"- Figure: `{fig_name}` (black rings mark the up-tooth apexes).") + lines.append("") + lines.append("Colouring dump key: `A=` annular cycle a0..a_{n-1}; `U[...]` " + "up-tooth apexes; `D[...]` singleton down apexes `d` and bite " + "apexes `p`. Colours are 0/1/2 = " + + ", ".join(f"{c}:{PALETTE_NAME[c]}" for c in (0, 1, 2)) + ".") + lines.append("") + for gidx in sorted(by_graph): + g = exp.graphs[gidx] + cols = by_graph[gidx] + lines.append(f"## {graph_id(gidx)} — {describe_graph(g)}") + lines.append("") + lines.append(f"{len(cols)} colouring(s) with up-tooth sequence `{s}`:") + lines.append("") + for coloring in cols: + raw = seq_str(up_tooth_sequence(g, coloring)) + lines.append(f"- up apexes (raw labels) `{raw}` → canonical `{s}` · " + f"`{compact_coloring(g, coloring)}`") + lines.append("") + with open(path, "w") as fh: + fh.write("\n".join(lines) + "\n") + print(f"wrote {path}") + + +def write_summary(exp: Experiment, path, notes_dir_name): + lines = [] + lines.append(f"# Up-tooth apex sequences of Kempe-balanced colourings " + f"(n={exp.n}, m={exp.m})") + lines.append("") + lines.append( + f"Every full medial tire graph M(T) with |A(T)| = {exp.n} and exactly " + f"{exp.m} up teeth, one representative per dihedral class: " + f"**{len(exp.graphs)} M(T)**. For each we enumerate the Kempe-balanced " + f"(valid) proper 3-colourings (modulo colour permutation), read the " + f"up-tooth apex colour sequence u0