From 082ee31966d7d2668266b43104e5590f90a59e8a Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 21 May 2026 13:34:36 -0400 Subject: [PATCH] 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 --- .../experiments/stress_test_termination.py | 206 ++++++++++++++++++ .../experiments/v_c_rotation_algorithm.py | 184 ++++++++++++++++ papers/level_switching/paper.tex | 45 +++- 3 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 papers/level_switching/experiments/stress_test_termination.py create mode 100644 papers/level_switching/experiments/v_c_rotation_algorithm.py diff --git a/papers/level_switching/experiments/stress_test_termination.py b/papers/level_switching/experiments/stress_test_termination.py new file mode 100644 index 0000000..1ca239a --- /dev/null +++ b/papers/level_switching/experiments/stress_test_termination.py @@ -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') diff --git a/papers/level_switching/experiments/v_c_rotation_algorithm.py b/papers/level_switching/experiments/v_c_rotation_algorithm.py new file mode 100644 index 0000000..3794af8 --- /dev/null +++ b/papers/level_switching/experiments/v_c_rotation_algorithm.py @@ -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) diff --git a/papers/level_switching/paper.tex b/papers/level_switching/paper.tex index ff8186d..367457b 100644 --- a/papers/level_switching/paper.tex +++ b/papers/level_switching/paper.tex @@ -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}