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

@@ -35,6 +35,10 @@
\newlabel{conj:medial-chain-pigeonhole}{{5.2}{7}}
\newlabel{conj:medial-route-fct}{{5.3}{7}}
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{5.1}{Kempe-cycle conservation across medial tires}}{7}{}\protected@file@percent }
\newlabel{lem:kempe-cycles}{{5.5}{7}}
\newlabel{lem:kempe-conservation}{{5.6}{8}}
\newlabel{def:kempe-balanced}{{5.7}{8}}
\newlabel{rem:kempe-balance-necessary}{{5.8}{8}}
\bibcite{bauerfeld-nested-tire-decompositions}{1}
\bibcite{tait-original}{2}
\newlabel{tocindent-1}{0pt}
@@ -1,5 +1,5 @@
# Fdb version 3
["pdflatex"] 1781194762 "paper.tex" "paper.pdf" "paper" 1781194763
["pdflatex"] 1781207945 "paper.tex" "paper.pdf" "paper" 1781207946
"/usr/local/texlive/2022/texmf-dist/fonts/map/fontname/texfonts.map" 1577235249 3524 cb3e574dea2d1052e39280babc910dc8 ""
"/usr/local/texlive/2022/texmf-dist/fonts/tfm/public/amsfonts/cmextra/cmex7.tfm" 1246382020 1004 54797486969f23fa377b128694d548df ""
"/usr/local/texlive/2022/texmf-dist/fonts/tfm/public/amsfonts/cmextra/cmex8.tfm" 1246382020 988 bdf658c3bfc2d96d3c8b02cfc1c94c20 ""
@@ -132,8 +132,8 @@
"/usr/local/texlive/2022/texmf-var/fonts/map/pdftex/updmap/pdftex.map" 1647878959 4410336 7d30a02e9fa9a16d7d1f8d037ba69641 ""
"/usr/local/texlive/2022/texmf-var/web2c/pdftex/pdflatex.fmt" 1665017617 2826443 7e98410c533054b636c6470db83a27bc ""
"/usr/local/texlive/2022/texmf.cnf" 1647878952 577 209b46be99c9075fd74d4c0369380e8c ""
"paper.aux" 1781194763 4035 146533a306519cb8688d4e85db1d3f80 "pdflatex"
"paper.tex" 1781194559 37363 b7b005cfaefc0e3a582f756b993a034e ""
"paper.aux" 1781207946 4206 a817291c83280f23be785ea9b9789717 "pdflatex"
"paper.tex" 1781207918 39941 f80d8bca5b99e67d65ad8e4bb2d30152 ""
(generated)
"paper.aux"
"paper.log"
@@ -1,4 +1,4 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 11 JUN 2026 12:19
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 11 JUN 2026 15:59
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
@@ -508,10 +508,10 @@ LaTeX Warning: `h' float specifier changed to `ht'.
[4] [5] [6] [7] [8] [9] [10] (./paper.aux) )
Here is how much of TeX's memory you used:
14415 strings out of 478268
283664 string characters out of 5846347
609309 words of memory out of 5000000
32244 multiletter control sequences out of 15000+600000
14419 strings out of 478268
283755 string characters out of 5846347
609349 words of memory out of 5000000
32248 multiletter control sequences out of 15000+600000
477048 words of font info for 58 fonts, out of 8000000 for 9000
1302 hyphenation exceptions out of 8191
84i,8n,89p,736b,838s stack positions out of 10000i,1000n,20000p,200000b,200000s
@@ -535,7 +535,7 @@ ts/cm/cmti10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfont
s/cm/cmti8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/
cm/cmtt8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/sy
mbols/msam10.pfb>
Output written on paper.pdf (10 pages, 272876 bytes).
Output written on paper.pdf (10 pages, 276272 bytes).
PDF statistics:
135 PDF objects out of 1000 (max. 8388607)
84 compressed objects within 1 object stream
@@ -727,7 +727,8 @@ Since $M$ is $4$-regular and $\varphi$ is proper, every vertex of
$M_P$ has degree $2$ in $M_P$. Hence every component of $M_P$ is a
cycle. We call these components the $P$-Kempe cycles of $\varphi$.
\begin{lemma}[Kempe cycles are cycles]
\begin{lemma}[Kempe chains are cycles]
\label{lem:kempe-cycles}
Let $G$ be a plane triangulation, let $M=M(G)$, and let
$\varphi$ be a proper $3$-colouring of $M$. For each
$P\in\{\{1,2\},\{2,3\},\{3,1\}\}$, every component of $M_P$ is a cycle.
@@ -763,6 +764,7 @@ in the tire tree.
The following lemma is the basic conservation principle.
\begin{lemma}[Kempe-cycle conservation across level cycles]
\label{lem:kempe-conservation}
Let $C$ be a level cycle of $M$ separating a parent side from a child
side. Let $K$ be a $P$-Kempe cycle for some
$P\in\{\{1,2\},\{2,3\},\{3,1\}\}$. Then $K$ cannot enter the child side
@@ -784,6 +786,56 @@ $C$. Thus every entrance through $C$ is paired with an exit through
$C$.
\end{proof}
We now use these Kempe cycles to single out the colourings of a full
medial tire graph that respect the annular tooth structure.
\begin{definition}[Kempe-balanced colouring]
\label{def:kempe-balanced}
Let $\varphi$ be a proper $3$-colouring of the full medial tire graph
$\mathsf{M}(T)$. For a colour pair $P=\{a,b\}$, let $\mathsf{M}(T)_P$ be
the subgraph induced by the vertices of colours $a$ and $b$. Since
$\mathsf{M}(T)$ need not be $4$-regular, the components of
$\mathsf{M}(T)_P$ are paths or cycles; we call them the $P$-\emph{Kempe
chains} of $\varphi$. Every vertex of colour $a$ or $b$ lies on exactly
one $P$-Kempe chain.
A \emph{valid face} is the outer face of $\mathsf{M}(T)$, or an interior
face of $B(T)$ that is not a tooth---namely the root face or a bite
inner-gap face of Remark~\ref{rem:bite-face-count}. The \emph{tooth
apexes incident to} a valid face $F$ are:
\begin{itemize}
\item the up-tooth apexes (Definition~\ref{def:annular-teeth}), when
$F$ is the outer face;
\item the singleton down-tooth apexes whose annular edge lies on $F$,
when $F$ is interior---the apex on annular edge $m$ being incident to
the innermost bite $(i,j)$ with $i<m<j$, or to the root face if there
is none.
\end{itemize}
Bite apexes are never incident to a valid face in this sense.
For a colour pair $P=\{a,b\}$ write $\nu_P(F)$ for the number of tooth
apexes incident to $F$ that are coloured $a$ or $b$---equivalently, that
lie on a $P$-Kempe chain. The colouring $\varphi$ is
\emph{Kempe-balanced} if $\nu_P(F)$ is even for every valid face $F$ and
every colour pair $P$.
\end{definition}
\begin{remark}[Necessity of Kempe-balance]
\label{rem:kempe-balance-necessary}
A proper $3$-colouring of $\mathsf{M}(T)$ can be part of a proper
$3$-colouring of the whole medial graph $M(G)$ only when it is
Kempe-balanced: if $\varphi$ is the restriction to $\mathsf{M}(T)$ of a
proper $3$-colouring of $M(G)$, then $\varphi$ is Kempe-balanced.
Equivalently, a colouring of $\mathsf{M}(T)$ that fails the parity
condition at some valid face and colour pair cannot extend to a proper
$3$-colouring of $M(G)$. This is an instance of Kempe-cycle
conservation (Lemma~\ref{lem:kempe-conservation}): in the $4$-regular
graph $M(G)$ each $P$-Kempe chain of $\mathsf{M}(T)$ closes up into a
$P$-Kempe cycle, and such a cycle crosses the boundary separating a
valid face from the rest of the sphere an even number of times, so the
$P$-coloured apexes incident to that face occur in even number.
\end{remark}
More generally, let $T$ be a medial tire region with boundary
\[
\partial T = C_0\sqcup C_1\sqcup\cdots\sqcup C_m.