Verify Remark 5.8 mechanism; correct it to level-cycle conservation

Computational checks of the necessity of Kempe-balance (Remark 5.8):

- check_medial_face_parity.py shows the naive "even P-coloured vertices
  per medial face" claim is false (odd vertex-faces on the octahedron and
  stacked triangulations), so the original face-parity justification was
  wrong.
- check_remark58_bitefree.py builds genuine bite-free tire pieces (capped
  triangulated annuli) and confirms every proper 3-colouring of M(G)
  restricts to a Kempe-balanced colouring (|A(T)|=6,8,10,12, all
  colourings, zero failures).

Rewrite Remark 5.8 to cite the correct mechanism: the up/down apexes lie
on level cycles, and a P-Kempe cycle meets each level cycle in an even
number of P-coloured incidences (Lemma 5.6).  Note the bite case is not
yet checked end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:33:00 -04:00
parent 79cbca8e00
commit 5bed8b4dfb
6 changed files with 354 additions and 10 deletions
@@ -0,0 +1,181 @@
"""Probe the parity mechanism behind Remark 5.8.
Claim X: in a proper 3-colouring of the 4-regular medial graph M(G) of a plane
triangulation G, for every face f of M(G) and every colour pair P = {a,b}, the
number of vertices on the boundary of f coloured a or b is even.
M(G) has two kinds of faces: a "vertex-face" per vertex v of G (the cyclic
sequence of edges around v) and a "face-face" per triangular face of G (its
three edges). The face-faces are triangles, trivially even (count 2); the
vertex-faces are the non-obvious case.
We build M(G) from a planar embedding's rotation system, enumerate proper
3-colourings of M(G), and check Claim X on every face / pair.
"""
from __future__ import annotations
import itertools
import networkx as nx
# --- a handful of small plane triangulations (maximal planar graphs) ---------
def tetrahedron() -> nx.Graph:
return nx.complete_graph(4)
def octahedron() -> nx.Graph:
# K_{2,2,2}: antipodal pairs non-adjacent
g = nx.Graph()
pairs = [(0, 1), (2, 3), (4, 5)]
nonadj = set(map(frozenset, pairs))
for u in range(6):
for v in range(u + 1, 6):
if frozenset((u, v)) not in nonadj:
g.add_edge(u, v)
return g
def stacked(levels: int) -> nx.Graph:
"""Apollonian-style: repeatedly insert a vertex in a triangular face."""
g = nx.Graph()
g.add_edges_from([(0, 1), (1, 2), (0, 2)])
faces = [(0, 1, 2)]
nxt = 3
for _ in range(levels):
a, b, c = faces.pop(0)
v = nxt
nxt += 1
g.add_edges_from([(v, a), (v, b), (v, c)])
faces += [(a, b, v), (b, c, v), (a, c, v)]
return g
def icosahedron() -> nx.Graph:
return nx.icosahedral_graph()
def double_wheel(rim: int) -> nx.Graph:
"""Two apexes over a rim cycle: a simple triangulated 'tire' with caps."""
g = nx.Graph()
g.add_cycle = None
for i in range(rim):
g.add_edge(i, (i + 1) % rim)
g.add_edge(i, "N")
g.add_edge(i, "S")
return g
# --- medial graph from a rotation system -------------------------------------
def rotation_system(g: nx.Graph) -> dict:
ok, emb = nx.check_planarity(g)
if not ok:
raise ValueError("graph is not planar")
return {v: list(emb.neighbors_cw_order(v)) for v in g.nodes()}, emb
def medial_graph(g: nx.Graph):
"""Return (M, vertex_faces, face_faces) built from the rotation system.
Medial vertices are edges of g (as sorted tuples). Around each vertex the
incident edges form a face cycle (vertex-face); around each triangular face
of g its three edges form a face cycle (face-face).
"""
rot, emb = rotation_system(g)
def ekey(u, v):
return (u, v) if u <= v else (v, u)
M = nx.Graph()
M.add_nodes_from(ekey(u, v) for u, v in g.edges())
vertex_faces = []
for v, order in rot.items():
edges = [ekey(v, w) for w in order]
vertex_faces.append(edges)
for i in range(len(edges)):
M.add_edge(edges[i], edges[(i + 1) % len(edges)])
# face-faces: traverse each face of the embedding once
seen = set()
face_faces = []
for u, v in list(emb.edges()):
if (u, v) in seen:
continue
face = emb.traverse_face(u, v, mark_half_edges=seen)
edges = [ekey(face[i], face[(i + 1) % len(face)]) for i in range(len(face))]
face_faces.append(edges)
return M, vertex_faces, face_faces
# --- proper 3-colourings of M(G) ---------------------------------------------
def proper_3_colorings(M: nx.Graph, limit: int | None = None):
nodes = list(M.nodes())
adj = {v: set(M.neighbors(v)) for v in nodes}
coloring: dict = {}
out = []
def rec(i):
if limit is not None and len(out) >= limit:
return
if i == len(nodes):
out.append(dict(coloring))
return
v = nodes[i]
used = {coloring[w] for w in adj[v] if w in coloring}
for c in (0, 1, 2):
if c not in used:
coloring[v] = c
rec(i + 1)
del coloring[v]
rec(0)
return out
def check_claim_x(name: str, g: nx.Graph, color_limit: int = 200):
M, vfaces, ffaces = medial_graph(g)
colorings = proper_3_colorings(M, limit=color_limit)
if not colorings:
print(f"{name}: M(G) has no proper 3-colouring (skip)")
return
faces = [("vertex", f) for f in vfaces] + [("face", f) for f in ffaces]
violations = 0
odd_vertex_faces = 0
for col in colorings:
for kind, face in faces:
for pair in ((0, 1), (0, 2), (1, 2)):
cnt = sum(1 for v in face if col[v] in pair)
if cnt % 2 != 0:
violations += 1
if kind == "vertex":
odd_vertex_faces += 1
deg = sorted({len(f) for f in vfaces})
print(f"{name}: |V(G)|={g.number_of_nodes()} |M|={M.number_of_nodes()} "
f"colourings tested={len(colorings)} vertex-face sizes={deg}")
print(f" Claim X violations: {violations} "
f"(vertex-face violations: {odd_vertex_faces})")
def main():
cases = [
("tetrahedron", tetrahedron()),
("octahedron", octahedron()),
("stacked-3", stacked(3)),
("stacked-6", stacked(6)),
("double_wheel-5", double_wheel(5)),
("double_wheel-6", double_wheel(6)),
("double_wheel-7", double_wheel(7)),
("icosahedron", icosahedron()),
]
for name, g in cases:
# ensure it is a triangulation (every face a triangle)
check_claim_x(name, g)
if __name__ == "__main__":
main()
@@ -0,0 +1,150 @@
"""Directly test Remark 5.8 on genuine (bite-free) tire pieces.
Construction. Build a triangulated annulus (an antiprism band) between an outer
p-cycle O = o_0..o_{p-1} and an inner p-cycle I = i_0..i_{p-1}, with the 2p
triangles
(o_k, o_{k+1}, i_k) and (o_{k+1}, i_k, i_{k+1}).
Cap the outer disk with an apex N joined to all o_k and the inner disk with an
apex S joined to all i_k. The result G is a closed plane triangulation, so its
medial graph M(G) is 4-regular.
The tread T is the annulus; its full medial tire graph M(T) is the subgraph of
M(G) on the medial vertices of the tread edges (outer, inner and annular edges).
This tread has simple boundaries, hence no bites: the up teeth are the outer
edges, the down teeth the inner edges, and the only valid faces are the outer
face (up apexes) and the root face (down apexes).
Remark 5.8 predicts: every proper 3-colouring of M(G), restricted to M(T), is
Kempe-balanced, i.e. for each colour pair P the up apexes coloured in P are even
in number, and likewise the down apexes. We enumerate colourings of M(G) and
check this.
"""
from __future__ import annotations
import itertools
import networkx as nx
PAIRS = ((0, 1), (0, 2), (1, 2))
def ekey(u, v):
return (u, v) if (str(u), u) <= (str(v), v) else (v, u)
def build_capped_annulus(p: int):
g = nx.Graph()
O = [("o", k) for k in range(p)]
I = [("i", k) for k in range(p)]
outer_edges, inner_edges, annular_edges = [], [], []
for k in range(p):
o, on = O[k], O[(k + 1) % p]
i, ino = I[k], I[(k + 1) % p]
outer_edges.append(ekey(o, on))
inner_edges.append(ekey(i, ino))
annular_edges += [ekey(o, i), ekey(on, i)]
# tread triangles
g.add_edges_from([(o, on), (on, i), (o, i)]) # (o_k,o_{k+1},i_k)
g.add_edges_from([(on, i), (i, ino), (on, ino)]) # (o_{k+1},i_k,i_{k+1})
# caps
for k in range(p):
g.add_edges_from([("N", O[k]), ("N", O[(k + 1) % p])])
g.add_edges_from([("S", I[k]), ("S", I[(k + 1) % p])])
meta = {
"outer_edges": [ekey(*e) for e in outer_edges],
"inner_edges": [ekey(*e) for e in inner_edges],
"annular_edges": [ekey(*e) for e in annular_edges],
}
return g, meta
def medial_graph(g: nx.Graph) -> nx.Graph:
ok, emb = nx.check_planarity(g)
if not ok:
raise ValueError("not planar")
M = nx.Graph()
M.add_nodes_from(ekey(u, v) for u, v in g.edges())
for v in g.nodes():
order = list(emb.neighbors_cw_order(v))
edges = [ekey(v, w) for w in order]
for a in range(len(edges)):
M.add_edge(edges[a], edges[(a + 1) % len(edges)])
return M
def proper_3_colorings(M: nx.Graph, limit: int):
nodes = list(M.nodes())
adj = {v: set(M.neighbors(v)) for v in nodes}
coloring: dict = {}
out = []
def rec(i):
if len(out) >= limit:
return
if i == len(nodes):
out.append(dict(coloring))
return
v = nodes[i]
used = {coloring[w] for w in adj[v] if w in coloring}
for c in (0, 1, 2):
if c not in used:
coloring[v] = c
rec(i + 1)
del coloring[v]
rec(0)
return out
def is_kempe_balanced(coloring, up_apexes, down_apexes):
for face in (up_apexes, down_apexes):
for pair in PAIRS:
if sum(1 for e in face if coloring[e] in pair) % 2 != 0:
return False, face is down_apexes
return True, None
def run(p: int, limit: int = 4000):
g, meta = build_capped_annulus(p)
M = medial_graph(g)
up = meta["outer_edges"]
down = meta["inner_edges"]
colorings = proper_3_colorings(M, limit)
balanced = 0
unbalanced = []
for col in colorings:
ok, _ = is_kempe_balanced(col, up, down)
if ok:
balanced += 1
else:
unbalanced.append(col)
n_ann = len(meta["annular_edges"])
print(f"p={p}: |V(G)|={g.number_of_nodes()} |M(G)|={M.number_of_nodes()} "
f"|A(T)|={n_ann} up={len(up)} down={len(down)}")
print(f" colourings tested={len(colorings)} (cap {limit}) "
f"balanced={balanced} UNBALANCED={len(unbalanced)}")
if unbalanced:
col = unbalanced[0]
upc = [col[e] for e in up]
dnc = [col[e] for e in down]
print(f" first unbalanced restriction: up colours={upc} down colours={dnc}")
return len(unbalanced)
def main():
total_bad = 0
for p in (3, 4, 5, 6):
total_bad += run(p)
print()
print("Remark 5.8 (bite-free) holds on all tested colourings"
if total_bad == 0 else
f"Remark 5.8 (bite-free) FAILS: {total_bad} unbalanced restrictions found")
if __name__ == "__main__":
main()