face_monochromatic_pairs: cycle-side splits and shared-vertex transitions

For each chord-apex+Kempe colouring (n in [12, 18]), record:
  (1) (#L, #R) split of c-edge sides along K_b and K_c. #L == #R only
      in 35.43% of colourings (the rest have unbalanced sides --
      consistent with the empirical Heawood non-constancy).
  (2) Ordered sequence of (i_b mod 2, i_c mod 2) parity pairs at
      shared K_b cap K_c vertices in K_b walk order, plus a tally of
      transitions in the 4-state space.

Two clean structural observations on the transition matrix:

(A) i_b parity strictly alternates between consecutive shared
    K_b-vertices. Every transition goes (0, *) -> (1, *) or
    (1, *) -> (0, *); transitions within (0, *) or within (1, *) are
    never observed. So shared positions on K_b alternate even/odd in
    walk order -- the gap on K_b between consecutive shared vertices
    is always odd.

(B) From odd-i_b states, i_c parity must flip too: (1, 0) only
    transitions to (0, 1) and (1, 1) only to (0, 0). From even-i_b
    states, both i_c outcomes occur.

(B) is explained structurally: at an odd-i_b shared vertex K_b leaves
via the a-edge (which is also on K_c), so K_b and K_c traverse the
same edge and K_c advances exactly one step, flipping i_c. At an
even-i_b shared vertex K_b leaves via the b-edge (off K_c), so K_c
advances at its own pace and i_c can be either parity at the next
shared vertex.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:32:07 -04:00
parent 703b523161
commit a29d145cec
@@ -0,0 +1,192 @@
"""Two diagnostics:
(1) Cycle-closure / winding count on K_b and K_c.
For each cycle, count the c-edges (off-cycle) at K-vertices that
lie on the local LEFT vs local RIGHT side of the walking direction.
Under constancy (h constant on V(K_b) U V(K_c)), Lemma A predicts
sides alternate, so #LEFT == #RIGHT == |V(K)|/2 exactly. We tally
the empirical (#LEFT, #RIGHT) split for K_b and K_c.
(2) Ordered sequence of (i_b mod 2, i_c mod 2) parity pairs at shared
vertices, sorted by i_b position. We look at the sequence of
transitions in the 4-state space {(0,0), (0,1), (1,0), (1,1)},
and ask whether the empirical sequences are constrained.
Run with: sage experiments/check_shared_sequence_and_winding.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_3_8_scaled import (
apply_reduction,
proper_3_edge_colorings,
matches_chord_apex_kempe,
trace_kempe_cycle,
edge_idx,
)
from check_heawood_on_kempe import dual_of, heawood_numbers
from check_heawood_local_side import c_edge_local_side
def walk_positions(walk):
pos = {}
for k, (_, leave_v) in enumerate(walk):
if leave_v not in pos:
pos[leave_v] = k
return pos
def collect_cycle_sides(H, walk, edges, col, emb, third_color):
"""For each step k of walk, compute side ('L' or 'R') of the
third_color edge at the leave_v vertex relative to the walking
direction (in_edge = walk[k], out_edge = walk[k+1]).
Returns list of sides indexed by k.
"""
L = len(walk)
sides = []
for k in range(L):
v = walk[k][1]
in_e = edges[walk[k][0]]
out_e = edges[walk[(k + 1) % L][0]]
u_in = in_e[0] if in_e[1] == v else in_e[1]
u_out = out_e[0] if out_e[1] == v else out_e[1]
s = c_edge_local_side(v, third_color, col, edges, emb, u_in, u_out)
sides.append(s)
return sides
def test_one(D):
D.is_planar(set_embedding=True)
n_col = 0
# (1) Side-split distribution: (#L, #R, L_cycle).
kb_split = {}; kc_split = {}
# (2) Ordered sequence of (i_b mod 2, i_c mod 2) at shared verts in
# K_b walk order: sequences (as tuples).
seq_dist = {} # signature tuple -> count
# Aggregate transition counts between the 4 parity states
transitions = {} # (prev_state, curr_state) -> count
for face in D.faces():
if len(face) != 5: continue
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)
emb = H.get_embedding()
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:
n_col += 1
merged_idx = edge_idx(edges, named['merged'])
a = col[merged_idx]
bs = [c for c in range(3) if c != a]
b_color, c_color = bs[0], bs[1]
walk_b = trace_kempe_cycle(edges, col, merged_idx, (a, b_color))
walk_c = trace_kempe_cycle(edges, col, merged_idx, (a, c_color))
pos_b = walk_positions(walk_b)
pos_c = walk_positions(walk_c)
shared = set(pos_b.keys()) & set(pos_c.keys())
# (1)
sides_b = collect_cycle_sides(H, walk_b, edges, col, emb, c_color)
sides_c = collect_cycle_sides(H, walk_c, edges, col, emb, b_color)
Lb_len = len(walk_b); Lc_len = len(walk_c)
Lb = sum(1 for s in sides_b if s == 'L')
Rb = sum(1 for s in sides_b if s == 'R')
Lc = sum(1 for s in sides_c if s == 'L')
Rc = sum(1 for s in sides_c if s == 'R')
kb_split[(Lb, Rb)] = kb_split.get((Lb, Rb), 0) + 1
kc_split[(Lc, Rc)] = kc_split.get((Lc, Rc), 0) + 1
# (2)
seq = []
for k in range(Lb_len):
v = walk_b[k][1]
if v in shared:
seq.append((k % 2, pos_c[v] % 2))
# Signature: count occurrences of each state in order.
sig = tuple(seq)
seq_dist[sig] = seq_dist.get(sig, 0) + 1
# Transitions:
for i in range(len(seq)):
prev = seq[i - 1] # wraps to seq[-1]
curr = seq[i]
transitions[(prev, curr)] = transitions.get(
(prev, curr), 0) + 1
return n_col, kb_split, kc_split, seq_dist, transitions
def main(max_n=18, time_budget_per_n=1800):
print(f"Cycle-side splits and shared-vertex sequence, "
f"n in [12, {max_n}]\n")
grand_col = 0
grand_kb = {}; grand_kc = {}
grand_seq = {}
grand_tr = {}
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_col_n = 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}/{len(triangulations)}")
break
G.is_planar(set_embedding=True)
D = dual_of(G)
ni, kb_i, kc_i, seq_i, tr_i = test_one(D)
n_col_n += ni
for k, v in kb_i.items(): grand_kb[k] = grand_kb.get(k, 0) + v
for k, v in kc_i.items(): grand_kc[k] = grand_kc.get(k, 0) + v
for k, v in seq_i.items(): grand_seq[k] = grand_seq.get(k, 0) + v
for k, v in tr_i.items(): grand_tr[k] = grand_tr.get(k, 0) + v
elapsed = time.time() - start
print(f"n={n}: {n_col_n} col., [{elapsed:.0f}s]")
sys.stdout.flush()
grand_col += n_col_n
print()
print("=" * 78)
print(f"Grand totals (n in [12, {max_n}], {grand_col} colourings)")
print(f"\n K_b (#L, #R) distribution:")
for k, v in sorted(grand_kb.items()):
print(f" {k}: {v}")
print(f"\n K_c (#L, #R) distribution:")
for k, v in sorted(grand_kc.items()):
print(f" {k}: {v}")
# Check: is #L == #R always?
kb_equal = sum(v for (L, R), v in grand_kb.items() if L == R)
kc_equal = sum(v for (L, R), v in grand_kc.items() if L == R)
total_kb = sum(grand_kb.values())
total_kc = sum(grand_kc.values())
print(f"\n K_b: #L == #R in {kb_equal}/{total_kb} "
f"({100*kb_equal/max(1,total_kb):.2f}%)")
print(f" K_c: #L == #R in {kc_equal}/{total_kc} "
f"({100*kc_equal/max(1,total_kc):.2f}%)")
print(f"\n Transition counts in the 4-state parity space "
f"((prev) -> (curr)):")
states = [(0,0), (0,1), (1,0), (1,1)]
for s1 in states:
for s2 in states:
c = grand_tr.get((s1, s2), 0)
print(f" {s1} -> {s2}: {c}")
print(f"\n Top 10 most common shared-sequence signatures (by K_b order):")
top = sorted(grand_seq.items(), key=lambda kv: -kv[1])[:10]
for sig, c in top:
print(f" {sig}: {c}")
if __name__ == '__main__':
main()