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:
+361
@@ -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()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 351 KiB |
Reference in New Issue
Block a user