Add worked walkthrough; factor explicit phase/colorder colouring

Refactor canonical_coloring into coloring_skeleton (phase-independent parts) +
canonical_coloring_explicit (explicit phases + DFS colour order) + a random-phase
wrapper for back-compat. This exposes the two control knobs deterministically so
they can be enumerated rather than only sampled.

Add a fully worked example on the smallest clean graph (ring [3,5]+hub, 9
vertices, one odd seam, no gadgets): even_program_walkthrough.md traces all six
stages -- generate G with embedding, pick source + BFS levels, choose the diamond
site that evens the level-5 seam, build M(G'), the canonical colouring (seam
mono-3, hub annulus alternates, root by DFS), and a real {1,2}-Kempe switch that
makes the diamond quad reducible. dump_walkthrough.py reproduces every number;
draw_walkthrough.py renders the 4-panel figure even_program_walkthrough.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:44:29 -04:00
parent 2ff712b994
commit 9d296eb9c8
5 changed files with 490 additions and 14 deletions
@@ -0,0 +1,155 @@
"""Step-by-step picture of the even-level-cycle programme on the smallest clean
example: the ring triangulation sizes=[3,5], leaf='hub' (rng seed 0), 9 vertices.
One odd level cycle (level 1, the 5-cycle 3-4-5-6-7), no terminal triangles, so
the only surgery is a single DIAMOND. We walk the FIRST successful choice-set
found by the sweep: insertion site = edge (3,4); colour phase = (0,); root DFS
colour order = (1,0,2). Panels:
A G with its odd level-5 seam (BFS levels from the outer triangle 0-1-2).
B G' = G + diamond w(=9) on edge (3,4): seam is now an even 6-cycle; the
diamond quad 3-0-4-8 (restored diagonal 3-4) shaded.
C medial M(G') with the canonical colouring BEFORE any switch: the four quad
medials m(0,3),m(0,4),m(4,8),m(3,8) are ALL colour 1 -> diamond_condition
fails (the obstruction).
D after one {1,2}-Kempe switch on the component through m(0,3)
{(0,3),(3,7),(3,8),(3,9)}: quad medials become 2,1,1,2 -> reducible;
remove w, restored diagonal (3,4) takes the third colour 0.
Layout is concentric by BFS level (tire-tread model), the level-1 ring placed in
seam-cycle order, so spokes read outward. Run with the repo venv python.
"""
import os, random
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
import kempe_even_program_harness as H
HERE = os.path.dirname(os.path.abspath(__file__))
PAL = {0: "#e6550d", 1: "#3182bd", 2: "#31a354"} # colours "1","2","3"(=2)
RAD = {0: 2.7, 1: 1.45, 2: 0.0}
def concentric(g, outer, an, ring_order):
pos = {}
for i, v in enumerate(outer):
a = 90 - i * 120
pos[v] = (np.cos(np.radians(a)) * RAD[0], np.sin(np.radians(a)) * RAD[0])
m = len(ring_order)
for i, v in enumerate(ring_order):
a = 90 + i * 360.0 / m
pos[v] = (np.cos(np.radians(a)) * RAD[1], np.sin(np.radians(a)) * RAD[1])
for v in sorted(g.rot):
if v in pos: continue
pos[v] = (0.0, 0.0) # hub (level 2)
return pos
def mid(p, q): return ((p[0]+q[0])/2, (p[1]+q[1])/2)
def draw_graph(ax, g, pos, level=None, bold_cycle=None, shade_quad=None, wvert=None):
if shade_quad:
ax.add_patch(Polygon([pos[v] for v in shade_quad], closed=True,
color="#ffe2bf", zorder=0))
bold = set()
if bold_cycle:
for i in range(len(bold_cycle)):
bold.add(frozenset((bold_cycle[i], bold_cycle[(i+1) % len(bold_cycle)])))
for ed in g.edges():
a, b = tuple(ed); pa, pb = pos[a], pos[b]
if ed in bold:
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#d62728", lw=2.8, zorder=2)
else:
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#888888", lw=1.0, zorder=1)
for v, p in pos.items():
c = "#d62728" if (wvert is not None and v == wvert) else "#222222"
ax.plot(*p, "o", color="white", ms=17, zorder=4)
ax.plot(*p, "o", ms=17, mfc="white", mec=c, mew=1.7, zorder=5)
ax.annotate(str(v), p, ha="center", va="center", fontsize=9,
fontweight="bold", color=c, zorder=6)
if level is not None:
ax.annotate(f"L{level[v]}", p, textcoords="offset points",
xytext=(12, 10), fontsize=6.5, color="#999999", zorder=6)
def draw_medial(ax, g, pos, col, halo=None, restored=None):
for ed in g.edges():
a, b = tuple(ed); pa, pb = pos[a], pos[b]
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#e3e3e3", lw=0.8, zorder=0)
adj = H.medial_adj(g)
mpos = {mm: mid(pos[tuple(mm)[0]], pos[tuple(mm)[1]]) for mm in adj}
seen = set()
for mm in adj:
for b in adj[mm]:
k = frozenset((mm, b))
if k in seen: continue
seen.add(k)
pa, pb = mpos[mm], mpos[b]
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#c9c9c9", lw=0.8, zorder=1)
if restored is not None:
a, b = restored
ax.plot([pos[a][0], pos[b][0]], [pos[a][1], pos[b][1]], color="#d62728",
lw=1.8, ls=":", zorder=2)
rp = mid(pos[a], pos[b])
ax.plot(*rp, "s", color=PAL[col[H.e(a, b)]], ms=13, mec="#d62728",
mew=2.0, zorder=7)
halo = halo or set()
for mm, p in mpos.items():
if mm not in col: continue
if mm in halo:
ax.plot(*p, "o", color="#000000", ms=16, zorder=5)
ax.plot(*p, "o", color=PAL[col[mm]], ms=10.5, mec="black", mew=0.8, zorder=6)
rng = random.Random(0)
g, outer = H.ring_triangulation([3, 5], 'hub', rng)
an = H.Analysis(g.copy(), outer)
ring = [k for k, c in an.seams if k == 1][0:1] and \
[c for k, c in an.seams if k == 1][0]
posG = concentric(g, outer, an, ring)
prep = H._prep_gadgets(g.copy(), outer)
template, an_g, gadgets = prep
gg = template.copy()
w, u, v, x, t = gg.insert_diamond(3, 4)
an2 = H.Analysis(gg, outer)
ring2 = [c for k, c in an2.seams if k == 1][0]
posGp = concentric(gg, outer, an2, ring2)
posGp[w] = mid(posGp[3], posGp[4])
quad = H.quad_of(gg, w, u, v) # (3,0,4,8)
col0, _ = H.canonical_coloring_explicit(gg, an2.level, outer, (0,), [1, 0, 2])
col1 = dict(col0)
adjm = H.medial_adj(gg)
comp = H.kempe_component(col1, adjm, H.e(0, 3), (1, 2))
H.switch(col1, comp, (1, 2))
third = H.diamond_condition(col1, quad)
col1[H.e(3, 4)] = third
fig, axes = plt.subplots(1, 4, figsize=(19, 5.4))
for ax in axes:
ax.set_aspect("equal"); ax.axis("off"); ax.set_xlim(-3.2, 3.2); ax.set_ylim(-3.2, 3.2)
draw_graph(axes[0], g, posG, level=an.level, bold_cycle=ring)
axes[0].set_title("A. G (BFS levels from source triangle 0-1-2)\n"
"odd level-1 seam = 5-cycle 3-4-5-6-7 (red)", fontsize=9)
draw_graph(axes[1], gg, posGp, level=an2.level, bold_cycle=ring2,
shade_quad=quad, wvert=w)
axes[1].set_title("B. G' = G + diamond w=9 on edge (3,4)\n"
"seam now even 6-cycle; quad 3-0-4-8 shaded", fontsize=9)
quad_med = {H.e(quad[i], quad[(i+1) % 4]) for i in range(4)}
draw_medial(axes[2], gg, posGp, col0, halo=quad_med)
axes[2].set_title("C. M(G') canonical colour (phase 0, DFS order 1,0,2)\n"
"quad medials m(0,3)m(0,4)m(4,8)m(3,8) ALL =1 (haloed)\n"
"-> diamond_condition FAILS", fontsize=9)
draw_medial(axes[3], gg, posGp, col1, halo=comp, restored=(3, 4))
axes[3].set_title("D. after {1,2}-Kempe switch on comp through m(0,3)\n"
"{(0,3),(3,7),(3,8),(3,9)} (haloed): quad -> 2,1,1,2\n"
f"remove w; restored edge (3,4)=square takes colour {third}",
fontsize=9)
fig.suptitle("Even-level-cycle programme, worked example (ring [3,5]+hub, 9 "
"vertices): one odd seam -> one diamond -> one Kempe switch -> "
"proper 3-colouring of M(G). Colours: 1=orange(0), 2=blue(1), "
"3=green(2).", fontsize=10)
fig.tight_layout(rect=(0, 0, 1, 0.9))
out = os.path.join(HERE, "even_program_walkthrough.png")
fig.savefig(out, dpi=160)
print("wrote", out)
@@ -0,0 +1,90 @@
"""Print every stage of the even-level-cycle programme on the smallest clean
example (ring sizes=[3,5], leaf='hub', rng seed 0; 9 vertices) for the first
choice-set the sweep succeeds on: site (3,4), phase (0,), DFS order (1,0,2).
This is the textual companion to even_program_walkthrough.md / .png.
"""
import random
import kempe_even_program_harness as H
def fz(m): # pretty-print an edge-medial
return tuple(sorted(tuple(m)))
def main():
rng = random.Random(0)
g, outer = H.ring_triangulation([3, 5], 'hub', rng)
print("OUTER (source/root triangle):", outer)
print("\n# STEP 1: graph G (rotation system: vertex -> embedding-order neighbours)")
for v in sorted(g.rot):
print(f" {v}: {g.rot[v]}")
print("faces:", [tuple(f) for f in g.faces()])
print("\n# STEP 2: levels (BFS from the source triangle) + seams")
an = H.Analysis(g.copy(), outer)
for v in sorted(an.level):
print(f" v{v}: level {an.level[v]}")
for k, cyc in an.seams:
print(f" seam level {k}: {cyc} (len {len(cyc)}, "
f"{'ODD' if len(cyc) % 2 else 'even'})")
print(" terminal triangles (need leaf gadget):", an.terminal)
print("\n# STEP 3: diamond sites + chosen edge")
template, an_g, gadgets = H._prep_gadgets(g.copy(), outer)
sites = H._candidate_sites(an_g)
print(" gadgets inserted:", gadgets)
print(" candidate diamond edges (odd seam):", sites)
combo = ((3, 4),)
print(" chosen combo (first successful):", combo)
gg = template.copy()
dia = [gg.insert_diamond(a, b) for (a, b) in combo]
print(" inserted (w,u,v,x,t):", dia)
an2 = H.Analysis(gg, outer)
print(" rot[w]:", gg.rot[dia[0][0]], " level[w]:", an2.level[dia[0][0]])
for k, cyc in an2.seams:
print(f" seam level {k} now: len {len(cyc)} "
f"{'ODD' if len(cyc) % 2 else 'even'} {cyc}")
print("\n# STEP 4: medial graph M(G') (one vertex per edge of G')")
adj = H.medial_adj(gg)
print(f" |V(M)| = {len(adj)}")
for m in sorted(adj, key=fz):
print(f" m{fz(m)}: {sorted(fz(b) for b in adj[m])}")
print("\n# STEP 5: canonical colouring phases=(0,) colorder=(1,0,2)")
phases, colorder = (0,), [1, 0, 2]
sk, _ = H.coloring_skeleton(gg, an2.level, outer)
for i, cyc in enumerate(sk['nonroot']):
print(f" non-root annulus #{i} (len {len(cyc)}): {[fz(m) for m in cyc]}")
print(" root annulus:", [fz(m) for m in sk['root']])
print(" outer-trio (free/DFS):", [fz(m) for m in sk['outer_es']])
col, _ = H.canonical_coloring_explicit(gg, an2.level, outer, phases, colorder)
for m in sorted(col, key=fz):
print(f" m{fz(m)} = {col[m]}")
print("\n# STEP 6: Kempe switch + diamond collapse")
w, u, v, x, t = dia[0]
quad = H.quad_of(gg, w, u, v)
support = [H.e(quad[i], quad[(i + 1) % 4]) for i in range(4)]
print(f" diamond w={w}, quad {quad} (diagonal {u}-{v})")
print(" quad medials:", [fz(s) for s in support])
print(" diamond_condition BEFORE switch:", H.diamond_condition(col, quad),
" support:", {fz(s): col[s] for s in support})
adjm = H.medial_adj(gg)
comp = H.kempe_component(col, adjm, H.e(0, 3), (1, 2))
print(" switch {1,2}-component through m(0,3):", sorted(fz(m) for m in comp))
H.switch(col, comp, (1, 2))
third = H.diamond_condition(col, quad)
print(" diamond_condition AFTER switch:", third,
" support:", {fz(s): col[s] for s in support})
H.collapse_degree4(gg, col, w, u, v)
col[H.e(u, v)] = third
print(f" removed w; restored edge ({u},{v}) takes colour {third}")
print(" proper 3-colouring of M(G)?", H.verify_proper(gg, col))
print(" vertices back to original G?", set(gg.rot) == set(g.rot))
if __name__ == "__main__":
main()
@@ -0,0 +1,192 @@
# Even-level-cycle programme — a fully worked example
A step-by-step trace of the whole pipeline on the **smallest clean graph**: the
synthetic ring triangulation `sizes=[3,5]`, `leaf='hub'` (generator
`random.Random(0)`), **9 vertices**. It has exactly one odd level cycle and no
terminal triangles, so the only surgery is a **single diamond** — which makes
every stage small enough to print in full.
Everything below is the *actual* state produced by `kempe_even_program_harness.py`
(regenerate the data with the dump at the end of this note; the figure is
`even_program_walkthrough.png`, drawn by `draw_walkthrough.py`). We walk the
**first choice-set the sweep finds that succeeds**:
> insertion site = edge `(3,4)` · colour phase = `(0,)` · root-DFS colour order = `(1,0,2)`
Colour convention throughout: values `{0,1,2}` are Tait colours "1,2,3"; `2` is
the "colour 3" the seam is painted with. In the figure: `0`=orange, `1`=blue,
`2`=green.
---
## Step 1 — Generate the triangulation with a plane embedding *(panel A)*
`ring_triangulation([3,5], 'hub')` builds three concentric rings — an outer
triangle, a 5-ring, and a hub — triangulating each annulus with a random tooth
word and capping the centre with a hub vertex. The result is a genuine plane
triangulation given by its rotation system (neighbours in embedding order):
```
0: [4, 3, 7, 6, 5, 2, 1] 3: [0, 4, 8, 7] 6: [5, 0, 7, 8]
1: [4, 0, 2, 5] 4: [3, 0, 1, 5, 8] 7: [6, 0, 3, 8]
2: [5, 1, 0] 5: [4, 1, 2, 0, 6, 8] 8: [3, 4, 5, 6, 7]
```
The 14 triangular faces (one is the outer/unbounded face) are
```
(4,3,8) (4,0,3) (4,1,0) (4,5,1) (4,8,5) (3,0,7) (3,7,8)
(0,6,7) (0,5,6) (0,2,5) (0,1,2) (1,5,2) (5,8,6) (6,8,7)
```
and the 21 edges are the pairs appearing above. (Euler check: 9 21 + 14 = 2.)
The figure draws this with a Tutte-style concentric layout.
## Step 2 — Pick the source and read off levels *(panel A)*
The **source** is the outer triangle, taken as the unbounded face `(0,1,2)`.
A BFS from those three vertices assigns each vertex its **level** (graph distance
to the source tread):
| level | vertices |
|------:|----------|
| 0 | 0, 1, 2 (the source triangle) |
| 1 | 3, 4, 5, 6, 7 (the ring) |
| 2 | 8 (the hub) |
The **level cycles ("seams")** are the same-level edge cycles at each depth ≥1:
```
level 1: cycle 3-4-5-6-7 length 5 -> ODD
```
There is exactly one seam and it is **odd**. There are **no terminal
triangles**, so the leaf gadget never fires — the only surgery needed is a
diamond on this one odd seam.
## Step 3 — Choose the edge(s) that make the level cycles even *(panel B)*
A diamond can be inserted on any seam edge whose two apexes straddle the
neighbouring levels (`k1` and `k+1`). For the level-1 seam, **all five** seam
edges qualify:
```
candidate diamond sites: (3,4) (4,5) (5,6) (6,7) (7,3)
```
This is the choice the **site sweep** ranges over (here a 5-element design
space). We take the **first one that leads to a full success: `(3,4)`**.
Insert the diamond on `(3,4)`:
- delete the edge `(3,4)`;
- add a new degree-4 vertex `w = 9` adjacent to `u=3, v=4` and the two apexes
`x=0` (level 0) and `t=8` (level 2), with rotation `rot[9] = [3,0,4,8]`.
`w` lands at level 1, so the level-1 seam becomes the cycle
```
3-9-4-5-6-7 length 6 -> EVEN
```
Every level cycle is now even. The four-cycle `3-0-4-8` around `w` (diagonal the
restored edge `3-4`) is the **diamond quad** we must later collapse — shaded in
panel B.
## Step 4 — Build the medial graph M(G) *(panels C, D)*
The medial graph has **one vertex per edge of G** (24 of them) and joins two
edge-medials iff the edges are consecutive around a common face. A 4-colouring
of the triangulation = a proper **3-colouring of M(G)**. The adjacency (each
medial `m(a,b)` listed with its neighbours):
```
m(0,1): (0,2)(0,4)(1,2)(1,4) m(3,7): (0,3)(0,7)(3,8)(7,8)
m(0,2): (0,1)(0,5)(1,2)(2,5) m(3,8): (3,7)(3,9)(7,8)(8,9)
m(0,3): (0,7)(0,9)(3,7)(3,9) m(3,9): (0,3)(0,9)(3,8)(8,9)
m(0,4): (0,1)(0,9)(1,4)(4,9) m(4,5): (1,4)(1,5)(4,8)(5,8)
m(0,5): (0,2)(0,6)(2,5)(5,6) m(4,8): (4,5)(4,9)(5,8)(8,9)
m(0,6): (0,5)(0,7)(5,6)(6,7) m(4,9): (0,4)(0,9)(4,8)(8,9)
m(0,7): (0,3)(0,6)(3,7)(6,7) m(5,6): (0,5)(0,6)(5,8)(6,8)
m(0,9): (0,3)(0,4)(3,9)(4,9) m(5,8): (4,5)(4,8)(5,6)(6,8)
m(1,2): (0,1)(0,2)(1,5)(2,5) m(6,7): (0,6)(0,7)(6,8)(7,8)
m(1,4): (0,1)(0,4)(1,5)(4,5) m(6,8): (5,6)(5,8)(6,7)(7,8)
m(1,5): (1,2)(1,4)(2,5)(4,5) m(7,8): (3,7)(3,8)(6,7)(6,8)
m(2,5): (0,2)(0,5)(1,2)(1,5) m(8,9): (3,8)(3,9)(4,8)(4,9)
```
## Step 5 — Canonical colouring (no 4CT): seam = 3, annuli alternate, root by DFS *(panel C)*
The canonical colouring is assembled from three deterministic ingredients plus
the two control knobs (phase, DFS order):
1. **Every level-edge medial → colour 3 (=2).** The even seam `3-9-4-5-6-7`
becomes **monochromatic 3**:
`m(3,9)=m(4,9)=m(4,5)=m(5,6)=m(6,7)=m(3,7)=2`.
2. **Each non-root annulus alternates {0,1} with a phase bit.** Here there is one
non-root annulus — the hub spokes between levels 1 and 2:
`[(8,9),(3,8),(7,8),(6,8),(5,8),(4,8)]` (length 6). With **phase 0** it is
coloured `0,1,0,1,0,1`:
`m(8,9)=0, m(3,8)=1, m(7,8)=0, m(6,8)=1, m(5,8)=0, m(4,8)=1`.
3. **The root region** — the level-0↔1 spokes plus the three outer-triangle
medials `m(0,1),m(0,2),m(1,2)` — is solved by a small DFS using colour
priority **`(1,0,2)`**.
The resulting proper colouring of M(G):
```
m(0,1)=2 m(0,2)=1 m(0,3)=1 m(0,4)=1 m(0,5)=0 m(0,6)=1 m(0,7)=0 m(0,9)=0
m(1,2)=0 m(1,4)=0 m(1,5)=1 m(2,5)=2 m(3,7)=2 m(3,8)=1 m(3,9)=2 m(4,5)=2
m(4,8)=1 m(4,9)=2 m(5,6)=2 m(5,8)=0 m(6,7)=2 m(6,8)=1 m(7,8)=0 m(8,9)=0
```
This is the "no-4CT" colouring of the **evened** graph — proper because the seam
is even (a monochromatic-3 cycle around even-length annuli is consistent). The
only thing standing between it and a colouring of the *original* G is the
diamond.
## Step 6 — Kempe switch, then collapse the diamond *(panel D)*
To remove `w=9` we restore the diagonal `(3,4)` and recolour. The **degree-4
removal condition** on the quad `3-0-4-8` reads: the opposite-corner medial pairs
`(m_{30}, m_{04})` and `(m_{48}, m_{83})` must each be *distinct*, using ≤2
colours total; the restored edge then takes the third.
Read the four quad medials off the canonical colouring:
```
m(0,3)=1 m(0,4)=1 m(4,8)=1 m(3,8)=1 ALL EQUAL
```
The first pair `(m_{30},m_{04}) = (1,1)` is **not** distinct → `diamond_condition`
returns `None`. **This is the obstruction** the bare canonical colouring hits
(haloed in panel C). It is *not* non-existence — a removable colouring exists; we
just have to reach one by a Kempe switch.
**The switch.** The bounded search picks the **`{1,2}`-Kempe component through
`m(0,3)`**:
```
component = { m(0,3), m(3,7), m(3,8), m(3,9) } (pair {1,2})
```
Swapping colours `1↔2` on this component flips `m(0,3): 1→2` and `m(3,8): 1→2`
(the other two are already in `{1,2}` and toggle within the class). The quad
medials become:
```
m(0,3)=2 m(0,4)=1 m(4,8)=1 m(3,8)=2
```
Now `(m_{30},m_{04}) = (2,1)` distinct ✓ and `(m_{48},m_{83}) = (1,2)` distinct ✓,
two colours `{1,2}` used → `diamond_condition` returns the **third colour 0**.
**Collapse.** Delete `w`, restore edge `(3,4)`, and colour the restored medial
`m(3,4) = 0` (the orange square in panel D). The result is verified to be a
**proper 3-colouring of M(G)** on exactly the original 9 vertices — i.e. a
Tait/4CT colouring of the original triangulation, obtained with no appeal to the
4CT.
---
## Reproduce
```bash
python3 dump_walkthrough.py # prints every step's data verbatim
../../../.venv/bin/python draw_walkthrough.py # the 4-panel figure (repo venv: numpy+matplotlib)
```
The reduction here genuinely exercises a Kempe switch. For larger graphs the
same six steps run with more diamonds (one per odd seam, swept over all sites)
and more phase/colour-order choices; the open difficulty is purely whether some
(site, phase) choice lets every diamond's quad become reducible simultaneously
— see `even_program_findings.md`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

