From dacef25cbbfafe46bdb00b014907398b7d33f40e Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 11 Jun 2026 17:36:34 -0400 Subject: [PATCH] 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 --- .../experiments/tire_realization_analysis.py | 451 ++++++++++++++++++ .../experiments/tire_realization_seed1.md | 37 ++ 2 files changed, 488 insertions(+) create mode 100644 papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py create mode 100644 papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_seed1.md diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py new file mode 100644 index 0000000..b5fc40a --- /dev/null +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py @@ -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")) diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_seed1.md b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_seed1.md new file mode 100644 index 0000000..f8429e9 --- /dev/null +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_seed1.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.