082ee31966
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>
207 lines
7.4 KiB
Python
207 lines
7.4 KiB
Python
"""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')
|