Add Even Level Graph Generators paper + extend Level Switching reachability

- New paper papers/even_level_graph_generators/: defines Even Level
  Graph (every level cycle even), derived level graphs, intertwining
  trees, and the disjunction conjecture (every maximal planar graph is
  a derived level graph or intertwining tree). Empirically tested
  through n=11: every iso class is at least an intertwining tree, so
  the disjunction holds trivially in this range. The intertwining tree
  disjunct fails at the Tutte graph dual (n=25), so the disjunction
  becomes non-trivial past some unknown threshold.

- Level Switching paper: adds Section 4 (Reachability via edge
  switches) with the two-step argument (Sleator-Tarjan-Thurston for
  Case 1; face-merges for Case 2) and Theorem 4.1 (O(n) edge switches
  suffice to reach all-depth-0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:44:39 -04:00
parent 082ee31966
commit c947ce75ff
29 changed files with 2180 additions and 23 deletions
@@ -0,0 +1,151 @@
"""User-proposed simpler algorithm:
1) Assign each face x = min dual-tree distance to any leaf face (ear).
2) When face F at depth d has no balanced surface switch, edge-switch
on the edge between F and F's neighbour with the smallest x.
3) Repeat until the number of depth-d faces has strictly decreased.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import math
import networkx as nx
from stress_test_termination import (
compute_depths, apply_switch, check_balanced, face_edges
)
def compute_x(faces, outer_set):
"""x(F) = min dual-tree distance from F to any leaf face."""
D = nx.Graph()
D.add_nodes_from(range(len(faces)))
for i, fi in enumerate(faces):
for j, fj in enumerate(faces):
if i < j and face_edges(fi) & face_edges(fj):
D.add_edge(i, j)
leaves = [i for i in D.nodes if D.degree(i) == 1]
x = {}
for i in range(len(faces)):
if not leaves:
x[i] = float('inf')
continue
x[i] = min(nx.shortest_path_length(D, i, lf) for lf in leaves)
return x, D
def neighbour_at_edge(faces, F_idx, e):
for j, fj in enumerate(faces):
if j != F_idx and e in face_edges(fj):
return j
return None
def run_leaf_distance_algorithm(faces, outer_set, max_steps=500,
verbose=True):
"""Run the algorithm. If F admits a balanced switch, take it; else
pick the neighbour with smallest x and switch on that edge."""
total = 0
log = []
for step in range(max_steps):
depth = compute_depths(faces, outer_set)
d_max = max(depth.values())
if d_max == 0:
log.append(f'terminated at step {step}')
return faces, total, log
max_d_faces = [i for i, d in depth.items() if d == d_max]
F_idx = max_d_faces[0]
F = faces[F_idx]
ok, _, fp_idx, e = check_balanced(F_idx, faces, depth, outer_set)
if ok:
Fp = faces[fp_idx]
u, v = tuple(e)
w = [vert for vert in F if vert not in (u, v)][0]
x = [vert for vert in Fp if vert not in (u, v)][0]
log.append(f'step {step}: BALANCED on edge ({u},{v}) '
f'with F\' = {Fp}')
faces = apply_switch(faces, (u, v), (w, x))
total += 1
continue
# Find x values
x_vals, D = compute_x(faces, outer_set)
# Among neighbours of F (interior edges, hence in dual graph),
# pick the one with smallest x.
nb_choices = []
for e_test in face_edges(F):
if e_test in outer_set:
continue
nb_idx = neighbour_at_edge(faces, F_idx, e_test)
if nb_idx is None:
continue
nb_choices.append((x_vals[nb_idx], e_test, nb_idx))
if not nb_choices:
log.append(f'step {step}: no interior neighbour; stuck')
break
nb_choices.sort()
x_min, e_chosen, nb_idx = nb_choices[0]
Fnb = faces[nb_idx]
u, v = tuple(e_chosen)
w = [vert for vert in F if vert not in (u, v)][0]
xv = [vert for vert in Fnb if vert not in (u, v)][0]
log.append(f'step {step}: x-preprocess on edge ({u},{v}) '
f'(F\'={Fnb}, x={x_min}; other choices: '
f'{[(c[0]) for c in nb_choices[1:]]})')
faces = apply_switch(faces, (u, v), (w, xv))
total += 1
log.append(f'hit max_steps; final max depth = '
f'{max(compute_depths(faces, outer_set).values())}')
return faces, total, log
# ----- TEST CASE 1: 9-vertex example -----
n9 = 9
outer9 = [(i, (i + 1) % n9) for i in range(n9)]
outer_set9 = {frozenset(e) for e in outer9}
faces9 = [
(0, 1, 2), (0, 2, 3), (3, 4, 5), (3, 5, 6),
(6, 7, 8), (6, 8, 0), (0, 3, 6),
]
print('=== 9-vertex example ===')
_, total, log = run_leaf_distance_algorithm(faces9, outer_set9)
print('\n'.join(log))
print(f'total switches: {total}\n')
# ----- TEST CASE 2: 24-vertex doubly-lopsided example -----
n24 = 24
outer24 = [(i, (i + 1) % n24) for i in range(n24)]
outer_set24 = {frozenset(e) for e in outer24}
def arm(a, b):
return [
(a, a + 1, a + 2), (a, a + 2, b), (a + 2, a + 3, a + 4),
(a + 2, a + 4, b), (a + 4, a + 5, a + 6), (a + 4, a + 6, b),
(a + 6, a + 7, b),
]
faces24 = [(0, 8, 16)]
for (a, b) in [(0, 8), (8, 16), (16, 24)]:
fs = arm(a, b)
fs = [tuple(0 if v == 24 else v for v in vt) for vt in fs]
faces24.extend(fs)
print('=== 24-vertex doubly-lopsided example ===')
_, total, log = run_leaf_distance_algorithm(faces24, outer_set24)
print('\n'.join(log[:25]))
if len(log) > 25:
print(f'... ({len(log) - 25} more lines)')
print(f'total switches: {total}\n')
# ----- TEST CASE 3: random outerplanar n=40 (one of the previously-slow seeds) -----
from stress_test_termination import random_triangulation
seed = 94476710
outer, _, faces = random_triangulation(40, seed=seed)
outer_set = {frozenset(e) for e in outer}
print(f'=== Random n=40 (seed={seed}) with the new algorithm ===')
_, total, log = run_leaf_distance_algorithm(faces, outer_set, max_steps=1000)
print(f'total switches: {total} (random algorithm with cap=10000 needed 863)')
print(f'first 5 lines: {log[:5]}')
print(f'last 2 lines: {log[-2:]}')
@@ -0,0 +1,140 @@
"""Leaf-distance algorithm with the additional rule:
Maintain a `blocked` set of edges; an edge cannot be flipped while
the count x of depth-d faces (d = current max depth) is unchanged.
When x decreases (a depth-d face is removed via a balanced switch),
clear `blocked`. Also clear `blocked` when d itself decreases."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
from matplotlib.backends.backend_pdf import PdfPages
import math
from leaf_distance_algorithm import compute_x
from stress_test_termination import (
compute_depths, apply_switch, check_balanced, face_edges,
random_triangulation
)
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
def neighbour_at_edge(faces, F_idx, e):
for j, fj in enumerate(faces):
if j != F_idx and e in face_edges(fj):
return j
return None
def run_with_blocking(faces, outer_set, max_steps=10000):
"""Run with the blocked-edge rule."""
log = []
blocked = set()
prev_x = None
prev_d = None
for step in range(max_steps):
depth = compute_depths(faces, outer_set)
d_max = max(depth.values())
n_at_max = sum(1 for d in depth.values() if d == d_max)
if prev_d is not None and (d_max < prev_d or
(d_max == prev_d and n_at_max < prev_x)):
blocked = set() # reset: count of depth-d faces dropped
prev_d = d_max
prev_x = n_at_max
if d_max == 0:
log.append(f'TERMINATED at step {step}')
return faces, log
max_d_faces = [i for i, d in depth.items() if d == d_max]
F_idx = max_d_faces[0]
F = faces[F_idx]
ok, _, fp_idx, e = check_balanced(F_idx, faces, depth, outer_set)
if ok:
Fp = faces[fp_idx]
u, v = tuple(e)
w = [vert for vert in F if vert not in (u, v)][0]
xv = [vert for vert in Fp if vert not in (u, v)][0]
log.append(f'step {step}: BALANCED on ({u},{v}) (d={d_max},x={n_at_max})')
blocked.add(frozenset((u, v)))
faces = apply_switch(faces, (u, v), (w, xv))
continue
# Preprocessing: pick lowest-x unblocked neighbour
x_vals, _ = compute_x(faces, outer_set)
nb_choices = []
for e_test in face_edges(F):
if e_test in outer_set or e_test in blocked:
continue
nb_idx = neighbour_at_edge(faces, F_idx, e_test)
if nb_idx is None:
continue
nb_choices.append((x_vals[nb_idx], e_test, nb_idx))
if not nb_choices:
log.append(f'step {step}: STUCK -- all interior edges blocked '
f'or none available; max depth={d_max}, x={n_at_max}')
return faces, log
nb_choices.sort()
_, e_chosen, fp_idx = nb_choices[0]
Fp = faces[fp_idx]
u, v = tuple(e_chosen)
w = [vert for vert in F if vert not in (u, v)][0]
xv = [vert for vert in Fp if vert not in (u, v)][0]
log.append(f'step {step}: preprocess ({u},{v}) (d={d_max},x={n_at_max}, '
f'#blocked={len(blocked)})')
blocked.add(frozenset((u, v)))
faces = apply_switch(faces, (u, v), (w, xv))
log.append(f'hit max_steps')
return faces, log
# ----- run on the stuck n=40 case -----
seed = 94476710
outer, _, faces = random_triangulation(40, seed=seed)
outer_set = {frozenset(e) for e in outer}
print(f'Running blocked algorithm on n=40, seed={seed}...')
faces_after, log = run_with_blocking(faces, outer_set, max_steps=5000)
print(f'Result: {log[-1]}')
print(f'Total log lines: {len(log)}')
print('First 25 lines:')
for line in log[:25]:
print(f' {line}')
print('Last 5 lines:')
for line in log[-5:]:
print(f' {line}')
# ----- also test on 9-vertex and 24-vertex -----
print('\n=== 9-vertex ===')
n9 = 9
outer9 = [(i, (i+1) % n9) for i in range(n9)]
outer_set9 = {frozenset(e) for e in outer9}
faces9 = [
(0, 1, 2), (0, 2, 3), (3, 4, 5), (3, 5, 6),
(6, 7, 8), (6, 8, 0), (0, 3, 6),
]
_, log9 = run_with_blocking(faces9, outer_set9)
for line in log9[:5]:
print(f' {line}')
print('\n=== 24-vertex doubly-lopsided ===')
n24 = 24
outer24 = [(i, (i+1) % n24) for i in range(n24)]
outer_set24 = {frozenset(e) for e in outer24}
def arm(a, b):
return [
(a, a+1, a+2), (a, a+2, b), (a+2, a+3, a+4),
(a+2, a+4, b), (a+4, a+5, a+6), (a+4, a+6, b),
(a+6, a+7, b),
]
faces24 = [(0, 8, 16)]
for (a, b) in [(0, 8), (8, 16), (16, 24)]:
fs = arm(a, b)
fs = [tuple(0 if v == 24 else v for v in vt) for vt in fs]
faces24.extend(fs)
_, log24 = run_with_blocking(faces24, outer_set24)
print(f' total: {len(log24) - 1} steps')
print(f' final: {log24[-1]}')
@@ -0,0 +1,140 @@
"""Generate a multi-page PDF showing the L_k state after each edge
switch, on the n=40 stuck example."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import math
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
from matplotlib.backends.backend_pdf import PdfPages
from leaf_distance_algorithm import compute_x
from stress_test_termination import (
compute_depths, apply_switch, check_balanced, face_edges,
random_triangulation
)
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
def neighbour_at_edge(faces, F_idx, e):
for j, fj in enumerate(faces):
if j != F_idx and e in face_edges(fj):
return j
return None
def positions(n):
return {i: (math.cos(math.radians(90 - i * 360 / n)),
math.sin(math.radians(90 - i * 360 / n)))
for i in range(n)}
def draw_state(ax, faces, n, outer_set, switched_edge=None,
action=None, step=None):
POS = positions(n)
depth = compute_depths(faces, outer_set)
palette = {0: '#86efac', 1: '#fde68a', 2: '#fca5a5'}
edge_pal = {0: '#16a34a', 1: '#d97706', 2: '#dc2626'}
for f in faces:
d = depth.get(faces.index(f), 0)
# Find depth by iterating
for i, ff in enumerate(faces):
if ff == f:
d = depth[i]
break
poly = Polygon([POS[v] for v in f], closed=True,
facecolor=palette.get(d, '#ddd'),
edgecolor=edge_pal.get(d, '#333'),
linewidth=0.7, alpha=0.6, zorder=0)
ax.add_patch(poly)
# Draw all edges
all_edges = set()
for f in faces:
all_edges.update(face_edges(f))
outer_edges = [tuple(e) for e in all_edges if e in outer_set]
chord_edges = [tuple(e) for e in all_edges if e not in outer_set]
for (a, b) in outer_edges + chord_edges:
color = '#333'; lw = 0.5
if switched_edge and {a, b} == set(switched_edge):
color = '#dc2626' if action == 'preprocess' else '#16a34a'
lw = 2.5
ax.plot([POS[a][0], POS[b][0]], [POS[a][1], POS[b][1]],
color=color, linewidth=lw, zorder=1)
# Vertices
for i, (x, y) in POS.items():
ax.scatter([x], [y], s=70, c='#1f2937', edgecolors='black',
linewidths=0.3, zorder=2)
ax.text(x, y, str(i), ha='center', va='center',
fontsize=5, color='white', fontweight='bold', zorder=3)
ax.set_aspect('equal'); ax.axis('off')
ax.set_xlim(-1.2, 1.2); ax.set_ylim(-1.2, 1.2)
d_max = max(depth.values())
n_d1 = sum(1 for d in depth.values() if d == 1)
n_d2 = sum(1 for d in depth.values() if d == 2)
title = f'step {step}'
if action and switched_edge:
title += f': {action} on {tuple(sorted(switched_edge))}'
title += f' (max={d_max}, #d1={n_d1}, #d2={n_d2})'
ax.set_title(title, fontsize=9)
def run_and_record(faces, outer_set, n, max_steps=80):
"""Run algorithm, capturing the state and the action at each step.
Returns list of (faces_at_start_of_step, action, edge)."""
records = []
for step in range(max_steps):
depth = compute_depths(faces, outer_set)
d_max = max(depth.values())
if d_max == 0:
records.append((list(faces), None, None))
return records, faces
max_d_faces = [i for i, d in depth.items() if d == d_max]
F_idx = max_d_faces[0]
F = faces[F_idx]
ok, _, fp_idx, e = check_balanced(F_idx, faces, depth, outer_set)
if ok:
action = 'BALANCED'
else:
x_vals, _ = compute_x(faces, outer_set)
nb_choices = []
for e_test in face_edges(F):
if e_test in outer_set:
continue
nb_idx = neighbour_at_edge(faces, F_idx, e_test)
if nb_idx is None:
continue
nb_choices.append((x_vals[nb_idx], e_test, nb_idx))
if not nb_choices:
records.append((list(faces), 'STUCK', None))
return records, faces
nb_choices.sort()
_, e, fp_idx = nb_choices[0]
action = 'preprocess'
Fp = faces[fp_idx]
u, v = tuple(e)
w = [vert for vert in F if vert not in (u, v)][0]
x = [vert for vert in Fp if vert not in (u, v)][0]
records.append((list(faces), action, (u, v)))
faces = apply_switch(faces, (u, v), (w, x))
return records, faces
seed = 94476710
outer, _, faces = random_triangulation(40, seed=seed)
outer_set = {frozenset(e) for e in outer}
print('recording trajectory...')
records, _ = run_and_record(faces, outer_set, 40, max_steps=80)
print(f'recorded {len(records)} steps')
out_pdf = os.path.join(OUT_DIR, 'fig_n40_every_step.pdf')
with PdfPages(out_pdf) as pdf:
for step, (state_faces, action, edge) in enumerate(records):
fig, ax = plt.subplots(figsize=(7, 7))
draw_state(ax, state_faces, 40, outer_set,
switched_edge=edge, action=action, step=step)
pdf.savefig(fig, dpi=120, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out_pdf}')
@@ -0,0 +1,122 @@
"""Plot the depth distribution over time for the random n=40 trajectory
under the leaf-distance algorithm. Shows whether progress is being made
or whether the algorithm is just grinding."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import matplotlib.pyplot as plt
from leaf_distance_algorithm import compute_x
from stress_test_termination import (
compute_depths, apply_switch, check_balanced, face_edges,
random_triangulation
)
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
def neighbour_at_edge(faces, F_idx, e):
for j, fj in enumerate(faces):
if j != F_idx and e in face_edges(fj):
return j
return None
def run_with_full_tracking(faces, outer_set, max_steps=3000):
"""Run algorithm; record at each step: (#faces at each depth), and
whether the step was BALANCED or x-preprocess."""
history = []
for step in range(max_steps):
depth = compute_depths(faces, outer_set)
d_max = max(depth.values())
# Record current state
d_dist = {}
for d in depth.values():
d_dist[d] = d_dist.get(d, 0) + 1
history.append({'step': step, 'd_max': d_max, 'd_dist': dict(d_dist)})
if d_max == 0:
return history, faces
max_d_faces = [i for i, d in depth.items() if d == d_max]
F_idx = max_d_faces[0]
F = faces[F_idx]
ok, _, fp_idx, e = check_balanced(F_idx, faces, depth, outer_set)
if ok:
history[-1]['action'] = 'BALANCED'
Fp = faces[fp_idx]
u, v = tuple(e)
w = [vert for vert in F if vert not in (u, v)][0]
x = [vert for vert in Fp if vert not in (u, v)][0]
faces = apply_switch(faces, (u, v), (w, x))
continue
history[-1]['action'] = 'preprocess'
x_vals, _ = compute_x(faces, outer_set)
nb_choices = []
for e_test in face_edges(F):
if e_test in outer_set:
continue
nb_idx = neighbour_at_edge(faces, F_idx, e_test)
if nb_idx is None:
continue
nb_choices.append((x_vals[nb_idx], e_test, nb_idx))
if not nb_choices:
return history, faces
nb_choices.sort()
_, e_chosen, nb_idx = nb_choices[0]
Fnb = faces[nb_idx]
u, v = tuple(e_chosen)
w = [vert for vert in F if vert not in (u, v)][0]
xv = [vert for vert in Fnb if vert not in (u, v)][0]
faces = apply_switch(faces, (u, v), (w, xv))
return history, faces
seed = 94476710
outer, _, faces = random_triangulation(40, seed=seed)
outer_set = {frozenset(e) for e in outer}
print(f'Running algorithm on n=40, seed={seed}...')
history, final_faces = run_with_full_tracking(faces, outer_set, max_steps=3000)
print(f'finished at step {len(history) - 1}, '
f'final max depth = {history[-1]["d_max"]}')
# Plot count of depth-d faces for d = 1 and 2 over time, and mark
# balanced vs preprocessing steps
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 7), sharex=True)
steps = [h['step'] for h in history]
n_d1 = [h['d_dist'].get(1, 0) for h in history]
n_d2 = [h['d_dist'].get(2, 0) for h in history]
balanced_steps = [h['step'] for h in history
if h.get('action') == 'BALANCED']
preprocess_steps = [h['step'] for h in history
if h.get('action') == 'preprocess']
ax1.plot(steps, n_d1, color='#d97706', linewidth=1.2, label='depth-1 faces')
ax1.plot(steps, n_d2, color='#dc2626', linewidth=1.2, label='depth-2 faces')
ax1.set_ylabel('count of faces at depth')
ax1.legend(loc='upper right')
ax1.set_title(f'n=40 trajectory under leaf-distance algorithm (seed={seed}, '
f'{len(history) - 1} steps)')
ax1.grid(alpha=0.3)
# Add markers showing balanced vs preprocess at bottom
ax2.scatter(balanced_steps, [1] * len(balanced_steps),
color='#16a34a', s=8, label=f'BALANCED ({len(balanced_steps)} steps)')
ax2.scatter(preprocess_steps, [0] * len(preprocess_steps),
color='#3b82f6', s=8, label=f'preprocess ({len(preprocess_steps)} steps)')
ax2.set_yticks([0, 1])
ax2.set_yticklabels(['preprocess', 'BALANCED'])
ax2.set_xlabel('step')
ax2.legend(loc='upper right')
ax2.grid(alpha=0.3)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_n40_trajectory.png')
fig.savefig(out, dpi=150, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
# Summary
print(f'\n#BALANCED switches: {len(balanced_steps)}')
print(f'#preprocess switches: {len(preprocess_steps)}')
print(f'Max depth-1 count seen: {max(n_d1)}')
print(f'Max depth-2 count seen: {max(n_d2)}')
@@ -0,0 +1,158 @@
"""Annotated diagram so the user can answer the v_c-rotation
clarification questions.
Shows the 9-vertex L_k with e_0 = (0, 3) highlighted, both candidate
v_c vertices (0 and 3) labelled, and the four (v_c, direction) fans
laid out around each."""
import os
import math
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon, FancyArrowPatch
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
n = 9
POS = {i: (math.cos(math.radians(90 - i * 360 / n)),
math.sin(math.radians(90 - i * 360 / n))) for i in range(n)}
OUTER_EDGES = [(i, (i + 1) % n) for i in range(n)]
CHORDS = [(0, 2), (0, 3), (3, 5), (3, 6), (0, 6), (6, 8)]
# Inner faces
FACES = {
(0, 1, 2): 0,
(0, 2, 3): 0,
(3, 4, 5): 0,
(3, 5, 6): 0,
(6, 7, 8): 0,
(6, 8, 0): 0,
(0, 3, 6): 1, # F
}
F_idx_face = (0, 3, 6)
Fp_face = (0, 2, 3)
e0 = (0, 3)
def cw_order_around(v):
"""Sort vertices adjacent to v by clockwise angle (decreasing
angle from v, starting at angle 90)."""
adj = set()
for f in FACES:
if v in f:
for u in f:
if u != v:
adj.add(u)
# angle of each adjacent vertex
def angle(u):
return (90 - u * 360 / n) % 360
return sorted(adj, key=lambda u: -angle(u))
fig, axes = plt.subplots(1, 2, figsize=(14, 7))
palette = {0: '#86efac', 1: '#fde68a'}
edge_pal = {0: '#16a34a', 1: '#d97706'}
def draw_base(ax, title):
# Fill faces by depth
for face, depth in FACES.items():
poly = Polygon([POS[v] for v in face], closed=True,
facecolor=palette[depth], edgecolor=edge_pal[depth],
linewidth=1.2, alpha=0.5, zorder=0)
ax.add_patch(poly)
# Edges
for (a, b) in OUTER_EDGES + CHORDS:
color, lw = '#333', 1.2
if {a, b} == set(e0):
color, lw = '#dc2626', 3.4 # e_0 highlighted
ax.plot([POS[a][0], POS[b][0]], [POS[a][1], POS[b][1]],
color=color, linewidth=lw, zorder=1)
# Vertices
for i, (x, y) in POS.items():
color = '#3b82f6' if i in e0 else '#1f2937'
size = 470 if i in e0 else 280
ax.scatter([x], [y], s=size, c=color, edgecolors='black',
linewidths=1.2, zorder=2)
ax.text(x, y, str(i), ha='center', va='center',
fontsize=11 if i in e0 else 9,
color='white', fontweight='bold', zorder=3)
# Annotate F and F'
cx_F = sum(POS[v][0] for v in F_idx_face) / 3
cy_F = sum(POS[v][1] for v in F_idx_face) / 3
ax.text(cx_F, cy_F + 0.1, r'$F$', ha='center', fontsize=14,
color='#92400e', fontweight='bold', zorder=4)
ax.text(cx_F, cy_F - 0.1, '(depth 1)', ha='center', fontsize=9,
color='#92400e', zorder=4)
cx_Fp = sum(POS[v][0] for v in Fp_face) / 3
cy_Fp = sum(POS[v][1] for v in Fp_face) / 3
ax.text(cx_Fp + 0.1, cy_Fp, r"$F'$" + '\n(depth 0)', ha='center',
fontsize=10, color='#16a34a', fontweight='bold', zorder=4)
# F''s outer-cycle edge: (2, 3)
px, py = POS[2], POS[3]
mid = ((px[0] + py[0]) / 2, (px[1] + py[1]) / 2)
ax.annotate("outer edge of $F'$",
xy=(mid[0] + 0.05, mid[1] - 0.05),
xytext=(1.35, 0.5),
fontsize=9, color='#16a34a',
arrowprops=dict(arrowstyle='->', color='#16a34a',
lw=1.0))
ax.set_aspect('equal'); ax.axis('off')
ax.set_xlim(-1.5, 1.7); ax.set_ylim(-1.4, 1.4)
ax.set_title(title, fontsize=12)
def annotate_fan(ax, vc, label):
"""Draw arrows around v_c showing CW order of edges."""
cw_neighbours = cw_order_around(vc)
# rotate so the e_0 neighbour comes first
e0_other = [u for u in e0 if u != vc][0]
idx = cw_neighbours.index(e0_other)
cw_neighbours = cw_neighbours[idx:] + cw_neighbours[:idx]
px, py = POS[vc]
# Draw a partial arc around v_c
for i, u in enumerate(cw_neighbours):
# midpoint between v_c and u, slightly inside
ux, uy = POS[u]
midx = px * 0.7 + ux * 0.3
midy = py * 0.7 + uy * 0.3
# is edge on outer cycle?
is_outer = (min(vc, u), max(vc, u)) in OUTER_EDGES or \
(max(vc, u), min(vc, u)) in OUTER_EDGES
marker_color = '#1d4ed8' if not is_outer else '#dc2626'
marker = 'o'
ax.scatter([midx], [midy], s=120, c=marker_color, marker=marker,
edgecolors='white', linewidths=1.5, zorder=5)
ax.text(midx, midy, str(i + 1), ha='center', va='center',
fontsize=8, color='white', fontweight='bold', zorder=6)
# Label
ax.text(px + 0.05, py + 0.25, label, ha='center',
fontsize=11, color='#1d4ed8', fontweight='bold')
draw_base(axes[0], r'Option A: $v_c = 0$ (CW order: 1=$(0,3)$, 2=$(0,6)$, 3=$(0,8)$ outer ...)')
annotate_fan(axes[0], 0, r'$v_c = 0$')
draw_base(axes[1], r'Option B: $v_c = 3$ (CW order: 1=$(0,3)$, 2=$(2,3)$ outer ...)')
annotate_fan(axes[1], 3, r'$v_c = 3$')
# Add legend below
fig.text(0.5, 0.02,
'Blue circles = chord edges (would be switched). '
'Red circles = outer-cycle edges (algorithm stops here). '
'Numbers = clockwise order around $v_c$ starting from $e_0$.',
ha='center', fontsize=10)
fig.tight_layout(rect=[0, 0.04, 1, 1])
out = os.path.join(OUT_DIR, 'fig_v_c_question.png')
fig.savefig(out, dpi=180, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

+12 -2
View File
@@ -51,10 +51,20 @@
\@writefile{lof}{\contentsline {figure}{\numberline {9}{\ignorespaces Recursive lopsidedness at $d = 2$. Left: $F = (0,8,16)$ depth $2$, every arm doubly-lopsided. Middle: one preprocessing switch $(0,8) \DOTSB \mapstochar \rightarrow (2,16)$ exposes the first lopsided layer; the new depth-$2$ face $(2,8,16)$ still has no balanced switch. Right: a second preprocessing switch $(8,2) \DOTSB \mapstochar \rightarrow (4,16)$ reaches the inner balanced face $K_0 = (4,6,8)$, whose two non-$F$ neighbours are both ears; the depth-$2$ face $(4,8,16)$ now admits a balanced surface switch on edge $(4,8)$.}}{8}{figure.9}\protected@file@percent }
\newlabel{fig:d2-recursive}{{9}{8}{Recursive lopsidedness at $d = 2$. Left: $F = (0,8,16)$ depth $2$, every arm doubly-lopsided. Middle: one preprocessing switch $(0,8) \mapsto (2,16)$ exposes the first lopsided layer; the new depth-$2$ face $(2,8,16)$ still has no balanced switch. Right: a second preprocessing switch $(8,2) \mapsto (4,16)$ reaches the inner balanced face $K_0 = (4,6,8)$, whose two non-$F$ neighbours are both ears; the depth-$2$ face $(4,8,16)$ now admits a balanced surface switch on edge $(4,8)$}{figure.9}{}}
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{}{Empirical termination}}{8}{section*.4}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{}{What the natural monovariants do not give us}}{9}{section*.5}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{}{Empirical termination on random configurations}}{9}{section*.6}\protected@file@percent }
\newlabel{q:preprocessing-terminates}{{3.6}{9}{}{theorem.3.6}{}}
\@writefile{toc}{\contentsline {section}{\tocsection {}{4}{Reachability via edge switches}}{9}{section.4}\protected@file@percent }
\newlabel{sec:reachability}{{4}{9}{Reachability via edge switches}{section.4}{}}
\citation{sleator-tarjan-thurston}
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{}{Two cases on the layer below $k$}}{10}{section*.7}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{}{Combining}}{10}{section*.8}\protected@file@percent }
\newlabel{thm:reachability}{{4.1}{10}{}{theorem.4.1}{}}
\bibcite{sleator-tarjan-thurston}{1}
\newlabel{tocindent-1}{0pt}
\newlabel{tocindent0}{14.69437pt}
\newlabel{tocindent1}{17.77782pt}
\newlabel{tocindent2}{0pt}
\newlabel{tocindent3}{0pt}
\newlabel{q:preprocessing-terminates}{{3.6}{9}{}{theorem.3.6}{}}
\gdef \@abspage@last{9}
\@writefile{toc}{\contentsline {section}{\tocsection {}{}{References}}{11}{section*.9}\protected@file@percent }
\gdef \@abspage@last{11}
+30 -21
View File
@@ -1,4 +1,4 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 20 MAY 2026 23:18
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 21 MAY 2026 14:28
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
@@ -353,12 +353,12 @@ Package epstopdf-base Info: Redefining graphics rule for `.eps' on input line 4
File: epstopdf-sys.cfg 2010/07/13 v1.3 Configuration of (r)epstopdf for TeX Liv
e
))
<fig_level_source.png, id=32, 715.11165pt x 317.988pt>
<fig_level_source.png, id=56, 715.11165pt x 317.988pt>
File: fig_level_source.png Graphic file (type png)
<use fig_level_source.png>
Package pdftex.def Info: fig_level_source.png used on input line 105.
(pdftex.def) Requested size: 306.0022pt x 136.07228pt.
<fig_levels.png, id=34, 454.21695pt x 391.34206pt>
<fig_levels.png, id=58, 454.21695pt x 391.34206pt>
File: fig_levels.png Graphic file (type png)
<use fig_levels.png>
Package pdftex.def Info: fig_levels.png used on input line 122.
@@ -367,7 +367,7 @@ Package pdftex.def Info: fig_levels.png used on input line 122.
LaTeX Warning: `h' float specifier changed to `ht'.
<fig_level_cycle.png, id=35, 452.04884pt x 391.34206pt>
<fig_level_cycle.png, id=59, 452.04884pt x 391.34206pt>
File: fig_level_cycle.png Graphic file (type png)
<use fig_level_cycle.png>
Package pdftex.def Info: fig_level_cycle.png used on input line 136.
@@ -377,7 +377,7 @@ LaTeX Warning: `h' float specifier changed to `ht'.
[1{/usr/local/texlive/2022/texmf-var/fonts/map/pdftex/updmap/pdftex.map} <./fig
_level_source.png>]
<fig_edge_switch.png, id=57, 859.65166pt x 378.33345pt>
<fig_edge_switch.png, id=81, 859.65166pt x 378.33345pt>
File: fig_edge_switch.png Graphic file (type png)
<use fig_edge_switch.png>
Package pdftex.def Info: fig_edge_switch.png used on input line 155.
@@ -386,7 +386,7 @@ Package pdftex.def Info: fig_edge_switch.png used on input line 155.
LaTeX Warning: `h' float specifier changed to `ht'.
<fig_parity_subgraph.png, id=59, 1076.46165pt x 319.79475pt>
<fig_parity_subgraph.png, id=83, 1076.46165pt x 319.79475pt>
File: fig_parity_subgraph.png Graphic file (type png)
<use fig_parity_subgraph.png>
Package pdftex.def Info: fig_parity_subgraph.png used on input line 173.
@@ -395,7 +395,7 @@ Package pdftex.def Info: fig_parity_subgraph.png used on input line 173.
LaTeX Warning: `h' float specifier changed to `ht'.
[2 <./fig_levels.png> <./fig_level_cycle.png>]
<fig_facial_depth.png, id=72, 482.40225pt x 498.663pt>
<fig_facial_depth.png, id=96, 482.40225pt x 498.663pt>
File: fig_facial_depth.png Graphic file (type png)
<use fig_facial_depth.png>
Package pdftex.def Info: fig_facial_depth.png used on input line 200.
@@ -413,7 +413,7 @@ bal-anced-ness gives $[](\OML/cmm/m/it/10 A[]\OT1/cmr/m/n/10 ) = \OML/cmm/m/it/
/m/n/10 ) \OMS/cmsy/m/n/10 ^^T
[]
<fig_no_balanced_switch.png, id=107, 483.0045pt x 498.2615pt>
<fig_no_balanced_switch.png, id=131, 483.0045pt x 498.2615pt>
File: fig_no_balanced_switch.png Graphic file (type png)
<use fig_no_balanced_switch.png>
Package pdftex.def Info: fig_no_balanced_switch.png used on input line 395.
@@ -421,7 +421,7 @@ Package pdftex.def Info: fig_no_balanced_switch.png used on input line 395.
LaTeX Warning: `h' float specifier changed to `ht'.
<fig_preprocessing.png, id=110, 998.932pt x 513.5185pt>
<fig_preprocessing.png, id=134, 998.932pt x 513.5185pt>
File: fig_preprocessing.png Graphic file (type png)
<use fig_preprocessing.png>
Package pdftex.def Info: fig_preprocessing.png used on input line 426.
@@ -462,7 +462,7 @@ cmm/m/it/10 ; \OT1/cmr/m/n/10 14)\OML/cmm/m/it/10 ; \OT1/cmr/m/n/10 (17\OML/cmm
/m/it/10 ; \OT1/cmr/m/n/10 19\OML/cmm/m/it/10 ; \OT1/cmr/m/n/10 0)$
[]
<fig_d2_recursive.png, id=128, 1293.633pt x 447.64888pt>
<fig_d2_recursive.png, id=152, 1293.633pt x 447.64888pt>
File: fig_d2_recursive.png Graphic file (type png)
<use fig_d2_recursive.png>
Package pdftex.def Info: fig_d2_recursive.png used on input line 477.
@@ -472,15 +472,24 @@ Overfull \hbox (44.02832pt too wide) detected at line 496
[][]
[]
[8 <./fig_d2_recursive.png>] [9] (./paper.aux)
[8 <./fig_d2_recursive.png>] [9]
Package hyperref Warning: Token not allowed in a PDF string (Unicode):
(hyperref) removing `math shift' on input line 581.
Package hyperref Warning: Token not allowed in a PDF string (Unicode):
(hyperref) removing `math shift' on input line 581.
[10] [11] (./paper.aux)
Package rerunfilecheck Info: File `paper.out' has not changed.
(rerunfilecheck) Checksum: DB53A88C1A1F5BD90EDB1F1E02E41C38;1447.
(rerunfilecheck) Checksum: EB616E34045D97804AC077E984931199;2673.
)
Here is how much of TeX's memory you used:
9791 strings out of 478268
151840 string characters out of 5846347
458685 words of memory out of 5000000
27682 multiletter control sequences out of 15000+600000
9811 strings out of 478268
152095 string characters out of 5846347
458816 words of memory out of 5000000
27691 multiletter control sequences out of 15000+600000
475666 words of font info for 53 fonts, out of 8000000 for 9000
1302 hyphenation exceptions out of 8191
69i,9n,76p,781b,448s stack positions out of 10000i,1000n,20000p,200000b,200000s
@@ -498,10 +507,10 @@ xlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmsy7.pfb></usr/local/texl
ive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmti10.pfb></usr/local/texli
ve/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmti8.pfb></usr/local/texlive
/2022/texmf-dist/fonts/type1/public/amsfonts/symbols/msam10.pfb>
Output written on paper.pdf (9 pages, 1268281 bytes).
Output written on paper.pdf (11 pages, 1286221 bytes).
PDF statistics:
215 PDF objects out of 1000 (max. 8388607)
156 compressed objects within 2 object streams
42 named destinations out of 1000 (max. 500000)
102 words of extra memory for PDF output out of 10000 (max. 10000000)
259 PDF objects out of 1000 (max. 8388607)
198 compressed objects within 2 object streams
53 named destinations out of 1000 (max. 500000)
150 words of extra memory for PDF output out of 10000 (max. 10000000)
+6
View File
@@ -5,3 +5,9 @@
\BOOKMARK [2][-]{section*.2}{\376\377\000P\000r\000e\000p\000r\000o\000c\000e\000s\000s\000i\000n\000g\000\040\000t\000o\000w\000a\000r\000d\000\040\000b\000a\000l\000a\000n\000c\000e\000d\000\040\000s\000w\000i\000t\000c\000h\000e\000s}{section.3}% 5
\BOOKMARK [2][-]{section*.3}{\376\377\000T\000h\000e\000\040\000d\000\040\0002\000\040\000a\000n\000a\000l\000o\000g\000\040\000a\000n\000d\000\040\000r\000e\000c\000u\000r\000s\000i\000v\000e\000\040\000l\000o\000p\000s\000i\000d\000e\000d\000n\000e\000s\000s}{section.3}% 6
\BOOKMARK [2][-]{section*.4}{\376\377\000E\000m\000p\000i\000r\000i\000c\000a\000l\000\040\000t\000e\000r\000m\000i\000n\000a\000t\000i\000o\000n}{section.3}% 7
\BOOKMARK [2][-]{section*.5}{\376\377\000W\000h\000a\000t\000\040\000t\000h\000e\000\040\000n\000a\000t\000u\000r\000a\000l\000\040\000m\000o\000n\000o\000v\000a\000r\000i\000a\000n\000t\000s\000\040\000d\000o\000\040\000n\000o\000t\000\040\000g\000i\000v\000e\000\040\000u\000s}{section.3}% 8
\BOOKMARK [2][-]{section*.6}{\376\377\000E\000m\000p\000i\000r\000i\000c\000a\000l\000\040\000t\000e\000r\000m\000i\000n\000a\000t\000i\000o\000n\000\040\000o\000n\000\040\000r\000a\000n\000d\000o\000m\000\040\000c\000o\000n\000f\000i\000g\000u\000r\000a\000t\000i\000o\000n\000s}{section.3}% 9
\BOOKMARK [1][-]{section.4}{\376\377\0004\000.\000\040\000R\000e\000a\000c\000h\000a\000b\000i\000l\000i\000t\000y\000\040\000v\000i\000a\000\040\000e\000d\000g\000e\000\040\000s\000w\000i\000t\000c\000h\000e\000s}{}% 10
\BOOKMARK [2][-]{section*.7}{\376\377\000T\000w\000o\000\040\000c\000a\000s\000e\000s\000\040\000o\000n\000\040\000t\000h\000e\000\040\000l\000a\000y\000e\000r\000\040\000b\000e\000l\000o\000w\000\040\000k}{section.4}% 11
\BOOKMARK [2][-]{section*.8}{\376\377\000C\000o\000m\000b\000i\000n\000i\000n\000g}{section.4}% 12
\BOOKMARK [1][-]{section*.9}{\376\377\000R\000e\000f\000e\000r\000e\000n\000c\000e\000s}{}% 13
Binary file not shown.
+103
View File
@@ -561,4 +561,107 @@ reached from the current maximum-depth face by a preprocessing path of
length bounded by the dual-tree diameter of $L_k$?
\end{question}
\section{Reachability via edge switches}
\label{sec:reachability}
We now sketch a positive termination argument that bypasses the local
question of balanced surface switches entirely: instead of insisting
that each switch strictly improve facial depth, we show that any
$L_k$ can be transformed by edge switches into a configuration in which
every face has depth $0$. Throughout this section we adopt the
\emph{stable-labelling convention}: the level $\ell_G(v)$ of each
vertex is fixed at the start of the procedure (by BFS from $S$ in the
initial triangulation $G$) and reused thereafter, even after edge
switches modify the triangulation. In particular, the level-$k$
subgraph $L_k$ of the current triangulation always means ``the
subgraph induced on the vertices labelled $k$ at the start''.
\subsection*{Two cases on the layer below $k$}
We split on whether any $L_k$-face has a higher-level vertex in its
interior in the planar embedding inherited from $\Pi_G$.
\textbf{Case 1: every inner face of $L_k$ is a triangle and contains
no vertex of level $\geq k+1$ in its interior.}
Under this hypothesis, for every chord $uv \in L_k$ the two $G$-triangles
at $uv$ have their third vertices in $L_k$ (since the interior of the
two $L_k$-faces adjacent to $uv$ in $\Pi_G$ contains no other vertex of
$G$). The edge switch at $uv$ is therefore always in Case~(ii) of
Proposition~\ref{prop:balanced-descent}, and acts as a flip of the
chord $uv$ in $L_k$ regarded purely as a maximal outerplanar graph.
Maximal outerplanar graphs on $n$ labelled vertices (arranged on a
common outer cycle) are exactly triangulations of a convex $n$-gon.
The set of such triangulations, with chord flips as edges, is the
1-skeleton of the associahedron and is connected; in fact any two
triangulations are joined by $O(n)$ chord flips~\cite{sleator-tarjan-thurston}.
A \emph{fan triangulation} -- the triangulation obtained by adding
chords from a single apex vertex $v_0$ to every other vertex -- has
every inner triangle bounded by an outer-cycle edge (namely the side
opposite $v_0$ in that triangle), so every face of a fan triangulation
lies in $\mathcal{B}$ and has depth $0$.
Combining: in Case~1, $L_k$ can be transformed into a fan triangulation
by $O(n)$ edge switches, after which every face has depth $0$.
\textbf{Case 2: some $L_k$-face $F$ has a vertex of level $\geq k+1$
in its interior.}
Pick any edge $uv$ of $\partial F$. The $G$-triangle at $uv$ on the
$F$-side has its third vertex $w$ inside $F$, so $w$ is a vertex of
level $\geq k+1$ and in particular $w \notin L_k$. The edge switch
at $uv$ is therefore in Case~(i) of
Proposition~\ref{prop:balanced-descent}: the edge $uv$ is removed from
$L_k$, no new edge is added to $L_k$, and the face $F$ merges with the
$L_k$-face on the opposite side of $uv$ into a single larger face. The
number of inner faces of $L_k$ strictly decreases by $1$.
\subsection*{Combining}
\begin{theorem}
\label{thm:reachability}
Under the stable-labelling convention, every $L_k$ can be transformed
by edge switches into a configuration in which every inner face of
$L_k$ has facial depth $0$, in $O(n)$ edge switches.
\end{theorem}
\begin{proof}[Proof sketch]
Apply Case~2 repeatedly while $L_k$ has any inner face with a
higher-level vertex in its interior. Each application reduces the
number of inner faces of $L_k$ by $1$, so after at most $|L_k| - 2 \leq
n - 2$ such steps we reach one of:
\begin{itemize}
\item A configuration satisfying the hypothesis of Case~1, in which
case Case~1 finishes the job in $O(n)$ flips by reaching a fan
triangulation.
\item A configuration in which $L_k$ has only one inner face -- i.e.,
$L_k$ consists of only its outer cycle, with no chords. The unique
inner face is bounded by all $n$ outer-cycle edges, so it lies in
$\mathcal{B}$ and has depth $0$.
\end{itemize}
Both outcomes leave every face at depth $0$. The total step count is
at most $(n - 2) + O(n) = O(n)$.
\end{proof}
\begin{remark}
Theorem~\ref{thm:reachability} settles the existence question
Question~\ref{q:preprocessing-terminates} affirmatively in the
following sense: \emph{some} sequence of edge switches drives every
face to depth $0$ in $O(n)$ steps. It does not, however, identify the
sequence by a local rule (the leaf-distance algorithm of
Section~\ref{sec:reachability}'s preceding discussion), and in
particular the question of which \emph{rule} produces such a sequence
without backtracking remains open.
\end{remark}
\begin{thebibliography}{9}
\bibitem{sleator-tarjan-thurston}
D.~D. Sleator, R.~E. Tarjan, W.~P. Thurston.
\emph{Rotation distance, triangulations, and hyperbolic geometry}.
Journal of the American Mathematical Society, 1988.
\end{thebibliography}
\end{document}