dual_decomposition: 4-edge-face criterion, Conj 3.8, cubic contraction theorem

- Conjecture 3.6: add the 4-edge-face criterion as clause (3), with empirical
  table through n=21 (complete, 535,182/535,182 pass) plus partial n=22
  (641,700 colourings, timed out).
- Conjecture 3.8: strengthening with clause (4) on the b,c-Kempe cycle / 3-colour
  alternative on the new face f_n; existential at the witness level. Tested
  through n=18 (13,800/13,800 pass).
- Definition + figure for cubic-graph edge contraction (delete edge, smooth the
  resulting degree-2 endpoints; equivalent to simple contraction in the dual).
- Theorem: cubic contraction across a 4-face preserves 3-edge-colourability when
  the two opposite boundary edges have different colours. Constructive proof:
  the two smoothed-in edges inherit the colour of the w_i pair they absorb, and
  e_1 is recoloured to the third colour.
- Add 2-panel illustration of the theorem's recolouring.
- Trim Remark 3.7 and 3.9 tables to fit within \textwidth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 13:28:15 -04:00
parent 464c524fa1
commit 753af5ffae
11 changed files with 1366 additions and 59 deletions
@@ -0,0 +1,426 @@
"""Test Conjecture 3.8 (strengthening of 3.6 with the {b,c}-Kempe / 3-colour
constraint on the new 4-edge face) on all min-degree-5 triangulations up to
n = 18.
For each (G, F_v, i_red, varphi) with varphi a chord-apex + Kempe coloring,
we look for a witness (F, e_1, e_2) of Conjecture 3.6 (clauses 1-3 including
the 4-edge-face criterion). Then we:
- subdivide e_1, e_2 by X_1, X_2,
- add the new edge X_1 X_2,
- recolour the (subdivided) Kempe cycle alternately starting from merged
so propriety holds and the new edge takes the third colour,
- identify f_n (the 4-edge face containing the new edge), and
- test clause 4:
EITHER partial(f_n) uses all 3 colours,
OR the {b, c}-Kempe cycle through X_1 X_2 has only X_1 X_2 itself in
partial(f_n).
Aggregates per-n.
Run with: sage experiments/check_conj_3_8_scaled.py
"""
from sage.all import Graph
from sage.graphs.graph_generators import graphs
import sys
import time
def dual_of(G):
G.is_planar(set_embedding=True)
faces = G.faces()
edge_to_faces = {}
for fi, face in enumerate(faces):
for u, v in face:
edge_to_faces.setdefault(frozenset((u, v)), []).append(fi)
return Graph(
[(fs[0], fs[1]) for fs in edge_to_faces.values() if len(fs) == 2],
multiedges=False, loops=False)
def apply_reduction(G, face, i, v_n_label):
boundary = [u for (u, v) in face]
if len(set(boundary)) != 5: return None
A = []
for B_k in boundary:
outer = [w for w in G.neighbor_iterator(B_k) if w not in boundary]
if len(outer) != 1: return None
A.append(outer[0])
if len(set(A)) != 5 or A[(i+3) % 5] == A[(i+4) % 5]: return None
H = G.copy()
for v in boundary: H.delete_vertex(v)
H.add_vertex(v_n_label)
side_0 = (v_n_label, A[i])
spike = (v_n_label, A[(i+1) % 5])
side_1 = (v_n_label, A[(i+2) % 5])
merged = (A[(i+3) % 5], A[(i+4) % 5])
H.add_edges([side_0, spike, side_1, merged])
if H.has_multiple_edges() or H.has_loops(): return None
if not H.is_planar(set_embedding=True): return None
if not all(H.degree(v) == 3 for v in H.vertex_iterator()): return None
return {'H': H, 'named': {'spike': frozenset(spike),
'side_0': frozenset(side_0), 'side_1': frozenset(side_1),
'merged': frozenset(merged)}}
def proper_3_edge_colorings(G):
edges = list(G.edges(labels=False))
n = len(edges)
adj = [[] for _ in range(n)]
for i in range(n):
u, v = edges[i][0], edges[i][1]
for j in range(i):
x, y = edges[j][0], edges[j][1]
if u in (x, y) or v in (x, y):
adj[i].append(j); adj[j].append(i)
coloring = [-1] * n
results = []
def back(k):
if k == n:
results.append(tuple(coloring)); return
for c in range(3):
if all(coloring[j] != c for j in adj[k]):
coloring[k] = c
back(k + 1)
coloring[k] = -1
back(0)
return edges, results
def kempe_cycle_set(edges, coloring, start_idx, color_pair):
a, b = color_pair
if coloring[start_idx] not in (a, b): return set()
in_sub = set(i for i in range(len(edges)) if coloring[i] in (a, b))
visited = {start_idx}; stack = [start_idx]
while stack:
cur = stack.pop()
u, v = edges[cur][0], edges[cur][1]
for j in in_sub:
if j in visited: continue
x, y = edges[j][0], edges[j][1]
if u in (x, y) or v in (x, y):
visited.add(j); stack.append(j)
return visited
def edge_idx(edges, e_frozen):
for i, e in enumerate(edges):
if frozenset((e[0], e[1])) == e_frozen:
return i
return None
def trace_kempe_cycle(edges, col_list, start_idx, color_pair):
cycle_set = kempe_cycle_set(edges, col_list, start_idx, color_pair)
incident_at = {}
for ei in cycle_set:
u, v = edges[ei][0], edges[ei][1]
incident_at.setdefault(u, []).append(ei)
incident_at.setdefault(v, []).append(ei)
start_u, start_v = edges[start_idx][0], edges[start_idx][1]
walk = [(start_idx, start_v)]
cur_e = start_idx
cur_leave = start_v
while True:
nbrs = incident_at[cur_leave]
if len(nbrs) != 2: break
nxt = nbrs[0] if nbrs[1] == cur_e else nbrs[1]
u2, v2 = edges[nxt][0], edges[nxt][1]
leave_next = v2 if u2 == cur_leave else u2
if nxt == start_idx: break
walk.append((nxt, leave_next))
cur_e = nxt
cur_leave = leave_next
return walk
def matches_chord_apex_kempe(edges, col, named):
idx = {role: edge_idx(edges, ns) for role, ns in named.items()}
if any(v is None for v in idx.values()): return False
c_spike = col[idx['spike']]
c_merged = col[idx['merged']]
if c_spike != c_merged: return False
c_s0 = col[idx['side_0']]; c_s1 = col[idx['side_1']]
kc0 = kempe_cycle_set(edges, col, idx['spike'], (c_spike, c_s0))
if idx['side_0'] not in kc0 or idx['merged'] not in kc0: return False
kc1 = kempe_cycle_set(edges, col, idx['spike'], (c_spike, c_s1))
if idx['side_1'] not in kc1 or idx['merged'] not in kc1: return False
return True
def find_all_36_witnesses(H, edges, col_list, named):
"""Yield every (F, e_1, e_2) satisfying clauses 1-3 of Conjecture 3.6."""
merged_idx = edge_idx(edges, named['merged'])
c_merged = col_list[merged_idx]
kempe_cycles = []
for c_prime in range(3):
if c_prime == c_merged: continue
kc = kempe_cycle_set(edges, col_list, merged_idx, (c_merged, c_prime))
kempe_cycles.append((c_prime, kc))
H.is_planar(set_embedding=True)
out = []
for face in H.faces():
face_edge_indices = []
for u, v in face:
ei = edge_idx(edges, frozenset((u, v)))
if ei is not None:
face_edge_indices.append(ei)
n_face = len(face_edge_indices)
for i in range(n_face):
for j in range(i + 1, n_face):
e1, e2 = face_edge_indices[i], face_edge_indices[j]
if e1 == merged_idx or e2 == merged_idx: continue
if col_list[e1] != col_list[e2]: continue
gap_a = (j - i - 1)
gap_b = (n_face - 2 - gap_a)
if gap_a != 1 and gap_b != 1: continue
for c_prime, kc in kempe_cycles:
if e1 in kc and e2 in kc:
if gap_a == 1:
e_F = face_edge_indices[i + 1]
else:
e_F = face_edge_indices[(j + 1) % n_face]
out.append({
'face_edges': face_edge_indices,
'e1': e1, 'e2': e2, 'e_F': e_F,
'kc_color_pair': (c_merged, c_prime),
})
return out
def check_clause_4(H, edges, col_list, named, witness):
"""Construct the modified H + recoloring, identify f_n, check clause 4."""
e1, e2 = witness['e1'], witness['e2']
e_F = witness['e_F']
a = col_list[e1] # color of e_1 = e_2
merged_idx = edge_idx(edges, named['merged'])
cyc_a, cyc_b = witness['kc_color_pair'] # = (c_merged, c_other)
# a is the colour of e_1, e_2 on the Kempe cycle. By chord-apex,
# c_merged is the color of merged = the color of all "a"-edges on the
# cycle. The cycle alternates between c_merged = a and c_other = b.
b = cyc_b
c = ({0, 1, 2} - {a, b}).pop()
# Subdivided cycle K' alternates blue/green starting from merged = blue
walk = trace_kempe_cycle(edges, col_list, merged_idx, (cyc_a, cyc_b))
walk_edges = [w[0] for w in walk]
leave_at = [w[1] for w in walk]
# K' position counter. For each old cycle edge e in cyclic order, position
# increments by 1 normally; for e1/e2, increments by 2 (two halves).
# For e1, we record (entry_half_color, exit_half_color) where entry_half
# is the half adjacent to entry_vertex and exit_half adjacent to leaving.
e1_entry_color = e1_exit_color = None
e2_entry_color = e2_exit_color = None
e1_entry_vertex = e1_exit_vertex = None
e2_entry_vertex = e2_exit_vertex = None
other_new_colors = {} # ei -> new color (for cycle edges other than e1/e2)
pos = 0
for k, ei in enumerate(walk_edges):
leaving = leave_at[k]
u, v = edges[ei][0], edges[ei][1]
entry = v if leaving == u else u
if ei == e1:
c_entry = cyc_b if pos % 2 == 0 else cyc_a
pos += 1
c_exit = cyc_b if pos % 2 == 0 else cyc_a
pos += 1
e1_entry_color = c_entry; e1_exit_color = c_exit
e1_entry_vertex = entry; e1_exit_vertex = leaving
elif ei == e2:
c_entry = cyc_b if pos % 2 == 0 else cyc_a
pos += 1
c_exit = cyc_b if pos % 2 == 0 else cyc_a
pos += 1
e2_entry_color = c_entry; e2_exit_color = c_exit
e2_entry_vertex = entry; e2_exit_vertex = leaving
else:
nc = cyc_b if pos % 2 == 0 else cyc_a
other_new_colors[ei] = nc
pos += 1
# Identify which half of e1 is on f_n: it's the half adjacent to e_F.
# e_F has 2 endpoints; one is shared with e1, one with e2.
e_F_endpoints = set(edges[e_F])
e1_endpoints = set(edges[e1])
e2_endpoints = set(edges[e2])
shared_e1_eF = (e_F_endpoints & e1_endpoints).pop()
shared_e2_eF = (e_F_endpoints & e2_endpoints).pop()
# The half of e1 on f_n connects X_1 to shared_e1_eF. So if shared_e1_eF
# equals e1_entry_vertex, the half on f_n is the "entry half"; else the
# "exit half".
if shared_e1_eF == e1_entry_vertex:
e1_h_color = e1_entry_color
else:
e1_h_color = e1_exit_color
if shared_e2_eF == e2_entry_vertex:
e2_h_color = e2_entry_color
else:
e2_h_color = e2_exit_color
# Determine e_F's new colour: if e_F is on the Kempe cycle (other_new_colors)
# use that, else original.
if e_F in other_new_colors:
e_F_color = other_new_colors[e_F]
else:
e_F_color = col_list[e_F]
# f_n's 4 edge colors: [new edge X_1-X_2 = c, e1_h, e_F, e2_h]
fn_colors = [c, e1_h_color, e_F_color, e2_h_color]
fn_distinct = set(fn_colors)
uses_3_colors = (len(fn_distinct) == 3)
if uses_3_colors:
return True # clause 4(i) satisfied
# Otherwise, check clause 4(ii): the {b,c}-Kempe cycle through the new
# edge has only X_1-X_2 in f_n's boundary.
# We need to actually build the modified graph and trace this cycle.
return check_clause_4_kempe_part(H, edges, col_list, named, witness,
a, b, c, e1_h_color, e2_h_color,
other_new_colors,
e1_entry_vertex, e1_exit_vertex,
e2_entry_vertex, e2_exit_vertex,
e1_entry_color, e1_exit_color,
e2_entry_color, e2_exit_color)
def check_clause_4_kempe_part(H, edges, col_list, named, witness, a, b, c,
e1_h_color, e2_h_color, other_new_colors,
e1_ev, e1_xv, e2_ev, e2_xv,
e1_ec, e1_xc, e2_ec, e2_xc):
"""Build modified H' and check whether the {b,c}-Kempe cycle through X1-X2
has only that one edge in partial(f_n)."""
e1 = witness['e1']; e2 = witness['e2']; e_F = witness['e_F']
H2 = H.copy()
X1 = max(v for v in H.vertices(sort=False) if isinstance(v, int)) + 1
X2 = X1 + 1
H2.add_vertex(X1); H2.add_vertex(X2)
e1_uv = tuple(edges[e1]); e2_uv = tuple(edges[e2])
H2.delete_edge(e1_uv); H2.delete_edge(e2_uv)
H2.add_edges([(e1_uv[0], X1), (X1, e1_uv[1]),
(e2_uv[0], X2), (X2, e2_uv[1]),
(X1, X2)])
# Build coloring for H2
new_coloring = {}
# Copy non-modified edges: original color (unless in other_new_colors)
for ei, c0 in enumerate(col_list):
if ei == e1 or ei == e2: continue
e_fs = frozenset(edges[ei])
if ei in other_new_colors:
new_coloring[e_fs] = other_new_colors[ei]
else:
new_coloring[e_fs] = c0
# Halves of e1, e2
new_coloring[frozenset((e1_ev, X1))] = e1_ec
new_coloring[frozenset((X1, e1_xv))] = e1_xc
new_coloring[frozenset((e2_ev, X2))] = e2_ec
new_coloring[frozenset((X2, e2_xv))] = e2_xc
new_coloring[frozenset((X1, X2))] = c
# Determine f_n's edges (in H2): new edge X1-X2, the half of e1 adjacent
# to e_F's shared vertex with e1, e_F itself, and the half of e2 adjacent
# to e_F's shared vertex with e2.
e_F_endpoints = set(edges[e_F])
e1_endpoints = set(edges[e1])
e2_endpoints = set(edges[e2])
shared_e1_eF = (e_F_endpoints & e1_endpoints).pop()
shared_e2_eF = (e_F_endpoints & e2_endpoints).pop()
fn_edges = {
frozenset((X1, X2)),
frozenset((X1, shared_e1_eF)),
frozenset(edges[e_F]),
frozenset((X2, shared_e2_eF)),
}
# Build edge list of H2 + coloring list
H2_edges = list(H2.edges(labels=False))
H2_col_list = [new_coloring[frozenset(e)] for e in H2_edges]
# Sanity: verify phi' is a proper 3-edge-colouring of H2 (the conjecture
# asserts this; if the construction is wrong, abort).
for v in H2.vertex_iterator():
seen = []
for w in H2.neighbor_iterator(v):
seen.append(new_coloring[frozenset((v, w))])
if len(set(seen)) != len(seen):
raise RuntimeError(
f"phi' is not proper at vertex {v}: colors {seen}")
# Trace the {b,c}-Kempe cycle through X1-X2 in H2 using new_coloring
H2_X1X2_idx = None
for i, e in enumerate(H2_edges):
if frozenset(e) == frozenset((X1, X2)):
H2_X1X2_idx = i; break
if H2_X1X2_idx is None:
return False
kc_bc = kempe_cycle_set(H2_edges, H2_col_list, H2_X1X2_idx, (b, c))
# Count edges in kc_bc that are in fn_edges
count = 0
for ei in kc_bc:
if frozenset(H2_edges[ei]) in fn_edges:
count += 1
return count == 1
def main(max_n=18, time_budget_per_n=3600):
rows = []
for n in range(12, max_n + 1):
start = time.time()
try:
triangulations = list(graphs.triangulations(n, minimum_degree=5))
except Exception as ex:
rows.append((n, 0, 0, 0, f"cannot enumerate"))
continue
n_tri = len(triangulations)
total_col = 0
total_pass = 0
timed_out = False
for tri_idx, G in enumerate(triangulations, start=1):
if time.time() - start > time_budget_per_n:
timed_out = True; break
G.is_planar(set_embedding=True)
D = dual_of(G); D.is_planar(set_embedding=True)
for face in D.faces():
if len(face) != 5: continue
if time.time() - start > time_budget_per_n:
timed_out = True; break
for i_red in range(5):
res = apply_reduction(D, face, i_red, 9999)
if res is None: continue
H = res['H']; named = res['named']
H.is_planar(set_embedding=True)
edges, colorings = proper_3_edge_colorings(H)
cand = [c for c in colorings
if matches_chord_apex_kempe(edges, c, named)]
for col in cand:
witnesses = find_all_36_witnesses(H, edges, list(col),
named)
if not witnesses:
continue
total_col += 1
ok = False
for w in witnesses:
try:
if check_clause_4(H, edges, list(col), named,
w):
ok = True
break
except Exception:
pass
if ok:
total_pass += 1
elapsed = time.time() - start
status = (f"TIMEOUT ({elapsed:.0f}s)" if timed_out
else f"complete ({elapsed:.0f}s)")
rows.append((n, n_tri, total_col, total_pass, status))
print(f"n={n}: {n_tri} tri, {total_col} cand witnesses, "
f"{total_pass} pass clause 4, {status}")
sys.stdout.flush()
print()
print("=" * 70)
print(f"{'n':>3} {'#tri':>5} {'#witness':>10} {'#pass_cl4':>10} {'status':>25}")
print("-" * 70)
for n, n_tri, n_col, n_pass, status in rows:
print(f"{n:>3} {n_tri:>5} {n_col:>10} {n_pass:>10} {status:>25}")
if __name__ == '__main__':
main()
@@ -130,11 +130,12 @@ def matches_chord_apex_kempe(edges, col, named):
def conjecture_holds_for(H, edges, col, named):
"""Returns (F, e1, e2, kc) if some face F has two same-color edges (e1, e2)
and both, together with merged, lie on a Kempe cycle kc. Else None."""
"""Returns (F, e1, e2, kc) if some face F of H has two same-colour edges
(e1, e2), neither equal to merged, both on a Kempe cycle kc through
merged, AND exactly one edge of partial F lies between e1 and e2 along
one of the two arcs of partial F. Else None."""
merged_idx = edge_idx(edges, named['merged'])
c_merged = col[merged_idx]
# All Kempe cycles through merged (one per color pair (c_merged, c'))
kempe_cycles = []
for c_prime in range(3):
if c_prime == c_merged: continue
@@ -146,10 +147,17 @@ def conjecture_holds_for(H, edges, col, named):
ei = edge_idx(edges, frozenset((u, v)))
if ei is not None:
face_edge_indices.append(ei)
for i in range(len(face_edge_indices)):
for j in range(i + 1, len(face_edge_indices)):
n_face = len(face_edge_indices)
for i in range(n_face):
for j in range(i + 1, n_face):
e1, e2 = face_edge_indices[i], face_edge_indices[j]
if e1 == merged_idx or e2 == merged_idx: continue
if col[e1] != col[e2]: continue
# exactly one edge between e1 and e2 in one arc
gap_a = (j - i - 1)
gap_b = (n_face - 2 - gap_a)
if gap_a != 1 and gap_b != 1:
continue
for kc in kempe_cycles:
if e1 in kc and e2 in kc:
return face, e1, e2, kc
@@ -0,0 +1,209 @@
"""Test Conjecture 3.6 (with the 4-edge-face criterion) across all
min-degree-5 triangulations up to n = 18. Aggregates per-n totals.
Run with: sage experiments/check_conj_face_kempe_scaled.py
"""
from sage.all import Graph
from sage.graphs.graph_generators import graphs
import sys
import time
def dual_of(G):
G.is_planar(set_embedding=True)
faces = G.faces()
edge_to_faces = {}
for fi, face in enumerate(faces):
for u, v in face:
edge_to_faces.setdefault(frozenset((u, v)), []).append(fi)
return Graph(
[(fs[0], fs[1]) for fs in edge_to_faces.values() if len(fs) == 2],
multiedges=False, loops=False)
def apply_reduction(G, face, i, v_n_label):
boundary = [u for (u, v) in face]
if len(set(boundary)) != 5: return None
A = []
for B_k in boundary:
outer = [w for w in G.neighbor_iterator(B_k) if w not in boundary]
if len(outer) != 1: return None
A.append(outer[0])
if len(set(A)) != 5 or A[(i+3) % 5] == A[(i+4) % 5]: return None
H = G.copy()
for v in boundary: H.delete_vertex(v)
H.add_vertex(v_n_label)
side_0 = (v_n_label, A[i])
spike = (v_n_label, A[(i+1) % 5])
side_1 = (v_n_label, A[(i+2) % 5])
merged = (A[(i+3) % 5], A[(i+4) % 5])
H.add_edges([side_0, spike, side_1, merged])
if H.has_multiple_edges() or H.has_loops(): return None
if not H.is_planar(set_embedding=True): return None
if not all(H.degree(v) == 3 for v in H.vertex_iterator()): return None
return {
'H': H,
'named': {
'spike': frozenset(spike),
'side_0': frozenset(side_0),
'side_1': frozenset(side_1),
'merged': frozenset(merged),
},
}
def proper_3_edge_colorings(G):
edges = list(G.edges(labels=False))
n = len(edges)
adj = [[] for _ in range(n)]
for i in range(n):
u, v = edges[i][0], edges[i][1]
for j in range(i):
x, y = edges[j][0], edges[j][1]
if u in (x, y) or v in (x, y):
adj[i].append(j); adj[j].append(i)
coloring = [-1] * n
results = []
def back(k):
if k == n:
results.append(tuple(coloring)); return
for c in range(3):
if all(coloring[j] != c for j in adj[k]):
coloring[k] = c
back(k + 1)
coloring[k] = -1
back(0)
return edges, results
def kempe_cycle(edges, coloring, start_idx, color_pair):
a, b = color_pair
if coloring[start_idx] not in (a, b):
return set()
in_sub = set(i for i in range(len(edges)) if coloring[i] in (a, b))
visited = {start_idx}; stack = [start_idx]
while stack:
cur = stack.pop()
u, v = edges[cur][0], edges[cur][1]
for j in in_sub:
if j in visited:
continue
x, y = edges[j][0], edges[j][1]
if u in (x, y) or v in (x, y):
visited.add(j); stack.append(j)
return visited
def edge_idx(edges, e_frozen):
for i, e in enumerate(edges):
if frozenset((e[0], e[1])) == e_frozen:
return i
return None
def matches_chord_apex_kempe(edges, col, named):
idx = {role: edge_idx(edges, ns) for role, ns in named.items()}
if any(v is None for v in idx.values()): return False
c_spike = col[idx['spike']]
c_merged = col[idx['merged']]
if c_spike != c_merged: return False
c_s0 = col[idx['side_0']]; c_s1 = col[idx['side_1']]
kc0 = kempe_cycle(edges, col, idx['spike'], (c_spike, c_s0))
if idx['side_0'] not in kc0 or idx['merged'] not in kc0: return False
kc1 = kempe_cycle(edges, col, idx['spike'], (c_spike, c_s1))
if idx['side_1'] not in kc1 or idx['merged'] not in kc1: return False
return True
def conjecture_holds_for(H, edges, col, named):
"""Full Conjecture 3.6 check: face F with two same-colour edges e1, e2
(neither equal to merged), both on a common Kempe cycle through merged,
and exactly one edge of partial F lies between them in one arc."""
merged_idx = edge_idx(edges, named['merged'])
c_merged = col[merged_idx]
kempe_cycles = []
for c_prime in range(3):
if c_prime == c_merged: continue
kc = kempe_cycle(edges, col, merged_idx, (c_merged, c_prime))
kempe_cycles.append(kc)
for face in H.faces():
face_edge_indices = []
for u, v in face:
ei = edge_idx(edges, frozenset((u, v)))
if ei is not None:
face_edge_indices.append(ei)
n_face = len(face_edge_indices)
for i in range(n_face):
for j in range(i + 1, n_face):
e1, e2 = face_edge_indices[i], face_edge_indices[j]
if e1 == merged_idx or e2 == merged_idx: continue
if col[e1] != col[e2]: continue
gap_a = (j - i - 1)
gap_b = (n_face - 2 - gap_a)
if gap_a != 1 and gap_b != 1:
continue
for kc in kempe_cycles:
if e1 in kc and e2 in kc:
return True
return False
def main(max_n=22, time_budget_per_n=1800):
rows = []
for n in range(12, max_n + 1):
start = time.time()
try:
triangulations = list(graphs.triangulations(n, minimum_degree=5))
except Exception as ex:
print(f"n={n}: cannot enumerate: {ex}")
rows.append((n, None, None, None, 'cannot enumerate'))
continue
n_tri = len(triangulations)
total_col = 0
total_pass = 0
timed_out = False
for tri_idx, G in enumerate(triangulations, start=1):
if time.time() - start > time_budget_per_n:
timed_out = True
break
G.is_planar(set_embedding=True)
D = dual_of(G); D.is_planar(set_embedding=True)
for face in D.faces():
if len(face) != 5: continue
if time.time() - start > time_budget_per_n:
timed_out = True
break
for i_red in range(5):
res = apply_reduction(D, face, i_red, 9999)
if res is None: continue
H = res['H']; named = res['named']
H.is_planar(set_embedding=True)
edges, colorings = proper_3_edge_colorings(H)
cand = [c for c in colorings
if matches_chord_apex_kempe(edges, c, named)]
for col in cand:
total_col += 1
if conjecture_holds_for(H, edges, col, named):
total_pass += 1
elapsed = time.time() - start
status = (f"TIMEOUT after {elapsed:.0f}s" if timed_out
else f"complete ({elapsed:.0f}s)")
rows.append((n, n_tri, total_col, total_pass, status))
print(f"n={n}: {n_tri} tri, {total_col} colorings, "
f"{total_pass} pass, {status}")
sys.stdout.flush()
print()
print("=" * 60)
print(f"{'n':>3} {'#tri':>5} {'#col':>10} {'#pass':>10} {'status':>20}")
print("-" * 60)
for n, n_tri, n_col, n_pass, status in rows:
n_tri_s = str(n_tri) if n_tri is not None else '-'
n_col_s = str(n_col) if n_col is not None else '-'
n_pass_s = str(n_pass) if n_pass is not None else '-'
print(f"{n:>3} {n_tri_s:>5} {n_col_s:>10} {n_pass_s:>10} {status:>20}")
if __name__ == '__main__':
main()
@@ -0,0 +1,122 @@
"""Draw a 3-panel illustration of cubic-graph edge contraction:
(1) the original cubic graph fragment with edge e = uv highlighted;
(2) after deleting e (u, v are degree-2);
(3) after smoothing u, v (gone, replaced by single edges).
Produces fig_cubic_edge_contraction.png.
"""
import os
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch
OUT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DARK = '#374151'
GRAY = '#9ca3af'
HIGHLIGHT = '#dc2626' # the edge being contracted (panel 1)
GHOST = '#fca5a5' # removed edges (panel 2)
DEG2 = '#f59e0b' # degree-2 vertices (panel 2)
NEW = '#2563eb' # smoothed-in new edges (panel 3)
# Positions: u centered at (-1, 0), v at (1, 0); their outer neighbors angled.
pos = {
'u': (-1.0, 0.0),
'v': ( 1.0, 0.0),
'a': (-2.2, 1.0),
'b': (-2.2, -1.0),
'c': ( 2.2, 1.0),
'd': ( 2.2, -1.0),
}
def draw_vertex(ax, p, color, size=110, label=None, label_offset=(0, 0.22)):
ax.scatter([p[0]], [p[1]], s=size, color=color, zorder=4)
if label is not None:
ax.text(p[0] + label_offset[0], p[1] + label_offset[1], label,
ha='center', va='center', fontsize=12, zorder=5,
color=DARK)
def draw_edge(ax, p, q, color, lw=2.0, ls='-', zorder=2):
ax.plot([p[0], q[0]], [p[1], q[1]], color=color, lw=lw, ls=ls,
zorder=zorder, solid_capstyle='round')
def panel_before(ax):
# Outer edges (gray)
for (x, y) in [('a', 'u'), ('b', 'u'), ('c', 'v'), ('d', 'v')]:
draw_edge(ax, pos[x], pos[y], DARK, lw=2.0)
# The highlighted edge e = uv
draw_edge(ax, pos['u'], pos['v'], HIGHLIGHT, lw=3.2)
# Vertices
for v in ('a', 'b', 'c', 'd'):
draw_vertex(ax, pos[v], DARK, size=60)
draw_vertex(ax, pos['u'], DARK, size=120, label='$u$',
label_offset=(-0.05, 0.28))
draw_vertex(ax, pos['v'], DARK, size=120, label='$v$',
label_offset=(0.05, 0.28))
# Label on the edge
mid = ((pos['u'][0] + pos['v'][0]) / 2,
(pos['u'][1] + pos['v'][1]) / 2)
ax.text(mid[0], mid[1] + 0.25, '$e$', ha='center', va='center',
fontsize=13, color=HIGHLIGHT, zorder=5)
ax.set_title('(1) cubic plane graph with edge $e = uv$',
fontsize=11, color=DARK, pad=8)
def panel_after_delete(ax):
# Outer edges (gray)
for (x, y) in [('a', 'u'), ('b', 'u'), ('c', 'v'), ('d', 'v')]:
draw_edge(ax, pos[x], pos[y], DARK, lw=2.0)
# Ghost the deleted edge
draw_edge(ax, pos['u'], pos['v'], GHOST, lw=2.0, ls=':')
# Vertices: u, v are now degree-2 (highlighted color)
for v in ('a', 'b', 'c', 'd'):
draw_vertex(ax, pos[v], DARK, size=60)
draw_vertex(ax, pos['u'], DEG2, size=140, label='$u$',
label_offset=(-0.05, 0.32))
draw_vertex(ax, pos['v'], DEG2, size=140, label='$v$',
label_offset=(0.05, 0.32))
ax.set_title('(2) delete $e$: $u, v$ now have degree $2$',
fontsize=11, color=DARK, pad=8)
def panel_after_smooth(ax):
# The smoothed-in new edges
draw_edge(ax, pos['a'], pos['b'], NEW, lw=3.0)
draw_edge(ax, pos['c'], pos['d'], NEW, lw=3.0)
# Outer vertices remain
for v in ('a', 'b', 'c', 'd'):
draw_vertex(ax, pos[v], DARK, size=60)
# u, v are gone — show their former positions as faint markers
ax.scatter([pos['u'][0], pos['v'][0]], [pos['u'][1], pos['v'][1]],
s=140, facecolors='none', edgecolors=GRAY, lw=1.0,
linestyles='--', zorder=3)
ax.text(pos['u'][0], pos['u'][1] + 0.32, '$u$ gone',
ha='center', va='center', fontsize=9, color=GRAY)
ax.text(pos['v'][0], pos['v'][1] + 0.32, '$v$ gone',
ha='center', va='center', fontsize=9, color=GRAY)
ax.set_title('(3) smooth $u, v$: their incident edges merge',
fontsize=11, color=DARK, pad=8)
def main():
fig, axes = plt.subplots(1, 3, figsize=(13.5, 4.2))
for ax in axes:
ax.set_xlim(-3.0, 3.0)
ax.set_ylim(-1.7, 1.7)
ax.set_aspect('equal')
ax.axis('off')
panel_before(axes[0])
panel_after_delete(axes[1])
panel_after_smooth(axes[2])
plt.subplots_adjust(left=0.02, right=0.98, top=0.92, bottom=0.04,
wspace=0.05)
out = os.path.join(OUT_DIR, 'fig_cubic_edge_contraction.png')
plt.savefig(out, dpi=180, bbox_inches='tight')
print(f"wrote {out}")
if __name__ == '__main__':
main()
@@ -0,0 +1,205 @@
"""Two-panel illustration of Theorem (cubic contraction across a 4-face).
Left: H near the 4-face, with the forced 3-edge-colouring
e_0=a, e_1=b, e_2=e_3=c, w_0=w_1=b, w_2=w_3=a.
Right: H' after cubic-graph edge contraction on e_0, with the new colouring
(e_2', e_3' both b; e_1 recoloured to c; everything else unchanged).
Produces fig_thm_cubic_contraction_4face.png.
"""
import os
import matplotlib.pyplot as plt
OUT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DARK = '#374151'
GRAY = '#9ca3af'
GHOST = '#fca5a5'
DEG2 = '#f59e0b'
# Colour code: a=orange-ish, b=blue, c=green. Chosen colourblind-friendly.
COL_A = '#ea580c' # 'a'
COL_B = '#2563eb' # 'b'
COL_C = '#16a34a' # 'c'
# Positions of the 4-face vertices and their external neighbours.
pos = {
'v0': (0.0, 0.0),
'v1': (2.4, 0.0),
'v2': (2.4, 2.4),
'v3': (0.0, 2.4),
'u0': (-1.5, -0.8),
'u1': ( 3.9, -0.8),
'u2': ( 3.9, 3.2),
'u3': (-1.5, 3.2),
}
def draw_edge(ax, p, q, color, lw=2.4, ls='-', zorder=2):
ax.plot([p[0], q[0]], [p[1], q[1]], color=color, lw=lw, ls=ls,
zorder=zorder, solid_capstyle='round')
def draw_vertex(ax, p, color=DARK, size=70, zorder=4):
ax.scatter([p[0]], [p[1]], s=size, color=color, zorder=zorder)
def label_vertex(ax, p, text, offset=(0.0, 0.28), fontsize=12, color=DARK):
ax.text(p[0] + offset[0], p[1] + offset[1], text,
ha='center', va='center', fontsize=fontsize, color=color,
zorder=5)
def label_edge(ax, p, q, text, offset=(0.0, 0.0), color=DARK, fontsize=11):
mid = ((p[0] + q[0]) / 2 + offset[0], (p[1] + q[1]) / 2 + offset[1])
ax.text(mid[0], mid[1], text, ha='center', va='center',
fontsize=fontsize, color=color, zorder=5,
bbox=dict(boxstyle='round,pad=0.15', facecolor='white',
edgecolor='none', alpha=0.85))
def shade_face(ax, vs, color='#fef9c3', alpha=0.7):
xs = [p[0] for p in vs] + [vs[0][0]]
ys = [p[1] for p in vs] + [vs[0][1]]
ax.fill(xs, ys, color=color, alpha=alpha, zorder=1)
def panel_before(ax):
# 4-face shading
shade_face(ax, [pos['v0'], pos['v1'], pos['v2'], pos['v3']])
# Face label
ax.text(1.2, 1.2, '$f$', ha='center', va='center', fontsize=14,
color=GRAY, style='italic', zorder=2)
# Face edges
draw_edge(ax, pos['v0'], pos['v1'], COL_A) # e_0 = a
draw_edge(ax, pos['v2'], pos['v3'], COL_B) # e_1 = b
draw_edge(ax, pos['v1'], pos['v2'], COL_C) # e_2 = c
draw_edge(ax, pos['v3'], pos['v0'], COL_C) # e_3 = c
# External edges
draw_edge(ax, pos['v0'], pos['u0'], COL_B) # w_0 = b
draw_edge(ax, pos['v1'], pos['u1'], COL_B) # w_1 = b
draw_edge(ax, pos['v2'], pos['u2'], COL_A) # w_2 = a
draw_edge(ax, pos['v3'], pos['u3'], COL_A) # w_3 = a
# Vertices
for v in ('v0', 'v1', 'v2', 'v3'):
draw_vertex(ax, pos[v], DARK, size=90)
for u in ('u0', 'u1', 'u2', 'u3'):
draw_vertex(ax, pos[u], DARK, size=60)
# Labels
label_vertex(ax, pos['v0'], '$v_0$', offset=(-0.20, -0.25))
label_vertex(ax, pos['v1'], '$v_1$', offset=( 0.20, -0.25))
label_vertex(ax, pos['v2'], '$v_2$', offset=( 0.20, 0.25))
label_vertex(ax, pos['v3'], '$v_3$', offset=(-0.20, 0.25))
label_vertex(ax, pos['u0'], '$u_0$', offset=(-0.25, 0.00))
label_vertex(ax, pos['u1'], '$u_1$', offset=( 0.25, 0.00))
label_vertex(ax, pos['u2'], '$u_2$', offset=( 0.25, 0.00))
label_vertex(ax, pos['u3'], '$u_3$', offset=(-0.25, 0.00))
# Edge labels with colour
label_edge(ax, pos['v0'], pos['v1'], r'$e_0\!=\!a$', offset=(0, -0.18),
color=COL_A)
label_edge(ax, pos['v2'], pos['v3'], r'$e_1\!=\!b$', offset=(0, 0.18),
color=COL_B)
label_edge(ax, pos['v1'], pos['v2'], r'$e_2\!=\!c$', offset=(0.30, 0.0),
color=COL_C)
label_edge(ax, pos['v3'], pos['v0'], r'$e_3\!=\!c$', offset=(-0.30, 0.0),
color=COL_C)
label_edge(ax, pos['v0'], pos['u0'], r'$w_0\!=\!b$',
offset=(-0.05, -0.05), color=COL_B, fontsize=10)
label_edge(ax, pos['v1'], pos['u1'], r'$w_1\!=\!b$',
offset=( 0.05, -0.05), color=COL_B, fontsize=10)
label_edge(ax, pos['v2'], pos['u2'], r'$w_2\!=\!a$',
offset=( 0.05, 0.05), color=COL_A, fontsize=10)
label_edge(ax, pos['v3'], pos['u3'], r'$w_3\!=\!a$',
offset=(-0.05, 0.05), color=COL_A, fontsize=10)
ax.set_title('$H$ with proper $3$-edge-colouring $\\varphi$:\n'
'$\\varphi(e_0)=a$, $\\varphi(e_1)=b$ (opposite, different)',
fontsize=11, color=DARK, pad=10)
def panel_after(ax):
# New 'face' shading: now (v_2, v_3) connected via e_1, plus new edges
# form a hexagonal-ish region. Just shade the area lightly.
shade_face(ax,
[pos['u0'], pos['v3'], pos['v2'], pos['u1'],
(pos['u1'][0] + 0.7, pos['u1'][1] - 0.7),
(pos['u0'][0] - 0.7, pos['u0'][1] - 0.7)],
color='#fef9c3', alpha=0.0) # invisible, just spacing
# Ghost the deleted edges (e_0, e_2, e_3, w_0, w_1) and former vertices
draw_edge(ax, pos['v0'], pos['v1'], GHOST, lw=1.5, ls=':') # e_0
draw_edge(ax, pos['v1'], pos['v2'], GHOST, lw=1.5, ls=':') # e_2
draw_edge(ax, pos['v3'], pos['v0'], GHOST, lw=1.5, ls=':') # e_3
draw_edge(ax, pos['v0'], pos['u0'], GHOST, lw=1.5, ls=':') # w_0
draw_edge(ax, pos['v1'], pos['u1'], GHOST, lw=1.5, ls=':') # w_1
# Surviving / recoloured edges
draw_edge(ax, pos['v2'], pos['v3'], COL_C, lw=3.0) # e_1 recoloured to c
draw_edge(ax, pos['v2'], pos['u2'], COL_A) # w_2 = a
draw_edge(ax, pos['v3'], pos['u3'], COL_A) # w_3 = a
# Smoothed-in new edges: e_2' from v_2 to u_1, e_3' from v_3 to u_0
draw_edge(ax, pos['v2'], pos['u1'], COL_B, lw=3.0)
draw_edge(ax, pos['v3'], pos['u0'], COL_B, lw=3.0)
# Vertices: v_0, v_1 removed; show their former positions faintly
for ghost in ('v0', 'v1'):
ax.scatter([pos[ghost][0]], [pos[ghost][1]], s=120,
facecolors='none', edgecolors=GRAY, lw=1.0,
linestyles='--', zorder=3)
for v in ('v2', 'v3'):
draw_vertex(ax, pos[v], DARK, size=90)
for u in ('u0', 'u1', 'u2', 'u3'):
draw_vertex(ax, pos[u], DARK, size=60)
# Labels
ax.text(pos['v0'][0], pos['v0'][1] - 0.30, '$v_0$ gone',
ha='center', va='center', fontsize=9, color=GRAY)
ax.text(pos['v1'][0], pos['v1'][1] - 0.30, '$v_1$ gone',
ha='center', va='center', fontsize=9, color=GRAY)
label_vertex(ax, pos['v2'], '$v_2$', offset=( 0.20, 0.25))
label_vertex(ax, pos['v3'], '$v_3$', offset=(-0.20, 0.25))
label_vertex(ax, pos['u0'], '$u_0$', offset=(-0.25, 0.00))
label_vertex(ax, pos['u1'], '$u_1$', offset=( 0.25, 0.00))
label_vertex(ax, pos['u2'], '$u_2$', offset=( 0.25, 0.00))
label_vertex(ax, pos['u3'], '$u_3$', offset=(-0.25, 0.00))
# Edge labels for the new/recoloured edges
label_edge(ax, pos['v2'], pos['v3'], r'$e_1\!=\!c$', offset=(0, 0.18),
color=COL_C)
label_edge(ax, pos['v2'], pos['u1'], r"$e_2'\!=\!b$",
offset=(0.20, 0.10), color=COL_B)
label_edge(ax, pos['v3'], pos['u0'], r"$e_3'\!=\!b$",
offset=(-0.20, 0.10), color=COL_B)
label_edge(ax, pos['v2'], pos['u2'], r'$w_2\!=\!a$',
offset=( 0.05, 0.05), color=COL_A, fontsize=10)
label_edge(ax, pos['v3'], pos['u3'], r'$w_3\!=\!a$',
offset=(-0.05, 0.05), color=COL_A, fontsize=10)
ax.set_title("$H'$ after cubic contraction of $e_0$:\n"
r"$e_2', e_3'$ get colour $b$; $e_1$ recoloured to $c$",
fontsize=11, color=DARK, pad=10)
def main():
fig, axes = plt.subplots(1, 2, figsize=(13, 5.8))
for ax in axes:
ax.set_xlim(-2.6, 4.8)
ax.set_ylim(-2.0, 4.2)
ax.set_aspect('equal')
ax.axis('off')
panel_before(axes[0])
panel_after(axes[1])
plt.subplots_adjust(left=0.02, right=0.98, top=0.92, bottom=0.04,
wspace=0.05)
out = os.path.join(OUT_DIR, 'fig_thm_cubic_contraction_4face.png')
plt.savefig(out, dpi=180, bbox_inches='tight')
print(f"wrote {out}")
if __name__ == '__main__':
main()