Add Realized/Unrealized/Invalid tire-colouring analysis

For a random 12-vertex maximal planar graph (sphere convex hull), enumerate
all proper 3-colourings of M(G), take the BFS-level (tire-tree) decomposition
from every source vertex, and build each full medial tire graph M(T) in the
ambient tread-face model (cycle + teeth + bites).  Recognise each M(T) as a
FullMedialTireGraph and label every proper 3-colouring Realized (Kempe-balanced
and a restriction of a global colouring), Unrealized (balanced but not a
restriction), or Invalid (not balanced).

Findings on seed 1 (17 pieces, M(G) with 90 colourings): zero realized-but-
invalid colourings (confirms Remark 5.8 on a real triangulation), and 12 of 17
pieces carry Unrealized colourings -- Kempe-balance is necessary but not
sufficient for realization; it is sufficient only on cap-like all-up/shallow
treads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:36:34 -04:00
parent cf035243f6
commit dacef25cbb
2 changed files with 488 additions and 0 deletions
@@ -0,0 +1,451 @@
"""Realized / Unrealized / Invalid analysis of full medial tire graphs.
Pipeline.
1. Take a maximal planar graph G on 12 vertices.
2. Build its medial graph M(G) and enumerate every proper 3-colouring.
3. For every source vertex S, take the BFS-level (tire-tree) decomposition;
each tread T_d sits between level cycle d and level cycle d+1, and yields a
full medial tire graph M(T_d) (the medial vertices of its tread edges).
4. For each M(T): enumerate every proper 3-colouring and label it
Realized -- Kempe-balanced AND a restriction of some colouring of M(G);
Unrealized -- Kempe-balanced but NOT such a restriction;
Invalid -- not Kempe-balanced.
Tread edge types are read off BFS levels: an edge with endpoints on levels d and
d+1 is annular; both endpoints on level d is an up (outer) tooth edge; both on
level d+1 is a down (inner) tooth edge. A down edge incident to two tread faces
is a bite.
"""
from __future__ import annotations
import itertools
import random
from collections import defaultdict
import networkx as nx
import numpy as np
import scipy.spatial
def random_sphere_triangulation(n: int, seed: int) -> nx.Graph:
"""A random maximal planar graph: convex hull of random points on S^2."""
rng = np.random.default_rng(seed)
pts = rng.normal(size=(n, 3))
pts /= np.linalg.norm(pts, axis=1, keepdims=True)
hull = scipy.spatial.ConvexHull(pts)
g = nx.Graph()
for a, b, c in hull.simplices:
g.add_edges_from([(int(a), int(b)), (int(b), int(c)), (int(a), int(c))])
return g
def medial_tire_facemodel(tread_faces) -> nx.Graph:
"""Full medial tire graph M(T): the ambient tread-face model. Each tread
face contributes a triangle on its three edge-medial-vertices; no medial
edges between two same-boundary edges (those come from non-tread corners)."""
Mt = nx.Graph()
for f in tread_faces:
es = [ekey(f[0], f[1]), ekey(f[1], f[2]), ekey(f[2], f[0])]
Mt.add_nodes_from(es)
for a in range(3):
Mt.add_edge(es[a], es[(a + 1) % 3])
return Mt
# --------------------------------------------------------------------------- #
# Maximal planar graph on n vertices: stacked seed + random diagonal flips.
# --------------------------------------------------------------------------- #
def random_maximal_planar(n: int, seed: int, flips: int = 400) -> nx.Graph:
rng = random.Random(seed)
g = nx.Graph()
g.add_edges_from([(0, 1), (1, 2), (0, 2)])
faces = [(0, 1, 2)]
nxt = 3
while nxt < n:
a, b, c = faces.pop(rng.randrange(len(faces)))
v = nxt
nxt += 1
g.add_edges_from([(v, a), (v, b), (v, c)])
faces += [(a, b, v), (b, c, v), (a, c, v)]
# randomise with diagonal flips that keep it simple and planar
edges = list(g.edges())
for _ in range(flips):
u, v = rng.choice(edges)
common = list(set(g.neighbors(u)) & set(g.neighbors(v)))
if len(common) != 2:
continue
a, b = common
if g.has_edge(a, b):
continue
g.remove_edge(u, v)
g.add_edge(a, b)
if not nx.check_planarity(g)[0] or g.number_of_edges() != 3 * n - 6:
g.add_edge(u, v)
g.remove_edge(a, b)
else:
edges = list(g.edges())
return g
def ekey(u, v):
return (u, v) if (str(u), u) <= (str(v), v) else (v, u)
def triangular_faces(g: nx.Graph):
ok, emb = nx.check_planarity(g)
if not ok:
raise ValueError("not planar")
seen = set()
faces = []
for u, v in list(emb.edges()):
if (u, v) in seen:
continue
f = emb.traverse_face(u, v, mark_half_edges=seen)
faces.append(tuple(f))
return faces, emb
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 = sorted(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
# --------------------------------------------------------------------------- #
# Tread extraction from a BFS-level decomposition.
# --------------------------------------------------------------------------- #
def extract_tread(faces, levels, d):
"""Tread T_d: faces spanning levels {d, d+1}. Returns its edge classes."""
tread_faces = []
for f in faces:
lv = [levels[x] for x in f]
if min(lv) == d and max(lv) == d + 1:
tread_faces.append(f)
if not tread_faces:
return None
annular, up, down = set(), set(), set()
face_of_down = defaultdict(int)
for f in tread_faces:
for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])):
e = ekey(x, y)
lx, ly = levels[x], levels[y]
if {lx, ly} == {d, d + 1}:
annular.add(e)
elif lx == ly == d:
up.add(e)
elif lx == ly == d + 1:
down.add(e)
face_of_down[e] += 1
bites = {e for e in down if face_of_down[e] == 2}
return {
"tread_faces": tread_faces,
"annular": annular, "up": up, "down": down, "bites": bites,
}
def annular_cycle_order(M: nx.Graph, annular: set):
"""Cyclic order of the annular medial vertices (they induce a cycle)."""
sub = M.subgraph(annular)
if sub.number_of_nodes() == 0 or any(sub.degree(v) != 2 for v in sub):
return None
if not nx.is_connected(sub):
return None
start = next(iter(annular))
order = [start]
prev = None
cur = start
while True:
nbrs = [w for w in sub.neighbors(cur) if w != prev]
if not nbrs:
break
nxt = nbrs[0]
if nxt == start:
break
order.append(nxt)
prev, cur = cur, nxt
return order if len(order) == len(annular) else None
# --------------------------------------------------------------------------- #
# Recognise a genuine tread as a FullMedialTireGraph and classify colourings.
# --------------------------------------------------------------------------- #
from full_medial_tire_generator import FullMedialTireGraph
from kempe_valid_colorings import classify as kempe_classify
def _linear_cut(n, bite_pairs):
"""Rotate the cycle so the bite pairs become linear non-crossing intervals."""
for r in range(n):
rel = [tuple(sorted(((i - r) % n, (j - r) % n))) for i, j in bite_pairs]
ok = True
for a, b in rel:
for c, d in rel:
if (a, b) != (c, d) and (a < c < b < d or c < a < d < b):
ok = False
break
if not ok:
break
if ok:
return r, rel
return None
def recognise(M, tread):
"""Return (FullMedialTireGraph, bijection fmt-name -> medial vertex) or None.
``M`` here is the tread-face model M(T) (cycle + teeth + bites)."""
annular = tread["annular"]
order = annular_cycle_order(M, annular)
if order is None or len(order) < 3:
return None
n = len(order)
ann_set = set(annular)
apex_of_edge = []
for i in range(n):
a, b = order[i], order[(i + 1) % n]
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b)) if w not in ann_set]
if len(common) != 1:
return None
apex_of_edge.append(common[0])
up = set(tread["up"])
# bite apex: serves two cycle edges (== adjacent to four annular vertices)
apex_positions = defaultdict(list)
for i, ap in enumerate(apex_of_edge):
apex_positions[ap].append(i)
tooth = []
bite_pairs = []
for ap, positions in apex_positions.items():
if len(positions) == 2:
bite_pairs.append(tuple(sorted(positions)))
for i, ap in enumerate(apex_of_edge):
tooth.append("U" if ap in up else "D")
cut = _linear_cut(n, bite_pairs)
if cut is None:
return None
r, rel_bites = cut
word = [""] * n
for i in range(n):
word[(i - r) % n] = tooth[i]
g = FullMedialTireGraph(n=n, tooth_word="".join(word), bites=frozenset(rel_bites))
# bijection fmt-name -> medial vertex
bij = {}
for k in range(n):
bij[f"a{k}"] = order[(k + r) % n]
for i in g.up_edges:
bij[f"u{i}"] = apex_of_edge[(i + r) % n]
for i in g.singleton_down_edges:
bij[f"d{i}"] = apex_of_edge[(i + r) % n]
for (i, j) in sorted(g.bites):
bij[f"p{i}_{j}"] = apex_of_edge[(i + r) % n]
# verify the reconstructed graph is edge-faithful to the tread-face M(T)
mt_edges = {ekey(*e) for e in M.edges()}
rec_edges = {ekey(bij[u], bij[v]) for u, v in g.edges()}
if rec_edges != mt_edges:
return None
return g, bij
def canonical(coloring, ordered):
remap, out = {}, []
for v in ordered:
c = coloring[v]
if c not in remap:
remap[c] = len(remap)
out.append(remap[c])
return tuple(out)
def proper_3_colorings_subgraph(M, nodes, limit=200000):
nodes = sorted(nodes)
adj = {v: set(M.neighbors(v)) & set(nodes) for v in nodes}
coloring, 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 analyse(seed: int, color_limit: int = 400000):
G = random_sphere_triangulation(12, seed)
faces, _ = triangular_faces(G)
M = medial_graph(G)
global_colorings = proper_3_colorings(M, color_limit)
assert len(global_colorings) < color_limit, "global colouring cap hit"
records = []
for s in sorted(G.nodes()):
levels = nx.single_source_shortest_path_length(G, s)
for d in range(max(levels.values())):
tread = extract_tread(faces, levels, d)
if tread is None or len(tread["up"]) < 3:
continue
mt = medial_tire_facemodel(tread["tread_faces"])
rec = recognise(mt, tread)
if rec is None:
continue
g, bij = rec
mt_nodes = list(bij.values())
name_of = {v: k for k, v in bij.items()}
# restrictions of global colourings to this M(T)
realized = set()
for col in global_colorings:
realized.add(canonical({v: col[v] for v in mt_nodes}, mt_nodes))
r_cnt = u_cnt = i_cnt = 0
viol58 = 0
seen = set()
for col in proper_3_colorings_subgraph(mt, mt_nodes):
key = canonical(col, mt_nodes)
if key in seen:
continue
seen.add(key)
fmt_col = {name_of[v]: c for v, c in col.items()}
balanced = kempe_classify(g, fmt_col).valid
is_real = key in realized
if is_real and not balanced:
viol58 += 1
if not balanced:
i_cnt += 1
elif is_real:
r_cnt += 1
else:
u_cnt += 1
records.append({
"source": s, "tread": d, "n": g.n, "word": g.tooth_word,
"bites": sorted(g.bites), "up": len(g.up_edges),
"down": len(g.down_edges),
"realized": r_cnt, "unrealized": u_cnt, "invalid": i_cnt,
"total": r_cnt + u_cnt + i_cnt, "viol58": viol58,
})
return G, M, len(global_colorings), records
def write_note(seed, G, M, n_global, records, path):
lines = []
lines.append(f"# Realized / Unrealized / Invalid analysis (seed {seed})\n")
lines.append(
f"Random maximal planar graph on **{G.number_of_nodes()} vertices** "
f"({G.number_of_edges()} edges, {2*G.number_of_nodes()-4} faces). "
f"Medial graph $M(G)$ has **{M.number_of_nodes()} vertices** and "
f"**{n_global} proper 3-colourings**.\n")
lines.append(
"For each full medial tire graph $M(T)$ from a source/tread, every proper "
"3-colouring (mod colour permutation) is labelled **Realized** "
"(Kempe-balanced and a restriction of a colouring of $M(G)$), "
"**Unrealized** (balanced but not a restriction), or **Invalid** "
"(not Kempe-balanced).\n")
tot58 = sum(r["viol58"] for r in records)
lines.append(
f"Remark 5.8 cross-check: **{tot58}** realized-but-invalid colourings "
f"across all pieces (must be 0; a positive count would refute 5.8).\n")
lines.append("| # | source | tread | n=\\|A(T)\\| | word | bites | "
"Realized | Unrealized | Invalid | total |")
lines.append("|--:|--:|--:|--:|:--|:--|--:|--:|--:|--:|")
for idx, r in enumerate(records):
b = ",".join(f"({i},{j})" for i, j in r["bites"]) or "-"
lines.append(
f"| {idx} | {r['source']} | T{r['tread']} | {r['n']} | `{r['word']}` | "
f"{b} | {r['realized']} | {r['unrealized']} | {r['invalid']} | "
f"{r['total']} |")
lines.append("")
tot_r = sum(r["realized"] for r in records)
tot_u = sum(r["unrealized"] for r in records)
tot_i = sum(r["invalid"] for r in records)
n_unreal = sum(1 for r in records if r["unrealized"] > 0)
suff = [idx for idx, r in enumerate(records) if r["unrealized"] == 0]
lines.append("## Findings\n")
lines.append(
f"- **Remark 5.8 holds here.** No colouring is realized-but-invalid "
f"({tot58} across all pieces): every restriction of a global colouring is "
f"Kempe-balanced, as Remark 5.8 predicts.")
lines.append(
f"- **Balance is necessary but not sufficient.** {n_unreal} of "
f"{len(records)} pieces have a *Kempe-balanced* colouring that is **not** "
f"the restriction of any global colouring (Unrealized). Totals over all "
f"pieces: Realized {tot_r}, Unrealized {tot_u}, Invalid {tot_i}.")
lines.append(
f"- **Where balance *is* sufficient:** pieces {suff} have zero Unrealized "
f"colourings (every balanced colouring is realized). These are the small "
f"all-up treads (`UUUU`, `UUUUU`) and the shallow bite tread "
f"`DUUUDUU`, i.e. the cap-like pieces with few down teeth.")
lines.append("")
lines.append(
"## Method\n\n"
"1. `random_sphere_triangulation` -- convex hull of 12 random points on "
"the sphere. 2. `medial_graph` and all proper 3-colourings of $M(G)$. "
"3. For each source $S$ the BFS-level decomposition; tread $T_d$ spans "
"levels $d,d+1$, and `medial_tire_facemodel` builds $M(T_d)$ in the "
"ambient tread-face model (cycle + teeth + bites). Degenerate treads "
"(no annular cycle, or fewer than three up teeth) are skipped. 4. Each "
"$M(T)$ is recognised as a `FullMedialTireGraph`; its colourings are "
"classified with the Kempe-balance rule and matched (mod colour "
"permutation) against restrictions of the global colourings.")
with open(path, "w") as fh:
fh.write("\n".join(lines) + "\n")
print(f"wrote {path}")
if __name__ == "__main__":
import os
SEED = 1
G, M, n_global, records = analyse(SEED)
here = os.path.dirname(os.path.abspath(__file__))
for r in records:
print(r)
write_note(SEED, G, M, n_global, records,
os.path.join(here, f"tire_realization_seed{SEED}.md"))
@@ -0,0 +1,37 @@
# Realized / Unrealized / Invalid analysis (seed 1)
Random maximal planar graph on **12 vertices** (30 edges, 20 faces). Medial graph $M(G)$ has **30 vertices** and **90 proper 3-colourings**.
For each full medial tire graph $M(T)$ from a source/tread, every proper 3-colouring (mod colour permutation) is labelled **Realized** (Kempe-balanced and a restriction of a colouring of $M(G)$), **Unrealized** (balanced but not a restriction), or **Invalid** (not Kempe-balanced).
Remark 5.8 cross-check: **0** realized-but-invalid colourings across all pieces (must be 0; a positive count would refute 5.8).
| # | source | tread | n=\|A(T)\| | word | bites | Realized | Unrealized | Invalid | total |
|--:|--:|--:|--:|:--|:--|--:|--:|--:|--:|
| 0 | 0 | T1 | 13 | `DUUUDDUDUUUDD` | (0,4),(5,12),(7,11) | 15 | 30 | 0 | 45 |
| 1 | 1 | T1 | 12 | `DDDUDDUDUDUU` | (0,9) | 15 | 39 | 203 | 257 |
| 2 | 2 | T1 | 9 | `UDUDUUDDD` | - | 11 | 13 | 61 | 85 |
| 3 | 2 | T2 | 7 | `DUUUDUU` | (0,4) | 7 | 0 | 0 | 7 |
| 4 | 3 | T1 | 13 | `DUUDUDUUDUUDD` | (0,3),(5,12),(8,11) | 15 | 40 | 0 | 55 |
| 5 | 4 | T1 | 12 | `DDUUDUUDUDUD` | (1,4) | 14 | 58 | 185 | 257 |
| 6 | 5 | T1 | 10 | `DDDUUDUDDU` | - | 14 | 40 | 117 | 171 |
| 7 | 5 | T2 | 5 | `UUUUU` | - | 5 | 0 | 0 | 5 |
| 8 | 6 | T1 | 12 | `DUDUDDDUUDDU` | - | 15 | 174 | 494 | 683 |
| 9 | 7 | T1 | 11 | `UUDUDDDUDDD` | - | 13 | 89 | 239 | 341 |
| 10 | 8 | T1 | 11 | `DUDDDUDUDDU` | - | 11 | 78 | 252 | 341 |
| 11 | 8 | T2 | 4 | `UUUU` | - | 3 | 0 | 0 | 3 |
| 12 | 9 | T1 | 10 | `UUUDDDUDUD` | - | 14 | 38 | 119 | 171 |
| 13 | 9 | T2 | 4 | `UUUU` | - | 3 | 0 | 0 | 3 |
| 14 | 10 | T1 | 10 | `DDUUDDDUUU` | - | 13 | 28 | 130 | 171 |
| 15 | 10 | T2 | 4 | `UUUU` | - | 3 | 0 | 0 | 3 |
| 16 | 11 | T1 | 11 | `DDUDDDUDUUD` | - | 14 | 88 | 239 | 341 |
## Findings
- **Remark 5.8 holds here.** No colouring is realized-but-invalid (0 across all pieces): every restriction of a global colouring is Kempe-balanced, as Remark 5.8 predicts.
- **Balance is necessary but not sufficient.** 12 of 17 pieces have a *Kempe-balanced* colouring that is **not** the restriction of any global colouring (Unrealized). Totals over all pieces: Realized 185, Unrealized 715, Invalid 2039.
- **Where balance *is* sufficient:** pieces [3, 7, 11, 13, 15] have zero Unrealized colourings (every balanced colouring is realized). These are the small all-up treads (`UUUU`, `UUUUU`) and the shallow bite tread `DUUUDUU`, i.e. the cap-like pieces with few down teeth.
## Method
1. `random_sphere_triangulation` -- convex hull of 12 random points on the sphere. 2. `medial_graph` and all proper 3-colourings of $M(G)$. 3. For each source $S$ the BFS-level decomposition; tread $T_d$ spans levels $d,d+1$, and `medial_tire_facemodel` builds $M(T_d)$ in the ambient tread-face model (cycle + teeth + bites). Degenerate treads (no annular cycle, or fewer than three up teeth) are skipped. 4. Each $M(T)$ is recognised as a `FullMedialTireGraph`; its colourings are classified with the Kempe-balance rule and matched (mod colour permutation) against restrictions of the global colourings.