@@ -376,20 +376,23 @@ def order_cycle(verts, adj):
return None
def canonical_coloring(g: Tri, level, outer, rng=None):
"""Canonical colouring of M(g): level-edge medials -> 2 ("colour 3"),
non-root annuli alternate 0,1 (random phases), root region solved by DFS.
Returns (coloring dict, adj) or (None, reason)."""
def coloring_skeleton(g: Tri, level, outer):
"""Phase/colorder-independent parts of the canonical colouring:
adjacency, the base colouring (level-edge medials -> 2, "colour 3"), the
non-root annular medial cycles in a deterministic order, the root cycle,
and the free (root + outer-trio) region. Returns (skel, None) or
(None, reason)."""
adj = medial_adj(g)
col = {}
base = {}
level_edges = [ed for ed in g.edges()
if level[tuple(ed)[0]] == level[tuple(ed)[1]]]
outer_es = {e(outer[0], outer[1]), e(outer[1], outer[2]),
e(outer[2], outer[0])}
for ed in level_edges:
if ed not in outer_es:
col[ed] = 2
base[ed] = 2
comps, _ = annular_cycles(g, level)
nonroot = []
root_comp = None
free = set(outer_es)
for comp in comps:
@@ -405,13 +408,38 @@ def canonical_coloring(g: Tri, level, outer, rng=None):
continue
if len(cyc) % 2 != 0:
return None, f"odd-non-root-annulus(len {len(cyc)})"
phase = rng.randint(0, 1) if rng else 0
for i, m in enumerate(cyc):
col[m] = (i + phase) % 2
nonroot.append(cyc)
if root_comp is None:
return None, "no-root-annulus"
# solve the free region (root annulus + outer trio) by DFS; if that
# fails, grow the defect region one level at a time.
def annulus_key(cyc):
depth = min(min(level[a], level[b]) for a, b in (tuple(ed) for ed in cyc))
return (depth, tuple(sorted(tuple(sorted(tuple(ed))) for ed in cyc)))
nonroot.sort(key=annulus_key)
return {"adj": adj, "base": base, "nonroot": nonroot, "root": root_comp,
"free": free, "outer_es": outer_es}, None
def canonical_coloring_explicit(g: Tri, level, outer, phases, colorder):
"""Canonical colouring with EXPLICIT control knobs:
phases -- tuple of one bit per non-root annulus (in skeleton order):
the {0,1} alternation phase of that annular medial cycle.
colorder -- the colour-priority list [.,.,.] used by the root-region DFS.
Returns (coloring dict, None) or (None, reason)."""
skel, reason = coloring_skeleton(g, level, outer)
if skel is None:
return None, reason
if len(phases) != len(skel["nonroot"]):
return None, "phase-arity-mismatch"
adj, free = skel["adj"], skel["free"]
col = dict(skel["base"])
for i, cyc in enumerate(skel["nonroot"]):
ph = phases[i] & 1
for j, m in enumerate(cyc):
col[m] = (j + ph) % 2
# solve the free region (root annulus + outer trio) by DFS; if that fails,
# grow the defect region one level at a time.
max_level = max(level.values())
for grow in range(0, max_level + 1):
free_now = set(free)
@@ -423,9 +451,6 @@ def canonical_coloring(g: Tri, level, outer, rng=None):
trial = {m: c for m, c in col.items() if m not in free_now}
order = sorted(free_now,
key=lambda m: -sum(1 for x in adj[m] if x in trial))
colorder = [0, 1, 2]
if rng:
rng.shuffle(colorder)
def ok(m, c):
return all(trial.get(x) != c for x in adj[m])
@@ -452,6 +477,20 @@ def canonical_coloring(g: Tri, level, outer, rng=None):
return None, "root-unsolvable"
def canonical_coloring(g: Tri, level, outer, rng=None):
"""Random-phase wrapper over canonical_coloring_explicit (back-compat).
Returns (coloring dict, None) or (None, reason)."""
skel, reason = coloring_skeleton(g, level, outer)
if skel is None:
return None, reason
phases = tuple(rng.randint(0, 1) if rng else 0
for _ in range(len(skel["nonroot"])))
colorder = [0, 1, 2]
if rng:
rng.shuffle(colorder)
return canonical_coloring_explicit(g, level, outer, phases, colorder)
# ---------------------------------------------------------------------------
# Kempe switching + removal conditions.
# ---------------------------------------------------------------------------