Prove outerplanarity and draft edge-flip resolution algorithm

- Promote Prop 3.1 (outerplanarity of level subgraphs) to Theorem 3.1
  with a proof by contradiction via a BFS-path argument; drop the
  $n \leq 10$ caveat and the now-resolved open question.
- Add Section 5 "An edge-flip resolution algorithm": apex classification
  of $L_k$-edges, bridge lemma, cross-level flip pass, definition of
  tricky-everywhere odd cycles and facial depth (seeded from inner
  faces with $\geq 2$ outer-face edges), and the depth-guided flip
  procedure. Observation 5.5 records empirical termination at
  $n = 9, 10, 11$; Question 5.6 asks if it holds in general.
- Add experiments/depth_monovariant_check.py (sanity check over
  triangulation iso-classes, confirms the count-of-tricky-faces
  monovariant strictly decreases per flip on all 1400 tricky configs
  at $n \leq 11$), viz_cycling.py and debug_cycling.py, and
  cycling_visualization.png illustrating the depth-definition fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 01:20:27 -04:00
parent bd9c46d3e4
commit db245eecea
9 changed files with 992 additions and 112 deletions
@@ -0,0 +1,88 @@
"""Dump one cycling case to see what's going on."""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import networkx as nx
from collections import defaultdict
from triangulation_gen import enumerate_all_triangulations
from level_cycles import compute_levels, level_sources
from depth_monovariant_check import (
faces_of_subgraph, canonical_face, edges_of_face,
apex_levels_for_edge, identify_outer_face, face_depths,
is_both_k_everywhere, tricky_odd_faces, lowest_depth_neighbor_flip,
)
def dump(G, emb, levels, nodes_k, k, source_label):
print(f" source: {source_label}, levels:")
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
for lv in sorted(by_level):
print(f" L_{lv} = {sorted(by_level[lv])}")
print(f" examining L_{k} = {sorted(nodes_k)}")
L_k = G.subgraph(nodes_k)
print(f" L_k edges: {sorted(L_k.edges())}")
faces = faces_of_subgraph(G, emb, nodes_k)
print(f" faces of L_k (inherited embedding):")
for f in faces:
print(f" {f} len={len(f)} canon={canonical_face(f)}")
outer = identify_outer_face(faces, G, emb, levels, k)
print(f" outer face: {outer}")
depths = face_depths(faces, outer)
print(f" depths: {depths}")
print(f" edge-by-edge apex-level pairs:")
for f in faces:
cf = canonical_face(f)
if cf == outer:
continue
if len(f) < 3:
continue
print(f" face {f} (depth={depths.get(cf)}, len={len(f)}):")
for (u, v) in edges_of_face(f):
result = apex_levels_for_edge(G, emb, u, v, levels)
if result is None:
print(f" ({u},{v}): no apex")
continue
(la, lb), (w, x) = result
print(f" ({u},{v}): apexes ({w}@L{la}, {x}@L{lb})")
tricky = tricky_odd_faces(faces, G, emb, levels, k, depths, outer)
print(f" tricky odd faces: {[(f, d) for cf, f, d in tricky]}")
if tricky:
cf_t, face_t, d_t = max(tricky, key=lambda x: x[2])
print(f" picking deepest tricky face: {face_t} at depth {d_t}")
flip = lowest_depth_neighbor_flip(face_t, G, emb, levels, k,
depths, faces, outer)
print(f" flip choice: {flip}")
# n=10, tri_idx=6, source face (0,4,2), k=1
tris = enumerate_all_triangulations(10)
G = tris[6]
ip, emb = nx.check_planarity(G)
print(f"G edges: {sorted(G.edges())}")
source = {0, 4, 2}
levels = compute_levels(G, source)
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
nodes_k = by_level[1]
dump(G, emb, levels, nodes_k, 1, ('face', (0, 4, 2)))
print("\n--- after one flip ---")
flip_choice = None
faces = faces_of_subgraph(G, emb, nodes_k)
outer = identify_outer_face(faces, G, emb, levels, 1)
depths = face_depths(faces, outer)
tricky = tricky_odd_faces(faces, G, emb, levels, 1, depths, outer)
if tricky:
cf_t, face_t, d_t = max(tricky, key=lambda x: x[2])
flip_choice = lowest_depth_neighbor_flip(face_t, G, emb, levels, 1,
depths, faces, outer)
if flip_choice:
u, v, w, x, _ = flip_choice
Gp = G.copy()
Gp.remove_edge(u, v)
Gp.add_edge(w, x)
print(f"Flipped ({u},{v}) -> ({w},{x})")
ip2, emb_p = nx.check_planarity(Gp)
dump(Gp, emb_p, levels, nodes_k, 1, ('face', (0, 4, 2)))
@@ -0,0 +1,369 @@
"""
Sanity check for the facial-depth monovariant on tricky (k,k) configurations.
For each (G, S, k) where L_k has at least one "tricky-everywhere" odd face
(every edge is a (k,k) apex pair):
- Run the proposed algorithm: pick the deepest tricky-everywhere odd face,
flip the edge whose other-side face has min facial depth.
- Iterate until no tricky-everywhere odd face remains, or step budget hits.
- Track multiple candidate monovariants per flip.
Reports counts of (a) terminations within budget, (b) cycle/failure cases,
(c) which monovariant (if any) decreases on every flip.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import networkx as nx
from collections import defaultdict, deque
from triangulation_gen import enumerate_all_triangulations
from level_cycles import (
compute_levels, get_all_faces, inherited_embedding, level_sources
)
# -- helpers ------------------------------------------------------------
def faces_of_subgraph(G, emb_G, nodes_k):
if len(nodes_k) < 3:
return []
L_k = G.subgraph(nodes_k)
if L_k.number_of_edges() < 3:
return []
try:
emb_L = inherited_embedding(emb_G, nodes_k)
return get_all_faces(emb_L)
except Exception:
ip, emb_L = nx.check_planarity(L_k)
if not ip:
return []
return get_all_faces(emb_L)
def canonical_face(face):
rots = [tuple(face[i:] + face[:i]) for i in range(len(face))] \
+ [tuple(face[::-1][i:] + face[::-1][:i]) for i in range(len(face))]
return min(rots)
def edges_of_face(face):
n = len(face)
return [(face[i], face[(i+1) % n]) for i in range(n)]
def apex_levels_for_edge(G, emb_G, u, v, levels):
if not G.has_edge(u, v):
return None
f1 = emb_G.traverse_face(u, v)
f2 = emb_G.traverse_face(v, u)
if len(f1) != 3 or len(f2) != 3:
return None
w = next(x for x in f1 if x != u and x != v)
x = next(y for y in f2 if y != u and y != v)
return (levels.get(w), levels.get(x)), (w, x)
def identify_outer_face(faces, G, emb_G, levels, k):
"""Outer face = the face whose 'outside' connects to lower levels.
Score each face by how many edges have at least one apex at level < k
on the opposite side; pick highest score, longest boundary as tiebreak."""
best = (-1, -1, None)
for face in faces:
score = 0
for (u, v) in edges_of_face(face):
if not G.has_edge(u, v):
continue
f1 = emb_G.traverse_face(u, v)
f2 = emb_G.traverse_face(v, u)
if len(f1) != 3 or len(f2) != 3:
continue
w = next(x for x in f1 if x != u and x != v)
x = next(y for y in f2 if y != u and y != v)
if (levels.get(w, k) < k) or (levels.get(x, k) < k):
score += 1
key = (score, len(face))
if key > (best[0], best[1]):
best = (score, len(face), face)
return canonical_face(best[2]) if best[2] is not None else None
def face_depths(faces, outer_canon):
"""BFS depth in the dual graph from the seed set:
inner faces with >= 2 edges incident to the outer face."""
edge_to_faces = defaultdict(list)
canon_set = set()
for face in faces:
cf = canonical_face(face)
canon_set.add(cf)
for (a, b) in edges_of_face(face):
edge_to_faces[frozenset([a, b])].append(cf)
# Outer-face edges = edges of the outer face.
outer_face_edges = set()
for face in faces:
if canonical_face(face) == outer_canon:
for (a, b) in edges_of_face(face):
outer_face_edges.add(frozenset([a, b]))
break
# Seed set: inner faces with >= 2 outer-face edges.
seeds = []
for face in faces:
cf = canonical_face(face)
if cf == outer_canon:
continue
count = 0
for (a, b) in edges_of_face(face):
if frozenset([a, b]) in outer_face_edges:
count += 1
if count >= 2:
seeds.append(cf)
if not seeds:
# Fall back: no seeds means no "easy" odd face nearby. Return empty.
return {}
D = nx.Graph()
for cf in canon_set:
if cf != outer_canon:
D.add_node(cf)
for e, fs in edge_to_faces.items():
if len(fs) == 2:
f1, f2 = fs
if f1 != outer_canon and f2 != outer_canon:
D.add_edge(f1, f2)
# Multi-source BFS.
depths = {}
queue = deque()
for s in seeds:
if s in D:
depths[s] = 0
queue.append(s)
while queue:
u = queue.popleft()
for v in D.neighbors(u):
if v not in depths:
depths[v] = depths[u] + 1
queue.append(v)
return depths
def is_both_k_everywhere(face, G, emb_G, levels, k):
for (u, v) in edges_of_face(face):
result = apex_levels_for_edge(G, emb_G, u, v, levels)
if result is None:
return False
(la, lb), _ = result
if (la, lb) != (k, k):
return False
return True
def tricky_odd_faces(faces, G, emb_G, levels, k, depths, outer_canon):
"""Return list of (canon, face, depth) for odd faces that are tricky-
everywhere AND have at least one (k,k) edge whose other-side face is
not the outer face (i.e., we can flip toward an inner neighbor)."""
out = []
for face in faces:
cf = canonical_face(face)
if cf == outer_canon:
continue
if len(face) % 2 != 1:
continue
if not is_both_k_everywhere(face, G, emb_G, levels, k):
continue
d = depths.get(cf)
if d is None:
continue
out.append((cf, face, d))
return out
def odd_faces_all(faces, depths, outer_canon):
out = []
for face in faces:
cf = canonical_face(face)
if cf == outer_canon:
continue
if len(face) % 2 != 1:
continue
d = depths.get(cf)
if d is None:
continue
out.append((cf, face, d))
return out
def lowest_depth_neighbor_flip(face, G, emb_G, levels, k, depths, faces,
outer_canon):
"""Among (k,k) edges of `face`, return the flip toward the lowest-depth
non-outer neighbor face. Returns (u, v, w, x, neighbor_depth) or None."""
edge_to_other = {}
face_canon = canonical_face(face)
for f in faces:
cf = canonical_face(f)
if cf == face_canon:
continue
for (a, b) in edges_of_face(f):
e = frozenset([a, b])
edge_to_other.setdefault(e, cf)
best = None
for (u, v) in edges_of_face(face):
result = apex_levels_for_edge(G, emb_G, u, v, levels)
if result is None:
continue
(la, lb), (w, x) = result
if (la, lb) != (k, k):
continue
if G.has_edge(w, x):
continue
e = frozenset([u, v])
other_canon = edge_to_other.get(e)
if other_canon is None or other_canon == outer_canon:
continue
other_depth = depths.get(other_canon)
if other_depth is None:
continue
if best is None or other_depth < best[4]:
best = (u, v, w, x, other_depth)
return best
# -- main routine -------------------------------------------------------
def analyze_config(G, emb, levels, nodes_k, k, max_steps=20):
"""Run the user's algorithm iteratively. Returns:
{'terminated': bool, 'steps': n, 'trace': list of monovariant tuples}
A trace entry per step is (max_tricky_depth, num_tricky, max_odd_depth).
"""
Gc = G.copy()
emb_c = emb
trace = []
for step in range(max_steps):
faces = faces_of_subgraph(Gc, emb_c, nodes_k)
if len(faces) < 2:
return {'terminated': True, 'steps': step, 'trace': trace,
'reason': 'no_faces'}
outer = identify_outer_face(faces, Gc, emb_c, levels, k)
depths = face_depths(faces, outer)
if depths is None:
return {'terminated': True, 'steps': step, 'trace': trace,
'reason': 'no_depths'}
odd_all = odd_faces_all(faces, depths, outer)
tricky = tricky_odd_faces(faces, Gc, emb_c, levels, k, depths, outer)
max_tricky = max((d for _, _, d in tricky), default=-1)
max_odd = max((d for _, _, d in odd_all), default=-1)
trace.append((max_tricky, len(tricky), max_odd))
if not tricky:
return {'terminated': True, 'steps': step, 'trace': trace,
'reason': 'no_tricky'}
# Pick deepest tricky face; flip toward lowest-depth neighbor.
cf_t, face_t, d_t = max(tricky, key=lambda x: x[2])
flip = lowest_depth_neighbor_flip(face_t, Gc, emb_c, levels, k,
depths, faces, outer)
if flip is None:
return {'terminated': False, 'steps': step, 'trace': trace,
'reason': 'no_flip'}
u, v, w, x, _ = flip
Gc.remove_edge(u, v)
Gc.add_edge(w, x)
ip, emb_c = nx.check_planarity(Gc)
if not ip:
return {'terminated': False, 'steps': step, 'trace': trace,
'reason': 'nonplanar'}
return {'terminated': False, 'steps': max_steps, 'trace': trace,
'reason': 'budget'}
def monovariant_decreases(trace, key):
"""Does `trace[i][key]` strictly decrease at each step (except when
the algorithm has already terminated)?"""
vals = [t[key] for t in trace]
for i in range(1, len(vals)):
if vals[i] >= vals[i - 1]:
return False
return True
def run_check(n_values, max_steps=20):
total_configs = 0
terminated = 0
nonterm = 0
mono_max_tricky = 0
mono_count_tricky = 0
mono_max_odd = 0
nonterm_examples = []
for n in n_values:
print(f"\n=== n = {n} ===")
tris = enumerate_all_triangulations(n)
print(f" {len(tris)} iso-classes")
for tri_idx, G in enumerate(tris):
ip, emb = nx.check_planarity(G)
if not ip:
continue
for kind, label, source_set in level_sources(G, emb):
levels = compute_levels(G, source_set)
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
for k, nodes_k in by_level.items():
if k == 0:
continue
faces = faces_of_subgraph(G, emb, nodes_k)
if len(faces) < 2:
continue
outer = identify_outer_face(faces, G, emb, levels, k)
if outer is None:
continue
depths = face_depths(faces, outer)
if depths is None:
continue
tricky = tricky_odd_faces(faces, G, emb, levels, k,
depths, outer)
if not tricky:
continue
total_configs += 1
result = analyze_config(G, emb, levels, nodes_k, k,
max_steps)
if result['terminated']:
terminated += 1
else:
nonterm += 1
if len(nonterm_examples) < 5:
nonterm_examples.append({
'n': n, 'tri_idx': tri_idx,
'source': (kind, label),
'k': k,
'reason': result['reason'],
'trace': result['trace'],
'steps': result['steps'],
})
# Monovariant check (only meaningful if trace has >= 2)
tr = result['trace']
if len(tr) >= 2:
if monovariant_decreases(tr, 0):
mono_max_tricky += 1
if monovariant_decreases(tr, 1):
mono_count_tricky += 1
if monovariant_decreases(tr, 2):
mono_max_odd += 1
print(f"\n=== summary ===")
print(f"tricky configs encountered: {total_configs}")
print(f" terminated within {max_steps} steps: {terminated}")
print(f" non-terminating / failed: {nonterm}")
print(f" candidate monovariants (strict decrease per step):")
print(f" max depth of tricky faces: {mono_max_tricky}")
print(f" count of tricky faces: {mono_count_tricky}")
print(f" max depth of all odd faces: {mono_max_odd}")
if nonterm_examples:
print(f"\nnon-terminating examples (first {len(nonterm_examples)}):")
for ex in nonterm_examples:
print(f" n={ex['n']} tri={ex['tri_idx']} src={ex['source']} "
f"k={ex['k']} reason={ex['reason']} steps={ex['steps']}")
print(f" trace (max_tricky, n_tricky, max_odd): {ex['trace']}")
if __name__ == "__main__":
if len(sys.argv) > 1:
ns = [int(x) for x in sys.argv[1:]]
else:
ns = [9, 10]
run_check(ns)
@@ -0,0 +1,197 @@
"""Visualize the cycling case for the (k,k) tricky-everywhere algorithm.
Shows L_1 of triangulation index 6 at n=10, with source face (0,4,2).
Renders 4 steps of the algorithm side by side.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import networkx as nx
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from collections import defaultdict
from triangulation_gen import enumerate_all_triangulations
from level_cycles import compute_levels
from depth_monovariant_check import (
faces_of_subgraph, canonical_face, edges_of_face, apex_levels_for_edge,
identify_outer_face, face_depths, is_both_k_everywhere,
tricky_odd_faces, lowest_depth_neighbor_flip,
)
def layout_outerplanar(nodes_k, outer_face, G_full):
"""Place outer-face vertices on a circle in the cyclic order they appear,
and interior vertices via a weighted average of their neighbors on the
circle."""
outer_list = list(outer_face)
n = len(outer_list)
pos = {}
for i, v in enumerate(outer_list):
angle = np.pi / 2 - 2 * np.pi * i / n
pos[v] = np.array([np.cos(angle), np.sin(angle)])
interior = [v for v in nodes_k if v not in pos]
if interior:
Lk = G_full.subgraph(nodes_k)
for v in interior:
nbrs = [u for u in Lk.neighbors(v) if u in pos]
if nbrs:
pos[v] = np.mean([pos[u] for u in nbrs], axis=0)
else:
pos[v] = np.array([0.0, 0.0])
return pos
def draw_panel(ax, G_full, emb, levels, nodes_k, k, title,
highlight_edge=None, new_edge=None):
"""Draw L_k with faces shaded by depth and tricky odd face highlighted."""
faces = faces_of_subgraph(G_full, emb, nodes_k)
outer = identify_outer_face(faces, G_full, emb, levels, k)
depths = face_depths(faces, outer)
tricky = tricky_odd_faces(faces, G_full, emb, levels, k, depths, outer)
tricky_canons = {cf for cf, _, _ in tricky}
# Find the outer face actual tuple (for layout)
outer_face = None
for f in faces:
if canonical_face(f) == outer:
outer_face = f
break
pos = layout_outerplanar(nodes_k, outer_face, G_full)
# Shade faces by depth (skip outer face)
max_depth = max(d for d in depths.values()) if depths else 1
for f in faces:
cf = canonical_face(f)
if cf == outer:
continue
d = depths.get(cf, 0)
if cf in tricky_canons:
color = '#ff5555' # red for tricky
alpha = 0.7
else:
# Gradient from light yellow (depth 1) to orange (deep)
shade = 0.3 + 0.4 * (d / max(max_depth, 1))
color = (1.0, 1.0 - shade * 0.3, 0.7 - shade * 0.4)
alpha = 0.5
poly = plt.Polygon([pos[v] for v in f], color=color, alpha=alpha,
zorder=1)
ax.add_patch(poly)
# Label face center with depth
cx, cy = np.mean([pos[v] for v in f], axis=0)
face_len = len(f)
parity_mark = ' (odd)' if face_len % 2 == 1 else ''
tricky_mark = ' TRICKY' if cf in tricky_canons else ''
ax.text(cx, cy, f"d={d}\nlen={face_len}{parity_mark}{tricky_mark}",
fontsize=9, ha='center', va='center', zorder=3,
fontweight='bold' if cf in tricky_canons else 'normal')
# Draw edges of L_k
Lk = G_full.subgraph(nodes_k)
hl_set = frozenset(highlight_edge) if highlight_edge else None
new_set = frozenset(new_edge) if new_edge else None
for u, v in Lk.edges():
eset = frozenset([u, v])
if hl_set and eset == hl_set:
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color='red', linewidth=4.0, zorder=2.5,
linestyle='--')
else:
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color='black', linewidth=1.6, zorder=2)
# Sketch where the new edge will land (dotted green)
if new_set:
nu, nv = new_edge
if nu in pos and nv in pos:
ax.plot([pos[nu][0], pos[nv][0]], [pos[nu][1], pos[nv][1]],
color='green', linewidth=2.5, zorder=2.5,
linestyle=':')
# Draw vertices
for v in nodes_k:
ax.plot(pos[v][0], pos[v][1], 'o', color='black', markersize=12,
zorder=4)
ax.text(pos[v][0] * 1.13, pos[v][1] * 1.13, str(v), fontsize=14,
ha='center', va='center', color='blue', zorder=5,
fontweight='bold')
ax.set_title(title, fontsize=12)
ax.set_aspect('equal')
ax.axis('off')
ax.set_xlim(-1.3, 1.3)
ax.set_ylim(-1.3, 1.3)
return tricky
def main():
tris = enumerate_all_triangulations(10)
G = tris[6]
ip, emb = nx.check_planarity(G)
source = {0, 4, 2}
levels = compute_levels(G, source)
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
nodes_k = by_level[1]
k = 1
fig, axes_grid = plt.subplots(2, 2, figsize=(14, 14))
axes = axes_grid.flatten()
fig.suptitle(
f"Cycling case: n=10, tri[6], source face (0,4,2), $L_{k}$\n"
f"Red = tricky-everywhere odd face (depth 2). Yellow = depth-1 odd "
f"faces (have free edges). Algorithm flips a (k,k) edge of the red "
f"face toward its lowest-depth neighbor each step.",
fontsize=13
)
Gc = G.copy()
emb_c = emb
for step in range(4):
ax = axes[step]
# Compute flip choice first so we can render highlight before draw
faces = faces_of_subgraph(Gc, emb_c, nodes_k)
outer = identify_outer_face(faces, Gc, emb_c, levels, k)
depths = face_depths(faces, outer)
tricky_pre = tricky_odd_faces(faces, Gc, emb_c, levels, k, depths,
outer)
flip = None
if tricky_pre:
cf_t, face_t, d_t = max(tricky_pre, key=lambda x: x[2])
flip = lowest_depth_neighbor_flip(face_t, Gc, emb_c, levels, k,
depths, faces, outer)
hl = None
new_e = None
if flip is not None:
u, v, w, x, _ = flip
hl = (u, v)
new_e = (w, x)
tricky = draw_panel(ax, Gc, emb_c, levels, nodes_k, k,
f"Step {step}",
highlight_edge=hl, new_edge=new_e)
if not tricky:
ax.set_title(f"Step {step}: no tricky face — done",
fontsize=12)
continue
if flip is None:
ax.set_title(f"Step {step}: no flip available", fontsize=12)
continue
u, v, w, x, _ = flip
ax.set_title(
f"Step {step}: tricky face {face_t}\n"
f"red-dashed: edge ({u},{v}) being flipped → "
f"green-dotted: new edge ({w},{x})",
fontsize=11)
# Apply flip
Gc.remove_edge(u, v)
Gc.add_edge(w, x)
ip2, emb_c = nx.check_planarity(Gc)
plt.tight_layout(rect=[0, 0, 1, 0.93])
out = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', 'cycling_visualization.png')
plt.savefig(out, dpi=130, bbox_inches='tight')
print(f"saved: {out}")
if __name__ == "__main__":
main()