Files
math-research/papers/face_monochromatic_pairs/experiments/check_30_residual.py
T
didericis 4ceae9c68a face_monochromatic_pairs: rename check_conj_3_8_scaled → check_conj_final_scaled; add n=21-24 test
Rename the shared helper module to a number-resistant name. Update
all 26 dependent scripts via sed.

Add experiments/test_n_21_to_24.py — extends the empirical check
beyond |V(G)| ≤ 20 to n_G ∈ [21, 24]. Checks per chord-apex+Kempe
colouring:
  (1) h_φ constant on V(K_b)? (counterexample to Corollary 5.4)
  (2) h_φ constant on V(K_b) ∪ V(K_c)? (counterexample to Conj 5.1)
  (3) Deciding face exists?

Writes results incrementally to test_n_21_to_24_results.jsonl (one
JSON line per triangulation, plus n-level and grand summaries).
Emits PROGRESS lines every 10 minutes (default) to stdout for live
monitoring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 08:01:29 -04:00

257 lines
9.8 KiB
Python

"""Verify the structural characterization of the 30 residual
chord-apex+Kempe colourings on which the G'-pentagon fallback
empirically fails (= |S| = 8 AND all G'-pentagons are hit by S).
Hypothesis: these are exactly the colourings on triangulations where
v's cyclic neighbour-degree sequence has the form (5, 7, 7, 5, 5)
(or cyclic shifts), and the reduction index i is chosen so that the
two "7"s land on the flank positions (n_i, n_{i+1}) = (7, 7).
For each of the 30 colourings, report:
- parent triangulation order n_G,
- cyclic degree sequence of v's 5 neighbours,
- reduction index i,
- p_G and # G'-pentagons hit by S,
- the actual deciding face's length and type.
If the hypothesis holds, all 30 are on (5, 7, 7, 5, 5)-type
configurations.
Run with: sage experiments/check_30_residual.py
"""
import os
import sys
import time
from sage.all import Graph
from sage.graphs.graph_generators import graphs
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from check_conj_final_scaled import (
apply_reduction,
proper_3_edge_colorings,
matches_chord_apex_kempe,
kempe_cycle_set,
edge_idx,
)
from check_heawood_on_kempe import dual_of, vertices_of_kempe
def is_g_prime_pentagon(f, named):
if len(f) != 5: return False
fset = {frozenset(e) for e in f}
return not (named['side_0'] in fset or named['side_1'] in fset
or named['spike'] in fset or named['merged'] in fset)
def classify_face(f, named):
fset = {frozenset(e) for e in f}
has_s0 = named['side_0'] in fset
has_s1 = named['side_1'] in fset
has_sp = named['spike'] in fset
has_m = named['merged'] in fset
if has_s0 and has_sp and not has_s1 and not has_m: return 'flank-lower'
if has_sp and has_s1 and not has_s0 and not has_m: return 'flank-upper'
if has_s0 and has_s1 and has_m and not has_sp: return 'outer'
if has_m and not (has_s0 or has_s1 or has_sp): return 'merged'
if not (has_s0 or has_s1 or has_sp or has_m): return 'G-prime'
return 'mixed'
def test_one(D, G_parent):
D.is_planar(set_embedding=True)
G_parent.is_planar(set_embedding=True)
emb_G = G_parent.get_embedding()
examples = [] # 30 residual colourings
# We need to map dual face → parent vertex (= the face's "dual vertex")
# For each dual face we want to identify the parent vertex v.
# The dual face's vertices correspond to triangular faces of G_parent
# incident to v.
# Build a face→parent-vertex map.
parent_faces = G_parent.faces()
face_to_dual_vertex_idx = {}
# The dual vertices are indexed by face number.
# Mapping: parent_face[fi] is a face, and dual vertex fi corresponds.
# We need the reverse: given a degree-5 vertex v in G_parent, its
# corresponding dual face = the face whose vertices are the 5
# parent_face-indices that contain v.
v_to_dual_face = {}
for v in G_parent.vertex_iterator():
if G_parent.degree(v) != 5: continue
# Find the 5 parent_face-indices that contain v
contains = []
for fi, pf in enumerate(parent_faces):
for e in pf:
if v in e:
contains.append(fi)
break
if len(contains) != 5: continue
v_to_dual_face[v] = contains # 5 dual vertex indices forming F_v boundary
for face in D.faces():
if len(face) != 5: continue
face_verts = [e[0] for e in face]
# Identify which parent v this face corresponds to
v_parent = None
for v, dual_verts in v_to_dual_face.items():
if set(face_verts) == set(dual_verts):
v_parent = v
break
if v_parent is None: continue
# Get the cyclic degree sequence of v_parent's neighbours
nbrs_cyclic = emb_G[v_parent]
cyc_degs = [G_parent.degree(u) for u in nbrs_cyclic]
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)]
v_n = 9999
for col in cand:
# Identify bad sub-case (ii.B)
target = {named['side_0'], named['spike']}
lower_flank = None
for f in H.faces():
if target.issubset({frozenset(e) for e in f}):
lower_flank = f; break
if lower_flank is None or len(lower_flank) != 5: continue
arc_verts = [e[0] for e in lower_flank]
if v_n not in arc_verts: continue
k = arc_verts.index(v_n)
cyc = arc_verts[k:] + arc_verts[:k]
A_i = next(iter(named['side_0'] - {v_n}))
A_ip1 = next(iter(named['spike'] - {v_n}))
if cyc[1] == A_i and cyc[4] == A_ip1:
P_1, P_2 = cyc[2], cyc[3]
elif cyc[1] == A_ip1 and cyc[4] == A_i:
P_2, P_1 = cyc[2], cyc[3]
else: continue
merged_idx = edge_idx(edges, named['merged'])
c_col = col[merged_idx]
c_0_col = col[edge_idx(edges, named['side_0'])]
c_1_col = col[edge_idx(edges, named['side_1'])]
e_AiP1 = edge_idx(edges, frozenset((A_i, P_1)))
e_P1P2 = edge_idx(edges, frozenset((P_1, P_2)))
if e_AiP1 is None or e_P1P2 is None: continue
if col[e_AiP1] != c_1_col or col[e_P1P2] != c_0_col:
continue
a = c_col
other = [x for x in range(3) if x != a]
kc_b = kempe_cycle_set(edges, col, merged_idx, (a, other[0]))
kc_c = kempe_cycle_set(edges, col, merged_idx, (a, other[1]))
V_b = vertices_of_kempe(edges, kc_b)
V_c = vertices_of_kempe(edges, kc_c)
V_union = V_b | V_c
S = set(H.vertices()) - V_union
if P_1 in V_union: continue
if len(S) != 8: continue
# Check if hit = p_G = 8 (= all G'-pentagons hit)
p_total = 0
p_hit = 0
for f in H.faces():
if not is_g_prime_pentagon(f, named): continue
p_total += 1
verts = {u for (u, v) in f} | {v for (u, v) in f}
if verts & S: p_hit += 1
if p_total != 8 or p_hit != 8: continue
# This is a residual case. Find the actual deciding face.
deciding_faces = []
for f in H.faces():
verts = {u for (u, v) in f} | {v for (u, v) in f}
if not verts.issubset(V_union): continue
L = len(f)
if L % 3 == 0: continue
deciding_faces.append((classify_face(f, named), L))
# Compute deg sequence around v_parent (cyclic)
examples.append({
'cyc_degs': cyc_degs,
'i_red': i_red,
'deciding_faces': deciding_faces,
'p_total': p_total,
'p_hit': p_hit,
'S_size': len(S),
})
return examples
def main(max_n=20, time_budget_per_n=1800):
print("Verifying the 30 residual chord-apex+Kempe colourings.\n")
grand_examples = []
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})")
continue
n_count = 0
for tri_idx, G in enumerate(triangulations):
if time.time() - start > time_budget_per_n:
print(f" n={n}: timeout at tri {tri_idx}")
break
G.is_planar(set_embedding=True)
D = dual_of(G)
exs = test_one(D, G)
for ex in exs:
ex['n_G'] = n
ex['tri_idx'] = tri_idx
n_count += len(exs)
grand_examples.extend(exs)
elapsed = time.time() - start
print(f"n={n}: {n_count} residual colourings [{elapsed:.0f}s]")
sys.stdout.flush()
print()
print("=" * 70)
print(f"Total residual colourings: {len(grand_examples)}")
# Count by cyclic deg sequence (canonical = min rotation)
def canon(t):
t = tuple(t)
best = t
n = len(t)
for r in range(1, n):
rot = t[r:] + t[:r]
if rot < best: best = rot
# Also reflections
rev = t[::-1]
for r in range(n):
rot = rev[r:] + rev[:r]
if rot < best: best = rot
return best
canon_dist = {}
for ex in grand_examples:
key = canon(ex['cyc_degs'])
canon_dist[key] = canon_dist.get(key, 0) + 1
print("\nCyclic deg sequence distribution (canonical = lex-min "
"over rotations + reflections):")
for k in sorted(canon_dist, key=lambda x: -canon_dist[x]):
c = canon_dist[k]
print(f" {k}: {c}")
# Decision face distribution
df_dist = {}
for ex in grand_examples:
for (t, L) in ex['deciding_faces']:
df_dist[(t, L)] = df_dist.get((t, L), 0) + 1
print("\nDeciding face (type, length) distribution:")
for k in sorted(df_dist, key=lambda x: -df_dist[x]):
t, L = k
print(f" {t}, |f|={L}: {df_dist[k]}")
# Print first 5 examples
print("\nFirst 5 examples:")
for ex in grand_examples[:5]:
print(f" n={ex['n_G']}, tri#{ex['tri_idx']}, i={ex['i_red']}, "
f"cyc_degs={ex['cyc_degs']}, "
f"deciding={ex['deciding_faces']}")
if __name__ == '__main__':
main()