Add Kempe-balanced colouring definition and validity classifier

Define Kempe-balanced colourings of a full medial tire graph (Def 5.7):
for each valid face (outer face or interior non-tooth face of B(T)) and
each colour pair {a,b}, the number of tooth apexes incident to the face
coloured a or b must be even.  Add Remark 5.8 (necessity: a colouring of
M(T) extends to M(G) only if it is Kempe-balanced) and rename Lemma 5.5
to "Kempe chains are cycles".

Add kempe_valid_colorings.py: enumerate all proper 3-colourings of a full
medial tire graph, label each Kempe-balanced/valid or invalid, and plot
them with the offending face's Kempe chains and odd apex set highlighted
on invalid panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:00:10 -04:00
parent 8cc94fb6b9
commit 79cbca8e00
8 changed files with 427 additions and 10 deletions
@@ -0,0 +1,361 @@
"""Enumerate proper 3-colourings of a full medial tire graph and label them
valid / invalid by a Kempe-parity rule.
For a proper 3-colouring of M(T) and a colour pair P = {a, b}, the subgraph
induced by the vertices coloured a or b splits into connected components, the
P-Kempe chains. Every vertex coloured a or b lies on exactly one P-Kempe
chain, so "lies on some P-Kempe chain" is the same as "coloured a or b".
A *valid face* is the single outer (unbounded) face, or an inner face that is
not a tooth -- equivalently the faces of B(T) other than tooth triangles: the
root face plus one inner-gap face per bite (Remark 3.8). A colouring is VALID
when, for every valid face F and every colour pair P = {a, b}, the number of
non-bite *apex* vertices incident to F that lie on a P-Kempe chain (i.e. are
coloured a or b) is even. The count is over the whole pair P on the face, not
per individual chain.
The non-bite apex vertices on the boundary of each valid face are:
* outer face: every up-tooth apex;
* an inner non-tooth face F: the singleton down-tooth apexes whose edge lies
on F's arc.
A singleton down apex on edge m lies on the inner face of the innermost bite
(i, j) with i < m < j, else the root face. Annular vertices and bite apexes are
never counted.
The rule is invariant under permuting the three colours, so colourings may be
reduced modulo the six colour permutations without affecting validity.
"""
from __future__ import annotations
import argparse
import math
import os
from dataclasses import dataclass
from typing import Iterator
from full_medial_tire_generator import (
FullMedialTireGraph,
face_singleton_counts,
generate,
innermost_bite,
)
HERE = os.path.dirname(os.path.abspath(__file__))
Coloring = dict[str, int]
COLOR_PAIRS = ((0, 1), (0, 2), (1, 2))
def adjacency(graph: FullMedialTireGraph) -> dict[str, set[str]]:
adj: dict[str, set[str]] = {v: set() for v in graph.vertices()}
for u, v in graph.edges():
adj[u].add(v)
adj[v].add(u)
return adj
def proper_3_colorings(graph: FullMedialTireGraph) -> Iterator[Coloring]:
"""Every proper 3-colouring of M(T), by backtracking (annular cycle first)."""
adj = adjacency(graph)
# colour the heavily-constrained annular cycle first, then the apexes
order = [f"a{k}" for k in range(graph.n)]
order += [v for v in graph.vertices() if not v.startswith("a")]
coloring: Coloring = {}
def rec(idx: int) -> Iterator[Coloring]:
if idx == len(order):
yield dict(coloring)
return
v = order[idx]
used = {coloring[w] for w in adj[v] if w in coloring}
for c in (0, 1, 2):
if c in used:
continue
coloring[v] = c
yield from rec(idx + 1)
coloring.pop(v, None)
yield from rec(0)
def kempe_components(
adj: dict[str, set[str]], coloring: Coloring, pair: tuple[int, int]
) -> list[set[str]]:
"""Connected components of the subgraph induced by the two colours in pair."""
members = {v for v, c in coloring.items() if c in pair}
seen: set[str] = set()
components: list[set[str]] = []
for start in members:
if start in seen:
continue
stack = [start]
comp: set[str] = set()
seen.add(start)
while stack:
v = stack.pop()
comp.add(v)
for w in adj[v]:
if w in members and w not in seen:
seen.add(w)
stack.append(w)
components.append(comp)
return components
def valid_faces(graph: FullMedialTireGraph) -> dict[str, frozenset[str]]:
"""Map each valid face to the set of non-bite *apex* vertices on it.
Keys: "outer", "root", or "bite(i,j)". The outer face carries the up-tooth
apexes; each inner non-tooth face carries the singleton down-tooth apexes on
its arc. Annular vertices and bite apexes are never included.
"""
faces: dict[str, set[str]] = {
"outer": {f"u{i}" for i in graph.up_edges},
"root": set(),
}
for i, j in graph.bites:
faces[f"bite({i},{j})"] = set()
def inner_key(face) -> str:
return "root" if face is None else f"bite({face[0]},{face[1]})"
for m in graph.singleton_down_edges:
faces[inner_key(innermost_bite(m, graph.bites))].add(f"d{m}")
return {name: frozenset(verts) for name, verts in faces.items()}
@dataclass(frozen=True)
class Verdict:
valid: bool
reason: str = "" # "" when valid
pair: tuple | None = None # the offending colour pair {a,b}
apexes: frozenset = frozenset() # the non-bite apexes counted (odd)
face: str = "" # name of the offending valid face
def classify(graph: FullMedialTireGraph, coloring: Coloring) -> Verdict:
"""Label a single proper 3-colouring valid / invalid by the Kempe rule.
For each valid face and each colour pair {a,b}, the non-bite apex vertices
on that face coloured a or b must be even in number. On a violation the
Verdict records the offending face, pair, and that odd apex set.
"""
faces = valid_faces(graph)
for face_name, face_apexes in faces.items():
for pair in COLOR_PAIRS:
on_pair = frozenset(v for v in face_apexes if coloring[v] in pair)
if len(on_pair) % 2 != 0:
return Verdict(
False,
f"{len(on_pair)} non-bite apex vertices on the {face_name} "
f"lie on an {set(pair)}-Kempe chain (odd)",
pair, on_pair, face_name,
)
return Verdict(True)
def canonical_coloring(graph: FullMedialTireGraph, coloring: Coloring) -> tuple:
"""Representative modulo the six colour permutations (first-seen relabel)."""
order = graph.vertices()
remap: dict[int, int] = {}
out = []
for v in order:
c = coloring[v]
if c not in remap:
remap[c] = len(remap)
out.append(remap[c])
return tuple(out)
def classify_colorings(
graph: FullMedialTireGraph, dedup_colors: bool = True
) -> list[tuple[Coloring, Verdict]]:
"""Enumerate all proper 3-colourings of ``graph`` and label each valid/invalid."""
results: list[tuple[Coloring, Verdict]] = []
seen: set[tuple] = set()
for coloring in proper_3_colorings(graph):
if dedup_colors:
key = canonical_coloring(graph, coloring)
if key in seen:
continue
seen.add(key)
results.append((coloring, classify(graph, coloring)))
return results
# ---------------------------------------------------------------------------
# Plotting.
# ---------------------------------------------------------------------------
def _draw_colored(ax, graph: FullMedialTireGraph, coloring: Coloring, verdict: Verdict):
import matplotlib.pyplot as plt # noqa: F401 (backend already chosen in run)
n = graph.n
palette = {0: "#e6550d", 1: "#3182bd", 2: "#31a354"} # three colour classes
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: dict[str, tuple[float, float]] = {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":
r = 1.42
pos[f"u{i}"] = (r * math.cos(mid_ang(i)), r * math.sin(mid_ang(i)))
elif i not in matched:
r = 0.58
pos[f"d{i}"] = (r * math.cos(mid_ang(i)), r * 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)
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)
# annular cycle a little heavier
for k in range(n):
a, b = f"a{k}", f"a{(k + 1) % n}"
ax.plot([pos[a][0], pos[b][0]], [pos[a][1], pos[b][1]],
color="#666666", lw=1.0, zorder=2)
# highlight the offending pair's Kempe chains (those carrying the counted
# apexes) on invalid colourings
if not verdict.valid and verdict.pair is not None:
pair = verdict.pair
highlight: set[str] = set()
for comp in kempe_components(adjacency(graph), coloring, pair):
if comp & verdict.apexes:
highlight |= comp
for u, v in graph.edges():
if u in highlight and v in highlight and coloring[u] in pair and coloring[v] in pair:
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color="#f5b800", lw=2.4, zorder=2.5, solid_capstyle="round")
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 apex vertices whose odd count caused the violation
if not verdict.valid and verdict.apexes:
xs = [pos[v][0] for v in verdict.apexes]
ys = [pos[v][1] for v in verdict.apexes]
ax.scatter(xs, ys, s=120, facecolors="none", edgecolors="#d62728",
linewidths=1.8, zorder=4)
ax.set_xlim(-1.65, 1.65)
ax.set_ylim(-1.85, 1.65)
ax.set_aspect("equal")
ax.axis("off")
color = "#2ca02c" if verdict.valid else "#d62728"
label = "VALID" if verdict.valid else "INVALID"
ax.set_title(label, fontsize=7, color=color, pad=1.5)
if not verdict.valid:
ax.text(0.5, -0.04, verdict.reason, transform=ax.transAxes,
ha="center", va="top", fontsize=4.6, color="#d62728")
def plot_all(graph: FullMedialTireGraph, results, path_png: str, path_pdf: str):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
cols = 10
rows = math.ceil(len(results) / cols)
fig, axes = plt.subplots(rows, cols, figsize=(cols * 1.5, rows * 1.65))
axes = axes.reshape(rows, cols)
for idx in range(rows * cols):
ax = axes[idx // cols][idx % cols]
if idx < len(results):
coloring, verdict = results[idx]
_draw_colored(ax, graph, coloring, verdict)
else:
ax.axis("off")
n_valid = sum(1 for _, v in results if v.valid)
bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-"
fig.suptitle(
f"Proper 3-colourings of M(T): word={graph.tooth_word}, bites={bites}\n"
f"{len(results)} colourings (mod colour permutation) — "
f"{n_valid} valid, {len(results) - n_valid} invalid\n"
f"invalid panels: gold = offending pair's Kempe chains, "
f"red ring = the odd set of non-bite apexes on the offending face",
fontsize=11, y=0.998,
)
fig.tight_layout(rect=(0, 0, 1, 0.97))
fig.savefig(path_png, dpi=200)
fig.savefig(path_pdf)
print(f"wrote {path_png}")
print(f"wrote {path_pdf}")
def pick_demo_graph() -> FullMedialTireGraph:
"""A small n=9 class that has a bite, at least one VALID colouring, and
(preferably) singleton down teeth sitting inside a bite's inner face, so the
plot shows both valid and invalid colourings and exercises a bite face."""
best = None
fallback = None
for g in generate(9, dedup=True):
if not g.bites:
continue
fallback = fallback or g
n_valid = sum(1 for _, v in classify_colorings(g, dedup_colors=True) if v.valid)
if n_valid == 0:
continue
has_bite_face = any(
face is not None for face in face_singleton_counts(g.tooth_word, g.bites)
)
key = (not has_bite_face, -n_valid, len(g.singleton_down_edges))
if best is None or key < best[0]:
best = (key, g)
return best[1] if best else fallback
def run(args: argparse.Namespace) -> None:
graph = pick_demo_graph()
bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-"
print(f"demo graph: n={graph.n} word={graph.tooth_word} bites={bites}")
print(f" up teeth: {graph.up_edges}")
print(f" singleton down teeth: {graph.singleton_down_edges}")
print(f" face singleton counts: {face_singleton_counts(graph.tooth_word, graph.bites)}")
all_results = classify_colorings(graph, dedup_colors=False)
results = classify_colorings(graph, dedup_colors=True)
n_valid = sum(1 for _, v in results if v.valid)
print(f"proper 3-colourings: {len(all_results)} total, "
f"{len(results)} modulo colour permutation")
print(f" valid: {n_valid} invalid: {len(results) - n_valid}")
# show a couple of invalid reasons for transparency
shown = 0
for _, v in results:
if not v.valid and shown < 3:
print(f" invalid example: {v.reason}")
shown += 1
# valid first, then invalid, for a readable grid
results.sort(key=lambda cv: (not cv[1].valid,))
png = os.path.join(HERE, "kempe_valid_colorings_demo.png")
pdf = os.path.join(HERE, "kempe_valid_colorings_demo.pdf")
plot_all(graph, results, png, pdf)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args()
run(parser.parse_args())
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB