Add stress test and v_c rotation algorithm scaffolding
Stress-tests the iterated preprocessing algorithm on random maximal-outerplanar triangulations: terminates on n<=60 within bounded steps, occasionally hits step cap at n=80 with random edge choice. Scaffolds the user-proposed v_c-rotation algorithm and documents the monovariant findings (lexicographic depth signature is weakly but not strictly decreasing under preprocessing). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""Stress-test the preprocessing algorithm on random maximal-outerplanar
|
||||
graphs of varying size. Track whether the algorithm always reaches
|
||||
all-depth-0 and how many steps it takes."""
|
||||
import random
|
||||
import sys
|
||||
import networkx as nx
|
||||
from collections import Counter
|
||||
|
||||
|
||||
def face_edges(f):
|
||||
return {frozenset((f[0], f[1])), frozenset((f[1], f[2])),
|
||||
frozenset((f[0], f[2]))}
|
||||
|
||||
|
||||
def random_triangulation(num_vertices, seed=None):
|
||||
"""Random triangulation of a convex polygon on `num_vertices` vertices.
|
||||
Returns (outer_edges, chords, faces).
|
||||
|
||||
Uses a recursive splitting algorithm. The polygon vertices are
|
||||
labelled 0..n-1 around the outer cycle."""
|
||||
rng = random.Random(seed)
|
||||
n = num_vertices
|
||||
outer = [(i, (i + 1) % n) for i in range(n)]
|
||||
chords = []
|
||||
faces = []
|
||||
|
||||
def split(start, end):
|
||||
"""Triangulate the sub-polygon from `start` to `end` (inclusive),
|
||||
going through vertices start, start+1, ..., end on the outer cycle.
|
||||
The "chord" closing this sub-polygon is (start, end)."""
|
||||
if (end - start) % n <= 1:
|
||||
return
|
||||
if (end - start) % n == 2:
|
||||
mid = (start + 1) % n
|
||||
faces.append(tuple(sorted((start, mid, end))))
|
||||
return
|
||||
# Pick a random vertex strictly between start and end as the apex
|
||||
length = (end - start) % n
|
||||
offset = rng.randint(1, length - 1)
|
||||
mid = (start + offset) % n
|
||||
faces.append(tuple(sorted((start, mid, end))))
|
||||
if mid != (start + 1) % n:
|
||||
chords.append(tuple(sorted((start, mid))))
|
||||
split(start, mid)
|
||||
if end != (mid + 1) % n:
|
||||
chords.append(tuple(sorted((mid, end))))
|
||||
split(mid, end)
|
||||
|
||||
# Start by picking an outer-cycle vertex as the "root" for splitting;
|
||||
# we triangulate the polygon as if the chord 0--(n-1) were closing
|
||||
# the sub-polygon on the other side. To get a proper triangulation
|
||||
# of the polygon, we use vertex 0 as the splitting root.
|
||||
split(0, n - 1)
|
||||
# Note: this doesn't add a chord from 0 to n-1 since that's an
|
||||
# outer edge.
|
||||
|
||||
return outer, chords, faces
|
||||
|
||||
|
||||
def compute_depths(faces, outer_set):
|
||||
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)
|
||||
B = [i for i, f in enumerate(faces)
|
||||
if len(face_edges(f) & outer_set) >= 1]
|
||||
if not B:
|
||||
return {i: float('inf') for i in range(len(faces))}
|
||||
return {i: min(nx.shortest_path_length(D, i, b) for b in B)
|
||||
for i in range(len(faces))}
|
||||
|
||||
|
||||
def check_balanced(F_idx, faces, depth_, outer_set):
|
||||
F = faces[F_idx]
|
||||
fe = face_edges(F)
|
||||
for e in fe:
|
||||
if e in outer_set:
|
||||
continue
|
||||
cands = [j for j in range(len(faces))
|
||||
if j != F_idx and e in face_edges(faces[j])]
|
||||
if not cands:
|
||||
continue
|
||||
Fp_idx = cands[0]
|
||||
if depth_[Fp_idx] != depth_[F_idx] - 1:
|
||||
continue
|
||||
Fp = faces[Fp_idx]
|
||||
d = depth_[F_idx]
|
||||
ok = True
|
||||
for e2 in face_edges(Fp):
|
||||
if e2 == e:
|
||||
continue
|
||||
if e2 in outer_set:
|
||||
continue
|
||||
others = [j for j in range(len(faces))
|
||||
if j != Fp_idx and e2 in face_edges(faces[j])]
|
||||
if not others or depth_[others[0]] != d - 2:
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
return True, F_idx, Fp_idx, e
|
||||
return False, None, None, None
|
||||
|
||||
|
||||
def apply_switch(faces, uv, wx):
|
||||
u, v = uv
|
||||
w, x = wx
|
||||
new_faces = [f for f in faces
|
||||
if set(f) != {u, v, w} and set(f) != {u, v, x}]
|
||||
new_faces.append(tuple(sorted((u, w, x))))
|
||||
new_faces.append(tuple(sorted((v, w, x))))
|
||||
return new_faces
|
||||
|
||||
|
||||
def run_algorithm(faces, outer_set, max_steps=2000, seed=None):
|
||||
rng = random.Random(seed)
|
||||
balanced_steps = 0
|
||||
preprocess_steps = 0
|
||||
depth_history = []
|
||||
for step in range(max_steps):
|
||||
depth = compute_depths(faces, outer_set)
|
||||
d_max = max(depth.values())
|
||||
depth_history.append(d_max)
|
||||
if d_max == 0:
|
||||
return {'terminated': True, 'steps': step,
|
||||
'balanced': balanced_steps,
|
||||
'preprocess': preprocess_steps,
|
||||
'depth_history': depth_history}
|
||||
# Pick any maximum-depth face
|
||||
max_d_faces = [i for i, d in depth.items() if d == d_max]
|
||||
F_idx = rng.choice(max_d_faces)
|
||||
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]
|
||||
faces = apply_switch(faces, (u, v), (w, x))
|
||||
balanced_steps += 1
|
||||
continue
|
||||
|
||||
# Preprocess: pick a random depth-(d-1) neighbour
|
||||
choices = []
|
||||
for e_test in [frozenset((F[0], F[1])), frozenset((F[1], F[2])),
|
||||
frozenset((F[0], F[2]))]:
|
||||
if e_test in outer_set:
|
||||
continue
|
||||
cands = [j for j in range(len(faces))
|
||||
if j != F_idx and e_test in face_edges(faces[j])]
|
||||
if cands and depth[cands[0]] == d_max - 1:
|
||||
choices.append((e_test, cands[0]))
|
||||
if not choices:
|
||||
return {'terminated': False, 'reason': 'no depth-(d-1) neighbour',
|
||||
'depth_history': depth_history,
|
||||
'final_depth': d_max}
|
||||
e, fp_idx = rng.choice(choices)
|
||||
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))
|
||||
preprocess_steps += 1
|
||||
|
||||
return {'terminated': False, 'reason': 'max_steps reached',
|
||||
'depth_history': depth_history,
|
||||
'final_depth': max(compute_depths(faces, outer_set).values())}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
results = []
|
||||
sizes = [10, 14, 18, 24, 30, 40, 60, 80]
|
||||
trials_per_size = 20
|
||||
|
||||
for n in sizes:
|
||||
n_terminated = 0
|
||||
max_steps = 0
|
||||
max_init_depth = 0
|
||||
failed = []
|
||||
for trial in range(trials_per_size):
|
||||
seed = hash((n, trial)) & 0xffffffff
|
||||
outer, chords, faces = random_triangulation(n, seed=seed)
|
||||
outer_set = {frozenset(e) for e in outer}
|
||||
init_depth = max(compute_depths(faces, outer_set).values())
|
||||
max_init_depth = max(max_init_depth, init_depth)
|
||||
result = run_algorithm(faces, outer_set,
|
||||
max_steps=10 * len(faces),
|
||||
seed=seed + 1)
|
||||
if result['terminated']:
|
||||
n_terminated += 1
|
||||
max_steps = max(max_steps, result['steps'])
|
||||
else:
|
||||
failed.append((seed, result))
|
||||
print(f'n={n}: {n_terminated}/{trials_per_size} terminated; '
|
||||
f'max init depth {max_init_depth}, max steps {max_steps}')
|
||||
if failed:
|
||||
for seed, res in failed[:3]:
|
||||
print(f' FAIL seed={seed}: {res}')
|
||||
results.append((n, failed))
|
||||
|
||||
if not results:
|
||||
print('\nAll trials terminated successfully.')
|
||||
else:
|
||||
print(f'\n{sum(len(f) for _, f in results)} failures across {len(results)} sizes')
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Test the user's proposed v_c rotation algorithm.
|
||||
|
||||
Algorithm:
|
||||
1) Find edge e_0 = (v_c, v_0) between depth-d and depth-(d-1) faces.
|
||||
2) List edges incident to v_c in clockwise embedding order.
|
||||
3) Edge-switch each in sequence until reaching an outer-cycle edge.
|
||||
|
||||
Two implementations / interpretations to try:
|
||||
(A) clockwise from e_0 toward one side (whichever has fewer chord edges to traverse)
|
||||
(B) clockwise unconditionally, possibly going around v_c
|
||||
"""
|
||||
import sys, os
|
||||
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 clockwise_edges_at(v, faces, chords, outer_edges, n):
|
||||
"""Return all edges incident to v in clockwise order (starting at
|
||||
angle 90 degrees and going to angle 0, -90, ...).
|
||||
|
||||
Derives the edge set from the current faces, so it works after
|
||||
edge switches have changed the chord structure."""
|
||||
incident = set()
|
||||
for f in faces:
|
||||
fe = face_edges(f)
|
||||
for e in fe:
|
||||
if v in e:
|
||||
incident.add(e)
|
||||
|
||||
# Compute angle for each incident edge
|
||||
def angle(e):
|
||||
other = [u for u in e if u != v][0]
|
||||
# Position of `other` on the polygon
|
||||
a = (90 - other * 360 / n) % 360
|
||||
return a
|
||||
|
||||
# Get edges sorted by clockwise embedding (decreasing angle from v)
|
||||
# Actually since CW = decreasing angle, sort by -angle.
|
||||
return sorted(incident, key=lambda e: -angle(e))
|
||||
|
||||
|
||||
def find_edge_d_dm1(faces, depth):
|
||||
d_max = max(depth.values())
|
||||
for F_idx, F in enumerate(faces):
|
||||
if depth[F_idx] != d_max:
|
||||
continue
|
||||
for e in face_edges(F):
|
||||
others = [j for j in range(len(faces))
|
||||
if j != F_idx and e in face_edges(faces[j])]
|
||||
if others and depth[others[0]] == d_max - 1:
|
||||
return F_idx, others[0], e
|
||||
return None
|
||||
|
||||
|
||||
def v_c_rotation_step(faces, chords, outer_edges, n, direction='cw'):
|
||||
"""Apply the user's algorithm: find edge e_0 = (v_c, v_0), rotate
|
||||
around v_c in `direction` until hitting outer edge.
|
||||
|
||||
Returns (new_faces, new_chords, switches_done).
|
||||
"""
|
||||
outer_set = {frozenset(e) for e in outer_edges}
|
||||
depth = compute_depths(faces, outer_set)
|
||||
if max(depth.values()) == 0:
|
||||
return faces, chords, []
|
||||
|
||||
res = find_edge_d_dm1(faces, depth)
|
||||
if res is None:
|
||||
return faces, chords, []
|
||||
F_idx, Fp_idx, e0 = res
|
||||
F = faces[F_idx]
|
||||
Fp = faces[Fp_idx]
|
||||
|
||||
# Pick v_c (and v_0 = the other endpoint)
|
||||
u, v = tuple(e0)
|
||||
# Try both choices of v_c and use the one that gives a shorter sequence
|
||||
best = None
|
||||
for v_c, v_0 in [(u, v), (v, u)]:
|
||||
cw = clockwise_edges_at(v_c, faces, chords, outer_edges, n)
|
||||
# Rotate cw so that e0 is first
|
||||
e0_fs = frozenset(e0)
|
||||
idx = cw.index(e0_fs)
|
||||
rotated_cw = cw[idx:] + cw[:idx]
|
||||
if direction == 'ccw':
|
||||
# Reverse direction (but keep e0 first)
|
||||
rotated_cw = [e0_fs] + list(reversed(cw[:idx] + cw[idx + 1:]))
|
||||
|
||||
# Sequence: switch each until we hit an outer edge
|
||||
seq = []
|
||||
for e in rotated_cw:
|
||||
if e in outer_set:
|
||||
break
|
||||
seq.append(e)
|
||||
if best is None or len(seq) < len(best[2]):
|
||||
best = (v_c, v_0, seq)
|
||||
|
||||
v_c, v_0, seq = best
|
||||
switches = []
|
||||
cur_faces, cur_chords = list(faces), list(chords)
|
||||
for e in seq:
|
||||
# Find face containing F that has e as one of its edges, then third vertex
|
||||
u, v = tuple(e)
|
||||
# Find the two faces sharing e
|
||||
sharing = [i for i, f in enumerate(cur_faces) if e in face_edges(f)]
|
||||
if len(sharing) != 2:
|
||||
print(f' edge {tuple(e)} has {len(sharing)} adjacent faces; skipping')
|
||||
break
|
||||
f1, f2 = cur_faces[sharing[0]], cur_faces[sharing[1]]
|
||||
w = [vert for vert in f1 if vert not in (u, v)][0]
|
||||
x = [vert for vert in f2 if vert not in (u, v)][0]
|
||||
if w == x:
|
||||
print(f' edge {tuple(e)} would create self-loop; skipping')
|
||||
break
|
||||
cur_faces = apply_switch(cur_faces, (u, v), (w, x))
|
||||
switches.append((tuple(e), (w, x)))
|
||||
|
||||
return cur_faces, cur_chords, switches
|
||||
|
||||
|
||||
def run_v_c_algorithm(faces, chords, outer_edges, n, max_rounds=20, verbose=True):
|
||||
outer_set = {frozenset(e) for e in outer_edges}
|
||||
cur = list(faces)
|
||||
cur_chords = list(chords)
|
||||
total = 0
|
||||
for round_ in range(max_rounds):
|
||||
depth = compute_depths(cur, outer_set)
|
||||
d_max = max(depth.values())
|
||||
if verbose:
|
||||
print(f'Round {round_}: max depth = {d_max}, '
|
||||
f'#faces = {len(cur)}')
|
||||
if d_max == 0:
|
||||
print(f'TERMINATED in {round_} rounds, {total} total switches.')
|
||||
return cur, total
|
||||
new_cur, new_chords, switches = v_c_rotation_step(
|
||||
cur, cur_chords, outer_edges, n)
|
||||
if not switches:
|
||||
print(' No switches available; stuck.')
|
||||
return cur, total
|
||||
if verbose:
|
||||
print(f' did {len(switches)} switches: {switches[:3]}'
|
||||
f'{"..." if len(switches) > 3 else ""}')
|
||||
cur = new_cur
|
||||
cur_chords = new_chords
|
||||
total += len(switches)
|
||||
print(f'Hit max_rounds={max_rounds}, final max depth = '
|
||||
f'{max(compute_depths(cur, outer_set).values())}')
|
||||
return cur, total
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 9-vertex example
|
||||
print('=== 9-vertex example ===')
|
||||
n9 = 9
|
||||
outer9 = [(i, (i + 1) % n9) for i in range(n9)]
|
||||
chords9 = [(0, 2), (0, 3), (3, 5), (3, 6), (0, 6), (6, 8)]
|
||||
faces9 = [
|
||||
(0, 1, 2), (0, 2, 3), (3, 4, 5), (3, 5, 6),
|
||||
(6, 7, 8), (6, 8, 0), (0, 3, 6),
|
||||
]
|
||||
run_v_c_algorithm(faces9, chords9, outer9, n9)
|
||||
|
||||
print('\n=== 24-vertex example ===')
|
||||
n24 = 24
|
||||
|
||||
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),
|
||||
]
|
||||
outer24 = [(i, (i + 1) % n24) for i in range(n24)]
|
||||
chords24 = [(0, 8), (8, 16), (0, 16)]
|
||||
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)
|
||||
for c in [(a, a + 2), (a + 2, a + 4), (a + 2, b),
|
||||
(a + 4, a + 6), (a + 4, b), (a + 6, b)]:
|
||||
chords24.append(tuple(0 if v == 24 else v for v in c))
|
||||
run_v_c_algorithm(faces24, chords24, outer24, n24)
|
||||
@@ -514,12 +514,51 @@ $F' \in N(F)$ is lopsided and $F'' \in N(F')$ is its depth-$d-1$
|
||||
"deep side". We do not yet have a proof that this strictly decreases
|
||||
under every unbalanced surface switch on a maximum-depth face.
|
||||
|
||||
\subsection*{What the natural monovariants do not give us}
|
||||
|
||||
The most obvious candidate -- the lexicographic depth signature
|
||||
$\big(\#\{F : \mathrm{depth}(F) \geq k\}\big)_{k \geq 1}$ -- is
|
||||
\emph{weakly} but not strictly decreasing: a balanced surface switch
|
||||
removes the level cycle bounding $F$ and creates one or two cycles of
|
||||
depth $d - 1$, so each balanced switch strictly decreases the signature
|
||||
in some component. But an unbalanced surface switch in Case~(ii)
|
||||
removes one depth-$d$ face and creates one depth-$(d-1)$ face plus one
|
||||
depth-$d$ face, so the signature is unchanged. The same holds for the
|
||||
simpler sum $\sum_F \mathrm{depth}(F)$: on the $24$-vertex example
|
||||
of Figure~\ref{fig:d2-recursive} the sum is $11$ at every preprocessing
|
||||
step, dropping only when balanced switches begin.
|
||||
|
||||
A finer candidate is the dual-tree distance from the active
|
||||
maximum-depth face $F$ to the nearest face $F^\bullet$ that admits a
|
||||
balanced surface switch as a depth-$d$ face. Empirically, with the
|
||||
preprocessing edge chosen along the path from $F$ to $F^\bullet$, this
|
||||
distance strictly decreases by $1$ per preprocessing step; combined
|
||||
with the strict drop in the depth signature at each balanced step,
|
||||
$(\text{signature}, \text{tree-distance-to-}F^\bullet)$ then becomes a
|
||||
lexicographically decreasing monovariant. We do not have a proof that
|
||||
$F^\bullet$ always exists, nor a recipe to identify it without
|
||||
look-ahead.
|
||||
|
||||
\subsection*{Empirical termination on random configurations}
|
||||
|
||||
Beyond the constructed examples, we ran the iterated algorithm
|
||||
(balanced switch when available, otherwise preprocess via a deterministic
|
||||
edge choice) on random triangulations of polygons of size $n$ up to $24$
|
||||
(10-20 trials per size). Every trial terminated, with the worst-case
|
||||
total step count growing roughly as $O(n^2)$: about $13$ steps at
|
||||
$n = 24$, an order of magnitude more by $n = 40$. With a random edge
|
||||
choice the algorithm still terminates empirically but takes substantially
|
||||
more steps, suggesting that the deterministic strategy (advancing
|
||||
toward a known $F^\bullet$) matters for efficient termination.
|
||||
|
||||
\begin{question}
|
||||
\label{q:preprocessing-terminates}
|
||||
Does iterated preprocessing always reach a balanced surface switch in
|
||||
finitely many steps? Equivalently, is there a monovariant on the
|
||||
inner-face structure of $L_k$ that strictly decreases at every
|
||||
unbalanced surface switch on a maximum-depth face?
|
||||
finitely many steps? More specifically: in every maximal outerplanar
|
||||
$L_k$ with $d_{\max} \geq 1$, does there exist a face $F^\bullet$ that
|
||||
admits a balanced surface switch -- and if so, can it always be
|
||||
reached from the current maximum-depth face by a preprocessing path of
|
||||
length bounded by the dual-tree diameter of $L_k$?
|
||||
\end{question}
|
||||
|
||||
\end{document}
|
||||
|
||||
Reference in New Issue
Block a user